Repository: PlasmaFAIR/fortitude (language: python)

Issue:
Auto-fixes
Rules should give suggestions for fixes where possible, with a mode to automatically apply them


Discussion / hints:
I've been playing around and trying to understand how to edit a tree-sitter tree 'in-place'. I think I have a working example:

```rust
extern crate tree_sitter;
extern crate tree_sitter_fortran;
use textwrap::dedent;
use tree_sitter::{InputEdit, Node, Point};

/// Utility for printing the tree to terminal
fn print_tree(node: &Node, indent: usize) {
    let mut cursor = node.walk();
    for child in node.named_children(&mut cursor) {
        let range = child.range();
        println!(
            "{}{}, pos={}, byte={}",
            "  ".repeat(indent),
            child.kind(),
            range.start_point,
            range.start_byte,
        );
        print_tree(&child, indent + 1);
    }
}

/// Parse some Fortran code, edit it with something a linter might do, and reparse.
fn main() {
    // Set up tree sitter parser
    let mut parser = tree_sitter::Parser::new();
    parser
        .set_language(&tree_sitter_fortran::LANGUAGE.into())
        .expect("Error loading Fortran grammar");

    // Parse some code
    let source = dedent(
        "
        program myprog
          integer, parameter :: i = 1
          write (*,*) i
        end program myprog
        ",
    );
    let mut tree = parser.parse(source.as_str(), None).unwrap();

    // Print current state of code to the terminal
    println!("{source}\n");
    print_tree(&tree.root_node(), 0);
    println!("\n");

    // Add new line after line 2 to add 'implicit none'
    let new_source = dedent(
        "
        program myprog
          implicit none
          integer, parameter :: i = 1
          write (*,*) i
        end program myprog
        ",
    );
    // First need to sync the old tree to account for new bytes added.
    // We'll be adding the following snippet to the start of the variable_declaration:
    let new_line = "implicit none\n  ";
    // By checking the result of print_tree() on the old tree, we see that the variable
    // declaration begins at position (2,2), byte 18.
    let edit = InputEdit {
        start_byte: 18,
        old_end_byte: 18,
        new_end_byte: 18 + new_line.len(),
        start_position: Point { row: 2, column: 2 },
        old_end_position: Point { row: 2, column: 2 },
        new_end_position: Point { row: 3, column: 2 },
    };
    tree.edit(&edit);
    
    // Print the edited tree and check that the bytes and positions have been updated.
    println!("Edited:");
    print_tree(&tree.root_node(), 0);
    println!("\n");

    // Parse new code, using old tree as starting point
    tree = parser.parse(new_source.as_str(), Some(&tree)).unwrap();

    // Check that it's the same as the last tree, only with a new implicit_statement node
    println!("{new_source}\n");
    print_tree(&tree.root_node(), 0);
    println!("\n");
}
```

I compiled this with the following `Cargo.toml`:

```toml
[package]
name = "tree_sitter_edits"
version = "0.1.0"
edition = "2021"

[dependencies]
textwrap = "0.16.0"
tree-sitter = "~0.23.0"
tree-sitter-fortran = "0.1.0"

```

There are some outstanding questions I have:

- How do we edit the source code in a general manner? Is there some way we can use the `start_byte`, `old_end_byte` and `new_end_byte` to efficiently insert new code into the source?
- In cases like this where we're adding new lines, how do we keep indentation consistent?
- When running in `check --fix` mode, how do we iterate over the tree? I imagine we would have to do something like:

  - Search the tree until finding a fixable violation.
  - Apply the fix: write new source code, build a new tree.
  - Search the new tree starting again from the root node.
  - Continue until no fixable violations are found.
  - Perform a final pass to collect all non-fixable violations.

  We could reduce the number of passes we would need to perform by collecting non-overlapping diffs during each pass, applying them all at once, and then repeating until no fixable violations are found. That algorithm could be quite complicated though.
Nice! That's a good start.

We should look at the two really good bits of prior art, ruff and clang-tidy, and steal their algorithm(s). Ruff's looks to be in `lint_fix` in `ruff_linter/src/linters.rs`. Clang stuff is a lot more complex, but this looks like a reasonable place to start: https://clang.llvm.org/docs/ClangTransformerTutorial.html

It does seem like ruff follows your suggested approach: repeatedly applying fixes and reparsing until the result is stable (or it fails).

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index d25d6d8..9c33413 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1,6 +1,6 @@
 # This file is automatically @generated by Cargo.
 # It is not intended for manual editing.
-version = 3
+version = 4
 
 [[package]]
 name = "aho-corasick"
