Skip to main content

rumdl_lib/rules/
md096_table_sort.rs

1//! Opt-in sorting of table body rows by a named column.
2
3#[cfg(test)]
4mod tests;
5
6use pulldown_cmark::{BrokenLink, CowStr, Event, Options, Parser};
7use serde::{Deserialize, Serialize};
8
9use crate::config::MarkdownFlavor;
10use crate::lint_context::LintContext;
11use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
12use crate::rule_config_serde::RuleConfig;
13use crate::utils::fix_utils::{apply_warning_fixes, filter_warnings_by_inline_config};
14use crate::utils::parser_options::rumdl_parser_options;
15use crate::utils::table_utils::TableUtils;
16use crate::utils::text_reflow::normalize_reference_label;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
19enum SortOrder {
20    #[default]
21    #[serde(rename = "asc", alias = "ASC")]
22    Asc,
23    #[serde(rename = "desc", alias = "DESC")]
24    Desc,
25}
26
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28struct MD096Config {
29    // Config::apply_per_rule_enabled folds this into the global rule lists.
30    #[serde(default)]
31    pub(super) enabled: bool,
32    /// No implicit first column: enabling the rule alone must not reorder tables.
33    #[serde(default)]
34    pub(super) column: Option<String>,
35    #[serde(default)]
36    pub(super) order: SortOrder,
37}
38
39impl RuleConfig for MD096Config {
40    const RULE_NAME: &'static str = "MD096";
41}
42
43#[derive(Debug, Clone, Default)]
44pub struct MD096TableSort {
45    config: MD096Config,
46}
47
48impl MD096TableSort {
49    fn from_config_struct(config: MD096Config) -> Self {
50        Self { config }
51    }
52
53    /// Parse inline content in a paragraph, never as a heading, list, reference
54    /// definition or HTML block. The sentinel starts the paragraph and is removed
55    /// after collecting text, even when the parser splits it across events.
56    fn sort_key(cell: &str, ctx: &LintContext) -> String {
57        let source = format!("x {}", cell.trim());
58        let mut options = rumdl_parser_options();
59        options.remove(Options::ENABLE_SMART_PUNCTUATION | Options::ENABLE_HEADING_ATTRIBUTES);
60        if ctx.flavor != MarkdownFlavor::Obsidian {
61            options.remove(Options::ENABLE_WIKILINKS);
62        }
63        let resolve = |link: BrokenLink<'_>| {
64            ctx.reference_definition(&normalize_reference_label(&link.reference))
65                .map(|_| (CowStr::Borrowed(""), CowStr::Borrowed("")))
66        };
67        let mut text = String::new();
68        for event in Parser::new_with_broken_link_callback(&source, options, Some(resolve)) {
69            match event {
70                Event::Text(value) | Event::InlineMath(value) | Event::DisplayMath(value) => text.push_str(&value),
71                // GFM removes a pipe's table escape even inside a code span.
72                Event::Code(value) => {
73                    if ctx.flavor == MarkdownFlavor::MkDocs {
74                        text.push_str(&value);
75                    } else {
76                        text.push_str(&value.replace("\\|", "|"));
77                    }
78                }
79                Event::SoftBreak | Event::HardBreak => text.push(' '),
80                _ => {}
81            }
82        }
83        // The parser drops a trailing space when the cell is empty. Strip only
84        // the sentinel character, then trim the remaining separator and text.
85        text.strip_prefix('x').unwrap_or(&text).trim().to_lowercase()
86    }
87}
88
89impl Rule for MD096TableSort {
90    fn name(&self) -> &'static str {
91        "MD096"
92    }
93
94    fn description(&self) -> &'static str {
95        "Table rows should be sorted by a named column"
96    }
97
98    fn category(&self) -> RuleCategory {
99        RuleCategory::Table
100    }
101
102    fn should_skip(&self, ctx: &LintContext) -> bool {
103        !ctx.likely_has_tables()
104            || self
105                .config
106                .column
107                .as_ref()
108                .is_none_or(|column| column.trim().is_empty())
109    }
110
111    fn check(&self, ctx: &LintContext) -> LintResult {
112        let Some(column) = self.config.column.as_ref().filter(|column| !column.trim().is_empty()) else {
113            return Ok(Vec::new());
114        };
115        let column_key = column.trim().to_lowercase();
116        let lines = ctx.raw_lines();
117        let mut warnings = Vec::new();
118
119        for table in ctx.table_blocks.iter().filter(|table| table.parser_confirmed) {
120            if table.content_lines.len() < 2 || table.content_lines.windows(2).any(|pair| pair[1] != pair[0] + 1) {
121                continue;
122            }
123            let table_span = ctx.line_span_byte_range(table.header_line + 1, table.end_line + 1);
124            if ctx
125                .parsed_table_range_at_line(table.header_line + 1)
126                .is_some_and(|(_, end)| end > table_span.end)
127            {
128                // A GFM table can continue with a row containing no pipes.
129                // The scanner does not own that row: do not sort a subset.
130                continue;
131            }
132            let header = TableUtils::extract_table_row_content(lines[table.header_line], table, 0);
133            let headers = TableUtils::split_table_row_for_rendering(header, ctx.flavor);
134            let mut matches = headers
135                .iter()
136                .enumerate()
137                .filter(|(_, cell)| Self::sort_key(cell, ctx) == column_key);
138            let Some((column_index, _)) = matches.next() else {
139                continue;
140            };
141            if matches.next().is_some() {
142                continue;
143            }
144
145            let mut rows = Vec::with_capacity(table.content_lines.len());
146            let mut prefix = None;
147            for (index, &line_index) in table.content_lines.iter().enumerate() {
148                let line = lines[line_index];
149                let content = TableUtils::extract_table_row_content(line, table, index + 2);
150                debug_assert!(line.ends_with(content), "table row content must be a source suffix");
151                let row_prefix = &line[..line.len() - content.len()];
152                // Moving a lazy/container prefix to a different position can
153                // change the parse. Leave mixed layouts to the author.
154                if prefix.is_some_and(|prefix| prefix != row_prefix) {
155                    break;
156                }
157                prefix = Some(row_prefix);
158                let cells = TableUtils::split_table_row_for_rendering(content, ctx.flavor);
159                if cells.len() != headers.len() {
160                    break;
161                }
162                rows.push((line_index, Self::sort_key(&cells[column_index], ctx)));
163            }
164            if rows.len() != table.content_lines.len() {
165                continue;
166            }
167            let compare = |a: &(usize, String), b: &(usize, String)| match self.config.order {
168                SortOrder::Asc => a.1.cmp(&b.1),
169                SortOrder::Desc => b.1.cmp(&a.1),
170            };
171            if rows.windows(2).all(|pair| !compare(&pair[0], &pair[1]).is_gt()) {
172                continue;
173            }
174            // Stable in both directions: reversing an ascending result would
175            // reverse equal keys as well.
176            rows.sort_by(compare);
177            let mut replacement = String::new();
178            for (&destination, &(source, _)) in table.content_lines.iter().zip(&rows) {
179                let source_range = ctx.line_content_byte_range(source + 1);
180                let destination_content = ctx.line_content_byte_range(destination + 1);
181                let destination_span = ctx.line_span_byte_range(destination + 1, destination + 1);
182                replacement.push_str(&ctx.content[source_range]);
183                // Line endings belong to positions, not rows. In particular,
184                // the last row may have no newline and may move to the middle.
185                replacement.push_str(&ctx.content[destination_content.end..destination_span.end]);
186            }
187            let first = table.content_lines[0] + 1;
188            let last = table.content_lines[table.content_lines.len() - 1] + 1;
189            // Comments can carry instructions whose scope changes when moved.
190            // Keep the raw finding for suppression accounting (MD087), but do
191            // not move a table containing comments automatically.
192            let table_range = ctx.line_span_byte_range(table.header_line + 1, last);
193            let comments = ctx.html_comment_ranges();
194            let next_comment = comments.partition_point(|comment| comment.end <= table_range.start);
195            let has_comments = comments
196                .get(next_comment)
197                .is_some_and(|comment| comment.start < table_range.end);
198            let direction = match self.config.order {
199                SortOrder::Asc => "ascending",
200                SortOrder::Desc => "descending",
201            };
202            warnings.push(LintWarning {
203                rule_name: Some(self.name().to_string()),
204                message: format!("Table rows should be sorted by '{column}' ({direction})"),
205                // A table-wide diagnostic lets the normal suppression pipeline
206                // honor a disable on any row, including the header/delimiter,
207                // and lets MD087 observe that suppression.
208                line: table.header_line + 1,
209                column: 1,
210                end_line: last,
211                end_column: lines[last - 1].chars().count() + 1,
212                severity: Severity::Warning,
213                fix: (!has_comments).then(|| Fix::new(ctx.line_span_byte_range(first, last), replacement)),
214            });
215        }
216        Ok(warnings)
217    }
218
219    fn fixes_from_diagnostics(&self) -> bool {
220        true
221    }
222
223    fn fix_capability(&self) -> FixCapability {
224        FixCapability::ConditionallyFixable
225    }
226
227    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
228        let warnings = filter_warnings_by_inline_config(self.check(ctx)?, ctx.inline_config(), self.name());
229        apply_warning_fixes(ctx.content, &warnings).map_err(LintError::FixFailed)
230    }
231
232    fn as_any(&self) -> &dyn std::any::Any {
233        self
234    }
235
236    crate::impl_rule_config_methods!(MD096Config);
237}