Repository: PlasmaFAIR/fortitude (language: python)

Issue:
Feature Request: Summarised output option
Thank you for this project.

Would it be possible to have an option for condensed feedback, e.g. `file:lineno:col <error code> summary` as was the case in an older version of fortitude?

Sometimes when running over a codebase for the first time, there are many errors, often repeated and easy to correct throughout a file. The condensed display was useful here rather than the full verbose printout with source code and pointers to the offending location.

I'm thinking something like the opposite of the common `-v, --verbose` flag.
Perhaps a `-c, --concise`:
```
fortitude check -c myfile.F90
```

Would be happy to try and contribute if you can point me where to start.


Discussion / hints:
Yep! Our plan is to ~~steal~~ adapt [ruff's output formats](https://docs.astral.sh/ruff/settings/#output-format), so that would look like:

```
fortitude check --output-format=concise
```

Doing it this way will let us also output annotations for GitHub's CI, JSON for an LSP server, etc.

We need one or two more pieces of infrastructure in place before we can start to use their code, but I think we're almost there!

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 8b1236e..ce7ed07 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14,11 +14,10 @@ dependencies = [
 [[package]]
 name = "annotate-snippets"
 version = "0.11.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24e35ed54e5ea7997c14ed4c70ba043478db1112e98263b3b035907aa197d991"
+source = "git+https://github.com/PlasmaFAIR/annotate-snippets-rs.git?branch=level-none#3e1ae5fadf250c5183653dc5d9db2ca79ecffd7d"
 dependencies = [
  "anstyle",
- "unicode-width",
+ "unicode-width 0.2.0",
 ]
 
 [[package]]
@@ -278,6 +277,7 @@ dependencies = [
  "annotate-snippets",
  "anyhow",
  "assert_cmd",
+ "bitflags",
  "clap",
  "colored",
  "fortitude_macros",
@@ -287,6 +287,8 @@ dependencies = [
  "itertools 0.12.1",
  "lazy-regex",
  "lazy_static",
+ "path-absolutize",
+ "pathdiff",
  "predicates",
  "pretty_assertions",
  "ruff_cache",
@@ -295,6 +297,7 @@ dependencies = [
  "ruff_source_file",
  "ruff_text_size",
  "serde",
+ "serde_json",
  "similar-asserts",
  "strum",
  "strum_macros",
@@ -517,6 +520,30 @@ version = "1.20.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
 
+[[package]]
+name = "path-absolutize"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4af381fe79fa195b4909485d99f73a80792331df0625188e707854f0b3383f5"
+dependencies = [
+ "path-dedot",
+]
+
+[[package]]
+name = "path-dedot"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "pathdiff"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361"
+
 [[package]]
 name = "predicates"
 version = "3.1.2"
@@ -635,6 +662,7 @@ dependencies = [
  "is-macro",
  "log",
  "ruff_text_size",
+ "serde",
 ]
 
 [[package]]
@@ -667,12 +695,16 @@ source = "git+https://github.com/astral-sh/ruff.git?tag=0.7.1#337af836d3b2d88413
 dependencies = [
  "memchr",
  "ruff_text_size",
+ "serde",
 ]
 
 [[package]]
 name = "ruff_text_size"
 version = "0.0.0"
 source = "git+https://github.com/astral-sh/ruff.git?tag=0.7.1#337af836d3b2d88413c1391495a94755af46f574"
+dependencies = [
+ "serde",
+]
 
 [[package]]
 name = "rustix"
@@ -886,7 +918,7 @@ checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
 dependencies = [
  "smawk",
  "unicode-linebreak",
- "unicode-width",
+ "unicode-width 0.1.14",
 ]
 
 [[package]]
@@ -995,6 +1027,12 @@ version = "0.1.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
 
+[[package]]
+name = "unicode-width"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.2"
diff --git a/Cargo.toml b/Cargo.toml
index 3f5a4cb..985ac37 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,8 @@ authors = [
 license = "MIT"
 
 [workspace.dependencies]
+colored = { version = "2.1.0" }
 itertools = "0.12.0"
+serde_json = { version = "1.0.113" }
 strum = { version = "0.26.0", features = ["strum_macros"] }
 strum_macros = { version = "0.26.0" }
diff --git a/README.md b/README.md
index e17ed4d..d3e6f3b 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,16 @@ fortitude check --select=T --ignore=T003
 fortitude check --select=typing --ignore=superfluous-implicit-none
 ```
 
+Use `--output-format=concise` to get shorter output:
+
+```bash
+$ fortitude check --concise
+test.f90:2:1: M001 function not contained within (sub)module or program
+test.f90:5:1: S061 end statement should read 'end function double'
+test.f90:7:1: M001 subroutine not contained within (sub)module or program
+test.f90:8:3: P021 real has implicit kind
+```
+
 The `explain` command can be used to get extra information about any rules:
 
 ```bash
diff --git a/fortitude/Cargo.toml b/fortitude/Cargo.toml
index e0a68c1..db98089 100644
--- a/fortitude/Cargo.toml
+++ b/fortitude/Cargo.toml
@@ -15,21 +15,28 @@ categories = ["command-line-utilities", "development-tools"]
 exclude = [".*", "test.f90"]
 
 [dependencies]
-annotate-snippets = "0.11.4"
+annotate-snippets = { git = "https://github.com/PlasmaFAIR/annotate-snippets-rs.git", branch = "level-none" }
 anyhow = "1.0.79"
-clap = { version = "4.4.16", features = ["derive", "string"] }
-colored = "2.1.0"
+bitflags = "2.6.0"
+clap = { version = "4.4.16", features = ["derive", "string", "env"] }
+colored = { workspace = true }
 fortitude_macros = { version = "0.1.0", path = "../fortitude_macros" }
 is-macro = "0.3.7"
 itertools = { workspace = true }
 lazy-regex = "3.3.0"
 lazy_static = "1.5.0"
+path-absolutize = { version = "3.1.1", features = [
+    "once_cell_cache",
+    "use_unix_paths_on_wasm",
+]}
+pathdiff = { version = "0.2.1" }
 ruff_cache = { git = "https://github.com/astral-sh/ruff.git", tag = "0.7.1", version = "0.0.0" }
-ruff_diagnostics = { git = "https://github.com/astral-sh/ruff.git", tag = "0.7.1", version = "0.0.0" }
+ruff_diagnostics = { git = "https://github.com/astral-sh/ruff.git", tag = "0.7.1", version = "0.0.0", features = ["serde"] }
 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" }
+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" }
 serde = { version = "1.0.210", features = ["derive"] }
+serde_json = { workspace = true }
 similar-asserts = "1.6.0"
 strum = { workspace = true }
 strum_macros = { workspace = true }
@@ -42,6 +49,8 @@ walkdir = "2.4.0"
 
 [dev-dependencies]
 assert_cmd = "2.0.16"
+# Disable colored output in tests
+colored = { workspace = true, features = ["no-color"] }
 insta = { version = "1.41.1", features = ["filters"] }
 insta-cmd = "0.6.0"
 predicates = "3.1.2"
diff --git a/fortitude/src/check.rs b/fortitude/src/check.rs
index 23354d9..16b024f 100644
--- a/fortitude/src/check.rs
+++ b/fortitude/src/check.rs
@@ -1,19 +1,20 @@
 use crate::ast::{parse, FortitudeNode};
 use crate::cli::{CheckArgs, GlobalConfigArgs, FORTRAN_EXTS};
+use crate::message::DiagnosticMessage;
+use crate::printer::{Flags as PrinterFlags, Printer};
 use crate::rule_selector::{PreviewOptions, RuleSelector, Specificity};
 use crate::rules::Rule;
 use crate::rules::{error::ioerror::IoError, AstRuleEnum, PathRuleEnum, TextRuleEnum};
-use crate::settings::{Settings, DEFAULT_SELECTORS};
-use crate::DiagnosticMessage;
+use crate::settings::{OutputFormat, Settings, DEFAULT_SELECTORS};
 
 use anyhow::{Context, Result};
-use colored::Colorize;
-use itertools::{join, Itertools};
+use itertools::Itertools;
 use ruff_diagnostics::Diagnostic;
 use ruff_source_file::{SourceFile, SourceFileBuilder};
 use ruff_text_size::TextRange;
 use serde::{Deserialize, Serialize};
 use std::collections::{BTreeMap, BTreeSet};
+use std::io;
 use std::path::{Path, PathBuf};
 use std::process::ExitCode;
 use strum::IntoEnumIterator;
@@ -115,6 +116,7 @@ pub struct CheckSettings {
     pub extend_select: Vec<RuleSelector>,
     pub line_length: usize,
     pub file_extensions: Vec<String>,
+    pub output_format: OutputFormat,
 }
 
 /// Read either fpm.toml or fortitude.toml into our "known good" file
@@ -140,6 +142,7 @@ 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()),
+            output_format: value.output_format.unwrap_or_default(),
         },
         None => CheckSettings::default(),
     };
@@ -354,6 +357,8 @@ pub fn check(args: CheckArgs, global_options: &GlobalConfigArgs) -> Result<ExitC
         extend_select: args.extend_select.unwrap_or(file_settings.extend_select),
     };
 
+    let output_format = args.output_format.unwrap_or(file_settings.output_format);
+
     // At this point, we've assembled all our settings, and we're
     // ready to check the project
 
@@ -362,76 +367,60 @@ pub fn check(args: CheckArgs, global_options: &GlobalConfigArgs) -> Result<ExitC
     let path_rules = rules_to_path_rules(&rules);
     let text_rules = rules_to_text_rules(&rules);
     let ast_entrypoints = ast_entrypoint_map(&rules);
-    let mut total_errors = 0;
-    let mut total_files = 0;
-    for path in get_files(files, file_extensions) {
-        let filename = path.to_string_lossy();
-        let empty_file = SourceFileBuilder::new(filename.as_ref(), "").finish();
-
-        let source = match read_to_string(&path) {
-            Ok(source) => source,
-            Err(error) => {
-                let message = format!("Error opening file: {error}");
-                let diagnostic = DiagnosticMessage::from_ruff(
-                    &empty_file,
-                    Diagnostic::new(IoError { message }, TextRange::default()),
-                );
-                println!("{diagnostic}");
-                total_errors += 1;
-                continue;
-            }
-        };
 
-        let file = SourceFileBuilder::new(filename.as_ref(), source.as_str()).finish();
-
-        match check_file(
-            &path_rules,
-            &text_rules,
-            &ast_entrypoints,
-            &path,
-            &file,
-            &settings,
-        ) {
-            Ok(violations) => {
-                let mut diagnostics: Vec<_> = violations
+    let diagnostics = get_files(files, file_extensions)
+        .iter()
+        .flat_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(
+                        filename,
+                        Diagnostic::new(IoError { message }, TextRange::default()),
+                    )];
+                }
+            };
+
+            let file = SourceFileBuilder::new(filename.as_ref(), source.as_str()).finish();
+
+            match check_file(
+                &path_rules,
+                &text_rules,
+                &ast_entrypoints,
+                path,
+                &file,
+                &settings,
+            ) {
+                Ok(violations) => violations
                     .into_iter()
                     .map(|v| DiagnosticMessage::from_ruff(&file, v))
-                    .collect();
-                if !diagnostics.is_empty() {
-                    diagnostics.sort_unstable();
-                    println!("{}", join(&diagnostics, "\n"));
+                    .collect_vec(),
+                Err(msg) => {
+                    let message = format!("Failed to process: {msg}");
+                    vec![DiagnosticMessage::from_error(
+                        filename,
+                        Diagnostic::new(IoError { message }, TextRange::default()),
+                    )]
                 }
-                total_errors += diagnostics.len();
-            }
-            Err(msg) => {
-                let message = format!("Failed to process: {msg}");
-                let diagnostic = DiagnosticMessage::from_ruff(
-                    &empty_file,
-                    Diagnostic::new(IoError { message }, TextRange::default()),
-                );
-                println!("{diagnostic}");
-                total_errors += 1;
             }
-        }
-        total_files += 1;
-    }
-    let file_no = format!(
-        "fortitude: {} files scanned.",
-        total_files.to_string().bold()
-    );
+        })
+        .sorted_unstable()
+        .collect_vec();
+
+    let total_errors = diagnostics.len();
+
+    let mut writer = Box::new(io::stdout());
+
+    let flags = PrinterFlags::SHOW_VIOLATIONS | PrinterFlags::SHOW_FIX_SUMMARY;
+
+    Printer::new(output_format, flags).write_once(&diagnostics, &mut writer)?;
+
     if total_errors == 0 {
-        let success = "All checks passed!".bright_green();
-        println!("\n{file_no}\n{success}\n");
         Ok(ExitCode::SUCCESS)
     } 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()
-        );
-        println!("\n{file_no}\n{err_no}\n\n{info}\n\n    {explain}\n");
         Ok(ExitCode::FAILURE)
     }
 }
diff --git a/fortitude/src/cli.rs b/fortitude/src/cli.rs
index 7ed35ac..3c16725 100644
--- a/fortitude/src/cli.rs
+++ b/fortitude/src/cli.rs
@@ -2,7 +2,7 @@ use clap::{Parser, Subcommand};
 use serde::{Deserialize, Serialize};
 use std::path::PathBuf;
 
