diff --git a/src/expand.rs b/src/expand.rs index 42f74a0..575bd4e 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -554,8 +554,12 @@ pub fn expand_var(chars: &mut Peekable>) -> ShResult { pub fn expand_glob(raw: &str) -> ShResult { let mut words = vec![]; + let opts = glob::MatchOptions { + require_literal_leading_dot: !crate::state::read_shopts(|s| s.core.dotglob), + ..Default::default() + }; for entry in - glob::glob(raw).map_err(|_| ShErr::simple(ShErrKind::SyntaxErr, "Invalid glob pattern"))? + glob::glob_with(raw, opts).map_err(|_| ShErr::simple(ShErrKind::SyntaxErr, "Invalid glob pattern"))? { let entry = entry.map_err(|_| ShErr::simple(ShErrKind::SyntaxErr, "Invalid filename found in glob"))?; @@ -1926,7 +1930,8 @@ pub fn expand_prompt(raw: &str) -> ShResult { let pathbuf = PathBuf::from(&path); let mut segments = pathbuf.iter().count(); let mut path_iter = pathbuf.iter(); - while segments > 4 { + let max_segments = crate::state::read_shopts(|s| s.prompt.trunc_prompt_path); + while segments > max_segments { path_iter.next(); segments -= 1; } diff --git a/src/parse/execute.rs b/src/parse/execute.rs index 2ef0f17..102b472 100644 --- a/src/parse/execute.rs +++ b/src/parse/execute.rs @@ -20,6 +20,10 @@ use super::{ ParsedSrc, Redir, RedirType, }; +thread_local! { + static RECURSE_DEPTH: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + pub struct ScopeGuard; @@ -105,7 +109,12 @@ impl ExecArgs { pub fn exec_input(input: String, io_stack: Option, interactive: bool) -> ShResult<()> { let log_tab = read_logic(|l| l.clone()); let input = expand_aliases(input, HashSet::new(), &log_tab); - let mut parser = ParsedSrc::new(Arc::new(input)); + let lex_flags = if interactive { + super::lex::LexFlags::INTERACTIVE + } else { + super::lex::LexFlags::empty() + }; + let mut parser = ParsedSrc::new(Arc::new(input)).with_lex_flags(lex_flags); if let Err(errors) = parser.parse_src() { for error in errors { eprintln!("{error}"); @@ -170,6 +179,10 @@ impl Dispatcher { self.exec_builtin(node) } else if is_subsh(node.get_command().cloned()) { self.exec_subsh(node) + } else if crate::state::read_shopts(|s| s.core.autocd) && Path::new(cmd.span.as_str()).is_dir() { + let dir = cmd.span.as_str().to_string(); + let stack = IoStack { stack: self.io_stack.clone() }; + exec_input(format!("cd {dir}"), Some(stack), self.interactive) } else { self.exec_cmd(node) } @@ -266,6 +279,21 @@ impl Dispatcher { unreachable!() }; + let max_depth = crate::state::read_shopts(|s| s.core.max_recurse_depth); + let depth = RECURSE_DEPTH.with(|d| { + let cur = d.get(); + d.set(cur + 1); + cur + 1 + }); + if depth > max_depth { + RECURSE_DEPTH.with(|d| d.set(d.get() - 1)); + return Err(ShErr::full( + ShErrKind::InternalErr, + format!("maximum recursion depth ({max_depth}) exceeded"), + blame, + )); + } + let env_vars = self.set_assignments(assignments, AssignBehavior::Export)?; let _var_guard = VarCtxGuard::new(env_vars.into_iter().collect()); @@ -273,27 +301,30 @@ impl Dispatcher { let func_name = argv.remove(0).span.as_str().to_string(); let argv = prepare_argv(argv)?; - if let Some(func) = read_logic(|l| l.get_func(&func_name)) { + let result = if let Some(func) = read_logic(|l| l.get_func(&func_name)) { let _guard = ScopeGuard::exclusive_scope(Some(argv)); if let Err(e) = self.exec_brc_grp((*func).clone()) { match e.kind() { ShErrKind::FuncReturn(code) => { state::set_status(*code); - return Ok(()); + Ok(()) } - _ => return Err(e), + _ => Err(e), } + } else { + Ok(()) } - - Ok(()) } else { Err(ShErr::full( ShErrKind::InternalErr, format!("Failed to find function '{}'", func_name), blame, )) - } + }; + + RECURSE_DEPTH.with(|d| d.set(d.get() - 1)); + result } fn exec_brc_grp(&mut self, brc_grp: Node) -> ShResult<()> { let NdRule::BraceGrp { body } = brc_grp.class else { diff --git a/src/parse/lex.rs b/src/parse/lex.rs index f7d2492..33c6be6 100644 --- a/src/parse/lex.rs +++ b/src/parse/lex.rs @@ -156,10 +156,10 @@ pub struct LexStream { } bitflags! { - #[derive(Debug)] + #[derive(Debug, Clone, Copy)] pub struct LexFlags: u32 { - /// Return comment tokens - const LEX_COMMENTS = 0b000000001; + /// The lexer is operating in interactive mode + const INTERACTIVE = 0b000000001; /// Allow unfinished input const LEX_UNFINISHED = 0b000000010; /// The next string-type token is a command name @@ -740,7 +740,7 @@ impl Iterator for LexStream { } self.get_token(ch_idx..self.cursor, TkRule::Sep) } - '#' => { + '#' if !self.flags.contains(LexFlags::INTERACTIVE) || crate::state::read_shopts(|s| s.core.interactive_comments) => { let ch_idx = self.cursor; self.cursor += 1; diff --git a/src/parse/mod.rs b/src/parse/mod.rs index 4fada91..48ef9a7 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -44,6 +44,7 @@ macro_rules! try_match { pub struct ParsedSrc { pub src: Arc, pub ast: Ast, + pub lex_flags: LexFlags, } impl ParsedSrc { @@ -51,11 +52,16 @@ impl ParsedSrc { Self { src, ast: Ast::new(vec![]), + lex_flags: LexFlags::empty(), } } + pub fn with_lex_flags(mut self, flags: LexFlags) -> Self { + self.lex_flags = flags; + self + } pub fn parse_src(&mut self) -> Result<(), Vec> { let mut tokens = vec![]; - for lex_result in LexStream::new(self.src.clone(), LexFlags::empty()) { + for lex_result in LexStream::new(self.src.clone(), self.lex_flags) { match lex_result { Ok(token) => tokens.push(token), Err(error) => return Err(vec![error]), diff --git a/src/procio.rs b/src/procio.rs index 6d2e0c7..5438529 100644 --- a/src/procio.rs +++ b/src/procio.rs @@ -263,7 +263,7 @@ impl DerefMut for IoFrame { /// redirection #[derive(Debug, Default)] pub struct IoStack { - stack: Vec, + pub stack: Vec, } impl IoStack { diff --git a/src/prompt/readline/complete.rs b/src/prompt/readline/complete.rs index cee027b..30d3624 100644 --- a/src/prompt/readline/complete.rs +++ b/src/prompt/readline/complete.rs @@ -87,8 +87,8 @@ impl Completer { ctx.push(markers::VAR_SUB); } } - markers::ARG => { - log::debug!("Found argument marker at position {}", pos); + markers::ARG | markers::ASSIGNMENT => { + log::debug!("Found argument/assignment marker at position {}", pos); if last_priority < 1 { ctx_start = pos; ctx.push(markers::ARG); @@ -328,6 +328,8 @@ impl Completer { }) .collect(); + let limit = crate::state::read_shopts(|s| s.prompt.comp_limit); + candidates.truncate(limit); Ok(CompResult::from_candidates(candidates)) } diff --git a/src/prompt/readline/highlight.rs b/src/prompt/readline/highlight.rs index 2d2c32a..aa65179 100644 --- a/src/prompt/readline/highlight.rs +++ b/src/prompt/readline/highlight.rs @@ -1,6 +1,6 @@ -use std::{env, path::{Path, PathBuf}}; +use std::{env, os::unix::fs::PermissionsExt, path::{Path, PathBuf}}; -use crate::{libsh::term::{Style, StyleSet, Styled}, prompt::readline::{annotate_input, markers}, state::read_logic}; +use crate::{libsh::term::{Style, StyleSet, Styled}, prompt::readline::{annotate_input, markers}, state::{read_logic, read_shopts}}; /// Syntax highlighter for shell input using Unicode marker-based annotation /// @@ -214,16 +214,31 @@ impl Highlighter { fn is_valid(command: &str) -> bool { let path = env::var("PATH").unwrap_or_default(); let paths = path.split(':'); - if PathBuf::from(&command).exists() { - return true; + let cmd_path = PathBuf::from(&command); + + if cmd_path.exists() { + // the user has given us an absolute path + if cmd_path.is_dir() && read_shopts(|o| o.core.autocd) { + // this is a directory and autocd is enabled + return true; + } else { + let Ok(meta) = cmd_path.metadata() else { return false }; + // this is a file that is executable by someone + return meta.permissions().mode() & 0o111 == 0 + } } else { + // they gave us a command name + // now we must traverse the PATH env var + // and see if we find any matches for path in paths { let path = PathBuf::from(path).join(command); if path.exists() { - return true; + let Ok(meta) = path.metadata() else { continue }; + return meta.permissions().mode() & 0o111 != 0; } } + // also check shell functions and aliases for any matches let found = read_logic(|l| l.get_func(command).is_some() || l.get_alias(command).is_some()); if found { return true; diff --git a/src/prompt/readline/history.rs b/src/prompt/readline/history.rs index 853aefa..1481a0e 100644 --- a/src/prompt/readline/history.rs +++ b/src/prompt/readline/history.rs @@ -217,11 +217,17 @@ pub struct History { impl History { pub fn new() -> ShResult { + let ignore_dups = crate::state::read_shopts(|s| s.core.hist_ignore_dupes); + let max_hist = crate::state::read_shopts(|s| s.core.max_hist); let path = PathBuf::from(env::var("FERNHIST").unwrap_or({ let home = env::var("HOME").unwrap(); format!("{home}/.fern_history") })); let mut entries = read_hist_file(&path)?; + // Enforce max_hist limit on loaded entries + if entries.len() > max_hist { + entries = entries.split_off(entries.len() - max_hist); + } // Create pending entry for current input let id = entries.last().map(|ent| ent.id + 1).unwrap_or(0); entries.push(HistEntry { @@ -238,8 +244,8 @@ impl History { search_mask, cursor, search_direction: Direction::Backward, - ignore_dups: true, - max_size: None, + ignore_dups, + max_size: Some(max_hist as u32), }) } diff --git a/src/prompt/readline/mod.rs b/src/prompt/readline/mod.rs index e847806..5aa8bae 100644 --- a/src/prompt/readline/mod.rs +++ b/src/prompt/readline/mod.rs @@ -212,7 +212,10 @@ impl FernVi { self.editor.set_hint(hint); } None => { - self.writer.flush_write("\x07")?; // Bell character + match crate::state::read_shopts(|s| s.core.bell_style) { + crate::shopt::FernBellStyle::Audible => { self.writer.flush_write("\x07")?; } + crate::shopt::FernBellStyle::Visible | crate::shopt::FernBellStyle::Disable => {} + } } } @@ -240,10 +243,12 @@ impl FernVi { self.print_line()?; self.writer.flush_write("\n")?; let buf = self.editor.take_buf(); - // Save command to history - self.history.push(buf.clone()); - if let Err(e) = self.history.save() { - eprintln!("Failed to save history: {e}"); + // Save command to history if auto_hist is enabled + if crate::state::read_shopts(|s| s.core.auto_hist) { + self.history.push(buf.clone()); + if let Err(e) = self.history.save() { + eprintln!("Failed to save history: {e}"); + } } return Ok(ReadlineEvent::Line(buf)); } @@ -283,7 +288,8 @@ impl FernVi { pub fn get_layout(&mut self, line: &str) -> Layout { let to_cursor = self.editor.slice_to_cursor().unwrap_or_default(); let (cols, _) = get_win_size(STDIN_FILENO); - Layout::from_parts(/* tab_stop: */ 8, cols, &self.prompt, to_cursor, line) + let tab_stop = crate::state::read_shopts(|s| s.prompt.tab_stop) as u16; + Layout::from_parts(tab_stop, cols, &self.prompt, to_cursor, line) } pub fn scroll_history(&mut self, cmd: ViCmd) { /* @@ -360,15 +366,16 @@ impl FernVi { } pub fn line_text(&mut self) -> String { - let start = Instant::now(); let line = self.editor.to_string(); - self.highlighter.load_input(&line); - self.highlighter.highlight(); - let highlighted = self.highlighter.take(); let hint = self.editor.get_hint_text(); - let complete = format!("{highlighted}{hint}"); - let end = start.elapsed(); - complete + if crate::state::read_shopts(|s| s.prompt.highlight) { + self.highlighter.load_input(&line); + self.highlighter.highlight(); + let highlighted = self.highlighter.take(); + format!("{highlighted}{hint}") + } else { + format!("{line}{hint}") + } } pub fn print_line(&mut self) -> ShResult<()> { diff --git a/src/shopt.rs b/src/shopt.rs index 830f03c..b96b51f 100644 --- a/src/shopt.rs +++ b/src/shopt.rs @@ -111,7 +111,7 @@ impl ShOpts { return Err( ShErr::simple( ShErrKind::SyntaxErr, - "shopt: Expected 'core' or 'prompt' in shopt key", + "shopt: expected 'core' or 'prompt' in shopt key", ) .with_note( Note::new("'shopt' takes arguments separated by periods to denote namespaces") @@ -384,9 +384,8 @@ pub struct ShOptPrompt { pub trunc_prompt_path: usize, pub edit_mode: FernEditMode, pub comp_limit: usize, - pub prompt_highlight: bool, + pub highlight: bool, pub tab_stop: usize, - pub custom: HashMap, // Contains functions for prompt modules } impl ShOptPrompt { @@ -419,14 +418,14 @@ impl ShOptPrompt { }; self.comp_limit = val; } - "prompt_highlight" => { + "highlight" => { let Ok(val) = val.parse::() else { return Err(ShErr::simple( ShErrKind::SyntaxErr, - "shopt: expected 'true' or 'false' for prompt_highlight value", + "shopt: expected 'true' or 'false' for highlight value", )); }; - self.prompt_highlight = val; + self.highlight = val; } "tab_stop" => { let Ok(val) = val.parse::() else { @@ -444,19 +443,17 @@ impl ShOptPrompt { return Err( ShErr::simple( ShErrKind::SyntaxErr, - format!("shopt: Unexpected 'core' option '{opt}'"), + format!("shopt: Unexpected 'prompt' option '{opt}'"), ) - .with_note(Note::new("options can be accessed like 'core.option_name'")) + .with_note(Note::new("options can be accessed like 'prompt.option_name'")) .with_note( - Note::new("'core' contains the following options").with_sub_notes(vec![ - "dotglob", - "autocd", - "hist_ignore_dupes", - "max_hist", - "interactive_comments", - "auto_hist", - "bell_style", - "max_recurse_depth", + Note::new("'prompt' contains the following options").with_sub_notes(vec![ + "trunc_prompt_path", + "edit_mode", + "comp_limit", + "highlight", + "tab_stop", + "custom", ]), ), ) @@ -489,10 +486,10 @@ impl ShOptPrompt { output.push_str(&format!("{}", self.comp_limit)); Ok(Some(output)) } - "prompt_highlight" => { + "highlight" => { let mut output = String::from("Whether to enable or disable syntax highlighting on the prompt\n"); - output.push_str(&format!("{}", self.prompt_highlight)); + output.push_str(&format!("{}", self.highlight)); Ok(Some(output)) } "tab_stop" => { @@ -500,16 +497,6 @@ impl ShOptPrompt { output.push_str(&format!("{}", self.tab_stop)); Ok(Some(output)) } - "custom" => { - let mut output = String::from( - "A table of custom 'modules' executed as shell functions for prompt scripting\n", - ); - output.push_str("Current modules: \n"); - for key in self.custom.keys() { - output.push_str(&format!(" - {key}\n")); - } - Ok(Some(output.trim().to_string())) - } _ => Err( ShErr::simple( ShErrKind::SyntaxErr, @@ -540,12 +527,8 @@ impl Display for ShOptPrompt { output.push(format!("trunc_prompt_path = {}", self.trunc_prompt_path)); output.push(format!("edit_mode = {}", self.edit_mode)); output.push(format!("comp_limit = {}", self.comp_limit)); - output.push(format!("prompt_highlight = {}", self.prompt_highlight)); + output.push(format!("highlight = {}", self.highlight)); output.push(format!("tab_stop = {}", self.tab_stop)); - output.push(String::from("prompt modules: ")); - for key in self.custom.keys() { - output.push(format!(" - {key}")); - } let final_output = output.join("\n"); @@ -559,9 +542,8 @@ impl Default for ShOptPrompt { trunc_prompt_path: 4, edit_mode: FernEditMode::Vi, comp_limit: 100, - prompt_highlight: true, + highlight: true, tab_stop: 4, - custom: HashMap::new(), } } } diff --git a/src/state.rs b/src/state.rs index 88aeef3..d1999dd 100644 --- a/src/state.rs +++ b/src/state.rs @@ -121,6 +121,8 @@ impl ScopeStack { pub fn new() -> Self { let mut new = Self::default(); new.scopes.push(VarTab::new()); + let shell_name = std::env::args().next().unwrap_or_else(|| "fern".to_string()); + new.global_params.insert(ShellParam::ShellName.to_string(), shell_name); new } pub fn descend(&mut self, argv: Option>) { @@ -482,14 +484,6 @@ impl VarTab { fn init_params() -> HashMap { let mut params = HashMap::new(); params.insert(ShellParam::ArgCount, "0".into()); // Number of positional parameters - params.insert( - ShellParam::Pos(0), - std::env::current_exe() - .unwrap() - .to_str() - .unwrap() - .to_string(), - ); // Name of the shell params.insert(ShellParam::ShPid, Pid::this().to_string()); // PID of the shell params.insert(ShellParam::LastJob, "".into()); // PID of the last background job (if any) params diff --git a/src/tests/complete.rs b/src/tests/complete.rs index e5d44e9..27595eb 100644 --- a/src/tests/complete.rs +++ b/src/tests/complete.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use tempfile::TempDir; use crate::prompt::readline::complete::Completer; +use crate::prompt::readline::markers; use crate::state::{write_logic, write_vars, VarFlags}; use super::*; @@ -320,12 +321,12 @@ fn context_detection_command_position() { let completer = Completer::new(); // At the beginning - command context - let (in_cmd, _) = completer.get_completion_context("ech", 3); - assert!(in_cmd, "Should be in command context at start"); + let (ctx, _) = completer.get_completion_context("ech", 3); + assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context at start"); // After whitespace - still command if no command yet - let (in_cmd, _) = completer.get_completion_context(" ech", 5); - assert!(in_cmd, "Should be in command context after whitespace"); + let (ctx, _) = completer.get_completion_context(" ech", 5); + assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context after whitespace"); } #[test] @@ -333,11 +334,11 @@ fn context_detection_argument_position() { let completer = Completer::new(); // After a complete command - argument context - let (in_cmd, _) = completer.get_completion_context("echo hello", 10); - assert!(!in_cmd, "Should be in argument context after command"); + let (ctx, _) = completer.get_completion_context("echo hello", 10); + assert!(ctx.last() != Some(&markers::COMMAND), "Should be in argument context after command"); - let (in_cmd, _) = completer.get_completion_context("ls -la /tmp", 11); - assert!(!in_cmd, "Should be in argument context"); + let (ctx, _) = completer.get_completion_context("ls -la /tmp", 11); + assert!(ctx.last() != Some(&markers::COMMAND), "Should be in argument context"); } #[test] @@ -345,12 +346,12 @@ fn context_detection_nested_command_sub() { let completer = Completer::new(); // Inside $() - should be command context - let (in_cmd, _) = completer.get_completion_context("echo \"$(ech", 11); - assert!(in_cmd, "Should be in command context inside $()"); + let (ctx, _) = completer.get_completion_context("echo \"$(ech", 11); + assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context inside $()"); // After command in $() - argument context - let (in_cmd, _) = completer.get_completion_context("echo \"$(echo hell", 17); - assert!(!in_cmd, "Should be in argument context inside $()"); + let (ctx, _) = completer.get_completion_context("echo \"$(echo hell", 17); + assert!(ctx.last() != Some(&markers::COMMAND), "Should be in argument context inside $()"); } #[test] @@ -358,8 +359,8 @@ fn context_detection_pipe() { let completer = Completer::new(); // After pipe - command context - let (in_cmd, _) = completer.get_completion_context("ls | gre", 8); - assert!(in_cmd, "Should be in command context after pipe"); + let (ctx, _) = completer.get_completion_context("ls | gre", 8); + assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context after pipe"); } #[test] @@ -367,12 +368,74 @@ fn context_detection_command_sep() { let completer = Completer::new(); // After semicolon - command context - let (in_cmd, _) = completer.get_completion_context("echo foo; l", 11); - assert!(in_cmd, "Should be in command context after semicolon"); + let (ctx, _) = completer.get_completion_context("echo foo; l", 11); + assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context after semicolon"); // After && - command context - let (in_cmd, _) = completer.get_completion_context("true && l", 9); - assert!(in_cmd, "Should be in command context after &&"); + let (ctx, _) = completer.get_completion_context("true && l", 9); + assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context after &&"); +} + +#[test] +fn context_detection_variable_substitution() { + let completer = Completer::new(); + + // $VAR at argument position - VAR_SUB should take priority over ARG + let (ctx, _) = completer.get_completion_context("echo $HOM", 9); + assert_eq!(ctx.last(), Some(&markers::VAR_SUB), "Should be in var_sub context for $HOM"); + + // $VAR at command position - VAR_SUB should take priority over COMMAND + let (ctx, _) = completer.get_completion_context("$HOM", 4); + assert_eq!(ctx.last(), Some(&markers::VAR_SUB), "Should be in var_sub context for bare $HOM"); +} + +#[test] +fn context_detection_variable_in_double_quotes() { + let completer = Completer::new(); + + // $VAR inside double quotes + let (ctx, _) = completer.get_completion_context("echo \"$HOM", 10); + assert_eq!(ctx.last(), Some(&markers::VAR_SUB), "Should be in var_sub context inside double quotes"); +} + +#[test] +fn context_detection_stack_base_is_null() { + let completer = Completer::new(); + + // Empty input - only NULL on the stack + let (ctx, _) = completer.get_completion_context("", 0); + assert_eq!(ctx, vec![markers::NULL], "Empty input should only have NULL marker"); +} + +#[test] +fn context_detection_context_start_position() { + let completer = Completer::new(); + + // Command at start - ctx_start should be 0 + let (_, ctx_start) = completer.get_completion_context("ech", 3); + assert_eq!(ctx_start, 0, "Command at start should have ctx_start=0"); + + // Argument after command - ctx_start should be at arg position + let (_, ctx_start) = completer.get_completion_context("echo hel", 8); + assert_eq!(ctx_start, 5, "Argument ctx_start should point to arg start"); + + // Variable sub - ctx_start should point to the $ + let (_, ctx_start) = completer.get_completion_context("echo $HOM", 9); + assert_eq!(ctx_start, 5, "Var sub ctx_start should point to the $"); +} + +#[test] +fn context_detection_priority_ordering() { + let completer = Completer::new(); + + // COMMAND (priority 2) should override ARG (priority 1) + // After a pipe, the next token is a command even though it looks like an arg + let (ctx, _) = completer.get_completion_context("echo foo | gr", 13); + assert_eq!(ctx.last(), Some(&markers::COMMAND), "COMMAND should win over ARG after pipe"); + + // VAR_SUB (priority 3) should override COMMAND (priority 2) + let (ctx, _) = completer.get_completion_context("$PA", 3); + assert_eq!(ctx.last(), Some(&markers::VAR_SUB), "VAR_SUB should win over COMMAND"); } // ============================================================================