Repository: PlasmaFAIR/fortitude
Pull request #354: Expand rule for `character*(*)` to handle `character*N`, `character*(:)`, and `character*(expression)`
Author: login: LiamPattinson
Labels: name: rule
description: A new rule for the linter
Created: 2025-03-07T16:23:19Z | Merged: 2025-03-12T17:43:47Z

Solves https://github.com/PlasmaFAIR/fortitude/issues/76

Something that's been in the backlog for a while.

It would be nice to retain upper/lowercase and report the variable name in the diagnostics.

I also don't have my copy of Modern Fortran Explained at hand, so I'm not sure if `modernisation` is the correct place for this!


Diff:
diff --git a/docs/rules.md b/docs/rules.md
index 80daaad6..f95cebf9 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -46,7 +46,7 @@
 | OB031 | [specific-name](rules/specific-name.md) | deprecated type-specific function '{func}' | <span title='Rule is in preview'>🧪</span> <span title='Automatic fix not available' style='opacity: 0.1' aria-hidden='true'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
 | OB041 | [computed-go-to](rules/computed-go-to.md) | computed go to statements are obsolescent, use a select case statement | <span title='Rule is in preview'>🧪</span> <span title='Automatic fix not available' style='opacity: 0.1' aria-hidden='true'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
 | OB051 | [pause-statement](rules/pause-statement.md) | `pause` statements are a deleted feature | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix not available' style='opacity: 0.1' aria-hidden='true'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
-| OB061 | [deprecated-assumed-size-character](rules/deprecated-assumed-size-character.md) | character '{name}' uses deprecated syntax for assumed size | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix not available' style='opacity: 0.1' aria-hidden='true'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
+| OB061 | [deprecated-character-syntax](rules/deprecated-character-syntax.md) | '{original}' uses deprecated syntax | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix available'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
 
 ### Modernisation (MOD)
 
@@ -88,7 +88,7 @@
 | ---- | ---- | ------- | ------: |
 | FORT001 | [invalid-rule-code-or-name](rules/invalid-rule-code-or-name.md) | Unknown rule or code `{rule}` in allow comment | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix not available' style='opacity: 0.1' aria-hidden='true'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
 | FORT002 | [unused-allow-comment](rules/unused-allow-comment.md) | Unused rule `{rule}` in allow comment | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix available'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
-| FORT003 | [redirected-allow-comment](rules/redirected-allow-comment.md) | `{original}` is a redirect to `{new_code}` | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix available'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
+| FORT003 | [redirected-allow-comment](rules/redirected-allow-comment.md) | `{original}` has been redirected to '{redirect}' | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix available'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
 | FORT004 | [duplicated-allow-comment](rules/duplicated-allow-comment.md) | Duplicated rule `{rule}` in allow comment | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix available'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
 | FORT005 | [disabled-allow-comment](rules/disabled-allow-comment.md) | Disabled rule `{rule}` in allow comment | <span title='Rule is stable' style='opacity: 0.6'>✔️</span> <span title='Automatic fix available'>🛠️</span> <span title='Rule turned on by default'>▶️</span> |
 
diff --git a/docs/rules/deprecated-assumed-size-character.md b/docs/rules/deprecated-assumed-size-character.md
deleted file mode 100644
index aa859210..00000000
--- a/docs/rules/deprecated-assumed-size-character.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# deprecated-assumed-size-character (OB061)
-This rule is turned on by default.
-
-## What does it do?
-Checks for deprecated declarations of `character`
-
-## Why is this bad?
-The syntax `character*(*)` is a deprecated form of `character(len=*)`. Prefer the
-second form.
\ No newline at end of file
diff --git a/docs/rules/deprecated-character-syntax.md b/docs/rules/deprecated-character-syntax.md
new file mode 100644
index 00000000..16d0dadf
--- /dev/null
+++ b/docs/rules/deprecated-character-syntax.md
@@ -0,0 +1,12 @@
+# deprecated-character-syntax (OB061)
+Fix is always available.
+
+This rule is turned on by default.
+
+## What does it do?
+Checks for outdated declarations of `character*N`, 'character*(*)',
+`character*(:)`, and 'character*(integer-expression)'.
+
+## Why is this bad?
+The syntax `character*N` has been replaced by `character(len=N)` in modern
+Fortran. Prefer the second form.
\ No newline at end of file
diff --git a/docs/rules/old-style-character-syntax.md b/docs/rules/old-style-character-syntax.md
new file mode 100644
index 00000000..f1c4e4f0
--- /dev/null
+++ b/docs/rules/old-style-character-syntax.md
@@ -0,0 +1,11 @@
+# old-style-character-syntax (MOD041)
+This rule is unstable and in [preview](../preview.md). The `--preview` flag is required for use.
+
+This rule is turned on by default.
+
+## What does it do?
+Checks for outdated declarations of `character*N`
+
+## Why is this bad?
+The syntax `character*N` has been replaced by `character(len=N)` in modern
+Fortran. Prefer the second form.
\ No newline at end of file
diff --git a/fortitude/resources/test/fixtures/obsolescent/OB061.f90 b/fortitude/resources/test/fixtures/obsolescent/OB061.f90
index 7da0a502..8acfbdd8 100644
--- a/fortitude/resources/test/fixtures/obsolescent/OB061.f90
+++ b/fortitude/resources/test/fixtures/obsolescent/OB061.f90
@@ -1,13 +1,36 @@
 program cases
+
+  implicit none
+
+  integer, parameter :: MAX_LEN = 64
+
 contains
-  subroutine char_input(a, b, c, d, e, f)
+
+  subroutine char_input(a, b, c, d, e, f, g, h, i, j, k, l, m, n)
+    use, intrinsic :: iso_c_binding, only: c_char
+
+    ! assumed size
     character * ( * ), intent(in) :: a
-    character*(*), intent(in) :: b
-    character*(len=*), intent(in) :: c
-    character*(3), intent(in) :: d
-    character*(MAX_LEN), intent(in) :: e
+    character*(*), intent(in) :: b, c*9
+    character*(:), allocatable :: d
+
+    ! sized with a number literal
+    character*5 e, f
+    CHARACTer  *  10 g
+    chAracTer* 3 h, i*7
+
+    ! sized with an integer expression
+    character*(MAX_LEN), intent(in) :: j
+    character * (2* (MAX_LEN) ) k
+
     ! these are ok
-    character(*, kind) :: f
-    character(len=*, kind=4) :: g
+    character(*, c_char) :: l
+    character(len=*, kind=4) :: m
+
+    ! this should raise a syntax error and otherwise be ignored
+    ! allow(syntax-error)
+    character*MAX_LEN n
+
   end subroutine char_input
+
 end program cases
