Skip to main content

provenant/parsers/
buck.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Buck BUILD and METADATA.bzl parsers
7//!
8//! Extracts package metadata from Buck build system files using Starlark (Python-like) syntax.
9//!
10//! ## Features
11//! - **BuckBuildParser**: Parses BUCK files with multiple package support
12//! - **BuckMetadataBzlParser**: Parses METADATA.bzl dictionary assignments with package_url support
13//!
14//! ## Usage
15//! - `BuckBuildParser::extract_packages()` - Returns ALL packages from BUCK file
16//! - `BuckMetadataBzlParser::extract_first_package()` - Returns single package from METADATA.bzl
17//!
18//! ## Reference
19//! Python implementation: `reference/scancode-toolkit/src/packagedcode/build.py`
20//! - BuckPackageHandler (lines 310-325)
21//! - BuckMetadataBzlHandler (lines 328-432)
22
23use std::collections::HashMap;
24use std::path::Path;
25
26use crate::parser_warn as warn;
27use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
28use packageurl::PackageUrl;
29use starlark_syntax::syntax::ast;
30use starlark_syntax::syntax::{AstModule, Dialect};
31
32use crate::models::{DatasourceId, PackageData, PackageType, Party, PartyType, Sha1Digest};
33
34use super::PackageParser;
35use super::metadata::ParserMetadata;
36
37type StarlarkCallArgs = ast::CallArgsP<ast::AstNoPayload>;
38
39struct StarlarkCall<'a> {
40    func: &'a ast::AstExpr,
41    args: &'a StarlarkCallArgs,
42}
43
44/// Parser for Buck BUCK files (build rules)
45pub struct BuckBuildParser;
46
47impl PackageParser for BuckBuildParser {
48    const PACKAGE_TYPE: PackageType = PackageType::Buck;
49
50    fn metadata() -> Vec<ParserMetadata> {
51        vec![ParserMetadata {
52            description: "Buck build file and METADATA.bzl",
53            file_patterns: &["**/BUCK", "**/METADATA.bzl"],
54            package_type: "buck",
55            primary_language: "",
56            documentation_url: Some("https://buck.build/"),
57        }]
58    }
59
60    fn is_match(path: &Path) -> bool {
61        path.file_name()
62            .and_then(|name| name.to_str())
63            .is_some_and(|name| name == "BUCK")
64    }
65
66    fn extract_packages(path: &Path) -> Vec<PackageData> {
67        match parse_buck_build(path) {
68            Ok(packages) if !packages.is_empty() => packages,
69            Ok(_) => vec![fallback_package_data(path)],
70            Err(e) => {
71                warn!("Failed to parse Buck BUCK file {:?}: {}", path, e);
72                vec![fallback_package_data(path)]
73            }
74        }
75    }
76}
77
78/// Parser for Buck METADATA.bzl files (metadata dictionaries)
79pub struct BuckMetadataBzlParser;
80
81impl PackageParser for BuckMetadataBzlParser {
82    const PACKAGE_TYPE: PackageType = PackageType::Buck;
83
84    fn is_match(path: &Path) -> bool {
85        path.file_name()
86            .and_then(|name| name.to_str())
87            .is_some_and(|name| name == "METADATA.bzl")
88    }
89
90    fn extract_packages(path: &Path) -> Vec<PackageData> {
91        vec![match parse_metadata_bzl(path) {
92            Ok(pkg) => pkg,
93            Err(e) => {
94                warn!("Failed to parse Buck METADATA.bzl {:?}: {}", path, e);
95                PackageData {
96                    datasource_id: Some(DatasourceId::BuckMetadata),
97                    ..Default::default()
98                }
99            }
100        }]
101    }
102}
103
104/// Parse a Buck BUCK file (same logic as Bazel BUILD)
105fn parse_buck_build(path: &Path) -> Result<Vec<PackageData>, String> {
106    let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
107    let module = parse_starlark_module("<BUCK>", content)?;
108
109    let mut packages = Vec::new();
110
111    for statement in top_level_statements(&module)
112        .iter()
113        .take(MAX_ITERATION_COUNT)
114    {
115        if let Some(package_data) = extract_build_package_from_statement(statement) {
116            packages.push(package_data);
117        }
118    }
119
120    Ok(packages)
121}
122
123/// Parse a Buck METADATA.bzl file
124fn parse_metadata_bzl(path: &Path) -> Result<PackageData, String> {
125    let content = read_file_to_string(path, None).map_err(|e| e.to_string())?;
126    let module = parse_starlark_module("<METADATA.bzl>", content)?;
127
128    for statement in top_level_statements(&module)
129        .iter()
130        .take(MAX_ITERATION_COUNT)
131    {
132        if let Some(dict) = extract_metadata_assignment_dict(statement) {
133            return Ok(extract_metadata_dict(dict));
134        }
135    }
136
137    // No METADATA found
138    Ok(PackageData {
139        datasource_id: Some(DatasourceId::BuckMetadata),
140        ..Default::default()
141    })
142}
143
144fn parse_starlark_module(filename: &str, content: String) -> Result<AstModule, String> {
145    let content = preprocess_starlark_content(&content);
146    let dialect = Dialect {
147        enable_top_level_stmt: true,
148        ..Dialect::Standard
149    };
150    AstModule::parse(filename, content, &dialect).map_err(|error| error.to_string())
151}
152
153fn preprocess_starlark_content(content: &str) -> String {
154    let mut normalized = String::with_capacity(content.len());
155    let mut pending_oss_disable_indent: Option<String> = None;
156
157    for raw_line in content.lines() {
158        let trimmed_start = raw_line.trim_start();
159        let indent_len = raw_line.len() - trimmed_start.len();
160        let indent = &raw_line[..indent_len];
161
162        if trimmed_start.starts_with('#') && trimmed_start.contains("@oss-disable") {
163            pending_oss_disable_indent = Some(indent.to_string());
164            continue;
165        }
166
167        if let Some(marker_index) = raw_line.find("# @oss-enable") {
168            let code = raw_line[..marker_index].trim_end();
169            if !code.is_empty() {
170                if let Some(disabled_indent) = pending_oss_disable_indent.take() {
171                    normalized.push_str(&disabled_indent);
172                    normalized.push_str(code.trim_start());
173                } else {
174                    normalized.push_str(code);
175                }
176                normalized.push('\n');
177            }
178            continue;
179        }
180
181        pending_oss_disable_indent = None;
182        normalized.push_str(raw_line);
183        normalized.push('\n');
184    }
185
186    if !content.ends_with('\n') && normalized.ends_with('\n') {
187        normalized.pop();
188    }
189
190    normalized
191}
192
193fn top_level_statements(module: &AstModule) -> &[ast::AstStmt] {
194    match &module.statement().node {
195        ast::StmtP::Statements(statements) => statements,
196        _ => std::slice::from_ref(module.statement()),
197    }
198}
199
200fn extract_metadata_assignment_dict(
201    statement: &ast::AstStmt,
202) -> Option<&[(ast::AstExpr, ast::AstExpr)]> {
203    let ast::StmtP::Assign(assign) = &statement.node else {
204        return None;
205    };
206    let ast::AssignTargetP::Identifier(target) = &assign.lhs.node else {
207        return None;
208    };
209    if target.node.ident != "METADATA" {
210        return None;
211    }
212    match &assign.rhs.node {
213        ast::ExprP::Dict(items) => Some(items.as_slice()),
214        _ => None,
215    }
216}
217
218/// Extract metadata from a dictionary AST node
219fn extract_metadata_dict(dict: &[(ast::AstExpr, ast::AstExpr)]) -> PackageData {
220    let mut fields: HashMap<String, MetadataValue> = HashMap::new();
221
222    for (key, value) in dict.iter().take(MAX_ITERATION_COUNT) {
223        let Some(key_name) = expr_as_string(key) else {
224            continue;
225        };
226        let Some(metadata_value) = metadata_value_from_expr(value) else {
227            continue;
228        };
229
230        fields.insert(key_name, metadata_value);
231    }
232
233    build_package_from_metadata(fields)
234}
235
236fn get_metadata_string(fields: &HashMap<String, MetadataValue>, keys: &[&str]) -> Option<String> {
237    keys.iter().find_map(|key| match fields.get(*key) {
238        Some(MetadataValue::String(value)) => Some(value.clone()),
239        _ => None,
240    })
241}
242
243fn get_metadata_list(
244    fields: &HashMap<String, MetadataValue>,
245    keys: &[&str],
246) -> Option<Vec<String>> {
247    keys.iter().find_map(|key| match fields.get(*key) {
248        Some(MetadataValue::List(values)) => Some(values.clone()),
249        _ => None,
250    })
251}
252
253/// Metadata value types
254enum MetadataValue {
255    String(String),
256    List(Vec<String>),
257}
258
259fn split_buck_license_values(values: &[String]) -> (Vec<String>, Vec<String>) {
260    let mut statements = Vec::new();
261    let mut references = Vec::new();
262
263    for value in values {
264        if is_probable_local_license_reference(value) {
265            references.push(value.clone());
266        } else {
267            statements.push(value.clone());
268        }
269    }
270
271    (statements, references)
272}
273
274fn is_probable_local_license_reference(value: &str) -> bool {
275    let trimmed = value.trim();
276    if trimmed.is_empty() {
277        return false;
278    }
279
280    let lower = trimmed.to_ascii_lowercase();
281    lower.contains('/')
282        || lower.contains('\\')
283        || lower.starts_with("license")
284        || lower.starts_with("licence")
285        || lower.starts_with("copying")
286        || lower.starts_with("notice")
287        || lower.starts_with("copyright")
288        || lower.ends_with(".txt")
289        || lower.ends_with(".md")
290        || lower.ends_with(".rst")
291        || lower.ends_with(".html")
292}
293
294fn insert_license_reference_extra_data(
295    extra_data: &mut HashMap<String, serde_json::Value>,
296    references: &[String],
297) {
298    match references {
299        [] => {}
300        [reference] => {
301            extra_data.insert(
302                "license_file".to_string(),
303                serde_json::Value::String(reference.clone()),
304            );
305        }
306        _ => {
307            extra_data.insert(
308                "license_files".to_string(),
309                serde_json::Value::Array(
310                    references
311                        .iter()
312                        .cloned()
313                        .map(serde_json::Value::String)
314                        .collect(),
315                ),
316            );
317        }
318    }
319}
320
321/// Build PackageData from extracted metadata fields
322fn build_package_from_metadata(fields: HashMap<String, MetadataValue>) -> PackageData {
323    let mut pkg = PackageData {
324        datasource_id: Some(DatasourceId::BuckMetadata),
325        ..Default::default()
326    };
327    let mut license_references = Vec::new();
328
329    // Extract name
330    if let Some(name) = get_metadata_string(&fields, &["name"]) {
331        pkg.name = Some(truncate_field(name));
332    }
333
334    // Extract version
335    if let Some(version) = get_metadata_string(&fields, &["version"]) {
336        pkg.version = Some(truncate_field(version));
337    }
338
339    // Extract namespace from explicit metadata when present.
340    if let Some(namespace) = get_metadata_string(&fields, &["namespace"]) {
341        pkg.namespace = Some(truncate_field(namespace));
342    }
343
344    // Extract package type from canonical or legacy ecosystem fields.
345    // Intentionally ignore `upstream_type`: it does not describe the purl package type.
346    if let Some(ecosystem) = get_metadata_string(&fields, &["ecosystem", "type", "package_type"])
347        && let Ok(package_type) = ecosystem.parse::<PackageType>()
348    {
349        pkg.package_type = Some(package_type);
350    }
351
352    // Extract licenses (licenses or license_expression)
353    if let Some(licenses) = get_metadata_list(&fields, &["licenses"]) {
354        let (license_statements, references) = split_buck_license_values(&licenses);
355        license_references = references;
356        let extracted_license_statement = if !license_statements.is_empty() {
357            Some(license_statements.join(", "))
358        } else if !license_references.is_empty() {
359            Some(license_references.join(", "))
360        } else {
361            None
362        };
363        pkg.extracted_license_statement = extracted_license_statement.map(truncate_field);
364    } else if let Some(license_expression) = get_metadata_string(&fields, &["license_expression"]) {
365        pkg.extracted_license_statement = Some(truncate_field(license_expression));
366    }
367
368    if let Some(copyright) = get_metadata_list(&fields, &["copyrights"]) {
369        if !copyright.is_empty() {
370            pkg.copyright = Some(truncate_field(copyright.join("\n")));
371        }
372    } else if let Some(copyright) = get_metadata_string(&fields, &["copyright"]) {
373        pkg.copyright = Some(truncate_field(copyright));
374    }
375
376    // Extract homepage (upstream_address, upstream_url, or homepage_url)
377    if let Some(homepage_url) = get_metadata_string(
378        &fields,
379        &["upstream_address", "upstream_url", "homepage_url"],
380    ) {
381        pkg.homepage_url = Some(truncate_field(homepage_url));
382    }
383
384    // Extract download_url
385    if let Some(download_url) = get_metadata_string(&fields, &["download_url"]) {
386        pkg.download_url = Some(truncate_field(download_url));
387    }
388
389    // Extract vcs_url
390    if let Some(vcs_url) = get_metadata_string(&fields, &["vcs_url"]) {
391        pkg.vcs_url = Some(truncate_field(vcs_url));
392    }
393
394    // Extract sha1 (download_archive_sha1)
395    if let Some(sha1) = get_metadata_string(&fields, &["download_archive_sha1"]) {
396        pkg.sha1 = Sha1Digest::from_hex(&sha1).ok();
397    }
398
399    // Extract maintainers
400    if let Some(maintainers) = get_metadata_list(&fields, &["maintainers"]) {
401        pkg.parties.extend(maintainers.iter().map(|name| Party {
402            r#type: Some(PartyType::Organization),
403            name: Some(name.clone()),
404            role: Some("maintainer".to_string()),
405            email: None,
406            url: None,
407            organization: None,
408            organization_url: None,
409            timezone: None,
410        }));
411    }
412
413    if let Some(vendor) = get_metadata_string(&fields, &["vendor", "publisher"]) {
414        pkg.parties.push(Party {
415            r#type: None,
416            name: Some(vendor),
417            role: Some("publisher".to_string()),
418            email: None,
419            url: None,
420            organization: None,
421            organization_url: None,
422            timezone: None,
423        });
424    }
425
426    // Extract extra_data fields
427    let mut extra_data = HashMap::new();
428    if let Some(vcs_commit_hash) = get_metadata_string(&fields, &["vcs_commit_hash"]) {
429        extra_data.insert(
430            "vcs_commit_hash".to_string(),
431            serde_json::Value::String(vcs_commit_hash),
432        );
433    }
434    if let Some(upstream_hash) =
435        get_metadata_string(&fields, &["upstream_hash", "upstream_commit_hash"])
436    {
437        extra_data.insert(
438            "upstream_hash".to_string(),
439            serde_json::Value::String(upstream_hash),
440        );
441    }
442    if let Some(upstream_branch) = get_metadata_string(&fields, &["upstream_branch"]) {
443        extra_data.insert(
444            "upstream_branch".to_string(),
445            serde_json::Value::String(upstream_branch),
446        );
447    }
448    insert_license_reference_extra_data(&mut extra_data, &license_references);
449    if !extra_data.is_empty() {
450        pkg.extra_data = Some(extra_data);
451    }
452
453    // Parse package_url if present and update package fields
454    if let Some(purl_str) = get_metadata_string(&fields, &["package_url"])
455        && let Ok(purl) = purl_str.parse::<PackageUrl>()
456    {
457        pkg.purl = Some(truncate_field(purl.to_string()));
458
459        if let Ok(package_type) = purl.ty().parse::<PackageType>() {
460            pkg.package_type = Some(package_type);
461        }
462        if let Some(ns) = purl.namespace() {
463            pkg.namespace = Some(truncate_field(ns.to_string()));
464        }
465        pkg.name = Some(truncate_field(purl.name().to_string()));
466        if let Some(ver) = purl.version() {
467            pkg.version = Some(truncate_field(ver.to_string()));
468        }
469        // Qualifiers
470        if !purl.qualifiers().is_empty() {
471            let quals: HashMap<String, String> = purl
472                .qualifiers()
473                .iter()
474                .map(|(k, v)| (k.to_string(), v.to_string()))
475                .collect();
476            pkg.qualifiers = Some(quals);
477        }
478        // Subpath
479        if let Some(sp) = purl.subpath() {
480            pkg.subpath = Some(sp.to_string());
481        }
482    }
483
484    pkg
485}
486
487fn metadata_value_from_expr(expr: &ast::AstExpr) -> Option<MetadataValue> {
488    if let Some(string) = expr_as_string(expr) {
489        return Some(MetadataValue::String(string));
490    }
491
492    let items = match &expr.node {
493        ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
494        _ => return None,
495    };
496    let values: Vec<_> = items
497        .iter()
498        .take(MAX_ITERATION_COUNT)
499        .filter_map(expr_as_string)
500        .collect();
501    (!values.is_empty()).then_some(MetadataValue::List(values))
502}
503
504/// Extract package data from a single AST statement (for BUCK files)
505fn extract_build_package_from_statement(statement: &ast::AstStmt) -> Option<PackageData> {
506    let call = extract_call(statement)?;
507    let rule_name = match &call.func.node {
508        ast::ExprP::Identifier(identifier) => identifier.node.ident.as_str(),
509        _ => return None,
510    };
511
512    if !check_rule_name_ending(rule_name) {
513        return None;
514    }
515
516    let name = extract_named_kwarg_string(&call, "name");
517    let licenses = extract_named_kwarg_string_list(&call, "licenses");
518
519    let package_name = name?;
520    let (license_statements, license_references) = licenses
521        .as_deref()
522        .map(split_buck_license_values)
523        .unwrap_or_default();
524    let extracted_license_statement = if !license_statements.is_empty() {
525        Some(truncate_field(license_statements.join(", ")))
526    } else if !license_references.is_empty() {
527        Some(truncate_field(license_references.join(", ")))
528    } else {
529        None
530    };
531    let mut extra_data = HashMap::new();
532    insert_license_reference_extra_data(&mut extra_data, &license_references);
533
534    Some(PackageData {
535        package_type: Some(BuckBuildParser::PACKAGE_TYPE),
536        name: Some(truncate_field(package_name)),
537        extracted_license_statement,
538        extra_data: (!extra_data.is_empty()).then_some(extra_data),
539        datasource_id: Some(DatasourceId::BuckFile),
540        ..Default::default()
541    })
542}
543
544fn extract_call(statement: &ast::AstStmt) -> Option<StarlarkCall<'_>> {
545    match &statement.node {
546        ast::StmtP::Expression(expr) => extract_call_expr(expr),
547        ast::StmtP::Assign(assign) => extract_call_expr(&assign.rhs),
548        _ => None,
549    }
550}
551
552fn extract_call_expr(expr: &ast::AstExpr) -> Option<StarlarkCall<'_>> {
553    match &expr.node {
554        ast::ExprP::Call(func, args) => Some(StarlarkCall { func, args }),
555        _ => None,
556    }
557}
558
559fn extract_named_kwarg<'a>(call: &'a StarlarkCall<'_>, key: &str) -> Option<&'a ast::AstExpr> {
560    call.args
561        .args
562        .iter()
563        .find_map(|argument| match &argument.node {
564            ast::ArgumentP::Named(name, value) if name.node == key => Some(value),
565            _ => None,
566        })
567}
568
569fn extract_named_kwarg_string(call: &StarlarkCall<'_>, key: &str) -> Option<String> {
570    extract_named_kwarg(call, key).and_then(expr_as_string)
571}
572
573fn extract_named_kwarg_string_list(call: &StarlarkCall<'_>, key: &str) -> Option<Vec<String>> {
574    let expr = extract_named_kwarg(call, key)?;
575    let items = match &expr.node {
576        ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
577        _ => return None,
578    };
579    let values: Vec<_> = items
580        .iter()
581        .take(MAX_ITERATION_COUNT)
582        .filter_map(expr_as_string)
583        .collect();
584    (!values.is_empty()).then_some(values)
585}
586
587fn expr_as_string(expr: &ast::AstExpr) -> Option<String> {
588    match &expr.node {
589        ast::ExprP::Literal(ast::AstLiteral::String(value)) => Some(value.node.clone()),
590        _ => None,
591    }
592}
593
594/// Check if rule name ends with "binary" or "library"
595fn check_rule_name_ending(rule_name: &str) -> bool {
596    rule_name.ends_with("binary") || rule_name.ends_with("library")
597}
598
599/// Create fallback package data using parent directory name
600fn fallback_package_data(path: &Path) -> PackageData {
601    let name = path
602        .parent()
603        .and_then(|p| p.file_name())
604        .and_then(|n| n.to_str())
605        .map(|s| s.to_string());
606
607    PackageData {
608        package_type: Some(BuckBuildParser::PACKAGE_TYPE),
609        name,
610        datasource_id: Some(DatasourceId::BuckFile),
611        ..Default::default()
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use std::path::PathBuf;
619
620    #[test]
621    fn test_buck_build_is_match() {
622        assert!(BuckBuildParser::is_match(&PathBuf::from("BUCK")));
623        assert!(BuckBuildParser::is_match(&PathBuf::from("path/to/BUCK")));
624        assert!(!BuckBuildParser::is_match(&PathBuf::from("BUILD")));
625        assert!(!BuckBuildParser::is_match(&PathBuf::from("buck")));
626    }
627
628    #[test]
629    fn test_metadata_bzl_is_match() {
630        assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
631            "METADATA.bzl"
632        )));
633        assert!(BuckMetadataBzlParser::is_match(&PathBuf::from(
634            "path/to/METADATA.bzl"
635        )));
636        assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from(
637            "metadata.bzl"
638        )));
639        assert!(!BuckMetadataBzlParser::is_match(&PathBuf::from("METADATA")));
640    }
641
642    #[test]
643    fn test_check_rule_name_ending() {
644        assert!(check_rule_name_ending("android_binary"));
645        assert!(check_rule_name_ending("android_library"));
646        assert!(check_rule_name_ending("java_binary"));
647        assert!(!check_rule_name_ending("filegroup"));
648    }
649
650    #[test]
651    fn test_preprocess_starlark_content_handles_oss_guarded_alternatives() {
652        let content = r#"# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_unittest.bzl", "rust_unittest")
653prelude = native
654
655# @oss-disable: rust_unittest(
656    rust_test( # @oss-enable
657        name = "test",
658    )
659
660platform_utils = None # @oss-enable
661"#;
662
663        let normalized = preprocess_starlark_content(content);
664
665        assert!(!normalized.contains("@oss-disable"));
666        assert!(!normalized.contains("@oss-enable"));
667        assert!(normalized.contains("rust_test("));
668        assert!(normalized.contains("platform_utils = None"));
669        assert!(!normalized.contains("    rust_test("));
670    }
671
672    #[test]
673    fn test_parse_buck_build_with_oss_guarded_rule() {
674        let content = r#"# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_library.bzl", "rust_library")
675# @oss-disable[end= ]: load("@fbsource//tools/build_defs:rust_unittest.bzl", "rust_unittest")
676
677oncall("build_infra")
678
679rust_library(
680    name = "library",
681    srcs = ["src/lib.rs"],
682)
683
684# @oss-disable: rust_unittest(
685    rust_test( # @oss-enable
686    name = "test",
687    srcs = ["tests/test.rs"],
688)
689"#;
690
691        let temp_dir = tempfile::tempdir().unwrap();
692        let buck_path = temp_dir.path().join("BUCK");
693        std::fs::write(&buck_path, content).unwrap();
694
695        let packages = parse_buck_build(&buck_path).expect("BUCK file should parse");
696
697        assert_eq!(packages.len(), 1);
698        assert_eq!(packages[0].package_type, Some(PackageType::Buck));
699        assert_eq!(packages[0].name.as_deref(), Some("library"));
700    }
701}