Skip to content

Commit a551135

Browse files
committed
fix(plan): normalize command arrays through existing shell path
Join command array entries with && before planning so string[] shorthands preserve existing shell semantics instead of relying on partial cwd-change detection.
1 parent c900e31 commit a551135

8 files changed

Lines changed: 220 additions & 201 deletions

File tree

crates/vite_shell/src/lib.rs

Lines changed: 2 additions & 158 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use brush_parser::{
44
Parser, ParserOptions,
55
ast::{
66
AndOr, Assignment, AssignmentName, AssignmentValue, Command, CommandPrefix,
7-
CommandPrefixOrSuffixItem, CommandSuffix, CompoundCommand, CompoundList, CompoundListItem,
8-
DoGroupCommand, Pipeline, Program, SeparatorOperator, SimpleCommand, SourceLocation, Word,
7+
CommandPrefixOrSuffixItem, CommandSuffix, CompoundListItem, Pipeline, Program,
8+
SeparatorOperator, SimpleCommand, SourceLocation, Word,
99
},
1010
word::{WordPiece, WordPieceWithSource},
1111
};
@@ -135,139 +135,6 @@ fn pipeline_to_command(pipeline: &Pipeline) -> Option<(TaskParsedCommand, Range<
135135
Some((TaskParsedCommand { envs, program: unquote(program)?, args }, range))
136136
}
137137