-use crate::{rule_selector::RuleSelector, RuleSelectorParser};
+use crate::{rule_selector::RuleSelector, settings::OutputFormat, RuleSelectorParser};
 
 /// Default extensions to check
 pub const FORTRAN_EXTS: &[&str] = &[
@@ -90,4 +90,9 @@ pub struct CheckArgs {
     /// File extensions to check
     #[arg(long, value_delimiter = ',', default_values = FORTRAN_EXTS)]
     pub file_extensions: Option<Vec<String>>,
+
+    /// Output serialization format for violations.
+    /// The default serialization format is "full".
+    #[arg(long, value_enum, env = "FORTITUDE_OUTPUT_FORMAT")]
+    pub output_format: Option<OutputFormat>,
 }
diff --git a/fortitude/src/fs.rs b/fortitude/src/fs.rs
new file mode 100644
index 0000000..e74b0c4
--- /dev/null
+++ b/fortitude/src/fs.rs
@@ -0,0 +1,50 @@
+use std::path::{Path, PathBuf};
+
+use path_absolutize::Absolutize;
+
+/// Convert any path to an absolute path (based on the current working
+/// directory).
+#[allow(dead_code)]
+pub fn normalize_path<P: AsRef<Path>>(path: P) -> PathBuf {
+    let path = path.as_ref();
+    if let Ok(path) = path.absolutize() {
+        return path.to_path_buf();
+    }
+    path.to_path_buf()
+}
+
+/// Convert any path to an absolute path (based on the specified project root).
+#[allow(dead_code)]
+pub fn normalize_path_to<P: AsRef<Path>, R: AsRef<Path>>(path: P, project_root: R) -> PathBuf {
+    let path = path.as_ref();
+    if let Ok(path) = path.absolutize_from(project_root.as_ref()) {
+        return path.to_path_buf();
+    }
+    path.to_path_buf()
+}
+
+/// Convert an absolute path to be relative to the current working directory.
+pub fn relativize_path<P: AsRef<Path>>(path: P) -> String {
+    let path = path.as_ref();
+
+    #[cfg(target_arch = "wasm32")]
+    let cwd = Path::new(".");
+    #[cfg(not(target_arch = "wasm32"))]
+    let cwd = path_absolutize::path_dedot::CWD.as_path();
+
+    if let Ok(path) = path.strip_prefix(cwd) {
+        return format!("{}", path.display());
+    }
+    format!("{}", path.display())
+}
+
+/// Convert an absolute path to be relative to the specified project root.
+#[allow(dead_code)]
+pub fn relativize_path_to<P: AsRef<Path>, R: AsRef<Path>>(path: P, project_root: R) -> String {
+    format!(
+        "{}",
+        pathdiff::diff_paths(&path, project_root)
+            .expect("Could not diff paths")
+            .display()
+    )
+}
diff --git a/fortitude/src/lib.rs b/fortitude/src/lib.rs
index 6e14984..6d9c44c 100644
--- a/fortitude/src/lib.rs
+++ b/fortitude/src/lib.rs
@@ -2,6 +2,9 @@ mod ast;
 pub mod check;
 pub mod cli;
 pub mod explain;
+mod fs;
+mod message;
+mod printer;
 mod registry;
 mod rule_redirects;
 mod rule_selector;
@@ -9,18 +12,14 @@ mod rules;
 mod settings;
 #[cfg(test)]
 mod test;
+mod text_helpers;
 pub use crate::registry::clap_completion::RuleParser;
-use crate::registry::AsRule;
 pub use crate::rule_selector::clap_completion::RuleSelectorParser;
-use annotate_snippets::{Level, Renderer, Snippet};
 use ast::{parse, FortitudeNode};
-use colored::{ColoredString, Colorize};
-use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix};
-use ruff_source_file::{OneIndexed, SourceFile, SourceLocation};
-use ruff_text_size::{Ranged, TextRange, TextSize};
+use ruff_diagnostics::{Diagnostic, DiagnosticKind};
+use ruff_source_file::{OneIndexed, SourceFile};
+use ruff_text_size::{TextRange, TextSize};
 use settings::Settings;
-use std::cmp::Ordering;
-use std::fmt;
 use std::path::Path;
 use tree_sitter::Node;
 
@@ -106,153 +105,6 @@ pub trait AstRule {
     }
 }
 
