Skip to main content

rumdl_lib/rules/
md089_cjk_spacing.rs

1//! Rule MD089: CJK letters and Latin text should be separated by a space.
2//!
3//! Chinese, Japanese and Korean copywriting guidelines put one space between a
4//! CJK letter and an adjacent ASCII letter or digit (`日本語 english`,
5//! `中文 123`, `한글 english`). The rule reports every such boundary that has
6//! no space and inserts one. It is opt-in: Japanese technical writing largely
7//! prefers the opposite, so the convention is a project's choice.
8//!
9//! Each prose line is split into units: a run of CJK letters, a run of ASCII
10//! letters and digits, one symbol, or an inline construct. The rule looks at
11//! both neighbours of every CJK run. A code span, an inline math span, a link,
12//! a wikilink or a bare URL is one opaque unit that behaves like Latin text: it
13//! gets a space on its outside and its inside is never touched. An image, an
14//! HTML tag, an HTML comment, a `#tag` or a link reference definition is a
15//! wall: the rule neither enters it nor spaces against it. An emphasis
16//! delimiter run is transparent:
17//! `**中**english` compares `中` with `english`, and the space goes on the
18//! outer side of the delimiter, where it keeps the emphasis intact. A
19//! configured symbol counts only when it is attached to a Latin run on its far
20//! side (`90°的` fires, `你好-世界` does not).
21
22mod md089_config;
23#[cfg(test)]
24mod tests;
25
26use std::collections::HashSet;
27use std::sync::LazyLock;
28
29use regex::Regex;
30
31use crate::filtered_lines::FilteredLinesExt;
32use crate::lint_context::LintContext;
33use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
34use crate::utils::obsidian_tag::TAG_PATTERN;
35use crate::utils::range_utils::byte_to_char_count;
36use crate::utils::unicode::is_cjk_letter;
37use md089_config::MD089Config;
38
39/// Rule MD089: CJK spacing.
40#[derive(Debug, Clone)]
41pub struct MD089CjkSpacing {
42    /// Symbols that lead a Latin run and take a space after a CJK letter (`$5`).
43    symbols_after_cjk: HashSet<char>,
44    /// Symbols that trail a Latin run and take a space before a CJK letter (`90°`).
45    symbols_before_cjk: HashSet<char>,
46}
47
48impl Default for MD089CjkSpacing {
49    fn default() -> Self {
50        Self::from_config_struct(MD089Config::default())
51    }
52}
53
54impl MD089CjkSpacing {
55    fn from_config_struct(config: MD089Config) -> Self {
56        let set = |symbols: String| symbols.chars().filter(|c| !c.is_whitespace()).collect();
57        Self {
58            symbols_after_cjk: set(config.symbols_after_cjk),
59            symbols_before_cjk: set(config.symbols_before_cjk),
60        }
61    }
62
63    /// For each unit, whether a CJK letter directly after it needs a space
64    /// (`latin_right`) and whether a CJK letter directly before it needs one
65    /// (`latin_left`). Latin runs and opaque constructs always qualify; a
66    /// configured symbol qualifies only when the unit on its far side does,
67    /// so `90°` and `$5` count while a lone `-` between two CJK words does not.
68    /// Delimiter runs pass the flag through unchanged.
69    fn latin_edges(&self, units: &[Unit]) -> (Vec<bool>, Vec<bool>) {
70        let n = units.len();
71        let mut right = vec![false; n];
72        for i in 0..n {
73            right[i] = match units[i].kind {
74                Kind::Latin | Kind::Opaque => true,
75                Kind::Symbol(c) => i > 0 && right[i - 1] && self.symbols_before_cjk.contains(&c),
76                Kind::Delimiter { .. } => i > 0 && right[i - 1],
77                Kind::Cjk | Kind::Other | Kind::Wall => false,
78            };
79        }
80        let mut left = vec![false; n];
81        for i in (0..n).rev() {
82            left[i] = match units[i].kind {
83                Kind::Latin | Kind::Opaque => true,
84                Kind::Symbol(c) => i + 1 < n && left[i + 1] && self.symbols_after_cjk.contains(&c),
85                Kind::Delimiter { .. } => i + 1 < n && left[i + 1],
86                Kind::Cjk | Kind::Other | Kind::Wall => false,
87            };
88        }
89        (right, left)
90    }
91
92    /// Every missing space on one line, ordered by position.
93    fn missing_spaces(&self, units: &[Unit]) -> Vec<Gap> {
94        let (latin_right, latin_left) = self.latin_edges(units);
95        let is_delimiter = |j: &usize| matches!(units[*j].kind, Kind::Delimiter { .. });
96        let mut gaps = Vec::new();
97        for (k, unit) in units.iter().enumerate() {
98            if unit.kind != Kind::Cjk {
99                continue;
100            }
101            // Latin text to the right of the CJK run.
102            if let Some(j) = (k + 1..units.len()).find(|j| !is_delimiter(j))
103                && latin_left[j]
104            {
105                gaps.push(Gap {
106                    insert_at: first_opener(&units[k + 1..j]).unwrap_or(units[j].start),
107                    left: (unit.start, unit.end),
108                    right: attached_run(units, j, &latin_right, &latin_left, true),
109                });
110            }
111            // Latin text to the left of the CJK run.
112            if let Some(j) = (0..k).rev().find(|j| !is_delimiter(j))
113                && latin_right[j]
114            {
115                gaps.push(Gap {
116                    insert_at: first_opener(&units[j + 1..k]).unwrap_or(unit.start),
117                    left: attached_run(units, j, &latin_right, &latin_left, false),
118                    right: (unit.start, unit.end),
119                });
120            }
121        }
122        gaps.sort_by_key(|gap| gap.insert_at);
123        gaps
124    }
125}
126
127/// How the rule treats one unit of a line.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129enum Kind {
130    /// A run of CJK letters.
131    Cjk,
132    /// A run of ASCII letters and digits.
133    Latin,
134    /// One character that is neither. Whether it joins a Latin run depends on
135    /// the configured symbol sets, so the character travels with the kind.
136    Symbol(char),
137    /// A run of characters the rule never spaces against and never looks
138    /// through: whitespace, and letters or digits of other scripts (`1`,
139    /// `é`, Cyrillic).
140    Other,
141    /// An emphasis delimiter run. Transparent: the rule looks through it.
142    Delimiter { opener: bool },
143    /// An inline construct treated as one Latin-like unit: code span, math
144    /// span, link, wikilink, bare URL.
145    Opaque,
146    /// An inline construct the rule neither enters nor spaces against: image,
147    /// HTML tag, HTML comment, `#tag`.
148    Wall,
149}
150
151/// A unit of a line, as absolute byte offsets into the document.
152#[derive(Debug, Clone, Copy)]
153struct Unit {
154    kind: Kind,
155    start: usize,
156    end: usize,
157}
158
159/// One missing space: where to insert it and the text on either side.
160struct Gap {
161    insert_at: usize,
162    left: (usize, usize),
163    right: (usize, usize),
164}
165
166/// Whether `c` renders as part of the character before it: a combining mark,
167/// which is `Mn` or `Me` and covers the variation selectors that pick a glyph
168/// for the kanji or emoji they follow. A format character is not bound to a
169/// base, so a joiner stays a run breaker rather than trailing a space. ASCII
170/// holds no marks, so the common case answers without the regex.
171fn is_attached_mark(c: char) -> bool {
172    if c.is_ascii() {
173        return false;
174    }
175    static ATTACHED_MARK: LazyLock<Regex> =
176        LazyLock::new(|| Regex::new(r"^[\p{Mn}\p{Me}]$").expect("attached mark class is a valid regex"));
177    let mut buf = [0u8; 4];
178    ATTACHED_MARK.is_match(c.encode_utf8(&mut buf))
179}
180
181fn classify(c: char) -> Kind {
182    if is_cjk_letter(c) {
183        Kind::Cjk
184    } else if c.is_ascii_alphanumeric() {
185        Kind::Latin
186    } else if c.is_whitespace() || c.is_alphanumeric() {
187        Kind::Other
188    } else {
189        Kind::Symbol(c)
190    }
191}
192
193/// The space goes outside the emphasis: before the first opener between the
194/// two units, or, when only closers lie between, at the boundary after them.
195fn first_opener(between: &[Unit]) -> Option<usize> {
196    between
197        .iter()
198        .find(|unit| unit.kind == Kind::Delimiter { opener: true })
199        .map(|unit| unit.start)
200}
201
202/// The byte extent of unit `j` together with every consecutive unit attached
203/// to it, so that `90°` or `"hello"` reads as one thing in the message. A
204/// symbol earns its place in the run through either edge array (`C++` holds
205/// together because `+` has Latin on its left, `++C` because `+` has Latin on
206/// its right), so both are checked at every step; passing one alone would
207/// stop the walk at the run's far end.
208fn attached_run(units: &[Unit], j: usize, latin_right: &[bool], latin_left: &[bool], forward: bool) -> (usize, usize) {
209    let (mut start, mut end) = (units[j].start, units[j].end);
210    if forward {
211        let mut m = j;
212        while m + 1 < units.len() && (latin_right[m + 1] || latin_left[m + 1]) {
213            m += 1;
214            end = units[m].end;
215        }
216    } else {
217        let mut m = j;
218        while m > 0 && (latin_right[m - 1] || latin_left[m - 1]) {
219            m -= 1;
220            start = units[m].start;
221        }
222    }
223    (start, end)
224}
225
226/// Whether the text of the link spanning `link` is exactly one image
227/// (`[![alt](img.png)](target)`): an image inside the link with nothing but
228/// whitespace between the opening `[` and the image, and nothing but
229/// whitespace between the image and the `]` that closes the text.
230fn link_wraps_only_an_image(content: &str, link: (usize, usize), images: &[(usize, usize)]) -> bool {
231    images.iter().any(|&(start, end)| {
232        link.0 < start
233            && end < link.1
234            && content
235                .get(link.0 + 1..start)
236                .is_some_and(|before| before.trim().is_empty())
237            && content
238                .get(end..link.1)
239                .is_some_and(|after| after.trim_start().starts_with(']'))
240    })
241}
242
243/// The end of the `[^id]` marker that starts at `start`. `FootnoteRef` carries
244/// no end offset, so the marker is measured from the source: it runs from `[^`
245/// to the next `]`. A slice that does not open a marker has no end.
246fn footnote_marker_end(content: &str, start: usize) -> Option<usize> {
247    let rest = content.get(start..)?;
248    if !rest.starts_with("[^") {
249        return None;
250    }
251    rest.find(']').map(|offset| start + offset + 1)
252}
253
254/// The `[^id]:` label of a footnote definition, as byte offsets into `line`.
255/// Indentation and blockquote markers may stand before the label; a
256/// continuation line of the same definition has none.
257fn footnote_label_range(line: &str) -> Option<(usize, usize)> {
258    let start = line.find("[^")?;
259    if !line[..start].chars().all(|c| c.is_whitespace() || c == '>') {
260        return None;
261    }
262    let close = start + line[start..].find(']')?;
263    line[close + 1..].starts_with(':').then_some((start, close + 2))
264}
265
266/// The text a container marker holds, or `None` when `rest` opens no
267/// container. A list marker (`-`, `+`, `*`, or one to nine digits and `.` or
268/// `)`) and a footnote-definition label (`[^id]:`) each open one, and each is
269/// separated from its content by whitespace: a marker with nothing after it
270/// holds nothing, so the whitespace is what makes it a marker.
271fn container_marker_content(rest: &str) -> Option<&str> {
272    let after_marker = if let Some(tail) = rest.strip_prefix(['-', '+', '*']) {
273        tail
274    } else if let Some(tail) = rest.strip_prefix("[^") {
275        let close = tail.find(']')?;
276        tail[close + 1..].strip_prefix(':')?
277    } else {
278        let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
279        if !(1..=9).contains(&digits) {
280            return None;
281        }
282        rest[digits..].strip_prefix([')', '.'])?
283    };
284    let content = after_marker.trim_start();
285    (content.len() < after_marker.len()).then_some(content)
286}
287
288/// Whether a space inserted at the end of `prefix` would complete a list
289/// marker, where `prefix` is the line content before the gap. `1)中文` is an
290/// enumeration label, and `1) 中文` is an ordered list item, so the space would
291/// change the block type of the line. A marker starts a block wherever its
292/// container starts one, so everything that opens a container before it is
293/// stripped first: indentation, blockquote markers, an enclosing list marker
294/// and a footnote-definition label, in any nesting.
295fn completes_list_marker(prefix: &str) -> bool {
296    let mut rest = prefix.trim_start();
297    loop {
298        if let Some(tail) = rest.strip_prefix('>') {
299            rest = tail.trim_start();
300        } else if let Some(content) = container_marker_content(rest) {
301            rest = content;
302        } else {
303            break;
304        }
305    }
306    if matches!(rest, "-" | "+" | "*") {
307        return true;
308    }
309    let Some(digits) = rest.strip_suffix([')', '.']) else {
310        return false;
311    };
312    (1..=9).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
313}
314
315/// Byte ranges of `#tag` tokens on a line, each running to the next
316/// whitespace. A tag opens only at the start of a word, so a `#` glued to the
317/// end of one opens nothing (`C#编程` is one word, `修复#123` an issue
318/// reference). A word ends at an alphanumeric character, which covers Latin
319/// letters, digits and CJK letters; everything else before the `#` starts a
320/// word, so an emphasis marker, a bracket or punctuation lets a tag open. What
321/// counts as a tag from there is [`TAG_PATTERN`], the definition MD018 reads
322/// too, and a heading marker (`# `, `## `) never qualifies.
323fn hashtag_ranges(line: &str, line_start: usize) -> Vec<(usize, usize)> {
324    let mut ranges = Vec::new();
325    let mut chars = line.char_indices().peekable();
326    while let Some((i, c)) = chars.next() {
327        if c != '#' {
328            continue;
329        }
330        if line[..i].chars().next_back().is_some_and(char::is_alphanumeric) {
331            continue;
332        }
333        if !TAG_PATTERN.is_match(&line[i..]) {
334            continue;
335        }
336        let end = line[i..]
337            .find(char::is_whitespace)
338            .map_or(line.len(), |offset| i + offset);
339        ranges.push((line_start + i, line_start + end));
340        while chars.peek().is_some_and(|&(j, _)| j < end) {
341            chars.next();
342        }
343    }
344    ranges
345}
346
347/// Every inline construct in the document that the character walk must not
348/// enter, sorted by start. Emphasis contributes its two delimiter runs, not
349/// its content.
350fn collect_specials(ctx: &LintContext) -> Vec<Unit> {
351    let mut specials = Vec::new();
352    let mut push = |start: usize, end: usize, kind: Kind| {
353        if start < end {
354            specials.push(Unit { kind, start, end });
355        }
356    };
357    for span in ctx.code_spans().iter() {
358        push(span.byte_offset, span.byte_end, Kind::Opaque);
359    }
360    for span in ctx.math_spans().iter() {
361        push(span.byte_offset, span.byte_end, Kind::Opaque);
362    }
363    let images: Vec<(usize, usize)> = ctx
364        .images()
365        .iter()
366        .map(|image| (image.byte_offset, image.byte_end))
367        .collect();
368    for link in ctx.links() {
369        // A link whose text is one image is the clickable-badge construct: a
370        // file reference with a target, not prose, so it is a wall like the
371        // image it holds. Every other link is Latin-like and takes a space.
372        let kind = if link_wraps_only_an_image(ctx.content, (link.byte_offset, link.byte_end), &images) {
373            Kind::Wall
374        } else {
375            Kind::Opaque
376        };
377        push(link.byte_offset, link.byte_end, kind);
378    }
379    for url in ctx.bare_urls().iter() {
380        push(url.byte_offset, url.byte_end, Kind::Opaque);
381    }
382    for &(start, end) in &images {
383        push(start, end, Kind::Wall);
384    }
385    for tag in ctx.html_tags().iter() {
386        push(tag.byte_offset, tag.byte_end, Kind::Wall);
387    }
388    for comment in ctx.html_comment_ranges() {
389        push(comment.start, comment.end, Kind::Wall);
390    }
391    // A reference definition is a whole-line construct (its title may sit on
392    // the next line); a blockquoted one starts after the `> ` prefix.
393    for def in ctx.reference_definitions() {
394        push(def.byte_offset, def.byte_end, Kind::Wall);
395    }
396    // A footnote marker names a footnote, so spacing it renames it and the
397    // reference stops matching its definition. Like an image it takes no
398    // space on either side.
399    for footnote in ctx.footnote_references() {
400        if let Some(end) = footnote_marker_end(ctx.content, footnote.byte_offset) {
401            push(footnote.byte_offset, end, Kind::Wall);
402        }
403    }
404    // Only the `[^id]:` label of a definition is a marker; the body after the
405    // colon is prose and stays reachable.
406    for line in ctx.lines.iter().filter(|line| line.in_footnote_definition) {
407        if let Some((start, end)) = footnote_label_range(line.content(ctx.content)) {
408            push(line.byte_offset + start, line.byte_offset + end, Kind::Wall);
409        }
410    }
411    for span in ctx.emphasis_spans().iter() {
412        let width = if span.is_strong { 2 } else { 1 };
413        push(
414            span.byte_offset,
415            span.byte_offset + width,
416            Kind::Delimiter { opener: true },
417        );
418        push(
419            span.byte_end.saturating_sub(width),
420            span.byte_end,
421            Kind::Delimiter { opener: false },
422        );
423    }
424    // A hashtag only exists in text the walk still owns, so every other
425    // construct has to be in place and sorted before the scan runs.
426    specials.sort_by_key(|unit| (unit.start, unit.end));
427    let mut tags = Vec::new();
428    let mut cursor = 0;
429    let mut offset = 0;
430    for line in ctx.content.split_inclusive('\n') {
431        for (start, end) in hashtag_ranges(line.trim_end_matches(['\n', '\r']), offset) {
432            while cursor < specials.len() && specials[cursor].end <= start {
433                cursor += 1;
434            }
435            if let Some((start, end)) = tag_outside_specials(&specials[cursor..], start, end) {
436                tags.push(Unit {
437                    kind: Kind::Wall,
438                    start,
439                    end,
440                });
441            }
442        }
443        offset += line.len();
444    }
445    specials.extend(tags);
446    specials.sort_by_key(|unit| (unit.start, unit.end));
447    specials
448}
449
450/// The part of a `#tag` range the character walk still owns, or `None` when
451/// the `#` sits inside another construct. An anchor link, a code span or an
452/// image can hold a `#`, and there the `#` is that construct's business, not
453/// a tag opener; a tag that runs into a construct ends where it begins.
454/// `specials` is sorted by start.
455fn tag_outside_specials(specials: &[Unit], start: usize, end: usize) -> Option<(usize, usize)> {
456    for special in specials {
457        if special.start > start {
458            return Some((start, end.min(special.start)));
459        }
460        if special.end > start {
461            return None;
462        }
463    }
464    Some((start, end))
465}
466
467/// Split one line into units. `specials` holds every document special that
468/// ends after the line starts, in start order; a special that overlaps the
469/// line becomes one unit clamped to the line, and specials nested inside it
470/// are skipped.
471fn line_units(content: &str, line_start: usize, specials: &[Unit]) -> Vec<Unit> {
472    let line_end = line_start + content.len();
473    let mut units: Vec<Unit> = Vec::new();
474    let mut next_special = 0;
475    let mut pos = line_start;
476    while pos < line_end {
477        while next_special < specials.len() && specials[next_special].end <= pos {
478            next_special += 1;
479        }
480        if let Some(special) = specials.get(next_special).filter(|special| special.start <= pos) {
481            let end = special.end.min(line_end);
482            units.push(Unit {
483                kind: special.kind,
484                start: pos,
485                end,
486            });
487            pos = end;
488            next_special += 1;
489            continue;
490        }
491        let c = content[pos - line_start..]
492            .chars()
493            .next()
494            .expect("pos is on a char boundary inside the line");
495        let end = pos + c.len_utf8();
496        // A mark or a variation selector belongs to the character before it, so
497        // it continues that unit rather than ending it. At the start of a line
498        // there is nothing for it to attach to and it stands on its own.
499        if is_attached_mark(c)
500            && let Some(last) = units.last_mut()
501            && last.end == pos
502        {
503            last.end = end;
504            pos = end;
505            continue;
506        }
507        let kind = classify(c);
508        match units.last_mut() {
509            Some(last)
510                if last.end == pos && last.kind == kind && matches!(kind, Kind::Cjk | Kind::Latin | Kind::Other) =>
511            {
512                last.end = end;
513            }
514            _ => units.push(Unit { kind, start: pos, end }),
515        }
516        pos = end;
517    }
518    units
519}
520
521/// Text of a byte range for the message, cut to sixteen characters.
522fn excerpt(content: &str, (start, end): (usize, usize)) -> String {
523    const MAX_CHARS: usize = 16;
524    let text = &content[start..end];
525    match text.char_indices().nth(MAX_CHARS) {
526        Some((cut, _)) => format!("{}...", &text[..cut]),
527        None => text.to_string(),
528    }
529}
530
531impl Rule for MD089CjkSpacing {
532    fn name(&self) -> &'static str {
533        "MD089"
534    }
535
536    fn description(&self) -> &'static str {
537        "CJK letters and Latin letters or digits should be separated by a space"
538    }
539
540    fn check(&self, ctx: &LintContext) -> LintResult {
541        if self.should_skip(ctx) {
542            return Ok(Vec::new());
543        }
544        let specials = collect_specials(ctx);
545        let mut cursor = 0;
546        let mut warnings = Vec::new();
547        for line in ctx
548            .filtered_lines()
549            .skip_front_matter()
550            .skip_code_blocks()
551            .skip_html_blocks()
552            .skip_html_comments()
553            .skip_math_blocks()
554            .skip_esm_blocks()
555            .skip_jsx_expressions()
556            .skip_mdx_comments()
557            .skip_obsidian_comments()
558        {
559            // A kramdown block IAL (`{:.class}`) and the body of a kramdown
560            // extension block are attribute metadata rather than prose, and a
561            // space inside a class name renames the class.
562            if line.line_info.is_kramdown_block_ial || line.line_info.in_kramdown_extension_block {
563                continue;
564            }
565            let line_start = line.line_info.byte_offset;
566            while cursor < specials.len() && specials[cursor].end <= line_start {
567                cursor += 1;
568            }
569            let units = line_units(line.content, line_start, &specials[cursor..]);
570            for gap in self.missing_spaces(&units) {
571                // A marker followed straight by CJK is an enumeration label,
572                // not prose touching prose. The whole warning goes, not just
573                // the fix: the reader cannot act on it without turning the
574                // line into a list item, so reporting it is worse than
575                // staying quiet.
576                if completes_list_marker(&line.content[..gap.insert_at - line_start]) {
577                    continue;
578                }
579                // Pandoc attribute metadata is not prose either. A bracketed
580                // span's range covers its text and its attributes as one
581                // unit, so the whole construct is left alone rather than
582                // guessing which half a gap belongs to.
583                if ctx.is_in_inline_code_attr(gap.insert_at) || ctx.is_in_bracketed_span(gap.insert_at) {
584                    continue;
585                }
586                let column = byte_to_char_count(line.content, gap.insert_at - line_start);
587                warnings.push(LintWarning {
588                    rule_name: Some(self.name().to_string()),
589                    line: line.line_num,
590                    column,
591                    end_line: line.line_num,
592                    end_column: column + 1,
593                    severity: Severity::Warning,
594                    message: format!(
595                        "Missing space between \"{}\" and \"{}\"",
596                        excerpt(ctx.content, gap.left),
597                        excerpt(ctx.content, gap.right)
598                    ),
599                    fix: Some(Fix::new(gap.insert_at..gap.insert_at, " ".to_string())),
600                });
601            }
602        }
603        Ok(warnings)
604    }
605
606    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
607        if self.should_skip(ctx) {
608            return Ok(ctx.content.to_string());
609        }
610        let warnings = self.check(ctx)?;
611        if warnings.is_empty() {
612            return Ok(ctx.content.to_string());
613        }
614        let warnings =
615            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
616        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
617    }
618
619    fn should_skip(&self, ctx: &LintContext) -> bool {
620        !ctx.content.chars().any(is_cjk_letter)
621    }
622
623    fn category(&self) -> RuleCategory {
624        RuleCategory::Whitespace
625    }
626
627    fn fix_capability(&self) -> FixCapability {
628        FixCapability::FullyFixable
629    }
630
631    fn as_any(&self) -> &dyn std::any::Any {
632        self
633    }
634
635    crate::impl_rule_config_methods!(MD089Config);
636}