From b0f6756411f6f71066a8f5ede6facf71ffe64750 Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Fri, 13 Feb 2026 16:09:20 -0500 Subject: [PATCH] feat: add File and Process effects for real I/O Adds two essential effects that enable Lux to interact with the system: File effect: - read(path) - Read file contents as string - write(path, content) - Write string to file - append(path, content) - Append to file - exists(path) - Check if file/directory exists - delete(path) - Delete a file - readDir(path) - List directory contents - isDir(path) - Check if path is directory - mkdir(path) - Create directory (including parents) Process effect: - exec(cmd) - Run shell command, return stdout - execStatus(cmd) - Run command, return exit code - env(name) - Get environment variable (returns Option) - args() - Get command line arguments - exit(code) - Exit program with code - cwd() - Get current working directory - setCwd(path) - Change working directory Also fixes formatter bug with empty handler blocks in `run ... with {}`. These effects make Lux capable of writing real CLI tools and scripts. Co-Authored-By: Claude Opus 4.5 --- examples/file_io.lux | 50 +++++++++ examples/shell.lux | 58 ++++++++++ src/formatter.rs | 60 ++++++++--- src/interpreter.rs | 247 +++++++++++++++++++++++++++++++++++++++++++ src/typechecker.rs | 4 +- src/types.rs | 103 ++++++++++++++++++ 6 files changed, 508 insertions(+), 14 deletions(-) create mode 100644 examples/file_io.lux create mode 100644 examples/shell.lux diff --git a/examples/file_io.lux b/examples/file_io.lux new file mode 100644 index 0000000..0c7f643 --- /dev/null +++ b/examples/file_io.lux @@ -0,0 +1,50 @@ +// File I/O example - demonstrates the File effect +// +// This script reads a file, counts lines/words, and writes a report + +fn countLines(content: String): Int = { + let lines = String.split(content, "\n") + List.length(lines) +} + +fn countWords(content: String): Int = { + let words = String.split(content, " ") + List.length(List.filter(words, fn(w: String): Bool => String.length(w) > 0)) +} + +fn analyzeFile(path: String): Unit with {File, Console} = { + Console.print("Analyzing file: " + path) + + if File.exists(path) then { + let content = File.read(path) + let lines = countLines(content) + let words = countWords(content) + let chars = String.length(content) + + Console.print(" Lines: " + toString(lines)) + Console.print(" Words: " + toString(words)) + Console.print(" Chars: " + toString(chars)) + } else { + Console.print(" Error: File not found!") + } +} + +fn main(): Unit with {File, Console} = { + Console.print("=== Lux File Analyzer ===") + Console.print("") + + // Analyze this file itself + analyzeFile("examples/file_io.lux") + Console.print("") + + // Analyze hello.lux + analyzeFile("examples/hello.lux") + Console.print("") + + // Write a report + let report = "File analysis complete.\nAnalyzed 2 files." + File.write("/tmp/lux_report.txt", report) + Console.print("Report written to /tmp/lux_report.txt") +} + +let result = run main() with {} diff --git a/examples/shell.lux b/examples/shell.lux new file mode 100644 index 0000000..7228386 --- /dev/null +++ b/examples/shell.lux @@ -0,0 +1,58 @@ +// Shell/Process example - demonstrates the Process effect +// +// This script runs shell commands and uses environment variables + +fn main(): Unit with {Process, Console} = { + Console.print("=== Lux Shell Example ===") + Console.print("") + + // Get current working directory + let cwd = Process.cwd() + Console.print("Current directory: " + cwd) + Console.print("") + + // Get environment variables + Console.print("Environment variables:") + match Process.env("USER") { + Some(user) => Console.print(" USER: " + user), + None => Console.print(" USER: (not set)") + } + match Process.env("HOME") { + Some(home) => Console.print(" HOME: " + home), + None => Console.print(" HOME: (not set)") + } + match Process.env("SHELL") { + Some(shell) => Console.print(" SHELL: " + shell), + None => Console.print(" SHELL: (not set)") + } + Console.print("") + + // Run shell commands + Console.print("Running shell commands:") + + let date = Process.exec("date") + Console.print(" date: " + String.trim(date)) + + let kernel = Process.exec("uname -r") + Console.print(" kernel: " + String.trim(kernel)) + + let files = Process.exec("ls examples/*.lux | wc -l") + Console.print(" .lux files in examples/: " + String.trim(files)) + Console.print("") + + // Command line arguments + Console.print("Command line arguments:") + let args = Process.args() + let argCount = List.length(args) + if argCount == 0 then { + Console.print(" (no arguments)") + } else { + Console.print(" Count: " + toString(argCount)) + match List.head(args) { + Some(first) => Console.print(" First: " + first), + None => Console.print(" First: (empty)") + } + } +} + +let result = run main() with {} diff --git a/src/formatter.rs b/src/formatter.rs index e1d9488..67ff5e3 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -167,19 +167,51 @@ impl Formatter { self.write(" ="); - // Body - let body_str = self.format_expr(&func.body); - if self.is_block_expr(&func.body) || body_str.contains('\n') { + // Body - handle blocks specially to keep `= {` on same line + if let Expr::Block { statements, result, .. } = &func.body { + self.write(" {"); self.newline(); self.indent_level += 1; + for stmt in statements { + let indent = self.indent(); + match stmt { + Statement::Let { name, typ, value, .. } => { + let type_str = typ.as_ref() + .map(|t| format!(": {}", self.format_type_expr(t))) + .unwrap_or_default(); + self.write(&indent); + self.writeln(&format!( + "let {}{} = {}", + name.name, + type_str, + self.format_expr(value) + )); + } + Statement::Expr(e) => { + self.write(&indent); + self.writeln(&self.format_expr(e)); + } + } + } self.write(&self.indent()); - self.write(&body_str); + self.writeln(&self.format_expr(result)); self.indent_level -= 1; + self.write(&self.indent()); + self.writeln("}"); } else { - self.write(" "); - self.write(&body_str); + let body_str = self.format_expr(&func.body); + if body_str.contains('\n') { + self.newline(); + self.indent_level += 1; + self.write(&self.indent()); + self.write(&body_str); + self.indent_level -= 1; + self.newline(); + } else { + self.write(" "); + self.writeln(&body_str); + } } - self.newline(); } fn format_let(&mut self, let_decl: &LetDecl) { @@ -674,12 +706,16 @@ impl Formatter { ) } Expr::Run { expr, handlers, .. } => { - let mut s = format!("run {} with {{\n", self.format_expr(expr)); - for (effect, handler) in handlers { - s.push_str(&format!(" {} = {},\n", effect.name, self.format_expr(handler))); + if handlers.is_empty() { + format!("run {} with {{}}", self.format_expr(expr)) + } else { + let mut s = format!("run {} with {{\n", self.format_expr(expr)); + for (effect, handler) in handlers { + s.push_str(&format!(" {} = {},\n", effect.name, self.format_expr(handler))); + } + s.push('}'); + s } - s.push('}'); - s } Expr::Resume { value, .. } => { format!("resume({})", self.format_expr(value)) diff --git a/src/interpreter.rs b/src/interpreter.rs index 3a2f887..609928c 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -2638,6 +2638,253 @@ impl Interpreter { thread::sleep(Duration::from_millis(ms)); Ok(Value::Unit) } + + // ===== File Effect ===== + ("File", "read") => { + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.read requires a string path".to_string(), + span: None, + }), + }; + match std::fs::read_to_string(&path) { + Ok(content) => Ok(Value::String(content)), + Err(e) => Err(RuntimeError { + message: format!("Failed to read file '{}': {}", path, e), + span: None, + }), + } + } + ("File", "write") => { + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.write requires a string path".to_string(), + span: None, + }), + }; + let content = match request.args.get(1) { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.write requires string content".to_string(), + span: None, + }), + }; + match std::fs::write(&path, &content) { + Ok(()) => Ok(Value::Unit), + Err(e) => Err(RuntimeError { + message: format!("Failed to write file '{}': {}", path, e), + span: None, + }), + } + } + ("File", "append") => { + use std::fs::OpenOptions; + use std::io::Write; + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.append requires a string path".to_string(), + span: None, + }), + }; + let content = match request.args.get(1) { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.append requires string content".to_string(), + span: None, + }), + }; + match OpenOptions::new().create(true).append(true).open(&path) { + Ok(mut file) => { + file.write_all(content.as_bytes()).map_err(|e| RuntimeError { + message: format!("Failed to append to file '{}': {}", path, e), + span: None, + })?; + Ok(Value::Unit) + } + Err(e) => Err(RuntimeError { + message: format!("Failed to open file '{}': {}", path, e), + span: None, + }), + } + } + ("File", "exists") => { + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.exists requires a string path".to_string(), + span: None, + }), + }; + Ok(Value::Bool(std::path::Path::new(&path).exists())) + } + ("File", "delete") => { + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.delete requires a string path".to_string(), + span: None, + }), + }; + match std::fs::remove_file(&path) { + Ok(()) => Ok(Value::Unit), + Err(e) => Err(RuntimeError { + message: format!("Failed to delete file '{}': {}", path, e), + span: None, + }), + } + } + ("File", "readDir") => { + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.readDir requires a string path".to_string(), + span: None, + }), + }; + match std::fs::read_dir(&path) { + Ok(entries) => { + let files: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| Value::String(e.file_name().to_string_lossy().to_string())) + .collect(); + Ok(Value::List(files)) + } + Err(e) => Err(RuntimeError { + message: format!("Failed to read directory '{}': {}", path, e), + span: None, + }), + } + } + ("File", "isDir") => { + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.isDir requires a string path".to_string(), + span: None, + }), + }; + Ok(Value::Bool(std::path::Path::new(&path).is_dir())) + } + ("File", "mkdir") => { + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "File.mkdir requires a string path".to_string(), + span: None, + }), + }; + match std::fs::create_dir_all(&path) { + Ok(()) => Ok(Value::Unit), + Err(e) => Err(RuntimeError { + message: format!("Failed to create directory '{}': {}", path, e), + span: None, + }), + } + } + + // ===== Process Effect ===== + ("Process", "exec") => { + use std::process::Command; + let cmd = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "Process.exec requires a string command".to_string(), + span: None, + }), + }; + match Command::new("sh").arg("-c").arg(&cmd).output() { + Ok(output) => { + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(Value::String(stdout)) + } + Err(e) => Err(RuntimeError { + message: format!("Failed to execute command: {}", e), + span: None, + }), + } + } + ("Process", "execStatus") => { + use std::process::Command; + let cmd = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "Process.execStatus requires a string command".to_string(), + span: None, + }), + }; + match Command::new("sh").arg("-c").arg(&cmd).status() { + Ok(status) => { + let code = status.code().unwrap_or(-1) as i64; + Ok(Value::Int(code)) + } + Err(e) => Err(RuntimeError { + message: format!("Failed to execute command: {}", e), + span: None, + }), + } + } + ("Process", "env") => { + let var_name = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "Process.env requires a string name".to_string(), + span: None, + }), + }; + match std::env::var(&var_name) { + Ok(value) => Ok(Value::Constructor { + name: "Some".to_string(), + fields: vec![Value::String(value)], + }), + Err(_) => Ok(Value::Constructor { + name: "None".to_string(), + fields: vec![], + }), + } + } + ("Process", "args") => { + let args: Vec = std::env::args() + .skip(1) // Skip the program name + .map(Value::String) + .collect(); + Ok(Value::List(args)) + } + ("Process", "exit") => { + let code = match request.args.first() { + Some(Value::Int(n)) => *n as i32, + _ => 0, + }; + std::process::exit(code); + } + ("Process", "cwd") => { + match std::env::current_dir() { + Ok(path) => Ok(Value::String(path.to_string_lossy().to_string())), + Err(e) => Err(RuntimeError { + message: format!("Failed to get current directory: {}", e), + span: None, + }), + } + } + ("Process", "setCwd") => { + let path = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "Process.setCwd requires a string path".to_string(), + span: None, + }), + }; + match std::env::set_current_dir(&path) { + Ok(()) => Ok(Value::Unit), + Err(e) => Err(RuntimeError { + message: format!("Failed to change directory to '{}': {}", path, e), + span: None, + }), + } + } + _ => Err(RuntimeError { message: format!( "Unhandled effect operation: {}.{}", diff --git a/src/typechecker.rs b/src/typechecker.rs index 9a6ca16..eb7cca4 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -935,7 +935,7 @@ impl TypeChecker { } // Built-in effects are always available - let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time"]; + let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process"]; let is_builtin = builtin_effects.contains(&effect.name.as_str()); // Track this effect for inference @@ -1440,7 +1440,7 @@ impl TypeChecker { // Built-in effects are always available in run blocks (they have runtime implementations) let builtin_effects: EffectSet = - EffectSet::from_iter(["Console", "Fail", "State", "Reader", "Random", "Time"].iter().map(|s| s.to_string())); + EffectSet::from_iter(["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process"].iter().map(|s| s.to_string())); // Extend current effects with handled ones and built-in effects let combined = self.current_effects.union(&handled_effects).union(&builtin_effects); diff --git a/src/types.rs b/src/types.rs index c2929a3..4bcb552 100644 --- a/src/types.rs +++ b/src/types.rs @@ -852,6 +852,109 @@ impl TypeEnv { }, ); + // Add File effect + env.effects.insert( + "File".to_string(), + EffectDef { + name: "File".to_string(), + type_params: Vec::new(), + operations: vec![ + EffectOpDef { + name: "read".to_string(), + params: vec![("path".to_string(), Type::String)], + return_type: Type::String, + }, + EffectOpDef { + name: "write".to_string(), + params: vec![ + ("path".to_string(), Type::String), + ("content".to_string(), Type::String), + ], + return_type: Type::Unit, + }, + EffectOpDef { + name: "append".to_string(), + params: vec![ + ("path".to_string(), Type::String), + ("content".to_string(), Type::String), + ], + return_type: Type::Unit, + }, + EffectOpDef { + name: "exists".to_string(), + params: vec![("path".to_string(), Type::String)], + return_type: Type::Bool, + }, + EffectOpDef { + name: "delete".to_string(), + params: vec![("path".to_string(), Type::String)], + return_type: Type::Unit, + }, + EffectOpDef { + name: "readDir".to_string(), + params: vec![("path".to_string(), Type::String)], + return_type: Type::List(Box::new(Type::String)), + }, + EffectOpDef { + name: "isDir".to_string(), + params: vec![("path".to_string(), Type::String)], + return_type: Type::Bool, + }, + EffectOpDef { + name: "mkdir".to_string(), + params: vec![("path".to_string(), Type::String)], + return_type: Type::Unit, + }, + ], + }, + ); + + // Add Process effect + env.effects.insert( + "Process".to_string(), + EffectDef { + name: "Process".to_string(), + type_params: Vec::new(), + operations: vec![ + EffectOpDef { + name: "exec".to_string(), + params: vec![("cmd".to_string(), Type::String)], + return_type: Type::String, + }, + EffectOpDef { + name: "execStatus".to_string(), + params: vec![("cmd".to_string(), Type::String)], + return_type: Type::Int, + }, + EffectOpDef { + name: "env".to_string(), + params: vec![("name".to_string(), Type::String)], + return_type: Type::Option(Box::new(Type::String)), + }, + EffectOpDef { + name: "args".to_string(), + params: Vec::new(), + return_type: Type::List(Box::new(Type::String)), + }, + EffectOpDef { + name: "exit".to_string(), + params: vec![("code".to_string(), Type::Int)], + return_type: Type::Unit, + }, + EffectOpDef { + name: "cwd".to_string(), + params: Vec::new(), + return_type: Type::String, + }, + EffectOpDef { + name: "setCwd".to_string(), + params: vec![("path".to_string(), Type::String)], + return_type: Type::Unit, + }, + ], + }, + ); + // Add Some and Ok, Err constructors // Some : fn(a) -> Option let a = Type::var();