-// Violation diagnostics
-// ---------------------
-
-/// Reports of each violation. They are pretty-printable and sortable.
-#[derive(Debug, PartialEq, Eq)]
-pub struct DiagnosticMessage<'a> {
-    kind: DiagnosticKind,
-    range: TextRange,
-    /// The file where an error was reported.
-    file: &'a SourceFile,
-    /// The rule code that was violated, expressed as a string.
-    code: String,
-    /// The suggested fix for the violation.
-    fix: Option<Fix>,
-}
-
-impl<'a> DiagnosticMessage<'a> {
-    pub fn from_ruff(file: &'a SourceFile, diagnostic: Diagnostic) -> Self {
-        let code = diagnostic.kind.rule().noqa_code().to_string();
-        Self {
-            kind: diagnostic.kind,
-            file,
-            code,
-            range: diagnostic.range,
-            fix: diagnostic.fix,
-        }
-    }
-}
-
-impl<'a> Ord for DiagnosticMessage<'a> {
-    fn cmp(&self, other: &Self) -> Ordering {
-        (self.file, self.range.start()).cmp(&(other.file, other.range.start()))
-    }
-}
-
-impl<'a> PartialOrd for DiagnosticMessage<'a> {
-    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
-        Some(self.cmp(other))
-    }
-}
-
-impl<'a> Ranged for DiagnosticMessage<'a> {
-    fn range(&self) -> TextRange {
-        self.range
-    }
-}
-
-impl<'a> fmt::Display for DiagnosticMessage<'a> {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        let mut path: ColoredString = self.file.name().bold();
-        let mut code: ColoredString = self.code.bold().bright_red();
-
-        // Disable colours for tests, if the user requests it via env var, or non-tty
-        if cfg!(test) || !colored::control::SHOULD_COLORIZE.should_colorize() {
-            path = path.clear();
-            code = code.clear();
-        };
-
-        let message = self.kind.body.as_str();
-        let suggestion = &self.kind.suggestion;
-        if self.range != TextRange::default() {
-            format_violation(self, f, &self.range, message, suggestion, &path, &code)
-        } else {
-            write!(f, "{path}: {code} {message}")
-        }
-    }
-}
-
-fn format_violation(
-    diagnostic: &DiagnosticMessage,
-    f: &mut fmt::Formatter,
-    range: &TextRange,
-    message: &str,
-    suggestion: &Option<String>,
-    path: &ColoredString,
-    code: &ColoredString,
-) -> fmt::Result {
-    let source_code = diagnostic.file.to_source_code();
-    let content_start_index = source_code.line_index(range.start());
-    let mut start_index = content_start_index.saturating_sub(2);
-
-    // Trim leading empty lines.
-    while start_index < content_start_index {
-        if !source_code.line_text(start_index).trim().is_empty() {
-            break;
-        }
-        start_index = start_index.saturating_add(1);
-    }
-
-    let content_end_index = source_code.line_index(range.end());
-    let mut end_index = content_end_index
-        .saturating_add(2)
-        .min(OneIndexed::from_zero_indexed(source_code.line_count()));
-
-    // Trim following empty lines.
-    while end_index > content_end_index {
-        if !source_code.line_text(end_index).trim().is_empty() {
-            break;
-        }
-        end_index = end_index.saturating_sub(1);
-    }
-
-    let start_offset = source_code.line_start(start_index);
-    let end_offset = source_code.line_end(end_index);
-
-    let source = source_code.slice(TextRange::new(start_offset, end_offset));
-    let message_range = range - start_offset;
-
-    let start_char = source[TextRange::up_to(message_range.start())]
-        .chars()
-        .count();
-    let end_char = source[TextRange::up_to(message_range.end())]
-        .chars()
-        .count();
-
-    // Some annoyance here: we *have* to have some level prefix to our
-    // message. Might be fixed in future version of annotate-snippets
-    // -- or we use an earlier version with more control.
-    // Also, we could use `.origin(path)` to get the filename and
-    // line:col automatically, but there is currently a bug in the
-    // library when annotating the start of the line
-    let SourceLocation { row, column } = source_code.source_location(range.start());
-    let message_line = format!("{path}:{row}:{column}: {code} {message}");
-    let snippet = Level::Warning.title(&message_line).snippet(
-        Snippet::source(source)
-            .line_start(start_index.get())
-            .annotation(Level::Error.span(start_char..end_char).label(code)),
-    );
-
-    let snippet_with_footer = if let Some(s) = suggestion {
-        snippet.footer(Level::Help.title(s))
-    } else {
-        snippet
-    };
-
-    // Disable colours for tests, if the user requests it via env var, or non-tty
-    let renderer = if !cfg!(test) && colored::control::SHOULD_COLORIZE.should_colorize() {
-        Renderer::styled()
-    } else {
-        Renderer::plain()
-    };
-    let source_block = renderer.render(snippet_with_footer);
-    writeln!(f, "{source_block}")?;
-
-    Ok(())
-}
-
 /// Simplify making a `SourceFile` in tests
 #[cfg(test)]
 pub fn test_file(source: &str) -> SourceFile {
diff --git a/fortitude/src/message/json.rs b/fortitude/src/message/json.rs
new file mode 100644
index 0000000..c2a2ba5
--- /dev/null
+++ b/fortitude/src/message/json.rs
@@ -0,0 +1,119 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::io::Write;
+
+use serde::ser::SerializeSeq;
+use serde::{Serialize, Serializer};
+use serde_json::{json, Value};
+
+use ruff_diagnostics::Edit;
+use ruff_source_file::SourceCode;
+use ruff_text_size::Ranged;
+
+use crate::message::{DiagnosticMessage, Emitter};
+
+#[derive(Default)]
+pub struct JsonEmitter;
+
+impl Emitter for JsonEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        serde_json::to_writer_pretty(writer, &ExpandedMessages { messages })?;
+
+        Ok(())
+    }
+}
+
+struct ExpandedMessages<'a> {
+    messages: &'a [DiagnosticMessage],
+}
+
+impl Serialize for ExpandedMessages<'_> {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        let mut s = serializer.serialize_seq(Some(self.messages.len()))?;
+
+        for message in self.messages {
+            let value = message_to_json_value(message);
+            s.serialize_element(&value)?;
+        }
+
+        s.end()
+    }
+}
+
+pub(crate) fn message_to_json_value(message: &DiagnosticMessage) -> Value {
+    let source_code = message.source_file().to_source_code();
+
+    let fix = message.fix().map(|fix| {
+        json!({
+            "applicability": fix.applicability(),
+            "message": message.suggestion(),
+            "edits": &ExpandedEdits { edits: fix.edits(), source_code: &source_code },
+        })
+    });
+
+    let start_location = source_code.source_location(message.start());
+    let end_location = source_code.source_location(message.end());
+
+    json!({
+        "code": message.rule().map(|rule| rule.noqa_code().to_string()),
+        "message": message.body(),
+        "fix": fix,
+        "location": start_location,
+        "end_location": end_location,
+        "filename": message.filename(),
+    })
+}
+
+struct ExpandedEdits<'a> {
+    edits: &'a [Edit],
+    source_code: &'a SourceCode<'a, 'a>,
+}
+
+impl Serialize for ExpandedEdits<'_> {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        let mut s = serializer.serialize_seq(Some(self.edits.len()))?;
+
+        for edit in self.edits {
+            let location = self.source_code.source_location(edit.start());
+            let end_location = self.source_code.source_location(edit.end());
+
+            let value = json!({
+                "content": edit.content().unwrap_or_default(),
+                "location": location,
+                "end_location": end_location
+            });
+
+            s.serialize_element(&value)?;
+        }
+
+        s.end()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::JsonEmitter;
+
+    #[test]
+    fn output() {
+        let mut emitter = JsonEmitter;
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+}
diff --git a/fortitude/src/message/mod.rs b/fortitude/src/message/mod.rs
new file mode 100644
index 0000000..0027b13
--- /dev/null
+++ b/fortitude/src/message/mod.rs
@@ -0,0 +1,211 @@
+pub use json::JsonEmitter;
+pub use text::TextEmitter;
+
+mod json;
+mod text;
+
+use std::cmp::Ordering;
+use std::io::Write;
+
+use crate::{registry::AsRule, rules::Rule};
+use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix};
+use ruff_source_file::{SourceFile, SourceFileBuilder, SourceLocation};
+use ruff_text_size::{Ranged, TextRange};
+
+/// Reports of each violation. They are pretty-printable and sortable.
+#[derive(Debug, PartialEq, Eq)]
+pub struct DiagnosticMessage {
+    kind: DiagnosticKind,
+    range: TextRange,
+    /// The file where an error was reported.
+    file: SourceFile,
+    /// The rule code that was violated, expressed as a string.
+    code: String,
+    /// The suggested fix for the violation.
+    fix: Option<Fix>,
+}
+
+impl DiagnosticMessage {
+    pub fn from_ruff(file: &SourceFile, diagnostic: Diagnostic) -> Self {
+        let code = diagnostic.kind.rule().noqa_code().to_string();
+        Self {
+            kind: diagnostic.kind,
+            file: file.clone(),
+            code,
+            range: diagnostic.range,
+            fix: diagnostic.fix,
+        }
+    }
+
+    pub fn from_error<S: AsRef<str>>(filename: S, diagnostic: Diagnostic) -> Self {
+        let code = diagnostic.kind.rule().noqa_code().to_string();
+        Self {
+            kind: diagnostic.kind,
+            file: SourceFileBuilder::new(filename.as_ref(), "").finish(),
+            code,
+            range: diagnostic.range,
+            fix: diagnostic.fix,
+        }
+    }
+
+    /// Returns the name used to represent the diagnostic.
+    #[allow(dead_code)]
+    pub fn name(&self) -> &str {
+        &self.kind.name
+    }
+
+    /// Returns the message body to display to the user.
+    pub fn body(&self) -> &str {
+        &self.kind.body
+    }
+
+    /// Returns the fix suggestion for the violation.
+    pub fn suggestion(&self) -> Option<&str> {
+        self.kind.suggestion.as_deref()
+    }
+
+    /// Returns the [`Fix`] for the message, if there is any.
+    pub fn fix(&self) -> Option<&Fix> {
+        self.fix.as_ref()
+    }
+
+    /// Returns `true` if the message contains a [`Fix`].
+    #[allow(dead_code)]
+    pub fn fixable(&self) -> bool {
+        self.fix().is_some()
+    }
+
+    /// Returns the [`Rule`] corresponding to the diagnostic message.
+    pub fn rule(&self) -> Option<Rule> {
+        Some(self.kind.rule())
+    }
+
+    /// Returns the filename for the message.
+    pub fn filename(&self) -> &str {
+        self.source_file().name()
+    }
+
+    /// Computes the start source location for the message.
+    pub fn compute_start_location(&self) -> SourceLocation {
+        self.source_file()
+            .to_source_code()
+            .source_location(self.start())
+    }
+
+    /// Computes the end source location for the message.
+    #[allow(dead_code)]
+    pub fn compute_end_location(&self) -> SourceLocation {
+        self.source_file()
+            .to_source_code()
+            .source_location(self.end())
+    }
+
+    /// Returns the [`SourceFile`] which the message belongs to.
+    pub fn source_file(&self) -> &SourceFile {
+        &self.file
+    }
+}
+
+impl Ord for DiagnosticMessage {
+    fn cmp(&self, other: &Self) -> Ordering {
+        (&self.file, self.range.start()).cmp(&(&other.file, other.range.start()))
+    }
+}
+
+impl PartialOrd for DiagnosticMessage {
+    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ranged for DiagnosticMessage {
+    fn range(&self) -> TextRange {
+        self.range
+    }
+}
+
+/// Display format for a [`Message`]s.
+///
+/// The emitter serializes a slice of [`Message`]'s and writes them to a [`Write`].
+pub trait Emitter {
+    /// Serializes the `messages` and writes the output to `writer`.
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()>;
+}
+
+#[cfg(test)]
+mod tests {
+    use ruff_diagnostics::{Diagnostic, DiagnosticKind, Edit, Fix};
+    use ruff_source_file::SourceFileBuilder;
+    use ruff_text_size::{TextRange, TextSize};
+
+    use crate::message::{DiagnosticMessage, Emitter};
+
+    pub(super) fn create_messages() -> Vec<DiagnosticMessage> {
+        let test_contents = r#"module test
+implicit none
+
+contains
+  subroutine foo
+    implicit none
+  end subroutine
+end module
+"#;
+
+        let superfluous_implicit_none = Diagnostic::new(
+            DiagnosticKind {
+                name: "SuperfluousImplicitNone".to_string(),
+                body: "'implicit none' set on the enclosing module".to_string(),
+                suggestion: Some("Remove unnecessary 'implicit none'".to_string()),
+            },
+            TextRange::new(TextSize::from(57), TextSize::from(70)),
+        )
+        .with_fix(Fix::unsafe_edit(Edit::range_deletion(TextRange::new(
+            TextSize::from(57),
+            TextSize::from(70),
+        ))));
+
+        let unnamed_end_statement = Diagnostic::new(
+            DiagnosticKind {
+                name: "UnnamedEndStatement".to_string(),
+                body: "end statement should read 'end subroutine foo'".to_string(),
+                suggestion: None,
+            },
+            TextRange::new(TextSize::from(73), TextSize::from(87)),
+        );
+
+        let test_source = SourceFileBuilder::new("test.f90", test_contents).finish();
+
+        let file_2 = r"integer*4 foo; end";
+
+        let star_kind = Diagnostic::new(
+            DiagnosticKind {
+                name: "StarKind".to_string(),
+                body: "integer*4 is non-standard, use integer(4)".to_string(),
+                suggestion: None,
+            },
+            TextRange::new(TextSize::from(7), TextSize::from(8)),
+        );
+
+        let file_2_source = SourceFileBuilder::new("star_kind.f90", file_2).finish();
+
+        vec![
+            DiagnosticMessage::from_ruff(&test_source, superfluous_implicit_none),
+            DiagnosticMessage::from_ruff(&test_source, unnamed_end_statement),
+            DiagnosticMessage::from_ruff(&file_2_source, star_kind),
+        ]
+    }
+
+    pub(super) fn capture_emitter_output(
+        emitter: &mut dyn Emitter,
+        messages: &[DiagnosticMessage],
+    ) -> String {
+        let mut output: Vec<u8> = Vec::new();
+        emitter.emit(&mut output, messages).unwrap();
+
+        String::from_utf8(output).expect("Output to be valid UTF-8")
+    }
+}
diff --git a/fortitude/src/message/text.rs b/fortitude/src/message/text.rs
new file mode 100644
index 0000000..ed4547c
--- /dev/null
+++ b/fortitude/src/message/text.rs
@@ -0,0 +1,270 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::fmt::{Display, Formatter};
+use std::io::Write;
+
+use annotate_snippets::{Level, Renderer, Snippet};
+use bitflags::bitflags;
+use colored::Colorize;
+
+use ruff_source_file::OneIndexed;
+use ruff_text_size::{Ranged, TextRange};
+
+use crate::fs::relativize_path;
+// use crate::line_width::{IndentWidth, LineWidthBuilder};
+// use crate::message::diff::Diff;
+use crate::message::Emitter;
+use crate::settings::UnsafeFixes;
+use crate::text_helpers::ShowNonprinting;
+
+use super::DiagnosticMessage;
+
+bitflags! {
+    #[derive(Default)]
+    struct EmitterFlags: u8 {
+        /// Whether to show the fix status of a diagnostic.
+        const SHOW_FIX_STATUS    = 0b0000_0001;
+        /// Whether to show the diff of a fix, for diagnostics that have a fix.
+        const SHOW_FIX_DIFF      = 0b0000_0010;
+        /// Whether to show the source code of a diagnostic.
+        const SHOW_SOURCE        = 0b0000_0100;
+    }
+}
+
+#[derive(Default)]
+pub struct TextEmitter {
+    flags: EmitterFlags,
+    unsafe_fixes: UnsafeFixes,
+}
+
+impl TextEmitter {
+    #[must_use]
+    pub fn with_show_fix_status(mut self, show_fix_status: bool) -> Self {
+        self.flags
+            .set(EmitterFlags::SHOW_FIX_STATUS, show_fix_status);
+        self
+    }
+
+    #[must_use]
+    pub fn with_show_fix_diff(mut self, show_fix_diff: bool) -> Self {
+        self.flags.set(EmitterFlags::SHOW_FIX_DIFF, show_fix_diff);
+        self
+    }
+
+    #[must_use]
+    pub fn with_show_source(mut self, show_source: bool) -> Self {
+        self.flags.set(EmitterFlags::SHOW_SOURCE, show_source);
+        self
+    }
+
+    #[must_use]
+    pub fn with_unsafe_fixes(mut self, unsafe_fixes: UnsafeFixes) -> Self {
+        self.unsafe_fixes = unsafe_fixes;
+        self
+    }
+}
+
+impl Emitter for TextEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        for message in messages {
+            write!(
+                writer,
+                "{path}{sep}",
+                path = relativize_path(message.filename()).bold(),
+                sep = ":".cyan(),
+            )?;
+
+            let start_location = message.compute_start_location();
+
+            let diagnostic_location = start_location;
+
+            writeln!(
+                writer,
+                "{row}{sep}{col}{sep} {code_and_body}",
+                row = diagnostic_location.row,
+                col = diagnostic_location.column,
+                sep = ":".cyan(),
+                code_and_body = RuleCodeAndBody {
+                    message,
+                    show_fix_status: self.flags.intersects(EmitterFlags::SHOW_FIX_STATUS),
+                    unsafe_fixes: self.unsafe_fixes,
+                }
+            )?;
+
+            if self.flags.intersects(EmitterFlags::SHOW_SOURCE) {
+                // The `0..0` range is used to highlight file-level diagnostics.
+                if message.range() != TextRange::default() {
+                    writeln!(writer, "{}", MessageCodeFrame { message })?;
+                }
+            }
+
+            // if self.flags.intersects(EmitterFlags::SHOW_FIX_DIFF) {
+            //     if let Some(diff) = Diff::from_message(message) {
+            //         writeln!(writer, "{diff}")?;
+            //     }
+            // }
+        }
+
+        Ok(())
+    }
+}
+
+pub(super) struct RuleCodeAndBody<'a> {
+    pub(crate) message: &'a DiagnosticMessage,
+    pub(crate) show_fix_status: bool,
+    pub(crate) unsafe_fixes: UnsafeFixes,
+}
+
+impl Display for RuleCodeAndBody<'_> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        if self.show_fix_status {
+            if let Some(fix) = self.message.fix() {
+                // Do not display an indicator for inapplicable fixes
+                if fix.applies(self.unsafe_fixes.required_applicability()) {
+                    if let Some(rule) = self.message.rule() {
+                        write!(f, "{} ", rule.noqa_code().to_string().red().bold())?;
+                    }
+                    return write!(
+                        f,
+                        "{fix}{body}",
+                        fix = format_args!("[{}] ", "*".cyan()),
+                        body = self.message.body(),
+                    );
+                }
+            }
+        };
+
+        if let Some(rule) = self.message.rule() {
+            write!(
+                f,
+                "{code} {body}",
+                code = rule.noqa_code().to_string().red().bold(),
+                body = self.message.body(),
+            )
+        } else {
+            f.write_str(self.message.body())
+        }
+    }
+}
+
+pub(super) struct MessageCodeFrame<'a> {
+    pub(crate) message: &'a DiagnosticMessage,
+}
+
+impl Display for MessageCodeFrame<'_> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let source_code = self.message.source_file().to_source_code();
+
+        let content_start_index = source_code.line_index(self.message.start());
+        let mut start_index = content_start_index.saturating_sub(2);
+
+        // Trim leading empty lines.
+        while start_index < content_start_index {
+            if !source_code.line_text(start_index).trim().is_empty() {
+                break;
+            }
+            start_index = start_index.saturating_add(1);
+        }
+
+        let content_end_index = source_code.line_index(self.message.end());
+        let mut end_index = content_end_index
+            .saturating_add(2)
+            .min(OneIndexed::from_zero_indexed(source_code.line_count()));
+
+        // Trim trailing empty lines.
+        while end_index > content_end_index {
+            if !source_code.line_text(end_index).trim().is_empty() {
+                break;
+            }
+
+            end_index = end_index.saturating_sub(1);
+        }
+
+        let start_offset = source_code.line_start(start_index);
+        let end_offset = source_code.line_end(end_index);
+
+        let source = source_code.slice(TextRange::new(start_offset, end_offset));
+        let message_range = self.message.range() - start_offset;
+
+        let start_char = source[TextRange::up_to(message_range.start())]
+            .chars()
+            .count();
+        let end_char = source[TextRange::up_to(message_range.end())]
+            .chars()
+            .count();
+
+        let mut code = self.message.code.bold().bright_red();
+
+        // Disable colours for tests, if the user requests it via env var, or non-tty
+        if cfg!(test) || !colored::control::SHOULD_COLORIZE.should_colorize() {
+            code = code.clear();
+        };
+
+        let source_text = source.show_nonprinting();
+
+        let snippet = Level::None.title("").snippet(
+            Snippet::source(&source_text)
+                .line_start(start_index.get())
+                .annotation(Level::Error.span(start_char..end_char).label(&code)),
+        );
+
+        let snippet_with_footer = if let Some(s) = self.message.suggestion() {
+            snippet.footer(Level::Help.title(s))
+        } else {
+            snippet
+        };
+
+        // Disable colours for tests, if the user requests it via env var, or non-tty
+        let renderer = if !cfg!(test) && colored::control::SHOULD_COLORIZE.should_colorize() {
+            Renderer::styled()
+        } else {
+            Renderer::plain()
+        };
+        let source_block = renderer.render(snippet_with_footer);
+        writeln!(f, "{source_block}")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::TextEmitter;
+    use crate::settings::UnsafeFixes;
+
+    #[test]
+    fn default() {
+        let mut emitter = TextEmitter::default().with_show_source(true);
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+
+    #[test]
+    fn fix_status() {
+        let mut emitter = TextEmitter::default()
+            .with_show_fix_status(true)
+            .with_show_source(true);
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+
+    #[test]
+    fn fix_status_unsafe() {
+        let mut emitter = TextEmitter::default()
+            .with_show_fix_status(true)
+            .with_show_source(true)
+            .with_unsafe_fixes(UnsafeFixes::Enabled);
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+}
diff --git a/fortitude/src/printer.rs b/fortitude/src/printer.rs
new file mode 100644
index 0000000..c060f96
--- /dev/null
+++ b/fortitude/src/printer.rs
@@ -0,0 +1,87 @@
+use std::{collections::BTreeSet, io::Write};
+
+use anyhow::Result;
+use bitflags::bitflags;
+use colored::Colorize;
+
+use crate::message::{DiagnosticMessage, Emitter, JsonEmitter, TextEmitter};
+use crate::settings::OutputFormat;
+
+bitflags! {
+    #[derive(Default, Debug, Copy, Clone)]
+    pub(crate) struct Flags: u8 {
+        /// Whether to show violations when emitting diagnostics.
+        const SHOW_VIOLATIONS = 0b0000_0001;
+        /// Whether to show a summary of the fixed violations when emitting diagnostics.
+        const SHOW_FIX_SUMMARY = 0b0000_0100;
+        /// Whether to show a diff of each fixed violation when emitting diagnostics.
+        const SHOW_FIX_DIFF = 0b0000_1000;
+    }
+}
+
+pub(crate) struct Printer {
+    format: OutputFormat,
+    flags: Flags,
+}
+
+impl Printer {
+    pub(crate) fn new(format: OutputFormat, flags: Flags) -> Self {
+        Self { format, flags }
+    }
+
+    fn write_summary_text(
+        &self,
+        writer: &mut dyn Write,
+        diagnostics: &[DiagnosticMessage],
+    ) -> Result<()> {
+        let diagnostics_by_file: BTreeSet<_> = diagnostics.iter().map(|d| d.filename()).collect();
+        let total_files = diagnostics_by_file.len();
+
+        let file_no = format!(
+            "fortitude: {} files scanned.",
+            total_files.to_string().bold()
+        );
+
+        let total_errors = diagnostics.len();
+
+        if total_errors == 0 {
+            let success = "All checks passed!".bright_green();
+            writeln!(writer, "\n{file_no}\n{success}\n")?;
+        } 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")?;
+        }
+
+        Ok(())
+    }
+
+    pub(crate) fn write_once(
+        &self,
+        diagnostics: &[DiagnosticMessage],
+        writer: &mut dyn Write,
+    ) -> Result<()> {
+        match self.format {
+            OutputFormat::Concise | OutputFormat::Full => {
+                TextEmitter::default()
+                    .with_show_fix_status(true)
+                    .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)?;
+                self.write_summary_text(writer, diagnostics)?;
+            }
+            OutputFormat::Json => {
+                JsonEmitter.emit(writer, diagnostics)?;
+            }
+        }
+
+        writer.flush()?;
+        Ok(())
+    }
+}
diff --git a/fortitude/src/settings.rs b/fortitude/src/settings.rs
index 13cbf1b..775499c 100644
--- a/fortitude/src/settings.rs
+++ b/fortitude/src/settings.rs
@@ -1,4 +1,6 @@
+use ruff_diagnostics::Applicability;
 use ruff_macros::CacheKey;
