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