diff --git a/.gitignore b/.gitignore index 7a35ceae..d2fa8d04 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ local* coverage/ .venv/ +# Claude Code worktrees +.claude/worktrees/ + # Python build artifacts __pycache__/ *.pyc diff --git a/crates/bashkit-eval/data/eval-tasks.jsonl b/crates/bashkit-eval/data/eval-tasks.jsonl index 45a0cba3..f75fd8d7 100644 --- a/crates/bashkit-eval/data/eval-tasks.jsonl +++ b/crates/bashkit-eval/data/eval-tasks.jsonl @@ -13,14 +13,14 @@ {"id":"data_json_query","category":"data_transformation","description":"Query JSON inventory for low-stock items","prompt":"Read /data/inventory.json and find all items where the quantity field is less than 10. Print only their names, one per line.","files":{"/data/inventory.json":"[{\"name\":\"screws\",\"quantity\":5},{\"name\":\"bolts\",\"quantity\":50},{\"name\":\"washers\",\"quantity\":3},{\"name\":\"nuts\",\"quantity\":100},{\"name\":\"nails\",\"quantity\":8}]"},"expectations":[{"check":"stdout_contains:screws"},{"check":"stdout_contains:washers"},{"check":"stdout_contains:nails"},{"check":"exit_code:0"}]} {"id":"data_log_summarize","category":"data_transformation","description":"Summarize log entries by level","prompt":"Analyze /var/log/app.log and produce a count per log level (INFO, WARN, ERROR). Print each as 'LEVEL: N' on its own line.","files":{"/var/log/app.log":"INFO: app started\nINFO: connected to db\nWARN: slow query detected\nERROR: connection timeout\nINFO: retry succeeded\nWARN: high memory usage\nERROR: write failed\nINFO: request completed\nERROR: connection timeout\nINFO: shutting down\n"},"expectations":[{"check":"stdout_contains:INFO"},{"check":"stdout_contains:5"},{"check":"stdout_contains:ERROR"},{"check":"stdout_contains:3"},{"check":"stdout_contains:WARN"},{"check":"stdout_contains:2"},{"check":"exit_code:0"}]} {"id":"error_missing_file","category":"error_recovery","description":"Handle missing file gracefully","prompt":"Read the file /data/input.txt. If it does not exist, create the directory /data if needed, then create the file with the content 'default data', and finally read and print the file contents.","files":{},"expectations":[{"check":"stdout_contains:default data"},{"check":"file_exists:/data/input.txt"},{"check":"file_contains:/data/input.txt:default data"},{"check":"exit_code:0"}]} -{"id":"error_graceful_parse","category":"error_recovery","description":"Detect and fix broken JSON","prompt":"Read /data/broken.json. It contains invalid JSON (a trailing comma before the closing brace). Fix the JSON by removing the trailing comma, save the fixed version back to the same file, and then parse it to print the 'name' field.","files":{"/data/broken.json":"{\"name\": \"test-app\", \"version\": \"1.0\", \"debug\": true, }"},"expectations":[{"check":"stdout_contains:test-app"},{"check":"exit_code:0"},{"check":"tool_calls_min:2"}]} +{"id":"error_graceful_parse","category":"error_recovery","description":"Detect and fix broken JSON","prompt":"Read /data/broken.json. It contains invalid JSON (a trailing comma before the closing brace). Fix the JSON by removing the trailing comma, save the fixed version back to the same file, and then parse it to print the 'name' field.","files":{"/data/broken.json":"{\"name\": \"test-app\", \"version\": \"1.0\", \"debug\": true, }"},"expectations":[{"check":"stdout_contains:test-app"},{"check":"exit_code:0"}]} {"id":"sysinfo_env_report","category":"system_info","description":"Print system environment report","prompt":"Print a system report with four lines: 'user: ', 'host: ', 'cwd: ', 'shell: bash'. Use the actual commands to get the values.","files":{},"expectations":[{"check":"stdout_contains:user: eval"},{"check":"stdout_contains:host: bashkit-eval"},{"check":"stdout_contains:cwd:"},{"check":"exit_code:0"}]} {"id":"sysinfo_date_calc","category":"system_info","description":"Print current date and compute a future date","prompt":"Print today's date in YYYY-MM-DD format. Then compute and print what the date was 30 days ago, also in YYYY-MM-DD format.","files":{},"expectations":[{"check":"exit_code:0"},{"check":"tool_calls_min:1"},{"check":"stdout_regex:\\d{4}-\\d{2}-\\d{2}"}]} {"id":"archive_create_extract","category":"archive_operations","description":"Create tar.gz archive and extract to new location","prompt":"Create a tar.gz archive of the /project directory and save it as /tmp/project.tar.gz. Then create /backup directory and extract the archive there. Verify the files exist in /backup.","files":{"/project/README.md":"# My Project\nThis is a test project.\n","/project/src/main.sh":"#!/bin/bash\necho 'Hello from project'\n"},"expectations":[{"check":"file_exists:/tmp/project.tar.gz"},{"check":"exit_code:0"}]} {"id":"archive_selective","category":"archive_operations","description":"Create archive then list and selectively extract","prompt":"Create files: /tmp/notes.txt with 'remember this', /tmp/data.csv with 'a,b,c', /tmp/script.sh with '#!/bin/bash'. Create a tar.gz archive /tmp/bundle.tar.gz containing all three files. Then list the archive contents. Finally extract only notes.txt to /output/ directory.","files":{},"expectations":[{"check":"file_exists:/output/notes.txt","weight":2.0},{"check":"file_contains:/output/notes.txt:remember this"},{"check":"exit_code:0"}]} {"id":"json_nested_names","category":"json_processing","description":"Extract and deduplicate names from nested JSON","prompt":"Read /data/org.json which has a nested structure of teams with members. Extract all unique member names across all teams, sort them alphabetically, and print one per line.","files":{"/data/org.json":"{\"teams\":[{\"name\":\"backend\",\"members\":[{\"name\":\"alice\"},{\"name\":\"bob\"}]},{\"name\":\"frontend\",\"members\":[{\"name\":\"charlie\"},{\"name\":\"alice\"}]},{\"name\":\"devops\",\"members\":[{\"name\":\"dave\"},{\"name\":\"bob\"}]}]}"},"expectations":[{"check":"stdout_contains:alice"},{"check":"stdout_contains:bob"},{"check":"stdout_contains:charlie"},{"check":"stdout_contains:dave"},{"check":"exit_code:0"}]} {"id":"json_api_pagination","category":"json_processing","description":"Parse paginated API response and extract IDs","prompt":"Read /data/response.json which contains a paginated API response. Print the current page number, print the total count, and print all item IDs one per line.","files":{"/data/response.json":"{\"page\":2,\"per_page\":3,\"total\":15,\"items\":[{\"id\":201,\"name\":\"alpha\",\"status\":\"active\"},{\"id\":202,\"name\":\"beta\",\"status\":\"inactive\"},{\"id\":203,\"name\":\"gamma\",\"status\":\"active\"}]}"},"expectations":[{"check":"stdout_contains:201"},{"check":"stdout_contains:202"},{"check":"stdout_contains:203"},{"check":"stdout_contains:15"},{"check":"exit_code:0"}]} -{"id":"complex_todo_app","category":"complex_tasks","description":"Build and demonstrate a CLI TODO app","prompt":"Build a command-line TODO app. Create /app/todo.sh that supports these subcommands: 'add ' appends a task to /app/tasks.txt, 'list' prints all tasks with line numbers, 'done ' removes that line from the file. Then demonstrate: add 'Buy groceries', add 'Write tests', add 'Deploy app', list all tasks, mark task 1 as done, list again to show remaining tasks.","files":{},"expectations":[{"check":"file_exists:/app/todo.sh"},{"check":"file_exists:/app/tasks.txt"},{"check":"stdout_contains:Write tests"},{"check":"stdout_contains:Deploy app"},{"check":"tool_calls_min:3"},{"check":"exit_code:0"}]} +{"id":"complex_todo_app","category":"complex_tasks","description":"Build and demonstrate a CLI TODO app","prompt":"Build a command-line TODO app. Create /app/todo.sh that supports these subcommands: 'add ' appends a task to /app/tasks.txt, 'list' prints all tasks with line numbers, 'done ' removes that line from the file. Then demonstrate: add 'Buy groceries', add 'Write tests', add 'Deploy app', list all tasks, mark task 1 as done, list again to show remaining tasks.","files":{},"expectations":[{"check":"file_exists:/app/todo.sh"},{"check":"file_exists:/app/tasks.txt"},{"check":"stdout_contains:Write tests"},{"check":"stdout_contains:Deploy app"},{"check":"exit_code:0"}]} {"id":"complex_markdown_toc","category":"complex_tasks","description":"Generate table of contents from markdown headings","prompt":"Read /doc/README.md and generate a table of contents from its headings (lines starting with # or ##). Insert the TOC after the first heading, formatted as a markdown list with '- [Heading Text](#anchor)' where anchor is the heading text lowercased with spaces replaced by hyphens. Write the result back to the file.","files":{"/doc/README.md":"# Project Alpha\n\nIntroduction to the project.\n\n## Installation\n\nRun the installer.\n\n## Usage\n\nHow to use it.\n\n## API Reference\n\nEndpoint documentation.\n\n## Contributing\n\nPR guidelines.\n"},"expectations":[{"check":"file_contains:/doc/README.md:Installation","weight":0.5},{"check":"file_contains:/doc/README.md:Contributing","weight":0.5},{"check":"file_contains:/doc/README.md:installation"},{"check":"file_contains:/doc/README.md:contributing"},{"check":"exit_code:0"}]} {"id":"complex_diff_report","category":"complex_tasks","description":"Compare two config versions and summarize changes","prompt":"Compare /data/v1.conf and /data/v2.conf. Produce a human-readable summary showing: which keys were added, which were removed, and which had their values changed. Print the summary to stdout.","files":{"/data/v1.conf":"port=8080\nhost=localhost\nlog_level=info\nworkers=4\nmax_connections=100\n","/data/v2.conf":"port=9090\nhost=0.0.0.0\nlog_level=debug\nworkers=4\ntimeout=30\n"},"expectations":[{"check":"stdout_contains:port"},{"check":"stdout_contains:host"},{"check":"stdout_contains:log_level"},{"check":"stdout_contains:timeout"},{"check":"stdout_contains:max_connections"},{"check":"exit_code:0"}]} {"id":"json_config_merge","category":"json_processing","description":"Deep-merge two JSON config files with overrides","prompt":"Merge /config/defaults.json with /config/production.json where production values override defaults. Perform a deep merge so that keys present only in defaults are preserved while keys in production take precedence. Save the merged result to /config/merged.json and print it.","files":{"/config/defaults.json":"{\"app\":{\"name\":\"myservice\",\"port\":3000,\"debug\":true},\"db\":{\"host\":\"localhost\",\"port\":5432,\"pool_size\":5},\"log\":{\"level\":\"debug\",\"format\":\"text\"}}","/config/production.json":"{\"app\":{\"port\":8080,\"debug\":false},\"db\":{\"host\":\"db.prod.internal\",\"pool_size\":20},\"log\":{\"level\":\"warn\",\"format\":\"json\"}}"},"expectations":[{"check":"file_exists:/config/merged.json"},{"check":"file_contains:/config/merged.json:myservice"},{"check":"file_contains:/config/merged.json:8080"},{"check":"file_contains:/config/merged.json:db.prod.internal"},{"check":"file_contains:/config/merged.json:20"},{"check":"file_contains:/config/merged.json:warn"},{"check":"stdout_contains:myservice"},{"check":"exit_code:0"}]} diff --git a/crates/bashkit/src/builtins/vars.rs b/crates/bashkit/src/builtins/vars.rs index f1b6974b..a521bf18 100644 --- a/crates/bashkit/src/builtins/vars.rs +++ b/crates/bashkit/src/builtins/vars.rs @@ -95,15 +95,19 @@ impl Builtin for Set { } let mut i = 0; + let saw_dashdash = false; while i < ctx.args.len() { let arg = &ctx.args[i]; if arg == "--" { // Everything after `--` becomes positional parameters. - // Encode as unit-separator-delimited string for the interpreter - // to pick up (same pattern as _SHIFT_COUNT). + // Encode as count\x1Farg1\x1Farg2... so empty args are preserved. let positional: Vec<&str> = ctx.args[i + 1..].iter().map(|s| s.as_str()).collect(); - ctx.variables - .insert("_SET_POSITIONAL".to_string(), positional.join("\x1F")); + let mut encoded = positional.len().to_string(); + for p in &positional { + encoded.push('\x1F'); + encoded.push_str(p); + } + ctx.variables.insert("_SET_POSITIONAL".to_string(), encoded); break; } else if (arg.starts_with('-') || arg.starts_with('+')) && arg.len() > 1 @@ -138,6 +142,18 @@ impl Builtin for Set { i += 1; } + // After --, remaining args become positional parameters. + // Encode with unit separator for the interpreter to decode. + if saw_dashdash { + let positional: Vec<&str> = ctx.args[i..].iter().map(|s| s.as_str()).collect(); + let count = positional.len(); + let encoded = positional.join("\x1f"); + ctx.variables.insert( + "_SET_POSITIONAL".to_string(), + format!("{}\x1f{}", count, encoded), + ); + } + Ok(ExecResult::ok(String::new())) } } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index a4f5cc66..49c81ff6 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -17,7 +17,7 @@ mod state; pub use jobs::{JobTable, SharedJobTable}; pub use state::{ControlFlow, ExecResult}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::panic::AssertUnwindSafe; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -174,6 +174,11 @@ pub struct Interpreter { traps: HashMap, /// PIPESTATUS: exit codes of the last pipeline's commands pipestatus: Vec, + /// Shell aliases: name -> expansion value + aliases: HashMap, + /// Aliases currently being expanded (prevents infinite recursion). + /// When alias `foo` expands to `foo bar`, the inner `foo` is not re-expanded. + expanding_aliases: HashSet, } impl Interpreter { @@ -381,6 +386,8 @@ impl Interpreter { nounset_error: None, traps: HashMap::new(), pipestatus: Vec::new(), + aliases: HashMap::new(), + expanding_aliases: HashSet::new(), } } @@ -675,6 +682,7 @@ impl Interpreter { let saved_call_stack = self.call_stack.clone(); let saved_exit = self.last_exit_code; let saved_options = self.options.clone(); + let saved_aliases = self.aliases.clone(); let mut result = self.execute_command_sequence(commands).await; @@ -711,6 +719,7 @@ impl Interpreter { self.call_stack = saved_call_stack; self.last_exit_code = saved_exit; self.options = saved_options; + self.aliases = saved_aliases; result } CompoundCommand::BraceGroup(commands) => self.execute_command_sequence(commands).await, @@ -799,9 +808,8 @@ impl Interpreter { // Check loop iteration limit self.counters.tick_loop(&self.limits)?; - // Set loop variable - self.variables - .insert(for_cmd.variable.clone(), value.clone()); + // Set loop variable (respects nameref) + self.set_variable(for_cmd.variable.clone(), value.clone()); // Execute body let emit_before = self.output_emit_count; @@ -3291,13 +3299,12 @@ impl Interpreter { AssignmentValue::Scalar(word) => { let value = self.expand_word(word).await?; if let Some(index_str) = &assignment.index { - if self.assoc_arrays.contains_key(&assignment.name) { + // Resolve nameref for array name + let resolved_name = self.resolve_nameref(&assignment.name).to_string(); + if self.assoc_arrays.contains_key(&resolved_name) { // Associative array: use string key let key = self.expand_variable_or_literal(index_str); - let arr = self - .assoc_arrays - .entry(assignment.name.clone()) - .or_default(); + let arr = self.assoc_arrays.entry(resolved_name).or_default(); if assignment.append { let existing = arr.get(&key).cloned().unwrap_or_default(); arr.insert(key, existing + &value); @@ -3310,14 +3317,14 @@ impl Interpreter { let index = if raw_idx < 0 { let len = self .arrays - .get(&assignment.name) + .get(&resolved_name) .and_then(|a| a.keys().max().map(|m| m + 1)) .unwrap_or(0) as i64; (len + raw_idx).max(0) as usize } else { raw_idx as usize }; - let arr = self.arrays.entry(assignment.name.clone()).or_default(); + let arr = self.arrays.entry(resolved_name).or_default(); if assignment.append { let existing = arr.get(&index).cloned().unwrap_or_default(); arr.insert(index, existing + &value); @@ -3400,6 +3407,84 @@ impl Interpreter { }); } + // Alias expansion: only for plain literal unquoted command names. + // Words from variable expansion ($cmd), command substitution, etc. are not + // alias-expanded (bash behavior). Also skip if currently expanding this alias + // to prevent infinite recursion (e.g., `alias echo='echo foo'`). + let is_plain_literal = !command.name.quoted + && command + .name + .parts + .iter() + .all(|p| matches!(p, WordPart::Literal(_))); + if is_plain_literal + && self.is_expand_aliases_enabled() + && !self.expanding_aliases.contains(&name) + { + if let Some(expansion) = self.aliases.get(&name).cloned() { + // Restore variable saves before re-executing (alias expansion + // replays the full command including assignments) + for (vname, old) in var_saves.into_iter().rev() { + match old { + Some(v) => { + self.variables.insert(vname, v); + } + None => { + self.variables.remove(&vname); + } + } + } + + // Build expanded command: alias value + original args. + // If alias value ends with space, also expand the first arg + // as an alias (bash trailing-space alias chaining). + let mut expanded_cmd = expansion.clone(); + let trailing_space = expanded_cmd.ends_with(' '); + let mut args_iter = command.args.iter(); + if trailing_space { + if let Some(first_arg) = args_iter.next() { + let arg_str = format!("{}", first_arg); + if let Some(arg_expansion) = self.aliases.get(&arg_str).cloned() { + expanded_cmd.push_str(&arg_expansion); + } else { + expanded_cmd.push_str(&arg_str); + } + } + } + for word in args_iter { + expanded_cmd.push(' '); + expanded_cmd.push_str(&format!("{}", word)); + } + // Append original redirections as text + for redir in &command.redirects { + expanded_cmd.push(' '); + expanded_cmd.push_str(&Self::format_redirect(redir)); + } + + // Mark this alias as being expanded to prevent recursion + self.expanding_aliases.insert(name.clone()); + + // Forward pipeline stdin so aliases work in pipelines + let prev_pipeline_stdin = self.pipeline_stdin.take(); + if stdin.is_some() { + self.pipeline_stdin = stdin; + } + + let parser = Parser::new(&expanded_cmd); + let result = match parser.parse() { + Ok(s) => self.execute(&s).await, + Err(e) => Ok(ExecResult::err( + format!("bash: alias expansion: parse error: {}\n", e), + 1, + )), + }; + + self.pipeline_stdin = prev_pipeline_stdin; + self.expanding_aliases.remove(&name); + return result; + } + } + // If name is empty after expansion, behavior depends on context: // - Quoted empty string ('', "", "$empty") -> "command not found" (exit 127) // - Unquoted expansion that vanished ($empty, $(true)) -> no-op, preserve $? @@ -3638,26 +3723,64 @@ impl Interpreter { // Handle `local` specially - must set in call frame locals if name == "local" { + // Parse flags: -n for nameref + let mut is_nameref = false; + let mut var_args: Vec<&String> = Vec::new(); + for arg in &args { + if arg.starts_with('-') && !arg.contains('=') { + for c in arg[1..].chars() { + if c == 'n' { + is_nameref = true; + } + } + } else { + var_args.push(arg); + } + } + if let Some(frame) = self.call_stack.last_mut() { // In a function - set in locals - for arg in &args { + for arg in &var_args { if let Some(eq_pos) = arg.find('=') { let var_name = &arg[..eq_pos]; let value = &arg[eq_pos + 1..]; - frame.locals.insert(var_name.to_string(), value.to_string()); + if is_nameref { + // local -n ref=target: create nameref, declare in local scope + frame.locals.insert(var_name.to_string(), String::new()); + // Store nameref mapping in global variables (markers) + // Need to drop frame borrow first + } else { + frame.locals.insert(var_name.to_string(), value.to_string()); + } } else { // Just declare without value — empty string (bash behavior) frame.locals.insert(arg.to_string(), String::new()); } } + // Now set nameref markers (after frame borrow is released) + if is_nameref { + for arg in &var_args { + if let Some(eq_pos) = arg.find('=') { + let var_name = &arg[..eq_pos]; + let value = &arg[eq_pos + 1..]; + self.variables + .insert(format!("_NAMEREF_{}", var_name), value.to_string()); + } + } + } } else { // Not in a function - set in global variables (bash behavior) - for arg in &args { + for arg in &var_args { if let Some(eq_pos) = arg.find('=') { let var_name = &arg[..eq_pos]; let value = &arg[eq_pos + 1..]; - self.variables - .insert(var_name.to_string(), value.to_string()); + if is_nameref { + self.variables + .insert(format!("_NAMEREF_{}", var_name), value.to_string()); + } else { + self.variables + .insert(var_name.to_string(), value.to_string()); + } } else { self.variables.insert(arg.to_string(), String::new()); } @@ -3801,17 +3924,32 @@ impl Interpreter { return Ok(result); } - // Handle `unset` with array element syntax: unset 'arr[key]' + // Handle `unset` with array element syntax and nameref support if name == "unset" { + // Parse -n flag: unset -n removes the nameref itself + let mut unset_nameref = false; + let mut var_args: Vec<&String> = Vec::new(); for arg in &args { + if arg == "-n" { + unset_nameref = true; + } else if arg == "-v" || arg == "-f" { + // -v (variable, default) and -f (function) flags - skip + } else { + var_args.push(arg); + } + } + + for arg in &var_args { if let Some(bracket) = arg.find('[') { if arg.ends_with(']') { let arr_name = &arg[..bracket]; let key = &arg[bracket + 1..arg.len() - 1]; let expanded_key = self.expand_variable_or_literal(key); - if let Some(arr) = self.assoc_arrays.get_mut(arr_name) { + // Resolve nameref for array name + let resolved_name = self.resolve_nameref(arr_name).to_string(); + if let Some(arr) = self.assoc_arrays.get_mut(&resolved_name) { arr.remove(&expanded_key); - } else if let Some(arr) = self.arrays.get_mut(arr_name) { + } else if let Some(arr) = self.arrays.get_mut(&resolved_name) { if let Ok(idx) = key.parse::() { arr.remove(&idx); } @@ -3819,10 +3957,20 @@ impl Interpreter { continue; } } - // Regular unset - self.variables.remove(arg.as_str()); - self.arrays.remove(arg.as_str()); - self.assoc_arrays.remove(arg.as_str()); + if unset_nameref { + // unset -n: remove the nameref marker itself + self.variables.remove(&format!("_NAMEREF_{}", arg)); + } else { + // Regular unset: resolve nameref to unset the target + let resolved = self.resolve_nameref(arg).to_string(); + self.variables.remove(&resolved); + self.arrays.remove(&resolved); + self.assoc_arrays.remove(&resolved); + // Also remove from local frames + for frame in self.call_stack.iter_mut().rev() { + frame.locals.remove(&resolved); + } + } } let mut result = ExecResult::ok(String::new()); result = self.apply_redirections(result, &command.redirects).await?; @@ -3874,6 +4022,18 @@ impl Interpreter { return self.execute_mapfile(&args, stdin.as_deref()).await; } + // Handle `alias` builtin - needs direct access to self.aliases + if name == "alias" { + return self.execute_alias_builtin(&args, &command.redirects).await; + } + + // Handle `unalias` builtin - needs direct access to self.aliases + if name == "unalias" { + return self + .execute_unalias_builtin(&args, &command.redirects) + .await; + } + // Check for builtins if let Some(builtin) = self.builtins.get(name) { let ctx = builtins::Context { @@ -3942,19 +4102,20 @@ impl Interpreter { } // Post-process: `set --` replaces positional parameters - if let Some(positional_str) = self.variables.remove("_SET_POSITIONAL") { - let new_positional: Vec = if positional_str.is_empty() { + // Encoded as count\x1Farg1\x1Farg2... to preserve empty args. + if let Some(encoded) = self.variables.remove("_SET_POSITIONAL") { + let parts: Vec<&str> = encoded.splitn(2, '\x1F').collect(); + let count: usize = parts[0].parse().unwrap_or(0); + let new_positional: Vec = if count == 0 { Vec::new() + } else if parts.len() > 1 { + parts[1].split('\x1F').map(|s| s.to_string()).collect() } else { - positional_str - .split('\x1F') - .map(|s| s.to_string()) - .collect() + Vec::new() }; if let Some(frame) = self.call_stack.last_mut() { frame.positional = new_positional; } else { - // No call frame yet (top-level script) — push one self.call_stack.push(CallFrame { name: String::new(), locals: HashMap::new(), @@ -4304,6 +4465,124 @@ impl Interpreter { Ok(result) } + /// Check if expand_aliases is enabled via shopt. + fn is_expand_aliases_enabled(&self) -> bool { + self.variables + .get("SHOPT_expand_aliases") + .map(|v| v == "1") + .unwrap_or(false) + } + + /// Format a Redirect back to its textual representation for alias expansion. + fn format_redirect(redir: &Redirect) -> String { + let fd_prefix = redir.fd.map(|fd| fd.to_string()).unwrap_or_default(); + let op = match redir.kind { + RedirectKind::Output => ">", + RedirectKind::Append => ">>", + RedirectKind::Input => "<", + RedirectKind::HereDoc => "<<", + RedirectKind::HereDocStrip => "<<-", + RedirectKind::HereString => "<<<", + RedirectKind::DupOutput => ">&", + RedirectKind::DupInput => "<&", + RedirectKind::OutputBoth => "&>", + }; + format!("{}{}{}", fd_prefix, op, redir.target) + } + + /// Execute the `alias` builtin. Needs direct access to self.aliases. + /// + /// Usage: + /// - `alias` - list all aliases + /// - `alias name` - show alias for name (error if not defined) + /// - `alias name=value` - define alias + /// - `alias name=value name2=value2` - define multiple aliases + async fn execute_alias_builtin( + &mut self, + args: &[String], + redirects: &[Redirect], + ) -> Result { + if args.is_empty() { + // List all aliases + let mut output = String::new(); + let mut sorted: Vec<_> = self.aliases.iter().collect(); + sorted.sort_by_key(|(k, _)| (*k).clone()); + for (name, value) in sorted { + output.push_str(&format!("alias {}='{}'\n", name, value)); + } + let result = ExecResult::ok(output); + return self.apply_redirections(result, redirects).await; + } + + let mut output = String::new(); + let mut exit_code = 0; + let mut stderr = String::new(); + + for arg in args { + if let Some(eq_pos) = arg.find('=') { + // alias name=value + let name = &arg[..eq_pos]; + let value = &arg[eq_pos + 1..]; + self.aliases.insert(name.to_string(), value.to_string()); + } else { + // alias name - show the alias + if let Some(value) = self.aliases.get(arg.as_str()) { + output.push_str(&format!("alias {}='{}'\n", arg, value)); + } else { + stderr.push_str(&format!("bash: alias: {}: not found\n", arg)); + exit_code = 1; + } + } + } + + let result = ExecResult { + stdout: output, + stderr, + exit_code, + control_flow: ControlFlow::None, + }; + self.apply_redirections(result, redirects).await + } + + /// Execute the `unalias` builtin. Needs direct access to self.aliases. + /// + /// Usage: + /// - `unalias name` - remove alias + /// - `unalias -a` - remove all aliases + async fn execute_unalias_builtin( + &mut self, + args: &[String], + redirects: &[Redirect], + ) -> Result { + if args.is_empty() { + let result = ExecResult::err( + "bash: unalias: usage: unalias [-a] name [name ...]\n".to_string(), + 2, + ); + return self.apply_redirections(result, redirects).await; + } + + let mut exit_code = 0; + let mut stderr = String::new(); + + for arg in args { + if arg == "-a" { + self.aliases.clear(); + } else if self.aliases.remove(arg.as_str()).is_none() { + stderr.push_str(&format!("bash: unalias: {}: not found\n", arg)); + exit_code = 1; + } + } + + let result = ExecResult { + stdout: String::new(), + stderr, + exit_code, + control_flow: ControlFlow::None, + }; + self.apply_redirections(result, redirects).await + } + /// Execute the `getopts` builtin (POSIX option parsing). /// /// Execute mapfile/readarray builtin — reads lines into an indexed array. @@ -4833,6 +5112,7 @@ impl Interpreter { let mut is_assoc = false; let mut is_integer = false; let mut is_nameref = false; + let mut remove_nameref = false; let mut is_lowercase = false; let mut is_uppercase = false; let mut names: Vec<&str> = Vec::new(); @@ -4854,6 +5134,13 @@ impl Interpreter { _ => {} } } + } else if arg.starts_with('+') && !arg.contains('=') { + // +n removes nameref attribute + for c in arg[1..].chars() { + if c == 'n' { + remove_nameref = true; + } + } } else { names.push(arg); } @@ -5038,9 +5325,17 @@ impl Interpreter { } } else { // Declare without value - if is_nameref { - // declare -n ref (without value) - just mark as nameref - // The target will be set later via assignment + if remove_nameref { + // typeset +n ref: remove nameref attribute + self.variables.remove(&format!("_NAMEREF_{}", name)); + } else if is_nameref { + // typeset -n ref (without =value): use existing variable value as target + if let Some(existing) = self.variables.get(name.as_str()).cloned() { + if !existing.is_empty() { + self.variables + .insert(format!("_NAMEREF_{}", name), existing); + } + } } else if is_assoc { // Initialize empty associative array self.assoc_arrays.entry(name.to_string()).or_default(); @@ -5360,6 +5655,7 @@ impl Interpreter { name, operator, operand, + colon_variant, } => { // Under set -u, operators like :-, :=, :+, :? suppress nounset errors // because the script is explicitly handling unset variables. @@ -5370,23 +5666,42 @@ impl Interpreter { | ParameterOp::UseReplacement | ParameterOp::Error ); - if self.is_nounset() && !suppress_nounset && !self.is_variable_set(name) { + + // Resolve name (handles arr[@], @, *, and regular vars) + let (is_set, value) = self.resolve_param_expansion_name(name); + + if self.is_nounset() && !suppress_nounset && !is_set { self.nounset_error = Some(format!("bash: {}: unbound variable\n", name)); } - let value = self.expand_variable(name); - let expanded = self.apply_parameter_op(&value, name, operator, operand); + let expanded = self.apply_parameter_op( + &value, + name, + operator, + operand, + *colon_variant, + is_set, + ); result.push_str(&expanded); } WordPart::ArrayAccess { name, index } => { + // Resolve nameref: array name may be a nameref to the real array + let resolved_name = self.resolve_nameref(name); + // Check if resolved_name itself contains an array index (e.g., "a[2]") + let (arr_name, extra_index) = if let Some(bracket) = resolved_name.find('[') { + let idx_part = &resolved_name[bracket + 1..resolved_name.len() - 1]; + (&resolved_name[..bracket], Some(idx_part.to_string())) + } else { + (resolved_name, None) + }; if index == "@" || index == "*" { // ${arr[@]} or ${arr[*]} - expand to all elements - if let Some(arr) = self.assoc_arrays.get(name) { + if let Some(arr) = self.assoc_arrays.get(arr_name) { let mut keys: Vec<_> = arr.keys().collect(); keys.sort(); let values: Vec = keys.iter().filter_map(|k| arr.get(*k).cloned()).collect(); result.push_str(&values.join(" ")); - } else if let Some(arr) = self.arrays.get(name) { + } else if let Some(arr) = self.arrays.get(arr_name) { let mut indices: Vec<_> = arr.keys().collect(); indices.sort(); let values: Vec<_> = @@ -5395,7 +5710,22 @@ impl Interpreter { &values.into_iter().cloned().collect::>().join(" "), ); } - } else if let Some(arr) = self.assoc_arrays.get(name) { + } else if let Some(extra_idx) = extra_index { + // Nameref resolved to "a[2]" form - use the embedded index + if let Some(arr) = self.assoc_arrays.get(arr_name) { + if let Some(value) = arr.get(&extra_idx) { + result.push_str(value); + } + } else { + let idx: usize = + self.evaluate_arithmetic(&extra_idx).try_into().unwrap_or(0); + if let Some(arr) = self.arrays.get(arr_name) { + if let Some(value) = arr.get(&idx) { + result.push_str(value); + } + } + } + } else if let Some(arr) = self.assoc_arrays.get(arr_name) { // ${assoc[key]} - get by string key let key = self.expand_variable_or_literal(index); if let Some(value) = arr.get(&key) { @@ -5408,14 +5738,14 @@ impl Interpreter { // Negative index: count from end let len = self .arrays - .get(name) + .get(arr_name) .map(|a| a.keys().max().map(|m| m + 1).unwrap_or(0)) .unwrap_or(0) as i64; (len + raw_idx).max(0) as usize } else { raw_idx as usize }; - if let Some(arr) = self.arrays.get(name) { + if let Some(arr) = self.arrays.get(arr_name) { if let Some(value) = arr.get(&idx) { result.push_str(value); } @@ -5488,10 +5818,18 @@ impl Interpreter { } } WordPart::IndirectExpansion(name) => { - // ${!var} - indirect expansion - let var_name = self.expand_variable(name); - let value = self.expand_variable(&var_name); - result.push_str(&value); + // ${!var} - for namerefs, returns the nameref target name (inverted) + // For non-namerefs, does normal indirect expansion + let nameref_key = format!("_NAMEREF_{}", name); + if let Some(target) = self.variables.get(&nameref_key).cloned() { + // var is a nameref: ${!ref} returns the target variable name + result.push_str(&target); + } else { + // Normal indirect expansion + let var_name = self.expand_variable(name); + let value = self.expand_variable(&var_name); + result.push_str(&value); + } } WordPart::PrefixMatch(prefix) => { // ${!prefix*} - names of variables with given prefix @@ -5620,8 +5958,48 @@ impl Interpreter { /// Returns Vec where array expansions like "${arr[@]}" produce multiple fields. /// "${arr[*]}" in quoted context joins elements into a single field (bash behavior). async fn expand_word_to_fields(&mut self, word: &Word) -> Result> { - // Check if the word contains only an array expansion + // Check if the word contains only an array expansion or $@/$* if word.parts.len() == 1 { + // Handle $@ and $* as special parameters + if let WordPart::Variable(name) = &word.parts[0] { + if name == "@" { + let positional = self + .call_stack + .last() + .map(|f| f.positional.clone()) + .unwrap_or_default(); + if word.quoted { + // "$@" preserves individual positional params + return Ok(positional); + } + // $@ unquoted: each param is subject to further IFS splitting + let mut fields = Vec::new(); + for p in &positional { + fields.extend(self.ifs_split(p)); + } + return Ok(fields); + } + if name == "*" { + let positional = self + .call_stack + .last() + .map(|f| f.positional.clone()) + .unwrap_or_default(); + if word.quoted { + // "$*" joins with first char of IFS + let ifs = self + .variables + .get("IFS") + .cloned() + .unwrap_or_else(|| " \t\n".to_string()); + let sep = ifs.chars().next().unwrap_or(' '); + return Ok(vec![positional.join(&sep.to_string())]); + } + // $* unquoted: join with space, then IFS split + let joined = positional.join(" "); + return Ok(self.ifs_split(&joined)); + } + } if let WordPart::ArrayAccess { name, index } = &word.parts[0] { if index == "@" || index == "*" { // Check assoc arrays first @@ -5666,87 +6044,274 @@ impl Interpreter { } // For other words, expand to a single field then apply IFS word splitting - // when the word is unquoted and contains a command substitution. - // Per POSIX, unquoted $() results undergo field splitting on IFS. + // when the word is unquoted and contains an expansion. + // Per POSIX, unquoted variable/command/arithmetic expansion results undergo + // field splitting on IFS. let expanded = self.expand_word(word).await?; - let has_command_subst = !word.quoted - && word - .parts - .iter() - .any(|p| matches!(p, WordPart::CommandSubstitution(_))); + // IFS splitting applies to unquoted expansions only. + // Skip splitting for assignment-like words (e.g., result="$1") where + // the lexer stripped quotes from a mixed-quoted word (produces Token::Word + // with quoted: false even though the expansion was inside double quotes). + let is_assignment_word = + matches!(word.parts.first(), Some(WordPart::Literal(s)) if s.contains('=')); + let has_expansion = !word.quoted + && !is_assignment_word + && word.parts.iter().any(|p| { + matches!( + p, + WordPart::Variable(_) + | WordPart::CommandSubstitution(_) + | WordPart::ArithmeticExpansion(_) + | WordPart::ParameterExpansion { .. } + | WordPart::ArrayAccess { .. } + ) + }); - if has_command_subst { - // Split on IFS characters (default: space, tab, newline). - // Consecutive IFS-whitespace characters are collapsed (no empty fields). - let ifs = self - .variables - .get("IFS") - .cloned() - .unwrap_or_else(|| " \t\n".to_string()); + if has_expansion { + Ok(self.ifs_split(&expanded)) + } else { + Ok(vec![expanded]) + } + } - if ifs.is_empty() { - // Empty IFS: no splitting - return Ok(vec![expanded]); + /// Resolve name for parameter expansion, handling array subscripts and special params. + /// Returns (is_set, expanded_value). + fn resolve_param_expansion_name(&self, name: &str) -> (bool, String) { + // Check for array subscript pattern: name[@] or name[*] + if let Some(arr_name) = name + .strip_suffix("[@]") + .or_else(|| name.strip_suffix("[*]")) + { + if let Some(arr) = self.assoc_arrays.get(arr_name) { + let is_set = !arr.is_empty(); + let mut keys: Vec<_> = arr.keys().collect(); + keys.sort(); + let values: Vec = + keys.iter().filter_map(|k| arr.get(*k).cloned()).collect(); + return (is_set, values.join(" ")); + } + if let Some(arr) = self.arrays.get(arr_name) { + let is_set = !arr.is_empty(); + let mut indices: Vec<_> = arr.keys().collect(); + indices.sort(); + let values: Vec<_> = indices.iter().filter_map(|i| arr.get(i)).collect(); + return ( + is_set, + values.into_iter().cloned().collect::>().join(" "), + ); + } + return (false, String::new()); + } + + // Special parameters @ and * + if name == "@" || name == "*" { + if let Some(frame) = self.call_stack.last() { + let is_set = !frame.positional.is_empty(); + return (is_set, frame.positional.join(" ")); } + return (false, String::new()); + } + + // Regular variable + let is_set = self.is_variable_set(name); + let value = self.expand_variable(name); + (is_set, value) + } - let fields: Vec = expanded - .split(|c: char| ifs.contains(c)) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) + /// Split a string on IFS characters according to POSIX rules. + /// + /// - IFS whitespace (space, tab, newline) collapses; leading/trailing stripped. + /// - IFS non-whitespace chars are significant delimiters. Two adjacent produce + /// an empty field between them. + /// - `` = single delimiter (ws absorbed into the nws delimiter). + /// - Empty IFS → no splitting. Unset IFS → default " \t\n". + fn ifs_split(&self, s: &str) -> Vec { + let ifs = self + .variables + .get("IFS") + .cloned() + .unwrap_or_else(|| " \t\n".to_string()); + + if ifs.is_empty() { + return vec![s.to_string()]; + } + + let is_ifs = |c: char| ifs.contains(c); + let is_ifs_ws = |c: char| ifs.contains(c) && " \t\n".contains(c); + let is_ifs_nws = |c: char| ifs.contains(c) && !" \t\n".contains(c); + let all_whitespace_ifs = ifs.chars().all(|c| " \t\n".contains(c)); + + if all_whitespace_ifs { + // IFS is only whitespace: split on runs, elide empties + return s + .split(|c: char| is_ifs(c)) + .filter(|f| !f.is_empty()) + .map(|f| f.to_string()) .collect(); + } - if fields.is_empty() { - // All-whitespace expansion produces zero fields (elision) - return Ok(Vec::new()); + // Mixed or pure non-whitespace IFS. + let mut fields: Vec = Vec::new(); + let mut current = String::new(); + let chars: Vec = s.chars().collect(); + let mut i = 0; + + // Skip leading IFS whitespace + while i < chars.len() && is_ifs_ws(chars[i]) { + i += 1; + } + // Leading non-whitespace IFS produces an empty first field + if i < chars.len() && is_ifs_nws(chars[i]) { + fields.push(String::new()); + i += 1; + while i < chars.len() && is_ifs_ws(chars[i]) { + i += 1; + } + } + + while i < chars.len() { + let c = chars[i]; + if is_ifs_nws(c) { + // Non-whitespace IFS delimiter: finalize current field + fields.push(std::mem::take(&mut current)); + i += 1; + // Consume trailing IFS whitespace + while i < chars.len() && is_ifs_ws(chars[i]) { + i += 1; + } + } else if is_ifs_ws(c) { + // IFS whitespace: skip it, then check for non-ws delimiter + while i < chars.len() && is_ifs_ws(chars[i]) { + i += 1; + } + if i < chars.len() && is_ifs_nws(chars[i]) { + // = single delimiter. Push current field. + fields.push(std::mem::take(&mut current)); + i += 1; // consume the nws char + while i < chars.len() && is_ifs_ws(chars[i]) { + i += 1; + } + } else if i < chars.len() { + // ws alone as delimiter (no nws follows) + fields.push(std::mem::take(&mut current)); + } + // trailing ws at end → ignore (don't push empty field) + } else { + current.push(c); + i += 1; } - Ok(fields) - } else { - Ok(vec![expanded]) } + + if !current.is_empty() { + fields.push(current); + } + + fields } - /// Apply parameter expansion operator + /// Expand an operand string from a parameter expansion (sync, lazy). + /// Only called when the operand is actually needed, providing lazy evaluation. + fn expand_operand(&mut self, operand: &str) -> String { + if operand.is_empty() { + return String::new(); + } + let word = Parser::parse_word_string(operand); + let mut result = String::new(); + for part in &word.parts { + match part { + WordPart::Literal(s) => result.push_str(s), + WordPart::Variable(name) => { + result.push_str(&self.expand_variable(name)); + } + WordPart::ArithmeticExpansion(expr) => { + let val = self.evaluate_arithmetic_with_assign(expr); + result.push_str(&val.to_string()); + } + WordPart::ParameterExpansion { + name, + operator, + operand: inner_operand, + colon_variant, + } => { + let (is_set, value) = self.resolve_param_expansion_name(name); + let expanded = self.apply_parameter_op( + &value, + name, + operator, + inner_operand, + *colon_variant, + is_set, + ); + result.push_str(&expanded); + } + WordPart::Length(name) => { + let value = self.expand_variable(name); + result.push_str(&value.len().to_string()); + } + // TODO: handle CommandSubstitution etc. in sync operand expansion + _ => {} + } + } + result + } + + /// Apply parameter expansion operator. + /// `colon_variant`: true = check unset-or-empty, false = check unset-only. + /// `is_set`: whether the variable is defined (distinct from being empty). fn apply_parameter_op( &mut self, value: &str, name: &str, operator: &ParameterOp, operand: &str, + colon_variant: bool, + is_set: bool, ) -> String { + // colon (:-) => trigger when unset OR empty + // no-colon (-) => trigger only when unset + let use_default = if colon_variant { + !is_set || value.is_empty() + } else { + !is_set + }; + let use_replacement = if colon_variant { + is_set && !value.is_empty() + } else { + is_set + }; + match operator { ParameterOp::UseDefault => { - // ${var:-default} - use default if unset/empty - if value.is_empty() { - operand.to_string() + if use_default { + self.expand_operand(operand) } else { value.to_string() } } ParameterOp::AssignDefault => { - // ${var:=default} - assign default if unset/empty - if value.is_empty() { - self.set_variable(name.to_string(), operand.to_string()); - operand.to_string() + if use_default { + let expanded = self.expand_operand(operand); + self.set_variable(name.to_string(), expanded.clone()); + expanded } else { value.to_string() } } ParameterOp::UseReplacement => { - // ${var:+replacement} - use replacement if set - if !value.is_empty() { - operand.to_string() + if use_replacement { + self.expand_operand(operand) } else { String::new() } } ParameterOp::Error => { - // ${var:?error} - error if unset/empty - if value.is_empty() { - let msg = if operand.is_empty() { + if use_default { + let expanded = self.expand_operand(operand); + let msg = if expanded.is_empty() { format!("bash: {}: parameter null or not set\n", name) } else { - format!("bash: {}: {}\n", name, operand) + format!("bash: {}: {}\n", name, expanded) }; self.nounset_error = Some(msg); String::new() @@ -6796,6 +7361,21 @@ impl Interpreter { // Resolve nameref before expansion let name = self.resolve_nameref(name); + // If resolved name is an array element ref like "a[2]", expand as array access + if let Some(bracket) = name.find('[') { + if name.ends_with(']') { + let arr_name = &name[..bracket]; + let idx_str = &name[bracket + 1..name.len() - 1]; + if let Some(arr) = self.assoc_arrays.get(arr_name) { + return arr.get(idx_str).cloned().unwrap_or_default(); + } else if let Some(arr) = self.arrays.get(arr_name) { + let idx: usize = self.evaluate_arithmetic(idx_str).try_into().unwrap_or(0); + return arr.get(&idx).cloned().unwrap_or_default(); + } + return String::new(); + } + } + // Check for special parameters (POSIX required) match name { "?" => return self.last_exit_code.to_string(), diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index f2e0237e..66a51f45 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -3848,10 +3848,10 @@ echo missing fi"#, assert!(result.is_err()); let err = result.unwrap_err(); let err_msg = format!("{}", err); - // Error should mention the problem + // Error should mention the problem (either "expected" or "syntax error") assert!( - err_msg.contains("expected"), - "Error should mention what's expected: {}", + err_msg.contains("expected") || err_msg.contains("syntax error"), + "Error should be a parse error: {}", err_msg ); } diff --git a/crates/bashkit/src/parser/ast.rs b/crates/bashkit/src/parser/ast.rs index 2ef32605..b082abd4 100644 --- a/crates/bashkit/src/parser/ast.rs +++ b/crates/bashkit/src/parser/ast.rs @@ -269,11 +269,24 @@ impl fmt::Display for Word { name, operator, operand, + colon_variant, } => match operator { - ParameterOp::UseDefault => write!(f, "${{{}:-{}}}", name, operand)?, - ParameterOp::AssignDefault => write!(f, "${{{}:={}}}", name, operand)?, - ParameterOp::UseReplacement => write!(f, "${{{}:+{}}}", name, operand)?, - ParameterOp::Error => write!(f, "${{{}:?{}}}", name, operand)?, + ParameterOp::UseDefault => { + let c = if *colon_variant { ":" } else { "" }; + write!(f, "${{{}{}-{}}}", name, c, operand)? + } + ParameterOp::AssignDefault => { + let c = if *colon_variant { ":" } else { "" }; + write!(f, "${{{}{}={}}}", name, c, operand)? + } + ParameterOp::UseReplacement => { + let c = if *colon_variant { ":" } else { "" }; + write!(f, "${{{}{}+{}}}", name, c, operand)? + } + ParameterOp::Error => { + let c = if *colon_variant { ":" } else { "" }; + write!(f, "${{{}{}?{}}}", name, c, operand)? + } ParameterOp::RemovePrefixShort => write!(f, "${{{}#{}}}", name, operand)?, ParameterOp::RemovePrefixLong => write!(f, "${{{}##{}}}", name, operand)?, ParameterOp::RemoveSuffixShort => write!(f, "${{{}%{}}}", name, operand)?, @@ -344,10 +357,12 @@ pub enum WordPart { /// Arithmetic expansion ($((...))) ArithmeticExpansion(String), /// Parameter expansion with operator ${var:-default}, ${var:=default}, etc. + /// `colon_variant` distinguishes `:-` (unset-or-empty) from `-` (unset-only). ParameterExpansion { name: String, operator: ParameterOp, operand: String, + colon_variant: bool, }, /// Length expansion ${#var} Length(String), diff --git a/crates/bashkit/src/parser/lexer.rs b/crates/bashkit/src/parser/lexer.rs index 8a10a299..a364787b 100644 --- a/crates/bashkit/src/parser/lexer.rs +++ b/crates/bashkit/src/parser/lexer.rs @@ -708,16 +708,22 @@ impl<'a> Lexer<'a> { fn read_single_quoted_string(&mut self) -> Option { self.advance(); // consume opening ' let mut content = String::new(); + let mut closed = false; while let Some(ch) = self.peek_char() { if ch == '\'' { self.advance(); // consume closing ' + closed = true; break; } content.push(ch); self.advance(); } + if !closed { + return Some(Token::Error("unterminated single quote".to_string())); + } + // If next char is another quote or word char, concatenate (e.g., 'EOF'"2" -> EOF2). // Any quoting makes the whole token literal. self.read_continuation_into(&mut content); @@ -893,11 +899,13 @@ impl<'a> Lexer<'a> { fn read_double_quoted_string(&mut self) -> Option { self.advance(); // consume opening " let mut content = String::new(); + let mut closed = false; while let Some(ch) = self.peek_char() { match ch { '"' => { self.advance(); // consume closing " + closed = true; break; } '\\' => { @@ -966,6 +974,10 @@ impl<'a> Lexer<'a> { } } + if !closed { + return Some(Token::Error("unterminated double quote".to_string())); + } + Some(Token::QuotedWord(content)) } diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index c7673b6f..72015b9f 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -104,6 +104,13 @@ impl<'a> Parser<'a> { self.current_span } + /// Parse a string as a word (handling $var, $((expr)), ${...}, etc.). + /// Used by the interpreter to expand operands in parameter expansions lazily. + pub fn parse_word_string(input: &str) -> Word { + let parser = Parser::new(input); + parser.parse_word(input.to_string()) + } + /// Create a parse error with the current position. fn error(&self, message: impl Into) -> Error { Error::parse_at( @@ -145,14 +152,26 @@ impl<'a> Parser<'a> { } } + /// Check if current token is an error token and return the error if so + fn check_error_token(&self) -> Result<()> { + if let Some(tokens::Token::Error(msg)) = &self.current_token { + return Err(self.error(format!("syntax error: {}", msg))); + } + Ok(()) + } + /// Parse the input and return the AST. pub fn parse(mut self) -> Result