normalize_manifest/
conan.rs1use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
7
8pub 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 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 let dep_str = line.split('#').next().unwrap_or(line).trim();
47 if dep_str.is_empty() {
48 continue;
49 }
50
51 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 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 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
82pub 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 if trimmed.starts_with("requires") && trimmed.contains('=') && !in_requires_list {
103 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 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
139fn 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 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}