1use super::config::{CodeBlockToolsConfig, FormatMode, LanguageToolConfig, NormalizeLanguage, OnError, OnMissing};
7use super::executor::{ExecutorError, FormatCheckOutput, ToolExecutor, ToolOutput};
8use super::linguist::LinguistResolver;
9use super::registry::{BuiltinLintMode, ToolRegistry, ToolSlot};
10use crate::config::MarkdownFlavor;
11use crate::rule::{LintWarning, Severity};
12use crate::utils::parser_options::rumdl_parser_options_for;
13use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
14#[cfg(feature = "parallel")]
15use rayon::prelude::*;
16
17pub const RUMDL_BUILTIN_TOOL: &str = "rumdl";
21
22pub fn is_rumdl_builtin(tool_id: &str) -> bool {
24 matches!(tool_id, RUMDL_BUILTIN_TOOL | "rumdl:lint" | "rumdl:format")
25}
26
27fn is_markdown_language(lang: &str) -> bool {
29 matches!(lang.to_lowercase().as_str(), "markdown" | "md")
30}
31
32fn strip_ansi_codes(s: &str) -> String {
37 let mut result = String::with_capacity(s.len());
38 let mut chars = s.chars().peekable();
39 while let Some(c) = chars.next() {
40 if c == '\x1b' {
41 if chars.peek() == Some(&'[') {
42 chars.next();
43 while let Some(&next) = chars.peek() {
45 chars.next();
46 if next.is_ascii_alphabetic() {
47 break;
48 }
49 }
50 }
51 } else {
52 result.push(c);
53 }
54 }
55 result
56}
57
58fn decode_workflow_command(message: &str) -> String {
64 let mut decoded = String::with_capacity(message.len());
65 let mut rest = message;
66
67 while let Some(index) = rest.find('%') {
68 decoded.push_str(&rest[..index]);
69 let escape = rest[index..].get(..3).unwrap_or_default();
70 let replacement = if escape.eq_ignore_ascii_case("%25") {
71 "%"
72 } else if escape.eq_ignore_ascii_case("%0a") || escape.eq_ignore_ascii_case("%0d") {
73 " "
74 } else if escape.eq_ignore_ascii_case("%3a") {
75 ":"
76 } else if escape.eq_ignore_ascii_case("%2c") {
77 ","
78 } else {
79 decoded.push('%');
81 rest = &rest[index + 1..];
82 continue;
83 };
84 decoded.push_str(replacement);
85 rest = &rest[index + 3..];
86 }
87
88 decoded.push_str(rest);
89 decoded
90}
91
92fn ensure_trailing_newline(content: &str) -> std::borrow::Cow<'_, str> {
101 if content.is_empty() || content.ends_with('\n') {
102 std::borrow::Cow::Borrowed(content)
103 } else {
104 std::borrow::Cow::Owned(format!("{content}\n"))
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct FencedCodeBlockInfo {
111 pub start_line: usize,
113 pub end_line: usize,
116 pub content_start: usize,
119 pub content_end: usize,
122 pub language: String,
124 pub info_string: String,
126 pub fence_char: char,
128 pub fence_length: usize,
130 pub content_prefix: String,
134 pub code: String,
137 pub line_prefix_widths: Vec<usize>,
140}
141
142impl FencedCodeBlockInfo {
143 pub fn source_for(&self, code: &str) -> String {
146 apply_content_prefix(code, &self.content_prefix)
147 }
148
149 pub fn document_column(&self, line: usize, column: usize) -> Option<usize> {
152 let width = self.line_prefix_widths.get(line.checked_sub(1)?)?;
153 Some(column + width)
154 }
155}
156
157#[derive(Debug, Clone)]
159pub struct CodeBlockDiagnostic {
160 pub file_line: usize,
162 pub column: Option<usize>,
164 pub message: String,
166 pub severity: DiagnosticSeverity,
168 pub tool: String,
170 pub code_block_start: usize,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum DiagnosticSeverity {
177 Error,
178 Warning,
179 Info,
180}
181
182impl CodeBlockDiagnostic {
183 pub fn refused_write_back(line: usize) -> Self {
187 Self {
188 file_line: line,
189 column: None,
190 message:
191 "Formatted code cannot be written back without changing the document's structure; block left unchanged"
192 .to_string(),
193 severity: DiagnosticSeverity::Error,
194 tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
195 code_block_start: line,
196 }
197 }
198
199 pub fn to_lint_warning(&self) -> LintWarning {
200 let severity = match self.severity {
201 DiagnosticSeverity::Error => Severity::Error,
202 DiagnosticSeverity::Warning => Severity::Warning,
203 DiagnosticSeverity::Info => Severity::Info,
204 };
205
206 LintWarning {
207 message: self.message.clone(),
208 line: self.file_line,
209 column: self.column.unwrap_or(1),
210 end_line: self.file_line,
211 end_column: self.column.unwrap_or(1),
212 severity,
213 fix: None, rule_name: Some(self.tool.clone()),
215 }
216 }
217}
218
219#[derive(Debug, Clone)]
221pub enum ProcessorError {
222 ToolError(ExecutorError),
224 ToolErrorAt {
226 error: ExecutorError,
227 line: usize,
228 language: String,
229 },
230 NoToolsConfigured { language: String, line: usize },
232 ToolBinaryNotFound {
234 tool: String,
235 language: String,
236 line: usize,
237 },
238 Policy { message: String, line: usize },
240 Aborted { message: String },
242}
243
244impl std::fmt::Display for ProcessorError {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 match self {
247 Self::ToolError(e) => write!(f, "{e}"),
248 Self::ToolErrorAt { error, line, language } => {
249 write!(f, "line {line} ({language}): {error}")
250 }
251 Self::NoToolsConfigured { language, line } => {
252 write!(f, "line {line} ({language}): no tools configured")
253 }
254 Self::ToolBinaryNotFound { tool, language, line } => {
255 write!(
256 f,
257 "line {line} ({language}): tool '{tool}' not found in allowed lookup locations"
258 )
259 }
260 Self::Policy { message, line } => write!(f, "line {line}: {message}"),
261 Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
262 }
263 }
264}
265
266impl ProcessorError {
267 pub fn line(&self) -> Option<usize> {
269 match self {
270 Self::ToolErrorAt { line, .. }
271 | Self::NoToolsConfigured { line, .. }
272 | Self::ToolBinaryNotFound { line, .. }
273 | Self::Policy { line, .. } => Some(*line),
274 Self::ToolError(_) | Self::Aborted { .. } => None,
275 }
276 }
277
278 fn detail(&self) -> String {
280 match self {
281 Self::ToolErrorAt { error, .. } => error.to_string(),
282 Self::NoToolsConfigured { language, .. } => format!("No tools configured for language '{language}'"),
283 Self::ToolBinaryNotFound { tool, .. } => {
284 format!("Tool binary '{tool}' not found in allowed lookup locations")
285 }
286 Self::Policy { message, .. } => message.clone(),
287 Self::ToolError(_) | Self::Aborted { .. } => self.to_string(),
288 }
289 }
290
291 pub fn to_lint_warning(&self) -> LintWarning {
297 let line = self.line().unwrap_or(1);
298 LintWarning {
299 message: self.detail(),
300 line,
301 column: 1,
302 end_line: line,
303 end_column: 1,
304 severity: Severity::Error,
305 fix: None,
306 rule_name: Some(CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string()),
307 }
308 }
309}
310
311pub const CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME: &str = "code-block-tools";
316
317impl std::error::Error for ProcessorError {}
318
319impl From<ExecutorError> for ProcessorError {
320 fn from(e: ExecutorError) -> Self {
321 Self::ToolError(e)
322 }
323}
324
325#[derive(Debug)]
327pub struct CodeBlockResult {
328 pub diagnostics: Vec<CodeBlockDiagnostic>,
330 pub formatted_content: Option<String>,
332 pub was_modified: bool,
334}
335
336#[derive(Debug)]
338pub struct FormatOutput {
339 pub content: String,
341 pub had_errors: bool,
343 pub error_messages: Vec<String>,
347 pub failures: Vec<CodeBlockDiagnostic>,
356}
357
358#[derive(Debug, Default)]
360pub struct LintOutput {
361 pub diagnostics: Vec<CodeBlockDiagnostic>,
363 pub warnings: Vec<String>,
367 pub incomplete: bool,
371}
372
373impl FormatOutput {
374 pub fn failed(&self) -> bool {
376 !self.failures.is_empty()
377 }
378}
379
380#[derive(Default)]
381struct FormatPlan {
382 planned: Vec<WriteBack>,
383 error_messages: Vec<(usize, String)>,
384 failures: Vec<CodeBlockDiagnostic>,
385}
386
387pub struct CodeBlockToolProcessor<'a> {
389 config: &'a CodeBlockToolsConfig,
390 flavor: MarkdownFlavor,
391 linguist: LinguistResolver,
392 registry: ToolRegistry,
393 executor: ToolExecutor,
394 run_state: std::sync::Arc<super::run_state::RunState>,
395 builtin_checks: bool,
396 user_aliases: std::collections::HashMap<String, String>,
397 languages: std::collections::HashMap<String, &'a LanguageToolConfig>,
399 custom_languages: Vec<String>,
400}
401
402impl<'a> CodeBlockToolProcessor<'a> {
403 pub fn new(config: &'a CodeBlockToolsConfig, flavor: MarkdownFlavor) -> Self {
405 let user_aliases = config
406 .language_aliases
407 .iter()
408 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
409 .collect();
410 let languages = config.languages.iter().map(|(k, v)| (k.to_lowercase(), v)).collect();
414 let run_state = config.run_state.clone().unwrap_or_default();
415 Self {
416 builtin_checks: true,
417 custom_languages: Vec::new(),
418 run_state: std::sync::Arc::clone(&run_state),
419 config,
420 flavor,
421 linguist: LinguistResolver::new(),
422 registry: ToolRegistry::new(config.tools.clone()),
423 executor: ToolExecutor::new(config.timeout)
424 .with_binary_resolution(
425 config.binary_preferences.clone(),
426 super::binary::project_root(&std::env::current_dir().unwrap_or_default()),
427 )
428 .with_run_state(run_state),
429 user_aliases,
430 languages,
431 }
432 }
433
434 pub fn run_state(&self) -> &super::run_state::RunState {
436 &self.run_state
437 }
438
439 fn needs_coverage_scan(&self) -> bool {
440 self.config.on_missing_language_tag != OnMissing::Ignore
441 || self.config.unknown_language_policy() != OnMissing::Ignore
442 || self.config.on_missing_mode_definition != OnMissing::Ignore
443 || self.config.on_missing_language_definition == OnMissing::Warn
444 }
445
446 fn block_policy(&self, block: &FencedCodeBlockInfo, lint: bool) -> Option<(OnMissing, String, String)> {
447 use crate::utils::fence_language::{FenceLanguage, classify};
448 match classify(&block.info_string, self.flavor) {
449 FenceLanguage::Exempt => return Some((OnMissing::Ignore, String::new(), String::new())),
450 FenceLanguage::Language | FenceLanguage::CodeSyntax => {}
451 FenceLanguage::Missing => {
452 return Some((
453 self.config.on_missing_language_tag,
454 "missing-tag".into(),
455 "Fenced code block has no language tag".into(),
456 ));
457 }
458 }
459 let canonical = self.resolve_language(&block.language);
460 let known = self.linguist.is_known(&block.language)
461 || self
462 .custom_languages
463 .iter()
464 .any(|label| label.eq_ignore_ascii_case(&block.language))
465 || self.user_aliases.contains_key(&block.language.to_lowercase())
466 || self.languages.contains_key(&block.language.to_lowercase())
467 || self.languages.contains_key(&canonical);
468 if !known {
469 return Some((
470 self.config.unknown_language_policy(),
471 format!("unknown-tag:{}", block.language.to_lowercase()),
472 format!("Unrecognized code block language tag '{}'", block.language),
473 ));
474 }
475 if let Some(language) = self.language_config(&canonical) {
476 if !language.enabled {
477 return None;
478 }
479 let (current, opposite, mode) = if lint {
480 (&language.lint, &language.format, "lint")
481 } else {
482 (&language.format, &language.lint, "format")
483 };
484 if current.is_empty() && !opposite.is_empty() {
485 return Some((
486 self.config.on_missing_mode_definition,
487 format!("missing-mode:{canonical}:{mode}"),
488 format!("No {mode} tools configured for language '{canonical}'"),
489 ));
490 }
491 }
492 None
493 }
494
495 fn policy_diagnostic(
496 &self,
497 policy: OnMissing,
498 key: String,
499 message: String,
500 line: usize,
501 ) -> Result<Option<CodeBlockDiagnostic>, ProcessorError> {
502 match policy {
503 OnMissing::Ignore => Ok(None),
504 OnMissing::Warn => {
505 self.run_state.warn(key, message);
506 Ok(None)
507 }
508 OnMissing::Fail => Ok(Some(CodeBlockDiagnostic {
509 file_line: line,
510 column: None,
511 message,
512 severity: DiagnosticSeverity::Error,
513 tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.into(),
514 code_block_start: line,
515 })),
516 OnMissing::FailFast => {
517 self.run_state.abort();
518 Err(ProcessorError::Policy { message, line })
519 }
520 }
521 }
522
523 fn version_diagnostic(
524 &self,
525 tool: &super::config::ToolDefinition,
526 line: usize,
527 ) -> Result<Option<CodeBlockDiagnostic>, ProcessorError> {
528 let Some(source) = self.config.tool_version_definition else {
529 return Ok(None);
530 };
531 if self.config.on_tool_version_mismatch == OnMissing::Ignore {
532 return Ok(None);
533 }
534 if let Err(failure) = self
535 .run_state
536 .versions
537 .verify(&self.executor, tool, source, self.config.timeout)
538 {
539 let message = if self.config.values_withheld {
540 "Configured tool version verification failed (details withheld)".into()
541 } else {
542 failure.message
543 };
544 return self.policy_diagnostic(
545 self.config.on_tool_version_mismatch,
546 if self.config.values_withheld {
547 "tool-version:withheld".into()
548 } else {
549 failure.key
550 },
551 message,
552 line,
553 );
554 }
555 Ok(None)
556 }
557
558 pub fn replay_cached_warnings(&self, content: &str) {
560 for block in self.extract_code_blocks(content) {
561 if let Some((policy, key, message)) = self.block_policy(&block, true) {
562 if policy == OnMissing::Warn {
563 self.run_state.warn(key, message);
564 }
565 } else if !block.language.is_empty() && self.config.on_missing_language_definition == OnMissing::Warn {
566 let canonical = self.resolve_language(&block.language);
567 if self
568 .language_config(&canonical)
569 .is_none_or(|lc| lc.enabled && lc.lint.is_empty() && lc.format.is_empty())
570 {
571 self.run_state.warn(
572 format!("missing-definition:{canonical}"),
573 format!("No tools configured for language '{canonical}'"),
574 );
575 }
576 }
577 }
578 }
579
580 pub fn has_lint_work(&self, content: &str) -> bool {
582 self.has_work(content, true)
583 }
584
585 pub fn has_work(&self, content: &str, lint: bool) -> bool {
586 let slot = if lint { ToolSlot::Lint } else { ToolSlot::Format };
587 self.extract_code_blocks(content).iter().any(|block| {
588 if self.block_policy(block, lint).is_some() || block.content_start == block.content_end {
589 return false;
590 }
591 let canonical = self.resolve_language(&block.language);
592 self.language_config(&canonical).is_some_and(|language| {
593 language.enabled
594 && (if lint { &language.lint } else { &language.format }).iter().any(|id| {
595 if self.registry.definition_problem(id, slot).is_some() {
596 return false;
597 }
598 if is_rumdl_builtin(id) && is_markdown_language(&canonical) {
599 return self.builtin_checks;
600 }
601 self.registry
602 .resolve(id, slot)
603 .and_then(|tool| tool.command.first())
604 .is_some_and(|binary| self.executor.is_tool_available(binary))
605 })
606 })
607 })
608 }
609
610 pub fn with_document_config(mut self, config: &crate::config::Config) -> Self {
612 let md040: crate::rules::md040_fenced_code_language::md040_config::MD040Config =
613 crate::rule_config_serde::load_rule_config(config);
614 self.custom_languages = md040.custom_languages;
615 self
616 }
617
618 pub fn has_external_lint_work(&self, content: &str) -> bool {
621 self.extract_code_blocks(content).iter().any(|block| {
622 if self.block_policy(block, true).is_some() || block.content_start == block.content_end {
623 return false;
624 }
625 let canonical = self.resolve_language(&block.language);
626 self.language_config(&canonical).is_some_and(|language| {
627 language.enabled
628 && language.lint.iter().any(|id| {
629 !(is_rumdl_builtin(id) && is_markdown_language(&canonical))
630 && self.registry.definition_problem(id, ToolSlot::Lint).is_none()
631 })
632 })
633 })
634 }
635
636 pub fn with_builtin_checks(mut self, enabled: bool) -> Self {
637 self.builtin_checks = enabled;
638 self
639 }
640
641 pub fn for_path(mut self, path: &std::path::Path) -> Self {
643 self.executor = self.executor.with_binary_resolution(
644 self.config.binary_preferences.clone(),
645 super::binary::project_root(path),
646 );
647 self
648 }
649
650 fn language_config(&self, canonical_lang: &str) -> Option<&'a LanguageToolConfig> {
652 self.languages.get(canonical_lang).copied()
653 }
654
655 fn format_candidate(&self, id: &str, language: &str) -> Option<&super::config::ToolDefinition> {
658 if is_rumdl_builtin(id) && is_markdown_language(language)
659 || self.registry.definition_problem(id, ToolSlot::Format).is_some()
660 || self.registry.fills_format_slot(id) == Some(false)
661 {
662 return None;
663 }
664 self.registry.resolve(id, ToolSlot::Format)
665 }
666
667 fn unknown_tool_warning(&self, tool_id: &str, canonical_lang: &str) -> String {
674 if self.config.values_withheld {
675 let withheld = crate::config::WITHHELD;
676 format!("Unknown tool {withheld} configured for language {withheld}")
677 } else {
678 format!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'")
679 }
680 }
681
682 fn has_potential_matching_blocks(&self, content: &str, lint_mode: bool) -> bool {
685 self.languages.values().any(|language| {
686 language.enabled
687 && if lint_mode {
688 !language.lint.is_empty()
689 } else {
690 !language.format.is_empty()
691 }
692 }) && (content.contains("```") || content.contains("~~~"))
693 }
694
695 pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
698 let mut blocks = extract_fenced_code_blocks(content);
699
700 if self.flavor == MarkdownFlavor::MkDocs {
702 let mkdocs_blocks = self.extract_mkdocs_code_blocks(content, &blocks);
703 for mb in mkdocs_blocks {
704 if !blocks.iter().any(|b| b.start_line == mb.start_line) {
706 blocks.push(mb);
707 }
708 }
709 blocks.sort_by_key(|b| b.start_line);
710 }
711
712 blocks
713 }
714
715 fn extract_mkdocs_code_blocks(&self, content: &str, fenced: &[FencedCodeBlockInfo]) -> Vec<FencedCodeBlockInfo> {
723 use crate::utils::mkdocs_admonitions;
724 use crate::utils::mkdocs_tabs;
725
726 let mut blocks = Vec::new();
727 let lines: Vec<&str> = content.lines().collect();
728 let mut in_fenced_block = vec![false; lines.len()];
729 for block in fenced {
730 let end = block.end_line.min(lines.len().saturating_sub(1));
731 if let Some(span) = in_fenced_block.get_mut(block.start_line..=end) {
732 span.fill(true);
733 }
734 }
735
736 let mut context_indent_stack: Vec<usize> = Vec::new();
739
740 let mut in_fence = false;
742 let mut fence_start_line: usize = 0;
743 let mut fence_content_start: usize = 0;
744 let mut fence_char: char = '`';
745 let mut fence_length: usize = 0;
746 let mut fence_indent_prefix = String::new();
747 let mut fence_language = String::new();
748 let mut fence_info_string = String::new();
749
750 let content_start_ptr = content.as_ptr() as usize;
755 let line_offsets: Vec<usize> = lines
756 .iter()
757 .map(|line| line.as_ptr() as usize - content_start_ptr)
758 .collect();
759
760 for (i, line) in lines.iter().enumerate() {
761 let line_indent = crate::utils::mkdocs_common::get_line_indent(line);
762 let is_admonition = mkdocs_admonitions::is_admonition_start(line);
763 let is_tab = mkdocs_tabs::is_tab_marker(line);
764
765 if !line.trim().is_empty() {
769 while let Some(&ctx_indent) = context_indent_stack.last() {
770 if line_indent < ctx_indent + 4 {
771 context_indent_stack.pop();
772 if in_fence {
773 in_fence = false;
774 }
775 } else {
776 break;
777 }
778 }
779 }
780
781 if in_fenced_block[i] {
784 continue;
785 }
786
787 if is_admonition && let Some(indent) = mkdocs_admonitions::get_admonition_indent(line) {
789 context_indent_stack.push(indent);
790 continue;
791 }
792
793 if is_tab && let Some(indent) = mkdocs_tabs::get_tab_indent(line) {
795 context_indent_stack.push(indent);
796 continue;
797 }
798
799 if context_indent_stack.is_empty() {
801 continue;
802 }
803
804 let trimmed = line.trim_start();
805 let leading_spaces = line.len() - trimmed.len();
806
807 if !in_fence {
808 let (fc, fl) = if trimmed.starts_with("```") {
810 ('`', trimmed.chars().take_while(|&c| c == '`').count())
811 } else if trimmed.starts_with("~~~") {
812 ('~', trimmed.chars().take_while(|&c| c == '~').count())
813 } else {
814 continue;
815 };
816
817 if fl >= 3 {
818 in_fence = true;
819 fence_start_line = i;
820 fence_char = fc;
821 fence_length = fl;
822 fence_indent_prefix = line.get(..leading_spaces).unwrap_or("").to_string();
823
824 let after_fence = &trimmed[fl..];
825 fence_info_string = after_fence.trim().to_string();
826 fence_language = fence_info_string.split_whitespace().next().unwrap_or("").to_string();
827
828 fence_content_start = line_offsets.get(i + 1).copied().unwrap_or(content.len());
830 }
831 } else {
832 let is_closing = if fence_char == '`' {
834 trimmed.starts_with("```")
835 && trimmed.chars().take_while(|&c| c == '`').count() >= fence_length
836 && trimmed.trim_start_matches('`').trim().is_empty()
837 } else {
838 trimmed.starts_with("~~~")
839 && trimmed.chars().take_while(|&c| c == '~').count() >= fence_length
840 && trimmed.trim_start_matches('~').trim().is_empty()
841 };
842
843 if is_closing {
844 let content_end = line_offsets.get(i).copied().unwrap_or(content.len());
845
846 if content_end >= fence_content_start {
847 let (code, line_prefix_widths) =
848 strip_indent_from_block(&content[fence_content_start..content_end], &fence_indent_prefix);
849 blocks.push(FencedCodeBlockInfo {
850 start_line: fence_start_line,
851 end_line: i,
852 content_start: fence_content_start,
853 content_end,
854 language: fence_language.clone(),
855 info_string: fence_info_string.clone(),
856 fence_char,
857 fence_length,
858 content_prefix: fence_indent_prefix.clone(),
859 code,
860 line_prefix_widths,
861 });
862 }
863
864 in_fence = false;
865 }
866 }
867 }
868
869 blocks
870 }
871
872 fn resolve_language(&self, language: &str) -> String {
874 let lower = language.to_lowercase();
875 if let Some(mapped) = self.user_aliases.get(&lower) {
876 return mapped.clone();
877 }
878 match self.config.normalize_language {
879 NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
880 NormalizeLanguage::Exact => lower,
881 }
882 }
883
884 fn get_on_error(&self, language: &str) -> OnError {
886 self.language_config(language)
887 .and_then(|lc| lc.on_error)
888 .unwrap_or(self.config.on_error)
889 }
890
891 pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
896 self.lint_output(content).map(|output| output.diagnostics)
897 }
898
899 pub fn lint_output(&self, content: &str) -> Result<LintOutput, ProcessorError> {
905 if !self.needs_coverage_scan()
909 && self.config.on_missing_language_definition.skips_the_block()
910 && !self
911 .config
912 .languages
913 .values()
914 .any(|lc| lc.enabled && !lc.lint.is_empty())
915 {
916 return Ok(LintOutput::default());
917 }
918
919 if !self.needs_coverage_scan()
922 && self.config.on_missing_language_definition.skips_the_block()
923 && !self.has_potential_matching_blocks(content, true)
924 {
925 return Ok(LintOutput::default());
926 }
927
928 let blocks = self.extract_code_blocks(content);
929 #[cfg(feature = "parallel")]
930 if self.can_parallelize_blocks(&blocks, true) {
931 let outputs: Vec<_> = blocks
935 .par_iter()
936 .map(|block| {
937 let mut messages = Vec::new();
938 let output = self.lint_blocks(content, std::slice::from_ref(block), |s| messages.push(s));
939 (output, messages)
940 })
941 .collect();
942 let mut combined = LintOutput::default();
943 for (output, messages) in outputs {
944 for message in messages {
945 log::warn!("{message}");
946 }
947 let output = output?;
948 combined.diagnostics.extend(output.diagnostics);
949 combined.warnings.extend(output.warnings);
950 combined.incomplete |= output.incomplete;
951 }
952 return Ok(combined);
953 }
954 self.lint_blocks(content, &blocks, |message| log::warn!("{message}"))
955 }
956
957 #[cfg(feature = "parallel")]
958 fn can_parallelize_blocks(&self, blocks: &[FencedCodeBlockInfo], lint: bool) -> bool {
959 blocks.len() > 1
960 && rayon::current_num_threads() > 1
961 && !self.config.has_fail_fast_policy()
962 && !blocks.iter().any(|block| {
963 let canonical = self.resolve_language(&block.language);
964 self.language_config(&canonical).is_some_and(|language| {
965 language.enabled
966 && !(if lint { &language.lint } else { &language.format }).is_empty()
967 && self.get_on_error(&canonical) == OnError::Fail
968 })
969 })
970 }
971
972 fn lint_blocks(
973 &self,
974 content: &str,
975 blocks: &[FencedCodeBlockInfo],
976 mut warn: impl FnMut(String),
977 ) -> Result<LintOutput, ProcessorError> {
978 let mut incomplete = false;
979 let mut all_diagnostics = Vec::new();
980 let mut warnings = Vec::new();
981
982 for block in blocks {
983 if let Some((policy, key, message)) = self.block_policy(block, true) {
984 if let Some(diagnostic) = self.policy_diagnostic(policy, key, message, block.start_line + 1)? {
985 all_diagnostics.push(diagnostic);
986 }
987 continue;
988 }
989 if block.language.is_empty() {
990 continue;
991 }
992
993 let canonical_lang = self.resolve_language(&block.language);
994
995 let lang_config = self.language_config(&canonical_lang);
997
998 if let Some(lc) = lang_config
1000 && !lc.enabled
1001 {
1002 continue;
1003 }
1004
1005 let lint_tools = match lang_config {
1006 Some(lc) if !lc.lint.is_empty() => &lc.lint,
1007 Some(lc) if !lc.format.is_empty() => continue,
1011 _ => {
1012 match self.config.on_missing_language_definition {
1014 OnMissing::Ignore => continue,
1020 OnMissing::Warn => {
1021 self.run_state.warn(
1022 format!("missing-definition:{canonical_lang}"),
1023 format!("No tools configured for language '{canonical_lang}'"),
1024 );
1025 continue;
1026 }
1027 OnMissing::Fail => {
1028 all_diagnostics.push(CodeBlockDiagnostic {
1029 file_line: block.start_line + 1,
1030 column: None,
1031 message: format!("No lint tools configured for language '{canonical_lang}'"),
1032 severity: DiagnosticSeverity::Error,
1033 tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1034 code_block_start: block.start_line + 1,
1035 });
1036 continue;
1037 }
1038 OnMissing::FailFast => {
1039 self.run_state.abort();
1040 return Err(ProcessorError::NoToolsConfigured {
1041 language: canonical_lang,
1042 line: block.start_line + 1,
1043 });
1044 }
1045 }
1046 }
1047 };
1048
1049 if block.content_start >= block.content_end || block.content_end > content.len() {
1051 continue;
1052 }
1053 let code_content = &block.code;
1054
1055 for tool_id in lint_tools {
1057 if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
1059 if self.builtin_checks {
1060 self.run_state.record_execution();
1061 }
1062 continue;
1063 }
1064
1065 if self.registry.definition_problem(tool_id, ToolSlot::Lint).is_some() {
1066 continue;
1067 }
1068 let Some(resolved_id) = self.registry.resolve_id(tool_id, ToolSlot::Lint) else {
1069 warn(self.unknown_tool_warning(tool_id, &canonical_lang));
1070 continue;
1071 };
1072 let Some(tool_def) = self.registry.get(&resolved_id) else {
1073 warn(self.unknown_tool_warning(tool_id, &canonical_lang));
1074 continue;
1075 };
1076
1077 let tool_name = tool_def.command.first().map_or("", String::as_str);
1079 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
1080 match self.config.on_missing_tool_binary {
1081 OnMissing::Ignore | OnMissing::Warn => {
1085 log::debug!("Tool binary '{tool_name}' not found, skipping");
1086 continue;
1087 }
1088 OnMissing::Fail => {
1089 all_diagnostics.push(CodeBlockDiagnostic {
1090 file_line: block.start_line + 1,
1091 column: None,
1092 message: format!("Tool binary '{tool_name}' not found in allowed lookup locations"),
1093 severity: DiagnosticSeverity::Error,
1094 tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1095 code_block_start: block.start_line + 1,
1096 });
1097 continue;
1098 }
1099 OnMissing::FailFast => {
1100 self.run_state.abort();
1101 return Err(ProcessorError::ToolBinaryNotFound {
1102 tool: tool_name.to_string(),
1103 language: canonical_lang.clone(),
1104 line: block.start_line + 1,
1105 });
1106 }
1107 }
1108 }
1109
1110 if let Some(diagnostic) = self.version_diagnostic(tool_def, block.start_line + 1)? {
1111 all_diagnostics.push(diagnostic);
1112 continue;
1113 }
1114
1115 let tool_input = ensure_trailing_newline(code_content);
1116 let run = match self.registry.lint_mode(&resolved_id) {
1117 Some(BuiltinLintMode::FormatCheck) => self
1120 .executor
1121 .format_check(
1122 tool_def,
1123 self.registry.native_format_check(&resolved_id).as_ref(),
1124 &tool_input,
1125 Some(self.config.timeout),
1126 )
1127 .map(|output| match output {
1128 FormatCheckOutput::Formatted(output) => {
1129 self.format_check_diagnostics(&output, code_content, tool_id, block.start_line + 1)
1130 }
1131 FormatCheckOutput::Native { changed: false } => Vec::new(),
1132 FormatCheckOutput::Native { changed: true } => {
1133 self.unformatted_diagnostic(tool_id, block.start_line + 1)
1134 }
1135 }),
1136 _ => self
1137 .executor
1138 .lint(tool_def, &tool_input, Some(self.config.timeout))
1139 .map(|output| {
1140 self.parse_tool_output(
1141 &output,
1142 tool_id,
1143 block.start_line + 1, )
1145 }),
1146 };
1147 match run {
1148 Ok(mut diagnostics) => {
1149 for diagnostic in &mut diagnostics {
1152 let code_line = diagnostic.file_line.checked_sub(block.start_line + 1);
1153 if let (Some(column), Some(line)) = (diagnostic.column.as_mut(), code_line)
1154 && let Some(document_column) = block.document_column(line, *column)
1155 {
1156 *column = document_column;
1157 }
1158 }
1159 all_diagnostics.extend(diagnostics);
1160 }
1161 Err(error) => match self.get_on_error(&canonical_lang) {
1162 OnError::Fail => {
1166 let failure = ProcessorError::ToolErrorAt {
1167 error,
1168 line: block.start_line + 1,
1169 language: canonical_lang,
1170 };
1171 all_diagnostics.push(CodeBlockDiagnostic {
1172 file_line: block.start_line + 1,
1173 column: None,
1174 message: failure.detail(),
1175 severity: DiagnosticSeverity::Error,
1176 tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1177 code_block_start: block.start_line + 1,
1178 });
1179 return Ok(LintOutput {
1180 diagnostics: all_diagnostics,
1181 warnings,
1182 incomplete: true,
1183 });
1184 }
1185 OnError::Warn => {
1186 incomplete = true;
1187 warnings.push(format!("line {} ({canonical_lang}): {error}", block.start_line + 1));
1188 }
1189 OnError::Skip => incomplete = true,
1190 },
1191 }
1192 }
1193 }
1194
1195 Ok(LintOutput {
1196 diagnostics: all_diagnostics,
1197 warnings,
1198 incomplete,
1199 })
1200 }
1201
1202 pub fn format(&self, content: &str) -> Result<FormatOutput, ProcessorError> {
1209 self.format_with_config(content, &crate::config::Config::default(), None)
1210 }
1211
1212 pub fn format_with_config(
1221 &self,
1222 content: &str,
1223 config: &crate::config::Config,
1224 path: Option<&std::path::Path>,
1225 ) -> Result<FormatOutput, ProcessorError> {
1226 self.format_guarded(content, None, config, path)
1227 }
1228
1229 pub fn format_for_rules(
1235 &self,
1236 content: &str,
1237 rules: &[Box<dyn crate::rule::Rule>],
1238 config: &crate::config::Config,
1239 path: Option<&std::path::Path>,
1240 ) -> Result<FormatOutput, ProcessorError> {
1241 self.format_guarded(content, Some(rules), config, path)
1242 }
1243
1244 fn format_guarded(
1245 &self,
1246 content: &str,
1247 rules: Option<&[Box<dyn crate::rule::Rule>]>,
1248 config: &crate::config::Config,
1249 path: Option<&std::path::Path>,
1250 ) -> Result<FormatOutput, ProcessorError> {
1251 let no_output = FormatOutput {
1252 content: content.to_string(),
1253 had_errors: false,
1254 error_messages: Vec::new(),
1255 failures: Vec::new(),
1256 };
1257
1258 let conflicted = match rules {
1259 Some(rules) => crate::merge_conflict::detect_for_analysis(content, rules, config, path).is_some(),
1260 None => crate::merge_conflict::detect_configured_for_analysis(content, config, path).is_some(),
1261 };
1262 if conflicted {
1263 return Ok(no_output);
1264 }
1265
1266 if !self.needs_coverage_scan()
1268 && self.config.on_missing_language_definition.skips_the_block()
1269 && !self
1270 .config
1271 .languages
1272 .values()
1273 .any(|lc| lc.enabled && !lc.format.is_empty())
1274 {
1275 return Ok(no_output);
1276 }
1277
1278 if !self.needs_coverage_scan()
1280 && self.config.on_missing_language_definition.skips_the_block()
1281 && !self.has_potential_matching_blocks(content, false)
1282 {
1283 return Ok(no_output);
1284 }
1285
1286 let blocks = self.extract_code_blocks(content);
1287
1288 if blocks.is_empty() {
1289 return Ok(no_output);
1290 }
1291
1292 let mut error_messages = Vec::new();
1293 let mut failures = Vec::new();
1294 for block in &blocks {
1295 if let Some((policy, key, message)) = self.block_policy(block, false)
1296 && let Some(diagnostic) = self.policy_diagnostic(policy, key, message, block.start_line + 1)?
1297 {
1298 error_messages.push((block.start_line, diagnostic.message.clone()));
1299 failures.push(diagnostic);
1300 }
1301 }
1302 for block in &blocks {
1304 if self.block_policy(block, false).is_some() {
1305 continue;
1306 }
1307 let canonical = self.resolve_language(&block.language);
1308 let language = self.language_config(&canonical);
1309 if language.is_some_and(|lc| !lc.enabled || lc.format.is_empty() && !lc.lint.is_empty()) {
1310 continue;
1311 }
1312 let Some(language) = language.filter(|lc| !lc.format.is_empty()) else {
1313 if self.config.on_missing_language_definition == OnMissing::FailFast {
1314 self.run_state.abort();
1315 return Err(ProcessorError::NoToolsConfigured {
1316 language: canonical,
1317 line: block.start_line + 1,
1318 });
1319 }
1320 continue;
1321 };
1322 if block.content_start >= block.content_end || block.content_end > content.len() {
1324 continue;
1325 }
1326 if self.config.on_missing_tool_binary != OnMissing::FailFast && !self.config.version_fail_fast() {
1327 continue;
1328 }
1329 for id in &language.format {
1330 let Some(tool) = self.format_candidate(id, &canonical) else {
1331 continue;
1332 };
1333 let Some(binary) = tool.command.first() else {
1334 continue;
1335 };
1336 if !self.executor.is_tool_available(binary) {
1337 if self.config.on_missing_tool_binary == OnMissing::FailFast {
1338 self.run_state.abort();
1339 return Err(ProcessorError::ToolBinaryNotFound {
1340 tool: binary.clone(),
1341 language: canonical.clone(),
1342 line: block.start_line + 1,
1343 });
1344 }
1345 continue;
1346 }
1347 if self.config.version_fail_fast() {
1348 self.version_diagnostic(tool, block.start_line + 1)?;
1349 }
1350 if language.format_mode == FormatMode::Fallback {
1353 break;
1354 }
1355 }
1356 }
1357 let sequential = || {
1360 let mut messages = Vec::new();
1361 let output = self.format_blocks(content, blocks.iter().enumerate().rev(), |line, message| {
1362 messages.push((line, message))
1363 });
1364 messages.sort_by_key(|(line, _)| *line);
1365 for (_, message) in messages {
1366 log::warn!("{message}");
1367 }
1368 output
1369 };
1370 #[cfg(feature = "parallel")]
1371 let plan = if self.can_parallelize_blocks(&blocks, false) {
1372 let outputs: Vec<_> = blocks
1373 .par_iter()
1374 .enumerate()
1375 .map(|(index, block)| {
1376 let mut messages = Vec::new();
1377 let output = self.format_blocks(content, std::iter::once((index, block)), |_, s| messages.push(s));
1378 (output, messages)
1379 })
1380 .collect();
1381 let mut combined = FormatPlan::default();
1382 for (output, messages) in outputs {
1383 for message in messages {
1384 log::warn!("{message}");
1385 }
1386 let output = output?;
1387 combined.planned.extend(output.planned);
1388 combined.error_messages.extend(output.error_messages);
1389 combined.failures.extend(output.failures);
1390 }
1391 combined
1392 } else {
1393 sequential()?
1394 };
1395 #[cfg(not(feature = "parallel"))]
1396 let plan = sequential()?;
1397 let planned = plan.planned;
1398 error_messages.extend(plan.error_messages);
1399 failures.extend(plan.failures);
1400
1401 let (result, refused) = write_back_verified(content, &blocks, &planned, |document| {
1402 self.extract_code_blocks(document)
1403 });
1404 for index in refused {
1405 let block = &blocks[index];
1406 let diagnostic = CodeBlockDiagnostic::refused_write_back(block.start_line + 1);
1407 error_messages.push((
1408 block.start_line,
1409 format!(
1410 "line {} ({}): {}",
1411 diagnostic.file_line,
1412 self.resolve_language(&block.language),
1413 diagnostic.message
1414 ),
1415 ));
1416 failures.push(diagnostic);
1417 }
1418
1419 error_messages.sort_by_key(|(line, _)| *line);
1420 failures.sort_by_key(|failure| failure.file_line);
1421 Ok(FormatOutput {
1422 content: result,
1423 had_errors: !error_messages.is_empty(),
1424 error_messages: error_messages.into_iter().map(|(_, message)| message).collect(),
1425 failures,
1426 })
1427 }
1428
1429 fn format_blocks<'b>(
1432 &self,
1433 content: &str,
1434 blocks: impl Iterator<Item = (usize, &'b FencedCodeBlockInfo)>,
1435 mut warn: impl FnMut(usize, String),
1436 ) -> Result<FormatPlan, ProcessorError> {
1437 let mut planned = Vec::new();
1438 let mut error_messages = Vec::new();
1439 let mut failures = Vec::new();
1440 for (index, block) in blocks {
1441 if self.block_policy(block, false).is_some() {
1442 continue;
1443 }
1444 if block.language.is_empty() {
1445 continue;
1446 }
1447
1448 let canonical_lang = self.resolve_language(&block.language);
1449
1450 let lang_config = self.language_config(&canonical_lang);
1452
1453 if let Some(lc) = lang_config
1455 && !lc.enabled
1456 {
1457 continue;
1458 }
1459
1460 let (format_tools, format_mode) = match lang_config {
1461 Some(lc) if !lc.format.is_empty() => (&lc.format, lc.format_mode),
1462 Some(lc) if !lc.lint.is_empty() => continue,
1464 _ => {
1465 match self.config.on_missing_language_definition {
1467 OnMissing::Ignore => continue,
1469 OnMissing::Warn => {
1470 self.run_state.warn(
1471 format!("missing-definition:{canonical_lang}"),
1472 format!("No tools configured for language '{canonical_lang}'"),
1473 );
1474 continue;
1475 }
1476 OnMissing::Fail => {
1477 error_messages.push((
1478 block.start_line,
1479 format!(
1480 "No format tools configured for language '{canonical_lang}' at line {}",
1481 block.start_line + 1
1482 ),
1483 ));
1484 failures.push(CodeBlockDiagnostic {
1485 file_line: block.start_line + 1,
1486 column: None,
1487 message: format!("No format tools configured for language '{canonical_lang}'"),
1488 severity: DiagnosticSeverity::Error,
1489 tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1490 code_block_start: block.start_line + 1,
1491 });
1492 continue;
1493 }
1494 OnMissing::FailFast => {
1495 self.run_state.abort();
1496 return Err(ProcessorError::NoToolsConfigured {
1497 language: canonical_lang,
1498 line: block.start_line + 1,
1499 });
1500 }
1501 }
1502 }
1503 };
1504
1505 if block.content_start >= block.content_end || block.content_end > content.len() {
1507 continue;
1508 }
1509 let code_content_raw = &content[block.content_start..block.content_end];
1510 let code_content = block.code.clone();
1511
1512 let mut formatted = code_content.clone();
1517 let mut tool_ran = false;
1518 for tool_id in format_tools {
1519 if is_rumdl_builtin(tool_id) && is_markdown_language(&canonical_lang) {
1521 if self.builtin_checks {
1522 self.run_state.record_execution();
1523 }
1524 continue;
1525 }
1526
1527 if self.registry.definition_problem(tool_id, ToolSlot::Format).is_some() {
1528 continue;
1529 }
1530 let Some(tool_def) = self.format_candidate(tool_id, &canonical_lang) else {
1531 if self.registry.fills_format_slot(tool_id) == Some(false) {
1532 warn(
1533 block.start_line,
1534 format!(
1535 "Tool '{tool_id}' is a linter and cannot format '{canonical_lang}' code blocks; move it to the lint slot"
1536 ),
1537 );
1538 } else {
1539 warn(block.start_line, self.unknown_tool_warning(tool_id, &canonical_lang));
1540 }
1541 continue;
1542 };
1543
1544 let tool_name = tool_def.command.first().map_or("", String::as_str);
1546 if !tool_name.is_empty() && !self.executor.is_tool_available(tool_name) {
1547 match self.config.on_missing_tool_binary {
1548 OnMissing::Ignore | OnMissing::Warn => {
1550 log::debug!("Tool binary '{tool_name}' not found, skipping");
1551 continue;
1552 }
1553 OnMissing::Fail => {
1554 error_messages.push((block.start_line, format!(
1555 "Tool binary '{tool_name}' not found in allowed lookup locations for language '{canonical_lang}' at line {}",
1556 block.start_line + 1
1557 )));
1558 failures.push(CodeBlockDiagnostic {
1559 file_line: block.start_line + 1,
1560 column: None,
1561 message: format!("Tool binary '{tool_name}' not found in allowed lookup locations"),
1562 severity: DiagnosticSeverity::Error,
1563 tool: CODE_BLOCK_TOOLS_DIAGNOSTIC_NAME.to_string(),
1564 code_block_start: block.start_line + 1,
1565 });
1566 continue;
1567 }
1568 OnMissing::FailFast => {
1569 self.run_state.abort();
1570 return Err(ProcessorError::ToolBinaryNotFound {
1571 tool: tool_name.to_string(),
1572 language: canonical_lang.clone(),
1573 line: block.start_line + 1,
1574 });
1575 }
1576 }
1577 }
1578
1579 if let Some(diagnostic) = self.version_diagnostic(tool_def, block.start_line + 1)? {
1580 error_messages.push((block.start_line, diagnostic.message.clone()));
1581 failures.push(diagnostic);
1582 continue;
1583 }
1584
1585 let tool_input = ensure_trailing_newline(&formatted);
1586 match self.executor.format(tool_def, &tool_input, Some(self.config.timeout)) {
1587 Ok(output) => {
1588 formatted = output;
1590 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1591 formatted.push('\n');
1592 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1593 formatted.pop();
1594 }
1595 tool_ran = true;
1596 if format_mode == FormatMode::Fallback {
1597 break;
1598 }
1599 }
1600 Err(e) => {
1601 let on_error = self.get_on_error(&canonical_lang);
1602 match on_error {
1603 OnError::Fail => {
1604 return Err(ProcessorError::ToolErrorAt {
1605 error: e,
1606 line: block.start_line + 1,
1607 language: canonical_lang,
1608 });
1609 }
1610 OnError::Warn => {
1611 error_messages.push((
1612 block.start_line,
1613 format!("line {} ({}): {e}", block.start_line + 1, canonical_lang),
1614 ));
1615 }
1616 OnError::Skip => {}
1617 }
1618 }
1619 }
1620 }
1621
1622 if tool_ran && formatted != code_content && block.source_for(&formatted) != code_content_raw {
1624 planned.push(WriteBack { index, code: formatted });
1625 }
1626 }
1627
1628 Ok(FormatPlan {
1629 planned,
1630 error_messages,
1631 failures,
1632 })
1633 }
1634
1635 fn format_check_diagnostics(
1646 &self,
1647 output: &str,
1648 code_content: &str,
1649 tool_id: &str,
1650 code_block_start_line: usize,
1651 ) -> Vec<CodeBlockDiagnostic> {
1652 let mut formatted = output.to_string();
1653 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
1654 formatted.push('\n');
1655 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
1656 formatted.pop();
1657 }
1658
1659 if formatted == code_content {
1660 return Vec::new();
1661 }
1662
1663 self.unformatted_diagnostic(tool_id, code_block_start_line)
1664 }
1665
1666 fn unformatted_diagnostic(&self, tool_id: &str, code_block_start_line: usize) -> Vec<CodeBlockDiagnostic> {
1667 vec![CodeBlockDiagnostic {
1668 file_line: code_block_start_line,
1669 column: None,
1670 message: "Code block is not formatted".to_string(),
1671 severity: DiagnosticSeverity::Warning,
1672 tool: tool_id.to_string(),
1673 code_block_start: code_block_start_line,
1674 }]
1675 }
1676
1677 fn parse_tool_output(
1682 &self,
1683 output: &ToolOutput,
1684 tool_id: &str,
1685 code_block_start_line: usize,
1686 ) -> Vec<CodeBlockDiagnostic> {
1687 let mut diagnostics = Vec::new();
1688 let mut shellcheck_line: Option<usize> = None;
1689
1690 let stdout_clean = strip_ansi_codes(&output.stdout);
1692 let stderr_clean = strip_ansi_codes(&output.stderr);
1693 let combined = format!("{stdout_clean}\n{stderr_clean}");
1694
1695 let mut pending_error: Option<(String, DiagnosticSeverity)> = None;
1697
1698 for line in combined.lines() {
1699 let line = line.trim();
1700 if line.is_empty() {
1701 continue;
1702 }
1703
1704 if let Some((ref msg, severity)) = pending_error {
1706 if let Some((line_num, col)) = Self::parse_at_line_column(line) {
1707 diagnostics.push(CodeBlockDiagnostic {
1708 file_line: code_block_start_line + line_num,
1709 column: Some(col),
1710 message: msg.clone(),
1711 severity,
1712 tool: tool_id.to_string(),
1713 code_block_start: code_block_start_line,
1714 });
1715 pending_error = None;
1716 continue;
1717 }
1718 diagnostics.push(Self::unpositioned_diagnostic(
1720 msg,
1721 severity,
1722 tool_id,
1723 code_block_start_line,
1724 ));
1725 pending_error = None;
1726 }
1728
1729 if let Some(line_num) = self.parse_shellcheck_header(line) {
1730 shellcheck_line = Some(line_num);
1731 continue;
1732 }
1733
1734 if let Some(line_num) = shellcheck_line
1735 && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
1736 {
1737 diagnostics.push(diag);
1738 continue;
1739 }
1740
1741 if let Some(diag) = Self::parse_github_annotation(line, tool_id, code_block_start_line) {
1743 diagnostics.push(diag);
1744 continue;
1745 }
1746
1747 if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
1749 diagnostics.push(diag);
1750 continue;
1751 }
1752
1753 if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
1755 diagnostics.push(diag);
1756 continue;
1757 }
1758
1759 if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
1761 diagnostics.push(diag);
1762 continue;
1763 }
1764
1765 if let Some(error_info) = Self::parse_error_line(line) {
1767 pending_error = Some(error_info);
1768 }
1769 }
1770
1771 if let Some((msg, severity)) = pending_error {
1773 diagnostics.push(Self::unpositioned_diagnostic(
1774 &msg,
1775 severity,
1776 tool_id,
1777 code_block_start_line,
1778 ));
1779 }
1780
1781 if diagnostics.is_empty() && !output.success {
1783 let lines: Vec<&str> = combined.lines().map(str::trim).filter(|l| !l.is_empty()).collect();
1784
1785 if lines.is_empty() {
1786 let exit_code = output.exit_code;
1787 diagnostics.push(CodeBlockDiagnostic {
1788 file_line: code_block_start_line,
1789 column: None,
1790 message: format!("Tool exited with code {exit_code}"),
1791 severity: DiagnosticSeverity::Error,
1792 tool: tool_id.to_string(),
1793 code_block_start: code_block_start_line,
1794 });
1795 } else {
1796 for line_text in lines {
1797 diagnostics.push(Self::unpositioned_diagnostic(
1798 line_text,
1799 DiagnosticSeverity::Error,
1800 tool_id,
1801 code_block_start_line,
1802 ));
1803 }
1804 }
1805 }
1806
1807 diagnostics
1808 }
1809
1810 fn unpositioned_diagnostic(
1816 message: &str,
1817 severity: DiagnosticSeverity,
1818 tool_id: &str,
1819 code_block_start_line: usize,
1820 ) -> CodeBlockDiagnostic {
1821 let (line_offset, column) = Self::parse_position_in_message(message).unwrap_or((0, None));
1822 CodeBlockDiagnostic {
1823 file_line: code_block_start_line + line_offset,
1824 column,
1825 message: message.to_string(),
1826 severity,
1827 tool: tool_id.to_string(),
1828 code_block_start: code_block_start_line,
1829 }
1830 }
1831
1832 fn parse_position_in_message(message: &str) -> Option<(usize, Option<usize>)> {
1837 let lower = message.to_ascii_lowercase();
1840 let after_marker = &lower[lower.rfind("at line ")? + "at line ".len()..];
1841
1842 let leading_number = |text: &str| -> Option<usize> {
1843 let end = text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len());
1844 text[..end].parse::<usize>().ok()
1845 };
1846
1847 let line_num = leading_number(after_marker)?;
1848 let after_line = after_marker.trim_start_matches(|c: char| c.is_ascii_digit());
1849 let after_separator = after_line
1850 .trim_start()
1851 .strip_prefix(',')
1852 .unwrap_or(after_line)
1853 .trim_start();
1854 let column = after_separator.strip_prefix("column ").and_then(leading_number);
1855
1856 Some((line_num, column))
1857 }
1858
1859 fn parse_standard_format(
1861 &self,
1862 line: &str,
1863 tool_id: &str,
1864 code_block_start_line: usize,
1865 ) -> Option<CodeBlockDiagnostic> {
1866 let mut parts = line.rsplitn(4, ':');
1868 let message = parts.next()?.trim().to_string();
1869 let part1 = parts.next()?.trim().to_string();
1870 let part2 = parts.next()?.trim().to_string();
1871 let part3 = parts.next().map(|s| s.trim().to_string());
1872
1873 let (line_part, col_part) = if part3.is_some() {
1874 (part2, Some(part1))
1875 } else {
1876 (part1, None)
1877 };
1878
1879 if let Ok(line_num) = line_part.parse::<usize>() {
1880 let column = col_part.and_then(|s| s.parse::<usize>().ok());
1881 let message = Self::strip_fixable_markers(&message);
1882 if !message.is_empty() {
1883 let severity = self.infer_severity(&message);
1884 return Some(CodeBlockDiagnostic {
1885 file_line: code_block_start_line + line_num,
1886 column,
1887 message,
1888 severity,
1889 tool: tool_id.to_string(),
1890 code_block_start: code_block_start_line,
1891 });
1892 }
1893 }
1894 None
1895 }
1896
1897 fn parse_github_annotation(line: &str, tool_id: &str, code_block_start_line: usize) -> Option<CodeBlockDiagnostic> {
1905 let body = line.strip_prefix("::")?;
1906 let (head, message) = body.split_once("::")?;
1907
1908 let (level, properties) = head.split_once(' ').unwrap_or((head, ""));
1909 let severity = match level {
1910 "error" => DiagnosticSeverity::Error,
1911 "warning" => DiagnosticSeverity::Warning,
1912 "notice" => DiagnosticSeverity::Info,
1913 _ => return None,
1914 };
1915
1916 let property = |name: &str| -> Option<usize> {
1917 properties
1918 .split(',')
1919 .filter_map(|pair| pair.split_once('='))
1920 .find(|(key, _)| *key == name)
1921 .and_then(|(_, value)| value.parse::<usize>().ok())
1922 };
1923
1924 let message = Self::strip_fixable_markers(&decode_workflow_command(message));
1925 if message.is_empty() {
1926 return None;
1927 }
1928
1929 Some(CodeBlockDiagnostic {
1930 file_line: code_block_start_line + property("line").unwrap_or(0),
1931 column: property("col"),
1932 message,
1933 severity,
1934 tool: tool_id.to_string(),
1935 code_block_start: code_block_start_line,
1936 })
1937 }
1938
1939 fn parse_eslint_format(
1941 &self,
1942 line: &str,
1943 tool_id: &str,
1944 code_block_start_line: usize,
1945 ) -> Option<CodeBlockDiagnostic> {
1946 let parts: Vec<&str> = line.splitn(3, ' ').collect();
1948 if parts.len() >= 2 {
1949 let loc_parts: Vec<&str> = parts[0].split(':').collect();
1950 if loc_parts.len() == 2
1951 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
1952 {
1953 let (sev_part, msg_part) = if parts.len() >= 3 {
1954 (parts[1], parts[2])
1955 } else {
1956 (parts[1], "")
1957 };
1958 let message = if msg_part.is_empty() {
1959 sev_part.to_string()
1960 } else {
1961 msg_part.to_string()
1962 };
1963 let message = Self::strip_fixable_markers(&message);
1964 let severity = match sev_part.to_lowercase().as_str() {
1965 "error" => DiagnosticSeverity::Error,
1966 "warning" | "warn" => DiagnosticSeverity::Warning,
1967 "info" => DiagnosticSeverity::Info,
1968 _ => self.infer_severity(&message),
1969 };
1970 return Some(CodeBlockDiagnostic {
1971 file_line: code_block_start_line + line_num,
1972 column: Some(col),
1973 message,
1974 severity,
1975 tool: tool_id.to_string(),
1976 code_block_start: code_block_start_line,
1977 });
1978 }
1979 }
1980 None
1981 }
1982
1983 fn parse_shellcheck_format(
1985 &self,
1986 line: &str,
1987 tool_id: &str,
1988 code_block_start_line: usize,
1989 ) -> Option<CodeBlockDiagnostic> {
1990 if line.starts_with("In ")
1992 && line.contains(" line ")
1993 && let Some(line_start) = line.find(" line ")
1994 {
1995 let after_line = &line[line_start + 6..];
1996 if let Some(colon_pos) = after_line.find(':')
1997 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
1998 {
1999 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
2000 if !message.is_empty() {
2001 let severity = self.infer_severity(&message);
2002 return Some(CodeBlockDiagnostic {
2003 file_line: code_block_start_line + line_num,
2004 column: None,
2005 message,
2006 severity,
2007 tool: tool_id.to_string(),
2008 code_block_start: code_block_start_line,
2009 });
2010 }
2011 }
2012 }
2013 None
2014 }
2015
2016 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
2018 if line.starts_with("In ")
2019 && line.contains(" line ")
2020 && let Some(line_start) = line.find(" line ")
2021 {
2022 let after_line = &line[line_start + 6..];
2023 if let Some(colon_pos) = after_line.find(':') {
2024 return after_line[..colon_pos].trim().parse::<usize>().ok();
2025 }
2026 }
2027 None
2028 }
2029
2030 fn parse_shellcheck_message(
2032 &self,
2033 line: &str,
2034 tool_id: &str,
2035 code_block_start_line: usize,
2036 line_num: usize,
2037 ) -> Option<CodeBlockDiagnostic> {
2038 let sc_pos = line.find("SC")?;
2039 let after_sc = &line[sc_pos + 2..];
2040 let code_len = after_sc.chars().take_while(char::is_ascii_digit).count();
2041 if code_len == 0 {
2042 return None;
2043 }
2044 let after_code = &after_sc[code_len..];
2045 let sev_start = after_code.find('(')? + 1;
2046 let sev_end = after_code[sev_start..].find(')')? + sev_start;
2047 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
2048 let message_start = after_code.find("):")? + 2;
2049 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
2050 if message.is_empty() {
2051 return None;
2052 }
2053
2054 let severity = match sev.as_str() {
2055 "error" => DiagnosticSeverity::Error,
2056 "warning" | "warn" => DiagnosticSeverity::Warning,
2057 "info" | "style" => DiagnosticSeverity::Info,
2058 _ => self.infer_severity(&message),
2059 };
2060
2061 Some(CodeBlockDiagnostic {
2062 file_line: code_block_start_line + line_num,
2063 column: None,
2064 message,
2065 severity,
2066 tool: tool_id.to_string(),
2067 code_block_start: code_block_start_line,
2068 })
2069 }
2070
2071 fn parse_error_line(line: &str) -> Option<(String, DiagnosticSeverity)> {
2077 let (msg, severity) = if let Some(msg) = line.strip_prefix("Error:") {
2078 (msg, DiagnosticSeverity::Error)
2079 } else if let Some(msg) = line.strip_prefix("Warning:") {
2080 (msg, DiagnosticSeverity::Warning)
2081 } else {
2082 return None;
2083 };
2084 let msg = msg.trim();
2085 if msg.is_empty() {
2086 return None;
2087 }
2088 Some((msg.to_string(), severity))
2089 }
2090
2091 fn parse_at_line_column(line: &str) -> Option<(usize, usize)> {
2095 let lower = line.to_lowercase();
2096 let rest = lower.strip_prefix("at line ")?;
2097 let mut parts = rest.split_whitespace();
2098 let line_num: usize = parts.next()?.parse().ok()?;
2099 if parts.next()? != "column" {
2100 return None;
2101 }
2102 let col: usize = parts.next()?.parse().ok()?;
2103 Some((line_num, col))
2104 }
2105
2106 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
2108 let lower = message.to_lowercase();
2109 if lower.contains("error")
2110 || lower.starts_with('e') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
2111 || lower.starts_with('f') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
2112 {
2113 DiagnosticSeverity::Error
2114 } else if lower.contains("warning")
2115 || lower.contains("warn")
2116 || lower.starts_with('w') && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
2117 {
2118 DiagnosticSeverity::Warning
2119 } else {
2120 DiagnosticSeverity::Info
2121 }
2122 }
2123
2124 fn strip_fixable_markers(message: &str) -> String {
2131 message
2132 .replace(" [*]", "")
2133 .replace("[*] ", "")
2134 .replace("[*]", "")
2135 .replace(" (fixable)", "")
2136 .replace("(fixable) ", "")
2137 .replace("(fixable)", "")
2138 .replace(" [fix available]", "")
2139 .replace("[fix available] ", "")
2140 .replace("[fix available]", "")
2141 .replace(" [autofix]", "")
2142 .replace("[autofix] ", "")
2143 .replace("[autofix]", "")
2144 .trim()
2145 .to_string()
2146 }
2147}
2148
2149pub fn extract_fenced_code_blocks(content: &str) -> Vec<FencedCodeBlockInfo> {
2152 let mut blocks = Vec::new();
2153 let mut current_block: Option<FencedCodeBlockBuilder> = None;
2154
2155 let options = rumdl_parser_options_for(content);
2156 let parser = Parser::new_ext(content, options).into_offset_iter();
2157
2158 let line_starts: Vec<usize> = std::iter::once(0)
2161 .chain(content.match_indices('\n').map(|(i, _)| i + 1))
2162 .collect();
2163 let line_of = |offset: usize| line_starts.partition_point(|&start| start <= offset) - 1;
2164
2165 let mut open_footnotes: Vec<usize> = Vec::new();
2167
2168 for (event, range) in parser {
2169 match event {
2170 Event::Start(Tag::FootnoteDefinition(_)) => open_footnotes.push(range.start),
2171 Event::End(TagEnd::FootnoteDefinition) => {
2172 open_footnotes.pop();
2173 }
2174 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
2175 let info_string = info.to_string();
2176 let language = info_string.split_whitespace().next().unwrap_or("").to_string();
2177
2178 let start_line = line_of(range.start);
2179
2180 let content_start = content[range.start..]
2182 .find('\n')
2183 .map_or(content.len(), |i| range.start + i + 1);
2184
2185 let fence_line_prefix = continuation_of_footnote_labels(
2188 &content[line_starts[start_line]..range.start],
2189 open_footnotes
2190 .iter()
2191 .filter(|&&start| start >= line_starts[start_line])
2192 .map(|&start| start - line_starts[start_line]),
2193 );
2194 let fence = &content[range.start..];
2195 let fence_char = if fence.starts_with('~') { '~' } else { '`' };
2196 let fence_length = fence.chars().take_while(|&c| c == fence_char).count();
2197
2198 current_block = Some(FencedCodeBlockBuilder {
2199 start_line,
2200 content_start,
2201 language,
2202 info_string,
2203 fence_char,
2204 fence_length,
2205 content_prefix: content_prefix_of(&fence_line_prefix),
2206 pieces: Vec::new(),
2207 });
2208 }
2209 Event::Text(text) => {
2210 if let Some(builder) = current_block.as_mut() {
2211 builder.pieces.push((range, text.into_string()));
2212 }
2213 }
2214 Event::End(TagEnd::CodeBlock) => {
2215 if let Some(builder) = current_block.take() {
2216 let end_line = line_of(range.end.saturating_sub(1).max(range.start));
2219
2220 let content_end = builder
2223 .pieces
2224 .last()
2225 .map_or(builder.content_start, |(last, _)| last.end);
2226 let (code, line_prefix_widths) =
2227 block_code_from_pieces(content, &builder.pieces, |offset| line_starts[line_of(offset)]);
2228
2229 blocks.push(FencedCodeBlockInfo {
2230 start_line: builder.start_line,
2231 end_line,
2232 content_start: builder.content_start,
2233 content_end: content_end.max(builder.content_start),
2234 language: builder.language,
2235 info_string: builder.info_string,
2236 fence_char: builder.fence_char,
2237 fence_length: builder.fence_length,
2238 content_prefix: builder.content_prefix,
2239 code,
2240 line_prefix_widths,
2241 });
2242 }
2243 }
2244 _ => {}
2245 }
2246 }
2247
2248 blocks
2249}
2250
2251#[derive(Debug, Clone)]
2253pub struct WriteBack {
2254 pub index: usize,
2256 pub code: String,
2257}
2258
2259pub fn write_back_verified(
2274 content: &str,
2275 blocks: &[FencedCodeBlockInfo],
2276 write_backs: &[WriteBack],
2277 extract: impl Fn(&str) -> Vec<FencedCodeBlockInfo>,
2278) -> (String, Vec<usize>) {
2279 if write_backs.is_empty() {
2280 return (content.to_string(), Vec::new());
2281 }
2282 let overlaps = |a: &WriteBack, b: &WriteBack| {
2283 let (a, b) = (&blocks[a.index], &blocks[b.index]);
2284 a.content_start < b.content_end && b.content_start < a.content_end
2285 };
2286 let (mut ordered, mut refused): (Vec<&WriteBack>, Vec<&WriteBack>) = write_backs.iter().partition(|write_back| {
2287 !write_backs
2288 .iter()
2289 .any(|other| other.index != write_back.index && overlaps(write_back, other))
2290 });
2291 ordered.sort_by_key(|write_back| std::cmp::Reverse(write_back.index));
2294
2295 let replace = |document: &mut String, write_back: &WriteBack| {
2296 let block = &blocks[write_back.index];
2297 document.replace_range(
2298 block.content_start..block.content_end,
2299 &block.source_for(&write_back.code),
2300 );
2301 };
2302 let reads_back = |document: &str, codes: &[&str]| {
2306 extract(document)
2307 .iter()
2308 .map(|block| block.code.replace("\r\n", "\n"))
2309 .eq(codes.iter().map(|code| code.replace("\r\n", "\n")))
2310 };
2311 let original_codes: Vec<&str> = blocks.iter().map(|block| block.code.as_str()).collect();
2312
2313 let refused_indices = |refused: Vec<&WriteBack>| {
2314 let mut indices: Vec<usize> = refused.iter().map(|write_back| write_back.index).collect();
2315 indices.sort_unstable();
2316 indices
2317 };
2318
2319 let mut codes = original_codes.clone();
2320 let mut result = content.to_string();
2321 for write_back in &ordered {
2322 replace(&mut result, write_back);
2323 codes[write_back.index] = &write_back.code;
2324 }
2325 if reads_back(&result, &codes) {
2326 return (result, refused_indices(refused));
2327 }
2328
2329 let mut codes = original_codes;
2332 let mut result = content.to_string();
2333 for write_back in ordered {
2334 let mut candidate = result.clone();
2335 replace(&mut candidate, write_back);
2336 let original = std::mem::replace(&mut codes[write_back.index], &write_back.code);
2337 if reads_back(&candidate, &codes) {
2338 result = candidate;
2339 } else {
2340 codes[write_back.index] = original;
2341 refused.push(write_back);
2342 }
2343 }
2344 (result, refused_indices(refused))
2345}
2346
2347struct FencedCodeBlockBuilder {
2349 start_line: usize,
2350 content_start: usize,
2351 language: String,
2352 info_string: String,
2353 fence_char: char,
2354 fence_length: usize,
2355 content_prefix: String,
2356 pieces: Vec<(std::ops::Range<usize>, String)>,
2358}
2359
2360fn continuation_of_footnote_labels(fence_line_prefix: &str, label_starts: impl Iterator<Item = usize>) -> String {
2368 let mut prefix = fence_line_prefix.to_string();
2369 let mut label_starts: Vec<usize> = label_starts.collect();
2370 label_starts.sort_unstable();
2371 for start in label_starts.into_iter().rev() {
2373 let Some(label_end) = footnote_label_end(&prefix[start..]) else {
2374 continue;
2375 };
2376 let rest = &prefix[start + label_end..];
2377 let end = start + label_end + rest.len() - rest.trim_start_matches([' ', '\t']).len();
2378 prefix.replace_range(start..end, " ");
2379 }
2380 prefix
2381}
2382
2383fn footnote_label_end(text: &str) -> Option<usize> {
2385 let mut escaped = false;
2386 for (i, c) in text.char_indices().skip(2) {
2387 match c {
2388 _ if escaped => escaped = false,
2389 '\\' => escaped = true,
2390 ']' => return text[i + 1..].starts_with(':').then_some(i + 2),
2391 _ => {}
2392 }
2393 }
2394 None
2395}
2396
2397fn content_prefix_of(fence_line_prefix: &str) -> String {
2409 let mut prefix = String::with_capacity(fence_line_prefix.len() + 1);
2410 let mut column = 0;
2411 let mut chars = fence_line_prefix.chars().peekable();
2412 while let Some(c) = chars.next() {
2413 match c {
2414 '>' => {
2415 prefix.push('>');
2416 if !matches!(chars.peek(), Some(' ' | '\t')) {
2417 prefix.push(' ');
2418 }
2419 column += 1;
2420 }
2421 '\t' => {
2422 let width = 4 - column % 4;
2423 prefix.extend(std::iter::repeat_n(' ', width));
2424 column += width;
2425 }
2426 _ => {
2427 prefix.push(' ');
2428 column += 1;
2429 }
2430 }
2431 }
2432 prefix
2433}
2434
2435fn block_code_from_pieces(
2446 content: &str,
2447 pieces: &[(std::ops::Range<usize>, String)],
2448 line_start_of: impl Fn(usize) -> usize,
2449) -> (String, Vec<usize>) {
2450 let mut code = String::new();
2451 let mut widths = Vec::new();
2452 let prefix_width = |offset: usize| {
2455 let before = &content[line_start_of(offset)..offset];
2456 before.strip_suffix('\r').unwrap_or(before).chars().count()
2457 };
2458
2459 for (range, text) in pieces {
2460 if content.get(range.clone()) == Some(text.as_str()) {
2461 let mut offset = range.start;
2462 for segment in text.split_inclusive('\n') {
2463 if code.is_empty() || code.ends_with('\n') {
2464 widths.push(prefix_width(offset));
2465 }
2466 code.push_str(segment);
2467 offset += segment.len();
2468 }
2469 } else {
2470 if code.is_empty() || code.ends_with('\n') {
2473 widths.push(prefix_width(range.start).saturating_sub(text.chars().count()));
2474 }
2475 code.push_str(text);
2476 }
2477 }
2478 (code, widths)
2479}
2480
2481fn strip_indent_from_block(content: &str, indent_prefix: &str) -> (String, Vec<usize>) {
2484 let mut code = String::with_capacity(content.len());
2485 let mut widths = Vec::new();
2486 for line in content.split_inclusive('\n') {
2487 match line.strip_prefix(indent_prefix) {
2488 Some(stripped) => {
2489 code.push_str(stripped);
2490 widths.push(indent_prefix.chars().count());
2491 }
2492 None => {
2493 code.push_str(line);
2494 widths.push(0);
2495 }
2496 }
2497 }
2498 (code, widths)
2499}
2500
2501fn apply_content_prefix(content: &str, prefix: &str) -> String {
2506 if prefix.is_empty() {
2507 return content.to_string();
2508 }
2509
2510 let blank_prefix = prefix.trim_end();
2511 let mut out = String::with_capacity(content.len() + prefix.len());
2512 for line in content.split_inclusive('\n') {
2513 if line == "\n" {
2514 out.push_str(blank_prefix);
2515 } else {
2516 out.push_str(prefix);
2517 }
2518 out.push_str(line);
2519 }
2520 out
2521}
2522
2523#[cfg(test)]
2524mod tests {
2525 use super::*;
2526
2527 fn default_config() -> CodeBlockToolsConfig {
2528 CodeBlockToolsConfig::default()
2529 }
2530
2531 #[test]
2532 fn test_extract_code_blocks() {
2533 let config = default_config();
2534 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2535
2536 let content = r#"# Example
2537
2538```python
2539def hello():
2540 print("Hello")
2541```
2542
2543Some text
2544
2545```rust
2546fn main() {}
2547```
2548"#;
2549
2550 let blocks = processor.extract_code_blocks(content);
2551
2552 assert_eq!(blocks.len(), 2);
2553
2554 assert_eq!(blocks[0].language, "python");
2555 assert_eq!(blocks[0].fence_char, '`');
2556 assert_eq!(blocks[0].fence_length, 3);
2557 assert_eq!(blocks[0].start_line, 2);
2558 assert_eq!(blocks[0].content_prefix, "");
2559 assert_eq!(blocks[0].code, "def hello():\n print(\"Hello\")\n");
2560
2561 assert_eq!(blocks[1].language, "rust");
2562 assert_eq!(blocks[1].fence_char, '`');
2563 assert_eq!(blocks[1].fence_length, 3);
2564 }
2565
2566 #[test]
2567 fn test_extract_code_blocks_with_info_string() {
2568 let config = default_config();
2569 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2570
2571 let content = "```python title=\"example.py\"\ncode\n```";
2572 let blocks = processor.extract_code_blocks(content);
2573
2574 assert_eq!(blocks.len(), 1);
2575 assert_eq!(blocks[0].language, "python");
2576 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
2577 }
2578
2579 #[test]
2580 fn test_extract_code_blocks_tilde_fence() {
2581 let config = default_config();
2582 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2583
2584 let content = "~~~bash\necho hello\n~~~";
2585 let blocks = processor.extract_code_blocks(content);
2586
2587 assert_eq!(blocks.len(), 1);
2588 assert_eq!(blocks[0].language, "bash");
2589 assert_eq!(blocks[0].fence_char, '~');
2590 assert_eq!(blocks[0].fence_length, 3);
2591 assert_eq!(blocks[0].content_prefix, "");
2592 assert_eq!(blocks[0].code, "echo hello\n");
2593 }
2594
2595 #[test]
2596 fn test_extract_code_blocks_with_indent_prefix() {
2597 let config = default_config();
2598 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2599
2600 let content = " - item\n ```python\n print('hi')\n ```";
2601 let blocks = processor.extract_code_blocks(content);
2602
2603 assert_eq!(blocks.len(), 1);
2604 assert_eq!(blocks[0].content_prefix, " ");
2605 assert_eq!(blocks[0].code, "print('hi')\n");
2606 assert_eq!(blocks[0].line_prefix_widths, vec![4]);
2607 }
2608
2609 #[test]
2613 fn test_extract_code_blocks_line_numbers() {
2614 let config = default_config();
2615 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2616
2617 let content = "# Ünïcödé 标题\r\n\r\n```python\r\nx = 'é'\r\n```\r\n\r\n> ```sh\n> echo 你好\n> ```\n\n~~~rust\nfn main() {}\n~~~";
2618 let blocks = processor.extract_code_blocks(content);
2619
2620 let lines: Vec<(usize, usize)> = blocks.iter().map(|b| (b.start_line, b.end_line)).collect();
2621 assert_eq!(lines, vec![(2, 4), (6, 8), (10, 12)]);
2622 }
2623
2624 #[test]
2627 fn test_extract_unterminated_code_block_line_numbers() {
2628 let config = default_config();
2629 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2630
2631 let cases = [
2632 ("> ```sh\n> a\nb\n", (0, 1)),
2633 ("- ```sh\n a\n\nb\n", (0, 2)),
2634 ("```sh\na\n", (0, 1)),
2635 ("```sh\na", (0, 1)),
2636 ("```sh\r\na\r\n", (0, 1)),
2637 ("```sh\n", (0, 0)),
2638 ];
2639 for (content, expected) in cases {
2640 let blocks = processor.extract_code_blocks(content);
2641 let lines: Vec<(usize, usize)> = blocks.iter().map(|b| (b.start_line, b.end_line)).collect();
2642 assert_eq!(lines, vec![expected], "{content:?}");
2643 }
2644 }
2645
2646 #[test]
2647 fn test_extract_code_blocks_no_language() {
2648 let config = default_config();
2649 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2650
2651 let content = "```\nplain code\n```";
2652 let blocks = processor.extract_code_blocks(content);
2653
2654 assert_eq!(blocks.len(), 1);
2655 assert_eq!(blocks[0].language, "");
2656 }
2657
2658 #[test]
2659 fn test_resolve_language_linguist() {
2660 let mut config = default_config();
2661 config.normalize_language = NormalizeLanguage::Linguist;
2662 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2663
2664 assert_eq!(processor.resolve_language("py"), "python");
2665 assert_eq!(processor.resolve_language("bash"), "shell");
2666 assert_eq!(processor.resolve_language("js"), "javascript");
2667 }
2668
2669 #[test]
2670 fn test_resolve_language_exact() {
2671 let mut config = default_config();
2672 config.normalize_language = NormalizeLanguage::Exact;
2673 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2674
2675 assert_eq!(processor.resolve_language("py"), "py");
2676 assert_eq!(processor.resolve_language("BASH"), "bash");
2677 }
2678
2679 #[test]
2680 fn test_resolve_language_user_alias_override() {
2681 let mut config = default_config();
2682 config.language_aliases.insert("py".to_string(), "python".to_string());
2683 config.normalize_language = NormalizeLanguage::Exact;
2684 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2685
2686 assert_eq!(processor.resolve_language("PY"), "python");
2687 }
2688
2689 #[test]
2690 fn test_indent_strip_and_reapply_roundtrip() {
2691 let raw = " def hello():\n print('hi')";
2692 let (stripped, widths) = strip_indent_from_block(raw, " ");
2693 assert_eq!(stripped, "def hello():\n print('hi')");
2694 assert_eq!(widths, vec![4, 4]);
2695
2696 assert_eq!(apply_content_prefix(&stripped, " "), raw);
2697 }
2698
2699 #[test]
2703 fn test_extract_code_blocks_strips_container_prefixes() {
2704 let config = default_config();
2705 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2706
2707 let cases: &[(&str, &str, &str, &[usize])] = &[
2709 ("> ```m\n> a\n> b\n> ```\n", "a\n b\n", "> ", &[2, 2]),
2710 ("> ```m\n> a\n>\n> b\n> ```\n", "a\n\nb\n", "> ", &[2, 1, 2]),
2711 (">```m\n>a\n> b\n>```\n", "a\n b\n", "> ", &[1, 2]),
2713 ("> > ```m\n> > a\n> > ```\n", "a\n", "> > ", &[4]),
2714 (" > ```m\n > a\n > ```\n", "a\n", " > ", &[4]),
2715 ("- ```m\n a\n b\n ```\n", "a\n b\n", " ", &[2, 2]),
2716 ("1. ```m\n a\n\n b\n ```\n", "a\n\nb\n", " ", &[3, 0, 3]),
2717 ("> - ```m\n> a\n>\n> b\n> ```\n", "a\n\nb\n", "> ", &[4, 1, 4]),
2718 ("- > ```m\n > a\n > b\n > ```\n", "a\nb\n", " > ", &[4, 4]),
2719 (">- ```m\n> a\n> ```\n", "a\n", "> ", &[4]),
2720 ("> ```m\n> a\n>\n> ```\n", "a\n\n", "> ", &[2, 1]),
2722 ("> ```m\n>\n> ```\n", "\n", "> ", &[1]),
2723 ("- ```m\n a\n b", "a\nb", " ", &[2, 2]),
2725 ("> ```m\n> a\n", "a\n", "> ", &[2]),
2726 ("> ```m\n>\ta\n> ```\n", " a\n", "> ", &[0]),
2728 ("> ```m\r\n> a\r\n>\r\n> b\r\n> ```\r\n", "a\n\nb\n", "> ", &[2, 1, 2]),
2731 ("```m\r\na\r\n\r\nb\r\n```\r\n", "a\n\nb\n", "", &[0, 0, 0]),
2732 ("[^a]: ```m\n a\n ```\n", "a\n", " ", &[4]),
2735 ("[^long-label]: ```m\n a\n ```\n", "a\n", " ", &[4]),
2736 ("[^a]:\t```m\n a\n ```\n", "a\n", " ", &[4]),
2737 ("[^a]: \t ```m\n a\n ```\n", "a\n", " ", &[4]),
2738 ("[^a\\]b]: ```m\n a\n ```\n", "a\n", " ", &[4]),
2739 ("[^a]: Note.\n\n ```m\n a\n ```\n", "a\n", " ", &[4]),
2740 ("> [^a]: ```m\n> a\n> ```\n", "a\n", "> ", &[6]),
2741 ("- [^a]: ```m\n a\n ```\n", "a\n", " ", &[6]),
2742 ("[^a]: > ```m\n > a\n > ```\n", "a\n", " > ", &[6]),
2743 ("[^a]: - ```m\n a\n ```\n", "a\n", " ", &[6]),
2744 ];
2745
2746 for &(document, code, prefix, widths) in cases {
2747 let blocks = processor.extract_code_blocks(document);
2748 assert_eq!(blocks.len(), 1, "{document:?}");
2749 let block = &blocks[0];
2750 assert_eq!(block.code, code, "code of {document:?}");
2751 assert_eq!(block.content_prefix, prefix, "prefix of {document:?}");
2752 assert_eq!(block.line_prefix_widths, widths, "widths of {document:?}");
2753 assert_eq!(
2754 (block.fence_char, block.fence_length),
2755 ('`', 3),
2756 "fence of {document:?}"
2757 );
2758 }
2759 }
2760
2761 #[test]
2764 fn test_extract_code_blocks_container_region() {
2765 let config = default_config();
2766 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
2767
2768 let cases = [
2769 ("> ```m\n> a\n>\n> b\n> ```\n", "> a\n>\n> b\n"),
2770 ("- ```m\n a\n ```\n", " a\n"),
2771 ("- ```m\n a\n b", " a\n b"),
2772 ("> ```m\r\n> a\r\n> b\r\n> ```\r\n", "> a\r\n> b\r\n"),
2773 ];
2774 for (document, region) in cases {
2775 let blocks = processor.extract_code_blocks(document);
2776 assert_eq!(blocks.len(), 1, "{document:?}");
2777 assert_eq!(
2778 &document[blocks[0].content_start..blocks[0].content_end],
2779 region,
2780 "{document:?}"
2781 );
2782 }
2783 }
2784
2785 #[test]
2786 fn test_apply_content_prefix_blank_lines() {
2787 assert_eq!(apply_content_prefix("a\n\nb", "> "), "> a\n>\n> b");
2788 assert_eq!(apply_content_prefix("a\n\nb", " "), " a\n\n b");
2789 assert_eq!(apply_content_prefix("a\n\nb", "> "), "> a\n>\n> b");
2790 assert_eq!(apply_content_prefix("a\n\nb", ""), "a\n\nb");
2791 }
2792
2793 fn container_formatter_config() -> CodeBlockToolsConfig {
2796 use super::super::config::{LanguageToolConfig, ToolDefinition};
2797
2798 let mut config = default_config();
2799 config.normalize_language = NormalizeLanguage::Exact;
2800 config.languages.insert(
2801 "m".to_string(),
2802 LanguageToolConfig {
2803 format: vec!["reshape".to_string()],
2804 ..Default::default()
2805 },
2806 );
2807 config.tools.insert(
2808 "reshape".to_string(),
2809 ToolDefinition {
2810 command: vec![
2811 "awk".to_string(),
2812 "NR == 1 { print toupper($0); print \"\"; next } { print \" \" toupper($0) }".to_string(),
2813 ],
2814 ..Default::default()
2815 },
2816 );
2817 config
2818 }
2819
2820 #[cfg(unix)]
2824 #[test]
2825 fn test_format_refuses_write_back_that_changes_structure() {
2826 use super::super::config::{LanguageToolConfig, ToolDefinition};
2827
2828 let mut config = container_formatter_config();
2829 config.languages.insert(
2830 "f".to_string(),
2831 LanguageToolConfig {
2832 format: vec!["fence".to_string()],
2833 ..Default::default()
2834 },
2835 );
2836 config.tools.insert(
2837 "fence".to_string(),
2838 ToolDefinition {
2839 command: vec!["awk".to_string(), "{ print \"```\"; print toupper($0) }".to_string()],
2840 ..Default::default()
2841 },
2842 );
2843 for (language, program) in [
2844 ("c", "{ sub(/\\r$/, \"\"); printf \"%s\\r\\n\", toupper($0) }"),
2845 ("cf", "{ sub(/\\r$/, \"\"); printf \"```\\r\\n%s\\r\\n\", toupper($0) }"),
2846 ] {
2847 config.languages.insert(
2848 language.to_string(),
2849 LanguageToolConfig {
2850 format: vec![language.to_string()],
2851 ..Default::default()
2852 },
2853 );
2854 config.tools.insert(
2855 language.to_string(),
2856 ToolDefinition {
2857 command: vec!["awk".to_string(), program.to_string()],
2858 ..Default::default()
2859 },
2860 );
2861 }
2862 let standard_cases: &[(&str, &str, &[usize])] = &[
2864 ("```f\na\n```\n", "```f\na\n```\n", &[1]),
2865 ("> ```f\n> a\n> ```\n", "> ```f\n> a\n> ```\n", &[1]),
2866 (
2867 "```m\na\n```\n\n```f\na\n```\n\n- ```m\n b\n ```\n",
2868 "```m\nA\n\n```\n\n```f\na\n```\n\n- ```m\n B\n\n ```\n",
2869 &[5],
2870 ),
2871 ("````f\na\n````\n", "````f\n```\nA\n````\n", &[]),
2873 ("```c\na\n```\n", "```c\nA\r\n```\n", &[]),
2875 ("```c\r\na\r\n```\r\n", "```c\r\nA\r\n```\r\n", &[]),
2876 ("> ```c\r\n> a\r\n> ```\r\n", "> ```c\r\n> A\r\n> ```\r\n", &[]),
2877 ("```cf\r\na\r\n```\r\n", "```cf\r\na\r\n```\r\n", &[1]),
2878 ];
2879 let mkdocs_cases: &[(&str, &str, &[usize])] = &[
2881 (
2882 "!!! note\n\n ```c\n a\n ```\n",
2883 "!!! note\n\n ```c\n A\r\n ```\n",
2884 &[],
2885 ),
2886 (
2887 "!!! note\r\n\r\n ```c\r\n a\r\n ```\r\n",
2888 "!!! note\r\n\r\n ```c\r\n A\r\n ```\r\n",
2889 &[],
2890 ),
2891 (
2892 "!!! note\r\n\r\n ```cf\r\n a\r\n ```\r\n",
2893 "!!! note\r\n\r\n ```cf\r\n a\r\n ```\r\n",
2894 &[3],
2895 ),
2896 ];
2897
2898 for (flavor, cases) in [
2899 (MarkdownFlavor::Standard, standard_cases),
2900 (MarkdownFlavor::MkDocs, mkdocs_cases),
2901 ] {
2902 let processor = CodeBlockToolProcessor::new(&config, flavor);
2903 for &(document, expected, refused) in cases {
2904 let output = processor.format(document).expect("format should succeed");
2905 assert_eq!(output.content, expected, "{document:?}");
2906 let lines: Vec<usize> = output.failures.iter().map(|failure| failure.file_line).collect();
2907 assert_eq!(lines, refused, "{document:?}");
2908 assert_eq!(output.error_messages.len(), refused.len(), "{document:?}");
2909 assert_eq!(output.had_errors, !refused.is_empty(), "{document:?}");
2910 assert!(
2911 output
2912 .failures
2913 .iter()
2914 .all(|failure| failure.message.contains("block left unchanged")),
2915 "{:?}",
2916 output.failures
2917 );
2918 }
2919 }
2920 }
2921
2922 #[cfg(unix)]
2925 #[test]
2926 fn test_format_mkdocs_admonition_example_inside_a_block() {
2927 use super::super::config::{LanguageToolConfig, ToolDefinition};
2928
2929 let mut config = default_config();
2930 config.normalize_language = NormalizeLanguage::Exact;
2931 for language in ["markdown", "json"] {
2932 config.languages.insert(
2933 language.to_string(),
2934 LanguageToolConfig {
2935 format: vec!["replace".to_string()],
2936 ..Default::default()
2937 },
2938 );
2939 }
2940 config.tools.insert(
2941 "replace".to_string(),
2942 ToolDefinition {
2943 command: vec!["awk".to_string(), "END { print \"x\" }".to_string()],
2944 ..Default::default()
2945 },
2946 );
2947 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
2948
2949 let long = "a".repeat(200);
2950 let document =
2951 format!("# T\n\n```markdown\n!!! note\n\n ```json\n {{\"k\": \"{long}\"}}\n ```\n```\n");
2952 let output = processor.format(&document).expect("format should succeed");
2953 assert_eq!(output.content, "# T\n\n```markdown\nx\n```\n");
2954 assert!(output.failures.is_empty(), "{:?}", output.failures);
2955 }
2956
2957 #[test]
2960 fn test_write_back_refuses_overlapping_blocks() {
2961 let with_inner = |mut blocks: Vec<FencedCodeBlockInfo>| {
2963 let mut inner = blocks[1].clone();
2964 inner.content_start += 2;
2965 inner.code = inner.code[2..].to_string();
2966 blocks.push(inner);
2967 blocks
2968 };
2969 let content = format!("```m\nz\n```\n\n```m\n{}\n```\n", "a".repeat(100));
2970 let blocks = with_inner(extract_fenced_code_blocks(&content));
2971 let write_backs = [
2972 WriteBack {
2973 index: 0,
2974 code: "Z\n".to_string(),
2975 },
2976 WriteBack {
2977 index: 1,
2978 code: "A\n".to_string(),
2979 },
2980 WriteBack {
2981 index: 2,
2982 code: "B\n".to_string(),
2983 },
2984 ];
2985
2986 let (result, refused) = write_back_verified(&content, &blocks, &write_backs, |document| {
2987 with_inner(extract_fenced_code_blocks(document))
2988 });
2989 assert_eq!(result, content.replacen("```m\nz\n", "```m\nZ\n", 1));
2990 assert_eq!(refused, [1, 2]);
2991 }
2992
2993 #[cfg(unix)]
2996 #[test]
2997 fn test_format_writes_back_inside_containers() {
2998 let config = container_formatter_config();
2999 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3000
3001 let cases = [
3003 (
3004 "- ```m\n a\n b\n ```\n- next\n",
3005 "- ```m\n A\n\n B\n ```\n- next\n",
3006 "A\n\n B\n",
3007 ),
3008 (
3009 "1. ```m\n a\n b\n ```\n",
3010 "1. ```m\n A\n\n B\n ```\n",
3011 "A\n\n B\n",
3012 ),
3013 (
3014 "> ```m\n> a\n> b\n> ```\n",
3015 "> ```m\n> A\n>\n> B\n> ```\n",
3016 "A\n\n B\n",
3017 ),
3018 (
3019 "> > ```m\n> > a\n> > b\n> > ```\n",
3020 "> > ```m\n> > A\n> >\n> > B\n> > ```\n",
3021 "A\n\n B\n",
3022 ),
3023 (
3024 "> - ```m\n> a\n> b\n> ```\n",
3025 "> - ```m\n> A\n>\n> B\n> ```\n",
3026 "A\n\n B\n",
3027 ),
3028 (
3029 "- > ```m\n > a\n > b\n > ```\n",
3030 "- > ```m\n > A\n >\n > B\n > ```\n",
3031 "A\n\n B\n",
3032 ),
3033 ("> ```m\n> a\n> ```\n", "> ```m\n> A\n>\n> ```\n", "A\n\n"),
3035 (
3037 ">```m\n>a\n> b\n>```\n",
3038 ">```m\n> A\n>\n> B\n>```\n",
3039 "A\n\n B\n",
3040 ),
3041 (
3043 ">2) ```m\n> a\n> b\n> ```\n",
3044 ">2) ```m\n> A\n>\n> B\n> ```\n",
3045 "A\n\n B\n",
3046 ),
3047 (
3050 "1.\t```m\n a\n b\n ```\n",
3051 "1.\t```m\n A\n\n B\n ```\n",
3052 "A\n\n B\n",
3053 ),
3054 (
3057 "[^long-label]: ```m\n a\n b\n ```\n",
3058 "[^long-label]: ```m\n A\n\n B\n ```\n",
3059 "A\n\n B\n",
3060 ),
3061 (
3062 "> [^a]: ```m\n> a\n> b\n> ```\n",
3063 "> [^a]: ```m\n> A\n>\n> B\n> ```\n",
3064 "A\n\n B\n",
3065 ),
3066 (
3067 "- [^a]: ```m\n a\n b\n ```\n",
3068 "- [^a]: ```m\n A\n\n B\n ```\n",
3069 "A\n\n B\n",
3070 ),
3071 ("```m\na\nb\n```\n", "```m\nA\n\n B\n```\n", "A\n\n B\n"),
3072 ];
3073
3074 for (document, expected, code) in cases {
3075 let output = processor.format(document).expect("format should succeed");
3076 assert!(
3077 output.error_messages.is_empty(),
3078 "{document:?}: {:?}",
3079 output.error_messages
3080 );
3081 assert_eq!(output.content, expected, "{document:?}");
3082
3083 let blocks = processor.extract_code_blocks(&output.content);
3084 assert_eq!(blocks.len(), 1, "{expected:?}");
3085 assert_eq!(blocks[0].code, code, "{expected:?}");
3086 }
3087 }
3088
3089 #[cfg(unix)]
3092 #[test]
3093 fn test_lint_columns_include_container_prefix() {
3094 use super::super::config::{LanguageToolConfig, ToolDefinition};
3095
3096 let mut config = default_config();
3097 config.normalize_language = NormalizeLanguage::Exact;
3098 config.languages.insert(
3099 "m".to_string(),
3100 LanguageToolConfig {
3101 lint: vec!["pos".to_string()],
3102 ..Default::default()
3103 },
3104 );
3105 config.tools.insert(
3106 "pos".to_string(),
3107 ToolDefinition {
3108 command: vec![
3109 "sh".to_string(),
3110 "-c".to_string(),
3111 "cat >/dev/null; echo '-:1:1: one'; echo '-:2:3: two'".to_string(),
3112 ],
3113 ..Default::default()
3114 },
3115 );
3116 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3117
3118 let cases = [
3119 ("```m\nx\nyyy\n```\n", [(2, 1), (3, 3)]),
3120 ("- ```m\n x\n yyy\n ```\n", [(2, 3), (3, 5)]),
3121 ("> ```m\n> x\n>\n> ```\n", [(2, 3), (3, 4)]),
3122 ("text\n\n> - ```m\n> x\n> yyy\n> ```\n", [(4, 5), (5, 7)]),
3123 ];
3124 for (document, expected) in cases {
3125 let diagnostics = processor.lint(document).expect("lint should succeed");
3126 let positions: Vec<(usize, usize)> = diagnostics
3127 .iter()
3128 .map(|d| (d.file_line, d.column.expect("positioned diagnostic")))
3129 .collect();
3130 assert_eq!(positions, expected, "{document:?}");
3131 }
3132 }
3133
3134 #[test]
3135 fn test_infer_severity() {
3136 let config = default_config();
3137 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3138
3139 assert_eq!(
3140 processor.infer_severity("E501 line too long"),
3141 DiagnosticSeverity::Error
3142 );
3143 assert_eq!(
3144 processor.infer_severity("W291 trailing whitespace"),
3145 DiagnosticSeverity::Warning
3146 );
3147 assert_eq!(
3148 processor.infer_severity("error: something failed"),
3149 DiagnosticSeverity::Error
3150 );
3151 assert_eq!(
3152 processor.infer_severity("warning: unused variable"),
3153 DiagnosticSeverity::Warning
3154 );
3155 assert_eq!(
3156 processor.infer_severity("note: consider using"),
3157 DiagnosticSeverity::Info
3158 );
3159 }
3160
3161 #[test]
3162 fn test_parse_standard_format_windows_path() {
3163 let config = default_config();
3164 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3165
3166 let output = ToolOutput {
3167 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
3168 stderr: String::new(),
3169 exit_code: 1,
3170 success: false,
3171 };
3172
3173 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
3174 assert_eq!(diags.len(), 1);
3175 assert_eq!(diags[0].file_line, 12);
3176 assert_eq!(diags[0].column, Some(5));
3177 assert_eq!(diags[0].message, "E123 message");
3178 }
3179
3180 #[test]
3181 fn test_parse_eslint_severity() {
3182 let config = default_config();
3183 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3184
3185 let output = ToolOutput {
3186 stdout: "1:2 error Unexpected token".to_string(),
3187 stderr: String::new(),
3188 exit_code: 1,
3189 success: false,
3190 };
3191
3192 let diags = processor.parse_tool_output(&output, "eslint", 5);
3193 assert_eq!(diags.len(), 1);
3194 assert_eq!(diags[0].file_line, 6);
3195 assert_eq!(diags[0].column, Some(2));
3196 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
3197 assert_eq!(diags[0].message, "Unexpected token");
3198 }
3199
3200 #[test]
3201 fn test_parse_shellcheck_multiline() {
3202 let config = default_config();
3203 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3204
3205 let output = ToolOutput {
3206 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
3207 stderr: String::new(),
3208 exit_code: 1,
3209 success: false,
3210 };
3211
3212 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
3213 assert_eq!(diags.len(), 1);
3214 assert_eq!(diags[0].file_line, 13);
3215 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
3216 assert_eq!(diags[0].message, "Double quote to prevent globbing");
3217 }
3218
3219 #[test]
3220 fn test_lint_no_config() {
3221 let config = default_config();
3222 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3223
3224 let content = "```python\nprint('hello')\n```";
3225 let result = processor.lint(content);
3226
3227 assert!(result.is_ok());
3229 assert!(result.unwrap().is_empty());
3230 }
3231
3232 #[test]
3233 fn test_format_no_config() {
3234 let config = default_config();
3235 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3236
3237 let content = "```python\nprint('hello')\n```";
3238 let result = processor.format(content);
3239
3240 assert!(result.is_ok());
3242 let output = result.unwrap();
3243 assert_eq!(output.content, content);
3244 assert!(!output.had_errors);
3245 assert!(output.error_messages.is_empty());
3246 }
3247
3248 #[test]
3249 fn test_lint_on_missing_language_definition_fail() {
3250 let mut config = default_config();
3251 config.on_missing_language_definition = OnMissing::Fail;
3252 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3253
3254 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
3255 let result = processor.lint(content);
3256
3257 assert!(result.is_ok());
3259 let diagnostics = result.unwrap();
3260 assert_eq!(diagnostics.len(), 2);
3261 assert!(diagnostics[0].message.contains("No lint tools configured"));
3262 assert!(diagnostics[0].message.contains("python"));
3263 assert!(diagnostics[1].message.contains("javascript"));
3264 }
3265
3266 #[test]
3267 fn test_lint_on_missing_language_definition_fail_fast() {
3268 let mut config = default_config();
3269 config.on_missing_language_definition = OnMissing::FailFast;
3270 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3271
3272 let content = "```python\nprint('hello')\n```\n\n```javascript\nconsole.log('hi');\n```";
3273 let result = processor.lint(content);
3274
3275 assert!(result.is_err());
3277 let err = result.unwrap_err();
3278 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
3279 }
3280
3281 #[test]
3282 fn test_format_on_missing_language_definition_fail() {
3283 let mut config = default_config();
3284 config.on_missing_language_definition = OnMissing::Fail;
3285 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3286
3287 let content = "```python\nprint('hello')\n```";
3288 let result = processor.format(content);
3289
3290 assert!(result.is_ok());
3292 let output = result.unwrap();
3293 assert_eq!(output.content, content); assert!(output.had_errors);
3295 assert!(!output.error_messages.is_empty());
3296 assert!(output.error_messages[0].contains("No format tools configured"));
3297 }
3298
3299 #[test]
3300 fn test_format_on_missing_language_definition_fail_fast() {
3301 let mut config = default_config();
3302 config.on_missing_language_definition = OnMissing::FailFast;
3303 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3304
3305 let content = "```python\nprint('hello')\n```";
3306 let result = processor.format(content);
3307
3308 assert!(result.is_err());
3310 let err = result.unwrap_err();
3311 assert!(matches!(err, ProcessorError::NoToolsConfigured { .. }));
3312 }
3313
3314 #[test]
3315 fn test_lint_on_missing_tool_binary_fail() {
3316 use super::super::config::{LanguageToolConfig, ToolDefinition};
3317
3318 let mut config = default_config();
3319 config.on_missing_tool_binary = OnMissing::Fail;
3320
3321 let lang_config = LanguageToolConfig {
3323 lint: vec!["nonexistent-linter".to_string()],
3324 ..Default::default()
3325 };
3326 config.languages.insert("python".to_string(), lang_config);
3327
3328 let tool_def = ToolDefinition {
3329 command: vec!["nonexistent-binary-xyz123".to_string()],
3330 ..Default::default()
3331 };
3332 config.tools.insert("nonexistent-linter".to_string(), tool_def);
3333
3334 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3335
3336 let content = "```python\nprint('hello')\n```";
3337 let result = processor.lint(content);
3338
3339 assert!(result.is_ok());
3341 let diagnostics = result.unwrap();
3342 assert_eq!(diagnostics.len(), 1);
3343 assert!(diagnostics[0].message.contains("not found in allowed lookup locations"));
3344 }
3345
3346 #[test]
3347 fn test_lint_on_missing_tool_binary_fail_fast() {
3348 use super::super::config::{LanguageToolConfig, ToolDefinition};
3349
3350 let mut config = default_config();
3351 config.on_missing_tool_binary = OnMissing::FailFast;
3352
3353 let lang_config = LanguageToolConfig {
3355 lint: vec!["nonexistent-linter".to_string()],
3356 ..Default::default()
3357 };
3358 config.languages.insert("python".to_string(), lang_config);
3359
3360 let tool_def = ToolDefinition {
3361 command: vec!["nonexistent-binary-xyz123".to_string()],
3362 ..Default::default()
3363 };
3364 config.tools.insert("nonexistent-linter".to_string(), tool_def);
3365
3366 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3367
3368 let content = "```python\nprint('hello')\n```";
3369 let result = processor.lint(content);
3370
3371 assert!(result.is_err());
3373 let err = result.unwrap_err();
3374 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
3375 }
3376
3377 #[test]
3378 fn test_format_on_missing_tool_binary_fail() {
3379 use super::super::config::{LanguageToolConfig, ToolDefinition};
3380
3381 let mut config = default_config();
3382 config.on_missing_tool_binary = OnMissing::Fail;
3383
3384 let lang_config = LanguageToolConfig {
3386 format: vec!["nonexistent-formatter".to_string()],
3387 ..Default::default()
3388 };
3389 config.languages.insert("python".to_string(), lang_config);
3390
3391 let tool_def = ToolDefinition {
3392 command: vec!["nonexistent-binary-xyz123".to_string()],
3393 ..Default::default()
3394 };
3395 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
3396
3397 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3398
3399 let content = "```python\nprint('hello')\n```";
3400 let result = processor.format(content);
3401
3402 assert!(result.is_ok());
3404 let output = result.unwrap();
3405 assert_eq!(output.content, content); assert!(output.had_errors);
3407 assert!(!output.error_messages.is_empty());
3408 assert!(output.error_messages[0].contains("not found in allowed lookup locations"));
3409 }
3410
3411 #[test]
3412 fn test_format_on_missing_tool_binary_fail_fast() {
3413 use super::super::config::{LanguageToolConfig, ToolDefinition};
3414
3415 let mut config = default_config();
3416 config.on_missing_tool_binary = OnMissing::FailFast;
3417
3418 let lang_config = LanguageToolConfig {
3420 format: vec!["nonexistent-formatter".to_string()],
3421 ..Default::default()
3422 };
3423 config.languages.insert("python".to_string(), lang_config);
3424
3425 let tool_def = ToolDefinition {
3426 command: vec!["nonexistent-binary-xyz123".to_string()],
3427 ..Default::default()
3428 };
3429 config.tools.insert("nonexistent-formatter".to_string(), tool_def);
3430
3431 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3432
3433 let content = "```python\nprint('hello')\n```";
3434 let result = processor.format(content);
3435
3436 assert!(result.is_err());
3438 let err = result.unwrap_err();
3439 assert!(matches!(err, ProcessorError::ToolBinaryNotFound { .. }));
3440 }
3441
3442 #[test]
3443 fn test_lint_rumdl_builtin_skipped_for_markdown() {
3444 let mut config = default_config();
3447 config.languages.insert(
3448 "markdown".to_string(),
3449 LanguageToolConfig {
3450 lint: vec![RUMDL_BUILTIN_TOOL.to_string()],
3451 ..Default::default()
3452 },
3453 );
3454 config.on_missing_language_definition = OnMissing::Fail;
3455 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3456
3457 let content = "```markdown\n# Hello\n```";
3458 let result = processor.lint(content);
3459
3460 assert!(result.is_ok());
3462 assert!(result.unwrap().is_empty());
3463 }
3464
3465 #[test]
3466 fn test_format_rumdl_builtin_skipped_for_markdown() {
3467 let mut config = default_config();
3469 config.languages.insert(
3470 "markdown".to_string(),
3471 LanguageToolConfig {
3472 format: vec![RUMDL_BUILTIN_TOOL.to_string()],
3473 ..Default::default()
3474 },
3475 );
3476 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3477
3478 let content = "```markdown\n# Hello\n```";
3479 let result = processor.format(content);
3480
3481 assert!(result.is_ok());
3483 let output = result.unwrap();
3484 assert_eq!(output.content, content);
3485 assert!(!output.had_errors);
3486 }
3487
3488 #[test]
3489 fn test_is_markdown_language() {
3490 assert!(is_markdown_language("markdown"));
3492 assert!(is_markdown_language("Markdown"));
3493 assert!(is_markdown_language("MARKDOWN"));
3494 assert!(is_markdown_language("md"));
3495 assert!(is_markdown_language("MD"));
3496 assert!(!is_markdown_language("python"));
3497 assert!(!is_markdown_language("rust"));
3498 assert!(!is_markdown_language(""));
3499 }
3500
3501 #[test]
3504 fn test_extract_mkdocs_admonition_code_block() {
3505 let config = default_config();
3506 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3507
3508 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
3509 let blocks = processor.extract_code_blocks(content);
3510
3511 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs admonition");
3512 assert_eq!(blocks[0].language, "python");
3513 }
3514
3515 #[test]
3516 fn test_extract_mkdocs_tab_code_block() {
3517 let config = default_config();
3518 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3519
3520 let content = "=== \"Python\"\n\n ```python\n print(\"hello\")\n ```\n";
3521 let blocks = processor.extract_code_blocks(content);
3522
3523 assert_eq!(blocks.len(), 1, "Should detect code block inside MkDocs tab");
3524 assert_eq!(blocks[0].language, "python");
3525 }
3526
3527 #[test]
3528 fn test_standard_flavor_ignores_admonition_indented_content() {
3529 let config = default_config();
3530 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3531
3532 let content = "!!! note\n Some text\n\n ```python\n def hello():\n pass\n ```\n";
3535 let blocks = processor.extract_code_blocks(content);
3536
3537 for (i, b) in blocks.iter().enumerate() {
3541 for (j, b2) in blocks.iter().enumerate() {
3542 if i != j {
3543 assert_ne!(b.start_line, b2.start_line, "No duplicate blocks should exist");
3544 }
3545 }
3546 }
3547 }
3548
3549 #[test]
3550 fn test_mkdocs_top_level_blocks_alongside_admonition() {
3551 let config = default_config();
3552 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3553
3554 let content =
3555 "```rust\nfn main() {}\n```\n\n!!! note\n Some text\n\n ```python\n print(\"hello\")\n ```\n";
3556 let blocks = processor.extract_code_blocks(content);
3557
3558 assert_eq!(
3559 blocks.len(),
3560 2,
3561 "Should detect both top-level and admonition code blocks"
3562 );
3563 assert_eq!(blocks[0].language, "rust");
3564 assert_eq!(blocks[1].language, "python");
3565 }
3566
3567 #[test]
3568 fn test_mkdocs_nested_admonition_code_block() {
3569 let config = default_config();
3570 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3571
3572 let content = "\
3573!!! note
3574 Some text
3575
3576 !!! warning
3577 Nested content
3578
3579 ```python
3580 x = 1
3581 ```
3582";
3583 let blocks = processor.extract_code_blocks(content);
3584 assert_eq!(blocks.len(), 1, "Should detect code block inside nested admonition");
3585 assert_eq!(blocks[0].language, "python");
3586 }
3587
3588 #[test]
3589 fn test_mkdocs_consecutive_admonitions_no_stale_context() {
3590 let config = default_config();
3591 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3592
3593 let content = "\
3596!!! note
3597 First admonition content
3598
3599!!! warning
3600 Second admonition content
3601
3602 ```python
3603 y = 2
3604 ```
3605";
3606 let blocks = processor.extract_code_blocks(content);
3607 assert_eq!(blocks.len(), 1, "Should detect code block in second admonition only");
3608 assert_eq!(blocks[0].language, "python");
3609 }
3610
3611 #[test]
3614 fn test_mkdocs_fenced_block_lines_are_code() {
3615 let config = default_config();
3616 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3617
3618 type Span<'a> = (&'a str, usize, usize);
3620 let cases: &[(&str, &[Span<'_>])] = &[
3621 (
3622 "```markdown\n!!! note\n\n ```json\n {}\n ```\n```\n",
3623 &[("markdown", 0, 6)],
3624 ),
3625 (
3626 "> ```md\n> === \"Tab\"\n>\n> ```py\n> x\n> ```\n> ```\n",
3627 &[("md", 0, 6)],
3628 ),
3629 (
3630 "!!! note\n\n text\n\n```text\nx\n```\n\n ```json\n {}\n ```\n",
3631 &[("text", 4, 6)],
3632 ),
3633 (
3634 "```markdown\n!!! note\n```\n\n!!! tip\n\n ```json\n {}\n ```\n",
3635 &[("markdown", 0, 2), ("json", 6, 8)],
3636 ),
3637 (
3638 "> ```up\n> a\n!!! note\n\n ```up\n b\n ```\n",
3639 &[("up", 0, 1), ("up", 4, 6)],
3640 ),
3641 ];
3642 for &(content, expected) in cases {
3643 let extracted = processor.extract_code_blocks(content);
3644 let blocks: Vec<Span<'_>> = extracted
3645 .iter()
3646 .map(|block| (block.language.as_str(), block.start_line, block.end_line))
3647 .collect();
3648 assert_eq!(blocks, expected, "{content:?}");
3649 }
3650 }
3651
3652 #[test]
3653 fn test_mkdocs_crlf_line_endings() {
3654 let config = default_config();
3655 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3656
3657 let content = "!!! note\r\n Some text\r\n\r\n ```python\r\n x = 1\r\n ```\r\n";
3659 let blocks = processor.extract_code_blocks(content);
3660
3661 assert_eq!(blocks.len(), 1, "Should detect code block with CRLF line endings");
3662 assert_eq!(blocks[0].language, "python");
3663
3664 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
3666 assert!(
3667 extracted.contains("x = 1"),
3668 "Extracted content should contain code. Got: {extracted:?}"
3669 );
3670 }
3671
3672 #[test]
3673 fn test_mkdocs_unclosed_fence_in_admonition() {
3674 let config = default_config();
3675 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3676
3677 let content = "!!! note\n ```python\n x = 1\n no closing fence\n";
3679 let blocks = processor.extract_code_blocks(content);
3680 assert_eq!(blocks.len(), 0, "Unclosed fence should not produce a block");
3681 }
3682
3683 #[test]
3684 fn test_mkdocs_tilde_fence_in_admonition() {
3685 let config = default_config();
3686 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3687
3688 let content = "!!! note\n ~~~ruby\n puts 'hi'\n ~~~\n";
3689 let blocks = processor.extract_code_blocks(content);
3690 assert_eq!(blocks.len(), 1, "Should detect tilde-fenced code block");
3691 assert_eq!(blocks[0].language, "ruby");
3692 }
3693
3694 #[test]
3695 fn test_mkdocs_empty_lines_in_code_block() {
3696 let config = default_config();
3697 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3698
3699 let content = "!!! note\n ```python\n x = 1\n\n y = 2\n ```\n";
3702 let blocks = processor.extract_code_blocks(content);
3703 assert_eq!(blocks.len(), 1);
3704
3705 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
3706 assert!(
3707 extracted.contains("x = 1") && extracted.contains("y = 2"),
3708 "Extracted content should span across the empty line. Got: {extracted:?}"
3709 );
3710 }
3711
3712 #[test]
3713 fn test_mkdocs_content_byte_offsets_lf() {
3714 let config = default_config();
3715 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3716
3717 let content = "!!! note\n ```python\n print('hi')\n ```\n";
3718 let blocks = processor.extract_code_blocks(content);
3719 assert_eq!(blocks.len(), 1);
3720
3721 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
3723 assert_eq!(extracted, " print('hi')\n", "Content offsets should be exact for LF");
3724 }
3725
3726 #[test]
3727 fn test_mkdocs_content_byte_offsets_crlf() {
3728 let config = default_config();
3729 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::MkDocs);
3730
3731 let content = "!!! note\r\n ```python\r\n print('hi')\r\n ```\r\n";
3732 let blocks = processor.extract_code_blocks(content);
3733 assert_eq!(blocks.len(), 1);
3734
3735 let extracted = &content[blocks[0].content_start..blocks[0].content_end];
3736 assert_eq!(
3737 extracted, " print('hi')\r\n",
3738 "Content offsets should be exact for CRLF"
3739 );
3740 }
3741
3742 #[test]
3743 fn test_lint_enabled_false_skips_language_in_strict_mode() {
3744 let mut config = default_config();
3747 config.normalize_language = NormalizeLanguage::Exact;
3748 config.on_missing_language_definition = OnMissing::Fail;
3749
3750 config.languages.insert(
3752 "python".to_string(),
3753 LanguageToolConfig {
3754 lint: vec!["ruff:check".to_string()],
3755 ..Default::default()
3756 },
3757 );
3758 config.languages.insert(
3759 "plaintext".to_string(),
3760 LanguageToolConfig {
3761 enabled: false,
3762 ..Default::default()
3763 },
3764 );
3765
3766 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3767
3768 let content = "```plaintext\nsome text\n```";
3769 let result = processor.lint(content);
3770
3771 assert!(result.is_ok());
3773 let diagnostics = result.unwrap();
3774 assert!(
3775 diagnostics.is_empty(),
3776 "Expected no diagnostics for disabled language, got: {diagnostics:?}"
3777 );
3778 }
3779
3780 #[test]
3781 fn test_format_enabled_false_skips_language_in_strict_mode() {
3782 let mut config = default_config();
3784 config.normalize_language = NormalizeLanguage::Exact;
3785 config.on_missing_language_definition = OnMissing::Fail;
3786
3787 config.languages.insert(
3788 "plaintext".to_string(),
3789 LanguageToolConfig {
3790 enabled: false,
3791 ..Default::default()
3792 },
3793 );
3794
3795 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3796
3797 let content = "```plaintext\nsome text\n```";
3798 let result = processor.format(content);
3799
3800 assert!(result.is_ok());
3802 let output = result.unwrap();
3803 assert!(!output.had_errors, "Expected no errors for disabled language");
3804 assert!(
3805 output.error_messages.is_empty(),
3806 "Expected no error messages, got: {:?}",
3807 output.error_messages
3808 );
3809 }
3810
3811 #[test]
3812 fn test_enabled_false_default_true_preserved() {
3813 let mut config = default_config();
3815 config.on_missing_language_definition = OnMissing::Fail;
3816
3817 config.languages.insert(
3819 "python".to_string(),
3820 LanguageToolConfig {
3821 lint: vec!["ruff:check".to_string()],
3822 ..Default::default()
3823 },
3824 );
3825
3826 let lang_config = config.languages.get("python").unwrap();
3827 assert!(lang_config.enabled, "enabled should default to true");
3828 }
3829
3830 #[test]
3831 fn test_enabled_false_with_fail_fast_no_error() {
3832 let mut config = default_config();
3834 config.normalize_language = NormalizeLanguage::Exact;
3835 config.on_missing_language_definition = OnMissing::FailFast;
3836
3837 config.languages.insert(
3838 "unknown".to_string(),
3839 LanguageToolConfig {
3840 enabled: false,
3841 ..Default::default()
3842 },
3843 );
3844
3845 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3846
3847 let content = "```unknown\nsome content\n```";
3848 let result = processor.lint(content);
3849
3850 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
3852 assert!(result.unwrap().is_empty());
3853 }
3854
3855 #[test]
3856 fn test_enabled_false_format_with_fail_fast_no_error() {
3857 let mut config = default_config();
3859 config.normalize_language = NormalizeLanguage::Exact;
3860 config.on_missing_language_definition = OnMissing::FailFast;
3861
3862 config.languages.insert(
3863 "unknown".to_string(),
3864 LanguageToolConfig {
3865 enabled: false,
3866 ..Default::default()
3867 },
3868 );
3869
3870 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3871
3872 let content = "```unknown\nsome content\n```";
3873 let result = processor.format(content);
3874
3875 assert!(result.is_ok(), "Expected Ok but got Err: {result:?}");
3876 let output = result.unwrap();
3877 assert!(!output.had_errors);
3878 }
3879
3880 #[test]
3881 fn test_enabled_false_with_tools_still_skips() {
3882 let mut config = default_config();
3884 config.on_missing_language_definition = OnMissing::Fail;
3885
3886 config.languages.insert(
3887 "python".to_string(),
3888 LanguageToolConfig {
3889 enabled: false,
3890 lint: vec!["ruff:check".to_string()],
3891 format: vec!["ruff:format".to_string()],
3892 ..Default::default()
3893 },
3894 );
3895
3896 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3897
3898 let content = "```python\nprint('hello')\n```";
3899
3900 let lint_result = processor.lint(content);
3902 assert!(lint_result.is_ok());
3903 assert!(lint_result.unwrap().is_empty());
3904
3905 let format_result = processor.format(content);
3907 assert!(format_result.is_ok());
3908 let output = format_result.unwrap();
3909 assert!(!output.had_errors);
3910 assert_eq!(output.content, content, "Content should be unchanged");
3911 }
3912
3913 #[test]
3914 fn test_enabled_true_without_tools_triggers_strict_mode() {
3915 let mut config = default_config();
3918 config.on_missing_language_definition = OnMissing::Fail;
3919
3920 config.languages.insert(
3921 "python".to_string(),
3922 LanguageToolConfig {
3923 ..Default::default()
3925 },
3926 );
3927
3928 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3929
3930 let content = "```python\nprint('hello')\n```";
3931 let result = processor.lint(content);
3932
3933 assert!(result.is_ok());
3935 let diagnostics = result.unwrap();
3936 assert_eq!(diagnostics.len(), 1);
3937 assert!(diagnostics[0].message.contains("No lint tools configured"));
3938 }
3939
3940 #[test]
3941 fn test_mixed_enabled_and_disabled_languages() {
3942 let mut config = default_config();
3944 config.normalize_language = NormalizeLanguage::Exact;
3945 config.on_missing_language_definition = OnMissing::Fail;
3946
3947 config.languages.insert(
3948 "plaintext".to_string(),
3949 LanguageToolConfig {
3950 enabled: false,
3951 ..Default::default()
3952 },
3953 );
3954
3955 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3956
3957 let content = "\
3958```plaintext
3959some text
3960```
3961
3962```javascript
3963console.log('hi');
3964```
3965";
3966
3967 let result = processor.lint(content);
3968 assert!(result.is_ok());
3969 let diagnostics = result.unwrap();
3970
3971 assert_eq!(diagnostics.len(), 1, "Expected 1 diagnostic, got: {diagnostics:?}");
3974 assert!(
3975 diagnostics[0].message.contains("javascript"),
3976 "Error should be about javascript, got: {}",
3977 diagnostics[0].message
3978 );
3979 }
3980
3981 #[test]
3982 fn test_generic_fallback_includes_all_stderr_lines() {
3983 let config = default_config();
3984 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
3985
3986 let output = ToolOutput {
3988 stdout: String::new(),
3989 stderr: "Parse error at position 42\nUnexpected token '::'\n3 errors found".to_string(),
3990 exit_code: 1,
3991 success: false,
3992 };
3993
3994 let diags = processor.parse_tool_output(&output, "tombi", 5);
3995 assert_eq!(diags.len(), 3, "Expected one diagnostic per non-empty stderr line");
3996 assert_eq!(diags[0].message, "Parse error at position 42");
3997 assert_eq!(diags[1].message, "Unexpected token '::'");
3998 assert_eq!(diags[2].message, "3 errors found");
3999 assert!(diags.iter().all(|d| d.tool == "tombi"));
4000 assert!(diags.iter().all(|d| d.file_line == 5));
4001 }
4002
4003 #[test]
4004 fn test_generic_fallback_includes_all_stdout_lines_when_stderr_empty() {
4005 let config = default_config();
4006 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4007
4008 let output = ToolOutput {
4009 stdout: "Line 1 error\nLine 2 detail\nLine 3 summary".to_string(),
4010 stderr: String::new(),
4011 exit_code: 1,
4012 success: false,
4013 };
4014
4015 let diags = processor.parse_tool_output(&output, "some-tool", 10);
4016 assert_eq!(diags.len(), 3);
4017 assert_eq!(diags[0].message, "Line 1 error");
4018 assert_eq!(diags[1].message, "Line 2 detail");
4019 assert_eq!(diags[2].message, "Line 3 summary");
4020 }
4021
4022 #[test]
4023 fn test_generic_fallback_skips_blank_lines() {
4024 let config = default_config();
4025 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4026
4027 let output = ToolOutput {
4028 stdout: String::new(),
4029 stderr: "error: bad input\n\n \n\ndetail: see above\n".to_string(),
4030 exit_code: 1,
4031 success: false,
4032 };
4033
4034 let diags = processor.parse_tool_output(&output, "tool", 1);
4035 assert_eq!(diags.len(), 2);
4036 assert_eq!(diags[0].message, "error: bad input");
4037 assert_eq!(diags[1].message, "detail: see above");
4038 }
4039
4040 #[test]
4041 fn test_generic_fallback_exit_code_when_no_output() {
4042 let config = default_config();
4043 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4044
4045 let output = ToolOutput {
4046 stdout: String::new(),
4047 stderr: String::new(),
4048 exit_code: 42,
4049 success: false,
4050 };
4051
4052 let diags = processor.parse_tool_output(&output, "tool", 1);
4053 assert_eq!(diags.len(), 1);
4054 assert_eq!(diags[0].message, "Tool exited with code 42");
4055 }
4056
4057 #[test]
4058 fn test_generic_fallback_not_triggered_on_success() {
4059 let config = default_config();
4060 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4061
4062 let output = ToolOutput {
4063 stdout: "some informational output".to_string(),
4064 stderr: String::new(),
4065 exit_code: 0,
4066 success: true,
4067 };
4068
4069 let diags = processor.parse_tool_output(&output, "tool", 1);
4070 assert!(
4071 diags.is_empty(),
4072 "Successful tool runs should produce no fallback diagnostics"
4073 );
4074 }
4075
4076 #[test]
4077 fn test_ansi_codes_stripped_before_parsing() {
4078 let config = default_config();
4079 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4080
4081 let output = ToolOutput {
4083 stdout: "\x1b[1m_.py\x1b[0m:\x1b[33m1\x1b[0m:\x1b[33m1\x1b[0m: \x1b[31mE501\x1b[0m Line too long"
4084 .to_string(),
4085 stderr: String::new(),
4086 exit_code: 1,
4087 success: false,
4088 };
4089
4090 let diags = processor.parse_tool_output(&output, "ruff:check", 5);
4091 assert_eq!(diags.len(), 1, "ANSI-colored output should still be parsed");
4092 assert_eq!(diags[0].message, "E501 Line too long");
4093 assert_eq!(diags[0].file_line, 6); }
4095
4096 #[test]
4097 fn test_tombi_multiline_error_format() {
4098 let config = default_config();
4099 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4100
4101 let output = ToolOutput {
4103 stdout: "[test]\ntest: \"test\"\nError: invalid key\n at line 2 column 1\nError: expected key\n at line 2 column 1\nError: expected '='\n at line 2 column 1\nError: expected value\n at line 2 column 1".to_string(),
4104 stderr: "1 file failed to be formatted".to_string(),
4105 exit_code: 1,
4106 success: false,
4107 };
4108
4109 let diags = processor.parse_tool_output(&output, "tombi", 7);
4110 assert_eq!(
4111 diags.len(),
4112 4,
4113 "Expected 4 diagnostics from tombi errors, got {diags:?}"
4114 );
4115 assert_eq!(diags[0].message, "invalid key");
4116 assert_eq!(diags[0].file_line, 9); assert_eq!(diags[0].column, Some(1));
4118 assert_eq!(diags[1].message, "expected key");
4119 assert_eq!(diags[1].file_line, 9);
4120 assert_eq!(diags[2].message, "expected '='");
4121 assert_eq!(diags[3].message, "expected value");
4122 assert!(diags.iter().all(|d| d.tool == "tombi"));
4123 }
4124
4125 #[test]
4126 fn test_tombi_with_ansi_codes() {
4127 let config = default_config();
4128 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4129
4130 let output = ToolOutput {
4132 stdout: "[test]\ntest: \"test\"\n\x1b[1;31m Error\x1b[0m: \x1b[1minvalid key\x1b[0m\n \x1b[90mat line 2 column 1\x1b[0m\n\x1b[1;31m Error\x1b[0m: \x1b[1mexpected '='\x1b[0m\n \x1b[90mat line 2 column 1\x1b[0m".to_string(),
4133 stderr: "1 file failed to be formatted".to_string(),
4134 exit_code: 1,
4135 success: false,
4136 };
4137
4138 let diags = processor.parse_tool_output(&output, "tombi", 7);
4139 assert_eq!(
4140 diags.len(),
4141 2,
4142 "Expected 2 diagnostics from ANSI-colored tombi output, got {diags:?}"
4143 );
4144 assert_eq!(diags[0].message, "invalid key");
4145 assert_eq!(diags[0].file_line, 9);
4146 assert_eq!(diags[1].message, "expected '='");
4147 assert_eq!(diags[1].file_line, 9);
4148 }
4149
4150 const SQLFLUFF_ANNOTATIONS: &str = "\
4155::group::stdin
4156::warning title=SQLFluff,file=stdin,line=1,col=7,endLine=1,endColumn=10::LT01: Expected only single space before numeric literal. Found ' '. [layout.spacing]
4157::warning title=SQLFluff,file=stdin,line=1,col=11,endLine=1,endColumn=13::LT01: Expected only single space before 'FROM' keyword. Found ' '. [layout.spacing]
4158::warning title=SQLFluff,file=stdin,line=1,col=17,endLine=1,endColumn=20::LT01: Expected only single space before naked identifier. Found ' '. [layout.spacing]
4159::endgroup::";
4160
4161 #[test]
4162 fn test_github_annotation_maps_line_and_column() {
4163 let config = default_config();
4164 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4165
4166 let output = ToolOutput {
4167 stdout: SQLFLUFF_ANNOTATIONS.to_string(),
4168 stderr: String::new(),
4169 exit_code: 1,
4170 success: false,
4171 };
4172
4173 let diags = processor.parse_tool_output(&output, "sqlfluff:lint", 3);
4174 assert_eq!(diags.len(), 3, "the group markers carry no finding: {diags:?}");
4175 assert_eq!(diags.iter().map(|d| d.file_line).collect::<Vec<_>>(), vec![4, 4, 4]);
4176 assert_eq!(
4177 diags.iter().map(|d| d.column).collect::<Vec<_>>(),
4178 vec![Some(7), Some(11), Some(17)]
4179 );
4180 assert!(diags[0].message.starts_with("LT01: Expected only single space"));
4181 assert!(diags.iter().all(|d| matches!(d.severity, DiagnosticSeverity::Warning)));
4182 }
4183
4184 #[test]
4185 fn test_github_annotation_severity_per_level() {
4186 let config = default_config();
4187 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4188
4189 let output = ToolOutput {
4190 stdout: "::error line=1::broken\n::warning line=2::suspicious\n::notice line=3::detail".to_string(),
4191 stderr: String::new(),
4192 exit_code: 1,
4193 success: false,
4194 };
4195
4196 let diags = processor.parse_tool_output(&output, "tool", 10);
4197 assert_eq!(diags.len(), 3);
4198 assert!(matches!(diags[0].severity, DiagnosticSeverity::Error));
4199 assert!(matches!(diags[1].severity, DiagnosticSeverity::Warning));
4200 assert!(matches!(diags[2].severity, DiagnosticSeverity::Info));
4201 assert_eq!(diags[2].file_line, 13);
4202 }
4203
4204 #[test]
4205 fn test_github_annotation_without_line_anchors_at_fence() {
4206 let config = default_config();
4207 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4208
4209 let output = ToolOutput {
4210 stdout: "::error::something the tool could not place".to_string(),
4211 stderr: String::new(),
4212 exit_code: 1,
4213 success: false,
4214 };
4215
4216 let diags = processor.parse_tool_output(&output, "tool", 12);
4217 assert_eq!(diags.len(), 1);
4218 assert_eq!(diags[0].file_line, 12);
4219 assert_eq!(diags[0].column, None);
4220 assert_eq!(diags[0].message, "something the tool could not place");
4221 }
4222
4223 #[test]
4224 fn test_github_annotation_decodes_escaped_message() {
4225 let config = default_config();
4226 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4227
4228 let output = ToolOutput {
4231 stdout: "::error line=1,col=2::100%25 of rows%3A a%2C b%0Aand more".to_string(),
4232 stderr: String::new(),
4233 exit_code: 1,
4234 success: false,
4235 };
4236
4237 let diags = processor.parse_tool_output(&output, "tool", 5);
4238 assert_eq!(diags.len(), 1);
4239 assert_eq!(diags[0].message, "100% of rows: a, b and more");
4240 assert_eq!(diags[0].file_line, 6);
4241 assert_eq!(diags[0].column, Some(2));
4242 }
4243
4244 #[test]
4245 fn test_decode_workflow_command_is_a_single_left_to_right_pass() {
4246 assert_eq!(decode_workflow_command("%250A"), "%0A");
4249 assert_eq!(decode_workflow_command("no escapes"), "no escapes");
4250 assert_eq!(decode_workflow_command("50% off"), "50% off");
4251 assert_eq!(decode_workflow_command("trailing %"), "trailing %");
4252 assert_eq!(decode_workflow_command("%0d%0a"), " ");
4253 assert_eq!(decode_workflow_command("%3A%2C"), ":,");
4254 }
4255
4256 #[test]
4257 fn test_position_in_message_anchors_a_prose_position() {
4258 let config = default_config();
4259 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4260
4261 let output = ToolOutput {
4264 stdout: String::new(),
4265 stderr: "jq: error (at <stdin>:0): syntax error, unexpected '}' at line 3, column 8".to_string(),
4266 exit_code: 2,
4267 success: false,
4268 };
4269
4270 let diags = processor.parse_tool_output(&output, "jq", 20);
4271 assert_eq!(diags.len(), 1);
4272 assert_eq!(diags[0].file_line, 23);
4273 assert_eq!(diags[0].column, Some(8));
4274 }
4275
4276 #[test]
4277 fn test_position_in_message_takes_the_last_one_named() {
4278 let config = default_config();
4279 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4280
4281 let output = ToolOutput {
4282 stdout: "unterminated string started at line 2, column 1 at line 6, column 4".to_string(),
4283 stderr: String::new(),
4284 exit_code: 1,
4285 success: false,
4286 };
4287
4288 let diags = processor.parse_tool_output(&output, "tool", 0);
4289 assert_eq!(diags.len(), 1);
4290 assert_eq!(diags[0].file_line, 6, "the sentence ends on the position it is about");
4291 assert_eq!(diags[0].column, Some(4));
4292 }
4293
4294 #[test]
4295 fn test_message_naming_no_position_stays_on_the_fence() {
4296 let config = default_config();
4297 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4298
4299 let output = ToolOutput {
4300 stdout: "could not parse the input".to_string(),
4301 stderr: String::new(),
4302 exit_code: 1,
4303 success: false,
4304 };
4305
4306 let diags = processor.parse_tool_output(&output, "tool", 7);
4307 assert_eq!(diags.len(), 1);
4308 assert_eq!(diags[0].file_line, 7);
4309 assert_eq!(diags[0].column, None);
4310 }
4311
4312 #[test]
4313 fn test_fallback_combines_stdout_and_stderr() {
4314 let config = default_config();
4315 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4316
4317 let output = ToolOutput {
4319 stdout: "problem found in input".to_string(),
4320 stderr: "1 file failed".to_string(),
4321 exit_code: 1,
4322 success: false,
4323 };
4324
4325 let diags = processor.parse_tool_output(&output, "tool", 1);
4326 assert_eq!(diags.len(), 2, "Fallback should include both stdout and stderr");
4327 assert_eq!(diags[0].message, "problem found in input");
4328 assert_eq!(diags[1].message, "1 file failed");
4329 }
4330
4331 #[test]
4332 fn test_error_line_without_position_info() {
4333 let config = default_config();
4334 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4335
4336 let output = ToolOutput {
4338 stdout: "Error: something went wrong\nsome unrelated line".to_string(),
4339 stderr: String::new(),
4340 exit_code: 1,
4341 success: false,
4342 };
4343
4344 let diags = processor.parse_tool_output(&output, "tool", 5);
4345 assert!(!diags.is_empty());
4348 assert_eq!(diags[0].message, "something went wrong");
4349 assert_eq!(diags[0].file_line, 5); }
4351
4352 #[test]
4353 fn test_warning_line_with_position() {
4354 let config = default_config();
4355 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4356
4357 let output = ToolOutput {
4358 stdout: "Warning: deprecated syntax\n at line 3 column 5".to_string(),
4359 stderr: String::new(),
4360 exit_code: 1,
4361 success: false,
4362 };
4363
4364 let diags = processor.parse_tool_output(&output, "tool", 10);
4365 assert_eq!(diags.len(), 1);
4366 assert_eq!(diags[0].message, "deprecated syntax");
4367 assert_eq!(diags[0].file_line, 13); assert_eq!(diags[0].column, Some(5));
4369 assert!(matches!(diags[0].severity, DiagnosticSeverity::Warning));
4370 }
4371
4372 #[test]
4373 fn test_strip_ansi_codes() {
4374 assert_eq!(strip_ansi_codes("hello"), "hello");
4375 assert_eq!(strip_ansi_codes("\x1b[31mred\x1b[0m"), "red");
4376 assert_eq!(
4377 strip_ansi_codes("\x1b[1;31m Error\x1b[0m: \x1b[1mmsg\x1b[0m"),
4378 " Error: msg"
4379 );
4380 assert_eq!(strip_ansi_codes("no codes here"), "no codes here");
4381 assert_eq!(strip_ansi_codes(""), "");
4382 assert_eq!(
4383 strip_ansi_codes("\x1b[90mat line 2 column 1\x1b[0m"),
4384 "at line 2 column 1"
4385 );
4386 }
4387
4388 #[test]
4389 fn test_parse_at_line_column() {
4390 assert_eq!(
4391 CodeBlockToolProcessor::parse_at_line_column("at line 2 column 1"),
4392 Some((2, 1))
4393 );
4394 assert_eq!(
4395 CodeBlockToolProcessor::parse_at_line_column("at line 10 column 15"),
4396 Some((10, 15))
4397 );
4398 assert_eq!(
4399 CodeBlockToolProcessor::parse_at_line_column("At Line 5 Column 3"),
4400 Some((5, 3))
4401 );
4402 assert_eq!(
4403 CodeBlockToolProcessor::parse_at_line_column("not a position line"),
4404 None
4405 );
4406 assert_eq!(
4407 CodeBlockToolProcessor::parse_at_line_column("at line abc column 1"),
4408 None
4409 );
4410 }
4411
4412 #[test]
4413 fn test_parse_error_line() {
4414 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Error: invalid key").unwrap();
4415 assert_eq!(msg, "invalid key");
4416 assert!(matches!(sev, DiagnosticSeverity::Error));
4417
4418 let (msg, sev) = CodeBlockToolProcessor::parse_error_line("Warning: deprecated").unwrap();
4419 assert_eq!(msg, "deprecated");
4420 assert!(matches!(sev, DiagnosticSeverity::Warning));
4421
4422 assert!(CodeBlockToolProcessor::parse_error_line("error: bad input").is_none());
4424 assert!(CodeBlockToolProcessor::parse_error_line("warning: minor issue").is_none());
4425
4426 assert!(CodeBlockToolProcessor::parse_error_line("Error:").is_none());
4428 assert!(CodeBlockToolProcessor::parse_error_line("Error: ").is_none());
4429
4430 assert!(CodeBlockToolProcessor::parse_error_line("some random text").is_none());
4432 }
4433
4434 #[test]
4435 fn test_consecutive_error_lines_without_position() {
4436 let config = default_config();
4437 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4438
4439 let output = ToolOutput {
4442 stdout: "Error: first problem\nError: second problem\n at line 3 column 1".to_string(),
4443 stderr: String::new(),
4444 exit_code: 1,
4445 success: false,
4446 };
4447
4448 let diags = processor.parse_tool_output(&output, "tool", 5);
4449 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
4450 assert_eq!(diags[0].message, "first problem");
4452 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
4454 assert_eq!(diags[1].message, "second problem");
4456 assert_eq!(diags[1].file_line, 8); assert_eq!(diags[1].column, Some(1));
4458 }
4459
4460 #[test]
4461 fn test_error_line_at_end_of_output() {
4462 let config = default_config();
4463 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4464
4465 let output = ToolOutput {
4467 stdout: "Error: trailing error".to_string(),
4468 stderr: String::new(),
4469 exit_code: 1,
4470 success: false,
4471 };
4472
4473 let diags = processor.parse_tool_output(&output, "tool", 5);
4474 assert_eq!(diags.len(), 1);
4475 assert_eq!(diags[0].message, "trailing error");
4476 assert_eq!(diags[0].file_line, 5); assert_eq!(diags[0].column, None);
4478 }
4479
4480 #[test]
4481 fn test_blank_lines_between_error_and_position() {
4482 let config = default_config();
4483 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4484
4485 let output = ToolOutput {
4487 stdout: "Error: spaced out\n\n\n at line 4 column 2".to_string(),
4488 stderr: String::new(),
4489 exit_code: 1,
4490 success: false,
4491 };
4492
4493 let diags = processor.parse_tool_output(&output, "tool", 10);
4494 assert_eq!(diags.len(), 1);
4495 assert_eq!(diags[0].message, "spaced out");
4496 assert_eq!(diags[0].file_line, 14); assert_eq!(diags[0].column, Some(2));
4498 }
4499
4500 #[test]
4501 fn test_mixed_structured_and_error_line_parsers() {
4502 let config = default_config();
4503 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4504
4505 let output = ToolOutput {
4507 stdout: "_.py:1:5: E501 Line too long\nError: invalid syntax\n at line 3 column 1".to_string(),
4508 stderr: String::new(),
4509 exit_code: 1,
4510 success: false,
4511 };
4512
4513 let diags = processor.parse_tool_output(&output, "tool", 5);
4514 assert_eq!(diags.len(), 2, "Expected 2 diagnostics, got {diags:?}");
4515 assert_eq!(diags[0].message, "E501 Line too long");
4517 assert_eq!(diags[0].file_line, 6); assert_eq!(diags[1].message, "invalid syntax");
4520 assert_eq!(diags[1].file_line, 8); }
4522
4523 #[test]
4524 fn test_at_line_without_preceding_error() {
4525 let config = default_config();
4526 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4527
4528 let output = ToolOutput {
4530 stdout: "at line 2 column 1\nsome other text".to_string(),
4531 stderr: String::new(),
4532 exit_code: 1,
4533 success: false,
4534 };
4535
4536 let diags = processor.parse_tool_output(&output, "tool", 5);
4537 assert_eq!(diags.len(), 2);
4540 assert_eq!(diags[0].message, "at line 2 column 1");
4541 assert_eq!(diags[1].message, "some other text");
4542 }
4543
4544 #[test]
4553 fn test_format_empty_output_does_not_erase_content() {
4554 use super::super::config::LanguageToolConfig;
4555
4556 let content = "```toml\nkey = \"value\"\n```\n";
4557 let config_for = |on_error: OnError| {
4558 let mut config = default_config();
4559 config.on_error = on_error;
4560 config.languages.insert(
4561 "toml".to_string(),
4562 LanguageToolConfig {
4563 format: vec!["empty-formatter".to_string()],
4564 ..Default::default()
4565 },
4566 );
4567 config.tools.insert(
4568 "empty-formatter".to_string(),
4569 super::super::config::ToolDefinition {
4570 command: vec!["true".to_string()],
4571 stdin: true,
4572 stdout: true,
4573 lint_args: vec![],
4574 format_args: vec![],
4575 },
4576 );
4577 config
4578 };
4579
4580 let config = config_for(OnError::Fail);
4581 let result = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format(content);
4582 assert!(
4583 matches!(result, Err(ProcessorError::ToolErrorAt { line: 1, .. })),
4584 "fail must stop at the block: {result:?}"
4585 );
4586
4587 let config = config_for(OnError::Warn);
4588 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
4589 .format(content)
4590 .unwrap();
4591 assert_eq!(output.content, content, "warn must leave the block as it was");
4592 assert_eq!(output.error_messages.len(), 1, "{:?}", output.error_messages);
4593 assert!(output.error_messages[0].starts_with("line 1 (toml): "));
4594
4595 let config = config_for(OnError::Skip);
4596 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
4597 .format(content)
4598 .unwrap();
4599 assert_eq!(output.content, content, "skip must leave the block as it was");
4600 assert!(output.error_messages.is_empty(), "{:?}", output.error_messages);
4601 }
4602
4603 #[test]
4605 fn test_format_identity_formatter_preserves_content() {
4606 use super::super::config::LanguageToolConfig;
4607
4608 let mut config = default_config();
4609 config.languages.insert(
4610 "toml".to_string(),
4611 LanguageToolConfig {
4612 format: vec!["cat-formatter".to_string()],
4613 ..Default::default()
4614 },
4615 );
4616 config.tools.insert(
4617 "cat-formatter".to_string(),
4618 super::super::config::ToolDefinition {
4619 command: vec!["cat".to_string()],
4620 stdin: true,
4621 stdout: true,
4622 lint_args: vec![],
4623 format_args: vec![],
4624 },
4625 );
4626
4627 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4628
4629 let content = "```toml\nkey = \"value\"\n```\n";
4630 let result = processor.format(content);
4631
4632 assert!(result.is_ok(), "Format should not error");
4633 let output = result.unwrap();
4634 assert_eq!(
4635 output.content, content,
4636 "Identity formatter should preserve content exactly"
4637 );
4638 }
4639
4640 #[test]
4643 fn test_resolve_tool_context_aware_tombi() {
4644 let config = default_config();
4645 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4646
4647 let format_def = processor
4649 .registry
4650 .resolve("tombi", ToolSlot::Format)
4651 .expect("Should resolve tombi in format context");
4652 assert!(
4653 format_def.command.iter().any(|arg| arg == "format"),
4654 "Bare 'tombi' in format context should resolve to 'tombi format', got: {:?}",
4655 format_def.command
4656 );
4657
4658 let lint_def = processor
4660 .registry
4661 .resolve("tombi", ToolSlot::Lint)
4662 .expect("Should resolve tombi in lint context");
4663 assert!(
4664 lint_def.command.iter().any(|arg| arg == "lint"),
4665 "Bare 'tombi' in lint context should resolve to 'tombi lint', got: {:?}",
4666 lint_def.command
4667 );
4668
4669 let explicit_def = processor
4671 .registry
4672 .resolve("tombi:lint", ToolSlot::Format)
4673 .expect("Should resolve explicit tombi:lint even in format context");
4674 assert!(
4675 explicit_def.command.iter().any(|arg| arg == "lint"),
4676 "Explicit 'tombi:lint' should always use lint, got: {:?}",
4677 explicit_def.command
4678 );
4679 }
4680
4681 #[test]
4683 fn test_resolve_tool_context_aware_ruff() {
4684 let config = default_config();
4685 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4686
4687 let lint_def = processor
4689 .registry
4690 .resolve("ruff", ToolSlot::Lint)
4691 .expect("Should resolve ruff in lint context");
4692 assert!(
4693 lint_def.command.iter().any(|arg| arg == "check"),
4694 "Bare 'ruff' in lint context should resolve to 'ruff check', got: {:?}",
4695 lint_def.command
4696 );
4697
4698 let format_def = processor
4700 .registry
4701 .resolve("ruff", ToolSlot::Format)
4702 .expect("Should resolve ruff in format context");
4703 assert!(
4704 format_def.command.iter().any(|arg| arg == "format"),
4705 "Bare 'ruff' in format context should resolve to 'ruff format', got: {:?}",
4706 format_def.command
4707 );
4708 }
4709
4710 #[test]
4712 fn test_resolve_tool_bare_name_fallback() {
4713 let config = default_config();
4714 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4715
4716 let def = processor
4718 .registry
4719 .resolve("shellcheck", ToolSlot::Lint)
4720 .expect("Should resolve shellcheck via fallback");
4721 assert!(
4722 def.command.iter().any(|arg| arg == "shellcheck"),
4723 "shellcheck should resolve to itself, got: {:?}",
4724 def.command
4725 );
4726 }
4727
4728 #[test]
4730 fn test_resolve_tool_context_aware_sqlfluff() {
4731 let config = default_config();
4732 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4733
4734 let format_def = processor
4736 .registry
4737 .resolve("sqlfluff", ToolSlot::Format)
4738 .expect("Should resolve sqlfluff in format context");
4739 assert!(
4740 format_def.command.iter().any(|arg| arg == "fix"),
4741 "Bare 'sqlfluff' in format context should resolve to 'sqlfluff fix', got: {:?}",
4742 format_def.command
4743 );
4744 }
4745
4746 #[test]
4748 fn test_resolve_tool_context_aware_djlint() {
4749 let config = default_config();
4750 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4751
4752 let format_def = processor
4754 .registry
4755 .resolve("djlint", ToolSlot::Format)
4756 .expect("Should resolve djlint in format context");
4757 assert!(
4758 format_def.command.iter().any(|arg| arg.contains("reformat")),
4759 "Bare 'djlint' in format context should resolve to djlint reformat, got: {:?}",
4760 format_def.command
4761 );
4762 }
4763
4764 #[test]
4766 fn test_resolve_tool_user_defined_with_context_variant() {
4767 use super::super::config::ToolDefinition;
4768
4769 let mut config = default_config();
4770 config.tools.insert(
4771 "mytool".to_string(),
4772 ToolDefinition {
4773 command: vec!["mytool".to_string(), "--lint".to_string()],
4774 ..Default::default()
4775 },
4776 );
4777 config.tools.insert(
4778 "mytool:format".to_string(),
4779 ToolDefinition {
4780 command: vec!["mytool".to_string(), "--format".to_string()],
4781 ..Default::default()
4782 },
4783 );
4784
4785 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4786
4787 let def = processor
4789 .registry
4790 .resolve("mytool", ToolSlot::Format)
4791 .expect("Should resolve user tool in format context");
4792 assert!(
4793 def.command.iter().any(|arg| arg == "--format"),
4794 "User 'mytool' in format context should resolve to mytool:format, got: {:?}",
4795 def.command
4796 );
4797
4798 let def = processor
4800 .registry
4801 .resolve("mytool", ToolSlot::Lint)
4802 .expect("Should resolve user tool in lint context via fallback");
4803 assert!(
4804 def.command.iter().any(|arg| arg == "--lint"),
4805 "User 'mytool' in lint context should fall back to bare name, got: {:?}",
4806 def.command
4807 );
4808 }
4809
4810 #[test]
4812 fn test_resolve_tool_nonexistent_returns_none() {
4813 let config = default_config();
4814 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4815
4816 assert!(
4817 processor
4818 .registry
4819 .resolve("nonexistent-tool-xyz", ToolSlot::Lint)
4820 .is_none(),
4821 "Nonexistent tool should return None in lint context"
4822 );
4823 assert!(
4824 processor
4825 .registry
4826 .resolve("nonexistent-tool-xyz", ToolSlot::Format)
4827 .is_none(),
4828 "Nonexistent tool should return None in format context"
4829 );
4830 }
4831
4832 #[test]
4833 fn test_strip_ansi_codes_edge_cases() {
4834 assert_eq!(strip_ansi_codes("before\x1bafter"), "beforeafter");
4836 assert_eq!(strip_ansi_codes("trailing\x1b"), "trailing");
4838 assert_eq!(strip_ansi_codes("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
4840 assert_eq!(strip_ansi_codes("\x1b[38;5;196mred\x1b[0m"), "red");
4842 assert_eq!(strip_ansi_codes("\x1b[38;2;255;0;0mred\x1b[0m"), "red");
4843 }
4844
4845 #[test]
4846 fn merge_conflict_prevents_external_formatting() {
4847 use super::super::config::{LanguageToolConfig, ToolDefinition};
4848 let mut config = default_config();
4849 config.on_missing_tool_binary = OnMissing::Fail;
4850 config.languages.insert(
4851 "testlang".to_string(),
4852 LanguageToolConfig {
4853 format: vec!["unavailable".to_string()],
4854 ..Default::default()
4855 },
4856 );
4857 config.tools.insert(
4858 "unavailable".to_string(),
4859 ToolDefinition {
4860 command: vec!["rumdl-858-tool-that-must-not-run".to_string()],
4861 ..Default::default()
4862 },
4863 );
4864 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4865 let content = "<<<<<<< HEAD\r\n\n```testlang\ntext\n```\n";
4866 let output = processor.format(content).unwrap();
4867 assert_eq!(output.content, content);
4868 assert!(!output.had_errors);
4869 assert!(output.failures.is_empty());
4870
4871 let mut document_config = crate::config::Config::default();
4874 document_config.global.disable.push("MD092".into());
4875 let output = processor.format_with_config(content, &document_config, None).unwrap();
4876 assert!(output.had_errors);
4877 assert!(!output.failures.is_empty());
4878 let suppressed = format!("<!-- rumdl-disable MD092 -->\n{content}");
4879 assert!(processor.format(&suppressed).unwrap().had_errors);
4880 }
4881
4882 #[cfg(unix)]
4887 #[test]
4888 fn test_lint_yaml_block_no_false_new_line_at_eof() {
4889 use super::super::config::{LanguageToolConfig, ToolDefinition};
4890
4891 let mut config = default_config();
4892 config.normalize_language = NormalizeLanguage::Exact;
4893 config.languages.insert(
4894 "yaml".to_string(),
4895 LanguageToolConfig {
4896 lint: vec!["eof-newline-linter".to_string()],
4897 ..Default::default()
4898 },
4899 );
4900 config.tools.insert(
4904 "eof-newline-linter".to_string(),
4905 ToolDefinition {
4906 command: vec![
4907 "sh".to_string(),
4908 "-c".to_string(),
4909 "if [ -n \"$(tail -c1)\" ]; then echo '1:1: no newline at end of file'; fi".to_string(),
4910 ],
4911 ..Default::default()
4912 },
4913 );
4914
4915 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4916
4917 let content = "```yaml\nfoo: bar\n```\n";
4918 let diagnostics = processor.lint(content).expect("lint should succeed");
4919
4920 assert!(
4921 diagnostics.is_empty(),
4922 "EOF-newline linter should not fire on an extracted code block, got: {diagnostics:?}"
4923 );
4924 }
4925
4926 #[cfg(unix)]
4931 #[test]
4932 fn test_format_feeds_newline_terminated_input() {
4933 use super::super::config::{LanguageToolConfig, ToolDefinition};
4934
4935 let mut config = default_config();
4936 config.normalize_language = NormalizeLanguage::Exact;
4937 config.languages.insert(
4938 "yaml".to_string(),
4939 LanguageToolConfig {
4940 format: vec!["newline-probe".to_string()],
4941 ..Default::default()
4942 },
4943 );
4944 config.tools.insert(
4945 "newline-probe".to_string(),
4946 ToolDefinition {
4947 command: vec![
4948 "sh".to_string(),
4949 "-c".to_string(),
4950 "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi".to_string(),
4951 ],
4952 ..Default::default()
4953 },
4954 );
4955
4956 let processor = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default());
4957
4958 let content = "```yaml\nfoo: bar\n```\n";
4959 let output = processor.format(content).expect("format should succeed");
4960
4961 assert!(
4962 output.content.contains("HAD_NEWLINE"),
4963 "Formatter should receive newline-terminated stdin, got: {:?}",
4964 output.content
4965 );
4966 }
4967
4968 #[cfg(unix)]
4972 fn staged_config(tools: &[&str], mode: FormatMode, on_error: OnError) -> CodeBlockToolsConfig {
4973 use super::super::config::ToolDefinition;
4974
4975 let mut config = default_config();
4976 config.normalize_language = NormalizeLanguage::Exact;
4977 config.on_error = on_error;
4978 config.languages.insert(
4979 "text".to_string(),
4980 LanguageToolConfig {
4981 format: tools.iter().map(ToString::to_string).collect(),
4982 format_mode: mode,
4983 ..Default::default()
4984 },
4985 );
4986 for (id, script) in [
4987 ("upper", "tr a-z A-Z"),
4988 ("exclaim", "sed 's/$/!/'"),
4989 ("prefix-a", "sed 's/^/a/'"),
4990 ("prefix-b", "sed 's/^/b/'"),
4991 ("identity", "cat"),
4992 ("fails", "cat >/dev/null; exit 3"),
4993 ("empty", "cat >/dev/null"),
4994 ] {
4995 config.tools.insert(
4996 id.to_string(),
4997 ToolDefinition {
4998 command: vec!["sh".to_string(), "-c".to_string(), script.to_string()],
4999 ..Default::default()
5000 },
5001 );
5002 }
5003 config
5004 }
5005
5006 #[cfg(unix)]
5007 fn format_staged(tools: &[&str], mode: FormatMode, on_error: OnError) -> Result<FormatOutput, ProcessorError> {
5008 let config = staged_config(tools, mode, on_error);
5009 CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format("```text\nhello\n```\n")
5010 }
5011
5012 #[cfg(unix)]
5013 fn staged_block(tools: &[&str], mode: FormatMode, on_error: OnError) -> String {
5014 let output = format_staged(tools, mode, on_error).expect("format runs");
5015 output
5016 .content
5017 .strip_prefix("```text\n")
5018 .and_then(|rest| rest.strip_suffix("```\n"))
5019 .unwrap_or_else(|| panic!("fence kept: {:?}", output.content))
5020 .to_string()
5021 }
5022
5023 #[cfg(unix)]
5026 #[test]
5027 fn a_fallback_format_list_stops_at_the_first_success() {
5028 assert_eq!(LanguageToolConfig::default().format_mode, FormatMode::Fallback);
5029 for (tools, expected) in [
5030 (&["upper", "exclaim"][..], "HELLO\n"),
5031 (&["identity", "upper"][..], "hello\n"),
5032 (&["fails", "upper", "exclaim"][..], "HELLO\n"),
5033 (&["empty", "upper", "exclaim"][..], "HELLO\n"),
5034 ] {
5035 assert_eq!(
5036 staged_block(tools, FormatMode::Fallback, OnError::Skip),
5037 expected,
5038 "{tools:?}"
5039 );
5040 }
5041 }
5042
5043 #[cfg(unix)]
5046 #[test]
5047 fn a_pipeline_runs_every_formatter_on_the_last_output() {
5048 for (tools, expected) in [
5049 (&["upper", "exclaim"][..], "HELLO!\n"),
5050 (&["identity", "upper"][..], "HELLO\n"),
5051 (&["prefix-a", "prefix-b"][..], "bahello\n"),
5052 (&["prefix-b", "prefix-a"][..], "abhello\n"),
5053 (&["identity"][..], "hello\n"),
5054 ] {
5055 assert_eq!(
5056 staged_block(tools, FormatMode::Pipeline, OnError::Fail),
5057 expected,
5058 "{tools:?}"
5059 );
5060 }
5061 }
5062
5063 #[cfg(unix)]
5067 #[test]
5068 fn a_failed_pipeline_stage_is_dropped_under_warn_and_skip() {
5069 for failing in ["fails", "empty"] {
5070 let tools = ["upper", failing, "exclaim"];
5071 let warned = format_staged(&tools, FormatMode::Pipeline, OnError::Warn).unwrap();
5072 assert_eq!(warned.content, "```text\nHELLO!\n```\n", "{tools:?}");
5073 assert_eq!(warned.error_messages.len(), 1, "{:?}", warned.error_messages);
5074 assert!(warned.error_messages[0].starts_with("line 1 (text): "));
5075
5076 let skipped = format_staged(&tools, FormatMode::Pipeline, OnError::Skip).unwrap();
5077 assert_eq!(skipped.content, "```text\nHELLO!\n```\n", "{tools:?}");
5078 assert!(skipped.error_messages.is_empty(), "{:?}", skipped.error_messages);
5079 }
5080 assert_eq!(
5082 staged_block(&["fails", "empty"], FormatMode::Pipeline, OnError::Skip),
5083 "hello\n"
5084 );
5085 }
5086
5087 #[cfg(unix)]
5090 #[test]
5091 fn a_failed_pipeline_stage_stops_the_run_under_fail() {
5092 for tools in [&["upper", "fails", "exclaim"][..], &["upper", "empty"][..]] {
5093 let result = format_staged(tools, FormatMode::Pipeline, OnError::Fail);
5094 assert!(
5095 matches!(result, Err(ProcessorError::ToolErrorAt { line: 1, .. })),
5096 "{tools:?}: {result:?}"
5097 );
5098 }
5099 }
5100
5101 #[cfg(unix)]
5104 #[test]
5105 fn a_pipeline_stage_with_a_missing_binary_follows_on_missing_tool_binary() {
5106 use super::super::config::ToolDefinition;
5107
5108 let mut config = staged_config(&["upper", "gone", "exclaim"], FormatMode::Pipeline, OnError::Fail);
5109 config.tools.insert(
5110 "gone".to_string(),
5111 ToolDefinition {
5112 command: vec!["rumdl-test-nonexistent-formatter".to_string()],
5113 ..Default::default()
5114 },
5115 );
5116 let content = "```text\nhello\n```\n";
5117
5118 config.on_missing_tool_binary = OnMissing::Ignore;
5119 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
5120 .format(content)
5121 .unwrap();
5122 assert_eq!(output.content, "```text\nHELLO!\n```\n");
5123
5124 config.on_missing_tool_binary = OnMissing::Fail;
5125 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
5126 .format(content)
5127 .unwrap();
5128 assert_eq!(output.content, "```text\nHELLO!\n```\n");
5129 assert_eq!(output.failures.len(), 1, "{:?}", output.failures);
5130
5131 config.on_missing_tool_binary = OnMissing::FailFast;
5132 let result = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default()).format(content);
5133 assert!(
5134 matches!(result, Err(ProcessorError::ToolBinaryNotFound { .. })),
5135 "{result:?}"
5136 );
5137 }
5138
5139 #[cfg(unix)]
5142 #[test]
5143 fn a_pipeline_keeps_the_block_line_ending_between_stages() {
5144 use super::super::config::ToolDefinition;
5145
5146 let mut config = staged_config(&["strip-newline", "probe"], FormatMode::Pipeline, OnError::Fail);
5147 for (id, script) in [
5148 ("strip-newline", "printf '%s' \"$(cat)\""),
5149 (
5150 "probe",
5151 "if [ -z \"$(tail -c1)\" ]; then echo HAD_NEWLINE; else echo NO_NEWLINE; fi",
5152 ),
5153 ] {
5154 config.tools.insert(
5155 id.to_string(),
5156 ToolDefinition {
5157 command: vec!["sh".to_string(), "-c".to_string(), script.to_string()],
5158 ..Default::default()
5159 },
5160 );
5161 }
5162 let output = CodeBlockToolProcessor::new(&config, MarkdownFlavor::default())
5163 .format("```text\nhello\n```\n")
5164 .unwrap();
5165 assert_eq!(output.content, "```text\nHAD_NEWLINE\n```\n");
5166 }
5167
5168 #[test]
5169 fn test_ensure_trailing_newline() {
5170 assert_eq!(ensure_trailing_newline("foo: bar"), "foo: bar\n");
5172 assert_eq!(ensure_trailing_newline("a: 1\nb: 2"), "a: 1\nb: 2\n");
5174 assert_eq!(ensure_trailing_newline("foo: bar\n"), "foo: bar\n");
5176 assert_eq!(ensure_trailing_newline("foo: bar\n\n"), "foo: bar\n\n");
5178 assert_eq!(ensure_trailing_newline(""), "");
5180 }
5181}