added interactive inputs, better error messages, bugfixes

This commit is contained in:
2025-11-03 20:55:45 +01:00
parent 2336ce0325
commit d289cff56c
17 changed files with 955 additions and 55 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
target target
node_modules node_modules
*.vsix

View File

@@ -11,6 +11,7 @@ Rush is an experimental shell scripting language interpreter that combines simpl
- **Control flow** with `if`/`else` and `not` - **Control flow** with `if`/`else` and `not`
- **For loops** for iteration - **For loops** for iteration
- **Parallel execution** blocks for concurrent tasks - **Parallel execution** blocks for concurrent tasks
- **Interactive input** with `input()`, `confirm()`, `select()`, and `multiselect()`
- **Strict parsing** that catches errors before execution - **Strict parsing** that catches errors before execution
## Quick Start ## Quick Start

View File

@@ -14,6 +14,7 @@
- [Loops](./language/loops.md) - [Loops](./language/loops.md)
- [Parallel Execution](./language/parallel.md) - [Parallel Execution](./language/parallel.md)
- [Built-in Variables](./language/builtins.md) - [Built-in Variables](./language/builtins.md)
- [User Input](./language/input.md)
# Examples # Examples

347
docs/src/language/input.md Normal file
View File

@@ -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.

37
src/error/colors.rs Normal file
View File

@@ -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)
}

View File

@@ -1,3 +1,5 @@
pub mod colors;
#[derive(Debug)] #[derive(Debug)]
pub enum RushError { pub enum RushError {
SyntaxError(String), SyntaxError(String),
@@ -7,7 +9,11 @@ pub enum RushError {
impl std::fmt::Display for RushError { impl std::fmt::Display for RushError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 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),
}
} }
} }

View File

