Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/kaish-help/content/en/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions crates/kaish-help/src/fragments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
```"#,
),
Expand Down
63 changes: 56 additions & 7 deletions crates/kaish-kernel/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1115,9 +1115,7 @@ fn lex_float(lex: &mut logos::Lexer<Token>) -> Result<f64, LexerError> {
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<Token>) -> String {
lex.slice().to_string()
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2815,6 +2816,8 @@ fn compute_value_context(tokens: &[Spanned<Token>]) -> Vec<ValueContext> {
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
};

Expand Down Expand Up @@ -3124,7 +3127,9 @@ fn compute_value_context(tokens: &[Spanned<Token>]) -> Vec<ValueContext> {
| 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)
Expand Down Expand Up @@ -3170,6 +3175,50 @@ fn compute_value_context(tokens: &[Spanned<Token>]) -> Vec<ValueContext> {
// mergeable.
// ═══════════════════════════════════════════════════════════════════

/// Outside tests, an assignment followed by `~` is not a regex comparison.
fn split_tilde_assignments(tokens: Vec<Spanned<Token>>, source: &str) -> Vec<Spanned<Token>> {
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!(
Expand Down Expand Up @@ -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,
Expand Down
87 changes: 87 additions & 0 deletions crates/kaish-kernel/tests/tilde_assignment_words_tests.rs
Original file line number Diff line number Diff line change
@@ -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");
}
1 change: 1 addition & 0 deletions docs/LANGUAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/日本語
Expand Down