+use serde::{Deserialize, Serialize};
 /// A collection of user-modifiable settings. Should be expanded as new features are added.
 use std::fmt::{Display, Formatter};
 
@@ -43,3 +45,87 @@ impl Display for PreviewMode {
 
 /// Default rule selection
 pub const DEFAULT_SELECTORS: &[RuleSelector] = &[RuleSelector::All];
+
+/// Toggle for unsafe fixes.
+/// `Hint` will not apply unsafe fixes but a message will be shown when they are available.
+/// `Disabled` will not apply unsafe fixes or show a message.
+/// `Enabled` will apply unsafe fixes.
+#[derive(Debug, Copy, Clone, CacheKey, Default, PartialEq, Eq, is_macro::Is)]
+pub enum UnsafeFixes {
+    #[default]
+    Hint,
+    Disabled,
+    Enabled,
+}
+
+impl Display for UnsafeFixes {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                Self::Hint => "hint",
+                Self::Disabled => "disabled",
+                Self::Enabled => "enabled",
+            }
+        )
+    }
+}
+
+impl From<bool> for UnsafeFixes {
+    fn from(value: bool) -> Self {
+        if value {
+            UnsafeFixes::Enabled
+        } else {
+            UnsafeFixes::Disabled
+        }
+    }
+}
+
+impl UnsafeFixes {
+    pub fn required_applicability(&self) -> Applicability {
+        match self {
+            Self::Enabled => Applicability::Unsafe,
+            Self::Disabled | Self::Hint => Applicability::Safe,
+        }
+    }
+}
+
+#[derive(
+    Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug, Hash, Default, clap::ValueEnum,
+)]
+#[serde(rename_all = "kebab-case")]
+pub enum OutputFormat {
+    Concise,
+    #[default]
+    Full,
+    Json,
+    // JsonLines,
+    // Junit,
+    // Grouped,
+    // Github,
+    // Gitlab,
+    // Pylint,
+    // Rdjson,
+    // Azure,
+    // Sarif,
+}
+
+impl Display for OutputFormat {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Concise => write!(f, "concise"),
+            Self::Full => write!(f, "full"),
+            Self::Json => write!(f, "json"),
+            // Self::JsonLines => write!(f, "json_lines"),
+            // Self::Junit => write!(f, "junit"),
+            // Self::Grouped => write!(f, "grouped"),
+            // Self::Github => write!(f, "github"),
+            // Self::Gitlab => write!(f, "gitlab"),
+            // Self::Pylint => write!(f, "pylint"),
+            // Self::Rdjson => write!(f, "rdjson"),
+            // Self::Azure => write!(f, "azure"),
+            // Self::Sarif => write!(f, "sarif"),
+        }
+    }
+}
diff --git a/fortitude/src/text_helpers.rs b/fortitude/src/text_helpers.rs
new file mode 100644
index 0000000..d4cb664
--- /dev/null
+++ b/fortitude/src/text_helpers.rs
@@ -0,0 +1,27 @@
+// Taken from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::borrow::Cow;
+
+pub(crate) trait ShowNonprinting {
+    fn show_nonprinting(&self) -> Cow<'_, str>;
+}
+
+macro_rules! impl_show_nonprinting {
+    ($(($from:expr, $to:expr)),+) => {
+        impl ShowNonprinting for str {
+            fn show_nonprinting(&self) -> Cow<'_, str> {
+                if self.find(&[$($from),*][..]).is_some() {
+                    Cow::Owned(
+                        self.$(replace($from, $to)).*
+                    )
+                } else {
+                    Cow::Borrowed(self)
+                }
+            }
+        }
+    };
+}
+
+impl_show_nonprinting!(('\x07', "␇"), ('\x08', "␈"), ('\x1b', "␛"), ('\x7f', "␡"));


Test patch:
diff --git a/fortitude/src/message/snapshots/fortitude__message__json__tests__output.snap b/fortitude/src/message/snapshots/fortitude__message__json__tests__output.snap
new file mode 100644
index 0000000..d56a2e4
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__json__tests__output.snap
@@ -0,0 +1,65 @@
+---
+source: fortitude/src/message/json.rs
+expression: content
+snapshot_kind: text
+---
+[
+  {
+    "code": "T003",
+    "end_location": {
+      "column": 18,
+      "row": 6
+    },
+    "filename": "test.f90",
+    "fix": {
+      "applicability": "unsafe",
+      "edits": [
+        {
+          "content": "",
+          "end_location": {
+            "column": 18,
+            "row": 6
+          },
+          "location": {
+            "column": 5,
+            "row": 6
+          }
+        }
+      ],
+      "message": "Remove unnecessary 'implicit none'"
+    },
+    "location": {
+      "column": 5,
+      "row": 6
+    },
+    "message": "'implicit none' set on the enclosing module"
+  },
+  {
+    "code": "S061",
+    "end_location": {
+      "column": 17,
+      "row": 7
+    },
+    "filename": "test.f90",
+    "fix": null,
+    "location": {
+      "column": 3,
+      "row": 7
+    },
+    "message": "end statement should read 'end subroutine foo'"
+  },
+  {
+    "code": "T021",
+    "end_location": {
+      "column": 9,
+      "row": 1
+    },
+    "filename": "star_kind.f90",
+    "fix": null,
+    "location": {
+      "column": 8,
+      "row": 1
+    },
+    "message": "integer*4 is non-standard, use integer(4)"
+  }
+]
diff --git a/fortitude/src/message/snapshots/fortitude__message__text__tests__default.snap b/fortitude/src/message/snapshots/fortitude__message__text__tests__default.snap
new file mode 100644
index 0000000..14128ef
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__text__tests__default.snap
@@ -0,0 +1,30 @@
+---
+source: fortitude/src/message/text.rs
+expression: content
+snapshot_kind: text
+---
+test.f90:6:5: T003 'implicit none' set on the enclosing module
+  |
+4 | contains
+5 |   subroutine foo
+6 |     implicit none
+  |     ^^^^^^^^^^^^^ T003
+7 |   end subroutine
+8 | end module
+  |
+  = help: Remove unnecessary 'implicit none'
+
+test.f90:7:3: S061 end statement should read 'end subroutine foo'
+  |
+5 |   subroutine foo
+6 |     implicit none
+7 |   end subroutine
+  |   ^^^^^^^^^^^^^^ S061
+8 | end module
+  |
+
+star_kind.f90:1:8: T021 integer*4 is non-standard, use integer(4)
+  |
+1 | integer*4 foo; end
+  |        ^ T021
+  |
diff --git a/fortitude/src/message/snapshots/fortitude__message__text__tests__fix_status.snap b/fortitude/src/message/snapshots/fortitude__message__text__tests__fix_status.snap
new file mode 100644
index 0000000..14128ef
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__text__tests__fix_status.snap
@@ -0,0 +1,30 @@
+---
+source: fortitude/src/message/text.rs
+expression: content
+snapshot_kind: text
+---
+test.f90:6:5: T003 'implicit none' set on the enclosing module
+  |
+4 | contains
+5 |   subroutine foo
+6 |     implicit none
+  |     ^^^^^^^^^^^^^ T003
+7 |   end subroutine
+8 | end module
+  |
+  = help: Remove unnecessary 'implicit none'
+
+test.f90:7:3: S061 end statement should read 'end subroutine foo'
+  |
+5 |   subroutine foo
+6 |     implicit none
+7 |   end subroutine
+  |   ^^^^^^^^^^^^^^ S061
+8 | end module
+  |
+
+star_kind.f90:1:8: T021 integer*4 is non-standard, use integer(4)
+  |
+1 | integer*4 foo; end
+  |        ^ T021
+  |
diff --git a/fortitude/src/message/snapshots/fortitude__message__text__tests__fix_status_unsafe.snap b/fortitude/src/message/snapshots/fortitude__message__text__tests__fix_status_unsafe.snap
new file mode 100644
index 0000000..ea69414
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__text__tests__fix_status_unsafe.snap
@@ -0,0 +1,30 @@
+---
+source: fortitude/src/message/text.rs
+expression: content
+snapshot_kind: text
+---
+test.f90:6:5: T003 [*] 'implicit none' set on the enclosing module
+  |
+4 | contains
+5 |   subroutine foo
+6 |     implicit none
+  |     ^^^^^^^^^^^^^ T003
+7 |   end subroutine
+8 | end module
+  |
+  = help: Remove unnecessary 'implicit none'
+
+test.f90:7:3: S061 end statement should read 'end subroutine foo'
+  |
+5 |   subroutine foo
+6 |     implicit none
+7 |   end subroutine
+  |   ^^^^^^^^^^^^^^ S061
+8 | end module
+  |
+
+star_kind.f90:1:8: T021 integer*4 is non-standard, use integer(4)
+  |
+1 | integer*4 foo; end
+  |        ^ T021
+  |
diff --git a/fortitude/src/rules/modules/snapshots/fortitude__rules__modules__tests__external-function_M001.f90.snap b/fortitude/src/rules/modules/snapshots/fortitude__rules__modules__tests__external-function_M001.f90.snap
index 20781f9..17a6a2d 100644
--- a/fortitude/src/rules/modules/snapshots/fortitude__rules__modules__tests__external-function_M001.f90.snap
+++ b/fortitude/src/rules/modules/snapshots/fortitude__rules__modules__tests__external-function_M001.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/modules/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/modules/M001.f90:1:1: M001 function not contained within (sub)module or program
+./resources/test/fixtures/modules/M001.f90:1:1: M001 function not contained within (sub)module or program
   |
 1 | integer function double(x)
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^ M001
@@ -11,10 +11,10 @@ warning: ./resources/test/fixtures/modules/M001.f90:1:1: M001 function not conta
 3 |   double = 2 * x
   |
 
-warning: ./resources/test/fixtures/modules/M001.f90:6:1: M001 subroutine not contained within (sub)module or program
+./resources/test/fixtures/modules/M001.f90:6:1: M001 subroutine not contained within (sub)module or program
   |
 4 | end function double
-5 | 
+5 |
 6 | subroutine triple(x)
   | ^^^^^^^^^^^^^^^^^^^^ M001
 7 |   integer, intent(inout) :: x
