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    pub fn format_with_config(
855        &self,
856        content: &str,
857        config: &crate::config::Config,
858        path: Option<&std::path::Path>,
859    ) -> Result<FormatOutput, ProcessorError> {
860        let no_output = FormatOutput {
861            content: content.to_string(),
862            had_errors: false,
863            error_messages: Vec::new(),
864            failures: Vec::new(),
865        };
866
867        if crate::merge_conflict::detect_configured(content, config, path).is_some() {
868            return Ok(no_output);
869        }
870
871        // Skip the expensive parse when no tools could produce output
872        if self.config.on_missing_language_definition.skips_the_block()
873            && !self
874                .config
875                .languages
876                .values()
877                .any(|lc| lc.enabled && !lc.format.is_empty())
878        {
879            return Ok(no_output);
880        }
881
882        // Quick content check: skip parsing if no configured language appears in the content
883        if self.config.on_missing_language_definition.skips_the_block()
884            && !self.has_potential_matching_blocks(content, false)
885        {
886            return Ok(no_output);
887        }
888
889        let blocks = self.extract_code_blocks(content);
890
891        if blocks.is_empty() {
892            return Ok(FormatOutput {
893                content: content.to_string(),
894                had_errors: false,
895                error_messages: Vec::new(),
896                failures: Vec::new(),
897            });
898        }
899
900        // Process blocks in reverse order to maintain byte offsets
901        let mut result = content.to_string();
902        let mut error_messages: Vec<String> = Vec::new();
903        let mut failures: Vec<CodeBlockDiagnostic> = Vec::new();
904
905        for block in blocks.into_iter().rev() {
906            if block.language.is_empty() {
907                continue;
908            }
909
910            let canonical_lang = self.resolve_language(&block.language);
911
912            // Get format tools for this language
913            let lang_config = self.language_config(&canonical_lang);
914
915            // If language is explicitly configured with enabled=false, skip silently
916            if let Some(lc) = lang_config
917                && !lc.enabled
918            {
919                continue;
920            }
921
922            let format_tools = match lang_config {
923                Some(lc) if !lc.format.is_empty() => &lc.format,
924                // Defined with lint tools only. See the matching arm in `lint`.
925                Some(lc) if !lc.lint.is_empty() => continue,
926                _ => {
927                    // The language has no tools in either mode
928                    match self.config.on_missing_language_definition {
929                        // See the matching arm in `lint`.
930                        OnMissing::Ignore | OnMissing::Warn => continue,
931                        OnMissing::Fail => {
932                            error_messages.push(format!(
933                                "No format tools configured for language '{canonical_lang}' at line {}",
934                                block.start_line + 1
935                            ));
936                            failures.push(CodeBlockDiagnostic {
937                                file_line: block.start_line + 1,
938                                column: None,
939                                message: format!("No format tools configured for language '{canonical_lang}'"),
940                                severity: DiagnosticSeverity::Error,
941                                tool: "code-block-tools".to_string(),
942                                code_block_start: block.start_line + 1,
943                            });
944                            continue;
945                        }
946                        OnMissing::FailFast => {
947                            return Err(ProcessorError::NoToolsConfigured {
948                                language: canonical_lang,
949                                line: block.start_line + 1,
950                            });
951                        }
952                    }
953                }
954            };
955
956            // Extract code block content
957            if block.content_start >= block.content_end || block.content_end > result.len() {
958                continue;
959            }
960            let code_content_raw = result[block.content_start..block.content_end].to_string();
961            let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
962
963            // Run format tools (use first successful one)
964            let mut formatted = code_content.clone();
965            let mut tool_ran = false;
966            for tool_id in format_tools {
967                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown formatting
968                if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
969                    continue;
970                }
971
972                let Some(tool_def) = self.registry.resolve(tool_id, ToolSlot::Format) else {
973                    self.warn_unknown_tool(tool_id, &canonical_lang);
974                    continue;
975                };
976
977                // A linter writes its report to stdout, so running one here replaces the
978                // block with that report: `ruff:check` in a format slot turns a clean
979                // Python block into the literal text `All checks passed!`. The
980                // empty-output guard below does not catch it, because a linter with
981                // nothing to say still exits 0 and can still print a summary line.
982                // Config validation reports this too, but a warning cannot undo an
983                // overwritten block, so the run declines the tool as well.
984                if self.registry.fills_format_slot(tool_id) == Some(false) {
985                    log::warn!(
986                        "Tool '{tool_id}' is a linter and cannot format '{canonical_lang}' code blocks; move it to the lint slot"
987                    );
988                    continue;
989                }
990
991                // Check if tool binary exists before running
992                let tool_name = tool_def.command.first().map_or("", String::as_str);
993                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
994                    match self.config.on_missing_tool_binary {
995                        // See the matching arm in `lint`.
996                        OnMissing::Ignore | OnMissing::Warn => {
997                            log::debug!("Tool binary '{tool_name}' not found, skipping");
998                            continue;
999                        }
1000                        OnMissing::Fail => {
1001                            error_messages.push(format!(
1002                                "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
1003                                block.start_line + 1
1004                            ));
1005                            failures.push(CodeBlockDiagnostic {
1006                                file_line: block.start_line + 1,
1007                                column: None,
1008                                message: format!("Tool binary '{tool_name}' not found in PATH"),
1009                                severity: DiagnosticSeverity::Error,
1010                                tool: "code-block-tools".to_string(),
1011                                code_block_start: block.start_line + 1,
1012                            });
1013                            continue;
1014                        }
1015                        OnMissing::FailFast => {
1016                            return Err(ProcessorError::ToolBinaryNotFound {
1017                                tool: tool_name.to_string(),
1018                                language: canonical_lang.clone(),
1019                                line: block.start_line + 1,
1020                            });
1021                        }
1022                    }
1023                }
1024
1025                let tool_input = ensure_trailing_newline(&formatted);
1026                match self.executor.format(tool_def, &tool_input, Some(self.config.timeout)) {
1027                    Ok(output) => {
1028                        // Guard against formatters that produce empty output for non-empty input.
1029                        // This prevents data loss from misconfigured tools (e.g., a lint tool
1030                        // used as a formatter that validates but doesn't output content).
1031                        if output.trim().is_empty() && !formatted.trim().is_empty() {
1032                            log::warn!("Formatter '{tool_id}' produced empty output for non-empty input, skipping");
1033                            continue;
1034                        }
1035
1036                        // Ensure trailing newline matches original (unindented)
1037                        formatted = output;
1038                        if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1039                            formatted.push('\n');
1040                        } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1041                            formatted.pop();
1042                        }
1043                        tool_ran = true;
1044                        break; // Use first successful formatter
1045                    }
1046                    Err(e) => {
1047                        let on_error = self.get_on_error(&canonical_lang);
1048                        match on_error {
1049                            OnError::Fail => {
1050                                return Err(ProcessorError::ToolErrorAt {
1051                                    error: e,
1052                                    line: block.start_line + 1,
1053                                    language: canonical_lang,
1054                                });
1055                            }
1056                            OnError::Warn => {
1057                                error_messages.push(format!("line {} ({}): {e}", block.start_line + 1, canonical_lang));
1058                            }
1059                            OnError::Skip => {}
1060                        }
1061                    }
1062                }
1063            }
1064
1065            // Replace content if changed and a tool actually ran
1066            if tool_ran && formatted != code_content {
1067                let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
1068                if reindented != code_content_raw {
1069                    result.replace_range(block.content_start..block.content_end, &reindented);
1070                }
1071            }
1072        }
1073
1074        Ok(FormatOutput {
1075            content: result,
1076            had_errors: !error_messages.is_empty(),
1077            error_messages,
1078            failures,
1079        })
1080    }
1081
1082    /// Diagnostics for a built-in formatter used in a `lint` slot.
1083    ///
1084    /// The formatter's own output is the answer: a block that comes back changed is not
1085    /// formatted. This deliberately does not use per-tool check flags, which disagree on
1086    /// every axis that matters (exit code, whether the diff goes to stdout, whether the
1087    /// flag survives alongside the stdin argument the tool also needs).
1088    ///
1089    /// The comparison mirrors the one [`Self::format`] makes before rewriting a
1090    /// block, so `check` reports exactly the blocks `fmt` would change.
1091    fn format_check_diagnostics(
1092        &self,
1093        output: &str,
1094        code_content: &str,
1095        tool_id: &str,
1096        code_block_start_line: usize,
1097    ) -> Vec<CodeBlockDiagnostic> {
1098        // Same guard the format path applies: a formatter that empties a non-empty block
1099        // is misconfigured, not a finding about the block.
1100        if output.trim().is_empty() && !code_content.trim().is_empty() {
1101            log::warn!("Formatter '{tool_id}' produced empty output for non-empty input, skipping");
1102            return Vec::new();
1103        }
1104
1105        let mut formatted = output.to_string();
1106        if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1107            formatted.push('\n');
1108        } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1109            formatted.pop();
1110        }
1111
1112        if formatted == code_content {
1113            return Vec::new();
1114        }
1115
1116        vec![CodeBlockDiagnostic {
1117            file_line: code_block_start_line,
1118            column: None,
1119            message: "Code block is not formatted".to_string(),
1120            severity: DiagnosticSeverity::Warning,
1121            tool: tool_id.to_string(),
1122            code_block_start: code_block_start_line,
1123        }]
1124    }
1125
1126    /// Parse tool output into diagnostics.
1127    ///
1128    /// This is a basic parser that handles common output formats.
1129    /// Tools vary widely in their output format, so this is best-effort.
1130    fn parse_tool_output(
1131        &self,
1132        output: &ToolOutput,
1133        tool_id: &str,
1134        code_block_start_line: usize,
1135    ) -> Vec<CodeBlockDiagnostic> {
1136        let mut diagnostics = Vec::new();
1137        let mut shellcheck_line: Option<usize> = None;
1138
1139        // Strip ANSI escape codes and combine stdout + stderr for parsing
1140        let stdout_clean = strip_ansi_codes(&output.stdout);
1141        let stderr_clean = strip_ansi_codes(&output.stderr);
1142        let combined = format!("{stdout_clean}\n{stderr_clean}");
1143
1144        // State for multi-line "Error: msg" / "at line N column M" pattern
1145        let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
1146
1147        for line in combined.lines() {
1148            let line = line.trim();
1149            if line.is_empty() {
1150                continue;
1151            }
1152
1153            // Resolve pending "Error: msg" from previous line
1154            if let Some((ref msg, severity)) = pending_error {
1155                if let Some((line_num, col)) = Self::parse_at_line_column(line) {
1156                    diagnostics.push(CodeBlockDiagnostic {
1157                        file_line: code_block_start_line + line_num,
1158                        column: Some(col),
1159                        message: msg.clone(),
1160                        severity,
1161                        tool: tool_id.to_string(),
1162                        code_block_start: code_block_start_line,
1163                    });
1164                    pending_error = None;
1165                    continue;
1166                }
1167                // No position line followed; fall back to what the message itself says.
1168                diagnostics.push(Self::unpositioned_diagnostic(
1169                    msg,
1170                    severity,
1171                    tool_id,
1172                    code_block_start_line,
1173                ));
1174                pending_error = None;
1175                // Fall through to parse current line
1176            }
1177
1178            if let Some(line_num) = self.parse_shellcheck_header(line) {
1179                shellcheck_line = Some(line_num);
1180                continue;
1181            }
1182
1183            if let Some(line_num) = shellcheck_line
1184                && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
1185            {
1186                diagnostics.push(diag);
1187                continue;
1188            }
1189
1190            // Try pattern: "::warning file=f,line=N,col=M::message" (GitHub annotation)
1191            if let Some(diag) = Self::parse_github_annotation(line, tool_id, code_block_start_line) {
1192                diagnostics.push(diag);
1193                continue;
1194            }
1195
1196            // Try pattern: "file:line:col: message" or "file:line: message"
1197            if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
1198                diagnostics.push(diag);
1199                continue;
1200            }
1201
1202            // Try pattern: "line:col message" (eslint style)
1203            if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
1204                diagnostics.push(diag);
1205                continue;
1206            }
1207
1208            // Try single-line shellcheck format fallback
1209            if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
1210                diagnostics.push(diag);
1211                continue;
1212            }
1213
1214            // Try multi-line "Error: msg" / "Warning: msg" pattern
1215            if let Some(error_info) = Self::parse_error_line(line) {
1216                pending_error = Some(error_info);
1217            }
1218        }
1219
1220        // Flush any remaining pending error
1221        if let Some((msg, severity)) = pending_error {
1222            diagnostics.push(Self::unpositioned_diagnostic(
1223                &msg,
1224                severity,
1225                tool_id,
1226                code_block_start_line,
1227            ));
1228        }
1229
1230        // If no diagnostics parsed but tool failed, use combined output as fallback
1231        if diagnostics.is_empty() && !output.success {
1232            let lines: Vec<&str> = combined.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
1233
1234            if lines.is_empty() {
1235                let exit_code = output.exit_code;
1236                diagnostics.push(CodeBlockDiagnostic {
1237                    file_line: code_block_start_line,
1238                    column: None,
1239                    message: format!("Tool exited with code {exit_code}"),
1240                    severity: DiagnosticSeverity::Error,
1241                    tool: tool_id.to_string(),
1242                    code_block_start: code_block_start_line,
1243                });
1244            } else {
1245                for line_text in lines {
1246                    diagnostics.push(Self::unpositioned_diagnostic(
1247                        line_text,
1248                        DiagnosticSeverity::Error,
1249                        tool_id,
1250                        code_block_start_line,
1251                    ));
1252                }
1253            }
1254        }
1255
1256        diagnostics
1257    }
1258
1259    /// Build a diagnostic for a message that carried no `file:line:col:` prefix.
1260    ///
1261    /// A tool that states the position in prose instead ("parse error: ... at line 3,
1262    /// column 8") is still telling us where the problem is, so the diagnostic goes there.
1263    /// Only a message that names no position at all anchors at the fence.
1264    fn unpositioned_diagnostic(
1265        message: &str,
1266        severity: DiagnosticSeverity,
1267        tool_id: &str,
1268        code_block_start_line: usize,
1269    ) -> CodeBlockDiagnostic {
1270        let (line_offset, column) = Self::parse_position_in_message(message).unwrap_or((0, None));
1271        CodeBlockDiagnostic {
1272            file_line: code_block_start_line + line_offset,
1273            column,
1274            message: message.to_string(),
1275            severity,
1276            tool: tool_id.to_string(),
1277            code_block_start: code_block_start_line,
1278        }
1279    }
1280
1281    /// Find an "at line N" / "at line N, column M" position stated inside a message.
1282    ///
1283    /// The last occurrence wins: the message is a sentence, and a tool that mentions more
1284    /// than one position ends on the one the diagnostic is about.
1285    fn parse_position_in_message(message: &str) -> Option<(usize, Option<usize>)> {
1286        // ASCII-lowercasing preserves byte offsets, so positions found here index `message`
1287        // itself; only ASCII digits are ever parsed out of it.
1288        let lower = message.to_ascii_lowercase();
1289        let after_marker = &lower[lower.rfind("at line ")? + "at line ".len()..];
1290
1291        let leading_number = |text: &str| -> Option<usize> {
1292            let end = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
1293            text[..end].parse::<usize>().ok()
1294        };
1295
1296        let line_num = leading_number(after_marker)?;
1297        let after_line = after_marker.trim_start_matches(|c: char| c.is_ascii_digit());
1298        let after_separator = after_line
1299            .trim_start()
1300            .strip_prefix(',')
1301            .unwrap_or(after_line)
1302            .trim_start();
1303        let column = after_separator.strip_prefix("column ").and_then(leading_number);
1304
1305        Some((line_num, column))
1306    }
1307
1308    /// Parse standard "file:line:col: message" format.
1309    fn parse_standard_format(
1310        &self,
1311        line: &str,
1312        tool_id: &str,
1313        code_block_start_line: usize,
1314    ) -> Option<CodeBlockDiagnostic> {
1315        // Match patterns like "file.py:1:10: E501 message"
1316        let mut parts = line.rsplitn(4, ':');
1317        let message = parts.next()?.trim().to_string();
1318        let part1 = parts.next()?.trim().to_string();
1319        let part2 = parts.next()?.trim().to_string();
1320        let part3 = parts.next().map(|s| s.trim().to_string());
1321
1322        let (line_part, col_part) = if part3.is_some() {
1323            (part2, Some(part1))
1324        } else {
1325            (part1, None)
1326        };
1327
1328        if let Ok(line_num) = line_part.parse::<usize>() {
1329            let column = col_part.and_then(|s| s.parse::<usize>().ok());
1330            let message = Self::strip_fixable_markers(&message);
1331            if !message.is_empty() {
1332                let severity = self.infer_severity(&message);
1333                return Some(CodeBlockDiagnostic {
1334                    file_line: code_block_start_line + line_num,
1335                    column,
1336                    message,
1337                    severity,
1338                    tool: tool_id.to_string(),
1339                    code_block_start: code_block_start_line,
1340                });
1341            }
1342        }
1343        None
1344    }
1345
1346    /// Parse a GitHub Actions workflow command: `::<level> <k=v>,...::<message>`.
1347    ///
1348    /// Emitted by `--format github-annotation-native` (sqlfluff) and by other tools with a
1349    /// GitHub Actions mode. Only the three annotation levels are diagnostics: `::group::`
1350    /// and `::endgroup::` bracket the findings and carry none, so they are skipped rather
1351    /// than reported. A missing `line` anchors the diagnostic at the fence, the same place
1352    /// an unpositioned message goes.
1353    fn parse_github_annotation(line: &str, tool_id: &str, code_block_start_line: usize) -> Option<CodeBlockDiagnostic> {
1354        let body = line.strip_prefix("::")?;
1355        let (head, message) = body.split_once("::")?;
1356
1357        let (level, properties) = head.split_once(' ').unwrap_or((head, ""));
1358        let severity = match level {
1359            "error" => DiagnosticSeverity::Error,
1360            "warning" => DiagnosticSeverity::Warning,
1361            "notice" => DiagnosticSeverity::Info,
1362            _ => return None,
1363        };
1364
1365        let property = |name: &str| -> Option<usize> {
1366            properties
1367                .split(',')
1368                .filter_map(|pair| pair.split_once('='))
1369                .find(|(key, _)| *key == name)
1370                .and_then(|(_, value)| value.parse::<usize>().ok())
1371        };
1372
1373        let message = Self::strip_fixable_markers(&decode_workflow_command(message));
1374        if message.is_empty() {
1375            return None;
1376        }
1377
1378        Some(CodeBlockDiagnostic {
1379            file_line: code_block_start_line + property("line").unwrap_or(0),
1380            column: property("col"),
1381            message,
1382            severity,
1383            tool: tool_id.to_string(),
1384            code_block_start: code_block_start_line,
1385        })
1386    }
1387
1388    /// Parse eslint-style "line:col severity message" format.
1389    fn parse_eslint_format(
1390        &self,
1391        line: &str,
1392        tool_id: &str,
1393        code_block_start_line: usize,
1394    ) -> Option<CodeBlockDiagnostic> {
1395        // Match "1:10 error Message"
1396        let parts: Vec<&str> = line.splitn(3, ' ').collect();
1397        if parts.len() >= 2 {
1398            let loc_parts: Vec<&str> = parts[0].split(':').collect();
1399            if loc_parts.len() == 2
1400                && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
1401            {
1402                let (sev_part, msg_part) = if parts.len() >= 3 {
1403                    (parts[1], parts[2])
1404                } else {
1405                    (parts[1], "")
1406                };
1407                let message = if msg_part.is_empty() {
1408                    sev_part.to_string()
1409                } else {
1410                    msg_part.to_string()
1411                };
1412                let message = Self::strip_fixable_markers(&message);
1413                let severity = match sev_part.to_lowercase().as_str() {
1414                    "error" => DiagnosticSeverity::Error,
1415                    "warning" | "warn" => DiagnosticSeverity::Warning,
1416                    "info" => DiagnosticSeverity::Info,
1417                    _ => self.infer_severity(&message),
1418                };
1419                return Some(CodeBlockDiagnostic {
1420                    file_line: code_block_start_line + line_num,
1421                    column: Some(col),
1422                    message,
1423                    severity,
1424                    tool: tool_id.to_string(),
1425                    code_block_start: code_block_start_line,
1426                });
1427            }
1428        }
1429        None
1430    }
1431
1432    /// Parse shellcheck-style "In - line N: message" format.
1433    fn parse_shellcheck_format(
1434        &self,
1435        line: &str,
1436        tool_id: &str,
1437        code_block_start_line: usize,
1438    ) -> Option<CodeBlockDiagnostic> {
1439        // Match "In - line 5:" pattern
1440        if line.starts_with("In ")
1441            && line.contains(" line ")
1442            && let Some(line_start) = line.find(" line ")
1443        {
1444            let after_line = &line[line_start + 6..];
1445            if let Some(colon_pos) = after_line.find(':')
1446                && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1447            {
1448                let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1449                if !message.is_empty() {
1450                    let severity = self.infer_severity(&message);
1451                    return Some(CodeBlockDiagnostic {
1452                        file_line: code_block_start_line + line_num,
1453                        column: None,
1454                        message,
1455                        severity,
1456                        tool: tool_id.to_string(),
1457                        code_block_start: code_block_start_line,
1458                    });
1459                }
1460            }
1461        }
1462        None
1463    }
1464
1465    /// Parse shellcheck header line to capture line number context.
1466    fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1467        if line.starts_with("In ")
1468            && line.contains(" line ")
1469            && let Some(line_start) = line.find(" line ")
1470        {
1471            let after_line = &line[line_start + 6..];
1472            if let Some(colon_pos) = after_line.find(':') {
1473                return after_line[..colon_pos].trim().parse::<usize>().ok();
1474            }
1475        }
1476        None
1477    }
1478
1479    /// Parse shellcheck message line containing SCXXXX codes.
1480    fn parse_shellcheck_message(
1481        &self,
1482        line: &str,
1483        tool_id: &str,
1484        code_block_start_line: usize,
1485        line_num: usize,
1486    ) -> Option<CodeBlockDiagnostic> {
1487        let sc_pos = line.find("SC")?;
1488        let after_sc = &line[sc_pos + 2..];
1489        let code_len = after_sc.chars().take_while(char::is_ascii_digit).count();
1490        if code_len == 0 {
1491            return None;
1492        }
1493        let after_code = &after_sc[code_len..];
1494        let sev_start = after_code.find('(')? + 1;
1495        let sev_end = after_code[sev_start..].find(')')? + sev_start;
1496        let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1497        let message_start = after_code.find("):")? + 2;
1498        let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1499        if message.is_empty() {
1500            return None;
1501        }
1502
1503        let severity = match sev.as_str() {
1504            "error" => DiagnosticSeverity::Error,
1505            "warning" | "warn" => DiagnosticSeverity::Warning,
1506            "info" | "style" => DiagnosticSeverity::Info,
1507            _ => self.infer_severity(&message),
1508        };
1509
1510        Some(CodeBlockDiagnostic {
1511            file_line: code_block_start_line + line_num,
1512            column: None,
1513            message,
1514            severity,
1515            tool: tool_id.to_string(),
1516            code_block_start: code_block_start_line,
1517        })
1518    }
1519
1520    /// Parse `Error: <message>` or `Warning: <message>` lines.
1521    ///
1522    /// Used for tools like tombi that output multi-line diagnostics where the
1523    /// error message and position are on separate lines. Only matches capitalized
1524    /// prefixes to avoid conflicting with lowercase `error:` in less structured output.
1525    fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1526        let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1527            (msg, DiagnosticSeverity::Error)
1528        } else if let Some(msg) = line.strip_prefix("Warning:") {
1529            (msg, DiagnosticSeverity::Warning)
1530        } else {
1531            return None;
1532        };
1533        let msg = msg.trim();
1534        if msg.is_empty() {
1535            return None;
1536        }
1537        Some((msg.to_string(), severity))
1538    }
1539
1540    /// Parse "at line N column M" position lines (case-insensitive).
1541    ///
1542    /// Returns (line_number, column_number) if the pattern matches.
1543    fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1544        let lower = line.to_lowercase();
1545        let rest = lower.strip_prefix("at line ")?;
1546        let mut parts = rest.split_whitespace();
1547        let line_num: usize = parts.next()?.parse().ok()?;
1548        if parts.next()? != "column" {
1549            return None;
1550        }
1551        let col: usize = parts.next()?.parse().ok()?;
1552        Some((line_num, col))
1553    }
1554
1555    /// Infer severity from message content.
1556    fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1557        let lower = message.to_lowercase();
1558        if lower.contains("error")
1559            || lower.starts_with('e') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1560            || lower.starts_with('f') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1561        {
1562            DiagnosticSeverity::Error
1563        } else if lower.contains("warning")
1564            || lower.contains("warn")
1565            || lower.starts_with('w') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1566        {
1567            DiagnosticSeverity::Warning
1568        } else {
1569            DiagnosticSeverity::Info
1570        }
1571    }
1572
1573    /// Strip "fixable" markers from external tool messages.
1574    ///
1575    /// External tools like ruff show `[*]` to indicate fixable issues, but in rumdl's
1576    /// context these markers can be misleading - the lint tool's fix capability may
1577    /// differ from what our configured formatter can fix. We strip these markers
1578    /// to avoid making promises we can't keep.
1579    fn strip_fixable_markers(message: &str) -> String {
1580        message
1581            .replace(" [*]", "")
1582            .replace("[*] ", "")
1583            .replace("[*]", "")
1584            .replace(" (fixable)", "")
1585            .replace("(fixable) ", "")
1586            .replace("(fixable)", "")
1587            .replace(" [fix available]", "")
1588            .replace("[fix available] ", "")
1589            .replace("[fix available]", "")
1590            .replace(" [autofix]", "")
1591            .replace("[autofix] ", "")
1592            .replace("[autofix]", "")
1593            .trim()
1594            .to_string()
1595    }
1596}
1597
1598/// Builder for FencedCodeBlockInfo during parsing.
1599struct FencedCodeBlockBuilder {
1600    start_line: usize,
1601    content_start: usize,
1602    language: String,
1603    info_string: String,
1604    fence_char: char,
1605    fence_length: usize,
1606    indent: usize,
1607    indent_prefix: String,
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612    use super::*;
1613
1614    fn default_config() -> CodeBlockToolsConfig {
1615        CodeBlockToolsConfig::default()
1616    }
1617
1618    #[test]
1619    fn test_extract_code_blocks() {
1620        let config = default_config();
1621        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1622
1623        let content = r#"# Example
1624
1625```python
1626def hello():
1627    print("Hello")
1628```
1629
1630Some text
1631
1632```rust
1633fn main() {}
1634```
1635"#;
1636
1637        let blocks = processor.extract_code_blocks(content);
1638
1639        assert_eq!(blocks.len(), 2);
1640
1641        assert_eq!(blocks[0].language, "python");
1642        assert_eq!(blocks[0].fence_char, '`');
1643        assert_eq!(blocks[0].fence_length, 3);
1644        assert_eq!(blocks[0].start_line, 2);
1645        assert_eq!(blocks[0].indent, 0);
1646        assert_eq!(blocks[0].indent_prefix, "");
1647
1648        assert_eq!(blocks[1].language, "rust");
1649        assert_eq!(blocks[1].fence_char, '`');
1650        assert_eq!(blocks[1].fence_length, 3);
1651    }
1652
1653    #[test]
1654    fn test_extract_code_blocks_with_info_string() {
1655        let config = default_config();
1656        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1657
1658        let content = "```python title=\"example.py\"\ncode\n```";
1659        let blocks = processor.extract_code_blocks(content);
1660
1661        assert_eq!(blocks.len(), 1);
1662        assert_eq!(blocks[0].language, "python");
1663        assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1664    }
1665
1666    #[test]
1667    fn test_extract_code_blocks_tilde_fence() {
1668        let config = default_config();
1669        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1670
1671        let content = "~~~bash\necho hello\n~~~";
1672        let blocks = processor.extract_code_blocks(content);
1673
1674        assert_eq!(blocks.len(), 1);
1675        assert_eq!(blocks[0].language, "bash");
1676        assert_eq!(blocks[0].fence_char, '~');
1677        assert_eq!(blocks[0].fence_length, 3);
1678        assert_eq!(blocks[0].indent_prefix, "");
1679    }
1680
1681    #[test]
1682    fn test_extract_code_blocks_with_indent_prefix() {
1683        let config = default_config();
1684        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1685
1686        let content = "  - item\n    ```python\n    print('hi')\n    ```";
1687        let blocks = processor.extract_code_blocks(content);
1688
1689        assert_eq!(blocks.len(), 1);
1690        assert_eq!(blocks[0].indent_prefix, "    ");
1691    }
1692
1693    #[test]
1694    fn test_extract_code_blocks_no_language() {
1695        let config = default_config();
1696        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1697
1698        let content = "```\nplain code\n```";
1699        let blocks = processor.extract_code_blocks(content);
1700
1701        assert_eq!(blocks.len(), 1);
1702        assert_eq!(blocks[0].language, "");
1703    }
1704
1705    #[test]
1706    fn test_resolve_language_linguist() {
1707        let mut config = default_config();
1708        config.normalize_language = NormalizeLanguage::Linguist;
1709        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1710
1711        assert_eq!(processor.resolve_language("py"), "python");
1712        assert_eq!(processor.resolve_language("bash"), "shell");
1713        assert_eq!(processor.resolve_language("js"), "javascript");
1714    }
1715
1716    #[test]
1717    fn test_resolve_language_exact() {
1718        let mut config = default_config();
1719        config.normalize_language = NormalizeLanguage::Exact;
1720        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1721
1722        assert_eq!(processor.resolve_language("py"), "py");
1723        assert_eq!(processor.resolve_language("BASH"), "bash");
1724    }
1725
1726    #[test]
1727    fn test_resolve_language_user_alias_override() {
1728        let mut config = default_config();
1729        config.language_aliases.insert("py".to_string(), "python".to_string());
1730        config.normalize_language = NormalizeLanguage::Exact;
1731        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1732
1733        assert_eq!(processor.resolve_language("PY"), "python");
1734    }
1735
1736    #[test]
1737    fn test_indent_strip_and_reapply_roundtrip() {
1738        let config = default_config();
1739        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1740
1741        let raw = "    def hello():\n        print('hi')";
1742        let stripped = processor.strip_indent_from_block(raw, "    ");
1743        assert_eq!(stripped, "def hello():\n    print('hi')");
1744
1745        let reapplied = processor.apply_indent_to_block(&stripped, "    ");
1746        assert_eq!(reapplied, raw);
1747    }
1748
1749    #[test]
1750    fn test_infer_severity() {
1751        let config = default_config();
1752        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1753
1754        assert_eq!(
1755            processor.infer_severity("E501 line too long"),
1756            DiagnosticSeverity::Error
1757        );
1758        assert_eq!(
1759            processor.infer_severity("W291 trailing whitespace"),
1760            DiagnosticSeverity::Warning
1761        );
1762        assert_eq!(
1763            processor.infer_severity("error: something failed"),
1764            DiagnosticSeverity::Error
1765        );
1766        assert_eq!(
1767            processor.infer_severity("warning: unused variable"),
1768            DiagnosticSeverity::Warning
1769        );
1770        assert_eq!(
1771            processor.infer_severity("note: consider using"),
1772            DiagnosticSeverity::Info
1773        );
1774    }
1775
1776    #[test]
1777    fn test_parse_standard_format_windows_path() {
1778        let config = default_config();
1779        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1780
1781        let output = ToolOutput {
1782            stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1783            stderr: String::new(),
1784            exit_code: 1,
1785            success: false,
1786        };
1787
1788        let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1789        assert_eq!(diags.len(), 1);
1790        assert_eq!(diags[0].file_line, 12);
1791        assert_eq!(diags[0].column, Some(5));
1792        assert_eq!(diags[0].message, "E123 message");
1793    }
1794
1795    #[test]
1796    fn test_parse_eslint_severity() {
1797        let config = default_config();
1798        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1799
1800        let output = ToolOutput {
1801            stdout: "1:2 error Unexpected token".to_string(),
1802            stderr: String::new(),
1803            exit_code: 1,
1804            success: false,
1805        };
1806
1807        let diags = processor.parse_tool_output(&output, "eslint", 5);
1808        assert_eq!(diags.len(), 1);
1809        assert_eq!(diags[0].file_line, 6);
1810        assert_eq!(diags[0].column, Some(2));
1811        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1812        assert_eq!(diags[0].message, "Unexpected token");
1813    }
1814
1815    #[test]
1816    fn test_parse_shellcheck_multiline() {
1817        let config = default_config();
1818        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1819
1820        let output = ToolOutput {
1821            stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1822            stderr: String::new(),
1823            exit_code: 1,
1824            success: false,
1825        };
1826
1827        let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1828        assert_eq!(diags.len(), 1);
1829        assert_eq!(diags[0].file_line, 13);
1830        assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1831        assert_eq!(diags[0].message, "Double quote to prevent globbing");
1832    }
1833
1834    #[test]
1835    fn test_lint_no_config() {
1836        let config = default_config();
1837        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1838
1839        let content = "```python\nprint('hello')\n```";
1840        let result = processor.lint(content);
1841
1842        // Should succeed with no diagnostics (no tools configured)
1843        assert!(result.is_ok());
1844        assert!(result.unwrap().is_empty());
1845    }
1846
1847    #[test]
1848    fn test_format_no_config() {
1849        let config = default_config();
1850        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1851
1852        let content = "```python\nprint('hello')\n```";
1853        let result = processor.format(content);
1854
1855        // Should succeed with unchanged content (no tools configured)
1856        assert!(result.is_ok());
1857        let output = result.unwrap();
1858        assert_eq!(output.content, content);
1859        assert!(!output.had_errors);
1860        assert!(output.error_messages.is_empty());
1861    }
1862
1863    #[test]
1864    fn test_lint_on_missing_language_definition_fail() {
1865        let mut config = default_config();
1866        config.on_missing_language_definition = OnMissing::Fail;
1867        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1868
1869        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1870        let result = processor.lint(content);
1871
1872        // Should succeed but return diagnostics for both missing language definitions
1873        assert!(result.is_ok());
1874        let diagnostics = result.unwrap();
1875        assert_eq!(diagnostics.len(), 2);
1876        assert!(diagnostics[0].message.contains("No lint tools configured"));
1877        assert!(diagnostics[0].message.contains("python"));
1878        assert!(diagnostics[1].message.contains("javascript"));
1879    }
1880
1881    #[test]
1882    fn test_lint_on_missing_language_definition_fail_fast() {
1883        let mut config = default_config();
1884        config.on_missing_language_definition = OnMissing::FailFast;
1885        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1886
1887        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1888        let result = processor.lint(content);
1889
1890        // Should fail immediately on first missing language
1891        assert!(result.is_err());
1892        let err = result.unwrap_err();
1893        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1894    }
1895
1896    #[test]
1897    fn test_format_on_missing_language_definition_fail() {
1898        let mut config = default_config();
1899        config.on_missing_language_definition = OnMissing::Fail;
1900        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1901
1902        let content = "```python\nprint('hello')\n```";
1903        let result = processor.format(content);
1904
1905        // Should succeed but report errors
1906        assert!(result.is_ok());
1907        let output = result.unwrap();
1908        assert_eq!(output.content, content); // Content unchanged
1909        assert!(output.had_errors);
1910        assert!(!output.error_messages.is_empty());
1911        assert!(output.error_messages[0].contains("No format tools configured"));
1912    }
1913
1914    #[test]
1915    fn test_format_on_missing_language_definition_fail_fast() {
1916        let mut config = default_config();
1917        config.on_missing_language_definition = OnMissing::FailFast;
1918        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1919
1920        let content = "```python\nprint('hello')\n```";
1921        let result = processor.format(content);
1922
1923        // Should fail immediately
1924        assert!(result.is_err());
1925        let err = result.unwrap_err();
1926        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1927    }
1928
1929    #[test]
1930    fn test_lint_on_missing_tool_binary_fail() {
1931        use super::super::config::{LanguageToolConfig, ToolDefinition};
1932
1933        let mut config = default_config();
1934        config.on_missing_tool_binary = OnMissing::Fail;
1935
1936        // Configure a tool with a non-existent binary
1937        let lang_config = LanguageToolConfig {
1938            lint: vec!["nonexistent-linter".to_string()],
1939            ..Default::default()
1940        };
1941        config.languages.insert("python".to_string(), lang_config);
1942
1943        let tool_def = ToolDefinition {
1944            command: vec!["nonexistent-binary-xyz123".to_string()],
1945            ..Default::default()
1946        };
1947        config.tools.insert("nonexistent-linter".to_string(), tool_def);
1948
1949        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1950
1951        let content = "```python\nprint('hello')\n```";
1952        let result = processor.lint(content);
1953
1954        // Should succeed but return diagnostic for missing binary
1955        assert!(result.is_ok());
1956        let diagnostics = result.unwrap();
1957        assert_eq!(diagnostics.len(), 1);
1958        assert!(diagnostics[0].message.contains("not found in PATH"));
1959    }
1960
1961    #[test]
1962    fn test_lint_on_missing_tool_binary_fail_fast() {
1963        use super::super::config::{LanguageToolConfig, ToolDefinition};
1964
1965        let mut config = default_config();
1966        config.on_missing_tool_binary = OnMissing::FailFast;
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 fail immediately
1987        assert!(result.is_err());
1988        let err = result.unwrap_err();
1989        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1990    }
1991
1992    #[test]
1993    fn test_format_on_missing_tool_binary_fail() {
1994        use super::super::config::{LanguageToolConfig, ToolDefinition};
1995
1996        let mut config = default_config();
1997        config.on_missing_tool_binary = OnMissing::Fail;
1998
1999        // Configure a tool with a non-existent binary
2000        let lang_config = LanguageToolConfig {
2001            format: vec!["nonexistent-formatter".to_string()],
2002            ..Default::default()
2003        };
2004        config.languages.insert("python".to_string(), lang_config);
2005
2006        let tool_def = ToolDefinition {
2007            command: vec!["nonexistent-binary-xyz123".to_string()],
2008            ..Default::default()
2009        };
2010        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
2011
2012        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2013
2014        let content = "```python\nprint('hello')\n```";
2015        let result = processor.format(content);
2016
2017        // Should succeed but report errors
2018        assert!(result.is_ok());
2019        let output = result.unwrap();
2020        assert_eq!(output.content, content); // Content unchanged
2021        assert!(output.had_errors);
2022        assert!(!output.error_messages.is_empty());
2023        assert!(output.error_messages[0].contains("not found in PATH"));
2024    }
2025
2026    #[test]
2027    fn test_format_on_missing_tool_binary_fail_fast() {
2028        use super::super::config::{LanguageToolConfig, ToolDefinition};
2029
2030        let mut config = default_config();
2031        config.on_missing_tool_binary = OnMissing::FailFast;
2032
2033        // Configure a tool with a non-existent binary
2034        let lang_config = LanguageToolConfig {
2035            format: vec!["nonexistent-formatter".to_string()],
2036            ..Default::default()
2037        };
2038        config.languages.insert("python".to_string(), lang_config);
2039
2040        let tool_def = ToolDefinition {
2041            command: vec!["nonexistent-binary-xyz123".to_string()],
2042            ..Default::default()
2043        };
2044        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
2045
2046        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2047
2048        let content = "```python\nprint('hello')\n```";
2049        let result = processor.format(content);
2050
2051        // Should fail immediately
2052        assert!(result.is_err());
2053        let err = result.unwrap_err();
2054        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
2055    }
2056
2057    #[test]
2058    fn test_lint_rumdl_builtin_skipped_for_markdown() {
2059        // Configure the built-in "rumdl" tool for markdown
2060        // The processor should skip it (handled by embedded markdown linting)
2061        let mut config = default_config();
2062        config.languages.insert(
2063            "markdown".to_string(),
2064            LanguageToolConfig {
2065                lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
2066                ..Default::default()
2067            },
2068        );
2069        config.on_missing_language_definition = OnMissing::Fail;
2070        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2071
2072        let content = "```markdown\n# Hello\n```";
2073        let result = processor.lint(content);
2074
2075        // Should succeed with no diagnostics - "rumdl" tool is skipped, not treated as unknown
2076        assert!(result.is_ok());
2077        assert!(result.unwrap().is_empty());
2078    }
2079
2080    #[test]
2081    fn test_format_rumdl_builtin_skipped_for_markdown() {
2082        // Configure the built-in "rumdl" tool for markdown
2083        let mut config = default_config();
2084        config.languages.insert(
2085            "markdown".to_string(),
2086            LanguageToolConfig {
2087                format: vec![RUMDL_BUILTIN_TOOL.to_string()],
2088                ..Default::default()
2089            },
2090        );
2091        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2092
2093        let content = "```markdown\n# Hello\n```";
2094        let result = processor.format(content);
2095
2096        // Should succeed with unchanged content - "rumdl" tool is skipped
2097        assert!(result.is_ok());
2098        let output = result.unwrap();
2099        assert_eq!(output.content, content);
2100        assert!(!output.had_errors);
2101    }
2102
2103    #[test]
2104    fn test_is_markdown_language() {
2105        // Test the helper function
2106        assert!(is_markdown_language("markdown"));
2107        assert!(is_markdown_language("Markdown"));
2108        assert!(is_markdown_language("MARKDOWN"));
2109        assert!(is_markdown_language("md"));
2110        assert!(is_markdown_language("MD"));
2111        assert!(!is_markdown_language("python"));
2112        assert!(!is_markdown_language("rust"));
2113        assert!(!is_markdown_language(""));
2114    }
2115
2116    // Issue #423: MkDocs admonition code block detection
2117
2118    #[test]
2119    fn test_extract_mkdocs_admonition_code_block() {
2120        let config = default_config();
2121        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2122
2123        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
2124        let blocks = processor.extract_code_blocks(content);
2125
2126        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
2127        assert_eq!(blocks[0].language, "python");
2128    }
2129
2130    #[test]
2131    fn test_extract_mkdocs_tab_code_block() {
2132        let config = default_config();
2133        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2134
2135        let content = "=== \"Python\"\n\n    ```python\n    print(\"hello\")\n    ```\n";
2136        let blocks = processor.extract_code_blocks(content);
2137
2138        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
2139        assert_eq!(blocks[0].language, "python");
2140    }
2141
2142    #[test]
2143    fn test_standard_flavor_ignores_admonition_indented_content() {
2144        let config = default_config();
2145        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2146
2147        // With standard flavor, pulldown_cmark parses this differently;
2148        // our MkDocs extraction should NOT run
2149        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
2150        let blocks = processor.extract_code_blocks(content);
2151
2152        // Standard flavor relies on pulldown_cmark only, which may or may not detect
2153        // indented fenced blocks. The key assertion is that we don't double-detect.
2154        // With standard flavor, the MkDocs extraction path is skipped entirely.
2155        for (i, b) in blocks.iter().enumerate() {
2156            for (j, b2) in blocks.iter().enumerate() {
2157                if i != j {
2158                    assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
2159                }
2160            }
2161        }
2162    }
2163
2164    #[test]
2165    fn test_mkdocs_top_level_blocks_alongside_admonition() {
2166        let config = default_config();
2167        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2168
2169        let content =
2170            "```rust\nfn main() {}\n```\n\n!!! note\n    Some text\n\n    ```python\n    print(\"hello\")\n    ```\n";
2171        let blocks = processor.extract_code_blocks(content);
2172
2173        assert_eq!(
2174            blocks.len(),
2175            2,
2176            "Should detect both top-level and admonition code blocks"
2177        );
2178        assert_eq!(blocks[0].language, "rust");
2179        assert_eq!(blocks[1].language, "python");
2180    }
2181
2182    #[test]
2183    fn test_mkdocs_nested_admonition_code_block() {
2184        let config = default_config();
2185        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2186
2187        let content = "\
2188!!! note
2189    Some text
2190
2191    !!! warning
2192        Nested content
2193
2194        ```python
2195        x = 1
2196        ```
2197";
2198        let blocks = processor.extract_code_blocks(content);
2199        assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
2200        assert_eq!(blocks[0].language, "python");
2201    }
2202
2203    #[test]
2204    fn test_mkdocs_consecutive_admonitions_no_stale_context() {
2205        let config = default_config();
2206        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2207
2208        // Two consecutive admonitions at the same indent level.
2209        // The first has no code block, the second does.
2210        let content = "\
2211!!! note
2212    First admonition content
2213
2214!!! warning
2215    Second admonition content
2216
2217    ```python
2218    y = 2
2219    ```
2220";
2221        let blocks = processor.extract_code_blocks(content);
2222        assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
2223        assert_eq!(blocks[0].language, "python");
2224    }
2225
2226    #[test]
2227    fn test_mkdocs_crlf_line_endings() {
2228        let config = default_config();
2229        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2230
2231        // Use \r\n line endings
2232        let content = "!!! note\r\n    Some text\r\n\r\n    ```python\r\n    x = 1\r\n    ```\r\n";
2233        let blocks = processor.extract_code_blocks(content);
2234
2235        assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
2236        assert_eq!(blocks[0].language, "python");
2237
2238        // Verify byte offsets point to valid content
2239        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2240        assert!(
2241            extracted.contains("x = 1"),
2242            "Extracted content should contain code. Got: {extracted:?}"
2243        );
2244    }
2245
2246    #[test]
2247    fn test_mkdocs_unclosed_fence_in_admonition() {
2248        let config = default_config();
2249        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2250
2251        // Unclosed fence should not produce a block
2252        let content = "!!! note\n    ```python\n    x = 1\n    no closing fence\n";
2253        let blocks = processor.extract_code_blocks(content);
2254        assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
2255    }
2256
2257    #[test]
2258    fn test_mkdocs_tilde_fence_in_admonition() {
2259        let config = default_config();
2260        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2261
2262        let content = "!!! note\n    ~~~ruby\n    puts 'hi'\n    ~~~\n";
2263        let blocks = processor.extract_code_blocks(content);
2264        assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
2265        assert_eq!(blocks[0].language, "ruby");
2266    }
2267
2268    #[test]
2269    fn test_mkdocs_empty_lines_in_code_block() {
2270        let config = default_config();
2271        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2272
2273        // Code block with empty lines inside — verifies byte offsets are correct
2274        // across empty lines (the previous find("") approach would break here)
2275        let content = "!!! note\n    ```python\n    x = 1\n\n    y = 2\n    ```\n";
2276        let blocks = processor.extract_code_blocks(content);
2277        assert_eq!(blocks.len(), 1);
2278
2279        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2280        assert!(
2281            extracted.contains("x = 1") && extracted.contains("y = 2"),
2282            "Extracted content should span across the empty line. Got: {extracted:?}"
2283        );
2284    }
2285
2286    #[test]
2287    fn test_mkdocs_content_byte_offsets_lf() {
2288        let config = default_config();
2289        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2290
2291        let content = "!!! note\n    ```python\n    print('hi')\n    ```\n";
2292        let blocks = processor.extract_code_blocks(content);
2293        assert_eq!(blocks.len(), 1);
2294
2295        // Verify the extracted content is exactly the code body
2296        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2297        assert_eq!(extracted, "    print('hi')\n", "Content offsets should be exact for LF");
2298    }
2299
2300    #[test]
2301    fn test_mkdocs_content_byte_offsets_crlf() {
2302        let config = default_config();
2303        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2304
2305        let content = "!!! note\r\n    ```python\r\n    print('hi')\r\n    ```\r\n";
2306        let blocks = processor.extract_code_blocks(content);
2307        assert_eq!(blocks.len(), 1);
2308
2309        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2310        assert_eq!(
2311            extracted, "    print('hi')\r\n",
2312            "Content offsets should be exact for CRLF"
2313        );
2314    }
2315
2316    #[test]
2317    fn test_lint_enabled_false_skips_language_in_strict_mode() {
2318        // With on-missing-language-definition = "fail", a language configured
2319        // with enabled=false should be silently skipped (no error).
2320        let mut config = default_config();
2321        config.normalize_language = NormalizeLanguage::Exact;
2322        config.on_missing_language_definition = OnMissing::Fail;
2323
2324        // Python has tools, plaintext is disabled
2325        config.languages.insert(
2326            "python".to_string(),
2327            LanguageToolConfig {
2328                lint: vec!["ruff:check".to_string()],
2329                ..Default::default()
2330            },
2331        );
2332        config.languages.insert(
2333            "plaintext".to_string(),
2334            LanguageToolConfig {
2335                enabled: false,
2336                ..Default::default()
2337            },
2338        );
2339
2340        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2341
2342        let content = "```plaintext\nsome text\n```";
2343        let result = processor.lint(content);
2344
2345        // No error for plaintext: enabled=false satisfies strict mode
2346        assert!(result.is_ok());
2347        let diagnostics = result.unwrap();
2348        assert!(
2349            diagnostics.is_empty(),
2350            "Expected no diagnostics for disabled language, got: {diagnostics:?}"
2351        );
2352    }
2353
2354    #[test]
2355    fn test_format_enabled_false_skips_language_in_strict_mode() {
2356        // Same test but for format mode
2357        let mut config = default_config();
2358        config.normalize_language = NormalizeLanguage::Exact;
2359        config.on_missing_language_definition = OnMissing::Fail;
2360
2361        config.languages.insert(
2362            "plaintext".to_string(),
2363            LanguageToolConfig {
2364                enabled: false,
2365                ..Default::default()
2366            },
2367        );
2368
2369        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2370
2371        let content = "```plaintext\nsome text\n```";
2372        let result = processor.format(content);
2373
2374        // No error for plaintext: enabled=false satisfies strict mode
2375        assert!(result.is_ok());
2376        let output = result.unwrap();
2377        assert!(!output.had_errors, "Expected no errors for disabled language");
2378        assert!(
2379            output.error_messages.is_empty(),
2380            "Expected no error messages, got: {:?}",
2381            output.error_messages
2382        );
2383    }
2384
2385    #[test]
2386    fn test_enabled_false_default_true_preserved() {
2387        // Verify that when enabled is not set, it defaults to true (existing behavior)
2388        let mut config = default_config();
2389        config.on_missing_language_definition = OnMissing::Fail;
2390
2391        // Configure python without explicitly setting enabled
2392        config.languages.insert(
2393            "python".to_string(),
2394            LanguageToolConfig {
2395                lint: vec!["ruff:check".to_string()],
2396                ..Default::default()
2397            },
2398        );
2399
2400        let lang_config = config.languages.get("python").unwrap();
2401        assert!(lang_config.enabled, "enabled should default to true");
2402    }
2403
2404    #[test]
2405    fn test_enabled_false_with_fail_fast_no_error() {
2406        // Even with fail-fast, enabled=false should skip silently
2407        let mut config = default_config();
2408        config.normalize_language = NormalizeLanguage::Exact;
2409        config.on_missing_language_definition = OnMissing::FailFast;
2410
2411        config.languages.insert(
2412            "unknown".to_string(),
2413            LanguageToolConfig {
2414                enabled: false,
2415                ..Default::default()
2416            },
2417        );
2418
2419        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2420
2421        let content = "```unknown\nsome content\n```";
2422        let result = processor.lint(content);
2423
2424        // Should not return an error: enabled=false takes precedence over fail-fast
2425        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2426        assert!(result.unwrap().is_empty());
2427    }
2428
2429    #[test]
2430    fn test_enabled_false_format_with_fail_fast_no_error() {
2431        // Same for format mode
2432        let mut config = default_config();
2433        config.normalize_language = NormalizeLanguage::Exact;
2434        config.on_missing_language_definition = OnMissing::FailFast;
2435
2436        config.languages.insert(
2437            "unknown".to_string(),
2438            LanguageToolConfig {
2439                enabled: false,
2440                ..Default::default()
2441            },
2442        );
2443
2444        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2445
2446        let content = "```unknown\nsome content\n```";
2447        let result = processor.format(content);
2448
2449        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2450        let output = result.unwrap();
2451        assert!(!output.had_errors);
2452    }
2453
2454    #[test]
2455    fn test_enabled_false_with_tools_still_skips() {
2456        // If enabled=false but tools are listed, the language should still be skipped
2457        let mut config = default_config();
2458        config.on_missing_language_definition = OnMissing::Fail;
2459
2460        config.languages.insert(
2461            "python".to_string(),
2462            LanguageToolConfig {
2463                enabled: false,
2464                lint: vec!["ruff:check".to_string()],
2465                format: vec!["ruff:format".to_string()],
2466                on_error: None,
2467            },
2468        );
2469
2470        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2471
2472        let content = "```python\nprint('hello')\n```";
2473
2474        // Lint should skip
2475        let lint_result = processor.lint(content);
2476        assert!(lint_result.is_ok());
2477        assert!(lint_result.unwrap().is_empty());
2478
2479        // Format should skip
2480        let format_result = processor.format(content);
2481        assert!(format_result.is_ok());
2482        let output = format_result.unwrap();
2483        assert!(!output.had_errors);
2484        assert_eq!(output.content, content, "Content should be unchanged");
2485    }
2486
2487    #[test]
2488    fn test_enabled_true_without_tools_triggers_strict_mode() {
2489        // A language configured with enabled=true (default) but no tools
2490        // should still trigger strict mode errors
2491        let mut config = default_config();
2492        config.on_missing_language_definition = OnMissing::Fail;
2493
2494        config.languages.insert(
2495            "python".to_string(),
2496            LanguageToolConfig {
2497                // enabled defaults to true, no tools
2498                ..Default::default()
2499            },
2500        );
2501
2502        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2503
2504        let content = "```python\nprint('hello')\n```";
2505        let result = processor.lint(content);
2506
2507        // Should report an error because enabled=true but no lint tools configured
2508        assert!(result.is_ok());
2509        let diagnostics = result.unwrap();
2510        assert_eq!(diagnostics.len(), 1);
2511        assert!(diagnostics[0].message.contains("No lint tools configured"));
2512    }
2513
2514    #[test]
2515    fn test_mixed_enabled_and_disabled_languages() {
2516        // Multiple languages: one disabled, one unconfigured
2517        let mut config = default_config();
2518        config.normalize_language = NormalizeLanguage::Exact;
2519        config.on_missing_language_definition = OnMissing::Fail;
2520
2521        config.languages.insert(
2522            "plaintext".to_string(),
2523            LanguageToolConfig {
2524                enabled: false,
2525                ..Default::default()
2526            },
2527        );
2528
2529        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2530
2531        let content = "\
2532```plaintext
2533some text
2534```
2535
2536```javascript
2537console.log('hi');
2538```
2539";
2540
2541        let result = processor.lint(content);
2542        assert!(result.is_ok());
2543        let diagnostics = result.unwrap();
2544
2545        // plaintext: skipped (enabled=false), no error
2546        // javascript: not configured at all, should trigger strict mode error
2547        assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2548        assert!(
2549            diagnostics[0].message.contains("javascript"),
2550            "Error should be about javascript, got: {}",
2551            diagnostics[0].message
2552        );
2553    }
2554
2555    #[test]
2556    fn test_generic_fallback_includes_all_stderr_lines() {
2557        let config = default_config();
2558        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2559
2560        // Use output that won't be parsed by any structured format parser
2561        let output = ToolOutput {
2562            stdout: String::new(),
2563            stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2564            exit_code: 1,
2565            success: false,
2566        };
2567
2568        let diags = processor.parse_tool_output(&output, "tombi", 5);
2569        assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2570        assert_eq!(diags[0].message, "Parse error at position 42");
2571        assert_eq!(diags[1].message, "Unexpected token '::'");
2572        assert_eq!(diags[2].message, "3 errors found");
2573        assert!(diags.iter().all(|d| d.tool == "tombi"));
2574        assert!(diags.iter().all(|d| d.file_line == 5));
2575    }
2576
2577    #[test]
2578    fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2579        let config = default_config();
2580        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2581
2582        let output = ToolOutput {
2583            stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2584            stderr: String::new(),
2585            exit_code: 1,
2586            success: false,
2587        };
2588
2589        let diags = processor.parse_tool_output(&output, "some-tool", 10);
2590        assert_eq!(diags.len(), 3);
2591        assert_eq!(diags[0].message, "Line 1 error");
2592        assert_eq!(diags[1].message, "Line 2 detail");
2593        assert_eq!(diags[2].message, "Line 3 summary");
2594    }
2595
2596    #[test]
2597    fn test_generic_fallback_skips_blank_lines() {
2598        let config = default_config();
2599        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2600
2601        let output = ToolOutput {
2602            stdout: String::new(),
2603            stderr: "error: bad input\n\n  \n\ndetail: see above\n".to_string(),
2604            exit_code: 1,
2605            success: false,
2606        };
2607
2608        let diags = processor.parse_tool_output(&output, "tool", 1);
2609        assert_eq!(diags.len(), 2);
2610        assert_eq!(diags[0].message, "error: bad input");
2611        assert_eq!(diags[1].message, "detail: see above");
2612    }
2613
2614    #[test]
2615    fn test_generic_fallback_exit_code_when_no_output() {
2616        let config = default_config();
2617        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2618
2619        let output = ToolOutput {
2620            stdout: String::new(),
2621            stderr: String::new(),
2622            exit_code: 42,
2623            success: false,
2624        };
2625
2626        let diags = processor.parse_tool_output(&output, "tool", 1);
2627        assert_eq!(diags.len(), 1);
2628        assert_eq!(diags[0].message, "Tool exited with code 42");
2629    }
2630
2631    #[test]
2632    fn test_generic_fallback_not_triggered_on_success() {
2633        let config = default_config();
2634        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2635
2636        let output = ToolOutput {
2637            stdout: "some informational output".to_string(),
2638            stderr: String::new(),
2639            exit_code: 0,
2640            success: true,
2641        };
2642
2643        let diags = processor.parse_tool_output(&output, "tool", 1);
2644        assert!(
2645            diags.is_empty(),
2646            "Successful tool runs should produce no fallback diagnostics"
2647        );
2648    }
2649
2650    #[test]
2651    fn test_ansi_codes_stripped_before_parsing() {
2652        let config = default_config();
2653        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2654
2655        // ruff-style output with ANSI color codes wrapping the message
2656        let output = ToolOutput {
2657            stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
2658                .to_string(),
2659            stderr: String::new(),
2660            exit_code: 1,
2661            success: false,
2662        };
2663
2664        let diags = processor.parse_tool_output(&output, "ruff:check", 5);
2665        assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
2666        assert_eq!(diags[0].message, "E501 Line too long");
2667        assert_eq!(diags[0].file_line, 6); // 5 + 1
2668    }
2669
2670    #[test]
2671    fn test_tombi_multiline_error_format() {
2672        let config = default_config();
2673        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2674
2675        // Simulates tombi output (ANSI codes stripped for clarity)
2676        let output = ToolOutput {
2677            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(),
2678            stderr: "1 file failed to be formatted".to_string(),
2679            exit_code: 1,
2680            success: false,
2681        };
2682
2683        let diags = processor.parse_tool_output(&output, "tombi", 7);
2684        assert_eq!(
2685            diags.len(),
2686            4,
2687            "Expected 4 diagnostics from tombi errors, got {diags:?}"
2688        );
2689        assert_eq!(diags[0].message, "invalid key");
2690        assert_eq!(diags[0].file_line, 9); // 7 + 2
2691        assert_eq!(diags[0].column, Some(1));
2692        assert_eq!(diags[1].message, "expected key");
2693        assert_eq!(diags[1].file_line, 9);
2694        assert_eq!(diags[2].message, "expected '='");
2695        assert_eq!(diags[3].message, "expected value");
2696        assert!(diags.iter().all(|d| d.tool == "tombi"));
2697    }
2698
2699    #[test]
2700    fn test_tombi_with_ansi_codes() {
2701        let config = default_config();
2702        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2703
2704        // Real tombi output with ANSI escape codes
2705        let output = ToolOutput {
2706            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(),
2707            stderr: "1 file failed to be formatted".to_string(),
2708            exit_code: 1,
2709            success: false,
2710        };
2711
2712        let diags = processor.parse_tool_output(&output, "tombi", 7);
2713        assert_eq!(
2714            diags.len(),
2715            2,
2716            "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
2717        );
2718        assert_eq!(diags[0].message, "invalid key");
2719        assert_eq!(diags[0].file_line, 9);
2720        assert_eq!(diags[1].message, "expected '='");
2721        assert_eq!(diags[1].file_line, 9);
2722    }
2723
2724    /// sqlfluff's `github-annotation-native` output, verbatim, for `SELECT   1  FROM   t`.
2725    ///
2726    /// The `::group::` / `::endgroup::` markers bracket every run and carry no finding, so
2727    /// a parser that treated any `::...::` line as a diagnostic would report five.
2728    const SQLFLUFF_ANNOTATIONS: &str = "\
2729::group::stdin
2730::warning title=SQLFluff,file=stdin,line=1,col=7,endLine=1,endColumn=10::LT01: Expected only single space before numeric literal. Found '   '. [layout.spacing]
2731::warning title=SQLFluff,file=stdin,line=1,col=11,endLine=1,endColumn=13::LT01: Expected only single space before 'FROM' keyword. Found '  '. [layout.spacing]
2732::warning title=SQLFluff,file=stdin,line=1,col=17,endLine=1,endColumn=20::LT01: Expected only single space before naked identifier. Found '   '. [layout.spacing]
2733::endgroup::";
2734
2735    #[test]
2736    fn test_github_annotation_maps_line_and_column() {
2737        let config = default_config();
2738        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2739
2740        let output = ToolOutput {
2741            stdout: SQLFLUFF_ANNOTATIONS.to_string(),
2742            stderr: String::new(),
2743            exit_code: 1,
2744            success: false,
2745        };
2746
2747        let diags = processor.parse_tool_output(&output, "sqlfluff:lint", 3);
2748        assert_eq!(diags.len(), 3, "the group markers carry no finding: {diags:?}");
2749        assert_eq!(diags.iter().map(|d| d.file_line).collect::<Vec<_>>(), vec![4, 4, 4]);
2750        assert_eq!(
2751            diags.iter().map(|d| d.column).collect::<Vec<_>>(),
2752            vec![Some(7), Some(11), Some(17)]
2753        );
2754        assert!(diags[0].message.starts_with("LT01: Expected only single space"));
2755        assert!(diags.iter().all(|d| matches!(d.severity, DiagnosticSeverity::Warning)));
2756    }
2757
2758    #[test]
2759    fn test_github_annotation_severity_per_level() {
2760        let config = default_config();
2761        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2762
2763        let output = ToolOutput {
2764            stdout: "::error line=1::broken\n::warning line=2::suspicious\n::notice line=3::detail".to_string(),
2765            stderr: String::new(),
2766            exit_code: 1,
2767            success: false,
2768        };
2769
2770        let diags = processor.parse_tool_output(&output, "tool", 10);
2771        assert_eq!(diags.len(), 3);
2772        assert!(matches!(diags[0].severity, DiagnosticSeverity::Error));
2773        assert!(matches!(diags[1].severity, DiagnosticSeverity::Warning));
2774        assert!(matches!(diags[2].severity, DiagnosticSeverity::Info));
2775        assert_eq!(diags[2].file_line, 13);
2776    }
2777
2778    #[test]
2779    fn test_github_annotation_without_line_anchors_at_fence() {
2780        let config = default_config();
2781        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2782
2783        let output = ToolOutput {
2784            stdout: "::error::something the tool could not place".to_string(),
2785            stderr: String::new(),
2786            exit_code: 1,
2787            success: false,
2788        };
2789
2790        let diags = processor.parse_tool_output(&output, "tool", 12);
2791        assert_eq!(diags.len(), 1);
2792        assert_eq!(diags[0].file_line, 12);
2793        assert_eq!(diags[0].column, None);
2794        assert_eq!(diags[0].message, "something the tool could not place");
2795    }
2796
2797    #[test]
2798    fn test_github_annotation_decodes_escaped_message() {
2799        let config = default_config();
2800        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2801
2802        // The format escapes `%`, `:`, `,` and line breaks, because each of them is a
2803        // separator in the line the message is embedded in.
2804        let output = ToolOutput {
2805            stdout: "::error line=1,col=2::100%25 of rows%3A a%2C b%0Aand more".to_string(),
2806            stderr: String::new(),
2807            exit_code: 1,
2808            success: false,
2809        };
2810
2811        let diags = processor.parse_tool_output(&output, "tool", 5);
2812        assert_eq!(diags.len(), 1);
2813        assert_eq!(diags[0].message, "100% of rows: a, b and more");
2814        assert_eq!(diags[0].file_line, 6);
2815        assert_eq!(diags[0].column, Some(2));
2816    }
2817
2818    #[test]
2819    fn test_decode_workflow_command_is_a_single_left_to_right_pass() {
2820        // `%250A` is an escaped `%` followed by the literal text "0A". Decoding `%25` first
2821        // and then looking for `%0A` in the result would turn it into a line break.
2822        assert_eq!(decode_workflow_command("%250A"), "%0A");
2823        assert_eq!(decode_workflow_command("no escapes"), "no escapes");
2824        assert_eq!(decode_workflow_command("50% off"), "50% off");
2825        assert_eq!(decode_workflow_command("trailing %"), "trailing %");
2826        assert_eq!(decode_workflow_command("%0d%0a"), "  ");
2827        assert_eq!(decode_workflow_command("%3A%2C"), ":,");
2828    }
2829
2830    #[test]
2831    fn test_position_in_message_anchors_a_prose_position() {
2832        let config = default_config();
2833        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2834
2835        // jq names the position inside the message rather than prefixing it, and exits
2836        // non-zero, so this arrives through the raw-output fallback.
2837        let output = ToolOutput {
2838            stdout: String::new(),
2839            stderr: "jq: error (at <stdin>:0): syntax error, unexpected '}' at line 3, column 8".to_string(),
2840            exit_code: 2,
2841            success: false,
2842        };
2843
2844        let diags = processor.parse_tool_output(&output, "jq", 20);
2845        assert_eq!(diags.len(), 1);
2846        assert_eq!(diags[0].file_line, 23);
2847        assert_eq!(diags[0].column, Some(8));
2848    }
2849
2850    #[test]
2851    fn test_position_in_message_takes_the_last_one_named() {
2852        let config = default_config();
2853        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2854
2855        let output = ToolOutput {
2856            stdout: "unterminated string started at line 2, column 1 at line 6, column 4".to_string(),
2857            stderr: String::new(),
2858            exit_code: 1,
2859            success: false,
2860        };
2861
2862        let diags = processor.parse_tool_output(&output, "tool", 0);
2863        assert_eq!(diags.len(), 1);
2864        assert_eq!(diags[0].file_line, 6, "the sentence ends on the position it is about");
2865        assert_eq!(diags[0].column, Some(4));
2866    }
2867
2868    #[test]
2869    fn test_message_naming_no_position_stays_on_the_fence() {
2870        let config = default_config();
2871        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2872
2873        let output = ToolOutput {
2874            stdout: "could not parse the input".to_string(),
2875            stderr: String::new(),
2876            exit_code: 1,
2877            success: false,
2878        };
2879
2880        let diags = processor.parse_tool_output(&output, "tool", 7);
2881        assert_eq!(diags.len(), 1);
2882        assert_eq!(diags[0].file_line, 7);
2883        assert_eq!(diags[0].column, None);
2884    }
2885
2886    #[test]
2887    fn test_fallback_combines_stdout_and_stderr() {
2888        let config = default_config();
2889        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2890
2891        // Tool puts some errors on stdout, summary on stderr
2892        let output = ToolOutput {
2893            stdout: "problem found in input".to_string(),
2894            stderr: "1 file failed".to_string(),
2895            exit_code: 1,
2896            success: false,
2897        };
2898
2899        let diags = processor.parse_tool_output(&output, "tool", 1);
2900        assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
2901        assert_eq!(diags[0].message, "problem found in input");
2902        assert_eq!(diags[1].message, "1 file failed");
2903    }
2904
2905    #[test]
2906    fn test_error_line_without_position_info() {
2907        let config = default_config();
2908        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2909
2910        // Error: line not followed by "at line N column M"
2911        let output = ToolOutput {
2912            stdout: "Error: something went wrong\nsome unrelated line".to_string(),
2913            stderr: String::new(),
2914            exit_code: 1,
2915            success: false,
2916        };
2917
2918        let diags = processor.parse_tool_output(&output, "tool", 5);
2919        // "Error: something went wrong" → parsed by error-line parser (no position)
2920        // "some unrelated line" → no parser matches, but diagnostics not empty → no fallback
2921        assert!(!diags.is_empty());
2922        assert_eq!(diags[0].message, "something went wrong");
2923        assert_eq!(diags[0].file_line, 5); // No line offset, uses code_block_start
2924    }
2925
2926    #[test]
2927    fn test_warning_line_with_position() {
2928        let config = default_config();
2929        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2930
2931        let output = ToolOutput {
2932            stdout: "Warning: deprecated syntax\n    at line 3 column 5".to_string(),
2933            stderr: String::new(),
2934            exit_code: 1,
2935            success: false,
2936        };
2937
2938        let diags = processor.parse_tool_output(&output, "tool", 10);
2939        assert_eq!(diags.len(), 1);
2940        assert_eq!(diags[0].message, "deprecated syntax");
2941        assert_eq!(diags[0].file_line, 13); // 10 + 3
2942        assert_eq!(diags[0].column, Some(5));
2943        assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
2944    }
2945
2946    #[test]
2947    fn test_strip_ansi_codes() {
2948        assert_eq!(strip_ansi_codes("hello"), "hello");
2949        assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
2950        assert_eq!(
2951            strip_ansi_codes("\x1b[1;31m  Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
2952            "  Error: msg"
2953        );
2954        assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
2955        assert_eq!(strip_ansi_codes(""), "");
2956        assert_eq!(
2957            strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
2958            "at line 2 column 1"
2959        );
2960    }
2961
2962    #[test]
2963    fn test_parse_at_line_column() {
2964        assert_eq!(
2965            CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
2966            Some((2, 1))
2967        );
2968        assert_eq!(
2969            CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
2970            Some((10, 15))
2971        );
2972        assert_eq!(
2973            CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
2974            Some((5, 3))
2975        );
2976        assert_eq!(
2977            CodeBlockToolProcessor::parse_at_line_column("not a position line"),
2978            None
2979        );
2980        assert_eq!(
2981            CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
2982            None
2983        );
2984    }
2985
2986    #[test]
2987    fn test_parse_error_line() {
2988        let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
2989        assert_eq!(msg, "invalid key");
2990        assert!(matches!(sev, DiagnosticSeverity::Error));
2991
2992        let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
2993        assert_eq!(msg, "deprecated");
2994        assert!(matches!(sev, DiagnosticSeverity::Warning));
2995
2996        // Lowercase should NOT match (avoids conflict with unstructured tool output)
2997        assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
2998        assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
2999
3000        // Empty message after prefix should not match
3001        assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
3002        assert!(CodeBlockToolProcessor::parse_error_line("Error:   ").is_none());
3003
3004        // Not an error line
3005        assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
3006    }
3007
3008    #[test]
3009    fn test_consecutive_error_lines_without_position() {
3010        let config = default_config();
3011        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3012
3013        // Two Error: lines in a row — first should flush without position,
3014        // second gets position from "at line"
3015        let output = ToolOutput {
3016            stdout: "Error: first problem\nError: second problem\n    at line 3 column 1".to_string(),
3017            stderr: String::new(),
3018            exit_code: 1,
3019            success: false,
3020        };
3021
3022        let diags = processor.parse_tool_output(&output, "tool", 5);
3023        assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
3024        // First error flushed without position when second Error: was encountered
3025        assert_eq!(diags[0].message, "first problem");
3026        assert_eq!(diags[0].file_line, 5); // No line mapping
3027        assert_eq!(diags[0].column, None);
3028        // Second error resolved with position
3029        assert_eq!(diags[1].message, "second problem");
3030        assert_eq!(diags[1].file_line, 8); // 5 + 3
3031        assert_eq!(diags[1].column, Some(1));
3032    }
3033
3034    #[test]
3035    fn test_error_line_at_end_of_output() {
3036        let config = default_config();
3037        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3038
3039        // Error: as the very last line — flushed by post-loop code
3040        let output = ToolOutput {
3041            stdout: "Error: trailing error".to_string(),
3042            stderr: String::new(),
3043            exit_code: 1,
3044            success: false,
3045        };
3046
3047        let diags = processor.parse_tool_output(&output, "tool", 5);
3048        assert_eq!(diags.len(), 1);
3049        assert_eq!(diags[0].message, "trailing error");
3050        assert_eq!(diags[0].file_line, 5); // No position info available
3051        assert_eq!(diags[0].column, None);
3052    }
3053
3054    #[test]
3055    fn test_blank_lines_between_error_and_position() {
3056        let config = default_config();
3057        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3058
3059        // Blank lines between Error: and "at line" should be transparently skipped
3060        let output = ToolOutput {
3061            stdout: "Error: spaced out\n\n\n    at line 4 column 2".to_string(),
3062            stderr: String::new(),
3063            exit_code: 1,
3064            success: false,
3065        };
3066
3067        let diags = processor.parse_tool_output(&output, "tool", 10);
3068        assert_eq!(diags.len(), 1);
3069        assert_eq!(diags[0].message, "spaced out");
3070        assert_eq!(diags[0].file_line, 14); // 10 + 4
3071        assert_eq!(diags[0].column, Some(2));
3072    }
3073
3074    #[test]
3075    fn test_mixed_structured_and_error_line_parsers() {
3076        let config = default_config();
3077        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3078
3079        // Mix of ruff-style structured output and tombi-style Error: output
3080        let output = ToolOutput {
3081            stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n    at line 3 column 1".to_string(),
3082            stderr: String::new(),
3083            exit_code: 1,
3084            success: false,
3085        };
3086
3087        let diags = processor.parse_tool_output(&output, "tool", 5);
3088        assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
3089        // First: standard format parser
3090        assert_eq!(diags[0].message, "E501 Line too long");
3091        assert_eq!(diags[0].file_line, 6); // 5 + 1
3092        // Second: Error: + at line parser
3093        assert_eq!(diags[1].message, "invalid syntax");
3094        assert_eq!(diags[1].file_line, 8); // 5 + 3
3095    }
3096
3097    #[test]
3098    fn test_at_line_without_preceding_error() {
3099        let config = default_config();
3100        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3101
3102        // "at line N column M" without a preceding Error: should not create a diagnostic
3103        let output = ToolOutput {
3104            stdout: "at line 2 column 1\nsome other text".to_string(),
3105            stderr: String::new(),
3106            exit_code: 1,
3107            success: false,
3108        };
3109
3110        let diags = processor.parse_tool_output(&output, "tool", 5);
3111        // No pending error, so "at line" is just an unmatched line
3112        // Both lines are unmatched, fallback fires with combined output
3113        assert_eq!(diags.len(), 2);
3114        assert_eq!(diags[0].message, "at line 2 column 1");
3115        assert_eq!(diags[1].message, "some other text");
3116    }
3117
3118    // =========================================================================
3119    // Issue #527: formatter that produces empty output should not erase content
3120    // =========================================================================
3121
3122    /// A formatter that produces no stdout (like `tombi lint -` mistakenly used
3123    /// as a formatter) should not replace non-empty content with an empty string.
3124    /// This test uses `true` which exits 0 with no output, simulating the bug.
3125    #[test]
3126    fn test_format_empty_output_does_not_erase_content() {
3127        use super::super::config::LanguageToolConfig;
3128
3129        let mut config = default_config();
3130        config.languages.insert(
3131            "toml".to_string(),
3132            LanguageToolConfig {
3133                format: vec!["empty-formatter".to_string()],
3134                ..Default::default()
3135            },
3136        );
3137        // Define a tool that exits 0 but produces no stdout (simulates `tombi lint -`)
3138        config.tools.insert(
3139            "empty-formatter".to_string(),
3140            super::super::config::ToolDefinition {
3141                command: vec!["true".to_string()],
3142                stdin: true,
3143                stdout: true,
3144                lint_args: vec![],
3145                format_args: vec![],
3146            },
3147        );
3148
3149        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3150
3151        let content = "```toml\nkey = \"value\"\n```\n";
3152        let result = processor.format(content);
3153
3154        assert!(result.is_ok(), "Format should not error");
3155        let output = result.unwrap();
3156
3157        // The content must NOT be erased — original content should be preserved
3158        assert!(
3159            output.content.contains("key = \"value\""),
3160            "Empty formatter output should not erase content. Got: {:?}",
3161            output.content
3162        );
3163    }
3164
3165    /// A formatter that echoes input back (like `cat`) should preserve content.
3166    #[test]
3167    fn test_format_identity_formatter_preserves_content() {
3168        use super::super::config::LanguageToolConfig;
3169
3170        let mut config = default_config();
3171        config.languages.insert(
3172            "toml".to_string(),
3173            LanguageToolConfig {
3174                format: vec!["cat-formatter".to_string()],
3175                ..Default::default()
3176            },
3177        );
3178        config.tools.insert(
3179            "cat-formatter".to_string(),
3180            super::super::config::ToolDefinition {
3181                command: vec!["cat".to_string()],
3182                stdin: true,
3183                stdout: true,
3184                lint_args: vec![],
3185                format_args: vec![],
3186            },
3187        );
3188
3189        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3190
3191        let content = "```toml\nkey = \"value\"\n```\n";
3192        let result = processor.format(content);
3193
3194        assert!(result.is_ok(), "Format should not error");
3195        let output = result.unwrap();
3196        assert_eq!(
3197            output.content, content,
3198            "Identity formatter should preserve content exactly"
3199        );
3200    }
3201
3202    /// Verify that the context-aware tool resolution resolves bare "tombi"
3203    /// to "tombi:format" in format context and "tombi:lint" in lint context.
3204    #[test]
3205    fn test_resolve_tool_context_aware_tombi() {
3206        let config = default_config();
3207        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3208
3209        // In format context, bare "tombi" should resolve to "tombi:format"
3210        let format_def = processor
3211            .registry
3212            .resolve("tombi", ToolSlot::Format)
3213            .expect("Should resolve tombi in format context");
3214        assert!(
3215            format_def.command.iter().any(|arg| arg == "format"),
3216            "Bare 'tombi' in format context should resolve to 'tombi format', got: {:?}",
3217            format_def.command
3218        );
3219
3220        // In lint context, bare "tombi" should resolve to "tombi:lint"
3221        let lint_def = processor
3222            .registry
3223            .resolve("tombi", ToolSlot::Lint)
3224            .expect("Should resolve tombi in lint context");
3225        assert!(
3226            lint_def.command.iter().any(|arg| arg == "lint"),
3227            "Bare 'tombi' in lint context should resolve to 'tombi lint', got: {:?}",
3228            lint_def.command
3229        );
3230
3231        // Explicit suffix should bypass context-aware resolution
3232        let explicit_def = processor
3233            .registry
3234            .resolve("tombi:lint", ToolSlot::Format)
3235            .expect("Should resolve explicit tombi:lint even in format context");
3236        assert!(
3237            explicit_def.command.iter().any(|arg| arg == "lint"),
3238            "Explicit 'tombi:lint' should always use lint, got: {:?}",
3239            explicit_def.command
3240        );
3241    }
3242
3243    /// Verify context-aware resolution for ruff (uses "check" suffix, not "lint").
3244    #[test]
3245    fn test_resolve_tool_context_aware_ruff() {
3246        let config = default_config();
3247        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3248
3249        // In lint context, bare "ruff" should resolve to "ruff:check"
3250        let lint_def = processor
3251            .registry
3252            .resolve("ruff", ToolSlot::Lint)
3253            .expect("Should resolve ruff in lint context");
3254        assert!(
3255            lint_def.command.iter().any(|arg| arg == "check"),
3256            "Bare 'ruff' in lint context should resolve to 'ruff check', got: {:?}",
3257            lint_def.command
3258        );
3259
3260        // In format context, bare "ruff" should resolve to "ruff:format"
3261        let format_def = processor
3262            .registry
3263            .resolve("ruff", ToolSlot::Format)
3264            .expect("Should resolve ruff in format context");
3265        assert!(
3266            format_def.command.iter().any(|arg| arg == "format"),
3267            "Bare 'ruff' in format context should resolve to 'ruff format', got: {:?}",
3268            format_def.command
3269        );
3270    }
3271
3272    /// Tools without context-specific variants should still resolve via bare name.
3273    #[test]
3274    fn test_resolve_tool_bare_name_fallback() {
3275        let config = default_config();
3276        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3277
3278        // "shellcheck" has no :lint or :format variant — should fall back to bare name
3279        let def = processor
3280            .registry
3281            .resolve("shellcheck", ToolSlot::Lint)
3282            .expect("Should resolve shellcheck via fallback");
3283        assert!(
3284            def.command.iter().any(|arg| arg == "shellcheck"),
3285            "shellcheck should resolve to itself, got: {:?}",
3286            def.command
3287        );
3288    }
3289
3290    /// Context-aware resolution for tools with non-standard format suffixes.
3291    #[test]
3292    fn test_resolve_tool_context_aware_sqlfluff() {
3293        let config = default_config();
3294        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3295
3296        // sqlfluff uses ":fix" as its format variant
3297        let format_def = processor
3298            .registry
3299            .resolve("sqlfluff", ToolSlot::Format)
3300            .expect("Should resolve sqlfluff in format context");
3301        assert!(
3302            format_def.command.iter().any(|arg| arg == "fix"),
3303            "Bare 'sqlfluff' in format context should resolve to 'sqlfluff fix', got: {:?}",
3304            format_def.command
3305        );
3306    }
3307
3308    /// Context-aware resolution for djlint (:reformat suffix).
3309    #[test]
3310    fn test_resolve_tool_context_aware_djlint() {
3311        let config = default_config();
3312        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3313
3314        // djlint uses ":reformat" as its format variant
3315        let format_def = processor
3316            .registry
3317            .resolve("djlint", ToolSlot::Format)
3318            .expect("Should resolve djlint in format context");
3319        assert!(
3320            format_def.command.iter().any(|arg| arg.contains("reformat")),
3321            "Bare 'djlint' in format context should resolve to djlint reformat, got: {:?}",
3322            format_def.command
3323        );
3324    }
3325
3326    /// User-defined tools with context-specific variants resolve correctly.
3327    #[test]
3328    fn test_resolve_tool_user_defined_with_context_variant() {
3329        use super::super::config::ToolDefinition;
3330
3331        let mut config = default_config();
3332        config.tools.insert(
3333            "mytool".to_string(),
3334            ToolDefinition {
3335                command: vec!["mytool".to_string(), "--lint".to_string()],
3336                ..Default::default()
3337            },
3338        );
3339        config.tools.insert(
3340            "mytool:format".to_string(),
3341            ToolDefinition {
3342                command: vec!["mytool".to_string(), "--format".to_string()],
3343                ..Default::default()
3344            },
3345        );
3346
3347        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3348
3349        // In format context, should resolve to "mytool:format"
3350        let def = processor
3351            .registry
3352            .resolve("mytool", ToolSlot::Format)
3353            .expect("Should resolve user tool in format context");
3354        assert!(
3355            def.command.iter().any(|arg| arg == "--format"),
3356            "User 'mytool' in format context should resolve to mytool:format, got: {:?}",
3357            def.command
3358        );
3359
3360        // In lint context, should fall back to bare "mytool" (no mytool:lint exists)
3361        let def = processor
3362            .registry
3363            .resolve("mytool", ToolSlot::Lint)
3364            .expect("Should resolve user tool in lint context via fallback");
3365        assert!(
3366            def.command.iter().any(|arg| arg == "--lint"),
3367            "User 'mytool' in lint context should fall back to bare name, got: {:?}",
3368            def.command
3369        );
3370    }
3371
3372    /// Nonexistent tool returns None.
3373    #[test]
3374    fn test_resolve_tool_nonexistent_returns_none() {
3375        let config = default_config();
3376        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3377
3378        assert!(
3379            processor
3380                .registry
3381                .resolve("nonexistent-tool-xyz", ToolSlot::Lint)
3382                .is_none(),
3383            "Nonexistent tool should return None in lint context"
3384        );
3385        assert!(
3386            processor
3387                .registry
3388                .resolve("nonexistent-tool-xyz", ToolSlot::Format)
3389                .is_none(),
3390            "Nonexistent tool should return None in format context"
3391        );
3392    }
3393
3394    #[test]
3395    fn test_strip_ansi_codes_edge_cases() {
3396        // Lone ESC without CSI bracket — non-printable, safely dropped
3397        assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
3398        // ESC at end of string
3399        assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
3400        // Nested/consecutive sequences
3401        assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
3402        // 256-color and RGB sequences
3403        assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
3404        assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
3405    }
3406
3407    #[test]
3408    fn merge_conflict_prevents_external_formatting() {
3409        use super::super::config::{LanguageToolConfig, ToolDefinition};
3410        let mut config = default_config();
3411        config.on_missing_tool_binary = OnMissing::Fail;
3412        config.languages.insert(
3413            "testlang".to_string(),
3414            LanguageToolConfig {
3415                format: vec!["unavailable".to_string()],
3416                ..Default::default()
3417            },
3418        );
3419        config.tools.insert(
3420            "unavailable".to_string(),
3421            ToolDefinition {
3422                command: vec!["rumdl-858-tool-that-must-not-run".to_string()],
3423                ..Default::default()
3424            },
3425        );
3426        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3427        let content = "<<<<<<< HEAD\r\n\n```testlang\ntext\n```\n";
3428        let output = processor.format(content).unwrap();
3429        assert_eq!(output.content, content);
3430        assert!(!output.had_errors);
3431        assert!(output.failures.is_empty());
3432
3433        // Disabling the safety rule must let the formatter run, rather than
3434        // silently skipping every code block because of a documented marker.
3435        let mut document_config = crate::config::Config::default();
3436        document_config.global.disable.push("MD092".into());
3437        let output = processor.format_with_config(content, &document_config, None).unwrap();
3438        assert!(output.had_errors);
3439        assert!(!output.failures.is_empty());
3440        let suppressed = format!("<!-- rumdl-disable MD092 -->\n{content}");
3441        assert!(processor.format(&suppressed).unwrap().had_errors);
3442    }
3443
3444    /// A linter that enforces a trailing newline (like ryl/yamllint
3445    /// `new-line-at-end-of-file`) must not fire on a fenced code block whose
3446    /// final newline was stripped during extraction. The content piped to the
3447    /// tool should be newline-terminated, matching how the tool sees a real file.
3448    #[cfg(unix)]
3449    #[test]
3450    fn test_lint_yaml_block_no_false_new_line_at_eof() {
3451        use super::super::config::{LanguageToolConfig, ToolDefinition};
3452
3453        let mut config = default_config();
3454        config.normalize_language = NormalizeLanguage::Exact;
3455        config.languages.insert(
3456            "yaml".to_string(),
3457            LanguageToolConfig {
3458                lint: vec!["eof-newline-linter".to_string()],
3459                ..Default::default()
3460            },
3461        );
3462        // Emits a diagnostic only when stdin's last byte is not a newline.
3463        // `$(tail -c1)` strips trailing newlines, so it is empty iff the input
3464        // already ends in a newline.
3465        config.tools.insert(
3466            "eof-newline-linter".to_string(),
3467            ToolDefinition {
3468                command: vec![
3469                    "sh".to_string(),
3470                    "-c".to_string(),
3471                    "if [ -n \"$(tail -c1)\" ]; then echo '1:1: no newline at end of file'; fi".to_string(),
3472                ],
3473                ..Default::default()
3474            },
3475        );
3476
3477        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3478
3479        let content = "```yaml\nfoo: bar\n```\n";
3480        let diagnostics = processor.lint(content).expect("lint should succeed");
3481
3482        assert!(
3483            diagnostics.is_empty(),
3484            "EOF-newline linter should not fire on an extracted code block, got: {diagnostics:?}"
3485        );
3486    }
3487
3488    /// Format mode should also feed tools newline-terminated input, so a
3489    /// formatter that enforces an end-of-file newline sees the content as a
3490    /// complete file rather than re-adding a newline rumdl would then have to
3491    /// strip. The formatter here reports whether its stdin ended in a newline.
3492    #[cfg(unix)]
3493    #[test]
3494    fn test_format_feeds_newline_terminated_input() {
3495        use super::super::config::{LanguageToolConfig, ToolDefinition};
3496
3497        let mut config = default_config();
3498        config.normalize_language = NormalizeLanguage::Exact;
3499        config.languages.insert(
3500            "yaml".to_string(),
3501            LanguageToolConfig {
3502                format: vec!["newline-probe".to_string()],
3503                ..Default::default()
3504            },
3505        );
3506        config.tools.insert(
3507            "newline-probe".to_string(),
3508            ToolDefinition {
3509                command: vec![
3510                    "sh".to_string(),
3511                    "-c".to_string(),
3512                    "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi".to_string(),
3513                ],
3514                ..Default::default()
3515            },
3516        );
3517
3518        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3519
3520        let content = "```yaml\nfoo: bar\n```\n";
3521        let output = processor.format(content).expect("format should succeed");
3522
3523        assert!(
3524            output.content.contains("HAD_NEWLINE"),
3525            "Formatter should receive newline-terminated stdin, got: {:?}",
3526            output.content
3527        );
3528    }
3529
3530    #[test]
3531    fn test_ensure_trailing_newline() {
3532        // Non-empty content without a trailing newline gets one appended.
3533        assert_eq!(ensure_trailing_newline("foo: bar"), "foo: bar\n");
3534        // Multi-line content gets a single newline appended to the last line.
3535        assert_eq!(ensure_trailing_newline("a: 1\nb: 2"), "a: 1\nb: 2\n");
3536        // Content already ending in a newline is returned unchanged.
3537        assert_eq!(ensure_trailing_newline("foo: bar\n"), "foo: bar\n");
3538        // A trailing blank line (already newline-terminated) is left alone.
3539        assert_eq!(ensure_trailing_newline("foo: bar\n\n"), "foo: bar\n\n");
3540        // Genuinely empty content is left empty — no newline synthesized.
3541        assert_eq!(ensure_trailing_newline(""), "");
3542    }
3543}