Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 deletions crates/vite_migration/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,7 @@ ast-grep-config = { workspace = true }
ast-grep-core = { workspace = true }
ast-grep-language = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }
tokio = { workspace = true, features = ["fs"] }
vite_error = { workspace = true }

[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }

[lints]
workspace = true
2 changes: 1 addition & 1 deletion crates/vite_migration/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
mod package;

pub use package::rewrite_package_json_scripts;
pub use package::rewrite_scripts;
269 changes: 199 additions & 70 deletions crates/vite_migration/src/package.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
use std::path::Path;

use ast_grep_config::{GlobalRules, RuleConfig, from_yaml_string};
use ast_grep_core::replacer::Replacer;
use ast_grep_language::{LanguageExt, SupportLang};
use serde_json::Value;
use tokio::fs;
use serde_json::{Map, Value};
use vite_error::Error;

/// load script rules from yaml file
async fn load_ast_grep_rules(yaml_path: &Path) -> Result<Vec<RuleConfig<SupportLang>>, Error> {
let yaml = fs::read_to_string(yaml_path).await?;
fn load_ast_grep_rules(yaml: &str) -> Result<Vec<RuleConfig<SupportLang>>, Error> {
let globals = GlobalRules::default();
let rules: Vec<RuleConfig<SupportLang>> = from_yaml_string::<SupportLang>(&yaml, &globals)?;
Ok(rules)
}

// Marker to replace "cross-env " before ast-grep processing
// Using a fake env var assignment that won't match our rules
const CROSS_ENV_MARKER: &str = "__CROSS_ENV__=1 ";
const CROSS_ENV_REPLACEMENT: &str = "cross-env ";

/// rewrite a single script command string using rules
fn rewrite_script(script: &str, rules: &[RuleConfig<SupportLang>]) -> String {
// current stores the current script text, and update it when the rule matches
let mut current = script.to_string();
// Only handle cross-env replacement if it's present in the script
let has_cross_env = script.contains(CROSS_ENV_REPLACEMENT);

// Step 1: Replace "cross-env " with marker so ast-grep can see the actual commands
let mut current = if has_cross_env {
script.replace(CROSS_ENV_REPLACEMENT, CROSS_ENV_MARKER)
} else {
script.to_string()
};

// Step 2: Process with ast-grep
for rule in rules {
// only handle bash rules
if rule.language != SupportLang::Bash {
Expand Down Expand Up @@ -56,102 +65,100 @@ fn rewrite_script(script: &str, rules: &[RuleConfig<SupportLang>]) -> String {
}
}

current
// Step 3: Replace marker back with "cross-env " (only if we replaced it)
if has_cross_env { current.replace(CROSS_ENV_MARKER, CROSS_ENV_REPLACEMENT) } else { current }
}

/// rewrite scripts in package.json using rules from rules_yaml_path
pub async fn rewrite_package_json_scripts(
package_json_path: &Path,
rules_yaml_path: &Path,
) -> Result<bool, Error> {
let content = fs::read_to_string(package_json_path).await?;
let mut json: Value = serde_json::from_str(&content)?;
let rules = load_ast_grep_rules(rules_yaml_path).await?;
/// rewrite scripts json content using rules from rules_yaml
pub fn rewrite_scripts(scripts_json: &str, rules_yaml: &str) -> Result<Option<String>, Error> {
let mut scripts: Map<String, Value> = serde_json::from_str(scripts_json)?;
let rules = load_ast_grep_rules(rules_yaml)?;

let mut updated = false;
// get scripts field (object)
if let Some(scripts) = json.get_mut("scripts").and_then(Value::as_object_mut) {
let keys: Vec<String> = scripts.keys().cloned().collect();
for key in keys {
if let Some(Value::String(script)) = scripts.get(&key) {
let new_script = rewrite_script(script, &rules);
if new_script != *script {

for value in scripts.values_mut() {
if value.is_array() {
// lint-staged scripts can be an array of strings
// https://github.com/lint-staged/lint-staged?tab=readme-ov-file#packagejson-example
if let Some(sub_scripts) = value.as_array_mut() {
for sub_script in sub_scripts.iter_mut() {
if sub_script.is_string()
&& let Some(raw_script) = sub_script.as_str()
{
let new_script = rewrite_script(raw_script, &rules);
if new_script != raw_script {
updated = true;
*sub_script = Value::String(new_script);
}
}
}
}
} else if value.is_string() {
if let Some(raw_script) = value.as_str() {
let new_script = rewrite_script(raw_script, &rules);
if new_script != raw_script {
updated = true;
scripts.insert(key.clone(), Value::String(new_script));
*value = Value::String(new_script);
}
}
}
}

if updated {
// write back to file
let new_content = serde_json::to_string_pretty(&json)?;
fs::write(package_json_path, new_content).await?;
let new_content = serde_json::to_string_pretty(&scripts)?;
Ok(Some(new_content))
} else {
Ok(None)
}

Ok(updated)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_rewrite_script() {
let yaml = r#"
# vite => vite dev
const RULES_YAML: &str = r#"
# vite => vite dev (handles all cases: with/without env var prefix and flag args)
# Match command_name to preserve env var prefix and arguments
# Excludes subcommands like "vite build", "vite test", etc.
---
id: replace-vite-alone
id: replace-vite
language: bash
rule:
kind: command
has:
kind: command_name
regex: '^vite$'
not:
has:
kind: word
field: argument
kind: command_name
regex: '^vite$'
inside:
kind: command
not:
# ignore non-flag arguments (subcommands like build, test, etc.)
regex: 'vite\s+[^-]'
fix: vite dev

# vite [OPTIONS] => vite dev [OPTIONS]
# oxlint => vite lint (handles all cases: with/without env var prefix and args)
# Match command_name to preserve env var prefix and arguments
---
id: replace-vite-with-args
id: replace-oxlint
language: bash
severity: info
rule:
pattern: vite $$$ARGS
not:
# ignore non-flag arguments
regex: 'vite\s+[^-]'
fix: vite dev $$$ARGS

# oxlint => vite lint
---
id: replace-oxlint-alone
language: bash
rule:
kind: command
has:
kind: command_name
regex: '^oxlint$'
not:
has:
kind: word
field: argument
kind: command_name
regex: '^oxlint$'
fix: vite lint

# oxlint [OPTIONS] => vite lint [OPTIONS]
# vitest => vite test
---
id: replace-oxlint-with-args
id: replace-vitest
language: bash
rule:
pattern: oxlint $$$ARGS
fix: vite lint $$$ARGS
"#;
kind: command_name
regex: '^vitest$'
fix: vite test
"#;

#[test]
fn test_rewrite_script() {
let globals = GlobalRules::default();
let rules: Vec<RuleConfig<SupportLang>> =
from_yaml_string::<SupportLang>(&yaml, &globals).unwrap();
from_yaml_string::<SupportLang>(&RULES_YAML, &globals).unwrap();
// vite commands
assert_eq!(rewrite_script("vite", &rules), "vite dev");
assert_eq!(rewrite_script("vite dev", &rules), "vite dev");
Expand Down Expand Up @@ -217,6 +224,21 @@ fix: vite lint $$$ARGS
),
"if [ -f file.txt ]; then vite dev --port 3000 && npm run lint; fi"
);
// env variable commands
assert_eq!(
rewrite_script("NODE_ENV=test VITE_CJS_IGNORE_WARNING=true vite", &rules),
"NODE_ENV=test VITE_CJS_IGNORE_WARNING=true vite dev"
);
assert_eq!(
rewrite_script("FOO=bar vite --port 3000", &rules),
"FOO=bar vite dev --port 3000"
);
// env variable with oxlint commands
assert_eq!(rewrite_script("DEBUG=1 oxlint", &rules), "DEBUG=1 vite lint");
assert_eq!(
rewrite_script("NODE_ENV=test oxlint --type-aware", &rules),
"NODE_ENV=test vite lint --type-aware"
);
// oxlint commands
assert_eq!(rewrite_script("oxlint", &rules), "vite lint");
assert_eq!(rewrite_script("oxlint --type-aware", &rules), "vite lint --type-aware");
Expand All @@ -230,4 +252,111 @@ fix: vite lint $$$ARGS
"npm run type-check && vite lint --type-aware"
);
}

#[test]
fn test_rewrite_package_json_scripts_success() {
let package_json_scripts = r#"
{
"dev": "vite"
}
"#;
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
.expect("failed to rewrite package.json scripts");
assert!(updated.is_some());
assert_eq!(
updated.unwrap(),
r#"
{
"dev": "vite dev"
}
"#
.trim()
);
}

#[test]
fn test_rewrite_package_json_scripts_with_env_variable_success() {
let package_json_scripts = r#"
{
"dev:cjs": "VITE_CJS_IGNORE_WARNING=true vite",
"lint": "VITE_CJS_IGNORE_WARNING=true FOO=bar oxlint --fix"
}
"#;
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
.expect("failed to rewrite package.json scripts");
assert!(updated.is_some());
assert_eq!(
updated.unwrap(),
r#"
{
"dev:cjs": "VITE_CJS_IGNORE_WARNING=true vite dev",
"lint": "VITE_CJS_IGNORE_WARNING=true FOO=bar vite lint --fix"
}
"#
.trim()
);
}

#[test]
fn test_rewrite_package_json_scripts_using_cross_env() {
let package_json_scripts = r#"
{
"dev:cjs": "cross-env VITE_CJS_IGNORE_WARNING=true vite && cross-env FOO=bar vitest run",
"lint": "cross-env VITE_CJS_IGNORE_WARNING=true FOO=bar oxlint --fix",
"test": "vite build && cross-env FOO=bar vitest run && echo ' cross-env test done ' || echo ' cross-env test failed '"
}
"#;
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
.expect("failed to rewrite package.json scripts");
assert!(updated.is_some());
assert_eq!(
updated.unwrap(),
r#"
{
"dev:cjs": "cross-env VITE_CJS_IGNORE_WARNING=true vite dev && cross-env FOO=bar vite test run",
"lint": "cross-env VITE_CJS_IGNORE_WARNING=true FOO=bar vite lint --fix",
"test": "vite build && cross-env FOO=bar vite test run && echo ' cross-env test done ' || echo ' cross-env test failed '"
}
"#
.trim()
);
}

#[test]
fn test_rewrite_package_json_scripts_lint_staged() {
let package_json_scripts = r#"
{
"*.js": ["oxlint --fix --type-aware", "oxfmt --fix"],
"*.ts": "oxfmt --fix"
}
"#;
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
.expect("failed to rewrite package.json scripts");
assert!(updated.is_some());
assert_eq!(
updated.unwrap(),
r#"
{
"*.js": [
"vite lint --fix --type-aware",
"oxfmt --fix"
],
"*.ts": "oxfmt --fix"
}
"#
.trim()
);
}

#[test]
fn test_rewrite_package_json_scripts_no_update() {
let package_json_scripts = r#"
{
"foo": "bar"
}
"#;
let updated = rewrite_scripts(package_json_scripts, &RULES_YAML)
.expect("failed to rewrite package.json scripts");
assert!(updated.is_none());
}
}
15 changes: 6 additions & 9 deletions packages/cli/binding/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,28 +138,25 @@ export interface PathAccess {
}

/**
* Rewrite package.json scripts using rules from rules_yaml_path
* Rewrite scripts json content using rules from rules_yaml
*
* # Arguments
*
* * `package_json_path` - The path to the package.json file
* * `rules_yaml_path` - The path to the ast-grep rules.yaml file
* * `scripts_json` - The scripts section of the package.json file as a JSON string
* * `rules_yaml` - The ast-grep rules.yaml as a YAML string
*
* # Returns
*
* * `updated` - Whether the package.json scripts were updated
* * `updated` - The updated scripts section of the package.json file as a JSON string, or `null` if no updates were made
*
* # Example
*
* ```javascript
* const updated = await rewritePackageJsonScripts("package.json", "rules.yaml");
* const updated = rewriteScripts("scripts section json content here", "ast-grep rules yaml content here");
* console.log(`Updated: ${updated}`);
* ```
*/
export declare function rewritePackageJsonScripts(
packageJsonPath: string,
rulesYamlPath: string,
): Promise<boolean>;
export declare function rewriteScripts(scriptsJson: string, rulesYaml: string): string | null;

/**
* Main entry point for the CLI, called from JavaScript.
Expand Down
Loading
Loading