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