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_batch_entry(entry, many) {
165 continue;
166 }
167 ran = true;
168 if run_one(entry) != ExitCode::SUCCESS {
169 failed = true;
170 }
171 }
172
173 if failed || !ran {
174 ExitCode::FAILURE
175 } else {
176 ExitCode::SUCCESS
177 }
178}
179
180fn should_skip_batch_entry(entry: &Path, many: bool) -> bool {
181 many && ((entry.is_file() && !is_mos_source(entry))
182 || (entry.is_dir()
183 && matches!(entry.join("mosaic.toml").try_exists(), Ok(false))
184 && matches!(entry.join("main.mos").try_exists(), Ok(false))))
185}
186
187fn is_mos_source(entry: &Path) -> bool {
188 entry.extension().is_some_and(|ext| ext == "mos")
189}
190
191fn unimplemented_subcommand(name: &str) -> ExitCode {
192 eprintln!("mos {name}: not yet implemented (see manifest §30 MVP roadmap)");
193 ExitCode::FAILURE
194}
195
196fn run_check(entry: &Path) -> ExitCode {
199 let Ok(entry) = resolve_entry("check", entry).map(|entry| entry.source) else {
200 return ExitCode::FAILURE;
201 };
202 let src = match std::fs::read_to_string(&entry) {
203 Ok(s) => s,
204 Err(err) => {
205 eprintln!("mos check: cannot read `{}`: {err}", display_path(&entry));
206 return ExitCode::FAILURE;
207 }
208 };
209
210 let mut sink = RenderingSink::new(&src);
211
212 let Ok(tree) = mos_parse::parse(&src, &entry, &mut sink) else {
216 return ExitCode::FAILURE;
217 };
218 if sink.had_error() {
219 eprintln!(
220 "mos check: {} error(s), {} warning(s)",
221 sink.errors, sink.warnings
222 );
223 return ExitCode::FAILURE;
224 }
225
226 let result = mos_eval::lower_tree(&tree);
228 let node_count = result.document.len();
229 sink.render_all(result.diagnostics);
230
231 if sink.had_error() {
232 eprintln!(
233 "mos check: {} error(s), {} warning(s)",
234 sink.errors, sink.warnings
235 );
236 ExitCode::FAILURE
237 } else {
238 println!("ok: {node_count} node(s), {} warning(s)", sink.warnings);
239 ExitCode::SUCCESS
240 }
241}
242
243fn run_build(entry: &Path, open: PdfOpen<'_>, debug_layout: bool) -> ExitCode {
249 let Ok(resolved) = resolve_entry("build", entry) else {
250 return ExitCode::FAILURE;
251 };
252 let entry = resolved.source;
253 let src = match std::fs::read_to_string(&entry) {
254 Ok(s) => s,
255 Err(err) => {
256 eprintln!("mos build: cannot read `{}`: {err}", display_path(&entry));
257 return ExitCode::FAILURE;
258 }
259 };
260
261 let started = std::time::Instant::now();
262 let mut sink = RenderingSink::new(&src);
263
264 let Ok(tree) = mos_parse::parse(&src, &entry, &mut sink) else {
268 return ExitCode::FAILURE;
269 };
270 if sink.had_error() {
271 return ExitCode::FAILURE;
272 }
273
274 let result = mos_eval::lower_tree(&tree);
275 sink.render_all(result.diagnostics);
276 if sink.had_error() {
277 return ExitCode::FAILURE;
278 }
279
280 let mut document = result.document;
286 let (page_outcome, layout) = mos_eval::resolve_page_reference_fixpoint(
287 &mut document,
288 |doc| {
289 let engine = mos_layout::LayoutEngine::new();
290 let layout = if debug_layout {
291 engine.layout_with_debug(doc)
292 } else {
293 engine.layout(doc)
294 };
295 (layout.label_pages.clone(), layout)
296 },
297 MAX_PAGE_FIXPOINT_ITERATIONS,
298 );
299 if let mos_eval::PageFixpointOutcome::NotConverged { iterations } = page_outcome {
300 let _ = sink.emit(Diagnostic::simple(
301 &mos_core::codes::MOS0047,
302 None,
303 format!(
304 "page references did not converge after {iterations} layout iterations; \
305 using the last computed page numbers"
306 ),
307 ));
308 }
309
310 sink.render_all(layout.diagnostics);
315 if sink.had_error() {
316 return ExitCode::FAILURE;
317 }
318
319 let stem = entry.file_stem().map_or_else(
320 || std::ffi::OsString::from("out"),
321 std::ffi::OsStr::to_os_string,
322 );
323 let out = resolved.output.unwrap_or_else(|| {
324 let mut path = resolved.output_base.join("build");
325 path.push(format!("{}.pdf", stem.to_string_lossy()));
326 path
327 });
328
329 let metadata = mos_pdf::PdfMetadata {
330 title: result.metadata.title.clone(),
331 author: result.metadata.author.clone(),
332 language: result.metadata.language,
333 };
334 match mos_pdf::emit(&layout.graph, &metadata, &out) {
335 Ok(pdf_diagnostics) => {
336 sink.render_all(pdf_diagnostics);
337 if sink.had_error() {
338 return ExitCode::FAILURE;
339 }
340 }
341 Err(err) => {
342 match err {
343 mos_core::CoreError::Diagnostic(d) => {
344 let _ = sink.emit(*d);
345 }
346 mos_core::CoreError::Unimplemented(msg) => {
347 eprintln!("mos build: {msg}");
348 }
349 }
350 return ExitCode::FAILURE;
351 }
352 }
353
354 let mut viewer_out = out.clone();
355 if let Some(report) = &layout.debug {
356 let debug_out = out.with_extension("layout.json");
357 let data = match serde_json::to_vec_pretty(report) {
358 Ok(mut data) => {
359 data.push(b'\n');
360 data
361 }
362 Err(err) => {
363 eprintln!("mos build: cannot encode layout debug report: {err}");
364 return ExitCode::FAILURE;
365 }
366 };
367 if let Err(err) = std::fs::write(&debug_out, data) {
368 eprintln!(
369 "mos build: cannot write layout debug report to {}: {err}",
370 display_path(&debug_out)
371 );
372 return ExitCode::FAILURE;
373 }
374 println!("wrote {}", display_path(&debug_out));
375 viewer_out = out.with_extension("layout.pdf");
376 if let Err(err) = mos_pdf::emit_debug(&layout.graph, report, &metadata, &viewer_out) {
377 match err {
380 mos_core::CoreError::Diagnostic(d) => {
381 let _ = sink.emit(*d);
382 }
383 mos_core::CoreError::Unimplemented(msg) => eprintln!("mos build: {msg}"),
384 }
385 return ExitCode::FAILURE;
386 }
387 println!("wrote {}", display_path(&viewer_out));
388 }
389
390 println!(
391 "wrote {} in {} ms",
392 display_path(&out),
393 started.elapsed().as_millis()
394 );
395 if open.should_open() {
396 match open_pdf(&viewer_out, open) {
397 Ok(()) => println!("opened {}", display_path(&viewer_out)),
398 Err(err) => {
399 eprintln!("mos build: {err}");
400 return ExitCode::FAILURE;
401 }
402 }
403 }
404 ExitCode::SUCCESS
405}
406
407struct ResolvedEntry {
408 source: PathBuf,
409 output_base: PathBuf,
410 output: Option<PathBuf>,
411}
412
413fn resolve_entry(command: &str, entry: &Path) -> Result<ResolvedEntry, ()> {
414 if !entry.is_dir() {
415 let output_base = entry
416 .parent()
417 .unwrap_or_else(|| Path::new("."))
418 .to_path_buf();
419 return Ok(ResolvedEntry {
420 source: entry.to_path_buf(),
421 output_base,
422 output: None,
423 });
424 }
425
426 let manifest_path = entry.join("mosaic.toml");
427 if manifest_path.is_file() {
428 let manifest = match mos_packages::ProjectManifest::load(&manifest_path) {
429 Ok(manifest) => manifest,
430 Err(err) => {
431 eprintln!("mos {command}: {err}");
432 return Err(());
433 }
434 };
435 let source = mos_core::resolve_relative(entry, &manifest.project.entry).map_err(|err| {
436 eprintln!(
437 "mos {command}: invalid project entry path `{}`: {err}",
438 manifest.project.entry
439 );
440 })?;
441 return Ok(ResolvedEntry {
442 source,
443 output_base: entry.to_path_buf(),
444 output: match manifest.output.pdf.as_deref() {
445 Some(path) => Some(resolve_manifest_output(command, entry, path)?),
446 None => None,
447 },
448 });
449 }
450
451 Ok(ResolvedEntry {
452 source: entry.join("main.mos"),
453 output_base: entry.to_path_buf(),
454 output: None,
455 })
456}
457
458fn resolve_manifest_output(command: &str, project_dir: &Path, output: &str) -> Result<PathBuf, ()> {
459 let output_path = Path::new(output);
460 if output_path.as_os_str().is_empty()
461 || output_path.components().any(|component| {
462 matches!(
463 component,
464 Component::ParentDir | Component::RootDir | Component::Prefix(_)
465 )
466 })
467 {
468 eprintln!(
469 "mos {command}: invalid PDF output path `{output}`; use a relative path inside the project"
470 );
471 return Err(());
472 }
473 mos_core::resolve_relative(project_dir, output).map_err(|err| {
474 eprintln!("mos {command}: invalid PDF output path `{output}`: {err}");
475 })
476}
477
478#[derive(Debug, Clone, Copy)]
479enum PdfOpen<'a> {
480 No,
481 Default,
482 Program(&'a str),
483}
484
485impl<'a> PdfOpen<'a> {
486 const fn from_cli(open: Option<&'a str>) -> Self {
487 match open {
488 None => Self::No,
489 Some(program) => {
490 if program.is_empty() {
491 Self::Default
492 } else {
493 Self::Program(program)
494 }
495 }
496 }
497 }
498
499 const fn should_open(self) -> bool {
500 !matches!(self, Self::No)
501 }
502}
503
504fn open_pdf(path: &Path, request: PdfOpen<'_>) -> Result<(), String> {
505 match request {
506 PdfOpen::No => Ok(()),
507 PdfOpen::Default => opener::open(path.as_os_str())
508 .map_err(|err| format!("could not open `{}`: {err}", display_path(path))),
509 PdfOpen::Program(program) => {
510 let mut command = ProcessCommand::new(program);
511 command.arg(path);
512 let status = command.status().map_err(|err| {
513 format!(
514 "could not open `{}` with `{program}`: {err}",
515 display_path(path)
516 )
517 })?;
518 if status.success() {
519 Ok(())
520 } else {
521 Err(format!(
522 "opener `{program}` failed for `{}` with {status}",
523 display_path(path)
524 ))
525 }
526 }
527 }
528}
529
530struct RenderingSink<'a> {
535 src: &'a str,
536 errors: usize,
537 warnings: usize,
538}
539
540impl<'a> RenderingSink<'a> {
541 const fn new(src: &'a str) -> Self {
542 Self {
543 src,
544 errors: 0,
545 warnings: 0,
546 }
547 }
548
549 const fn had_error(&self) -> bool {
550 self.errors > 0
551 }
552
553 fn render_all(&mut self, diags: impl IntoIterator<Item = Diagnostic>) {
556 for diag in diags {
557 let _ = self.emit(diag);
558 }
559 }
560}
561
562impl DiagnosticSink for RenderingSink<'_> {
563 fn emit(&mut self, diagnostic: Diagnostic) -> DiagnosticResult<()> {
564 match diagnostic.severity() {
565 Severity::Error => self.errors += 1,
566 Severity::Warning => self.warnings += 1,
567 Severity::Notice => {}
568 }
569 render_diagnostic(&diagnostic, self.src);
570 Ok(())
571 }
572}
573
574const fn severity_label(s: Severity) -> &'static str {
575 match s {
576 Severity::Error => "error",
577 Severity::Warning => "warning",
578 Severity::Notice => "notice",
579 }
580}
581
582fn render_diagnostic(diag: &Diagnostic, src: &str) {
583 let label = severity_label(diag.severity());
584 let code = format!("{} ({})", diag.def().id(), diag.def().code());
585 if let Some(span) = diag.span() {
586 let (line, col) = linecol(src, span.start());
587 eprintln!(
588 "{label}[{code}]: {msg}\n --> {file}:{line}:{col}",
589 msg = diag.message(),
590 file = display_path(&span.file),
591 );
592 render_span_caret(src, span);
593 } else {
594 eprintln!("{label}[{code}]: {msg}", msg = diag.message());
595 }
596 for annotation in diag.annotations() {
597 match annotation {
598 DiagnosticAnnotation::Related { span, message } => {
599 let (line, col) = linecol(src, span.start());
600 eprintln!(
601 " note: {message} ({file}:{line}:{col})",
602 file = display_path(&span.file),
603 );
604 }
605 DiagnosticAnnotation::Note(message) => eprintln!(" note: {message}"),
606 DiagnosticAnnotation::Help(message) => eprintln!(" help: {message}"),
607 DiagnosticAnnotation::Hint(message) => eprintln!(" hint: {message}"),
608 }
609 }
610 for suggestion in diag.suggestions() {
611 render_suggestion(src, suggestion);
612 }
613}
614
615fn render_suggestion(src: &str, suggestion: &Suggestion) {
616 eprintln!(" help: {}", suggestion_help(src, suggestion));
617}
618
619fn suggestion_help(src: &str, suggestion: &Suggestion) -> String {
620 let (line, col) = linecol(src, suggestion.span.start());
621 let file = display_path(&suggestion.span.file);
622 match suggestion_text(src, &suggestion.span) {
625 Some("") => {
626 let replacement = display_edit_text(&suggestion.replacement);
627 format!("insert `{replacement}` at {file}:{line}:{col}")
628 }
629 Some(text) if suggestion.replacement.is_empty() => {
630 let text = display_edit_text(text);
631 format!("delete `{text}` at {file}:{line}:{col}")
632 }
633 Some(text) => {
634 let text = display_edit_text(text);
635 let replacement = display_edit_text(&suggestion.replacement);
636 format!("replace `{text}` with `{replacement}` at {file}:{line}:{col}")
637 }
638 None => {
639 let replacement = display_edit_text(&suggestion.replacement);
640 format!("replace text with `{replacement}` at {file}:{line}:{col}")
641 }
642 }
643}
644
645fn suggestion_text<'a>(src: &'a str, span: &SourceSpan) -> Option<&'a str> {
646 let start = clamp_to_char_boundary(src, span.start().min(src.len()));
647 let end = clamp_to_char_boundary(src, span.end().min(src.len()));
648 src.get(start..end)
649}
650
651fn display_edit_text(text: &str) -> String {
652 let mut out = String::with_capacity(text.len());
653 for ch in text.chars() {
654 match ch {
655 '\n' => out.push_str("\\n"),
656 '\r' => out.push_str("\\r"),
657 '\t' => out.push_str("\\t"),
658 other if other.is_control() => out.extend(other.escape_unicode()),
659 other => out.push(other),
660 }
661 }
662 out
663}
664
665fn clamp_to_char_boundary(src: &str, mut offset: usize) -> usize {
666 offset = offset.min(src.len());
667 while offset > 0 && !src.is_char_boundary(offset) {
668 offset -= 1;
669 }
670 offset
671}
672
673fn render_span_caret(src: &str, span: &SourceSpan) {
674 let (line_no, col) = linecol(src, span.start());
675 let span_start = clamp_to_char_boundary(src, span.start());
676 let line_start = src[..span_start].rfind('\n').map_or(0, |p| p + 1);
677 let raw_line_end = src[line_start..]
678 .find('\n')
679 .map_or(src.len(), |p| line_start + p);
680 let line_end = if raw_line_end > line_start && src.as_bytes()[raw_line_end - 1] == b'\r' {
684 raw_line_end - 1
685 } else {
686 raw_line_end
687 };
688 let line_text = &src[line_start..line_end];
689 let span_byte_end = clamp_to_char_boundary(src, span.end().min(line_end));
694 let span_byte_start = clamp_to_char_boundary(src, span_start.min(span_byte_end));
695 let caret_chars = src[span_byte_start..span_byte_end].chars().count().max(1);
696 eprintln!(" |");
697 eprintln!("{line_no:>3}| {line_text}");
698 eprintln!(
699 " | {pad}{carets}",
700 pad = " ".repeat(col.saturating_sub(1)),
701 carets = "^".repeat(caret_chars),
702 );
703}
704
705#[cfg(test)]
706mod tests {
707 use std::path::PathBuf;
708
709 use mos_core::{SourceSpan, Suggestion};
710
711 use super::{PdfOpen, display_edit_text, suggestion_help};
712
713 #[test]
714 fn display_edit_text_escapes_control_characters_and_keeps_backslashes() {
715 assert_eq!(display_edit_text("a\u{1b}[31mb"), "a\\u{1b}[31mb");
716 assert_eq!(display_edit_text("x\u{7f}\u{0}y"), "x\\u{7f}\\u{0}y");
717 assert_eq!(display_edit_text("assets\\logo.png"), "assets\\logo.png");
718 assert_eq!(display_edit_text("a\nb\tc\rd"), "a\\nb\\tc\\rd");
719 }
720
721 #[test]
722 fn pdf_open_from_cli_distinguishes_absent_default_and_program() {
723 assert!(matches!(PdfOpen::from_cli(None), PdfOpen::No));
724
725 assert!(matches!(PdfOpen::from_cli(Some("")), PdfOpen::Default));
726
727 assert!(matches!(
728 PdfOpen::from_cli(Some("zathura")),
729 PdfOpen::Program("zathura")
730 ));
731 }
732
733 #[test]
734 fn suggestion_help_formats_replace_delete_insert_and_stale_spans() {
735 let file = PathBuf::from("main.mos");
736 let src = "see @bad\n";
737
738 assert_eq!(
739 suggestion_help(
740 src,
741 &Suggestion::new(SourceSpan::new(file.clone(), 4, 8), "@good")
742 ),
743 "replace `@bad` with `@good` at main.mos:1:5"
744 );
745 assert_eq!(
746 suggestion_help(
747 src,
748 &Suggestion::new(SourceSpan::new(file.clone(), 4, 8), "")
749 ),
750 "delete `@bad` at main.mos:1:5"
751 );
752 assert_eq!(
753 suggestion_help(
754 src,
755 &Suggestion::new(SourceSpan::new(file.clone(), src.len(), src.len()), "!")
756 ),
757 "insert `!` at main.mos:2:1"
758 );
759 assert_eq!(
760 suggestion_help(
761 src,
762 &Suggestion::new(SourceSpan::new(file, src.len() + 10, src.len() + 20), "!")
763 ),
764 "insert `!` at main.mos:2:1"
765 );
766 }
767}