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
811 changes: 21 additions & 790 deletions Cargo.lock

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "php-refactor"
version = "0.1.0"
version = "0.1.1"
edition = "2024"

[[bin]]
Expand All @@ -13,10 +13,11 @@ path = "src/lib.rs"

[dependencies]
libc = "0.2"
mago-syntax = "1.15.3"
mago-span = "1.15.3"
mago-database = "1.15.3"
bumpalo = "3"
serde = { version = "1", features = ["derive"] }
toml = "0.8"
walkdir = "2"
glob = "0.3"
rayon = "1"
regex = "1"
ignore = "0.4"
globset = "0.4"
90 changes: 90 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use std::fs;
use std::path::Path;

fn main() {
// Scan src/rules/ directory to auto-discover rule modules
let rules_dir = "src/rules";
let mut rules_code = String::from("// Auto-generated rule registry\n\n");

// Track rules for all_rules() and all_source_transforms()
let mut all_rules_entries = Vec::new();
let mut all_transforms_entries = Vec::new();

// Scan categories (subdirectories of src/rules/)
if let Ok(entries) = fs::read_dir(rules_dir) {
for entry in entries.flatten() {
let path = entry.path();

// Skip mod.rs and other files, only process directories
if !path.is_dir() {
continue;
}

let category = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();

if category == "quality" {
// Scan quality subdirectory for rule modules
if let Ok(rule_entries) = fs::read_dir(&path) {
for rule_entry in rule_entries.flatten() {
let rule_path = rule_entry.path();

// Look for .rs files that are rule modules
if rule_path.extension().map(|e| e == "rs").unwrap_or(false) {
let rule_name =
rule_path.file_stem().and_then(|n| n.to_str()).unwrap_or("");

// Skip mod.rs
if rule_name == "mod" {
continue;
}

let rule_key = format!("{}/{}", category, rule_name);
let module_path = format!("{}::{}", category, rule_name);

all_rules_entries.push(format!(
" (\"{}\", {}::apply),",
rule_key, module_path
));
all_transforms_entries.push(format!(
" (\"{}\", {}::apply_to_source),",
rule_key, module_path
));
}
}
}
}
}
}

// Generate all_rules() function
rules_code.push_str("pub fn all_rules() -> Vec<(&'static str, RuleFn)> {\n");
rules_code.push_str(" vec![\n");
for entry in all_rules_entries {
rules_code.push_str(&entry);
rules_code.push('\n');
}
rules_code.push_str(" ]\n");
rules_code.push_str("}\n\n");

// Generate all_source_transforms() function
rules_code
.push_str("pub fn all_source_transforms() -> Vec<(&'static str, SourceTransformFn)> {\n");
rules_code.push_str(" vec![\n");
for entry in all_transforms_entries {
rules_code.push_str(&entry);
rules_code.push('\n');
}
rules_code.push_str(" ]\n");
rules_code.push_str("}\n");

// Write generated code to a file in OUT_DIR
let out_dir = std::env::var("OUT_DIR").unwrap();
let dest_path = Path::new(&out_dir).join("generated_rules.rs");
fs::write(&dest_path, rules_code).unwrap();

println!("cargo:rerun-if-changed=src/rules/");
}
8 changes: 8 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::fs;

#[derive(Deserialize)]
pub struct Config {
pub source: SourceConfig,
#[serde(flatten)]
pub rules: HashMap<String, HashMap<String, RuleConfig>>,
}

#[derive(Deserialize)]
pub struct SourceConfig {
pub paths: Vec<String>,
}

#[derive(Deserialize, Clone)]
pub struct RuleConfig {
pub paths: Vec<String>,
}

