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 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
39/// Conan conanfile.py recipe parser.
40///
41/// Parses Python-based Conan recipe files using AST analysis (no code execution).
42/// Extracts package metadata and dependencies from ConanFile class attributes.
43pub 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
71/// Parse conanfile.py AST to extract ConanFile class attributes
72fn 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
84/// Check if class inherits from ConanFile
85fn 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
95/// Extract package data from ConanFile class definition
96fn 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
189/// Get assignment target name (e.g., "name" from "name = 'foo'")
190fn 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
200/// Extract string value from AST expression
201fn 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
212/// Extract list of strings from tuple or list expression
213fn 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
231/// Extract self.requires() method calls from function body
232fn 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
308/// Conan conanfile.txt manifest parser.
309///
310/// Extracts dependencies from the simple conanfile.txt format, which uses
311/// INI-style sections to specify runtime and build-time dependencies.
312pub 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
342/// Conan lockfile (conan.lock) parser.
343///
344/// Extracts resolved dependencies from Conan lockfiles, which capture the
345/// complete dependency graph with exact versions and revisions.
346pub 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);