Skip to main content

mdwright_lint/stdlib/
unicodeable_subscript.rs

1//! Braced super/subscript with a Unicode single-codepoint equivalent.
2//!
3//! `f^{-1}` reads more clearly as `f⁻¹` once the project commits to
4//! Unicode mathematics. The rule recognises the closed set
5//! `{^{-1}, ^{-d}, ^{0..9}, _{0..9}, ^{n,i}, _{n,i}}` and offers a
6//! safe autofix. Advisory: informational, not a defect.
7
8use std::sync::OnceLock;
9
10use regex::Regex;
11
12use crate::diagnostic::{Diagnostic, Fix};
13use crate::regex_util::compile_static;
14use crate::rule::LintRule;
15use mdwright_document::Document;
16use mdwright_latex::{unicode_sub, unicode_super};
17
18pub struct UnicodeableSubscript;
19
20fn pattern() -> &'static Regex {
21    static RE: OnceLock<Regex> = OnceLock::new();
22    RE.get_or_init(|| {
23        compile_static(
24            r"\^\{-1\}|\^\{-(?P<sneg>[0-9])\}|\^\{(?P<sd>[0-9])\}|_\{(?P<bd>[0-9])\}|\^\{n\}|_\{n\}|\^\{i\}|_\{i\}",
25        )
26    })
27}
28
29impl LintRule for UnicodeableSubscript {
30    fn name(&self) -> &str {
31        "unicodeable-subscript"
32    }
33
34    fn description(&self) -> &str {
35        "Braced super/subscript that has a single-codepoint Unicode form."
36    }
37
38    fn explain(&self) -> &str {
39        include_str!("explain/unicodeable_subscript.md")
40    }
41
42    fn produces_fix(&self) -> bool {
43        true
44    }
45
46    fn is_advisory(&self) -> bool {
47        true
48    }
49
50    fn check(&self, doc: &Document, out: &mut Vec<Diagnostic>) {
51        for chunk in doc.prose_chunks() {
52            for cap in pattern().captures_iter(&chunk.text) {
53                let Some(m) = cap.get(0) else { continue };
54                let matched = m.as_str();
55                let replacement = match matched {
56                    "^{-1}" => "⁻¹".to_owned(),
57                    "^{n}" => "ⁿ".to_owned(),
58                    "_{n}" => "ₙ".to_owned(),
59                    "^{i}" => "ⁱ".to_owned(),
60                    "_{i}" => "ᵢ".to_owned(),
61                    _ => {
62                        if let Some(d) = cap.name("sneg") {
63                            let Some(c) = d.as_str().chars().next() else {
64                                continue;
65                            };
66                            match unicode_super(c) {
67                                Some(u) => format!("⁻{u}"),
68                                None => continue,
69                            }
70                        } else if let Some(d) = cap.name("sd") {
71                            let Some(c) = d.as_str().chars().next() else {
72                                continue;
73                            };
74                            match unicode_super(c) {
75                                Some(u) => u.to_string(),
76                                None => continue,
77                            }
78                        } else if let Some(d) = cap.name("bd") {
79                            let Some(c) = d.as_str().chars().next() else {
80                                continue;
81                            };
82                            match unicode_sub(c) {
83                                Some(u) => u.to_string(),
84                                None => continue,
85                            }
86                        } else {
87                            continue;
88                        }
89                    }
90                };
91                let message = format!("`{matched}` has a Unicode equivalent `{replacement}` — clearer to read");
92                if let Some(d) = Diagnostic::at(
93                    doc,
94                    chunk.byte_offset,
95                    m.range(),
96                    message,
97                    Some(Fix {
98                        replacement,
99                        safe: true,
100                    }),
101                ) {
102                    out.push(d);
103                }
104            }
105        }
106    }
107}