diff --git a/fortitude/src/rules/modules/snapshots/fortitude__rules__modules__tests__use-all_M011.f90.snap b/fortitude/src/rules/modules/snapshots/fortitude__rules__modules__tests__use-all_M011.f90.snap
index dbb7260..9aaec47 100644
--- a/fortitude/src/rules/modules/snapshots/fortitude__rules__modules__tests__use-all_M011.f90.snap
+++ b/fortitude/src/rules/modules/snapshots/fortitude__rules__modules__tests__use-all_M011.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/modules/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/modules/M011.f90:3:3: M011 'use' statement missing 'only' clause
+./resources/test/fixtures/modules/M011.f90:3:3: M011 'use' statement missing 'only' clause
   |
 1 | module my_module
 2 |   use, intrinsic :: iso_fortran_env, only: real32
diff --git a/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__double-precision_P011.f90.snap b/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__double-precision_P011.f90.snap
index 3676530..ee67b0b 100644
--- a/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__double-precision_P011.f90.snap
+++ b/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__double-precision_P011.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/precision/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/precision/P011.f90:1:1: P011 prefer 'real(real64)' to 'double precision' (see 'iso_fortran_env')
+./resources/test/fixtures/precision/P011.f90:1:1: P011 prefer 'real(real64)' to 'double precision' (see 'iso_fortran_env')
   |
 1 | double precision function double(x)
   | ^^^^^^^^^^^^^^^^ P011
@@ -11,7 +11,7 @@ warning: ./resources/test/fixtures/precision/P011.f90:1:1: P011 prefer 'real(rea
 3 |   double = 2 * x
   |
 
-warning: ./resources/test/fixtures/precision/P011.f90:2:3: P011 prefer 'real(real64)' to 'double precision' (see 'iso_fortran_env')
+./resources/test/fixtures/precision/P011.f90:2:3: P011 prefer 'real(real64)' to 'double precision' (see 'iso_fortran_env')
   |
 1 | double precision function double(x)
 2 |   double precision, intent(in) :: x
@@ -20,7 +20,7 @@ warning: ./resources/test/fixtures/precision/P011.f90:2:3: P011 prefer 'real(rea
 4 | end function double
   |
 
-warning: ./resources/test/fixtures/precision/P011.f90:7:3: P011 prefer 'real(real64)' to 'double precision' (see 'iso_fortran_env')
+./resources/test/fixtures/precision/P011.f90:7:3: P011 prefer 'real(real64)' to 'double precision' (see 'iso_fortran_env')
   |
 6 | subroutine triple(x)
 7 |   double precision, intent(inout) :: x
@@ -29,7 +29,7 @@ warning: ./resources/test/fixtures/precision/P011.f90:7:3: P011 prefer 'real(rea
 9 | end subroutine triple
   |
 
-warning: ./resources/test/fixtures/precision/P011.f90:12:3: P011 prefer 'real(real64)' to 'double precision' (see 'iso_fortran_env')
+./resources/test/fixtures/precision/P011.f90:12:3: P011 prefer 'real(real64)' to 'double precision' (see 'iso_fortran_env')
    |
 11 | function complex_mul(x, y)
 12 |   double precision, intent(in) :: x
@@ -38,7 +38,7 @@ warning: ./resources/test/fixtures/precision/P011.f90:12:3: P011 prefer 'real(re
 14 |   double complex :: complex_mul
    |
 
-warning: ./resources/test/fixtures/precision/P011.f90:13:3: P011 prefer 'complex(real64)' to 'double complex' (see 'iso_fortran_env')
+./resources/test/fixtures/precision/P011.f90:13:3: P011 prefer 'complex(real64)' to 'double complex' (see 'iso_fortran_env')
    |
 11 | function complex_mul(x, y)
 12 |   double precision, intent(in) :: x
@@ -48,7 +48,7 @@ warning: ./resources/test/fixtures/precision/P011.f90:13:3: P011 prefer 'complex
 15 |   complex_mul = x * y
    |
 
-warning: ./resources/test/fixtures/precision/P011.f90:14:3: P011 prefer 'complex(real64)' to 'double complex' (see 'iso_fortran_env')
+./resources/test/fixtures/precision/P011.f90:14:3: P011 prefer 'complex(real64)' to 'double complex' (see 'iso_fortran_env')
    |
 12 |   double precision, intent(in) :: x
 13 |   double complex, intent(in) :: y
diff --git a/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__implicit-real-kind_P021.f90.snap b/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__implicit-real-kind_P021.f90.snap
index 85e19c3..3468231 100644
--- a/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__implicit-real-kind_P021.f90.snap
+++ b/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__implicit-real-kind_P021.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/precision/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/precision/P021.f90:1:1: P021 real has implicit kind
+./resources/test/fixtures/precision/P021.f90:1:1: P021 real has implicit kind
   |
 1 | real function my_func(a, b, c, d, e)       ! catch
   | ^^^^ P021
@@ -11,7 +11,7 @@ warning: ./resources/test/fixtures/precision/P021.f90:1:1: P021 real has implici
 3 |   real(4), intent(in) :: b                 ! ignore
   |
 
-warning: ./resources/test/fixtures/precision/P021.f90:2:3: P021 real has implicit kind
+./resources/test/fixtures/precision/P021.f90:2:3: P021 real has implicit kind
   |
 1 | real function my_func(a, b, c, d, e)       ! catch
 2 |   real, intent(in) :: a                    ! catch
@@ -20,7 +20,7 @@ warning: ./resources/test/fixtures/precision/P021.f90:2:3: P021 real has implici
 4 |   integer, intent(in) :: c                 ! ignore
   |
 
-warning: ./resources/test/fixtures/precision/P021.f90:5:3: P021 complex has implicit kind
+./resources/test/fixtures/precision/P021.f90:5:3: P021 complex has implicit kind
   |
 3 |   real(4), intent(in) :: b                 ! ignore
 4 |   integer, intent(in) :: c                 ! ignore
diff --git a/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__no-real-suffix_P001.f90.snap b/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__no-real-suffix_P001.f90.snap
index c56eb3d..34678bf 100644
--- a/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__no-real-suffix_P001.f90.snap
+++ b/fortitude/src/rules/precision/snapshots/fortitude__rules__precision__tests__no-real-suffix_P001.f90.snap
@@ -3,17 +3,17 @@ source: fortitude/src/rules/precision/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/precision/P001.f90:4:31: P001 real literal 1.234567 missing kind suffix
+./resources/test/fixtures/precision/P001.f90:4:31: P001 real literal 1.234567 missing kind suffix
   |
 2 |   use, intrinsic :: iso_fortran_env, only: sp => real32, dp => real64
-3 | 
+3 |
 4 |   real(sp), parameter :: x1 = 1.234567
   |                               ^^^^^^^^ P001
 5 |   real(dp), parameter :: x2 = 1.234567_dp
 6 |   real(dp), parameter :: x3 = 1.789d3 ! rule should ignore d exponentiation
   |
 
-warning: ./resources/test/fixtures/precision/P001.f90:7:31: P001 real literal 9.876 missing kind suffix
+./resources/test/fixtures/precision/P001.f90:7:31: P001 real literal 9.876 missing kind suffix
   |
 5 |   real(dp), parameter :: x2 = 1.234567_dp
 6 |   real(dp), parameter :: x3 = 1.789d3 ! rule should ignore d exponentiation
@@ -23,7 +23,7 @@ warning: ./resources/test/fixtures/precision/P001.f90:7:31: P001 real literal 9.
 9 |   real(sp), parameter :: x6 = 2.
   |
 
-warning: ./resources/test/fixtures/precision/P001.f90:9:31: P001 real literal 2. missing kind suffix
+./resources/test/fixtures/precision/P001.f90:9:31: P001 real literal 2. missing kind suffix
    |
  7 |   real(dp), parameter :: x4 = 9.876
  8 |   real(sp), parameter :: x5 = 2.468_sp
@@ -33,7 +33,7 @@ warning: ./resources/test/fixtures/precision/P001.f90:9:31: P001 real literal 2.
 11 |   real(sp), parameter :: x8 = 1E2
    |
 
-warning: ./resources/test/fixtures/precision/P001.f90:10:31: P001 real literal .0 missing kind suffix
+./resources/test/fixtures/precision/P001.f90:10:31: P001 real literal .0 missing kind suffix
    |
  8 |   real(sp), parameter :: x5 = 2.468_sp
  9 |   real(sp), parameter :: x6 = 2.
@@ -43,7 +43,7 @@ warning: ./resources/test/fixtures/precision/P001.f90:10:31: P001 real literal .
 12 |   real(sp), parameter :: x9 = .1e2
    |
 
-warning: ./resources/test/fixtures/precision/P001.f90:11:31: P001 real literal 1E2 missing kind suffix
+./resources/test/fixtures/precision/P001.f90:11:31: P001 real literal 1E2 missing kind suffix
    |
  9 |   real(sp), parameter :: x6 = 2.
 10 |   real(sp), parameter :: x7 = .0
@@ -53,7 +53,7 @@ warning: ./resources/test/fixtures/precision/P001.f90:11:31: P001 real literal 1
 13 |   real(sp), parameter :: y1 = 1.E2
    |
 
-warning: ./resources/test/fixtures/precision/P001.f90:12:31: P001 real literal .1e2 missing kind suffix
+./resources/test/fixtures/precision/P001.f90:12:31: P001 real literal .1e2 missing kind suffix
    |
 10 |   real(sp), parameter :: x7 = .0
 11 |   real(sp), parameter :: x8 = 1E2
@@ -63,7 +63,7 @@ warning: ./resources/test/fixtures/precision/P001.f90:12:31: P001 real literal .
 14 |   real(sp), parameter :: y2 = 1.2e3
    |
 
-warning: ./resources/test/fixtures/precision/P001.f90:13:31: P001 real literal 1.E2 missing kind suffix
+./resources/test/fixtures/precision/P001.f90:13:31: P001 real literal 1.E2 missing kind suffix
    |
 11 |   real(sp), parameter :: x8 = 1E2
 12 |   real(sp), parameter :: x9 = .1e2
@@ -73,7 +73,7 @@ warning: ./resources/test/fixtures/precision/P001.f90:13:31: P001 real literal 1
 15 | end program test
    |
 
-warning: ./resources/test/fixtures/precision/P001.f90:14:31: P001 real literal 1.2e3 missing kind suffix
+./resources/test/fixtures/precision/P001.f90:14:31: P001 real literal 1.2e3 missing kind suffix
    |
 12 |   real(sp), parameter :: x9 = .1e2
 13 |   real(sp), parameter :: y1 = 1.E2
diff --git a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__deprecated-relational-operator_S051.f90.snap b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__deprecated-relational-operator_S051.f90.snap
index 66f05aa..8a57f1f 100644
--- a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__deprecated-relational-operator_S051.f90.snap
+++ b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__deprecated-relational-operator_S051.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/style/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/style/S051.f90:2:9: S051 deprecated relational operator '.gt.', prefer '>' instead
+./resources/test/fixtures/style/S051.f90:2:9: S051 deprecated relational operator '.gt.', prefer '>' instead
   |
 1 | program test
 2 |   if (0 .gt. 1) error stop
@@ -12,7 +12,7 @@ warning: ./resources/test/fixtures/style/S051.f90:2:9: S051 deprecated relationa
 4 |   if (a.eq.b.and.a.ne.b) error stop
   |
 
-warning: ./resources/test/fixtures/style/S051.f90:3:9: S051 deprecated relational operator '.le.', prefer '<=' instead
+./resources/test/fixtures/style/S051.f90:3:9: S051 deprecated relational operator '.le.', prefer '<=' instead
   |
 1 | program test
 2 |   if (0 .gt. 1) error stop
@@ -22,7 +22,7 @@ warning: ./resources/test/fixtures/style/S051.f90:3:9: S051 deprecated relationa
 5 |   if (1 == 2) error stop  ! OK
   |
 
-warning: ./resources/test/fixtures/style/S051.f90:4:8: S051 deprecated relational operator '.eq.', prefer '==' instead
+./resources/test/fixtures/style/S051.f90:4:8: S051 deprecated relational operator '.eq.', prefer '==' instead
   |
 2 |   if (0 .gt. 1) error stop
 3 |   if (1 .le. 0) error stop
@@ -32,7 +32,7 @@ warning: ./resources/test/fixtures/style/S051.f90:4:8: S051 deprecated relationa
 6 |   if (2 /= 2) error stop  ! OK
   |
 
-warning: ./resources/test/fixtures/style/S051.f90:4:19: S051 deprecated relational operator '.ne.', prefer '/=' instead
+./resources/test/fixtures/style/S051.f90:4:19: S051 deprecated relational operator '.ne.', prefer '/=' instead
   |
 2 |   if (0 .gt. 1) error stop
 3 |   if (1 .le. 0) error stop
diff --git a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__line-too-long_S001.f90.snap b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__line-too-long_S001.f90.snap
index 06e00ab..ae51a99 100644
--- a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__line-too-long_S001.f90.snap
+++ b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__line-too-long_S001.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/style/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/style/S001.f90:2:101: S001 line length of 222, exceeds maximum 100
+./resources/test/fixtures/style/S001.f90:2:101: S001 line length of 222, exceeds maximum 100
   |
 1 | ...
 2 | ...ally_really_really_really_really_really_really_really_really_really_really_long_module_name, only : integer_working_precision
