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