1#[cfg(test)]
7use super::config::LanguageToolConfig;
8use super::config::{CodeBlockToolsConfig, NormalizeLanguage, OnError, OnMissing, ToolDefinition};
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
16pub const RUMDL_BUILTIN_TOOL: &str = "rumdl";
20
21fn is_markdown_language(lang: &str) -> bool {
23 matches!(lang.to_lowercase().as_str(), "markdown" | "md")
24}
25
26fn 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 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#[derive(Debug, Clone)]
54pub struct FencedCodeBlockInfo {
55 pub start_line: usize,
57 pub end_line: usize,
59 pub content_start: usize,
61 pub content_end: usize,
63 pub language: String,
65 pub info_string: String,
67 pub fence_char: char,
69 pub fence_length: usize,
71 pub indent: usize,
73 pub indent_prefix: String,
75}
76
77#[derive(Debug, Clone)]
79pub struct CodeBlockDiagnostic {
80 pub file_line: usize,
82 pub column: Option<usize>,
84 pub message: String,
86 pub severity: DiagnosticSeverity,
88 pub tool: String,
90 pub code_block_start: usize,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum DiagnosticSeverity {
97 Error,
98 Warning,
99 Info,
100}
101
102impl CodeBlockDiagnostic {
103 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, rule_name: Some(self.tool.clone()),
120 }
121 }
122}
123
124#[derive(Debug, Clone)]
126pub enum ProcessorError {
127 ToolError(ExecutorError),
129 ToolErrorAt {
131 error: ExecutorError,
132 line: usize,
133 language: String,
134 },
135 NoToolsConfigured { language: String, line: usize },
137 ToolBinaryNotFound {
139 tool: String,
140 language: String,
141 line: usize,
142 },
143 Aborted { message: String },
145}
146
147impl std::fmt::Display for ProcessorError {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 match self {
150 Self::ToolError(e) => write!(f, "{e}"),
151 Self::ToolErrorAt { error, line, language } => {
152 write!(f, "line {line} ({language}): {error}")
153 }
154 Self::NoToolsConfigured { language, line } => {
155 write!(f, "line {line} ({language}): no tools configured")
156 }
157 Self::ToolBinaryNotFound { tool, language, line } => {
158 write!(f, "line {line} ({language}): tool '{tool}' not found in PATH")
159 }
160 Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
161 }
162 }
163}
164
165impl std::error::Error for ProcessorError {}
166
167impl From<ExecutorError> for ProcessorError {
168 fn from(e: ExecutorError) -> Self {
169 Self::ToolError(e)
170 }
171}
172
173#[derive(Debug)]
175pub struct CodeBlockResult {
176 pub diagnostics: Vec<CodeBlockDiagnostic>,
178 pub formatted_content: Option<String>,
180 pub was_modified: bool,
182}
183
184#[derive(Debug)]
186pub struct FormatOutput {
187 pub content: String,
189 pub had_errors: bool,
191 pub error_messages: Vec<String>,
193}
194
195enum ToolContext {
198 Lint,
199 Format,
200}
201
202pub struct CodeBlockToolProcessor<'a> {
203 config: &'a CodeBlockToolsConfig,
204 flavor: MarkdownFlavor,
205 linguist: LinguistResolver,
206 registry: ToolRegistry,
207 executor: ToolExecutor,
208 user_aliases: std::collections::HashMap<String, String>,
209}
210
211impl<'a> CodeBlockToolProcessor<'a> {
212 pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
214 let user_aliases = config
215 .language_aliases
216 .iter()
217 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
218 .collect();
219 Self {
220 config,
221 flavor,
222 linguist: LinguistResolver::new(),
223 registry: ToolRegistry::new(config.tools.clone()),
224 executor: ToolExecutor::new(config.timeout),
225 user_aliases,
226 }
227 }
228
229 fn resolve_tool<'b>(&'b self, tool_id: &str, context: ToolContext) -> Option<&'b ToolDefinition> {
235 if tool_id.contains(':') {
237 return self.registry.get(tool_id);
238 }
239
240 let suffixes = match context {
242 ToolContext::Format => &["format", "fmt", "fix", "reformat"][..],
243 ToolContext::Lint => &["lint", "check"][..],
244 };
245
246 for suffix in suffixes {
247 let qualified = format!("{tool_id}:{suffix}");
248 if let Some(def) = self.registry.get(&qualified) {
249 return Some(def);
250 }
251 }
252
253 self.registry.get(tool_id)
255 }
256
257 fn has_potential_matching_blocks(&self, content: &str, lint_mode: bool) -> bool {
260 let configured_langs: Vec<&str> = self
262 .config
263 .languages
264 .iter()
265 .filter(|(_, lc)| {
266 lc.enabled
267 && if lint_mode {
268 !lc.lint.is_empty()
269 } else {
270 !lc.format.is_empty()
271 }
272 })
273 .map(|(lang, _)| lang.as_str())
274 .collect();
275
276 if configured_langs.is_empty() {
277 return false;
278 }
279
280 for line in content.lines() {
282 let trimmed = line.trim_start();
283 let after_fence = if let Some(rest) = trimmed.strip_prefix("```") {
284 rest
285 } else if let Some(rest) = trimmed.strip_prefix("~~~") {
286 rest
287 } else {
288 continue;
289 };
290
291 let lang = after_fence.split_whitespace().next().unwrap_or("");
292 if lang.is_empty() {
293 continue;
294 }
295 let canonical = self.resolve_language(lang);
297 if configured_langs.contains(&canonical.as_str()) {
298 return true;
299 }
300 }
301
302 false
303 }
304
305 pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
307 let mut blocks = Vec::new();
308 let mut current_block: Option<FencedCodeBlockBuilder> = None;
309
310 let options = Options::all();
311 let parser = Parser::new_ext(content, options).into_offset_iter();
312
313 let lines: Vec<&str> = content.lines().collect();
314
315 for (event, range) in parser {
316 match event {
317 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
318 let info_string = info.to_string();
319 let language = info_string.split_whitespace().next().unwrap_or("").to_string();
320
321 let start_line = content[..range.start].chars().filter(|&c| c == '\n').count();
323
324 let content_start = content[range.start..]
326 .find('\n')
327 .map(|i| range.start + i + 1)
328 .unwrap_or(content.len());
329
330 let fence_line = lines.get(start_line).unwrap_or(&"");
332 let trimmed = fence_line.trim_start();
333 let indent = fence_line.len() - trimmed.len();
334 let indent_prefix = fence_line.get(..indent).unwrap_or("").to_string();
335 let (fence_char, fence_length) = if trimmed.starts_with('~') {
336 ('~', trimmed.chars().take_while(|&c| c == '~').count())
337 } else {
338 ('`', trimmed.chars().take_while(|&c| c == '`').count())
339 };
340
341 current_block = Some(FencedCodeBlockBuilder {
342 start_line,
343 content_start,
344 language,
345 info_string,
346 fence_char,
347 fence_length,
348 indent,
349 indent_prefix,
350 });
351 }
352 Event::End(TagEnd::CodeBlock) => {
353 if let Some(builder) = current_block.take() {
354 let end_line = content[..range.end].chars().filter(|&c| c == '\n').count();
356
357 let search_start = builder.content_start.min(range.end);
359 let content_end = if search_start < range.end {
360 content[search_start..range.end]
361 .rfind('\n')
362 .map(|i| search_start + i)
363 .unwrap_or(search_start)
364 } else {
365 search_start
366 };
367
368 if content_end >= builder.content_start {
369 blocks.push(FencedCodeBlockInfo {
370 start_line: builder.start_line,
371 end_line,
372 content_start: builder.content_start,
373 content_end,
374 language: builder.language,
375 info_string: builder.info_string,
376 fence_char: builder.fence_char,
377 fence_length: builder.fence_length,
378 indent: builder.indent,
379 indent_prefix: builder.indent_prefix,
380 });
381 }
382 }
383 }
384 _ => {}
385 }
386 }
387
388 if self.flavor == MarkdownFlavor::MkDocs {
390 let mkdocs_blocks = self.extract_mkdocs_code_blocks(content);
391 for mb in mkdocs_blocks {
392 if !blocks.iter().any(|b| b.start_line == mb.start_line) {
394 blocks.push(mb);
395 }
396 }
397 blocks.sort_by_key(|b| b.start_line);
398 }
399
400 blocks
401 }
402
403 fn extract_mkdocs_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
409 use crate::utils::mkdocs_admonitions;
410 use crate::utils::mkdocs_tabs;
411
412 let mut blocks = Vec::new();
413 let lines: Vec<&str> = content.lines().collect();
414
415 let mut context_indent_stack: Vec<usize> = Vec::new();
418
419 let mut in_fence = false;
421 let mut fence_start_line: usize = 0;
422 let mut fence_content_start: usize = 0;
423 let mut fence_char: char = '`';
424 let mut fence_length: usize = 0;
425 let mut fence_indent: usize = 0;
426 let mut fence_indent_prefix = String::new();
427 let mut fence_language = String::new();
428 let mut fence_info_string = String::new();
429
430 let content_start_ptr = content.as_ptr() as usize;
435 let line_offsets: Vec<usize> = lines
436 .iter()
437 .map(|line| line.as_ptr() as usize - content_start_ptr)
438 .collect();
439
440 for (i, line) in lines.iter().enumerate() {
441 let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
442 let is_admonition = mkdocs_admonitions::is_admonition_start(line);
443 let is_tab = mkdocs_tabs::is_tab_marker(line);
444
445 if !line.trim().is_empty() {
449 while let Some(&ctx_indent) = context_indent_stack.last() {
450 if line_indent < ctx_indent + 4 {
451 context_indent_stack.pop();
452 if in_fence {
453 in_fence = false;
454 }
455 } else {
456 break;
457 }
458 }
459 }
460
461 if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
463 context_indent_stack.push(indent);
464 continue;
465 }
466
467 if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
469 context_indent_stack.push(indent);
470 continue;
471 }
472
473 if context_indent_stack.is_empty() {
475 continue;
476 }
477
478 let trimmed = line.trim_start();
479 let leading_spaces = line.len() - trimmed.len();
480
481 if !in_fence {
482 let (fc, fl) = if trimmed.starts_with("```") {
484 ('`', trimmed.chars().take_while(|&c| c == '`').count())
485 } else if trimmed.starts_with("~~~") {
486 ('~', trimmed.chars().take_while(|&c| c == '~').count())
487 } else {
488 continue;
489 };
490
491 if fl >= 3 {
492 in_fence = true;
493 fence_start_line = i;
494 fence_char = fc;
495 fence_length = fl;
496 fence_indent = leading_spaces;
497 fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
498
499 let after_fence = &trimmed[fl..];
500 fence_info_string = after_fence.trim().to_string();
501 fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
502
503 fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
505 }
506 } else {
507 let is_closing = if fence_char == '`' {
509 trimmed.starts_with("```")
510 && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
511 && trimmed.trim_start_matches('`').trim().is_empty()
512 } else {
513 trimmed.starts_with("~~~")
514 && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
515 && trimmed.trim_start_matches('~').trim().is_empty()
516 };
517
518 if is_closing {
519 let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
520
521 if content_end >= fence_content_start {
522 blocks.push(FencedCodeBlockInfo {
523 start_line: fence_start_line,
524 end_line: i,
525 content_start: fence_content_start,
526 content_end,
527 language: fence_language.clone(),
528 info_string: fence_info_string.clone(),
529 fence_char,
530 fence_length,
531 indent: fence_indent,
532 indent_prefix: fence_indent_prefix.clone(),
533 });
534 }
535
536 in_fence = false;
537 }
538 }
539 }
540
541 blocks
542 }
543
544 fn resolve_language(&self, language: &str) -> String {
546 let lower = language.to_lowercase();
547 if let Some(mapped) = self.user_aliases.get(&lower) {
548 return mapped.clone();
549 }
550 match self.config.normalize_language {
551 NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
552 NormalizeLanguage::Exact => lower,
553 }
554 }
555
556 fn get_on_error(&self, language: &str) -> OnError {
558 self.config
559 .languages
560 .get(language)
561 .and_then(|lc| lc.on_error)
562 .unwrap_or(self.config.on_error)
563 }
564
565 fn strip_indent_from_block(&self, content: &str, indent_prefix: &str) -> String {
567 if indent_prefix.is_empty() {
568 return content.to_string();
569 }
570
571 let mut out = String::with_capacity(content.len());
572 for line in content.split_inclusive('\n') {
573 if let Some(stripped) = line.strip_prefix(indent_prefix) {
574 out.push_str(stripped);
575 } else {
576 out.push_str(line);
577 }
578 }
579 out
580 }
581
582 fn apply_indent_to_block(&self, content: &str, indent_prefix: &str) -> String {
584 if indent_prefix.is_empty() {
585 return content.to_string();
586 }
587 if content.is_empty() {
588 return String::new();
589 }
590
591 let mut out = String::with_capacity(content.len() + indent_prefix.len());
592 for line in content.split_inclusive('\n') {
593 if line == "\n" {
594 out.push_str(line);
595 } else {
596 out.push_str(indent_prefix);
597 out.push_str(line);
598 }
599 }
600 out
601 }
602
603 pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
607 if self.config.on_missing_language_definition == OnMissing::Ignore
611 && !self
612 .config
613 .languages
614 .values()
615 .any(|lc| lc.enabled && !lc.lint.is_empty())
616 {
617 return Ok(Vec::new());
618 }
619
620 if self.config.on_missing_language_definition == OnMissing::Ignore
623 && !self.has_potential_matching_blocks(content, true)
624 {
625 return Ok(Vec::new());
626 }
627
628 let mut all_diagnostics = Vec::new();
629 let blocks = self.extract_code_blocks(content);
630
631 for block in blocks {
632 if block.language.is_empty() {
633 continue; }
635
636 let canonical_lang = self.resolve_language(&block.language);
637
638 let lang_config = self.config.languages.get(&canonical_lang);
640
641 if let Some(lc) = lang_config
643 && !lc.enabled
644 {
645 continue;
646 }
647
648 let lint_tools = match lang_config {
649 Some(lc) if !lc.lint.is_empty() => &lc.lint,
650 _ => {
651 match self.config.on_missing_language_definition {
653 OnMissing::Ignore => continue,
654 OnMissing::Fail => {
655 all_diagnostics.push(CodeBlockDiagnostic {
656 file_line: block.start_line + 1,
657 column: None,
658 message: format!("No lint tools configured for language '{canonical_lang}'"),
659 severity: DiagnosticSeverity::Error,
660 tool: "code-block-tools".to_string(),
661 code_block_start: block.start_line + 1,
662 });
663 continue;
664 }
665 OnMissing::FailFast => {
666 return Err(ProcessorError::NoToolsConfigured {
667 language: canonical_lang,
668 line: block.start_line + 1,
669 });
670 }
671 }
672 }
673 };
674
675 let code_content_raw = if block.content_start < block.content_end && block.content_end <= content.len() {
677 &content[block.content_start..block.content_end]
678 } else {
679 continue;
680 };
681 let code_content = self.strip_indent_from_block(code_content_raw, &block.indent_prefix);
682
683 for tool_id in lint_tools {
685 if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
687 continue;
688 }
689
690 let tool_def = match self.resolve_tool(tool_id, ToolContext::Lint) {
691 Some(t) => t,
692 None => {
693 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
694 continue;
695 }
696 };
697
698 let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
700 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
701 match self.config.on_missing_tool_binary {
702 OnMissing::Ignore => {
703 log::debug!("Tool binary '{tool_name}' not found, skipping");
704 continue;
705 }
706 OnMissing::Fail => {
707 all_diagnostics.push(CodeBlockDiagnostic {
708 file_line: block.start_line + 1,
709 column: None,
710 message: format!("Tool binary '{tool_name}' not found in PATH"),
711 severity: DiagnosticSeverity::Error,
712 tool: "code-block-tools".to_string(),
713 code_block_start: block.start_line + 1,
714 });
715 continue;
716 }
717 OnMissing::FailFast => {
718 return Err(ProcessorError::ToolBinaryNotFound {
719 tool: tool_name.to_string(),
720 language: canonical_lang.clone(),
721 line: block.start_line + 1,
722 });
723 }
724 }
725 }
726
727 match self.executor.lint(tool_def, &code_content, Some(self.config.timeout)) {
728 Ok(output) => {
729 let diagnostics = self.parse_tool_output(
731 &output,
732 tool_id,
733 block.start_line + 1, );
735 all_diagnostics.extend(diagnostics);
736 }
737 Err(e) => {
738 let on_error = self.get_on_error(&canonical_lang);
739 match on_error {
740 OnError::Fail => return Err(e.into()),
741 OnError::Warn => {
742 log::warn!("Tool '{tool_id}' failed: {e}");
743 }
744 OnError::Skip => {
745 }
747 }
748 }
749 }
750 }
751 }
752
753 Ok(all_diagnostics)
754 }
755
756 pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
762 let no_output = FormatOutput {
763 content: content.to_string(),
764 had_errors: false,
765 error_messages: Vec::new(),
766 };
767
768 if self.config.on_missing_language_definition == OnMissing::Ignore
770 && !self
771 .config
772 .languages
773 .values()
774 .any(|lc| lc.enabled && !lc.format.is_empty())
775 {
776 return Ok(no_output);
777 }
778
779 if self.config.on_missing_language_definition == OnMissing::Ignore
781 && !self.has_potential_matching_blocks(content, false)
782 {
783 return Ok(no_output);
784 }
785
786 let blocks = self.extract_code_blocks(content);
787
788 if blocks.is_empty() {
789 return Ok(FormatOutput {
790 content: content.to_string(),
791 had_errors: false,
792 error_messages: Vec::new(),
793 });
794 }
795
796 let mut result = content.to_string();
798 let mut error_messages: Vec<String> = Vec::new();
799
800 for block in blocks.into_iter().rev() {
801 if block.language.is_empty() {
802 continue;
803 }
804
805 let canonical_lang = self.resolve_language(&block.language);
806
807 let lang_config = self.config.languages.get(&canonical_lang);
809
810 if let Some(lc) = lang_config
812 && !lc.enabled
813 {
814 continue;
815 }
816
817 let format_tools = match lang_config {
818 Some(lc) if !lc.format.is_empty() => &lc.format,
819 _ => {
820 match self.config.on_missing_language_definition {
822 OnMissing::Ignore => continue,
823 OnMissing::Fail => {
824 error_messages.push(format!(
825 "No format tools configured for language '{canonical_lang}' at line {}",
826 block.start_line + 1
827 ));
828 continue;
829 }
830 OnMissing::FailFast => {
831 return Err(ProcessorError::NoToolsConfigured {
832 language: canonical_lang,
833 line: block.start_line + 1,
834 });
835 }
836 }
837 }
838 };
839
840 if block.content_start >= block.content_end || block.content_end > result.len() {
842 continue;
843 }
844 let code_content_raw = result[block.content_start..block.content_end].to_string();
845 let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
846
847 let mut formatted = code_content.clone();
849 let mut tool_ran = false;
850 for tool_id in format_tools {
851 if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
853 continue;
854 }
855
856 let tool_def = match self.resolve_tool(tool_id, ToolContext::Format) {
857 Some(t) => t,
858 None => {
859 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
860 continue;
861 }
862 };
863
864 let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
866 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
867 match self.config.on_missing_tool_binary {
868 OnMissing::Ignore => {
869 log::debug!("Tool binary '{tool_name}' not found, skipping");
870 continue;
871 }
872 OnMissing::Fail => {
873 error_messages.push(format!(
874 "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
875 block.start_line + 1
876 ));
877 continue;
878 }
879 OnMissing::FailFast => {
880 return Err(ProcessorError::ToolBinaryNotFound {
881 tool: tool_name.to_string(),
882 language: canonical_lang.clone(),
883 line: block.start_line + 1,
884 });
885 }
886 }
887 }
888
889 match self.executor.format(tool_def, &formatted, Some(self.config.timeout)) {
890 Ok(output) => {
891 if output.trim().is_empty() && !formatted.trim().is_empty() {
895 log::warn!("Formatter '{tool_id}' produced empty output for non-empty input, skipping");
896 continue;
897 }
898
899 formatted = output;
901 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
902 formatted.push('\n');
903 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
904 formatted.pop();
905 }
906 tool_ran = true;
907 break; }
909 Err(e) => {
910 let on_error = self.get_on_error(&canonical_lang);
911 match on_error {
912 OnError::Fail => {
913 return Err(ProcessorError::ToolErrorAt {
914 error: e,
915 line: block.start_line + 1,
916 language: canonical_lang,
917 });
918 }
919 OnError::Warn => {
920 error_messages.push(format!("line {} ({}): {e}", block.start_line + 1, canonical_lang));
921 }
922 OnError::Skip => {}
923 }
924 }
925 }
926 }
927
928 if tool_ran && formatted != code_content {
930 let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
931 if reindented != code_content_raw {
932 result.replace_range(block.content_start..block.content_end, &reindented);
933 }
934 }
935 }
936
937 Ok(FormatOutput {
938 content: result,
939 had_errors: !error_messages.is_empty(),
940 error_messages,
941 })
942 }
943
944 fn parse_tool_output(
949 &self,
950 output: &ToolOutput,
951 tool_id: &str,
952 code_block_start_line: usize,
953 ) -> Vec<CodeBlockDiagnostic> {
954 let mut diagnostics = Vec::new();
955 let mut shellcheck_line: Option<usize> = None;
956
957 let stdout_clean = strip_ansi_codes(&output.stdout);
959 let stderr_clean = strip_ansi_codes(&output.stderr);
960 let combined = format!("{stdout_clean}\n{stderr_clean}");
961
962 let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
964
965 for line in combined.lines() {
966 let line = line.trim();
967 if line.is_empty() {
968 continue;
969 }
970
971 if let Some((ref msg, severity)) = pending_error {
973 if let Some((line_num, col)) = Self::parse_at_line_column(line) {
974 diagnostics.push(CodeBlockDiagnostic {
975 file_line: code_block_start_line + line_num,
976 column: Some(col),
977 message: msg.clone(),
978 severity,
979 tool: tool_id.to_string(),
980 code_block_start: code_block_start_line,
981 });
982 pending_error = None;
983 continue;
984 }
985 diagnostics.push(CodeBlockDiagnostic {
987 file_line: code_block_start_line,
988 column: None,
989 message: msg.clone(),
990 severity,
991 tool: tool_id.to_string(),
992 code_block_start: code_block_start_line,
993 });
994 pending_error = None;
995 }
997
998 if let Some(line_num) = self.parse_shellcheck_header(line) {
999 shellcheck_line = Some(line_num);
1000 continue;
1001 }
1002
1003 if let Some(line_num) = shellcheck_line
1004 && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
1005 {
1006 diagnostics.push(diag);
1007 continue;
1008 }
1009
1010 if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
1012 diagnostics.push(diag);
1013 continue;
1014 }
1015
1016 if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
1018 diagnostics.push(diag);
1019 continue;
1020 }
1021
1022 if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
1024 diagnostics.push(diag);
1025 continue;
1026 }
1027
1028 if let Some(error_info) = Self::parse_error_line(line) {
1030 pending_error = Some(error_info);
1031 }
1032 }
1033
1034 if let Some((msg, severity)) = pending_error {
1036 diagnostics.push(CodeBlockDiagnostic {
1037 file_line: code_block_start_line,
1038 column: None,
1039 message: msg,
1040 severity,
1041 tool: tool_id.to_string(),
1042 code_block_start: code_block_start_line,
1043 });
1044 }
1045
1046 if diagnostics.is_empty() && !output.success {
1048 let lines: Vec<&str> = combined.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
1049
1050 if lines.is_empty() {
1051 let exit_code = output.exit_code;
1052 diagnostics.push(CodeBlockDiagnostic {
1053 file_line: code_block_start_line,
1054 column: None,
1055 message: format!("Tool exited with code {exit_code}"),
1056 severity: DiagnosticSeverity::Error,
1057 tool: tool_id.to_string(),
1058 code_block_start: code_block_start_line,
1059 });
1060 } else {
1061 for line_text in lines {
1062 diagnostics.push(CodeBlockDiagnostic {
1063 file_line: code_block_start_line,
1064 column: None,
1065 message: line_text.to_string(),
1066 severity: DiagnosticSeverity::Error,
1067 tool: tool_id.to_string(),
1068 code_block_start: code_block_start_line,
1069 });
1070 }
1071 }
1072 }
1073
1074 diagnostics
1075 }
1076
1077 fn parse_standard_format(
1079 &self,
1080 line: &str,
1081 tool_id: &str,
1082 code_block_start_line: usize,
1083 ) -> Option<CodeBlockDiagnostic> {
1084 let mut parts = line.rsplitn(4, ':');
1086 let message = parts.next()?.trim().to_string();
1087 let part1 = parts.next()?.trim().to_string();
1088 let part2 = parts.next()?.trim().to_string();
1089 let part3 = parts.next().map(|s| s.trim().to_string());
1090
1091 let (line_part, col_part) = if part3.is_some() {
1092 (part2, Some(part1))
1093 } else {
1094 (part1, None)
1095 };
1096
1097 if let Ok(line_num) = line_part.parse::<usize>() {
1098 let column = col_part.and_then(|s| s.parse::<usize>().ok());
1099 let message = Self::strip_fixable_markers(&message);
1100 if !message.is_empty() {
1101 let severity = self.infer_severity(&message);
1102 return Some(CodeBlockDiagnostic {
1103 file_line: code_block_start_line + line_num,
1104 column,
1105 message,
1106 severity,
1107 tool: tool_id.to_string(),
1108 code_block_start: code_block_start_line,
1109 });
1110 }
1111 }
1112 None
1113 }
1114
1115 fn parse_eslint_format(
1117 &self,
1118 line: &str,
1119 tool_id: &str,
1120 code_block_start_line: usize,
1121 ) -> Option<CodeBlockDiagnostic> {
1122 let parts: Vec<&str> = line.splitn(3, ' ').collect();
1124 if parts.len() >= 2 {
1125 let loc_parts: Vec<&str> = parts[0].split(':').collect();
1126 if loc_parts.len() == 2
1127 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
1128 {
1129 let (sev_part, msg_part) = if parts.len() >= 3 {
1130 (parts[1], parts[2])
1131 } else {
1132 (parts[1], "")
1133 };
1134 let message = if msg_part.is_empty() {
1135 sev_part.to_string()
1136 } else {
1137 msg_part.to_string()
1138 };
1139 let message = Self::strip_fixable_markers(&message);
1140 let severity = match sev_part.to_lowercase().as_str() {
1141 "error" => DiagnosticSeverity::Error,
1142 "warning" | "warn" => DiagnosticSeverity::Warning,
1143 "info" => DiagnosticSeverity::Info,
1144 _ => self.infer_severity(&message),
1145 };
1146 return Some(CodeBlockDiagnostic {
1147 file_line: code_block_start_line + line_num,
1148 column: Some(col),
1149 message,
1150 severity,
1151 tool: tool_id.to_string(),
1152 code_block_start: code_block_start_line,
1153 });
1154 }
1155 }
1156 None
1157 }
1158
1159 fn parse_shellcheck_format(
1161 &self,
1162 line: &str,
1163 tool_id: &str,
1164 code_block_start_line: usize,
1165 ) -> Option<CodeBlockDiagnostic> {
1166 if line.starts_with("In ")
1168 && line.contains(" line ")
1169 && let Some(line_start) = line.find(" line ")
1170 {
1171 let after_line = &line[line_start + 6..];
1172 if let Some(colon_pos) = after_line.find(':')
1173 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1174 {
1175 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1176 if !message.is_empty() {
1177 let severity = self.infer_severity(&message);
1178 return Some(CodeBlockDiagnostic {
1179 file_line: code_block_start_line + line_num,
1180 column: None,
1181 message,
1182 severity,
1183 tool: tool_id.to_string(),
1184 code_block_start: code_block_start_line,
1185 });
1186 }
1187 }
1188 }
1189 None
1190 }
1191
1192 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1194 if line.starts_with("In ")
1195 && line.contains(" line ")
1196 && let Some(line_start) = line.find(" line ")
1197 {
1198 let after_line = &line[line_start + 6..];
1199 if let Some(colon_pos) = after_line.find(':') {
1200 return after_line[..colon_pos].trim().parse::<usize>().ok();
1201 }
1202 }
1203 None
1204 }
1205
1206 fn parse_shellcheck_message(
1208 &self,
1209 line: &str,
1210 tool_id: &str,
1211 code_block_start_line: usize,
1212 line_num: usize,
1213 ) -> Option<CodeBlockDiagnostic> {
1214 let sc_pos = line.find("SC")?;
1215 let after_sc = &line[sc_pos + 2..];
1216 let code_len = after_sc.chars().take_while(|c| c.is_ascii_digit()).count();
1217 if code_len == 0 {
1218 return None;
1219 }
1220 let after_code = &after_sc[code_len..];
1221 let sev_start = after_code.find('(')? + 1;
1222 let sev_end = after_code[sev_start..].find(')')? + sev_start;
1223 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1224 let message_start = after_code.find("):")? + 2;
1225 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1226 if message.is_empty() {
1227 return None;
1228 }
1229
1230 let severity = match sev.as_str() {
1231 "error" => DiagnosticSeverity::Error,
1232 "warning" | "warn" => DiagnosticSeverity::Warning,
1233 "info" | "style" => DiagnosticSeverity::Info,
1234 _ => self.infer_severity(&message),
1235 };
1236
1237 Some(CodeBlockDiagnostic {
1238 file_line: code_block_start_line + line_num,
1239 column: None,
1240 message,
1241 severity,
1242 tool: tool_id.to_string(),
1243 code_block_start: code_block_start_line,
1244 })
1245 }
1246
1247 fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1253 let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1254 (msg, DiagnosticSeverity::Error)
1255 } else if let Some(msg) = line.strip_prefix("Warning:") {
1256 (msg, DiagnosticSeverity::Warning)
1257 } else {
1258 return None;
1259 };
1260 let msg = msg.trim();
1261 if msg.is_empty() {
1262 return None;
1263 }
1264 Some((msg.to_string(), severity))
1265 }
1266
1267 fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1271 let lower = line.to_lowercase();
1272 let rest = lower.strip_prefix("at line ")?;
1273 let mut parts = rest.split_whitespace();
1274 let line_num: usize = parts.next()?.parse().ok()?;
1275 if parts.next()? != "column" {
1276 return None;
1277 }
1278 let col: usize = parts.next()?.parse().ok()?;
1279 Some((line_num, col))
1280 }
1281
1282 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1284 let lower = message.to_lowercase();
1285 if lower.contains("error")
1286 || lower.starts_with("e") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1287 || lower.starts_with("f") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1288 {
1289 DiagnosticSeverity::Error
1290 } else if lower.contains("warning")
1291 || lower.contains("warn")
1292 || lower.starts_with("w") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1293 {
1294 DiagnosticSeverity::Warning
1295 } else {
1296 DiagnosticSeverity::Info
1297 }
1298 }
1299
1300 fn strip_fixable_markers(message: &str) -> String {
1307 message
1308 .replace(" [*]", "")
1309 .replace("[*] ", "")
1310 .replace("[*]", "")
1311 .replace(" (fixable)", "")
1312 .replace("(fixable) ", "")
1313 .replace("(fixable)", "")
1314 .replace(" [fix available]", "")
1315 .replace("[fix available] ", "")
1316 .replace("[fix available]", "")
1317 .replace(" [autofix]", "")
1318 .replace("[autofix] ", "")
1319 .replace("[autofix]", "")
1320 .trim()
1321 .to_string()
1322 }
1323}
1324
1325struct FencedCodeBlockBuilder {
1327 start_line: usize,
1328 content_start: usize,
1329 language: String,
1330 info_string: String,
1331 fence_char: char,
1332 fence_length: usize,
1333 indent: usize,
1334 indent_prefix: String,
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use super::*;
1340
1341 fn default_config() -> CodeBlockToolsConfig {
1342 CodeBlockToolsConfig::default()
1343 }
1344
1345 #[test]
1346 fn test_extract_code_blocks() {
1347 let config = default_config();
1348 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1349
1350 let content = r#"# Example
1351
1352```python
1353def hello():
1354 print("Hello")
1355```
1356
1357Some text
1358
1359```rust
1360fn main() {}
1361```
1362"#;
1363
1364 let blocks = processor.extract_code_blocks(content);
1365
1366 assert_eq!(blocks.len(), 2);
1367
1368 assert_eq!(blocks[0].language, "python");
1369 assert_eq!(blocks[0].fence_char, '`');
1370 assert_eq!(blocks[0].fence_length, 3);
1371 assert_eq!(blocks[0].start_line, 2);
1372 assert_eq!(blocks[0].indent, 0);
1373 assert_eq!(blocks[0].indent_prefix, "");
1374
1375 assert_eq!(blocks[1].language, "rust");
1376 assert_eq!(blocks[1].fence_char, '`');
1377 assert_eq!(blocks[1].fence_length, 3);
1378 }
1379
1380 #[test]
1381 fn test_extract_code_blocks_with_info_string() {
1382 let config = default_config();
1383 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1384
1385 let content = "```python title=\"example.py\"\ncode\n```";
1386 let blocks = processor.extract_code_blocks(content);
1387
1388 assert_eq!(blocks.len(), 1);
1389 assert_eq!(blocks[0].language, "python");
1390 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1391 }
1392
1393 #[test]
1394 fn test_extract_code_blocks_tilde_fence() {
1395 let config = default_config();
1396 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1397
1398 let content = "~~~bash\necho hello\n~~~";
1399 let blocks = processor.extract_code_blocks(content);
1400
1401 assert_eq!(blocks.len(), 1);
1402 assert_eq!(blocks[0].language, "bash");
1403 assert_eq!(blocks[0].fence_char, '~');
1404 assert_eq!(blocks[0].fence_length, 3);
1405 assert_eq!(blocks[0].indent_prefix, "");
1406 }
1407
1408 #[test]
1409 fn test_extract_code_blocks_with_indent_prefix() {
1410 let config = default_config();
1411 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1412
1413 let content = " - item\n ```python\n print('hi')\n ```";
1414 let blocks = processor.extract_code_blocks(content);
1415
1416 assert_eq!(blocks.len(), 1);
1417 assert_eq!(blocks[0].indent_prefix, " ");
1418 }
1419
1420 #[test]
1421 fn test_extract_code_blocks_no_language() {
1422 let config = default_config();
1423 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1424
1425 let content = "```\nplain code\n```";
1426 let blocks = processor.extract_code_blocks(content);
1427
1428 assert_eq!(blocks.len(), 1);
1429 assert_eq!(blocks[0].language, "");
1430 }
1431
1432 #[test]
1433 fn test_resolve_language_linguist() {
1434 let mut config = default_config();
1435 config.normalize_language = NormalizeLanguage::Linguist;
1436 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1437
1438 assert_eq!(processor.resolve_language("py"), "python");
1439 assert_eq!(processor.resolve_language("bash"), "shell");
1440 assert_eq!(processor.resolve_language("js"), "javascript");
1441 }
1442
1443 #[test]
1444 fn test_resolve_language_exact() {
1445 let mut config = default_config();
1446 config.normalize_language = NormalizeLanguage::Exact;
1447 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1448
1449 assert_eq!(processor.resolve_language("py"), "py");
1450 assert_eq!(processor.resolve_language("BASH"), "bash");
1451 }
1452
1453 #[test]
1454 fn test_resolve_language_user_alias_override() {
1455 let mut config = default_config();
1456 config.language_aliases.insert("py".to_string(), "python".to_string());
1457 config.normalize_language = NormalizeLanguage::Exact;
1458 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1459
1460 assert_eq!(processor.resolve_language("PY"), "python");
1461 }
1462
1463 #[test]
1464 fn test_indent_strip_and_reapply_roundtrip() {
1465 let config = default_config();
1466 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1467
1468 let raw = " def hello():\n print('hi')";
1469 let stripped = processor.strip_indent_from_block(raw, " ");
1470 assert_eq!(stripped, "def hello():\n print('hi')");
1471
1472 let reapplied = processor.apply_indent_to_block(&stripped, " ");
1473 assert_eq!(reapplied, raw);
1474 }
1475
1476 #[test]
1477 fn test_infer_severity() {
1478 let config = default_config();
1479 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1480
1481 assert_eq!(
1482 processor.infer_severity("E501 line too long"),
1483 DiagnosticSeverity::Error
1484 );
1485 assert_eq!(
1486 processor.infer_severity("W291 trailing whitespace"),
1487 DiagnosticSeverity::Warning
1488 );
1489 assert_eq!(
1490 processor.infer_severity("error: something failed"),
1491 DiagnosticSeverity::Error
1492 );
1493 assert_eq!(
1494 processor.infer_severity("warning: unused variable"),
1495 DiagnosticSeverity::Warning
1496 );
1497 assert_eq!(
1498 processor.infer_severity("note: consider using"),
1499 DiagnosticSeverity::Info
1500 );
1501 }
1502
1503 #[test]
1504 fn test_parse_standard_format_windows_path() {
1505 let config = default_config();
1506 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1507
1508 let output = ToolOutput {
1509 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1510 stderr: String::new(),
1511 exit_code: 1,
1512 success: false,
1513 };
1514
1515 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1516 assert_eq!(diags.len(), 1);
1517 assert_eq!(diags[0].file_line, 12);
1518 assert_eq!(diags[0].column, Some(5));
1519 assert_eq!(diags[0].message, "E123 message");
1520 }
1521
1522 #[test]
1523 fn test_parse_eslint_severity() {
1524 let config = default_config();
1525 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1526
1527 let output = ToolOutput {
1528 stdout: "1:2 error Unexpected token".to_string(),
1529 stderr: String::new(),
1530 exit_code: 1,
1531 success: false,
1532 };
1533
1534 let diags = processor.parse_tool_output(&output, "eslint", 5);
1535 assert_eq!(diags.len(), 1);
1536 assert_eq!(diags[0].file_line, 6);
1537 assert_eq!(diags[0].column, Some(2));
1538 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1539 assert_eq!(diags[0].message, "Unexpected token");
1540 }
1541
1542 #[test]
1543 fn test_parse_shellcheck_multiline() {
1544 let config = default_config();
1545 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1546
1547 let output = ToolOutput {
1548 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1549 stderr: String::new(),
1550 exit_code: 1,
1551 success: false,
1552 };
1553
1554 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1555 assert_eq!(diags.len(), 1);
1556 assert_eq!(diags[0].file_line, 13);
1557 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1558 assert_eq!(diags[0].message, "Double quote to prevent globbing");
1559 }
1560
1561 #[test]
1562 fn test_lint_no_config() {
1563 let config = default_config();
1564 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1565
1566 let content = "```python\nprint('hello')\n```";
1567 let result = processor.lint(content);
1568
1569 assert!(result.is_ok());
1571 assert!(result.unwrap().is_empty());
1572 }
1573
1574 #[test]
1575 fn test_format_no_config() {
1576 let config = default_config();
1577 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1578
1579 let content = "```python\nprint('hello')\n```";
1580 let result = processor.format(content);
1581
1582 assert!(result.is_ok());
1584 let output = result.unwrap();
1585 assert_eq!(output.content, content);
1586 assert!(!output.had_errors);
1587 assert!(output.error_messages.is_empty());
1588 }
1589
1590 #[test]
1591 fn test_lint_on_missing_language_definition_fail() {
1592 let mut config = default_config();
1593 config.on_missing_language_definition = OnMissing::Fail;
1594 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1595
1596 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1597 let result = processor.lint(content);
1598
1599 assert!(result.is_ok());
1601 let diagnostics = result.unwrap();
1602 assert_eq!(diagnostics.len(), 2);
1603 assert!(diagnostics[0].message.contains("No lint tools configured"));
1604 assert!(diagnostics[0].message.contains("python"));
1605 assert!(diagnostics[1].message.contains("javascript"));
1606 }
1607
1608 #[test]
1609 fn test_lint_on_missing_language_definition_fail_fast() {
1610 let mut config = default_config();
1611 config.on_missing_language_definition = OnMissing::FailFast;
1612 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1613
1614 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1615 let result = processor.lint(content);
1616
1617 assert!(result.is_err());
1619 let err = result.unwrap_err();
1620 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1621 }
1622
1623 #[test]
1624 fn test_format_on_missing_language_definition_fail() {
1625 let mut config = default_config();
1626 config.on_missing_language_definition = OnMissing::Fail;
1627 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1628
1629 let content = "```python\nprint('hello')\n```";
1630 let result = processor.format(content);
1631
1632 assert!(result.is_ok());
1634 let output = result.unwrap();
1635 assert_eq!(output.content, content); assert!(output.had_errors);
1637 assert!(!output.error_messages.is_empty());
1638 assert!(output.error_messages[0].contains("No format tools configured"));
1639 }
1640
1641 #[test]
1642 fn test_format_on_missing_language_definition_fail_fast() {
1643 let mut config = default_config();
1644 config.on_missing_language_definition = OnMissing::FailFast;
1645 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1646
1647 let content = "```python\nprint('hello')\n```";
1648 let result = processor.format(content);
1649
1650 assert!(result.is_err());
1652 let err = result.unwrap_err();
1653 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1654 }
1655
1656 #[test]
1657 fn test_lint_on_missing_tool_binary_fail() {
1658 use super::super::config::{LanguageToolConfig, ToolDefinition};
1659
1660 let mut config = default_config();
1661 config.on_missing_tool_binary = OnMissing::Fail;
1662
1663 let lang_config = LanguageToolConfig {
1665 lint: vec!["nonexistent-linter".to_string()],
1666 ..Default::default()
1667 };
1668 config.languages.insert("python".to_string(), lang_config);
1669
1670 let tool_def = ToolDefinition {
1671 command: vec!["nonexistent-binary-xyz123".to_string()],
1672 ..Default::default()
1673 };
1674 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1675
1676 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1677
1678 let content = "```python\nprint('hello')\n```";
1679 let result = processor.lint(content);
1680
1681 assert!(result.is_ok());
1683 let diagnostics = result.unwrap();
1684 assert_eq!(diagnostics.len(), 1);
1685 assert!(diagnostics[0].message.contains("not found in PATH"));
1686 }
1687
1688 #[test]
1689 fn test_lint_on_missing_tool_binary_fail_fast() {
1690 use super::super::config::{LanguageToolConfig, ToolDefinition};
1691
1692 let mut config = default_config();
1693 config.on_missing_tool_binary = OnMissing::FailFast;
1694
1695 let lang_config = LanguageToolConfig {
1697 lint: vec!["nonexistent-linter".to_string()],
1698 ..Default::default()
1699 };
1700 config.languages.insert("python".to_string(), lang_config);
1701
1702 let tool_def = ToolDefinition {
1703 command: vec!["nonexistent-binary-xyz123".to_string()],
1704 ..Default::default()
1705 };
1706 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1707
1708 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1709
1710 let content = "```python\nprint('hello')\n```";
1711 let result = processor.lint(content);
1712
1713 assert!(result.is_err());
1715 let err = result.unwrap_err();
1716 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1717 }
1718
1719 #[test]
1720 fn test_format_on_missing_tool_binary_fail() {
1721 use super::super::config::{LanguageToolConfig, ToolDefinition};
1722
1723 let mut config = default_config();
1724 config.on_missing_tool_binary = OnMissing::Fail;
1725
1726 let lang_config = LanguageToolConfig {
1728 format: vec!["nonexistent-formatter".to_string()],
1729 ..Default::default()
1730 };
1731 config.languages.insert("python".to_string(), lang_config);
1732
1733 let tool_def = ToolDefinition {
1734 command: vec!["nonexistent-binary-xyz123".to_string()],
1735 ..Default::default()
1736 };
1737 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1738
1739 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1740
1741 let content = "```python\nprint('hello')\n```";
1742 let result = processor.format(content);
1743
1744 assert!(result.is_ok());
1746 let output = result.unwrap();
1747 assert_eq!(output.content, content); assert!(output.had_errors);
1749 assert!(!output.error_messages.is_empty());
1750 assert!(output.error_messages[0].contains("not found in PATH"));
1751 }
1752
1753 #[test]
1754 fn test_format_on_missing_tool_binary_fail_fast() {
1755 use super::super::config::{LanguageToolConfig, ToolDefinition};
1756
1757 let mut config = default_config();
1758 config.on_missing_tool_binary = OnMissing::FailFast;
1759
1760 let lang_config = LanguageToolConfig {
1762 format: vec!["nonexistent-formatter".to_string()],
1763 ..Default::default()
1764 };
1765 config.languages.insert("python".to_string(), lang_config);
1766
1767 let tool_def = ToolDefinition {
1768 command: vec!["nonexistent-binary-xyz123".to_string()],
1769 ..Default::default()
1770 };
1771 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1772
1773 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1774
1775 let content = "```python\nprint('hello')\n```";
1776 let result = processor.format(content);
1777
1778 assert!(result.is_err());
1780 let err = result.unwrap_err();
1781 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1782 }
1783
1784 #[test]
1785 fn test_lint_rumdl_builtin_skipped_for_markdown() {
1786 let mut config = default_config();
1789 config.languages.insert(
1790 "markdown".to_string(),
1791 LanguageToolConfig {
1792 lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
1793 ..Default::default()
1794 },
1795 );
1796 config.on_missing_language_definition = OnMissing::Fail;
1797 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1798
1799 let content = "```markdown\n# Hello\n```";
1800 let result = processor.lint(content);
1801
1802 assert!(result.is_ok());
1804 assert!(result.unwrap().is_empty());
1805 }
1806
1807 #[test]
1808 fn test_format_rumdl_builtin_skipped_for_markdown() {
1809 let mut config = default_config();
1811 config.languages.insert(
1812 "markdown".to_string(),
1813 LanguageToolConfig {
1814 format: vec![RUMDL_BUILTIN_TOOL.to_string()],
1815 ..Default::default()
1816 },
1817 );
1818 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1819
1820 let content = "```markdown\n# Hello\n```";
1821 let result = processor.format(content);
1822
1823 assert!(result.is_ok());
1825 let output = result.unwrap();
1826 assert_eq!(output.content, content);
1827 assert!(!output.had_errors);
1828 }
1829
1830 #[test]
1831 fn test_is_markdown_language() {
1832 assert!(is_markdown_language("markdown"));
1834 assert!(is_markdown_language("Markdown"));
1835 assert!(is_markdown_language("MARKDOWN"));
1836 assert!(is_markdown_language("md"));
1837 assert!(is_markdown_language("MD"));
1838 assert!(!is_markdown_language("python"));
1839 assert!(!is_markdown_language("rust"));
1840 assert!(!is_markdown_language(""));
1841 }
1842
1843 #[test]
1846 fn test_extract_mkdocs_admonition_code_block() {
1847 let config = default_config();
1848 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1849
1850 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
1851 let blocks = processor.extract_code_blocks(content);
1852
1853 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
1854 assert_eq!(blocks[0].language, "python");
1855 }
1856
1857 #[test]
1858 fn test_extract_mkdocs_tab_code_block() {
1859 let config = default_config();
1860 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1861
1862 let content = "=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n";
1863 let blocks = processor.extract_code_blocks(content);
1864
1865 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
1866 assert_eq!(blocks[0].language, "python");
1867 }
1868
1869 #[test]
1870 fn test_standard_flavor_ignores_admonition_indented_content() {
1871 let config = default_config();
1872 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1873
1874 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
1877 let blocks = processor.extract_code_blocks(content);
1878
1879 for (i, b) in blocks.iter().enumerate() {
1883 for (j, b2) in blocks.iter().enumerate() {
1884 if i != j {
1885 assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
1886 }
1887 }
1888 }
1889 }
1890
1891 #[test]
1892 fn test_mkdocs_top_level_blocks_alongside_admonition() {
1893 let config = default_config();
1894 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1895
1896 let content =
1897 "```rust\nfn main() {}\n```\n\n!!! note\n Some text\n\n ```python\n print(\"hello\")\n ```\n";
1898 let blocks = processor.extract_code_blocks(content);
1899
1900 assert_eq!(
1901 blocks.len(),
1902 2,
1903 "Should detect both top-level and admonition code blocks"
1904 );
1905 assert_eq!(blocks[0].language, "rust");
1906 assert_eq!(blocks[1].language, "python");
1907 }
1908
1909 #[test]
1910 fn test_mkdocs_nested_admonition_code_block() {
1911 let config = default_config();
1912 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1913
1914 let content = "\
1915!!! note
1916 Some text
1917
1918 !!! warning
1919 Nested content
1920
1921 ```python
1922 x = 1
1923 ```
1924";
1925 let blocks = processor.extract_code_blocks(content);
1926 assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
1927 assert_eq!(blocks[0].language, "python");
1928 }
1929
1930 #[test]
1931 fn test_mkdocs_consecutive_admonitions_no_stale_context() {
1932 let config = default_config();
1933 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1934
1935 let content = "\
1938!!! note
1939 First admonition content
1940
1941!!! warning
1942 Second admonition content
1943
1944 ```python
1945 y = 2
1946 ```
1947";
1948 let blocks = processor.extract_code_blocks(content);
1949 assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
1950 assert_eq!(blocks[0].language, "python");
1951 }
1952
1953 #[test]
1954 fn test_mkdocs_crlf_line_endings() {
1955 let config = default_config();
1956 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1957
1958 let content = "!!! note\r\n Some text\r\n\r\n ```python\r\n x = 1\r\n ```\r\n";
1960 let blocks = processor.extract_code_blocks(content);
1961
1962 assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
1963 assert_eq!(blocks[0].language, "python");
1964
1965 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1967 assert!(
1968 extracted.contains("x = 1"),
1969 "Extracted content should contain code. Got: {extracted:?}"
1970 );
1971 }
1972
1973 #[test]
1974 fn test_mkdocs_unclosed_fence_in_admonition() {
1975 let config = default_config();
1976 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1977
1978 let content = "!!! note\n ```python\n x = 1\n no closing fence\n";
1980 let blocks = processor.extract_code_blocks(content);
1981 assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
1982 }
1983
1984 #[test]
1985 fn test_mkdocs_tilde_fence_in_admonition() {
1986 let config = default_config();
1987 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1988
1989 let content = "!!! note\n ~~~ruby\n puts 'hi'\n ~~~\n";
1990 let blocks = processor.extract_code_blocks(content);
1991 assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
1992 assert_eq!(blocks[0].language, "ruby");
1993 }
1994
1995 #[test]
1996 fn test_mkdocs_empty_lines_in_code_block() {
1997 let config = default_config();
1998 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1999
2000 let content = "!!! note\n ```python\n x = 1\n\n y = 2\n ```\n";
2003 let blocks = processor.extract_code_blocks(content);
2004 assert_eq!(blocks.len(), 1);
2005
2006 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2007 assert!(
2008 extracted.contains("x = 1") && extracted.contains("y = 2"),
2009 "Extracted content should span across the empty line. Got: {extracted:?}"
2010 );
2011 }
2012
2013 #[test]
2014 fn test_mkdocs_content_byte_offsets_lf() {
2015 let config = default_config();
2016 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2017
2018 let content = "!!! note\n ```python\n print('hi')\n ```\n";
2019 let blocks = processor.extract_code_blocks(content);
2020 assert_eq!(blocks.len(), 1);
2021
2022 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2024 assert_eq!(extracted, " print('hi')\n", "Content offsets should be exact for LF");
2025 }
2026
2027 #[test]
2028 fn test_mkdocs_content_byte_offsets_crlf() {
2029 let config = default_config();
2030 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2031
2032 let content = "!!! note\r\n ```python\r\n print('hi')\r\n ```\r\n";
2033 let blocks = processor.extract_code_blocks(content);
2034 assert_eq!(blocks.len(), 1);
2035
2036 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2037 assert_eq!(
2038 extracted, " print('hi')\r\n",
2039 "Content offsets should be exact for CRLF"
2040 );
2041 }
2042
2043 #[test]
2044 fn test_lint_enabled_false_skips_language_in_strict_mode() {
2045 let mut config = default_config();
2048 config.normalize_language = NormalizeLanguage::Exact;
2049 config.on_missing_language_definition = OnMissing::Fail;
2050
2051 config.languages.insert(
2053 "python".to_string(),
2054 LanguageToolConfig {
2055 lint: vec!["ruff:check".to_string()],
2056 ..Default::default()
2057 },
2058 );
2059 config.languages.insert(
2060 "plaintext".to_string(),
2061 LanguageToolConfig {
2062 enabled: false,
2063 ..Default::default()
2064 },
2065 );
2066
2067 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2068
2069 let content = "```plaintext\nsome text\n```";
2070 let result = processor.lint(content);
2071
2072 assert!(result.is_ok());
2074 let diagnostics = result.unwrap();
2075 assert!(
2076 diagnostics.is_empty(),
2077 "Expected no diagnostics for disabled language, got: {diagnostics:?}"
2078 );
2079 }
2080
2081 #[test]
2082 fn test_format_enabled_false_skips_language_in_strict_mode() {
2083 let mut config = default_config();
2085 config.normalize_language = NormalizeLanguage::Exact;
2086 config.on_missing_language_definition = OnMissing::Fail;
2087
2088 config.languages.insert(
2089 "plaintext".to_string(),
2090 LanguageToolConfig {
2091 enabled: false,
2092 ..Default::default()
2093 },
2094 );
2095
2096 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2097
2098 let content = "```plaintext\nsome text\n```";
2099 let result = processor.format(content);
2100
2101 assert!(result.is_ok());
2103 let output = result.unwrap();
2104 assert!(!output.had_errors, "Expected no errors for disabled language");
2105 assert!(
2106 output.error_messages.is_empty(),
2107 "Expected no error messages, got: {:?}",
2108 output.error_messages
2109 );
2110 }
2111
2112 #[test]
2113 fn test_enabled_false_default_true_preserved() {
2114 let mut config = default_config();
2116 config.on_missing_language_definition = OnMissing::Fail;
2117
2118 config.languages.insert(
2120 "python".to_string(),
2121 LanguageToolConfig {
2122 lint: vec!["ruff:check".to_string()],
2123 ..Default::default()
2124 },
2125 );
2126
2127 let lang_config = config.languages.get("python").unwrap();
2128 assert!(lang_config.enabled, "enabled should default to true");
2129 }
2130
2131 #[test]
2132 fn test_enabled_false_with_fail_fast_no_error() {
2133 let mut config = default_config();
2135 config.normalize_language = NormalizeLanguage::Exact;
2136 config.on_missing_language_definition = OnMissing::FailFast;
2137
2138 config.languages.insert(
2139 "unknown".to_string(),
2140 LanguageToolConfig {
2141 enabled: false,
2142 ..Default::default()
2143 },
2144 );
2145
2146 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2147
2148 let content = "```unknown\nsome content\n```";
2149 let result = processor.lint(content);
2150
2151 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2153 assert!(result.unwrap().is_empty());
2154 }
2155
2156 #[test]
2157 fn test_enabled_false_format_with_fail_fast_no_error() {
2158 let mut config = default_config();
2160 config.normalize_language = NormalizeLanguage::Exact;
2161 config.on_missing_language_definition = OnMissing::FailFast;
2162
2163 config.languages.insert(
2164 "unknown".to_string(),
2165 LanguageToolConfig {
2166 enabled: false,
2167 ..Default::default()
2168 },
2169 );
2170
2171 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2172
2173 let content = "```unknown\nsome content\n```";
2174 let result = processor.format(content);
2175
2176 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2177 let output = result.unwrap();
2178 assert!(!output.had_errors);
2179 }
2180
2181 #[test]
2182 fn test_enabled_false_with_tools_still_skips() {
2183 let mut config = default_config();
2185 config.on_missing_language_definition = OnMissing::Fail;
2186
2187 config.languages.insert(
2188 "python".to_string(),
2189 LanguageToolConfig {
2190 enabled: false,
2191 lint: vec!["ruff:check".to_string()],
2192 format: vec!["ruff:format".to_string()],
2193 on_error: None,
2194 },
2195 );
2196
2197 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2198
2199 let content = "```python\nprint('hello')\n```";
2200
2201 let lint_result = processor.lint(content);
2203 assert!(lint_result.is_ok());
2204 assert!(lint_result.unwrap().is_empty());
2205
2206 let format_result = processor.format(content);
2208 assert!(format_result.is_ok());
2209 let output = format_result.unwrap();
2210 assert!(!output.had_errors);
2211 assert_eq!(output.content, content, "Content should be unchanged");
2212 }
2213
2214 #[test]
2215 fn test_enabled_true_without_tools_triggers_strict_mode() {
2216 let mut config = default_config();
2219 config.on_missing_language_definition = OnMissing::Fail;
2220
2221 config.languages.insert(
2222 "python".to_string(),
2223 LanguageToolConfig {
2224 ..Default::default()
2226 },
2227 );
2228
2229 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2230
2231 let content = "```python\nprint('hello')\n```";
2232 let result = processor.lint(content);
2233
2234 assert!(result.is_ok());
2236 let diagnostics = result.unwrap();
2237 assert_eq!(diagnostics.len(), 1);
2238 assert!(diagnostics[0].message.contains("No lint tools configured"));
2239 }
2240
2241 #[test]
2242 fn test_mixed_enabled_and_disabled_languages() {
2243 let mut config = default_config();
2245 config.normalize_language = NormalizeLanguage::Exact;
2246 config.on_missing_language_definition = OnMissing::Fail;
2247
2248 config.languages.insert(
2249 "plaintext".to_string(),
2250 LanguageToolConfig {
2251 enabled: false,
2252 ..Default::default()
2253 },
2254 );
2255
2256 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2257
2258 let content = "\
2259```plaintext
2260some text
2261```
2262
2263```javascript
2264console.log('hi');
2265```
2266";
2267
2268 let result = processor.lint(content);
2269 assert!(result.is_ok());
2270 let diagnostics = result.unwrap();
2271
2272 assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2275 assert!(
2276 diagnostics[0].message.contains("javascript"),
2277 "Error should be about javascript, got: {}",
2278 diagnostics[0].message
2279 );
2280 }
2281
2282 #[test]
2283 fn test_generic_fallback_includes_all_stderr_lines() {
2284 let config = default_config();
2285 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2286
2287 let output = ToolOutput {
2289 stdout: String::new(),
2290 stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2291 exit_code: 1,
2292 success: false,
2293 };
2294
2295 let diags = processor.parse_tool_output(&output, "tombi", 5);
2296 assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2297 assert_eq!(diags[0].message, "Parse error at position 42");
2298 assert_eq!(diags[1].message, "Unexpected token '::'");
2299 assert_eq!(diags[2].message, "3 errors found");
2300 assert!(diags.iter().all(|d| d.tool == "tombi"));
2301 assert!(diags.iter().all(|d| d.file_line == 5));
2302 }
2303
2304 #[test]
2305 fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2306 let config = default_config();
2307 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2308
2309 let output = ToolOutput {
2310 stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2311 stderr: String::new(),
2312 exit_code: 1,
2313 success: false,
2314 };
2315
2316 let diags = processor.parse_tool_output(&output, "some-tool", 10);
2317 assert_eq!(diags.len(), 3);
2318 assert_eq!(diags[0].message, "Line 1 error");
2319 assert_eq!(diags[1].message, "Line 2 detail");
2320 assert_eq!(diags[2].message, "Line 3 summary");
2321 }
2322
2323 #[test]
2324 fn test_generic_fallback_skips_blank_lines() {
2325 let config = default_config();
2326 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2327
2328 let output = ToolOutput {
2329 stdout: String::new(),
2330 stderr: "error: bad input\n\n \n\ndetail: see above\n".to_string(),
2331 exit_code: 1,
2332 success: false,
2333 };
2334
2335 let diags = processor.parse_tool_output(&output, "tool", 1);
2336 assert_eq!(diags.len(), 2);
2337 assert_eq!(diags[0].message, "error: bad input");
2338 assert_eq!(diags[1].message, "detail: see above");
2339 }
2340
2341 #[test]
2342 fn test_generic_fallback_exit_code_when_no_output() {
2343 let config = default_config();
2344 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2345
2346 let output = ToolOutput {
2347 stdout: String::new(),
2348 stderr: String::new(),
2349 exit_code: 42,
2350 success: false,
2351 };
2352
2353 let diags = processor.parse_tool_output(&output, "tool", 1);
2354 assert_eq!(diags.len(), 1);
2355 assert_eq!(diags[0].message, "Tool exited with code 42");
2356 }
2357
2358 #[test]
2359 fn test_generic_fallback_not_triggered_on_success() {
2360 let config = default_config();
2361 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2362
2363 let output = ToolOutput {
2364 stdout: "some informational output".to_string(),
2365 stderr: String::new(),
2366 exit_code: 0,
2367 success: true,
2368 };
2369
2370 let diags = processor.parse_tool_output(&output, "tool", 1);
2371 assert!(
2372 diags.is_empty(),
2373 "Successful tool runs should produce no fallback diagnostics"
2374 );
2375 }
2376
2377 #[test]
2378 fn test_ansi_codes_stripped_before_parsing() {
2379 let config = default_config();
2380 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2381
2382 let output = ToolOutput {
2384 stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
2385 .to_string(),
2386 stderr: String::new(),
2387 exit_code: 1,
2388 success: false,
2389 };
2390
2391 let diags = processor.parse_tool_output(&output, "ruff:check", 5);
2392 assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
2393 assert_eq!(diags[0].message, "E501 Line too long");
2394 assert_eq!(diags[0].file_line, 6); }
2396
2397 #[test]
2398 fn test_tombi_multiline_error_format() {
2399 let config = default_config();
2400 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2401
2402 let output = ToolOutput {
2404 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(),
2405 stderr: "1 file failed to be formatted".to_string(),
2406 exit_code: 1,
2407 success: false,
2408 };
2409
2410 let diags = processor.parse_tool_output(&output, "tombi", 7);
2411 assert_eq!(
2412 diags.len(),
2413 4,
2414 "Expected 4 diagnostics from tombi errors, got {diags:?}"
2415 );
2416 assert_eq!(diags[0].message, "invalid key");
2417 assert_eq!(diags[0].file_line, 9); assert_eq!(diags[0].column, Some(1));
2419 assert_eq!(diags[1].message, "expected key");
2420 assert_eq!(diags[1].file_line, 9);
2421 assert_eq!(diags[2].message, "expected '='");
2422 assert_eq!(diags[3].message, "expected value");
2423 assert!(diags.iter().all(|d| d.tool == "tombi"));
2424 }
2425
2426 #[test]
2427 fn test_tombi_with_ansi_codes() {
2428 let config = default_config();
2429 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2430
2431 let output = ToolOutput {
2433 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(),
2434 stderr: "1 file failed to be formatted".to_string(),
2435 exit_code: 1,
2436 success: false,
2437 };
2438
2439 let diags = processor.parse_tool_output(&output, "tombi", 7);
2440 assert_eq!(
2441 diags.len(),
2442 2,
2443 "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
2444 );
2445 assert_eq!(diags[0].message, "invalid key");
2446 assert_eq!(diags[0].file_line, 9);
2447 assert_eq!(diags[1].message, "expected '='");
2448 assert_eq!(diags[1].file_line, 9);
2449 }
2450
2451 #[test]
2452 fn test_fallback_combines_stdout_and_stderr() {
2453 let config = default_config();
2454 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2455
2456 let output = ToolOutput {
2458 stdout: "problem found in input".to_string(),
2459 stderr: "1 file failed".to_string(),
2460 exit_code: 1,
2461 success: false,
2462 };
2463
2464 let diags = processor.parse_tool_output(&output, "tool", 1);
2465 assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
2466 assert_eq!(diags[0].message, "problem found in input");
2467 assert_eq!(diags[1].message, "1 file failed");
2468 }
2469
2470 #[test]
2471 fn test_error_line_without_position_info() {
2472 let config = default_config();
2473 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2474
2475 let output = ToolOutput {
2477 stdout: "Error: something went wrong\nsome unrelated line".to_string(),
2478 stderr: String::new(),
2479 exit_code: 1,
2480 success: false,
2481 };
2482
2483 let diags = processor.parse_tool_output(&output, "tool", 5);
2484 assert!(!diags.is_empty());
2487 assert_eq!(diags[0].message, "something went wrong");
2488 assert_eq!(diags[0].file_line, 5); }
2490
2491 #[test]
2492 fn test_warning_line_with_position() {
2493 let config = default_config();
2494 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2495
2496 let output = ToolOutput {
2497 stdout: "Warning: deprecated syntax\n at line 3 column 5".to_string(),
2498 stderr: String::new(),
2499 exit_code: 1,
2500 success: false,
2501 };
2502
2503 let diags = processor.parse_tool_output(&output, "tool", 10);
2504 assert_eq!(diags.len(), 1);
2505 assert_eq!(diags[0].message, "deprecated syntax");
2506 assert_eq!(diags[0].file_line, 13); assert_eq!(diags[0].column, Some(5));
2508 assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
2509 }
2510
2511 #[test]
2512 fn test_strip_ansi_codes() {
2513 assert_eq!(strip_ansi_codes("hello"), "hello");
2514 assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
2515 assert_eq!(
2516 strip_ansi_codes("\x1b[1;31m Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
2517 " Error: msg"
2518 );
2519 assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
2520 assert_eq!(strip_ansi_codes(""), "");
2521 assert_eq!(
2522 strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
2523 "at line 2 column 1"
2524 );
2525 }
2526
2527 #[test]
2528 fn test_parse_at_line_column() {
2529 assert_eq!(
2530 CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
2531 Some((2, 1))
2532 );
2533 assert_eq!(
2534 CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
2535 Some((10, 15))
2536 );
2537 assert_eq!(
2538 CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
2539 Some((5, 3))
2540 );
2541 assert_eq!(
2542 CodeBlockToolProcessor::parse_at_line_column("not a position line"),
2543 None
2544 );
2545 assert_eq!(
2546 CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
2547 None
2548 );
2549 }
2550
2551 #[test]
2552 fn test_parse_error_line() {
2553 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
2554 assert_eq!(msg, "invalid key");
2555 assert!(matches!(sev, DiagnosticSeverity::Error));
2556
2557 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
2558 assert_eq!(msg, "deprecated");
2559 assert!(matches!(sev, DiagnosticSeverity::Warning));
2560
2561 assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
2563 assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
2564
2565 assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
2567 assert!(CodeBlockToolProcessor::parse_error_line("Error: ").is_none());
2568
2569 assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
2571 }
2572
2573 #[test]
2574 fn test_consecutive_error_lines_without_position() {
2575 let config = default_config();
2576 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2577
2578 let output = ToolOutput {
2581 stdout: "Error: first problem\nError: second problem\n at line 3 column 1".to_string(),
2582 stderr: String::new(),
2583 exit_code: 1,
2584 success: false,
2585 };
2586
2587 let diags = processor.parse_tool_output(&output, "tool", 5);
2588 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
2589 assert_eq!(diags[0].message, "first problem");
2591 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
2593 assert_eq!(diags[1].message, "second problem");
2595 assert_eq!(diags[1].file_line, 8); assert_eq!(diags[1].column, Some(1));
2597 }
2598
2599 #[test]
2600 fn test_error_line_at_end_of_output() {
2601 let config = default_config();
2602 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2603
2604 let output = ToolOutput {
2606 stdout: "Error: trailing error".to_string(),
2607 stderr: String::new(),
2608 exit_code: 1,
2609 success: false,
2610 };
2611
2612 let diags = processor.parse_tool_output(&output, "tool", 5);
2613 assert_eq!(diags.len(), 1);
2614 assert_eq!(diags[0].message, "trailing error");
2615 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
2617 }
2618
2619 #[test]
2620 fn test_blank_lines_between_error_and_position() {
2621 let config = default_config();
2622 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2623
2624 let output = ToolOutput {
2626 stdout: "Error: spaced out\n\n\n at line 4 column 2".to_string(),
2627 stderr: String::new(),
2628 exit_code: 1,
2629 success: false,
2630 };
2631
2632 let diags = processor.parse_tool_output(&output, "tool", 10);
2633 assert_eq!(diags.len(), 1);
2634 assert_eq!(diags[0].message, "spaced out");
2635 assert_eq!(diags[0].file_line, 14); assert_eq!(diags[0].column, Some(2));
2637 }
2638
2639 #[test]
2640 fn test_mixed_structured_and_error_line_parsers() {
2641 let config = default_config();
2642 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2643
2644 let output = ToolOutput {
2646 stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n at line 3 column 1".to_string(),
2647 stderr: String::new(),
2648 exit_code: 1,
2649 success: false,
2650 };
2651
2652 let diags = processor.parse_tool_output(&output, "tool", 5);
2653 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
2654 assert_eq!(diags[0].message, "E501 Line too long");
2656 assert_eq!(diags[0].file_line, 6); assert_eq!(diags[1].message, "invalid syntax");
2659 assert_eq!(diags[1].file_line, 8); }
2661
2662 #[test]
2663 fn test_at_line_without_preceding_error() {
2664 let config = default_config();
2665 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2666
2667 let output = ToolOutput {
2669 stdout: "at line 2 column 1\nsome other text".to_string(),
2670 stderr: String::new(),
2671 exit_code: 1,
2672 success: false,
2673 };
2674
2675 let diags = processor.parse_tool_output(&output, "tool", 5);
2676 assert_eq!(diags.len(), 2);
2679 assert_eq!(diags[0].message, "at line 2 column 1");
2680 assert_eq!(diags[1].message, "some other text");
2681 }
2682
2683 #[test]
2691 fn test_format_empty_output_does_not_erase_content() {
2692 use super::super::config::LanguageToolConfig;
2693
2694 let mut config = default_config();
2695 config.languages.insert(
2696 "toml".to_string(),
2697 LanguageToolConfig {
2698 format: vec!["empty-formatter".to_string()],
2699 ..Default::default()
2700 },
2701 );
2702 config.tools.insert(
2704 "empty-formatter".to_string(),
2705 super::super::config::ToolDefinition {
2706 command: vec!["true".to_string()],
2707 stdin: true,
2708 stdout: true,
2709 lint_args: vec![],
2710 format_args: vec![],
2711 },
2712 );
2713
2714 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2715
2716 let content = "```toml\nkey = \"value\"\n```\n";
2717 let result = processor.format(content);
2718
2719 assert!(result.is_ok(), "Format should not error");
2720 let output = result.unwrap();
2721
2722 assert!(
2724 output.content.contains("key = \"value\""),
2725 "Empty formatter output should not erase content. Got: {:?}",
2726 output.content
2727 );
2728 }
2729
2730 #[test]
2732 fn test_format_identity_formatter_preserves_content() {
2733 use super::super::config::LanguageToolConfig;
2734
2735 let mut config = default_config();
2736 config.languages.insert(
2737 "toml".to_string(),
2738 LanguageToolConfig {
2739 format: vec!["cat-formatter".to_string()],
2740 ..Default::default()
2741 },
2742 );
2743 config.tools.insert(
2744 "cat-formatter".to_string(),
2745 super::super::config::ToolDefinition {
2746 command: vec!["cat".to_string()],
2747 stdin: true,
2748 stdout: true,
2749 lint_args: vec![],
2750 format_args: vec![],
2751 },
2752 );
2753
2754 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2755
2756 let content = "```toml\nkey = \"value\"\n```\n";
2757 let result = processor.format(content);
2758
2759 assert!(result.is_ok(), "Format should not error");
2760 let output = result.unwrap();
2761 assert_eq!(
2762 output.content, content,
2763 "Identity formatter should preserve content exactly"
2764 );
2765 }
2766
2767 #[test]
2770 fn test_resolve_tool_context_aware_tombi() {
2771 let config = default_config();
2772 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2773
2774 let format_def = processor
2776 .resolve_tool("tombi", ToolContext::Format)
2777 .expect("Should resolve tombi in format context");
2778 assert!(
2779 format_def.command.iter().any(|arg| arg == "format"),
2780 "Bare 'tombi' in format context should resolve to 'tombi format', got: {:?}",
2781 format_def.command
2782 );
2783
2784 let lint_def = processor
2786 .resolve_tool("tombi", ToolContext::Lint)
2787 .expect("Should resolve tombi in lint context");
2788 assert!(
2789 lint_def.command.iter().any(|arg| arg == "lint"),
2790 "Bare 'tombi' in lint context should resolve to 'tombi lint', got: {:?}",
2791 lint_def.command
2792 );
2793
2794 let explicit_def = processor
2796 .resolve_tool("tombi:lint", ToolContext::Format)
2797 .expect("Should resolve explicit tombi:lint even in format context");
2798 assert!(
2799 explicit_def.command.iter().any(|arg| arg == "lint"),
2800 "Explicit 'tombi:lint' should always use lint, got: {:?}",
2801 explicit_def.command
2802 );
2803 }
2804
2805 #[test]
2807 fn test_resolve_tool_context_aware_ruff() {
2808 let config = default_config();
2809 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2810
2811 let lint_def = processor
2813 .resolve_tool("ruff", ToolContext::Lint)
2814 .expect("Should resolve ruff in lint context");
2815 assert!(
2816 lint_def.command.iter().any(|arg| arg == "check"),
2817 "Bare 'ruff' in lint context should resolve to 'ruff check', got: {:?}",
2818 lint_def.command
2819 );
2820
2821 let format_def = processor
2823 .resolve_tool("ruff", ToolContext::Format)
2824 .expect("Should resolve ruff in format context");
2825 assert!(
2826 format_def.command.iter().any(|arg| arg == "format"),
2827 "Bare 'ruff' in format context should resolve to 'ruff format', got: {:?}",
2828 format_def.command
2829 );
2830 }
2831
2832 #[test]
2834 fn test_resolve_tool_bare_name_fallback() {
2835 let config = default_config();
2836 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2837
2838 let def = processor
2840 .resolve_tool("shellcheck", ToolContext::Lint)
2841 .expect("Should resolve shellcheck via fallback");
2842 assert!(
2843 def.command.iter().any(|arg| arg == "shellcheck"),
2844 "shellcheck should resolve to itself, got: {:?}",
2845 def.command
2846 );
2847 }
2848
2849 #[test]
2851 fn test_resolve_tool_context_aware_sqlfluff() {
2852 let config = default_config();
2853 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2854
2855 let format_def = processor
2857 .resolve_tool("sqlfluff", ToolContext::Format)
2858 .expect("Should resolve sqlfluff in format context");
2859 assert!(
2860 format_def.command.iter().any(|arg| arg == "fix"),
2861 "Bare 'sqlfluff' in format context should resolve to 'sqlfluff fix', got: {:?}",
2862 format_def.command
2863 );
2864 }
2865
2866 #[test]
2868 fn test_resolve_tool_context_aware_djlint() {
2869 let config = default_config();
2870 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2871
2872 let format_def = processor
2874 .resolve_tool("djlint", ToolContext::Format)
2875 .expect("Should resolve djlint in format context");
2876 assert!(
2877 format_def.command.iter().any(|arg| arg.contains("reformat")),
2878 "Bare 'djlint' in format context should resolve to djlint reformat, got: {:?}",
2879 format_def.command
2880 );
2881 }
2882
2883 #[test]
2885 fn test_resolve_tool_user_defined_with_context_variant() {
2886 use super::super::config::ToolDefinition;
2887
2888 let mut config = default_config();
2889 config.tools.insert(
2890 "mytool".to_string(),
2891 ToolDefinition {
2892 command: vec!["mytool".to_string(), "--lint".to_string()],
2893 ..Default::default()
2894 },
2895 );
2896 config.tools.insert(
2897 "mytool:format".to_string(),
2898 ToolDefinition {
2899 command: vec!["mytool".to_string(), "--format".to_string()],
2900 ..Default::default()
2901 },
2902 );
2903
2904 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2905
2906 let def = processor
2908 .resolve_tool("mytool", ToolContext::Format)
2909 .expect("Should resolve user tool in format context");
2910 assert!(
2911 def.command.iter().any(|arg| arg == "--format"),
2912 "User 'mytool' in format context should resolve to mytool:format, got: {:?}",
2913 def.command
2914 );
2915
2916 let def = processor
2918 .resolve_tool("mytool", ToolContext::Lint)
2919 .expect("Should resolve user tool in lint context via fallback");
2920 assert!(
2921 def.command.iter().any(|arg| arg == "--lint"),
2922 "User 'mytool' in lint context should fall back to bare name, got: {:?}",
2923 def.command
2924 );
2925 }
2926
2927 #[test]
2929 fn test_resolve_tool_nonexistent_returns_none() {
2930 let config = default_config();
2931 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2932
2933 assert!(
2934 processor
2935 .resolve_tool("nonexistent-tool-xyz", ToolContext::Lint)
2936 .is_none(),
2937 "Nonexistent tool should return None in lint context"
2938 );
2939 assert!(
2940 processor
2941 .resolve_tool("nonexistent-tool-xyz", ToolContext::Format)
2942 .is_none(),
2943 "Nonexistent tool should return None in format context"
2944 );
2945 }
2946
2947 #[test]
2948 fn test_strip_ansi_codes_edge_cases() {
2949 assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
2951 assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
2953 assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
2955 assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
2957 assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
2958 }
2959}