Something went wrong. Try again.
The Monad language. Dependent types, functional programming compiled with LLVM. Hobby project. monad-lang.org
dependent-types language compiler programming-language functional-programming
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215mod lsp;mod mcp;
/// The default system allocator is the wrong fit for this binary's/// dominant workload. Interpreting `cli/src/main.mo` (the self-hosted/// compiler) is allocation-bound by construction: `core_value.rs`'s/// `Env::extend` allocates an `Arc` frame per beta reduction, and every/// constructor is an `Arc<ConArgs>` -- a callgrind profile put allocator/// churn at ~42% of eval time. Measured A/B before keeping (see/// `plans/implementations/2026-09-09-compiler-optimization.md`, item B1).#[global_allocator]static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use std::path::{Path, PathBuf};
use clap::{Parser, Subcommand};use monad_core::{ FileTestResult, SymbolInfo, SymbolKind, TestOutcome, check_files, diag::{Diagnostic, Severity, render_diagnostics}, eval::EvalOptions, organize_imports_for_files, run, run_tests, run_tests_for_files, symbols_for_files, term::mote::{Manifest, Resolver},};
#[cfg(feature = "repl")]use monad_core::repl;
#[derive(Subcommand, Debug)]enum Commands { Repl { #[arg(short, long, default_value_t = false)] debug: bool, #[arg(long, default_value_t = false)] benchmark: bool, #[arg(long, default_value_t = false, overrides_with = "no_color")] color: bool, #[arg(long = "no-color", default_value_t = false)] no_color: bool, #[arg(long)] max_depth: Option<u64>, },
Run { #[arg(value_name = "FILE")] input: PathBuf, #[arg(short, long, default_value_t = false)] debug: bool, #[arg(long, default_value_t = false)] benchmark: bool, #[arg(value_name = "ARGS", trailing_var_arg = true)] args: Vec<String>, #[arg(long, default_value_t = false, overrides_with = "no_color")] color: bool, #[arg(long = "no-color", default_value_t = false)] no_color: bool, #[arg(long)] max_depth: Option<u64>, #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },
Test { #[arg(value_name = "PATHS", num_args = 1..)] inputs: Vec<PathBuf>, /// Emit machine-readable JSON (for editor/agent integrations) instead /// of the default human-readable PASS/FAIL text report. #[arg(long, default_value_t = false)] json: bool, #[arg(short, long, default_value_t = false)] debug: bool, #[arg(long, default_value_t = false)] benchmark: bool, #[arg(long, default_value_t = false, overrides_with = "no_color")] color: bool, #[arg(long = "no-color", default_value_t = false)] no_color: bool, #[arg(long)] max_depth: Option<u64>, #[arg(long)] timeout: Option<f64>, #[arg(short = 'j', long)] jobs: Option<usize>, #[arg(long, default_value_t = false)] sequential: bool, #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },
Check { /// Files or directories to check. Directories are scanned recursively /// for `.mo` files. Defaults to the current directory if omitted. #[arg(value_name = "PATHS")] inputs: Vec<PathBuf>, /// Emit machine-readable JSON (for editor/agent integrations) instead /// of the default human-readable text report. #[arg(long, default_value_t = false)] json: bool, /// Check the whole resolved workspace instead of PATHS: the project's /// own `src/` plus every dependency mote's `src/` (the same /// directories manifest discovery/`--manifest-path` already resolve /// into `--mote-path` for `use`/`open` resolution — this flag also /// makes them the file set to check). Falls back to the /// current-directory scan if no manifest is discoverable. #[arg(long, default_value_t = false, conflicts_with = "inputs")] workspace: bool, #[arg(long, default_value_t = false, overrides_with = "no_color")] color: bool, #[arg(long = "no-color", default_value_t = false)] no_color: bool, #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },
Symbols { /// Files or directories to index. Directories are scanned recursively /// for `.mo` files. Defaults to the current directory if omitted. #[arg(value_name = "PATHS")] inputs: Vec<PathBuf>, /// Emit machine-readable JSON instead of a plain text listing. #[arg(long, default_value_t = false)] json: bool, /// Index the whole resolved workspace instead of PATHS — see `check /// --workspace` for exactly what that covers. #[arg(long, default_value_t = false, conflicts_with = "inputs")] workspace: bool, #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },
/// Type signature (and, for a def, its full type) of the identifier at /// a position. LINE/COL are 1-indexed, matching the positions already /// shown in `check`'s own diagnostics (e.g. "at 3:1"). Hover { #[arg(value_name = "FILE")] file: PathBuf, #[arg(value_name = "LINE")] line: u32, #[arg(value_name = "COL")] col: usize, #[arg(long, default_value_t = false)] json: bool, #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },
/// Where the identifier at a position is defined. LINE/COL are /// 1-indexed, same convention as `hover`/`check`. Definition { #[arg(value_name = "FILE")] file: PathBuf, #[arg(value_name = "LINE")] line: u32, #[arg(value_name = "COL")] col: usize, #[arg(long, default_value_t = false)] json: bool, #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },
/// Rewrite bare `use Module`/`open Module` declarations to explicit /// `use/open Module {name1, name2}` (minimal name list — only what the /// file actually references) and `@[...]` attributes to `#[...]`. /// Prints a diff by default; pass `--write` to apply it. OrganizeImports { /// Files or directories to convert. Directories are scanned /// recursively for `.mo` files. Defaults to the current directory if /// omitted. #[arg(value_name = "PATHS")] inputs: Vec<PathBuf>, /// Apply the rewrite to disk. Without this flag, only a diff is /// printed and nothing is written. #[arg(long, default_value_t = false)] write: bool, /// Convert the whole resolved workspace instead of PATHS — see `check /// --workspace` for exactly what that covers. #[arg(long, default_value_t = false, conflicts_with = "inputs")] workspace: bool, #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },
/// Start the LSP server (stdio JSON-RPC). Diagnostics and navigation /// (hover/definition/documentSymbol) only — no completions, rename, /// semantic tokens, or code actions yet (see `lsp` module docs). Lsp { #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },
/// Start the MCP server (stdio, newline-delimited JSON-RPC). Exposes /// `check`/`symbols`/`hover`/`definition`/`organize_imports` as tools — /// the same five operations as the CLI's own `--json` subcommands, for /// agent clients that speak MCP instead of shelling out (see `mcp` /// module docs). Mcp { #[arg(short = 'p', long = "mote-path", value_name = "DIR")] mote_path: Vec<PathBuf>, #[arg(long = "manifest-path", value_name = "PATH")] manifest_path: Option<PathBuf>, },}
#[derive(Debug, Parser)]#[command( name = "monad", version, about = "Monad language interpreter and compiler")]struct Cli { #[command(subcommand)] command: Commands,}
fn augment_mote_paths(mote_path: &mut Vec<PathBuf>, manifest_path: Option<&PathBuf>) { let (manifest, project_root) = if let Some(mp) = manifest_path { let root = mp.parent().map(|p| p.to_path_buf()); (Manifest::parse(mp).ok(), root) } else { std::env::current_dir() .ok() .and_then(|cwd| Manifest::discover(&cwd)) .map(|(path, m)| { let root = path.parent().map(|p| p.to_path_buf()); (Some(m), root) }) .unwrap_or((None, None)) };
if let (Some(manifest), Some(root)) = (manifest, project_root) { let src_dir = root.join("src"); if src_dir.is_dir() && !mote_path.contains(&src_dir) { mote_path.push(src_dir); }
// A mote must be able to name ITSELF: `use lib.x` inside mote `demo` // resolves to `demo.x`, which is `demo/src/x.mo` -- reachable only from // the directory holding the mote. That is free in the monad workspace // (every mote sits directly under the CWD) and not otherwise. Skipped // for a virtual workspace root, which is not itself a mote. if manifest.mote.is_some() { if let Some(parent) = root.parent() { let parent = parent.to_path_buf(); if parent.is_dir() && !mote_path.contains(&parent) { mote_path.push(parent); } } }
match Resolver::resolve(&manifest, &root, None) { Ok(resolved) => { for mote in &resolved.motes { if let Some(source_path) = &mote.source_path { let dep_src_dir = source_path.join("src"); if dep_src_dir.is_dir() && !mote_path.contains(&dep_src_dir) { mote_path.push(dep_src_dir); } } } } Err(e) => { eprintln!("warning: failed to resolve dependencies: {e}"); } } }}
// --- `check --json` wire format -------------------------------------------//// A deliberately separate, hand-written shape from `monad_core::diag`'s// internal `Diagnostic` (1-indexed `Location`, Rust field names) — this is// the CLI's own translation to the LSP-conventional shape (0-indexed// `Position`, `uri`, camelCase `filesChecked`) agent/editor tooling// expects, matching the "structured CLI tools" shape from// `plans/library-ideas/language-server.md`. Converting here (not in// `core`) keeps `monad-core` itself unaware of LSP conventions.
#[derive(serde::Serialize)]pub(crate) struct JsonPosition { pub(crate) line: u32, pub(crate) character: usize,}
#[derive(serde::Serialize)]pub(crate) struct JsonRange { pub(crate) start: JsonPosition, pub(crate) end: JsonPosition,}
#[derive(serde::Serialize)]pub(crate) struct JsonDiagnostic { range: JsonRange, severity: String, message: String,}
#[derive(serde::Serialize)]pub(crate) struct JsonFileReport { uri: String, diagnostics: Vec<JsonDiagnostic>,}
#[derive(serde::Serialize)]#[serde(rename_all = "camelCase")]pub(crate) struct JsonSummary { errors: usize, warnings: usize, files_checked: usize,}
#[derive(serde::Serialize)]pub(crate) struct JsonCheckReport { files: Vec<JsonFileReport>, summary: JsonSummary,}
/// LSP `Position` is 0-indexed; `core`'s `Location` is 1-indexed (see its/// doc comment) — converts at this one boundary, `saturating_sub` so a/// (should-never-happen) `0` line/column from a `SourceRange::default()`/// placeholder can't underflow instead of panicking.pub(crate) fn to_json_position(loc: &monad_core::term::Location) -> JsonPosition { JsonPosition { line: loc.line.saturating_sub(1), character: loc.column.saturating_sub(1), }}
/// `SourceRange` -> `JsonRange`, falling back to `0:0-0:0` when there's no/// location at all (an internal-error diagnostic with nothing to point/// at) rather than making every caller handle the `None` case itself.pub(crate) fn location_to_json_range(loc: Option<&monad_core::term::SourceRange>) -> JsonRange { match loc { Some(loc) => JsonRange { start: to_json_position(&loc.start), end: to_json_position(&loc.end), }, None => JsonRange { start: JsonPosition { line: 0, character: 0, }, end: JsonPosition { line: 0, character: 0, }, }, }}
pub(crate) fn to_json_diagnostic(diag: &Diagnostic) -> JsonDiagnostic { let range = location_to_json_range(diag.location.as_ref()); JsonDiagnostic { range, severity: match diag.severity { Severity::Error => "error", Severity::Warning => "warning", Severity::Note => "note", Severity::Help => "help", } .to_string(), message: diag.message.clone(), }}
pub(crate) fn path_to_uri(path: &std::path::Path) -> String { let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); format!("file://{}", abs.display())}
// --- `test`/`test --json` --------------------------------------------------//// DTOs + conversion helpers shared by the CLI's own `test --json` and// MCP's `test` tool (mirroring `JsonCheckReport`/`to_json_diagnostic`'s// same shared role for `check`), so the two can't drift on what a test// report looks like.
#[derive(serde::Serialize)]#[serde(rename_all = "camelCase")]pub(crate) struct JsonTestCase { name: String, outcome: String, // "pass" | "fail" #[serde(skip_serializing_if = "Option::is_none")] message: Option<String>, // present only for a FailWithMessage outcome duration_ms: f64,}
#[derive(serde::Serialize)]pub(crate) struct JsonFileTestReport { uri: String, tests: Vec<JsonTestCase>, #[serde(skip_serializing_if = "Option::is_none")] error: Option<String>,}
#[derive(serde::Serialize)]#[serde(rename_all = "camelCase")]pub(crate) struct JsonTestSummary { passed: usize, failed: usize, files_tested: usize,}
#[derive(serde::Serialize)]pub(crate) struct JsonTestReport { files: Vec<JsonFileTestReport>, summary: JsonTestSummary,}
pub(crate) fn to_json_test_case(t: &monad_core::TestCaseResult) -> JsonTestCase { let (outcome, message) = match &t.outcome { TestOutcome::Pass => ("pass", None), TestOutcome::Fail => ("fail", None), TestOutcome::FailWithMessage(m) => ("fail", Some(m.clone())), }; JsonTestCase { name: t.name.as_ref().to_string(), outcome: outcome.to_string(), message, duration_ms: t.duration.as_secs_f64() * 1000.0, }}
/// Shared by the CLI's `test --json` and MCP's `test` tool.pub(crate) fn build_json_test_report(results: &[FileTestResult]) -> JsonTestReport { let mut passed = 0usize; let mut failed = 0usize; let files = results .iter() .map(|r| { let tests: Vec<JsonTestCase> = r .tests .iter() .map(|t| { match t.outcome { TestOutcome::Pass => passed += 1, TestOutcome::Fail | TestOutcome::FailWithMessage(_) => failed += 1, } to_json_test_case(t) }) .collect(); JsonFileTestReport { uri: path_to_uri(&r.path), tests, error: r.error_message.clone(), } }) .collect(); JsonTestReport { files, summary: JsonTestSummary { passed, failed, files_tested: results.len(), }, }}
/// `monad test --json`'s exit path — mirrors `run_check`'s json branch/// (same exit-code convention: 0 clean, 1 failures found, 2 internal/// error). Never falls through to `run_tests`'s own printing path,/// unlike the human-text path which stays byte-for-byte unchanged below.fn run_test_json( inputs: Vec<PathBuf>, options: EvalOptions, num_threads: usize, test_timeout: Option<std::time::Duration>, mote_path: Vec<PathBuf>,) -> ! { let results = match run_tests_for_files(inputs, options, num_threads, test_timeout, mote_path, None) { Ok(r) => r, Err(e) => { eprintln!("error: {e}"); std::process::exit(2); } }; let report = build_json_test_report(&results); match serde_json::to_string_pretty(&report) { Ok(s) => println!("{s}"), Err(e) => { eprintln!("error: failed to serialize report: {e}"); std::process::exit(2); } } std::process::exit(if report.summary.failed > 0 { 1 } else { 0 });}
fn run_check(inputs: Vec<PathBuf>, json: bool, use_colors: bool, mote_path: Vec<PathBuf>) -> ! { let results = match check_files(inputs, mote_path) { Ok(results) => results, Err(e) => { eprintln!("error: {e}"); std::process::exit(2); } };
let mut error_count = 0usize; let mut warning_count = 0usize; for result in &results { for d in &result.diagnostics { match d.severity { Severity::Error => error_count += 1, Severity::Warning => warning_count += 1, _ => {} } } }
if json { let report = JsonCheckReport { files: results .iter() .map(|r| JsonFileReport { uri: path_to_uri(&r.path), diagnostics: r.diagnostics.iter().map(to_json_diagnostic).collect(), }) .collect(), summary: JsonSummary { errors: error_count, warnings: warning_count, files_checked: results.len(), }, }; match serde_json::to_string_pretty(&report) { Ok(s) => println!("{s}"), Err(e) => { eprintln!("error: failed to serialize report: {e}"); std::process::exit(2); } } } else { for result in &results { if !result.diagnostics.is_empty() { print!( "{}", render_diagnostics(&result.diagnostics, None, use_colors) ); } } println!( "{} file(s) checked, {} error(s), {} warning(s)", results.len(), error_count, warning_count ); }
std::process::exit(if error_count > 0 { 1 } else { 0 });}
/// Print a minimal line-level diff between `old` and `new` — every edit/// this tool makes is confined to a single `use`/`open`/attribute line, so/// a full unified-diff algorithm isn't needed: walk both files' lines in/// lockstep and report wherever they differ.fn print_line_diff(path: &Path, old: &str, new: &str) { println!("--- {}", path.display()); let old_lines: Vec<&str> = old.lines().collect(); let new_lines: Vec<&str> = new.lines().collect(); for (i, (o, n)) in old_lines.iter().zip(new_lines.iter()).enumerate() { if o != n { println!(" {}:", i + 1); println!(" - {o}"); println!(" + {n}"); } }}
fn run_organize_imports( inputs: Vec<PathBuf>, write: bool, mote_path: Vec<PathBuf>,) -> Result<(), String> { let results = organize_imports_for_files(inputs, mote_path)?;
let mut changed = 0usize; let mut errors = 0usize; for result in &results { if let Some(e) = &result.error { eprintln!("error: {}: {e}", result.path.display()); errors += 1; continue; } let Some(new_source) = &result.new_source else { continue; }; changed += 1; let old_source = std::fs::read_to_string(&result.path).map_err(|e| format!("{e}"))?; print_line_diff(&result.path, &old_source, new_source); if write { std::fs::write(&result.path, new_source).map_err(|e| format!("{e}"))?; } }
if write { println!("{changed} file(s) rewritten, {errors} error(s)"); } else { println!("{changed} file(s) would be rewritten, {errors} error(s) (pass --write to apply)"); }
if errors > 0 { return Err(format!("{errors} file(s) failed to load")); } Ok(())}
#[derive(serde::Serialize)]pub(crate) struct JsonSymbol { name: String, kind: SymbolKind, range: JsonRange, #[serde(skip_serializing_if = "Option::is_none")] detail: Option<String>,}
#[derive(serde::Serialize)]pub(crate) struct JsonSymbolFile { uri: String, symbols: Vec<JsonSymbol>,}
pub(crate) fn symbol_kind_label(kind: SymbolKind) -> &'static str { match kind { SymbolKind::Function => "function", SymbolKind::Struct => "struct", SymbolKind::Class => "class", SymbolKind::Enum => "enum", SymbolKind::Instance => "instance", }}
fn run_symbols(inputs: Vec<PathBuf>, json: bool, mote_path: Vec<PathBuf>) -> ! { let results = match symbols_for_files(inputs, mote_path) { Ok(results) => results, Err(e) => { eprintln!("error: {e}"); std::process::exit(2); } };
if json { let files: Vec<JsonSymbolFile> = results .iter() .map(|r| JsonSymbolFile { uri: path_to_uri(&r.path), symbols: r .symbols .iter() .map(|s| JsonSymbol { name: s.name.clone(), kind: s.kind, range: location_to_json_range(s.location.as_ref()), detail: s.detail.clone(), }) .collect(), }) .collect(); match serde_json::to_string_pretty(&files) { Ok(s) => println!("{s}"), Err(e) => { eprintln!("error: failed to serialize symbols: {e}"); std::process::exit(2); } } } else { for result in &results { println!("{}", result.path.display()); for symbol in &result.symbols { let loc = symbol .location .as_ref() .map(|l| format!("{}:{}", l.start.line, l.start.column)) .unwrap_or_else(|| "?".to_string()); println!( " {} {} ({loc})", symbol_kind_label(symbol.kind), symbol.name ); } } }
std::process::exit(0);}
// --- `hover`/`definition` --------------------------------------------------//// Deliberately name-based, not a walk of the checked term tree to the exact// sub-expression under the cursor: `identifier_at` extracts the token// touching the cursor by a plain text scan, then both commands look it up// by NAME (`find_symbol_by_name`) — first in the target file's own symbol// index (`symbols_for_files`), then, via `resolve_symbol`, across the// whole resolved workspace if not found locally. Still no scope-awareness// (a local variable shadowing a same-named top-level def resolves to the// top-level one, and an ambiguous name present in two different mote// files resolves to whichever `symbols_for_files` happens to return// first) — getting that exactly right needs walking the checked// (pre-raise) `CoreTerm` — `raise_core` intentionally discards `Ctx` when// producing the final `Term` (see its own doc comment), so that's a// larger, separately-scoped follow-up, not attempted here.
/// The identifier/path token touching 1-indexed `(line, col)` in `source`/// — e.g. `"add"`, `"List.map"` — plus its own 1-indexed `(start_col,/// end_col)` span on that line, for reporting a precise hover range./// Identifier characters: alphanumeric, `_`, `.` (Monad's path/// separator), `'` (allowed in identifiers, e.g. `x'`). Returns `None` if/// the position isn't on an identifier character at all.pub(crate) fn identifier_at(source: &str, line: u32, col: usize) -> Option<(String, usize, usize)> { let line_text = source.lines().nth((line as usize).checked_sub(1)?)?; let chars: Vec<char> = line_text.chars().collect(); let idx = col.checked_sub(1)?; if idx >= chars.len() { return None; } let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '.' || c == '\''; if !is_ident(chars[idx]) { return None; } let mut start = idx; while start > 0 && is_ident(chars[start - 1]) { start -= 1; } let mut end = idx; while end + 1 < chars.len() && is_ident(chars[end + 1]) { end += 1; } let name: String = chars[start..=end].iter().collect(); Some((name, start + 1, end + 2))}
/// Match `name` against an already-computed symbol list — exact match/// first (the common case: cursor on a bare `add`), then "declared name/// ends with `.name`" as a fallback for a qualified reference (`List.map`/// typed at the call site resolving to a symbol whose own recorded name/// already includes a module prefix). Pure lookup, no I/O — shared by the/// disk-based CLI path (`find_symbol_in_file`) and the LSP server's/// in-memory-buffer path (`lsp::hover`/`lsp::definition`, which computes/// its own symbol list via `symbols_from_source` instead of reading the/// file back off disk).pub(crate) fn find_symbol_by_name<'a>( symbols: &'a [SymbolInfo], name: &str,) -> Option<&'a SymbolInfo> { symbols.iter().find(|s| s.name == name).or_else(|| { symbols .iter() .find(|s| s.name.ends_with(&format!(".{name}"))) })}
/// Disk-reading convenience wrapper: `file`'s own symbol index (via/// `symbols_for_files`, same as the `symbols` command) — the "local"/// input to `resolve_symbol` below for the disk-based CLI/MCP paths (the/// LSP server computes its own equivalent from the in-memory buffer via/// `symbols_from_source` instead of reading the file back off disk).pub(crate) fn symbols_of_file(file: &Path, mote_path: Vec<PathBuf>) -> Vec<SymbolInfo> { symbols_for_files(vec![file.to_path_buf()], mote_path) .ok() .and_then(|results| results.into_iter().find(|r| r.path == file)) .map(|r| r.symbols) .unwrap_or_default()}
/// Resolve `name` to a definition: first within `local_symbols` (the/// querying file's own symbol table — preserves `find_symbol_by_name`'s/// existing local-file-wins, shadowing-friendly behavior and every/// existing test that only ever exercises single-file lookup), and only/// if not found there, across the whole resolved workspace (`mote_path`/// — the project's own `src/` plus every dependency mote's `src/`, the/// same directories manifest discovery/`--manifest-path` already resolve/// for `use`/`open` resolution, reused here as the set of files to search/// across for a name match). Returns the *defining* file's path alongside/// the symbol — for a cross-file hit that's no longer `local_file`, and/// callers must use the returned path (not `local_file`) when building a/// `Location`/URI in their response.////// No caching: a miss re-walks and re-type-checks every `mote_path`/// directory via `symbols_for_files`. Matches this server's existing "no/// caching anywhere yet" reality (see e.g. `lsp.rs`'s own "no debounce"/// note) — an acceptable v1 tradeoff, not an oversight;/// `plans/library-ideas/language-server.md`'s "Performance/// Considerations" section already flags workspace-symbol caching as a/// known future optimization, not a new gap introduced here.pub(crate) fn resolve_symbol( name: &str, local_file: &Path, local_symbols: &[SymbolInfo], mote_path: &[PathBuf],) -> Option<(PathBuf, SymbolInfo)> { if let Some(sym) = find_symbol_by_name(local_symbols, name) { return Some((local_file.to_path_buf(), sym.clone())); } let results = symbols_for_files(mote_path.to_vec(), mote_path.to_vec()).ok()?; results.into_iter().find_map(|r| { find_symbol_by_name(&r.symbols, name) .cloned() .map(|s| (r.path, s)) })}
#[derive(serde::Serialize)]pub(crate) struct JsonHover { contents: String, kind: String, range: JsonRange,}
#[derive(serde::Serialize)]pub(crate) struct JsonLocation { uri: String, range: JsonRange,}
fn run_hover( file: PathBuf, line: u32, col: usize, json: bool, mote_path: Vec<PathBuf>,) -> Result<(), String> { let source = std::fs::read_to_string(&file).map_err(|e| format!("{e}"))?; let local_symbols = symbols_of_file(&file, mote_path.clone()); let found = identifier_at(&source, line, col).and_then(|(name, start_col, end_col)| { resolve_symbol(&name, &file, &local_symbols, &mote_path) .map(|(_, sym)| (sym, start_col, end_col)) });
match found { None => { if json { println!("null"); } else { println!("(no symbol found at {line}:{col})"); } } Some((sym, start_col, end_col)) => { let contents = sym .detail .clone() .unwrap_or_else(|| format!("{} {}", symbol_kind_label(sym.kind), sym.name)); if json { let hover = JsonHover { contents, kind: symbol_kind_label(sym.kind).to_string(), range: JsonRange { start: JsonPosition { line: line.saturating_sub(1), character: start_col.saturating_sub(1), }, end: JsonPosition { line: line.saturating_sub(1), character: end_col.saturating_sub(1), }, }, }; let s = serde_json::to_string_pretty(&hover) .map_err(|e| format!("failed to serialize hover: {e}"))?; println!("{s}"); } else { println!("{contents}"); } } } Ok(())}
fn run_definition( file: PathBuf, line: u32, col: usize, json: bool, mote_path: Vec<PathBuf>,) -> Result<(), String> { let source = std::fs::read_to_string(&file).map_err(|e| format!("{e}"))?; let local_symbols = symbols_of_file(&file, mote_path.clone()); let found = identifier_at(&source, line, col) .and_then(|(name, _, _)| resolve_symbol(&name, &file, &local_symbols, &mote_path));
match found { None => { if json { println!("null"); } else { println!("(no definition found at {line}:{col})"); } } Some((def_file, sym)) => { if json { let loc = JsonLocation { uri: path_to_uri(&def_file), range: location_to_json_range(sym.location.as_ref()), }; let s = serde_json::to_string_pretty(&loc) .map_err(|e| format!("failed to serialize location: {e}"))?; println!("{s}"); } else { match &sym.location { Some(loc) => println!( "{}:{}:{}", def_file.display(), loc.start.line, loc.start.column ), None => println!("{} (no location)", def_file.display()), } } } } Ok(())}
fn main() -> Result<(), String> { let cli = Cli::parse(); execute(cli.command)}
fn execute(command: Commands) -> Result<(), String> { match command { #[cfg(feature = "repl")] Commands::Repl { debug, benchmark, color, no_color, max_depth, } => { let use_colors = color && !no_color; repl(EvalOptions { debug, benchmark, use_colors, max_recursion_depth: max_depth, }) .map_err(|e| e.to_string()) } #[cfg(not(feature = "repl"))] Commands::Repl { .. } => { Err("REPL support was not compiled in. Install with repl feature enabled.".into()) } Commands::Run { input, debug, benchmark, args, color, no_color, max_depth, mut mote_path, manifest_path, } => { let use_colors = color && !no_color; augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let result = run( input, args, EvalOptions { debug, benchmark, use_colors, max_recursion_depth: max_depth, }, mote_path, ); match result { // The program's own exit code becomes this process's exit code. // Without this a failed `monad-rs run cli/src/main.mo compile ...` // -- a gate rejecting the build and emitting no binary -- still // exited 0 and read as success to any caller. Ok(code) => { if code != 0 { // POSIX keeps only the low 8 bits of an exit status, so a // code that is nonzero but ≡ 0 mod 256 (or does not survive // the i32 narrowing) would report SUCCESS -- the exact // failure-reads-as-success this exit code exists to prevent. // Anything unrepresentable becomes a plain 1. let status = i32::try_from(code).ok().filter(|c| c % 256 != 0); std::process::exit(status.unwrap_or(1)); } Ok(()) } Err(e) => { eprintln!("error: {e}"); Err(e) } } } Commands::Test { inputs, json, debug, benchmark, color, no_color, max_depth, timeout, jobs, sequential, mut mote_path, manifest_path, } => { let use_colors = color && !no_color; augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let num_threads = if sequential { 1 } else { jobs.unwrap_or_else(|| { std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(1) }) }; let options = EvalOptions { debug, benchmark, use_colors, max_recursion_depth: max_depth, }; let test_timeout = timeout.map(std::time::Duration::from_secs_f64); if json { // Diverges (exits the process) -- never falls through to // `run_tests`'s own printing path below. run_test_json(inputs, options, num_threads, test_timeout, mote_path); } let result = run_tests(inputs, options, num_threads, test_timeout, mote_path); match result { Ok(_) => (), Err(ref e) => { eprintln!("error: {e}") } } result } Commands::Check { inputs, json, workspace, color, no_color, mut mote_path, manifest_path, } => { let use_colors = color && !no_color; augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let inputs = if workspace { mote_path.clone() } else { inputs }; // `run_check` always exits the process itself (it needs a 3-way // 0/1/2 exit code — success/errors-found/internal-failure — that // `execute`'s shared `Result<(), String>` return convention, used // by every other command, can't distinguish). run_check(inputs, json, use_colors, mote_path) } Commands::Symbols { inputs, json, workspace, mut mote_path, manifest_path, } => { augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let inputs = if workspace { mote_path.clone() } else { inputs }; run_symbols(inputs, json, mote_path) } Commands::Hover { file, line, col, json, mut mote_path, manifest_path, } => { augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let result = run_hover(file, line, col, json, mote_path); if let Err(ref e) = result { eprintln!("error: {e}"); } result } Commands::Definition { file, line, col, json, mut mote_path, manifest_path, } => { augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let result = run_definition(file, line, col, json, mote_path); if let Err(ref e) = result { eprintln!("error: {e}"); } result } Commands::OrganizeImports { inputs, write, workspace, mut mote_path, manifest_path, } => { augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let inputs = if workspace { mote_path.clone() } else { inputs }; let result = run_organize_imports(inputs, write, mote_path); if let Err(ref e) = result { eprintln!("error: {e}"); } result } Commands::Lsp { mut mote_path, manifest_path, } => { augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let result = lsp::run(mote_path); if let Err(ref e) = result { eprintln!("error: {e}"); } result } Commands::Mcp { mut mote_path, manifest_path, } => { augment_mote_paths(&mut mote_path, manifest_path.as_ref()); let result = mcp::run(mote_path); if let Err(ref e) = result { eprintln!("error: {e}"); } result } }}
#[cfg(test)]mod test { use super::*; use std::sync::Arc;
#[test] fn test_to_json_test_case_pass() { let t = monad_core::TestCaseResult { name: Arc::from("test_a"), outcome: TestOutcome::Pass, duration: std::time::Duration::from_micros(80), location: None, }; let json = to_json_test_case(&t); assert_eq!(json.name, "test_a"); assert_eq!(json.outcome, "pass"); assert!(json.message.is_none()); assert!((json.duration_ms - 0.08).abs() < 0.001); }
#[test] fn test_to_json_test_case_fail_with_message() { let t = monad_core::TestCaseResult { name: Arc::from("test_b"), outcome: TestOutcome::FailWithMessage("expected 1 got 2".to_string()), duration: std::time::Duration::from_millis(1), location: None, }; let json = to_json_test_case(&t); assert_eq!(json.outcome, "fail"); assert_eq!(json.message.as_deref(), Some("expected 1 got 2")); }
#[test] fn test_build_json_test_report_summary_counts() { let results = vec![ FileTestResult { path: PathBuf::from(format!("/tmp/a-{:x}.mo", std::process::id())), tests: vec![ monad_core::TestCaseResult { name: Arc::from("test_a"), outcome: TestOutcome::Pass, duration: std::time::Duration::ZERO, location: None, }, monad_core::TestCaseResult { name: Arc::from("test_b"), outcome: TestOutcome::Fail, duration: std::time::Duration::ZERO, location: None, }, ], error_message: None, }, FileTestResult { path: PathBuf::from(format!("/tmp/broken-{:x}.mo", std::process::id())), tests: Vec::new(), error_message: Some("parse error".to_string()), }, ]; let report = build_json_test_report(&results); assert_eq!(report.summary.passed, 1); assert_eq!(report.summary.failed, 1); assert_eq!(report.summary.files_tested, 2); assert_eq!(report.files.len(), 2); assert_eq!(report.files[1].error.as_deref(), Some("parse error")); }}