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