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 message = if !output.stderr.is_empty() {
823 output.stderr.lines().next().unwrap_or("Tool failed").to_string()
824 } else if !output.stdout.is_empty() {
825 output.stdout.lines().next().unwrap_or("Tool failed").to_string()
826 } else {
827 let exit_code = output.exit_code;
828 format!("Tool exited with code {exit_code}")
829 };
830
831 diagnostics.push(CodeBlockDiagnostic {
832 file_line: code_block_start_line,
833 column: None,
834 message,
835 severity: DiagnosticSeverity::Error,
836 tool: tool_id.to_string(),
837 code_block_start: code_block_start_line,
838 });
839 }
840
841 diagnostics
842 }
843
844 fn parse_standard_format(
846 &self,
847 line: &str,
848 tool_id: &str,
849 code_block_start_line: usize,
850 ) -> Option<CodeBlockDiagnostic> {
851 let mut parts = line.rsplitn(4, ':');
853 let message = parts.next()?.trim().to_string();
854 let part1 = parts.next()?.trim().to_string();
855 let part2 = parts.next()?.trim().to_string();
856 let part3 = parts.next().map(|s| s.trim().to_string());
857
858 let (line_part, col_part) = if part3.is_some() {
859 (part2, Some(part1))
860 } else {
861 (part1, None)
862 };
863
864 if let Ok(line_num) = line_part.parse::<usize>() {
865 let column = col_part.and_then(|s| s.parse::<usize>().ok());
866 let message = Self::strip_fixable_markers(&message);
867 if !message.is_empty() {
868 let severity = self.infer_severity(&message);
869 return Some(CodeBlockDiagnostic {
870 file_line: code_block_start_line + line_num,
871 column,
872 message,
873 severity,
874 tool: tool_id.to_string(),
875 code_block_start: code_block_start_line,
876 });
877 }
878 }
879 None
880 }
881
882 fn parse_eslint_format(
884 &self,
885 line: &str,
886 tool_id: &str,
887 code_block_start_line: usize,
888 ) -> Option<CodeBlockDiagnostic> {
889 let parts: Vec<&str> = line.splitn(3, ' ').collect();
891 if parts.len() >= 2 {
892 let loc_parts: Vec<&str> = parts[0].split(':').collect();
893 if loc_parts.len() == 2
894 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
895 {
896 let (sev_part, msg_part) = if parts.len() >= 3 {
897 (parts[1], parts[2])
898 } else {
899 (parts[1], "")
900 };
901 let message = if msg_part.is_empty() {
902 sev_part.to_string()
903 } else {
904 msg_part.to_string()
905 };
906 let message = Self::strip_fixable_markers(&message);
907 let severity = match sev_part.to_lowercase().as_str() {
908 "error" => DiagnosticSeverity::Error,
909 "warning" | "warn" => DiagnosticSeverity::Warning,
910 "info" => DiagnosticSeverity::Info,
911 _ => self.infer_severity(&message),
912 };
913 return Some(CodeBlockDiagnostic {
914 file_line: code_block_start_line + line_num,
915 column: Some(col),
916 message,
917 severity,
918 tool: tool_id.to_string(),
919 code_block_start: code_block_start_line,
920 });
921 }
922 }
923 None
924 }
925
926 fn parse_shellcheck_format(
928 &self,
929 line: &str,
930 tool_id: &str,
931 code_block_start_line: usize,
932 ) -> Option<CodeBlockDiagnostic> {
933 if line.starts_with("In ")
935 && line.contains(" line ")
936 && let Some(line_start) = line.find(" line ")
937 {
938 let after_line = &line[line_start + 6..];
939 if let Some(colon_pos) = after_line.find(':')
940 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
941 {
942 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
943 if !message.is_empty() {
944 let severity = self.infer_severity(&message);
945 return Some(CodeBlockDiagnostic {
946 file_line: code_block_start_line + line_num,
947 column: None,
948 message,
949 severity,
950 tool: tool_id.to_string(),
951 code_block_start: code_block_start_line,
952 });
953 }
954 }
955 }
956 None
957 }
958
959 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
961 if line.starts_with("In ")
962 && line.contains(" line ")
963 && let Some(line_start) = line.find(" line ")
964 {
965 let after_line = &line[line_start + 6..];
966 if let Some(colon_pos) = after_line.find(':') {
967 return after_line[..colon_pos].trim().parse::<usize>().ok();
968 }
969 }
970 None
971 }
972
973 fn parse_shellcheck_message(
975 &self,
976 line: &str,
977 tool_id: &str,
978 code_block_start_line: usize,
979 line_num: usize,
980 ) -> Option<CodeBlockDiagnostic> {
981 let sc_pos = line.find("SC")?;
982 let after_sc = &line[sc_pos + 2..];
983 let code_len = after_sc.chars().take_while(|c| c.is_ascii_digit()).count();
984 if code_len == 0 {
985 return None;
986 }
987 let after_code = &after_sc[code_len..];
988 let sev_start = after_code.find('(')? + 1;
989 let sev_end = after_code[sev_start..].find(')')? + sev_start;
990 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
991 let message_start = after_code.find("):")? + 2;
992 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
993 if message.is_empty() {
994 return None;
995 }
996
997 let severity = match sev.as_str() {
998 "error" => DiagnosticSeverity::Error,
999 "warning" | "warn" => DiagnosticSeverity::Warning,
1000 "info" | "style" => DiagnosticSeverity::Info,
1001 _ => self.infer_severity(&message),
1002 };
1003
1004 Some(CodeBlockDiagnostic {
1005 file_line: code_block_start_line + line_num,
1006 column: None,
1007 message,
1008 severity,
1009 tool: tool_id.to_string(),
1010 code_block_start: code_block_start_line,
1011 })
1012 }
1013
1014 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
1016 let lower = message.to_lowercase();
1017 if lower.contains("error")
1018 || lower.starts_with("e") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1019 || lower.starts_with("f") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1020 {
1021 DiagnosticSeverity::Error
1022 } else if lower.contains("warning")
1023 || lower.contains("warn")
1024 || lower.starts_with("w") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
1025 {
1026 DiagnosticSeverity::Warning
1027 } else {
1028 DiagnosticSeverity::Info
1029 }
1030 }
1031
1032 fn strip_fixable_markers(message: &str) -> String {
1039 message
1040 .replace(" [*]", "")
1041 .replace("[*] ", "")
1042 .replace("[*]", "")
1043 .replace(" (fixable)", "")
1044 .replace("(fixable) ", "")
1045 .replace("(fixable)", "")
1046 .replace(" [fix available]", "")
1047 .replace("[fix available] ", "")
1048 .replace("[fix available]", "")
1049 .replace(" [autofix]", "")
1050 .replace("[autofix] ", "")
1051 .replace("[autofix]", "")
1052 .trim()
1053 .to_string()
1054 }
1055}
1056
1057struct FencedCodeBlockBuilder {
1059 start_line: usize,
1060 content_start: usize,
1061 language: String,
1062 info_string: String,
1063 fence_char: char,
1064 fence_length: usize,
1065 indent: usize,
1066 indent_prefix: String,
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072
1073 fn default_config() -> CodeBlockToolsConfig {
1074 CodeBlockToolsConfig::default()
1075 }
1076
1077 #[test]
1078 fn test_extract_code_blocks() {
1079 let config = default_config();
1080 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1081
1082 let content = r#"# Example
1083
1084```python
1085def hello():
1086 print("Hello")
1087```
1088
1089Some text
1090
1091```rust
1092fn main() {}
1093```
1094"#;
1095
1096 let blocks = processor.extract_code_blocks(content);
1097
1098 assert_eq!(blocks.len(), 2);
1099
1100 assert_eq!(blocks[0].language, "python");
1101 assert_eq!(blocks[0].fence_char, '`');
1102 assert_eq!(blocks[0].fence_length, 3);
1103 assert_eq!(blocks[0].start_line, 2);
1104 assert_eq!(blocks[0].indent, 0);
1105 assert_eq!(blocks[0].indent_prefix, "");
1106
1107 assert_eq!(blocks[1].language, "rust");
1108 assert_eq!(blocks[1].fence_char, '`');
1109 assert_eq!(blocks[1].fence_length, 3);
1110 }
1111
1112 #[test]
1113 fn test_extract_code_blocks_with_info_string() {
1114 let config = default_config();
1115 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1116
1117 let content = "```python title=\"example.py\"\ncode\n```";
1118 let blocks = processor.extract_code_blocks(content);
1119
1120 assert_eq!(blocks.len(), 1);
1121 assert_eq!(blocks[0].language, "python");
1122 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
1123 }
1124
1125 #[test]
1126 fn test_extract_code_blocks_tilde_fence() {
1127 let config = default_config();
1128 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1129
1130 let content = "~~~bash\necho hello\n~~~";
1131 let blocks = processor.extract_code_blocks(content);
1132
1133 assert_eq!(blocks.len(), 1);
1134 assert_eq!(blocks[0].language, "bash");
1135 assert_eq!(blocks[0].fence_char, '~');
1136 assert_eq!(blocks[0].fence_length, 3);
1137 assert_eq!(blocks[0].indent_prefix, "");
1138 }
1139
1140 #[test]
1141 fn test_extract_code_blocks_with_indent_prefix() {
1142 let config = default_config();
1143 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1144
1145 let content = " - item\n ```python\n print('hi')\n ```";
1146 let blocks = processor.extract_code_blocks(content);
1147
1148 assert_eq!(blocks.len(), 1);
1149 assert_eq!(blocks[0].indent_prefix, " ");
1150 }
1151
1152 #[test]
1153 fn test_extract_code_blocks_no_language() {
1154 let config = default_config();
1155 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1156
1157 let content = "```\nplain code\n```";
1158 let blocks = processor.extract_code_blocks(content);
1159
1160 assert_eq!(blocks.len(), 1);
1161 assert_eq!(blocks[0].language, "");
1162 }
1163
1164 #[test]
1165 fn test_resolve_language_linguist() {
1166 let mut config = default_config();
1167 config.normalize_language = NormalizeLanguage::Linguist;
1168 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1169
1170 assert_eq!(processor.resolve_language("py"), "python");
1171 assert_eq!(processor.resolve_language("bash"), "shell");
1172 assert_eq!(processor.resolve_language("js"), "javascript");
1173 }
1174
1175 #[test]
1176 fn test_resolve_language_exact() {
1177 let mut config = default_config();
1178 config.normalize_language = NormalizeLanguage::Exact;
1179 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1180
1181 assert_eq!(processor.resolve_language("py"), "py");
1182 assert_eq!(processor.resolve_language("BASH"), "bash");
1183 }
1184
1185 #[test]
1186 fn test_resolve_language_user_alias_override() {
1187 let mut config = default_config();
1188 config.language_aliases.insert("py".to_string(), "python".to_string());
1189 config.normalize_language = NormalizeLanguage::Exact;
1190 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1191
1192 assert_eq!(processor.resolve_language("PY"), "python");
1193 }
1194
1195 #[test]
1196 fn test_indent_strip_and_reapply_roundtrip() {
1197 let config = default_config();
1198 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1199
1200 let raw = " def hello():\n print('hi')";
1201 let stripped = processor.strip_indent_from_block(raw, " ");
1202 assert_eq!(stripped, "def hello():\n print('hi')");
1203
1204 let reapplied = processor.apply_indent_to_block(&stripped, " ");
1205 assert_eq!(reapplied, raw);
1206 }
1207
1208 #[test]
1209 fn test_infer_severity() {
1210 let config = default_config();
1211 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1212
1213 assert_eq!(
1214 processor.infer_severity("E501 line too long"),
1215 DiagnosticSeverity::Error
1216 );
1217 assert_eq!(
1218 processor.infer_severity("W291 trailing whitespace"),
1219 DiagnosticSeverity::Warning
1220 );
1221 assert_eq!(
1222 processor.infer_severity("error: something failed"),
1223 DiagnosticSeverity::Error
1224 );
1225 assert_eq!(
1226 processor.infer_severity("warning: unused variable"),
1227 DiagnosticSeverity::Warning
1228 );
1229 assert_eq!(
1230 processor.infer_severity("note: consider using"),
1231 DiagnosticSeverity::Info
1232 );
1233 }
1234
1235 #[test]
1236 fn test_parse_standard_format_windows_path() {
1237 let config = default_config();
1238 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1239
1240 let output = ToolOutput {
1241 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
1242 stderr: String::new(),
1243 exit_code: 1,
1244 success: false,
1245 };
1246
1247 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
1248 assert_eq!(diags.len(), 1);
1249 assert_eq!(diags[0].file_line, 12);
1250 assert_eq!(diags[0].column, Some(5));
1251 assert_eq!(diags[0].message, "E123 message");
1252 }
1253
1254 #[test]
1255 fn test_parse_eslint_severity() {
1256 let config = default_config();
1257 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1258
1259 let output = ToolOutput {
1260 stdout: "1:2 error Unexpected token".to_string(),
1261 stderr: String::new(),
1262 exit_code: 1,
1263 success: false,
1264 };
1265
1266 let diags = processor.parse_tool_output(&output, "eslint", 5);
1267 assert_eq!(diags.len(), 1);
1268 assert_eq!(diags[0].file_line, 6);
1269 assert_eq!(diags[0].column, Some(2));
1270 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
1271 assert_eq!(diags[0].message, "Unexpected token");
1272 }
1273
1274 #[test]
1275 fn test_parse_shellcheck_multiline() {
1276 let config = default_config();
1277 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1278
1279 let output = ToolOutput {
1280 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
1281 stderr: String::new(),
1282 exit_code: 1,
1283 success: false,
1284 };
1285
1286 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
1287 assert_eq!(diags.len(), 1);
1288 assert_eq!(diags[0].file_line, 13);
1289 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
1290 assert_eq!(diags[0].message, "Double quote to prevent globbing");
1291 }
1292
1293 #[test]
1294 fn test_lint_no_config() {
1295 let config = default_config();
1296 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1297
1298 let content = "```python\nprint('hello')\n```";
1299 let result = processor.lint(content);
1300
1301 assert!(result.is_ok());
1303 assert!(result.unwrap().is_empty());
1304 }
1305
1306 #[test]
1307 fn test_format_no_config() {
1308 let config = default_config();
1309 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1310
1311 let content = "```python\nprint('hello')\n```";
1312 let result = processor.format(content);
1313
1314 assert!(result.is_ok());
1316 let output = result.unwrap();
1317 assert_eq!(output.content, content);
1318 assert!(!output.had_errors);
1319 assert!(output.error_messages.is_empty());
1320 }
1321
1322 #[test]
1323 fn test_lint_on_missing_language_definition_fail() {
1324 let mut config = default_config();
1325 config.on_missing_language_definition = OnMissing::Fail;
1326 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1327
1328 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1329 let result = processor.lint(content);
1330
1331 assert!(result.is_ok());
1333 let diagnostics = result.unwrap();
1334 assert_eq!(diagnostics.len(), 2);
1335 assert!(diagnostics[0].message.contains("No lint tools configured"));
1336 assert!(diagnostics[0].message.contains("python"));
1337 assert!(diagnostics[1].message.contains("javascript"));
1338 }
1339
1340 #[test]
1341 fn test_lint_on_missing_language_definition_fail_fast() {
1342 let mut config = default_config();
1343 config.on_missing_language_definition = OnMissing::FailFast;
1344 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1345
1346 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
1347 let result = processor.lint(content);
1348
1349 assert!(result.is_err());
1351 let err = result.unwrap_err();
1352 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1353 }
1354
1355 #[test]
1356 fn test_format_on_missing_language_definition_fail() {
1357 let mut config = default_config();
1358 config.on_missing_language_definition = OnMissing::Fail;
1359 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1360
1361 let content = "```python\nprint('hello')\n```";
1362 let result = processor.format(content);
1363
1364 assert!(result.is_ok());
1366 let output = result.unwrap();
1367 assert_eq!(output.content, content); assert!(output.had_errors);
1369 assert!(!output.error_messages.is_empty());
1370 assert!(output.error_messages[0].contains("No format tools configured"));
1371 }
1372
1373 #[test]
1374 fn test_format_on_missing_language_definition_fail_fast() {
1375 let mut config = default_config();
1376 config.on_missing_language_definition = OnMissing::FailFast;
1377 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1378
1379 let content = "```python\nprint('hello')\n```";
1380 let result = processor.format(content);
1381
1382 assert!(result.is_err());
1384 let err = result.unwrap_err();
1385 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
1386 }
1387
1388 #[test]
1389 fn test_lint_on_missing_tool_binary_fail() {
1390 use super::super::config::{LanguageToolConfig, ToolDefinition};
1391
1392 let mut config = default_config();
1393 config.on_missing_tool_binary = OnMissing::Fail;
1394
1395 let lang_config = LanguageToolConfig {
1397 lint: vec!["nonexistent-linter".to_string()],
1398 ..Default::default()
1399 };
1400 config.languages.insert("python".to_string(), lang_config);
1401
1402 let tool_def = ToolDefinition {
1403 command: vec!["nonexistent-binary-xyz123".to_string()],
1404 ..Default::default()
1405 };
1406 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1407
1408 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1409
1410 let content = "```python\nprint('hello')\n```";
1411 let result = processor.lint(content);
1412
1413 assert!(result.is_ok());
1415 let diagnostics = result.unwrap();
1416 assert_eq!(diagnostics.len(), 1);
1417 assert!(diagnostics[0].message.contains("not found in PATH"));
1418 }
1419
1420 #[test]
1421 fn test_lint_on_missing_tool_binary_fail_fast() {
1422 use super::super::config::{LanguageToolConfig, ToolDefinition};
1423
1424 let mut config = default_config();
1425 config.on_missing_tool_binary = OnMissing::FailFast;
1426
1427 let lang_config = LanguageToolConfig {
1429 lint: vec!["nonexistent-linter".to_string()],
1430 ..Default::default()
1431 };
1432 config.languages.insert("python".to_string(), lang_config);
1433
1434 let tool_def = ToolDefinition {
1435 command: vec!["nonexistent-binary-xyz123".to_string()],
1436 ..Default::default()
1437 };
1438 config.tools.insert("nonexistent-linter".to_string(), tool_def);
1439
1440 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1441
1442 let content = "```python\nprint('hello')\n```";
1443 let result = processor.lint(content);
1444
1445 assert!(result.is_err());
1447 let err = result.unwrap_err();
1448 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1449 }
1450
1451 #[test]
1452 fn test_format_on_missing_tool_binary_fail() {
1453 use super::super::config::{LanguageToolConfig, ToolDefinition};
1454
1455 let mut config = default_config();
1456 config.on_missing_tool_binary = OnMissing::Fail;
1457
1458 let lang_config = LanguageToolConfig {
1460 format: vec!["nonexistent-formatter".to_string()],
1461 ..Default::default()
1462 };
1463 config.languages.insert("python".to_string(), lang_config);
1464
1465 let tool_def = ToolDefinition {
1466 command: vec!["nonexistent-binary-xyz123".to_string()],
1467 ..Default::default()
1468 };
1469 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1470
1471 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1472
1473 let content = "```python\nprint('hello')\n```";
1474 let result = processor.format(content);
1475
1476 assert!(result.is_ok());
1478 let output = result.unwrap();
1479 assert_eq!(output.content, content); assert!(output.had_errors);
1481 assert!(!output.error_messages.is_empty());
1482 assert!(output.error_messages[0].contains("not found in PATH"));
1483 }
1484
1485 #[test]
1486 fn test_format_on_missing_tool_binary_fail_fast() {
1487 use super::super::config::{LanguageToolConfig, ToolDefinition};
1488
1489 let mut config = default_config();
1490 config.on_missing_tool_binary = OnMissing::FailFast;
1491
1492 let lang_config = LanguageToolConfig {
1494 format: vec!["nonexistent-formatter".to_string()],
1495 ..Default::default()
1496 };
1497 config.languages.insert("python".to_string(), lang_config);
1498
1499 let tool_def = ToolDefinition {
1500 command: vec!["nonexistent-binary-xyz123".to_string()],
1501 ..Default::default()
1502 };
1503 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
1504
1505 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1506
1507 let content = "```python\nprint('hello')\n```";
1508 let result = processor.format(content);
1509
1510 assert!(result.is_err());
1512 let err = result.unwrap_err();
1513 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
1514 }
1515
1516 #[test]
1517 fn test_lint_rumdl_builtin_skipped_for_markdown() {
1518 let mut config = default_config();
1521 config.languages.insert(
1522 "markdown".to_string(),
1523 LanguageToolConfig {
1524 lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
1525 ..Default::default()
1526 },
1527 );
1528 config.on_missing_language_definition = OnMissing::Fail;
1529 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1530
1531 let content = "```markdown\n# Hello\n```";
1532 let result = processor.lint(content);
1533
1534 assert!(result.is_ok());
1536 assert!(result.unwrap().is_empty());
1537 }
1538
1539 #[test]
1540 fn test_format_rumdl_builtin_skipped_for_markdown() {
1541 let mut config = default_config();
1543 config.languages.insert(
1544 "markdown".to_string(),
1545 LanguageToolConfig {
1546 format: vec![RUMDL_BUILTIN_TOOL.to_string()],
1547 ..Default::default()
1548 },
1549 );
1550 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1551
1552 let content = "```markdown\n# Hello\n```";
1553 let result = processor.format(content);
1554
1555 assert!(result.is_ok());
1557 let output = result.unwrap();
1558 assert_eq!(output.content, content);
1559 assert!(!output.had_errors);
1560 }
1561
1562 #[test]
1563 fn test_is_markdown_language() {
1564 assert!(is_markdown_language("markdown"));
1566 assert!(is_markdown_language("Markdown"));
1567 assert!(is_markdown_language("MARKDOWN"));
1568 assert!(is_markdown_language("md"));
1569 assert!(is_markdown_language("MD"));
1570 assert!(!is_markdown_language("python"));
1571 assert!(!is_markdown_language("rust"));
1572 assert!(!is_markdown_language(""));
1573 }
1574
1575 #[test]
1578 fn test_extract_mkdocs_admonition_code_block() {
1579 let config = default_config();
1580 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1581
1582 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
1583 let blocks = processor.extract_code_blocks(content);
1584
1585 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
1586 assert_eq!(blocks[0].language, "python");
1587 }
1588
1589 #[test]
1590 fn test_extract_mkdocs_tab_code_block() {
1591 let config = default_config();
1592 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1593
1594 let content = "=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n";
1595 let blocks = processor.extract_code_blocks(content);
1596
1597 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
1598 assert_eq!(blocks[0].language, "python");
1599 }
1600
1601 #[test]
1602 fn test_standard_flavor_ignores_admonition_indented_content() {
1603 let config = default_config();
1604 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1605
1606 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
1609 let blocks = processor.extract_code_blocks(content);
1610
1611 for (i, b) in blocks.iter().enumerate() {
1615 for (j, b2) in blocks.iter().enumerate() {
1616 if i != j {
1617 assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
1618 }
1619 }
1620 }
1621 }
1622
1623 #[test]
1624 fn test_mkdocs_top_level_blocks_alongside_admonition() {
1625 let config = default_config();
1626 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1627
1628 let content =
1629 "```rust\nfn main() {}\n```\n\n!!! note\n Some text\n\n ```python\n print(\"hello\")\n ```\n";
1630 let blocks = processor.extract_code_blocks(content);
1631
1632 assert_eq!(
1633 blocks.len(),
1634 2,
1635 "Should detect both top-level and admonition code blocks"
1636 );
1637 assert_eq!(blocks[0].language, "rust");
1638 assert_eq!(blocks[1].language, "python");
1639 }
1640
1641 #[test]
1642 fn test_mkdocs_nested_admonition_code_block() {
1643 let config = default_config();
1644 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1645
1646 let content = "\
1647!!! note
1648 Some text
1649
1650 !!! warning
1651 Nested content
1652
1653 ```python
1654 x = 1
1655 ```
1656";
1657 let blocks = processor.extract_code_blocks(content);
1658 assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
1659 assert_eq!(blocks[0].language, "python");
1660 }
1661
1662 #[test]
1663 fn test_mkdocs_consecutive_admonitions_no_stale_context() {
1664 let config = default_config();
1665 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1666
1667 let content = "\
1670!!! note
1671 First admonition content
1672
1673!!! warning
1674 Second admonition content
1675
1676 ```python
1677 y = 2
1678 ```
1679";
1680 let blocks = processor.extract_code_blocks(content);
1681 assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
1682 assert_eq!(blocks[0].language, "python");
1683 }
1684
1685 #[test]
1686 fn test_mkdocs_crlf_line_endings() {
1687 let config = default_config();
1688 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1689
1690 let content = "!!! note\r\n Some text\r\n\r\n ```python\r\n x = 1\r\n ```\r\n";
1692 let blocks = processor.extract_code_blocks(content);
1693
1694 assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
1695 assert_eq!(blocks[0].language, "python");
1696
1697 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1699 assert!(
1700 extracted.contains("x = 1"),
1701 "Extracted content should contain code. Got: {extracted:?}"
1702 );
1703 }
1704
1705 #[test]
1706 fn test_mkdocs_unclosed_fence_in_admonition() {
1707 let config = default_config();
1708 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1709
1710 let content = "!!! note\n ```python\n x = 1\n no closing fence\n";
1712 let blocks = processor.extract_code_blocks(content);
1713 assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
1714 }
1715
1716 #[test]
1717 fn test_mkdocs_tilde_fence_in_admonition() {
1718 let config = default_config();
1719 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1720
1721 let content = "!!! note\n ~~~ruby\n puts 'hi'\n ~~~\n";
1722 let blocks = processor.extract_code_blocks(content);
1723 assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
1724 assert_eq!(blocks[0].language, "ruby");
1725 }
1726
1727 #[test]
1728 fn test_mkdocs_empty_lines_in_code_block() {
1729 let config = default_config();
1730 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1731
1732 let content = "!!! note\n ```python\n x = 1\n\n y = 2\n ```\n";
1735 let blocks = processor.extract_code_blocks(content);
1736 assert_eq!(blocks.len(), 1);
1737
1738 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1739 assert!(
1740 extracted.contains("x = 1") && extracted.contains("y = 2"),
1741 "Extracted content should span across the empty line. Got: {extracted:?}"
1742 );
1743 }
1744
1745 #[test]
1746 fn test_mkdocs_content_byte_offsets_lf() {
1747 let config = default_config();
1748 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1749
1750 let content = "!!! note\n ```python\n print('hi')\n ```\n";
1751 let blocks = processor.extract_code_blocks(content);
1752 assert_eq!(blocks.len(), 1);
1753
1754 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1756 assert_eq!(extracted, " print('hi')\n", "Content offsets should be exact for LF");
1757 }
1758
1759 #[test]
1760 fn test_mkdocs_content_byte_offsets_crlf() {
1761 let config = default_config();
1762 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
1763
1764 let content = "!!! note\r\n ```python\r\n print('hi')\r\n ```\r\n";
1765 let blocks = processor.extract_code_blocks(content);
1766 assert_eq!(blocks.len(), 1);
1767
1768 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
1769 assert_eq!(
1770 extracted, " print('hi')\r\n",
1771 "Content offsets should be exact for CRLF"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_lint_enabled_false_skips_language_in_strict_mode() {
1777 let mut config = default_config();
1780 config.normalize_language = NormalizeLanguage::Exact;
1781 config.on_missing_language_definition = OnMissing::Fail;
1782
1783 config.languages.insert(
1785 "python".to_string(),
1786 LanguageToolConfig {
1787 lint: vec!["ruff:check".to_string()],
1788 ..Default::default()
1789 },
1790 );
1791 config.languages.insert(
1792 "plaintext".to_string(),
1793 LanguageToolConfig {
1794 enabled: false,
1795 ..Default::default()
1796 },
1797 );
1798
1799 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1800
1801 let content = "```plaintext\nsome text\n```";
1802 let result = processor.lint(content);
1803
1804 assert!(result.is_ok());
1806 let diagnostics = result.unwrap();
1807 assert!(
1808 diagnostics.is_empty(),
1809 "Expected no diagnostics for disabled language, got: {diagnostics:?}"
1810 );
1811 }
1812
1813 #[test]
1814 fn test_format_enabled_false_skips_language_in_strict_mode() {
1815 let mut config = default_config();
1817 config.normalize_language = NormalizeLanguage::Exact;
1818 config.on_missing_language_definition = OnMissing::Fail;
1819
1820 config.languages.insert(
1821 "plaintext".to_string(),
1822 LanguageToolConfig {
1823 enabled: false,
1824 ..Default::default()
1825 },
1826 );
1827
1828 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1829
1830 let content = "```plaintext\nsome text\n```";
1831 let result = processor.format(content);
1832
1833 assert!(result.is_ok());
1835 let output = result.unwrap();
1836 assert!(!output.had_errors, "Expected no errors for disabled language");
1837 assert!(
1838 output.error_messages.is_empty(),
1839 "Expected no error messages, got: {:?}",
1840 output.error_messages
1841 );
1842 }
1843
1844 #[test]
1845 fn test_enabled_false_default_true_preserved() {
1846 let mut config = default_config();
1848 config.on_missing_language_definition = OnMissing::Fail;
1849
1850 config.languages.insert(
1852 "python".to_string(),
1853 LanguageToolConfig {
1854 lint: vec!["ruff:check".to_string()],
1855 ..Default::default()
1856 },
1857 );
1858
1859 let lang_config = config.languages.get("python").unwrap();
1860 assert!(lang_config.enabled, "enabled should default to true");
1861 }
1862
1863 #[test]
1864 fn test_enabled_false_with_fail_fast_no_error() {
1865 let mut config = default_config();
1867 config.normalize_language = NormalizeLanguage::Exact;
1868 config.on_missing_language_definition = OnMissing::FailFast;
1869
1870 config.languages.insert(
1871 "unknown".to_string(),
1872 LanguageToolConfig {
1873 enabled: false,
1874 ..Default::default()
1875 },
1876 );
1877
1878 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1879
1880 let content = "```unknown\nsome content\n```";
1881 let result = processor.lint(content);
1882
1883 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
1885 assert!(result.unwrap().is_empty());
1886 }
1887
1888 #[test]
1889 fn test_enabled_false_format_with_fail_fast_no_error() {
1890 let mut config = default_config();
1892 config.normalize_language = NormalizeLanguage::Exact;
1893 config.on_missing_language_definition = OnMissing::FailFast;
1894
1895 config.languages.insert(
1896 "unknown".to_string(),
1897 LanguageToolConfig {
1898 enabled: false,
1899 ..Default::default()
1900 },
1901 );
1902
1903 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1904
1905 let content = "```unknown\nsome content\n```";
1906 let result = processor.format(content);
1907
1908 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
1909 let output = result.unwrap();
1910 assert!(!output.had_errors);
1911 }
1912
1913 #[test]
1914 fn test_enabled_false_with_tools_still_skips() {
1915 let mut config = default_config();
1917 config.on_missing_language_definition = OnMissing::Fail;
1918
1919 config.languages.insert(
1920 "python".to_string(),
1921 LanguageToolConfig {
1922 enabled: false,
1923 lint: vec!["ruff:check".to_string()],
1924 format: vec!["ruff:format".to_string()],
1925 on_error: None,
1926 },
1927 );
1928
1929 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1930
1931 let content = "```python\nprint('hello')\n```";
1932
1933 let lint_result = processor.lint(content);
1935 assert!(lint_result.is_ok());
1936 assert!(lint_result.unwrap().is_empty());
1937
1938 let format_result = processor.format(content);
1940 assert!(format_result.is_ok());
1941 let output = format_result.unwrap();
1942 assert!(!output.had_errors);
1943 assert_eq!(output.content, content, "Content should be unchanged");
1944 }
1945
1946 #[test]
1947 fn test_enabled_true_without_tools_triggers_strict_mode() {
1948 let mut config = default_config();
1951 config.on_missing_language_definition = OnMissing::Fail;
1952
1953 config.languages.insert(
1954 "python".to_string(),
1955 LanguageToolConfig {
1956 ..Default::default()
1958 },
1959 );
1960
1961 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
1962
1963 let content = "```python\nprint('hello')\n```";
1964 let result = processor.lint(content);
1965
1966 assert!(result.is_ok());
1968 let diagnostics = result.unwrap();
1969 assert_eq!(diagnostics.len(), 1);
1970 assert!(diagnostics[0].message.contains("No lint tools configured"));
1971 }
1972
1973 #[test]
1974 fn test_mixed_enabled_and_disabled_languages() {
1975 let mut config = default_config();
1977 config.normalize_language = NormalizeLanguage::Exact;
1978 config.on_missing_language_definition = OnMissing::Fail;
1979
1980 config.languages.insert(
1981 "plaintext".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 = "\
1991```plaintext
1992some text
1993```
1994
1995```javascript
1996console.log('hi');
1997```
1998";
1999
2000 let result = processor.lint(content);
2001 assert!(result.is_ok());
2002 let diagnostics = result.unwrap();
2003
2004 assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
2007 assert!(
2008 diagnostics[0].message.contains("javascript"),
2009 "Error should be about javascript, got: {}",
2010 diagnostics[0].message
2011 );
2012 }
2013}