1#![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
27const 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 Init {
51 #[arg(default_value = ".")]
52 path: PathBuf,
53 },
54
55 Build {
57 #[arg(value_name = "PATH")]
58 entries: Vec<PathBuf>,
59 #[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 #[arg(long)]
73 debug_layout: bool,
74 #[arg(long)]
76 frozen: bool,
77 #[arg(long)]
79 reproducible: bool,
80 },
81
82 Watch {
84 #[arg(default_value = "main.mos")]
85 entry: PathBuf,
86 },
87
88 Check {
90 #[arg(value_name = "PATH")]
91 entries: Vec<PathBuf>,
92 },
93
94 Fmt {
96 #[arg(default_value = ".")]
97 path: PathBuf,
98 },
99
100 Test,
102
103 Profile {
105 #[arg(default_value = "main.mos")]
106 entry: PathBuf,
107 },
108
109 Clean,
111
112 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
193fn 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 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 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
240fn 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 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 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 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 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
527struct 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 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 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 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 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}