amarok_cli/
main.rs

1//! Command-line entry point for Amarok.
2//!
3//! Parses arguments, reads the source file, registers it with a
4//! [`SourceMap`], invokes [`amarok_parser`] then [`amarok_interpreter`],
5//! and renders any [`Diagnostic`] with line and column information.
6
7use amarok_syntax::{Diagnostic, FileId, SourceMap};
8use std::env;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12const USAGE: &str = "Usage: amarok_cli [--no-std] [--stdlib <dir>] <path-to-file.amarok>";
13
14fn main() {
15    if let Err(message) = run() {
16        eprintln!("{message}");
17        std::process::exit(1);
18    }
19}
20
21fn run() -> Result<(), String> {
22    let mut arguments = env::args().skip(1);
23
24    let mut no_std = false;
25    let mut stdlib_override: Option<PathBuf> = None;
26    let mut path: Option<String> = None;
27
28    while let Some(argument) = arguments.next() {
29        match argument.as_str() {
30            "--no-std" => no_std = true,
31            "--stdlib" => {
32                let value = arguments
33                    .next()
34                    .ok_or_else(|| format!("--stdlib requires a directory.\n{USAGE}"))?;
35                stdlib_override = Some(PathBuf::from(value));
36            }
37            "--" => {
38                path = arguments.next();
39                break;
40            }
41            other if other.starts_with("--") => {
42                return Err(format!("Unknown option: {other}\n{USAGE}"));
43            }
44            _ => {
45                path = Some(argument);
46                break;
47            }
48        }
49    }
50
51    let Some(path) = path else {
52        return Err(format!("Missing source file.\n{USAGE}"));
53    };
54
55    if arguments.next().is_some() {
56        return Err(format!("Too many arguments.\n{USAGE}"));
57    }
58
59    let entry_path = PathBuf::from(&path);
60    let source = fs::read_to_string(&entry_path)
61        .map_err(|error| format!("Amarok error: Failed to read file {path}: {error}"))?;
62
63    // Build the SourceMap and register the entry file before parsing so its
64    // spans carry the right FileId for later diagnostics.
65    let mut source_map = SourceMap::new();
66    let entry_canonical = entry_path
67        .canonicalize()
68        .unwrap_or_else(|_| entry_path.clone());
69    let entry_file_id = source_map.add_file(entry_canonical.clone(), source.clone());
70
71    let program = match amarok_parser::parse_program_with_file_id(&source, entry_file_id) {
72        Ok(program) => program,
73        Err(error) => {
74            return Err(format!(
75                "Amarok error: {}",
76                render_diagnostic(&source_map, &entry_path, &source, &error)
77            ));
78        }
79    };
80
81    let mut interpreter = if no_std {
82        amarok_interpreter::Interpreter::new_no_std()
83    } else {
84        amarok_interpreter::Interpreter::new()
85    };
86
87    // Module search roots: project root (entry file's directory), then stdlib.
88    let project_root = entry_canonical
89        .parent()
90        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
91    interpreter.add_module_root(project_root);
92
93    if let Some(dir) = stdlib_override
94        .or_else(|| env::var_os("AMAROK_STDLIB").map(PathBuf::from))
95        .or_else(default_stdlib_dir)
96    {
97        interpreter.add_module_root(dir);
98    }
99
100    interpreter.set_source_map(source_map);
101
102    let result = interpreter.run_program(&program);
103
104    // Always print whatever was produced before the error (or program end).
105    for line in interpreter.output_lines() {
106        println!("{line}");
107    }
108
109    if let Err(error) = result {
110        let source_map = interpreter.source_map();
111        return Err(format!(
112            "Amarok error: {}",
113            render_diagnostic(source_map, &entry_path, &source, &error)
114        ));
115    }
116
117    Ok(())
118}
119
120fn default_stdlib_dir() -> Option<PathBuf> {
121    // Dev convenience: <repo>/stdlib relative to this crate's manifest.
122    let manifest_dir = env!("CARGO_MANIFEST_DIR");
123    let candidate = Path::new(manifest_dir).join("..").join("..").join("stdlib");
124    if candidate.is_dir() {
125        Some(candidate)
126    } else {
127        None
128    }
129}
130
131// --- error rendering helpers ---
132
133fn line_col_from_offset(source: &str, offset: usize) -> (usize, usize) {
134    let mut line = 1usize;
135    let mut col = 1usize;
136
137    for (index, ch) in source.char_indices() {
138        if index >= offset {
139            break;
140        }
141        if ch == '\n' {
142            line += 1;
143            col = 1;
144        } else {
145            col += 1;
146        }
147    }
148
149    (line, col)
150}
151
152fn line_text_and_line_start(source: &str, line_number: usize) -> Option<(&str, usize)> {
153    let mut current_line = 1usize;
154    let mut line_start = 0usize;
155
156    for (index, ch) in source.char_indices() {
157        if current_line == line_number && ch == '\n' {
158            return Some((&source[line_start..index], line_start));
159        }
160        if ch == '\n' {
161            current_line += 1;
162            line_start = index + 1;
163        }
164    }
165
166    if current_line == line_number {
167        Some((&source[line_start..], line_start))
168    } else {
169        None
170    }
171}
172
173fn render_diagnostic(
174    source_map: &SourceMap,
175    entry_path: &Path,
176    entry_source: &str,
177    diagnostic: &Diagnostic,
178) -> String {
179    let Some(span) = diagnostic.span else {
180        return format!("{}: {}\n", entry_path.display(), diagnostic.message);
181    };
182
183    // Resolve the file the span belongs to: prefer the source map; fall back
184    // to the entry file (covers spans without a registered FileId).
185    let (path_display, source_text): (String, &str) =
186        if span.file_id != FileId::DUMMY && source_map.get(span.file_id).is_some() {
187            let file = source_map.get(span.file_id).unwrap();
188            (file.path.display().to_string(), file.source.as_str())
189        } else {
190            (entry_path.display().to_string(), entry_source)
191        };
192
193    let (line, col) = line_col_from_offset(source_text, span.start);
194    let mut output = format!("{path_display}:{line}:{col}: {}\n", diagnostic.message);
195
196    if let Some((line_text, line_start)) = line_text_and_line_start(source_text, line) {
197        output.push_str(line_text);
198        output.push('\n');
199
200        let caret_start = span.start.saturating_sub(line_start);
201        let caret_end = span.end.saturating_sub(line_start).max(caret_start + 1);
202
203        output.push_str(&" ".repeat(caret_start));
204        output.push_str(&"^".repeat(caret_end - caret_start));
205        output.push('\n');
206    }
207
208    output
209}