diff --git a/CHANGELOG.md b/CHANGELOG.md index a66a987e..5a5ca58f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/crates/kaish-help/content/en/syntax.md b/crates/kaish-help/content/en/syntax.md index b5b4e6b4..d8d7ca84 100644 --- a/crates/kaish-help/content/en/syntax.md +++ b/crates/kaish-help/content/en/syntax.md @@ -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 @@ -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 @@ -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 diff --git a/crates/kaish-help/src/fragments.rs b/crates/kaish-help/src/fragments.rs index 82e88a98..9aba435b 100644 --- a/crates/kaish-help/src/fragments.rs +++ b/crates/kaish-help/src/fragments.rs @@ -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 @@ -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 @@ -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( diff --git a/crates/kaish-kernel/src/lexer.rs b/crates/kaish-kernel/src/lexer.rs index c595b8d3..b3a0200a 100644 --- a/crates/kaish-kernel/src/lexer.rs +++ b/crates/kaish-kernel/src/lexer.rs @@ -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`. @@ -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) @@ -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), // ═══════════════════════════════════════════════════════════════════ @@ -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(_) ) diff --git a/crates/kaish-kernel/src/parser.rs b/crates/kaish-kernel/src/parser.rs index 6cc44ec6..055d2107 100644 --- a/crates/kaish-kernel/src/parser.rs +++ b/crates/kaish-kernel/src/parser.rs @@ -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()), @@ -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, 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::>()) .map(|((name, args), redirects)| Command { name, diff --git a/crates/kaish-kernel/tests/glued_arg_span_tests.rs b/crates/kaish-kernel/tests/glued_arg_span_tests.rs index 7b4a7cba..dbfe7898 100644 --- a/crates/kaish-kernel/tests/glued_arg_span_tests.rs +++ b/crates/kaish-kernel/tests/glued_arg_span_tests.rs @@ -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] @@ -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"), @@ -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, @@ -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" ); } diff --git a/crates/kaish-kernel/tests/literal_path_words_tests.rs b/crates/kaish-kernel/tests/literal_path_words_tests.rs new file mode 100644 index 00000000..7d5c98ff --- /dev/null +++ b/crates/kaish-kernel/tests/literal_path_words_tests.rs @@ -0,0 +1,244 @@ +//! Literal paths keep their source spelling across lexer and parser contexts. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use kaish_kernel::ast::{Arg, Command, Expr, Stmt, Value}; +use kaish_kernel::lexer::{Token, tokenize}; +use kaish_kernel::parser::parse; +use kaish_kernel::{Kernel, KernelConfig}; +use rstest::rstest; + +#[rstest] +#[case(".git/HEAD")] +#[case(".git/logs/HEAD")] +#[case(".git/")] +#[case("./")] +#[case("../")] +#[case("2026/report")] +#[case("007/0644")] +#[case("1.0/report")] +#[case(".5/report")] +#[case(".日本/設定")] +#[case("a+b/file")] +#[case("a@b/file")] +#[case("/tmp/a@b")] +#[case("@scope/pkg+tag")] +#[case("repo/.git/HEAD")] +#[case("./.git/HEAD")] +#[case("../HEAD")] +#[case("foo#bar/file")] +fn literal_path_is_one_word_in_each_context(#[case] path: &str) { + let tokens = tokenize(path).unwrap(); + assert_eq!(tokens.len(), 1, "{path}: {tokens:?}"); + assert_eq!(tokens[0].span, 0..path.len()); + let text = match &tokens[0].token { + Token::Path(text) + | Token::RelativePath(text) + | Token::DotSlashPath(text) + | Token::TildePath(text) + | Token::AtWord(text) => text, + other => panic!("expected literal path: {other:?}"), + }; + assert_eq!(text, path); + + for source in [format!("echo {path}"), format!("echo -- {path}")] { + let program = parse(&source).unwrap(); + assert_eq!(program.statements.len(), 1); + let command = only_command(&program.statements); + let arguments: Vec<_> = command + .args + .iter() + .filter(|arg| !matches!(arg, Arg::DoubleDash)) + .collect(); + assert_eq!(arguments.len(), 1, "{source}: {arguments:?}"); + assert_eq!( + arguments[0], + &Arg::Positional(Expr::Literal(Value::String(path.into()))) + ); + } + + let program = parse(&format!("p={path}")).unwrap(); + assert_eq!(program.statements.len(), 1); + let Stmt::Assignment(assignment) = &program.statements[0] else { + panic!("{program:?}") + }; + assert_eq!(assignment.value, Expr::Literal(Value::String(path.into()))); + + let program = parse(&format!("cat <{path}")).unwrap(); + assert_eq!(program.statements.len(), 1); + let command = only_command(&program.statements); + assert!(command.args.is_empty()); + assert_eq!(command.redirects.len(), 1); + assert_eq!( + command.redirects[0].target, + Expr::Literal(Value::String(path.into())) + ); +} + +#[rstest] +#[case("HEAD:src/main.rs")] +#[case("HEAD:.git/config")] +#[case("host:./dir/file")] +#[case("host:../dir/file")] +#[case("HEAD:007/0644")] +#[case("http://host/path")] +fn colon_path_keeps_one_literal_argument(#[case] path: &str) { + let program = parse(&format!("echo {path}")).unwrap(); + assert_eq!(program.statements.len(), 1); + let command = only_command(&program.statements); + assert_eq!( + command.args, + vec![Arg::Positional(Expr::Literal(Value::String(path.into())))] + ); +} + +#[rstest] +#[case(".git/$name")] +#[case("2026/$(echo report)")] +#[case(".git/\"HEAD\"")] +#[case(".git/'HEAD'")] +#[case("$dir/HEAD")] +#[case(".git/$((1+2))")] +fn path_fragments_still_require_quoting(#[case] word: &str) { + for source in [ + format!("echo {word}"), + format!("echo -- {word}"), + format!("cat <{word}"), + ] { + let errors = parse(&source).expect_err(&source); + assert!( + errors.iter().any(|error| error.message.contains("quote")), + "{source}: {errors:?}" + ); + } +} + +#[rstest] +#[case(".git/*")] +#[case("2026/*.txt")] +#[case("1.0/file*")] +#[case(".git/[ab]")] +fn path_globs_remain_patterns(#[case] pattern: &str) { + let program = parse(&format!("echo {pattern}")).unwrap(); + let command = only_command(&program.statements); + assert_eq!( + command.args, + vec![Arg::Positional(Expr::GlobPattern(pattern.into()))] + ); +} + +#[tokio::test] +async fn reported_command_reads_the_intended_files() { + let kernel = Kernel::new(KernelConfig::isolated()).unwrap(); + let setup = kernel.execute(r#"mkdir -p ".git/logs"; echo ref:main >".git/HEAD"; printf 'User-Agent one\nother\nuser-agent two\n' >".git/logs/HEAD""#).await.unwrap(); + assert_eq!(setup.code, 0, "{}", setup.err); + let result = kernel.execute(r#"cat .git/HEAD; grep -c "" .git/logs/HEAD; grep -n "User-Agent\|user-agent" .git/logs/HEAD | tail -n 5"#).await.unwrap(); + assert_eq!(result.code, 0, "{}", result.err); + assert_eq!( + result.text_out(), + "ref:main\n3\n1:User-Agent one\n3:user-agent two\n" + ); +} + +fn only_command(statements: &[Stmt]) -> &Command { + assert_eq!(statements.len(), 1); + match &statements[0] { + Stmt::Command(command) => command, + Stmt::Pipeline(pipeline) if pipeline.stages.len() == 1 => { + pipeline.stages[0].as_command().unwrap() + } + other => panic!("expected one command: {other:?}"), + } +} + +#[rstest] +#[case(".git/hooks/pre-commit")] +#[case("src/bin")] +#[case("../bin")] +#[case("2026/bin")] +#[case("./bin")] +#[case("/tmp/bin")] +fn literal_executable_paths_keep_the_command_name(#[case] path: &str) { + let program = parse(&format!("{path} argument")).unwrap(); + let command = only_command(&program.statements); + assert_eq!(command.name, path); + assert_eq!( + command.args, + vec![Arg::Positional(Expr::Literal(Value::String( + "argument".into() + )))] + ); +} + +#[rstest] +#[case("./bin$x")] +#[case("/tmp/bin$x")] +#[case(".git/hooks/$name")] +#[case("src/$(echo bin)")] +#[case("./bin\"suffix\"")] +#[case("./bin' suffix'")] +fn executable_fragments_do_not_become_arguments(#[case] source: &str) { + assert!( + parse(source).is_err(), + "{source} must not run a different executable" + ); +} + +#[tokio::test] +async fn collections_numbers_and_source_keep_their_meaning() { + let kernel = Kernel::new(KernelConfig::isolated()).unwrap(); + let result = kernel.execute(r#"p=.git/HEAD; xs=[.git/HEAD 007/0644]; r={file:.git/HEAD}; echo "$p"; echo "${xs[1]}"; echo "${r[file]}"; echo $((6 / 2)); echo 007; echo 1.0; echo 'echo sourced' > script; . script"#).await.unwrap(); + assert_eq!(result.code, 0, "{}", result.err); + assert_eq!( + result.text_out(), + ".git/HEAD\n007/0644\n.git/HEAD\n3\n007\n1.0\nsourced\n" + ); +} + +#[tokio::test] +async fn path_words_reach_the_tool_as_exact_arguments() { + let kernel = Kernel::new(KernelConfig::isolated()).unwrap(); + for path in [ + ".git/HEAD", + "007/0644", + "a+b/file", + "a@b/file", + "HEAD:src/main.rs", + ] { + let result = kernel + .execute(&format!("printf '%s\\n' before {path} after")) + .await + .unwrap(); + assert_eq!(result.code, 0, "{}", result.err); + assert_eq!(result.text_out(), format!("before\n{path}\nafter\n")); + } +} + +#[rstest] +#[case("./bin>out")] +#[case("./bin|cat")] +#[case("./bin;echo next")] +#[case("./bin&&echo next")] +#[case("./bin argument")] +#[case(". script")] +fn command_operators_and_spaced_arguments_still_parse(#[case] source: &str) { + assert!(parse(source).is_ok(), "{source}"); +} + +#[test] +fn tilde_path_keeps_at_sign_in_arguments_and_redirects() { + let tokens = tokenize("~/a@b").unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].token, Token::TildePath("~/a@b".into())); + let program = parse("cat ~/a@b").unwrap(); + assert_eq!( + only_command(&program.statements).args, + vec![Arg::Positional(Expr::Literal(Value::String( + "~/a@b".into() + )))] + ); + let program = parse("cat <~/a@b").unwrap(); + assert_eq!( + only_command(&program.statements).redirects[0].target, + Expr::Literal(Value::String("~/a@b".into())) + ); +} diff --git a/docs/LANGUAGE.md b/docs/LANGUAGE.md index 96702afa..56649d5f 100644 --- a/docs/LANGUAGE.md +++ b/docs/LANGUAGE.md @@ -523,9 +523,20 @@ A name containing `#` is not a valid assignment target: `abc#3=5` is error `E018`, because `$abc#3` would not read it back. kaish refuses to create a variable nothing can reference. -### Quote to join — kaish does not paste adjacent tokens +### Quote to join -kaish never concatenates adjacent *unquoted* tokens into one word. `$VAR`, +```sh +cat .git/HEAD # literal path: one argument +cat 2026/report # numeric directory name: still a path +cat "$dir/HEAD" # text joined with an expansion: quote the whole word +``` + +Literal paths need no quotes when their characters are allowed unquoted. +This includes `./`, `../`, dot-prefixed directories, and numeric directory +names. A slash makes the word text: `007/0644` is a path, not a number. +`HEAD:src/main.rs` is one literal argument too. + +kaish does not join separate quoted strings, expansions, or adjacent text. `$VAR`, `$(cmd)`, and globs are each their own word; to build a single word from text plus interpolation, **quote the whole thing**: @@ -542,8 +553,8 @@ echo /tmp/$(id -u).sock # error: quote "/tmp/$(id -u).sock" Rather than silently splat such a word into multiple arguments, kaish rejects it at parse time with a "quote the whole word" hint — fail-loud beats wrong argv. -Single-token words are unaffected: `file.txt`, `a.b.c`, and `v1.2.3` lex as one -token, so they need no quoting. +Literal words such as `file.txt`, `a.b.c`, `v1.2.3`, and `.git/HEAD` +need no quoting. This is the complement of the no-word-splitting rule: kaish neither splits a variable's value **nor** pastes neighbouring words. The "always quote @@ -1353,6 +1364,8 @@ npm install # Absolute and relative paths work directly (no PATH lookup) /bin/echo hello # absolute path ./myscript.sh # relative path +.git/hooks/pre-commit # dot-prefixed directory +../bin/check # parent directory # Virtual bin path runs builtins explicitly /v/bin/echo hello # builtin via virtual path @@ -1382,6 +1395,9 @@ date --json # {"iso":…,"epoch":…,"weekday":…, …} ``` **How it works:** +A command name is literal. Put a space between the command name and its +first argument; attached text cannot become a separate argument. + 1. Kaish parses the command (handling quotes, variables, flags) 2. If the name contains `/`, it's used as a direct path (absolute or relative) 3. `/v/bin/name` dispatches to the builtin `name`