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