diff --git a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__line-too-long_S001_line_length_20.f90.snap b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__line-too-long_S001_line_length_20.f90.snap
index cabb07e..76aea57 100644
--- a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__line-too-long_S001_line_length_20.f90.snap
+++ b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__line-too-long_S001_line_length_20.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/style/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/style/S001_line_length_20.f90:2:21: S001 line length of 68, exceeds maximum 20
+./resources/test/fixtures/style/S001_line_length_20.f90:2:21: S001 line length of 68, exceeds maximum 20
   |
 1 | program test
 2 |   use some_really_long_module_name, only : integer_working_precision
@@ -12,7 +12,7 @@ warning: ./resources/test/fixtures/style/S001_line_length_20.f90:2:21: S001 line
 4 |   integer(integer_working_precision), parameter, dimension(1) :: a = [1]
   |
 
-warning: ./resources/test/fixtures/style/S001_line_length_20.f90:4:21: S001 line length of 72, exceeds maximum 20
+./resources/test/fixtures/style/S001_line_length_20.f90:4:21: S001 line length of 72, exceeds maximum 20
   |
 2 |   use some_really_long_module_name, only : integer_working_precision
 3 |   implicit none
diff --git a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__missing-exit-or-cycle-label_S021.f90.snap b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__missing-exit-or-cycle-label_S021.f90.snap
index df3825b..8cf959f 100644
--- a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__missing-exit-or-cycle-label_S021.f90.snap
+++ b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__missing-exit-or-cycle-label_S021.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/style/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/style/S021.f90:4:7: S021 'exit' statement in named 'do' loop missing label 'label1'
+./resources/test/fixtures/style/S021.f90:4:7: S021 'exit' statement in named 'do' loop missing label 'label1'
   |
 2 |   label1: do
 3 |     if (.true.) then
@@ -13,7 +13,7 @@ warning: ./resources/test/fixtures/style/S021.f90:4:7: S021 'exit' statement in
 6 |   end do label1
   |
 
-warning: ./resources/test/fixtures/style/S021.f90:9:17: S021 'exit' statement in named 'do' loop missing label 'label2'
+./resources/test/fixtures/style/S021.f90:9:17: S021 'exit' statement in named 'do' loop missing label 'label2'
    |
  8 |   label2: do
  9 |     if (.true.) exit
@@ -21,7 +21,7 @@ warning: ./resources/test/fixtures/style/S021.f90:9:17: S021 'exit' statement in
 10 |   end do label2
    |
 
-warning: ./resources/test/fixtures/style/S021.f90:28:19: S021 'cycle' statement in named 'do' loop missing label 'inner'
+./resources/test/fixtures/style/S021.f90:28:19: S021 'cycle' statement in named 'do' loop missing label 'inner'
    |
 26 |   label6: do i = 1, 2
 27 |     inner: do j = 1, 2
diff --git a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__old-style-array-literal_S041.f90.snap b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__old-style-array-literal_S041.f90.snap
index 35e596d..006f1f9 100644
--- a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__old-style-array-literal_S041.f90.snap
+++ b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__old-style-array-literal_S041.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/style/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/style/S041.f90:2:21: S041 Array literal uses old-style syntax: prefer `[...]`
+./resources/test/fixtures/style/S041.f90:2:21: S041 Array literal uses old-style syntax: prefer `[...]`
   |
 1 | program test
 2 |   integer :: a(3) = (/1, 2, 3/)
@@ -12,7 +12,7 @@ warning: ./resources/test/fixtures/style/S041.f90:2:21: S041 Array literal uses
 4 |        1, &
   |
 
-warning: ./resources/test/fixtures/style/S041.f90:3:21: S041 Array literal uses old-style syntax: prefer `[...]`
+./resources/test/fixtures/style/S041.f90:3:21: S041 Array literal uses old-style syntax: prefer `[...]`
   |
 1 |   program test
 2 |     integer :: a(3) = (/1, 2, 3/)
@@ -27,7 +27,7 @@ warning: ./resources/test/fixtures/style/S041.f90:3:21: S041 Array literal uses
 9 |     b(1:3) = (/ &
   |
 
-warning: ./resources/test/fixtures/style/S041.f90:8:19: S041 Array literal uses old-style syntax: prefer `[...]`
+./resources/test/fixtures/style/S041.f90:8:19: S041 Array literal uses old-style syntax: prefer `[...]`
    |
  6 |        3 &
  7 |        /)
@@ -37,7 +37,7 @@ warning: ./resources/test/fixtures/style/S041.f90:8:19: S041 Array literal uses
 10 |        4, &
    |
 
-warning: ./resources/test/fixtures/style/S041.f90:9:12: S041 Array literal uses old-style syntax: prefer `[...]`
+./resources/test/fixtures/style/S041.f90:9:12: S041 Array literal uses old-style syntax: prefer `[...]`
    |
  7 |          /)
  8 |     if (.true.) a = (/4, 5, 6/)
diff --git a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__trailing-whitespace_S101.f90.snap b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__trailing-whitespace_S101.f90.snap
index 39ad346..1436918 100644
--- a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__trailing-whitespace_S101.f90.snap
+++ b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__trailing-whitespace_S101.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/style/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/style/S101.f90:1:13: S101 trailing whitespace
+./resources/test/fixtures/style/S101.f90:1:13: S101 trailing whitespace
   |
 1 | program test  
   |             ^^ S101
@@ -11,7 +11,7 @@ warning: ./resources/test/fixtures/style/S101.f90:1:13: S101 trailing whitespace
 3 |   integer :: a(3) = [ & 
   |
 
-warning: ./resources/test/fixtures/style/S101.f90:3:24: S101 trailing whitespace
+./resources/test/fixtures/style/S101.f90:3:24: S101 trailing whitespace
   |
 1 | program test  
 2 |   implicit none
@@ -21,7 +21,7 @@ warning: ./resources/test/fixtures/style/S101.f90:3:24: S101 trailing whitespace
 5 |     2, &
   |
 
-warning: ./resources/test/fixtures/style/S101.f90:7:4: S101 trailing whitespace
+./resources/test/fixtures/style/S101.f90:7:4: S101 trailing whitespace
   |
 5 |     2, &
 6 |     3 &
@@ -31,7 +31,7 @@ warning: ./resources/test/fixtures/style/S101.f90:7:4: S101 trailing whitespace
 9 | end program test
   |
 
-warning: ./resources/test/fixtures/style/S101.f90:8:1: S101 trailing whitespace
+./resources/test/fixtures/style/S101.f90:8:1: S101 trailing whitespace
   |
 6 |     3 &
 7 |   ]    
diff --git a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__unnamed-end-statement_S061.f90.snap b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__unnamed-end-statement_S061.f90.snap
index 5fbd084..80251ce 100644
--- a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__unnamed-end-statement_S061.f90.snap
+++ b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__unnamed-end-statement_S061.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/style/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/style/S061.f90:5:3: S061 end statement should read 'end type mytype'
+./resources/test/fixtures/style/S061.f90:5:3: S061 end statement should read 'end type mytype'
   |
 3 |   type mytype
 4 |     integer :: x
@@ -13,7 +13,7 @@ warning: ./resources/test/fixtures/style/S061.f90:5:3: S061 end statement should
 7 |   subroutine mysub1()
   |
 
-warning: ./resources/test/fixtures/style/S061.f90:9:3: S061 end statement should read 'end subroutine mysub1'
+./resources/test/fixtures/style/S061.f90:9:3: S061 end statement should read 'end subroutine mysub1'
    |
  7 |   subroutine mysub1()
  8 |     write (*,*) 'hello world'
@@ -23,7 +23,7 @@ warning: ./resources/test/fixtures/style/S061.f90:9:3: S061 end statement should
 11 |     write (*,*) 'hello world'
    |
 
-warning: ./resources/test/fixtures/style/S061.f90:13:1: S061 end statement should read 'end module mymod1'
+./resources/test/fixtures/style/S061.f90:13:1: S061 end statement should read 'end module mymod1'
    |
 11 |     write (*,*) 'hello world'
 12 |   end subroutine mysub2         ! ignore this
@@ -33,7 +33,7 @@ warning: ./resources/test/fixtures/style/S061.f90:13:1: S061 end statement shoul
 15 |   implicit none
    |
 
-warning: ./resources/test/fixtures/style/S061.f90:22:3: S061 end statement should read 'end function myfunc1'
+./resources/test/fixtures/style/S061.f90:22:3: S061 end statement should read 'end function myfunc1'
    |
 20 |   integer function myfunc1()
 21 |     myfunc1 = 1
@@ -43,7 +43,7 @@ warning: ./resources/test/fixtures/style/S061.f90:22:3: S061 end statement shoul
 24 |     myfunc2 = 1
    |
 
-warning: ./resources/test/fixtures/style/S061.f90:26:1: S061 end statement should read 'end module mymod2'
+./resources/test/fixtures/style/S061.f90:26:1: S061 end statement should read 'end module mymod2'
    |
 24 |     myfunc2 = 1
 25 |   end function myfunc2          ! ignore this
@@ -53,7 +53,7 @@ warning: ./resources/test/fixtures/style/S061.f90:26:1: S061 end statement shoul
 28 |   interface
    |
 
-warning: ./resources/test/fixtures/style/S061.f90:44:3: S061 end statement should read 'end procedure foo'
+./resources/test/fixtures/style/S061.f90:44:3: S061 end statement should read 'end procedure foo'
    |
 42 |   module procedure foo
 43 |     x = 1
@@ -63,7 +63,7 @@ warning: ./resources/test/fixtures/style/S061.f90:44:3: S061 end statement shoul
 46 | submodule (mymod3) mysub2
    |
 
-warning: ./resources/test/fixtures/style/S061.f90:45:1: S061 end statement should read 'end submodule mysub1'
+./resources/test/fixtures/style/S061.f90:45:1: S061 end statement should read 'end submodule mysub1'
    |
 43 |     x = 1
 44 |   end procedure                 ! catch this
@@ -73,7 +73,7 @@ warning: ./resources/test/fixtures/style/S061.f90:45:1: S061 end statement shoul
 47 | contains
    |
 
-warning: ./resources/test/fixtures/style/S061.f90:51:1: S061 end statement should read 'end submodule mysub2'
+./resources/test/fixtures/style/S061.f90:51:1: S061 end statement should read 'end submodule mysub2'
    |
 49 |     x = 1
 50 |   end procedure bar             ! ignore this
@@ -83,7 +83,7 @@ warning: ./resources/test/fixtures/style/S061.f90:51:1: S061 end statement shoul
 53 | contains
    |
 
-warning: ./resources/test/fixtures/style/S061.f90:61:1: S061 end statement should read 'end program myprog'
+./resources/test/fixtures/style/S061.f90:61:1: S061 end statement should read 'end program myprog'
    |
 59 |   implicit none
 60 |   write (*,*) 'hello world'
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__assumed-size-character-intent_T042.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__assumed-size-character-intent_T042.f90.snap
index 52ecfa0..58e2ffa 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__assumed-size-character-intent_T042.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__assumed-size-character-intent_T042.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T042.f90:3:14: T042 character 'autochar_glob' has assumed size but does not have `intent(in)`
+./resources/test/fixtures/typing/T042.f90:3:14: T042 character 'autochar_glob' has assumed size but does not have `intent(in)`
   |
 1 | program cases
 2 |   ! A char array outside a function or subroutine, no exception
@@ -13,7 +13,7 @@ warning: ./resources/test/fixtures/typing/T042.f90:3:14: T042 character 'autocha
 5 |   subroutine char_input(autochar_in, autochar_inout, autochar_out, fixedchar)
   |
 
-warning: ./resources/test/fixtures/typing/T042.f90:9:15: T042 character 'autochar_inout' has assumed size but does not have `intent(in)`
+./resources/test/fixtures/typing/T042.f90:9:15: T042 character 'autochar_inout' has assumed size but does not have `intent(in)`
    |
  7 |     character(*), intent(in)       :: autochar_in
  8 |     ! A char array with disallowed intent, exception
@@ -23,7 +23,7 @@ warning: ./resources/test/fixtures/typing/T042.f90:9:15: T042 character 'autocha
 11 |     character(len=*), intent(out)  :: autochar_out
    |
 
-warning: ./resources/test/fixtures/typing/T042.f90:11:19: T042 character 'autochar_out' has assumed size but does not have `intent(in)`
+./resources/test/fixtures/typing/T042.f90:11:19: T042 character 'autochar_out' has assumed size but does not have `intent(in)`
    |
  9 |     character(*), intent(inout)    :: autochar_inout
 10 |     ! A char array with disallowed intent, exception
@@ -33,7 +33,7 @@ warning: ./resources/test/fixtures/typing/T042.f90:11:19: T042 character 'autoch
 13 |     character(*)                   :: autochar_var
    |
 
-warning: ./resources/test/fixtures/typing/T042.f90:13:15: T042 character 'autochar_var' has assumed size but does not have `intent(in)`
+./resources/test/fixtures/typing/T042.f90:13:15: T042 character 'autochar_var' has assumed size but does not have `intent(in)`
    |
 11 |     character(len=*), intent(out)  :: autochar_out
 12 |     ! A char array not passed as a parameter, no exception
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__assumed-size_T041.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__assumed-size_T041.f90.snap
index ac26166..db64acd 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__assumed-size_T041.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__assumed-size_T041.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T041.f90:3:28: T041 'array' has assumed size
+./resources/test/fixtures/typing/T041.f90:3:28: T041 'array' has assumed size
   |
 1 | subroutine assumed_size_dimension(array, n, m, l, o, p, options, thing, q)
 2 |   integer, intent(in) :: n, m
