1use std::fs;
25use std::path::Path;
26
27use log::warn;
28use packageurl::PackageUrl;
29use rustpython_parser::{Parse, ast};
30use serde_json::Value;
31
32use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
33
34use super::PackageParser;
35
36pub struct ConanFilePyParser;
41
42impl PackageParser for ConanFilePyParser {
43 const PACKAGE_TYPE: PackageType = PackageType::Conan;
44
45 fn is_match(path: &Path) -> bool {
46 path.file_name().is_some_and(|name| name == "conanfile.py")
47 }
48
49 fn extract_packages(path: &Path) -> Vec<PackageData> {
50 let contents = match fs::read_to_string(path) {
51 Ok(c) => c,
52 Err(e) => {
53 warn!("Failed to read {}: {}", path.display(), e);
54 return vec![default_package_data()];
55 }
56 };
57
58 vec![match ast::Suite::parse(&contents, "<conanfile.py>") {
59 Ok(statements) => parse_conanfile_py(&statements),
60 Err(e) => {
61 warn!("Failed to parse Python AST in {}: {}", path.display(), e);
62 default_package_data()
63 }
64 }]
65 }
66}
67
68fn parse_conanfile_py(statements: &[ast::Stmt]) -> PackageData {
70 for stmt in statements {
71 if let ast::Stmt::ClassDef(class_def) = stmt
72 && has_conanfile_base(class_def)
73 {
74 return extract_conanfile_data(class_def);
75 }
76 }
77
78 default_package_data()
79}
80
81fn has_conanfile_base(class_def: &ast::StmtClassDef) -> bool {
83 class_def.bases.iter().any(|base| {
84 if let ast::Expr::Name(ast::ExprName { id, .. }) = base {
85 id.as_str() == "ConanFile"
86 } else {
87 false
88 }
89 })
90}
91
92fn extract_conanfile_data(class_def: &ast::StmtClassDef) -> PackageData {
94 let mut name = None;
95 let mut version = None;
96 let mut description = None;
97 let mut _author = None;
98 let mut homepage_url = None;
99 let mut vcs_url = None;
100 let mut license_list = Vec::new();
101 let mut keywords = Vec::new();
102 let mut requires_list = Vec::new();
103
104 for stmt in class_def.body.iter() {
105 match stmt {
106 ast::Stmt::Assign(ast::StmtAssign { targets, value, .. }) => {
107 if let Some(target_name) = get_assignment_target(targets) {
108 match target_name.as_str() {
109 "name" => name = get_string_value(value),
110 "version" => version = get_string_value(value),
111 "description" => description = get_string_value(value),
112 "author" => _author = get_string_value(value),
113 "homepage" => homepage_url = get_string_value(value),
114 "url" => vcs_url = get_string_value(value),
115 "license" => license_list = get_list_values(value),
116 "topics" => keywords = get_list_values(value),
117 "requires" => requires_list = get_list_values(value),
118 _ => {}
119 }
120 }
121 }
122 ast::Stmt::FunctionDef(ast::StmtFunctionDef { body, .. }) => {
123 if let Some(requires) = extract_self_requires_calls(body) {
124 requires_list.extend(requires);
125 }
126 }
127 _ => {}
128 }
129 }
130
131 let dependencies = requires_list
132 .into_iter()
133 .filter_map(|req| parse_conan_reference(&req))
134 .collect();
135
136 let extracted_license = if !license_list.is_empty() {
137 Some(license_list.join(", "))
138 } else {
139 None
140 };
141
142 PackageData {
143 name,
144 version,
145 description,
146 homepage_url,
147 vcs_url,
148 keywords,
149 dependencies,
150 extracted_license_statement: extracted_license,
151 datasource_id: Some(DatasourceId::ConanConanFilePy),
152 ..default_package_data()
153 }
154}
155
156fn get_assignment_target(targets: &[ast::Expr]) -> Option<String> {
158 targets.first().and_then(|target| {
159 if let ast::Expr::Name(ast::ExprName { id, .. }) = target {
160 Some(id.to_string())
161 } else {
162 None
163 }
164 })
165}
166
167fn get_string_value(expr: &ast::Expr) -> Option<String> {
169 if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = expr {
170 match value {
171 ast::Constant::Str(s) => Some(s.to_string()),
172 _ => None,
173 }
174 } else {
175 None
176 }
177}
178
179fn get_list_values(expr: &ast::Expr) -> Vec<String> {
181 match expr {
182 ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
183 elts.iter().filter_map(get_string_value).collect()
184 }
185 ast::Expr::List(ast::ExprList { elts, .. }) => {
186 elts.iter().filter_map(get_string_value).collect()
187 }
188 _ => {
189 if let Some(s) = get_string_value(expr) {
190 vec![s]
191 } else {
192 Vec::new()
193 }
194 }
195 }
196}
197
198fn extract_self_requires_calls(body: &[ast::Stmt]) -> Option<Vec<String>> {
200 let mut requires = Vec::new();
201
202 for stmt in body {
203 if let ast::Stmt::Expr(ast::StmtExpr { value, .. }) = stmt
204 && let ast::Expr::Call(call) = value.as_ref()
205 && is_self_requires_call(call)
206 && let Some(arg) = call.args.first()
207 && let Some(req) = get_string_value(arg)
208 {
209 requires.push(req);
210 }
211 }
212
213 if requires.is_empty() {
214 None
215 } else {
216 Some(requires)
217 }
218}
219
220fn is_self_requires_call(call: &ast::ExprCall) -> bool {
222 if let ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = call.func.as_ref()
223 && let ast::Expr::Name(ast::ExprName { id, .. }) = value.as_ref()
224 {
225 return id.as_str() == "self" && attr.as_str() == "requires";
226 }
227 false
228}
229
230pub struct ConanfileTxtParser;
235
236impl PackageParser for ConanfileTxtParser {
237 const PACKAGE_TYPE: PackageType = PackageType::Conan;
238
239 fn is_match(path: &Path) -> bool {
240 path.file_name().is_some_and(|name| name == "conanfile.txt")
241 }
242
243 fn extract_packages(path: &Path) -> Vec<PackageData> {
244 let contents = match fs::read_to_string(path) {
245 Ok(c) => c,
246 Err(e) => {
247 warn!("Failed to read {}: {}", path.display(), e);
248 return vec![default_package_data()];
249 }
250 };
251
252 let dependencies = parse_conanfile_txt(&contents);
253
254 vec![PackageData {
255 package_type: Some(Self::PACKAGE_TYPE),
256 dependencies,
257 primary_language: Some("C++".to_string()),
258 datasource_id: Some(DatasourceId::ConanConanFileTxt),
259 ..default_package_data()
260 }]
261 }
262}
263
264pub struct ConanLockParser;
269
270impl PackageParser for ConanLockParser {
271 const PACKAGE_TYPE: PackageType = PackageType::Conan;
272
273 fn is_match(path: &Path) -> bool {
274 path.file_name().is_some_and(|name| name == "conan.lock")
275 }
276
277 fn extract_packages(path: &Path) -> Vec<PackageData> {
278 let contents = match fs::read_to_string(path) {
279 Ok(c) => c,
280 Err(e) => {
281 warn!("Failed to read {}: {}", path.display(), e);
282 return vec![default_package_data()];
283 }
284 };
285
286 let json: Value = match serde_json::from_str(&contents) {
287 Ok(j) => j,
288 Err(e) => {
289 warn!("Failed to parse JSON in {}: {}", path.display(), e);
290 return vec![default_package_data()];
291 }
292 };
293
294 let dependencies = parse_conan_lock(&json);
295
296 vec![PackageData {
297 package_type: Some(Self::PACKAGE_TYPE),
298 dependencies,
299 primary_language: Some("C++".to_string()),
300 datasource_id: Some(DatasourceId::ConanLock),
301 ..default_package_data()
302 }]
303 }
304}
305
306fn parse_conan_reference(ref_str: &str) -> Option<Dependency> {
307 let (name, version_spec) = if let Some((n, v)) = ref_str.split_once('/') {
308 (n.trim(), Some(v.trim().to_string()))
309 } else {
310 (ref_str.trim(), None)
311 };
312
313 let version = version_spec.as_ref().and_then(|v| {
314 if !v.contains('[') && !v.contains('>') && !v.contains('<') {
315 Some(v.clone())
316 } else {
317 None
318 }
319 });
320
321 let purl = if let Some(v) = version.as_deref() {
322 PackageUrl::new("conan", name)
323 .map(|mut p| {
324 let _ = p.with_version(v);
325 p.to_string()
326 })
327 .unwrap_or_else(|_| format!("pkg:conan/{}", name))
328 } else {
329 format!("pkg:conan/{}", name)
330 };
331
332 let is_pinned = version_spec
333 .as_ref()
334 .map(|v| !v.contains('[') && !v.contains('>') && !v.contains('<'))
335 .unwrap_or(false);
336
337 Some(Dependency {
338 purl: Some(purl),
339 extracted_requirement: version_spec,
340 scope: Some("install".to_string()),
341 is_runtime: Some(true),
342 is_optional: Some(false),
343 is_pinned: Some(is_pinned),
344 is_direct: Some(true),
345 resolved_package: None,
346 extra_data: None,
347 })
348}
349
350fn parse_conanfile_txt(contents: &str) -> Vec<Dependency> {
351 let mut dependencies = Vec::new();
352 let mut current_section = None;
353
354 for line in contents.lines() {
355 let trimmed = line.trim();
356
357 if trimmed.is_empty() || trimmed.starts_with('#') {
358 continue;
359 }
360
361 if trimmed.starts_with('[') && trimmed.ends_with(']') {
362 current_section = Some(trimmed.trim_matches(|c| c == '[' || c == ']').to_string());
363 continue;
364 }
365
366 if let Some(ref section) = current_section {
367 let (scope, is_runtime) = match section.as_str() {
368 "requires" => ("install", true),
369 "build_requires" => ("build", false),
370 _ => continue,
371 };
372
373 if let Some(dep) = parse_conan_reference(trimmed) {
374 dependencies.push(Dependency {
375 scope: Some(scope.to_string()),
376 is_runtime: Some(is_runtime),
377 ..dep
378 });
379 }
380 }
381 }
382
383 dependencies
384}
385
386fn parse_conan_lock(json: &Value) -> Vec<Dependency> {
387 let mut dependencies = Vec::new();
388
389 if let Some(graph_lock) = json.get("graph_lock")
390 && let Some(nodes) = graph_lock.get("nodes").and_then(|n| n.as_object())
391 {
392 for (_node_id, node_data) in nodes {
393 if let Some(ref_str) = node_data.get("ref").and_then(|r| r.as_str())
394 && !ref_str.is_empty()
395 && ref_str != "conanfile"
396 && let Some(dep) = parse_conan_reference(ref_str)
397 {
398 dependencies.push(dep);
399 }
400 }
401 }
402
403 dependencies
404}
405
406fn default_package_data() -> PackageData {
407 PackageData {
408 package_type: Some(ConanFilePyParser::PACKAGE_TYPE),
409 primary_language: Some("C++".to_string()),
410 ..Default::default()
411 }
412}
413
414crate::register_parser!(
415 "Conan C/C++ package manifest",
416 &["**/conanfile.py", "**/conanfile.txt", "**/conan.lock"],
417 "conan",
418 "C++",
419 Some("https://docs.conan.io/"),
420);