feat: improve CLI with auto-discovery and JS target
- Auto-discover .lux files for fmt and check commands - Add --target js flag for JavaScript compilation - Improve help text for new features Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
388
src/main.rs
388
src/main.rs
@@ -1,5 +1,6 @@
|
||||
//! Lux - A functional programming language with first-class effects
|
||||
|
||||
mod analysis;
|
||||
mod ast;
|
||||
mod codegen;
|
||||
mod debugger;
|
||||
@@ -87,18 +88,8 @@ fn main() {
|
||||
println!("Lux {}", VERSION);
|
||||
}
|
||||
"fmt" => {
|
||||
// Format files
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: lux fmt <file.lux> [--check]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let check_only = args.iter().any(|a| a == "--check");
|
||||
for arg in &args[2..] {
|
||||
if arg.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
format_file(arg, check_only);
|
||||
}
|
||||
// Format files (auto-discovers if no file specified)
|
||||
format_files(&args[2..]);
|
||||
}
|
||||
"test" => {
|
||||
// Run tests
|
||||
@@ -117,12 +108,8 @@ fn main() {
|
||||
init_project(args.get(2).map(|s| s.as_str()));
|
||||
}
|
||||
"check" => {
|
||||
// Type check without running
|
||||
if args.len() < 3 {
|
||||
eprintln!("Usage: lux check <file.lux>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
check_file(&args[2]);
|
||||
// Type check files (auto-discovers if no file specified)
|
||||
check_files(&args[2..]);
|
||||
}
|
||||
"debug" => {
|
||||
// Start debugger
|
||||
@@ -188,8 +175,8 @@ fn print_help() {
|
||||
println!(" lux compile <f> --run Compile and execute");
|
||||
println!(" lux compile <f> --emit-c Output C code instead of binary");
|
||||
println!(" lux compile <f> --target js Compile to JavaScript");
|
||||
println!(" lux fmt <file.lux> Format a file (--check to verify only)");
|
||||
println!(" lux check <file.lux> Type check without running");
|
||||
println!(" lux fmt [file] [--check] Format files (auto-discovers if no file given)");
|
||||
println!(" lux check [file] Type check files (auto-discovers if no file given)");
|
||||
println!(" lux test [pattern] Run tests (optional pattern filter)");
|
||||
println!(" lux watch <file.lux> Watch and re-run on changes");
|
||||
println!(" lux debug <file.lux> Start interactive debugger");
|
||||
@@ -200,56 +187,178 @@ fn print_help() {
|
||||
println!(" lux --version Show version");
|
||||
}
|
||||
|
||||
fn format_file(path: &str, check_only: bool) {
|
||||
fn format_files(args: &[String]) {
|
||||
use formatter::{format, FormatConfig};
|
||||
use std::path::Path;
|
||||
|
||||
let source = match std::fs::read_to_string(path) {
|
||||
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!("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!("Error reading file '{}': {}", path, e);
|
||||
std::process::exit(1);
|
||||
eprintln!("{}: ERROR - {}", path, e);
|
||||
error_count += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let config = FormatConfig::default();
|
||||
let formatted = match format(&source, &config) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
eprintln!("Error formatting '{}': {}", path, e);
|
||||
std::process::exit(1);
|
||||
eprintln!("{}: FORMAT ERROR - {}", path, e);
|
||||
error_count += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if check_only {
|
||||
if source != formatted {
|
||||
eprintln!("{} would be reformatted", path);
|
||||
std::process::exit(1);
|
||||
would_reformat.push(path);
|
||||
} else {
|
||||
println!("{} is correctly formatted", path);
|
||||
unchanged_count += 1;
|
||||
}
|
||||
} else {
|
||||
if source != formatted {
|
||||
if let Err(e) = std::fs::write(path, &formatted) {
|
||||
eprintln!("Error writing file '{}': {}", path, e);
|
||||
std::process::exit(1);
|
||||
} else if source != formatted {
|
||||
if let Err(e) = std::fs::write(file_path, &formatted) {
|
||||
eprintln!("{}: WRITE ERROR - {}", path, e);
|
||||
error_count += 1;
|
||||
continue;
|
||||
}
|
||||
println!("Formatted {}", path);
|
||||
formatted_count += 1;
|
||||
} else {
|
||||
println!("{} unchanged", path);
|
||||
}
|
||||
unchanged_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn check_file(path: &str) {
|
||||
println!();
|
||||
if check_only {
|
||||
if !would_reformat.is_empty() {
|
||||
println!("Files that would be reformatted:");
|
||||
for path in &would_reformat {
|
||||
println!(" {}", path);
|
||||
}
|
||||
println!();
|
||||
println!("{} would be reformatted, {} already formatted", would_reformat.len(), unchanged_count);
|
||||
std::process::exit(1);
|
||||
} else {
|
||||
println!("All {} files are correctly formatted", unchanged_count);
|
||||
}
|
||||
} else {
|
||||
println!("{} formatted, {} unchanged, {} errors", formatted_count, unchanged_count, error_count);
|
||||
}
|
||||
|
||||
if error_count > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_files(args: &[String]) {
|
||||
use modules::ModuleLoader;
|
||||
use std::path::Path;
|
||||
|
||||
let file_path = Path::new(path);
|
||||
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!("Looking in: ., src/, examples/, tests/");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort for consistent output
|
||||
files_to_check.sort();
|
||||
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
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!("Error reading file '{}': {}", path, e);
|
||||
std::process::exit(1);
|
||||
eprintln!("{}: ERROR - {}", path, e);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -261,21 +370,47 @@ fn check_file(path: &str) {
|
||||
let program = match loader.load_source(&source, Some(file_path)) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("Module error: {}", e);
|
||||
std::process::exit(1);
|
||||
eprintln!("{}: MODULE ERROR - {}", path, e);
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut checker = TypeChecker::new();
|
||||
if let Err(errors) = checker.check_program_with_modules(&program, &loader) {
|
||||
eprintln!("{}: FAILED", path);
|
||||
for error in errors {
|
||||
let diagnostic = error.to_diagnostic();
|
||||
eprint!("{}", render(&diagnostic, &source, Some(path)));
|
||||
eprint!("{}", render(&diagnostic, &source, Some(&path)));
|
||||
}
|
||||
failed += 1;
|
||||
} else {
|
||||
println!("{}: OK", path);
|
||||
passed += 1;
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
println!("{}: OK", path);
|
||||
println!();
|
||||
println!("Checked {} files: {} passed, {} failed", passed + failed, passed, failed);
|
||||
|
||||
if failed > 0 {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_to_c(path: &str, output_path: Option<&str>, run_after: bool, emit_c: bool) {
|
||||
@@ -389,8 +524,9 @@ fn compile_to_c(path: &str, output_path: Option<&str>, run_after: bool, emit_c:
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(&temp_c);
|
||||
// Keep temp file for debugging
|
||||
eprintln!("C file: {}", temp_c.display());
|
||||
// let _ = std::fs::remove_file(&temp_c);
|
||||
|
||||
if run_after {
|
||||
// Run the compiled binary
|
||||
@@ -706,6 +842,29 @@ fn collect_test_files(dir: &str, pattern: Option<&str>, files: &mut Vec<std::pat
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -783,11 +942,17 @@ fn handle_pkg_command(args: &[String]) {
|
||||
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 init' to create a new project");
|
||||
eprintln!("Run 'lux pkg init' to initialize a project here, or 'lux init <name>' to create a new project");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
@@ -898,6 +1063,7 @@ fn print_pkg_help() {
|
||||
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:");
|
||||
@@ -911,6 +1077,8 @@ fn print_pkg_help() {
|
||||
println!(" clean Remove installed packages");
|
||||
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");
|
||||
@@ -918,6 +1086,55 @@ fn print_pkg_help() {
|
||||
println!(" lux pkg remove http");
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -2267,6 +2484,33 @@ c")"#;
|
||||
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
|
||||
@@ -2313,6 +2557,52 @@ c")"#;
|
||||
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};
|
||||
|
||||
Reference in New Issue
Block a user