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,059 changes: 1,059 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,17 @@ name = "php-refactor"
version = "0.1.0"
edition = "2024"

[[bin]]
name = "php-refactor"
path = "src/main.rs"

[lib]
name = "php_refactor"
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"
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod reporter;
pub mod rules;
59 changes: 58 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,60 @@
use php_refactor::reporter;
use php_refactor::rules;
use std::fs;
use std::process;
use std::time::Instant;

fn main() {
println!("PHP Refactor");
let total_start = Instant::now();

let path = match std::env::args().nth(1) {
Some(p) => p,
None => {
let elapsed = total_start.elapsed();
eprintln!("[ERROR] Something went wrong: Usage: php-refactor <path/to/file.php>");
reporter::print_timing(&[], elapsed);
process::exit(1);
}
};

let original = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
let elapsed = total_start.elapsed();
eprintln!("[ERROR] Something went wrong: {}", e);
reporter::print_timing(&[], elapsed);
process::exit(1);
}
};

let mut rule_timings: Vec<(&str, std::time::Duration)> = Vec::new();
let mut content = original.clone();

for (rule_name, rule_fn) in rules::all_rules() {
let rule_start = Instant::now();
let result = rule_fn(&content);
rule_timings.push((rule_name, rule_start.elapsed()));

if let Some(new_content) = result {
content = new_content;
}
}

let total_elapsed = total_start.elapsed();

// Separate "needs update?" step — rule logic stays pure
let needs_update = content != original;

if needs_update {
if let Err(e) = fs::write(&path, &content) {
eprintln!("[ERROR] Something went wrong: {}", e);
reporter::print_timing(&rule_timings, total_elapsed);
process::exit(1);
}
println!("[OK] 1 file has been changed, 1 file analyzed.");
} else {
println!("[OK] 1 file analyzed, nothing to do.");
}

reporter::print_timing(&rule_timings, total_elapsed);
}
52 changes: 52 additions & 0 deletions src/reporter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use std::time::Duration;

pub fn peak_memory_kb() -> u64 {
#[cfg(any(target_os = "macos", target_os = "linux"))]
unsafe {
let mut rusage: libc::rusage = std::mem::zeroed();
libc::getrusage(libc::RUSAGE_SELF, &mut rusage);
#[cfg(target_os = "macos")]
return (rusage.ru_maxrss / 1024) as u64;
#[cfg(target_os = "linux")]
return rusage.ru_maxrss as u64;
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
0
}

pub fn format_memory(kb: u64) -> String {
if kb >= 1024 {
format!("{:.1}MB peak memory", kb as f64 / 1024.0)
} else {
format!("{}KB peak memory", kb)
}
}

pub fn format_timing_line(
rule_timings: &[(&str, Duration)],
total: Duration,
memory_kb: u64,
) -> String {
let memory_str = format_memory(memory_kb);
let rules_str: String = rule_timings
.iter()
.map(|(name, d)| format!("{}: {:.2?}", name, d))
.collect::<Vec<_>>()
.join(", ");

if rules_str.is_empty() {
format!("{:.2?} total duration, {}.", total, memory_str)
} else {
format!(
"{:.2?} total duration, {}. {}",
total, memory_str, rules_str
)
}
}

pub fn print_timing(rule_timings: &[(&str, Duration)], total: Duration) {
println!(
"{}",
format_timing_line(rule_timings, total, peak_memory_kb())
);
}
10 changes: 10 additions & 0 deletions src/rules/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
pub mod quality;

pub type RuleFn = fn(&str) -> Option<String>;

pub fn all_rules() -> Vec<(&'static str, RuleFn)> {
vec![(
"quality/add_final_keyword",
quality::add_final_keyword::apply,
)]
}
57 changes: 57 additions & 0 deletions src/rules/quality/add_final_keyword.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use bumpalo::Bump;
use mago_database::file::FileId;
use mago_syntax::ast::{Modifier, Sequence, Statement};
use mago_syntax::parser::parse_file_content;

pub fn apply(source: &str) -> Option<String> {
let arena = Bump::new();
let file_id = FileId::zero();
let program = parse_file_content(&arena, file_id, source);

let mut insertions: Vec<usize> = Vec::new();
collect_insertions(&program.statements, &mut insertions);

if insertions.is_empty() {
return None;
}

// Apply insertions back-to-front to preserve offsets
let mut result = source.to_string();
insertions.sort_unstable_by(|a, b| b.cmp(a));
for offset in insertions {
result.insert_str(offset, "final ");
}

Some(result)
}

fn collect_insertions<'a>(
statements: &'a Sequence<'a, Statement<'a>>,
insertions: &mut Vec<usize>,
) {
for stmt in statements {
match stmt {
Statement::Class(class) => {
let mods = &class.modifiers;
if mods.contains_final() || mods.contains_abstract() {
continue;
}
// Insert before `readonly` if present, else before `class` keyword
let offset = if mods.contains_readonly() {
match mods.get_readonly() {
Some(Modifier::Readonly(kw)) => kw.span.start.offset as usize,
_ => unreachable!(),
}
} else {
class.class.span.start.offset as usize
};
insertions.push(offset);
}
Statement::Interface(_) => {} // skip
Statement::Namespace(ns) => {
collect_insertions(ns.statements(), insertions);
}
_ => {}
}
}
}
1 change: 1 addition & 0 deletions src/rules/quality/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod add_final_keyword;
11 changes: 0 additions & 11 deletions tests/integration_test.rs

