Repository: PlasmaFAIR/fortitude (language: python)

Issue:
Output to SARIF format
The SARIF format (https://sarifweb.azurewebsites.net/) is a standard schema for JSON that is written and read by many different static analysis  tools (https://github.com/oasis-tcs/sarif-spec/wiki/Known-Producers-and-Consumers#known-producers-of-sarif). For instance, it is written by the GCC and Clang static analyzers, and GitHub CodeQL, and it can be read by IDE extensions (e.g. [VSCode](https://marketplace.visualstudio.com/items?itemName=MS-SarifVSCode.sarif-viewer)) and also git forges (e.g., [GitHub Security](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning), [Azure DevOps](https://docs.kics.io/latest/integrations_azurepipelines/), and there is a request for integration into [GitLab](https://gitlab.com/gitlab-org/gitlab/-/issues/452042)). My use-case would primarily be the git forge integration, so that I can eventually include fortitude in a pipeline and have it scan PRs for issues.

It appears there is a Rust library that wrap the structure already - [serde-sarif](https://docs.rs/serde-sarif/latest/serde_sarif/index.html), so there shouldn't be a need to actually write the JSON formatter for this if we can include this dependency.


Discussion / hints:
This is actually already on our to-do list! :smile: Because we've built a bunch of fortitude on top of ruff, we're able to mostly reuse their output formatters too. We should be able to mostly copy-paste their [SARIF formatter](https://github.com/astral-sh/ruff/blob/main/crates/ruff_linter/src/message/sarif.rs), as well as their GitHub and Gitlab ones too. It looks like the serde-sarif crate might be even more straightforward too.

PRs definitely welcome if you're interested in contributing!

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index f0145d0..d25d6d8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -103,6 +103,15 @@ version = "2.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
 
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
 [[package]]
 name = "bstr"
 version = "1.10.0"
@@ -135,6 +144,15 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
+[[package]]
+name = "chrono"
+version = "0.4.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "clap"
 version = "4.5.20"
@@ -224,6 +242,15 @@ dependencies = [
  "unicode-xid",
 ]
 
+[[package]]
+name = "cpufeatures"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "crossbeam-deque"
 version = "0.8.5"
@@ -249,6 +276,16 @@ version = "0.8.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
 
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
 [[package]]
 name = "deranged"
 version = "0.3.11"
@@ -270,6 +307,27 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
 
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "doc-comment"
 version = "0.3.3"
@@ -331,6 +389,15 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
 [[package]]
 name = "fortitude"
 version = "0.5.1"
@@ -353,6 +420,7 @@ dependencies = [
  "pathdiff",
  "predicates",
  "pretty_assertions",
+ "quick-junit",
  "rayon",
  "ruff_cache",
  "ruff_diagnostics",
@@ -362,16 +430,18 @@ dependencies = [
  "serde",
  "serde_json",
  "shadow-rs",
+ "similar",
  "similar-asserts",
  "strum",
  "strum_macros",
  "tempfile",
  "test-case",
  "textwrap",
- "thiserror",
+ "thiserror 1.0.66",
  "toml",
  "tree-sitter",
  "tree-sitter-fortran",
+ "url",
  "walkdir",
 ]
 
@@ -385,6 +455,16 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
 [[package]]
 name = "glob"
 version = "0.3.1"
@@ -416,6 +496,145 @@ version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "icu_collections"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid_transform"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
+dependencies = [
+ "displaydoc",
+ "icu_locid",
+ "icu_locid_transform_data",
+ "icu_provider",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locid_transform_data"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
+
+[[package]]
+name = "icu_normalizer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "utf16_iter",
+ "utf8_iter",
+ "write16",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
+
+[[package]]
+name = "icu_properties"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locid_transform",
+ "icu_properties_data",
+ "icu_provider",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
+
+[[package]]
+name = "icu_provider"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
+dependencies = [
+ "displaydoc",
+ "icu_locid",
+ "icu_provider_macros",
+ "stable_deref_trait",
+ "tinystr",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_provider_macros"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "idna"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
 [[package]]
 name = "indexmap"
 version = "2.6.0"
@@ -450,6 +669,8 @@ dependencies = [
  "console",
  "lazy_static",
  "linked-hash-map",
+ "pest",
+ "pest_derive",
  "regex",
  "serde",
  "similar",
@@ -581,6 +802,12 @@ version = "0.4.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
 
+[[package]]
+name = "litemap"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
+
 [[package]]
 name = "log"
 version = "0.4.22"
@@ -593,6 +820,15 @@ version = "2.7.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
 
+[[package]]
+name = "newtype-uuid"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c8781e2ef64806278a55ad223f0bc875772fd40e1fe6e73e8adbf027817229d"
+dependencies = [
+ "uuid",
+]
+
 [[package]]
 name = "normalize-line-endings"
 version = "0.3.0"
@@ -659,6 +895,57 @@ version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361"
 
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "pest"
+version = "2.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879952a81a83930934cbf1786752d6dedc3b1f29e8f8fb2ad1d0a36f377cf442"
+dependencies = [
+ "memchr",
+ "thiserror 1.0.66",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d214365f632b123a47fd913301e14c946c61d1c183ee245fa76eb752e59a02dd"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb55586734301717aea2ac313f50b2eb8f60d2fc3dc01d190eefa2e625f60c4e"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75da2a70cf4d9cb76833c990ac9cd3923c9a8905a8929789ce347c84564d03d"
+dependencies = [
+ "once_cell",
+ "pest",
+ "sha2",
+]
+
 [[package]]
 name = "portable-atomic"
 version = "1.9.0"
@@ -720,6 +1007,30 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "quick-junit"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ed1a693391a16317257103ad06a88c6529ac640846021da7c435a06fffdacd7"
+dependencies = [
+ "chrono",
+ "indexmap",
+ "newtype-uuid",
+ "quick-xml",
+ "strip-ansi-escapes",
+ "thiserror 2.0.3",
+ "uuid",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f22f29bdff3987b4d8632ef95fd6424ec7e4e0a57e2f4fc63e489e75357f6a03"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "quote"
 version = "1.0.37"
@@ -934,6 +1245,17 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
 [[package]]
 name = "shadow-rs"
 version = "0.36.0"
@@ -971,18 +1293,39 @@ dependencies = [
  "similar",
 ]
 
+[[package]]
+name = "smallvec"
+version = "1.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
+
 [[package]]
 name = "smawk"
 version = "0.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
 
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
 [[package]]
 name = "streaming-iterator"
 version = "0.1.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
 
+[[package]]
+name = "strip-ansi-escapes"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55ff8ef943b384c414f54aefa961dd2bd853add74ec75e7ac74cf91dba62bcfa"
+dependencies = [
+ "vte",
+]
+
 [[package]]
 name = "strsim"
 version = "0.11.1"
@@ -1013,15 +1356,26 @@ dependencies = [
 
 [[package]]
 name = "syn"
-version = "2.0.86"
+version = "2.0.87"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e89275301d38033efb81a6e60e3497e734dfcc62571f2854bf4b16690398824c"
+checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d"
 dependencies = [
  "proc-macro2",
  "quote",
  "unicode-ident",
 ]
 
+[[package]]
+name = "synstructure"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "tempfile"
 version = "3.13.0"
@@ -1091,7 +1445,16 @@ version = "1.0.66"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5d171f59dbaa811dbbb1aee1e73db92ec2b122911a48e1390dfe327a821ddede"
 dependencies = [
- "thiserror-impl",
+ "thiserror-impl 1.0.66",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa"
+dependencies = [
+ "thiserror-impl 2.0.3",
 ]
 
 [[package]]
@@ -1105,6 +1468,17 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "thiserror-impl"
+version = "2.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "time"
 version = "0.3.36"
@@ -1138,6 +1512,16 @@ dependencies = [
  "time-core",
 ]
 
+[[package]]
+name = "tinystr"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
 [[package]]
 name = "toml"
 version = "0.8.19"
@@ -1201,6 +1585,18 @@ version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e8ddffe35a0e5eeeadf13ff7350af564c6e73993a24db62caee1822b185c2600"
 
+[[package]]
+name = "typenum"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.13"
@@ -1237,12 +1633,67 @@ version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
 
+[[package]]
+name = "url"
+version = "2.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf16_iter"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
 
+[[package]]
+name = "uuid"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vte"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197"
+dependencies = [
+ "utf8parse",
+ "vte_generate_state_changes",
+]
+
+[[package]]
+name = "vte_generate_state_changes"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
 [[package]]
 name = "wait-timeout"
 version = "0.2.0"
@@ -1493,8 +1944,87 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "write16"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
+
+[[package]]
+name = "writeable"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
+
 [[package]]
 name = "yansi"
 version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+[[package]]
+name = "yoke"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
diff --git a/fortitude/Cargo.toml b/fortitude/Cargo.toml
index 7f3efda..ab97801 100644
--- a/fortitude/Cargo.toml
+++ b/fortitude/Cargo.toml
@@ -32,6 +32,7 @@ path-absolutize = { version = "3.1.1", features = [
     "use_unix_paths_on_wasm",
 ]}
 pathdiff = { version = "0.2.1" }
+quick-junit = { version = "0.5.0" }
 rayon = "1.10.0"
 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", features = ["serde"] }
@@ -40,6 +41,7 @@ ruff_source_file = { git = "https://github.com/astral-sh/ruff.git", tag = "0.7.1
 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 = { version = "2.4.0", features = ["inline"] }
 similar-asserts = "1.6.0"
 shadow-rs = { version = "0.36.0", default-features = false }
 strum = { workspace = true }
@@ -49,6 +51,7 @@ thiserror = { version = "1.0.58" }
 toml = "0.8.19"
 tree-sitter = "~0.24.0"
 tree-sitter-fortran = "0.3.0"
+url = { version = "2.5.0" }
 walkdir = "2.4.0"
 
 [build-dependencies]
@@ -58,7 +61,7 @@ shadow-rs = { version = "0.36.0", default-features = false }
 assert_cmd = "2.0.16"
 # Disable colored output in tests
 colored = { workspace = true, features = ["no-color"] }
-insta = { version = "1.41.1", features = ["filters"] }
+insta = { version = "1.41.1", features = ["filters", "json", "redactions"] }
 insta-cmd = "0.6.0"
 predicates = "3.1.2"
 pretty_assertions = "1.4.1"
diff --git a/fortitude/src/message/azure.rs b/fortitude/src/message/azure.rs
new file mode 100644
index 0000000..0f32c4c
--- /dev/null
+++ b/fortitude/src/message/azure.rs
@@ -0,0 +1,57 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::io::Write;
+
+use crate::message::Emitter;
+
+use super::DiagnosticMessage;
+
+/// Generate error logging commands for Azure Pipelines format.
+/// See [documentation](https://learn.microsoft.com/en-us/azure/devops/pipelines/scripts/logging-commands?view=azure-devops&tabs=bash#logissue-log-an-error-or-warning)
+#[derive(Default)]
+pub struct AzureEmitter;
+
+impl Emitter for AzureEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        for message in messages {
+            let location = message.compute_start_location();
+
+            writeln!(
+                writer,
+                "##vso[task.logissue type=error\
+                        ;sourcepath={filename};linenumber={line};columnnumber={col};{code}]{body}",
+                filename = message.filename(),
+                line = location.row,
+                col = location.column,
+                code = message
+                    .rule()
+                    .map_or_else(String::new, |rule| format!("code={};", rule.noqa_code())),
+                body = message.body(),
+            )?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::AzureEmitter;
+
+    #[test]
+    fn output() {
+        let mut emitter = AzureEmitter;
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+}
diff --git a/fortitude/src/message/diff.rs b/fortitude/src/message/diff.rs
new file mode 100644
index 0000000..776e47b
--- /dev/null
+++ b/fortitude/src/message/diff.rs
@@ -0,0 +1,207 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::fmt::{Display, Formatter};
+use std::num::NonZeroUsize;
+
+use colored::{Color, ColoredString, Colorize, Styles};
+
+use ruff_text_size::{Ranged, TextRange, TextSize};
+use similar::{ChangeTag, TextDiff};
+
+use ruff_diagnostics::{Applicability, Fix};
+use ruff_source_file::{OneIndexed, SourceFile};
+
+use crate::message::DiagnosticMessage;
+use crate::text_helpers::ShowNonprinting;
+
+/// Renders a diff that shows the code fixes.
+///
+/// The implementation isn't fully fledged out and only used by tests. Before using in production, try
+/// * Improve layout
+/// * Replace tabs with spaces for a consistent experience across terminals
+/// * Replace zero-width whitespaces
+/// * Print a simpler diff if only a single line has changed
+/// * Compute the diff from the [`Edit`] because diff calculation is expensive.
+pub(super) struct Diff<'a> {
+    fix: &'a Fix,
+    source_code: &'a SourceFile,
+}
+
+impl<'a> Diff<'a> {
+    pub(crate) fn from_message(message: &'a DiagnosticMessage) -> Option<Diff<'a>> {
+        message.fix().map(|fix| Diff {
+            source_code: message.source_file(),
+            fix,
+        })
+    }
+}
+
+impl Display for Diff<'_> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        // TODO(dhruvmanila): Add support for Notebook cells once it's user-facing
+        let mut output = String::with_capacity(self.source_code.source_text().len());
+        let mut last_end = TextSize::default();
+
+        for edit in self.fix.edits() {
+            output.push_str(
+                self.source_code
+                    .slice(TextRange::new(last_end, edit.start())),
+            );
+            output.push_str(edit.content().unwrap_or_default());
+            last_end = edit.end();
+        }
+
+        output.push_str(&self.source_code.source_text()[usize::from(last_end)..]);
+
+        let diff = TextDiff::from_lines(self.source_code.source_text(), &output);
+
+        let message = match self.fix.applicability() {
+            // TODO(zanieb): Adjust this messaging once it's user-facing
+            Applicability::Safe => "Safe fix",
+            Applicability::Unsafe => "Unsafe fix",
+            Applicability::DisplayOnly => "Display-only fix",
+        };
+        writeln!(f, "ℹ {}", message.blue())?;
+
+        let (largest_old, largest_new) = diff
+            .ops()
+            .last()
+            .map(|op| (op.old_range().start, op.new_range().start))
+            .unwrap_or_default();
+
+        let digit_with =
+            calculate_print_width(OneIndexed::from_zero_indexed(largest_new.max(largest_old)));
+
+        for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
+            if idx > 0 {
+                writeln!(f, "{:-^1$}", "-", 80)?;
+            }
+            for op in group {
+                for change in diff.iter_inline_changes(op) {
+                    let sign = match change.tag() {
+                        ChangeTag::Delete => "-",
+                        ChangeTag::Insert => "+",
+                        ChangeTag::Equal => " ",
+                    };
+
+                    let line_style = LineStyle::from(change.tag());
+
+                    let old_index = change.old_index().map(OneIndexed::from_zero_indexed);
+                    let new_index = change.new_index().map(OneIndexed::from_zero_indexed);
+
+                    write!(
+                        f,
+                        "{} {} |{}",
+                        Line {
+                            index: old_index,
+                            width: digit_with
+                        },
+                        Line {
+                            index: new_index,
+                            width: digit_with
+                        },
+                        line_style.apply_to(sign).bold()
+                    )?;
+
+                    for (emphasized, value) in change.iter_strings_lossy() {
+                        let value = value.show_nonprinting();
+                        if emphasized {
+                            write!(f, "{}", line_style.apply_to(&value).underline().on_black())?;
+                        } else {
+                            write!(f, "{}", line_style.apply_to(&value))?;
+                        }
+                    }
+                    if change.missing_newline() {
+                        writeln!(f)?;
+                    }
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
+struct LineStyle {
+    fgcolor: Option<Color>,
+    style: Option<Styles>,
+}
+
+impl LineStyle {
+    fn apply_to(&self, input: &str) -> ColoredString {
+        let mut colored = ColoredString::from(input);
+        if let Some(color) = self.fgcolor {
+            colored = colored.color(color);
+        }
+
+        if let Some(style) = self.style {
+            match style {
+                Styles::Clear => colored.clear(),
+                Styles::Bold => colored.bold(),
+                Styles::Dimmed => colored.dimmed(),
+                Styles::Underline => colored.underline(),
+                Styles::Reversed => colored.reversed(),
+                Styles::Italic => colored.italic(),
+                Styles::Blink => colored.blink(),
+                Styles::Hidden => colored.hidden(),
+                Styles::Strikethrough => colored.strikethrough(),
+            }
+        } else {
+            colored
+        }
+    }
+}
+
+impl From<ChangeTag> for LineStyle {
+    fn from(value: ChangeTag) -> Self {
+        match value {
+            ChangeTag::Equal => LineStyle {
+                fgcolor: None,
+                style: Some(Styles::Dimmed),
+            },
+            ChangeTag::Delete => LineStyle {
+                fgcolor: Some(Color::Red),
+                style: None,
+            },
+            ChangeTag::Insert => LineStyle {
+                fgcolor: Some(Color::Green),
+                style: None,
+            },
+        }
+    }
+}
+
+struct Line {
+    index: Option<OneIndexed>,
+    width: NonZeroUsize,
+}
+
+impl Display for Line {
+    fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
+        match self.index {
+            None => {
+                for _ in 0..self.width.get() {
+                    f.write_str(" ")?;
+                }
+                Ok(())
+            }
+            Some(idx) => write!(f, "{:<width$}", idx, width = self.width.get()),
+        }
+    }
+}
+
+/// Calculate the length of the string representation of `value`
+pub(super) fn calculate_print_width(mut value: OneIndexed) -> NonZeroUsize {
+    const TEN: OneIndexed = OneIndexed::from_zero_indexed(9);
+
+    let mut width = OneIndexed::ONE;
+
+    while value >= TEN {
+        value = OneIndexed::new(value.get() / 10).unwrap_or(OneIndexed::MIN);
+        width = width.checked_add(1).unwrap();
+    }
+
+    width
+}
diff --git a/fortitude/src/message/github.rs b/fortitude/src/message/github.rs
new file mode 100644
index 0000000..4d224ff
--- /dev/null
+++ b/fortitude/src/message/github.rs
@@ -0,0 +1,73 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::io::Write;
+
+use crate::fs::relativize_path;
+use crate::message::Emitter;
+
+use super::DiagnosticMessage;
+
+/// Generate error workflow command in GitHub Actions format.
+/// See: [GitHub documentation](https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message)
+#[derive(Default)]
+pub struct GithubEmitter;
+
+impl Emitter for GithubEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        for message in messages {
+            let source_location = message.compute_start_location();
+            let location = source_location.clone();
+
+            let end_location = message.compute_end_location();
+
+            write!(
+                writer,
+                "::error title=Fortitude{code},file={file},line={row},col={column},endLine={end_row},endColumn={end_column}::",
+                code = message.rule().map_or_else(String::new, |rule| format!(" ({})", rule.noqa_code())),
+                file = message.filename(),
+                row = source_location.row,
+                column = source_location.column,
+                end_row = end_location.row,
+                end_column = end_location.column,
+            )?;
+
+            write!(
+                writer,
+                "{path}:{row}:{column}:",
+                path = relativize_path(message.filename()),
+                row = location.row,
+                column = location.column,
+            )?;
+
+            if let Some(rule) = message.rule() {
+                write!(writer, " {}", rule.noqa_code())?;
+            }
+
+            writeln!(writer, " {}", message.body())?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::GithubEmitter;
+
+    #[test]
+    fn output() {
+        let mut emitter = GithubEmitter;
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+}
diff --git a/fortitude/src/message/gitlab.rs b/fortitude/src/message/gitlab.rs
new file mode 100644
index 0000000..f324dfd
--- /dev/null
+++ b/fortitude/src/message/gitlab.rs
@@ -0,0 +1,157 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::collections::hash_map::DefaultHasher;
+use std::collections::HashSet;
+use std::hash::{Hash, Hasher};
+use std::io::Write;
+
+use serde::ser::SerializeSeq;
+use serde::{Serialize, Serializer};
+use serde_json::json;
+
+use crate::fs::{relativize_path, relativize_path_to};
+use crate::message::Emitter;
+
+use super::DiagnosticMessage;
+
+/// Generate JSON with violations in GitLab CI format
+//  https://docs.gitlab.com/ee/ci/testing/code_quality.html#implement-a-custom-tool
+pub struct GitlabEmitter {
+    project_dir: Option<String>,
+}
+
+impl Default for GitlabEmitter {
+    fn default() -> Self {
+        Self {
+            project_dir: std::env::var("CI_PROJECT_DIR").ok(),
+        }
+    }
+}
+
+impl Emitter for GitlabEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        serde_json::to_writer_pretty(
+            writer,
+            &SerializedMessages {
+                messages,
+                project_dir: self.project_dir.as_deref(),
+            },
+        )?;
+
+        Ok(())
+    }
+}
+
+struct SerializedMessages<'a> {
+    messages: &'a [DiagnosticMessage],
+    project_dir: Option<&'a str>,
+}
+
+impl Serialize for SerializedMessages<'_> {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        let mut s = serializer.serialize_seq(Some(self.messages.len()))?;
+        let mut fingerprints = HashSet::<u64>::with_capacity(self.messages.len());
+
+        for message in self.messages {
+            let start_location = message.compute_start_location();
+            let end_location = message.compute_end_location();
+
+            let lines = json!({
+                "begin": start_location.row,
+                "end": end_location.row
+            });
+
+            let path = self.project_dir.as_ref().map_or_else(
+                || relativize_path(message.filename()),
+                |project_dir| relativize_path_to(message.filename(), project_dir),
+            );
+
+            let mut message_fingerprint = fingerprint(message, &path, 0);
+
+            // Make sure that we do not get a fingerprint that is already in use
+            // by adding in the previously generated one.
+            while fingerprints.contains(&message_fingerprint) {
+                message_fingerprint = fingerprint(message, &path, message_fingerprint);
+            }
+            fingerprints.insert(message_fingerprint);
+
+            let description = if let Some(rule) = message.rule() {
+                format!("({}) {}", rule.noqa_code(), message.body())
+            } else {
+                message.body().to_string()
+            };
+
+            let value = json!({
+                "description": description,
+                "severity": "major",
+                "fingerprint": format!("{:x}", message_fingerprint),
+                "location": {
+                    "path": path,
+                    "lines": lines
+                }
+            });
+
+            s.serialize_element(&value)?;
+        }
+
+        s.end()
+    }
+}
+
+/// Generate a unique fingerprint to identify a violation.
+fn fingerprint(message: &DiagnosticMessage, project_path: &str, salt: u64) -> u64 {
+    let mut hasher = DefaultHasher::new();
+
+    salt.hash(&mut hasher);
+    message.name().hash(&mut hasher);
+    project_path.hash(&mut hasher);
+
+    hasher.finish()
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::GitlabEmitter;
+
+    #[test]
+    fn output() {
+        let mut emitter = GitlabEmitter::default();
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(redact_fingerprint(&content));
+    }
+
+    // Redact the fingerprint because the default hasher isn't stable across platforms.
+    fn redact_fingerprint(content: &str) -> String {
+        static FINGERPRINT_HAY_KEY: &str = r#""fingerprint": ""#;
+
+        let mut output = String::with_capacity(content.len());
+        let mut last = 0;
+
+        for (start, _) in content.match_indices(FINGERPRINT_HAY_KEY) {
+            let fingerprint_hash_start = start + FINGERPRINT_HAY_KEY.len();
+            output.push_str(&content[last..fingerprint_hash_start]);
+            output.push_str("<redacted>");
+            last = fingerprint_hash_start
+                + content[fingerprint_hash_start..]
+                    .find('"')
+                    .expect("Expected terminating quote");
+        }
+
+        output.push_str(&content[last..]);
+
+        output
+    }
+}
diff --git a/fortitude/src/message/grouped.rs b/fortitude/src/message/grouped.rs
new file mode 100644
index 0000000..23e9baa
--- /dev/null
+++ b/fortitude/src/message/grouped.rs
@@ -0,0 +1,226 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::fmt::{Display, Formatter};
+use std::io::Write;
+use std::num::NonZeroUsize;
+
+use colored::Colorize;
+
+use ruff_source_file::OneIndexed;
+
+use crate::fs::relativize_path;
+use crate::message::diff::calculate_print_width;
+use crate::message::text::{MessageCodeFrame, RuleCodeAndBody};
+use crate::message::{group_messages_by_filename, Emitter, MessageWithLocation};
+use crate::settings::UnsafeFixes;
+
+use super::DiagnosticMessage;
+
+#[derive(Default)]
+pub struct GroupedEmitter {
+    show_fix_status: bool,
+    show_source: bool,
+    unsafe_fixes: UnsafeFixes,
+}
+
+impl GroupedEmitter {
+    #[must_use]
+    pub fn with_show_fix_status(mut self, show_fix_status: bool) -> Self {
+        self.show_fix_status = show_fix_status;
+        self
+    }
+
+    // Might only be used in tests?
+    #[must_use]
+    #[allow(dead_code)]
+    pub fn with_show_source(mut self, show_source: bool) -> Self {
+        self.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 GroupedEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        for (filename, messages) in group_messages_by_filename(messages) {
+            // Compute the maximum number of digits in the row and column, for messages in
+            // this file.
+
+            let mut max_row_length = OneIndexed::MIN;
+            let mut max_column_length = OneIndexed::MIN;
+
+            for message in &messages {
+                max_row_length = max_row_length.max(message.start_location.row);
+                max_column_length = max_column_length.max(message.start_location.column);
+            }
+
+            let row_length = calculate_print_width(max_row_length);
+            let column_length = calculate_print_width(max_column_length);
+
+            // Print the filename.
+            writeln!(writer, "{}:", relativize_path(filename).underline())?;
+
+            // Print each message.
+            for message in messages {
+                write!(
+                    writer,
+                    "{}",
+                    DisplayGroupedMessage {
+                        message,
+                        show_fix_status: self.show_fix_status,
+                        unsafe_fixes: self.unsafe_fixes,
+                        show_source: self.show_source,
+                        row_length,
+                        column_length,
+                    }
+                )?;
+            }
+
+            // Print a blank line between files, unless we're showing the source, in which case
+            // we'll have already printed a blank line between messages.
+            if !self.show_source {
+                writeln!(writer)?;
+            }
+        }
+
+        Ok(())
+    }
+}
+
+struct DisplayGroupedMessage<'a> {
+    message: MessageWithLocation<'a>,
+    show_fix_status: bool,
+    unsafe_fixes: UnsafeFixes,
+    show_source: bool,
+    row_length: NonZeroUsize,
+    column_length: NonZeroUsize,
+}
+
+impl Display for DisplayGroupedMessage<'_> {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        let MessageWithLocation {
+            message,
+            start_location,
+        } = &self.message;
+
+        write!(
+            f,
+            "  {row_padding}",
+            row_padding =
+                " ".repeat(self.row_length.get() - calculate_print_width(start_location.row).get())
+        )?;
+
+        // Check if we're working on a jupyter notebook and translate positions with cell accordingly
+        let (row, col) = (start_location.row, start_location.column);
+
+        writeln!(
+            f,
+            "{row}{sep}{col}{col_padding} {code_and_body}",
+            sep = ":".cyan(),
+            col_padding = " ".repeat(
+                self.column_length.get() - calculate_print_width(start_location.column).get()
+            ),
+            code_and_body = RuleCodeAndBody {
+                message,
+                show_fix_status: self.show_fix_status,
+                unsafe_fixes: self.unsafe_fixes
+            },
+        )?;
+
+        if self.show_source {
+            use std::fmt::Write;
+            let mut padded = PadAdapter::new(f);
+            writeln!(padded, "{}", MessageCodeFrame { message })?;
+        }
+
+        Ok(())
+    }
+}
+
+/// Adapter that adds a '  ' at the start of every line without the need to copy the string.
+/// Inspired by Rust's `debug_struct()` internal implementation that also uses a `PadAdapter`.
+struct PadAdapter<'buf> {
+    buf: &'buf mut (dyn std::fmt::Write + 'buf),
+    on_newline: bool,
+}
+
+impl<'buf> PadAdapter<'buf> {
+    fn new(buf: &'buf mut (dyn std::fmt::Write + 'buf)) -> Self {
+        Self {
+            buf,
+            on_newline: true,
+        }
+    }
+}
+
+impl std::fmt::Write for PadAdapter<'_> {
+    fn write_str(&mut self, s: &str) -> std::fmt::Result {
+        for s in s.split_inclusive('\n') {
+            if self.on_newline {
+                self.buf.write_str("  ")?;
+            }
+
+            self.on_newline = s.ends_with('\n');
+            self.buf.write_str(s)?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::GroupedEmitter;
+    use crate::settings::UnsafeFixes;
+
+    #[test]
+    fn default() {
+        let mut emitter = GroupedEmitter::default();
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+
+    #[test]
+    fn show_source() {
+        let mut emitter = GroupedEmitter::default().with_show_source(true);
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+
+    #[test]
+    fn fix_status() {
+        let mut emitter = GroupedEmitter::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 = GroupedEmitter::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/message/json_lines.rs b/fortitude/src/message/json_lines.rs
new file mode 100644
index 0000000..01b15d1
--- /dev/null
+++ b/fortitude/src/message/json_lines.rs
@@ -0,0 +1,43 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::io::Write;
+
+use crate::message::json::message_to_json_value;
+use crate::message::Emitter;
+
+use super::DiagnosticMessage;
+
+#[derive(Default)]
+pub struct JsonLinesEmitter;
+
+impl Emitter for JsonLinesEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        for message in messages {
+            serde_json::to_writer(&mut *writer, &message_to_json_value(message))?;
+            writer.write_all(b"\n")?;
+        }
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::json_lines::JsonLinesEmitter;
+    use crate::message::tests::{capture_emitter_output, create_messages};
+
+    #[test]
+    fn output() {
+        let mut emitter = JsonLinesEmitter;
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+}
diff --git a/fortitude/src/message/junit.rs b/fortitude/src/message/junit.rs
new file mode 100644
index 0000000..2aa9ea4
--- /dev/null
+++ b/fortitude/src/message/junit.rs
@@ -0,0 +1,105 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::io::Write;
+use std::path::Path;
+
+use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite, XmlString};
+
+use crate::message::{group_messages_by_filename, Emitter, MessageWithLocation};
+
+use super::DiagnosticMessage;
+
+#[derive(Default)]
+pub struct JunitEmitter;
+
+impl Emitter for JunitEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        let mut report = Report::new("fortitude");
+
+        if messages.is_empty() {
+            let mut test_suite = TestSuite::new("fortitude");
+            test_suite.extra.insert(
+                XmlString::new("package"),
+                XmlString::new("org.plasmafair.fortitude"),
+            );
+            let mut case = TestCase::new("No errors found", TestCaseStatus::success());
+            case.set_classname("fortitude");
+            test_suite.add_test_case(case);
+            report.add_test_suite(test_suite);
+        } else {
+            for (filename, messages) in group_messages_by_filename(messages) {
+                let mut test_suite = TestSuite::new(filename);
+                test_suite.extra.insert(
+                    XmlString::new("package"),
+                    XmlString::new("org.plasmafair.fortitude"),
+                );
+
+                for message in messages {
+                    let MessageWithLocation {
+                        message,
+                        start_location,
+                    } = message;
+                    let mut status = TestCaseStatus::non_success(NonSuccessKind::Failure);
+                    status.set_message(message.body());
+                    let location = start_location;
+
+                    status.set_description(format!(
+                        "line {row}, col {col}, {body}",
+                        row = location.row,
+                        col = location.column,
+                        body = message.body()
+                    ));
+                    let mut case = TestCase::new(
+                        if let Some(rule) = message.rule() {
+                            format!("org.plasmafair.fortitude.{}", rule.noqa_code())
+                        } else {
+                            "org.plasmafair.fortitude".to_string()
+                        },
+                        status,
+                    );
+                    let file_path = Path::new(filename);
+                    let file_stem = file_path.file_stem().unwrap().to_str().unwrap();
+                    let classname = file_path.parent().unwrap().join(file_stem);
+                    case.set_classname(classname.to_str().unwrap());
+                    case.extra.insert(
+                        XmlString::new("line"),
+                        XmlString::new(location.row.to_string()),
+                    );
+                    case.extra.insert(
+                        XmlString::new("column"),
+                        XmlString::new(location.column.to_string()),
+                    );
+
+                    test_suite.add_test_case(case);
+                }
+                report.add_test_suite(test_suite);
+            }
+        }
+
+        report.serialize(writer)?;
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::JunitEmitter;
+
+    #[test]
+    fn output() {
+        let mut emitter = JunitEmitter;
+        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
index aa46498..ad6d228 100644
--- a/fortitude/src/message/mod.rs
+++ b/fortitude/src/message/mod.rs
@@ -1,11 +1,31 @@
+pub use azure::AzureEmitter;
+pub use github::GithubEmitter;
+pub use gitlab::GitlabEmitter;
+pub use grouped::GroupedEmitter;
 pub use json::JsonEmitter;
+pub use json_lines::JsonLinesEmitter;
+pub use junit::JunitEmitter;
+pub use pylint::PylintEmitter;
+pub use rdjson::RdjsonEmitter;
+pub use sarif::SarifEmitter;
 pub use text::TextEmitter;
 
+mod azure;
+mod diff;
+mod github;
+mod gitlab;
+mod grouped;
 mod json;
+mod json_lines;
+mod junit;
+mod pylint;
+mod rdjson;
+mod sarif;
 mod text;
 
-use std::cmp::Ordering;
+use std::collections::BTreeMap;
 use std::io::Write;
+use std::{cmp::Ordering, ops::Deref};
 
 use crate::{registry::AsRule, rules::Rule};
 use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix};
@@ -135,6 +155,35 @@ pub trait Emitter {
     ) -> anyhow::Result<()>;
 }
 
+struct MessageWithLocation<'a> {
+    message: &'a DiagnosticMessage,
+    start_location: SourceLocation,
+}
+
+impl Deref for MessageWithLocation<'_> {
+    type Target = DiagnosticMessage;
+
+    fn deref(&self) -> &Self::Target {
+        self.message
+    }
+}
+
+fn group_messages_by_filename(
+    messages: &[DiagnosticMessage],
+) -> BTreeMap<&str, Vec<MessageWithLocation>> {
+    let mut grouped_messages = BTreeMap::default();
+    for message in messages {
+        grouped_messages
+            .entry(message.filename())
+            .or_insert_with(Vec::new)
+            .push(MessageWithLocation {
+                message,
+                start_location: message.compute_start_location(),
+            });
+    }
+    grouped_messages
+}
+
 #[cfg(test)]
 mod tests {
     use ruff_diagnostics::{Diagnostic, DiagnosticKind, Edit, Fix};
diff --git a/fortitude/src/message/pylint.rs b/fortitude/src/message/pylint.rs
new file mode 100644
index 0000000..e267d12
--- /dev/null
+++ b/fortitude/src/message/pylint.rs
@@ -0,0 +1,61 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::io::Write;
+
+use crate::fs::relativize_path;
+use crate::message::Emitter;
+
+use super::DiagnosticMessage;
+
+/// Generate violations in Pylint format.
+/// See: [Flake8 documentation](https://flake8.pycqa.org/en/latest/internal/formatters.html#pylint-formatter)
+#[derive(Default)]
+pub struct PylintEmitter;
+
+impl Emitter for PylintEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        for message in messages {
+            let row = message.compute_start_location().row;
+
+            let body = if let Some(rule) = message.rule() {
+                format!(
+                    "[{code}] {body}",
+                    code = rule.noqa_code(),
+                    body = message.body()
+                )
+            } else {
+                message.body().to_string()
+            };
+
+            writeln!(
+                writer,
+                "{path}:{row}: {body}",
+                path = relativize_path(message.filename()),
+            )?;
+        }
+
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::PylintEmitter;
+
+    #[test]
+    fn output() {
+        let mut emitter = PylintEmitter;
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+}
diff --git a/fortitude/src/message/rdjson.rs b/fortitude/src/message/rdjson.rs
new file mode 100644
index 0000000..cb2d24f
--- /dev/null
+++ b/fortitude/src/message/rdjson.rs
@@ -0,0 +1,142 @@
+// 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::{Emitter, SourceLocation};
+
+use super::DiagnosticMessage;
+
+#[derive(Default)]
+pub struct RdjsonEmitter;
+
+impl Emitter for RdjsonEmitter {
+    fn emit(
+        &mut self,
+        writer: &mut dyn Write,
+        messages: &[DiagnosticMessage],
+    ) -> anyhow::Result<()> {
+        serde_json::to_writer_pretty(
+            writer,
+            &json!({
+                "source": {
+                    "name": "fortitude",
+                    "url": "https://github.com/PlasmaFAIR/fortitude",
+                },
+                "severity": "warning",
+                "diagnostics": &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_rdjson_value(message);
+            s.serialize_element(&value)?;
+        }
+
+        s.end()
+    }
+}
+
+fn message_to_rdjson_value(message: &DiagnosticMessage) -> Value {
+    let source_code = message.source_file().to_source_code();
+
+    let start_location = source_code.source_location(message.start());
+    let end_location = source_code.source_location(message.end());
+
+    if let Some(fix) = message.fix() {
+        json!({
+            "message": message.body(),
+            "location": {
+                "path": message.filename(),
+                "range": rdjson_range(&start_location, &end_location),
+            },
+            "code": {
+                "value": message.rule().map(|rule| rule.noqa_code().to_string()),
+                // "url": message.rule().and_then(|rule| rule.url()),
+            },
+            "suggestions": rdjson_suggestions(fix.edits(), &source_code),
+        })
+    } else {
+        json!({
+            "message": message.body(),
+            "location": {
+                "path": message.filename(),
+                "range": rdjson_range(&start_location, &end_location),
+            },
+            "code": {
+                "value": message.rule().map(|rule| rule.noqa_code().to_string()),
+                // "url": message.rule().and_then(|rule| rule.url()),
+            },
+        })
+    }
+}
+
+fn rdjson_suggestions(edits: &[Edit], source_code: &SourceCode) -> Value {
+    Value::Array(
+        edits
+            .iter()
+            .map(|edit| {
+                let location = source_code.source_location(edit.start());
+                let end_location = source_code.source_location(edit.end());
+
+                json!({
+                    "range": rdjson_range(&location, &end_location),
+                    "text": edit.content().unwrap_or_default(),
+                })
+            })
+            .collect(),
+    )
+}
+
+fn rdjson_range(start: &SourceLocation, end: &SourceLocation) -> Value {
+    json!({
+        "start": {
+            "line": start.row,
+            "column": start.column,
+        },
+        "end": {
+            "line": end.row,
+            "column": end.column,
+        },
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use insta::assert_snapshot;
+
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::RdjsonEmitter;
+
+    #[test]
+    fn output() {
+        let mut emitter = RdjsonEmitter;
+        let content = capture_emitter_output(&mut emitter, &create_messages());
+
+        assert_snapshot!(content);
+    }
+}
diff --git a/fortitude/src/message/sarif.rs b/fortitude/src/message/sarif.rs
new file mode 100644
index 0000000..ba01bde
--- /dev/null
+++ b/fortitude/src/message/sarif.rs
@@ -0,0 +1,214 @@
+// Adapted from from ruff
+// Copyright 2022 Charles Marsh
+// SPDX-License-Identifier: MIT
+
+use std::collections::HashSet;
+use std::io::Write;
+
+use anyhow::Result;
+use serde::{Serialize, Serializer};
+use serde_json::json;
+
+use ruff_source_file::OneIndexed;
+
+use crate::build::VERSION;
+use crate::fs::normalize_path;
+use crate::message::Emitter;
+use crate::registry::{Category, RuleNamespace};
+use crate::rules::Rule;
+
+use super::DiagnosticMessage;
+
+pub struct SarifEmitter;
+
+impl Emitter for SarifEmitter {
+    fn emit(&mut self, writer: &mut dyn Write, messages: &[DiagnosticMessage]) -> Result<()> {
+        let results = messages
+            .iter()
+            .map(SarifResult::from_message)
+            .collect::<Result<Vec<_>>>()?;
+
+        let unique_rules: HashSet<_> = results.iter().filter_map(|result| result.rule).collect();
+        let mut rules: Vec<SarifRule> = unique_rules.into_iter().map(SarifRule::from).collect();
+        rules.sort_by(|a, b| a.code.cmp(&b.code));
+
+        let output = json!({
+            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+            "version": "2.1.0",
+            "runs": [{
+                "tool": {
+                    "driver": {
+                        "name": "fortitude",
+                        "informationUri": "https://github.com/PlasmaFAIR/fortitude",
+                        "rules": rules,
+                        "version": VERSION.to_string(),
+                    }
+                },
+                "results": results,
+            }],
+        });
+        serde_json::to_writer_pretty(writer, &output)?;
+        Ok(())
+    }
+}
+
+#[derive(Debug, Clone)]
+struct SarifRule<'a> {
+    name: &'a str,
+    code: String,
+    linter: &'a str,
+    summary: &'a str,
+    explanation: Option<&'a str>,
+    // url: Option<String>,
+}
+
+impl From<Rule> for SarifRule<'_> {
+    fn from(rule: Rule) -> Self {
+        let code = rule.noqa_code().to_string();
+        let (linter, _) = Category::parse_code(&code).unwrap();
+        Self {
+            name: rule.into(),
+            code,
+            linter: linter.name(),
+            summary: rule.message_formats()[0],
+            explanation: rule.explanation(),
+            // url: rule.url(),
+        }
+    }
+}
+
+impl Serialize for SarifRule<'_> {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        json!({
+            "id": self.code,
+            "shortDescription": {
+                "text": self.summary,
+            },
+            "fullDescription": {
+                "text": self.explanation,
+            },
+            "help": {
+                "text": self.summary,
+            },
+            // "helpUri": self.url,
+            "properties": {
+                "id": self.code,
+                "kind": self.linter,
+                "name": self.name,
+                "problem.severity": "error".to_string(),
+            },
+        })
+        .serialize(serializer)
+    }
+}
+
+#[derive(Debug)]
+struct SarifResult {
+    rule: Option<Rule>,
+    level: String,
+    message: String,
+    uri: String,
+    start_line: OneIndexed,
+    start_column: OneIndexed,
+    end_line: OneIndexed,
+    end_column: OneIndexed,
+}
+
+impl SarifResult {
+    #[cfg(not(target_arch = "wasm32"))]
+    fn from_message(message: &DiagnosticMessage) -> Result<Self> {
+        let start_location = message.compute_start_location();
+        let end_location = message.compute_end_location();
+        let path = normalize_path(message.filename());
+        Ok(Self {
+            rule: message.rule(),
+            level: "error".to_string(),
+            message: message.body().to_string(),
+            uri: url::Url::from_file_path(&path)
+                .map_err(|()| anyhow::anyhow!("Failed to convert path to URL: {}", path.display()))?
+                .to_string(),
+            start_line: start_location.row,
+            start_column: start_location.column,
+            end_line: end_location.row,
+            end_column: end_location.column,
+        })
+    }
+
+    #[cfg(target_arch = "wasm32")]
+    #[allow(clippy::unnecessary_wraps)]
+    fn from_message(message: &Message) -> Result<Self> {
+        let start_location = message.compute_start_location();
+        let end_location = message.compute_end_location();
+        let path = normalize_path(message.filename());
+        Ok(Self {
+            rule: message.rule(),
+            level: "error".to_string(),
+            message: message.body().to_string(),
+            uri: path.display().to_string(),
+            start_line: start_location.row,
+            start_column: start_location.column,
+            end_line: end_location.row,
+            end_column: end_location.column,
+        })
+    }
+}
+
+impl Serialize for SarifResult {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        json!({
+            "level": self.level,
+            "message": {
+                "text": self.message,
+            },
+            "locations": [{
+                "physicalLocation": {
+                    "artifactLocation": {
+                        "uri": self.uri,
+                    },
+                    "region": {
+                        "startLine": self.start_line,
+                        "startColumn": self.start_column,
+                        "endLine": self.end_line,
+                        "endColumn": self.end_column,
+                    }
+                }
+            }],
+            "ruleId": self.rule.map(|rule| rule.noqa_code().to_string()),
+        })
+        .serialize(serializer)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::message::tests::{capture_emitter_output, create_messages};
+    use crate::message::SarifEmitter;
+
+    fn get_output() -> String {
+        let mut emitter = SarifEmitter {};
+        capture_emitter_output(&mut emitter, &create_messages())
+    }
+
+    #[test]
+    fn valid_json() {
+        let content = get_output();
+        serde_json::from_str::<serde_json::Value>(&content).unwrap();
+    }
+
+    #[test]
+    fn test_results() {
+        let content = get_output();
+        let value = serde_json::from_str::<serde_json::Value>(&content).unwrap();
+
+        insta::assert_json_snapshot!(value, {
+            ".runs[0].tool.driver.version" => "[VERSION]",
+            ".runs[0].results[].locations[].physicalLocation.artifactLocation.uri" => "[URI]",
+        });
+    }
+}
diff --git a/fortitude/src/message/text.rs b/fortitude/src/message/text.rs
index ed4547c..6e71a4b 100644
--- a/fortitude/src/message/text.rs
+++ b/fortitude/src/message/text.rs
@@ -14,7 +14,7 @@ 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::diff::Diff;
 use crate::message::Emitter;
 use crate::settings::UnsafeFixes;
 use crate::text_helpers::ShowNonprinting;
@@ -104,11 +104,11 @@ impl Emitter for TextEmitter {
                 }
             }
 
-            // if self.flags.intersects(EmitterFlags::SHOW_FIX_DIFF) {
-            //     if let Some(diff) = Diff::from_message(message) {
-            //         writeln!(writer, "{diff}")?;
-            //     }
-            // }
+            if self.flags.intersects(EmitterFlags::SHOW_FIX_DIFF) {
+                if let Some(diff) = Diff::from_message(message) {
+                    writeln!(writer, "{diff}")?;
+                }
+            }
         }
 
         Ok(())
diff --git a/fortitude/src/printer.rs b/fortitude/src/printer.rs
index a3a9100..29f54f8 100644
--- a/fortitude/src/printer.rs
+++ b/fortitude/src/printer.rs
@@ -4,7 +4,11 @@ use anyhow::Result;
 use bitflags::bitflags;
 use colored::Colorize;
 
-use crate::message::{DiagnosticMessage, Emitter, JsonEmitter, TextEmitter};
+use crate::message::{
+    AzureEmitter, DiagnosticMessage, Emitter, GithubEmitter, GitlabEmitter, GroupedEmitter,
+    JsonEmitter, JsonLinesEmitter, JunitEmitter, PylintEmitter, RdjsonEmitter, SarifEmitter,
+    TextEmitter,
+};
 use crate::settings::OutputFormat;
 
 bitflags! {
@@ -77,6 +81,8 @@ impl Printer {
         diagnostics: &[DiagnosticMessage],
         writer: &mut dyn Write,
     ) -> Result<()> {
+        // TODO: implement tracking of fixables
+
         match self.format {
             OutputFormat::Concise | OutputFormat::Full => {
                 TextEmitter::default()
@@ -87,9 +93,48 @@ impl Printer {
                     .emit(writer, diagnostics)?;
                 self.write_summary_text(writer, diagnostics, num_files)?;
             }
+            OutputFormat::Github => {
+                GithubEmitter.emit(writer, diagnostics)?;
+            }
+            OutputFormat::Gitlab => {
+                GitlabEmitter::default().emit(writer, diagnostics)?;
+            }
+            OutputFormat::Grouped => {
+                GroupedEmitter::default()
+                    .with_show_fix_status(true)
+                    .with_unsafe_fixes(crate::settings::UnsafeFixes::Hint)
+                    .emit(writer, diagnostics)?;
+
+                // if self.flags.intersects(Flags::SHOW_FIX_SUMMARY) {
+                //     if !diagnostics.fixed.is_empty() {
+                //         writeln!(writer)?;
+                //         print_fix_summary(writer, &diagnostics.fixed)?;
+                //         writeln!(writer)?;
+                //     }
+                // }
+                self.write_summary_text(writer, diagnostics, num_files)?;
+            }
             OutputFormat::Json => {
                 JsonEmitter.emit(writer, diagnostics)?;
             }
+            OutputFormat::Sarif => {
+                SarifEmitter.emit(writer, diagnostics)?;
+            }
+            OutputFormat::Azure => {
+                AzureEmitter.emit(writer, diagnostics)?;
+            }
+            OutputFormat::JsonLines => {
+                JsonLinesEmitter.emit(writer, diagnostics)?;
+            }
+            OutputFormat::Rdjson => {
+                RdjsonEmitter.emit(writer, diagnostics)?;
+            }
+            OutputFormat::Junit => {
+                JunitEmitter.emit(writer, diagnostics)?;
+            }
+            OutputFormat::Pylint => {
+                PylintEmitter.emit(writer, diagnostics)?;
+            }
         }
 
         writer.flush()?;
diff --git a/fortitude/src/rules/style/line_length.rs b/fortitude/src/rules/style/line_length.rs
index 2a2cec4..ac9dd69 100644
--- a/fortitude/src/rules/style/line_length.rs
+++ b/fortitude/src/rules/style/line_length.rs
@@ -1,10 +1,10 @@
+/// Defines rules that govern line length.
 use crate::settings::Settings;
 use crate::{FromStartEndLineCol, TextRule};
 use lazy_regex::regex_is_match;
 use ruff_diagnostics::{Diagnostic, Violation};
 use ruff_macros::{derive_message_formats, violation};
 use ruff_source_file::SourceFile;
-/// Defines rules that govern line length.
 
 /// ## What does it do?
 /// Checks line length isn't too long
diff --git a/fortitude/src/rules/style/whitespace.rs b/fortitude/src/rules/style/whitespace.rs
index 29437f1..7851a03 100644
--- a/fortitude/src/rules/style/whitespace.rs
+++ b/fortitude/src/rules/style/whitespace.rs
@@ -1,10 +1,10 @@
+/// Defines rules that enforce widely accepted whitespace rules.
 use ruff_diagnostics::{Diagnostic, Violation};
 use ruff_macros::{derive_message_formats, violation};
 use ruff_source_file::SourceFile;
 
 use crate::settings::Settings;
 use crate::{FromStartEndLineCol, TextRule};
-/// Defines rules that enforce widely accepted whitespace rules.
 
 /// ## What does it do?
 /// Checks for tailing whitespace
diff --git a/fortitude/src/rules/typing/implicit_typing.rs b/fortitude/src/rules/typing/implicit_typing.rs
index 57901f6..31eee6b 100644
--- a/fortitude/src/rules/typing/implicit_typing.rs
+++ b/fortitude/src/rules/typing/implicit_typing.rs
@@ -1,3 +1,4 @@
+/// Defines rules that raise errors if implicit typing is in use.
 use crate::ast::FortitudeNode;
 use crate::settings::Settings;
 use crate::{AstRule, FromAstNode};
@@ -6,8 +7,6 @@ use ruff_macros::{derive_message_formats, violation};
 use ruff_source_file::SourceFile;
 use tree_sitter::Node;
 
-/// Defines rules that raise errors if implicit typing is in use.
-
 fn implicit_statement_is_none(node: &Node) -> bool {
     if let Some(child) = node.child(1) {
         return child.kind() == "none";
diff --git a/fortitude/src/settings.rs b/fortitude/src/settings.rs
index 0a7ffb3..75de8a7 100644
--- a/fortitude/src/settings.rs
+++ b/fortitude/src/settings.rs
@@ -126,15 +126,15 @@ pub enum OutputFormat {
     #[default]
     Full,
     Json,
-    // JsonLines,
-    // Junit,
-    // Grouped,
-    // Github,
-    // Gitlab,
-    // Pylint,
-    // Rdjson,
-    // Azure,
-    // Sarif,
+    JsonLines,
+    Junit,
+    Grouped,
+    Github,
+    Gitlab,
+    Pylint,
+    Rdjson,
+    Azure,
+    Sarif,
 }
 
 impl Display for OutputFormat {
@@ -143,15 +143,15 @@ impl Display for OutputFormat {
             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"),
+            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"),
         }
     }
 }


Test patch:
diff --git a/fortitude/src/message/snapshots/fortitude__message__azure__tests__output.snap b/fortitude/src/message/snapshots/fortitude__message__azure__tests__output.snap
new file mode 100644
index 0000000..7dd20d4
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__azure__tests__output.snap
@@ -0,0 +1,8 @@
+---
+source: fortitude/src/message/azure.rs
+expression: content
+snapshot_kind: text
+---
+##vso[task.logissue type=error;sourcepath=test.f90;linenumber=6;columnnumber=5;code=T003;]'implicit none' set on the enclosing module
+##vso[task.logissue type=error;sourcepath=test.f90;linenumber=7;columnnumber=3;code=S061;]end statement should read 'end subroutine foo'
+##vso[task.logissue type=error;sourcepath=star_kind.f90;linenumber=1;columnnumber=8;code=T021;]integer*4 is non-standard, use integer(4)
diff --git a/fortitude/src/message/snapshots/fortitude__message__github__tests__output.snap b/fortitude/src/message/snapshots/fortitude__message__github__tests__output.snap
new file mode 100644
index 0000000..ab89781
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__github__tests__output.snap
@@ -0,0 +1,8 @@
+---
+source: fortitude/src/message/github.rs
+expression: content
+snapshot_kind: text
+---
+::error title=Fortitude (T003),file=test.f90,line=6,col=5,endLine=6,endColumn=18::test.f90:6:5: T003 'implicit none' set on the enclosing module
+::error title=Fortitude (S061),file=test.f90,line=7,col=3,endLine=7,endColumn=17::test.f90:7:3: S061 end statement should read 'end subroutine foo'
+::error title=Fortitude (T021),file=star_kind.f90,line=1,col=8,endLine=1,endColumn=9::star_kind.f90:1:8: T021 integer*4 is non-standard, use integer(4)
diff --git a/fortitude/src/message/snapshots/fortitude__message__gitlab__tests__output.snap b/fortitude/src/message/snapshots/fortitude__message__gitlab__tests__output.snap
new file mode 100644
index 0000000..a3db932
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__gitlab__tests__output.snap
@@ -0,0 +1,43 @@
+---
+source: fortitude/src/message/gitlab.rs
+expression: redact_fingerprint(&content)
+snapshot_kind: text
+---
+[
+  {
+    "description": "(T003) 'implicit none' set on the enclosing module",
+    "fingerprint": "<redacted>",
+    "location": {
+      "lines": {
+        "begin": 6,
+        "end": 6
+      },
+      "path": "test.f90"
+    },
+    "severity": "major"
+  },
+  {
+    "description": "(S061) end statement should read 'end subroutine foo'",
+    "fingerprint": "<redacted>",
+    "location": {
+      "lines": {
+        "begin": 7,
+        "end": 7
+      },
+      "path": "test.f90"
+    },
+    "severity": "major"
+  },
+  {
+    "description": "(T021) integer*4 is non-standard, use integer(4)",
+    "fingerprint": "<redacted>",
+    "location": {
+      "lines": {
+        "begin": 1,
+        "end": 1
+      },
+      "path": "star_kind.f90"
+    },
+    "severity": "major"
+  }
+]
diff --git a/fortitude/src/message/snapshots/fortitude__message__grouped__tests__default.snap b/fortitude/src/message/snapshots/fortitude__message__grouped__tests__default.snap
new file mode 100644
index 0000000..0a9338e
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__grouped__tests__default.snap
@@ -0,0 +1,11 @@
+---
+source: fortitude/src/message/grouped.rs
+expression: content
+snapshot_kind: text
+---
+star_kind.f90:
+  1:8 T021 integer*4 is non-standard, use integer(4)
+
+test.f90:
+  6:5 T003 'implicit none' set on the enclosing module
+  7:3 S061 end statement should read 'end subroutine foo'
diff --git a/fortitude/src/message/snapshots/fortitude__message__grouped__tests__fix_status.snap b/fortitude/src/message/snapshots/fortitude__message__grouped__tests__fix_status.snap
new file mode 100644
index 0000000..eb28b34
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__grouped__tests__fix_status.snap
@@ -0,0 +1,32 @@
+---
+source: fortitude/src/message/grouped.rs
+expression: content
+snapshot_kind: text
+---
+star_kind.f90:
+  1:8 T021 integer*4 is non-standard, use integer(4)
+    |
+  1 | integer*4 foo; end
+    |        ^ T021
+    |
+  
+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'
+  
+  7:3 S061 end statement should read 'end subroutine foo'
+    |
+  5 |   subroutine foo
+  6 |     implicit none
+  7 |   end subroutine
+    |   ^^^^^^^^^^^^^^ S061
+  8 | end module
+    |
diff --git a/fortitude/src/message/snapshots/fortitude__message__grouped__tests__fix_status_unsafe.snap b/fortitude/src/message/snapshots/fortitude__message__grouped__tests__fix_status_unsafe.snap
new file mode 100644
index 0000000..3001bee
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__grouped__tests__fix_status_unsafe.snap
@@ -0,0 +1,32 @@
+---
+source: fortitude/src/message/grouped.rs
+expression: content
+snapshot_kind: text
+---
+star_kind.f90:
+  1:8 T021 integer*4 is non-standard, use integer(4)
+    |
+  1 | integer*4 foo; end
+    |        ^ T021
+    |
+  
+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'
+  
+  7:3 S061 end statement should read 'end subroutine foo'
+    |
+  5 |   subroutine foo
+  6 |     implicit none
+  7 |   end subroutine
+    |   ^^^^^^^^^^^^^^ S061
+  8 | end module
+    |
diff --git a/fortitude/src/message/snapshots/fortitude__message__grouped__tests__show_source.snap b/fortitude/src/message/snapshots/fortitude__message__grouped__tests__show_source.snap
new file mode 100644
index 0000000..eb28b34
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__grouped__tests__show_source.snap
@@ -0,0 +1,32 @@
+---
+source: fortitude/src/message/grouped.rs
+expression: content
+snapshot_kind: text
+---
+star_kind.f90:
+  1:8 T021 integer*4 is non-standard, use integer(4)
+    |
+  1 | integer*4 foo; end
+    |        ^ T021
+    |
+  
+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'
+  
+  7:3 S061 end statement should read 'end subroutine foo'
+    |
+  5 |   subroutine foo
+  6 |     implicit none
+  7 |   end subroutine
+    |   ^^^^^^^^^^^^^^ S061
+  8 | end module
+    |
diff --git a/fortitude/src/message/snapshots/fortitude__message__json_lines__tests__output.snap b/fortitude/src/message/snapshots/fortitude__message__json_lines__tests__output.snap
new file mode 100644
index 0000000..4b73500
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__json_lines__tests__output.snap
@@ -0,0 +1,8 @@
+---
+source: fortitude/src/message/json_lines.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__junit__tests__output.snap b/fortitude/src/message/snapshots/fortitude__message__junit__tests__output.snap
new file mode 100644
index 0000000..daacc4c
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__junit__tests__output.snap
@@ -0,0 +1,21 @@
+---
+source: fortitude/src/message/junit.rs
+expression: content
+snapshot_kind: text
+---
+<?xml version="1.0" encoding="UTF-8"?>
+<testsuites name="fortitude" tests="3" failures="3" errors="0">
+    <testsuite name="star_kind.f90" tests="1" disabled="0" errors="0" failures="1" package="org.plasmafair.fortitude">
+        <testcase name="org.plasmafair.fortitude.T021" classname="star_kind" line="1" column="8">
+            <failure message="integer*4 is non-standard, use integer(4)">line 1, col 8, integer*4 is non-standard, use integer(4)</failure>
+        </testcase>
+    </testsuite>
+    <testsuite name="test.f90" tests="2" disabled="0" errors="0" failures="2" package="org.plasmafair.fortitude">
+        <testcase name="org.plasmafair.fortitude.T003" classname="test" line="6" column="5">
+            <failure message="&apos;implicit none&apos; set on the enclosing module">line 6, col 5, &apos;implicit none&apos; set on the enclosing module</failure>
+        </testcase>
+        <testcase name="org.plasmafair.fortitude.S061" classname="test" line="7" column="3">
+            <failure message="end statement should read &apos;end subroutine foo&apos;">line 7, col 3, end statement should read &apos;end subroutine foo&apos;</failure>
+        </testcase>
+    </testsuite>
+</testsuites>
diff --git a/fortitude/src/message/snapshots/fortitude__message__pylint__tests__output.snap b/fortitude/src/message/snapshots/fortitude__message__pylint__tests__output.snap
new file mode 100644
index 0000000..09b62be
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__pylint__tests__output.snap
@@ -0,0 +1,8 @@
+---
+source: fortitude/src/message/pylint.rs
+expression: content
+snapshot_kind: text
+---
+test.f90:6: [T003] 'implicit none' set on the enclosing module
+test.f90:7: [S061] end statement should read 'end subroutine foo'
+star_kind.f90:1: [T021] integer*4 is non-standard, use integer(4)
diff --git a/fortitude/src/message/snapshots/fortitude__message__rdjson__tests__output.snap b/fortitude/src/message/snapshots/fortitude__message__rdjson__tests__output.snap
new file mode 100644
index 0000000..29b20d4
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__rdjson__tests__output.snap
@@ -0,0 +1,86 @@
+---
+source: fortitude/src/message/rdjson.rs
+expression: content
+snapshot_kind: text
+---
+{
+  "diagnostics": [
+    {
+      "code": {
+        "value": "T003"
+      },
+      "location": {
+        "path": "test.f90",
+        "range": {
+          "end": {
+            "column": 18,
+            "line": 6
+          },
+          "start": {
+            "column": 5,
+            "line": 6
+          }
+        }
+      },
+      "message": "'implicit none' set on the enclosing module",
+      "suggestions": [
+        {
+          "range": {
+            "end": {
+              "column": 18,
+              "line": 6
+            },
+            "start": {
+              "column": 5,
+              "line": 6
+            }
+          },
+          "text": ""
+        }
+      ]
+    },
+    {
+      "code": {
+        "value": "S061"
+      },
+      "location": {
+        "path": "test.f90",
+        "range": {
+          "end": {
+            "column": 17,
+            "line": 7
+          },
+          "start": {
+            "column": 3,
+            "line": 7
+          }
+        }
+      },
+      "message": "end statement should read 'end subroutine foo'"
+    },
+    {
+      "code": {
+        "value": "T021"
+      },
+      "location": {
+        "path": "star_kind.f90",
+        "range": {
+          "end": {
+            "column": 9,
+            "line": 1
+          },
+          "start": {
+            "column": 8,
+            "line": 1
+          }
+        }
+      },
+      "message": "integer*4 is non-standard, use integer(4)"
+    }
+  ],
+  "severity": "warning",
+  "source": {
+    "name": "fortitude",
+    "url": "https://github.com/PlasmaFAIR/fortitude"
+  }
+}
diff --git a/fortitude/src/message/snapshots/fortitude__message__sarif__tests__results.snap b/fortitude/src/message/snapshots/fortitude__message__sarif__tests__results.snap
new file mode 100644
index 0000000..33aaf44
--- /dev/null
+++ b/fortitude/src/message/snapshots/fortitude__message__sarif__tests__results.snap
@@ -0,0 +1,144 @@
+---
+source: fortitude/src/message/sarif.rs
+expression: value
+snapshot_kind: text
+---
+{
+  "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+  "runs": [
+    {
+      "results": [
+        {
+          "level": "error",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "[URI]"
+                },
+                "region": {
+                  "endColumn": 18,
+                  "endLine": 6,
+                  "startColumn": 5,
+                  "startLine": 6
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "'implicit none' set on the enclosing module"
+          },
+          "ruleId": "T003"
+        },
+        {
+          "level": "error",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "[URI]"
+                },
+                "region": {
+                  "endColumn": 17,
+                  "endLine": 7,
+                  "startColumn": 3,
+                  "startLine": 7
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "end statement should read 'end subroutine foo'"
+          },
+          "ruleId": "S061"
+        },
+        {
+          "level": "error",
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "[URI]"
+                },
+                "region": {
+                  "endColumn": 9,
+                  "endLine": 1,
+                  "startColumn": 8,
+                  "startLine": 1
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "integer*4 is non-standard, use integer(4)"
+          },
+          "ruleId": "T021"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "informationUri": "https://github.com/PlasmaFAIR/fortitude",
+          "name": "fortitude",
+          "rules": [
+            {
+              "fullDescription": {
+                "text": "## What does it do?\nChecks that `end` statements include the type of construct they're ending\n\n## Why is this bad?\nEnd statements should specify what kind of construct they're ending, and the\nname of that construct. For example, prefer this:\n\n```f90\nmodule mymodule\n  ...\nend module mymodule\n```\n\nTo this:\n\n```f90\nmodule mymodule\n  ...\nend\n```\n\nOr this:\n\n```f90\nmodule mymodule\n  ...\nend module\n```\n\nSimilar rules apply for many other Fortran statements\n"
+              },
+              "help": {
+                "text": "end statement should read 'end {statement} {name}'"
+              },
+              "id": "S061",
+              "properties": {
+                "id": "S061",
+                "kind": "Style",
+                "name": "unnamed-end-statement",
+                "problem.severity": "error"
+              },
+              "shortDescription": {
+                "text": "end statement should read 'end {statement} {name}'"
+              }
+            },
+            {
+              "fullDescription": {
+                "text": "## What it does\nChecks for unnecessary `implicit none` in module procedures\n\n## Why is this bad?\nIf a module has 'implicit none' set, it is not necessary to set it in contained\nfunctions and subroutines (except when using interfaces).\n"
+              },
+              "help": {
+                "text": "'implicit none' set on the enclosing {entity}"
+              },
+              "id": "T003",
+              "properties": {
+                "id": "T003",
+                "kind": "Typing",
+                "name": "superfluous-implicit-none",
+                "problem.severity": "error"
+              },
+              "shortDescription": {
+                "text": "'implicit none' set on the enclosing {entity}"
+              }
+            },
+            {
+              "fullDescription": {
+                "text": "## What it does\nChecks for non-standard kind specifiers such as `int*4` or `real*8`\n\n## Why is this bad?\nTypes such as 'real*8' or 'integer*4' are not standard Fortran and should be\navoided. For these cases, consider instead using 'real(real64)' or\n'integer(int32)', where 'real64' and 'int32' may be found in the intrinsic\nmodule 'iso_fortran_env'. You may also wish to determine kinds using the\nbuilt-in functions 'selected_real_kind' and 'selected_int_kind'.\n\nAlso prefers the use of `character(len=*)` to\n`character*(*)`, as although the latter is permitted by the standard, the former is\nmore explicit.\n"
+              },
+              "help": {
+                "text": "{dtype}{size} is non-standard, use {dtype}({kind})"
+              },
+              "id": "T021",
+              "properties": {
+                "id": "T021",
+                "kind": "Typing",
+                "name": "star-kind",
+                "problem.severity": "error"
+              },
+              "shortDescription": {
+                "text": "{dtype}{size} is non-standard, use {dtype}({kind})"
+              }
+            }
+          ],
+          "version": "[VERSION]"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
diff --git a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__missing-double-colon_S071.f90.snap b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__missing-double-colon_S071.f90.snap
index fc1e537..c31363c 100644
--- a/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__missing-double-colon_S071.f90.snap
+++ b/fortitude/src/rules/style/snapshots/fortitude__rules__style__tests__missing-double-colon_S071.f90.snap
@@ -14,6 +14,15 @@ snapshot_kind: text
   |
   = help: Add '::'
 
+ℹ Safe fix
+1 1 | program test
+2 2 |   implicit none
+3   |-  real these, are
+  3 |+  real :: these, are
+4 4 |   type(real(kind=kind(1.0d0))) all(42), bad
+5 5 |   integer :: but, these_ones, are_fine
+6 6 | end program test
+
 ./resources/test/fixtures/style/S071.f90:4:3: S071 [*] variable declaration missing '::'
   |
 2 |   implicit none
@@ -24,3 +33,12 @@ snapshot_kind: text
 6 | end program test
   |
   = help: Add '::'
+
+ℹ Safe fix
+1 1 | program test
+2 2 |   implicit none
+3 3 |   real these, are
+4   |-  type(real(kind=kind(1.0d0))) all(42), bad
+  4 |+  type(real(kind=kind(1.0d0))) :: all(42), bad
+5 5 |   integer :: but, these_ones, are_fine
+6 6 | end program test
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 f755385..8c142c6 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
@@ -14,6 +14,15 @@ snapshot_kind: text
   |
   = help: Remove unnecessary 'implicit none'
 
+ℹ Safe fix
+2 2 |   implicit none
+3 3 | contains
+4 4 |   integer function myfunc(x)
+5   |-    implicit none
+6 5 |     integer, intent(in) :: x
+7 6 |     myfunc = x * 2
+8 7 |   end function myfunc
+
 ./resources/test/fixtures/typing/T003.f90:10:5: T003 [*] 'implicit none' set on the enclosing module
    |
  8 |   end function myfunc
@@ -25,6 +34,15 @@ snapshot_kind: text
    |
    = help: Remove unnecessary 'implicit none'
 
+ℹ Safe fix
+7  7  |     myfunc = x * 2
+8  8  |   end function myfunc
+9  9  |   subroutine mysub(x)
+10    |-    implicit none
+11 10 |     integer, intent(inout) :: x
+12 11 |     x = x * 2
+13 12 |   end subroutine mysub
+
 ./resources/test/fixtures/typing/T003.f90:23:5: T003 [*] 'implicit none' set on the enclosing program
    |
 21 | contains
@@ -36,6 +54,15 @@ snapshot_kind: text
    |
    = help: Remove unnecessary 'implicit none'
 
+ℹ Safe fix
+20 20 | 
+21 21 | contains
+22 22 |   integer function myfunc2(x)
+23    |-    implicit none
+24 23 |     integer, intent(in) :: x
+25 24 |     myfunc2 = x * 2
+26 25 |   end function myfunc2
+
 ./resources/test/fixtures/typing/T003.f90:28:5: T003 [*] 'implicit none' set on the enclosing program
    |
 26 |   end function myfunc2
@@ -46,3 +73,12 @@ snapshot_kind: text
 30 |     x = x * 2
    |
    = help: Remove unnecessary 'implicit none'
+
+ℹ Safe fix
+25 25 |     myfunc2 = x * 2
+26 26 |   end function myfunc2
+27 27 |   subroutine mysub2(x)
+28    |-    implicit none
+29 28 |     integer, intent(inout) :: x
+30 29 |     x = x * 2
+31 30 |   end subroutine mysub2