Skip to main content

normalize_manifest/
conan.rs

1//! Parsers for Conan C/C++ package manifests.
2//!
3//! - `conanfile.txt` (v1 INI-style)
4//! - `conanfile.py` (v1/v2 Python file, heuristic extraction)
5
6use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
7
8/// Parser for `conanfile.txt` (Conan v1 INI format).
9///
10/// Extracts `[requires]` section entries of the form `pkg/version[@user/channel]`.
11pub struct ConanTxtParser;
12
13impl ManifestParser for ConanTxtParser {
14    fn filename(&self) -> &'static str {
15        "conanfile.txt"
16    }
17
18    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
19        let deps = parse_conan_txt(content);
20        Ok(ParsedManifest {
21            ecosystem: "conan",
22            name: None,
23            version: None,
24            dependencies: deps,
25        })
26    }
27}
28
29pub(crate) fn parse_conan_txt(content: &str) -> Vec<DeclaredDep> {
30    let mut deps = Vec::new();
31    let mut in_requires = false;
32
33    for line in content.lines() {
34        let line = line.trim();
35
36        if line.starts_with('[') {
37            // Section header — only [requires] yields deps
38            in_requires = line.eq_ignore_ascii_case("[requires]");
39            continue;
40        }
41        if !in_requires || line.is_empty() || line.starts_with('#') {
42            continue;
43        }
44
45        // Strip inline comment
46        let dep_str = line.split('#').next().unwrap_or(line).trim();
47        if dep_str.is_empty() {
48            continue;
49        }
50
51        // Format: pkg/version  or  pkg/version@user/channel
52        if let Some(slash_idx) = dep_str.find('/') {
53            let name = dep_str[..slash_idx].trim().to_string();
54            let rest = dep_str[slash_idx + 1..].trim();
55            // Strip @user/channel if present
56            let version = rest.split('@').next().unwrap_or(rest).trim();
57            let version_req = if version.is_empty() {
58                None
59            } else {
60                Some(version.to_string())
61            };
62            if !name.is_empty() {
63                deps.push(DeclaredDep {
64                    name,
65                    version_req,
66                    kind: DepKind::Normal,
67                });
68            }
69        } else {
70            // Bare name with no version
71            deps.push(DeclaredDep {
72                name: dep_str.to_string(),
73                version_req: None,
74                kind: DepKind::Normal,
75            });
76        }
77    }
78
79    deps
80}
81
82/// Parser for `conanfile.py` (Conan Python file, heuristic).
83///
84/// Extracts deps from:
85/// - `requires = ["pkg/1.0", ...]` list literal
86/// - `self.requires("pkg/1.0")` calls
87pub struct ConanPyParser;
88
89impl ManifestParser for ConanPyParser {
90    fn filename(&self) -> &'static str {
91        "conanfile.py"
92    }
93
94    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
95        let mut deps = Vec::new();
96        let mut in_requires_list = false;
97
98        for line in content.lines() {
99            let trimmed = line.trim();
100
101            // requires = ["pkg/1.0", "other/2.0"]  (single or multi-line list)
102            if trimmed.starts_with("requires") && trimmed.contains('=') && !in_requires_list {
103                // Check if list opens on this line
104                let after_eq = trimmed.split_once('=').map(|x| x.1).unwrap_or("").trim();
105                extract_quoted_refs(after_eq, &mut deps);
106                if after_eq.contains('[') && !after_eq.contains(']') {
107                    in_requires_list = true;
108                }
109                continue;
110            }
111
112            if in_requires_list {
113                extract_quoted_refs(trimmed, &mut deps);
114                if trimmed.contains(']') {
115                    in_requires_list = false;
116                }
117                continue;
118            }
119
120            // self.requires("pkg/1.0") or self.requires("pkg/1.0", headers=True)
121            if (trimmed.starts_with("self.requires(") || trimmed.starts_with("self.tool_requires("))
122                && trimmed.contains('"')
123                && let Some(ref_str) = extract_first_string(trimmed)
124                && let Some(dep) = conan_ref_to_dep(&ref_str)
125            {
126                deps.push(dep);
127            }
128        }
129
130        Ok(ParsedManifest {
131            ecosystem: "conan",
132            name: None,
133            version: None,
134            dependencies: deps,
135        })
136    }
137}
138
139/// Extract all `"pkg/version"` quoted strings from a line and push as deps.
140fn extract_quoted_refs(line: &str, out: &mut Vec<DeclaredDep>) {
141    let mut rest = line;
142    while let Some(start) = rest.find('"') {
143        rest = &rest[start + 1..];
144        if let Some(end) = rest.find('"') {
145            let s = &rest[..end];
146            if let Some(dep) = conan_ref_to_dep(s) {
147                out.push(dep);
148            }
149            rest = &rest[end + 1..];
150        } else {
151            break;
152        }
153    }
154}
155
156fn extract_first_string(line: &str) -> Option<String> {
157    let start = line.find('"')? + 1;
158    let end = line[start..].find('"')?;
159    Some(line[start..start + end].to_string())
160}
161
162fn conan_ref_to_dep(s: &str) -> Option<DeclaredDep> {
163    let s = s.trim();
164    if s.is_empty() {
165        return None;
166    }
167    // pkg/version@user/channel or pkg/version
168    if let Some(slash_idx) = s.find('/') {
169        let name = s[..slash_idx].trim().to_string();
170        let rest = s[slash_idx + 1..].trim();
171        let version = rest.split('@').next().unwrap_or(rest).trim();
172        let version_req = if version.is_empty() {
173            None
174        } else {
175            Some(version.to_string())
176        };
177        if name.is_empty() {
178            return None;
179        }
180        Some(DeclaredDep {
181            name,
182            version_req,
183            kind: DepKind::Normal,
184        })
185    } else {
186        Some(DeclaredDep {
187            name: s.to_string(),
188            version_req: None,
189            kind: DepKind::Normal,
190        })
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::ManifestParser;
198
199    #[test]
200    fn test_conan_txt() {
201        let content = r#"[requires]
202zlib/1.2.13
203boost/1.83.0@conan/stable
204openssl/3.1.0  # pinned
205
206[generators]
207cmake
208"#;
209        let m = ConanTxtParser.parse(content).unwrap();
210        assert_eq!(m.ecosystem, "conan");
211        assert_eq!(m.dependencies.len(), 3);
212
213        let zlib = m.dependencies.iter().find(|d| d.name == "zlib").unwrap();
214        assert_eq!(zlib.version_req.as_deref(), Some("1.2.13"));
215
216        let boost = m.dependencies.iter().find(|d| d.name == "boost").unwrap();
217        assert_eq!(boost.version_req.as_deref(), Some("1.83.0"));
218    }
219
220    #[test]
221    fn test_conan_py_list() {
222        let content = r#"from conan import ConanFile
223
224class MyConan(ConanFile):
225    requires = ["zlib/1.2.13", "boost/1.83.0"]
226    tool_requires = []
227"#;
228        let m = ConanPyParser.parse(content).unwrap();
229        assert_eq!(m.ecosystem, "conan");
230        assert_eq!(m.dependencies.len(), 2);
231        assert!(m.dependencies.iter().any(|d| d.name == "zlib"));
232        assert!(m.dependencies.iter().any(|d| d.name == "boost"));
233    }
234
235    #[test]
236    fn test_conan_py_self_requires() {
237        let content = r#"from conan import ConanFile
238
239class MyConan(ConanFile):
240    def requirements(self):
241        self.requires("zlib/1.2.13")
242        self.requires("openssl/3.1.0", headers=True)
243        self.tool_requires("cmake/3.25.0")
244"#;
245        let m = ConanPyParser.parse(content).unwrap();
246        assert_eq!(m.dependencies.len(), 3);
247    }
248}