use std::fs;
use std::path::Path;
const SWITCH_FLAGS: &[&str] = &[
"--experimental-transform-types",
"--experimental-strip-types",
"--no-experimental-strip-types",
];
const VALUE_FLAGS: &[(&str, &str)] = &[
("--import", "--import"),
("--require", "--require"),
("-r", "--require"),
("--experimental-loader", "--experimental-loader"),
("--loader", "--experimental-loader"),
("--conditions", "--conditions"),
("-C", "--conditions"),
];
const COMMAND_SEPARATORS: &[&str] = &["&&", "||", ";", "|", "&"];
const TEST_SCRIPT: &str = "test";
pub fn node_test_args(root: &Path) -> Vec<String> {
let Ok(text) = fs::read_to_string(root.join("package.json")) else {
return Vec::new();
};
let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
let Some(scripts) = manifest.get("scripts").and_then(|value| value.as_object()) else {
return Vec::new();
};
let mut names: Vec<&String> = scripts.keys().collect();
names.sort_by_key(|name| (name.as_str() != TEST_SCRIPT, name.as_str()));
names
.into_iter()
.filter_map(|name| scripts.get(name).and_then(serde_json::Value::as_str))
.find_map(script_node_test_args)
.unwrap_or_default()
}
fn script_node_test_args(script: &str) -> Option<Vec<String>> {
split_commands(&split_words(script))
.into_iter()
.find_map(node_test_command_args)
}
fn node_test_command_args(words: &[String]) -> Option<Vec<String>> {
let start = words.iter().position(|word| !is_assignment(word))?;
let (program, args) = words[start..].split_first()?;
if !is_node(program) || !args.iter().any(|arg| arg == "--test") {
return None;
}
Some(module_flags(args))
}
fn module_flags(args: &[String]) -> Vec<String> {
let mut flags = Vec::new();
let mut index = 0;
while index < args.len() {
let arg = args[index].as_str();
index += 1;
if SWITCH_FLAGS.contains(&arg) {
flags.push(arg.to_owned());
continue;
}
let (name, inline) = match arg.split_once('=') {
Some((name, value)) if name.starts_with("--") => (name, Some(value)),
_ => (arg, None),
};
let Some((_, long)) = VALUE_FLAGS.iter().find(|(flag, _)| *flag == name) else {
continue;
};
let value = match inline {
Some(value) => value.to_owned(),
None => {
let Some(value) = args.get(index) else {
break;
};
index += 1;
value.clone()
}
};
flags.push((*long).to_owned());
flags.push(value);
}
flags
}
fn is_assignment(word: &str) -> bool {
word.split_once('=').is_some_and(|(name, _)| {
!name.is_empty()
&& !name.starts_with('-')
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
})
}
fn is_node(program: &str) -> bool {
let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
name == "node" || name.eq_ignore_ascii_case("node.exe")
}
fn split_commands(words: &[String]) -> Vec<&[String]> {
words
.split(|word| COMMAND_SEPARATORS.contains(&word.as_str()))
.filter(|command| !command.is_empty())
.collect()
}
fn split_words(script: &str) -> Vec<String> {
let mut words = Vec::new();
let mut word = String::new();
let mut quote: Option<char> = None;
let mut chars = script.chars().peekable();
while let Some(ch) = chars.next() {
if let Some(open) = quote {
if ch == open {
quote = None;
} else {
word.push(ch);
}
continue;
}
match ch {
'"' | '\'' => quote = Some(ch),
';' | '&' | '|' => {
push_word(&mut words, &mut word);
let mut operator = ch.to_string();
if chars.peek() == Some(&ch) && ch != ';' {
chars.next();
operator.push(ch);
}
words.push(operator);
}
ch if ch.is_whitespace() => push_word(&mut words, &mut word),
ch => word.push(ch),
}
}
push_word(&mut words, &mut word);
words
}
fn push_word(words: &mut Vec<String>, word: &mut String) {
if !word.is_empty() {
words.push(std::mem::take(word));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args(script: &str) -> Option<Vec<String>> {
script_node_test_args(script)
}
fn owned(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
#[test]
fn the_flags_of_a_node_test_command_after_other_commands_are_read() {
assert_eq!(
args("xo && tsc --noEmit && node --experimental-transform-types --test test/test.ts"),
Some(owned(&["--experimental-transform-types"]))
);
}
#[test]
fn value_flags_take_the_next_word_or_the_text_after_the_equals_sign() {
assert_eq!(
args(
"node --import tsx --require=./setup.cjs -r dotenv/config --loader ./l.mjs \
--conditions=development -C custom --test"
),
Some(owned(&[
"--import",
"tsx",
"--require",
"./setup.cjs",
"--require",
"dotenv/config",
"--experimental-loader",
"./l.mjs",
"--conditions",
"development",
"--conditions",
"custom",
]))
);
assert_eq!(
args("node --experimental-strip-types --no-experimental-strip-types --test"),
Some(owned(&[
"--experimental-strip-types",
"--no-experimental-strip-types"
]))
);
}
#[test]
fn other_flags_and_the_test_files_are_ignored() {
assert_eq!(
args("node --test --test-reporter spec --experimental-test-coverage test/a.test.ts"),
Some(Vec::new())
);
assert_eq!(
args("NODE_ENV=test node --import \"./setup file.mjs\" --test"),
Some(owned(&["--import", "./setup file.mjs"]))
);
}
#[test]
fn a_script_without_a_node_test_command_gives_none() {
assert_eq!(args("node --import tsx src/main.ts"), None);
assert_eq!(args("vitest run --test"), None);
assert_eq!(args("echo node --test"), None);
assert_eq!(
args("tsc&&node --experimental-transform-types --test"),
Some(owned(&["--experimental-transform-types"]))
);
}
#[test]
fn the_test_script_comes_first_and_a_missing_manifest_gives_no_flags() {
let dir = tempfile::tempdir().expect("dir");
assert!(node_test_args(dir.path()).is_empty());
fs::write(
dir.path().join("package.json"),
r#"{"scripts":{"a":"node --import ./a.mjs --test","test":"node --experimental-transform-types --test test/test.ts"}}"#,
)
.expect("write");
assert_eq!(
node_test_args(dir.path()),
owned(&["--experimental-transform-types"])
);
fs::write(
dir.path().join("package.json"),
r#"{"scripts":{"b":"node --import ./b.mjs --test","a":"node --import ./a.mjs --test","test":"xo"}}"#,
)
.expect("write");
assert_eq!(node_test_args(dir.path()), owned(&["--import", "./a.mjs"]));
}
}