Skip to main content

mos/
main.rs

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