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