diff --git a/Cargo.lock b/Cargo.lock index 0d509f55ce2e..204a0113274d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,6 +492,37 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-build" +version = "0.0.0" +dependencies = [ + "expect-test", + "fluent-syntax", + "proc-macro2", + "quote", + "test-fixture", + "test-utils", +] + +[[package]] +name = "fluent-files" +version = "0.0.0" +dependencies = [ + "expect-test", + "fluent-build", + "test-fixture", + "test-utils", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "foldhash" version = "0.1.4" @@ -967,6 +998,7 @@ dependencies = [ "cov-mark", "either", "expect-test", + "fluent-files", "hir", "ide-db", "itertools 0.14.0", diff --git a/Cargo.toml b/Cargo.toml index d9c57b4e44da..273dc3961359 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ debug = 2 # local crates base-db = { path = "./crates/base-db", version = "0.0.0" } cfg = { path = "./crates/cfg", version = "0.0.0", features = ["tt"] } +fluent-build = { path = "./crates/fluent-build", version = "0.0.0" } +fluent-files = { path = "./crates/fluent-files", version = "0.0.0" } hir = { path = "./crates/hir", version = "0.0.0" } hir-def = { path = "./crates/hir-def", version = "0.0.0" } hir-expand = { path = "./crates/hir-expand", version = "0.0.0" } @@ -113,6 +115,7 @@ dissimilar = "1.0.10" dot = "0.1.4" either = "1.15.0" expect-test = "1.5.1" +fluent-syntax = "0.11.1" indexmap = { version = "2.8.0", features = ["serde"] } itertools = "0.14.0" libc = "0.2.171" @@ -127,9 +130,11 @@ object = { version = "0.36.7", default-features = false, features = [ "macho", "pe", ] } +proc-macro2 = "1" process-wrap = { version = "8.2.0", features = ["std"] } pulldown-cmark-to-cmark = "10.0.4" pulldown-cmark = { version = "0.9.6", default-features = false } +quote = "1" rayon = "1.10.0" salsa = "0.19" rustc-hash = "2.1.1" diff --git a/crates/fluent-build/Cargo.toml b/crates/fluent-build/Cargo.toml new file mode 100644 index 000000000000..bd9788bbcf0d --- /dev/null +++ b/crates/fluent-build/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "fluent-build" +version = "0.0.0" +repository.workspace = true +description = "Utils for build-time support for fluent files" + +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lib] + +[dependencies] +fluent-syntax.workspace = true +proc-macro2.workspace = true +quote.workspace = true + +# local deps + +[dev-dependencies] +expect-test = "1.5.1" + +# local deps +test-utils.workspace = true +test-fixture.workspace = true + +[lints] +workspace = true + diff --git a/crates/fluent-build/src/error.rs b/crates/fluent-build/src/error.rs new file mode 100644 index 000000000000..d74aa912f8f4 --- /dev/null +++ b/crates/fluent-build/src/error.rs @@ -0,0 +1,24 @@ +use std::io; + +pub type FluentParserError = + (fluent_syntax::ast::Resource<String>, Vec<fluent_syntax::parser::ParserError>); + +#[derive(Debug)] +pub enum FluentBuildError { + Io(io::Error), + Parser(FluentParserError), + /// Panic errors + Custom(String), +} + +impl From<io::Error> for FluentBuildError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From<FluentParserError> for FluentBuildError { + fn from(value: FluentParserError) -> Self { + Self::Parser(value) + } +} diff --git a/crates/fluent-build/src/lib.rs b/crates/fluent-build/src/lib.rs new file mode 100644 index 000000000000..e4f69131bbe8 --- /dev/null +++ b/crates/fluent-build/src/lib.rs @@ -0,0 +1,189 @@ +pub mod error; + +use std::path::PathBuf; +use std::{env, fs, io}; + +use error::FluentBuildError; +use fluent_syntax::ast; +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +fn get_cargo_manifest() -> String { + env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| panic!("Running on non cargo environment")) +} + +pub fn build_fluent_src() -> io::Result<()> { + let mut output = "//! This file is generated by `fluent-build`".to_owned(); + + for entry in fs::read_dir("src")? { + let entry = entry?.path(); + + if !entry.extension().is_none_or(|e| e.to_string_lossy().to_string() == "ftl") { + continue; + } + + let output_mod = entry + .file_stem() + .unwrap() + .to_string_lossy() + .to_string() + .replace("-", "_") + .replace(".", "_"); + let output_file = + entry.with_extension("rs").file_name().unwrap().to_string_lossy().to_string(); + + output += + &format!("\n#[path = \"{output_file}\"]\nmod {output_mod};\npub use {output_mod}::*;"); + + build_fluent_file(entry).map_err(|e| match e { + FluentBuildError::Io(error) => return error, + FluentBuildError::Parser(_) => todo!("parse error"), + FluentBuildError::Custom(c) => panic!("{c}"), + })?; + } + + let absolute_path = PathBuf::from(get_cargo_manifest()).join("src/generated.rs"); + fs::write(absolute_path, output)?; + + Ok(()) +} + +pub fn build_fluent_file(relative_path: PathBuf) -> Result<(), FluentBuildError> { + let absolute_path = PathBuf::from(get_cargo_manifest()).join(relative_path); + let resource = fs::read_to_string(&absolute_path) + .map_err(|e| FluentBuildError::Custom(format!("{absolute_path:#?}: {e}")))?; + + for esc in ["\\n", "\\\"", "\\'"] { + if resource.contains(esc) { + return Err(FluentBuildError::Custom(format!( + "invalid escape `{esc}` in Fluent resource.\nFluent does not interpret these escape sequences (<https://projectfluent.org/fluent/guide/special.html>)" + ))); + } + } + + // Use parse_runtime because we don't need debug info + let resource = fluent_syntax::parser::parse_runtime(resource)?.body; + + let entries = resource + .into_iter() + .filter_map(|entry| { + let (name, value, attributes, comment) = match entry { + ast::Entry::Message(ast::Message { + id, + value: Some(value), + attributes, + comment, + }) => (id.name, value, attributes, comment), + ast::Entry::Term(ast::Term { id, value, attributes, comment }) => { + (id.name, value, attributes, comment) + } + _ => return None, + }; + + let children = attributes + .into_iter() + .map(|ast::Attribute { id, value }| { + entry_to_code(format!("{name}-{}", id.name), value, None) + }) + .collect::<Vec<_>>(); + + let mut code = entry_to_code(name, value, comment).to_string(); + + for child in children { + code.push('\n'); + code += &child.to_string(); + } + + Some(code) + }) + .collect::<Vec<_>>(); + + let code = entries.join("\n"); + let result_file = absolute_path.with_extension("rs"); + + fs::write(result_file, code)?; + + Ok(()) +} + +fn entry_to_code( + name: String, + pattern: ast::Pattern<String>, + comment: Option<ast::Comment<String>>, +) -> TokenStream { + let name = format_ident!("{}", name.replace("-", "_")); + + let is_dynamic = + pattern.elements.iter().any(|elm| matches!(elm, ast::PatternElement::Placeable { .. })); + + let comment = comment + .map(|comment| { + let content = comment.content.into_iter().map(|c| quote! {#[doc = #c]}); + quote! {#(#content)*} + }) + .unwrap_or(quote! {}); + + if is_dynamic { + let (args, format_str) = pattern.elements.into_iter().fold( + (Vec::new(), String::new()), + |(mut args, mut body), elm| { + resolve_pattern_element(&mut args, &mut body, elm); + (args, body) + }, + ); + + quote! { + #comment + pub fn #name(#(#args),*) -> String { + format!(#format_str) + } + } + } else { + // Collect all static elements + let body = pattern + .elements + .into_iter() + .filter_map(|elm| match elm { + ast::PatternElement::TextElement { value } => Some(value), + _ => None, + }) + .collect::<String>(); + + quote! { + #comment + #[allow(non_upper_case_globals)] + pub const #name: &str = #body; + } + } +} + +fn resolve_pattern_element( + args: &mut Vec<TokenStream>, + body: &mut String, + elm: ast::PatternElement<String>, +) { + match elm { + ast::PatternElement::TextElement { value } => { + *body += &value; + } + ast::PatternElement::Placeable { expression } => match expression { + ast::Expression::Select { .. } => todo!(), + ast::Expression::Inline(inline_expression) => match inline_expression { + ast::InlineExpression::StringLiteral { .. } => todo!(), + ast::InlineExpression::NumberLiteral { .. } => todo!(), + ast::InlineExpression::FunctionReference { .. } => todo!(), + ast::InlineExpression::MessageReference { .. } => todo!(), + ast::InlineExpression::TermReference { .. } => todo!(), + ast::InlineExpression::VariableReference { id } => { + let arg = format_ident!("{}", id.name); + args.push(quote! { #arg : impl ::std::fmt::Display }); + + body.push('{'); + body.push_str(&id.name); + body.push('}'); + } + ast::InlineExpression::Placeable { .. } => todo!("Expression inception"), + }, + }, + } +} diff --git a/crates/fluent-files/.gitignore b/crates/fluent-files/.gitignore new file mode 100644 index 000000000000..38cf000dd543 --- /dev/null +++ b/crates/fluent-files/.gitignore @@ -0,0 +1,2 @@ +src/*.rs +!src/lib.rs diff --git a/crates/fluent-files/Cargo.toml b/crates/fluent-files/Cargo.toml new file mode 100644 index 000000000000..232eb470dbfa --- /dev/null +++ b/crates/fluent-files/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "fluent-files" +version = "0.0.0" +repository.workspace = true +description = "Collection of translated files" + +authors.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lib] + +[build-dependencies] +# local deps +fluent-build.workspace = true + +[dev-dependencies] +expect-test = "1.5.1" + +# local deps +test-utils.workspace = true +test-fixture.workspace = true + +[lints] +workspace = true + + diff --git a/crates/fluent-files/build.rs b/crates/fluent-files/build.rs new file mode 100644 index 000000000000..966bc4492532 --- /dev/null +++ b/crates/fluent-files/build.rs @@ -0,0 +1,7 @@ +fn main() { + println!("cargo::rerun-if-changed=src/"); + let result = fluent_build::build_fluent_src(); + if let Err(r) = result { + panic!("{r:#?}") + } +} diff --git a/crates/fluent-files/src/await_outside_of_async.ftl b/crates/fluent-files/src/await_outside_of_async.ftl new file mode 100644 index 000000000000..e42c4d821711 --- /dev/null +++ b/crates/fluent-files/src/await_outside_of_async.ftl @@ -0,0 +1,2 @@ +await-outside-of-async-code = E0728 +await-outside-of-async = `await` is used inside {$ctx}, which is not an `async` context diff --git a/crates/fluent-files/src/lib.rs b/crates/fluent-files/src/lib.rs new file mode 100644 index 000000000000..14ed671113d3 --- /dev/null +++ b/crates/fluent-files/src/lib.rs @@ -0,0 +1,2 @@ +mod generated; +pub use generated::*; diff --git a/crates/ide-diagnostics/Cargo.toml b/crates/ide-diagnostics/Cargo.toml index 96be51e1b266..dc54f1824939 100644 --- a/crates/ide-diagnostics/Cargo.toml +++ b/crates/ide-diagnostics/Cargo.toml @@ -19,6 +19,7 @@ serde_json.workspace = true tracing.workspace = true # local deps +fluent-files.workspace = true stdx.workspace = true syntax.workspace = true cfg.workspace = true diff --git a/crates/ide-diagnostics/src/handlers/await_outside_of_async.rs b/crates/ide-diagnostics/src/handlers/await_outside_of_async.rs index 92ca7a74184f..90be9b68a694 100644 --- a/crates/ide-diagnostics/src/handlers/await_outside_of_async.rs +++ b/crates/ide-diagnostics/src/handlers/await_outside_of_async.rs @@ -10,8 +10,8 @@ pub(crate) fn await_outside_of_async( let display_range = adjusted_display_range(ctx, d.node, &|node| Some(node.await_token()?.text_range())); Diagnostic::new( - crate::DiagnosticCode::RustcHardError("E0728"), - format!("`await` is used inside {}, which is not an `async` context", d.location), + crate::DiagnosticCode::RustcHardError(fluent_files::await_outside_of_async_code), + fluent_files::await_outside_of_async(&d.location), display_range, ) }