cargo_zng/
l10n.rs

1//! Localization text scrapper.
2//!
3//! See the [`l10n!`] documentation for more details.
4//!
5//! [`l10n!`]: https://zng-ui.github.io/doc/zng/l10n/macro.l10n.html#scrap-template
6
7use std::{
8    cmp::Ordering,
9    fmt::Write as _,
10    fs, io,
11    path::{Path, PathBuf},
12};
13
14use clap::*;
15
16use crate::util;
17
18mod pseudo;
19mod scraper;
20
21#[derive(Args, Debug)]
22pub struct L10nArgs {
23    /// Rust files glob or directory
24    #[arg(short, long, default_value = "")]
25    input: String,
26
27    /// L10n resources dir
28    #[arg(short, long, default_value = "")]
29    output: String,
30
31    /// Package to scrap and copy dependencies
32    ///
33    /// If set the --input and --output default is src/**.rs and l10n/
34    #[arg(short, long, default_value = "")]
35    package: String,
36
37    /// Path to Cargo.toml of crate to scrap and copy dependencies
38    ///
39    /// If set the --input and --output default to src/**.rs and l10n/
40    #[arg(long, default_value = "")]
41    manifest_path: String,
42
43    /// Don't copy dependencies localization
44    ///
45    /// Use with --package or --manifest-path to not copy {dep-pkg}/l10n/*.ftl files
46    #[arg(long, action)]
47    no_deps: bool,
48
49    /// Don't scrap `#.#.#-local` dependencies
50    ///
51    /// Use with --package or --manifest-path to not scrap local dependencies.
52    #[arg(long, action)]
53    no_local: bool,
54
55    /// Don't scrap the target package.
56    ///
57    /// Use with --package or --manifest-path to only scrap dependencies.
58    #[arg(long, action)]
59    no_pkg: bool,
60
61    /// Remove all previously copied dependency localization files.
62    #[arg(long, action)]
63    clean_deps: bool,
64
65    /// Remove all previously scraped resources before scraping.
66    #[arg(long, action)]
67    clean_template: bool,
68
69    /// Same as --clean-deps --clean-template
70    #[arg(long, action)]
71    clean: bool,
72
73    /// Custom l10n macro names, comma separated
74    #[arg(short, long, default_value = "")]
75    macros: String,
76
77    /// Generate pseudo locale from dir/lang
78    ///
79    /// EXAMPLE
80    ///
81    /// "l10n/en" generates pseudo from "l10n/en.ftl" and "l10n/en/*.ftl"
82    #[arg(long, default_value = "")]
83    pseudo: String,
84    /// Generate pseudo mirrored locale
85    #[arg(long, default_value = "")]
86    pseudo_m: String,
87    /// Generate pseudo wide locale
88    #[arg(long, default_value = "")]
89    pseudo_w: String,
90
91    /// Only verify that the generated files are the same
92    #[arg(long, action)]
93    check: bool,
94
95    /// Use verbose output.
96    #[arg(short, long, action)]
97    verbose: bool,
98}
99
100pub fn run(args: L10nArgs) {
101    run_impl(args, false);
102}
103fn run_impl(mut args: L10nArgs, is_local_scrap_recursion: bool) {
104    if !args.package.is_empty() && !args.manifest_path.is_empty() {
105        fatal!("only one of --package --manifest-path must be set")
106    }
107
108    let mut input = String::new();
109    let mut output = args.output.replace('\\', "/");
110
111    if !args.input.is_empty() {
112        input = args.input.replace('\\', "/");
113
114        if !input.contains('*') && PathBuf::from(&input).is_dir() {
115            input = format!("{}/**/*.rs", input.trim_end_matches('/'));
116        }
117    }
118    if !args.package.is_empty() {
119        if let Some(m) = crate::util::manifest_path_from_package(&args.package) {
120            args.manifest_path = m;
121        } else {
122            fatal!("package `{}` not found in workspace", args.package);
123        }
124    }
125
126    if !args.manifest_path.is_empty() {
127        if !Path::new(&args.manifest_path).exists() {
128            fatal!("{input} does not exist")
129        }
130
131        if let Some(path) = args.manifest_path.replace('\\', "/").strip_suffix("/Cargo.toml") {
132            if output.is_empty() {
133                output = format!("{path}/l10n");
134            }
135            if input.is_empty() {
136                input = format!("{path}/src/**/*.rs");
137            }
138        } else {
139            fatal!("expected path to Cargo.toml manifest file");
140        }
141    }
142
143    if args.check {
144        args.clean = false;
145        args.clean_deps = false;
146        args.clean_template = false;
147    } else if args.clean {
148        args.clean_deps = true;
149        args.clean_template = true;
150    }
151
152    if args.verbose && !is_local_scrap_recursion {
153        println!(
154            "input: `{input}`\noutput: `{output}`\nclean_deps: {}\nclean_template: {}",
155            args.clean_deps, args.clean_template
156        );
157    }
158
159    if !input.is_empty() {
160        if output.is_empty() {
161            fatal!("--output is required for --input")
162        }
163
164        // scrap the target package
165        if !args.no_pkg {
166            if args.check {
167                println!(r#"checking "{input}".."#);
168            } else {
169                println!(r#"scraping "{input}".."#);
170            }
171
172            let custom_macro_names: Vec<&str> = args.macros.split(',').map(|n| n.trim()).collect();
173            // let args = ();
174
175            let mut template = scraper::scrape_fluent_text(&input, &custom_macro_names);
176            if !args.check {
177                match template.entries.len() {
178                    0 => println!("did not find any entry"),
179                    1 => println!("found 1 entry"),
180                    n => println!("found {n} entries"),
181                }
182            }
183
184            if !template.entries.is_empty() || !template.notes.is_empty() {
185                if let Err(e) = util::check_or_create_dir_all(args.check, &output) {
186                    fatal!("cannot create dir `{output}`, {e}");
187                }
188
189                template.sort();
190
191                let r = template.write(|file, contents| {
192                    let mut output = PathBuf::from(&output);
193                    output.push("template");
194
195                    if args.clean_template {
196                        debug_assert!(!args.check);
197                        if args.verbose {
198                            println!("removing `{}` to clean template", output.display());
199                        }
200                        if let Err(e) = fs::remove_dir_all(&output) {
201                            if !matches!(e.kind(), io::ErrorKind::NotFound) {
202                                error!("cannot remove `{}`, {e}", output.display());
203                            }
204                        }
205                    }
206                    util::check_or_create_dir_all(args.check, &output)?;
207                    output.push(format!("{}.ftl", if file.is_empty() { "_" } else { file }));
208                    util::check_or_write(args.check, output, contents, args.verbose)
209                });
210                if let Err(e) = r {
211                    fatal!("error writing template files, {e}");
212                }
213            }
214        }
215
216        // cleanup dependencies
217        let l10n_dir = Path::new(&output);
218        if args.clean_deps {
219            for entry in glob::glob(&format!("{}/*/deps", l10n_dir.display()))
220                .unwrap_or_else(|e| fatal!("cannot cleanup deps in `{}`, {e}", l10n_dir.display()))
221            {
222                let dir = entry.unwrap_or_else(|e| fatal!("cannot cleanup deps, {e}"));
223                if args.verbose {
224                    println!("removing `{}` to clean deps", dir.display());
225                }
226                if let Err(e) = std::fs::remove_dir_all(&dir) {
227                    if !matches!(e.kind(), io::ErrorKind::NotFound) {
228                        error!("cannot remove `{}`, {e}", dir.display());
229                    }
230                }
231            }
232        }
233
234        // collect dependencies
235        let mut local = vec![];
236        if !args.no_deps {
237            let mut count = 0;
238            let (workspace_root, deps) = util::dependencies(&args.manifest_path);
239            for dep in deps {
240                if dep.version.pre.as_str() == "local" && dep.manifest_path.starts_with(&workspace_root) {
241                    local.push(dep);
242                    continue;
243                }
244
245                let dep_l10n = dep.manifest_path.with_file_name("l10n");
246                let dep_l10n_reader = match fs::read_dir(&dep_l10n) {
247                    Ok(d) => d,
248                    Err(e) => {
249                        if !matches!(e.kind(), io::ErrorKind::NotFound) {
250                            error!("cannot read `{}`, {e}", dep_l10n.display());
251                        }
252                        continue;
253                    }
254                };
255
256                let mut any = false;
257
258                // get l10n_dir/{lang}/deps/dep.name/dep.version/
259                let mut l10n_dir = |lang: Option<&std::ffi::OsStr>| {
260                    any = true;
261                    let dir = l10n_dir.join(lang.unwrap()).join("deps");
262
263                    let ignore_file = dir.join(".gitignore");
264
265                    if !ignore_file.exists() {
266                        // create dir and .gitignore file
267                        (|| -> io::Result<()> {
268                            util::check_or_create_dir_all(args.check, &dir)?;
269
270                            let mut ignore = "# Dependency localization files\n".to_owned();
271
272                            let output = Path::new(&output);
273                            let custom_output = if output != Path::new(&args.manifest_path).with_file_name("l10n") {
274                                format!(
275                                    " --output \"{}\"",
276                                    output.strip_prefix(std::env::current_dir().unwrap()).unwrap_or(output).display()
277                                )
278                                .replace('\\', "/")
279                            } else {
280                                String::new()
281                            };
282                            if !args.package.is_empty() {
283                                writeln!(
284                                    &mut ignore,
285                                    "# Call `cargo zng l10n --package {}{custom_output} --no-pkg --no-local --clean-deps` to update",
286                                    args.package
287                                )
288                                .unwrap();
289                            } else {
290                                let path = Path::new(&args.manifest_path);
291                                let path = path.strip_prefix(std::env::current_dir().unwrap()).unwrap_or(path);
292                                writeln!(
293                                    &mut ignore,
294                                    "# Call `cargo zng l10n --manifest-path \"{}\" --no-pkg --no-local --clean-deps` to update",
295                                    path.display()
296                                )
297                                .unwrap();
298                            }
299                            writeln!(&mut ignore).unwrap();
300                            writeln!(&mut ignore, "*").unwrap();
301                            writeln!(&mut ignore, "!.gitignore").unwrap();
302
303                            if let Err(e) = fs::write(&ignore_file, ignore.as_bytes()) {
304                                fatal!("cannot write `{}`, {e}", ignore_file.display())
305                            }
306
307                            Ok(())
308                        })()
309                        .unwrap_or_else(|e| fatal!("cannot create `{}`, {e}", l10n_dir.display()));
310                    }
311
312                    let dir = dir.join(&dep.name).join(dep.version.to_string());
313                    let _ = util::check_or_create_dir_all(args.check, &dir);
314
315                    dir
316                };
317
318                // [(exporter_dep, ".../{lang}?/deps")]
319                let mut reexport_deps = vec![];
320
321                for dep_l10n_entry in dep_l10n_reader {
322                    let dep_l10n_entry = match dep_l10n_entry {
323                        Ok(e) => e.path(),
324                        Err(e) => {
325                            error!("cannot read `{}` entry, {e}", dep_l10n.display());
326                            continue;
327                        }
328                    };
329                    if dep_l10n_entry.is_dir() {
330                        // l10n/{lang}/deps/{dep.name}/{dep.version}
331                        let output_dir = l10n_dir(dep_l10n_entry.file_name());
332                        let _ = util::check_or_create_dir_all(args.check, &output_dir);
333
334                        let lang_dir_reader = match fs::read_dir(&dep_l10n_entry) {
335                            Ok(d) => d,
336                            Err(e) => {
337                                error!("cannot read `{}`, {e}", dep_l10n_entry.display());
338                                continue;
339                            }
340                        };
341
342                        for lang_entry in lang_dir_reader {
343                            let lang_entry = match lang_entry {
344                                Ok(e) => e.path(),
345                                Err(e) => {
346                                    error!("cannot read `{}` entry, {e}", dep_l10n_entry.display());
347                                    continue;
348                                }
349                            };
350
351                            if lang_entry.is_dir() {
352                                if lang_entry.file_name().map(|n| n == "deps").unwrap_or(false) {
353                                    reexport_deps.push((&dep, lang_entry));
354                                }
355                            } else if lang_entry.is_file() && lang_entry.extension().map(|e| e == "ftl").unwrap_or(false) {
356                                let _ = util::check_or_create_dir_all(args.check, &output_dir);
357                                let to = output_dir.join(lang_entry.file_name().unwrap());
358                                if let Err(e) = util::check_or_copy(args.check, &lang_entry, &to, args.verbose) {
359                                    error!("cannot copy `{}` to `{}`, {e}", lang_entry.display(), to.display());
360                                    continue;
361                                }
362                            }
363                        }
364                    }
365                }
366
367                reexport_deps.sort_by(|a, b| match a.0.name.cmp(&b.0.name) {
368                    Ordering::Equal => b.0.version.cmp(&a.0.version),
369                    o => o,
370                });
371
372                for (_, deps) in reexport_deps {
373                    // dep/l10n/lang/deps/
374                    let target = l10n_dir(deps.parent().and_then(|p| p.file_name()));
375
376                    // deps/pkg-name/pkg-version/*.ftl
377                    for entry in glob::glob(&deps.join("*/*/*.ftl").display().to_string()).unwrap() {
378                        let entry = entry.unwrap_or_else(|e| fatal!("cannot read `{}` entry, {e}", deps.display()));
379                        let target = target.join(entry.strip_prefix(&deps).unwrap());
380                        if !target.exists() && entry.is_file() {
381                            if let Err(e) = util::check_or_copy(args.check, &entry, &target, args.verbose) {
382                                error!("cannot copy `{}` to `{}`, {e}", entry.display(), target.display());
383                            }
384                        }
385                    }
386                }
387
388                count += any as u32;
389            }
390            println!("found {count} dependencies with localization");
391        }
392
393        // scrap local dependencies
394        if !args.no_local {
395            for dep in local {
396                run_impl(
397                    L10nArgs {
398                        input: String::new(),
399                        output: output.clone(),
400                        package: String::new(),
401                        manifest_path: dep.manifest_path.display().to_string(),
402                        no_deps: true,
403                        no_local: true,
404                        no_pkg: false,
405                        clean_deps: false,
406                        clean_template: false,
407                        clean: false,
408                        macros: args.macros.clone(),
409                        pseudo: String::new(),
410                        pseudo_m: String::new(),
411                        pseudo_w: String::new(),
412                        check: args.check,
413                        verbose: args.verbose,
414                    },
415                    true,
416                )
417            }
418        }
419    }
420
421    if !args.pseudo.is_empty() {
422        pseudo::pseudo(&args.pseudo, args.check, args.verbose);
423    }
424    if !args.pseudo_m.is_empty() {
425        pseudo::pseudo_mirr(&args.pseudo_m, args.check, args.verbose);
426    }
427    if !args.pseudo_w.is_empty() {
428        pseudo::pseudo_wide(&args.pseudo_w, args.check, args.verbose);
429    }
430}