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 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
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()];
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
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()
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
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
175/// Get assignment target name (e.g., "name" from "name = 'foo'")
176fn 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
186/// Extract string value from AST expression
187fn 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
198/// Extract list of strings from tuple or list expression
199fn 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
217/// Extract self.requires() method calls from function body
218fn 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
239/// Check if call is self.requires()
240fn 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
249/// Conan conanfile.txt manifest parser.
250///
251/// Extracts dependencies from the simple conanfile.txt format, which uses
252/// INI-style sections to specify runtime and build-time dependencies.
253pub 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
283/// Conan lockfile (conan.lock) parser.
284///
285/// Extracts resolved dependencies from Conan lockfiles, which capture the
286/// complete dependency graph with exact versions and revisions.
287pub 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);