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/日本語