Skip to main content

mos/
main.rs

1//! `mos`: command-line interface for the Mosaic typesetting engine.
2//!
3//! Subcommands mirror manifest §15.1. `mos check` (read source → parse →
4//! lower → resolve → report diagnostics) and `mos build` (the same, then
5//! layout → PDF) are wired end-to-end; every other subcommand exits with an
6//! error until its slice lands.
7
8#![doc(
9    html_logo_url = "https://mosaiclang.dev/assets/A4.svg",
10    html_favicon_url = "https://mosaiclang.dev/assets/A4.svg"
11)]
12#![allow(
13    clippy::print_stderr,
14    clippy::print_stdout,
15    reason = "CLI boundary intentionally writes user output directly"
16)]
17
18use std::path::{Component, Path, PathBuf};
19use std::process::{Command as ProcessCommand, ExitCode};
20
21use clap::{Parser, Subcommand};
22use mos_core::{
23    Diagnostic, DiagnosticAnnotation, DiagnosticResult, DiagnosticSink, Severity, SourceSpan,
24    Suggestion, display_path, linecol,
25};
26
27/// Cap on resolve↔layout rounds for page references before the engine gives up
28/// and reports `MOS0047` (issue #72). Stable documents settle in one or two
29/// rounds; the cap bounds pathological oscillation so the build always
30/// terminates.
31const MAX_PAGE_FIXPOINT_ITERATIONS: u32 = 8;
32
33#[derive(Parser, Debug)]
34#[command(
35    name = "mos",
36    bin_name = "mos",
37    version,
38    about = "Mosaic: semantic, incremental typesetting compiler",
39    long_about = "Mosaic compiles `.mos` source files to PDF.\n\
40                  See manifest.md in the repository root for the full design."
41)]
42struct Cli {
43    #[command(subcommand)]
44    command: Command,
45}
46
47#[derive(Subcommand, Debug)]
48enum Command {
49    /// Initialise a new Mosaic project in the current directory.
50    Init {
51        #[arg(default_value = ".")]
52        path: PathBuf,
53    },
54
55    /// Build the project to its declared outputs.
56    Build {
57        #[arg(value_name = "PATH")]
58        entries: Vec<PathBuf>,
59        /// Open the generated PDF after a successful build.
60        ///
61        /// Use `--open` for the platform default, or `--open=PROGRAM`
62        /// to invoke a specific viewer.
63        #[arg(
64            long,
65            value_name = "PROGRAM",
66            num_args = 0..=1,
67            default_missing_value = "",
68            require_equals = true
69        )]
70        open: Option<String>,
71        /// Write a .layout.pdf with boxes/baselines and a .layout.json report beside each PDF.
72        #[arg(long)]
73        debug_layout: bool,
74        /// Refuse to update dependencies (manifest §15.3).
75        #[arg(long)]
76        frozen: bool,
77        /// Make the build deterministic (manifest §24).
78        #[arg(long)]
79        reproducible: bool,
80    },
81
82    /// Watch sources and rebuild on change (manifest §8).
83    Watch {
84        #[arg(default_value = "main.mos")]
85        entry: PathBuf,
86    },
87
88    /// Type-check and validate without producing output.
89    Check {
90        #[arg(value_name = "PATH")]
91        entries: Vec<PathBuf>,
92    },
93
94    /// Format `.mos` sources (manifest §18).
95    Fmt {
96        #[arg(default_value = ".")]
97        path: PathBuf,
98    },
99
100    /// Run document and package tests (manifest §28).
101    Test,
102
103    /// Profile a build and report layout hot spots (manifest §16).
104    Profile {
105        #[arg(default_value = "main.mos")]
106        entry: PathBuf,
107    },
108
109    /// Remove build artefacts and the local cache.
110    Clean,
111
112    /// Bundle a project into a `.mosaicbundle` archive (manifest §15.3).
113    Package {
114        #[arg(default_value = "main.mos")]
115        entry: PathBuf,
116    },
117}
118
119fn main() -> ExitCode {
120    let cli = Cli::parse();
121
122    match cli.command {
123        Command::Check { entries } => run_checks(&entries),
124        Command::Build {
125            entries,
126            open,
127            debug_layout,
128            frozen: _,
129            reproducible: _,
130        } => run_builds(&entries, PdfOpen::from_cli(open.as_deref()), debug_layout),
131        Command::Init { .. } => unimplemented_subcommand("init"),
132        Command::Watch { .. } => unimplemented_subcommand("watch"),
133        Command::Fmt { .. } => unimplemented_subcommand("fmt"),
134        Command::Test => unimplemented_subcommand("test"),
135        Command::Profile { .. } => unimplemented_subcommand("profile"),
136        Command::Clean => unimplemented_subcommand("clean"),
137        Command::Package { .. } => unimplemented_subcommand("package"),
138    }
139}
140
141fn default_entries(entries: &[PathBuf]) -> Vec<PathBuf> {
142    if entries.is_empty() {
143        vec![PathBuf::from("main.mos")]
144    } else {
145        entries.to_owned()
146    }
147}
148
149fn run_checks(entries: &[PathBuf]) -> ExitCode {
150    run_many(entries, run_check)
151}
152
153fn run_builds(entries: &[PathBuf], open: PdfOpen<'_>, debug_layout: bool) -> ExitCode {
154    run_many(entries, |entry| run_build(entry, open, debug_layout))
155}
156
157fn run_many(entries: &[PathBuf], mut run_one: impl FnMut(&Path) -> ExitCode) -> ExitCode {
158    let entries = default_entries(entries);
159    let many = entries.len() > 1;
160    let mut ran = false;
161    let mut failed = false;
162
163    for entry in &entries {
164        if should_skip_batch_entry(entry, many) {
165            continue;
166        }
167        ran = true;
168        if run_one(entry) != ExitCode::SUCCESS {
169            failed = true;
170        }
171    }
172
173    if failed || !ran {
174        ExitCode::FAILURE
175    } else {
176        ExitCode::SUCCESS
177    }
178}
179
180fn should_skip_batch_entry(entry: &Path, many: bool) -> bool {
181    many && ((entry.is_file() && !is_mos_source(entry))
182        || (entry.is_dir()
183            && matches!(entry.join("mosaic.toml").try_exists(), Ok(false))
184            && matches!(entry.join("main.mos").try_exists(), Ok(false))))
185}
186
187fn is_mos_source(entry: &Path) -> bool {
188    entry.extension().is_some_and(|ext| ext == "mos")
189}
190
191fn unimplemented_subcommand(name: &str) -> ExitCode {
192    eprintln!("mos {name}: not yet implemented (see manifest §30 MVP roadmap)");
193    ExitCode::FAILURE
194}
195
196/// `mos check`: parse + lower the entry file and report diagnostics.
197/// Exits 0 if no errors (warnings still print); 1 otherwise.
198fn run_check(entry: &Path) -> ExitCode {
199    let Ok(entry) = resolve_entry("check", entry).map(|entry| entry.source) else {
200        return ExitCode::FAILURE;
201    };
202    let src = match std::fs::read_to_string(&entry) {
203        Ok(s) => s,
204        Err(err) => {
205            eprintln!("mos check: cannot read `{}`: {err}", display_path(&entry));
206            return ExitCode::FAILURE;
207        }
208    };
209
210    let mut sink = RenderingSink::new(&src);
211
212    // Parse phase. A parse error stops the pipeline before lowering, so
213    // the evaluator never runs on a structurally broken tree and the
214    // user sees every recoverable syntax diagnostic in one pass.
215    let Ok(tree) = mos_parse::parse(&src, &entry, &mut sink) else {
216        return ExitCode::FAILURE;
217    };
218    if sink.had_error() {
219        eprintln!(
220            "mos check: {} error(s), {} warning(s)",
221            sink.errors, sink.warnings
222        );
223        return ExitCode::FAILURE;
224    }
225
226    // Lower + resolve phase.
227    let result = mos_eval::lower_tree(&tree);
228    let node_count = result.document.len();
229    sink.render_all(result.diagnostics);
230
231    if sink.had_error() {
232        eprintln!(
233            "mos check: {} error(s), {} warning(s)",
234            sink.errors, sink.warnings
235        );
236        ExitCode::FAILURE
237    } else {
238        println!("ok: {node_count} node(s), {} warning(s)", sink.warnings);
239        ExitCode::SUCCESS
240    }
241}
242
243/// `mos build`: read source, parse, lower, lay out, and emit a PDF to
244/// `build/<entry-stem>.pdf` or the project's `[output].pdf`. Paper size and
245/// margins come from `#set page(...)`; text uses the Base-14 fonts with the
246/// bundled Noto Sans embedded and subset for glyphs they lack. Layout
247/// warnings print but don't fail the build.
248fn run_build(entry: &Path, open: PdfOpen<'_>, debug_layout: bool) -> ExitCode {
249    let Ok(resolved) = resolve_entry("build", entry) else {
250        return ExitCode::FAILURE;
251    };
252    let entry = resolved.source;
253    let src = match std::fs::read_to_string(&entry) {
254        Ok(s) => s,
255        Err(err) => {
256            eprintln!("mos build: cannot read `{}`: {err}", display_path(&entry));
257            return ExitCode::FAILURE;
258        }
259    };
260
261    let started = std::time::Instant::now();
262    let mut sink = RenderingSink::new(&src);
263
264    // Each phase runs to completion, then the barrier below stops the
265    // build before the next phase if any error was collected, so a
266    // broken document never reaches PDF emission and writes garbage.
267    let Ok(tree) = mos_parse::parse(&src, &entry, &mut sink) else {
268        return ExitCode::FAILURE;
269    };
270    if sink.had_error() {
271        return ExitCode::FAILURE;
272    }
273
274    let result = mos_eval::lower_tree(&tree);
275    sink.render_all(result.diagnostics);
276    if sink.had_error() {
277        return ExitCode::FAILURE;
278    }
279
280    // Resolve `@page(...)` references by iterating layout until the page
281    // numbers stabilize (issue #72). Each round lays the document out and feeds
282    // the resulting label→page map back into the resolver; layout is the
283    // injected closure so the fixpoint logic itself lives in `mos-eval`. A
284    // document with no page references settles in one round.
285    let mut document = result.document;
286    let (page_outcome, layout) = mos_eval::resolve_page_reference_fixpoint(
287        &mut document,
288        |doc| {
289            let engine = mos_layout::LayoutEngine::new();
290            let layout = if debug_layout {
291                engine.layout_with_debug(doc)
292            } else {
293                engine.layout(doc)
294            };
295            (layout.label_pages.clone(), layout)
296        },
297        MAX_PAGE_FIXPOINT_ITERATIONS,
298    );
299    if let mos_eval::PageFixpointOutcome::NotConverged { iterations } = page_outcome {
300        let _ = sink.emit(Diagnostic::simple(
301            &mos_core::codes::MOS0047,
302            None,
303            format!(
304                "page references did not converge after {iterations} layout iterations; \
305                 using the last computed page numbers"
306            ),
307        ));
308    }
309
310    // Layout can produce real errors (MOS0017 unknown paper, MOS0023
311    // geometrically invalid margin/leading). Don't ship a PDF with
312    // broken config under a success exit code. Only the final layout's
313    // diagnostics are rendered, so iterating does not duplicate them.
314    sink.render_all(layout.diagnostics);
315    if sink.had_error() {
316        return ExitCode::FAILURE;
317    }
318
319    let stem = entry.file_stem().map_or_else(
320        || std::ffi::OsString::from("out"),
321        std::ffi::OsStr::to_os_string,
322    );
323    let out = resolved.output.unwrap_or_else(|| {
324        let mut path = resolved.output_base.join("build");
325        path.push(format!("{}.pdf", stem.to_string_lossy()));
326        path
327    });
328
329    let metadata = mos_pdf::PdfMetadata {
330        title: result.metadata.title.clone(),
331        author: result.metadata.author.clone(),
332        language: result.metadata.language,
333    };
334    match mos_pdf::emit(&layout.graph, &metadata, &out) {
335        Ok(pdf_diagnostics) => {
336            sink.render_all(pdf_diagnostics);
337            if sink.had_error() {
338                return ExitCode::FAILURE;
339            }
340        }
341        Err(err) => {
342            match err {
343                mos_core::CoreError::Diagnostic(d) => {
344                    let _ = sink.emit(*d);
345                }
346                mos_core::CoreError::Unimplemented(msg) => {
347                    eprintln!("mos build: {msg}");
348                }
349            }
350            return ExitCode::FAILURE;
351        }
352    }
353
354    let mut viewer_out = out.clone();
355    if let Some(report) = &layout.debug {
356        let debug_out = out.with_extension("layout.json");
357        let data = match serde_json::to_vec_pretty(report) {
358            Ok(mut data) => {
359                data.push(b'\n');
360                data
361            }
362            Err(err) => {
363                eprintln!("mos build: cannot encode layout debug report: {err}");
364                return ExitCode::FAILURE;
365            }
366        };
367        if let Err(err) = std::fs::write(&debug_out, data) {
368            eprintln!(
369                "mos build: cannot write layout debug report to {}: {err}",
370                display_path(&debug_out)
371            );
372            return ExitCode::FAILURE;
373        }
374        println!("wrote {}", display_path(&debug_out));
375        viewer_out = out.with_extension("layout.pdf");
376        if let Err(err) = mos_pdf::emit_debug(&layout.graph, report, &metadata, &viewer_out) {
377            // Font diagnostics were already rendered for the identical graph
378            // above; only a new emission failure needs reporting here.
379            match err {
380                mos_core::CoreError::Diagnostic(d) => {
381                    let _ = sink.emit(*d);
382                }
383                mos_core::CoreError::Unimplemented(msg) => eprintln!("mos build: {msg}"),
384            }
385            return ExitCode::FAILURE;
386        }
387        println!("wrote {}", display_path(&viewer_out));
388    }
389
390    println!(
391        "wrote {} in {} ms",
392        display_path(&out),
393        started.elapsed().as_millis()
394    );
395    if open.should_open() {
396        match open_pdf(&viewer_out, open) {
397            Ok(()) => println!("opened {}", display_path(&viewer_out)),
398            Err(err) => {
399                eprintln!("mos build: {err}");
400                return ExitCode::FAILURE;
401            }
402        }
403    }
404    ExitCode::SUCCESS
405}
406
407struct ResolvedEntry {
408    source: PathBuf,
409    output_base: PathBuf,
410    output: Option<PathBuf>,
411}
412
413fn resolve_entry(command: &str, entry: &Path) -> Result<ResolvedEntry, ()> {
414    if !entry.is_dir() {
415        let output_base = entry
416            .parent()
417            .unwrap_or_else(|| Path::new("."))
418            .to_path_buf();
419        return Ok(ResolvedEntry {
420            source: entry.to_path_buf(),
421            output_base,
422            output: None,
423        });
424    }
425
426    let manifest_path = entry.join("mosaic.toml");
427    if manifest_path.is_file() {
428        let manifest = match mos_packages::ProjectManifest::load(&manifest_path) {
429            Ok(manifest) => manifest,
430            Err(err) => {
431                eprintln!("mos {command}: {err}");
432                return Err(());
433            }
434        };
435        let source = mos_core::resolve_relative(entry, &manifest.project.entry).map_err(|err| {
436            eprintln!(
437                "mos {command}: invalid project entry path `{}`: {err}",
438                manifest.project.entry
439            );
440        })?;
441        return Ok(ResolvedEntry {
442            source,
443            output_base: entry.to_path_buf(),
444            output: match manifest.output.pdf.as_deref() {
445                Some(path) => Some(resolve_manifest_output(command, entry, path)?),
446                None => None,
447            },
448        });
449    }
450
451    Ok(ResolvedEntry {
452        source: entry.join("main.mos"),
453        output_base: entry.to_path_buf(),
454        output: None,
455    })
456}
457
458fn resolve_manifest_output(command: &str, project_dir: &Path, output: &str) -> Result<PathBuf, ()> {
459    let output_path = Path::new(output);
460    if output_path.as_os_str().is_empty()
461        || output_path.components().any(|component| {
462            matches!(
463                component,
464                Component::ParentDir | Component::RootDir | Component::Prefix(_)
465            )
466        })
467    {
468        eprintln!(
469            "mos {command}: invalid PDF output path `{output}`; use a relative path inside the project"
470        );
471        return Err(());
472    }
473    mos_core::resolve_relative(project_dir, output).map_err(|err| {
474        eprintln!("mos {command}: invalid PDF output path `{output}`: {err}");
475    })
476}
477
478#[derive(Debug, Clone, Copy)]
479enum PdfOpen<'a> {
480    No,
481    Default,
482    Program(&'a str),
483}
484
485impl<'a> PdfOpen<'a> {
486    const fn from_cli(open: Option<&'a str>) -> Self {
487        match open {
488            None => Self::No,
489            Some(program) => {
490                if program.is_empty() {
491                    Self::Default
492                } else {
493                    Self::Program(program)
494                }
495            }
496        }
497    }
498
499    const fn should_open(self) -> bool {
500        !matches!(self, Self::No)
501    }
502}
503
504fn open_pdf(path: &Path, request: PdfOpen<'_>) -> Result<(), String> {
505    match request {
506        PdfOpen::No => Ok(()),
507        PdfOpen::Default => opener::open(path.as_os_str())
508            .map_err(|err| format!("could not open `{}`: {err}", display_path(path))),
509        PdfOpen::Program(program) => {
510            let mut command = ProcessCommand::new(program);
511            command.arg(path);
512            let status = command.status().map_err(|err| {
513                format!(
514                    "could not open `{}` with `{program}`: {err}",
515                    display_path(path)
516                )
517            })?;
518            if status.success() {
519                Ok(())
520            } else {
521                Err(format!(
522                    "opener `{program}` failed for `{}` with {status}",
523                    display_path(path)
524                ))
525            }
526        }
527    }
528}
529
530/// A [`DiagnosticSink`] that renders each diagnostic to stderr as it
531/// arrives and tracks error/warning counts. The CLI drives one of these
532/// across every phase and checks [`Self::had_error`] at each phase
533/// barrier; that, not `Severity::Error` itself, is what stops the build.
534struct RenderingSink<'a> {
535    src: &'a str,
536    errors: usize,
537    warnings: usize,
538}
539
540impl<'a> RenderingSink<'a> {
541    const fn new(src: &'a str) -> Self {
542        Self {
543            src,
544            errors: 0,
545            warnings: 0,
546        }
547    }
548
549    const fn had_error(&self) -> bool {
550        self.errors > 0
551    }
552
553    /// Render every diagnostic in `diags`. Bridges phases that still
554    /// return a `Vec<Diagnostic>` (layout, PDF emit) into the sink.
555    fn render_all(&mut self, diags: impl IntoIterator<Item = Diagnostic>) {
556        for diag in diags {
557            let _ = self.emit(diag);
558        }
559    }
560}
561
562impl DiagnosticSink for RenderingSink<'_> {
563    fn emit(&mut self, diagnostic: Diagnostic) -> DiagnosticResult<()> {
564        match diagnostic.severity() {
565            Severity::Error => self.errors += 1,
566            Severity::Warning => self.warnings += 1,
567            Severity::Notice => {}
568        }
569        render_diagnostic(&diagnostic, self.src);
570        Ok(())
571    }
572}
573
574const fn severity_label(s: Severity) -> &'static str {
575    match s {
576        Severity::Error => "error",
577        Severity::Warning => "warning",
578        Severity::Notice => "notice",
579    }
580}
581
582fn render_diagnostic(diag: &Diagnostic, src: &str) {
583    let label = severity_label(diag.severity());
584    let code = format!("{} ({})", diag.def().id(), diag.def().code());
585    if let Some(span) = diag.span() {
586        let (line, col) = linecol(src, span.start());
587        eprintln!(
588            "{label}[{code}]: {msg}\n  --> {file}:{line}:{col}",
589            msg = diag.message(),
590            file = display_path(&span.file),
591        );
592        render_span_caret(src, span);
593    } else {
594        eprintln!("{label}[{code}]: {msg}", msg = diag.message());
595    }
596    for annotation in diag.annotations() {
597        match annotation {
598            DiagnosticAnnotation::Related { span, message } => {
599                let (line, col) = linecol(src, span.start());
600                eprintln!(
601                    "  note: {message} ({file}:{line}:{col})",
602                    file = display_path(&span.file),
603                );
604            }
605            DiagnosticAnnotation::Note(message) => eprintln!("  note: {message}"),
606            DiagnosticAnnotation::Help(message) => eprintln!("  help: {message}"),
607            DiagnosticAnnotation::Hint(message) => eprintln!("  hint: {message}"),
608        }
609    }
610    for suggestion in diag.suggestions() {
611        render_suggestion(src, suggestion);
612    }
613}
614
615fn render_suggestion(src: &str, suggestion: &Suggestion) {
616    eprintln!("  help: {}", suggestion_help(src, suggestion));
617}
618
619fn suggestion_help(src: &str, suggestion: &Suggestion) -> String {
620    let (line, col) = linecol(src, suggestion.span.start());
621    let file = display_path(&suggestion.span.file);
622    // The deletion arm carries an empty replacement, so the replacement text
623    // is formatted only in the arms that actually print it.
624    match suggestion_text(src, &suggestion.span) {
625        Some("") => {
626            let replacement = display_edit_text(&suggestion.replacement);
627            format!("insert `{replacement}` at {file}:{line}:{col}")
628        }
629        Some(text) if suggestion.replacement.is_empty() => {
630            let text = display_edit_text(text);
631            format!("delete `{text}` at {file}:{line}:{col}")
632        }
633        Some(text) => {
634            let text = display_edit_text(text);
635            let replacement = display_edit_text(&suggestion.replacement);
636            format!("replace `{text}` with `{replacement}` at {file}:{line}:{col}")
637        }
638        None => {
639            let replacement = display_edit_text(&suggestion.replacement);
640            format!("replace text with `{replacement}` at {file}:{line}:{col}")
641        }
642    }
643}
644
645fn suggestion_text<'a>(src: &'a str, span: &SourceSpan) -> Option<&'a str> {
646    let start = clamp_to_char_boundary(src, span.start().min(src.len()));
647    let end = clamp_to_char_boundary(src, span.end().min(src.len()));
648    src.get(start..end)
649}
650
651fn display_edit_text(text: &str) -> String {
652    let mut out = String::with_capacity(text.len());
653    for ch in text.chars() {
654        match ch {
655            '\n' => out.push_str("\\n"),
656            '\r' => out.push_str("\\r"),
657            '\t' => out.push_str("\\t"),
658            other if other.is_control() => out.extend(other.escape_unicode()),
659            other => out.push(other),
660        }
661    }
662    out
663}
664
665fn clamp_to_char_boundary(src: &str, mut offset: usize) -> usize {
666    offset = offset.min(src.len());
667    while offset > 0 && !src.is_char_boundary(offset) {
668        offset -= 1;
669    }
670    offset
671}
672
673fn render_span_caret(src: &str, span: &SourceSpan) {
674    let (line_no, col) = linecol(src, span.start());
675    let span_start = clamp_to_char_boundary(src, span.start());
676    let line_start = src[..span_start].rfind('\n').map_or(0, |p| p + 1);
677    let raw_line_end = src[line_start..]
678        .find('\n')
679        .map_or(src.len(), |p| line_start + p);
680    // CRLF sources keep the trailing `\r` inside `[line_start, '\n')`;
681    // strip it so the caret line lines up with what stderr actually
682    // prints.
683    let line_end = if raw_line_end > line_start && src.as_bytes()[raw_line_end - 1] == b'\r' {
684        raw_line_end - 1
685    } else {
686        raw_line_end
687    };
688    let line_text = &src[line_start..line_end];
689    // Convert byte offsets into char counts so multibyte UTF-8
690    // sequences (e.g. `µ`, `é`) line up with the source above. Clamp
691    // both ends to char boundaries first; otherwise a span that
692    // straddles a multibyte sequence would panic the slice below.
693    let span_byte_end = clamp_to_char_boundary(src, span.end().min(line_end));
694    let span_byte_start = clamp_to_char_boundary(src, span_start.min(span_byte_end));
695    let caret_chars = src[span_byte_start..span_byte_end].chars().count().max(1);
696    eprintln!("   |");
697    eprintln!("{line_no:>3}| {line_text}");
698    eprintln!(
699        "   | {pad}{carets}",
700        pad = " ".repeat(col.saturating_sub(1)),
701        carets = "^".repeat(caret_chars),
702    );
703}
704
705#[cfg(test)]
706mod tests {
707    use std::path::PathBuf;
708
709    use mos_core::{SourceSpan, Suggestion};
710
711    use super::{PdfOpen, display_edit_text, suggestion_help};
712
713    #[test]
714    fn display_edit_text_escapes_control_characters_and_keeps_backslashes() {
715        assert_eq!(display_edit_text("a\u{1b}[31mb"), "a\\u{1b}[31mb");
716        assert_eq!(display_edit_text("x\u{7f}\u{0}y"), "x\\u{7f}\\u{0}y");
717        assert_eq!(display_edit_text("assets\\logo.png"), "assets\\logo.png");
718        assert_eq!(display_edit_text("a\nb\tc\rd"), "a\\nb\\tc\\rd");
719    }
720
721    #[test]
722    fn pdf_open_from_cli_distinguishes_absent_default_and_program() {
723        assert!(matches!(PdfOpen::from_cli(None), PdfOpen::No));
724
725        assert!(matches!(PdfOpen::from_cli(Some("")), PdfOpen::Default));
726
727        assert!(matches!(
728            PdfOpen::from_cli(Some("zathura")),
729            PdfOpen::Program("zathura")
730        ));
731    }
732
733    #[test]
734    fn suggestion_help_formats_replace_delete_insert_and_stale_spans() {
735        let file = PathBuf::from("main.mos");
736        let src = "see @bad\n";
737
738        assert_eq!(
739            suggestion_help(
740                src,
741                &Suggestion::new(SourceSpan::new(file.clone(), 4, 8), "@good")
742            ),
743            "replace `@bad` with `@good` at main.mos:1:5"
744        );
745        assert_eq!(
746            suggestion_help(
747                src,
748                &Suggestion::new(SourceSpan::new(file.clone(), 4, 8), "")
749            ),
750            "delete `@bad` at main.mos:1:5"
751        );
752        assert_eq!(
753            suggestion_help(
754                src,
755                &Suggestion::new(SourceSpan::new(file.clone(), src.len(), src.len()), "!")
756            ),
757            "insert `!` at main.mos:2:1"
758        );
759        assert_eq!(
760            suggestion_help(
761                src,
762                &Suggestion::new(SourceSpan::new(file, src.len() + 10, src.len() + 20), "!")
763            ),
764            "insert `!` at main.mos:2:1"
765        );
766    }
767}