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