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