code-buster 0.11.0

Repository-aware static analysis for AI-assisted development
Documentation
use std::fs;
use std::path::Path;

use crate::cli::PreviewOptions;
use crate::summary_command;

pub enum FixOutcome {
    Success,
    Empty,
}

pub fn execute(options: &PreviewOptions) -> Result<FixOutcome, String> {
    let run = summary_command::analyze(options)?;
    if run.prepared.files.is_empty() && !options.allow_empty {
        eprintln!("no source files found (use --allow-empty to allow this)");
        return Ok(FixOutcome::Empty);
    }
    let root = Path::new(&run.prepared.root);
    let mut changed = Vec::new();
    for source_file in &run.prepared.files {
        let path = root.join(source_file.path.replace('/', std::path::MAIN_SEPARATOR_STR));
        let before = fs::read_to_string(&path)
            .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
        let after = fixed_content(&before, &source_file.path);
        if before == after {
            continue;
        }
        if !options.dry_run {
            fs::write(&path, after)
                .map_err(|error| format!("failed to write {}: {error}", path.display()))?;
        }
        changed.push(source_file.path.as_str());
    }
    for path in &changed {
        println!(
            "{} {path}",
            if options.dry_run {
                "would fix"
            } else {
                "fixed"
            }
        );
    }
    if changed.is_empty() {
        println!("no safe fixes needed");
    } else {
        println!(
            "{} file(s) {}",
            changed.len(),
            if options.dry_run {
                "would change"
            } else {
                "changed"
            }
        );
    }
    Ok(FixOutcome::Success)
}

pub(crate) fn fixed_content(source: &str, path: &str) -> String {
    let newline = if source.contains("\r\n") {
        "\r\n"
    } else {
        "\n"
    };
    let has_final_newline = source.ends_with(newline);
    let mut lines = source.split(newline).collect::<Vec<_>>();
    if has_final_newline {
        lines.pop();
    }
    let fix_nim_imports = path.ends_with(".nim") || path.ends_with(".nims");
    let mut multiline_delimiter = None;
    let mut output = Vec::with_capacity(lines.len());
    for raw_line in lines {
        let plan = line_fix_policy(raw_line, multiline_delimiter, fix_nim_imports);
        let (line, next_delimiter) =
            apply_line_fixes(raw_line.to_owned(), plan, multiline_delimiter);
        output.push(line);
        multiline_delimiter = next_delimiter;
    }
    let mut result = output.join(newline);
    if has_final_newline {
        result.push_str(newline);
    }
    result
}

#[derive(Clone, Copy, Default)]
struct FixCandidates {
    indentation_end: Option<usize>,
    trim_trailing_whitespace: bool,
    fix_nim_import: bool,
}

#[derive(Clone, Copy)]
enum DelimiterPolicy {
    Preserve,
    Detect,
    Clear,
}

#[derive(Clone, Copy)]
struct LineFixPlan {
    candidates: FixCandidates,
    delimiter_policy: DelimiterPolicy,
}

fn select_fix_candidates(line: &str, fix_nim_import: bool) -> FixCandidates {
    let indentation = indentation_end(line);
    FixCandidates {
        indentation_end: line.as_bytes()[..indentation]
            .contains(&b'\t')
            .then_some(indentation),
        trim_trailing_whitespace: true,
        fix_nim_import,
    }
}

fn line_fix_policy(
    line: &str,
    multiline_delimiter: Option<&'static str>,
    fix_nim_import: bool,
) -> LineFixPlan {
    match multiline_delimiter {
        None => LineFixPlan {
            candidates: select_fix_candidates(line, fix_nim_import),
            delimiter_policy: DelimiterPolicy::Detect,
        },
        Some(delimiter) if line.contains(delimiter) => LineFixPlan {
            candidates: FixCandidates {
                trim_trailing_whitespace: true,
                ..FixCandidates::default()
            },
            delimiter_policy: DelimiterPolicy::Clear,
        },
        Some(_) => LineFixPlan {
            candidates: FixCandidates::default(),
            delimiter_policy: DelimiterPolicy::Preserve,
        },
    }
}

fn apply_line_fixes(
    mut line: String,
    plan: LineFixPlan,
    multiline_delimiter: Option<&'static str>,
) -> (String, Option<&'static str>) {
    if let Some(indentation) = plan.candidates.indentation_end {
        let prefix = line[..indentation].replace('\t', "  ");
        line.replace_range(..indentation, &prefix);
    }
    if plan.candidates.trim_trailing_whitespace {
        trim_trailing_whitespace(&mut line);
    }
    if plan.candidates.fix_nim_import {
        line = fix_nim_std_import(line);
    }
    let next_delimiter = match plan.delimiter_policy {
        DelimiterPolicy::Preserve => multiline_delimiter,
        DelimiterPolicy::Detect => opening_delimiter(&line),
        DelimiterPolicy::Clear => None,
    };
    (line, next_delimiter)
}

fn indentation_end(line: &str) -> usize {
    line.bytes()
        .take_while(|byte| matches!(byte, b' ' | b'\t'))
        .count()
}

fn trim_trailing_whitespace(line: &mut String) {
    let trimmed = line.trim_end_matches([' ', '\t']).len();
    line.truncate(trimmed);
}

fn fix_nim_std_import(line: String) -> String {
    let indentation = indentation_end(&line);
    let prefix = &line[..indentation];
    let trimmed = line.trim();
    if trimmed == "import os" {
        format!("{prefix}import std/os")
    } else if let Some(rest) = trimmed.strip_prefix("from os import") {
        format!("{prefix}from std/os import{rest}")
    } else if let Some(rest) = trimmed.strip_prefix("import os,") {
        format!("{prefix}import std/os,{rest}")
    } else {
        line
    }
}

fn opening_delimiter(line: &str) -> Option<&'static str> {
    let double_index = line.find("\"\"\"");
    let single_index = line.find("'''");
    let delimiter = match (double_index, single_index) {
        (None, None) => return None,
        (Some(_), None) => "\"\"\"",
        (None, Some(_)) => "'''",
        (Some(double), Some(single)) if double < single => "\"\"\"",
        (Some(_), Some(_)) => "'''",
    };
    (line.matches(delimiter).count() % 2 == 1).then_some(delimiter)
}

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

    #[test]
    fn fixes_only_safe_whitespace_outside_dart_multiline_strings() {
        let source =
            "\tvoid main() { print(\"left\\tmiddle\"); }  \nfinal text = '''\n\tkeep  \n''';  \n";
        assert_eq!(
            fixed_content(source, "lib/main.dart"),
            "  void main() { print(\"left\\tmiddle\"); }\nfinal text = '''\n\tkeep  \n''';\n"
        );
    }

    #[test]
    fn preserves_crlf_and_updates_nim_os_imports() {
        assert_eq!(
            fixed_content("\timport os  \r\n", "main.nim"),
            "  import std/os\r\n"
        );
    }
}