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