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