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
6#[cfg(test)]
7use super::config::LanguageToolConfig;
8use super::config::{CodeBlockToolsConfig, NormalizeLanguage, OnError, OnMissing};
9use super::executor::{ExecutorError, ToolExecutor, ToolOutput};
10use super::linguist::LinguistResolver;
11use super::registry::ToolRegistry;
12use crate::config::MarkdownFlavor;
13use crate::rule::{LintWarning, Severity};
14use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
15
16/// Special built-in tool name for rumdl's own markdown linting.
17/// When this tool is configured for markdown blocks, the processor skips
18/// external execution since it's handled by embedded markdown linting.
19pub const RUMDL_BUILTIN_TOOL: &str = "rumdl";
20
21/// Check if a language is markdown (handles common variations).
22fn is_markdown_language(lang: &str) -> bool {
23    matches!(lang.to_lowercase().as_str(), "markdown" | "md")
24}
25
26/// Strip ANSI escape sequences from tool output.
27///
28/// Many tools output colored text (e.g. `\x1b[1;31mError\x1b[0m`), which prevents
29/// structured parsers from matching patterns like `file:line:col: message`.
30fn strip_ansi_codes(s: &str) -> String {
31    let mut result = String::with_capacity(s.len());
32    let mut chars = s.chars().peekable();
33    while let Some(c) = chars.next() {
34        if c == '\x1b' {
35            if chars.peek() == Some(&'[') {
36                chars.next();
37                // Consume until we hit an ASCII letter (the terminator)
38                while let Some(&next) = chars.peek() {
39                    chars.next();
40                    if next.is_ascii_alphabetic() {
41                        break;
42                    }
43                }
44            }
45        } else {
46            result.push(c);
47        }
48    }
49    result
50}
51
52/// Information about a fenced code block for processing.
53#[derive(Debug, Clone)]
54pub struct FencedCodeBlockInfo {
55    /// 0-indexed line number where opening fence starts.
56    pub start_line: usize,
57    /// 0-indexed line number where closing fence ends.
58    pub end_line: usize,
59    /// Byte offset where code content starts (after opening fence line).
60    pub content_start: usize,
61    /// Byte offset where code content ends (before closing fence line).
62    pub content_end: usize,
63    /// Language tag extracted from info string (first token).
64    pub language: String,
65    /// Full info string from the fence.
66    pub info_string: String,
67    /// The fence character used (` or ~).
68    pub fence_char: char,
69    /// Length of the fence (3 or more).
70    pub fence_length: usize,
71    /// Leading whitespace on the fence line.
72    pub indent: usize,
73    /// Exact leading whitespace prefix from the fence line.
74    pub indent_prefix: String,
75}
76
77/// A diagnostic message from an external tool.
78#[derive(Debug, Clone)]
79pub struct CodeBlockDiagnostic {
80    /// Line number in the original markdown file (1-indexed).
81    pub file_line: usize,
82    /// Column number (1-indexed, if available).
83    pub column: Option<usize>,
84    /// Message from the tool.
85    pub message: String,
86    /// Severity (error, warning, info).
87    pub severity: DiagnosticSeverity,
88    /// Name of the tool that produced this.
89    pub tool: String,
90    /// Line where the code block starts (1-indexed, for context).
91    pub code_block_start: usize,
92}
93
94/// Severity level for diagnostics.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum DiagnosticSeverity {
97    Error,
98    Warning,
99    Info,
100}
101
102impl CodeBlockDiagnostic {
103    /// Convert to a LintWarning for integration with rumdl's warning system.
104    pub fn to_lint_warning(&self) -> LintWarning {
105        let severity = match self.severity {
106            DiagnosticSeverity::Error => Severity::Error,
107            DiagnosticSeverity::Warning => Severity::Warning,
108            DiagnosticSeverity::Info => Severity::Info,
109        };
110
111        LintWarning {
112            message: self.message.clone(),
113            line: self.file_line,
114            column: self.column.unwrap_or(1),
115            end_line: self.file_line,
116            end_column: self.column.unwrap_or(1),
117            severity,
118            fix: None, // External tool diagnostics don't provide fixes
119            rule_name: Some(self.tool.clone()),
120        }
121    }
122}
123
124/// Error during code block processing.
125#[derive(Debug, Clone)]
126pub enum ProcessorError {
127    /// Tool execution failed.
128    ToolError(ExecutorError),
129    /// No tools configured for language.
130    NoToolsConfigured { language: String },
131    /// Tool binary not found.
132    ToolBinaryNotFound { tool: String, language: String },
133    /// Processing was aborted due to on_error = fail.
134    Aborted { message: String },
135}
136
137impl std::fmt::Display for ProcessorError {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        match self {
140            Self::ToolError(e) => write!(f, "{e}"),
141            Self::NoToolsConfigured { language } => {
142                write!(f, "No tools configured for language '{language}'")
143            }
144            Self::ToolBinaryNotFound { tool, language } => {
145                write!(f, "Tool '{tool}' binary not found for language '{language}'")
146            }
147            Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
148        }
149    }
150}
151
152impl std::error::Error for ProcessorError {}
153
154impl From<ExecutorError> for ProcessorError {
155    fn from(e: ExecutorError) -> Self {
156        Self::ToolError(e)
157    }
158}
159
160/// Result of processing a single code block.
161#[derive(Debug)]
162pub struct CodeBlockResult {
163    /// Diagnostics from linting.
164    pub diagnostics: Vec<CodeBlockDiagnostic>,
165    /// Formatted content (if formatting was requested and succeeded).
166    pub formatted_content: Option<String>,
167    /// Whether the code block was modified.
168    pub was_modified: bool,
169}
170
171/// Result of formatting code blocks in a document.
172#[derive(Debug)]
173pub struct FormatOutput {
174    /// The formatted content (may be partially formatted if errors occurred).
175    pub content: String,
176    /// Whether any errors occurred during formatting.
177    pub had_errors: bool,
178    /// Error messages for blocks that couldn't be formatted.
179    pub error_messages: Vec<String>,
180}
181
182/// Main processor for code block tools.
183pub struct CodeBlockToolProcessor<'a> {
184    config: &'a CodeBlockToolsConfig,
185    flavor: MarkdownFlavor,
186    linguist: LinguistResolver,
187    registry: ToolRegistry,
188    executor: ToolExecutor,
189    user_aliases: std::collections::HashMap<String, String>,
190}
191
192impl<'a> CodeBlockToolProcessor<'a> {
193    /// Create a new processor with the given configuration and markdown flavor.
194    pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
195        let user_aliases = config
196            .language_aliases
197            .iter()
198            .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
199            .collect();
200        Self {
201            config,
202            flavor,
203            linguist: LinguistResolver::new(),
204            registry: ToolRegistry::new(config.tools.clone()),
205            executor: ToolExecutor::new(config.timeout),
206            user_aliases,
207        }
208    }
209
210    /// Extract all fenced code blocks from content.
211    pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
212        let mut blocks = Vec::new();
213        let mut current_block: Option<FencedCodeBlockBuilder> = None;
214
215        let options = Options::all();
216        let parser = Parser::new_ext(content, options).into_offset_iter();
217
218        let lines: Vec<&str> = content.lines().collect();
219
220        for (event, range) in parser {
221            match event {
222                Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
223                    let info_string = info.to_string();
224                    let language = info_string.split_whitespace().next().unwrap_or("").to_string();
225
226                    // Find start line
227                    let start_line = content[..range.start].chars().filter(|&c| c == '\n').count();
228
229                    // Find content start (after opening fence line)
230                    let content_start = content[range.start..]
231                        .find('\n')
232                        .map(|i| range.start + i + 1)
233                        .unwrap_or(content.len());
234
235                    // Detect fence character and length from the line
236                    let fence_line = lines.get(start_line).unwrap_or(&"");
237                    let trimmed = fence_line.trim_start();
238                    let indent = fence_line.len() - trimmed.len();
239                    let indent_prefix = fence_line.get(..indent).unwrap_or("").to_string();
240                    let (fence_char, fence_length) = if trimmed.starts_with('~') {
241                        ('~', trimmed.chars().take_while(|&c| c == '~').count())
242                    } else {
243                        ('`', trimmed.chars().take_while(|&c| c == '`').count())
244                    };
245
246                    current_block = Some(FencedCodeBlockBuilder {
247                        start_line,
248                        content_start,
249                        language,
250                        info_string,
251                        fence_char,
252                        fence_length,
253                        indent,
254                        indent_prefix,
255                    });
256                }
257                Event::End(TagEnd::CodeBlock) => {
258                    if let Some(builder) = current_block.take() {
259                        // Find end line
260                        let end_line = content[..range.end].chars().filter(|&c| c == '\n').count();
261
262                        // Find content end (before closing fence line)
263                        let search_start = builder.content_start.min(range.end);
264                        let content_end = if search_start < range.end {
265                            content[search_start..range.end]
266                                .rfind('\n')
267                                .map(|i| search_start + i)
268                                .unwrap_or(search_start)
269                        } else {
270                            search_start
271                        };
272
273                        if content_end >= builder.content_start {
274                            blocks.push(FencedCodeBlockInfo {
275                                start_line: builder.start_line,
276                                end_line,
277                                content_start: builder.content_start,
278                                content_end,
279                                language: builder.language,
280                                info_string: builder.info_string,
281                                fence_char: builder.fence_char,
282                                fence_length: builder.fence_length,
283                                indent: builder.indent,
284                                indent_prefix: builder.indent_prefix,
285                            });
286                        }
287                    }
288                }
289                _ => {}
290            }
291        }
292
293        // For MkDocs flavor, also extract code blocks inside admonitions and tabs
294        if self.flavor == MarkdownFlavor::MkDocs {
295            let mkdocs_blocks = self.extract_mkdocs_code_blocks(content);
296            for mb in mkdocs_blocks {
297                // Deduplicate: only add if no existing block starts at the same line
298                if !blocks.iter().any(|b| b.start_line == mb.start_line) {
299                    blocks.push(mb);
300                }
301            }
302            blocks.sort_by_key(|b| b.start_line);
303        }
304
305        blocks
306    }
307
308    /// Extract fenced code blocks that are inside MkDocs admonitions or tabs.
309    ///
310    /// pulldown_cmark doesn't parse MkDocs-specific constructs, so indented
311    /// code blocks inside `!!!`/`???` admonitions or `===` tabs are missed.
312    /// This method manually scans for them.
313    fn extract_mkdocs_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
314        use crate::utils::mkdocs_admonitions;
315        use crate::utils::mkdocs_tabs;
316
317        let mut blocks = Vec::new();
318        let lines: Vec<&str> = content.lines().collect();
319
320        // Track current MkDocs context indent level
321        // We only need to know if we're inside any MkDocs block, so a simple stack suffices.
322        let mut context_indent_stack: Vec<usize> = Vec::new();
323
324        // Track fence state inside MkDocs context
325        let mut in_fence = false;
326        let mut fence_start_line: usize = 0;
327        let mut fence_content_start: usize = 0;
328        let mut fence_char: char = '`';
329        let mut fence_length: usize = 0;
330        let mut fence_indent: usize = 0;
331        let mut fence_indent_prefix = String::new();
332        let mut fence_language = String::new();
333        let mut fence_info_string = String::new();
334
335        // Compute byte offsets via pointer arithmetic.
336        // `content.lines()` returns slices into the original string,
337        // so each line's pointer offset from `content` gives its byte position.
338        // This correctly handles \n, \r\n, and empty lines.
339        let content_start_ptr = content.as_ptr() as usize;
340        let line_offsets: Vec<usize> = lines
341            .iter()
342            .map(|line| line.as_ptr() as usize - content_start_ptr)
343            .collect();
344
345        for (i, line) in lines.iter().enumerate() {
346            let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
347            let is_admonition = mkdocs_admonitions::is_admonition_start(line);
348            let is_tab = mkdocs_tabs::is_tab_marker(line);
349
350            // Pop contexts when the current line is not indented enough to be content.
351            // This runs for ALL lines (including new admonition/tab starts) to clean
352            // up stale entries before potentially pushing a new context.
353            if !line.trim().is_empty() {
354                while let Some(&ctx_indent) = context_indent_stack.last() {
355                    if line_indent < ctx_indent + 4 {
356                        context_indent_stack.pop();
357                        if in_fence {
358                            in_fence = false;
359                        }
360                    } else {
361                        break;
362                    }
363                }
364            }
365
366            // Check for admonition start — push new context
367            if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
368                context_indent_stack.push(indent);
369                continue;
370            }
371
372            // Check for tab marker — push new context
373            if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
374                context_indent_stack.push(indent);
375                continue;
376            }
377
378            // Only look for fences inside a MkDocs context
379            if context_indent_stack.is_empty() {
380                continue;
381            }
382
383            let trimmed = line.trim_start();
384            let leading_spaces = line.len() - trimmed.len();
385
386            if !in_fence {
387                // Check for fence opening
388                let (fc, fl) = if trimmed.starts_with("```") {
389                    ('`', trimmed.chars().take_while(|&c| c == '`').count())
390                } else if trimmed.starts_with("~~~") {
391                    ('~', trimmed.chars().take_while(|&c| c == '~').count())
392                } else {
393                    continue;
394                };
395
396                if fl >= 3 {
397                    in_fence = true;
398                    fence_start_line = i;
399                    fence_char = fc;
400                    fence_length = fl;
401                    fence_indent = leading_spaces;
402                    fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
403
404                    let after_fence = &trimmed[fl..];
405                    fence_info_string = after_fence.trim().to_string();
406                    fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
407
408                    // Content starts at the next line's byte offset
409                    fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
410                }
411            } else {
412                // Check for fence closing
413                let is_closing = if fence_char == '`' {
414                    trimmed.starts_with("```")
415                        && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
416                        && trimmed.trim_start_matches('`').trim().is_empty()
417                } else {
418                    trimmed.starts_with("~~~")
419                        && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
420                        && trimmed.trim_start_matches('~').trim().is_empty()
421                };
422
423                if is_closing {
424                    let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
425
426                    if content_end >= fence_content_start {
427                        blocks.push(FencedCodeBlockInfo {
428                            start_line: fence_start_line,
429                            end_line: i,
430                            content_start: fence_content_start,
431                            content_end,
432                            language: fence_language.clone(),
433                            info_string: fence_info_string.clone(),
434                            fence_char,
435                            fence_length,
436                            indent: fence_indent,
437                            indent_prefix: fence_indent_prefix.clone(),
438                        });
439                    }
440
441                    in_fence = false;
442                }
443            }
444        }
445
446        blocks
447    }
448
449    /// Resolve a language tag to its canonical name.
450    fn resolve_language(&self, language: &str) -> String {
451        let lower = language.to_lowercase();
452        if let Some(mapped) = self.user_aliases.get(&lower) {
453            return mapped.clone();
454        }
455        match self.config.normalize_language {
456            NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
457            NormalizeLanguage::Exact => lower,
458        }
459    }
460
461    /// Get the effective on_error setting for a language.
462    fn get_on_error(&self, language: &str) -> OnError {
463        self.config
464            .languages
465            .get(language)
466            .and_then(|lc| lc.on_error)
467            .unwrap_or(self.config.on_error)
468    }
469
470    /// Strip the fence indentation prefix from each line of a code block.
471    fn strip_indent_from_block(&self, content: &str, indent_prefix: &str) -> String {
472        if indent_prefix.is_empty() {
473            return content.to_string();
474        }
475
476        let mut out = String::with_capacity(content.len());
477        for line in content.split_inclusive('\n') {
478            if let Some(stripped) = line.strip_prefix(indent_prefix) {
479                out.push_str(stripped);
480            } else {
481                out.push_str(line);
482            }
483        }
484        out
485    }
486
487    /// Re-apply the fence indentation prefix to each line of a code block.
488    fn apply_indent_to_block(&self, content: &str, indent_prefix: &str) -> String {
489        if indent_prefix.is_empty() {
490            return content.to_string();
491        }
492        if content.is_empty() {
493            return String::new();
494        }
495
496        let mut out = String::with_capacity(content.len() + indent_prefix.len());
497        for line in content.split_inclusive('\n') {
498            if line == "\n" {
499                out.push_str(line);
500            } else {
501                out.push_str(indent_prefix);
502                out.push_str(line);
503            }
504        }
505        out
506    }
507
508    /// Lint all code blocks in the content.
509    ///
510    /// Returns diagnostics from all configured linters.
511    pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
512        let mut all_diagnostics = Vec::new();
513        let blocks = self.extract_code_blocks(content);
514
515        for block in blocks {
516            if block.language.is_empty() {
517                continue; // Skip blocks without language tag
518            }
519
520            let canonical_lang = self.resolve_language(&block.language);
521
522            // Get lint tools for this language
523            let lang_config = self.config.languages.get(&canonical_lang);
524
525            // If language is explicitly configured with enabled=false, skip silently
526            if let Some(lc) = lang_config
527                && !lc.enabled
528            {
529                continue;
530            }
531
532            let lint_tools = match lang_config {
533                Some(lc) if !lc.lint.is_empty() => &lc.lint,
534                _ => {
535                    // No tools configured for this language in lint mode
536                    match self.config.on_missing_language_definition {
537                        OnMissing::Ignore => continue,
538                        OnMissing::Fail => {
539                            all_diagnostics.push(CodeBlockDiagnostic {
540                                file_line: block.start_line + 1,
541                                column: None,
542                                message: format!("No lint tools configured for language '{canonical_lang}'"),
543                                severity: DiagnosticSeverity::Error,
544                                tool: "code-block-tools".to_string(),
545                                code_block_start: block.start_line + 1,
546                            });
547                            continue;
548                        }
549                        OnMissing::FailFast => {
550                            return Err(ProcessorError::NoToolsConfigured {
551                                language: canonical_lang,
552                            });
553                        }
554                    }
555                }
556            };
557
558            // Extract code block content
559            let code_content_raw = if block.content_start < block.content_end && block.content_end <= content.len() {
560                &content[block.content_start..block.content_end]
561            } else {
562                continue;
563            };
564            let code_content = self.strip_indent_from_block(code_content_raw, &block.indent_prefix);
565
566            // Run each lint tool
567            for tool_id in lint_tools {
568                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown linting
569                if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
570                    continue;
571                }
572
573                let tool_def = match self.registry.get(tool_id) {
574                    Some(t) => t,
575                    None => {
576                        log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
577                        continue;
578                    }
579                };
580
581                // Check if tool binary exists before running
582                let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
583                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
584                    match self.config.on_missing_tool_binary {
585                        OnMissing::Ignore => {
586                            log::debug!("Tool binary '{tool_name}' not found, skipping");
587                            continue;
588                        }
589                        OnMissing::Fail => {
590                            all_diagnostics.push(CodeBlockDiagnostic {
591                                file_line: block.start_line + 1,
592                                column: None,
593                                message: format!("Tool binary '{tool_name}' not found in PATH"),
594                                severity: DiagnosticSeverity::Error,
595                                tool: "code-block-tools".to_string(),
596                                code_block_start: block.start_line + 1,
597                            });
598                            continue;
599                        }
600                        OnMissing::FailFast => {
601                            return Err(ProcessorError::ToolBinaryNotFound {
602                                tool: tool_name.to_string(),
603                                language: canonical_lang.clone(),
604                            });
605                        }
606                    }
607                }
608
609                match self.executor.lint(tool_def, &code_content, Some(self.config.timeout)) {
610                    Ok(output) => {
611                        // Parse tool output into diagnostics
612                        let diagnostics = self.parse_tool_output(
613                            &output,
614                            tool_id,
615                            block.start_line + 1, // Convert to 1-indexed
616                        );
617                        all_diagnostics.extend(diagnostics);
618                    }
619                    Err(e) => {
620                        let on_error = self.get_on_error(&canonical_lang);
621                        match on_error {
622                            OnError::Fail => return Err(e.into()),
623                            OnError::Warn => {
624                                log::warn!("Tool '{tool_id}' failed: {e}");
625                            }
626                            OnError::Skip => {
627                                // Silently skip
628                            }
629                        }
630                    }
631                }
632            }
633        }
634
635        Ok(all_diagnostics)
636    }
637
638    /// Format all code blocks in the content.
639    ///
640    /// Returns the modified content with formatted code blocks and any errors that occurred.
641    /// With `on-missing-*` = `fail`, errors are collected but formatting continues.
642    /// With `on-missing-*` = `fail-fast`, returns Err immediately on first error.
643    pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
644        let blocks = self.extract_code_blocks(content);
645
646        if blocks.is_empty() {
647            return Ok(FormatOutput {
648                content: content.to_string(),
649                had_errors: false,
650                error_messages: Vec::new(),
651            });
652        }
653
654        // Process blocks in reverse order to maintain byte offsets
655        let mut result = content.to_string();
656        let mut error_messages: Vec<String> = Vec::new();
657
658        for block in blocks.into_iter().rev() {
659            if block.language.is_empty() {
660                continue;
661            }
662
663            let canonical_lang = self.resolve_language(&block.language);
664
665            // Get format tools for this language
666            let lang_config = self.config.languages.get(&canonical_lang);
667
668            // If language is explicitly configured with enabled=false, skip silently
669            if let Some(lc) = lang_config
670                && !lc.enabled
671            {
672                continue;
673            }
674
675            let format_tools = match lang_config {
676                Some(lc) if !lc.format.is_empty() => &lc.format,
677                _ => {
678                    // No tools configured for this language in format mode
679                    match self.config.on_missing_language_definition {
680                        OnMissing::Ignore => continue,
681                        OnMissing::Fail => {
682                            error_messages.push(format!(
683                                "No format tools configured for language '{canonical_lang}' at line {}",
684                                block.start_line + 1
685                            ));
686                            continue;
687                        }
688                        OnMissing::FailFast => {
689                            return Err(ProcessorError::NoToolsConfigured {
690                                language: canonical_lang,
691                            });
692                        }
693                    }
694                }
695            };
696
697            // Extract code block content
698            if block.content_start >= block.content_end || block.content_end > result.len() {
699                continue;
700            }
701            let code_content_raw = result[block.content_start..block.content_end].to_string();
702            let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
703
704            // Run format tools (use first successful one)
705            let mut formatted = code_content.clone();
706            let mut tool_ran = false;
707            for tool_id in format_tools {
708                // Skip built-in "rumdl" tool for markdown - handled separately by embedded markdown formatting
709                if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
710                    continue;
711                }
712
713                let tool_def = match self.registry.get(tool_id) {
714                    Some(t) => t,
715                    None => {
716                        log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
717                        continue;
718                    }
719                };
720
721                // Check if tool binary exists before running
722                let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
723                if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
724                    match self.config.on_missing_tool_binary {
725                        OnMissing::Ignore => {
726                            log::debug!("Tool binary '{tool_name}' not found, skipping");
727                            continue;
728                        }
729                        OnMissing::Fail => {
730                            error_messages.push(format!(
731                                "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
732                                block.start_line + 1
733                            ));
734                            continue;
735                        }
736                        OnMissing::FailFast => {
737                            return Err(ProcessorError::ToolBinaryNotFound {
738                                tool: tool_name.to_string(),
739                                language: canonical_lang.clone(),
740                            });
741                        }
742                    }
743                }
744
745                match self.executor.format(tool_def, &formatted, Some(self.config.timeout)) {
746                    Ok(output) => {
747                        // Ensure trailing newline matches original (unindented)
748                        formatted = output;
749                        if code_content.ends_with('\n') && !formatted.ends_with('\n') {
750                            formatted.push('\n');
751                        } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
752                            formatted.pop();
753                        }
754                        tool_ran = true;
755                        break; // Use first successful formatter
756                    }
757                    Err(e) => {
758                        let on_error = self.get_on_error(&canonical_lang);
759                        match on_error {
760                            OnError::Fail => return Err(e.into()),
761                            OnError::Warn => {
762                                log::warn!("Formatter '{tool_id}' failed: {e}");
763                            }
764                            OnError::Skip => {}
765                        }
766                    }
767                }
768            }
769
770            // Replace content if changed and a tool actually ran
771            if tool_ran && formatted != code_content {
772                let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
773                if reindented != code_content_raw {
774                    result.replace_range(block.content_start..block.content_end, &reindented);
775                }
776            }
777        }
778
779        Ok(FormatOutput {
780            content: result,
781            had_errors: !error_messages.is_empty(),
782            error_messages,
783        })
784    }
785
786    /// Parse tool output into diagnostics.
787    ///
788    /// This is a basic parser that handles common output formats.
789    /// Tools vary widely in their output format, so this is best-effort.
790    fn parse_tool_output(
791        &self,
792        output: &ToolOutput,
793        tool_id: &str,
794        code_block_start_line: usize,
795    ) -> Vec<CodeBlockDiagnostic> {
796        let mut diagnostics = Vec::new();
797        let mut shellcheck_line: Option<usize> = None;
798
799        // Strip ANSI escape codes and combine stdout + stderr for parsing
800        let stdout_clean = strip_ansi_codes(&output.stdout);
801        let stderr_clean = strip_ansi_codes(&output.stderr);
802        let combined = format!("{stdout_clean}\n{stderr_clean}");
803
804        // State for multi-line "Error: msg" / "at line N column M" pattern
805        let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
806
807        for line in combined.lines() {
808            let line = line.trim();
809            if line.is_empty() {
810                continue;
811            }
812
813            // Resolve pending "Error: msg" from previous line
814            if let Some((ref msg, severity)) = pending_error {
815                if let Some((line_num, col)) = Self::parse_at_line_column(line) {
816                    diagnostics.push(CodeBlockDiagnostic {
817                        file_line: code_block_start_line + line_num,
818                        column: Some(col),
819                        message: msg.clone(),
820                        severity,
821                        tool: tool_id.to_string(),
822                        code_block_start: code_block_start_line,
823                    });
824                    pending_error = None;
825                    continue;
826                }
827                // No position info found; emit error without line mapping
828                diagnostics.push(CodeBlockDiagnostic {
829                    file_line: code_block_start_line,
830                    column: None,
831                    message: msg.clone(),
832                    severity,
833                    tool: tool_id.to_string(),
834                    code_block_start: code_block_start_line,
835                });
836                pending_error = None;
837                // Fall through to parse current line
838            }
839
840            if let Some(line_num) = self.parse_shellcheck_header(line) {
841                shellcheck_line = Some(line_num);
842                continue;
843            }
844
845            if let Some(line_num) = shellcheck_line
846                && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
847            {
848                diagnostics.push(diag);
849                continue;
850            }
851
852            // Try pattern: "file:line:col: message" or "file:line: message"
853            if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
854                diagnostics.push(diag);
855                continue;
856            }
857
858            // Try pattern: "line:col message" (eslint style)
859            if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
860                diagnostics.push(diag);
861                continue;
862            }
863
864            // Try single-line shellcheck format fallback
865            if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
866                diagnostics.push(diag);
867                continue;
868            }
869
870            // Try multi-line "Error: msg" / "Warning: msg" pattern
871            if let Some(error_info) = Self::parse_error_line(line) {
872                pending_error = Some(error_info);
873            }
874        }
875
876        // Flush any remaining pending error
877        if let Some((msg, severity)) = pending_error {
878            diagnostics.push(CodeBlockDiagnostic {
879                file_line: code_block_start_line,
880                column: None,
881                message: msg,
882                severity,
883                tool: tool_id.to_string(),
884                code_block_start: code_block_start_line,
885            });
886        }
887
888        // If no diagnostics parsed but tool failed, use combined output as fallback
889        if diagnostics.is_empty() && !output.success {
890            let lines: Vec<&str> = combined.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
891
892            if lines.is_empty() {
893                let exit_code = output.exit_code;
894                diagnostics.push(CodeBlockDiagnostic {
895                    file_line: code_block_start_line,
896                    column: None,
897                    message: format!("Tool exited with code {exit_code}"),
898                    severity: DiagnosticSeverity::Error,
899                    tool: tool_id.to_string(),
900                    code_block_start: code_block_start_line,
901                });
902            } else {
903                for line_text in lines {
904                    diagnostics.push(CodeBlockDiagnostic {
905                        file_line: code_block_start_line,
906                        column: None,
907                        message: line_text.to_string(),
908                        severity: DiagnosticSeverity::Error,
909                        tool: tool_id.to_string(),
910                        code_block_start: code_block_start_line,
911                    });
912                }
913            }
914        }
915
916        diagnostics
917    }
918
919    /// Parse standard "file:line:col: message" format.
920    fn parse_standard_format(
921        &self,
922        line: &str,
923        tool_id: &str,
924        code_block_start_line: usize,
925    ) -> Option<CodeBlockDiagnostic> {
926        // Match patterns like "file.py:1:10: E501 message"
927        let mut parts = line.rsplitn(4, ':');
928        let message = parts.next()?.trim().to_string();
929        let part1 = parts.next()?.trim().to_string();
930        let part2 = parts.next()?.trim().to_string();
931        let part3 = parts.next().map(|s| s.trim().to_string());
932
933        let (line_part, col_part) = if part3.is_some() {
934            (part2, Some(part1))
935        } else {
936            (part1, None)
937        };
938
939        if let Ok(line_num) = line_part.parse::<usize>() {
940            let column = col_part.and_then(|s| s.parse::<usize>().ok());
941            let message = Self::strip_fixable_markers(&message);
942            if !message.is_empty() {
943                let severity = self.infer_severity(&message);
944                return Some(CodeBlockDiagnostic {
945                    file_line: code_block_start_line + line_num,
946                    column,
947                    message,
948                    severity,
949                    tool: tool_id.to_string(),
950                    code_block_start: code_block_start_line,
951                });
952            }
953        }
954        None
955    }
956
957    /// Parse eslint-style "line:col severity message" format.
958    fn parse_eslint_format(
959        &self,
960        line: &str,
961        tool_id: &str,
962        code_block_start_line: usize,
963    ) -> Option<CodeBlockDiagnostic> {
964        // Match "1:10 error Message"
965        let parts: Vec<&str> = line.splitn(3, ' ').collect();
966        if parts.len() >= 2 {
967            let loc_parts: Vec<&str> = parts[0].split(':').collect();
968            if loc_parts.len() == 2
969                && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
970            {
971                let (sev_part, msg_part) = if parts.len() >= 3 {
972                    (parts[1], parts[2])
973                } else {
974                    (parts[1], "")
975                };
976                let message = if msg_part.is_empty() {
977                    sev_part.to_string()
978                } else {
979                    msg_part.to_string()
980                };
981                let message = Self::strip_fixable_markers(&message);
982                let severity = match sev_part.to_lowercase().as_str() {
983                    "error" => DiagnosticSeverity::Error,
984                    "warning" | "warn" => DiagnosticSeverity::Warning,
985                    "info" => DiagnosticSeverity::Info,
986                    _ => self.infer_severity(&message),
987                };
988                return Some(CodeBlockDiagnostic {
989                    file_line: code_block_start_line + line_num,
990                    column: Some(col),
991                    message,
992                    severity,
993                    tool: tool_id.to_string(),
994                    code_block_start: code_block_start_line,
995                });
996            }
997        }
998        None
999    }
1000
1001    /// Parse shellcheck-style "In - line N: message" format.
1002    fn parse_shellcheck_format(
1003        &self,
1004        line: &str,
1005        tool_id: &str,
1006        code_block_start_line: usize,
1007    ) -> Option<CodeBlockDiagnostic> {
1008        // Match "In - line 5:" pattern
1009        if line.starts_with("In ")
1010            && line.contains(" line ")
1011            && let Some(line_start) = line.find(" line ")
1012        {
1013            let after_line = &line[line_start + 6..];
1014            if let Some(colon_pos) = after_line.find(':')
1015                && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1016            {
1017                let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1018                if !message.is_empty() {
1019                    let severity = self.infer_severity(&message);
1020                    return Some(CodeBlockDiagnostic {
1021                        file_line: code_block_start_line + line_num,
1022                        column: None,
1023                        message,
1024                        severity,
1025                        tool: tool_id.to_string(),
1026                        code_block_start: code_block_start_line,
1027                    });
1028                }
1029            }
1030        }
1031        None
1032    }
1033
1034    /// Parse shellcheck header line to capture line number context.
1035    fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1036        if line.starts_with("In ")
1037            && line.contains(" line ")
1038            && let Some(line_start) = line.find(" line ")
1039        {
1040            let after_line = &line[line_start + 6..];
1041            if let Some(colon_pos) = after_line.find(':') {
1042                return after_line[..colon_pos].trim().parse::<usize>().ok();
1043            }
1044        }
1045        None
1046    }
1047
1048    /// Parse shellcheck message line containing SCXXXX codes.
1049    fn parse_shellcheck_message(
1050        &self,
1051        line: &str,
1052        tool_id: &str,
1053        code_block_start_line: usize,
1054        line_num: usize,
1055    ) -> Option<CodeBlockDiagnostic> {
1056        let sc_pos = line.find("SC")?;
1057        let after_sc = &line[sc_pos + 2..];
1058        let code_len = after_sc.chars().take_while(|c| c.is_ascii_digit()).count();
1059        if code_len == 0 {
1060            return None;
1061        }
1062        let after_code = &after_sc[code_len..];
1063        let sev_start = after_code.find('(')? + 1;
1064        let sev_end = after_code[sev_start..].find(')')? + sev_start;
1065        let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1066        let message_start = after_code.find("):")? + 2;
1067        let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1068        if message.is_empty() {
1069            return None;
1070        }
1071
1072        let severity = match sev.as_str() {
1073            "error" => DiagnosticSeverity::Error,
1074            "warning" | "warn" => DiagnosticSeverity::Warning,
1075            "info" | "style" => DiagnosticSeverity::Info,
1076            _ => self.infer_severity(&message),
1077        };
1078
1079        Some(CodeBlockDiagnostic {
1080            file_line: code_block_start_line + line_num,
1081            column: None,
1082            message,
1083            severity,
1084            tool: tool_id.to_string(),
1085            code_block_start: code_block_start_line,
1086        })
1087    }
1088
1089    /// Parse "Error: <message>" or "Warning: <message>" lines.
1090    ///
1091    /// Used for tools like tombi that output multi-line diagnostics where the
1092    /// error message and position are on separate lines. Only matches capitalized
1093    /// prefixes to avoid conflicting with lowercase `error:` in less structured output.
1094    fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1095        let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1096            (msg, DiagnosticSeverity::Error)
1097        } else if let Some(msg) = line.strip_prefix("Warning:") {
1098            (msg, DiagnosticSeverity::Warning)
1099        } else {
1100            return None;
1101        };
1102        let msg = msg.trim();
1103        if msg.is_empty() {
1104            return None;
1105        }
1106        Some((msg.to_string(), severity))
1107    }
1108
1109    /// Parse "at line N column M" position lines (case-insensitive).
1110    ///
1111    /// Returns (line_number, column_number) if the pattern matches.
1112    fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1113        let lower = line.to_lowercase();
1114        let rest = lower.strip_prefix("at line ")?;
1115        let mut parts = rest.split_whitespace();
1116        let line_num: usize = parts.next()?.parse().ok()?;
1117        if parts.next()? != "column" {
1118            return None;
1119        }
1120        let col: usize = parts.next()?.parse().ok()?;
1121        Some((line_num, col))
1122    }
1123
1124    /// Infer severity from message content.
1125    fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1126        let lower = message.to_lowercase();
1127        if lower.contains("error")
1128            || lower.starts_with("e") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1129            || lower.starts_with("f") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1130        {
1131            DiagnosticSeverity::Error
1132        } else if lower.contains("warning")
1133            || lower.contains("warn")
1134            || lower.starts_with("w") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1135        {
1136            DiagnosticSeverity::Warning
1137        } else {
1138            DiagnosticSeverity::Info
1139        }
1140    }
1141
1142    /// Strip "fixable" markers from external tool messages.
1143    ///
1144    /// External tools like ruff show `[*]` to indicate fixable issues, but in rumdl's
1145    /// context these markers can be misleading - the lint tool's fix capability may
1146    /// differ from what our configured formatter can fix. We strip these markers
1147    /// to avoid making promises we can't keep.
1148    fn strip_fixable_markers(message: &str) -> String {
1149        message
1150            .replace(" [*]", "")
1151            .replace("[*] ", "")
1152            .replace("[*]", "")
1153            .replace(" (fixable)", "")
1154            .replace("(fixable) ", "")
1155            .replace("(fixable)", "")
1156            .replace(" [fix available]", "")
1157            .replace("[fix available] ", "")
1158            .replace("[fix available]", "")
1159            .replace(" [autofix]", "")
1160            .replace("[autofix] ", "")
1161            .replace("[autofix]", "")
1162            .trim()
1163            .to_string()
1164    }
1165}
1166
1167/// Builder for FencedCodeBlockInfo during parsing.
1168struct FencedCodeBlockBuilder {
1169    start_line: usize,
1170    content_start: usize,
1171    language: String,
1172    info_string: String,
1173    fence_char: char,
1174    fence_length: usize,
1175    indent: usize,
1176    indent_prefix: String,
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181    use super::*;
1182
1183    fn default_config() -> CodeBlockToolsConfig {
1184        CodeBlockToolsConfig::default()
1185    }
1186
1187    #[test]
1188    fn test_extract_code_blocks() {
1189        let config = default_config();
1190        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1191
1192        let content = r#"# Example
1193
1194```python
1195def hello():
1196    print("Hello")
1197```
1198
1199Some text
1200
1201```rust
1202fn main() {}
1203```
1204"#;
1205
1206        let blocks = processor.extract_code_blocks(content);
1207
1208        assert_eq!(blocks.len(), 2);
1209
1210        assert_eq!(blocks[0].language, "python");
1211        assert_eq!(blocks[0].fence_char, '`');
1212        assert_eq!(blocks[0].fence_length, 3);
1213        assert_eq!(blocks[0].start_line, 2);
1214        assert_eq!(blocks[0].indent, 0);
1215        assert_eq!(blocks[0].indent_prefix, "");
1216
1217        assert_eq!(blocks[1].language, "rust");
1218        assert_eq!(blocks[1].fence_char, '`');
1219        assert_eq!(blocks[1].fence_length, 3);
1220    }
1221
1222    #[test]
1223    fn test_extract_code_blocks_with_info_string() {
1224        let config = default_config();
1225        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1226
1227        let content = "```python title=\"example.py\"\ncode\n```";
1228        let blocks = processor.extract_code_blocks(content);
1229
1230        assert_eq!(blocks.len(), 1);
1231        assert_eq!(blocks[0].language, "python");
1232        assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1233    }
1234
1235    #[test]
1236    fn test_extract_code_blocks_tilde_fence() {
1237        let config = default_config();
1238        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1239
1240        let content = "~~~bash\necho hello\n~~~";
1241        let blocks = processor.extract_code_blocks(content);
1242
1243        assert_eq!(blocks.len(), 1);
1244        assert_eq!(blocks[0].language, "bash");
1245        assert_eq!(blocks[0].fence_char, '~');
1246        assert_eq!(blocks[0].fence_length, 3);
1247        assert_eq!(blocks[0].indent_prefix, "");
1248    }
1249
1250    #[test]
1251    fn test_extract_code_blocks_with_indent_prefix() {
1252        let config = default_config();
1253        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1254
1255        let content = "  - item\n    ```python\n    print('hi')\n    ```";
1256        let blocks = processor.extract_code_blocks(content);
1257
1258        assert_eq!(blocks.len(), 1);
1259        assert_eq!(blocks[0].indent_prefix, "    ");
1260    }
1261
1262    #[test]
1263    fn test_extract_code_blocks_no_language() {
1264        let config = default_config();
1265        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1266
1267        let content = "```\nplain code\n```";
1268        let blocks = processor.extract_code_blocks(content);
1269
1270        assert_eq!(blocks.len(), 1);
1271        assert_eq!(blocks[0].language, "");
1272    }
1273
1274    #[test]
1275    fn test_resolve_language_linguist() {
1276        let mut config = default_config();
1277        config.normalize_language = NormalizeLanguage::Linguist;
1278        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1279
1280        assert_eq!(processor.resolve_language("py"), "python");
1281        assert_eq!(processor.resolve_language("bash"), "shell");
1282        assert_eq!(processor.resolve_language("js"), "javascript");
1283    }
1284
1285    #[test]
1286    fn test_resolve_language_exact() {
1287        let mut config = default_config();
1288        config.normalize_language = NormalizeLanguage::Exact;
1289        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1290
1291        assert_eq!(processor.resolve_language("py"), "py");
1292        assert_eq!(processor.resolve_language("BASH"), "bash");
1293    }
1294
1295    #[test]
1296    fn test_resolve_language_user_alias_override() {
1297        let mut config = default_config();
1298        config.language_aliases.insert("py".to_string(), "python".to_string());
1299        config.normalize_language = NormalizeLanguage::Exact;
1300        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1301
1302        assert_eq!(processor.resolve_language("PY"), "python");
1303    }
1304
1305    #[test]
1306    fn test_indent_strip_and_reapply_roundtrip() {
1307        let config = default_config();
1308        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1309
1310        let raw = "    def hello():\n        print('hi')";
1311        let stripped = processor.strip_indent_from_block(raw, "    ");
1312        assert_eq!(stripped, "def hello():\n    print('hi')");
1313
1314        let reapplied = processor.apply_indent_to_block(&stripped, "    ");
1315        assert_eq!(reapplied, raw);
1316    }
1317
1318    #[test]
1319    fn test_infer_severity() {
1320        let config = default_config();
1321        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1322
1323        assert_eq!(
1324            processor.infer_severity("E501 line too long"),
1325            DiagnosticSeverity::Error
1326        );
1327        assert_eq!(
1328            processor.infer_severity("W291 trailing whitespace"),
1329            DiagnosticSeverity::Warning
1330        );
1331        assert_eq!(
1332            processor.infer_severity("error: something failed"),
1333            DiagnosticSeverity::Error
1334        );
1335        assert_eq!(
1336            processor.infer_severity("warning: unused variable"),
1337            DiagnosticSeverity::Warning
1338        );
1339        assert_eq!(
1340            processor.infer_severity("note: consider using"),
1341            DiagnosticSeverity::Info
1342        );
1343    }
1344
1345    #[test]
1346    fn test_parse_standard_format_windows_path() {
1347        let config = default_config();
1348        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1349
1350        let output = ToolOutput {
1351            stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1352            stderr: String::new(),
1353            exit_code: 1,
1354            success: false,
1355        };
1356
1357        let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1358        assert_eq!(diags.len(), 1);
1359        assert_eq!(diags[0].file_line, 12);
1360        assert_eq!(diags[0].column, Some(5));
1361        assert_eq!(diags[0].message, "E123 message");
1362    }
1363
1364    #[test]
1365    fn test_parse_eslint_severity() {
1366        let config = default_config();
1367        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1368
1369        let output = ToolOutput {
1370            stdout: "1:2 error Unexpected token".to_string(),
1371            stderr: String::new(),
1372            exit_code: 1,
1373            success: false,
1374        };
1375
1376        let diags = processor.parse_tool_output(&output, "eslint", 5);
1377        assert_eq!(diags.len(), 1);
1378        assert_eq!(diags[0].file_line, 6);
1379        assert_eq!(diags[0].column, Some(2));
1380        assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1381        assert_eq!(diags[0].message, "Unexpected token");
1382    }
1383
1384    #[test]
1385    fn test_parse_shellcheck_multiline() {
1386        let config = default_config();
1387        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1388
1389        let output = ToolOutput {
1390            stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1391            stderr: String::new(),
1392            exit_code: 1,
1393            success: false,
1394        };
1395
1396        let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1397        assert_eq!(diags.len(), 1);
1398        assert_eq!(diags[0].file_line, 13);
1399        assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1400        assert_eq!(diags[0].message, "Double quote to prevent globbing");
1401    }
1402
1403    #[test]
1404    fn test_lint_no_config() {
1405        let config = default_config();
1406        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1407
1408        let content = "```python\nprint('hello')\n```";
1409        let result = processor.lint(content);
1410
1411        // Should succeed with no diagnostics (no tools configured)
1412        assert!(result.is_ok());
1413        assert!(result.unwrap().is_empty());
1414    }
1415
1416    #[test]
1417    fn test_format_no_config() {
1418        let config = default_config();
1419        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1420
1421        let content = "```python\nprint('hello')\n```";
1422        let result = processor.format(content);
1423
1424        // Should succeed with unchanged content (no tools configured)
1425        assert!(result.is_ok());
1426        let output = result.unwrap();
1427        assert_eq!(output.content, content);
1428        assert!(!output.had_errors);
1429        assert!(output.error_messages.is_empty());
1430    }
1431
1432    #[test]
1433    fn test_lint_on_missing_language_definition_fail() {
1434        let mut config = default_config();
1435        config.on_missing_language_definition = OnMissing::Fail;
1436        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1437
1438        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1439        let result = processor.lint(content);
1440
1441        // Should succeed but return diagnostics for both missing language definitions
1442        assert!(result.is_ok());
1443        let diagnostics = result.unwrap();
1444        assert_eq!(diagnostics.len(), 2);
1445        assert!(diagnostics[0].message.contains("No lint tools configured"));
1446        assert!(diagnostics[0].message.contains("python"));
1447        assert!(diagnostics[1].message.contains("javascript"));
1448    }
1449
1450    #[test]
1451    fn test_lint_on_missing_language_definition_fail_fast() {
1452        let mut config = default_config();
1453        config.on_missing_language_definition = OnMissing::FailFast;
1454        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1455
1456        let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1457        let result = processor.lint(content);
1458
1459        // Should fail immediately on first missing language
1460        assert!(result.is_err());
1461        let err = result.unwrap_err();
1462        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1463    }
1464
1465    #[test]
1466    fn test_format_on_missing_language_definition_fail() {
1467        let mut config = default_config();
1468        config.on_missing_language_definition = OnMissing::Fail;
1469        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1470
1471        let content = "```python\nprint('hello')\n```";
1472        let result = processor.format(content);
1473
1474        // Should succeed but report errors
1475        assert!(result.is_ok());
1476        let output = result.unwrap();
1477        assert_eq!(output.content, content); // Content unchanged
1478        assert!(output.had_errors);
1479        assert!(!output.error_messages.is_empty());
1480        assert!(output.error_messages[0].contains("No format tools configured"));
1481    }
1482
1483    #[test]
1484    fn test_format_on_missing_language_definition_fail_fast() {
1485        let mut config = default_config();
1486        config.on_missing_language_definition = OnMissing::FailFast;
1487        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1488
1489        let content = "```python\nprint('hello')\n```";
1490        let result = processor.format(content);
1491
1492        // Should fail immediately
1493        assert!(result.is_err());
1494        let err = result.unwrap_err();
1495        assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1496    }
1497
1498    #[test]
1499    fn test_lint_on_missing_tool_binary_fail() {
1500        use super::super::config::{LanguageToolConfig, ToolDefinition};
1501
1502        let mut config = default_config();
1503        config.on_missing_tool_binary = OnMissing::Fail;
1504
1505        // Configure a tool with a non-existent binary
1506        let lang_config = LanguageToolConfig {
1507            lint: vec!["nonexistent-linter".to_string()],
1508            ..Default::default()
1509        };
1510        config.languages.insert("python".to_string(), lang_config);
1511
1512        let tool_def = ToolDefinition {
1513            command: vec!["nonexistent-binary-xyz123".to_string()],
1514            ..Default::default()
1515        };
1516        config.tools.insert("nonexistent-linter".to_string(), tool_def);
1517
1518        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1519
1520        let content = "```python\nprint('hello')\n```";
1521        let result = processor.lint(content);
1522
1523        // Should succeed but return diagnostic for missing binary
1524        assert!(result.is_ok());
1525        let diagnostics = result.unwrap();
1526        assert_eq!(diagnostics.len(), 1);
1527        assert!(diagnostics[0].message.contains("not found in PATH"));
1528    }
1529
1530    #[test]
1531    fn test_lint_on_missing_tool_binary_fail_fast() {
1532        use super::super::config::{LanguageToolConfig, ToolDefinition};
1533
1534        let mut config = default_config();
1535        config.on_missing_tool_binary = OnMissing::FailFast;
1536
1537        // Configure a tool with a non-existent binary
1538        let lang_config = LanguageToolConfig {
1539            lint: vec!["nonexistent-linter".to_string()],
1540            ..Default::default()
1541        };
1542        config.languages.insert("python".to_string(), lang_config);
1543
1544        let tool_def = ToolDefinition {
1545            command: vec!["nonexistent-binary-xyz123".to_string()],
1546            ..Default::default()
1547        };
1548        config.tools.insert("nonexistent-linter".to_string(), tool_def);
1549
1550        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1551
1552        let content = "```python\nprint('hello')\n```";
1553        let result = processor.lint(content);
1554
1555        // Should fail immediately
1556        assert!(result.is_err());
1557        let err = result.unwrap_err();
1558        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1559    }
1560
1561    #[test]
1562    fn test_format_on_missing_tool_binary_fail() {
1563        use super::super::config::{LanguageToolConfig, ToolDefinition};
1564
1565        let mut config = default_config();
1566        config.on_missing_tool_binary = OnMissing::Fail;
1567
1568        // Configure a tool with a non-existent binary
1569        let lang_config = LanguageToolConfig {
1570            format: vec!["nonexistent-formatter".to_string()],
1571            ..Default::default()
1572        };
1573        config.languages.insert("python".to_string(), lang_config);
1574
1575        let tool_def = ToolDefinition {
1576            command: vec!["nonexistent-binary-xyz123".to_string()],
1577            ..Default::default()
1578        };
1579        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1580
1581        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1582
1583        let content = "```python\nprint('hello')\n```";
1584        let result = processor.format(content);
1585
1586        // Should succeed but report errors
1587        assert!(result.is_ok());
1588        let output = result.unwrap();
1589        assert_eq!(output.content, content); // Content unchanged
1590        assert!(output.had_errors);
1591        assert!(!output.error_messages.is_empty());
1592        assert!(output.error_messages[0].contains("not found in PATH"));
1593    }
1594
1595    #[test]
1596    fn test_format_on_missing_tool_binary_fail_fast() {
1597        use super::super::config::{LanguageToolConfig, ToolDefinition};
1598
1599        let mut config = default_config();
1600        config.on_missing_tool_binary = OnMissing::FailFast;
1601
1602        // Configure a tool with a non-existent binary
1603        let lang_config = LanguageToolConfig {
1604            format: vec!["nonexistent-formatter".to_string()],
1605            ..Default::default()
1606        };
1607        config.languages.insert("python".to_string(), lang_config);
1608
1609        let tool_def = ToolDefinition {
1610            command: vec!["nonexistent-binary-xyz123".to_string()],
1611            ..Default::default()
1612        };
1613        config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1614
1615        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1616
1617        let content = "```python\nprint('hello')\n```";
1618        let result = processor.format(content);
1619
1620        // Should fail immediately
1621        assert!(result.is_err());
1622        let err = result.unwrap_err();
1623        assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1624    }
1625
1626    #[test]
1627    fn test_lint_rumdl_builtin_skipped_for_markdown() {
1628        // Configure the built-in "rumdl" tool for markdown
1629        // The processor should skip it (handled by embedded markdown linting)
1630        let mut config = default_config();
1631        config.languages.insert(
1632            "markdown".to_string(),
1633            LanguageToolConfig {
1634                lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
1635                ..Default::default()
1636            },
1637        );
1638        config.on_missing_language_definition = OnMissing::Fail;
1639        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1640
1641        let content = "```markdown\n# Hello\n```";
1642        let result = processor.lint(content);
1643
1644        // Should succeed with no diagnostics - "rumdl" tool is skipped, not treated as unknown
1645        assert!(result.is_ok());
1646        assert!(result.unwrap().is_empty());
1647    }
1648
1649    #[test]
1650    fn test_format_rumdl_builtin_skipped_for_markdown() {
1651        // Configure the built-in "rumdl" tool for markdown
1652        let mut config = default_config();
1653        config.languages.insert(
1654            "markdown".to_string(),
1655            LanguageToolConfig {
1656                format: vec![RUMDL_BUILTIN_TOOL.to_string()],
1657                ..Default::default()
1658            },
1659        );
1660        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1661
1662        let content = "```markdown\n# Hello\n```";
1663        let result = processor.format(content);
1664
1665        // Should succeed with unchanged content - "rumdl" tool is skipped
1666        assert!(result.is_ok());
1667        let output = result.unwrap();
1668        assert_eq!(output.content, content);
1669        assert!(!output.had_errors);
1670    }
1671
1672    #[test]
1673    fn test_is_markdown_language() {
1674        // Test the helper function
1675        assert!(is_markdown_language("markdown"));
1676        assert!(is_markdown_language("Markdown"));
1677        assert!(is_markdown_language("MARKDOWN"));
1678        assert!(is_markdown_language("md"));
1679        assert!(is_markdown_language("MD"));
1680        assert!(!is_markdown_language("python"));
1681        assert!(!is_markdown_language("rust"));
1682        assert!(!is_markdown_language(""));
1683    }
1684
1685    // Issue #423: MkDocs admonition code block detection
1686
1687    #[test]
1688    fn test_extract_mkdocs_admonition_code_block() {
1689        let config = default_config();
1690        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1691
1692        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
1693        let blocks = processor.extract_code_blocks(content);
1694
1695        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
1696        assert_eq!(blocks[0].language, "python");
1697    }
1698
1699    #[test]
1700    fn test_extract_mkdocs_tab_code_block() {
1701        let config = default_config();
1702        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1703
1704        let content = "=== \"Python\"\n\n    ```python\n    print(\"hello\")\n    ```\n";
1705        let blocks = processor.extract_code_blocks(content);
1706
1707        assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
1708        assert_eq!(blocks[0].language, "python");
1709    }
1710
1711    #[test]
1712    fn test_standard_flavor_ignores_admonition_indented_content() {
1713        let config = default_config();
1714        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1715
1716        // With standard flavor, pulldown_cmark parses this differently;
1717        // our MkDocs extraction should NOT run
1718        let content = "!!! note\n    Some text\n\n    ```python\n    def hello():\n        pass\n    ```\n";
1719        let blocks = processor.extract_code_blocks(content);
1720
1721        // Standard flavor relies on pulldown_cmark only, which may or may not detect
1722        // indented fenced blocks. The key assertion is that we don't double-detect.
1723        // With standard flavor, the MkDocs extraction path is skipped entirely.
1724        for (i, b) in blocks.iter().enumerate() {
1725            for (j, b2) in blocks.iter().enumerate() {
1726                if i != j {
1727                    assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
1728                }
1729            }
1730        }
1731    }
1732
1733    #[test]
1734    fn test_mkdocs_top_level_blocks_alongside_admonition() {
1735        let config = default_config();
1736        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1737
1738        let content =
1739            "```rust\nfn main() {}\n```\n\n!!! note\n    Some text\n\n    ```python\n    print(\"hello\")\n    ```\n";
1740        let blocks = processor.extract_code_blocks(content);
1741
1742        assert_eq!(
1743            blocks.len(),
1744            2,
1745            "Should detect both top-level and admonition code blocks"
1746        );
1747        assert_eq!(blocks[0].language, "rust");
1748        assert_eq!(blocks[1].language, "python");
1749    }
1750
1751    #[test]
1752    fn test_mkdocs_nested_admonition_code_block() {
1753        let config = default_config();
1754        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1755
1756        let content = "\
1757!!! note
1758    Some text
1759
1760    !!! warning
1761        Nested content
1762
1763        ```python
1764        x = 1
1765        ```
1766";
1767        let blocks = processor.extract_code_blocks(content);
1768        assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
1769        assert_eq!(blocks[0].language, "python");
1770    }
1771
1772    #[test]
1773    fn test_mkdocs_consecutive_admonitions_no_stale_context() {
1774        let config = default_config();
1775        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1776
1777        // Two consecutive admonitions at the same indent level.
1778        // The first has no code block, the second does.
1779        let content = "\
1780!!! note
1781    First admonition content
1782
1783!!! warning
1784    Second admonition content
1785
1786    ```python
1787    y = 2
1788    ```
1789";
1790        let blocks = processor.extract_code_blocks(content);
1791        assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
1792        assert_eq!(blocks[0].language, "python");
1793    }
1794
1795    #[test]
1796    fn test_mkdocs_crlf_line_endings() {
1797        let config = default_config();
1798        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1799
1800        // Use \r\n line endings
1801        let content = "!!! note\r\n    Some text\r\n\r\n    ```python\r\n    x = 1\r\n    ```\r\n";
1802        let blocks = processor.extract_code_blocks(content);
1803
1804        assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
1805        assert_eq!(blocks[0].language, "python");
1806
1807        // Verify byte offsets point to valid content
1808        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1809        assert!(
1810            extracted.contains("x = 1"),
1811            "Extracted content should contain code. Got: {extracted:?}"
1812        );
1813    }
1814
1815    #[test]
1816    fn test_mkdocs_unclosed_fence_in_admonition() {
1817        let config = default_config();
1818        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1819
1820        // Unclosed fence should not produce a block
1821        let content = "!!! note\n    ```python\n    x = 1\n    no closing fence\n";
1822        let blocks = processor.extract_code_blocks(content);
1823        assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
1824    }
1825
1826    #[test]
1827    fn test_mkdocs_tilde_fence_in_admonition() {
1828        let config = default_config();
1829        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1830
1831        let content = "!!! note\n    ~~~ruby\n    puts 'hi'\n    ~~~\n";
1832        let blocks = processor.extract_code_blocks(content);
1833        assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
1834        assert_eq!(blocks[0].language, "ruby");
1835    }
1836
1837    #[test]
1838    fn test_mkdocs_empty_lines_in_code_block() {
1839        let config = default_config();
1840        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1841
1842        // Code block with empty lines inside — verifies byte offsets are correct
1843        // across empty lines (the previous find("") approach would break here)
1844        let content = "!!! note\n    ```python\n    x = 1\n\n    y = 2\n    ```\n";
1845        let blocks = processor.extract_code_blocks(content);
1846        assert_eq!(blocks.len(), 1);
1847
1848        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1849        assert!(
1850            extracted.contains("x = 1") && extracted.contains("y = 2"),
1851            "Extracted content should span across the empty line. Got: {extracted:?}"
1852        );
1853    }
1854
1855    #[test]
1856    fn test_mkdocs_content_byte_offsets_lf() {
1857        let config = default_config();
1858        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1859
1860        let content = "!!! note\n    ```python\n    print('hi')\n    ```\n";
1861        let blocks = processor.extract_code_blocks(content);
1862        assert_eq!(blocks.len(), 1);
1863
1864        // Verify the extracted content is exactly the code body
1865        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1866        assert_eq!(extracted, "    print('hi')\n", "Content offsets should be exact for LF");
1867    }
1868
1869    #[test]
1870    fn test_mkdocs_content_byte_offsets_crlf() {
1871        let config = default_config();
1872        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1873
1874        let content = "!!! note\r\n    ```python\r\n    print('hi')\r\n    ```\r\n";
1875        let blocks = processor.extract_code_blocks(content);
1876        assert_eq!(blocks.len(), 1);
1877
1878        let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1879        assert_eq!(
1880            extracted, "    print('hi')\r\n",
1881            "Content offsets should be exact for CRLF"
1882        );
1883    }
1884
1885    #[test]
1886    fn test_lint_enabled_false_skips_language_in_strict_mode() {
1887        // With on-missing-language-definition = "fail", a language configured
1888        // with enabled=false should be silently skipped (no error).
1889        let mut config = default_config();
1890        config.normalize_language = NormalizeLanguage::Exact;
1891        config.on_missing_language_definition = OnMissing::Fail;
1892
1893        // Python has tools, plaintext is disabled
1894        config.languages.insert(
1895            "python".to_string(),
1896            LanguageToolConfig {
1897                lint: vec!["ruff:check".to_string()],
1898                ..Default::default()
1899            },
1900        );
1901        config.languages.insert(
1902            "plaintext".to_string(),
1903            LanguageToolConfig {
1904                enabled: false,
1905                ..Default::default()
1906            },
1907        );
1908
1909        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1910
1911        let content = "```plaintext\nsome text\n```";
1912        let result = processor.lint(content);
1913
1914        // No error for plaintext: enabled=false satisfies strict mode
1915        assert!(result.is_ok());
1916        let diagnostics = result.unwrap();
1917        assert!(
1918            diagnostics.is_empty(),
1919            "Expected no diagnostics for disabled language, got: {diagnostics:?}"
1920        );
1921    }
1922
1923    #[test]
1924    fn test_format_enabled_false_skips_language_in_strict_mode() {
1925        // Same test but for format mode
1926        let mut config = default_config();
1927        config.normalize_language = NormalizeLanguage::Exact;
1928        config.on_missing_language_definition = OnMissing::Fail;
1929
1930        config.languages.insert(
1931            "plaintext".to_string(),
1932            LanguageToolConfig {
1933                enabled: false,
1934                ..Default::default()
1935            },
1936        );
1937
1938        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1939
1940        let content = "```plaintext\nsome text\n```";
1941        let result = processor.format(content);
1942
1943        // No error for plaintext: enabled=false satisfies strict mode
1944        assert!(result.is_ok());
1945        let output = result.unwrap();
1946        assert!(!output.had_errors, "Expected no errors for disabled language");
1947        assert!(
1948            output.error_messages.is_empty(),
1949            "Expected no error messages, got: {:?}",
1950            output.error_messages
1951        );
1952    }
1953
1954    #[test]
1955    fn test_enabled_false_default_true_preserved() {
1956        // Verify that when enabled is not set, it defaults to true (existing behavior)
1957        let mut config = default_config();
1958        config.on_missing_language_definition = OnMissing::Fail;
1959
1960        // Configure python without explicitly setting enabled
1961        config.languages.insert(
1962            "python".to_string(),
1963            LanguageToolConfig {
1964                lint: vec!["ruff:check".to_string()],
1965                ..Default::default()
1966            },
1967        );
1968
1969        let lang_config = config.languages.get("python").unwrap();
1970        assert!(lang_config.enabled, "enabled should default to true");
1971    }
1972
1973    #[test]
1974    fn test_enabled_false_with_fail_fast_no_error() {
1975        // Even with fail-fast, enabled=false should skip silently
1976        let mut config = default_config();
1977        config.normalize_language = NormalizeLanguage::Exact;
1978        config.on_missing_language_definition = OnMissing::FailFast;
1979
1980        config.languages.insert(
1981            "unknown".to_string(),
1982            LanguageToolConfig {
1983                enabled: false,
1984                ..Default::default()
1985            },
1986        );
1987
1988        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1989
1990        let content = "```unknown\nsome content\n```";
1991        let result = processor.lint(content);
1992
1993        // Should not return an error: enabled=false takes precedence over fail-fast
1994        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
1995        assert!(result.unwrap().is_empty());
1996    }
1997
1998    #[test]
1999    fn test_enabled_false_format_with_fail_fast_no_error() {
2000        // Same for format mode
2001        let mut config = default_config();
2002        config.normalize_language = NormalizeLanguage::Exact;
2003        config.on_missing_language_definition = OnMissing::FailFast;
2004
2005        config.languages.insert(
2006            "unknown".to_string(),
2007            LanguageToolConfig {
2008                enabled: false,
2009                ..Default::default()
2010            },
2011        );
2012
2013        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2014
2015        let content = "```unknown\nsome content\n```";
2016        let result = processor.format(content);
2017
2018        assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2019        let output = result.unwrap();
2020        assert!(!output.had_errors);
2021    }
2022
2023    #[test]
2024    fn test_enabled_false_with_tools_still_skips() {
2025        // If enabled=false but tools are listed, the language should still be skipped
2026        let mut config = default_config();
2027        config.on_missing_language_definition = OnMissing::Fail;
2028
2029        config.languages.insert(
2030            "python".to_string(),
2031            LanguageToolConfig {
2032                enabled: false,
2033                lint: vec!["ruff:check".to_string()],
2034                format: vec!["ruff:format".to_string()],
2035                on_error: None,
2036            },
2037        );
2038
2039        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2040
2041        let content = "```python\nprint('hello')\n```";
2042
2043        // Lint should skip
2044        let lint_result = processor.lint(content);
2045        assert!(lint_result.is_ok());
2046        assert!(lint_result.unwrap().is_empty());
2047
2048        // Format should skip
2049        let format_result = processor.format(content);
2050        assert!(format_result.is_ok());
2051        let output = format_result.unwrap();
2052        assert!(!output.had_errors);
2053        assert_eq!(output.content, content, "Content should be unchanged");
2054    }
2055
2056    #[test]
2057    fn test_enabled_true_without_tools_triggers_strict_mode() {
2058        // A language configured with enabled=true (default) but no tools
2059        // should still trigger strict mode errors
2060        let mut config = default_config();
2061        config.on_missing_language_definition = OnMissing::Fail;
2062
2063        config.languages.insert(
2064            "python".to_string(),
2065            LanguageToolConfig {
2066                // enabled defaults to true, no tools
2067                ..Default::default()
2068            },
2069        );
2070
2071        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2072
2073        let content = "```python\nprint('hello')\n```";
2074        let result = processor.lint(content);
2075
2076        // Should report an error because enabled=true but no lint tools configured
2077        assert!(result.is_ok());
2078        let diagnostics = result.unwrap();
2079        assert_eq!(diagnostics.len(), 1);
2080        assert!(diagnostics[0].message.contains("No lint tools configured"));
2081    }
2082
2083    #[test]
2084    fn test_mixed_enabled_and_disabled_languages() {
2085        // Multiple languages: one disabled, one unconfigured
2086        let mut config = default_config();
2087        config.normalize_language = NormalizeLanguage::Exact;
2088        config.on_missing_language_definition = OnMissing::Fail;
2089
2090        config.languages.insert(
2091            "plaintext".to_string(),
2092            LanguageToolConfig {
2093                enabled: false,
2094                ..Default::default()
2095            },
2096        );
2097
2098        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2099
2100        let content = "\
2101```plaintext
2102some text
2103```
2104
2105```javascript
2106console.log('hi');
2107```
2108";
2109
2110        let result = processor.lint(content);
2111        assert!(result.is_ok());
2112        let diagnostics = result.unwrap();
2113
2114        // plaintext: skipped (enabled=false), no error
2115        // javascript: not configured at all, should trigger strict mode error
2116        assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2117        assert!(
2118            diagnostics[0].message.contains("javascript"),
2119            "Error should be about javascript, got: {}",
2120            diagnostics[0].message
2121        );
2122    }
2123
2124    #[test]
2125    fn test_generic_fallback_includes_all_stderr_lines() {
2126        let config = default_config();
2127        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2128
2129        // Use output that won't be parsed by any structured format parser
2130        let output = ToolOutput {
2131            stdout: String::new(),
2132            stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2133            exit_code: 1,
2134            success: false,
2135        };
2136
2137        let diags = processor.parse_tool_output(&output, "tombi", 5);
2138        assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2139        assert_eq!(diags[0].message, "Parse error at position 42");
2140        assert_eq!(diags[1].message, "Unexpected token '::'");
2141        assert_eq!(diags[2].message, "3 errors found");
2142        assert!(diags.iter().all(|d| d.tool == "tombi"));
2143        assert!(diags.iter().all(|d| d.file_line == 5));
2144    }
2145
2146    #[test]
2147    fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2148        let config = default_config();
2149        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2150
2151        let output = ToolOutput {
2152            stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2153            stderr: String::new(),
2154            exit_code: 1,
2155            success: false,
2156        };
2157
2158        let diags = processor.parse_tool_output(&output, "some-tool", 10);
2159        assert_eq!(diags.len(), 3);
2160        assert_eq!(diags[0].message, "Line 1 error");
2161        assert_eq!(diags[1].message, "Line 2 detail");
2162        assert_eq!(diags[2].message, "Line 3 summary");
2163    }
2164
2165    #[test]
2166    fn test_generic_fallback_skips_blank_lines() {
2167        let config = default_config();
2168        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2169
2170        let output = ToolOutput {
2171            stdout: String::new(),
2172            stderr: "error: bad input\n\n  \n\ndetail: see above\n".to_string(),
2173            exit_code: 1,
2174            success: false,
2175        };
2176
2177        let diags = processor.parse_tool_output(&output, "tool", 1);
2178        assert_eq!(diags.len(), 2);
2179        assert_eq!(diags[0].message, "error: bad input");
2180        assert_eq!(diags[1].message, "detail: see above");
2181    }
2182
2183    #[test]
2184    fn test_generic_fallback_exit_code_when_no_output() {
2185        let config = default_config();
2186        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2187
2188        let output = ToolOutput {
2189            stdout: String::new(),
2190            stderr: String::new(),
2191            exit_code: 42,
2192            success: false,
2193        };
2194
2195        let diags = processor.parse_tool_output(&output, "tool", 1);
2196        assert_eq!(diags.len(), 1);
2197        assert_eq!(diags[0].message, "Tool exited with code 42");
2198    }
2199
2200    #[test]
2201    fn test_generic_fallback_not_triggered_on_success() {
2202        let config = default_config();
2203        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2204
2205        let output = ToolOutput {
2206            stdout: "some informational output".to_string(),
2207            stderr: String::new(),
2208            exit_code: 0,
2209            success: true,
2210        };
2211
2212        let diags = processor.parse_tool_output(&output, "tool", 1);
2213        assert!(
2214            diags.is_empty(),
2215            "Successful tool runs should produce no fallback diagnostics"
2216        );
2217    }
2218
2219    #[test]
2220    fn test_ansi_codes_stripped_before_parsing() {
2221        let config = default_config();
2222        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2223
2224        // ruff-style output with ANSI color codes wrapping the message
2225        let output = ToolOutput {
2226            stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
2227                .to_string(),
2228            stderr: String::new(),
2229            exit_code: 1,
2230            success: false,
2231        };
2232
2233        let diags = processor.parse_tool_output(&output, "ruff:check", 5);
2234        assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
2235        assert_eq!(diags[0].message, "E501 Line too long");
2236        assert_eq!(diags[0].file_line, 6); // 5 + 1
2237    }
2238
2239    #[test]
2240    fn test_tombi_multiline_error_format() {
2241        let config = default_config();
2242        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2243
2244        // Simulates tombi output (ANSI codes stripped for clarity)
2245        let output = ToolOutput {
2246            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(),
2247            stderr: "1 file failed to be formatted".to_string(),
2248            exit_code: 1,
2249            success: false,
2250        };
2251
2252        let diags = processor.parse_tool_output(&output, "tombi", 7);
2253        assert_eq!(
2254            diags.len(),
2255            4,
2256            "Expected 4 diagnostics from tombi errors, got {diags:?}"
2257        );
2258        assert_eq!(diags[0].message, "invalid key");
2259        assert_eq!(diags[0].file_line, 9); // 7 + 2
2260        assert_eq!(diags[0].column, Some(1));
2261        assert_eq!(diags[1].message, "expected key");
2262        assert_eq!(diags[1].file_line, 9);
2263        assert_eq!(diags[2].message, "expected '='");
2264        assert_eq!(diags[3].message, "expected value");
2265        assert!(diags.iter().all(|d| d.tool == "tombi"));
2266    }
2267
2268    #[test]
2269    fn test_tombi_with_ansi_codes() {
2270        let config = default_config();
2271        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2272
2273        // Real tombi output with ANSI escape codes
2274        let output = ToolOutput {
2275            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(),
2276            stderr: "1 file failed to be formatted".to_string(),
2277            exit_code: 1,
2278            success: false,
2279        };
2280
2281        let diags = processor.parse_tool_output(&output, "tombi", 7);
2282        assert_eq!(
2283            diags.len(),
2284            2,
2285            "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
2286        );
2287        assert_eq!(diags[0].message, "invalid key");
2288        assert_eq!(diags[0].file_line, 9);
2289        assert_eq!(diags[1].message, "expected '='");
2290        assert_eq!(diags[1].file_line, 9);
2291    }
2292
2293    #[test]
2294    fn test_fallback_combines_stdout_and_stderr() {
2295        let config = default_config();
2296        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2297
2298        // Tool puts some errors on stdout, summary on stderr
2299        let output = ToolOutput {
2300            stdout: "problem found in input".to_string(),
2301            stderr: "1 file failed".to_string(),
2302            exit_code: 1,
2303            success: false,
2304        };
2305
2306        let diags = processor.parse_tool_output(&output, "tool", 1);
2307        assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
2308        assert_eq!(diags[0].message, "problem found in input");
2309        assert_eq!(diags[1].message, "1 file failed");
2310    }
2311
2312    #[test]
2313    fn test_error_line_without_position_info() {
2314        let config = default_config();
2315        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2316
2317        // Error: line not followed by "at line N column M"
2318        let output = ToolOutput {
2319            stdout: "Error: something went wrong\nsome unrelated line".to_string(),
2320            stderr: String::new(),
2321            exit_code: 1,
2322            success: false,
2323        };
2324
2325        let diags = processor.parse_tool_output(&output, "tool", 5);
2326        // "Error: something went wrong" → parsed by error-line parser (no position)
2327        // "some unrelated line" → no parser matches, but diagnostics not empty → no fallback
2328        assert!(!diags.is_empty());
2329        assert_eq!(diags[0].message, "something went wrong");
2330        assert_eq!(diags[0].file_line, 5); // No line offset, uses code_block_start
2331    }
2332
2333    #[test]
2334    fn test_warning_line_with_position() {
2335        let config = default_config();
2336        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2337
2338        let output = ToolOutput {
2339            stdout: "Warning: deprecated syntax\n    at line 3 column 5".to_string(),
2340            stderr: String::new(),
2341            exit_code: 1,
2342            success: false,
2343        };
2344
2345        let diags = processor.parse_tool_output(&output, "tool", 10);
2346        assert_eq!(diags.len(), 1);
2347        assert_eq!(diags[0].message, "deprecated syntax");
2348        assert_eq!(diags[0].file_line, 13); // 10 + 3
2349        assert_eq!(diags[0].column, Some(5));
2350        assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
2351    }
2352
2353    #[test]
2354    fn test_strip_ansi_codes() {
2355        assert_eq!(strip_ansi_codes("hello"), "hello");
2356        assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
2357        assert_eq!(
2358            strip_ansi_codes("\x1b[1;31m  Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
2359            "  Error: msg"
2360        );
2361        assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
2362        assert_eq!(strip_ansi_codes(""), "");
2363        assert_eq!(
2364            strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
2365            "at line 2 column 1"
2366        );
2367    }
2368
2369    #[test]
2370    fn test_parse_at_line_column() {
2371        assert_eq!(
2372            CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
2373            Some((2, 1))
2374        );
2375        assert_eq!(
2376            CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
2377            Some((10, 15))
2378        );
2379        assert_eq!(
2380            CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
2381            Some((5, 3))
2382        );
2383        assert_eq!(
2384            CodeBlockToolProcessor::parse_at_line_column("not a position line"),
2385            None
2386        );
2387        assert_eq!(
2388            CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
2389            None
2390        );
2391    }
2392
2393    #[test]
2394    fn test_parse_error_line() {
2395        let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
2396        assert_eq!(msg, "invalid key");
2397        assert!(matches!(sev, DiagnosticSeverity::Error));
2398
2399        let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
2400        assert_eq!(msg, "deprecated");
2401        assert!(matches!(sev, DiagnosticSeverity::Warning));
2402
2403        // Lowercase should NOT match (avoids conflict with unstructured tool output)
2404        assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
2405        assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
2406
2407        // Empty message after prefix should not match
2408        assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
2409        assert!(CodeBlockToolProcessor::parse_error_line("Error:   ").is_none());
2410
2411        // Not an error line
2412        assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
2413    }
2414
2415    #[test]
2416    fn test_consecutive_error_lines_without_position() {
2417        let config = default_config();
2418        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2419
2420        // Two Error: lines in a row — first should flush without position,
2421        // second gets position from "at line"
2422        let output = ToolOutput {
2423            stdout: "Error: first problem\nError: second problem\n    at line 3 column 1".to_string(),
2424            stderr: String::new(),
2425            exit_code: 1,
2426            success: false,
2427        };
2428
2429        let diags = processor.parse_tool_output(&output, "tool", 5);
2430        assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
2431        // First error flushed without position when second Error: was encountered
2432        assert_eq!(diags[0].message, "first problem");
2433        assert_eq!(diags[0].file_line, 5); // No line mapping
2434        assert_eq!(diags[0].column, None);
2435        // Second error resolved with position
2436        assert_eq!(diags[1].message, "second problem");
2437        assert_eq!(diags[1].file_line, 8); // 5 + 3
2438        assert_eq!(diags[1].column, Some(1));
2439    }
2440
2441    #[test]
2442    fn test_error_line_at_end_of_output() {
2443        let config = default_config();
2444        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2445
2446        // Error: as the very last line — flushed by post-loop code
2447        let output = ToolOutput {
2448            stdout: "Error: trailing error".to_string(),
2449            stderr: String::new(),
2450            exit_code: 1,
2451            success: false,
2452        };
2453
2454        let diags = processor.parse_tool_output(&output, "tool", 5);
2455        assert_eq!(diags.len(), 1);
2456        assert_eq!(diags[0].message, "trailing error");
2457        assert_eq!(diags[0].file_line, 5); // No position info available
2458        assert_eq!(diags[0].column, None);
2459    }
2460
2461    #[test]
2462    fn test_blank_lines_between_error_and_position() {
2463        let config = default_config();
2464        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2465
2466        // Blank lines between Error: and "at line" should be transparently skipped
2467        let output = ToolOutput {
2468            stdout: "Error: spaced out\n\n\n    at line 4 column 2".to_string(),
2469            stderr: String::new(),
2470            exit_code: 1,
2471            success: false,
2472        };
2473
2474        let diags = processor.parse_tool_output(&output, "tool", 10);
2475        assert_eq!(diags.len(), 1);
2476        assert_eq!(diags[0].message, "spaced out");
2477        assert_eq!(diags[0].file_line, 14); // 10 + 4
2478        assert_eq!(diags[0].column, Some(2));
2479    }
2480
2481    #[test]
2482    fn test_mixed_structured_and_error_line_parsers() {
2483        let config = default_config();
2484        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2485
2486        // Mix of ruff-style structured output and tombi-style Error: output
2487        let output = ToolOutput {
2488            stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n    at line 3 column 1".to_string(),
2489            stderr: String::new(),
2490            exit_code: 1,
2491            success: false,
2492        };
2493
2494        let diags = processor.parse_tool_output(&output, "tool", 5);
2495        assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
2496        // First: standard format parser
2497        assert_eq!(diags[0].message, "E501 Line too long");
2498        assert_eq!(diags[0].file_line, 6); // 5 + 1
2499        // Second: Error: + at line parser
2500        assert_eq!(diags[1].message, "invalid syntax");
2501        assert_eq!(diags[1].file_line, 8); // 5 + 3
2502    }
2503
2504    #[test]
2505    fn test_at_line_without_preceding_error() {
2506        let config = default_config();
2507        let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2508
2509        // "at line N column M" without a preceding Error: should not create a diagnostic
2510        let output = ToolOutput {
2511            stdout: "at line 2 column 1\nsome other text".to_string(),
2512            stderr: String::new(),
2513            exit_code: 1,
2514            success: false,
2515        };
2516
2517        let diags = processor.parse_tool_output(&output, "tool", 5);
2518        // No pending error, so "at line" is just an unmatched line
2519        // Both lines are unmatched, fallback fires with combined output
2520        assert_eq!(diags.len(), 2);
2521        assert_eq!(diags[0].message, "at line 2 column 1");
2522        assert_eq!(diags[1].message, "some other text");
2523    }
2524
2525    #[test]
2526    fn test_strip_ansi_codes_edge_cases() {
2527        // Lone ESC without CSI bracket — non-printable, safely dropped
2528        assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
2529        // ESC at end of string
2530        assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
2531        // Nested/consecutive sequences
2532        assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
2533        // 256-color and RGB sequences
2534        assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
2535        assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
2536    }
2537}