normalize_manifest/
ocaml.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
18
19pub 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 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 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 if !trimmed.contains('[') {
71 section = Section::None;
72 }
73 }
74 }
75
76 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 if section != Section::None && bracket_depth > 0 {
92 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
115fn 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
127fn parse_opam_dep_entry(line: &str, default_kind: DepKind) -> Option<DeclaredDep> {
129 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 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 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 let kind = if constraint.contains("with-test") || constraint.contains("with-doc") {
156 DepKind::Dev
157 } else {
158 default_kind
159 };
160
161 let version_req = extract_opam_version(constraint);
163
164 Some(DeclaredDep {
165 name: pkg_name,
166 version_req,
167 kind,
168 })
169}
170
171fn extract_opam_version(constraint: &str) -> Option<String> {
173 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 while chars.peek().is_some_and(|&c| matches!(c, '>' | '<' | '=')) {
190 current_op.push(chars.next().unwrap());
192 }
193 while chars.peek().is_some_and(|c| c.is_whitespace()) {
195 chars.next();
196 }
197 if chars.peek() == Some(&'"') {
199 chars.next(); 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 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}