command arguments are now underlined if they match an existing path -m ran rustfmt on the entire codebase

This commit is contained in:
2026-02-19 21:32:03 -05:00
parent b668dab522
commit a18a0b622f
44 changed files with 5549 additions and 5019 deletions

View File

@@ -3,7 +3,7 @@ use crate::{
libsh::error::{ShErr, ShErrKind, ShResult},
parse::{NdRule, Node},
prelude::*,
procio::{borrow_fd, IoStack},
procio::{IoStack, borrow_fd},
state::{self, read_logic, write_logic},
};

View File

@@ -49,9 +49,13 @@ pub fn cd(node: Node, job: &mut JobBldr) -> ShResult<()> {
span,
));
}
let new_dir = env::current_dir().map_err(
|e| ShErr::full(ShErrKind::ExecFail, format!("cd: Failed to get current directory: {}", e), span)
)?;
let new_dir = env::current_dir().map_err(|e| {
ShErr::full(
ShErrKind::ExecFail,
format!("cd: Failed to get current directory: {}", e),
span,
)
})?;
unsafe { env::set_var("PWD", new_dir) };
state::set_status(0);

View File

@@ -1,14 +1,34 @@
use std::sync::LazyLock;
use crate::{
builtin::setup_builtin, expand::expand_prompt, getopt::{Opt, OptSpec, get_opts_from_tokens}, jobs::JobBldr, libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt}, parse::{NdRule, Node}, prelude::*, procio::{IoStack, borrow_fd}, state
builtin::setup_builtin,
expand::expand_prompt,
getopt::{Opt, OptSpec, get_opts_from_tokens},
jobs::JobBldr,
libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt},
parse::{NdRule, Node},
prelude::*,
procio::{IoStack, borrow_fd},
state,
};
pub const ECHO_OPTS: [OptSpec; 4] = [
OptSpec { opt: Opt::Short('n'), takes_arg: false },
OptSpec { opt: Opt::Short('E'), takes_arg: false },
OptSpec { opt: Opt::Short('e'), takes_arg: false },
OptSpec { opt: Opt::Short('p'), takes_arg: false },
OptSpec {
opt: Opt::Short('n'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('E'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('e'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('p'),
takes_arg: false,
},
];
bitflags! {
@@ -40,13 +60,15 @@ pub fn echo(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResult<(
borrow_fd(STDOUT_FILENO)
};
let mut echo_output = prepare_echo_args(argv
let mut echo_output = prepare_echo_args(
argv
.into_iter()
.map(|a| a.0) // Extract the String from the tuple of (String,Span)
.collect::<Vec<_>>(),
flags.contains(EchoFlags::USE_ESCAPE),
flags.contains(EchoFlags::USE_PROMPT)
)?.join(" ");
flags.contains(EchoFlags::USE_PROMPT),
)?
.join(" ");
if !flags.contains(EchoFlags::NO_NEWLINE) && !echo_output.ends_with('\n') {
echo_output.push('\n')
@@ -58,14 +80,18 @@ pub fn echo(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResult<(
Ok(())
}
pub fn prepare_echo_args(argv: Vec<String>, use_escape: bool, use_prompt: bool) -> ShResult<Vec<String>> {
pub fn prepare_echo_args(
argv: Vec<String>,
use_escape: bool,
use_prompt: bool,
) -> ShResult<Vec<String>> {
if !use_escape {
if use_prompt {
let expanded: ShResult<Vec<String>> = argv
.into_iter()
.map(|s| expand_prompt(s.as_str()))
.collect();
return expanded
return expanded;
}
return Ok(argv);
}

View File

@@ -1,6 +1,6 @@
use crate::{
libsh::error::{ShErr, ShErrKind, ShResult},
parse::{execute::prepare_argv, NdRule, Node},
parse::{NdRule, Node, execute::prepare_argv},
prelude::*,
};
@@ -32,7 +32,6 @@ pub fn flowctl(node: Node, kind: ShErrKind) -> ShResult<()> {
code = status;
}
let kind = match kind {
LoopContinue(_) => LoopContinue(code),
LoopBreak(_) => LoopBreak(code),

View File

@@ -1,9 +1,9 @@
use crate::{
jobs::{JobBldr, JobCmdFlags, JobID},
libsh::error::{ShErr, ShErrKind, ShResult},
parse::{lex::Span, NdRule, Node},
parse::{NdRule, Node, lex::Span},
prelude::*,
procio::{borrow_fd, IoStack},
procio::{IoStack, borrow_fd},
state::{self, read_jobs, write_jobs},
};
@@ -168,7 +168,7 @@ pub fn jobs(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResult<(
ShErrKind::SyntaxErr,
"Invalid flag in jobs call",
span,
))
));
}
};
flags |= flag

View File

@@ -4,7 +4,9 @@ use crate::{
jobs::{ChildProc, JobBldr},
libsh::error::ShResult,
parse::{
Redir, execute::prepare_argv, lex::{Span, Tk}
Redir,
execute::prepare_argv,
lex::{Span, Tk},
},
procio::{IoFrame, IoStack, RedirGuard},
};
@@ -16,19 +18,17 @@ pub mod export;
pub mod flowctl;
pub mod jobctl;
pub mod pwd;
pub mod read;
pub mod shift;
pub mod shopt;
pub mod source;
pub mod test; // [[ ]] thing
pub mod read;
pub mod zoltraak;
pub mod trap;
pub mod zoltraak;
pub const BUILTINS: [&str; 21] = [
"echo", "cd", "read", "export", "pwd", "source",
"shift", "jobs", "fg", "bg", "alias", "unalias",
"return", "break", "continue", "exit", "zoltraak",
"shopt", "builtin", "command", "trap"
"echo", "cd", "read", "export", "pwd", "source", "shift", "jobs", "fg", "bg", "alias", "unalias",
"return", "break", "continue", "exit", "zoltraak", "shopt", "builtin", "command", "trap",
];
/// Sets up a builtin command

View File

@@ -3,7 +3,7 @@ use crate::{
libsh::error::ShResult,
parse::{NdRule, Node},
prelude::*,
procio::{borrow_fd, IoStack},
procio::{IoStack, borrow_fd},
state,
};

View File

@@ -1,16 +1,50 @@
use bitflags::bitflags;
use nix::{errno::Errno, libc::{STDIN_FILENO, STDOUT_FILENO}, unistd::{isatty, read, write}};
use nix::{
errno::Errno,
libc::{STDIN_FILENO, STDOUT_FILENO},
unistd::{isatty, read, write},
};
use crate::{builtin::setup_builtin, getopt::{Opt, OptSpec, get_opts_from_tokens}, jobs::JobBldr, libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt}, parse::{NdRule, Node}, procio::{IoStack, borrow_fd}, prompt::readline::term::RawModeGuard, state::{self, VarFlags, read_vars, write_vars}};
use crate::{
builtin::setup_builtin,
getopt::{Opt, OptSpec, get_opts_from_tokens},
jobs::JobBldr,
libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt},
parse::{NdRule, Node},
procio::{IoStack, borrow_fd},
prompt::readline::term::RawModeGuard,
state::{self, VarFlags, read_vars, write_vars},
};
pub const READ_OPTS: [OptSpec; 7] = [
OptSpec { opt: Opt::Short('r'), takes_arg: false }, // don't allow backslash escapes
OptSpec { opt: Opt::Short('s'), takes_arg: false }, // don't echo input
OptSpec { opt: Opt::Short('a'), takes_arg: false }, // read into array
OptSpec { opt: Opt::Short('n'), takes_arg: false }, // read only N characters
OptSpec { opt: Opt::Short('t'), takes_arg: false }, // timeout
OptSpec { opt: Opt::Short('p'), takes_arg: true }, // prompt
OptSpec { opt: Opt::Short('d'), takes_arg: true }, // read until delimiter
OptSpec {
opt: Opt::Short('r'),
takes_arg: false,
}, // don't allow backslash escapes
OptSpec {
opt: Opt::Short('s'),
takes_arg: false,
}, // don't echo input
OptSpec {
opt: Opt::Short('a'),
takes_arg: false,
}, // read into array
OptSpec {
opt: Opt::Short('n'),
takes_arg: false,
}, // read only N characters
OptSpec {
opt: Opt::Short('t'),
takes_arg: false,
}, // timeout
OptSpec {
opt: Opt::Short('p'),
takes_arg: true,
}, // prompt
OptSpec {
opt: Opt::Short('d'),
takes_arg: true,
}, // read until delimiter
];
bitflags! {
@@ -33,8 +67,9 @@ pub fn read_builtin(node: Node, _io_stack: &mut IoStack, job: &mut JobBldr) -> S
let blame = node.get_span().clone();
let NdRule::Command {
assignments: _,
argv
} = node.class else {
argv,
} = node.class
else {
unreachable!()
};
@@ -56,10 +91,12 @@ pub fn read_builtin(node: Node, _io_stack: &mut IoStack, job: &mut JobBldr) -> S
match read(STDIN_FILENO, &mut buf) {
Ok(0) => {
state::set_status(1);
let str_result = String::from_utf8(input.clone()).map_err(|e| ShErr::simple(
let str_result = String::from_utf8(input.clone()).map_err(|e| {
ShErr::simple(
ShErrKind::ExecFail,
format!("read: Input was not valid UTF-8: {e}"),
))?;
)
})?;
return Ok(str_result); // EOF
}
Ok(_) => {
@@ -70,29 +107,32 @@ pub fn read_builtin(node: Node, _io_stack: &mut IoStack, job: &mut JobBldr) -> S
// Delimiter reached, stop reading
break;
}
}
else if read_opts.flags.contains(ReadFlags::NO_ESCAPES)
&& buf[0] == b'\\' {
} else if read_opts.flags.contains(ReadFlags::NO_ESCAPES) && buf[0] == b'\\' {
escaped = true;
} else {
input.push(buf[0]);
}
}
Err(Errno::EINTR) => continue,
Err(e) => return Err(ShErr::simple(
Err(e) => {
return Err(ShErr::simple(
ShErrKind::ExecFail,
format!("read: Failed to read from stdin: {e}"),
)),
));
}
}
}
state::set_status(0);
let str_result = String::from_utf8(input.clone()).map_err(|e| ShErr::simple(
let str_result = String::from_utf8(input.clone()).map_err(|e| {
ShErr::simple(
ShErrKind::ExecFail,
format!("read: Input was not valid UTF-8: {e}"),
))?;
)
})?;
Ok(str_result)
}).blame(blame)?
})
.blame(blame)?
} else {
let mut input: Vec<u8> = vec![];
loop {
@@ -109,16 +149,20 @@ pub fn read_builtin(node: Node, _io_stack: &mut IoStack, job: &mut JobBldr) -> S
input.push(buf[0]);
}
Err(Errno::EINTR) => continue,
Err(e) => return Err(ShErr::simple(
Err(e) => {
return Err(ShErr::simple(
ShErrKind::ExecFail,
format!("read: Failed to read from stdin: {e}"),
)),
));
}
}
String::from_utf8(input).map_err(|e| ShErr::simple(
}
String::from_utf8(input).map_err(|e| {
ShErr::simple(
ShErrKind::ExecFail,
format!("read: Input was not valid UTF-8: {e}"),
))?
)
})?
};
if argv.is_empty() {
@@ -128,7 +172,9 @@ pub fn read_builtin(node: Node, _io_stack: &mut IoStack, job: &mut JobBldr) -> S
} else {
// get our field separator
let mut field_sep = read_vars(|v| v.get_var("IFS"));
if field_sep.is_empty() { field_sep = " ".to_string() }
if field_sep.is_empty() {
field_sep = " ".to_string()
}
let mut remaining = input;
for (i, arg) in argv.iter().enumerate() {
@@ -146,7 +192,8 @@ pub fn read_builtin(node: Node, _io_stack: &mut IoStack, job: &mut JobBldr) -> S
let (field, rest) = trimmed.split_at(idx);
write_vars(|v| v.set_var(&arg.0, field, VarFlags::NONE));
// note that this doesn't account for consecutive IFS characters, which is what that trim above is for
// note that this doesn't account for consecutive IFS characters, which is what
// that trim above is for
remaining = rest.to_string();
} else {
write_vars(|v| v.set_var(&arg.0, trimmed, VarFlags::NONE));
@@ -174,7 +221,9 @@ pub fn get_read_flags(opts: Vec<Opt>) -> ShResult<ReadOpts> {
Opt::Short('n') => read_opts.flags |= ReadFlags::N_CHARS,
Opt::Short('t') => read_opts.flags |= ReadFlags::TIMEOUT,
Opt::ShortWithArg('p', prompt) => read_opts.prompt = Some(prompt),
Opt::ShortWithArg('d', delim) => read_opts.delim = delim.chars().map(|c| c as u8).next().unwrap_or(b'\n'),
Opt::ShortWithArg('d', delim) => {
read_opts.delim = delim.chars().map(|c| c as u8).next().unwrap_or(b'\n')
}
_ => {
return Err(ShErr::simple(
ShErrKind::ExecFail,

View File

@@ -3,7 +3,7 @@ use crate::{
libsh::error::{ShResult, ShResultExt},
parse::{NdRule, Node},
prelude::*,
procio::{borrow_fd, IoStack},
procio::{IoStack, borrow_fd},
state::write_shopts,
};

View File

@@ -8,7 +8,7 @@ use regex::Regex;
use crate::{
libsh::error::{ShErr, ShErrKind, ShResult},
parse::{ConjunctOp, NdRule, Node, TestCase, TEST_UNARY_OPS},
parse::{ConjunctOp, NdRule, Node, TEST_UNARY_OPS, TestCase},
prelude::*,
};
@@ -254,7 +254,7 @@ pub fn double_bracket_test(node: Node) -> ShResult<bool> {
msg: "Expected a binary operator in this test call; found a unary operator".into(),
notes: vec![],
span: err_span,
})
});
}
TestOp::StringEq => rhs.trim() == lhs.trim(),
TestOp::StringNeq => rhs.trim() != lhs.trim(),

View File

@@ -1,14 +1,25 @@
use std::{fmt::Display, str::FromStr};
use nix::{libc::{STDERR_FILENO, STDOUT_FILENO}, sys::signal::Signal, unistd::write};
use nix::{
libc::{STDERR_FILENO, STDOUT_FILENO},
sys::signal::Signal,
unistd::write,
};
use crate::{builtin::setup_builtin, jobs::JobBldr, libsh::error::{ShErr, ShErrKind, ShResult}, parse::{NdRule, Node}, procio::{IoStack, borrow_fd}, state::{self, read_logic, write_logic}};
use crate::{
builtin::setup_builtin,
jobs::JobBldr,
libsh::error::{ShErr, ShErrKind, ShResult},
parse::{NdRule, Node},
procio::{IoStack, borrow_fd},
state::{self, read_logic, write_logic},
};
#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord, Hash)]
pub enum TrapTarget {
Exit,
Error,
Signal(Signal)
Signal(Signal),
}
impl FromStr for TrapTarget {
@@ -52,7 +63,7 @@ impl FromStr for TrapTarget {
return Err(ShErr::simple(
ShErrKind::ExecFail,
format!("invalid trap target '{}'", s),
))
));
}
}
}
@@ -63,8 +74,7 @@ impl Display for TrapTarget {
match self {
TrapTarget::Exit => write!(f, "EXIT"),
TrapTarget::Error => write!(f, "ERR"),
TrapTarget::Signal(s) => {
match s {
TrapTarget::Signal(s) => match s {
Signal::SIGHUP => write!(f, "HUP"),
Signal::SIGINT => write!(f, "INT"),
Signal::SIGQUIT => write!(f, "QUIT"),
@@ -101,8 +111,7 @@ impl Display for TrapTarget {
log::warn!("TrapTarget::fmt() : unrecognized signal {}", s);
Err(std::fmt::Error)
}
}
}
},
}
}
}
@@ -136,7 +145,7 @@ pub fn trap(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResult<(
let stderr = borrow_fd(STDERR_FILENO);
write(stderr, b"usage: trap <COMMAND> [SIGNAL...]\n")?;
state::set_status(1);
return Ok(())
return Ok(());
}
let mut args = argv.into_iter();

View File

@@ -38,12 +38,30 @@ pub fn zoltraak(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResu
unreachable!()
};
let zolt_opts = [
OptSpec { opt: Opt::Long("dry-run".into()), takes_arg: false },
OptSpec { opt: Opt::Long("confirm".into()), takes_arg: false },
OptSpec { opt: Opt::Long("no-preserve-root".into()), takes_arg: false },
OptSpec { opt: Opt::Short('r'), takes_arg: false },
OptSpec { opt: Opt::Short('f'), takes_arg: false },
OptSpec { opt: Opt::Short('v'), takes_arg: false }
OptSpec {
opt: Opt::Long("dry-run".into()),
takes_arg: false,
},
OptSpec {
opt: Opt::Long("confirm".into()),
takes_arg: false,
},
OptSpec {
opt: Opt::Long("no-preserve-root".into()),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('r'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('f'),
takes_arg: false,
},
OptSpec {
opt: Opt::Short('v'),
takes_arg: false,
},
];
let mut flags = ZoltFlags::empty();
@@ -90,7 +108,6 @@ pub fn zoltraak(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResu
let (argv, _guard) = setup_builtin(argv, job, Some((io_stack, node.redirs)))?;
for (arg, span) in argv {
if &arg == "/" && !flags.contains(ZoltFlags::NO_PRESERVE_ROOT) {
return Err(
@@ -109,7 +126,6 @@ pub fn zoltraak(node: Node, io_stack: &mut IoStack, job: &mut JobBldr) -> ShResu
}
}
Ok(())
}

View File

@@ -7,11 +7,13 @@ use regex::Regex;
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
use crate::parse::execute::exec_input;
use crate::parse::lex::{is_field_sep, is_hard_sep, LexFlags, LexStream, Tk, TkFlags, TkRule};
use crate::parse::lex::{LexFlags, LexStream, Tk, TkFlags, TkRule, is_field_sep, is_hard_sep};
use crate::parse::{Redir, RedirType};
use crate::{jobs, prelude::*};
use crate::procio::{IoBuf, IoFrame, IoMode, IoStack};
use crate::state::{LogTab, VarFlags, read_jobs, read_logic, read_vars, write_jobs, write_meta, write_vars};
use crate::state::{
LogTab, VarFlags, read_jobs, read_logic, read_vars, write_jobs, write_meta, write_vars,
};
use crate::{jobs, prelude::*};
const PARAMETERS: [char; 7] = ['@', '*', '#', '$', '?', '!', '0'];
@@ -30,8 +32,9 @@ pub const PROC_SUB_IN: char = '\u{fdd5}';
/// Output process sub marker
pub const PROC_SUB_OUT: char = '\u{fdd6}';
/// Marker for null expansion
/// This is used for when "$@" or "$*" are used in quotes and there are no arguments
/// Without this marker, it would be handled like an empty string, which breaks some commands
/// This is used for when "$@" or "$*" are used in quotes and there are no
/// arguments Without this marker, it would be handled like an empty string,
/// which breaks some commands
pub const NULL_EXPAND: char = '\u{fdd7}';
impl Tk {
@@ -74,13 +77,14 @@ impl Expander {
let has_leading_dot_slash = self.raw.starts_with("./");
if let Ok(glob_exp) = expand_glob(&self.raw)
&& !glob_exp.is_empty() {
&& !glob_exp.is_empty()
{
self.raw = glob_exp;
}
if has_trailing_slash && !self.raw.ends_with('/') {
// glob expansion can remove trailing slashes and leading dot-slashes, but we want to preserve them
// so that things like tab completion don't break
// glob expansion can remove trailing slashes and leading dot-slashes, but we
// want to preserve them so that things like tab completion don't break
self.raw.push('/');
}
if has_leading_dot_slash && !self.raw.starts_with("./") {
@@ -132,7 +136,8 @@ impl Expander {
}
/// Check if a string contains valid brace expansion patterns.
/// Returns true if there's a valid {a,b} or {1..5} pattern at the outermost level.
/// Returns true if there's a valid {a,b} or {1..5} pattern at the outermost
/// level.
fn has_braces(s: &str) -> bool {
let mut chars = s.chars().peekable();
let mut depth = 0;
@@ -143,7 +148,9 @@ fn has_braces(s: &str) -> bool {
while let Some(ch) = chars.next() {
match ch {
'\\' => { chars.next(); } // skip escaped char
'\\' => {
chars.next();
} // skip escaped char
'\'' if cur_quote.is_none() => cur_quote = Some('\''),
'\'' if cur_quote == Some('\'') => cur_quote = None,
'"' if cur_quote.is_none() => cur_quote = Some('"'),
@@ -222,19 +229,23 @@ fn expand_one_brace(word: &str) -> ShResult<Vec<String>> {
if parts.len() == 1 && parts[0] == inner {
// Check if it's a range
if let Some(range_parts) = try_expand_range(&inner) {
return Ok(range_parts
return Ok(
range_parts
.into_iter()
.map(|p| format!("{}{}{}", prefix, p, suffix))
.collect());
.collect(),
);
}
// Not a valid brace expression, return as-is with literal braces
return Ok(vec![format!("{}{{{}}}{}", prefix, inner, suffix)]);
}
Ok(parts
Ok(
parts
.into_iter()
.map(|p| format!("{}{}{}", prefix, p, suffix))
.collect())
.collect(),
)
}
/// Extract prefix, inner, and suffix from a brace expression.
@@ -253,12 +264,22 @@ fn get_brace_parts(word: &str) -> Option<(String, String, String)> {
prefix.push(next);
}
}
'\'' if cur_quote.is_none() => { cur_quote = Some('\'');
prefix.push(ch); }
'\'' if cur_quote == Some('\'') => { cur_quote = None;
prefix.push(ch); }
'"' if cur_quote.is_none() => { cur_quote = Some('"'); prefix.push(ch); }
'"' if cur_quote == Some('"') => { cur_quote = None; prefix.push(ch); }
'\'' if cur_quote.is_none() => {
cur_quote = Some('\'');
prefix.push(ch);
}
'\'' if cur_quote == Some('\'') => {
cur_quote = None;
prefix.push(ch);
}
'"' if cur_quote.is_none() => {
cur_quote = Some('"');
prefix.push(ch);
}
'"' if cur_quote == Some('"') => {
cur_quote = None;
prefix.push(ch);
}
'{' if cur_quote.is_none() => {
break;
}
@@ -279,10 +300,22 @@ fn get_brace_parts(word: &str) -> Option<(String, String, String)> {
inner.push(next);
}
}
'\'' if cur_quote.is_none() => { cur_quote = Some('\''); inner.push(ch); }
'\'' if cur_quote == Some('\'') => { cur_quote = None; inner.push(ch); }
'"' if cur_quote.is_none() => { cur_quote = Some('"'); inner.push(ch); }
'"' if cur_quote == Some('"') => { cur_quote = None; inner.push(ch); }
'\'' if cur_quote.is_none() => {
cur_quote = Some('\'');
inner.push(ch);
}
'\'' if cur_quote == Some('\'') => {
cur_quote = None;
inner.push(ch);
}
'"' if cur_quote.is_none() => {
cur_quote = Some('"');
inner.push(ch);
}
'"' if cur_quote == Some('"') => {
cur_quote = None;
inner.push(ch);
}
'{' if cur_quote.is_none() => {
depth += 1;
inner.push(ch);
@@ -326,10 +359,22 @@ fn split_brace_inner(inner: &str) -> Vec<String> {
current.push(next);
}
}
'\'' if cur_quote.is_none() => { cur_quote = Some('\''); current.push(ch); }
'\'' if cur_quote == Some('\'') => { cur_quote = None; current.push(ch); }
'"' if cur_quote.is_none() => { cur_quote = Some('"'); current.push(ch); }
'"' if cur_quote == Some('"') => { cur_quote = None; current.push(ch); }
'\'' if cur_quote.is_none() => {
cur_quote = Some('\'');
current.push(ch);
}
'\'' if cur_quote == Some('\'') => {
cur_quote = None;
current.push(ch);
}
'"' if cur_quote.is_none() => {
cur_quote = Some('"');
current.push(ch);
}
'"' if cur_quote == Some('"') => {
cur_quote = None;
current.push(ch);
}
'{' if cur_quote.is_none() => {
depth += 1;
current.push(ch);
@@ -364,15 +409,16 @@ fn try_expand_range(inner: &str) -> Option<Vec<String>> {
let start = parts[0];
let end = parts[1];
let step: i32 = parts[2].parse().ok()?;
if step == 0 { return None; }
if step == 0 {
return None;
}
expand_range(start, end, step.unsigned_abs() as usize)
}
_ => None,
}
}
fn expand_range(start: &str, end: &str, step: usize) ->
Option<Vec<String>> {
fn expand_range(start: &str, end: &str, step: usize) -> Option<Vec<String>> {
// Try character range first
if is_alpha_range_bound(start) && is_alpha_range_bound(end) {
let start_char = start.chars().next()? as u8;
@@ -405,8 +451,7 @@ Option<Vec<String>> {
// Handle zero-padding
let pad_width = start.len().max(end.len());
let needs_padding = start.starts_with('0') ||
end.starts_with('0');
let needs_padding = start.starts_with('0') || end.starts_with('0');
let (lo, hi) = if reverse {
(end_num, start_num)
@@ -435,7 +480,6 @@ Option<Vec<String>> {
None
}
fn is_alpha_range_bound(word: &str) -> bool {
word.len() == 1 && word.chars().all(|c| c.is_ascii_alphabetic())
}
@@ -558,8 +602,8 @@ pub fn expand_glob(raw: &str) -> ShResult<String> {
require_literal_leading_dot: !crate::state::read_shopts(|s| s.core.dotglob),
..Default::default()
};
for entry in
glob::glob_with(raw, opts).map_err(|_| ShErr::simple(ShErrKind::SyntaxErr, "Invalid glob pattern"))?
for entry in 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"))?;
@@ -625,7 +669,7 @@ impl ArithTk {
kind: ShErrKind::ParseErr,
msg: "Invalid character in arithmetic substitution".into(),
notes: vec![],
})
});
}
}
}
@@ -707,7 +751,7 @@ impl ArithTk {
kind: ShErrKind::ParseErr,
msg: "Unexpected token during evaluation".into(),
notes: vec![],
})
});
}
}
}
@@ -807,8 +851,10 @@ pub fn expand_proc_sub(raw: &str, is_input: bool) -> ShResult<String> {
/// Get the command output of a given command input as a String
pub fn expand_cmd_sub(raw: &str) -> ShResult<String> {
if raw.starts_with('(') && raw.ends_with(')')
&& let Ok(output) = expand_arithmetic(raw) {
if raw.starts_with('(')
&& raw.ends_with(')')
&& let Ok(output) = expand_arithmetic(raw)
{
return Ok(output); // It's actually an arithmetic sub
}
let (rpipe, wpipe) = IoMode::get_pipes();
@@ -829,7 +875,8 @@ pub fn expand_cmd_sub(raw: &str) -> ShResult<String> {
ForkResult::Parent { child } => {
std::mem::drop(cmd_sub_io_frame); // Closes the write pipe
// Read output first (before waiting) to avoid deadlock if child fills pipe buffer
// Read output first (before waiting) to avoid deadlock if child fills pipe
// buffer
loop {
match io_buf.fill_buffer() {
Ok(()) => break,
@@ -851,9 +898,7 @@ pub fn expand_cmd_sub(raw: &str) -> ShResult<String> {
jobs::take_term()?;
match status {
WtStat::Exited(_, _) => {
Ok(io_buf.as_str()?.trim_end().to_string())
}
WtStat::Exited(_, _) => Ok(io_buf.as_str()?.trim_end().to_string()),
_ => Err(ShErr::simple(ShErrKind::InternalErr, "Command sub failed")),
}
}
@@ -1785,7 +1830,6 @@ fn tokenize_prompt(raw: &str) -> Vec<PromptTk> {
let mut func_name = String::new();
let is_braced = chars.peek() == Some(&'{');
while let Some(ch) = chars.peek() {
match ch {
'}' if is_braced => {
chars.next();

View File

@@ -42,7 +42,7 @@ impl Display for Opt {
Self::Long(opt) => write!(f, "--{}", opt),
Self::Short(opt) => write!(f, "-{}", opt),
Self::LongWithArg(opt, arg) => write!(f, "--{} {}", opt, arg),
Self::ShortWithArg(opt, arg) => write!(f, "-{} {}", opt, arg)
Self::ShortWithArg(opt, arg) => write!(f, "-{} {}", opt, arg),
}
}
}
@@ -87,7 +87,8 @@ pub fn get_opts_from_tokens(tokens: Vec<Tk>, opt_specs: &[OptSpec]) -> (Vec<Tk>,
for opt_spec in opt_specs {
if opt_spec.opt == opt {
if opt_spec.takes_arg {
let arg = tokens_iter.next()
let arg = tokens_iter
.next()
.map(|t| t.to_string())
.unwrap_or_default();

View File

@@ -2,7 +2,11 @@ use crate::{
libsh::{
error::ShResult,
term::{Style, Styled},
}, prelude::*, procio::{IoMode, borrow_fd}, signal::{disable_reaping, enable_reaping}, state::{self, set_status, read_jobs, write_jobs}
},
prelude::*,
procio::{IoMode, borrow_fd},
signal::{disable_reaping, enable_reaping},
state::{self, read_jobs, set_status, write_jobs},
};
pub const SIG_EXIT_OFFSET: i32 = 128;
@@ -685,7 +689,9 @@ pub fn wait_fg(job: Job) -> ShResult<()> {
}
// If job wasn't stopped (moved to bg), clear the fg slot
if !was_stopped {
write_jobs(|j| { j.take_fg(); });
write_jobs(|j| {
j.take_fg();
});
}
take_term()?;
set_status(code);

View File

@@ -1,6 +1,6 @@
use termios::{LocalFlags, Termios};
use crate::{prelude::*};
use crate::prelude::*;
///
/// The previous state of the terminal options.
///
@@ -33,12 +33,14 @@ pub(crate) static mut SAVED_TERMIOS: Option<Option<Termios>> = None;
#[derive(Debug)]
pub struct TermiosGuard {
saved_termios: Option<Termios>
saved_termios: Option<Termios>,
}
impl TermiosGuard {
pub fn new(new_termios: Termios) -> Self {
let mut new = Self { saved_termios: None };
let mut new = Self {
saved_termios: None,
};
if isatty(std::io::stdin().as_raw_fd()).unwrap() {
let current_termios = termios::tcgetattr(std::io::stdin()).unwrap();
@@ -48,7 +50,8 @@ impl TermiosGuard {
std::io::stdin(),
nix::sys::termios::SetArg::TCSANOW,
&new_termios,
).unwrap();
)
.unwrap();
}
new
@@ -66,11 +69,7 @@ impl Default for TermiosGuard {
impl Drop for TermiosGuard {
fn drop(&mut self) {
if let Some(saved) = &self.saved_termios {
termios::tcsetattr(
std::io::stdin(),
nix::sys::termios::SetArg::TCSANOW,
saved,
).unwrap();
termios::tcsetattr(std::io::stdin(), nix::sys::termios::SetArg::TCSANOW, saved).unwrap();
}
}
}

View File

@@ -83,8 +83,7 @@ impl TkVecUtils<Tk> for Vec<Tk> {
}
}
fn debug_tokens(&self) {
for token in self {
}
for token in self {}
}
}

View File

@@ -22,10 +22,10 @@ use std::os::fd::BorrowedFd;
use std::process::ExitCode;
use std::sync::atomic::Ordering;
use nix::errno::Errno;
use nix::libc::STDIN_FILENO;
use nix::poll::{PollFd, PollFlags, PollTimeout, poll};
use nix::unistd::read;
use nix::errno::Errno;
use crate::builtin::trap::TrapTarget;
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
@@ -87,7 +87,8 @@ fn main() -> ExitCode {
};
if let Some(trap) = read_logic(|l| l.get_trap(TrapTarget::Exit))
&& let Err(e) = exec_input(trap, None, false) {
&& let Err(e) = exec_input(trap, None, false)
{
eprintln!("fern: error running EXIT trap: {e}");
}
@@ -99,15 +100,24 @@ fn run_script<P: AsRef<Path>>(path: P, args: Vec<String>) -> ShResult<()> {
if !path.is_file() {
eprintln!("fern: Failed to open input file: {}", path.display());
QUIT_CODE.store(1, Ordering::SeqCst);
return Err(ShErr::simple(ShErrKind::CleanExit(1), "input file not found"));
return Err(ShErr::simple(
ShErrKind::CleanExit(1),
"input file not found",
));
}
let Ok(input) = fs::read_to_string(path) else {
eprintln!("fern: Failed to read input file: {}", path.display());
QUIT_CODE.store(1, Ordering::SeqCst);
return Err(ShErr::simple(ShErrKind::CleanExit(1), "failed to read input file"));
return Err(ShErr::simple(
ShErrKind::CleanExit(1),
"failed to read input file",
));
};
write_vars(|v| v.cur_scope_mut().bpush_arg(path.to_string_lossy().to_string()));
write_vars(|v| {
v.cur_scope_mut()
.bpush_arg(path.to_string_lossy().to_string())
});
for arg in args {
write_vars(|v| v.cur_scope_mut().bpush_arg(arg))
}
@@ -129,7 +139,10 @@ fn fern_interactive() -> ShResult<()> {
Err(e) => {
eprintln!("Failed to initialize readline: {e}");
QUIT_CODE.store(1, Ordering::SeqCst);
return Err(ShErr::simple(ShErrKind::CleanExit(1), "readline initialization failed"));
return Err(ShErr::simple(
ShErrKind::CleanExit(1),
"readline initialization failed",
));
}
};
@@ -173,7 +186,10 @@ fn fern_interactive() -> ShResult<()> {
}
// Check if stdin has data
if fds[0].revents().is_some_and(|r| r.contains(PollFlags::POLLIN)) {
if fds[0]
.revents()
.is_some_and(|r| r.contains(PollFlags::POLLIN))
{
let mut buffer = [0u8; 1024];
match read(STDIN_FILENO, &mut buffer) {
Ok(0) => {
@@ -225,15 +241,13 @@ fn fern_interactive() -> ShResult<()> {
Ok(ReadlineEvent::Pending) => {
// No complete input yet, keep polling
}
Err(e) => {
match e.kind() {
Err(e) => match e.kind() {
ShErrKind::CleanExit(code) => {
QUIT_CODE.store(*code, Ordering::SeqCst);
return Ok(());
}
_ => eprintln!("{e}"),
}
}
},
}
}

View File

@@ -2,22 +2,33 @@ use std::collections::{HashSet, VecDeque};
use crate::{
builtin::{
alias::{alias, unalias}, cd::cd, echo::echo, export::export, flowctl::flowctl, jobctl::{JobBehavior, continue_job, jobs}, pwd::pwd, read::read_builtin, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, zoltraak::zoltraak
alias::{alias, unalias},
cd::cd,
echo::echo,
export::export,
flowctl::flowctl,
jobctl::{JobBehavior, continue_job, jobs},
pwd::pwd,
read::read_builtin,
shift::shift,
shopt::shopt,
source::source,
test::double_bracket_test,
trap::{TrapTarget, trap},
zoltraak::zoltraak,
},
expand::expand_aliases,
jobs::{ChildProc, JobStack, dispatch_job},
libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt},
prelude::*,
procio::{IoMode, IoStack},
state::{
self, ShFunc, VarFlags, read_logic, write_logic, write_vars
},
state::{self, ShFunc, VarFlags, read_logic, write_logic, write_vars},
};
use super::{
lex::{Span, Tk, TkFlags, KEYWORDS},
AssignKind, CaseNode, CondNode, ConjunctNode, ConjunctOp, LoopKind, NdFlags, NdRule, Node,
ParsedSrc, Redir, RedirType,
lex::{KEYWORDS, Span, Tk, TkFlags},
};
thread_local! {
@@ -26,7 +37,6 @@ thread_local! {
pub struct ScopeGuard;
impl ScopeGuard {
pub fn exclusive_scope(args: Option<Vec<(String, Span)>>) -> Self {
let argv = args.map(|a| a.into_iter().map(|(s, _)| s).collect::<Vec<_>>());
@@ -50,7 +60,7 @@ impl Drop for ScopeGuard {
/// such as 'VAR=value <command> <args>'
/// or for-loop variables
struct VarCtxGuard {
vars: HashSet<String>
vars: HashSet<String>,
}
impl VarCtxGuard {
fn new(vars: HashSet<String>) -> Self {
@@ -129,7 +139,8 @@ pub fn exec_input(input: String, io_stack: Option<IoStack>, interactive: bool) -
let result = dispatcher.begin_dispatch();
if state::get_status() != 0
&& let Some(trap) = read_logic(|l| l.get_trap(TrapTarget::Error)) {
&& let Some(trap) = read_logic(|l| l.get_trap(TrapTarget::Error))
{
let saved_status = state::get_status();
exec_input(trap, None, false)?;
state::set_status(saved_status);
@@ -188,9 +199,12 @@ 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() {
} 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() };
let stack = IoStack {
stack: self.io_stack.clone(),
};
exec_input(format!("cd {dir}"), Some(stack), self.interactive)
} else {
self.exec_cmd(node)
@@ -340,9 +354,7 @@ impl Dispatcher {
unreachable!()
};
self.io_stack.append_to_frame(brc_grp.redirs);
let _guard = self.io_stack
.pop_frame()
.redirect()?;
let _guard = self.io_stack.pop_frame().redirect()?;
for node in body {
let blame = node.get_span();
@@ -361,9 +373,7 @@ impl Dispatcher {
};
self.io_stack.append_to_frame(case_stmt.redirs);
let _guard = self.io_stack
.pop_frame()
.redirect()?;
let _guard = self.io_stack.pop_frame().redirect()?;
let exp_pattern = pattern.clone().expand()?;
let pattern_raw = exp_pattern
@@ -402,9 +412,7 @@ impl Dispatcher {
};
self.io_stack.append_to_frame(loop_stmt.redirs);
let _guard = self.io_stack
.pop_frame()
.redirect()?;
let _guard = self.io_stack.pop_frame().redirect()?;
let CondNode { cond, body } = cond_node;
'outer: loop {
@@ -445,32 +453,31 @@ impl Dispatcher {
};
let to_expanded_strings = |tks: Vec<Tk>| -> ShResult<Vec<String>> {
Ok(tks.into_iter()
Ok(
tks
.into_iter()
.map(|tk| tk.expand().map(|tk| tk.get_words()))
.collect::<ShResult<Vec<Vec<String>>>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>())
.collect::<Vec<_>>(),
)
};
// Expand all array variables
let arr: Vec<String> = to_expanded_strings(arr)?;
let vars: Vec<String> = to_expanded_strings(vars)?;
let mut for_guard = VarCtxGuard::new(
vars.iter().map(|v| v.to_string()).collect()
);
let mut for_guard = VarCtxGuard::new(vars.iter().map(|v| v.to_string()).collect());
self.io_stack.append_to_frame(for_stmt.redirs);
let _guard = self.io_stack
.pop_frame()
.redirect()?;
let _guard = self.io_stack.pop_frame().redirect()?;
'outer: for chunk in arr.chunks(vars.len()) {
let empty = String::new();
let chunk_iter = vars.iter().zip(
chunk.iter().chain(std::iter::repeat(&empty)),
);
let chunk_iter = vars
.iter()
.zip(chunk.iter().chain(std::iter::repeat(&empty)));
for (var, val) in chunk_iter {
write_vars(|v| v.set_var(&var.to_string(), &val.to_string(), VarFlags::NONE));
@@ -506,9 +513,7 @@ impl Dispatcher {
};
self.io_stack.append_to_frame(if_stmt.redirs);
let _guard = self.io_stack
.pop_frame()
.redirect()?;
let _guard = self.io_stack.pop_frame().redirect()?;
let mut matched = false;
for node in cond_nodes {
@@ -562,11 +567,7 @@ impl Dispatcher {
Ok(())
}
fn exec_builtin(&mut self, mut cmd: Node) -> ShResult<()> {
let NdRule::Command {
assignments,
argv,
} = &mut cmd.class
else {
let NdRule::Command { assignments, argv } = &mut cmd.class else {
unreachable!()
};
let env_vars = self.set_assignments(mem::take(assignments), AssignBehavior::Export)?;
@@ -646,9 +647,7 @@ impl Dispatcher {
let exec_args = ExecArgs::new(argv)?;
let _guard = self.io_stack
.pop_frame()
.redirect()?;
let _guard = self.io_stack.pop_frame().redirect()?;
let job = self.job_stack.curr_job_mut().unwrap();

View File

@@ -324,7 +324,8 @@ impl LexStream {
let can_be_subshell = chars.peek() == Some(&'(');
if self.flags.contains(LexFlags::IN_CASE)
&& let Some(count) = case_pat_lookahead(chars.clone()) {
&& let Some(count) = case_pat_lookahead(chars.clone())
{
pos += count;
let casepat_tk = self.get_token(self.cursor..pos, TkRule::CasePattern);
self.cursor = pos;
@@ -740,7 +741,10 @@ 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) => {
'#'
if !self.flags.contains(LexFlags::INTERACTIVE)
|| crate::state::read_shopts(|s| s.core.interactive_comments) =>
{
let ch_idx = self.cursor;
self.cursor += 1;

View File

@@ -1335,7 +1335,12 @@ impl ParseStream {
break;
}
match tk.class {
TkRule::EOI | TkRule::Pipe | TkRule::And | TkRule::BraceGrpEnd | TkRule::Or | TkRule::Bg => break,
TkRule::EOI
| TkRule::Pipe
| TkRule::And
| TkRule::BraceGrpEnd
| TkRule::Or
| TkRule::Bg => break,
TkRule::Sep => {
node_tks.push(tk.clone());
break;

View File

@@ -19,17 +19,17 @@ pub use std::os::unix::io::{AsRawFd, BorrowedFd, FromRawFd, IntoRawFd, OwnedFd,
pub use bitflags::bitflags;
pub use nix::{
errno::Errno,
fcntl::{open, OFlag},
fcntl::{OFlag, open},
libc::{self, STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
sys::{
signal::{self, kill, killpg, pthread_sigmask, signal, SigHandler, SigSet, SigmaskHow, Signal},
signal::{self, SigHandler, SigSet, SigmaskHow, Signal, kill, killpg, pthread_sigmask, signal},
stat::Mode,
termios::{self},
wait::{waitpid, WaitPidFlag as WtFlag, WaitStatus as WtStat},
wait::{WaitPidFlag as WtFlag, WaitStatus as WtStat, waitpid},
},
unistd::{
close, dup, dup2, execvpe, fork, getpgid, getpgrp, isatty, pipe, read, setpgid, tcgetpgrp,
tcsetpgrp, write, ForkResult, Pid,
ForkResult, Pid, close, dup, dup2, execvpe, fork, getpgid, getpgrp, isatty, pipe, read,
setpgid, tcgetpgrp, tcsetpgrp, write,
},
};

View File

@@ -4,10 +4,13 @@ use std::{
};
use crate::{
expand::Expander, libsh::{
expand::Expander,
libsh::{
error::{ShErr, ShErrKind, ShResult},
utils::RedirVecUtils,
}, parse::{Redir, RedirType, get_redir_file}, prelude::*
},
parse::{Redir, RedirType, get_redir_file},
prelude::*,
};
// Credit to fish-shell for many of the implementation ideas present in this
@@ -70,15 +73,10 @@ impl IoMode {
}
pub fn open_file(mut self) -> ShResult<Self> {
if let IoMode::File { tgt_fd, path, mode } = self {
let path_raw = path
.as_os_str()
.to_str()
.unwrap_or_default()
.to_string();
let path_raw = path.as_os_str().to_str().unwrap_or_default().to_string();
let expanded_path = Expander::from_raw(&path_raw)?
.expand()?
.join(" "); // should just be one string, will have to find some way to handle a return of multiple
let expanded_path = Expander::from_raw(&path_raw)?.expand()?.join(" "); // should just be one string, will have to find some way to handle a return of
// multiple
let expanded_pathbuf = PathBuf::from(expanded_path);

View File

@@ -1,7 +1,6 @@
pub mod readline;
pub mod statusline;
use crate::{expand::expand_prompt, libsh::error::ShResult, prelude::*};
/// Initialize the line editor

View File

@@ -1,20 +1,25 @@
use std::{env, os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
use crate::{builtin::BUILTINS, libsh::error::{ShErr, ShErrKind, ShResult}, parse::lex::{self, LexFlags, Tk, TkFlags}, prompt::readline::{Marker, annotate_input, annotate_input_recursive, get_insertions, markers::{self, is_marker}}, state::{read_logic, read_vars}};
use crate::{
builtin::BUILTINS,
libsh::error::{ShErr, ShErrKind, ShResult},
parse::lex::{self, LexFlags, Tk, TkFlags},
prompt::readline::{
Marker, annotate_input, annotate_input_recursive, get_insertions,
markers::{self, is_marker},
},
state::{read_logic, read_vars},
};
pub enum CompCtx {
CmdName,
FileName
FileName,
}
pub enum CompResult {
NoMatch,
Single {
result: String
},
Many {
candidates: Vec<String>
}
Single { result: String },
Many { candidates: Vec<String> },
}
impl CompResult {
@@ -22,7 +27,9 @@ impl CompResult {
if candidates.is_empty() {
Self::NoMatch
} else if candidates.len() == 1 {
Self::Single { result: candidates[0].clone() }
Self::Single {
result: candidates[0].clone(),
}
} else {
Self::Many { candidates }
}
@@ -55,7 +62,6 @@ impl Completer {
pub fn get_completion_context(&self, line: &str, cursor_pos: usize) -> (Vec<Marker>, usize) {
let annotated = annotate_input_recursive(line);
log::debug!("Annotated input for completion context: {:?}", annotated);
let mut ctx = vec![markers::NULL];
let mut last_priority = 0;
let mut ctx_start = 0;
@@ -63,10 +69,8 @@ impl Completer {
for ch in annotated.chars() {
match ch {
_ if is_marker(ch) => {
match ch {
_ if is_marker(ch) => match ch {
markers::COMMAND | markers::BUILTIN => {
log::debug!("Found command marker at position {}", pos);
if last_priority < 2 {
if last_priority > 0 {
ctx.pop();
@@ -77,7 +81,6 @@ impl Completer {
}
}
markers::VAR_SUB => {
log::debug!("Found variable substitution marker at position {}", pos);
if last_priority < 3 {
if last_priority > 0 {
ctx.pop();
@@ -88,15 +91,13 @@ impl Completer {
}
}
markers::ARG | markers::ASSIGNMENT => {
log::debug!("Found argument/assignment marker at position {}", pos);
if last_priority < 1 {
ctx_start = pos;
ctx.push(markers::ARG);
}
}
_ => {}
}
}
},
_ => {
last_priority = 0; // reset priority on normal characters
pos += 1; // we hit a normal character, advance our position
@@ -118,7 +119,12 @@ impl Completer {
self.active = false;
}
pub fn complete(&mut self, line: String, cursor_pos: usize, direction: i32) -> ShResult<Option<String>> {
pub fn complete(
&mut self,
line: String,
cursor_pos: usize,
direction: i32,
) -> ShResult<Option<String>> {
if self.active {
Ok(Some(self.cycle_completion(direction)))
} else {
@@ -160,8 +166,7 @@ impl Completer {
Ok(Some(self.get_completed_line()))
}
CompResult::NoMatch => Ok(None)
CompResult::NoMatch => Ok(None),
}
}
@@ -220,19 +225,24 @@ impl Completer {
let selected = &self.candidates[self.selected_idx];
let (start, end) = self.token_span;
format!("{}{}{}", &self.original_input[..start], selected, &self.original_input[end..])
format!(
"{}{}{}",
&self.original_input[..start],
selected,
&self.original_input[end..]
)
}
pub fn get_candidates(&mut self, line: String, cursor_pos: usize) -> ShResult<CompResult> {
let source = Arc::new(line.clone());
let tokens = lex::LexStream::new(source, LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<Tk>>>()?;
let tokens =
lex::LexStream::new(source, LexFlags::LEX_UNFINISHED).collect::<ShResult<Vec<Tk>>>()?;
let Some(mut cur_token) = tokens.into_iter().find(|tk| {
let start = tk.span.start;
let end = tk.span.end;
(start..=end).contains(&cursor_pos)
}) else {
log::debug!("No token found at cursor position");
let candidates = Self::complete_filename("./"); // Default to filename completion if no token is found
let end_pos = line.len();
self.token_span = (end_pos, end_pos);
@@ -241,11 +251,12 @@ impl Completer {
self.token_span = (cur_token.span.start, cur_token.span.end);
// Look for marker at the START of what we're completing, not at cursor
let (mut ctx, token_start) = self.get_completion_context(&line, cursor_pos);
self.token_span.0 = token_start; // Update start of token span based on context
cur_token.span.set_range(self.token_span.0..self.token_span.1); // Update token span to reflect context
cur_token
.span
.set_range(self.token_span.0..self.token_span.1); // Update token span to reflect context
// If token contains '=', only complete after the '='
let token_str = cur_token.span.as_str();
@@ -257,12 +268,12 @@ impl Completer {
if ctx.last().is_some_and(|m| *m == markers::VAR_SUB) {
let var_sub = &cur_token.as_str();
if let Some((var_name, start, end)) = Self::extract_var_name(var_sub) {
log::debug!("Extracted variable name for completion: {}", var_name);
if read_vars(|v| v.get_var(&var_name)).is_empty() {
// if we are here, we have a variable substitution that isn't complete
// so let's try to complete it
let ret: ShResult<CompResult> = read_vars(|v| {
let var_matches = v.flatten_vars()
let var_matches = v
.flatten_vars()
.keys()
.filter(|k| k.starts_with(&var_name) && *k != &var_name)
.map(|k| k.to_string())
@@ -272,7 +283,9 @@ impl Completer {
let name_start = cur_token.span.start + start;
let name_end = cur_token.span.start + end;
self.token_span = (name_start, name_end);
cur_token.span.set_range(self.token_span.0..self.token_span.1);
cur_token
.span
.set_range(self.token_span.0..self.token_span.1);
Ok(CompResult::from_candidates(var_matches))
} else {
Ok(CompResult::NoMatch)
@@ -296,20 +309,12 @@ impl Completer {
let expanded = expanded_words.join("\\ ");
let mut candidates = match ctx.pop() {
Some(markers::COMMAND) => {
log::debug!("Completing command: {}", &expanded);
Self::complete_command(&expanded)?
}
Some(markers::ARG) => {
log::debug!("Completing filename: {}", &expanded);
Self::complete_filename(&expanded)
}
Some(m) => {
log::warn!("Unknown marker {:?} in completion context", m);
Some(markers::COMMAND) => Self::complete_command(&expanded)?,
Some(markers::ARG) => Self::complete_filename(&expanded),
Some(_) => {
return Ok(CompResult::NoMatch);
}
None => {
log::warn!("No marker found in completion context");
return Ok(CompResult::NoMatch);
}
};
@@ -321,10 +326,11 @@ impl Completer {
// /path/to/some_path/file.txt
// and instead returns
// $SOME_PATH/file.txt
candidates = candidates.into_iter()
candidates = candidates
.into_iter()
.map(|c| match c.strip_prefix(&expanded) {
Some(suffix) => format!("{raw_tk}{suffix}"),
None => c
None => c,
})
.collect();
@@ -341,16 +347,23 @@ impl Completer {
let paths = path.split(':').map(PathBuf::from).collect::<Vec<_>>();
for path in paths {
// Skip directories that don't exist (common in PATH)
let Ok(entries) = std::fs::read_dir(path) else { continue; };
let Ok(entries) = std::fs::read_dir(path) else {
continue;
};
for entry in entries {
let Ok(entry) = entry else { continue; };
let Ok(meta) = entry.metadata() else { continue; };
let Ok(entry) = entry else {
continue;
};
let Ok(meta) = entry.metadata() else {
continue;
};
let file_name = entry.file_name().to_string_lossy().to_string();
if meta.is_file()
&& (meta.permissions().mode() & 0o111) != 0
&& file_name.starts_with(start) {
&& file_name.starts_with(start)
{
candidates.push(file_name);
}
}
@@ -390,13 +403,7 @@ impl Completer {
fn complete_filename(start: &str) -> Vec<String> {
let mut candidates = vec![];
// If completing after '=', only use the part after it
let start = if let Some(eq_pos) = start.rfind('=') {
&start[eq_pos + 1..]
} else {
start
};
let has_dotslash = start.starts_with("./");
// Split path into directory and filename parts
// Use "." if start is empty (e.g., after "foo=")
@@ -405,9 +412,13 @@ impl Completer {
// Completing inside a directory: "src/" → dir="src/", prefix=""
(path, "")
} else if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty() {
&& !parent.as_os_str().is_empty()
{
// Has directory component: "src/ma" → dir="src", prefix="ma"
(parent.to_path_buf(), path.file_name().unwrap().to_str().unwrap_or(""))
(
parent.to_path_buf(),
path.file_name().unwrap().to_str().unwrap_or(""),
)
} else {
// No directory: "fil" → dir=".", prefix="fil"
(PathBuf::from("."), start)
@@ -435,7 +446,12 @@ impl Completer {
full_path.push(""); // adds trailing /
}
candidates.push(full_path.to_string_lossy().to_string());
let mut path_raw = full_path.to_string_lossy().to_string();
if path_raw.starts_with("./") && !has_dotslash {
path_raw = path_raw.trim_start_matches("./").to_string();
}
candidates.push(path_raw);
}
}
@@ -443,3 +459,9 @@ impl Completer {
candidates
}
}
impl Default for Completer {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,13 +1,22 @@
use std::{env, os::unix::fs::PermissionsExt, 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, read_shopts}};
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
///
/// The highlighter processes annotated input strings containing invisible Unicode markers
/// (U+FDD0-U+FDEF range) that indicate syntax elements. It generates ANSI escape codes
/// for terminal display while maintaining a style stack for proper color restoration
/// in nested constructs (e.g., variables inside strings inside command substitutions).
/// The highlighter processes annotated input strings containing invisible
/// Unicode markers (U+FDD0-U+FDEF range) that indicate syntax elements. It
/// generates ANSI escape codes for terminal display while maintaining a style
/// stack for proper color restoration in nested constructs (e.g., variables
/// inside strings inside command substitutions).
pub struct Highlighter {
input: String,
output: String,
@@ -45,29 +54,26 @@ impl Highlighter {
let mut input_chars = input.chars().peekable();
while let Some(ch) = input_chars.next() {
match ch {
markers::STRING_DQ_END |
markers::STRING_SQ_END |
markers::VAR_SUB_END |
markers::CMD_SUB_END |
markers::PROC_SUB_END |
markers::SUBSH_END => self.pop_style(),
markers::STRING_DQ_END
| markers::STRING_SQ_END
| markers::VAR_SUB_END
| markers::CMD_SUB_END
| markers::PROC_SUB_END
| markers::SUBSH_END => self.pop_style(),
markers::CMD_SEP |
markers::RESET => self.clear_styles(),
markers::CMD_SEP | markers::RESET => self.clear_styles(),
markers::STRING_DQ |
markers::STRING_SQ |
markers::KEYWORD => self.push_style(Style::Yellow),
markers::STRING_DQ | markers::STRING_SQ | markers::KEYWORD => {
self.push_style(Style::Yellow)
}
markers::BUILTIN => self.push_style(Style::Green),
markers::CASE_PAT => self.push_style(Style::Blue),
markers::ARG => self.push_style(Style::White),
markers::COMMENT => self.push_style(Style::BrightBlack),
markers::GLOB => self.push_style(Style::Blue),
markers::REDIRECT |
markers::OPERATOR => self.push_style(Style::Magenta | Style::Bold),
markers::REDIRECT | markers::OPERATOR => self.push_style(Style::Magenta | Style::Bold),
markers::ASSIGNMENT => {
let mut var_name = String::new();
@@ -92,14 +98,34 @@ impl Highlighter {
self.pop_style();
}
markers::COMMAND => {
let mut cmd_name = String::new();
while let Some(ch) = input_chars.peek() {
if *ch == markers::RESET {
markers::ARG => {
let mut arg = String::new();
let mut chars_clone = input_chars.clone();
while let Some(ch) = chars_clone.next() {
if ch == markers::RESET {
break;
}
cmd_name.push(*ch);
input_chars.next();
arg.push(ch);
}
let style = if Self::is_filename(&arg) {
Style::White | Style::Underline
} else {
Style::White.into()
};
self.push_style(style);
self.last_was_reset = false;
}
markers::COMMAND => {
let mut cmd_name = String::new();
let mut chars_clone = input_chars.clone();
while let Some(ch) = chars_clone.next() {
if ch == markers::RESET {
break;
}
cmd_name.push(ch);
}
let style = if Self::is_valid(&cmd_name) {
Style::Green.into()
@@ -107,7 +133,6 @@ impl Highlighter {
Style::Red | Style::Bold
};
self.push_style(style);
self.output.push_str(&cmd_name);
self.last_was_reset = false;
}
markers::CMD_SUB | markers::SUBSH | markers::PROC_SUB => {
@@ -134,17 +159,19 @@ impl Highlighter {
markers::CMD_SUB => "$(",
markers::SUBSH => "(",
markers::PROC_SUB => {
if inner.starts_with("<(") { "<(" }
else if inner.starts_with(">(") { ">(" }
else { "<(" } // fallback
if inner.starts_with("<(") {
"<("
} else if inner.starts_with(">(") {
">("
} else {
"<("
} // fallback
}
_ => unreachable!(),
};
let inner_content = if incomplete {
inner
.strip_prefix(prefix)
.unwrap_or(&inner)
inner.strip_prefix(prefix).unwrap_or(&inner)
} else {
inner
.strip_prefix(prefix)
@@ -198,14 +225,16 @@ impl Highlighter {
/// Extracts the highlighted output and resets the highlighter state
///
/// Clears the input buffer, style stack, and returns the generated output
/// containing ANSI escape codes. The highlighter is ready for reuse after this.
/// containing ANSI escape codes. The highlighter is ready for reuse after
/// this.
pub fn take(&mut self) -> String {
self.input.clear();
self.clear_styles();
std::mem::take(&mut self.output)
}
/// Checks if a command name is valid (exists in PATH, is a function, or is an alias)
/// Checks if a command name is valid (exists in PATH, is a function, or is an
/// alias)
///
/// Searches:
/// 1. Current directory if command is a path
@@ -222,9 +251,11 @@ impl Highlighter {
// this is a directory and autocd is enabled
return true;
} else {
let Ok(meta) = cmd_path.metadata() else { return false };
let Ok(meta) = cmd_path.metadata() else {
return false;
};
// this is a file that is executable by someone
return meta.permissions().mode() & 0o111 == 0
return meta.permissions().mode() & 0o111 == 0;
}
} else {
// they gave us a command name
@@ -248,6 +279,49 @@ impl Highlighter {
false
}
fn is_filename(arg: &str) -> bool {
let path = PathBuf::from(arg);
if path.exists() {
return true;
}
if let Some(parent_dir) = path.parent()
&& let Ok(entries) = parent_dir.read_dir()
{
let files = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>();
let Some(arg_filename) = PathBuf::from(arg)
.file_name()
.map(|s| s.to_string_lossy().to_string())
else {
return false;
};
for file in files {
if file.starts_with(&arg_filename) {
return true;
}
}
};
if let Ok(this_dir) = env::current_dir()
&& let Ok(entries) = this_dir.read_dir()
{
let this_dir_files = entries
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>();
for file in this_dir_files {
if file.starts_with(arg) {
return true;
}
}
};
false
}
/// Emits a reset ANSI code to the output, with deduplication
///
/// Only emits the reset if the last emitted code was not already a reset,
@@ -280,10 +354,11 @@ impl Highlighter {
/// Pops a style from the stack and restores the previous style
///
/// Used when exiting a syntax context. If there's a parent style on the stack,
/// it's re-emitted to restore the previous color. Otherwise, emits a reset.
/// This ensures colors are properly restored in nested constructs like
/// `"string with $VAR"` where the string color resumes after the variable.
/// Used when exiting a syntax context. If there's a parent style on the
/// stack, it's re-emitted to restore the previous color. Otherwise, emits a
/// reset. This ensures colors are properly restored in nested constructs
/// like `"string with $VAR"` where the string color resumes after the
/// variable.
pub fn pop_style(&mut self) {
self.style_stack.pop();
if let Some(style) = self.style_stack.last().cloned() {
@@ -302,13 +377,15 @@ impl Highlighter {
self.emit_reset();
}
/// Simple marker-to-ANSI replacement (unused in favor of stack-based highlighting)
/// Simple marker-to-ANSI replacement (unused in favor of stack-based
/// highlighting)
///
/// Performs direct string replacement of markers with ANSI codes, without
/// handling nesting or proper color restoration. Kept for reference but not
/// used in the current implementation.
pub fn trivial_replace(&mut self) {
self.input = self.input
self.input = self
.input
.replace([markers::RESET, markers::ARG], "\x1b[0m")
.replace(markers::KEYWORD, "\x1b[33m")
.replace(markers::CASE_PAT, "\x1b[34m")

View File

@@ -189,8 +189,8 @@ fn read_hist_file(path: &Path) -> ShResult<Vec<HistEntry>> {
Ok(raw.parse::<HistEntries>()?.0)
}
/// Deduplicate entries, keeping only the most recent occurrence of each command.
/// Preserves chronological order (oldest to newest).
/// Deduplicate entries, keeping only the most recent occurrence of each
/// command. Preserves chronological order (oldest to newest).
fn dedupe_entries(entries: &[HistEntry]) -> Vec<HistEntry> {
let mut seen = HashSet::new();
// Iterate backwards (newest first), keeping first occurrence of each command
@@ -207,7 +207,7 @@ fn dedupe_entries(entries: &[HistEntry]) -> Vec<HistEntry> {
pub struct History {
path: PathBuf,
pub pending: Option<String>,
pub pending: Option<(String, usize)>, // command, cursor_pos
entries: Vec<HistEntry>,
search_mask: Vec<HistEntry>,
no_matches: bool,
@@ -270,14 +270,14 @@ impl History {
self.cursor = self.search_mask.len();
}
pub fn update_pending_cmd(&mut self, command: &str) {
let cmd = command.to_string();
pub fn update_pending_cmd(&mut self, buf: (&str, usize)) {
let cmd = buf.0.to_string();
let constraint = SearchConstraint {
kind: SearchKind::Prefix,
term: cmd.clone(),
};
self.pending = Some(cmd);
self.pending = Some((cmd, buf.1));
self.constrain_entries(constraint);
}
@@ -328,12 +328,14 @@ impl History {
}
pub fn hint_entry(&self) -> Option<&HistEntry> {
if self.no_matches { return None };
if self.no_matches {
return None;
};
self.search_mask.last()
}
pub fn get_hint(&self) -> Option<String> {
if self.at_pending() && self.pending.as_ref().is_some_and(|p| !p.is_empty()) {
if self.at_pending() && self.pending.as_ref().is_some_and(|p| !p.0.is_empty()) {
let entry = self.hint_entry()?;
Some(entry.command().to_string())
} else {
@@ -342,9 +344,15 @@ impl History {
}
pub fn scroll(&mut self, offset: isize) -> Option<&HistEntry> {
self.cursor = self.cursor.saturating_add_signed(offset).clamp(0, self.search_mask.len());
self.cursor = self
.cursor
.saturating_add_signed(offset)
.clamp(0, self.search_mask.len());
log::debug!("Scrolling history by offset {offset} from cursor at index {}", self.cursor);
log::debug!(
"Scrolling history by offset {offset} from cursor at index {}",
self.cursor
);
self.search_mask.get(self.cursor)
}
@@ -378,7 +386,8 @@ impl History {
let last_file_entry = self
.entries
.iter().rfind(|ent| !ent.new)
.iter()
.rfind(|ent| !ent.new)
.map(|ent| ent.command.clone())
.unwrap_or_default();

View File

@@ -628,8 +628,9 @@ impl LineBuf {
self.next_sentence_start_from_punctuation(pos).is_some()
}
/// If position is at sentence-ending punctuation, returns the position of the next sentence start.
/// Handles closing delimiters (`)`, `]`, `"`, `'`) after punctuation.
/// If position is at sentence-ending punctuation, returns the position of the
/// next sentence start. Handles closing delimiters (`)`, `]`, `"`, `'`)
/// after punctuation.
#[allow(clippy::collapsible_if)]
pub fn next_sentence_start_from_punctuation(&self, pos: usize) -> Option<usize> {
if let Some(gr) = self.read_grapheme_at(pos) {
@@ -956,7 +957,8 @@ impl LineBuf {
let start = start.unwrap_or(0);
if count > 1
&& let Some((_, new_end)) = self.text_obj_sentence(end, count - 1, bound) {
&& let Some((_, new_end)) = self.text_obj_sentence(end, count - 1, bound)
{
end = new_end;
}
@@ -1363,7 +1365,12 @@ impl LineBuf {
}
/// Find the start of the next word forward
pub fn start_of_word_forward(&mut self, mut pos: usize, word: Word, include_last_char: bool) -> usize {
pub fn start_of_word_forward(
&mut self,
mut pos: usize,
word: Word,
include_last_char: bool,
) -> usize {
let default = self.grapheme_indices().len();
let mut indices_iter = (pos..self.cursor.max).peekable();
@@ -1390,8 +1397,7 @@ impl LineBuf {
let on_whitespace = is_whitespace(&cur_char);
if !on_whitespace {
let Some(ws_pos) =
indices_iter.find(|i| self.grapheme_at(*i).is_some_and(is_whitespace))
let Some(ws_pos) = indices_iter.find(|i| self.grapheme_at(*i).is_some_and(is_whitespace))
else {
return default;
};
@@ -1457,7 +1463,12 @@ impl LineBuf {
}
/// Find the end of the previous word backward
pub fn end_of_word_backward(&mut self, mut pos: usize, word: Word, include_last_char: bool) -> usize {
pub fn end_of_word_backward(
&mut self,
mut pos: usize,
word: Word,
include_last_char: bool,
) -> usize {
let default = self.grapheme_indices().len();
let mut indices_iter = (0..pos).rev().peekable();
@@ -1484,8 +1495,7 @@ impl LineBuf {
let on_whitespace = is_whitespace(&cur_char);
if !on_whitespace {
let Some(ws_pos) =
indices_iter.find(|i| self.grapheme_at(*i).is_some_and(is_whitespace))
let Some(ws_pos) = indices_iter.find(|i| self.grapheme_at(*i).is_some_and(is_whitespace))
else {
return default;
};
@@ -1742,11 +1752,7 @@ impl LineBuf {
};
pos = next_ws_pos;
if pos == 0 {
pos
} else {
pos + 1
}
if pos == 0 { pos } else { pos + 1 }
}
}
}
@@ -2575,7 +2581,8 @@ impl LineBuf {
}
Verb::SwapVisualAnchor => {
if let Some((start, end)) = self.select_range()
&& let Some(mut mode) = self.select_mode {
&& let Some(mut mode) = self.select_mode
{
mode.invert_anchor();
let new_cursor_pos = match mode.anchor() {
SelectAnchor::Start => start,
@@ -2731,8 +2738,10 @@ impl LineBuf {
let edit_is_merging = self.undo_stack.last().is_some_and(|edit| edit.merging);
// Merge character inserts into one edit
if edit_is_merging && cmd.verb.as_ref().is_none_or(|v| !v.1.is_char_insert())
&& let Some(edit) = self.undo_stack.last_mut() {
if edit_is_merging
&& cmd.verb.as_ref().is_none_or(|v| !v.1.is_char_insert())
&& let Some(edit) = self.undo_stack.last_mut()
{
edit.stop_merge();
}
@@ -2821,8 +2830,7 @@ impl LineBuf {
self.saved_col = None;
}
if is_char_insert
&& let Some(edit) = self.undo_stack.last_mut() {
if is_char_insert && let Some(edit) = self.undo_stack.last_mut() {
edit.start_merge();
}
@@ -2833,7 +2841,11 @@ impl LineBuf {
}
pub fn get_hint_text(&self) -> String {
self.hint.clone().map(|h| h.styled(Style::BrightBlack)).unwrap_or_default()
self
.hint
.clone()
.map(|h| h.styled(Style::BrightBlack))
.unwrap_or_default()
}
}

View File

@@ -2,16 +2,22 @@ use history::History;
use keys::{KeyCode, KeyEvent, ModKeys};
use linebuf::{LineBuf, SelectAnchor, SelectMode};
use nix::libc::STDOUT_FILENO;
use term::{get_win_size, KeyReader, Layout, LineWriter, PollReader, TermWriter};
use vicmd::{CmdFlags, Motion, MotionCmd, RegisterName, To, Verb, VerbCmd, ViCmd};
use term::{KeyReader, Layout, LineWriter, PollReader, TermWriter, get_win_size};
use vicmd::{CmdFlags, Motion, MotionCmd, RegisterName, Verb, VerbCmd, ViCmd};
use vimode::{CmdReplay, ModeReport, ViInsert, ViMode, ViNormal, ViReplace, ViVisual};
use crate::{libsh::{
error::{ShErrKind, ShResult},
term::{Style, Styled},
}, parse::lex::{self, LexFlags, Tk, TkFlags, TkRule}, prompt::readline::{complete::{CompResult, Completer}, highlight::Highlighter}};
use crate::prelude::*;
use crate::{
libsh::{
error::ShResult,
term::{Style, Styled},
},
parse::lex::{self, LexFlags, Tk, TkFlags, TkRule},
prompt::readline::{complete::Completer, highlight::Highlighter},
};
pub mod complete;
pub mod highlight;
pub mod history;
pub mod keys;
pub mod layout;
@@ -20,8 +26,6 @@ pub mod register;
pub mod term;
pub mod vicmd;
pub mod vimode;
pub mod highlight;
pub mod complete;
pub mod markers {
use super::Marker;
@@ -65,28 +69,12 @@ pub mod markers {
STRING_DQ_END,
STRING_SQ_END,
SUBSH_END,
RESET
RESET,
];
pub const TOKEN_LEVEL: [Marker; 10] = [
SUBSH,
COMMAND,
BUILTIN,
ARG,
KEYWORD,
OPERATOR,
REDIRECT,
CMD_SEP,
CASE_PAT,
ASSIGNMENT,
];
pub const SUB_TOKEN: [Marker;6] = [
VAR_SUB,
CMD_SUB,
PROC_SUB,
STRING_DQ,
STRING_SQ,
GLOB,
SUBSH, COMMAND, BUILTIN, ARG, KEYWORD, OPERATOR, REDIRECT, CMD_SEP, CASE_PAT, ASSIGNMENT,
];
pub const SUB_TOKEN: [Marker; 6] = [VAR_SUB, CMD_SUB, PROC_SUB, STRING_DQ, STRING_SQ, GLOB];
pub fn is_marker(c: Marker) -> bool {
TOKEN_LEVEL.contains(&c) || SUB_TOKEN.contains(&c) || END_MARKERS.contains(&c)
@@ -145,14 +133,14 @@ impl FernVi {
pub fn with_initial(mut self, initial: &str) -> Self {
self.editor = LineBuf::new().with_initial(initial, 0);
self.history.update_pending_cmd(self.editor.as_str());
self
.history
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
self
}
/// Feed raw bytes from stdin into the reader's buffer
pub fn feed_bytes(&mut self, bytes: &[u8]) {
let test_input = "echo \"hello $USER\" | grep $(whoami)";
let annotated = annotate_input(test_input);
self.reader.feed_bytes(bytes);
}
@@ -185,13 +173,14 @@ impl FernVi {
// Process all available keys
while let Some(key) = self.reader.read_key()? {
if self.should_accept_hint(&key) {
self.editor.accept_hint();
if !self.history.at_pending() {
self.history.reset_to_pending();
}
self.history.update_pending_cmd(self.editor.as_str());
self
.history
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
self.needs_redraw = true;
continue;
}
@@ -205,9 +194,14 @@ impl FernVi {
let cursor_pos = self.editor.cursor_byte_pos();
match self.completer.complete(line, cursor_pos, direction)? {
Some(mut line) => {
Some(line) => {
let span_start = self.completer.token_span.0;
let new_cursor = span_start + self.completer.selected_candidate().map(|c| c.len()).unwrap_or_default();
let new_cursor = span_start
+ self
.completer
.selected_candidate()
.map(|c| c.len())
.unwrap_or_default();
self.editor.set_buffer(line);
self.editor.cursor.set(new_cursor);
@@ -215,16 +209,18 @@ impl FernVi {
if !self.history.at_pending() {
self.history.reset_to_pending();
}
self.history.update_pending_cmd(self.editor.as_str());
self
.history
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
let hint = self.history.get_hint();
self.editor.set_hint(hint);
}
None => {
match crate::state::read_shopts(|s| s.core.bell_style) {
crate::shopt::FernBellStyle::Audible => { self.writer.flush_write("\x07")?; }
None => 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 => {}
}
}
},
}
self.needs_redraw = true;
@@ -252,8 +248,7 @@ impl FernVi {
self.writer.flush_write("\n")?;
let buf = self.editor.take_buf();
// Save command to history if auto_hist is enabled
if crate::state::read_shopts(|s| s.core.auto_hist)
&& !buf.is_empty() {
if crate::state::read_shopts(|s| s.core.auto_hist) && !buf.is_empty() {
self.history.push(buf.clone());
if let Err(e) = self.history.save() {
eprintln!("Failed to save history: {e}");
@@ -278,7 +273,9 @@ impl FernVi {
let after = self.editor.as_str();
if before != after {
self.history.update_pending_cmd(self.editor.as_str());
self
.history
.update_pending_cmd((self.editor.as_str(), self.editor.cursor.get()));
}
let hint = self.history.get_hint();
@@ -311,12 +308,8 @@ impl FernVi {
let count = &cmd.motion().unwrap().0;
let motion = &cmd.motion().unwrap().1;
let count = match motion {
Motion::LineUpCharwise => {
-(*count as isize)
}
Motion::LineDownCharwise => {
*count as isize
}
Motion::LineUpCharwise => -(*count as isize),
Motion::LineDownCharwise => *count as isize,
_ => unreachable!(),
};
let entry = self.history.scroll(count);
@@ -326,12 +319,13 @@ impl FernVi {
let pending = self.editor.take_buf();
self.editor.set_buffer(entry.command().to_string());
if self.history.pending.is_none() {
self.history.pending = Some(pending);
self.history.pending = Some((pending, self.editor.cursor.get()));
}
self.editor.set_hint(None);
} else if let Some(pending) = self.history.pending.take() {
log::info!("Setting buffer to pending command: {}", &pending);
self.editor.set_buffer(pending);
log::info!("Setting buffer to pending command: {}", &pending.0);
self.editor.set_buffer(pending.0);
self.editor.cursor.set(pending.1);
self.editor.set_hint(None);
}
}
@@ -385,9 +379,7 @@ impl FernVi {
self.writer.clear_rows(layout)?;
}
self
.writer
.redraw(&self.prompt, &line, &new_layout)?;
self.writer.redraw(&self.prompt, &line, &new_layout)?;
self.writer.flush_write(&self.mode.cursor_style())?;
@@ -553,8 +545,8 @@ impl FernVi {
/// Annotates shell input with invisible Unicode markers for syntax highlighting
///
/// Takes raw shell input and inserts non-character markers (U+FDD0-U+FDEF range)
/// around syntax elements. These markers indicate:
/// Takes raw shell input and inserts non-character markers (U+FDD0-U+FDEF
/// range) around syntax elements. These markers indicate:
/// - Token-level context (commands, arguments, operators, keywords)
/// - Sub-token constructs (strings, variables, command substitutions, globs)
///
@@ -597,9 +589,7 @@ pub fn annotate_input_recursive(input: &str) -> String {
while let Some((pos, ch)) = chars.next() {
match ch {
markers::CMD_SUB |
markers::SUBSH |
markers::PROC_SUB => {
markers::CMD_SUB | markers::SUBSH | markers::PROC_SUB => {
let mut body = String::new();
let span_start = pos + ch.len_utf8();
let mut span_end = span_start;
@@ -607,7 +597,7 @@ pub fn annotate_input_recursive(input: &str) -> String {
markers::CMD_SUB => markers::CMD_SUB_END,
markers::SUBSH => markers::SUBSH_END,
markers::PROC_SUB => markers::PROC_SUB_END,
_ => unreachable!()
_ => unreachable!(),
};
while let Some((sub_pos, sub_ch)) = chars.next() {
match sub_ch {
@@ -619,19 +609,17 @@ pub fn annotate_input_recursive(input: &str) -> String {
}
}
let prefix = match ch {
markers::PROC_SUB => {
match chars.peek().map(|(_, c)| *c) {
markers::PROC_SUB => match chars.peek().map(|(_, c)| *c) {
Some('>') => ">(",
Some('<') => "<(",
_ => {
log::error!("Unexpected character after PROC_SUB marker: expected '>' or '<'");
"<("
}
}
}
},
markers::CMD_SUB => "$(",
markers::SUBSH => "(",
_ => unreachable!()
_ => unreachable!(),
};
body = body.trim_start_matches(prefix).to_string();
@@ -667,8 +655,8 @@ pub fn get_insertions(input: &str) -> Vec<(usize, Marker)> {
/// Maps token class to its corresponding marker character
///
/// Returns the appropriate Unicode marker for token-level syntax elements.
/// Token-level markers are derived directly from the lexer's token classification
/// and represent complete tokens (operators, separators, etc.).
/// Token-level markers are derived directly from the lexer's token
/// classification and represent complete tokens (operators, separators, etc.).
///
/// Returns `None` for:
/// - String tokens (which need sub-token scanning for variables, quotes, etc.)
@@ -676,22 +664,16 @@ pub fn get_insertions(input: &str) -> Vec<(usize, Marker)> {
/// - Unimplemented features (comments, brace groups)
pub fn marker_for(class: &TkRule) -> Option<Marker> {
match class {
TkRule::Pipe |
TkRule::ErrPipe |
TkRule::And |
TkRule::Or |
TkRule::Bg => Some(markers::OPERATOR),
TkRule::Pipe | TkRule::ErrPipe | TkRule::And | TkRule::Or | TkRule::Bg => {
Some(markers::OPERATOR)
}
TkRule::Sep => Some(markers::CMD_SEP),
TkRule::Redir => Some(markers::REDIRECT),
TkRule::CasePattern => Some(markers::CASE_PAT),
TkRule::BraceGrpStart => todo!(),
TkRule::BraceGrpEnd => todo!(),
TkRule::Comment => todo!(),
TkRule::Expanded { exp: _ } |
TkRule::EOI |
TkRule::SOI |
TkRule::Null |
TkRule::Str => None,
TkRule::Expanded { exp: _ } | TkRule::EOI | TkRule::SOI | TkRule::Null | TkRule::Str => None,
}
}
@@ -702,23 +684,22 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
// - END markers last (inserted last, ends up leftmost)
// Result: [END][TOGGLE][RESET]
let sort_insertions = |insertions: &mut Vec<(usize, Marker)>| {
insertions.sort_by(|a, b| {
match b.0.cmp(&a.0) {
insertions.sort_by(|a, b| match b.0.cmp(&a.0) {
std::cmp::Ordering::Equal => {
let priority = |m: Marker| -> u8 {
match m {
markers::RESET => 0,
markers::VAR_SUB |
markers::VAR_SUB_END |
markers::CMD_SUB |
markers::CMD_SUB_END |
markers::PROC_SUB |
markers::PROC_SUB_END |
markers::STRING_DQ |
markers::STRING_DQ_END |
markers::STRING_SQ |
markers::STRING_SQ_END |
markers::SUBSH_END => 2,
markers::VAR_SUB
| markers::VAR_SUB_END
| markers::CMD_SUB
| markers::CMD_SUB_END
| markers::PROC_SUB
| markers::PROC_SUB_END
| markers::STRING_DQ
| markers::STRING_DQ_END
| markers::STRING_SQ
| markers::STRING_SQ_END
| markers::SUBSH_END => 2,
markers::ARG => 3,
_ => 1,
}
@@ -726,7 +707,6 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
priority(a.1).cmp(&priority(b.1))
}
other => other,
}
});
};
@@ -738,17 +718,17 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
let priority = |m: Marker| -> u8 {
match m {
markers::RESET => 0,
markers::VAR_SUB |
markers::VAR_SUB_END |
markers::CMD_SUB |
markers::CMD_SUB_END |
markers::PROC_SUB |
markers::PROC_SUB_END |
markers::STRING_DQ |
markers::STRING_DQ_END |
markers::STRING_SQ |
markers::STRING_SQ_END |
markers::SUBSH_END => 2,
markers::VAR_SUB
| markers::VAR_SUB_END
| markers::CMD_SUB
| markers::CMD_SUB_END
| markers::PROC_SUB
| markers::PROC_SUB_END
| markers::STRING_DQ
| markers::STRING_DQ_END
| markers::STRING_SQ
| markers::STRING_SQ_END
| markers::SUBSH_END => 2,
markers::ARG => 3, // Lowest priority - processed first, overridden by sub-tokens
_ => 1,
}
@@ -769,9 +749,9 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
let mut insertions: Vec<(usize, Marker)> = vec![];
if token.class != TkRule::Str
&& let Some(marker) = marker_for(&token.class) {
&& let Some(marker) = marker_for(&token.class)
{
insertions.push((token.span.end, markers::RESET));
insertions.push((token.span.start, marker));
return insertions;
@@ -784,11 +764,8 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
return insertions;
}
let token_raw = token.span.as_str();
let mut token_chars = token_raw
.char_indices()
.peekable();
let mut token_chars = token_raw.char_indices().peekable();
let span_start = token.span.start;
@@ -863,7 +840,8 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
|| *br_ch == '+'
|| *br_ch == '='
|| *br_ch == '/' // parameter expansion symbols
|| *br_ch == '?' {
|| *br_ch == '?'
{
token_chars.next();
} else if *br_ch == '}' {
token_chars.next(); // consume the closing brace
@@ -910,7 +888,8 @@ pub fn annotate_token(token: Tk) -> Vec<(usize, Marker)> {
'<' | '>' if !in_dub_qt && !in_sng_qt && cmd_sub_depth == 0 && proc_sub_depth == 0 => {
token_chars.next();
if let Some((_, proc_sub_ch)) = token_chars.peek()
&& *proc_sub_ch == '(' {
&& *proc_sub_ch == '('
{
proc_sub_depth += 1;
token_chars.next(); // consume the paren
if proc_sub_depth == 1 {

View File

@@ -17,11 +17,15 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use vte::{Parser, Perform};
use crate::{prelude::*, procio::borrow_fd, state::{read_meta, write_meta}};
use crate::{
libsh::error::{ShErr, ShErrKind, ShResult},
prompt::readline::keys::{KeyCode, ModKeys},
};
use crate::{
prelude::*,
procio::borrow_fd,
state::{read_meta, write_meta},
};
use super::{keys::KeyEvent, linebuf::LineBuf};
@@ -242,9 +246,7 @@ impl Read for TermBuffer {
let result = nix::unistd::read(self.tty, buf);
match result {
Ok(n) => Ok(n),
Err(Errno::EINTR) => {
Err(Errno::EINTR.into())
}
Err(Errno::EINTR) => Err(Errno::EINTR.into()),
Err(e) => Err(std::io::Error::from_raw_os_error(e as i32)),
}
}
@@ -281,14 +283,18 @@ impl RawModeGuard {
}
pub fn with_cooked_mode<F, R>(f: F) -> R
where F: FnOnce() -> R {
where
F: FnOnce() -> R,
{
let raw = tcgetattr(borrow_fd(STDIN_FILENO)).expect("Failed to get terminal attributes");
let mut cooked = raw.clone();
cooked.local_flags |= termios::LocalFlags::ICANON | termios::LocalFlags::ECHO;
cooked.input_flags |= termios::InputFlags::ICRNL;
tcsetattr(borrow_fd(STDIN_FILENO), termios::SetArg::TCSANOW, &cooked).expect("Failed to set cooked mode");
tcsetattr(borrow_fd(STDIN_FILENO), termios::SetArg::TCSANOW, &cooked)
.expect("Failed to set cooked mode");
let res = f();
tcsetattr(borrow_fd(STDIN_FILENO), termios::SetArg::TCSANOW, &raw).expect("Failed to restore raw mode");
tcsetattr(borrow_fd(STDIN_FILENO), termios::SetArg::TCSANOW, &raw)
.expect("Failed to restore raw mode");
res
}
}
@@ -333,9 +339,15 @@ impl KeyCollector {
// CSI modifiers: param = 1 + (shift) + (alt*2) + (ctrl*4) + (meta*8)
let bits = param.saturating_sub(1);
let mut mods = ModKeys::empty();
if bits & 1 != 0 { mods |= ModKeys::SHIFT; }
if bits & 2 != 0 { mods |= ModKeys::ALT; }
if bits & 4 != 0 { mods |= ModKeys::CTRL; }
if bits & 1 != 0 {
mods |= ModKeys::SHIFT;
}
if bits & 2 != 0 {
mods |= ModKeys::ALT;
}
if bits & 4 != 0 {
mods |= ModKeys::CTRL;
}
mods
}
}
@@ -374,46 +386,72 @@ impl Perform for KeyCollector {
self.push(event);
}
fn csi_dispatch(&mut self, params: &vte::Params, intermediates: &[u8], _ignore: bool, action: char) {
let params: Vec<u16> = params.iter()
fn csi_dispatch(
&mut self,
params: &vte::Params,
intermediates: &[u8],
_ignore: bool,
action: char,
) {
let params: Vec<u16> = params
.iter()
.map(|p| p.first().copied().unwrap_or(0))
.collect();
let event = match (intermediates, action) {
// Arrow keys: CSI A/B/C/D or CSI 1;mod A/B/C/D
([], 'A') => {
let mods = params.get(1).map(|&m| Self::parse_modifiers(m)).unwrap_or(ModKeys::empty());
let mods = params
.get(1)
.map(|&m| Self::parse_modifiers(m))
.unwrap_or(ModKeys::empty());
KeyEvent(KeyCode::Up, mods)
}
([], 'B') => {
let mods = params.get(1).map(|&m| Self::parse_modifiers(m)).unwrap_or(ModKeys::empty());
let mods = params
.get(1)
.map(|&m| Self::parse_modifiers(m))
.unwrap_or(ModKeys::empty());
KeyEvent(KeyCode::Down, mods)
}
([], 'C') => {
let mods = params.get(1).map(|&m| Self::parse_modifiers(m)).unwrap_or(ModKeys::empty());
let mods = params
.get(1)
.map(|&m| Self::parse_modifiers(m))
.unwrap_or(ModKeys::empty());
KeyEvent(KeyCode::Right, mods)
}
([], 'D') => {
let mods = params.get(1).map(|&m| Self::parse_modifiers(m)).unwrap_or(ModKeys::empty());
let mods = params
.get(1)
.map(|&m| Self::parse_modifiers(m))
.unwrap_or(ModKeys::empty());
KeyEvent(KeyCode::Left, mods)
}
// Home/End: CSI H/F or CSI 1;mod H/F
([], 'H') => {
let mods = params.get(1).map(|&m| Self::parse_modifiers(m)).unwrap_or(ModKeys::empty());
let mods = params
.get(1)
.map(|&m| Self::parse_modifiers(m))
.unwrap_or(ModKeys::empty());
KeyEvent(KeyCode::Home, mods)
}
([], 'F') => {
let mods = params.get(1).map(|&m| Self::parse_modifiers(m)).unwrap_or(ModKeys::empty());
let mods = params
.get(1)
.map(|&m| Self::parse_modifiers(m))
.unwrap_or(ModKeys::empty());
KeyEvent(KeyCode::End, mods)
}
// Shift+Tab: CSI Z
([], 'Z') => {
KeyEvent(KeyCode::Tab, ModKeys::SHIFT)
}
([], 'Z') => KeyEvent(KeyCode::Tab, ModKeys::SHIFT),
// Special keys with tilde: CSI num ~ or CSI num;mod ~
([], '~') => {
let key_num = params.first().copied().unwrap_or(0);
let mods = params.get(1).map(|&m| Self::parse_modifiers(m)).unwrap_or(ModKeys::empty());
let mods = params
.get(1)
.map(|&m| Self::parse_modifiers(m))
.unwrap_or(ModKeys::empty());
let key = match key_num {
1 | 7 => KeyCode::Home,
2 => KeyCode::Insert,
@@ -473,7 +511,9 @@ impl PollReader {
pub fn feed_bytes(&mut self, bytes: &[u8]) {
if bytes == [b'\x1b'] {
// Single escape byte - user pressed ESC key
self.collector.push(KeyEvent(KeyCode::Esc, ModKeys::empty()));
self
.collector
.push(KeyEvent(KeyCode::Esc, ModKeys::empty()));
return;
}

View File

@@ -161,8 +161,10 @@ impl ViCmd {
}
/// If a ViCmd has a linewise motion, but no verb, we change it to charwise
pub fn alter_line_motion_if_no_verb(&mut self) {
if self.is_line_motion() && self.verb.is_none()
&& let Some(motion) = self.motion.as_mut() {
if self.is_line_motion()
&& self.verb.is_none()
&& let Some(motion) = self.motion.as_mut()
{
match motion.1 {
Motion::LineUp => motion.1 = Motion::LineUpCharwise,
Motion::LineDown => motion.1 = Motion::LineDownCharwise,

View File

@@ -315,7 +315,7 @@ impl ViNormal {
return match obj {
TextObj::Sentence(_) | TextObj::Paragraph(_) => CmdState::Complete,
_ => CmdState::Invalid,
}
};
}
Some(_) => return CmdState::Complete,
None => return CmdState::Pending,
@@ -410,7 +410,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'~' => {
chars_clone.next();
@@ -445,7 +445,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'x' => {
return Some(ViCmd {
@@ -454,7 +454,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::ForwardChar)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'X' => {
return Some(ViCmd {
@@ -463,7 +463,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::BackwardChar)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
's' => {
return Some(ViCmd {
@@ -472,7 +472,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::ForwardChar)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'S' => {
return Some(ViCmd {
@@ -481,7 +481,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'p' => {
chars = chars_clone;
@@ -516,7 +516,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'~' => {
return Some(ViCmd {
@@ -525,7 +525,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'u' => {
return Some(ViCmd {
@@ -534,7 +534,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'v' => {
return Some(ViCmd {
@@ -543,7 +543,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'V' => {
return Some(ViCmd {
@@ -552,7 +552,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'o' => {
return Some(ViCmd {
@@ -561,7 +561,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'O' => {
return Some(ViCmd {
@@ -570,7 +570,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'a' => {
return Some(ViCmd {
@@ -579,7 +579,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::ForwardChar)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'A' => {
return Some(ViCmd {
@@ -588,7 +588,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::EndOfLine)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'i' => {
return Some(ViCmd {
@@ -597,7 +597,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'I' => {
return Some(ViCmd {
@@ -606,7 +606,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::BeginningOfFirstWord)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'J' => {
return Some(ViCmd {
@@ -615,7 +615,7 @@ impl ViNormal {
motion: None,
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'y' => {
chars = chars_clone;
@@ -636,7 +636,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::EndOfLine)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'D' => {
return Some(ViCmd {
@@ -645,7 +645,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::EndOfLine)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'C' => {
return Some(ViCmd {
@@ -654,7 +654,7 @@ impl ViNormal {
motion: Some(MotionCmd(1, Motion::EndOfLine)),
raw_seq: self.take_cmd(),
flags: self.flags(),
})
});
}
'=' => {
chars = chars_clone;
@@ -684,7 +684,7 @@ impl ViNormal {
| ('~', Some(VerbCmd(_, Verb::ToggleCaseRange)))
| ('>', Some(VerbCmd(_, Verb::Indent)))
| ('<', Some(VerbCmd(_, Verb::Dedent))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine))
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine));
}
('W', Some(VerbCmd(_, Verb::Change))) => {
// Same with 'W'
@@ -994,8 +994,7 @@ impl ViNormal {
}
};
if chars.peek().is_some() {
}
if chars.peek().is_some() {}
let verb_ref = verb.as_ref().map(|v| &v.1);
let motion_ref = motion.as_ref().map(|m| &m.1);
@@ -1185,7 +1184,7 @@ impl ViVisual {
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'?' => {
return Some(ViCmd {
@@ -1194,7 +1193,7 @@ impl ViVisual {
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
_ => break 'verb_parse None,
}
@@ -1209,7 +1208,7 @@ impl ViVisual {
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'x' => {
chars = chars_clone;
@@ -1222,7 +1221,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'Y' => {
return Some(ViCmd {
@@ -1231,7 +1230,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'D' => {
return Some(ViCmd {
@@ -1240,7 +1239,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'R' | 'C' => {
return Some(ViCmd {
@@ -1249,7 +1248,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'>' => {
return Some(ViCmd {
@@ -1258,7 +1257,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'<' => {
return Some(ViCmd {
@@ -1267,7 +1266,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'=' => {
return Some(ViCmd {
@@ -1276,7 +1275,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::WholeLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'p' | 'P' => {
chars = chars_clone;
@@ -1299,7 +1298,7 @@ impl ViVisual {
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'u' => {
return Some(ViCmd {
@@ -1308,7 +1307,7 @@ impl ViVisual {
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'U' => {
return Some(ViCmd {
@@ -1317,7 +1316,7 @@ impl ViVisual {
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'O' | 'o' => {
return Some(ViCmd {
@@ -1326,7 +1325,7 @@ impl ViVisual {
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'A' => {
return Some(ViCmd {
@@ -1335,7 +1334,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::ForwardChar)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'I' => {
return Some(ViCmd {
@@ -1344,7 +1343,7 @@ impl ViVisual {
motion: Some(MotionCmd(1, Motion::BeginningOfLine)),
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'J' => {
return Some(ViCmd {
@@ -1353,7 +1352,7 @@ impl ViVisual {
motion: None,
raw_seq: self.take_cmd(),
flags: CmdFlags::empty(),
})
});
}
'y' => {
chars = chars_clone;
@@ -1395,7 +1394,7 @@ impl ViVisual {
| ('=', Some(VerbCmd(_, Verb::Equalize)))
| ('>', Some(VerbCmd(_, Verb::Indent)))
| ('<', Some(VerbCmd(_, Verb::Dedent))) => {
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine))
break 'motion_parse Some(MotionCmd(count, Motion::WholeLine));
}
_ => {}
}
@@ -1652,8 +1651,7 @@ impl ViVisual {
}
};
if chars.peek().is_some() {
}
if chars.peek().is_some() {}
let verb_ref = verb.as_ref().map(|v| &v.1);
let motion_ref = motion.as_ref().map(|m| &m.1);

View File

@@ -117,7 +117,7 @@ impl ShOpts {
Note::new("'shopt' takes arguments separated by periods to denote namespaces")
.with_sub_notes(vec!["Example: 'shopt core.autocd=true'"]),
),
)
);
}
}
Ok(())
@@ -263,7 +263,7 @@ impl ShOptCore {
"max_recurse_depth",
]),
),
)
);
}
}
Ok(())
@@ -445,7 +445,9 @@ impl ShOptPrompt {
ShErrKind::SyntaxErr,
format!("shopt: Unexpected 'prompt' option '{opt}'"),
)
.with_note(Note::new("options can be accessed like 'prompt.option_name'"))
.with_note(Note::new(
"options can be accessed like 'prompt.option_name'",
))
.with_note(
Note::new("'prompt' contains the following options").with_sub_notes(vec![
"trunc_prompt_path",
@@ -456,7 +458,7 @@ impl ShOptPrompt {
"custom",
]),
),
)
);
}
}
Ok(())

View File

@@ -3,7 +3,12 @@ use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering};
use nix::sys::signal::{SaFlags, SigAction, sigaction};
use crate::{
builtin::trap::TrapTarget, jobs::{JobCmdFlags, JobID, take_term}, libsh::error::{ShErr, ShErrKind, ShResult}, parse::execute::exec_input, prelude::*, state::{read_jobs, read_logic, write_jobs, write_meta}
builtin::trap::TrapTarget,
jobs::{JobCmdFlags, JobID, take_term},
libsh::error::{ShErr, ShErrKind, ShResult},
parse::execute::exec_input,
prelude::*,
state::{read_jobs, read_logic, write_jobs, write_meta},
};
static SIGNALS: AtomicU64 = AtomicU64::new(0);
@@ -98,7 +103,6 @@ pub fn sig_setup() {
let action = SigAction::new(SigHandler::Handler(handle_signal), flags, SigSet::empty());
let ignore = SigAction::new(SigHandler::SigIgn, flags, SigSet::empty());
unsafe {
@@ -269,8 +273,8 @@ pub fn child_exited(pid: Pid, status: WtStat) -> ShResult<()> {
} else {
None
}
})
&& is_finished {
}) && is_finished
{
if is_fg {
take_term()?;
} else {

View File

@@ -1,14 +1,25 @@
use std::{
cell::RefCell, collections::{HashMap, VecDeque}, fmt::Display, ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Deref}, str::FromStr, time::Duration
cell::RefCell,
collections::{HashMap, VecDeque},
fmt::Display,
ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Deref},
str::FromStr,
time::Duration,
};
use nix::unistd::{gethostname, getppid, User};
use nix::unistd::{User, gethostname, getppid};
use crate::{
builtin::trap::TrapTarget, exec_input, jobs::JobTab, libsh::{
builtin::trap::TrapTarget,
exec_input,
jobs::JobTab,
libsh::{
error::{ShErr, ShErrKind, ShResult},
utils::VecDequeExt,
}, parse::{ConjunctNode, NdRule, Node, ParsedSrc}, prelude::*, shopt::ShOpts
},
parse::{ConjunctNode, NdRule, Node, ParsedSrc},
prelude::*,
shopt::ShOpts,
};
pub struct Fern {
@@ -49,7 +60,7 @@ pub enum ShellParam {
Pos(usize),
AllArgs,
AllArgsStr,
ArgCount
ArgCount,
}
impl ShellParam {
@@ -116,8 +127,12 @@ 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);
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<Vec<String>>) {
@@ -208,7 +223,9 @@ impl ScopeStack {
std::env::var(var_name).unwrap_or_default()
}
pub fn get_param(&self, param: ShellParam) -> String {
if param.is_global() && let Some(val) = self.global_params.get(&param.to_string()) {
if param.is_global()
&& let Some(val) = self.global_params.get(&param.to_string())
{
return val.clone();
}
for scope in self.scopes.iter().rev() {
@@ -224,16 +241,12 @@ impl ScopeStack {
/// Therefore, these are global state and we use the global scope
pub fn set_param(&mut self, param: ShellParam, val: &str) {
match param {
ShellParam::ShPid |
ShellParam::Status |
ShellParam::LastJob |
ShellParam::ShellName => {
self.global_params.insert(param.to_string(), val.to_string());
ShellParam::ShPid | ShellParam::Status | ShellParam::LastJob | ShellParam::ShellName => {
self
.global_params
.insert(param.to_string(), val.to_string());
}
ShellParam::Pos(_) |
ShellParam::AllArgs |
ShellParam::AllArgsStr |
ShellParam::ArgCount => {
ShellParam::Pos(_) | ShellParam::AllArgs | ShellParam::AllArgsStr | ShellParam::ArgCount => {
if let Some(scope) = self.scopes.first_mut() {
scope.set_param(param, val);
}
@@ -446,10 +459,7 @@ pub struct Var {
impl Var {
pub fn new(kind: VarKind, flags: VarFlags) -> Self {
Self {
flags,
kind
}
Self { flags, kind }
}
pub fn kind(&self) -> &VarKind {
&self.kind
@@ -575,7 +585,10 @@ impl VarTab {
self.bpush_arg(env::current_exe().unwrap().to_str().unwrap().to_string());
}
fn update_arg_params(&mut self) {
self.set_param(ShellParam::AllArgs, &self.sh_argv.clone().to_vec()[1..].join(" "));
self.set_param(
ShellParam::AllArgs,
&self.sh_argv.clone().to_vec()[1..].join(" "),
);
self.set_param(ShellParam::ArgCount, &(self.sh_argv.len() - 1).to_string());
}
/// Push an arg to the front of the arg deque
@@ -664,20 +677,16 @@ impl VarTab {
}
pub fn get_param(&self, param: ShellParam) -> String {
match param {
ShellParam::Pos(n) => {
self
ShellParam::Pos(n) => self
.sh_argv()
.get(n)
.map(|s| s.to_string())
.unwrap_or_default()
}
ShellParam::Status => {
self
.unwrap_or_default(),
ShellParam::Status => self
.params
.get(&ShellParam::Status)
.map(|s| s.to_string())
.unwrap_or("0".into())
}
.unwrap_or("0".into()),
_ => self
.params
.get(&param)
@@ -695,7 +704,7 @@ pub struct MetaTab {
runtime_stop: Option<Instant>,
// pending system messages
system_msg: Vec<String>
system_msg: Vec<String>,
}
impl MetaTab {
@@ -788,7 +797,9 @@ pub fn get_shopt(path: &str) -> String {
}
pub fn get_status() -> i32 {
read_vars(|v| v.get_param(ShellParam::Status)).parse::<i32>().unwrap()
read_vars(|v| v.get_param(ShellParam::Status))
.parse::<i32>()
.unwrap()
}
#[track_caller]
pub fn set_status(code: i32) {

View File

@@ -6,7 +6,7 @@ use tempfile::TempDir;
use crate::prompt::readline::complete::Completer;
use crate::prompt::readline::markers;
use crate::state::{write_logic, write_vars, VarFlags};
use crate::state::{VarFlags, write_logic, write_vars};
use super::*;
@@ -77,8 +77,9 @@ fn complete_command_builtin() {
assert!(completer.candidates.iter().any(|c| c == "export"));
}
// NOTE: Disabled - ShFunc constructor requires parsed AST which is complex to set up in tests
// TODO: Re-enable once we have a helper to create test functions
// NOTE: Disabled - ShFunc constructor requires parsed AST which is complex to
// set up in tests TODO: Re-enable once we have a helper to create test
// functions
/*
#[test]
fn complete_command_function() {
@@ -191,7 +192,12 @@ fn complete_filename_with_slash() {
// Should complete files in subdir/
if result.is_some() {
assert!(completer.candidates.iter().any(|c| c.contains("nested.txt")));
assert!(
completer
.candidates
.iter()
.any(|c| c.contains("nested.txt"))
);
}
}
@@ -322,11 +328,17 @@ fn context_detection_command_position() {
// At the beginning - command context
let (ctx, _) = completer.get_completion_context("ech", 3);
assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context at start");
assert!(
ctx.last() == Some(&markers::COMMAND),
"Should be in command context at start"
);
// After whitespace - still command if no command yet
let (ctx, _) = completer.get_completion_context(" ech", 5);
assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context after whitespace");
assert!(
ctx.last() == Some(&markers::COMMAND),
"Should be in command context after whitespace"
);
}
#[test]
@@ -335,10 +347,16 @@ fn context_detection_argument_position() {
// After a complete command - argument context
let (ctx, _) = completer.get_completion_context("echo hello", 10);
assert!(ctx.last() != Some(&markers::COMMAND), "Should be in argument context after command");
assert!(
ctx.last() != Some(&markers::COMMAND),
"Should be in argument context after command"
);
let (ctx, _) = completer.get_completion_context("ls -la /tmp", 11);
assert!(ctx.last() != Some(&markers::COMMAND), "Should be in argument context");
assert!(
ctx.last() != Some(&markers::COMMAND),
"Should be in argument context"
);
}
#[test]
@@ -347,11 +365,17 @@ fn context_detection_nested_command_sub() {
// Inside $() - should be command context
let (ctx, _) = completer.get_completion_context("echo \"$(ech", 11);
assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context inside $()");
assert!(
ctx.last() == Some(&markers::COMMAND),
"Should be in command context inside $()"
);
// After command in $() - argument context
let (ctx, _) = completer.get_completion_context("echo \"$(echo hell", 17);
assert!(ctx.last() != Some(&markers::COMMAND), "Should be in argument context inside $()");
assert!(
ctx.last() != Some(&markers::COMMAND),
"Should be in argument context inside $()"
);
}
#[test]
@@ -360,7 +384,10 @@ fn context_detection_pipe() {
// After pipe - command context
let (ctx, _) = completer.get_completion_context("ls | gre", 8);
assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context after pipe");
assert!(
ctx.last() == Some(&markers::COMMAND),
"Should be in command context after pipe"
);
}
#[test]
@@ -369,11 +396,17 @@ fn context_detection_command_sep() {
// After semicolon - command context
let (ctx, _) = completer.get_completion_context("echo foo; l", 11);
assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context after semicolon");
assert!(
ctx.last() == Some(&markers::COMMAND),
"Should be in command context after semicolon"
);
// After && - command context
let (ctx, _) = completer.get_completion_context("true && l", 9);
assert!(ctx.last() == Some(&markers::COMMAND), "Should be in command context after &&");
assert!(
ctx.last() == Some(&markers::COMMAND),
"Should be in command context after &&"
);
}
#[test]
@@ -382,11 +415,19 @@ fn context_detection_variable_substitution() {
// $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");
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");
assert_eq!(
ctx.last(),
Some(&markers::VAR_SUB),
"Should be in var_sub context for bare $HOM"
);
}
#[test]
@@ -395,7 +436,11 @@ fn context_detection_variable_in_double_quotes() {
// $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");
assert_eq!(
ctx.last(),
Some(&markers::VAR_SUB),
"Should be in var_sub context inside double quotes"
);
}
#[test]
@@ -404,7 +449,11 @@ fn context_detection_stack_base_is_null() {
// 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");
assert_eq!(
ctx,
vec![markers::NULL],
"Empty input should only have NULL marker"
);
}
#[test]
@@ -431,11 +480,19 @@ fn context_detection_priority_ordering() {
// 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");
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");
assert_eq!(
ctx.last(),
Some(&markers::VAR_SUB),
"VAR_SUB should win over COMMAND"
);
}
// ============================================================================
@@ -647,7 +704,12 @@ fn complete_special_characters_in_filename() {
if result.is_some() {
// Should handle special chars in filenames
assert!(completer.candidates.iter().any(|c| c.contains("file-with-dash") || c.contains("file_with_underscore")));
assert!(
completer
.candidates
.iter()
.any(|c| c.contains("file-with-dash") || c.contains("file_with_underscore"))
);
}
}

View File

@@ -1,6 +1,6 @@
use std::collections::HashSet;
use crate::expand::{perform_param_expansion, DUB_QUOTE, VAR_SUB};
use crate::expand::{DUB_QUOTE, VAR_SUB, perform_param_expansion};
use crate::state::VarFlags;
use super::*;
@@ -295,7 +295,10 @@ fn param_expansion_replacesuffix() {
fn dquote_escape_dollar() {
// "\$foo" should strip backslash, produce literal $foo (no expansion)
let result = unescape_str(r#""\$foo""#);
assert!(!result.contains(VAR_SUB), "Escaped $ should not become VAR_SUB");
assert!(
!result.contains(VAR_SUB),
"Escaped $ should not become VAR_SUB"
);
assert!(result.contains('$'), "Literal $ should be preserved");
assert!(!result.contains('\\'), "Backslash should be stripped");
}
@@ -304,47 +307,54 @@ fn dquote_escape_dollar() {
fn dquote_escape_backslash() {
// "\\" in double quotes should produce a single backslash
let result = unescape_str(r#""\\""#);
let inner: String = result.chars()
.filter(|&c| c != DUB_QUOTE)
.collect();
assert_eq!(inner, "\\", "Double backslash should produce single backslash");
let inner: String = result.chars().filter(|&c| c != DUB_QUOTE).collect();
assert_eq!(
inner, "\\",
"Double backslash should produce single backslash"
);
}
#[test]
fn dquote_escape_quote() {
// "\"" should produce a literal double quote
let result = unescape_str(r#""\"""#);
let inner: String = result.chars()
.filter(|&c| c != DUB_QUOTE)
.collect();
assert!(inner.contains('"'), "Escaped quote should produce literal quote");
let inner: String = result.chars().filter(|&c| c != DUB_QUOTE).collect();
assert!(
inner.contains('"'),
"Escaped quote should produce literal quote"
);
}
#[test]
fn dquote_escape_backtick() {
// "\`" should strip backslash, produce literal backtick
let result = unescape_str(r#""\`""#);
let inner: String = result.chars()
.filter(|&c| c != DUB_QUOTE)
.collect();
assert_eq!(inner, "`", "Escaped backtick should produce literal backtick");
let inner: String = result.chars().filter(|&c| c != DUB_QUOTE).collect();
assert_eq!(
inner, "`",
"Escaped backtick should produce literal backtick"
);
}
#[test]
fn dquote_escape_nonspecial_preserves_backslash() {
// "\a" inside double quotes should preserve the backslash (a is not special)
let result = unescape_str(r#""\a""#);
let inner: String = result.chars()
.filter(|&c| c != DUB_QUOTE)
.collect();
assert_eq!(inner, "\\a", "Backslash before non-special char should be preserved");
let inner: String = result.chars().filter(|&c| c != DUB_QUOTE).collect();
assert_eq!(
inner, "\\a",
"Backslash before non-special char should be preserved"
);
}
#[test]
fn dquote_unescaped_dollar_expands() {
// "$foo" inside double quotes should produce VAR_SUB (expansion marker)
let result = unescape_str(r#""$foo""#);
assert!(result.contains(VAR_SUB), "Unescaped $ should become VAR_SUB");
assert!(
result.contains(VAR_SUB),
"Unescaped $ should become VAR_SUB"
);
}
#[test]
@@ -354,9 +364,7 @@ fn dquote_mixed_escapes() {
assert!(!result.contains(VAR_SUB), "Escaped $ should not expand");
assert!(result.contains('$'), "Literal $ should be in output");
// Should have exactly one backslash (from \\)
let inner: String = result.chars()
.filter(|&c| c != DUB_QUOTE)
.collect();
let inner: String = result.chars().filter(|&c| c != DUB_QUOTE).collect();
let backslash_count = inner.chars().filter(|&c| c == '\\').count();
assert_eq!(backslash_count, 1, "\\\\ should produce one backslash");
}

View File

@@ -1,6 +1,5 @@
use crate::prompt::readline::{
annotate_input, annotate_input_recursive, markers,
highlight::Highlighter,
annotate_input, annotate_input_recursive, highlight::Highlighter, markers,
};
use super::*;
@@ -17,7 +16,10 @@ fn find_marker(annotated: &str, marker: char) -> Option<usize> {
/// Helper to check if markers appear in the correct order
fn marker_before(annotated: &str, first: char, second: char) -> bool {
if let (Some(pos1), Some(pos2)) = (find_marker(annotated, first), find_marker(annotated, second)) {
if let (Some(pos1), Some(pos2)) = (
find_marker(annotated, first),
find_marker(annotated, second),
) {
pos1 < pos2
} else {
false
@@ -51,7 +53,8 @@ fn annotate_builtin_command() {
// Should mark "export" as BUILTIN
assert!(has_marker(&annotated, markers::BUILTIN));
// Should mark assignment (or ARG if assignment isn't specifically marked separately)
// Should mark assignment (or ARG if assignment isn't specifically marked
// separately)
assert!(has_marker(&annotated, markers::ASSIGNMENT) || has_marker(&annotated, markers::ARG));
}
@@ -149,7 +152,11 @@ fn annotate_variable_in_string() {
assert!(has_marker(&annotated, markers::VAR_SUB));
// VAR_SUB should be inside STRING_DQ
assert!(marker_before(&annotated, markers::STRING_DQ, markers::VAR_SUB));
assert!(marker_before(
&annotated,
markers::STRING_DQ,
markers::VAR_SUB
));
}
#[test]
@@ -239,7 +246,10 @@ fn annotate_recursive_nested_command_sub() {
// Should have multiple CMD_SUB markers (nested)
let cmd_sub_count = annotated.chars().filter(|&c| c == markers::CMD_SUB).count();
assert!(cmd_sub_count >= 2, "Should have at least 2 CMD_SUB markers for nested substitutions");
assert!(
cmd_sub_count >= 2,
"Should have at least 2 CMD_SUB markers for nested substitutions"
);
}
#[test]
@@ -251,7 +261,10 @@ fn annotate_recursive_command_sub_with_args() {
// Just check that we have command-type markers
let builtin_count = annotated.chars().filter(|&c| c == markers::BUILTIN).count();
let command_count = annotated.chars().filter(|&c| c == markers::COMMAND).count();
assert!(builtin_count + command_count >= 2, "Expected at least 2 command markers (BUILTIN or COMMAND)");
assert!(
builtin_count + command_count >= 2,
"Expected at least 2 command markers (BUILTIN or COMMAND)"
);
}
#[test]
@@ -297,7 +310,10 @@ fn annotate_recursive_deeply_nested() {
let annotated = annotate_input_recursive(input);
// Should have multiple STRING_DQ and CMD_SUB markers
let string_count = annotated.chars().filter(|&c| c == markers::STRING_DQ).count();
let string_count = annotated
.chars()
.filter(|&c| c == markers::STRING_DQ)
.count();
let cmd_sub_count = annotated.chars().filter(|&c| c == markers::CMD_SUB).count();
assert!(string_count >= 2, "Should have multiple STRING_DQ markers");
@@ -314,7 +330,11 @@ fn marker_priority_var_in_string() {
let annotated = annotate_input(input);
// STRING_DQ should come before VAR_SUB (outer before inner)
assert!(marker_before(&annotated, markers::STRING_DQ, markers::VAR_SUB));
assert!(marker_before(
&annotated,
markers::STRING_DQ,
markers::VAR_SUB
));
}
#[test]
@@ -351,7 +371,10 @@ fn highlighter_produces_ansi_codes() {
let output = highlighter.take();
// Should contain ANSI escape codes
assert!(output.contains("\x1b["), "Output should contain ANSI escape sequences");
assert!(
output.contains("\x1b["),
"Output should contain ANSI escape sequences"
);
// Should still contain the original text
assert!(output.contains("echo"));
@@ -401,7 +424,8 @@ fn highlighter_preserves_text_content() {
let output = highlighter.take();
// Remove ANSI codes to check text content
let text_only: String = output.chars()
let text_only: String = output
.chars()
.filter(|c| !c.is_control() && *c != '\x1b')
.collect();
@@ -518,7 +542,11 @@ fn annotate_special_variables() {
// Should mark positional parameters
let var_count = annotated.chars().filter(|&c| c == markers::VAR_SUB).count();
assert!(var_count >= 5, "Expected at least 5 VAR_SUB markers, found {}", var_count);
assert!(
var_count >= 5,
"Expected at least 5 VAR_SUB markers, found {}",
var_count
);
}
#[test]
@@ -539,7 +567,10 @@ fn annotate_complex_pipeline() {
let annotated = annotate_input(input);
// Should have multiple OPERATOR markers for pipes
let operator_count = annotated.chars().filter(|&c| c == markers::OPERATOR).count();
let operator_count = annotated
.chars()
.filter(|&c| c == markers::OPERATOR)
.count();
assert!(operator_count >= 4);
// Should have multiple COMMAND markers
@@ -577,7 +608,10 @@ fn annotate_multiple_redirects() {
let annotated = annotate_input(input);
// Should have multiple REDIRECT markers
let redirect_count = annotated.chars().filter(|&c| c == markers::REDIRECT).count();
let redirect_count = annotated
.chars()
.filter(|&c| c == markers::REDIRECT)
.count();
assert!(redirect_count >= 2);
}

View File

@@ -4,8 +4,9 @@ use super::*;
use crate::expand::{expand_aliases, unescape_str};
use crate::libsh::error::{Note, ShErr, ShErrKind};
use crate::parse::{
NdRule, Node, ParseStream,
lex::{LexFlags, LexStream, Tk, TkRule},
node_operation, NdRule, Node, ParseStream,
node_operation,
};
use crate::state::{write_logic, write_vars};

View File

@@ -1,14 +1,17 @@
use std::collections::VecDeque;
use crate::{
libsh::{error::ShErr, term::{Style, Styled}},
libsh::{
error::ShErr,
term::{Style, Styled},
},
prompt::readline::{
FernVi,
history::History,
keys::{KeyCode, KeyEvent, ModKeys},
linebuf::LineBuf,
term::{raw_mode, KeyReader, LineWriter},
term::{KeyReader, LineWriter, raw_mode},
vimode::{ViInsert, ViMode, ViNormal},
FernVi,
},
};
@@ -173,8 +176,9 @@ impl LineWriter for TestWriter {
}
}
// NOTE: FernVi structure has changed significantly and readline() method no longer exists
// These test helpers are disabled until they can be properly updated
// NOTE: FernVi structure has changed significantly and readline() method no
// longer exists These test helpers are disabled until they can be properly
// updated
/*
impl FernVi {
pub fn new_test(prompt: Option<String>, input: &str, initial: &str) -> Self {

View File

@@ -1,8 +1,8 @@
use std::sync::Arc;
use crate::parse::{
NdRule, Node, ParseStream, Redir, RedirType,
lex::{LexFlags, LexStream},
Node, NdRule, ParseStream, RedirType, Redir,
};
use crate::procio::{IoFrame, IoMode, IoStack};
@@ -16,9 +16,7 @@ fn parse_command(input: &str) -> Node {
.flatten()
.collect::<Vec<_>>();
let mut nodes = ParseStream::new(tokens)
.flatten()
.collect::<Vec<_>>();
let mut nodes = ParseStream::new(tokens).flatten().collect::<Vec<_>>();
assert_eq!(nodes.len(), 1, "Expected exactly one node");
let top_node = nodes.remove(0);
@@ -27,24 +25,41 @@ fn parse_command(input: &str) -> Node {
// Structure is typically: Conjunction -> Pipeline -> Command
match top_node.class {
NdRule::Conjunction { elements } => {
let first_element = elements.into_iter().next().expect("Expected at least one conjunction element");
let first_element = elements
.into_iter()
.next()
.expect("Expected at least one conjunction element");
match first_element.cmd.class {
NdRule::Pipeline { cmds, .. } => {
let mut commands = cmds;
assert_eq!(commands.len(), 1, "Expected exactly one command in pipeline");
assert_eq!(
commands.len(),
1,
"Expected exactly one command in pipeline"
);
commands.remove(0)
}
NdRule::Command { .. } => *first_element.cmd,
_ => panic!("Expected Command or Pipeline node, got {:?}", first_element.cmd.class),
_ => panic!(
"Expected Command or Pipeline node, got {:?}",
first_element.cmd.class
),
}
}
NdRule::Pipeline { cmds, .. } => {
let mut commands = cmds;
assert_eq!(commands.len(), 1, "Expected exactly one command in pipeline");
assert_eq!(
commands.len(),
1,
"Expected exactly one command in pipeline"
);
commands.remove(0)
}
NdRule::Command { .. } => top_node,
_ => panic!("Expected Conjunction, Pipeline, or Command node, got {:?}", top_node.class),
_ => panic!(
"Expected Conjunction, Pipeline, or Command node, got {:?}",
top_node.class
),
}
}
@@ -99,7 +114,13 @@ fn parse_stderr_to_stdout() {
assert_eq!(node.redirs.len(), 1);
let redir = &node.redirs[0];
assert!(matches!(redir.io_mode, IoMode::Fd { tgt_fd: 2, src_fd: 1 }));
assert!(matches!(
redir.io_mode,
IoMode::Fd {
tgt_fd: 2,
src_fd: 1
}
));
}
#[test]
@@ -109,7 +130,13 @@ fn parse_stdout_to_stderr() {
assert_eq!(node.redirs.len(), 1);
let redir = &node.redirs[0];
assert!(matches!(redir.io_mode, IoMode::Fd { tgt_fd: 1, src_fd: 2 }));
assert!(matches!(
redir.io_mode,
IoMode::Fd {
tgt_fd: 1,
src_fd: 2
}
));
}
#[test]
@@ -120,15 +147,24 @@ fn parse_multiple_redirects() {
// Input redirect
assert!(matches!(node.redirs[0].class, RedirType::Input));
assert!(matches!(node.redirs[0].io_mode, IoMode::File { tgt_fd: 0, .. }));
assert!(matches!(
node.redirs[0].io_mode,
IoMode::File { tgt_fd: 0, .. }
));
// Stdout redirect
assert!(matches!(node.redirs[1].class, RedirType::Output));
assert!(matches!(node.redirs[1].io_mode, IoMode::File { tgt_fd: 1, .. }));
assert!(matches!(
node.redirs[1].io_mode,
IoMode::File { tgt_fd: 1, .. }
));
// Stderr redirect
assert!(matches!(node.redirs[2].class, RedirType::Output));
assert!(matches!(node.redirs[2].io_mode, IoMode::File { tgt_fd: 2, .. }));
assert!(matches!(
node.redirs[2].io_mode,
IoMode::File { tgt_fd: 2, .. }
));
}
#[test]
@@ -149,7 +185,13 @@ fn parse_custom_fd_dup() {
assert_eq!(node.redirs.len(), 1);
let redir = &node.redirs[0];
assert!(matches!(redir.io_mode, IoMode::Fd { tgt_fd: 3, src_fd: 4 }));
assert!(matches!(
redir.io_mode,
IoMode::Fd {
tgt_fd: 3,
src_fd: 4
}
));
}
#[test]
@@ -187,11 +229,20 @@ fn parse_redirect_order_preserved() {
assert_eq!(node.redirs.len(), 2);
// First redirect: 2>&1
assert!(matches!(node.redirs[0].io_mode, IoMode::Fd { tgt_fd: 2, src_fd: 1 }));
assert!(matches!(
node.redirs[0].io_mode,
IoMode::Fd {
tgt_fd: 2,
src_fd: 1
}
));
// Second redirect: > file.txt
assert!(matches!(node.redirs[1].class, RedirType::Output));
assert!(matches!(node.redirs[1].io_mode, IoMode::File { tgt_fd: 1, .. }));
assert!(matches!(
node.redirs[1].io_mode,
IoMode::File { tgt_fd: 1, .. }
));
}
// ============================================================================
@@ -241,10 +292,7 @@ fn iostack_never_empties() {
fn iostack_push_to_frame() {
let mut stack = IoStack::new();
let redir = crate::parse::Redir::new(
IoMode::fd(1, 2),
RedirType::Output,
);
let redir = crate::parse::Redir::new(IoMode::fd(1, 2), RedirType::Output);
stack.push_to_frame(redir);
assert_eq!(stack.curr_frame().len(), 1);
@@ -299,7 +347,10 @@ fn iostack_flatten() {
// Push new frame with redir
let mut frame2 = IoFrame::new();
frame2.push(crate::parse::Redir::new(IoMode::fd(2, 1), RedirType::Output));
frame2.push(crate::parse::Redir::new(
IoMode::fd(2, 1),
RedirType::Output,
));
stack.push_frame(frame2);
// Push third frame with redir

View File

@@ -9,7 +9,9 @@ fn scopestack_new() {
let stack = ScopeStack::new();
// Should start with one global scope
assert!(stack.var_exists("PATH") || !stack.var_exists("PATH")); // Just check it doesn't panic
assert!(stack.var_exists("PATH") || !stack.var_exists("PATH")); // Just check
// it doesn't
// panic
}
#[test]
@@ -59,7 +61,11 @@ fn scopestack_variable_shadowing() {
stack.ascend();
// Global should be restored
assert_eq!(stack.get_var("VAR"), "global", "Global should be unchanged after ascend");
assert_eq!(
stack.get_var("VAR"),
"global",
"Global should be unchanged after ascend"
);
}
#[test]
@@ -147,9 +153,13 @@ fn scopestack_descend_with_args() {
// In local scope, positional params come from the VarTab created during descend
// VarTab::new() initializes with process args, then our args are appended
// So we check that SOME positional parameter exists (implementation detail may vary)
// So we check that SOME positional parameter exists (implementation detail may
// vary)
let local_param = stack.get_param(ShellParam::Pos(1));
assert!(!local_param.is_empty(), "Should have positional parameters in local scope");
assert!(
!local_param.is_empty(),
"Should have positional parameters in local scope"
);
// Ascend back
stack.ascend();
@@ -239,13 +249,19 @@ fn scopestack_var_exists() {
assert!(stack.var_exists("EXISTS"));
stack.descend(None);
assert!(stack.var_exists("EXISTS"), "Global var should be visible in local scope");
assert!(
stack.var_exists("EXISTS"),
"Global var should be visible in local scope"
);
stack.set_var("LOCAL", "yes", VarFlags::LOCAL);
assert!(stack.var_exists("LOCAL"));
stack.ascend();
assert!(!stack.var_exists("LOCAL"), "Local var should not exist after ascend");
assert!(
!stack.var_exists("LOCAL"),
"Local var should not exist after ascend"
);
}
#[test]
@@ -333,11 +349,14 @@ fn logtab_multiple_aliases() {
assert_eq!(logtab.aliases().len(), 3);
assert_eq!(logtab.get_alias("ll"), Some("ls -la".to_string()));
assert_eq!(logtab.get_alias("la"), Some("ls -A".to_string()));
assert_eq!(logtab.get_alias("grep"), Some("grep --color=auto".to_string()));
assert_eq!(
logtab.get_alias("grep"),
Some("grep --color=auto".to_string())
);
}
// Note: Function tests are limited because ShFunc requires complex setup (parsed AST)
// We'll test the basic storage/retrieval mechanics
// Note: Function tests are limited because ShFunc requires complex setup
// (parsed AST) We'll test the basic storage/retrieval mechanics
#[test]
fn logtab_funcs_empty_initially() {
@@ -427,7 +446,10 @@ fn vartab_positional_params() {
// Now sh_argv should be: [exe_path, test_arg1, test_arg2]
// Pos(0) = exe_path, Pos(1) = test_arg1, Pos(2) = test_arg2
let final_len = vartab.sh_argv().len();
assert!(final_len > initial_len || final_len >= 1, "Should have arguments");
assert!(
final_len > initial_len || final_len >= 1,
"Should have arguments"
);
// Just verify we can retrieve the last args we pushed
let last_idx = final_len - 1;
@@ -517,13 +539,34 @@ fn shellparam_is_global() {
#[test]
fn shellparam_from_str() {
assert!(matches!("?".parse::<ShellParam>().unwrap(), ShellParam::Status));
assert!(matches!("$".parse::<ShellParam>().unwrap(), ShellParam::ShPid));
assert!(matches!("!".parse::<ShellParam>().unwrap(), ShellParam::LastJob));
assert!(matches!("0".parse::<ShellParam>().unwrap(), ShellParam::ShellName));
assert!(matches!("@".parse::<ShellParam>().unwrap(), ShellParam::AllArgs));
assert!(matches!("*".parse::<ShellParam>().unwrap(), ShellParam::AllArgsStr));
assert!(matches!("#".parse::<ShellParam>().unwrap(), ShellParam::ArgCount));
assert!(matches!(
"?".parse::<ShellParam>().unwrap(),
ShellParam::Status
));
assert!(matches!(
"$".parse::<ShellParam>().unwrap(),
ShellParam::ShPid
));
assert!(matches!(
"!".parse::<ShellParam>().unwrap(),
ShellParam::LastJob
));
assert!(matches!(
"0".parse::<ShellParam>().unwrap(),
ShellParam::ShellName
));
assert!(matches!(
"@".parse::<ShellParam>().unwrap(),
ShellParam::AllArgs
));
assert!(matches!(
"*".parse::<ShellParam>().unwrap(),
ShellParam::AllArgsStr
));
assert!(matches!(
"#".parse::<ShellParam>().unwrap(),
ShellParam::ArgCount
));
match "1".parse::<ShellParam>().unwrap() {
ShellParam::Pos(n) => assert_eq!(n, 1),