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, FormatCheckOutput, ToolExecutor, ToolOutput};
8use super::linguist::LinguistResolver;
9use super::registry::{BuiltinLintMode, ToolRegistry, ToolSlot};
10use crate::config::MarkdownFlavor;
11use crate::rule::{LintWarning, Severity};
12use crate::utils::parser_options::rumdl_parser_options_for;
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 of the block's last line: its closing fence, or its
112    /// last code line when the block is unterminated.
113    pub end_line: usize,
114    /// Byte offset where the source of the code starts (the first content line,
115    /// container prefix included).
116    pub content_start: usize,
117    /// Byte offset where the source of the code ends: after the line ending of
118    /// its last line, or at the end of the document for an unterminated last line.
119    pub content_end: usize,
120    /// Language tag extracted from info string (first token).
121    pub language: String,
122    /// Full info string from the fence.
123    pub info_string: String,
124    /// The fence character used (` or ~).
125    pub fence_char: char,
126    /// Length of the fence (3 or more).
127    pub fence_length: usize,
128    /// What precedes every content line for the containers the block sits in:
129    /// blockquote markers, list continuation indent and the fence's own indent.
130    /// Formatted code is written back behind it.
131    pub content_prefix: String,
132    /// The code as CommonMark reads it, with every container prefix removed.
133    /// This is what a tool receives.
134    pub code: String,
135    /// Width in characters of the source prefix removed from each line of `code`,
136    /// so a tool's column can be placed in the document.
137    pub line_prefix_widths: Vec<usize>,
138}
139
140impl FencedCodeBlockInfo {
141    /// The source that replaces `content_start..content_end` so the block's code
142    /// becomes `code`, every line inside the block's containers.
143    pub fn source_for(&self, code: &str) -> String {
144        apply_content_prefix(code, &self.content_prefix)
145    }
146
147    /// The document column of a 1-indexed `column` on 1-indexed `line` of the
148    /// code. `None` for a line the code does not have.
149    pub fn document_column(&self, line: usize, column: usize) -> Option<usize> {
150        let width = self.line_prefix_widths.get(line.checked_sub(1)?)?;
151        Some(column + width)
152    }
153}
154
155/// A diagnostic message from an external tool.
156#[derive(Debug, Clone)]
157pub struct CodeBlockDiagnostic {
158    /// Line number in the original markdown file (1-indexed).
159    pub file_line: usize,
160    /// Column number (1-indexed, if available).
161    pub column: Option<usize>,
162    /// Message from the tool.
163    pub message: String,
164    /// Severity (error, warning, info).
165    pub severity: DiagnosticSeverity,
166    /// Name of the tool that produced this.
167    pub tool: String,
168    /// Line where the code block starts (1-indexed, for context).
169    pub code_block_start: usize,
170}
171
172/// Severity level for diagnostics.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum DiagnosticSeverity {
175    Error,
176    Warning,
177    Info,
178}
179
180impl CodeBlockDiagnostic {
181    /// Convert to a LintWarning for integration with rumdl's warning system.
182    /// The report for a block whose formatted code was not written back
183    /// because it would change the document's structure.
184    pub fn refused_write_back(line: usize) -> Self {
185        Self {
186            file_line: line,
187            column: None,
188            message:
189                "Formatted code cannot be written back without changing the document's structure; block left unchanged"
190                    .to_string(),
191            severity: DiagnosticSeverity::Error,
192            tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
193            code_block_start: line,
194        }
195    }
196
197    pub fn to_lint_warning(&self) -> LintWarning {
198        let severity = match self.severity {
199            DiagnosticSeverity::Error => Severity::Error,
200            DiagnosticSeverity::Warning => Severity::Warning,
201            DiagnosticSeverity::Info => Severity::Info,
202        };
203
204        LintWarning {
205            message: self.message.clone(),
206            line: self.file_line,
207            column: self.column.unwrap_or(1),
208            end_line: self.file_line,
209            end_column: self.column.unwrap_or(1),
210            severity,
211            fix: None, // External tool diagnostics don't provide fixes
212            rule_name: Some(self.tool.clone()),
213        }
214    }
215}
216
217/// Error during code block processing.
218#[derive(Debug, Clone)]
219pub enum ProcessorError {
220    /// Tool execution failed.
221    ToolError(ExecutorError),
222    /// Tool execution failed with code block location context.
223    ToolErrorAt {
224        error: ExecutorError,
225        line: usize,
226        language: String,
227    },
228    /// No tools configured for language.
229    NoToolsConfigured { language: String, line: usize },
230    /// Tool binary not found.
231    ToolBinaryNotFound {
232        tool: String,
233        language: String,
234        line: usize,
235    },
236    /// A coverage or configuration policy stopped processing.
237    Policy { message: String, line: usize },
238    /// Processing was aborted due to on_error = fail.
239    Aborted { message: String },
240}
241
242impl std::fmt::Display for ProcessorError {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Self::ToolError(e) => write!(f, "{e}"),
246            Self::ToolErrorAt { error, line, language } => {
247                write!(f, "line {line} ({language}): {error}")
248            }
249            Self::NoToolsConfigured { language, line } => {
250                write!(f, "line {line} ({language}): no tools configured")
251            }
252            Self::ToolBinaryNotFound { tool, language, line } => {
253                write!(
254                    f,
255                    "line {line} ({language}): tool '{tool}' not found in allowed lookup locations"
256                )
257            }
258            Self::Policy { message, line } => write!(f, "line {line}: {message}"),
259            Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
260        }
261    }
262}
263
264impl ProcessorError {
265    /// The 1-indexed line of the code block the error is about, when it is about one.
266    pub fn line(&self) -> Option<usize> {
267        match self {
268            Self::ToolErrorAt { line, .. }
269            | Self::NoToolsConfigured { line, .. }
270            | Self::ToolBinaryNotFound { line, .. }
271            | Self::Policy { line, .. } => Some(*line),
272            Self::ToolError(_) | Self::Aborted { .. } => None,
273        }
274    }
275
276    /// The error without the position [`Display`](std::fmt::Display) prefixes it with.
277    fn detail(&self) -> String {
278        match self {
279            Self::ToolErrorAt { error, .. } => error.to_string(),
280            Self::NoToolsConfigured { language, .. } => format!("No tools configured for language '{language}'"),
281            Self::ToolBinaryNotFound { tool, .. } => {
282                format!("Tool binary '{tool}' not found in allowed lookup locations")
283            }
284            Self::Policy { message, .. } => message.clone(),
285            Self::ToolError(_) | Self::Aborted { .. } => self.to_string(),
286        }
287    }
288
289    /// Report the error as a finding, at its code block when it names one.
290    ///
291    /// A run that stopped on this error still has to report it where the reader
292    /// can find it, in every output format, and under the same name whichever
293    /// path (lint, format, LSP) hit it.
294    pub fn to_lint_warning(&self) -> LintWarning {
295        let line = self.line().unwrap_or(1);
296        LintWarning {
297            message: self.detail(),
298            line,
299            column: 1,
300            end_line: line,
301            end_column: 1,
302            severity: Severity::Error,
303            fix: None,
304            rule_name: Some(CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string()),
305        }
306    }
307}
308
309/// The name a failure of the code-block-tools machinery itself is reported under.
310///
311/// Not a rule name and not a tool id: it names the class of problem, so nothing
312/// looks it up in the rule registry.
313pub const CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME: &str = "code-block-tools";
314
315impl std::error::Error for ProcessorError {}
316
317impl From<ExecutorError> for ProcessorError {
318    fn from(e: ExecutorError) -> Self {
319        Self::ToolError(e)
320    }
321}
322
323/// Result of processing a single code block.
324#[derive(Debug)]
325pub struct CodeBlockResult {
326    /// Diagnostics from linting.
327    pub diagnostics: Vec<CodeBlockDiagnostic>,
328    /// Formatted content (if formatting was requested and succeeded).
329    pub formatted_content: Option<String>,
330    /// Whether the code block was modified.
331    pub was_modified: bool,
332}
333
334/// Result of formatting code blocks in a document.
335#[derive(Debug)]
336pub struct FormatOutput {
337    /// The formatted content (may be partially formatted if errors occurred).
338    pub content: String,
339    /// Whether any messages were collected. Warn-level messages count too.
340    pub had_errors: bool,
341    /// Error messages for blocks that couldn't be formatted, in the prose form
342    /// the terminal shows. Both levels appear here: a message collected under
343    /// `on-error = "warn"` is for the reader and nothing more.
344    pub error_messages: Vec<String>,
345    /// The fail-level subset of the above, as diagnostics rather than prose.
346    ///
347    /// A tool could not run under a setting of `fail`, so the document was only
348    /// partly formatted and the run must not be reported as clean. The lint path
349    /// records the same fact as a diagnostic and every output format carries it;
350    /// these exist so a format run says it in the same machine-readable place
351    /// instead of only in a line of stderr. Messages collected under
352    /// `on-error = "warn"` are not failures and are not here.
353    pub failures: Vec<CodeBlockDiagnostic>,
354}
355
356/// Result of linting the code blocks in a document.
357#[derive(Debug, Default)]
358pub struct LintOutput {
359    /// Findings, including a tool that could not run under `on-error = "fail"`.
360    pub diagnostics: Vec<CodeBlockDiagnostic>,
361    /// Tools that could not run under `on-error = "warn"`, in the prose form
362    /// [`FormatOutput::error_messages`] uses. They are for the reader and are not
363    /// findings: `warn` asks to be told and to carry on.
364    pub warnings: Vec<String>,
365    /// Whether a tool failed to run under any `on-error` setting, so that some
366    /// block went unchecked. Such a result says nothing about that block and is
367    /// not one to cache.
368    pub incomplete: bool,
369}
370
371impl FormatOutput {
372    /// Whether part of the document went unformatted because a tool could not run.
373    pub fn failed(&self) -> bool {
374        !self.failures.is_empty()
375    }
376}
377
378/// Main processor for code block tools.
379pub struct CodeBlockToolProcessor<'a> {
380    config: &'a CodeBlockToolsConfig,
381    flavor: MarkdownFlavor,
382    linguist: LinguistResolver,
383    registry: ToolRegistry,
384    executor: ToolExecutor,
385    run_state: std::sync::Arc<super::run_state::RunState>,
386    builtin_checks: bool,
387    user_aliases: std::collections::HashMap<String, String>,
388    /// `config.languages`, keyed by lowercased language name.
389    languages: std::collections::HashMap<String, &'a LanguageToolConfig>,
390    custom_languages: Vec<String>,
391}
392
393impl<'a> CodeBlockToolProcessor<'a> {
394    /// Create a new processor with the given configuration and markdown flavor.
395    pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
396        let user_aliases = config
397            .language_aliases
398            .iter()
399            .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
400            .collect();
401        // A fence tag is resolved to a lowercase canonical name, so a `languages` key has
402        // to be lowercased to be reachable at all. `BTreeMap` iteration is sorted and
403        // uppercase sorts first, so an exact-lowercase key wins over a mixed-case one.
404        let languages = config.languages.iter().map(|(k, v)| (k.to_lowercase(), v)).collect();
405        let run_state = config.run_state.clone().unwrap_or_default();
406        Self {
407            builtin_checks: true,
408            custom_languages: Vec::new(),
409            run_state: std::sync::Arc::clone(&run_state),
410            config,
411            flavor,
412            linguist: LinguistResolver::new(),
413            registry: ToolRegistry::new(config.tools.clone()),
414            executor: ToolExecutor::new(config.timeout)
415                .with_binary_resolution(
416                    config.binary_preferences.clone(),
417                    super::binary::project_root(&std::env::current_dir().unwrap_or_default()),
418                )
419                .with_run_state(run_state),
420            user_aliases,
421            languages,
422        }
423    }
424
425    /// Invocation accounting and once-per-run policy warnings.
426    pub fn run_state(&self) -> &super::run_state::RunState {
427        &self.run_state
428    }
429
430    fn needs_coverage_scan(&self) -> bool {
431        self.config.on_missing_language_tag != OnMissing::Ignore
432            || self.config.unknown_language_policy() != OnMissing::Ignore
433            || self.config.on_missing_mode_definition != OnMissing::Ignore
434            || self.config.on_missing_language_definition == OnMissing::Warn
435    }
436
437    fn block_policy(&self, block: &FencedCodeBlockInfo, lint: bool) -> Option<(OnMissing, String, String)> {
438        use crate::utils::fence_language::{FenceLanguage, classify};
439        match classify(&block.info_string, self.flavor) {
440            FenceLanguage::Exempt => return Some((OnMissing::Ignore, String::new(), String::new())),
441            FenceLanguage::Language | FenceLanguage::CodeSyntax => {}
442            FenceLanguage::Missing => {
443                return Some((
444                    self.config.on_missing_language_tag,
445                    "missing-tag".into(),
446                    "Fenced code block has no language tag".into(),
447                ));
448            }
449        }
450        let canonical = self.resolve_language(&block.language);
451        let known = self.linguist.is_known(&block.language)
452            || self
453                .custom_languages
454                .iter()
455                .any(|label| label.eq_ignore_ascii_case(&block.language))
456            || self.user_aliases.contains_key(&block.language.to_lowercase())
457            || self.languages.contains_key(&block.language.to_lowercase())
458            || self.languages.contains_key(&canonical);
459        if !known {
460            return Some((
461                self.config.unknown_language_policy(),
462                format!("unknown-tag:{}", block.language.to_lowercase()),
463                format!("Unrecognized code block language tag '{}'", block.language),
464            ));
465        }
466        if let Some(language) = self.language_config(&canonical) {
467            if !language.enabled {
468                return None;
469            }
470            let (current, opposite, mode) = if lint {
471                (&language.lint, &language.format, "lint")
472            } else {
473                (&language.format, &language.lint, "format")
474            };
475            if current.is_empty() && !opposite.is_empty() {
476                return Some((
477                    self.config.on_missing_mode_definition,
478                    format!("missing-mode:{canonical}:{mode}"),
479                    format!("No {mode} tools configured for language '{canonical}'"),
480                ));
481            }
482        }
483        None
484    }
485
486    fn policy_diagnostic(
487        &self,
488        policy: OnMissing,
489        key: String,
490        message: String,
491        line: usize,
492    ) -> Result<Option<CodeBlockDiagnostic>, ProcessorError> {
493        match policy {
494            OnMissing::Ignore => Ok(None),
495            OnMissing::Warn => {
496                self.run_state.warn(key, message);
497                Ok(None)
498            }
499            OnMissing::Fail => Ok(Some(CodeBlockDiagnostic {
500                file_line: line,
501                column: None,
502                message,
503                severity: DiagnosticSeverity::Error,
504                tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.into(),
505                code_block_start: line,
506            })),
507            OnMissing::FailFast => {
508                self.run_state.abort();
509                Err(ProcessorError::Policy { message, line })
510            }
511        }
512    }
513
514    /// Replay coverage warnings without re-executing tools on a cache hit.
515    pub fn replay_cached_warnings(&self, content: &str) {
516        for block in self.extract_code_blocks(content) {
517            if let Some((policy, key, message)) = self.block_policy(&block, true) {
518                if policy == OnMissing::Warn {
519                    self.run_state.warn(key, message);
520                }
521            } else if !block.language.is_empty() && self.config.on_missing_language_definition == OnMissing::Warn {
522                let canonical = self.resolve_language(&block.language);
523                if self
524                    .language_config(&canonical)
525                    .is_none_or(|lc| lc.enabled && lc.lint.is_empty() && lc.format.is_empty())
526                {
527                    self.run_state.warn(
528                        format!("missing-definition:{canonical}"),
529                        format!("No tools configured for language '{canonical}'"),
530                    );
531                }
532            }
533        }
534    }
535
536    /// Whether a valid cached lint result represents at least one usable tool.
537    pub fn has_lint_work(&self, content: &str) -> bool {
538        self.has_work(content, true)
539    }
540
541    pub fn has_work(&self, content: &str, lint: bool) -> bool {
542        let slot = if lint { ToolSlot::Lint } else { ToolSlot::Format };
543        self.extract_code_blocks(content).iter().any(|block| {
544            if self.block_policy(block, lint).is_some() || block.content_start == block.content_end {
545                return false;
546            }
547            let canonical = self.resolve_language(&block.language);
548            self.language_config(&canonical).is_some_and(|language| {
549                language.enabled
550                    && (if lint { &language.lint } else { &language.format }).iter().any(|id| {
551                        if self.registry.definition_problem(id, slot).is_some() {
552                            return false;
553                        }
554                        if is_rumdl_builtin(id) && is_markdown_language(&canonical) {
555                            return self.builtin_checks;
556                        }
557                        self.registry
558                            .resolve(id, slot)
559                            .and_then(|tool| tool.command.first())
560                            .is_some_and(|binary| self.executor.is_tool_available(binary))
561                    })
562            })
563        })
564    }
565
566    /// Share project-declared MD040 language labels with tools-only validation.
567    pub fn with_document_config(mut self, config: &crate::config::Config) -> Self {
568        let md040: crate::rules::md040_fenced_code_language::md040_config::MD040Config =
569            crate::rule_config_serde::load_rule_config(config);
570        self.custom_languages = md040.custom_languages;
571        self
572    }
573
574    /// External commands may read configuration, environment or wrappers that
575    /// rumdl cannot validate. A combined file cache must not reuse their verdicts.
576    pub fn has_external_lint_work(&self, content: &str) -> bool {
577        self.extract_code_blocks(content).iter().any(|block| {
578            if self.block_policy(block, true).is_some() || block.content_start == block.content_end {
579                return false;
580            }
581            let canonical = self.resolve_language(&block.language);
582            self.language_config(&canonical).is_some_and(|language| {
583                language.enabled
584                    && language.lint.iter().any(|id| {
585                        !(is_rumdl_builtin(id) && is_markdown_language(&canonical))
586                            && self.registry.definition_problem(id, ToolSlot::Lint).is_none()
587                    })
588            })
589        })
590    }
591
592    pub fn with_builtin_checks(mut self, enabled: bool) -> Self {
593        self.builtin_checks = enabled;
594        self
595    }
596
597    /// Resolve project-local executables relative to this document's project.
598    pub fn for_path(mut self, path: &std::path::Path) -> Self {
599        self.executor = self.executor.with_binary_resolution(
600            self.config.binary_preferences.clone(),
601            super::binary::project_root(path),
602        );
603        self
604    }
605
606    /// Configuration for a canonical (lowercase) language name.
607    fn language_config(&self, canonical_lang: &str) -> Option<&'a LanguageToolConfig> {
608        self.languages.get(canonical_lang).copied()
609    }
610
611    /// Report a tool id no registry entry answers to.
612    ///
613    /// Both the id and the language it was configured under are text out of whichever
614    /// file supplied the section (the language through `language-aliases`), so a section
615    /// reached through `extends` is described rather than quoted. See
616    /// [`crate::config::Config::withheld_rule_values`] for the same policy on rule options.
617    fn warn_unknown_tool(&self, tool_id: &str, canonical_lang: &str) {
618        if self.config.values_withheld {
619            let withheld = crate::config::WITHHELD;
620            log::warn!("Unknown tool {withheld} configured for language {withheld}");
621        } else {
622            log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
623        }
624    }
625
626    /// Conservatively skip parsing documents without any possible fence.
627    /// Fence length and container prefixes must not hide applicable tool work.
628    fn has_potential_matching_blocks(&self, content: &str, lint_mode: bool) -> bool {
629        self.languages.values().any(|language| {
630            language.enabled
631                && if lint_mode {
632                    !language.lint.is_empty()
633                } else {
634                    !language.format.is_empty()
635                }
636        }) && (content.contains("```") || content.contains("~~~"))
637    }
638
639    /// Extract all fenced code blocks from content, including those the flavor
640    /// adds (MkDocs admonitions and tabs).
641    pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
642        let mut blocks = extract_fenced_code_blocks(content);
643
644        // For MkDocs flavor, also extract code blocks inside admonitions and tabs
645        if self.flavor == MarkdownFlavor::MkDocs {
646            let mkdocs_blocks = self.extract_mkdocs_code_blocks(content, &blocks);
647            for mb in mkdocs_blocks {
648                // Deduplicate: only add if no existing block starts at the same line
649                if !blocks.iter().any(|b| b.start_line == mb.start_line) {
650                    blocks.push(mb);
651                }
652            }
653            blocks.sort_by_key(|b| b.start_line);
654        }
655
656        blocks
657    }
658
659    /// Extract fenced code blocks that are inside MkDocs admonitions or tabs.
660    ///
661    /// pulldown_cmark doesn't parse MkDocs-specific constructs, so indented
662    /// code blocks inside `!!!`/`???` admonitions or `===` tabs are missed.
663    /// This method manually scans for them, skipping the lines of the
664    /// `fenced` blocks the CommonMark parser found: what looks like an
665    /// admonition there is code, such as an example in a `markdown` block.
666    fn extract_mkdocs_code_blocks(&self, content: &str, fenced: &[FencedCodeBlockInfo]) -> Vec<FencedCodeBlockInfo> {
667        use crate::utils::mkdocs_admonitions;
668        use crate::utils::mkdocs_tabs;
669
670        let mut blocks = Vec::new();
671        let lines: Vec<&str> = content.lines().collect();
672        let mut in_fenced_block = vec![false; lines.len()];
673        for block in fenced {
674            let end = block.end_line.min(lines.len().saturating_sub(1));
675            if let Some(span) = in_fenced_block.get_mut(block.start_line..=end) {
676                span.fill(true);
677            }
678        }
679
680        // Track current MkDocs context indent level
681        // We only need to know if we're inside any MkDocs block, so a simple stack suffices.
682        let mut context_indent_stack: Vec<usize> = Vec::new();
683
684        // Track fence state inside MkDocs context
685        let mut in_fence = false;
686        let mut fence_start_line: usize = 0;
687        let mut fence_content_start: usize = 0;
688        let mut fence_char: char = '`';
689        let mut fence_length: usize = 0;
690        let mut fence_indent_prefix = String::new();
691        let mut fence_language = String::new();
692        let mut fence_info_string = String::new();
693
694        // Compute byte offsets via pointer arithmetic.
695        // `content.lines()` returns slices into the original string,
696        // so each line's pointer offset from `content` gives its byte position.
697        // This correctly handles \n, \r\n, and empty lines.
698        let content_start_ptr = content.as_ptr() as usize;
699        let line_offsets: Vec<usize> = lines
700            .iter()
701            .map(|line| line.as_ptr() as usize - content_start_ptr)
702            .collect();
703
704        for (i, line) in lines.iter().enumerate() {
705            let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
706            let is_admonition = mkdocs_admonitions::is_admonition_start(line);
707            let is_tab = mkdocs_tabs::is_tab_marker(line);
708
709            // Pop contexts when the current line is not indented enough to be content.
710            // This runs for ALL lines (including new admonition/tab starts) to clean
711            // up stale entries before potentially pushing a new context.
712            if !line.trim().is_empty() {
713                while let Some(&ctx_indent) = context_indent_stack.last() {
714                    if line_indent < ctx_indent + 4 {
715                        context_indent_stack.pop();
716                        if in_fence {
717                            in_fence = false;
718                        }
719                    } else {
720                        break;
721                    }
722                }
723            }
724
725            // A line of a fenced block can end a context, but never opens one
726            // or a fence: it is code.
727            if in_fenced_block[i] {
728                continue;
729            }
730
731            // Check for admonition start — push new context
732            if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
733                context_indent_stack.push(indent);
734                continue;
735            }
736
737            // Check for tab marker — push new context
738            if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
739                context_indent_stack.push(indent);
740                continue;
741            }
742
743            // Only look for fences inside a MkDocs context
744            if context_indent_stack.is_empty() {
745                continue;
746            }
747
748            let trimmed = line.trim_start();
749            let leading_spaces = line.len() - trimmed.len();
750
751            if !in_fence {
752                // Check for fence opening
753                let (fc, fl) = if trimmed.starts_with("```") {
754                    ('`', trimmed.chars().take_while(|&c| c == '`').count())
755                } else if trimmed.starts_with("~~~") {
756                    ('~', trimmed.chars().take_while(|&c| c == '~').count())
757                } else {
758                    continue;
759                };
760
761                if fl >= 3 {
762                    in_fence = true;
763                    fence_start_line = i;
764                    fence_char = fc;
765                    fence_length = fl;
766                    fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
767
768                    let after_fence = &trimmed[fl..];
769                    fence_info_string = after_fence.trim().to_string();
770                    fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
771
772                    // Content starts at the next line's byte offset
773                    fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
774                }
775            } else {
776                // Check for fence closing
777                let is_closing = if fence_char == '`' {
778                    trimmed.starts_with("```")
779                        && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
780                        && trimmed.trim_start_matches('`').trim().is_empty()
781                } else {
782                    trimmed.starts_with("~~~")
783                        && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
784                        && trimmed.trim_start_matches('~').trim().is_empty()
785                };
786
787                if is_closing {
788                    let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
789
790                    if content_end >= fence_content_start {
791                        let (code, line_prefix_widths) =
792                            strip_indent_from_block(&content[fence_content_start..content_end], &fence_indent_prefix);
793                        blocks.push(FencedCodeBlockInfo {
794                            start_line: fence_start_line,
795                            end_line: i,
796                            content_start: fence_content_start,
797                            content_end,
798                            language: fence_language.clone(),
799                            info_string: fence_info_string.clone(),
800                            fence_char,
801                            fence_length,
802                            content_prefix: fence_indent_prefix.clone(),
803                            code,
804                            line_prefix_widths,
805                        });
806                    }
807
808                    in_fence = false;
809                }
810            }
811        }
812
813        blocks
814    }
815
816    /// Resolve a language tag to its canonical name.
817    fn resolve_language(&self, language: &str) -> String {
818        let lower = language.to_lowercase();
819        if let Some(mapped) = self.user_aliases.get(&lower) {
820            return mapped.clone();
821        }
822        match self.config.normalize_language {
823            NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
824            NormalizeLanguage::Exact => lower,
825        }
826    }
827
828    /// Get the effective on_error setting for a language.
829    fn get_on_error(&self, language: &str) -> OnError {
830        self.language_config(language)
831            .and_then(|lc| lc.on_error)
832            .unwrap_or(self.config.on_error)
833    }
834
835    /// Lint all code blocks in the content.
836    ///
837    /// Returns diagnostics from all configured linters. Tools that could not run
838    /// under `on-error = "warn"` are left out; [`Self::lint_output`] returns them.
839    pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
840        self.lint_output(content).map(|output| output.diagnostics)
841    }
842
843    /// Lint all code blocks in the content, with the tool failures `warn` reports.
844    ///
845    /// Returns `Err` only for `fail-fast` settings. A tool that cannot run under
846    /// `on-error = "fail"` stops the document and is reported as a finding at its
847    /// block, beside the findings of the blocks checked before it.
848    pub fn lint_output(&self, content: &str) -> Result<LintOutput, ProcessorError> {
849        // Skip the expensive parse when no tools could possibly produce output.
850        // With on_missing=Ignore (default) and no languages with lint tools configured,
851        // every block would be skipped, so the parse is wasted work.
852        if !self.needs_coverage_scan()
853            && self.config.on_missing_language_definition.skips_the_block()
854            && !self
855                .config
856                .languages
857                .values()
858                .any(|lc| lc.enabled && !lc.lint.is_empty())
859        {
860            return Ok(LintOutput::default());
861        }
862
863        // Quick content check: skip parsing if no configured language appears in the content.
864        // This avoids the expensive pulldown-cmark parse when there are no matching code blocks.
865        if !self.needs_coverage_scan()
866            && self.config.on_missing_language_definition.skips_the_block()
867            && !self.has_potential_matching_blocks(content, true)
868        {
869            return Ok(LintOutput::default());
870        }
871
872        let mut incomplete = false;
873        let mut all_diagnostics = Vec::new();
874        let mut warnings = Vec::new();
875        let blocks = self.extract_code_blocks(content);
876
877        for block in blocks {
878            if let Some((policy, key, message)) = self.block_policy(&block, true) {
879                if let Some(diagnostic) = self.policy_diagnostic(policy, key, message, block.start_line + 1)? {
880                    all_diagnostics.push(diagnostic);
881                }
882                continue;
883            }
884            if block.language.is_empty() {
885                continue;
886            }
887
888            let canonical_lang = self.resolve_language(&block.language);
889
890            // Get lint tools for this language
891            let lang_config = self.language_config(&canonical_lang);
892
893            // If language is explicitly configured with enabled=false, skip silently
894            if let Some(lc) = lang_config
895                && !lc.enabled
896            {
897                continue;
898            }
899
900            let lint_tools = match lang_config {
901                Some(lc) if !lc.lint.is_empty() => &lc.lint,
902                // Defined with format tools only. That is a complete definition:
903                // `on-missing-language-definition` speaks for a language the config never
904                // gave any tool, not for a mode a defined language does not use.
905                Some(lc) if !lc.format.is_empty() => continue,
906                _ => {
907                    // The language has no tools in either mode
908                    match self.config.on_missing_language_definition {
909                        // Which languages appear in a document is not known before
910                        // reading it, so there is no once-per-run place to say this
911                        // and `warn` has nothing to add over `ignore`. Config
912                        // validation tells the user that, rather than leaving a
913                        // setting quietly doing nothing.
914                        OnMissing::Ignore => continue,
915                        OnMissing::Warn => {
916                            self.run_state.warn(
917                                format!("missing-definition:{canonical_lang}"),
918                                format!("No tools configured for language '{canonical_lang}'"),
919                            );
920                            continue;
921                        }
922                        OnMissing::Fail => {
923                            all_diagnostics.push(CodeBlockDiagnostic {
924                                file_line: block.start_line + 1,
925                                column: None,
926                                message: format!("No lint tools configured for language '{canonical_lang}'"),
927                                severity: DiagnosticSeverity::Error,
928                                tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
929                                code_block_start: block.start_line + 1,
930                            });
931                            continue;
932                        }
933                        OnMissing::FailFast => {
934                            self.run_state.abort();
935                            return Err(ProcessorError::NoToolsConfigured {
936                                language: canonical_lang,
937                                line: block.start_line + 1,
938                            });
939                        }
940                    }
941                }
942            };
943
944            // Extract code block content
945            if block.content_start >= block.content_end || block.content_end > content.len() {
946                continue;
947            }
948            let code_content = &block.code;
949
950            // Run each lint tool
951            for tool_id in lint_tools {
952                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown linting
953                if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
954                    if self.builtin_checks {
955                        self.run_state.record_execution();
956                    }
957                    continue;
958                }
959
960                if self.registry.definition_problem(tool_id, ToolSlot::Lint).is_some() {
961                    continue;
962                }
963                let Some(resolved_id) = self.registry.resolve_id(tool_id, ToolSlot::Lint) else {
964                    self.warn_unknown_tool(tool_id, &canonical_lang);
965                    continue;
966                };
967                let Some(tool_def) = self.registry.get(&resolved_id) else {
968                    self.warn_unknown_tool(tool_id, &canonical_lang);
969                    continue;
970                };
971
972                // Check if tool binary exists before running
973                let tool_name = tool_def.command.first().map_or("", String::as_str);
974                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
975                    match self.config.on_missing_tool_binary {
976                        // `warn` is announced once for the whole run, beside the
977                        // configuration that named the tool, rather than once per
978                        // block. Here it skips exactly as `ignore` does.
979                        OnMissing::Ignore | OnMissing::Warn => {
980                            log::debug!("Tool binary '{tool_name}' not found, skipping");
981                            continue;
982                        }
983                        OnMissing::Fail => {
984                            all_diagnostics.push(CodeBlockDiagnostic {
985                                file_line: block.start_line + 1,
986                                column: None,
987                                message: format!("Tool binary '{tool_name}' not found in allowed lookup locations"),
988                                severity: DiagnosticSeverity::Error,
989                                tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
990                                code_block_start: block.start_line + 1,
991                            });
992                            continue;
993                        }
994                        OnMissing::FailFast => {
995                            self.run_state.abort();
996                            return Err(ProcessorError::ToolBinaryNotFound {
997                                tool: tool_name.to_string(),
998                                language: canonical_lang.clone(),
999                                line: block.start_line + 1,
1000                            });
1001                        }
1002                    }
1003                }
1004
1005                let tool_input = ensure_trailing_newline(code_content);
1006                let run = match self.registry.lint_mode(&resolved_id) {
1007                    // Explicit djLint and Shuck modes prefer verified native checks;
1008                    // all other formatters and unsupported versions compare output.
1009                    Some(BuiltinLintMode::FormatCheck) => self
1010                        .executor
1011                        .format_check(
1012                            tool_def,
1013                            self.registry.native_format_check(&resolved_id).as_ref(),
1014                            &tool_input,
1015                            Some(self.config.timeout),
1016                        )
1017                        .map(|output| match output {
1018                            FormatCheckOutput::Formatted(output) => {
1019                                self.format_check_diagnostics(&output, code_content, tool_id, block.start_line + 1)
1020                            }
1021                            FormatCheckOutput::Native { changed: false } => Vec::new(),
1022                            FormatCheckOutput::Native { changed: true } => {
1023                                self.unformatted_diagnostic(tool_id, block.start_line + 1)
1024                            }
1025                        }),
1026                    _ => self
1027                        .executor
1028                        .lint(tool_def, &tool_input, Some(self.config.timeout))
1029                        .map(|output| {
1030                            self.parse_tool_output(
1031                                &output,
1032                                tool_id,
1033                                block.start_line + 1, // Convert to 1-indexed
1034                            )
1035                        }),
1036                };
1037                match run {
1038                    Ok(mut diagnostics) => {
1039                        // A tool counts columns in the code it was given, which
1040                        // starts after the line's container prefix in the document.
1041                        for diagnostic in &mut diagnostics {
1042                            let code_line = diagnostic.file_line.checked_sub(block.start_line + 1);
1043                            if let (Some(column), Some(line)) = (diagnostic.column.as_mut(), code_line)
1044                                && let Some(document_column) = block.document_column(line, *column)
1045                            {
1046                                *column = document_column;
1047                            }
1048                        }
1049                        all_diagnostics.extend(diagnostics);
1050                    }
1051                    Err(error) => match self.get_on_error(&canonical_lang) {
1052                        // `fail` stops the document here. What the blocks before
1053                        // this one reported is still true, so it is kept, and the
1054                        // failure is reported at the block it happened in.
1055                        OnError::Fail => {
1056                            let failure = ProcessorError::ToolErrorAt {
1057                                error,
1058                                line: block.start_line + 1,
1059                                language: canonical_lang,
1060                            };
1061                            all_diagnostics.push(CodeBlockDiagnostic {
1062                                file_line: block.start_line + 1,
1063                                column: None,
1064                                message: failure.detail(),
1065                                severity: DiagnosticSeverity::Error,
1066                                tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1067                                code_block_start: block.start_line + 1,
1068                            });
1069                            return Ok(LintOutput {
1070                                diagnostics: all_diagnostics,
1071                                warnings,
1072                                incomplete: true,
1073                            });
1074                        }
1075                        OnError::Warn => {
1076                            incomplete = true;
1077                            warnings.push(format!("line {} ({canonical_lang}): {error}", block.start_line + 1));
1078                        }
1079                        OnError::Skip => incomplete = true,
1080                    },
1081                }
1082            }
1083        }
1084
1085        Ok(LintOutput {
1086            diagnostics: all_diagnostics,
1087            warnings,
1088            incomplete,
1089        })
1090    }
1091
1092    /// Format all code blocks in analysis text (the document's encoding BOM
1093    /// already removed by the caller, as for `format_with_config`).
1094    ///
1095    /// Returns the modified content with formatted code blocks and any errors that occurred.
1096    /// With `on-missing-*` = `fail`, errors are collected but formatting continues.
1097    /// With `on-missing-*` = `fail-fast`, returns Err immediately on first error.
1098    pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
1099        self.format_with_config(content, &crate::config::Config::default(), None)
1100    }
1101
1102    /// Format with the document's conflict-marker configuration and per-file exceptions.
1103    ///
1104    /// The conflict guard reads the configuration alone. A caller that resolved a
1105    /// rule selection of its own passes it to `format_for_rules` instead.
1106    ///
1107    /// `content` is analysis text: a caller holding a file with a leading byte
1108    /// order mark removes it first (`utils::bom::strip_bom`) and puts it back on
1109    /// the result, so a second U+FEFF stays ordinary content.
1110    pub fn format_with_config(
1111        &self,
1112        content: &str,
1113        config: &crate::config::Config,
1114        path: Option<&std::path::Path>,
1115    ) -> Result<FormatOutput, ProcessorError> {
1116        self.format_guarded(content, None, config, path)
1117    }
1118
1119    /// Format under the invocation's rule selection.
1120    ///
1121    /// A run that dropped MD092 formats a conflicted document like any other, so
1122    /// its fenced code is formatted too: the alternative is an outer document
1123    /// rewritten around code blocks the same run silently left alone.
1124    pub fn format_for_rules(
1125        &self,
1126        content: &str,
1127        rules: &[Box<dyn crate::rule::Rule>],
1128        config: &crate::config::Config,
1129        path: Option<&std::path::Path>,
1130    ) -> Result<FormatOutput, ProcessorError> {
1131        self.format_guarded(content, Some(rules), config, path)
1132    }
1133
1134    fn format_guarded(
1135        &self,
1136        content: &str,
1137        rules: Option<&[Box<dyn crate::rule::Rule>]>,
1138        config: &crate::config::Config,
1139        path: Option<&std::path::Path>,
1140    ) -> Result<FormatOutput, ProcessorError> {
1141        let no_output = FormatOutput {
1142            content: content.to_string(),
1143            had_errors: false,
1144            error_messages: Vec::new(),
1145            failures: Vec::new(),
1146        };
1147
1148        let conflicted = match rules {
1149            Some(rules) => crate::merge_conflict::detect_for_analysis(content, rules, config, path).is_some(),
1150            None => crate::merge_conflict::detect_configured_for_analysis(content, config, path).is_some(),
1151        };
1152        if conflicted {
1153            return Ok(no_output);
1154        }
1155
1156        // Skip the expensive parse when no tools could produce output
1157        if !self.needs_coverage_scan()
1158            && self.config.on_missing_language_definition.skips_the_block()
1159            && !self
1160                .config
1161                .languages
1162                .values()
1163                .any(|lc| lc.enabled && !lc.format.is_empty())
1164        {
1165            return Ok(no_output);
1166        }
1167
1168        // Quick content check: skip parsing if no configured language appears in the content
1169        if !self.needs_coverage_scan()
1170            && self.config.on_missing_language_definition.skips_the_block()
1171            && !self.has_potential_matching_blocks(content, false)
1172        {
1173            return Ok(no_output);
1174        }
1175
1176        let blocks = self.extract_code_blocks(content);
1177
1178        if blocks.is_empty() {
1179            return Ok(no_output);
1180        }
1181
1182        let mut error_messages = Vec::new();
1183        let mut failures = Vec::new();
1184        for block in &blocks {
1185            if let Some((policy, key, message)) = self.block_policy(block, false)
1186                && let Some(diagnostic) = self.policy_diagnostic(policy, key, message, block.start_line + 1)?
1187            {
1188                error_messages.push(diagnostic.message.clone());
1189                failures.push(diagnostic);
1190            }
1191        }
1192        // Check fail-fast gaps in source order before reverse-offset execution.
1193        for block in &blocks {
1194            if self.block_policy(block, false).is_some() {
1195                continue;
1196            }
1197            let canonical = self.resolve_language(&block.language);
1198            let language = self.language_config(&canonical);
1199            if language.is_some_and(|lc| !lc.enabled || lc.format.is_empty() && !lc.lint.is_empty()) {
1200                continue;
1201            }
1202            let Some(language) = language.filter(|lc| !lc.format.is_empty()) else {
1203                if self.config.on_missing_language_definition == OnMissing::FailFast {
1204                    self.run_state.abort();
1205                    return Err(ProcessorError::NoToolsConfigured {
1206                        language: canonical,
1207                        line: block.start_line + 1,
1208                    });
1209                }
1210                continue;
1211            };
1212            if self.config.on_missing_tool_binary != OnMissing::FailFast {
1213                continue;
1214            }
1215            for id in &language.format {
1216                if is_rumdl_builtin(id) && is_markdown_language(&canonical) {
1217                    continue;
1218                }
1219                if self.registry.definition_problem(id, ToolSlot::Format).is_some() {
1220                    continue;
1221                }
1222                let Some(tool) = self.registry.resolve(id, ToolSlot::Format) else {
1223                    continue;
1224                };
1225                let Some(binary) = tool.command.first() else {
1226                    continue;
1227                };
1228                if !self.executor.is_tool_available(binary) {
1229                    self.run_state.abort();
1230                    return Err(ProcessorError::ToolBinaryNotFound {
1231                        tool: binary.clone(),
1232                        language: canonical.clone(),
1233                        line: block.start_line + 1,
1234                    });
1235                }
1236                // Fallback stages after an available formatter are only known
1237                // to be needed if it fails; runtime retains that check.
1238                if language.format_mode == FormatMode::Fallback {
1239                    break;
1240                }
1241            }
1242        }
1243        // Execution uses original offsets; coverage policies are checked above
1244        // in source order, so fail-fast never starts later blocks speculatively.
1245        let mut planned = Vec::new();
1246        for (index, block) in blocks.iter().enumerate().rev() {
1247            if self.block_policy(block, false).is_some() {
1248                continue;
1249            }
1250            if block.language.is_empty() {
1251                continue;
1252            }
1253
1254            let canonical_lang = self.resolve_language(&block.language);
1255
1256            // Get format tools for this language
1257            let lang_config = self.language_config(&canonical_lang);
1258
1259            // If language is explicitly configured with enabled=false, skip silently
1260            if let Some(lc) = lang_config
1261                && !lc.enabled
1262            {
1263                continue;
1264            }
1265
1266            let (format_tools, format_mode) = match lang_config {
1267                Some(lc) if !lc.format.is_empty() => (&lc.format, lc.format_mode),
1268                // Defined with lint tools only. See the matching arm in `lint`.
1269                Some(lc) if !lc.lint.is_empty() => continue,
1270                _ => {
1271                    // The language has no tools in either mode
1272                    match self.config.on_missing_language_definition {
1273                        // See the matching arm in `lint`.
1274                        OnMissing::Ignore => continue,
1275                        OnMissing::Warn => {
1276                            self.run_state.warn(
1277                                format!("missing-definition:{canonical_lang}"),
1278                                format!("No tools configured for language '{canonical_lang}'"),
1279                            );
1280                            continue;
1281                        }
1282                        OnMissing::Fail => {
1283                            error_messages.push(format!(
1284                                "No format tools configured for language '{canonical_lang}' at line {}",
1285                                block.start_line + 1
1286                            ));
1287                            failures.push(CodeBlockDiagnostic {
1288                                file_line: block.start_line + 1,
1289                                column: None,
1290                                message: format!("No format tools configured for language '{canonical_lang}'"),
1291                                severity: DiagnosticSeverity::Error,
1292                                tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1293                                code_block_start: block.start_line + 1,
1294                            });
1295                            continue;
1296                        }
1297                        OnMissing::FailFast => {
1298                            self.run_state.abort();
1299                            return Err(ProcessorError::NoToolsConfigured {
1300                                language: canonical_lang,
1301                                line: block.start_line + 1,
1302                            });
1303                        }
1304                    }
1305                }
1306            };
1307
1308            // Extract code block content
1309            if block.content_start >= block.content_end || block.content_end > content.len() {
1310                continue;
1311            }
1312            let code_content_raw = &content[block.content_start..block.content_end];
1313            let code_content = block.code.clone();
1314
1315            // In fallback mode the first formatter that succeeds supplies the
1316            // block. In pipeline mode every formatter runs, each on the output of
1317            // the last one that succeeded; a formatter that fails, or is skipped,
1318            // never replaces that output, so the next one starts from it.
1319            let mut formatted = code_content.clone();
1320            let mut tool_ran = false;
1321            for tool_id in format_tools {
1322                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown formatting
1323                if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
1324                    if self.builtin_checks {
1325                        self.run_state.record_execution();
1326                    }
1327                    continue;
1328                }
1329
1330                if self.registry.definition_problem(tool_id, ToolSlot::Format).is_some() {
1331                    continue;
1332                }
1333                let Some(tool_def) = self.registry.resolve(tool_id, ToolSlot::Format) else {
1334                    self.warn_unknown_tool(tool_id, &canonical_lang);
1335                    continue;
1336                };
1337
1338                // A linter writes its report to stdout, so running one here replaces the
1339                // block with that report: `ruff:check` in a format slot turns a clean
1340                // Python block into the literal text `All checks passed!`. The
1341                // empty-output guard below does not catch it, because a linter with
1342                // nothing to say still exits 0 and can still print a summary line.
1343                // Config validation reports this too, but a warning cannot undo an
1344                // overwritten block, so the run declines the tool as well.
1345                if self.registry.fills_format_slot(tool_id) == Some(false) {
1346                    log::warn!(
1347                        "Tool '{tool_id}' is a linter and cannot format '{canonical_lang}' code blocks; move it to the lint slot"
1348                    );
1349                    continue;
1350                }
1351
1352                // Check if tool binary exists before running
1353                let tool_name = tool_def.command.first().map_or("", String::as_str);
1354                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
1355                    match self.config.on_missing_tool_binary {
1356                        // See the matching arm in `lint`.
1357                        OnMissing::Ignore | OnMissing::Warn => {
1358                            log::debug!("Tool binary '{tool_name}' not found, skipping");
1359                            continue;
1360                        }
1361                        OnMissing::Fail => {
1362                            error_messages.push(format!(
1363                                "Tool binary '{tool_name}' not found in allowed lookup locations for language '{canonical_lang}' at line {}",
1364                                block.start_line + 1
1365                            ));
1366                            failures.push(CodeBlockDiagnostic {
1367                                file_line: block.start_line + 1,
1368                                column: None,
1369                                message: format!("Tool binary '{tool_name}' not found in allowed lookup locations"),
1370                                severity: DiagnosticSeverity::Error,
1371                                tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1372                                code_block_start: block.start_line + 1,
1373                            });
1374                            continue;
1375                        }
1376                        OnMissing::FailFast => {
1377                            self.run_state.abort();
1378                            return Err(ProcessorError::ToolBinaryNotFound {
1379                                tool: tool_name.to_string(),
1380                                language: canonical_lang.clone(),
1381                                line: block.start_line + 1,
1382                            });
1383                        }
1384                    }
1385                }
1386
1387                let tool_input = ensure_trailing_newline(&formatted);
1388                match self.executor.format(tool_def, &tool_input, Some(self.config.timeout)) {
1389                    Ok(output) => {
1390                        // Ensure trailing newline matches original (unindented)
1391                        formatted = output;
1392                        if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1393                            formatted.push('\n');
1394                        } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1395                            formatted.pop();
1396                        }
1397                        tool_ran = true;
1398                        if format_mode == FormatMode::Fallback {
1399                            break;
1400                        }
1401                    }
1402                    Err(e) => {
1403                        let on_error = self.get_on_error(&canonical_lang);
1404                        match on_error {
1405                            OnError::Fail => {
1406                                return Err(ProcessorError::ToolErrorAt {
1407                                    error: e,
1408                                    line: block.start_line + 1,
1409                                    language: canonical_lang,
1410                                });
1411                            }
1412                            OnError::Warn => {
1413                                error_messages.push(format!("line {} ({}): {e}", block.start_line + 1, canonical_lang));
1414                            }
1415                            OnError::Skip => {}
1416                        }
1417                    }
1418                }
1419            }
1420
1421            // Replace content if changed and a tool actually ran
1422            if tool_ran && formatted != code_content && block.source_for(&formatted) != code_content_raw {
1423                planned.push(WriteBack { index, code: formatted });
1424            }
1425        }
1426
1427        let (result, refused) = write_back_verified(content, &blocks, &planned, |document| {
1428            self.extract_code_blocks(document)
1429        });
1430        for index in refused {
1431            let block = &blocks[index];
1432            let diagnostic = CodeBlockDiagnostic::refused_write_back(block.start_line + 1);
1433            error_messages.push(format!(
1434                "line {} ({}): {}",
1435                diagnostic.file_line,
1436                self.resolve_language(&block.language),
1437                diagnostic.message
1438            ));
1439            failures.push(diagnostic);
1440        }
1441
1442        Ok(FormatOutput {
1443            content: result,
1444            had_errors: !error_messages.is_empty(),
1445            error_messages,
1446            failures,
1447        })
1448    }
1449
1450    /// Diagnostics for a built-in formatter used in a `lint` slot.
1451    ///
1452    /// The formatter's own output is the answer: a block that comes back changed is not
1453    /// formatted. This comparison handles ordinary formatter IDs and the fallback
1454    /// for native-check IDs whose installed version does not support reliable stdin checks.
1455    ///
1456    /// The comparison mirrors the one [`Self::format`] makes before rewriting a
1457    /// block, so `check` reports exactly the blocks `fmt` would change. A
1458    /// formatter that emptied a non-empty block never gets here: the executor
1459    /// reports it as a tool failure, for `on-error` to handle.
1460    fn format_check_diagnostics(
1461        &self,
1462        output: &str,
1463        code_content: &str,
1464        tool_id: &str,
1465        code_block_start_line: usize,
1466    ) -> Vec<CodeBlockDiagnostic> {
1467        let mut formatted = output.to_string();
1468        if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1469            formatted.push('\n');
1470        } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1471            formatted.pop();
1472        }
1473
1474        if formatted == code_content {
1475            return Vec::new();
1476        }
1477
1478        self.unformatted_diagnostic(tool_id, code_block_start_line)
1479    }
1480
1481    fn unformatted_diagnostic(&self, tool_id: &str, code_block_start_line: usize) -> Vec<CodeBlockDiagnostic> {
1482        vec![CodeBlockDiagnostic {
1483            file_line: code_block_start_line,
1484            column: None,
1485            message: "Code block is not formatted".to_string(),
1486            severity: DiagnosticSeverity::Warning,
1487            tool: tool_id.to_string(),
1488            code_block_start: code_block_start_line,
1489        }]
1490    }
1491
1492    /// Parse tool output into diagnostics.
1493    ///
1494    /// This is a basic parser that handles common output formats.
1495    /// Tools vary widely in their output format, so this is best-effort.
1496    fn parse_tool_output(
1497        &self,
1498        output: &ToolOutput,
1499        tool_id: &str,
1500        code_block_start_line: usize,
1501    ) -> Vec<CodeBlockDiagnostic> {
1502        let mut diagnostics = Vec::new();
1503        let mut shellcheck_line: Option<usize> = None;
1504
1505        // Strip ANSI escape codes and combine stdout + stderr for parsing
1506        let stdout_clean = strip_ansi_codes(&output.stdout);
1507        let stderr_clean = strip_ansi_codes(&output.stderr);
1508        let combined = format!("{stdout_clean}\n{stderr_clean}");
1509
1510        // State for multi-line "Error: msg" / "at line N column M" pattern
1511        let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
1512
1513        for line in combined.lines() {
1514            let line = line.trim();
1515            if line.is_empty() {
1516                continue;
1517            }
1518
1519            // Resolve pending "Error: msg" from previous line
1520            if let Some((ref msg, severity)) = pending_error {
1521                if let Some((line_num, col)) = Self::parse_at_line_column(line) {
1522                    diagnostics.push(CodeBlockDiagnostic {
1523                        file_line: code_block_start_line + line_num,
1524                        column: Some(col),
1525                        message: msg.clone(),
1526                        severity,
1527                        tool: tool_id.to_string(),
1528                        code_block_start: code_block_start_line,
1529                    });
1530                    pending_error = None;
1531                    continue;
1532                }
1533                // No position line followed; fall back to what the message itself says.
1534                diagnostics.push(Self::unpositioned_diagnostic(
1535                    msg,
1536                    severity,
1537                    tool_id,
1538                    code_block_start_line,
1539                ));
1540                pending_error = None;
1541                // Fall through to parse current line
1542            }
1543
1544            if let Some(line_num) = self.parse_shellcheck_header(line) {
1545                shellcheck_line = Some(line_num);
1546                continue;
1547            }
1548
1549            if let Some(line_num) = shellcheck_line
1550                && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
1551            {
1552                diagnostics.push(diag);
1553                continue;
1554            }
1555
1556            // Try pattern: "::warning file=f,line=N,col=M::message" (GitHub annotation)
1557            if let Some(diag) = Self::parse_github_annotation(line, tool_id, code_block_start_line) {
1558                diagnostics.push(diag);
1559                continue;
1560            }
1561
1562            // Try pattern: "file:line:col: message" or "file:line: message"
1563            if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
1564                diagnostics.push(diag);
1565                continue;
1566            }
1567
1568            // Try pattern: "line:col message" (eslint style)
1569            if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
1570                diagnostics.push(diag);
1571                continue;
1572            }
1573
1574            // Try single-line shellcheck format fallback
1575            if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
1576                diagnostics.push(diag);
1577                continue;
1578            }
1579
1580            // Try multi-line "Error: msg" / "Warning: msg" pattern
1581            if let Some(error_info) = Self::parse_error_line(line) {
1582                pending_error = Some(error_info);
1583            }
1584        }
1585
1586        // Flush any remaining pending error
1587        if let Some((msg, severity)) = pending_error {
1588            diagnostics.push(Self::unpositioned_diagnostic(
1589                &msg,
1590                severity,
1591                tool_id,
1592                code_block_start_line,
1593            ));
1594        }
1595
1596        // If no diagnostics parsed but tool failed, use combined output as fallback
1597        if diagnostics.is_empty() && !output.success {
1598            let lines: Vec<&str> = combined.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
1599
1600            if lines.is_empty() {
1601                let exit_code = output.exit_code;
1602                diagnostics.push(CodeBlockDiagnostic {
1603                    file_line: code_block_start_line,
1604                    column: None,
1605                    message: format!("Tool exited with code {exit_code}"),
1606                    severity: DiagnosticSeverity::Error,
1607                    tool: tool_id.to_string(),
1608                    code_block_start: code_block_start_line,
1609                });
1610            } else {
1611                for line_text in lines {
1612                    diagnostics.push(Self::unpositioned_diagnostic(
1613                        line_text,
1614                        DiagnosticSeverity::Error,
1615                        tool_id,
1616                        code_block_start_line,
1617                    ));
1618                }
1619            }
1620        }
1621
1622        diagnostics
1623    }
1624
1625    /// Build a diagnostic for a message that carried no `file:line:col:` prefix.
1626    ///
1627    /// A tool that states the position in prose instead ("parse error: ... at line 3,
1628    /// column 8") is still telling us where the problem is, so the diagnostic goes there.
1629    /// Only a message that names no position at all anchors at the fence.
1630    fn unpositioned_diagnostic(
1631        message: &str,
1632        severity: DiagnosticSeverity,
1633        tool_id: &str,
1634        code_block_start_line: usize,
1635    ) -> CodeBlockDiagnostic {
1636        let (line_offset, column) = Self::parse_position_in_message(message).unwrap_or((0, None));
1637        CodeBlockDiagnostic {
1638            file_line: code_block_start_line + line_offset,
1639            column,
1640            message: message.to_string(),
1641            severity,
1642            tool: tool_id.to_string(),
1643            code_block_start: code_block_start_line,
1644        }
1645    }
1646
1647    /// Find an "at line N" / "at line N, column M" position stated inside a message.
1648    ///
1649    /// The last occurrence wins: the message is a sentence, and a tool that mentions more
1650    /// than one position ends on the one the diagnostic is about.
1651    fn parse_position_in_message(message: &str) -> Option<(usize, Option<usize>)> {
1652        // ASCII-lowercasing preserves byte offsets, so positions found here index `message`
1653        // itself; only ASCII digits are ever parsed out of it.
1654        let lower = message.to_ascii_lowercase();
1655        let after_marker = &lower[lower.rfind("at line ")? + "at line ".len()..];
1656
1657        let leading_number = |text: &str| -> Option<usize> {
1658            let end = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
1659            text[..end].parse::<usize>().ok()
1660        };
1661
1662        let line_num = leading_number(after_marker)?;
1663        let after_line = after_marker.trim_start_matches(|c: char| c.is_ascii_digit());
1664        let after_separator = after_line
1665            .trim_start()
1666            .strip_prefix(',')
1667            .unwrap_or(after_line)
1668            .trim_start();
1669        let column = after_separator.strip_prefix("column ").and_then(leading_number);
1670
1671        Some((line_num, column))
1672    }
1673
1674    /// Parse standard "file:line:col: message" format.
1675    fn parse_standard_format(
1676        &self,
1677        line: &str,
1678        tool_id: &str,
1679        code_block_start_line: usize,
1680    ) -> Option<CodeBlockDiagnostic> {
1681        // Match patterns like "file.py:1:10: E501 message"
1682        let mut parts = line.rsplitn(4, ':');
1683        let message = parts.next()?.trim().to_string();
1684        let part1 = parts.next()?.trim().to_string();
1685        let part2 = parts.next()?.trim().to_string();
1686        let part3 = parts.next().map(|s| s.trim().to_string());
1687
1688        let (line_part, col_part) = if part3.is_some() {
1689            (part2, Some(part1))
1690        } else {
1691            (part1, None)
1692        };
1693
1694        if let Ok(line_num) = line_part.parse::<usize>() {
1695            let column = col_part.and_then(|s| s.parse::<usize>().ok());
1696            let message = Self::strip_fixable_markers(&message);
1697            if !message.is_empty() {
1698                let severity = self.infer_severity(&message);
1699                return Some(CodeBlockDiagnostic {
1700                    file_line: code_block_start_line + line_num,
1701                    column,
1702                    message,
1703                    severity,
1704                    tool: tool_id.to_string(),
1705                    code_block_start: code_block_start_line,
1706                });
1707            }
1708        }
1709        None
1710    }
1711
1712    /// Parse a GitHub Actions workflow command: `::<level> <k=v>,...::<message>`.
1713    ///
1714    /// Emitted by `--format github-annotation-native` (sqlfluff) and by other tools with a
1715    /// GitHub Actions mode. Only the three annotation levels are diagnostics: `::group::`
1716    /// and `::endgroup::` bracket the findings and carry none, so they are skipped rather
1717    /// than reported. A missing `line` anchors the diagnostic at the fence, the same place
1718    /// an unpositioned message goes.
1719    fn parse_github_annotation(line: &str, tool_id: &str, code_block_start_line: usize) -> Option<CodeBlockDiagnostic> {
1720        let body = line.strip_prefix("::")?;
1721        let (head, message) = body.split_once("::")?;
1722
1723        let (level, properties) = head.split_once(' ').unwrap_or((head, ""));
1724        let severity = match level {
1725            "error" => DiagnosticSeverity::Error,
1726            "warning" => DiagnosticSeverity::Warning,
1727            "notice" => DiagnosticSeverity::Info,
1728            _ => return None,
1729        };
1730
1731        let property = |name: &str| -> Option<usize> {
1732            properties
1733                .split(',')
1734                .filter_map(|pair| pair.split_once('='))
1735                .find(|(key, _)| *key == name)
1736                .and_then(|(_, value)| value.parse::<usize>().ok())
1737        };
1738
1739        let message = Self::strip_fixable_markers(&decode_workflow_command(message));
1740        if message.is_empty() {
1741            return None;
1742        }
1743
1744        Some(CodeBlockDiagnostic {
1745            file_line: code_block_start_line + property("line").unwrap_or(0),
1746            column: property("col"),
1747            message,
1748            severity,
1749            tool: tool_id.to_string(),
1750            code_block_start: code_block_start_line,
1751        })
1752    }
1753
1754    /// Parse eslint-style "line:col severity message" format.
1755    fn parse_eslint_format(
1756        &self,
1757        line: &str,
1758        tool_id: &str,
1759        code_block_start_line: usize,
1760    ) -> Option<CodeBlockDiagnostic> {
1761        // Match "1:10 error Message"
1762        let parts: Vec<&str> = line.splitn(3, ' ').collect();
1763        if parts.len() >= 2 {
1764            let loc_parts: Vec<&str> = parts[0].split(':').collect();
1765            if loc_parts.len() == 2
1766                && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
1767            {
1768                let (sev_part, msg_part) = if parts.len() >= 3 {
1769                    (parts[1], parts[2])
1770                } else {
1771                    (parts[1], "")
1772                };
1773                let message = if msg_part.is_empty() {
1774                    sev_part.to_string()
1775                } else {
1776                    msg_part.to_string()
1777                };
1778                let message = Self::strip_fixable_markers(&message);
1779                let severity = match sev_part.to_lowercase().as_str() {
1780                    "error" => DiagnosticSeverity::Error,
1781                    "warning" | "warn" => DiagnosticSeverity::Warning,
1782                    "info" => DiagnosticSeverity::Info,
1783                    _ => self.infer_severity(&message),
1784                };
1785                return Some(CodeBlockDiagnostic {
1786                    file_line: code_block_start_line + line_num,
1787                    column: Some(col),
1788                    message,
1789                    severity,
1790                    tool: tool_id.to_string(),
1791                    code_block_start: code_block_start_line,
1792                });
1793            }
1794        }
1795        None
1796    }
1797
1798    /// Parse shellcheck-style "In - line N: message" format.
1799    fn parse_shellcheck_format(
1800        &self,
1801        line: &str,
1802        tool_id: &str,
1803        code_block_start_line: usize,
1804    ) -> Option<CodeBlockDiagnostic> {
1805        // Match "In - line 5:" pattern
1806        if line.starts_with("In ")
1807            && line.contains(" line ")
1808            && let Some(line_start) = line.find(" line ")
1809        {
1810            let after_line = &line[line_start + 6..];
1811            if let Some(colon_pos) = after_line.find(':')
1812                && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1813            {
1814                let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1815                if !message.is_empty() {
1816                    let severity = self.infer_severity(&message);
1817                    return Some(CodeBlockDiagnostic {
1818                        file_line: code_block_start_line + line_num,
1819                        column: None,
1820                        message,
1821                        severity,
1822                        tool: tool_id.to_string(),
1823                        code_block_start: code_block_start_line,
1824                    });
1825                }
1826            }
1827        }
1828        None
1829    }
1830
1831    /// Parse shellcheck header line to capture line number context.
1832    fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1833        if line.starts_with("In ")
1834            && line.contains(" line ")
1835            && let Some(line_start) = line.find(" line ")
1836        {
1837            let after_line = &line[line_start + 6..];
1838            if let Some(colon_pos) = after_line.find(':') {
1839                return after_line[..colon_pos].trim().parse::<usize>().ok();
1840            }
1841        }
1842        None
1843    }
1844
1845    /// Parse shellcheck message line containing SCXXXX codes.
1846    fn parse_shellcheck_message(
1847        &self,
1848        line: &str,
1849        tool_id: &str,
1850        code_block_start_line: usize,
1851        line_num: usize,
1852    ) -> Option<CodeBlockDiagnostic> {
1853        let sc_pos = line.find("SC")?;
1854        let after_sc = &line[sc_pos + 2..];
1855        let code_len = after_sc.chars().take_while(char::is_ascii_digit).count();
1856        if code_len == 0 {
1857            return None;
1858        }
1859        let after_code = &after_sc[code_len..];
1860        let sev_start = after_code.find('(')? + 1;
1861        let sev_end = after_code[sev_start..].find(')')? + sev_start;
1862        let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1863        let message_start = after_code.find("):")? + 2;
1864        let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1865        if message.is_empty() {
1866            return None;
1867        }
1868
1869        let severity = match sev.as_str() {
1870            "error" => DiagnosticSeverity::Error,
1871            "warning" | "warn" => DiagnosticSeverity::Warning,
1872            "info" | "style" => DiagnosticSeverity::Info,
1873            _ => self.infer_severity(&message),
1874        };
1875
1876        Some(CodeBlockDiagnostic {
1877            file_line: code_block_start_line + line_num,
1878            column: None,
1879            message,
1880            severity,
1881            tool: tool_id.to_string(),
1882            code_block_start: code_block_start_line,
1883        })
1884    }
1885
1886    /// Parse `Error: <message>` or `Warning: <message>` lines.
1887    ///
1888    /// Used for tools like tombi that output multi-line diagnostics where the
1889    /// error message and position are on separate lines. Only matches capitalized
1890    /// prefixes to avoid conflicting with lowercase `error:` in less structured output.
1891    fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1892        let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1893            (msg, DiagnosticSeverity::Error)
1894        } else if let Some(msg) = line.strip_prefix("Warning:") {
1895            (msg, DiagnosticSeverity::Warning)
1896        } else {
1897            return None;
1898        };
1899        let msg = msg.trim();
1900        if msg.is_empty() {
1901            return None;
1902        }
1903        Some((msg.to_string(), severity))
1904    }
1905
1906    /// Parse "at line N column M" position lines (case-insensitive).
1907    ///
1908    /// Returns (line_number, column_number) if the pattern matches.
1909    fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1910        let lower = line.to_lowercase();
1911        let rest = lower.strip_prefix("at line ")?;
1912        let mut parts = rest.split_whitespace();
1913        let line_num: usize = parts.next()?.parse().ok()?;
1914        if parts.next()? != "column" {
1915            return None;
1916        }
1917        let col: usize = parts.next()?.parse().ok()?;
1918        Some((line_num, col))
1919    }
1920
1921    /// Infer severity from message content.
1922    fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1923        let lower = message.to_lowercase();
1924        if lower.contains("error")
1925            || lower.starts_with('e') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1926            || lower.starts_with('f') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1927        {
1928            DiagnosticSeverity::Error
1929        } else if lower.contains("warning")
1930            || lower.contains("warn")
1931            || lower.starts_with('w') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1932        {
1933            DiagnosticSeverity::Warning
1934        } else {
1935            DiagnosticSeverity::Info
1936        }
1937    }
1938
1939    /// Strip "fixable" markers from external tool messages.
1940    ///
1941    /// External tools like ruff show `[*]` to indicate fixable issues, but in rumdl's
1942    /// context these markers can be misleading - the lint tool's fix capability may
1943    /// differ from what our configured formatter can fix. We strip these markers
1944    /// to avoid making promises we can't keep.
1945    fn strip_fixable_markers(message: &str) -> String {
1946        message
1947            .replace(" [*]", "")
1948            .replace("[*] ", "")
1949            .replace("[*]", "")
1950            .replace(" (fixable)", "")
1951            .replace("(fixable) ", "")
1952            .replace("(fixable)", "")
1953            .replace(" [fix available]", "")
1954            .replace("[fix available] ", "")
1955            .replace("[fix available]", "")
1956            .replace(" [autofix]", "")
1957            .replace("[autofix] ", "")
1958            .replace("[autofix]", "")
1959            .trim()
1960            .to_string()
1961    }
1962}
1963
1964/// Every fenced code block CommonMark sees in `content`, in document order,
1965/// inside containers included.
1966pub fn extract_fenced_code_blocks(content: &str) -> Vec<FencedCodeBlockInfo> {
1967    let mut blocks = Vec::new();
1968    let mut current_block: Option<FencedCodeBlockBuilder> = None;
1969
1970    let options = rumdl_parser_options_for(content);
1971    let parser = Parser::new_ext(content, options).into_offset_iter();
1972
1973    // Byte offset at which each line starts, so a fence's line number is a binary
1974    // search rather than a newline count over everything before it.
1975    let line_starts: Vec<usize> = std::iter::once(0)
1976        .chain(content.match_indices('\n').map(|(i, _)| i + 1))
1977        .collect();
1978    let line_of = |offset: usize| line_starts.partition_point(|&start| start <= offset) - 1;
1979
1980    // Where each footnote definition enclosing the current event starts.
1981    let mut open_footnotes: Vec<usize> = Vec::new();
1982
1983    for (event, range) in parser {
1984        match event {
1985            Event::Start(Tag::FootnoteDefinition(_)) => open_footnotes.push(range.start),
1986            Event::End(TagEnd::FootnoteDefinition) => {
1987                open_footnotes.pop();
1988            }
1989            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
1990                let info_string = info.to_string();
1991                let language = info_string.split_whitespace().next().unwrap_or("").to_string();
1992
1993                let start_line = line_of(range.start);
1994
1995                // The code starts on the line after the opening fence
1996                let content_start = content[range.start..]
1997                    .find('\n')
1998                    .map_or(content.len(), |i| range.start + i + 1);
1999
2000                // The event starts at the fence itself, so everything before it
2001                // on its line belongs to the containers and the fence's indent.
2002                let fence_line_prefix = continuation_of_footnote_labels(
2003                    &content[line_starts[start_line]..range.start],
2004                    open_footnotes
2005                        .iter()
2006                        .filter(|&&start| start >= line_starts[start_line])
2007                        .map(|&start| start - line_starts[start_line]),
2008                );
2009                let fence = &content[range.start..];
2010                let fence_char = if fence.starts_with('~') { '~' } else { '`' };
2011                let fence_length = fence.chars().take_while(|&c| c == fence_char).count();
2012
2013                current_block = Some(FencedCodeBlockBuilder {
2014                    start_line,
2015                    content_start,
2016                    language,
2017                    info_string,
2018                    fence_char,
2019                    fence_length,
2020                    content_prefix: content_prefix_of(&fence_line_prefix),
2021                    pieces: Vec::new(),
2022                });
2023            }
2024            Event::Text(text) => {
2025                if let Some(builder) = current_block.as_mut() {
2026                    builder.pieces.push((range, text.into_string()));
2027                }
2028            }
2029            Event::End(TagEnd::CodeBlock) => {
2030                if let Some(builder) = current_block.take() {
2031                    // The block's range can run through the line ending of its
2032                    // last line, so its last byte is what places it.
2033                    let end_line = line_of(range.end.saturating_sub(1).max(range.start));
2034
2035                    // The source of the code runs to the end of its last line,
2036                    // line ending included. An empty block has no source at all.
2037                    let content_end = builder
2038                        .pieces
2039                        .last()
2040                        .map_or(builder.content_start, |(last, _)| last.end);
2041                    let (code, line_prefix_widths) =
2042                        block_code_from_pieces(content, &builder.pieces, |offset| line_starts[line_of(offset)]);
2043
2044                    blocks.push(FencedCodeBlockInfo {
2045                        start_line: builder.start_line,
2046                        end_line,
2047                        content_start: builder.content_start,
2048                        content_end: content_end.max(builder.content_start),
2049                        language: builder.language,
2050                        info_string: builder.info_string,
2051                        fence_char: builder.fence_char,
2052                        fence_length: builder.fence_length,
2053                        content_prefix: builder.content_prefix,
2054                        code,
2055                        line_prefix_widths,
2056                    });
2057                }
2058            }
2059            _ => {}
2060        }
2061    }
2062
2063    blocks
2064}
2065
2066/// Formatted code waiting to replace the code of a block.
2067#[derive(Debug, Clone)]
2068pub struct WriteBack {
2069    /// The block's position among the blocks extracted from the document.
2070    pub index: usize,
2071    pub code: String,
2072}
2073
2074/// `content` with each write-back's code in place of its block's code, keeping
2075/// only the write-backs after which `extract` still reads the document as the
2076/// same blocks with exactly the new code; and the indices of the blocks whose
2077/// write-back was refused.
2078///
2079/// Whether new code can sit where the old code did depends on more than the
2080/// block's containers: code with a line that closes the fence ends the block
2081/// early, and everything after that line becomes document. A refused block
2082/// keeps its original code. `extract` must be the function `blocks` came from,
2083/// so an unchanged document reads back as exactly `blocks`, in document order.
2084///
2085/// Blocks are expected not to overlap. Write-backs to blocks that do are
2086/// refused, since replacing one would leave the other's range pointing at
2087/// different text.
2088pub fn write_back_verified(
2089    content: &str,
2090    blocks: &[FencedCodeBlockInfo],
2091    write_backs: &[WriteBack],
2092    extract: impl Fn(&str) -> Vec<FencedCodeBlockInfo>,
2093) -> (String, Vec<usize>) {
2094    if write_backs.is_empty() {
2095        return (content.to_string(), Vec::new());
2096    }
2097    let overlaps = |a: &WriteBack, b: &WriteBack| {
2098        let (a, b) = (&blocks[a.index], &blocks[b.index]);
2099        a.content_start < b.content_end && b.content_start < a.content_end
2100    };
2101    let (mut ordered, mut refused): (Vec<&WriteBack>, Vec<&WriteBack>) = write_backs.iter().partition(|write_back| {
2102        !write_backs
2103            .iter()
2104            .any(|other| other.index != write_back.index && overlaps(write_back, other))
2105    });
2106    // Last block first, so each replacement leaves the offsets of the blocks
2107    // before it valid.
2108    ordered.sort_by_key(|write_back| std::cmp::Reverse(write_back.index));
2109
2110    let replace = |document: &mut String, write_back: &WriteBack| {
2111        let block = &blocks[write_back.index];
2112        document.replace_range(
2113            block.content_start..block.content_end,
2114            &block.source_for(&write_back.code),
2115        );
2116    };
2117    // Extractors differ in whether code keeps CRLF line endings: the CommonMark
2118    // parser reads them as LF, the MkDocs extractor keeps them. Code a formatter
2119    // wrote with either line ending is the same code.
2120    let reads_back = |document: &str, codes: &[&str]| {
2121        extract(document)
2122            .iter()
2123            .map(|block| block.code.replace("\r\n", "\n"))
2124            .eq(codes.iter().map(|code| code.replace("\r\n", "\n")))
2125    };
2126    let original_codes: Vec<&str> = blocks.iter().map(|block| block.code.as_str()).collect();
2127
2128    let refused_indices = |refused: Vec<&WriteBack>| {
2129        let mut indices: Vec<usize> = refused.iter().map(|write_back| write_back.index).collect();
2130        indices.sort_unstable();
2131        indices
2132    };
2133
2134    let mut codes = original_codes.clone();
2135    let mut result = content.to_string();
2136    for write_back in &ordered {
2137        replace(&mut result, write_back);
2138        codes[write_back.index] = &write_back.code;
2139    }
2140    if reads_back(&result, &codes) {
2141        return (result, refused_indices(refused));
2142    }
2143
2144    // At least one write-back changes the document's structure: apply them one
2145    // at a time and keep each only if the document still reads back.
2146    let mut codes = original_codes;
2147    let mut result = content.to_string();
2148    for write_back in ordered {
2149        let mut candidate = result.clone();
2150        replace(&mut candidate, write_back);
2151        let original = std::mem::replace(&mut codes[write_back.index], &write_back.code);
2152        if reads_back(&candidate, &codes) {
2153            result = candidate;
2154        } else {
2155            codes[write_back.index] = original;
2156            refused.push(write_back);
2157        }
2158    }
2159    (result, refused_indices(refused))
2160}
2161
2162/// Builder for FencedCodeBlockInfo during parsing.
2163struct FencedCodeBlockBuilder {
2164    start_line: usize,
2165    content_start: usize,
2166    language: String,
2167    info_string: String,
2168    fence_char: char,
2169    fence_length: usize,
2170    content_prefix: String,
2171    /// The parser's text events for the code, each with its source range.
2172    pieces: Vec<(std::ops::Range<usize>, String)>,
2173}
2174
2175/// The fence line prefix with each footnote label on it (`[^note]:` and the
2176/// whitespace after it, at the given byte offsets) replaced by the four spaces a
2177/// footnote definition's continuation lines are indented by.
2178///
2179/// A label is as wide as its name, but a footnote definition always continues
2180/// at four columns, so its width on the fence line says nothing about the
2181/// lines after it.
2182fn continuation_of_footnote_labels(fence_line_prefix: &str, label_starts: impl Iterator<Item = usize>) -> String {
2183    let mut prefix = fence_line_prefix.to_string();
2184    let mut label_starts: Vec<usize> = label_starts.collect();
2185    label_starts.sort_unstable();
2186    // Later labels first, so earlier offsets stay valid.
2187    for start in label_starts.into_iter().rev() {
2188        let Some(label_end) = footnote_label_end(&prefix[start..]) else {
2189            continue;
2190        };
2191        let rest = &prefix[start + label_end..];
2192        let end = start + label_end + rest.len() - rest.trim_start_matches([' ', '\t']).len();
2193        prefix.replace_range(start..end, "    ");
2194    }
2195    prefix
2196}
2197
2198/// The length of the `[^label]:` that `text` starts with.
2199fn footnote_label_end(text: &str) -> Option<usize> {
2200    let mut escaped = false;
2201    for (i, c) in text.char_indices().skip(2) {
2202        match c {
2203            _ if escaped => escaped = false,
2204            '\\' => escaped = true,
2205            ']' => return text[i + 1..].starts_with(':').then_some(i + 2),
2206            _ => {}
2207        }
2208    }
2209    None
2210}
2211
2212/// The prefix a content line repeats for the containers around a fence, from
2213/// what precedes the opening fence on its own line.
2214///
2215/// Only blockquote markers, whitespace and list markers can precede a fence, so
2216/// keeping `>` and turning everything else into spaces gives the continuation of
2217/// every container. Tabs become the spaces they span on the fence line, so a
2218/// container that consumes part of a tab still gets exactly its columns.
2219///
2220/// A `>` written without its optional space (`>code`, `>- item`) gets one: on a
2221/// continuation line the parser takes the first space after `>` as that
2222/// optional space, so without it the line would lose a column to the quote.
2223fn content_prefix_of(fence_line_prefix: &str) -> String {
2224    let mut prefix = String::with_capacity(fence_line_prefix.len() + 1);
2225    let mut column = 0;
2226    let mut chars = fence_line_prefix.chars().peekable();
2227    while let Some(c) = chars.next() {
2228        match c {
2229            '>' => {
2230                prefix.push('>');
2231                if !matches!(chars.peek(), Some(' ' | '\t')) {
2232                    prefix.push(' ');
2233                }
2234                column += 1;
2235            }
2236            '\t' => {
2237                let width = 4 - column % 4;
2238                prefix.extend(std::iter::repeat_n(' ', width));
2239                column += width;
2240            }
2241            _ => {
2242                prefix.push(' ');
2243                column += 1;
2244            }
2245        }
2246    }
2247    prefix
2248}
2249
2250/// The code of a fenced block and the source prefix width of each of its lines,
2251/// from the parser's text events.
2252///
2253/// The parser has already removed what CommonMark does not count as code
2254/// (blockquote markers, list indentation, the fence's indentation). A line can
2255/// arrive in several events (a CRLF line ending splits one), and one event can
2256/// span several lines when a line carries no prefix at all (a blank line in a
2257/// list item). A line's prefix is whatever precedes its first event on its
2258/// source line. An event the parser rewrote (spaces standing in for a tab a
2259/// container split) is placed at the start of its source range.
2260fn block_code_from_pieces(
2261    content: &str,
2262    pieces: &[(std::ops::Range<usize>, String)],
2263    line_start_of: impl Fn(usize) -> usize,
2264) -> (String, Vec<usize>) {
2265    let mut code = String::new();
2266    let mut widths = Vec::new();
2267    // A carriage return before a line's first event is its line ending (the
2268    // parser reports a CRLF blank line as just its `\n`), never prefix.
2269    let prefix_width = |offset: usize| {
2270        let before = &content[line_start_of(offset)..offset];
2271        before.strip_suffix('\r').unwrap_or(before).chars().count()
2272    };
2273
2274    for (range, text) in pieces {
2275        if content.get(range.clone()) == Some(text.as_str()) {
2276            let mut offset = range.start;
2277            for segment in text.split_inclusive('\n') {
2278                if code.is_empty() || code.ends_with('\n') {
2279                    widths.push(prefix_width(offset));
2280                }
2281                code.push_str(segment);
2282                offset += segment.len();
2283            }
2284        } else {
2285            // The parser writes out as spaces the columns of a tab a container
2286            // only partly consumed. Those columns are code, so they are not prefix.
2287            if code.is_empty() || code.ends_with('\n') {
2288                widths.push(prefix_width(range.start).saturating_sub(text.chars().count()));
2289            }
2290            code.push_str(text);
2291        }
2292    }
2293    (code, widths)
2294}
2295
2296/// Strip a fence's indentation from each line of a code block's source, and
2297/// report how much was stripped from each line.
2298fn strip_indent_from_block(content: &str, indent_prefix: &str) -> (String, Vec<usize>) {
2299    let mut code = String::with_capacity(content.len());
2300    let mut widths = Vec::new();
2301    for line in content.split_inclusive('\n') {
2302        match line.strip_prefix(indent_prefix) {
2303            Some(stripped) => {
2304                code.push_str(stripped);
2305                widths.push(indent_prefix.chars().count());
2306            }
2307            None => {
2308                code.push_str(line);
2309                widths.push(0);
2310            }
2311        }
2312    }
2313    (code, widths)
2314}
2315
2316/// Put the containers' prefix back in front of each line of formatted code.
2317///
2318/// A blank line takes the prefix without its trailing whitespace: `>` inside a
2319/// blockquote, nothing at all inside a list item.
2320fn apply_content_prefix(content: &str, prefix: &str) -> String {
2321    if prefix.is_empty() {
2322        return content.to_string();
2323    }
2324
2325    let blank_prefix = prefix.trim_end();
2326    let mut out = String::with_capacity(content.len() + prefix.len());
2327    for line in content.split_inclusive('\n') {
2328        if line == "\n" {
2329            out.push_str(blank_prefix);
2330        } else {
2331            out.push_str(prefix);
2332        }
2333        out.push_str(line);
2334    }
2335    out
2336}
2337
2338#[cfg(test)]
2339mod tests {
2340    use super::*;
2341
2342    fn default_config() -> CodeBlockToolsConfig {
2343        CodeBlockToolsConfig::default()
2344    }
2345
2346    #[test]
2347    fn test_extract_code_blocks() {
2348        let config = default_config();
2349        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2350
2351        let content = r#"# Example
2352
2353```python
2354def hello():
2355    print("Hello")
2356```
2357
2358Some text
2359
2360```rust
2361fn main() {}
2362```
2363"#;
2364
2365        let blocks = processor.extract_code_blocks(content);
2366
2367        assert_eq!(blocks.len(), 2);
2368
2369        assert_eq!(blocks[0].language, "python");
2370        assert_eq!(blocks[0].fence_char, '`');
2371        assert_eq!(blocks[0].fence_length, 3);
2372        assert_eq!(blocks[0].start_line, 2);
2373        assert_eq!(blocks[0].content_prefix, "");
2374        assert_eq!(blocks[0].code, "def hello():\n    print(\"Hello\")\n");
2375
2376        assert_eq!(blocks[1].language, "rust");
2377        assert_eq!(blocks[1].fence_char, '`');
2378        assert_eq!(blocks[1].fence_length, 3);
2379    }
2380
2381    #[test]
2382    fn test_extract_code_blocks_with_info_string() {
2383        let config = default_config();
2384        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2385
2386        let content = "```python title=\"example.py\"\ncode\n```";
2387        let blocks = processor.extract_code_blocks(content);
2388
2389        assert_eq!(blocks.len(), 1);
2390        assert_eq!(blocks[0].language, "python");
2391        assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
2392    }
2393
2394    #[test]
2395    fn test_extract_code_blocks_tilde_fence() {
2396        let config = default_config();
2397        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2398
2399        let content = "~~~bash\necho hello\n~~~";
2400        let blocks = processor.extract_code_blocks(content);
2401
2402        assert_eq!(blocks.len(), 1);
2403        assert_eq!(blocks[0].language, "bash");
2404        assert_eq!(blocks[0].fence_char, '~');
2405        assert_eq!(blocks[0].fence_length, 3);
2406        assert_eq!(blocks[0].content_prefix, "");
2407        assert_eq!(blocks[0].code, "echo hello\n");
2408    }
2409
2410    #[test]
2411    fn test_extract_code_blocks_with_indent_prefix() {
2412        let config = default_config();
2413        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2414
2415        let content = "  - item\n    ```python\n    print('hi')\n    ```";
2416        let blocks = processor.extract_code_blocks(content);
2417
2418        assert_eq!(blocks.len(), 1);
2419        assert_eq!(blocks[0].content_prefix, "    ");
2420        assert_eq!(blocks[0].code, "print('hi')\n");
2421        assert_eq!(blocks[0].line_prefix_widths, vec![4]);
2422    }
2423
2424    /// Fence line numbers across content where byte offsets, character counts and line
2425    /// counts all diverge: multibyte text, CRLF endings, a block in a blockquote, and a
2426    /// final block with no trailing newline.
2427    #[test]
2428    fn test_extract_code_blocks_line_numbers() {
2429        let config = default_config();
2430        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2431
2432        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~~~";
2433        let blocks = processor.extract_code_blocks(content);
2434
2435        let lines: Vec<(usize, usize)> = blocks.iter().map(|b| (b.start_line, b.end_line)).collect();
2436        assert_eq!(lines, vec![(2, 4), (6, 8), (10, 12)]);
2437    }
2438
2439    /// An unterminated block ends on its own last line, not on the line that
2440    /// follows it, however it is ended.
2441    #[test]
2442    fn test_extract_unterminated_code_block_line_numbers() {
2443        let config = default_config();
2444        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2445
2446        let cases = [
2447            ("> ```sh\n> a\nb\n", (0, 1)),
2448            ("- ```sh\n  a\n\nb\n", (0, 2)),
2449            ("```sh\na\n", (0, 1)),
2450            ("```sh\na", (0, 1)),
2451            ("```sh\r\na\r\n", (0, 1)),
2452            ("```sh\n", (0, 0)),
2453        ];
2454        for (content, expected) in cases {
2455            let blocks = processor.extract_code_blocks(content);
2456            let lines: Vec<(usize, usize)> = blocks.iter().map(|b| (b.start_line, b.end_line)).collect();
2457            assert_eq!(lines, vec![expected], "{content:?}");
2458        }
2459    }
2460
2461    #[test]
2462    fn test_extract_code_blocks_no_language() {
2463        let config = default_config();
2464        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2465
2466        let content = "```\nplain code\n```";
2467        let blocks = processor.extract_code_blocks(content);
2468
2469        assert_eq!(blocks.len(), 1);
2470        assert_eq!(blocks[0].language, "");
2471    }
2472
2473    #[test]
2474    fn test_resolve_language_linguist() {
2475        let mut config = default_config();
2476        config.normalize_language = NormalizeLanguage::Linguist;
2477        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2478
2479        assert_eq!(processor.resolve_language("py"), "python");
2480        assert_eq!(processor.resolve_language("bash"), "shell");
2481        assert_eq!(processor.resolve_language("js"), "javascript");
2482    }
2483
2484    #[test]
2485    fn test_resolve_language_exact() {
2486        let mut config = default_config();
2487        config.normalize_language = NormalizeLanguage::Exact;
2488        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2489
2490        assert_eq!(processor.resolve_language("py"), "py");
2491        assert_eq!(processor.resolve_language("BASH"), "bash");
2492    }
2493
2494    #[test]
2495    fn test_resolve_language_user_alias_override() {
2496        let mut config = default_config();
2497        config.language_aliases.insert("py".to_string(), "python".to_string());
2498        config.normalize_language = NormalizeLanguage::Exact;
2499        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2500
2501        assert_eq!(processor.resolve_language("PY"), "python");
2502    }
2503
2504    #[test]
2505    fn test_indent_strip_and_reapply_roundtrip() {
2506        let raw = "    def hello():\n        print('hi')";
2507        let (stripped, widths) = strip_indent_from_block(raw, "    ");
2508        assert_eq!(stripped, "def hello():\n    print('hi')");
2509        assert_eq!(widths, vec![4, 4]);
2510
2511        assert_eq!(apply_content_prefix(&stripped, "    "), raw);
2512    }
2513
2514    /// The code a tool receives from a fence inside containers is the code
2515    /// CommonMark gives that block: no blockquote markers, no list indentation.
2516    /// Each expectation is cmark's code block text for the same document.
2517    #[test]
2518    fn test_extract_code_blocks_strips_container_prefixes() {
2519        let config = default_config();
2520        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2521
2522        // (document, code, content prefix, prefix width of each code line)
2523        let cases: &[(&str, &str, &str, &[usize])] = &[
2524            ("> ```m\n> a\n>   b\n> ```\n", "a\n  b\n", "> ", &[2, 2]),
2525            ("> ```m\n> a\n>\n> b\n> ```\n", "a\n\nb\n", "> ", &[2, 1, 2]),
2526            // The optional space after `>` is not code, so it is part of the prefix.
2527            (">```m\n>a\n>  b\n>```\n", "a\n b\n", "> ", &[1, 2]),
2528            ("> > ```m\n> > a\n> > ```\n", "a\n", "> > ", &[4]),
2529            ("  > ```m\n  > a\n  > ```\n", "a\n", "  > ", &[4]),
2530            ("- ```m\n  a\n    b\n  ```\n", "a\n  b\n", "  ", &[2, 2]),
2531            ("1. ```m\n   a\n\n   b\n   ```\n", "a\n\nb\n", "   ", &[3, 0, 3]),
2532            ("> - ```m\n>   a\n>\n>   b\n>   ```\n", "a\n\nb\n", ">   ", &[4, 1, 4]),
2533            ("- > ```m\n  > a\n  > b\n  > ```\n", "a\nb\n", "  > ", &[4, 4]),
2534            (">- ```m\n>   a\n>   ```\n", "a\n", ">   ", &[4]),
2535            // Blank lines are code too, at the end of a block or as all of it.
2536            ("> ```m\n> a\n>\n> ```\n", "a\n\n", "> ", &[2, 1]),
2537            ("> ```m\n>\n> ```\n", "\n", "> ", &[1]),
2538            // Unclosed: the block runs to the end of its container, last line included.
2539            ("- ```m\n  a\n  b", "a\nb", "  ", &[2, 2]),
2540            ("> ```m\n> a\n", "a\n", "> ", &[2]),
2541            // A tab the quote marker only partly consumes leaves code columns.
2542            ("> ```m\n>\ta\n> ```\n", "  a\n", "> ", &[0]),
2543            // The parser ends code lines with LF, and a carriage return is never
2544            // part of a prefix.
2545            ("> ```m\r\n> a\r\n>\r\n> b\r\n> ```\r\n", "a\n\nb\n", "> ", &[2, 1, 2]),
2546            ("```m\r\na\r\n\r\nb\r\n```\r\n", "a\n\nb\n", "", &[0, 0, 0]),
2547            // A footnote definition continues at four columns, however long the
2548            // label that opens it on the fence's line.
2549            ("[^a]: ```m\n    a\n    ```\n", "a\n", "    ", &[4]),
2550            ("[^long-label]: ```m\n    a\n    ```\n", "a\n", "    ", &[4]),
2551            ("[^a]:\t```m\n    a\n    ```\n", "a\n", "    ", &[4]),
2552            ("[^a]:  \t ```m\n    a\n    ```\n", "a\n", "    ", &[4]),
2553            ("[^a\\]b]: ```m\n    a\n    ```\n", "a\n", "    ", &[4]),
2554            ("[^a]: Note.\n\n    ```m\n    a\n    ```\n", "a\n", "    ", &[4]),
2555            ("> [^a]: ```m\n>     a\n>     ```\n", "a\n", ">     ", &[6]),
2556            ("- [^a]: ```m\n      a\n      ```\n", "a\n", "      ", &[6]),
2557            ("[^a]: > ```m\n    > a\n    > ```\n", "a\n", "    > ", &[6]),
2558            ("[^a]: - ```m\n      a\n      ```\n", "a\n", "      ", &[6]),
2559        ];
2560
2561        for &(document, code, prefix, widths) in cases {
2562            let blocks = processor.extract_code_blocks(document);
2563            assert_eq!(blocks.len(), 1, "{document:?}");
2564            let block = &blocks[0];
2565            assert_eq!(block.code, code, "code of {document:?}");
2566            assert_eq!(block.content_prefix, prefix, "prefix of {document:?}");
2567            assert_eq!(block.line_prefix_widths, widths, "widths of {document:?}");
2568            assert_eq!(
2569                (block.fence_char, block.fence_length),
2570                ('`', 3),
2571                "fence of {document:?}"
2572            );
2573        }
2574    }
2575
2576    /// The source region of a block's code covers its lines exactly, prefixes
2577    /// and line endings included, in LF and CRLF documents.
2578    #[test]
2579    fn test_extract_code_blocks_container_region() {
2580        let config = default_config();
2581        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2582
2583        let cases = [
2584            ("> ```m\n> a\n>\n> b\n> ```\n", "> a\n>\n> b\n"),
2585            ("- ```m\n  a\n  ```\n", "  a\n"),
2586            ("- ```m\n  a\n  b", "  a\n  b"),
2587            ("> ```m\r\n> a\r\n> b\r\n> ```\r\n", "> a\r\n> b\r\n"),
2588        ];
2589        for (document, region) in cases {
2590            let blocks = processor.extract_code_blocks(document);
2591            assert_eq!(blocks.len(), 1, "{document:?}");
2592            assert_eq!(
2593                &document[blocks[0].content_start..blocks[0].content_end],
2594                region,
2595                "{document:?}"
2596            );
2597        }
2598    }
2599
2600    #[test]
2601    fn test_apply_content_prefix_blank_lines() {
2602        assert_eq!(apply_content_prefix("a\n\nb", "> "), "> a\n>\n> b");
2603        assert_eq!(apply_content_prefix("a\n\nb", "  "), "  a\n\n  b");
2604        assert_eq!(apply_content_prefix("a\n\nb", ">   "), ">   a\n>\n>   b");
2605        assert_eq!(apply_content_prefix("a\n\nb", ""), "a\n\nb");
2606    }
2607
2608    /// A formatter that indents, adds a blank line and changes text, so every
2609    /// part of the write-back is exercised.
2610    fn container_formatter_config() -> CodeBlockToolsConfig {
2611        use super::super::config::{LanguageToolConfig, ToolDefinition};
2612
2613        let mut config = default_config();
2614        config.normalize_language = NormalizeLanguage::Exact;
2615        config.languages.insert(
2616            "m".to_string(),
2617            LanguageToolConfig {
2618                format: vec!["reshape".to_string()],
2619                ..Default::default()
2620            },
2621        );
2622        config.tools.insert(
2623            "reshape".to_string(),
2624            ToolDefinition {
2625                command: vec![
2626                    "awk".to_string(),
2627                    "NR == 1 { print toupper($0); print \"\"; next } { print \"  \" toupper($0) }".to_string(),
2628                ],
2629                ..Default::default()
2630            },
2631        );
2632        config
2633    }
2634
2635    /// Formatted code that would end its block early is not written back: the
2636    /// block stays as it was and the refusal is reported, while every other
2637    /// block is still formatted.
2638    #[cfg(unix)]
2639    #[test]
2640    fn test_format_refuses_write_back_that_changes_structure() {
2641        use super::super::config::{LanguageToolConfig, ToolDefinition};
2642
2643        let mut config = container_formatter_config();
2644        config.languages.insert(
2645            "f".to_string(),
2646            LanguageToolConfig {
2647                format: vec!["fence".to_string()],
2648                ..Default::default()
2649            },
2650        );
2651        config.tools.insert(
2652            "fence".to_string(),
2653            ToolDefinition {
2654                command: vec!["awk".to_string(), "{ print \"```\"; print toupper($0) }".to_string()],
2655                ..Default::default()
2656            },
2657        );
2658        for (language, program) in [
2659            ("c", "{ sub(/\\r$/, \"\"); printf \"%s\\r\\n\", toupper($0) }"),
2660            ("cf", "{ sub(/\\r$/, \"\"); printf \"```\\r\\n%s\\r\\n\", toupper($0) }"),
2661        ] {
2662            config.languages.insert(
2663                language.to_string(),
2664                LanguageToolConfig {
2665                    format: vec![language.to_string()],
2666                    ..Default::default()
2667                },
2668            );
2669            config.tools.insert(
2670                language.to_string(),
2671                ToolDefinition {
2672                    command: vec!["awk".to_string(), program.to_string()],
2673                    ..Default::default()
2674                },
2675            );
2676        }
2677        // (document, document after formatting, lines of the refused blocks)
2678        let standard_cases: &[(&str, &str, &[usize])] = &[
2679            ("```f\na\n```\n", "```f\na\n```\n", &[1]),
2680            ("> ```f\n> a\n> ```\n", "> ```f\n> a\n> ```\n", &[1]),
2681            (
2682                "```m\na\n```\n\n```f\na\n```\n\n- ```m\n  b\n  ```\n",
2683                "```m\nA\n\n```\n\n```f\na\n```\n\n- ```m\n  B\n\n  ```\n",
2684                &[5],
2685            ),
2686            // A longer fence holds the same output.
2687            ("````f\na\n````\n", "````f\n```\nA\n````\n", &[]),
2688            // Code written with CRLF line endings reads back as the same code.
2689            ("```c\na\n```\n", "```c\nA\r\n```\n", &[]),
2690            ("```c\r\na\r\n```\r\n", "```c\r\nA\r\n```\r\n", &[]),
2691            ("> ```c\r\n> a\r\n> ```\r\n", "> ```c\r\n> A\r\n> ```\r\n", &[]),
2692            ("```cf\r\na\r\n```\r\n", "```cf\r\na\r\n```\r\n", &[1]),
2693        ];
2694        // The MkDocs extractor keeps CRLF line endings in the code it reads.
2695        let mkdocs_cases: &[(&str, &str, &[usize])] = &[
2696            (
2697                "!!! note\n\n    ```c\n    a\n    ```\n",
2698                "!!! note\n\n    ```c\n    A\r\n    ```\n",
2699                &[],
2700            ),
2701            (
2702                "!!! note\r\n\r\n    ```c\r\n    a\r\n    ```\r\n",
2703                "!!! note\r\n\r\n    ```c\r\n    A\r\n    ```\r\n",
2704                &[],
2705            ),
2706            (
2707                "!!! note\r\n\r\n    ```cf\r\n    a\r\n    ```\r\n",
2708                "!!! note\r\n\r\n    ```cf\r\n    a\r\n    ```\r\n",
2709                &[3],
2710            ),
2711        ];
2712
2713        for (flavor, cases) in [
2714            (MarkdownFlavor::Standard, standard_cases),
2715            (MarkdownFlavor::MkDocs, mkdocs_cases),
2716        ] {
2717            let processor = CodeBlockToolProcessor::new(&config, flavor);
2718            for &(document, expected, refused) in cases {
2719                let output = processor.format(document).expect("format should succeed");
2720                assert_eq!(output.content, expected, "{document:?}");
2721                let lines: Vec<usize> = output.failures.iter().map(|failure| failure.file_line).collect();
2722                assert_eq!(lines, refused, "{document:?}");
2723                assert_eq!(output.error_messages.len(), refused.len(), "{document:?}");
2724                assert_eq!(output.had_errors, !refused.is_empty(), "{document:?}");
2725                assert!(
2726                    output
2727                        .failures
2728                        .iter()
2729                        .all(|failure| failure.message.contains("block left unchanged")),
2730                    "{:?}",
2731                    output.failures
2732                );
2733            }
2734        }
2735    }
2736
2737    /// With MkDocs, an admonition example inside a `markdown` block is part of
2738    /// that block's code, so only the `markdown` block is formatted.
2739    #[cfg(unix)]
2740    #[test]
2741    fn test_format_mkdocs_admonition_example_inside_a_block() {
2742        use super::super::config::{LanguageToolConfig, ToolDefinition};
2743
2744        let mut config = default_config();
2745        config.normalize_language = NormalizeLanguage::Exact;
2746        for language in ["markdown", "json"] {
2747            config.languages.insert(
2748                language.to_string(),
2749                LanguageToolConfig {
2750                    format: vec!["replace".to_string()],
2751                    ..Default::default()
2752                },
2753            );
2754        }
2755        config.tools.insert(
2756            "replace".to_string(),
2757            ToolDefinition {
2758                command: vec!["awk".to_string(), "END { print \"x\" }".to_string()],
2759                ..Default::default()
2760            },
2761        );
2762        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2763
2764        let long = "a".repeat(200);
2765        let document =
2766            format!("# T\n\n```markdown\n!!! note\n\n    ```json\n    {{\"k\": \"{long}\"}}\n    ```\n```\n");
2767        let output = processor.format(&document).expect("format should succeed");
2768        assert_eq!(output.content, "# T\n\n```markdown\nx\n```\n");
2769        assert!(output.failures.is_empty(), "{:?}", output.failures);
2770    }
2771
2772    /// Replacing one of two overlapping blocks would leave the other's range
2773    /// pointing at different text, so neither is written back.
2774    #[test]
2775    fn test_write_back_refuses_overlapping_blocks() {
2776        // The last block, and a block inside it starting two bytes in.
2777        let with_inner = |mut blocks: Vec<FencedCodeBlockInfo>| {
2778            let mut inner = blocks[1].clone();
2779            inner.content_start += 2;
2780            inner.code = inner.code[2..].to_string();
2781            blocks.push(inner);
2782            blocks
2783        };
2784        let content = format!("```m\nz\n```\n\n```m\n{}\n```\n", "a".repeat(100));
2785        let blocks = with_inner(extract_fenced_code_blocks(&content));
2786        let write_backs = [
2787            WriteBack {
2788                index: 0,
2789                code: "Z\n".to_string(),
2790            },
2791            WriteBack {
2792                index: 1,
2793                code: "A\n".to_string(),
2794            },
2795            WriteBack {
2796                index: 2,
2797                code: "B\n".to_string(),
2798            },
2799        ];
2800
2801        let (result, refused) = write_back_verified(&content, &blocks, &write_backs, |document| {
2802            with_inner(extract_fenced_code_blocks(document))
2803        });
2804        assert_eq!(result, content.replacen("```m\nz\n", "```m\nZ\n", 1));
2805        assert_eq!(refused, [1, 2]);
2806    }
2807
2808    /// Formatted code goes back inside the same containers, so the document
2809    /// still parses as the same block with the formatted code in it.
2810    #[cfg(unix)]
2811    #[test]
2812    fn test_format_writes_back_inside_containers() {
2813        let config = container_formatter_config();
2814        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2815
2816        // (document, document after formatting, the formatted block's code)
2817        let cases = [
2818            (
2819                "- ```m\n  a\n  b\n  ```\n- next\n",
2820                "- ```m\n  A\n\n    B\n  ```\n- next\n",
2821                "A\n\n  B\n",
2822            ),
2823            (
2824                "1. ```m\n   a\n   b\n   ```\n",
2825                "1. ```m\n   A\n\n     B\n   ```\n",
2826                "A\n\n  B\n",
2827            ),
2828            (
2829                "> ```m\n> a\n> b\n> ```\n",
2830                "> ```m\n> A\n>\n>   B\n> ```\n",
2831                "A\n\n  B\n",
2832            ),
2833            (
2834                "> > ```m\n> > a\n> > b\n> > ```\n",
2835                "> > ```m\n> > A\n> >\n> >   B\n> > ```\n",
2836                "A\n\n  B\n",
2837            ),
2838            (
2839                "> - ```m\n>   a\n>   b\n>   ```\n",
2840                "> - ```m\n>   A\n>\n>     B\n>   ```\n",
2841                "A\n\n  B\n",
2842            ),
2843            (
2844                "- > ```m\n  > a\n  > b\n  > ```\n",
2845                "- > ```m\n  > A\n  >\n  >   B\n  > ```\n",
2846                "A\n\n  B\n",
2847            ),
2848            // The added blank line after a one-line block stays inside the quote.
2849            ("> ```m\n> a\n> ```\n", "> ```m\n> A\n>\n> ```\n", "A\n\n"),
2850            // Code indented past `>` without a space keeps every column.
2851            (
2852                ">```m\n>a\n>  b\n>```\n",
2853                ">```m\n> A\n>\n>    B\n>```\n",
2854                "A\n\n   B\n",
2855            ),
2856            // The same for a list item that starts right after `>`.
2857            (
2858                ">2) ```m\n>    a\n>    b\n>    ```\n",
2859                ">2) ```m\n>    A\n>\n>      B\n>    ```\n",
2860                "A\n\n  B\n",
2861            ),
2862            // A tab after the marker reaches column 4, so the item's content
2863            // does too; one space per character would leave the code outside it.
2864            (
2865                "1.\t```m\n    a\n    b\n    ```\n",
2866                "1.\t```m\n    A\n\n      B\n    ```\n",
2867                "A\n\n  B\n",
2868            ),
2869            // A footnote definition continues at four columns, not at the
2870            // width of the label on the fence's line.
2871            (
2872                "[^long-label]: ```m\n    a\n    b\n    ```\n",
2873                "[^long-label]: ```m\n    A\n\n      B\n    ```\n",
2874                "A\n\n  B\n",
2875            ),
2876            (
2877                "> [^a]: ```m\n>     a\n>     b\n>     ```\n",
2878                "> [^a]: ```m\n>     A\n>\n>       B\n>     ```\n",
2879                "A\n\n  B\n",
2880            ),
2881            (
2882                "- [^a]: ```m\n      a\n      b\n      ```\n",
2883                "- [^a]: ```m\n      A\n\n        B\n      ```\n",
2884                "A\n\n  B\n",
2885            ),
2886            ("```m\na\nb\n```\n", "```m\nA\n\n  B\n```\n", "A\n\n  B\n"),
2887        ];
2888
2889        for (document, expected, code) in cases {
2890            let output = processor.format(document).expect("format should succeed");
2891            assert!(
2892                output.error_messages.is_empty(),
2893                "{document:?}: {:?}",
2894                output.error_messages
2895            );
2896            assert_eq!(output.content, expected, "{document:?}");
2897
2898            let blocks = processor.extract_code_blocks(&output.content);
2899            assert_eq!(blocks.len(), 1, "{expected:?}");
2900            assert_eq!(blocks[0].code, code, "{expected:?}");
2901        }
2902    }
2903
2904    /// A tool's column counts from the start of the code it was given; the
2905    /// document column adds the line's container prefix.
2906    #[cfg(unix)]
2907    #[test]
2908    fn test_lint_columns_include_container_prefix() {
2909        use super::super::config::{LanguageToolConfig, ToolDefinition};
2910
2911        let mut config = default_config();
2912        config.normalize_language = NormalizeLanguage::Exact;
2913        config.languages.insert(
2914            "m".to_string(),
2915            LanguageToolConfig {
2916                lint: vec!["pos".to_string()],
2917                ..Default::default()
2918            },
2919        );
2920        config.tools.insert(
2921            "pos".to_string(),
2922            ToolDefinition {
2923                command: vec![
2924                    "sh".to_string(),
2925                    "-c".to_string(),
2926                    "cat >/dev/null; echo '-:1:1: one'; echo '-:2:3: two'".to_string(),
2927                ],
2928                ..Default::default()
2929            },
2930        );
2931        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2932
2933        let cases = [
2934            ("```m\nx\nyyy\n```\n", [(2, 1), (3, 3)]),
2935            ("- ```m\n  x\n  yyy\n  ```\n", [(2, 3), (3, 5)]),
2936            ("> ```m\n> x\n>\n> ```\n", [(2, 3), (3, 4)]),
2937            ("text\n\n> - ```m\n>   x\n>   yyy\n>   ```\n", [(4, 5), (5, 7)]),
2938        ];
2939        for (document, expected) in cases {
2940            let diagnostics = processor.lint(document).expect("lint should succeed");
2941            let positions: Vec<(usize, usize)> = diagnostics
2942                .iter()
2943                .map(|d| (d.file_line, d.column.expect("positioned diagnostic")))
2944                .collect();
2945            assert_eq!(positions, expected, "{document:?}");
2946        }
2947    }
2948
2949    #[test]
2950    fn test_infer_severity() {
2951        let config = default_config();
2952        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2953
2954        assert_eq!(
2955            processor.infer_severity("E501 line too long"),
2956            DiagnosticSeverity::Error
2957        );
2958        assert_eq!(
2959            processor.infer_severity("W291 trailing whitespace"),
2960            DiagnosticSeverity::Warning
2961        );
2962        assert_eq!(
2963            processor.infer_severity("error: something failed"),
2964            DiagnosticSeverity::Error
2965        );
2966        assert_eq!(
2967            processor.infer_severity("warning: unused variable"),
2968            DiagnosticSeverity::Warning
2969        );
2970        assert_eq!(
2971            processor.infer_severity("note: consider using"),
2972            DiagnosticSeverity::Info
2973        );
2974    }
2975
2976    #[test]
2977    fn test_parse_standard_format_windows_path() {
2978        let config = default_config();
2979        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2980
2981        let output = ToolOutput {
2982            stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
2983            stderr: String::new(),
2984            exit_code: 1,
2985            success: false,
2986        };
2987
2988        let diags = processor.parse_tool_output(&output, "ruff:check", 10);
2989        assert_eq!(diags.len(), 1);
2990        assert_eq!(diags[0].file_line, 12);
2991        assert_eq!(diags[0].column, Some(5));
2992        assert_eq!(diags[0].message, "E123 message");
2993    }
2994
2995    #[test]
2996    fn test_parse_eslint_severity() {
2997        let config = default_config();
2998        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2999
3000        let output = ToolOutput {
3001            stdout: "1:2 error Unexpected token".to_string(),
3002            stderr: String::new(),
3003            exit_code: 1,
3004            success: false,
3005        };
3006
3007        let diags = processor.parse_tool_output(&output, "eslint", 5);
3008        assert_eq!(diags.len(), 1);
3009        assert_eq!(diags[0].file_line, 6);
3010        assert_eq!(diags[0].column, Some(2));
3011        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
3012        assert_eq!(diags[0].message, "Unexpected token");
3013    }
3014
3015    #[test]
3016    fn test_parse_shellcheck_multiline() {
3017        let config = default_config();
3018        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3019
3020        let output = ToolOutput {
3021            stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
3022            stderr: String::new(),
3023            exit_code: 1,
3024            success: false,
3025        };
3026
3027        let diags = processor.parse_tool_output(&output, "shellcheck", 10);
3028        assert_eq!(diags.len(), 1);
3029        assert_eq!(diags[0].file_line, 13);
3030        assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
3031        assert_eq!(diags[0].message, "Double quote to prevent globbing");
3032    }
3033
3034    #[test]
3035    fn test_lint_no_config() {
3036        let config = default_config();
3037        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3038
3039        let content = "```python\nprint('hello')\n```";
3040        let result = processor.lint(content);
3041
3042        // Should succeed with no diagnostics (no tools configured)
3043        assert!(result.is_ok());
3044        assert!(result.unwrap().is_empty());
3045    }
3046
3047    #[test]
3048    fn test_format_no_config() {
3049        let config = default_config();
3050        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3051
3052        let content = "```python\nprint('hello')\n```";
3053        let result = processor.format(content);
3054
3055        // Should succeed with unchanged content (no tools configured)
3056        assert!(result.is_ok());
3057        let output = result.unwrap();
3058        assert_eq!(output.content, content);
3059        assert!(!output.had_errors);
3060        assert!(output.error_messages.is_empty());
3061    }
3062
3063    #[test]
3064    fn test_lint_on_missing_language_definition_fail() {
3065        let mut config = default_config();
3066        config.on_missing_language_definition = OnMissing::Fail;
3067        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3068
3069        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
3070        let result = processor.lint(content);
3071
3072        // Should succeed but return diagnostics for both missing language definitions
3073        assert!(result.is_ok());
3074        let diagnostics = result.unwrap();
3075        assert_eq!(diagnostics.len(), 2);
3076        assert!(diagnostics[0].message.contains("No lint tools configured"));
3077        assert!(diagnostics[0].message.contains("python"));
3078        assert!(diagnostics[1].message.contains("javascript"));
3079    }
3080
3081    #[test]
3082    fn test_lint_on_missing_language_definition_fail_fast() {
3083        let mut config = default_config();
3084        config.on_missing_language_definition = OnMissing::FailFast;
3085        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3086
3087        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
3088        let result = processor.lint(content);
3089
3090        // Should fail immediately on first missing language
3091        assert!(result.is_err());
3092        let err = result.unwrap_err();
3093        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
3094    }
3095
3096    #[test]
3097    fn test_format_on_missing_language_definition_fail() {
3098        let mut config = default_config();
3099        config.on_missing_language_definition = OnMissing::Fail;
3100        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3101
3102        let content = "```python\nprint('hello')\n```";
3103        let result = processor.format(content);
3104
3105        // Should succeed but report errors
3106        assert!(result.is_ok());
3107        let output = result.unwrap();
3108        assert_eq!(output.content, content); // Content unchanged
3109        assert!(output.had_errors);
3110        assert!(!output.error_messages.is_empty());
3111        assert!(output.error_messages[0].contains("No format tools configured"));
3112    }
3113
3114    #[test]
3115    fn test_format_on_missing_language_definition_fail_fast() {
3116        let mut config = default_config();
3117        config.on_missing_language_definition = OnMissing::FailFast;
3118        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3119
3120        let content = "```python\nprint('hello')\n```";
3121        let result = processor.format(content);
3122
3123        // Should fail immediately
3124        assert!(result.is_err());
3125        let err = result.unwrap_err();
3126        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
3127    }
3128
3129    #[test]
3130    fn test_lint_on_missing_tool_binary_fail() {
3131        use super::super::config::{LanguageToolConfig, ToolDefinition};
3132
3133        let mut config = default_config();
3134        config.on_missing_tool_binary = OnMissing::Fail;
3135
3136        // Configure a tool with a non-existent binary
3137        let lang_config = LanguageToolConfig {
3138            lint: vec!["nonexistent-linter".to_string()],
3139            ..Default::default()
3140        };
3141        config.languages.insert("python".to_string(), lang_config);
3142
3143        let tool_def = ToolDefinition {
3144            command: vec!["nonexistent-binary-xyz123".to_string()],
3145            ..Default::default()
3146        };
3147        config.tools.insert("nonexistent-linter".to_string(), tool_def);
3148
3149        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3150
3151        let content = "```python\nprint('hello')\n```";
3152        let result = processor.lint(content);
3153
3154        // Should succeed but return diagnostic for missing binary
3155        assert!(result.is_ok());
3156        let diagnostics = result.unwrap();
3157        assert_eq!(diagnostics.len(), 1);
3158        assert!(diagnostics[0].message.contains("not found in allowed lookup locations"));
3159    }
3160
3161    #[test]
3162    fn test_lint_on_missing_tool_binary_fail_fast() {
3163        use super::super::config::{LanguageToolConfig, ToolDefinition};
3164
3165        let mut config = default_config();
3166        config.on_missing_tool_binary = OnMissing::FailFast;
3167
3168        // Configure a tool with a non-existent binary
3169        let lang_config = LanguageToolConfig {
3170            lint: vec!["nonexistent-linter".to_string()],
3171            ..Default::default()
3172        };
3173        config.languages.insert("python".to_string(), lang_config);
3174
3175        let tool_def = ToolDefinition {
3176            command: vec!["nonexistent-binary-xyz123".to_string()],
3177            ..Default::default()
3178        };
3179        config.tools.insert("nonexistent-linter".to_string(), tool_def);
3180
3181        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3182
3183        let content = "```python\nprint('hello')\n```";
3184        let result = processor.lint(content);
3185
3186        // Should fail immediately
3187        assert!(result.is_err());
3188        let err = result.unwrap_err();
3189        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
3190    }
3191
3192    #[test]
3193    fn test_format_on_missing_tool_binary_fail() {
3194        use super::super::config::{LanguageToolConfig, ToolDefinition};
3195
3196        let mut config = default_config();
3197        config.on_missing_tool_binary = OnMissing::Fail;
3198
3199        // Configure a tool with a non-existent binary
3200        let lang_config = LanguageToolConfig {
3201            format: vec!["nonexistent-formatter".to_string()],
3202            ..Default::default()
3203        };
3204        config.languages.insert("python".to_string(), lang_config);
3205
3206        let tool_def = ToolDefinition {
3207            command: vec!["nonexistent-binary-xyz123".to_string()],
3208            ..Default::default()
3209        };
3210        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
3211
3212        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3213
3214        let content = "```python\nprint('hello')\n```";
3215        let result = processor.format(content);
3216
3217        // Should succeed but report errors
3218        assert!(result.is_ok());
3219        let output = result.unwrap();
3220        assert_eq!(output.content, content); // Content unchanged
3221        assert!(output.had_errors);
3222        assert!(!output.error_messages.is_empty());
3223        assert!(output.error_messages[0].contains("not found in allowed lookup locations"));
3224    }
3225
3226    #[test]
3227    fn test_format_on_missing_tool_binary_fail_fast() {
3228        use super::super::config::{LanguageToolConfig, ToolDefinition};
3229
3230        let mut config = default_config();
3231        config.on_missing_tool_binary = OnMissing::FailFast;
3232
3233        // Configure a tool with a non-existent binary
3234        let lang_config = LanguageToolConfig {
3235            format: vec!["nonexistent-formatter".to_string()],
3236            ..Default::default()
3237        };
3238        config.languages.insert("python".to_string(), lang_config);
3239
3240        let tool_def = ToolDefinition {
3241            command: vec!["nonexistent-binary-xyz123".to_string()],
3242            ..Default::default()
3243        };
3244        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
3245
3246        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3247
3248        let content = "```python\nprint('hello')\n```";
3249        let result = processor.format(content);
3250
3251        // Should fail immediately
3252        assert!(result.is_err());
3253        let err = result.unwrap_err();
3254        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
3255    }
3256
3257    #[test]
3258    fn test_lint_rumdl_builtin_skipped_for_markdown() {
3259        // Configure the built-in "rumdl" tool for markdown
3260        // The processor should skip it (handled by embedded markdown linting)
3261        let mut config = default_config();
3262        config.languages.insert(
3263            "markdown".to_string(),
3264            LanguageToolConfig {
3265                lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
3266                ..Default::default()
3267            },
3268        );
3269        config.on_missing_language_definition = OnMissing::Fail;
3270        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3271
3272        let content = "```markdown\n# Hello\n```";
3273        let result = processor.lint(content);
3274
3275        // Should succeed with no diagnostics - "rumdl" tool is skipped, not treated as unknown
3276        assert!(result.is_ok());
3277        assert!(result.unwrap().is_empty());
3278    }
3279
3280    #[test]
3281    fn test_format_rumdl_builtin_skipped_for_markdown() {
3282        // Configure the built-in "rumdl" tool for markdown
3283        let mut config = default_config();
3284        config.languages.insert(
3285            "markdown".to_string(),
3286            LanguageToolConfig {
3287                format: vec![RUMDL_BUILTIN_TOOL.to_string()],
3288                ..Default::default()
3289            },
3290        );
3291        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3292
3293        let content = "```markdown\n# Hello\n```";
3294        let result = processor.format(content);
3295
3296        // Should succeed with unchanged content - "rumdl" tool is skipped
3297        assert!(result.is_ok());
3298        let output = result.unwrap();
3299        assert_eq!(output.content, content);
3300        assert!(!output.had_errors);
3301    }
3302
3303    #[test]
3304    fn test_is_markdown_language() {
3305        // Test the helper function
3306        assert!(is_markdown_language("markdown"));
3307        assert!(is_markdown_language("Markdown"));
3308        assert!(is_markdown_language("MARKDOWN"));
3309        assert!(is_markdown_language("md"));
3310        assert!(is_markdown_language("MD"));
3311        assert!(!is_markdown_language("python"));
3312        assert!(!is_markdown_language("rust"));
3313        assert!(!is_markdown_language(""));
3314    }
3315
3316    // Issue #423: MkDocs admonition code block detection
3317
3318    #[test]
3319    fn test_extract_mkdocs_admonition_code_block() {
3320        let config = default_config();
3321        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3322
3323        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
3324        let blocks = processor.extract_code_blocks(content);
3325
3326        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
3327        assert_eq!(blocks[0].language, "python");
3328    }
3329
3330    #[test]
3331    fn test_extract_mkdocs_tab_code_block() {
3332        let config = default_config();
3333        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3334
3335        let content = "=== \"Python\"\n\n    ```python\n    print(\"hello\")\n    ```\n";
3336        let blocks = processor.extract_code_blocks(content);
3337
3338        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
3339        assert_eq!(blocks[0].language, "python");
3340    }
3341
3342    #[test]
3343    fn test_standard_flavor_ignores_admonition_indented_content() {
3344        let config = default_config();
3345        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3346
3347        // With standard flavor, pulldown_cmark parses this differently;
3348        // our MkDocs extraction should NOT run
3349        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
3350        let blocks = processor.extract_code_blocks(content);
3351
3352        // Standard flavor relies on pulldown_cmark only, which may or may not detect
3353        // indented fenced blocks. The key assertion is that we don't double-detect.
3354        // With standard flavor, the MkDocs extraction path is skipped entirely.
3355        for (i, b) in blocks.iter().enumerate() {
3356            for (j, b2) in blocks.iter().enumerate() {
3357                if i != j {
3358                    assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
3359                }
3360            }
3361        }
3362    }
3363
3364    #[test]
3365    fn test_mkdocs_top_level_blocks_alongside_admonition() {
3366        let config = default_config();
3367        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3368
3369        let content =
3370            "```rust\nfn main() {}\n```\n\n!!! note\n    Some text\n\n    ```python\n    print(\"hello\")\n    ```\n";
3371        let blocks = processor.extract_code_blocks(content);
3372
3373        assert_eq!(
3374            blocks.len(),
3375            2,
3376            "Should detect both top-level and admonition code blocks"
3377        );
3378        assert_eq!(blocks[0].language, "rust");
3379        assert_eq!(blocks[1].language, "python");
3380    }
3381
3382    #[test]
3383    fn test_mkdocs_nested_admonition_code_block() {
3384        let config = default_config();
3385        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3386
3387        let content = "\
3388!!! note
3389    Some text
3390
3391    !!! warning
3392        Nested content
3393
3394        ```python
3395        x = 1
3396        ```
3397";
3398        let blocks = processor.extract_code_blocks(content);
3399        assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
3400        assert_eq!(blocks[0].language, "python");
3401    }
3402
3403    #[test]
3404    fn test_mkdocs_consecutive_admonitions_no_stale_context() {
3405        let config = default_config();
3406        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3407
3408        // Two consecutive admonitions at the same indent level.
3409        // The first has no code block, the second does.
3410        let content = "\
3411!!! note
3412    First admonition content
3413
3414!!! warning
3415    Second admonition content
3416
3417    ```python
3418    y = 2
3419    ```
3420";
3421        let blocks = processor.extract_code_blocks(content);
3422        assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
3423        assert_eq!(blocks[0].language, "python");
3424    }
3425
3426    /// An admonition inside a fenced block is code, not an admonition, and a
3427    /// fenced block still ends the admonition it is not indented into.
3428    #[test]
3429    fn test_mkdocs_fenced_block_lines_are_code() {
3430        let config = default_config();
3431        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3432
3433        // (language, first line, last line) of an extracted block
3434        type Span<'a> = (&'a str, usize, usize);
3435        let cases: &[(&str, &[Span<'_>])] = &[
3436            (
3437                "```markdown\n!!! note\n\n    ```json\n    {}\n    ```\n```\n",
3438                &[("markdown", 0, 6)],
3439            ),
3440            (
3441                "> ```md\n> === \"Tab\"\n>\n>     ```py\n>     x\n>     ```\n> ```\n",
3442                &[("md", 0, 6)],
3443            ),
3444            (
3445                "!!! note\n\n    text\n\n```text\nx\n```\n\n    ```json\n    {}\n    ```\n",
3446                &[("text", 4, 6)],
3447            ),
3448            (
3449                "```markdown\n!!! note\n```\n\n!!! tip\n\n    ```json\n    {}\n    ```\n",
3450                &[("markdown", 0, 2), ("json", 6, 8)],
3451            ),
3452            (
3453                "> ```up\n> a\n!!! note\n\n    ```up\n    b\n    ```\n",
3454                &[("up", 0, 1), ("up", 4, 6)],
3455            ),
3456        ];
3457        for &(content, expected) in cases {
3458            let extracted = processor.extract_code_blocks(content);
3459            let blocks: Vec<Span<'_>> = extracted
3460                .iter()
3461                .map(|block| (block.language.as_str(), block.start_line, block.end_line))
3462                .collect();
3463            assert_eq!(blocks, expected, "{content:?}");
3464        }
3465    }
3466
3467    #[test]
3468    fn test_mkdocs_crlf_line_endings() {
3469        let config = default_config();
3470        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3471
3472        // Use \r\n line endings
3473        let content = "!!! note\r\n    Some text\r\n\r\n    ```python\r\n    x = 1\r\n    ```\r\n";
3474        let blocks = processor.extract_code_blocks(content);
3475
3476        assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
3477        assert_eq!(blocks[0].language, "python");
3478
3479        // Verify byte offsets point to valid content
3480        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
3481        assert!(
3482            extracted.contains("x = 1"),
3483            "Extracted content should contain code. Got: {extracted:?}"
3484        );
3485    }
3486
3487    #[test]
3488    fn test_mkdocs_unclosed_fence_in_admonition() {
3489        let config = default_config();
3490        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3491
3492        // Unclosed fence should not produce a block
3493        let content = "!!! note\n    ```python\n    x = 1\n    no closing fence\n";
3494        let blocks = processor.extract_code_blocks(content);
3495        assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
3496    }
3497
3498    #[test]
3499    fn test_mkdocs_tilde_fence_in_admonition() {
3500        let config = default_config();
3501        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3502
3503        let content = "!!! note\n    ~~~ruby\n    puts 'hi'\n    ~~~\n";
3504        let blocks = processor.extract_code_blocks(content);
3505        assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
3506        assert_eq!(blocks[0].language, "ruby");
3507    }
3508
3509    #[test]
3510    fn test_mkdocs_empty_lines_in_code_block() {
3511        let config = default_config();
3512        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3513
3514        // Code block with empty lines inside — verifies byte offsets are correct
3515        // across empty lines (the previous find("") approach would break here)
3516        let content = "!!! note\n    ```python\n    x = 1\n\n    y = 2\n    ```\n";
3517        let blocks = processor.extract_code_blocks(content);
3518        assert_eq!(blocks.len(), 1);
3519
3520        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
3521        assert!(
3522            extracted.contains("x = 1") && extracted.contains("y = 2"),
3523            "Extracted content should span across the empty line. Got: {extracted:?}"
3524        );
3525    }
3526
3527    #[test]
3528    fn test_mkdocs_content_byte_offsets_lf() {
3529        let config = default_config();
3530        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3531
3532        let content = "!!! note\n    ```python\n    print('hi')\n    ```\n";
3533        let blocks = processor.extract_code_blocks(content);
3534        assert_eq!(blocks.len(), 1);
3535
3536        // Verify the extracted content is exactly the code body
3537        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
3538        assert_eq!(extracted, "    print('hi')\n", "Content offsets should be exact for LF");
3539    }
3540
3541    #[test]
3542    fn test_mkdocs_content_byte_offsets_crlf() {
3543        let config = default_config();
3544        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3545
3546        let content = "!!! note\r\n    ```python\r\n    print('hi')\r\n    ```\r\n";
3547        let blocks = processor.extract_code_blocks(content);
3548        assert_eq!(blocks.len(), 1);
3549
3550        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
3551        assert_eq!(
3552            extracted, "    print('hi')\r\n",
3553            "Content offsets should be exact for CRLF"
3554        );
3555    }
3556
3557    #[test]
3558    fn test_lint_enabled_false_skips_language_in_strict_mode() {
3559        // With on-missing-language-definition = "fail", a language configured
3560        // with enabled=false should be silently skipped (no error).
3561        let mut config = default_config();
3562        config.normalize_language = NormalizeLanguage::Exact;
3563        config.on_missing_language_definition = OnMissing::Fail;
3564
3565        // Python has tools, plaintext is disabled
3566        config.languages.insert(
3567            "python".to_string(),
3568            LanguageToolConfig {
3569                lint: vec!["ruff:check".to_string()],
3570                ..Default::default()
3571            },
3572        );
3573        config.languages.insert(
3574            "plaintext".to_string(),
3575            LanguageToolConfig {
3576                enabled: false,
3577                ..Default::default()
3578            },
3579        );
3580
3581        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3582
3583        let content = "```plaintext\nsome text\n```";
3584        let result = processor.lint(content);
3585
3586        // No error for plaintext: enabled=false satisfies strict mode
3587        assert!(result.is_ok());
3588        let diagnostics = result.unwrap();
3589        assert!(
3590            diagnostics.is_empty(),
3591            "Expected no diagnostics for disabled language, got: {diagnostics:?}"
3592        );
3593    }
3594
3595    #[test]
3596    fn test_format_enabled_false_skips_language_in_strict_mode() {
3597        // Same test but for format mode
3598        let mut config = default_config();
3599        config.normalize_language = NormalizeLanguage::Exact;
3600        config.on_missing_language_definition = OnMissing::Fail;
3601
3602        config.languages.insert(
3603            "plaintext".to_string(),
3604            LanguageToolConfig {
3605                enabled: false,
3606                ..Default::default()
3607            },
3608        );
3609
3610        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3611
3612        let content = "```plaintext\nsome text\n```";
3613        let result = processor.format(content);
3614
3615        // No error for plaintext: enabled=false satisfies strict mode
3616        assert!(result.is_ok());
3617        let output = result.unwrap();
3618        assert!(!output.had_errors, "Expected no errors for disabled language");
3619        assert!(
3620            output.error_messages.is_empty(),
3621            "Expected no error messages, got: {:?}",
3622            output.error_messages
3623        );
3624    }
3625
3626    #[test]
3627    fn test_enabled_false_default_true_preserved() {
3628        // Verify that when enabled is not set, it defaults to true (existing behavior)
3629        let mut config = default_config();
3630        config.on_missing_language_definition = OnMissing::Fail;
3631
3632        // Configure python without explicitly setting enabled
3633        config.languages.insert(
3634            "python".to_string(),
3635            LanguageToolConfig {
3636                lint: vec!["ruff:check".to_string()],
3637                ..Default::default()
3638            },
3639        );
3640
3641        let lang_config = config.languages.get("python").unwrap();
3642        assert!(lang_config.enabled, "enabled should default to true");
3643    }
3644
3645    #[test]
3646    fn test_enabled_false_with_fail_fast_no_error() {
3647        // Even with fail-fast, enabled=false should skip silently
3648        let mut config = default_config();
3649        config.normalize_language = NormalizeLanguage::Exact;
3650        config.on_missing_language_definition = OnMissing::FailFast;
3651
3652        config.languages.insert(
3653            "unknown".to_string(),
3654            LanguageToolConfig {
3655                enabled: false,
3656                ..Default::default()
3657            },
3658        );
3659
3660        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3661
3662        let content = "```unknown\nsome content\n```";
3663        let result = processor.lint(content);
3664
3665        // Should not return an error: enabled=false takes precedence over fail-fast
3666        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
3667        assert!(result.unwrap().is_empty());
3668    }
3669
3670    #[test]
3671    fn test_enabled_false_format_with_fail_fast_no_error() {
3672        // Same for format mode
3673        let mut config = default_config();
3674        config.normalize_language = NormalizeLanguage::Exact;
3675        config.on_missing_language_definition = OnMissing::FailFast;
3676
3677        config.languages.insert(
3678            "unknown".to_string(),
3679            LanguageToolConfig {
3680                enabled: false,
3681                ..Default::default()
3682            },
3683        );
3684
3685        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3686
3687        let content = "```unknown\nsome content\n```";
3688        let result = processor.format(content);
3689
3690        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
3691        let output = result.unwrap();
3692        assert!(!output.had_errors);
3693    }
3694
3695    #[test]
3696    fn test_enabled_false_with_tools_still_skips() {
3697        // If enabled=false but tools are listed, the language should still be skipped
3698        let mut config = default_config();
3699        config.on_missing_language_definition = OnMissing::Fail;
3700
3701        config.languages.insert(
3702            "python".to_string(),
3703            LanguageToolConfig {
3704                enabled: false,
3705                lint: vec!["ruff:check".to_string()],
3706                format: vec!["ruff:format".to_string()],
3707                ..Default::default()
3708            },
3709        );
3710
3711        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3712
3713        let content = "```python\nprint('hello')\n```";
3714
3715        // Lint should skip
3716        let lint_result = processor.lint(content);
3717        assert!(lint_result.is_ok());
3718        assert!(lint_result.unwrap().is_empty());
3719
3720        // Format should skip
3721        let format_result = processor.format(content);
3722        assert!(format_result.is_ok());
3723        let output = format_result.unwrap();
3724        assert!(!output.had_errors);
3725        assert_eq!(output.content, content, "Content should be unchanged");
3726    }
3727
3728    #[test]
3729    fn test_enabled_true_without_tools_triggers_strict_mode() {
3730        // A language configured with enabled=true (default) but no tools
3731        // should still trigger strict mode errors
3732        let mut config = default_config();
3733        config.on_missing_language_definition = OnMissing::Fail;
3734
3735        config.languages.insert(
3736            "python".to_string(),
3737            LanguageToolConfig {
3738                // enabled defaults to true, no tools
3739                ..Default::default()
3740            },
3741        );
3742
3743        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3744
3745        let content = "```python\nprint('hello')\n```";
3746        let result = processor.lint(content);
3747
3748        // Should report an error because enabled=true but no lint tools configured
3749        assert!(result.is_ok());
3750        let diagnostics = result.unwrap();
3751        assert_eq!(diagnostics.len(), 1);
3752        assert!(diagnostics[0].message.contains("No lint tools configured"));
3753    }
3754
3755    #[test]
3756    fn test_mixed_enabled_and_disabled_languages() {
3757        // Multiple languages: one disabled, one unconfigured
3758        let mut config = default_config();
3759        config.normalize_language = NormalizeLanguage::Exact;
3760        config.on_missing_language_definition = OnMissing::Fail;
3761
3762        config.languages.insert(
3763            "plaintext".to_string(),
3764            LanguageToolConfig {
3765                enabled: false,
3766                ..Default::default()
3767            },
3768        );
3769
3770        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3771
3772        let content = "\
3773```plaintext
3774some text
3775```
3776
3777```javascript
3778console.log('hi');
3779```
3780";
3781
3782        let result = processor.lint(content);
3783        assert!(result.is_ok());
3784        let diagnostics = result.unwrap();
3785
3786        // plaintext: skipped (enabled=false), no error
3787        // javascript: not configured at all, should trigger strict mode error
3788        assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
3789        assert!(
3790            diagnostics[0].message.contains("javascript"),
3791            "Error should be about javascript, got: {}",
3792            diagnostics[0].message
3793        );
3794    }
3795
3796    #[test]
3797    fn test_generic_fallback_includes_all_stderr_lines() {
3798        let config = default_config();
3799        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3800
3801        // Use output that won't be parsed by any structured format parser
3802        let output = ToolOutput {
3803            stdout: String::new(),
3804            stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
3805            exit_code: 1,
3806            success: false,
3807        };
3808
3809        let diags = processor.parse_tool_output(&output, "tombi", 5);
3810        assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
3811        assert_eq!(diags[0].message, "Parse error at position 42");
3812        assert_eq!(diags[1].message, "Unexpected token '::'");
3813        assert_eq!(diags[2].message, "3 errors found");
3814        assert!(diags.iter().all(|d| d.tool == "tombi"));
3815        assert!(diags.iter().all(|d| d.file_line == 5));
3816    }
3817
3818    #[test]
3819    fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
3820        let config = default_config();
3821        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3822
3823        let output = ToolOutput {
3824            stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
3825            stderr: String::new(),
3826            exit_code: 1,
3827            success: false,
3828        };
3829
3830        let diags = processor.parse_tool_output(&output, "some-tool", 10);
3831        assert_eq!(diags.len(), 3);
3832        assert_eq!(diags[0].message, "Line 1 error");
3833        assert_eq!(diags[1].message, "Line 2 detail");
3834        assert_eq!(diags[2].message, "Line 3 summary");
3835    }
3836
3837    #[test]
3838    fn test_generic_fallback_skips_blank_lines() {
3839        let config = default_config();
3840        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3841
3842        let output = ToolOutput {
3843            stdout: String::new(),
3844            stderr: "error: bad input\n\n  \n\ndetail: see above\n".to_string(),
3845            exit_code: 1,
3846            success: false,
3847        };
3848
3849        let diags = processor.parse_tool_output(&output, "tool", 1);
3850        assert_eq!(diags.len(), 2);
3851        assert_eq!(diags[0].message, "error: bad input");
3852        assert_eq!(diags[1].message, "detail: see above");
3853    }
3854
3855    #[test]
3856    fn test_generic_fallback_exit_code_when_no_output() {
3857        let config = default_config();
3858        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3859
3860        let output = ToolOutput {
3861            stdout: String::new(),
3862            stderr: String::new(),
3863            exit_code: 42,
3864            success: false,
3865        };
3866
3867        let diags = processor.parse_tool_output(&output, "tool", 1);
3868        assert_eq!(diags.len(), 1);
3869        assert_eq!(diags[0].message, "Tool exited with code 42");
3870    }
3871
3872    #[test]
3873    fn test_generic_fallback_not_triggered_on_success() {
3874        let config = default_config();
3875        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3876
3877        let output = ToolOutput {
3878            stdout: "some informational output".to_string(),
3879            stderr: String::new(),
3880            exit_code: 0,
3881            success: true,
3882        };
3883
3884        let diags = processor.parse_tool_output(&output, "tool", 1);
3885        assert!(
3886            diags.is_empty(),
3887            "Successful tool runs should produce no fallback diagnostics"
3888        );
3889    }
3890
3891    #[test]
3892    fn test_ansi_codes_stripped_before_parsing() {
3893        let config = default_config();
3894        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3895
3896        // ruff-style output with ANSI color codes wrapping the message
3897        let output = ToolOutput {
3898            stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
3899                .to_string(),
3900            stderr: String::new(),
3901            exit_code: 1,
3902            success: false,
3903        };
3904
3905        let diags = processor.parse_tool_output(&output, "ruff:check", 5);
3906        assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
3907        assert_eq!(diags[0].message, "E501 Line too long");
3908        assert_eq!(diags[0].file_line, 6); // 5 + 1
3909    }
3910
3911    #[test]
3912    fn test_tombi_multiline_error_format() {
3913        let config = default_config();
3914        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3915
3916        // Simulates tombi output (ANSI codes stripped for clarity)
3917        let output = ToolOutput {
3918            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(),
3919            stderr: "1 file failed to be formatted".to_string(),
3920            exit_code: 1,
3921            success: false,
3922        };
3923
3924        let diags = processor.parse_tool_output(&output, "tombi", 7);
3925        assert_eq!(
3926            diags.len(),
3927            4,
3928            "Expected 4 diagnostics from tombi errors, got {diags:?}"
3929        );
3930        assert_eq!(diags[0].message, "invalid key");
3931        assert_eq!(diags[0].file_line, 9); // 7 + 2
3932        assert_eq!(diags[0].column, Some(1));
3933        assert_eq!(diags[1].message, "expected key");
3934        assert_eq!(diags[1].file_line, 9);
3935        assert_eq!(diags[2].message, "expected '='");
3936        assert_eq!(diags[3].message, "expected value");
3937        assert!(diags.iter().all(|d| d.tool == "tombi"));
3938    }
3939
3940    #[test]
3941    fn test_tombi_with_ansi_codes() {
3942        let config = default_config();
3943        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3944
3945        // Real tombi output with ANSI escape codes
3946        let output = ToolOutput {
3947            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(),
3948            stderr: "1 file failed to be formatted".to_string(),
3949            exit_code: 1,
3950            success: false,
3951        };
3952
3953        let diags = processor.parse_tool_output(&output, "tombi", 7);
3954        assert_eq!(
3955            diags.len(),
3956            2,
3957            "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
3958        );
3959        assert_eq!(diags[0].message, "invalid key");
3960        assert_eq!(diags[0].file_line, 9);
3961        assert_eq!(diags[1].message, "expected '='");
3962        assert_eq!(diags[1].file_line, 9);
3963    }
3964
3965    /// sqlfluff's `github-annotation-native` output, verbatim, for `SELECT   1  FROM   t`.
3966    ///
3967    /// The `::group::` / `::endgroup::` markers bracket every run and carry no finding, so
3968    /// a parser that treated any `::...::` line as a diagnostic would report five.
3969    const SQLFLUFF_ANNOTATIONS: &str = "\
3970::group::stdin
3971::warning title=SQLFluff,file=stdin,line=1,col=7,endLine=1,endColumn=10::LT01: Expected only single space before numeric literal. Found '   '. [layout.spacing]
3972::warning title=SQLFluff,file=stdin,line=1,col=11,endLine=1,endColumn=13::LT01: Expected only single space before 'FROM' keyword. Found '  '. [layout.spacing]
3973::warning title=SQLFluff,file=stdin,line=1,col=17,endLine=1,endColumn=20::LT01: Expected only single space before naked identifier. Found '   '. [layout.spacing]
3974::endgroup::";
3975
3976    #[test]
3977    fn test_github_annotation_maps_line_and_column() {
3978        let config = default_config();
3979        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3980
3981        let output = ToolOutput {
3982            stdout: SQLFLUFF_ANNOTATIONS.to_string(),
3983            stderr: String::new(),
3984            exit_code: 1,
3985            success: false,
3986        };
3987
3988        let diags = processor.parse_tool_output(&output, "sqlfluff:lint", 3);
3989        assert_eq!(diags.len(), 3, "the group markers carry no finding: {diags:?}");
3990        assert_eq!(diags.iter().map(|d| d.file_line).collect::<Vec<_>>(), vec![4, 4, 4]);
3991        assert_eq!(
3992            diags.iter().map(|d| d.column).collect::<Vec<_>>(),
3993            vec![Some(7), Some(11), Some(17)]
3994        );
3995        assert!(diags[0].message.starts_with("LT01: Expected only single space"));
3996        assert!(diags.iter().all(|d| matches!(d.severity, DiagnosticSeverity::Warning)));
3997    }
3998
3999    #[test]
4000    fn test_github_annotation_severity_per_level() {
4001        let config = default_config();
4002        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4003
4004        let output = ToolOutput {
4005            stdout: "::error line=1::broken\n::warning line=2::suspicious\n::notice line=3::detail".to_string(),
4006            stderr: String::new(),
4007            exit_code: 1,
4008            success: false,
4009        };
4010
4011        let diags = processor.parse_tool_output(&output, "tool", 10);
4012        assert_eq!(diags.len(), 3);
4013        assert!(matches!(diags[0].severity, DiagnosticSeverity::Error));
4014        assert!(matches!(diags[1].severity, DiagnosticSeverity::Warning));
4015        assert!(matches!(diags[2].severity, DiagnosticSeverity::Info));
4016        assert_eq!(diags[2].file_line, 13);
4017    }
4018
4019    #[test]
4020    fn test_github_annotation_without_line_anchors_at_fence() {
4021        let config = default_config();
4022        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4023
4024        let output = ToolOutput {
4025            stdout: "::error::something the tool could not place".to_string(),
4026            stderr: String::new(),
4027            exit_code: 1,
4028            success: false,
4029        };
4030
4031        let diags = processor.parse_tool_output(&output, "tool", 12);
4032        assert_eq!(diags.len(), 1);
4033        assert_eq!(diags[0].file_line, 12);
4034        assert_eq!(diags[0].column, None);
4035        assert_eq!(diags[0].message, "something the tool could not place");
4036    }
4037
4038    #[test]
4039    fn test_github_annotation_decodes_escaped_message() {
4040        let config = default_config();
4041        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4042
4043        // The format escapes `%`, `:`, `,` and line breaks, because each of them is a
4044        // separator in the line the message is embedded in.
4045        let output = ToolOutput {
4046            stdout: "::error line=1,col=2::100%25 of rows%3A a%2C b%0Aand more".to_string(),
4047            stderr: String::new(),
4048            exit_code: 1,
4049            success: false,
4050        };
4051
4052        let diags = processor.parse_tool_output(&output, "tool", 5);
4053        assert_eq!(diags.len(), 1);
4054        assert_eq!(diags[0].message, "100% of rows: a, b and more");
4055        assert_eq!(diags[0].file_line, 6);
4056        assert_eq!(diags[0].column, Some(2));
4057    }
4058
4059    #[test]
4060    fn test_decode_workflow_command_is_a_single_left_to_right_pass() {
4061        // `%250A` is an escaped `%` followed by the literal text "0A". Decoding `%25` first
4062        // and then looking for `%0A` in the result would turn it into a line break.
4063        assert_eq!(decode_workflow_command("%250A"), "%0A");
4064        assert_eq!(decode_workflow_command("no escapes"), "no escapes");
4065        assert_eq!(decode_workflow_command("50% off"), "50% off");
4066        assert_eq!(decode_workflow_command("trailing %"), "trailing %");
4067        assert_eq!(decode_workflow_command("%0d%0a"), "  ");
4068        assert_eq!(decode_workflow_command("%3A%2C"), ":,");
4069    }
4070
4071    #[test]
4072    fn test_position_in_message_anchors_a_prose_position() {
4073        let config = default_config();
4074        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4075
4076        // jq names the position inside the message rather than prefixing it, and exits
4077        // non-zero, so this arrives through the raw-output fallback.
4078        let output = ToolOutput {
4079            stdout: String::new(),
4080            stderr: "jq: error (at <stdin>:0): syntax error, unexpected '}' at line 3, column 8".to_string(),
4081            exit_code: 2,
4082            success: false,
4083        };
4084
4085        let diags = processor.parse_tool_output(&output, "jq", 20);
4086        assert_eq!(diags.len(), 1);
4087        assert_eq!(diags[0].file_line, 23);
4088        assert_eq!(diags[0].column, Some(8));
4089    }
4090
4091    #[test]
4092    fn test_position_in_message_takes_the_last_one_named() {
4093        let config = default_config();
4094        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4095
4096        let output = ToolOutput {
4097            stdout: "unterminated string started at line 2, column 1 at line 6, column 4".to_string(),
4098            stderr: String::new(),
4099            exit_code: 1,
4100            success: false,
4101        };
4102
4103        let diags = processor.parse_tool_output(&output, "tool", 0);
4104        assert_eq!(diags.len(), 1);
4105        assert_eq!(diags[0].file_line, 6, "the sentence ends on the position it is about");
4106        assert_eq!(diags[0].column, Some(4));
4107    }
4108
4109    #[test]
4110    fn test_message_naming_no_position_stays_on_the_fence() {
4111        let config = default_config();
4112        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4113
4114        let output = ToolOutput {
4115            stdout: "could not parse the input".to_string(),
4116            stderr: String::new(),
4117            exit_code: 1,
4118            success: false,
4119        };
4120
4121        let diags = processor.parse_tool_output(&output, "tool", 7);
4122        assert_eq!(diags.len(), 1);
4123        assert_eq!(diags[0].file_line, 7);
4124        assert_eq!(diags[0].column, None);
4125    }
4126
4127    #[test]
4128    fn test_fallback_combines_stdout_and_stderr() {
4129        let config = default_config();
4130        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4131
4132        // Tool puts some errors on stdout, summary on stderr
4133        let output = ToolOutput {
4134            stdout: "problem found in input".to_string(),
4135            stderr: "1 file failed".to_string(),
4136            exit_code: 1,
4137            success: false,
4138        };
4139
4140        let diags = processor.parse_tool_output(&output, "tool", 1);
4141        assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
4142        assert_eq!(diags[0].message, "problem found in input");
4143        assert_eq!(diags[1].message, "1 file failed");
4144    }
4145
4146    #[test]
4147    fn test_error_line_without_position_info() {
4148        let config = default_config();
4149        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4150
4151        // Error: line not followed by "at line N column M"
4152        let output = ToolOutput {
4153            stdout: "Error: something went wrong\nsome unrelated line".to_string(),
4154            stderr: String::new(),
4155            exit_code: 1,
4156            success: false,
4157        };
4158
4159        let diags = processor.parse_tool_output(&output, "tool", 5);
4160        // "Error: something went wrong" → parsed by error-line parser (no position)
4161        // "some unrelated line" → no parser matches, but diagnostics not empty → no fallback
4162        assert!(!diags.is_empty());
4163        assert_eq!(diags[0].message, "something went wrong");
4164        assert_eq!(diags[0].file_line, 5); // No line offset, uses code_block_start
4165    }
4166
4167    #[test]
4168    fn test_warning_line_with_position() {
4169        let config = default_config();
4170        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4171
4172        let output = ToolOutput {
4173            stdout: "Warning: deprecated syntax\n    at line 3 column 5".to_string(),
4174            stderr: String::new(),
4175            exit_code: 1,
4176            success: false,
4177        };
4178
4179        let diags = processor.parse_tool_output(&output, "tool", 10);
4180        assert_eq!(diags.len(), 1);
4181        assert_eq!(diags[0].message, "deprecated syntax");
4182        assert_eq!(diags[0].file_line, 13); // 10 + 3
4183        assert_eq!(diags[0].column, Some(5));
4184        assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
4185    }
4186
4187    #[test]
4188    fn test_strip_ansi_codes() {
4189        assert_eq!(strip_ansi_codes("hello"), "hello");
4190        assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
4191        assert_eq!(
4192            strip_ansi_codes("\x1b[1;31m  Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
4193            "  Error: msg"
4194        );
4195        assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
4196        assert_eq!(strip_ansi_codes(""), "");
4197        assert_eq!(
4198            strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
4199            "at line 2 column 1"
4200        );
4201    }
4202
4203    #[test]
4204    fn test_parse_at_line_column() {
4205        assert_eq!(
4206            CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
4207            Some((2, 1))
4208        );
4209        assert_eq!(
4210            CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
4211            Some((10, 15))
4212        );
4213        assert_eq!(
4214            CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
4215            Some((5, 3))
4216        );
4217        assert_eq!(
4218            CodeBlockToolProcessor::parse_at_line_column("not a position line"),
4219            None
4220        );
4221        assert_eq!(
4222            CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
4223            None
4224        );
4225    }
4226
4227    #[test]
4228    fn test_parse_error_line() {
4229        let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
4230        assert_eq!(msg, "invalid key");
4231        assert!(matches!(sev, DiagnosticSeverity::Error));
4232
4233        let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
4234        assert_eq!(msg, "deprecated");
4235        assert!(matches!(sev, DiagnosticSeverity::Warning));
4236
4237        // Lowercase should NOT match (avoids conflict with unstructured tool output)
4238        assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
4239        assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
4240
4241        // Empty message after prefix should not match
4242        assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
4243        assert!(CodeBlockToolProcessor::parse_error_line("Error:   ").is_none());
4244
4245        // Not an error line
4246        assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
4247    }
4248
4249    #[test]
4250    fn test_consecutive_error_lines_without_position() {
4251        let config = default_config();
4252        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4253
4254        // Two Error: lines in a row — first should flush without position,
4255        // second gets position from "at line"
4256        let output = ToolOutput {
4257            stdout: "Error: first problem\nError: second problem\n    at line 3 column 1".to_string(),
4258            stderr: String::new(),
4259            exit_code: 1,
4260            success: false,
4261        };
4262
4263        let diags = processor.parse_tool_output(&output, "tool", 5);
4264        assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
4265        // First error flushed without position when second Error: was encountered
4266        assert_eq!(diags[0].message, "first problem");
4267        assert_eq!(diags[0].file_line, 5); // No line mapping
4268        assert_eq!(diags[0].column, None);
4269        // Second error resolved with position
4270        assert_eq!(diags[1].message, "second problem");
4271        assert_eq!(diags[1].file_line, 8); // 5 + 3
4272        assert_eq!(diags[1].column, Some(1));
4273    }
4274
4275    #[test]
4276    fn test_error_line_at_end_of_output() {
4277        let config = default_config();
4278        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4279
4280        // Error: as the very last line — flushed by post-loop code
4281        let output = ToolOutput {
4282            stdout: "Error: trailing error".to_string(),
4283            stderr: String::new(),
4284            exit_code: 1,
4285            success: false,
4286        };
4287
4288        let diags = processor.parse_tool_output(&output, "tool", 5);
4289        assert_eq!(diags.len(), 1);
4290        assert_eq!(diags[0].message, "trailing error");
4291        assert_eq!(diags[0].file_line, 5); // No position info available
4292        assert_eq!(diags[0].column, None);
4293    }
4294
4295    #[test]
4296    fn test_blank_lines_between_error_and_position() {
4297        let config = default_config();
4298        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4299
4300        // Blank lines between Error: and "at line" should be transparently skipped
4301        let output = ToolOutput {
4302            stdout: "Error: spaced out\n\n\n    at line 4 column 2".to_string(),
4303            stderr: String::new(),
4304            exit_code: 1,
4305            success: false,
4306        };
4307
4308        let diags = processor.parse_tool_output(&output, "tool", 10);
4309        assert_eq!(diags.len(), 1);
4310        assert_eq!(diags[0].message, "spaced out");
4311        assert_eq!(diags[0].file_line, 14); // 10 + 4
4312        assert_eq!(diags[0].column, Some(2));
4313    }
4314
4315    #[test]
4316    fn test_mixed_structured_and_error_line_parsers() {
4317        let config = default_config();
4318        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4319
4320        // Mix of ruff-style structured output and tombi-style Error: output
4321        let output = ToolOutput {
4322            stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n    at line 3 column 1".to_string(),
4323            stderr: String::new(),
4324            exit_code: 1,
4325            success: false,
4326        };
4327
4328        let diags = processor.parse_tool_output(&output, "tool", 5);
4329        assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
4330        // First: standard format parser
4331        assert_eq!(diags[0].message, "E501 Line too long");
4332        assert_eq!(diags[0].file_line, 6); // 5 + 1
4333        // Second: Error: + at line parser
4334        assert_eq!(diags[1].message, "invalid syntax");
4335        assert_eq!(diags[1].file_line, 8); // 5 + 3
4336    }
4337
4338    #[test]
4339    fn test_at_line_without_preceding_error() {
4340        let config = default_config();
4341        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4342
4343        // "at line N column M" without a preceding Error: should not create a diagnostic
4344        let output = ToolOutput {
4345            stdout: "at line 2 column 1\nsome other text".to_string(),
4346            stderr: String::new(),
4347            exit_code: 1,
4348            success: false,
4349        };
4350
4351        let diags = processor.parse_tool_output(&output, "tool", 5);
4352        // No pending error, so "at line" is just an unmatched line
4353        // Both lines are unmatched, fallback fires with combined output
4354        assert_eq!(diags.len(), 2);
4355        assert_eq!(diags[0].message, "at line 2 column 1");
4356        assert_eq!(diags[1].message, "some other text");
4357    }
4358
4359    // =========================================================================
4360    // Issue #527: formatter that produces empty output should not erase content
4361    // =========================================================================
4362
4363    /// A formatter that produces no stdout (like `tombi lint -` mistakenly used
4364    /// as a formatter) must never replace non-empty content with an empty string.
4365    /// It is a formatter that failed, so `on-error` decides what is reported.
4366    /// This test uses `true`, which exits 0 with no output.
4367    #[test]
4368    fn test_format_empty_output_does_not_erase_content() {
4369        use super::super::config::LanguageToolConfig;
4370
4371        let content = "```toml\nkey = \"value\"\n```\n";
4372        let config_for = |on_error: OnError| {
4373            let mut config = default_config();
4374            config.on_error = on_error;
4375            config.languages.insert(
4376                "toml".to_string(),
4377                LanguageToolConfig {
4378                    format: vec!["empty-formatter".to_string()],
4379                    ..Default::default()
4380                },
4381            );
4382            config.tools.insert(
4383                "empty-formatter".to_string(),
4384                super::super::config::ToolDefinition {
4385                    command: vec!["true".to_string()],
4386                    stdin: true,
4387                    stdout: true,
4388                    lint_args: vec![],
4389                    format_args: vec![],
4390                },
4391            );
4392            config
4393        };
4394
4395        let config = config_for(OnError::Fail);
4396        let result = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format(content);
4397        assert!(
4398            matches!(result, Err(ProcessorError::ToolErrorAt { line: 1, .. })),
4399            "fail must stop at the block: {result:?}"
4400        );
4401
4402        let config = config_for(OnError::Warn);
4403        let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
4404            .format(content)
4405            .unwrap();
4406        assert_eq!(output.content, content, "warn must leave the block as it was");
4407        assert_eq!(output.error_messages.len(), 1, "{:?}", output.error_messages);
4408        assert!(output.error_messages[0].starts_with("line 1 (toml): "));
4409
4410        let config = config_for(OnError::Skip);
4411        let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
4412            .format(content)
4413            .unwrap();
4414        assert_eq!(output.content, content, "skip must leave the block as it was");
4415        assert!(output.error_messages.is_empty(), "{:?}", output.error_messages);
4416    }
4417
4418    /// A formatter that echoes input back (like `cat`) should preserve content.
4419    #[test]
4420    fn test_format_identity_formatter_preserves_content() {
4421        use super::super::config::LanguageToolConfig;
4422
4423        let mut config = default_config();
4424        config.languages.insert(
4425            "toml".to_string(),
4426            LanguageToolConfig {
4427                format: vec!["cat-formatter".to_string()],
4428                ..Default::default()
4429            },
4430        );
4431        config.tools.insert(
4432            "cat-formatter".to_string(),
4433            super::super::config::ToolDefinition {
4434                command: vec!["cat".to_string()],
4435                stdin: true,
4436                stdout: true,
4437                lint_args: vec![],
4438                format_args: vec![],
4439            },
4440        );
4441
4442        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4443
4444        let content = "```toml\nkey = \"value\"\n```\n";
4445        let result = processor.format(content);
4446
4447        assert!(result.is_ok(), "Format should not error");
4448        let output = result.unwrap();
4449        assert_eq!(
4450            output.content, content,
4451            "Identity formatter should preserve content exactly"
4452        );
4453    }
4454
4455    /// Verify that the context-aware tool resolution resolves bare "tombi"
4456    /// to "tombi:format" in format context and "tombi:lint" in lint context.
4457    #[test]
4458    fn test_resolve_tool_context_aware_tombi() {
4459        let config = default_config();
4460        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4461
4462        // In format context, bare "tombi" should resolve to "tombi:format"
4463        let format_def = processor
4464            .registry
4465            .resolve("tombi", ToolSlot::Format)
4466            .expect("Should resolve tombi in format context");
4467        assert!(
4468            format_def.command.iter().any(|arg| arg == "format"),
4469            "Bare 'tombi' in format context should resolve to 'tombi format', got: {:?}",
4470            format_def.command
4471        );
4472
4473        // In lint context, bare "tombi" should resolve to "tombi:lint"
4474        let lint_def = processor
4475            .registry
4476            .resolve("tombi", ToolSlot::Lint)
4477            .expect("Should resolve tombi in lint context");
4478        assert!(
4479            lint_def.command.iter().any(|arg| arg == "lint"),
4480            "Bare 'tombi' in lint context should resolve to 'tombi lint', got: {:?}",
4481            lint_def.command
4482        );
4483
4484        // Explicit suffix should bypass context-aware resolution
4485        let explicit_def = processor
4486            .registry
4487            .resolve("tombi:lint", ToolSlot::Format)
4488            .expect("Should resolve explicit tombi:lint even in format context");
4489        assert!(
4490            explicit_def.command.iter().any(|arg| arg == "lint"),
4491            "Explicit 'tombi:lint' should always use lint, got: {:?}",
4492            explicit_def.command
4493        );
4494    }
4495
4496    /// Verify context-aware resolution for ruff (uses "check" suffix, not "lint").
4497    #[test]
4498    fn test_resolve_tool_context_aware_ruff() {
4499        let config = default_config();
4500        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4501
4502        // In lint context, bare "ruff" should resolve to "ruff:check"
4503        let lint_def = processor
4504            .registry
4505            .resolve("ruff", ToolSlot::Lint)
4506            .expect("Should resolve ruff in lint context");
4507        assert!(
4508            lint_def.command.iter().any(|arg| arg == "check"),
4509            "Bare 'ruff' in lint context should resolve to 'ruff check', got: {:?}",
4510            lint_def.command
4511        );
4512
4513        // In format context, bare "ruff" should resolve to "ruff:format"
4514        let format_def = processor
4515            .registry
4516            .resolve("ruff", ToolSlot::Format)
4517            .expect("Should resolve ruff in format context");
4518        assert!(
4519            format_def.command.iter().any(|arg| arg == "format"),
4520            "Bare 'ruff' in format context should resolve to 'ruff format', got: {:?}",
4521            format_def.command
4522        );
4523    }
4524
4525    /// Tools without context-specific variants should still resolve via bare name.
4526    #[test]
4527    fn test_resolve_tool_bare_name_fallback() {
4528        let config = default_config();
4529        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4530
4531        // "shellcheck" has no :lint or :format variant — should fall back to bare name
4532        let def = processor
4533            .registry
4534            .resolve("shellcheck", ToolSlot::Lint)
4535            .expect("Should resolve shellcheck via fallback");
4536        assert!(
4537            def.command.iter().any(|arg| arg == "shellcheck"),
4538            "shellcheck should resolve to itself, got: {:?}",
4539            def.command
4540        );
4541    }
4542
4543    /// Context-aware resolution for tools with non-standard format suffixes.
4544    #[test]
4545    fn test_resolve_tool_context_aware_sqlfluff() {
4546        let config = default_config();
4547        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4548
4549        // sqlfluff uses ":fix" as its format variant
4550        let format_def = processor
4551            .registry
4552            .resolve("sqlfluff", ToolSlot::Format)
4553            .expect("Should resolve sqlfluff in format context");
4554        assert!(
4555            format_def.command.iter().any(|arg| arg == "fix"),
4556            "Bare 'sqlfluff' in format context should resolve to 'sqlfluff fix', got: {:?}",
4557            format_def.command
4558        );
4559    }
4560
4561    /// Context-aware resolution for djlint (:reformat suffix).
4562    #[test]
4563    fn test_resolve_tool_context_aware_djlint() {
4564        let config = default_config();
4565        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4566
4567        // djlint uses ":reformat" as its format variant
4568        let format_def = processor
4569            .registry
4570            .resolve("djlint", ToolSlot::Format)
4571            .expect("Should resolve djlint in format context");
4572        assert!(
4573            format_def.command.iter().any(|arg| arg.contains("reformat")),
4574            "Bare 'djlint' in format context should resolve to djlint reformat, got: {:?}",
4575            format_def.command
4576        );
4577    }
4578
4579    /// User-defined tools with context-specific variants resolve correctly.
4580    #[test]
4581    fn test_resolve_tool_user_defined_with_context_variant() {
4582        use super::super::config::ToolDefinition;
4583
4584        let mut config = default_config();
4585        config.tools.insert(
4586            "mytool".to_string(),
4587            ToolDefinition {
4588                command: vec!["mytool".to_string(), "--lint".to_string()],
4589                ..Default::default()
4590            },
4591        );
4592        config.tools.insert(
4593            "mytool:format".to_string(),
4594            ToolDefinition {
4595                command: vec!["mytool".to_string(), "--format".to_string()],
4596                ..Default::default()
4597            },
4598        );
4599
4600        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4601
4602        // In format context, should resolve to "mytool:format"
4603        let def = processor
4604            .registry
4605            .resolve("mytool", ToolSlot::Format)
4606            .expect("Should resolve user tool in format context");
4607        assert!(
4608            def.command.iter().any(|arg| arg == "--format"),
4609            "User 'mytool' in format context should resolve to mytool:format, got: {:?}",
4610            def.command
4611        );
4612
4613        // In lint context, should fall back to bare "mytool" (no mytool:lint exists)
4614        let def = processor
4615            .registry
4616            .resolve("mytool", ToolSlot::Lint)
4617            .expect("Should resolve user tool in lint context via fallback");
4618        assert!(
4619            def.command.iter().any(|arg| arg == "--lint"),
4620            "User 'mytool' in lint context should fall back to bare name, got: {:?}",
4621            def.command
4622        );
4623    }
4624
4625    /// Nonexistent tool returns None.
4626    #[test]
4627    fn test_resolve_tool_nonexistent_returns_none() {
4628        let config = default_config();
4629        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4630
4631        assert!(
4632            processor
4633                .registry
4634                .resolve("nonexistent-tool-xyz", ToolSlot::Lint)
4635                .is_none(),
4636            "Nonexistent tool should return None in lint context"
4637        );
4638        assert!(
4639            processor
4640                .registry
4641                .resolve("nonexistent-tool-xyz", ToolSlot::Format)
4642                .is_none(),
4643            "Nonexistent tool should return None in format context"
4644        );
4645    }
4646
4647    #[test]
4648    fn test_strip_ansi_codes_edge_cases() {
4649        // Lone ESC without CSI bracket — non-printable, safely dropped
4650        assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
4651        // ESC at end of string
4652        assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
4653        // Nested/consecutive sequences
4654        assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
4655        // 256-color and RGB sequences
4656        assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
4657        assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
4658    }
4659
4660    #[test]
4661    fn merge_conflict_prevents_external_formatting() {
4662        use super::super::config::{LanguageToolConfig, ToolDefinition};
4663        let mut config = default_config();
4664        config.on_missing_tool_binary = OnMissing::Fail;
4665        config.languages.insert(
4666            "testlang".to_string(),
4667            LanguageToolConfig {
4668                format: vec!["unavailable".to_string()],
4669                ..Default::default()
4670            },
4671        );
4672        config.tools.insert(
4673            "unavailable".to_string(),
4674            ToolDefinition {
4675                command: vec!["rumdl-858-tool-that-must-not-run".to_string()],
4676                ..Default::default()
4677            },
4678        );
4679        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4680        let content = "<<<<<<< HEAD\r\n\n```testlang\ntext\n```\n";
4681        let output = processor.format(content).unwrap();
4682        assert_eq!(output.content, content);
4683        assert!(!output.had_errors);
4684        assert!(output.failures.is_empty());
4685
4686        // Disabling the safety rule must let the formatter run, rather than
4687        // silently skipping every code block because of a documented marker.
4688        let mut document_config = crate::config::Config::default();
4689        document_config.global.disable.push("MD092".into());
4690        let output = processor.format_with_config(content, &document_config, None).unwrap();
4691        assert!(output.had_errors);
4692        assert!(!output.failures.is_empty());
4693        let suppressed = format!("<!-- rumdl-disable MD092 -->\n{content}");
4694        assert!(processor.format(&suppressed).unwrap().had_errors);
4695    }
4696
4697    /// A linter that enforces a trailing newline (like ryl/yamllint
4698    /// `new-line-at-end-of-file`) must not fire on a fenced code block whose
4699    /// final newline was stripped during extraction. The content piped to the
4700    /// tool should be newline-terminated, matching how the tool sees a real file.
4701    #[cfg(unix)]
4702    #[test]
4703    fn test_lint_yaml_block_no_false_new_line_at_eof() {
4704        use super::super::config::{LanguageToolConfig, ToolDefinition};
4705
4706        let mut config = default_config();
4707        config.normalize_language = NormalizeLanguage::Exact;
4708        config.languages.insert(
4709            "yaml".to_string(),
4710            LanguageToolConfig {
4711                lint: vec!["eof-newline-linter".to_string()],
4712                ..Default::default()
4713            },
4714        );
4715        // Emits a diagnostic only when stdin's last byte is not a newline.
4716        // `$(tail -c1)` strips trailing newlines, so it is empty iff the input
4717        // already ends in a newline.
4718        config.tools.insert(
4719            "eof-newline-linter".to_string(),
4720            ToolDefinition {
4721                command: vec![
4722                    "sh".to_string(),
4723                    "-c".to_string(),
4724                    "if [ -n \"$(tail -c1)\" ]; then echo '1:1: no newline at end of file'; fi".to_string(),
4725                ],
4726                ..Default::default()
4727            },
4728        );
4729
4730        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4731
4732        let content = "```yaml\nfoo: bar\n```\n";
4733        let diagnostics = processor.lint(content).expect("lint should succeed");
4734
4735        assert!(
4736            diagnostics.is_empty(),
4737            "EOF-newline linter should not fire on an extracted code block, got: {diagnostics:?}"
4738        );
4739    }
4740
4741    /// Format mode should also feed tools newline-terminated input, so a
4742    /// formatter that enforces an end-of-file newline sees the content as a
4743    /// complete file rather than re-adding a newline rumdl would then have to
4744    /// strip. The formatter here reports whether its stdin ended in a newline.
4745    #[cfg(unix)]
4746    #[test]
4747    fn test_format_feeds_newline_terminated_input() {
4748        use super::super::config::{LanguageToolConfig, ToolDefinition};
4749
4750        let mut config = default_config();
4751        config.normalize_language = NormalizeLanguage::Exact;
4752        config.languages.insert(
4753            "yaml".to_string(),
4754            LanguageToolConfig {
4755                format: vec!["newline-probe".to_string()],
4756                ..Default::default()
4757            },
4758        );
4759        config.tools.insert(
4760            "newline-probe".to_string(),
4761            ToolDefinition {
4762                command: vec![
4763                    "sh".to_string(),
4764                    "-c".to_string(),
4765                    "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi".to_string(),
4766                ],
4767                ..Default::default()
4768            },
4769        );
4770
4771        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4772
4773        let content = "```yaml\nfoo: bar\n```\n";
4774        let output = processor.format(content).expect("format should succeed");
4775
4776        assert!(
4777            output.content.contains("HAD_NEWLINE"),
4778            "Formatter should receive newline-terminated stdin, got: {:?}",
4779            output.content
4780        );
4781    }
4782
4783    /// A config whose `text` blocks are formatted by `tools` in `mode`. The
4784    /// stages are small shell filters, so each one leaves a visible mark and a
4785    /// result names exactly which stages ran and in what order.
4786    #[cfg(unix)]
4787    fn staged_config(tools: &[&str], mode: FormatMode, on_error: OnError) -> CodeBlockToolsConfig {
4788        use super::super::config::ToolDefinition;
4789
4790        let mut config = default_config();
4791        config.normalize_language = NormalizeLanguage::Exact;
4792        config.on_error = on_error;
4793        config.languages.insert(
4794            "text".to_string(),
4795            LanguageToolConfig {
4796                format: tools.iter().map(ToString::to_string).collect(),
4797                format_mode: mode,
4798                ..Default::default()
4799            },
4800        );
4801        for (id, script) in [
4802            ("upper", "tr a-z A-Z"),
4803            ("exclaim", "sed 's/$/!/'"),
4804            ("prefix-a", "sed 's/^/a/'"),
4805            ("prefix-b", "sed 's/^/b/'"),
4806            ("identity", "cat"),
4807            ("fails", "cat >/dev/null; exit 3"),
4808            ("empty", "cat >/dev/null"),
4809        ] {
4810            config.tools.insert(
4811                id.to_string(),
4812                ToolDefinition {
4813                    command: vec!["sh".to_string(), "-c".to_string(), script.to_string()],
4814                    ..Default::default()
4815                },
4816            );
4817        }
4818        config
4819    }
4820
4821    #[cfg(unix)]
4822    fn format_staged(tools: &[&str], mode: FormatMode, on_error: OnError) -> Result<FormatOutput, ProcessorError> {
4823        let config = staged_config(tools, mode, on_error);
4824        CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format("```text\nhello\n```\n")
4825    }
4826
4827    #[cfg(unix)]
4828    fn staged_block(tools: &[&str], mode: FormatMode, on_error: OnError) -> String {
4829        let output = format_staged(tools, mode, on_error).expect("format runs");
4830        output
4831            .content
4832            .strip_prefix("```text\n")
4833            .and_then(|rest| rest.strip_suffix("```\n"))
4834            .unwrap_or_else(|| panic!("fence kept: {:?}", output.content))
4835            .to_string()
4836    }
4837
4838    /// The first formatter that succeeds supplies the block, even when it
4839    /// changes nothing, and the default is that fallback list.
4840    #[cfg(unix)]
4841    #[test]
4842    fn a_fallback_format_list_stops_at_the_first_success() {
4843        assert_eq!(LanguageToolConfig::default().format_mode, FormatMode::Fallback);
4844        for (tools, expected) in [
4845            (&["upper", "exclaim"][..], "HELLO\n"),
4846            (&["identity", "upper"][..], "hello\n"),
4847            (&["fails", "upper", "exclaim"][..], "HELLO\n"),
4848            (&["empty", "upper", "exclaim"][..], "HELLO\n"),
4849        ] {
4850            assert_eq!(
4851                staged_block(tools, FormatMode::Fallback, OnError::Skip),
4852                expected,
4853                "{tools:?}"
4854            );
4855        }
4856    }
4857
4858    /// Every formatter runs in declaration order on the previous one's output,
4859    /// and a formatter that changes nothing does not end the pipeline.
4860    #[cfg(unix)]
4861    #[test]
4862    fn a_pipeline_runs_every_formatter_on_the_last_output() {
4863        for (tools, expected) in [
4864            (&["upper", "exclaim"][..], "HELLO!\n"),
4865            (&["identity", "upper"][..], "HELLO\n"),
4866            (&["prefix-a", "prefix-b"][..], "bahello\n"),
4867            (&["prefix-b", "prefix-a"][..], "abhello\n"),
4868            (&["identity"][..], "hello\n"),
4869        ] {
4870            assert_eq!(
4871                staged_block(tools, FormatMode::Pipeline, OnError::Fail),
4872                expected,
4873                "{tools:?}"
4874            );
4875        }
4876    }
4877
4878    /// A stage that fails, or prints nothing, is dropped under `warn` and
4879    /// `skip`: the next stage starts from the last output that succeeded, never
4880    /// from the failed stage's output. Only `warn` reports it.
4881    #[cfg(unix)]
4882    #[test]
4883    fn a_failed_pipeline_stage_is_dropped_under_warn_and_skip() {
4884        for failing in ["fails", "empty"] {
4885            let tools = ["upper", failing, "exclaim"];
4886            let warned = format_staged(&tools, FormatMode::Pipeline, OnError::Warn).unwrap();
4887            assert_eq!(warned.content, "```text\nHELLO!\n```\n", "{tools:?}");
4888            assert_eq!(warned.error_messages.len(), 1, "{:?}", warned.error_messages);
4889            assert!(warned.error_messages[0].starts_with("line 1 (text): "));
4890
4891            let skipped = format_staged(&tools, FormatMode::Pipeline, OnError::Skip).unwrap();
4892            assert_eq!(skipped.content, "```text\nHELLO!\n```\n", "{tools:?}");
4893            assert!(skipped.error_messages.is_empty(), "{:?}", skipped.error_messages);
4894        }
4895        // With no stage left that succeeds, the block stays as written.
4896        assert_eq!(
4897            staged_block(&["fails", "empty"], FormatMode::Pipeline, OnError::Skip),
4898            "hello\n"
4899        );
4900    }
4901
4902    /// Under `fail`, a stage that fails stops the run at its block, after an
4903    /// earlier stage succeeded just as before any did.
4904    #[cfg(unix)]
4905    #[test]
4906    fn a_failed_pipeline_stage_stops_the_run_under_fail() {
4907        for tools in [&["upper", "fails", "exclaim"][..], &["upper", "empty"][..]] {
4908            let result = format_staged(tools, FormatMode::Pipeline, OnError::Fail);
4909            assert!(
4910                matches!(result, Err(ProcessorError::ToolErrorAt { line: 1, .. })),
4911                "{tools:?}: {result:?}"
4912            );
4913        }
4914    }
4915
4916    /// A stage whose binary is missing follows `on-missing-tool-binary`, and
4917    /// the pipeline carries on past it.
4918    #[cfg(unix)]
4919    #[test]
4920    fn a_pipeline_stage_with_a_missing_binary_follows_on_missing_tool_binary() {
4921        use super::super::config::ToolDefinition;
4922
4923        let mut config = staged_config(&["upper", "gone", "exclaim"], FormatMode::Pipeline, OnError::Fail);
4924        config.tools.insert(
4925            "gone".to_string(),
4926            ToolDefinition {
4927                command: vec!["rumdl-test-nonexistent-formatter".to_string()],
4928                ..Default::default()
4929            },
4930        );
4931        let content = "```text\nhello\n```\n";
4932
4933        config.on_missing_tool_binary = OnMissing::Ignore;
4934        let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
4935            .format(content)
4936            .unwrap();
4937        assert_eq!(output.content, "```text\nHELLO!\n```\n");
4938
4939        config.on_missing_tool_binary = OnMissing::Fail;
4940        let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
4941            .format(content)
4942            .unwrap();
4943        assert_eq!(output.content, "```text\nHELLO!\n```\n");
4944        assert_eq!(output.failures.len(), 1, "{:?}", output.failures);
4945
4946        config.on_missing_tool_binary = OnMissing::FailFast;
4947        let result = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format(content);
4948        assert!(
4949            matches!(result, Err(ProcessorError::ToolBinaryNotFound { .. })),
4950            "{result:?}"
4951        );
4952    }
4953
4954    /// Each stage gets newline-terminated input, and the final block keeps the
4955    /// original's line ending however the stages end theirs.
4956    #[cfg(unix)]
4957    #[test]
4958    fn a_pipeline_keeps_the_block_line_ending_between_stages() {
4959        use super::super::config::ToolDefinition;
4960
4961        let mut config = staged_config(&["strip-newline", "probe"], FormatMode::Pipeline, OnError::Fail);
4962        for (id, script) in [
4963            ("strip-newline", "printf '%s' \"$(cat)\""),
4964            (
4965                "probe",
4966                "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi",
4967            ),
4968        ] {
4969            config.tools.insert(
4970                id.to_string(),
4971                ToolDefinition {
4972                    command: vec!["sh".to_string(), "-c".to_string(), script.to_string()],
4973                    ..Default::default()
4974                },
4975            );
4976        }
4977        let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
4978            .format("```text\nhello\n```\n")
4979            .unwrap();
4980        assert_eq!(output.content, "```text\nHAD_NEWLINE\n```\n");
4981    }
4982
4983    #[test]
4984    fn test_ensure_trailing_newline() {
4985        // Non-empty content without a trailing newline gets one appended.
4986        assert_eq!(ensure_trailing_newline("foo: bar"), "foo: bar\n");
4987        // Multi-line content gets a single newline appended to the last line.
4988        assert_eq!(ensure_trailing_newline("a: 1\nb: 2"), "a: 1\nb: 2\n");
4989        // Content already ending in a newline is returned unchanged.
4990        assert_eq!(ensure_trailing_newline("foo: bar\n"), "foo: bar\n");
4991        // A trailing blank line (already newline-terminated) is left alone.
4992        assert_eq!(ensure_trailing_newline("foo: bar\n\n"), "foo: bar\n\n");
4993        // Genuinely empty content is left empty — no newline synthesized.
4994        assert_eq!(ensure_trailing_newline(""), "");
4995    }
4996}