pub fn load(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
let contents = fs::read_to_string(path)?;
let config = toml::from_str(&contents)?;
Expand Down
31 changes: 28 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,35 @@ fn main() {
let mut total_changed = 0;
let mut total_analyzed = 0;

for (_, rule_fn) in rules::all_rules() {
let result = rule_fn(&path);
// Load config once instead of per-rule
let config = if path.ends_with(".toml") {
php_refactor::config::load(&path).ok()
} else {
None
};

for (rule_key, rule_fn) in rules::all_rules() {
let collect_start = Instant::now();
let files =
php_refactor::resolver::resolve_for_rule_with_config(&path, rule_key, config.as_ref());
let collect_ms = collect_start.elapsed().as_secs_f64() * 1000.0;

let process_start = Instant::now();
let result = rule_fn(&files);
let process_ms = process_start.elapsed().as_secs_f64() * 1000.0;

eprintln!(
"[INFO] {}: collected {} in {:.2}ms → matched {}, changed {}, processed in {:.2}ms",
rule_key,
files.len(),
collect_ms,
result.files_matched,
result.files_changed,
process_ms
);

total_changed += result.files_changed;
total_analyzed = result.files_analyzed;
total_analyzed += result.files_analyzed;
}

let total_elapsed = total_start.elapsed();
Expand Down
134 changes: 134 additions & 0 deletions src/resolver.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,52 @@
use globset::{Glob, GlobSetBuilder};
use ignore::{WalkBuilder, WalkState};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc::channel;
use walkdir::WalkDir;

/// Resolve the effective files for a rule with an already-loaded config.
/// Avoids re-loading and re-parsing the config per rule.
pub fn resolve_for_rule_with_config(
path: &str,
rule_key: &str,
config: Option<&crate::config::Config>,
) -> Vec<PathBuf> {
if let Some(cfg) = config {
// Split rule_key (e.g., "quality/add_final_keyword") into category and name
let parts: Vec<&str> = rule_key.split('/').collect();
if parts.len() == 2 {
let category = parts[0];
let name = parts[1];

// Check if this rule has a specific path configuration
if let Some(rule_config) = cfg.rules.get(category).and_then(|c| c.get(name)) {
return collect_php_files_from_globs(&rule_config.paths);
}
}
}

// Fall back to standard path expansion
collect_php_files(path).unwrap_or_default()
}

/// Resolve the effective files for a rule based on CLI path and rule key.
///
/// If path is a .toml config file and the rule has per-rule paths configured,
/// use those paths (with glob expansion). Otherwise, use standard path expansion.
///
/// This function loads the config on each call. For better performance with multiple rules,
/// use `resolve_for_rule_with_config` and load the config once.
pub fn resolve_for_rule(path: &str, rule_key: &str) -> Vec<PathBuf> {
let config = if path.ends_with(".toml") {
crate::config::load(path).ok()
} else {
None
};

resolve_for_rule_with_config(path, rule_key, config.as_ref())
}

/// Expand a path argument into a list of PHP files to process.
/// - `.toml` file → load config, walk configured paths
/// - directory → walk and collect .php files
Expand All @@ -22,6 +68,71 @@ pub fn collect_php_files(path: &str) -> Result<Vec<PathBuf>, Box<dyn std::error:
}
}

/// Collect PHP files matching glob patterns.
/// Uses parallel directory walking (ignore::WalkParallel) anchored to pattern base directories,
/// combined with glob::Pattern matching. WalkParallel automatically respects .gitignore,
/// dramatically reducing traversal of vendor/ and other excluded directories.
///
/// Patterns without `/` are treated as recursive (auto-prepends `**/`).
/// Returns deduplicated, sorted list of matching files, ignoring inaccessible paths.
pub fn collect_php_files_from_globs(patterns: &[String]) -> Vec<PathBuf> {
// 1. Compile all patterns into one GlobSet (fast DFA matching, all patterns in one pass).
// Deduplicate base directories to avoid walking the same tree multiple times.
let mut gsbuilder = GlobSetBuilder::new();
let mut bases: HashSet<PathBuf> = HashSet::new();

for p in patterns {
let effective = if p.contains('/') {
p.clone()
} else {
format!("**/{}", p)
};
bases.insert(extract_base_dir(&effective));
if let Ok(g) = Glob::new(&effective) {
gsbuilder.add(g);
}
}

let Ok(globset) = gsbuilder.build() else {
return vec![];
};

// 2. One WalkBuilder with all base directories as roots.
let mut bases_iter = bases.into_iter();
let Some(first) = bases_iter.next() else {
return vec![];
};

let mut builder = WalkBuilder::new(&first);
for base in bases_iter {
builder.add(&base);
}

// 3. One WalkParallel run: all roots walked in parallel, globset matching all patterns.
let (tx, rx) = channel();
builder.standard_filters(true).build_parallel().run(|| {
let gs = globset.clone();
let tx = tx.clone();
Box::new(move |result| {
if let Ok(entry) = result {
let path = entry.path();
if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false)
&& path.extension().and_then(|s| s.to_str()) == Some("php")
&& gs.is_match(path)
{
let _ = tx.send(path.to_path_buf());
}
}
WalkState::Continue
})
});
drop(tx);

let mut result: Vec<PathBuf> = rx.into_iter().collect();
result.sort();
result
}

/// Load a TOML config and collect all .php files from configured paths.
fn collect_from_config(config_path: &str) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let cfg = crate::config::load(config_path)?;
Expand All @@ -43,3 +154,26 @@ fn collect_from_directory(dir_path: &str) -> Vec<PathBuf> {
.map(|e| e.path().to_path_buf())
.collect()
}

/// Extract the longest non-wildcard prefix directory from a glob pattern.
/// This anchors walkdir to the smallest useful subtree, avoiding unnecessary traversal
/// of unrelated directories (e.g., vendor/ when pattern is src/**/*.php).
///
/// Examples:
/// - "src/Interface/**/*Request.php" → PathBuf("src/Interface")
/// - "src/**/*.php" → PathBuf("src")
/// - "**/*.php" → PathBuf(".")
fn extract_base_dir(pattern: &str) -> PathBuf {
let parts: Vec<&str> = pattern.split('/').collect();
let base_parts: Vec<&str> = parts
.iter()
.take_while(|p| !p.contains('*') && !p.contains('?') && !p.contains('{'))
.copied()
.collect();

if base_parts.is_empty() {
PathBuf::from(".")
} else {
PathBuf::from(base_parts.join("/"))
}
}
22 changes: 5 additions & 17 deletions src/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,15 @@ pub mod quality;
#[derive(Debug, Clone, Copy)]
pub struct RuleResult {
pub files_changed: usize,
pub files_matched: usize,
pub files_analyzed: usize,
}

/// File-aware rule: takes a path (file, config, or directory) and returns stats.
pub type RuleFn = fn(&str) -> RuleResult;
/// File-aware rule: takes a list of files to process and returns stats.
pub type RuleFn = fn(&[std::path::PathBuf]) -> RuleResult;

/// Pure source transformation: takes source code, returns modified source or None.
pub type SourceTransformFn = fn(&str) -> Option<String>;

/// Rules registered for execution via main.rs
pub fn all_rules() -> Vec<(&'static str, RuleFn)> {
vec![(
"quality/add_final_keyword",
quality::add_final_keyword::apply,
)]
}

/// Pure source transforms used by tests
pub fn all_source_transforms() -> Vec<(&'static str, SourceTransformFn)> {
vec![(
"quality/add_final_keyword",
quality::add_final_keyword::apply_to_source,
)]
}
// Include auto-generated rule registry (discovered from src/rules/ at build time)
include!(concat!(env!("OUT_DIR"), "/generated_rules.rs"));
Loading