1use std::path::Path;
25
26use crate::parser_warn as warn;
27use packageurl::PackageUrl;
28use ruff_python_ast as ast;
29use ruff_python_parser::parse_module;
30use serde_json::Value;
31
32use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
33
34use super::PackageParser;
35use super::license_normalization::{
36 DeclaredLicenseMatchMetadata, build_declared_license_data, normalize_declared_license_key,
37};
38use super::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
39
40const MAX_AST_DEPTH: usize = 50;
41const MAX_AST_NODES: usize = 10_000;
42
43pub struct ConanFilePyParser;
48
49impl PackageParser for ConanFilePyParser {
50 const PACKAGE_TYPE: PackageType = PackageType::Conan;
51
52 fn is_match(path: &Path) -> bool {
53 path.file_name().is_some_and(|name| name == "conanfile.py")
54 }
55
56 fn extract_packages(path: &Path) -> Vec<PackageData> {
57 let contents = match read_file_to_string(path, None) {
58 Ok(c) => c,
59 Err(e) => {
60 warn!("Failed to read {}: {}", path.display(), e);
61 return vec![default_package_data(DatasourceId::ConanConanFilePy)];
62 }
63 };
64
65 vec![match parse_module(&contents) {
66 Ok(parsed) => parse_conanfile_py(parsed.suite()),
67 Err(e) => {
68 warn!("Failed to parse Python AST in {}: {}", path.display(), e);
69 default_package_data(DatasourceId::ConanConanFilePy)
70 }
71 }]
72 }
73}
74
75fn parse_conanfile_py(statements: &[ast::Stmt]) -> PackageData {
77 for stmt in statements {
78 if let ast::Stmt::ClassDef(class_def) = stmt
79 && has_conanfile_base(class_def)
80 {
81 return extract_conanfile_data(class_def);
82 }
83 }
84
85 default_package_data(DatasourceId::ConanConanFilePy)
86}
87
88fn has_conanfile_base(class_def: &ast::StmtClassDef) -> bool {
90 class_def.bases().iter().any(|base| {
91 if let ast::Expr::Name(ast::ExprName { id, .. }) = base {
92 id.as_str() == "ConanFile"
93 } else {
94 false
95 }
96 })
97}
98
99fn extract_conanfile_data(class_def: &ast::StmtClassDef) -> PackageData {
101 let mut name = None;
102 let mut version = None;
103 let mut description = None;
104 let mut _author = None;
105 let mut homepage_url = None;
106 let mut vcs_url = None;
107 let mut license_list = Vec::new();
108 let mut keywords = Vec::new();
109 let mut requires_list = Vec::new();
110 let mut tool_requires_list = Vec::new();
111
112 for stmt in class_def.body.iter().take(MAX_ITERATION_COUNT) {
113 match stmt {
114 ast::Stmt::Assign(ast::StmtAssign { targets, value, .. }) => {
115 if let Some(target_name) = get_assignment_target(targets) {
116 match target_name.as_str() {
117 "name" => name = get_string_value(value).map(truncate_field),
118 "version" => version = get_string_value(value).map(truncate_field),
119 "description" => description = get_string_value(value).map(truncate_field),
120 "author" => _author = get_string_value(value).map(truncate_field),
121 "homepage" => homepage_url = get_string_value(value).map(truncate_field),
122 "url" => vcs_url = get_string_value(value).map(truncate_field),
123 "license" => {
124 license_list = get_list_values(value)
125 .into_iter()
126 .map(truncate_field)
127 .collect()
128 }
129 "topics" => {
130 keywords = get_list_values(value)
131 .into_iter()
132 .map(truncate_field)
133 .collect()
134 }
135 "requires" => {
136 requires_list = get_list_values(value)
137 .into_iter()
138 .map(truncate_field)
139 .collect()
140 }
141 _ => {}
142 }
143 }
144 }
145 ast::Stmt::FunctionDef(ast::StmtFunctionDef { body, .. }) => {
146 if let Some(requires) = extract_self_requires_calls(body, "requires") {
147 requires_list.extend(requires);
148 }
149 if let Some(tool_requires) = extract_self_requires_calls(body, "tool_requires") {
150 tool_requires_list.extend(tool_requires);
151 }
152 }
153 _ => {}
154 }
155 }
156
157 let mut dependencies = requires_list
158 .into_iter()
159 .filter_map(|req| parse_conan_reference(&req))
160 .collect::<Vec<_>>();
161 dependencies.extend(
162 tool_requires_list
163 .into_iter()
164 .filter_map(|req| parse_conan_reference(&req))
165 .map(|dep| Dependency {
166 scope: Some("build".to_string()),
167 is_runtime: Some(false),
168 ..dep
169 }),
170 );
171
172 let extracted_license = if !license_list.is_empty() {
173 Some(truncate_field(license_list.join(", ")))
174 } else {
175 None
176 };
177 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
178 if license_list.len() == 1 {
179 if let Some(normalized) = normalize_declared_license_key(&license_list[0]) {
180 let (expr, spdx, detections) = build_declared_license_data(
181 normalized,
182 DeclaredLicenseMatchMetadata::single_line(&license_list[0]),
183 );
184 (
185 expr.map(truncate_field),
186 spdx.map(truncate_field),
187 detections,
188 )
189 } else {
190 (None, None, Vec::new())
191 }
192 } else {
193 (None, None, Vec::new())
194 };
195
196 PackageData {
197 name,
198 version,
199 description,
200 homepage_url,
201 vcs_url,
202 keywords,
203 dependencies,
204 declared_license_expression,
205 declared_license_expression_spdx,
206 license_detections,
207 extracted_license_statement: extracted_license,
208 datasource_id: Some(DatasourceId::ConanConanFilePy),
209 ..default_package_data(DatasourceId::ConanConanFilePy)
210 }
211}
212
213fn get_assignment_target(targets: &[ast::Expr]) -> Option<String> {
215 targets.first().and_then(|target| {
216 if let ast::Expr::Name(ast::ExprName { id, .. }) = target {
217 Some(id.to_string())
218 } else {
219 None
220 }
221 })
222}
223
224fn get_string_value(expr: &ast::Expr) -> Option<String> {
226 match expr {
227 ast::Expr::StringLiteral(ast::ExprStringLiteral { value, .. }) => {
228 Some(value.to_str().to_string())
229 }
230 _ => None,
231 }
232}
233
234fn get_list_values(expr: &ast::Expr) -> Vec<String> {
236 match expr {
237 ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
238 elts.iter().filter_map(get_string_value).collect()
239 }
240 ast::Expr::List(ast::ExprList { elts, .. }) => {
241 elts.iter().filter_map(get_string_value).collect()
242 }
243 _ => {
244 if let Some(s) = get_string_value(expr) {
245 vec![s]
246 } else {
247 Vec::new()
248 }
249 }
250 }
251}
252
253fn extract_self_requires_calls(body: &[ast::Stmt], method_name: &str) -> Option<Vec<String>> {
255 let mut requires = Vec::new();
256 let mut node_count = 0usize;
257
258 for stmt in body {
259 collect_self_method_calls(stmt, method_name, &mut requires, 0, &mut node_count);
260 if node_count >= MAX_AST_NODES {
261 warn!(
262 "Exceeded MAX_AST_NODES ({}) in extract_self_requires_calls",
263 MAX_AST_NODES
264 );
265 break;
266 }
267 }
268
269 if requires.is_empty() {
270 None
271 } else {
272 Some(requires)
273 }
274}
275
276fn collect_self_method_calls(
277 stmt: &ast::Stmt,
278 method_name: &str,
279 out: &mut Vec<String>,
280 depth: usize,
281 node_count: &mut usize,
282) {
283 if depth > MAX_AST_DEPTH {
284 warn!(
285 "Exceeded MAX_AST_DEPTH ({}) in collect_self_method_calls",
286 MAX_AST_DEPTH
287 );
288 return;
289 }
290 *node_count += 1;
291 if *node_count > MAX_AST_NODES {
292 return;
293 }
294
295 match stmt {
296 ast::Stmt::Expr(ast::StmtExpr { value, .. }) => {
297 if let ast::Expr::Call(call) = value.as_ref()
298 && is_self_method_call(call, method_name)
299 && let Some(arg) = call.arguments.args.first()
300 && let Some(req) = get_string_value(arg)
301 {
302 out.push(truncate_field(req));
303 }
304 }
305 ast::Stmt::If(ast::StmtIf {
306 body,
307 elif_else_clauses,
308 ..
309 }) => {
310 for nested in body {
311 collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
312 }
313 for clause in elif_else_clauses {
314 for nested in &clause.body {
315 collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
316 }
317 }
318 }
319 ast::Stmt::With(ast::StmtWith { body, .. })
320 | ast::Stmt::While(ast::StmtWhile { body, .. })
321 | ast::Stmt::For(ast::StmtFor { body, .. }) => {
322 for nested in body {
323 collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
324 }
325 }
326 ast::Stmt::Try(ast::StmtTry {
327 body,
328 handlers,
329 orelse,
330 finalbody,
331 ..
332 }) => {
333 for nested in body.iter().chain(orelse.iter()).chain(finalbody.iter()) {
334 collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
335 }
336 for handler in handlers {
337 let ast::ExceptHandler::ExceptHandler(handler) = handler;
338 for nested in &handler.body {
339 collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
340 }
341 }
342 }
343 ast::Stmt::Match(ast::StmtMatch { cases, .. }) => {
344 for case in cases {
345 for nested in &case.body {
346 collect_self_method_calls(nested, method_name, out, depth + 1, node_count);
347 }
348 }
349 }
350 _ => {}
351 }
352}
353
354fn is_self_method_call(call: &ast::ExprCall, method_name: &str) -> bool {
355 if let ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = call.func.as_ref()
356 && let ast::Expr::Name(ast::ExprName { id, .. }) = value.as_ref()
357 {
358 return id.as_str() == "self" && attr.as_str() == method_name;
359 }
360 false
361}
362
363pub struct ConanfileTxtParser;
368
369impl PackageParser for ConanfileTxtParser {
370 const PACKAGE_TYPE: PackageType = PackageType::Conan;
371
372 fn is_match(path: &Path) -> bool {
373 path.file_name().is_some_and(|name| name == "conanfile.txt")
374 }
375
376 fn extract_packages(path: &Path) -> Vec<PackageData> {
377 let contents = match read_file_to_string(path, None) {
378 Ok(c) => c,
379 Err(e) => {
380 warn!("Failed to read {}: {}", path.display(), e);
381 return vec![default_package_data(DatasourceId::ConanConanFileTxt)];
382 }
383 };
384
385 let dependencies = parse_conanfile_txt(&contents);
386
387 vec![PackageData {
388 package_type: Some(Self::PACKAGE_TYPE),
389 dependencies,
390 primary_language: Some("C++".to_string()),
391 datasource_id: Some(DatasourceId::ConanConanFileTxt),
392 ..default_package_data(DatasourceId::ConanConanFileTxt)
393 }]
394 }
395}
396
397pub struct ConanLockParser;
402
403impl PackageParser for ConanLockParser {
404 const PACKAGE_TYPE: PackageType = PackageType::Conan;
405
406 fn is_match(path: &Path) -> bool {
407 path.file_name().is_some_and(|name| name == "conan.lock")
408 }
409
410 fn extract_packages(path: &Path) -> Vec<PackageData> {
411 let contents = match read_file_to_string(path, None) {
412 Ok(c) => c,
413 Err(e) => {
414 warn!("Failed to read {}: {}", path.display(), e);
415 return vec![default_package_data(DatasourceId::ConanLock)];
416 }
417 };
418
419 let json: Value = match serde_json::from_str(&contents) {
420 Ok(j) => j,
421 Err(e) => {
422 warn!("Failed to parse JSON in {}: {}", path.display(), e);
423 return vec![default_package_data(DatasourceId::ConanLock)];
424 }
425 };
426
427 let dependencies = parse_conan_lock(&json);
428
429 vec![PackageData {
430 package_type: Some(Self::PACKAGE_TYPE),
431 dependencies,
432 primary_language: Some("C++".to_string()),
433 datasource_id: Some(DatasourceId::ConanLock),
434 ..default_package_data(DatasourceId::ConanLock)
435 }]
436 }
437}
438
439fn parse_conan_reference(ref_str: &str) -> Option<Dependency> {
440 let (name, version_spec) = if let Some((n, v)) = ref_str.split_once('/') {
441 (n.trim(), Some(truncate_field(v.trim().to_string())))
442 } else {
443 (ref_str.trim(), None)
444 };
445
446 let version = version_spec.as_ref().and_then(|v| {
447 if !v.contains('[') && !v.contains('>') && !v.contains('<') {
448 Some(v.clone())
449 } else {
450 None
451 }
452 });
453
454 let purl = if let Some(v) = version.as_deref() {
455 PackageUrl::new("conan", name)
456 .map(|mut p| {
457 let _ = p.with_version(v);
458 p.to_string()
459 })
460 .unwrap_or_else(|_| format!("pkg:conan/{}", name))
461 } else {
462 format!("pkg:conan/{}", name)
463 };
464
465 let is_pinned = version_spec
466 .as_ref()
467 .map(|v| !v.contains('[') && !v.contains('>') && !v.contains('<'))
468 .unwrap_or(false);
469
470 Some(Dependency {
471 purl: Some(truncate_field(purl)),
472 extracted_requirement: version_spec,
473 scope: Some("install".to_string()),
474 is_runtime: Some(true),
475 is_optional: Some(false),
476 is_pinned: Some(is_pinned),
477 is_direct: Some(true),
478 resolved_package: None,
479 extra_data: None,
480 })
481}
482
483fn parse_conanfile_txt(contents: &str) -> Vec<Dependency> {
484 let mut dependencies = Vec::new();
485 let mut current_section = None;
486
487 for line in contents.lines().take(MAX_ITERATION_COUNT) {
488 let trimmed = line.trim();
489
490 if trimmed.is_empty() || trimmed.starts_with('#') {
491 continue;
492 }
493
494 if trimmed.starts_with('[') && trimmed.ends_with(']') {
495 current_section = Some(trimmed.trim_matches(|c| c == '[' || c == ']').to_string());
496 continue;
497 }
498
499 if let Some(ref section) = current_section {
500 let (scope, is_runtime) = match section.as_str() {
501 "requires" => ("install", true),
502 "build_requires" => ("build", false),
503 _ => continue,
504 };
505
506 if let Some(dep) = parse_conan_reference(trimmed) {
507 dependencies.push(Dependency {
508 scope: Some(scope.to_string()),
509 is_runtime: Some(is_runtime),
510 ..dep
511 });
512 }
513 }
514 }
515
516 dependencies
517}
518
519fn parse_conan_lock(json: &Value) -> Vec<Dependency> {
520 let mut dependencies = Vec::new();
521
522 if let Some(graph_lock) = json.get("graph_lock")
523 && let Some(nodes) = graph_lock.get("nodes").and_then(|n| n.as_object())
524 {
525 for (_node_id, node_data) in nodes.iter().take(MAX_ITERATION_COUNT) {
526 if let Some(ref_str) = node_data.get("ref").and_then(|r| r.as_str())
527 && !ref_str.is_empty()
528 && ref_str != "conanfile"
529 && let Some(dep) = parse_conan_reference(ref_str)
530 {
531 dependencies.push(dep);
532 }
533 }
534 }
535
536 dependencies
537}
538
539fn default_package_data(datasource_id: DatasourceId) -> PackageData {
540 PackageData {
541 package_type: Some(ConanFilePyParser::PACKAGE_TYPE),
542 primary_language: Some("C++".to_string()),
543 datasource_id: Some(datasource_id),
544 ..Default::default()
545 }
546}
547
548crate::register_parser!(
549 "Conan C/C++ package manifest",
550 &["**/conanfile.py", "**/conanfile.txt", "**/conan.lock"],
551 "conan",
552 "C++",
553 Some("https://docs.conan.io/"),
554);