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