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