@@ -1,23 +1,53 @@
use crate::parser::ast::{AstNode, Command, Statement}; use crate::parser::ast::{AstNode, Command, Statement};
use crate::runtime::builtins; use crate::runtime::builtins;
use crate::runtime::environment::Environment; use crate::runtime::environment::Environment;
use crate::error::colors;
use std::process::Command as StdCommand; use std::process::Command as StdCommand;
pub struct Executor<'a> { pub struct Executor<'a> {
env: &'a mut Environment, env: &'a mut Environment,
current_file: Option<String>,
current_line: usize,
} }
impl<'a> Executor<'a> { impl<'a> Executor<'a> {
pub fn new(env: &'a mut Environment) -> Self { 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) { pub fn execute_node(&mut self, node: AstNode) {
match node { match node {
AstNode::VariableAssignment { name, value } => { AstNode::VariableAssignment { name, value } => {
if let AstNode::Literal(val) = *value { match *value {
let substituted = self.substitute_variables(&val); AstNode::Literal(val) => {
self.env.set_variable(name, substituted); 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 } => { AstNode::Command { name, args } => {
@@ -43,6 +73,16 @@ impl<'a> Executor<'a> {
builtins::exit(code); builtins::exit(code);
} else if name == "require_root" { } else if name == "require_root" {
builtins::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 { } else {
let cmd = Command { let cmd = Command {
name: name.clone(), name: name.clone(),
@@ -51,6 +91,10 @@ impl<'a> Executor<'a> {
self.execute_command(cmd); self.execute_command(cmd);
} }
} }
AstNode::FunctionCall { name, args } => {
self.execute_function_call(&name, args);
}
AstNode::ControlFlow { AstNode::ControlFlow {
condition, condition,
then_branch, then_branch,
@@ -384,27 +428,186 @@ impl<'a> Executor<'a> {
} }
} }
fn execute_function_call(&mut self, name: &str, args: Vec<AstNode>) -> String {
let arg_strings: Vec<String> = 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) { 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<String> = 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 { match result {
Ok(output) => { Ok(output) => {
if !output.stdout.is_empty() { 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)); eprint!("{}", String::from_utf8_lossy(&output.stderr));
} }
if !output.status.success() { if !output.status.success() {
eprintln!( eprintln!(
"Command '{}' failed with exit code: {:?}", "{}{}",
command.name, colors::error(&format!(
output.status.code() "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) => { 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 { 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.is_empty()
&& value != "0"
&& value.to_lowercase() != "false" && value.to_lowercase() != "false"
&& value.to_lowercase() != "no" && value.to_lowercase() != "no"
} }

View File

@@ -6,12 +6,16 @@ use crate::runtime::environment::Environment;
use executor::Executor; use executor::Executor;
pub fn execute(content: &str) { pub fn execute(content: &str) {
execute_with_filename(content, None);
}
pub fn execute_with_filename(content: &str, filename: Option<String>) {
let parser = Parser::new(); let parser = Parser::new();
let program = match parser.parse(content) { let program = match parser.parse(content) {
Ok(prog) => prog, Ok(prog) => prog,
Err(e) => { Err(e) => {
eprintln!("Parse error: {}", e); eprintln!("{}", e);
std::process::exit(1); std::process::exit(1);
} }
}; };
@@ -20,6 +24,10 @@ pub fn execute(content: &str) {
let mut executor = Executor::new(&mut env); let mut executor = Executor::new(&mut env);
if let Some(file) = filename {
executor.set_file(file);
}
for statement in program.statements { for statement in program.statements {
executor.execute_node(statement); executor.execute_node(statement);
} }

View File

@@ -1,3 +1,5 @@
use rush::error::colors;
fn main() { fn main() {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
@@ -11,9 +13,12 @@ fn main() {
let content = std::fs::read_to_string(script_path); let content = std::fs::read_to_string(script_path);
if content.is_err() { if content.is_err() {
eprintln!( eprintln!(
"Error reading file {}: {}", "{}",
script_path, colors::error(&format!(
content.err().unwrap() "Failed to read file '{}': {}",
colors::highlight(script_path),
content.err().unwrap()
))
); );
std::process::exit(1); std::process::exit(1);
} }
@@ -25,5 +30,5 @@ fn main() {
content content
}; };
rush::interpreter::execute(&stripped_content); rush::interpreter::execute_with_filename(&stripped_content, Some(script_path.clone()));
} }

View File

@@ -13,6 +13,10 @@ pub enum AstNode {
name: String, name: String,
args: Vec<AstNode>, args: Vec<AstNode>,
}, },
FunctionCall {
name: String,
args: Vec<AstNode>,
},
For { For {
var: String, var: String,
items: Vec<String>, items: Vec<String>,

View File

@@ -1,6 +1,7 @@
pub mod ast; pub mod ast;
use crate::error::RushError; use crate::error::RushError;
use crate::error::colors;
pub use ast::*; pub use ast::*;
use std::collections::HashSet; use std::collections::HashSet;
@@ -72,6 +73,11 @@ impl Parser {
self.check_node_for_undefined_vars(arg, defined_vars)?; 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 { AstNode::For {
var, var,
items: _, items: _,
@@ -129,10 +135,12 @@ impl Parser {
AstNode::Literal(s) => { AstNode::Literal(s) => {
for var_name in self.extract_variables(s) { for var_name in self.extract_variables(s) {
if !defined_vars.contains(&var_name) { if !defined_vars.contains(&var_name) {
return Err(RushError::VariableError(format!( return Err(RushError::VariableError(
"Variable '{}' is used before being defined", colors::error(&format!(
var_name "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)?; 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 // other node types dont directly contain variable references
} }
@@ -180,7 +193,7 @@ impl Parser {
fn parse_statement( fn parse_statement(
&self, &self,
lines: &[&str], lines: &[&str],
mut i: usize, i: usize,
) -> Result<(Option<AstNode>, usize), RushError> { ) -> Result<(Option<AstNode>, usize), RushError> {
self.parse_statement_with_context(lines, i, false) self.parse_statement_with_context(lines, i, false)
} }
@@ -278,7 +291,42 @@ impl Parser {
let var_name = parts[0].trim(); let var_name = parts[0].trim();
if !var_name.is_empty() && var_name.chars().all(|c| c.is_alphanumeric() || c == '_') 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<AstNode> = 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(( return Ok((
Some(AstNode::VariableAssignment { Some(AstNode::VariableAssignment {
name: var_name.to_string(), name: var_name.to_string(),

View File

@@ -1,3 +1,6 @@
use std::io::{self, Write};
use std::env;
pub fn echo(args: Vec<String>) { pub fn echo(args: Vec<String>) {
println!("{}", args.join(" ")); println!("{}", args.join(" "));
} }
@@ -13,6 +16,169 @@ pub fn exit(code: i32) {
std::process::exit(code); 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::<usize>() {
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<usize> = input
.split(|c: char| c == ',' || c.is_whitespace())
.filter_map(|s| s.trim().parse::<usize>().ok())
.collect();
if selections.iter().all(|&n| n >= 1 && n <= options.len()) && !selections.is_empty() {
return selections
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.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 { fn is_root() -> bool {
// check if the current user is root by checking UID // check if the current user is root by checking UID
// on Unix systems, root has UID 0 // on Unix systems, root has UID 0

View File

@@ -87,26 +87,57 @@ impl Environment {
while let Some(ch) = chars.next() { while let Some(ch) = chars.next() {
if ch == '$' { if ch == '$' {
let mut var_name = String::new(); if chars.peek() == Some(&'(') {
chars.next();
let mut cmd = String::new();
let mut depth = 1;
while let Some(&next_ch) = chars.peek() { while let Some(&next_ch) = chars.peek() {
if next_ch.is_alphanumeric() || next_ch == '_' {
var_name.push(next_ch);
chars.next(); chars.next();
} else { if next_ch == '(' {
break; 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 Ok(output) = std::process::Command::new("sh")
if let Some(value) = self.get_variable(&var_name) { .arg("-c")
result.push_str(value); .arg(&cmd)
} else { .output()
result.push('$'); {
result.push_str(&var_name); let stdout = String::from_utf8_lossy(&output.stdout);
result.push_str(stdout.trim());
} }
} else { } 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 { } else {
result.push(ch); result.push(ch);

View File

@@ -2,3 +2,15 @@
.vscode-test/** .vscode-test/**
.gitignore .gitignore
vsc-extension-quickstart.md 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

View File

@@ -2,8 +2,29 @@
All notable changes to the "rush" extension will be documented in this file. 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
### 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 - 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

View File

@@ -2,7 +2,7 @@
"name": "rush", "name": "rush",
"displayName": "Rush Shell", "displayName": "Rush Shell",
"description": "Syntax highlighting and validation for Rush shell scripts", "description": "Syntax highlighting and validation for Rush shell scripts",
"version": "0.0.1", "version": "0.0.2",
"publisher": "rush-lang", "publisher": "rush-lang",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@@ -6,10 +6,10 @@
"include": "#shebang" "include": "#shebang"
}, },
{ {
"include": "#invalid-syntax" "include": "#comments"
}, },
{ {
"include": "#comments" "include": "#invalid-syntax"
}, },
{ {
"include": "#keywords" "include": "#keywords"
@@ -40,11 +40,11 @@
"shebang": { "shebang": {
"patterns": [ "patterns": [
{ {
"name": "meta.shebang.rush", "name": "meta.directive.shebang.rush",
"match": "\\A#!.*$", "match": "^(#!.*$)",
"captures": { "captures": {
"0": { "1": {
"name": "comment.line.shebang.rush" "name": "keyword.other.directive.shebang.rush"
} }
} }
} }
@@ -73,7 +73,7 @@
"patterns": [ "patterns": [
{ {
"name": "comment.line.number-sign.rush", "name": "comment.line.number-sign.rush",
"match": "#(?!!).*$" "match": "^[\\t ]*#(?!\\!).*$"
} }
] ]
}, },
@@ -163,7 +163,7 @@
"patterns": [ "patterns": [
{ {
"name": "support.function.builtin.rush", "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"
} }
] ]
} }