Repository: zng-ui/zng (language: rust)

Issue:
Implement scrapping of standalone notes for a single localization file
Right now the code below will generate a copy of "### Standalone Note" in both "foo.ftl" and "bar.ftl" templates, this is documented, but it is not very useful.

```rust
fn foo() {
    let _ = l10n!("foo/id", "foo");
}

fn bar() {
   // l10n-### Standalone Note
    let _ = l10n!("bar/id", "bar");
}
```

Implement `// l10n-{file}-###` syntax to fix this, `// l10n-*-###` copies the comment to all files, `// l10n-###` copies the comment to the *unamed* template, that is `template.ftl`.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a052af0c7..830cc82be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,14 @@
 * Deprecate `NilAnimationObserver`, use `()` now.
 * Add `ForceAnimationController` to force important animations to run when animations are disabled on the system.
 * Fix crash handler passing app name twice as command line arguments.
+* **Breaking** Implemented new syntax for the localization scrapper to separate standalone notes per file:
+    - `// l10n-file-### {note}` only adds the note to the `template/file.ftl`.
+    - `// l10n-*-### {note}` adds the note to all files that match the glob pattern (`template/*.ftl`).
+    - The old syntax `// l10n-### {note}` is still supported, but now it is equivalent to `// l10n--###` that
+      matches the default `template.ftl` file only.
+    - Note that this is only a breaking change for dependents of `zng-l10n-scraper`. Normal users (cargo install)
+      must update the tool to scrap using the new syntax, comments with the new file pattern matcher are ignored
+      by older scrappers.
 
 # 0.5.0
 