138-
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139-
enum CwdChangingCommand {
140-
Cd,
141-
Chdir,
142-
Dot,
143-
Source,
144-
Eval,
145-
}
146-
147-
impl CwdChangingCommand {
148-
fn from_name(command_name: &str) -> Option<Self> {
149-
Some(match command_name {
150-
"cd" => Self::Cd,
151-
"chdir" => Self::Chdir,
152-
"." => Self::Dot,
153-
"source" => Self::Source,
154-
"eval" => Self::Eval,
155-
_ => return None,
156-
})
157-
}
158-
}
159-
160-
fn command_name_may_change_cwd(command_name: &str) -> bool {
161-
CwdChangingCommand::from_name(command_name).is_some()
162-
}
163-
164-
fn wrapper_command_may_change_cwd(suffix: Option<&CommandSuffix>) -> bool {
165-
let Some(CommandSuffix(suffix_items)) = suffix else {
166-
return false;
167-
};
168-
169-
for suffix_item in suffix_items {
170-
let CommandPrefixOrSuffixItem::Word(word) = suffix_item else {
171-
continue;
172-
};
173-
let Some(arg) = unquote(word) else {
174-
return true;
175-
};
176-
if arg == "--" || arg.starts_with('-') {
177-
continue;
178-
}
179-
return command_name_may_change_cwd(arg.as_str());
180-
}
181-
182-
false
183-
}
184-
185-
fn simple_command_may_change_cwd(simple_command: &SimpleCommand) -> bool {
186-
let Some(program) = &simple_command.word_or_name else {
187-
return false;
188-
};
189-
let Some(program) = unquote(program) else {
190-
return true;
191-
};
192-
193-
match program.as_str() {
194-
"command" | "builtin" | "time" => {
195-
wrapper_command_may_change_cwd(simple_command.suffix.as_ref())
196-
}
197-
command_name => command_name_may_change_cwd(command_name),
198-
}
199-
}
200-
201-
fn and_or_list_may_change_cwd(and_or_list: &brush_parser::ast::AndOrList) -> bool {
202-
pipeline_may_change_cwd(&and_or_list.first)
203-
|| and_or_list.additional.iter().any(|and_or| {
204-
let pipeline = match and_or {
205-
AndOr::And(pipeline) | AndOr::Or(pipeline) => pipeline,
206-
};
207-
pipeline_may_change_cwd(pipeline)
208-
})
209-
}
210-
211-
fn compound_list_may_change_cwd(compound_list: &CompoundList) -> bool {
212-
compound_list
213-
.0
214-
.iter()
215-
.any(|CompoundListItem(and_or_list, _)| and_or_list_may_change_cwd(and_or_list))
216-
}
217-
218-
fn do_group_may_change_cwd(do_group: &DoGroupCommand) -> bool {
219-
compound_list_may_change_cwd(&do_group.list)
220-
}
221-
222-
fn compound_command_may_change_cwd(compound_command: &CompoundCommand) -> bool {
223-
match compound_command {
224-
CompoundCommand::Arithmetic(_) | CompoundCommand::Subshell(_) => false,
225-
CompoundCommand::ArithmeticForClause(command) => do_group_may_change_cwd(&command.body),
226-
CompoundCommand::BraceGroup(command) => compound_list_may_change_cwd(&command.list),
227-
CompoundCommand::ForClause(command) => do_group_may_change_cwd(&command.body),
228-
CompoundCommand::CaseClause(command) => command
229-
.cases
230-
.iter()
231-
.any(|case| case.cmd.as_ref().is_some_and(compound_list_may_change_cwd)),
232-
CompoundCommand::IfClause(command) => {
233-
compound_list_may_change_cwd(&command.condition)
234-
|| compound_list_may_change_cwd(&command.then)
235-
|| command.elses.as_ref().is_some_and(|elses| {
236-
elses.iter().any(|else_clause| {
237-
else_clause.condition.as_ref().is_some_and(compound_list_may_change_cwd)
238-
|| compound_list_may_change_cwd(&else_clause.body)
239-
})
240-
})
241-
}
242-
CompoundCommand::WhileClause(command) | CompoundCommand::UntilClause(command) => {
243-
compound_list_may_change_cwd(&command.0) || do_group_may_change_cwd(&command.1)
244-
}
245-
}
246-
}
247-
248-
fn command_may_change_cwd(command: &Command) -> bool {
249-
match command {
250-
Command::Simple(simple_command) => simple_command_may_change_cwd(simple_command),
251-
Command::Compound(compound_command, _) => compound_command_may_change_cwd(compound_command),
252-
Command::Function(function) => compound_command_may_change_cwd(&function.body.0),
253-
Command::ExtendedTest(..) => false,
254-
}
255-
}
256-
257-
fn pipeline_may_change_cwd(pipeline: &Pipeline) -> bool {
258-
pipeline.seq.iter().any(command_may_change_cwd)
259-
}
260-
261-
#[must_use]
262-
pub fn shell_command_may_change_cwd(cmd: &str) -> bool {
263-
let mut parser = Parser::new(cmd.as_bytes(), &PARSER_OPTIONS);
264-
let Ok(Program { complete_commands }) = parser.parse_program() else {
265-
return true;
266-
};
267-
268-
complete_commands.iter().any(compound_list_may_change_cwd)
269-
}
270-
271138
#[must_use]
272139
pub fn try_parse_as_and_list(cmd: &str) -> Option<Vec<(TaskParsedCommand, Range<usize>)>> {
273140
let mut parser = Parser::new(cmd.as_bytes(), &PARSER_OPTIONS);
@@ -295,29 +162,6 @@ pub fn try_parse_as_and_list(cmd: &str) -> Option<Vec<(TaskParsedCommand, Range<
295162
mod tests {
296163
use super::*;
297164

298-
#[test]
299-
fn test_shell_command_may_change_cwd_with_unresolved_arg() {
300-
assert!(shell_command_may_change_cwd(r#"cd "$APP_DIR""#));
301-
assert!(shell_command_may_change_cwd(r#"echo ok && cd "$APP_DIR""#));
302-
assert!(shell_command_may_change_cwd(r#"FOO=bar 'cd' "$APP_DIR""#));
303-
}
304-
305-
#[test]
306-
fn test_shell_command_may_change_cwd_in_compound_commands() {
307-
assert!(shell_command_may_change_cwd("if true; then cd snapshots; fi"));
308-
assert!(shell_command_may_change_cwd("{ cd snapshots; }"));
309-
assert!(shell_command_may_change_cwd("f(){ cd snapshots; }; f"));
310-
assert!(shell_command_may_change_cwd("command cd snapshots"));
311-
assert!(shell_command_may_change_cwd("time cd snapshots"));
312-
}
313-
314-
#[test]
315-
fn test_shell_command_may_change_cwd_ignores_non_current_shell_cd() {
316-
assert!(!shell_command_may_change_cwd(r#"echo "cd $APP_DIR""#));
317-
assert!(!shell_command_may_change_cwd("cdtool $APP_DIR"));
318-
assert!(!shell_command_may_change_cwd("(cd snapshots)"));
319-
}
320-
321165
#[test]
322166
fn test_parse_single_command() {
323167
let source = r"A=B hello world";

crates/vite_task_plan/src/plan.rs

Lines changed: 28 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use futures_util::FutureExt;
1616
use petgraph::Direction;
1717
use rustc_hash::FxHashMap;
1818
use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf, relative::InvalidPathDataError};
19-
use vite_shell::{TaskParsedCommand, shell_command_may_change_cwd, try_parse_as_and_list};
19+
use vite_shell::{TaskParsedCommand, try_parse_as_and_list};
2020
use vite_str::Str;
2121
use vite_task_graph::{
2222
TaskNodeIndex, TaskSource,
@@ -87,10 +87,9 @@ enum PlannedCommand {
8787
}
8888

8989
#[expect(clippy::result_large_err, reason = "Error is large for diagnostics")]
90-
fn planned_commands(command: &TaskCommand) -> Result<Vec<PlannedCommand>, Error> {
91-
let mut array_len = None;
92-
let snippets: Box<dyn Iterator<Item = &Str> + '_> = match command {
93-
TaskCommand::String(command) => Box::new(std::iter::once(command)),
90+
fn command_source(command: &TaskCommand) -> Result<Str, Error> {
91+
match command {
92+
TaskCommand::String(command) => Ok(command.clone()),
9493
TaskCommand::Array(commands) => {
9594
if commands.is_empty() {
9695
return Err(Error::InvalidTaskCommand("command array must not be empty".into()));
@@ -100,36 +99,34 @@ fn planned_commands(command: &TaskCommand) -> Result<Vec<PlannedCommand>, Error>
10099
"command array entries must not be empty".into(),
101100
));
102101
}
103-
array_len = Some(commands.len());
104-
Box::new(commands.iter())
105-
}
106-
};
107102

108-
let mut planned = Vec::new();
109-
for (snippet_index, snippet) in snippets.enumerate() {
110-
if let Some(parsed) = try_parse_as_and_list(snippet.as_str()) {
111-
for (and_item, range) in parsed {
112-
planned.push(PlannedCommand::Parsed {
113-
display: Str::from(&snippet.as_str()[range.clone()]),
114-
and_item,
115-
stack_frame: range,
116-
});
117-
}
118-
} else {
119-
// A shell fallback runs in a child shell, so any cwd change inside it cannot be
120-
// reflected in the planner's cwd for following array entries.
121-
if array_len.is_some_and(|len| snippet_index + 1 < len)
122-
&& shell_command_may_change_cwd(snippet.as_str())
123-
{
124-
return Err(Error::InvalidTaskCommand(
125-
"command array entries that may change directory in a shell must be the final entry"
126-
.into(),
127-
));
103+
let mut source = Str::default();
104+
for (index, command) in commands.iter().enumerate() {
105+
if index > 0 {
106+
source.push_str(" && ");
107+
}
108+
source.push_str(command.as_str());
128109
}
129-
planned.push(PlannedCommand::Shell(snippet.clone()));
110+
Ok(source)
130111
}
131112
}
132-
Ok(planned)
113+
}
114+
115+
#[expect(clippy::result_large_err, reason = "Error is large for diagnostics")]
116+
fn planned_commands(command: &TaskCommand) -> Result<Vec<PlannedCommand>, Error> {
117+
let source = command_source(command)?;
118+
if let Some(parsed) = try_parse_as_and_list(source.as_str()) {
119+
Ok(parsed
120+
.into_iter()
121+
.map(|(and_item, range)| PlannedCommand::Parsed {
122+
display: Str::from(&source.as_str()[range.clone()]),
123+
and_item,
124+
stack_frame: range,
125+
})
126+
.collect())
127+
} else {
128+
Ok(vec![PlannedCommand::Shell(source)])
129+
}
133130
}
134131

135132
/// - `with_hooks`: whether to look up `preX`/`postX` lifecycle hooks for this task.

crates/vite_task_plan/tests/plan_snapshots/fixtures/task_command_shorthands/snapshots.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ name = "array_cd_shell"
2323
args = ["run", "array_cd_shell"]
2424

2525
[[plan]]
26-
name = "array_shell_cd_before_next_error"
26+
name = "array_shell_cd_before_next"
2727
args = ["run", "array_shell_cd_before_next"]
2828

2929
[[plan]]
30-
name = "array_compound_cd_before_next_error"
30+
name = "array_compound_cd_before_next"
3131
args = ["run", "array_compound_cd_before_next"]
3232

3333
[[plan]]

crates/vite_task_plan/tests/plan_snapshots/fixtures/task_command_shorthands/snapshots/query_array_cd_shell.jsonc

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,24 @@
2020
"task_name": "array_cd_shell",
2121
"package_path": "<workspace>/"
2222
},
23-
"command": "echo $PWD",
24-
"and_item_index": 1,
25-
"cwd": "<workspace>/snapshots"
23+
"command": "cd snapshots && echo $PWD",
24+
"and_item_index": null,
25+
"cwd": "<workspace>/"
2626
},
2727
"kind": {
2828
"Leaf": {
2929
"Spawn": {
3030
"cache_metadata": {
3131
"spawn_fingerprint": {
32-
"cwd": "snapshots",
32+
"cwd": "",
3333
"program_fingerprint": {
3434
"OutsideWorkspace": {
3535
"program_name": "<os_shell_name>"
3636
}
3737
},
3838
"args": [
3939
"<os_shell_args>",
40-
"echo $PWD"
40+
"cd snapshots && echo $PWD"
4141
],
4242
"env_fingerprints": {
4343
"fingerprinted_envs": {},
@@ -49,7 +49,7 @@
4949
"execution_cache_key": {
5050
"UserTask": {
5151
"task_name": "array_cd_shell",
52-
"and_item_index": 1,
52+
"and_item_index": 0,
5353
"extra_args": [],
5454
"package_path": ""
5555
}
@@ -69,13 +69,13 @@
6969
"program_path": "<os_shell_path>",
7070
"args": [
7171
"<os_shell_args>",
72-
"echo $PWD"
72+
"cd snapshots && echo $PWD"
7373
],
7474
"all_envs": {
7575
"FORCE_COLOR": "1",
7676
"PATH": "<workspace>/node_modules/.bin:<tools>"
7777
},
78-
"cwd": "<workspace>/snapshots"
78+
"cwd": "<workspace>/"
7979
}
8080
}
8181
}

0 commit comments

Comments
 (0)