From 194475246bd3a79f0833741a51dd915aa30cd8ff Mon Sep 17 00:00:00 2001 From: A Tobey Date: Tue, 8 Sep 2026 10:21:30 -0400 Subject: [PATCH] fix: distinguish tilde assignments from regex comparisons An assignment followed immediately by a home-relative path was tokenized as a regex comparison operator. Separate the assignment delimiter from the tilde path outside tests, while preserving the regex operator inside tests and respecting nested command-substitution scopes. Home-relative assignments now parse: ```sh p=~/x local p=~/x p={x:empty}; p[x]=~/x foo --path=~/x ``` Regex comparisons retain their operator, including after logical operators: ```sh [[ a == a && x=~/x ]] [[ $(p=~/x; echo $p) =~ /x ]] ``` The lexer uses its existing test and substitution frames to distinguish these contexts. A closing bracket qualifies only after a statement-head assignment target, so a glob character class cannot become an assignment delimiter. Logical operators inside a test now retain that test frame. Split and joined tokens use the original source spans; text adjacent to interpolation still requires quoting. Runtime tilde expansion keeps its existing behavior. Validation: 12 regression cases failed before the fix; all 27 targeted cases pass, including token spans, plain/local/bracket assignments, named arguments, nested substitutions, regex operators with and without spaces, interpolation refusal, and a glob bracket that must not be treated as an assignment target. Workspace tests, clippy across all targets with warnings denied, rustdoc with warnings denied, kernel tests without default features, and the WASI build passed. Help fragments and generated syntax are synchronized. Reviewed through kaibo's default cast, GLM-5.2 with DeepSeek V4 Flash investigation. The review also covers the preceding literal-word layer's final numeric punctuation cases. This layer builds on #440 in the native stack rooted at #437; no version bump. --- CHANGELOG.md | 4 + crates/kaish-help/content/en/syntax.md | 1 + crates/kaish-help/src/fragments.rs | 1 + crates/kaish-kernel/src/lexer.rs | 63 ++++++++++++-- .../tests/tilde_assignment_words_tests.rs | 87 +++++++++++++++++++ docs/LANGUAGE.md | 1 + 6 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 crates/kaish-kernel/tests/tilde_assignment_words_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd8bcda..89bda837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ breaking entries are marked **BREAKING**. ### Fixed +- Home-relative assignments such as `p=~/x` now keep the assignment delimiter + separate from the path. The `=~` regex operator remains intact inside + tests, including tests containing command substitutions with assignments. + - Numeric filenames and versions such as `123.txt` and `1.2.3` now stay literal words. Float-prefixed globs such as `1.0*` preserve their exact spelling and match filenames without changing scalar number rules. diff --git a/crates/kaish-help/content/en/syntax.md b/crates/kaish-help/content/en/syntax.md index 1431b8e1..6a4501ac 100644 --- a/crates/kaish-help/content/en/syntax.md +++ b/crates/kaish-help/content/en/syntax.md @@ -174,6 +174,7 @@ echo /tmp/$(id -u).sock # error — quote "/tmp/$(id -u).sock" cmd > $dir/out.txt # error — quote "$dir/out.txt" # Literal words keep their complete spelling, without quotes: echo 123.txt 1.2.3 true:foo a+b .git/HEAD +p=~/x; echo "$p" # assignment followed by a home-relative path ls 1.0* # glob keeps the written numeric prefix ``` diff --git a/crates/kaish-help/src/fragments.rs b/crates/kaish-help/src/fragments.rs index d4370adc..0a7f470e 100644 --- a/crates/kaish-help/src/fragments.rs +++ b/crates/kaish-help/src/fragments.rs @@ -473,6 +473,7 @@ echo /tmp/$(id -u).sock # error — quote "/tmp/$(id -u).sock" cmd > $dir/out.txt # error — quote "$dir/out.txt" # Literal words keep their complete spelling, without quotes: echo 123.txt 1.2.3 true:foo a+b .git/HEAD +p=~/x; echo "$p" # assignment followed by a home-relative path ls 1.0* # glob keeps the written numeric prefix ```"#, ), diff --git a/crates/kaish-kernel/src/lexer.rs b/crates/kaish-kernel/src/lexer.rs index 148acc32..a5da47bf 100644 --- a/crates/kaish-kernel/src/lexer.rs +++ b/crates/kaish-kernel/src/lexer.rs @@ -1115,9 +1115,7 @@ fn lex_float(lex: &mut logos::Lexer) -> Result { lex.slice().parse().map_err(|_| LexerError::InvalidNumber) } -/// Lex a digit-leading bareword like `019dda1c` or `019dda1c-5b3f-7000`. -/// Distinguished from `Int` because at least one alpha character follows the -/// leading digits — the slice is treated as a string, not a number. +/// Preserve the spelling of a numeric-prefixed literal word. fn lex_number_ident(lex: &mut logos::Lexer) -> String { lex.slice().to_string() } @@ -1379,8 +1377,7 @@ impl fmt::Display for Token { impl Token { /// Returns true if this token is a keyword. // Must match the Keyword variants in `Token::category()` (minus the - // TypeX variants, which `is_type()` covers separately). Currently - // uncalled — kept exhaustive so future callers don't get wrong answers. + // TypeX variants, which `is_type()` covers separately). pub fn is_keyword(&self) -> bool { matches!( self, @@ -2652,6 +2649,10 @@ struct ValueContext { /// suppresses colon-merge fusion. Narrower than `in_literal` on /// purpose: a plain scalar assignment `x=foo:bar` must keep fusing. in_brace: bool, + /// Inside a test in the current substitution scope; `=~` is an operator. + in_test: bool, + /// Immediately after a statement-head assignment target, including subscripts. + after_lvalue: bool, /// This token is (part of) `push`'s bracket-path TARGET — see /// [`PushTarget`]. Lets `flush_glob_run` fuse `services[web][tags]` /// verbatim into a single `Ident` (a path to walk) instead of a @@ -2815,6 +2816,8 @@ fn compute_value_context(tokens: &[Spanned]) -> Vec { ctx[i] = ValueContext { in_literal: expect_value || in_open_literal, in_brace: matches!(top, Some(Frame::Record)), + in_test: frames[floor..].contains(&Frame::Test), + after_lvalue: matches!(scopes.last(), Some(StmtHead::Lvalue(_))), push_target: false, // set below once this token's transition is known }; @@ -3124,7 +3127,9 @@ fn compute_value_context(tokens: &[Spanned]) -> Vec { | Token::Amp | Token::And | Token::Or => { - while frames.len() > floor + // `&&` and `||` inside a test join comparisons. + while !(in_test && matches!(t, Token::And | Token::Or)) + && frames.len() > floor && matches!( frames.last(), Some(Frame::Test) | Some(Frame::List) | Some(Frame::Record) @@ -3170,6 +3175,50 @@ fn compute_value_context(tokens: &[Spanned]) -> Vec { // mergeable. // ═══════════════════════════════════════════════════════════════════ +/// Outside tests, an assignment followed by `~` is not a regex comparison. +fn split_tilde_assignments(tokens: Vec>, source: &str) -> Vec> { + if !tokens.iter().any(|token| matches!(token.token, Token::Match)) { + return tokens; + } + let contexts = compute_value_context(&tokens); + let mut result = Vec::with_capacity(tokens.len()); + let mut index = 0; + while index < tokens.len() { + let token = &tokens[index]; + let assignment_key = index.checked_sub(1).map(|previous| &tokens[previous]); + let after_key = assignment_key.is_some_and(|previous| { + previous.span.end == token.span.start + && (matches!(previous.token, Token::Ident(_) | Token::LongFlag(_)) + || (matches!(previous.token, Token::RBracket) && contexts[index].after_lvalue) + || previous.token.is_keyword() || previous.token.is_type()) + }); + if !matches!(token.token, Token::Match) || contexts[index].in_test || !after_key { + result.push(token.clone()); + index += 1; + continue; + } + let tilde_start = token.span.start + 1; + result.push(Spanned::new(Token::Eq, token.span.start..tilde_start)); + let suffix = tokens.get(index + 1).filter(|next| { + next.span.start == token.span.end + && (matches!(next.token, Token::Path(_) | Token::RelativePath(_) + | Token::DotSlashPath(_) | Token::DottedIdent(_) | Token::Ident(_) + | Token::NumberIdent(_) | Token::Int(_) | Token::Float(_) + | Token::AtWord(_) | Token::PlusFlag(_)) + || next.token.is_keyword() || next.token.is_type()) + }); + if let Some(suffix) = suffix { + let span = tilde_start..suffix.span.end; + result.push(Spanned::new(Token::TildePath(source[span.clone()].to_string()), span)); + index += 2; + } else { + result.push(Spanned::new(Token::Tilde, tilde_start..token.span.end)); + index += 1; + } + } + result +} + /// True for token types that can participate in colon-adjacent merging. fn is_colon_mergeable(token: &Token) -> bool { token.is_keyword() || token.is_type() || matches!( @@ -3690,7 +3739,7 @@ fn tokenize_impl( Ok(preserve_numeric_source_text( merge_glob_adjacent( - merge_colon_adjacent(merge_flag_metachar_adjacent(mapped), source), + merge_colon_adjacent(merge_flag_metachar_adjacent(split_tilde_assignments(mapped, source)), source), source, ), source, diff --git a/crates/kaish-kernel/tests/tilde_assignment_words_tests.rs b/crates/kaish-kernel/tests/tilde_assignment_words_tests.rs new file mode 100644 index 00000000..d09d2ad8 --- /dev/null +++ b/crates/kaish-kernel/tests/tilde_assignment_words_tests.rs @@ -0,0 +1,87 @@ +//! Assignment delimiters stay separate from home-relative paths. +#![allow(clippy::unwrap_used, clippy::expect_used)] +use kaish_kernel::ast::{Expr, Stmt, Value}; +use kaish_kernel::lexer::{tokenize, Token}; +use kaish_kernel::parser::parse; +use kaish_kernel::{Kernel, KernelConfig}; +use rstest::rstest; + +#[rstest] +#[case("~/x")] +#[case("~/")] +#[case("~")] +#[case("~/日本語")] +#[case("~/a+b")] +#[case("~/a:b")] +#[case("~fixture-user/x")] +#[case("~007")] +#[case("~1.0")] +fn tilde_assignment_has_an_assignment_delimiter(#[case] path: &str) { + let source = format!("p={path}"); + let tokens = tokenize(&source).unwrap(); + assert_eq!(tokens.len(), 3, "{tokens:?}"); + assert_eq!(tokens[1].token, Token::Eq); + assert_eq!(tokens[1].span, 1..2); + assert_eq!(tokens[2].span, 2..source.len()); + let program = parse(&source).unwrap(); + let Stmt::Assignment(assignment) = &program.statements[0] else { panic!("{program:?}") }; + assert_eq!(assignment.value, Expr::Literal(Value::String(path.into()))); +} + +#[rstest] +#[case("p=~/x; echo $p", "/home/fixture/x\n")] +#[case("p=~; echo $p", "/home/fixture\n")] +#[case("echo p=~/x", "p=/home/fixture/x\n")] +#[case("p=~/; echo $p", "/home/fixture/\n")] +#[case("echo $(p=~; echo $p)", "/home/fixture\n")] +#[case("local p=~/x; echo $p", "/home/fixture/x\n")] +#[case("p={x:empty}; p[x]=~/x; echo ${p[x]}", "/home/fixture/x\n")] +#[case("echo $(p=~/x; echo $p)", "/home/fixture/x\n")] +#[case("[[ $(p=~/x; echo $p) =~ /x ]] && echo yes", "yes\n")] +#[tokio::test] +async fn assignments_expand_against_session_home(#[case] source: &str, #[case] expected: &str) { + let kernel = Kernel::new(KernelConfig::isolated().with_initial_vars( + [("HOME".into(), Value::String("/home/fixture".into()))].into())).unwrap(); + let result = kernel.execute(source).await.unwrap(); + assert!(result.ok(), "{result:?}"); + assert_eq!(result.text_out(), expected); +} + +#[rstest] +#[case("[[ x=~/x ]]")] +#[case("[[ a == a && x=~/x ]]")] +#[case("[[ a == a || x=~/x ]]")] +#[case("[[ x =~ /x ]]")] +#[case("[[ $(echo x)=~/x ]]")] +fn regex_operator_remains_whole_inside_tests(#[case] source: &str) { + let tokens = tokenize(source).unwrap(); + assert_eq!(tokens.iter().filter(|t| matches!(t.token, Token::Match)).count(), 1); + parse(source).unwrap(); +} + +#[test] +fn tilde_assignment_does_not_join_expansions() { + assert!(parse("p=~/$name").is_err()); + parse("p=\"$HOME/$name\"").unwrap(); + parse("foo --path=~/x").unwrap(); +} + +#[test] +fn glob_bracket_adjacency_does_not_execute_as_separate_arguments() { + assert!(parse("echo [x]=~/y").is_err()); +} + +#[test] +fn glob_bracket_is_not_an_assignment_target() { + let tokens = tokenize("echo [x]=~/y").unwrap(); + assert_eq!(tokens.iter().filter(|t| matches!(t.token, Token::Match)).count(), 1); +} + +#[tokio::test] +async fn named_path_argument_expands_before_tool_binding() { + let kernel = Kernel::new(KernelConfig::isolated().with_initial_vars( + [("HOME".into(), Value::String("/home/fixture".into()))].into())).unwrap(); + let result = kernel.execute("mkdir -p /home/fixture; touch /home/fixture/x /home/fixture/y; find /home/fixture --path=~/x").await.unwrap(); + assert!(result.ok(), "{result:?}"); + assert_eq!(result.text_out(), "/home/fixture/x"); +} diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md index 737e55a9..edfe6864 100644 --- a/docs/LANGUAGE.md +++ b/docs/LANGUAGE.md @@ -410,6 +410,7 @@ error, as it was before.) ```sh echo 123.txt 1.2.3 true:foo a+b # complete literal words ls 1.0* # glob keeps the written numeric prefix +p=~/x; echo "$p" # assignment followed by a home-relative path echo café cd ~/文書 ls /tmp/日本語