1#![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
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(long, value_name = "PROGRAM", num_args = 0..=1, require_equals = true)]
64 open: Option<Option<String>>,
65 #[arg(long)]
67 frozen: bool,
68 #[arg(long)]
70 reproducible: bool,
71 },
72
73 Watch {
75 #[arg(default_value = "main.mos")]
76 entry: PathBuf,
77 },
78
79 Check {
81 #[arg(value_name = "PATH")]
82 entries: Vec<PathBuf>,
83 },
84
85 Fmt {
87 #[arg(default_value = ".")]
88 path: PathBuf,
89 },
90
91 Test,
93
94 Profile {
96 #[arg(default_value = "main.mos")]
97 entry: PathBuf,
98 },
99
100 Clean,
102
103 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
183fn 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 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 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
230fn 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 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 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 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
471struct 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 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 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 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 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}