Skip to main content

provenant/parsers/
bazel.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//! Bazel BUILD file parser
8//!
9//! Extracts package metadata from Bazel BUILD files using Starlark (Python-like) syntax.
10//!
11//! ## Features
12//! - Parses Starlark syntax using starlark_syntax
13//! - Extracts build rules ending with "binary" or "library" (e.g., cc_binary, cc_library)
14//! - Extracts name and licenses fields from rule arguments
15//! - Falls back to parent directory name if no rules found
16//! - **Supports multiple packages**: `extract_packages()` returns all rules (100% parity)
17//!
18//! ## Usage
19//! - `extract_first_package()` - Returns first package (convenience method)
20//! - `extract_packages()` - Returns ALL packages (recommended for BUILD files)
21//!
22//! ## Reference
23//! Python implementation: `reference/scancode-toolkit/src/packagedcode/build.py` (BazelBuildHandler)
24
25use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
26use crate::parsers::utils::{RecursionGuard, capped_iteration_limit, truncate_field};
27use packageurl::PackageUrl;
28use serde_json::{Map as JsonMap, Value as JsonValue};
29use std::path::Path;
30
31use crate::parser_warn as warn;
32use starlark_syntax::syntax::ast;
33use starlark_syntax::syntax::{AstModule, Dialect};
34
35use super::PackageParser;
36use super::metadata::ParserMetadata;
37
38type StarlarkCallArgs = ast::CallArgsP<ast::AstNoPayload>;
39const SCANCODE_SIMPLE_TOP_LEVEL_KEY: &str = "scancode_simple_top_level";
40
41struct StarlarkCall<'a> {
42    func: &'a ast::AstExpr,
43    args: &'a StarlarkCallArgs,
44}
45
46pub struct BazelBuildParser;
47
48impl PackageParser for BazelBuildParser {
49    const PACKAGE_TYPE: PackageType = PackageType::Bazel;
50
51    fn metadata() -> Vec<ParserMetadata> {
52        vec![ParserMetadata {
53            description: "Bazel BUILD file",
54            file_patterns: &["**/BUILD"],
55            package_type: "bazel",
56            primary_language: "",
57            documentation_url: Some("https://bazel.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 == "BUILD")
65    }
66
67    fn extract_packages(path: &Path) -> Vec<PackageData> {
68        match parse_bazel_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 Bazel BUILD file {:?}: {}", path, e);
73                vec![fallback_package_data(path)]
74            }
75        }
76    }
77}
78
79/// Parse a Bazel BUILD file and extract all package data
80fn parse_bazel_build(path: &Path) -> Result<Vec<PackageData>, String> {
81    let content =
82        crate::parsers::utils::read_file_to_string(path, None).map_err(|e| e.to_string())?;
83    let module = parse_starlark_module("<BUILD>", content)?;
84    let scancode_simple_top_level = is_scancode_simple_top_level_module(&module);
85
86    let mut packages = Vec::new();
87
88    let statements = top_level_statements(&module);
89    let limit = capped_iteration_limit(statements.len(), "bazel BUILD top-level statements");
90    for statement in statements.iter().take(limit) {
91        if let Some(mut package_data) = extract_package_from_statement(statement) {
92            set_scancode_simple_top_level(&mut package_data, scancode_simple_top_level);
93            packages.push(package_data);
94        }
95    }
96
97    Ok(packages)
98}
99
100/// Extract package data from a single AST statement
101fn extract_package_from_statement(statement: &ast::AstStmt) -> Option<PackageData> {
102    let call = extract_call(statement)?;
103    let rule_name = extract_call_name(&call)?;
104
105    if !check_rule_name_ending(rule_name) {
106        return None;
107    }
108
109    let name = extract_string_kwarg(&call, "name")?;
110    let licenses = extract_string_list_kwarg(&call, "licenses");
111    let purl = build_bazel_purl(&name, None).map(truncate_field);
112
113    // A Bazel `licenses` attribute lists license *file* references, not a license
114    // expression. Record them as declared-license file references so reference-
115    // following resolves each to the referenced file's license (else
116    // `unknown-license-reference`), rather than free-text detecting the attribute.
117    let extra_data = licenses.as_ref().map(|licenses| {
118        let references = licenses
119            .iter()
120            .map(|license| JsonValue::String(truncate_field(license.clone())))
121            .collect();
122        let mut map = JsonMap::new();
123        map.insert("license_files".to_string(), JsonValue::Array(references));
124        map.into_iter().collect()
125    });
126
127    Some(PackageData {
128        package_type: Some(BazelBuildParser::PACKAGE_TYPE),
129        name: Some(truncate_field(name)),
130        extracted_license_statement: licenses.map(|licenses| truncate_field(licenses.join(", "))),
131        datasource_id: Some(DatasourceId::BazelBuild),
132        purl,
133        extra_data,
134        ..Default::default()
135    })
136}
137
138/// Check if rule name ends with "binary" or "library"
139fn check_rule_name_ending(rule_name: &str) -> bool {
140    rule_name.ends_with("binary") || rule_name.ends_with("library")
141}
142
143/// Create fallback package data using parent directory name
144fn fallback_package_data(path: &Path) -> PackageData {
145    let name = path
146        .parent()
147        .and_then(|p| p.file_name())
148        .and_then(|n| n.to_str())
149        .map(|s| truncate_field(s.to_string()));
150
151    PackageData {
152        package_type: Some(BazelBuildParser::PACKAGE_TYPE),
153        purl: name
154            .as_deref()
155            .and_then(|name| build_bazel_purl(name, None))
156            .map(truncate_field),
157        name,
158        datasource_id: Some(DatasourceId::BazelBuild),
159        ..Default::default()
160    }
161}
162
163fn set_scancode_simple_top_level(package_data: &mut PackageData, enabled: bool) {
164    let extra_data = package_data.extra_data.get_or_insert_with(Default::default);
165    extra_data.insert(
166        SCANCODE_SIMPLE_TOP_LEVEL_KEY.to_string(),
167        JsonValue::Bool(enabled),
168    );
169}
170
171fn is_scancode_simple_top_level_module(module: &AstModule) -> bool {
172    top_level_statements(module)
173        .iter()
174        .all(is_scancode_simple_top_level_statement)
175}
176
177fn is_scancode_simple_top_level_statement(statement: &ast::AstStmt) -> bool {
178    match &statement.node {
179        ast::StmtP::Expression(expr) => {
180            matches!(&expr.node, ast::ExprP::Call(func, _) if matches!(&func.node, ast::ExprP::Identifier(_)))
181        }
182        _ => true,
183    }
184}
185
186pub struct BazelModuleParser;
187
188impl PackageParser for BazelModuleParser {
189    const PACKAGE_TYPE: PackageType = PackageType::Bazel;
190
191    fn metadata() -> Vec<ParserMetadata> {
192        vec![ParserMetadata {
193            description: "Bazel MODULE.bazel file",
194            file_patterns: &["**/MODULE.bazel"],
195            package_type: "bazel",
196            primary_language: "",
197            documentation_url: Some("https://bazel.build/external/module"),
198        }]
199    }
200
201    fn is_match(path: &Path) -> bool {
202        path.file_name()
203            .and_then(|name| name.to_str())
204            .is_some_and(|name| name == "MODULE.bazel")
205    }
206
207    fn extract_packages(path: &Path) -> Vec<PackageData> {
208        match parse_bazel_module(path) {
209            Ok(package) => vec![package],
210            Err(e) => {
211                warn!("Failed to parse Bazel MODULE.bazel {:?}: {}", path, e);
212                vec![default_bazel_module_package_data()]
213            }
214        }
215    }
216}
217
218fn parse_bazel_module(path: &Path) -> Result<PackageData, String> {
219    let content =
220        crate::parsers::utils::read_file_to_string(path, None).map_err(|e| e.to_string())?;
221    let module = parse_starlark_module("<MODULE.bazel>", content)?;
222
223    let mut package = default_bazel_module_package_data();
224    let mut extra_data = JsonMap::new();
225    let mut dependencies = Vec::new();
226    let mut overrides = Vec::new();
227
228    let statements = top_level_statements(&module);
229    let limit = capped_iteration_limit(statements.len(), "MODULE.bazel top-level statements");
230    for statement in statements.iter().take(limit) {
231        let Some(call) = extract_call(statement) else {
232            continue;
233        };
234
235        let Some(function_name) = extract_call_name(&call) else {
236            continue;
237        };
238
239        match function_name {
240            "module" => {
241                package.name = extract_string_kwarg(&call, "name").map(truncate_field);
242                package.version = extract_string_kwarg(&call, "version").map(truncate_field);
243                package.purl = package
244                    .name
245                    .as_deref()
246                    .and_then(|name| build_bazel_purl(name, package.version.as_deref()))
247                    .map(truncate_field);
248
249                if let Some(repo_name) =
250                    extract_string_kwarg(&call, "repo_name").map(truncate_field)
251                {
252                    extra_data.insert("repo_name".to_string(), JsonValue::String(repo_name));
253                }
254                if let Some(compatibility_level) = extract_int_kwarg(&call, "compatibility_level") {
255                    extra_data.insert(
256                        "compatibility_level".to_string(),
257                        JsonValue::Number(compatibility_level.into()),
258                    );
259                }
260                if let Some(bazel_compatibility) = extract_kwarg_json(&call, "bazel_compatibility")
261                {
262                    extra_data.insert("bazel_compatibility".to_string(), bazel_compatibility);
263                }
264            }
265            "bazel_dep" => {
266                if let Some(dep) = extract_bazel_dependency(&call) {
267                    dependencies.push(dep);
268                }
269            }
270            "archive_override"
271            | "git_override"
272            | "local_path_override"
273            | "single_version_override"
274            | "multiple_version_override" => {
275                overrides.push(extract_override(function_name, &call));
276            }
277            _ => {}
278        }
279    }
280
281    if package.name.is_none() {
282        return Ok(default_bazel_module_package_data());
283    }
284
285    if !overrides.is_empty() {
286        extra_data.insert("overrides".to_string(), JsonValue::Array(overrides));
287    }
288
289    package.dependencies = dependencies;
290    package.extra_data = (!extra_data.is_empty()).then(|| extra_data.into_iter().collect());
291    Ok(package)
292}
293
294fn parse_starlark_module(filename: &str, content: String) -> Result<AstModule, String> {
295    let dialect = Dialect {
296        enable_top_level_stmt: true,
297        ..Dialect::Standard
298    };
299    AstModule::parse(filename, content, &dialect).map_err(|error| error.to_string())
300}
301
302fn top_level_statements(module: &AstModule) -> &[ast::AstStmt] {
303    match &module.statement().node {
304        ast::StmtP::Statements(statements) => statements,
305        _ => std::slice::from_ref(module.statement()),
306    }
307}
308
309fn extract_call(statement: &ast::AstStmt) -> Option<StarlarkCall<'_>> {
310    match &statement.node {
311        ast::StmtP::Expression(expr) => extract_call_expr(expr),
312        ast::StmtP::Assign(assign) => extract_call_expr(&assign.rhs),
313        _ => None,
314    }
315}
316
317fn extract_call_expr(expr: &ast::AstExpr) -> Option<StarlarkCall<'_>> {
318    match &expr.node {
319        ast::ExprP::Call(func, args) => Some(StarlarkCall { func, args }),
320        _ => None,
321    }
322}
323
324fn extract_call_name<'a>(call: &'a StarlarkCall<'_>) -> Option<&'a str> {
325    match &call.func.node {
326        ast::ExprP::Identifier(identifier) => Some(identifier.node.ident.as_str()),
327        _ => None,
328    }
329}
330
331fn extract_named_kwarg<'a>(call: &'a StarlarkCall<'_>, key: &str) -> Option<&'a ast::AstExpr> {
332    call.args
333        .args
334        .iter()
335        .find_map(|argument| match &argument.node {
336            ast::ArgumentP::Named(name, value) if name.node == key => Some(value),
337            _ => None,
338        })
339}
340
341fn extract_string_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<String> {
342    extract_named_kwarg(call, key).and_then(expr_as_string)
343}
344
345fn extract_string_list_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<Vec<String>> {
346    let expr = extract_named_kwarg(call, key)?;
347    let items = match &expr.node {
348        ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
349        _ => return None,
350    };
351    let limit = capped_iteration_limit(items.len(), "bazel string-list kwarg items");
352    let values: Vec<_> = items
353        .iter()
354        .take(limit)
355        .filter_map(expr_as_string)
356        .collect();
357    (!values.is_empty()).then_some(values)
358}
359
360fn extract_bool_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<bool> {
361    extract_named_kwarg(call, key).and_then(expr_as_bool)
362}
363
364fn extract_int_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<i64> {
365    extract_named_kwarg(call, key).and_then(expr_as_i64)
366}
367
368fn extract_kwarg_json(call: &StarlarkCall<'_>, key: &str) -> Option<JsonValue> {
369    extract_named_kwarg(call, key)
370        .and_then(|expr| expr_to_json(expr, &mut RecursionGuard::depth_only()))
371}
372
373fn extract_bazel_dependency(call: &StarlarkCall<'_>) -> Option<Dependency> {
374    let name = extract_string_kwarg(call, "name").map(truncate_field)?;
375    let version = extract_string_kwarg(call, "version").map(truncate_field);
376    let is_dev = extract_bool_kwarg(call, "dev_dependency").unwrap_or(false);
377    let mut extra_data = JsonMap::new();
378
379    let fields = ["repo_name", "max_compatibility_level", "registry"];
380    let limit = capped_iteration_limit(fields.len(), "bazel dependency extra fields");
381    for field in fields.iter().take(limit) {
382        if let Some(value) = extract_kwarg_json(call, field) {
383            extra_data.insert(field.to_string(), value);
384        }
385    }
386
387    Some(Dependency {
388        purl: build_bazel_purl(&name, version.as_deref()).map(truncate_field),
389        extracted_requirement: version.clone(),
390        scope: Some(if is_dev { "dev" } else { "dependencies" }.to_string()),
391        is_runtime: Some(!is_dev),
392        is_optional: Some(is_dev),
393        is_pinned: Some(version.is_some()),
394        is_direct: Some(true),
395        resolved_package: None,
396        extra_data: (!extra_data.is_empty()).then(|| extra_data.into_iter().collect()),
397    })
398}
399
400fn extract_override(kind: &str, call: &StarlarkCall<'_>) -> JsonValue {
401    let mut override_map = JsonMap::new();
402    override_map.insert("kind".to_string(), JsonValue::String(kind.to_string()));
403    let limit = capped_iteration_limit(call.args.args.len(), "bazel override arguments");
404    for argument in call.args.args.iter().take(limit) {
405        if let ast::ArgumentP::Named(name, value) = &argument.node
406            && let Some(value) = expr_to_json(value, &mut RecursionGuard::depth_only())
407        {
408            override_map.insert(name.node.clone(), value);
409        }
410    }
411    JsonValue::Object(override_map)
412}
413
414fn expr_as_string(expr: &ast::AstExpr) -> Option<String> {
415    match &expr.node {
416        ast::ExprP::Literal(ast::AstLiteral::String(value)) => Some(value.node.clone()),
417        _ => None,
418    }
419}
420
421fn expr_as_bool(expr: &ast::AstExpr) -> Option<bool> {
422    match &expr.node {
423        ast::ExprP::Identifier(identifier) => match identifier.node.ident.as_str() {
424            "True" => Some(true),
425            "False" => Some(false),
426            _ => None,
427        },
428        _ => None,
429    }
430}
431
432fn expr_as_i64(expr: &ast::AstExpr) -> Option<i64> {
433    match &expr.node {
434        ast::ExprP::Literal(ast::AstLiteral::Int(value)) => value.node.to_string().parse().ok(),
435        _ => None,
436    }
437}
438
439fn expr_to_json(expr: &ast::AstExpr, guard: &mut RecursionGuard<()>) -> Option<JsonValue> {
440    if guard.descend() {
441        return None;
442    }
443    let result = match &expr.node {
444        ast::ExprP::Literal(ast::AstLiteral::String(value)) => {
445            Some(JsonValue::String(value.node.clone()))
446        }
447        ast::ExprP::Literal(ast::AstLiteral::Int(value)) => value
448            .node
449            .to_string()
450            .parse::<i64>()
451            .ok()
452            .map(|value| JsonValue::Number(value.into()))
453            .or_else(|| Some(JsonValue::String(value.node.to_string()))),
454        ast::ExprP::Literal(ast::AstLiteral::Float(value)) => {
455            serde_json::Number::from_f64(value.node).map(JsonValue::Number)
456        }
457        ast::ExprP::Identifier(identifier) => match identifier.node.ident.as_str() {
458            "True" => Some(JsonValue::Bool(true)),
459            "False" => Some(JsonValue::Bool(false)),
460            "None" => Some(JsonValue::Null),
461            _ => None,
462        },
463        ast::ExprP::List(elts) | ast::ExprP::Tuple(elts) => {
464            let limit = capped_iteration_limit(elts.len(), "bazel list/tuple elements");
465            Some(JsonValue::Array(
466                elts.iter()
467                    .take(limit)
468                    .filter_map(|e| expr_to_json(e, guard))
469                    .collect(),
470            ))
471        }
472        ast::ExprP::Dict(items) => {
473            let mut map = JsonMap::new();
474            let limit = capped_iteration_limit(items.len(), "bazel dict entries");
475            for (key, value) in items.iter().take(limit) {
476                let Some(key) = expr_as_string(key) else {
477                    continue;
478                };
479                if let Some(value) = expr_to_json(value, guard) {
480                    map.insert(key, value);
481                }
482            }
483            Some(JsonValue::Object(map))
484        }
485        _ => None,
486    };
487    guard.ascend();
488    result
489}
490
491fn build_bazel_purl(name: &str, version: Option<&str>) -> Option<String> {
492    let mut purl = PackageUrl::new("bazel", name).ok()?;
493    if let Some(version) = version.filter(|value| !value.trim().is_empty()) {
494        purl.with_version(version).ok()?;
495    }
496    Some(purl.to_string())
497}
498
499fn default_bazel_module_package_data() -> PackageData {
500    PackageData {
501        package_type: Some(BazelModuleParser::PACKAGE_TYPE),
502        datasource_id: Some(DatasourceId::BazelModule),
503        ..Default::default()
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::models::PackageType;
511    use std::path::PathBuf;
512
513    #[test]
514    fn test_is_match() {
515        assert!(BazelBuildParser::is_match(&PathBuf::from("BUILD")));
516        assert!(BazelBuildParser::is_match(&PathBuf::from("path/to/BUILD")));
517        assert!(!BazelBuildParser::is_match(&PathBuf::from("BUILD.bazel")));
518        assert!(!BazelBuildParser::is_match(&PathBuf::from("build")));
519        assert!(!BazelBuildParser::is_match(&PathBuf::from("BUCK")));
520    }
521
522    #[test]
523    fn test_check_rule_name_ending() {
524        assert!(check_rule_name_ending("cc_binary"));
525        assert!(check_rule_name_ending("cc_library"));
526        assert!(check_rule_name_ending("java_binary"));
527        assert!(check_rule_name_ending("py_library"));
528        assert!(!check_rule_name_ending("filegroup"));
529        assert!(!check_rule_name_ending("load"));
530        assert!(!check_rule_name_ending("cc_test"));
531    }
532
533    #[test]
534    fn test_fallback_package_data() {
535        let path = PathBuf::from("/path/to/myproject/BUILD");
536        let pkg = fallback_package_data(&path);
537        assert_eq!(pkg.package_type, Some(PackageType::Bazel));
538        assert_eq!(pkg.name, Some("myproject".to_string()));
539        assert_eq!(pkg.purl.as_deref(), Some("pkg:bazel/myproject"));
540    }
541
542    #[test]
543    fn test_scancode_simple_top_level_allows_direct_calls() {
544        let module = parse_starlark_module(
545            "<BUILD>",
546            "cc_library(name = \"demo\")\npy_binary(name = \"tool\")\n".to_string(),
547        )
548        .expect("parse BUILD");
549
550        assert!(is_scancode_simple_top_level_module(&module));
551    }
552
553    #[test]
554    fn test_scancode_simple_top_level_rejects_attribute_calls() {
555        let module = parse_starlark_module(
556            "<BUILD>",
557            "selects.config_setting_group(name = \"demo\")\ncc_library(name = \"demo\")\n"
558                .to_string(),
559        )
560        .expect("parse BUILD");
561
562        assert!(!is_scancode_simple_top_level_module(&module));
563    }
564
565    #[test]
566    fn test_scancode_simple_top_level_rejects_non_call_expressions() {
567        let module =
568            parse_starlark_module("<BUILD>", "[(cc_binary(name = \"demo\"),)]\n".to_string())
569                .expect("parse BUILD");
570
571        assert!(!is_scancode_simple_top_level_module(&module));
572    }
573}