Skip to main content

provenant/parsers/
buck.rs

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