This file was deleted.

52 changes: 52 additions & 0 deletions tests/reporter_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use php_refactor::reporter::{format_memory, format_timing_line};
use std::time::Duration;

// format_memory tests
#[test]
fn test_format_memory_bytes_under_mb() {
assert_eq!(format_memory(0), "0KB peak memory");
assert_eq!(format_memory(512), "512KB peak memory");
assert_eq!(format_memory(1023), "1023KB peak memory");
}

#[test]
fn test_format_memory_exact_mb() {
assert_eq!(format_memory(1024), "1.0MB peak memory");
assert_eq!(format_memory(2048), "2.0MB peak memory");
}

#[test]
fn test_format_memory_fractional_mb() {
assert_eq!(format_memory(1536), "1.5MB peak memory");
assert_eq!(format_memory(10240), "10.0MB peak memory");
}

// format_timing_line tests
#[test]
fn test_format_timing_line_no_rules() {
let line = format_timing_line(&[], Duration::from_millis(100), 512);
assert_eq!(line, "100.00ms total duration, 512KB peak memory.");
}

#[test]
fn test_format_timing_line_with_rules() {
let timings = vec![("quality/add_final_keyword", Duration::from_millis(50))];
let line = format_timing_line(&timings, Duration::from_millis(100), 2048);
assert_eq!(
line,
"100.00ms total duration, 2.0MB peak memory. quality/add_final_keyword: 50.00ms"
);
}

#[test]
fn test_format_timing_line_multiple_rules() {
let timings = vec![
("dir1/rule_a", Duration::from_millis(20)),
("dir2/rule_b", Duration::from_millis(30)),
];
let line = format_timing_line(&timings, Duration::from_millis(100), 1024);
assert_eq!(
line,
"100.00ms total duration, 1.0MB peak memory. dir1/rule_a: 20.00ms, dir2/rule_b: 30.00ms"
);
}
10 changes: 10 additions & 0 deletions tests/rules/quality/add_final_keyword/add_final_keyword.php.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

class Something {
}

-----
<?php

final class Something {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Rules\AddFinalKeyword;

class Something {
}

-----
<?php

namespace Rules\AddFinalKeyword;

final class Something {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

readonly class Something {
}

-----
<?php

final readonly class Something {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

abstract class Something {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

final class Something {
}
4 changes: 4 additions & 0 deletions tests/rules/quality/add_final_keyword/skip_interface.php.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

interface Something {
}
4 changes: 4 additions & 0 deletions tests/rules/quality/add_final_keyword/skip_trait.php.inc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?php

trait Something {
}
69 changes: 69 additions & 0 deletions tests/rules_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
use std::fs;
use std::path::Path;

const SEPARATOR: &str = "\n-----\n";

#[test]
fn test_all_rules() {
let rules = php_refactor::rules::all_rules();

for (rule_path, rule_fn) in rules {
let fixture_dir = format!("tests/rules/{}", rule_path);
let fixture_path = Path::new(&fixture_dir);

if !fixture_path.exists() {
panic!(
"Fixture directory not found for rule '{}': {}",
rule_path, fixture_dir
);
}

let mut entries: Vec<_> = fs::read_dir(fixture_path)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
entries.sort(); // deterministic order

for path in entries {
// Skip non-.php.inc files
if path.extension().map(|e| e.to_str()).flatten() != Some("inc") {
continue;
}

let content = fs::read_to_string(&path)
.unwrap_or_else(|_| panic!("Failed to read fixture: {}", path.display()));
let file_name = path.file_name().unwrap().to_str().unwrap();

if content.contains(SEPARATOR) {
// Transform fixture: expects Some(output)
let parts: Vec<&str> = content.splitn(2, SEPARATOR).collect();
let input = parts[0];
let expected = parts[1];

let result = rule_fn(input);
assert!(
result.is_some(),
"Rule '{}': Expected Some for fixture '{}', got None",
rule_path,
file_name
);
assert_eq!(
result.unwrap().trim(),
expected.trim(),
"Rule '{}': Output mismatch for fixture '{}'",
rule_path,
file_name
);
} else {
// No-transform fixture: expects None
let result = rule_fn(content.trim());
assert!(
result.is_none(),
"Rule '{}': Expected None for fixture '{}', got Some",
rule_path,
file_name
);
}
}
}
}