@@ -13,7 +13,7 @@ warning: ./resources/test/fixtures/typing/T041.f90:3:28: T041 'array' has assume
 5 |   ! warning must be on the array part for characters
   |
 
-warning: ./resources/test/fixtures/typing/T041.f90:4:28: T041 'l' has assumed size
+./resources/test/fixtures/typing/T041.f90:4:28: T041 'l' has assumed size
   |
 2 |   integer, intent(in) :: n, m
 3 |   integer, dimension(n, m, *), intent(in) :: array
@@ -23,7 +23,7 @@ warning: ./resources/test/fixtures/typing/T041.f90:4:28: T041 'l' has assumed si
 6 |   character(len=*), dimension(*) :: options
   |
 
-warning: ./resources/test/fixtures/typing/T041.f90:4:37: T041 'p' has assumed size
+./resources/test/fixtures/typing/T041.f90:4:37: T041 'p' has assumed size
   |
 2 |   integer, intent(in) :: n, m
 3 |   integer, dimension(n, m, *), intent(in) :: array
@@ -33,7 +33,7 @@ warning: ./resources/test/fixtures/typing/T041.f90:4:37: T041 'p' has assumed si
 6 |   character(len=*), dimension(*) :: options
   |
 
-warning: ./resources/test/fixtures/typing/T041.f90:6:31: T041 'options' has assumed size
+./resources/test/fixtures/typing/T041.f90:6:31: T041 'options' has assumed size
   |
 4 |   integer, intent(in) :: l(*), o, p(*)
 5 |   ! warning must be on the array part for characters
@@ -43,7 +43,7 @@ warning: ./resources/test/fixtures/typing/T041.f90:6:31: T041 'options' has assu
 8 |   ! this is ok
   |
 
-warning: ./resources/test/fixtures/typing/T041.f90:7:25: T041 'thing' has assumed size
+./resources/test/fixtures/typing/T041.f90:7:25: T041 'thing' has assumed size
   |
 5 |   ! warning must be on the array part for characters
 6 |   character(len=*), dimension(*) :: options
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__deprecated-assumed-size-character_T043.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__deprecated-assumed-size-character_T043.f90.snap
index ec34756..2a007db 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__deprecated-assumed-size-character_T043.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__deprecated-assumed-size-character_T043.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T043.f90:4:15: T043 character 'a' uses deprecated syntax for assumed size
+./resources/test/fixtures/typing/T043.f90:4:15: T043 character 'a' uses deprecated syntax for assumed size
   |
 2 | contains
 3 |   subroutine char_input(a, b, c, d, e, f)
@@ -13,7 +13,7 @@ warning: ./resources/test/fixtures/typing/T043.f90:4:15: T043 character 'a' uses
 6 |     character*(len=*), intent(in) :: c
   |
 
-warning: ./resources/test/fixtures/typing/T043.f90:5:14: T043 character 'b' uses deprecated syntax for assumed size
+./resources/test/fixtures/typing/T043.f90:5:14: T043 character 'b' uses deprecated syntax for assumed size
   |
 3 |   subroutine char_input(a, b, c, d, e, f)
 4 |     character * ( * ), intent(in) :: a
@@ -23,7 +23,7 @@ warning: ./resources/test/fixtures/typing/T043.f90:5:14: T043 character 'b' uses
 7 |     character*(3), intent(in) :: d
   |
 
-warning: ./resources/test/fixtures/typing/T043.f90:6:14: T043 character 'c' uses deprecated syntax for assumed size
+./resources/test/fixtures/typing/T043.f90:6:14: T043 character 'c' uses deprecated syntax for assumed size
   |
 4 |     character * ( * ), intent(in) :: a
 5 |     character*(*), intent(in) :: b
@@ -33,7 +33,7 @@ warning: ./resources/test/fixtures/typing/T043.f90:6:14: T043 character 'c' uses
 8 |     character*(MAX_LEN), intent(in) :: e
   |
 
-warning: ./resources/test/fixtures/typing/T043.f90:7:14: T043 character 'd' uses deprecated syntax for assumed size
+./resources/test/fixtures/typing/T043.f90:7:14: T043 character 'd' uses deprecated syntax for assumed size
   |
 5 |     character*(*), intent(in) :: b
 6 |     character*(len=*), intent(in) :: c
@@ -43,7 +43,7 @@ warning: ./resources/test/fixtures/typing/T043.f90:7:14: T043 character 'd' uses
 9 |     ! these are ok
   |
 
-warning: ./resources/test/fixtures/typing/T043.f90:8:14: T043 character 'e' uses deprecated syntax for assumed size
+./resources/test/fixtures/typing/T043.f90:8:14: T043 character 'e' uses deprecated syntax for assumed size
    |
  6 |     character*(len=*), intent(in) :: c
  7 |     character*(3), intent(in) :: d
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__implicit-typing_T001.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__implicit-typing_T001.f90.snap
index 82f54c7..e2e42c4 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__implicit-typing_T001.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__implicit-typing_T001.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T001.f90:1:1: T001 module missing 'implicit none'
+./resources/test/fixtures/typing/T001.f90:1:1: T001 module missing 'implicit none'
   |
 1 | module my_module
   | ^^^^^^^^^^^^^^^^ T001
@@ -11,10 +11,10 @@ warning: ./resources/test/fixtures/typing/T001.f90:1:1: T001 module missing 'imp
 3 | end module my_module
   |
 
-warning: ./resources/test/fixtures/typing/T001.f90:5:1: T001 program missing 'implicit none'
+./resources/test/fixtures/typing/T001.f90:5:1: T001 program missing 'implicit none'
   |
 3 | end module my_module
-4 | 
+4 |
 5 | program my_program
   | ^^^^^^^^^^^^^^^^^^ T001
 6 |   write(*,*) 42
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__initialisation-in-declaration_T051.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__initialisation-in-declaration_T051.f90.snap
index 57a91a4..d70d134 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__initialisation-in-declaration_T051.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__initialisation-in-declaration_T051.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T051.f90:10:16: T051 'foo' is initialised in its declaration and has no explicit `save` or `parameter` attribute
+./resources/test/fixtures/typing/T051.f90:10:16: T051 'foo' is initialised in its declaration and has no explicit `save` or `parameter` attribute
    |
  9 |   subroutine init_decl1()
 10 |     integer :: foo = 1
@@ -11,7 +11,7 @@ warning: ./resources/test/fixtures/typing/T051.f90:10:16: T051 'foo' is initiali
 11 |   end subroutine init_decl1
    |
 
-warning: ./resources/test/fixtures/typing/T051.f90:19:21: T051 'bar' is initialised in its declaration and has no explicit `save` or `parameter` attribute
+./resources/test/fixtures/typing/T051.f90:19:21: T051 'bar' is initialised in its declaration and has no explicit `save` or `parameter` attribute
    |
 18 |   subroutine init_decl3()
 19 |     integer :: foo, bar = 1, quazz, zapp = 2
@@ -19,7 +19,7 @@ warning: ./resources/test/fixtures/typing/T051.f90:19:21: T051 'bar' is initiali
 20 |   end subroutine init_decl3
    |
 
-warning: ./resources/test/fixtures/typing/T051.f90:19:37: T051 'zapp' is initialised in its declaration and has no explicit `save` or `parameter` attribute
+./resources/test/fixtures/typing/T051.f90:19:37: T051 'zapp' is initialised in its declaration and has no explicit `save` or `parameter` attribute
    |
 18 |   subroutine init_decl3()
 19 |     integer :: foo, bar = 1, quazz, zapp = 2
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__interface-implicit-typing_T002.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__interface-implicit-typing_T002.f90.snap
index 64cf160..b8cbceb 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__interface-implicit-typing_T002.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__interface-implicit-typing_T002.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T002.f90:4:5: T002 interface 'function' missing 'implicit none'
+./resources/test/fixtures/typing/T002.f90:4:5: T002 interface 'function' missing 'implicit none'
   |
 2 |   implicit none
 3 |   interface
@@ -13,7 +13,7 @@ warning: ./resources/test/fixtures/typing/T002.f90:4:5: T002 interface 'function
 6 |     end function myfunc
   |
 
-warning: ./resources/test/fixtures/typing/T002.f90:13:5: T002 interface 'subroutine' missing 'implicit none'
+./resources/test/fixtures/typing/T002.f90:13:5: T002 interface 'subroutine' missing 'implicit none'
    |
 11 |   implicit none
 12 |   interface
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__literal-kind-suffix_T012.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__literal-kind-suffix_T012.f90.snap
index cc29696..7d2d64e 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__literal-kind-suffix_T012.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__literal-kind-suffix_T012.f90.snap
@@ -3,17 +3,17 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T012.f90:4:40: T012 '1.234567_4' has literal suffix '4', use 'iso_fortran_env' parameter
+./resources/test/fixtures/typing/T012.f90:4:40: T012 '1.234567_4' has literal suffix '4', use 'iso_fortran_env' parameter
   |
 2 |   use, intrinsic :: iso_fortran_env, only: sp => real32, dp => real64
-3 | 
+3 |
 4 |   real(sp), parameter :: x1 = 1.234567_4
   |                                        ^ T012
 5 |   real(dp), parameter :: x2 = 1.234567_dp
 6 |   real(dp), parameter :: x3 = 1.789d3
   |
 
-warning: ./resources/test/fixtures/typing/T012.f90:7:37: T012 '9.876_8' has literal suffix '8', use 'iso_fortran_env' parameter
+./resources/test/fixtures/typing/T012.f90:7:37: T012 '9.876_8' has literal suffix '8', use 'iso_fortran_env' parameter
   |
 5 |   real(dp), parameter :: x2 = 1.234567_dp
 6 |   real(dp), parameter :: x3 = 1.789d3
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__literal-kind_T011.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__literal-kind_T011.f90.snap
index 9d6044d..2eb2e2b 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__literal-kind_T011.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__literal-kind_T011.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T011.f90:1:9: T011 integer kind set with number literal '8', use 'iso_fortran_env' parameter
+./resources/test/fixtures/typing/T011.f90:1:9: T011 integer kind set with number literal '8', use 'iso_fortran_env' parameter
   |
 1 | integer(8) function add_if(x, y, z)
   |         ^ T011
@@ -11,7 +11,7 @@ warning: ./resources/test/fixtures/typing/T011.f90:1:9: T011 integer kind set wi
 3 |   integer(kind=2), intent(in) :: x
   |
 
-warning: ./resources/test/fixtures/typing/T011.f90:3:16: T011 integer kind set with number literal '2', use 'iso_fortran_env' parameter
+./resources/test/fixtures/typing/T011.f90:3:16: T011 integer kind set with number literal '2', use 'iso_fortran_env' parameter
   |
 1 | integer(8) function add_if(x, y, z)
 2 |   integer :: w
@@ -21,17 +21,17 @@ warning: ./resources/test/fixtures/typing/T011.f90:3:16: T011 integer kind set w
 5 |   logical(kind=4), intent(in) :: z
   |
 
-warning: ./resources/test/fixtures/typing/T011.f90:5:16: T011 logical kind set with number literal '4', use 'iso_fortran_env' parameter
+./resources/test/fixtures/typing/T011.f90:5:16: T011 logical kind set with number literal '4', use 'iso_fortran_env' parameter
   |
 3 |   integer(kind=2), intent(in) :: x
 4 |   integer(i32), intent(in) :: y
 5 |   logical(kind=4), intent(in) :: z
   |                ^ T011
-6 | 
+6 |
 7 |   if (x) then
   |
 
-warning: ./resources/test/fixtures/typing/T011.f90:15:8: T011 real kind set with number literal '8', use 'iso_fortran_env' parameter
+./resources/test/fixtures/typing/T011.f90:15:8: T011 real kind set with number literal '8', use 'iso_fortran_env' parameter
    |
 14 | subroutine complex_mul(x, y)
 15 |   real(8), intent(in) :: x
@@ -40,7 +40,7 @@ warning: ./resources/test/fixtures/typing/T011.f90:15:8: T011 real kind set with
 17 |   real :: z = 0.5
    |
 
-warning: ./resources/test/fixtures/typing/T011.f90:16:11: T011 complex kind set with number literal '4', use 'iso_fortran_env' parameter
+./resources/test/fixtures/typing/T011.f90:16:11: T011 complex kind set with number literal '4', use 'iso_fortran_env' parameter
    |
 14 | subroutine complex_mul(x, y)
 15 |   real(8), intent(in) :: x
@@ -50,7 +50,7 @@ warning: ./resources/test/fixtures/typing/T011.f90:16:11: T011 complex kind set
 18 |   y = y * x
    |
 
-warning: ./resources/test/fixtures/typing/T011.f90:23:16: T011 complex kind set with number literal '4', use 'iso_fortran_env' parameter
+./resources/test/fixtures/typing/T011.f90:23:16: T011 complex kind set with number literal '4', use 'iso_fortran_env' parameter
    |
 21 | complex(real64) function complex_add(x, y)
 22 |   real(real64), intent(in) :: x
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__missing-intent_T031.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__missing-intent_T031.f90.snap
index 08b234d..0af9226 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__missing-intent_T031.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__missing-intent_T031.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T031.f90:3:14: T031 function argument 'a' missing 'intent' attribute
+./resources/test/fixtures/typing/T031.f90:3:14: T031 function argument 'a' missing 'intent' attribute
   |
 1 | integer function foo(a, b, c)
 2 |   use mod
