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_glob_file(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_glob_file(entry: &Path, many: bool) -> bool {
181    many && entry.is_file() && !is_mos_source(entry)
182}
183
184fn is_mos_source(entry: &Path) -> bool {
185    entry.extension().is_some_and(|ext| ext == "mos")
186}
187
188fn unimplemented_subcommand(name: &str) -> ExitCode {
189    eprintln!("mos {name}: not yet implemented (see manifest §30 MVP roadmap)");
190    ExitCode::FAILURE
191}
192
193/// `mos check`: parse + lower the entry file and report diagnostics.
194/// Exits 0 if no errors (warnings still print); 1 otherwise.
195fn run_check(entry: &Path) -> ExitCode {
196    let Ok(entry) = resolve_entry("check", entry).map(|entry| entry.source) else {
197        return ExitCode::FAILURE;
198    };
199    let src = match std::fs::read_to_string(&entry) {
200        Ok(s) => s,
201        Err(err) => {
202            eprintln!("mos check: cannot read `{}`: {err}", display_path(&entry));
203            return ExitCode::FAILURE;
204        }
205    };
206
207    let mut sink = RenderingSink::new(&src);
208
209    // Parse phase. A parse error stops the pipeline before lowering, so
210    // the evaluator never runs on a structurally broken tree and the
211    // user sees every recoverable syntax diagnostic in one pass.
212    let Ok(tree) = mos_parse::parse(&src, &entry, &mut sink) else {
213        return ExitCode::FAILURE;
214    };
215    if sink.had_error() {
216        eprintln!(
217            "mos check: {} error(s), {} warning(s)",
218            sink.errors, sink.warnings
219        );
220        return ExitCode::FAILURE;
221    }
222
223    // Lower + resolve phase.
224    let result = mos_eval::lower_tree(&tree);
225    let node_count = result.document.len();
226    sink.render_all(result.diagnostics);
227
228    if sink.had_error() {
229        eprintln!(
230            "mos check: {} error(s), {} warning(s)",
231            sink.errors, sink.warnings
232        );
233        ExitCode::FAILURE
234    } else {
235        println!("ok: {node_count} node(s), {} warning(s)", sink.warnings);
236        ExitCode::SUCCESS
237    }
238}
239
240/// `mos build`: read source, parse, lower, lay out, and emit a PDF to
241/// `build/<entry-stem>.pdf` or the project's `[output].pdf`. Paper size and
242/// margins come from `#set page(...)`; text uses the Base-14 fonts with the
243/// bundled Noto Sans embedded and subset for glyphs they lack. Layout
244/// warnings print but don't fail the build.
245fn run_build(entry: &Path, open: PdfOpen<'_>, debug_layout: bool) -> ExitCode {
246    let Ok(resolved) = resolve_entry("build", entry) else {
247        return ExitCode::FAILURE;
248    };
249    let entry = resolved.source;
250    let src = match std::fs::read_to_string(&entry) {
251        Ok(s) => s,
252        Err(err) => {
253            eprintln!("mos build: cannot read `{}`: {err}", display_path(&entry));
254            return ExitCode::FAILURE;
255        }
256    };
257
258    let started = std::time::Instant::now();
259    let mut sink = RenderingSink::new(&src);
260
261    // Each phase runs to completion, then the barrier below stops the
262    // build before the next phase if any error was collected, so a
263    // broken document never reaches PDF emission and writes garbage.
264    let Ok(tree) = mos_parse::parse(&src, &entry, &mut sink) else {
265        return ExitCode::FAILURE;
266    };
267    if sink.had_error() {
268        return ExitCode::FAILURE;
269    }
270
271    let result = mos_eval::lower_tree(&tree);
272    sink.render_all(result.diagnostics);
273    if sink.had_error() {
274        return ExitCode::FAILURE;
275    }
276
277    // Resolve `@page(...)` references by iterating layout until the page
278    // numbers stabilize (issue #72). Each round lays the document out and feeds
279    // the resulting label→page map back into the resolver; layout is the
280    // injected closure so the fixpoint logic itself lives in `mos-eval`. A
281    // document with no page references settles in one round.
282    let mut document = result.document;
283    let (page_outcome, layout) = mos_eval::resolve_page_reference_fixpoint(
284        &mut document,
285        |doc| {
286            let engine = mos_layout::LayoutEngine::new();
287            let layout = if debug_layout {
288                engine.layout_with_debug(doc)
289            } else {
290                engine.layout(doc)
291            };
292            (layout.label_pages.clone(), layout)
293        },
294        MAX_PAGE_FIXPOINT_ITERATIONS,
295    );
296    if let mos_eval::PageFixpointOutcome::NotConverged { iterations } = page_outcome {
297        let _ = sink.emit(Diagnostic::simple(
298            &mos_core::codes::MOS0047,
299            None,
300            format!(
301                "page references did not converge after {iterations} layout iterations; \
302                 using the last computed page numbers"
303            ),
304        ));
305    }
306
307    // Layout can produce real errors (MOS0017 unknown paper, MOS0023
308    // geometrically invalid margin/leading). Don't ship a PDF with
309    // broken config under a success exit code. Only the final layout's
310    // diagnostics are rendered, so iterating does not duplicate them.
311    sink.render_all(layout.diagnostics);
312    if sink.had_error() {
313        return ExitCode::FAILURE;
314    }
315
316    let stem = entry.file_stem().map_or_else(
317        || std::ffi::OsString::from("out"),
318        std::ffi::OsStr::to_os_string,
319    );
320    let out = resolved.output.unwrap_or_else(|| {
321        let mut path = resolved.output_base.join("build");
322        path.push(format!("{}.pdf", stem.to_string_lossy()));
323        path
324    });
325
326    let metadata = mos_pdf::PdfMetadata {
327        title: result.metadata.title.clone(),
328        author: result.metadata.author.clone(),
329        language: result.metadata.language,
330    };
331    match mos_pdf::emit(&layout.graph, &metadata, &out) {
332        Ok(pdf_diagnostics) => {
333            sink.render_all(pdf_diagnostics);
334            if sink.had_error() {
335                return ExitCode::FAILURE;
336            }
337        }
338        Err(err) => {
339            match err {
340                mos_core::CoreError::Diagnostic(d) => {
341                    let _ = sink.emit(*d);
342                }
343                mos_core::CoreError::Unimplemented(msg) => {
344                    eprintln!("mos build: {msg}");
345                }
346            }
347            return ExitCode::FAILURE;
348        }
349    }
350
351    let mut viewer_out = out.clone();
352    if let Some(report) = &layout.debug {
353        let debug_out = out.with_extension("layout.json");
354        let data = match serde_json::to_vec_pretty(report) {
355            Ok(mut data) => {
356                data.push(b'\n');
357                data
358            }
359            Err(err) => {
360                eprintln!("mos build: cannot encode layout debug report: {err}");
361                return ExitCode::FAILURE;
362            }
363        };
364        if let Err(err) = std::fs::write(&debug_out, data) {
365            eprintln!(
366                "mos build: cannot write layout debug report to {}: {err}",
367                display_path(&debug_out)
368            );
369            return ExitCode::FAILURE;
370        }
371        println!("wrote {}", display_path(&debug_out));
372        viewer_out = out.with_extension("layout.pdf");
373        if let Err(err) = mos_pdf::emit_debug(&layout.graph, report, &metadata, &viewer_out) {
374            // Font diagnostics were already rendered for the identical graph
375            // above; only a new emission failure needs reporting here.
376            match err {
377                mos_core::CoreError::Diagnostic(d) => {
378                    let _ = sink.emit(*d);
379                }
380                mos_core::CoreError::Unimplemented(msg) => eprintln!("mos build: {msg}"),
381            }
382            return ExitCode::FAILURE;
383        }
384        println!("wrote {}", display_path(&viewer_out));
385    }
386
387    println!(
388        "wrote {} in {} ms",
389        display_path(&out),
390        started.elapsed().as_millis()
391    );
392    if open.should_open() {
393        match open_pdf(&viewer_out, open) {
394            Ok(()) => println!("opened {}", display_path(&viewer_out)),
395            Err(err) => {
396                eprintln!("mos build: {err}");
397                return ExitCode::FAILURE;
398            }
399        }
400    }
401    ExitCode::SUCCESS
402}
403
404struct ResolvedEntry {
405    source: PathBuf,
406    output_base: PathBuf,
407    output: Option<PathBuf>,
408}
409
410fn resolve_entry(command: &str, entry: &Path) -> Result<ResolvedEntry, ()> {
411    if !entry.is_dir() {
412        let output_base = entry
413            .parent()
414            .unwrap_or_else(|| Path::new("."))
415            .to_path_buf();
416        return Ok(ResolvedEntry {
417            source: entry.to_path_buf(),
418            output_base,
419            output: None,
420        });
421    }
422
423    let manifest_path = entry.join("mosaic.toml");
424    if manifest_path.is_file() {
425        let manifest = match mos_packages::ProjectManifest::load(&manifest_path) {
426            Ok(manifest) => manifest,
427            Err(err) => {
428                eprintln!("mos {command}: {err}");
429                return Err(());
430            }
431        };
432        let source = mos_core::resolve_relative(entry, &manifest.project.entry).map_err(|err| {
433            eprintln!(
434                "mos {command}: invalid project entry path `{}`: {err}",
435                manifest.project.entry
436            );
437        })?;
438        return Ok(ResolvedEntry {
439            source,
440            output_base: entry.to_path_buf(),
441            output: match manifest.output.pdf.as_deref() {
442                Some(path) => Some(resolve_manifest_output(command, entry, path)?),
443                None => None,
444            },
445        });
446    }
447
448    Ok(ResolvedEntry {
449        source: entry.join("main.mos"),
450        output_base: entry.to_path_buf(),
451        output: None,
452    })
453}
454
455fn resolve_manifest_output(command: &str, project_dir: &Path, output: &str) -> Result<PathBuf, ()> {
456    let output_path = Path::new(output);
457    if output_path.as_os_str().is_empty()
458        || output_path.components().any(|component| {
459            matches!(
460                component,
461                Component::ParentDir | Component::RootDir | Component::Prefix(_)
462            )
463        })
464    {
465        eprintln!(
466            "mos {command}: invalid PDF output path `{output}`; use a relative path inside the project"
467        );
468        return Err(());
469    }
470    mos_core::resolve_relative(project_dir, output).map_err(|err| {
471        eprintln!("mos {command}: invalid PDF output path `{output}`: {err}");
472    })
473}
474
475#[derive(Debug, Clone, Copy)]
476enum PdfOpen<'a> {
477    No,
478    Default,
479    Program(&'a str),
480}
481
482impl<'a> PdfOpen<'a> {
483    const fn from_cli(open: Option<&'a str>) -> Self {
484        match open {
485            None => Self::No,
486            Some(program) => {
487                if program.is_empty() {
488                    Self::Default
489                } else {
490                    Self::Program(program)
491                }
492            }
493        }
494    }
495
496    const fn should_open(self) -> bool {
497        !matches!(self, Self::No)
498    }
499}
500
501fn open_pdf(path: &Path, request: PdfOpen<'_>) -> Result<(), String> {
502    match request {
503        PdfOpen::No => Ok(()),
504        PdfOpen::Default => opener::open(path.as_os_str())
505            .map_err(|err| format!("could not open `{}`: {err}", display_path(path))),
506        PdfOpen::Program(program) => {
507            let mut command = ProcessCommand::new(program);
508            command.arg(path);
509            let status = command.status().map_err(|err| {
510                format!(
511                    "could not open `{}` with `{program}`: {err}",
512                    display_path(path)
513                )
514            })?;
515            if status.success() {
516                Ok(())
517            } else {
518                Err(format!(
519                    "opener `{program}` failed for `{}` with {status}",
520                    display_path(path)
521                ))
522            }
523        }
524    }
525}
526
527/// A [`DiagnosticSink`] that renders each diagnostic to stderr as it
528/// arrives and tracks error/warning counts. The CLI drives one of these
529/// across every phase and checks [`Self::had_error`] at each phase
530/// barrier; that, not `Severity::Error` itself, is what stops the build.
531struct RenderingSink<'a> {
532    src: &'a str,
533    errors: usize,
534    warnings: usize,
535}
536
537impl<'a> RenderingSink<'a> {
538    const fn new(src: &'a str) -> Self {
539        Self {
540            src,
541            errors: 0,
542            warnings: 0,
543        }
544    }
545
546    const fn had_error(&self) -> bool {
547        self.errors > 0
548    }
549
550    /// Render every diagnostic in `diags`. Bridges phases that still
551    /// return a `Vec<Diagnostic>` (layout, PDF emit) into the sink.
552    fn render_all(&mut self, diags: impl IntoIterator<Item = Diagnostic>) {
553        for diag in diags {
554            let _ = self.emit(diag);
555        }
556    }
557}
558
559impl DiagnosticSink for RenderingSink<'_> {
560    fn emit(&mut self, diagnostic: Diagnostic) -> DiagnosticResult<()> {
561        match diagnostic.severity() {
562            Severity::Error => self.errors += 1,
563            Severity::Warning => self.warnings += 1,
564            Severity::Notice => {}
565        }
566        render_diagnostic(&diagnostic, self.src);
567        Ok(())
568    }
569}
570
571const fn severity_label(s: Severity) -> &'static str {
572    match s {
573        Severity::Error => "error",
574        Severity::Warning => "warning",
575        Severity::Notice => "notice",
576    }
577}
578
579fn render_diagnostic(diag: &Diagnostic, src: &str) {
580    let label = severity_label(diag.severity());
581    let code = format!("{} ({})", diag.def().id(), diag.def().code());
582    if let Some(span) = diag.span() {
583        let (line, col) = linecol(src, span.start());
584        eprintln!(
585            "{label}[{code}]: {msg}\n  --> {file}:{line}:{col}",
586            msg = diag.message(),
587            file = display_path(&span.file),
588        );
589        render_span_caret(src, span);
590    } else {
591        eprintln!("{label}[{code}]: {msg}", msg = diag.message());
592    }
593    for annotation in diag.annotations() {
594        match annotation {
595            DiagnosticAnnotation::Related { span, message } => {
596                let (line, col) = linecol(src, span.start());
597                eprintln!(
598                    "  note: {message} ({file}:{line}:{col})",
599                    file = display_path(&span.file),
600                );
601            }
602            DiagnosticAnnotation::Note(message) => eprintln!("  note: {message}"),
603            DiagnosticAnnotation::Help(message) => eprintln!("  help: {message}"),
604            DiagnosticAnnotation::Hint(message) => eprintln!("  hint: {message}"),
605        }
606    }
607    for suggestion in diag.suggestions() {
608        render_suggestion(src, suggestion);
609    }
610}
611
612fn render_suggestion(src: &str, suggestion: &Suggestion) {
613    eprintln!("  help: {}", suggestion_help(src, suggestion));
614}
615
616fn suggestion_help(src: &str, suggestion: &Suggestion) -> String {
617    let (line, col) = linecol(src, suggestion.span.start());
618    let file = display_path(&suggestion.span.file);
619    // The deletion arm carries an empty replacement, so the replacement text
620    // is formatted only in the arms that actually print it.
621    match suggestion_text(src, &suggestion.span) {
622        Some("") => {
623            let replacement = display_edit_text(&suggestion.replacement);
624            format!("insert `{replacement}` at {file}:{line}:{col}")
625        }
626        Some(text) if suggestion.replacement.is_empty() => {
627            let text = display_edit_text(text);
628            format!("delete `{text}` at {file}:{line}:{col}")
629        }
630        Some(text) => {
631            let text = display_edit_text(text);
632            let replacement = display_edit_text(&suggestion.replacement);
633            format!("replace `{text}` with `{replacement}` at {file}:{line}:{col}")
634        }
635        None => {
636            let replacement = display_edit_text(&suggestion.replacement);
637            format!("replace text with `{replacement}` at {file}:{line}:{col}")
638        }
639    }
640}
641
642fn suggestion_text<'a>(src: &'a str, span: &SourceSpan) -> Option<&'a str> {
643    let start = clamp_to_char_boundary(src, span.start().min(src.len()));
644    let end = clamp_to_char_boundary(src, span.end().min(src.len()));
645    src.get(start..end)
646}
647
648fn display_edit_text(text: &str) -> String {
649    let mut out = String::with_capacity(text.len());
650    for ch in text.chars() {
651        match ch {
652            '\n' => out.push_str("\\n"),
653            '\r' => out.push_str("\\r"),
654            '\t' => out.push_str("\\t"),
655            other if other.is_control() => out.extend(other.escape_unicode()),
656            other => out.push(other),
657        }
658    }
659    out
660}
661
662fn clamp_to_char_boundary(src: &str, mut offset: usize) -> usize {
663    offset = offset.min(src.len());
664    while offset > 0 && !src.is_char_boundary(offset) {
665        offset -= 1;
666    }
667    offset
668}
669
670fn render_span_caret(src: &str, span: &SourceSpan) {
671    let (line_no, col) = linecol(src, span.start());
672    let span_start = clamp_to_char_boundary(src, span.start());
673    let line_start = src[..span_start].rfind('\n').map_or(0, |p| p + 1);
674    let raw_line_end = src[line_start..]
675        .find('\n')
676        .map_or(src.len(), |p| line_start + p);
677    // CRLF sources keep the trailing `\r` inside `[line_start, '\n')`;
678    // strip it so the caret line lines up with what stderr actually
679    // prints.
680    let line_end = if raw_line_end > line_start && src.as_bytes()[raw_line_end - 1] == b'\r' {
681        raw_line_end - 1
682    } else {
683        raw_line_end
684    };
685    let line_text = &src[line_start..line_end];
686    // Convert byte offsets into char counts so multibyte UTF-8
687    // sequences (e.g. `µ`, `é`) line up with the source above. Clamp
688    // both ends to char boundaries first; otherwise a span that
689    // straddles a multibyte sequence would panic the slice below.
690    let span_byte_end = clamp_to_char_boundary(src, span.end().min(line_end));
691    let span_byte_start = clamp_to_char_boundary(src, span_start.min(span_byte_end));
692    let caret_chars = src[span_byte_start..span_byte_end].chars().count().max(1);
693    eprintln!("   |");
694    eprintln!("{line_no:>3}| {line_text}");
695    eprintln!(
696        "   | {pad}{carets}",
697        pad = " ".repeat(col.saturating_sub(1)),
698        carets = "^".repeat(caret_chars),
699    );
700}
701
702#[cfg(test)]
703mod tests {
704    use std::path::PathBuf;
705
706    use mos_core::{SourceSpan, Suggestion};
707
708    use super::{PdfOpen, display_edit_text, suggestion_help};
709
710    #[test]
711    fn display_edit_text_escapes_control_characters_and_keeps_backslashes() {
712        assert_eq!(display_edit_text("a\u{1b}[31mb"), "a\\u{1b}[31mb");
713        assert_eq!(display_edit_text("x\u{7f}\u{0}y"), "x\\u{7f}\\u{0}y");
714        assert_eq!(display_edit_text("assets\\logo.png"), "assets\\logo.png");
715        assert_eq!(display_edit_text("a\nb\tc\rd"), "a\\nb\\tc\\rd");
716    }
717
718    #[test]
719    fn pdf_open_from_cli_distinguishes_absent_default_and_program() {
720        assert!(matches!(PdfOpen::from_cli(None), PdfOpen::No));
721
722        assert!(matches!(PdfOpen::from_cli(Some("")), PdfOpen::Default));
723
724        assert!(matches!(
725            PdfOpen::from_cli(Some("zathura")),
726            PdfOpen::Program("zathura")
727        ));
728    }
729
730    #[test]
731    fn suggestion_help_formats_replace_delete_insert_and_stale_spans() {
732        let file = PathBuf::from("main.mos");
733        let src = "see @bad\n";
734
735        assert_eq!(
736            suggestion_help(
737                src,
738                &Suggestion::new(SourceSpan::new(file.clone(), 4, 8), "@good")
739            ),
740            "replace `@bad` with `@good` at main.mos:1:5"
741        );
742        assert_eq!(
743            suggestion_help(
744                src,
745                &Suggestion::new(SourceSpan::new(file.clone(), 4, 8), "")
746            ),
747            "delete `@bad` at main.mos:1:5"
748        );
749        assert_eq!(
750            suggestion_help(
751                src,
752                &Suggestion::new(SourceSpan::new(file.clone(), src.len(), src.len()), "!")
753            ),
754            "insert `!` at main.mos:2:1"
755        );
756        assert_eq!(
757            suggestion_help(
758                src,
759                &Suggestion::new(SourceSpan::new(file, src.len() + 10, src.len() + 20), "!")
760            ),
761            "insert `!` at main.mos:2:1"
762        );
763    }
764}