@@ -427,6 +427,7 @@ dependencies = [
  "ruff_macros",
  "ruff_source_file",
  "ruff_text_size",
+ "rustc-hash",
  "serde",
  "serde_json",
  "shadow-rs",
@@ -441,6 +442,7 @@ dependencies = [
  "toml",
  "tree-sitter",
  "tree-sitter-fortran",
+ "unicode-width 0.2.0",
  "url",
  "walkdir",
 ]
@@ -1164,6 +1166,12 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "rustc-hash"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497"
+
 [[package]]
 name = "rustix"
 version = "0.38.38"
diff --git a/README.md b/README.md
index a95eebc..b0c58c0 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,54 @@ fortitude --help
 fortitude check --help
 ```
 
+### Fixes
+
+> [!NOTE]
+> Added in v0.6.0
+
+Fortitude can automatically fix some lint warnings, such as
+unnecessary `implicit none` statements, missing double-colons in
+variable declarations, and more. Just pass the `--fix` flag to
+`check`:
+
+```console
+$ fortitude check --fix
+fortitude: 1 files scanned.
+Number of errors: 2 (2 fixed, 0 remaining)
+```
+
+Run `fortitude explain` to see which rules have fixes available.
+
+### Preview
+
+> [!NOTE]
+> Added in v0.6.0
+
+Some fortitude rules are only available through an opt-in preview
+mode to give the community some time to evaluate them and provide
+feedback. To enable preview rules, pass the `--preview` flag to
+`check`,
+
+```console
+$ fortitude check --preview
+```
+
+or to enable more permanently, set it in your `fpm.toml`:
+
+```toml
+[extra.fortitude.check]
+preview = true
+```
+
+or `fortitude.toml`:
+
+```toml
+[check]
+preview = true
+```
+
+Run `fortitude explain` to see which rules are in preview mode.
+
 ## Configuration
 
 Fortitude will look for either a `fortitude.toml` or `fpm.toml` file in the
diff --git a/fortitude/Cargo.toml b/fortitude/Cargo.toml
index ab97801..56ff2a3 100644
--- a/fortitude/Cargo.toml
+++ b/fortitude/Cargo.toml
@@ -39,6 +39,7 @@ ruff_diagnostics = { git = "https://github.com/astral-sh/ruff.git", tag = "0.7.1
 ruff_macros = { git = "https://github.com/astral-sh/ruff.git", tag = "0.7.1", version = "0.0.0" }
 ruff_source_file = { git = "https://github.com/astral-sh/ruff.git", tag = "0.7.1", version = "0.0.0", features = ["serde"] }
 ruff_text_size = { git = "https://github.com/astral-sh/ruff.git", tag = "0.7.1", version = "0.0.0" }
+rustc-hash = "2.0.0"
 serde = { version = "1.0.210", features = ["derive"] }
 serde_json = { workspace = true }
 similar = { version = "2.4.0", features = ["inline"] }
@@ -51,6 +52,7 @@ thiserror = { version = "1.0.58" }
 toml = "0.8.19"
 tree-sitter = "~0.24.0"
 tree-sitter-fortran = "0.3.0"
+unicode-width = "0.2.0"
 url = { version = "2.5.0" }
 walkdir = "2.4.0"
 
diff --git a/fortitude/src/check.rs b/fortitude/src/check.rs
index a3b848c..fbb23ee 100644
--- a/fortitude/src/check.rs
+++ b/fortitude/src/check.rs
@@ -1,22 +1,33 @@
 use crate::ast::FortitudeNode;
 use crate::cli::{CheckArgs, GlobalConfigArgs, FORTRAN_EXTS};
+use crate::diagnostics::{Diagnostics, FixMap};
+use crate::fix::{fix_file, FixResult};
+use crate::fs;
 use crate::message::DiagnosticMessage;
 use crate::printer::{Flags as PrinterFlags, Printer};
+use crate::registry::AsRule;
 use crate::rule_selector::{PreviewOptions, RuleSelector, Specificity};
 use crate::rules::Rule;
 use crate::rules::{error::ioerror::IoError, AstRuleEnum, PathRuleEnum, TextRuleEnum};
-use crate::settings::{OutputFormat, PreviewMode, ProgressBar, Settings, DEFAULT_SELECTORS};
+use crate::settings::{
+    FixMode, OutputFormat, PreviewMode, ProgressBar, Settings, UnsafeFixes, DEFAULT_SELECTORS,
+};
 
 use anyhow::{Context, Result};
+use colored::Colorize;
 use indicatif::{ParallelProgressIterator, ProgressStyle};
 use itertools::Itertools;
 use rayon::prelude::*;
 use ruff_diagnostics::Diagnostic;
-use ruff_source_file::{SourceFile, SourceFileBuilder};
+use ruff_source_file::{Locator, SourceFile, SourceFileBuilder};
 use ruff_text_size::TextRange;
+use rustc_hash::FxHashMap;
 use serde::{Deserialize, Serialize};
+use std::borrow::Cow;
 use std::collections::{BTreeMap, BTreeSet};
+use std::fs::File;
 use std::io;
+use std::io::Write;
 use std::path::{Path, PathBuf};
 use std::process::ExitCode;
 use strum::IntoEnumIterator;
@@ -131,6 +142,10 @@ pub struct CheckSettings {
     pub extend_select: Vec<RuleSelector>,
     pub line_length: usize,
     pub file_extensions: Vec<String>,
+    pub fix: bool,
+    pub fix_only: bool,
+    pub show_fixes: bool,
+    pub unsafe_fixes: UnsafeFixes,
     pub output_format: OutputFormat,
     pub progress_bar: ProgressBar,
     pub preview: PreviewMode,
@@ -159,6 +174,12 @@ fn parse_config_file(config_file: &Option<PathBuf>) -> Result<CheckSettings> {
             file_extensions: value
                 .file_extensions
                 .unwrap_or(FORTRAN_EXTS.iter().map(|ext| ext.to_string()).collect_vec()),
+            fix: resolve_bool_arg(value.fix, value.no_fix).unwrap_or_default(),
+            fix_only: resolve_bool_arg(value.fix_only, value.no_fix_only).unwrap_or_default(),
+            show_fixes: resolve_bool_arg(value.show_fixes, value.no_show_fixes).unwrap_or_default(),
+            unsafe_fixes: resolve_bool_arg(value.unsafe_fixes, value.no_unsafe_fixes)
+                .map(UnsafeFixes::from)
+                .unwrap_or_default(),
             output_format: value.output_format.unwrap_or_default(),
             progress_bar: value.progress_bar.unwrap_or_default(),
             preview: resolve_bool_arg(value.preview, value.no_preview)
@@ -245,6 +266,7 @@ fn get_files<S: AsRef<str>>(files_in: &Vec<PathBuf>, extensions: &[S]) -> Vec<Pa
 }
 
 /// Parse a file, check it for issues, and return the report.
+#[allow(clippy::too_many_arguments)]
 pub(crate) fn check_file(
     path_rules: &Vec<PathRuleEnum>,
     text_rules: &Vec<TextRuleEnum>,
@@ -252,7 +274,77 @@ pub(crate) fn check_file(
     path: &Path,
     file: &SourceFile,
     settings: &Settings,
-) -> anyhow::Result<Vec<Diagnostic>> {
+    fix_mode: FixMode,
+    unsafe_fixes: UnsafeFixes,
+) -> anyhow::Result<Diagnostics> {
+    let (messages, fixed) = if matches!(fix_mode, FixMode::Apply | FixMode::Diff) {
+        if let Ok(FixerResult {
+            result,
+            transformed,
+            fixed,
+        }) = check_and_fix_file(
+            path_rules,
+            text_rules,
+            ast_entrypoints,
+            path,
+            file,
+            settings,
+            unsafe_fixes,
+        ) {
+            if !fixed.is_empty() {
+                match fix_mode {
+                    FixMode::Apply => {
+                        let mut out_file = File::create(path)?;
+                        out_file.write_all(transformed.source_text().as_bytes())?;
+                    }
+                    // TODO: diff
+                    FixMode::Diff => {}
+                    FixMode::Generate => {}
+                }
+            }
+
+            (result, fixed)
+        } else {
+            // Failed to fix, so just lint the original source
+            let result = check_only_file(
+                path_rules,
+                text_rules,
+                ast_entrypoints,
+                path,
+                file,
+                settings,
+            )?;
+            let fixed = FxHashMap::default();
+            (result, fixed)
+        }
+    } else {
+        let result = check_only_file(
+            path_rules,
+            text_rules,
+            ast_entrypoints,
+            path,
+            file,
+            settings,
+        )?;
+        let fixed = FxHashMap::default();
+        (result, fixed)
+    };
+
+    Ok(Diagnostics {
+        messages,
+        fixed: FixMap::from_iter([(fs::relativize_path(path), fixed)]),
+    })
+}
+
+/// Parse a file, check it for issues, and return the report.
+pub(crate) fn check_only_file(
+    path_rules: &Vec<PathRuleEnum>,
+    text_rules: &Vec<TextRuleEnum>,
+    ast_entrypoints: &BTreeMap<&str, Vec<AstRuleEnum>>,
+    path: &Path,
+    file: &SourceFile,
+    settings: &Settings,
+) -> anyhow::Result<Vec<DiagnosticMessage>> {
     let mut violations = Vec::new();
 
     for rule in path_rules {
@@ -286,7 +378,169 @@ pub(crate) fn check_file(
         }
     }
 
-    Ok(violations)
+    Ok(violations
+        .into_iter()
+        .map(|v| DiagnosticMessage::from_ruff(file, v))
+        .collect_vec())
+}
+
+const MAX_ITERATIONS: usize = 100;
+
+pub type FixTable = FxHashMap<Rule, usize>;
+
+pub struct FixerResult<'a> {
+    /// The result returned by the linter, after applying any fixes.
+    pub result: Vec<DiagnosticMessage>,
+    /// The resulting source code, after applying any fixes.
+    pub transformed: Cow<'a, SourceFile>,
+    /// The number of fixes applied for each [`Rule`].
+    pub fixed: FixTable,
+}
+
+pub(crate) fn check_and_fix_file<'a>(
+    path_rules: &Vec<PathRuleEnum>,
+    text_rules: &Vec<TextRuleEnum>,
+    ast_entrypoints: &BTreeMap<&str, Vec<AstRuleEnum>>,
+    path: &Path,
+    file: &'a SourceFile,
+    settings: &Settings,
+    unsafe_fixes: UnsafeFixes,
+) -> anyhow::Result<FixerResult<'a>> {
+    let mut transformed = Cow::Borrowed(file);
+
+    // Track the number of fixed errors across iterations.
+    let mut fixed = FxHashMap::default();
+
+    // As an escape hatch, bail after 100 iterations.
+    let mut iterations = 0;
+
+    // Track whether the _initial_ source code is valid syntax.
+    // TODO(peter): implement this
+    #[allow(unused_variables, unused_mut)]
+    let mut is_valid_syntax = false;
+
+    let mut parser = Parser::new();
+    parser
+        .set_language(&tree_sitter_fortran::LANGUAGE.into())
+        .context("Error loading Fortran grammar")?;
+
+    // Continuously fix until the source code stabilizes.
+    loop {
+        let mut violations = Vec::new();
+
+        // Map row and column locations to byte slices (lazily).
+        let locator = Locator::new(transformed.source_text());
+
+        // No fixes on file path
+        for rule in path_rules {
+            if let Some(violation) = rule.check(settings, path) {
+                violations.push(violation);
+            }
+        }
+
+        // Perform plain text analysis
+        for rule in text_rules {
+            violations.extend(rule.check(settings, &transformed));
+        }
+
+        // TODO: check for syntax errors on first pass, so we can know
+        // if we've introduced them
+        let tree = parser
+            .parse(transformed.source_text(), None)
+            .context("Failed to parse")?;
+
+        // Perform AST analysis
+        for node in tree.root_node().named_descendants() {
+            if let Some(rules) = ast_entrypoints.get(node.kind()) {
+                for rule in rules {
+                    if let Some(violation) = rule.check(settings, &node, &transformed) {
+                        for v in violation {
+                            violations.push(v);
+                        }
+                    }
+                }
+            }
+        }
+
+        // Apply fix
+        if let Some(FixResult {
+            code: fixed_contents,
+            fixes: applied,
+            ..
+        }) = fix_file(
+            &violations,
+            &locator,
+            unsafe_fixes,
+            path.to_string_lossy().as_ref(),
+        ) {
+            if iterations < MAX_ITERATIONS {
+                // Count the number of fixed errors
+                for (rule, count) in applied {
+                    *fixed.entry(rule).or_default() += count;
+                }
+
+                transformed = Cow::Owned(fixed_contents);
+
+                iterations += 1;
+
+                // Re-run the linter pass
+                continue;
+            }
+
+            report_failed_to_converge_error(path, transformed.source_text(), &violations);
+        };
+
+        return Ok(FixerResult {
+            result: violations
+                .into_iter()
+                .map(|v| DiagnosticMessage::from_ruff(file, v))
+                .collect_vec(),
+            transformed,
+            fixed,
+        });
+    }
+}
+
+fn collect_rule_codes(rules: impl IntoIterator<Item = Rule>) -> String {
+    rules
+        .into_iter()
+        .map(|rule| rule.noqa_code().to_string())
+        .sorted_unstable()
+        .dedup()
+        .join(", ")
+}
+
+#[allow(clippy::print_stderr)]
+fn report_failed_to_converge_error(path: &Path, transformed: &str, diagnostics: &[Diagnostic]) {
+    let codes = collect_rule_codes(diagnostics.iter().map(|diagnostic| diagnostic.kind.rule()));
+    if cfg!(debug_assertions) {
+        eprintln!(
+            "{}{} Failed to converge after {} iterations in `{}` with rule codes {}:---\n{}\n---",
+            "debug error".red().bold(),
+            ":".bold(),
+            MAX_ITERATIONS,
+            fs::relativize_path(path),
+            codes,
+            transformed,
+        );
+    } else {
+        eprintln!(
+            r#"
+{}{} Failed to converge after {} iterations.
+
+This indicates a bug in fortitude. If you could open an issue at:
+
+    https://github.com/PlasmaFAIR/fortitude/issues/new?title=%5BInfinite%20loop%5D
+
+...quoting the contents of `{}`, the rule codes {}, along with the `fpm.toml` settings and executed command, we'd be very appreciative!
+"#,
+            "error".red().bold(),
+            ":".bold(),
+            MAX_ITERATIONS,
+            fs::relativize_path(path),
+            codes
+        );
+    }
 }
 
 /// Wrapper around `std::fs::read_to_string` with some extra error
@@ -397,6 +651,30 @@ pub fn check(args: CheckArgs, global_options: &GlobalConfigArgs) -> Result<ExitC
         progress_bar = ProgressBar::Ascii;
     }
 
+    let fix = resolve_bool_arg(args.fix, args.no_fix).unwrap_or(file_settings.fix);
+    let fix_only =
+        resolve_bool_arg(args.fix_only, args.no_fix_only).unwrap_or(file_settings.fix_only);
+    let unsafe_fixes = resolve_bool_arg(args.unsafe_fixes, args.no_unsafe_fixes)
+        .map(UnsafeFixes::from)
+        .unwrap_or(file_settings.unsafe_fixes);
+
+    let show_fixes =
+        resolve_bool_arg(args.show_fixes, args.no_show_fixes).unwrap_or(file_settings.show_fixes);
+
+    // Fix rules are as follows:
+    // - By default, generate all fixes, but don't apply them to the filesystem.
+    // - If `--fix` or `--fix-only` is set, apply applicable fixes to the filesystem (or
+    //   print them to stdout, if we're reading from stdin).
+    // - If `--diff` or `--fix-only` are set, don't print any violations (only applicable fixes)
+    // - By default, applicable fixes only include [`Applicablility::Automatic`], but if
+    //   `--unsafe-fixes` is set, then [`Applicablility::Suggested`] fixes are included.
+
+    let fix_mode = if fix || fix_only {
+        FixMode::Apply
+    } else {
+        FixMode::Generate
+    };
+
     // At this point, we've assembled all our settings, and we're
     // ready to check the project
 
@@ -432,21 +710,21 @@ pub fn check(args: CheckArgs, global_options: &GlobalConfigArgs) -> Result<ExitC
         ProgressBar::Off => ProgressStyle::with_template("").unwrap(),
     };
 
-    let mut diagnostics: Vec<_> = files
+    let diagnostics_per_file = files
         .par_iter()
         .progress_with_style(progress_bar_style)
         .with_prefix("Checking file:")
-        .flat_map(|path| {
+        .map(|path| {
             let filename = path.to_string_lossy();
 
             let source = match read_to_string(path) {
                 Ok(source) => source,
                 Err(error) => {
                     let message = format!("Error opening file: {error}");
-                    return vec![DiagnosticMessage::from_error(
+                    return Diagnostics::new(vec![DiagnosticMessage::from_error(
                         filename,
                         Diagnostic::new(IoError { message }, TextRange::default()),
-                    )];
+                    )]);
                 }
             };
 
@@ -459,31 +737,45 @@ pub fn check(args: CheckArgs, global_options: &GlobalConfigArgs) -> Result<ExitC
                 path,
                 &file,
                 &settings,
+                fix_mode,
+                unsafe_fixes,
             ) {
-                Ok(violations) => violations
-                    .into_iter()
-                    .map(|v| DiagnosticMessage::from_ruff(&file, v))
-                    .collect_vec(),
+                Ok(violations) => violations,
                 Err(msg) => {
                     let message = format!("Failed to process: {msg}");
-                    vec![DiagnosticMessage::from_error(
+                    Diagnostics::new(vec![DiagnosticMessage::from_error(
                         filename,
                         Diagnostic::new(IoError { message }, TextRange::default()),
-                    )]
+                    )])
                 }
             }
+        });
+
+    let mut all_diagnostics = diagnostics_per_file
+        .fold(Diagnostics::default, |all_diagnostics, file_diagnostics| {
+            all_diagnostics + file_diagnostics
         })
-        .collect();
+        .reduce(Diagnostics::default, |a, b| a + b);
 
-    diagnostics.par_sort_unstable();
+    all_diagnostics.messages.par_sort_unstable();
 
-    let total_errors = diagnostics.len();
+    let total_errors = all_diagnostics.messages.len();
 
     let mut writer = Box::new(io::stdout());
 
-    let flags = PrinterFlags::SHOW_VIOLATIONS | PrinterFlags::SHOW_FIX_SUMMARY;
+    let mut printer_flags = PrinterFlags::empty();
+    if !fix_only {
+        printer_flags |= PrinterFlags::SHOW_VIOLATIONS;
+    }
+    if show_fixes {
+        printer_flags |= PrinterFlags::SHOW_FIX_SUMMARY;
+    }
 
-    Printer::new(output_format, flags).write_once(files.len(), &diagnostics, &mut writer)?;
+    Printer::new(output_format, printer_flags, fix_mode, unsafe_fixes).write_once(
+        files.len(),
+        &all_diagnostics,
+        &mut writer,
+    )?;
 
     if total_errors == 0 {
         Ok(ExitCode::SUCCESS)
diff --git a/fortitude/src/cli.rs b/fortitude/src/cli.rs
index 802595f..f326af7 100644
--- a/fortitude/src/cli.rs
+++ b/fortitude/src/cli.rs
@@ -1,4 +1,4 @@
-use clap::{Parser, Subcommand};
+use clap::{ArgAction::SetTrue, Parser, Subcommand};
 use serde::{Deserialize, Serialize};
 use std::path::PathBuf;
 
@@ -97,6 +97,31 @@ pub struct CheckArgs {
     #[arg(long, value_delimiter = ',', default_values = FORTRAN_EXTS)]
     pub file_extensions: Option<Vec<String>>,
 
+    /// Apply fixes to resolve lint violations.
+    /// Use `--no-fix` to disable or `--unsafe-fixes` to include unsafe fixes.
+    #[arg(long, overrides_with("no_fix"), action = clap::ArgAction::SetTrue)]
+    pub fix: Option<bool>,
+    #[clap(long, overrides_with("fix"), hide = true, action = SetTrue)]
+    pub no_fix: Option<bool>,
+    /// Include fixes that may not retain the original intent of the code.
+    /// Use `--no-unsafe-fixes` to disable.
+    #[arg(long, overrides_with("no_unsafe_fixes"), action = SetTrue)]
+    pub unsafe_fixes: Option<bool>,
+    #[arg(long, overrides_with("unsafe_fixes"), hide = true, action = SetTrue)]
+    pub no_unsafe_fixes: Option<bool>,
+    /// Show an enumeration of all fixed lint violations.
+    /// Use `--no-show-fixes` to disable.
+    #[arg(long, overrides_with("no_show_fixes"), action = SetTrue)]
+    pub show_fixes: Option<bool>,
+    #[clap(long, overrides_with("show_fixes"), hide = true, action = SetTrue)]
+    pub no_show_fixes: Option<bool>,
+    /// Apply fixes to resolve lint violations, but don't report on, or exit non-zero for, leftover violations. Implies `--fix`.
+    /// Use `--no-fix-only` to disable or `--unsafe-fixes` to include unsafe fixes.
+    #[arg(long, overrides_with("no_fix_only"), action = SetTrue)]
+    pub fix_only: Option<bool>,
+    #[clap(long, overrides_with("fix_only"), hide = true, action = SetTrue)]
+    pub no_fix_only: Option<bool>,
+
     /// Output serialization format for violations.
     /// The default serialization format is "full".
     #[arg(long, value_enum, env = "FORTITUDE_OUTPUT_FORMAT")]
@@ -104,9 +129,9 @@ pub struct CheckArgs {
 
     /// Enable preview mode; checks will include unstable rules and fixes.
     /// Use `--no-preview` to disable.
-    #[arg(long, overrides_with("no_preview"), action = clap::ArgAction::SetTrue)]
+    #[arg(long, overrides_with("no_preview"), action = SetTrue)]
     pub preview: Option<bool>,
-    #[clap(long, overrides_with("preview"), hide = true, action = clap::ArgAction::SetTrue)]
+    #[clap(long, overrides_with("preview"), hide = true, action = SetTrue)]
     pub no_preview: Option<bool>,
 
     /// Progress bar settings.
diff --git a/fortitude/src/diagnostics.rs b/fortitude/src/diagnostics.rs
new file mode 100644
index 0000000..9917919
--- /dev/null
+++ b/fortitude/src/diagnostics.rs
@@ -0,0 +1,87 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::ops::{Add, AddAssign};
+
+use rustc_hash::FxHashMap;
+
+use crate::{fix::FixTable, message::DiagnosticMessage};
+
+#[derive(Debug, Default, PartialEq)]
+pub(crate) struct Diagnostics {
+    pub(crate) messages: Vec<DiagnosticMessage>,
+    pub(crate) fixed: FixMap,
+}
+
+impl Diagnostics {
+    pub(crate) fn new(messages: Vec<DiagnosticMessage>) -> Self {
+        Self {
+            messages,
+            fixed: FixMap::default(),
+        }
+    }
+}
+
+impl Add for Diagnostics {
+    type Output = Diagnostics;
+
+    fn add(mut self, other: Self) -> Self::Output {
+        self += other;
+        self
+    }
+}
+
+impl AddAssign for Diagnostics {
+    fn add_assign(&mut self, other: Self) {
+        self.messages.extend(other.messages);
+        self.fixed += other.fixed;
+    }
+}
+
+/// A collection of fixes indexed by file path.
+#[derive(Debug, Default, PartialEq)]
+pub(crate) struct FixMap(FxHashMap<String, FixTable>);
+
+impl FixMap {
+    /// Returns `true` if there are no fixes in the map.
+    pub(crate) fn is_empty(&self) -> bool {
+        self.0.is_empty()
+    }
+
+    /// Returns an iterator over the fixes in the map, along with the file path.
+    pub(crate) fn iter(&self) -> impl Iterator<Item = (&String, &FixTable)> {
+        self.0.iter()
+    }
+
+    /// Returns an iterator over the fixes in the map.
+    pub(crate) fn values(&self) -> impl Iterator<Item = &FixTable> {
+        self.0.values()
+    }
+}
+
+impl FromIterator<(String, FixTable)> for FixMap {
+    fn from_iter<T: IntoIterator<Item = (String, FixTable)>>(iter: T) -> Self {
+        Self(
+            iter.into_iter()
+                .filter(|(_, fixes)| !fixes.is_empty())
+                .collect(),
+        )
+    }
+}
+
+impl AddAssign for FixMap {
+    fn add_assign(&mut self, rhs: Self) {
+        for (filename, fixed) in rhs.0 {
+            if fixed.is_empty() {
+                continue;
+            }
+            let fixed_in_file = self.0.entry(filename).or_default();
+            for (rule, count) in fixed {
+                if count > 0 {
+                    *fixed_in_file.entry(rule).or_default() += count;
+                }
+            }
+        }
+    }
+}
diff --git a/fortitude/src/fix/mod.rs b/fortitude/src/fix/mod.rs
new file mode 100644
index 0000000..4ebc269
--- /dev/null
+++ b/fortitude/src/fix/mod.rs
@@ -0,0 +1,365 @@
+// Adapted from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::collections::BTreeSet;
+
+use itertools::Itertools;
+use rustc_hash::{FxHashMap, FxHashSet};
+
+use ruff_diagnostics::{Diagnostic, Edit, Fix, IsolationLevel, SourceMap};
+use ruff_source_file::{Locator, SourceFile, SourceFileBuilder};
+use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
+
+use crate::registry::{AsRule, Rule};
+use crate::settings::UnsafeFixes;
+
+pub(crate) mod snippet;
+
+pub type FixTable = FxHashMap<Rule, usize>;
+
+pub(crate) struct FixResult {
+    /// The resulting source code, after applying all fixes.
+    pub(crate) code: SourceFile,
+    /// The number of fixes applied for each [`Rule`].
+    pub(crate) fixes: FixTable,
+    /// Source map for the fixed source code.
+    #[allow(dead_code)]
+    pub(crate) source_map: SourceMap,
+}
+
+/// Fix errors in a file, and write the fixed source code to disk.
+pub(crate) fn fix_file(
+    diagnostics: &[Diagnostic],
+    locator: &Locator,
+    unsafe_fixes: UnsafeFixes,
+    name: &str,
+) -> Option<FixResult> {
+    let required_applicability = unsafe_fixes.required_applicability();
+
+    let mut with_fixes = diagnostics
+        .iter()
+        .filter(|diagnostic| {
+            diagnostic
+                .fix
+                .as_ref()
+                .map_or(false, |fix| fix.applies(required_applicability))
+        })
+        .peekable();
+
+    if with_fixes.peek().is_none() {
+        None
+    } else {
+        Some(apply_fixes(with_fixes, locator, name))
+    }
+}
+
+/// Apply a series of fixes.
+fn apply_fixes<'a>(
+    diagnostics: impl Iterator<Item = &'a Diagnostic>,
+    locator: &'a Locator<'a>,
+    name: &str,
+) -> FixResult {
+    let mut output = String::with_capacity(locator.len());
+    let mut last_pos: Option<TextSize> = None;
+    let mut applied: BTreeSet<&Edit> = BTreeSet::default();
+    let mut isolated: FxHashSet<u32> = FxHashSet::default();
+    let mut fixed = FxHashMap::default();
+    let mut source_map = SourceMap::default();
+
+    for (rule, fix) in diagnostics
+        .filter_map(|diagnostic| {
+            diagnostic
+                .fix
+                .as_ref()
+                .map(|fix| (diagnostic.kind.rule(), fix))
+        })
+        .sorted_by(|(rule1, fix1), (rule2, fix2)| cmp_fix(*rule1, *rule2, fix1, fix2))
+    {
+        let mut edits = fix
+            .edits()
+            .iter()
+            .filter(|edit| !applied.contains(edit))
+            .peekable();
+
+        // If the fix contains at least one new edit, enforce isolation and positional requirements.
+        if let Some(first) = edits.peek() {
+            // If this fix requires isolation, and we've already applied another fix in the
+            // same isolation group, skip it.
+            if let IsolationLevel::Group(id) = fix.isolation() {
+                if !isolated.insert(id) {
+                    continue;
+                }
+            }
+
+            // If this fix overlaps with a fix we've already applied, skip it.
+            if last_pos.is_some_and(|last_pos| last_pos >= first.start()) {
+                continue;
+            }
+        }
+
+        let mut applied_edits = Vec::with_capacity(fix.edits().len());
+        for edit in edits {
+            // Add all contents from `last_pos` to `fix.location`.
+            let slice = locator.slice(TextRange::new(last_pos.unwrap_or_default(), edit.start()));
+            output.push_str(slice);
+
+            // Add the start source marker for the patch.
+            source_map.push_start_marker(edit, output.text_len());
+
+            // Add the patch itself.
+            output.push_str(edit.content().unwrap_or_default());
+
+            // Add the end source marker for the added patch.
+            source_map.push_end_marker(edit, output.text_len());
+
+            // Track that the edit was applied.
+            last_pos = Some(edit.end());
+            applied_edits.push(edit);
+        }
+
+        applied.extend(applied_edits.drain(..));
+        *fixed.entry(rule).or_default() += 1;
+    }
+
+    // Add the remaining content.
+    let slice = locator.after(last_pos.unwrap_or_default());
+    output.push_str(slice);
+
+    // TODO: Should be able to reuse locator here? Or don't store a
+    // SourceFile in FixResult, store something else?
+    let source_file = SourceFileBuilder::new(name, output).finish();
+
+    FixResult {
+        code: source_file,
+        fixes: fixed,
+        source_map,
+    }
+}
+
+/// Compare two fixes.
+fn cmp_fix(_rule1: Rule, _rule2: Rule, fix1: &Fix, fix2: &Fix) -> std::cmp::Ordering {
+    // Apply fixes in order of their start position.
+    fix1.min_start().cmp(&fix2.min_start())
+}
+
+#[cfg(test)]
+mod tests {
+    use ruff_diagnostics::{Diagnostic, Edit, Fix, SourceMarker};
+    use ruff_source_file::Locator;
+    use ruff_text_size::{Ranged, TextSize};
+
+    use crate::fix::{apply_fixes, FixResult};
+    use crate::rules::modules::use_statements::UseAll;
+
+    #[allow(deprecated)]
+    fn create_diagnostics(edit: impl IntoIterator<Item = Edit>) -> Vec<Diagnostic> {
+        edit.into_iter()
+            .map(|edit| Diagnostic {
+                // The choice of rule here is arbitrary.
+                kind: UseAll {}.into(),
+                range: edit.range(),
+                fix: Some(Fix::safe_edit(edit)),
+                parent: None,
+            })
+            .collect()
+    }
+
+    #[test]
+    fn empty_file() {
+        let locator = Locator::new(r"");
+        let diagnostics = create_diagnostics([]);
+        let FixResult {
+            code,
+            fixes,
+            source_map,
+        } = apply_fixes(diagnostics.iter(), &locator, "test.f90");
+        assert_eq!(code.source_text(), "");
+        assert_eq!(fixes.values().sum::<usize>(), 0);
+        assert!(source_map.markers().is_empty());
+    }
+
+    #[test]
+    fn apply_one_insertion() {
+        let locator = Locator::new(
+            r#"
+import os
+
+print("hello world")
+"#
+            .trim(),
+        );
+        let diagnostics = create_diagnostics([Edit::insertion(
+            "import sys\n".to_string(),
+            TextSize::new(10),
+        )]);
+        let FixResult {
+            code,
+            fixes,
+            source_map,
+        } = apply_fixes(diagnostics.iter(), &locator, "test.f90");
+        assert_eq!(
+            code.source_text(),
+            r#"
+import os
+import sys
+
+print("hello world")
+"#
+            .trim()
+        );
+        assert_eq!(fixes.values().sum::<usize>(), 1);
+        assert_eq!(
+            source_map.markers(),
+            &[
+                SourceMarker::new(10.into(), 10.into()),
+                SourceMarker::new(10.into(), 21.into()),
+            ]
+        );
+    }
+
+    #[test]
+    fn apply_one_replacement() {
+        let locator = Locator::new(
+            r"
+class A(object):
+    ...
+"
+            .trim(),
+        );
+        let diagnostics = create_diagnostics([Edit::replacement(
+            "Bar".to_string(),
+            TextSize::new(8),
+            TextSize::new(14),
+        )]);
+        let FixResult {
+            code,
+            fixes,
+            source_map,
+        } = apply_fixes(diagnostics.iter(), &locator, "test.f90");
+        assert_eq!(
+            code.source_text(),
+            r"
+class A(Bar):
+    ...
+"
+            .trim(),
+        );
+        assert_eq!(fixes.values().sum::<usize>(), 1);
+        assert_eq!(
+            source_map.markers(),
+            &[
+                SourceMarker::new(8.into(), 8.into()),
+                SourceMarker::new(14.into(), 11.into()),
+            ]
+        );
+    }
+
+    #[test]
+    fn apply_one_removal() {
+        let locator = Locator::new(
+            r"
+class A(object):
+    ...
+"
+            .trim(),
+        );
+        let diagnostics = create_diagnostics([Edit::deletion(TextSize::new(7), TextSize::new(15))]);
+        let FixResult {
+            code,
+            fixes,
+            source_map,
+        } = apply_fixes(diagnostics.iter(), &locator, "test.f90");
+        assert_eq!(
+            code.source_text(),
+            r"
+class A:
+    ...
+"
+            .trim()
+        );
+        assert_eq!(fixes.values().sum::<usize>(), 1);
+        assert_eq!(
+            source_map.markers(),
+            &[
+                SourceMarker::new(7.into(), 7.into()),
+                SourceMarker::new(15.into(), 7.into()),
+            ]
+        );
+    }
+
+    #[test]
+    fn apply_two_removals() {
+        let locator = Locator::new(
+            r"
+class A(object, object, object):
+    ...
+"
+            .trim(),
+        );
+        let diagnostics = create_diagnostics([
+            Edit::deletion(TextSize::from(8), TextSize::from(16)),
+            Edit::deletion(TextSize::from(22), TextSize::from(30)),
+        ]);
+        let FixResult {
+            code,
+            fixes,
+            source_map,
+        } = apply_fixes(diagnostics.iter(), &locator, "test.f90");
+
+        assert_eq!(
+            code.source_text(),
+            r"
+class A(object):
+    ...
+"
+            .trim()
+        );
+        assert_eq!(fixes.values().sum::<usize>(), 2);
+        assert_eq!(
+            source_map.markers(),
+            &[
+                SourceMarker::new(8.into(), 8.into()),
+                SourceMarker::new(16.into(), 8.into()),
+                SourceMarker::new(22.into(), 14.into()),
+                SourceMarker::new(30.into(), 14.into()),
+            ]
+        );
+    }
+
+    #[test]
+    fn ignore_overlapping_fixes() {
+        let locator = Locator::new(
+            r"
+class A(object):
+    ...
+"
+            .trim(),
+        );
+        let diagnostics = create_diagnostics([
+            Edit::deletion(TextSize::from(7), TextSize::from(15)),
+            Edit::replacement("ignored".to_string(), TextSize::from(9), TextSize::from(11)),
+        ]);
+        let FixResult {
+            code,
+            fixes,
+            source_map,
+        } = apply_fixes(diagnostics.iter(), &locator, "test.f90");
+        assert_eq!(
+            code.source_text(),
+            r"
+class A:
+    ...
+"
+            .trim(),
+        );
+        assert_eq!(fixes.values().sum::<usize>(), 1);
+        assert_eq!(
+            source_map.markers(),
+            &[
+                SourceMarker::new(7.into(), 7.into()),
+                SourceMarker::new(15.into(), 7.into()),
+            ]
+        );
+    }
+}
diff --git a/fortitude/src/fix/snippet.rs b/fortitude/src/fix/snippet.rs
new file mode 100644
index 0000000..5ca845f
--- /dev/null
+++ b/fortitude/src/fix/snippet.rs
@@ -0,0 +1,45 @@
+#![allow(dead_code)]
+// Taken from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use unicode_width::UnicodeWidthStr;
+
+/// A snippet of source code for user-facing display, as in a diagnostic.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct SourceCodeSnippet(String);
+
+impl SourceCodeSnippet {
+    pub(crate) fn new(source_code: String) -> Self {
+        Self(source_code)
+    }
+
+    pub(crate) fn from_str(source_code: &str) -> Self {
+        Self(source_code.to_string())
+    }
+
+    /// Return the full snippet for user-facing display, or `None` if the snippet should be
+    /// truncated.
+    pub(crate) fn full_display(&self) -> Option<&str> {
+        if Self::should_truncate(&self.0) {
+            None
+        } else {
+            Some(&self.0)
+        }
+    }
+
+    /// Return a truncated snippet for user-facing display.
+    pub(crate) fn truncated_display(&self) -> &str {
+        if Self::should_truncate(&self.0) {
+            "..."
+        } else {
+            &self.0
+        }
+    }
+
+    /// Returns `true` if the source code should be truncated when included in a user-facing
+    /// diagnostic.
+    fn should_truncate(source_code: &str) -> bool {
+        source_code.width() > 50 || source_code.contains(['\r', '\n'])
+    }
+}
diff --git a/fortitude/src/lib.rs b/fortitude/src/lib.rs
index e4b7622..49f2814 100644
--- a/fortitude/src/lib.rs
+++ b/fortitude/src/lib.rs
@@ -1,7 +1,9 @@
 mod ast;
 pub mod check;
 pub mod cli;
+mod diagnostics;
 pub mod explain;
+mod fix;
 mod fs;
 mod message;
 mod printer;
diff --git a/fortitude/src/printer.rs b/fortitude/src/printer.rs
index 29f54f8..c311c55 100644
--- a/fortitude/src/printer.rs
+++ b/fortitude/src/printer.rs
@@ -1,15 +1,18 @@
+use std::cmp::Reverse;
 use std::io::Write;
 
 use anyhow::Result;
 use bitflags::bitflags;
 use colored::Colorize;
+use itertools::{iterate, Itertools};
 
+use crate::diagnostics::{Diagnostics, FixMap};
+use crate::fs::relativize_path;
 use crate::message::{
-    AzureEmitter, DiagnosticMessage, Emitter, GithubEmitter, GitlabEmitter, GroupedEmitter,
-    JsonEmitter, JsonLinesEmitter, JunitEmitter, PylintEmitter, RdjsonEmitter, SarifEmitter,
-    TextEmitter,
+    AzureEmitter, Emitter, GithubEmitter, GitlabEmitter, GroupedEmitter, JsonEmitter,
+    JsonLinesEmitter, JunitEmitter, PylintEmitter, RdjsonEmitter, SarifEmitter, TextEmitter,
 };
-use crate::settings::OutputFormat;
+use crate::settings::{FixMode, OutputFormat, UnsafeFixes};
 
 bitflags! {
     #[derive(Default, Debug, Copy, Clone)]
@@ -26,23 +29,46 @@ bitflags! {
 pub(crate) struct Printer {
     format: OutputFormat,
     flags: Flags,
+    fix_mode: FixMode,
+    unsafe_fixes: UnsafeFixes,
 }
 
 impl Printer {
-    pub(crate) fn new(format: OutputFormat, flags: Flags) -> Self {
-        Self { format, flags }
+    pub(crate) fn new(
+        format: OutputFormat,
+        flags: Flags,
+        fix_mode: FixMode,
+        unsafe_fixes: UnsafeFixes,
+    ) -> Self {
+        Self {
+            format,
+            flags,
+            fix_mode,
+            unsafe_fixes,
+        }
     }
 
     fn write_summary_text(
         &self,
         writer: &mut dyn Write,
-        diagnostics: &[DiagnosticMessage],
+        diagnostics: &Diagnostics,
         num_files: usize,
     ) -> Result<()> {
         // IO Errors indicate that we failed to read a file
-        let num_failed = diagnostics.iter().filter(|x| x.name() == "IoError").count();
+        let num_failed = diagnostics
+            .messages
+            .iter()
+            .filter(|x| x.name() == "IoError")
+            .count();
         let total_files = num_files - num_failed;
 
+        let fixables = FixableStatistics::try_from(diagnostics, self.unsafe_fixes);
+        let fixed = diagnostics
+            .fixed
+            .values()
+            .flat_map(std::collections::HashMap::values)
+            .sum::<usize>();
+
         let file_no = if num_failed == 0 {
             format!(
                 "fortitude: {} files scanned.",
@@ -56,20 +82,71 @@ impl Printer {
             )
         };
 
-        let total_errors = diagnostics.len();
+        let remaining = diagnostics.messages.len();
+        let total = fixed + remaining;
 
-        if total_errors == 0 {
-            let success = "All checks passed!".bright_green();
-            writeln!(writer, "\n{file_no}\n{success}\n")?;
+        let total_txt = total.to_string().bold();
+        let fixed_txt = fixed.to_string().bold();
+        let remaining_txt = remaining.to_string().bold();
+
+        writeln!(writer, "{file_no}")?;
+
+        let explain = format!(
+            "fortitude explain {},{},...",
+            "X001".bold().bright_red(),
+            "Y002".bold().bright_red()
+        );
+        let info = format!("For more information about specific rules, run:\n\n    {explain}\n");
+
+        if fixed > 0 {
+            writeln!(writer, "Number of errors: {total_txt} ({fixed_txt} fixed, {remaining_txt} remaining)\n\n{info}")?;
+        } else if remaining > 0 {
+            writeln!(writer, "Number of errors: {remaining_txt}\n\n{info}")?;
         } else {
-            let err_no = format!("Number of errors: {}", total_errors.to_string().bold());
-            let info = "For more information about specific rules, run:";
-            let explain = format!(
-                "fortitude explain {},{},...",
-                "X001".bold().bright_red(),
-                "Y002".bold().bright_red()
-            );
-            writeln!(writer, "\n{file_no}\n{err_no}\n\n{info}\n\n    {explain}\n")?;
+            let success = "All checks passed!".bright_green();
+            writeln!(writer, "{success}\n")?;
+        }
+
+        if let Some(fixables) = fixables {
+            let fix_prefix = format!("[{}]", "*".cyan());
+
+            if self.unsafe_fixes.is_hint() {
+                if fixables.applicable > 0 && fixables.inapplicable_unsafe > 0 {
+                    let es = if fixables.inapplicable_unsafe == 1 {
+                        ""
+                    } else {
+                        "es"
+                    };
+                    writeln!(writer,
+                                "{fix_prefix} {} fixable with the `--fix` option ({} hidden fix{es} can be enabled with the `--unsafe-fixes` option).",
+                                fixables.applicable, fixables.inapplicable_unsafe
+                            )?;
+                } else if fixables.applicable > 0 {
+                    // Only applicable fixes
+                    writeln!(
+                        writer,
+                        "{fix_prefix} {} fixable with the `--fix` option.",
+                        fixables.applicable,
+                    )?;
+                } else {
+                    // Only inapplicable fixes
+                    let es = if fixables.inapplicable_unsafe == 1 {
+                        ""
+                    } else {
+                        "es"
+                    };
+                    writeln!(writer,
+                                "No fixes available ({} hidden fix{es} can be enabled with the `--unsafe-fixes` option).",
+                                fixables.inapplicable_unsafe
+                            )?;
+                }
+            } else if fixables.applicable > 0 {
+                writeln!(
+                    writer,
+                    "{fix_prefix} {} fixable with the --fix option.",
+                    fixables.applicable
+                )?;
+            }
         }
 
         Ok(())
@@ -78,10 +155,10 @@ impl Printer {
     pub(crate) fn write_once(
         &self,
         num_files: usize,
-        diagnostics: &[DiagnosticMessage],
+        diagnostics: &Diagnostics,
         writer: &mut dyn Write,
     ) -> Result<()> {
-        // TODO: implement tracking of fixables
+        let fixables = FixableStatistics::try_from(diagnostics, self.unsafe_fixes);
 
         match self.format {
             OutputFormat::Concise | OutputFormat::Full => {
@@ -90,50 +167,55 @@ impl Printer {
                     .with_show_fix_diff(self.flags.intersects(Flags::SHOW_FIX_DIFF))
                     .with_show_source(self.format == OutputFormat::Full)
                     .with_unsafe_fixes(crate::settings::UnsafeFixes::Hint)
-                    .emit(writer, diagnostics)?;
+                    .emit(writer, &diagnostics.messages)?;
+
+                if self.flags.intersects(Flags::SHOW_FIX_SUMMARY) && !diagnostics.fixed.is_empty() {
+                    writeln!(writer)?;
+                    print_fix_summary(writer, &diagnostics.fixed)?;
+                    writeln!(writer)?;
+                }
+
                 self.write_summary_text(writer, diagnostics, num_files)?;
             }
             OutputFormat::Github => {
-                GithubEmitter.emit(writer, diagnostics)?;
+                GithubEmitter.emit(writer, &diagnostics.messages)?;
             }
             OutputFormat::Gitlab => {
-                GitlabEmitter::default().emit(writer, diagnostics)?;
+                GitlabEmitter::default().emit(writer, &diagnostics.messages)?;
             }
             OutputFormat::Grouped => {
                 GroupedEmitter::default()
-                    .with_show_fix_status(true)
-                    .with_unsafe_fixes(crate::settings::UnsafeFixes::Hint)
-                    .emit(writer, diagnostics)?;
-
-                // if self.flags.intersects(Flags::SHOW_FIX_SUMMARY) {
-                //     if !diagnostics.fixed.is_empty() {
-                //         writeln!(writer)?;
-                //         print_fix_summary(writer, &diagnostics.fixed)?;
-                //         writeln!(writer)?;
-                //     }
-                // }
+                    .with_show_fix_status(show_fix_status(self.fix_mode, fixables.as_ref()))
+                    .with_unsafe_fixes(self.unsafe_fixes)
+                    .emit(writer, &diagnostics.messages)?;
+
+                if self.flags.intersects(Flags::SHOW_FIX_SUMMARY) && !diagnostics.fixed.is_empty() {
+                    writeln!(writer)?;
+                    print_fix_summary(writer, &diagnostics.fixed)?;
+                    writeln!(writer)?;
+                }
                 self.write_summary_text(writer, diagnostics, num_files)?;
             }
             OutputFormat::Json => {
-                JsonEmitter.emit(writer, diagnostics)?;
+                JsonEmitter.emit(writer, &diagnostics.messages)?;
             }
             OutputFormat::Sarif => {
-                SarifEmitter.emit(writer, diagnostics)?;
+                SarifEmitter.emit(writer, &diagnostics.messages)?;
             }
             OutputFormat::Azure => {
-                AzureEmitter.emit(writer, diagnostics)?;
+                AzureEmitter.emit(writer, &diagnostics.messages)?;
             }
             OutputFormat::JsonLines => {
-                JsonLinesEmitter.emit(writer, diagnostics)?;
+                JsonLinesEmitter.emit(writer, &diagnostics.messages)?;
             }
             OutputFormat::Rdjson => {
-                RdjsonEmitter.emit(writer, diagnostics)?;
+                RdjsonEmitter.emit(writer, &diagnostics.messages)?;
             }
             OutputFormat::Junit => {
-                JunitEmitter.emit(writer, diagnostics)?;
+                JunitEmitter.emit(writer, &diagnostics.messages)?;
             }
             OutputFormat::Pylint => {
-                PylintEmitter.emit(writer, diagnostics)?;
+                PylintEmitter.emit(writer, &diagnostics.messages)?;
             }
         }
 
@@ -141,3 +223,101 @@ impl Printer {
         Ok(())
     }
 }
+
+fn num_digits(n: usize) -> usize {
+    iterate(n, |&n| n / 10)
+        .take_while(|&n| n > 0)
+        .count()
+        .max(1)
+}
+
+/// Return `true` if the [`Printer`] should indicate that a rule is fixable.
+fn show_fix_status(fix_mode: FixMode, fixables: Option<&FixableStatistics>) -> bool {
+    // If we're in application mode, avoid indicating that a rule is fixable.
+    // If the specific violation were truly fixable, it would've been fixed in
+    // this pass! (We're occasionally unable to determine whether a specific
+    // violation is fixable without trying to fix it, so if fix is not
+    // enabled, we may inadvertently indicate that a rule is fixable.)
+    (!fix_mode.is_apply()) && fixables.is_some_and(FixableStatistics::any_applicable_fixes)
+}
+
+fn print_fix_summary(writer: &mut dyn Write, fixed: &FixMap) -> Result<()> {
+    let total = fixed
+        .values()
+        .map(|table| table.values().sum::<usize>())
+        .sum::<usize>();
+    assert!(total > 0);
+    let num_digits = num_digits(
+        *fixed
+            .values()
+            .filter_map(|table| table.values().max())
+            .max()
+            .unwrap(),
+    );
+
+    let s = if total == 1 { "" } else { "s" };
+    let label = format!("Fixed {total} error{s}:");
+    writeln!(writer, "{}", label.bold().green())?;
+
+    for (filename, table) in fixed
+        .iter()
+        .sorted_by_key(|(filename, ..)| filename.as_str())
+    {
+        writeln!(
+            writer,
+            "{} {}{}",
+            "-".cyan(),
+            relativize_path(filename).bold(),
+            ":".cyan()
+        )?;
+        for (rule, count) in table.iter().sorted_by_key(|(.., count)| Reverse(*count)) {
+            writeln!(
+                writer,
+                "    {count:>num_digits$} × {} ({})",
+                rule.noqa_code().to_string().red().bold(),
+                rule.as_ref(),
+            )?;
+        }
+    }
+    Ok(())
+}
+
+/// Statistics for [applicable][ruff_diagnostics::Applicability] fixes.
+#[derive(Debug)]
+struct FixableStatistics {
+    applicable: u32,
+    inapplicable_unsafe: u32,
+}
+
+impl FixableStatistics {
+    fn try_from(diagnostics: &Diagnostics, unsafe_fixes: UnsafeFixes) -> Option<Self> {
+        let mut applicable = 0;
+        let mut inapplicable_unsafe = 0;
+
+        for message in diagnostics.messages.iter() {
+            if let Some(fix) = message.fix() {
+                if fix.applies(unsafe_fixes.required_applicability()) {
+                    applicable += 1;
+                } else {
+                    // Do not include inapplicable fixes at other levels that do not provide an opt-in
+                    if fix.applicability().is_unsafe() {
+                        inapplicable_unsafe += 1;
+                    }
+                }
+            }
+        }
+
+        if applicable == 0 && inapplicable_unsafe == 0 {
+            None
+        } else {
+            Some(Self {
+                applicable,
+                inapplicable_unsafe,
+            })
+        }
+    }
+
+    fn any_applicable_fixes(&self) -> bool {
+        self.applicable > 0
+    }
+}
diff --git a/fortitude/src/rules/mod.rs b/fortitude/src/rules/mod.rs
index 5d0eec7..4b7c435 100644
--- a/fortitude/src/rules/mod.rs
+++ b/fortitude/src/rules/mod.rs
@@ -1,15 +1,15 @@
 #![allow(clippy::useless_format)]
 /// A collection of all rules, and utilities to select a subset at runtime.
-pub(crate) mod error; // Public so we can use `IoError` in other places
-mod filesystem;
+pub(crate) mod error;
+pub(crate) mod filesystem;
 #[macro_use]
 mod macros;
-mod modules;
-mod obsolescent;
-mod precision;
-mod style;
-mod testing;
-mod typing;
+pub(crate) mod modules;
+pub(crate) mod obsolescent;
+pub(crate) mod precision;
+pub(crate) mod style;
+pub(crate) mod testing;
+pub(crate) mod typing;
 use crate::registry::{AsRule, Category};
 
 use std::fmt::Formatter;
diff --git a/fortitude/src/settings.rs b/fortitude/src/settings.rs
index 75de8a7..5dcfcd2 100644
--- a/fortitude/src/settings.rs
+++ b/fortitude/src/settings.rs
@@ -155,3 +155,11 @@ impl Display for OutputFormat {
         }
     }
 }
+
+#[derive(Debug, Copy, Clone, Hash, is_macro::Is)]
+pub enum FixMode {
+    Generate,
+    Apply,
+    #[allow(dead_code)]
+    Diff,
+}


Test patch:
diff --git a/fortitude/src/test.rs b/fortitude/src/test.rs
index b1e6603..384e0f5 100644
--- a/fortitude/src/test.rs
+++ b/fortitude/src/test.rs
@@ -1,16 +1,15 @@
 use std::path::Path;
 
 use anyhow::Result;
-use itertools::Itertools;
 use ruff_source_file::{SourceFile, SourceFileBuilder};
 
 use crate::{
     check::{
         ast_entrypoint_map, check_file, read_to_string, rules_to_path_rules, rules_to_text_rules,
     },
-    message::{DiagnosticMessage, Emitter, TextEmitter},
+    message::{Emitter, TextEmitter},
     rules::Rule,
-    settings::Settings,
+    settings::{FixMode, Settings, UnsafeFixes},
 };
 
 pub(crate) fn test_resource_path(path: impl AsRef<Path>) -> std::path::PathBuf {
@@ -47,15 +46,13 @@ pub(crate) fn test_contents(
         path,
         file,
         settings,
+        FixMode::Generate,
+        UnsafeFixes::Hint,
     ) {
         Ok(violations) => {
-            if violations.is_empty() {
+            if violations.messages.is_empty() {
                 return String::new();
             }
-            let diagnostics = violations
-                .into_iter()
-                .map(|v| DiagnosticMessage::from_ruff(file, v))
-                .collect_vec();
 
             let mut output = Vec::new();
 
@@ -64,7 +61,7 @@ pub(crate) fn test_contents(
                 .with_show_fix_diff(true)
                 .with_show_source(true)
                 .with_unsafe_fixes(crate::settings::UnsafeFixes::Enabled)
-                .emit(&mut output, &diagnostics)
+                .emit(&mut output, &violations.messages)
                 .unwrap();
 
             String::from_utf8(output).unwrap()
diff --git a/fortitude/tests/check.rs b/fortitude/tests/check.rs
index c2a5288..ba11828 100644
--- a/fortitude/tests/check.rs
+++ b/fortitude/tests/check.rs
@@ -30,7 +30,6 @@ fn check_file_doesnt_exist() -> anyhow::Result<()> {
     exit_code: 1
     ----- stdout -----
     test/file/doesnt/exist.f90:1:1: E000 Error opening file: No such file or directory (os error 2)
-
     fortitude: 0 files scanned, 1 could not be read.
     Number of errors: 1
 
@@ -70,7 +69,7 @@ unknown-key = 1
       |
     2 | unknown-key = 1
       | ^^^^^^^^^^^
-    unknown field `unknown-key`, expected one of `files`, `ignore`, `select`, `extend-select`, `line-length`, `file-extensions`, `output-format`, `preview`, `no-preview`, `progress-bar`
+    unknown field `unknown-key`, expected one of `files`, `ignore`, `select`, `extend-select`, `line-length`, `file-extensions`, `fix`, `no-fix`, `unsafe-fixes`, `no-unsafe-fixes`, `show-fixes`, `no-show-fixes`, `fix-only`, `no-fix-only`, `output-format`, `preview`, `no-preview`, `progress-bar`
     ");
     Ok(())
 }
@@ -128,7 +127,6 @@ end program
       | ^^^^^^^^^^^ S061
       |
 
-
     fortitude: 1 files scanned.
     Number of errors: 4
 
@@ -180,7 +178,6 @@ end program
       | ^^^^^^^^^^^ S061
       |
 
-
     fortitude: 1 files scanned.
     Number of errors: 2
 
@@ -241,7 +238,6 @@ select = ["T001", "style"]
       | ^^^^^^^^^^^ S061
       |
 
-
     fortitude: 1 files scanned.
     Number of errors: 2
 
@@ -304,7 +300,6 @@ select = ["T001"]
       | ^^^^^^^^^^^ S061
       |
 
-
     fortitude: 1 files scanned.
     Number of errors: 2
 
@@ -365,7 +360,6 @@ select = ["T001", "style"]
       | ^^^^^^^^^^^ S061
       |
 
-
     fortitude: 1 files scanned.
     Number of errors: 2
 
@@ -378,3 +372,70 @@ select = ["T001", "style"]
     ");
     Ok(())
 }
+
+#[test]
+fn apply_fixes() -> anyhow::Result<()> {
+    let tempdir = TempDir::new()?;
+    let test_file = tempdir.path().join("test.f90");
+    fs::write(
+        &test_file,
+        r#"
+program foo
+  implicit none
+  real i
+  i = 4.0
+contains
+  subroutine bar
+    implicit none
+  end subroutine bar
+end program foo
+"#,
+    )?;
+    apply_common_filters!();
+    assert_cmd_snapshot!(Command::cargo_bin(BIN_NAME)?
+                         .arg("check")
+                         .arg("--preview")
+                         .arg("--fix")
+                         .arg(&test_file),
+                         @r"
+    success: false
+    exit_code: 1
+    ----- stdout -----
+    [TEMP_FILE] P021 real has implicit kind
+      |
+    2 | program foo
+    3 |   implicit none
+    4 |   real i
+      |   ^^^^ P021
+    5 |   i = 4.0
+    6 | contains
+      |
+
+    fortitude: 1 files scanned.
+    Number of errors: 3 (2 fixed, 1 remaining)
+
+    For more information about specific rules, run:
+
+        fortitude explain X001,Y002,...
+
+
+    ----- stderr -----
+    ");
+
+    let expected = r#"
+program foo
+  implicit none
+  real :: i
+  i = 4.0
+contains
+  subroutine bar
+  end subroutine bar
+end program foo
+"#
+    .to_string();
+
+    let transformed = fs::read_to_string(&test_file)?;
+    assert_eq!(transformed, expected);
+
+    Ok(())
+}