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