use crate::config::Config;
use crate::linter::diagnostics::{Diagnostic, Location};
use crate::linter::rules::Rule;
use crate::syntax::{SyntaxKind, SyntaxNode};
use rowan::NodeOrToken;
pub struct EmptyListItemRule;
impl Rule for EmptyListItemRule {
fn name(&self) -> &str {
"empty-list-item"
}
fn check(
&self,
tree: &SyntaxNode,
input: &str,
_config: &Config,
_metadata: Option<&crate::metadata::DocumentMetadata>,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in tree.descendants() {
if node.kind() != SyntaxKind::LIST_ITEM {
continue;
}
if let Some(diag) = classify(&node, input) {
diagnostics.push(diag);
}
}
diagnostics
}
}
fn classify(list_item: &SyntaxNode, input: &str) -> Option<Diagnostic> {
let marker = list_item
.children_with_tokens()
.find(|c| c.kind() == SyntaxKind::LIST_MARKER)?;
let marker_range = marker.text_range();
if is_directly_empty(list_item) {
let location = Location::from_range(marker_range, input);
return Some(Diagnostic::warning(
location,
"empty-list-item",
"List item has no content",
));
}
if let Some(underline) = setext_dash_underline_only_content(list_item) {
let location = Location::from_range(underline.text_range(), input);
return Some(Diagnostic::warning(
location,
"empty-list-item",
"Bare `-` after a list-item line was parsed as a Setext H2 underline, \
merging this item with the previous text",
));
}
None
}
fn is_directly_empty(list_item: &SyntaxNode) -> bool {
let mut saw_marker = false;
for child in list_item.children_with_tokens() {
match child {
NodeOrToken::Token(tok) => match tok.kind() {
SyntaxKind::LIST_MARKER => saw_marker = true,
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE => {}
_ => return false,
},
NodeOrToken::Node(node) => match node.kind() {
SyntaxKind::PLAIN | SyntaxKind::PARAGRAPH => {
if !inline_container_is_blank(&node) {
return false;
}
}
_ => return false,
},
}
}
saw_marker
}
fn inline_container_is_blank(node: &SyntaxNode) -> bool {
node.descendants_with_tokens().all(|c| {
matches!(
c.kind(),
SyntaxKind::PLAIN
| SyntaxKind::PARAGRAPH
| SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
)
})
}
fn setext_dash_underline_only_content(list_item: &SyntaxNode) -> Option<SyntaxNode> {
let mut heading = None;
for child in list_item.children_with_tokens() {
match child {
NodeOrToken::Token(tok) => match tok.kind() {
SyntaxKind::LIST_MARKER | SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE => {}
_ => return None,
},
NodeOrToken::Node(node) => {
if node.kind() != SyntaxKind::HEADING || heading.is_some() {
return None;
}
heading = Some(node);
}
}
}
let heading = heading?;
let underline = heading
.children()
.find(|n| n.kind() == SyntaxKind::SETEXT_HEADING_UNDERLINE)?;
let underline_text = underline.text().to_string();
underline_text
.chars()
.all(|c| c == '-')
.then_some(underline)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_and_lint(input: &str) -> Vec<Diagnostic> {
let config = Config::default();
let tree = crate::parser::parse(input, Some(config.clone()));
EmptyListItemRule.check(&tree, input, &config, None)
}
#[test]
fn flags_bare_bullet_between_items() {
let diagnostics = parse_and_lint("- one\n-\n- three\n");
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, "empty-list-item");
assert!(diagnostics[0].message.contains("no content"));
}
#[test]
fn flags_bare_ordered_marker() {
let diagnostics = parse_and_lint("1.\n2. next\n");
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].code, "empty-list-item");
}
#[test]
fn flags_marker_with_only_trailing_whitespace() {
let diagnostics = parse_and_lint("- one\n- \n- three\n");
assert_eq!(diagnostics.len(), 1);
}
#[test]
fn flags_setext_h2_inside_list_item() {
let diagnostics = parse_and_lint("- bullet trap\n -\n");
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message.contains("Setext"));
}
#[test]
fn does_not_flag_setext_h1_inside_list_item() {
let diagnostics = parse_and_lint("- heading\n ===\n");
assert_eq!(diagnostics.len(), 0);
}
#[test]
fn does_not_flag_nonempty_items() {
let diagnostics = parse_and_lint("- one\n- two\n- three\n");
assert_eq!(diagnostics.len(), 0);
}
#[test]
fn does_not_flag_item_with_nested_list() {
let diagnostics = parse_and_lint("- parent\n - child\n");
assert_eq!(diagnostics.len(), 0);
}
#[test]
fn flags_marker_at_end_of_input() {
let diagnostics = parse_and_lint("-");
assert_eq!(diagnostics.len(), 1);
}
}