Skip to main content

ailint_core/rules/structural/
malformed_yaml.rs

1//! AIL041 `malformed-yaml` — a `.yaml` / `.yml` file failed to parse.
2//!
3//! See: `docs/rules/structural/AIL041.md`
4
5use crate::file_type::FileType;
6use crate::parser::{DocumentContent, ParsedDocument};
7use crate::rules::structural::AIL041;
8use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
9
10/// AIL041 malformed-yaml: YAML file or frontmatter fails to parse.
11#[derive(Debug, Default)]
12pub struct MalformedYamlRule;
13
14impl Rule for MalformedYamlRule {
15    fn id(&self) -> RuleId {
16        AIL041
17    }
18
19    fn default_severity(&self) -> Severity {
20        Severity::Error
21    }
22
23    fn description(&self) -> &'static str {
24        "YAML file failed to parse."
25    }
26
27    fn fix_hint(&self) -> &'static str {
28        "Fix the YAML syntax error at the reported location."
29    }
30
31    /// Applies to any file we treat as YAML, guidance or generic.
32    fn applies_to(&self, file_type: FileType) -> bool {
33        matches!(
34            file_type,
35            FileType::GenericYaml
36                | FileType::GenericSystemPrompt
37                | FileType::CursorRules
38                | FileType::WindsurfRules
39                | FileType::ClineRules
40                | FileType::ContinueRules
41        )
42    }
43
44    fn run(&self, doc: &ParsedDocument, _ctx: &RuleContext<'_>) -> Vec<Violation> {
45        let msg = match &doc.content {
46            DocumentContent::ParseError(m) => m,
47            _ => return Vec::new(),
48        };
49        let (line, column) = extract_location(msg).unwrap_or((1, 1));
50        let v = Violation::new(
51            AIL041,
52            self.default_severity(),
53            doc.path.clone(),
54            "YAML/JSON parse error",
55        )
56        .at(line, column)
57        .with_detail(msg.clone());
58        vec![v]
59    }
60}
61
62/// Best-effort extraction of `line: N column: M` from serde_yaml errors.
63fn extract_location(msg: &str) -> Option<(usize, usize)> {
64    let lower = msg.to_ascii_lowercase();
65    let line = extract_after(&lower, "line ").or_else(|| extract_after(&lower, "line: "))?;
66    let col = extract_after(&lower, "column ")
67        .or_else(|| extract_after(&lower, "column: "))
68        .unwrap_or(1);
69    Some((line, col))
70}
71
72fn extract_after(haystack: &str, needle: &str) -> Option<usize> {
73    let idx = haystack.find(needle)?;
74    let rest = &haystack[idx + needle.len()..];
75    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
76    digits.parse().ok()
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn extracts_serde_yaml_style_location() {
85        assert_eq!(
86            extract_location("mapping values are not allowed in this context at line 3 column 5"),
87            Some((3, 5))
88        );
89    }
90
91    #[test]
92    fn falls_back_when_no_location() {
93        assert_eq!(extract_location("some other error"), None);
94    }
95}