Skip to main content

normalize_manifest/
ocaml.rs

1//! Parser for `*.opam` files (OCaml/OPAM).
2//!
3//! Heuristic line-based parsing of OPAM package files:
4//! - `name:` / `version:` → package metadata
5//! - `depends:` list → `DepKind::Normal` by default,
6//!   `{with-test ...}` constraint → `DepKind::Dev`
7//! - `depopts:` list → `DepKind::Optional`
8//!
9//! OPAM list format:
10//! ```text
11//! depends: [
12//!   "pkg-name" {>= "1.0"}
13//!   "other-pkg"
14//! ]
15//! ```
16
17use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
18
19/// Parser for `*.opam` files (OCaml OPAM packages).
20///
21/// Since OPAM files use non-standard filenames (e.g. `mypackage.opam`),
22/// register via extension using `parse_manifest_by_extension("opam", content)`.
23pub struct OpamParser;
24
25impl ManifestParser for OpamParser {
26    fn filename(&self) -> &'static str {
27        "*.opam"
28    }
29
30    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
31        let mut name: Option<String> = None;
32        let mut version: Option<String> = None;
33        let mut deps: Vec<DeclaredDep> = Vec::new();
34
35        #[derive(Clone, Copy, PartialEq)]
36        enum Section {
37            None,
38            Depends,
39            Depopts,
40        }
41
42        let mut section = Section::None;
43        let mut bracket_depth: i32 = 0;
44
45        for line in content.lines() {
46            let trimmed = line.trim();
47
48            if trimmed.is_empty() || trimmed.starts_with('#') {
49                continue;
50            }
51
52            // Check for section headers at depth 0
53            if bracket_depth == 0 {
54                if let Some(val) = parse_opam_field(trimmed, "name") {
55                    name = Some(val);
56                    continue;
57                }
58                if let Some(val) = parse_opam_field(trimmed, "version") {
59                    version = Some(val);
60                    continue;
61                }
62
63                // `depends: [` or `depends: [ ... ]`
64                if trimmed.starts_with("depends:") {
65                    section = Section::Depends;
66                } else if trimmed.starts_with("depopts:") {
67                    section = Section::Depopts;
68                } else if !trimmed.starts_with('"') {
69                    // Non-dep line at top level — could be another field starting a new block
70                    if !trimmed.contains('[') {
71                        section = Section::None;
72                    }
73                }
74            }
75
76            // Count brackets
77            for ch in trimmed.chars() {
78                match ch {
79                    '[' => bracket_depth += 1,
80                    ']' => {
81                        bracket_depth -= 1;
82                        if bracket_depth == 0 {
83                            section = Section::None;
84                        }
85                    }
86                    _ => {}
87                }
88            }
89
90            // Parse dep entries inside a section
91            if section != Section::None && bracket_depth > 0 {
92                // A dep entry looks like: `"pkg-name" {constraints}` or just `"pkg-name"`
93                if trimmed.starts_with('"') {
94                    let kind_for_section = match section {
95                        Section::Depends => DepKind::Normal,
96                        Section::Depopts => DepKind::Optional,
97                        Section::None => continue,
98                    };
99                    if let Some(dep) = parse_opam_dep_entry(trimmed, kind_for_section) {
100                        deps.push(dep);
101                    }
102                }
103            }
104        }
105
106        Ok(ParsedManifest {
107            ecosystem: "opam",
108            name,
109            version,
110            dependencies: deps,
111        })
112    }
113}
114
115/// Parse `key: "value"` → `Some(value)` (strips quotes).
116fn parse_opam_field(line: &str, key: &str) -> Option<String> {
117    let prefix = format!("{}:", key);
118    let rest = line.strip_prefix(&prefix)?.trim();
119    let val = rest.trim_matches('"');
120    if val.is_empty() {
121        None
122    } else {
123        Some(val.to_string())
124    }
125}
126
127/// Parse a dep entry like `"pkg-name" {>= "1.0"}` or `"pkg-name" {with-test & >= "1.6"}`.
128fn parse_opam_dep_entry(line: &str, default_kind: DepKind) -> Option<DeclaredDep> {
129    // Extract the package name (first quoted string)
130    let after_quote = line.strip_prefix('"')?;
131    let name_end = after_quote.find('"')?;
132    let pkg_name = after_quote[..name_end].to_string();
133    if pkg_name.is_empty() {
134        return None;
135    }
136
137    let rest = after_quote[name_end + 1..].trim();
138
139    // No constraints
140    if rest.is_empty() || !rest.contains('{') {
141        return Some(DeclaredDep {
142            name: pkg_name,
143            version_req: None,
144            kind: default_kind,
145        });
146    }
147
148    // Parse constraint block `{...}`
149    let brace_start = rest.find('{')?;
150    let brace_content_start = brace_start + 1;
151    let brace_end = rest.rfind('}')?;
152    let constraint = rest[brace_content_start..brace_end].trim();
153
154    // Detect `with-test` → Dev
155    let kind = if constraint.contains("with-test") || constraint.contains("with-doc") {
156        DepKind::Dev
157    } else {
158        default_kind
159    };
160
161    // Extract version: look for `>= "x"` or `"x"` patterns within constraint
162    let version_req = extract_opam_version(constraint);
163
164    Some(DeclaredDep {
165        name: pkg_name,
166        version_req,
167        kind,
168    })
169}
170
171/// Extract a version string from an OPAM constraint like `>= "4.14"` or `>= "1.0" & < "2.0"`.
172fn extract_opam_version(constraint: &str) -> Option<String> {
173    // Remove with-test and similar flags, collect version constraints
174    let mut parts = Vec::new();
175
176    let clean = constraint
177        .replace("with-test", "")
178        .replace("with-doc", "")
179        .replace(['&', '|'], " ");
180
181    let mut chars = clean.chars().peekable();
182    let mut current_op = String::new();
183
184    while let Some(ch) = chars.next() {
185        match ch {
186            '>' | '<' | '=' | '!' => {
187                current_op.push(ch);
188                // Collect the rest of the operator
189                while chars.peek().is_some_and(|&c| matches!(c, '>' | '<' | '=')) {
190                    // normalize-syntax-allow: rust/unwrap-in-impl - peek() confirmed Some; next() cannot fail
191                    current_op.push(chars.next().unwrap());
192                }
193                // Skip whitespace
194                while chars.peek().is_some_and(|c| c.is_whitespace()) {
195                    chars.next();
196                }
197                // Read quoted version
198                if chars.peek() == Some(&'"') {
199                    chars.next(); // consume '"'
200                    let mut ver = String::new();
201                    for c in chars.by_ref() {
202                        if c == '"' {
203                            break;
204                        }
205                        ver.push(c);
206                    }
207                    parts.push(format!("{} \"{}\"", current_op.trim(), ver));
208                    current_op.clear();
209                }
210            }
211            '"' => {
212                // Bare quoted version (no operator)
213                let mut ver = String::new();
214                for c in chars.by_ref() {
215                    if c == '"' {
216                        break;
217                    }
218                    ver.push(c);
219                }
220                if !ver.is_empty() {
221                    parts.push(format!("\"{}\"", ver));
222                }
223            }
224            _ => {}
225        }
226    }
227
228    if parts.is_empty() {
229        None
230    } else {
231        Some(parts.join(" & "))
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::ManifestParser;
239
240    #[test]
241    fn test_parse_opam() {
242        let content = r#"opam-version: "2.0"
243name: "my-package"
244version: "0.1.0"
245synopsis: "My OCaml package"
246depends: [
247  "ocaml" {>= "4.14"}
248  "dune" {>= "3.0"}
249  "cmdliner" {>= "1.1"}
250  "alcotest" {with-test & >= "1.6"}
251]
252depopts: [
253  "ppx_sexp_conv"
254]
255"#;
256        let m = OpamParser.parse(content).unwrap();
257        assert_eq!(m.ecosystem, "opam");
258        assert_eq!(m.name.as_deref(), Some("my-package"));
259        assert_eq!(m.version.as_deref(), Some("0.1.0"));
260
261        let ocaml = m.dependencies.iter().find(|d| d.name == "ocaml").unwrap();
262        assert_eq!(ocaml.kind, DepKind::Normal);
263        assert!(ocaml.version_req.is_some());
264
265        let dune = m.dependencies.iter().find(|d| d.name == "dune").unwrap();
266        assert_eq!(dune.kind, DepKind::Normal);
267
268        let alcotest = m
269            .dependencies
270            .iter()
271            .find(|d| d.name == "alcotest")
272            .unwrap();
273        assert_eq!(alcotest.kind, DepKind::Dev);
274
275        let ppx = m
276            .dependencies
277            .iter()
278            .find(|d| d.name == "ppx_sexp_conv")
279            .unwrap();
280        assert_eq!(ppx.kind, DepKind::Optional);
281    }
282
283    #[test]
284    fn test_bare_dep() {
285        let content = "opam-version: \"2.0\"\nname: \"mypkg\"\nversion: \"1.0\"\ndepends: [\n  \"ocaml\"\n  \"dune\"\n]\n";
286        let m = OpamParser.parse(content).unwrap();
287        assert_eq!(m.dependencies.len(), 2);
288        assert!(m.dependencies.iter().all(|d| d.kind == DepKind::Normal));
289    }
290}