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
37 changes: 20 additions & 17 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,31 @@ 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.
- Keywords inside colon words and globs (`true:foo`, `do*`) now remain
literal text. Embedded `+` and tilde paths containing `:` are accepted
consistently; standalone keywords and plus-prefixed flags keep their meaning.

- For-loop items now require whitespace between them. Quote a whole word to
join text with interpolation; adjacent fragments are refused before the
substitution or loop body runs, instead of becoming separate iterations.

- Literal paths such as `.git/HEAD`, `2026/report`, `./`, and `../` now
parse as one word. Relative, absolute, and tilde paths accept `@` and `+`
- Literal paths such as `.git/HEAD`, `2026/report`, `./`, and `../` now parse
as one word. Relative, absolute, and tilde paths accept `@` and `+`
consistently; Git revision paths such as `HEAD:src/main.rs` stay one
argument. Text joined with an expansion still requires quoting.
- Relative executable paths such as `.git/hooks/pre-commit` and `../bin/check`
now parse. A command name attached to its first argument is refused instead
of silently running a different executable (`./bin$x` as `./bin $x`).
- For-loop items now require whitespace between them. Quote a whole word to
join text with interpolation; adjacent fragments are refused before the
substitution or loop body runs, instead of becoming separate iterations.
- 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.
- Keywords inside colon words and globs (`true:foo`, `do*`) now remain literal
text. Embedded `+` and tilde paths containing `:` are accepted consistently;
standalone keywords and plus-prefixed flags keep their meaning.
- 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.
- A command name that cannot start an assignment now reports glued words and
names the word to quote. `./bin=1` and `2026/report=1` gave a generic
"expected" list; they now name `./bin=1` and `2026/report=1`.
- Home-relative assignments accept a dash-number path: `p=~10-20` and
`p=~2024-01-02` parse like `p=~user` instead of failing with an internal
token name.

## [0.17.1] - 2026-09-02

