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 frozen: bool,
74 #[arg(long)]
76 reproducible: bool,
77 },
78
79 Watch {
81 #[arg(default_value = "main.mos")]
82 entry: PathBuf,
83 },
84
85 Check {
87 #[arg(value_name = "PATH")]
88 entries: Vec<PathBuf>,
89 },
90
91 Fmt {
93 #[arg(default_value = ".")]
94 path: PathBuf,
95 },
96
97 Test,
99
100 Profile {
102 #[arg(default_value = "main.mos")]
103 entry: PathBuf,
104 },
105
106 Clean,
108
109 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
189fn 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 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 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
236fn 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 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 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 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
481struct 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 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 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 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 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}