@@ -13,7 +13,7 @@ warning: ./resources/test/fixtures/typing/T031.f90:3:14: T031 function argument
 5 | end function foo
   |
 
-warning: ./resources/test/fixtures/typing/T031.f90:3:17: T031 function argument 'c' missing 'intent' attribute
+./resources/test/fixtures/typing/T031.f90:3:17: T031 function argument 'c' missing 'intent' attribute
   |
 1 | integer function foo(a, b, c)
 2 |   use mod
@@ -23,7 +23,7 @@ warning: ./resources/test/fixtures/typing/T031.f90:3:17: T031 function argument
 5 | end function foo
   |
 
-warning: ./resources/test/fixtures/typing/T031.f90:8:23: T031 subroutine argument 'd' missing 'intent' attribute
+./resources/test/fixtures/typing/T031.f90:8:23: T031 subroutine argument 'd' missing 'intent' attribute
    |
  7 | subroutine bar(d, e, f)
  8 |   integer, pointer :: d
@@ -32,7 +32,7 @@ warning: ./resources/test/fixtures/typing/T031.f90:8:23: T031 subroutine argumen
 10 |   type(integer(kind=int64)), intent(inout) :: f
    |
 
-warning: ./resources/test/fixtures/typing/T031.f90:9:27: T031 subroutine argument 'e' missing 'intent' attribute
+./resources/test/fixtures/typing/T031.f90:9:27: T031 subroutine argument 'e' missing 'intent' attribute
    |
  7 | subroutine bar(d, e, f)
  8 |   integer, pointer :: d
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__star-kind_T021.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__star-kind_T021.f90.snap
index 96f8efb..55aeff3 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__star-kind_T021.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__star-kind_T021.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T021.f90:1:8: T021 integer*8 is non-standard, use integer(8)
+./resources/test/fixtures/typing/T021.f90:1:8: T021 integer*8 is non-standard, use integer(8)
   |
 1 | integer*8 function add_if(x, y, z)
   |        ^^ T021
@@ -11,7 +11,7 @@ warning: ./resources/test/fixtures/typing/T021.f90:1:8: T021 integer*8 is non-st
 3 |   integer *4, intent(in) :: y
   |
 
-warning: ./resources/test/fixtures/typing/T021.f90:3:11: T021 integer*4 is non-standard, use integer(4)
+./resources/test/fixtures/typing/T021.f90:3:11: T021 integer*4 is non-standard, use integer(4)
   |
 1 | integer*8 function add_if(x, y, z)
 2 |   integer(kind=2), intent(in) :: x
@@ -21,7 +21,7 @@ warning: ./resources/test/fixtures/typing/T021.f90:3:11: T021 integer*4 is non-s
 5 |   real    * &
   |
 
-warning: ./resources/test/fixtures/typing/T021.f90:4:10: T021 logical*4 is non-standard, use logical(4)
+./resources/test/fixtures/typing/T021.f90:4:10: T021 logical*4 is non-standard, use logical(4)
   |
 2 |   integer(kind=2), intent(in) :: x
 3 |   integer *4, intent(in) :: y
@@ -31,7 +31,7 @@ warning: ./resources/test/fixtures/typing/T021.f90:4:10: T021 logical*4 is non-s
 6 |        8 :: t
   |
 
-warning: ./resources/test/fixtures/typing/T021.f90:5:11: T021 real*8 is non-standard, use real(8)
+./resources/test/fixtures/typing/T021.f90:5:11: T021 real*8 is non-standard, use real(8)
   |
 3 |     integer *4, intent(in) :: y
 4 |     logical*   4, intent(in) :: z
@@ -39,11 +39,11 @@ warning: ./resources/test/fixtures/typing/T021.f90:5:11: T021 real*8 is non-stan
   |  ___________^
 6 | |        8 :: t
   | |________^ T021
-7 |   
+7 |
 8 |     if (x == 2) then
   |
 
-warning: ./resources/test/fixtures/typing/T021.f90:16:8: T021 real*4 is non-standard, use real(4)
+./resources/test/fixtures/typing/T021.f90:16:8: T021 real*4 is non-standard, use real(4)
    |
 15 | subroutine complex_mul(x, real)
 16 |   real * 4, intent(in) :: x
@@ -52,7 +52,7 @@ warning: ./resources/test/fixtures/typing/T021.f90:16:8: T021 real*4 is non-stan
 18 |   ! This would be a false positive with purely regexp based linting
    |
 
-warning: ./resources/test/fixtures/typing/T021.f90:17:12: T021 complex*8 is non-standard, use complex(8)
+./resources/test/fixtures/typing/T021.f90:17:12: T021 complex*8 is non-standard, use complex(8)
    |
 15 | subroutine complex_mul(x, real)
 16 |   real * 4, intent(in) :: x
diff --git a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__superfluous-implicit-none_T003.f90.snap b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__superfluous-implicit-none_T003.f90.snap
index 789decd..f755385 100644
--- a/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__superfluous-implicit-none_T003.f90.snap
+++ b/fortitude/src/rules/typing/snapshots/fortitude__rules__typing__tests__superfluous-implicit-none_T003.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/typing/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-warning: ./resources/test/fixtures/typing/T003.f90:5:5: T003 'implicit none' set on the enclosing module
+./resources/test/fixtures/typing/T003.f90:5:5: T003 [*] 'implicit none' set on the enclosing module
   |
 3 | contains
 4 |   integer function myfunc(x)
@@ -14,7 +14,7 @@ warning: ./resources/test/fixtures/typing/T003.f90:5:5: T003 'implicit none' set
   |
   = help: Remove unnecessary 'implicit none'
 
-warning: ./resources/test/fixtures/typing/T003.f90:10:5: T003 'implicit none' set on the enclosing module
+./resources/test/fixtures/typing/T003.f90:10:5: T003 [*] 'implicit none' set on the enclosing module
    |
  8 |   end function myfunc
  9 |   subroutine mysub(x)
@@ -25,7 +25,7 @@ warning: ./resources/test/fixtures/typing/T003.f90:10:5: T003 'implicit none' se
    |
    = help: Remove unnecessary 'implicit none'
 
-warning: ./resources/test/fixtures/typing/T003.f90:23:5: T003 'implicit none' set on the enclosing program
+./resources/test/fixtures/typing/T003.f90:23:5: T003 [*] 'implicit none' set on the enclosing program
    |
 21 | contains
 22 |   integer function myfunc2(x)
@@ -36,7 +36,7 @@ warning: ./resources/test/fixtures/typing/T003.f90:23:5: T003 'implicit none' se
    |
    = help: Remove unnecessary 'implicit none'
 
-warning: ./resources/test/fixtures/typing/T003.f90:28:5: T003 'implicit none' set on the enclosing program
+./resources/test/fixtures/typing/T003.f90:28:5: T003 [*] 'implicit none' set on the enclosing program
    |
 26 |   end function myfunc2
 27 |   subroutine mysub2(x)
diff --git a/fortitude/src/test.rs b/fortitude/src/test.rs
index 40405bc..b1e6603 100644
--- a/fortitude/src/test.rs
+++ b/fortitude/src/test.rs
@@ -1,16 +1,16 @@
 use std::path::Path;
 
 use anyhow::Result;
-use itertools::join;
+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},
     rules::Rule,
     settings::Settings,
-    DiagnosticMessage,
 };
 
 pub(crate) fn test_resource_path(path: impl AsRef<Path>) -> std::path::PathBuf {
@@ -52,11 +52,22 @@ pub(crate) fn test_contents(
             if violations.is_empty() {
                 return String::new();
             }
-            let strings = violations
+            let diagnostics = violations
                 .into_iter()
                 .map(|v| DiagnosticMessage::from_ruff(file, v))
-                .map(|d| d.to_string());
-            join(strings, "\n")
+                .collect_vec();
+
+            let mut output = Vec::new();
+
+            TextEmitter::default()
+                .with_show_fix_status(true)
+                .with_show_fix_diff(true)
+                .with_show_source(true)
+                .with_unsafe_fixes(crate::settings::UnsafeFixes::Enabled)
+                .emit(&mut output, &diagnostics)
+                .unwrap();
+
+            String::from_utf8(output).unwrap()
         }
         Err(msg) => {
             panic!("Failed to process: {msg}");
diff --git a/fortitude/tests/check.rs b/fortitude/tests/check.rs
index f016cec..f017126 100644
--- a/fortitude/tests/check.rs
+++ b/fortitude/tests/check.rs
@@ -29,9 +29,9 @@ fn check_file_doesnt_exist() -> anyhow::Result<()> {
     success: false
     exit_code: 1
     ----- stdout -----
-    test/file/doesnt/exist.f90: E000 Error opening file: No such file or directory (os error 2)
+    test/file/doesnt/exist.f90:1:1: E000 Error opening file: No such file or directory (os error 2)
 
-    fortitude: 0 files scanned.
+    fortitude: 1 files scanned.
     Number of errors: 1
 
     For more information about specific rules, run:
@@ -70,7 +70,7 @@ unknown-key = 1
       |
     2 | unknown-key = 1
       | ^^^^^^^^^^^
-    unknown field `unknown-key`, expected one of `files`, `ignore`, `select`, `extend-select`, `line-length`, `file-extensions`
+    unknown field `unknown-key`, expected one of `files`, `ignore`, `select`, `extend-select`, `line-length`, `file-extensions`, `output-format`
     ");
     Ok(())
 }
@@ -96,7 +96,7 @@ end program
     success: false
     exit_code: 1
     ----- stdout -----
-    warning: [TEMP_FILE] T001 program missing 'implicit none'
+    [TEMP_FILE] T001 program missing 'implicit none'
       |
     2 | program test
       | ^^^^^^^^^^^^ T001
@@ -104,7 +104,7 @@ end program
     4 | end program
       |
 
-    warning: [TEMP_FILE] T021 logical*4 is non-standard, use logical(4)
+    [TEMP_FILE] T021 logical*4 is non-standard, use logical(4)
       |
     2 | program test
     3 |   logical*4, parameter :: true = .true.
@@ -112,7 +112,7 @@ end program
     4 | end program
       |
 
-    warning: [TEMP_FILE] T011 logical kind set with number literal '4', use 'iso_fortran_env' parameter
+    [TEMP_FILE] T011 logical kind set with number literal '4', use 'iso_fortran_env' parameter
       |
     2 | program test
     3 |   logical*4, parameter :: true = .true.
@@ -120,7 +120,7 @@ end program
     4 | end program
       |
 
-    warning: [TEMP_FILE] S061 end statement should read 'end program test'
+    [TEMP_FILE] S061 end statement should read 'end program test'
       |
     2 | program test
     3 |   logical*4, parameter :: true = .true.
@@ -164,7 +164,7 @@ end program
     success: false
     exit_code: 1
     ----- stdout -----
-    warning: [TEMP_FILE] T001 program missing 'implicit none'
+    [TEMP_FILE] T001 program missing 'implicit none'
       |
     2 | program test
       | ^^^^^^^^^^^^ T001
@@ -172,7 +172,7 @@ end program
     4 | end program
       |
 
-    warning: [TEMP_FILE] S061 end statement should read 'end program test'
+    [TEMP_FILE] S061 end statement should read 'end program test'
       |
     2 | program test
     3 |   logical*4, parameter :: true = .true.
@@ -225,7 +225,7 @@ select = ["T001", "style"]
     success: false
     exit_code: 1
     ----- stdout -----
-    warning: [TEMP_FILE] T001 program missing 'implicit none'
+    [TEMP_FILE] T001 program missing 'implicit none'
       |
     2 | program test
       | ^^^^^^^^^^^^ T001
@@ -233,7 +233,7 @@ select = ["T001", "style"]
     4 | end program
       |
 
-    warning: [TEMP_FILE] S061 end statement should read 'end program test'
+    [TEMP_FILE] S061 end statement should read 'end program test'
       |
     2 | program test
     3 |   logical*4, parameter :: true = .true.
@@ -288,7 +288,7 @@ select = ["T001"]
     success: false
     exit_code: 1
     ----- stdout -----
-    warning: [TEMP_FILE] T001 program missing 'implicit none'
+    [TEMP_FILE] T001 program missing 'implicit none'
       |
     2 | program test
       | ^^^^^^^^^^^^ T001
@@ -296,7 +296,7 @@ select = ["T001"]
     4 | end program
       |
 
-    warning: [TEMP_FILE] S061 end statement should read 'end program test'
+    [TEMP_FILE] S061 end statement should read 'end program test'
       |
     2 | program test
     3 |   logical*4, parameter :: true = .true.
@@ -349,7 +349,7 @@ select = ["T001", "style"]
     success: false
     exit_code: 1
     ----- stdout -----
-    warning: [TEMP_FILE] T001 program missing 'implicit none'
+    [TEMP_FILE] T001 program missing 'implicit none'
       |
     2 | program test
       | ^^^^^^^^^^^^ T001
@@ -357,7 +357,7 @@ select = ["T001", "style"]
     4 | end program
       |
 
-    warning: [TEMP_FILE] S061 end statement should read 'end program test'
+    [TEMP_FILE] S061 end statement should read 'end program test'
       |
     2 | program test
     3 |   logical*4, parameter :: true = .true.