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