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