#![warn(clippy::all)]
#![deny(rust_2018_idioms)]
mod cli;
use ilo::ast;
use ilo::caps::{Caps, Policy};
use ilo::codegen;
use ilo::diagnostic;
use ilo::graph;
use ilo::interpreter;
use ilo::lexer;
use ilo::parser;
use ilo::tools;
use ilo::verify;
use ilo::vm;
use std::sync::Arc;
use clap::Parser as _;
use cli::args::OutputMode;
use diagnostic::{Diagnostic, ansi::AnsiRenderer, json};
fn compact_spec() -> &'static str {
include_str!("../ai.txt")
}
struct Skill {
name: &'static str,
description: &'static str,
path: &'static str,
content: &'static str,
}
const SKILLS: &[Skill] = &[
Skill {
name: "ilo-language",
description: "Use this when writing or reviewing .ilo source. Covers prefix notation, type sigils, guards, match, pipes, Results, loops, and lambdas.",
path: "skills/ilo/ilo-language.md",
content: include_str!("../skills/ilo/ilo-language.md"),
},
Skill {
name: "ilo-language-records",
description: "Use this when writing ilo code that declares or uses record types. Covers type declarations, construction, field access, destructuring, update syntax, and safe navigation.",
path: "skills/ilo/ilo-language-records.md",
content: include_str!("../skills/ilo/ilo-language-records.md"),
},
Skill {
name: "ilo-builtins-core",
description: "Use this when calling core ilo builtins. Type coercions (len, str, num, trm), list ops, HOFs, and map ops.",
path: "skills/ilo/ilo-builtins-core.md",
content: include_str!("../skills/ilo/ilo-builtins-core.md"),
},
Skill {
name: "ilo-builtins-math",
description: "Use this when calling math builtins. Arithmetic, trig, constants (pi, tau, e), random, and statistics.",
path: "skills/ilo/ilo-builtins-math.md",
content: include_str!("../skills/ilo/ilo-builtins-math.md"),
},
Skill {
name: "ilo-builtins-io",
description: "Use this when calling I/O builtins. File read/write, HTTP, JSON, path ops, env, time, and process.",
path: "skills/ilo/ilo-builtins-io.md",
content: include_str!("../skills/ilo/ilo-builtins-io.md"),
},
Skill {
name: "ilo-builtins-text",
description: "Use this when calling text builtins. Manipulation, regex, formatting (fmt, fmt2), CSV/TSV, and date parsing.",
path: "skills/ilo/ilo-builtins-text.md",
content: include_str!("../skills/ilo/ilo-builtins-text.md"),
},
Skill {
name: "ilo-errors",
description: "Use this when reading ILO-XXXX error codes or fixing failures. Lists the common codes with one-line cause + fix; run `ilo --explain ILO-XXXX` for the long form.",
path: "skills/ilo/ilo-errors.md",
content: include_str!("../skills/ilo/ilo-errors.md"),
},
Skill {
name: "ilo-tools",
description: "Use this when declaring or using MCP tools in ilo programs. Covers the `tool` keyword, HTTP and MCP providers, and runtime tool-call handling.",
path: "skills/ilo/ilo-tools.md",
content: include_str!("../skills/ilo/ilo-tools.md"),
},
Skill {
name: "ilo-engines",
description: "Use this when choosing between tree, VM, JIT, or AOT execution. Covers the feature matrix, default behaviour, and when each backend matters.",
path: "skills/ilo/ilo-engines.md",
content: include_str!("../skills/ilo/ilo-engines.md"),
},
Skill {
name: "ilo-agent",
description: "Use this when integrating ilo into an agent loop. Covers skill discovery, running programs, and the output contract.",
path: "skills/ilo/ilo-agent.md",
content: include_str!("../skills/ilo/ilo-agent.md"),
},
Skill {
name: "ilo-examples",
description: "Use this when looking for a runnable pattern for the kind of task you are doing. Curated index of `examples/*.ilo` grouped by what each one demonstrates.",
path: "skills/ilo/ilo-examples.md",
content: include_str!("../skills/ilo/ilo-examples.md"),
},
Skill {
name: "ilo-edit-loop",
description: "Use this when an ilo program fails and you need to recover or iterate. Covers the repair loop, JSON diagnostics, `--explain`, and fix patterns for the common ILO-XXXX classes.",
path: "skills/ilo/ilo-edit-loop.md",
content: include_str!("../skills/ilo/ilo-edit-loop.md"),
},
];
fn find_skill(name: &str) -> Option<&'static Skill> {
SKILLS.iter().find(|s| s.name == name)
}
fn skill_unknown(name: &str) -> i32 {
eprintln!("error: unknown skill '{name}'");
eprintln!("run `ilo skill list` to see available skills.");
1
}
fn skill_unknown_json(name: &str) -> i32 {
let v = serde_json::json!({
"schemaVersion": 1,
"error": {
"code": "unknown-skill",
"message": format!("unknown skill '{name}'"),
"name": name,
}
});
println!("{}", v);
1
}
fn skill_list_cmd(as_json: bool) -> i32 {
if as_json {
let items: Vec<serde_json::Value> = SKILLS
.iter()
.map(|s| {
serde_json::json!({
"name": s.name,
"description": s.description,
"path": s.path,
})
})
.collect();
let v = serde_json::json!({
"schemaVersion": 1,
"skills": items,
});
match serde_json::to_string_pretty(&v) {
Ok(s) => println!("{}", s),
Err(e) => {
eprintln!("error: failed to serialise skill list: {e}");
return 1;
}
}
return 0;
}
for s in SKILLS {
println!("{:<14} {}", s.name, s.description);
}
0
}
fn skill_get_cmd(name: &str, as_json: bool) -> i32 {
match find_skill(name) {
Some(s) => {
if as_json {
let v = serde_json::json!({
"schemaVersion": 1,
"name": s.name,
"description": s.description,
"path": s.path,
"content": s.content,
});
match serde_json::to_string_pretty(&v) {
Ok(out) => println!("{}", out),
Err(e) => {
eprintln!("error: failed to serialise skill: {e}");
return 1;
}
}
} else {
print!("{}", s.content);
}
0
}
None => {
if as_json {
skill_unknown_json(name)
} else {
skill_unknown(name)
}
}
}
}
fn skill_path_cmd(name: &str, as_json: bool) -> i32 {
match find_skill(name) {
Some(s) => {
if as_json {
let v = serde_json::json!({
"schemaVersion": 1,
"name": s.name,
"path": s.path,
});
println!("{}", v);
} else {
println!("{}", s.path);
}
0
}
None => {
if as_json {
skill_unknown_json(name)
} else {
skill_unknown(name)
}
}
}
}
fn skill_show_cmd(name: &str, as_json: bool) -> i32 {
match find_skill(name) {
Some(s) => {
if as_json {
let v = serde_json::json!({
"schemaVersion": 1,
"name": s.name,
"description": s.description,
"path": s.path,
"content": s.content,
});
match serde_json::to_string_pretty(&v) {
Ok(out) => println!("{}", out),
Err(e) => {
eprintln!("error: failed to serialise skill: {e}");
return 1;
}
}
} else {
println!("# {} ({})", s.name, s.path);
println!();
println!("{}", s.description);
println!();
println!("---");
println!();
print!("{}", s.content);
}
0
}
None => {
if as_json {
skill_unknown_json(name)
} else {
skill_unknown(name)
}
}
}
}
fn version_cmd(as_json: bool) -> i32 {
if as_json {
let v = serde_json::json!({
"schemaVersion": 1,
"name": "ilo",
"version": env!("CARGO_PKG_VERSION"),
"features": features_list(),
});
match serde_json::to_string_pretty(&v) {
Ok(s) => println!("{}", s),
Err(e) => {
eprintln!("error: failed to serialise version: {e}");
return 1;
}
}
} else {
println!("ilo {}", env!("CARGO_PKG_VERSION"));
}
0
}
fn features_list() -> Vec<&'static str> {
let entries: [Option<&'static str>; 3] = [
#[cfg(feature = "cranelift")]
Some("cranelift"),
#[cfg(not(feature = "cranelift"))]
None,
#[cfg(feature = "llvm")]
Some("llvm"),
#[cfg(not(feature = "llvm"))]
None,
#[cfg(feature = "tools")]
Some("tools"),
#[cfg(not(feature = "tools"))]
None,
];
entries.into_iter().flatten().collect()
}
fn explain_cmd(code: &str, as_json: bool) -> i32 {
match diagnostic::registry::lookup(code) {
Some(entry) => {
if as_json {
let v = serde_json::json!({
"schemaVersion": 1,
"code": entry.code,
"phase": entry.phase.as_str(),
"short": entry.short,
"long": entry.long,
});
match serde_json::to_string_pretty(&v) {
Ok(s) => println!("{}", s),
Err(e) => {
eprintln!("error: failed to serialise explain: {e}");
return 1;
}
}
} else {
print!("{}", entry.long);
}
0
}
None => {
if as_json {
let v = serde_json::json!({
"schemaVersion": 1,
"error": {
"code": "unknown-error-code",
"message": format!("unknown error code: {code}"),
"input": code,
}
});
println!("{}", v);
} else {
eprintln!("unknown error code: {}", code);
eprintln!("Error codes have the form ILO-L001, ILO-P001, ILO-T001, ILO-R001.");
}
1
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ToolsOutputFmt {
Human, Ilo, Json, }
fn tools_cmd(args: &[String]) -> i32 {
let mut mcp_path: Option<String> = None;
let mut http_path: Option<String> = None;
let mut fmt = ToolsOutputFmt::Human;
let mut full = false;
let mut graph = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--mcp" | "-m" => {
if i + 1 >= args.len() {
eprintln!("error: --mcp requires a path");
return 1;
}
mcp_path = Some(args[i + 1].clone());
i += 2;
}
"--tools" | "-t" => {
if i + 1 >= args.len() {
eprintln!("error: --tools requires a path");
return 1;
}
http_path = Some(args[i + 1].clone());
i += 2;
}
"--human" => {
fmt = ToolsOutputFmt::Human;
i += 1;
}
"--ilo" => {
fmt = ToolsOutputFmt::Ilo;
i += 1;
}
"--json" | "-j" => {
fmt = ToolsOutputFmt::Json;
i += 1;
}
"--full" | "-f" => {
full = true;
i += 1;
}
"--graph" | "-g" => {
graph = true;
i += 1;
}
_ => {
eprintln!("unknown flag: {}", args[i]);
eprintln!(
"Usage: ilo tools [-m <path>] [-t <path>] \
[--human|--ilo|--json] [--full] [--graph]"
);
return 1;
}
}
}
if mcp_path.is_none() && http_path.is_none() {
eprintln!("error: ilo tools requires at least one of --mcp <path> or --tools <path>");
eprintln!(
"Usage: ilo tools [--mcp <path>] [--tools <path>] \
[--human|--ilo|--json] [--full] [--graph]"
);
return 1;
}
if matches!(fmt, ToolsOutputFmt::Ilo | ToolsOutputFmt::Json) || graph {
full = true;
}
let mut http_names: Vec<String> = Vec::new();
if let Some(ref path) = http_path {
let config = match tools::http_provider::ToolsConfig::from_file(path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}", e);
return 1;
}
};
let mut names: Vec<String> = config.tools.keys().cloned().collect();
names.sort();
http_names = names;
}
let mcp_decls = match collect_mcp_tool_decls(mcp_path.as_deref()) {
Ok(d) => d,
Err(e) => {
eprintln!("{}", e);
return 1;
}
};
match fmt {
ToolsOutputFmt::Human => {
for name in &http_names {
if full {
println!("{:<32} (http tool — no type info)", name);
} else {
println!("{}", name);
}
}
for decl in &mcp_decls {
if let ast::Decl::Tool {
name,
description,
params,
return_type,
..
} = decl
{
if full {
let sig = tool_sig_str(params, return_type);
println!("{:<32} {:<44} {}", name, description, sig);
} else {
println!("{}", name);
}
}
}
}
ToolsOutputFmt::Ilo => {
for name in &http_names {
println!("tool {}\"\" > R t t", name);
}
for decl in &mcp_decls {
println!(
"{}",
codegen::fmt::format_decl(decl, codegen::fmt::FmtMode::Dense)
);
}
}
ToolsOutputFmt::Json => {
let mut items: Vec<serde_json::Value> = Vec::new();
for name in &http_names {
items.push(serde_json::json!({
"name": name,
"source": "http",
"description": null,
"params": [],
"return": null
}));
}
for decl in &mcp_decls {
if let ast::Decl::Tool {
name,
description,
params,
return_type,
..
} = decl
{
let params_json: Vec<serde_json::Value> = params
.iter()
.map(|p| {
serde_json::json!({
"name": p.name,
"type": codegen::fmt::type_str(&p.ty)
})
})
.collect();
items.push(serde_json::json!({
"name": name,
"source": "mcp",
"description": description,
"params": params_json,
"return": codegen::fmt::type_str(return_type)
}));
}
}
let envelope = serde_json::json!({
"schemaVersion": 1,
"tools": items,
});
match serde_json::to_string_pretty(&envelope) {
Ok(s) => println!("{}", s),
Err(e) => {
eprintln!("failed to render JSON: {}", e);
return 1;
}
}
}
}
if graph {
print_tool_graph(&mcp_decls);
}
0
}
fn print_tool_graph(decls: &[ast::Decl]) {
let tools: Vec<(&str, &[ast::Param], &ast::Type)> = decls
.iter()
.filter_map(|d| {
if let ast::Decl::Tool {
name,
params,
return_type,
..
} = d
{
Some((name.as_str(), params.as_slice(), return_type))
} else {
None
}
})
.collect();
if tools.is_empty() {
println!("(no typed tools — graph requires MCP source)");
return;
}
let name_w = tools
.iter()
.map(|(n, _, _)| n.len())
.max()
.unwrap_or(8)
.max(8);
let sig_w: usize = 36;
println!("Tool composition graph\n");
println!("{:<name_w$} {:<sig_w$} feeds →", "tool", "signature");
println!("{}", "─".repeat(name_w + 2 + sig_w + 2 + 40));
for &(src_name, src_params, src_ret) in &tools {
let sig = tool_sig_str(src_params, src_ret);
let out_ty = tool_ok_type(src_ret);
let mut consumers: Vec<&str> = tools
.iter()
.filter(|&&(dst_name, dst_params, _)| {
dst_name != src_name
&& dst_params
.iter()
.any(|p| types_pipe_compatible(out_ty, &p.ty))
})
.map(|&(n, _, _)| n)
.collect();
consumers.sort();
let feeds = if consumers.is_empty() {
"—".to_string()
} else {
consumers.join(", ")
};
let sig_char_len = sig.chars().count();
let sig_display = if sig_char_len > sig_w {
let truncated: String = sig.chars().take(sig_w.saturating_sub(1)).collect();
format!("{}…", truncated)
} else {
sig
};
println!("{:<name_w$} {:<sig_w$} {}", src_name, sig_display, feeds);
}
println!();
}
fn tool_ok_type(ty: &ast::Type) -> &ast::Type {
if let ast::Type::Result(ok, _) = ty {
ok
} else {
ty
}
}
fn types_pipe_compatible(out: &ast::Type, param: &ast::Type) -> bool {
use ast::Type::*;
let param = if let Optional(inner) = param {
inner
} else {
param
};
match (out, param) {
(Named(_), _) | (_, Named(_)) | (Any, _) | (_, Any) => true,
(Number, Number) | (Text, Text) | (Bool, Bool) => true,
(List(a), List(b)) => types_pipe_compatible(a, b),
(Map(ak, av), Map(bk, bv)) => {
types_pipe_compatible(ak, bk) && types_pipe_compatible(av, bv)
}
(Result(ao, ae), Result(bo, be)) => {
types_pipe_compatible(ao, bo) && types_pipe_compatible(ae, be)
}
(Sum(_), Text) | (Text, Sum(_)) | (Sum(_), Sum(_)) => true,
_ => false,
}
}
fn tool_sig_str(params: &[ast::Param], ret: &ast::Type) -> String {
let ps: Vec<String> = params
.iter()
.map(|p| format!("{}:{}", p.name, codegen::fmt::type_str(&p.ty)))
.collect();
if ps.is_empty() {
format!("> {}", codegen::fmt::type_str(ret))
} else {
format!("{} > {}", ps.join(" "), codegen::fmt::type_str(ret))
}
}
fn graph_cmd(args: &[String]) -> i32 {
if args.is_empty() {
eprintln!(
"Usage: ilo graph <file> [--fn NAME] [--reverse] [--subgraph] [--budget N] [--dot]"
);
return 1;
}
let file = &args[0];
let source = match std::fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", file, e);
return 1;
}
};
let tokens = match lexer::lex(&source) {
Ok(t) => t,
Err(e) => {
eprintln!("Lex error: {:?}", e);
return 1;
}
};
let token_spans: Vec<(lexer::Token, ast::Span)> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut program, _) = parser::parse(token_spans);
ast::resolve_aliases(&mut program);
ast::desugar_dot_var_index(&mut program);
program.source = Some(source);
let pg = graph::build_graph(&program);
let mut fn_name: Option<String> = None;
let mut reverse = false;
let mut subgraph = false;
let mut budget: Option<usize> = None;
let mut dot = false;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--fn" => {
if i + 1 >= args.len() {
eprintln!("error: --fn requires a function name");
return 1;
}
fn_name = Some(args[i + 1].clone());
i += 2;
}
"--reverse" => {
reverse = true;
i += 1;
}
"--subgraph" => {
subgraph = true;
i += 1;
}
"--budget" => {
if i + 1 >= args.len() {
eprintln!("error: --budget requires a number");
return 1;
}
match args[i + 1].parse::<usize>() {
Ok(v) => budget = Some(v),
Err(_) => {
eprintln!("error: --budget value must be a positive integer");
return 1;
}
}
i += 2;
}
"--dot" => {
dot = true;
i += 1;
}
_ => {
eprintln!("unknown flag: {}", args[i]);
return 1;
}
}
}
if dot {
print!("{}", graph::to_dot(&pg));
return 0;
}
fn emit_with_schema_version<T: serde::Serialize>(payload: &T) -> i32 {
let mut v = match serde_json::to_value(payload) {
Ok(v) => v,
Err(e) => {
eprintln!("error serialising graph: {}", e);
return 1;
}
};
if let Some(obj) = v.as_object_mut() {
obj.insert(
"schemaVersion".to_string(),
serde_json::Value::Number(1.into()),
);
}
match serde_json::to_string_pretty(&v) {
Ok(s) => {
println!("{}", s);
0
}
Err(e) => {
eprintln!("error serialising graph: {}", e);
1
}
}
}
if let Some(ref name) = fn_name {
if reverse {
match graph::query_reverse(&program, &pg, name) {
Some(r) => emit_with_schema_version(&r),
None => {
eprintln!("function '{}' not found", name);
1
}
}
} else if let Some(b) = budget {
match graph::query_budget(&program, &pg, name, b) {
Some(q) => emit_with_schema_version(&q),
None => {
eprintln!("function '{}' not found", name);
1
}
}
} else if subgraph {
match graph::query_subgraph(&program, &pg, name) {
Some(q) => emit_with_schema_version(&q),
None => {
eprintln!("function '{}' not found", name);
1
}
}
} else {
match graph::query_fn(&program, &pg, name) {
Some(q) => emit_with_schema_version(&q),
None => {
eprintln!("function '{}' not found", name);
1
}
}
}
} else {
emit_with_schema_version(&pg)
}
}
#[cfg(feature = "tools")]
fn collect_mcp_tool_decls(path: Option<&str>) -> Result<Vec<ast::Decl>, String> {
let path = match path {
Some(p) => p,
None => return Ok(vec![]),
};
let config = tools::mcp_provider::McpConfig::from_file(path).map_err(|e| e.to_string())?;
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
let provider = rt
.block_on(tools::mcp_provider::McpProvider::connect(&config))
.map_err(|e| format!("MCP error: {}", e))?;
Ok(provider.tool_decls())
}
#[cfg(not(feature = "tools"))]
fn collect_mcp_tool_decls(path: Option<&str>) -> Result<Vec<ast::Decl>, String> {
if path.is_some() {
return Err("error: --mcp requires the 'tools' feature \
(build with: cargo build --features tools)"
.to_string());
}
Ok(vec![])
}
fn httpd_cmd(port: u16, handler_file: &str, func_name: &str) -> i32 {
use std::net::TcpListener;
use std::sync::Arc;
let source = match std::fs::read_to_string(handler_file) {
Ok(s) => s,
Err(e) => {
eprintln!("error: cannot read handler file '{}': {}", handler_file, e);
return 1;
}
};
let tokens = match lexer::lex(&source) {
Ok(t) => t,
Err(e) => {
let d = Diagnostic::from(&e).with_source(source.clone());
eprint!("{}", AnsiRenderer { use_color: true }.render(&d));
return 1;
}
};
let token_spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut program, parse_errors) = parser::parse(token_spans);
ast::resolve_aliases(&mut program);
ast::desugar_dot_var_index(&mut program);
program.source = Some(source.clone());
if !parse_errors.is_empty() {
for e in &parse_errors {
let d = Diagnostic::from(e).with_source(source.clone());
eprint!("{}", AnsiRenderer { use_color: true }.render(&d));
}
return 1;
}
let base_dir: Option<std::path::PathBuf> = std::path::Path::new(handler_file)
.canonicalize()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()));
let mut visited = std::collections::HashSet::new();
if let Ok(canonical_file) = std::path::Path::new(handler_file).canonicalize() {
visited.insert(canonical_file);
}
let mut import_diagnostics: Vec<Diagnostic> = Vec::new();
program.declarations = resolve_imports(
program.declarations,
base_dir.as_deref(),
&mut visited,
&mut import_diagnostics,
BuildTarget::default(),
);
if !import_diagnostics.is_empty() {
for d in &import_diagnostics {
eprint!("{}", AnsiRenderer { use_color: true }.render(d));
}
return 1;
}
let vr = verify::verify(&program);
for w in &vr.warnings {
eprint!(
"{}",
AnsiRenderer { use_color: true }
.render(&Diagnostic::from(w).with_source(source.clone()))
);
}
if !vr.errors.is_empty() {
for e in &vr.errors {
eprint!(
"{}",
AnsiRenderer { use_color: true }
.render(&Diagnostic::from(e).with_source(source.clone()))
);
}
return 1;
}
let program = Arc::new(program);
let func = func_name.to_string();
let addr = format!("0.0.0.0:{}", port);
let listener = match TcpListener::bind(&addr) {
Ok(l) => l,
Err(e) => {
eprintln!("error: cannot bind to {}: {}", addr, e);
return 1;
}
};
eprintln!("ilo httpd listening on http://0.0.0.0:{}", port);
for stream in listener.incoming() {
let stream = match stream {
Ok(s) => s,
Err(e) => {
eprintln!("accept error: {}", e);
continue;
}
};
let program = Arc::clone(&program);
let func = func.clone();
std::thread::spawn(move || {
if let Err(e) = handle_http_connection(stream, &program, &func) {
eprintln!("connection error: {}", e);
}
});
}
0
}
fn handle_http_connection(
stream: std::net::TcpStream,
program: &ast::Program,
func_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
let peer = stream
.peer_addr()
.map(|a| a.to_string())
.unwrap_or_default();
let mut reader = BufReader::new(stream.try_clone()?);
let mut writer = stream;
let mut request_line = String::new();
reader.read_line(&mut request_line)?;
let request_line = request_line.trim_end();
let mut parts = request_line.splitn(3, ' ');
let method = parts.next().unwrap_or("GET").to_string();
let path = parts.next().unwrap_or("/").to_string();
let mut raw_headers: Vec<(String, String)> = Vec::new();
let mut content_length: usize = 0;
loop {
let mut line = String::new();
reader.read_line(&mut line)?;
let line = line.trim_end();
if line.is_empty() {
break;
}
if let Some((k, v)) = line.split_once(':') {
let key = k.trim().to_lowercase();
let val = v.trim().to_string();
if key == "content-length" {
content_length = val.parse().unwrap_or(0);
}
raw_headers.push((key, val));
}
}
let body = if content_length > 0 {
let mut buf = vec![0u8; content_length];
use std::io::Read;
reader.read_exact(&mut buf)?;
String::from_utf8_lossy(&buf).into_owned()
} else {
String::new()
};
use interpreter::MapKey;
use interpreter::Value;
let mut hdr_map: HashMap<interpreter::MapKey, Value> = HashMap::new();
for (k, v) in &raw_headers {
hdr_map.insert(
MapKey::Text(k.clone()),
Value::Text(std::sync::Arc::new(v.clone())),
);
}
let mut req_fields: HashMap<String, Value> = HashMap::new();
req_fields.insert(
"method".to_string(),
Value::Text(std::sync::Arc::new(method.clone())),
);
req_fields.insert(
"path".to_string(),
Value::Text(std::sync::Arc::new(path.clone())),
);
req_fields.insert(
"headers".to_string(),
Value::Map(std::sync::Arc::new(hdr_map)),
);
req_fields.insert("body".to_string(), Value::Text(std::sync::Arc::new(body)));
let req_val = Value::Record {
type_name: "Request".to_string(),
fields: req_fields,
};
let result = interpreter::run(program, Some(func_name), vec![req_val]);
let resp = match result {
Ok(v) => v,
Err(e) => {
let msg = format!("handler error: {}", e);
eprintln!("{}", msg);
let body = format!("Internal Server Error: {}\n", e);
let resp_bytes = format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
writer.write_all(resp_bytes.as_bytes())?;
eprintln!("{} {} {} -> 500", peer, method, path);
return Ok(());
}
};
let resp = match resp {
Value::Ok(inner) => *inner,
Value::Err(e) => {
let body = format!("Handler returned Err: {}\n", e);
let resp_bytes = format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
writer.write_all(resp_bytes.as_bytes())?;
eprintln!("{} {} {} -> 500 (Err)", peer, method, path);
return Ok(());
}
other => other,
};
enum BodyShape {
Plain(String),
Chunked(Vec<String>),
}
let (status, resp_headers, body_shape) = match &resp {
Value::Record { fields, .. } => {
let status = match fields.get("status") {
Some(Value::Number(n)) => *n as u16,
_ => 200,
};
let body_shape = match fields.get("body") {
Some(Value::Text(s)) => BodyShape::Plain((**s).clone()),
Some(Value::List(items)) => {
let chunks = items.iter().map(|v| v.to_string()).collect();
BodyShape::Chunked(chunks)
}
Some(Value::FnRef(name)) => {
match interpreter::run(program, Some(name.as_str()), vec![]) {
Ok(Value::List(items)) => {
let chunks = items.iter().map(|v| v.to_string()).collect();
BodyShape::Chunked(chunks)
}
Ok(other) => BodyShape::Plain(other.to_string()),
Err(e) => BodyShape::Plain(format!("chunk-fn error: {}", e)),
}
}
Some(Value::Closure { fn_name, .. }) => {
match interpreter::run(program, Some(fn_name.as_str()), vec![]) {
Ok(Value::List(items)) => {
let chunks = items.iter().map(|v| v.to_string()).collect();
BodyShape::Chunked(chunks)
}
Ok(other) => BodyShape::Plain(other.to_string()),
Err(e) => BodyShape::Plain(format!("chunk-fn error: {}", e)),
}
}
Some(other) => BodyShape::Plain(other.to_string()),
None => BodyShape::Plain(String::new()),
};
let resp_headers: Vec<(String, String)> = match fields.get("headers") {
Some(Value::Map(m)) => m
.iter()
.map(|(k, v)| {
let ks = match k {
MapKey::Text(s) => s.clone(),
MapKey::Int(n) => n.to_string(),
};
let vs = match v {
Value::Text(s) => (**s).clone(),
other => other.to_string(),
};
(ks, vs)
})
.collect(),
_ => vec![],
};
(status, resp_headers, body_shape)
}
Value::Text(s) => (200u16, vec![], BodyShape::Plain((**s).clone())),
other => (200u16, vec![], BodyShape::Plain(other.to_string())),
};
let status_text = match status {
200 => "OK",
201 => "Created",
204 => "No Content",
400 => "Bad Request",
401 => "Unauthorized",
403 => "Forbidden",
404 => "Not Found",
500 => "Internal Server Error",
_ => "OK",
};
let has_content_type = resp_headers
.iter()
.any(|(k, _)| k.to_lowercase() == "content-type");
match body_shape {
BodyShape::Plain(resp_body) => {
let mut header_block = format!("HTTP/1.1 {} {}\r\n", status, status_text);
if !has_content_type {
header_block.push_str("Content-Type: text/plain; charset=utf-8\r\n");
}
for (k, v) in &resp_headers {
header_block.push_str(&format!("{}: {}\r\n", k, v));
}
header_block.push_str(&format!("Content-Length: {}\r\n", resp_body.len()));
header_block.push_str("Connection: close\r\n");
header_block.push_str("\r\n");
writer.write_all(header_block.as_bytes())?;
writer.write_all(resp_body.as_bytes())?;
}
BodyShape::Chunked(chunks) => {
let mut header_block = format!("HTTP/1.1 {} {}\r\n", status, status_text);
if !has_content_type {
header_block.push_str("Content-Type: text/plain; charset=utf-8\r\n");
}
for (k, v) in &resp_headers {
header_block.push_str(&format!("{}: {}\r\n", k, v));
}
header_block.push_str("Transfer-Encoding: chunked\r\n");
header_block.push_str("Connection: close\r\n");
header_block.push_str("\r\n");
writer.write_all(header_block.as_bytes())?;
for chunk in &chunks {
let data = chunk.as_bytes();
if !data.is_empty() {
writer.write_all(format!("{:x}\r\n", data.len()).as_bytes())?;
writer.write_all(data)?;
writer.write_all(b"\r\n")?;
}
}
writer.write_all(b"0\r\n\r\n")?;
}
}
eprintln!("{} {} {} -> {}", peer, method, path, status);
Ok(())
}
fn diag_to_json(d: &Diagnostic) -> serde_json::Value {
let s = diagnostic::json::render(d);
serde_json::from_str(&s).unwrap_or(serde_json::json!({"message": s}))
}
fn process_serv_request(
line: &str,
mcp_tool_decls: &[ast::Decl],
#[cfg(feature = "tools")] provider: Option<std::sync::Arc<dyn tools::ToolProvider>>,
#[cfg_attr(not(feature = "tools"), allow(unused_variables))] http_config: Option<
&tools::http_provider::ToolsConfig,
>,
#[cfg(feature = "tools")] rt: std::sync::Arc<tokio::runtime::Runtime>,
) -> serde_json::Value {
#[derive(serde::Deserialize)]
struct Req {
program: String,
#[serde(default)]
args: Vec<String>,
func: Option<String>,
}
let req: Req = match serde_json::from_str(line) {
Ok(r) => r,
Err(e) => {
return serde_json::json!({
"schemaVersion": 1,
"error": {"phase": "request", "message": format!("invalid JSON: {e}")}
});
}
};
let start = std::time::Instant::now();
let source = req.program.clone();
let tokens = match lexer::lex(&source) {
Ok(t) => t,
Err(e) => {
return serde_json::json!({
"schemaVersion": 1,
"error": {
"phase": "lex",
"diagnostics": [diag_to_json(&Diagnostic::from(&e))]
}
});
}
};
let token_spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut program, parse_errors) = parser::parse(token_spans);
ast::resolve_aliases(&mut program);
ast::desugar_dot_var_index(&mut program);
program.source = Some(source.clone());
if !parse_errors.is_empty() {
let diags: Vec<_> = parse_errors
.iter()
.map(|e| diag_to_json(&Diagnostic::from(e)))
.collect();
return serde_json::json!({"schemaVersion": 1, "error": {"phase": "parse", "diagnostics": diags}});
}
if !mcp_tool_decls.is_empty() {
let mut decls = mcp_tool_decls.to_vec();
decls.append(&mut program.declarations);
program.declarations = decls;
}
let vr = verify::verify(&program);
if !vr.errors.is_empty() {
let diags: Vec<_> = vr
.errors
.iter()
.map(|e| diag_to_json(&Diagnostic::from(e).with_source(source.clone())))
.collect();
return serde_json::json!({"schemaVersion": 1, "error": {"phase": "verify", "diagnostics": diags}});
}
let func_name = req.func.as_deref();
let run_args = parse_cli_args_typed(&program, func_name, &req.args);
#[cfg(feature = "tools")]
let result = if let Some(p) = provider {
interpreter::run_with_tools(&program, func_name, run_args, p, rt)
} else if let Some(cfg) = http_config {
let p = std::sync::Arc::new(tools::http_provider::HttpProvider::new(cfg.clone()));
interpreter::run_with_tools(&program, func_name, run_args, p, rt)
} else {
interpreter::run(&program, func_name, run_args)
};
#[cfg(not(feature = "tools"))]
let result = interpreter::run(&program, func_name, run_args);
let ms = start.elapsed().as_millis() as u64;
match result {
Ok(value) => match value {
interpreter::Value::Ok(inner) => {
let v = inner.to_json().unwrap_or(serde_json::Value::Null);
serde_json::json!({"schemaVersion": 1, "ok": v, "ms": ms})
}
interpreter::Value::Err(inner) => {
let v = inner
.to_json()
.unwrap_or_else(|_| serde_json::Value::String(inner.to_string()));
serde_json::json!({"schemaVersion": 1, "error": {"phase": "program", "value": v}, "ms": ms})
}
other => {
let v = other
.to_json()
.unwrap_or_else(|_| serde_json::Value::String(other.to_string()));
serde_json::json!({"schemaVersion": 1, "ok": v, "ms": ms})
}
},
Err(e) => {
let d = Diagnostic::from(&e).with_source(source);
serde_json::json!({"schemaVersion": 1, "error": {"phase": "runtime", "diagnostics": [diag_to_json(&d)]}})
}
}
}
fn type_to_ilo(ty: &ast::Type) -> String {
match ty {
ast::Type::Number => "n".to_string(),
ast::Type::Text => "t".to_string(),
ast::Type::Bool => "b".to_string(),
ast::Type::Any => "_".to_string(),
ast::Type::Optional(inner) => format!("O {}", type_to_ilo(inner)),
ast::Type::List(inner) => format!("L {}", type_to_ilo(inner)),
ast::Type::Map(k, v) => format!("M {} {}", type_to_ilo(k), type_to_ilo(v)),
ast::Type::Result(ok, err) => format!("R {} {}", type_to_ilo(ok), type_to_ilo(err)),
ast::Type::Sum(variants) => format!("S {}", variants.join(" ")),
ast::Type::Fn(params, ret) => {
let ps: Vec<_> = params.iter().map(type_to_ilo).collect();
format!("F {} {}", ps.join(" "), type_to_ilo(ret))
}
ast::Type::Named(name) => name.clone(),
ast::Type::U32 => "U32".to_string(),
ast::Type::U64 => "U64".to_string(),
ast::Type::I64 => "I64".to_string(),
}
}
fn brace_depth(s: &str) -> i32 {
let mut depth: i32 = 0;
let mut in_string = false;
let mut prev_backslash = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '-' && chars.peek() == Some(&'-') && !in_string {
break; }
if c == '"' {
if in_string && prev_backslash {
prev_backslash = false;
continue;
}
in_string = !in_string;
prev_backslash = false;
continue;
}
if in_string {
prev_backslash = c == '\\' && !prev_backslash;
continue;
}
prev_backslash = false;
if c == '{' {
depth += 1;
} else if c == '}' {
depth -= 1;
}
}
depth
}
fn repl_cmd() {
use std::io::{BufRead, Write};
let version = env!("CARGO_PKG_VERSION");
let renderer = AnsiRenderer { use_color: true };
println!("ilo {version} — type :help for commands, :q to quit\n");
let mut defs: Vec<String> = Vec::new();
let stdin = std::io::stdin();
let mut reader = stdin.lock();
loop {
print!("> ");
std::io::stdout().flush().ok();
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break, Ok(_) => {}
Err(_) => break,
}
let mut input_buf = line.trim().to_string();
if input_buf.is_empty() {
continue;
}
while brace_depth(&input_buf) > 0 || input_buf.ends_with(';') {
print!(".. ");
std::io::stdout().flush().ok();
let mut cont = String::new();
match reader.read_line(&mut cont) {
Ok(0) => break, Ok(_) => {}
Err(_) => break,
}
let trimmed = cont.trim();
if trimmed.is_empty() {
break; }
if !input_buf.ends_with('{') && !input_buf.ends_with(';') && !trimmed.starts_with('}') {
input_buf.push(';');
}
input_buf.push_str(trimmed);
}
let input = input_buf.trim();
if input.is_empty() {
continue;
}
if input.starts_with(':') {
match input {
":q" | ":q!" | ":x" | ":quit" | ":exit" => break,
":wq" => {
if defs.is_empty() {
eprintln!("no definitions to save");
} else {
eprintln!("usage: :w <file.ilo>");
}
continue;
}
_ if input.starts_with(":wq ") || input.starts_with(":w ") => {
let is_wq = input.starts_with(":wq");
let path = match input.split_once(' ') {
Some((_, p)) => p.trim(),
None => {
eprintln!("usage: :w <file.ilo>");
continue;
}
};
if defs.is_empty() {
eprintln!("no definitions to save");
} else if let Err(e) = std::fs::write(path, defs.join(" ") + "\n") {
eprintln!("error: {e}");
} else {
println!("saved {} definition(s) to {path}", defs.len());
}
if is_wq {
break;
} else {
continue;
}
}
":defs" => {
if defs.is_empty() {
println!("(no definitions)");
} else {
for d in &defs {
println!(" {d}");
}
}
continue;
}
":clear" => {
defs.clear();
println!("cleared all definitions");
continue;
}
":help" => {
println!(":q :q! :x :quit :exit quit");
println!(":w <file> save definitions to file");
println!(":wq <file> save and quit");
println!(":defs list defined functions");
println!(":clear clear all definitions");
println!(":help show this help");
continue;
}
_ => {
eprintln!("unknown command: {input} (type :help)");
continue;
}
}
}
if input == "exit" || input == "quit" {
break;
}
let def_program = {
let tokens = lexer::lex(input);
if let Ok(tokens) = tokens {
let token_spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (program, errors) = parser::parse(token_spans);
if errors.is_empty()
&& !program.declarations.is_empty()
&& program.declarations.iter().all(|d| {
matches!(
d,
ast::Decl::Function { .. }
| ast::Decl::TypeDef { .. }
| ast::Decl::Alias { .. }
)
})
{
Some(program)
} else {
None
}
} else {
None
}
};
if let Some(program) = def_program {
defs.push(input.to_string());
for d in &program.declarations {
match d {
ast::Decl::Function {
name,
params,
return_type,
..
} => {
let params_str: Vec<_> = params
.iter()
.map(|p| format!("{}:{}", p.name, type_to_ilo(&p.ty)))
.collect();
println!(
"defined: {}({}) -> {}",
name,
params_str.join(", "),
type_to_ilo(return_type)
);
}
ast::Decl::TypeDef { name, fields, .. } => {
let fields_str: Vec<_> = fields
.iter()
.map(|f| format!("{}:{}", f.name, type_to_ilo(&f.ty)))
.collect();
println!("defined type: {}{{{}}}", name, fields_str.join(";"));
}
ast::Decl::Alias { name, target, .. } => {
println!("defined alias: {} = {}", name, type_to_ilo(target));
}
_ => {}
}
}
continue;
}
let full_source = if defs.is_empty() {
format!("repleval>n;{input}")
} else {
format!("{} repleval>n;{input}", defs.join(" "))
};
let tokens = match lexer::lex(&full_source) {
Ok(t) => t,
Err(e) => {
let d = Diagnostic::from(&e);
eprintln!("{}", renderer.render(&d));
continue;
}
};
let token_spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut full_program, parse_errors) = parser::parse(token_spans);
ast::resolve_aliases(&mut full_program);
ast::desugar_dot_var_index(&mut full_program);
full_program.source = Some(full_source.clone());
if !parse_errors.is_empty() {
for e in &parse_errors {
let d = Diagnostic::from(e);
eprintln!("{}", renderer.render(&d));
}
continue;
}
match interpreter::run(&full_program, Some("repleval"), vec![]) {
Ok(value) => println!("{value}"),
Err(e) => {
let d = Diagnostic::from(&e).with_source(full_source);
eprintln!("{}", renderer.render(&d));
}
}
}
}
#[cfg(feature = "cranelift")]
fn compile_cmd(args: &[String]) -> i32 {
if args.is_empty() {
eprintln!("Usage: ilo compile <file-or-code> [-o output] [func]");
return 1;
}
let mut output_path: Option<String> = None;
let mut source_arg: Option<&str> = None;
let mut func_name: Option<&str> = None;
let mut bench_mode = false;
let mut as_json = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-o" => {
i += 1;
if i >= args.len() {
eprintln!("Error: -o requires a path argument");
return 1;
}
output_path = Some(args[i].clone());
}
"--bench" => {
bench_mode = true;
}
"--json" | "-j" => {
as_json = true;
}
_ if source_arg.is_none() => {
source_arg = Some(&args[i]);
}
_ => {
func_name = Some(&args[i]);
}
}
i += 1;
}
let source_arg = match source_arg {
Some(s) => s,
None => {
eprintln!("Error: no source file or code provided");
return 1;
}
};
let source = if std::path::Path::new(source_arg).is_file() {
match std::fs::read_to_string(source_arg) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", source_arg, e);
return 1;
}
}
} else {
source_arg.to_string()
};
let output = output_path.unwrap_or_else(|| {
if source_arg.ends_with(".ilo") {
source_arg.trim_end_matches(".ilo").to_string()
} else {
"a.out".to_string()
}
});
let tokens = match lexer::lex(&source) {
Ok(t) => t,
Err(e) => {
eprint!(
"{}",
AnsiRenderer { use_color: true }
.render(&Diagnostic::from(&e).with_source(source.clone()),)
);
return 1;
}
};
let token_spans: Vec<(lexer::Token, ast::Span)> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut program, parse_errors) = parser::parse(token_spans);
if !parse_errors.is_empty() {
for e in &parse_errors {
let d = Diagnostic::from(e).with_source(source.clone());
eprint!("{}", AnsiRenderer { use_color: true }.render(&d));
}
return 1;
}
ast::desugar_dot_var_index(&mut program);
let base_dir: Option<std::path::PathBuf> = if std::path::Path::new(source_arg).is_file() {
std::path::Path::new(source_arg)
.canonicalize()
.ok()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
} else {
None
};
let mut visited = std::collections::HashSet::new();
let mut import_diagnostics: Vec<Diagnostic> = Vec::new();
program.declarations = resolve_imports(
program.declarations,
base_dir.as_deref(),
&mut visited,
&mut import_diagnostics,
BuildTarget::default(),
);
if !import_diagnostics.is_empty() {
for d in &import_diagnostics {
eprint!("{}", AnsiRenderer { use_color: true }.render(d));
}
return 1;
}
let verify_result = verify::verify(&program);
for w in &verify_result.warnings {
eprint!(
"{}",
AnsiRenderer { use_color: true }
.render(&Diagnostic::from(w).with_source(source.clone()),)
);
}
if !verify_result.errors.is_empty() {
for e in &verify_result.errors {
eprint!(
"{}",
AnsiRenderer { use_color: true }
.render(&Diagnostic::from(e).with_source(source.clone()),)
);
}
return 1;
}
let compiled = match vm::compile(&program) {
Ok(c) => c,
Err(e) => {
eprintln!("Compile error: {}", e);
return 1;
}
};
let user_fn_names: Vec<&str> = program
.declarations
.iter()
.filter_map(|d| match d {
ast::Decl::Function { name, .. } | ast::Decl::Tool { name, .. }
if !name.starts_with("__") =>
{
Some(name.as_str())
}
_ => None,
})
.collect();
let entry: &str = if let Some(name) = func_name {
name
} else if user_fn_names.len() == 1 {
user_fn_names[0]
} else if user_fn_names.contains(&"main") {
"main"
} else {
eprintln!(
"error[ILO-E801]: AOT compile needs an entry function but no `main` is defined and no entry name was supplied"
);
if user_fn_names.is_empty() {
eprintln!("note: this file declares no functions");
} else {
eprintln!("available functions:");
for n in &user_fn_names {
eprintln!(" {}", n);
}
}
eprintln!();
eprintln!(
" ilo compile {} <func> compile and call <func> as the entry point",
source_arg
);
eprintln!(
" ilo compile {} -o <out> <func> compile to <out> with <func> as the entry point",
source_arg
);
return 1;
};
let start = std::time::Instant::now();
let result = if bench_mode {
vm::compile_cranelift::compile_to_bench_binary(&compiled, entry, &output)
} else {
vm::compile_cranelift::compile_to_binary(&compiled, entry, &output)
};
let duration_ms = start.elapsed().as_millis();
match result {
Ok(()) => {
if as_json {
let size_bytes = std::fs::metadata(&output).map(|m| m.len()).ok();
let v = serde_json::json!({
"schemaVersion": 1,
"ok": true,
"output": output,
"entry": entry,
"bench": bench_mode,
"sizeBytes": size_bytes,
"durationMs": duration_ms,
});
println!("{}", v);
} else {
eprintln!("Compiled: {}", output);
}
0
}
Err(e) => {
if as_json {
let v = serde_json::json!({
"schemaVersion": 1,
"ok": false,
"error": {
"phase": "aot-compile",
"message": e.to_string(),
}
});
println!("{}", v);
} else {
eprintln!("AOT compile error: {}", e);
}
1
}
}
}
#[cfg(not(feature = "cranelift"))]
fn compile_cmd(_args: &[String]) -> i32 {
eprintln!("Error: AOT compilation requires the cranelift feature (--features cranelift)");
1
}
fn serv_cmd(args_slice: &[String]) {
let mut mcp_path: Option<String> = None;
let mut http_path: Option<String> = None;
let mut i = 0;
while i < args_slice.len() {
match args_slice[i].as_str() {
"--mcp" | "-m" => {
if i + 1 >= args_slice.len() {
eprintln!("error: --mcp requires a path");
std::process::exit(1);
}
mcp_path = Some(args_slice[i + 1].clone());
i += 2;
}
"--tools" | "-t" => {
if i + 1 >= args_slice.len() {
eprintln!("error: --tools requires a path");
std::process::exit(1);
}
http_path = Some(args_slice[i + 1].clone());
i += 2;
}
"-j" | "--json" => {
i += 1;
}
_ => {
eprintln!("unknown flag: {}", args_slice[i]);
eprintln!("Usage: ilo repl [-j] [--mcp <path>] [--tools <path>]");
std::process::exit(1);
}
}
}
let http_config: Option<tools::http_provider::ToolsConfig> = http_path.as_ref().map(|p| {
tools::http_provider::ToolsConfig::from_file(p).unwrap_or_else(|e| {
eprintln!("{}", e);
std::process::exit(1);
})
});
#[cfg(feature = "tools")]
let rt = std::sync::Arc::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime"),
);
#[cfg(feature = "tools")]
let (mcp_tool_decls, mcp_provider_arc): (
Vec<ast::Decl>,
Option<std::sync::Arc<dyn tools::ToolProvider>>,
) = if let Some(ref path) = mcp_path {
let config = tools::mcp_provider::McpConfig::from_file(path).unwrap_or_else(|e| {
eprintln!("{}", e);
std::process::exit(1);
});
let provider = rt
.block_on(tools::mcp_provider::McpProvider::connect(&config))
.unwrap_or_else(|e| {
eprintln!("MCP error: {}", e);
std::process::exit(1);
});
let decls = provider.tool_decls();
(decls, Some(std::sync::Arc::new(provider)))
} else {
(vec![], None)
};
#[cfg(not(feature = "tools"))]
let mcp_tool_decls: Vec<ast::Decl> = {
if mcp_path.is_some() {
eprintln!(
"error: --mcp requires the 'tools' feature \
(build with: cargo build --features tools)"
);
std::process::exit(1);
}
vec![]
};
println!("{}", serde_json::json!({"schemaVersion": 1, "ready": true}));
use std::io::BufRead;
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(e) => {
eprintln!("stdin read error: {}", e);
break;
}
};
if line.trim().is_empty() {
continue;
}
let resp = process_serv_request(
&line,
&mcp_tool_decls,
#[cfg(feature = "tools")]
mcp_provider_arc.as_ref().map(std::sync::Arc::clone),
http_config.as_ref(),
#[cfg(feature = "tools")]
std::sync::Arc::clone(&rt),
);
println!("{}", resp);
}
}
fn extract_run_engine_flag(
args: Vec<String>,
) -> Result<(Option<cli::Engine>, Vec<String>), String> {
let mut engine: Option<cli::Engine> = None;
let mut conflict = false;
let mut saw_run_vm_alias = false;
let mut remaining = Vec::with_capacity(args.len());
for arg in args {
let candidate = match arg.as_str() {
"--jit" => Some(cli::Engine::Cranelift),
"--run-llvm" => Some(cli::Engine::Llvm),
"--vm" => Some(cli::Engine::Vm),
"--run-vm" => {
saw_run_vm_alias = true;
Some(cli::Engine::Vm)
}
_ => None,
};
match candidate {
Some(e) => match engine {
None => engine = Some(e),
Some(prev) if prev == e => {} Some(_) => conflict = true,
},
None => remaining.push(arg),
}
}
if conflict {
return Err("error: --vm, --jit, --run-llvm are mutually exclusive".to_string());
}
if saw_run_vm_alias {
emit_run_vm_alias_hint();
}
Ok((engine, remaining))
}
fn emit_run_vm_alias_hint() {
use std::sync::atomic::{AtomicBool, Ordering};
static EMITTED: AtomicBool = AtomicBool::new(false);
if EMITTED.swap(true, Ordering::Relaxed) {
return;
}
eprintln!(
"hint: --run-vm → --vm (canonical form). The --run-vm alias will be removed in 0.13.0."
);
}
fn detect_output_mode(args: Vec<String>) -> (OutputMode, bool, bool, bool, Vec<String>) {
let mut mode: Option<OutputMode> = None;
let mut remaining = Vec::with_capacity(args.len());
let mut conflict = false;
let mut no_hints = false;
let mut silent = false;
for arg in args {
match arg.as_str() {
"--json" | "-j" => {
if mode.is_some() {
conflict = true;
} else {
mode = Some(OutputMode::Json);
}
}
"--text" | "-t" => {
if mode.is_some() {
conflict = true;
} else {
mode = Some(OutputMode::Text);
}
}
"--ansi" | "-a" => {
if mode.is_some() {
conflict = true;
} else {
mode = Some(OutputMode::Ansi);
}
}
"--no-hints" | "-nh" => {
no_hints = true;
}
"--silent" | "-s" => {
silent = true;
}
_ => remaining.push(arg),
}
}
if conflict {
eprintln!("error: --json, --text, and --ansi are mutually exclusive");
std::process::exit(1);
}
let explicit_json = matches!(mode, Some(OutputMode::Json));
let resolved = mode.unwrap_or_else(|| {
use std::io::IsTerminal;
let is_tty = std::io::stderr().is_terminal();
let no_color = std::env::var("NO_COLOR").is_ok();
if is_tty && !no_color {
OutputMode::Ansi
} else if is_tty {
OutputMode::Text
} else {
OutputMode::Json
}
});
(resolved, explicit_json, no_hints, silent, remaining)
}
fn strip_string_contents(source: &str) -> String {
let mut result = String::with_capacity(source.len());
let mut in_string = false;
let mut chars = source.chars().peekable();
while let Some(c) = chars.next() {
if in_string {
if c == '\\' {
result.push(' ');
if chars.next().is_some() {
result.push(' ');
}
} else if c == '"' {
result.push('"');
in_string = false;
} else {
result.push(' ');
}
} else if c == '"' {
result.push('"');
in_string = true;
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
fn collect_hints(source: &str) -> Vec<String> {
collect_hints_with_program(source, None)
}
fn collect_hints_with_program(source: &str, program: Option<&ast::Program>) -> Vec<String> {
let mut hints = Vec::new();
let stripped = strip_string_and_comment_contents(source);
let mut pos = 0;
let bytes = stripped.as_bytes();
while pos + 1 < bytes.len() {
if bytes[pos] == b'=' && bytes[pos + 1] == b'=' {
hints.push("hint: `==` → `=` saves 1 char (both mean equality in ilo)".to_string());
break; }
pos += 1;
}
if let Ok(tokens) = lexer::lex(source) {
for (tok, _) in &tokens {
if let lexer::Token::Ident(word) = tok
&& let Some(canonical) = ast::resolve_alias(word)
{
hints.push(format!("hint: `{word}` → `{canonical}` (canonical form)"));
break; }
}
if let Some(hint) = detect_prefix_precedence_trap(&tokens) {
hints.push(hint);
}
}
if let Some(prog) = program {
for decl in &prog.declarations {
if let ast::Decl::Function { body, .. } = decl
&& walk_for_discarded_guard_result(body)
{
hints.push(
"hint: `cond{^\"err\"}` and `cond{~v}` discard the body expression. For early-return use the braceless form `cond ^\"err\"` or wrap the body in `{ret ^\"err\"}`."
.to_string(),
);
break; }
}
}
hints
}
fn walk_for_discarded_guard_result(body: &[ast::Spanned<ast::Stmt>]) -> bool {
for s in body {
if walk_stmt_for_discarded_guard_result(&s.node) {
return true;
}
}
false
}
fn walk_stmt_for_discarded_guard_result(stmt: &ast::Stmt) -> bool {
match stmt {
ast::Stmt::Guard {
body,
else_body,
braceless,
..
} => {
if !*braceless
&& body.len() == 1
&& let Some(only) = body.first()
&& matches!(
only.node,
ast::Stmt::Expr(ast::Expr::Err(_) | ast::Expr::Ok(_))
)
{
return true;
}
if walk_for_discarded_guard_result(body) {
return true;
}
if let Some(eb) = else_body
&& walk_for_discarded_guard_result(eb)
{
return true;
}
false
}
ast::Stmt::Match { arms, .. } => arms
.iter()
.any(|a| walk_for_discarded_guard_result(&a.body)),
ast::Stmt::ForEach { body, .. }
| ast::Stmt::ForRange { body, .. }
| ast::Stmt::While { body, .. } => walk_for_discarded_guard_result(body),
_ => false,
}
}
fn detect_prefix_precedence_trap(
tokens: &[(lexer::Token, std::ops::Range<usize>)],
) -> Option<String> {
use lexer::Token::*;
for i in 0..tokens.len().saturating_sub(1) {
let op1 = &tokens[i].0;
let op2 = &tokens[i + 1].0;
let pair = match (op1, op2) {
(Star, Slash) => Some(("*/", "(a/b)*c", "/*a b c", "r=*a b;/r c")),
(Slash, Star) => Some(("/*", "(a*b)/c", "*/a b c", "r=/a b;*r c")),
(Plus, Minus) => Some(("+-", "(a-b)+c", "-+a b c", "r=+a b;- r c")),
(Minus, Plus) => Some(("-+", "(a+b)-c", "+-a b c", "r=-a b;+r c")),
_ => None,
};
let Some((shape, parsed_as, swap_form, bind_form)) = pair else {
continue;
};
if i > 0 && is_value_yielding(&tokens[i - 1].0) {
continue;
}
if i > 0 && matches!(&tokens[i - 1].0, LParen | LBracket | LBrace) {
continue;
}
if i + 2 >= tokens.len() || !is_value_yielding(&tokens[i + 2].0) {
continue;
}
return Some(format!(
"hint: `{shape}a b c` parses as `{parsed_as}` (inner prefix op binds first). \
For the other order, swap the ops (`{swap_form}`) or bind: `{bind_form}`",
));
}
None
}
fn is_value_yielding(tok: &lexer::Token) -> bool {
use lexer::Token::*;
matches!(
tok,
Ident(_)
| Number(_)
| Text(_)
| True
| False
| Nil
| RParen
| RBracket
| RBrace
| Bang
| BangBang
)
}
fn strip_string_and_comment_contents(source: &str) -> String {
let stripped = strip_string_contents(source);
let mut out = String::with_capacity(stripped.len());
let mut chars = stripped.chars().peekable();
let mut in_comment = false;
while let Some(c) = chars.next() {
if in_comment {
if c == '\n' {
in_comment = false;
out.push(c);
} else {
out.push(' ');
}
} else if c == '-' && chars.peek() == Some(&'-') {
chars.next();
out.push(' ');
out.push(' ');
in_comment = true;
} else {
out.push(c);
}
}
out
}
fn emit_hints(hints: &[String], mode: OutputMode) {
if hints.is_empty() {
return;
}
match mode {
OutputMode::Ansi | OutputMode::Text => {
for hint in hints {
eprintln!("{hint}");
}
}
OutputMode::Json => {
let json = serde_json::json!({ "hints": hints });
eprintln!("{}", json);
}
}
}
fn warn_cross_language_syntax(source: &str, mode: OutputMode) {
let patterns: &[(&str, &str)] = &[
("&&", "'&&' — ilo uses '&' for AND"),
("||", "'||' — ilo uses '|' for OR"),
("->", "'->' — ilo uses '>' for return type separator"),
("//", "'//' — ilo uses '--' for comments"),
];
let stripped = strip_string_and_comment_contents(source);
let details: Vec<&str> = patterns
.iter()
.filter(|(pat, _)| stripped.contains(*pat))
.map(|(_, desc)| *desc)
.collect();
if details.is_empty() {
return;
}
let msg = format!(
"source contains syntax from another language: {}",
details.join(", ")
);
let d = Diagnostic::warning(msg);
report_diagnostic(&d, mode);
}
fn decl_name(decl: &ast::Decl) -> Option<&str> {
match decl {
ast::Decl::Function { name, .. } => Some(name),
ast::Decl::Tool { name, .. } => Some(name),
ast::Decl::TypeDef { name, .. } => Some(name),
ast::Decl::Alias { name, .. } => Some(name),
ast::Decl::SumType { name, .. } => Some(name),
ast::Decl::Use { .. } | ast::Decl::VersionPragma { .. } | ast::Decl::Error { .. } => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BuildTarget {
#[default]
Native,
Wasm,
Test,
}
impl BuildTarget {
pub fn eval(self, pred: ast::UsePredicate) -> bool {
matches!(
(self, pred),
(BuildTarget::Wasm, ast::UsePredicate::Wasm)
| (BuildTarget::Native, ast::UsePredicate::Native)
| (BuildTarget::Test, ast::UsePredicate::Test)
)
}
}
fn apply_only_filter(
decls: Vec<ast::Decl>,
only: &Option<Vec<String>>,
path: &str,
span: ast::Span,
diagnostics: &mut Vec<Diagnostic>,
) -> Vec<ast::Decl> {
let Some(names) = only else {
return decls;
};
for name in names {
let found = decls.iter().any(|d| decl_name(d) == Some(name.as_str()));
if !found {
diagnostics.push(
Diagnostic::error(format!(
"use \"{}\": name '{}' not found in imported file",
path, name
))
.with_code("ILO-P019")
.with_span(span, "imported here"),
);
}
}
decls
.into_iter()
.filter(|d| {
decl_name(d)
.map(|n| names.iter().any(|s| s == n))
.unwrap_or(false)
})
.collect()
}
fn resolve_imports(
decls: Vec<ast::Decl>,
base_dir: Option<&std::path::Path>,
visited: &mut std::collections::HashSet<std::path::PathBuf>,
diagnostics: &mut Vec<Diagnostic>,
build_target: BuildTarget,
) -> Vec<ast::Decl> {
let mut eager_decls: Vec<ast::Decl> = Vec::new();
let mut lazy_pending: Vec<ast::Decl> = Vec::new();
for decl in decls {
if matches!(&decl, ast::Decl::Use { lazy: true, .. }) {
lazy_pending.push(decl);
} else {
eager_decls.push(decl);
}
}
let mut result = resolve_imports_inner(
eager_decls,
base_dir,
visited,
diagnostics,
build_target,
false,
)
.0;
for decl in lazy_pending {
if let ast::Decl::Use { ref alias, .. } = decl {
let prefix = format!("{}-", alias.as_deref().unwrap_or(""));
if decls_reference_prefix(&result, &prefix) {
let loaded = resolve_imports_inner(
vec![decl],
base_dir,
visited,
diagnostics,
build_target,
false,
)
.0;
let mut combined = loaded;
combined.extend(result);
result = combined;
}
}
}
result
}
fn resolve_imports_inner(
decls: Vec<ast::Decl>,
base_dir: Option<&std::path::Path>,
visited: &mut std::collections::HashSet<std::path::PathBuf>,
diagnostics: &mut Vec<Diagnostic>,
build_target: BuildTarget,
for_export: bool,
) -> (Vec<ast::Decl>, Vec<String>) {
let _ = for_export; let mut result: Vec<ast::Decl> = Vec::new();
let mut exported_names: std::collections::HashSet<String> = std::collections::HashSet::new();
for decl in decls {
if let ast::Decl::Use {
path,
only,
alias,
predicate,
alt_path,
reexport,
lazy: _,
span,
} = decl
{
let path = if let Some(pred) = predicate {
if build_target.eval(pred) {
path
} else {
alt_path.unwrap_or(path)
}
} else {
path
};
if ilo::pkg::is_pkg_path(&path) {
let resolved = ilo::pkg::resolve_pkg_path(&path);
match resolved {
Err(msg) => {
diagnostics.push(
Diagnostic::error(format!("use \"{path}\": {msg}"))
.with_code("ILO-P017")
.with_span(span, "imported here"),
);
continue;
}
Ok(pkg_file) => {
let abs = pkg_file.to_string_lossy().into_owned();
let synthetic = ast::Decl::Use {
path: abs,
only: only.clone(),
alias: alias.clone(),
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span,
};
let mut sub = resolve_imports(
vec![synthetic],
None, visited,
diagnostics,
build_target,
);
result.append(&mut sub);
continue;
}
}
}
let Some(dir) = base_dir else {
if path.starts_with('/') {
let canonical = match std::path::PathBuf::from(&path).canonicalize() {
Ok(c) => c,
Err(_) => {
diagnostics.push(
Diagnostic::error(format!("use \"{}\": file not found", path))
.with_code("ILO-P017")
.with_span(span, "imported here"),
);
continue;
}
};
let imported_dir = canonical.parent().map(|p| p.to_path_buf());
let source = match std::fs::read_to_string(&canonical) {
Ok(s) => s,
Err(e) => {
diagnostics.push(
Diagnostic::error(format!("use \"{}\": {}", path, e))
.with_code("ILO-P017")
.with_span(span, "imported here"),
);
continue;
}
};
if visited.contains(&canonical) {
diagnostics.push(
Diagnostic::error(format!("use \"{}\": circular import", path))
.with_code("ILO-P018")
.with_span(span, "imported here"),
);
continue;
}
let tokens = match lexer::lex(&source) {
Ok(t) => t,
Err(e) => {
diagnostics.push(Diagnostic::from(&e));
continue;
}
};
let token_spans: Vec<(lexer::Token, ast::Span)> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut imported_prog, parse_errors) = parser::parse(token_spans);
ast::resolve_aliases(&mut imported_prog);
ast::desugar_dot_var_index(&mut imported_prog);
for e in &parse_errors {
diagnostics.push(Diagnostic::from(e));
}
visited.insert(canonical.clone());
let imported_decls = resolve_imports(
imported_prog.declarations,
imported_dir.as_deref(),
visited,
diagnostics,
build_target,
);
visited.remove(&canonical);
let filtered =
apply_only_filter(imported_decls, &only, &path, span, diagnostics);
result.extend(filtered);
continue;
}
diagnostics.push(
Diagnostic::error(
"`use` requires a file path context — not supported in inline code",
)
.with_code("ILO-P017")
.with_span(span, "here"),
);
continue;
};
let file_path = dir.join(&path);
let canonical = match file_path.canonicalize() {
Ok(c) => c,
Err(_) => {
diagnostics.push(
Diagnostic::error(format!("use \"{}\": file not found", path))
.with_code("ILO-P017")
.with_span(span, "imported here"),
);
continue;
}
};
if visited.contains(&canonical) {
diagnostics.push(
Diagnostic::error(format!("use \"{}\": circular import", path))
.with_code("ILO-P018")
.with_span(span, "imported here"),
);
continue;
}
let source = match std::fs::read_to_string(&canonical) {
Ok(s) => s,
Err(e) => {
diagnostics.push(
Diagnostic::error(format!("use \"{}\": {}", path, e))
.with_code("ILO-P017")
.with_span(span, "imported here"),
);
continue;
}
};
let tokens = match lexer::lex(&source) {
Ok(t) => t,
Err(e) => {
diagnostics.push(Diagnostic::from(&e));
continue;
}
};
let token_spans: Vec<(lexer::Token, ast::Span)> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut imported_prog, parse_errors) = parser::parse(token_spans);
ast::resolve_aliases(&mut imported_prog);
ast::desugar_dot_var_index(&mut imported_prog);
for e in &parse_errors {
diagnostics.push(Diagnostic::from(e));
}
visited.insert(canonical.clone());
let imported_dir = canonical.parent();
let (imported_decls, imported_exported) = resolve_imports_inner(
imported_prog.declarations,
imported_dir,
visited,
diagnostics,
build_target,
true,
);
visited.remove(&canonical);
let filtered = if let Some(ref names) = only {
for name in names {
if name.starts_with('_') {
diagnostics.push(
Diagnostic::error(format!(
"use \"{}\": '{}' is module-private (names starting with `_` \
are not exported)",
path, name
))
.with_code("ILO-P019")
.with_span(span, "imported here"),
);
continue;
}
let found = imported_exported.iter().any(|e| e == name)
|| imported_decls
.iter()
.any(|d| decl_name(d) == Some(name.as_str()));
if !found {
diagnostics.push(
Diagnostic::error(format!(
"use \"{}\": name '{}' not found in imported file",
path, name
))
.with_code("ILO-P019")
.with_span(span, "imported here"),
);
}
}
let selected = imported_decls
.into_iter()
.filter(|d| {
decl_name(d)
.map(|n| !n.starts_with('_') && names.iter().any(|s| s == n))
.unwrap_or(false)
})
.collect::<Vec<_>>();
if reexport {
for name in names {
if !name.starts_with('_') {
exported_names.insert(name.clone());
}
}
}
selected
} else if let Some(ref pfx) = alias {
let public_decls: Vec<ast::Decl> = imported_decls
.into_iter()
.filter(|d| decl_name(d).map(|n| !n.starts_with('_')).unwrap_or(true))
.collect();
apply_module_alias(public_decls, pfx)
} else {
imported_decls
};
result.extend(filtered);
} else {
if let Some(name) = decl_name(&decl) {
if !name.starts_with('_') {
exported_names.insert(name.to_string());
}
}
result.push(decl);
}
}
(result, exported_names.into_iter().collect())
}
fn decls_reference_prefix(decls: &[ast::Decl], prefix: &str) -> bool {
fn expr_refs(expr: &ast::Expr, prefix: &str) -> bool {
match expr {
ast::Expr::Call { function, args, .. } => {
if function.starts_with(prefix) {
return true;
}
args.iter().any(|a| expr_refs(a, prefix))
}
ast::Expr::Ref(name) => name.starts_with(prefix),
ast::Expr::BinOp { left, right, .. } => {
expr_refs(left, prefix) || expr_refs(right, prefix)
}
ast::Expr::UnaryOp { operand, .. } => expr_refs(operand, prefix),
ast::Expr::Ok(inner) | ast::Expr::Err(inner) => expr_refs(inner, prefix),
ast::Expr::List(items) => items.iter().any(|e| expr_refs(e, prefix)),
ast::Expr::Record { fields, .. } => fields.iter().any(|(_, e)| expr_refs(e, prefix)),
ast::Expr::Field { object, .. } => expr_refs(object, prefix),
ast::Expr::Index { object, .. } => expr_refs(object, prefix),
ast::Expr::Match { subject, arms } => {
subject
.as_deref()
.map(|s| expr_refs(s, prefix))
.unwrap_or(false)
|| arms
.iter()
.any(|arm| arm.body.iter().any(|s| stmt_refs(&s.node, prefix)))
}
ast::Expr::NilCoalesce { value, default } => {
expr_refs(value, prefix) || expr_refs(default, prefix)
}
ast::Expr::With { object, updates } => {
expr_refs(object, prefix) || updates.iter().any(|(_, e)| expr_refs(e, prefix))
}
ast::Expr::Ternary {
condition,
then_expr,
else_expr,
} => {
expr_refs(condition, prefix)
|| expr_refs(then_expr, prefix)
|| expr_refs(else_expr, prefix)
}
ast::Expr::MakeClosure { captures, .. } => {
captures.iter().any(|e| expr_refs(e, prefix))
}
ast::Expr::AnonRecord { fields, .. } => {
fields.iter().any(|(_, e)| expr_refs(e, prefix))
}
ast::Expr::Literal(_) | ast::Expr::Todo(_) | ast::Expr::Panic(_) => false,
}
}
fn stmt_refs(stmt: &ast::Stmt, prefix: &str) -> bool {
match stmt {
ast::Stmt::Let { value, .. } | ast::Stmt::Destructure { value, .. } => {
expr_refs(value, prefix)
}
ast::Stmt::Expr(e) | ast::Stmt::Return(e) => expr_refs(e, prefix),
ast::Stmt::Break(Some(e)) => expr_refs(e, prefix),
ast::Stmt::Break(None) | ast::Stmt::Continue => false,
ast::Stmt::Guard {
condition,
body,
else_body,
..
} => {
expr_refs(condition, prefix)
|| body.iter().any(|s| stmt_refs(&s.node, prefix))
|| else_body
.as_deref()
.map(|b| b.iter().any(|s| stmt_refs(&s.node, prefix)))
.unwrap_or(false)
}
ast::Stmt::Match { subject, arms } => {
subject
.as_ref()
.map(|s| expr_refs(s, prefix))
.unwrap_or(false)
|| arms
.iter()
.any(|arm| arm.body.iter().any(|s| stmt_refs(&s.node, prefix)))
}
ast::Stmt::ForEach {
collection, body, ..
} => expr_refs(collection, prefix) || body.iter().any(|s| stmt_refs(&s.node, prefix)),
ast::Stmt::ForRange {
start, end, body, ..
} => {
expr_refs(start, prefix)
|| expr_refs(end, prefix)
|| body.iter().any(|s| stmt_refs(&s.node, prefix))
}
ast::Stmt::While { condition, body } => {
expr_refs(condition, prefix) || body.iter().any(|s| stmt_refs(&s.node, prefix))
}
ast::Stmt::Defer { expr, .. } => expr_refs(expr, prefix),
}
}
for decl in decls {
let referenced = match decl {
ast::Decl::Function { body, .. } => body.iter().any(|s| stmt_refs(&s.node, prefix)),
ast::Decl::Tool { .. }
| ast::Decl::TypeDef { .. }
| ast::Decl::SumType { .. }
| ast::Decl::Alias { .. }
| ast::Decl::Use { .. }
| ast::Decl::VersionPragma { .. }
| ast::Decl::Error { .. } => false,
};
if referenced {
return true;
}
}
false
}
fn apply_module_alias(decls: Vec<ast::Decl>, alias: &str) -> Vec<ast::Decl> {
decls
.into_iter()
.map(|d| rename_decl_with_alias(d, alias))
.collect()
}
fn rename_decl_with_alias(decl: ast::Decl, alias: &str) -> ast::Decl {
match decl {
ast::Decl::Function {
name,
params,
return_type,
body,
span,
type_params,
effect_set,
} => ast::Decl::Function {
name: format!("{}-{}", alias, name),
params,
return_type,
body,
span,
type_params,
effect_set,
},
ast::Decl::Tool {
name,
description,
params,
return_type,
timeout,
retry,
span,
} => ast::Decl::Tool {
name: format!("{}-{}", alias, name),
description,
params,
return_type,
timeout,
retry,
span,
},
ast::Decl::TypeDef { name, fields, span } => ast::Decl::TypeDef {
name: format!("{}-{}", alias, name),
fields,
span,
},
ast::Decl::Alias { name, target, span } => ast::Decl::Alias {
name: format!("{}-{}", alias, name),
target,
span,
},
other => other,
}
}
fn report_diagnostic(d: &Diagnostic, mode: OutputMode) {
let s = match mode {
OutputMode::Ansi => AnsiRenderer { use_color: true }.render(d),
OutputMode::Text => AnsiRenderer { use_color: false }.render(d),
OutputMode::Json => format!("{}\n", json::render(d)),
};
eprint!("{}", s);
}
fn load_env_file(path: &str) {
let Ok(contents) = std::fs::read_to_string(path) else {
return;
};
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, val)) = line.split_once('=') {
let key = key.trim();
let val = val.trim();
if !key.is_empty() && std::env::var(key).is_err() {
unsafe { std::env::set_var(key, val) };
}
}
}
}
fn load_dotenv() {
load_env_file(".env.local");
load_env_file(".env");
}
fn install_runtime_guard(global: &cli::Global, mode: OutputMode) {
let secs = global
.max_runtime
.unwrap_or(ilo::runtime_guard::DEFAULT_MAX_RUNTIME_SECS);
let bytes = global
.max_output_bytes
.unwrap_or(ilo::runtime_guard::DEFAULT_MAX_OUTPUT_BYTES);
let abort_mode = if matches!(mode, OutputMode::Json) {
ilo::runtime_guard::AbortMode::Json
} else {
ilo::runtime_guard::AbortMode::Text
};
ilo::runtime_guard::install(std::time::Duration::from_secs(secs), bytes, abort_mode);
}
fn main() {
load_dotenv();
let mut raw_args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < raw_args.len() {
if raw_args[i] == "--" {
break;
}
if raw_args[i] == "--max-ast-depth" {
if i + 1 >= raw_args.len() {
eprintln!("error: --max-ast-depth requires a value");
std::process::exit(1);
}
match raw_args[i + 1].parse::<usize>() {
Ok(n) if n >= 1 => parser::set_max_ast_depth_override(n),
_ => {
eprintln!(
"error: --max-ast-depth requires a positive integer, got '{}'",
raw_args[i + 1]
);
std::process::exit(1);
}
}
raw_args.drain(i..i + 2);
continue;
}
if let Some(rest) = raw_args[i].strip_prefix("--max-ast-depth=") {
match rest.parse::<usize>() {
Ok(n) if n >= 1 => parser::set_max_ast_depth_override(n),
_ => {
eprintln!("error: --max-ast-depth requires a positive integer, got '{rest}'");
std::process::exit(1);
}
}
raw_args.remove(i);
continue;
}
i += 1;
}
{
let mut hit = false;
for a in raw_args.iter().skip(1) {
if a == "--" {
break;
}
if a == "--run-vm" {
hit = true;
break;
}
}
if hit {
emit_run_vm_alias_hint();
}
}
if raw_args.get(1).map(|s| s.as_str()) == Some("-ai") {
print!("{}", compact_spec());
std::process::exit(0);
}
if raw_args.len() == 2 {
match raw_args[1].as_str() {
"run" => {
eprintln!("Usage: ilo run <file.ilo> [func] [args...]");
eprintln!(" ilo run <inline-code> [func] [args...]");
std::process::exit(1);
}
"check" => {
eprintln!("Usage: ilo check <file.ilo>");
eprintln!(" ilo check <inline-code>");
eprintln!(" ilo check <file.ilo> --json (machine-readable diagnostics)");
std::process::exit(1);
}
"build" => {
eprintln!("Usage: ilo build <file.ilo> [-o out] [func]");
std::process::exit(1);
}
"trace" => {
eprintln!("Usage: ilo trace <file.ilo> [func] [args...]");
std::process::exit(1);
}
_ => {}
}
}
let (cli, bare_args_have_bin_name) = match cli::Cli::try_parse_from(&raw_args) {
Ok(c) => (c, false),
Err(_) => {
(
cli::Cli {
cmd: None,
global: cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
},
args: raw_args,
},
true,
)
}
};
let code = dispatch_cli(cli, bare_args_have_bin_name);
if code != 0 {
std::process::exit(code);
}
}
fn dispatch_cli(cli: cli::Cli, bare_has_bin: bool) -> i32 {
match cli.cmd {
Some(cli::Cmd::Tools(t)) => {
let mut args: Vec<String> = Vec::new();
if let Some(ref p) = t.mcp_path {
args.push("--mcp".into());
args.push(p.clone());
}
if let Some(ref p) = t.tools_path {
args.push("--tools".into());
args.push(p.clone());
}
if t.human {
args.push("--human".into());
}
if t.ilo {
args.push("--ilo".into());
}
if t.json {
args.push("--json".into());
}
if t.full {
args.push("--full".into());
}
if t.graph {
args.push("--graph".into());
}
tools_cmd(&args)
}
Some(cli::Cmd::Graph(g)) => {
let mut args: Vec<String> = vec![g.file];
if let Some(ref n) = g.fn_name {
args.push("--fn".into());
args.push(n.clone());
}
if g.reverse {
args.push("--reverse".into());
}
if g.subgraph {
args.push("--subgraph".into());
}
if let Some(b) = g.budget {
args.push("--budget".into());
args.push(b.to_string());
}
if g.dot {
args.push("--dot".into());
}
graph_cmd(&args)
}
Some(cli::Cmd::Repl) => {
if cli.global.json {
serv_cmd(&["-j".to_string()]);
} else {
repl_cmd();
}
0
}
Some(cli::Cmd::Serv(s)) => {
let mut args: Vec<String> = vec!["-j".into()];
if let Some(ref p) = s.mcp_path {
args.push("--mcp".into());
args.push(p.clone());
}
if let Some(ref p) = s.tools_path {
args.push("--tools".into());
args.push(p.clone());
}
serv_cmd(&args);
0
}
Some(cli::Cmd::Compile(c)) | Some(cli::Cmd::Build(c)) => {
if let Some(ref t) = c.target {
if !cli::args::SUPPORTED_TARGETS.contains(&t.as_str()) {
let list = cli::args::SUPPORTED_TARGETS.join(", ");
eprintln!("error: unsupported target '{t}'. Supported targets: {list}");
return 1;
}
}
let mut args: Vec<String> = vec![c.source];
if let Some(ref o) = c.output {
args.push("-o".into());
args.push(o.clone());
}
if c.bench {
args.push("--bench".into());
}
if cli.global.explicit_json() {
args.push("--json".into());
}
if let Some(ref t) = c.target {
args.push("--target".into());
args.push(t.clone());
}
if let Some(ref f) = c.func {
args.push(f.clone());
}
compile_cmd(&args)
}
Some(cli::Cmd::Check(c)) => {
let mode = cli.global.output_mode();
let explicit_json = cli.global.explicit_json();
check_cmd(&c.source, mode, explicit_json, c.strict, c.show_effects)
}
Some(cli::Cmd::Explain(e)) => {
let as_json = cli.global.explicit_json();
explain_cmd(&e.code, as_json)
}
Some(cli::Cmd::Spec(s)) => {
let as_json = cli.global.explicit_json();
match s.topic.as_deref() {
Some("lang") => {
if as_json {
let v = serde_json::json!({
"schemaVersion": 1,
"format": "markdown",
"content": include_str!("../SPEC.md"),
});
println!("{}", v);
} else {
print!("{}", include_str!("../SPEC.md"));
}
}
Some("ai") => {
if as_json {
let builtins_list: Vec<serde_json::Value> = ilo::builtins::Builtin::ALL
.iter()
.map(|b| {
serde_json::json!({
"name": b.name(),
"stability": b.stability(),
})
})
.collect();
let v = serde_json::json!({
"schemaVersion": 1,
"format": "ai-txt",
"content": compact_spec(),
"stability": {
"doc": "STABILITY.md",
"tiers": ["stable", "provisional", "experimental"],
"stable": ["schemaVersion:1", "ILO-error-codes", "serv-protocol-phases", "file-version-pragma", "manifesto-principles", "reserved-name-policy"],
"provisional": ["builtin-signatures", "cli-flag-names", "error-message-prose", "examples-corpus", "ilo-test-surface"],
"experimental": ["0.13-in-flight-features", "aot-artifact-format", "cranelift-jit-internals", "extensions-dir", "cargo-feature-flags"],
},
"builtins": builtins_list,
});
println!("{}", v);
} else {
print!("{}", compact_spec());
}
}
_ => print_help(),
}
0
}
Some(cli::Cmd::Skill(s)) => {
let as_json = cli.global.explicit_json();
match s.cmd {
cli::args::SkillCmd::List => skill_list_cmd(as_json),
cli::args::SkillCmd::Get { name } => skill_get_cmd(&name, as_json),
cli::args::SkillCmd::Path { name } => skill_path_cmd(&name, as_json),
cli::args::SkillCmd::Show { name } => skill_show_cmd(&name, as_json),
}
}
Some(cli::Cmd::Httpd(h)) => {
let func = h.func.as_deref().unwrap_or("handler");
httpd_cmd(h.port, &h.handler, func)
}
Some(cli::Cmd::Test(t)) => cli::test_runner::run(t),
Some(cli::Cmd::Trace(t)) => cli::trace::run(t),
Some(cli::Cmd::Version) => version_cmd(cli.global.explicit_json()),
Some(cli::Cmd::Add(a)) => std::process::exit(ilo::pkg::cmd_add(&a.package)),
Some(cli::Cmd::Update(u)) => std::process::exit(ilo::pkg::cmd_update(u.package.as_deref())),
Some(cli::Cmd::Run(r)) => {
let mode = cli.global.output_mode();
let explicit_json = cli.global.explicit_json();
let no_hints = cli.global.no_hints;
let silent = cli.global.silent;
install_runtime_guard(&cli.global, mode);
dispatch_run(r, mode, explicit_json, no_hints, silent)
}
None => {
let args = if bare_has_bin {
cli.args
} else {
let mut full = vec!["ilo".to_string()];
full.extend(cli.args);
full
};
install_runtime_guard(&cli.global, cli.global.output_mode());
dispatch_bare_args(args, &cli.global)
}
}
}
fn dispatch_bare_args(raw_args: Vec<String>, global: &cli::Global) -> i32 {
let (mode, explicit_json, no_hints, silent, args) = detect_output_mode(raw_args);
let (pre_engine, args) = match extract_run_engine_flag(args) {
Ok(v) => v,
Err(msg) => {
eprintln!("{msg}");
return 1;
}
};
let (pre_ast, args) = {
let mut found = false;
let mut filtered: Vec<String> = Vec::with_capacity(args.len());
for a in args.into_iter() {
if a == "--ast" {
found = true;
} else {
filtered.push(a);
}
}
(found, filtered)
};
let mode = if global.ansi {
OutputMode::Ansi
} else if global.text {
OutputMode::Text
} else if global.json {
OutputMode::Json
} else {
mode
};
let explicit_json = explicit_json || global.explicit_json();
let no_hints = no_hints || global.no_hints;
let silent = silent || global.silent;
if args.len() < 2 {
eprintln!(
"Usage: ilo <file-or-code> [args... | --run func args... | --bench func args... | --emit python]"
);
eprintln!(" ilo run <file> [args...] Run (verb form)");
eprintln!(" ilo check <file> [--json] Verify without running");
eprintln!(
" ilo build <file> -o <out> [func] AOT compile (alias for compile)"
);
eprintln!(" ilo repl Interactive REPL");
eprintln!(" ilo serv [--mcp <path>] [--tools <path>] Stdio agent loop");
eprintln!(" ilo graph <file> [--fn NAME] [--dot] Dependency graph");
eprintln!(" ilo compile <file> [-o out] [func] AOT compile to binary");
eprintln!(" ilo help | -h Show usage and examples");
eprintln!(" ilo help lang Show language specification");
eprintln!(" ilo help ai | -ai Compact spec for LLM consumption");
eprintln!(" ilo --version | -V | -v");
return 1;
}
if args[1] == "--version" || args[1] == "-V" || args[1] == "-v" {
println!("ilo {}", env!("CARGO_PKG_VERSION"));
return 0;
}
if args[1] == "--explain" {
return match args.get(2) {
Some(code) => match diagnostic::registry::lookup(code) {
Some(entry) => {
print!("{}", entry.long);
0
}
None => {
eprintln!("unknown error code: {code}");
eprintln!("Error codes have the form ILO-L001, ILO-P001, ILO-T001, ILO-R001.");
1
}
},
None => {
eprintln!("Usage: ilo --explain <code> (e.g. ilo --explain ILO-T005)");
1
}
};
}
if args[1] == "-ai" {
print!("{}", compact_spec());
return 0;
}
if args[1] == "help" || args[1] == "--help" || args[1] == "-h" {
if args.len() > 2 && args[2] == "lang" {
print!("{}", include_str!("../SPEC.md"));
} else if args.len() > 2 && args[2] == "ai" {
print!("{}", compact_spec());
} else {
print_help();
}
return 0;
}
if let Err(msg) = cli::reject_unknown_flags(std::slice::from_ref(&args[1])) {
eprintln!("{msg}");
return 1;
}
let (source, mode_args_start) = if std::path::Path::new(&args[1]).is_file() {
(args[1].clone(), 2)
} else if args[1] == "-e" {
if args.len() < 3 || args[2].is_empty() {
eprintln!("Usage: ilo <file-or-code> [args... | --run func args... | --emit python]");
return 1;
}
(args[2].clone(), 3)
} else {
if args[1].is_empty() {
eprintln!("Error: empty code string");
return 1;
}
(args[1].clone(), 2)
};
let (tools_config_path, mcp_config_path, args) = {
let mut tools_path: Option<String> = None;
let mut mcp_path: Option<String> = None;
let mut filtered: Vec<String> = Vec::with_capacity(args.len());
let mut i = 0;
while i < args.len() {
if args[i] == "--tools" {
if i + 1 < args.len() {
tools_path = Some(args[i + 1].clone());
i += 2;
} else {
eprintln!("error: --tools requires a path argument");
return 1;
}
} else if args[i] == "--mcp" {
if i + 1 < args.len() {
mcp_path = Some(args[i + 1].clone());
i += 2;
} else {
eprintln!("error: --mcp requires a path argument");
return 1;
}
} else {
filtered.push(args[i].clone());
i += 1;
}
}
if tools_path.is_some() && mcp_path.is_some() {
eprintln!("error: --tools and --mcp are mutually exclusive");
return 1;
}
(tools_path, mcp_path, filtered)
};
let m = mode_args_start;
let (engine_flag, run_rest_start) = if args.len() > m {
match args[m].as_str() {
"--jit" => (Some(cli::Engine::Cranelift), m + 1),
"--run-llvm" => (Some(cli::Engine::Llvm), m + 1),
"--vm" => (Some(cli::Engine::Vm), m + 1),
"--run-vm" => {
emit_run_vm_alias_hint();
(Some(cli::Engine::Vm), m + 1)
}
_ => (None, m),
}
} else {
(None, m)
};
let engine_flag = engine_flag.or(pre_engine);
if args.len() > m {
match args[m].as_str() {
"--bench" => {
let run_args = cli::RunArgs {
source,
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: true,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: tools_config_path,
mcp_path: mcp_config_path,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: args[m + 1..].to_vec(),
};
return dispatch_run(run_args, mode, explicit_json, no_hints, silent);
}
"--explain" | "-x" if engine_flag.is_none() => {
let run_args = cli::RunArgs {
source,
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: true,
dense: false,
expanded: false,
ast: false,
tools_path: tools_config_path,
mcp_path: mcp_config_path,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec![],
};
return dispatch_run(run_args, mode, explicit_json, no_hints, silent);
}
"--emit" if engine_flag.is_none() => {
let target = if args.len() > m + 1 {
Some(args[m + 1].clone())
} else {
None
};
let run_args = cli::RunArgs {
source,
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: target,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: tools_config_path,
mcp_path: mcp_config_path,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec![],
};
return dispatch_run(run_args, mode, explicit_json, no_hints, silent);
}
"--dense" | "-d" | "--fmt" if engine_flag.is_none() => {
let run_args = cli::RunArgs {
source,
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: true,
expanded: false,
ast: false,
tools_path: tools_config_path,
mcp_path: mcp_config_path,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec![],
};
return dispatch_run(run_args, mode, explicit_json, no_hints, silent);
}
"--expanded" | "-e" | "--fmt-expanded" if engine_flag.is_none() => {
let run_args = cli::RunArgs {
source,
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: true,
ast: false,
tools_path: tools_config_path,
mcp_path: mcp_config_path,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec![],
};
return dispatch_run(run_args, mode, explicit_json, no_hints, silent);
}
_ => {}
}
}
let engine = engine_flag.unwrap_or(cli::Engine::Default);
let rest_start = if engine_flag.is_some() {
run_rest_start
} else {
m
};
let rest: Vec<String> = args[rest_start..].to_vec();
let run_args = cli::RunArgs {
source,
engine,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: pre_ast,
tools_path: tools_config_path,
mcp_path: mcp_config_path,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest,
};
dispatch_run(run_args, mode, explicit_json, no_hints, silent)
}
fn resolve_engine_func_name<'a>(
program: &ast::Program,
rest: &'a [String],
) -> (Option<&'a str>, &'a [String]) {
let has_main = program.declarations.iter().any(|d| {
matches!(
d,
ast::Decl::Function { name, .. } if !name.starts_with("__") && name == "main"
)
});
if let Some(first) = rest.first() {
if has_main && !looks_like_subcommand_name(first) {
return (Some("main"), rest);
}
return (Some(first.as_str()), &rest[1..]);
}
if has_main {
return (Some("main"), &[][..]);
}
(None, &[][..])
}
fn check_cmd(
source_arg: &str,
mode: OutputMode,
_explicit_json: bool,
strict: bool,
show_effects: bool,
) -> i32 {
let (source, is_file) = if std::path::Path::new(source_arg).is_file() {
match std::fs::read_to_string(source_arg) {
Ok(s) => (s, true),
Err(e) => {
eprintln!("Error reading {}: {}", source_arg, e);
return 1;
}
}
} else {
if source_arg.is_empty() {
eprintln!("Error: empty code string");
return 1;
}
(source_arg.to_string(), false)
};
let diag_path: Option<String> = if is_file {
Some(source_arg.to_string())
} else {
None
};
let enrich = |d: Diagnostic| -> Diagnostic {
let mut d = d.with_source(source.clone());
if let Some(p) = &diag_path {
d = d.with_path(p.clone());
}
d.derive_fix_plan()
};
let mut had_errors = false;
let mut had_warnings = false;
let tokens = match lexer::lex(&source) {
Ok(t) => t,
Err(e) => {
report_diagnostic(&enrich(Diagnostic::from(&e)), mode);
return 1;
}
};
let token_spans: Vec<(lexer::Token, ast::Span)> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut program, parse_errors) = parser::parse(token_spans);
ast::resolve_aliases(&mut program);
ast::desugar_dot_var_index(&mut program);
program.source = Some(source.clone());
{
let base_dir: Option<std::path::PathBuf> = if is_file {
std::path::Path::new(source_arg)
.canonicalize()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
} else {
None
};
let mut import_diagnostics: Vec<Diagnostic> = Vec::new();
let mut visited = std::collections::HashSet::new();
if let Ok(canonical_file) = std::path::Path::new(source_arg).canonicalize() {
visited.insert(canonical_file);
}
program.declarations = resolve_imports(
program.declarations,
base_dir.as_deref(),
&mut visited,
&mut import_diagnostics,
BuildTarget::default(),
);
for d in import_diagnostics {
report_diagnostic(&d, mode);
had_errors = true;
}
}
for e in &parse_errors {
report_diagnostic(&enrich(Diagnostic::from(e)), mode);
had_errors = true;
}
let verify_result = verify::verify_with_effects(&program, show_effects);
for w in &verify_result.warnings {
report_diagnostic(&enrich(Diagnostic::from(w)), mode);
had_warnings = true;
}
if !verify_result.errors.is_empty() {
for e in &verify_result.errors {
report_diagnostic(&enrich(Diagnostic::from(e)), mode);
}
had_errors = true;
}
if show_effects && !verify_result.effects.is_empty() {
println!("Effect sets:");
for fx in &verify_result.effects {
let declared_str = match &fx.declared {
None => String::new(),
Some(v) if v.is_empty() => " (declared: ^)".to_string(),
Some(v) => format!(" (declared: ^{})", v.join("|")),
};
let inferred_str = if fx.inferred.is_empty() {
"none".to_string()
} else {
fx.inferred.join("|")
};
println!(" {}: {}{}", fx.name, inferred_str, declared_str);
}
}
if had_errors || (strict && had_warnings) {
1
} else {
0
}
}
fn build_caps(r: &cli::RunArgs) -> Arc<Caps> {
let any = r.allow_net.is_some()
|| r.allow_read.is_some()
|| r.allow_write.is_some()
|| r.allow_run.is_some()
|| r.allow_env.is_some();
if !any {
return Arc::new(Caps::Permissive);
}
Arc::new(Caps::Restricted {
net: r
.allow_net
.as_deref()
.map(Caps::parse_allow)
.unwrap_or(Policy::All),
read: r
.allow_read
.as_deref()
.map(Caps::parse_allow)
.unwrap_or(Policy::All),
write: r
.allow_write
.as_deref()
.map(Caps::parse_allow)
.unwrap_or(Policy::All),
run: r
.allow_run
.as_deref()
.map(Caps::parse_allow)
.unwrap_or(Policy::All),
env: r
.allow_env
.as_deref()
.map(Caps::parse_allow)
.unwrap_or(Policy::All),
})
}
fn dispatch_run(
r: cli::RunArgs,
mode: OutputMode,
explicit_json: bool,
no_hints: bool,
silent: bool,
) -> i32 {
if let Err(msg) = cli::reject_unknown_flags(&r.rest) {
eprintln!("{msg}");
return 1;
}
let mut r = r;
if let Some(idx) = r.rest.iter().position(|s| s == "--") {
r.rest.remove(idx);
}
let caps = build_caps(&r);
let source_arg = &r.source;
let (source, is_file) = if std::path::Path::new(source_arg).is_file() {
let s = match std::fs::read_to_string(source_arg) {
Ok(s) => s,
Err(e) => {
eprintln!("Error reading {}: {}", source_arg, e);
return 1;
}
};
(s, true)
} else {
if source_arg.is_empty() {
eprintln!("Error: empty code string");
return 1;
}
(source_arg.clone(), false)
};
let tools_config_path = r.tools_path.clone();
let mcp_config_path = r.mcp_path.clone();
if tools_config_path.is_some() && mcp_config_path.is_some() {
eprintln!("error: --tools and --mcp are mutually exclusive");
return 1;
}
warn_cross_language_syntax(&source, mode);
let tokens = match lexer::lex(&source) {
Ok(t) => t,
Err(e) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.clone()), mode);
return 1;
}
};
let token_spans: Vec<(lexer::Token, ast::Span)> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut program, parse_errors) = parser::parse(token_spans);
ast::resolve_aliases(&mut program);
ast::desugar_dot_var_index(&mut program);
program.source = Some(source.clone());
#[cfg(not(feature = "tools"))]
if mcp_config_path.is_some() {
eprintln!(
"error: --mcp requires the 'tools' feature (build with: cargo build --features tools)"
);
return 1;
}
#[cfg(feature = "tools")]
let mut mcp_rt: Option<tokio::runtime::Runtime> = None;
#[cfg(feature = "tools")]
let mut mcp_provider_holder: Option<tools::mcp_provider::McpProvider> = None;
#[cfg(feature = "tools")]
if let Some(ref path) = mcp_config_path {
let config = match tools::mcp_provider::McpConfig::from_file(path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}", e);
return 1;
}
};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
let provider = match rt.block_on(tools::mcp_provider::McpProvider::connect(&config)) {
Ok(p) => p,
Err(e) => {
eprintln!("MCP error: {}", e);
return 1;
}
};
let mut decls = provider.tool_decls();
decls.append(&mut program.declarations);
program.declarations = decls;
mcp_rt = Some(rt);
mcp_provider_holder = Some(provider);
}
let mut had_errors = false;
{
let base_dir: Option<std::path::PathBuf> = if is_file {
std::path::Path::new(source_arg)
.canonicalize()
.ok()
.and_then(|p| p.parent().map(|d| d.to_path_buf()))
} else {
None
};
let mut import_diagnostics: Vec<Diagnostic> = Vec::new();
let mut visited = std::collections::HashSet::new();
if let Ok(canonical_file) = std::path::Path::new(source_arg).canonicalize() {
visited.insert(canonical_file);
}
program.declarations = resolve_imports(
program.declarations,
base_dir.as_deref(),
&mut visited,
&mut import_diagnostics,
BuildTarget::default(),
);
for d in import_diagnostics {
report_diagnostic(&d, mode);
had_errors = true;
}
}
for e in &parse_errors {
report_diagnostic(&Diagnostic::from(e).with_source(source.clone()), mode);
had_errors = true;
}
let user_fn_names: Vec<&str> = program
.declarations
.iter()
.filter_map(|d| match d {
ast::Decl::Function { name, .. } if !name.starts_with("__") => Some(name.as_str()),
_ => None,
})
.collect();
let inline_will_auto_run = !is_file
&& r.rest.is_empty()
&& matches!(r.effective_engine(), cli::Engine::Default)
&& (user_fn_names.len() == 1 || user_fn_names.contains(&"main"));
let ast_dump_mode = r.ast
|| (!is_file
&& r.rest.is_empty()
&& !r.bench
&& !r.explain
&& r.emit.is_none()
&& !r.dense
&& !r.expanded
&& matches!(r.effective_engine(), cli::Engine::Default)
&& !inline_will_auto_run);
if !ast_dump_mode {
let verify_result = verify::verify(&program);
for w in &verify_result.warnings {
report_diagnostic(&Diagnostic::from(w).with_source(source.clone()), mode);
}
if !verify_result.errors.is_empty() {
for e in &verify_result.errors {
report_diagnostic(&Diagnostic::from(e).with_source(source.clone()), mode);
}
had_errors = true;
}
}
if had_errors {
return 1;
}
let exit_code = if r.bench {
let func_name = r.rest.first().map(|s| s.as_str());
let raw = if r.rest.len() > 1 {
&r.rest[1..]
} else {
&[][..]
};
let run_args = parse_cli_args_typed(&program, func_name, raw);
let json = matches!(mode, OutputMode::Json);
run_bench(&program, func_name, &run_args, json, silent);
0
} else if r.explain {
let filename = if is_file {
Some(source_arg.as_str())
} else {
None
};
print!("{}", codegen::explain::explain(&program, filename));
0
} else if let Some(ref target) = r.emit {
if target == "python" {
println!("{}", codegen::python::emit(&program));
0
} else if target == "js" {
println!("{}", codegen::js::emit(&program));
0
} else {
eprintln!("Unknown emit target. Supported: python, js");
1
}
} else if r.dense {
println!(
"{}",
codegen::fmt::format(&program, codegen::fmt::FmtMode::Dense)
);
0
} else if r.expanded {
print!(
"{}",
codegen::fmt::format(&program, codegen::fmt::FmtMode::Expanded)
);
0
} else {
let engine = r.effective_engine();
let rest = &r.rest;
match engine {
cli::Engine::Cranelift => {
run_cranelift_engine(&program, rest, &source, mode, explicit_json)
}
cli::Engine::Llvm => run_llvm_engine(&program, rest),
cli::Engine::Vm => {
let (func_name, raw) = resolve_engine_func_name(&program, rest);
let run_args = parse_cli_args_typed(&program, func_name, raw);
if let Err(code) =
check_cli_arity(&program, func_name, run_args.len(), &source, mode)
{
return code;
}
let compiled = match vm::compile(&program) {
Ok(c) => c,
Err(e) => {
eprintln!("Compile error: {}", e);
return 1;
}
};
let suppress = program_result_should_suppress(&program, func_name);
run_vm_with_provider(
&compiled,
func_name,
run_args,
tools_config_path.as_deref(),
#[cfg(feature = "tools")]
mcp_provider_holder.as_ref(),
#[cfg(feature = "tools")]
mcp_rt.as_ref(),
&source,
mode,
explicit_json,
suppress,
caps,
)
}
cli::Engine::Tree => {
let (func_name, raw) = resolve_engine_func_name(&program, rest);
let run_args = parse_cli_args_typed(&program, func_name, raw);
if let Err(code) =
check_cli_arity(&program, func_name, run_args.len(), &source, mode)
{
return code;
}
run_interp_with_provider(
&program,
func_name,
run_args,
tools_config_path.as_deref(),
#[cfg(feature = "tools")]
mcp_provider_holder,
#[cfg(feature = "tools")]
mcp_rt,
&source,
mode,
explicit_json,
caps,
)
}
cli::Engine::Default => {
let func_names: Vec<&str> = program
.declarations
.iter()
.filter_map(|d| match d {
ast::Decl::Function { name, .. } if !name.starts_with("__") => {
Some(name.as_str())
}
_ => None,
})
.collect();
if r.ast {
return dump_ast_json(&program);
}
let (func_name, run_args) = if let Some(first) = rest.first() {
if func_names.contains(&first.as_str()) {
let fn_ref = Some(first.as_str());
(fn_ref, parse_cli_args_typed(&program, fn_ref, &rest[1..]))
} else if is_file && func_names.len() > 1 && looks_like_subcommand_name(first) {
eprintln!("error: no such function '{}' in {}", first, source_arg);
eprintln!("available functions:");
for n in &func_names {
eprintln!(" {}", n);
}
eprintln!();
eprintln!(" ilo {} <func> [args...] run a function", source_arg);
return 1;
} else if is_file && func_names.contains(&"main") {
let fn_ref = Some("main");
(fn_ref, parse_cli_args_typed(&program, fn_ref, rest))
} else {
(None, rest.iter().map(|a| parse_cli_arg(a)).collect())
}
} else if is_file {
match func_names.len() {
0 => return dump_ast_json(&program),
1 => (None, vec![]),
_ if func_names.contains(&"main") => (Some("main"), vec![]),
_ => {
eprintln!(
"error: {} defines multiple functions, please specify one.",
source_arg
);
eprintln!("available functions:");
for n in &func_names {
eprintln!(" {}", n);
}
eprintln!();
eprintln!(" ilo {} <func> [args...] run a function", source_arg);
eprintln!(
" ilo --ast {} dump the parsed AST as JSON",
source_arg
);
return 1;
}
}
} else {
match func_names.len() {
0 => return dump_ast_json(&program),
1 => (None, vec![]),
_ if func_names.contains(&"main") => (Some("main"), vec![]),
_ => return dump_ast_json(&program),
}
};
run_default(
&program,
func_name,
run_args,
&source,
mode,
explicit_json,
caps,
)
}
}
};
if exit_code == 0 && !no_hints {
let hints = collect_hints_with_program(&source, Some(&program));
emit_hints(&hints, mode);
}
exit_code
}
fn run_cranelift_engine(
program: &ast::Program,
rest: &[String],
source: &str,
mode: OutputMode,
explicit_json: bool,
) -> i32 {
let (func_name, raw) = resolve_engine_func_name(program, rest);
let run_args = parse_cli_args_typed(program, func_name, raw);
if let Err(code) = check_cli_arity(program, func_name, run_args.len(), source, mode) {
return code;
}
let suppress = program_result_should_suppress(program, func_name);
#[cfg(feature = "cranelift")]
{
let compiled = match vm::compile(program) {
Ok(c) => c,
Err(e) => {
eprintln!("Compile error: {}", e);
return 1;
}
};
let target_name = func_name.unwrap_or(
compiled
.func_names
.first()
.map(|s| s.as_str())
.unwrap_or("main"),
);
let func_idx = match compiled.func_names.iter().position(|n| n == target_name) {
Some(i) => i,
None => {
eprintln!("undefined function: {}", target_name);
return 1;
}
};
let chunk = &compiled.chunks[func_idx];
let nan_consts = &compiled.nan_constants[func_idx];
let nan_args: Vec<u64> = run_args
.iter()
.map(|v| vm::NanVal::from_value_with_program(v, &compiled.func_names).0)
.collect();
match vm::jit_cranelift::compile_and_call(chunk, nan_consts, &nan_args, &compiled) {
Ok(result_bits) => {
let result = vm::NanVal(result_bits).to_value_with_program(&compiled.func_names);
print_value(&result, explicit_json, suppress);
program_exit_code(&result)
}
Err(vm::jit_cranelift::JitCallError::Runtime(e)) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.to_string()), mode);
1
}
Err(vm::jit_cranelift::JitCallError::NotEligible) => {
match vm::run(&compiled, func_name, run_args) {
Ok(val) => {
print_value(&val, explicit_json, suppress);
program_exit_code(&val)
}
Err(e) => {
report_diagnostic(
&Diagnostic::from(&e).with_source(source.to_string()),
mode,
);
1
}
}
}
Err(vm::jit_cranelift::JitCallError::Panic { msg }) => {
vm::jit_cranelift::note_jit_panic_fallback(&msg, "bytecode VM");
match vm::run(&compiled, func_name, run_args) {
Ok(val) => {
print_value(&val, explicit_json, suppress);
program_exit_code(&val)
}
Err(e) => {
report_diagnostic(
&Diagnostic::from(&e).with_source(source.to_string()),
mode,
);
1
}
}
}
}
}
#[cfg(not(feature = "cranelift"))]
{
let _ = (func_name, run_args, source, mode, explicit_json, suppress);
eprintln!("Cranelift JIT not enabled. Build with: cargo build --features cranelift");
1
}
}
fn run_llvm_engine(_program: &ast::Program, rest: &[String]) -> i32 {
let func_name = rest.first().map(|s| s.as_str());
let jit_args: Vec<f64> = if rest.len() > 1 {
let mut args = Vec::new();
for a in &rest[1..] {
match a.parse::<f64>() {
Ok(v) => args.push(v),
Err(_) => {
eprintln!("error: JIT argument '{}' is not a valid number", a);
return 1;
}
}
}
args
} else {
vec![]
};
#[cfg(feature = "llvm")]
{
let compiled = match vm::compile(program) {
Ok(c) => c,
Err(e) => {
eprintln!("Compile error: {}", e);
return 1;
}
};
let target_name = func_name.unwrap_or(
compiled
.func_names
.first()
.map(|s| s.as_str())
.unwrap_or("main"),
);
let func_idx = match compiled.func_names.iter().position(|n| n == target_name) {
Some(i) => i,
None => {
eprintln!("undefined function: {}", target_name);
return 1;
}
};
let chunk = &compiled.chunks[func_idx];
let nan_consts = &compiled.nan_constants[func_idx];
match vm::jit_llvm::compile_and_call(chunk, nan_consts, &jit_args) {
Some(result) => {
if result == (result as i64) as f64 {
println!("{}", result as i64);
} else {
println!("{}", result);
}
0
}
None => {
eprintln!("LLVM JIT: function not eligible for compilation");
1
}
}
}
#[cfg(not(feature = "llvm"))]
{
let _ = (func_name, jit_args);
eprintln!("LLVM JIT not enabled. Build with: cargo build --features llvm");
1
}
}
fn print_help() {
println!("ilo — a programming language for AI agents\n");
println!("Usage:");
println!(" ilo run <file.ilo> [args...] Run (verb form; alias for positional)");
println!(" ilo check <file.ilo> Verify without running (exit 0 = clean)");
println!(" ilo build <file.ilo> -o <out> AOT compile (alias for `compile`)");
println!(" ilo <code> [args...] Run (bytecode VM; use --jit for JIT)");
println!(" ilo <file.ilo> [args...] Run from file");
println!(" ilo <code> func [args...] Run a specific function");
println!(" ilo <code> --emit python Transpile to Python");
println!(" ilo <code> --emit js Transpile to JavaScript (ES modules)");
println!(" ilo <code> --explain / -x Annotate each statement with its role");
println!(" ilo <code> --dense / -d Reformat (dense wire format)");
println!(" ilo <code> --expanded / -e Reformat (expanded human format)");
println!(" ilo --ast <code-or-file> Print AST as JSON");
println!(" ilo <code> --bench func [args...] Benchmark a function");
println!(" ilo repl Interactive REPL");
println!(" ilo graph <file> [flags] Dependency graph (JSON or DOT)");
println!(" ilo help lang Show language specification");
println!(" ilo help ai | ilo -ai Compact spec for LLM consumption");
println!(" ilo --explain ILO-T005 Explain an error code");
println!(" ilo --version | -V | -v Print version\n");
println!("Output format (errors):");
println!(" --ansi / -a Force ANSI colour output (default when stderr is a TTY)");
println!(" --text / -t Force plain text output (no colour)");
println!(" --json / -j Force JSON output (default when stderr is not a TTY)");
println!(" --no-hints / -nh Suppress idiomatic hints after execution");
println!(" NO_COLOR=1 Disable colour (same as --text)\n");
println!("Tool providers (requires --features tools build):");
println!(" --tools <path> HTTP tool provider config (JSON)");
println!(" --mcp <path> MCP server config (Claude Desktop format JSON)\n");
println!("Tool discovery:");
println!(" ilo tool -m <path> List tools from MCP server");
println!(" ilo tool -t <path> List tools from HTTP config");
println!(" ilo tool ... --full Show full signatures");
println!(" ilo tool ... --ilo Output as valid ilo tool declarations");
println!(" ilo tool ... --json Output as JSON array\n");
println!("Agent serve loop:");
println!(" ilo serv [-m <path>] [-t <path>]");
println!(" Request: {{\"program\": \"<ilo>\", \"args\": [...], \"func\": \"name\"}}");
println!(
" Response: {{\"ok\": <value>, \"ms\": n}} | {{\"error\": {{\"phase\": \"...\", ...}}}}\n"
);
println!("Dependency graph:");
println!(" ilo graph <file> Full call graph (JSON)");
println!(" ilo graph <file> --fn NAME Subgraph for one function");
println!(" ilo graph <file> --reverse Reverse callers");
println!(" ilo graph <file> --subgraph Transitive dependencies");
println!(" ilo graph <file> --budget N Limit to N tokens of source");
println!(" ilo graph <file> --dot Output as DOT (Graphviz)\n");
println!("AOT compilation:");
println!(" ilo compile <file> [-o out] [func] Compile to standalone binary\n");
println!("Backends:");
println!(" (default) Register VM (closure-aware, all opcodes supported)");
println!(
" --jit Cranelift JIT (faster on hot numeric loops; falls back to VM on bailout)"
);
println!(
" --vm Register VM (canonical form, symmetric with --jit; --run-vm is a deprecated alias)\n"
);
println!("Examples:");
println!(" ilo 'f x:n>n;*x 2' 5 Define and call f(5) → 10");
println!(" ilo 'f xs:L n>n;len xs' 1,2,3 Pass a list → 3");
println!(" ilo program.ilo 10 20 Run file with arguments");
println!(" ilo 'f x:n>n;*x 2' --emit python Transpile to Python");
println!(" ilo 'f x:n>n;*x 2' --emit js Transpile to JavaScript");
}
#[allow(clippy::too_many_arguments)]
fn run_vm_with_provider(
compiled: &vm::CompiledProgram,
func_name: Option<&str>,
args: Vec<interpreter::Value>,
tools_config_path: Option<&str>,
#[cfg(feature = "tools")] mcp_provider: Option<&tools::mcp_provider::McpProvider>,
#[cfg(feature = "tools")] mcp_rt: Option<&tokio::runtime::Runtime>,
source: &str,
mode: OutputMode,
explicit_json: bool,
suppress_loop_tail: bool,
caps: Arc<Caps>,
) -> i32 {
#[cfg(feature = "tools")]
if let Some(provider) = mcp_provider {
let rt = mcp_rt.expect("runtime present with mcp_provider");
match vm::run_with_tools(compiled, func_name, args, provider, rt) {
Ok(val) => {
print_value(&val, explicit_json, suppress_loop_tail);
return program_exit_code(&val);
}
Err(e) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.to_string()), mode);
return 1;
}
}
}
if let Some(tools_path) = tools_config_path {
let config = match tools::http_provider::ToolsConfig::from_file(tools_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}", e);
return 1;
}
};
let provider = tools::http_provider::HttpProvider::new(config);
#[cfg(feature = "tools")]
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
return match vm::run_with_tools(
compiled,
func_name,
args,
&provider,
#[cfg(feature = "tools")]
&runtime,
) {
Ok(val) => {
print_value(&val, explicit_json, suppress_loop_tail);
program_exit_code(&val)
}
Err(e) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.to_string()), mode);
1
}
};
}
match vm::run_with_caps(compiled, func_name, args, caps) {
Ok(val) => {
print_value(&val, explicit_json, suppress_loop_tail);
program_exit_code(&val)
}
Err(e) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.to_string()), mode);
1
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_interp_with_provider(
program: &ast::Program,
func_name: Option<&str>,
args: Vec<interpreter::Value>,
tools_config_path: Option<&str>,
#[cfg(feature = "tools")] mcp_provider: Option<tools::mcp_provider::McpProvider>,
#[cfg(feature = "tools")] mcp_rt: Option<tokio::runtime::Runtime>,
source: &str,
mode: OutputMode,
explicit_json: bool,
caps: Arc<Caps>,
) -> i32 {
let suppress = program_result_should_suppress(program, func_name);
#[cfg(feature = "tools")]
if let Some(provider) = mcp_provider {
let rt = std::sync::Arc::new(mcp_rt.expect("runtime present with mcp_provider"));
match interpreter::run_with_tools_and_caps(
program,
func_name,
args,
std::sync::Arc::new(provider),
rt,
caps,
) {
Ok(val) => {
print_value(&val, explicit_json, suppress);
return program_exit_code(&val);
}
Err(e) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.to_string()), mode);
return 1;
}
}
}
if let Some(tools_path) = tools_config_path {
let config = match tools::http_provider::ToolsConfig::from_file(tools_path) {
Ok(c) => c,
Err(e) => {
eprintln!("{}", e);
return 1;
}
};
let provider = std::sync::Arc::new(tools::http_provider::HttpProvider::new(config));
#[cfg(feature = "tools")]
let runtime = std::sync::Arc::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime"),
);
return match interpreter::run_with_tools_and_caps(
program,
func_name,
args,
provider,
#[cfg(feature = "tools")]
runtime,
caps,
) {
Ok(val) => {
print_value(&val, explicit_json, suppress);
program_exit_code(&val)
}
Err(e) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.to_string()), mode);
1
}
};
}
match interpreter::run_with_caps(program, func_name, args, caps) {
Ok(val) => {
print_value(&val, explicit_json, suppress);
program_exit_code(&val)
}
Err(e) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.to_string()), mode);
1
}
}
}
fn dump_ast_json(program: &ast::Program) -> i32 {
let mut v = match serde_json::to_value(program) {
Ok(v) => v,
Err(e) => {
eprintln!("Serialization error: {}", e);
return 1;
}
};
if let Some(obj) = v.as_object_mut() {
obj.insert(
"schemaVersion".to_string(),
serde_json::Value::Number(1.into()),
);
}
match serde_json::to_string_pretty(&v) {
Ok(json) => {
println!("{}", json);
0
}
Err(e) => {
eprintln!("Serialization error: {}", e);
1
}
}
}
fn run_default(
program: &ast::Program,
func_name: Option<&str>,
args: Vec<interpreter::Value>,
source: &str,
mode: OutputMode,
explicit_json: bool,
caps: Arc<Caps>,
) -> i32 {
if let Err(code) = check_cli_arity(program, func_name, args.len(), source, mode) {
return code;
}
let suppress = program_result_should_suppress(program, func_name);
if let Ok(compiled) = vm::compile(program) {
match vm::run_with_caps(&compiled, func_name, args.clone(), caps.clone()) {
Ok(val) => {
print_value(&val, explicit_json, suppress);
return program_exit_code(&val);
}
Err(_e) => {
}
}
}
match interpreter::run_with_caps(program, func_name, args, caps) {
Ok(val) => {
print_value(&val, explicit_json, suppress);
program_exit_code(&val)
}
Err(e) => {
report_diagnostic(&Diagnostic::from(&e).with_source(source.to_string()), mode);
1
}
}
}
fn body_has_early_return(body: &[ast::Spanned<ast::Stmt>]) -> bool {
for s in body {
if stmt_has_early_return(&s.node) {
return true;
}
}
false
}
fn stmt_has_early_return(stmt: &ast::Stmt) -> bool {
match stmt {
ast::Stmt::Return(_) => true,
ast::Stmt::Guard {
braceless: true, ..
} => true,
ast::Stmt::Guard {
body, else_body, ..
} => {
body_has_early_return(body)
|| else_body.as_ref().is_some_and(|b| body_has_early_return(b))
}
ast::Stmt::Match { arms, .. } => arms.iter().any(|a| body_has_early_return(&a.body)),
ast::Stmt::ForEach { body, .. }
| ast::Stmt::ForRange { body, .. }
| ast::Stmt::While { body, .. } => body_has_early_return(body),
_ => false,
}
}
fn program_result_should_suppress(program: &ast::Program, func_name: Option<&str>) -> bool {
let entry_body: Option<&Vec<ast::Spanned<ast::Stmt>>> = match func_name {
Some(name) => program.declarations.iter().find_map(|d| match d {
ast::Decl::Function { name: n, body, .. } if n == name => Some(body),
_ => None,
}),
None => program.declarations.iter().find_map(|d| match d {
ast::Decl::Function { body, .. } => Some(body),
_ => None,
}),
};
let Some(body) = entry_body else {
return false;
};
let Some(last) = body.last() else {
return false;
};
if let ast::Stmt::Expr(ast::Expr::Call { function, args, .. }) = &last.node {
if function == "prnt" && !matches!(args.first(), Some(ast::Expr::Ok(_) | ast::Expr::Err(_)))
{
return true;
}
}
let ends_with_loop = matches!(
last.node,
ast::Stmt::ForEach { .. } | ast::Stmt::ForRange { .. } | ast::Stmt::While { .. }
);
if ends_with_loop {
return !body_has_early_return(body);
}
let tail_is_wrapped_string_literal = matches!(
&last.node,
ast::Stmt::Expr(ast::Expr::Ok(inner) | ast::Expr::Err(inner))
if matches!(inner.as_ref(), ast::Expr::Literal(ast::Literal::Text(_)))
);
if tail_is_wrapped_string_literal && body_has_top_level_prnt(body) {
return true;
}
false
}
fn body_has_top_level_prnt(body: &[ast::Spanned<ast::Stmt>]) -> bool {
body.iter().any(|s| {
matches!(
&s.node,
ast::Stmt::Expr(ast::Expr::Call { function, .. }) if function == "prnt"
)
})
}
fn print_value(val: &interpreter::Value, as_json: bool, suppress_loop_tail: bool) {
if !as_json {
if matches!(val, interpreter::Value::Err(_)) {
eprintln!("{}", val);
return;
}
if suppress_loop_tail {
return;
}
if let interpreter::Value::Ok(inner) = val {
println!("{}", inner);
return;
}
println!("{}", val);
return;
}
let json = match val {
interpreter::Value::Ok(inner) => {
let v = inner.to_json().unwrap_or(serde_json::Value::Null);
serde_json::json!({"schemaVersion": 1, "ok": v})
}
interpreter::Value::Err(inner) => {
let v = inner
.to_json()
.unwrap_or_else(|_| serde_json::Value::String(inner.to_string()));
serde_json::json!({"schemaVersion": 1, "error": {"phase": "program", "value": v}})
}
other => {
let v = other
.to_json()
.unwrap_or_else(|_| serde_json::Value::String(other.to_string()));
serde_json::json!({"schemaVersion": 1, "ok": v})
}
};
println!("{}", json);
}
fn program_exit_code(val: &interpreter::Value) -> i32 {
if matches!(val, interpreter::Value::Err(_)) {
1
} else {
0
}
}
#[cfg(unix)]
struct StdoutSilencer {
saved_fd: libc::c_int,
}
#[cfg(unix)]
impl StdoutSilencer {
fn new() -> Option<Self> {
use std::io::Write;
let _ = std::io::stdout().flush();
unsafe {
let saved = libc::dup(1);
if saved < 0 {
return None;
}
let devnull = std::ffi::CString::new("/dev/null").ok()?;
let null_fd = libc::open(devnull.as_ptr(), libc::O_WRONLY);
if null_fd < 0 {
libc::close(saved);
return None;
}
if libc::dup2(null_fd, 1) < 0 {
libc::close(null_fd);
libc::close(saved);
return None;
}
libc::close(null_fd);
Some(StdoutSilencer { saved_fd: saved })
}
}
fn emit(&self, s: &str) {
use std::io::Write;
let _ = std::io::stdout().flush();
unsafe {
let bytes = s.as_bytes();
let _ = libc::write(self.saved_fd, bytes.as_ptr() as *const _, bytes.len());
if !s.ends_with('\n') {
let nl = b"\n";
let _ = libc::write(self.saved_fd, nl.as_ptr() as *const _, 1);
}
}
}
}
#[cfg(unix)]
impl Drop for StdoutSilencer {
fn drop(&mut self) {
use std::io::Write;
let _ = std::io::stdout().flush();
unsafe {
libc::dup2(self.saved_fd, 1);
libc::close(self.saved_fd);
}
}
}
#[cfg(not(unix))]
struct StdoutSilencer;
#[cfg(not(unix))]
impl StdoutSilencer {
fn new() -> Option<Self> {
Some(StdoutSilencer)
}
fn emit(&self, s: &str) {
if s.ends_with('\n') {
print!("{s}");
} else {
println!("{s}");
}
}
}
fn bench_println(silencer: Option<&StdoutSilencer>, s: &str) {
match silencer {
Some(g) => g.emit(s),
None => println!("{s}"),
}
}
#[allow(unused_variables, unused_mut)]
fn emit_bench_json(
engine: &str,
variant: Option<&str>,
result: &str,
iterations: u32,
total_ms: f64,
per_call_ns: u128,
silencer: Option<&StdoutSilencer>,
) {
let mut esc = String::with_capacity(result.len() + 2);
for c in result.chars() {
match c {
'"' => esc.push_str("\\\""),
'\\' => esc.push_str("\\\\"),
'\n' => esc.push_str("\\n"),
'\r' => esc.push_str("\\r"),
'\t' => esc.push_str("\\t"),
c if (c as u32) < 0x20 => esc.push_str(&format!("\\u{:04x}", c as u32)),
c => esc.push(c),
}
}
let variant_field = match variant {
Some(v) => format!(",\"variant\":\"{}\"", v),
None => String::new(),
};
let line = format!(
"{{\"schemaVersion\":1,\"engine\":\"{engine}\"{variant_field},\"result\":\"{esc}\",\"iterations\":{iterations},\"totalMs\":{total_ms:.4},\"perCallNs\":{per_call_ns}}}"
);
bench_println(silencer, &line);
}
fn run_bench(
program: &ast::Program,
func_name: Option<&str>,
args: &[interpreter::Value],
json: bool,
silent: bool,
) {
use std::io::Write;
use std::process::Command;
use std::time::Instant;
let iterations: u32 = 10_000;
let silencer = if silent { StdoutSilencer::new() } else { None };
for _ in 0..100 {
let _ = interpreter::run(program, func_name, args.to_vec());
}
let start = Instant::now();
let mut result = interpreter::Value::Nil;
for _ in 0..iterations {
result = interpreter::run(program, func_name, args.to_vec())
.expect("interpreter error during benchmark");
}
let interp_dur = start.elapsed();
let interp_ns = interp_dur.as_nanos() / iterations as u128;
if json {
emit_bench_json(
"tree",
None,
&result.to_string(),
iterations,
interp_dur.as_nanos() as f64 / 1e6,
interp_ns,
silencer.as_ref(),
);
} else {
bench_println(silencer.as_ref(), "Rust interpreter");
bench_println(silencer.as_ref(), &format!(" result: {}", result));
bench_println(silencer.as_ref(), &format!(" iterations: {}", iterations));
bench_println(
silencer.as_ref(),
&format!(" total: {:.2}ms", interp_dur.as_nanos() as f64 / 1e6),
);
bench_println(silencer.as_ref(), &format!(" per call: {}ns", interp_ns));
bench_println(silencer.as_ref(), "");
}
let compiled = vm::compile(program).expect("compile error in benchmark");
for _ in 0..100 {
let _ = vm::run(&compiled, func_name, args.to_vec());
}
let start = Instant::now();
let mut vm_result = interpreter::Value::Nil;
for _ in 0..iterations {
vm_result =
vm::run(&compiled, func_name, args.to_vec()).expect("VM error during benchmark");
}
let vm_dur = start.elapsed();
let vm_ns = vm_dur.as_nanos() / iterations as u128;
if json {
emit_bench_json(
"vm",
Some("fresh"),
&vm_result.to_string(),
iterations,
vm_dur.as_nanos() as f64 / 1e6,
vm_ns,
silencer.as_ref(),
);
} else {
bench_println(silencer.as_ref(), "Register VM");
bench_println(silencer.as_ref(), &format!(" result: {}", vm_result));
bench_println(silencer.as_ref(), &format!(" iterations: {}", iterations));
bench_println(
silencer.as_ref(),
&format!(" total: {:.2}ms", vm_dur.as_nanos() as f64 / 1e6),
);
bench_println(silencer.as_ref(), &format!(" per call: {}ns", vm_ns));
bench_println(silencer.as_ref(), "");
}
let call_name = func_name.unwrap_or(
compiled
.func_names
.first()
.map(|s| s.as_str())
.unwrap_or("main"),
);
let mut vm_state = vm::VmState::new(&compiled);
for _ in 0..100 {
let _ = vm_state.call(call_name, args.to_vec());
}
let start = Instant::now();
for _ in 0..iterations {
vm_result = vm_state
.call(call_name, args.to_vec())
.expect("VM reusable error during benchmark");
}
let vm_reuse_dur = start.elapsed();
let vm_reuse_ns = vm_reuse_dur.as_nanos() / iterations as u128;
if json {
emit_bench_json(
"vm",
Some("reusable"),
&vm_result.to_string(),
iterations,
vm_reuse_dur.as_nanos() as f64 / 1e6,
vm_reuse_ns,
silencer.as_ref(),
);
} else {
bench_println(silencer.as_ref(), "Register VM (reusable)");
bench_println(silencer.as_ref(), &format!(" result: {}", vm_result));
bench_println(silencer.as_ref(), &format!(" iterations: {}", iterations));
bench_println(
silencer.as_ref(),
&format!(
" total: {:.2}ms",
vm_reuse_dur.as_nanos() as f64 / 1e6
),
);
bench_println(
silencer.as_ref(),
&format!(" per call: {}ns", vm_reuse_ns),
);
bench_println(silencer.as_ref(), "");
}
let call_name_jit = func_name.unwrap_or(
compiled
.func_names
.first()
.map(|s| s.as_str())
.unwrap_or("main"),
);
let func_idx_jit = compiled.func_names.iter().position(|n| n == call_name_jit);
#[cfg(feature = "llvm")]
let jit_args: Vec<f64> = args
.iter()
.filter_map(|a| match a {
interpreter::Value::Number(n) => Some(*n),
_ => None,
})
.collect();
#[cfg(feature = "llvm")]
let all_numeric = jit_args.len() == args.len();
let mut jit_cranelift_ns: Option<u128> = None;
#[cfg(feature = "cranelift")]
if let Some(fi) = func_idx_jit {
let chunk = &compiled.chunks[fi];
let nan_consts = &compiled.nan_constants[fi];
let nan_args: Vec<u64> = args.iter().map(|v| vm::NanVal::from_value(v).0).collect();
vm::with_active_registry(&compiled, || {
if let Some(jit_func) = vm::jit_cranelift::compile(chunk, nan_consts, &compiled) {
let entry_name = compiled.func_names.get(fi).map(|s| s.as_str());
for _ in 0..100 {
let _ = vm::jit_cranelift::call(&jit_func, &nan_args, entry_name);
}
let start = Instant::now();
let mut jit_result_bits = 0u64;
for _ in 0..iterations {
jit_result_bits = vm::jit_cranelift::call(&jit_func, &nan_args, entry_name)
.expect("Cranelift JIT error during benchmark");
}
let jit_dur = start.elapsed();
let ns = jit_dur.as_nanos() / iterations as u128;
jit_cranelift_ns = Some(ns);
let jit_result =
vm::NanVal(jit_result_bits).to_value_with_program(&compiled.func_names);
if json {
emit_bench_json(
"jit",
None,
&jit_result.to_string(),
iterations,
jit_dur.as_nanos() as f64 / 1e6,
ns,
silencer.as_ref(),
);
} else {
bench_println(silencer.as_ref(), "Cranelift JIT");
bench_println(silencer.as_ref(), &format!(" result: {}", jit_result));
bench_println(silencer.as_ref(), &format!(" iterations: {}", iterations));
bench_println(
silencer.as_ref(),
&format!(" total: {:.2}ms", jit_dur.as_nanos() as f64 / 1e6),
);
bench_println(silencer.as_ref(), &format!(" per call: {}ns", ns));
bench_println(silencer.as_ref(), "");
}
}
});
}
#[allow(unused_variables)]
let jit_llvm_ns: Option<u128> = None;
#[cfg(feature = "llvm")]
if let Some(fi) = func_idx_jit {
if all_numeric {
let chunk = &compiled.chunks[fi];
let nan_consts = &compiled.nan_constants[fi];
if let Some(jit_func) = vm::jit_llvm::compile(chunk, nan_consts) {
for _ in 0..100 {
let _ = vm::jit_llvm::call(&jit_func, &jit_args);
}
let start = Instant::now();
let mut jit_result = 0.0f64;
for _ in 0..iterations {
jit_result = vm::jit_llvm::call(&jit_func, &jit_args)
.expect("LLVM JIT error during benchmark");
}
let jit_dur = start.elapsed();
let ns = jit_dur.as_nanos() / iterations as u128;
jit_llvm_ns = Some(ns);
let result_str = if jit_result == (jit_result as i64) as f64 {
format!("{}", jit_result as i64)
} else {
format!("{}", jit_result)
};
if json {
emit_bench_json(
"llvm",
None,
&result_str,
iterations,
jit_dur.as_nanos() as f64 / 1e6,
ns,
silencer.as_ref(),
);
} else {
bench_println(silencer.as_ref(), "LLVM JIT");
bench_println(silencer.as_ref(), &format!(" result: {}", result_str));
bench_println(silencer.as_ref(), &format!(" iterations: {}", iterations));
bench_println(
silencer.as_ref(),
&format!(" total: {:.2}ms", jit_dur.as_nanos() as f64 / 1e6),
);
bench_println(silencer.as_ref(), &format!(" per call: {}ns", ns));
bench_println(silencer.as_ref(), "");
}
}
}
}
if json {
return;
}
let py_code = codegen::python::emit(program);
let call_func = func_name.unwrap_or("main").replace('-', "_");
let call_args: Vec<String> = args
.iter()
.map(|a| match a {
interpreter::Value::Number(n) => {
if *n == (*n as i64) as f64 {
format!("{}", *n as i64)
} else {
format!("{}", n)
}
}
interpreter::Value::Text(s) => format!("\"{}\"", s),
interpreter::Value::Bool(b) => {
if *b {
"True".to_string()
} else {
"False".to_string()
}
}
_ => "None".to_string(),
})
.collect();
let py_script = format!(
r#"import time
{code}
_n = {n}
for _ in range(100):
{func}({args})
_start = time.perf_counter_ns()
for _ in range(_n):
_r = {func}({args})
_elapsed = time.perf_counter_ns() - _start
_per = _elapsed // _n
print(f"result: {{_r}}")
print(f"iterations: {{_n}}")
print(f"total: {{_elapsed / 1e6:.2f}}ms")
print(f"per call: {{_per}}ns")
print(f"__NS__={{_per}}")
"#,
code = py_code,
n = iterations,
func = call_func,
args = call_args.join(", ")
);
bench_println(silencer.as_ref(), "Python transpiled");
let output = Command::new("python3").arg("-c").arg(&py_script).output();
let mut py_ns: Option<u128> = None;
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if let Some(val) = line.strip_prefix("__NS__=") {
py_ns = val.parse().ok();
} else {
bench_println(silencer.as_ref(), &format!(" {}", line));
}
}
std::io::stderr()
.write_all(&out.stderr)
.expect("write to stderr");
}
Err(e) => eprintln!(" failed to run python3: {}", e),
}
bench_println(silencer.as_ref(), "");
bench_println(silencer.as_ref(), "Summary");
if vm_ns > 0 && interp_ns > 0 {
if vm_ns < interp_ns {
bench_println(
silencer.as_ref(),
&format!(
" Register VM is {:.1}x faster than interpreter",
interp_ns as f64 / vm_ns as f64
),
);
} else {
bench_println(
silencer.as_ref(),
&format!(
" Interpreter is {:.1}x faster than bytecode VM",
vm_ns as f64 / interp_ns as f64
),
);
}
}
if let Some(jit_ns) = jit_cranelift_ns
&& jit_ns > 0
&& vm_reuse_ns > 0
{
bench_println(
silencer.as_ref(),
&format!(
" Cranelift JIT is {:.1}x faster than VM (reusable)",
vm_reuse_ns as f64 / jit_ns as f64
),
);
}
if let Some(jit_ns) = jit_llvm_ns
&& jit_ns > 0
&& vm_reuse_ns > 0
{
bench_println(
silencer.as_ref(),
&format!(
" LLVM JIT is {:.1}x faster than VM (reusable)",
vm_reuse_ns as f64 / jit_ns as f64
),
);
}
if let Some(py) = py_ns {
if interp_ns > 0 && py > 0 {
if interp_ns < py {
bench_println(
silencer.as_ref(),
&format!(
" Rust interpreter is {:.1}x faster than Python",
py as f64 / interp_ns as f64
),
);
} else {
bench_println(
silencer.as_ref(),
&format!(
" Python is {:.1}x faster than Rust interpreter",
interp_ns as f64 / py as f64
),
);
}
}
if vm_ns > 0 && py > 0 {
if vm_ns < py {
bench_println(
silencer.as_ref(),
&format!(
" Register VM is {:.1}x faster than Python",
py as f64 / vm_ns as f64
),
);
} else {
bench_println(
silencer.as_ref(),
&format!(
" Python is {:.1}x faster than Register VM",
vm_ns as f64 / py as f64
),
);
}
}
if vm_reuse_ns > 0 && py > 0 {
if vm_reuse_ns < py {
bench_println(
silencer.as_ref(),
&format!(
" VM (reusable) is {:.1}x faster than Python",
py as f64 / vm_reuse_ns as f64
),
);
} else {
bench_println(
silencer.as_ref(),
&format!(
" Python is {:.1}x faster than VM (reusable)",
vm_reuse_ns as f64 / py as f64
),
);
}
}
if let Some(jit_ns) = jit_cranelift_ns
&& jit_ns > 0
&& py > 0
{
bench_println(
silencer.as_ref(),
&format!(
" Cranelift JIT is {:.1}x faster than Python",
py as f64 / jit_ns as f64
),
);
}
if let Some(jit_ns) = jit_llvm_ns
&& jit_ns > 0
&& py > 0
{
bench_println(
silencer.as_ref(),
&format!(
" LLVM JIT is {:.1}x faster than Python",
py as f64 / jit_ns as f64
),
);
}
}
}
fn looks_like_subcommand_name(s: &str) -> bool {
if matches!(s, "true" | "false" | "nil") {
return false;
}
let bytes = s.as_bytes();
match bytes.first() {
Some(&c) if c.is_ascii_alphabetic() || c == b'_' => {}
_ => return false,
}
if bytes.last() == Some(&b'-') {
return false;
}
let mut prev_dash = false;
for &c in &bytes[1..] {
let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-';
if !ok {
return false;
}
if c == b'-' && prev_dash {
return false;
}
prev_dash = c == b'-';
}
true
}
use ilo::cli_parse::parse_cli_arg;
use ilo::cli_parse::parse_cli_arg_for_param;
fn lookup_param_types<'a>(
program: &'a ast::Program,
func_name: Option<&str>,
) -> Option<&'a [ast::Param]> {
let name = func_name?;
program.declarations.iter().find_map(|d| match d {
ast::Decl::Function {
name: n, params, ..
} if n == name => Some(params.as_slice()),
_ => None,
})
}
fn resolve_entry_func_name<'a>(
program: &'a ast::Program,
func_name: Option<&'a str>,
) -> Option<&'a str> {
if func_name.is_some() {
return func_name;
}
program.declarations.iter().find_map(|d| match d {
ast::Decl::Function { name, .. } => Some(name.as_str()),
_ => None,
})
}
fn check_cli_arity(
program: &ast::Program,
func_name: Option<&str>,
args_len: usize,
source: &str,
mode: OutputMode,
) -> Result<(), i32> {
let target = match resolve_entry_func_name(program, func_name) {
Some(t) => t,
None => return Ok(()),
};
let params = match lookup_param_types(program, Some(target)) {
Some(p) => p,
None => return Ok(()),
};
if args_len == params.len() {
return Ok(());
}
let err = interpreter::RuntimeError {
code: "ILO-R004",
message: format!(
"{}: expected {} args, got {}",
target,
params.len(),
args_len
),
span: None,
call_stack: Vec::new(),
propagate_value: None,
};
report_diagnostic(
&Diagnostic::from(&err).with_source(source.to_string()),
mode,
);
Err(1)
}
fn parse_cli_args_typed(
program: &ast::Program,
func_name: Option<&str>,
raw: &[String],
) -> Vec<interpreter::Value> {
let params = lookup_param_types(program, func_name);
raw.iter()
.enumerate()
.map(|(i, s)| {
let expected = params.and_then(|ps| ps.get(i)).map(|p| &p.ty);
let mut v = parse_cli_arg_for_param(s, expected);
if matches!(expected, Some(ast::Type::List(_)))
&& !matches!(&v, interpreter::Value::List(_))
{
v = interpreter::Value::List(std::sync::Arc::new(vec![v]));
}
v
})
.collect()
}
#[cfg(test)]
fn coerce_cli_args(
program: &ast::Program,
func_name: Option<&str>,
mut args: Vec<interpreter::Value>,
) -> Vec<interpreter::Value> {
let Some(params) = lookup_param_types(program, func_name) else {
return args;
};
for (i, param) in params.iter().enumerate() {
if i >= args.len() {
break;
}
if matches!(¶m.ty, ast::Type::List(_))
&& !matches!(&args[i], interpreter::Value::List(_))
{
args[i] = interpreter::Value::List(std::sync::Arc::new(vec![args[i].clone()]));
}
}
args
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn subcommand_shape_accepts_idents() {
assert!(looks_like_subcommand_name("wibble"));
assert!(looks_like_subcommand_name("helper"));
assert!(looks_like_subcommand_name("a"));
assert!(looks_like_subcommand_name("_priv"));
assert!(looks_like_subcommand_name("foo_bar"));
assert!(looks_like_subcommand_name("fn2"));
assert!(looks_like_subcommand_name("list-orders"));
assert!(looks_like_subcommand_name("wibble-x"));
assert!(looks_like_subcommand_name("parse-csv"));
assert!(looks_like_subcommand_name("a-b-c"));
assert!(looks_like_subcommand_name("foo-bar"));
}
#[test]
fn subcommand_shape_rejects_data() {
assert!(!looks_like_subcommand_name("42"));
assert!(!looks_like_subcommand_name("-1"));
assert!(!looks_like_subcommand_name("3.14"));
assert!(!looks_like_subcommand_name("\"hi\""));
assert!(!looks_like_subcommand_name("[1,2,3]"));
assert!(!looks_like_subcommand_name("--flag"));
assert!(!looks_like_subcommand_name(""));
assert!(!looks_like_subcommand_name("2x"));
assert!(!looks_like_subcommand_name("top200.csv"));
assert!(!looks_like_subcommand_name("data.csv"));
assert!(!looks_like_subcommand_name("path/to/file"));
assert!(!looks_like_subcommand_name("foo-"));
assert!(!looks_like_subcommand_name("foo--bar"));
assert!(!looks_like_subcommand_name("-foo"));
}
#[test]
fn subcommand_shape_exempts_reserved_literals() {
assert!(!looks_like_subcommand_name("true"));
assert!(!looks_like_subcommand_name("false"));
assert!(!looks_like_subcommand_name("nil"));
}
fn make_compiled(src: &str) -> vm::CompiledProgram {
let tokens = lexer::lex(src).unwrap();
let token_spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (program, _) = parser::parse(token_spans);
vm::compile(&program).unwrap()
}
fn make_program(src: &str) -> ast::Program {
let tokens = lexer::lex(src).unwrap();
let token_spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut program, _) = parser::parse(token_spans);
program.source = Some(src.to_string());
program
}
fn run_serv(line: &str) -> serde_json::Value {
#[cfg(not(feature = "tools"))]
return process_serv_request(line, &[], None);
#[cfg(feature = "tools")]
{
let rt = std::sync::Arc::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap(),
);
process_serv_request(line, &[], None, None, rt)
}
}
#[test]
fn cli_arg_integer() {
assert_eq!(parse_cli_arg("42"), interpreter::Value::Number(42.0));
}
#[test]
fn cli_arg_float() {
#[allow(clippy::approx_constant)]
let expected = interpreter::Value::Number(3.14);
assert_eq!(parse_cli_arg("3.14"), expected);
}
#[test]
fn cli_arg_bool_true() {
assert_eq!(parse_cli_arg("true"), interpreter::Value::Bool(true));
}
#[test]
fn cli_arg_bool_false() {
assert_eq!(parse_cli_arg("false"), interpreter::Value::Bool(false));
}
#[test]
fn cli_arg_text() {
assert_eq!(
parse_cli_arg("hello"),
interpreter::Value::Text(Arc::new("hello".to_string()))
);
}
#[test]
fn cli_arg_bracketed_list() {
assert_eq!(
parse_cli_arg("[1,2,3]"),
interpreter::Value::List(Arc::new(vec![
interpreter::Value::Number(1.0),
interpreter::Value::Number(2.0),
interpreter::Value::Number(3.0),
]))
);
}
#[test]
fn cli_arg_empty_bracketed_list() {
assert_eq!(
parse_cli_arg("[]"),
interpreter::Value::List(Arc::new(vec![]))
);
}
#[test]
fn cli_arg_comma_list() {
assert_eq!(
parse_cli_arg("1,2,3"),
interpreter::Value::List(Arc::new(vec![
interpreter::Value::Number(1.0),
interpreter::Value::Number(2.0),
interpreter::Value::Number(3.0),
]))
);
}
#[test]
fn cli_arg_mixed_comma_list() {
assert_eq!(
parse_cli_arg("1,hello,true"),
interpreter::Value::List(Arc::new(vec![
interpreter::Value::Number(1.0),
interpreter::Value::Text(Arc::new("hello".to_string())),
interpreter::Value::Bool(true),
]))
);
}
#[test]
fn cli_arg_infinity_is_text() {
assert_eq!(
parse_cli_arg("inf"),
interpreter::Value::Text(Arc::new("inf".to_string()))
);
}
#[test]
fn detect_mode_json_long_flag() {
let (mode, explicit, _, _, remaining) =
detect_output_mode(vec!["--json".into(), "foo".into()]);
assert!(matches!(mode, OutputMode::Json));
assert!(explicit);
assert_eq!(remaining, vec!["foo".to_string()]);
}
#[test]
fn detect_mode_json_short_flag() {
let (mode, explicit, _, _, _) = detect_output_mode(vec!["-j".into()]);
assert!(matches!(mode, OutputMode::Json));
assert!(explicit);
}
#[test]
fn detect_mode_text_long_flag() {
let (mode, explicit, _, _, _) = detect_output_mode(vec!["--text".into()]);
assert!(matches!(mode, OutputMode::Text));
assert!(!explicit);
}
#[test]
fn detect_mode_text_short_flag() {
let (mode, _, _, _, _) = detect_output_mode(vec!["-t".into()]);
assert!(matches!(mode, OutputMode::Text));
}
#[test]
fn detect_mode_ansi_long_flag() {
let (mode, explicit, _, _, _) = detect_output_mode(vec!["--ansi".into()]);
assert!(matches!(mode, OutputMode::Ansi));
assert!(!explicit);
}
#[test]
fn detect_mode_ansi_short_flag() {
let (mode, _, _, _, _) = detect_output_mode(vec!["-a".into()]);
assert!(matches!(mode, OutputMode::Ansi));
}
#[test]
fn detect_mode_non_flag_args_pass_through() {
let (_, _, _, _, remaining) =
detect_output_mode(vec!["ilo".into(), "f>n;1".into(), "42".into()]);
assert_eq!(remaining, vec!["ilo", "f>n;1", "42"]);
}
#[test]
fn detect_mode_format_flag_stripped_from_remaining() {
let (_, _, _, _, remaining) =
detect_output_mode(vec!["--json".into(), "code".into(), "arg".into()]);
assert_eq!(remaining, vec!["code", "arg"]);
}
#[test]
fn detect_mode_no_hints_flag() {
let (_, _, no_hints, _, _) = detect_output_mode(vec!["--no-hints".into(), "code".into()]);
assert!(no_hints);
}
#[test]
fn detect_mode_no_hints_short_flag() {
let (_, _, no_hints, _, _) = detect_output_mode(vec!["-nh".into(), "code".into()]);
assert!(no_hints);
}
#[test]
fn detect_mode_no_hints_not_stripped() {
let (_, _, no_hints, _, remaining) =
detect_output_mode(vec!["--no-hints".into(), "code".into()]);
assert!(no_hints);
assert_eq!(remaining, vec!["code"]);
}
#[test]
fn detect_mode_silent_long_flag() {
let (_, _, _, silent, remaining) =
detect_output_mode(vec!["--silent".into(), "code".into()]);
assert!(silent);
assert_eq!(remaining, vec!["code"]);
}
#[test]
fn detect_mode_silent_short_flag() {
let (_, _, _, silent, _) = detect_output_mode(vec!["-s".into(), "code".into()]);
assert!(silent);
}
#[test]
fn detect_mode_silent_composes_with_json() {
let (mode, explicit, _, silent, _) =
detect_output_mode(vec!["--json".into(), "--silent".into(), "code".into()]);
assert!(matches!(mode, OutputMode::Json));
assert!(explicit);
assert!(silent);
}
#[test]
fn collect_hints_double_equals() {
let hints = collect_hints("f x:n y:n>b;==x y");
assert_eq!(hints.len(), 1);
assert!(hints[0].contains("=="));
}
#[test]
fn collect_hints_single_equals_no_hint() {
let hints = collect_hints("f x:n y:n>b;=x y");
assert!(hints.is_empty());
}
#[test]
fn collect_hints_double_equals_inside_string_no_hint() {
let hints = collect_hints(r#"f x:s>s;"hello==world""#);
assert!(hints.is_empty());
}
#[test]
fn collect_hints_no_source_no_hint() {
let hints = collect_hints("f x:n>n;+x 1");
assert!(hints.is_empty());
}
fn parse_program(source: &str) -> ast::Program {
let tokens = lexer::lex(source).expect("lex failed");
let token_spans: Vec<(lexer::Token, ast::Span)> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (prog, errors) = parser::parse(token_spans);
assert!(errors.is_empty(), "parse errors: {:?}", errors);
prog
}
#[test]
fn collect_hints_discarded_err_in_guard_body() {
let prog = parse_program("f x:n>R n t;<x 0{^\"neg\"};~x");
let hints = collect_hints_with_program("f x:n>R n t;<x 0{^\"neg\"};~x", Some(&prog));
assert!(
hints
.iter()
.any(|h| h.contains("discard the body expression")),
"hints: {:?}",
hints
);
}
#[test]
fn collect_hints_discarded_ok_in_guard_body() {
let prog = parse_program("f x:n>R n t;>x 0{~x};^\"neg\"");
let hints = collect_hints_with_program("f x:n>R n t;>x 0{~x};^\"neg\"", Some(&prog));
assert!(
hints
.iter()
.any(|h| h.contains("discard the body expression")),
"hints: {:?}",
hints
);
}
#[test]
fn collect_hints_braceless_guard_no_discard_hint() {
let prog = parse_program("f x:n>R n t;<x 0 ^\"neg\";~x");
let hints = collect_hints_with_program("f x:n>R n t;<x 0 ^\"neg\";~x", Some(&prog));
assert!(
!hints
.iter()
.any(|h| h.contains("discard the body expression")),
"hints: {:?}",
hints
);
}
#[test]
fn collect_hints_explicit_ret_in_guard_no_discard_hint() {
let prog = parse_program("f x:n>R n t;<x 0{ret ^\"neg\"};~x");
let hints = collect_hints_with_program("f x:n>R n t;<x 0{ret ^\"neg\"};~x", Some(&prog));
assert!(
!hints
.iter()
.any(|h| h.contains("discard the body expression")),
"hints: {:?}",
hints
);
}
#[test]
fn collect_hints_discarded_err_inside_foreach_body() {
let prog = parse_program("f xs:L n>R n t;@k xs{<k 0{^\"neg\"}};~0");
let hints =
collect_hints_with_program("f xs:L n>R n t;@k xs{<k 0{^\"neg\"}};~0", Some(&prog));
assert!(
hints
.iter()
.any(|h| h.contains("discard the body expression")),
"hints: {:?}",
hints
);
}
#[test]
fn collect_hints_discarded_err_inside_match_arm_body() {
let src = "f r:R n t>R n t;?r{~v:<v 0{^\"neg\"};~v;^e:^e}";
let prog = parse_program(src);
let hints = collect_hints_with_program(src, Some(&prog));
assert!(
hints
.iter()
.any(|h| h.contains("discard the body expression")),
"hints: {:?}",
hints
);
}
#[test]
fn collect_hints_multi_stmt_guard_body_with_trailing_ok_no_hint() {
let src = "f n:n>R t t;=n 0{prnt \"empty\";~\"ok\"};~\"done\"";
let prog = parse_program(src);
let hints = collect_hints_with_program(src, Some(&prog));
assert!(
!hints
.iter()
.any(|h| h.contains("discard the body expression")),
"hints: {:?}",
hints
);
}
#[test]
fn serv_invalid_json_returns_request_phase() {
let resp = run_serv("not valid json");
assert_eq!(resp["error"]["phase"], "request");
}
#[test]
fn serv_parse_error_returns_parse_phase() {
let resp = run_serv(r#"{"program": "f>n;??invalid"}"#);
assert_eq!(resp["error"]["phase"], "parse");
}
#[test]
fn serv_verify_error_returns_verify_phase() {
let resp = run_serv(r#"{"program": "f x:n>t;x"}"#);
assert_eq!(resp["error"]["phase"], "verify");
}
#[test]
fn serv_success_simple_number() {
let resp = run_serv(r#"{"program": "f>n;99"}"#);
assert!(resp.get("ok").is_some(), "expected ok, got: {resp}");
assert_eq!(resp["ok"].as_f64(), Some(99.0));
assert!(resp["ms"].is_number());
}
#[test]
fn serv_success_with_args() {
let resp = run_serv(r#"{"program": "f x:n>n;*x 2", "args": ["5"], "func": "f"}"#);
assert!(resp.get("ok").is_some(), "expected ok, got: {resp}");
assert_eq!(resp["ok"].as_f64(), Some(10.0));
}
#[test]
fn serv_result_err_value_returns_program_phase() {
let resp = run_serv(r#"{"program": "f>R n t;^\"oops\""}"#);
assert_eq!(
resp["error"]["phase"], "program",
"expected program phase for Err value, got: {resp}"
);
}
#[test]
fn serv_result_ok_value_unwrapped() {
let resp = run_serv(r#"{"program": "f>R n t;~42"}"#);
assert!(resp.get("ok").is_some(), "expected ok, got: {resp}");
assert_eq!(resp["ok"].as_f64(), Some(42.0));
}
#[test]
fn serv_text_result() {
let resp = run_serv(r#"{"program": "f>t;\"hello\""}"#);
assert_eq!(resp["ok"].as_str(), Some("hello"));
}
#[test]
fn serv_with_func_field_selects_function() {
let prog = "{\"program\": \"a>n;1\\nb>n;2\", \"func\": \"b\"}";
let resp = run_serv(prog);
assert_eq!(resp["ok"].as_f64(), Some(2.0));
}
#[test]
fn serv_mcp_decls_prepended() {
let resp = run_serv(r#"{"program": "f>n;1"}"#);
assert_eq!(resp["ok"].as_f64(), Some(1.0));
}
#[test]
fn tool_ok_type_extracts_ok_branch() {
let ty = ast::Type::Result(Box::new(ast::Type::Number), Box::new(ast::Type::Text));
assert!(matches!(tool_ok_type(&ty), ast::Type::Number));
}
#[test]
fn tool_ok_type_passthrough_non_result() {
assert!(matches!(tool_ok_type(&ast::Type::Text), ast::Type::Text));
assert!(matches!(
tool_ok_type(&ast::Type::Number),
ast::Type::Number
));
assert!(matches!(tool_ok_type(&ast::Type::Bool), ast::Type::Bool));
}
#[test]
fn pipe_compat_same_primitives() {
assert!(types_pipe_compatible(
&ast::Type::Number,
&ast::Type::Number
));
assert!(types_pipe_compatible(&ast::Type::Text, &ast::Type::Text));
assert!(types_pipe_compatible(&ast::Type::Bool, &ast::Type::Bool));
assert!(types_pipe_compatible(&ast::Type::Any, &ast::Type::Any));
}
#[test]
fn pipe_compat_different_primitives_incompatible() {
assert!(!types_pipe_compatible(&ast::Type::Number, &ast::Type::Text));
assert!(!types_pipe_compatible(&ast::Type::Bool, &ast::Type::Number));
}
#[test]
fn pipe_compat_named_type_is_wildcard() {
assert!(types_pipe_compatible(
&ast::Type::Named("foo".into()),
&ast::Type::Text
));
assert!(types_pipe_compatible(
&ast::Type::Text,
&ast::Type::Named("bar".into())
));
}
#[test]
fn pipe_compat_optional_param_accepts_inner_type() {
let opt = ast::Type::Optional(Box::new(ast::Type::Number));
assert!(types_pipe_compatible(&ast::Type::Number, &opt));
}
#[test]
fn pipe_compat_list_checks_element_type() {
let list_n = ast::Type::List(Box::new(ast::Type::Number));
let list_t = ast::Type::List(Box::new(ast::Type::Text));
assert!(types_pipe_compatible(&list_n, &list_n.clone()));
assert!(!types_pipe_compatible(&list_n, &list_t));
}
#[test]
fn pipe_compat_sum_is_text_compatible() {
let sum = ast::Type::Sum(vec!["a".into(), "b".into()]);
assert!(types_pipe_compatible(&sum, &ast::Type::Text));
assert!(types_pipe_compatible(&ast::Type::Text, &sum));
assert!(types_pipe_compatible(&sum, &sum.clone()));
}
#[test]
fn pipe_compat_map_checks_key_and_value() {
let map_nn = ast::Type::Map(Box::new(ast::Type::Text), Box::new(ast::Type::Number));
let map_nt = ast::Type::Map(Box::new(ast::Type::Text), Box::new(ast::Type::Text));
assert!(types_pipe_compatible(&map_nn, &map_nn.clone()));
assert!(!types_pipe_compatible(&map_nn, &map_nt));
}
#[test]
fn tool_sig_str_no_params() {
assert_eq!(tool_sig_str(&[], &ast::Type::Number), "> n");
}
#[test]
fn tool_sig_str_one_param() {
let params = vec![ast::Param {
name: "x".into(),
ty: ast::Type::Number,
}];
assert_eq!(tool_sig_str(¶ms, &ast::Type::Text), "x:n > t");
}
#[test]
fn tool_sig_str_two_params() {
let params = vec![
ast::Param {
name: "a".into(),
ty: ast::Type::Number,
},
ast::Param {
name: "b".into(),
ty: ast::Type::Text,
},
];
assert_eq!(tool_sig_str(¶ms, &ast::Type::Bool), "a:n b:t > b");
}
#[test]
fn load_env_file_sets_new_var() {
use std::io::Write;
let path = "/tmp/ilo_test_env_load_A7B3.env";
let key = "ILO_TEST_LOAD_VAR_A7B3";
unsafe { std::env::remove_var(key) };
std::fs::remove_file(path).ok();
let mut f = std::fs::File::create(path).unwrap();
writeln!(f, "# comment line").unwrap();
writeln!(f).unwrap(); writeln!(f, "{key}=hello_world").unwrap();
writeln!(f, " KEY_WITH_SPACES = trimmed ").unwrap();
drop(f);
load_env_file(path);
assert_eq!(std::env::var(key).unwrap(), "hello_world");
unsafe { std::env::remove_var(key) };
std::fs::remove_file(path).ok();
}
#[test]
fn load_env_file_does_not_overwrite_existing_var() {
use std::io::Write;
let path = "/tmp/ilo_test_env_no_overwrite_C5D1.env";
let key = "ILO_TEST_NO_OVERWRITE_C5D1";
unsafe { std::env::remove_var(key) };
unsafe { std::env::set_var(key, "original") };
let mut f = std::fs::File::create(path).unwrap();
writeln!(f, "{key}=new_value").unwrap();
drop(f);
load_env_file(path);
assert_eq!(std::env::var(key).unwrap(), "original");
unsafe { std::env::remove_var(key) };
std::fs::remove_file(path).ok();
}
#[test]
fn load_env_file_missing_file_is_noop() {
load_env_file("/tmp/ilo_nonexistent_env_file_X9Z2.env");
}
#[test]
fn warn_cross_lang_clean_source_no_panic() {
warn_cross_language_syntax("f x:n>n;*x 2", OutputMode::Text);
}
#[test]
fn warn_cross_lang_detects_double_ampersand() {
warn_cross_language_syntax("f a:b>b;&& a true", OutputMode::Text);
}
#[test]
fn warn_cross_lang_detects_double_pipe() {
warn_cross_language_syntax("f a:b>b;|| a false", OutputMode::Text);
}
#[test]
fn warn_cross_lang_detects_arrow() {
warn_cross_language_syntax("f x:n->n;x", OutputMode::Text);
}
#[test]
fn warn_cross_lang_equality_no_longer_warns() {
warn_cross_language_syntax("f x:n>b;== x 1", OutputMode::Text);
}
#[test]
fn decl_name_function_returns_name() {
let d = ast::Decl::Function {
type_params: vec![],
name: "myfunc".into(),
params: vec![],
return_type: ast::Type::Number,
effect_set: None,
body: vec![],
span: ast::Span { start: 0, end: 0 },
};
assert_eq!(decl_name(&d), Some("myfunc"));
}
#[test]
fn decl_name_use_returns_none() {
let d = ast::Decl::Use {
path: "lib.ilo".into(),
only: None,
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
};
assert_eq!(decl_name(&d), None);
}
#[test]
fn decl_name_alias_returns_name() {
let d = ast::Decl::Alias {
name: "mytype".into(),
target: ast::Type::Number,
span: ast::Span { start: 0, end: 0 },
};
assert_eq!(decl_name(&d), Some("mytype"));
}
#[test]
fn decl_name_error_returns_none() {
let d = ast::Decl::Error {
span: ast::Span { start: 0, end: 0 },
};
assert_eq!(decl_name(&d), None);
}
#[test]
fn resolve_imports_only_filter_keeps_named_decl() {
use std::io::Write;
let lib_path = "/tmp/ilo_test_resolve_only_F2G7.ilo";
let mut f = std::fs::File::create(lib_path).unwrap();
writeln!(f, "dbl n:n>n;*n 2").unwrap();
writeln!(f, "half n:n>n;/n 2").unwrap();
drop(f);
let use_decl = ast::Decl::Use {
path: "ilo_test_resolve_only_F2G7.ilo".into(),
only: Some(vec!["dbl".into()]),
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
let names: Vec<&str> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(names.contains(&"dbl"), "expected dbl: {names:?}");
assert!(
!names.contains(&"half"),
"half should be filtered: {names:?}"
);
assert!(diags.is_empty(), "no errors expected: {diags:?}");
std::fs::remove_file(lib_path).ok();
}
#[test]
fn resolve_imports_only_filter_warns_missing_name() {
use std::io::Write;
let lib_path = "/tmp/ilo_test_resolve_missing_H4K9.ilo";
let mut f = std::fs::File::create(lib_path).unwrap();
writeln!(f, "dbl n:n>n;*n 2").unwrap();
drop(f);
let use_decl = ast::Decl::Use {
path: "ilo_test_resolve_missing_H4K9.ilo".into(),
only: Some(vec!["dbl".into(), "nonexistent".into()]),
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let _ = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(
diags.iter().any(|d| d.code == Some("ILO-P019")),
"expected ILO-P019 for missing name, got: {diags:?}"
);
std::fs::remove_file(lib_path).ok();
}
#[test]
fn diag_to_json_simple_error() {
let d = Diagnostic::error("something went wrong").with_code("ILO-T001");
let val = diag_to_json(&d);
assert!(val.is_object());
let obj = val.as_object().unwrap();
assert_eq!(obj["code"], "ILO-T001");
assert!(
obj["message"]
.as_str()
.unwrap()
.contains("something went wrong")
);
}
#[test]
fn diag_to_json_with_span_and_source() {
let d = Diagnostic::error("bad token")
.with_code("ILO-L001")
.with_span(ast::Span { start: 0, end: 3 }, "here")
.with_source("abc".to_string());
let val = diag_to_json(&d);
assert!(val.is_object());
assert_eq!(val["severity"], "error");
}
#[test]
fn pipe_compat_result_matching() {
use ast::Type::*;
let r1 = Result(Box::new(Number), Box::new(Text));
let r2 = Result(Box::new(Number), Box::new(Text));
assert!(types_pipe_compatible(&r1, &r2));
}
#[test]
fn pipe_compat_result_mismatched_ok() {
use ast::Type::*;
assert!(!types_pipe_compatible(
&Result(Box::new(Number), Box::new(Text)),
&Result(Box::new(Text), Box::new(Text)),
));
}
#[test]
fn pipe_compat_result_mismatched_err() {
use ast::Type::*;
assert!(!types_pipe_compatible(
&Result(Box::new(Number), Box::new(Text)),
&Result(Box::new(Number), Box::new(Number)),
));
}
#[test]
fn pipe_compat_map_matching() {
use ast::Type::*;
let m = Map(Box::new(Text), Box::new(Number));
assert!(types_pipe_compatible(&m, &m.clone()));
}
#[test]
fn pipe_compat_map_key_mismatch() {
use ast::Type::*;
assert!(!types_pipe_compatible(
&Map(Box::new(Text), Box::new(Number)),
&Map(Box::new(Number), Box::new(Number)),
));
}
#[test]
fn pipe_compat_map_value_mismatch() {
use ast::Type::*;
assert!(!types_pipe_compatible(
&Map(Box::new(Text), Box::new(Number)),
&Map(Box::new(Text), Box::new(Text)),
));
}
#[test]
fn pipe_compat_result_named_wildcard() {
use ast::Type::*;
assert!(types_pipe_compatible(
&Result(Box::new(Named("T".into())), Box::new(Text)),
&Result(Box::new(Number), Box::new(Text)),
));
}
#[test]
fn pipe_compat_map_named_wildcard() {
use ast::Type::*;
assert!(types_pipe_compatible(
&Map(Box::new(Text), Box::new(Named("V".into()))),
&Map(Box::new(Text), Box::new(Number)),
));
}
#[test]
fn run_vm_with_provider_success_no_tools() {
let compiled = make_compiled("f x:n>n;*x 2");
run_vm_with_provider(
&compiled,
Some("f"),
vec![interpreter::Value::Number(5.0)],
None,
#[cfg(feature = "tools")]
None,
#[cfg(feature = "tools")]
None,
"f x:n>n;*x 2",
OutputMode::Text,
false,
false,
Arc::new(Caps::default()),
);
}
#[test]
fn run_vm_with_provider_explicit_json_wraps_ok() {
let compiled = make_compiled("f x:n>n;*x 3");
run_vm_with_provider(
&compiled,
Some("f"),
vec![interpreter::Value::Number(4.0)],
None,
#[cfg(feature = "tools")]
None,
#[cfg(feature = "tools")]
None,
"f x:n>n;*x 3",
OutputMode::Json,
true,
false,
Arc::new(Caps::default()),
);
}
#[test]
fn run_interp_with_provider_success_no_tools() {
let program = make_program("f x:n>n;*x 2");
run_interp_with_provider(
&program,
Some("f"),
vec![interpreter::Value::Number(7.0)],
None,
#[cfg(feature = "tools")]
None,
#[cfg(feature = "tools")]
None,
"f x:n>n;*x 2",
OutputMode::Text,
false,
Arc::new(Caps::default()),
);
}
#[test]
fn run_interp_with_provider_explicit_json() {
let program = make_program("f x:n>n;+x 1");
run_interp_with_provider(
&program,
Some("f"),
vec![interpreter::Value::Number(10.0)],
None,
#[cfg(feature = "tools")]
None,
#[cfg(feature = "tools")]
None,
"f x:n>n;+x 1",
OutputMode::Json,
true,
Arc::new(Caps::default()),
);
}
#[test]
fn run_default_simple_numeric() {
let program = make_program("f x:n>n;*x 2");
run_default(
&program,
Some("f"),
vec![interpreter::Value::Number(3.0)],
"f x:n>n;*x 2",
OutputMode::Text,
false,
Arc::new(Caps::default()),
);
}
#[test]
fn run_default_text_result() {
let program = make_program("greet name:t>t;cat \"hi \" name");
run_default(
&program,
Some("greet"),
vec![interpreter::Value::Text(Arc::new("world".to_string()))],
"greet name:t>t;cat \"hi \" name",
OutputMode::Text,
false,
Arc::new(Caps::default()),
);
}
#[test]
fn run_default_none_func_name_uses_first() {
let program = make_program("double x:n>n;*x 2");
run_default(
&program,
None,
vec![interpreter::Value::Number(4.0)],
"double x:n>n;*x 2",
OutputMode::Text,
false,
Arc::new(Caps::default()),
);
}
#[test]
fn resolve_imports_inline_code_emits_p017() {
let use_decl = ast::Decl::Use {
path: "something.ilo".into(),
only: None,
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 20 },
};
let mut visited = std::collections::HashSet::new();
let mut diags = Vec::new();
let result = resolve_imports(
vec![use_decl],
None,
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(result.is_empty());
assert!(diags.iter().any(|d| d.code == Some("ILO-P017")));
assert!(diags[0].message.contains("inline code"));
}
#[test]
fn resolve_imports_file_not_found_emits_p017() {
let use_decl = ast::Decl::Use {
path: "nonexistent_xyz_99999.ilo".into(),
only: None,
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 30 },
};
let mut visited = std::collections::HashSet::new();
let mut diags = Vec::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(result.is_empty());
assert!(
diags
.iter()
.any(|d| d.code == Some("ILO-P017") && d.message.contains("file not found"))
);
}
#[test]
fn resolve_imports_non_use_decl_passes_through() {
let func_decl = ast::Decl::Function {
type_params: vec![],
name: "f".into(),
params: vec![],
return_type: ast::Type::Number,
effect_set: None,
body: vec![],
span: ast::Span { start: 0, end: 0 },
};
let mut visited = std::collections::HashSet::new();
let mut diags = Vec::new();
let result = resolve_imports(
vec![func_decl],
None,
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert_eq!(result.len(), 1);
assert!(diags.is_empty());
}
#[test]
fn print_value_plain_number_no_json() {
print_value(&interpreter::Value::Number(42.0), false, false);
}
#[test]
fn print_value_ok_as_json() {
let val = interpreter::Value::Ok(Box::new(interpreter::Value::Number(42.0)));
print_value(&val, true, false);
}
#[test]
fn print_value_err_as_json() {
let val = interpreter::Value::Err(Box::new(interpreter::Value::Text(Arc::new(
"oops".to_string(),
))));
print_value(&val, true, false);
}
#[test]
fn print_value_err_no_json() {
let val = interpreter::Value::Err(Box::new(interpreter::Value::Text(Arc::new(
"fail".to_string(),
))));
print_value(&val, false, false);
}
#[test]
fn print_value_text_as_json() {
print_value(
&interpreter::Value::Text(Arc::new("hello".to_string())),
true,
false,
);
}
#[test]
fn print_value_bool_as_json() {
print_value(&interpreter::Value::Bool(true), true, false);
}
#[test]
fn print_value_nil_as_json() {
print_value(&interpreter::Value::Nil, true, false);
}
#[test]
fn print_value_list_as_json() {
let val = interpreter::Value::List(Arc::new(vec![
interpreter::Value::Number(1.0),
interpreter::Value::Number(2.0),
]));
print_value(&val, true, false);
}
#[test]
fn warn_cross_lang_json_mode() {
warn_cross_language_syntax("f x:b y:b>b;&& x y", OutputMode::Json);
}
#[test]
fn warn_cross_lang_multiple_patterns_json_mode() {
warn_cross_language_syntax("f x:n->n;== x 1 // check", OutputMode::Json);
}
#[test]
fn resolve_imports_parse_error_in_imported_file() {
let bad_path = "/tmp/ilo_unit_bad_parse_imports.ilo";
std::fs::write(bad_path, "f x:>n;x").expect("write bad file");
let decls = vec![ast::Decl::Use {
path: "ilo_unit_bad_parse_imports.ilo".into(),
only: None,
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
}];
let mut visited = std::collections::HashSet::new();
let mut diags = Vec::new();
let _ = resolve_imports(
decls,
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(
!diags.is_empty(),
"expected parse error diagnostic from imported file"
);
std::fs::remove_file(bad_path).ok();
}
#[test]
fn resolve_imports_transitive() {
let file_b = "/tmp/ilo_unit_trans_b_Q3R8.ilo";
let file_a = "/tmp/ilo_unit_trans_a_Q3R8.ilo";
std::fs::write(file_b, "triple x:n>n;*x 3").expect("write B");
std::fs::write(
file_a,
"use \"ilo_unit_trans_b_Q3R8.ilo\"\nsextuple x:n>n;t=triple x;*t 2",
)
.expect("write A");
let decls = vec![ast::Decl::Use {
path: "ilo_unit_trans_a_Q3R8.ilo".into(),
only: None,
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
}];
let mut visited = std::collections::HashSet::new();
let mut diags = Vec::new();
let result = resolve_imports(
decls,
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let names: Vec<_> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(names.contains(&"triple"), "expected triple: {names:?}");
assert!(names.contains(&"sextuple"), "expected sextuple: {names:?}");
std::fs::remove_file(file_b).ok();
std::fs::remove_file(file_a).ok();
}
#[test]
fn resolve_imports_alias_renames_public_decls() {
use std::io::Write;
let lib_path = "/tmp/ilo_test_alias_rename_X9Y2.ilo";
let mut f = std::fs::File::create(lib_path).unwrap();
writeln!(f, "dbl n:n>n;*n 2").unwrap();
writeln!(f, "triple n:n>n;*n 3").unwrap();
drop(f);
let use_decl = ast::Decl::Use {
path: "ilo_test_alias_rename_X9Y2.ilo".into(),
only: None,
alias: Some("m".into()),
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(diags.is_empty(), "no errors expected: {diags:?}");
let names: Vec<&str> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(names.contains(&"m-dbl"), "expected m-dbl: {names:?}");
assert!(names.contains(&"m-triple"), "expected m-triple: {names:?}");
assert!(
!names.contains(&"dbl"),
"plain dbl should not be in result: {names:?}"
);
std::fs::remove_file(lib_path).ok();
}
#[test]
fn resolve_imports_alias_excludes_private_decls() {
use std::io::Write;
let lib_path = "/tmp/ilo_test_alias_priv_W7Z4.ilo";
let mut f = std::fs::File::create(lib_path).unwrap();
writeln!(f, "_private n:n>n;+n 0").unwrap();
writeln!(f, "pub-fn n:n>n;+n 1").unwrap();
drop(f);
let use_decl = ast::Decl::Use {
path: "ilo_test_alias_priv_W7Z4.ilo".into(),
only: None,
alias: Some("m".into()),
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(diags.is_empty(), "no errors expected: {diags:?}");
let names: Vec<&str> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(names.contains(&"m-pub-fn"), "expected m-pub-fn: {names:?}");
assert!(
!names
.iter()
.any(|n| n.starts_with("m-_") || *n == "_private"),
"private decl should not appear: {names:?}"
);
std::fs::remove_file(lib_path).ok();
}
#[test]
fn resolve_imports_selective_blocks_private_name() {
use std::io::Write;
let lib_path = "/tmp/ilo_test_sel_priv_V3K8.ilo";
let mut f = std::fs::File::create(lib_path).unwrap();
writeln!(f, "_priv n:n>n;+n 0").unwrap();
writeln!(f, "pub-fn n:n>n;+n 1").unwrap();
drop(f);
let use_decl = ast::Decl::Use {
path: "ilo_test_sel_priv_V3K8.ilo".into(),
only: Some(vec!["_priv".into()]),
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(result.is_empty(), "private name should produce no result");
assert!(
diags.iter().any(|d| d.code == Some("ILO-P019")),
"expected ILO-P019: {diags:?}"
);
assert!(
diags.iter().any(|d| d.message.contains("module-private")),
"expected 'module-private' in error: {diags:?}"
);
std::fs::remove_file(lib_path).ok();
}
#[test]
fn resolve_imports_conditional_wasm_true_branch() {
let wasm_path = "/tmp/ilo_cond_wasm_ILO399.ilo";
let native_path = "/tmp/ilo_cond_native_ILO399.ilo";
std::fs::write(wasm_path, "wasm-fn>n;42").unwrap();
std::fs::write(native_path, "native-fn>n;99").unwrap();
let use_decl = ast::Decl::Use {
path: "ilo_cond_wasm_ILO399.ilo".into(),
only: None,
alias: None,
predicate: Some(ast::UsePredicate::Wasm),
alt_path: Some("ilo_cond_native_ILO399.ilo".into()),
reexport: false,
lazy: false,
span: ast::Span::UNKNOWN,
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::Wasm,
);
assert!(diags.is_empty(), "no errors expected: {diags:?}");
let names: Vec<_> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(
names.contains(&"wasm-fn"),
"expected wasm-fn, got: {names:?}"
);
assert!(
!names.contains(&"native-fn"),
"native-fn should not be imported"
);
std::fs::remove_file(wasm_path).ok();
std::fs::remove_file(native_path).ok();
}
#[test]
fn resolve_imports_reexport_makes_names_available() {
use std::io::Write;
let inner_path = "/tmp/ilo_reexport_inner_A1B2.ilo";
let outer_path = "/tmp/ilo_reexport_outer_A1B2.ilo";
let mut inner = std::fs::File::create(inner_path).unwrap();
writeln!(inner, "foo n:n>n;+n 1").unwrap();
writeln!(inner, "bar n:n>n;*n 2").unwrap();
drop(inner);
let mut outer = std::fs::File::create(outer_path).unwrap();
writeln!(outer, "use re:\"ilo_reexport_inner_A1B2.ilo\" [foo bar]").unwrap();
writeln!(outer, "baz n:n>n;+n 10").unwrap();
drop(outer);
let use_decl = ast::Decl::Use {
path: "ilo_reexport_outer_A1B2.ilo".into(),
only: Some(vec!["foo".into(), "baz".into()]),
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::Native,
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let names: Vec<&str> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(
names.contains(&"foo"),
"foo should be available via re-export: {names:?}"
);
assert!(
names.contains(&"baz"),
"baz should be available as outer's own decl: {names:?}"
);
std::fs::remove_file(inner_path).ok();
std::fs::remove_file(outer_path).ok();
}
#[test]
fn resolve_imports_conditional_wasm_false_branch() {
let wasm_path = "/tmp/ilo_cond2_wasm_ILO399.ilo";
let native_path = "/tmp/ilo_cond2_native_ILO399.ilo";
std::fs::write(wasm_path, "wasm-fn>n;42").unwrap();
std::fs::write(native_path, "native-fn>n;99").unwrap();
let use_decl = ast::Decl::Use {
path: "ilo_cond2_wasm_ILO399.ilo".into(),
only: None,
alias: None,
predicate: Some(ast::UsePredicate::Wasm),
alt_path: Some("ilo_cond2_native_ILO399.ilo".into()),
reexport: false,
lazy: false,
span: ast::Span::UNKNOWN,
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::Native, );
assert!(diags.is_empty(), "no errors expected: {diags:?}");
let names: Vec<_> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(
names.contains(&"native-fn"),
"expected native-fn, got: {names:?}"
);
assert!(
!names.contains(&"wasm-fn"),
"wasm-fn should not be imported"
);
std::fs::remove_file(wasm_path).ok();
std::fs::remove_file(native_path).ok();
}
#[test]
fn resolve_imports_reexport_flat_includes_reexported_names() {
use std::io::Write;
let inner_path = "/tmp/ilo_reexport_flat_inner_C3D4.ilo";
let outer_path = "/tmp/ilo_reexport_flat_outer_C3D4.ilo";
let mut inner = std::fs::File::create(inner_path).unwrap();
writeln!(inner, "foo n:n>n;+n 1").unwrap();
drop(inner);
let mut outer = std::fs::File::create(outer_path).unwrap();
writeln!(outer, "use re:\"ilo_reexport_flat_inner_C3D4.ilo\" [foo]").unwrap();
writeln!(outer, "baz n:n>n;+n 10").unwrap();
drop(outer);
let use_decl = ast::Decl::Use {
path: "ilo_reexport_flat_outer_C3D4.ilo".into(),
only: None,
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span { start: 0, end: 0 },
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::Native,
);
assert!(diags.is_empty(), "unexpected diagnostics: {diags:?}");
let names: Vec<&str> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(
names.contains(&"foo"),
"foo should come through flat import: {names:?}"
);
assert!(
names.contains(&"baz"),
"baz should come through flat import: {names:?}"
);
std::fs::remove_file(inner_path).ok();
std::fs::remove_file(outer_path).ok();
}
#[test]
fn resolve_imports_conditional_test_predicate() {
let stub_path = "/tmp/ilo_cond_stub_ILO399.ilo";
let real_path = "/tmp/ilo_cond_real_ILO399.ilo";
std::fs::write(stub_path, "stub-fn>n;0").unwrap();
std::fs::write(real_path, "real-fn>n;1").unwrap();
let use_decl = ast::Decl::Use {
path: "ilo_cond_stub_ILO399.ilo".into(),
only: None,
alias: None,
predicate: Some(ast::UsePredicate::Test),
alt_path: Some("ilo_cond_real_ILO399.ilo".into()),
reexport: false,
lazy: false,
span: ast::Span::UNKNOWN,
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![use_decl],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::Test,
);
assert!(diags.is_empty(), "no errors: {diags:?}");
let names: Vec<_> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(
names.contains(&"stub-fn"),
"expected stub-fn, got: {names:?}"
);
std::fs::remove_file(stub_path).ok();
std::fs::remove_file(real_path).ok();
}
#[test]
fn resolve_imports_lazy_not_loaded_when_unreferenced() {
let lib_path = "/tmp/ilo_lazy_SHOULD_NOT_OPEN_ILO400.ilo";
std::fs::remove_file(lib_path).ok();
let caller = ast::Decl::Function {
name: "main".into(),
type_params: vec![],
params: vec![],
return_type: ast::Type::Number,
body: vec![ast::Spanned::unknown(ast::Stmt::Return(
ast::Expr::Literal(ast::Literal::Number(42.0)),
))],
span: ast::Span::UNKNOWN,
effect_set: None,
};
let lazy_use = ast::Decl::Use {
path: "ilo_lazy_SHOULD_NOT_OPEN_ILO400.ilo".into(),
only: None,
alias: Some("ilo-lazy-SHOULD-NOT-OPEN-ILO400".into()),
predicate: None,
alt_path: None,
reexport: false,
lazy: true,
span: ast::Span::UNKNOWN,
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![lazy_use, caller],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(
diags.is_empty(),
"lazy module must not be opened when unreferenced, got diags: {diags:?}"
);
let names: Vec<_> = result.iter().filter_map(|d| decl_name(d)).collect();
assert_eq!(names, vec!["main"], "expected only main, got: {names:?}");
}
#[test]
fn resolve_imports_lazy_loaded_when_referenced() {
use std::io::Write;
let lib_path = "/tmp/ilo_lazy_load_ILO400.ilo";
let mut f = std::fs::File::create(lib_path).unwrap();
writeln!(f, "mod-helper>n;99").unwrap();
drop(f);
let caller = ast::Decl::Function {
name: "main".into(),
type_params: vec![],
params: vec![],
return_type: ast::Type::Number,
body: vec![ast::Spanned::unknown(ast::Stmt::Return(ast::Expr::Call {
function: "mod-helper".into(),
args: vec![],
unwrap: ast::UnwrapMode::None,
}))],
span: ast::Span::UNKNOWN,
effect_set: None,
};
let lazy_use = ast::Decl::Use {
path: "ilo_lazy_load_ILO400.ilo".into(),
only: None,
alias: Some("mod".into()),
predicate: None,
alt_path: None,
reexport: false,
lazy: true,
span: ast::Span::UNKNOWN,
};
let mut diags = Vec::new();
let mut visited = std::collections::HashSet::new();
let result = resolve_imports(
vec![lazy_use, caller],
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diags,
BuildTarget::default(),
);
assert!(diags.is_empty(), "no errors expected: {diags:?}");
let names: Vec<_> = result.iter().filter_map(|d| decl_name(d)).collect();
assert!(
names.iter().any(|n| n.starts_with("mod-")),
"expected mod-* symbol from loaded lazy module, got: {names:?}"
);
assert!(
names.contains(&"main"),
"main must also be present: {names:?}"
);
std::fs::remove_file(lib_path).ok();
}
#[test]
fn build_target_eval_all_predicates() {
assert!(BuildTarget::Wasm.eval(ast::UsePredicate::Wasm));
assert!(!BuildTarget::Wasm.eval(ast::UsePredicate::Native));
assert!(!BuildTarget::Wasm.eval(ast::UsePredicate::Test));
assert!(BuildTarget::Native.eval(ast::UsePredicate::Native));
assert!(!BuildTarget::Native.eval(ast::UsePredicate::Wasm));
assert!(!BuildTarget::Native.eval(ast::UsePredicate::Test));
assert!(BuildTarget::Test.eval(ast::UsePredicate::Test));
assert!(!BuildTarget::Test.eval(ast::UsePredicate::Wasm));
assert!(!BuildTarget::Test.eval(ast::UsePredicate::Native));
}
#[test]
fn report_diagnostic_text_mode_no_panic() {
let d = Diagnostic::error("test error").with_code("ILO-T001");
report_diagnostic(&d, OutputMode::Text);
}
#[test]
fn report_diagnostic_ansi_mode_no_panic() {
let d = Diagnostic::error("ansi error").with_code("ILO-T002");
report_diagnostic(&d, OutputMode::Ansi);
}
#[test]
fn report_diagnostic_json_mode_no_panic() {
let d = Diagnostic::error("json error").with_code("ILO-T003");
report_diagnostic(&d, OutputMode::Json);
}
#[test]
fn report_diagnostic_warning_all_modes_no_panic() {
let d = Diagnostic::warning("test warning");
report_diagnostic(&d, OutputMode::Text);
report_diagnostic(&d, OutputMode::Ansi);
report_diagnostic(&d, OutputMode::Json);
}
#[test]
fn decl_name_tool_returns_name() {
let d = ast::Decl::Tool {
name: "my_tool".into(),
description: "does things".into(),
params: vec![],
return_type: ast::Type::Text,
timeout: None,
retry: None,
span: ast::Span { start: 0, end: 0 },
};
assert_eq!(decl_name(&d), Some("my_tool"));
}
#[test]
fn decl_name_typedef_returns_name() {
let d = ast::Decl::TypeDef {
name: "Point".into(),
fields: vec![],
span: ast::Span { start: 0, end: 0 },
};
assert_eq!(decl_name(&d), Some("Point"));
}
#[test]
fn warn_cross_lang_detects_double_slash_comment() {
warn_cross_language_syntax("f x:n>n;// this is a comment", OutputMode::Text);
}
#[test]
fn warn_cross_lang_ignores_slash_in_strings() {
warn_cross_language_syntax(r#"f>t;"https://example.com""#, OutputMode::Text);
}
#[test]
fn strip_string_contents_preserves_outside() {
let result = strip_string_contents(r#"abc "hello" def"#);
assert_eq!(result, r#"abc " " def"#);
}
#[test]
fn strip_string_contents_handles_escapes() {
let result = strip_string_contents(r#""a\"b""#);
assert_eq!(result, r#"" ""#);
}
#[test]
fn strip_string_contents_url() {
let result = strip_string_contents(r#"get "https://api.com/users""#);
assert!(!result.contains("//"));
}
#[test]
fn warn_cross_lang_ansi_mode_no_panic() {
warn_cross_language_syntax("f x:n>n;&& x true", OutputMode::Ansi);
}
#[test]
fn cli_arg_nan_is_text() {
assert_eq!(
parse_cli_arg("NaN"),
interpreter::Value::Text(Arc::new("NaN".to_string()))
);
}
#[test]
fn cli_arg_negative_number() {
assert_eq!(parse_cli_arg("-5"), interpreter::Value::Number(-5.0));
}
#[test]
fn cli_arg_nil() {
assert_eq!(parse_cli_arg("nil"), interpreter::Value::Nil);
}
#[test]
fn cli_arg_nil_not_text() {
assert_ne!(
parse_cli_arg("nil"),
interpreter::Value::Text(Arc::new("nil".to_string()))
);
}
#[test]
fn coerce_single_number_to_list() {
let src = "f xs:L n>n;sum xs";
let tokens = crate::lexer::lex(src).unwrap();
let spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
crate::ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (program, _) = crate::parser::parse(spans);
let args = vec![interpreter::Value::Number(10.0)];
let coerced = coerce_cli_args(&program, Some("f"), args);
assert_eq!(
coerced,
vec![interpreter::Value::List(Arc::new(vec![
interpreter::Value::Number(10.0)
]))]
);
}
#[test]
fn coerce_list_unchanged() {
let src = "f xs:L n>n;sum xs";
let tokens = crate::lexer::lex(src).unwrap();
let spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
crate::ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (program, _) = crate::parser::parse(spans);
let args = vec![interpreter::Value::List(Arc::new(vec![
interpreter::Value::Number(1.0),
interpreter::Value::Number(2.0),
]))];
let coerced = coerce_cli_args(&program, Some("f"), args.clone());
assert_eq!(coerced, args);
}
#[test]
fn coerce_non_list_param_unchanged() {
let src = "f x:n>n;+x 0";
let tokens = crate::lexer::lex(src).unwrap();
let spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
crate::ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (program, _) = crate::parser::parse(spans);
let args = vec![interpreter::Value::Number(10.0)];
let coerced = coerce_cli_args(&program, Some("f"), args.clone());
assert_eq!(coerced, args);
}
#[test]
fn coerce_mixed_params() {
let src = "f xs:L n v:n>n;+v 0";
let tokens = crate::lexer::lex(src).unwrap();
let spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
crate::ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (program, _) = crate::parser::parse(spans);
let args = vec![
interpreter::Value::Number(5.0),
interpreter::Value::Number(3.0),
];
let coerced = coerce_cli_args(&program, Some("f"), args);
assert_eq!(
coerced,
vec![
interpreter::Value::List(Arc::new(vec![interpreter::Value::Number(5.0)])),
interpreter::Value::Number(3.0),
]
);
}
#[test]
fn typed_text_param_preserves_numeric_looking_input() {
let program = make_program("f arg:t>t;arg");
let raw = vec!["2".to_string()];
let parsed = parse_cli_args_typed(&program, Some("f"), &raw);
assert_eq!(
parsed,
vec![interpreter::Value::Text(Arc::new("2".to_string()))]
);
}
#[test]
fn typed_text_param_preserves_bool_looking_input() {
let program = make_program("f arg:t>t;arg");
let raw = vec!["true".to_string()];
let parsed = parse_cli_args_typed(&program, Some("f"), &raw);
assert_eq!(
parsed,
vec![interpreter::Value::Text(Arc::new("true".to_string()))]
);
}
#[test]
fn typed_text_param_preserves_nil_looking_input() {
let program = make_program("f arg:t>t;arg");
let raw = vec!["nil".to_string()];
let parsed = parse_cli_args_typed(&program, Some("f"), &raw);
assert_eq!(
parsed,
vec![interpreter::Value::Text(Arc::new("nil".to_string()))]
);
}
#[test]
fn typed_text_param_preserves_list_looking_input() {
let program = make_program("f arg:t>t;arg");
let raw = vec!["[1,2]".to_string()];
let parsed = parse_cli_args_typed(&program, Some("f"), &raw);
assert_eq!(
parsed,
vec![interpreter::Value::Text(Arc::new("[1,2]".to_string()))]
);
}
#[test]
fn typed_number_param_still_parses_as_number() {
let program = make_program("f x:n>n;x");
let raw = vec!["42".to_string()];
let parsed = parse_cli_args_typed(&program, Some("f"), &raw);
assert_eq!(parsed, vec![interpreter::Value::Number(42.0)]);
}
#[test]
fn typed_list_param_still_wraps_scalar() {
let program = make_program("f xs:L n>n;sum xs");
let raw = vec!["10".to_string()];
let parsed = parse_cli_args_typed(&program, Some("f"), &raw);
assert_eq!(
parsed,
vec![interpreter::Value::List(Arc::new(vec![
interpreter::Value::Number(10.0)
]))]
);
}
#[test]
fn typed_mixed_params_route_correctly() {
let program = make_program("f a:t b:n>t;a");
let raw = vec!["2".to_string(), "3".to_string()];
let parsed = parse_cli_args_typed(&program, Some("f"), &raw);
assert_eq!(
parsed,
vec![
interpreter::Value::Text(Arc::new("2".to_string())),
interpreter::Value::Number(3.0),
]
);
}
#[test]
fn typed_no_func_name_falls_back_to_legacy_parsing() {
let program = make_program("f arg:t>t;arg");
let raw = vec!["2".to_string()];
let parsed = parse_cli_args_typed(&program, None, &raw);
assert_eq!(parsed, vec![interpreter::Value::Number(2.0)]);
}
#[test]
fn coerce_no_func_name_unchanged() {
let src = "f xs:L n>n;sum xs";
let tokens = crate::lexer::lex(src).unwrap();
let spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
crate::ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (program, _) = crate::parser::parse(spans);
let args = vec![interpreter::Value::Number(10.0)];
let coerced = coerce_cli_args(&program, None, args.clone());
assert_eq!(coerced, args);
}
#[test]
fn load_dotenv_env_local_takes_priority_over_env() {
use std::io::Write;
let dir = "/tmp/ilo_test_load_dotenv_prio_M8N2";
std::fs::create_dir_all(dir).unwrap();
let local_path = format!("{dir}/.env.local");
let env_path = format!("{dir}/.env");
let key = "ILO_TEST_DOTENV_PRIO_M8N2";
unsafe { std::env::remove_var(key) };
let mut f = std::fs::File::create(&local_path).unwrap();
writeln!(f, "{key}=from_local").unwrap();
drop(f);
let mut f = std::fs::File::create(&env_path).unwrap();
writeln!(f, "{key}=from_env").unwrap();
drop(f);
load_env_file(&local_path);
load_env_file(&env_path);
assert_eq!(
std::env::var(key).unwrap(),
"from_local",
".env.local should take priority over .env"
);
unsafe { std::env::remove_var(key) };
std::fs::remove_file(&local_path).ok();
std::fs::remove_file(&env_path).ok();
std::fs::remove_dir(dir).ok();
}
#[test]
fn serv_lex_error_returns_lex_phase() {
let resp = run_serv(r#"{"program": "f>t;\""}"#);
let phase = resp["error"]["phase"].as_str().unwrap_or("");
assert!(
phase == "lex" || phase == "parse",
"expected lex or parse phase for unterminated string, got: {resp}"
);
}
#[test]
fn serv_runtime_error_returns_runtime_phase() {
let resp = run_serv(r#"{"program": "f>n;/1 0", "func": "f"}"#);
assert_eq!(
resp["error"]["phase"], "runtime",
"expected runtime phase for division by zero, got: {resp}"
);
}
#[test]
fn serv_with_non_empty_mcp_tool_decls_succeed() {
let tool_decl = ast::Decl::Tool {
name: "echo_tool".into(),
description: "echoes input".into(),
params: vec![ast::Param {
name: "msg".into(),
ty: ast::Type::Text,
}],
return_type: ast::Type::Result(Box::new(ast::Type::Text), Box::new(ast::Type::Text)),
timeout: None,
retry: None,
span: ast::Span { start: 0, end: 0 },
};
let line = r#"{"program": "f>n;42", "func": "f"}"#;
#[cfg(not(feature = "tools"))]
let resp = process_serv_request(line, &[tool_decl], None);
#[cfg(feature = "tools")]
let resp = {
let rt = std::sync::Arc::new(
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap(),
);
process_serv_request(line, &[tool_decl], None, None, rt)
};
assert!(
resp.get("ok").is_some(),
"expected ok response, got: {resp}"
);
assert_eq!(resp["ok"].as_f64(), Some(42.0));
}
#[test]
fn diag_to_json_warning_severity() {
let d = Diagnostic::warning("suspicious pattern");
let val = diag_to_json(&d);
assert!(val.is_object());
assert_eq!(val["severity"], "warning");
}
#[test]
fn print_value_list_plain_not_json() {
let val = interpreter::Value::List(Arc::new(vec![
interpreter::Value::Number(1.0),
interpreter::Value::Text(Arc::new("x".to_string())),
]));
print_value(&val, false, false);
}
#[test]
fn print_value_map_as_json() {
let mut m = std::collections::HashMap::new();
m.insert(
interpreter::MapKey::Text("k".to_string()),
interpreter::Value::Number(7.0),
);
let val = interpreter::Value::Map(std::sync::Arc::new(m));
print_value(&val, true, false);
}
#[test]
fn print_value_map_plain_not_json() {
let mut m = std::collections::HashMap::new();
m.insert(
interpreter::MapKey::Text("key".to_string()),
interpreter::Value::Bool(true),
);
let val = interpreter::Value::Map(std::sync::Arc::new(m));
print_value(&val, false, false);
}
#[test]
fn suppress_prnt_at_tail() {
let prog = make_program("main>_;prnt \"hello\"");
assert!(
program_result_should_suppress(&prog, None),
"prnt at tail should suppress auto-print"
);
}
#[test]
fn suppress_print_alias_at_tail() {
let tokens = lexer::lex("main>_;print \"hello\"").unwrap();
let token_spans: Vec<_> = tokens
.into_iter()
.map(|(t, r)| {
(
t,
ast::Span {
start: r.start,
end: r.end,
},
)
})
.collect();
let (mut prog, _) = parser::parse(token_spans);
ast::resolve_aliases(&mut prog);
assert!(
program_result_should_suppress(&prog, None),
"print alias at tail should suppress after alias resolution"
);
}
#[test]
fn no_suppress_prnt_not_at_tail() {
let prog = make_program("main>_;prnt \"hi\";\"final\"");
assert!(
!program_result_should_suppress(&prog, None),
"prnt not at tail must not suppress"
);
}
#[test]
fn suppress_loop_at_tail_unchanged() {
let prog = make_program("main>_;@x [1 2 3]{prnt x}");
assert!(
program_result_should_suppress(&prog, None),
"loop at tail should still suppress"
);
}
#[test]
fn no_suppress_plain_expr_at_tail() {
let prog = make_program("main>_;\"hello\"");
assert!(
!program_result_should_suppress(&prog, None),
"plain expr at tail must not suppress"
);
}
#[test]
fn no_suppress_prnt_of_ok_wrap_at_tail() {
let prog = make_program("m>R t t;prnt ~\"x\"");
assert!(
!program_result_should_suppress(&prog, None),
"prnt of `~v` at tail must NOT suppress (auto-echo strips the wrapper to a different line)"
);
}
#[test]
fn no_suppress_prnt_of_err_wrap_at_tail() {
let prog = make_program("m>R t t;prnt ^\"oops\"");
assert!(
!program_result_should_suppress(&prog, None),
"prnt of `^e` at tail must NOT suppress"
);
}
fn ilo_bin() -> std::path::PathBuf {
let exe = std::env::current_exe().expect("current_exe");
let profile_dir = exe
.parent() .and_then(|p| p.parent()) .expect("could not locate profile dir");
let bin = profile_dir.join("ilo");
assert!(
bin.exists(),
"ilo binary not found at {}; run `cargo build` first",
bin.display()
);
bin
}
#[test]
fn cli_version_flag_prints_version() {
let out = std::process::Command::new(ilo_bin())
.arg("--version")
.output()
.expect("failed to run ilo --version");
assert!(out.status.success(), "exit status: {}", out.status);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains('.'),
"expected version number in stdout, got: {stdout}"
);
assert!(
stdout.to_lowercase().contains("ilo")
|| stdout
.trim()
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false),
"expected ilo version string, got: {stdout}"
);
}
#[test]
fn cli_explain_valid_code_exits_zero_with_text() {
let out = std::process::Command::new(ilo_bin())
.args(["--explain", "ILO-T001"])
.output()
.expect("failed to run ilo --explain ILO-T001");
assert!(
out.status.success(),
"expected exit 0, got: {}; stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.trim().is_empty(),
"expected explanation text on stdout, got empty"
);
}
#[test]
fn cli_explain_unknown_code_exits_nonzero() {
let out = std::process::Command::new(ilo_bin())
.args(["--explain", "INVALID-CODE"])
.output()
.expect("failed to run ilo --explain INVALID-CODE");
assert!(
!out.status.success(),
"expected non-zero exit for unknown code"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("unknown") || stderr.contains("not found"),
"expected 'unknown' or 'not found' in stderr, got: {stderr}"
);
}
#[test]
fn cli_help_default_exits_zero_with_usage() {
let out = std::process::Command::new(ilo_bin())
.arg("help")
.output()
.expect("failed to run ilo help");
assert!(
out.status.success(),
"expected exit 0, got: {}; stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("Usage") || stdout.contains("usage") || stdout.contains("ilo"),
"expected usage info in stdout, got: {stdout}"
);
}
#[test]
fn cli_help_lang_exits_zero_with_spec_content() {
let out = std::process::Command::new(ilo_bin())
.args(["help", "lang"])
.output()
.expect("failed to run ilo help lang");
assert!(
out.status.success(),
"expected exit 0, got: {}; stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.trim().is_empty(),
"expected spec content on stdout, got empty"
);
}
#[test]
fn cli_help_ai_exits_zero_with_compact_spec() {
let out = std::process::Command::new(ilo_bin())
.args(["help", "ai"])
.output()
.expect("failed to run ilo help ai");
assert!(
out.status.success(),
"expected exit 0, got: {}; stderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
!stdout.trim().is_empty(),
"expected compact spec on stdout, got empty"
);
}
#[test]
fn cli_empty_code_string_exits_nonzero() {
let out = std::process::Command::new(ilo_bin())
.arg("")
.output()
.expect("failed to run ilo with empty arg");
assert!(
!out.status.success(),
"expected non-zero exit for empty code string"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.trim().is_empty(),
"expected some error on stderr, got empty"
);
}
#[test]
fn cli_emit_unknown_target_exits_nonzero() {
let out = std::process::Command::new(ilo_bin())
.args(["f>n;1", "--emit", "rust"])
.output()
.expect("failed to run ilo --emit rust");
assert!(
!out.status.success(),
"expected non-zero exit for unknown emit target"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("Unknown emit")
|| stderr.contains("Supported")
|| stderr.contains("python"),
"expected unknown-emit error in stderr, got: {stderr}"
);
}
#[test]
fn cli_tools_and_mcp_mutually_exclusive() {
let out = std::process::Command::new(ilo_bin())
.args(["f>n;1", "--tools", "/tmp/x.json", "--mcp", "/tmp/y.json"])
.output()
.expect("failed to run ilo with --tools and --mcp");
assert!(
!out.status.success(),
"expected non-zero exit when both --tools and --mcp are provided"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("mutually exclusive") || stderr.contains("exclusive"),
"expected 'mutually exclusive' in stderr, got: {stderr}"
);
}
#[test]
fn cli_tools_cmd_no_flags_exits_nonzero() {
let out = std::process::Command::new(ilo_bin())
.arg("tools")
.output()
.expect("failed to run ilo tools");
assert!(
!out.status.success(),
"expected non-zero exit for `ilo tools` with no flags"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--mcp") || stderr.contains("--tools") || stderr.contains("requires"),
"expected usage hint in stderr, got: {stderr}"
);
}
#[test]
fn cli_tools_cmd_mcp_no_path_exits_nonzero() {
let out = std::process::Command::new(ilo_bin())
.args(["tools", "--mcp"])
.output()
.expect("failed to run ilo tools --mcp");
assert!(
!out.status.success(),
"expected non-zero exit for `ilo tools --mcp` with no path"
);
}
#[test]
fn cli_serv_unknown_flag_exits_nonzero() {
let out = std::process::Command::new(ilo_bin())
.args(["serv", "--invalid-flag"])
.output()
.expect("failed to run ilo serv --invalid-flag");
assert!(
!out.status.success(),
"expected non-zero exit for unknown flag to `ilo serv`"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.trim().is_empty(),
"expected error on stderr, got empty"
);
}
fn write_temp_tools_config(name: &str, tools_json: &str) -> String {
let path = format!("/tmp/ilo_test_{name}.json");
std::fs::write(&path, tools_json).expect("write tools config");
path
}
#[test]
fn cli_tools_cmd_with_http_config_human_output() {
let config = r#"{"tools":{"greet":{"url":"http://localhost:9"},"ping":{"url":"http://localhost:9"}}}"#;
let path = write_temp_tools_config("human_A1", config);
let out = std::process::Command::new(ilo_bin())
.args(["tools", "--tools", &path])
.output()
.expect("failed to run ilo tools --tools");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("greet") || stdout.contains("ping"),
"expected tool names in stdout, got: {stdout}"
);
}
#[test]
fn cli_tools_cmd_with_http_config_ilo_output() {
let config = r#"{"tools":{"calc":{"url":"http://localhost:9"}}}"#;
let path = write_temp_tools_config("ilo_B2", config);
let out = std::process::Command::new(ilo_bin())
.args(["tools", "--tools", &path, "--ilo"])
.output()
.expect("failed to run ilo tools --tools --ilo");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("tool") || stdout.contains("calc"),
"expected ilo tool decl in stdout, got: {stdout}"
);
}
#[test]
fn cli_tools_cmd_with_http_config_json_output() {
let config = r#"{"tools":{"lookup":{"url":"http://localhost:9"}}}"#;
let path = write_temp_tools_config("json_C3", config);
let out = std::process::Command::new(ilo_bin())
.args(["tools", "--tools", &path, "--json"])
.output()
.expect("failed to run ilo tools --tools --json");
let stdout = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(stdout.trim())
.unwrap_or_else(|e| panic!("expected JSON envelope, got: {stdout} ({e})"));
assert_eq!(v["schemaVersion"], 1, "envelope must carry schemaVersion");
let tools = v["tools"]
.as_array()
.unwrap_or_else(|| panic!("expected `tools` array, got: {stdout}"));
assert!(
tools.iter().any(|t| t["name"] == "lookup"),
"expected 'lookup' in tools array, got: {stdout}"
);
}
#[test]
fn cli_tools_cmd_with_http_config_full_flag() {
let config = r#"{"tools":{"do_thing":{"url":"http://localhost:9"}}}"#;
let path = write_temp_tools_config("full_D4", config);
let out = std::process::Command::new(ilo_bin())
.args(["tools", "--tools", &path, "--full"])
.output()
.expect("failed to run ilo tools --tools --full");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("do_thing"),
"expected 'do_thing' in output, got: {stdout}"
);
}
#[test]
fn cli_tools_cmd_unknown_flag_exits_nonzero() {
let config = r#"{"tools":{}}"#;
let path = write_temp_tools_config("ukflag_E5", config);
let out = std::process::Command::new(ilo_bin())
.args(["tools", "--tools", &path, "--unknown-flag"])
.output()
.expect("failed to run ilo tools with unknown flag");
assert!(
!out.status.success(),
"expected non-zero exit for unknown flag"
);
}
#[test]
fn cli_tools_cmd_tools_no_path_exits_nonzero() {
let out = std::process::Command::new(ilo_bin())
.args(["tools", "--tools"])
.output()
.expect("failed to run ilo tools --tools");
assert!(
!out.status.success(),
"expected non-zero exit for --tools with no path"
);
}
#[test]
fn print_tool_graph_no_tools_prints_no_typed_tools() {
print_tool_graph(&[]);
}
#[test]
fn print_tool_graph_with_typed_tools_no_panic() {
use ast::{Decl, Param, Type};
let decls = vec![
Decl::Tool {
name: "alpha".into(),
description: "first tool".into(),
params: vec![Param {
name: "x".into(),
ty: Type::Number,
}],
return_type: Type::Result(Box::new(Type::Text), Box::new(Type::Text)),
timeout: None,
retry: None,
span: ast::Span::UNKNOWN,
},
Decl::Tool {
name: "beta".into(),
description: "second tool".into(),
params: vec![Param {
name: "s".into(),
ty: Type::Text,
}],
return_type: Type::Text,
timeout: None,
retry: None,
span: ast::Span::UNKNOWN,
},
];
print_tool_graph(&decls);
}
#[test]
fn tool_sig_str_no_params_result_type() {
use ast::{Param, Type};
let params: Vec<Param> = vec![];
let ret = Type::Result(Box::new(Type::Number), Box::new(Type::Text));
let sig = tool_sig_str(¶ms, &ret);
assert!(
sig.starts_with('>'),
"expected '>' prefix for no-param sig, got: {sig}"
);
assert!(sig.contains("R"), "expected result type in sig, got: {sig}");
}
#[test]
fn verify_warnings_emitted_via_subprocess() {
let out = std::process::Command::new(ilo_bin())
.args(["f>n;x=1;2"])
.output()
.expect("failed to run ilo");
let _ = out;
}
fn make_use_decl(path: &str) -> ast::Decl {
ast::Decl::Use {
path: path.to_string(),
only: None,
alias: None,
predicate: None,
alt_path: None,
reexport: false,
lazy: false,
span: ast::Span::UNKNOWN,
}
}
#[test]
fn resolve_imports_no_base_dir_emits_error() {
let decls = vec![make_use_decl("math.ilo")];
let mut visited = std::collections::HashSet::new();
let mut diagnostics = Vec::new();
let result = resolve_imports(
decls,
None,
&mut visited,
&mut diagnostics,
BuildTarget::default(),
);
assert!(result.is_empty(), "should return no decls");
assert!(!diagnostics.is_empty(), "should emit error");
assert!(diagnostics[0].message.contains("file path context"));
}
#[test]
fn resolve_imports_file_not_found_emits_error() {
let decls = vec![make_use_decl("nonexistent_file_xyz.ilo")];
let mut visited = std::collections::HashSet::new();
let mut diagnostics = Vec::new();
let dir = std::path::Path::new("/tmp");
let result = resolve_imports(
decls,
Some(dir),
&mut visited,
&mut diagnostics,
BuildTarget::default(),
);
assert!(result.is_empty());
assert!(!diagnostics.is_empty());
assert!(diagnostics[0].message.contains("nonexistent_file_xyz.ilo"));
}
#[test]
fn resolve_imports_circular_emits_error() {
let path = "/tmp/ilo_circ_test.ilo";
std::fs::write(path, "f>n;1").unwrap();
let canonical = std::fs::canonicalize(path).unwrap();
let decls = vec![make_use_decl("ilo_circ_test.ilo")];
let mut visited = std::collections::HashSet::new();
visited.insert(canonical);
let mut diagnostics = Vec::new();
let dir = std::path::Path::new("/tmp");
let result = resolve_imports(
decls,
Some(dir),
&mut visited,
&mut diagnostics,
BuildTarget::default(),
);
assert!(result.is_empty());
assert!(!diagnostics.is_empty());
assert!(diagnostics[0].message.contains("circular"));
std::fs::remove_file(path).ok();
}
#[test]
fn resolve_imports_lex_error_in_imported_file() {
let path = "/tmp/ilo_lex_err_test.ilo";
std::fs::write(path, "MyFunc invalid_UpperCase").unwrap();
let decls = vec![make_use_decl("ilo_lex_err_test.ilo")];
let mut visited = std::collections::HashSet::new();
let mut diagnostics = Vec::new();
let dir = std::path::Path::new("/tmp");
let _result = resolve_imports(
decls,
Some(dir),
&mut visited,
&mut diagnostics,
BuildTarget::default(),
);
assert!(!diagnostics.is_empty(), "should emit lex error diagnostic");
std::fs::remove_file(path).ok();
}
#[test]
fn resolve_imports_read_error_after_canonicalize() {
let path = "/tmp/ilo_read_err_test.ilo";
std::fs::write(path, "f>n;1").unwrap();
std::fs::remove_file(path).ok();
let decls = vec![make_use_decl("ilo_read_err_test.ilo")];
let mut visited = std::collections::HashSet::new();
let mut diagnostics = Vec::new();
let dir = std::path::Path::new("/tmp");
resolve_imports(
decls,
Some(dir),
&mut visited,
&mut diagnostics,
BuildTarget::default(),
);
assert!(!diagnostics.is_empty());
}
#[test]
fn warn_cross_language_syntax_detects_and_or() {
warn_cross_language_syntax("f>b;x&&y", OutputMode::Text);
warn_cross_language_syntax("f>b;x||y", OutputMode::Text);
}
#[test]
fn warn_cross_language_syntax_no_match_is_silent() {
warn_cross_language_syntax("f x:n>n;+x 1", OutputMode::Text);
}
#[test]
fn report_diagnostic_ansi_mode() {
let d = Diagnostic::error("test error".to_string());
report_diagnostic(&d, OutputMode::Ansi);
}
#[test]
fn report_diagnostic_text_mode() {
let d = Diagnostic::error("test error".to_string());
report_diagnostic(&d, OutputMode::Text);
}
#[test]
fn report_diagnostic_json_mode() {
let d = Diagnostic::error("test error".to_string());
report_diagnostic(&d, OutputMode::Json);
}
fn write_tools_config_unit(name: &str) -> String {
let path = format!("/tmp/ilo_unit_tools_{name}.json");
std::fs::write(&path,
r#"{"tools":{"search":{"url":"http://localhost:9"},"fetch":{"url":"http://localhost:9"}}}"#
).unwrap();
path
}
#[test]
fn tools_cmd_human_flag_renders_no_panic() {
let path = write_tools_config_unit("human_unit");
tools_cmd(&["--tools".to_string(), path.clone(), "--human".to_string()]);
std::fs::remove_file(&path).ok();
}
#[test]
fn tools_cmd_ilo_flag_renders_no_panic() {
let path = write_tools_config_unit("ilo_unit");
tools_cmd(&["--tools".to_string(), path.clone(), "--ilo".to_string()]);
std::fs::remove_file(&path).ok();
}
#[test]
fn tools_cmd_json_flag_renders_no_panic() {
let path = write_tools_config_unit("json_unit");
tools_cmd(&["--tools".to_string(), path.clone(), "--json".to_string()]);
std::fs::remove_file(&path).ok();
}
#[test]
fn tools_cmd_full_flag_human_shows_http_label() {
let path = write_tools_config_unit("full_unit");
tools_cmd(&["--tools".to_string(), path.clone(), "--full".to_string()]);
std::fs::remove_file(&path).ok();
}
#[test]
fn tools_cmd_graph_flag_no_panic() {
let path = write_tools_config_unit("graph_unit");
tools_cmd(&["--tools".to_string(), path.clone(), "--graph".to_string()]);
std::fs::remove_file(&path).ok();
}
#[test]
fn print_tool_graph_with_function_decl_skipped() {
use ast::{Decl, Param, Span, Type};
let decls = vec![
Decl::Function {
type_params: vec![],
name: "helper".into(),
params: vec![Param {
name: "x".into(),
ty: Type::Number,
}],
return_type: Type::Number,
effect_set: None,
body: vec![],
span: Span::UNKNOWN,
},
Decl::Tool {
name: "alpha".into(),
description: "a tool".into(),
params: vec![Param {
name: "x".into(),
ty: Type::Text,
}],
return_type: Type::Result(Box::new(Type::Text), Box::new(Type::Text)),
timeout: None,
retry: None,
span: Span::UNKNOWN,
},
];
print_tool_graph(&decls);
}
#[test]
fn tool_sig_str_with_params() {
let params = vec![
ast::Param {
name: "url".into(),
ty: ast::Type::Text,
},
ast::Param {
name: "limit".into(),
ty: ast::Type::Number,
},
];
let ret = ast::Type::Result(Box::new(ast::Type::Text), Box::new(ast::Type::Text));
let sig = tool_sig_str(¶ms, &ret);
assert!(sig.contains("url"), "expected url param in sig: {sig}");
assert!(sig.contains("limit"), "expected limit param in sig: {sig}");
}
#[test]
fn print_tool_graph_long_sig_truncates_no_panic() {
use ast::{Decl, Param, Type};
let decls = vec![Decl::Tool {
name: "search".into(),
description: "search tool".into(),
params: vec![
Param {
name: "url".into(),
ty: Type::Text,
},
Param {
name: "query".into(),
ty: Type::Text,
},
Param {
name: "page".into(),
ty: Type::Number,
},
Param {
name: "limit".into(),
ty: Type::Number,
},
Param {
name: "size".into(),
ty: Type::Number,
},
],
return_type: Type::Result(Box::new(Type::Text), Box::new(Type::Text)),
timeout: None,
retry: None,
span: ast::Span::UNKNOWN,
}];
print_tool_graph(&decls);
}
#[test]
fn resolve_imports_directory_triggers_read_error() {
let dir_name = "ilo_test_dir_import_Z9.ilo";
let dir_path = format!("/tmp/{dir_name}");
std::fs::create_dir_all(&dir_path).unwrap();
let decls = vec![make_use_decl(dir_name)];
let mut visited = std::collections::HashSet::new();
let mut diagnostics = Vec::new();
let result = resolve_imports(
decls,
Some(std::path::Path::new("/tmp")),
&mut visited,
&mut diagnostics,
BuildTarget::default(),
);
assert!(result.is_empty());
assert!(
!diagnostics.is_empty(),
"should emit error for directory import"
);
std::fs::remove_dir(&dir_path).ok();
}
#[test]
fn type_to_ilo_number() {
assert_eq!(type_to_ilo(&ast::Type::Number), "n");
}
#[test]
fn type_to_ilo_text() {
assert_eq!(type_to_ilo(&ast::Type::Text), "t");
}
#[test]
fn type_to_ilo_bool() {
assert_eq!(type_to_ilo(&ast::Type::Bool), "b");
}
#[test]
fn type_to_ilo_nil() {
assert_eq!(type_to_ilo(&ast::Type::Any), "_");
}
#[test]
fn type_to_ilo_optional() {
assert_eq!(
type_to_ilo(&ast::Type::Optional(Box::new(ast::Type::Number))),
"O n"
);
}
#[test]
fn type_to_ilo_list() {
assert_eq!(
type_to_ilo(&ast::Type::List(Box::new(ast::Type::Text))),
"L t"
);
}
#[test]
fn type_to_ilo_map() {
assert_eq!(
type_to_ilo(&ast::Type::Map(
Box::new(ast::Type::Text),
Box::new(ast::Type::Number),
)),
"M t n"
);
}
#[test]
fn type_to_ilo_result() {
assert_eq!(
type_to_ilo(&ast::Type::Result(
Box::new(ast::Type::Text),
Box::new(ast::Type::Number),
)),
"R t n"
);
}
#[test]
fn type_to_ilo_sum() {
assert_eq!(
type_to_ilo(&ast::Type::Sum(vec!["ok".into(), "err".into()])),
"S ok err"
);
}
#[test]
fn type_to_ilo_fn() {
assert_eq!(
type_to_ilo(&ast::Type::Fn(
vec![ast::Type::Number, ast::Type::Text],
Box::new(ast::Type::Bool),
)),
"F n t b"
);
}
#[test]
fn type_to_ilo_named() {
assert_eq!(type_to_ilo(&ast::Type::Named("point".into())), "point");
}
#[test]
fn type_to_ilo_nested_optional_list() {
assert_eq!(
type_to_ilo(&ast::Type::Optional(Box::new(ast::Type::List(Box::new(
ast::Type::Number
))))),
"O L n"
);
}
#[test]
fn brace_depth_empty() {
assert_eq!(brace_depth(""), 0);
}
#[test]
fn brace_depth_balanced() {
assert_eq!(brace_depth("{a;b}"), 0);
}
#[test]
fn brace_depth_unclosed() {
assert_eq!(brace_depth("{a;b"), 1);
}
#[test]
fn brace_depth_nested_unclosed() {
assert_eq!(brace_depth("{{a"), 2);
}
#[test]
fn brace_depth_extra_close() {
assert_eq!(brace_depth("}"), -1);
}
#[test]
fn brace_depth_ignores_string() {
assert_eq!(brace_depth("\"{}\""), 0);
}
#[test]
fn brace_depth_ignores_comment() {
assert_eq!(brace_depth("x -- {unclosed"), 0);
}
#[test]
fn brace_depth_mixed_string_and_real() {
assert_eq!(brace_depth("{\"}\"}"), 0);
}
#[test]
fn brace_depth_no_braces() {
assert_eq!(brace_depth("hello world"), 0);
}
#[test]
fn brace_depth_escaped_quote_in_string() {
assert_eq!(brace_depth(r#""x\"}{\"y""#), 0);
}
#[test]
fn brace_depth_escaped_backslash_before_quote() {
assert_eq!(brace_depth(r#""a\\"{""#), 1);
}
#[test]
fn brace_depth_triple_backslash_before_quote() {
assert_eq!(brace_depth(r#""a\\\"{""#), 0);
}
#[test]
fn load_env_file_line_without_equals_skipped() {
use std::io::Write;
let path = "/tmp/ilo_test_env_noeq_X7.env";
let key = "ILO_TEST_ENV_NOEQ_X7";
unsafe { std::env::remove_var(key) };
let mut f = std::fs::File::create(path).unwrap();
writeln!(f, "# comment").unwrap(); writeln!(f, "no_equals_here").unwrap(); writeln!(f, "{key}=set_value").unwrap(); drop(f);
load_env_file(path);
assert_eq!(std::env::var(key).unwrap(), "set_value");
unsafe { std::env::remove_var(key) };
std::fs::remove_file(path).ok();
}
#[test]
fn dispatch_cli_none_bare_has_bin_false_prepends_ilo() {
let cli = cli::Cli {
cmd: None,
global: cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
},
args: vec!["f>n;1".to_string()],
};
let code = dispatch_cli(cli, false);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_ai_flag_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "-ai".to_string()], &global);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_help_lang_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec!["ilo".to_string(), "help".to_string(), "lang".to_string()],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_help_ai_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec!["ilo".to_string(), "help".to_string(), "ai".to_string()],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_dash_h_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "-h".to_string()], &global);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_explain_valid_code_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"--explain".to_string(),
"ILO-T001".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_explain_unknown_code_exits_one() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"--explain".to_string(),
"NOT-A-CODE".to_string(),
],
&global,
);
assert_eq!(code, 1);
}
#[test]
fn dispatch_bare_args_explain_no_code_arg_exits_one() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "--explain".to_string()], &global);
assert_eq!(code, 1);
}
#[test]
fn dispatch_bare_args_version_long_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "--version".to_string()], &global);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_version_short_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "-V".to_string()], &global);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_version_lowercase_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "-v".to_string()], &global);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_expanded_flag_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--expanded".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_fmt_expanded_alias_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--fmt-expanded".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_minus_e_code_shorthand() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec!["ilo".to_string(), "-e".to_string(), "f>n;42".to_string()],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_minus_e_empty_code_exits_one() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec!["ilo".to_string(), "-e".to_string(), "".to_string()],
&global,
);
assert_eq!(code, 1);
}
#[test]
fn dispatch_bare_args_bench_flag_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--bench".to_string(),
"f".to_string(),
"3".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_emit_python_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--emit".to_string(),
"python".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_emit_unknown_target_exits_one() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--emit".to_string(),
"rust".to_string(),
],
&global,
);
assert_eq!(code, 1);
}
#[test]
fn dispatch_bare_args_emit_no_target_runs_default() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f>n;42".to_string(),
"--emit".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_dense_flag_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--dense".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_fmt_alias_exits_zero() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--fmt".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_explain_x_flag_at_m() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--explain".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_explain_x_short_flag() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"-x".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_run_vm_engine_flag() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--run-vm".to_string(),
"f".to_string(),
"4".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_run_alias_now_rejected() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--run".to_string(),
"f".to_string(),
"5".to_string(),
],
&global,
);
assert_eq!(code, 1, "expected exit 1 for removed --run flag");
}
#[test]
fn dispatch_bare_args_run_tree_long_flag_now_rejected() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"f x:n>n;*x 2".to_string(),
"--run-tree".to_string(),
"f".to_string(),
"5".to_string(),
],
&global,
);
assert_eq!(code, 1, "expected exit 1 for removed --run-tree flag");
}
#[test]
fn dispatch_bare_args_global_ansi_overrides_mode() {
let global = cli::Global {
ansi: true,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "f>n;42".to_string()], &global);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_global_text_overrides_mode() {
let global = cli::Global {
ansi: false,
text: true,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "f>n;42".to_string()], &global);
assert_eq!(code, 0);
}
#[test]
fn dispatch_bare_args_global_json_overrides_mode() {
let global = cli::Global {
ansi: false,
text: false,
json: true,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(vec!["ilo".to_string(), "f>n;42".to_string()], &global);
assert_eq!(code, 0);
}
#[test]
fn emit_hints_ansi_mode_no_panic() {
emit_hints(&["hint: `==` → `=`".to_string()], OutputMode::Ansi);
}
#[test]
fn emit_hints_text_mode_no_panic() {
emit_hints(&["hint: `==` → `=`".to_string()], OutputMode::Text);
}
#[test]
fn emit_hints_json_mode_outputs_json_array() {
emit_hints(&["hint: `==` → `=`".to_string()], OutputMode::Json);
}
#[test]
fn emit_hints_empty_slice_is_noop() {
emit_hints(&[], OutputMode::Ansi);
emit_hints(&[], OutputMode::Text);
emit_hints(&[], OutputMode::Json);
}
#[test]
fn collect_hints_alias_word_produces_hint() {
let hints = collect_hints("f xs:L n>n;length xs");
let _ = hints;
}
#[test]
fn collect_hints_no_hints_when_clean() {
let hints = collect_hints("f x:n>n;+x 1");
assert!(hints.is_empty(), "clean code should have no hints");
}
#[test]
fn collect_hints_alias_in_comment_does_not_fire() {
let hints = collect_hints("-- reading /tmp/ilo-streaming-tail-rerun/app.log\nf>n;42");
assert!(
hints.is_empty(),
"alias word in comment should not fire hint, got: {:?}",
hints
);
}
#[test]
fn collect_hints_other_alias_words_in_comments_do_not_fire() {
for word in ["tail", "filter", "flatmap", "length", "head", "append"] {
let src = format!("-- builtin: {word} something\nf>n;42");
let hints = collect_hints(&src);
assert!(
hints.is_empty(),
"comment-only `{}` should not fire hint, got: {:?}",
word,
hints
);
}
}
#[test]
fn collect_hints_alias_in_string_does_not_fire() {
let hints = collect_hints(r#"f>t;"tail of the file""#);
assert!(
hints.is_empty(),
"alias word in string literal should not fire hint, got: {:?}",
hints
);
}
#[test]
fn collect_hints_real_alias_use_still_fires() {
let hints = collect_hints("f xs:L n>L n;tail xs");
assert!(
hints
.iter()
.any(|h| h.contains("`tail`") && h.contains("`tl`")),
"expected tail→tl hint on real alias use, got: {:?}",
hints
);
}
#[test]
fn collect_hints_double_equals_inside_comment_no_hint() {
let hints = collect_hints("-- compare a == b here\nf x:n y:n>b;=x y");
assert!(
hints.is_empty(),
"`==` inside comment should not fire hint, got: {:?}",
hints
);
}
fn has_prefix_trap_hint(hints: &[String]) -> bool {
hints
.iter()
.any(|h| h.contains("inner prefix op binds first"))
}
#[test]
fn collect_hints_mul_div_prefix_pair_fires() {
let hints = collect_hints("f a:n b:n c:n>n;*/a b c");
assert!(
has_prefix_trap_hint(&hints),
"expected prefix-trap hint on `*/a b c`, got: {:?}",
hints
);
let hint = hints
.iter()
.find(|h| h.contains("inner prefix op binds first"))
.unwrap();
assert!(
hint.contains("(a/b)*c"),
"hint should state actual parse, got: {hint}"
);
}
#[test]
fn collect_hints_div_mul_prefix_pair_fires() {
let hints = collect_hints("f a:n b:n c:n>n;/*a b c");
assert!(
has_prefix_trap_hint(&hints),
"expected prefix-trap hint on `/*a b c`, got: {:?}",
hints
);
}
#[test]
fn collect_hints_plus_minus_prefix_pair_fires() {
let hints = collect_hints("f a:n b:n c:n>n;+-a b c");
assert!(
has_prefix_trap_hint(&hints),
"expected prefix-trap hint on `+-a b c`, got: {:?}",
hints
);
}
#[test]
fn collect_hints_minus_plus_prefix_pair_fires() {
let hints = collect_hints("f a:n b:n c:n>n;-+a b c");
assert!(
has_prefix_trap_hint(&hints),
"expected prefix-trap hint on `-+a b c`, got: {:?}",
hints
);
}
#[test]
fn collect_hints_same_op_repeat_does_not_fire() {
let hints = collect_hints("f a:n b:n c:n>n;++a b c");
assert!(
!has_prefix_trap_hint(&hints),
"same-op repeat should not fire prefix-trap hint, got: {:?}",
hints
);
let hints = collect_hints("f a:n b:n c:n>n;**a b c");
assert!(!has_prefix_trap_hint(&hints));
}
#[test]
fn collect_hints_different_precedence_pair_does_not_fire() {
let hints = collect_hints("f a:n b:n c:n>n;+*a b c");
assert!(
!has_prefix_trap_hint(&hints),
"different-precedence pair should not fire prefix-trap hint, got: {:?}",
hints
);
let hints = collect_hints("f a:n b:n c:n>n;*+a b c");
assert!(!has_prefix_trap_hint(&hints));
}
#[test]
fn collect_hints_infix_arith_does_not_fire() {
let hints = collect_hints("f a:n b:n c:n>n;a*b/c");
assert!(
!has_prefix_trap_hint(&hints),
"infix `a*b/c` should not fire prefix-trap hint, got: {:?}",
hints
);
}
#[test]
fn collect_hints_prefix_op_after_assign_fires() {
let hints = collect_hints("f a:n b:n c:n>n;r=*/a b c;r");
assert!(
has_prefix_trap_hint(&hints),
"prefix-trap after `=` should fire, got: {:?}",
hints
);
}
#[test]
fn collect_hints_op_pair_after_value_does_not_fire() {
use lexer::Token::*;
let tokens = vec![
(Ident("x".to_string()), 0..1),
(Star, 1..2),
(Slash, 2..3),
(Ident("y".to_string()), 3..4),
];
assert!(
detect_prefix_precedence_trap(&tokens).is_none(),
"trap detector must not fire when op pair follows a value"
);
}
#[test]
fn collect_hints_minus_followed_by_number_no_match() {
let hints = collect_hints("f b:n c:n>n;*-5 b c");
assert!(
!has_prefix_trap_hint(&hints),
"negative literal should not be misread as prefix-pair, got: {:?}",
hints
);
}
#[test]
fn collect_hints_minus_with_space_does_fire() {
use lexer::Token::*;
let tokens = vec![
(Star, 0..1),
(Minus, 1..2),
(Number(5.0), 3..4),
(Ident("b".to_string()), 5..6),
(Ident("c".to_string()), 7..8),
];
assert!(detect_prefix_precedence_trap(&tokens).is_none());
}
#[test]
fn collect_hints_paren_grouped_prefix_pair_does_not_fire() {
let hints = collect_hints("f errs:n tot:n>n;(/ *errs 100 tot)");
assert!(
!has_prefix_trap_hint(&hints),
"parenthesised prefix pair `(/ *errs 100 tot)` should not fire prefix-trap hint, got: {:?}",
hints
);
for src in [
"f a:n b:n c:n>n;(*/ a b c)",
"f a:n b:n c:n>n;(/* a b c)",
"f a:n b:n c:n>n;(+- a b c)",
"f a:n b:n c:n>n;(-+ a b c)",
] {
let hints = collect_hints(src);
assert!(
!has_prefix_trap_hint(&hints),
"paren-grouped pair in `{src}` should be silent, got: {:?}",
hints
);
}
}
#[test]
fn collect_hints_nested_parens_prefix_pair_does_not_fire() {
let hints = collect_hints("f a:n b:n c:n>n;((*/ a b c))");
assert!(
!has_prefix_trap_hint(&hints),
"doubly-parenthesised pair should be silent, got: {:?}",
hints
);
}
#[test]
fn collect_hints_bare_prefix_pair_still_fires_after_paren_suppression() {
let hints = collect_hints("f a:n b:n c:n>n;*/a b c");
assert!(
has_prefix_trap_hint(&hints),
"bare `*/a b c` must still fire after paren-suppression added, got: {:?}",
hints
);
}
#[test]
fn collect_hints_paren_grouped_pair_unit_check() {
use lexer::Token::*;
let tokens = vec![
(LParen, 0..1),
(Star, 1..2),
(Slash, 2..3),
(Ident("a".to_string()), 4..5),
(Ident("b".to_string()), 6..7),
(Ident("c".to_string()), 8..9),
(RParen, 9..10),
];
assert!(
detect_prefix_precedence_trap(&tokens).is_none(),
"detector must suppress when immediate predecessor is LParen"
);
}
#[test]
fn collect_hints_bracket_grouped_prefix_pair_does_not_fire() {
let hints = collect_hints("f a:n b:n c:n>n;hd [*/a b c]");
assert!(
!has_prefix_trap_hint(&hints),
"bracket-grouped pair `[*/a b c]` should not fire prefix-trap hint, got: {:?}",
hints
);
for src in [
"f a:n b:n c:n>n;hd [/* a b c]",
"f a:n b:n c:n>n;hd [+- a b c]",
"f a:n b:n c:n>n;hd [-+ a b c]",
] {
let hints = collect_hints(src);
assert!(
!has_prefix_trap_hint(&hints),
"bracket-grouped pair in `{src}` should be silent, got: {:?}",
hints
);
}
}
#[test]
fn collect_hints_paren_inside_brackets_prefix_pair_does_not_fire() {
let hints = collect_hints("f a:n b:n c:n>n;hd [(*/a b c)]");
assert!(
!has_prefix_trap_hint(&hints),
"`[(*/a b c)]` should be silent (paren is the immediate predecessor), got: {:?}",
hints
);
}
#[test]
fn collect_hints_bracket_brace_pair_unit_check() {
use lexer::Token::*;
for opener in [LBracket, LBrace] {
let tokens = vec![
(opener.clone(), 0..1),
(Star, 1..2),
(Slash, 2..3),
(Ident("a".to_string()), 4..5),
(Ident("b".to_string()), 6..7),
(Ident("c".to_string()), 8..9),
];
assert!(
detect_prefix_precedence_trap(&tokens).is_none(),
"detector must suppress when immediate predecessor is {opener:?}"
);
}
}
#[test]
fn tools_cmd_mcp_flag_missing_path_returns_one() {
let code = tools_cmd(&["--mcp".to_string()]);
assert_eq!(code, 1);
}
#[test]
fn tools_cmd_tools_flag_missing_path_returns_one() {
let code = tools_cmd(&["--tools".to_string()]);
assert_eq!(code, 1);
}
#[test]
fn tools_cmd_unknown_flag_returns_one() {
let code = tools_cmd(&["--unknown-xyz".to_string()]);
assert_eq!(code, 1);
}
#[test]
fn tools_cmd_no_args_returns_one() {
let code = tools_cmd(&[]);
assert_eq!(code, 1);
}
#[test]
fn graph_cmd_no_args_returns_one() {
let code = graph_cmd(&[]);
assert_eq!(code, 1);
}
#[test]
fn graph_cmd_fn_flag_missing_name_returns_one() {
let path = "/tmp/ilo_graph_test_fn_missing.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[path.to_string(), "--fn".to_string()]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_budget_flag_missing_number_returns_one() {
let path = "/tmp/ilo_graph_test_budget_missing.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[path.to_string(), "--budget".to_string()]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_budget_invalid_value_returns_one() {
let path = "/tmp/ilo_graph_test_budget_invalid.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[
path.to_string(),
"--budget".to_string(),
"notanumber".to_string(),
]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_unknown_flag_returns_one() {
let path = "/tmp/ilo_graph_test_unknown_flag.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[path.to_string(), "--nonexistent-flag".to_string()]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_file_not_found_returns_one() {
let code = graph_cmd(&["/tmp/ilo_no_such_file_99999.ilo".to_string()]);
assert_eq!(code, 1);
}
#[test]
fn graph_cmd_fn_not_found_returns_one() {
let path = "/tmp/ilo_graph_test_fn_notfound.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[
path.to_string(),
"--fn".to_string(),
"nonexistent_fn".to_string(),
]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_fn_reverse_not_found_returns_one() {
let path = "/tmp/ilo_graph_test_rev_notfound.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[
path.to_string(),
"--fn".to_string(),
"nonexistent_fn".to_string(),
"--reverse".to_string(),
]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_fn_subgraph_not_found_returns_one() {
let path = "/tmp/ilo_graph_test_sub_notfound.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[
path.to_string(),
"--fn".to_string(),
"nonexistent_fn".to_string(),
"--subgraph".to_string(),
]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_fn_budget_not_found_returns_one() {
let path = "/tmp/ilo_graph_test_bud_notfound.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[
path.to_string(),
"--fn".to_string(),
"nonexistent_fn".to_string(),
"--budget".to_string(),
"50".to_string(),
]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn dispatch_run_empty_source_returns_one() {
let run_args = cli::RunArgs {
source: "".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec![],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 1);
}
#[test]
fn dispatch_run_tools_and_mcp_mutually_exclusive() {
let run_args = cli::RunArgs {
source: "f>n;1".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: Some("/tmp/t.json".to_string()),
mcp_path: Some("/tmp/m.json".to_string()),
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec![],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 1);
}
#[test]
fn dispatch_run_no_rest_args_auto_runs_inline() {
let run_args = cli::RunArgs {
source: "f>n;42".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec![],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 0);
}
#[test]
fn dispatch_run_hints_emitted_with_double_equals() {
let run_args = cli::RunArgs {
source: "f x:n>b;==x 1".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec!["f".to_string(), "1".to_string()],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 0);
}
#[test]
fn dispatch_run_hints_suppressed_with_no_hints() {
let run_args = cli::RunArgs {
source: "f x:n>b;==x 1".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec!["f".to_string(), "1".to_string()],
};
let code = dispatch_run(run_args, OutputMode::Text, false, true, false);
assert_eq!(code, 0);
}
#[test]
fn dispatch_run_lex_error_returns_one() {
let run_args = cli::RunArgs {
source: "MyFunc INVALID_UPPER".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec![],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 1);
}
#[test]
fn dispatch_run_verify_error_returns_one() {
let run_args = cli::RunArgs {
source: "f x:n>t;x".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec!["f".to_string(), "1".to_string()],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 1);
}
#[test]
fn dispatch_cli_version_cmd_exits_zero() {
let cli = cli::Cli {
cmd: Some(cli::Cmd::Version),
global: cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
},
args: vec![],
};
let code = dispatch_cli(cli, false);
assert_eq!(code, 0);
}
#[test]
fn dispatch_cli_spec_none_topic_prints_help() {
let cli = cli::Cli {
cmd: Some(cli::Cmd::Spec(cli::args::SpecArgs { topic: None })),
global: cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
},
args: vec![],
};
let code = dispatch_cli(cli, false);
assert_eq!(code, 0);
}
#[test]
fn dispatch_cli_explain_unknown_code_exits_one() {
let cli = cli::Cli {
cmd: Some(cli::Cmd::Explain(cli::args::ExplainArgs {
code: "NOT-A-REAL-CODE".to_string(),
})),
global: cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
},
args: vec![],
};
let code = dispatch_cli(cli, false);
assert_eq!(code, 1);
}
#[test]
fn dispatch_cli_explain_valid_code_exits_zero() {
let cli = cli::Cli {
cmd: Some(cli::Cmd::Explain(cli::args::ExplainArgs {
code: "ILO-T001".to_string(),
})),
global: cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
},
args: vec![],
};
let code = dispatch_cli(cli, false);
assert_eq!(code, 0);
}
#[test]
fn dispatch_cli_tools_cmd_no_source_exits_one() {
let cli = cli::Cli {
cmd: Some(cli::Cmd::Tools(cli::args::ToolsArgs {
mcp_path: None,
tools_path: None,
format: None,
human: false,
ilo: false,
json: false,
full: false,
graph: false,
})),
global: cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
},
args: vec![],
};
let code = dispatch_cli(cli, false);
assert_eq!(code, 1);
}
#[test]
fn graph_cmd_dot_output_exits_zero() {
let path = "/tmp/ilo_graph_dot_test_unit.ilo";
std::fs::write(path, "f x:n>n;+x 1 g x:n>n;f x").unwrap();
let code = graph_cmd(&[path.to_string(), "--dot".to_string()]);
assert_eq!(code, 0);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_fn_success_exits_zero() {
let path = "/tmp/ilo_graph_fn_success.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[path.to_string(), "--fn".to_string(), "f".to_string()]);
assert_eq!(code, 0);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_fn_reverse_success_exits_zero() {
let path = "/tmp/ilo_graph_rev_success.ilo";
std::fs::write(path, "helper x:n>n;*x 2 main x:n>n;helper x").unwrap();
let code = graph_cmd(&[
path.to_string(),
"--fn".to_string(),
"helper".to_string(),
"--reverse".to_string(),
]);
assert_eq!(code, 0);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_fn_subgraph_success_exits_zero() {
let path = "/tmp/ilo_graph_sub_success.ilo";
std::fs::write(path, "helper x:n>n;*x 2 main x:n>n;helper x").unwrap();
let code = graph_cmd(&[
path.to_string(),
"--fn".to_string(),
"main".to_string(),
"--subgraph".to_string(),
]);
assert_eq!(code, 0);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_fn_budget_success_exits_zero() {
let path = "/tmp/ilo_graph_bud_success.ilo";
std::fs::write(path, "f x:n>n;+x 1").unwrap();
let code = graph_cmd(&[
path.to_string(),
"--fn".to_string(),
"f".to_string(),
"--budget".to_string(),
"100".to_string(),
]);
assert_eq!(code, 0);
std::fs::remove_file(path).ok();
}
#[test]
fn graph_cmd_full_json_success_exits_zero() {
let path = "/tmp/ilo_graph_full_json.ilo";
std::fs::write(path, "f x:n>n;+x 1 g x:n>n;f x").unwrap();
let code = graph_cmd(&[path.to_string()]);
assert_eq!(code, 0);
std::fs::remove_file(path).ok();
}
#[test]
fn run_llvm_engine_not_enabled_returns_one() {
let program = make_program("f x:n>n;*x 2");
let code = run_llvm_engine(&program, &[]);
assert_eq!(code, 1);
}
#[test]
fn run_llvm_engine_non_numeric_arg_returns_one() {
let program = make_program("f x:n>n;*x 2");
let code = run_llvm_engine(&program, &["f".to_string(), "notanumber".to_string()]);
assert_eq!(code, 1);
}
#[test]
fn run_cranelift_engine_basic_numeric() {
let program = make_program("f x:n>n;*x 2");
let code = run_cranelift_engine(
&program,
&["f".to_string(), "5".to_string()],
"",
OutputMode::Text,
false,
);
assert!(code == 0 || code == 1);
}
#[cfg(all(feature = "cranelift", debug_assertions))]
static JIT_PANIC_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
#[cfg(all(feature = "cranelift", debug_assertions))]
fn run_cranelift_engine_panic_falls_back_to_vm() {
let _guard = JIT_PANIC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let program = make_program("f x:n>n;*x 2");
vm::jit_cranelift::reset_jit_panic_fallback_count();
vm::jit_cranelift::FORCE_PANIC_FOR_TEST.with(|c| c.set(true));
let code = run_cranelift_engine(
&program,
&["f".to_string(), "5".to_string()],
"",
OutputMode::Text,
false,
);
assert_eq!(code, 0);
assert!(!vm::jit_cranelift::FORCE_PANIC_FOR_TEST.with(|c| c.get()));
assert!(
vm::jit_cranelift::jit_panic_fallback_count() >= 1,
"expected panic-fallback counter to increment on JIT panic"
);
}
#[test]
#[cfg(all(feature = "cranelift", debug_assertions))]
fn run_default_does_not_invoke_jit() {
let _guard = JIT_PANIC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
let program = make_program("f x:n>n;*x 2");
vm::jit_cranelift::reset_jit_panic_fallback_count();
vm::jit_cranelift::FORCE_PANIC_FOR_TEST.with(|c| c.set(true));
let code = run_default(
&program,
Some("f"),
vec![interpreter::Value::Number(5.0)],
"",
OutputMode::Text,
false,
Arc::new(Caps::default()),
);
assert_eq!(code, 0);
assert!(
vm::jit_cranelift::FORCE_PANIC_FOR_TEST.with(|c| c.get()),
"default path must not invoke the JIT; FORCE_PANIC flag should stay armed"
);
assert_eq!(
vm::jit_cranelift::jit_panic_fallback_count(),
0,
"default path must not invoke the JIT; fallback counter should stay at zero"
);
vm::jit_cranelift::FORCE_PANIC_FOR_TEST.with(|c| c.set(false));
}
#[test]
#[cfg(all(feature = "cranelift", debug_assertions))]
fn jit_panic_fallback_counter_increments_per_panic() {
let _guard = JIT_PANIC_TEST_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
vm::jit_cranelift::reset_jit_panic_fallback_count();
assert_eq!(vm::jit_cranelift::jit_panic_fallback_count(), 0);
vm::jit_cranelift::note_jit_panic_fallback("synthetic", "bytecode VM");
assert_eq!(vm::jit_cranelift::jit_panic_fallback_count(), 1);
vm::jit_cranelift::note_jit_panic_fallback("synthetic", "interpreter");
assert_eq!(vm::jit_cranelift::jit_panic_fallback_count(), 2);
vm::jit_cranelift::note_jit_panic_fallback("synthetic", "bytecode VM");
assert_eq!(vm::jit_cranelift::jit_panic_fallback_count(), 3);
}
#[test]
fn run_cranelift_engine_fn_not_found_returns_one() {
let program = make_program("f x:n>n;*x 2");
let code = run_cranelift_engine(
&program,
&["nonexistent".to_string(), "5".to_string()],
"",
OutputMode::Text,
false,
);
assert_eq!(code, 1);
}
#[test]
fn run_vm_with_provider_runtime_error_returns_one() {
let compiled = make_compiled("f>n;/1 0");
let code = run_vm_with_provider(
&compiled,
Some("f"),
vec![],
None,
#[cfg(feature = "tools")]
None,
#[cfg(feature = "tools")]
None,
"f>n;/1 0",
OutputMode::Text,
false,
false,
Arc::new(Caps::default()),
);
assert_eq!(code, 1);
}
#[test]
fn run_interp_with_provider_runtime_error_returns_one() {
let program = make_program("f>n;/1 0");
let code = run_interp_with_provider(
&program,
Some("f"),
vec![],
None,
#[cfg(feature = "tools")]
None,
#[cfg(feature = "tools")]
None,
"f>n;/1 0",
OutputMode::Text,
false,
Arc::new(Caps::default()),
);
assert_eq!(code, 1);
}
#[test]
fn run_default_runtime_error_returns_one() {
let program = make_program("f>n;g 1");
let code = run_default(
&program,
Some("f"),
vec![],
"f>n;g 1",
OutputMode::Text,
false,
Arc::new(Caps::default()),
);
assert_eq!(code, 1);
}
#[test]
fn tools_cmd_empty_tools_config_exits_zero_for_human_mode() {
let path = "/tmp/ilo_tools_empty_human.json";
std::fs::write(path, r#"{"tools":{}}"#).unwrap();
let code = tools_cmd(&[
"--tools".to_string(),
path.to_string(),
"--human".to_string(),
]);
assert_eq!(code, 0);
std::fs::remove_file(path).ok();
}
#[test]
fn tools_cmd_bad_http_config_returns_one() {
let path = "/tmp/ilo_tools_bad_config.json";
std::fs::write(path, "not valid json").unwrap();
let code = tools_cmd(&["--tools".to_string(), path.to_string()]);
assert_eq!(code, 1);
std::fs::remove_file(path).ok();
}
#[test]
fn spec_json_ai_builtins_array_has_stability_fields() {
let output = std::process::Command::new(
std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.join("ilo"),
)
.args(["spec", "--json", "ai"])
.output();
let output = match output {
Ok(o) => o,
Err(_) => return,
};
assert!(output.status.success());
let v: serde_json::Value =
serde_json::from_slice(&output.stdout).expect("spec --json ai must be valid JSON");
let builtins = v["builtins"].as_array().expect("builtins must be an array");
assert!(
!builtins.is_empty(),
"builtins array must contain at least one entry"
);
for b in builtins {
let name = b["name"].as_str().expect("each builtin must have a name");
let stability = b["stability"]
.as_str()
.expect("each builtin must have a stability");
assert!(
stability == "provisional" || stability == "experimental",
"builtin {name} has unknown stability tier '{stability}'"
);
}
assert!(
v["stability"]["doc"].as_str().is_some(),
"top-level stability.doc must still be present for backward compat"
);
}
#[test]
fn dispatch_bare_args_func_name_in_rest_routes_correctly() {
let global = cli::Global {
ansi: false,
text: false,
json: false,
no_hints: false,
silent: false,
max_ast_depth: None,
max_runtime: None,
max_output_bytes: None,
};
let code = dispatch_bare_args(
vec![
"ilo".to_string(),
"double x:n>n;*x 2".to_string(),
"double".to_string(),
"5".to_string(),
],
&global,
);
assert_eq!(code, 0);
}
#[test]
fn allow_env_permissive_mode_reads_path() {
let run_args = cli::RunArgs {
source: "f k:t>R t t;env k".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: None,
rest: vec!["f".to_string(), "PATH".to_string()],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 0);
}
#[test]
fn allow_env_empty_list_blocks_reads() {
let run_args = cli::RunArgs {
source: "f k:t>R t t;env k".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: Some(String::new()),
rest: vec!["f".to_string(), "PATH".to_string()],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 1);
}
#[test]
fn allow_env_specific_var_allowed() {
let run_args = cli::RunArgs {
source: "f k:t>R t t;env k".to_string(),
engine: cli::Engine::Default,
run_tree: false,
run: false,
run_vm: false,
jit: false,
run_llvm: false,
bench: false,
emit: None,
explain: false,
dense: false,
expanded: false,
ast: false,
tools_path: None,
mcp_path: None,
allow_net: None,
allow_read: None,
allow_write: None,
allow_run: None,
allow_env: Some("PATH".to_string()),
rest: vec!["f".to_string(), "PATH".to_string()],
};
let code = dispatch_run(run_args, OutputMode::Text, false, false, false);
assert_eq!(code, 0);
}
#[test]
fn httpd_chunked_response_writes_chunked_encoding() {
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
let src = r#"
body-chunks>L t
["hello" " " "world"]
type rsp{status:n;body:_}
handler req:_>rsp
rsp status:200 body:body-chunks
"#;
let program = Arc::new(make_program(src));
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let prog_clone = Arc::clone(&program);
let jh = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
handle_http_connection(conn, &prog_clone, "handler").unwrap();
});
let mut client = TcpStream::connect(addr).unwrap();
client
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
.unwrap();
client.shutdown(std::net::Shutdown::Write).unwrap();
let mut response = String::new();
client.read_to_string(&mut response).unwrap();
jh.join().unwrap();
assert!(
response.contains("Transfer-Encoding: chunked"),
"expected chunked header, got:\n{}",
response
);
assert!(
response.contains("hello"),
"expected 'hello' in body:\n{}",
response
);
assert!(
response.contains("world"),
"expected 'world' in body:\n{}",
response
);
assert!(
response.ends_with("0\r\n\r\n"),
"expected terminating chunk:\n{}",
response
);
assert!(
!response.contains("Content-Length"),
"Content-Length must be absent in chunked response:\n{}",
response
);
}
#[test]
fn httpd_plain_response_uses_content_length() {
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
let src = r#"
type rsp{status:n;body:t}
handler req:_>rsp
rsp status:200 body:"hello plain"
"#;
let program = Arc::new(make_program(src));
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let prog_clone = Arc::clone(&program);
let jh = std::thread::spawn(move || {
let (conn, _) = listener.accept().unwrap();
handle_http_connection(conn, &prog_clone, "handler").unwrap();
});
let mut client = TcpStream::connect(addr).unwrap();
client
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")
.unwrap();
client.shutdown(std::net::Shutdown::Write).unwrap();
let mut response = String::new();
client.read_to_string(&mut response).unwrap();
jh.join().unwrap();
assert!(
response.contains("Content-Length: 11"),
"expected Content-Length:\n{}",
response
);
assert!(
!response.contains("Transfer-Encoding"),
"must not have TE:\n{}",
response
);
assert!(
response.contains("hello plain"),
"expected body:\n{}",
response
);
}
}