1use std::fs;
25use std::path::Path;
26
27use log::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()];
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()
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()
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
107 for stmt in class_def.body.iter() {
108 match stmt {
109 ast::Stmt::Assign(ast::StmtAssign { targets, value, .. }) => {
110 if let Some(target_name) = get_assignment_target(targets) {
111 match target_name.as_str() {
112 "name" => name = get_string_value(value),
113 "version" => version = get_string_value(value),
114 "description" => description = get_string_value(value),
115 "author" => _author = get_string_value(value),
116 "homepage" => homepage_url = get_string_value(value),
117 "url" => vcs_url = get_string_value(value),
118 "license" => license_list = get_list_values(value),
119 "topics" => keywords = get_list_values(value),
120 "requires" => requires_list = get_list_values(value),
121 _ => {}
122 }
123 }
124 }
125 ast::Stmt::FunctionDef(ast::StmtFunctionDef { body, .. }) => {
126 if let Some(requires) = extract_self_requires_calls(body) {
127 requires_list.extend(requires);
128 }
129 }
130 _ => {}
131 }
132 }
133
134 let dependencies = requires_list
135 .into_iter()
136 .filter_map(|req| parse_conan_reference(&req))
137 .collect();
138
139 let extracted_license = if !license_list.is_empty() {
140 Some(license_list.join(", "))
141 } else {
142 None
143 };
144 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
145 if license_list.len() == 1 {
146 if let Some(normalized) = normalize_declared_license_key(&license_list[0]) {
147 build_declared_license_data(
148 normalized,
149 DeclaredLicenseMatchMetadata::single_line(&license_list[0]),
150 )
151 } else {
152 (None, None, Vec::new())
153 }
154 } else {
155 (None, None, Vec::new())
156 };
157
158 PackageData {
159 name,
160 version,
161 description,
162 homepage_url,
163 vcs_url,
164 keywords,
165 dependencies,
166 declared_license_expression,
167 declared_license_expression_spdx,
168 license_detections,
169 extracted_license_statement: extracted_license,
170 datasource_id: Some(DatasourceId::ConanConanFilePy),
171 ..default_package_data()
172 }
173}
174
175fn get_assignment_target(targets: &[ast::Expr]) -> Option<String> {
177 targets.first().and_then(|target| {
178 if let ast::Expr::Name(ast::ExprName { id, .. }) = target {
179 Some(id.to_string())
180 } else {
181 None
182 }
183 })
184}
185
186fn get_string_value(expr: &ast::Expr) -> Option<String> {
188 if let ast::Expr::Constant(ast::ExprConstant { value, .. }) = expr {
189 match value {
190 ast::Constant::Str(s) => Some(s.to_string()),
191 _ => None,
192 }
193 } else {
194 None
195 }
196}
197
198fn get_list_values(expr: &ast::Expr) -> Vec<String> {
200 match expr {
201 ast::Expr::Tuple(ast::ExprTuple { elts, .. }) => {
202 elts.iter().filter_map(get_string_value).collect()
203 }
204 ast::Expr::List(ast::ExprList { elts, .. }) => {
205 elts.iter().filter_map(get_string_value).collect()
206 }
207 _ => {
208 if let Some(s) = get_string_value(expr) {
209 vec![s]
210 } else {
211 Vec::new()
212 }
213 }
214 }
215}
216
217fn extract_self_requires_calls(body: &[ast::Stmt]) -> Option<Vec<String>> {
219 let mut requires = Vec::new();
220
221 for stmt in body {
222 if let ast::Stmt::Expr(ast::StmtExpr { value, .. }) = stmt
223 && let ast::Expr::Call(call) = value.as_ref()
224 && is_self_requires_call(call)
225 && let Some(arg) = call.args.first()
226 && let Some(req) = get_string_value(arg)
227 {
228 requires.push(req);
229 }
230 }
231
232 if requires.is_empty() {
233 None
234 } else {
235 Some(requires)
236 }
237}
238
239fn is_self_requires_call(call: &ast::ExprCall) -> bool {
241 if let ast::Expr::Attribute(ast::ExprAttribute { value, attr, .. }) = call.func.as_ref()
242 && let ast::Expr::Name(ast::ExprName { id, .. }) = value.as_ref()
243 {
244 return id.as_str() == "self" && attr.as_str() == "requires";
245 }
246 false
247}
248
249pub struct ConanfileTxtParser;
254
255impl PackageParser for ConanfileTxtParser {
256 const PACKAGE_TYPE: PackageType = PackageType::Conan;
257
258 fn is_match(path: &Path) -> bool {
259 path.file_name().is_some_and(|name| name == "conanfile.txt")
260 }
261
262 fn extract_packages(path: &Path) -> Vec<PackageData> {
263 let contents = match fs::read_to_string(path) {
264 Ok(c) => c,
265 Err(e) => {
266 warn!("Failed to read {}: {}", path.display(), e);
267 return vec![default_package_data()];
268 }
269 };
270
271 let dependencies = parse_conanfile_txt(&contents);
272
273 vec![PackageData {
274 package_type: Some(Self::PACKAGE_TYPE),
275 dependencies,
276 primary_language: Some("C++".to_string()),
277 datasource_id: Some(DatasourceId::ConanConanFileTxt),
278 ..default_package_data()
279 }]
280 }
281}
282
283pub struct ConanLockParser;
288
289impl PackageParser for ConanLockParser {
290 const PACKAGE_TYPE: PackageType = PackageType::Conan;
291
292 fn is_match(path: &Path) -> bool {
293 path.file_name().is_some_and(|name| name == "conan.lock")
294 }
295
296 fn extract_packages(path: &Path) -> Vec<PackageData> {
297 let contents = match fs::read_to_string(path) {
298 Ok(c) => c,
299 Err(e) => {
300 warn!("Failed to read {}: {}", path.display(), e);
301 return vec![default_package_data()];
302 }
303 };
304
305 let json: Value = match serde_json::from_str(&contents) {
306 Ok(j) => j,
307 Err(e) => {
308 warn!("Failed to parse JSON in {}: {}", path.display(), e);
309 return vec![default_package_data()];
310 }
311 };
312
313 let dependencies = parse_conan_lock(&json);
314
315 vec![PackageData {
316 package_type: Some(Self::PACKAGE_TYPE),
317 dependencies,
318 primary_language: Some("C++".to_string()),
319 datasource_id: Some(DatasourceId::ConanLock),
320 ..default_package_data()
321 }]
322 }
323}
324
325fn parse_conan_reference(ref_str: &str) -> Option<Dependency> {
326 let (name, version_spec) = if let Some((n, v)) = ref_str.split_once('/') {
327 (n.trim(), Some(v.trim().to_string()))
328 } else {
329 (ref_str.trim(), None)
330 };
331
332 let version = version_spec.as_ref().and_then(|v| {
333 if !v.contains('[') && !v.contains('>') && !v.contains('<') {
334 Some(v.clone())
335 } else {
336 None
337 }
338 });
339
340 let purl = if let Some(v) = version.as_deref() {
341 PackageUrl::new("conan", name)
342 .map(|mut p| {
343 let _ = p.with_version(v);
344 p.to_string()
345 })
346 .unwrap_or_else(|_| format!("pkg:conan/{}", name))
347 } else {
348 format!("pkg:conan/{}", name)
349 };
350
351 let is_pinned = version_spec
352 .as_ref()
353 .map(|v| !v.contains('[') && !v.contains('>') && !v.contains('<'))
354 .unwrap_or(false);
355
356 Some(Dependency {
357 purl: Some(purl),
358 extracted_requirement: version_spec,
359 scope: Some("install".to_string()),
360 is_runtime: Some(true),
361 is_optional: Some(false),
362 is_pinned: Some(is_pinned),
363 is_direct: Some(true),
364 resolved_package: None,
365 extra_data: None,
366 })
367}
368
369fn parse_conanfile_txt(contents: &str) -> Vec<Dependency> {
370 let mut dependencies = Vec::new();
371 let mut current_section = None;
372
373 for line in contents.lines() {
374 let trimmed = line.trim();
375
376 if trimmed.is_empty() || trimmed.starts_with('#') {
377 continue;
378 }
379
380 if trimmed.starts_with('[') && trimmed.ends_with(']') {
381 current_section = Some(trimmed.trim_matches(|c| c == '[' || c == ']').to_string());
382 continue;
383 }
384
385 if let Some(ref section) = current_section {
386 let (scope, is_runtime) = match section.as_str() {
387 "requires" => ("install", true),
388 "build_requires" => ("build", false),
389 _ => continue,
390 };
391
392 if let Some(dep) = parse_conan_reference(trimmed) {
393 dependencies.push(Dependency {
394 scope: Some(scope.to_string()),
395 is_runtime: Some(is_runtime),
396 ..dep
397 });
398 }
399 }
400 }
401
402 dependencies
403}
404
405fn parse_conan_lock(json: &Value) -> Vec<Dependency> {
406 let mut dependencies = Vec::new();
407
408 if let Some(graph_lock) = json.get("graph_lock")
409 && let Some(nodes) = graph_lock.get("nodes").and_then(|n| n.as_object())
410 {
411 for (_node_id, node_data) in nodes {
412 if let Some(ref_str) = node_data.get("ref").and_then(|r| r.as_str())
413 && !ref_str.is_empty()
414 && ref_str != "conanfile"
415 && let Some(dep) = parse_conan_reference(ref_str)
416 {
417 dependencies.push(dep);
418 }
419 }
420 }
421
422 dependencies
423}
424
425fn default_package_data() -> PackageData {
426 PackageData {
427 package_type: Some(ConanFilePyParser::PACKAGE_TYPE),
428 primary_language: Some("C++".to_string()),
429 ..Default::default()
430 }
431}
432
433crate::register_parser!(
434 "Conan C/C++ package manifest",
435 &["**/conanfile.py", "**/conanfile.txt", "**/conan.lock"],
436 "conan",
437 "C++",
438 Some("https://docs.conan.io/"),
439);