rrubocop 0.8.15

10x faster RuboCop drop-in. Same output format and configs, without maintaining RuboCop gems.
//! RuboCop-style autocorrect loop: re-lint until no corrections apply.

use std::collections::HashSet;
use std::path::Path;

use anyhow::{bail, Result};
use sha2::{Digest, Sha256};

use crate::cli::AutocorrectMode;
use crate::config::{CopFilterSet, ResolvedConfig};
use crate::cop::registry::CopRegistry;
use crate::correction::CorrectionSet;
use crate::diagnostic::Diagnostic;
use crate::parse::source::SourceFile;

use super::{lint_source, LintOutput};

pub(crate) const MAX_ITERATIONS: usize = 200;

struct LintCtx<'a> {
    path: &'a Path,
    config: &'a ResolvedConfig,
    registry: &'a CopRegistry,
    filters: &'a CopFilterSet,
    only: Option<&'a [String]>,
    except: &'a [String],
    mode: AutocorrectMode,
    ignore_disable: bool,
}

impl<'a> LintCtx<'a> {
    fn lint_bytes(&self, bytes: &[u8]) -> Result<LintOutput> {
        lint_source(
            &SourceFile::from_bytes(self.path, bytes.to_vec()),
            self.config,
            self.registry,
            self.filters,
            self.only,
            self.except,
            self.mode,
            self.ignore_disable,
        )
    }
}

pub(crate) fn lint_file_autocorrect(
    path: &Path,
    config: &ResolvedConfig,
    registry: &CopRegistry,
    filters: &CopFilterSet,
    only: Option<&[String]>,
    except: &[String],
    mode: AutocorrectMode,
    ignore_disable: bool,
) -> Result<Vec<Diagnostic>> {
    let mut bytes = std::fs::read(path)?;
    let diags = lint_bytes_autocorrect(
        path,
        &mut bytes,
        config,
        registry,
        filters,
        only,
        except,
        mode,
        ignore_disable,
    )?;
    if mode != AutocorrectMode::Off {
        std::fs::write(path, &bytes)?;
    }
    Ok(diags)
}

pub(crate) fn lint_bytes_autocorrect(
    path: &Path,
    bytes: &mut Vec<u8>,
    config: &ResolvedConfig,
    registry: &CopRegistry,
    filters: &CopFilterSet,
    only: Option<&[String]>,
    except: &[String],
    mode: AutocorrectMode,
    ignore_disable: bool,
) -> Result<Vec<Diagnostic>> {
    let ctx = LintCtx {
        path,
        config,
        registry,
        filters,
        only,
        except,
        mode,
        ignore_disable,
    };
    if mode == AutocorrectMode::Off {
        return Ok(ctx.lint_bytes(bytes)?.diagnostics);
    }
    run_autocorrect_loop(&ctx, bytes)
}

fn run_autocorrect_loop(ctx: &LintCtx<'_>, bytes: &mut Vec<u8>) -> Result<Vec<Diagnostic>> {
    let mut corrected = collect_corrected_passes(ctx, bytes, &mut HashSet::new())?;
    corrected.append(&mut final_pass(ctx, bytes)?);
    corrected.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
    Ok(corrected)
}

fn collect_corrected_passes(
    ctx: &LintCtx<'_>,
    bytes: &mut Vec<u8>,
    checksums: &mut HashSet<[u8; 32]>,
) -> Result<Vec<Diagnostic>> {
    let mut corrected = Vec::new();
    for _ in 0..MAX_ITERATIONS {
        if !checksums.insert(sha256(bytes)) {
            bail!("Infinite loop detected in {}", ctx.path.display());
        }
        match next_pass(ctx, bytes)? {
            // Remaining offenses come only from `final_pass` — do not return them
            // here or they are duplicated in the `-A` report.
            PassOutcome::Finished => return Ok(corrected),
            PassOutcome::Applied { mut diags, set } => {
                corrected.extend(diags.drain(..).filter(|d| d.corrected));
                *bytes = set.apply(bytes);
            }
        }
    }
    Ok(corrected)
}

enum PassOutcome {
    Finished,
    Applied {
        diags: Vec<Diagnostic>,
        set: CorrectionSet,
    },
}