diff --git a/crates/zng-ext-l10n/src/lib.rs b/crates/zng-ext-l10n/src/lib.rs
index 95b5b29d5..7265e0c46 100644
--- a/crates/zng-ext-l10n/src/lib.rs
+++ b/crates/zng-ext-l10n/src/lib.rs
@@ -115,9 +115,11 @@ impl AppExtension for L10nManager {
 /// matches this macro name and the two first input literals, avoid renaming this macro to support scrapping, otherwise you will
 /// have to declare the template file manually.
 ///
-/// The scrapper also has some support for comments, if the previous code line from a [`l10n!`] call is a comment starting with
+/// The scrapper can also scrap comments, if the previous code line from a [`l10n!`] call is a comment starting with
 /// prefix `l10n-# ` the text the follows is collected, same for a comment in the same line of the [`l10n!`] call. Sections
-/// can be declared using `l10n-##` and standalone notes can be added to the top of the template file from anywhere using `l10n-###`.
+/// can be declared using `l10n-##` and standalone notes can be added to the top of the template file from anywhere using
+/// `l10n-{file_pattern}-###`, file pattern can be omitted, `l10n-###` is equivalent to `l10n--###` that matches the localization
+/// template used when no file is specified.
 ///
 /// ```
 /// # use zng_ext_l10n::*;
diff --git a/crates/zng-l10n-scraper/src/main.rs b/crates/zng-l10n-scraper/src/main.rs
index 4fac38ef4..56d092062 100644
--- a/crates/zng-l10n-scraper/src/main.rs
+++ b/crates/zng-l10n-scraper/src/main.rs
@@ -21,7 +21,7 @@ struct Args {
     #[arg(short, long)]
     output: PathBuf,
 
-    /// Custom macro names, comma separated
+    /// Custom l10n macro names, comma separated
     #[arg(short, long, default_value = "")]
     macros: String,
 
diff --git a/crates/zng-l10n-scraper/src/scraper.rs b/crates/zng-l10n-scraper/src/scraper.rs
index ebf77f44b..d4ef24db7 100644
--- a/crates/zng-l10n-scraper/src/scraper.rs
+++ b/crates/zng-l10n-scraper/src/scraper.rs
@@ -37,7 +37,7 @@ fn scrape_files(buf: &mut Vec<PathBuf>, custom_macro_names: &[&str]) -> io::Resu
     buf.par_drain(..).map(|f| scrape_file(f, custom_macro_names)).reduce(
         || {
             Ok(FluentTemplate {
-                notes: String::new(),
+                notes: vec![],
                 entries: vec![],
             })
         },
@@ -62,8 +62,7 @@ fn scrape_file(file: PathBuf, custom_macro_names: &[&str]) -> io::Result<FluentT
         s = &s[i..];
     }
 
-    let mut l10n_notes = String::new();
-    let mut last_note_line = 0;
+    let mut l10n_notes = vec![];
     let mut l10n_section = Arc::new(String::new());
 
     let mut output: Vec<FluentEntry> = vec![];
@@ -97,36 +96,39 @@ fn scrape_file(file: PathBuf, custom_macro_names: &[&str]) -> io::Result<FluentT
             Expect::CommentOrMacroName => match token.kind {
                 rustc_lexer::TokenKind::LineComment => {
                     let c = s[..token.len].trim().trim_start_matches('/').trim_start();
-                    if let Some(c) = c.strip_prefix("l10n-###") {
-                        if !l10n_notes.is_empty() && (line - last_note_line) > 1 {
-                            l10n_notes.push('\n');
-                        }
-                        l10n_notes.push_str(c.trim());
-                        l10n_notes.push('\n');
-
-                        last_note_line = line;
-                    } else if let Some(c) = c.strip_prefix("l10n-##") {
-                        l10n_section = Arc::new(c.trim().to_owned())
-                    } else if let Some(c) = c.strip_prefix("l10n-#") {
-                        let c = c.trim_start();
-
-                        // comment still on the last already inserted entry lines
-                        if last_entry_line == line && !output.is_empty() {
-                            let last = output.len() - 1;
-                            if !output[last].comments.is_empty() {
-                                output[last].comments.push('\n');
-                            }
-                            output[last].comments.push_str(c);
-                        } else {
-                            if !entry.comments.is_empty() {
-                                if (line - last_comment_line) > 1 {
-                                    entry.comments.clear();
-                                } else {
-                                    entry.comments.push('\n');
+
+                    if let Some(c) = c.strip_prefix("l10n-") {
+                        if let Some(i) = c.find("###") {
+                            let file_name = c[..i].trim_end_matches('-');
+                            let c = &c[i + "###".len()..];
+
+                            l10n_notes.push(FluentNote {
+                                file: file_name.to_owned(),
+                                note: c.trim().to_owned(),
+                            });
+                        } else if let Some(c) = c.strip_prefix("##") {
+                            l10n_section = Arc::new(c.trim().to_owned())
+                        } else if let Some(c) = c.strip_prefix('#') {
+                            let c = c.trim_start();
+
+                            // comment still on the last already inserted entry lines
+                            if last_entry_line == line && !output.is_empty() {
+                                let last = output.len() - 1;
+                                if !output[last].comments.is_empty() {
+                                    output[last].comments.push('\n');
                                 }
+                                output[last].comments.push_str(c);
+                            } else {
+                                if !entry.comments.is_empty() {
+                                    if (line - last_comment_line) > 1 {
+                                        entry.comments.clear();
+                                    } else {
+                                        entry.comments.push('\n');
+                                    }
+                                }
+                                entry.comments.push_str(c);
+                                last_comment_line = line;
                             }
-                            entry.comments.push_str(c);
-                            last_comment_line = line;
                         }
                     }
                 }
@@ -185,24 +187,26 @@ fn scrape_file(file: PathBuf, custom_macro_names: &[&str]) -> io::Result<FluentT
                     // comment inside macro
 
                     let c = s[..token.len].trim().trim_start_matches('/').trim_start();
-                    if let Some(c) = c.strip_prefix("l10n-###") {
-                        if !l10n_notes.is_empty() && (line - last_note_line) > 1 {
-                            l10n_notes.push('\n');
-                        }
-                        l10n_notes.push_str(c.trim());
-                        l10n_notes.push('\n');
+                    if let Some(c) = c.strip_prefix("l10n-") {
+                        if let Some(i) = c.find("###") {
+                            let file_name = c[..i].trim_end_matches('-');
+                            let c = &c[i + "###".len()..];
+
+                            l10n_notes.push(FluentNote {
+                                file: file_name.to_owned(),
+                                note: c.trim().to_owned(),
+                            });
+                        } else if let Some(c) = c.strip_prefix("##") {
+                            l10n_section = Arc::new(c.trim().to_owned())
+                        } else if let Some(c) = c.strip_prefix('#') {
+                            let c = c.trim_start();
 
-                        last_note_line = line;
-                    } else if let Some(c) = c.strip_prefix("l10n-##") {
-                        l10n_section = Arc::new(c.trim().to_owned())
-                    } else if let Some(c) = c.strip_prefix("l10n-#") {
-                        let c = c.trim_start();
-
-                        if !entry.comments.is_empty() {
-                            entry.comments.push('\n');
+                            if !entry.comments.is_empty() {
+                                entry.comments.push('\n');
+                            }
+                            entry.comments.push_str(c);
+                            last_comment_line = line;
                         }
-                        entry.comments.push_str(c);
-                        last_comment_line = line;
                     }
                 }
                 rustc_lexer::TokenKind::Whitespace => {}
@@ -219,24 +223,26 @@ fn scrape_file(file: PathBuf, custom_macro_names: &[&str]) -> io::Result<FluentT
                     // comment inside macro
 
                     let c = s[..token.len].trim().trim_start_matches('/').trim_start();
-                    if let Some(c) = c.strip_prefix("l10n-###") {
-                        if !l10n_notes.is_empty() && (line - last_note_line) > 1 {
-                            l10n_notes.push('\n');
-                        }
-                        l10n_notes.push_str(c.trim());
-                        l10n_notes.push('\n');
-
-                        last_note_line = line;
-                    } else if let Some(c) = c.strip_prefix("l10n-##") {
-                        l10n_section = Arc::new(c.trim().to_owned())
-                    } else if let Some(c) = c.strip_prefix("l10n-#") {
-                        let c = c.trim_start();
+                    if let Some(c) = c.strip_prefix("l10n-") {
+                        if let Some(i) = c.find("###") {
+                            let file_name = c[..i].trim_end_matches('-');
+                            let c = &c[i + "###".len()..];
+
+                            l10n_notes.push(FluentNote {
+                                file: file_name.to_owned(),
+                                note: c.trim().to_owned(),
+                            });
+                        } else if let Some(c) = c.strip_prefix("##") {
+                            l10n_section = Arc::new(c.trim().to_owned())
+                        } else if let Some(c) = c.strip_prefix('#') {
+                            let c = c.trim_start();
 
-                        if !entry.comments.is_empty() {
-                            entry.comments.push('\n');
+                            if !entry.comments.is_empty() {
+                                entry.comments.push('\n');
+                            }
+                            entry.comments.push_str(c);
+                            last_comment_line = line;
                         }
-                        entry.comments.push_str(c);
-                        last_comment_line = line;
                     }
                 }
                 rustc_lexer::TokenKind::Whitespace => {}
@@ -284,24 +290,26 @@ fn scrape_file(file: PathBuf, custom_macro_names: &[&str]) -> io::Result<FluentT
                     // comment inside macro
 
                     let c = s[..token.len].trim().trim_start_matches('/').trim_start();
-                    if let Some(c) = c.strip_prefix("l10n-###") {
-                        if !l10n_notes.is_empty() && (line - last_note_line) > 1 {
-                            l10n_notes.push('\n');
-                        }
-                        l10n_notes.push_str(c.trim());
-                        l10n_notes.push('\n');
+                    if let Some(c) = c.strip_prefix("l10n-") {
+                        if let Some(i) = c.find("###") {
+                            let file_name = c[..i].trim_end_matches('-');
+                            let c = &c[i + "###".len()..];
+
+                            l10n_notes.push(FluentNote {
+                                file: file_name.to_owned(),
+                                note: c.trim().to_owned(),
+                            });
+                        } else if let Some(c) = c.strip_prefix("##") {
+                            l10n_section = Arc::new(c.trim().to_owned())
+                        } else if let Some(c) = c.strip_prefix('#') {
+                            let c = c.trim_start();
 
-                        last_note_line = line;
-                    } else if let Some(c) = c.strip_prefix("l10n-##") {
-                        l10n_section = Arc::new(c.trim().to_owned())
-                    } else if let Some(c) = c.strip_prefix("l10n-#") {
-                        let c = c.trim_start();
-
-                        if !entry.comments.is_empty() {
-                            entry.comments.push('\n');
+                            if !entry.comments.is_empty() {
+                                entry.comments.push('\n');
+                            }
+                            entry.comments.push_str(c);
+                            last_comment_line = line;
                         }
-                        entry.comments.push_str(c);
-                        last_comment_line = line;
                     }
                 }
                 rustc_lexer::TokenKind::Whitespace => {}
@@ -323,6 +331,16 @@ fn scrape_file(file: PathBuf, custom_macro_names: &[&str]) -> io::Result<FluentT
     })
 }
 
+/// Represents a standalone note, declared using `// l10n-{file}-### {note}` or `l10n-### {note}`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct FluentNote {
+    /// Localization file name pattern where the note must be added.
+    pub file: String,
+
+    /// The note.
+    pub note: String,
+}
+
 /// Represents one call to `l10n!` or similar macro in a Rust code file.
 ///
 /// Use [`scrape_fluent_text`] to collect entries.
@@ -350,8 +368,8 @@ pub struct FluentEntry {
 /// Use [`scrape_fluent_text`] to collect entries.
 #[derive(Default)]
 pub struct FluentTemplate {
-    /// Scraped note comments `// l10n-### `.
-    pub notes: String,
+    /// Scraped standalone note comments.
+    pub notes: Vec<FluentNote>,
 
     /// Scraped entries.
     ///
@@ -361,12 +379,7 @@ pub struct FluentTemplate {
 impl FluentTemplate {
     /// Append `other` to `self`.
     pub fn extend(&mut self, other: Self) {
-        if self.notes.is_empty() {
-            self.notes = other.notes;
-        } else if !other.notes.is_empty() {
-            self.notes.push('\n');
-            self.notes.push_str(&other.notes);
-        }
+        self.notes.extend(other.notes);
         self.entries.extend(other.entries);
     }
 
@@ -489,8 +502,19 @@ impl FluentTemplate {
                 let mut out = select_l10n_file(&entry.file)?;
 
                 if !self.notes.is_empty() {
-                    for line in self.notes.lines() {
-                        out.write_fmt(format_args!("### {line}\n"))?;
+                    for n in &self.notes {
+                        let matches_file = if n.file.contains('*') {
+                            match glob::Pattern::new(&n.file) {
+                                Ok(b) => b.matches(&entry.file),
+                                Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidInput, e)),
+                            }
+                        } else {
+                            n.file == entry.file
+                        };
+
+                        if matches_file {
+                            out.write_fmt(format_args!("### {}\n", n.note))?;
+                        }
                     }
                     out.write_all("\n".as_bytes())?;
                 }
diff --git a/crates/zng/src/l10n.rs b/crates/zng/src/l10n.rs
index b3cfd7ca4..271434863 100644
--- a/crates/zng/src/l10n.rs
+++ b/crates/zng/src/l10n.rs
@@ -68,7 +68,7 @@
 //! use zng::prelude::*;
 //! # let _scope = APP.defaults();
 //!
-//! // l10n-### This standalone comment is added to all scraped template files.
+//! // l10n-### This standalone comment is added to the scraped template file.
 //!
 //! let click_count = var(0u32);
 //! # let _ =
diff --git a/examples/localize.rs b/examples/localize.rs
index 0f646dc3a..db2236d90 100644
--- a/examples/localize.rs
+++ b/examples/localize.rs
@@ -15,8 +15,10 @@ use zng::{
     widget::node::presenter,
 };
 
-// l10n-### Localize Example
-// l10n-### This standalone comment is added to all scraped template files.
+// l10n-*-### Localize Example
+// l10n-*-### This standalone comment is added to all scraped template files.
+// l10n-### This standalone comment is only added to the default file.
+// l10n-msg-### This standalone comment is only added to the `msg` file.
 
 // Run this command to scrap template:
 // cargo run -p zng-l10n-scraper -- -i"examples/localize*" -o"examples/res/localize"
@@ -167,4 +169,4 @@ fn locale_menu() -> impl UiNode {
     }
 }
 
-// l10n-### Another standalone comment, also added to the top of all template files.
+// l10n--### Another standalone comment, also added to the top of the template file.
diff --git a/examples/res/localize/pseudo-mirr.ftl b/examples/res/localize/pseudo-mirr.ftl
index c70f08bca..952c7d6a8 100644
--- a/examples/res/localize/pseudo-mirr.ftl
+++ b/examples/res/localize/pseudo-mirr.ftl
@@ -1,7 +1,7 @@
 ### Localize Example
 ### This standalone comment is added to all scraped template files.
-### 
-### Another standalone comment, also added to the top of all template files.
+### This standalone comment is only added to the default file.
+### Another standalone comment, also added to the top of the template file.
 
 # icon:
 #     first syllable of "Localize"
diff --git a/examples/res/localize/pseudo-mirr/msg.ftl b/examples/res/localize/pseudo-mirr/msg.ftl
index 1ebe9d2cf..ce5ec7937 100644
--- a/examples/res/localize/pseudo-mirr/msg.ftl
+++ b/examples/res/localize/pseudo-mirr/msg.ftl
@@ -1,6 +1,5 @@
 ### Localize Example
 ### This standalone comment is added to all scraped template files.
-### 
-### Another standalone comment, also added to the top of all template files.
+### This standalone comment is only added to the `msg` file.
 
 click-count = Ↄʅıɔʞǝp {$n} ʇıɯǝs
diff --git a/examples/res/localize/pseudo-wide.ftl b/examples/res/localize/pseudo-wide.ftl
index 5aeef6688..40d4f56ab 100644
--- a/examples/res/localize/pseudo-wide.ftl
+++ b/examples/res/localize/pseudo-wide.ftl
@@ -1,7 +1,7 @@
 ### Localize Example
 ### This standalone comment is added to all scraped template files.
-### 
-### Another standalone comment, also added to the top of all template files.
+### This standalone comment is only added to the default file.
+### Another standalone comment, also added to the top of the template file.
 
 # icon:
 #     first syllable of "Localize"
diff --git a/examples/res/localize/pseudo-wide/msg.ftl b/examples/res/localize/pseudo-wide/msg.ftl
index df5f57eb2..992406c17 100644
--- a/examples/res/localize/pseudo-wide/msg.ftl
+++ b/examples/res/localize/pseudo-wide/msg.ftl
@@ -1,6 +1,5 @@
 ### Localize Example
 ### This standalone comment is added to all scraped template files.
-### 
-### Another standalone comment, also added to the top of all template files.
+### This standalone comment is only added to the `msg` file.
 
 click-count = Ƈŀiƈķeeḓ {$n} ŧiḿeeş
diff --git a/examples/res/localize/pseudo.ftl b/examples/res/localize/pseudo.ftl
index a10c2982a..2faca8463 100644
--- a/examples/res/localize/pseudo.ftl
+++ b/examples/res/localize/pseudo.ftl
@@ -1,7 +1,7 @@
 ### Localize Example
 ### This standalone comment is added to all scraped template files.
-### 
-### Another standalone comment, also added to the top of all template files.
+### This standalone comment is only added to the default file.
+### Another standalone comment, also added to the top of the template file.
 
 # icon:
 #     first syllable of "Localize"
diff --git a/examples/res/localize/pseudo/msg.ftl b/examples/res/localize/pseudo/msg.ftl
index 13770df54..a1ca683e5 100644
--- a/examples/res/localize/pseudo/msg.ftl
+++ b/examples/res/localize/pseudo/msg.ftl
@@ -1,6 +1,5 @@
 ### Localize Example
 ### This standalone comment is added to all scraped template files.
-### 
-### Another standalone comment, also added to the top of all template files.
+### This standalone comment is only added to the `msg` file.
 
 click-count = Ƈŀiƈķeḓ {$n} ŧiḿeş
diff --git a/examples/res/localize/template.ftl b/examples/res/localize/template.ftl
index c227ec164..edaa60d0d 100644
--- a/examples/res/localize/template.ftl
+++ b/examples/res/localize/template.ftl
@@ -1,7 +1,7 @@
 ### Localize Example
 ### This standalone comment is added to all scraped template files.
-### 
-### Another standalone comment, also added to the top of all template files.
+### This standalone comment is only added to the default file.
+### Another standalone comment, also added to the top of the template file.
 
 # icon:
 #     first syllable of "Localize"
diff --git a/examples/res/localize/template/msg.ftl b/examples/res/localize/template/msg.ftl
index a91c2b2e1..f2236ef63 100644
--- a/examples/res/localize/template/msg.ftl
+++ b/examples/res/localize/template/msg.ftl
@@ -1,6 +1,5 @@
 ### Localize Example
 ### This standalone comment is added to all scraped template files.
-### 
-### Another standalone comment, also added to the top of all template files.
+### This standalone comment is only added to the `msg` file.
 
 click-count = Clicked {$n} times


Test patch: