use serde::Deserialize;
use std::rc::Rc;
use tree_sitter::Node;
use crate::{
linter::{range_from_tree_sitter, RuleViolation},
rules::{Context, Rule, RuleLinter, RuleType},
};
#[derive(Debug, PartialEq, Clone, Deserialize)]
pub enum TablePipeStyle {
#[serde(rename = "consistent")]
Consistent,
#[serde(rename = "leading_and_trailing")]
LeadingAndTrailing,
#[serde(rename = "leading_only")]
LeadingOnly,
#[serde(rename = "trailing_only")]
TrailingOnly,
#[serde(rename = "no_leading_or_trailing")]
NoLeadingOrTrailing,
}
impl Default for TablePipeStyle {
fn default() -> Self {
Self::Consistent
}
}
#[derive(Debug, PartialEq, Clone, Deserialize)]
pub struct MD055TablePipeStyleTable {
#[serde(default)]
pub style: TablePipeStyle,
}
impl Default for MD055TablePipeStyleTable {
fn default() -> Self {
Self {
style: TablePipeStyle::Consistent,
}
}
}
pub(crate) struct MD055Linter {
context: Rc<Context>,
violations: Vec<RuleViolation>,
first_table_style: Option<(bool, bool)>, }
struct ViolationInfo {
message: String,
column_offset: usize,
}
impl MD055Linter {
pub fn new(context: Rc<Context>) -> Self {
Self {
context,
violations: Vec::new(),
first_table_style: None,
}
}
}
impl RuleLinter for MD055Linter {
fn feed(&mut self, node: &Node) {
if node.kind() == "pipe_table" {
self.check_table(node);
}
}
fn finalize(&mut self) -> Vec<RuleViolation> {
std::mem::take(&mut self.violations)
}
}
impl MD055Linter {
fn check_table(&mut self, table_node: &Node) {
let mut table_rows = Vec::new();
let mut cursor = table_node.walk();
for child in table_node.children(&mut cursor) {
if child.kind() == "pipe_table_header"
|| child.kind() == "pipe_table_row"
|| child.kind() == "pipe_table_delimiter_row"
{
table_rows.push(child);
}
}
if table_rows.is_empty() {
return;
}
let mut all_violation_infos = Vec::new();
{
let document_content = self.context.document_content.borrow();
let config_style = &self.context.config.linters.settings.table_pipe_style.style;
let expected_style = match config_style {
TablePipeStyle::Consistent => {
if let Some(style) = self.first_table_style {
style
} else {
let first_row_text = table_rows[0]
.utf8_text(document_content.as_bytes())
.unwrap_or("")
.trim();
let has_leading = first_row_text.starts_with('|');
let has_trailing =
first_row_text.ends_with('|') && first_row_text.len() > 1;
let style = (has_leading, has_trailing);
self.first_table_style = Some(style);
style
}
}
TablePipeStyle::LeadingAndTrailing => (true, true),
TablePipeStyle::LeadingOnly => (true, false),
TablePipeStyle::TrailingOnly => (false, true),
TablePipeStyle::NoLeadingOrTrailing => (false, false),
};
for row in &table_rows {
let infos = self.check_row_pipe_style(row, expected_style, &document_content);
if !infos.is_empty() {
all_violation_infos.push((*row, infos));
}
}
}
for (row, infos) in all_violation_infos {
for info in infos {
self.create_violation_at_position(&row, info.message, info.column_offset);
}
}
}
fn check_row_pipe_style(
&self,
row_node: &Node,
expected: (bool, bool),
document_content: &str,
) -> Vec<ViolationInfo> {
let mut infos = Vec::new();
let (expected_leading, expected_trailing) = expected;
let row_text = row_node
.utf8_text(document_content.as_bytes())
.unwrap_or("");
let leading_whitespace_len = row_text.len() - row_text.trim_start().len();
let trimmed_text = row_text.trim();
let actual_leading = trimmed_text.starts_with('|');
let actual_trailing = trimmed_text.ends_with('|') && trimmed_text.len() > 1;
if expected_leading != actual_leading {
let message = if expected_leading {
"Missing leading pipe"
} else {
"Unexpected leading pipe"
};
infos.push(ViolationInfo {
message: message.to_string(),
column_offset: leading_whitespace_len,
});
}
if expected_trailing != actual_trailing {
let message = if expected_trailing {
"Missing trailing pipe"
} else {
"Unexpected trailing pipe"
};
let pos = if actual_trailing {
leading_whitespace_len + trimmed_text.len().saturating_sub(1)
} else {
leading_whitespace_len + trimmed_text.len()
};
infos.push(ViolationInfo {
message: message.to_string(),
column_offset: pos,
});
}
infos
}
fn create_violation_at_position(&mut self, node: &Node, message: String, column_offset: usize) {
let mut range = range_from_tree_sitter(&node.range());
range.start.character += column_offset;
range.end.character = range.start.character + 1;
self.violations.push(RuleViolation::new(
&MD055,
message,
self.context.file_path.clone(),
range,
));
}
}
pub const MD055: Rule = Rule {
id: "MD055",
alias: "table-pipe-style",
tags: &["table"],
description: "Table pipe style",
rule_type: RuleType::Token,
required_nodes: &["pipe_table"],
new_linter: |context| Box::new(MD055Linter::new(context)),
};
#[cfg(test)]
mod test {
use std::path::PathBuf;
use crate::{
config::{MD055TablePipeStyleTable, RuleSeverity, TablePipeStyle},
linter::MultiRuleLinter,
test_utils::test_helpers::test_config_with_rules,
};
fn test_config() -> crate::config::QuickmarkConfig {
test_config_with_rules(vec![("table-pipe-style", RuleSeverity::Error)])
}
fn test_config_with_style(style: TablePipeStyle) -> crate::config::QuickmarkConfig {
let mut config = test_config();
config.linters.settings.table_pipe_style = MD055TablePipeStyleTable { style };
config
}
#[test]
fn test_consistent_style_with_leading_and_trailing() {
let input = r#"| Header 1 | Header 2 |
| -------- | -------- |
| Cell 1 | Cell 2 |"#;
let config = test_config_with_style(TablePipeStyle::Consistent);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_consistent_style_with_leading_only() {
let input = r#"| Header 1 | Header 2
| -------- | --------
| Cell 1 | Cell 2"#;
let config = test_config_with_style(TablePipeStyle::Consistent);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_consistent_style_with_trailing_only() {
let input = r#"Header 1 | Header 2 |
-------- | -------- |
Cell 1 | Cell 2 |"#;
let config = test_config_with_style(TablePipeStyle::Consistent);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_consistent_style_with_no_leading_or_trailing() {
let input = r#"Header 1 | Header 2
-------- | --------
Cell 1 | Cell 2"#;
let config = test_config_with_style(TablePipeStyle::Consistent);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_consistent_style_violation() {
let input = r#"| Header 1 | Header 2 |
| -------- | -------- |
Cell 1 | Cell 2 |"#; let config = test_config_with_style(TablePipeStyle::Consistent);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(1, violations.len());
assert!(violations[0].message().contains("Missing leading pipe"));
}
#[test]
fn test_leading_and_trailing_style_valid() {
let input = r#"| Header 1 | Header 2 |
| -------- | -------- |
| Cell 1 | Cell 2 |"#;
let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_leading_and_trailing_style_missing_leading() {
let input = r#"Header 1 | Header 2 |
-------- | -------- |
Cell 1 | Cell 2 |"#;
let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(3, violations.len()); for violation in &violations {
assert!(violation.message().contains("Missing leading pipe"));
}
}
#[test]
fn test_leading_and_trailing_style_missing_trailing() {
let input = r#"| Header 1 | Header 2
| -------- | --------
| Cell 1 | Cell 2"#;
let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(3, violations.len()); for violation in &violations {
assert!(violation.message().contains("Missing trailing pipe"));
}
}
#[test]
fn test_leading_only_style_valid() {
let input = r#"| Header 1 | Header 2
| -------- | --------
| Cell 1 | Cell 2"#;
let config = test_config_with_style(TablePipeStyle::LeadingOnly);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_leading_only_style_unexpected_trailing() {
let input = r#"| Header 1 | Header 2 |
| -------- | -------- |
| Cell 1 | Cell 2 |"#;
let config = test_config_with_style(TablePipeStyle::LeadingOnly);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(3, violations.len()); for violation in &violations {
assert!(violation.message().contains("Unexpected trailing pipe"));
}
}
#[test]
fn test_trailing_only_style_valid() {
let input = r#"Header 1 | Header 2 |
-------- | -------- |
Cell 1 | Cell 2 |"#;
let config = test_config_with_style(TablePipeStyle::TrailingOnly);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_trailing_only_style_unexpected_leading() {
let input = r#"| Header 1 | Header 2 |
| -------- | -------- |
| Cell 1 | Cell 2 |"#;
let config = test_config_with_style(TablePipeStyle::TrailingOnly);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(3, violations.len()); for violation in &violations {
assert!(violation.message().contains("Unexpected leading pipe"));
}
}
#[test]
fn test_no_leading_or_trailing_style_valid() {
let input = r#"Header 1 | Header 2
-------- | --------
Cell 1 | Cell 2"#;
let config = test_config_with_style(TablePipeStyle::NoLeadingOrTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_no_leading_or_trailing_style_unexpected_leading() {
let input = r#"| Header 1 | Header 2
| -------- | --------
| Cell 1 | Cell 2"#;
let config = test_config_with_style(TablePipeStyle::NoLeadingOrTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(3, violations.len()); for violation in &violations {
assert!(violation.message().contains("Unexpected leading pipe"));
}
}
#[test]
fn test_no_leading_or_trailing_style_unexpected_trailing() {
let input = r#"Header 1 | Header 2 |
-------- | -------- |
Cell 1 | Cell 2 |"#;
let config = test_config_with_style(TablePipeStyle::NoLeadingOrTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(3, violations.len()); for violation in &violations {
assert!(violation.message().contains("Unexpected trailing pipe"));
}
}
#[test]
fn test_multiple_tables_consistent_style() {
let input = r#"| Table 1 | Header |
| ------- | ------ |
| Cell | Value |
Header | Column |
------ | ------ |
Data | Info |"#;
let config = test_config_with_style(TablePipeStyle::Consistent);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(3, violations.len()); for violation in &violations {
assert!(violation.message().contains("Missing"));
}
}
#[test]
fn test_empty_table() {
let input = "";
let config = test_config_with_style(TablePipeStyle::Consistent);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len());
}
#[test]
fn test_delimiter_rows_are_checked() {
let input = r#"| Header 1 | Header 2 |
-------- | -------- |
| Cell 1 | Cell 2 |"#; let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert!(!violations.is_empty()); let violation_lines: Vec<usize> = violations
.iter()
.map(|v| v.location().range.start.line)
.collect();
assert!(violation_lines.contains(&1));
let delimiter_violations: Vec<_> = violations
.iter()
.filter(|v| v.location().range.start.line == 1)
.collect();
assert!(!delimiter_violations.is_empty()); }
#[test]
fn test_column_position_accuracy() {
let input = r#"Header 1 | Header 2
-------- | --------
Data 1 | Data 2"#; let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert!(violations.len() >= 2);
let leading_violations: Vec<_> = violations
.iter()
.filter(|v| v.message().contains("Missing leading"))
.collect();
assert!(!leading_violations.is_empty());
for violation in leading_violations {
assert_eq!(0, violation.location().range.start.character);
}
let trailing_violations: Vec<_> = violations
.iter()
.filter(|v| v.message().contains("Missing trailing"))
.collect();
assert!(!trailing_violations.is_empty());
for violation in trailing_violations {
assert!(violation.location().range.start.character > 0);
}
}
#[test]
fn test_single_row_table() {
let input = r#"| Header 1 | Header 2 |"#;
let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len()); }
#[test]
fn test_consistent_style_with_first_table_no_pipes() {
let input = r#"Header 1 | Header 2
-------- | --------
Data 1 | Data 2
| Another | Table |
| ------- | ----- |
| With | Pipes |"#;
let config = test_config_with_style(TablePipeStyle::Consistent);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert!(!violations.is_empty());
for violation in &violations {
assert!(violation.message().contains("Unexpected"));
}
}
#[test]
fn test_mixed_violations_same_row() {
let input = r#"| Header 1 | Header 2 |
| -------- | -------- |
Cell 1 | Cell 2"#; let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
let row3_violations: Vec<_> = violations
.iter()
.filter(|v| v.location().range.start.line == 2)
.collect();
assert_eq!(2, row3_violations.len()); }
#[test]
fn test_table_with_empty_cells() {
let input = r#"| Header | |
| ------ | |
| Value | |"#;
let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len()); }
#[test]
fn test_table_with_escaped_pipes() {
let input = r#"| Header | Content |
| ------ | ------- |
| Value | \| pipe |"#;
let config = test_config_with_style(TablePipeStyle::LeadingAndTrailing);
let mut linter = MultiRuleLinter::new_for_document(PathBuf::from("test.md"), config, input);
let violations = linter.analyze();
assert_eq!(0, violations.len()); }
}