File.copy(source, dest) copies files via interpreter (std::fs::copy) and C backend (fread/fwrite). Effectful callbacks passed to higher-order functions like List.map/forEach now propagate their effects to the enclosing function's inferred effect set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5684 lines
194 KiB
Rust
5684 lines
194 KiB
Rust
//! Lux — Make the important things visible.
|
|
//!
|
|
//! A functional programming language with first-class effects, schema evolution,
|
|
//! and behavioral types. See `lux philosophy` or docs/PHILOSOPHY.md.
|
|
|
|
mod analysis;
|
|
mod ast;
|
|
mod codegen;
|
|
mod debugger;
|
|
mod diagnostics;
|
|
mod exhaustiveness;
|
|
mod formatter;
|
|
mod interpreter;
|
|
mod lexer;
|
|
mod linter;
|
|
mod lsp;
|
|
mod modules;
|
|
mod package;
|
|
mod parser;
|
|
mod registry;
|
|
mod schema;
|
|
mod symbol_table;
|
|
mod typechecker;
|
|
mod types;
|
|
|
|
use diagnostics::{render, c, bc, colors};
|
|
use interpreter::Interpreter;
|
|
use parser::Parser;
|
|
use rustyline::completion::{Completer, Pair};
|
|
use rustyline::error::ReadlineError;
|
|
use rustyline::highlight::Highlighter;
|
|
use rustyline::hint::Hinter;
|
|
use rustyline::history::DefaultHistory;
|
|
use rustyline::validate::Validator;
|
|
use rustyline::{Config, Editor, Helper};
|
|
use std::borrow::Cow;
|
|
use std::collections::HashSet;
|
|
use typechecker::TypeChecker;
|
|
|
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
const HELP: &str = r#"
|
|
Lux - A functional language with first-class effects
|
|
|
|
Commands:
|
|
:help, :h Show this help
|
|
:quit, :q Exit the REPL
|
|
:type <expr> Show the type of an expression
|
|
:info <name> Show info about a binding
|
|
:doc <name> Show documentation for a function/type
|
|
:browse <mod> List exports of a module (List, String, Option, etc.)
|
|
:env Show user-defined bindings
|
|
:clear Clear the environment
|
|
:load <file> Load and execute a file
|
|
:reload, :r Reload the last loaded file
|
|
:trace on/off Enable/disable effect tracing
|
|
:traces Show recorded effect traces
|
|
:ast <expr> Show the AST of an expression (for debugging)
|
|
|
|
Keyboard:
|
|
Tab Autocomplete
|
|
Ctrl-C Cancel current input
|
|
Ctrl-D Exit
|
|
Up/Down Browse history
|
|
Ctrl-R Search history
|
|
|
|
Effects:
|
|
All code in the REPL runs with Console, File, and other standard effects.
|
|
Use :trace on to see effect invocations during execution.
|
|
|
|
Examples:
|
|
> let x = 42
|
|
> x + 1
|
|
43
|
|
|
|
> fn double(n: Int): Int = n * 2
|
|
> :type double
|
|
double : fn(Int) -> Int
|
|
|
|
> :load myfile.lux
|
|
> :reload
|
|
> double(21)
|
|
42
|
|
|
|
> match Some(5) { Some(x) => x, None => 0 }
|
|
5
|
|
"#;
|
|
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
|
|
if args.len() > 1 {
|
|
match args[1].as_str() {
|
|
"--lsp" => {
|
|
// Run LSP server
|
|
if let Err(e) = lsp::LspServer::run() {
|
|
eprintln!("LSP server error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"--help" | "-h" => {
|
|
print_help();
|
|
}
|
|
"--version" | "-v" => {
|
|
println!("{}", bc(colors::GREEN, &format!("Lux {}", VERSION)));
|
|
}
|
|
"fmt" | "f" => {
|
|
// Format files (auto-discovers if no file specified)
|
|
format_files(&args[2..]);
|
|
}
|
|
"lint" | "l" => {
|
|
// Lint files
|
|
lint_files(&args[2..]);
|
|
}
|
|
"test" | "t" => {
|
|
// Run tests
|
|
run_tests(&args[2..]);
|
|
}
|
|
"watch" => {
|
|
// Watch mode
|
|
if args.len() < 3 {
|
|
eprintln!("Usage: lux watch <file.lux>");
|
|
std::process::exit(1);
|
|
}
|
|
watch_file(&args[2]);
|
|
}
|
|
"init" => {
|
|
// Initialize a new project
|
|
init_project(args.get(2).map(|s| s.as_str()));
|
|
}
|
|
"check" | "k" => {
|
|
// Type check files (auto-discovers if no file specified)
|
|
check_files(&args[2..]);
|
|
}
|
|
"debug" => {
|
|
// Start debugger
|
|
if args.len() < 3 {
|
|
eprintln!("Usage: lux debug <file.lux>");
|
|
std::process::exit(1);
|
|
}
|
|
if let Err(e) = debugger::Debugger::run(&args[2]) {
|
|
eprintln!("Debugger error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"pkg" => {
|
|
// Package manager
|
|
handle_pkg_command(&args[2..]);
|
|
}
|
|
"registry" => {
|
|
// Run package registry server
|
|
let storage = args.iter()
|
|
.position(|a| a == "--storage" || a == "-s")
|
|
.and_then(|i| args.get(i + 1))
|
|
.map(|s| s.as_str())
|
|
.unwrap_or("./lux-registry");
|
|
|
|
let bind = args.iter()
|
|
.position(|a| a == "--bind" || a == "-b")
|
|
.and_then(|i| args.get(i + 1))
|
|
.map(|s| s.as_str())
|
|
.unwrap_or("127.0.0.1:8080");
|
|
|
|
if let Err(e) = registry::run_registry_server(storage, bind) {
|
|
eprintln!("Registry server error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"serve" | "s" => {
|
|
// Static file server (like python -m http.server)
|
|
let port = args.iter()
|
|
.position(|a| a == "--port" || a == "-p")
|
|
.and_then(|i| args.get(i + 1))
|
|
.and_then(|s| s.parse::<u16>().ok())
|
|
.unwrap_or(8080);
|
|
|
|
let port_value_idx = args.iter()
|
|
.position(|a| a == "--port" || a == "-p")
|
|
.map(|i| i + 1);
|
|
let dir = args.iter().enumerate()
|
|
.skip(2)
|
|
.filter(|(i, a)| !a.starts_with('-') && Some(*i) != port_value_idx)
|
|
.map(|(_, a)| a.as_str())
|
|
.next()
|
|
.unwrap_or(".");
|
|
|
|
serve_static_files(dir, port);
|
|
}
|
|
"compile" | "c" => {
|
|
// Compile to native binary or JavaScript
|
|
if args.len() < 3 {
|
|
eprintln!("Usage: lux compile <file.lux> [-o binary]");
|
|
eprintln!(" lux compile <file.lux> --run");
|
|
eprintln!(" lux compile <file.lux> --emit-c [-o file.c]");
|
|
eprintln!(" lux compile <file.lux> --target js [-o file.js]");
|
|
eprintln!(" lux compile <file.lux> --watch");
|
|
std::process::exit(1);
|
|
}
|
|
let run_after = args.iter().any(|a| a == "--run");
|
|
let emit_c = args.iter().any(|a| a == "--emit-c");
|
|
let watch = args.iter().any(|a| a == "--watch");
|
|
let target_js = args.iter()
|
|
.position(|a| a == "--target")
|
|
.and_then(|i| args.get(i + 1))
|
|
.map(|s| s.as_str() == "js")
|
|
.unwrap_or(false);
|
|
let output_path = args.iter()
|
|
.position(|a| a == "-o")
|
|
.and_then(|i| args.get(i + 1))
|
|
.map(|s| s.as_str());
|
|
|
|
if target_js {
|
|
compile_to_js(&args[2], output_path, run_after);
|
|
} else {
|
|
compile_to_c(&args[2], output_path, run_after, emit_c);
|
|
}
|
|
|
|
if watch {
|
|
// Build the args to replay for each recompilation (without --watch)
|
|
let compile_args: Vec<String> = args.iter()
|
|
.skip(1)
|
|
.filter(|a| a.as_str() != "--watch")
|
|
.cloned()
|
|
.collect();
|
|
watch_and_rerun(&args[2], &compile_args);
|
|
}
|
|
}
|
|
"repl" => {
|
|
// Start REPL
|
|
run_repl();
|
|
}
|
|
"doc" => {
|
|
// Generate API documentation
|
|
generate_docs(&args[2..]);
|
|
}
|
|
"philosophy" => {
|
|
print_philosophy();
|
|
}
|
|
cmd => {
|
|
// Check if it looks like a command typo
|
|
if !std::path::Path::new(cmd).exists() && !cmd.starts_with('-') && !cmd.contains('.') && !cmd.contains('/') {
|
|
let known_commands = vec![
|
|
"fmt", "lint", "test", "watch", "init", "check", "debug",
|
|
"pkg", "registry", "serve", "compile", "doc", "repl", "philosophy",
|
|
];
|
|
let suggestions = diagnostics::find_similar_names(cmd, known_commands.into_iter(), 2);
|
|
if !suggestions.is_empty() {
|
|
if let Some(hint) = diagnostics::format_did_you_mean(&suggestions) {
|
|
eprintln!("{} Unknown command '{}'. {}", c(colors::YELLOW, "warning:"), cmd, c(colors::YELLOW, &hint));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
// Run as file
|
|
run_file(cmd);
|
|
}
|
|
}
|
|
} else {
|
|
// No arguments — show help
|
|
print_help();
|
|
}
|
|
}
|
|
|
|
fn print_help() {
|
|
println!("{}", bc(colors::GREEN, &format!("Lux {}", VERSION)));
|
|
println!("{}", c(colors::DIM, "Make the important things visible."));
|
|
println!();
|
|
println!(" {} Effects in types — see what code does", c(colors::DIM, "·"));
|
|
println!(" {} Composition over configuration — no DI frameworks", c(colors::DIM, "·"));
|
|
println!(" {} Safety without ceremony — inference where it helps", c(colors::DIM, "·"));
|
|
println!(" {} One right way — opinionated formatter, integrated tools", c(colors::DIM, "·"));
|
|
println!();
|
|
println!("{}", bc("", "Usage:"));
|
|
println!();
|
|
println!(" {} Show this help", bc(colors::CYAN, "lux"));
|
|
println!(" {} Start the REPL", bc(colors::CYAN, "lux repl"));
|
|
println!(" {} {} Run a file (interpreter)", bc(colors::CYAN, "lux"), c(colors::YELLOW, "<file.lux>"));
|
|
println!(" {} {} {} Compile to native binary", bc(colors::CYAN, "lux"), bc(colors::CYAN, "compile"), c(colors::YELLOW, "<file.lux>"));
|
|
println!(" {} {} {} {} Compile with output name", bc(colors::CYAN, "lux"), bc(colors::CYAN, "compile"), c(colors::YELLOW, "<f>"), c(colors::YELLOW, "-o app"));
|
|
println!(" {} {} {} {} Compile and execute", bc(colors::CYAN, "lux"), bc(colors::CYAN, "compile"), c(colors::YELLOW, "<f>"), c(colors::YELLOW, "--run"));
|
|
println!(" {} {} {} {} Output C code", bc(colors::CYAN, "lux"), bc(colors::CYAN, "compile"), c(colors::YELLOW, "<f>"), c(colors::YELLOW, "--emit-c"));
|
|
println!(" {} {} {} {} Compile to JavaScript", bc(colors::CYAN, "lux"), bc(colors::CYAN, "compile"), c(colors::YELLOW, "<f>"), c(colors::YELLOW, "--target js"));
|
|
println!(" {} {} {} Format files {}",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "fmt"), c(colors::YELLOW, "[file] [--check]"),
|
|
c(colors::DIM, "(alias: f)"));
|
|
println!(" {} {} {} Lint files {}",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "lint"), c(colors::YELLOW, "[file]"),
|
|
c(colors::DIM, "(alias: l)"));
|
|
println!(" {} {} {} Type check files {}",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "check"), c(colors::YELLOW, "[file]"),
|
|
c(colors::DIM, "(alias: k)"));
|
|
println!(" {} {} {} Run tests {}",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "test"), c(colors::YELLOW, "[pattern]"),
|
|
c(colors::DIM, "(alias: t)"));
|
|
println!(" {} {} {} Watch and re-run on changes",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "watch"), c(colors::YELLOW, "<file.lux>"));
|
|
println!(" {} {} {} Start interactive debugger",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "debug"), c(colors::YELLOW, "<file.lux>"));
|
|
println!(" {} {} {} Initialize a new project",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "init"), c(colors::YELLOW, "[name]"));
|
|
println!(" {} {} {} Package manager {}",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "pkg"), c(colors::YELLOW, "<command>"),
|
|
c(colors::DIM, "(install, add, remove, list, update)"));
|
|
println!(" {} {} Start package registry server",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "registry"));
|
|
println!(" {} {} {} Start static file server {}",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "serve"), c(colors::YELLOW, "[dir]"),
|
|
c(colors::DIM, "(alias: s)"));
|
|
println!(" {} {} {} Generate API documentation",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "doc"), c(colors::YELLOW, "[file] [-o dir]"));
|
|
println!(" {} {} Show language philosophy",
|
|
bc(colors::CYAN, "lux"), bc(colors::CYAN, "philosophy"));
|
|
println!(" {} {} Start LSP server",
|
|
bc(colors::CYAN, "lux"), c(colors::YELLOW, "--lsp"));
|
|
println!(" {} {} Show this help",
|
|
bc(colors::CYAN, "lux"), c(colors::YELLOW, "--help"));
|
|
println!(" {} {} Show version",
|
|
bc(colors::CYAN, "lux"), c(colors::YELLOW, "--version"));
|
|
}
|
|
|
|
fn print_philosophy() {
|
|
println!("{}", bc(colors::GREEN, &format!("The Lux Philosophy")));
|
|
println!();
|
|
println!(" {}", bc("", "Make the important things visible."));
|
|
println!();
|
|
println!(" Most languages hide what matters most in production: what code");
|
|
println!(" can do, how data changes over time, and what guarantees functions");
|
|
println!(" provide. Lux makes all three first-class, compiler-checked features.");
|
|
println!();
|
|
println!(" {} {}", bc(colors::CYAN, "1. Explicit over implicit"), c(colors::DIM, "— effects in types, not hidden behind interfaces"));
|
|
println!(" fn processOrder(order: Order): Receipt {} {}", c(colors::YELLOW, "with {Database, Email}"), c(colors::DIM, "// signature IS documentation"));
|
|
println!();
|
|
println!(" {} {}", bc(colors::CYAN, "2. Composition over configuration"), c(colors::DIM, "— no DI frameworks, no monad transformers"));
|
|
println!(" run app() {} {}", c(colors::YELLOW, "with { Database = mock, Http = mock }"), c(colors::DIM, "// swap handlers, not libraries"));
|
|
println!();
|
|
println!(" {} {}", bc(colors::CYAN, "3. Safety without ceremony"), c(colors::DIM, "— type inference where it helps, annotations where they document"));
|
|
println!(" let x = 42 {}", c(colors::DIM, "// inferred"));
|
|
println!(" fn f(x: Int): Int = x * 2 {}", c(colors::DIM, "// annotated: API contract"));
|
|
println!();
|
|
println!(" {} {}", bc(colors::CYAN, "4. Practical over academic"), c(colors::DIM, "— ML semantics in C-family syntax, no monads to learn"));
|
|
println!(" {} {} {}", c(colors::DIM, "fn main(): Unit"), c(colors::YELLOW, "with {Console}"), c(colors::DIM, "= Console.print(\"Hello!\")"));
|
|
println!();
|
|
println!(" {} {}", bc(colors::CYAN, "5. One right way"), c(colors::DIM, "— opinionated formatter, integrated tooling, built-in testing"));
|
|
println!(" lux fmt | lux lint | lux check | lux test | lux compile");
|
|
println!();
|
|
println!(" {} {}", bc(colors::CYAN, "6. Tools are the language"), c(colors::DIM, "— formatter knows the AST, linter knows the types, LSP knows the effects"));
|
|
println!();
|
|
println!(" See {} for the full philosophy with language comparisons.", c(colors::CYAN, "docs/PHILOSOPHY.md"));
|
|
}
|
|
|
|
fn format_files(args: &[String]) {
|
|
use formatter::{format, FormatConfig};
|
|
use std::path::Path;
|
|
use std::time::Instant;
|
|
|
|
let start = Instant::now();
|
|
let check_only = args.iter().any(|a| a == "--check");
|
|
let pattern = args.iter().find(|a| !a.starts_with('-')).map(|s| s.as_str());
|
|
|
|
// Collect files to format
|
|
let mut files_to_format = Vec::new();
|
|
|
|
// If a specific file is given, use it
|
|
if let Some(p) = pattern {
|
|
if Path::new(p).is_file() {
|
|
files_to_format.push(std::path::PathBuf::from(p));
|
|
}
|
|
}
|
|
|
|
// If no specific file found, auto-discover
|
|
if files_to_format.is_empty() {
|
|
// Current directory (non-recursive for src/)
|
|
if Path::new("src").is_dir() {
|
|
collect_lux_files("src", pattern, &mut files_to_format);
|
|
}
|
|
|
|
// Also check current directory for top-level files
|
|
collect_lux_files_nonrecursive(".", pattern, &mut files_to_format);
|
|
|
|
// examples/ subdirectory
|
|
if Path::new("examples").is_dir() {
|
|
collect_lux_files("examples", pattern, &mut files_to_format);
|
|
}
|
|
|
|
// tests/ subdirectory
|
|
if Path::new("tests").is_dir() {
|
|
collect_lux_files("tests", pattern, &mut files_to_format);
|
|
}
|
|
}
|
|
|
|
if files_to_format.is_empty() {
|
|
println!("No .lux files found.");
|
|
println!("{}", c(colors::DIM, "Looking in: ., src/, examples/, tests/"));
|
|
return;
|
|
}
|
|
|
|
// Sort for consistent output
|
|
files_to_format.sort();
|
|
|
|
let config = FormatConfig::default();
|
|
let mut formatted_count = 0;
|
|
let mut unchanged_count = 0;
|
|
let mut error_count = 0;
|
|
let mut would_reformat = Vec::new();
|
|
|
|
for file_path in &files_to_format {
|
|
let path = file_path.to_string_lossy().to_string();
|
|
|
|
let source = match std::fs::read_to_string(file_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, e);
|
|
error_count += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let formatted = match format(&source, &config) {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, e);
|
|
error_count += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
if check_only {
|
|
if source != formatted {
|
|
would_reformat.push(path);
|
|
} else {
|
|
unchanged_count += 1;
|
|
}
|
|
} else if source != formatted {
|
|
if let Err(e) = std::fs::write(file_path, &formatted) {
|
|
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, e);
|
|
error_count += 1;
|
|
continue;
|
|
}
|
|
println!(" {} {}", c(colors::CYAN, "Formatted"), path);
|
|
formatted_count += 1;
|
|
} else {
|
|
unchanged_count += 1;
|
|
}
|
|
}
|
|
|
|
let elapsed = start.elapsed();
|
|
let time_str = c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64()));
|
|
|
|
println!();
|
|
if check_only {
|
|
if !would_reformat.is_empty() {
|
|
println!("Files that would be reformatted:");
|
|
for path in &would_reformat {
|
|
println!(" {}", c(colors::YELLOW, path));
|
|
}
|
|
println!();
|
|
println!("{} {} would be reformatted, {} already formatted {}",
|
|
c(colors::YELLOW, "\u{2717}"), would_reformat.len(), unchanged_count, time_str);
|
|
std::process::exit(1);
|
|
} else {
|
|
println!("{} All {} files are correctly formatted {}",
|
|
c(colors::GREEN, "\u{2713}"), unchanged_count, time_str);
|
|
}
|
|
} else if error_count > 0 {
|
|
let mut parts = Vec::new();
|
|
if formatted_count > 0 { parts.push(format!("{} formatted", formatted_count)); }
|
|
if unchanged_count > 0 { parts.push(format!("{} unchanged", unchanged_count)); }
|
|
parts.push(format!("{} errors", error_count));
|
|
println!("{} {} {}", c(colors::RED, "\u{2717}"), parts.join(", "), time_str);
|
|
std::process::exit(1);
|
|
} else if formatted_count > 0 {
|
|
println!("{} {} formatted, {} unchanged {}",
|
|
c(colors::GREEN, "\u{2713}"), formatted_count, unchanged_count, time_str);
|
|
} else {
|
|
println!("{} All {} files already formatted {}",
|
|
c(colors::GREEN, "\u{2713}"), unchanged_count, time_str);
|
|
}
|
|
}
|
|
|
|
fn lint_files(args: &[String]) {
|
|
use linter::{LintConfig, LintLevel, Linter};
|
|
use std::path::Path;
|
|
use std::time::Instant;
|
|
|
|
let start = Instant::now();
|
|
let explain = args.iter().any(|a| a == "--explain");
|
|
let _fix = args.iter().any(|a| a == "--fix");
|
|
let pattern = args
|
|
.iter()
|
|
.find(|a| !a.starts_with('-'))
|
|
.map(|s| s.as_str());
|
|
|
|
// Collect files to lint
|
|
let mut files_to_lint = Vec::new();
|
|
|
|
if let Some(p) = pattern {
|
|
if Path::new(p).is_file() {
|
|
files_to_lint.push(std::path::PathBuf::from(p));
|
|
}
|
|
}
|
|
|
|
if files_to_lint.is_empty() {
|
|
if Path::new("src").is_dir() {
|
|
collect_lux_files("src", pattern, &mut files_to_lint);
|
|
}
|
|
collect_lux_files_nonrecursive(".", pattern, &mut files_to_lint);
|
|
if Path::new("examples").is_dir() {
|
|
collect_lux_files("examples", pattern, &mut files_to_lint);
|
|
}
|
|
if Path::new("tests").is_dir() {
|
|
collect_lux_files("tests", pattern, &mut files_to_lint);
|
|
}
|
|
}
|
|
|
|
if files_to_lint.is_empty() {
|
|
println!("No .lux files found.");
|
|
println!("{}", c(colors::DIM, "Looking in: ., src/, examples/, tests/"));
|
|
return;
|
|
}
|
|
|
|
files_to_lint.sort();
|
|
|
|
let config = LintConfig::default();
|
|
let mut total_warnings = 0;
|
|
let mut total_errors = 0;
|
|
let mut files_clean = 0;
|
|
|
|
for file_path in &files_to_lint {
|
|
let path = file_path.to_string_lossy().to_string();
|
|
let source = match std::fs::read_to_string(file_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, e);
|
|
total_errors += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let program = match Parser::parse_source(&source) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, c(colors::RED, &format!("parse error: {}", e)));
|
|
total_errors += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let linter = Linter::new(config.clone(), &source);
|
|
let lints = linter.lint(&program);
|
|
|
|
if lints.is_empty() {
|
|
files_clean += 1;
|
|
} else {
|
|
let errors = lints.iter().filter(|l| l.level == LintLevel::Deny).count();
|
|
let warnings = lints
|
|
.iter()
|
|
.filter(|l| l.level == LintLevel::Warn)
|
|
.count();
|
|
total_errors += errors;
|
|
total_warnings += warnings;
|
|
|
|
if explain {
|
|
eprint!("{}", linter::render_lints(&lints, &source, Some(&path)));
|
|
} else {
|
|
// Compact output: one line per lint
|
|
for lint in &lints {
|
|
let level_str = match lint.level {
|
|
LintLevel::Deny => c(colors::RED, "error"),
|
|
LintLevel::Warn => c(colors::YELLOW, "warning"),
|
|
LintLevel::Allow => continue,
|
|
};
|
|
let lint_code = c(
|
|
colors::DIM,
|
|
&format!("[{}/{}]", lint.id.category().category_name(), lint.id.name()),
|
|
);
|
|
if lint.span.start > 0 || lint.span.end > 0 {
|
|
let (line, col) =
|
|
diagnostics::offset_to_line_col(&source, lint.span.start);
|
|
eprintln!(
|
|
" {}{} {}:{}:{}: {}",
|
|
level_str, lint_code, path, line, col, lint.message
|
|
);
|
|
} else {
|
|
eprintln!(" {}{} {}: {}", level_str, lint_code, path, lint.message);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let elapsed = start.elapsed();
|
|
let time_str = c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64()));
|
|
|
|
println!();
|
|
if total_errors > 0 || total_warnings > 0 {
|
|
let mut parts = Vec::new();
|
|
if total_errors > 0 {
|
|
parts.push(c(colors::RED, &format!("{} errors", total_errors)));
|
|
}
|
|
if total_warnings > 0 {
|
|
parts.push(c(
|
|
colors::YELLOW,
|
|
&format!("{} warnings", total_warnings),
|
|
));
|
|
}
|
|
parts.push(format!("{} files clean", files_clean));
|
|
let icon = if total_errors > 0 {
|
|
c(colors::RED, "\u{2717}")
|
|
} else {
|
|
c(colors::YELLOW, "\u{26a0}")
|
|
};
|
|
println!("{} {} {}", icon, parts.join(", "), time_str);
|
|
if total_errors > 0 {
|
|
std::process::exit(1);
|
|
}
|
|
} else {
|
|
println!(
|
|
"{} {} files clean {}",
|
|
c(colors::GREEN, "\u{2713}"),
|
|
files_clean,
|
|
time_str
|
|
);
|
|
}
|
|
}
|
|
|
|
fn check_files(args: &[String]) {
|
|
use linter::{LintConfig, LintLevel, Linter};
|
|
use modules::ModuleLoader;
|
|
use std::path::Path;
|
|
use std::time::Instant;
|
|
|
|
let start = Instant::now();
|
|
let pattern = args.first().map(|s| s.as_str());
|
|
|
|
// Collect files to check
|
|
let mut files_to_check = Vec::new();
|
|
|
|
// If a specific file is given, use it
|
|
if let Some(p) = pattern {
|
|
if Path::new(p).is_file() {
|
|
files_to_check.push(std::path::PathBuf::from(p));
|
|
}
|
|
}
|
|
|
|
// If no specific file found, auto-discover
|
|
if files_to_check.is_empty() {
|
|
// Current directory (non-recursive for src/)
|
|
if Path::new("src").is_dir() {
|
|
collect_lux_files("src", pattern, &mut files_to_check);
|
|
}
|
|
|
|
// Also check current directory for top-level files
|
|
collect_lux_files_nonrecursive(".", pattern, &mut files_to_check);
|
|
|
|
// examples/ subdirectory
|
|
if Path::new("examples").is_dir() {
|
|
collect_lux_files("examples", pattern, &mut files_to_check);
|
|
}
|
|
|
|
// tests/ subdirectory
|
|
if Path::new("tests").is_dir() {
|
|
collect_lux_files("tests", pattern, &mut files_to_check);
|
|
}
|
|
}
|
|
|
|
if files_to_check.is_empty() {
|
|
println!("No .lux files found.");
|
|
println!("{}", c(colors::DIM, "Looking in: ., src/, examples/, tests/"));
|
|
return;
|
|
}
|
|
|
|
// Sort for consistent output
|
|
files_to_check.sort();
|
|
|
|
let mut passed = 0;
|
|
let mut failed = 0;
|
|
let mut total_warnings = 0;
|
|
|
|
let lint_config = LintConfig::default();
|
|
|
|
for file_path in &files_to_check {
|
|
let path = file_path.to_string_lossy().to_string();
|
|
let source = match std::fs::read_to_string(file_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, e);
|
|
failed += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let mut loader = ModuleLoader::new();
|
|
if let Some(parent) = file_path.parent() {
|
|
loader.add_search_path(parent.to_path_buf());
|
|
}
|
|
|
|
let program = match loader.load_source(&source, Some(file_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("{} {}: {}", c(colors::RED, "\u{2717}"), path, e);
|
|
failed += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let mut checker = TypeChecker::new();
|
|
if let Err(errors) = checker.check_program_with_modules(&program, &loader) {
|
|
eprintln!("{} {}", c(colors::RED, "\u{2717}"), path);
|
|
for error in errors {
|
|
let diagnostic = error.to_diagnostic();
|
|
eprint!("{}", render(&diagnostic, &source, Some(&path)));
|
|
}
|
|
failed += 1;
|
|
} else {
|
|
// Type check passed — also run lints
|
|
let linter = Linter::new(lint_config.clone(), &source);
|
|
let lints = linter.lint(&program);
|
|
let warnings = lints.iter().filter(|l| l.level == LintLevel::Warn).count();
|
|
let errors = lints.iter().filter(|l| l.level == LintLevel::Deny).count();
|
|
|
|
if errors > 0 {
|
|
eprintln!("{} {}", c(colors::RED, "\u{2717}"), path);
|
|
for lint in &lints {
|
|
if lint.level == LintLevel::Deny {
|
|
let lint_code = c(
|
|
colors::DIM,
|
|
&format!("[{}/{}]", lint.id.category().category_name(), lint.id.name()),
|
|
);
|
|
if lint.span.start > 0 || lint.span.end > 0 {
|
|
let (line, col) = diagnostics::offset_to_line_col(&source, lint.span.start);
|
|
eprintln!(" {}{} {}:{}:{}: {}",
|
|
c(colors::RED, "error"), lint_code, path, line, col, lint.message);
|
|
} else {
|
|
eprintln!(" {}{} {}: {}",
|
|
c(colors::RED, "error"), lint_code, path, lint.message);
|
|
}
|
|
}
|
|
}
|
|
failed += 1;
|
|
} else if warnings > 0 {
|
|
println!("{} {} {}", c(colors::YELLOW, "\u{26a0}"), path,
|
|
c(colors::DIM, &format!("({} lint warnings)", warnings)));
|
|
total_warnings += warnings;
|
|
passed += 1;
|
|
} else {
|
|
println!("{} {}", c(colors::GREEN, "\u{2713}"), path);
|
|
passed += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
let elapsed = start.elapsed();
|
|
let time_str = c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64()));
|
|
|
|
println!();
|
|
if failed > 0 {
|
|
let mut summary = format!("{} {} passed, {} failed",
|
|
c(colors::RED, "\u{2717}"), passed, failed);
|
|
if total_warnings > 0 {
|
|
summary.push_str(&format!(", {}", c(colors::YELLOW, &format!("{} warnings", total_warnings))));
|
|
}
|
|
println!("{} {}", summary, time_str);
|
|
std::process::exit(1);
|
|
} else {
|
|
let mut summary = format!("{} {} passed",
|
|
c(colors::GREEN, "\u{2713}"), passed);
|
|
if total_warnings > 0 {
|
|
summary.push_str(&format!(", {}", c(colors::YELLOW, &format!("{} warnings", total_warnings))));
|
|
}
|
|
println!("{} {}", summary, time_str);
|
|
}
|
|
}
|
|
|
|
fn collect_lux_files_nonrecursive(dir: &str, pattern: Option<&str>, files: &mut Vec<std::path::PathBuf>) {
|
|
use std::fs;
|
|
|
|
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
|
|
let path = entry.path();
|
|
if path.is_file() && path.extension().map(|e| e == "lux").unwrap_or(false) {
|
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
if pattern.map(|p| name.contains(p)).unwrap_or(true) {
|
|
files.push(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Find a C compiler. Priority: $CC env var, build-time embedded path, PATH search.
|
|
fn find_c_compiler() -> String {
|
|
// 1. Explicit env var
|
|
if let Ok(cc) = std::env::var("CC") {
|
|
if !cc.is_empty() {
|
|
return cc;
|
|
}
|
|
}
|
|
// 2. Path captured at build time (e.g. absolute nix store path)
|
|
let built_in = env!("LUX_CC_PATH");
|
|
if !built_in.is_empty() && std::path::Path::new(built_in).exists() {
|
|
return built_in.to_string();
|
|
}
|
|
// 3. Search PATH
|
|
for name in &["cc", "gcc", "clang"] {
|
|
if let Ok(output) = std::process::Command::new("which").arg(name).output() {
|
|
if output.status.success() {
|
|
if let Ok(p) = String::from_utf8(output.stdout) {
|
|
let p = p.trim();
|
|
if !p.is_empty() {
|
|
return p.to_string();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// 4. Last resort
|
|
"cc".to_string()
|
|
}
|
|
|
|
fn compile_to_c(path: &str, output_path: Option<&str>, run_after: bool, emit_c: bool) {
|
|
use codegen::c_backend::CBackend;
|
|
use modules::ModuleLoader;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::Instant;
|
|
|
|
let start = Instant::now();
|
|
let file_path = Path::new(path);
|
|
let source = match std::fs::read_to_string(file_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("{} Error reading file '{}': {}", c(colors::RED, "error:"), path, e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Parse with module loading
|
|
let mut loader = ModuleLoader::new();
|
|
if let Some(parent) = file_path.parent() {
|
|
loader.add_search_path(parent.to_path_buf());
|
|
}
|
|
|
|
let program = match loader.load_source(&source, Some(file_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("{} {}", c(colors::RED, "error:"), e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Type check
|
|
let mut checker = TypeChecker::new();
|
|
if let Err(errors) = checker.check_program_with_modules(&program, &loader) {
|
|
for error in errors {
|
|
let diagnostic = error.to_diagnostic();
|
|
eprint!("{}", render(&diagnostic, &source, Some(path)));
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Generate C code
|
|
let mut backend = CBackend::new();
|
|
let c_code = match backend.generate(&program, loader.module_cache()) {
|
|
Ok(code) => code,
|
|
Err(e) => {
|
|
eprintln!("{} C codegen: {}", c(colors::RED, "error:"), e);
|
|
eprintln!();
|
|
eprintln!("{}", c(colors::DIM, "Note: The C backend supports functions, closures, ADTs,"));
|
|
eprintln!("{}", c(colors::DIM, "pattern matching, lists, and Console.print."));
|
|
eprintln!("{}", c(colors::DIM, "Not yet supported: other effects, some advanced features."));
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Handle --emit-c: output C code instead of binary
|
|
if emit_c {
|
|
if let Some(out_path) = output_path {
|
|
if let Err(e) = std::fs::write(out_path, &c_code) {
|
|
eprintln!("{} writing '{}': {}", c(colors::RED, "error:"), out_path, e);
|
|
std::process::exit(1);
|
|
}
|
|
let elapsed = start.elapsed();
|
|
eprintln!("{} Emitted C to {} {}",
|
|
c(colors::GREEN, "\u{2713}"), bc(colors::CYAN, out_path),
|
|
c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64())));
|
|
} else {
|
|
println!("{}", c_code);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Default: compile to native binary
|
|
let temp_c = std::env::temp_dir().join("lux_output.c");
|
|
|
|
// Determine output binary name
|
|
let output_bin = if let Some(out) = output_path {
|
|
Path::new(out).to_path_buf()
|
|
} else {
|
|
// Derive from source filename: foo.lux -> ./foo
|
|
let stem = file_path.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("a.out");
|
|
Path::new(".").join(stem)
|
|
};
|
|
|
|
if let Err(e) = std::fs::write(&temp_c, &c_code) {
|
|
eprintln!("{} writing temp file: {}", c(colors::RED, "error:"), e);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Find C compiler: $CC env var > embedded build-time path > PATH search
|
|
let cc = find_c_compiler();
|
|
|
|
let compile_result = Command::new(&cc)
|
|
.args(["-O2", "-o"])
|
|
.arg(&output_bin)
|
|
.arg(&temp_c)
|
|
.arg("-lm")
|
|
.output();
|
|
|
|
match compile_result {
|
|
Ok(output) => {
|
|
if !output.status.success() {
|
|
eprintln!("{} C compilation failed:", c(colors::RED, "error:"));
|
|
eprintln!("{}", String::from_utf8_lossy(&output.stderr));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("{} Failed to run C compiler '{}': {}", c(colors::RED, "error:"), cc, e);
|
|
eprintln!("{}", c(colors::DIM, "Make sure gcc or clang is installed, or set CC environment variable."));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
let elapsed = start.elapsed();
|
|
|
|
if run_after {
|
|
// Run the compiled binary
|
|
let run_result = Command::new(&output_bin).status();
|
|
match run_result {
|
|
Ok(status) => {
|
|
std::process::exit(status.code().unwrap_or(1));
|
|
}
|
|
Err(e) => {
|
|
eprintln!("{} Failed to run compiled binary: {}", c(colors::RED, "error:"), e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
} else {
|
|
eprintln!("{} Compiled to {} {}",
|
|
c(colors::GREEN, "\u{2713}"),
|
|
bc(colors::CYAN, &output_bin.display().to_string()),
|
|
c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64())));
|
|
}
|
|
}
|
|
|
|
fn compile_to_js(path: &str, output_path: Option<&str>, run_after: bool) {
|
|
use codegen::js_backend::JsBackend;
|
|
use modules::ModuleLoader;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::time::Instant;
|
|
|
|
let start = Instant::now();
|
|
let file_path = Path::new(path);
|
|
let source = match std::fs::read_to_string(file_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("{} Error reading file '{}': {}", c(colors::RED, "error:"), path, e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Parse with module loading
|
|
let mut loader = ModuleLoader::new();
|
|
if let Some(parent) = file_path.parent() {
|
|
loader.add_search_path(parent.to_path_buf());
|
|
}
|
|
|
|
let program = match loader.load_source(&source, Some(file_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("{} {}", c(colors::RED, "error:"), e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Type check
|
|
let mut checker = TypeChecker::new();
|
|
if let Err(errors) = checker.check_program_with_modules(&program, &loader) {
|
|
for error in errors {
|
|
let diagnostic = error.to_diagnostic();
|
|
eprint!("{}", render(&diagnostic, &source, Some(path)));
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Generate JavaScript code
|
|
let mut backend = JsBackend::new();
|
|
let js_code = match backend.generate(&program) {
|
|
Ok(code) => code,
|
|
Err(e) => {
|
|
eprintln!("{} JS codegen: {}", c(colors::RED, "error:"), e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Determine output file path
|
|
let output_js = if let Some(out) = output_path {
|
|
Path::new(out).to_path_buf()
|
|
} else {
|
|
// Derive from source filename: foo.lux -> ./foo.js
|
|
let stem = file_path.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("output");
|
|
Path::new(".").join(format!("{}.js", stem))
|
|
};
|
|
|
|
// Write the JavaScript file
|
|
if let Err(e) = std::fs::write(&output_js, &js_code) {
|
|
eprintln!("{} writing '{}': {}", c(colors::RED, "error:"), output_js.display(), e);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let elapsed = start.elapsed();
|
|
|
|
if run_after {
|
|
// Run with Node.js
|
|
let node_result = Command::new("node")
|
|
.arg(&output_js)
|
|
.status();
|
|
|
|
match node_result {
|
|
Ok(status) => {
|
|
std::process::exit(status.code().unwrap_or(1));
|
|
}
|
|
Err(e) => {
|
|
eprintln!("{} Failed to run with Node.js: {}", c(colors::RED, "error:"), e);
|
|
eprintln!("{}", c(colors::DIM, "Make sure Node.js is installed."));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
} else {
|
|
eprintln!("{} Compiled to {} {}",
|
|
c(colors::GREEN, "\u{2713}"),
|
|
bc(colors::CYAN, &output_js.display().to_string()),
|
|
c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64())));
|
|
}
|
|
}
|
|
|
|
fn run_tests(args: &[String]) {
|
|
use std::path::Path;
|
|
use std::fs;
|
|
use std::time::Instant;
|
|
|
|
let start = Instant::now();
|
|
|
|
// Find test files
|
|
let pattern = args.first().map(|s| s.as_str());
|
|
|
|
// Look for test files in multiple locations
|
|
let mut test_files = Vec::new();
|
|
|
|
// Current directory
|
|
collect_test_files(".", pattern, &mut test_files);
|
|
|
|
// tests/ subdirectory
|
|
if Path::new("tests").is_dir() {
|
|
collect_test_files("tests", pattern, &mut test_files);
|
|
}
|
|
|
|
// examples/ subdirectory (for example tests)
|
|
if Path::new("examples").is_dir() {
|
|
collect_test_files("examples", pattern, &mut test_files);
|
|
}
|
|
|
|
// If a specific file is given, use that
|
|
if let Some(p) = pattern {
|
|
if Path::new(p).is_file() {
|
|
test_files.clear();
|
|
test_files.push(std::path::PathBuf::from(p));
|
|
}
|
|
}
|
|
|
|
if test_files.is_empty() {
|
|
println!("No test files found.");
|
|
println!("{}", c(colors::DIM, "Test files should be named test_*.lux or *_test.lux"));
|
|
println!("{}", c(colors::DIM, "Or contain functions named test_*"));
|
|
return;
|
|
}
|
|
|
|
println!("{}\n", bc("", "Running tests..."));
|
|
|
|
let mut total_passed = 0;
|
|
let mut total_failed = 0;
|
|
let mut all_failures = Vec::new();
|
|
|
|
for test_file in &test_files {
|
|
let path_str = test_file.to_string_lossy().to_string();
|
|
|
|
// Read and parse the file (with module loading)
|
|
let source = match fs::read_to_string(test_file) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
println!(" {} {} {}", c(colors::RED, "\u{2717}"), path_str, c(colors::RED, &e.to_string()));
|
|
total_failed += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
use modules::ModuleLoader;
|
|
let mut loader = ModuleLoader::new();
|
|
if let Some(parent) = test_file.parent() {
|
|
loader.add_search_path(parent.to_path_buf());
|
|
}
|
|
|
|
let program = match loader.load_source(&source, Some(test_file.as_path())) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
println!(" {} {} {}", c(colors::RED, "\u{2717}"), path_str, c(colors::RED, &format!("parse error: {}", e)));
|
|
total_failed += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Type check with module support
|
|
let mut checker = typechecker::TypeChecker::new();
|
|
if let Err(errors) = checker.check_program_with_modules(&program, &loader) {
|
|
println!(" {} {} {}", c(colors::RED, "\u{2717}"), path_str, c(colors::RED, "type error"));
|
|
for err in errors {
|
|
eprintln!(" {}", err);
|
|
}
|
|
total_failed += 1;
|
|
continue;
|
|
}
|
|
|
|
// Get auto-generated migrations from typechecker
|
|
let auto_migrations = checker.get_auto_migrations().clone();
|
|
|
|
// Find test functions (functions starting with test_)
|
|
let test_funcs: Vec<_> = program.declarations.iter().filter_map(|d| {
|
|
if let ast::Declaration::Function(f) = d {
|
|
if f.name.name.starts_with("test_") {
|
|
return Some(f.name.name.clone());
|
|
}
|
|
}
|
|
None
|
|
}).collect();
|
|
|
|
if test_funcs.is_empty() {
|
|
// No test functions, run the whole file
|
|
let mut interp = Interpreter::new();
|
|
interp.register_auto_migrations(&auto_migrations);
|
|
interp.reset_test_results();
|
|
|
|
match interp.run_with_modules(&program, &loader) {
|
|
Ok(_) => {
|
|
let results = interp.get_test_results();
|
|
if results.failed == 0 && results.passed == 0 {
|
|
println!(" {} {} {}", c(colors::GREEN, "\u{2713}"), path_str, c(colors::DIM, "(no assertions)"));
|
|
total_passed += 1;
|
|
} else if results.failed == 0 {
|
|
println!(" {} {} {}", c(colors::GREEN, "\u{2713}"), path_str,
|
|
c(colors::DIM, &format!("({} assertions)", results.passed)));
|
|
total_passed += results.passed;
|
|
} else {
|
|
println!(" {} {} {}", c(colors::RED, "\u{2717}"), path_str,
|
|
c(colors::RED, &format!("{} passed, {} failed", results.passed, results.failed)));
|
|
total_passed += results.passed;
|
|
total_failed += results.failed;
|
|
for failure in &results.failures {
|
|
all_failures.push((path_str.clone(), "".to_string(), failure.clone()));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!(" {} {} {}", c(colors::RED, "\u{2717}"), path_str, c(colors::RED, &format!("runtime error: {}", e)));
|
|
total_failed += 1;
|
|
}
|
|
}
|
|
} else {
|
|
// Run individual test functions
|
|
println!(" {}:", c(colors::DIM, &path_str));
|
|
|
|
for test_name in &test_funcs {
|
|
let mut interp = Interpreter::new();
|
|
interp.register_auto_migrations(&auto_migrations);
|
|
interp.reset_test_results();
|
|
|
|
// First run the file to define all functions and load imports
|
|
if let Err(e) = interp.run_with_modules(&program, &loader) {
|
|
println!(" {} {} {}", c(colors::RED, "\u{2717}"), test_name, c(colors::RED, &e.to_string()));
|
|
total_failed += 1;
|
|
continue;
|
|
}
|
|
|
|
// Call the test function
|
|
let call_source = format!("let testResult = run {}() with {{}}", test_name);
|
|
let call_program = match Parser::parse_source(&call_source) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
println!(" {} {} {}", c(colors::RED, "\u{2717}"), test_name, c(colors::RED, &e.to_string()));
|
|
total_failed += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
match interp.run(&call_program) {
|
|
Ok(_) => {
|
|
let results = interp.get_test_results();
|
|
if results.failed == 0 {
|
|
println!(" {} {}", c(colors::GREEN, "\u{2713}"), test_name);
|
|
total_passed += 1;
|
|
} else {
|
|
println!(" {} {}", c(colors::RED, "\u{2717}"), test_name);
|
|
total_failed += 1;
|
|
for failure in &results.failures {
|
|
all_failures.push((path_str.clone(), test_name.clone(), failure.clone()));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!(" {} {} {}", c(colors::RED, "\u{2717}"), test_name, c(colors::RED, &e.to_string()));
|
|
total_failed += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print failure details
|
|
if !all_failures.is_empty() {
|
|
println!("\n{}\n", bc(colors::RED, "--- Failures ---"));
|
|
for (file, test, failure) in &all_failures {
|
|
if test.is_empty() {
|
|
println!("{}:", bc("", file));
|
|
} else {
|
|
println!("{} - {}:", bc("", file), bc("", test));
|
|
}
|
|
println!(" {}", failure.message);
|
|
if let Some(expected) = &failure.expected {
|
|
println!(" {} {}", c(colors::CYAN, "Expected:"), expected);
|
|
}
|
|
if let Some(actual) = &failure.actual {
|
|
println!(" {} {}", c(colors::RED, "Actual: "), actual);
|
|
}
|
|
println!();
|
|
}
|
|
}
|
|
|
|
let elapsed = start.elapsed();
|
|
let time_str = c(colors::DIM, &format!("in {:.2}s", elapsed.as_secs_f64()));
|
|
|
|
if total_failed > 0 {
|
|
println!("{} {} passed, {} failed {}",
|
|
c(colors::RED, "\u{2717}"), total_passed, total_failed, time_str);
|
|
std::process::exit(1);
|
|
} else {
|
|
println!("{} {} passed {}",
|
|
c(colors::GREEN, "\u{2713}"), total_passed, time_str);
|
|
}
|
|
}
|
|
|
|
fn collect_test_files(dir: &str, pattern: Option<&str>, files: &mut Vec<std::path::PathBuf>) {
|
|
use std::fs;
|
|
|
|
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
|
|
let path = entry.path();
|
|
if path.extension().map(|e| e == "lux").unwrap_or(false) {
|
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
if name.starts_with("test_") || name.ends_with("_test.lux") {
|
|
if pattern.map(|p| name.contains(p)).unwrap_or(true) {
|
|
files.push(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_lux_files(dir: &str, pattern: Option<&str>, files: &mut Vec<std::path::PathBuf>) {
|
|
use std::fs;
|
|
|
|
for entry in fs::read_dir(dir).into_iter().flatten().flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
// Recursively search subdirectories
|
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
// Skip hidden directories and common non-source dirs
|
|
if !name.starts_with('.') && name != "target" && name != "node_modules" {
|
|
collect_lux_files(path.to_str().unwrap_or(""), pattern, files);
|
|
}
|
|
}
|
|
} else if path.extension().map(|e| e == "lux").unwrap_or(false) {
|
|
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
|
if pattern.map(|p| name.contains(p)).unwrap_or(true) {
|
|
files.push(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn watch_file(path: &str) {
|
|
use std::time::{Duration, SystemTime};
|
|
use std::path::Path;
|
|
|
|
let file_path = Path::new(path);
|
|
if !file_path.exists() {
|
|
eprintln!("File not found: {}", path);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!("Watching {} for changes (Ctrl+C to stop)...", path);
|
|
println!();
|
|
|
|
let mut last_modified = SystemTime::UNIX_EPOCH;
|
|
|
|
loop {
|
|
let metadata = match std::fs::metadata(file_path) {
|
|
Ok(m) => m,
|
|
Err(_) => {
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
|
|
|
|
if modified > last_modified {
|
|
last_modified = modified;
|
|
|
|
// Clear screen
|
|
print!("\x1B[2J\x1B[H");
|
|
|
|
println!("=== Running {} ===", path);
|
|
println!();
|
|
|
|
// Run the file
|
|
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
|
.arg(path)
|
|
.status();
|
|
|
|
match result {
|
|
Ok(status) if status.success() => {
|
|
println!();
|
|
println!("=== Success ===");
|
|
}
|
|
Ok(_) => {
|
|
println!();
|
|
println!("=== Failed ===");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error running file: {}", e);
|
|
}
|
|
}
|
|
|
|
println!();
|
|
println!("Watching for changes...");
|
|
}
|
|
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
}
|
|
}
|
|
|
|
fn watch_and_rerun(path: &str, compile_args: &[String]) {
|
|
use std::time::{Duration, SystemTime};
|
|
use std::path::Path;
|
|
|
|
let file_path = Path::new(path);
|
|
if !file_path.exists() {
|
|
eprintln!("File not found: {}", path);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!();
|
|
println!("Watching {} for changes (Ctrl+C to stop)...", path);
|
|
|
|
let mut last_modified = std::fs::metadata(file_path)
|
|
.and_then(|m| m.modified())
|
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
|
|
|
loop {
|
|
std::thread::sleep(Duration::from_millis(500));
|
|
|
|
let modified = match std::fs::metadata(file_path).and_then(|m| m.modified()) {
|
|
Ok(m) => m,
|
|
Err(_) => continue,
|
|
};
|
|
|
|
if modified > last_modified {
|
|
last_modified = modified;
|
|
|
|
// Clear screen
|
|
print!("\x1B[2J\x1B[H");
|
|
|
|
println!("=== Compiling {} ===", path);
|
|
println!();
|
|
|
|
let result = std::process::Command::new(std::env::current_exe().unwrap())
|
|
.args(compile_args)
|
|
.status();
|
|
|
|
match result {
|
|
Ok(status) if status.success() => {
|
|
println!();
|
|
println!("=== Success ===");
|
|
}
|
|
Ok(_) => {
|
|
println!();
|
|
println!("=== Failed ===");
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error running compiler: {}", e);
|
|
}
|
|
}
|
|
|
|
println!();
|
|
println!("Watching for changes...");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn serve_static_files(dir: &str, port: u16) {
|
|
use std::io::{Write, BufRead, BufReader};
|
|
use std::net::TcpListener;
|
|
use std::path::Path;
|
|
|
|
let root = Path::new(dir).canonicalize().unwrap_or_else(|_| {
|
|
eprintln!("{} Directory not found: {}", c(colors::RED, "error:"), dir);
|
|
std::process::exit(1);
|
|
});
|
|
|
|
let listener = match TcpListener::bind(format!("0.0.0.0:{}", port)) {
|
|
Ok(l) => l,
|
|
Err(e) => {
|
|
if e.kind() == std::io::ErrorKind::AddrInUse {
|
|
eprintln!("{} Port {} is already in use", c(colors::RED, "error:"), port);
|
|
// Try to find an available port
|
|
for try_port in (port + 1)..=(port + 3) {
|
|
if TcpListener::bind(format!("0.0.0.0:{}", try_port)).is_ok() {
|
|
eprintln!("{}", c(colors::CYAN, &format!(" Try: lux serve --port {}", try_port)));
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
eprintln!("{} Could not bind to port {}: {}", c(colors::RED, "error:"), port, e);
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
println!("{}", bc("", "Lux static file server"));
|
|
println!(" {} {}", c(colors::DIM, "Serving:"), root.display());
|
|
println!(" {} {}", c(colors::DIM, "URL:"), bc(colors::CYAN, &format!("http://localhost:{}", port)));
|
|
println!();
|
|
println!("{}", c(colors::DIM, "Press Ctrl+C to stop"));
|
|
println!();
|
|
|
|
for stream in listener.incoming() {
|
|
let Ok(mut stream) = stream else { continue };
|
|
|
|
// Read the request line
|
|
let mut reader = BufReader::new(&stream);
|
|
let mut request_line = String::new();
|
|
if reader.read_line(&mut request_line).is_err() {
|
|
continue;
|
|
}
|
|
|
|
// Parse the request
|
|
let parts: Vec<&str> = request_line.split_whitespace().collect();
|
|
if parts.len() < 2 {
|
|
continue;
|
|
}
|
|
|
|
let method = parts[0];
|
|
let path = parts[1];
|
|
|
|
// Log the request (status is logged after response is determined)
|
|
let log_method = bc("", method);
|
|
let log_path = path.to_string();
|
|
|
|
// Only handle GET requests
|
|
if method != "GET" {
|
|
let response = "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 18\r\n\r\nMethod Not Allowed";
|
|
let _ = stream.write_all(response.as_bytes());
|
|
continue;
|
|
}
|
|
|
|
// Resolve the file path
|
|
let mut file_path = root.clone();
|
|
let request_path = if path == "/" { "/index.html" } else { path };
|
|
|
|
// Prevent directory traversal
|
|
for component in request_path.trim_start_matches('/').split('/') {
|
|
if component == ".." {
|
|
let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 9\r\n\r\nForbidden";
|
|
let _ = stream.write_all(response.as_bytes());
|
|
continue;
|
|
}
|
|
file_path.push(component);
|
|
}
|
|
|
|
// Try to serve the file
|
|
let (status, content_type, body) = if file_path.is_file() {
|
|
match std::fs::read(&file_path) {
|
|
Ok(content) => {
|
|
let mime = get_mime_type(&file_path);
|
|
("200 OK", mime, content)
|
|
}
|
|
Err(_) => {
|
|
("500 Internal Server Error", "text/plain", b"Error reading file".to_vec())
|
|
}
|
|
}
|
|
} else if file_path.is_dir() {
|
|
// Try index.html
|
|
let index_path = file_path.join("index.html");
|
|
if index_path.is_file() {
|
|
match std::fs::read(&index_path) {
|
|
Ok(content) => ("200 OK", "text/html", content),
|
|
Err(_) => ("500 Internal Server Error", "text/plain", b"Error reading file".to_vec()),
|
|
}
|
|
} else {
|
|
("404 Not Found", "text/plain", b"Not Found".to_vec())
|
|
}
|
|
} else {
|
|
// Try with .html extension
|
|
let html_path = file_path.with_extension("html");
|
|
if html_path.is_file() {
|
|
match std::fs::read(&html_path) {
|
|
Ok(content) => ("200 OK", "text/html", content),
|
|
Err(_) => ("500 Internal Server Error", "text/plain", b"Error reading file".to_vec()),
|
|
}
|
|
} else {
|
|
("404 Not Found", "text/plain", b"Not Found".to_vec())
|
|
}
|
|
};
|
|
|
|
// Log the request with colored status
|
|
let status_color = if status.starts_with("200") {
|
|
colors::GREEN
|
|
} else if status.starts_with("404") {
|
|
colors::YELLOW
|
|
} else {
|
|
colors::RED
|
|
};
|
|
let status_code = status.split_whitespace().next().unwrap_or(status);
|
|
println!(" {} {} {}", log_method, c(status_color, status_code), log_path);
|
|
|
|
// Send response
|
|
let response = format!(
|
|
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
status, content_type, body.len()
|
|
);
|
|
let _ = stream.write_all(response.as_bytes());
|
|
let _ = stream.write_all(&body);
|
|
}
|
|
}
|
|
|
|
fn get_mime_type(path: &std::path::Path) -> &'static str {
|
|
match path.extension().and_then(|e| e.to_str()) {
|
|
Some("html") | Some("htm") => "text/html",
|
|
Some("css") => "text/css",
|
|
Some("js") => "application/javascript",
|
|
Some("json") => "application/json",
|
|
Some("png") => "image/png",
|
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
|
Some("gif") => "image/gif",
|
|
Some("svg") => "image/svg+xml",
|
|
Some("ico") => "image/x-icon",
|
|
Some("woff") => "font/woff",
|
|
Some("woff2") => "font/woff2",
|
|
Some("ttf") => "font/ttf",
|
|
Some("txt") => "text/plain",
|
|
Some("xml") => "application/xml",
|
|
Some("pdf") => "application/pdf",
|
|
_ => "application/octet-stream",
|
|
}
|
|
}
|
|
|
|
fn handle_pkg_command(args: &[String]) {
|
|
use package::{PackageManager, DependencySource};
|
|
use std::path::PathBuf;
|
|
|
|
if args.is_empty() {
|
|
print_pkg_help();
|
|
return;
|
|
}
|
|
|
|
// Help doesn't require being in a project
|
|
if matches!(args[0].as_str(), "help" | "--help" | "-h") {
|
|
print_pkg_help();
|
|
return;
|
|
}
|
|
|
|
// Init doesn't require being in a project (it creates one)
|
|
if args[0] == "init" {
|
|
init_pkg_here(args.get(1).map(|s| s.as_str()));
|
|
return;
|
|
}
|
|
|
|
let project_root = match PackageManager::find_project_root() {
|
|
Some(root) => root,
|
|
None => {
|
|
eprintln!("Error: Not in a Lux project (no lux.toml found)");
|
|
eprintln!("Run 'lux pkg init' to initialize a project here, or 'lux init <name>' to create a new project");
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
let pkg = PackageManager::new(&project_root);
|
|
|
|
match args[0].as_str() {
|
|
"install" | "i" => {
|
|
if let Err(e) = pkg.install() {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"add" => {
|
|
if args.len() < 2 {
|
|
eprintln!("Usage: lux pkg add <package> [version] [--git <url>] [--path <path>]");
|
|
std::process::exit(1);
|
|
}
|
|
let name = &args[1];
|
|
let mut version = "0.0.0".to_string();
|
|
let mut source = DependencySource::Registry;
|
|
|
|
let mut i = 2;
|
|
while i < args.len() {
|
|
match args[i].as_str() {
|
|
"--git" => {
|
|
if i + 1 < args.len() {
|
|
let url = args[i + 1].clone();
|
|
let branch = if i + 3 < args.len() && args[i + 2] == "--branch" {
|
|
i += 2;
|
|
Some(args[i + 1].clone())
|
|
} else {
|
|
None
|
|
};
|
|
source = DependencySource::Git { url, branch };
|
|
i += 2;
|
|
} else {
|
|
eprintln!("--git requires a URL");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"--path" => {
|
|
if i + 1 < args.len() {
|
|
source = DependencySource::Path {
|
|
path: PathBuf::from(&args[i + 1]),
|
|
};
|
|
i += 2;
|
|
} else {
|
|
eprintln!("--path requires a path");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
v if !v.starts_with('-') => {
|
|
version = v.to_string();
|
|
i += 1;
|
|
}
|
|
_ => i += 1,
|
|
}
|
|
}
|
|
|
|
if let Err(e) = pkg.add(name, &version, source) {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"remove" | "rm" => {
|
|
if args.len() < 2 {
|
|
eprintln!("Usage: lux pkg remove <package>");
|
|
std::process::exit(1);
|
|
}
|
|
if let Err(e) = pkg.remove(&args[1]) {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"list" | "ls" => {
|
|
if let Err(e) = pkg.list() {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"update" => {
|
|
if let Err(e) = pkg.update() {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"clean" => {
|
|
if let Err(e) = pkg.clean() {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
"search" => {
|
|
if args.len() < 2 {
|
|
eprintln!("Usage: lux pkg search <query>");
|
|
std::process::exit(1);
|
|
}
|
|
search_registry(&args[1]);
|
|
}
|
|
"publish" => {
|
|
publish_package(&project_root);
|
|
}
|
|
"help" | "--help" | "-h" => {
|
|
print_pkg_help();
|
|
}
|
|
unknown => {
|
|
eprintln!("Unknown package command: {}", unknown);
|
|
print_pkg_help();
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn search_registry(query: &str) {
|
|
let registry_url = std::env::var("LUX_REGISTRY_URL")
|
|
.unwrap_or_else(|_| "https://pkgs.lux-lang.org".to_string());
|
|
|
|
println!("Searching for '{}' in {}...", query, registry_url);
|
|
|
|
// Make HTTP request to registry
|
|
let url = format!("{}/api/v1/search?q={}", registry_url, query);
|
|
|
|
// Use a simple HTTP client (could use reqwest in production)
|
|
match simple_http_get(&url) {
|
|
Ok(response) => {
|
|
// Parse JSON response and display results
|
|
if response.contains("\"packages\":[]") {
|
|
println!("No packages found matching '{}'", query);
|
|
} else {
|
|
println!("\nFound packages:");
|
|
// Simple parsing of package names from JSON
|
|
for line in response.lines() {
|
|
if line.contains("\"name\":") {
|
|
if let Some(start) = line.find("\"name\":") {
|
|
let rest = &line[start + 8..];
|
|
if let Some(end) = rest.find('"') {
|
|
let name = &rest[..end];
|
|
println!(" {}", name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to connect to registry: {}", e);
|
|
eprintln!("Make sure the registry server is running or check LUX_REGISTRY_URL");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn publish_package(project_root: &std::path::Path) {
|
|
use std::fs;
|
|
|
|
let registry_url = std::env::var("LUX_REGISTRY_URL")
|
|
.unwrap_or_else(|_| "https://pkgs.lux-lang.org".to_string());
|
|
|
|
// Load manifest
|
|
let manifest_path = project_root.join("lux.toml");
|
|
if !manifest_path.exists() {
|
|
eprintln!("No lux.toml found");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let manifest_content = fs::read_to_string(&manifest_path).unwrap();
|
|
|
|
// Extract project info
|
|
let mut name = String::new();
|
|
let mut version = String::new();
|
|
|
|
for line in manifest_content.lines() {
|
|
let line = line.trim();
|
|
if line.starts_with("name") {
|
|
if let Some(eq) = line.find('=') {
|
|
name = line[eq+1..].trim().trim_matches('"').to_string();
|
|
}
|
|
} else if line.starts_with("version") {
|
|
if let Some(eq) = line.find('=') {
|
|
version = line[eq+1..].trim().trim_matches('"').to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
if name.is_empty() || version.is_empty() {
|
|
eprintln!("Invalid lux.toml: missing name or version");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!("Publishing {} v{} to {}...", name, version, registry_url);
|
|
|
|
// Create tarball of the package
|
|
let tarball_name = format!("{}-{}.tar.gz", name, version);
|
|
let tarball_path = project_root.join(&tarball_name);
|
|
|
|
// Use tar command to create tarball
|
|
let status = std::process::Command::new("tar")
|
|
.arg("-czf")
|
|
.arg(&tarball_path)
|
|
.arg("--exclude=.lux_packages")
|
|
.arg("--exclude=.git")
|
|
.arg("--exclude=target")
|
|
.arg("-C")
|
|
.arg(project_root)
|
|
.arg(".")
|
|
.status();
|
|
|
|
match status {
|
|
Ok(s) if s.success() => {
|
|
println!("Created package tarball: {}", tarball_name);
|
|
println!();
|
|
println!("To publish, upload the tarball to the registry:");
|
|
println!(" curl -X POST {}/api/v1/publish -F 'package=@{}'",
|
|
registry_url, tarball_name);
|
|
|
|
// Clean up tarball
|
|
// fs::remove_file(&tarball_path).ok();
|
|
}
|
|
Ok(_) => {
|
|
eprintln!("Failed to create package tarball");
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to run tar: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn simple_http_get(url: &str) -> Result<String, String> {
|
|
use std::io::{Read, Write};
|
|
use std::net::TcpStream;
|
|
|
|
// Parse URL
|
|
let url = url.strip_prefix("http://").or_else(|| url.strip_prefix("https://")).unwrap_or(url);
|
|
let (host_port, path) = if let Some(slash) = url.find('/') {
|
|
(&url[..slash], &url[slash..])
|
|
} else {
|
|
(url, "/")
|
|
};
|
|
|
|
let (host, port) = if let Some(colon) = host_port.find(':') {
|
|
(&host_port[..colon], host_port[colon+1..].parse::<u16>().unwrap_or(80))
|
|
} else {
|
|
(host_port, 80)
|
|
};
|
|
|
|
let mut stream = TcpStream::connect((host, port))
|
|
.map_err(|e| format!("Connection failed: {}", e))?;
|
|
|
|
let request = format!(
|
|
"GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n",
|
|
path, host
|
|
);
|
|
|
|
stream.write_all(request.as_bytes())
|
|
.map_err(|e| format!("Write failed: {}", e))?;
|
|
|
|
let mut response = String::new();
|
|
stream.read_to_string(&mut response)
|
|
.map_err(|e| format!("Read failed: {}", e))?;
|
|
|
|
// Extract body (after \r\n\r\n)
|
|
if let Some(body_start) = response.find("\r\n\r\n") {
|
|
Ok(response[body_start + 4..].to_string())
|
|
} else {
|
|
Ok(response)
|
|
}
|
|
}
|
|
|
|
fn print_pkg_help() {
|
|
println!("Lux Package Manager");
|
|
println!();
|
|
println!("Usage: lux pkg <command> [options]");
|
|
println!();
|
|
println!("Commands:");
|
|
println!(" init [name] Initialize a lux.toml in the current directory");
|
|
println!(" install, i Install all dependencies from lux.toml");
|
|
println!(" add <pkg> Add a dependency");
|
|
println!(" Options:");
|
|
println!(" [version] Specify version (default: 0.0.0)");
|
|
println!(" --git <url> Install from git repository");
|
|
println!(" --branch <name> Git branch (with --git)");
|
|
println!(" --path <path> Install from local path");
|
|
println!(" remove, rm Remove a dependency");
|
|
println!(" list, ls List dependencies and their status");
|
|
println!(" update Update all dependencies");
|
|
println!(" clean Remove installed packages");
|
|
println!(" search <query> Search for packages in the registry");
|
|
println!(" publish Publish package to the registry");
|
|
println!();
|
|
println!("Registry Configuration:");
|
|
println!(" Set LUX_REGISTRY_URL to use a custom registry (default: https://pkgs.lux-lang.org)");
|
|
println!();
|
|
println!("Examples:");
|
|
println!(" lux pkg init");
|
|
println!(" lux pkg init my-project");
|
|
println!(" lux pkg install");
|
|
println!(" lux pkg add http 1.0.0");
|
|
println!(" lux pkg add mylib --git https://github.com/user/mylib");
|
|
println!(" lux pkg add local-lib --path ../lib");
|
|
println!(" lux pkg remove http");
|
|
println!(" lux pkg search json");
|
|
println!(" lux pkg publish");
|
|
}
|
|
|
|
fn init_pkg_here(name: Option<&str>) {
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
let lux_toml = Path::new("lux.toml");
|
|
|
|
if lux_toml.exists() {
|
|
eprintln!("lux.toml already exists in this directory");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Get project name from argument or current directory name
|
|
let project_name = name.map(String::from).unwrap_or_else(|| {
|
|
std::env::current_dir()
|
|
.ok()
|
|
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))
|
|
.unwrap_or_else(|| "my-project".to_string())
|
|
});
|
|
|
|
// Create lux.toml
|
|
let toml_content = format!(
|
|
r#"[project]
|
|
name = "{}"
|
|
version = "0.1.0"
|
|
description = "A Lux project"
|
|
|
|
[dependencies]
|
|
# Add dependencies here
|
|
# example = "1.0.0"
|
|
# mylib = {{ git = "https://github.com/user/mylib" }}
|
|
# local = {{ path = "../local-lib" }}
|
|
"#,
|
|
project_name
|
|
);
|
|
|
|
if let Err(e) = fs::write(lux_toml, toml_content) {
|
|
eprintln!("Failed to create lux.toml: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!("Initialized Lux project: {}", project_name);
|
|
println!();
|
|
println!("Created lux.toml");
|
|
println!();
|
|
println!("Next steps:");
|
|
println!(" lux pkg add <package> Add a dependency");
|
|
println!(" lux pkg install Install dependencies");
|
|
}
|
|
|
|
fn init_project(name: Option<&str>) {
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
let project_name = name.unwrap_or("my-lux-project");
|
|
let project_dir = Path::new(project_name);
|
|
|
|
if project_dir.exists() {
|
|
eprintln!("Directory '{}' already exists", project_name);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Create project structure
|
|
fs::create_dir_all(project_dir.join("src")).unwrap();
|
|
fs::create_dir_all(project_dir.join("tests")).unwrap();
|
|
|
|
// Create lux.toml
|
|
let toml_content = format!(
|
|
r#"[project]
|
|
name = "{}"
|
|
version = "0.1.0"
|
|
description = "A Lux project"
|
|
|
|
[dependencies]
|
|
# Add dependencies here
|
|
# example = "1.0.0"
|
|
"#,
|
|
project_name
|
|
);
|
|
fs::write(project_dir.join("lux.toml"), toml_content).unwrap();
|
|
|
|
// Create main.lux
|
|
let main_content = r#"// Main entry point
|
|
|
|
fn main(): Unit with {Console} = {
|
|
Console.print("Hello from Lux!")
|
|
}
|
|
|
|
let output = run main() with {}
|
|
"#;
|
|
fs::write(project_dir.join("src").join("main.lux"), main_content).unwrap();
|
|
|
|
// Create a test file
|
|
let test_content = r#"// Example test file
|
|
|
|
fn testAddition(): Bool = {
|
|
let result = 2 + 2
|
|
result == 4
|
|
}
|
|
|
|
fn main(): Unit with {Console} = {
|
|
if testAddition() then
|
|
Console.print("Test passed!")
|
|
else
|
|
Console.print("Test failed!")
|
|
}
|
|
|
|
let output = run main() with {}
|
|
"#;
|
|
fs::write(project_dir.join("tests").join("test_example.lux"), test_content).unwrap();
|
|
|
|
// Create .gitignore
|
|
let gitignore_content = r#"# Lux build artifacts
|
|
/target/
|
|
*.luxc
|
|
|
|
# Editor files
|
|
.vscode/
|
|
.idea/
|
|
*.swp
|
|
*~
|
|
"#;
|
|
fs::write(project_dir.join(".gitignore"), gitignore_content).unwrap();
|
|
|
|
println!("{} Created new Lux project: {}", c(colors::GREEN, "\u{2713}"), bc(colors::GREEN, project_name));
|
|
println!();
|
|
println!("{}", bc("", "Project structure:"));
|
|
println!(" {}", bc(colors::CYAN, &format!("{}/", project_name)));
|
|
println!(" {} {}", c(colors::DIM, "\u{251c}\u{2500}\u{2500}"), "lux.toml");
|
|
println!(" {} {}", c(colors::DIM, "\u{251c}\u{2500}\u{2500}"), bc(colors::CYAN, "src/"));
|
|
println!(" {} {} {}", c(colors::DIM, "\u{2502}"), c(colors::DIM, " \u{2514}\u{2500}\u{2500}"), "main.lux");
|
|
println!(" {} {}", c(colors::DIM, "\u{2514}\u{2500}\u{2500}"), bc(colors::CYAN, "tests/"));
|
|
println!(" {} {}", c(colors::DIM, "\u{2514}\u{2500}\u{2500}"), "test_example.lux");
|
|
println!();
|
|
println!("To get started:");
|
|
println!(" {} {}", c(colors::DIM, "$"), bc(colors::CYAN, &format!("cd {}", project_name)));
|
|
println!(" {} {}", c(colors::DIM, "$"), bc(colors::CYAN, "lux src/main.lux"));
|
|
}
|
|
|
|
/// Generate API documentation for Lux source files
|
|
fn generate_docs(args: &[String]) {
|
|
use std::path::Path;
|
|
use std::collections::HashMap;
|
|
|
|
let output_json = args.iter().any(|a| a == "--json");
|
|
let output_dir = args.iter()
|
|
.position(|a| a == "-o")
|
|
.and_then(|i| args.get(i + 1))
|
|
.map(|s| s.as_str())
|
|
.unwrap_or("docs");
|
|
let input_file = args.iter().find(|a| !a.starts_with('-') && *a != output_dir);
|
|
|
|
// Collect files to document
|
|
let mut files_to_doc = Vec::new();
|
|
|
|
if let Some(path) = input_file {
|
|
if Path::new(path).is_file() {
|
|
files_to_doc.push(path.to_string());
|
|
} else {
|
|
eprintln!("File not found: {}", path);
|
|
std::process::exit(1);
|
|
}
|
|
} else {
|
|
// Auto-discover files
|
|
if Path::new("src").is_dir() {
|
|
collect_lux_files_for_docs("src", &mut files_to_doc);
|
|
}
|
|
if Path::new("stdlib").is_dir() {
|
|
collect_lux_files_for_docs("stdlib", &mut files_to_doc);
|
|
}
|
|
}
|
|
|
|
if files_to_doc.is_empty() {
|
|
eprintln!("No .lux files found to document");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Create output directory
|
|
if !output_json {
|
|
if let Err(e) = std::fs::create_dir_all(output_dir) {
|
|
eprintln!("Failed to create output directory: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
let mut all_docs: HashMap<String, ModuleDoc> = HashMap::new();
|
|
let mut error_count = 0;
|
|
|
|
for file_path in &files_to_doc {
|
|
let source = match std::fs::read_to_string(file_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("{}: ERROR - {}", file_path, e);
|
|
error_count += 1;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
match extract_module_doc(&source, file_path) {
|
|
Ok(doc) => {
|
|
let module_name = Path::new(file_path)
|
|
.file_stem()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
all_docs.insert(module_name, doc);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("{}: PARSE ERROR - {}", file_path, e);
|
|
error_count += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if output_json {
|
|
// Output as JSON
|
|
println!("{}", docs_to_json(&all_docs));
|
|
} else {
|
|
// Generate HTML files
|
|
let index_html = generate_index_html(&all_docs);
|
|
let index_path = format!("{}/index.html", output_dir);
|
|
if let Err(e) = std::fs::write(&index_path, &index_html) {
|
|
eprintln!("Failed to write index.html: {}", e);
|
|
error_count += 1;
|
|
}
|
|
|
|
for (module_name, doc) in &all_docs {
|
|
let html = generate_module_html(module_name, doc);
|
|
let path = format!("{}/{}.html", output_dir, module_name);
|
|
if let Err(e) = std::fs::write(&path, &html) {
|
|
eprintln!("Failed to write {}: {}", path, e);
|
|
error_count += 1;
|
|
}
|
|
}
|
|
|
|
// Generate CSS
|
|
let css_path = format!("{}/style.css", output_dir);
|
|
if let Err(e) = std::fs::write(&css_path, DOC_CSS) {
|
|
eprintln!("Failed to write style.css: {}", e);
|
|
error_count += 1;
|
|
}
|
|
|
|
println!("Generated documentation in {}/", output_dir);
|
|
println!(" {} modules documented", all_docs.len());
|
|
if error_count > 0 {
|
|
println!(" {} errors", error_count);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collect_lux_files_for_docs(dir: &str, files: &mut Vec<String>) {
|
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_file() && path.extension().map(|e| e == "lux").unwrap_or(false) {
|
|
files.push(path.to_string_lossy().to_string());
|
|
} else if path.is_dir() {
|
|
collect_lux_files_for_docs(&path.to_string_lossy(), files);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ModuleDoc {
|
|
description: Option<String>,
|
|
functions: Vec<FunctionDoc>,
|
|
types: Vec<TypeDoc>,
|
|
effects: Vec<EffectDoc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct FunctionDoc {
|
|
name: String,
|
|
signature: String,
|
|
description: Option<String>,
|
|
is_public: bool,
|
|
properties: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct TypeDoc {
|
|
name: String,
|
|
definition: String,
|
|
description: Option<String>,
|
|
is_public: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct EffectDoc {
|
|
name: String,
|
|
operations: Vec<String>,
|
|
description: Option<String>,
|
|
}
|
|
|
|
fn extract_module_doc(source: &str, path: &str) -> Result<ModuleDoc, String> {
|
|
use modules::ModuleLoader;
|
|
use std::path::Path;
|
|
|
|
let mut loader = ModuleLoader::new();
|
|
let file_path = Path::new(path);
|
|
if let Some(parent) = file_path.parent() {
|
|
loader.add_search_path(parent.to_path_buf());
|
|
}
|
|
|
|
let program = loader.load_source(source, Some(file_path))
|
|
.map_err(|e| format!("{}", e))?;
|
|
|
|
let mut module_desc: Option<String> = None;
|
|
let mut functions = Vec::new();
|
|
let mut types = Vec::new();
|
|
let mut effects = Vec::new();
|
|
let mut pending_doc: Option<String> = None;
|
|
|
|
// Extract module-level comment (first comment before any declarations)
|
|
let lines: Vec<&str> = source.lines().collect();
|
|
let mut module_comment = Vec::new();
|
|
for line in &lines {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with("//") {
|
|
module_comment.push(trimmed.trim_start_matches('/').trim());
|
|
} else if !trimmed.is_empty() {
|
|
break;
|
|
}
|
|
}
|
|
if !module_comment.is_empty() {
|
|
module_desc = Some(module_comment.join("\n"));
|
|
}
|
|
|
|
for decl in &program.declarations {
|
|
match decl {
|
|
ast::Declaration::Function(f) => {
|
|
// Build signature
|
|
let params: Vec<String> = f.params.iter()
|
|
.map(|p| format!("{}: {}", p.name.name, format_type(&p.typ)))
|
|
.collect();
|
|
let effects_str = if f.effects.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" with {{{}}}", f.effects.iter().map(|e| e.name.clone()).collect::<Vec<_>>().join(", "))
|
|
};
|
|
let props: Vec<String> = f.properties.iter()
|
|
.map(|p| format!("{:?}", p).to_lowercase())
|
|
.collect();
|
|
let props_str = if props.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" is {}", props.join(", "))
|
|
};
|
|
|
|
let signature = format!(
|
|
"fn {}({}): {}{}{}",
|
|
f.name.name,
|
|
params.join(", "),
|
|
format_type(&f.return_type),
|
|
props_str,
|
|
effects_str
|
|
);
|
|
|
|
// Extract doc comment
|
|
let doc = extract_doc_comment(source, f.span.start);
|
|
|
|
functions.push(FunctionDoc {
|
|
name: f.name.name.clone(),
|
|
signature,
|
|
description: doc,
|
|
is_public: matches!(f.visibility, ast::Visibility::Public),
|
|
properties: props,
|
|
});
|
|
}
|
|
ast::Declaration::Type(t) => {
|
|
let doc = extract_doc_comment(source, t.span.start);
|
|
types.push(TypeDoc {
|
|
name: t.name.name.clone(),
|
|
definition: format_type_def(t),
|
|
description: doc,
|
|
is_public: matches!(t.visibility, ast::Visibility::Public),
|
|
});
|
|
}
|
|
ast::Declaration::Effect(e) => {
|
|
let doc = extract_doc_comment(source, e.span.start);
|
|
let ops: Vec<String> = e.operations.iter()
|
|
.map(|op| {
|
|
let params: Vec<String> = op.params.iter()
|
|
.map(|p| format!("{}: {}", p.name.name, format_type(&p.typ)))
|
|
.collect();
|
|
format!("{}({}): {}", op.name.name, params.join(", "), format_type(&op.return_type))
|
|
})
|
|
.collect();
|
|
effects.push(EffectDoc {
|
|
name: e.name.name.clone(),
|
|
operations: ops,
|
|
description: doc,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
Ok(ModuleDoc {
|
|
description: module_desc,
|
|
functions,
|
|
types,
|
|
effects,
|
|
})
|
|
}
|
|
|
|
fn extract_doc_comment(source: &str, pos: usize) -> Option<String> {
|
|
// Look backwards from the declaration for doc comments
|
|
let prefix = &source[..pos];
|
|
let lines: Vec<&str> = prefix.lines().collect();
|
|
|
|
let mut doc_lines = Vec::new();
|
|
for line in lines.iter().rev() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with("///") {
|
|
doc_lines.push(trimmed.trim_start_matches('/').trim());
|
|
} else if trimmed.starts_with("//") {
|
|
// Regular comment, skip
|
|
continue;
|
|
} else if trimmed.is_empty() {
|
|
if !doc_lines.is_empty() {
|
|
break;
|
|
}
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if doc_lines.is_empty() {
|
|
None
|
|
} else {
|
|
doc_lines.reverse();
|
|
Some(doc_lines.join("\n"))
|
|
}
|
|
}
|
|
|
|
fn format_type(t: &ast::TypeExpr) -> String {
|
|
match t {
|
|
ast::TypeExpr::Named(ident) => ident.name.clone(),
|
|
ast::TypeExpr::App(base, args) => {
|
|
let args_str: Vec<String> = args.iter().map(format_type).collect();
|
|
format!("{}<{}>", format_type(base), args_str.join(", "))
|
|
}
|
|
ast::TypeExpr::Function { params, return_type, .. } => {
|
|
let params_str: Vec<String> = params.iter().map(format_type).collect();
|
|
format!("fn({}): {}", params_str.join(", "), format_type(return_type))
|
|
}
|
|
ast::TypeExpr::Tuple(types) => {
|
|
let types_str: Vec<String> = types.iter().map(format_type).collect();
|
|
format!("({})", types_str.join(", "))
|
|
}
|
|
ast::TypeExpr::Record(fields) => {
|
|
let fields_str: Vec<String> = fields.iter()
|
|
.map(|f| format!("{}: {}", f.name.name, format_type(&f.typ)))
|
|
.collect();
|
|
format!("{{ {} }}", fields_str.join(", "))
|
|
}
|
|
ast::TypeExpr::Unit => "Unit".to_string(),
|
|
ast::TypeExpr::Versioned { base, .. } => format_type(base),
|
|
}
|
|
}
|
|
|
|
fn format_type_def(t: &ast::TypeDecl) -> String {
|
|
match &t.definition {
|
|
ast::TypeDef::Alias(typ) => format!("type {} = {}", t.name.name, format_type(typ)),
|
|
ast::TypeDef::Enum(variants) => {
|
|
let variants_str: Vec<String> = variants.iter()
|
|
.map(|v| {
|
|
match &v.fields {
|
|
ast::VariantFields::Unit => v.name.name.clone(),
|
|
ast::VariantFields::Tuple(types) => {
|
|
let types_str: Vec<String> = types.iter().map(format_type).collect();
|
|
format!("{}({})", v.name.name, types_str.join(", "))
|
|
}
|
|
ast::VariantFields::Record(fields) => {
|
|
let fields_str: Vec<String> = fields.iter()
|
|
.map(|f| format!("{}: {}", f.name.name, format_type(&f.typ)))
|
|
.collect();
|
|
format!("{}{{ {} }}", v.name.name, fields_str.join(", "))
|
|
}
|
|
}
|
|
})
|
|
.collect();
|
|
format!("type {} = {}", t.name.name, variants_str.join(" | "))
|
|
}
|
|
ast::TypeDef::Record(fields) => {
|
|
let fields_str: Vec<String> = fields.iter()
|
|
.map(|f| format!("{}: {}", f.name.name, format_type(&f.typ)))
|
|
.collect();
|
|
format!("type {} = {{ {} }}", t.name.name, fields_str.join(", "))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn docs_to_json(docs: &std::collections::HashMap<String, ModuleDoc>) -> String {
|
|
let mut json = String::from("{\n");
|
|
let mut first_module = true;
|
|
|
|
for (name, doc) in docs {
|
|
if !first_module {
|
|
json.push_str(",\n");
|
|
}
|
|
first_module = false;
|
|
|
|
json.push_str(&format!(" \"{}\": {{\n", escape_json(name)));
|
|
|
|
if let Some(desc) = &doc.description {
|
|
json.push_str(&format!(" \"description\": \"{}\",\n", escape_json(desc)));
|
|
}
|
|
|
|
// Functions
|
|
json.push_str(" \"functions\": [\n");
|
|
for (i, f) in doc.functions.iter().enumerate() {
|
|
json.push_str(&format!(
|
|
" {{\"name\": \"{}\", \"signature\": \"{}\", \"public\": {}, \"description\": {}}}",
|
|
escape_json(&f.name),
|
|
escape_json(&f.signature),
|
|
f.is_public,
|
|
f.description.as_ref().map(|d| format!("\"{}\"", escape_json(d))).unwrap_or("null".to_string())
|
|
));
|
|
if i < doc.functions.len() - 1 {
|
|
json.push(',');
|
|
}
|
|
json.push('\n');
|
|
}
|
|
json.push_str(" ],\n");
|
|
|
|
// Types
|
|
json.push_str(" \"types\": [\n");
|
|
for (i, t) in doc.types.iter().enumerate() {
|
|
json.push_str(&format!(
|
|
" {{\"name\": \"{}\", \"definition\": \"{}\", \"public\": {}, \"description\": {}}}",
|
|
escape_json(&t.name),
|
|
escape_json(&t.definition),
|
|
t.is_public,
|
|
t.description.as_ref().map(|d| format!("\"{}\"", escape_json(d))).unwrap_or("null".to_string())
|
|
));
|
|
if i < doc.types.len() - 1 {
|
|
json.push(',');
|
|
}
|
|
json.push('\n');
|
|
}
|
|
json.push_str(" ],\n");
|
|
|
|
// Effects
|
|
json.push_str(" \"effects\": [\n");
|
|
for (i, e) in doc.effects.iter().enumerate() {
|
|
let ops_json: Vec<String> = e.operations.iter()
|
|
.map(|o| format!("\"{}\"", escape_json(o)))
|
|
.collect();
|
|
json.push_str(&format!(
|
|
" {{\"name\": \"{}\", \"operations\": [{}], \"description\": {}}}",
|
|
escape_json(&e.name),
|
|
ops_json.join(", "),
|
|
e.description.as_ref().map(|d| format!("\"{}\"", escape_json(d))).unwrap_or("null".to_string())
|
|
));
|
|
if i < doc.effects.len() - 1 {
|
|
json.push(',');
|
|
}
|
|
json.push('\n');
|
|
}
|
|
json.push_str(" ]\n");
|
|
|
|
json.push_str(" }");
|
|
}
|
|
|
|
json.push_str("\n}");
|
|
json
|
|
}
|
|
|
|
fn escape_json(s: &str) -> String {
|
|
s.replace('\\', "\\\\")
|
|
.replace('"', "\\\"")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "\\r")
|
|
.replace('\t', "\\t")
|
|
}
|
|
|
|
fn generate_index_html(docs: &std::collections::HashMap<String, ModuleDoc>) -> String {
|
|
let mut html = String::from(r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Lux API Documentation</title>
|
|
<link rel="stylesheet" href="style.css">
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>Lux API Documentation</h1>
|
|
</header>
|
|
<main>
|
|
<h2>Modules</h2>
|
|
<ul class="module-list">
|
|
"#);
|
|
|
|
let mut modules: Vec<_> = docs.keys().collect();
|
|
modules.sort();
|
|
|
|
for name in modules {
|
|
let doc = &docs[name];
|
|
let desc = doc.description.as_ref()
|
|
.map(|d| d.lines().next().unwrap_or(""))
|
|
.unwrap_or("");
|
|
html.push_str(&format!(
|
|
" <li><a href=\"{}.html\">{}</a> - {}</li>\n",
|
|
name, name, html_escape(desc)
|
|
));
|
|
}
|
|
|
|
html.push_str(r#" </ul>
|
|
</main>
|
|
</body>
|
|
</html>"#);
|
|
|
|
html
|
|
}
|
|
|
|
fn generate_module_html(name: &str, doc: &ModuleDoc) -> String {
|
|
let mut html = format!(r#"<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{} - Lux API</title>
|
|
<link rel="stylesheet" href="style.css">
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<a href="index.html">Back to Index</a>
|
|
<h1>{}</h1>
|
|
</header>
|
|
<main>
|
|
"#, name, name);
|
|
|
|
if let Some(desc) = &doc.description {
|
|
html.push_str(&format!(" <div class=\"module-description\">{}</div>\n", html_escape(desc)));
|
|
}
|
|
|
|
// Types
|
|
if !doc.types.is_empty() {
|
|
html.push_str(" <section>\n <h2>Types</h2>\n");
|
|
for t in &doc.types {
|
|
let visibility = if t.is_public { "pub " } else { "" };
|
|
html.push_str(&format!(
|
|
" <div class=\"item\">\n <code class=\"signature\">{}{}</code>\n",
|
|
visibility, html_escape(&t.definition)
|
|
));
|
|
if let Some(desc) = &t.description {
|
|
html.push_str(&format!(" <p class=\"description\">{}</p>\n", html_escape(desc)));
|
|
}
|
|
html.push_str(" </div>\n");
|
|
}
|
|
html.push_str(" </section>\n");
|
|
}
|
|
|
|
// Effects
|
|
if !doc.effects.is_empty() {
|
|
html.push_str(" <section>\n <h2>Effects</h2>\n");
|
|
for e in &doc.effects {
|
|
html.push_str(&format!(
|
|
" <div class=\"item\">\n <h3>effect {}</h3>\n",
|
|
html_escape(&e.name)
|
|
));
|
|
if let Some(desc) = &e.description {
|
|
html.push_str(&format!(" <p class=\"description\">{}</p>\n", html_escape(desc)));
|
|
}
|
|
html.push_str(" <ul class=\"operations\">\n");
|
|
for op in &e.operations {
|
|
html.push_str(&format!(" <li><code>{}</code></li>\n", html_escape(op)));
|
|
}
|
|
html.push_str(" </ul>\n </div>\n");
|
|
}
|
|
html.push_str(" </section>\n");
|
|
}
|
|
|
|
// Functions
|
|
if !doc.functions.is_empty() {
|
|
html.push_str(" <section>\n <h2>Functions</h2>\n");
|
|
for f in &doc.functions {
|
|
let visibility = if f.is_public { "pub " } else { "" };
|
|
html.push_str(&format!(
|
|
" <div class=\"item\" id=\"{}\">\n <code class=\"signature\">{}{}</code>\n",
|
|
f.name, visibility, html_escape(&f.signature)
|
|
));
|
|
if let Some(desc) = &f.description {
|
|
html.push_str(&format!(" <p class=\"description\">{}</p>\n", html_escape(desc)));
|
|
}
|
|
html.push_str(" </div>\n");
|
|
}
|
|
html.push_str(" </section>\n");
|
|
}
|
|
|
|
html.push_str(r#" </main>
|
|
</body>
|
|
</html>"#);
|
|
|
|
html
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
}
|
|
|
|
const DOC_CSS: &str = r#"
|
|
:root {
|
|
--bg-color: #1a1a2e;
|
|
--text-color: #e0e0e0;
|
|
--link-color: #64b5f6;
|
|
--code-bg: #16213e;
|
|
--header-bg: #0f3460;
|
|
--accent: #e94560;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background-color: var(--bg-color);
|
|
color: var(--text-color);
|
|
margin: 0;
|
|
padding: 0;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
header {
|
|
background-color: var(--header-bg);
|
|
padding: 1rem 2rem;
|
|
border-bottom: 2px solid var(--accent);
|
|
}
|
|
|
|
header h1 {
|
|
margin: 0;
|
|
color: white;
|
|
}
|
|
|
|
header a {
|
|
color: var(--link-color);
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
main {
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
padding: 2rem;
|
|
}
|
|
|
|
h2 {
|
|
color: var(--accent);
|
|
border-bottom: 1px solid var(--accent);
|
|
padding-bottom: 0.5rem;
|
|
}
|
|
|
|
h3 {
|
|
color: var(--link-color);
|
|
}
|
|
|
|
.module-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
}
|
|
|
|
.module-list li {
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.module-list a {
|
|
color: var(--link-color);
|
|
text-decoration: none;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.item {
|
|
background-color: var(--code-bg);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
margin: 1rem 0;
|
|
}
|
|
|
|
.signature {
|
|
display: block;
|
|
background-color: rgba(0,0,0,0.3);
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 4px;
|
|
font-family: 'Fira Code', 'Monaco', monospace;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.description {
|
|
margin-top: 0.5rem;
|
|
color: #aaa;
|
|
}
|
|
|
|
.operations {
|
|
list-style: none;
|
|
padding-left: 1rem;
|
|
}
|
|
|
|
.operations li {
|
|
padding: 0.25rem 0;
|
|
}
|
|
|
|
.module-description {
|
|
background-color: var(--code-bg);
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 2rem;
|
|
border-left: 3px solid var(--accent);
|
|
}
|
|
"#;
|
|
|
|
fn run_file(path: &str) {
|
|
use modules::ModuleLoader;
|
|
use std::path::Path;
|
|
|
|
let file_path = Path::new(path);
|
|
let source = match std::fs::read_to_string(file_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
eprintln!("Error reading file '{}': {}", path, e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
// Set up module loader with the file's directory as a search path
|
|
let mut loader = ModuleLoader::new();
|
|
if let Some(parent) = file_path.parent() {
|
|
loader.add_search_path(parent.to_path_buf());
|
|
}
|
|
|
|
// Load and parse the program (including any imports)
|
|
let program = match loader.load_source(&source, Some(file_path)) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
eprintln!("Module error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
let mut checker = TypeChecker::new();
|
|
if let Err(errors) = checker.check_program_with_modules(&program, &loader) {
|
|
for error in errors {
|
|
let diagnostic = error.to_diagnostic();
|
|
eprint!("{}", render(&diagnostic, &source, Some(path)));
|
|
}
|
|
std::process::exit(1);
|
|
}
|
|
|
|
let mut interp = Interpreter::new();
|
|
match interp.run_with_modules(&program, &loader) {
|
|
Ok(value) => {
|
|
if !matches!(value, interpreter::Value::Unit) {
|
|
println!("{}", value);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let diagnostic = e.to_diagnostic();
|
|
eprint!("{}", render(&diagnostic, &source, Some(path)));
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// REPL helper for tab completion and syntax highlighting
|
|
struct LuxHelper {
|
|
keywords: HashSet<String>,
|
|
commands: Vec<String>,
|
|
user_defined: HashSet<String>,
|
|
last_loaded_file: Option<String>,
|
|
}
|
|
|
|
impl LuxHelper {
|
|
fn new() -> Self {
|
|
let keywords: HashSet<String> = vec![
|
|
"fn", "let", "if", "then", "else", "match", "with", "effect", "handler",
|
|
"handle", "resume", "type", "import", "pub", "is", "pure", "total",
|
|
"idempotent", "deterministic", "commutative", "where", "assume", "true",
|
|
"false", "None", "Some", "Ok", "Err", "Int", "Float", "String", "Bool",
|
|
"Char", "Unit", "Option", "Result", "List",
|
|
]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
|
|
let commands = vec![
|
|
":help", ":h", ":quit", ":q", ":type", ":t", ":clear", ":load", ":l",
|
|
":reload", ":r", ":trace", ":traces", ":info", ":i", ":env", ":doc", ":d",
|
|
":browse", ":b", ":ast",
|
|
]
|
|
.into_iter()
|
|
.map(String::from)
|
|
.collect();
|
|
|
|
Self {
|
|
keywords,
|
|
commands,
|
|
user_defined: HashSet::new(),
|
|
last_loaded_file: None,
|
|
}
|
|
}
|
|
|
|
fn add_definition(&mut self, name: &str) {
|
|
self.user_defined.insert(name.to_string());
|
|
}
|
|
}
|
|
|
|
impl Completer for LuxHelper {
|
|
type Candidate = Pair;
|
|
|
|
fn complete(
|
|
&self,
|
|
line: &str,
|
|
pos: usize,
|
|
_ctx: &rustyline::Context<'_>,
|
|
) -> rustyline::Result<(usize, Vec<Pair>)> {
|
|
// Find the start of the current word
|
|
let start = line[..pos]
|
|
.rfind(|c: char| c.is_whitespace() || "(){}[],.;:".contains(c))
|
|
.map(|i| i + 1)
|
|
.unwrap_or(0);
|
|
|
|
let prefix = &line[start..pos];
|
|
|
|
if prefix.is_empty() {
|
|
return Ok((pos, Vec::new()));
|
|
}
|
|
|
|
let mut completions = Vec::new();
|
|
|
|
// Complete commands
|
|
if prefix.starts_with(':') {
|
|
for cmd in &self.commands {
|
|
if cmd.starts_with(prefix) {
|
|
completions.push(Pair {
|
|
display: cmd.clone(),
|
|
replacement: cmd.clone(),
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
// Complete keywords
|
|
for kw in &self.keywords {
|
|
if kw.starts_with(prefix) {
|
|
completions.push(Pair {
|
|
display: kw.clone(),
|
|
replacement: kw.clone(),
|
|
});
|
|
}
|
|
}
|
|
// Complete user-defined names
|
|
for name in &self.user_defined {
|
|
if name.starts_with(prefix) {
|
|
completions.push(Pair {
|
|
display: name.clone(),
|
|
replacement: name.clone(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok((start, completions))
|
|
}
|
|
}
|
|
|
|
impl Hinter for LuxHelper {
|
|
type Hint = String;
|
|
|
|
fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
|
|
None
|
|
}
|
|
}
|
|
|
|
impl Highlighter for LuxHelper {
|
|
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
|
|
Cow::Owned(format!("\x1b[90m{}\x1b[0m", hint))
|
|
}
|
|
|
|
fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
|
|
let mut result = String::with_capacity(line.len() * 2);
|
|
let mut chars = line.char_indices().peekable();
|
|
|
|
while let Some((i, c)) = chars.next() {
|
|
if c == '"' {
|
|
// String literal - highlight in green
|
|
result.push_str("\x1b[32m\"");
|
|
let mut escaped = false;
|
|
for (_, ch) in chars.by_ref() {
|
|
result.push(ch);
|
|
if escaped {
|
|
escaped = false;
|
|
} else if ch == '\\' {
|
|
escaped = true;
|
|
} else if ch == '"' {
|
|
break;
|
|
}
|
|
}
|
|
result.push_str("\x1b[0m");
|
|
} else if c == ':' && i == 0 {
|
|
// Command - highlight in cyan
|
|
result.push_str("\x1b[36m:");
|
|
for (_, ch) in chars.by_ref() {
|
|
if ch.is_whitespace() {
|
|
result.push_str("\x1b[0m");
|
|
result.push(ch);
|
|
break;
|
|
}
|
|
result.push(ch);
|
|
}
|
|
// Continue with the rest
|
|
for (_, ch) in chars.by_ref() {
|
|
result.push(ch);
|
|
}
|
|
} else if c.is_ascii_digit() || (c == '-' && chars.peek().map(|(_, ch)| ch.is_ascii_digit()).unwrap_or(false)) {
|
|
// Number - highlight in yellow
|
|
result.push_str("\x1b[33m");
|
|
result.push(c);
|
|
while let Some(&(_, ch)) = chars.peek() {
|
|
if ch.is_ascii_digit() || ch == '.' {
|
|
result.push(ch);
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
result.push_str("\x1b[0m");
|
|
} else if c.is_alphabetic() || c == '_' {
|
|
// Identifier or keyword
|
|
let start = i;
|
|
let mut end = i + c.len_utf8();
|
|
while let Some(&(j, ch)) = chars.peek() {
|
|
if ch.is_alphanumeric() || ch == '_' {
|
|
end = j + ch.len_utf8();
|
|
chars.next();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
let word = &line[start..end];
|
|
|
|
// Check if it's a keyword
|
|
if self.keywords.contains(word) {
|
|
result.push_str("\x1b[35m"); // Magenta for keywords
|
|
result.push_str(word);
|
|
result.push_str("\x1b[0m");
|
|
} else if word.starts_with(char::is_uppercase) {
|
|
result.push_str("\x1b[34m"); // Blue for types/constructors
|
|
result.push_str(word);
|
|
result.push_str("\x1b[0m");
|
|
} else {
|
|
result.push_str(word);
|
|
}
|
|
} else if c == '/' && chars.peek().map(|(_, ch)| *ch == '/').unwrap_or(false) {
|
|
// Comment - highlight in gray
|
|
result.push_str("\x1b[90m");
|
|
result.push(c);
|
|
for (_, ch) in chars.by_ref() {
|
|
result.push(ch);
|
|
}
|
|
result.push_str("\x1b[0m");
|
|
} else {
|
|
result.push(c);
|
|
}
|
|
}
|
|
|
|
Cow::Owned(result)
|
|
}
|
|
|
|
fn highlight_char(&self, _line: &str, _pos: usize, _forced: bool) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
impl Validator for LuxHelper {}
|
|
impl Helper for LuxHelper {}
|
|
|
|
fn get_history_path() -> Option<std::path::PathBuf> {
|
|
std::env::var_os("HOME").map(|home| {
|
|
std::path::PathBuf::from(home).join(".lux_history")
|
|
})
|
|
}
|
|
|
|
fn run_repl() {
|
|
println!("Lux v{}", VERSION);
|
|
println!("Type :help for help, :quit to exit\n");
|
|
|
|
let config = Config::builder()
|
|
.history_ignore_space(true)
|
|
.completion_type(rustyline::CompletionType::List)
|
|
.edit_mode(rustyline::EditMode::Emacs)
|
|
.build();
|
|
|
|
let helper = LuxHelper::new();
|
|
let mut rl: Editor<LuxHelper, DefaultHistory> = Editor::with_config(config).unwrap();
|
|
rl.set_helper(Some(helper));
|
|
|
|
// Load history
|
|
if let Some(history_path) = get_history_path() {
|
|
if let Some(parent) = history_path.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let _ = rl.load_history(&history_path);
|
|
}
|
|
|
|
let mut interp = Interpreter::new();
|
|
let mut checker = TypeChecker::new();
|
|
let mut buffer = String::new();
|
|
let mut continuation = false;
|
|
|
|
loop {
|
|
let prompt = if continuation { "... " } else { "lux> " };
|
|
|
|
match rl.readline(prompt) {
|
|
Ok(line) => {
|
|
let line = line.trim_end().to_string();
|
|
|
|
// Don't add empty lines or continuations to history
|
|
if !line.is_empty() && !continuation {
|
|
let _ = rl.add_history_entry(line.as_str());
|
|
}
|
|
|
|
// Handle commands
|
|
if !continuation && line.starts_with(':') {
|
|
handle_command(&line, &mut interp, &mut checker, rl.helper_mut().unwrap());
|
|
continue;
|
|
}
|
|
|
|
// Accumulate input
|
|
buffer.push_str(&line);
|
|
buffer.push('\n');
|
|
|
|
// Check for continuation (unbalanced braces/parens)
|
|
let open_braces = buffer.chars().filter(|c| *c == '{').count();
|
|
let close_braces = buffer.chars().filter(|c| *c == '}').count();
|
|
let open_parens = buffer.chars().filter(|c| *c == '(').count();
|
|
let close_parens = buffer.chars().filter(|c| *c == ')').count();
|
|
|
|
if open_braces > close_braces || open_parens > close_parens {
|
|
continuation = true;
|
|
continue;
|
|
}
|
|
|
|
continuation = false;
|
|
let input = std::mem::take(&mut buffer);
|
|
|
|
if input.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
// Track new definitions for completion
|
|
if let Some(name) = extract_definition_name(&input) {
|
|
rl.helper_mut().unwrap().add_definition(&name);
|
|
}
|
|
|
|
eval_input(&input, &mut interp, &mut checker);
|
|
}
|
|
Err(ReadlineError::Interrupted) => {
|
|
// Ctrl-C: clear buffer
|
|
buffer.clear();
|
|
continuation = false;
|
|
println!("^C");
|
|
}
|
|
Err(ReadlineError::Eof) => {
|
|
// Ctrl-D: exit
|
|
break;
|
|
}
|
|
Err(err) => {
|
|
eprintln!("Error: {:?}", err);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save history
|
|
if let Some(history_path) = get_history_path() {
|
|
let _ = rl.save_history(&history_path);
|
|
}
|
|
|
|
println!("\nGoodbye!");
|
|
}
|
|
|
|
fn extract_definition_name(input: &str) -> Option<String> {
|
|
let input = input.trim();
|
|
if input.starts_with("fn ") {
|
|
input.split_whitespace().nth(1).and_then(|s| {
|
|
s.split('(').next().map(String::from)
|
|
})
|
|
} else if input.starts_with("let ") {
|
|
input.split_whitespace().nth(1).and_then(|s| {
|
|
s.split([':', '=']).next().map(|s| s.trim().to_string())
|
|
})
|
|
} else if input.starts_with("type ") {
|
|
input.split_whitespace().nth(1).and_then(|s| {
|
|
s.split(['<', '=']).next().map(|s| s.trim().to_string())
|
|
})
|
|
} else if input.starts_with("effect ") {
|
|
input.split_whitespace().nth(1).map(|s| {
|
|
s.trim_end_matches('{').trim().to_string()
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn handle_command(
|
|
line: &str,
|
|
interp: &mut Interpreter,
|
|
checker: &mut TypeChecker,
|
|
helper: &mut LuxHelper,
|
|
) {
|
|
let parts: Vec<&str> = line.splitn(2, ' ').collect();
|
|
let cmd = parts[0];
|
|
let arg = parts.get(1).map(|s| s.trim());
|
|
|
|
match cmd {
|
|
":help" | ":h" => {
|
|
println!("{}", HELP);
|
|
}
|
|
":quit" | ":q" => {
|
|
println!("Goodbye!");
|
|
std::process::exit(0);
|
|
}
|
|
":type" | ":t" => {
|
|
if let Some(expr_str) = arg {
|
|
show_type(expr_str, checker);
|
|
} else {
|
|
println!("Usage: :type <expression>");
|
|
}
|
|
}
|
|
":info" | ":i" => {
|
|
if let Some(name) = arg {
|
|
show_info(name, checker);
|
|
} else {
|
|
println!("Usage: :info <name>");
|
|
}
|
|
}
|
|
":env" => {
|
|
show_environment(checker, helper);
|
|
}
|
|
":clear" => {
|
|
*interp = Interpreter::new();
|
|
*checker = TypeChecker::new();
|
|
helper.user_defined.clear();
|
|
println!("Environment cleared.");
|
|
}
|
|
":load" | ":l" => {
|
|
if let Some(path) = arg {
|
|
helper.last_loaded_file = Some(path.to_string());
|
|
load_file(path, interp, checker, helper);
|
|
} else {
|
|
println!("Usage: :load <filename>");
|
|
}
|
|
}
|
|
":reload" | ":r" => {
|
|
if let Some(ref path) = helper.last_loaded_file.clone() {
|
|
println!("Reloading {}...", path);
|
|
// Clear environment first
|
|
*interp = Interpreter::new();
|
|
*checker = TypeChecker::new();
|
|
helper.user_defined.clear();
|
|
load_file(path, interp, checker, helper);
|
|
} else {
|
|
println!("No file to reload. Use :load <file> first.");
|
|
}
|
|
}
|
|
":ast" => {
|
|
if let Some(expr_str) = arg {
|
|
show_ast(expr_str);
|
|
} else {
|
|
println!("Usage: :ast <expression>");
|
|
}
|
|
}
|
|
":trace" => match arg {
|
|
Some("on") => {
|
|
interp.enable_tracing();
|
|
println!("Effect tracing enabled.");
|
|
}
|
|
Some("off") => {
|
|
interp.trace_effects = false;
|
|
println!("Effect tracing disabled.");
|
|
}
|
|
_ => {
|
|
println!("Usage: :trace on|off");
|
|
}
|
|
},
|
|
":traces" => {
|
|
if interp.get_traces().is_empty() {
|
|
println!("No effect traces recorded. Use :trace on to enable tracing.");
|
|
} else {
|
|
interp.print_traces();
|
|
}
|
|
}
|
|
":doc" | ":d" => {
|
|
if let Some(name) = arg {
|
|
show_doc(name);
|
|
} else {
|
|
println!("Usage: :doc <name>");
|
|
}
|
|
}
|
|
":browse" | ":b" => {
|
|
if let Some(module) = arg {
|
|
browse_module(module);
|
|
} else {
|
|
println!("Usage: :browse <module>");
|
|
println!("Available modules: List, String, Option, Result, Console, Random, File, Http");
|
|
}
|
|
}
|
|
_ => {
|
|
println!("Unknown command: {}", cmd);
|
|
println!("Type :help for help");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_info(name: &str, checker: &TypeChecker) {
|
|
// Look up in the type environment
|
|
if let Some(scheme) = checker.lookup(name) {
|
|
println!("{} : {}", name, scheme);
|
|
} else {
|
|
println!("'{}' is not defined.", name);
|
|
}
|
|
}
|
|
|
|
fn show_environment(checker: &TypeChecker, helper: &LuxHelper) {
|
|
println!("User-defined bindings:");
|
|
if helper.user_defined.is_empty() {
|
|
println!(" (none)");
|
|
} else {
|
|
for name in &helper.user_defined {
|
|
if let Some(scheme) = checker.lookup(name) {
|
|
println!(" {} : {}", name, scheme);
|
|
} else {
|
|
println!(" {} : <unknown>", name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_doc(name: &str) {
|
|
// Built-in documentation for common functions
|
|
let doc = match name {
|
|
// List functions
|
|
"List.length" => Some(("List.length : List<T> -> Int", "Returns the number of elements in a list.")),
|
|
"List.head" => Some(("List.head : List<T> -> Option<T>", "Returns the first element of a list, or None if empty.")),
|
|
"List.tail" => Some(("List.tail : List<T> -> Option<List<T>>", "Returns all elements except the first, or None if empty.")),
|
|
"List.map" => Some(("List.map : (List<A>, fn(A) -> B) -> List<B>", "Applies a function to each element, returning a new list.")),
|
|
"List.filter" => Some(("List.filter : (List<T>, fn(T) -> Bool) -> List<T>", "Returns elements for which the predicate returns true.")),
|
|
"List.fold" => Some(("List.fold : (List<T>, U, fn(U, T) -> U) -> U", "Reduces a list to a single value using an accumulator function.")),
|
|
"List.reverse" => Some(("List.reverse : List<T> -> List<T>", "Returns a new list with elements in reverse order.")),
|
|
"List.concat" => Some(("List.concat : (List<T>, List<T>) -> List<T>", "Concatenates two lists.")),
|
|
"List.take" => Some(("List.take : (List<T>, Int) -> List<T>", "Returns the first n elements.")),
|
|
"List.drop" => Some(("List.drop : (List<T>, Int) -> List<T>", "Returns all elements after the first n.")),
|
|
"List.get" => Some(("List.get : (List<T>, Int) -> Option<T>", "Returns the element at index n, or None if out of bounds.")),
|
|
"List.contains" => Some(("List.contains : (List<T>, T) -> Bool", "Returns true if the list contains the element.")),
|
|
"List.all" => Some(("List.all : (List<T>, fn(T) -> Bool) -> Bool", "Returns true if all elements satisfy the predicate.")),
|
|
"List.any" => Some(("List.any : (List<T>, fn(T) -> Bool) -> Bool", "Returns true if any element satisfies the predicate.")),
|
|
"List.find" => Some(("List.find : (List<T>, fn(T) -> Bool) -> Option<T>", "Returns the first element satisfying the predicate.")),
|
|
"List.sort" => Some(("List.sort : List<Int> -> List<Int>", "Sorts a list of integers in ascending order.")),
|
|
"List.range" => Some(("List.range : (Int, Int) -> List<Int>", "Creates a list of integers from start to end (exclusive).")),
|
|
|
|
// String functions
|
|
"String.length" => Some(("String.length : String -> Int", "Returns the number of characters in a string.")),
|
|
"String.concat" => Some(("String.concat : (String, String) -> String", "Concatenates two strings.")),
|
|
"String.substring" => Some(("String.substring : (String, Int, Int) -> String", "Returns a substring from start to end index.")),
|
|
"String.split" => Some(("String.split : (String, String) -> List<String>", "Splits a string by a delimiter.")),
|
|
"String.join" => Some(("String.join : (List<String>, String) -> String", "Joins a list of strings with a delimiter.")),
|
|
"String.trim" => Some(("String.trim : String -> String", "Removes leading and trailing whitespace.")),
|
|
"String.contains" => Some(("String.contains : (String, String) -> Bool", "Returns true if the string contains the substring.")),
|
|
"String.replace" => Some(("String.replace : (String, String, String) -> String", "Replaces all occurrences of a pattern.")),
|
|
"String.startsWith" => Some(("String.startsWith : (String, String) -> Bool", "Returns true if the string starts with the prefix.")),
|
|
"String.endsWith" => Some(("String.endsWith : (String, String) -> Bool", "Returns true if the string ends with the suffix.")),
|
|
"String.toUpper" => Some(("String.toUpper : String -> String", "Converts to uppercase.")),
|
|
"String.toLower" => Some(("String.toLower : String -> String", "Converts to lowercase.")),
|
|
|
|
// Option functions
|
|
"Option.map" => Some(("Option.map : (Option<A>, fn(A) -> B) -> Option<B>", "Applies a function to the value inside Some, or returns None.")),
|
|
"Option.flatMap" => Some(("Option.flatMap : (Option<A>, fn(A) -> Option<B>) -> Option<B>", "Like map, but the function returns an Option.")),
|
|
"Option.getOrElse" => Some(("Option.getOrElse : (Option<T>, T) -> T", "Returns the value inside Some, or the default if None.")),
|
|
"Option.isSome" => Some(("Option.isSome : Option<T> -> Bool", "Returns true if the value is Some.")),
|
|
"Option.isNone" => Some(("Option.isNone : Option<T> -> Bool", "Returns true if the value is None.")),
|
|
|
|
// Result functions
|
|
"Result.map" => Some(("Result.map : (Result<A, E>, fn(A) -> B) -> Result<B, E>", "Applies a function to the Ok value.")),
|
|
"Result.flatMap" => Some(("Result.flatMap : (Result<A, E>, fn(A) -> Result<B, E>) -> Result<B, E>", "Like map, but the function returns a Result.")),
|
|
"Result.getOrElse" => Some(("Result.getOrElse : (Result<T, E>, T) -> T", "Returns the Ok value, or the default if Err.")),
|
|
"Result.isOk" => Some(("Result.isOk : Result<T, E> -> Bool", "Returns true if the value is Ok.")),
|
|
"Result.isErr" => Some(("Result.isErr : Result<T, E> -> Bool", "Returns true if the value is Err.")),
|
|
|
|
// Effects
|
|
"Console.print" => Some(("Console.print : String -> Unit", "Prints a string to the console.")),
|
|
"Console.readLine" => Some(("Console.readLine : () -> String", "Reads a line from standard input.")),
|
|
"Random.int" => Some(("Random.int : (Int, Int) -> Int", "Generates a random integer in the range [min, max].")),
|
|
"Random.float" => Some(("Random.float : () -> Float", "Generates a random float in [0, 1).")),
|
|
"Random.bool" => Some(("Random.bool : () -> Bool", "Generates a random boolean.")),
|
|
"File.read" => Some(("File.read : String -> String", "Reads the contents of a file.")),
|
|
"File.write" => Some(("File.write : (String, String) -> Unit", "Writes content to a file.")),
|
|
"File.exists" => Some(("File.exists : String -> Bool", "Returns true if the file exists.")),
|
|
"Http.get" => Some(("Http.get : String -> String", "Performs an HTTP GET request.")),
|
|
"Http.post" => Some(("Http.post : (String, String) -> String", "Performs an HTTP POST request with a body.")),
|
|
"Time.now" => Some(("Time.now : () -> Int", "Returns the current Unix timestamp in milliseconds.")),
|
|
"Sql.open" => Some(("Sql.open : String -> SqlConn", "Opens a SQLite database file.")),
|
|
"Sql.openMemory" => Some(("Sql.openMemory : () -> SqlConn", "Opens an in-memory SQLite database.")),
|
|
"Sql.query" => Some(("Sql.query : (SqlConn, String) -> List<Row>", "Executes a SQL query and returns all rows.")),
|
|
"Sql.execute" => Some(("Sql.execute : (SqlConn, String) -> Int", "Executes a SQL statement and returns affected rows.")),
|
|
"Postgres.connect" => Some(("Postgres.connect : String -> Int", "Connects to a PostgreSQL database.")),
|
|
"Postgres.query" => Some(("Postgres.query : (Int, String) -> List<Row>", "Executes a SQL query on PostgreSQL.")),
|
|
"Postgres.execute" => Some(("Postgres.execute : (Int, String) -> Int", "Executes a SQL statement on PostgreSQL.")),
|
|
|
|
// Language constructs
|
|
"fn" => Some(("fn name(args): Type = body", "Defines a function.")),
|
|
"let" => Some(("let name = value", "Binds a value to a name.")),
|
|
"if" => Some(("if condition then expr1 else expr2", "Conditional expression.")),
|
|
"match" => Some(("match value { pattern => expr, ... }", "Pattern matching expression.")),
|
|
"effect" => Some(("effect Name { fn op(...): Type }", "Defines an effect with operations.")),
|
|
"handler" => Some(("handler { op => body }", "Defines an effect handler.")),
|
|
"with" => Some(("fn f(): T with {Effect1, Effect2}", "Declares effects a function may perform.")),
|
|
"type" => Some(("type Name<T> = ...", "Defines a type alias.")),
|
|
"run" => Some(("run expr with { handler }", "Executes an expression with effect handlers.")),
|
|
|
|
_ => None,
|
|
};
|
|
|
|
match doc {
|
|
Some((sig, desc)) => {
|
|
println!("\x1b[1m{}\x1b[0m", sig);
|
|
println!();
|
|
println!(" {}", desc);
|
|
}
|
|
None => {
|
|
println!("No documentation for '{}'", name);
|
|
println!("Try :doc List.map or :browse List");
|
|
}
|
|
}
|
|
}
|
|
|
|
fn browse_module(module: &str) {
|
|
let exports: Vec<(&str, &str)> = match module {
|
|
"List" => vec![
|
|
("length", "List<T> -> Int"),
|
|
("head", "List<T> -> Option<T>"),
|
|
("tail", "List<T> -> Option<List<T>>"),
|
|
("get", "(List<T>, Int) -> Option<T>"),
|
|
("map", "(List<A>, fn(A) -> B) -> List<B>"),
|
|
("filter", "(List<T>, fn(T) -> Bool) -> List<T>"),
|
|
("fold", "(List<T>, U, fn(U, T) -> U) -> U"),
|
|
("reverse", "List<T> -> List<T>"),
|
|
("concat", "(List<T>, List<T>) -> List<T>"),
|
|
("take", "(List<T>, Int) -> List<T>"),
|
|
("drop", "(List<T>, Int) -> List<T>"),
|
|
("contains", "(List<T>, T) -> Bool"),
|
|
("all", "(List<T>, fn(T) -> Bool) -> Bool"),
|
|
("any", "(List<T>, fn(T) -> Bool) -> Bool"),
|
|
("find", "(List<T>, fn(T) -> Bool) -> Option<T>"),
|
|
("sort", "List<Int> -> List<Int>"),
|
|
("range", "(Int, Int) -> List<Int>"),
|
|
("forEach", "(List<T>, fn(T) -> Unit) -> Unit"),
|
|
],
|
|
"String" => vec![
|
|
("length", "String -> Int"),
|
|
("concat", "(String, String) -> String"),
|
|
("substring", "(String, Int, Int) -> String"),
|
|
("split", "(String, String) -> List<String>"),
|
|
("join", "(List<String>, String) -> String"),
|
|
("trim", "String -> String"),
|
|
("contains", "(String, String) -> Bool"),
|
|
("replace", "(String, String, String) -> String"),
|
|
("startsWith", "(String, String) -> Bool"),
|
|
("endsWith", "(String, String) -> Bool"),
|
|
("toUpper", "String -> String"),
|
|
("toLower", "String -> String"),
|
|
("lines", "String -> List<String>"),
|
|
],
|
|
"Option" => vec![
|
|
("map", "(Option<A>, fn(A) -> B) -> Option<B>"),
|
|
("flatMap", "(Option<A>, fn(A) -> Option<B>) -> Option<B>"),
|
|
("getOrElse", "(Option<T>, T) -> T"),
|
|
("isSome", "Option<T> -> Bool"),
|
|
("isNone", "Option<T> -> Bool"),
|
|
],
|
|
"Result" => vec![
|
|
("map", "(Result<A, E>, fn(A) -> B) -> Result<B, E>"),
|
|
("flatMap", "(Result<A, E>, fn(A) -> Result<B, E>) -> Result<B, E>"),
|
|
("getOrElse", "(Result<T, E>, T) -> T"),
|
|
("isOk", "Result<T, E> -> Bool"),
|
|
("isErr", "Result<T, E> -> Bool"),
|
|
],
|
|
"Console" => vec![
|
|
("print", "String -> Unit"),
|
|
("readLine", "() -> String"),
|
|
],
|
|
"Random" => vec![
|
|
("int", "(Int, Int) -> Int"),
|
|
("float", "() -> Float"),
|
|
("bool", "() -> Bool"),
|
|
],
|
|
"File" => vec![
|
|
("read", "String -> String"),
|
|
("write", "(String, String) -> Unit"),
|
|
("exists", "String -> Bool"),
|
|
("delete", "String -> Unit"),
|
|
],
|
|
"Http" => vec![
|
|
("get", "String -> String"),
|
|
("post", "(String, String) -> String"),
|
|
],
|
|
"Time" => vec![
|
|
("now", "() -> Int"),
|
|
],
|
|
"Sql" => vec![
|
|
("open", "String -> SqlConn"),
|
|
("openMemory", "() -> SqlConn"),
|
|
("close", "SqlConn -> Unit"),
|
|
("query", "(SqlConn, String) -> List<Row>"),
|
|
("queryOne", "(SqlConn, String) -> Option<Row>"),
|
|
("execute", "(SqlConn, String) -> Int"),
|
|
("beginTx", "SqlConn -> Unit"),
|
|
("commit", "SqlConn -> Unit"),
|
|
("rollback", "SqlConn -> Unit"),
|
|
],
|
|
"Postgres" => vec![
|
|
("connect", "String -> Int"),
|
|
("close", "Int -> Unit"),
|
|
("query", "(Int, String) -> List<Row>"),
|
|
("queryOne", "(Int, String) -> Option<Row>"),
|
|
("execute", "(Int, String) -> Int"),
|
|
("beginTx", "Int -> Unit"),
|
|
("commit", "Int -> Unit"),
|
|
("rollback", "Int -> Unit"),
|
|
],
|
|
"Test" => vec![
|
|
("assert", "(Bool, String) -> Unit"),
|
|
("assertEqual", "(T, T) -> Unit"),
|
|
("assertTrue", "Bool -> Unit"),
|
|
("assertFalse", "Bool -> Unit"),
|
|
("fail", "String -> Unit"),
|
|
],
|
|
_ => {
|
|
println!("Unknown module: {}", module);
|
|
println!("Available modules: List, String, Option, Result, Console, Random, File, Http, Time, Sql, Postgres, Test");
|
|
return;
|
|
}
|
|
};
|
|
|
|
println!("\x1b[1mmodule {}\x1b[0m", module);
|
|
println!();
|
|
for (name, sig) in exports {
|
|
println!(" \x1b[34m{}.{}\x1b[0m : {}", module, name, sig);
|
|
}
|
|
}
|
|
|
|
fn show_type(expr_str: &str, checker: &mut TypeChecker) {
|
|
// Wrap expression in a let to parse it
|
|
let wrapped = format!("let _expr_ = {}", expr_str);
|
|
|
|
match Parser::parse_source(&wrapped) {
|
|
Ok(program) => {
|
|
if let Err(errors) = checker.check_program(&program) {
|
|
for error in errors {
|
|
println!("Type error: {}", error);
|
|
}
|
|
} else {
|
|
println!("(type checking passed)");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("Parse error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_ast(expr_str: &str) {
|
|
// Wrap expression in a let to parse it
|
|
let wrapped = format!("let _expr_ = {}", expr_str);
|
|
|
|
match Parser::parse_source(&wrapped) {
|
|
Ok(program) => {
|
|
// Pretty print the AST
|
|
for decl in &program.declarations {
|
|
println!("{:#?}", decl);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("Parse error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_file(path: &str, interp: &mut Interpreter, checker: &mut TypeChecker, helper: &mut LuxHelper) {
|
|
let source = match std::fs::read_to_string(path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
println!("Error reading file '{}': {}", path, e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let program = match Parser::parse_source(&source) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
println!("Parse error: {}", e);
|
|
return;
|
|
}
|
|
};
|
|
|
|
if let Err(errors) = checker.check_program(&program) {
|
|
for error in errors {
|
|
println!("Type error: {}", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Add definitions for completion
|
|
for decl in &program.declarations {
|
|
if let Some(name) = extract_definition_name(&format!("{:?}", decl)) {
|
|
helper.add_definition(&name);
|
|
}
|
|
}
|
|
|
|
match interp.run(&program) {
|
|
Ok(_) => println!("Loaded '{}'", path),
|
|
Err(e) => println!("Runtime error: {}", e),
|
|
}
|
|
}
|
|
|
|
fn eval_input(input: &str, interp: &mut Interpreter, checker: &mut TypeChecker) {
|
|
// Try to parse as a program (declarations)
|
|
match Parser::parse_source(input) {
|
|
Ok(program) => {
|
|
// Type check
|
|
if let Err(errors) = checker.check_program(&program) {
|
|
for error in errors {
|
|
println!("Type error: {}", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Execute
|
|
match interp.run(&program) {
|
|
Ok(value) => {
|
|
if !matches!(value, interpreter::Value::Unit) {
|
|
println!("{}", value);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
println!("Runtime error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
Err(parse_err) => {
|
|
// Try wrapping as an expression
|
|
let wrapped = format!("let _result_ = {}", input.trim());
|
|
match Parser::parse_source(&wrapped) {
|
|
Ok(program) => {
|
|
if let Err(errors) = checker.check_program(&program) {
|
|
for error in errors {
|
|
println!("Type error: {}", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
match interp.run(&program) {
|
|
Ok(value) => {
|
|
println!("{}", value);
|
|
}
|
|
Err(e) => {
|
|
println!("Runtime error: {}", e);
|
|
}
|
|
}
|
|
}
|
|
Err(_) => {
|
|
// Use original error
|
|
println!("Parse error: {}", parse_err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn eval(source: &str) -> Result<String, String> {
|
|
let program = Parser::parse_source(source).map_err(|e| e.to_string())?;
|
|
|
|
let mut checker = TypeChecker::new();
|
|
checker.check_program(&program).map_err(|errors| {
|
|
errors
|
|
.iter()
|
|
.map(|e| e.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
})?;
|
|
|
|
let mut interp = Interpreter::new();
|
|
let value = interp.run(&program).map_err(|e| e.to_string())?;
|
|
Ok(format!("{}", value))
|
|
}
|
|
|
|
#[test]
|
|
fn test_arithmetic() {
|
|
assert_eq!(eval("let x = 1 + 2").unwrap(), "3");
|
|
assert_eq!(eval("let x = 10 - 3").unwrap(), "7");
|
|
assert_eq!(eval("let x = 4 * 5").unwrap(), "20");
|
|
assert_eq!(eval("let x = 15 / 3").unwrap(), "5");
|
|
}
|
|
|
|
#[test]
|
|
fn test_function() {
|
|
let source = r#"
|
|
fn add(a: Int, b: Int): Int = a + b
|
|
let result = add(3, 4)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "7");
|
|
}
|
|
|
|
#[test]
|
|
fn test_if_expr() {
|
|
let source = r#"
|
|
fn max(a: Int, b: Int): Int = if a > b then a else b
|
|
let result = max(5, 3)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "5");
|
|
}
|
|
|
|
#[test]
|
|
fn test_recursion() {
|
|
let source = r#"
|
|
fn factorial(n: Int): Int = if n <= 1 then 1 else n * factorial(n - 1)
|
|
let result = factorial(5)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "120");
|
|
}
|
|
|
|
#[test]
|
|
fn test_lambda() {
|
|
let source = r#"
|
|
let double = fn(x: Int): Int => x * 2
|
|
let result = double(21)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_records() {
|
|
let source = r#"
|
|
let person = { name: "Alice", age: 30 }
|
|
let result = person.age
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "30");
|
|
}
|
|
|
|
#[test]
|
|
fn test_lists() {
|
|
let source = "let nums = [1, 2, 3]";
|
|
assert_eq!(eval(source).unwrap(), "[1, 2, 3]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tuples() {
|
|
let source = "let pair = (42, \"hello\")";
|
|
assert_eq!(eval(source).unwrap(), "(42, \"hello\")");
|
|
}
|
|
|
|
#[test]
|
|
fn test_block() {
|
|
let source = r#"
|
|
let result = {
|
|
let x = 10
|
|
let y = 20
|
|
x + y
|
|
}
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "30");
|
|
}
|
|
|
|
#[test]
|
|
fn test_pipe() {
|
|
let source = r#"
|
|
fn double(x: Int): Int = x * 2
|
|
fn add_one(x: Int): Int = x + 1
|
|
let result = 5 |> double |> add_one
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "11");
|
|
}
|
|
|
|
// ============ Standard Library Tests ============
|
|
|
|
// List tests
|
|
#[test]
|
|
fn test_list_length() {
|
|
assert_eq!(eval("let x = List.length([1, 2, 3])").unwrap(), "3");
|
|
assert_eq!(eval("let x = List.length([])").unwrap(), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_reverse() {
|
|
assert_eq!(
|
|
eval("let x = List.reverse([1, 2, 3])").unwrap(),
|
|
"[3, 2, 1]"
|
|
);
|
|
assert_eq!(eval("let x = List.reverse([])").unwrap(), "[]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_range() {
|
|
assert_eq!(eval("let x = List.range(0, 5)").unwrap(), "[0, 1, 2, 3, 4]");
|
|
assert_eq!(eval("let x = List.range(3, 3)").unwrap(), "[]");
|
|
assert_eq!(eval("let x = List.range(-2, 2)").unwrap(), "[-2, -1, 0, 1]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_head() {
|
|
assert_eq!(eval("let x = List.head([1, 2, 3])").unwrap(), "Some(1)");
|
|
assert_eq!(eval("let x = List.head([])").unwrap(), "None");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_tail() {
|
|
assert_eq!(
|
|
eval("let x = List.tail([1, 2, 3])").unwrap(),
|
|
"Some([2, 3])"
|
|
);
|
|
assert_eq!(eval("let x = List.tail([1])").unwrap(), "Some([])");
|
|
assert_eq!(eval("let x = List.tail([])").unwrap(), "None");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_concat() {
|
|
assert_eq!(
|
|
eval("let x = List.concat([1, 2], [3, 4])").unwrap(),
|
|
"[1, 2, 3, 4]"
|
|
);
|
|
assert_eq!(eval("let x = List.concat([], [1])").unwrap(), "[1]");
|
|
assert_eq!(eval("let x = List.concat([1], [])").unwrap(), "[1]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_get() {
|
|
assert_eq!(
|
|
eval("let x = List.get([10, 20, 30], 0)").unwrap(),
|
|
"Some(10)"
|
|
);
|
|
assert_eq!(
|
|
eval("let x = List.get([10, 20, 30], 2)").unwrap(),
|
|
"Some(30)"
|
|
);
|
|
assert_eq!(eval("let x = List.get([10, 20, 30], 5)").unwrap(), "None");
|
|
assert_eq!(eval("let x = List.get([10, 20, 30], -1)").unwrap(), "None");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_map() {
|
|
let source = r#"
|
|
fn double(x: Int): Int = x * 2
|
|
let result = List.map([1, 2, 3], double)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "[2, 4, 6]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_map_lambda() {
|
|
let source = "let x = List.map([1, 2, 3], fn(x: Int): Int => x * x)";
|
|
assert_eq!(eval(source).unwrap(), "[1, 4, 9]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_filter() {
|
|
let source = "let x = List.filter([1, 2, 3, 4, 5], fn(x: Int): Bool => x > 2)";
|
|
assert_eq!(eval(source).unwrap(), "[3, 4, 5]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_filter_all() {
|
|
let source = "let x = List.filter([1, 2, 3], fn(x: Int): Bool => x > 10)";
|
|
assert_eq!(eval(source).unwrap(), "[]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_fold() {
|
|
let source = "let x = List.fold([1, 2, 3, 4], 0, fn(acc: Int, x: Int): Int => acc + x)";
|
|
assert_eq!(eval(source).unwrap(), "10");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_fold_product() {
|
|
let source = "let x = List.fold([1, 2, 3, 4], 1, fn(acc: Int, x: Int): Int => acc * x)";
|
|
assert_eq!(eval(source).unwrap(), "24");
|
|
}
|
|
|
|
// String tests
|
|
#[test]
|
|
fn test_string_length() {
|
|
assert_eq!(eval(r#"let x = String.length("hello")"#).unwrap(), "5");
|
|
assert_eq!(eval(r#"let x = String.length("")"#).unwrap(), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_split() {
|
|
assert_eq!(
|
|
eval(r#"let x = String.split("a,b,c", ",")"#).unwrap(),
|
|
r#"["a", "b", "c"]"#
|
|
);
|
|
assert_eq!(
|
|
eval(r#"let x = String.split("hello", ",")"#).unwrap(),
|
|
r#"["hello"]"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_join() {
|
|
assert_eq!(
|
|
eval(r#"let x = String.join(["a", "b", "c"], "-")"#).unwrap(),
|
|
r#""a-b-c""#
|
|
);
|
|
assert_eq!(
|
|
eval(r#"let x = String.join(["hello"], ",")"#).unwrap(),
|
|
r#""hello""#
|
|
);
|
|
assert_eq!(eval(r#"let x = String.join([], ",")"#).unwrap(), r#""""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_trim() {
|
|
assert_eq!(
|
|
eval(r#"let x = String.trim(" hello ")"#).unwrap(),
|
|
r#""hello""#
|
|
);
|
|
assert_eq!(
|
|
eval(r#"let x = String.trim("hello")"#).unwrap(),
|
|
r#""hello""#
|
|
);
|
|
assert_eq!(eval(r#"let x = String.trim(" ")"#).unwrap(), r#""""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_contains() {
|
|
assert_eq!(
|
|
eval(r#"let x = String.contains("hello world", "world")"#).unwrap(),
|
|
"true"
|
|
);
|
|
assert_eq!(
|
|
eval(r#"let x = String.contains("hello", "xyz")"#).unwrap(),
|
|
"false"
|
|
);
|
|
assert_eq!(
|
|
eval(r#"let x = String.contains("hello", "")"#).unwrap(),
|
|
"true"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_replace() {
|
|
assert_eq!(
|
|
eval(r#"let x = String.replace("hello", "l", "L")"#).unwrap(),
|
|
r#""heLLo""#
|
|
);
|
|
assert_eq!(
|
|
eval(r#"let x = String.replace("aaa", "a", "b")"#).unwrap(),
|
|
r#""bbb""#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_chars() {
|
|
assert_eq!(eval(r#"let x = String.chars("hi")"#).unwrap(), "['h', 'i']");
|
|
assert_eq!(eval(r#"let x = String.chars("")"#).unwrap(), "[]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_lines() {
|
|
// Note: Using actual newline in the string
|
|
let source = r#"let x = String.lines("a
|
|
b
|
|
c")"#;
|
|
assert_eq!(eval(source).unwrap(), r#"["a", "b", "c"]"#);
|
|
}
|
|
|
|
// String interpolation tests
|
|
#[test]
|
|
fn test_string_interpolation_simple() {
|
|
let source = r#"
|
|
let name = "World"
|
|
let x = "Hello, {name}!"
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), r#""Hello, World!""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_interpolation_numbers() {
|
|
let source = r#"
|
|
let n = 42
|
|
let x = "The answer is {n}"
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), r#""The answer is 42""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_interpolation_expressions() {
|
|
let source = r#"
|
|
let a = 2
|
|
let b = 3
|
|
let x = "{a} + {b} = {a + b}"
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), r#""2 + 3 = 5""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_interpolation_escaped_braces() {
|
|
let source = r#"let x = "literal \{braces\}""#;
|
|
assert_eq!(eval(source).unwrap(), r#""literal {braces}""#);
|
|
}
|
|
|
|
// Option tests
|
|
#[test]
|
|
fn test_option_constructors() {
|
|
assert_eq!(eval("let x = Some(42)").unwrap(), "Some(42)");
|
|
assert_eq!(eval("let x = None").unwrap(), "None");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_is_some() {
|
|
assert_eq!(eval("let x = Option.isSome(Some(42))").unwrap(), "true");
|
|
assert_eq!(eval("let x = Option.isSome(None)").unwrap(), "false");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_is_none() {
|
|
assert_eq!(eval("let x = Option.isNone(None)").unwrap(), "true");
|
|
assert_eq!(eval("let x = Option.isNone(Some(42))").unwrap(), "false");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_get_or_else() {
|
|
assert_eq!(eval("let x = Option.getOrElse(Some(42), 0)").unwrap(), "42");
|
|
assert_eq!(eval("let x = Option.getOrElse(None, 0)").unwrap(), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_map() {
|
|
let source = "let x = Option.map(Some(5), fn(x: Int): Int => x * 2)";
|
|
assert_eq!(eval(source).unwrap(), "Some(10)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_map_none() {
|
|
let source = "let x = Option.map(None, fn(x: Int): Int => x * 2)";
|
|
assert_eq!(eval(source).unwrap(), "None");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_flat_map() {
|
|
let source = "let x = Option.flatMap(Some(5), fn(x: Int): Option<Int> => Some(x * 2))";
|
|
assert_eq!(eval(source).unwrap(), "Some(10)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_flat_map_to_none() {
|
|
let source = "let x = Option.flatMap(Some(5), fn(x: Int): Option<Int> => None)";
|
|
assert_eq!(eval(source).unwrap(), "None");
|
|
}
|
|
|
|
// Result tests
|
|
#[test]
|
|
fn test_result_constructors() {
|
|
assert_eq!(eval("let x = Ok(42)").unwrap(), "Ok(42)");
|
|
assert_eq!(eval(r#"let x = Err("error")"#).unwrap(), r#"Err("error")"#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_result_is_ok() {
|
|
assert_eq!(eval("let x = Result.isOk(Ok(42))").unwrap(), "true");
|
|
assert_eq!(eval(r#"let x = Result.isOk(Err("e"))"#).unwrap(), "false");
|
|
}
|
|
|
|
#[test]
|
|
fn test_result_is_err() {
|
|
assert_eq!(eval(r#"let x = Result.isErr(Err("e"))"#).unwrap(), "true");
|
|
assert_eq!(eval("let x = Result.isErr(Ok(42))").unwrap(), "false");
|
|
}
|
|
|
|
#[test]
|
|
fn test_result_get_or_else() {
|
|
assert_eq!(eval("let x = Result.getOrElse(Ok(42), 0)").unwrap(), "42");
|
|
assert_eq!(
|
|
eval(r#"let x = Result.getOrElse(Err("e"), 0)"#).unwrap(),
|
|
"0"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_result_map() {
|
|
let source = "let x = Result.map(Ok(5), fn(x: Int): Int => x * 2)";
|
|
assert_eq!(eval(source).unwrap(), "Ok(10)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_result_map_err() {
|
|
let source = r#"let x = Result.map(Err("e"), fn(x: Int): Int => x * 2)"#;
|
|
assert_eq!(eval(source).unwrap(), r#"Err("e")"#);
|
|
}
|
|
|
|
// Utility function tests
|
|
#[test]
|
|
fn test_to_string() {
|
|
assert_eq!(eval("let x = toString(42)").unwrap(), r#""42""#);
|
|
assert_eq!(eval("let x = toString(true)").unwrap(), r#""true""#);
|
|
assert_eq!(eval("let x = toString([1, 2])").unwrap(), r#""[1, 2]""#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_type_of() {
|
|
assert_eq!(eval("let x = typeOf(42)").unwrap(), r#""Int""#);
|
|
assert_eq!(eval("let x = typeOf(true)").unwrap(), r#""Bool""#);
|
|
assert_eq!(eval("let x = typeOf([1, 2])").unwrap(), r#""List""#);
|
|
assert_eq!(eval(r#"let x = typeOf("hello")"#).unwrap(), r#""String""#);
|
|
}
|
|
|
|
// Bug fix tests
|
|
#[test]
|
|
fn test_record_equality() {
|
|
assert_eq!(eval("let x = { a: 1, b: 2 } == { a: 1, b: 2 }").unwrap(), "true");
|
|
assert_eq!(eval("let x = { a: 1 } == { a: 2 }").unwrap(), "false");
|
|
assert_eq!(eval("let x = { a: 1, b: 2 } == { a: 1, b: 3 }").unwrap(), "false");
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_escape_sequence() {
|
|
let result = eval(r#"let x = "\z""#);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Invalid escape sequence"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_effect_error() {
|
|
let result = eval("fn test(): Unit with {FakeEffect} = ()");
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Unknown effect"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_circular_type_definitions() {
|
|
let result = eval("type A = B\ntype B = A");
|
|
assert!(result.is_err(), "Should detect circular type definitions");
|
|
let err = result.unwrap_err();
|
|
assert!(err.contains("Circular"), "Error should mention 'Circular': {}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mutual_recursion() {
|
|
let source = r#"
|
|
fn isEven(n: Int): Bool = if n == 0 then true else isOdd(n - 1)
|
|
fn isOdd(n: Int): Bool = if n == 0 then false else isEven(n - 1)
|
|
let result = isEven(10)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "true");
|
|
}
|
|
|
|
// Pipe with stdlib tests
|
|
#[test]
|
|
fn test_pipe_with_list() {
|
|
assert_eq!(
|
|
eval("let x = [1, 2, 3] |> List.reverse").unwrap(),
|
|
"[3, 2, 1]"
|
|
);
|
|
assert_eq!(eval("let x = [1, 2, 3] |> List.length").unwrap(), "3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_pipe_with_string() {
|
|
assert_eq!(
|
|
eval(r#"let x = " hello " |> String.trim"#).unwrap(),
|
|
r#""hello""#
|
|
);
|
|
}
|
|
|
|
// Combined stdlib usage tests
|
|
#[test]
|
|
fn test_list_filter_even() {
|
|
let source = r#"
|
|
fn isEven(x: Int): Bool = x % 2 == 0
|
|
let result = List.filter(List.range(1, 6), isEven)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "[2, 4]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_chain() {
|
|
let source = r#"
|
|
fn times10(x: Int): Int = x * 10
|
|
let head = List.head([1, 2, 3])
|
|
let mapped = Option.map(head, times10)
|
|
let result = Option.getOrElse(mapped, 0)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "10");
|
|
}
|
|
|
|
#[test]
|
|
fn test_option_chain_empty() {
|
|
let source = r#"
|
|
fn times10(x: Int): Int = x * 10
|
|
let head = List.head([])
|
|
let mapped = Option.map(head, times10)
|
|
let result = Option.getOrElse(mapped, 0)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "0");
|
|
}
|
|
|
|
// ============ Behavioral Types Tests ============
|
|
|
|
#[test]
|
|
fn test_behavioral_pure_function() {
|
|
// A pure function with no effects should work
|
|
let source = r#"
|
|
fn double(x: Int): Int is pure = x * 2
|
|
let result = double(21)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_total_function() {
|
|
// A total function should work
|
|
let source = r#"
|
|
fn always42(): Int is total = 42
|
|
let result = always42()
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_idempotent_function() {
|
|
// An idempotent function
|
|
let source = r#"
|
|
fn clamp(x: Int): Int is idempotent = if x < 0 then 0 else x
|
|
let result = clamp(clamp(-5))
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_multiple_properties() {
|
|
// A function with multiple properties
|
|
let source = r#"
|
|
fn identity(x: Int): Int is pure, is total = x
|
|
let result = identity(100)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "100");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_deterministic() {
|
|
let source = r#"
|
|
fn square(x: Int): Int is deterministic = x * x
|
|
let result = square(7)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "49");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_pure_with_effects_error() {
|
|
// A pure function with effects should produce a type error
|
|
let source = r#"
|
|
fn bad(x: Int): Int with {Logger} is pure = x
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("pure but has effects"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_deterministic_with_random_error() {
|
|
// A deterministic function cannot use Random effect
|
|
let source = r#"
|
|
fn bad(): Int with {Random} is deterministic = Random.int(1, 100)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("deterministic but uses non-deterministic effects"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_deterministic_with_time_error() {
|
|
// A deterministic function cannot use Time effect
|
|
let source = r#"
|
|
fn bad(): Int with {Time} is deterministic = Time.now()
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("deterministic but uses non-deterministic effects"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_commutative_add() {
|
|
// Addition is commutative
|
|
let source = r#"
|
|
fn add(a: Int, b: Int): Int is commutative = a + b
|
|
let result = add(3, 5)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "8");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_commutative_mul() {
|
|
// Multiplication is commutative
|
|
let source = r#"
|
|
fn mul(a: Int, b: Int): Int is commutative = a * b
|
|
let result = mul(3, 5)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "15");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_commutative_subtract_error() {
|
|
// Subtraction is NOT commutative
|
|
let source = r#"
|
|
fn sub(a: Int, b: Int): Int is commutative = a - b
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("commutative but its body is not"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_commutative_wrong_params_error() {
|
|
// Commutative requires exactly 2 params
|
|
let source = r#"
|
|
fn bad(a: Int): Int is commutative = a
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("has 1 parameters (expected 2)"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_idempotent_identity() {
|
|
// Identity function is idempotent
|
|
let source = r#"
|
|
fn identity(x: Int): Int is idempotent = x
|
|
let result = identity(42)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_idempotent_constant() {
|
|
// Constant function is idempotent
|
|
let source = r#"
|
|
fn always42(x: Int): Int is idempotent = 42
|
|
let result = always42(100)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_idempotent_clamping() {
|
|
// Clamping pattern is idempotent
|
|
let source = r#"
|
|
fn clampPositive(x: Int): Int is idempotent =
|
|
if x < 0 then 0 else x
|
|
let result = clampPositive(-5)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_idempotent_increment_error() {
|
|
// Increment is NOT idempotent
|
|
let source = r#"
|
|
fn increment(x: Int): Int is idempotent = x + 1
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("idempotent but could not be verified"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_total_non_recursive() {
|
|
// Non-recursive function is total
|
|
let source = r#"
|
|
fn double(x: Int): Int is total = x * 2
|
|
let result = double(21)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_total_structural_recursion() {
|
|
// Structural recursion (n - 1) is total
|
|
let source = r#"
|
|
fn factorial(n: Int): Int is total =
|
|
if n <= 1 then 1 else n * factorial(n - 1)
|
|
let result = factorial(5)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "120");
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_total_infinite_loop_error() {
|
|
// Infinite loop is NOT total
|
|
let source = r#"
|
|
fn loop(x: Int): Int is total = loop(x)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("may not terminate"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_total_with_fail_error() {
|
|
// Total function cannot use Fail effect
|
|
let source = r#"
|
|
effect Fail { fn fail(msg: String): Unit }
|
|
fn bad(x: Int): Int with {Fail} is total =
|
|
if x < 0 then { Fail.fail("negative"); 0 } else x
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("total but uses the Fail effect"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_where_clause_parsing() {
|
|
// Where clause property constraints are parsed correctly
|
|
let source = r#"
|
|
fn retry<F>(action: F, times: Int): Int where F is idempotent = times
|
|
fn idempotentFn(): Int is idempotent = 42
|
|
let x = 1
|
|
"#;
|
|
// Just verify it parses and type-checks without errors
|
|
assert!(eval(source).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_where_clause_satisfied() {
|
|
// Where clause should be satisfied when passing an idempotent function
|
|
// Note: Parameter type must be the type parameter F for constraint to apply
|
|
let source = r#"
|
|
fn runIdempotent<F>(action: F): Int where F is idempotent = 42
|
|
fn idempotentFn(): Int is idempotent = 42
|
|
let result = runIdempotent(idempotentFn)
|
|
"#;
|
|
assert!(eval(source).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_where_clause_violation() {
|
|
// Where clause should fail when passing a non-idempotent function
|
|
// The function notIdempotent doesn't declare "is idempotent", so this should fail
|
|
let source = r#"
|
|
fn runIdempotent<F>(action: F): Int where F is idempotent = 42
|
|
fn notIdempotent(): Int = 42
|
|
let result = runIdempotent(notIdempotent)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err(), "Should fail when where clause is violated");
|
|
assert!(result.unwrap_err().contains("property constraint"),
|
|
"Error should mention property constraint");
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_version_preserved() {
|
|
// Version annotations are preserved in type annotations
|
|
let source = r#"
|
|
fn processV1(x: Int @v1): Int = x
|
|
let value: Int @v1 = 42
|
|
let result = processV1(value)
|
|
"#;
|
|
assert!(eval(source).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_version_mismatch_error() {
|
|
// Version mismatch should produce an error when versions don't match
|
|
let source = r#"
|
|
fn expectV2(x: Int @v2): Int = x
|
|
let oldValue: Int @v1 = 42
|
|
let result = expectV2(oldValue)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Version mismatch"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_version_atleast() {
|
|
// @v2+ should accept v2 or higher
|
|
let source = r#"
|
|
fn processAtLeastV2(x: Int @v2+): Int = x
|
|
let value: Int @v2 = 42
|
|
let result = processAtLeastV2(value)
|
|
"#;
|
|
assert!(eval(source).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_version_latest() {
|
|
// @latest should be compatible with any version
|
|
let source = r#"
|
|
fn processLatest(x: Int @latest): Int = x
|
|
let value: Int @v1 = 42
|
|
let result = processLatest(value)
|
|
"#;
|
|
assert!(eval(source).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_breaking_change_with_migration() {
|
|
// Breaking change with migration should not error
|
|
let source = r#"
|
|
type User @v1 { name: String }
|
|
type User @v2 {
|
|
name: String,
|
|
email: String,
|
|
from @v1 = { name: old.name, email: "unknown@example.com" }
|
|
}
|
|
let x = 1
|
|
"#;
|
|
assert!(eval(source).is_ok(), "Breaking change with migration should be allowed");
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_breaking_change_without_migration() {
|
|
// Breaking change without migration should produce an error
|
|
let source = r#"
|
|
type Config @v1 { host: String }
|
|
type Config @v2 {
|
|
host: String,
|
|
port: Int
|
|
}
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err(), "Breaking change without migration should fail");
|
|
let err = result.unwrap_err();
|
|
assert!(err.contains("Breaking changes") || err.contains("required field"),
|
|
"Error should mention breaking changes: {}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_compatible_change() {
|
|
// Compatible change (adding optional field) should not error
|
|
// Note: Currently we don't have Option types fully integrated,
|
|
// so this test verifies the basic flow
|
|
let source = r#"
|
|
type Settings @v1 { theme: String }
|
|
type Settings @v2 { theme: String }
|
|
let x = 1
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Compatible change should be allowed: {:?}", result);
|
|
}
|
|
|
|
// Built-in effect tests
|
|
mod effect_tests {
|
|
use crate::interpreter::{Interpreter, Value};
|
|
use crate::parser::Parser;
|
|
use crate::typechecker::TypeChecker;
|
|
|
|
fn run_with_effects(source: &str, initial_state: Value, reader_value: Value) -> Result<(String, String), String> {
|
|
let program = Parser::parse_source(source).map_err(|e| e.to_string())?;
|
|
let mut checker = TypeChecker::new();
|
|
checker.check_program(&program).map_err(|errors| {
|
|
errors.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("\n")
|
|
})?;
|
|
let mut interp = Interpreter::new();
|
|
interp.set_state(initial_state);
|
|
interp.set_reader(reader_value);
|
|
let result = interp.run(&program).map_err(|e| e.to_string())?;
|
|
let final_state = interp.get_state();
|
|
Ok((format!("{}", result), format!("{}", final_state)))
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_get() {
|
|
let source = r#"
|
|
fn getValue(): Int with {State} = State.get()
|
|
let result = run getValue() with {}
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Int(42), Value::Unit).unwrap();
|
|
assert_eq!(result, "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_put() {
|
|
let source = r#"
|
|
fn setValue(x: Int): Unit with {State} = State.put(x)
|
|
let result = run setValue(100) with {}
|
|
"#;
|
|
let (_, final_state) = run_with_effects(source, Value::Int(0), Value::Unit).unwrap();
|
|
assert_eq!(final_state, "100");
|
|
}
|
|
|
|
#[test]
|
|
fn test_state_get_and_put() {
|
|
let source = r#"
|
|
fn increment(): Int with {State} = {
|
|
let current = State.get()
|
|
State.put(current + 1)
|
|
State.get()
|
|
}
|
|
let result = run increment() with {}
|
|
"#;
|
|
let (result, final_state) = run_with_effects(source, Value::Int(10), Value::Unit).unwrap();
|
|
assert_eq!(result, "11");
|
|
assert_eq!(final_state, "11");
|
|
}
|
|
|
|
#[test]
|
|
fn test_reader_ask() {
|
|
let source = r#"
|
|
fn getConfig(): String with {Reader} = Reader.ask()
|
|
let result = run getConfig() with {}
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::String("config_value".to_string())).unwrap();
|
|
// Value's Display includes quotes for strings
|
|
assert_eq!(result, "\"config_value\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_fail_effect() {
|
|
let source = r#"
|
|
fn failing(): Int with {Fail} = Fail.fail("oops")
|
|
let result = run failing() with {}
|
|
"#;
|
|
let result = run_with_effects(source, Value::Unit, Value::Unit);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("oops"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_combined_effects() {
|
|
let source = r#"
|
|
fn compute(): Int with {State, Reader} = {
|
|
let config = Reader.ask()
|
|
let current = State.get()
|
|
State.put(current + 1)
|
|
current
|
|
}
|
|
let result = run compute() with {}
|
|
"#;
|
|
let (result, final_state) = run_with_effects(source, Value::Int(5), Value::String("test".to_string())).unwrap();
|
|
assert_eq!(result, "5");
|
|
assert_eq!(final_state, "6");
|
|
}
|
|
|
|
#[test]
|
|
fn test_random_int() {
|
|
let source = r#"
|
|
fn getRandomInt(): Int with {Random} = Random.int(1, 10)
|
|
let result = run getRandomInt() with {}
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
let num: i64 = result.parse().expect("Should be an integer");
|
|
assert!(num >= 1 && num <= 10, "Random int should be in range 1-10, got {}", num);
|
|
}
|
|
|
|
#[test]
|
|
fn test_random_float() {
|
|
let source = r#"
|
|
fn getRandomFloat(): Float with {Random} = Random.float()
|
|
let result = run getRandomFloat() with {}
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
let num: f64 = result.parse().expect("Should be a float");
|
|
assert!(num >= 0.0 && num < 1.0, "Random float should be in range [0, 1), got {}", num);
|
|
}
|
|
|
|
#[test]
|
|
fn test_random_bool() {
|
|
let source = r#"
|
|
fn getRandomBool(): Bool with {Random} = Random.bool()
|
|
let result = run getRandomBool() with {}
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert!(result == "true" || result == "false", "Random bool should be true or false, got {}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_time_now() {
|
|
let source = r#"
|
|
fn getTime(): Int with {Time} = Time.now()
|
|
let result = run getTime() with {}
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
let timestamp: i64 = result.parse().expect("Should be a timestamp");
|
|
// Timestamp should be a reasonable Unix time in milliseconds (after 2020)
|
|
assert!(timestamp > 1577836800000, "Timestamp should be after 2020");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resumable_handler() {
|
|
// Test that resume works in handler bodies
|
|
let source = r#"
|
|
effect Counter {
|
|
fn increment(): Int
|
|
}
|
|
|
|
fn useCounter(): Int with {Counter} = {
|
|
let a = Counter.increment()
|
|
let b = Counter.increment()
|
|
a + b
|
|
}
|
|
|
|
handler countingHandler: Counter {
|
|
fn increment() = resume(10)
|
|
}
|
|
|
|
let result = run useCounter() with {
|
|
Counter = countingHandler
|
|
}
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
// Each increment returns 10, so a + b = 10 + 10 = 20
|
|
assert_eq!(result, "20");
|
|
}
|
|
|
|
#[test]
|
|
fn test_resume_outside_handler_fails() {
|
|
// Resume outside handler should fail at runtime
|
|
let source = r#"
|
|
fn bad(): Int = resume(42)
|
|
let result = bad()
|
|
"#;
|
|
let result = run_with_effects(source, Value::Unit, Value::Unit);
|
|
assert!(result.is_err());
|
|
let err_msg = result.unwrap_err();
|
|
assert!(err_msg.contains("outside") || err_msg.contains("Resume"),
|
|
"Error should mention resume outside handler: {}", err_msg);
|
|
}
|
|
|
|
// Schema Evolution tests
|
|
#[test]
|
|
fn test_schema_versioned() {
|
|
let source = r#"
|
|
let user = Schema.versioned("User", 1, { name: "Alice", age: 30 })
|
|
let version = Schema.getVersion(user)
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "1");
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_migrate_same_version() {
|
|
let source = r#"
|
|
let user = Schema.versioned("User", 2, { name: "Bob" })
|
|
let migrated = Schema.migrate(user, 2)
|
|
let version = Schema.getVersion(migrated)
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "2");
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_migrate_upgrade() {
|
|
let source = r#"
|
|
let user = Schema.versioned("User", 1, { name: "Charlie" })
|
|
let migrated = Schema.migrate(user, 3)
|
|
let version = Schema.getVersion(migrated)
|
|
"#;
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_schema_migrate_downgrade_fails() {
|
|
let source = r#"
|
|
let user = Schema.versioned("User", 3, { name: "Dave" })
|
|
let migrated = Schema.migrate(user, 1)
|
|
"#;
|
|
let result = run_with_effects(source, Value::Unit, Value::Unit);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("downgrade"));
|
|
}
|
|
|
|
// HttpServer effect type-checking tests
|
|
#[test]
|
|
fn test_http_server_typecheck() {
|
|
// Verify HttpServer effect operations type-check correctly
|
|
// We can't actually run the server in tests, but we can verify types
|
|
use crate::parser::Parser;
|
|
use crate::typechecker::TypeChecker;
|
|
|
|
let source = r#"
|
|
// Function that uses HttpServer effect
|
|
fn handleRequest(req: { method: String, path: String, body: String, headers: List<(String, String)> }): Unit with {HttpServer} =
|
|
HttpServer.respond(200, "Hello!")
|
|
|
|
fn serveOne(port: Int): Unit with {HttpServer} = {
|
|
HttpServer.listen(port)
|
|
let req = HttpServer.accept()
|
|
handleRequest(req)
|
|
HttpServer.stop()
|
|
}
|
|
"#;
|
|
|
|
let program = Parser::parse_source(source).expect("parse failed");
|
|
let mut checker = TypeChecker::new();
|
|
let result = checker.check_program(&program);
|
|
assert!(result.is_ok(), "HttpServer type checking failed: {:?}", result);
|
|
}
|
|
|
|
// Behavioral types tests
|
|
#[test]
|
|
fn test_behavioral_pure_function() {
|
|
use crate::parser::Parser;
|
|
use crate::typechecker::TypeChecker;
|
|
|
|
let source = r#"
|
|
fn add(a: Int, b: Int): Int is pure = a + b
|
|
fn square(x: Int): Int is pure = x * x
|
|
let result = add(square(3), square(4))
|
|
"#;
|
|
let program = Parser::parse_source(source).expect("parse failed");
|
|
let mut checker = TypeChecker::new();
|
|
assert!(checker.check_program(&program).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_deterministic() {
|
|
use crate::parser::Parser;
|
|
use crate::typechecker::TypeChecker;
|
|
|
|
let source = r#"
|
|
fn factorial(n: Int): Int is deterministic =
|
|
if n <= 1 then 1 else n * factorial(n - 1)
|
|
let result = factorial(5)
|
|
"#;
|
|
let program = Parser::parse_source(source).expect("parse failed");
|
|
let mut checker = TypeChecker::new();
|
|
assert!(checker.check_program(&program).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_commutative() {
|
|
use crate::parser::Parser;
|
|
use crate::typechecker::TypeChecker;
|
|
|
|
// Commutative verification works with arithmetic operators
|
|
let source = r#"
|
|
fn add(a: Int, b: Int): Int is commutative = a + b
|
|
fn mul(a: Int, b: Int): Int is commutative = a * b
|
|
let result = add(10, 20)
|
|
"#;
|
|
let program = Parser::parse_source(source).expect("parse failed");
|
|
let mut checker = TypeChecker::new();
|
|
assert!(checker.check_program(&program).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_idempotent() {
|
|
use crate::parser::Parser;
|
|
use crate::typechecker::TypeChecker;
|
|
|
|
// Idempotent verification works with abs pattern
|
|
let source = r#"
|
|
fn absolute(x: Int): Int is idempotent =
|
|
if x < 0 then 0 - x else x
|
|
let result = absolute(-42)
|
|
"#;
|
|
let program = Parser::parse_source(source).expect("parse failed");
|
|
let mut checker = TypeChecker::new();
|
|
assert!(checker.check_program(&program).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_behavioral_total() {
|
|
use crate::parser::Parser;
|
|
use crate::typechecker::TypeChecker;
|
|
|
|
let source = r#"
|
|
fn sumTo(n: Int): Int is total =
|
|
if n <= 0 then 0 else n + sumTo(n - 1)
|
|
fn power(base: Int, exp: Int): Int is total =
|
|
if exp <= 0 then 1 else base * power(base, exp - 1)
|
|
let result = sumTo(10)
|
|
"#;
|
|
let program = Parser::parse_source(source).expect("parse failed");
|
|
let mut checker = TypeChecker::new();
|
|
assert!(checker.check_program(&program).is_ok());
|
|
}
|
|
|
|
// Math module tests
|
|
#[test]
|
|
fn test_math_abs() {
|
|
let (result, _) = run_with_effects("let x = Math.abs(-42)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "42");
|
|
let (result, _) = run_with_effects("let x = Math.abs(42)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_math_min_max() {
|
|
let (result, _) = run_with_effects("let x = Math.min(3, 7)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "3");
|
|
let (result, _) = run_with_effects("let x = Math.max(3, 7)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "7");
|
|
}
|
|
|
|
#[test]
|
|
fn test_math_sqrt() {
|
|
let (result, _) = run_with_effects("let x = Math.sqrt(16)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "4");
|
|
}
|
|
|
|
#[test]
|
|
fn test_math_pow() {
|
|
let (result, _) = run_with_effects("let x = Math.pow(2, 10)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "1024");
|
|
}
|
|
|
|
#[test]
|
|
fn test_math_floor_ceil_round() {
|
|
let (result, _) = run_with_effects("let x = Math.floor(3.7)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "3");
|
|
let (result, _) = run_with_effects("let x = Math.ceil(3.2)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "4");
|
|
let (result, _) = run_with_effects("let x = Math.round(3.5)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "4");
|
|
}
|
|
|
|
// List module additional functions
|
|
#[test]
|
|
fn test_list_is_empty() {
|
|
let (result, _) = run_with_effects("let x = List.isEmpty([])", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "true");
|
|
let (result, _) = run_with_effects("let x = List.isEmpty([1, 2])", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "false");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_find() {
|
|
let source = "let x = List.find([1, 2, 3, 4, 5], fn(x: Int): Bool => x > 3)";
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "Some(4)");
|
|
let source = "let x = List.find([1, 2, 3], fn(x: Int): Bool => x > 10)";
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "None");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_any_all() {
|
|
let source = "let x = List.any([1, 2, 3], fn(x: Int): Bool => x > 2)";
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "true");
|
|
let source = "let x = List.all([1, 2, 3], fn(x: Int): Bool => x > 0)";
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "true");
|
|
let source = "let x = List.all([1, 2, 3], fn(x: Int): Bool => x > 2)";
|
|
let (result, _) = run_with_effects(source, Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "false");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_take_drop() {
|
|
let (result, _) = run_with_effects("let x = List.take([1, 2, 3, 4, 5], 3)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "[1, 2, 3]");
|
|
let (result, _) = run_with_effects("let x = List.drop([1, 2, 3, 4, 5], 2)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "[3, 4, 5]");
|
|
}
|
|
|
|
// String module additional functions
|
|
#[test]
|
|
fn test_string_starts_ends_with() {
|
|
let (result, _) = run_with_effects("let x = String.startsWith(\"hello\", \"he\")", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "true");
|
|
let (result, _) = run_with_effects("let x = String.endsWith(\"hello\", \"lo\")", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "true");
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_case_conversion() {
|
|
let (result, _) = run_with_effects("let x = String.toUpper(\"hello\")", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "\"HELLO\"");
|
|
let (result, _) = run_with_effects("let x = String.toLower(\"HELLO\")", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "\"hello\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_string_substring() {
|
|
let (result, _) = run_with_effects("let x = String.substring(\"hello world\", 0, 5)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "\"hello\"");
|
|
let (result, _) = run_with_effects("let x = String.substring(\"hello\", 2, 4)", Value::Unit, Value::Unit).unwrap();
|
|
assert_eq!(result, "\"ll\"");
|
|
}
|
|
}
|
|
|
|
// Diagnostic rendering tests
|
|
mod diagnostic_tests {
|
|
use crate::diagnostics::{render_diagnostic_plain, Diagnostic, Severity};
|
|
use crate::ast::Span;
|
|
use crate::typechecker::TypeError;
|
|
use crate::interpreter::RuntimeError;
|
|
|
|
#[test]
|
|
fn test_type_error_to_diagnostic() {
|
|
let error = TypeError {
|
|
message: "Type mismatch: expected Int, got String".to_string(),
|
|
span: Span { start: 10, end: 20 },
|
|
};
|
|
let diag = error.to_diagnostic();
|
|
|
|
assert_eq!(diag.severity, Severity::Error);
|
|
assert_eq!(diag.title, "Type Mismatch");
|
|
assert!(diag.hints.len() > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_runtime_error_to_diagnostic() {
|
|
let error = RuntimeError {
|
|
message: "Division by zero".to_string(),
|
|
span: Some(Span { start: 5, end: 10 }),
|
|
};
|
|
let diag = error.to_diagnostic();
|
|
|
|
assert_eq!(diag.severity, Severity::Error);
|
|
assert_eq!(diag.title, "Division by Zero");
|
|
assert!(diag.hints.len() > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_diagnostic_render_with_real_code() {
|
|
let source = "fn add(a: Int, b: Int): Int = a + b\nlet result = add(1, \"two\")";
|
|
let diag = Diagnostic::error(
|
|
"Type Mismatch",
|
|
"Expected Int but got String",
|
|
Span { start: 56, end: 61 },
|
|
)
|
|
.with_hint("The second argument should be an Int.");
|
|
|
|
let output = render_diagnostic_plain(&diag, source, Some("example.lux"));
|
|
|
|
assert!(output.contains("ERROR"));
|
|
assert!(output.contains("example.lux"));
|
|
assert!(output.contains("Type Mismatch"));
|
|
assert!(output.contains("\"two\""));
|
|
assert!(output.contains("Hint:"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_undefined_variable_categorization() {
|
|
let error = TypeError {
|
|
message: "Undefined variable: foobar".to_string(),
|
|
span: Span { start: 0, end: 6 },
|
|
};
|
|
let diag = error.to_diagnostic();
|
|
|
|
assert_eq!(diag.title, "Undefined Variable");
|
|
assert!(diag.hints.iter().any(|h| h.contains("spelling")));
|
|
// Check error code is set
|
|
assert!(diag.code.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_undefined_variable_suggestion() {
|
|
// Test that similar variable names are suggested
|
|
let source = r#"
|
|
let myVariable = 42
|
|
let x = myVriable
|
|
"#;
|
|
let result = super::eval(source);
|
|
assert!(result.is_err());
|
|
let err = result.unwrap_err();
|
|
// The error should contain a "Did you mean?" suggestion
|
|
assert!(err.contains("Did you mean") || err.contains("myVariable"),
|
|
"Error should suggest 'myVariable': {}", err);
|
|
}
|
|
|
|
#[test]
|
|
fn test_purity_violation_categorization() {
|
|
let error = TypeError {
|
|
message: "Function 'foo' is declared as pure but has effects: {Console}".to_string(),
|
|
span: Span { start: 0, end: 10 },
|
|
};
|
|
let diag = error.to_diagnostic();
|
|
|
|
assert_eq!(diag.title, "Purity Violation");
|
|
}
|
|
}
|
|
|
|
// ============ Multi-line Arguments Tests ============
|
|
|
|
#[test]
|
|
fn test_multiline_function_args() {
|
|
let source = r#"
|
|
fn add(a: Int, b: Int): Int = a + b
|
|
let result = add(
|
|
1,
|
|
2
|
|
)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiline_function_args_with_lambda() {
|
|
let source = r#"
|
|
let xs = List.map(
|
|
[1, 2, 3],
|
|
fn(x) => x * 2
|
|
)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "[2, 4, 6]");
|
|
}
|
|
|
|
// ============ Tuple Index Tests ============
|
|
|
|
#[test]
|
|
fn test_tuple_index_access() {
|
|
let source = r#"
|
|
let pair = (42, "hello")
|
|
let first = pair.0
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tuple_index_access_second() {
|
|
let source = r#"
|
|
let pair = (42, "hello")
|
|
let second = pair.1
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "\"hello\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tuple_index_triple() {
|
|
let source = r#"
|
|
let triple = (1, 2, 3)
|
|
let sum = triple.0 + triple.1 + triple.2
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "6");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tuple_index_in_function() {
|
|
let source = r#"
|
|
fn first(pair: (Int, String)): Int = pair.0
|
|
fn second(pair: (Int, String)): String = pair.1
|
|
let p = (42, "hello")
|
|
let result = first(p)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
|
|
// Exhaustiveness checking tests
|
|
mod exhaustiveness_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_exhaustive_bool_match() {
|
|
let source = r#"
|
|
fn check(b: Bool): Int = match b {
|
|
true => 1,
|
|
false => 0
|
|
}
|
|
let result = check(true)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_non_exhaustive_bool_match() {
|
|
let source = r#"
|
|
fn check(b: Bool): Int = match b {
|
|
true => 1
|
|
}
|
|
let result = check(true)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Non-exhaustive"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_exhaustive_option_match() {
|
|
let source = r#"
|
|
fn unwrap_or(opt: Option<Int>, default: Int): Int = match opt {
|
|
Some(x) => x,
|
|
None => default
|
|
}
|
|
let result = unwrap_or(Some(42), 0)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_non_exhaustive_option_missing_none() {
|
|
let source = r#"
|
|
fn get_value(opt: Option<Int>): Int = match opt {
|
|
Some(x) => x
|
|
}
|
|
let result = get_value(Some(1))
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Non-exhaustive"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_wildcard_is_exhaustive() {
|
|
let source = r#"
|
|
fn classify(n: Int): String = match n {
|
|
0 => "zero",
|
|
1 => "one",
|
|
_ => "many"
|
|
}
|
|
let result = classify(5)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_variable_pattern_is_exhaustive() {
|
|
let source = r#"
|
|
fn identity(n: Int): Int = match n {
|
|
x => x
|
|
}
|
|
let result = identity(42)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_redundant_arm_warning() {
|
|
let source = r#"
|
|
fn test_fn(n: Int): Int = match n {
|
|
_ => 1,
|
|
0 => 2
|
|
}
|
|
let result = test_fn(0)
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("Redundant"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_exhaustive_result_match() {
|
|
let source = r#"
|
|
fn handle_result(r: Result<Int, String>): Int = match r {
|
|
Ok(n) => n,
|
|
Err(_) => 0
|
|
}
|
|
let result = handle_result(Ok(42))
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_tail_call_optimization() {
|
|
// This test verifies that tail-recursive functions don't overflow the stack.
|
|
// Without TCO, a countdown from 10000 would cause a stack overflow.
|
|
let source = r#"
|
|
fn countdown(n: Int): Int = if n <= 0 then 0 else countdown(n - 1)
|
|
let result = countdown(10000)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tail_call_with_accumulator() {
|
|
// Test TCO with an accumulator pattern (common for tail-recursive sum)
|
|
let source = r#"
|
|
fn sum_to(n: Int, acc: Int): Int = if n <= 0 then acc else sum_to(n - 1, acc + n)
|
|
let result = sum_to(1000, 0)
|
|
"#;
|
|
// Sum from 1 to 1000 = 1000 * 1001 / 2 = 500500
|
|
assert_eq!(eval(source).unwrap(), "500500");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tail_call_in_match() {
|
|
// Test that TCO works through match expressions
|
|
let source = r#"
|
|
fn process(opt: Option<Int>, acc: Int): Int = match opt {
|
|
Some(n) => if n <= 0 then acc else process(Some(n - 1), acc + n),
|
|
None => acc
|
|
}
|
|
let result = process(Some(100), 0)
|
|
"#;
|
|
// Sum from 1 to 100 = 5050
|
|
assert_eq!(eval(source).unwrap(), "5050");
|
|
}
|
|
|
|
#[test]
|
|
fn test_trait_definition() {
|
|
// Test that trait declarations parse and type check correctly
|
|
let source = r#"
|
|
trait Show {
|
|
fn show(x: Int): String
|
|
}
|
|
let result = 42
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_trait_impl() {
|
|
// Test that impl declarations parse and type check correctly
|
|
let source = r#"
|
|
trait Double {
|
|
fn double(x: Int): Int
|
|
}
|
|
impl Double for Int {
|
|
fn double(x: Int): Int = x * 2
|
|
}
|
|
let result = 21
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_trait_with_super_trait() {
|
|
// Test super trait syntax
|
|
let source = r#"
|
|
trait Eq {
|
|
fn eq(a: Int, b: Int): Bool
|
|
}
|
|
trait Ord: Eq {
|
|
fn lt(a: Int, b: Int): Bool
|
|
}
|
|
let result = 42
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_impl_with_where_clause() {
|
|
// Test impl with where clause for trait constraints
|
|
let source = r#"
|
|
trait Show {
|
|
fn show(x: Int): String
|
|
}
|
|
let result = 42
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_effect_inference_function() {
|
|
// Test that effects are inferred when not explicitly declared
|
|
// This function uses Console effect without declaring it
|
|
let source = r#"
|
|
effect Console {
|
|
fn print(msg: String): Unit
|
|
}
|
|
fn greet(name: String): Unit = Console.print("Hello")
|
|
let result = 42
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success with inferred effects but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_effect_inference_lambda() {
|
|
// Test that lambda effects are inferred
|
|
let source = r#"
|
|
effect Logger {
|
|
fn log(msg: String): Unit
|
|
}
|
|
let logFn = fn(msg: String) => Logger.log(msg)
|
|
let result = 42
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success with inferred lambda effects but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_explicit_effects_validation() {
|
|
// Test that explicitly declared effects are validated against usage
|
|
let source = r#"
|
|
effect Console {
|
|
fn print(msg: String): Unit
|
|
}
|
|
fn greet(name: String): Unit with {Console} = Console.print("Hello")
|
|
let result = 42
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Expected success with explicit effects but got: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_doc_comments_on_function() {
|
|
// Test that doc comments are parsed and attached to functions
|
|
let source = r#"
|
|
/// Adds two numbers together.
|
|
/// Returns the sum.
|
|
fn add(a: Int, b: Int): Int = a + b
|
|
let result = add(1, 2)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_doc_comments_on_type() {
|
|
// Test that doc comments are parsed and attached to types
|
|
let source = r#"
|
|
/// A point in 2D space.
|
|
type Point { x: Int, y: Int }
|
|
let p = { x: 1, y: 2 }
|
|
let result = p.x + p.y
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_doc_comments_multiline() {
|
|
// Test that multiple doc comment lines are combined
|
|
let source = r#"
|
|
/// First line of documentation.
|
|
/// Second line of documentation.
|
|
/// Third line of documentation.
|
|
fn documented(): Int = 42
|
|
let result = documented()
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "42");
|
|
}
|
|
}
|
|
|
|
// Integration tests for example files
|
|
mod example_tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
fn check_file(path: &str) -> Result<(), String> {
|
|
let source = fs::read_to_string(path)
|
|
.map_err(|e| format!("Failed to read {}: {}", path, e))?;
|
|
|
|
let program = Parser::parse_source(&source)
|
|
.map_err(|e| format!("Parse error in {}: {}", path, e))?;
|
|
|
|
let mut checker = TypeChecker::new();
|
|
checker.check_program(&program).map_err(|errors| {
|
|
format!("Type errors in {}:\n{}", path,
|
|
errors.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("\n"))
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_hello() {
|
|
check_file("examples/hello.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_factorial() {
|
|
check_file("examples/factorial.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_functional() {
|
|
check_file("examples/functional.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_pipelines() {
|
|
check_file("examples/pipelines.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_tailcall() {
|
|
check_file("examples/tailcall.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_effects() {
|
|
check_file("examples/effects.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_handlers() {
|
|
check_file("examples/handlers.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_builtin_effects() {
|
|
check_file("examples/builtin_effects.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_datatypes() {
|
|
check_file("examples/datatypes.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_generics() {
|
|
check_file("examples/generics.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_traits() {
|
|
check_file("examples/traits.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_interpolation() {
|
|
check_file("examples/interpolation.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_behavioral() {
|
|
check_file("examples/behavioral.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_behavioral_types() {
|
|
check_file("examples/behavioral_types.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_versioning() {
|
|
check_file("examples/versioning.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_schema_evolution() {
|
|
check_file("examples/schema_evolution.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_file_io() {
|
|
check_file("examples/file_io.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_json() {
|
|
check_file("examples/json.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_random() {
|
|
check_file("examples/random.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_statemachine() {
|
|
check_file("examples/statemachine.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_shell() {
|
|
check_file("examples/shell.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_http() {
|
|
check_file("examples/http.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_http_server() {
|
|
check_file("examples/http_server.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_example_jit_test() {
|
|
check_file("examples/jit_test.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_json_parser() {
|
|
check_file("projects/json-parser/main.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_markdown_converter() {
|
|
check_file("projects/markdown-converter/main.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_todo_app() {
|
|
check_file("projects/todo-app/main.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_mini_interpreter() {
|
|
check_file("projects/mini-interpreter/main.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_guessing_game() {
|
|
check_file("projects/guessing-game/main.lux").unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_project_rest_api() {
|
|
check_file("projects/rest-api/main.lux").unwrap();
|
|
}
|
|
}
|
|
|
|
// === Map type tests ===
|
|
|
|
#[test]
|
|
fn test_map_new_and_size() {
|
|
let source = r#"
|
|
let m = Map.new()
|
|
let result = Map.size(m)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "0");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_set_and_get() {
|
|
let source = r#"
|
|
let m = Map.new()
|
|
let m2 = Map.set(m, "name", "Alice")
|
|
let result = Map.get(m2, "name")
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "Some(\"Alice\")");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_get_missing() {
|
|
let source = r#"
|
|
let m = Map.new()
|
|
let result = Map.get(m, "missing")
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "None");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_contains() {
|
|
let source = r#"
|
|
let m = Map.set(Map.new(), "x", 1)
|
|
let result = (Map.contains(m, "x"), Map.contains(m, "y"))
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "(true, false)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_remove() {
|
|
let source = r#"
|
|
let m = Map.set(Map.set(Map.new(), "a", 1), "b", 2)
|
|
let m2 = Map.remove(m, "a")
|
|
let result = (Map.size(m2), Map.contains(m2, "a"), Map.contains(m2, "b"))
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "(1, false, true)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_keys_and_values() {
|
|
let source = r#"
|
|
let m = Map.set(Map.set(Map.new(), "b", 2), "a", 1)
|
|
let result = Map.keys(m)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "[\"a\", \"b\"]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_from_list() {
|
|
let source = r#"
|
|
let m = Map.fromList([("x", 10), ("y", 20)])
|
|
let result = (Map.get(m, "x"), Map.size(m))
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "(Some(10), 2)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_to_list() {
|
|
let source = r#"
|
|
let m = Map.set(Map.set(Map.new(), "b", 2), "a", 1)
|
|
let result = Map.toList(m)
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "[(\"a\", 1), (\"b\", 2)]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_merge() {
|
|
let source = r#"
|
|
let m1 = Map.fromList([("a", 1), ("b", 2)])
|
|
let m2 = Map.fromList([("b", 3), ("c", 4)])
|
|
let merged = Map.merge(m1, m2)
|
|
let result = (Map.get(merged, "a"), Map.get(merged, "b"), Map.get(merged, "c"))
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "(Some(1), Some(3), Some(4))");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_immutability() {
|
|
let source = r#"
|
|
let m1 = Map.fromList([("a", 1)])
|
|
let m2 = Map.set(m1, "b", 2)
|
|
let result = (Map.size(m1), Map.size(m2))
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "(1, 2)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_is_empty() {
|
|
let source = r#"
|
|
let m1 = Map.new()
|
|
let m2 = Map.set(m1, "x", 1)
|
|
let result = (Map.isEmpty(m1), Map.isEmpty(m2))
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "(true, false)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_map_type_annotation() {
|
|
let source = r#"
|
|
fn lookup(m: Map<String, Int>, key: String): Option<Int> =
|
|
Map.get(m, key)
|
|
let m = Map.fromList([("age", 30)])
|
|
let result = lookup(m, "age")
|
|
"#;
|
|
assert_eq!(eval(source).unwrap(), "Some(30)");
|
|
}
|
|
|
|
#[test]
|
|
fn test_file_copy() {
|
|
use std::io::Write;
|
|
// Create a temp file, copy it, verify contents
|
|
let dir = std::env::temp_dir().join("lux_test_file_copy");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
let src = dir.join("src.txt");
|
|
let dst = dir.join("dst.txt");
|
|
std::fs::File::create(&src).unwrap().write_all(b"hello copy").unwrap();
|
|
let _ = std::fs::remove_file(&dst);
|
|
|
|
let source = format!(r#"
|
|
fn main(): Unit with {{File}} =
|
|
File.copy("{}", "{}")
|
|
let _ = run main() with {{}}
|
|
let result = "done"
|
|
"#, src.display(), dst.display());
|
|
let result = eval(&source);
|
|
assert!(result.is_ok(), "File.copy failed: {:?}", result);
|
|
let contents = std::fs::read_to_string(&dst).unwrap();
|
|
assert_eq!(contents, "hello copy");
|
|
|
|
// Cleanup
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectful_callback_propagation() {
|
|
// WISH-7: effectful callbacks in List.forEach should propagate effects
|
|
// This should type-check successfully because Console effect is inferred
|
|
let source = r#"
|
|
fn printAll(items: List<String>): Unit =
|
|
List.forEach(items, fn(x: String): Unit => Console.print(x))
|
|
let result = "ok"
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Effectful callback should type-check: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_effectful_callback_in_map() {
|
|
// Effectful callback in List.map should propagate effects
|
|
let source = r#"
|
|
fn readAll(paths: List<String>): List<String> =
|
|
List.map(paths, fn(p: String): String => File.read(p))
|
|
let result = "ok"
|
|
"#;
|
|
let result = eval(source);
|
|
assert!(result.is_ok(), "Effectful callback in map should type-check: {:?}", result);
|
|
}
|
|
}
|