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