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