rumdl_lib/rules/
md096_table_sort.rs1#[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 #[serde(default)]
31 pub(super) enabled: bool,
32 #[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 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 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 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 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 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 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 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 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 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}