Skip to main content

compare/
compare.rs

1//! Compare the two shapers word by word: `FontShaper` (HarfBuzz rules — the bundled
2//! mongfontbuilder Hudum font through harfrust) against `Shaper` (the ported rules).
3//!
4//! ```text
5//! cargo run --release --example compare --features font -- --random 100000
6//! cargo run --release --example compare --features font -- words.txt --tsv diff.tsv
7//! ```
8//!
9//! The report is printed to stdout: a summary, the differences grouped by the units the two
10//! shapers disagree on (each with an example word), and the first differing words. `--tsv FILE`
11//! also writes every differing word to FILE, tab-separated, for a spreadsheet or `grep`;
12//! `--markdown FILE` appends the report as Markdown (CI points it at the job summary) and
13//! `--github-warning` adds a GitHub Actions annotation. `--fail-on-diff` turns the report into a
14//! gate: the exit status is 1 when any word differs. Both shapers are compared on the public
15//! `shape`; see `docs/development.md`.
16
17use std::collections::{HashMap, HashSet};
18use std::fs::{File, OpenOptions};
19use std::io::{self, BufRead, BufWriter, IsTerminal, Write};
20use std::process::ExitCode;
21
22use mongol_norm::{is_mongolian_word_char, FontShaper, Locale, Shaper, WrittenUnit};
23
24const USAGE: &str = "\
25usage: cargo run --release --example compare --features font -- [OPTIONS] [FILE ...]
26
27Compare FontShaper (the bundled mongfontbuilder Hudum font through harfrust) with Shaper (the
28ported rules) and report the words they shape differently. The report goes to stdout.
29
30input (one of):
31  FILE ...          the Mongolian words of these text files ('-' is stdin)
32  (no FILE)         the Mongolian words of stdin
33  --random N        N random strings over the Hudum letters, FVS1-4, MVS, NNBSP, nirugu and ZWJ
34
35options:
36  --seed S          seed of --random (default 1)
37  --max-len L       longest random string (default 8)
38  --limit K         differing words to list (default 50; 0 lists none)
39  --groups G        difference groups to list (default 20; 0 lists none)
40  --tsv FILE        also write every differing word to FILE (tab-separated, with a header)
41  --markdown FILE   also append the report to FILE as Markdown (CI: $GITHUB_STEP_SUMMARY)
42  --github-warning  also print a GitHub Actions annotation: a warning when any word differs
43                    (an error with --fail-on-diff), a notice when none does
44  --fail-on-diff    exit with status 1 when any word differs or is shaped by one side only
45  -h, --help        show this help
46
47The exit status is 0 whatever the shapers do, unless --fail-on-diff is given and they differ
48(1). A usage or I/O error exits with 2.
49";
50
51struct Options {
52    files: Vec<String>,
53    random: Option<usize>,
54    seed: u64,
55    max_len: usize,
56    limit: usize,
57    groups: usize,
58    tsv: Option<String>,
59    markdown: Option<String>,
60    github_warning: bool,
61    fail_on_diff: bool,
62}
63
64fn parse_args() -> Result<Options, String> {
65    let mut options = Options {
66        files: Vec::new(),
67        random: None,
68        seed: 1,
69        max_len: 8,
70        limit: 50,
71        groups: 20,
72        tsv: None,
73        markdown: None,
74        github_warning: false,
75        fail_on_diff: false,
76    };
77    let mut args = std::env::args().skip(1);
78    while let Some(arg) = args.next() {
79        let mut value = |name: &str| args.next().ok_or_else(|| format!("{name} needs a value"));
80        let number = |name: &str, text: String| {
81            text.parse::<u64>()
82                .map_err(|_| format!("{name}: not a number: {text}"))
83        };
84        match arg.as_str() {
85            "-h" | "--help" => return Err(String::new()),
86            "--random" => options.random = Some(number("--random", value("--random")?)? as usize),
87            "--seed" => options.seed = number("--seed", value("--seed")?)?,
88            "--max-len" => options.max_len = number("--max-len", value("--max-len")?)? as usize,
89            "--limit" => options.limit = number("--limit", value("--limit")?)? as usize,
90            "--groups" => options.groups = number("--groups", value("--groups")?)? as usize,
91            "--tsv" => options.tsv = Some(value("--tsv")?),
92            "--markdown" => options.markdown = Some(value("--markdown")?),
93            "--github-warning" => options.github_warning = true,
94            "--fail-on-diff" => options.fail_on_diff = true,
95            _ if arg.starts_with("--") => return Err(format!("unknown option {arg}")),
96            _ => options.files.push(arg),
97        }
98    }
99    if options.random.is_some() && !options.files.is_empty() {
100        return Err("--random takes no FILE".to_owned());
101    }
102    if options.max_len == 0 {
103        return Err("--max-len must be at least 1".to_owned());
104    }
105    Ok(options)
106}
107
108/// The Mongolian words of some text: the runs of word characters, each kept once, in order.
109fn words_of(
110    reader: impl BufRead,
111    words: &mut Vec<String>,
112    seen: &mut HashSet<String>,
113) -> io::Result<()> {
114    for line in reader.lines() {
115        for word in line?.split(|c: char| !is_mongolian_word_char(c)) {
116            if !word.is_empty() && seen.insert(word.to_owned()) {
117                words.push(word.to_owned());
118            }
119        }
120    }
121    Ok(())
122}
123
124fn read_words(files: &[String]) -> io::Result<Vec<String>> {
125    let mut words = Vec::new();
126    let mut seen = HashSet::new();
127    if files.is_empty() {
128        if io::stdin().is_terminal() {
129            eprintln!("reading words from stdin (end with Ctrl-D); --help for the options");
130        }
131        words_of(io::stdin().lock(), &mut words, &mut seen)?;
132    }
133    for file in files {
134        if file == "-" {
135            words_of(io::stdin().lock(), &mut words, &mut seen)?;
136        } else {
137            let reader = io::BufReader::new(
138                File::open(file)
139                    .map_err(|error| io::Error::new(error.kind(), format!("{file}: {error}")))?,
140            );
141            words_of(reader, &mut words, &mut seen)?;
142        }
143    }
144    Ok(words)
145}
146
147/// A deterministic xorshift stream, so a seed always gives the same strings.
148struct XorShift(u64);
149
150impl XorShift {
151    fn below(&mut self, n: usize) -> usize {
152        let mut x = self.0;
153        x ^= x << 13;
154        x ^= x >> 7;
155        x ^= x << 17;
156        self.0 = x;
157        (x % n as u64) as usize
158    }
159}
160
161fn random_words(count: usize, seed: u64, max_len: usize) -> Vec<String> {
162    let mut alphabet: Vec<char> = (0x1820..=0x1842).filter_map(char::from_u32).collect();
163    alphabet.extend([
164        '\u{180B}', '\u{180C}', '\u{180D}', '\u{180F}', '\u{180E}', '\u{202F}', '\u{180A}',
165        '\u{200D}',
166    ]);
167    // Seed 0 would make xorshift emit zeros forever; mix the seed so nearby seeds diverge.
168    let mut rng = XorShift(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1);
169    (0..count)
170        .map(|_| {
171            let len = 1 + rng.below(max_len);
172            (0..len)
173                .map(|_| alphabet[rng.below(alphabet.len())])
174                .collect()
175        })
176        .collect()
177}
178
179fn joined(units: &[WrittenUnit]) -> String {
180    if units.is_empty() {
181        return "∅".to_owned();
182    }
183    units
184        .iter()
185        .map(|unit| unit.as_str())
186        .collect::<Vec<_>>()
187        .join("+")
188}
189
190fn code_points(word: &str) -> String {
191    word.chars()
192        .map(|c| format!("{:04X}", c as u32))
193        .collect::<Vec<_>>()
194        .join(" ")
195}
196
197/// The part of two unit sequences between their common prefix and common suffix: what the two
198/// shapers actually disagree on (`F+T+K2` vs `F+D+K2` → `T` vs `D`).
199fn middle<'a>(
200    a: &'a [WrittenUnit],
201    b: &'a [WrittenUnit],
202) -> (&'a [WrittenUnit], &'a [WrittenUnit]) {
203    let prefix = a.iter().zip(b).take_while(|(x, y)| x == y).count();
204    let suffix = a[prefix..]
205        .iter()
206        .rev()
207        .zip(b[prefix..].iter().rev())
208        .take_while(|(x, y)| x == y)
209        .count();
210    (&a[prefix..a.len() - suffix], &b[prefix..b.len() - suffix])
211}
212
213/// The upstream commit the bundled font was built at, from its pin file.
214fn pinned_commit() -> &'static str {
215    include_str!("../fonts/mongfontbuilder.json")
216        .split("\"commit\": \"")
217        .nth(1)
218        .and_then(|rest| rest.get(..12))
219        .unwrap_or("?")
220}
221
222struct Difference {
223    word: String,
224    font: String,
225    engine: String,
226    /// The disagreeing parts (font, engine) — the group the word is counted in.
227    group: (String, String),
228}
229
230/// A word only one side shapes: the side that rejected it, and the error.
231struct Rejected {
232    word: String,
233    side: &'static str,
234    error: String,
235}
236
237/// Words that differ in the same parts: how many, and the first of them.
238struct Group {
239    font: String,
240    engine: String,
241    count: usize,
242    example: String,
243}
244
245struct Report {
246    input: String,
247    total: usize,
248    differences: Vec<Difference>,
249    rejected: Vec<Rejected>,
250    /// Most frequent first.
251    groups: Vec<Group>,
252}
253
254impl Report {
255    fn same(&self) -> usize {
256        self.total - self.differences.len() - self.rejected.len()
257    }
258
259    fn percent(&self, n: usize) -> f64 {
260        100.0 * n as f64 / self.total.max(1) as f64
261    }
262}
263
264fn compare(options: &Options, words: &[String]) -> Report {
265    let font = FontShaper::new();
266    let engine = Shaper::new(Locale::Mng);
267    let mut differences = Vec::new();
268    let mut rejected = Vec::new();
269    for word in words {
270        match (font.shape(word), engine.shape(word)) {
271            (Ok(a), Ok(b)) if a == b => {}
272            (Ok(a), Ok(b)) => {
273                let (x, y) = middle(&a, &b);
274                differences.push(Difference {
275                    word: word.clone(),
276                    font: joined(&a),
277                    engine: joined(&b),
278                    group: (joined(x), joined(y)),
279                });
280            }
281            (Err(error), Ok(_)) => rejected.push(Rejected {
282                word: word.clone(),
283                side: "font",
284                error: error.to_string(),
285            }),
286            (Ok(_), Err(error)) => rejected.push(Rejected {
287                word: word.clone(),
288                side: "engine",
289                error: error.to_string(),
290            }),
291            (Err(_), Err(_)) => {}
292        }
293    }
294
295    let mut counts: HashMap<&(String, String), (usize, &str)> = HashMap::new();
296    for difference in &differences {
297        counts
298            .entry(&difference.group)
299            .or_insert((0, &difference.word))
300            .0 += 1;
301    }
302    let mut groups: Vec<Group> = counts
303        .into_iter()
304        .map(|((font, engine), (count, example))| Group {
305            font: font.clone(),
306            engine: engine.clone(),
307            count,
308            example: example.to_owned(),
309        })
310        .collect();
311    groups.sort_by(|a, b| {
312        b.count
313            .cmp(&a.count)
314            .then_with(|| (&a.font, &a.engine).cmp(&(&b.font, &b.engine)))
315    });
316
317    let input = match options.random {
318        Some(count) => format!(
319            "{count} random strings (seed {}, length 1-{})",
320            options.seed, options.max_len
321        ),
322        None => format!("{} distinct words", words.len()),
323    };
324    Report {
325        input,
326        total: words.len(),
327        differences,
328        rejected,
329        groups,
330    }
331}
332
333fn write_text(out: &mut impl Write, report: &Report, options: &Options) -> io::Result<()> {
334    writeln!(
335        out,
336        "FontShaper (fonts/hudum.otf, mongfontbuilder {}) vs Shaper (ported rules), public shape",
337        pinned_commit()
338    )?;
339    writeln!(out, "input: {}", report.input)?;
340    writeln!(
341        out,
342        "same: {}   differ: {} ({:.2}%)   shaped by one side only: {}",
343        report.same(),
344        report.differences.len(),
345        report.percent(report.differences.len()),
346        report.rejected.len()
347    )?;
348
349    if options.groups > 0 && !report.groups.is_empty() {
350        writeln!(
351            out,
352            "\nWhere they differ ({} groups; font vs engine, between the common prefix and suffix):",
353            report.groups.len()
354        )?;
355        let shown = &report.groups[..report.groups.len().min(options.groups)];
356        let width = shown
357            .iter()
358            .map(|group| group.font.chars().count())
359            .max()
360            .unwrap_or(0);
361        for group in shown {
362            writeln!(
363                out,
364                "  {:>6}  {:<width$}  vs  {:<12}  e.g. {}  ({})",
365                group.count,
366                group.font,
367                group.engine,
368                group.example,
369                code_points(&group.example)
370            )?;
371        }
372        if report.groups.len() > shown.len() {
373            writeln!(
374                out,
375                "  … {} more groups (--groups N)",
376                report.groups.len() - shown.len()
377            )?;
378        }
379    }
380
381    if options.limit > 0 && !report.differences.is_empty() {
382        let shown = &report.differences[..report.differences.len().min(options.limit)];
383        writeln!(
384            out,
385            "\nDiffering words ({} of {}; --limit N for more, --tsv FILE for all):",
386            shown.len(),
387            report.differences.len()
388        )?;
389        let font_width = shown.iter().map(|d| d.font.len()).max().unwrap_or(0).max(4);
390        let engine_width = shown
391            .iter()
392            .map(|d| d.engine.len())
393            .max()
394            .unwrap_or(0)
395            .max(6);
396        writeln!(
397            out,
398            "  {:<font_width$}  {:<engine_width$}  code points / word",
399            "font", "engine"
400        )?;
401        for difference in shown {
402            writeln!(
403                out,
404                "  {:<font_width$}  {:<engine_width$}  {}  {}",
405                difference.font,
406                difference.engine,
407                code_points(&difference.word),
408                difference.word
409            )?;
410        }
411    }
412
413    if !report.rejected.is_empty() {
414        writeln!(
415            out,
416            "\nShaped by one side only ({}):",
417            report.rejected.len()
418        )?;
419        for rejected in report.rejected.iter().take(options.limit.max(1)) {
420            writeln!(
421                out,
422                "  {} rejects {}  {}: {}",
423                rejected.side,
424                code_points(&rejected.word),
425                rejected.word,
426                rejected.error
427            )?;
428        }
429    }
430    Ok(())
431}
432
433fn write_markdown(out: &mut impl Write, report: &Report, options: &Options) -> io::Result<()> {
434    writeln!(out, "## FontShaper vs Shaper\n")?;
435    writeln!(
436        out,
437        "`fonts/hudum.otf` (mongfontbuilder `{}`) against the ported rules, public `shape`, on {}.\n",
438        pinned_commit(),
439        report.input
440    )?;
441    writeln!(out, "| words | same | differ | shaped by one side only |")?;
442    writeln!(out, "|---:|---:|---:|---:|")?;
443    writeln!(
444        out,
445        "| {} | {} | {} ({:.2}%) | {} |\n",
446        report.total,
447        report.same(),
448        report.differences.len(),
449        report.percent(report.differences.len()),
450        report.rejected.len()
451    )?;
452
453    if options.groups > 0 && !report.groups.is_empty() {
454        let shown = &report.groups[..report.groups.len().min(options.groups)];
455        writeln!(
456            out,
457            "### Where they differ: {} groups{}\n",
458            report.groups.len(),
459            if shown.len() < report.groups.len() {
460                format!(" (the {} largest)", shown.len())
461            } else {
462                String::new()
463            }
464        )?;
465        writeln!(
466            out,
467            "The units between the common prefix and suffix of the two shapes; `∅` is nothing.\n"
468        )?;
469        writeln!(out, "| words | font | engine | example | code points |")?;
470        writeln!(out, "|---:|---|---|---|---|")?;
471        for group in shown {
472            writeln!(
473                out,
474                "| {} | `{}` | `{}` | {} | `{}` |",
475                group.count,
476                group.font,
477                group.engine,
478                group.example,
479                code_points(&group.example)
480            )?;
481        }
482        writeln!(out)?;
483    }
484
485    if options.limit > 0 && !report.differences.is_empty() {
486        let shown = &report.differences[..report.differences.len().min(options.limit)];
487        writeln!(
488            out,
489            "<details><summary>The first {} of {} differing words</summary>\n",
490            shown.len(),
491            report.differences.len()
492        )?;
493        writeln!(out, "| font | engine | code points | word |")?;
494        writeln!(out, "|---|---|---|---|")?;
495        for difference in shown {
496            writeln!(
497                out,
498                "| `{}` | `{}` | `{}` | {} |",
499                difference.font,
500                difference.engine,
501                code_points(&difference.word),
502                difference.word
503            )?;
504        }
505        writeln!(out, "\n</details>\n")?;
506    }
507
508    if !report.rejected.is_empty() {
509        writeln!(
510            out,
511            "### Shaped by one side only: {}\n",
512            report.rejected.len()
513        )?;
514        writeln!(out, "| rejected by | code points | word | error |")?;
515        writeln!(out, "|---|---|---|---|")?;
516        for rejected in report.rejected.iter().take(options.limit.max(1)) {
517            writeln!(
518                out,
519                "| {} | `{}` | {} | {} |",
520                rejected.side,
521                code_points(&rejected.word),
522                rejected.word,
523                rejected.error
524            )?;
525        }
526        writeln!(out)?;
527    }
528    Ok(())
529}
530
531/// The CI artifact a TSV is uploaded as: its file name without the extension.
532fn artifact_name(path: &str) -> Option<&str> {
533    std::path::Path::new(path).file_stem()?.to_str()
534}
535
536/// A GitHub Actions workflow-command value: `%`, CR and LF escaped (and, in a property, `:`
537/// and `,` too).
538fn escape_command(text: &str, property: bool) -> String {
539    let mut out = String::with_capacity(text.len());
540    for c in text.chars() {
541        match c {
542            '%' => out.push_str("%25"),
543            '\r' => out.push_str("%0D"),
544            '\n' => out.push_str("%0A"),
545            ':' if property => out.push_str("%3A"),
546            ',' if property => out.push_str("%2C"),
547            _ => out.push(c),
548        }
549    }
550    out
551}
552
553/// One annotation for the whole run: a warning that names the largest groups when any word
554/// differs (or only one side shapes it), a notice when the shapers agree on every word.
555fn github_annotation(report: &Report, fail_on_diff: bool, artifact: Option<&str>) -> String {
556    let title = format!(
557        "FontShaper vs Shaper: {} of {} differ ({:.2}%)",
558        report.differences.len(),
559        report.total,
560        report.percent(report.differences.len())
561    );
562    if report.differences.is_empty() && report.rejected.is_empty() {
563        return format!(
564            "::notice title={}::The two shapers agree on all {}.",
565            escape_command(&title, true),
566            report.input
567        );
568    }
569    let mut message = format!(
570        "Input: {}. {} groups; the largest (font vs engine):",
571        report.input,
572        report.groups.len()
573    );
574    for group in report.groups.iter().take(5) {
575        message.push_str(&format!(
576            "\n{}× {} vs {}, e.g. {} ({})",
577            group.count,
578            group.font,
579            group.engine,
580            group.example,
581            code_points(&group.example)
582        ));
583    }
584    if !report.rejected.is_empty() {
585        message.push_str(&format!(
586            "\n{} words are shaped by one side only.",
587            report.rejected.len()
588        ));
589    }
590    match artifact {
591        Some(name) => message.push_str(&format!(
592            "\nFull report: the job summary; every word: the {name} artifact."
593        )),
594        None => message.push_str("\nFull report: the job summary."),
595    }
596    let level = if fail_on_diff { "error" } else { "warning" };
597    format!(
598        "::{level} title={}::{}",
599        escape_command(&title, true),
600        escape_command(&message, false)
601    )
602}
603
604fn write_tsv(path: &str, report: &Report) -> io::Result<()> {
605    let mut tsv = BufWriter::new(File::create(path)?);
606    writeln!(
607        tsv,
608        "word\tcode_points\tfont\tengine\tfont_part\tengine_part"
609    )?;
610    for difference in &report.differences {
611        writeln!(
612            tsv,
613            "{}\t{}\t{}\t{}\t{}\t{}",
614            difference.word,
615            code_points(&difference.word),
616            difference.font,
617            difference.engine,
618            difference.group.0,
619            difference.group.1
620        )?;
621    }
622    for rejected in &report.rejected {
623        let error = format!("error: {}", rejected.error);
624        let (font, engine) = if rejected.side == "font" {
625            (error.as_str(), "")
626        } else {
627            ("", error.as_str())
628        };
629        writeln!(
630            tsv,
631            "{}\t{}\t{font}\t{engine}\t\t",
632            rejected.word,
633            code_points(&rejected.word)
634        )?;
635    }
636    tsv.flush()
637}
638
639/// Writes the report; `Ok(true)` when the shapers agree on every word.
640fn run(options: &Options, words: &[String]) -> io::Result<bool> {
641    let report = compare(options, words);
642    let stdout = io::stdout();
643    let mut out = BufWriter::new(stdout.lock());
644    write_text(&mut out, &report, options)?;
645    if let Some(path) = &options.tsv {
646        write_tsv(path, &report)?;
647        writeln!(
648            out,
649            "\nwrote {} rows to {path}",
650            report.differences.len() + report.rejected.len()
651        )?;
652    }
653    if let Some(path) = &options.markdown {
654        let file = OpenOptions::new().create(true).append(true).open(path)?;
655        let mut markdown = BufWriter::new(file);
656        write_markdown(&mut markdown, &report, options)?;
657        markdown.flush()?;
658        writeln!(out, "appended the Markdown report to {path}")?;
659    }
660    if options.github_warning {
661        let artifact = options.tsv.as_deref().and_then(artifact_name);
662        writeln!(
663            out,
664            "{}",
665            github_annotation(&report, options.fail_on_diff, artifact)
666        )?;
667    }
668    out.flush()?;
669    Ok(report.differences.is_empty() && report.rejected.is_empty())
670}
671
672fn main() -> ExitCode {
673    let options = match parse_args() {
674        Ok(options) => options,
675        Err(message) if message.is_empty() => {
676            print!("{USAGE}");
677            return ExitCode::SUCCESS;
678        }
679        Err(message) => {
680            eprintln!("compare: {message}\n\n{USAGE}");
681            return ExitCode::from(2);
682        }
683    };
684    let words = match options.random {
685        Some(count) => random_words(count, options.seed, options.max_len),
686        None => match read_words(&options.files) {
687            Ok(words) => words,
688            Err(error) => {
689                eprintln!("compare: {error}");
690                return ExitCode::from(2);
691            }
692        },
693    };
694    match run(&options, &words) {
695        Ok(agree) if agree || !options.fail_on_diff => ExitCode::SUCCESS,
696        Ok(_) => ExitCode::from(1),
697        Err(error) => {
698            eprintln!("compare: {error}");
699            ExitCode::from(2)
700        }
701    }
702}