use rustc_hash::FxHashMap;
use serde_json::Value;
use super::shell;
const RUN_COMMANDS_EXECUTORS: &[&str] = &[
"nx:run-commands",
"@nx/workspace:run-commands",
"@nrwl/workspace:run-commands",
];
const MAX_INLINE_DEPTH: usize = 4;
const INLINE_BYTE_BUDGET: usize = 256 * 1024;
#[must_use]
pub fn run_commands_scripts(project_json: &str, project_root: &str) -> FxHashMap<String, String> {
let Ok(project) = serde_json::from_str::<Value>(project_json) else {
return FxHashMap::default();
};
let Some(targets) = project.get("targets").and_then(Value::as_object) else {
return FxHashMap::default();
};
let mut commands: FxHashMap<String, String> = FxHashMap::default();
for (name, target) in targets {
if !is_run_commands(target) {
continue;
}
let options = target.get("options");
if let Some(command) = target_command(options, options, project_root) {
commands.insert(name.clone(), command);
}
let Some(configurations) = target.get("configurations").and_then(Value::as_object) else {
continue;
};
for (configuration, overrides) in configurations {
if overrides.get("command").is_none() && overrides.get("commands").is_none() {
continue;
}
if let Some(command) = target_command(Some(overrides), options, project_root) {
commands.insert(format!("{name}:{configuration}"), command);
}
}
}
let targets = commands.clone();
let mut budget = INLINE_BYTE_BUDGET;
let mut names: Vec<String> = commands.keys().cloned().collect();
names.sort();
names
.into_iter()
.map(|name| {
let command = inline_targets(&commands[&name], &targets, MAX_INLINE_DEPTH, &mut budget);
(name, command)
})
.collect()
}
fn is_run_commands(target: &Value) -> bool {
target
.get("executor")
.and_then(Value::as_str)
.is_some_and(|executor| RUN_COMMANDS_EXECUTORS.contains(&executor))
}
fn target_command(own: Option<&Value>, base: Option<&Value>, project_root: &str) -> Option<String> {
let cwd = own
.and_then(|options| options.get("cwd"))
.or_else(|| base.and_then(|options| options.get("cwd")))
.and_then(Value::as_str);
if !runs_in_project(cwd, project_root) {
return None;
}
let own = own?;
let parts: Vec<String> = match (own.get("commands"), own.get("command")) {
(Some(Value::Array(commands)), _) => commands.iter().filter_map(command_text).collect(),
(_, Some(Value::String(command))) => vec![command.clone()],
_ => Vec::new(),
};
if parts.is_empty() {
return None;
}
Some(expand_tokens(&parts.join(" && "), project_root))
}
fn command_text(command: &Value) -> Option<String> {
match command {
Value::String(text) => Some(text.clone()),
Value::Object(object) => object.get("command")?.as_str().map(str::to_string),
_ => None,
}
}
fn runs_in_project(cwd: Option<&str>, project_root: &str) -> bool {
let Some(cwd) = cwd else {
return project_root.is_empty();
};
let cwd = cwd
.replace("{projectRoot}", project_root)
.replace("{workspaceRoot}", "");
normalize_dir(&cwd) == normalize_dir(project_root)
}
fn normalize_dir(path: &str) -> String {
path.split('/')
.filter(|part| !part.is_empty() && *part != ".")
.collect::<Vec<_>>()
.join("/")
}
fn expand_tokens(command: &str, project_root: &str) -> String {
let up = if project_root.is_empty() {
".".to_string()
} else {
vec![
"..";
project_root
.split('/')
.filter(|part| !part.is_empty())
.count()
]
.join("/")
};
command
.replace("{projectRoot}", ".")
.replace("{workspaceRoot}", &up)
}
fn inline_targets(
command: &str,
targets: &FxHashMap<String, String>,
depth: usize,
budget: &mut usize,
) -> String {
if depth == 0 {
return command.to_string();
}
shell::split_shell_operators(command)
.into_iter()
.map(|segment| inline_segment(segment, targets, depth, budget))
.collect::<Vec<_>>()
.join(" && ")
}
fn inline_segment(
segment: &str,
targets: &FxHashMap<String, String>,
depth: usize,
budget: &mut usize,
) -> String {
let words = shell::split_words(segment);
let tokens: Vec<&str> = words.iter().map(|word| word.value.as_ref()).collect();
let Some(first) = shell::skip_initial_wrappers(&tokens, 0) else {
return segment.trim().to_string();
};
let nx_at = match tokens.get(first) {
Some(&"nx") => first,
Some(&"npx" | &"pnpx" | &"bunx") if tokens.get(first + 1) == Some(&"nx") => first + 1,
_ => return segment.trim().to_string(),
};
let Some(target) = tokens
.get(nx_at + 1)
.filter(|name| targets.contains_key(**name))
else {
return segment.trim().to_string();
};
let mut rest_at = nx_at + 2;
if tokens.get(rest_at) == Some(&"--") {
rest_at += 1;
}
let head = &segment[..words[first].start];
let runner = &segment[words[first].start..words[nx_at].start];
let tail = words.get(rest_at).map_or("", |word| &segment[word.start..]);
let body = &targets[*target];
if body.len() > *budget {
return segment.trim().to_string();
}
*budget -= body.len();
let inlined = inline_targets(body, targets, depth - 1, budget);
format!("{head}{runner}{inlined} {tail}").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn scripts(json: &str, root: &str) -> Vec<(String, String)> {
let mut found: Vec<(String, String)> =
run_commands_scripts(json, root).into_iter().collect();
found.sort();
found
}
#[test]
fn reads_a_command_and_a_commands_array() {
let json = r#"{"targets":{
"test":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","command":"jest --config=jest.config.mjs"}},
"build":{"executor":"@nx/workspace:run-commands","options":{"cwd":"packages/app","commands":["rimraf dist",{"command":"tsc -p tsconfig.build.json"}]}}
}}"#;
assert_eq!(
scripts(json, "packages/app"),
vec![
(
"build".to_string(),
"rimraf dist && tsc -p tsconfig.build.json".to_string()
),
(
"test".to_string(),
"jest --config=jest.config.mjs".to_string()
),
]
);
}
#[test]
fn skips_other_executors_and_foreign_working_directories() {
let json = r#"{"targets":{
"build":{"executor":"@nx/vite:build","options":{"command":"vite build"}},
"lint":{"executor":"nx:run-commands","options":{"command":"eslint ."}},
"other":{"executor":"nx:run-commands","options":{"cwd":"packages/other","command":"tsx scripts/x.ts"}}
}}"#;
assert!(scripts(json, "packages/app").is_empty());
}
#[test]
fn a_target_without_cwd_runs_from_the_workspace_root() {
let json = r#"{"targets":{"lint":{"executor":"nx:run-commands","options":{"command":"eslint ."}}}}"#;
assert_eq!(
scripts(json, ""),
vec![("lint".to_string(), "eslint .".to_string())]
);
}
#[test]
fn expands_tokens_for_the_project_and_the_workspace() {
let json = r#"{"targets":{"gen":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","command":"tsx {workspaceRoot}/tools/gen.ts {projectRoot}/schema.json"}}}}"#;
assert_eq!(
scripts(json, "packages/app"),
vec![(
"gen".to_string(),
"tsx ../../tools/gen.ts ./schema.json".to_string()
)]
);
}
#[test]
fn a_configuration_can_replace_the_command() {
let json = r#"{"targets":{"test":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","command":"jest"},
"configurations":{"ci":{"command":"jest --config jest.ci.config.ts --ci"}}}}}"#;
let found = scripts(json, "packages/app");
assert!(found.contains(&("test".to_string(), "jest".to_string())));
assert!(found.contains(&(
"test:ci".to_string(),
"jest --config jest.ci.config.ts --ci".to_string()
)));
}
#[test]
fn inlines_a_call_to_another_target_of_the_same_project() {
let json = r#"{"targets":{
"jest":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","command":"jest"}},
"ts-node":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","command":"ts-node"}},
"integration":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","commands":["NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" nx jest --config ./jest-integration.config.ts --logHeapUsage"]}},
"migrate":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","command":"npx nx ts-node -- src/database/run.ts && echo done"}}
}}"#;
let found = scripts(json, "packages/app");
assert!(found.contains(&(
"integration".to_string(),
"NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=6144\" jest --config ./jest-integration.config.ts --logHeapUsage".to_string()
)));
assert!(found.contains(&(
"migrate".to_string(),
"npx ts-node src/database/run.ts && echo done".to_string()
)));
}
#[test]
fn a_target_that_calls_itself_does_not_loop() {
let json = r#"{"targets":{"a":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","command":"nx b"}},
"b":{"executor":"nx:run-commands","options":{"cwd":"{projectRoot}","command":"nx a"}}}}"#;
let _ = scripts(json, "packages/app");
}
#[test]
fn a_fan_out_of_target_calls_stays_bounded() {
let fan = |next: &str| vec![format!("nx {next}"); 60].join(" && ");
let json = format!(
r#"{{"targets":{{
"a":{{"executor":"nx:run-commands","options":{{"cwd":"{{projectRoot}}","command":"{}"}}}},
"b":{{"executor":"nx:run-commands","options":{{"cwd":"{{projectRoot}}","command":"{}"}}}},
"c":{{"executor":"nx:run-commands","options":{{"cwd":"{{projectRoot}}","command":"{}"}}}},
"d":{{"executor":"nx:run-commands","options":{{"cwd":"{{projectRoot}}","command":"{}"}}}},
"e":{{"executor":"nx:run-commands","options":{{"cwd":"{{projectRoot}}","command":"tsx x.ts"}}}}
}}}}"#,
fan("b"),
fan("c"),
fan("d"),
fan("e"),
);
let found = run_commands_scripts(&json, "packages/app");
let total: usize = found.values().map(String::len).sum();
assert!(
total < 4 * 1024 * 1024,
"inlined commands must stay bounded, got {total} bytes"
);
}
#[test]
fn a_parent_directory_is_not_the_project_root() {
let json = r#"{"targets":{"up":{"executor":"nx:run-commands","options":{"cwd":"..","command":"tsx x.ts"}}}}"#;
assert!(scripts(json, "").is_empty(), "`..` leaves the root project");
assert!(scripts(json, "packages/app").is_empty());
}
#[test]
fn a_root_project_may_name_the_workspace_root_as_its_cwd() {
let json = r#"{"targets":{"seed":{"executor":"nx:run-commands","options":{"cwd":"{workspaceRoot}","command":"tsx seed.ts"}}}}"#;
assert_eq!(
scripts(json, ""),
vec![("seed".to_string(), "tsx seed.ts".to_string())]
);
assert!(scripts(json, "packages/app").is_empty());
}
#[test]
fn run_script_targets_are_not_commands() {
let json = r#"{"targets":{"s":{"executor":"nx:run-script","options":{"script":"build"}}}}"#;
assert!(scripts(json, "").is_empty());
}
#[test]
fn malformed_json_gives_no_scripts() {
assert!(scripts("{ not json", "packages/app").is_empty());
}
}