blob: 01cd49e7667da6768019025fa2b2e8ffe3147684 [file]
// Copyright 2026 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
use crate::DocCheckerArgs;
use crate::checker::{DocCheck, DocCheckError};
use crate::md_element::Element;
use anyhow::Result;
use async_trait::async_trait;
use std::ops::Range;
use std::path::PathBuf;
const TAB_STOP: usize = 4;
/// Computes visual indentation in columns (expanding '\t' to 4-space tab stops)
/// and returns `(visual_indent, trimmed_slice)`.
fn visual_indent(line: &str) -> (usize, &str) {
let mut indent = 0;
let mut byte_idx = 0;
for (idx, ch) in line.char_indices() {
match ch {
' ' => {
indent += 1;
byte_idx = idx + 1;
}
'\t' => {
indent += TAB_STOP - (indent % TAB_STOP);
byte_idx = idx + 1;
}
_ => break,
}
}
(indent, &line[byte_idx..])
}
/// Computes visual indentation in columns and strips optional leading blockquote
/// prefixes (`>`), returning `(total_visual_indent, remaining_slice)`.
fn strip_indent_and_blockquote(line: &str) -> (usize, &str) {
let (mut indent, mut trimmed) = visual_indent(line);
while let Some(after_gt) = trimmed.strip_prefix('>') {
let (extra_indent, next_trimmed) = visual_indent(after_gt);
indent += 1 + extra_indent;
trimmed = next_trimmed;
}
(indent, trimmed)
}
/// Parses the opening fence of a code block line (` ``` ` or `~~~`), returning
/// `(base_indent, fence_char, fence_len, info_string)`.
fn parse_opening_fence(line: &str) -> Option<(usize, char, usize, &str)> {
let (indent, trimmed) = strip_indent_and_blockquote(line);
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
let fence_char = trimmed.chars().next()?;
let fence_len = trimmed.chars().take_while(|&c| c == fence_char).count();
if fence_len >= 3 {
let remainder = trimmed[fence_len..].trim();
if !remainder.contains(fence_char) {
return Some((indent, fence_char, fence_len, remainder));
}
}
}
None
}
/// Checks whether `trimmed` is a closing fence matching `(fence_char, fence_len)`.
fn is_closing_fence(trimmed: &str, fence_char: char, fence_len: usize) -> bool {
if trimmed.starts_with(fence_char) {
let close_len = trimmed.chars().take_while(|&c| c == fence_char).count();
if close_len >= fence_len && trimmed[close_len..].trim().is_empty() {
return true;
}
}
false
}
#[derive(Default)]
pub struct CodeBlockChecker {
/// Tracks an orphaned closing fence expected in the same file after an indented
/// code block inside a container was prematurely terminated by `pulldown_cmark`.
orphaned_fence: Option<(PathBuf, char, usize)>,
}
impl CodeBlockChecker {
fn check_element_tree(&mut self, element: &Element<'_>, errors: &mut Vec<DocCheckError>) {
match element {
Element::CodeBlock(info, _, doc_line, Some((file_text, range))) => {
if let Some(block_errors) =
self.check_code_block(info.as_ref(), &doc_line.file_name, file_text, range)
{
errors.extend(block_errors);
}
}
Element::Block(_, children, _)
| Element::Image(_, _, _, children, _)
| Element::Link(_, _, _, children, _)
| Element::List(_, children, _) => {
for child in children {
self.check_element_tree(child, errors);
}
}
_ => {}
}
}
fn check_code_block(
&mut self,
info: &str,
file_name: &PathBuf,
file_text: &str,
range: &Range<usize>,
) -> Option<Vec<DocCheckError>> {
let line_start = file_text[..range.start].rfind('\n').map_or(0, |idx| idx + 1);
let start_line_num = file_text[..line_start].bytes().filter(|&b| b == b'\n').count() + 1;
let block_slice = &file_text[line_start..range.end];
let block_lines: Vec<&str> = block_slice.lines().collect();
let first_line = *block_lines.first()?;
let (base_indent, fence_char, fence_len, _) = parse_opening_fence(first_line)?;
let mut errors = vec![];
let mut closed = false;
let mut reported_inner_error = false;
for (idx, &line) in block_lines.iter().enumerate().skip(1) {
let current_line_num = start_line_num + idx;
let (indent, trimmed) = strip_indent_and_blockquote(line);
if is_closing_fence(trimmed, fence_char, fence_len) {
closed = true;
if indent < base_indent {
errors.push(DocCheckError::new_error_helpful(
current_line_num,
file_name.clone(),
&format!(
"Closing fence for code block starting at line {} has less indentation ({} spaces) than the opening fence ({} spaces).",
start_line_num, indent, base_indent
),
"indenting the closing code block fence to match the opening fence.",
));
}
break;
}
if trimmed.is_empty() {
continue;
}
if indent < base_indent && !reported_inner_error {
errors.push(DocCheckError::new_error_helpful(
current_line_num,
file_name.clone(),
&format!(
"Code block starting at line {} is unclosed or its inner lines are improperly indented.",
start_line_num
),
"properly indenting the code block lines or closing it.",
));
reported_inner_error = true;
}
}
if !closed {
// If this block is itself an orphaned bare closing fence from an earlier
// container-terminated block in the same file, consume it without a duplicate error.
if info.trim().is_empty()
&& self.orphaned_fence.as_ref().is_some_and(|(f, ch, len)| {
f == file_name && *ch == fence_char && fence_len >= *len
})
{
self.orphaned_fence = None;
return None;
}
// Look ahead after `range.end` to see if `pulldown_cmark` terminated the block
// early due to an under-indented line or under-indented closing fence, or if it hit EOF.
let trailing = &file_text[range.end..];
let first_non_empty =
trailing.lines().enumerate().find(|(_, line)| !line.trim().is_empty());
if let Some((offset, next_line)) = first_non_empty {
let offending_line_num = start_line_num + block_lines.len() + offset;
let (next_indent, next_trimmed) = strip_indent_and_blockquote(next_line);
self.orphaned_fence = Some((file_name.clone(), fence_char, fence_len));
if !reported_inner_error {
if is_closing_fence(next_trimmed, fence_char, fence_len)
&& next_indent < base_indent
{
errors.push(DocCheckError::new_error_helpful(
offending_line_num,
file_name.clone(),
&format!(
"Closing fence for code block starting at line {} has less indentation ({} spaces) than the opening fence ({} spaces).",
start_line_num, next_indent, base_indent
),
"indenting the closing code block fence to match the opening fence.",
));
} else {
errors.push(DocCheckError::new_error_helpful(
offending_line_num,
file_name.clone(),
&format!(
"Code block starting at line {} is unclosed or its inner lines are improperly indented.",
start_line_num
),
"properly indenting the code block lines or closing it.",
));
}
}
} else if !reported_inner_error {
errors.push(DocCheckError::new_error_helpful(
start_line_num,
file_name.clone(),
&format!(
"Code block starting at line {} is unclosed before end of file.",
start_line_num
),
"adding a matching closing code block fence.",
));
}
}
if errors.is_empty() { None } else { Some(errors) }
}
}
pub(crate) fn register_markdown_checks(_opt: &DocCheckerArgs) -> Result<Vec<Box<dyn DocCheck>>> {
Ok(vec![Box::new(CodeBlockChecker::default())])
}
#[async_trait]
impl DocCheck for CodeBlockChecker {
fn name(&self) -> &str {
"CodeBlockChecker"
}
fn check(&mut self, element: &Element<'_>) -> Result<Option<Vec<DocCheckError>>> {
let mut errors = vec![];
self.check_element_tree(element, &mut errors);
if errors.is_empty() { Ok(None) } else { Ok(Some(errors)) }
}
async fn post_check(&self) -> Result<Option<Vec<DocCheckError>>> {
Ok(None)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::md_element::DocContext;
fn run_checker(markdown: &str) -> Vec<DocCheckError> {
let mut callback = |broken_link: pulldown_cmark::BrokenLink<'_>| {
DocContext::handle_broken_link(broken_link, markdown)
};
let ctx = DocContext::new(PathBuf::from("test.md"), markdown, Some(&mut callback));
let mut checker = CodeBlockChecker::default();
let mut errors = vec![];
for element in ctx {
if let Some(errs) = checker.check(&element).unwrap() {
errors.extend(errs);
}
}
errors
}
#[fuchsia::test]
fn test_valid_code_block() {
let md = r#" ```rust
fn main() {}
```
"#;
assert!(run_checker(md).is_empty());
}
#[fuchsia::test]
fn test_invalid_code_block() {
let md = r#" ```rust
fn main() {}
un-indented-line
```
"#;
let errors = run_checker(md);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].doc_line.line_num, 3);
assert!(errors[0].message.contains("Code block starting at line 1 is unclosed"));
}
#[fuchsia::test]
fn test_unclosed_code_block_eof() {
let md = r#"```rust
fn main() {}
"#;
let errors = run_checker(md);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].doc_line.line_num, 1);
assert!(errors[0].message.contains("unclosed before end of file"));
}
#[fuchsia::test]
fn test_code_block_nested_in_list() {
let valid_md = r#"* {Rust}
```rust
fn main() {}
```
"#;
assert!(run_checker(valid_md).is_empty());
let invalid_md = r#"* {Rust}
```rust
fn main() {}
unindented
```
"#;
let errors = run_checker(invalid_md);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].doc_line.line_num, 5);
}
#[fuchsia::test]
fn test_multiple_code_blocks_in_file() {
let md = r#"* {C++}
```cpp
int main() {}
```
* {Rust}
```rust
fn main() {}
wrong_indent
```
"#;
let errors = run_checker(md);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].doc_line.line_num, 11);
}
#[fuchsia::test]
fn test_under_indented_closing_fence() {
let md = r#"* {Rust}
```rust
fn main() {}
```
"#;
let errors = run_checker(md);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].doc_line.line_num, 5);
assert!(errors[0].message.contains("Closing fence for code block starting at line 3"));
}
#[fuchsia::test]
fn test_html_comment_code_fence_ignored() {
let md = r#"<!--
```rust
unindented
-->
```rust
fn main() {}
```
"#;
assert!(run_checker(md).is_empty());
}
#[fuchsia::test]
fn test_tab_indentation() {
let md = "\t```rust\n fn main() {}\n\t```\n";
assert!(run_checker(md).is_empty());
}
#[fuchsia::test]
fn test_code_block_in_blockquote() {
let valid_md = r#"> ```rust
> fn main() {}
>
> ```
"#;
assert!(run_checker(valid_md).is_empty());
let invalid_md = r#"> ```rust
> fn main() {}
> ```
"#;
let errors = run_checker(invalid_md);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].doc_line.line_num, 2);
}
}