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