From 0e5c653973cf970f803ff0d9cb71e22e586e65cd Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Fri, 16 Oct 2026 21:40:57 +0900 Subject: [PATCH 1/5] test(plan): cover task commands with multi-byte characters Command spans from the shell parser are sliced out of the script to display each command. These cases put multi-byte UTF-8 characters before span boundaries, where `vp run` currently panics or truncates the displayed command (#719). Co-authored-by: Claude Opus 5.5 --- .../fixtures/non_ascii_commands/package.json | 10 +++++++ .../non_ascii_commands/snapshots.toml | 27 +++++++++++++++++++ crates/vt_shell/src/lib.rs | 11 ++++++++ 3 files changed, 48 insertions(+) create mode 100644 crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/package.json create mode 100644 crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots.toml diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/package.json b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/package.json new file mode 100644 index 000000000..68023b058 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/package.json @@ -0,0 +1,10 @@ +{ + "name": "non-ascii-commands", + "private": true, + "scripts": { + "greet": "echo ああ", + "greet-long": "echo あああ", + "greet-and-done": "echo 你好 && FOO=é echo 🎉 done", + "greet-and-missing": "echo こんにちは && vt run missing" + } +} diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots.toml b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots.toml new file mode 100644 index 000000000..b8bdf7d13 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots.toml @@ -0,0 +1,27 @@ +# Tests that commands containing multi-byte UTF-8 characters are displayed in full. +# A character position used as a byte position either lands inside a character +# (`greet`) or truncates the command (`greet-long`). + +[fields] +key = true +execution_item_display = { command = true } + +[[plan]] +name = "command_ending_with_multi_byte_characters" +args = ["run", "greet"] + +[[plan]] +name = "command_ending_on_a_character_boundary" +args = ["run", "greet-long"] + +[[plan]] +name = "and_list_after_multi_byte_characters" +args = ["run", "greet-and-done"] + +[[plan]] +name = "extra_args_after_multi_byte_characters" +args = ["run", "greet", "🎉"] + +[[plan]] +name = "nested_error_after_multi_byte_characters" +args = ["run", "greet-and-missing"] diff --git a/crates/vt_shell/src/lib.rs b/crates/vt_shell/src/lib.rs index 57abfeae4..84d66bc26 100644 --- a/crates/vt_shell/src/lib.rs +++ b/crates/vt_shell/src/lib.rs @@ -216,6 +216,17 @@ mod tests { ); } + #[test] + fn command_ranges_are_byte_ranges_after_multi_byte_characters() { + // `あ` is 3 bytes, `é` is 2 bytes, and `🎉` is 4 bytes in UTF-8, so + // character and byte positions diverge after each of them. + let source = "echo ああ && FOO=é echo 🎉 && echo done"; + let list = try_parse_as_and_list(source).unwrap(); + + let substrs = list.iter().map(|(_, range)| source.get(range.clone())).collect::>(); + assert_eq!(substrs, [Some("echo ああ"), Some("FOO=é echo 🎉"), Some("echo done")]); + } + #[test] fn test_task_parsed_command_stable_env_ordering() { // Test that environment variables maintain stable ordering From f020adcf2b0e6fc8fdb25c586f03c36c02eb0513 Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Fri, 16 Oct 2026 21:45:41 +0900 Subject: [PATCH 2/5] fix(plan): slice task commands by byte offsets `brush_parser` reports source positions as character indices, but the command spans were used as byte ranges into the script. A script with a multi-byte UTF-8 character before a span boundary made `vp run` panic before running it, or display a truncated command. Convert the parser's character indices to byte indices before returning the spans. Fixes #719 Co-authored-by: Claude Opus 5.5 --- ...and_list_after_multi_byte_characters.jsonc | 25 +++ ...mmand_ending_on_a_character_boundary.jsonc | 20 +++ ...nd_ending_with_multi_byte_characters.jsonc | 20 +++ ...tra_args_after_multi_byte_characters.jsonc | 20 +++ ...ted_error_after_multi_byte_characters.snap | 1 + .../snapshots/task_graph.md | 170 ++++++++++++++++++ crates/vt_shell/src/lib.rs | 27 ++- 7 files changed, 279 insertions(+), 4 deletions(-) create mode 100644 crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_and_list_after_multi_byte_characters.jsonc create mode 100644 crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_command_ending_on_a_character_boundary.jsonc create mode 100644 crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_command_ending_with_multi_byte_characters.jsonc create mode 100644 crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_extra_args_after_multi_byte_characters.jsonc create mode 100644 crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_nested_error_after_multi_byte_characters.snap create mode 100644 crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/task_graph.md diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_and_list_after_multi_byte_characters.jsonc b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_and_list_after_multi_byte_characters.jsonc new file mode 100644 index 000000000..ff9b6a081 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_and_list_after_multi_byte_characters.jsonc @@ -0,0 +1,25 @@ +// run greet-and-done +{ + "graph": [ + { + "key": [ + "/", + "greet-and-done" + ], + "node": { + "items": [ + { + "execution_item_display": { + "command": "echo 你好" + } + }, + { + "execution_item_display": { + "command": "FOO=é echo 🎉 done" + } + } + ] + } + } + ] +} diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_command_ending_on_a_character_boundary.jsonc b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_command_ending_on_a_character_boundary.jsonc new file mode 100644 index 000000000..64a9c50b7 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_command_ending_on_a_character_boundary.jsonc @@ -0,0 +1,20 @@ +// run greet-long +{ + "graph": [ + { + "key": [ + "/", + "greet-long" + ], + "node": { + "items": [ + { + "execution_item_display": { + "command": "echo あああ" + } + } + ] + } + } + ] +} diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_command_ending_with_multi_byte_characters.jsonc b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_command_ending_with_multi_byte_characters.jsonc new file mode 100644 index 000000000..83b1e8f14 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_command_ending_with_multi_byte_characters.jsonc @@ -0,0 +1,20 @@ +// run greet +{ + "graph": [ + { + "key": [ + "/", + "greet" + ], + "node": { + "items": [ + { + "execution_item_display": { + "command": "echo ああ" + } + } + ] + } + } + ] +} diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_extra_args_after_multi_byte_characters.jsonc b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_extra_args_after_multi_byte_characters.jsonc new file mode 100644 index 000000000..924e2ac27 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_extra_args_after_multi_byte_characters.jsonc @@ -0,0 +1,20 @@ +// run greet 🎉 +{ + "graph": [ + { + "key": [ + "/", + "greet" + ], + "node": { + "items": [ + { + "execution_item_display": { + "command": "echo ああ '🎉'" + } + } + ] + } + } + ] +} diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_nested_error_after_multi_byte_characters.snap b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_nested_error_after_multi_byte_characters.snap new file mode 100644 index 000000000..428376554 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/query_nested_error_after_multi_byte_characters.snap @@ -0,0 +1 @@ +Failed to plan tasks from `vt run missing` in task non-ascii-commands#greet-and-missing: Task "missing" not found \ No newline at end of file diff --git a/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/task_graph.md b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/task_graph.md new file mode 100644 index 000000000..6e0a271e2 --- /dev/null +++ b/crates/vt_plan/tests/plan_snapshots/fixtures/non_ascii_commands/snapshots/task_graph.md @@ -0,0 +1,170 @@ +# task graph + +```mermaid +flowchart TD + task_0["/#greet"] + task_1["/#greet-and-done"] + task_2["/#greet-and-missing"] + task_3["/#greet-long"] +``` + +## `/#greet` + +```json +{ + "task_display": { + "package_name": "non-ascii-commands", + "task_name": "greet", + "package_path": "/" + }, + "resolved_config": { + "commands": [ + "echo ああ" + ], + "resolved_options": { + "cwd": "/", + "cache_config": { + "remote_cache_allowed": true, + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + +## `/#greet-and-done` + +```json +{ + "task_display": { + "package_name": "non-ascii-commands", + "task_name": "greet-and-done", + "package_path": "/" + }, + "resolved_config": { + "commands": [ + "echo 你好 && FOO=é echo 🎉 done" + ], + "resolved_options": { + "cwd": "/", + "cache_config": { + "remote_cache_allowed": true, + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + +## `/#greet-and-missing` + +```json +{ + "task_display": { + "package_name": "non-ascii-commands", + "task_name": "greet-and-missing", + "package_path": "/" + }, + "resolved_config": { + "commands": [ + "echo こんにちは && vt run missing" + ], + "resolved_options": { + "cwd": "/", + "cache_config": { + "remote_cache_allowed": true, + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + +## `/#greet-long` + +```json +{ + "task_display": { + "package_name": "non-ascii-commands", + "task_name": "greet-long", + "package_path": "/" + }, + "resolved_config": { + "commands": [ + "echo あああ" + ], + "resolved_options": { + "cwd": "/", + "cache_config": { + "remote_cache_allowed": true, + "env_config": { + "fingerprinted_envs": [], + "untracked_env": [ + "" + ] + }, + "input_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + }, + "output_config": { + "includes_auto": true, + "positive_globs": [], + "negative_globs": [] + } + } + } + }, + "source": "PackageJsonScript" +} +``` + diff --git a/crates/vt_shell/src/lib.rs b/crates/vt_shell/src/lib.rs index 84d66bc26..5969cf171 100644 --- a/crates/vt_shell/src/lib.rs +++ b/crates/vt_shell/src/lib.rs @@ -92,9 +92,25 @@ fn flatten_pieces(pieces: &[WordPieceWithSource], result: &mut Str) -> Option<() Some(()) } -fn pipeline_to_command(pipeline: &Pipeline) -> Option<(TaskParsedCommand, Range)> { +/// Converts a character index reported by `brush_parser` into a byte index +/// into `source`. +/// +/// `brush_parser::SourcePosition::index` counts characters, while the spans +/// returned by [`try_parse_as_and_list`] slice the `&str` by bytes. Remove +/// this conversion once `brush_parser` reports byte offsets +/// (); that change renames +/// `index`, so it fails to compile here instead of converting twice. +fn char_index_to_byte_index(source: &str, char_index: usize) -> Option { + source.char_indices().map(|(byte_index, _)| byte_index).chain([source.len()]).nth(char_index) +} + +fn pipeline_to_command( + source: &str, + pipeline: &Pipeline, +) -> Option<(TaskParsedCommand, Range)> { let location = pipeline.location()?; - let range = location.start.index..location.end.index; + let range = char_index_to_byte_index(source, location.start.index)? + ..char_index_to_byte_index(source, location.end.index)?; let Pipeline { timed: None, bang: false, seq } = pipeline else { return None; @@ -137,6 +153,9 @@ fn pipeline_to_command(pipeline: &Pipeline) -> Option<(TaskParsedCommand, Range< Some((TaskParsedCommand { envs, program: unquote(program)?, args }, range)) } +/// Parses `cmd` as simple commands joined by `&&`. +/// +/// Each command is returned with its byte range in `cmd`. #[must_use] pub fn try_parse_as_and_list(cmd: &str) -> Option)>> { let mut parser = Parser::new(cmd.as_bytes(), &PARSER_OPTIONS); @@ -150,12 +169,12 @@ pub fn try_parse_as_and_list(cmd: &str) -> Option)>::new(); - commands.push(pipeline_to_command(&and_or_list.first)?); + commands.push(pipeline_to_command(cmd, &and_or_list.first)?); for and_or in &and_or_list.additional { let AndOr::And(pipeline) = and_or else { return None; }; - commands.push(pipeline_to_command(pipeline)?); + commands.push(pipeline_to_command(cmd, pipeline)?); } Some(commands) } From ebc2d09cc2f7def9d80e6e6757f5a2f483c888db Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Fri, 16 Oct 2026 21:46:00 +0900 Subject: [PATCH 3/5] docs(changelog): note the non-ASCII script fix Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index edd3132ec..e68063c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** `vp run` no longer panics before running a script that contains non-ASCII characters, such as `echo こんにちは`, and no longer cuts off such commands where it displays them ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)). From 0cfc5ed7dd374b4f58f5e0306006a083536b8d1e Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Sun, 18 Oct 2026 01:13:59 +0900 Subject: [PATCH 4/5] docs(plan): name the test that guards the brush index conversion The comment relied on an open brush PR that renames `index`. If a brush release switches to byte offsets without the rename, the unit test for multi-byte characters is what detects the double conversion. Co-authored-by: Claude Opus 5.5 --- crates/vt_shell/src/lib.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/vt_shell/src/lib.rs b/crates/vt_shell/src/lib.rs index 5969cf171..2b7d38d0e 100644 --- a/crates/vt_shell/src/lib.rs +++ b/crates/vt_shell/src/lib.rs @@ -96,10 +96,13 @@ fn flatten_pieces(pieces: &[WordPieceWithSource], result: &mut Str) -> Option<() /// into `source`. /// /// `brush_parser::SourcePosition::index` counts characters, while the spans -/// returned by [`try_parse_as_and_list`] slice the `&str` by bytes. Remove -/// this conversion once `brush_parser` reports byte offsets -/// (); that change renames -/// `index`, so it fails to compile here instead of converting twice. +/// returned by [`try_parse_as_and_list`] slice the `&str` by bytes. +/// +/// Remove this conversion when `brush_parser` reports byte offsets. The open +/// plans this change and also +/// renames `index`, which would fail to compile here. If a release changes +/// the unit without the rename, this conversion applies twice, and the test +/// `command_ranges_are_byte_ranges_after_multi_byte_characters` fails. fn char_index_to_byte_index(source: &str, char_index: usize) -> Option { source.char_indices().map(|(byte_index, _)| byte_index).chain([source.len()]).nth(char_index) } From e2d28da197a8a8f859cbd003204cf27db03440af Mon Sep 17 00:00:00 2001 From: Yusuke Tanaka Date: Sun, 18 Oct 2026 13:58:57 +0900 Subject: [PATCH 5/5] docs(changelog): link the non-ASCII script fix to #861 Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e68063c47..b342fb6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** `vp run` no longer panics before running a script that contains non-ASCII characters, such as `echo こんにちは`, and no longer cuts off such commands where it displays them ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** `vp run` no longer panics before running a script that contains non-ASCII characters, such as `echo こんにちは`, and no longer cuts off such commands where it displays them ([#861](https://github.com/voidzero-dev/vite-task/pull/861)). - **Fixed** On Linux, concurrent system calls in tracked programs, such as `pnpm dlx`, no longer cause a stack overflow ([#854](https://github.com/voidzero-dev/vite-task/pull/854)). - **Fixed** Cached tasks can now run scripts whose shebang names an interpreter path longer than 126 bytes. Before, the path was cut short and the script failed to start or ran the wrong program. On macOS, text after a `#` in the shebang line is now ignored, as it is when the script runs on its own ([#788](https://github.com/voidzero-dev/vite-task/pull/788)). - **Fixed** On macOS, tasks with automatic file-access tracking can now run Node.js single executable applications, such as pnpm 11's standalone `pnpm` binary, which previously aborted with `Assertion failed: (magic) == (kMagic)`, and `deno compile` outputs, which previously failed with `Did not find magic bytes` ([#840](https://github.com/voidzero-dev/vite-task/pull/840)).