1use super::config::{CodeBlockToolsConfig, LanguageToolConfig, NormalizeLanguage, OnError, OnMissing};
7use super::executor::{ExecutorError, ToolExecutor, ToolOutput};
8use super::linguist::LinguistResolver;
9use super::registry::{BuiltinLintMode, ToolRegistry, ToolSlot};
10use crate::config::MarkdownFlavor;
11use crate::rule::{LintWarning, Severity};
12use crate::utils::rumdl_parser_options;
13use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
14
15pub const RUMDL_BUILTIN_TOOL: &str = "rumdl";
19
20pub fn is_rumdl_builtin(tool_id: &str) -> bool {
22 matches!(tool_id, RUMDL_BUILTIN_TOOL | "rumdl:lint" | "rumdl:format")
23}
24
25fn is_markdown_language(lang: &str) -> bool {
27 matches!(lang.to_lowercase().as_str(), "markdown" | "md")
28}
29
30fn strip_ansi_codes(s: &str) -> String {
35 let mut result = String::with_capacity(s.len());
36 let mut chars = s.chars().peekable();
37 while let Some(c) = chars.next() {
38 if c == '\x1b' {
39 if chars.peek() == Some(&'[') {
40 chars.next();
41 while let Some(&next) = chars.peek() {
43 chars.next();
44 if next.is_ascii_alphabetic() {
45 break;
46 }
47 }
48 }
49 } else {
50 result.push(c);
51 }
52 }
53 result
54}
55
56fn decode_workflow_command(message: &str) -> String {
62 let mut decoded = String::with_capacity(message.len());
63 let mut rest = message;
64
65 while let Some(index) = rest.find('%') {
66 decoded.push_str(&rest[..index]);
67 let escape = rest[index..].get(..3).unwrap_or_default();
68 let replacement = if escape.eq_ignore_ascii_case("%25") {
69 "%"
70 } else if escape.eq_ignore_ascii_case("%0a") || escape.eq_ignore_ascii_case("%0d") {
71 " "
72 } else if escape.eq_ignore_ascii_case("%3a") {
73 ":"
74 } else if escape.eq_ignore_ascii_case("%2c") {
75 ","
76 } else {
77 decoded.push('%');
79 rest = &rest[index + 1..];
80 continue;
81 };
82 decoded.push_str(replacement);
83 rest = &rest[index + 3..];
84 }
85
86 decoded.push_str(rest);
87 decoded
88}
89
90fn ensure_trailing_newline(content: &str) -> std::borrow::Cow<'_, str> {
99 if content.is_empty() || content.ends_with('\n') {
100 std::borrow::Cow::Borrowed(content)
101 } else {
102 std::borrow::Cow::Owned(format!("{content}\n"))
103 }
104}
105
106#[derive(Debug, Clone)]
108pub struct FencedCodeBlockInfo {
109 pub start_line: usize,
111 pub end_line: usize,
113 pub content_start: usize,
115 pub content_end: usize,
117 pub language: String,
119 pub info_string: String,
121 pub fence_char: char,
123 pub fence_length: usize,
125 pub indent: usize,
127 pub indent_prefix: String,
129}
130
131#[derive(Debug, Clone)]
133pub struct CodeBlockDiagnostic {
134 pub file_line: usize,
136 pub column: Option<usize>,
138 pub message: String,
140 pub severity: DiagnosticSeverity,
142 pub tool: String,
144 pub code_block_start: usize,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum DiagnosticSeverity {
151 Error,
152 Warning,
153 Info,
154}
155
156impl CodeBlockDiagnostic {
157 pub fn to_lint_warning(&self) -> LintWarning {
159 let severity = match self.severity {
160 DiagnosticSeverity::Error => Severity::Error,
161 DiagnosticSeverity::Warning => Severity::Warning,
162 DiagnosticSeverity::Info => Severity::Info,
163 };
164
165 LintWarning {
166 message: self.message.clone(),
167 line: self.file_line,
168 column: self.column.unwrap_or(1),
169 end_line: self.file_line,
170 end_column: self.column.unwrap_or(1),
171 severity,
172 fix: None, rule_name: Some(self.tool.clone()),
174 }
175 }
176}
177
178#[derive(Debug, Clone)]
180pub enum ProcessorError {
181 ToolError(ExecutorError),
183 ToolErrorAt {
185 error: ExecutorError,
186 line: usize,
187 language: String,
188 },
189 NoToolsConfigured { language: String, line: usize },
191 ToolBinaryNotFound {
193 tool: String,
194 language: String,
195 line: usize,
196 },
197 Aborted { message: String },
199}
200
201impl std::fmt::Display for ProcessorError {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 match self {
204 Self::ToolError(e) => write!(f, "{e}"),
205 Self::ToolErrorAt { error, line, language } => {
206 write!(f, "line {line} ({language}): {error}")
207 }
208 Self::NoToolsConfigured { language, line } => {
209 write!(f, "line {line} ({language}): no tools configured")
210 }
211 Self::ToolBinaryNotFound { tool, language, line } => {
212 write!(f, "line {line} ({language}): tool '{tool}' not found in PATH")
213 }
214 Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
215 }
216 }
217}
218
219impl std::error::Error for ProcessorError {}
220
221impl From<ExecutorError> for ProcessorError {
222 fn from(e: ExecutorError) -> Self {
223 Self::ToolError(e)
224 }
225}
226
227#[derive(Debug)]
229pub struct CodeBlockResult {
230 pub diagnostics: Vec<CodeBlockDiagnostic>,
232 pub formatted_content: Option<String>,
234 pub was_modified: bool,
236}
237
238#[derive(Debug)]
240pub struct FormatOutput {
241 pub content: String,
243 pub had_errors: bool,
245 pub error_messages: Vec<String>,
249 pub failures: Vec<CodeBlockDiagnostic>,
258}
259
260impl FormatOutput {
261 pub fn failed(&self) -> bool {
263 !self.failures.is_empty()
264 }
265}
266
267pub struct CodeBlockToolProcessor<'a> {
269 config: &'a CodeBlockToolsConfig,
270 flavor: MarkdownFlavor,
271 linguist: LinguistResolver,
272 registry: ToolRegistry,
273 executor: ToolExecutor,
274 user_aliases: std::collections::HashMap<String, String>,
275 languages: std::collections::HashMap<String, &'a LanguageToolConfig>,
277}
278
279impl<'a> CodeBlockToolProcessor<'a> {
280 pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
282 let user_aliases = config
283 .language_aliases
284 .iter()
285 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
286 .collect();
287 let languages = config.languages.iter().map(|(k, v)| (k.to_lowercase(), v)).collect();
291 Self {
292 config,
293 flavor,
294 linguist: LinguistResolver::new(),
295 registry: ToolRegistry::new(config.tools.clone()),
296 executor: ToolExecutor::new(config.timeout),
297 user_aliases,
298 languages,
299 }
300 }
301
302 fn language_config(&self, canonical_lang: &str) -> Option<&'a LanguageToolConfig> {
304 self.languages.get(canonical_lang).copied()
305 }
306
307 fn warn_unknown_tool(&self, tool_id: &str, canonical_lang: &str) {
314 if self.config.values_withheld {
315 let withheld = crate::config::WITHHELD;
316 log::warn!("Unknown tool {withheld} configured for language {withheld}");
317 } else {
318 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
319 }
320 }
321
322 fn has_potential_matching_blocks(&self, content: &str, lint_mode: bool) -> bool {
325 let configured_langs: Vec<&str> = self
327 .languages
328 .iter()
329 .filter(|(_, lc)| {
330 lc.enabled
331 && if lint_mode {
332 !lc.lint.is_empty()
333 } else {
334 !lc.format.is_empty()
335 }
336 })
337 .map(|(lang, _)| lang.as_str())
338 .collect();
339
340 if configured_langs.is_empty() {
341 return false;
342 }
343
344 for line in content.lines() {
346 let trimmed = line.trim_start();
347 let after_fence = if let Some(rest) = trimmed.strip_prefix("```") {
348 rest
349 } else if let Some(rest) = trimmed.strip_prefix("~~~") {
350 rest
351 } else {
352 continue;
353 };
354
355 let lang = after_fence.split_whitespace().next().unwrap_or("");
356 if lang.is_empty() {
357 continue;
358 }
359 let canonical = self.resolve_language(lang);
361 if configured_langs.contains(&canonical.as_str()) {
362 return true;
363 }
364 }
365
366 false
367 }
368
369 pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
371 let mut blocks = Vec::new();
372 let mut current_block: Option<FencedCodeBlockBuilder> = None;
373
374 let options = rumdl_parser_options();
375 let parser = Parser::new_ext(content, options).into_offset_iter();
376
377 let lines: Vec<&str> = content.lines().collect();
378
379 for (event, range) in parser {
380 match event {
381 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
382 let info_string = info.to_string();
383 let language = info_string.split_whitespace().next().unwrap_or("").to_string();
384
385 let start_line = content[..range.start].chars().filter(|&c| c == '\n').count();
387
388 let content_start = content[range.start..]
390 .find('\n')
391 .map_or(content.len(), |i| range.start + i + 1);
392
393 let fence_line = lines.get(start_line).unwrap_or(&"");
395 let trimmed = fence_line.trim_start();
396 let indent = fence_line.len() - trimmed.len();
397 let indent_prefix = fence_line.get(..indent).unwrap_or("").to_string();
398 let (fence_char, fence_length) = if trimmed.starts_with('~') {
399 ('~', trimmed.chars().take_while(|&c| c == '~').count())
400 } else {
401 ('`', trimmed.chars().take_while(|&c| c == '`').count())
402 };
403
404 current_block = Some(FencedCodeBlockBuilder {
405 start_line,
406 content_start,
407 language,
408 info_string,
409 fence_char,
410 fence_length,
411 indent,
412 indent_prefix,
413 });
414 }
415 Event::End(TagEnd::CodeBlock) => {
416 if let Some(builder) = current_block.take() {
417 let end_line = content[..range.end].chars().filter(|&c| c == '\n').count();
419
420 let search_start = builder.content_start.min(range.end);
422 let content_end = if search_start < range.end {
423 content[search_start..range.end]
424 .rfind('\n')
425 .map_or(search_start, |i| search_start + i)
426 } else {
427 search_start
428 };
429
430 if content_end >= builder.content_start {
431 blocks.push(FencedCodeBlockInfo {
432 start_line: builder.start_line,
433 end_line,
434 content_start: builder.content_start,
435 content_end,
436 language: builder.language,
437 info_string: builder.info_string,
438 fence_char: builder.fence_char,
439 fence_length: builder.fence_length,
440 indent: builder.indent,
441 indent_prefix: builder.indent_prefix,
442 });
443 }
444 }
445 }
446 _ => {}
447 }
448 }
449
450 if self.flavor == MarkdownFlavor::MkDocs {
452 let mkdocs_blocks = self.extract_mkdocs_code_blocks(content);
453 for mb in mkdocs_blocks {
454 if !blocks.iter().any(|b| b.start_line == mb.start_line) {
456 blocks.push(mb);
457 }
458 }
459 blocks.sort_by_key(|b| b.start_line);
460 }
461
462 blocks
463 }
464
465 fn extract_mkdocs_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
471 use crate::utils::mkdocs_admonitions;
472 use crate::utils::mkdocs_tabs;
473
474 let mut blocks = Vec::new();
475 let lines: Vec<&str> = content.lines().collect();
476
477 let mut context_indent_stack: Vec<usize> = Vec::new();
480
481 let mut in_fence = false;
483 let mut fence_start_line: usize = 0;
484 let mut fence_content_start: usize = 0;
485 let mut fence_char: char = '`';
486 let mut fence_length: usize = 0;
487 let mut fence_indent: usize = 0;
488 let mut fence_indent_prefix = String::new();
489 let mut fence_language = String::new();
490 let mut fence_info_string = String::new();
491
492 let content_start_ptr = content.as_ptr() as usize;
497 let line_offsets: Vec<usize> = lines
498 .iter()
499 .map(|line| line.as_ptr() as usize - content_start_ptr)
500 .collect();
501
502 for (i, line) in lines.iter().enumerate() {
503 let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
504 let is_admonition = mkdocs_admonitions::is_admonition_start(line);
505 let is_tab = mkdocs_tabs::is_tab_marker(line);
506
507 if !line.trim().is_empty() {
511 while let Some(&ctx_indent) = context_indent_stack.last() {
512 if line_indent < ctx_indent + 4 {
513 context_indent_stack.pop();
514 if in_fence {
515 in_fence = false;
516 }
517 } else {
518 break;
519 }
520 }
521 }
522
523 if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
525 context_indent_stack.push(indent);
526 continue;
527 }
528
529 if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
531 context_indent_stack.push(indent);
532 continue;
533 }
534
535 if context_indent_stack.is_empty() {
537 continue;
538 }
539
540 let trimmed = line.trim_start();
541 let leading_spaces = line.len() - trimmed.len();
542
543 if !in_fence {
544 let (fc, fl) = if trimmed.starts_with("```") {
546 ('`', trimmed.chars().take_while(|&c| c == '`').count())
547 } else if trimmed.starts_with("~~~") {
548 ('~', trimmed.chars().take_while(|&c| c == '~').count())
549 } else {
550 continue;
551 };
552
553 if fl >= 3 {
554 in_fence = true;
555 fence_start_line = i;
556 fence_char = fc;
557 fence_length = fl;
558 fence_indent = leading_spaces;
559 fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
560
561 let after_fence = &trimmed[fl..];
562 fence_info_string = after_fence.trim().to_string();
563 fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
564
565 fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
567 }
568 } else {
569 let is_closing = if fence_char == '`' {
571 trimmed.starts_with("```")
572 && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
573 && trimmed.trim_start_matches('`').trim().is_empty()
574 } else {
575 trimmed.starts_with("~~~")
576 && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
577 && trimmed.trim_start_matches('~').trim().is_empty()
578 };
579
580 if is_closing {
581 let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
582
583 if content_end >= fence_content_start {
584 blocks.push(FencedCodeBlockInfo {
585 start_line: fence_start_line,
586 end_line: i,
587 content_start: fence_content_start,
588 content_end,
589 language: fence_language.clone(),
590 info_string: fence_info_string.clone(),
591 fence_char,
592 fence_length,
593 indent: fence_indent,
594 indent_prefix: fence_indent_prefix.clone(),
595 });
596 }
597
598 in_fence = false;
599 }
600 }
601 }
602
603 blocks
604 }
605
606 fn resolve_language(&self, language: &str) -> String {
608 let lower = language.to_lowercase();
609 if let Some(mapped) = self.user_aliases.get(&lower) {
610 return mapped.clone();
611 }
612 match self.config.normalize_language {
613 NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
614 NormalizeLanguage::Exact => lower,
615 }
616 }
617
618 fn get_on_error(&self, language: &str) -> OnError {
620 self.language_config(language)
621 .and_then(|lc| lc.on_error)
622 .unwrap_or(self.config.on_error)
623 }
624
625 fn strip_indent_from_block(&self, content: &str, indent_prefix: &str) -> String {
627 if indent_prefix.is_empty() {
628 return content.to_string();
629 }
630
631 let mut out = String::with_capacity(content.len());
632 for line in content.split_inclusive('\n') {
633 if let Some(stripped) = line.strip_prefix(indent_prefix) {
634 out.push_str(stripped);
635 } else {
636 out.push_str(line);
637 }
638 }
639 out
640 }
641
642 fn apply_indent_to_block(&self, content: &str, indent_prefix: &str) -> String {
644 if indent_prefix.is_empty() {
645 return content.to_string();
646 }
647 if content.is_empty() {
648 return String::new();
649 }
650
651 let mut out = String::with_capacity(content.len() + indent_prefix.len());
652 for line in content.split_inclusive('\n') {
653 if line == "\n" {
654 out.push_str(line);
655 } else {
656 out.push_str(indent_prefix);
657 out.push_str(line);
658 }
659 }
660 out
661 }
662
663 pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
667 if self.config.on_missing_language_definition.skips_the_block()
671 && !self
672 .config
673 .languages
674 .values()
675 .any(|lc| lc.enabled && !lc.lint.is_empty())
676 {
677 return Ok(Vec::new());
678 }
679
680 if self.config.on_missing_language_definition.skips_the_block()
683 && !self.has_potential_matching_blocks(content, true)
684 {
685 return Ok(Vec::new());
686 }
687
688 let mut all_diagnostics = Vec::new();
689 let blocks = self.extract_code_blocks(content);
690
691 for block in blocks {
692 if block.language.is_empty() {
693 continue; }
695
696 let canonical_lang = self.resolve_language(&block.language);
697
698 let lang_config = self.language_config(&canonical_lang);
700
701 if let Some(lc) = lang_config
703 && !lc.enabled
704 {
705 continue;
706 }
707
708 let lint_tools = match lang_config {
709 Some(lc) if !lc.lint.is_empty() => &lc.lint,
710 Some(lc) if !lc.format.is_empty() => continue,
714 _ => {
715 match self.config.on_missing_language_definition {
717 OnMissing::Ignore | OnMissing::Warn => continue,
723 OnMissing::Fail => {
724 all_diagnostics.push(CodeBlockDiagnostic {
725 file_line: block.start_line + 1,
726 column: None,
727 message: format!("No lint tools configured for language '{canonical_lang}'"),
728 severity: DiagnosticSeverity::Error,
729 tool: "code-block-tools".to_string(),
730 code_block_start: block.start_line + 1,
731 });
732 continue;
733 }
734 OnMissing::FailFast => {
735 return Err(ProcessorError::NoToolsConfigured {
736 language: canonical_lang,
737 line: block.start_line + 1,
738 });
739 }
740 }
741 }
742 };
743
744 let code_content_raw = if block.content_start < block.content_end && block.content_end <= content.len() {
746 &content[block.content_start..block.content_end]
747 } else {
748 continue;
749 };
750 let code_content = self.strip_indent_from_block(code_content_raw, &block.indent_prefix);
751
752 for tool_id in lint_tools {
754 if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
756 continue;
757 }
758
759 let Some(resolved_id) = self.registry.resolve_id(tool_id, ToolSlot::Lint) else {
760 self.warn_unknown_tool(tool_id, &canonical_lang);
761 continue;
762 };
763 let Some(tool_def) = self.registry.get(&resolved_id) else {
764 self.warn_unknown_tool(tool_id, &canonical_lang);
765 continue;
766 };
767
768 let tool_name = tool_def.command.first().map_or("", String::as_str);
770 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
771 match self.config.on_missing_tool_binary {
772 OnMissing::Ignore | OnMissing::Warn => {
776 log::debug!("Tool binary '{tool_name}' not found, skipping");
777 continue;
778 }
779 OnMissing::Fail => {
780 all_diagnostics.push(CodeBlockDiagnostic {
781 file_line: block.start_line + 1,
782 column: None,
783 message: format!("Tool binary '{tool_name}' not found in PATH"),
784 severity: DiagnosticSeverity::Error,
785 tool: "code-block-tools".to_string(),
786 code_block_start: block.start_line + 1,
787 });
788 continue;
789 }
790 OnMissing::FailFast => {
791 return Err(ProcessorError::ToolBinaryNotFound {
792 tool: tool_name.to_string(),
793 language: canonical_lang.clone(),
794 line: block.start_line + 1,
795 });
796 }
797 }
798 }
799
800 let tool_input = ensure_trailing_newline(&code_content);
801 let run = match self.registry.lint_mode(&resolved_id) {
802 Some(BuiltinLintMode::FormatCheck) => self
805 .executor
806 .format(tool_def, &tool_input, Some(self.config.timeout))
807 .map(|output| {
808 self.format_check_diagnostics(&output, &code_content, tool_id, block.start_line + 1)
809 }),
810 _ => self
811 .executor
812 .lint(tool_def, &tool_input, Some(self.config.timeout))
813 .map(|output| {
814 self.parse_tool_output(
815 &output,
816 tool_id,
817 block.start_line + 1, )
819 }),
820 };
821 match run {
822 Ok(diagnostics) => {
823 all_diagnostics.extend(diagnostics);
824 }
825 Err(e) => {
826 let on_error = self.get_on_error(&canonical_lang);
827 match on_error {
828 OnError::Fail => return Err(e.into()),
829 OnError::Warn => {
830 log::warn!("Tool '{tool_id}' failed: {e}");
831 }
832 OnError::Skip => {
833 }
835 }
836 }
837 }
838 }
839 }
840
841 Ok(all_diagnostics)
842 }
843
844 pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
850 self.format_with_config(content, &crate::config::Config::default(), None)
851 }
852
853 pub fn format_with_config(
858 &self,
859 content: &str,
860 config: &crate::config::Config,
861 path: Option<&std::path::Path>,
862 ) -> Result<FormatOutput, ProcessorError> {
863 self.format_guarded(content, None, config, path)
864 }
865
866 pub fn format_for_rules(
872 &self,
873 content: &str,
874 rules: &[Box<dyn crate::rule::Rule>],
875 config: &crate::config::Config,
876 path: Option<&std::path::Path>,
877 ) -> Result<FormatOutput, ProcessorError> {
878 self.format_guarded(content, Some(rules), config, path)
879 }
880
881 fn format_guarded(
882 &self,
883 content: &str,
884 rules: Option<&[Box<dyn crate::rule::Rule>]>,
885 config: &crate::config::Config,
886 path: Option<&std::path::Path>,
887 ) -> Result<FormatOutput, ProcessorError> {
888 let no_output = FormatOutput {
889 content: content.to_string(),
890 had_errors: false,
891 error_messages: Vec::new(),
892 failures: Vec::new(),
893 };
894
895 let conflicted = match rules {
896 Some(rules) => crate::merge_conflict::detect_for_rules(content, rules, config, path).is_some(),
897 None => crate::merge_conflict::detect_configured(content, config, path).is_some(),
898 };
899 if conflicted {
900 return Ok(no_output);
901 }
902
903 if self.config.on_missing_language_definition.skips_the_block()
905 && !self
906 .config
907 .languages
908 .values()
909 .any(|lc| lc.enabled && !lc.format.is_empty())
910 {
911 return Ok(no_output);
912 }
913
914 if self.config.on_missing_language_definition.skips_the_block()
916 && !self.has_potential_matching_blocks(content, false)
917 {
918 return Ok(no_output);
919 }
920
921 let blocks = self.extract_code_blocks(content);
922
923 if blocks.is_empty() {
924 return Ok(FormatOutput {
925 content: content.to_string(),
926 had_errors: false,
927 error_messages: Vec::new(),
928 failures: Vec::new(),
929 });
930 }
931
932 let mut result = content.to_string();
934 let mut error_messages: Vec<String> = Vec::new();
935 let mut failures: Vec<CodeBlockDiagnostic> = Vec::new();
936
937 for block in blocks.into_iter().rev() {
938 if block.language.is_empty() {
939 continue;
940 }
941
942 let canonical_lang = self.resolve_language(&block.language);
943
944 let lang_config = self.language_config(&canonical_lang);
946
947 if let Some(lc) = lang_config
949 && !lc.enabled
950 {
951 continue;
952 }
953
954 let format_tools = match lang_config {
955 Some(lc) if !lc.format.is_empty() => &lc.format,
956 Some(lc) if !lc.lint.is_empty() => continue,
958 _ => {
959 match self.config.on_missing_language_definition {
961 OnMissing::Ignore | OnMissing::Warn => continue,
963 OnMissing::Fail => {
964 error_messages.push(format!(
965 "No format tools configured for language '{canonical_lang}' at line {}",
966 block.start_line + 1
967 ));
968 failures.push(CodeBlockDiagnostic {
969 file_line: block.start_line + 1,
970 column: None,
971 message: format!("No format tools configured for language '{canonical_lang}'"),
972 severity: DiagnosticSeverity::Error,
973 tool: "code-block-tools".to_string(),
974 code_block_start: block.start_line + 1,
975 });
976 continue;
977 }
978 OnMissing::FailFast => {
979 return Err(ProcessorError::NoToolsConfigured {
980 language: canonical_lang,
981 line: block.start_line + 1,
982 });
983 }
984 }
985 }
986 };
987
988 if block.content_start >= block.content_end || block.content_end > result.len() {
990 continue;
991 }
992 let code_content_raw = result[block.content_start..block.content_end].to_string();
993 let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
994
995 let mut formatted = code_content.clone();
997 let mut tool_ran = false;
998 for tool_id in format_tools {
999 if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
1001 continue;
1002 }
1003
1004 let Some(tool_def) = self.registry.resolve(tool_id, ToolSlot::Format) else {
1005 self.warn_unknown_tool(tool_id, &canonical_lang);
1006 continue;
1007 };
1008
1009 if self.registry.fills_format_slot(tool_id) == Some(false) {
1017 log::warn!(
1018 "Tool '{tool_id}' is a linter and cannot format '{canonical_lang}' code blocks; move it to the lint slot"
1019 );
1020 continue;
1021 }
1022
1023 let tool_name = tool_def.command.first().map_or("", String::as_str);
1025 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
1026 match self.config.on_missing_tool_binary {
1027 OnMissing::Ignore | OnMissing::Warn => {
1029 log::debug!("Tool binary '{tool_name}' not found, skipping");
1030 continue;
1031 }
1032 OnMissing::Fail => {
1033 error_messages.push(format!(
1034 "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
1035 block.start_line + 1
1036 ));
1037 failures.push(CodeBlockDiagnostic {
1038 file_line: block.start_line + 1,
1039 column: None,
1040 message: format!("Tool binary '{tool_name}' not found in PATH"),
1041 severity: DiagnosticSeverity::Error,
1042 tool: "code-block-tools".to_string(),
1043 code_block_start: block.start_line + 1,
1044 });
1045 continue;
1046 }
1047 OnMissing::FailFast => {
1048 return Err(ProcessorError::ToolBinaryNotFound {
1049 tool: tool_name.to_string(),
1050 language: canonical_lang.clone(),
1051 line: block.start_line + 1,
1052 });
1053 }
1054 }
1055 }
1056
1057 let tool_input = ensure_trailing_newline(&formatted);
1058 match self.executor.format(tool_def, &tool_input, Some(self.config.timeout)) {
1059 Ok(output) => {
1060 if output.trim().is_empty() && !formatted.trim().is_empty() {
1064 log::warn!("Formatter '{tool_id}' produced empty output for non-empty input, skipping");
1065 continue;
1066 }
1067
1068 formatted = output;
1070 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1071 formatted.push('\n');
1072 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1073 formatted.pop();
1074 }
1075 tool_ran = true;
1076 break; }
1078 Err(e) => {
1079 let on_error = self.get_on_error(&canonical_lang);
1080 match on_error {
1081 OnError::Fail => {
1082 return Err(ProcessorError::ToolErrorAt {
1083 error: e,
1084 line: block.start_line + 1,
1085 language: canonical_lang,
1086 });
1087 }
1088 OnError::Warn => {
1089 error_messages.push(format!("line {} ({}): {e}", block.start_line + 1, canonical_lang));
1090 }
1091 OnError::Skip => {}
1092 }
1093 }
1094 }
1095 }
1096
1097 if tool_ran && formatted != code_content {
1099 let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
1100 if reindented != code_content_raw {
1101 result.replace_range(block.content_start..block.content_end, &reindented);
1102 }
1103 }
1104 }
1105
1106 Ok(FormatOutput {
1107 content: result,
1108 had_errors: !error_messages.is_empty(),
1109 error_messages,
1110 failures,
1111 })
1112 }
1113
1114 fn format_check_diagnostics(
1124 &self,
1125 output: &str,
1126 code_content: &str,
1127 tool_id: &str,
1128 code_block_start_line: usize,
1129 ) -> Vec<CodeBlockDiagnostic> {
1130 if output.trim().is_empty() && !code_content.trim().is_empty() {
1133 log::warn!("Formatter '{tool_id}' produced empty output for non-empty input, skipping");
1134 return Vec::new();
1135 }
1136
1137 let mut formatted = output.to_string();
1138 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1139 formatted.push('\n');
1140 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1141 formatted.pop();
1142 }
1143
1144 if formatted == code_content {
1145 return Vec::new();
1146 }
1147
1148 vec![CodeBlockDiagnostic {
1149 file_line: code_block_start_line,
1150 column: None,
1151 message: "Code block is not formatted".to_string(),
1152 severity: DiagnosticSeverity::Warning,
1153 tool: tool_id.to_string(),
1154 code_block_start: code_block_start_line,
1155 }]
1156 }
1157
1158 fn parse_tool_output(
1163 &self,
1164 output: &ToolOutput,
1165 tool_id: &str,
1166 code_block_start_line: usize,
1167 ) -> Vec<CodeBlockDiagnostic> {
1168 let mut diagnostics = Vec::new();
1169 let mut shellcheck_line: Option<usize> = None;
1170
1171 let stdout_clean = strip_ansi_codes(&output.stdout);
1173 let stderr_clean = strip_ansi_codes(&output.stderr);
1174 let combined = format!("{stdout_clean}\n{stderr_clean}");
1175
1176 let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
1178
1179 for line in combined.lines() {
1180 let line = line.trim();
1181 if line.is_empty() {
1182 continue;
1183 }
1184
1185 if let Some((ref msg, severity)) = pending_error {
1187 if let Some((line_num, col)) = Self::parse_at_line_column(line) {
1188 diagnostics.push(CodeBlockDiagnostic {
1189 file_line: code_block_start_line + line_num,
1190 column: Some(col),
1191 message: msg.clone(),
1192 severity,
1193 tool: tool_id.to_string(),
1194 code_block_start: code_block_start_line,
1195 });
1196 pending_error = None;
1197 continue;
1198 }
1199 diagnostics.push(Self::unpositioned_diagnostic(
1201 msg,
1202 severity,
1203 tool_id,
1204 code_block_start_line,
1205 ));
1206 pending_error = None;
1207 }
1209
1210 if let Some(line_num) = self.parse_shellcheck_header(line) {
1211 shellcheck_line = Some(line_num);
1212 continue;
1213 }
1214
1215 if let Some(line_num) = shellcheck_line
1216 && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
1217 {
1218 diagnostics.push(diag);
1219 continue;
1220 }
1221
1222 if let Some(diag) = Self::parse_github_annotation(line, tool_id, code_block_start_line) {
1224 diagnostics.push(diag);
1225 continue;
1226 }
1227
1228 if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
1230 diagnostics.push(diag);
1231 continue;
1232 }
1233
1234 if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
1236 diagnostics.push(diag);
1237 continue;
1238 }
1239
1240 if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
1242 diagnostics.push(diag);
1243 continue;
1244 }
1245
1246 if let Some(error_info) = Self::parse_error_line(line) {
1248 pending_error = Some(error_info);
1249 }
1250 }
1251
1252 if let Some((msg, severity)) = pending_error {
1254 diagnostics.push(Self::unpositioned_diagnostic(
1255 &msg,
1256 severity,
1257 tool_id,
1258 code_block_start_line,
1259 ));
1260 }
1261
1262 if diagnostics.is_empty() && !output.success {
1264 let lines: Vec<&str> = combined.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
1265
1266 if lines.is_empty() {
1267 let exit_code = output.exit_code;
1268 diagnostics.push(CodeBlockDiagnostic {
1269 file_line: code_block_start_line,
1270 column: None,
1271 message: format!("Tool exited with code {exit_code}"),
1272 severity: DiagnosticSeverity::Error,
1273 tool: tool_id.to_string(),
1274 code_block_start: code_block_start_line,
1275 });
1276 } else {
1277 for line_text in lines {
1278 diagnostics.push(Self::unpositioned_diagnostic(
1279 line_text,
1280 DiagnosticSeverity::Error,
1281 tool_id,
1282 code_block_start_line,
1283 ));
1284 }
1285 }
1286 }
1287
1288 diagnostics
1289 }
1290
1291 fn unpositioned_diagnostic(
1297 message: &str,
1298 severity: DiagnosticSeverity,
1299 tool_id: &str,
1300 code_block_start_line: usize,
1301 ) -> CodeBlockDiagnostic {
1302 let (line_offset, column) = Self::parse_position_in_message(message).unwrap_or((0, None));
1303 CodeBlockDiagnostic {
1304 file_line: code_block_start_line + line_offset,
1305 column,
1306 message: message.to_string(),
1307 severity,
1308 tool: tool_id.to_string(),
1309 code_block_start: code_block_start_line,
1310 }
1311 }
1312
1313 fn parse_position_in_message(message: &str) -> Option<(usize, Option<usize>)> {
1318 let lower = message.to_ascii_lowercase();
1321 let after_marker = &lower[lower.rfind("at line ")? + "at line ".len()..];
1322
1323 let leading_number = |text: &str| -> Option<usize> {
1324 let end = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
1325 text[..end].parse::<usize>().ok()
1326 };
1327
1328 let line_num = leading_number(after_marker)?;
1329 let after_line = after_marker.trim_start_matches(|c: char| c.is_ascii_digit());
1330 let after_separator = after_line
1331 .trim_start()
1332 .strip_prefix(',')
1333 .unwrap_or(after_line)
1334 .trim_start();
1335 let column = after_separator.strip_prefix("column ").and_then(leading_number);
1336
1337 Some((line_num, column))
1338 }
1339
1340 fn parse_standard_format(
1342 &self,
1343 line: &str,
1344 tool_id: &str,
1345 code_block_start_line: usize,
1346 ) -> Option<CodeBlockDiagnostic> {
1347 let mut parts = line.rsplitn(4, ':');
1349 let message = parts.next()?.trim().to_string();
1350 let part1 = parts.next()?.trim().to_string();
1351 let part2 = parts.next()?.trim().to_string();
1352 let part3 = parts.next().map(|s| s.trim().to_string());
1353
1354 let (line_part, col_part) = if part3.is_some() {
1355 (part2, Some(part1))
1356 } else {
1357 (part1, None)
1358 };
1359
1360 if let Ok(line_num) = line_part.parse::<usize>() {
1361 let column = col_part.and_then(|s| s.parse::<usize>().ok());
1362 let message = Self::strip_fixable_markers(&message);
1363 if !message.is_empty() {
1364 let severity = self.infer_severity(&message);
1365 return Some(CodeBlockDiagnostic {
1366 file_line: code_block_start_line + line_num,
1367 column,
1368 message,
1369 severity,
1370 tool: tool_id.to_string(),
1371 code_block_start: code_block_start_line,
1372 });
1373 }
1374 }
1375 None
1376 }
1377
1378 fn parse_github_annotation(line: &str, tool_id: &str, code_block_start_line: usize) -> Option<CodeBlockDiagnostic> {
1386 let body = line.strip_prefix("::")?;
1387 let (head, message) = body.split_once("::")?;
1388
1389 let (level, properties) = head.split_once(' ').unwrap_or((head, ""));
1390 let severity = match level {
1391 "error" => DiagnosticSeverity::Error,
1392 "warning" => DiagnosticSeverity::Warning,
1393 "notice" => DiagnosticSeverity::Info,
1394 _ => return None,
1395 };
1396
1397 let property = |name: &str| -> Option<usize> {
1398 properties
1399 .split(',')
1400 .filter_map(|pair| pair.split_once('='))
1401 .find(|(key, _)| *key == name)
1402 .and_then(|(_, value)| value.parse::<usize>().ok())
1403 };
1404
1405 let message = Self::strip_fixable_markers(&decode_workflow_command(message));
1406 if message.is_empty() {
1407 return None;
1408 }
1409
1410 Some(CodeBlockDiagnostic {
1411 file_line: code_block_start_line + property("line").unwrap_or(0),
1412 column: property("col"),
1413 message,
1414 severity,
1415 tool: tool_id.to_string(),
1416 code_block_start: code_block_start_line,
1417 })
1418 }
1419
1420 fn parse_eslint_format(
1422 &self,
1423 line: &str,
1424 tool_id: &str,
1425 code_block_start_line: usize,
1426 ) -> Option<CodeBlockDiagnostic> {
1427 let parts: Vec<&str> = line.splitn(3, ' ').collect();
1429 if parts.len() >= 2 {
1430 let loc_parts: Vec<&str> = parts[0].split(':').collect();
1431 if loc_parts.len() == 2
1432 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
1433 {
1434 let (sev_part, msg_part) = if parts.len() >= 3 {
1435 (parts[1], parts[2])
1436 } else {
1437 (parts[1], "")
1438 };
1439 let message = if msg_part.is_empty() {
1440 sev_part.to_string()
1441 } else {
1442 msg_part.to_string()
1443 };
1444 let message = Self::strip_fixable_markers(&message);
1445 let severity = match sev_part.to_lowercase().as_str() {
1446 "error" => DiagnosticSeverity::Error,
1447 "warning" | "warn" => DiagnosticSeverity::Warning,
1448 "info" => DiagnosticSeverity::Info,
1449 _ => self.infer_severity(&message),
1450 };
1451 return Some(CodeBlockDiagnostic {
1452 file_line: code_block_start_line + line_num,
1453 column: Some(col),
1454 message,
1455 severity,
1456 tool: tool_id.to_string(),
1457 code_block_start: code_block_start_line,
1458 });
1459 }
1460 }
1461 None
1462 }
1463
1464 fn parse_shellcheck_format(
1466 &self,
1467 line: &str,
1468 tool_id: &str,
1469 code_block_start_line: usize,
1470 ) -> Option<CodeBlockDiagnostic> {
1471 if line.starts_with("In ")
1473 && line.contains(" line ")
1474 && let Some(line_start) = line.find(" line ")
1475 {
1476 let after_line = &line[line_start + 6..];
1477 if let Some(colon_pos) = after_line.find(':')
1478 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1479 {
1480 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1481 if !message.is_empty() {
1482 let severity = self.infer_severity(&message);
1483 return Some(CodeBlockDiagnostic {
1484 file_line: code_block_start_line + line_num,
1485 column: None,
1486 message,
1487 severity,
1488 tool: tool_id.to_string(),
1489 code_block_start: code_block_start_line,
1490 });
1491 }
1492 }
1493 }
1494 None
1495 }
1496
1497 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1499 if line.starts_with("In ")
1500 && line.contains(" line ")
1501 && let Some(line_start) = line.find(" line ")
1502 {
1503 let after_line = &line[line_start + 6..];
1504 if let Some(colon_pos) = after_line.find(':') {
1505 return after_line[..colon_pos].trim().parse::<usize>().ok();
1506 }
1507 }
1508 None
1509 }
1510
1511 fn parse_shellcheck_message(
1513 &self,
1514 line: &str,
1515 tool_id: &str,
1516 code_block_start_line: usize,
1517 line_num: usize,
1518 ) -> Option<CodeBlockDiagnostic> {
1519 let sc_pos = line.find("SC")?;
1520 let after_sc = &line[sc_pos + 2..];
1521 let code_len = after_sc.chars().take_while(char::is_ascii_digit).count();
1522 if code_len == 0 {
1523 return None;
1524 }
1525 let after_code = &after_sc[code_len..];
1526 let sev_start = after_code.find('(')? + 1;
1527 let sev_end = after_code[sev_start..].find(')')? + sev_start;
1528 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1529 let message_start = after_code.find("):")? + 2;
1530 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1531 if message.is_empty() {
1532 return None;
1533 }
1534
1535 let severity = match sev.as_str() {
1536 "error" => DiagnosticSeverity::Error,
1537 "warning" | "warn" => DiagnosticSeverity::Warning,
1538 "info" | "style" => DiagnosticSeverity::Info,
1539 _ => self.infer_severity(&message),
1540 };
1541
1542 Some(CodeBlockDiagnostic {
1543 file_line: code_block_start_line + line_num,
1544 column: None,
1545 message,
1546 severity,
1547 tool: tool_id.to_string(),
1548 code_block_start: code_block_start_line,
1549 })
1550 }
1551
1552 fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1558 let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1559 (msg, DiagnosticSeverity::Error)
1560 } else if let Some(msg) = line.strip_prefix("Warning:") {
1561 (msg, DiagnosticSeverity::Warning)
1562 } else {
1563 return None;
1564 };
1565 let msg = msg.trim();
1566 if msg.is_empty() {
1567 return None;
1568 }
1569 Some((msg.to_string(), severity))
1570 }
1571
1572 fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1576 let lower = line.to_lowercase();
1577 let rest = lower.strip_prefix("at line ")?;
1578 let mut parts = rest.split_whitespace();
1579 let line_num: usize = parts.next()?.parse().ok()?;
1580 if parts.next()? != "column" {
1581 return None;
1582 }
1583 let col: usize = parts.next()?.parse().ok()?;
1584 Some((line_num, col))
1585 }
1586
1587 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1589 let lower = message.to_lowercase();
1590 if lower.contains("error")
1591 || lower.starts_with('e') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1592 || lower.starts_with('f') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1593 {
1594 DiagnosticSeverity::Error
1595 } else if lower.contains("warning")
1596 || lower.contains("warn")
1597 || lower.starts_with('w') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1598 {
1599 DiagnosticSeverity::Warning
1600 } else {
1601 DiagnosticSeverity::Info
1602 }
1603 }
1604
1605 fn strip_fixable_markers(message: &str) -> String {
1612 message
1613 .replace(" [*]", "")
1614 .replace("[*] ", "")
1615 .replace("[*]", "")
1616 .replace(" (fixable)", "")
1617 .replace("(fixable) ", "")
1618 .replace("(fixable)", "")
1619 .replace(" [fix available]", "")
1620 .replace("[fix available] ", "")
1621 .replace("[fix available]", "")
1622 .replace(" [autofix]", "")
1623 .replace("[autofix] ", "")
1624 .replace("[autofix]", "")
1625 .trim()
1626 .to_string()
1627 }
1628}
1629
1630struct FencedCodeBlockBuilder {
1632 start_line: usize,
1633 content_start: usize,
1634 language: String,
1635 info_string: String,
1636 fence_char: char,
1637 fence_length: usize,
1638 indent: usize,
1639 indent_prefix: String,
1640}
1641
1642#[cfg(test)]
1643mod tests {
1644 use super::*;
1645
1646 fn default_config() -> CodeBlockToolsConfig {
1647 CodeBlockToolsConfig::default()
1648 }
1649
1650 #[test]
1651 fn test_extract_code_blocks() {
1652 let config = default_config();
1653 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1654
1655 let content = r#"# Example
1656
1657```python
1658def hello():
1659 print("Hello")
1660```
1661
1662Some text
1663
1664```rust
1665fn main() {}
1666```
1667"#;
1668
1669 let blocks = processor.extract_code_blocks(content);
1670
1671 assert_eq!(blocks.len(), 2);
1672
1673 assert_eq!(blocks[0].language, "python");
1674 assert_eq!(blocks[0].fence_char, '`');
1675 assert_eq!(blocks[0].fence_length, 3);
1676 assert_eq!(blocks[0].start_line, 2);
1677 assert_eq!(blocks[0].indent, 0);
1678 assert_eq!(blocks[0].indent_prefix, "");
1679
1680 assert_eq!(blocks[1].language, "rust");
1681 assert_eq!(blocks[1].fence_char, '`');
1682 assert_eq!(blocks[1].fence_length, 3);
1683 }
1684
1685 #[test]
1686 fn test_extract_code_blocks_with_info_string() {
1687 let config = default_config();
1688 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1689
1690 let content = "```python title=\"example.py\"\ncode\n```";
1691 let blocks = processor.extract_code_blocks(content);
1692
1693 assert_eq!(blocks.len(), 1);
1694 assert_eq!(blocks[0].language, "python");
1695 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1696 }
1697
1698 #[test]
1699 fn test_extract_code_blocks_tilde_fence() {
1700 let config = default_config();
1701 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1702
1703 let content = "~~~bash\necho hello\n~~~";
1704 let blocks = processor.extract_code_blocks(content);
1705
1706 assert_eq!(blocks.len(), 1);
1707 assert_eq!(blocks[0].language, "bash");
1708 assert_eq!(blocks[0].fence_char, '~');
1709 assert_eq!(blocks[0].fence_length, 3);
1710 assert_eq!(blocks[0].indent_prefix, "");
1711 }
1712
1713 #[test]
1714 fn test_extract_code_blocks_with_indent_prefix() {
1715 let config = default_config();
1716 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1717
1718 let content = " - item\n ```python\n print('hi')\n ```";
1719 let blocks = processor.extract_code_blocks(content);
1720
1721 assert_eq!(blocks.len(), 1);
1722 assert_eq!(blocks[0].indent_prefix, " ");
1723 }
1724
1725 #[test]
1726 fn test_extract_code_blocks_no_language() {
1727 let config = default_config();
1728 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1729
1730 let content = "```\nplain code\n```";
1731 let blocks = processor.extract_code_blocks(content);
1732
1733 assert_eq!(blocks.len(), 1);
1734 assert_eq!(blocks[0].language, "");
1735 }
1736
1737 #[test]
1738 fn test_resolve_language_linguist() {
1739 let mut config = default_config();
1740 config.normalize_language = NormalizeLanguage::Linguist;
1741 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1742
1743 assert_eq!(processor.resolve_language("py"), "python");
1744 assert_eq!(processor.resolve_language("bash"), "shell");
1745 assert_eq!(processor.resolve_language("js"), "javascript");
1746 }
1747
1748 #[test]
1749 fn test_resolve_language_exact() {
1750 let mut config = default_config();
1751 config.normalize_language = NormalizeLanguage::Exact;
1752 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1753
1754 assert_eq!(processor.resolve_language("py"), "py");
1755 assert_eq!(processor.resolve_language("BASH"), "bash");
1756 }
1757
1758 #[test]
1759 fn test_resolve_language_user_alias_override() {
1760 let mut config = default_config();
1761 config.language_aliases.insert("py".to_string(), "python".to_string());
1762 config.normalize_language = NormalizeLanguage::Exact;
1763 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1764
1765 assert_eq!(processor.resolve_language("PY"), "python");
1766 }
1767
1768 #[test]
1769 fn test_indent_strip_and_reapply_roundtrip() {
1770 let config = default_config();
1771 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1772
1773 let raw = " def hello():\n print('hi')";
1774 let stripped = processor.strip_indent_from_block(raw, " ");
1775 assert_eq!(stripped, "def hello():\n print('hi')");
1776
1777 let reapplied = processor.apply_indent_to_block(&stripped, " ");
1778 assert_eq!(reapplied, raw);
1779 }
1780
1781 #[test]
1782 fn test_infer_severity() {
1783 let config = default_config();
1784 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1785
1786 assert_eq!(
1787 processor.infer_severity("E501 line too long"),
1788 DiagnosticSeverity::Error
1789 );
1790 assert_eq!(
1791 processor.infer_severity("W291 trailing whitespace"),
1792 DiagnosticSeverity::Warning
1793 );
1794 assert_eq!(
1795 processor.infer_severity("error: something failed"),
1796 DiagnosticSeverity::Error
1797 );
1798 assert_eq!(
1799 processor.infer_severity("warning: unused variable"),
1800 DiagnosticSeverity::Warning
1801 );
1802 assert_eq!(
1803 processor.infer_severity("note: consider using"),
1804 DiagnosticSeverity::Info
1805 );
1806 }
1807
1808 #[test]
1809 fn test_parse_standard_format_windows_path() {
1810 let config = default_config();
1811 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1812
1813 let output = ToolOutput {
1814 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1815 stderr: String::new(),
1816 exit_code: 1,
1817 success: false,
1818 };
1819
1820 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1821 assert_eq!(diags.len(), 1);
1822 assert_eq!(diags[0].file_line, 12);
1823 assert_eq!(diags[0].column, Some(5));
1824 assert_eq!(diags[0].message, "E123 message");
1825 }
1826
1827 #[test]
1828 fn test_parse_eslint_severity() {
1829 let config = default_config();
1830 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1831
1832 let output = ToolOutput {
1833 stdout: "1:2 error Unexpected token".to_string(),
1834 stderr: String::new(),
1835 exit_code: 1,
1836 success: false,
1837 };
1838
1839 let diags = processor.parse_tool_output(&output, "eslint", 5);
1840 assert_eq!(diags.len(), 1);
1841 assert_eq!(diags[0].file_line, 6);
1842 assert_eq!(diags[0].column, Some(2));
1843 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1844 assert_eq!(diags[0].message, "Unexpected token");
1845 }
1846
1847 #[test]
1848 fn test_parse_shellcheck_multiline() {
1849 let config = default_config();
1850 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1851
1852 let output = ToolOutput {
1853 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1854 stderr: String::new(),
1855 exit_code: 1,
1856 success: false,
1857 };
1858
1859 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1860 assert_eq!(diags.len(), 1);
1861 assert_eq!(diags[0].file_line, 13);
1862 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1863 assert_eq!(diags[0].message, "Double quote to prevent globbing");
1864 }
1865
1866 #[test]
1867 fn test_lint_no_config() {
1868 let config = default_config();
1869 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1870
1871 let content = "```python\nprint('hello')\n```";
1872 let result = processor.lint(content);
1873
1874 assert!(result.is_ok());
1876 assert!(result.unwrap().is_empty());
1877 }
1878
1879 #[test]
1880 fn test_format_no_config() {
1881 let config = default_config();
1882 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1883
1884 let content = "```python\nprint('hello')\n```";
1885 let result = processor.format(content);
1886
1887 assert!(result.is_ok());
1889 let output = result.unwrap();
1890 assert_eq!(output.content, content);
1891 assert!(!output.had_errors);
1892 assert!(output.error_messages.is_empty());
1893 }
1894
1895 #[test]
1896 fn test_lint_on_missing_language_definition_fail() {
1897 let mut config = default_config();
1898 config.on_missing_language_definition = OnMissing::Fail;
1899 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1900
1901 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1902 let result = processor.lint(content);
1903
1904 assert!(result.is_ok());
1906 let diagnostics = result.unwrap();
1907 assert_eq!(diagnostics.len(), 2);
1908 assert!(diagnostics[0].message.contains("No lint tools configured"));
1909 assert!(diagnostics[0].message.contains("python"));
1910 assert!(diagnostics[1].message.contains("javascript"));
1911 }
1912
1913 #[test]
1914 fn test_lint_on_missing_language_definition_fail_fast() {
1915 let mut config = default_config();
1916 config.on_missing_language_definition = OnMissing::FailFast;
1917 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1918
1919 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1920 let result = processor.lint(content);
1921
1922 assert!(result.is_err());
1924 let err = result.unwrap_err();
1925 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1926 }
1927
1928 #[test]
1929 fn test_format_on_missing_language_definition_fail() {
1930 let mut config = default_config();
1931 config.on_missing_language_definition = OnMissing::Fail;
1932 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1933
1934 let content = "```python\nprint('hello')\n```";
1935 let result = processor.format(content);
1936
1937 assert!(result.is_ok());
1939 let output = result.unwrap();
1940 assert_eq!(output.content, content); assert!(output.had_errors);
1942 assert!(!output.error_messages.is_empty());
1943 assert!(output.error_messages[0].contains("No format tools configured"));
1944 }
1945
1946 #[test]
1947 fn test_format_on_missing_language_definition_fail_fast() {
1948 let mut config = default_config();
1949 config.on_missing_language_definition = OnMissing::FailFast;
1950 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1951
1952 let content = "```python\nprint('hello')\n```";
1953 let result = processor.format(content);
1954
1955 assert!(result.is_err());
1957 let err = result.unwrap_err();
1958 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1959 }
1960
1961 #[test]
1962 fn test_lint_on_missing_tool_binary_fail() {
1963 use super::super::config::{LanguageToolConfig, ToolDefinition};
1964
1965 let mut config = default_config();
1966 config.on_missing_tool_binary = OnMissing::Fail;
1967
1968 let lang_config = LanguageToolConfig {
1970 lint: vec!["nonexistent-linter".to_string()],
1971 ..Default::default()
1972 };
1973 config.languages.insert("python".to_string(), lang_config);
1974
1975 let tool_def = ToolDefinition {
1976 command: vec!["nonexistent-binary-xyz123".to_string()],
1977 ..Default::default()
1978 };
1979 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1980
1981 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1982
1983 let content = "```python\nprint('hello')\n```";
1984 let result = processor.lint(content);
1985
1986 assert!(result.is_ok());
1988 let diagnostics = result.unwrap();
1989 assert_eq!(diagnostics.len(), 1);
1990 assert!(diagnostics[0].message.contains("not found in PATH"));
1991 }
1992
1993 #[test]
1994 fn test_lint_on_missing_tool_binary_fail_fast() {
1995 use super::super::config::{LanguageToolConfig, ToolDefinition};
1996
1997 let mut config = default_config();
1998 config.on_missing_tool_binary = OnMissing::FailFast;
1999
2000 let lang_config = LanguageToolConfig {
2002 lint: vec!["nonexistent-linter".to_string()],
2003 ..Default::default()
2004 };
2005 config.languages.insert("python".to_string(), lang_config);
2006
2007 let tool_def = ToolDefinition {
2008 command: vec!["nonexistent-binary-xyz123".to_string()],
2009 ..Default::default()
2010 };
2011 config.tools.insert("nonexistent-linter".to_string(), tool_def);
2012
2013 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2014
2015 let content = "```python\nprint('hello')\n```";
2016 let result = processor.lint(content);
2017
2018 assert!(result.is_err());
2020 let err = result.unwrap_err();
2021 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
2022 }
2023
2024 #[test]
2025 fn test_format_on_missing_tool_binary_fail() {
2026 use super::super::config::{LanguageToolConfig, ToolDefinition};
2027
2028 let mut config = default_config();
2029 config.on_missing_tool_binary = OnMissing::Fail;
2030
2031 let lang_config = LanguageToolConfig {
2033 format: vec!["nonexistent-formatter".to_string()],
2034 ..Default::default()
2035 };
2036 config.languages.insert("python".to_string(), lang_config);
2037
2038 let tool_def = ToolDefinition {
2039 command: vec!["nonexistent-binary-xyz123".to_string()],
2040 ..Default::default()
2041 };
2042 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
2043
2044 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2045
2046 let content = "```python\nprint('hello')\n```";
2047 let result = processor.format(content);
2048
2049 assert!(result.is_ok());
2051 let output = result.unwrap();
2052 assert_eq!(output.content, content); assert!(output.had_errors);
2054 assert!(!output.error_messages.is_empty());
2055 assert!(output.error_messages[0].contains("not found in PATH"));
2056 }
2057
2058 #[test]
2059 fn test_format_on_missing_tool_binary_fail_fast() {
2060 use super::super::config::{LanguageToolConfig, ToolDefinition};
2061
2062 let mut config = default_config();
2063 config.on_missing_tool_binary = OnMissing::FailFast;
2064
2065 let lang_config = LanguageToolConfig {
2067 format: vec!["nonexistent-formatter".to_string()],
2068 ..Default::default()
2069 };
2070 config.languages.insert("python".to_string(), lang_config);
2071
2072 let tool_def = ToolDefinition {
2073 command: vec!["nonexistent-binary-xyz123".to_string()],
2074 ..Default::default()
2075 };
2076 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
2077
2078 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2079
2080 let content = "```python\nprint('hello')\n```";
2081 let result = processor.format(content);
2082
2083 assert!(result.is_err());
2085 let err = result.unwrap_err();
2086 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
2087 }
2088
2089 #[test]
2090 fn test_lint_rumdl_builtin_skipped_for_markdown() {
2091 let mut config = default_config();
2094 config.languages.insert(
2095 "markdown".to_string(),
2096 LanguageToolConfig {
2097 lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
2098 ..Default::default()
2099 },
2100 );
2101 config.on_missing_language_definition = OnMissing::Fail;
2102 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2103
2104 let content = "```markdown\n# Hello\n```";
2105 let result = processor.lint(content);
2106
2107 assert!(result.is_ok());
2109 assert!(result.unwrap().is_empty());
2110 }
2111
2112 #[test]
2113 fn test_format_rumdl_builtin_skipped_for_markdown() {
2114 let mut config = default_config();
2116 config.languages.insert(
2117 "markdown".to_string(),
2118 LanguageToolConfig {
2119 format: vec![RUMDL_BUILTIN_TOOL.to_string()],
2120 ..Default::default()
2121 },
2122 );
2123 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2124
2125 let content = "```markdown\n# Hello\n```";
2126 let result = processor.format(content);
2127
2128 assert!(result.is_ok());
2130 let output = result.unwrap();
2131 assert_eq!(output.content, content);
2132 assert!(!output.had_errors);
2133 }
2134
2135 #[test]
2136 fn test_is_markdown_language() {
2137 assert!(is_markdown_language("markdown"));
2139 assert!(is_markdown_language("Markdown"));
2140 assert!(is_markdown_language("MARKDOWN"));
2141 assert!(is_markdown_language("md"));
2142 assert!(is_markdown_language("MD"));
2143 assert!(!is_markdown_language("python"));
2144 assert!(!is_markdown_language("rust"));
2145 assert!(!is_markdown_language(""));
2146 }
2147
2148 #[test]
2151 fn test_extract_mkdocs_admonition_code_block() {
2152 let config = default_config();
2153 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2154
2155 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
2156 let blocks = processor.extract_code_blocks(content);
2157
2158 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
2159 assert_eq!(blocks[0].language, "python");
2160 }
2161
2162 #[test]
2163 fn test_extract_mkdocs_tab_code_block() {
2164 let config = default_config();
2165 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2166
2167 let content = "=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n";
2168 let blocks = processor.extract_code_blocks(content);
2169
2170 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
2171 assert_eq!(blocks[0].language, "python");
2172 }
2173
2174 #[test]
2175 fn test_standard_flavor_ignores_admonition_indented_content() {
2176 let config = default_config();
2177 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2178
2179 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
2182 let blocks = processor.extract_code_blocks(content);
2183
2184 for (i, b) in blocks.iter().enumerate() {
2188 for (j, b2) in blocks.iter().enumerate() {
2189 if i != j {
2190 assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
2191 }
2192 }
2193 }
2194 }
2195
2196 #[test]
2197 fn test_mkdocs_top_level_blocks_alongside_admonition() {
2198 let config = default_config();
2199 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2200
2201 let content =
2202 "```rust\nfn main() {}\n```\n\n!!! note\n Some text\n\n ```python\n print(\"hello\")\n ```\n";
2203 let blocks = processor.extract_code_blocks(content);
2204
2205 assert_eq!(
2206 blocks.len(),
2207 2,
2208 "Should detect both top-level and admonition code blocks"
2209 );
2210 assert_eq!(blocks[0].language, "rust");
2211 assert_eq!(blocks[1].language, "python");
2212 }
2213
2214 #[test]
2215 fn test_mkdocs_nested_admonition_code_block() {
2216 let config = default_config();
2217 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2218
2219 let content = "\
2220!!! note
2221 Some text
2222
2223 !!! warning
2224 Nested content
2225
2226 ```python
2227 x = 1
2228 ```
2229";
2230 let blocks = processor.extract_code_blocks(content);
2231 assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
2232 assert_eq!(blocks[0].language, "python");
2233 }
2234
2235 #[test]
2236 fn test_mkdocs_consecutive_admonitions_no_stale_context() {
2237 let config = default_config();
2238 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2239
2240 let content = "\
2243!!! note
2244 First admonition content
2245
2246!!! warning
2247 Second admonition content
2248
2249 ```python
2250 y = 2
2251 ```
2252";
2253 let blocks = processor.extract_code_blocks(content);
2254 assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
2255 assert_eq!(blocks[0].language, "python");
2256 }
2257
2258 #[test]
2259 fn test_mkdocs_crlf_line_endings() {
2260 let config = default_config();
2261 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2262
2263 let content = "!!! note\r\n Some text\r\n\r\n ```python\r\n x = 1\r\n ```\r\n";
2265 let blocks = processor.extract_code_blocks(content);
2266
2267 assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
2268 assert_eq!(blocks[0].language, "python");
2269
2270 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2272 assert!(
2273 extracted.contains("x = 1"),
2274 "Extracted content should contain code. Got: {extracted:?}"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_mkdocs_unclosed_fence_in_admonition() {
2280 let config = default_config();
2281 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2282
2283 let content = "!!! note\n ```python\n x = 1\n no closing fence\n";
2285 let blocks = processor.extract_code_blocks(content);
2286 assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
2287 }
2288
2289 #[test]
2290 fn test_mkdocs_tilde_fence_in_admonition() {
2291 let config = default_config();
2292 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2293
2294 let content = "!!! note\n ~~~ruby\n puts 'hi'\n ~~~\n";
2295 let blocks = processor.extract_code_blocks(content);
2296 assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
2297 assert_eq!(blocks[0].language, "ruby");
2298 }
2299
2300 #[test]
2301 fn test_mkdocs_empty_lines_in_code_block() {
2302 let config = default_config();
2303 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2304
2305 let content = "!!! note\n ```python\n x = 1\n\n y = 2\n ```\n";
2308 let blocks = processor.extract_code_blocks(content);
2309 assert_eq!(blocks.len(), 1);
2310
2311 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2312 assert!(
2313 extracted.contains("x = 1") && extracted.contains("y = 2"),
2314 "Extracted content should span across the empty line. Got: {extracted:?}"
2315 );
2316 }
2317
2318 #[test]
2319 fn test_mkdocs_content_byte_offsets_lf() {
2320 let config = default_config();
2321 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2322
2323 let content = "!!! note\n ```python\n print('hi')\n ```\n";
2324 let blocks = processor.extract_code_blocks(content);
2325 assert_eq!(blocks.len(), 1);
2326
2327 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2329 assert_eq!(extracted, " print('hi')\n", "Content offsets should be exact for LF");
2330 }
2331
2332 #[test]
2333 fn test_mkdocs_content_byte_offsets_crlf() {
2334 let config = default_config();
2335 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2336
2337 let content = "!!! note\r\n ```python\r\n print('hi')\r\n ```\r\n";
2338 let blocks = processor.extract_code_blocks(content);
2339 assert_eq!(blocks.len(), 1);
2340
2341 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2342 assert_eq!(
2343 extracted, " print('hi')\r\n",
2344 "Content offsets should be exact for CRLF"
2345 );
2346 }
2347
2348 #[test]
2349 fn test_lint_enabled_false_skips_language_in_strict_mode() {
2350 let mut config = default_config();
2353 config.normalize_language = NormalizeLanguage::Exact;
2354 config.on_missing_language_definition = OnMissing::Fail;
2355
2356 config.languages.insert(
2358 "python".to_string(),
2359 LanguageToolConfig {
2360 lint: vec!["ruff:check".to_string()],
2361 ..Default::default()
2362 },
2363 );
2364 config.languages.insert(
2365 "plaintext".to_string(),
2366 LanguageToolConfig {
2367 enabled: false,
2368 ..Default::default()
2369 },
2370 );
2371
2372 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2373
2374 let content = "```plaintext\nsome text\n```";
2375 let result = processor.lint(content);
2376
2377 assert!(result.is_ok());
2379 let diagnostics = result.unwrap();
2380 assert!(
2381 diagnostics.is_empty(),
2382 "Expected no diagnostics for disabled language, got: {diagnostics:?}"
2383 );
2384 }
2385
2386 #[test]
2387 fn test_format_enabled_false_skips_language_in_strict_mode() {
2388 let mut config = default_config();
2390 config.normalize_language = NormalizeLanguage::Exact;
2391 config.on_missing_language_definition = OnMissing::Fail;
2392
2393 config.languages.insert(
2394 "plaintext".to_string(),
2395 LanguageToolConfig {
2396 enabled: false,
2397 ..Default::default()
2398 },
2399 );
2400
2401 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2402
2403 let content = "```plaintext\nsome text\n```";
2404 let result = processor.format(content);
2405
2406 assert!(result.is_ok());
2408 let output = result.unwrap();
2409 assert!(!output.had_errors, "Expected no errors for disabled language");
2410 assert!(
2411 output.error_messages.is_empty(),
2412 "Expected no error messages, got: {:?}",
2413 output.error_messages
2414 );
2415 }
2416
2417 #[test]
2418 fn test_enabled_false_default_true_preserved() {
2419 let mut config = default_config();
2421 config.on_missing_language_definition = OnMissing::Fail;
2422
2423 config.languages.insert(
2425 "python".to_string(),
2426 LanguageToolConfig {
2427 lint: vec!["ruff:check".to_string()],
2428 ..Default::default()
2429 },
2430 );
2431
2432 let lang_config = config.languages.get("python").unwrap();
2433 assert!(lang_config.enabled, "enabled should default to true");
2434 }
2435
2436 #[test]
2437 fn test_enabled_false_with_fail_fast_no_error() {
2438 let mut config = default_config();
2440 config.normalize_language = NormalizeLanguage::Exact;
2441 config.on_missing_language_definition = OnMissing::FailFast;
2442
2443 config.languages.insert(
2444 "unknown".to_string(),
2445 LanguageToolConfig {
2446 enabled: false,
2447 ..Default::default()
2448 },
2449 );
2450
2451 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2452
2453 let content = "```unknown\nsome content\n```";
2454 let result = processor.lint(content);
2455
2456 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2458 assert!(result.unwrap().is_empty());
2459 }
2460
2461 #[test]
2462 fn test_enabled_false_format_with_fail_fast_no_error() {
2463 let mut config = default_config();
2465 config.normalize_language = NormalizeLanguage::Exact;
2466 config.on_missing_language_definition = OnMissing::FailFast;
2467
2468 config.languages.insert(
2469 "unknown".to_string(),
2470 LanguageToolConfig {
2471 enabled: false,
2472 ..Default::default()
2473 },
2474 );
2475
2476 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2477
2478 let content = "```unknown\nsome content\n```";
2479 let result = processor.format(content);
2480
2481 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2482 let output = result.unwrap();
2483 assert!(!output.had_errors);
2484 }
2485
2486 #[test]
2487 fn test_enabled_false_with_tools_still_skips() {
2488 let mut config = default_config();
2490 config.on_missing_language_definition = OnMissing::Fail;
2491
2492 config.languages.insert(
2493 "python".to_string(),
2494 LanguageToolConfig {
2495 enabled: false,
2496 lint: vec!["ruff:check".to_string()],
2497 format: vec!["ruff:format".to_string()],
2498 on_error: None,
2499 },
2500 );
2501
2502 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2503
2504 let content = "```python\nprint('hello')\n```";
2505
2506 let lint_result = processor.lint(content);
2508 assert!(lint_result.is_ok());
2509 assert!(lint_result.unwrap().is_empty());
2510
2511 let format_result = processor.format(content);
2513 assert!(format_result.is_ok());
2514 let output = format_result.unwrap();
2515 assert!(!output.had_errors);
2516 assert_eq!(output.content, content, "Content should be unchanged");
2517 }
2518
2519 #[test]
2520 fn test_enabled_true_without_tools_triggers_strict_mode() {
2521 let mut config = default_config();
2524 config.on_missing_language_definition = OnMissing::Fail;
2525
2526 config.languages.insert(
2527 "python".to_string(),
2528 LanguageToolConfig {
2529 ..Default::default()
2531 },
2532 );
2533
2534 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2535
2536 let content = "```python\nprint('hello')\n```";
2537 let result = processor.lint(content);
2538
2539 assert!(result.is_ok());
2541 let diagnostics = result.unwrap();
2542 assert_eq!(diagnostics.len(), 1);
2543 assert!(diagnostics[0].message.contains("No lint tools configured"));
2544 }
2545
2546 #[test]
2547 fn test_mixed_enabled_and_disabled_languages() {
2548 let mut config = default_config();
2550 config.normalize_language = NormalizeLanguage::Exact;
2551 config.on_missing_language_definition = OnMissing::Fail;
2552
2553 config.languages.insert(
2554 "plaintext".to_string(),
2555 LanguageToolConfig {
2556 enabled: false,
2557 ..Default::default()
2558 },
2559 );
2560
2561 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2562
2563 let content = "\
2564```plaintext
2565some text
2566```
2567
2568```javascript
2569console.log('hi');
2570```
2571";
2572
2573 let result = processor.lint(content);
2574 assert!(result.is_ok());
2575 let diagnostics = result.unwrap();
2576
2577 assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2580 assert!(
2581 diagnostics[0].message.contains("javascript"),
2582 "Error should be about javascript, got: {}",
2583 diagnostics[0].message
2584 );
2585 }
2586
2587 #[test]
2588 fn test_generic_fallback_includes_all_stderr_lines() {
2589 let config = default_config();
2590 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2591
2592 let output = ToolOutput {
2594 stdout: String::new(),
2595 stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2596 exit_code: 1,
2597 success: false,
2598 };
2599
2600 let diags = processor.parse_tool_output(&output, "tombi", 5);
2601 assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2602 assert_eq!(diags[0].message, "Parse error at position 42");
2603 assert_eq!(diags[1].message, "Unexpected token '::'");
2604 assert_eq!(diags[2].message, "3 errors found");
2605 assert!(diags.iter().all(|d| d.tool == "tombi"));
2606 assert!(diags.iter().all(|d| d.file_line == 5));
2607 }
2608
2609 #[test]
2610 fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2611 let config = default_config();
2612 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2613
2614 let output = ToolOutput {
2615 stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2616 stderr: String::new(),
2617 exit_code: 1,
2618 success: false,
2619 };
2620
2621 let diags = processor.parse_tool_output(&output, "some-tool", 10);
2622 assert_eq!(diags.len(), 3);
2623 assert_eq!(diags[0].message, "Line 1 error");
2624 assert_eq!(diags[1].message, "Line 2 detail");
2625 assert_eq!(diags[2].message, "Line 3 summary");
2626 }
2627
2628 #[test]
2629 fn test_generic_fallback_skips_blank_lines() {
2630 let config = default_config();
2631 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2632
2633 let output = ToolOutput {
2634 stdout: String::new(),
2635 stderr: "error: bad input\n\n \n\ndetail: see above\n".to_string(),
2636 exit_code: 1,
2637 success: false,
2638 };
2639
2640 let diags = processor.parse_tool_output(&output, "tool", 1);
2641 assert_eq!(diags.len(), 2);
2642 assert_eq!(diags[0].message, "error: bad input");
2643 assert_eq!(diags[1].message, "detail: see above");
2644 }
2645
2646 #[test]
2647 fn test_generic_fallback_exit_code_when_no_output() {
2648 let config = default_config();
2649 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2650
2651 let output = ToolOutput {
2652 stdout: String::new(),
2653 stderr: String::new(),
2654 exit_code: 42,
2655 success: false,
2656 };
2657
2658 let diags = processor.parse_tool_output(&output, "tool", 1);
2659 assert_eq!(diags.len(), 1);
2660 assert_eq!(diags[0].message, "Tool exited with code 42");
2661 }
2662
2663 #[test]
2664 fn test_generic_fallback_not_triggered_on_success() {
2665 let config = default_config();
2666 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2667
2668 let output = ToolOutput {
2669 stdout: "some informational output".to_string(),
2670 stderr: String::new(),
2671 exit_code: 0,
2672 success: true,
2673 };
2674
2675 let diags = processor.parse_tool_output(&output, "tool", 1);
2676 assert!(
2677 diags.is_empty(),
2678 "Successful tool runs should produce no fallback diagnostics"
2679 );
2680 }
2681
2682 #[test]
2683 fn test_ansi_codes_stripped_before_parsing() {
2684 let config = default_config();
2685 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2686
2687 let output = ToolOutput {
2689 stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
2690 .to_string(),
2691 stderr: String::new(),
2692 exit_code: 1,
2693 success: false,
2694 };
2695
2696 let diags = processor.parse_tool_output(&output, "ruff:check", 5);
2697 assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
2698 assert_eq!(diags[0].message, "E501 Line too long");
2699 assert_eq!(diags[0].file_line, 6); }
2701
2702 #[test]
2703 fn test_tombi_multiline_error_format() {
2704 let config = default_config();
2705 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2706
2707 let output = ToolOutput {
2709 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(),
2710 stderr: "1 file failed to be formatted".to_string(),
2711 exit_code: 1,
2712 success: false,
2713 };
2714
2715 let diags = processor.parse_tool_output(&output, "tombi", 7);
2716 assert_eq!(
2717 diags.len(),
2718 4,
2719 "Expected 4 diagnostics from tombi errors, got {diags:?}"
2720 );
2721 assert_eq!(diags[0].message, "invalid key");
2722 assert_eq!(diags[0].file_line, 9); assert_eq!(diags[0].column, Some(1));
2724 assert_eq!(diags[1].message, "expected key");
2725 assert_eq!(diags[1].file_line, 9);
2726 assert_eq!(diags[2].message, "expected '='");
2727 assert_eq!(diags[3].message, "expected value");
2728 assert!(diags.iter().all(|d| d.tool == "tombi"));
2729 }
2730
2731 #[test]
2732 fn test_tombi_with_ansi_codes() {
2733 let config = default_config();
2734 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2735
2736 let output = ToolOutput {
2738 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(),
2739 stderr: "1 file failed to be formatted".to_string(),
2740 exit_code: 1,
2741 success: false,
2742 };
2743
2744 let diags = processor.parse_tool_output(&output, "tombi", 7);
2745 assert_eq!(
2746 diags.len(),
2747 2,
2748 "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
2749 );
2750 assert_eq!(diags[0].message, "invalid key");
2751 assert_eq!(diags[0].file_line, 9);
2752 assert_eq!(diags[1].message, "expected '='");
2753 assert_eq!(diags[1].file_line, 9);
2754 }
2755
2756 const SQLFLUFF_ANNOTATIONS: &str = "\
2761::group::stdin
2762::warning title=SQLFluff,file=stdin,line=1,col=7,endLine=1,endColumn=10::LT01: Expected only single space before numeric literal. Found ' '. [layout.spacing]
2763::warning title=SQLFluff,file=stdin,line=1,col=11,endLine=1,endColumn=13::LT01: Expected only single space before 'FROM' keyword. Found ' '. [layout.spacing]
2764::warning title=SQLFluff,file=stdin,line=1,col=17,endLine=1,endColumn=20::LT01: Expected only single space before naked identifier. Found ' '. [layout.spacing]
2765::endgroup::";
2766
2767 #[test]
2768 fn test_github_annotation_maps_line_and_column() {
2769 let config = default_config();
2770 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2771
2772 let output = ToolOutput {
2773 stdout: SQLFLUFF_ANNOTATIONS.to_string(),
2774 stderr: String::new(),
2775 exit_code: 1,
2776 success: false,
2777 };
2778
2779 let diags = processor.parse_tool_output(&output, "sqlfluff:lint", 3);
2780 assert_eq!(diags.len(), 3, "the group markers carry no finding: {diags:?}");
2781 assert_eq!(diags.iter().map(|d| d.file_line).collect::<Vec<_>>(), vec![4, 4, 4]);
2782 assert_eq!(
2783 diags.iter().map(|d| d.column).collect::<Vec<_>>(),
2784 vec![Some(7), Some(11), Some(17)]
2785 );
2786 assert!(diags[0].message.starts_with("LT01: Expected only single space"));
2787 assert!(diags.iter().all(|d| matches!(d.severity, DiagnosticSeverity::Warning)));
2788 }
2789
2790 #[test]
2791 fn test_github_annotation_severity_per_level() {
2792 let config = default_config();
2793 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2794
2795 let output = ToolOutput {
2796 stdout: "::error line=1::broken\n::warning line=2::suspicious\n::notice line=3::detail".to_string(),
2797 stderr: String::new(),
2798 exit_code: 1,
2799 success: false,
2800 };
2801
2802 let diags = processor.parse_tool_output(&output, "tool", 10);
2803 assert_eq!(diags.len(), 3);
2804 assert!(matches!(diags[0].severity, DiagnosticSeverity::Error));
2805 assert!(matches!(diags[1].severity, DiagnosticSeverity::Warning));
2806 assert!(matches!(diags[2].severity, DiagnosticSeverity::Info));
2807 assert_eq!(diags[2].file_line, 13);
2808 }
2809
2810 #[test]
2811 fn test_github_annotation_without_line_anchors_at_fence() {
2812 let config = default_config();
2813 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2814
2815 let output = ToolOutput {
2816 stdout: "::error::something the tool could not place".to_string(),
2817 stderr: String::new(),
2818 exit_code: 1,
2819 success: false,
2820 };
2821
2822 let diags = processor.parse_tool_output(&output, "tool", 12);
2823 assert_eq!(diags.len(), 1);
2824 assert_eq!(diags[0].file_line, 12);
2825 assert_eq!(diags[0].column, None);
2826 assert_eq!(diags[0].message, "something the tool could not place");
2827 }
2828
2829 #[test]
2830 fn test_github_annotation_decodes_escaped_message() {
2831 let config = default_config();
2832 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2833
2834 let output = ToolOutput {
2837 stdout: "::error line=1,col=2::100%25 of rows%3A a%2C b%0Aand more".to_string(),
2838 stderr: String::new(),
2839 exit_code: 1,
2840 success: false,
2841 };
2842
2843 let diags = processor.parse_tool_output(&output, "tool", 5);
2844 assert_eq!(diags.len(), 1);
2845 assert_eq!(diags[0].message, "100% of rows: a, b and more");
2846 assert_eq!(diags[0].file_line, 6);
2847 assert_eq!(diags[0].column, Some(2));
2848 }
2849
2850 #[test]
2851 fn test_decode_workflow_command_is_a_single_left_to_right_pass() {
2852 assert_eq!(decode_workflow_command("%250A"), "%0A");
2855 assert_eq!(decode_workflow_command("no escapes"), "no escapes");
2856 assert_eq!(decode_workflow_command("50% off"), "50% off");
2857 assert_eq!(decode_workflow_command("trailing %"), "trailing %");
2858 assert_eq!(decode_workflow_command("%0d%0a"), " ");
2859 assert_eq!(decode_workflow_command("%3A%2C"), ":,");
2860 }
2861
2862 #[test]
2863 fn test_position_in_message_anchors_a_prose_position() {
2864 let config = default_config();
2865 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2866
2867 let output = ToolOutput {
2870 stdout: String::new(),
2871 stderr: "jq: error (at <stdin>:0): syntax error, unexpected '}' at line 3, column 8".to_string(),
2872 exit_code: 2,
2873 success: false,
2874 };
2875
2876 let diags = processor.parse_tool_output(&output, "jq", 20);
2877 assert_eq!(diags.len(), 1);
2878 assert_eq!(diags[0].file_line, 23);
2879 assert_eq!(diags[0].column, Some(8));
2880 }
2881
2882 #[test]
2883 fn test_position_in_message_takes_the_last_one_named() {
2884 let config = default_config();
2885 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2886
2887 let output = ToolOutput {
2888 stdout: "unterminated string started at line 2, column 1 at line 6, column 4".to_string(),
2889 stderr: String::new(),
2890 exit_code: 1,
2891 success: false,
2892 };
2893
2894 let diags = processor.parse_tool_output(&output, "tool", 0);
2895 assert_eq!(diags.len(), 1);
2896 assert_eq!(diags[0].file_line, 6, "the sentence ends on the position it is about");
2897 assert_eq!(diags[0].column, Some(4));
2898 }
2899
2900 #[test]
2901 fn test_message_naming_no_position_stays_on_the_fence() {
2902 let config = default_config();
2903 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2904
2905 let output = ToolOutput {
2906 stdout: "could not parse the input".to_string(),
2907 stderr: String::new(),
2908 exit_code: 1,
2909 success: false,
2910 };
2911
2912 let diags = processor.parse_tool_output(&output, "tool", 7);
2913 assert_eq!(diags.len(), 1);
2914 assert_eq!(diags[0].file_line, 7);
2915 assert_eq!(diags[0].column, None);
2916 }
2917
2918 #[test]
2919 fn test_fallback_combines_stdout_and_stderr() {
2920 let config = default_config();
2921 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2922
2923 let output = ToolOutput {
2925 stdout: "problem found in input".to_string(),
2926 stderr: "1 file failed".to_string(),
2927 exit_code: 1,
2928 success: false,
2929 };
2930
2931 let diags = processor.parse_tool_output(&output, "tool", 1);
2932 assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
2933 assert_eq!(diags[0].message, "problem found in input");
2934 assert_eq!(diags[1].message, "1 file failed");
2935 }
2936
2937 #[test]
2938 fn test_error_line_without_position_info() {
2939 let config = default_config();
2940 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2941
2942 let output = ToolOutput {
2944 stdout: "Error: something went wrong\nsome unrelated line".to_string(),
2945 stderr: String::new(),
2946 exit_code: 1,
2947 success: false,
2948 };
2949
2950 let diags = processor.parse_tool_output(&output, "tool", 5);
2951 assert!(!diags.is_empty());
2954 assert_eq!(diags[0].message, "something went wrong");
2955 assert_eq!(diags[0].file_line, 5); }
2957
2958 #[test]
2959 fn test_warning_line_with_position() {
2960 let config = default_config();
2961 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2962
2963 let output = ToolOutput {
2964 stdout: "Warning: deprecated syntax\n at line 3 column 5".to_string(),
2965 stderr: String::new(),
2966 exit_code: 1,
2967 success: false,
2968 };
2969
2970 let diags = processor.parse_tool_output(&output, "tool", 10);
2971 assert_eq!(diags.len(), 1);
2972 assert_eq!(diags[0].message, "deprecated syntax");
2973 assert_eq!(diags[0].file_line, 13); assert_eq!(diags[0].column, Some(5));
2975 assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
2976 }
2977
2978 #[test]
2979 fn test_strip_ansi_codes() {
2980 assert_eq!(strip_ansi_codes("hello"), "hello");
2981 assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
2982 assert_eq!(
2983 strip_ansi_codes("\x1b[1;31m Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
2984 " Error: msg"
2985 );
2986 assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
2987 assert_eq!(strip_ansi_codes(""), "");
2988 assert_eq!(
2989 strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
2990 "at line 2 column 1"
2991 );
2992 }
2993
2994 #[test]
2995 fn test_parse_at_line_column() {
2996 assert_eq!(
2997 CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
2998 Some((2, 1))
2999 );
3000 assert_eq!(
3001 CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
3002 Some((10, 15))
3003 );
3004 assert_eq!(
3005 CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
3006 Some((5, 3))
3007 );
3008 assert_eq!(
3009 CodeBlockToolProcessor::parse_at_line_column("not a position line"),
3010 None
3011 );
3012 assert_eq!(
3013 CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
3014 None
3015 );
3016 }
3017
3018 #[test]
3019 fn test_parse_error_line() {
3020 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
3021 assert_eq!(msg, "invalid key");
3022 assert!(matches!(sev, DiagnosticSeverity::Error));
3023
3024 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
3025 assert_eq!(msg, "deprecated");
3026 assert!(matches!(sev, DiagnosticSeverity::Warning));
3027
3028 assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
3030 assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
3031
3032 assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
3034 assert!(CodeBlockToolProcessor::parse_error_line("Error: ").is_none());
3035
3036 assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
3038 }
3039
3040 #[test]
3041 fn test_consecutive_error_lines_without_position() {
3042 let config = default_config();
3043 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3044
3045 let output = ToolOutput {
3048 stdout: "Error: first problem\nError: second problem\n at line 3 column 1".to_string(),
3049 stderr: String::new(),
3050 exit_code: 1,
3051 success: false,
3052 };
3053
3054 let diags = processor.parse_tool_output(&output, "tool", 5);
3055 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
3056 assert_eq!(diags[0].message, "first problem");
3058 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
3060 assert_eq!(diags[1].message, "second problem");
3062 assert_eq!(diags[1].file_line, 8); assert_eq!(diags[1].column, Some(1));
3064 }
3065
3066 #[test]
3067 fn test_error_line_at_end_of_output() {
3068 let config = default_config();
3069 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3070
3071 let output = ToolOutput {
3073 stdout: "Error: trailing error".to_string(),
3074 stderr: String::new(),
3075 exit_code: 1,
3076 success: false,
3077 };
3078
3079 let diags = processor.parse_tool_output(&output, "tool", 5);
3080 assert_eq!(diags.len(), 1);
3081 assert_eq!(diags[0].message, "trailing error");
3082 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
3084 }
3085
3086 #[test]
3087 fn test_blank_lines_between_error_and_position() {
3088 let config = default_config();
3089 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3090
3091 let output = ToolOutput {
3093 stdout: "Error: spaced out\n\n\n at line 4 column 2".to_string(),
3094 stderr: String::new(),
3095 exit_code: 1,
3096 success: false,
3097 };
3098
3099 let diags = processor.parse_tool_output(&output, "tool", 10);
3100 assert_eq!(diags.len(), 1);
3101 assert_eq!(diags[0].message, "spaced out");
3102 assert_eq!(diags[0].file_line, 14); assert_eq!(diags[0].column, Some(2));
3104 }
3105
3106 #[test]
3107 fn test_mixed_structured_and_error_line_parsers() {
3108 let config = default_config();
3109 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3110
3111 let output = ToolOutput {
3113 stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n at line 3 column 1".to_string(),
3114 stderr: String::new(),
3115 exit_code: 1,
3116 success: false,
3117 };
3118
3119 let diags = processor.parse_tool_output(&output, "tool", 5);
3120 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
3121 assert_eq!(diags[0].message, "E501 Line too long");
3123 assert_eq!(diags[0].file_line, 6); assert_eq!(diags[1].message, "invalid syntax");
3126 assert_eq!(diags[1].file_line, 8); }
3128
3129 #[test]
3130 fn test_at_line_without_preceding_error() {
3131 let config = default_config();
3132 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3133
3134 let output = ToolOutput {
3136 stdout: "at line 2 column 1\nsome other text".to_string(),
3137 stderr: String::new(),
3138 exit_code: 1,
3139 success: false,
3140 };
3141
3142 let diags = processor.parse_tool_output(&output, "tool", 5);
3143 assert_eq!(diags.len(), 2);
3146 assert_eq!(diags[0].message, "at line 2 column 1");
3147 assert_eq!(diags[1].message, "some other text");
3148 }
3149
3150 #[test]
3158 fn test_format_empty_output_does_not_erase_content() {
3159 use super::super::config::LanguageToolConfig;
3160
3161 let mut config = default_config();
3162 config.languages.insert(
3163 "toml".to_string(),
3164 LanguageToolConfig {
3165 format: vec!["empty-formatter".to_string()],
3166 ..Default::default()
3167 },
3168 );
3169 config.tools.insert(
3171 "empty-formatter".to_string(),
3172 super::super::config::ToolDefinition {
3173 command: vec!["true".to_string()],
3174 stdin: true,
3175 stdout: true,
3176 lint_args: vec![],
3177 format_args: vec![],
3178 },
3179 );
3180
3181 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3182
3183 let content = "```toml\nkey = \"value\"\n```\n";
3184 let result = processor.format(content);
3185
3186 assert!(result.is_ok(), "Format should not error");
3187 let output = result.unwrap();
3188
3189 assert!(
3191 output.content.contains("key = \"value\""),
3192 "Empty formatter output should not erase content. Got: {:?}",
3193 output.content
3194 );
3195 }
3196
3197 #[test]
3199 fn test_format_identity_formatter_preserves_content() {
3200 use super::super::config::LanguageToolConfig;
3201
3202 let mut config = default_config();
3203 config.languages.insert(
3204 "toml".to_string(),
3205 LanguageToolConfig {
3206 format: vec!["cat-formatter".to_string()],
3207 ..Default::default()
3208 },
3209 );
3210 config.tools.insert(
3211 "cat-formatter".to_string(),
3212 super::super::config::ToolDefinition {
3213 command: vec!["cat".to_string()],
3214 stdin: true,
3215 stdout: true,
3216 lint_args: vec![],
3217 format_args: vec![],
3218 },
3219 );
3220
3221 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3222
3223 let content = "```toml\nkey = \"value\"\n```\n";
3224 let result = processor.format(content);
3225
3226 assert!(result.is_ok(), "Format should not error");
3227 let output = result.unwrap();
3228 assert_eq!(
3229 output.content, content,
3230 "Identity formatter should preserve content exactly"
3231 );
3232 }
3233
3234 #[test]
3237 fn test_resolve_tool_context_aware_tombi() {
3238 let config = default_config();
3239 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3240
3241 let format_def = processor
3243 .registry
3244 .resolve("tombi", ToolSlot::Format)
3245 .expect("Should resolve tombi in format context");
3246 assert!(
3247 format_def.command.iter().any(|arg| arg == "format"),
3248 "Bare 'tombi' in format context should resolve to 'tombi format', got: {:?}",
3249 format_def.command
3250 );
3251
3252 let lint_def = processor
3254 .registry
3255 .resolve("tombi", ToolSlot::Lint)
3256 .expect("Should resolve tombi in lint context");
3257 assert!(
3258 lint_def.command.iter().any(|arg| arg == "lint"),
3259 "Bare 'tombi' in lint context should resolve to 'tombi lint', got: {:?}",
3260 lint_def.command
3261 );
3262
3263 let explicit_def = processor
3265 .registry
3266 .resolve("tombi:lint", ToolSlot::Format)
3267 .expect("Should resolve explicit tombi:lint even in format context");
3268 assert!(
3269 explicit_def.command.iter().any(|arg| arg == "lint"),
3270 "Explicit 'tombi:lint' should always use lint, got: {:?}",
3271 explicit_def.command
3272 );
3273 }
3274
3275 #[test]
3277 fn test_resolve_tool_context_aware_ruff() {
3278 let config = default_config();
3279 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3280
3281 let lint_def = processor
3283 .registry
3284 .resolve("ruff", ToolSlot::Lint)
3285 .expect("Should resolve ruff in lint context");
3286 assert!(
3287 lint_def.command.iter().any(|arg| arg == "check"),
3288 "Bare 'ruff' in lint context should resolve to 'ruff check', got: {:?}",
3289 lint_def.command
3290 );
3291
3292 let format_def = processor
3294 .registry
3295 .resolve("ruff", ToolSlot::Format)
3296 .expect("Should resolve ruff in format context");
3297 assert!(
3298 format_def.command.iter().any(|arg| arg == "format"),
3299 "Bare 'ruff' in format context should resolve to 'ruff format', got: {:?}",
3300 format_def.command
3301 );
3302 }
3303
3304 #[test]
3306 fn test_resolve_tool_bare_name_fallback() {
3307 let config = default_config();
3308 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3309
3310 let def = processor
3312 .registry
3313 .resolve("shellcheck", ToolSlot::Lint)
3314 .expect("Should resolve shellcheck via fallback");
3315 assert!(
3316 def.command.iter().any(|arg| arg == "shellcheck"),
3317 "shellcheck should resolve to itself, got: {:?}",
3318 def.command
3319 );
3320 }
3321
3322 #[test]
3324 fn test_resolve_tool_context_aware_sqlfluff() {
3325 let config = default_config();
3326 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3327
3328 let format_def = processor
3330 .registry
3331 .resolve("sqlfluff", ToolSlot::Format)
3332 .expect("Should resolve sqlfluff in format context");
3333 assert!(
3334 format_def.command.iter().any(|arg| arg == "fix"),
3335 "Bare 'sqlfluff' in format context should resolve to 'sqlfluff fix', got: {:?}",
3336 format_def.command
3337 );
3338 }
3339
3340 #[test]
3342 fn test_resolve_tool_context_aware_djlint() {
3343 let config = default_config();
3344 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3345
3346 let format_def = processor
3348 .registry
3349 .resolve("djlint", ToolSlot::Format)
3350 .expect("Should resolve djlint in format context");
3351 assert!(
3352 format_def.command.iter().any(|arg| arg.contains("reformat")),
3353 "Bare 'djlint' in format context should resolve to djlint reformat, got: {:?}",
3354 format_def.command
3355 );
3356 }
3357
3358 #[test]
3360 fn test_resolve_tool_user_defined_with_context_variant() {
3361 use super::super::config::ToolDefinition;
3362
3363 let mut config = default_config();
3364 config.tools.insert(
3365 "mytool".to_string(),
3366 ToolDefinition {
3367 command: vec!["mytool".to_string(), "--lint".to_string()],
3368 ..Default::default()
3369 },
3370 );
3371 config.tools.insert(
3372 "mytool:format".to_string(),
3373 ToolDefinition {
3374 command: vec!["mytool".to_string(), "--format".to_string()],
3375 ..Default::default()
3376 },
3377 );
3378
3379 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3380
3381 let def = processor
3383 .registry
3384 .resolve("mytool", ToolSlot::Format)
3385 .expect("Should resolve user tool in format context");
3386 assert!(
3387 def.command.iter().any(|arg| arg == "--format"),
3388 "User 'mytool' in format context should resolve to mytool:format, got: {:?}",
3389 def.command
3390 );
3391
3392 let def = processor
3394 .registry
3395 .resolve("mytool", ToolSlot::Lint)
3396 .expect("Should resolve user tool in lint context via fallback");
3397 assert!(
3398 def.command.iter().any(|arg| arg == "--lint"),
3399 "User 'mytool' in lint context should fall back to bare name, got: {:?}",
3400 def.command
3401 );
3402 }
3403
3404 #[test]
3406 fn test_resolve_tool_nonexistent_returns_none() {
3407 let config = default_config();
3408 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3409
3410 assert!(
3411 processor
3412 .registry
3413 .resolve("nonexistent-tool-xyz", ToolSlot::Lint)
3414 .is_none(),
3415 "Nonexistent tool should return None in lint context"
3416 );
3417 assert!(
3418 processor
3419 .registry
3420 .resolve("nonexistent-tool-xyz", ToolSlot::Format)
3421 .is_none(),
3422 "Nonexistent tool should return None in format context"
3423 );
3424 }
3425
3426 #[test]
3427 fn test_strip_ansi_codes_edge_cases() {
3428 assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
3430 assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
3432 assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
3434 assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
3436 assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
3437 }
3438
3439 #[test]
3440 fn merge_conflict_prevents_external_formatting() {
3441 use super::super::config::{LanguageToolConfig, ToolDefinition};
3442 let mut config = default_config();
3443 config.on_missing_tool_binary = OnMissing::Fail;
3444 config.languages.insert(
3445 "testlang".to_string(),
3446 LanguageToolConfig {
3447 format: vec!["unavailable".to_string()],
3448 ..Default::default()
3449 },
3450 );
3451 config.tools.insert(
3452 "unavailable".to_string(),
3453 ToolDefinition {
3454 command: vec!["rumdl-858-tool-that-must-not-run".to_string()],
3455 ..Default::default()
3456 },
3457 );
3458 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3459 let content = "<<<<<<< HEAD\r\n\n```testlang\ntext\n```\n";
3460 let output = processor.format(content).unwrap();
3461 assert_eq!(output.content, content);
3462 assert!(!output.had_errors);
3463 assert!(output.failures.is_empty());
3464
3465 let mut document_config = crate::config::Config::default();
3468 document_config.global.disable.push("MD092".into());
3469 let output = processor.format_with_config(content, &document_config, None).unwrap();
3470 assert!(output.had_errors);
3471 assert!(!output.failures.is_empty());
3472 let suppressed = format!("<!-- rumdl-disable MD092 -->\n{content}");
3473 assert!(processor.format(&suppressed).unwrap().had_errors);
3474 }
3475
3476 #[cfg(unix)]
3481 #[test]
3482 fn test_lint_yaml_block_no_false_new_line_at_eof() {
3483 use super::super::config::{LanguageToolConfig, ToolDefinition};
3484
3485 let mut config = default_config();
3486 config.normalize_language = NormalizeLanguage::Exact;
3487 config.languages.insert(
3488 "yaml".to_string(),
3489 LanguageToolConfig {
3490 lint: vec!["eof-newline-linter".to_string()],
3491 ..Default::default()
3492 },
3493 );
3494 config.tools.insert(
3498 "eof-newline-linter".to_string(),
3499 ToolDefinition {
3500 command: vec![
3501 "sh".to_string(),
3502 "-c".to_string(),
3503 "if [ -n \"$(tail -c1)\" ]; then echo '1:1: no newline at end of file'; fi".to_string(),
3504 ],
3505 ..Default::default()
3506 },
3507 );
3508
3509 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3510
3511 let content = "```yaml\nfoo: bar\n```\n";
3512 let diagnostics = processor.lint(content).expect("lint should succeed");
3513
3514 assert!(
3515 diagnostics.is_empty(),
3516 "EOF-newline linter should not fire on an extracted code block, got: {diagnostics:?}"
3517 );
3518 }
3519
3520 #[cfg(unix)]
3525 #[test]
3526 fn test_format_feeds_newline_terminated_input() {
3527 use super::super::config::{LanguageToolConfig, ToolDefinition};
3528
3529 let mut config = default_config();
3530 config.normalize_language = NormalizeLanguage::Exact;
3531 config.languages.insert(
3532 "yaml".to_string(),
3533 LanguageToolConfig {
3534 format: vec!["newline-probe".to_string()],
3535 ..Default::default()
3536 },
3537 );
3538 config.tools.insert(
3539 "newline-probe".to_string(),
3540 ToolDefinition {
3541 command: vec![
3542 "sh".to_string(),
3543 "-c".to_string(),
3544 "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi".to_string(),
3545 ],
3546 ..Default::default()
3547 },
3548 );
3549
3550 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3551
3552 let content = "```yaml\nfoo: bar\n```\n";
3553 let output = processor.format(content).expect("format should succeed");
3554
3555 assert!(
3556 output.content.contains("HAD_NEWLINE"),
3557 "Formatter should receive newline-terminated stdin, got: {:?}",
3558 output.content
3559 );
3560 }
3561
3562 #[test]
3563 fn test_ensure_trailing_newline() {
3564 assert_eq!(ensure_trailing_newline("foo: bar"), "foo: bar\n");
3566 assert_eq!(ensure_trailing_newline("a: 1\nb: 2"), "a: 1\nb: 2\n");
3568 assert_eq!(ensure_trailing_newline("foo: bar\n"), "foo: bar\n");
3570 assert_eq!(ensure_trailing_newline("foo: bar\n\n"), "foo: bar\n\n");
3572 assert_eq!(ensure_trailing_newline(""), "");
3574 }
3575}