From d289cff56c3e7af7003c938da218e4624aa4508e Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Mon, 3 Nov 2025 20:55:45 +0100 Subject: [PATCH] added interactive inputs, better error messages, bugfixes --- .gitignore | 3 +- README.md | 1 + docs/src/SUMMARY.md | 1 + docs/src/language/input.md | 347 ++++++++++++++++++ src/error/colors.rs | 37 ++ src/error/mod.rs | 8 +- src/interpreter/executor.rs | 240 +++++++++++- src/interpreter/mod.rs | 10 +- src/main.rs | 13 +- src/parser/ast.rs | 4 + src/parser/mod.rs | 60 ++- src/runtime/builtins.rs | 166 +++++++++ src/runtime/environment.rs | 63 +++- vsc-extension/rush/.vscodeignore | 12 + vsc-extension/rush/CHANGELOG.md | 27 +- vsc-extension/rush/package.json | 2 +- .../rush/syntaxes/rush.tmLanguage.json | 16 +- 17 files changed, 955 insertions(+), 55 deletions(-) create mode 100644 docs/src/language/input.md create mode 100644 src/error/colors.rs diff --git a/.gitignore b/.gitignore index 5feb907..5ad724f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target -node_modules \ No newline at end of file +node_modules +*.vsix \ No newline at end of file diff --git a/README.md b/README.md index 46629c8..7c6b38d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Rush is an experimental shell scripting language interpreter that combines simpl - **Control flow** with `if`/`else` and `not` - **For loops** for iteration - **Parallel execution** blocks for concurrent tasks +- **Interactive input** with `input()`, `confirm()`, `select()`, and `multiselect()` - **Strict parsing** that catches errors before execution ## Quick Start diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 7c194a9..fd01a4a 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,7 @@ - [Loops](./language/loops.md) - [Parallel Execution](./language/parallel.md) - [Built-in Variables](./language/builtins.md) +- [User Input](./language/input.md) # Examples diff --git a/docs/src/language/input.md b/docs/src/language/input.md new file mode 100644 index 0000000..ecfb40b --- /dev/null +++ b/docs/src/language/input.md @@ -0,0 +1,347 @@ +# User Input + +> ⚠️ This documentation is AI-generated and may contain errors. + +Rush provides several built-in functions for interactive user input, making it easy to create interactive scripts and tools. + +## Input Functions + +### `input()` + +Read a line of text from the user. + +**Syntax:** +```rush +VARIABLE = input() +VARIABLE = input("prompt") +``` + +**Examples:** +```rush +# With prompt +NAME = input("What is your name? ") +echo "Hello, $NAME!" + +# Without prompt +echo "Enter a value:" +VALUE = input() +echo "You entered: $VALUE" +``` + +**Use cases:** +- Collecting user information +- Reading configuration values +- Interactive script parameters + +--- + +### `confirm()` + +Ask a yes/no question and get a boolean response. + +**Syntax:** +```rush +VARIABLE = confirm("question") +``` + +**Returns:** +- `1` for yes (y/yes/Y/YES) +- `0` for no (n/no/N/NO) + +**Examples:** +```rush +PROCEED = confirm("Do you want to continue?") + +if $PROCEED { + echo "Continuing..." +} else { + echo "Stopped." + exit 0 +} +``` + +```rush +# Check before dangerous operation +DELETE_CONFIRM = confirm("Are you sure you want to delete all files?") + +if not $DELETE_CONFIRM { + echo "Operation cancelled." + exit 1 +} + +echo "Deleting files..." +``` + +**Use cases:** +- Confirmations before destructive actions +- Binary choices +- Permission checks + +--- + +### `select()` + +Display a menu and let the user select one option. + +**Syntax:** +```rush +CHOICE = select("prompt", "option1", "option2", "option3", ...) +``` + +**Returns:** +- A number representing the selected option (1-indexed) +- `0` if cancelled or error + +**Examples:** +```rush +ENV = select("Select environment:", "Development", "Staging", "Production") + +if $ENV { + echo "Selected environment: $ENV" +} +``` + +```rush +# Using the result +LANG = select("Choose language:", "English", "Spanish", "French", "German") + +# Process based on selection +if $LANG { + echo "You selected option $LANG" +} +``` + +**Display:** +``` +Choose language: + + 1. English + 2. Spanish + 3. French + 4. German + +Select (1-4): +``` + +**Use cases:** +- Environment selection +- Configuration options +- Mode selection +- File/directory choice + +--- + +### `multiselect()` + +Display a menu and let the user select multiple options. + +**Syntax:** +```rush +CHOICES = multiselect("prompt", "option1", "option2", "option3", ...) +``` + +**Returns:** +- A comma-separated string of selected indices (e.g., "1,3,5") +- Empty string if none selected or error + +**Input format:** +- Comma-separated: `1,2,3` +- Space-separated: `1 2 3` +- Mixed: `1, 2, 3` + +**Examples:** +```rush +COMPONENTS = multiselect("Select components to install:", "Core", "Database", "Web Server", "Cache", "Monitoring") + +echo "Selected components: $COMPONENTS" +``` + +```rush +# Feature flags +FEATURES = multiselect("Enable features:", "Logging", "Analytics", "Notifications", "Dark Mode", "Auto-save") + +echo "Enabled features: $FEATURES" +``` + +**Display:** +``` +Select components to install: + + 1. Core + 2. Database + 3. Web Server + 4. Cache + 5. Monitoring + +Select multiple (e.g., 1,2,3 or 1 2 3): +``` + +**Use cases:** +- Component selection +- Feature flags +- Multiple file selection +- Task selection + +--- + +## Complete Example + +Here's a complete interactive script demonstrating all input methods: + +```rush +#!/usr/bin/env rush + +echo "=== Project Setup Wizard ===" +echo "" + +# Get project details +PROJECT_NAME = input("Project name: ") +DESCRIPTION = input("Description: ") + +echo "" + +# Select project type +PROJECT_TYPE = select("Project type:", "Web Application", "CLI Tool", "Library", "API Service") + +echo "" + +# Select features +FEATURES = multiselect("Select features:", "Authentication", "Database", "API", "Testing", "Documentation") + +echo "" + +# Confirm setup +PROCEED = confirm("Create project with these settings?") + +if not $PROCEED { + echo "Setup cancelled." + exit 1 +} + +echo "" +echo "=== Creating Project ===" +echo "Name: $PROJECT_NAME" +echo "Description: $DESCRIPTION" +echo "Type: $PROJECT_TYPE" +echo "Features: $FEATURES" +echo "" +echo "Project created successfully!" +``` + +--- + +## Patterns and Best Practices + +### Input Validation + +Always validate user input: + +```rush +AGE = input("Enter your age: ") + +# Basic validation (you can add more sophisticated checks) +if not $AGE { + echo "Age is required" + exit 1 +} + +echo "Age: $AGE" +``` + +### Required Confirmations + +For destructive operations, always confirm: + +```rush +echo "WARNING: This will delete all data!" +CONFIRMED = confirm("Are you absolutely sure?") + +if not $CONFIRMED { + echo "Operation cancelled." + exit 0 +} + +# Proceed with destructive operation +echo "Deleting data..." +``` + +### Menu-driven Scripts + +Create interactive menus for complex workflows: + +```rush +echo "=== Main Menu ===" +ACTION = select("What would you like to do?", "Install", "Update", "Remove", "Configure", "Exit") + +# Handle selection +# ... process based on $ACTION value +``` + +### Progressive Input + +Collect information step by step: + +```rush +echo "Step 1: User Information" +USERNAME = input("Username: ") + +echo "" +echo "Step 2: Preferences" +THEME = select("Theme:", "Light", "Dark", "Auto") + +echo "" +echo "Step 3: Features" +FEATURES = multiselect("Features:", "Feature A", "Feature B", "Feature C") + +echo "" +CONFIRM = confirm("Save these settings?") + +if $CONFIRM { + echo "Settings saved!" +} +``` + +--- + +## Error Handling + +Input functions handle errors gracefully: + +- `input()` - Returns empty string on EOF +- `confirm()` - Keeps prompting until valid input (y/n) +- `select()` - Returns 0 on error, keeps prompting for valid range +- `multiselect()` - Returns empty string on error, validates range + +**Example:** +```rush +# Check for empty input +NAME = input("Name: ") + +if not $NAME { + echo "Error: Name cannot be empty" + exit 1 +} + +echo "Hello, $NAME!" +``` + +--- + +## Tips + +1. **Clear prompts** - Make prompts descriptive and include examples +2. **Show options** - For select/multiselect, use clear, concise option names +3. **Confirm destructive actions** - Always use `confirm()` before dangerous operations +4. **Validate input** - Check that required inputs are provided +5. **Provide feedback** - Echo back what the user selected for confirmation + +--- + +## Limitations + +- Input is line-based (no character-by-character input) +- No built-in password masking (input is visible) +- No default values (yet) +- No input history or autocomplete + +These features may be added in future versions. diff --git a/src/error/colors.rs b/src/error/colors.rs new file mode 100644 index 0000000..4a9f58d --- /dev/null +++ b/src/error/colors.rs @@ -0,0 +1,37 @@ +pub struct Colors; + +impl Colors { + pub const RESET: &'static str = "\x1b[0m"; + pub const BOLD: &'static str = "\x1b[1m"; + + pub const RED: &'static str = "\x1b[31m"; + pub const GREEN: &'static str = "\x1b[32m"; + pub const YELLOW: &'static str = "\x1b[33m"; + pub const BLUE: &'static str = "\x1b[34m"; + pub const MAGENTA: &'static str = "\x1b[35m"; + pub const CYAN: &'static str = "\x1b[36m"; + + pub const BOLD_RED: &'static str = "\x1b[1;31m"; + pub const BOLD_YELLOW: &'static str = "\x1b[1;33m"; + pub const BOLD_CYAN: &'static str = "\x1b[1;36m"; +} + +pub fn error(msg: &str) -> String { + format!("{}{}ERROR:{} {}", Colors::BOLD_RED, Colors::BOLD, Colors::RESET, msg) +} + +pub fn warning(msg: &str) -> String { + format!("{}{}WARNING:{} {}", Colors::BOLD_YELLOW, Colors::BOLD, Colors::RESET, msg) +} + +pub fn info(msg: &str) -> String { + format!("{}{}INFO:{} {}", Colors::BOLD_CYAN, Colors::BOLD, Colors::RESET, msg) +} + +pub fn location(file: &str, line: usize) -> String { + format!("{}{}:{}{}", Colors::CYAN, file, line, Colors::RESET) +} + +pub fn highlight(text: &str) -> String { + format!("{}{}{}", Colors::YELLOW, text, Colors::RESET) +} diff --git a/src/error/mod.rs b/src/error/mod.rs index c7d3d9b..4c1097f 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -1,3 +1,5 @@ +pub mod colors; + #[derive(Debug)] pub enum RushError { SyntaxError(String), @@ -7,7 +9,11 @@ pub enum RushError { impl std::fmt::Display for RushError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{:?}", self) + match self { + RushError::SyntaxError(msg) => write!(f, "{}", msg), + RushError::RuntimeError(msg) => write!(f, "{}", msg), + RushError::VariableError(msg) => write!(f, "{}", msg), + } } } diff --git a/src/interpreter/executor.rs b/src/interpreter/executor.rs index ff7d507..47516d0 100644 --- a/src/interpreter/executor.rs +++ b/src/interpreter/executor.rs @@ -1,23 +1,53 @@ use crate::parser::ast::{AstNode, Command, Statement}; use crate::runtime::builtins; use crate::runtime::environment::Environment; +use crate::error::colors; use std::process::Command as StdCommand; pub struct Executor<'a> { env: &'a mut Environment, + current_file: Option, + current_line: usize, } impl<'a> Executor<'a> { pub fn new(env: &'a mut Environment) -> Self { - Executor { env } + Executor { + env, + current_file: None, + current_line: 0, + } + } + + pub fn set_file(&mut self, filename: String) { + self.current_file = Some(filename); + } + + fn error_context(&self) -> String { + if let Some(ref file) = self.current_file { + if self.current_line > 0 { + format!(" at {}", colors::location(file, self.current_line)) + } else { + format!(" in {}", colors::highlight(file)) + } + } else { + String::new() + } } pub fn execute_node(&mut self, node: AstNode) { match node { AstNode::VariableAssignment { name, value } => { - if let AstNode::Literal(val) = *value { - let substituted = self.substitute_variables(&val); - self.env.set_variable(name, substituted); + match *value { + AstNode::Literal(val) => { + let substituted = self.substitute_variables(&val); + self.env.set_variable(name, substituted); + } + AstNode::FunctionCall { name: fn_name, args } => { + let result = self.execute_function_call(&fn_name, args); + self.env.set_variable(name, result); + } + _ => {} } } AstNode::Command { name, args } => { @@ -43,6 +73,16 @@ impl<'a> Executor<'a> { builtins::exit(code); } else if name == "require_root" { builtins::require_root(); + } else if name == "cd" { + let path = arg_strings.first().map(|s| s.as_str()).unwrap_or("."); + if let Err(e) = builtins::cd(path) { + eprintln!("{}{}", colors::error(&format!("Failed to change directory: {}", e)), self.error_context()); + std::process::exit(1); + } + } else if name == "test" { + + + builtins::test(&arg_strings); } else { let cmd = Command { name: name.clone(), @@ -51,6 +91,10 @@ impl<'a> Executor<'a> { self.execute_command(cmd); } } + AstNode::FunctionCall { name, args } => { + + self.execute_function_call(&name, args); + } AstNode::ControlFlow { condition, then_branch, @@ -384,27 +428,186 @@ impl<'a> Executor<'a> { } } + fn execute_function_call(&mut self, name: &str, args: Vec) -> String { + let arg_strings: Vec = args + .into_iter() + .filter_map(|arg| { + if let AstNode::Literal(s) = arg { + Some(self.substitute_variables(&s)) + } else { + None + } + }) + .collect(); + + match name { + "input" => { + + let prompt = arg_strings.first().map(|s| s.as_str()); + builtins::input(prompt) + } + "confirm" => { + + let prompt = arg_strings.first().map(|s| s.as_str()).unwrap_or("Confirm?"); + if builtins::confirm(prompt) { + "1".to_string() + } else { + "0".to_string() + } + } + "test" => { + + if builtins::test(&arg_strings) { + "1".to_string() + } else { + "0".to_string() + } + } + "select" => { + + if arg_strings.is_empty() { + eprintln!("{}{}", colors::error("select() requires at least a prompt"), self.error_context()); + return "0".to_string(); + } + let prompt = &arg_strings[0]; + let options = &arg_strings[1..]; + if options.is_empty() { + eprintln!("{}{}", colors::error("select() requires at least one option"), self.error_context()); + return "0".to_string(); + } + let selection = builtins::select(prompt, &options.to_vec()); + selection.to_string() + } + "multiselect" => { + + if arg_strings.is_empty() { + eprintln!("{}{}", colors::error("multiselect() requires at least a prompt"), self.error_context()); + return String::new(); + } + let prompt = &arg_strings[0]; + let options = &arg_strings[1..]; + if options.is_empty() { + eprintln!("{}{}", colors::error("multiselect() requires at least one option"), self.error_context()); + return String::new(); + } + builtins::multiselect(prompt, &options.to_vec()) + } + _ => { + eprintln!("{}{}", colors::error(&format!("Unknown function: {}", colors::highlight(name))), self.error_context()); + String::new() + } + } + } + fn execute_command(&mut self, command: Command) { - let result = StdCommand::new(&command.name).args(&command.args).output(); + use std::fs::OpenOptions; + use std::io::Write as IoWrite; + + + let mut redirect_stdout: Option<(String, bool)> = None; + let mut suppress_stderr = false; + + let filtered_args: Vec = command + .args + .iter() + .enumerate() + .filter_map(|(i, arg)| { + if arg == ">" || arg == ">>" { + + if i + 1 < command.args.len() { + redirect_stdout = Some((command.args[i + 1].clone(), arg == ">>")); + } + None + } else if arg.starts_with('>') { + + let (file, append) = if arg.starts_with(">>") { + (&arg[2..], true) + } else { + (&arg[1..], false) + }; + redirect_stdout = Some((file.to_string(), append)); + None + } else if arg == "2>/dev/null" || arg.starts_with("2>") { + suppress_stderr = true; + None + } else if redirect_stdout.is_some() && i > 0 && (command.args[i-1] == ">" || command.args[i-1] == ">>") { + + None + } else { + Some(arg.clone()) + } + }) + .collect(); + + let result = StdCommand::new(&command.name).args(&filtered_args).output(); match result { Ok(output) => { + if !output.stdout.is_empty() { - print!("{}", String::from_utf8_lossy(&output.stdout)); + let stdout_str = String::from_utf8_lossy(&output.stdout); + + if let Some((filename, append)) = redirect_stdout { + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(!append) + .append(append) + .open(&filename); + + match file { + Ok(ref mut f) => { + let _ = f.write_all(stdout_str.as_bytes()); + } + Err(e) => { + eprintln!( + "{}{}", + colors::error(&format!( + "Failed to write to '{}': {}", + colors::highlight(&filename), + e + )), + self.error_context() + ); + std::process::exit(1); + } + } + } else { + + print!("{}", stdout_str); + } } - if !output.stderr.is_empty() { + + + if !output.stderr.is_empty() && !suppress_stderr { eprint!("{}", String::from_utf8_lossy(&output.stderr)); } + if !output.status.success() { eprintln!( - "Command '{}' failed with exit code: {:?}", - command.name, - output.status.code() + "{}{}", + colors::error(&format!( + "Command '{}' failed with exit code {}", + colors::highlight(&command.name), + output.status.code().unwrap_or(-1) + )), + self.error_context() ); + std::process::exit(output.status.code().unwrap_or(1)); } } Err(e) => { - eprintln!("Failed to execute command '{}': {}", command.name, e); + eprintln!( + "{}{}", + colors::error(&format!( + "Failed to execute command '{}': {}", + colors::highlight(&command.name), + e + )), + self.error_context() + ); + std::process::exit(1); } } } @@ -442,10 +645,19 @@ impl<'a> Executor<'a> { } fn is_truthy(&self, value: &str) -> bool { - // Empty string, "0", "false", "no" are falsy - // Everything else is truthy + + + + if value == "0" { + return false; + } + + if value == "1" { + return true; + } + + !value.is_empty() - && value != "0" && value.to_lowercase() != "false" && value.to_lowercase() != "no" } diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 391963c..42e1be6 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -6,12 +6,16 @@ use crate::runtime::environment::Environment; use executor::Executor; pub fn execute(content: &str) { + execute_with_filename(content, None); +} + +pub fn execute_with_filename(content: &str, filename: Option) { let parser = Parser::new(); let program = match parser.parse(content) { Ok(prog) => prog, Err(e) => { - eprintln!("Parse error: {}", e); + eprintln!("{}", e); std::process::exit(1); } }; @@ -19,6 +23,10 @@ pub fn execute(content: &str) { let mut env = Environment::new(); let mut executor = Executor::new(&mut env); + + if let Some(file) = filename { + executor.set_file(file); + } for statement in program.statements { executor.execute_node(statement); diff --git a/src/main.rs b/src/main.rs index fd4c9f0..17345f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use rush::error::colors; + fn main() { let args: Vec = std::env::args().collect(); @@ -11,9 +13,12 @@ fn main() { let content = std::fs::read_to_string(script_path); if content.is_err() { eprintln!( - "Error reading file {}: {}", - script_path, - content.err().unwrap() + "{}", + colors::error(&format!( + "Failed to read file '{}': {}", + colors::highlight(script_path), + content.err().unwrap() + )) ); std::process::exit(1); } @@ -25,5 +30,5 @@ fn main() { content }; - rush::interpreter::execute(&stripped_content); + rush::interpreter::execute_with_filename(&stripped_content, Some(script_path.clone())); } diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 513ed4a..b0c7627 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -13,6 +13,10 @@ pub enum AstNode { name: String, args: Vec, }, + FunctionCall { + name: String, + args: Vec, + }, For { var: String, items: Vec, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e0a5337..e747c30 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,6 +1,7 @@ pub mod ast; use crate::error::RushError; +use crate::error::colors; pub use ast::*; use std::collections::HashSet; @@ -72,6 +73,11 @@ impl Parser { self.check_node_for_undefined_vars(arg, defined_vars)?; } } + AstNode::FunctionCall { name: _, args } => { + for arg in args { + self.check_node_for_undefined_vars(arg, defined_vars)?; + } + } AstNode::For { var, items: _, @@ -129,10 +135,12 @@ impl Parser { AstNode::Literal(s) => { for var_name in self.extract_variables(s) { if !defined_vars.contains(&var_name) { - return Err(RushError::VariableError(format!( - "Variable '{}' is used before being defined", - var_name - ))); + return Err(RushError::VariableError( + colors::error(&format!( + "Variable '{}' is used before being defined", + colors::highlight(&format!("${}", var_name)) + )) + )); } } } @@ -144,6 +152,11 @@ impl Parser { self.check_node_for_undefined_vars(arg, defined_vars)?; } } + AstNode::FunctionCall { name: _, args } => { + for arg in args { + self.check_node_for_undefined_vars(arg, defined_vars)?; + } + } _ => { // other node types dont directly contain variable references } @@ -180,7 +193,7 @@ impl Parser { fn parse_statement( &self, lines: &[&str], - mut i: usize, + i: usize, ) -> Result<(Option, usize), RushError> { self.parse_statement_with_context(lines, i, false) } @@ -278,7 +291,42 @@ impl Parser { let var_name = parts[0].trim(); if !var_name.is_empty() && var_name.chars().all(|c| c.is_alphanumeric() || c == '_') { - let value = parts[1].trim().trim_matches('"').to_string(); + let value_str = parts[1].trim(); + + // Check if the value is a function call (e.g., input("prompt") or confirm("question")) + // But NOT command substitution $(...) + let is_command_sub = value_str.trim_start_matches('"').starts_with("$("); + + if value_str.contains('(') && value_str.contains(')') && !is_command_sub { + let fn_start = value_str.find('(').unwrap(); + let fn_name = value_str[..fn_start].trim(); + let fn_end = value_str.rfind(')').unwrap(); + let args_str = &value_str[fn_start + 1..fn_end]; + + let args: Vec = if args_str.trim().is_empty() { + Vec::new() + } else { + args_str + .split(',') + .map(|arg| { + AstNode::Literal(arg.trim().trim_matches('"').to_string()) + }) + .collect() + }; + + return Ok(( + Some(AstNode::VariableAssignment { + name: var_name.to_string(), + value: Box::new(AstNode::FunctionCall { + name: fn_name.to_string(), + args, + }), + }), + i + 1, + )); + } + + let value = value_str.trim_matches('"').to_string(); return Ok(( Some(AstNode::VariableAssignment { name: var_name.to_string(), diff --git a/src/runtime/builtins.rs b/src/runtime/builtins.rs index d7615c7..fb3c47e 100644 --- a/src/runtime/builtins.rs +++ b/src/runtime/builtins.rs @@ -1,3 +1,6 @@ +use std::io::{self, Write}; +use std::env; + pub fn echo(args: Vec) { println!("{}", args.join(" ")); } @@ -13,6 +16,169 @@ pub fn exit(code: i32) { std::process::exit(code); } +pub fn cd(path: &str) -> Result<(), String> { + env::set_current_dir(path).map_err(|e| format!("cd: {}: {}", path, e)) +} + +/// Test file/directory conditions +/// Returns true (1) if condition is met, false (0) otherwise +/// Supported flags: +/// -d path: true if path exists and is a directory +/// -f path: true if path exists and is a regular file +/// -e path: true if path exists +pub fn test(args: &[String]) -> bool { + if args.is_empty() { + return false; + } + + match args[0].as_str() { + "-d" => { + if args.len() < 2 { + return false; + } + std::path::Path::new(&args[1]).is_dir() + } + "-f" => { + if args.len() < 2 { + return false; + } + std::path::Path::new(&args[1]).is_file() + } + "-e" => { + if args.len() < 2 { + return false; + } + std::path::Path::new(&args[1]).exists() + } + _ => { + eprintln!("test: unknown flag '{}'", args[0]); + false + } + } +} + +/// Read a line of input from stdin with an optional prompt +/// Returns the input string (trimmed) +pub fn input(prompt: Option<&str>) -> String { + if let Some(p) = prompt { + print!("{}", p); + io::stdout().flush().unwrap(); + } + + let mut buffer = String::new(); + io::stdin() + .read_line(&mut buffer) + .expect("Failed to read input"); + + buffer.trim().to_string() +} + +/// Ask a yes/no question and return true for yes, false for no +/// Accepts: y/yes/Y/YES or n/no/N/NO +/// Keeps prompting until valid input is received +pub fn confirm(prompt: &str) -> bool { + loop { + print!("{} (y/n): ", prompt); + io::stdout().flush().unwrap(); + + let mut buffer = String::new(); + io::stdin() + .read_line(&mut buffer) + .expect("Failed to read input"); + + let response = buffer.trim().to_lowercase(); + match response.as_str() { + "y" | "yes" => return true, + "n" | "no" => return false, + _ => { + println!("Please enter 'y' or 'n'"); + } + } + } +} + +/// Display a menu and let the user select one option +/// Returns the selected option (1-indexed) or 0 if cancelled +pub fn select(prompt: &str, options: &[String]) -> usize { + if options.is_empty() { + eprintln!("Error: No options provided to select"); + return 0; + } + + println!("{}", prompt); + println!(); + for (i, option) in options.iter().enumerate() { + println!(" {}. {}", i + 1, option); + } + println!(); + + loop { + print!("Select (1-{}): ", options.len()); + io::stdout().flush().unwrap(); + + let mut buffer = String::new(); + io::stdin() + .read_line(&mut buffer) + .expect("Failed to read input"); + + let choice = buffer.trim(); + if let Ok(num) = choice.parse::() { + if num >= 1 && num <= options.len() { + return num; + } + } + println!("Please enter a number between 1 and {}", options.len()); + } +} + +/// Display a menu and let the user select multiple options +/// Returns a comma-separated string of selected indices (1-indexed) +/// User can enter multiple numbers separated by commas or spaces +pub fn multiselect(prompt: &str, options: &[String]) -> String { + if options.is_empty() { + eprintln!("Error: No options provided to multiselect"); + return String::new(); + } + + println!("{}", prompt); + println!(); + for (i, option) in options.iter().enumerate() { + println!(" {}. {}", i + 1, option); + } + println!(); + + loop { + print!("Select multiple (e.g., 1,2,3 or 1 2 3): "); + io::stdout().flush().unwrap(); + + let mut buffer = String::new(); + io::stdin() + .read_line(&mut buffer) + .expect("Failed to read input"); + + let input = buffer.trim(); + + let selections: Vec = input + .split(|c: char| c == ',' || c.is_whitespace()) + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + + if selections.iter().all(|&n| n >= 1 && n <= options.len()) && !selections.is_empty() { + return selections + .iter() + .map(|n| n.to_string()) + .collect::>() + .join(","); + } + + if selections.is_empty() { + println!("Please enter at least one number"); + } else { + println!("Please enter numbers between 1 and {}", options.len()); + } + } +} + fn is_root() -> bool { // check if the current user is root by checking UID // on Unix systems, root has UID 0 diff --git a/src/runtime/environment.rs b/src/runtime/environment.rs index 5a87dee..a826a3d 100644 --- a/src/runtime/environment.rs +++ b/src/runtime/environment.rs @@ -87,26 +87,57 @@ impl Environment { while let Some(ch) = chars.next() { if ch == '$' { - let mut var_name = String::new(); - - while let Some(&next_ch) = chars.peek() { - if next_ch.is_alphanumeric() || next_ch == '_' { - var_name.push(next_ch); + if chars.peek() == Some(&'(') { + chars.next(); + let mut cmd = String::new(); + let mut depth = 1; + + while let Some(&next_ch) = chars.peek() { chars.next(); - } else { - break; + if next_ch == '(' { + depth += 1; + cmd.push(next_ch); + } else if next_ch == ')' { + depth -= 1; + if depth == 0 { + break; + } + cmd.push(next_ch); + } else { + cmd.push(next_ch); + } } - } - - if !var_name.is_empty() { - if let Some(value) = self.get_variable(&var_name) { - result.push_str(value); - } else { - result.push('$'); - result.push_str(&var_name); + + if let Ok(output) = std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + { + let stdout = String::from_utf8_lossy(&output.stdout); + result.push_str(stdout.trim()); } } else { - result.push('$'); + let mut var_name = String::new(); + + while let Some(&next_ch) = chars.peek() { + if next_ch.is_alphanumeric() || next_ch == '_' { + var_name.push(next_ch); + chars.next(); + } else { + break; + } + } + + if !var_name.is_empty() { + if let Some(value) = self.get_variable(&var_name) { + result.push_str(value); + } else { + result.push('$'); + result.push_str(&var_name); + } + } else { + result.push('$'); + } } } else { result.push(ch); diff --git a/vsc-extension/rush/.vscodeignore b/vsc-extension/rush/.vscodeignore index f369b5e..e138785 100644 --- a/vsc-extension/rush/.vscodeignore +++ b/vsc-extension/rush/.vscodeignore @@ -2,3 +2,15 @@ .vscode-test/** .gitignore vsc-extension-quickstart.md +node_modules/** +pnpm-lock.yaml +package-lock.json +*.vsix +.editorconfig +.eslintrc.json +tsconfig.json +**/*.ts +**/*.map +**/tsconfig.json +README-old.md +INSTALL.md \ No newline at end of file diff --git a/vsc-extension/rush/CHANGELOG.md b/vsc-extension/rush/CHANGELOG.md index 4d8271f..13cb2de 100644 --- a/vsc-extension/rush/CHANGELOG.md +++ b/vsc-extension/rush/CHANGELOG.md @@ -2,8 +2,29 @@ All notable changes to the "rush" extension will be documented in this file. -Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## [0.0.2] - 2025-11-03 -## [Unreleased] +### Added +- Added `test` builtin command syntax highlighting +- Added `input`, `confirm`, `select`, `multiselect` builtin functions +- Shebang lines (`#!/...`) now have distinct purple highlighting -- Initial release \ No newline at end of file +### Changed +- Improved shebang pattern matching to work on any line +- Removed shell command highlighting (git, cargo, npm, etc.) - only Rush builtins are highlighted now + +### Fixed +- Comments no longer match shebang lines +- Shebang `#!` punctuation now has the same color as the rest of the line + +## [0.0.1] - 2025-11-01 + +### Added +- Initial release +- Basic syntax highlighting for Rush shell scripts +- Support for `.rush` and `.rsh` file extensions +- Keyword highlighting (if, else, for, parallel, workers) +- Variable highlighting ($VAR syntax) +- Builtin command highlighting (echo, exit, cd, require_root) +- Comment support +- String interpolation support \ No newline at end of file diff --git a/vsc-extension/rush/package.json b/vsc-extension/rush/package.json index 1df554d..d524281 100644 --- a/vsc-extension/rush/package.json +++ b/vsc-extension/rush/package.json @@ -2,7 +2,7 @@ "name": "rush", "displayName": "Rush Shell", "description": "Syntax highlighting and validation for Rush shell scripts", - "version": "0.0.1", + "version": "0.0.2", "publisher": "rush-lang", "repository": { "type": "git", diff --git a/vsc-extension/rush/syntaxes/rush.tmLanguage.json b/vsc-extension/rush/syntaxes/rush.tmLanguage.json index bd9e682..02878e2 100644 --- a/vsc-extension/rush/syntaxes/rush.tmLanguage.json +++ b/vsc-extension/rush/syntaxes/rush.tmLanguage.json @@ -6,10 +6,10 @@ "include": "#shebang" }, { - "include": "#invalid-syntax" + "include": "#comments" }, { - "include": "#comments" + "include": "#invalid-syntax" }, { "include": "#keywords" @@ -40,11 +40,11 @@ "shebang": { "patterns": [ { - "name": "meta.shebang.rush", - "match": "\\A#!.*$", + "name": "meta.directive.shebang.rush", + "match": "^(#!.*$)", "captures": { - "0": { - "name": "comment.line.shebang.rush" + "1": { + "name": "keyword.other.directive.shebang.rush" } } } @@ -73,7 +73,7 @@ "patterns": [ { "name": "comment.line.number-sign.rush", - "match": "#(?!!).*$" + "match": "^[\\t ]*#(?!\\!).*$" } ] }, @@ -163,7 +163,7 @@ "patterns": [ { "name": "support.function.builtin.rush", - "match": "\\b(echo|exit|cd|ls|mkdir|rm|cp|mv|cat|grep|find|chmod|chown)\\b" + "match": "\\b(echo|exit|cd|test|require_root|input|confirm|select|multiselect)\\b" } ] }