Skip to main content

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