Skip to main content

cargo_zng/res/built_in/
l10n.rs

1use std::{
2    borrow::Cow,
3    collections::{HashMap, HashSet},
4};
5
6use once_cell::unsync::Lazy;
7
8use super::*;
9
10const L10N_HELP: &str = "
11Copy localization files (.ftl) and optimize for release
12
13The request file:
14  source/l10n.zr-l10n
15   | # comment
16   | path/dev-l10n
17
18Copies the `path/dev-l10n` dir to:
19  target/l10n
20
21Paths are relative to the Cargo workspace root
22
23Filter:
24
25Only localization files are included
26    **/*.ftl
27
28Development langs are excluded
29    !./pseudo*
30    !./template
31
32Only lang folders that have local translations are included
33    If ./{lang}/deps/** exists but no ./{lang}/*.ftl exists it is excluded
34
35Comments are stripped
36
37Subsetting:
38
39If a l10n subset profile is found is is applied to the dependency localization
40
41The subset profile is an allow list, see the docs `zng::l10n` for how to create one
42
43The subset profile is resolved in this order:
44
45ZNG_L10N_PROFILE_FILE env if is set
46    Must be set to a .subset file path, relative to the Cargo workspace root
47    If the file is a {name}.rec.subset auto includes a {name}.subset and vice versa 
48
49res/optimization-profiles/zng-ext-l10n.rec.subset
50    Default location, also includes zng-ext-l10n.subset if present
51
52{l10n-path}/*.subset
53    If multiple files match all are used
54
55";
56pub(super) fn l10n() {
57    help(L10N_HELP);
58
59    // read source
60    let source = read_path(&path(ZR_REQUEST)).unwrap_or_else(|e| fatal!("{e}"));
61    // target derived from the request file name
62    let mut target = path(ZR_TARGET);
63    // request without name "./.zr-copy", take name from source (this is deliberate not documented)
64    if target.ends_with(".zr-l10n") {
65        target = target.with_file_name(source.file_name().unwrap());
66    }
67
68    if source.is_dir() {
69        println!("{}", display_path(&target));
70        fs::create_dir(&target).unwrap_or_else(|e| {
71            if e.kind() != io::ErrorKind::AlreadyExists {
72                fatal!("{e}")
73            }
74        });
75
76        l10n_filter_copy(source, target);
77    } else if source.is_file() {
78        fatal!("expected l10n dir, '{}' is a file", source.display());
79    } else if source.is_symlink() {
80        symlink_warn(&source);
81    } else {
82        warn!("cannot copy l10n dir '{}', not found", source.display());
83    }
84}
85
86/// cargo zng l10n --release-langs PATH
87pub(crate) fn release_langs(path: &Path) {
88    let mut sep = "";
89    for from_lang in fs::read_dir(path).unwrap_or_else(|e| fatal!("cannot read {}, {}", path.display(), e)) {
90        let from_lang = from_lang
91            .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", path.display(), e))
92            .path();
93        if !from_lang.is_dir() {
94            continue;
95        }
96        let lang = match from_lang.file_name().and_then(|s| s.to_str()) {
97            Some(l) => l,
98            None => continue,
99        };
100        if lang.starts_with("pseudo") || lang == "template" {
101            continue;
102        }
103
104        let mut any_ftl = false;
105        for from_entry in fs::read_dir(&from_lang).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_lang.display(), e)) {
106            let from_entry = from_entry
107                .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_lang.display(), e))
108                .path();
109
110            if from_entry.is_file()
111                && let Some(ext) = from_entry.extension()
112                && ext.eq_ignore_ascii_case("ftl")
113            {
114                any_ftl = true;
115                break;
116            }
117        }
118        if any_ftl {
119            print!("{sep}{lang}");
120            sep = ",";
121        }
122    }
123    println!();
124}
125
126fn l10n_filter_copy(from: PathBuf, to: PathBuf) {
127    let subset = allow_subset(&from);
128
129    for from_lang in fs::read_dir(&from).unwrap_or_else(|e| fatal!("cannot read {}, {}", from.display(), e)) {
130        let from_lang = from_lang
131            .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from.display(), e))
132            .path();
133        if !from_lang.is_dir() {
134            continue;
135        }
136
137        // skip pseudo* and template
138        let lang = match from_lang.file_name().and_then(|s| s.to_str()) {
139            Some(l) => l,
140            None => continue,
141        };
142        if lang.starts_with("pseudo") || lang == "template" {
143            continue;
144        }
145
146        let to_lang = to.join(lang);
147
148        // copy *.ftl and collect ./deps
149        let mut any_ftl = false;
150        let mut from_deps = None;
151        for from_entry in fs::read_dir(&from_lang).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_lang.display(), e)) {
152            let from_entry = from_entry
153                .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_lang.display(), e))
154                .path();
155
156            if from_entry.is_file() {
157                if let Some(ext) = from_entry.extension()
158                    && ext.eq_ignore_ascii_case("ftl")
159                {
160                    if !any_ftl {
161                        fs::create_dir(&to_lang).unwrap_or_else(|e| fatal!("cannot create {}, {}", to_lang.display(), e));
162                    }
163                    any_ftl = true;
164
165                    let to_entry = to_lang.join(from_entry.file_name().unwrap());
166                    fs::copy(from_entry, &to_entry).unwrap_or_else(|e| fatal!("cannot copy to {}, {}", to_entry.display(), e));
167                }
168            } else if from_entry.is_dir()
169                && let Some(name) = from_entry.file_name()
170                && name == "deps"
171            {
172                from_deps = Some(from_entry);
173            }
174        }
175
176        // skip lang, no local translations
177        if !any_ftl {
178            continue;
179        }
180
181        let from_deps = match from_deps {
182            Some(p) => p,
183            None => continue,
184        };
185
186        macro_rules! lazy_path {
187            ($init:expr) => {
188                Lazy::<PathBuf, _>::new(|| {
189                    let p = $init;
190                    fs::create_dir(&p).unwrap_or_else(|e| fatal!("cannot create {}, {}", p.display(), e));
191                    p
192                })
193            };
194        }
195        let to_deps = lazy_path!(to_lang.join("deps"));
196
197        // copy ./deps/*/*/*.ftl
198        for from_pkg in fs::read_dir(&from_deps).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_deps.display(), e)) {
199            let from_pkg = from_pkg
200                .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_deps.display(), e))
201                .path();
202            if !from_pkg.is_dir() {
203                continue;
204            }
205
206            let pkg = match from_pkg.file_name().and_then(|p| p.to_str()) {
207                Some(p) => p,
208                None => continue,
209            };
210            let subset_pkg = match subset.get(pkg) {
211                Some(m) => Cow::Borrowed(m),
212                None => {
213                    // subset filter
214                    if !subset.is_empty() {
215                        continue;
216                    }
217                    // no filter
218                    Cow::Owned(HashMap::new())
219                }
220            };
221
222            let to_pkg = lazy_path!(to_deps.join(pkg));
223
224            for from_ver in fs::read_dir(&from_pkg).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_pkg.display(), e)) {
225                let from_ver = from_ver
226                    .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_pkg.display(), e))
227                    .path();
228                if !from_ver.is_dir() {
229                    continue;
230                }
231
232                let to_ver = lazy_path!(to_pkg.join(from_ver.file_name().unwrap()));
233
234                for from_entry in fs::read_dir(&from_ver).unwrap_or_else(|e| fatal!("cannot read {}, {}", from_ver.display(), e)) {
235                    let from_entry = from_entry
236                        .unwrap_or_else(|e| fatal!("cannot read {} entry, {}", from_ver.display(), e))
237                        .path();
238                    if !from_entry.is_file() || !matches!(from_entry.extension(), Some(ext) if ext.eq_ignore_ascii_case("ftl")) {
239                        continue;
240                    }
241
242                    let file = match from_entry.file_name().unwrap().to_str() {
243                        Some(f) => f,
244                        None => continue,
245                    };
246                    let subset_file = match subset_pkg.get(file) {
247                        Some(m) => Cow::Borrowed(m),
248                        None => {
249                            if !subset_pkg.is_empty() {
250                                continue;
251                            }
252                            Cow::Owned(HashMap::new())
253                        }
254                    };
255
256                    let to_entry = to_ver.join(file);
257
258                    let ok = crate::l10n::generate_util::transform_file(
259                        &from_entry,
260                        &to_entry,
261                        "",
262                        &|id, attr| match subset_file.get(id) {
263                            Some(attrs) => attr.is_empty() || attrs.contains(attr),
264                            None => subset_file.is_empty(), // allow if no filter
265                        },
266                        &|s| Cow::Borrowed(s),
267                        false,
268                        false,
269                    );
270                    if !ok {
271                        fatal!("cannot optimize {}", from_entry.display());
272                    }
273                }
274            }
275        }
276    }
277}
278
279// [package => [file => [id => [attribute]]]]
280type SubsetMap = HashMap<String, HashMap<String, HashMap<String, HashSet<String>>>>;
281
282fn allow_subset(from: &Path) -> SubsetMap {
283    let mut out = SubsetMap::new();
284
285    if let Ok(path) = env::var("ZNG_L10N_PROFILE_FILE")
286        && !path.is_empty()
287    {
288        if !path.ends_with(".subset") {
289            fatal!("ZNG_L10N_PROFILE_FILE must be a .subset file");
290        }
291        read_subset(Path::new(&path), true, &mut out);
292    } else {
293        let default_profile = Path::new("res/optimization-profiles/zng-ext-l10n.rec.subset");
294        if default_profile.exists() {
295            read_subset(default_profile, true, &mut out);
296        } else {
297            let default_profile_pair = Path::new("res/optimization-profiles/zng-ext-l10n.subset");
298            if default_profile_pair.exists() {
299                read_subset(default_profile, false, &mut out);
300            } else {
301                for file in ::glob::glob(&format!("{}/*.subset", from.display())).unwrap() {
302                    let file = file.unwrap_or_else(|e| fatal!("cannot read {}, {}", from.display(), e));
303                    read_subset(&file, false, &mut out);
304                }
305            }
306        }
307    }
308
309    out
310}
311fn read_subset(path: &Path, try_pair: bool, out: &mut SubsetMap) {
312    let file = fs::File::open(path).unwrap_or_else(|e| fatal!("cannot  read {}, {}", path.display(), e));
313    let file = io::BufReader::new(file);
314    for (i, line) in file.lines().enumerate() {
315        let line = line.unwrap_or_else(|e| fatal!("cannot read {}, {}", path.display(), e));
316        let line = line.trim();
317        if line.starts_with('#') || line.is_empty() {
318            continue;
319        }
320
321        let (dependency, mut key) = match line.split_once("//") {
322            Some((d, k)) if !d.is_empty() && !k.is_empty() => (d, k),
323            _ => fatal!("unexpected line {}:{}", path.display(), i + 1),
324        };
325        let file = match key.split_once('/') {
326            Some((f, k)) => {
327                key = k;
328                // add .ftl so we can match the file_name directly
329                if f == "_" || f.is_empty() {
330                    Cow::Borrowed("_.ftl")
331                } else {
332                    Cow::Owned(format!("{f}.ftl"))
333                }
334            }
335            None => Cow::Borrowed("_.ftl"),
336        };
337        let (id, attribute) = match key.split_once('.') {
338            Some((id, a)) => {
339                if id.is_empty() || a.is_empty() {
340                    fatal!("unexpected line {}:{}", path.display(), i + 1);
341                }
342                (id, a)
343            }
344            None => (key, ""),
345        };
346
347        let dep_map = match out.get_mut(dependency) {
348            Some(m) => m,
349            None => out.entry(dependency.to_owned()).or_default(),
350        };
351        let file_map = match dep_map.get_mut(&*file) {
352            Some(m) => m,
353            None => dep_map.entry(file.into_owned()).or_default(),
354        };
355        let id_map = match file_map.get_mut(id) {
356            Some(m) => m,
357            None => file_map.entry(id.to_owned()).or_default(),
358        };
359        if !attribute.is_empty() && !id_map.contains(attribute) {
360            id_map.insert(attribute.to_owned());
361        }
362    }
363
364    if try_pair {
365        let name = path.file_name().unwrap().to_str().unwrap();
366        let pair_name = if let Some(n) = name.strip_suffix(".rec.subset") {
367            format!("{n}.subset")
368        } else if let Some(n) = name.strip_suffix(".subset") {
369            format!("{n}.rec.subset")
370        } else {
371            return;
372        };
373        let pair_path = path.parent().unwrap().join(pair_name);
374        if pair_path.exists() {
375            read_subset(&pair_path, false, out);
376        }
377    }
378}