Expand Down
4 changes: 2 additions & 2 deletions crates/kaish-kernel/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3203,8 +3203,8 @@ fn split_tilde_assignments(tokens: Vec<Spanned<Token>>, source: &str) -> Vec<Spa
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(_))
| Token::NumberIdent(_) | Token::DashNumWord(_) | Token::Int(_)
| Token::Float(_) | Token::AtWord(_) | Token::PlusFlag(_))
|| next.token.is_keyword() || next.token.is_type())
});
if let Some(suffix) = suffix {
Expand Down
49 changes: 34 additions & 15 deletions crates/kaish-kernel/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1311,8 +1311,9 @@ fn parse_tokens(
// Only when the standing error IS that rejection: this corrects a span
// and must never author a verdict. The scan is an approximation of the
// argv grammar and finds adjacency the grammar accepts, so the gate is
// load-bearing. Scanning from the grammar's own position forward keeps
// it from blaming a legal run in an earlier clause.
// load-bearing. Keeping only runs that reach the grammar's own position
// keeps it from blaming a legal run in an earlier clause, while still
// covering a run the grammar reported at the last fragment of.
if errs.iter().all(is_glued_args_error)
&& let Some(from_offset) = errs.iter().map(|e| e.span().start).min()
&& let Err(specific) = validate_glued_args(&tokens, from_offset)
Expand Down Expand Up @@ -2127,15 +2128,17 @@ where
// when nothing adjacent fused it into a word — inside brackets and braces
// the colon is structural (record entries, slices, character classes) and
// never reaches a command-name position.
// The flag records whether the name is an `Ident`, the only token an
// assignment lvalue can start with (see `lvalue_path_parser`).
let command_name = choice((
ident_parser(),
path_parser(),
select! { Token::DotSlashPath(s) => s },
select! { Token::RelativePath(s) => s },
just(Token::True).to("true".to_string()),
just(Token::False).to("false".to_string()),
just(Token::Colon).to(":".to_string()),
just(Token::Dot).to(".".to_string()),
ident_parser().map(|name| (name, true)),
path_parser().map(|name| (name, false)),
select! { Token::DotSlashPath(s) => (s, false) },
select! { Token::RelativePath(s) => (s, false) },
just(Token::True).to(("true".to_string(), false)),
just(Token::False).to(("false".to_string(), false)),
just(Token::Colon).to((":".to_string(), false)),
just(Token::Dot).to((".".to_string(), false)),
));

// NB: the "at most one stdin source per command" rule is enforced by a
Expand All @@ -2148,13 +2151,17 @@ where
// structurally after parsing, where the message is fully under our control
// (verified empirically 2026-06-07).
command_name
.map_with(|name, extra| -> (String, Span) { (name, extra.span()) })
.map_with(|(name, is_identifier), extra| -> (String, bool, Span) {
(name, is_identifier, extra.span())
})
// An adjacent `=` belongs to assignment parsing, including its errors.
// Only an identifier can start an assignment, so a path name keeps the
// command alternative and the glued-word diagnosis that names the fix.
.then(just(Token::Eq).map_with(|_, extra| -> Span { extra.span() }).or_not().rewind())
.filter(|((_, name_span), equals)| {
!equals.is_some_and(|span| name_span.end == span.start)
.filter(|((_, is_identifier, name_span), equals)| {
!(*is_identifier && equals.is_some_and(|span| name_span.end == span.start))
})
.map(|(name, _)| name)
.map(|((name, _, name_span), _)| (name, name_span))
.then(args_list_parser().map_with(|args, extra| -> (Vec<Arg>, Span) { (args, extra.span()) }))
.validate(|((name, name_span), (args, args_span)), _, emitter| {
if !args.is_empty() && name_span.end == args_span.start {
Expand Down Expand Up @@ -3749,6 +3756,18 @@ fn glue_candidate_units(tokens: &[(Token, Span)]) -> Vec<Span> {
continue;
}

// A bare `=` between two adjacent words is part of the run, not a
// break in it: a name that cannot start an assignment (`./bin=1`)
// reaches argv as word/`=`/word and must be reported as one word.
if matches!(tok, Token::Eq)
&& units.last().is_some_and(|last: &Span| last.end == span.start)
&& word_unit(tokens, i + 1).is_some_and(|(next, _)| next.start == span.end)
{
units.push(*span);
i += 1;
continue;
}

i += 1;
}
units
Expand Down Expand Up @@ -3865,7 +3884,7 @@ fn validate_glued_args(
// in regions the grammar parsed happily, such as `$X==1` inside
// `[[ ]]`. Take the first run at or after the
// grammar's own position so the earlier legal run cannot win.
if units[start_idx].start < from_offset {
if units[end_idx].end <= from_offset {
continue;
}
let span: Span = (units[start_idx].start..units[end_idx].end).into();
Expand Down
28 changes: 28 additions & 0 deletions crates/kaish-kernel/tests/glued_arg_span_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]

use kaish_kernel::parser::parse;
use rstest::rstest;

/// The text the argv-glue error's own span covers, after checking it is
/// that error and only that error.
Expand Down Expand Up @@ -274,3 +275,30 @@ fn the_legal_test_condition_really_is_legal() {
parse("if [[ $X==1 ]]; then echo hi; fi")
.expect("a test condition may contain adjacent operands and operators");
}

/// A command name that is not an identifier cannot start an assignment, so
/// `name=value` there is glued argv and must name the word to quote — not
/// fall through to the assignment alternative's generic "expected …" list.
#[rstest]
#[case("./bin=1", "./bin=1")]
#[case("./bin=$x", "./bin=$x")]
#[case("src/bin=1", "src/bin=1")]
#[case("2026/report=1", "2026/report=1")]
#[case("/usr/bin/x=1", "/usr/bin/x=1")]
#[case("true=1", "true=1")]
fn non_identifier_name_with_equals_names_the_whole_word(
#[case] source: &str,
#[case] expected: &str,
) {
assert_eq!(glued_span_text(source), expected);
}

/// An identifier name still hands an adjacent `=` to assignment parsing.
#[rstest]
#[case("x=1")]
#[case("cat=1")]
#[case("x = 1")]
#[case("x[0]=1")]
fn identifier_name_with_equals_stays_an_assignment(#[case] source: &str) {
parse(source).expect("must parse as an assignment");
}
2 changes: 2 additions & 0 deletions crates/kaish-kernel/tests/tilde_assignment_words_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ use rstest::rstest;
#[case("~fixture-user/x")]
#[case("~007")]
#[case("~1.0")]
#[case("~10-20")]
#[case("~2024-01-02")]
fn tilde_assignment_has_an_assignment_delimiter(#[case] path: &str) {
let source = format!("p={path}");
let tokens = tokenize(&source).unwrap();
Expand Down