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