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