1use super::config::{CodeBlockToolsConfig, FormatMode, 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, format_mode) = match lang_config {
1058 Some(lc) if !lc.format.is_empty() => (&lc.format, lc.format_mode),
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();
1103 let mut tool_ran = false;
1104 for tool_id in format_tools {
1105 if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
1107 continue;
1108 }
1109
1110 let Some(tool_def) = self.registry.resolve(tool_id, ToolSlot::Format) else {
1111 self.warn_unknown_tool(tool_id, &canonical_lang);
1112 continue;
1113 };
1114
1115 if self.registry.fills_format_slot(tool_id) == Some(false) {
1123 log::warn!(
1124 "Tool '{tool_id}' is a linter and cannot format '{canonical_lang}' code blocks; move it to the lint slot"
1125 );
1126 continue;
1127 }
1128
1129 let tool_name = tool_def.command.first().map_or("", String::as_str);
1131 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
1132 match self.config.on_missing_tool_binary {
1133 OnMissing::Ignore | OnMissing::Warn => {
1135 log::debug!("Tool binary '{tool_name}' not found, skipping");
1136 continue;
1137 }
1138 OnMissing::Fail => {
1139 error_messages.push(format!(
1140 "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
1141 block.start_line + 1
1142 ));
1143 failures.push(CodeBlockDiagnostic {
1144 file_line: block.start_line + 1,
1145 column: None,
1146 message: format!("Tool binary '{tool_name}' not found in PATH"),
1147 severity: DiagnosticSeverity::Error,
1148 tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1149 code_block_start: block.start_line + 1,
1150 });
1151 continue;
1152 }
1153 OnMissing::FailFast => {
1154 return Err(ProcessorError::ToolBinaryNotFound {
1155 tool: tool_name.to_string(),
1156 language: canonical_lang.clone(),
1157 line: block.start_line + 1,
1158 });
1159 }
1160 }
1161 }
1162
1163 let tool_input = ensure_trailing_newline(&formatted);
1164 match self.executor.format(tool_def, &tool_input, Some(self.config.timeout)) {
1165 Ok(output) => {
1166 formatted = output;
1168 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1169 formatted.push('\n');
1170 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1171 formatted.pop();
1172 }
1173 tool_ran = true;
1174 if format_mode == FormatMode::Fallback {
1175 break;
1176 }
1177 }
1178 Err(e) => {
1179 let on_error = self.get_on_error(&canonical_lang);
1180 match on_error {
1181 OnError::Fail => {
1182 return Err(ProcessorError::ToolErrorAt {
1183 error: e,
1184 line: block.start_line + 1,
1185 language: canonical_lang,
1186 });
1187 }
1188 OnError::Warn => {
1189 error_messages.push(format!("line {} ({}): {e}", block.start_line + 1, canonical_lang));
1190 }
1191 OnError::Skip => {}
1192 }
1193 }
1194 }
1195 }
1196
1197 if tool_ran && formatted != code_content {
1199 let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
1200 if reindented != code_content_raw {
1201 result.replace_range(block.content_start..block.content_end, &reindented);
1202 }
1203 }
1204 }
1205
1206 Ok(FormatOutput {
1207 content: result,
1208 had_errors: !error_messages.is_empty(),
1209 error_messages,
1210 failures,
1211 })
1212 }
1213
1214 fn format_check_diagnostics(
1226 &self,
1227 output: &str,
1228 code_content: &str,
1229 tool_id: &str,
1230 code_block_start_line: usize,
1231 ) -> Vec<CodeBlockDiagnostic> {
1232 let mut formatted = output.to_string();
1233 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1234 formatted.push('\n');
1235 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1236 formatted.pop();
1237 }
1238
1239 if formatted == code_content {
1240 return Vec::new();
1241 }
1242
1243 vec![CodeBlockDiagnostic {
1244 file_line: code_block_start_line,
1245 column: None,
1246 message: "Code block is not formatted".to_string(),
1247 severity: DiagnosticSeverity::Warning,
1248 tool: tool_id.to_string(),
1249 code_block_start: code_block_start_line,
1250 }]
1251 }
1252
1253 fn parse_tool_output(
1258 &self,
1259 output: &ToolOutput,
1260 tool_id: &str,
1261 code_block_start_line: usize,
1262 ) -> Vec<CodeBlockDiagnostic> {
1263 let mut diagnostics = Vec::new();
1264 let mut shellcheck_line: Option<usize> = None;
1265
1266 let stdout_clean = strip_ansi_codes(&output.stdout);
1268 let stderr_clean = strip_ansi_codes(&output.stderr);
1269 let combined = format!("{stdout_clean}\n{stderr_clean}");
1270
1271 let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
1273
1274 for line in combined.lines() {
1275 let line = line.trim();
1276 if line.is_empty() {
1277 continue;
1278 }
1279
1280 if let Some((ref msg, severity)) = pending_error {
1282 if let Some((line_num, col)) = Self::parse_at_line_column(line) {
1283 diagnostics.push(CodeBlockDiagnostic {
1284 file_line: code_block_start_line + line_num,
1285 column: Some(col),
1286 message: msg.clone(),
1287 severity,
1288 tool: tool_id.to_string(),
1289 code_block_start: code_block_start_line,
1290 });
1291 pending_error = None;
1292 continue;
1293 }
1294 diagnostics.push(Self::unpositioned_diagnostic(
1296 msg,
1297 severity,
1298 tool_id,
1299 code_block_start_line,
1300 ));
1301 pending_error = None;
1302 }
1304
1305 if let Some(line_num) = self.parse_shellcheck_header(line) {
1306 shellcheck_line = Some(line_num);
1307 continue;
1308 }
1309
1310 if let Some(line_num) = shellcheck_line
1311 && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
1312 {
1313 diagnostics.push(diag);
1314 continue;
1315 }
1316
1317 if let Some(diag) = Self::parse_github_annotation(line, tool_id, code_block_start_line) {
1319 diagnostics.push(diag);
1320 continue;
1321 }
1322
1323 if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
1325 diagnostics.push(diag);
1326 continue;
1327 }
1328
1329 if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
1331 diagnostics.push(diag);
1332 continue;
1333 }
1334
1335 if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
1337 diagnostics.push(diag);
1338 continue;
1339 }
1340
1341 if let Some(error_info) = Self::parse_error_line(line) {
1343 pending_error = Some(error_info);
1344 }
1345 }
1346
1347 if let Some((msg, severity)) = pending_error {
1349 diagnostics.push(Self::unpositioned_diagnostic(
1350 &msg,
1351 severity,
1352 tool_id,
1353 code_block_start_line,
1354 ));
1355 }
1356
1357 if diagnostics.is_empty() && !output.success {
1359 let lines: Vec<&str> = combined.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
1360
1361 if lines.is_empty() {
1362 let exit_code = output.exit_code;
1363 diagnostics.push(CodeBlockDiagnostic {
1364 file_line: code_block_start_line,
1365 column: None,
1366 message: format!("Tool exited with code {exit_code}"),
1367 severity: DiagnosticSeverity::Error,
1368 tool: tool_id.to_string(),
1369 code_block_start: code_block_start_line,
1370 });
1371 } else {
1372 for line_text in lines {
1373 diagnostics.push(Self::unpositioned_diagnostic(
1374 line_text,
1375 DiagnosticSeverity::Error,
1376 tool_id,
1377 code_block_start_line,
1378 ));
1379 }
1380 }
1381 }
1382
1383 diagnostics
1384 }
1385
1386 fn unpositioned_diagnostic(
1392 message: &str,
1393 severity: DiagnosticSeverity,
1394 tool_id: &str,
1395 code_block_start_line: usize,
1396 ) -> CodeBlockDiagnostic {
1397 let (line_offset, column) = Self::parse_position_in_message(message).unwrap_or((0, None));
1398 CodeBlockDiagnostic {
1399 file_line: code_block_start_line + line_offset,
1400 column,
1401 message: message.to_string(),
1402 severity,
1403 tool: tool_id.to_string(),
1404 code_block_start: code_block_start_line,
1405 }
1406 }
1407
1408 fn parse_position_in_message(message: &str) -> Option<(usize, Option<usize>)> {
1413 let lower = message.to_ascii_lowercase();
1416 let after_marker = &lower[lower.rfind("at line ")? + "at line ".len()..];
1417
1418 let leading_number = |text: &str| -> Option<usize> {
1419 let end = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
1420 text[..end].parse::<usize>().ok()
1421 };
1422
1423 let line_num = leading_number(after_marker)?;
1424 let after_line = after_marker.trim_start_matches(|c: char| c.is_ascii_digit());
1425 let after_separator = after_line
1426 .trim_start()
1427 .strip_prefix(',')
1428 .unwrap_or(after_line)
1429 .trim_start();
1430 let column = after_separator.strip_prefix("column ").and_then(leading_number);
1431
1432 Some((line_num, column))
1433 }
1434
1435 fn parse_standard_format(
1437 &self,
1438 line: &str,
1439 tool_id: &str,
1440 code_block_start_line: usize,
1441 ) -> Option<CodeBlockDiagnostic> {
1442 let mut parts = line.rsplitn(4, ':');
1444 let message = parts.next()?.trim().to_string();
1445 let part1 = parts.next()?.trim().to_string();
1446 let part2 = parts.next()?.trim().to_string();
1447 let part3 = parts.next().map(|s| s.trim().to_string());
1448
1449 let (line_part, col_part) = if part3.is_some() {
1450 (part2, Some(part1))
1451 } else {
1452 (part1, None)
1453 };
1454
1455 if let Ok(line_num) = line_part.parse::<usize>() {
1456 let column = col_part.and_then(|s| s.parse::<usize>().ok());
1457 let message = Self::strip_fixable_markers(&message);
1458 if !message.is_empty() {
1459 let severity = self.infer_severity(&message);
1460 return Some(CodeBlockDiagnostic {
1461 file_line: code_block_start_line + line_num,
1462 column,
1463 message,
1464 severity,
1465 tool: tool_id.to_string(),
1466 code_block_start: code_block_start_line,
1467 });
1468 }
1469 }
1470 None
1471 }
1472
1473 fn parse_github_annotation(line: &str, tool_id: &str, code_block_start_line: usize) -> Option<CodeBlockDiagnostic> {
1481 let body = line.strip_prefix("::")?;
1482 let (head, message) = body.split_once("::")?;
1483
1484 let (level, properties) = head.split_once(' ').unwrap_or((head, ""));
1485 let severity = match level {
1486 "error" => DiagnosticSeverity::Error,
1487 "warning" => DiagnosticSeverity::Warning,
1488 "notice" => DiagnosticSeverity::Info,
1489 _ => return None,
1490 };
1491
1492 let property = |name: &str| -> Option<usize> {
1493 properties
1494 .split(',')
1495 .filter_map(|pair| pair.split_once('='))
1496 .find(|(key, _)| *key == name)
1497 .and_then(|(_, value)| value.parse::<usize>().ok())
1498 };
1499
1500 let message = Self::strip_fixable_markers(&decode_workflow_command(message));
1501 if message.is_empty() {
1502 return None;
1503 }
1504
1505 Some(CodeBlockDiagnostic {
1506 file_line: code_block_start_line + property("line").unwrap_or(0),
1507 column: property("col"),
1508 message,
1509 severity,
1510 tool: tool_id.to_string(),
1511 code_block_start: code_block_start_line,
1512 })
1513 }
1514
1515 fn parse_eslint_format(
1517 &self,
1518 line: &str,
1519 tool_id: &str,
1520 code_block_start_line: usize,
1521 ) -> Option<CodeBlockDiagnostic> {
1522 let parts: Vec<&str> = line.splitn(3, ' ').collect();
1524 if parts.len() >= 2 {
1525 let loc_parts: Vec<&str> = parts[0].split(':').collect();
1526 if loc_parts.len() == 2
1527 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
1528 {
1529 let (sev_part, msg_part) = if parts.len() >= 3 {
1530 (parts[1], parts[2])
1531 } else {
1532 (parts[1], "")
1533 };
1534 let message = if msg_part.is_empty() {
1535 sev_part.to_string()
1536 } else {
1537 msg_part.to_string()
1538 };
1539 let message = Self::strip_fixable_markers(&message);
1540 let severity = match sev_part.to_lowercase().as_str() {
1541 "error" => DiagnosticSeverity::Error,
1542 "warning" | "warn" => DiagnosticSeverity::Warning,
1543 "info" => DiagnosticSeverity::Info,
1544 _ => self.infer_severity(&message),
1545 };
1546 return Some(CodeBlockDiagnostic {
1547 file_line: code_block_start_line + line_num,
1548 column: Some(col),
1549 message,
1550 severity,
1551 tool: tool_id.to_string(),
1552 code_block_start: code_block_start_line,
1553 });
1554 }
1555 }
1556 None
1557 }
1558
1559 fn parse_shellcheck_format(
1561 &self,
1562 line: &str,
1563 tool_id: &str,
1564 code_block_start_line: usize,
1565 ) -> Option<CodeBlockDiagnostic> {
1566 if line.starts_with("In ")
1568 && line.contains(" line ")
1569 && let Some(line_start) = line.find(" line ")
1570 {
1571 let after_line = &line[line_start + 6..];
1572 if let Some(colon_pos) = after_line.find(':')
1573 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1574 {
1575 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1576 if !message.is_empty() {
1577 let severity = self.infer_severity(&message);
1578 return Some(CodeBlockDiagnostic {
1579 file_line: code_block_start_line + line_num,
1580 column: None,
1581 message,
1582 severity,
1583 tool: tool_id.to_string(),
1584 code_block_start: code_block_start_line,
1585 });
1586 }
1587 }
1588 }
1589 None
1590 }
1591
1592 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1594 if line.starts_with("In ")
1595 && line.contains(" line ")
1596 && let Some(line_start) = line.find(" line ")
1597 {
1598 let after_line = &line[line_start + 6..];
1599 if let Some(colon_pos) = after_line.find(':') {
1600 return after_line[..colon_pos].trim().parse::<usize>().ok();
1601 }
1602 }
1603 None
1604 }
1605
1606 fn parse_shellcheck_message(
1608 &self,
1609 line: &str,
1610 tool_id: &str,
1611 code_block_start_line: usize,
1612 line_num: usize,
1613 ) -> Option<CodeBlockDiagnostic> {
1614 let sc_pos = line.find("SC")?;
1615 let after_sc = &line[sc_pos + 2..];
1616 let code_len = after_sc.chars().take_while(char::is_ascii_digit).count();
1617 if code_len == 0 {
1618 return None;
1619 }
1620 let after_code = &after_sc[code_len..];
1621 let sev_start = after_code.find('(')? + 1;
1622 let sev_end = after_code[sev_start..].find(')')? + sev_start;
1623 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1624 let message_start = after_code.find("):")? + 2;
1625 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1626 if message.is_empty() {
1627 return None;
1628 }
1629
1630 let severity = match sev.as_str() {
1631 "error" => DiagnosticSeverity::Error,
1632 "warning" | "warn" => DiagnosticSeverity::Warning,
1633 "info" | "style" => DiagnosticSeverity::Info,
1634 _ => self.infer_severity(&message),
1635 };
1636
1637 Some(CodeBlockDiagnostic {
1638 file_line: code_block_start_line + line_num,
1639 column: None,
1640 message,
1641 severity,
1642 tool: tool_id.to_string(),
1643 code_block_start: code_block_start_line,
1644 })
1645 }
1646
1647 fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1653 let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1654 (msg, DiagnosticSeverity::Error)
1655 } else if let Some(msg) = line.strip_prefix("Warning:") {
1656 (msg, DiagnosticSeverity::Warning)
1657 } else {
1658 return None;
1659 };
1660 let msg = msg.trim();
1661 if msg.is_empty() {
1662 return None;
1663 }
1664 Some((msg.to_string(), severity))
1665 }
1666
1667 fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1671 let lower = line.to_lowercase();
1672 let rest = lower.strip_prefix("at line ")?;
1673 let mut parts = rest.split_whitespace();
1674 let line_num: usize = parts.next()?.parse().ok()?;
1675 if parts.next()? != "column" {
1676 return None;
1677 }
1678 let col: usize = parts.next()?.parse().ok()?;
1679 Some((line_num, col))
1680 }
1681
1682 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1684 let lower = message.to_lowercase();
1685 if lower.contains("error")
1686 || lower.starts_with('e') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1687 || lower.starts_with('f') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1688 {
1689 DiagnosticSeverity::Error
1690 } else if lower.contains("warning")
1691 || lower.contains("warn")
1692 || lower.starts_with('w') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1693 {
1694 DiagnosticSeverity::Warning
1695 } else {
1696 DiagnosticSeverity::Info
1697 }
1698 }
1699
1700 fn strip_fixable_markers(message: &str) -> String {
1707 message
1708 .replace(" [*]", "")
1709 .replace("[*] ", "")
1710 .replace("[*]", "")
1711 .replace(" (fixable)", "")
1712 .replace("(fixable) ", "")
1713 .replace("(fixable)", "")
1714 .replace(" [fix available]", "")
1715 .replace("[fix available] ", "")
1716 .replace("[fix available]", "")
1717 .replace(" [autofix]", "")
1718 .replace("[autofix] ", "")
1719 .replace("[autofix]", "")
1720 .trim()
1721 .to_string()
1722 }
1723}
1724
1725struct FencedCodeBlockBuilder {
1727 start_line: usize,
1728 content_start: usize,
1729 language: String,
1730 info_string: String,
1731 fence_char: char,
1732 fence_length: usize,
1733 indent: usize,
1734 indent_prefix: String,
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739 use super::*;
1740
1741 fn default_config() -> CodeBlockToolsConfig {
1742 CodeBlockToolsConfig::default()
1743 }
1744
1745 #[test]
1746 fn test_extract_code_blocks() {
1747 let config = default_config();
1748 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1749
1750 let content = r#"# Example
1751
1752```python
1753def hello():
1754 print("Hello")
1755```
1756
1757Some text
1758
1759```rust
1760fn main() {}
1761```
1762"#;
1763
1764 let blocks = processor.extract_code_blocks(content);
1765
1766 assert_eq!(blocks.len(), 2);
1767
1768 assert_eq!(blocks[0].language, "python");
1769 assert_eq!(blocks[0].fence_char, '`');
1770 assert_eq!(blocks[0].fence_length, 3);
1771 assert_eq!(blocks[0].start_line, 2);
1772 assert_eq!(blocks[0].indent, 0);
1773 assert_eq!(blocks[0].indent_prefix, "");
1774
1775 assert_eq!(blocks[1].language, "rust");
1776 assert_eq!(blocks[1].fence_char, '`');
1777 assert_eq!(blocks[1].fence_length, 3);
1778 }
1779
1780 #[test]
1781 fn test_extract_code_blocks_with_info_string() {
1782 let config = default_config();
1783 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1784
1785 let content = "```python title=\"example.py\"\ncode\n```";
1786 let blocks = processor.extract_code_blocks(content);
1787
1788 assert_eq!(blocks.len(), 1);
1789 assert_eq!(blocks[0].language, "python");
1790 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1791 }
1792
1793 #[test]
1794 fn test_extract_code_blocks_tilde_fence() {
1795 let config = default_config();
1796 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1797
1798 let content = "~~~bash\necho hello\n~~~";
1799 let blocks = processor.extract_code_blocks(content);
1800
1801 assert_eq!(blocks.len(), 1);
1802 assert_eq!(blocks[0].language, "bash");
1803 assert_eq!(blocks[0].fence_char, '~');
1804 assert_eq!(blocks[0].fence_length, 3);
1805 assert_eq!(blocks[0].indent_prefix, "");
1806 }
1807
1808 #[test]
1809 fn test_extract_code_blocks_with_indent_prefix() {
1810 let config = default_config();
1811 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1812
1813 let content = " - item\n ```python\n print('hi')\n ```";
1814 let blocks = processor.extract_code_blocks(content);
1815
1816 assert_eq!(blocks.len(), 1);
1817 assert_eq!(blocks[0].indent_prefix, " ");
1818 }
1819
1820 #[test]
1824 fn test_extract_code_blocks_line_numbers() {
1825 let config = default_config();
1826 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1827
1828 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~~~";
1829 let blocks = processor.extract_code_blocks(content);
1830
1831 let lines: Vec<(usize, usize)> = blocks.iter().map(|b| (b.start_line, b.end_line)).collect();
1832 assert_eq!(lines, vec![(2, 4), (6, 8), (10, 12)]);
1833 }
1834
1835 #[test]
1836 fn test_extract_code_blocks_no_language() {
1837 let config = default_config();
1838 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1839
1840 let content = "```\nplain code\n```";
1841 let blocks = processor.extract_code_blocks(content);
1842
1843 assert_eq!(blocks.len(), 1);
1844 assert_eq!(blocks[0].language, "");
1845 }
1846
1847 #[test]
1848 fn test_resolve_language_linguist() {
1849 let mut config = default_config();
1850 config.normalize_language = NormalizeLanguage::Linguist;
1851 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1852
1853 assert_eq!(processor.resolve_language("py"), "python");
1854 assert_eq!(processor.resolve_language("bash"), "shell");
1855 assert_eq!(processor.resolve_language("js"), "javascript");
1856 }
1857
1858 #[test]
1859 fn test_resolve_language_exact() {
1860 let mut config = default_config();
1861 config.normalize_language = NormalizeLanguage::Exact;
1862 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1863
1864 assert_eq!(processor.resolve_language("py"), "py");
1865 assert_eq!(processor.resolve_language("BASH"), "bash");
1866 }
1867
1868 #[test]
1869 fn test_resolve_language_user_alias_override() {
1870 let mut config = default_config();
1871 config.language_aliases.insert("py".to_string(), "python".to_string());
1872 config.normalize_language = NormalizeLanguage::Exact;
1873 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1874
1875 assert_eq!(processor.resolve_language("PY"), "python");
1876 }
1877
1878 #[test]
1879 fn test_indent_strip_and_reapply_roundtrip() {
1880 let config = default_config();
1881 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1882
1883 let raw = " def hello():\n print('hi')";
1884 let stripped = processor.strip_indent_from_block(raw, " ");
1885 assert_eq!(stripped, "def hello():\n print('hi')");
1886
1887 let reapplied = processor.apply_indent_to_block(&stripped, " ");
1888 assert_eq!(reapplied, raw);
1889 }
1890
1891 #[test]
1892 fn test_infer_severity() {
1893 let config = default_config();
1894 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1895
1896 assert_eq!(
1897 processor.infer_severity("E501 line too long"),
1898 DiagnosticSeverity::Error
1899 );
1900 assert_eq!(
1901 processor.infer_severity("W291 trailing whitespace"),
1902 DiagnosticSeverity::Warning
1903 );
1904 assert_eq!(
1905 processor.infer_severity("error: something failed"),
1906 DiagnosticSeverity::Error
1907 );
1908 assert_eq!(
1909 processor.infer_severity("warning: unused variable"),
1910 DiagnosticSeverity::Warning
1911 );
1912 assert_eq!(
1913 processor.infer_severity("note: consider using"),
1914 DiagnosticSeverity::Info
1915 );
1916 }
1917
1918 #[test]
1919 fn test_parse_standard_format_windows_path() {
1920 let config = default_config();
1921 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1922
1923 let output = ToolOutput {
1924 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1925 stderr: String::new(),
1926 exit_code: 1,
1927 success: false,
1928 };
1929
1930 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1931 assert_eq!(diags.len(), 1);
1932 assert_eq!(diags[0].file_line, 12);
1933 assert_eq!(diags[0].column, Some(5));
1934 assert_eq!(diags[0].message, "E123 message");
1935 }
1936
1937 #[test]
1938 fn test_parse_eslint_severity() {
1939 let config = default_config();
1940 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1941
1942 let output = ToolOutput {
1943 stdout: "1:2 error Unexpected token".to_string(),
1944 stderr: String::new(),
1945 exit_code: 1,
1946 success: false,
1947 };
1948
1949 let diags = processor.parse_tool_output(&output, "eslint", 5);
1950 assert_eq!(diags.len(), 1);
1951 assert_eq!(diags[0].file_line, 6);
1952 assert_eq!(diags[0].column, Some(2));
1953 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1954 assert_eq!(diags[0].message, "Unexpected token");
1955 }
1956
1957 #[test]
1958 fn test_parse_shellcheck_multiline() {
1959 let config = default_config();
1960 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1961
1962 let output = ToolOutput {
1963 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1964 stderr: String::new(),
1965 exit_code: 1,
1966 success: false,
1967 };
1968
1969 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1970 assert_eq!(diags.len(), 1);
1971 assert_eq!(diags[0].file_line, 13);
1972 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1973 assert_eq!(diags[0].message, "Double quote to prevent globbing");
1974 }
1975
1976 #[test]
1977 fn test_lint_no_config() {
1978 let config = default_config();
1979 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1980
1981 let content = "```python\nprint('hello')\n```";
1982 let result = processor.lint(content);
1983
1984 assert!(result.is_ok());
1986 assert!(result.unwrap().is_empty());
1987 }
1988
1989 #[test]
1990 fn test_format_no_config() {
1991 let config = default_config();
1992 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1993
1994 let content = "```python\nprint('hello')\n```";
1995 let result = processor.format(content);
1996
1997 assert!(result.is_ok());
1999 let output = result.unwrap();
2000 assert_eq!(output.content, content);
2001 assert!(!output.had_errors);
2002 assert!(output.error_messages.is_empty());
2003 }
2004
2005 #[test]
2006 fn test_lint_on_missing_language_definition_fail() {
2007 let mut config = default_config();
2008 config.on_missing_language_definition = OnMissing::Fail;
2009 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2010
2011 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
2012 let result = processor.lint(content);
2013
2014 assert!(result.is_ok());
2016 let diagnostics = result.unwrap();
2017 assert_eq!(diagnostics.len(), 2);
2018 assert!(diagnostics[0].message.contains("No lint tools configured"));
2019 assert!(diagnostics[0].message.contains("python"));
2020 assert!(diagnostics[1].message.contains("javascript"));
2021 }
2022
2023 #[test]
2024 fn test_lint_on_missing_language_definition_fail_fast() {
2025 let mut config = default_config();
2026 config.on_missing_language_definition = OnMissing::FailFast;
2027 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2028
2029 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
2030 let result = processor.lint(content);
2031
2032 assert!(result.is_err());
2034 let err = result.unwrap_err();
2035 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
2036 }
2037
2038 #[test]
2039 fn test_format_on_missing_language_definition_fail() {
2040 let mut config = default_config();
2041 config.on_missing_language_definition = OnMissing::Fail;
2042 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2043
2044 let content = "```python\nprint('hello')\n```";
2045 let result = processor.format(content);
2046
2047 assert!(result.is_ok());
2049 let output = result.unwrap();
2050 assert_eq!(output.content, content); assert!(output.had_errors);
2052 assert!(!output.error_messages.is_empty());
2053 assert!(output.error_messages[0].contains("No format tools configured"));
2054 }
2055
2056 #[test]
2057 fn test_format_on_missing_language_definition_fail_fast() {
2058 let mut config = default_config();
2059 config.on_missing_language_definition = OnMissing::FailFast;
2060 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2061
2062 let content = "```python\nprint('hello')\n```";
2063 let result = processor.format(content);
2064
2065 assert!(result.is_err());
2067 let err = result.unwrap_err();
2068 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
2069 }
2070
2071 #[test]
2072 fn test_lint_on_missing_tool_binary_fail() {
2073 use super::super::config::{LanguageToolConfig, ToolDefinition};
2074
2075 let mut config = default_config();
2076 config.on_missing_tool_binary = OnMissing::Fail;
2077
2078 let lang_config = LanguageToolConfig {
2080 lint: vec!["nonexistent-linter".to_string()],
2081 ..Default::default()
2082 };
2083 config.languages.insert("python".to_string(), lang_config);
2084
2085 let tool_def = ToolDefinition {
2086 command: vec!["nonexistent-binary-xyz123".to_string()],
2087 ..Default::default()
2088 };
2089 config.tools.insert("nonexistent-linter".to_string(), tool_def);
2090
2091 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2092
2093 let content = "```python\nprint('hello')\n```";
2094 let result = processor.lint(content);
2095
2096 assert!(result.is_ok());
2098 let diagnostics = result.unwrap();
2099 assert_eq!(diagnostics.len(), 1);
2100 assert!(diagnostics[0].message.contains("not found in PATH"));
2101 }
2102
2103 #[test]
2104 fn test_lint_on_missing_tool_binary_fail_fast() {
2105 use super::super::config::{LanguageToolConfig, ToolDefinition};
2106
2107 let mut config = default_config();
2108 config.on_missing_tool_binary = OnMissing::FailFast;
2109
2110 let lang_config = LanguageToolConfig {
2112 lint: vec!["nonexistent-linter".to_string()],
2113 ..Default::default()
2114 };
2115 config.languages.insert("python".to_string(), lang_config);
2116
2117 let tool_def = ToolDefinition {
2118 command: vec!["nonexistent-binary-xyz123".to_string()],
2119 ..Default::default()
2120 };
2121 config.tools.insert("nonexistent-linter".to_string(), tool_def);
2122
2123 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2124
2125 let content = "```python\nprint('hello')\n```";
2126 let result = processor.lint(content);
2127
2128 assert!(result.is_err());
2130 let err = result.unwrap_err();
2131 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
2132 }
2133
2134 #[test]
2135 fn test_format_on_missing_tool_binary_fail() {
2136 use super::super::config::{LanguageToolConfig, ToolDefinition};
2137
2138 let mut config = default_config();
2139 config.on_missing_tool_binary = OnMissing::Fail;
2140
2141 let lang_config = LanguageToolConfig {
2143 format: vec!["nonexistent-formatter".to_string()],
2144 ..Default::default()
2145 };
2146 config.languages.insert("python".to_string(), lang_config);
2147
2148 let tool_def = ToolDefinition {
2149 command: vec!["nonexistent-binary-xyz123".to_string()],
2150 ..Default::default()
2151 };
2152 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
2153
2154 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2155
2156 let content = "```python\nprint('hello')\n```";
2157 let result = processor.format(content);
2158
2159 assert!(result.is_ok());
2161 let output = result.unwrap();
2162 assert_eq!(output.content, content); assert!(output.had_errors);
2164 assert!(!output.error_messages.is_empty());
2165 assert!(output.error_messages[0].contains("not found in PATH"));
2166 }
2167
2168 #[test]
2169 fn test_format_on_missing_tool_binary_fail_fast() {
2170 use super::super::config::{LanguageToolConfig, ToolDefinition};
2171
2172 let mut config = default_config();
2173 config.on_missing_tool_binary = OnMissing::FailFast;
2174
2175 let lang_config = LanguageToolConfig {
2177 format: vec!["nonexistent-formatter".to_string()],
2178 ..Default::default()
2179 };
2180 config.languages.insert("python".to_string(), lang_config);
2181
2182 let tool_def = ToolDefinition {
2183 command: vec!["nonexistent-binary-xyz123".to_string()],
2184 ..Default::default()
2185 };
2186 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
2187
2188 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2189
2190 let content = "```python\nprint('hello')\n```";
2191 let result = processor.format(content);
2192
2193 assert!(result.is_err());
2195 let err = result.unwrap_err();
2196 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
2197 }
2198
2199 #[test]
2200 fn test_lint_rumdl_builtin_skipped_for_markdown() {
2201 let mut config = default_config();
2204 config.languages.insert(
2205 "markdown".to_string(),
2206 LanguageToolConfig {
2207 lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
2208 ..Default::default()
2209 },
2210 );
2211 config.on_missing_language_definition = OnMissing::Fail;
2212 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2213
2214 let content = "```markdown\n# Hello\n```";
2215 let result = processor.lint(content);
2216
2217 assert!(result.is_ok());
2219 assert!(result.unwrap().is_empty());
2220 }
2221
2222 #[test]
2223 fn test_format_rumdl_builtin_skipped_for_markdown() {
2224 let mut config = default_config();
2226 config.languages.insert(
2227 "markdown".to_string(),
2228 LanguageToolConfig {
2229 format: vec![RUMDL_BUILTIN_TOOL.to_string()],
2230 ..Default::default()
2231 },
2232 );
2233 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2234
2235 let content = "```markdown\n# Hello\n```";
2236 let result = processor.format(content);
2237
2238 assert!(result.is_ok());
2240 let output = result.unwrap();
2241 assert_eq!(output.content, content);
2242 assert!(!output.had_errors);
2243 }
2244
2245 #[test]
2246 fn test_is_markdown_language() {
2247 assert!(is_markdown_language("markdown"));
2249 assert!(is_markdown_language("Markdown"));
2250 assert!(is_markdown_language("MARKDOWN"));
2251 assert!(is_markdown_language("md"));
2252 assert!(is_markdown_language("MD"));
2253 assert!(!is_markdown_language("python"));
2254 assert!(!is_markdown_language("rust"));
2255 assert!(!is_markdown_language(""));
2256 }
2257
2258 #[test]
2261 fn test_extract_mkdocs_admonition_code_block() {
2262 let config = default_config();
2263 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2264
2265 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
2266 let blocks = processor.extract_code_blocks(content);
2267
2268 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
2269 assert_eq!(blocks[0].language, "python");
2270 }
2271
2272 #[test]
2273 fn test_extract_mkdocs_tab_code_block() {
2274 let config = default_config();
2275 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2276
2277 let content = "=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n";
2278 let blocks = processor.extract_code_blocks(content);
2279
2280 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
2281 assert_eq!(blocks[0].language, "python");
2282 }
2283
2284 #[test]
2285 fn test_standard_flavor_ignores_admonition_indented_content() {
2286 let config = default_config();
2287 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2288
2289 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
2292 let blocks = processor.extract_code_blocks(content);
2293
2294 for (i, b) in blocks.iter().enumerate() {
2298 for (j, b2) in blocks.iter().enumerate() {
2299 if i != j {
2300 assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
2301 }
2302 }
2303 }
2304 }
2305
2306 #[test]
2307 fn test_mkdocs_top_level_blocks_alongside_admonition() {
2308 let config = default_config();
2309 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2310
2311 let content =
2312 "```rust\nfn main() {}\n```\n\n!!! note\n Some text\n\n ```python\n print(\"hello\")\n ```\n";
2313 let blocks = processor.extract_code_blocks(content);
2314
2315 assert_eq!(
2316 blocks.len(),
2317 2,
2318 "Should detect both top-level and admonition code blocks"
2319 );
2320 assert_eq!(blocks[0].language, "rust");
2321 assert_eq!(blocks[1].language, "python");
2322 }
2323
2324 #[test]
2325 fn test_mkdocs_nested_admonition_code_block() {
2326 let config = default_config();
2327 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2328
2329 let content = "\
2330!!! note
2331 Some text
2332
2333 !!! warning
2334 Nested content
2335
2336 ```python
2337 x = 1
2338 ```
2339";
2340 let blocks = processor.extract_code_blocks(content);
2341 assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
2342 assert_eq!(blocks[0].language, "python");
2343 }
2344
2345 #[test]
2346 fn test_mkdocs_consecutive_admonitions_no_stale_context() {
2347 let config = default_config();
2348 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2349
2350 let content = "\
2353!!! note
2354 First admonition content
2355
2356!!! warning
2357 Second admonition content
2358
2359 ```python
2360 y = 2
2361 ```
2362";
2363 let blocks = processor.extract_code_blocks(content);
2364 assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
2365 assert_eq!(blocks[0].language, "python");
2366 }
2367
2368 #[test]
2369 fn test_mkdocs_crlf_line_endings() {
2370 let config = default_config();
2371 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2372
2373 let content = "!!! note\r\n Some text\r\n\r\n ```python\r\n x = 1\r\n ```\r\n";
2375 let blocks = processor.extract_code_blocks(content);
2376
2377 assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
2378 assert_eq!(blocks[0].language, "python");
2379
2380 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2382 assert!(
2383 extracted.contains("x = 1"),
2384 "Extracted content should contain code. Got: {extracted:?}"
2385 );
2386 }
2387
2388 #[test]
2389 fn test_mkdocs_unclosed_fence_in_admonition() {
2390 let config = default_config();
2391 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2392
2393 let content = "!!! note\n ```python\n x = 1\n no closing fence\n";
2395 let blocks = processor.extract_code_blocks(content);
2396 assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
2397 }
2398
2399 #[test]
2400 fn test_mkdocs_tilde_fence_in_admonition() {
2401 let config = default_config();
2402 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2403
2404 let content = "!!! note\n ~~~ruby\n puts 'hi'\n ~~~\n";
2405 let blocks = processor.extract_code_blocks(content);
2406 assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
2407 assert_eq!(blocks[0].language, "ruby");
2408 }
2409
2410 #[test]
2411 fn test_mkdocs_empty_lines_in_code_block() {
2412 let config = default_config();
2413 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2414
2415 let content = "!!! note\n ```python\n x = 1\n\n y = 2\n ```\n";
2418 let blocks = processor.extract_code_blocks(content);
2419 assert_eq!(blocks.len(), 1);
2420
2421 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2422 assert!(
2423 extracted.contains("x = 1") && extracted.contains("y = 2"),
2424 "Extracted content should span across the empty line. Got: {extracted:?}"
2425 );
2426 }
2427
2428 #[test]
2429 fn test_mkdocs_content_byte_offsets_lf() {
2430 let config = default_config();
2431 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2432
2433 let content = "!!! note\n ```python\n print('hi')\n ```\n";
2434 let blocks = processor.extract_code_blocks(content);
2435 assert_eq!(blocks.len(), 1);
2436
2437 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2439 assert_eq!(extracted, " print('hi')\n", "Content offsets should be exact for LF");
2440 }
2441
2442 #[test]
2443 fn test_mkdocs_content_byte_offsets_crlf() {
2444 let config = default_config();
2445 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2446
2447 let content = "!!! note\r\n ```python\r\n print('hi')\r\n ```\r\n";
2448 let blocks = processor.extract_code_blocks(content);
2449 assert_eq!(blocks.len(), 1);
2450
2451 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
2452 assert_eq!(
2453 extracted, " print('hi')\r\n",
2454 "Content offsets should be exact for CRLF"
2455 );
2456 }
2457
2458 #[test]
2459 fn test_lint_enabled_false_skips_language_in_strict_mode() {
2460 let mut config = default_config();
2463 config.normalize_language = NormalizeLanguage::Exact;
2464 config.on_missing_language_definition = OnMissing::Fail;
2465
2466 config.languages.insert(
2468 "python".to_string(),
2469 LanguageToolConfig {
2470 lint: vec!["ruff:check".to_string()],
2471 ..Default::default()
2472 },
2473 );
2474 config.languages.insert(
2475 "plaintext".to_string(),
2476 LanguageToolConfig {
2477 enabled: false,
2478 ..Default::default()
2479 },
2480 );
2481
2482 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2483
2484 let content = "```plaintext\nsome text\n```";
2485 let result = processor.lint(content);
2486
2487 assert!(result.is_ok());
2489 let diagnostics = result.unwrap();
2490 assert!(
2491 diagnostics.is_empty(),
2492 "Expected no diagnostics for disabled language, got: {diagnostics:?}"
2493 );
2494 }
2495
2496 #[test]
2497 fn test_format_enabled_false_skips_language_in_strict_mode() {
2498 let mut config = default_config();
2500 config.normalize_language = NormalizeLanguage::Exact;
2501 config.on_missing_language_definition = OnMissing::Fail;
2502
2503 config.languages.insert(
2504 "plaintext".to_string(),
2505 LanguageToolConfig {
2506 enabled: false,
2507 ..Default::default()
2508 },
2509 );
2510
2511 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2512
2513 let content = "```plaintext\nsome text\n```";
2514 let result = processor.format(content);
2515
2516 assert!(result.is_ok());
2518 let output = result.unwrap();
2519 assert!(!output.had_errors, "Expected no errors for disabled language");
2520 assert!(
2521 output.error_messages.is_empty(),
2522 "Expected no error messages, got: {:?}",
2523 output.error_messages
2524 );
2525 }
2526
2527 #[test]
2528 fn test_enabled_false_default_true_preserved() {
2529 let mut config = default_config();
2531 config.on_missing_language_definition = OnMissing::Fail;
2532
2533 config.languages.insert(
2535 "python".to_string(),
2536 LanguageToolConfig {
2537 lint: vec!["ruff:check".to_string()],
2538 ..Default::default()
2539 },
2540 );
2541
2542 let lang_config = config.languages.get("python").unwrap();
2543 assert!(lang_config.enabled, "enabled should default to true");
2544 }
2545
2546 #[test]
2547 fn test_enabled_false_with_fail_fast_no_error() {
2548 let mut config = default_config();
2550 config.normalize_language = NormalizeLanguage::Exact;
2551 config.on_missing_language_definition = OnMissing::FailFast;
2552
2553 config.languages.insert(
2554 "unknown".to_string(),
2555 LanguageToolConfig {
2556 enabled: false,
2557 ..Default::default()
2558 },
2559 );
2560
2561 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2562
2563 let content = "```unknown\nsome content\n```";
2564 let result = processor.lint(content);
2565
2566 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2568 assert!(result.unwrap().is_empty());
2569 }
2570
2571 #[test]
2572 fn test_enabled_false_format_with_fail_fast_no_error() {
2573 let mut config = default_config();
2575 config.normalize_language = NormalizeLanguage::Exact;
2576 config.on_missing_language_definition = OnMissing::FailFast;
2577
2578 config.languages.insert(
2579 "unknown".to_string(),
2580 LanguageToolConfig {
2581 enabled: false,
2582 ..Default::default()
2583 },
2584 );
2585
2586 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2587
2588 let content = "```unknown\nsome content\n```";
2589 let result = processor.format(content);
2590
2591 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2592 let output = result.unwrap();
2593 assert!(!output.had_errors);
2594 }
2595
2596 #[test]
2597 fn test_enabled_false_with_tools_still_skips() {
2598 let mut config = default_config();
2600 config.on_missing_language_definition = OnMissing::Fail;
2601
2602 config.languages.insert(
2603 "python".to_string(),
2604 LanguageToolConfig {
2605 enabled: false,
2606 lint: vec!["ruff:check".to_string()],
2607 format: vec!["ruff:format".to_string()],
2608 ..Default::default()
2609 },
2610 );
2611
2612 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2613
2614 let content = "```python\nprint('hello')\n```";
2615
2616 let lint_result = processor.lint(content);
2618 assert!(lint_result.is_ok());
2619 assert!(lint_result.unwrap().is_empty());
2620
2621 let format_result = processor.format(content);
2623 assert!(format_result.is_ok());
2624 let output = format_result.unwrap();
2625 assert!(!output.had_errors);
2626 assert_eq!(output.content, content, "Content should be unchanged");
2627 }
2628
2629 #[test]
2630 fn test_enabled_true_without_tools_triggers_strict_mode() {
2631 let mut config = default_config();
2634 config.on_missing_language_definition = OnMissing::Fail;
2635
2636 config.languages.insert(
2637 "python".to_string(),
2638 LanguageToolConfig {
2639 ..Default::default()
2641 },
2642 );
2643
2644 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2645
2646 let content = "```python\nprint('hello')\n```";
2647 let result = processor.lint(content);
2648
2649 assert!(result.is_ok());
2651 let diagnostics = result.unwrap();
2652 assert_eq!(diagnostics.len(), 1);
2653 assert!(diagnostics[0].message.contains("No lint tools configured"));
2654 }
2655
2656 #[test]
2657 fn test_mixed_enabled_and_disabled_languages() {
2658 let mut config = default_config();
2660 config.normalize_language = NormalizeLanguage::Exact;
2661 config.on_missing_language_definition = OnMissing::Fail;
2662
2663 config.languages.insert(
2664 "plaintext".to_string(),
2665 LanguageToolConfig {
2666 enabled: false,
2667 ..Default::default()
2668 },
2669 );
2670
2671 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2672
2673 let content = "\
2674```plaintext
2675some text
2676```
2677
2678```javascript
2679console.log('hi');
2680```
2681";
2682
2683 let result = processor.lint(content);
2684 assert!(result.is_ok());
2685 let diagnostics = result.unwrap();
2686
2687 assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2690 assert!(
2691 diagnostics[0].message.contains("javascript"),
2692 "Error should be about javascript, got: {}",
2693 diagnostics[0].message
2694 );
2695 }
2696
2697 #[test]
2698 fn test_generic_fallback_includes_all_stderr_lines() {
2699 let config = default_config();
2700 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2701
2702 let output = ToolOutput {
2704 stdout: String::new(),
2705 stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2706 exit_code: 1,
2707 success: false,
2708 };
2709
2710 let diags = processor.parse_tool_output(&output, "tombi", 5);
2711 assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2712 assert_eq!(diags[0].message, "Parse error at position 42");
2713 assert_eq!(diags[1].message, "Unexpected token '::'");
2714 assert_eq!(diags[2].message, "3 errors found");
2715 assert!(diags.iter().all(|d| d.tool == "tombi"));
2716 assert!(diags.iter().all(|d| d.file_line == 5));
2717 }
2718
2719 #[test]
2720 fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2721 let config = default_config();
2722 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2723
2724 let output = ToolOutput {
2725 stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2726 stderr: String::new(),
2727 exit_code: 1,
2728 success: false,
2729 };
2730
2731 let diags = processor.parse_tool_output(&output, "some-tool", 10);
2732 assert_eq!(diags.len(), 3);
2733 assert_eq!(diags[0].message, "Line 1 error");
2734 assert_eq!(diags[1].message, "Line 2 detail");
2735 assert_eq!(diags[2].message, "Line 3 summary");
2736 }
2737
2738 #[test]
2739 fn test_generic_fallback_skips_blank_lines() {
2740 let config = default_config();
2741 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2742
2743 let output = ToolOutput {
2744 stdout: String::new(),
2745 stderr: "error: bad input\n\n \n\ndetail: see above\n".to_string(),
2746 exit_code: 1,
2747 success: false,
2748 };
2749
2750 let diags = processor.parse_tool_output(&output, "tool", 1);
2751 assert_eq!(diags.len(), 2);
2752 assert_eq!(diags[0].message, "error: bad input");
2753 assert_eq!(diags[1].message, "detail: see above");
2754 }
2755
2756 #[test]
2757 fn test_generic_fallback_exit_code_when_no_output() {
2758 let config = default_config();
2759 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2760
2761 let output = ToolOutput {
2762 stdout: String::new(),
2763 stderr: String::new(),
2764 exit_code: 42,
2765 success: false,
2766 };
2767
2768 let diags = processor.parse_tool_output(&output, "tool", 1);
2769 assert_eq!(diags.len(), 1);
2770 assert_eq!(diags[0].message, "Tool exited with code 42");
2771 }
2772
2773 #[test]
2774 fn test_generic_fallback_not_triggered_on_success() {
2775 let config = default_config();
2776 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2777
2778 let output = ToolOutput {
2779 stdout: "some informational output".to_string(),
2780 stderr: String::new(),
2781 exit_code: 0,
2782 success: true,
2783 };
2784
2785 let diags = processor.parse_tool_output(&output, "tool", 1);
2786 assert!(
2787 diags.is_empty(),
2788 "Successful tool runs should produce no fallback diagnostics"
2789 );
2790 }
2791
2792 #[test]
2793 fn test_ansi_codes_stripped_before_parsing() {
2794 let config = default_config();
2795 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2796
2797 let output = ToolOutput {
2799 stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
2800 .to_string(),
2801 stderr: String::new(),
2802 exit_code: 1,
2803 success: false,
2804 };
2805
2806 let diags = processor.parse_tool_output(&output, "ruff:check", 5);
2807 assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
2808 assert_eq!(diags[0].message, "E501 Line too long");
2809 assert_eq!(diags[0].file_line, 6); }
2811
2812 #[test]
2813 fn test_tombi_multiline_error_format() {
2814 let config = default_config();
2815 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2816
2817 let output = ToolOutput {
2819 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(),
2820 stderr: "1 file failed to be formatted".to_string(),
2821 exit_code: 1,
2822 success: false,
2823 };
2824
2825 let diags = processor.parse_tool_output(&output, "tombi", 7);
2826 assert_eq!(
2827 diags.len(),
2828 4,
2829 "Expected 4 diagnostics from tombi errors, got {diags:?}"
2830 );
2831 assert_eq!(diags[0].message, "invalid key");
2832 assert_eq!(diags[0].file_line, 9); assert_eq!(diags[0].column, Some(1));
2834 assert_eq!(diags[1].message, "expected key");
2835 assert_eq!(diags[1].file_line, 9);
2836 assert_eq!(diags[2].message, "expected '='");
2837 assert_eq!(diags[3].message, "expected value");
2838 assert!(diags.iter().all(|d| d.tool == "tombi"));
2839 }
2840
2841 #[test]
2842 fn test_tombi_with_ansi_codes() {
2843 let config = default_config();
2844 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2845
2846 let output = ToolOutput {
2848 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(),
2849 stderr: "1 file failed to be formatted".to_string(),
2850 exit_code: 1,
2851 success: false,
2852 };
2853
2854 let diags = processor.parse_tool_output(&output, "tombi", 7);
2855 assert_eq!(
2856 diags.len(),
2857 2,
2858 "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
2859 );
2860 assert_eq!(diags[0].message, "invalid key");
2861 assert_eq!(diags[0].file_line, 9);
2862 assert_eq!(diags[1].message, "expected '='");
2863 assert_eq!(diags[1].file_line, 9);
2864 }
2865
2866 const SQLFLUFF_ANNOTATIONS: &str = "\
2871::group::stdin
2872::warning title=SQLFluff,file=stdin,line=1,col=7,endLine=1,endColumn=10::LT01: Expected only single space before numeric literal. Found ' '. [layout.spacing]
2873::warning title=SQLFluff,file=stdin,line=1,col=11,endLine=1,endColumn=13::LT01: Expected only single space before 'FROM' keyword. Found ' '. [layout.spacing]
2874::warning title=SQLFluff,file=stdin,line=1,col=17,endLine=1,endColumn=20::LT01: Expected only single space before naked identifier. Found ' '. [layout.spacing]
2875::endgroup::";
2876
2877 #[test]
2878 fn test_github_annotation_maps_line_and_column() {
2879 let config = default_config();
2880 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2881
2882 let output = ToolOutput {
2883 stdout: SQLFLUFF_ANNOTATIONS.to_string(),
2884 stderr: String::new(),
2885 exit_code: 1,
2886 success: false,
2887 };
2888
2889 let diags = processor.parse_tool_output(&output, "sqlfluff:lint", 3);
2890 assert_eq!(diags.len(), 3, "the group markers carry no finding: {diags:?}");
2891 assert_eq!(diags.iter().map(|d| d.file_line).collect::<Vec<_>>(), vec![4, 4, 4]);
2892 assert_eq!(
2893 diags.iter().map(|d| d.column).collect::<Vec<_>>(),
2894 vec![Some(7), Some(11), Some(17)]
2895 );
2896 assert!(diags[0].message.starts_with("LT01: Expected only single space"));
2897 assert!(diags.iter().all(|d| matches!(d.severity, DiagnosticSeverity::Warning)));
2898 }
2899
2900 #[test]
2901 fn test_github_annotation_severity_per_level() {
2902 let config = default_config();
2903 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2904
2905 let output = ToolOutput {
2906 stdout: "::error line=1::broken\n::warning line=2::suspicious\n::notice line=3::detail".to_string(),
2907 stderr: String::new(),
2908 exit_code: 1,
2909 success: false,
2910 };
2911
2912 let diags = processor.parse_tool_output(&output, "tool", 10);
2913 assert_eq!(diags.len(), 3);
2914 assert!(matches!(diags[0].severity, DiagnosticSeverity::Error));
2915 assert!(matches!(diags[1].severity, DiagnosticSeverity::Warning));
2916 assert!(matches!(diags[2].severity, DiagnosticSeverity::Info));
2917 assert_eq!(diags[2].file_line, 13);
2918 }
2919
2920 #[test]
2921 fn test_github_annotation_without_line_anchors_at_fence() {
2922 let config = default_config();
2923 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2924
2925 let output = ToolOutput {
2926 stdout: "::error::something the tool could not place".to_string(),
2927 stderr: String::new(),
2928 exit_code: 1,
2929 success: false,
2930 };
2931
2932 let diags = processor.parse_tool_output(&output, "tool", 12);
2933 assert_eq!(diags.len(), 1);
2934 assert_eq!(diags[0].file_line, 12);
2935 assert_eq!(diags[0].column, None);
2936 assert_eq!(diags[0].message, "something the tool could not place");
2937 }
2938
2939 #[test]
2940 fn test_github_annotation_decodes_escaped_message() {
2941 let config = default_config();
2942 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2943
2944 let output = ToolOutput {
2947 stdout: "::error line=1,col=2::100%25 of rows%3A a%2C b%0Aand more".to_string(),
2948 stderr: String::new(),
2949 exit_code: 1,
2950 success: false,
2951 };
2952
2953 let diags = processor.parse_tool_output(&output, "tool", 5);
2954 assert_eq!(diags.len(), 1);
2955 assert_eq!(diags[0].message, "100% of rows: a, b and more");
2956 assert_eq!(diags[0].file_line, 6);
2957 assert_eq!(diags[0].column, Some(2));
2958 }
2959
2960 #[test]
2961 fn test_decode_workflow_command_is_a_single_left_to_right_pass() {
2962 assert_eq!(decode_workflow_command("%250A"), "%0A");
2965 assert_eq!(decode_workflow_command("no escapes"), "no escapes");
2966 assert_eq!(decode_workflow_command("50% off"), "50% off");
2967 assert_eq!(decode_workflow_command("trailing %"), "trailing %");
2968 assert_eq!(decode_workflow_command("%0d%0a"), " ");
2969 assert_eq!(decode_workflow_command("%3A%2C"), ":,");
2970 }
2971
2972 #[test]
2973 fn test_position_in_message_anchors_a_prose_position() {
2974 let config = default_config();
2975 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2976
2977 let output = ToolOutput {
2980 stdout: String::new(),
2981 stderr: "jq: error (at <stdin>:0): syntax error, unexpected '}' at line 3, column 8".to_string(),
2982 exit_code: 2,
2983 success: false,
2984 };
2985
2986 let diags = processor.parse_tool_output(&output, "jq", 20);
2987 assert_eq!(diags.len(), 1);
2988 assert_eq!(diags[0].file_line, 23);
2989 assert_eq!(diags[0].column, Some(8));
2990 }
2991
2992 #[test]
2993 fn test_position_in_message_takes_the_last_one_named() {
2994 let config = default_config();
2995 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2996
2997 let output = ToolOutput {
2998 stdout: "unterminated string started at line 2, column 1 at line 6, column 4".to_string(),
2999 stderr: String::new(),
3000 exit_code: 1,
3001 success: false,
3002 };
3003
3004 let diags = processor.parse_tool_output(&output, "tool", 0);
3005 assert_eq!(diags.len(), 1);
3006 assert_eq!(diags[0].file_line, 6, "the sentence ends on the position it is about");
3007 assert_eq!(diags[0].column, Some(4));
3008 }
3009
3010 #[test]
3011 fn test_message_naming_no_position_stays_on_the_fence() {
3012 let config = default_config();
3013 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3014
3015 let output = ToolOutput {
3016 stdout: "could not parse the input".to_string(),
3017 stderr: String::new(),
3018 exit_code: 1,
3019 success: false,
3020 };
3021
3022 let diags = processor.parse_tool_output(&output, "tool", 7);
3023 assert_eq!(diags.len(), 1);
3024 assert_eq!(diags[0].file_line, 7);
3025 assert_eq!(diags[0].column, None);
3026 }
3027
3028 #[test]
3029 fn test_fallback_combines_stdout_and_stderr() {
3030 let config = default_config();
3031 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3032
3033 let output = ToolOutput {
3035 stdout: "problem found in input".to_string(),
3036 stderr: "1 file failed".to_string(),
3037 exit_code: 1,
3038 success: false,
3039 };
3040
3041 let diags = processor.parse_tool_output(&output, "tool", 1);
3042 assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
3043 assert_eq!(diags[0].message, "problem found in input");
3044 assert_eq!(diags[1].message, "1 file failed");
3045 }
3046
3047 #[test]
3048 fn test_error_line_without_position_info() {
3049 let config = default_config();
3050 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3051
3052 let output = ToolOutput {
3054 stdout: "Error: something went wrong\nsome unrelated line".to_string(),
3055 stderr: String::new(),
3056 exit_code: 1,
3057 success: false,
3058 };
3059
3060 let diags = processor.parse_tool_output(&output, "tool", 5);
3061 assert!(!diags.is_empty());
3064 assert_eq!(diags[0].message, "something went wrong");
3065 assert_eq!(diags[0].file_line, 5); }
3067
3068 #[test]
3069 fn test_warning_line_with_position() {
3070 let config = default_config();
3071 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3072
3073 let output = ToolOutput {
3074 stdout: "Warning: deprecated syntax\n at line 3 column 5".to_string(),
3075 stderr: String::new(),
3076 exit_code: 1,
3077 success: false,
3078 };
3079
3080 let diags = processor.parse_tool_output(&output, "tool", 10);
3081 assert_eq!(diags.len(), 1);
3082 assert_eq!(diags[0].message, "deprecated syntax");
3083 assert_eq!(diags[0].file_line, 13); assert_eq!(diags[0].column, Some(5));
3085 assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
3086 }
3087
3088 #[test]
3089 fn test_strip_ansi_codes() {
3090 assert_eq!(strip_ansi_codes("hello"), "hello");
3091 assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
3092 assert_eq!(
3093 strip_ansi_codes("\x1b[1;31m Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
3094 " Error: msg"
3095 );
3096 assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
3097 assert_eq!(strip_ansi_codes(""), "");
3098 assert_eq!(
3099 strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
3100 "at line 2 column 1"
3101 );
3102 }
3103
3104 #[test]
3105 fn test_parse_at_line_column() {
3106 assert_eq!(
3107 CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
3108 Some((2, 1))
3109 );
3110 assert_eq!(
3111 CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
3112 Some((10, 15))
3113 );
3114 assert_eq!(
3115 CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
3116 Some((5, 3))
3117 );
3118 assert_eq!(
3119 CodeBlockToolProcessor::parse_at_line_column("not a position line"),
3120 None
3121 );
3122 assert_eq!(
3123 CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
3124 None
3125 );
3126 }
3127
3128 #[test]
3129 fn test_parse_error_line() {
3130 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
3131 assert_eq!(msg, "invalid key");
3132 assert!(matches!(sev, DiagnosticSeverity::Error));
3133
3134 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
3135 assert_eq!(msg, "deprecated");
3136 assert!(matches!(sev, DiagnosticSeverity::Warning));
3137
3138 assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
3140 assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
3141
3142 assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
3144 assert!(CodeBlockToolProcessor::parse_error_line("Error: ").is_none());
3145
3146 assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
3148 }
3149
3150 #[test]
3151 fn test_consecutive_error_lines_without_position() {
3152 let config = default_config();
3153 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3154
3155 let output = ToolOutput {
3158 stdout: "Error: first problem\nError: second problem\n at line 3 column 1".to_string(),
3159 stderr: String::new(),
3160 exit_code: 1,
3161 success: false,
3162 };
3163
3164 let diags = processor.parse_tool_output(&output, "tool", 5);
3165 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
3166 assert_eq!(diags[0].message, "first problem");
3168 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
3170 assert_eq!(diags[1].message, "second problem");
3172 assert_eq!(diags[1].file_line, 8); assert_eq!(diags[1].column, Some(1));
3174 }
3175
3176 #[test]
3177 fn test_error_line_at_end_of_output() {
3178 let config = default_config();
3179 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3180
3181 let output = ToolOutput {
3183 stdout: "Error: trailing error".to_string(),
3184 stderr: String::new(),
3185 exit_code: 1,
3186 success: false,
3187 };
3188
3189 let diags = processor.parse_tool_output(&output, "tool", 5);
3190 assert_eq!(diags.len(), 1);
3191 assert_eq!(diags[0].message, "trailing error");
3192 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
3194 }
3195
3196 #[test]
3197 fn test_blank_lines_between_error_and_position() {
3198 let config = default_config();
3199 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3200
3201 let output = ToolOutput {
3203 stdout: "Error: spaced out\n\n\n at line 4 column 2".to_string(),
3204 stderr: String::new(),
3205 exit_code: 1,
3206 success: false,
3207 };
3208
3209 let diags = processor.parse_tool_output(&output, "tool", 10);
3210 assert_eq!(diags.len(), 1);
3211 assert_eq!(diags[0].message, "spaced out");
3212 assert_eq!(diags[0].file_line, 14); assert_eq!(diags[0].column, Some(2));
3214 }
3215
3216 #[test]
3217 fn test_mixed_structured_and_error_line_parsers() {
3218 let config = default_config();
3219 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3220
3221 let output = ToolOutput {
3223 stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n at line 3 column 1".to_string(),
3224 stderr: String::new(),
3225 exit_code: 1,
3226 success: false,
3227 };
3228
3229 let diags = processor.parse_tool_output(&output, "tool", 5);
3230 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
3231 assert_eq!(diags[0].message, "E501 Line too long");
3233 assert_eq!(diags[0].file_line, 6); assert_eq!(diags[1].message, "invalid syntax");
3236 assert_eq!(diags[1].file_line, 8); }
3238
3239 #[test]
3240 fn test_at_line_without_preceding_error() {
3241 let config = default_config();
3242 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3243
3244 let output = ToolOutput {
3246 stdout: "at line 2 column 1\nsome other text".to_string(),
3247 stderr: String::new(),
3248 exit_code: 1,
3249 success: false,
3250 };
3251
3252 let diags = processor.parse_tool_output(&output, "tool", 5);
3253 assert_eq!(diags.len(), 2);
3256 assert_eq!(diags[0].message, "at line 2 column 1");
3257 assert_eq!(diags[1].message, "some other text");
3258 }
3259
3260 #[test]
3269 fn test_format_empty_output_does_not_erase_content() {
3270 use super::super::config::LanguageToolConfig;
3271
3272 let content = "```toml\nkey = \"value\"\n```\n";
3273 let config_for = |on_error: OnError| {
3274 let mut config = default_config();
3275 config.on_error = on_error;
3276 config.languages.insert(
3277 "toml".to_string(),
3278 LanguageToolConfig {
3279 format: vec!["empty-formatter".to_string()],
3280 ..Default::default()
3281 },
3282 );
3283 config.tools.insert(
3284 "empty-formatter".to_string(),
3285 super::super::config::ToolDefinition {
3286 command: vec!["true".to_string()],
3287 stdin: true,
3288 stdout: true,
3289 lint_args: vec![],
3290 format_args: vec![],
3291 },
3292 );
3293 config
3294 };
3295
3296 let config = config_for(OnError::Fail);
3297 let result = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format(content);
3298 assert!(
3299 matches!(result, Err(ProcessorError::ToolErrorAt { line: 1, .. })),
3300 "fail must stop at the block: {result:?}"
3301 );
3302
3303 let config = config_for(OnError::Warn);
3304 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
3305 .format(content)
3306 .unwrap();
3307 assert_eq!(output.content, content, "warn must leave the block as it was");
3308 assert_eq!(output.error_messages.len(), 1, "{:?}", output.error_messages);
3309 assert!(output.error_messages[0].starts_with("line 1 (toml): "));
3310
3311 let config = config_for(OnError::Skip);
3312 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
3313 .format(content)
3314 .unwrap();
3315 assert_eq!(output.content, content, "skip must leave the block as it was");
3316 assert!(output.error_messages.is_empty(), "{:?}", output.error_messages);
3317 }
3318
3319 #[test]
3321 fn test_format_identity_formatter_preserves_content() {
3322 use super::super::config::LanguageToolConfig;
3323
3324 let mut config = default_config();
3325 config.languages.insert(
3326 "toml".to_string(),
3327 LanguageToolConfig {
3328 format: vec!["cat-formatter".to_string()],
3329 ..Default::default()
3330 },
3331 );
3332 config.tools.insert(
3333 "cat-formatter".to_string(),
3334 super::super::config::ToolDefinition {
3335 command: vec!["cat".to_string()],
3336 stdin: true,
3337 stdout: true,
3338 lint_args: vec![],
3339 format_args: vec![],
3340 },
3341 );
3342
3343 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3344
3345 let content = "```toml\nkey = \"value\"\n```\n";
3346 let result = processor.format(content);
3347
3348 assert!(result.is_ok(), "Format should not error");
3349 let output = result.unwrap();
3350 assert_eq!(
3351 output.content, content,
3352 "Identity formatter should preserve content exactly"
3353 );
3354 }
3355
3356 #[test]
3359 fn test_resolve_tool_context_aware_tombi() {
3360 let config = default_config();
3361 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3362
3363 let format_def = processor
3365 .registry
3366 .resolve("tombi", ToolSlot::Format)
3367 .expect("Should resolve tombi in format context");
3368 assert!(
3369 format_def.command.iter().any(|arg| arg == "format"),
3370 "Bare 'tombi' in format context should resolve to 'tombi format', got: {:?}",
3371 format_def.command
3372 );
3373
3374 let lint_def = processor
3376 .registry
3377 .resolve("tombi", ToolSlot::Lint)
3378 .expect("Should resolve tombi in lint context");
3379 assert!(
3380 lint_def.command.iter().any(|arg| arg == "lint"),
3381 "Bare 'tombi' in lint context should resolve to 'tombi lint', got: {:?}",
3382 lint_def.command
3383 );
3384
3385 let explicit_def = processor
3387 .registry
3388 .resolve("tombi:lint", ToolSlot::Format)
3389 .expect("Should resolve explicit tombi:lint even in format context");
3390 assert!(
3391 explicit_def.command.iter().any(|arg| arg == "lint"),
3392 "Explicit 'tombi:lint' should always use lint, got: {:?}",
3393 explicit_def.command
3394 );
3395 }
3396
3397 #[test]
3399 fn test_resolve_tool_context_aware_ruff() {
3400 let config = default_config();
3401 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3402
3403 let lint_def = processor
3405 .registry
3406 .resolve("ruff", ToolSlot::Lint)
3407 .expect("Should resolve ruff in lint context");
3408 assert!(
3409 lint_def.command.iter().any(|arg| arg == "check"),
3410 "Bare 'ruff' in lint context should resolve to 'ruff check', got: {:?}",
3411 lint_def.command
3412 );
3413
3414 let format_def = processor
3416 .registry
3417 .resolve("ruff", ToolSlot::Format)
3418 .expect("Should resolve ruff in format context");
3419 assert!(
3420 format_def.command.iter().any(|arg| arg == "format"),
3421 "Bare 'ruff' in format context should resolve to 'ruff format', got: {:?}",
3422 format_def.command
3423 );
3424 }
3425
3426 #[test]
3428 fn test_resolve_tool_bare_name_fallback() {
3429 let config = default_config();
3430 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3431
3432 let def = processor
3434 .registry
3435 .resolve("shellcheck", ToolSlot::Lint)
3436 .expect("Should resolve shellcheck via fallback");
3437 assert!(
3438 def.command.iter().any(|arg| arg == "shellcheck"),
3439 "shellcheck should resolve to itself, got: {:?}",
3440 def.command
3441 );
3442 }
3443
3444 #[test]
3446 fn test_resolve_tool_context_aware_sqlfluff() {
3447 let config = default_config();
3448 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3449
3450 let format_def = processor
3452 .registry
3453 .resolve("sqlfluff", ToolSlot::Format)
3454 .expect("Should resolve sqlfluff in format context");
3455 assert!(
3456 format_def.command.iter().any(|arg| arg == "fix"),
3457 "Bare 'sqlfluff' in format context should resolve to 'sqlfluff fix', got: {:?}",
3458 format_def.command
3459 );
3460 }
3461
3462 #[test]
3464 fn test_resolve_tool_context_aware_djlint() {
3465 let config = default_config();
3466 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3467
3468 let format_def = processor
3470 .registry
3471 .resolve("djlint", ToolSlot::Format)
3472 .expect("Should resolve djlint in format context");
3473 assert!(
3474 format_def.command.iter().any(|arg| arg.contains("reformat")),
3475 "Bare 'djlint' in format context should resolve to djlint reformat, got: {:?}",
3476 format_def.command
3477 );
3478 }
3479
3480 #[test]
3482 fn test_resolve_tool_user_defined_with_context_variant() {
3483 use super::super::config::ToolDefinition;
3484
3485 let mut config = default_config();
3486 config.tools.insert(
3487 "mytool".to_string(),
3488 ToolDefinition {
3489 command: vec!["mytool".to_string(), "--lint".to_string()],
3490 ..Default::default()
3491 },
3492 );
3493 config.tools.insert(
3494 "mytool:format".to_string(),
3495 ToolDefinition {
3496 command: vec!["mytool".to_string(), "--format".to_string()],
3497 ..Default::default()
3498 },
3499 );
3500
3501 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3502
3503 let def = processor
3505 .registry
3506 .resolve("mytool", ToolSlot::Format)
3507 .expect("Should resolve user tool in format context");
3508 assert!(
3509 def.command.iter().any(|arg| arg == "--format"),
3510 "User 'mytool' in format context should resolve to mytool:format, got: {:?}",
3511 def.command
3512 );
3513
3514 let def = processor
3516 .registry
3517 .resolve("mytool", ToolSlot::Lint)
3518 .expect("Should resolve user tool in lint context via fallback");
3519 assert!(
3520 def.command.iter().any(|arg| arg == "--lint"),
3521 "User 'mytool' in lint context should fall back to bare name, got: {:?}",
3522 def.command
3523 );
3524 }
3525
3526 #[test]
3528 fn test_resolve_tool_nonexistent_returns_none() {
3529 let config = default_config();
3530 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3531
3532 assert!(
3533 processor
3534 .registry
3535 .resolve("nonexistent-tool-xyz", ToolSlot::Lint)
3536 .is_none(),
3537 "Nonexistent tool should return None in lint context"
3538 );
3539 assert!(
3540 processor
3541 .registry
3542 .resolve("nonexistent-tool-xyz", ToolSlot::Format)
3543 .is_none(),
3544 "Nonexistent tool should return None in format context"
3545 );
3546 }
3547
3548 #[test]
3549 fn test_strip_ansi_codes_edge_cases() {
3550 assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
3552 assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
3554 assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
3556 assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
3558 assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
3559 }
3560
3561 #[test]
3562 fn merge_conflict_prevents_external_formatting() {
3563 use super::super::config::{LanguageToolConfig, ToolDefinition};
3564 let mut config = default_config();
3565 config.on_missing_tool_binary = OnMissing::Fail;
3566 config.languages.insert(
3567 "testlang".to_string(),
3568 LanguageToolConfig {
3569 format: vec!["unavailable".to_string()],
3570 ..Default::default()
3571 },
3572 );
3573 config.tools.insert(
3574 "unavailable".to_string(),
3575 ToolDefinition {
3576 command: vec!["rumdl-858-tool-that-must-not-run".to_string()],
3577 ..Default::default()
3578 },
3579 );
3580 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3581 let content = "<<<<<<< HEAD\r\n\n```testlang\ntext\n```\n";
3582 let output = processor.format(content).unwrap();
3583 assert_eq!(output.content, content);
3584 assert!(!output.had_errors);
3585 assert!(output.failures.is_empty());
3586
3587 let mut document_config = crate::config::Config::default();
3590 document_config.global.disable.push("MD092".into());
3591 let output = processor.format_with_config(content, &document_config, None).unwrap();
3592 assert!(output.had_errors);
3593 assert!(!output.failures.is_empty());
3594 let suppressed = format!("<!-- rumdl-disable MD092 -->\n{content}");
3595 assert!(processor.format(&suppressed).unwrap().had_errors);
3596 }
3597
3598 #[cfg(unix)]
3603 #[test]
3604 fn test_lint_yaml_block_no_false_new_line_at_eof() {
3605 use super::super::config::{LanguageToolConfig, ToolDefinition};
3606
3607 let mut config = default_config();
3608 config.normalize_language = NormalizeLanguage::Exact;
3609 config.languages.insert(
3610 "yaml".to_string(),
3611 LanguageToolConfig {
3612 lint: vec!["eof-newline-linter".to_string()],
3613 ..Default::default()
3614 },
3615 );
3616 config.tools.insert(
3620 "eof-newline-linter".to_string(),
3621 ToolDefinition {
3622 command: vec![
3623 "sh".to_string(),
3624 "-c".to_string(),
3625 "if [ -n \"$(tail -c1)\" ]; then echo '1:1: no newline at end of file'; fi".to_string(),
3626 ],
3627 ..Default::default()
3628 },
3629 );
3630
3631 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3632
3633 let content = "```yaml\nfoo: bar\n```\n";
3634 let diagnostics = processor.lint(content).expect("lint should succeed");
3635
3636 assert!(
3637 diagnostics.is_empty(),
3638 "EOF-newline linter should not fire on an extracted code block, got: {diagnostics:?}"
3639 );
3640 }
3641
3642 #[cfg(unix)]
3647 #[test]
3648 fn test_format_feeds_newline_terminated_input() {
3649 use super::super::config::{LanguageToolConfig, ToolDefinition};
3650
3651 let mut config = default_config();
3652 config.normalize_language = NormalizeLanguage::Exact;
3653 config.languages.insert(
3654 "yaml".to_string(),
3655 LanguageToolConfig {
3656 format: vec!["newline-probe".to_string()],
3657 ..Default::default()
3658 },
3659 );
3660 config.tools.insert(
3661 "newline-probe".to_string(),
3662 ToolDefinition {
3663 command: vec![
3664 "sh".to_string(),
3665 "-c".to_string(),
3666 "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi".to_string(),
3667 ],
3668 ..Default::default()
3669 },
3670 );
3671
3672 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3673
3674 let content = "```yaml\nfoo: bar\n```\n";
3675 let output = processor.format(content).expect("format should succeed");
3676
3677 assert!(
3678 output.content.contains("HAD_NEWLINE"),
3679 "Formatter should receive newline-terminated stdin, got: {:?}",
3680 output.content
3681 );
3682 }
3683
3684 #[cfg(unix)]
3688 fn staged_config(tools: &[&str], mode: FormatMode, on_error: OnError) -> CodeBlockToolsConfig {
3689 use super::super::config::ToolDefinition;
3690
3691 let mut config = default_config();
3692 config.normalize_language = NormalizeLanguage::Exact;
3693 config.on_error = on_error;
3694 config.languages.insert(
3695 "text".to_string(),
3696 LanguageToolConfig {
3697 format: tools.iter().map(ToString::to_string).collect(),
3698 format_mode: mode,
3699 ..Default::default()
3700 },
3701 );
3702 for (id, script) in [
3703 ("upper", "tr a-z A-Z"),
3704 ("exclaim", "sed 's/$/!/'"),
3705 ("prefix-a", "sed 's/^/a/'"),
3706 ("prefix-b", "sed 's/^/b/'"),
3707 ("identity", "cat"),
3708 ("fails", "cat >/dev/null; exit 3"),
3709 ("empty", "cat >/dev/null"),
3710 ] {
3711 config.tools.insert(
3712 id.to_string(),
3713 ToolDefinition {
3714 command: vec!["sh".to_string(), "-c".to_string(), script.to_string()],
3715 ..Default::default()
3716 },
3717 );
3718 }
3719 config
3720 }
3721
3722 #[cfg(unix)]
3723 fn format_staged(tools: &[&str], mode: FormatMode, on_error: OnError) -> Result<FormatOutput, ProcessorError> {
3724 let config = staged_config(tools, mode, on_error);
3725 CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format("```text\nhello\n```\n")
3726 }
3727
3728 #[cfg(unix)]
3729 fn staged_block(tools: &[&str], mode: FormatMode, on_error: OnError) -> String {
3730 let output = format_staged(tools, mode, on_error).expect("format runs");
3731 output
3732 .content
3733 .strip_prefix("```text\n")
3734 .and_then(|rest| rest.strip_suffix("```\n"))
3735 .unwrap_or_else(|| panic!("fence kept: {:?}", output.content))
3736 .to_string()
3737 }
3738
3739 #[cfg(unix)]
3742 #[test]
3743 fn a_fallback_format_list_stops_at_the_first_success() {
3744 assert_eq!(LanguageToolConfig::default().format_mode, FormatMode::Fallback);
3745 for (tools, expected) in [
3746 (&["upper", "exclaim"][..], "HELLO\n"),
3747 (&["identity", "upper"][..], "hello\n"),
3748 (&["fails", "upper", "exclaim"][..], "HELLO\n"),
3749 (&["empty", "upper", "exclaim"][..], "HELLO\n"),
3750 ] {
3751 assert_eq!(
3752 staged_block(tools, FormatMode::Fallback, OnError::Skip),
3753 expected,
3754 "{tools:?}"
3755 );
3756 }
3757 }
3758
3759 #[cfg(unix)]
3762 #[test]
3763 fn a_pipeline_runs_every_formatter_on_the_last_output() {
3764 for (tools, expected) in [
3765 (&["upper", "exclaim"][..], "HELLO!\n"),
3766 (&["identity", "upper"][..], "HELLO\n"),
3767 (&["prefix-a", "prefix-b"][..], "bahello\n"),
3768 (&["prefix-b", "prefix-a"][..], "abhello\n"),
3769 (&["identity"][..], "hello\n"),
3770 ] {
3771 assert_eq!(
3772 staged_block(tools, FormatMode::Pipeline, OnError::Fail),
3773 expected,
3774 "{tools:?}"
3775 );
3776 }
3777 }
3778
3779 #[cfg(unix)]
3783 #[test]
3784 fn a_failed_pipeline_stage_is_dropped_under_warn_and_skip() {
3785 for failing in ["fails", "empty"] {
3786 let tools = ["upper", failing, "exclaim"];
3787 let warned = format_staged(&tools, FormatMode::Pipeline, OnError::Warn).unwrap();
3788 assert_eq!(warned.content, "```text\nHELLO!\n```\n", "{tools:?}");
3789 assert_eq!(warned.error_messages.len(), 1, "{:?}", warned.error_messages);
3790 assert!(warned.error_messages[0].starts_with("line 1 (text): "));
3791
3792 let skipped = format_staged(&tools, FormatMode::Pipeline, OnError::Skip).unwrap();
3793 assert_eq!(skipped.content, "```text\nHELLO!\n```\n", "{tools:?}");
3794 assert!(skipped.error_messages.is_empty(), "{:?}", skipped.error_messages);
3795 }
3796 assert_eq!(
3798 staged_block(&["fails", "empty"], FormatMode::Pipeline, OnError::Skip),
3799 "hello\n"
3800 );
3801 }
3802
3803 #[cfg(unix)]
3806 #[test]
3807 fn a_failed_pipeline_stage_stops_the_run_under_fail() {
3808 for tools in [&["upper", "fails", "exclaim"][..], &["upper", "empty"][..]] {
3809 let result = format_staged(tools, FormatMode::Pipeline, OnError::Fail);
3810 assert!(
3811 matches!(result, Err(ProcessorError::ToolErrorAt { line: 1, .. })),
3812 "{tools:?}: {result:?}"
3813 );
3814 }
3815 }
3816
3817 #[cfg(unix)]
3820 #[test]
3821 fn a_pipeline_stage_with_a_missing_binary_follows_on_missing_tool_binary() {
3822 use super::super::config::ToolDefinition;
3823
3824 let mut config = staged_config(&["upper", "gone", "exclaim"], FormatMode::Pipeline, OnError::Fail);
3825 config.tools.insert(
3826 "gone".to_string(),
3827 ToolDefinition {
3828 command: vec!["rumdl-test-nonexistent-formatter".to_string()],
3829 ..Default::default()
3830 },
3831 );
3832 let content = "```text\nhello\n```\n";
3833
3834 config.on_missing_tool_binary = OnMissing::Ignore;
3835 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
3836 .format(content)
3837 .unwrap();
3838 assert_eq!(output.content, "```text\nHELLO!\n```\n");
3839
3840 config.on_missing_tool_binary = OnMissing::Fail;
3841 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
3842 .format(content)
3843 .unwrap();
3844 assert_eq!(output.content, "```text\nHELLO!\n```\n");
3845 assert_eq!(output.failures.len(), 1, "{:?}", output.failures);
3846
3847 config.on_missing_tool_binary = OnMissing::FailFast;
3848 let result = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format(content);
3849 assert!(
3850 matches!(result, Err(ProcessorError::ToolBinaryNotFound { .. })),
3851 "{result:?}"
3852 );
3853 }
3854
3855 #[cfg(unix)]
3858 #[test]
3859 fn a_pipeline_keeps_the_block_line_ending_between_stages() {
3860 use super::super::config::ToolDefinition;
3861
3862 let mut config = staged_config(&["strip-newline", "probe"], FormatMode::Pipeline, OnError::Fail);
3863 for (id, script) in [
3864 ("strip-newline", "printf '%s' \"$(cat)\""),
3865 (
3866 "probe",
3867 "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi",
3868 ),
3869 ] {
3870 config.tools.insert(
3871 id.to_string(),
3872 ToolDefinition {
3873 command: vec!["sh".to_string(), "-c".to_string(), script.to_string()],
3874 ..Default::default()
3875 },
3876 );
3877 }
3878 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
3879 .format("```text\nhello\n```\n")
3880 .unwrap();
3881 assert_eq!(output.content, "```text\nHAD_NEWLINE\n```\n");
3882 }
3883
3884 #[test]
3885 fn test_ensure_trailing_newline() {
3886 assert_eq!(ensure_trailing_newline("foo: bar"), "foo: bar\n");
3888 assert_eq!(ensure_trailing_newline("a: 1\nb: 2"), "a: 1\nb: 2\n");
3890 assert_eq!(ensure_trailing_newline("foo: bar\n"), "foo: bar\n");
3892 assert_eq!(ensure_trailing_newline("foo: bar\n\n"), "foo: bar\n\n");
3894 assert_eq!(ensure_trailing_newline(""), "");
3896 }
3897}