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
26#[derive(Debug, Clone)]
28pub struct FencedCodeBlockInfo {
29 pub start_line: usize,
31 pub end_line: usize,
33 pub content_start: usize,
35 pub content_end: usize,
37 pub language: String,
39 pub info_string: String,
41 pub fence_char: char,
43 pub fence_length: usize,
45 pub indent: usize,
47 pub indent_prefix: String,
49}
50
51#[derive(Debug, Clone)]
53pub struct CodeBlockDiagnostic {
54 pub file_line: usize,
56 pub column: Option<usize>,
58 pub message: String,
60 pub severity: DiagnosticSeverity,
62 pub tool: String,
64 pub code_block_start: usize,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum DiagnosticSeverity {
71 Error,
72 Warning,
73 Info,
74}
75
76impl CodeBlockDiagnostic {
77 pub fn to_lint_warning(&self) -> LintWarning {
79 let severity = match self.severity {
80 DiagnosticSeverity::Error => Severity::Error,
81 DiagnosticSeverity::Warning => Severity::Warning,
82 DiagnosticSeverity::Info => Severity::Info,
83 };
84
85 LintWarning {
86 message: self.message.clone(),
87 line: self.file_line,
88 column: self.column.unwrap_or(1),
89 end_line: self.file_line,
90 end_column: self.column.unwrap_or(1),
91 severity,
92 fix: None, rule_name: Some(self.tool.clone()),
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
100pub enum ProcessorError {
101 ToolError(ExecutorError),
103 NoToolsConfigured { language: String },
105 ToolBinaryNotFound { tool: String, language: String },
107 Aborted { message: String },
109}
110
111impl std::fmt::Display for ProcessorError {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 match self {
114 Self::ToolError(e) => write!(f, "{e}"),
115 Self::NoToolsConfigured { language } => {
116 write!(f, "No tools configured for language '{language}'")
117 }
118 Self::ToolBinaryNotFound { tool, language } => {
119 write!(f, "Tool '{tool}' binary not found for language '{language}'")
120 }
121 Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
122 }
123 }
124}
125
126impl std::error::Error for ProcessorError {}
127
128impl From<ExecutorError> for ProcessorError {
129 fn from(e: ExecutorError) -> Self {
130 Self::ToolError(e)
131 }
132}
133
134#[derive(Debug)]
136pub struct CodeBlockResult {
137 pub diagnostics: Vec<CodeBlockDiagnostic>,
139 pub formatted_content: Option<String>,
141 pub was_modified: bool,
143}
144
145#[derive(Debug)]
147pub struct FormatOutput {
148 pub content: String,
150 pub had_errors: bool,
152 pub error_messages: Vec<String>,
154}
155
156pub struct CodeBlockToolProcessor<'a> {
158 config: &'a CodeBlockToolsConfig,
159 flavor: MarkdownFlavor,
160 linguist: LinguistResolver,
161 registry: ToolRegistry,
162 executor: ToolExecutor,
163 user_aliases: std::collections::HashMap<String, String>,
164}
165
166impl<'a> CodeBlockToolProcessor<'a> {
167 pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
169 let user_aliases = config
170 .language_aliases
171 .iter()
172 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
173 .collect();
174 Self {
175 config,
176 flavor,
177 linguist: LinguistResolver::new(),
178 registry: ToolRegistry::new(config.tools.clone()),
179 executor: ToolExecutor::new(config.timeout),
180 user_aliases,
181 }
182 }
183
184 pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
186 let mut blocks = Vec::new();
187 let mut current_block: Option<FencedCodeBlockBuilder> = None;
188
189 let options = Options::all();
190 let parser = Parser::new_ext(content, options).into_offset_iter();
191
192 let lines: Vec<&str> = content.lines().collect();
193
194 for (event, range) in parser {
195 match event {
196 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
197 let info_string = info.to_string();
198 let language = info_string.split_whitespace().next().unwrap_or("").to_string();
199
200 let start_line = content[..range.start].chars().filter(|&c| c == '\n').count();
202
203 let content_start = content[range.start..]
205 .find('\n')
206 .map(|i| range.start + i + 1)
207 .unwrap_or(content.len());
208
209 let fence_line = lines.get(start_line).unwrap_or(&"");
211 let trimmed = fence_line.trim_start();
212 let indent = fence_line.len() - trimmed.len();
213 let indent_prefix = fence_line.get(..indent).unwrap_or("").to_string();
214 let (fence_char, fence_length) = if trimmed.starts_with('~') {
215 ('~', trimmed.chars().take_while(|&c| c == '~').count())
216 } else {
217 ('`', trimmed.chars().take_while(|&c| c == '`').count())
218 };
219
220 current_block = Some(FencedCodeBlockBuilder {
221 start_line,
222 content_start,
223 language,
224 info_string,
225 fence_char,
226 fence_length,
227 indent,
228 indent_prefix,
229 });
230 }
231 Event::End(TagEnd::CodeBlock) => {
232 if let Some(builder) = current_block.take() {
233 let end_line = content[..range.end].chars().filter(|&c| c == '\n').count();
235
236 let search_start = builder.content_start.min(range.end);
238 let content_end = if search_start < range.end {
239 content[search_start..range.end]
240 .rfind('\n')
241 .map(|i| search_start + i)
242 .unwrap_or(search_start)
243 } else {
244 search_start
245 };
246
247 if content_end >= builder.content_start {
248 blocks.push(FencedCodeBlockInfo {
249 start_line: builder.start_line,
250 end_line,
251 content_start: builder.content_start,
252 content_end,
253 language: builder.language,
254 info_string: builder.info_string,
255 fence_char: builder.fence_char,
256 fence_length: builder.fence_length,
257 indent: builder.indent,
258 indent_prefix: builder.indent_prefix,
259 });
260 }
261 }
262 }
263 _ => {}
264 }
265 }
266
267 if self.flavor == MarkdownFlavor::MkDocs {
269 let mkdocs_blocks = self.extract_mkdocs_code_blocks(content);
270 for mb in mkdocs_blocks {
271 if !blocks.iter().any(|b| b.start_line == mb.start_line) {
273 blocks.push(mb);
274 }
275 }
276 blocks.sort_by_key(|b| b.start_line);
277 }
278
279 blocks
280 }
281
282 fn extract_mkdocs_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
288 use crate::utils::mkdocs_admonitions;
289 use crate::utils::mkdocs_tabs;
290
291 let mut blocks = Vec::new();
292 let lines: Vec<&str> = content.lines().collect();
293
294 let mut context_indent_stack: Vec<usize> = Vec::new();
297
298 let mut in_fence = false;
300 let mut fence_start_line: usize = 0;
301 let mut fence_content_start: usize = 0;
302 let mut fence_char: char = '`';
303 let mut fence_length: usize = 0;
304 let mut fence_indent: usize = 0;
305 let mut fence_indent_prefix = String::new();
306 let mut fence_language = String::new();
307 let mut fence_info_string = String::new();
308
309 let content_start_ptr = content.as_ptr() as usize;
314 let line_offsets: Vec<usize> = lines
315 .iter()
316 .map(|line| line.as_ptr() as usize - content_start_ptr)
317 .collect();
318
319 for (i, line) in lines.iter().enumerate() {
320 let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
321 let is_admonition = mkdocs_admonitions::is_admonition_start(line);
322 let is_tab = mkdocs_tabs::is_tab_marker(line);
323
324 if !line.trim().is_empty() {
328 while let Some(&ctx_indent) = context_indent_stack.last() {
329 if line_indent < ctx_indent + 4 {
330 context_indent_stack.pop();
331 if in_fence {
332 in_fence = false;
333 }
334 } else {
335 break;
336 }
337 }
338 }
339
340 if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
342 context_indent_stack.push(indent);
343 continue;
344 }
345
346 if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
348 context_indent_stack.push(indent);
349 continue;
350 }
351
352 if context_indent_stack.is_empty() {
354 continue;
355 }
356
357 let trimmed = line.trim_start();
358 let leading_spaces = line.len() - trimmed.len();
359
360 if !in_fence {
361 let (fc, fl) = if trimmed.starts_with("```") {
363 ('`', trimmed.chars().take_while(|&c| c == '`').count())
364 } else if trimmed.starts_with("~~~") {
365 ('~', trimmed.chars().take_while(|&c| c == '~').count())
366 } else {
367 continue;
368 };
369
370 if fl >= 3 {
371 in_fence = true;
372 fence_start_line = i;
373 fence_char = fc;
374 fence_length = fl;
375 fence_indent = leading_spaces;
376 fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
377
378 let after_fence = &trimmed[fl..];
379 fence_info_string = after_fence.trim().to_string();
380 fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
381
382 fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
384 }
385 } else {
386 let is_closing = if fence_char == '`' {
388 trimmed.starts_with("```")
389 && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
390 && trimmed.trim_start_matches('`').trim().is_empty()
391 } else {
392 trimmed.starts_with("~~~")
393 && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
394 && trimmed.trim_start_matches('~').trim().is_empty()
395 };
396
397 if is_closing {
398 let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
399
400 if content_end >= fence_content_start {
401 blocks.push(FencedCodeBlockInfo {
402 start_line: fence_start_line,
403 end_line: i,
404 content_start: fence_content_start,
405 content_end,
406 language: fence_language.clone(),
407 info_string: fence_info_string.clone(),
408 fence_char,
409 fence_length,
410 indent: fence_indent,
411 indent_prefix: fence_indent_prefix.clone(),
412 });
413 }
414
415 in_fence = false;
416 }
417 }
418 }
419
420 blocks
421 }
422
423 fn resolve_language(&self, language: &str) -> String {
425 let lower = language.to_lowercase();
426 if let Some(mapped) = self.user_aliases.get(&lower) {
427 return mapped.clone();
428 }
429 match self.config.normalize_language {
430 NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
431 NormalizeLanguage::Exact => lower,
432 }
433 }
434
435 fn get_on_error(&self, language: &str) -> OnError {
437 self.config
438 .languages
439 .get(language)
440 .and_then(|lc| lc.on_error)
441 .unwrap_or(self.config.on_error)
442 }
443
444 fn strip_indent_from_block(&self, content: &str, indent_prefix: &str) -> String {
446 if indent_prefix.is_empty() {
447 return content.to_string();
448 }
449
450 let mut out = String::with_capacity(content.len());
451 for line in content.split_inclusive('\n') {
452 if let Some(stripped) = line.strip_prefix(indent_prefix) {
453 out.push_str(stripped);
454 } else {
455 out.push_str(line);
456 }
457 }
458 out
459 }
460
461 fn apply_indent_to_block(&self, content: &str, indent_prefix: &str) -> String {
463 if indent_prefix.is_empty() {
464 return content.to_string();
465 }
466 if content.is_empty() {
467 return String::new();
468 }
469
470 let mut out = String::with_capacity(content.len() + indent_prefix.len());
471 for line in content.split_inclusive('\n') {
472 if line == "\n" {
473 out.push_str(line);
474 } else {
475 out.push_str(indent_prefix);
476 out.push_str(line);
477 }
478 }
479 out
480 }
481
482 pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
486 let mut all_diagnostics = Vec::new();
487 let blocks = self.extract_code_blocks(content);
488
489 for block in blocks {
490 if block.language.is_empty() {
491 continue; }
493
494 let canonical_lang = self.resolve_language(&block.language);
495
496 let lang_config = self.config.languages.get(&canonical_lang);
498
499 if let Some(lc) = lang_config
501 && !lc.enabled
502 {
503 continue;
504 }
505
506 let lint_tools = match lang_config {
507 Some(lc) if !lc.lint.is_empty() => &lc.lint,
508 _ => {
509 match self.config.on_missing_language_definition {
511 OnMissing::Ignore => continue,
512 OnMissing::Fail => {
513 all_diagnostics.push(CodeBlockDiagnostic {
514 file_line: block.start_line + 1,
515 column: None,
516 message: format!("No lint tools configured for language '{canonical_lang}'"),
517 severity: DiagnosticSeverity::Error,
518 tool: "code-block-tools".to_string(),
519 code_block_start: block.start_line + 1,
520 });
521 continue;
522 }
523 OnMissing::FailFast => {
524 return Err(ProcessorError::NoToolsConfigured {
525 language: canonical_lang,
526 });
527 }
528 }
529 }
530 };
531
532 let code_content_raw = if block.content_start < block.content_end && block.content_end <= content.len() {
534 &content[block.content_start..block.content_end]
535 } else {
536 continue;
537 };
538 let code_content = self.strip_indent_from_block(code_content_raw, &block.indent_prefix);
539
540 for tool_id in lint_tools {
542 if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
544 continue;
545 }
546
547 let tool_def = match self.registry.get(tool_id) {
548 Some(t) => t,
549 None => {
550 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
551 continue;
552 }
553 };
554
555 let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
557 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
558 match self.config.on_missing_tool_binary {
559 OnMissing::Ignore => {
560 log::debug!("Tool binary '{tool_name}' not found, skipping");
561 continue;
562 }
563 OnMissing::Fail => {
564 all_diagnostics.push(CodeBlockDiagnostic {
565 file_line: block.start_line + 1,
566 column: None,
567 message: format!("Tool binary '{tool_name}' not found in PATH"),
568 severity: DiagnosticSeverity::Error,
569 tool: "code-block-tools".to_string(),
570 code_block_start: block.start_line + 1,
571 });
572 continue;
573 }
574 OnMissing::FailFast => {
575 return Err(ProcessorError::ToolBinaryNotFound {
576 tool: tool_name.to_string(),
577 language: canonical_lang.clone(),
578 });
579 }
580 }
581 }
582
583 match self.executor.lint(tool_def, &code_content, Some(self.config.timeout)) {
584 Ok(output) => {
585 let diagnostics = self.parse_tool_output(
587 &output,
588 tool_id,
589 block.start_line + 1, );
591 all_diagnostics.extend(diagnostics);
592 }
593 Err(e) => {
594 let on_error = self.get_on_error(&canonical_lang);
595 match on_error {
596 OnError::Fail => return Err(e.into()),
597 OnError::Warn => {
598 log::warn!("Tool '{tool_id}' failed: {e}");
599 }
600 OnError::Skip => {
601 }
603 }
604 }
605 }
606 }
607 }
608
609 Ok(all_diagnostics)
610 }
611
612 pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
618 let blocks = self.extract_code_blocks(content);
619
620 if blocks.is_empty() {
621 return Ok(FormatOutput {
622 content: content.to_string(),
623 had_errors: false,
624 error_messages: Vec::new(),
625 });
626 }
627
628 let mut result = content.to_string();
630 let mut error_messages: Vec<String> = Vec::new();
631
632 for block in blocks.into_iter().rev() {
633 if block.language.is_empty() {
634 continue;
635 }
636
637 let canonical_lang = self.resolve_language(&block.language);
638
639 let lang_config = self.config.languages.get(&canonical_lang);
641
642 if let Some(lc) = lang_config
644 && !lc.enabled
645 {
646 continue;
647 }
648
649 let format_tools = match lang_config {
650 Some(lc) if !lc.format.is_empty() => &lc.format,
651 _ => {
652 match self.config.on_missing_language_definition {
654 OnMissing::Ignore => continue,
655 OnMissing::Fail => {
656 error_messages.push(format!(
657 "No format tools configured for language '{canonical_lang}' at line {}",
658 block.start_line + 1
659 ));
660 continue;
661 }
662 OnMissing::FailFast => {
663 return Err(ProcessorError::NoToolsConfigured {
664 language: canonical_lang,
665 });
666 }
667 }
668 }
669 };
670
671 if block.content_start >= block.content_end || block.content_end > result.len() {
673 continue;
674 }
675 let code_content_raw = result[block.content_start..block.content_end].to_string();
676 let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
677
678 let mut formatted = code_content.clone();
680 let mut tool_ran = false;
681 for tool_id in format_tools {
682 if tool_id == RUMDL_BUILTIN_TOOL && is_markdown_language(&canonical_lang) {
684 continue;
685 }
686
687 let tool_def = match self.registry.get(tool_id) {
688 Some(t) => t,
689 None => {
690 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
691 continue;
692 }
693 };
694
695 let tool_name = tool_def.command.first().map(String::as_str).unwrap_or("");
697 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
698 match self.config.on_missing_tool_binary {
699 OnMissing::Ignore => {
700 log::debug!("Tool binary '{tool_name}' not found, skipping");
701 continue;
702 }
703 OnMissing::Fail => {
704 error_messages.push(format!(
705 "Tool binary '{tool_name}' not found in PATH for language '{canonical_lang}' at line {}",
706 block.start_line + 1
707 ));
708 continue;
709 }
710 OnMissing::FailFast => {
711 return Err(ProcessorError::ToolBinaryNotFound {
712 tool: tool_name.to_string(),
713 language: canonical_lang.clone(),
714 });
715 }
716 }
717 }
718
719 match self.executor.format(tool_def, &formatted, Some(self.config.timeout)) {
720 Ok(output) => {
721 formatted = output;
723 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
724 formatted.push('\n');
725 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
726 formatted.pop();
727 }
728 tool_ran = true;
729 break; }
731 Err(e) => {
732 let on_error = self.get_on_error(&canonical_lang);
733 match on_error {
734 OnError::Fail => return Err(e.into()),
735 OnError::Warn => {
736 log::warn!("Formatter '{tool_id}' failed: {e}");
737 }
738 OnError::Skip => {}
739 }
740 }
741 }
742 }
743
744 if tool_ran && formatted != code_content {
746 let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
747 if reindented != code_content_raw {
748 result.replace_range(block.content_start..block.content_end, &reindented);
749 }
750 }
751 }
752
753 Ok(FormatOutput {
754 content: result,
755 had_errors: !error_messages.is_empty(),
756 error_messages,
757 })
758 }
759
760 fn parse_tool_output(
765 &self,
766 output: &ToolOutput,
767 tool_id: &str,
768 code_block_start_line: usize,
769 ) -> Vec<CodeBlockDiagnostic> {
770 let mut diagnostics = Vec::new();
771 let mut shellcheck_line: Option<usize> = None;
772
773 let stdout = &output.stdout;
775 let stderr = &output.stderr;
776 let combined = format!("{stdout}\n{stderr}");
777
778 for line in combined.lines() {
785 let line = line.trim();
786 if line.is_empty() {
787 continue;
788 }
789
790 if let Some(line_num) = self.parse_shellcheck_header(line) {
791 shellcheck_line = Some(line_num);
792 continue;
793 }
794
795 if let Some(line_num) = shellcheck_line
796 && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
797 {
798 diagnostics.push(diag);
799 continue;
800 }
801
802 if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
804 diagnostics.push(diag);
805 continue;
806 }
807
808 if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
810 diagnostics.push(diag);
811 continue;
812 }
813
814 if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
816 diagnostics.push(diag);
817 }
818 }
819
820 if diagnostics.is_empty() && !output.success {
822 let raw_output = if !output.stderr.is_empty() {
823 &output.stderr
824 } else if !output.stdout.is_empty() {
825 &output.stdout
826 } else {
827 ""
828 };
829
830 let lines: Vec<&str> = raw_output.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
831
832 if lines.is_empty() {
833 let exit_code = output.exit_code;
834 diagnostics.push(CodeBlockDiagnostic {
835 file_line: code_block_start_line,
836 column: None,
837 message: format!("Tool exited with code {exit_code}"),
838 severity: DiagnosticSeverity::Error,
839 tool: tool_id.to_string(),
840 code_block_start: code_block_start_line,
841 });
842 } else {
843 for line_text in lines {
844 diagnostics.push(CodeBlockDiagnostic {
845 file_line: code_block_start_line,
846 column: None,
847 message: line_text.to_string(),
848 severity: DiagnosticSeverity::Error,
849 tool: tool_id.to_string(),
850 code_block_start: code_block_start_line,
851 });
852 }
853 }
854 }
855
856 diagnostics
857 }
858
859 fn parse_standard_format(
861 &self,
862 line: &str,
863 tool_id: &str,
864 code_block_start_line: usize,
865 ) -> Option<CodeBlockDiagnostic> {
866 let mut parts = line.rsplitn(4, ':');
868 let message = parts.next()?.trim().to_string();
869 let part1 = parts.next()?.trim().to_string();
870 let part2 = parts.next()?.trim().to_string();
871 let part3 = parts.next().map(|s| s.trim().to_string());
872
873 let (line_part, col_part) = if part3.is_some() {
874 (part2, Some(part1))
875 } else {
876 (part1, None)
877 };
878
879 if let Ok(line_num) = line_part.parse::<usize>() {
880 let column = col_part.and_then(|s| s.parse::<usize>().ok());
881 let message = Self::strip_fixable_markers(&message);
882 if !message.is_empty() {
883 let severity = self.infer_severity(&message);
884 return Some(CodeBlockDiagnostic {
885 file_line: code_block_start_line + line_num,
886 column,
887 message,
888 severity,
889 tool: tool_id.to_string(),
890 code_block_start: code_block_start_line,
891 });
892 }
893 }
894 None
895 }
896
897 fn parse_eslint_format(
899 &self,
900 line: &str,
901 tool_id: &str,
902 code_block_start_line: usize,
903 ) -> Option<CodeBlockDiagnostic> {
904 let parts: Vec<&str> = line.splitn(3, ' ').collect();
906 if parts.len() >= 2 {
907 let loc_parts: Vec<&str> = parts[0].split(':').collect();
908 if loc_parts.len() == 2
909 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
910 {
911 let (sev_part, msg_part) = if parts.len() >= 3 {
912 (parts[1], parts[2])
913 } else {
914 (parts[1], "")
915 };
916 let message = if msg_part.is_empty() {
917 sev_part.to_string()
918 } else {
919 msg_part.to_string()
920 };
921 let message = Self::strip_fixable_markers(&message);
922 let severity = match sev_part.to_lowercase().as_str() {
923 "error" => DiagnosticSeverity::Error,
924 "warning" | "warn" => DiagnosticSeverity::Warning,
925 "info" => DiagnosticSeverity::Info,
926 _ => self.infer_severity(&message),
927 };
928 return Some(CodeBlockDiagnostic {
929 file_line: code_block_start_line + line_num,
930 column: Some(col),
931 message,
932 severity,
933 tool: tool_id.to_string(),
934 code_block_start: code_block_start_line,
935 });
936 }
937 }
938 None
939 }
940
941 fn parse_shellcheck_format(
943 &self,
944 line: &str,
945 tool_id: &str,
946 code_block_start_line: usize,
947 ) -> Option<CodeBlockDiagnostic> {
948 if line.starts_with("In ")
950 && line.contains(" line ")
951 && let Some(line_start) = line.find(" line ")
952 {
953 let after_line = &line[line_start + 6..];
954 if let Some(colon_pos) = after_line.find(':')
955 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
956 {
957 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
958 if !message.is_empty() {
959 let severity = self.infer_severity(&message);
960 return Some(CodeBlockDiagnostic {
961 file_line: code_block_start_line + line_num,
962 column: None,
963 message,
964 severity,
965 tool: tool_id.to_string(),
966 code_block_start: code_block_start_line,
967 });
968 }
969 }
970 }
971 None
972 }
973
974 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
976 if line.starts_with("In ")
977 && line.contains(" line ")
978 && let Some(line_start) = line.find(" line ")
979 {
980 let after_line = &line[line_start + 6..];
981 if let Some(colon_pos) = after_line.find(':') {
982 return after_line[..colon_pos].trim().parse::<usize>().ok();
983 }
984 }
985 None
986 }
987
988 fn parse_shellcheck_message(
990 &self,
991 line: &str,
992 tool_id: &str,
993 code_block_start_line: usize,
994 line_num: usize,
995 ) -> Option<CodeBlockDiagnostic> {
996 let sc_pos = line.find("SC")?;
997 let after_sc = &line[sc_pos + 2..];
998 let code_len = after_sc.chars().take_while(|c| c.is_ascii_digit()).count();
999 if code_len == 0 {
1000 return None;
1001 }
1002 let after_code = &after_sc[code_len..];
1003 let sev_start = after_code.find('(')? + 1;
1004 let sev_end = after_code[sev_start..].find(')')? + sev_start;
1005 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
1006 let message_start = after_code.find("):")? + 2;
1007 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
1008 if message.is_empty() {
1009 return None;
1010 }
1011
1012 let severity = match sev.as_str() {
1013 "error" => DiagnosticSeverity::Error,
1014 "warning" | "warn" => DiagnosticSeverity::Warning,
1015 "info" | "style" => DiagnosticSeverity::Info,
1016 _ => self.infer_severity(&message),
1017 };
1018
1019 Some(CodeBlockDiagnostic {
1020 file_line: code_block_start_line + line_num,
1021 column: None,
1022 message,
1023 severity,
1024 tool: tool_id.to_string(),
1025 code_block_start: code_block_start_line,
1026 })
1027 }
1028
1029 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1031 let lower = message.to_lowercase();
1032 if lower.contains("error")
1033 || lower.starts_with("e") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1034 || lower.starts_with("f") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1035 {
1036 DiagnosticSeverity::Error
1037 } else if lower.contains("warning")
1038 || lower.contains("warn")
1039 || lower.starts_with("w") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1040 {
1041 DiagnosticSeverity::Warning
1042 } else {
1043 DiagnosticSeverity::Info
1044 }
1045 }
1046
1047 fn strip_fixable_markers(message: &str) -> String {
1054 message
1055 .replace(" [*]", "")
1056 .replace("[*] ", "")
1057 .replace("[*]", "")
1058 .replace(" (fixable)", "")
1059 .replace("(fixable) ", "")
1060 .replace("(fixable)", "")
1061 .replace(" [fix available]", "")
1062 .replace("[fix available] ", "")
1063 .replace("[fix available]", "")
1064 .replace(" [autofix]", "")
1065 .replace("[autofix] ", "")
1066 .replace("[autofix]", "")
1067 .trim()
1068 .to_string()
1069 }
1070}
1071
1072struct FencedCodeBlockBuilder {
1074 start_line: usize,
1075 content_start: usize,
1076 language: String,
1077 info_string: String,
1078 fence_char: char,
1079 fence_length: usize,
1080 indent: usize,
1081 indent_prefix: String,
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087
1088 fn default_config() -> CodeBlockToolsConfig {
1089 CodeBlockToolsConfig::default()
1090 }
1091
1092 #[test]
1093 fn test_extract_code_blocks() {
1094 let config = default_config();
1095 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1096
1097 let content = r#"# Example
1098
1099```python
1100def hello():
1101 print("Hello")
1102```
1103
1104Some text
1105
1106```rust
1107fn main() {}
1108```
1109"#;
1110
1111 let blocks = processor.extract_code_blocks(content);
1112
1113 assert_eq!(blocks.len(), 2);
1114
1115 assert_eq!(blocks[0].language, "python");
1116 assert_eq!(blocks[0].fence_char, '`');
1117 assert_eq!(blocks[0].fence_length, 3);
1118 assert_eq!(blocks[0].start_line, 2);
1119 assert_eq!(blocks[0].indent, 0);
1120 assert_eq!(blocks[0].indent_prefix, "");
1121
1122 assert_eq!(blocks[1].language, "rust");
1123 assert_eq!(blocks[1].fence_char, '`');
1124 assert_eq!(blocks[1].fence_length, 3);
1125 }
1126
1127 #[test]
1128 fn test_extract_code_blocks_with_info_string() {
1129 let config = default_config();
1130 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1131
1132 let content = "```python title=\"example.py\"\ncode\n```";
1133 let blocks = processor.extract_code_blocks(content);
1134
1135 assert_eq!(blocks.len(), 1);
1136 assert_eq!(blocks[0].language, "python");
1137 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1138 }
1139
1140 #[test]
1141 fn test_extract_code_blocks_tilde_fence() {
1142 let config = default_config();
1143 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1144
1145 let content = "~~~bash\necho hello\n~~~";
1146 let blocks = processor.extract_code_blocks(content);
1147
1148 assert_eq!(blocks.len(), 1);
1149 assert_eq!(blocks[0].language, "bash");
1150 assert_eq!(blocks[0].fence_char, '~');
1151 assert_eq!(blocks[0].fence_length, 3);
1152 assert_eq!(blocks[0].indent_prefix, "");
1153 }
1154
1155 #[test]
1156 fn test_extract_code_blocks_with_indent_prefix() {
1157 let config = default_config();
1158 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1159
1160 let content = " - item\n ```python\n print('hi')\n ```";
1161 let blocks = processor.extract_code_blocks(content);
1162
1163 assert_eq!(blocks.len(), 1);
1164 assert_eq!(blocks[0].indent_prefix, " ");
1165 }
1166
1167 #[test]
1168 fn test_extract_code_blocks_no_language() {
1169 let config = default_config();
1170 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1171
1172 let content = "```\nplain code\n```";
1173 let blocks = processor.extract_code_blocks(content);
1174
1175 assert_eq!(blocks.len(), 1);
1176 assert_eq!(blocks[0].language, "");
1177 }
1178
1179 #[test]
1180 fn test_resolve_language_linguist() {
1181 let mut config = default_config();
1182 config.normalize_language = NormalizeLanguage::Linguist;
1183 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1184
1185 assert_eq!(processor.resolve_language("py"), "python");
1186 assert_eq!(processor.resolve_language("bash"), "shell");
1187 assert_eq!(processor.resolve_language("js"), "javascript");
1188 }
1189
1190 #[test]
1191 fn test_resolve_language_exact() {
1192 let mut config = default_config();
1193 config.normalize_language = NormalizeLanguage::Exact;
1194 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1195
1196 assert_eq!(processor.resolve_language("py"), "py");
1197 assert_eq!(processor.resolve_language("BASH"), "bash");
1198 }
1199
1200 #[test]
1201 fn test_resolve_language_user_alias_override() {
1202 let mut config = default_config();
1203 config.language_aliases.insert("py".to_string(), "python".to_string());
1204 config.normalize_language = NormalizeLanguage::Exact;
1205 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1206
1207 assert_eq!(processor.resolve_language("PY"), "python");
1208 }
1209
1210 #[test]
1211 fn test_indent_strip_and_reapply_roundtrip() {
1212 let config = default_config();
1213 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1214
1215 let raw = " def hello():\n print('hi')";
1216 let stripped = processor.strip_indent_from_block(raw, " ");
1217 assert_eq!(stripped, "def hello():\n print('hi')");
1218
1219 let reapplied = processor.apply_indent_to_block(&stripped, " ");
1220 assert_eq!(reapplied, raw);
1221 }
1222
1223 #[test]
1224 fn test_infer_severity() {
1225 let config = default_config();
1226 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1227
1228 assert_eq!(
1229 processor.infer_severity("E501 line too long"),
1230 DiagnosticSeverity::Error
1231 );
1232 assert_eq!(
1233 processor.infer_severity("W291 trailing whitespace"),
1234 DiagnosticSeverity::Warning
1235 );
1236 assert_eq!(
1237 processor.infer_severity("error: something failed"),
1238 DiagnosticSeverity::Error
1239 );
1240 assert_eq!(
1241 processor.infer_severity("warning: unused variable"),
1242 DiagnosticSeverity::Warning
1243 );
1244 assert_eq!(
1245 processor.infer_severity("note: consider using"),
1246 DiagnosticSeverity::Info
1247 );
1248 }
1249
1250 #[test]
1251 fn test_parse_standard_format_windows_path() {
1252 let config = default_config();
1253 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1254
1255 let output = ToolOutput {
1256 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1257 stderr: String::new(),
1258 exit_code: 1,
1259 success: false,
1260 };
1261
1262 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1263 assert_eq!(diags.len(), 1);
1264 assert_eq!(diags[0].file_line, 12);
1265 assert_eq!(diags[0].column, Some(5));
1266 assert_eq!(diags[0].message, "E123 message");
1267 }
1268
1269 #[test]
1270 fn test_parse_eslint_severity() {
1271 let config = default_config();
1272 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1273
1274 let output = ToolOutput {
1275 stdout: "1:2 error Unexpected token".to_string(),
1276 stderr: String::new(),
1277 exit_code: 1,
1278 success: false,
1279 };
1280
1281 let diags = processor.parse_tool_output(&output, "eslint", 5);
1282 assert_eq!(diags.len(), 1);
1283 assert_eq!(diags[0].file_line, 6);
1284 assert_eq!(diags[0].column, Some(2));
1285 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1286 assert_eq!(diags[0].message, "Unexpected token");
1287 }
1288
1289 #[test]
1290 fn test_parse_shellcheck_multiline() {
1291 let config = default_config();
1292 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1293
1294 let output = ToolOutput {
1295 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1296 stderr: String::new(),
1297 exit_code: 1,
1298 success: false,
1299 };
1300
1301 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1302 assert_eq!(diags.len(), 1);
1303 assert_eq!(diags[0].file_line, 13);
1304 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1305 assert_eq!(diags[0].message, "Double quote to prevent globbing");
1306 }
1307
1308 #[test]
1309 fn test_lint_no_config() {
1310 let config = default_config();
1311 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1312
1313 let content = "```python\nprint('hello')\n```";
1314 let result = processor.lint(content);
1315
1316 assert!(result.is_ok());
1318 assert!(result.unwrap().is_empty());
1319 }
1320
1321 #[test]
1322 fn test_format_no_config() {
1323 let config = default_config();
1324 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1325
1326 let content = "```python\nprint('hello')\n```";
1327 let result = processor.format(content);
1328
1329 assert!(result.is_ok());
1331 let output = result.unwrap();
1332 assert_eq!(output.content, content);
1333 assert!(!output.had_errors);
1334 assert!(output.error_messages.is_empty());
1335 }
1336
1337 #[test]
1338 fn test_lint_on_missing_language_definition_fail() {
1339 let mut config = default_config();
1340 config.on_missing_language_definition = OnMissing::Fail;
1341 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1342
1343 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1344 let result = processor.lint(content);
1345
1346 assert!(result.is_ok());
1348 let diagnostics = result.unwrap();
1349 assert_eq!(diagnostics.len(), 2);
1350 assert!(diagnostics[0].message.contains("No lint tools configured"));
1351 assert!(diagnostics[0].message.contains("python"));
1352 assert!(diagnostics[1].message.contains("javascript"));
1353 }
1354
1355 #[test]
1356 fn test_lint_on_missing_language_definition_fail_fast() {
1357 let mut config = default_config();
1358 config.on_missing_language_definition = OnMissing::FailFast;
1359 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1360
1361 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1362 let result = processor.lint(content);
1363
1364 assert!(result.is_err());
1366 let err = result.unwrap_err();
1367 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1368 }
1369
1370 #[test]
1371 fn test_format_on_missing_language_definition_fail() {
1372 let mut config = default_config();
1373 config.on_missing_language_definition = OnMissing::Fail;
1374 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1375
1376 let content = "```python\nprint('hello')\n```";
1377 let result = processor.format(content);
1378
1379 assert!(result.is_ok());
1381 let output = result.unwrap();
1382 assert_eq!(output.content, content); assert!(output.had_errors);
1384 assert!(!output.error_messages.is_empty());
1385 assert!(output.error_messages[0].contains("No format tools configured"));
1386 }
1387
1388 #[test]
1389 fn test_format_on_missing_language_definition_fail_fast() {
1390 let mut config = default_config();
1391 config.on_missing_language_definition = OnMissing::FailFast;
1392 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1393
1394 let content = "```python\nprint('hello')\n```";
1395 let result = processor.format(content);
1396
1397 assert!(result.is_err());
1399 let err = result.unwrap_err();
1400 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1401 }
1402
1403 #[test]
1404 fn test_lint_on_missing_tool_binary_fail() {
1405 use super::super::config::{LanguageToolConfig, ToolDefinition};
1406
1407 let mut config = default_config();
1408 config.on_missing_tool_binary = OnMissing::Fail;
1409
1410 let lang_config = LanguageToolConfig {
1412 lint: vec!["nonexistent-linter".to_string()],
1413 ..Default::default()
1414 };
1415 config.languages.insert("python".to_string(), lang_config);
1416
1417 let tool_def = ToolDefinition {
1418 command: vec!["nonexistent-binary-xyz123".to_string()],
1419 ..Default::default()
1420 };
1421 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1422
1423 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1424
1425 let content = "```python\nprint('hello')\n```";
1426 let result = processor.lint(content);
1427
1428 assert!(result.is_ok());
1430 let diagnostics = result.unwrap();
1431 assert_eq!(diagnostics.len(), 1);
1432 assert!(diagnostics[0].message.contains("not found in PATH"));
1433 }
1434
1435 #[test]
1436 fn test_lint_on_missing_tool_binary_fail_fast() {
1437 use super::super::config::{LanguageToolConfig, ToolDefinition};
1438
1439 let mut config = default_config();
1440 config.on_missing_tool_binary = OnMissing::FailFast;
1441
1442 let lang_config = LanguageToolConfig {
1444 lint: vec!["nonexistent-linter".to_string()],
1445 ..Default::default()
1446 };
1447 config.languages.insert("python".to_string(), lang_config);
1448
1449 let tool_def = ToolDefinition {
1450 command: vec!["nonexistent-binary-xyz123".to_string()],
1451 ..Default::default()
1452 };
1453 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1454
1455 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1456
1457 let content = "```python\nprint('hello')\n```";
1458 let result = processor.lint(content);
1459
1460 assert!(result.is_err());
1462 let err = result.unwrap_err();
1463 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1464 }
1465
1466 #[test]
1467 fn test_format_on_missing_tool_binary_fail() {
1468 use super::super::config::{LanguageToolConfig, ToolDefinition};
1469
1470 let mut config = default_config();
1471 config.on_missing_tool_binary = OnMissing::Fail;
1472
1473 let lang_config = LanguageToolConfig {
1475 format: vec!["nonexistent-formatter".to_string()],
1476 ..Default::default()
1477 };
1478 config.languages.insert("python".to_string(), lang_config);
1479
1480 let tool_def = ToolDefinition {
1481 command: vec!["nonexistent-binary-xyz123".to_string()],
1482 ..Default::default()
1483 };
1484 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1485
1486 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1487
1488 let content = "```python\nprint('hello')\n```";
1489 let result = processor.format(content);
1490
1491 assert!(result.is_ok());
1493 let output = result.unwrap();
1494 assert_eq!(output.content, content); assert!(output.had_errors);
1496 assert!(!output.error_messages.is_empty());
1497 assert!(output.error_messages[0].contains("not found in PATH"));
1498 }
1499
1500 #[test]
1501 fn test_format_on_missing_tool_binary_fail_fast() {
1502 use super::super::config::{LanguageToolConfig, ToolDefinition};
1503
1504 let mut config = default_config();
1505 config.on_missing_tool_binary = OnMissing::FailFast;
1506
1507 let lang_config = LanguageToolConfig {
1509 format: vec!["nonexistent-formatter".to_string()],
1510 ..Default::default()
1511 };
1512 config.languages.insert("python".to_string(), lang_config);
1513
1514 let tool_def = ToolDefinition {
1515 command: vec!["nonexistent-binary-xyz123".to_string()],
1516 ..Default::default()
1517 };
1518 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1519
1520 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1521
1522 let content = "```python\nprint('hello')\n```";
1523 let result = processor.format(content);
1524
1525 assert!(result.is_err());
1527 let err = result.unwrap_err();
1528 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1529 }
1530
1531 #[test]
1532 fn test_lint_rumdl_builtin_skipped_for_markdown() {
1533 let mut config = default_config();
1536 config.languages.insert(
1537 "markdown".to_string(),
1538 LanguageToolConfig {
1539 lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
1540 ..Default::default()
1541 },
1542 );
1543 config.on_missing_language_definition = OnMissing::Fail;
1544 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1545
1546 let content = "```markdown\n# Hello\n```";
1547 let result = processor.lint(content);
1548
1549 assert!(result.is_ok());
1551 assert!(result.unwrap().is_empty());
1552 }
1553
1554 #[test]
1555 fn test_format_rumdl_builtin_skipped_for_markdown() {
1556 let mut config = default_config();
1558 config.languages.insert(
1559 "markdown".to_string(),
1560 LanguageToolConfig {
1561 format: vec![RUMDL_BUILTIN_TOOL.to_string()],
1562 ..Default::default()
1563 },
1564 );
1565 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1566
1567 let content = "```markdown\n# Hello\n```";
1568 let result = processor.format(content);
1569
1570 assert!(result.is_ok());
1572 let output = result.unwrap();
1573 assert_eq!(output.content, content);
1574 assert!(!output.had_errors);
1575 }
1576
1577 #[test]
1578 fn test_is_markdown_language() {
1579 assert!(is_markdown_language("markdown"));
1581 assert!(is_markdown_language("Markdown"));
1582 assert!(is_markdown_language("MARKDOWN"));
1583 assert!(is_markdown_language("md"));
1584 assert!(is_markdown_language("MD"));
1585 assert!(!is_markdown_language("python"));
1586 assert!(!is_markdown_language("rust"));
1587 assert!(!is_markdown_language(""));
1588 }
1589
1590 #[test]
1593 fn test_extract_mkdocs_admonition_code_block() {
1594 let config = default_config();
1595 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1596
1597 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
1598 let blocks = processor.extract_code_blocks(content);
1599
1600 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
1601 assert_eq!(blocks[0].language, "python");
1602 }
1603
1604 #[test]
1605 fn test_extract_mkdocs_tab_code_block() {
1606 let config = default_config();
1607 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1608
1609 let content = "=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n";
1610 let blocks = processor.extract_code_blocks(content);
1611
1612 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
1613 assert_eq!(blocks[0].language, "python");
1614 }
1615
1616 #[test]
1617 fn test_standard_flavor_ignores_admonition_indented_content() {
1618 let config = default_config();
1619 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1620
1621 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
1624 let blocks = processor.extract_code_blocks(content);
1625
1626 for (i, b) in blocks.iter().enumerate() {
1630 for (j, b2) in blocks.iter().enumerate() {
1631 if i != j {
1632 assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
1633 }
1634 }
1635 }
1636 }
1637
1638 #[test]
1639 fn test_mkdocs_top_level_blocks_alongside_admonition() {
1640 let config = default_config();
1641 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1642
1643 let content =
1644 "```rust\nfn main() {}\n```\n\n!!! note\n Some text\n\n ```python\n print(\"hello\")\n ```\n";
1645 let blocks = processor.extract_code_blocks(content);
1646
1647 assert_eq!(
1648 blocks.len(),
1649 2,
1650 "Should detect both top-level and admonition code blocks"
1651 );
1652 assert_eq!(blocks[0].language, "rust");
1653 assert_eq!(blocks[1].language, "python");
1654 }
1655
1656 #[test]
1657 fn test_mkdocs_nested_admonition_code_block() {
1658 let config = default_config();
1659 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1660
1661 let content = "\
1662!!! note
1663 Some text
1664
1665 !!! warning
1666 Nested content
1667
1668 ```python
1669 x = 1
1670 ```
1671";
1672 let blocks = processor.extract_code_blocks(content);
1673 assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
1674 assert_eq!(blocks[0].language, "python");
1675 }
1676
1677 #[test]
1678 fn test_mkdocs_consecutive_admonitions_no_stale_context() {
1679 let config = default_config();
1680 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1681
1682 let content = "\
1685!!! note
1686 First admonition content
1687
1688!!! warning
1689 Second admonition content
1690
1691 ```python
1692 y = 2
1693 ```
1694";
1695 let blocks = processor.extract_code_blocks(content);
1696 assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
1697 assert_eq!(blocks[0].language, "python");
1698 }
1699
1700 #[test]
1701 fn test_mkdocs_crlf_line_endings() {
1702 let config = default_config();
1703 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1704
1705 let content = "!!! note\r\n Some text\r\n\r\n ```python\r\n x = 1\r\n ```\r\n";
1707 let blocks = processor.extract_code_blocks(content);
1708
1709 assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
1710 assert_eq!(blocks[0].language, "python");
1711
1712 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1714 assert!(
1715 extracted.contains("x = 1"),
1716 "Extracted content should contain code. Got: {extracted:?}"
1717 );
1718 }
1719
1720 #[test]
1721 fn test_mkdocs_unclosed_fence_in_admonition() {
1722 let config = default_config();
1723 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1724
1725 let content = "!!! note\n ```python\n x = 1\n no closing fence\n";
1727 let blocks = processor.extract_code_blocks(content);
1728 assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
1729 }
1730
1731 #[test]
1732 fn test_mkdocs_tilde_fence_in_admonition() {
1733 let config = default_config();
1734 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1735
1736 let content = "!!! note\n ~~~ruby\n puts 'hi'\n ~~~\n";
1737 let blocks = processor.extract_code_blocks(content);
1738 assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
1739 assert_eq!(blocks[0].language, "ruby");
1740 }
1741
1742 #[test]
1743 fn test_mkdocs_empty_lines_in_code_block() {
1744 let config = default_config();
1745 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1746
1747 let content = "!!! note\n ```python\n x = 1\n\n y = 2\n ```\n";
1750 let blocks = processor.extract_code_blocks(content);
1751 assert_eq!(blocks.len(), 1);
1752
1753 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1754 assert!(
1755 extracted.contains("x = 1") && extracted.contains("y = 2"),
1756 "Extracted content should span across the empty line. Got: {extracted:?}"
1757 );
1758 }
1759
1760 #[test]
1761 fn test_mkdocs_content_byte_offsets_lf() {
1762 let config = default_config();
1763 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1764
1765 let content = "!!! note\n ```python\n print('hi')\n ```\n";
1766 let blocks = processor.extract_code_blocks(content);
1767 assert_eq!(blocks.len(), 1);
1768
1769 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1771 assert_eq!(extracted, " print('hi')\n", "Content offsets should be exact for LF");
1772 }
1773
1774 #[test]
1775 fn test_mkdocs_content_byte_offsets_crlf() {
1776 let config = default_config();
1777 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1778
1779 let content = "!!! note\r\n ```python\r\n print('hi')\r\n ```\r\n";
1780 let blocks = processor.extract_code_blocks(content);
1781 assert_eq!(blocks.len(), 1);
1782
1783 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1784 assert_eq!(
1785 extracted, " print('hi')\r\n",
1786 "Content offsets should be exact for CRLF"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_lint_enabled_false_skips_language_in_strict_mode() {
1792 let mut config = default_config();
1795 config.normalize_language = NormalizeLanguage::Exact;
1796 config.on_missing_language_definition = OnMissing::Fail;
1797
1798 config.languages.insert(
1800 "python".to_string(),
1801 LanguageToolConfig {
1802 lint: vec!["ruff:check".to_string()],
1803 ..Default::default()
1804 },
1805 );
1806 config.languages.insert(
1807 "plaintext".to_string(),
1808 LanguageToolConfig {
1809 enabled: false,
1810 ..Default::default()
1811 },
1812 );
1813
1814 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1815
1816 let content = "```plaintext\nsome text\n```";
1817 let result = processor.lint(content);
1818
1819 assert!(result.is_ok());
1821 let diagnostics = result.unwrap();
1822 assert!(
1823 diagnostics.is_empty(),
1824 "Expected no diagnostics for disabled language, got: {diagnostics:?}"
1825 );
1826 }
1827
1828 #[test]
1829 fn test_format_enabled_false_skips_language_in_strict_mode() {
1830 let mut config = default_config();
1832 config.normalize_language = NormalizeLanguage::Exact;
1833 config.on_missing_language_definition = OnMissing::Fail;
1834
1835 config.languages.insert(
1836 "plaintext".to_string(),
1837 LanguageToolConfig {
1838 enabled: false,
1839 ..Default::default()
1840 },
1841 );
1842
1843 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1844
1845 let content = "```plaintext\nsome text\n```";
1846 let result = processor.format(content);
1847
1848 assert!(result.is_ok());
1850 let output = result.unwrap();
1851 assert!(!output.had_errors, "Expected no errors for disabled language");
1852 assert!(
1853 output.error_messages.is_empty(),
1854 "Expected no error messages, got: {:?}",
1855 output.error_messages
1856 );
1857 }
1858
1859 #[test]
1860 fn test_enabled_false_default_true_preserved() {
1861 let mut config = default_config();
1863 config.on_missing_language_definition = OnMissing::Fail;
1864
1865 config.languages.insert(
1867 "python".to_string(),
1868 LanguageToolConfig {
1869 lint: vec!["ruff:check".to_string()],
1870 ..Default::default()
1871 },
1872 );
1873
1874 let lang_config = config.languages.get("python").unwrap();
1875 assert!(lang_config.enabled, "enabled should default to true");
1876 }
1877
1878 #[test]
1879 fn test_enabled_false_with_fail_fast_no_error() {
1880 let mut config = default_config();
1882 config.normalize_language = NormalizeLanguage::Exact;
1883 config.on_missing_language_definition = OnMissing::FailFast;
1884
1885 config.languages.insert(
1886 "unknown".to_string(),
1887 LanguageToolConfig {
1888 enabled: false,
1889 ..Default::default()
1890 },
1891 );
1892
1893 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1894
1895 let content = "```unknown\nsome content\n```";
1896 let result = processor.lint(content);
1897
1898 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
1900 assert!(result.unwrap().is_empty());
1901 }
1902
1903 #[test]
1904 fn test_enabled_false_format_with_fail_fast_no_error() {
1905 let mut config = default_config();
1907 config.normalize_language = NormalizeLanguage::Exact;
1908 config.on_missing_language_definition = OnMissing::FailFast;
1909
1910 config.languages.insert(
1911 "unknown".to_string(),
1912 LanguageToolConfig {
1913 enabled: false,
1914 ..Default::default()
1915 },
1916 );
1917
1918 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1919
1920 let content = "```unknown\nsome content\n```";
1921 let result = processor.format(content);
1922
1923 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
1924 let output = result.unwrap();
1925 assert!(!output.had_errors);
1926 }
1927
1928 #[test]
1929 fn test_enabled_false_with_tools_still_skips() {
1930 let mut config = default_config();
1932 config.on_missing_language_definition = OnMissing::Fail;
1933
1934 config.languages.insert(
1935 "python".to_string(),
1936 LanguageToolConfig {
1937 enabled: false,
1938 lint: vec!["ruff:check".to_string()],
1939 format: vec!["ruff:format".to_string()],
1940 on_error: None,
1941 },
1942 );
1943
1944 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1945
1946 let content = "```python\nprint('hello')\n```";
1947
1948 let lint_result = processor.lint(content);
1950 assert!(lint_result.is_ok());
1951 assert!(lint_result.unwrap().is_empty());
1952
1953 let format_result = processor.format(content);
1955 assert!(format_result.is_ok());
1956 let output = format_result.unwrap();
1957 assert!(!output.had_errors);
1958 assert_eq!(output.content, content, "Content should be unchanged");
1959 }
1960
1961 #[test]
1962 fn test_enabled_true_without_tools_triggers_strict_mode() {
1963 let mut config = default_config();
1966 config.on_missing_language_definition = OnMissing::Fail;
1967
1968 config.languages.insert(
1969 "python".to_string(),
1970 LanguageToolConfig {
1971 ..Default::default()
1973 },
1974 );
1975
1976 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1977
1978 let content = "```python\nprint('hello')\n```";
1979 let result = processor.lint(content);
1980
1981 assert!(result.is_ok());
1983 let diagnostics = result.unwrap();
1984 assert_eq!(diagnostics.len(), 1);
1985 assert!(diagnostics[0].message.contains("No lint tools configured"));
1986 }
1987
1988 #[test]
1989 fn test_mixed_enabled_and_disabled_languages() {
1990 let mut config = default_config();
1992 config.normalize_language = NormalizeLanguage::Exact;
1993 config.on_missing_language_definition = OnMissing::Fail;
1994
1995 config.languages.insert(
1996 "plaintext".to_string(),
1997 LanguageToolConfig {
1998 enabled: false,
1999 ..Default::default()
2000 },
2001 );
2002
2003 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2004
2005 let content = "\
2006```plaintext
2007some text
2008```
2009
2010```javascript
2011console.log('hi');
2012```
2013";
2014
2015 let result = processor.lint(content);
2016 assert!(result.is_ok());
2017 let diagnostics = result.unwrap();
2018
2019 assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2022 assert!(
2023 diagnostics[0].message.contains("javascript"),
2024 "Error should be about javascript, got: {}",
2025 diagnostics[0].message
2026 );
2027 }
2028
2029 #[test]
2030 fn test_generic_fallback_includes_all_stderr_lines() {
2031 let config = default_config();
2032 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2033
2034 let output = ToolOutput {
2036 stdout: String::new(),
2037 stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
2038 exit_code: 1,
2039 success: false,
2040 };
2041
2042 let diags = processor.parse_tool_output(&output, "tombi", 5);
2043 assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
2044 assert_eq!(diags[0].message, "Parse error at position 42");
2045 assert_eq!(diags[1].message, "Unexpected token '::'");
2046 assert_eq!(diags[2].message, "3 errors found");
2047 assert!(diags.iter().all(|d| d.tool == "tombi"));
2048 assert!(diags.iter().all(|d| d.file_line == 5));
2049 }
2050
2051 #[test]
2052 fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
2053 let config = default_config();
2054 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2055
2056 let output = ToolOutput {
2057 stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
2058 stderr: String::new(),
2059 exit_code: 1,
2060 success: false,
2061 };
2062
2063 let diags = processor.parse_tool_output(&output, "some-tool", 10);
2064 assert_eq!(diags.len(), 3);
2065 assert_eq!(diags[0].message, "Line 1 error");
2066 assert_eq!(diags[1].message, "Line 2 detail");
2067 assert_eq!(diags[2].message, "Line 3 summary");
2068 }
2069
2070 #[test]
2071 fn test_generic_fallback_skips_blank_lines() {
2072 let config = default_config();
2073 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2074
2075 let output = ToolOutput {
2076 stdout: String::new(),
2077 stderr: "error: bad input\n\n \n\ndetail: see above\n".to_string(),
2078 exit_code: 1,
2079 success: false,
2080 };
2081
2082 let diags = processor.parse_tool_output(&output, "tool", 1);
2083 assert_eq!(diags.len(), 2);
2084 assert_eq!(diags[0].message, "error: bad input");
2085 assert_eq!(diags[1].message, "detail: see above");
2086 }
2087
2088 #[test]
2089 fn test_generic_fallback_exit_code_when_no_output() {
2090 let config = default_config();
2091 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2092
2093 let output = ToolOutput {
2094 stdout: String::new(),
2095 stderr: String::new(),
2096 exit_code: 42,
2097 success: false,
2098 };
2099
2100 let diags = processor.parse_tool_output(&output, "tool", 1);
2101 assert_eq!(diags.len(), 1);
2102 assert_eq!(diags[0].message, "Tool exited with code 42");
2103 }
2104
2105 #[test]
2106 fn test_generic_fallback_not_triggered_on_success() {
2107 let config = default_config();
2108 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2109
2110 let output = ToolOutput {
2111 stdout: "some informational output".to_string(),
2112 stderr: String::new(),
2113 exit_code: 0,
2114 success: true,
2115 };
2116
2117 let diags = processor.parse_tool_output(&output, "tool", 1);
2118 assert!(
2119 diags.is_empty(),
2120 "Successful tool runs should produce no fallback diagnostics"
2121 );
2122 }
2123}