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(
855 &self,
856 content: &str,
857 config: &crate::config::Config,
858 path: Option<&std::path::Path>,
859 ) -> Result<FormatOutput, ProcessorError> {
860 let no_output = FormatOutput {
861 content: content.to_string(),
862 had_errors: false,
863 error_messages: Vec::new(),
864 failures: Vec::new(),
865 };
866
867 if crate::merge_conflict::detect_configured(content, config, path).is_some() {
868 return Ok(no_output);
869 }
870
871 if self.config.on_missing_language_definition.skips_the_block()
873 && !self
874 .config
875 .languages
876 .values()
877 .any(|lc| lc.enabled && !lc.format.is_empty())
878 {
879 return Ok(no_output);
880 }
881
882 if self.config.on_missing_language_definition.skips_the_block()
884 && !self.has_potential_matching_blocks(content, false)
885 {
886 return Ok(no_output);
887 }
888
889 let blocks = self.extract_code_blocks(content);
890
891 if blocks.is_empty() {
892 return Ok(FormatOutput {
893 content: content.to_string(),
894 had_errors: false,
895 error_messages: Vec::new(),
896 failures: Vec::new(),
897 });
898 }
899
900 let mut result = content.to_string();
902 let mut error_messages: Vec<String> = Vec::new();
903 let mut failures: Vec<CodeBlockDiagnostic> = Vec::new();
904
905 for block in blocks.into_iter().rev() {
906 if block.language.is_empty() {
907 continue;
908 }
909
910 let canonical_lang = self.resolve_language(&block.language);
911
912 let lang_config = self.language_config(&canonical_lang);
914
915 if let Some(lc) = lang_config
917 && !lc.enabled
918 {
919 continue;
920 }
921
922 let format_tools = match lang_config {
923 Some(lc) if !lc.format.is_empty() => &lc.format,
924 Some(lc) if !lc.lint.is_empty() => continue,
926 _ => {
927 match self.config.on_missing_language_definition {
929 OnMissing::Ignore | OnMissing::Warn => continue,
931 OnMissing::Fail => {
932 error_messages.push(format!(
933 "No format tools configured for language '{canonical_lang}' at line {}",
934 block.start_line + 1
935 ));
936 failures.push(CodeBlockDiagnostic {
937 file_line: block.start_line + 1,
938 column: None,
939 message: format!("No format tools configured for language '{canonical_lang}'"),
940 severity: DiagnosticSeverity::Error,
941 tool: "code-block-tools".to_string(),
942 code_block_start: block.start_line + 1,
943 });
944 continue;
945 }
946 OnMissing::FailFast => {
947 return Err(ProcessorError::NoToolsConfigured {
948 language: canonical_lang,
949 line: block.start_line + 1,
950 });
951 }
952 }
953 }
954 };
955
956 if block.content_start >= block.content_end || block.content_end > result.len() {
958 continue;
959 }
960 let code_content_raw = result[block.content_start..block.content_end].to_string();
961 let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
962
963 let mut formatted = code_content.clone();
965 let mut tool_ran = false;
966 for tool_id in format_tools {
967 if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
969 continue;
970 }
971
972 let Some(tool_def) = self.registry.resolve(tool_id, ToolSlot::Format) else {
973 self.warn_unknown_tool(tool_id, &canonical_lang);
974 continue;
975 };
976
977 if self.registry.fills_format_slot(tool_id) == Some(false) {
985 log::warn!(
986 "Tool '{tool_id}' is a linter and cannot format '{canonical_lang}' code blocks; move it to the lint slot"
987 );
988 continue;
989 }
990
991 let tool_name = tool_def.command.first().map_or("", String::as_str);
993 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
994 match self.config.on_missing_tool_binary {
995 OnMissing::Ignore | OnMissing::Warn => {
997 log::debug!("Tool binary '{tool_name}' not found, skipping");
998 continue;
999 }
1000 OnMissing::Fail => {
1001 error_messages.push(format!(
1002 "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
1003 block.start_line + 1
1004 ));
1005 failures.push(CodeBlockDiagnostic {
1006 file_line: block.start_line + 1,
1007 column: None,
1008 message: format!("Tool binary '{tool_name}' not found in PATH"),
1009 severity: DiagnosticSeverity::Error,
1010 tool: "code-block-tools".to_string(),
1011 code_block_start: block.start_line + 1,
1012 });
1013 continue;
1014 }
1015 OnMissing::FailFast => {
1016 return Err(ProcessorError::ToolBinaryNotFound {
1017 tool: tool_name.to_string(),
1018 language: canonical_lang.clone(),
1019 line: block.start_line + 1,
1020 });
1021 }
1022 }
1023 }
1024
1025 let tool_input = ensure_trailing_newline(&formatted);
1026 match self.executor.format(tool_def, &tool_input, Some(self.config.timeout)) {
1027 Ok(output) => {
1028 if output.trim().is_empty() && !formatted.trim().is_empty() {
1032 log::warn!("Formatter '{tool_id}' produced empty output for non-empty input, skipping");
1033 continue;
1034 }
1035
1036 formatted = output;
1038 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1039 formatted.push('\n');
1040 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1041 formatted.pop();
1042 }
1043 tool_ran = true;
1044 break; }
1046 Err(e) => {
1047 let on_error = self.get_on_error(&canonical_lang);
1048 match on_error {
1049 OnError::Fail => {
1050 return Err(ProcessorError::ToolErrorAt {
1051 error: e,
1052 line: block.start_line + 1,
1053 language: canonical_lang,
1054 });
1055 }
1056 OnError::Warn => {
1057 error_messages.push(format!("line {} ({}): {e}", block.start_line + 1, canonical_lang));
1058 }
1059 OnError::Skip => {}
1060 }
1061 }
1062 }
1063 }
1064
1065 if tool_ran && formatted != code_content {
1067 let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
1068 if reindented != code_content_raw {
1069 result.replace_range(block.content_start..block.content_end, &reindented);
1070 }
1071 }
1072 }
1073
1074 Ok(FormatOutput {
1075 content: result,
1076 had_errors: !error_messages.is_empty(),
1077 error_messages,
1078 failures,
1079 })
1080 }
1081
1082 fn format_check_diagnostics(
1092 &self,
1093 output: &str,
1094 code_content: &str,
1095 tool_id: &str,
1096 code_block_start_line: usize,
1097 ) -> Vec<CodeBlockDiagnostic> {
1098 if output.trim().is_empty() && !code_content.trim().is_empty() {
1101 log::warn!("Formatter '{tool_id}' produced empty output for non-empty input, skipping");
1102 return Vec::new();
1103 }
1104
1105 let mut formatted = output.to_string();
1106 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1107 formatted.push('\n');
1108 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1109 formatted.pop();
1110 }
1111
1112 if formatted == code_content {
1113 return Vec::new();
1114 }
1115
1116 vec![CodeBlockDiagnostic {
1117 file_line: code_block_start_line,
1118 column: None,
1119 message: "Code block is not formatted".to_string(),
1120 severity: DiagnosticSeverity::Warning,
1121 tool: tool_id.to_string(),
1122 code_block_start: code_block_start_line,
1123 }]
1124 }
1125
1126 fn parse_tool_output(
1131 &self,
1132 output: &ToolOutput,
1133 tool_id: &str,
1134 code_block_start_line: usize,
1135 ) -> Vec<CodeBlockDiagnostic> {
1136 let mut diagnostics = Vec::new();
1137 let mut shellcheck_line: Option<usize> = None;
1138
1139 let stdout_clean = strip_ansi_codes(&output.stdout);
1141 let stderr_clean = strip_ansi_codes(&output.stderr);
1142 let combined = format!("{stdout_clean}\n{stderr_clean}");
1143
1144 let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
1146
1147 for line in combined.lines() {
1148 let line = line.trim();
1149 if line.is_empty() {
1150 continue;
1151 }
1152
1153 if let Some((ref msg, severity)) = pending_error {
1155 if let Some((line_num, col)) = Self::parse_at_line_column(line) {
1156 diagnostics.push(CodeBlockDiagnostic {
1157 file_line: code_block_start_line + line_num,
1158 column: Some(col),
1159 message: msg.clone(),
1160 severity,
1161 tool: tool_id.to_string(),
1162 code_block_start: code_block_start_line,
1163 });
1164 pending_error = None;
1165 continue;
1166 }
1167 diagnostics.push(Self::unpositioned_diagnostic(
1169 msg,
1170 severity,
1171 tool_id,
1172 code_block_start_line,
1173 ));
1174 pending_error = None;
1175 }
1177
1178 if let Some(line_num) = self.parse_shellcheck_header(line) {
1179 shellcheck_line = Some(line_num);
1180 continue;
1181 }
1182
1183 if let Some(line_num) = shellcheck_line
1184 && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
1185 {
1186 diagnostics.push(diag);
1187 continue;
1188 }
1189
1190 if let Some(diag) = Self::parse_github_annotation(line, tool_id, code_block_start_line) {
1192 diagnostics.push(diag);
1193 continue;
1194 }
1195
1196 if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
1198 diagnostics.push(diag);
1199 continue;
1200 }
1201
1202 if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
1204 diagnostics.push(diag);
1205 continue;
1206 }
1207
1208 if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
1210 diagnostics.push(diag);
1211 continue;
1212 }
1213
1214 if let Some(error_info) = Self::parse_error_line(line) {
1216 pending_error = Some(error_info);
1217 }
1218 }
1219
1220 if let Some((msg, severity)) = pending_error {
1222 diagnostics.push(Self::unpositioned_diagnostic(
1223 &msg,
1224 severity,
1225 tool_id,
1226 code_block_start_line,
1227 ));
1228 }
1229
1230 if diagnostics.is_empty() && !output.success {
1232 let lines: Vec<&str> = combined.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
1233
1234 if lines.is_empty() {
1235 let exit_code = output.exit_code;
1236 diagnostics.push(CodeBlockDiagnostic {
1237 file_line: code_block_start_line,
1238 column: None,
1239 message: format!("Tool exited with code {exit_code}"),
1240 severity: DiagnosticSeverity::Error,
1241 tool: tool_id.to_string(),
1242 code_block_start: code_block_start_line,
1243 });
1244 } else {
1245 for line_text in lines {
1246 diagnostics.push(Self::unpositioned_diagnostic(
1247 line_text,
1248 DiagnosticSeverity::Error,
1249 tool_id,
1250 code_block_start_line,
1251 ));
1252 }
1253 }
1254 }
1255
1256 diagnostics
1257 }
1258
1259 fn unpositioned_diagnostic(
1265 message: &str,
1266 severity: DiagnosticSeverity,
1267 tool_id: &str,
1268 code_block_start_line: usize,
1269 ) -> CodeBlockDiagnostic {
1270 let (line_offset, column) = Self::parse_position_in_message(message).unwrap_or((0, None));
1271 CodeBlockDiagnostic {
1272 file_line: code_block_start_line + line_offset,
1273 column,
1274 message: message.to_string(),
1275 severity,
1276 tool: tool_id.to_string(),
1277 code_block_start: code_block_start_line,
1278 }
1279 }
1280
1281 fn parse_position_in_message(message: &str) -> Option<(usize, Option<usize>)> {
1286 let lower = message.to_ascii_lowercase();
1289 let after_marker = &lower[lower.rfind("at line ")? + "at line ".len()..];
1290
1291 let leading_number = |text: &str| -> Option<usize> {
1292 let end = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
1293 text[..end].parse::<usize>().ok()
1294 };
1295
1296 let line_num = leading_number(after_marker)?;
1297 let after_line = after_marker.trim_start_matches(|c: char| c.is_ascii_digit());
1298 let after_separator = after_line
1299 .trim_start()
1300 .strip_prefix(',')
1301 .unwrap_or(after_line)
1302 .trim_start();
1303 let column = after_separator.strip_prefix("column ").and_then(leading_number);
1304
1305 Some((line_num, column))
1306 }
1307
1308 fn parse_standard_format(
1310 &self,
1311 line: &str,
1312 tool_id: &str,
1313 code_block_start_line: usize,
1314 ) -> Option<CodeBlockDiagnostic> {
1315 let mut parts = line.rsplitn(4, ':');
1317 let message = parts.next()?.trim().to_string();
1318 let part1 = parts.next()?.trim().to_string();
1319 let part2 = parts.next()?.trim().to_string();
1320 let part3 = parts.next().map(|s| s.trim().to_string());
1321
1322 let (line_part, col_part) = if part3.is_some() {
1323 (part2, Some(part1))
1324 } else {
1325 (part1, None)
1326 };
1327
1328 if let Ok(line_num) = line_part.parse::<usize>() {
1329 let column = col_part.and_then(|s| s.parse::<usize>().ok());
1330 let message = Self::strip_fixable_markers(&message);
1331 if !message.is_empty() {
1332 let severity = self.infer_severity(&message);
1333 return Some(CodeBlockDiagnostic {
1334 file_line: code_block_start_line + line_num,
1335 column,
1336 message,
1337 severity,
1338 tool: tool_id.to_string(),
1339 code_block_start: code_block_start_line,
1340 });
1341 }
1342 }
1343 None
1344 }
1345
1346 fn parse_github_annotation(line: &str, tool_id: &str, code_block_start_line: usize) -> Option<CodeBlockDiagnostic> {
1354 let body = line.strip_prefix("::")?;
1355 let (head, message) = body.split_once("::")?;
1356
1357 let (level, properties) = head.split_once(' ').unwrap_or((head, ""));
1358 let severity = match level {
1359 "error" => DiagnosticSeverity::Error,
1360 "warning" => DiagnosticSeverity::Warning,
1361 "notice" => DiagnosticSeverity::Info,
1362 _ => return None,
1363 };
1364
1365 let property = |name: &str| -> Option<usize> {
1366 properties
1367 .split(',')
1368 .filter_map(|pair| pair.split_once('='))
1369 .find(|(key, _)| *key == name)
1370 .and_then(|(_, value)| value.parse::<usize>().ok())
1371 };
1372
1373 let message = Self::strip_fixable_markers(&decode_workflow_command(message));
1374 if message.is_empty() {
1375 return None;
1376 }
1377
1378 Some(CodeBlockDiagnostic {
1379 file_line: code_block_start_line + property("line").unwrap_or(0),
1380 column: property("col"),
1381 message,
1382 severity,
1383 tool: tool_id.to_string(),
1384 code_block_start: code_block_start_line,
1385 })
1386 }
1387
1388 fn parse_eslint_format(
1390 &self,
1391 line: &str,
1392 tool_id: &str,
1393 code_block_start_line: usize,
1394 ) -> Option<CodeBlockDiagnostic> {
1395 let parts: Vec<&str> = line.splitn(3, ' ').collect();
1397 if parts.len() >= 2 {
1398 let loc_parts: Vec<&str> = parts[0].split(':').collect();
1399 if loc_parts.len() == 2
1400 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
1401 {
1402 let (sev_part, msg_part) = if parts.len() >= 3 {
1403 (parts[1], parts[2])
1404 } else {
1405 (parts[1], "")
1406 };
1407 let message = if msg_part.is_empty() {
1408 sev_part.to_string()
1409 } else {
1410 msg_part.to_string()
1411 };
1412 let message = Self::strip_fixable_markers(&message);
1413 let severity = match sev_part.to_lowercase().as_str() {
1414 "error" => DiagnosticSeverity::Error,
1415 "warning" | "warn" => DiagnosticSeverity::Warning,
1416 "info" => DiagnosticSeverity::Info,
1417 _ => self.infer_severity(&message),
1418 };
1419 return Some(CodeBlockDiagnostic {
1420 file_line: code_block_start_line + line_num,
1421 column: Some(col),
1422 message,
1423 severity,
1424 tool: tool_id.to_string(),
1425 code_block_start: code_block_start_line,
1426 });
1427 }
1428 }
1429 None
1430 }
1431
1432 fn parse_shellcheck_format(
1434 &self,
1435 line: &str,
1436 tool_id: &str,
1437 code_block_start_line: usize,
1438 ) -> Option<CodeBlockDiagnostic> {
1439 if line.starts_with("In ")
1441 && line.contains(" line ")
1442 && let Some(line_start) = line.find(" line ")
1443 {
1444 let after_line = &line[line_start + 6..];
1445 if let Some(colon_pos) = after_line.find(':')
1446 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1447 {
1448 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1449 if !message.is_empty() {
1450 let severity = self.infer_severity(&message);
1451 return Some(CodeBlockDiagnostic {
1452 file_line: code_block_start_line + line_num,
1453 column: None,
1454 message,
1455 severity,
1456 tool: tool_id.to_string(),
1457 code_block_start: code_block_start_line,
1458 });
1459 }
1460 }
1461 }
1462 None
1463 }
1464
1465 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1467 if line.starts_with("In ")
1468 && line.contains(" line ")
1469 && let Some(line_start) = line.find(" line ")
1470 {
1471 let after_line = &line[line_start + 6..];
1472 if let Some(colon_pos) = after_line.find(':') {
1473 return after_line[..colon_pos].trim().parse::<usize>().ok();
1474 }
1475 }
1476 None
1477 }
1478
1479 fn parse_shellcheck_message(
1481 &self,
1482 line: &str,
1483 tool_id: &str,
1484 code_block_start_line: usize,
1485 line_num: usize,
1486 ) -> Option<CodeBlockDiagnostic> {
1487 let sc_pos = line.find("SC")?;
1488 let after_sc = &line[sc_pos + 2..];
1489 let code_len = after_sc.chars().take_while(char::is_ascii_digit).count();
1490 if code_len == 0 {
1491 return None;
1492 }
1493 let after_code = &after_sc[code_len..];
1494 let sev_start = after_code.find('(')? + 1;
1495 let sev_end = after_code[sev_start..].find(')')? + sev_start;
1496 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1497 let message_start = after_code.find("):")? + 2;
1498 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1499 if message.is_empty() {
1500 return None;
1501 }
1502
1503 let severity = match sev.as_str() {
1504 "error" => DiagnosticSeverity::Error,
1505 "warning" | "warn" => DiagnosticSeverity::Warning,
1506 "info" | "style" => DiagnosticSeverity::Info,
1507 _ => self.infer_severity(&message),
1508 };
1509
1510 Some(CodeBlockDiagnostic {
1511 file_line: code_block_start_line + line_num,
1512 column: None,
1513 message,
1514 severity,
1515 tool: tool_id.to_string(),
1516 code_block_start: code_block_start_line,
1517 })
1518 }
1519
1520 fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1526 let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1527 (msg, DiagnosticSeverity::Error)
1528 } else if let Some(msg) = line.strip_prefix("Warning:") {
1529 (msg, DiagnosticSeverity::Warning)
1530 } else {
1531 return None;
1532 };
1533 let msg = msg.trim();
1534 if msg.is_empty() {
1535 return None;
1536 }
1537 Some((msg.to_string(), severity))
1538 }
1539
1540 fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1544 let lower = line.to_lowercase();
1545 let rest = lower.strip_prefix("at line ")?;
1546 let mut parts = rest.split_whitespace();
1547 let line_num: usize = parts.next()?.parse().ok()?;
1548 if parts.next()? != "column" {
1549 return None;
1550 }
1551 let col: usize = parts.next()?.parse().ok()?;
1552 Some((line_num, col))
1553 }
1554
1555 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1557 let lower = message.to_lowercase();
1558 if lower.contains("error")
1559 || lower.starts_with('e') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1560 || lower.starts_with('f') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1561 {
1562 DiagnosticSeverity::Error
1563 } else if lower.contains("warning")
1564 || lower.contains("warn")
1565 || lower.starts_with('w') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1566 {
1567 DiagnosticSeverity::Warning
1568 } else {
1569 DiagnosticSeverity::Info
1570 }
1571 }
1572
1573 fn strip_fixable_markers(message: &str) -> String {
1580 message
1581 .replace(" [*]", "")
1582 .replace("[*] ", "")
1583 .replace("[*]", "")
1584 .replace(" (fixable)", "")
1585 .replace("(fixable) ", "")
1586 .replace("(fixable)", "")
1587 .replace(" [fix available]", "")
1588 .replace("[fix available] ", "")
1589 .replace("[fix available]", "")
1590 .replace(" [autofix]", "")
1591 .replace("[autofix] ", "")
1592 .replace("[autofix]", "")
1593 .trim()
1594 .to_string()
1595 }
1596}
1597
1598struct FencedCodeBlockBuilder {
1600 start_line: usize,
1601 content_start: usize,
1602 language: String,
1603 info_string: String,
1604 fence_char: char,
1605 fence_length: usize,
1606 indent: usize,
1607 indent_prefix: String,
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612 use super::*;
1613
1614 fn default_config() -> CodeBlockToolsConfig {
1615 CodeBlockToolsConfig::default()
1616 }
1617
1618 #[test]
1619 fn test_extract_code_blocks() {
1620 let config = default_config();
1621 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1622
1623 let content = r#"# Example
1624
1625```python
1626def hello():
1627 print("Hello")
1628```
1629
1630Some text
1631
1632```rust
1633fn main() {}
1634```
1635"#;
1636
1637 let blocks = processor.extract_code_blocks(content);
1638
1639 assert_eq!(blocks.len(), 2);
1640
1641 assert_eq!(blocks[0].language, "python");
1642 assert_eq!(blocks[0].fence_char, '`');
1643 assert_eq!(blocks[0].fence_length, 3);
1644 assert_eq!(blocks[0].start_line, 2);
1645 assert_eq!(blocks[0].indent, 0);
1646 assert_eq!(blocks[0].indent_prefix, "");
1647
1648 assert_eq!(blocks[1].language, "rust");
1649 assert_eq!(blocks[1].fence_char, '`');
1650 assert_eq!(blocks[1].fence_length, 3);
1651 }
1652
1653 #[test]
1654 fn test_extract_code_blocks_with_info_string() {
1655 let config = default_config();
1656 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1657
1658 let content = "```python title=\"example.py\"\ncode\n```";
1659 let blocks = processor.extract_code_blocks(content);
1660
1661 assert_eq!(blocks.len(), 1);
1662 assert_eq!(blocks[0].language, "python");
1663 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1664 }
1665
1666 #[test]
1667 fn test_extract_code_blocks_tilde_fence() {
1668 let config = default_config();
1669 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1670
1671 let content = "~~~bash\necho hello\n~~~";
1672 let blocks = processor.extract_code_blocks(content);
1673
1674 assert_eq!(blocks.len(), 1);
1675 assert_eq!(blocks[0].language, "bash");
1676 assert_eq!(blocks[0].fence_char, '~');
1677 assert_eq!(blocks[0].fence_length, 3);
1678 assert_eq!(blocks[0].indent_prefix, "");
1679 }
1680
1681 #[test]
1682 fn test_extract_code_blocks_with_indent_prefix() {
1683 let config = default_config();
1684 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1685
1686 let content = " - item\n ```python\n print('hi')\n ```";
1687 let blocks = processor.extract_code_blocks(content);
1688
1689 assert_eq!(blocks.len(), 1);
1690 assert_eq!(blocks[0].indent_prefix, " ");
1691 }
1692
1693 #[test]
1694 fn test_extract_code_blocks_no_language() {
1695 let config = default_config();
1696 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1697
1698 let content = "```\nplain code\n```";
1699 let blocks = processor.extract_code_blocks(content);
1700
1701 assert_eq!(blocks.len(), 1);
1702 assert_eq!(blocks[0].language, "");
1703 }
1704
1705 #[test]
1706 fn test_resolve_language_linguist() {
1707 let mut config = default_config();
1708 config.normalize_language = NormalizeLanguage::Linguist;
1709 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1710
1711 assert_eq!(processor.resolve_language("py"), "python");
1712 assert_eq!(processor.resolve_language("bash"), "shell");
1713 assert_eq!(processor.resolve_language("js"), "javascript");
1714 }
1715
1716 #[test]
1717 fn test_resolve_language_exact() {
1718 let mut config = default_config();
1719 config.normalize_language = NormalizeLanguage::Exact;
1720 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1721
1722 assert_eq!(processor.resolve_language("py"), "py");
1723 assert_eq!(processor.resolve_language("BASH"), "bash");
1724 }
1725
1726 #[test]
1727 fn test_resolve_language_user_alias_override() {
1728 let mut config = default_config();
1729 config.language_aliases.insert("py".to_string(), "python".to_string());
1730 config.normalize_language = NormalizeLanguage::Exact;
1731 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1732
1733 assert_eq!(processor.resolve_language("PY"), "python");
1734 }
1735
1736 #[test]
1737 fn test_indent_strip_and_reapply_roundtrip() {
1738 let config = default_config();
1739 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1740
1741 let raw = " def hello():\n print('hi')";
1742 let stripped = processor.strip_indent_from_block(raw, " ");
1743 assert_eq!(stripped, "def hello():\n print('hi')");
1744
1745 let reapplied = processor.apply_indent_to_block(&stripped, " ");
1746 assert_eq!(reapplied, raw);
1747 }
1748
1749 #[test]
1750 fn test_infer_severity() {
1751 let config = default_config();
1752 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1753
1754 assert_eq!(
1755 processor.infer_severity("E501 line too long"),
1756 DiagnosticSeverity::Error
1757 );
1758 assert_eq!(
1759 processor.infer_severity("W291 trailing whitespace"),
1760 DiagnosticSeverity::Warning
1761 );
1762 assert_eq!(
1763 processor.infer_severity("error: something failed"),
1764 DiagnosticSeverity::Error
1765 );
1766 assert_eq!(
1767 processor.infer_severity("warning: unused variable"),
1768 DiagnosticSeverity::Warning
1769 );
1770 assert_eq!(
1771 processor.infer_severity("note: consider using"),
1772 DiagnosticSeverity::Info
1773 );
1774 }
1775
1776 #[test]
1777 fn test_parse_standard_format_windows_path() {
1778 let config = default_config();
1779 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1780
1781 let output = ToolOutput {
1782 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1783 stderr: String::new(),
1784 exit_code: 1,
1785 success: false,
1786 };
1787
1788 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1789 assert_eq!(diags.len(), 1);
1790 assert_eq!(diags[0].file_line, 12);
1791 assert_eq!(diags[0].column, Some(5));
1792 assert_eq!(diags[0].message, "E123 message");
1793 }
1794
1795 #[test]
1796 fn test_parse_eslint_severity() {
1797 let config = default_config();
1798 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1799
1800 let output = ToolOutput {
1801 stdout: "1:2 error Unexpected token".to_string(),
1802 stderr: String::new(),
1803 exit_code: 1,
1804 success: false,
1805 };
1806
1807 let diags = processor.parse_tool_output(&output, "eslint", 5);
1808 assert_eq!(diags.len(), 1);
1809 assert_eq!(diags[0].file_line, 6);
1810 assert_eq!(diags[0].column, Some(2));
1811 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1812 assert_eq!(diags[0].message, "Unexpected token");
1813 }
1814
1815 #[test]
1816 fn test_parse_shellcheck_multiline() {
1817 let config = default_config();
1818 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1819
1820 let output = ToolOutput {
1821 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1822 stderr: String::new(),
1823 exit_code: 1,
1824 success: false,
1825 };
1826
1827 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1828 assert_eq!(diags.len(), 1);
1829 assert_eq!(diags[0].file_line, 13);
1830 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1831 assert_eq!(diags[0].message, "Double quote to prevent globbing");
1832 }
1833
1834 #[test]
1835 fn test_lint_no_config() {
1836 let config = default_config();
1837 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1838
1839 let content = "```python\nprint('hello')\n```";
1840 let result = processor.lint(content);
1841
1842 assert!(result.is_ok());
1844 assert!(result.unwrap().is_empty());
1845 }
1846
1847 #[test]
1848 fn test_format_no_config() {
1849 let config = default_config();
1850 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1851
1852 let content = "```python\nprint('hello')\n```";
1853 let result = processor.format(content);
1854
1855 assert!(result.is_ok());
1857 let output = result.unwrap();
1858 assert_eq!(output.content, content);
1859 assert!(!output.had_errors);
1860 assert!(output.error_messages.is_empty());
1861 }
1862
1863 #[test]
1864 fn test_lint_on_missing_language_definition_fail() {
1865 let mut config = default_config();
1866 config.on_missing_language_definition = OnMissing::Fail;
1867 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1868
1869 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1870 let result = processor.lint(content);
1871
1872 assert!(result.is_ok());
1874 let diagnostics = result.unwrap();
1875 assert_eq!(diagnostics.len(), 2);
1876 assert!(diagnostics[0].message.contains("No lint tools configured"));
1877 assert!(diagnostics[0].message.contains("python"));
1878 assert!(diagnostics[1].message.contains("javascript"));
1879 }
1880
1881 #[test]
1882 fn test_lint_on_missing_language_definition_fail_fast() {
1883 let mut config = default_config();
1884 config.on_missing_language_definition = OnMissing::FailFast;
1885 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1886
1887 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1888 let result = processor.lint(content);
1889
1890 assert!(result.is_err());
1892 let err = result.unwrap_err();
1893 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1894 }
1895
1896 #[test]
1897 fn test_format_on_missing_language_definition_fail() {
1898 let mut config = default_config();
1899 config.on_missing_language_definition = OnMissing::Fail;
1900 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1901
1902 let content = "```python\nprint('hello')\n```";
1903 let result = processor.format(content);
1904
1905 assert!(result.is_ok());
1907 let output = result.unwrap();
1908 assert_eq!(output.content, content); assert!(output.had_errors);
1910 assert!(!output.error_messages.is_empty());
1911 assert!(output.error_messages[0].contains("No format tools configured"));
1912 }
1913
1914 #[test]
1915 fn test_format_on_missing_language_definition_fail_fast() {
1916 let mut config = default_config();
1917 config.on_missing_language_definition = OnMissing::FailFast;
1918 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1919
1920 let content = "```python\nprint('hello')\n```";
1921 let result = processor.format(content);
1922
1923 assert!(result.is_err());
1925 let err = result.unwrap_err();
1926 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1927 }
1928
1929 #[test]
1930 fn test_lint_on_missing_tool_binary_fail() {
1931 use super::super::config::{LanguageToolConfig, ToolDefinition};
1932
1933 let mut config = default_config();
1934 config.on_missing_tool_binary = OnMissing::Fail;
1935
1936 let lang_config = LanguageToolConfig {
1938 lint: vec!["nonexistent-linter".to_string()],
1939 ..Default::default()
1940 };
1941 config.languages.insert("python".to_string(), lang_config);
1942
1943 let tool_def = ToolDefinition {
1944 command: vec!["nonexistent-binary-xyz123".to_string()],
1945 ..Default::default()
1946 };
1947 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1948
1949 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1950
1951 let content = "```python\nprint('hello')\n```";
1952 let result = processor.lint(content);
1953
1954 assert!(result.is_ok());
1956 let diagnostics = result.unwrap();
1957 assert_eq!(diagnostics.len(), 1);
1958 assert!(diagnostics[0].message.contains("not found in PATH"));
1959 }
1960
1961 #[test]
1962 fn test_lint_on_missing_tool_binary_fail_fast() {
1963 use super::super::config::{LanguageToolConfig, ToolDefinition};
1964
1965 let mut config = default_config();
1966 config.on_missing_tool_binary = OnMissing::FailFast;
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_err());
1988 let err = result.unwrap_err();
1989 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1990 }
1991
1992 #[test]
1993 fn test_format_on_missing_tool_binary_fail() {
1994 use super::super::config::{LanguageToolConfig, ToolDefinition};
1995
1996 let mut config = default_config();
1997 config.on_missing_tool_binary = OnMissing::Fail;
1998
1999 let lang_config = LanguageToolConfig {
2001 format: vec!["nonexistent-formatter".to_string()],
2002 ..Default::default()
2003 };
2004 config.languages.insert("python".to_string(), lang_config);
2005
2006 let tool_def = ToolDefinition {
2007 command: vec!["nonexistent-binary-xyz123".to_string()],
2008 ..Default::default()
2009 };
2010 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
2011
2012 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2013
2014 let content = "```python\nprint('hello')\n```";
2015 let result = processor.format(content);
2016
2017 assert!(result.is_ok());
2019 let output = result.unwrap();
2020 assert_eq!(output.content, content); assert!(output.had_errors);
2022 assert!(!output.error_messages.is_empty());
2023 assert!(output.error_messages[0].contains("not found in PATH"));
2024 }
2025
2026 #[test]
2027 fn test_format_on_missing_tool_binary_fail_fast() {
2028 use super::super::config::{LanguageToolConfig, ToolDefinition};
2029
2030 let mut config = default_config();
2031 config.on_missing_tool_binary = OnMissing::FailFast;
2032
2033 let lang_config = LanguageToolConfig {
2035 format: vec!["nonexistent-formatter".to_string()],
2036 ..Default::default()
2037 };
2038 config.languages.insert("python".to_string(), lang_config);
2039
2040 let tool_def = ToolDefinition {
2041 command: vec!["nonexistent-binary-xyz123".to_string()],
2042 ..Default::default()
2043 };
2044 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
2045
2046 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2047
2048 let content = "```python\nprint('hello')\n```";
2049 let result = processor.format(content);
2050
2051 assert!(result.is_err());
2053 let err = result.unwrap_err();
2054 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
2055 }
2056
2057 #[test]
2058 fn test_lint_rumdl_builtin_skipped_for_markdown() {
2059 let mut config = default_config();
2062 config.languages.insert(
2063 "markdown".to_string(),
2064 LanguageToolConfig {
2065 lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
2066 ..Default::default()
2067 },
2068 );
2069 config.on_missing_language_definition = OnMissing::Fail;
2070 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2071
2072 let content = "```markdown\n# Hello\n```";
2073 let result = processor.lint(content);
2074
2075 assert!(result.is_ok());
2077 assert!(result.unwrap().is_empty());
2078 }
2079
2080 #[test]
2081 fn test_format_rumdl_builtin_skipped_for_markdown() {
2082 let mut config = default_config();
2084 config.languages.insert(
2085 "markdown".to_string(),
2086 LanguageToolConfig {
2087 format: vec![RUMDL_BUILTIN_TOOL.to_string()],
2088 ..Default::default()
2089 },
2090 );
2091 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2092
2093 let content = "```markdown\n# Hello\n```";
2094 let result = processor.format(content);
2095
2096 assert!(result.is_ok());
2098 let output = result.unwrap();
2099 assert_eq!(output.content, content);
2100 assert!(!output.had_errors);
2101 }
2102
2103 #[test]
2104 fn test_is_markdown_language() {
2105 assert!(is_markdown_language("markdown"));
2107 assert!(is_markdown_language("Markdown"));
2108 assert!(is_markdown_language("MARKDOWN"));
2109 assert!(is_markdown_language("md"));
2110 assert!(is_markdown_language("MD"));
2111 assert!(!is_markdown_language("python"));
2112 assert!(!is_markdown_language("rust"));
2113 assert!(!is_markdown_language(""));
2114 }
2115
2116 #[test]
2119 fn test_extract_mkdocs_admonition_code_block() {
2120 let config = default_config();
2121 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2122
2123 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
2124 let blocks = processor.extract_code_blocks(content);
2125
2126 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
2127 assert_eq!(blocks[0].language, "python");
2128 }
2129
2130 #[test]
2131 fn test_extract_mkdocs_tab_code_block() {
2132 let config = default_config();
2133 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2134
2135 let content = "=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n";
2136 let blocks = processor.extract_code_blocks(content);
2137
2138 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
2139 assert_eq!(blocks[0].language, "python");
2140 }
2141
2142 #[test]
2143 fn test_standard_flavor_ignores_admonition_indented_content() {
2144 let config = default_config();
2145 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2146
2147 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
2150 let blocks = processor.extract_code_blocks(content);
2151
2152 for (i, b) in blocks.iter().enumerate() {
2156 for (j, b2) in blocks.iter().enumerate() {
2157 if i != j {
2158 assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
2159 }
2160 }
2161 }
2162 }
2163
2164 #[test]
2165 fn test_mkdocs_top_level_blocks_alongside_admonition() {
2166 let config = default_config();
2167 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2168
2169 let content =
2170 "```rust\nfn main() {}\n```\n\n!!! note\n Some text\n\n ```python\n print(\"hello\")\n ```\n";
2171 let blocks = processor.extract_code_blocks(content);
2172
2173 assert_eq!(
2174 blocks.len(),
2175 2,
2176 "Should detect both top-level and admonition code blocks"
2177 );
2178 assert_eq!(blocks[0].language, "rust");
2179 assert_eq!(blocks[1].language, "python");
2180 }
2181
2182 #[test]
2183 fn test_mkdocs_nested_admonition_code_block() {
2184 let config = default_config();
2185 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2186
2187 let content = "\
2188!!! note
2189 Some text
2190
2191 !!! warning
2192 Nested content
2193
2194 ```python
2195 x = 1
2196 ```
2197";
2198 let blocks = processor.extract_code_blocks(content);
2199 assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
2200 assert_eq!(blocks[0].language, "python");
2201 }
2202
2203 #[test]
2204 fn test_mkdocs_consecutive_admonitions_no_stale_context() {
2205 let config = default_config();
2206 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2207
2208 let content = "\
2211!!! note
2212 First admonition content
2213
2214!!! warning
2215 Second admonition content
2216
2217 ```python
2218 y = 2
2219 ```
2220";
2221 let blocks = processor.extract_code_blocks(content);
2222 assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
2223 assert_eq!(blocks[0].language, "python");
2224 }
2225
2226 #[test]
2227 fn test_mkdocs_crlf_line_endings() {
2228 let config = default_config();
2229 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2230
2231 let content = "!!! note\r\n Some text\r\n\r\n ```python\r\n x = 1\r\n ```\r\n";
2233 let blocks = processor.extract_code_blocks(content);
2234
2235 assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
2236 assert_eq!(blocks[0].language, "python");
2237
2238 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2240 assert!(
2241 extracted.contains("x = 1"),
2242 "Extracted content should contain code. Got: {extracted:?}"
2243 );
2244 }
2245
2246 #[test]
2247 fn test_mkdocs_unclosed_fence_in_admonition() {
2248 let config = default_config();
2249 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2250
2251 let content = "!!! note\n ```python\n x = 1\n no closing fence\n";
2253 let blocks = processor.extract_code_blocks(content);
2254 assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
2255 }
2256
2257 #[test]
2258 fn test_mkdocs_tilde_fence_in_admonition() {
2259 let config = default_config();
2260 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2261
2262 let content = "!!! note\n ~~~ruby\n puts 'hi'\n ~~~\n";
2263 let blocks = processor.extract_code_blocks(content);
2264 assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
2265 assert_eq!(blocks[0].language, "ruby");
2266 }
2267
2268 #[test]
2269 fn test_mkdocs_empty_lines_in_code_block() {
2270 let config = default_config();
2271 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2272
2273 let content = "!!! note\n ```python\n x = 1\n\n y = 2\n ```\n";
2276 let blocks = processor.extract_code_blocks(content);
2277 assert_eq!(blocks.len(), 1);
2278
2279 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2280 assert!(
2281 extracted.contains("x = 1") && extracted.contains("y = 2"),
2282 "Extracted content should span across the empty line. Got: {extracted:?}"
2283 );
2284 }
2285
2286 #[test]
2287 fn test_mkdocs_content_byte_offsets_lf() {
2288 let config = default_config();
2289 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2290
2291 let content = "!!! note\n ```python\n print('hi')\n ```\n";
2292 let blocks = processor.extract_code_blocks(content);
2293 assert_eq!(blocks.len(), 1);
2294
2295 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2297 assert_eq!(extracted, " print('hi')\n", "Content offsets should be exact for LF");
2298 }
2299
2300 #[test]
2301 fn test_mkdocs_content_byte_offsets_crlf() {
2302 let config = default_config();
2303 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2304
2305 let content = "!!! note\r\n ```python\r\n print('hi')\r\n ```\r\n";
2306 let blocks = processor.extract_code_blocks(content);
2307 assert_eq!(blocks.len(), 1);
2308
2309 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2310 assert_eq!(
2311 extracted, " print('hi')\r\n",
2312 "Content offsets should be exact for CRLF"
2313 );
2314 }
2315
2316 #[test]
2317 fn test_lint_enabled_false_skips_language_in_strict_mode() {
2318 let mut config = default_config();
2321 config.normalize_language = NormalizeLanguage::Exact;
2322 config.on_missing_language_definition = OnMissing::Fail;
2323
2324 config.languages.insert(
2326 "python".to_string(),
2327 LanguageToolConfig {
2328 lint: vec!["ruff:check".to_string()],
2329 ..Default::default()
2330 },
2331 );
2332 config.languages.insert(
2333 "plaintext".to_string(),
2334 LanguageToolConfig {
2335 enabled: false,
2336 ..Default::default()
2337 },
2338 );
2339
2340 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2341
2342 let content = "```plaintext\nsome text\n```";
2343 let result = processor.lint(content);
2344
2345 assert!(result.is_ok());
2347 let diagnostics = result.unwrap();
2348 assert!(
2349 diagnostics.is_empty(),
2350 "Expected no diagnostics for disabled language, got: {diagnostics:?}"
2351 );
2352 }
2353
2354 #[test]
2355 fn test_format_enabled_false_skips_language_in_strict_mode() {
2356 let mut config = default_config();
2358 config.normalize_language = NormalizeLanguage::Exact;
2359 config.on_missing_language_definition = OnMissing::Fail;
2360
2361 config.languages.insert(
2362 "plaintext".to_string(),
2363 LanguageToolConfig {
2364 enabled: false,
2365 ..Default::default()
2366 },
2367 );
2368
2369 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2370
2371 let content = "```plaintext\nsome text\n```";
2372 let result = processor.format(content);
2373
2374 assert!(result.is_ok());
2376 let output = result.unwrap();
2377 assert!(!output.had_errors, "Expected no errors for disabled language");
2378 assert!(
2379 output.error_messages.is_empty(),
2380 "Expected no error messages, got: {:?}",
2381 output.error_messages
2382 );
2383 }
2384
2385 #[test]
2386 fn test_enabled_false_default_true_preserved() {
2387 let mut config = default_config();
2389 config.on_missing_language_definition = OnMissing::Fail;
2390
2391 config.languages.insert(
2393 "python".to_string(),
2394 LanguageToolConfig {
2395 lint: vec!["ruff:check".to_string()],
2396 ..Default::default()
2397 },
2398 );
2399
2400 let lang_config = config.languages.get("python").unwrap();
2401 assert!(lang_config.enabled, "enabled should default to true");
2402 }
2403
2404 #[test]
2405 fn test_enabled_false_with_fail_fast_no_error() {
2406 let mut config = default_config();
2408 config.normalize_language = NormalizeLanguage::Exact;
2409 config.on_missing_language_definition = OnMissing::FailFast;
2410
2411 config.languages.insert(
2412 "unknown".to_string(),
2413 LanguageToolConfig {
2414 enabled: false,
2415 ..Default::default()
2416 },
2417 );
2418
2419 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2420
2421 let content = "```unknown\nsome content\n```";
2422 let result = processor.lint(content);
2423
2424 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2426 assert!(result.unwrap().is_empty());
2427 }
2428
2429 #[test]
2430 fn test_enabled_false_format_with_fail_fast_no_error() {
2431 let mut config = default_config();
2433 config.normalize_language = NormalizeLanguage::Exact;
2434 config.on_missing_language_definition = OnMissing::FailFast;
2435
2436 config.languages.insert(
2437 "unknown".to_string(),
2438 LanguageToolConfig {
2439 enabled: false,
2440 ..Default::default()
2441 },
2442 );
2443
2444 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2445
2446 let content = "```unknown\nsome content\n```";
2447 let result = processor.format(content);
2448
2449 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2450 let output = result.unwrap();
2451 assert!(!output.had_errors);
2452 }
2453
2454 #[test]
2455 fn test_enabled_false_with_tools_still_skips() {
2456 let mut config = default_config();
2458 config.on_missing_language_definition = OnMissing::Fail;
2459
2460 config.languages.insert(
2461 "python".to_string(),
2462 LanguageToolConfig {
2463 enabled: false,
2464 lint: vec!["ruff:check".to_string()],
2465 format: vec!["ruff:format".to_string()],
2466 on_error: None,
2467 },
2468 );
2469
2470 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2471
2472 let content = "```python\nprint('hello')\n```";
2473
2474 let lint_result = processor.lint(content);
2476 assert!(lint_result.is_ok());
2477 assert!(lint_result.unwrap().is_empty());
2478
2479 let format_result = processor.format(content);
2481 assert!(format_result.is_ok());
2482 let output = format_result.unwrap();
2483 assert!(!output.had_errors);
2484 assert_eq!(output.content, content, "Content should be unchanged");
2485 }
2486
2487 #[test]
2488 fn test_enabled_true_without_tools_triggers_strict_mode() {
2489 let mut config = default_config();
2492 config.on_missing_language_definition = OnMissing::Fail;
2493
2494 config.languages.insert(
2495 "python".to_string(),
2496 LanguageToolConfig {
2497 ..Default::default()
2499 },
2500 );
2501
2502 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2503
2504 let content = "```python\nprint('hello')\n```";
2505 let result = processor.lint(content);
2506
2507 assert!(result.is_ok());
2509 let diagnostics = result.unwrap();
2510 assert_eq!(diagnostics.len(), 1);
2511 assert!(diagnostics[0].message.contains("No lint tools configured"));
2512 }
2513
2514 #[test]
2515 fn test_mixed_enabled_and_disabled_languages() {
2516 let mut config = default_config();
2518 config.normalize_language = NormalizeLanguage::Exact;
2519 config.on_missing_language_definition = OnMissing::Fail;
2520
2521 config.languages.insert(
2522 "plaintext".to_string(),
2523 LanguageToolConfig {
2524 enabled: false,
2525 ..Default::default()
2526 },
2527 );
2528
2529 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2530
2531 let content = "\
2532```plaintext
2533some text
2534```
2535
2536```javascript
2537console.log('hi');
2538```
2539";
2540
2541 let result = processor.lint(content);
2542 assert!(result.is_ok());
2543 let diagnostics = result.unwrap();
2544
2545 assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2548 assert!(
2549 diagnostics[0].message.contains("javascript"),
2550 "Error should be about javascript, got: {}",
2551 diagnostics[0].message
2552 );
2553 }
2554
2555 #[test]
2556 fn test_generic_fallback_includes_all_stderr_lines() {
2557 let config = default_config();
2558 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2559
2560 let output = ToolOutput {
2562 stdout: String::new(),
2563 stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2564 exit_code: 1,
2565 success: false,
2566 };
2567
2568 let diags = processor.parse_tool_output(&output, "tombi", 5);
2569 assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2570 assert_eq!(diags[0].message, "Parse error at position 42");
2571 assert_eq!(diags[1].message, "Unexpected token '::'");
2572 assert_eq!(diags[2].message, "3 errors found");
2573 assert!(diags.iter().all(|d| d.tool == "tombi"));
2574 assert!(diags.iter().all(|d| d.file_line == 5));
2575 }
2576
2577 #[test]
2578 fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2579 let config = default_config();
2580 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2581
2582 let output = ToolOutput {
2583 stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2584 stderr: String::new(),
2585 exit_code: 1,
2586 success: false,
2587 };
2588
2589 let diags = processor.parse_tool_output(&output, "some-tool", 10);
2590 assert_eq!(diags.len(), 3);
2591 assert_eq!(diags[0].message, "Line 1 error");
2592 assert_eq!(diags[1].message, "Line 2 detail");
2593 assert_eq!(diags[2].message, "Line 3 summary");
2594 }
2595
2596 #[test]
2597 fn test_generic_fallback_skips_blank_lines() {
2598 let config = default_config();
2599 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2600
2601 let output = ToolOutput {
2602 stdout: String::new(),
2603 stderr: "error: bad input\n\n \n\ndetail: see above\n".to_string(),
2604 exit_code: 1,
2605 success: false,
2606 };
2607
2608 let diags = processor.parse_tool_output(&output, "tool", 1);
2609 assert_eq!(diags.len(), 2);
2610 assert_eq!(diags[0].message, "error: bad input");
2611 assert_eq!(diags[1].message, "detail: see above");
2612 }
2613
2614 #[test]
2615 fn test_generic_fallback_exit_code_when_no_output() {
2616 let config = default_config();
2617 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2618
2619 let output = ToolOutput {
2620 stdout: String::new(),
2621 stderr: String::new(),
2622 exit_code: 42,
2623 success: false,
2624 };
2625
2626 let diags = processor.parse_tool_output(&output, "tool", 1);
2627 assert_eq!(diags.len(), 1);
2628 assert_eq!(diags[0].message, "Tool exited with code 42");
2629 }
2630
2631 #[test]
2632 fn test_generic_fallback_not_triggered_on_success() {
2633 let config = default_config();
2634 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2635
2636 let output = ToolOutput {
2637 stdout: "some informational output".to_string(),
2638 stderr: String::new(),
2639 exit_code: 0,
2640 success: true,
2641 };
2642
2643 let diags = processor.parse_tool_output(&output, "tool", 1);
2644 assert!(
2645 diags.is_empty(),
2646 "Successful tool runs should produce no fallback diagnostics"
2647 );
2648 }
2649
2650 #[test]
2651 fn test_ansi_codes_stripped_before_parsing() {
2652 let config = default_config();
2653 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2654
2655 let output = ToolOutput {
2657 stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
2658 .to_string(),
2659 stderr: String::new(),
2660 exit_code: 1,
2661 success: false,
2662 };
2663
2664 let diags = processor.parse_tool_output(&output, "ruff:check", 5);
2665 assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
2666 assert_eq!(diags[0].message, "E501 Line too long");
2667 assert_eq!(diags[0].file_line, 6); }
2669
2670 #[test]
2671 fn test_tombi_multiline_error_format() {
2672 let config = default_config();
2673 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2674
2675 let output = ToolOutput {
2677 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(),
2678 stderr: "1 file failed to be formatted".to_string(),
2679 exit_code: 1,
2680 success: false,
2681 };
2682
2683 let diags = processor.parse_tool_output(&output, "tombi", 7);
2684 assert_eq!(
2685 diags.len(),
2686 4,
2687 "Expected 4 diagnostics from tombi errors, got {diags:?}"
2688 );
2689 assert_eq!(diags[0].message, "invalid key");
2690 assert_eq!(diags[0].file_line, 9); assert_eq!(diags[0].column, Some(1));
2692 assert_eq!(diags[1].message, "expected key");
2693 assert_eq!(diags[1].file_line, 9);
2694 assert_eq!(diags[2].message, "expected '='");
2695 assert_eq!(diags[3].message, "expected value");
2696 assert!(diags.iter().all(|d| d.tool == "tombi"));
2697 }
2698
2699 #[test]
2700 fn test_tombi_with_ansi_codes() {
2701 let config = default_config();
2702 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2703
2704 let output = ToolOutput {
2706 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(),
2707 stderr: "1 file failed to be formatted".to_string(),
2708 exit_code: 1,
2709 success: false,
2710 };
2711
2712 let diags = processor.parse_tool_output(&output, "tombi", 7);
2713 assert_eq!(
2714 diags.len(),
2715 2,
2716 "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
2717 );
2718 assert_eq!(diags[0].message, "invalid key");
2719 assert_eq!(diags[0].file_line, 9);
2720 assert_eq!(diags[1].message, "expected '='");
2721 assert_eq!(diags[1].file_line, 9);
2722 }
2723
2724 const SQLFLUFF_ANNOTATIONS: &str = "\
2729::group::stdin
2730::warning title=SQLFluff,file=stdin,line=1,col=7,endLine=1,endColumn=10::LT01: Expected only single space before numeric literal. Found ' '. [layout.spacing]
2731::warning title=SQLFluff,file=stdin,line=1,col=11,endLine=1,endColumn=13::LT01: Expected only single space before 'FROM' keyword. Found ' '. [layout.spacing]
2732::warning title=SQLFluff,file=stdin,line=1,col=17,endLine=1,endColumn=20::LT01: Expected only single space before naked identifier. Found ' '. [layout.spacing]
2733::endgroup::";
2734
2735 #[test]
2736 fn test_github_annotation_maps_line_and_column() {
2737 let config = default_config();
2738 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2739
2740 let output = ToolOutput {
2741 stdout: SQLFLUFF_ANNOTATIONS.to_string(),
2742 stderr: String::new(),
2743 exit_code: 1,
2744 success: false,
2745 };
2746
2747 let diags = processor.parse_tool_output(&output, "sqlfluff:lint", 3);
2748 assert_eq!(diags.len(), 3, "the group markers carry no finding: {diags:?}");
2749 assert_eq!(diags.iter().map(|d| d.file_line).collect::<Vec<_>>(), vec![4, 4, 4]);
2750 assert_eq!(
2751 diags.iter().map(|d| d.column).collect::<Vec<_>>(),
2752 vec![Some(7), Some(11), Some(17)]
2753 );
2754 assert!(diags[0].message.starts_with("LT01: Expected only single space"));
2755 assert!(diags.iter().all(|d| matches!(d.severity, DiagnosticSeverity::Warning)));
2756 }
2757
2758 #[test]
2759 fn test_github_annotation_severity_per_level() {
2760 let config = default_config();
2761 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2762
2763 let output = ToolOutput {
2764 stdout: "::error line=1::broken\n::warning line=2::suspicious\n::notice line=3::detail".to_string(),
2765 stderr: String::new(),
2766 exit_code: 1,
2767 success: false,
2768 };
2769
2770 let diags = processor.parse_tool_output(&output, "tool", 10);
2771 assert_eq!(diags.len(), 3);
2772 assert!(matches!(diags[0].severity, DiagnosticSeverity::Error));
2773 assert!(matches!(diags[1].severity, DiagnosticSeverity::Warning));
2774 assert!(matches!(diags[2].severity, DiagnosticSeverity::Info));
2775 assert_eq!(diags[2].file_line, 13);
2776 }
2777
2778 #[test]
2779 fn test_github_annotation_without_line_anchors_at_fence() {
2780 let config = default_config();
2781 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2782
2783 let output = ToolOutput {
2784 stdout: "::error::something the tool could not place".to_string(),
2785 stderr: String::new(),
2786 exit_code: 1,
2787 success: false,
2788 };
2789
2790 let diags = processor.parse_tool_output(&output, "tool", 12);
2791 assert_eq!(diags.len(), 1);
2792 assert_eq!(diags[0].file_line, 12);
2793 assert_eq!(diags[0].column, None);
2794 assert_eq!(diags[0].message, "something the tool could not place");
2795 }
2796
2797 #[test]
2798 fn test_github_annotation_decodes_escaped_message() {
2799 let config = default_config();
2800 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2801
2802 let output = ToolOutput {
2805 stdout: "::error line=1,col=2::100%25 of rows%3A a%2C b%0Aand more".to_string(),
2806 stderr: String::new(),
2807 exit_code: 1,
2808 success: false,
2809 };
2810
2811 let diags = processor.parse_tool_output(&output, "tool", 5);
2812 assert_eq!(diags.len(), 1);
2813 assert_eq!(diags[0].message, "100% of rows: a, b and more");
2814 assert_eq!(diags[0].file_line, 6);
2815 assert_eq!(diags[0].column, Some(2));
2816 }
2817
2818 #[test]
2819 fn test_decode_workflow_command_is_a_single_left_to_right_pass() {
2820 assert_eq!(decode_workflow_command("%250A"), "%0A");
2823 assert_eq!(decode_workflow_command("no escapes"), "no escapes");
2824 assert_eq!(decode_workflow_command("50% off"), "50% off");
2825 assert_eq!(decode_workflow_command("trailing %"), "trailing %");
2826 assert_eq!(decode_workflow_command("%0d%0a"), " ");
2827 assert_eq!(decode_workflow_command("%3A%2C"), ":,");
2828 }
2829
2830 #[test]
2831 fn test_position_in_message_anchors_a_prose_position() {
2832 let config = default_config();
2833 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2834
2835 let output = ToolOutput {
2838 stdout: String::new(),
2839 stderr: "jq: error (at <stdin>:0): syntax error, unexpected '}' at line 3, column 8".to_string(),
2840 exit_code: 2,
2841 success: false,
2842 };
2843
2844 let diags = processor.parse_tool_output(&output, "jq", 20);
2845 assert_eq!(diags.len(), 1);
2846 assert_eq!(diags[0].file_line, 23);
2847 assert_eq!(diags[0].column, Some(8));
2848 }
2849
2850 #[test]
2851 fn test_position_in_message_takes_the_last_one_named() {
2852 let config = default_config();
2853 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2854
2855 let output = ToolOutput {
2856 stdout: "unterminated string started at line 2, column 1 at line 6, column 4".to_string(),
2857 stderr: String::new(),
2858 exit_code: 1,
2859 success: false,
2860 };
2861
2862 let diags = processor.parse_tool_output(&output, "tool", 0);
2863 assert_eq!(diags.len(), 1);
2864 assert_eq!(diags[0].file_line, 6, "the sentence ends on the position it is about");
2865 assert_eq!(diags[0].column, Some(4));
2866 }
2867
2868 #[test]
2869 fn test_message_naming_no_position_stays_on_the_fence() {
2870 let config = default_config();
2871 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2872
2873 let output = ToolOutput {
2874 stdout: "could not parse the input".to_string(),
2875 stderr: String::new(),
2876 exit_code: 1,
2877 success: false,
2878 };
2879
2880 let diags = processor.parse_tool_output(&output, "tool", 7);
2881 assert_eq!(diags.len(), 1);
2882 assert_eq!(diags[0].file_line, 7);
2883 assert_eq!(diags[0].column, None);
2884 }
2885
2886 #[test]
2887 fn test_fallback_combines_stdout_and_stderr() {
2888 let config = default_config();
2889 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2890
2891 let output = ToolOutput {
2893 stdout: "problem found in input".to_string(),
2894 stderr: "1 file failed".to_string(),
2895 exit_code: 1,
2896 success: false,
2897 };
2898
2899 let diags = processor.parse_tool_output(&output, "tool", 1);
2900 assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
2901 assert_eq!(diags[0].message, "problem found in input");
2902 assert_eq!(diags[1].message, "1 file failed");
2903 }
2904
2905 #[test]
2906 fn test_error_line_without_position_info() {
2907 let config = default_config();
2908 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2909
2910 let output = ToolOutput {
2912 stdout: "Error: something went wrong\nsome unrelated line".to_string(),
2913 stderr: String::new(),
2914 exit_code: 1,
2915 success: false,
2916 };
2917
2918 let diags = processor.parse_tool_output(&output, "tool", 5);
2919 assert!(!diags.is_empty());
2922 assert_eq!(diags[0].message, "something went wrong");
2923 assert_eq!(diags[0].file_line, 5); }
2925
2926 #[test]
2927 fn test_warning_line_with_position() {
2928 let config = default_config();
2929 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2930
2931 let output = ToolOutput {
2932 stdout: "Warning: deprecated syntax\n at line 3 column 5".to_string(),
2933 stderr: String::new(),
2934 exit_code: 1,
2935 success: false,
2936 };
2937
2938 let diags = processor.parse_tool_output(&output, "tool", 10);
2939 assert_eq!(diags.len(), 1);
2940 assert_eq!(diags[0].message, "deprecated syntax");
2941 assert_eq!(diags[0].file_line, 13); assert_eq!(diags[0].column, Some(5));
2943 assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
2944 }
2945
2946 #[test]
2947 fn test_strip_ansi_codes() {
2948 assert_eq!(strip_ansi_codes("hello"), "hello");
2949 assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
2950 assert_eq!(
2951 strip_ansi_codes("\x1b[1;31m Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
2952 " Error: msg"
2953 );
2954 assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
2955 assert_eq!(strip_ansi_codes(""), "");
2956 assert_eq!(
2957 strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
2958 "at line 2 column 1"
2959 );
2960 }
2961
2962 #[test]
2963 fn test_parse_at_line_column() {
2964 assert_eq!(
2965 CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
2966 Some((2, 1))
2967 );
2968 assert_eq!(
2969 CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
2970 Some((10, 15))
2971 );
2972 assert_eq!(
2973 CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
2974 Some((5, 3))
2975 );
2976 assert_eq!(
2977 CodeBlockToolProcessor::parse_at_line_column("not a position line"),
2978 None
2979 );
2980 assert_eq!(
2981 CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
2982 None
2983 );
2984 }
2985
2986 #[test]
2987 fn test_parse_error_line() {
2988 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
2989 assert_eq!(msg, "invalid key");
2990 assert!(matches!(sev, DiagnosticSeverity::Error));
2991
2992 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
2993 assert_eq!(msg, "deprecated");
2994 assert!(matches!(sev, DiagnosticSeverity::Warning));
2995
2996 assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
2998 assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
2999
3000 assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
3002 assert!(CodeBlockToolProcessor::parse_error_line("Error: ").is_none());
3003
3004 assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
3006 }
3007
3008 #[test]
3009 fn test_consecutive_error_lines_without_position() {
3010 let config = default_config();
3011 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3012
3013 let output = ToolOutput {
3016 stdout: "Error: first problem\nError: second problem\n at line 3 column 1".to_string(),
3017 stderr: String::new(),
3018 exit_code: 1,
3019 success: false,
3020 };
3021
3022 let diags = processor.parse_tool_output(&output, "tool", 5);
3023 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
3024 assert_eq!(diags[0].message, "first problem");
3026 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
3028 assert_eq!(diags[1].message, "second problem");
3030 assert_eq!(diags[1].file_line, 8); assert_eq!(diags[1].column, Some(1));
3032 }
3033
3034 #[test]
3035 fn test_error_line_at_end_of_output() {
3036 let config = default_config();
3037 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3038
3039 let output = ToolOutput {
3041 stdout: "Error: trailing error".to_string(),
3042 stderr: String::new(),
3043 exit_code: 1,
3044 success: false,
3045 };
3046
3047 let diags = processor.parse_tool_output(&output, "tool", 5);
3048 assert_eq!(diags.len(), 1);
3049 assert_eq!(diags[0].message, "trailing error");
3050 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
3052 }
3053
3054 #[test]
3055 fn test_blank_lines_between_error_and_position() {
3056 let config = default_config();
3057 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3058
3059 let output = ToolOutput {
3061 stdout: "Error: spaced out\n\n\n at line 4 column 2".to_string(),
3062 stderr: String::new(),
3063 exit_code: 1,
3064 success: false,
3065 };
3066
3067 let diags = processor.parse_tool_output(&output, "tool", 10);
3068 assert_eq!(diags.len(), 1);
3069 assert_eq!(diags[0].message, "spaced out");
3070 assert_eq!(diags[0].file_line, 14); assert_eq!(diags[0].column, Some(2));
3072 }
3073
3074 #[test]
3075 fn test_mixed_structured_and_error_line_parsers() {
3076 let config = default_config();
3077 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3078
3079 let output = ToolOutput {
3081 stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n at line 3 column 1".to_string(),
3082 stderr: String::new(),
3083 exit_code: 1,
3084 success: false,
3085 };
3086
3087 let diags = processor.parse_tool_output(&output, "tool", 5);
3088 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
3089 assert_eq!(diags[0].message, "E501 Line too long");
3091 assert_eq!(diags[0].file_line, 6); assert_eq!(diags[1].message, "invalid syntax");
3094 assert_eq!(diags[1].file_line, 8); }
3096
3097 #[test]
3098 fn test_at_line_without_preceding_error() {
3099 let config = default_config();
3100 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3101
3102 let output = ToolOutput {
3104 stdout: "at line 2 column 1\nsome other text".to_string(),
3105 stderr: String::new(),
3106 exit_code: 1,
3107 success: false,
3108 };
3109
3110 let diags = processor.parse_tool_output(&output, "tool", 5);
3111 assert_eq!(diags.len(), 2);
3114 assert_eq!(diags[0].message, "at line 2 column 1");
3115 assert_eq!(diags[1].message, "some other text");
3116 }
3117
3118 #[test]
3126 fn test_format_empty_output_does_not_erase_content() {
3127 use super::super::config::LanguageToolConfig;
3128
3129 let mut config = default_config();
3130 config.languages.insert(
3131 "toml".to_string(),
3132 LanguageToolConfig {
3133 format: vec!["empty-formatter".to_string()],
3134 ..Default::default()
3135 },
3136 );
3137 config.tools.insert(
3139 "empty-formatter".to_string(),
3140 super::super::config::ToolDefinition {
3141 command: vec!["true".to_string()],
3142 stdin: true,
3143 stdout: true,
3144 lint_args: vec![],
3145 format_args: vec![],
3146 },
3147 );
3148
3149 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3150
3151 let content = "```toml\nkey = \"value\"\n```\n";
3152 let result = processor.format(content);
3153
3154 assert!(result.is_ok(), "Format should not error");
3155 let output = result.unwrap();
3156
3157 assert!(
3159 output.content.contains("key = \"value\""),
3160 "Empty formatter output should not erase content. Got: {:?}",
3161 output.content
3162 );
3163 }
3164
3165 #[test]
3167 fn test_format_identity_formatter_preserves_content() {
3168 use super::super::config::LanguageToolConfig;
3169
3170 let mut config = default_config();
3171 config.languages.insert(
3172 "toml".to_string(),
3173 LanguageToolConfig {
3174 format: vec!["cat-formatter".to_string()],
3175 ..Default::default()
3176 },
3177 );
3178 config.tools.insert(
3179 "cat-formatter".to_string(),
3180 super::super::config::ToolDefinition {
3181 command: vec!["cat".to_string()],
3182 stdin: true,
3183 stdout: true,
3184 lint_args: vec![],
3185 format_args: vec![],
3186 },
3187 );
3188
3189 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3190
3191 let content = "```toml\nkey = \"value\"\n```\n";
3192 let result = processor.format(content);
3193
3194 assert!(result.is_ok(), "Format should not error");
3195 let output = result.unwrap();
3196 assert_eq!(
3197 output.content, content,
3198 "Identity formatter should preserve content exactly"
3199 );
3200 }
3201
3202 #[test]
3205 fn test_resolve_tool_context_aware_tombi() {
3206 let config = default_config();
3207 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3208
3209 let format_def = processor
3211 .registry
3212 .resolve("tombi", ToolSlot::Format)
3213 .expect("Should resolve tombi in format context");
3214 assert!(
3215 format_def.command.iter().any(|arg| arg == "format"),
3216 "Bare 'tombi' in format context should resolve to 'tombi format', got: {:?}",
3217 format_def.command
3218 );
3219
3220 let lint_def = processor
3222 .registry
3223 .resolve("tombi", ToolSlot::Lint)
3224 .expect("Should resolve tombi in lint context");
3225 assert!(
3226 lint_def.command.iter().any(|arg| arg == "lint"),
3227 "Bare 'tombi' in lint context should resolve to 'tombi lint', got: {:?}",
3228 lint_def.command
3229 );
3230
3231 let explicit_def = processor
3233 .registry
3234 .resolve("tombi:lint", ToolSlot::Format)
3235 .expect("Should resolve explicit tombi:lint even in format context");
3236 assert!(
3237 explicit_def.command.iter().any(|arg| arg == "lint"),
3238 "Explicit 'tombi:lint' should always use lint, got: {:?}",
3239 explicit_def.command
3240 );
3241 }
3242
3243 #[test]
3245 fn test_resolve_tool_context_aware_ruff() {
3246 let config = default_config();
3247 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3248
3249 let lint_def = processor
3251 .registry
3252 .resolve("ruff", ToolSlot::Lint)
3253 .expect("Should resolve ruff in lint context");
3254 assert!(
3255 lint_def.command.iter().any(|arg| arg == "check"),
3256 "Bare 'ruff' in lint context should resolve to 'ruff check', got: {:?}",
3257 lint_def.command
3258 );
3259
3260 let format_def = processor
3262 .registry
3263 .resolve("ruff", ToolSlot::Format)
3264 .expect("Should resolve ruff in format context");
3265 assert!(
3266 format_def.command.iter().any(|arg| arg == "format"),
3267 "Bare 'ruff' in format context should resolve to 'ruff format', got: {:?}",
3268 format_def.command
3269 );
3270 }
3271
3272 #[test]
3274 fn test_resolve_tool_bare_name_fallback() {
3275 let config = default_config();
3276 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3277
3278 let def = processor
3280 .registry
3281 .resolve("shellcheck", ToolSlot::Lint)
3282 .expect("Should resolve shellcheck via fallback");
3283 assert!(
3284 def.command.iter().any(|arg| arg == "shellcheck"),
3285 "shellcheck should resolve to itself, got: {:?}",
3286 def.command
3287 );
3288 }
3289
3290 #[test]
3292 fn test_resolve_tool_context_aware_sqlfluff() {
3293 let config = default_config();
3294 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3295
3296 let format_def = processor
3298 .registry
3299 .resolve("sqlfluff", ToolSlot::Format)
3300 .expect("Should resolve sqlfluff in format context");
3301 assert!(
3302 format_def.command.iter().any(|arg| arg == "fix"),
3303 "Bare 'sqlfluff' in format context should resolve to 'sqlfluff fix', got: {:?}",
3304 format_def.command
3305 );
3306 }
3307
3308 #[test]
3310 fn test_resolve_tool_context_aware_djlint() {
3311 let config = default_config();
3312 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3313
3314 let format_def = processor
3316 .registry
3317 .resolve("djlint", ToolSlot::Format)
3318 .expect("Should resolve djlint in format context");
3319 assert!(
3320 format_def.command.iter().any(|arg| arg.contains("reformat")),
3321 "Bare 'djlint' in format context should resolve to djlint reformat, got: {:?}",
3322 format_def.command
3323 );
3324 }
3325
3326 #[test]
3328 fn test_resolve_tool_user_defined_with_context_variant() {
3329 use super::super::config::ToolDefinition;
3330
3331 let mut config = default_config();
3332 config.tools.insert(
3333 "mytool".to_string(),
3334 ToolDefinition {
3335 command: vec!["mytool".to_string(), "--lint".to_string()],
3336 ..Default::default()
3337 },
3338 );
3339 config.tools.insert(
3340 "mytool:format".to_string(),
3341 ToolDefinition {
3342 command: vec!["mytool".to_string(), "--format".to_string()],
3343 ..Default::default()
3344 },
3345 );
3346
3347 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3348
3349 let def = processor
3351 .registry
3352 .resolve("mytool", ToolSlot::Format)
3353 .expect("Should resolve user tool in format context");
3354 assert!(
3355 def.command.iter().any(|arg| arg == "--format"),
3356 "User 'mytool' in format context should resolve to mytool:format, got: {:?}",
3357 def.command
3358 );
3359
3360 let def = processor
3362 .registry
3363 .resolve("mytool", ToolSlot::Lint)
3364 .expect("Should resolve user tool in lint context via fallback");
3365 assert!(
3366 def.command.iter().any(|arg| arg == "--lint"),
3367 "User 'mytool' in lint context should fall back to bare name, got: {:?}",
3368 def.command
3369 );
3370 }
3371
3372 #[test]
3374 fn test_resolve_tool_nonexistent_returns_none() {
3375 let config = default_config();
3376 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3377
3378 assert!(
3379 processor
3380 .registry
3381 .resolve("nonexistent-tool-xyz", ToolSlot::Lint)
3382 .is_none(),
3383 "Nonexistent tool should return None in lint context"
3384 );
3385 assert!(
3386 processor
3387 .registry
3388 .resolve("nonexistent-tool-xyz", ToolSlot::Format)
3389 .is_none(),
3390 "Nonexistent tool should return None in format context"
3391 );
3392 }
3393
3394 #[test]
3395 fn test_strip_ansi_codes_edge_cases() {
3396 assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
3398 assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
3400 assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
3402 assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
3404 assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
3405 }
3406
3407 #[test]
3408 fn merge_conflict_prevents_external_formatting() {
3409 use super::super::config::{LanguageToolConfig, ToolDefinition};
3410 let mut config = default_config();
3411 config.on_missing_tool_binary = OnMissing::Fail;
3412 config.languages.insert(
3413 "testlang".to_string(),
3414 LanguageToolConfig {
3415 format: vec!["unavailable".to_string()],
3416 ..Default::default()
3417 },
3418 );
3419 config.tools.insert(
3420 "unavailable".to_string(),
3421 ToolDefinition {
3422 command: vec!["rumdl-858-tool-that-must-not-run".to_string()],
3423 ..Default::default()
3424 },
3425 );
3426 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3427 let content = "<<<<<<< HEAD\r\n\n```testlang\ntext\n```\n";
3428 let output = processor.format(content).unwrap();
3429 assert_eq!(output.content, content);
3430 assert!(!output.had_errors);
3431 assert!(output.failures.is_empty());
3432
3433 let mut document_config = crate::config::Config::default();
3436 document_config.global.disable.push("MD092".into());
3437 let output = processor.format_with_config(content, &document_config, None).unwrap();
3438 assert!(output.had_errors);
3439 assert!(!output.failures.is_empty());
3440 let suppressed = format!("<!-- rumdl-disable MD092 -->\n{content}");
3441 assert!(processor.format(&suppressed).unwrap().had_errors);
3442 }
3443
3444 #[cfg(unix)]
3449 #[test]
3450 fn test_lint_yaml_block_no_false_new_line_at_eof() {
3451 use super::super::config::{LanguageToolConfig, ToolDefinition};
3452
3453 let mut config = default_config();
3454 config.normalize_language = NormalizeLanguage::Exact;
3455 config.languages.insert(
3456 "yaml".to_string(),
3457 LanguageToolConfig {
3458 lint: vec!["eof-newline-linter".to_string()],
3459 ..Default::default()
3460 },
3461 );
3462 config.tools.insert(
3466 "eof-newline-linter".to_string(),
3467 ToolDefinition {
3468 command: vec![
3469 "sh".to_string(),
3470 "-c".to_string(),
3471 "if [ -n \"$(tail -c1)\" ]; then echo '1:1: no newline at end of file'; fi".to_string(),
3472 ],
3473 ..Default::default()
3474 },
3475 );
3476
3477 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3478
3479 let content = "```yaml\nfoo: bar\n```\n";
3480 let diagnostics = processor.lint(content).expect("lint should succeed");
3481
3482 assert!(
3483 diagnostics.is_empty(),
3484 "EOF-newline linter should not fire on an extracted code block, got: {diagnostics:?}"
3485 );
3486 }
3487
3488 #[cfg(unix)]
3493 #[test]
3494 fn test_format_feeds_newline_terminated_input() {
3495 use super::super::config::{LanguageToolConfig, ToolDefinition};
3496
3497 let mut config = default_config();
3498 config.normalize_language = NormalizeLanguage::Exact;
3499 config.languages.insert(
3500 "yaml".to_string(),
3501 LanguageToolConfig {
3502 format: vec!["newline-probe".to_string()],
3503 ..Default::default()
3504 },
3505 );
3506 config.tools.insert(
3507 "newline-probe".to_string(),
3508 ToolDefinition {
3509 command: vec![
3510 "sh".to_string(),
3511 "-c".to_string(),
3512 "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi".to_string(),
3513 ],
3514 ..Default::default()
3515 },
3516 );
3517
3518 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3519
3520 let content = "```yaml\nfoo: bar\n```\n";
3521 let output = processor.format(content).expect("format should succeed");
3522
3523 assert!(
3524 output.content.contains("HAD_NEWLINE"),
3525 "Formatter should receive newline-terminated stdin, got: {:?}",
3526 output.content
3527 );
3528 }
3529
3530 #[test]
3531 fn test_ensure_trailing_newline() {
3532 assert_eq!(ensure_trailing_newline("foo: bar"), "foo: bar\n");
3534 assert_eq!(ensure_trailing_newline("a: 1\nb: 2"), "a: 1\nb: 2\n");
3536 assert_eq!(ensure_trailing_newline("foo: bar\n"), "foo: bar\n");
3538 assert_eq!(ensure_trailing_newline("foo: bar\n\n"), "foo: bar\n\n");
3540 assert_eq!(ensure_trailing_newline(""), "");
3542 }
3543}