Skip to main content

lintel_validate/parsers/
yaml.rs

1use miette::NamedSource;
2use serde_json::Value;
3
4use crate::diagnostics::ParseDiagnostic;
5
6use super::Parser;
7
8pub struct YamlParser;
9
10impl Parser for YamlParser {
11    fn parse(&self, content: &str, file_name: &str) -> Result<Value, ParseDiagnostic> {
12        // Strip UTF-8 BOM characters that can appear at the start of a file or
13        // mid-stream (e.g. after a comment line), which serde_yaml misinterprets
14        // as a multi-document separator.
15        let clean: alloc::borrow::Cow<'_, str> = if content.contains('\u{FEFF}') {
16            content.replace('\u{FEFF}', "").into()
17        } else {
18            content.into()
19        };
20        serde_yaml::from_str(&clean).map_err(|e| {
21            let offset = e.location().map_or(0, |loc| loc.index());
22            ParseDiagnostic {
23                src: NamedSource::new(file_name, content.to_string()),
24                span: offset.into(),
25                message: e.to_string(),
26            }
27        })
28    }
29
30    fn annotate(&self, content: &str, schema_url: &str) -> Option<String> {
31        Some(format!(
32            "# yaml-language-server: $schema={schema_url}\n{content}"
33        ))
34    }
35
36    fn strip_annotation(&self, content: &str) -> String {
37        strip_yaml_modeline(content)
38    }
39
40    fn extract_schema_uri(&self, content: &str, value: &Value) -> Option<String> {
41        // First check for yaml-language-server modeline in leading comments
42        if let Some(uri) = extract_yaml_modeline_schema(content) {
43            return Some(uri);
44        }
45        // Fall back to top-level $schema property
46        value
47            .get("$schema")
48            .and_then(Value::as_str)
49            .map(String::from)
50    }
51}
52
53/// Remove the `# yaml-language-server: $schema=URL` modeline from leading comments.
54fn strip_yaml_modeline(content: &str) -> String {
55    let mut offset = 0;
56    for line in content.lines() {
57        let trimmed = line.trim();
58        if trimmed.is_empty() {
59            offset += line.len() + 1;
60            continue;
61        }
62        if !trimmed.starts_with('#') {
63            break;
64        }
65        if let Some(after_hash) = trimmed.strip_prefix('#') {
66            let after_hash = after_hash.trim();
67            if let Some(rest) = after_hash.strip_prefix("yaml-language-server:")
68                && rest.trim().starts_with("$schema=")
69            {
70                let line_end = offset + line.len();
71                let remove_end = if content.as_bytes().get(line_end) == Some(&b'\n') {
72                    line_end + 1
73                } else {
74                    line_end
75                };
76                return format!("{}{}", &content[..offset], &content[remove_end..]);
77            }
78        }
79        offset += line.len() + 1;
80    }
81    content.to_string()
82}
83
84/// Extract schema URI from `# yaml-language-server: $schema=URL` comment.
85fn extract_yaml_modeline_schema(content: &str) -> Option<String> {
86    for line in content.lines() {
87        let trimmed = line.trim();
88        if trimmed.is_empty() {
89            continue;
90        }
91        if !trimmed.starts_with('#') {
92            break;
93        }
94        let after_hash = trimmed.strip_prefix('#')?.trim();
95        if let Some(rest) = after_hash.strip_prefix("yaml-language-server:") {
96            let rest = rest.trim();
97            if let Some(uri) = rest.strip_prefix("$schema=") {
98                let uri = uri.trim();
99                if !uri.is_empty() {
100                    return Some(uri.to_string());
101                }
102            }
103        }
104    }
105    None
106}