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