| // 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); | |
| } | |
| } |