Skip to main content

normalize_manifest/
vlang.rs

1//! Parser for `v.mod` files (V language/vpm).
2//!
3//! V module files use a simple key-value syntax. We heuristically extract
4//! `name`, `version`, and `dependencies` fields using line-based matching.
5
6use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
7
8/// Parser for `v.mod` files.
9pub struct VModParser;
10
11impl ManifestParser for VModParser {
12    fn filename(&self) -> &'static str {
13        "v.mod"
14    }
15
16    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
17        let mut name: Option<String> = None;
18        let mut version: Option<String> = None;
19        let mut deps: Vec<DeclaredDep> = Vec::new();
20
21        let mut in_deps = false;
22
23        for line in content.lines() {
24            let trimmed = line.trim();
25
26            if trimmed.is_empty() || trimmed.starts_with("//") {
27                continue;
28            }
29
30            // Enter/exit the `dependencies: [...]` block.
31            if trimmed.starts_with("dependencies:") {
32                in_deps = true;
33                // Check if the whole list is on one line: dependencies: ['a', 'b']
34                if let Some(rest) = trimmed.strip_prefix("dependencies:") {
35                    let rest = rest.trim();
36                    if rest.starts_with('[') {
37                        extract_vmod_list(rest, &mut deps);
38                        if rest.contains(']') {
39                            in_deps = false;
40                        }
41                    }
42                }
43                continue;
44            }
45
46            if in_deps {
47                extract_vmod_list(trimmed, &mut deps);
48                if trimmed.contains(']') {
49                    in_deps = false;
50                }
51                continue;
52            }
53
54            // name: 'mymodule'
55            if let Some(rest) = trimmed.strip_prefix("name:") {
56                if name.is_none() {
57                    let v = extract_single_quoted(rest.trim())
58                        .or_else(|| extract_double_quoted(rest.trim()))
59                        .unwrap_or_else(|| rest.trim().to_string());
60                    if !v.is_empty() {
61                        name = Some(v);
62                    }
63                }
64                continue;
65            }
66
67            // version: '0.1.0'
68            if let Some(rest) = trimmed.strip_prefix("version:") {
69                if version.is_none() {
70                    let v = extract_single_quoted(rest.trim())
71                        .or_else(|| extract_double_quoted(rest.trim()))
72                        .unwrap_or_else(|| rest.trim().to_string());
73                    if !v.is_empty() {
74                        version = Some(v);
75                    }
76                }
77                continue;
78            }
79        }
80
81        Ok(ParsedManifest {
82            ecosystem: "vpm",
83            name,
84            version,
85            dependencies: deps,
86        })
87    }
88}
89
90/// Extract single-quoted string: `'value'` → `value`.
91fn extract_single_quoted(s: &str) -> Option<String> {
92    let s = s.trim();
93    let inner = s.strip_prefix('\'')?;
94    let end = inner.find('\'')?;
95    Some(inner[..end].to_string())
96}
97
98/// Extract double-quoted string: `"value"` → `value`.
99fn extract_double_quoted(s: &str) -> Option<String> {
100    let s = s.trim();
101    let inner = s.strip_prefix('"')?;
102    let end = inner.find('"')?;
103    Some(inner[..end].to_string())
104}
105
106/// Extract all single-quoted dep entries from a fragment like `['a', 'b.c']`.
107fn extract_vmod_list(fragment: &str, deps: &mut Vec<DeclaredDep>) {
108    let mut s = fragment;
109    while let Some(start) = s.find('\'') {
110        s = &s[start + 1..];
111        if let Some(end) = s.find('\'') {
112            let dep_name = s[..end].trim().to_string();
113            if !dep_name.is_empty() {
114                deps.push(DeclaredDep {
115                    name: dep_name,
116                    version_req: None,
117                    kind: DepKind::Normal,
118                });
119            }
120            s = &s[end + 1..];
121        } else {
122            break;
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::ManifestParser;
131
132    const SAMPLE: &str = r#"Module {
133  name: 'mymodule'
134  description: 'My V module'
135  version: '0.1.0'
136  license: 'MIT'
137  dependencies: ['vweb', 'json', 'db.sqlite']
138}
139"#;
140
141    #[test]
142    fn test_parse_vmod() {
143        let m = VModParser.parse(SAMPLE).unwrap();
144        assert_eq!(m.ecosystem, "vpm");
145        assert_eq!(m.name.as_deref(), Some("mymodule"));
146        assert_eq!(m.version.as_deref(), Some("0.1.0"));
147
148        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
149        assert!(names.contains(&"vweb"), "{names:?}");
150        assert!(names.contains(&"json"), "{names:?}");
151        assert!(names.contains(&"db.sqlite"), "{names:?}");
152        assert_eq!(m.dependencies.len(), 3);
153    }
154
155    #[test]
156    fn test_multiline_deps() {
157        let content = r#"Module {
158  name: 'multi'
159  version: '0.2.0'
160  dependencies: [
161    'a',
162    'b',
163    'c'
164  ]
165}
166"#;
167        let m = VModParser.parse(content).unwrap();
168        let names: Vec<&str> = m.dependencies.iter().map(|d| d.name.as_str()).collect();
169        assert_eq!(names, vec!["a", "b", "c"], "{names:?}");
170    }
171
172    #[test]
173    fn test_no_deps() {
174        let content = r#"Module {
175  name: 'simple'
176  version: '1.0.0'
177}
178"#;
179        let m = VModParser.parse(content).unwrap();
180        assert_eq!(m.name.as_deref(), Some("simple"));
181        assert!(m.dependencies.is_empty());
182    }
183}