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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ breaking entries are marked **BREAKING**.

## [Unreleased]

### Fixed

- 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`).

## [0.17.1] - 2026-09-02

### Migrating to 0.17.0
Expand Down
9 changes: 6 additions & 3 deletions crates/kaish-help/content/en/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ push services[web][tags] canary # bracket-path target
```sh
/usr/bin/foo # absolute
../parent/file # relative with ..
.git/HEAD # dot-prefixed directory
2026/report # numeric directory name
./ # current directory
../ # parent directory
./script.sh # dot-slash (explicit relative)
~/src/project # tilde expands to $HOME
cd # bare cd goes to $HOME
Expand All @@ -159,8 +163,7 @@ cd - # previous directory
"literal \$X" # escape $ to prevent expansion
'hello $NAME' # single quotes — literal, no interpolation

# Quote to JOIN text with interpolation — kaish does not paste adjacent
# unquoted tokens into one word (no implicit concatenation):
# Quote the whole word to join text with interpolation:
"$dir/file.txt" # one path
"out-$(date +%s).log" # one filename (text + command substitution)
echo "/tmp/$(id -u).sock" # one argument
Expand All @@ -169,7 +172,7 @@ echo "/tmp/$(id -u).sock" # one argument
echo $dir/file.txt # error — quote "$dir/file.txt"
echo /tmp/$(id -u).sock # error — quote "/tmp/$(id -u).sock"
cmd > $dir/out.txt # error — quote "$dir/out.txt"
# (single-token words like file.txt or v1.2.3 are fine unquoted)
# Literal words like file.txt, v1.2.3, and .git/HEAD need no quotes.
```

## Comments
Expand Down
9 changes: 6 additions & 3 deletions crates/kaish-help/src/fragments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,10 @@ push services[web][tags] canary # bracket-path target
r#"```sh
/usr/bin/foo # absolute
../parent/file # relative with ..
.git/HEAD # dot-prefixed directory
2026/report # numeric directory name
./ # current directory
../ # parent directory
./script.sh # dot-slash (explicit relative)
~/src/project # tilde expands to $HOME
cd # bare cd goes to $HOME
Expand All @@ -458,8 +462,7 @@ cd - # previous directory
"literal \$X" # escape $ to prevent expansion
'hello $NAME' # single quotes — literal, no interpolation

