use crate::utils::unicode::{break_text_at_width, get_column_width};
use crate::Result;
use std::fs;
use std::path::Path;
use std::process::Command;
pub struct SourceFixer;
impl SourceFixer {
pub fn fix_comment_spacing(path: &Path) -> Result<()> {
let content = fs::read_to_string(path).map_err(|e| {
crate::LintisError::formatter("source-fixer", path, format!("Failed to read: {}", e))
})?;
let mut modified = false;
let mut result_lines = Vec::new();
for line in content.lines() {
let fixed_line = Self::fix_comment_spacing_line(line);
if fixed_line != line {
modified = true;
}
result_lines.push(fixed_line);
}
if modified {
let mut result = result_lines.join("\n");
if content.ends_with('\n') {
result.push('\n');
}
fs::write(path, result).map_err(|e| {
crate::LintisError::formatter(
"source-fixer",
path,
format!("Failed to write: {}", e),
)
})?;
}
Ok(())
}
fn fix_comment_spacing_line(line: &str) -> String {
let Some(comment_pos) = Self::find_real_comment_pos(line) else {
return line.to_string();
};
let before_comment = &line[..comment_pos];
let comment_part = &line[comment_pos..];
let slash_count = comment_part.chars().take_while(|&c| c == '/').count();
let after_slashes = &comment_part[slash_count..];
if !after_slashes.is_empty() {
let first_char = after_slashes.chars().next().unwrap();
if first_char != ' ' && first_char != '\n' && first_char != '\r' {
return format!(
"{}{} {}",
before_comment,
"/".repeat(slash_count),
after_slashes
);
}
}
line.to_string()
}
fn find_real_comment_pos(line: &str) -> Option<usize> {
let mut search_start = 0;
loop {
let rest = &line[search_start..];
let rel_pos = rest.find("//")?;
let abs_pos = search_start + rel_pos;
let before_comment = &line[..abs_pos];
if !Self::is_in_cpp_string(before_comment) {
return Some(abs_pos);
}
search_start = abs_pos + 2;
}
}
fn is_in_cpp_string(line: &str) -> bool {
let line = line.replace("\\\\", "XX");
let total_quotes = line.matches('"').count();
let escaped_quotes = line.matches("\\\"").count();
let char_literal_quotes = line.matches("'\"'").count();
let effective_quotes = total_quotes - escaped_quotes - char_literal_quotes;
(effective_quotes & 1) == 1
}
pub fn fix_todo_comments(path: &Path) -> Result<()> {
let content = fs::read_to_string(path).map_err(|e| {
crate::LintisError::formatter("source-fixer", path, format!("Failed to read: {}", e))
})?;
let mut result = Vec::new();
let mut modified = false;
for (i, line) in content.lines().enumerate() {
if let Some(todo_pos) = line.find("TODO") {
let after_todo = &line[todo_pos + 4..];
if after_todo.starts_with('(') {
result.push(line.to_string());
continue;
}
let trimmed = after_todo.trim_start();
if trimmed.starts_with(':') {
let username = Self::get_git_blame_author(path, i + 1)
.unwrap_or_else(Self::get_fallback_username);
let colon_offset = after_todo.find(':').unwrap();
let rest_start = todo_pos + 4 + colon_offset + 1;
let rest = if rest_start < line.len() {
line[rest_start..].trim_start()
} else {
""
};
let new_line = if rest.is_empty() {
format!("{}TODO({}):", &line[..todo_pos], username)
} else {
format!("{}TODO({}): {}", &line[..todo_pos], username, rest)
};
result.push(new_line);
modified = true;
continue;
}
}
result.push(line.to_string());
}
if modified {
let new_content = result.join("\n");
let final_content = if content.ends_with('\n') {
format!("{}\n", new_content)
} else {
new_content
};
fs::write(path, final_content).map_err(|e| {
crate::LintisError::formatter(
"source-fixer",
path,
format!("Failed to write: {}", e),
)
})?;
}
Ok(())
}
fn get_git_blame_author(path: &Path, line_number: usize) -> Option<String> {
let output = Command::new("git")
.args([
"blame",
"--porcelain",
"-L",
&format!("{},{}", line_number, line_number),
"--",
])
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Some(author) = line.strip_prefix("author ") {
return Some(author.to_string());
}
}
None
}
fn get_fallback_username() -> String {
if let Ok(output) = Command::new("git").args(["config", "user.name"]).output() {
if output.status.success() {
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !name.is_empty() {
return name;
}
}
}
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "user".to_string())
}
pub fn fix_lone_semicolon(path: &Path) -> Result<()> {
let content = fs::read_to_string(path).map_err(|e| {
crate::LintisError::formatter("source-fixer", path, format!("Failed to read: {}", e))
})?;
let mut result = String::with_capacity(content.len());
let mut modified = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == ";" {
modified = true;
continue;
}
result.push_str(line);
result.push('\n');
}
if !content.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
if modified {
fs::write(path, result).map_err(|e| {
crate::LintisError::formatter(
"source-fixer",
path,
format!("Failed to write: {}", e),
)
})?;
}
Ok(())
}
pub fn fix_long_comments(path: &Path, max_length: usize) -> Result<()> {
let content = fs::read_to_string(path).map_err(|e| {
crate::LintisError::formatter("source-fixer", path, format!("Failed to read: {}", e))
})?;
let mut result = String::with_capacity(content.len());
let mut modified = false;
for line in content.lines() {
let col_width = get_column_width(line);
if col_width <= max_length {
result.push_str(line);
result.push('\n');
continue;
}
if let Some(broken) = Self::break_long_comment_line(line, max_length) {
result.push_str(&broken);
modified = true;
} else {
result.push_str(line);
result.push('\n');
}
}
if !content.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
if modified {
fs::write(path, result).map_err(|e| {
crate::LintisError::formatter(
"source-fixer",
path,
format!("Failed to write: {}", e),
)
})?;
}
Ok(())
}
fn break_long_comment_line(line: &str, max_length: usize) -> Option<String> {
let trimmed = line.trim_start();
let indent = &line[..line.len() - trimmed.len()];
if let Some(comment_start) = trimmed.find("//") {
let before_comment = &trimmed[..comment_start];
let comment_part = &trimmed[comment_start..];
if !before_comment.trim().is_empty() {
return Self::break_trailing_comment(
indent,
before_comment,
comment_part,
max_length,
);
}
return Self::break_pure_comment(indent, comment_part, max_length);
}
None
}
fn break_trailing_comment(
indent: &str,
code_part: &str,
comment_part: &str,
max_length: usize,
) -> Option<String> {
let mut result = String::new();
result.push_str(indent);
result.push_str(code_part.trim_end());
result.push('\n');
let comment_content = comment_part.trim_start_matches('/');
let slash_count = comment_part.len() - comment_content.len();
let prefix: String = "/".repeat(slash_count);
let content = comment_content.trim_start();
let comment_indent = format!("{}{} ", indent, prefix);
let indent_width = get_column_width(&comment_indent);
let available_width = max_length.saturating_sub(indent_width);
if available_width < 30 {
return None; }
let lines = break_text_at_width(content, available_width);
for l in lines {
result.push_str(&comment_indent);
result.push_str(&l);
result.push('\n');
}
Some(result)
}
fn break_pure_comment(indent: &str, comment_part: &str, max_length: usize) -> Option<String> {
let comment_content = comment_part.trim_start_matches('/');
let slash_count = comment_part.len() - comment_content.len();
let prefix: String = "/".repeat(slash_count);
let content = comment_content.trim_start();
let comment_indent = format!("{}{} ", indent, prefix);
let indent_width = get_column_width(&comment_indent);
let available_width = max_length.saturating_sub(indent_width);
if available_width < 30 {
return None; }
let lines = break_text_at_width(content, available_width);
if lines.len() <= 1 {
return None; }
let mut result = String::new();
for l in lines {
result.push_str(&comment_indent);
result.push_str(&l);
result.push('\n');
}
Some(result)
}
pub fn fix_pragma_separators(path: &Path) -> Result<()> {
let content = fs::read_to_string(path).map_err(|e| {
crate::LintisError::formatter("source-fixer", path, format!("Failed to read: {}", e))
})?;
let mut result = String::with_capacity(content.len());
let mut modified = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("#pragma") {
if trimmed.contains("-- --") || trimmed.contains("- - -") {
let pragma_content = trimmed.trim_start_matches("#pragma").trim();
if pragma_content.starts_with("mark") {
let after_mark = pragma_content.trim_start_matches("mark").trim();
if after_mark.chars().all(|c| c == '-' || c == ' ') {
result.push_str("#pragma mark -");
result.push('\n');
modified = true;
continue;
}
}
let separator_start = pragma_content
.find("-- ")
.or_else(|| pragma_content.find("- -"))
.or_else(|| pragma_content.find("--"))
.unwrap_or(pragma_content.len());
let section_name = pragma_content[..separator_start]
.trim()
.trim_end_matches('-')
.trim();
let new_line = if section_name.is_empty() || section_name == "mark" {
"#pragma mark -".to_string()
} else {
format!("#pragma mark - {}", section_name)
};
result.push_str(&new_line);
result.push('\n');
modified = true;
continue;
}
}
result.push_str(line);
result.push('\n');
}
if !content.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
if modified {
fs::write(path, result).map_err(|e| {
crate::LintisError::formatter(
"source-fixer",
path,
format!("Failed to write: {}", e),
)
})?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn create_temp_file(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file
}
fn read_temp_file(file: &NamedTempFile) -> String {
fs::read_to_string(file.path()).unwrap()
}
#[test]
fn test_fix_comment_spacing_basic() {
let file = create_temp_file("//comment\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "// comment\n");
}
#[test]
fn test_fix_comment_spacing_already_has_space() {
let file = create_temp_file("// already spaced\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "// already spaced\n");
}
#[test]
fn test_fix_comment_spacing_triple_slash() {
let file = create_temp_file("///doc comment\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "/// doc comment\n");
}
#[test]
fn test_fix_comment_spacing_chinese() {
let file = create_temp_file("//δΈζ注ι\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "// δΈζ注ι\n");
}
#[test]
fn test_fix_comment_spacing_empty_comment() {
let file = create_temp_file("//\ncode();\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "//\ncode();\n");
}
#[test]
fn test_fix_comment_spacing_preserves_url() {
let file = create_temp_file("return @\"https://example.com\";\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "return @\"https://example.com\";\n");
}
#[test]
fn test_fix_comment_spacing_preserves_multiple_urls() {
let content = r#"NSString *url1 = @"https://tmga.qq.com";
NSString *url2 = @"http://example.com/path";
NSString *url3 = @"file:///local/path";
"#;
let file = create_temp_file(content);
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), content);
}
#[test]
fn test_fix_comment_spacing_url_and_comment() {
let file = create_temp_file("NSString *url = @\"https://example.com\"; //comment\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(
read_temp_file(&file),
"NSString *url = @\"https://example.com\"; // comment\n"
);
}
#[test]
fn test_fix_comment_spacing_string_with_slashes() {
let file = create_temp_file("char *path = \"path//to//file\";\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "char *path = \"path//to//file\";\n");
}
#[test]
fn test_fix_comment_spacing_escaped_quote() {
let file = create_temp_file("char *s = \"he said \\\"hello//world\\\"\";\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(
read_temp_file(&file),
"char *s = \"he said \\\"hello//world\\\"\";\n"
);
}
#[test]
fn test_fix_comment_spacing_char_literal() {
let file = create_temp_file("char c = '/'; //comment\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "char c = '/'; // comment\n");
}
#[test]
fn test_fix_comment_spacing_quote_in_char_literal() {
let file = create_temp_file("char c = '\"'; //comment\n");
SourceFixer::fix_comment_spacing(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "char c = '\"'; // comment\n");
}
#[test]
fn test_is_in_cpp_string() {
assert!(!SourceFixer::is_in_cpp_string("int x = 1;"));
assert!(SourceFixer::is_in_cpp_string("char *s = \"hello"));
assert!(!SourceFixer::is_in_cpp_string("char *s = \"hello\""));
assert!(SourceFixer::is_in_cpp_string("char *s = \"hello\\\""));
assert!(!SourceFixer::is_in_cpp_string("char *s = \"hello\\\"\""));
assert!(!SourceFixer::is_in_cpp_string("char c = '\"';"));
assert!(SourceFixer::is_in_cpp_string("char *s = \"a\\\\"));
assert!(!SourceFixer::is_in_cpp_string("char *s = \"a\\\\\""));
}
#[test]
fn test_fix_lone_semicolon_removes() {
let file = create_temp_file("code();\n;\nmore();\n");
SourceFixer::fix_lone_semicolon(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "code();\nmore();\n");
}
#[test]
fn test_fix_lone_semicolon_with_whitespace() {
let file = create_temp_file("code();\n ; \nmore();\n");
SourceFixer::fix_lone_semicolon(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "code();\nmore();\n");
}
#[test]
fn test_fix_lone_semicolon_keeps_valid() {
let file = create_temp_file("for(;;) {}\nwhile(1);\n");
SourceFixer::fix_lone_semicolon(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "for(;;) {}\nwhile(1);\n");
}
#[test]
fn test_fix_long_comments_short_line() {
let file = create_temp_file("// short comment\n");
SourceFixer::fix_long_comments(file.path(), 120).unwrap();
assert_eq!(read_temp_file(&file), "// short comment\n");
}
#[test]
fn test_fix_long_comments_breaks_long_line() {
let long_comment = format!("// {}\n", "x".repeat(100));
let file = create_temp_file(&long_comment);
SourceFixer::fix_long_comments(file.path(), 80).unwrap();
let result = read_temp_file(&file);
assert!(result.lines().count() > 1);
}
#[test]
fn test_fix_long_comments_chinese() {
let chinese_comment = format!("// {}\n", "δΈ".repeat(50));
let file = create_temp_file(&chinese_comment);
SourceFixer::fix_long_comments(file.path(), 80).unwrap();
let result = read_temp_file(&file);
assert!(result.lines().count() > 1 || result.lines().any(|l| l.len() < 100));
}
#[test]
fn test_fix_pragma_mark_separator() {
let file = create_temp_file("#pragma mark - - - -\n");
SourceFixer::fix_pragma_separators(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "#pragma mark -\n");
}
#[test]
fn test_fix_pragma_with_section_name() {
let file = create_temp_file("#pragma webview delegate-- -- --\n");
SourceFixer::fix_pragma_separators(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "#pragma mark - webview delegate\n");
}
#[test]
fn test_fix_pragma_keeps_normal() {
let file = create_temp_file("#pragma mark - Normal Section\n");
SourceFixer::fix_pragma_separators(file.path()).unwrap();
assert_eq!(read_temp_file(&file), "#pragma mark - Normal Section\n");
}
}