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