Add array support for local/export/readonly builtins

Add array length syntax ${arr[#]}

Map read path now expands variables before splitting on ., fixing map "$node" with dotted paths

Map assignment path uses quote-aware token splitting, enabling quoted keys like "--type="

Completion errors now display above prompt instead of being overwritten

Fix nested if/fi parser bug when closing keywords appear on separate lines

Add QuoteState enum, replacing ad-hoc quote tracking booleans across lexer, highlighter, and expansion

Add split_tk_at/split_tk for quote-aware token splitting with span preservation

Refactor setup_builtin to accept optional argv for deferred expansion

Add ariadne dependency (not yet wired up)
This commit is contained in:
2026-02-28 15:51:09 -05:00
parent 4cda68e635
commit 9d8d8901d7
26 changed files with 375 additions and 281 deletions

View File

@@ -7,7 +7,7 @@ use regex::Regex;
use crate::libsh::error::{ShErr, ShErrKind, ShResult};
use crate::parse::execute::exec_input;
use crate::parse::lex::{LexFlags, LexStream, Tk, TkFlags, TkRule, is_hard_sep};
use crate::parse::lex::{LexFlags, LexStream, QuoteState, Tk, TkFlags, TkRule, is_hard_sep};
use crate::parse::{Redir, RedirType};
use crate::procio::{IoBuf, IoFrame, IoMode, IoStack};
use crate::readline::markers;
@@ -130,18 +130,16 @@ fn has_braces(s: &str) -> bool {
let mut found_open = false;
let mut has_comma = false;
let mut has_range = false;
let mut cur_quote: Option<char> = None;
let mut qt_state = QuoteState::default();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
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('"'),
'"' if cur_quote == Some('"') => cur_quote = None,
'{' if cur_quote.is_none() => {
'\'' => qt_state.toggle_single(),
'"' => qt_state.toggle_double(),
'{' if qt_state.in_quote() => {
if depth == 0 {
found_open = true;
has_comma = false;
@@ -149,16 +147,16 @@ fn has_braces(s: &str) -> bool {
}
depth += 1;
}
'}' if cur_quote.is_none() && depth > 0 => {
'}' if qt_state.outside() && depth > 0 => {
depth -= 1;
if depth == 0 && found_open && (has_comma || has_range) {
return true;
}
}
',' if cur_quote.is_none() && depth == 1 => {
',' if qt_state.outside() && depth == 1 => {
has_comma = true;
}
'.' if cur_quote.is_none() && depth == 1 => {
'.' if qt_state.outside() && depth == 1 => {
if chars.peek() == Some(&'.') {
chars.next();
has_range = true;
@@ -239,7 +237,7 @@ fn expand_one_brace(word: &str) -> ShResult<Vec<String>> {
fn get_brace_parts(word: &str) -> Option<(String, String, String)> {
let mut chars = word.chars().peekable();
let mut prefix = String::new();
let mut cur_quote: Option<char> = None;
let mut qt_state = QuoteState::default();
// Find the opening brace
while let Some(ch) = chars.next() {
@@ -250,23 +248,15 @@ 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() => {
'\'' => {
qt_state.toggle_single();
prefix.push(ch);
}
'"' => {
qt_state.toggle_double();
prefix.push(ch);
}
'{' if qt_state.outside() => {
break;
}
_ => prefix.push(ch),
@@ -276,7 +266,7 @@ fn get_brace_parts(word: &str) -> Option<(String, String, String)> {
// Find matching closing brace
let mut depth = 1;
let mut inner = String::new();
cur_quote = None;
qt_state = QuoteState::default();
while let Some(ch) = chars.next() {
match ch {
@@ -286,27 +276,19 @@ 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() => {
'\'' => {
qt_state.toggle_single();
inner.push(ch);
}
'"' => {
qt_state.toggle_double();
inner.push(ch);
}
'{' if qt_state.outside() => {
depth += 1;
inner.push(ch);
}
'}' if cur_quote.is_none() => {
'}' if qt_state.outside() => {
depth -= 1;
if depth == 0 {
break;
@@ -335,7 +317,7 @@ fn split_brace_inner(inner: &str) -> Vec<String> {
let mut current = String::new();
let mut chars = inner.chars().peekable();
let mut depth = 0;
let mut cur_quote: Option<char> = None;
let mut qt_state = QuoteState::default();
while let Some(ch) = chars.next() {
match ch {
@@ -345,31 +327,23 @@ 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() => {
'\'' => {
qt_state.toggle_single();
current.push(ch);
}
'"' => {
qt_state.toggle_double();
current.push(ch);
}
'{' if qt_state.outside() => {
depth += 1;
current.push(ch);
}
'}' if cur_quote.is_none() => {
'}' if qt_state.outside() => {
depth -= 1;
current.push(ch);
}
',' if cur_quote.is_none() && depth == 0 => {
',' if qt_state.outside() && depth == 0 => {
parts.push(std::mem::take(&mut current));
}
_ => current.push(ch),
@@ -556,6 +530,11 @@ pub fn expand_var(chars: &mut Peekable<Chars<'_>>) -> ShResult<String> {
let arg_sep = markers::ARG_SEP.to_string();
read_vars(|v| v.get_arr_elems(&var_name))?.join(&arg_sep)
}
ArrIndex::ArgCount => {
read_vars(|v| v.get_arr_elems(&var_name))
.map(|elems| elems.len().to_string())
.unwrap_or_else(|_| "0".to_string())
}
ArrIndex::AllJoined => {
let ifs = read_vars(|v| v.try_get_var("IFS"))
.unwrap_or_else(|| " \t\n".to_string())