diff --git a/fortitude/src/allow_comments.rs b/fortitude/src/allow_comments.rs
index ba00a9ce..cc03d54d 100644
--- a/fortitude/src/allow_comments.rs
+++ b/fortitude/src/allow_comments.rs
@@ -135,14 +135,17 @@ pub fn check_allow_comments(
             let redirect = get_redirect_target(code.code);
             if rules.enabled(Rule::RedirectedAllowComment) {
                 if let Some(redirect) = redirect {
-                    let new_name = Rule::from_code(redirect).unwrap().as_ref().to_string();
+                    let rule = Rule::from_code(redirect).unwrap();
+                    let new_code = rule.noqa_code().to_string();
+                    let new_name = rule.as_ref().to_string();
                     let edit =
                         Edit::replacement(new_name.clone(), code.loc.start(), code.loc.end());
                     diagnostics.push(
                         Diagnostic::new(
                             RedirectedAllowComment {
                                 original: code.code.to_string(),
-                                new_code: redirect.to_string(),
+                                redirect: redirect.to_string(),
+                                new_code,
                                 new_name,
                             },
                             code.loc,
diff --git a/fortitude/src/configuration.rs b/fortitude/src/configuration.rs
index e4265156..27be7a7d 100644
--- a/fortitude/src/configuration.rs
+++ b/fortitude/src/configuration.rs
@@ -2,6 +2,7 @@ use crate::cli::CheckArgs;
 use crate::fs::{FilePattern, FilePatternSet, EXCLUDE_BUILTINS, FORTRAN_EXTS};
 use crate::options::Options;
 use crate::registry::RuleNamespace;
+use crate::rule_redirects::get_redirect;
 use crate::rule_selector::{
     collect_per_file_ignores, CompiledPerFileIgnoreList, PerFileIgnore, PreviewOptions,
     RuleSelector, Specificity,
@@ -504,12 +505,18 @@ pub fn to_rule_table(args: RuleSelection, preview: &PreviewMode) -> anyhow::Resu
     }
 
     for (from, target) in redirects.iter().sorted_by_key(|item| item.0) {
-        warn_user_once_by_id!(
-            from,
-            "`{from}` has been remapped to `{}{}`.",
-            target.category().common_prefix(),
-            target.short_code()
-        );
+        // Some redirects are from one long-code to another, but the target will
+        // be converted to a short code as part of the redirection. To get around
+        // this, we need to check the redirect map from scratch.
+        let target = match get_redirect(from) {
+            Some(target) => target.1.to_string(),
+            None => format!(
+                "{}{}",
+                target.category().common_prefix(),
+                target.short_code()
+            ),
+        };
+        warn_user_once_by_id!(from, "`{from}` has been remapped to `{target}`.");
     }
 
     if preview.mode.is_disabled() {
diff --git a/fortitude/src/registry.rs b/fortitude/src/registry.rs
index 1ba3b1ee..e532bc08 100644
--- a/fortitude/src/registry.rs
+++ b/fortitude/src/registry.rs
@@ -1,4 +1,5 @@
 use fortitude_macros::RuleNamespace;
+use rayon::str;
 use std::str::FromStr; // Needed by strum_macros
 
 pub use crate::rules::Rule;
@@ -79,6 +80,9 @@ pub trait RuleNamespace: Sized {
     /// rules from this category starts with.
     fn common_prefix(&self) -> &'static str;
 
+    /// Returns the variant corresponding to the given prefix.
+    fn from_prefix(preifx: &str) -> Result<Self, &'static str>;
+
     /// Attempts to parse the given rule code. If the prefix is recognized
     /// returns the respective variant along with the code with the common
     /// prefix stripped.
diff --git a/fortitude/src/rule_redirects.rs b/fortitude/src/rule_redirects.rs
index 759e9cc0..334b3aa6 100644
--- a/fortitude/src/rule_redirects.rs
+++ b/fortitude/src/rule_redirects.rs
@@ -57,6 +57,10 @@ static REDIRECTS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(
         ("S041", "MOD011"),
         ("S051", "MOD021"),
         ("S021", "C141"),
+        (
+            "deprecated-assumed-size-character",
+            "deprecated-character-syntax",
+        ),
     ])
 });
 
diff --git a/fortitude/src/rule_selector.rs b/fortitude/src/rule_selector.rs
index c9984b38..972acd3a 100644
--- a/fortitude/src/rule_selector.rs
+++ b/fortitude/src/rule_selector.rs
@@ -77,13 +77,8 @@ impl FromStr for RuleSelector {
                     None => (s, None),
                 };
 
-                // If passed full name of rule, use the equivalent code instead.
-                if let Ok(rule) = Rule::from_str(s) {
-                    let c = rule.noqa_code().to_string();
-                    return Self::from_str(c.as_str());
-                }
-
                 // If passed a deprecated category, get list of all rules in that category.
+                // These are handled separately as they require unique warnings.
                 if let Some((rules, redirects)) = get_deprecated_category(s) {
                     let redirected_to = redirects
                         .iter()
diff --git a/fortitude/src/rules/fortitude/allow_comments.rs b/fortitude/src/rules/fortitude/allow_comments.rs
index a1ddde92..4041daee 100644
--- a/fortitude/src/rules/fortitude/allow_comments.rs
+++ b/fortitude/src/rules/fortitude/allow_comments.rs
@@ -51,6 +51,7 @@ impl Violation for InvalidRuleCodeOrName {
 #[derive(ViolationMetadata)]
 pub(crate) struct RedirectedAllowComment {
     pub original: String,
+    pub redirect: String,
     pub new_code: String,
     pub new_name: String,
 }
@@ -59,9 +60,9 @@ impl AlwaysFixableViolation for RedirectedAllowComment {
     #[derive_message_formats]
     fn message(&self) -> String {
         let Self {
-            original, new_code, ..
+            original, redirect, ..
         } = self;
-        format!("`{original}` is a redirect to `{new_code}`")
+        format!("`{original}` has been redirected to '{redirect}'")
     }
 
     fn fix_title(&self) -> String {
diff --git a/fortitude/src/rules/fortitude/snapshots/fortitude__rules__fortitude__tests__redirected-allow-comment_FORT003.f90.snap b/fortitude/src/rules/fortitude/snapshots/fortitude__rules__fortitude__tests__redirected-allow-comment_FORT003.f90.snap
index c160d335..582627f3 100644
--- a/fortitude/src/rules/fortitude/snapshots/fortitude__rules__fortitude__tests__redirected-allow-comment_FORT003.f90.snap
+++ b/fortitude/src/rules/fortitude/snapshots/fortitude__rules__fortitude__tests__redirected-allow-comment_FORT003.f90.snap
@@ -3,7 +3,7 @@ source: fortitude/src/rules/fortitude/mod.rs
 expression: diagnostics
 snapshot_kind: text
 ---
-./resources/test/fixtures/fortitude/FORT003.f90:1:9: FORT003 [*] `T001` is a redirect to `C001`
+./resources/test/fixtures/fortitude/FORT003.f90:1:9: FORT003 [*] `T001` has been redirected to 'C001'
   |
 1 | ! allow(T001)
   |         ^^^^ FORT003
diff --git a/fortitude/src/rules/mod.rs b/fortitude/src/rules/mod.rs
index 7d1f2dd9..b6fb6f18 100755
--- a/fortitude/src/rules/mod.rs
+++ b/fortitude/src/rules/mod.rs
@@ -20,12 +20,12 @@ pub struct NoqaCode(&'static str, &'static str);
 
 impl NoqaCode {
     /// Return the prefix for the [`NoqaCode`], e.g., `SIM` for `SIM101`.
-    pub fn prefix(&self) -> &str {
+    pub fn prefix(&self) -> &'static str {
         self.0
     }
 
     /// Return the suffix for the [`NoqaCode`], e.g., `101` for `SIM101`.
-    pub fn suffix(&self) -> &str {
+    pub fn suffix(&self) -> &'static str {
         self.1
     }
 }
@@ -126,7 +126,7 @@ pub fn code_to_rule(category: Category, code: &str) -> Option<(RuleGroup, Rule)>
         (Obsolescent, "031") => (RuleGroup::Preview, Ast, Default, obsolescent::specific_names::SpecificName),
         (Obsolescent, "041") => (RuleGroup::Preview, Ast, Default, obsolescent::computed_goto::ComputedGoTo),
         (Obsolescent, "051") => (RuleGroup::Stable, Ast, Default, obsolescent::pause_statement::PauseStatement),
-        (Obsolescent, "061") => (RuleGroup::Stable, Ast, Default, obsolescent::assumed_size_character_syntax::DeprecatedAssumedSizeCharacter),
+        (Obsolescent, "061") => (RuleGroup::Stable, Ast, Default, obsolescent::deprecated_character_syntax::DeprecatedCharacterSyntax),
 
         (Fortitude, "001") => (RuleGroup::Stable, None, Default, fortitude::allow_comments::InvalidRuleCodeOrName),
         (Fortitude, "002") => (RuleGroup::Stable, None, Default, fortitude::allow_comments::UnusedAllowComment),
diff --git a/fortitude/src/rules/obsolescent/assumed_size_character_syntax.rs b/fortitude/src/rules/obsolescent/assumed_size_character_syntax.rs
deleted file mode 100644
index a320dac4..00000000
--- a/fortitude/src/rules/obsolescent/assumed_size_character_syntax.rs
+++ /dev/null
@@ -1,67 +0,0 @@
-use crate::ast::FortitudeNode;
-use crate::settings::Settings;
-use crate::{AstRule, FromAstNode};
-use itertools::Itertools;
-use ruff_diagnostics::{Diagnostic, Violation};
-use ruff_macros::{derive_message_formats, ViolationMetadata};
-use ruff_source_file::SourceFile;
-use tree_sitter::Node;
-
-/// ## What does it do?
-/// Checks for deprecated declarations of `character`
-///
-/// ## Why is this bad?
-/// The syntax `character*(*)` is a deprecated form of `character(len=*)`. Prefer the
-/// second form.
-#[derive(ViolationMetadata)]
-pub(crate) struct DeprecatedAssumedSizeCharacter {
-    name: String,
-}
-
-impl Violation for DeprecatedAssumedSizeCharacter {
-    #[derive_message_formats]
-    fn message(&self) -> String {
-        let Self { name } = self;
-        format!("character '{name}' uses deprecated syntax for assumed size")
-    }
-}
-
-impl AstRule for DeprecatedAssumedSizeCharacter {
-    fn check(_settings: &Settings, node: &Node, src: &SourceFile) -> Option<Vec<Diagnostic>> {
-        let src = src.source_text();
-        let declaration = node
-            .ancestors()
-            .find(|parent| parent.kind() == "variable_declaration")?;
-
-        // Only applies to `character`
-        if declaration.parse_intrinsic_type()?.to_lowercase() != "character" {
-            return None;
-        }
-
-        // Are we immediately (modulo whitespace) in front of `(...)`?
-        if node.next_sibling()?.kind() != "(" {
-            return None;
-        }
-
-        // Collect all declarations on this line
-        let all_decls = declaration
-            .children_by_field_name("declarator", &mut declaration.walk())
-            .filter_map(|declarator| {
-                let identifier = match declarator.kind() {
-                    "identifier" => Some(declarator),
-                    "sized_declarator" => declarator.child_with_name("identifier"),
-                    _ => None,
-                }?;
-                identifier.to_text(src)
-            })
-            .map(|name| name.to_string())
-            .map(|name| Diagnostic::from_node(Self { name }, node))
-            .collect_vec();
-
-        Some(all_decls)
-    }
-
-    fn entrypoints() -> Vec<&'static str> {
-        vec!["assumed_size"]
-    }
-}
diff --git a/fortitude/src/rules/obsolescent/deprecated_character_syntax.rs b/fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
new file mode 100644
index 00000000..a9e3aa88
--- /dev/null
+++ b/fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
@@ -0,0 +1,93 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Fix};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`, 'character*(*)',
+/// `character*(:)`, and 'character*(integer-expression)'.
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    dtype: String,
+    length: String,
+}
+
+impl AlwaysFixableViolation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses deprecated syntax")
+    }
+
+    fn fix_title(&self) -> String {
+        let Self { dtype, length, .. } = self;
+        format!("Replace with '{dtype}(len={length})'")
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        let dtype = node.child(0)?;
+        if dtype.kind() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // If kind does not start with '*', exit early
+        if !kind_text.starts_with('*') {
+            return None;
+        }
+
+        // The '*' should be followed by:
+        // - An integer literal
+        // - '(*)'
+        // - An integer expression within parentheses
+        // For the first case, the first child_node will be a number_literal.
+        // For the latter two, the first child node will be `assumed_size`, and
+        // the second child node will be the length (which may be a
+        // number_literal, a math_expression, or another assumed_size).
+        let child = kind.named_child(0)?;
+        let length = if child.kind() == "assumed_size" {
+            kind.named_child(1)?.to_text(src)?.to_string()
+        } else {
+            child.to_text(src)?.to_string()
+        };
+
+        let original = node.to_text(src)?.to_string();
+        let dtype = dtype.to_text(src)?.to_string();
+        let replacement = format!("{}(len={})", dtype, length);
+        let fix = Fix::safe_edit(node.edit_replacement(source_file, replacement));
+        some_vec![Diagnostic::from_node(
+            Self {
+                original,
+                dtype,
+                length
+            },
+            node
+        )
+        .with_fix(fix)]
+    }
+
+    fn entrypoints() -> Vec<&'static str> {
+        vec!["intrinsic_type"]
+    }
+}
diff --git a/fortitude/src/rules/obsolescent/mod.rs b/fortitude/src/rules/obsolescent/mod.rs
index 7da6bf8e..1801ca53 100644
--- a/fortitude/src/rules/obsolescent/mod.rs
+++ b/fortitude/src/rules/obsolescent/mod.rs
@@ -1,6 +1,6 @@
-pub mod assumed_size_character_syntax;
 pub mod common_blocks;
 pub mod computed_goto;
+pub mod deprecated_character_syntax;
 pub mod entry_statement;
 pub mod pause_statement;
 pub mod specific_names;
@@ -25,7 +25,7 @@ mod tests {
     #[test_case(Rule::SpecificName, Path::new("OB031.f90"))]
     #[test_case(Rule::ComputedGoTo, Path::new("OB041.f90"))]
     #[test_case(Rule::PauseStatement, Path::new("OB051.f90"))]
-    #[test_case(Rule::DeprecatedAssumedSizeCharacter, Path::new("OB061.f90"))]
+    #[test_case(Rule::DeprecatedCharacterSyntax, Path::new("OB061.f90"))]
     fn rules(rule_code: Rule, path: &Path) -> Result<()> {
         let snapshot = format!("{}_{}", rule_code.as_ref(), path.to_string_lossy());
         let diagnostics = test_path(
diff --git a/fortitude/src/rules/obsolescent/snapshots/fortitude__rules__obsolescent__tests__deprecated-assumed-size-character_OB061.f90.snap b/fortitude/src/rules/obsolescent/snapshots/fortitude__rules__obsolescent__tests__deprecated-assumed-size-character_OB061.f90.snap
deleted file mode 100644
index cc29a484..00000000
--- a/fortitude/src/rules/obsolescent/snapshots/fortitude__rules__obsolescent__tests__deprecated-assumed-size-character_OB061.f90.snap
+++ /dev/null
@@ -1,54 +0,0 @@
----
-source: fortitude/src/rules/obsolescent/mod.rs
-expression: diagnostics
-snapshot_kind: text
----
-./resources/test/fixtures/obsolescent/OB061.f90:4:15: OB061 character 'a' uses deprecated syntax for assumed size
-  |
-2 | contains
-3 |   subroutine char_input(a, b, c, d, e, f)
-4 |     character * ( * ), intent(in) :: a
-  |               ^ OB061
-5 |     character*(*), intent(in) :: b
-6 |     character*(len=*), intent(in) :: c
-  |
-
-./resources/test/fixtures/obsolescent/OB061.f90:5:14: OB061 character 'b' uses deprecated syntax for assumed size
-  |
-3 |   subroutine char_input(a, b, c, d, e, f)
-4 |     character * ( * ), intent(in) :: a
-5 |     character*(*), intent(in) :: b
-  |              ^ OB061
-6 |     character*(len=*), intent(in) :: c
-7 |     character*(3), intent(in) :: d
-  |
-
-./resources/test/fixtures/obsolescent/OB061.f90:6:14: OB061 character 'c' uses deprecated syntax for assumed size
-  |
-4 |     character * ( * ), intent(in) :: a
-5 |     character*(*), intent(in) :: b
-6 |     character*(len=*), intent(in) :: c
-  |              ^ OB061
-7 |     character*(3), intent(in) :: d
-8 |     character*(MAX_LEN), intent(in) :: e
-  |
-
-./resources/test/fixtures/obsolescent/OB061.f90:7:14: OB061 character 'd' uses deprecated syntax for assumed size
-  |
-5 |     character*(*), intent(in) :: b
-6 |     character*(len=*), intent(in) :: c
-7 |     character*(3), intent(in) :: d
-  |              ^ OB061
-8 |     character*(MAX_LEN), intent(in) :: e
-9 |     ! these are ok
-  |
-
-./resources/test/fixtures/obsolescent/OB061.f90:8:14: OB061 character 'e' uses deprecated syntax for assumed size
-   |
- 6 |     character*(len=*), intent(in) :: c
- 7 |     character*(3), intent(in) :: d
- 8 |     character*(MAX_LEN), intent(in) :: e
-   |              ^ OB061
- 9 |     ! these are ok
-10 |     character(*, kind) :: f
-   |
diff --git a/fortitude/src/rules/obsolescent/snapshots/fortitude__rules__obsolescent__tests__deprecated-character-syntax_OB061.f90.snap b/fortitude/src/rules/obsolescent/snapshots/fortitude__rules__obsolescent__tests__deprecated-character-syntax_OB061.f90.snap
new file mode 100644
index 00000000..50434ad6
--- /dev/null
+++ b/fortitude/src/rules/obsolescent/snapshots/fortitude__rules__obsolescent__tests__deprecated-character-syntax_OB061.f90.snap
@@ -0,0 +1,166 @@
+---
+source: fortitude/src/rules/obsolescent/mod.rs
+expression: diagnostics
+snapshot_kind: text
+---
+./resources/test/fixtures/obsolescent/OB061.f90:13:5: OB061 [*] 'character * ( * )' uses deprecated syntax
+   |
+12 |     ! assumed size
+13 |     character * ( * ), intent(in) :: a
+   |     ^^^^^^^^^^^^^^^^^ OB061
+14 |     character*(*), intent(in) :: b, c*9
+15 |     character*(:), allocatable :: d
+   |
+   = help: Replace with 'character(len=*)'
+
+ℹ Safe fix
+10 10 |     use, intrinsic :: iso_c_binding, only: c_char
+11 11 | 
+12 12 |     ! assumed size
+13    |-    character * ( * ), intent(in) :: a
+   13 |+    character(len=*), intent(in) :: a
+14 14 |     character*(*), intent(in) :: b, c*9
+15 15 |     character*(:), allocatable :: d
+16 16 | 
+
+./resources/test/fixtures/obsolescent/OB061.f90:14:5: OB061 [*] 'character*(*)' uses deprecated syntax
+   |
+12 |     ! assumed size
+13 |     character * ( * ), intent(in) :: a
+14 |     character*(*), intent(in) :: b, c*9
+   |     ^^^^^^^^^^^^^ OB061
+15 |     character*(:), allocatable :: d
+   |
+   = help: Replace with 'character(len=*)'
+
+ℹ Safe fix
+11 11 | 
+12 12 |     ! assumed size
+13 13 |     character * ( * ), intent(in) :: a
+14    |-    character*(*), intent(in) :: b, c*9
+   14 |+    character(len=*), intent(in) :: b, c*9
+15 15 |     character*(:), allocatable :: d
+16 16 | 
+17 17 |     ! sized with a number literal
+
+./resources/test/fixtures/obsolescent/OB061.f90:15:5: OB061 [*] 'character*(:)' uses deprecated syntax
+   |
+13 |     character * ( * ), intent(in) :: a
+14 |     character*(*), intent(in) :: b, c*9
+15 |     character*(:), allocatable :: d
+   |     ^^^^^^^^^^^^^ OB061
+16 |
+17 |     ! sized with a number literal
+   |
+   = help: Replace with 'character(len=:)'
+
+ℹ Safe fix
+12 12 |     ! assumed size
+13 13 |     character * ( * ), intent(in) :: a
+14 14 |     character*(*), intent(in) :: b, c*9
+15    |-    character*(:), allocatable :: d
+   15 |+    character(len=:), allocatable :: d
+16 16 | 
+17 17 |     ! sized with a number literal
+18 18 |     character*5 e, f
+
+./resources/test/fixtures/obsolescent/OB061.f90:18:5: OB061 [*] 'character*5' uses deprecated syntax
+   |
+17 |     ! sized with a number literal
+18 |     character*5 e, f
+   |     ^^^^^^^^^^^ OB061
+19 |     CHARACTer  *  10 g
+20 |     chAracTer* 3 h, i*7
+   |
+   = help: Replace with 'character(len=5)'
+
+ℹ Safe fix
+15 15 |     character*(:), allocatable :: d
+16 16 | 
+17 17 |     ! sized with a number literal
+18    |-    character*5 e, f
+   18 |+    character(len=5) e, f
+19 19 |     CHARACTer  *  10 g
+20 20 |     chAracTer* 3 h, i*7
+21 21 | 
+
+./resources/test/fixtures/obsolescent/OB061.f90:19:5: OB061 [*] 'CHARACTer  *  10' uses deprecated syntax
+   |
+17 |     ! sized with a number literal
+18 |     character*5 e, f
+19 |     CHARACTer  *  10 g
+   |     ^^^^^^^^^^^^^^^^ OB061
+20 |     chAracTer* 3 h, i*7
+   |
+   = help: Replace with 'CHARACTer(len=10)'
+
+ℹ Safe fix
+16 16 | 
+17 17 |     ! sized with a number literal
+18 18 |     character*5 e, f
+19    |-    CHARACTer  *  10 g
+   19 |+    CHARACTer(len=10) g
+20 20 |     chAracTer* 3 h, i*7
+21 21 | 
+22 22 |     ! sized with an integer expression
+
+./resources/test/fixtures/obsolescent/OB061.f90:20:5: OB061 [*] 'chAracTer* 3' uses deprecated syntax
+   |
+18 |     character*5 e, f
+19 |     CHARACTer  *  10 g
+20 |     chAracTer* 3 h, i*7
+   |     ^^^^^^^^^^^^ OB061
+21 |
+22 |     ! sized with an integer expression
+   |
+   = help: Replace with 'chAracTer(len=3)'
+
+ℹ Safe fix
+17 17 |     ! sized with a number literal
+18 18 |     character*5 e, f
+19 19 |     CHARACTer  *  10 g
+20    |-    chAracTer* 3 h, i*7
+   20 |+    chAracTer(len=3) h, i*7
+21 21 | 
+22 22 |     ! sized with an integer expression
+23 23 |     character*(MAX_LEN), intent(in) :: j
+
+./resources/test/fixtures/obsolescent/OB061.f90:23:5: OB061 [*] 'character*(MAX_LEN)' uses deprecated syntax
+   |
+22 |     ! sized with an integer expression
+23 |     character*(MAX_LEN), intent(in) :: j
+   |     ^^^^^^^^^^^^^^^^^^^ OB061
+24 |     character * (2* (MAX_LEN) ) k
+   |
+   = help: Replace with 'character(len=MAX_LEN)'
+
+ℹ Safe fix
+20 20 |     chAracTer* 3 h, i*7
+21 21 | 
+22 22 |     ! sized with an integer expression
+23    |-    character*(MAX_LEN), intent(in) :: j
+   23 |+    character(len=MAX_LEN), intent(in) :: j
+24 24 |     character * (2* (MAX_LEN) ) k
+25 25 | 
+26 26 |     ! these are ok
+
+./resources/test/fixtures/obsolescent/OB061.f90:24:5: OB061 [*] 'character * (2* (MAX_LEN) )' uses deprecated syntax
+   |
+22 |     ! sized with an integer expression
+23 |     character*(MAX_LEN), intent(in) :: j
+24 |     character * (2* (MAX_LEN) ) k
+   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^ OB061
+25 |
+26 |     ! these are ok
+   |
+   = help: Replace with 'character(len=2* (MAX_LEN))'
+
+ℹ Safe fix
+21 21 | 
+22 22 |     ! sized with an integer expression
+23 23 |     character*(MAX_LEN), intent(in) :: j
+24    |-    character * (2* (MAX_LEN) ) k
+   24 |+    character(len=2* (MAX_LEN)) k
+25 25 | 
+26 26 |     ! these are ok
+27 27 |     character(*, c_char) :: l
diff --git a/fortitude_macros/src/rule_namespace.rs b/fortitude_macros/src/rule_namespace.rs
index 7b1fc101..ff30c80f 100644
--- a/fortitude_macros/src/rule_namespace.rs
+++ b/fortitude_macros/src/rule_namespace.rs
@@ -25,6 +25,7 @@ pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<proc_macro2::TokenS
     let mut parsed = Vec::new();
 
     let mut common_prefix_match_arms = quote!();
+    let mut from_prefix_match_arms = quote!();
     let mut name_match_arms = quote!();
     let mut description_match_arms = quote!();
 
@@ -81,6 +82,7 @@ pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<proc_macro2::TokenS
 
         if let [prefix] = &prefixes[..] {
             common_prefix_match_arms.extend(quote! { Self::#variant_ident => #prefix, });
+            from_prefix_match_arms.extend(quote! { #prefix => Ok(Self::#variant_ident), });
         } else {
             // There is more than one prefix. We already previously asserted
             // that prefixes of the same variant don't start with the same character
@@ -109,6 +111,14 @@ pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<proc_macro2::TokenS
                 if let Ok(variant) = Self::from_str(code) {
                     return Some((variant, ""));
                 }
+                if let Ok(rule_from_long_name) = Rule::from_str(code) {
+                    let noqa = rule_from_long_name.noqa_code();
+                    let prefix = noqa.prefix();
+                    let suffix = noqa.suffix();
+                    if let Ok(variant) = Self::from_prefix(prefix) {
+                        return Some((variant, suffix));
+                    }
+                }
                 #if_statements
                 None
             }
@@ -117,6 +127,13 @@ pub(crate) fn derive_impl(input: DeriveInput) -> syn::Result<proc_macro2::TokenS
                 match self { #common_prefix_match_arms }
             }
 
+            fn from_prefix(prefix: &str) -> Result<Self, &'static str> {
+                match prefix {
+                    #from_prefix_match_arms
+                    _ => Err("Unknown prefix")
+                }
+            }
+
             fn name(&self) -> &'static str {
                 match self { #name_match_arms }
             }


Reviews:
user: login: ZedThree
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-07T16:36:52Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T10:16:06Z
user: login: ZedThree
body: LGTM, thanks! One extra thing might be to add the `character*(*)` form to this test, and `character*N` to the other rule, to check we're not getting duplicate warnings?
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T10:21:49Z
user: login: imciner2
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T15:49:45Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T16:51:40Z
user: login: ZedThree
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T17:07:14Z
user: login: ZedThree
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T17:08:07Z
user: login: ZedThree
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T17:11:06Z
user: login: ZedThree
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T17:11:28Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T17:17:13Z
user: login: ZedThree
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T17:22:00Z
user: login: ZedThree
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-10T17:22:41Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T17:34:40Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T17:47:02Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-10T17:51:36Z
user: login: ZedThree
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-12T15:10:56Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T15:50:37Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T15:54:46Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T15:55:22Z
user: login: LiamPattinson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-12T15:56:34Z
user: login: ZedThree
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-12T17:43:39Z

Review comments:
diff_hunk: @@ -0,0 +1,67 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct OldStyleCharacterSyntax {
+    length: String,
+}
+
+impl Violation for OldStyleCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { length } = self;
+        format!("'character*{length}' uses outdated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { length, .. } = self;
+        Some(format!("Replace with 'character(len={length})'"))
+    }
+}
+
+impl AstRule for OldStyleCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Only applies to `character`
+        if node.child(0)?.kind().to_lowercase() != "character" {
+            return None;
+        }
+
+        if let Some(kind) = node.child_by_field_name("kind") {
path: fortitude/src/rules/modernisation/old_style_character_syntax.rs
user: login: ZedThree
body: You can use:

```suggestion
        let kind = node.child_by_field_name("kind")?;
```

to remove the `if` and reduce indentation
created_at: 2025-03-07T16:36:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,67 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct OldStyleCharacterSyntax {
+    length: String,
+}
+
+impl Violation for OldStyleCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { length } = self;
+        format!("'character*{length}' uses outdated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { length, .. } = self;
+        Some(format!("Replace with 'character(len={length})'"))
+    }
+}
+
+impl AstRule for OldStyleCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Only applies to `character`
+        if node.child(0)?.kind().to_lowercase() != "character" {
+            return None;
+        }
+
+        if let Some(kind) = node.child_by_field_name("kind") {
path: fortitude/src/rules/modernisation/old_style_character_syntax.rs
user: login: LiamPattinson
body: Good point, there were actually two levels of indentation I could remove here. Done in https://github.com/PlasmaFAIR/fortitude/pull/354/commits/db8879e415df8c8560874c81ff09587465afafcd.
created_at: 2025-03-10T10:16:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,85 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses outdated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { length, .. } = self;
+        Some(format!("Replace with 'character(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        if node.child(0)?.kind().to_lowercase() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // Look for a kind that begins with a '*' and is followed by a length.
+        // The length itself might be within any number of parentheses, and may
+        // be either a number literal or any number of alphanumeric or
+        // underscore characters (by the standard it should be just a number
+        // literal, but preprocessor macros may be used with some compilers).
+        // Alternatively, the initial '*' may be followed by '(*)' with any
+        // amount of whitespace between each character. In this case there
+        // must strictly be only one set of parentheses.
+        // If 'kind' field doesn't match the regex, exit early
+        let (_, length, star) = regex_captures!(
+            r#"^\*(?:[\s\(]*([[:word:]]+)[\s\)]*|\s*\(\s*(\*)\s*\))$"#,
+            kind_text
+        )?;
+        // Only one of length or star should be present. The other will be empty.
+        let length = if length.is_empty() { star } else { length };
+
+        let original = node.to_text(src)?;
+        let replacement = format!("character(len={})", length);
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: imciner2
body: There is the function `match_original_case` (https://github.com/PlasmaFAIR/fortitude/blob/45ac087ee67855f932b7bca247c6651530e17e3f/fortitude/src/rules/utilities.rs#L5) that will match the cases using simple heuristics (at this point, just matching the case of the first character), so it would at least preserve all uppercase and all lowercase keywords. (anything more complicated probably needs to be implemented in each rule).
created_at: 2025-03-10T15:49:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,85 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses outdated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { length, .. } = self;
+        Some(format!("Replace with 'character(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        if node.child(0)?.kind().to_lowercase() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // Look for a kind that begins with a '*' and is followed by a length.
+        // The length itself might be within any number of parentheses, and may
+        // be either a number literal or any number of alphanumeric or
+        // underscore characters (by the standard it should be just a number
+        // literal, but preprocessor macros may be used with some compilers).
+        // Alternatively, the initial '*' may be followed by '(*)' with any
+        // amount of whitespace between each character. In this case there
+        // must strictly be only one set of parentheses.
+        // If 'kind' field doesn't match the regex, exit early
+        let (_, length, star) = regex_captures!(
+            r#"^\*(?:[\s\(]*([[:word:]]+)[\s\)]*|\s*\(\s*(\*)\s*\))$"#,
+            kind_text
+        )?;
+        // Only one of length or star should be present. The other will be empty.
+        let length = if length.is_empty() { star } else { length };
+
+        let original = node.to_text(src)?;
+        let replacement = format!("character(len={})", length);
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: LiamPattinson
body: Cheers, but I just figured out that I was already checking `dtype.lowercase() == "character"`, and so with `dtype.to_text(src)` I could copy it across directly. At some point we should probably add a `mixed-case` rule to prevent people writing things like `Character` or `ChArActEr`.
created_at: 2025-03-10T16:51:40Z
author_association: COLLABORATOR
diff_hunk: @@ -57,6 +57,10 @@ static REDIRECTS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(
         ("S041", "MOD011"),
         ("S051", "MOD021"),
         ("S021", "C141"),
+        (
+            "deprecated-assumed-size-character",
+            "deprecated-character-syntax",
path: fortitude/src/rule_redirects.rs
user: login: ZedThree
body: Nice! I'm glad we can do this!
created_at: 2025-03-10T17:07:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,115 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`,
+/// 'character*(*)' and 'character*(expression)'.
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    dtype: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses deprecated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { dtype, length, .. } = self;
+        Some(format!("Replace with '{dtype}(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        let dtype = node.child(0)?;
+        if dtype.kind() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // If kind does not start with '*', exit early
+        if !kind_text.starts_with('*') {
+            return None;
+        }
+
+        // The '*' should be followed by:
+        // - An integer literal
+        // - '(*)'
+        // - An integer expression within parentheses
+        // To test for the latter, we need to consider two cases. If some
+        // arithmetic takes place, we need to check for a math_expression node
+        // within the kind node. If there isn't, and the kind is instead something
+        // like '(3)', '(N)', or '((((4))))', we need to handle that with regex.
+        let length = match kind
+            .named_descendants()
+            .find(|n| n.kind() == "math_expression")
+        {
+            Some(math_expression) => math_expression.to_text(src)?.to_string(),
+            _ => {
+                // If there is no math_expression node, this may be an integer
+                // literal or '(*)', or it may also be something like '(N)', or
+                // '((((4))))'.
+                // An arbitrary amount of whitespace is permitted.
+                // Strangely, plain '*N' is not allowed.
+                let (_, length, star, expression) = regex_captures!(
+                    r#"^\*(?:\s*(\d+)|\s*\(\s*(\*)\s*\)|\([\s\(]*([[:word:]]+)[\s\)]*\))$"#,
+                    kind_text
+                )?;
+                // Only one of length, star, or expression should be present. The others will be empty.
+                if !length.is_empty() {
+                    length.to_string()
+                } else if !star.is_empty() {
+                    star.to_string()
+                } else if !expression.is_empty() {
+                    expression.to_string()
+                } else {
+                    panic!("This should not happen");
+                }
+            }
+        };
+
+        let original = node.to_text(src)?.to_string();
+        let dtype = dtype.to_text(src)?.to_string();
+        let replacement = format!("{}(len={})", dtype, length);
+        let fix = Fix::safe_edit(node.edit_replacement(source_file, replacement));
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: ZedThree
body: If this is a safe edit, should it be an `AlwaysFixableViolation`?
created_at: 2025-03-10T17:08:07Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,115 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`,
+/// 'character*(*)' and 'character*(expression)'.
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    dtype: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses deprecated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { dtype, length, .. } = self;
+        Some(format!("Replace with '{dtype}(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        let dtype = node.child(0)?;
+        if dtype.kind() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // If kind does not start with '*', exit early
+        if !kind_text.starts_with('*') {
+            return None;
+        }
+
+        // The '*' should be followed by:
+        // - An integer literal
+        // - '(*)'
+        // - An integer expression within parentheses
+        // To test for the latter, we need to consider two cases. If some
+        // arithmetic takes place, we need to check for a math_expression node
+        // within the kind node. If there isn't, and the kind is instead something
+        // like '(3)', '(N)', or '((((4))))', we need to handle that with regex.
+        let length = match kind
+            .named_descendants()
+            .find(|n| n.kind() == "math_expression")
+        {
+            Some(math_expression) => math_expression.to_text(src)?.to_string(),
+            _ => {
+                // If there is no math_expression node, this may be an integer
+                // literal or '(*)', or it may also be something like '(N)', or
+                // '((((4))))'.
+                // An arbitrary amount of whitespace is permitted.
+                // Strangely, plain '*N' is not allowed.
+                let (_, length, star, expression) = regex_captures!(
+                    r#"^\*(?:\s*(\d+)|\s*\(\s*(\*)\s*\)|\([\s\(]*([[:word:]]+)[\s\)]*\))$"#,
+                    kind_text
+                )?;
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: ZedThree
body: Sort of surprised we have to go for a regex here. Is the tree very complicated at this point?
created_at: 2025-03-10T17:11:05Z
author_association: MEMBER
diff_hunk: @@ -57,6 +57,10 @@ static REDIRECTS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(
         ("S041", "MOD011"),
         ("S051", "MOD021"),
         ("S021", "C141"),
+        (
+            "deprecated-assumed-size-character",
+            "deprecated-character-syntax",
path: fortitude/src/rule_redirects.rs
user: login: LiamPattinson
body: Oddly, it doesn't raise a redirect warning. I didn't look into it deeply enough to figure out why. Should I try to get that fixed before the merge?
created_at: 2025-03-10T17:17:13Z
author_association: COLLABORATOR
diff_hunk: @@ -57,6 +57,10 @@ static REDIRECTS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(
         ("S041", "MOD011"),
         ("S051", "MOD021"),
         ("S021", "C141"),
+        (
+            "deprecated-assumed-size-character",
+            "deprecated-character-syntax",
path: fortitude/src/rule_redirects.rs
user: login: ZedThree
body: Hmm, a little suspicious. Does it actually work?
created_at: 2025-03-10T17:22:00Z
author_association: MEMBER
diff_hunk: @@ -57,6 +57,10 @@ static REDIRECTS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(
         ("S041", "MOD011"),
         ("S051", "MOD021"),
         ("S021", "C141"),
+        (
+            "deprecated-assumed-size-character",
+            "deprecated-character-syntax",
path: fortitude/src/rule_redirects.rs
user: login: ZedThree
body: I mean, the fix might be best in another PR, but it would be good to check if it's a bug
created_at: 2025-03-10T17:22:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,115 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`,
+/// 'character*(*)' and 'character*(expression)'.
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    dtype: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses deprecated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { dtype, length, .. } = self;
+        Some(format!("Replace with '{dtype}(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        let dtype = node.child(0)?;
+        if dtype.kind() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // If kind does not start with '*', exit early
+        if !kind_text.starts_with('*') {
+            return None;
+        }
+
+        // The '*' should be followed by:
+        // - An integer literal
+        // - '(*)'
+        // - An integer expression within parentheses
+        // To test for the latter, we need to consider two cases. If some
+        // arithmetic takes place, we need to check for a math_expression node
+        // within the kind node. If there isn't, and the kind is instead something
+        // like '(3)', '(N)', or '((((4))))', we need to handle that with regex.
+        let length = match kind
+            .named_descendants()
+            .find(|n| n.kind() == "math_expression")
+        {
+            Some(math_expression) => math_expression.to_text(src)?.to_string(),
+            _ => {
+                // If there is no math_expression node, this may be an integer
+                // literal or '(*)', or it may also be something like '(N)', or
+                // '((((4))))'.
+                // An arbitrary amount of whitespace is permitted.
+                // Strangely, plain '*N' is not allowed.
+                let (_, length, star, expression) = regex_captures!(
+                    r#"^\*(?:\s*(\d+)|\s*\(\s*(\*)\s*\)|\([\s\(]*([[:word:]]+)[\s\)]*\))$"#,
+                    kind_text
+                )?;
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: LiamPattinson
body: Looking into it, I don't know if we strictly need to. The tree does look quite strange, with `character*(expression)` having an `assumed_size` node even if expression is not `*`. I'll see if I can simplify things.
created_at: 2025-03-10T17:34:40Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,115 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`,
+/// 'character*(*)' and 'character*(expression)'.
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    dtype: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses deprecated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { dtype, length, .. } = self;
+        Some(format!("Replace with '{dtype}(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        let dtype = node.child(0)?;
+        if dtype.kind() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // If kind does not start with '*', exit early
+        if !kind_text.starts_with('*') {
+            return None;
+        }
+
+        // The '*' should be followed by:
+        // - An integer literal
+        // - '(*)'
+        // - An integer expression within parentheses
+        // To test for the latter, we need to consider two cases. If some
+        // arithmetic takes place, we need to check for a math_expression node
+        // within the kind node. If there isn't, and the kind is instead something
+        // like '(3)', '(N)', or '((((4))))', we need to handle that with regex.
+        let length = match kind
+            .named_descendants()
+            .find(|n| n.kind() == "math_expression")
+        {
+            Some(math_expression) => math_expression.to_text(src)?.to_string(),
+            _ => {
+                // If there is no math_expression node, this may be an integer
+                // literal or '(*)', or it may also be something like '(N)', or
+                // '((((4))))'.
+                // An arbitrary amount of whitespace is permitted.
+                // Strangely, plain '*N' is not allowed.
+                let (_, length, star, expression) = regex_captures!(
+                    r#"^\*(?:\s*(\d+)|\s*\(\s*(\*)\s*\)|\([\s\(]*([[:word:]]+)[\s\)]*\))$"#,
+                    kind_text
+                )?;
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: LiamPattinson
body: Neat, managed to get it down to just a few lines in https://github.com/PlasmaFAIR/fortitude/pull/354/commits/e101e5bd1e012de0c0426ba3b69167af0b778956.

This isn't at all urgent or necessary, but could the `tree-sitter` grammar be amended so that the first `assumed_size` node is instead something like `character_length_operator`?
created_at: 2025-03-10T17:47:02Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,115 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`,
+/// 'character*(*)' and 'character*(expression)'.
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    dtype: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses deprecated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { dtype, length, .. } = self;
+        Some(format!("Replace with '{dtype}(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        let dtype = node.child(0)?;
+        if dtype.kind() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // If kind does not start with '*', exit early
+        if !kind_text.starts_with('*') {
+            return None;
+        }
+
+        // The '*' should be followed by:
+        // - An integer literal
+        // - '(*)'
+        // - An integer expression within parentheses
+        // To test for the latter, we need to consider two cases. If some
+        // arithmetic takes place, we need to check for a math_expression node
+        // within the kind node. If there isn't, and the kind is instead something
+        // like '(3)', '(N)', or '((((4))))', we need to handle that with regex.
+        let length = match kind
+            .named_descendants()
+            .find(|n| n.kind() == "math_expression")
+        {
+            Some(math_expression) => math_expression.to_text(src)?.to_string(),
+            _ => {
+                // If there is no math_expression node, this may be an integer
+                // literal or '(*)', or it may also be something like '(N)', or
+                // '((((4))))'.
+                // An arbitrary amount of whitespace is permitted.
+                // Strangely, plain '*N' is not allowed.
+                let (_, length, star, expression) = regex_captures!(
+                    r#"^\*(?:\s*(\d+)|\s*\(\s*(\*)\s*\)|\([\s\(]*([[:word:]]+)[\s\)]*\))$"#,
+                    kind_text
+                )?;
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: LiamPattinson
body: It also turns out that this was necessary to capture `character*(:)` too!
created_at: 2025-03-10T17:51:36Z
author_association: COLLABORATOR
diff_hunk: @@ -58,10 +58,8 @@ pub(crate) struct RedirectedAllowComment {
 impl AlwaysFixableViolation for RedirectedAllowComment {
     #[derive_message_formats]
     fn message(&self) -> String {
-        let Self {
-            original, new_code, ..
-        } = self;
-        format!("`{original}` is a redirect to `{new_code}`")
+        let Self { original, .. } = self;
+        format!("`{original}` has been redirected")
path: fortitude/src/rules/fortitude/allow_comments.rs
user: login: ZedThree
body: Have we dropped this because we don't know (at this point, at least) if it was a code or a name that's triggered the redirect?

The fix will include the correct one, so this isn't so important, but how difficult would it be to get the redirect here too?
created_at: 2025-03-12T15:10:56Z
author_association: MEMBER
diff_hunk: @@ -58,10 +58,8 @@ pub(crate) struct RedirectedAllowComment {
 impl AlwaysFixableViolation for RedirectedAllowComment {
     #[derive_message_formats]
     fn message(&self) -> String {
-        let Self {
-            original, new_code, ..
-        } = self;
-        format!("`{original}` is a redirect to `{new_code}`")
+        let Self { original, .. } = self;
+        format!("`{original}` has been redirected")
path: fortitude/src/rules/fortitude/allow_comments.rs
user: login: LiamPattinson
body: Whoops, I changed that on my first pass at fixing this up and forgot to come back to it. It'll be easy enough to fix.
created_at: 2025-03-12T15:50:37Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,115 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`,
+/// 'character*(*)' and 'character*(expression)'.
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    dtype: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses deprecated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { dtype, length, .. } = self;
+        Some(format!("Replace with '{dtype}(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        let dtype = node.child(0)?;
+        if dtype.kind() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // If kind does not start with '*', exit early
+        if !kind_text.starts_with('*') {
+            return None;
+        }
+
+        // The '*' should be followed by:
+        // - An integer literal
+        // - '(*)'
+        // - An integer expression within parentheses
+        // To test for the latter, we need to consider two cases. If some
+        // arithmetic takes place, we need to check for a math_expression node
+        // within the kind node. If there isn't, and the kind is instead something
+        // like '(3)', '(N)', or '((((4))))', we need to handle that with regex.
+        let length = match kind
+            .named_descendants()
+            .find(|n| n.kind() == "math_expression")
+        {
+            Some(math_expression) => math_expression.to_text(src)?.to_string(),
+            _ => {
+                // If there is no math_expression node, this may be an integer
+                // literal or '(*)', or it may also be something like '(N)', or
+                // '((((4))))'.
+                // An arbitrary amount of whitespace is permitted.
+                // Strangely, plain '*N' is not allowed.
+                let (_, length, star, expression) = regex_captures!(
+                    r#"^\*(?:\s*(\d+)|\s*\(\s*(\*)\s*\)|\([\s\(]*([[:word:]]+)[\s\)]*\))$"#,
+                    kind_text
+                )?;
+                // Only one of length, star, or expression should be present. The others will be empty.
+                if !length.is_empty() {
+                    length.to_string()
+                } else if !star.is_empty() {
+                    star.to_string()
+                } else if !expression.is_empty() {
+                    expression.to_string()
+                } else {
+                    panic!("This should not happen");
+                }
+            }
+        };
+
+        let original = node.to_text(src)?.to_string();
+        let dtype = dtype.to_text(src)?.to_string();
+        let replacement = format!("{}(len={})", dtype, length);
+        let fix = Fix::safe_edit(node.edit_replacement(source_file, replacement));
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: LiamPattinson
body: Fixed in https://github.com/PlasmaFAIR/fortitude/pull/354/commits/13e47d3082b8434a378e83396ddd456633562187, forgot about this one for a while!
created_at: 2025-03-12T15:54:46Z
author_association: COLLABORATOR
diff_hunk: @@ -58,10 +58,8 @@ pub(crate) struct RedirectedAllowComment {
 impl AlwaysFixableViolation for RedirectedAllowComment {
     #[derive_message_formats]
     fn message(&self) -> String {
-        let Self {
-            original, new_code, ..
-        } = self;
-        format!("`{original}` is a redirect to `{new_code}`")
+        let Self { original, .. } = self;
+        format!("`{original}` has been redirected")
path: fortitude/src/rules/fortitude/allow_comments.rs
user: login: LiamPattinson
body: Fixed in https://github.com/PlasmaFAIR/fortitude/pull/354/commits/e5ece1e2745b174b902c4452ff69c26a3a86a171
created_at: 2025-03-12T15:55:22Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,115 @@
+use crate::ast::FortitudeNode;
+use crate::settings::Settings;
+use crate::{AstRule, FromAstNode};
+use lazy_regex::regex_captures;
+use ruff_diagnostics::{Diagnostic, Fix, Violation};
+use ruff_macros::{derive_message_formats, ViolationMetadata};
+use ruff_source_file::SourceFile;
+use tree_sitter::Node;
+
+/// ## What does it do?
+/// Checks for outdated declarations of `character*N`,
+/// 'character*(*)' and 'character*(expression)'.
+///
+/// ## Why is this bad?
+/// The syntax `character*N` has been replaced by `character(len=N)` in modern
+/// Fortran. Prefer the second form.
+#[derive(ViolationMetadata)]
+pub(crate) struct DeprecatedCharacterSyntax {
+    original: String,
+    dtype: String,
+    length: String,
+}
+
+impl Violation for DeprecatedCharacterSyntax {
+    #[derive_message_formats]
+    fn message(&self) -> String {
+        let Self { original, .. } = self;
+        format!("'{original}' uses deprecated syntax")
+    }
+
+    fn fix_title(&self) -> Option<String> {
+        let Self { dtype, length, .. } = self;
+        Some(format!("Replace with '{dtype}(len={length})'"))
+    }
+}
+
+impl AstRule for DeprecatedCharacterSyntax {
+    fn check(
+        _settings: &Settings,
+        node: &Node,
+        source_file: &SourceFile,
+    ) -> Option<Vec<Diagnostic>> {
+        let src = source_file.source_text();
+
+        // Rule only applies to `character`.
+        // Expect child(0) to always be present.
+        let dtype = node.child(0)?;
+        if dtype.kind() != "character" {
+            return None;
+        }
+
+        // If 'kind' field isn't present, exit early
+        let kind = node.child_by_field_name("kind")?;
+        let kind_text = kind.to_text(src)?;
+
+        // If kind does not start with '*', exit early
+        if !kind_text.starts_with('*') {
+            return None;
+        }
+
+        // The '*' should be followed by:
+        // - An integer literal
+        // - '(*)'
+        // - An integer expression within parentheses
+        // To test for the latter, we need to consider two cases. If some
+        // arithmetic takes place, we need to check for a math_expression node
+        // within the kind node. If there isn't, and the kind is instead something
+        // like '(3)', '(N)', or '((((4))))', we need to handle that with regex.
+        let length = match kind
+            .named_descendants()
+            .find(|n| n.kind() == "math_expression")
+        {
+            Some(math_expression) => math_expression.to_text(src)?.to_string(),
+            _ => {
+                // If there is no math_expression node, this may be an integer
+                // literal or '(*)', or it may also be something like '(N)', or
+                // '((((4))))'.
+                // An arbitrary amount of whitespace is permitted.
+                // Strangely, plain '*N' is not allowed.
+                let (_, length, star, expression) = regex_captures!(
+                    r#"^\*(?:\s*(\d+)|\s*\(\s*(\*)\s*\)|\([\s\(]*([[:word:]]+)[\s\)]*\))$"#,
+                    kind_text
+                )?;
+                // Only one of length, star, or expression should be present. The others will be empty.
+                if !length.is_empty() {
+                    length.to_string()
+                } else if !star.is_empty() {
+                    star.to_string()
+                } else if !expression.is_empty() {
+                    expression.to_string()
+                } else {
+                    panic!("This should not happen");
+                }
+            }
+        };
+
+        let original = node.to_text(src)?.to_string();
+        let dtype = dtype.to_text(src)?.to_string();
+        let replacement = format!("{}(len={})", dtype, length);
+        let fix = Fix::safe_edit(node.edit_replacement(source_file, replacement));
path: fortitude/src/rules/obsolescent/deprecated_character_syntax.rs
user: login: LiamPattinson
body: Correction, fixed in https://github.com/PlasmaFAIR/fortitude/pull/354/commits/86af927ddc0694de31507664dbc50e4ed7dc144f, I forgot to update the docs before pushing.
created_at: 2025-03-12T15:56:34Z
author_association: COLLABORATOR

PR comments:
user: login: ZedThree
created_at: 2025-03-07T16:41:08Z
body: I think this is actually just a variation on `DeprecatedAssumedSizeCharacter` and should be in there. gfortran actually only warns that this form is obsolescent, and not `character*(*)` (or the even weirder `character a(N)*N`)
author_association: MEMBER
user: login: LiamPattinson
created_at: 2025-03-10T09:53:43Z
body: You're right, Modern Fortran Explained lists this as obsolescent. I'll move it over.
author_association: COLLABORATOR
user: login: LiamPattinson
created_at: 2025-03-10T10:58:15Z
body: Good thing you asked me to do that, as it turns out there are a bunch of false positives between the two rules!
author_association: COLLABORATOR
user: login: LiamPattinson
created_at: 2025-03-10T11:15:58Z
body: I've also found that the test `OB061`/`deprecated-assumed-size-character` doesn't compile, as while tree-sitter seems happy to accept syntax like `character*(len=*)`, this isn't actually valid Fortran.
author_association: COLLABORATOR
user: login: ZedThree
created_at: 2025-03-10T11:19:44Z
body: I think it's actually best to squash them into the same rule.

The clause about the obsolescent form is:

> B.3.8 `CHARACTER*` form of CHARACTER declaration
> In addition to the `CHARACTER*char-length` form introduced in Fortran 77, Fortran 90 provided the `CHARACTER([ LEN = ] type-param-value)` form. The older form `(CHARACTER*char-length)` is redundant.

If we look at the rule for `Character type specifier` (this is the 2023 standard):

```
R721 char-selector  is length-selector
                    or ( LEN = type-param-value ,
                         KIND = scalar-int-constant-expr )
                    or ( type-param-value ,
                         [ KIND = ] scalar-int-constant-expr )
                    or ( KIND = scalar-int-constant-expr
                         [ , LEN =type-param-value ] )

R722 length-selector is ( [ LEN = ] type-param-value )
                     or * char-length [ , ]
R723 char-length     is ( type-param-value )
                     or int-literal-constant
```

and:

```
R701 type-param-value is scalar-int-expr
                      or *
                      or :
```

so we can have `* char-length` with `char-length` as `(*)` or `5` for instance, and they're both forms of the obsolete rule. You also can't tell from the text above, but in the standard `* char-length` in R722 is in small case, indicating that it's obsolescent.

This means that `DeprecatedAssumedSizeCharacter` is not really the correct name for the rule, as it also applies to fixed-size characters.

I'm not sure if this also means that the `character :: foo(N)*N` form is also the same rule, or if that's just some non-standard thing. Looking further through the standard, there's this bit for `entity-decl`:

```
R803 entity-decl  is  object-name [ ( array-spec ) ]
                        [ lbracket coarray-spec rbracket ]
                        [ * char-length ] [ initialization ]
                      or function-name [ * char-length ]
```

Here `* char-length` is not in small case, and I think a really strict reading of B.3.8 means that it's not obsolescent?! I guess because this might be the only way to have multiple `character` variables with different lengths declared on the same line
author_association: MEMBER
user: login: LiamPattinson
created_at: 2025-03-10T11:28:52Z
body: Oh wow, that's absolutely awful... Just checked and the following compiles and runs just fine:

```f90
program p
  implicit none

  character*((5)) foo
  foo = "hello"
  call my_print(foo)

contains

  subroutine my_print(foo)
    character*(*) foo, bar*5
    bar = "world"
    print *, foo, bar
  end subroutine my_print

end program p
```

I think I'd quite like a rule stopping the use of `bar*5` like that. I can't imagine a scenario where that would be preferable to just moving it to its own line.

author_association: COLLABORATOR
user: login: ZedThree
created_at: 2025-03-10T11:44:45Z
body: Made a post on fortran-lang to get some outside opinion: https://fortran-lang.discourse.group/t/is-character-foo-n-n-obsolescent/9325
author_association: MEMBER
user: login: LiamPattinson
created_at: 2025-03-10T12:11:26Z
body: I've merged the two rules. It now targets the `intrinsic_type` endpoint rather than the `assumed_size` endpoint, and only reports once if there are multiple declarations on the same line. Let me know what you think.

Due to the rule name change and the altered behaviour, this will count as a breaking change, albeit a minor one.
author_association: COLLABORATOR
user: login: LiamPattinson
created_at: 2025-03-10T14:47:29Z
body: Another `tree-sitter-fortran` preprocessor issue I encountered:

```f90
#define MAX_LEN 64

character*MAX_LEN x    ! Fails with a syntax error
character*(MAX_LEN) y  ! Passes
```

Not sure if it's worth fixing this in the grammar.

---

[Edit] On re-reading the standard, I see now why it works this way, and it has nothing to do with the preprocessor:

```f90
integer, parameter :: N = 64

character*N foo    ! Fails
character*(N) bar  ! Okay
```

I'll update the test and comments to reflect this

---

[EditEdit] Never mind, my regex can't handle arbitrary integer expressions, this will take a while longer...
author_association: COLLABORATOR
user: login: LiamPattinson
created_at: 2025-03-10T17:00:37Z
body: I think I'm finally there. As usual, this turned into a much more complicated task than I'd expected!
author_association: COLLABORATOR
user: login: ZedThree
created_at: 2025-03-12T09:32:21Z
body: How breaking is this change, actually? With the name redirect, users should just get a warning, right?
author_association: MEMBER
user: login: LiamPattinson
created_at: 2025-03-12T10:02:25Z
body: The name change doesn't generate a warning in this case because the short code hasn't changed. I could fix that, but we would need to tweak `RuleSelector::from_str`.

I guess it could be considered a breaking change because an existing 'stable' rule is now behaving differently. I'd personally be tempted to just call this a bugfix and release it in v0.7.1 after we've cleaned up a few other outstanding issues.
author_association: COLLABORATOR
user: login: imciner2
created_at: 2025-03-12T10:09:10Z
body: Since the name changed, I guess that would break any allow comments in existing code? Would there be a way to alias them somehow? 
author_association: CONTRIBUTOR
user: login: LiamPattinson
created_at: 2025-03-12T10:17:34Z
body: > Since the name changed, I guess that would break any allow comments in existing code? Would there be a way to alias them somehow?

We have the rule `FORT003` to warn about redirected allow comments, and they should just work. For example:

```f90
! allow(T004)
implicit none
```

Results in:

```bash
test.f90:2:11: FORT003 [*] `T004` is a redirect to `C003`
  |
1 | program p
2 |   ! allow(T004)
  |           ^^^^ FORT003
3 |   implicit none
  |
  = help: Replace with `C003` or `implicit-external-procedures`
```

The allow comment successfully redirects, and the diagnostic for `implicit none (type, external)` isn't raised.


In this case however, it's causing Fortitude to panic somewhere. I guess I'll need to fix this up sooner rather than later.
author_association: COLLABORATOR
user: login: ZedThree
created_at: 2025-03-12T10:21:57Z
body: Allow comments do work with the old name. Testing on the `OB061.F90` test file:

```f90
  ! allow(deprecated-assumed-size-character)
  subroutine char_input(a, b, c, d, e, f, g, h, i, j, k, l, m, n)
```

does indeed allow the violation. However for some reason this only works if I explicitly pass `--select=OB`, even though the rule is allowed?

```
thread 'main' panicked at fortitude/src/allow_comments.rs:138:62:
called `Result::unwrap()` on an `Err` value: Unknown
stack backtrace:
   0: rust_begin_unwind
             at /rustc/9fc6b43126469e3858e2fe86cafb4f0fd5068869/library/std/src/panicking.rs:665:5
   1: core::panicking::panic_fmt
             at /rustc/9fc6b43126469e3858e2fe86cafb4f0fd5068869/library/core/src/panicking.rs:76:14
   2: core::result::unwrap_failed
             at /rustc/9fc6b43126469e3858e2fe86cafb4f0fd5068869/library/core/src/result.rs:1699:5
   3: core::result::Result<T,E>::unwrap
             at /home/peter/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/result.rs:1104:23
   4: fortitude::allow_comments::check_allow_comments
             at ./fortitude/src/allow_comments.rs:138:36
   5: fortitude::check::check_path
             at ./fortitude/src/check.rs:284:23
   6: fortitude::check::check_only_file
             at ./fortitude/src/check.rs:179:22
   7: fortitude::check::check_file
             at ./fortitude/src/check.rs:121:22
   8: fortitude::check::check_files::{{closure}}
             at ./fortitude/src/check.rs:870:19
   9: <rayon::iter::map::MapFolder<C,F> as rayon::iter::plumbing::Folder<T>>::consume
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/map.rs:237:27
  10: <indicatif::rayon::ProgressFolder<C> as rayon::iter::plumbing::Folder<T>>::consume
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/indicatif-0.17.9/src/rayon.rs:186:19
  11: rayon::iter::plumbing::Folder::consume_iter
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/plumbing/mod.rs:178:20
  12: rayon::iter::plumbing::Producer::fold_with
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/plumbing/mod.rs:109:9
  13: rayon::iter::plumbing::bridge_producer_consumer::helper
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/plumbing/mod.rs:437:13
  14: rayon::iter::plumbing::bridge_producer_consumer
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/plumbing/mod.rs:396:12
  15: <rayon::iter::plumbing::bridge::Callback<C> as rayon::iter::plumbing::ProducerCallback<I>>::callback
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/plumbing/mod.rs:372:13
  16: <rayon::slice::Iter<T> as rayon::iter::IndexedParallelIterator>::with_producer
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/slice/mod.rs:826:9
  17: rayon::iter::plumbing::bridge
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/plumbing/mod.rs:356:12
  18: <rayon::slice::Iter<T> as rayon::iter::ParallelIterator>::drive_unindexed
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/slice/mod.rs:802:9
  19: indicatif::rayon::<impl rayon::iter::ParallelIterator for indicatif::iter::ProgressBarIter<T>>::drive_unindexed
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/indicatif-0.17.9/src/rayon.rs:205:9
  20: <rayon::iter::map::Map<I,F> as rayon::iter::ParallelIterator>::drive_unindexed
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/map.rs:49:9
  21: <rayon::iter::fold::Fold<I,ID,F> as rayon::iter::ParallelIterator>::drive_unindexed
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/fold.rs:59:9
  22: rayon::iter::reduce::reduce
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/reduce.rs:15:5
  23: rayon::iter::ParallelIterator::reduce
             at /home/peter/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rayon-1.10.0/src/iter/mod.rs:998:9
  24: fortitude::check::check_files
             at ./fortitude/src/check.rs:839:23
  25: fortitude::check::check
             at ./fortitude/src/check.rs:735:9
  26: fortitude::main
             at ./fortitude/src/main.rs:18:43
  27: core::ops::function::FnOnce::call_once
             at /home/peter/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
```

Ah, the problem is `redirected-allow-comment (FORT003)`. Turning that off avoids the panic. So there's a bug in there -- probably we're only expecting codes and not names!
author_association: MEMBER
user: login: LiamPattinson
created_at: 2025-03-12T11:58:58Z
body: @ZedThree @imciner2 I think I have it fixed now. The bug went deeper than just `FORT003` itself, so I've overhauled how rule long names are handled. Previously, `RuleSelector::from_str` would test if a rule could be parsed from the long name as a string (`line-too-long` -> `LinetooLong`), convert to the short code (`S001`), and recursively call `from_str` again from the short code. Now, `Category::parse_code` can handle the long name directly. This means redirect warnings are now raised as expected.

I've also rewritten a bit of `FORT003`, as it was repeating the long name of `deprecated-assumed-size-character` twice in the help text, and I've updated the redirect warnings to ensure long-name redirects are reported correctly.

Now that I've figured out a way to handle long names with the macros, it might be possible to push this functionality back into Ruff.
author_association: COLLABORATOR
user: login: ZedThree
created_at: 2025-03-12T15:16:32Z
body: > Now that I've figured out a way to handle long names with the macros, it might be possible to push this functionality back into Ruff.

That would be really cool!

Thanks Liam, LGTM, just a couple of questions left unresolved above, but happy to merge this.
author_association: MEMBER
user: login: ZedThree
created_at: 2025-03-12T17:44:48Z
body: Although you've also fixed a bug here, I don't think it was possible for it to manifest previously, so we probably don't need a separate changelog entry for that?
author_association: MEMBER
user: login: LiamPattinson
created_at: 2025-03-13T12:19:11Z
body: I don't think it could, as previously all redirects were short code -> short code, so I don't think it needs flagging up as a bug-fix.
author_association: COLLABORATOR