# Quote to JOIN text with interpolation — kaish does not paste adjacent
# unquoted tokens into one word (no implicit concatenation):
# Quote the whole word to join text with interpolation:
"$dir/file.txt" # one path
"out-$(date +%s).log" # one filename (text + command substitution)
echo "/tmp/$(id -u).sock" # one argument
Expand All @@ -468,7 +471,7 @@ echo "/tmp/$(id -u).sock" # one argument
echo $dir/file.txt # error — quote "$dir/file.txt"
echo /tmp/$(id -u).sock # error — quote "/tmp/$(id -u).sock"
cmd > $dir/out.txt # error — quote "$dir/out.txt"
# (single-token words like file.txt or v1.2.3 are fine unquoted)
# Literal words like file.txt, v1.2.3, and .git/HEAD need no quotes.
```"#,
),
syntax_section(
Expand Down
23 changes: 11 additions & 12 deletions crates/kaish-kernel/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,24 +436,21 @@ pub enum Token {
Dot,

/// Tilde path: `~/foo`, `~user/bar` - value includes the full string.
#[regex(r"~[a-zA-Z0-9_./+#\-\u{80}-\u{10FFFF}]+", lex_tilde_path, priority = 3)]
#[regex(r"~[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]+", lex_tilde_path, priority = 3)]
TildePath(String),

/// Bare tilde: `~` alone (expands to $HOME)
#[token("~")]
Tilde,

/// Relative path: `../foo/bar`, bare `src/kaish` (ident containing `/`),
/// or a directory reference with a trailing slash like `dest/`. The
/// trailing-slash form uses `*` (not `+`) after the slash so `dest/`
/// lexes as one token instead of `Ident("dest")` + `Path("/")` — the
/// latter split silently turned `cp a b dest/` into a 4-operand command.
#[regex(r"\.\./[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]+", lex_relative_path, priority = 3)]
#[regex(r"[a-zA-Z_\u{80}-\u{10FFFF}][a-zA-Z0-9_.#\-\u{80}-\u{10FFFF}]*/[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]*", lex_relative_path, priority = 3)]
/// Slash-containing relative word: `.git/HEAD`, `2026/report`, `../`.
/// A slash makes the whole word text, including a numeric first component.
#[regex(r"[a-zA-Z0-9_.\u{80}-\u{10FFFF}][a-zA-Z0-9_.@+#\-\u{80}-\u{10FFFF}]*/[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]*", lex_relative_path, priority = 3)]
RelativePath(String),

/// Dot-slash path: `./foo`, `./script.sh`.
#[regex(r"\./[a-zA-Z0-9_./#\-\u{80}-\u{10FFFF}]+", lex_dot_slash_path, priority = 3)]
/// Dot-slash path: `./`, `./foo`, `./script.sh`.
/// Wins ties with RelativePath to retain the existing token category.
#[regex(r"\./[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]*", lex_dot_slash_path, priority = 4)]
DotSlashPath(String),

/// Dot-prefixed bareword: `.parent`, `.gitignore`, `.foo.bar`.
Expand Down Expand Up @@ -711,7 +708,7 @@ pub enum Token {
/// `date -d @0`), or bare `@`. Mid-word `@` (`user@host`) is handled by
/// `Ident`; this covers the leading-`@` cases that would otherwise be an
/// "unexpected character" lexer error.
#[regex(r"@[a-zA-Z0-9_./@\-\u{80}-\u{10FFFF}]*", lex_slice_word, priority = 3)]
#[regex(r"@[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]*", lex_slice_word, priority = 3)]
AtWord(String),

/// Invalid: float without leading digit (like .5)
Expand All @@ -728,7 +725,7 @@ pub enum Token {
// ═══════════════════════════════════════════════════════════════════

/// Absolute path: `/tmp/out`, `/etc/hosts`, `/tmp/日本語`, etc.
#[regex(r"/[a-zA-Z0-9_./+#\-\u{80}-\u{10FFFF}]*", lex_path)]
#[regex(r"/[a-zA-Z0-9_./@+#\-\u{80}-\u{10FFFF}]*", lex_path)]
Path(String),

// ═══════════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -3183,6 +3180,8 @@ fn is_colon_mergeable(token: &Token) -> bool {
| Token::DottedIdent(_)
| Token::Colon
| Token::Int(_)
| Token::RelativePath(_)
| Token::DotSlashPath(_)
| Token::Path(_)
| Token::Float(_)
)
Expand Down
19 changes: 18 additions & 1 deletion crates/kaish-kernel/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2109,6 +2109,7 @@ where
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()),
Expand All @@ -2125,7 +2126,23 @@ where
// structurally after parsing, where the message is fully under our control
// (verified empirically 2026-06-07).
command_name
.then(args_list_parser())
.map_with(|name, extra| -> (String, Span) { (name, extra.span()) })
// An adjacent `=` belongs to assignment parsing, including its errors.
.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)
})
.map(|(name, _)| name)
.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 {
emitter.emit(Rich::custom(
args_span,
"command name and first argument need a space between them",
));
}
(name, args)
})
.then(redirect_parser(primary_expr_parser()).repeated().collect::<Vec<_>>())
.map(|((name, args), redirects)| Command {
name,
Expand Down
13 changes: 7 additions & 6 deletions crates/kaish-kernel/tests/glued_arg_span_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,15 @@ fn glued_span_text(source: &str) -> &str {
fn colon_glued_path_names_the_whole_word() {
// Used to point at `show` — an innocent, already-fine word.
assert_eq!(
glued_span_text("git show HEAD:training/v9/x.py"),
"HEAD:training/v9/x.py"
glued_span_text("git show HEAD:training/$version/x.py"),
"HEAD:training/$version/x.py"
);
}

#[test]
fn colon_glued_short_path_names_the_whole_word() {
// Used to point at `fetch`.
assert_eq!(glued_span_text("git fetch origin a/b:c"), "a/b:c");
assert_eq!(glued_span_text("git fetch origin a/b:$ref"), "a/b:$ref");
}

#[test]
Expand Down Expand Up @@ -122,6 +122,7 @@ fn purpose_built_diagnoses_are_never_replaced_by_the_paste_message() {
const PASTE: &str = "adjacent words with no space between them are not joined into one";
let cases = [
("cat > $DIR/out.txt", "redirect target"),
("./bin$x", "command name and first argument need a space"),
("x={msg: hello world}", "record value: unexpected word"),
("echo ${x:1:2}", "kaish slices with brackets"),
("echo $(foo", "unterminated command substitution"),
Expand Down Expand Up @@ -162,7 +163,7 @@ fn purpose_built_diagnoses_are_never_replaced_by_the_paste_message() {
#[test]
fn parser_custom_guard_count_is_pinned() {
const PARSER_SOURCE: &str = include_str!("../src/parser.rs");
const EXPECTED: usize = 12;
const EXPECTED: usize = 13;
let found = PARSER_SOURCE.matches("Rich::custom(").count();
assert_eq!(
found, EXPECTED,
Expand Down Expand Up @@ -236,8 +237,8 @@ fn unrelated_failure_keeps_its_own_error_and_span() {
#[test]
fn long_flag_value_fusion_keeps_spaced_flags_out_of_the_run() {
assert_eq!(
glued_span_text("foo --a=1 --b=2 HEAD:x/y"),
"HEAD:x/y",
glued_span_text("foo --a=1 --b=2 HEAD:$dir/y"),
"HEAD:$dir/y",
"spaced --key=value flags must not be mistaken for the pasted word"
);
}
Expand Down
Loading