1use super::config::{CodeBlockToolsConfig, NormalizeLanguage, OnError};
7use super::executor::{ExecutorError, ToolExecutor, ToolOutput};
8use super::linguist::LinguistResolver;
9use super::registry::ToolRegistry;
10use crate::rule::{LintWarning, Severity};
11use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
12
13#[derive(Debug, Clone)]
15pub struct FencedCodeBlockInfo {
16 pub start_line: usize,
18 pub end_line: usize,
20 pub content_start: usize,
22 pub content_end: usize,
24 pub language: String,
26 pub info_string: String,
28 pub fence_char: char,
30 pub fence_length: usize,
32 pub indent: usize,
34 pub indent_prefix: String,
36}
37
38#[derive(Debug, Clone)]
40pub struct CodeBlockDiagnostic {
41 pub file_line: usize,
43 pub column: Option<usize>,
45 pub message: String,
47 pub severity: DiagnosticSeverity,
49 pub tool: String,
51 pub code_block_start: usize,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum DiagnosticSeverity {
58 Error,
59 Warning,
60 Info,
61}
62
63impl CodeBlockDiagnostic {
64 pub fn to_lint_warning(&self) -> LintWarning {
66 let severity = match self.severity {
67 DiagnosticSeverity::Error => Severity::Error,
68 DiagnosticSeverity::Warning => Severity::Warning,
69 DiagnosticSeverity::Info => Severity::Info,
70 };
71
72 LintWarning {
73 message: self.message.clone(),
74 line: self.file_line,
75 column: self.column.unwrap_or(1),
76 end_line: self.file_line,
77 end_column: self.column.unwrap_or(1),
78 severity,
79 fix: None, rule_name: Some(self.tool.clone()),
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
87pub enum ProcessorError {
88 ToolError(ExecutorError),
90 NoToolsConfigured { language: String },
92 Aborted { message: String },
94}
95
96impl std::fmt::Display for ProcessorError {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 Self::ToolError(e) => write!(f, "{e}"),
100 Self::NoToolsConfigured { language } => {
101 write!(f, "No tools configured for language '{language}'")
102 }
103 Self::Aborted { message } => write!(f, "Processing aborted: {message}"),
104 }
105 }
106}
107
108impl std::error::Error for ProcessorError {}
109
110impl From<ExecutorError> for ProcessorError {
111 fn from(e: ExecutorError) -> Self {
112 Self::ToolError(e)
113 }
114}
115
116#[derive(Debug)]
118pub struct CodeBlockResult {
119 pub diagnostics: Vec<CodeBlockDiagnostic>,
121 pub formatted_content: Option<String>,
123 pub was_modified: bool,
125}
126
127pub struct CodeBlockToolProcessor<'a> {
129 config: &'a CodeBlockToolsConfig,
130 linguist: LinguistResolver,
131 registry: ToolRegistry,
132 executor: ToolExecutor,
133 user_aliases: std::collections::HashMap<String, String>,
134}
135
136impl<'a> CodeBlockToolProcessor<'a> {
137 pub fn new(config: &'a CodeBlockToolsConfig) -> Self {
139 let user_aliases = config
140 .language_aliases
141 .iter()
142 .map(|(k, v)| (k.to_lowercase(), v.to_lowercase()))
143 .collect();
144 Self {
145 config,
146 linguist: LinguistResolver::new(),
147 registry: ToolRegistry::new(config.tools.clone()),
148 executor: ToolExecutor::new(config.timeout),
149 user_aliases,
150 }
151 }
152
153 pub fn extract_code_blocks(&self, content: &str) -> Vec<FencedCodeBlockInfo> {
155 let mut blocks = Vec::new();
156 let mut current_block: Option<FencedCodeBlockBuilder> = None;
157
158 let options = Options::all();
159 let parser = Parser::new_ext(content, options).into_offset_iter();
160
161 let lines: Vec<&str> = content.lines().collect();
162
163 for (event, range) in parser {
164 match event {
165 Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
166 let info_string = info.to_string();
167 let language = info_string.split_whitespace().next().unwrap_or("").to_string();
168
169 let start_line = content[..range.start].chars().filter(|&c| c == '\n').count();
171
172 let content_start = content[range.start..]
174 .find('\n')
175 .map(|i| range.start + i + 1)
176 .unwrap_or(content.len());
177
178 let fence_line = lines.get(start_line).unwrap_or(&"");
180 let trimmed = fence_line.trim_start();
181 let indent = fence_line.len() - trimmed.len();
182 let indent_prefix = fence_line.get(..indent).unwrap_or("").to_string();
183 let (fence_char, fence_length) = if trimmed.starts_with('~') {
184 ('~', trimmed.chars().take_while(|&c| c == '~').count())
185 } else {
186 ('`', trimmed.chars().take_while(|&c| c == '`').count())
187 };
188
189 current_block = Some(FencedCodeBlockBuilder {
190 start_line,
191 content_start,
192 language,
193 info_string,
194 fence_char,
195 fence_length,
196 indent,
197 indent_prefix,
198 });
199 }
200 Event::End(TagEnd::CodeBlock) => {
201 if let Some(builder) = current_block.take() {
202 let end_line = content[..range.end].chars().filter(|&c| c == '\n').count();
204
205 let search_start = builder.content_start.min(range.end);
207 let content_end = if search_start < range.end {
208 content[search_start..range.end]
209 .rfind('\n')
210 .map(|i| search_start + i)
211 .unwrap_or(search_start)
212 } else {
213 search_start
214 };
215
216 if content_end >= builder.content_start {
217 blocks.push(FencedCodeBlockInfo {
218 start_line: builder.start_line,
219 end_line,
220 content_start: builder.content_start,
221 content_end,
222 language: builder.language,
223 info_string: builder.info_string,
224 fence_char: builder.fence_char,
225 fence_length: builder.fence_length,
226 indent: builder.indent,
227 indent_prefix: builder.indent_prefix,
228 });
229 }
230 }
231 }
232 _ => {}
233 }
234 }
235
236 blocks
237 }
238
239 fn resolve_language(&self, language: &str) -> String {
241 let lower = language.to_lowercase();
242 if let Some(mapped) = self.user_aliases.get(&lower) {
243 return mapped.clone();
244 }
245 match self.config.normalize_language {
246 NormalizeLanguage::Linguist => self.linguist.resolve(&lower),
247 NormalizeLanguage::Exact => lower,
248 }
249 }
250
251 fn get_on_error(&self, language: &str) -> OnError {
253 self.config
254 .languages
255 .get(language)
256 .and_then(|lc| lc.on_error)
257 .unwrap_or(self.config.on_error)
258 }
259
260 fn strip_indent_from_block(&self, content: &str, indent_prefix: &str) -> String {
262 if indent_prefix.is_empty() {
263 return content.to_string();
264 }
265
266 let mut out = String::with_capacity(content.len());
267 for line in content.split_inclusive('\n') {
268 if let Some(stripped) = line.strip_prefix(indent_prefix) {
269 out.push_str(stripped);
270 } else {
271 out.push_str(line);
272 }
273 }
274 out
275 }
276
277 fn apply_indent_to_block(&self, content: &str, indent_prefix: &str) -> String {
279 if indent_prefix.is_empty() {
280 return content.to_string();
281 }
282 if content.is_empty() {
283 return String::new();
284 }
285
286 let mut out = String::with_capacity(content.len() + indent_prefix.len());
287 for line in content.split_inclusive('\n') {
288 if line == "\n" {
289 out.push_str(line);
290 } else {
291 out.push_str(indent_prefix);
292 out.push_str(line);
293 }
294 }
295 out
296 }
297
298 pub fn lint(&self, content: &str) -> Result<Vec<CodeBlockDiagnostic>, ProcessorError> {
302 let mut all_diagnostics = Vec::new();
303 let blocks = self.extract_code_blocks(content);
304
305 for block in blocks {
306 if block.language.is_empty() {
307 continue; }
309
310 let canonical_lang = self.resolve_language(&block.language);
311
312 let lint_tools = match self.config.languages.get(&canonical_lang) {
314 Some(lc) => &lc.lint,
315 None => continue, };
317
318 if lint_tools.is_empty() {
319 continue;
320 }
321
322 let code_content_raw = if block.content_start < block.content_end && block.content_end <= content.len() {
324 &content[block.content_start..block.content_end]
325 } else {
326 continue;
327 };
328 let code_content = self.strip_indent_from_block(code_content_raw, &block.indent_prefix);
329
330 for tool_id in lint_tools {
332 let tool_def = match self.registry.get(tool_id) {
333 Some(t) => t,
334 None => {
335 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
336 continue;
337 }
338 };
339
340 match self.executor.lint(tool_def, &code_content, Some(self.config.timeout)) {
341 Ok(output) => {
342 let diagnostics = self.parse_tool_output(
344 &output,
345 tool_id,
346 block.start_line + 1, );
348 all_diagnostics.extend(diagnostics);
349 }
350 Err(e) => {
351 let on_error = self.get_on_error(&canonical_lang);
352 match on_error {
353 OnError::Fail => return Err(e.into()),
354 OnError::Warn => {
355 log::warn!("Tool '{tool_id}' failed: {e}");
356 }
357 OnError::Skip => {
358 }
360 }
361 }
362 }
363 }
364 }
365
366 Ok(all_diagnostics)
367 }
368
369 pub fn format(&self, content: &str) -> Result<String, ProcessorError> {
373 let blocks = self.extract_code_blocks(content);
374
375 if blocks.is_empty() {
376 return Ok(content.to_string());
377 }
378
379 let mut result = content.to_string();
381
382 for block in blocks.into_iter().rev() {
383 if block.language.is_empty() {
384 continue;
385 }
386
387 let canonical_lang = self.resolve_language(&block.language);
388
389 let format_tools = match self.config.languages.get(&canonical_lang) {
391 Some(lc) => &lc.format,
392 None => continue,
393 };
394
395 if format_tools.is_empty() {
396 continue;
397 }
398
399 if block.content_start >= block.content_end || block.content_end > result.len() {
401 continue;
402 }
403 let code_content_raw = result[block.content_start..block.content_end].to_string();
404 let code_content = self.strip_indent_from_block(&code_content_raw, &block.indent_prefix);
405
406 let mut formatted = code_content.clone();
408 for tool_id in format_tools {
409 let tool_def = match self.registry.get(tool_id) {
410 Some(t) => t,
411 None => {
412 log::warn!("Unknown tool '{tool_id}' configured for language '{canonical_lang}'");
413 continue;
414 }
415 };
416
417 match self.executor.format(tool_def, &formatted, Some(self.config.timeout)) {
418 Ok(output) => {
419 formatted = output;
421 if code_content.ends_with('\n') && !formatted.ends_with('\n') {
422 formatted.push('\n');
423 } else if !code_content.ends_with('\n') && formatted.ends_with('\n') {
424 formatted.pop();
425 }
426 break; }
428 Err(e) => {
429 let on_error = self.get_on_error(&canonical_lang);
430 match on_error {
431 OnError::Fail => return Err(e.into()),
432 OnError::Warn => {
433 log::warn!("Formatter '{tool_id}' failed: {e}");
434 }
435 OnError::Skip => {}
436 }
437 }
438 }
439 }
440
441 if formatted != code_content {
443 let reindented = self.apply_indent_to_block(&formatted, &block.indent_prefix);
444 if reindented != code_content_raw {
445 result.replace_range(block.content_start..block.content_end, &reindented);
446 }
447 }
448 }
449
450 Ok(result)
451 }
452
453 fn parse_tool_output(
458 &self,
459 output: &ToolOutput,
460 tool_id: &str,
461 code_block_start_line: usize,
462 ) -> Vec<CodeBlockDiagnostic> {
463 let mut diagnostics = Vec::new();
464 let mut shellcheck_line: Option<usize> = None;
465
466 let stdout = &output.stdout;
468 let stderr = &output.stderr;
469 let combined = format!("{stdout}\n{stderr}");
470
471 for line in combined.lines() {
478 let line = line.trim();
479 if line.is_empty() {
480 continue;
481 }
482
483 if let Some(line_num) = self.parse_shellcheck_header(line) {
484 shellcheck_line = Some(line_num);
485 continue;
486 }
487
488 if let Some(line_num) = shellcheck_line
489 && let Some(diag) = self.parse_shellcheck_message(line, tool_id, code_block_start_line, line_num)
490 {
491 diagnostics.push(diag);
492 continue;
493 }
494
495 if let Some(diag) = self.parse_standard_format(line, tool_id, code_block_start_line) {
497 diagnostics.push(diag);
498 continue;
499 }
500
501 if let Some(diag) = self.parse_eslint_format(line, tool_id, code_block_start_line) {
503 diagnostics.push(diag);
504 continue;
505 }
506
507 if let Some(diag) = self.parse_shellcheck_format(line, tool_id, code_block_start_line) {
509 diagnostics.push(diag);
510 }
511 }
512
513 if diagnostics.is_empty() && !output.success {
515 let message = if !output.stderr.is_empty() {
516 output.stderr.lines().next().unwrap_or("Tool failed").to_string()
517 } else if !output.stdout.is_empty() {
518 output.stdout.lines().next().unwrap_or("Tool failed").to_string()
519 } else {
520 let exit_code = output.exit_code;
521 format!("Tool exited with code {exit_code}")
522 };
523
524 diagnostics.push(CodeBlockDiagnostic {
525 file_line: code_block_start_line,
526 column: None,
527 message,
528 severity: DiagnosticSeverity::Error,
529 tool: tool_id.to_string(),
530 code_block_start: code_block_start_line,
531 });
532 }
533
534 diagnostics
535 }
536
537 fn parse_standard_format(
539 &self,
540 line: &str,
541 tool_id: &str,
542 code_block_start_line: usize,
543 ) -> Option<CodeBlockDiagnostic> {
544 let mut parts = line.rsplitn(4, ':');
546 let message = parts.next()?.trim().to_string();
547 let part1 = parts.next()?.trim().to_string();
548 let part2 = parts.next()?.trim().to_string();
549 let part3 = parts.next().map(|s| s.trim().to_string());
550
551 let (line_part, col_part) = if part3.is_some() {
552 (part2, Some(part1))
553 } else {
554 (part1, None)
555 };
556
557 if let Ok(line_num) = line_part.parse::<usize>() {
558 let column = col_part.and_then(|s| s.parse::<usize>().ok());
559 let message = Self::strip_fixable_markers(&message);
560 if !message.is_empty() {
561 let severity = self.infer_severity(&message);
562 return Some(CodeBlockDiagnostic {
563 file_line: code_block_start_line + line_num,
564 column,
565 message,
566 severity,
567 tool: tool_id.to_string(),
568 code_block_start: code_block_start_line,
569 });
570 }
571 }
572 None
573 }
574
575 fn parse_eslint_format(
577 &self,
578 line: &str,
579 tool_id: &str,
580 code_block_start_line: usize,
581 ) -> Option<CodeBlockDiagnostic> {
582 let parts: Vec<&str> = line.splitn(3, ' ').collect();
584 if parts.len() >= 2 {
585 let loc_parts: Vec<&str> = parts[0].split(':').collect();
586 if loc_parts.len() == 2
587 && let (Ok(line_num), Ok(col)) = (loc_parts[0].parse::<usize>(), loc_parts[1].parse::<usize>())
588 {
589 let (sev_part, msg_part) = if parts.len() >= 3 {
590 (parts[1], parts[2])
591 } else {
592 (parts[1], "")
593 };
594 let message = if msg_part.is_empty() {
595 sev_part.to_string()
596 } else {
597 msg_part.to_string()
598 };
599 let message = Self::strip_fixable_markers(&message);
600 let severity = match sev_part.to_lowercase().as_str() {
601 "error" => DiagnosticSeverity::Error,
602 "warning" | "warn" => DiagnosticSeverity::Warning,
603 "info" => DiagnosticSeverity::Info,
604 _ => self.infer_severity(&message),
605 };
606 return Some(CodeBlockDiagnostic {
607 file_line: code_block_start_line + line_num,
608 column: Some(col),
609 message,
610 severity,
611 tool: tool_id.to_string(),
612 code_block_start: code_block_start_line,
613 });
614 }
615 }
616 None
617 }
618
619 fn parse_shellcheck_format(
621 &self,
622 line: &str,
623 tool_id: &str,
624 code_block_start_line: usize,
625 ) -> Option<CodeBlockDiagnostic> {
626 if line.starts_with("In ")
628 && line.contains(" line ")
629 && let Some(line_start) = line.find(" line ")
630 {
631 let after_line = &line[line_start + 6..];
632 if let Some(colon_pos) = after_line.find(':')
633 && let Ok(line_num) = after_line[..colon_pos].trim().parse::<usize>()
634 {
635 let message = Self::strip_fixable_markers(after_line[colon_pos + 1..].trim());
636 if !message.is_empty() {
637 let severity = self.infer_severity(&message);
638 return Some(CodeBlockDiagnostic {
639 file_line: code_block_start_line + line_num,
640 column: None,
641 message,
642 severity,
643 tool: tool_id.to_string(),
644 code_block_start: code_block_start_line,
645 });
646 }
647 }
648 }
649 None
650 }
651
652 fn parse_shellcheck_header(&self, line: &str) -> Option<usize> {
654 if line.starts_with("In ")
655 && line.contains(" line ")
656 && let Some(line_start) = line.find(" line ")
657 {
658 let after_line = &line[line_start + 6..];
659 if let Some(colon_pos) = after_line.find(':') {
660 return after_line[..colon_pos].trim().parse::<usize>().ok();
661 }
662 }
663 None
664 }
665
666 fn parse_shellcheck_message(
668 &self,
669 line: &str,
670 tool_id: &str,
671 code_block_start_line: usize,
672 line_num: usize,
673 ) -> Option<CodeBlockDiagnostic> {
674 let sc_pos = line.find("SC")?;
675 let after_sc = &line[sc_pos + 2..];
676 let code_len = after_sc.chars().take_while(|c| c.is_ascii_digit()).count();
677 if code_len == 0 {
678 return None;
679 }
680 let after_code = &after_sc[code_len..];
681 let sev_start = after_code.find('(')? + 1;
682 let sev_end = after_code[sev_start..].find(')')? + sev_start;
683 let sev = after_code[sev_start..sev_end].trim().to_lowercase();
684 let message_start = after_code.find("):")? + 2;
685 let message = Self::strip_fixable_markers(after_code[message_start..].trim());
686 if message.is_empty() {
687 return None;
688 }
689
690 let severity = match sev.as_str() {
691 "error" => DiagnosticSeverity::Error,
692 "warning" | "warn" => DiagnosticSeverity::Warning,
693 "info" | "style" => DiagnosticSeverity::Info,
694 _ => self.infer_severity(&message),
695 };
696
697 Some(CodeBlockDiagnostic {
698 file_line: code_block_start_line + line_num,
699 column: None,
700 message,
701 severity,
702 tool: tool_id.to_string(),
703 code_block_start: code_block_start_line,
704 })
705 }
706
707 fn infer_severity(&self, message: &str) -> DiagnosticSeverity {
709 let lower = message.to_lowercase();
710 if lower.contains("error")
711 || lower.starts_with("e") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
712 || lower.starts_with("f") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
713 {
714 DiagnosticSeverity::Error
715 } else if lower.contains("warning")
716 || lower.contains("warn")
717 || lower.starts_with("w") && lower.chars().nth(1).is_some_and(|c| c.is_ascii_digit())
718 {
719 DiagnosticSeverity::Warning
720 } else {
721 DiagnosticSeverity::Info
722 }
723 }
724
725 fn strip_fixable_markers(message: &str) -> String {
732 message
733 .replace(" [*]", "")
734 .replace("[*] ", "")
735 .replace("[*]", "")
736 .replace(" (fixable)", "")
737 .replace("(fixable) ", "")
738 .replace("(fixable)", "")
739 .replace(" [fix available]", "")
740 .replace("[fix available] ", "")
741 .replace("[fix available]", "")
742 .replace(" [autofix]", "")
743 .replace("[autofix] ", "")
744 .replace("[autofix]", "")
745 .trim()
746 .to_string()
747 }
748}
749
750struct FencedCodeBlockBuilder {
752 start_line: usize,
753 content_start: usize,
754 language: String,
755 info_string: String,
756 fence_char: char,
757 fence_length: usize,
758 indent: usize,
759 indent_prefix: String,
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 fn default_config() -> CodeBlockToolsConfig {
767 CodeBlockToolsConfig::default()
768 }
769
770 #[test]
771 fn test_extract_code_blocks() {
772 let config = default_config();
773 let processor = CodeBlockToolProcessor::new(&config);
774
775 let content = r#"# Example
776
777```python
778def hello():
779 print("Hello")
780```
781
782Some text
783
784```rust
785fn main() {}
786```
787"#;
788
789 let blocks = processor.extract_code_blocks(content);
790
791 assert_eq!(blocks.len(), 2);
792
793 assert_eq!(blocks[0].language, "python");
794 assert_eq!(blocks[0].fence_char, '`');
795 assert_eq!(blocks[0].fence_length, 3);
796 assert_eq!(blocks[0].start_line, 2);
797 assert_eq!(blocks[0].indent, 0);
798 assert_eq!(blocks[0].indent_prefix, "");
799
800 assert_eq!(blocks[1].language, "rust");
801 assert_eq!(blocks[1].fence_char, '`');
802 assert_eq!(blocks[1].fence_length, 3);
803 }
804
805 #[test]
806 fn test_extract_code_blocks_with_info_string() {
807 let config = default_config();
808 let processor = CodeBlockToolProcessor::new(&config);
809
810 let content = "```python title=\"example.py\"\ncode\n```";
811 let blocks = processor.extract_code_blocks(content);
812
813 assert_eq!(blocks.len(), 1);
814 assert_eq!(blocks[0].language, "python");
815 assert_eq!(blocks[0].info_string, "python title=\"example.py\"");
816 }
817
818 #[test]
819 fn test_extract_code_blocks_tilde_fence() {
820 let config = default_config();
821 let processor = CodeBlockToolProcessor::new(&config);
822
823 let content = "~~~bash\necho hello\n~~~";
824 let blocks = processor.extract_code_blocks(content);
825
826 assert_eq!(blocks.len(), 1);
827 assert_eq!(blocks[0].language, "bash");
828 assert_eq!(blocks[0].fence_char, '~');
829 assert_eq!(blocks[0].fence_length, 3);
830 assert_eq!(blocks[0].indent_prefix, "");
831 }
832
833 #[test]
834 fn test_extract_code_blocks_with_indent_prefix() {
835 let config = default_config();
836 let processor = CodeBlockToolProcessor::new(&config);
837
838 let content = " - item\n ```python\n print('hi')\n ```";
839 let blocks = processor.extract_code_blocks(content);
840
841 assert_eq!(blocks.len(), 1);
842 assert_eq!(blocks[0].indent_prefix, " ");
843 }
844
845 #[test]
846 fn test_extract_code_blocks_no_language() {
847 let config = default_config();
848 let processor = CodeBlockToolProcessor::new(&config);
849
850 let content = "```\nplain code\n```";
851 let blocks = processor.extract_code_blocks(content);
852
853 assert_eq!(blocks.len(), 1);
854 assert_eq!(blocks[0].language, "");
855 }
856
857 #[test]
858 fn test_resolve_language_linguist() {
859 let mut config = default_config();
860 config.normalize_language = NormalizeLanguage::Linguist;
861 let processor = CodeBlockToolProcessor::new(&config);
862
863 assert_eq!(processor.resolve_language("py"), "python");
864 assert_eq!(processor.resolve_language("bash"), "shell");
865 assert_eq!(processor.resolve_language("js"), "javascript");
866 }
867
868 #[test]
869 fn test_resolve_language_exact() {
870 let mut config = default_config();
871 config.normalize_language = NormalizeLanguage::Exact;
872 let processor = CodeBlockToolProcessor::new(&config);
873
874 assert_eq!(processor.resolve_language("py"), "py");
875 assert_eq!(processor.resolve_language("BASH"), "bash");
876 }
877
878 #[test]
879 fn test_resolve_language_user_alias_override() {
880 let mut config = default_config();
881 config.language_aliases.insert("py".to_string(), "python".to_string());
882 config.normalize_language = NormalizeLanguage::Exact;
883 let processor = CodeBlockToolProcessor::new(&config);
884
885 assert_eq!(processor.resolve_language("PY"), "python");
886 }
887
888 #[test]
889 fn test_indent_strip_and_reapply_roundtrip() {
890 let config = default_config();
891 let processor = CodeBlockToolProcessor::new(&config);
892
893 let raw = " def hello():\n print('hi')";
894 let stripped = processor.strip_indent_from_block(raw, " ");
895 assert_eq!(stripped, "def hello():\n print('hi')");
896
897 let reapplied = processor.apply_indent_to_block(&stripped, " ");
898 assert_eq!(reapplied, raw);
899 }
900
901 #[test]
902 fn test_infer_severity() {
903 let config = default_config();
904 let processor = CodeBlockToolProcessor::new(&config);
905
906 assert_eq!(
907 processor.infer_severity("E501 line too long"),
908 DiagnosticSeverity::Error
909 );
910 assert_eq!(
911 processor.infer_severity("W291 trailing whitespace"),
912 DiagnosticSeverity::Warning
913 );
914 assert_eq!(
915 processor.infer_severity("error: something failed"),
916 DiagnosticSeverity::Error
917 );
918 assert_eq!(
919 processor.infer_severity("warning: unused variable"),
920 DiagnosticSeverity::Warning
921 );
922 assert_eq!(
923 processor.infer_severity("note: consider using"),
924 DiagnosticSeverity::Info
925 );
926 }
927
928 #[test]
929 fn test_parse_standard_format_windows_path() {
930 let config = default_config();
931 let processor = CodeBlockToolProcessor::new(&config);
932
933 let output = ToolOutput {
934 stdout: "C:\\path\\file.py:2:5: E123 message".to_string(),
935 stderr: String::new(),
936 exit_code: 1,
937 success: false,
938 };
939
940 let diags = processor.parse_tool_output(&output, "ruff:check", 10);
941 assert_eq!(diags.len(), 1);
942 assert_eq!(diags[0].file_line, 12);
943 assert_eq!(diags[0].column, Some(5));
944 assert_eq!(diags[0].message, "E123 message");
945 }
946
947 #[test]
948 fn test_parse_eslint_severity() {
949 let config = default_config();
950 let processor = CodeBlockToolProcessor::new(&config);
951
952 let output = ToolOutput {
953 stdout: "1:2 error Unexpected token".to_string(),
954 stderr: String::new(),
955 exit_code: 1,
956 success: false,
957 };
958
959 let diags = processor.parse_tool_output(&output, "eslint", 5);
960 assert_eq!(diags.len(), 1);
961 assert_eq!(diags[0].file_line, 6);
962 assert_eq!(diags[0].column, Some(2));
963 assert_eq!(diags[0].severity, DiagnosticSeverity::Error);
964 assert_eq!(diags[0].message, "Unexpected token");
965 }
966
967 #[test]
968 fn test_parse_shellcheck_multiline() {
969 let config = default_config();
970 let processor = CodeBlockToolProcessor::new(&config);
971
972 let output = ToolOutput {
973 stdout: "In - line 3:\necho $var\n ^-- SC2086 (info): Double quote to prevent globbing".to_string(),
974 stderr: String::new(),
975 exit_code: 1,
976 success: false,
977 };
978
979 let diags = processor.parse_tool_output(&output, "shellcheck", 10);
980 assert_eq!(diags.len(), 1);
981 assert_eq!(diags[0].file_line, 13);
982 assert_eq!(diags[0].severity, DiagnosticSeverity::Info);
983 assert_eq!(diags[0].message, "Double quote to prevent globbing");
984 }
985
986 #[test]
987 fn test_lint_no_config() {
988 let config = default_config();
989 let processor = CodeBlockToolProcessor::new(&config);
990
991 let content = "```python\nprint('hello')\n```";
992 let result = processor.lint(content);
993
994 assert!(result.is_ok());
996 assert!(result.unwrap().is_empty());
997 }
998
999 #[test]
1000 fn test_format_no_config() {
1001 let config = default_config();
1002 let processor = CodeBlockToolProcessor::new(&config);
1003
1004 let content = "```python\nprint('hello')\n```";
1005 let result = processor.format(content);
1006
1007 assert!(result.is_ok());
1009 assert_eq!(result.unwrap(), content);
1010 }
1011}