Skip to main content

provenant/parsers/
conan.rs

1//! Parser for Conan C/C++ package manager manifests.
2//!
3//! Extracts package metadata and dependencies from Conan manifest files.
4//!
5//! # Supported Formats
6//! - conanfile.py (Recipe files with Python AST parsing)
7//! - conanfile.txt (Simple dependency specification format)
8//! - conan.lock (Lockfile with resolved dependency graph)
9//!
10//! # Key Features
11//! - AST-based conanfile.py parsing (NO code execution)
12//! - Dependency extraction from [requires] and [build_requires] sections
13//! - Version constraint parsing for Conan reference format (name/version@user/channel)
14//! - Package URL (purl) generation for resolved dependencies
15//! - Lockfile dependency graph parsing
16//!
17//! # Implementation Notes
18//! - conanfile.py: AST extracts class attributes and self.requires() calls
19//! - conanfile.txt sections: [requires] = runtime, [build_requires] = build-time
20//! - conan.lock uses JSON format with graph_lock.nodes structure
21//! - Version constraints use Conan-specific operators: [>, <, ranges]
22//! - Only exact versions (without operators) are extracted as pinned versions
23
24use 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
40/// Conan conanfile.py recipe parser.
41///
42/// Parses Python-based Conan recipe files using AST analysis (no code execution).
43/// Extracts package metadata and dependencies from ConanFile class attributes.
44pub 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
72/// Parse conanfile.py AST to extract ConanFile class attributes
73fn 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
85/// Check if class inherits from ConanFile
86fn 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
96/// Extract package data from ConanFile class definition
97fn 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
190/// Get assignment target name (e.g., "name" from "name = 'foo'")
191fn 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
201/// Extract string value from AST expression
202fn 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
211/// Extract list of strings from tuple or list expression
212fn 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
230/// Extract self.requires() method calls from function body
231fn 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
314/// Conan conanfile.txt manifest parser.
315///
316/// Extracts dependencies from the simple conanfile.txt format, which uses
317/// INI-style sections to specify runtime and build-time dependencies.
318pub 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
348/// Conan lockfile (conan.lock) parser.
349///
350/// Extracts resolved dependencies from Conan lockfiles, which capture the
351/// complete dependency graph with exact versions and revisions.
352pub 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);