Skip to main content

rumdl_lib/code_block_tools/
processor.rs

1//! Main processor for code block linting and formatting.
2//!
3//! This module coordinates language resolution, tool lookup, execution,
4//! and result collection for processing code blocks in markdown files.
5
6#[cfg(test)]
7use super::config::LanguageToolConfig;
8use super::config::{CodeBlockToolsConfig, NormalizeLanguage, OnError, OnMissing, ToolDefinition};
9use super::executor::{ExecutorError, ToolExecutor, ToolOutput};
10use super::linguist::LinguistResolver;
11use super::registry::ToolRegistry;
12use crate::config::MarkdownFlavor;
13use crate::rule::{LintWarning, Severity};
14use crate::utils::rumdl_parser_options;
15use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
16
17/// Special built-in tool name for rumdl's own markdown linting.
18/// When this tool is configured for markdown blocks, the processor skips
19/// external execution since it's handled by embedded markdown linting.
20pub const RUMDL_BUILTIN_TOOL: &str = "rumdl";
21
22/// Check if a language is markdown (handles common variations).
23fn is_markdown_language(lang: &str) -> bool {
24    matches!(lang.to_lowercase().as_str(), "markdown" | "md")
25}
26
27/// Strip ANSI escape sequences from tool output.
28///
29/// Many tools output colored text (e.g. `\x1b[1;31mError\x1b[0m`), which prevents
30/// structured parsers from matching patterns like `file:line:col: message`.
31fn strip_ansi_codes(s: &str) -> String {
32    let mut result = String::with_capacity(s.len());
33    let mut chars = s.chars().peekable();
34    while let Some(c) = chars.next() {
35        if c == '\x1b' {
36            if chars.peek() == Some(&'[') {
37                chars.next();
38                // Consume until we hit an ASCII letter (the terminator)
39                while let Some(&next) = chars.peek() {
40                    chars.next();
41                    if next.is_ascii_alphabetic() {
42                        break;
43                    }
44                }
45            }
46        } else {
47            result.push(c);
48        }
49    }
50    result
51}
52
53/// Ensure content handed to an external tool is newline-terminated.
54///
55/// Code block extraction strips the line ending before the closing fence, but
56/// per CommonMark that final newline is part of the block's content. External
57/// tools treat their stdin as a complete file, so a tool with an end-of-file
58/// newline rule (e.g. yamllint/ryl `new-line-at-end-of-file`, ruff `W292`)
59/// would otherwise emit a false positive. Genuinely empty content is left
60/// untouched so we never synthesize content where there is none.
61fn ensure_trailing_newline(content: &str) -> std::borrow::Cow<'_, str> {
62    if content.is_empty() || content.ends_with('\n') {
63        std::borrow::Cow::Borrowed(content)
64    } else {
65        std::borrow::Cow::Owned(format!("{content}\n"))
66    }
67}
68
69/// Information about a fenced code block for processing.
70#[derive(Debug, Clone)]
71pub struct FencedCodeBlockInfo {
72    /// 0-indexed line number where opening fence starts.
73    pub start_line: usize,
74    /// 0-indexed line number where closing fence ends.
75    pub end_line: usize,
76    /// Byte offset where code content starts (after opening fence line).
77    pub content_start: usize,
78    /// Byte offset where code content ends (before closing fence line).
79    pub content_end: usize,
80    /// Language tag extracted from info string (first token).
81    pub language: String,
82    /// Full info string from the fence.
83    pub info_string: String,
84    /// The fence character used (` or ~).
85    pub fence_char: char,
86    /// Length of the fence (3 or more).
87    pub fence_length: usize,
88    /// Leading whitespace on the fence line.
89    pub indent: usize,
90    /// Exact leading whitespace prefix from the fence line.
91    pub indent_prefix: String,
92}
93
94/// A diagnostic message from an external tool.
95#[derive(Debug, Clone)]
96pub struct CodeBlockDiagnostic {
97    /// Line number in the original markdown file (1-indexed).
98    pub file_line: usize,
99    /// Column number (1-indexed, if available).
100    pub column: Option<usize>,
101    /// Message from the tool.
102    pub message: String,
103    /// Severity (error, warning, info).
104    pub severity: DiagnosticSeverity,
105    /// Name of the tool that produced this.
106    pub tool: String,
107    /// Line where the code block starts (1-indexed, for context).
108    pub code_block_start: usize,
109}
110
111/// Severity level for diagnostics.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum DiagnosticSeverity {
114    Error,
115    Warning,
116    Info,
117}
118
119impl CodeBlockDiagnostic {
120    /// Convert to a LintWarning for integration with rumdl's warning system.
121    pub fn to_lint_warning(&self) -> LintWarning {
122        let severity = match self.severity {
123            DiagnosticSeverity::Error => Severity::Error,
124            DiagnosticSeverity::Warning => Severity::Warning,
125            DiagnosticSeverity::Info => Severity::Info,
126        };
127
128        LintWarning {
129            message: self.message.clone(),
130            line: self.file_line,
131            column: self.column.unwrap_or(1),
132            end_line: self.file_line,
133            end_column: self.column.unwrap_or(1),
134            severity,
135            fix: None, // External tool diagnostics don't provide fixes
136            rule_name: Some(self.tool.clone()),
137        }
138    }
139}
140
141/// Error during code block processing.
142#[derive(Debug, Clone)]
143pub enum ProcessorError {
144    /// Tool execution failed.
145    ToolError(ExecutorError),
146    /// Tool execution failed with code block location context.
147    ToolErrorAt {
148        error: ExecutorError,
149        line: usize,
150        language: String,
151    },
152    /// No tools configured for language.
153    NoToolsConfigured { language: String, line: usize },
154    /// Tool binary not found.
155    ToolBinaryNotFound {
156        tool: String,
157        language: String,
158        line: usize,
159    },
160    /// Processing was aborted due to on_error = fail.
161    Aborted { message: String },
162}
163
164impl std::fmt::Display for ProcessorError {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        match self {
167            Self::ToolError(e) => write!(f, "{e}"),
168            Self::ToolErrorAt { error, line, language } => {
169                write!(f, "line {line} ({language}): {error}")
170            }
171            Self::NoToolsConfigured { language, line } => {
172                write!(f, "line {line} ({language}): no tools configured")
173            }
174            Self::ToolBinaryNotFound { tool, language, line } => {
175                write!(f, "line {line} ({language}): tool '{tool}' not found in PATH")
176            }
177            Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
178        }
179    }
180}
181
182impl std::error::Error for ProcessorError {}
183
184impl From<ExecutorError> for ProcessorError {
185    fn from(e: ExecutorError) -> Self {
186        Self::ToolError(e)
187    }
188}
189
190/// Result of processing a single code block.
191#[derive(Debug)]
192pub struct CodeBlockResult {
193    /// Diagnostics from linting.
194    pub diagnostics: Vec<CodeBlockDiagnostic>,
195    /// Formatted content (if formatting was requested and succeeded).
196    pub formatted_content: Option<String>,
197    /// Whether the code block was modified.
198    pub was_modified: bool,
199}
200
201/// Result of formatting code blocks in a document.
202#[derive(Debug)]
203pub struct FormatOutput {
204    /// The formatted content (may be partially formatted if errors occurred).
205    pub content: String,
206    /// Whether any errors occurred during formatting.
207    pub had_errors: bool,
208    /// Error messages for blocks that couldn't be formatted.
209    pub error_messages: Vec<String>,
210}
211
212/// Main processor for code block tools.
213/// Context in which a tool is being used.
214#[derive(Copy, Clone)]
215enum ToolContext {
216    Lint,
217    Format,
218}
219
220pub struct CodeBlockToolProcessor<'a> {
221    config: &'a CodeBlockToolsConfig,
222    flavor: MarkdownFlavor,
223    linguist: LinguistResolver,
224    registry: ToolRegistry,
225    executor: ToolExecutor,
226    user_aliases: std::collections::HashMap<String, String>,
227}
228
229impl<'a> CodeBlockToolProcessor<'a> {
230    /// Create a new processor with the given configuration and markdown flavor.
231    pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
232        let user_aliases = config
233            .language_aliases
234            .iter()
235            .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
236            .collect();
237        Self {
238            config,
239            flavor,
240            linguist: LinguistResolver::new(),
241            registry: ToolRegistry::new(config.tools.clone()),
242            executor: ToolExecutor::new(config.timeout),
243            user_aliases,
244        }
245    }
246
247    /// Resolve a tool ID with context awareness.
248    ///
249    /// When a bare tool name (e.g., "tombi") is used in a specific context
250    /// (lint or format), try the context-specific variant first (e.g., "tombi:format"),
251    /// then common alternatives (e.g., "tombi:check"), before falling back to the bare name.
252    fn resolve_tool<'b>(&'b self, tool_id: &str, context: ToolContext) -> Option<&'b ToolDefinition> {
253        // If the tool ID already has a colon suffix, use it directly
254        if tool_id.contains(':') {
255            return self.registry.get(tool_id);
256        }
257
258        // Try context-specific variants first
259        let suffixes = match context {
260            ToolContext::Format => &["format", "fmt", "fix", "reformat"][..],
261            ToolContext::Lint => &["lint", "check"][..],
262        };
263
264        for suffix in suffixes {
265            let qualified = format!("{tool_id}:{suffix}");
266            if let Some(def) = self.registry.get(&qualified) {
267                return Some(def);
268            }
269        }
270
271        // Fall back to bare name
272        self.registry.get(tool_id)
273    }
274
275    /// Report a tool id no registry entry answers to.
276    ///
277    /// Both the id and the language it was configured under are text out of whichever
278    /// file supplied the section (the language through `language-aliases`), so a section
279    /// reached through `extends` is described rather than quoted. See
280    /// [`crate::config::Config::withheld_rule_values`] for the same policy on rule options.
281    fn warn_unknown_tool(&self, tool_id: &str, canonical_lang: &str) {
282        if self.config.values_withheld {
283            let withheld = crate::config::WITHHELD;
284            log::warn!("Unknown tool {withheld} configured for language {withheld}");
285        } else {
286            log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
287        }
288    }
289
290    /// Quick check whether any configured language might appear in fenced code blocks.
291    /// Scans for `` ```lang `` or `` ~~~lang `` patterns without full parsing.
292    fn has_potential_matching_blocks(&self, content: &str, lint_mode: bool) -> bool {
293        // Collect languages that have tools configured for the requested mode
294        let configured_langs: Vec<&str> = self
295            .config
296            .languages
297            .iter()
298            .filter(|(_, lc)| {
299                lc.enabled
300                    && if lint_mode {
301                        !lc.lint.is_empty()
302                    } else {
303                        !lc.format.is_empty()
304                    }
305            })
306            .map(|(lang, _)| lang.as_str())
307            .collect();
308
309        if configured_langs.is_empty() {
310            return false;
311        }
312
313        // Scan content line-by-line for fence openers matching configured languages
314        for line in content.lines() {
315            let trimmed = line.trim_start();
316            let after_fence = if let Some(rest) = trimmed.strip_prefix("```") {
317                rest
318            } else if let Some(rest) = trimmed.strip_prefix("~~~") {
319                rest
320            } else {
321                continue;
322            };
323
324            let lang = after_fence.split_whitespace().next().unwrap_or("");
325            if lang.is_empty() {
326                continue;
327            }
328            // Check both the raw language and the canonical (normalized) form
329            let canonical = self.resolve_language(lang);
330            if configured_langs.contains(&canonical.as_str()) {
331                return true;
332            }
333        }
334
335        false
336    }
337
338    /// Extract all fenced code blocks from content.
339    pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
340        let mut blocks = Vec::new();
341        let mut current_block: Option<FencedCodeBlockBuilder> = None;
342
343        let options = rumdl_parser_options();
344        let parser = Parser::new_ext(content, options).into_offset_iter();
345
346        let lines: Vec<&str> = content.lines().collect();
347
348        for (event, range) in parser {
349            match event {
350                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
351                    let info_string = info.to_string();
352                    let language = info_string.split_whitespace().next().unwrap_or("").to_string();
353
354                    // Find start line
355                    let start_line = content[..range.start].chars().filter(|&c| c == '\n').count();
356
357                    // Find content start (after opening fence line)
358                    let content_start = content[range.start..]
359                        .find('\n')
360                        .map_or(content.len(), |i| range.start + i + 1);
361
362                    // Detect fence character and length from the line
363                    let fence_line = lines.get(start_line).unwrap_or(&"");
364                    let trimmed = fence_line.trim_start();
365                    let indent = fence_line.len() - trimmed.len();
366                    let indent_prefix = fence_line.get(..indent).unwrap_or("").to_string();
367                    let (fence_char, fence_length) = if trimmed.starts_with('~') {
368                        ('~', trimmed.chars().take_while(|&c| c == '~').count())
369                    } else {
370                        ('`', trimmed.chars().take_while(|&c| c == '`').count())
371                    };
372
373                    current_block = Some(FencedCodeBlockBuilder {
374                        start_line,
375                        content_start,
376                        language,
377                        info_string,
378                        fence_char,
379                        fence_length,
380                        indent,
381                        indent_prefix,
382                    });
383                }
384                Event::End(TagEnd::CodeBlock) => {
385                    if let Some(builder) = current_block.take() {
386                        // Find end line
387                        let end_line = content[..range.end].chars().filter(|&c| c == '\n').count();
388
389                        // Find content end (before closing fence line)
390                        let search_start = builder.content_start.min(range.end);
391                        let content_end = if search_start < range.end {
392                            content[search_start..range.end]
393                                .rfind('\n')
394                                .map_or(search_start, |i| search_start + i)
395                        } else {
396                            search_start
397                        };
398
399                        if content_end >= builder.content_start {
400                            blocks.push(FencedCodeBlockInfo {
401                                start_line: builder.start_line,
402                                end_line,
403                                content_start: builder.content_start,
404                                content_end,
405                                language: builder.language,
406                                info_string: builder.info_string,
407                                fence_char: builder.fence_char,
408                                fence_length: builder.fence_length,
409                                indent: builder.indent,
410                                indent_prefix: builder.indent_prefix,
411                            });
412                        }
413                    }
414                }
415                _ => {}
416            }
417        }
418
419        // For MkDocs flavor, also extract code blocks inside admonitions and tabs
420        if self.flavor == MarkdownFlavor::MkDocs {
421            let mkdocs_blocks = self.extract_mkdocs_code_blocks(content);
422            for mb in mkdocs_blocks {
423                // Deduplicate: only add if no existing block starts at the same line
424                if !blocks.iter().any(|b| b.start_line == mb.start_line) {
425                    blocks.push(mb);
426                }
427            }
428            blocks.sort_by_key(|b| b.start_line);
429        }
430
431        blocks
432    }
433
434    /// Extract fenced code blocks that are inside MkDocs admonitions or tabs.
435    ///
436    /// pulldown_cmark doesn't parse MkDocs-specific constructs, so indented
437    /// code blocks inside `!!!`/`???` admonitions or `===` tabs are missed.
438    /// This method manually scans for them.
439    fn extract_mkdocs_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
440        use crate::utils::mkdocs_admonitions;
441        use crate::utils::mkdocs_tabs;
442
443        let mut blocks = Vec::new();
444        let lines: Vec<&str> = content.lines().collect();
445
446        // Track current MkDocs context indent level
447        // We only need to know if we're inside any MkDocs block, so a simple stack suffices.
448        let mut context_indent_stack: Vec<usize> = Vec::new();
449
450        // Track fence state inside MkDocs context
451        let mut in_fence = false;
452        let mut fence_start_line: usize = 0;
453        let mut fence_content_start: usize = 0;
454        let mut fence_char: char = '`';
455        let mut fence_length: usize = 0;
456        let mut fence_indent: usize = 0;
457        let mut fence_indent_prefix = String::new();
458        let mut fence_language = String::new();
459        let mut fence_info_string = String::new();
460
461        // Compute byte offsets via pointer arithmetic.
462        // `content.lines()` returns slices into the original string,
463        // so each line's pointer offset from `content` gives its byte position.
464        // This correctly handles \n, \r\n, and empty lines.
465        let content_start_ptr = content.as_ptr() as usize;
466        let line_offsets: Vec<usize> = lines
467            .iter()
468            .map(|line| line.as_ptr() as usize - content_start_ptr)
469            .collect();
470
471        for (i, line) in lines.iter().enumerate() {
472            let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
473            let is_admonition = mkdocs_admonitions::is_admonition_start(line);
474            let is_tab = mkdocs_tabs::is_tab_marker(line);
475
476            // Pop contexts when the current line is not indented enough to be content.
477            // This runs for ALL lines (including new admonition/tab starts) to clean
478            // up stale entries before potentially pushing a new context.
479            if !line.trim().is_empty() {
480                while let Some(&ctx_indent) = context_indent_stack.last() {
481                    if line_indent < ctx_indent + 4 {
482                        context_indent_stack.pop();
483                        if in_fence {
484                            in_fence = false;
485                        }
486                    } else {
487                        break;
488                    }
489                }
490            }
491
492            // Check for admonition start — push new context
493            if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
494                context_indent_stack.push(indent);
495                continue;
496            }
497
498            // Check for tab marker — push new context
499            if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
500                context_indent_stack.push(indent);
501                continue;
502            }
503
504            // Only look for fences inside a MkDocs context
505            if context_indent_stack.is_empty() {
506                continue;
507            }
508
509            let trimmed = line.trim_start();
510            let leading_spaces = line.len() - trimmed.len();
511
512            if !in_fence {
513                // Check for fence opening
514                let (fc, fl) = if trimmed.starts_with("```") {
515                    ('`', trimmed.chars().take_while(|&c| c == '`').count())
516                } else if trimmed.starts_with("~~~") {
517                    ('~', trimmed.chars().take_while(|&c| c == '~').count())
518                } else {
519                    continue;
520                };
521
522                if fl >= 3 {
523                    in_fence = true;
524                    fence_start_line = i;
525                    fence_char = fc;
526                    fence_length = fl;
527                    fence_indent = leading_spaces;
528                    fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
529
530                    let after_fence = &trimmed[fl..];
531                    fence_info_string = after_fence.trim().to_string();
532                    fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
533
534                    // Content starts at the next line's byte offset
535                    fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
536                }
537            } else {
538                // Check for fence closing
539                let is_closing = if fence_char == '`' {
540                    trimmed.starts_with("```")
541                        && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
542                        && trimmed.trim_start_matches('`').trim().is_empty()
543                } else {
544                    trimmed.starts_with("~~~")
545                        && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
546                        && trimmed.trim_start_matches('~').trim().is_empty()
547                };
548
549                if is_closing {
550                    let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
551
552                    if content_end >= fence_content_start {
553                        blocks.push(FencedCodeBlockInfo {
554                            start_line: fence_start_line,
555                            end_line: i,
556                            content_start: fence_content_start,
557                            content_end,
558                            language: fence_language.clone(),
559                            info_string: fence_info_string.clone(),
560                            fence_char,
561                            fence_length,
562                            indent: fence_indent,
563                            indent_prefix: fence_indent_prefix.clone(),
564                        });
565                    }
566
567                    in_fence = false;
568                }
569            }
570        }
571
572        blocks
573    }
574
575    /// Resolve a language tag to its canonical name.
576    fn resolve_language(&self, language: &str) -> String {
577        let lower = language.to_lowercase();
578        if let Some(mapped) = self.user_aliases.get(&lower) {
579            return mapped.clone();
580        }
581        match self.config.normalize_language {
582            NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
583            NormalizeLanguage::Exact => lower,
584        }
585    }
586
587    /// Get the effective on_error setting for a language.
588    fn get_on_error(&self, language: &str) -> OnError {
589        self.config
590            .languages
591            .get(language)
592            .and_then(|lc| lc.on_error)
593            .unwrap_or(self.config.on_error)
594    }
595
596    /// Strip the fence indentation prefix from each line of a code block.
597    fn strip_indent_from_block(&self, content: &str, indent_prefix: &str) -> String {
598        if indent_prefix.is_empty() {
599            return content.to_string();
600        }
601
602        let mut out = String::with_capacity(content.len());
603        for line in content.split_inclusive('\n') {
604            if let Some(stripped) = line.strip_prefix(indent_prefix) {
605                out.push_str(stripped);
606            } else {
607                out.push_str(line);
608            }
609        }
610        out
611    }
612
613    /// Re-apply the fence indentation prefix to each line of a code block.
614    fn apply_indent_to_block(&self, content: &str, indent_prefix: &str) -> String {
615        if indent_prefix.is_empty() {
616            return content.to_string();
617        }
618        if content.is_empty() {
619            return String::new();
620        }
621
622        let mut out = String::with_capacity(content.len() + indent_prefix.len());
623        for line in content.split_inclusive('\n') {
624            if line == "\n" {
625                out.push_str(line);
626            } else {
627                out.push_str(indent_prefix);
628                out.push_str(line);
629            }
630        }
631        out
632    }
633
634    /// Lint all code blocks in the content.
635    ///
636    /// Returns diagnostics from all configured linters.
637    pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
638        // Skip the expensive parse when no tools could possibly produce output.
639        // With on_missing=Ignore (default) and no languages with lint tools configured,
640        // every block would be skipped, so the parse is wasted work.
641        if self.config.on_missing_language_definition == OnMissing::Ignore
642            && !self
643                .config
644                .languages
645                .values()
646                .any(|lc| lc.enabled && !lc.lint.is_empty())
647        {
648            return Ok(Vec::new());
649        }
650
651        // Quick content check: skip parsing if no configured language appears in the content.
652        // This avoids the expensive pulldown-cmark parse when there are no matching code blocks.
653        if self.config.on_missing_language_definition == OnMissing::Ignore
654            && !self.has_potential_matching_blocks(content, true)
655        {
656            return Ok(Vec::new());
657        }
658
659        let mut all_diagnostics = Vec::new();
660        let blocks = self.extract_code_blocks(content);
661
662        for block in blocks {
663            if block.language.is_empty() {
664                continue; // Skip blocks without language tag
665            }
666
667            let canonical_lang = self.resolve_language(&block.language);
668
669            // Get lint tools for this language
670            let lang_config = self.config.languages.get(&canonical_lang);
671
672            // If language is explicitly configured with enabled=false, skip silently
673            if let Some(lc) = lang_config
674                && !lc.enabled
675            {
676                continue;
677            }
678
679            let lint_tools = match lang_config {
680                Some(lc) if !lc.lint.is_empty() => &lc.lint,
681                _ => {
682                    // No tools configured for this language in lint mode
683                    match self.config.on_missing_language_definition {
684                        OnMissing::Ignore => continue,
685                        OnMissing::Fail => {
686                            all_diagnostics.push(CodeBlockDiagnostic {
687                                file_line: block.start_line + 1,
688                                column: None,
689                                message: format!("No lint tools configured for language '{canonical_lang}'"),
690                                severity: DiagnosticSeverity::Error,
691                                tool: "code-block-tools".to_string(),
692                                code_block_start: block.start_line + 1,
693                            });
694                            continue;
695                        }
696                        OnMissing::FailFast => {
697                            return Err(ProcessorError::NoToolsConfigured {
698                                language: canonical_lang,
699                                line: block.start_line + 1,
700                            });
701                        }
702                    }
703                }
704            };
705
706            // Extract code block content
707            let code_content_raw = if block.content_start < block.content_end && block.content_end <= content.len() {
708                &content[block.content_start..block.content_end]
709            } else {
710                continue;
711            };
712            let code_content = self.strip_indent_from_block(code_content_raw, &block.indent_prefix);
713
714            // Run each lint tool
715            for tool_id in lint_tools {
716                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown linting
717                if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
718                    continue;
719                }
720
721                let Some(tool_def) = self.resolve_tool(tool_id, ToolContext::Lint) else {
722                    self.warn_unknown_tool(tool_id, &canonical_lang);
723                    continue;
724                };
725
726                // Check if tool binary exists before running
727                let tool_name = tool_def.command.first().map_or("", String::as_str);
728                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
729                    match self.config.on_missing_tool_binary {
730                        OnMissing::Ignore => {
731                            log::debug!("Tool binary '{tool_name}' not found, skipping");
732                            continue;
733                        }
734                        OnMissing::Fail => {
735                            all_diagnostics.push(CodeBlockDiagnostic {
736                                file_line: block.start_line + 1,
737                                column: None,
738                                message: format!("Tool binary '{tool_name}' not found in PATH"),
739                                severity: DiagnosticSeverity::Error,
740                                tool: "code-block-tools".to_string(),
741                                code_block_start: block.start_line + 1,
742                            });
743                            continue;
744                        }
745                        OnMissing::FailFast => {
746                            return Err(ProcessorError::ToolBinaryNotFound {
747                                tool: tool_name.to_string(),
748                                language: canonical_lang.clone(),
749                                line: block.start_line + 1,
750                            });
751                        }
752                    }
753                }
754
755                let tool_input = ensure_trailing_newline(&code_content);
756                match self.executor.lint(tool_def, &tool_input, Some(self.config.timeout)) {
757                    Ok(output) => {
758                        // Parse tool output into diagnostics
759                        let diagnostics = self.parse_tool_output(
760                            &output,
761                            tool_id,
762                            block.start_line + 1, // Convert to 1-indexed
763                        );
764                        all_diagnostics.extend(diagnostics);
765                    }
766                    Err(e) => {
767                        let on_error = self.get_on_error(&canonical_lang);
768                        match on_error {
769                            OnError::Fail => return Err(e.into()),
770                            OnError::Warn => {
771                                log::warn!("Tool '{tool_id}' failed: {e}");
772                            }
773                            OnError::Skip => {
774                                // Silently skip
775                            }
776                        }
777                    }
778                }
779            }
780        }
781
782        Ok(all_diagnostics)
783    }
784
785    /// Format all code blocks in the content.
786    ///
787    /// Returns the modified content with formatted code blocks and any errors that occurred.
788    /// With `on-missing-*` = `fail`, errors are collected but formatting continues.
789    /// With `on-missing-*` = `fail-fast`, returns Err immediately on first error.
790    pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
791        let no_output = FormatOutput {
792            content: content.to_string(),
793            had_errors: false,
794            error_messages: Vec::new(),
795        };
796
797        // Skip the expensive parse when no tools could produce output
798        if self.config.on_missing_language_definition == OnMissing::Ignore
799            && !self
800                .config
801                .languages
802                .values()
803                .any(|lc| lc.enabled && !lc.format.is_empty())
804        {
805            return Ok(no_output);
806        }
807
808        // Quick content check: skip parsing if no configured language appears in the content
809        if self.config.on_missing_language_definition == OnMissing::Ignore
810            && !self.has_potential_matching_blocks(content, false)
811        {
812            return Ok(no_output);
813        }
814
815        let blocks = self.extract_code_blocks(content);
816
817        if blocks.is_empty() {
818            return Ok(FormatOutput {
819                content: content.to_string(),
820                had_errors: false,
821                error_messages: Vec::new(),
822            });
823        }
824
825        // Process blocks in reverse order to maintain byte offsets
826        let mut result = content.to_string();
827        let mut error_messages: Vec<String> = Vec::new();
828
829        for block in blocks.into_iter().rev() {
830            if block.language.is_empty() {
831                continue;
832            }
833
834            let canonical_lang = self.resolve_language(&block.language);
835
836            // Get format tools for this language
837            let lang_config = self.config.languages.get(&canonical_lang);
838
839            // If language is explicitly configured with enabled=false, skip silently
840            if let Some(lc) = lang_config
841                && !lc.enabled
842            {
843                continue;
844            }
845
846            let format_tools = match lang_config {
847                Some(lc) if !lc.format.is_empty() => &lc.format,
848                _ => {
849                    // No tools configured for this language in format mode
850                    match self.config.on_missing_language_definition {
851                        OnMissing::Ignore => continue,
852                        OnMissing::Fail => {
853                            error_messages.push(format!(
854                                "No format tools configured for language '{canonical_lang}' at line {}",
855                                block.start_line + 1
856                            ));
857                            continue;
858                        }
859                        OnMissing::FailFast => {
860                            return Err(ProcessorError::NoToolsConfigured {
861                                language: canonical_lang,
862                                line: block.start_line + 1,
863                            });
864                        }
865                    }
866                }
867            };
868
869            // Extract code block content
870            if block.content_start >= block.content_end || block.content_end > result.len() {
871                continue;
872            }
873            let code_content_raw = result[block.content_start..block.content_end].to_string();
874            let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
875
876            // Run format tools (use first successful one)
877            let mut formatted = code_content.clone();
878            let mut tool_ran = false;
879            for tool_id in format_tools {
880                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown formatting
881                if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
882                    continue;
883                }
884
885                let Some(tool_def) = self.resolve_tool(tool_id, ToolContext::Format) else {
886                    self.warn_unknown_tool(tool_id, &canonical_lang);
887                    continue;
888                };
889
890                // Check if tool binary exists before running
891                let tool_name = tool_def.command.first().map_or("", String::as_str);
892                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
893                    match self.config.on_missing_tool_binary {
894                        OnMissing::Ignore => {
895                            log::debug!("Tool binary '{tool_name}' not found, skipping");
896                            continue;
897                        }
898                        OnMissing::Fail => {
899                            error_messages.push(format!(
900                                "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
901                                block.start_line + 1
902                            ));
903                            continue;
904                        }
905                        OnMissing::FailFast => {
906                            return Err(ProcessorError::ToolBinaryNotFound {
907                                tool: tool_name.to_string(),
908                                language: canonical_lang.clone(),
909                                line: block.start_line + 1,
910                            });
911                        }
912                    }
913                }
914
915                let tool_input = ensure_trailing_newline(&formatted);
916                match self.executor.format(tool_def, &tool_input, Some(self.config.timeout)) {
917                    Ok(output) => {
918                        // Guard against formatters that produce empty output for non-empty input.
919                        // This prevents data loss from misconfigured tools (e.g., a lint tool
920                        // used as a formatter that validates but doesn't output content).
921                        if output.trim().is_empty() && !formatted.trim().is_empty() {
922                            log::warn!("Formatter '{tool_id}' produced empty output for non-empty input, skipping");
923                            continue;
924                        }
925
926                        // Ensure trailing newline matches original (unindented)
927                        formatted = output;
928                        if code_content.ends_with('\n') && !formatted.ends_with('\n') {
929                            formatted.push('\n');
930                        } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
931                            formatted.pop();
932                        }
933                        tool_ran = true;
934                        break; // Use first successful formatter
935                    }
936                    Err(e) => {
937                        let on_error = self.get_on_error(&canonical_lang);
938                        match on_error {
939                            OnError::Fail => {
940                                return Err(ProcessorError::ToolErrorAt {
941                                    error: e,
942                                    line: block.start_line + 1,
943                                    language: canonical_lang,
944                                });
945                            }
946                            OnError::Warn => {
947                                error_messages.push(format!("line {} ({}): {e}", block.start_line + 1, canonical_lang));
948                            }
949                            OnError::Skip => {}
950                        }
951                    }
952                }
953            }
954
955            // Replace content if changed and a tool actually ran
956            if tool_ran && formatted != code_content {
957                let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
958                if reindented != code_content_raw {
959                    result.replace_range(block.content_start..block.content_end, &reindented);
960                }
961            }
962        }
963
964        Ok(FormatOutput {
965            content: result,
966            had_errors: !error_messages.is_empty(),
967            error_messages,
968        })
969    }
970
971    /// Parse tool output into diagnostics.
972    ///
973    /// This is a basic parser that handles common output formats.
974    /// Tools vary widely in their output format, so this is best-effort.
975    fn parse_tool_output(
976        &self,
977        output: &ToolOutput,
978        tool_id: &str,
979        code_block_start_line: usize,
980    ) -> Vec<CodeBlockDiagnostic> {
981        let mut diagnostics = Vec::new();
982        let mut shellcheck_line: Option<usize> = None;
983
984        // Strip ANSI escape codes and combine stdout + stderr for parsing
985        let stdout_clean = strip_ansi_codes(&output.stdout);
986        let stderr_clean = strip_ansi_codes(&output.stderr);
987        let combined = format!("{stdout_clean}\n{stderr_clean}");
988
989        // State for multi-line "Error: msg" / "at line N column M" pattern
990        let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
991
992        for line in combined.lines() {
993            let line = line.trim();
994            if line.is_empty() {
995                continue;
996            }
997
998            // Resolve pending "Error: msg" from previous line
999            if let Some((ref msg, severity)) = pending_error {
1000                if let Some((line_num, col)) = Self::parse_at_line_column(line) {
1001                    diagnostics.push(CodeBlockDiagnostic {
1002                        file_line: code_block_start_line + line_num,
1003                        column: Some(col),
1004                        message: msg.clone(),
1005                        severity,
1006                        tool: tool_id.to_string(),
1007                        code_block_start: code_block_start_line,
1008                    });
1009                    pending_error = None;
1010                    continue;
1011                }
1012                // No position info found; emit error without line mapping
1013                diagnostics.push(CodeBlockDiagnostic {
1014                    file_line: code_block_start_line,
1015                    column: None,
1016                    message: msg.clone(),
1017                    severity,
1018                    tool: tool_id.to_string(),
1019                    code_block_start: code_block_start_line,
1020                });
1021                pending_error = None;
1022                // Fall through to parse current line
1023            }
1024
1025            if let Some(line_num) = self.parse_shellcheck_header(line) {
1026                shellcheck_line = Some(line_num);
1027                continue;
1028            }
1029
1030            if let Some(line_num) = shellcheck_line
1031                && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
1032            {
1033                diagnostics.push(diag);
1034                continue;
1035            }
1036
1037            // Try pattern: "file:line:col: message" or "file:line: message"
1038            if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
1039                diagnostics.push(diag);
1040                continue;
1041            }
1042
1043            // Try pattern: "line:col message" (eslint style)
1044            if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
1045                diagnostics.push(diag);
1046                continue;
1047            }
1048
1049            // Try single-line shellcheck format fallback
1050            if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
1051                diagnostics.push(diag);
1052                continue;
1053            }
1054
1055            // Try multi-line "Error: msg" / "Warning: msg" pattern
1056            if let Some(error_info) = Self::parse_error_line(line) {
1057                pending_error = Some(error_info);
1058            }
1059        }
1060
1061        // Flush any remaining pending error
1062        if let Some((msg, severity)) = pending_error {
1063            diagnostics.push(CodeBlockDiagnostic {
1064                file_line: code_block_start_line,
1065                column: None,
1066                message: msg,
1067                severity,
1068                tool: tool_id.to_string(),
1069                code_block_start: code_block_start_line,
1070            });
1071        }
1072
1073        // If no diagnostics parsed but tool failed, use combined output as fallback
1074        if diagnostics.is_empty() && !output.success {
1075            let lines: Vec<&str> = combined.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
1076
1077            if lines.is_empty() {
1078                let exit_code = output.exit_code;
1079                diagnostics.push(CodeBlockDiagnostic {
1080                    file_line: code_block_start_line,
1081                    column: None,
1082                    message: format!("Tool exited with code {exit_code}"),
1083                    severity: DiagnosticSeverity::Error,
1084                    tool: tool_id.to_string(),
1085                    code_block_start: code_block_start_line,
1086                });
1087            } else {
1088                for line_text in lines {
1089                    diagnostics.push(CodeBlockDiagnostic {
1090                        file_line: code_block_start_line,
1091                        column: None,
1092                        message: line_text.to_string(),
1093                        severity: DiagnosticSeverity::Error,
1094                        tool: tool_id.to_string(),
1095                        code_block_start: code_block_start_line,
1096                    });
1097                }
1098            }
1099        }
1100
1101        diagnostics
1102    }
1103
1104    /// Parse standard "file:line:col: message" format.
1105    fn parse_standard_format(
1106        &self,
1107        line: &str,
1108        tool_id: &str,
1109        code_block_start_line: usize,
1110    ) -> Option<CodeBlockDiagnostic> {
1111        // Match patterns like "file.py:1:10: E501 message"
1112        let mut parts = line.rsplitn(4, ':');
1113        let message = parts.next()?.trim().to_string();
1114        let part1 = parts.next()?.trim().to_string();
1115        let part2 = parts.next()?.trim().to_string();
1116        let part3 = parts.next().map(|s| s.trim().to_string());
1117
1118        let (line_part, col_part) = if part3.is_some() {
1119            (part2, Some(part1))
1120        } else {
1121            (part1, None)
1122        };
1123
1124        if let Ok(line_num) = line_part.parse::<usize>() {
1125            let column = col_part.and_then(|s| s.parse::<usize>().ok());
1126            let message = Self::strip_fixable_markers(&message);
1127            if !message.is_empty() {
1128                let severity = self.infer_severity(&message);
1129                return Some(CodeBlockDiagnostic {
1130                    file_line: code_block_start_line + line_num,
1131                    column,
1132                    message,
1133                    severity,
1134                    tool: tool_id.to_string(),
1135                    code_block_start: code_block_start_line,
1136                });
1137            }
1138        }
1139        None
1140    }
1141
1142    /// Parse eslint-style "line:col severity message" format.
1143    fn parse_eslint_format(
1144        &self,
1145        line: &str,
1146        tool_id: &str,
1147        code_block_start_line: usize,
1148    ) -> Option<CodeBlockDiagnostic> {
1149        // Match "1:10 error Message"
1150        let parts: Vec<&str> = line.splitn(3, ' ').collect();
1151        if parts.len() >= 2 {
1152            let loc_parts: Vec<&str> = parts[0].split(':').collect();
1153            if loc_parts.len() == 2
1154                && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
1155            {
1156                let (sev_part, msg_part) = if parts.len() >= 3 {
1157                    (parts[1], parts[2])
1158                } else {
1159                    (parts[1], "")
1160                };
1161                let message = if msg_part.is_empty() {
1162                    sev_part.to_string()
1163                } else {
1164                    msg_part.to_string()
1165                };
1166                let message = Self::strip_fixable_markers(&message);
1167                let severity = match sev_part.to_lowercase().as_str() {
1168                    "error" => DiagnosticSeverity::Error,
1169                    "warning" | "warn" => DiagnosticSeverity::Warning,
1170                    "info" => DiagnosticSeverity::Info,
1171                    _ => self.infer_severity(&message),
1172                };
1173                return Some(CodeBlockDiagnostic {
1174                    file_line: code_block_start_line + line_num,
1175                    column: Some(col),
1176                    message,
1177                    severity,
1178                    tool: tool_id.to_string(),
1179                    code_block_start: code_block_start_line,
1180                });
1181            }
1182        }
1183        None
1184    }
1185
1186    /// Parse shellcheck-style "In - line N: message" format.
1187    fn parse_shellcheck_format(
1188        &self,
1189        line: &str,
1190        tool_id: &str,
1191        code_block_start_line: usize,
1192    ) -> Option<CodeBlockDiagnostic> {
1193        // Match "In - line 5:" pattern
1194        if line.starts_with("In ")
1195            && line.contains(" line ")
1196            && let Some(line_start) = line.find(" line ")
1197        {
1198            let after_line = &line[line_start + 6..];
1199            if let Some(colon_pos) = after_line.find(':')
1200                && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1201            {
1202                let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1203                if !message.is_empty() {
1204                    let severity = self.infer_severity(&message);
1205                    return Some(CodeBlockDiagnostic {
1206                        file_line: code_block_start_line + line_num,
1207                        column: None,
1208                        message,
1209                        severity,
1210                        tool: tool_id.to_string(),
1211                        code_block_start: code_block_start_line,
1212                    });
1213                }
1214            }
1215        }
1216        None
1217    }
1218
1219    /// Parse shellcheck header line to capture line number context.
1220    fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1221        if line.starts_with("In ")
1222            && line.contains(" line ")
1223            && let Some(line_start) = line.find(" line ")
1224        {
1225            let after_line = &line[line_start + 6..];
1226            if let Some(colon_pos) = after_line.find(':') {
1227                return after_line[..colon_pos].trim().parse::<usize>().ok();
1228            }
1229        }
1230        None
1231    }
1232
1233    /// Parse shellcheck message line containing SCXXXX codes.
1234    fn parse_shellcheck_message(
1235        &self,
1236        line: &str,
1237        tool_id: &str,
1238        code_block_start_line: usize,
1239        line_num: usize,
1240    ) -> Option<CodeBlockDiagnostic> {
1241        let sc_pos = line.find("SC")?;
1242        let after_sc = &line[sc_pos + 2..];
1243        let code_len = after_sc.chars().take_while(char::is_ascii_digit).count();
1244        if code_len == 0 {
1245            return None;
1246        }
1247        let after_code = &after_sc[code_len..];
1248        let sev_start = after_code.find('(')? + 1;
1249        let sev_end = after_code[sev_start..].find(')')? + sev_start;
1250        let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1251        let message_start = after_code.find("):")? + 2;
1252        let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1253        if message.is_empty() {
1254            return None;
1255        }
1256
1257        let severity = match sev.as_str() {
1258            "error" => DiagnosticSeverity::Error,
1259            "warning" | "warn" => DiagnosticSeverity::Warning,
1260            "info" | "style" => DiagnosticSeverity::Info,
1261            _ => self.infer_severity(&message),
1262        };
1263
1264        Some(CodeBlockDiagnostic {
1265            file_line: code_block_start_line + line_num,
1266            column: None,
1267            message,
1268            severity,
1269            tool: tool_id.to_string(),
1270            code_block_start: code_block_start_line,
1271        })
1272    }
1273
1274    /// Parse "Error: <message>" or "Warning: <message>" lines.
1275    ///
1276    /// Used for tools like tombi that output multi-line diagnostics where the
1277    /// error message and position are on separate lines. Only matches capitalized
1278    /// prefixes to avoid conflicting with lowercase `error:` in less structured output.
1279    fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1280        let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1281            (msg, DiagnosticSeverity::Error)
1282        } else if let Some(msg) = line.strip_prefix("Warning:") {
1283            (msg, DiagnosticSeverity::Warning)
1284        } else {
1285            return None;
1286        };
1287        let msg = msg.trim();
1288        if msg.is_empty() {
1289            return None;
1290        }
1291        Some((msg.to_string(), severity))
1292    }
1293
1294    /// Parse "at line N column M" position lines (case-insensitive).
1295    ///
1296    /// Returns (line_number, column_number) if the pattern matches.
1297    fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1298        let lower = line.to_lowercase();
1299        let rest = lower.strip_prefix("at line ")?;
1300        let mut parts = rest.split_whitespace();
1301        let line_num: usize = parts.next()?.parse().ok()?;
1302        if parts.next()? != "column" {
1303            return None;
1304        }
1305        let col: usize = parts.next()?.parse().ok()?;
1306        Some((line_num, col))
1307    }
1308
1309    /// Infer severity from message content.
1310    fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1311        let lower = message.to_lowercase();
1312        if lower.contains("error")
1313            || lower.starts_with('e') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1314            || lower.starts_with('f') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1315        {
1316            DiagnosticSeverity::Error
1317        } else if lower.contains("warning")
1318            || lower.contains("warn")
1319            || lower.starts_with('w') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1320        {
1321            DiagnosticSeverity::Warning
1322        } else {
1323            DiagnosticSeverity::Info
1324        }
1325    }
1326
1327    /// Strip "fixable" markers from external tool messages.
1328    ///
1329    /// External tools like ruff show `[*]` to indicate fixable issues, but in rumdl's
1330    /// context these markers can be misleading - the lint tool's fix capability may
1331    /// differ from what our configured formatter can fix. We strip these markers
1332    /// to avoid making promises we can't keep.
1333    fn strip_fixable_markers(message: &str) -> String {
1334        message
1335            .replace(" [*]", "")
1336            .replace("[*] ", "")
1337            .replace("[*]", "")
1338            .replace(" (fixable)", "")
1339            .replace("(fixable) ", "")
1340            .replace("(fixable)", "")
1341            .replace(" [fix available]", "")
1342            .replace("[fix available] ", "")
1343            .replace("[fix available]", "")
1344            .replace(" [autofix]", "")
1345            .replace("[autofix] ", "")
1346            .replace("[autofix]", "")
1347            .trim()
1348            .to_string()
1349    }
1350}
1351
1352/// Builder for FencedCodeBlockInfo during parsing.
1353struct FencedCodeBlockBuilder {
1354    start_line: usize,
1355    content_start: usize,
1356    language: String,
1357    info_string: String,
1358    fence_char: char,
1359    fence_length: usize,
1360    indent: usize,
1361    indent_prefix: String,
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367
1368    fn default_config() -> CodeBlockToolsConfig {
1369        CodeBlockToolsConfig::default()
1370    }
1371
1372    #[test]
1373    fn test_extract_code_blocks() {
1374        let config = default_config();
1375        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1376
1377        let content = r#"# Example
1378
1379```python
1380def hello():
1381    print("Hello")
1382```
1383
1384Some text
1385
1386```rust
1387fn main() {}
1388```
1389"#;
1390
1391        let blocks = processor.extract_code_blocks(content);
1392
1393        assert_eq!(blocks.len(), 2);
1394
1395        assert_eq!(blocks[0].language, "python");
1396        assert_eq!(blocks[0].fence_char, '`');
1397        assert_eq!(blocks[0].fence_length, 3);
1398        assert_eq!(blocks[0].start_line, 2);
1399        assert_eq!(blocks[0].indent, 0);
1400        assert_eq!(blocks[0].indent_prefix, "");
1401
1402        assert_eq!(blocks[1].language, "rust");
1403        assert_eq!(blocks[1].fence_char, '`');
1404        assert_eq!(blocks[1].fence_length, 3);
1405    }
1406
1407    #[test]
1408    fn test_extract_code_blocks_with_info_string() {
1409        let config = default_config();
1410        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1411
1412        let content = "```python title=\"example.py\"\ncode\n```";
1413        let blocks = processor.extract_code_blocks(content);
1414
1415        assert_eq!(blocks.len(), 1);
1416        assert_eq!(blocks[0].language, "python");
1417        assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1418    }
1419
1420    #[test]
1421    fn test_extract_code_blocks_tilde_fence() {
1422        let config = default_config();
1423        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1424
1425        let content = "~~~bash\necho hello\n~~~";
1426        let blocks = processor.extract_code_blocks(content);
1427
1428        assert_eq!(blocks.len(), 1);
1429        assert_eq!(blocks[0].language, "bash");
1430        assert_eq!(blocks[0].fence_char, '~');
1431        assert_eq!(blocks[0].fence_length, 3);
1432        assert_eq!(blocks[0].indent_prefix, "");
1433    }
1434
1435    #[test]
1436    fn test_extract_code_blocks_with_indent_prefix() {
1437        let config = default_config();
1438        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1439
1440        let content = "  - item\n    ```python\n    print('hi')\n    ```";
1441        let blocks = processor.extract_code_blocks(content);
1442
1443        assert_eq!(blocks.len(), 1);
1444        assert_eq!(blocks[0].indent_prefix, "    ");
1445    }
1446
1447    #[test]
1448    fn test_extract_code_blocks_no_language() {
1449        let config = default_config();
1450        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1451
1452        let content = "```\nplain code\n```";
1453        let blocks = processor.extract_code_blocks(content);
1454
1455        assert_eq!(blocks.len(), 1);
1456        assert_eq!(blocks[0].language, "");
1457    }
1458
1459    #[test]
1460    fn test_resolve_language_linguist() {
1461        let mut config = default_config();
1462        config.normalize_language = NormalizeLanguage::Linguist;
1463        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1464
1465        assert_eq!(processor.resolve_language("py"), "python");
1466        assert_eq!(processor.resolve_language("bash"), "shell");
1467        assert_eq!(processor.resolve_language("js"), "javascript");
1468    }
1469
1470    #[test]
1471    fn test_resolve_language_exact() {
1472        let mut config = default_config();
1473        config.normalize_language = NormalizeLanguage::Exact;
1474        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1475
1476        assert_eq!(processor.resolve_language("py"), "py");
1477        assert_eq!(processor.resolve_language("BASH"), "bash");
1478    }
1479
1480    #[test]
1481    fn test_resolve_language_user_alias_override() {
1482        let mut config = default_config();
1483        config.language_aliases.insert("py".to_string(), "python".to_string());
1484        config.normalize_language = NormalizeLanguage::Exact;
1485        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1486
1487        assert_eq!(processor.resolve_language("PY"), "python");
1488    }
1489
1490    #[test]
1491    fn test_indent_strip_and_reapply_roundtrip() {
1492        let config = default_config();
1493        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1494
1495        let raw = "    def hello():\n        print('hi')";
1496        let stripped = processor.strip_indent_from_block(raw, "    ");
1497        assert_eq!(stripped, "def hello():\n    print('hi')");
1498
1499        let reapplied = processor.apply_indent_to_block(&stripped, "    ");
1500        assert_eq!(reapplied, raw);
1501    }
1502
1503    #[test]
1504    fn test_infer_severity() {
1505        let config = default_config();
1506        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1507
1508        assert_eq!(
1509            processor.infer_severity("E501 line too long"),
1510            DiagnosticSeverity::Error
1511        );
1512        assert_eq!(
1513            processor.infer_severity("W291 trailing whitespace"),
1514            DiagnosticSeverity::Warning
1515        );
1516        assert_eq!(
1517            processor.infer_severity("error: something failed"),
1518            DiagnosticSeverity::Error
1519        );
1520        assert_eq!(
1521            processor.infer_severity("warning: unused variable"),
1522            DiagnosticSeverity::Warning
1523        );
1524        assert_eq!(
1525            processor.infer_severity("note: consider using"),
1526            DiagnosticSeverity::Info
1527        );
1528    }
1529
1530    #[test]
1531    fn test_parse_standard_format_windows_path() {
1532        let config = default_config();
1533        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1534
1535        let output = ToolOutput {
1536            stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1537            stderr: String::new(),
1538            exit_code: 1,
1539            success: false,
1540        };
1541
1542        let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1543        assert_eq!(diags.len(), 1);
1544        assert_eq!(diags[0].file_line, 12);
1545        assert_eq!(diags[0].column, Some(5));
1546        assert_eq!(diags[0].message, "E123 message");
1547    }
1548
1549    #[test]
1550    fn test_parse_eslint_severity() {
1551        let config = default_config();
1552        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1553
1554        let output = ToolOutput {
1555            stdout: "1:2 error Unexpected token".to_string(),
1556            stderr: String::new(),
1557            exit_code: 1,
1558            success: false,
1559        };
1560
1561        let diags = processor.parse_tool_output(&output, "eslint", 5);
1562        assert_eq!(diags.len(), 1);
1563        assert_eq!(diags[0].file_line, 6);
1564        assert_eq!(diags[0].column, Some(2));
1565        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1566        assert_eq!(diags[0].message, "Unexpected token");
1567    }
1568
1569    #[test]
1570    fn test_parse_shellcheck_multiline() {
1571        let config = default_config();
1572        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1573
1574        let output = ToolOutput {
1575            stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1576            stderr: String::new(),
1577            exit_code: 1,
1578            success: false,
1579        };
1580
1581        let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1582        assert_eq!(diags.len(), 1);
1583        assert_eq!(diags[0].file_line, 13);
1584        assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1585        assert_eq!(diags[0].message, "Double quote to prevent globbing");
1586    }
1587
1588    #[test]
1589    fn test_lint_no_config() {
1590        let config = default_config();
1591        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1592
1593        let content = "```python\nprint('hello')\n```";
1594        let result = processor.lint(content);
1595
1596        // Should succeed with no diagnostics (no tools configured)
1597        assert!(result.is_ok());
1598        assert!(result.unwrap().is_empty());
1599    }
1600
1601    #[test]
1602    fn test_format_no_config() {
1603        let config = default_config();
1604        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1605
1606        let content = "```python\nprint('hello')\n```";
1607        let result = processor.format(content);
1608
1609        // Should succeed with unchanged content (no tools configured)
1610        assert!(result.is_ok());
1611        let output = result.unwrap();
1612        assert_eq!(output.content, content);
1613        assert!(!output.had_errors);
1614        assert!(output.error_messages.is_empty());
1615    }
1616
1617    #[test]
1618    fn test_lint_on_missing_language_definition_fail() {
1619        let mut config = default_config();
1620        config.on_missing_language_definition = OnMissing::Fail;
1621        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1622
1623        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1624        let result = processor.lint(content);
1625
1626        // Should succeed but return diagnostics for both missing language definitions
1627        assert!(result.is_ok());
1628        let diagnostics = result.unwrap();
1629        assert_eq!(diagnostics.len(), 2);
1630        assert!(diagnostics[0].message.contains("No lint tools configured"));
1631        assert!(diagnostics[0].message.contains("python"));
1632        assert!(diagnostics[1].message.contains("javascript"));
1633    }
1634
1635    #[test]
1636    fn test_lint_on_missing_language_definition_fail_fast() {
1637        let mut config = default_config();
1638        config.on_missing_language_definition = OnMissing::FailFast;
1639        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1640
1641        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1642        let result = processor.lint(content);
1643
1644        // Should fail immediately on first missing language
1645        assert!(result.is_err());
1646        let err = result.unwrap_err();
1647        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1648    }
1649
1650    #[test]
1651    fn test_format_on_missing_language_definition_fail() {
1652        let mut config = default_config();
1653        config.on_missing_language_definition = OnMissing::Fail;
1654        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1655
1656        let content = "```python\nprint('hello')\n```";
1657        let result = processor.format(content);
1658
1659        // Should succeed but report errors
1660        assert!(result.is_ok());
1661        let output = result.unwrap();
1662        assert_eq!(output.content, content); // Content unchanged
1663        assert!(output.had_errors);
1664        assert!(!output.error_messages.is_empty());
1665        assert!(output.error_messages[0].contains("No format tools configured"));
1666    }
1667
1668    #[test]
1669    fn test_format_on_missing_language_definition_fail_fast() {
1670        let mut config = default_config();
1671        config.on_missing_language_definition = OnMissing::FailFast;
1672        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1673
1674        let content = "```python\nprint('hello')\n```";
1675        let result = processor.format(content);
1676
1677        // Should fail immediately
1678        assert!(result.is_err());
1679        let err = result.unwrap_err();
1680        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1681    }
1682
1683    #[test]
1684    fn test_lint_on_missing_tool_binary_fail() {
1685        use super::super::config::{LanguageToolConfig, ToolDefinition};
1686
1687        let mut config = default_config();
1688        config.on_missing_tool_binary = OnMissing::Fail;
1689
1690        // Configure a tool with a non-existent binary
1691        let lang_config = LanguageToolConfig {
1692            lint: vec!["nonexistent-linter".to_string()],
1693            ..Default::default()
1694        };
1695        config.languages.insert("python".to_string(), lang_config);
1696
1697        let tool_def = ToolDefinition {
1698            command: vec!["nonexistent-binary-xyz123".to_string()],
1699            ..Default::default()
1700        };
1701        config.tools.insert("nonexistent-linter".to_string(), tool_def);
1702
1703        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1704
1705        let content = "```python\nprint('hello')\n```";
1706        let result = processor.lint(content);
1707
1708        // Should succeed but return diagnostic for missing binary
1709        assert!(result.is_ok());
1710        let diagnostics = result.unwrap();
1711        assert_eq!(diagnostics.len(), 1);
1712        assert!(diagnostics[0].message.contains("not found in PATH"));
1713    }
1714
1715    #[test]
1716    fn test_lint_on_missing_tool_binary_fail_fast() {
1717        use super::super::config::{LanguageToolConfig, ToolDefinition};
1718
1719        let mut config = default_config();
1720        config.on_missing_tool_binary = OnMissing::FailFast;
1721
1722        // Configure a tool with a non-existent binary
1723        let lang_config = LanguageToolConfig {
1724            lint: vec!["nonexistent-linter".to_string()],
1725            ..Default::default()
1726        };
1727        config.languages.insert("python".to_string(), lang_config);
1728
1729        let tool_def = ToolDefinition {
1730            command: vec!["nonexistent-binary-xyz123".to_string()],
1731            ..Default::default()
1732        };
1733        config.tools.insert("nonexistent-linter".to_string(), tool_def);
1734
1735        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1736
1737        let content = "```python\nprint('hello')\n```";
1738        let result = processor.lint(content);
1739
1740        // Should fail immediately
1741        assert!(result.is_err());
1742        let err = result.unwrap_err();
1743        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1744    }
1745
1746    #[test]
1747    fn test_format_on_missing_tool_binary_fail() {
1748        use super::super::config::{LanguageToolConfig, ToolDefinition};
1749
1750        let mut config = default_config();
1751        config.on_missing_tool_binary = OnMissing::Fail;
1752
1753        // Configure a tool with a non-existent binary
1754        let lang_config = LanguageToolConfig {
1755            format: vec!["nonexistent-formatter".to_string()],
1756            ..Default::default()
1757        };
1758        config.languages.insert("python".to_string(), lang_config);
1759
1760        let tool_def = ToolDefinition {
1761            command: vec!["nonexistent-binary-xyz123".to_string()],
1762            ..Default::default()
1763        };
1764        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1765
1766        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1767
1768        let content = "```python\nprint('hello')\n```";
1769        let result = processor.format(content);
1770
1771        // Should succeed but report errors
1772        assert!(result.is_ok());
1773        let output = result.unwrap();
1774        assert_eq!(output.content, content); // Content unchanged
1775        assert!(output.had_errors);
1776        assert!(!output.error_messages.is_empty());
1777        assert!(output.error_messages[0].contains("not found in PATH"));
1778    }
1779
1780    #[test]
1781    fn test_format_on_missing_tool_binary_fail_fast() {
1782        use super::super::config::{LanguageToolConfig, ToolDefinition};
1783
1784        let mut config = default_config();
1785        config.on_missing_tool_binary = OnMissing::FailFast;
1786
1787        // Configure a tool with a non-existent binary
1788        let lang_config = LanguageToolConfig {
1789            format: vec!["nonexistent-formatter".to_string()],
1790            ..Default::default()
1791        };
1792        config.languages.insert("python".to_string(), lang_config);
1793
1794        let tool_def = ToolDefinition {
1795            command: vec!["nonexistent-binary-xyz123".to_string()],
1796            ..Default::default()
1797        };
1798        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1799
1800        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1801
1802        let content = "```python\nprint('hello')\n```";
1803        let result = processor.format(content);
1804
1805        // Should fail immediately
1806        assert!(result.is_err());
1807        let err = result.unwrap_err();
1808        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1809    }
1810
1811    #[test]
1812    fn test_lint_rumdl_builtin_skipped_for_markdown() {
1813        // Configure the built-in "rumdl" tool for markdown
1814        // The processor should skip it (handled by embedded markdown linting)
1815        let mut config = default_config();
1816        config.languages.insert(
1817            "markdown".to_string(),
1818            LanguageToolConfig {
1819                lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
1820                ..Default::default()
1821            },
1822        );
1823        config.on_missing_language_definition = OnMissing::Fail;
1824        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1825
1826        let content = "```markdown\n# Hello\n```";
1827        let result = processor.lint(content);
1828
1829        // Should succeed with no diagnostics - "rumdl" tool is skipped, not treated as unknown
1830        assert!(result.is_ok());
1831        assert!(result.unwrap().is_empty());
1832    }
1833
1834    #[test]
1835    fn test_format_rumdl_builtin_skipped_for_markdown() {
1836        // Configure the built-in "rumdl" tool for markdown
1837        let mut config = default_config();
1838        config.languages.insert(
1839            "markdown".to_string(),
1840            LanguageToolConfig {
1841                format: vec![RUMDL_BUILTIN_TOOL.to_string()],
1842                ..Default::default()
1843            },
1844        );
1845        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1846
1847        let content = "```markdown\n# Hello\n```";
1848        let result = processor.format(content);
1849
1850        // Should succeed with unchanged content - "rumdl" tool is skipped
1851        assert!(result.is_ok());
1852        let output = result.unwrap();
1853        assert_eq!(output.content, content);
1854        assert!(!output.had_errors);
1855    }
1856
1857    #[test]
1858    fn test_is_markdown_language() {
1859        // Test the helper function
1860        assert!(is_markdown_language("markdown"));
1861        assert!(is_markdown_language("Markdown"));
1862        assert!(is_markdown_language("MARKDOWN"));
1863        assert!(is_markdown_language("md"));
1864        assert!(is_markdown_language("MD"));
1865        assert!(!is_markdown_language("python"));
1866        assert!(!is_markdown_language("rust"));
1867        assert!(!is_markdown_language(""));
1868    }
1869
1870    // Issue #423: MkDocs admonition code block detection
1871
1872    #[test]
1873    fn test_extract_mkdocs_admonition_code_block() {
1874        let config = default_config();
1875        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1876
1877        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
1878        let blocks = processor.extract_code_blocks(content);
1879
1880        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
1881        assert_eq!(blocks[0].language, "python");
1882    }
1883
1884    #[test]
1885    fn test_extract_mkdocs_tab_code_block() {
1886        let config = default_config();
1887        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1888
1889        let content = "=== \"Python\"\n\n    ```python\n    print(\"hello\")\n    ```\n";
1890        let blocks = processor.extract_code_blocks(content);
1891
1892        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
1893        assert_eq!(blocks[0].language, "python");
1894    }
1895
1896    #[test]
1897    fn test_standard_flavor_ignores_admonition_indented_content() {
1898        let config = default_config();
1899        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1900
1901        // With standard flavor, pulldown_cmark parses this differently;
1902        // our MkDocs extraction should NOT run
1903        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
1904        let blocks = processor.extract_code_blocks(content);
1905
1906        // Standard flavor relies on pulldown_cmark only, which may or may not detect
1907        // indented fenced blocks. The key assertion is that we don't double-detect.
1908        // With standard flavor, the MkDocs extraction path is skipped entirely.
1909        for (i, b) in blocks.iter().enumerate() {
1910            for (j, b2) in blocks.iter().enumerate() {
1911                if i != j {
1912                    assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
1913                }
1914            }
1915        }
1916    }
1917
1918    #[test]
1919    fn test_mkdocs_top_level_blocks_alongside_admonition() {
1920        let config = default_config();
1921        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1922
1923        let content =
1924            "```rust\nfn main() {}\n```\n\n!!! note\n    Some text\n\n    ```python\n    print(\"hello\")\n    ```\n";
1925        let blocks = processor.extract_code_blocks(content);
1926
1927        assert_eq!(
1928            blocks.len(),
1929            2,
1930            "Should detect both top-level and admonition code blocks"
1931        );
1932        assert_eq!(blocks[0].language, "rust");
1933        assert_eq!(blocks[1].language, "python");
1934    }
1935
1936    #[test]
1937    fn test_mkdocs_nested_admonition_code_block() {
1938        let config = default_config();
1939        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1940
1941        let content = "\
1942!!! note
1943    Some text
1944
1945    !!! warning
1946        Nested content
1947
1948        ```python
1949        x = 1
1950        ```
1951";
1952        let blocks = processor.extract_code_blocks(content);
1953        assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
1954        assert_eq!(blocks[0].language, "python");
1955    }
1956
1957    #[test]
1958    fn test_mkdocs_consecutive_admonitions_no_stale_context() {
1959        let config = default_config();
1960        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1961
1962        // Two consecutive admonitions at the same indent level.
1963        // The first has no code block, the second does.
1964        let content = "\
1965!!! note
1966    First admonition content
1967
1968!!! warning
1969    Second admonition content
1970
1971    ```python
1972    y = 2
1973    ```
1974";
1975        let blocks = processor.extract_code_blocks(content);
1976        assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
1977        assert_eq!(blocks[0].language, "python");
1978    }
1979
1980    #[test]
1981    fn test_mkdocs_crlf_line_endings() {
1982        let config = default_config();
1983        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1984
1985        // Use \r\n line endings
1986        let content = "!!! note\r\n    Some text\r\n\r\n    ```python\r\n    x = 1\r\n    ```\r\n";
1987        let blocks = processor.extract_code_blocks(content);
1988
1989        assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
1990        assert_eq!(blocks[0].language, "python");
1991
1992        // Verify byte offsets point to valid content
1993        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1994        assert!(
1995            extracted.contains("x = 1"),
1996            "Extracted content should contain code. Got: {extracted:?}"
1997        );
1998    }
1999
2000    #[test]
2001    fn test_mkdocs_unclosed_fence_in_admonition() {
2002        let config = default_config();
2003        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2004
2005        // Unclosed fence should not produce a block
2006        let content = "!!! note\n    ```python\n    x = 1\n    no closing fence\n";
2007        let blocks = processor.extract_code_blocks(content);
2008        assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
2009    }
2010
2011    #[test]
2012    fn test_mkdocs_tilde_fence_in_admonition() {
2013        let config = default_config();
2014        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2015
2016        let content = "!!! note\n    ~~~ruby\n    puts 'hi'\n    ~~~\n";
2017        let blocks = processor.extract_code_blocks(content);
2018        assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
2019        assert_eq!(blocks[0].language, "ruby");
2020    }
2021
2022    #[test]
2023    fn test_mkdocs_empty_lines_in_code_block() {
2024        let config = default_config();
2025        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2026
2027        // Code block with empty lines inside — verifies byte offsets are correct
2028        // across empty lines (the previous find("") approach would break here)
2029        let content = "!!! note\n    ```python\n    x = 1\n\n    y = 2\n    ```\n";
2030        let blocks = processor.extract_code_blocks(content);
2031        assert_eq!(blocks.len(), 1);
2032
2033        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2034        assert!(
2035            extracted.contains("x = 1") && extracted.contains("y = 2"),
2036            "Extracted content should span across the empty line. Got: {extracted:?}"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_mkdocs_content_byte_offsets_lf() {
2042        let config = default_config();
2043        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2044
2045        let content = "!!! note\n    ```python\n    print('hi')\n    ```\n";
2046        let blocks = processor.extract_code_blocks(content);
2047        assert_eq!(blocks.len(), 1);
2048
2049        // Verify the extracted content is exactly the code body
2050        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2051        assert_eq!(extracted, "    print('hi')\n", "Content offsets should be exact for LF");
2052    }
2053
2054    #[test]
2055    fn test_mkdocs_content_byte_offsets_crlf() {
2056        let config = default_config();
2057        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2058
2059        let content = "!!! note\r\n    ```python\r\n    print('hi')\r\n    ```\r\n";
2060        let blocks = processor.extract_code_blocks(content);
2061        assert_eq!(blocks.len(), 1);
2062
2063        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2064        assert_eq!(
2065            extracted, "    print('hi')\r\n",
2066            "Content offsets should be exact for CRLF"
2067        );
2068    }
2069
2070    #[test]
2071    fn test_lint_enabled_false_skips_language_in_strict_mode() {
2072        // With on-missing-language-definition = "fail", a language configured
2073        // with enabled=false should be silently skipped (no error).
2074        let mut config = default_config();
2075        config.normalize_language = NormalizeLanguage::Exact;
2076        config.on_missing_language_definition = OnMissing::Fail;
2077
2078        // Python has tools, plaintext is disabled
2079        config.languages.insert(
2080            "python".to_string(),
2081            LanguageToolConfig {
2082                lint: vec!["ruff:check".to_string()],
2083                ..Default::default()
2084            },
2085        );
2086        config.languages.insert(
2087            "plaintext".to_string(),
2088            LanguageToolConfig {
2089                enabled: false,
2090                ..Default::default()
2091            },
2092        );
2093
2094        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2095
2096        let content = "```plaintext\nsome text\n```";
2097        let result = processor.lint(content);
2098
2099        // No error for plaintext: enabled=false satisfies strict mode
2100        assert!(result.is_ok());
2101        let diagnostics = result.unwrap();
2102        assert!(
2103            diagnostics.is_empty(),
2104            "Expected no diagnostics for disabled language, got: {diagnostics:?}"
2105        );
2106    }
2107
2108    #[test]
2109    fn test_format_enabled_false_skips_language_in_strict_mode() {
2110        // Same test but for format mode
2111        let mut config = default_config();
2112        config.normalize_language = NormalizeLanguage::Exact;
2113        config.on_missing_language_definition = OnMissing::Fail;
2114
2115        config.languages.insert(
2116            "plaintext".to_string(),
2117            LanguageToolConfig {
2118                enabled: false,
2119                ..Default::default()
2120            },
2121        );
2122
2123        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2124
2125        let content = "```plaintext\nsome text\n```";
2126        let result = processor.format(content);
2127
2128        // No error for plaintext: enabled=false satisfies strict mode
2129        assert!(result.is_ok());
2130        let output = result.unwrap();
2131        assert!(!output.had_errors, "Expected no errors for disabled language");
2132        assert!(
2133            output.error_messages.is_empty(),
2134            "Expected no error messages, got: {:?}",
2135            output.error_messages
2136        );
2137    }
2138
2139    #[test]
2140    fn test_enabled_false_default_true_preserved() {
2141        // Verify that when enabled is not set, it defaults to true (existing behavior)
2142        let mut config = default_config();
2143        config.on_missing_language_definition = OnMissing::Fail;
2144
2145        // Configure python without explicitly setting enabled
2146        config.languages.insert(
2147            "python".to_string(),
2148            LanguageToolConfig {
2149                lint: vec!["ruff:check".to_string()],
2150                ..Default::default()
2151            },
2152        );
2153
2154        let lang_config = config.languages.get("python").unwrap();
2155        assert!(lang_config.enabled, "enabled should default to true");
2156    }
2157
2158    #[test]
2159    fn test_enabled_false_with_fail_fast_no_error() {
2160        // Even with fail-fast, enabled=false should skip silently
2161        let mut config = default_config();
2162        config.normalize_language = NormalizeLanguage::Exact;
2163        config.on_missing_language_definition = OnMissing::FailFast;
2164
2165        config.languages.insert(
2166            "unknown".to_string(),
2167            LanguageToolConfig {
2168                enabled: false,
2169                ..Default::default()
2170            },
2171        );
2172
2173        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2174
2175        let content = "```unknown\nsome content\n```";
2176        let result = processor.lint(content);
2177
2178        // Should not return an error: enabled=false takes precedence over fail-fast
2179        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2180        assert!(result.unwrap().is_empty());
2181    }
2182
2183    #[test]
2184    fn test_enabled_false_format_with_fail_fast_no_error() {
2185        // Same for format mode
2186        let mut config = default_config();
2187        config.normalize_language = NormalizeLanguage::Exact;
2188        config.on_missing_language_definition = OnMissing::FailFast;
2189
2190        config.languages.insert(
2191            "unknown".to_string(),
2192            LanguageToolConfig {
2193                enabled: false,
2194                ..Default::default()
2195            },
2196        );
2197
2198        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2199
2200        let content = "```unknown\nsome content\n```";
2201        let result = processor.format(content);
2202
2203        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2204        let output = result.unwrap();
2205        assert!(!output.had_errors);
2206    }
2207
2208    #[test]
2209    fn test_enabled_false_with_tools_still_skips() {
2210        // If enabled=false but tools are listed, the language should still be skipped
2211        let mut config = default_config();
2212        config.on_missing_language_definition = OnMissing::Fail;
2213
2214        config.languages.insert(
2215            "python".to_string(),
2216            LanguageToolConfig {
2217                enabled: false,
2218                lint: vec!["ruff:check".to_string()],
2219                format: vec!["ruff:format".to_string()],
2220                on_error: None,
2221            },
2222        );
2223
2224        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2225
2226        let content = "```python\nprint('hello')\n```";
2227
2228        // Lint should skip
2229        let lint_result = processor.lint(content);
2230        assert!(lint_result.is_ok());
2231        assert!(lint_result.unwrap().is_empty());
2232
2233        // Format should skip
2234        let format_result = processor.format(content);
2235        assert!(format_result.is_ok());
2236        let output = format_result.unwrap();
2237        assert!(!output.had_errors);
2238        assert_eq!(output.content, content, "Content should be unchanged");
2239    }
2240
2241    #[test]
2242    fn test_enabled_true_without_tools_triggers_strict_mode() {
2243        // A language configured with enabled=true (default) but no tools
2244        // should still trigger strict mode errors
2245        let mut config = default_config();
2246        config.on_missing_language_definition = OnMissing::Fail;
2247
2248        config.languages.insert(
2249            "python".to_string(),
2250            LanguageToolConfig {
2251                // enabled defaults to true, no tools
2252                ..Default::default()
2253            },
2254        );
2255
2256        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2257
2258        let content = "```python\nprint('hello')\n```";
2259        let result = processor.lint(content);
2260
2261        // Should report an error because enabled=true but no lint tools configured
2262        assert!(result.is_ok());
2263        let diagnostics = result.unwrap();
2264        assert_eq!(diagnostics.len(), 1);
2265        assert!(diagnostics[0].message.contains("No lint tools configured"));
2266    }
2267
2268    #[test]
2269    fn test_mixed_enabled_and_disabled_languages() {
2270        // Multiple languages: one disabled, one unconfigured
2271        let mut config = default_config();
2272        config.normalize_language = NormalizeLanguage::Exact;
2273        config.on_missing_language_definition = OnMissing::Fail;
2274
2275        config.languages.insert(
2276            "plaintext".to_string(),
2277            LanguageToolConfig {
2278                enabled: false,
2279                ..Default::default()
2280            },
2281        );
2282
2283        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2284
2285        let content = "\
2286```plaintext
2287some text
2288```
2289
2290```javascript
2291console.log('hi');
2292```
2293";
2294
2295        let result = processor.lint(content);
2296        assert!(result.is_ok());
2297        let diagnostics = result.unwrap();
2298
2299        // plaintext: skipped (enabled=false), no error
2300        // javascript: not configured at all, should trigger strict mode error
2301        assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2302        assert!(
2303            diagnostics[0].message.contains("javascript"),
2304            "Error should be about javascript, got: {}",
2305            diagnostics[0].message
2306        );
2307    }
2308
2309    #[test]
2310    fn test_generic_fallback_includes_all_stderr_lines() {
2311        let config = default_config();
2312        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2313
2314        // Use output that won't be parsed by any structured format parser
2315        let output = ToolOutput {
2316            stdout: String::new(),
2317            stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2318            exit_code: 1,
2319            success: false,
2320        };
2321
2322        let diags = processor.parse_tool_output(&output, "tombi", 5);
2323        assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2324        assert_eq!(diags[0].message, "Parse error at position 42");
2325        assert_eq!(diags[1].message, "Unexpected token '::'");
2326        assert_eq!(diags[2].message, "3 errors found");
2327        assert!(diags.iter().all(|d| d.tool == "tombi"));
2328        assert!(diags.iter().all(|d| d.file_line == 5));
2329    }
2330
2331    #[test]
2332    fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2333        let config = default_config();
2334        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2335
2336        let output = ToolOutput {
2337            stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2338            stderr: String::new(),
2339            exit_code: 1,
2340            success: false,
2341        };
2342
2343        let diags = processor.parse_tool_output(&output, "some-tool", 10);
2344        assert_eq!(diags.len(), 3);
2345        assert_eq!(diags[0].message, "Line 1 error");
2346        assert_eq!(diags[1].message, "Line 2 detail");
2347        assert_eq!(diags[2].message, "Line 3 summary");
2348    }
2349
2350    #[test]
2351    fn test_generic_fallback_skips_blank_lines() {
2352        let config = default_config();
2353        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2354
2355        let output = ToolOutput {
2356            stdout: String::new(),
2357            stderr: "error: bad input\n\n  \n\ndetail: see above\n".to_string(),
2358            exit_code: 1,
2359            success: false,
2360        };
2361
2362        let diags = processor.parse_tool_output(&output, "tool", 1);
2363        assert_eq!(diags.len(), 2);
2364        assert_eq!(diags[0].message, "error: bad input");
2365        assert_eq!(diags[1].message, "detail: see above");
2366    }
2367
2368    #[test]
2369    fn test_generic_fallback_exit_code_when_no_output() {
2370        let config = default_config();
2371        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2372
2373        let output = ToolOutput {
2374            stdout: String::new(),
2375            stderr: String::new(),
2376            exit_code: 42,
2377            success: false,
2378        };
2379
2380        let diags = processor.parse_tool_output(&output, "tool", 1);
2381        assert_eq!(diags.len(), 1);
2382        assert_eq!(diags[0].message, "Tool exited with code 42");
2383    }
2384
2385    #[test]
2386    fn test_generic_fallback_not_triggered_on_success() {
2387        let config = default_config();
2388        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2389
2390        let output = ToolOutput {
2391            stdout: "some informational output".to_string(),
2392            stderr: String::new(),
2393            exit_code: 0,
2394            success: true,
2395        };
2396
2397        let diags = processor.parse_tool_output(&output, "tool", 1);
2398        assert!(
2399            diags.is_empty(),
2400            "Successful tool runs should produce no fallback diagnostics"
2401        );
2402    }
2403
2404    #[test]
2405    fn test_ansi_codes_stripped_before_parsing() {
2406        let config = default_config();
2407        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2408
2409        // ruff-style output with ANSI color codes wrapping the message
2410        let output = ToolOutput {
2411            stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
2412                .to_string(),
2413            stderr: String::new(),
2414            exit_code: 1,
2415            success: false,
2416        };
2417
2418        let diags = processor.parse_tool_output(&output, "ruff:check", 5);
2419        assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
2420        assert_eq!(diags[0].message, "E501 Line too long");
2421        assert_eq!(diags[0].file_line, 6); // 5 + 1
2422    }
2423
2424    #[test]
2425    fn test_tombi_multiline_error_format() {
2426        let config = default_config();
2427        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2428
2429        // Simulates tombi output (ANSI codes stripped for clarity)
2430        let output = ToolOutput {
2431            stdout: "[test]\ntest: \"test\"\nError: invalid key\n    at line 2 column 1\nError: expected key\n    at line 2 column 1\nError: expected '='\n    at line 2 column 1\nError: expected value\n    at line 2 column 1".to_string(),
2432            stderr: "1 file failed to be formatted".to_string(),
2433            exit_code: 1,
2434            success: false,
2435        };
2436
2437        let diags = processor.parse_tool_output(&output, "tombi", 7);
2438        assert_eq!(
2439            diags.len(),
2440            4,
2441            "Expected 4 diagnostics from tombi errors, got {diags:?}"
2442        );
2443        assert_eq!(diags[0].message, "invalid key");
2444        assert_eq!(diags[0].file_line, 9); // 7 + 2
2445        assert_eq!(diags[0].column, Some(1));
2446        assert_eq!(diags[1].message, "expected key");
2447        assert_eq!(diags[1].file_line, 9);
2448        assert_eq!(diags[2].message, "expected '='");
2449        assert_eq!(diags[3].message, "expected value");
2450        assert!(diags.iter().all(|d| d.tool == "tombi"));
2451    }
2452
2453    #[test]
2454    fn test_tombi_with_ansi_codes() {
2455        let config = default_config();
2456        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2457
2458        // Real tombi output with ANSI escape codes
2459        let output = ToolOutput {
2460            stdout: "[test]\ntest: \"test\"\n\x1b[1;31m  Error\x1b[0m: \x1b[1minvalid key\x1b[0m\n    \x1b[90mat line 2 column 1\x1b[0m\n\x1b[1;31m  Error\x1b[0m: \x1b[1mexpected '='\x1b[0m\n    \x1b[90mat line 2 column 1\x1b[0m".to_string(),
2461            stderr: "1 file failed to be formatted".to_string(),
2462            exit_code: 1,
2463            success: false,
2464        };
2465
2466        let diags = processor.parse_tool_output(&output, "tombi", 7);
2467        assert_eq!(
2468            diags.len(),
2469            2,
2470            "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
2471        );
2472        assert_eq!(diags[0].message, "invalid key");
2473        assert_eq!(diags[0].file_line, 9);
2474        assert_eq!(diags[1].message, "expected '='");
2475        assert_eq!(diags[1].file_line, 9);
2476    }
2477
2478    #[test]
2479    fn test_fallback_combines_stdout_and_stderr() {
2480        let config = default_config();
2481        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2482
2483        // Tool puts some errors on stdout, summary on stderr
2484        let output = ToolOutput {
2485            stdout: "problem found in input".to_string(),
2486            stderr: "1 file failed".to_string(),
2487            exit_code: 1,
2488            success: false,
2489        };
2490
2491        let diags = processor.parse_tool_output(&output, "tool", 1);
2492        assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
2493        assert_eq!(diags[0].message, "problem found in input");
2494        assert_eq!(diags[1].message, "1 file failed");
2495    }
2496
2497    #[test]
2498    fn test_error_line_without_position_info() {
2499        let config = default_config();
2500        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2501
2502        // Error: line not followed by "at line N column M"
2503        let output = ToolOutput {
2504            stdout: "Error: something went wrong\nsome unrelated line".to_string(),
2505            stderr: String::new(),
2506            exit_code: 1,
2507            success: false,
2508        };
2509
2510        let diags = processor.parse_tool_output(&output, "tool", 5);
2511        // "Error: something went wrong" → parsed by error-line parser (no position)
2512        // "some unrelated line" → no parser matches, but diagnostics not empty → no fallback
2513        assert!(!diags.is_empty());
2514        assert_eq!(diags[0].message, "something went wrong");
2515        assert_eq!(diags[0].file_line, 5); // No line offset, uses code_block_start
2516    }
2517
2518    #[test]
2519    fn test_warning_line_with_position() {
2520        let config = default_config();
2521        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2522
2523        let output = ToolOutput {
2524            stdout: "Warning: deprecated syntax\n    at line 3 column 5".to_string(),
2525            stderr: String::new(),
2526            exit_code: 1,
2527            success: false,
2528        };
2529
2530        let diags = processor.parse_tool_output(&output, "tool", 10);
2531        assert_eq!(diags.len(), 1);
2532        assert_eq!(diags[0].message, "deprecated syntax");
2533        assert_eq!(diags[0].file_line, 13); // 10 + 3
2534        assert_eq!(diags[0].column, Some(5));
2535        assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
2536    }
2537
2538    #[test]
2539    fn test_strip_ansi_codes() {
2540        assert_eq!(strip_ansi_codes("hello"), "hello");
2541        assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
2542        assert_eq!(
2543            strip_ansi_codes("\x1b[1;31m  Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
2544            "  Error: msg"
2545        );
2546        assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
2547        assert_eq!(strip_ansi_codes(""), "");
2548        assert_eq!(
2549            strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
2550            "at line 2 column 1"
2551        );
2552    }
2553
2554    #[test]
2555    fn test_parse_at_line_column() {
2556        assert_eq!(
2557            CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
2558            Some((2, 1))
2559        );
2560        assert_eq!(
2561            CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
2562            Some((10, 15))
2563        );
2564        assert_eq!(
2565            CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
2566            Some((5, 3))
2567        );
2568        assert_eq!(
2569            CodeBlockToolProcessor::parse_at_line_column("not a position line"),
2570            None
2571        );
2572        assert_eq!(
2573            CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
2574            None
2575        );
2576    }
2577
2578    #[test]
2579    fn test_parse_error_line() {
2580        let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
2581        assert_eq!(msg, "invalid key");
2582        assert!(matches!(sev, DiagnosticSeverity::Error));
2583
2584        let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
2585        assert_eq!(msg, "deprecated");
2586        assert!(matches!(sev, DiagnosticSeverity::Warning));
2587
2588        // Lowercase should NOT match (avoids conflict with unstructured tool output)
2589        assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
2590        assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
2591
2592        // Empty message after prefix should not match
2593        assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
2594        assert!(CodeBlockToolProcessor::parse_error_line("Error:   ").is_none());
2595
2596        // Not an error line
2597        assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
2598    }
2599
2600    #[test]
2601    fn test_consecutive_error_lines_without_position() {
2602        let config = default_config();
2603        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2604
2605        // Two Error: lines in a row — first should flush without position,
2606        // second gets position from "at line"
2607        let output = ToolOutput {
2608            stdout: "Error: first problem\nError: second problem\n    at line 3 column 1".to_string(),
2609            stderr: String::new(),
2610            exit_code: 1,
2611            success: false,
2612        };
2613
2614        let diags = processor.parse_tool_output(&output, "tool", 5);
2615        assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
2616        // First error flushed without position when second Error: was encountered
2617        assert_eq!(diags[0].message, "first problem");
2618        assert_eq!(diags[0].file_line, 5); // No line mapping
2619        assert_eq!(diags[0].column, None);
2620        // Second error resolved with position
2621        assert_eq!(diags[1].message, "second problem");
2622        assert_eq!(diags[1].file_line, 8); // 5 + 3
2623        assert_eq!(diags[1].column, Some(1));
2624    }
2625
2626    #[test]
2627    fn test_error_line_at_end_of_output() {
2628        let config = default_config();
2629        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2630
2631        // Error: as the very last line — flushed by post-loop code
2632        let output = ToolOutput {
2633            stdout: "Error: trailing error".to_string(),
2634            stderr: String::new(),
2635            exit_code: 1,
2636            success: false,
2637        };
2638
2639        let diags = processor.parse_tool_output(&output, "tool", 5);
2640        assert_eq!(diags.len(), 1);
2641        assert_eq!(diags[0].message, "trailing error");
2642        assert_eq!(diags[0].file_line, 5); // No position info available
2643        assert_eq!(diags[0].column, None);
2644    }
2645
2646    #[test]
2647    fn test_blank_lines_between_error_and_position() {
2648        let config = default_config();
2649        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2650
2651        // Blank lines between Error: and "at line" should be transparently skipped
2652        let output = ToolOutput {
2653            stdout: "Error: spaced out\n\n\n    at line 4 column 2".to_string(),
2654            stderr: String::new(),
2655            exit_code: 1,
2656            success: false,
2657        };
2658
2659        let diags = processor.parse_tool_output(&output, "tool", 10);
2660        assert_eq!(diags.len(), 1);
2661        assert_eq!(diags[0].message, "spaced out");
2662        assert_eq!(diags[0].file_line, 14); // 10 + 4
2663        assert_eq!(diags[0].column, Some(2));
2664    }
2665
2666    #[test]
2667    fn test_mixed_structured_and_error_line_parsers() {
2668        let config = default_config();
2669        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2670
2671        // Mix of ruff-style structured output and tombi-style Error: output
2672        let output = ToolOutput {
2673            stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n    at line 3 column 1".to_string(),
2674            stderr: String::new(),
2675            exit_code: 1,
2676            success: false,
2677        };
2678
2679        let diags = processor.parse_tool_output(&output, "tool", 5);
2680        assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
2681        // First: standard format parser
2682        assert_eq!(diags[0].message, "E501 Line too long");
2683        assert_eq!(diags[0].file_line, 6); // 5 + 1
2684        // Second: Error: + at line parser
2685        assert_eq!(diags[1].message, "invalid syntax");
2686        assert_eq!(diags[1].file_line, 8); // 5 + 3
2687    }
2688
2689    #[test]
2690    fn test_at_line_without_preceding_error() {
2691        let config = default_config();
2692        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2693
2694        // "at line N column M" without a preceding Error: should not create a diagnostic
2695        let output = ToolOutput {
2696            stdout: "at line 2 column 1\nsome other text".to_string(),
2697            stderr: String::new(),
2698            exit_code: 1,
2699            success: false,
2700        };
2701
2702        let diags = processor.parse_tool_output(&output, "tool", 5);
2703        // No pending error, so "at line" is just an unmatched line
2704        // Both lines are unmatched, fallback fires with combined output
2705        assert_eq!(diags.len(), 2);
2706        assert_eq!(diags[0].message, "at line 2 column 1");
2707        assert_eq!(diags[1].message, "some other text");
2708    }
2709
2710    // =========================================================================
2711    // Issue #527: formatter that produces empty output should not erase content
2712    // =========================================================================
2713
2714    /// A formatter that produces no stdout (like `tombi lint -` mistakenly used
2715    /// as a formatter) should not replace non-empty content with an empty string.
2716    /// This test uses `true` which exits 0 with no output, simulating the bug.
2717    #[test]
2718    fn test_format_empty_output_does_not_erase_content() {
2719        use super::super::config::LanguageToolConfig;
2720
2721        let mut config = default_config();
2722        config.languages.insert(
2723            "toml".to_string(),
2724            LanguageToolConfig {
2725                format: vec!["empty-formatter".to_string()],
2726                ..Default::default()
2727            },
2728        );
2729        // Define a tool that exits 0 but produces no stdout (simulates `tombi lint -`)
2730        config.tools.insert(
2731            "empty-formatter".to_string(),
2732            super::super::config::ToolDefinition {
2733                command: vec!["true".to_string()],
2734                stdin: true,
2735                stdout: true,
2736                lint_args: vec![],
2737                format_args: vec![],
2738            },
2739        );
2740
2741        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2742
2743        let content = "```toml\nkey = \"value\"\n```\n";
2744        let result = processor.format(content);
2745
2746        assert!(result.is_ok(), "Format should not error");
2747        let output = result.unwrap();
2748
2749        // The content must NOT be erased — original content should be preserved
2750        assert!(
2751            output.content.contains("key = \"value\""),
2752            "Empty formatter output should not erase content. Got: {:?}",
2753            output.content
2754        );
2755    }
2756
2757    /// A formatter that echoes input back (like `cat`) should preserve content.
2758    #[test]
2759    fn test_format_identity_formatter_preserves_content() {
2760        use super::super::config::LanguageToolConfig;
2761
2762        let mut config = default_config();
2763        config.languages.insert(
2764            "toml".to_string(),
2765            LanguageToolConfig {
2766                format: vec!["cat-formatter".to_string()],
2767                ..Default::default()
2768            },
2769        );
2770        config.tools.insert(
2771            "cat-formatter".to_string(),
2772            super::super::config::ToolDefinition {
2773                command: vec!["cat".to_string()],
2774                stdin: true,
2775                stdout: true,
2776                lint_args: vec![],
2777                format_args: vec![],
2778            },
2779        );
2780
2781        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2782
2783        let content = "```toml\nkey = \"value\"\n```\n";
2784        let result = processor.format(content);
2785
2786        assert!(result.is_ok(), "Format should not error");
2787        let output = result.unwrap();
2788        assert_eq!(
2789            output.content, content,
2790            "Identity formatter should preserve content exactly"
2791        );
2792    }
2793
2794    /// Verify that the context-aware tool resolution resolves bare "tombi"
2795    /// to "tombi:format" in format context and "tombi:lint" in lint context.
2796    #[test]
2797    fn test_resolve_tool_context_aware_tombi() {
2798        let config = default_config();
2799        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2800
2801        // In format context, bare "tombi" should resolve to "tombi:format"
2802        let format_def = processor
2803            .resolve_tool("tombi", ToolContext::Format)
2804            .expect("Should resolve tombi in format context");
2805        assert!(
2806            format_def.command.iter().any(|arg| arg == "format"),
2807            "Bare 'tombi' in format context should resolve to 'tombi format', got: {:?}",
2808            format_def.command
2809        );
2810
2811        // In lint context, bare "tombi" should resolve to "tombi:lint"
2812        let lint_def = processor
2813            .resolve_tool("tombi", ToolContext::Lint)
2814            .expect("Should resolve tombi in lint context");
2815        assert!(
2816            lint_def.command.iter().any(|arg| arg == "lint"),
2817            "Bare 'tombi' in lint context should resolve to 'tombi lint', got: {:?}",
2818            lint_def.command
2819        );
2820
2821        // Explicit suffix should bypass context-aware resolution
2822        let explicit_def = processor
2823            .resolve_tool("tombi:lint", ToolContext::Format)
2824            .expect("Should resolve explicit tombi:lint even in format context");
2825        assert!(
2826            explicit_def.command.iter().any(|arg| arg == "lint"),
2827            "Explicit 'tombi:lint' should always use lint, got: {:?}",
2828            explicit_def.command
2829        );
2830    }
2831
2832    /// Verify context-aware resolution for ruff (uses "check" suffix, not "lint").
2833    #[test]
2834    fn test_resolve_tool_context_aware_ruff() {
2835        let config = default_config();
2836        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2837
2838        // In lint context, bare "ruff" should resolve to "ruff:check"
2839        let lint_def = processor
2840            .resolve_tool("ruff", ToolContext::Lint)
2841            .expect("Should resolve ruff in lint context");
2842        assert!(
2843            lint_def.command.iter().any(|arg| arg == "check"),
2844            "Bare 'ruff' in lint context should resolve to 'ruff check', got: {:?}",
2845            lint_def.command
2846        );
2847
2848        // In format context, bare "ruff" should resolve to "ruff:format"
2849        let format_def = processor
2850            .resolve_tool("ruff", ToolContext::Format)
2851            .expect("Should resolve ruff in format context");
2852        assert!(
2853            format_def.command.iter().any(|arg| arg == "format"),
2854            "Bare 'ruff' in format context should resolve to 'ruff format', got: {:?}",
2855            format_def.command
2856        );
2857    }
2858
2859    /// Tools without context-specific variants should still resolve via bare name.
2860    #[test]
2861    fn test_resolve_tool_bare_name_fallback() {
2862        let config = default_config();
2863        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2864
2865        // "shellcheck" has no :lint or :format variant — should fall back to bare name
2866        let def = processor
2867            .resolve_tool("shellcheck", ToolContext::Lint)
2868            .expect("Should resolve shellcheck via fallback");
2869        assert!(
2870            def.command.iter().any(|arg| arg == "shellcheck"),
2871            "shellcheck should resolve to itself, got: {:?}",
2872            def.command
2873        );
2874    }
2875
2876    /// Context-aware resolution for tools with non-standard format suffixes.
2877    #[test]
2878    fn test_resolve_tool_context_aware_sqlfluff() {
2879        let config = default_config();
2880        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2881
2882        // sqlfluff uses ":fix" as its format variant
2883        let format_def = processor
2884            .resolve_tool("sqlfluff", ToolContext::Format)
2885            .expect("Should resolve sqlfluff in format context");
2886        assert!(
2887            format_def.command.iter().any(|arg| arg == "fix"),
2888            "Bare 'sqlfluff' in format context should resolve to 'sqlfluff fix', got: {:?}",
2889            format_def.command
2890        );
2891    }
2892
2893    /// Context-aware resolution for djlint (:reformat suffix).
2894    #[test]
2895    fn test_resolve_tool_context_aware_djlint() {
2896        let config = default_config();
2897        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2898
2899        // djlint uses ":reformat" as its format variant
2900        let format_def = processor
2901            .resolve_tool("djlint", ToolContext::Format)
2902            .expect("Should resolve djlint in format context");
2903        assert!(
2904            format_def.command.iter().any(|arg| arg.contains("reformat")),
2905            "Bare 'djlint' in format context should resolve to djlint reformat, got: {:?}",
2906            format_def.command
2907        );
2908    }
2909
2910    /// User-defined tools with context-specific variants resolve correctly.
2911    #[test]
2912    fn test_resolve_tool_user_defined_with_context_variant() {
2913        use super::super::config::ToolDefinition;
2914
2915        let mut config = default_config();
2916        config.tools.insert(
2917            "mytool".to_string(),
2918            ToolDefinition {
2919                command: vec!["mytool".to_string(), "--lint".to_string()],
2920                ..Default::default()
2921            },
2922        );
2923        config.tools.insert(
2924            "mytool:format".to_string(),
2925            ToolDefinition {
2926                command: vec!["mytool".to_string(), "--format".to_string()],
2927                ..Default::default()
2928            },
2929        );
2930
2931        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2932
2933        // In format context, should resolve to "mytool:format"
2934        let def = processor
2935            .resolve_tool("mytool", ToolContext::Format)
2936            .expect("Should resolve user tool in format context");
2937        assert!(
2938            def.command.iter().any(|arg| arg == "--format"),
2939            "User 'mytool' in format context should resolve to mytool:format, got: {:?}",
2940            def.command
2941        );
2942
2943        // In lint context, should fall back to bare "mytool" (no mytool:lint exists)
2944        let def = processor
2945            .resolve_tool("mytool", ToolContext::Lint)
2946            .expect("Should resolve user tool in lint context via fallback");
2947        assert!(
2948            def.command.iter().any(|arg| arg == "--lint"),
2949            "User 'mytool' in lint context should fall back to bare name, got: {:?}",
2950            def.command
2951        );
2952    }
2953
2954    /// Nonexistent tool returns None.
2955    #[test]
2956    fn test_resolve_tool_nonexistent_returns_none() {
2957        let config = default_config();
2958        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2959
2960        assert!(
2961            processor
2962                .resolve_tool("nonexistent-tool-xyz", ToolContext::Lint)
2963                .is_none(),
2964            "Nonexistent tool should return None in lint context"
2965        );
2966        assert!(
2967            processor
2968                .resolve_tool("nonexistent-tool-xyz", ToolContext::Format)
2969                .is_none(),
2970            "Nonexistent tool should return None in format context"
2971        );
2972    }
2973
2974    #[test]
2975    fn test_strip_ansi_codes_edge_cases() {
2976        // Lone ESC without CSI bracket — non-printable, safely dropped
2977        assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
2978        // ESC at end of string
2979        assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
2980        // Nested/consecutive sequences
2981        assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
2982        // 256-color and RGB sequences
2983        assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
2984        assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
2985    }
2986
2987    /// A linter that enforces a trailing newline (like ryl/yamllint
2988    /// `new-line-at-end-of-file`) must not fire on a fenced code block whose
2989    /// final newline was stripped during extraction. The content piped to the
2990    /// tool should be newline-terminated, matching how the tool sees a real file.
2991    #[cfg(unix)]
2992    #[test]
2993    fn test_lint_yaml_block_no_false_new_line_at_eof() {
2994        use super::super::config::{LanguageToolConfig, ToolDefinition};
2995
2996        let mut config = default_config();
2997        config.normalize_language = NormalizeLanguage::Exact;
2998        config.languages.insert(
2999            "yaml".to_string(),
3000            LanguageToolConfig {
3001                lint: vec!["eof-newline-linter".to_string()],
3002                ..Default::default()
3003            },
3004        );
3005        // Emits a diagnostic only when stdin's last byte is not a newline.
3006        // `$(tail -c1)` strips trailing newlines, so it is empty iff the input
3007        // already ends in a newline.
3008        config.tools.insert(
3009            "eof-newline-linter".to_string(),
3010            ToolDefinition {
3011                command: vec![
3012                    "sh".to_string(),
3013                    "-c".to_string(),
3014                    "if [ -n \"$(tail -c1)\" ]; then echo '1:1: no newline at end of file'; fi".to_string(),
3015                ],
3016                ..Default::default()
3017            },
3018        );
3019
3020        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3021
3022        let content = "```yaml\nfoo: bar\n```\n";
3023        let diagnostics = processor.lint(content).expect("lint should succeed");
3024
3025        assert!(
3026            diagnostics.is_empty(),
3027            "EOF-newline linter should not fire on an extracted code block, got: {diagnostics:?}"
3028        );
3029    }
3030
3031    /// Format mode should also feed tools newline-terminated input, so a
3032    /// formatter that enforces an end-of-file newline sees the content as a
3033    /// complete file rather than re-adding a newline rumdl would then have to
3034    /// strip. The formatter here reports whether its stdin ended in a newline.
3035    #[cfg(unix)]
3036    #[test]
3037    fn test_format_feeds_newline_terminated_input() {
3038        use super::super::config::{LanguageToolConfig, ToolDefinition};
3039
3040        let mut config = default_config();
3041        config.normalize_language = NormalizeLanguage::Exact;
3042        config.languages.insert(
3043            "yaml".to_string(),
3044            LanguageToolConfig {
3045                format: vec!["newline-probe".to_string()],
3046                ..Default::default()
3047            },
3048        );
3049        config.tools.insert(
3050            "newline-probe".to_string(),
3051            ToolDefinition {
3052                command: vec![
3053                    "sh".to_string(),
3054                    "-c".to_string(),
3055                    "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi".to_string(),
3056                ],
3057                ..Default::default()
3058            },
3059        );
3060
3061        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3062
3063        let content = "```yaml\nfoo: bar\n```\n";
3064        let output = processor.format(content).expect("format should succeed");
3065
3066        assert!(
3067            output.content.contains("HAD_NEWLINE"),
3068            "Formatter should receive newline-terminated stdin, got: {:?}",
3069            output.content
3070        );
3071    }
3072
3073    #[test]
3074    fn test_ensure_trailing_newline() {
3075        // Non-empty content without a trailing newline gets one appended.
3076        assert_eq!(ensure_trailing_newline("foo: bar"), "foo: bar\n");
3077        // Multi-line content gets a single newline appended to the last line.
3078        assert_eq!(ensure_trailing_newline("a: 1\nb: 2"), "a: 1\nb: 2\n");
3079        // Content already ending in a newline is returned unchanged.
3080        assert_eq!(ensure_trailing_newline("foo: bar\n"), "foo: bar\n");
3081        // A trailing blank line (already newline-terminated) is left alone.
3082        assert_eq!(ensure_trailing_newline("foo: bar\n\n"), "foo: bar\n\n");
3083        // Genuinely empty content is left empty — no newline synthesized.
3084        assert_eq!(ensure_trailing_newline(""), "");
3085    }
3086}