fn next_pass(ctx: &LintCtx<'_>, bytes: &[u8]) -> Result<PassOutcome> {
    let LintOutput {
        diagnostics,
        corrections,
    } = ctx.lint_bytes(bytes)?;
    let Some(set) = corrections
        .filter(|c| !c.is_empty())
        .map(CorrectionSet::from_vec)
        .filter(|s| !s.is_empty())
    else {
        return Ok(PassOutcome::Finished);
    };
    Ok(PassOutcome::Applied {
        diags: diagnostics,
        set,
    })
}

fn final_pass(ctx: &LintCtx<'_>, bytes: &[u8]) -> Result<Vec<Diagnostic>> {
    let off = LintCtx {
        mode: AutocorrectMode::Off,
        ..*ctx
    };
    Ok(off.lint_bytes(bytes)?.diagnostics)
}

fn sha256(bytes: &[u8]) -> [u8; 32] {
    let mut h = Sha256::new();
    h.update(bytes);
    h.finalize().into()
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::*;
    use crate::config::ResolvedConfig;
    use crate::cop::registry::CopRegistry;

    fn lint_only(path: &str, bytes: &mut Vec<u8>, only: &str) -> Vec<Diagnostic> {
        let config = ResolvedConfig::empty();
        let registry = CopRegistry::default_registry();
        let filters = CopFilterSet::build(&config, &registry);
        let only = [only.to_string()];
        lint_bytes_autocorrect(
            Path::new(path),
            bytes,
            &config,
            &registry,
            &filters,
            Some(&only),
            &[],
            AutocorrectMode::All,
            false,
        )
        .expect("lint")
    }

    #[test]
    fn first_hash_shifts_value_lines() {
        let mut bytes = b"a = {\n    a: [\n      1\n    ]\n}\n".to_vec();
        lint_only("t.rb", &mut bytes, "Layout/FirstHashElementIndentation");
        assert_eq!(bytes, b"a = {\n  a: [\n    1\n  ]\n}\n");
    }

    #[test]
    fn loop_applies_multiple_passes() {
        let mut bytes = b"x = 1  \n  y = 2  \n".to_vec();
        let diags = lint_only("sample.rb", &mut bytes, "Layout/TrailingWhitespace");
        assert_eq!(bytes, b"x = 1\n  y = 2\n");
        assert!(diags.iter().all(|d| d.corrected));
    }

    #[test]
    fn no_duplicate_remaining_offenses() {
        let diags = lint_only("Gemfile", &mut b"gem 'b'\ngem 'a'\n".to_vec(), "Bundler/OrderedGems");
        let n = diags
            .iter()
            .filter(|d| d.cop_name == "Bundler/OrderedGems")
            .count();
        assert_eq!(n, 1, "remaining offenses must not be duplicated: {diags:?}");
    }

    #[test]
    fn string_literals_autocorrect() {
        let mut bytes = b"x = \"hello\"\n".to_vec();
        let diags = lint_only("sample.rb", &mut bytes, "Style/StringLiterals");
        assert_eq!(bytes, b"x = 'hello'\n");
        assert!(diags.iter().all(|d| d.corrected));
    }

    #[test]
    fn regexp_literal_autocorrect() {
        let mut bytes = b"x = /\\/foo$/i\n".to_vec();
        let diags = lint_only("sample.rb", &mut bytes, "Style/RegexpLiteral");
        assert_eq!(bytes, b"x = %r{/foo$}i\n");
        assert!(diags.iter().all(|d| d.corrected));
    }

    #[test]
    fn reconcile_clears_false_corrected_flags() {
        use crate::correction::{reconcile_corrected, Correction, CorrectionSet};
        use crate::diagnostic::{Diagnostic, Location, Severity};
        use crate::parse::source::SourceFile;

        let source = SourceFile::from_bytes(Path::new("a.rb"), b"foo  \n".to_vec());
        let mut diags = vec![Diagnostic {
            path: "a.rb".into(),
            cop_name: "Layout/TrailingWhitespace".into(),
            message: "m".into(),
            location: Location { line: 1, column: 4 },
            severity: Severity::Convention,
            corrected: true,
            correctable: true,
            source_line: String::new(),
            highlight_length: 1,
        }];
        reconcile_corrected(
            &mut diags,
            &source,
            &CorrectionSet::from_vec(vec![Correction {
                start: 3,
                end: 5,
                replacement: String::new(),
                cop_name: "Layout/Other",
                cop_index: 0,
            }]),
        );
        assert!(!diags[0].corrected);
    }
}