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