1#[cfg(test)]
7use super::config::LanguageToolConfig;
8use super::config::{CodeBlockToolsConfig, NormalizeLanguage, OnError, OnMissing};
9use super::executor::{ExecutorError, ToolExecutor, ToolOutput};
10use super::linguist::LinguistResolver;
11use super::registry::ToolRegistry;
12use crate::config::MarkdownFlavor;
13use crate::rule::{LintWarning, Severity};
14use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
15
16pub const RUMDL_BUILTIN_TOOL: &str = "rumdl";
20
21fn is_markdown_language(lang: &str) -> bool {
23 matches!(lang.to_lowercase().as_str(), "markdown" | "md")
24}
25
26fn strip_ansi_codes(s: &str) -> String {
31 let mut result = String::with_capacity(s.len());
32 let mut chars = s.chars().peekable();
33 while let Some(c) = chars.next() {
34 if c == '\x1b' {
35 if chars.peek() == Some(&'[') {
36 chars.next();
37 while let Some(&next) = chars.peek() {
39 chars.next();
40 if next.is_ascii_alphabetic() {
41 break;
42 }
43 }
44 }
45 } else {
46 result.push(c);
47 }
48 }
49 result
50}
51
52#[derive(Debug, Clone)]
54pub struct FencedCodeBlockInfo {
55 pub start_line: usize,
57 pub end_line: usize,
59 pub content_start: usize,
61 pub content_end: usize,
63 pub language: String,
65 pub info_string: String,
67 pub fence_char: char,
69 pub fence_length: usize,
71 pub indent: usize,
73 pub indent_prefix: String,
75}
76
77#[derive(Debug, Clone)]
79pub struct CodeBlockDiagnostic {
80 pub file_line: usize,
82 pub column: Option<usize>,
84 pub message: String,
86 pub severity: DiagnosticSeverity,
88 pub tool: String,
90 pub code_block_start: usize,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum DiagnosticSeverity {
97 Error,
98 Warning,
99 Info,
100}
101
102impl CodeBlockDiagnostic {
103 pub fn to_lint_warning(&self) -> LintWarning {
105 let severity = match self.severity {
106 DiagnosticSeverity::Error => Severity::Error,
107 DiagnosticSeverity::Warning => Severity::Warning,
108 DiagnosticSeverity::Info => Severity::Info,
109 };
110
111 LintWarning {
112 message: self.message.clone(),
113 line: self.file_line,
114 column: self.column.unwrap_or(1),
115 end_line: self.file_line,
116 end_column: self.column.unwrap_or(1),
117 severity,
118 fix: None, rule_name: Some(self.tool.clone()),
120 }
121 }
122}
123
124#[derive(Debug, Clone)]
126pub enum ProcessorError {
127 ToolError(ExecutorError),
129 NoToolsConfigured { language: String },
131 ToolBinaryNotFound { tool: String, language: String },
133 Aborted { message: String },
135}
136
137impl std::fmt::Display for ProcessorError {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 match self {
140 Self::ToolError(e) => write!(f, "{e}"),
141 Self::NoToolsConfigured { language } => {
142 write!(f, "No tools configured for language '{language}'")
143 }
144 Self::ToolBinaryNotFound { tool, language } => {
145 write!(f, "Tool '{tool}' binary not found for language '{language}'")
146 }
147 Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
148 }
149 }
150}
151
152impl std::error::Error for ProcessorError {}
153
154impl From<ExecutorError> for ProcessorError {
155 fn from(e: ExecutorError) -> Self {
156 Self::ToolError(e)
157 }
158}
159
160#[derive(Debug)]
162pub struct CodeBlockResult {
163 pub diagnostics: Vec<CodeBlockDiagnostic>,
165 pub formatted_content: Option<String>,
167 pub was_modified: bool,
169}
170
171#[derive(Debug)]
173pub struct FormatOutput {
174 pub content: String,
176 pub had_errors: bool,
178 pub error_messages: Vec<String>,
180}
181
182pub struct CodeBlockToolProcessor<'a> {
184 config: &'a CodeBlockToolsConfig,
185 flavor: MarkdownFlavor,
186 linguist: LinguistResolver,
187 registry: ToolRegistry,
188 executor: ToolExecutor,
189 user_aliases: std::collections::HashMap<String, String>,
190}
191
192impl<'a> CodeBlockToolProcessor<'a> {
193 pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
195 let user_aliases = config
196 .language_aliases
197 .iter()
198 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
199 .collect();
200 Self {
201 config,
202 flavor,
203 linguist: LinguistResolver::new(),
204 registry: ToolRegistry::new(config.tools.clone()),
205 executor: ToolExecutor::new(config.timeout),
206 user_aliases,
207 }
208 }
209
210 pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
212 let mut blocks = Vec::new();
213 let mut current_block: Option<FencedCodeBlockBuilder> = None;
214
215 let options = Options::all();
216 let parser = Parser::new_ext(content, options).into_offset_iter();
217
218 let lines: Vec<&str> = content.lines().collect();
219
220 for (event, range) in parser {
221 match event {
222 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
223 let info_string = info.to_string();
224 let language = info_string.split_whitespace().next().unwrap_or("").to_string();
225
226 let start_line = content[..range.start].chars().filter(|&c| c == '\n').count();
228
229 let content_start = content[range.start..]
231 .find('\n')
232 .map(|i| range.start + i + 1)
233 .unwrap_or(content.len());
234
235 let fence_line = lines.get(start_line).unwrap_or(&"");
237 let trimmed = fence_line.trim_start();
238 let indent = fence_line.len() - trimmed.len();
239 let indent_prefix = fence_line.get(..indent).unwrap_or("").to_string();
240 let (fence_char, fence_length) = if trimmed.starts_with('~') {
241 ('~', trimmed.chars().take_while(|&c| c == '~').count())
242 } else {
243 ('`', trimmed.chars().take_while(|&c| c == '`').count())
244 };
245
246 current_block = Some(FencedCodeBlockBuilder {
247 start_line,
248 content_start,
249 language,
250 info_string,
251 fence_char,
252 fence_length,
253 indent,
254 indent_prefix,
255 });
256 }
257 Event::End(TagEnd::CodeBlock) => {
258 if let Some(builder) = current_block.take() {
259 let end_line = content[..range.end].chars().filter(|&c| c == '\n').count();
261
262 let search_start = builder.content_start.min(range.end);
264 let content_end = if search_start < range.end {
265 content[search_start..range.end]
266 .rfind('\n')
267 .map(|i| search_start + i)
268 .unwrap_or(search_start)
269 } else {
270 search_start
271 };
272
273 if content_end >= builder.content_start {
274 blocks.push(FencedCodeBlockInfo {
275 start_line: builder.start_line,
276 end_line,
277 content_start: builder.content_start,
278 content_end,
279 language: builder.language,
280 info_string: builder.info_string,
281 fence_char: builder.fence_char,
282 fence_length: builder.fence_length,
283 indent: builder.indent,
284 indent_prefix: builder.indent_prefix,
285 });
286 }
287 }
288 }
289 _ => {}
290 }
291 }
292
293 if self.flavor == MarkdownFlavor::MkDocs {
295 let mkdocs_blocks = self.extract_mkdocs_code_blocks(content);
296 for mb in mkdocs_blocks {
297 if !blocks.iter().any(|b| b.start_line == mb.start_line) {
299 blocks.push(mb);
300 }
301 }
302 blocks.sort_by_key(|b| b.start_line);
303 }
304
305 blocks
306 }
307
308 fn extract_mkdocs_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
314 use crate::utils::mkdocs_admonitions;
315 use crate::utils::mkdocs_tabs;
316
317 let mut blocks = Vec::new();
318 let lines: Vec<&str> = content.lines().collect();
319
320 let mut context_indent_stack: Vec<usize> = Vec::new();
323
324 let mut in_fence = false;
326 let mut fence_start_line: usize = 0;
327 let mut fence_content_start: usize = 0;
328 let mut fence_char: char = '`';
329 let mut fence_length: usize = 0;
330 let mut fence_indent: usize = 0;
331 let mut fence_indent_prefix = String::new();
332 let mut fence_language = String::new();
333 let mut fence_info_string = String::new();
334
335 let content_start_ptr = content.as_ptr() as usize;
340 let line_offsets: Vec<usize> = lines
341 .iter()
342 .map(|line| line.as_ptr() as usize - content_start_ptr)
343 .collect();
344
345 for (i, line) in lines.iter().enumerate() {
346 let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
347 let is_admonition = mkdocs_admonitions::is_admonition_start(line);
348 let is_tab = mkdocs_tabs::is_tab_marker(line);
349
350 if !line.trim().is_empty() {
354 while let Some(&ctx_indent) = context_indent_stack.last() {
355 if line_indent < ctx_indent + 4 {
356 context_indent_stack.pop();
357 if in_fence {
358 in_fence = false;
359 }
360 } else {
361 break;
362 }
363 }
364 }
365
366 if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
368 context_indent_stack.push(indent);
369 continue;
370 }
371
372 if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
374 context_indent_stack.push(indent);
375 continue;
376 }
377
378 if context_indent_stack.is_empty() {
380 continue;
381 }
382
383 let trimmed = line.trim_start();
384 let leading_spaces = line.len() - trimmed.len();
385
386 if !in_fence {
387 let (fc, fl) = if trimmed.starts_with("```") {
389 ('`', trimmed.chars().take_while(|&c| c == '`').count())
390 } else if trimmed.starts_with("~~~") {
391 ('~', trimmed.chars().take_while(|&c| c == '~').count())
392 } else {
393 continue;
394 };
395
396 if fl >= 3 {
397 in_fence = true;
398 fence_start_line = i;
399 fence_char = fc;
400 fence_length = fl;
401 fence_indent = leading_spaces;
402 fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
403
404 let after_fence = &trimmed[fl..];
405 fence_info_string = after_fence.trim().to_string();
406 fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
407
408 fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
410 }
411 } else {
412 let is_closing = if fence_char == '`' {
414 trimmed.starts_with("```")
415 && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
416 && trimmed.trim_start_matches('`').trim().is_empty()
417 } else {
418 trimmed.starts_with("~~~")
419 && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
420 && trimmed.trim_start_matches('~').trim().is_empty()
421 };
422
423 if is_closing {
424 let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
425
426 if content_end >= fence_content_start {
427 blocks.push(FencedCodeBlockInfo {
428 start_line: fence_start_line,
429 end_line: i,
430 content_start: fence_content_start,
431 content_end,
432 language: fence_language.clone(),
433 info_string: fence_info_string.clone(),
434 fence_char,
435 fence_length,
436 indent: fence_indent,
437 indent_prefix: fence_indent_prefix.clone(),
438 });
439 }
440
441 in_fence = false;
442 }
443 }
444 }
445
446 blocks
447 }
448
449 fn resolve_language(&self, language: &str) -> String {
451 let lower = language.to_lowercase();
452 if let Some(mapped) = self.user_aliases.get(&lower) {
453 return mapped.clone();
454 }
455 match self.config.normalize_language {
456 NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
457 NormalizeLanguage::Exact => lower,
458 }
459 }
460
461 fn get_on_error(&self, language: &str) -> OnError {
463 self.config
464 .languages
465 .get(language)
466 .and_then(|lc| lc.on_error)
467 .unwrap_or(self.config.on_error)
468 }
469
470 fn strip_indent_from_block(&self, content: &str, indent_prefix: &str) -> String {
472 if indent_prefix.is_empty() {
473 return content.to_string();
474 }
475
476 let mut out = String::with_capacity(content.len());
477 for line in content.split_inclusive('\n') {
478 if let Some(stripped) = line.strip_prefix(indent_prefix) {
479 out.push_str(stripped);
480 } else {
481 out.push_str(line);
482 }
483 }
484 out
485 }
486
487 fn apply_indent_to_block(&self, content: &str, indent_prefix: &str) -> String {
489 if indent_prefix.is_empty() {
490 return content.to_string();
491 }
492 if content.is_empty() {
493 return String::new();
494 }
495
496 let mut out = String::with_capacity(content.len() + indent_prefix.len());
497 for line in content.split_inclusive('\n') {
498 if line == "\n" {
499 out.push_str(line);
500 } else {
501 out.push_str(indent_prefix);
502 out.push_str(line);
503 }
504 }
505 out
506 }
507
508 pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
512 let mut all_diagnostics = Vec::new();
513 let blocks = self.extract_code_blocks(content);
514
515 for block in blocks {
516 if block.language.is_empty() {
517 continue; }
519
520 let canonical_lang = self.resolve_language(&block.language);
521
522 let lang_config = self.config.languages.get(&canonical_lang);
524
525 if let Some(lc) = lang_config
527 && !lc.enabled
528 {
529 continue;
530 }
531
532 let lint_tools = match lang_config {
533 Some(lc) if !lc.lint.is_empty() => &lc.lint,
534 _ => {
535 match self.config.on_missing_language_definition {
537 OnMissing::Ignore => continue,
538 OnMissing::Fail => {
539 all_diagnostics.push(CodeBlockDiagnostic {
540 file_line: block.start_line + 1,
541 column: None,
542 message: format!("No lint tools configured for language '{canonical_lang}'"),
543 severity: DiagnosticSeverity::Error,
544 tool: "code-block-tools".to_string(),
545 code_block_start: block.start_line + 1,
546 });
547 continue;
548 }
549 OnMissing::FailFast => {
550 return Err(ProcessorError::NoToolsConfigured {
551 language: canonical_lang,
552 });
553 }
554 }
555 }
556 };
557
558 let code_content_raw = if block.content_start < block.content_end && block.content_end <= content.len() {
560 &content[block.content_start..block.content_end]
561 } else {
562 continue;
563 };
564 let code_content = self.strip_indent_from_block(code_content_raw, &block.indent_prefix);
565
566 for tool_id in lint_tools {
568 if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
570 continue;
571 }
572
573 let tool_def = match self.registry.get(tool_id) {
574 Some(t) => t,
575 None => {
576 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
577 continue;
578 }
579 };
580
581 let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
583 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
584 match self.config.on_missing_tool_binary {
585 OnMissing::Ignore => {
586 log::debug!("Tool binary '{tool_name}' not found, skipping");
587 continue;
588 }
589 OnMissing::Fail => {
590 all_diagnostics.push(CodeBlockDiagnostic {
591 file_line: block.start_line + 1,
592 column: None,
593 message: format!("Tool binary '{tool_name}' not found in PATH"),
594 severity: DiagnosticSeverity::Error,
595 tool: "code-block-tools".to_string(),
596 code_block_start: block.start_line + 1,
597 });
598 continue;
599 }
600 OnMissing::FailFast => {
601 return Err(ProcessorError::ToolBinaryNotFound {
602 tool: tool_name.to_string(),
603 language: canonical_lang.clone(),
604 });
605 }
606 }
607 }
608
609 match self.executor.lint(tool_def, &code_content, Some(self.config.timeout)) {
610 Ok(output) => {
611 let diagnostics = self.parse_tool_output(
613 &output,
614 tool_id,
615 block.start_line + 1, );
617 all_diagnostics.extend(diagnostics);
618 }
619 Err(e) => {
620 let on_error = self.get_on_error(&canonical_lang);
621 match on_error {
622 OnError::Fail => return Err(e.into()),
623 OnError::Warn => {
624 log::warn!("Tool '{tool_id}' failed: {e}");
625 }
626 OnError::Skip => {
627 }
629 }
630 }
631 }
632 }
633 }
634
635 Ok(all_diagnostics)
636 }
637
638 pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
644 let blocks = self.extract_code_blocks(content);
645
646 if blocks.is_empty() {
647 return Ok(FormatOutput {
648 content: content.to_string(),
649 had_errors: false,
650 error_messages: Vec::new(),
651 });
652 }
653
654 let mut result = content.to_string();
656 let mut error_messages: Vec<String> = Vec::new();
657
658 for block in blocks.into_iter().rev() {
659 if block.language.is_empty() {
660 continue;
661 }
662
663 let canonical_lang = self.resolve_language(&block.language);
664
665 let lang_config = self.config.languages.get(&canonical_lang);
667
668 if let Some(lc) = lang_config
670 && !lc.enabled
671 {
672 continue;
673 }
674
675 let format_tools = match lang_config {
676 Some(lc) if !lc.format.is_empty() => &lc.format,
677 _ => {
678 match self.config.on_missing_language_definition {
680 OnMissing::Ignore => continue,
681 OnMissing::Fail => {
682 error_messages.push(format!(
683 "No format tools configured for language '{canonical_lang}' at line {}",
684 block.start_line + 1
685 ));
686 continue;
687 }
688 OnMissing::FailFast => {
689 return Err(ProcessorError::NoToolsConfigured {
690 language: canonical_lang,
691 });
692 }
693 }
694 }
695 };
696
697 if block.content_start >= block.content_end || block.content_end > result.len() {
699 continue;
700 }
701 let code_content_raw = result[block.content_start..block.content_end].to_string();
702 let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
703
704 let mut formatted = code_content.clone();
706 let mut tool_ran = false;
707 for tool_id in format_tools {
708 if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
710 continue;
711 }
712
713 let tool_def = match self.registry.get(tool_id) {
714 Some(t) => t,
715 None => {
716 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
717 continue;
718 }
719 };
720
721 let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
723 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
724 match self.config.on_missing_tool_binary {
725 OnMissing::Ignore => {
726 log::debug!("Tool binary '{tool_name}' not found, skipping");
727 continue;
728 }
729 OnMissing::Fail => {
730 error_messages.push(format!(
731 "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
732 block.start_line + 1
733 ));
734 continue;
735 }
736 OnMissing::FailFast => {
737 return Err(ProcessorError::ToolBinaryNotFound {
738 tool: tool_name.to_string(),
739 language: canonical_lang.clone(),
740 });
741 }
742 }
743 }
744
745 match self.executor.format(tool_def, &formatted, Some(self.config.timeout)) {
746 Ok(output) => {
747 formatted = output;
749 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
750 formatted.push('\n');
751 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
752 formatted.pop();
753 }
754 tool_ran = true;
755 break; }
757 Err(e) => {
758 let on_error = self.get_on_error(&canonical_lang);
759 match on_error {
760 OnError::Fail => return Err(e.into()),
761 OnError::Warn => {
762 log::warn!("Formatter '{tool_id}' failed: {e}");
763 }
764 OnError::Skip => {}
765 }
766 }
767 }
768 }
769
770 if tool_ran && formatted != code_content {
772 let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
773 if reindented != code_content_raw {
774 result.replace_range(block.content_start..block.content_end, &reindented);
775 }
776 }
777 }
778
779 Ok(FormatOutput {
780 content: result,
781 had_errors: !error_messages.is_empty(),
782 error_messages,
783 })
784 }
785
786 fn parse_tool_output(
791 &self,
792 output: &ToolOutput,
793 tool_id: &str,
794 code_block_start_line: usize,
795 ) -> Vec<CodeBlockDiagnostic> {
796 let mut diagnostics = Vec::new();
797 let mut shellcheck_line: Option<usize> = None;
798
799 let stdout_clean = strip_ansi_codes(&output.stdout);
801 let stderr_clean = strip_ansi_codes(&output.stderr);
802 let combined = format!("{stdout_clean}\n{stderr_clean}");
803
804 let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
806
807 for line in combined.lines() {
808 let line = line.trim();
809 if line.is_empty() {
810 continue;
811 }
812
813 if let Some((ref msg, severity)) = pending_error {
815 if let Some((line_num, col)) = Self::parse_at_line_column(line) {
816 diagnostics.push(CodeBlockDiagnostic {
817 file_line: code_block_start_line + line_num,
818 column: Some(col),
819 message: msg.clone(),
820 severity,
821 tool: tool_id.to_string(),
822 code_block_start: code_block_start_line,
823 });
824 pending_error = None;
825 continue;
826 }
827 diagnostics.push(CodeBlockDiagnostic {
829 file_line: code_block_start_line,
830 column: None,
831 message: msg.clone(),
832 severity,
833 tool: tool_id.to_string(),
834 code_block_start: code_block_start_line,
835 });
836 pending_error = None;
837 }
839
840 if let Some(line_num) = self.parse_shellcheck_header(line) {
841 shellcheck_line = Some(line_num);
842 continue;
843 }
844
845 if let Some(line_num) = shellcheck_line
846 && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
847 {
848 diagnostics.push(diag);
849 continue;
850 }
851
852 if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
854 diagnostics.push(diag);
855 continue;
856 }
857
858 if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
860 diagnostics.push(diag);
861 continue;
862 }
863
864 if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
866 diagnostics.push(diag);
867 continue;
868 }
869
870 if let Some(error_info) = Self::parse_error_line(line) {
872 pending_error = Some(error_info);
873 }
874 }
875
876 if let Some((msg, severity)) = pending_error {
878 diagnostics.push(CodeBlockDiagnostic {
879 file_line: code_block_start_line,
880 column: None,
881 message: msg,
882 severity,
883 tool: tool_id.to_string(),
884 code_block_start: code_block_start_line,
885 });
886 }
887
888 if diagnostics.is_empty() && !output.success {
890 let lines: Vec<&str> = combined.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
891
892 if lines.is_empty() {
893 let exit_code = output.exit_code;
894 diagnostics.push(CodeBlockDiagnostic {
895 file_line: code_block_start_line,
896 column: None,
897 message: format!("Tool exited with code {exit_code}"),
898 severity: DiagnosticSeverity::Error,
899 tool: tool_id.to_string(),
900 code_block_start: code_block_start_line,
901 });
902 } else {
903 for line_text in lines {
904 diagnostics.push(CodeBlockDiagnostic {
905 file_line: code_block_start_line,
906 column: None,
907 message: line_text.to_string(),
908 severity: DiagnosticSeverity::Error,
909 tool: tool_id.to_string(),
910 code_block_start: code_block_start_line,
911 });
912 }
913 }
914 }
915
916 diagnostics
917 }
918
919 fn parse_standard_format(
921 &self,
922 line: &str,
923 tool_id: &str,
924 code_block_start_line: usize,
925 ) -> Option<CodeBlockDiagnostic> {
926 let mut parts = line.rsplitn(4, ':');
928 let message = parts.next()?.trim().to_string();
929 let part1 = parts.next()?.trim().to_string();
930 let part2 = parts.next()?.trim().to_string();
931 let part3 = parts.next().map(|s| s.trim().to_string());
932
933 let (line_part, col_part) = if part3.is_some() {
934 (part2, Some(part1))
935 } else {
936 (part1, None)
937 };
938
939 if let Ok(line_num) = line_part.parse::<usize>() {
940 let column = col_part.and_then(|s| s.parse::<usize>().ok());
941 let message = Self::strip_fixable_markers(&message);
942 if !message.is_empty() {
943 let severity = self.infer_severity(&message);
944 return Some(CodeBlockDiagnostic {
945 file_line: code_block_start_line + line_num,
946 column,
947 message,
948 severity,
949 tool: tool_id.to_string(),
950 code_block_start: code_block_start_line,
951 });
952 }
953 }
954 None
955 }
956
957 fn parse_eslint_format(
959 &self,
960 line: &str,
961 tool_id: &str,
962 code_block_start_line: usize,
963 ) -> Option<CodeBlockDiagnostic> {
964 let parts: Vec<&str> = line.splitn(3, ' ').collect();
966 if parts.len() >= 2 {
967 let loc_parts: Vec<&str> = parts[0].split(':').collect();
968 if loc_parts.len() == 2
969 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
970 {
971 let (sev_part, msg_part) = if parts.len() >= 3 {
972 (parts[1], parts[2])
973 } else {
974 (parts[1], "")
975 };
976 let message = if msg_part.is_empty() {
977 sev_part.to_string()
978 } else {
979 msg_part.to_string()
980 };
981 let message = Self::strip_fixable_markers(&message);
982 let severity = match sev_part.to_lowercase().as_str() {
983 "error" => DiagnosticSeverity::Error,
984 "warning" | "warn" => DiagnosticSeverity::Warning,
985 "info" => DiagnosticSeverity::Info,
986 _ => self.infer_severity(&message),
987 };
988 return Some(CodeBlockDiagnostic {
989 file_line: code_block_start_line + line_num,
990 column: Some(col),
991 message,
992 severity,
993 tool: tool_id.to_string(),
994 code_block_start: code_block_start_line,
995 });
996 }
997 }
998 None
999 }
1000
1001 fn parse_shellcheck_format(
1003 &self,
1004 line: &str,
1005 tool_id: &str,
1006 code_block_start_line: usize,
1007 ) -> Option<CodeBlockDiagnostic> {
1008 if line.starts_with("In ")
1010 && line.contains(" line ")
1011 && let Some(line_start) = line.find(" line ")
1012 {
1013 let after_line = &line[line_start + 6..];
1014 if let Some(colon_pos) = after_line.find(':')
1015 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1016 {
1017 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
1018 if !message.is_empty() {
1019 let severity = self.infer_severity(&message);
1020 return Some(CodeBlockDiagnostic {
1021 file_line: code_block_start_line + line_num,
1022 column: None,
1023 message,
1024 severity,
1025 tool: tool_id.to_string(),
1026 code_block_start: code_block_start_line,
1027 });
1028 }
1029 }
1030 }
1031 None
1032 }
1033
1034 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
1036 if line.starts_with("In ")
1037 && line.contains(" line ")
1038 && let Some(line_start) = line.find(" line ")
1039 {
1040 let after_line = &line[line_start + 6..];
1041 if let Some(colon_pos) = after_line.find(':') {
1042 return after_line[..colon_pos].trim().parse::<usize>().ok();
1043 }
1044 }
1045 None
1046 }
1047
1048 fn parse_shellcheck_message(
1050 &self,
1051 line: &str,
1052 tool_id: &str,
1053 code_block_start_line: usize,
1054 line_num: usize,
1055 ) -> Option<CodeBlockDiagnostic> {
1056 let sc_pos = line.find("SC")?;
1057 let after_sc = &line[sc_pos + 2..];
1058 let code_len = after_sc.chars().take_while(|c| c.is_ascii_digit()).count();
1059 if code_len == 0 {
1060 return None;
1061 }
1062 let after_code = &after_sc[code_len..];
1063 let sev_start = after_code.find('(')? + 1;
1064 let sev_end = after_code[sev_start..].find(')')? + sev_start;
1065 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1066 let message_start = after_code.find("):")? + 2;
1067 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1068 if message.is_empty() {
1069 return None;
1070 }
1071
1072 let severity = match sev.as_str() {
1073 "error" => DiagnosticSeverity::Error,
1074 "warning" | "warn" => DiagnosticSeverity::Warning,
1075 "info" | "style" => DiagnosticSeverity::Info,
1076 _ => self.infer_severity(&message),
1077 };
1078
1079 Some(CodeBlockDiagnostic {
1080 file_line: code_block_start_line + line_num,
1081 column: None,
1082 message,
1083 severity,
1084 tool: tool_id.to_string(),
1085 code_block_start: code_block_start_line,
1086 })
1087 }
1088
1089 fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
1095 let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
1096 (msg, DiagnosticSeverity::Error)
1097 } else if let Some(msg) = line.strip_prefix("Warning:") {
1098 (msg, DiagnosticSeverity::Warning)
1099 } else {
1100 return None;
1101 };
1102 let msg = msg.trim();
1103 if msg.is_empty() {
1104 return None;
1105 }
1106 Some((msg.to_string(), severity))
1107 }
1108
1109 fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
1113 let lower = line.to_lowercase();
1114 let rest = lower.strip_prefix("at line ")?;
1115 let mut parts = rest.split_whitespace();
1116 let line_num: usize = parts.next()?.parse().ok()?;
1117 if parts.next()? != "column" {
1118 return None;
1119 }
1120 let col: usize = parts.next()?.parse().ok()?;
1121 Some((line_num, col))
1122 }
1123
1124 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1126 let lower = message.to_lowercase();
1127 if lower.contains("error")
1128 || lower.starts_with("e") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1129 || lower.starts_with("f") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1130 {
1131 DiagnosticSeverity::Error
1132 } else if lower.contains("warning")
1133 || lower.contains("warn")
1134 || lower.starts_with("w") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1135 {
1136 DiagnosticSeverity::Warning
1137 } else {
1138 DiagnosticSeverity::Info
1139 }
1140 }
1141
1142 fn strip_fixable_markers(message: &str) -> String {
1149 message
1150 .replace(" [*]", "")
1151 .replace("[*] ", "")
1152 .replace("[*]", "")
1153 .replace(" (fixable)", "")
1154 .replace("(fixable) ", "")
1155 .replace("(fixable)", "")
1156 .replace(" [fix available]", "")
1157 .replace("[fix available] ", "")
1158 .replace("[fix available]", "")
1159 .replace(" [autofix]", "")
1160 .replace("[autofix] ", "")
1161 .replace("[autofix]", "")
1162 .trim()
1163 .to_string()
1164 }
1165}
1166
1167struct FencedCodeBlockBuilder {
1169 start_line: usize,
1170 content_start: usize,
1171 language: String,
1172 info_string: String,
1173 fence_char: char,
1174 fence_length: usize,
1175 indent: usize,
1176 indent_prefix: String,
1177}
1178
1179#[cfg(test)]
1180mod tests {
1181 use super::*;
1182
1183 fn default_config() -> CodeBlockToolsConfig {
1184 CodeBlockToolsConfig::default()
1185 }
1186
1187 #[test]
1188 fn test_extract_code_blocks() {
1189 let config = default_config();
1190 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1191
1192 let content = r#"# Example
1193
1194```python
1195def hello():
1196 print("Hello")
1197```
1198
1199Some text
1200
1201```rust
1202fn main() {}
1203```
1204"#;
1205
1206 let blocks = processor.extract_code_blocks(content);
1207
1208 assert_eq!(blocks.len(), 2);
1209
1210 assert_eq!(blocks[0].language, "python");
1211 assert_eq!(blocks[0].fence_char, '`');
1212 assert_eq!(blocks[0].fence_length, 3);
1213 assert_eq!(blocks[0].start_line, 2);
1214 assert_eq!(blocks[0].indent, 0);
1215 assert_eq!(blocks[0].indent_prefix, "");
1216
1217 assert_eq!(blocks[1].language, "rust");
1218 assert_eq!(blocks[1].fence_char, '`');
1219 assert_eq!(blocks[1].fence_length, 3);
1220 }
1221
1222 #[test]
1223 fn test_extract_code_blocks_with_info_string() {
1224 let config = default_config();
1225 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1226
1227 let content = "```python title=\"example.py\"\ncode\n```";
1228 let blocks = processor.extract_code_blocks(content);
1229
1230 assert_eq!(blocks.len(), 1);
1231 assert_eq!(blocks[0].language, "python");
1232 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1233 }
1234
1235 #[test]
1236 fn test_extract_code_blocks_tilde_fence() {
1237 let config = default_config();
1238 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1239
1240 let content = "~~~bash\necho hello\n~~~";
1241 let blocks = processor.extract_code_blocks(content);
1242
1243 assert_eq!(blocks.len(), 1);
1244 assert_eq!(blocks[0].language, "bash");
1245 assert_eq!(blocks[0].fence_char, '~');
1246 assert_eq!(blocks[0].fence_length, 3);
1247 assert_eq!(blocks[0].indent_prefix, "");
1248 }
1249
1250 #[test]
1251 fn test_extract_code_blocks_with_indent_prefix() {
1252 let config = default_config();
1253 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1254
1255 let content = " - item\n ```python\n print('hi')\n ```";
1256 let blocks = processor.extract_code_blocks(content);
1257
1258 assert_eq!(blocks.len(), 1);
1259 assert_eq!(blocks[0].indent_prefix, " ");
1260 }
1261
1262 #[test]
1263 fn test_extract_code_blocks_no_language() {
1264 let config = default_config();
1265 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1266
1267 let content = "```\nplain code\n```";
1268 let blocks = processor.extract_code_blocks(content);
1269
1270 assert_eq!(blocks.len(), 1);
1271 assert_eq!(blocks[0].language, "");
1272 }
1273
1274 #[test]
1275 fn test_resolve_language_linguist() {
1276 let mut config = default_config();
1277 config.normalize_language = NormalizeLanguage::Linguist;
1278 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1279
1280 assert_eq!(processor.resolve_language("py"), "python");
1281 assert_eq!(processor.resolve_language("bash"), "shell");
1282 assert_eq!(processor.resolve_language("js"), "javascript");
1283 }
1284
1285 #[test]
1286 fn test_resolve_language_exact() {
1287 let mut config = default_config();
1288 config.normalize_language = NormalizeLanguage::Exact;
1289 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1290
1291 assert_eq!(processor.resolve_language("py"), "py");
1292 assert_eq!(processor.resolve_language("BASH"), "bash");
1293 }
1294
1295 #[test]
1296 fn test_resolve_language_user_alias_override() {
1297 let mut config = default_config();
1298 config.language_aliases.insert("py".to_string(), "python".to_string());
1299 config.normalize_language = NormalizeLanguage::Exact;
1300 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1301
1302 assert_eq!(processor.resolve_language("PY"), "python");
1303 }
1304
1305 #[test]
1306 fn test_indent_strip_and_reapply_roundtrip() {
1307 let config = default_config();
1308 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1309
1310 let raw = " def hello():\n print('hi')";
1311 let stripped = processor.strip_indent_from_block(raw, " ");
1312 assert_eq!(stripped, "def hello():\n print('hi')");
1313
1314 let reapplied = processor.apply_indent_to_block(&stripped, " ");
1315 assert_eq!(reapplied, raw);
1316 }
1317
1318 #[test]
1319 fn test_infer_severity() {
1320 let config = default_config();
1321 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1322
1323 assert_eq!(
1324 processor.infer_severity("E501 line too long"),
1325 DiagnosticSeverity::Error
1326 );
1327 assert_eq!(
1328 processor.infer_severity("W291 trailing whitespace"),
1329 DiagnosticSeverity::Warning
1330 );
1331 assert_eq!(
1332 processor.infer_severity("error: something failed"),
1333 DiagnosticSeverity::Error
1334 );
1335 assert_eq!(
1336 processor.infer_severity("warning: unused variable"),
1337 DiagnosticSeverity::Warning
1338 );
1339 assert_eq!(
1340 processor.infer_severity("note: consider using"),
1341 DiagnosticSeverity::Info
1342 );
1343 }
1344
1345 #[test]
1346 fn test_parse_standard_format_windows_path() {
1347 let config = default_config();
1348 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1349
1350 let output = ToolOutput {
1351 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1352 stderr: String::new(),
1353 exit_code: 1,
1354 success: false,
1355 };
1356
1357 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1358 assert_eq!(diags.len(), 1);
1359 assert_eq!(diags[0].file_line, 12);
1360 assert_eq!(diags[0].column, Some(5));
1361 assert_eq!(diags[0].message, "E123 message");
1362 }
1363
1364 #[test]
1365 fn test_parse_eslint_severity() {
1366 let config = default_config();
1367 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1368
1369 let output = ToolOutput {
1370 stdout: "1:2 error Unexpected token".to_string(),
1371 stderr: String::new(),
1372 exit_code: 1,
1373 success: false,
1374 };
1375
1376 let diags = processor.parse_tool_output(&output, "eslint", 5);
1377 assert_eq!(diags.len(), 1);
1378 assert_eq!(diags[0].file_line, 6);
1379 assert_eq!(diags[0].column, Some(2));
1380 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1381 assert_eq!(diags[0].message, "Unexpected token");
1382 }
1383
1384 #[test]
1385 fn test_parse_shellcheck_multiline() {
1386 let config = default_config();
1387 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1388
1389 let output = ToolOutput {
1390 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1391 stderr: String::new(),
1392 exit_code: 1,
1393 success: false,
1394 };
1395
1396 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1397 assert_eq!(diags.len(), 1);
1398 assert_eq!(diags[0].file_line, 13);
1399 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1400 assert_eq!(diags[0].message, "Double quote to prevent globbing");
1401 }
1402
1403 #[test]
1404 fn test_lint_no_config() {
1405 let config = default_config();
1406 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1407
1408 let content = "```python\nprint('hello')\n```";
1409 let result = processor.lint(content);
1410
1411 assert!(result.is_ok());
1413 assert!(result.unwrap().is_empty());
1414 }
1415
1416 #[test]
1417 fn test_format_no_config() {
1418 let config = default_config();
1419 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1420
1421 let content = "```python\nprint('hello')\n```";
1422 let result = processor.format(content);
1423
1424 assert!(result.is_ok());
1426 let output = result.unwrap();
1427 assert_eq!(output.content, content);
1428 assert!(!output.had_errors);
1429 assert!(output.error_messages.is_empty());
1430 }
1431
1432 #[test]
1433 fn test_lint_on_missing_language_definition_fail() {
1434 let mut config = default_config();
1435 config.on_missing_language_definition = OnMissing::Fail;
1436 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1437
1438 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1439 let result = processor.lint(content);
1440
1441 assert!(result.is_ok());
1443 let diagnostics = result.unwrap();
1444 assert_eq!(diagnostics.len(), 2);
1445 assert!(diagnostics[0].message.contains("No lint tools configured"));
1446 assert!(diagnostics[0].message.contains("python"));
1447 assert!(diagnostics[1].message.contains("javascript"));
1448 }
1449
1450 #[test]
1451 fn test_lint_on_missing_language_definition_fail_fast() {
1452 let mut config = default_config();
1453 config.on_missing_language_definition = OnMissing::FailFast;
1454 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1455
1456 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1457 let result = processor.lint(content);
1458
1459 assert!(result.is_err());
1461 let err = result.unwrap_err();
1462 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1463 }
1464
1465 #[test]
1466 fn test_format_on_missing_language_definition_fail() {
1467 let mut config = default_config();
1468 config.on_missing_language_definition = OnMissing::Fail;
1469 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1470
1471 let content = "```python\nprint('hello')\n```";
1472 let result = processor.format(content);
1473
1474 assert!(result.is_ok());
1476 let output = result.unwrap();
1477 assert_eq!(output.content, content); assert!(output.had_errors);
1479 assert!(!output.error_messages.is_empty());
1480 assert!(output.error_messages[0].contains("No format tools configured"));
1481 }
1482
1483 #[test]
1484 fn test_format_on_missing_language_definition_fail_fast() {
1485 let mut config = default_config();
1486 config.on_missing_language_definition = OnMissing::FailFast;
1487 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1488
1489 let content = "```python\nprint('hello')\n```";
1490 let result = processor.format(content);
1491
1492 assert!(result.is_err());
1494 let err = result.unwrap_err();
1495 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1496 }
1497
1498 #[test]
1499 fn test_lint_on_missing_tool_binary_fail() {
1500 use super::super::config::{LanguageToolConfig, ToolDefinition};
1501
1502 let mut config = default_config();
1503 config.on_missing_tool_binary = OnMissing::Fail;
1504
1505 let lang_config = LanguageToolConfig {
1507 lint: vec!["nonexistent-linter".to_string()],
1508 ..Default::default()
1509 };
1510 config.languages.insert("python".to_string(), lang_config);
1511
1512 let tool_def = ToolDefinition {
1513 command: vec!["nonexistent-binary-xyz123".to_string()],
1514 ..Default::default()
1515 };
1516 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1517
1518 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1519
1520 let content = "```python\nprint('hello')\n```";
1521 let result = processor.lint(content);
1522
1523 assert!(result.is_ok());
1525 let diagnostics = result.unwrap();
1526 assert_eq!(diagnostics.len(), 1);
1527 assert!(diagnostics[0].message.contains("not found in PATH"));
1528 }
1529
1530 #[test]
1531 fn test_lint_on_missing_tool_binary_fail_fast() {
1532 use super::super::config::{LanguageToolConfig, ToolDefinition};
1533
1534 let mut config = default_config();
1535 config.on_missing_tool_binary = OnMissing::FailFast;
1536
1537 let lang_config = LanguageToolConfig {
1539 lint: vec!["nonexistent-linter".to_string()],
1540 ..Default::default()
1541 };
1542 config.languages.insert("python".to_string(), lang_config);
1543
1544 let tool_def = ToolDefinition {
1545 command: vec!["nonexistent-binary-xyz123".to_string()],
1546 ..Default::default()
1547 };
1548 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1549
1550 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1551
1552 let content = "```python\nprint('hello')\n```";
1553 let result = processor.lint(content);
1554
1555 assert!(result.is_err());
1557 let err = result.unwrap_err();
1558 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1559 }
1560
1561 #[test]
1562 fn test_format_on_missing_tool_binary_fail() {
1563 use super::super::config::{LanguageToolConfig, ToolDefinition};
1564
1565 let mut config = default_config();
1566 config.on_missing_tool_binary = OnMissing::Fail;
1567
1568 let lang_config = LanguageToolConfig {
1570 format: vec!["nonexistent-formatter".to_string()],
1571 ..Default::default()
1572 };
1573 config.languages.insert("python".to_string(), lang_config);
1574
1575 let tool_def = ToolDefinition {
1576 command: vec!["nonexistent-binary-xyz123".to_string()],
1577 ..Default::default()
1578 };
1579 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1580
1581 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1582
1583 let content = "```python\nprint('hello')\n```";
1584 let result = processor.format(content);
1585
1586 assert!(result.is_ok());
1588 let output = result.unwrap();
1589 assert_eq!(output.content, content); assert!(output.had_errors);
1591 assert!(!output.error_messages.is_empty());
1592 assert!(output.error_messages[0].contains("not found in PATH"));
1593 }
1594
1595 #[test]
1596 fn test_format_on_missing_tool_binary_fail_fast() {
1597 use super::super::config::{LanguageToolConfig, ToolDefinition};
1598
1599 let mut config = default_config();
1600 config.on_missing_tool_binary = OnMissing::FailFast;
1601
1602 let lang_config = LanguageToolConfig {
1604 format: vec!["nonexistent-formatter".to_string()],
1605 ..Default::default()
1606 };
1607 config.languages.insert("python".to_string(), lang_config);
1608
1609 let tool_def = ToolDefinition {
1610 command: vec!["nonexistent-binary-xyz123".to_string()],
1611 ..Default::default()
1612 };
1613 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1614
1615 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1616
1617 let content = "```python\nprint('hello')\n```";
1618 let result = processor.format(content);
1619
1620 assert!(result.is_err());
1622 let err = result.unwrap_err();
1623 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1624 }
1625
1626 #[test]
1627 fn test_lint_rumdl_builtin_skipped_for_markdown() {
1628 let mut config = default_config();
1631 config.languages.insert(
1632 "markdown".to_string(),
1633 LanguageToolConfig {
1634 lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
1635 ..Default::default()
1636 },
1637 );
1638 config.on_missing_language_definition = OnMissing::Fail;
1639 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1640
1641 let content = "```markdown\n# Hello\n```";
1642 let result = processor.lint(content);
1643
1644 assert!(result.is_ok());
1646 assert!(result.unwrap().is_empty());
1647 }
1648
1649 #[test]
1650 fn test_format_rumdl_builtin_skipped_for_markdown() {
1651 let mut config = default_config();
1653 config.languages.insert(
1654 "markdown".to_string(),
1655 LanguageToolConfig {
1656 format: vec![RUMDL_BUILTIN_TOOL.to_string()],
1657 ..Default::default()
1658 },
1659 );
1660 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1661
1662 let content = "```markdown\n# Hello\n```";
1663 let result = processor.format(content);
1664
1665 assert!(result.is_ok());
1667 let output = result.unwrap();
1668 assert_eq!(output.content, content);
1669 assert!(!output.had_errors);
1670 }
1671
1672 #[test]
1673 fn test_is_markdown_language() {
1674 assert!(is_markdown_language("markdown"));
1676 assert!(is_markdown_language("Markdown"));
1677 assert!(is_markdown_language("MARKDOWN"));
1678 assert!(is_markdown_language("md"));
1679 assert!(is_markdown_language("MD"));
1680 assert!(!is_markdown_language("python"));
1681 assert!(!is_markdown_language("rust"));
1682 assert!(!is_markdown_language(""));
1683 }
1684
1685 #[test]
1688 fn test_extract_mkdocs_admonition_code_block() {
1689 let config = default_config();
1690 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1691
1692 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
1693 let blocks = processor.extract_code_blocks(content);
1694
1695 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
1696 assert_eq!(blocks[0].language, "python");
1697 }
1698
1699 #[test]
1700 fn test_extract_mkdocs_tab_code_block() {
1701 let config = default_config();
1702 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1703
1704 let content = "=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n";
1705 let blocks = processor.extract_code_blocks(content);
1706
1707 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
1708 assert_eq!(blocks[0].language, "python");
1709 }
1710
1711 #[test]
1712 fn test_standard_flavor_ignores_admonition_indented_content() {
1713 let config = default_config();
1714 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1715
1716 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
1719 let blocks = processor.extract_code_blocks(content);
1720
1721 for (i, b) in blocks.iter().enumerate() {
1725 for (j, b2) in blocks.iter().enumerate() {
1726 if i != j {
1727 assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
1728 }
1729 }
1730 }
1731 }
1732
1733 #[test]
1734 fn test_mkdocs_top_level_blocks_alongside_admonition() {
1735 let config = default_config();
1736 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1737
1738 let content =
1739 "```rust\nfn main() {}\n```\n\n!!! note\n Some text\n\n ```python\n print(\"hello\")\n ```\n";
1740 let blocks = processor.extract_code_blocks(content);
1741
1742 assert_eq!(
1743 blocks.len(),
1744 2,
1745 "Should detect both top-level and admonition code blocks"
1746 );
1747 assert_eq!(blocks[0].language, "rust");
1748 assert_eq!(blocks[1].language, "python");
1749 }
1750
1751 #[test]
1752 fn test_mkdocs_nested_admonition_code_block() {
1753 let config = default_config();
1754 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1755
1756 let content = "\
1757!!! note
1758 Some text
1759
1760 !!! warning
1761 Nested content
1762
1763 ```python
1764 x = 1
1765 ```
1766";
1767 let blocks = processor.extract_code_blocks(content);
1768 assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
1769 assert_eq!(blocks[0].language, "python");
1770 }
1771
1772 #[test]
1773 fn test_mkdocs_consecutive_admonitions_no_stale_context() {
1774 let config = default_config();
1775 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1776
1777 let content = "\
1780!!! note
1781 First admonition content
1782
1783!!! warning
1784 Second admonition content
1785
1786 ```python
1787 y = 2
1788 ```
1789";
1790 let blocks = processor.extract_code_blocks(content);
1791 assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
1792 assert_eq!(blocks[0].language, "python");
1793 }
1794
1795 #[test]
1796 fn test_mkdocs_crlf_line_endings() {
1797 let config = default_config();
1798 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1799
1800 let content = "!!! note\r\n Some text\r\n\r\n ```python\r\n x = 1\r\n ```\r\n";
1802 let blocks = processor.extract_code_blocks(content);
1803
1804 assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
1805 assert_eq!(blocks[0].language, "python");
1806
1807 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1809 assert!(
1810 extracted.contains("x = 1"),
1811 "Extracted content should contain code. Got: {extracted:?}"
1812 );
1813 }
1814
1815 #[test]
1816 fn test_mkdocs_unclosed_fence_in_admonition() {
1817 let config = default_config();
1818 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1819
1820 let content = "!!! note\n ```python\n x = 1\n no closing fence\n";
1822 let blocks = processor.extract_code_blocks(content);
1823 assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
1824 }
1825
1826 #[test]
1827 fn test_mkdocs_tilde_fence_in_admonition() {
1828 let config = default_config();
1829 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1830
1831 let content = "!!! note\n ~~~ruby\n puts 'hi'\n ~~~\n";
1832 let blocks = processor.extract_code_blocks(content);
1833 assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
1834 assert_eq!(blocks[0].language, "ruby");
1835 }
1836
1837 #[test]
1838 fn test_mkdocs_empty_lines_in_code_block() {
1839 let config = default_config();
1840 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1841
1842 let content = "!!! note\n ```python\n x = 1\n\n y = 2\n ```\n";
1845 let blocks = processor.extract_code_blocks(content);
1846 assert_eq!(blocks.len(), 1);
1847
1848 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1849 assert!(
1850 extracted.contains("x = 1") && extracted.contains("y = 2"),
1851 "Extracted content should span across the empty line. Got: {extracted:?}"
1852 );
1853 }
1854
1855 #[test]
1856 fn test_mkdocs_content_byte_offsets_lf() {
1857 let config = default_config();
1858 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1859
1860 let content = "!!! note\n ```python\n print('hi')\n ```\n";
1861 let blocks = processor.extract_code_blocks(content);
1862 assert_eq!(blocks.len(), 1);
1863
1864 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1866 assert_eq!(extracted, " print('hi')\n", "Content offsets should be exact for LF");
1867 }
1868
1869 #[test]
1870 fn test_mkdocs_content_byte_offsets_crlf() {
1871 let config = default_config();
1872 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1873
1874 let content = "!!! note\r\n ```python\r\n print('hi')\r\n ```\r\n";
1875 let blocks = processor.extract_code_blocks(content);
1876 assert_eq!(blocks.len(), 1);
1877
1878 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1879 assert_eq!(
1880 extracted, " print('hi')\r\n",
1881 "Content offsets should be exact for CRLF"
1882 );
1883 }
1884
1885 #[test]
1886 fn test_lint_enabled_false_skips_language_in_strict_mode() {
1887 let mut config = default_config();
1890 config.normalize_language = NormalizeLanguage::Exact;
1891 config.on_missing_language_definition = OnMissing::Fail;
1892
1893 config.languages.insert(
1895 "python".to_string(),
1896 LanguageToolConfig {
1897 lint: vec!["ruff:check".to_string()],
1898 ..Default::default()
1899 },
1900 );
1901 config.languages.insert(
1902 "plaintext".to_string(),
1903 LanguageToolConfig {
1904 enabled: false,
1905 ..Default::default()
1906 },
1907 );
1908
1909 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1910
1911 let content = "```plaintext\nsome text\n```";
1912 let result = processor.lint(content);
1913
1914 assert!(result.is_ok());
1916 let diagnostics = result.unwrap();
1917 assert!(
1918 diagnostics.is_empty(),
1919 "Expected no diagnostics for disabled language, got: {diagnostics:?}"
1920 );
1921 }
1922
1923 #[test]
1924 fn test_format_enabled_false_skips_language_in_strict_mode() {
1925 let mut config = default_config();
1927 config.normalize_language = NormalizeLanguage::Exact;
1928 config.on_missing_language_definition = OnMissing::Fail;
1929
1930 config.languages.insert(
1931 "plaintext".to_string(),
1932 LanguageToolConfig {
1933 enabled: false,
1934 ..Default::default()
1935 },
1936 );
1937
1938 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1939
1940 let content = "```plaintext\nsome text\n```";
1941 let result = processor.format(content);
1942
1943 assert!(result.is_ok());
1945 let output = result.unwrap();
1946 assert!(!output.had_errors, "Expected no errors for disabled language");
1947 assert!(
1948 output.error_messages.is_empty(),
1949 "Expected no error messages, got: {:?}",
1950 output.error_messages
1951 );
1952 }
1953
1954 #[test]
1955 fn test_enabled_false_default_true_preserved() {
1956 let mut config = default_config();
1958 config.on_missing_language_definition = OnMissing::Fail;
1959
1960 config.languages.insert(
1962 "python".to_string(),
1963 LanguageToolConfig {
1964 lint: vec!["ruff:check".to_string()],
1965 ..Default::default()
1966 },
1967 );
1968
1969 let lang_config = config.languages.get("python").unwrap();
1970 assert!(lang_config.enabled, "enabled should default to true");
1971 }
1972
1973 #[test]
1974 fn test_enabled_false_with_fail_fast_no_error() {
1975 let mut config = default_config();
1977 config.normalize_language = NormalizeLanguage::Exact;
1978 config.on_missing_language_definition = OnMissing::FailFast;
1979
1980 config.languages.insert(
1981 "unknown".to_string(),
1982 LanguageToolConfig {
1983 enabled: false,
1984 ..Default::default()
1985 },
1986 );
1987
1988 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1989
1990 let content = "```unknown\nsome content\n```";
1991 let result = processor.lint(content);
1992
1993 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
1995 assert!(result.unwrap().is_empty());
1996 }
1997
1998 #[test]
1999 fn test_enabled_false_format_with_fail_fast_no_error() {
2000 let mut config = default_config();
2002 config.normalize_language = NormalizeLanguage::Exact;
2003 config.on_missing_language_definition = OnMissing::FailFast;
2004
2005 config.languages.insert(
2006 "unknown".to_string(),
2007 LanguageToolConfig {
2008 enabled: false,
2009 ..Default::default()
2010 },
2011 );
2012
2013 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2014
2015 let content = "```unknown\nsome content\n```";
2016 let result = processor.format(content);
2017
2018 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
2019 let output = result.unwrap();
2020 assert!(!output.had_errors);
2021 }
2022
2023 #[test]
2024 fn test_enabled_false_with_tools_still_skips() {
2025 let mut config = default_config();
2027 config.on_missing_language_definition = OnMissing::Fail;
2028
2029 config.languages.insert(
2030 "python".to_string(),
2031 LanguageToolConfig {
2032 enabled: false,
2033 lint: vec!["ruff:check".to_string()],
2034 format: vec!["ruff:format".to_string()],
2035 on_error: None,
2036 },
2037 );
2038
2039 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2040
2041 let content = "```python\nprint('hello')\n```";
2042
2043 let lint_result = processor.lint(content);
2045 assert!(lint_result.is_ok());
2046 assert!(lint_result.unwrap().is_empty());
2047
2048 let format_result = processor.format(content);
2050 assert!(format_result.is_ok());
2051 let output = format_result.unwrap();
2052 assert!(!output.had_errors);
2053 assert_eq!(output.content, content, "Content should be unchanged");
2054 }
2055
2056 #[test]
2057 fn test_enabled_true_without_tools_triggers_strict_mode() {
2058 let mut config = default_config();
2061 config.on_missing_language_definition = OnMissing::Fail;
2062
2063 config.languages.insert(
2064 "python".to_string(),
2065 LanguageToolConfig {
2066 ..Default::default()
2068 },
2069 );
2070
2071 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2072
2073 let content = "```python\nprint('hello')\n```";
2074 let result = processor.lint(content);
2075
2076 assert!(result.is_ok());
2078 let diagnostics = result.unwrap();
2079 assert_eq!(diagnostics.len(), 1);
2080 assert!(diagnostics[0].message.contains("No lint tools configured"));
2081 }
2082
2083 #[test]
2084 fn test_mixed_enabled_and_disabled_languages() {
2085 let mut config = default_config();
2087 config.normalize_language = NormalizeLanguage::Exact;
2088 config.on_missing_language_definition = OnMissing::Fail;
2089
2090 config.languages.insert(
2091 "plaintext".to_string(),
2092 LanguageToolConfig {
2093 enabled: false,
2094 ..Default::default()
2095 },
2096 );
2097
2098 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2099
2100 let content = "\
2101```plaintext
2102some text
2103```
2104
2105```javascript
2106console.log('hi');
2107```
2108";
2109
2110 let result = processor.lint(content);
2111 assert!(result.is_ok());
2112 let diagnostics = result.unwrap();
2113
2114 assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2117 assert!(
2118 diagnostics[0].message.contains("javascript"),
2119 "Error should be about javascript, got: {}",
2120 diagnostics[0].message
2121 );
2122 }
2123
2124 #[test]
2125 fn test_generic_fallback_includes_all_stderr_lines() {
2126 let config = default_config();
2127 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2128
2129 let output = ToolOutput {
2131 stdout: String::new(),
2132 stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2133 exit_code: 1,
2134 success: false,
2135 };
2136
2137 let diags = processor.parse_tool_output(&output, "tombi", 5);
2138 assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2139 assert_eq!(diags[0].message, "Parse error at position 42");
2140 assert_eq!(diags[1].message, "Unexpected token '::'");
2141 assert_eq!(diags[2].message, "3 errors found");
2142 assert!(diags.iter().all(|d| d.tool == "tombi"));
2143 assert!(diags.iter().all(|d| d.file_line == 5));
2144 }
2145
2146 #[test]
2147 fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2148 let config = default_config();
2149 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2150
2151 let output = ToolOutput {
2152 stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2153 stderr: String::new(),
2154 exit_code: 1,
2155 success: false,
2156 };
2157
2158 let diags = processor.parse_tool_output(&output, "some-tool", 10);
2159 assert_eq!(diags.len(), 3);
2160 assert_eq!(diags[0].message, "Line 1 error");
2161 assert_eq!(diags[1].message, "Line 2 detail");
2162 assert_eq!(diags[2].message, "Line 3 summary");
2163 }
2164
2165 #[test]
2166 fn test_generic_fallback_skips_blank_lines() {
2167 let config = default_config();
2168 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2169
2170 let output = ToolOutput {
2171 stdout: String::new(),
2172 stderr: "error: bad input\n\n \n\ndetail: see above\n".to_string(),
2173 exit_code: 1,
2174 success: false,
2175 };
2176
2177 let diags = processor.parse_tool_output(&output, "tool", 1);
2178 assert_eq!(diags.len(), 2);
2179 assert_eq!(diags[0].message, "error: bad input");
2180 assert_eq!(diags[1].message, "detail: see above");
2181 }
2182
2183 #[test]
2184 fn test_generic_fallback_exit_code_when_no_output() {
2185 let config = default_config();
2186 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2187
2188 let output = ToolOutput {
2189 stdout: String::new(),
2190 stderr: String::new(),
2191 exit_code: 42,
2192 success: false,
2193 };
2194
2195 let diags = processor.parse_tool_output(&output, "tool", 1);
2196 assert_eq!(diags.len(), 1);
2197 assert_eq!(diags[0].message, "Tool exited with code 42");
2198 }
2199
2200 #[test]
2201 fn test_generic_fallback_not_triggered_on_success() {
2202 let config = default_config();
2203 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2204
2205 let output = ToolOutput {
2206 stdout: "some informational output".to_string(),
2207 stderr: String::new(),
2208 exit_code: 0,
2209 success: true,
2210 };
2211
2212 let diags = processor.parse_tool_output(&output, "tool", 1);
2213 assert!(
2214 diags.is_empty(),
2215 "Successful tool runs should produce no fallback diagnostics"
2216 );
2217 }
2218
2219 #[test]
2220 fn test_ansi_codes_stripped_before_parsing() {
2221 let config = default_config();
2222 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2223
2224 let output = ToolOutput {
2226 stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
2227 .to_string(),
2228 stderr: String::new(),
2229 exit_code: 1,
2230 success: false,
2231 };
2232
2233 let diags = processor.parse_tool_output(&output, "ruff:check", 5);
2234 assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
2235 assert_eq!(diags[0].message, "E501 Line too long");
2236 assert_eq!(diags[0].file_line, 6); }
2238
2239 #[test]
2240 fn test_tombi_multiline_error_format() {
2241 let config = default_config();
2242 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2243
2244 let output = ToolOutput {
2246 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(),
2247 stderr: "1 file failed to be formatted".to_string(),
2248 exit_code: 1,
2249 success: false,
2250 };
2251
2252 let diags = processor.parse_tool_output(&output, "tombi", 7);
2253 assert_eq!(
2254 diags.len(),
2255 4,
2256 "Expected 4 diagnostics from tombi errors, got {diags:?}"
2257 );
2258 assert_eq!(diags[0].message, "invalid key");
2259 assert_eq!(diags[0].file_line, 9); assert_eq!(diags[0].column, Some(1));
2261 assert_eq!(diags[1].message, "expected key");
2262 assert_eq!(diags[1].file_line, 9);
2263 assert_eq!(diags[2].message, "expected '='");
2264 assert_eq!(diags[3].message, "expected value");
2265 assert!(diags.iter().all(|d| d.tool == "tombi"));
2266 }
2267
2268 #[test]
2269 fn test_tombi_with_ansi_codes() {
2270 let config = default_config();
2271 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2272
2273 let output = ToolOutput {
2275 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(),
2276 stderr: "1 file failed to be formatted".to_string(),
2277 exit_code: 1,
2278 success: false,
2279 };
2280
2281 let diags = processor.parse_tool_output(&output, "tombi", 7);
2282 assert_eq!(
2283 diags.len(),
2284 2,
2285 "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
2286 );
2287 assert_eq!(diags[0].message, "invalid key");
2288 assert_eq!(diags[0].file_line, 9);
2289 assert_eq!(diags[1].message, "expected '='");
2290 assert_eq!(diags[1].file_line, 9);
2291 }
2292
2293 #[test]
2294 fn test_fallback_combines_stdout_and_stderr() {
2295 let config = default_config();
2296 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2297
2298 let output = ToolOutput {
2300 stdout: "problem found in input".to_string(),
2301 stderr: "1 file failed".to_string(),
2302 exit_code: 1,
2303 success: false,
2304 };
2305
2306 let diags = processor.parse_tool_output(&output, "tool", 1);
2307 assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
2308 assert_eq!(diags[0].message, "problem found in input");
2309 assert_eq!(diags[1].message, "1 file failed");
2310 }
2311
2312 #[test]
2313 fn test_error_line_without_position_info() {
2314 let config = default_config();
2315 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2316
2317 let output = ToolOutput {
2319 stdout: "Error: something went wrong\nsome unrelated line".to_string(),
2320 stderr: String::new(),
2321 exit_code: 1,
2322 success: false,
2323 };
2324
2325 let diags = processor.parse_tool_output(&output, "tool", 5);
2326 assert!(!diags.is_empty());
2329 assert_eq!(diags[0].message, "something went wrong");
2330 assert_eq!(diags[0].file_line, 5); }
2332
2333 #[test]
2334 fn test_warning_line_with_position() {
2335 let config = default_config();
2336 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2337
2338 let output = ToolOutput {
2339 stdout: "Warning: deprecated syntax\n at line 3 column 5".to_string(),
2340 stderr: String::new(),
2341 exit_code: 1,
2342 success: false,
2343 };
2344
2345 let diags = processor.parse_tool_output(&output, "tool", 10);
2346 assert_eq!(diags.len(), 1);
2347 assert_eq!(diags[0].message, "deprecated syntax");
2348 assert_eq!(diags[0].file_line, 13); assert_eq!(diags[0].column, Some(5));
2350 assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
2351 }
2352
2353 #[test]
2354 fn test_strip_ansi_codes() {
2355 assert_eq!(strip_ansi_codes("hello"), "hello");
2356 assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
2357 assert_eq!(
2358 strip_ansi_codes("\x1b[1;31m Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
2359 " Error: msg"
2360 );
2361 assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
2362 assert_eq!(strip_ansi_codes(""), "");
2363 assert_eq!(
2364 strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
2365 "at line 2 column 1"
2366 );
2367 }
2368
2369 #[test]
2370 fn test_parse_at_line_column() {
2371 assert_eq!(
2372 CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
2373 Some((2, 1))
2374 );
2375 assert_eq!(
2376 CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
2377 Some((10, 15))
2378 );
2379 assert_eq!(
2380 CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
2381 Some((5, 3))
2382 );
2383 assert_eq!(
2384 CodeBlockToolProcessor::parse_at_line_column("not a position line"),
2385 None
2386 );
2387 assert_eq!(
2388 CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
2389 None
2390 );
2391 }
2392
2393 #[test]
2394 fn test_parse_error_line() {
2395 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
2396 assert_eq!(msg, "invalid key");
2397 assert!(matches!(sev, DiagnosticSeverity::Error));
2398
2399 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
2400 assert_eq!(msg, "deprecated");
2401 assert!(matches!(sev, DiagnosticSeverity::Warning));
2402
2403 assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
2405 assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
2406
2407 assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
2409 assert!(CodeBlockToolProcessor::parse_error_line("Error: ").is_none());
2410
2411 assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
2413 }
2414
2415 #[test]
2416 fn test_consecutive_error_lines_without_position() {
2417 let config = default_config();
2418 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2419
2420 let output = ToolOutput {
2423 stdout: "Error: first problem\nError: second problem\n at line 3 column 1".to_string(),
2424 stderr: String::new(),
2425 exit_code: 1,
2426 success: false,
2427 };
2428
2429 let diags = processor.parse_tool_output(&output, "tool", 5);
2430 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
2431 assert_eq!(diags[0].message, "first problem");
2433 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
2435 assert_eq!(diags[1].message, "second problem");
2437 assert_eq!(diags[1].file_line, 8); assert_eq!(diags[1].column, Some(1));
2439 }
2440
2441 #[test]
2442 fn test_error_line_at_end_of_output() {
2443 let config = default_config();
2444 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2445
2446 let output = ToolOutput {
2448 stdout: "Error: trailing error".to_string(),
2449 stderr: String::new(),
2450 exit_code: 1,
2451 success: false,
2452 };
2453
2454 let diags = processor.parse_tool_output(&output, "tool", 5);
2455 assert_eq!(diags.len(), 1);
2456 assert_eq!(diags[0].message, "trailing error");
2457 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
2459 }
2460
2461 #[test]
2462 fn test_blank_lines_between_error_and_position() {
2463 let config = default_config();
2464 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2465
2466 let output = ToolOutput {
2468 stdout: "Error: spaced out\n\n\n at line 4 column 2".to_string(),
2469 stderr: String::new(),
2470 exit_code: 1,
2471 success: false,
2472 };
2473
2474 let diags = processor.parse_tool_output(&output, "tool", 10);
2475 assert_eq!(diags.len(), 1);
2476 assert_eq!(diags[0].message, "spaced out");
2477 assert_eq!(diags[0].file_line, 14); assert_eq!(diags[0].column, Some(2));
2479 }
2480
2481 #[test]
2482 fn test_mixed_structured_and_error_line_parsers() {
2483 let config = default_config();
2484 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2485
2486 let output = ToolOutput {
2488 stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n at line 3 column 1".to_string(),
2489 stderr: String::new(),
2490 exit_code: 1,
2491 success: false,
2492 };
2493
2494 let diags = processor.parse_tool_output(&output, "tool", 5);
2495 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
2496 assert_eq!(diags[0].message, "E501 Line too long");
2498 assert_eq!(diags[0].file_line, 6); assert_eq!(diags[1].message, "invalid syntax");
2501 assert_eq!(diags[1].file_line, 8); }
2503
2504 #[test]
2505 fn test_at_line_without_preceding_error() {
2506 let config = default_config();
2507 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2508
2509 let output = ToolOutput {
2511 stdout: "at line 2 column 1\nsome other text".to_string(),
2512 stderr: String::new(),
2513 exit_code: 1,
2514 success: false,
2515 };
2516
2517 let diags = processor.parse_tool_output(&output, "tool", 5);
2518 assert_eq!(diags.len(), 2);
2521 assert_eq!(diags[0].message, "at line 2 column 1");
2522 assert_eq!(diags[1].message, "some other text");
2523 }
2524
2525 #[test]
2526 fn test_strip_ansi_codes_edge_cases() {
2527 assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
2529 assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
2531 assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
2533 assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
2535 assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
2536 }
2537}