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::AstModule;
33use starlark_syntax::syntax::ast;
34
35use super::PackageParser;
36use super::metadata::ParserMetadata;
37use super::starlark_parse::{self, RECOVERY_MISSING_SEPARATOR};
38
39type StarlarkCallArgs = ast::CallArgsP<ast::AstNoPayload>;
40
41struct StarlarkCall<'a> {
42    func: &'a ast::AstExpr,
43    args: &'a StarlarkCallArgs,
44}
45
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, repaired) = parse_starlark_module("<BUILD>", content)?;
84
85    let mut packages = Vec::new();
86
87    let statements = top_level_statements(&module);
88    let limit = capped_iteration_limit(statements.len(), "bazel BUILD top-level statements");
89    for statement in statements.iter().take(limit) {
90        if let Some(mut package_data) = extract_package_from_statement(statement) {
91            if repaired {
92                starlark_parse::mark_parse_recovery(&mut package_data, RECOVERY_MISSING_SEPARATOR);
93            }
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    // A Bazel `licenses` attribute lists license *file* references, not a license
115    // expression. Record them as declared-license file references so reference-
116    // following resolves each to the referenced file's license (else
117    // `unknown-license-reference`), rather than free-text detecting the attribute.
118    let extra_data = licenses.as_ref().map(|licenses| {
119        let references = licenses
120            .iter()
121            .map(|license| JsonValue::String(truncate_field(license.clone())))
122            .collect();
123        let mut map = JsonMap::new();
124        map.insert("license_files".to_string(), JsonValue::Array(references));
125        map.into_iter().collect()
126    });
127
128    Some(PackageData {
129        package_type: Some(BazelBuildParser::PACKAGE_TYPE),
130        name: Some(truncate_field(name)),
131        extracted_license_statement: licenses.map(|licenses| truncate_field(licenses.join(", "))),
132        datasource_id: Some(DatasourceId::BazelBuild),
133        purl,
134        extra_data,
135        ..Default::default()
136    })
137}
138
139/// Check if rule name ends with "binary" or "library"
140fn check_rule_name_ending(rule_name: &str) -> bool {
141    rule_name.ends_with("binary") || rule_name.ends_with("library")
142}
143
144/// Create fallback package data using parent directory name
145fn fallback_package_data(path: &Path) -> PackageData {
146    let name = path
147        .parent()
148        .and_then(|p| p.file_name())
149        .and_then(|n| n.to_str())
150        .map(|s| truncate_field(s.to_string()));
151
152    PackageData {
153        package_type: Some(BazelBuildParser::PACKAGE_TYPE),
154        purl: name
155            .as_deref()
156            .and_then(|name| build_bazel_purl(name, None))
157            .map(truncate_field),
158        name,
159        datasource_id: Some(DatasourceId::BazelBuild),
160        ..Default::default()
161    }
162}
163
164pub struct BazelModuleParser;
165
166impl PackageParser for BazelModuleParser {
167    const PACKAGE_TYPE: PackageType = PackageType::Bazel;
168
169    fn metadata() -> Vec<ParserMetadata> {
170        vec![ParserMetadata {
171            description: "Bazel MODULE.bazel file",
172            file_patterns: &["**/MODULE.bazel"],
173            package_type: "bazel",
174            primary_language: "",
175            documentation_url: Some("https://bazel.build/external/module"),
176        }]
177    }
178
179    fn is_match(path: &Path) -> bool {
180        path.file_name()
181            .and_then(|name| name.to_str())
182            .is_some_and(|name| name == "MODULE.bazel")
183    }
184
185    fn extract_packages(path: &Path) -> Vec<PackageData> {
186        match parse_bazel_module(path) {
187            Ok(package) => vec![package],
188            Err(e) => {
189                warn!("Failed to parse Bazel MODULE.bazel {:?}: {}", path, e);
190                vec![default_bazel_module_package_data()]
191            }
192        }
193    }
194}
195
196fn parse_bazel_module(path: &Path) -> Result<PackageData, String> {
197    let content =
198        crate::parsers::utils::read_file_to_string(path, None).map_err(|e| e.to_string())?;
199    let (module, repaired) = parse_starlark_module("<MODULE.bazel>", content)?;
200
201    let mut package = default_bazel_module_package_data();
202    let mut extra_data = JsonMap::new();
203    let mut dependencies = Vec::new();
204    let mut overrides = Vec::new();
205
206    let statements = top_level_statements(&module);
207    let limit = capped_iteration_limit(statements.len(), "MODULE.bazel top-level statements");
208    for statement in statements.iter().take(limit) {
209        let Some(call) = extract_call(statement) else {
210            continue;
211        };
212
213        let Some(function_name) = extract_call_name(&call) else {
214            continue;
215        };
216
217        match function_name {
218            "module" => {
219                package.name = extract_string_kwarg(&call, "name").map(truncate_field);
220                package.version = extract_string_kwarg(&call, "version").map(truncate_field);
221                package.purl = package
222                    .name
223                    .as_deref()
224                    .and_then(|name| build_bazel_purl(name, package.version.as_deref()))
225                    .map(truncate_field);
226
227                if let Some(repo_name) =
228                    extract_string_kwarg(&call, "repo_name").map(truncate_field)
229                {
230                    extra_data.insert("repo_name".to_string(), JsonValue::String(repo_name));
231                }
232                if let Some(compatibility_level) = extract_int_kwarg(&call, "compatibility_level") {
233                    extra_data.insert(
234                        "compatibility_level".to_string(),
235                        JsonValue::Number(compatibility_level.into()),
236                    );
237                }
238                if let Some(bazel_compatibility) = extract_kwarg_json(&call, "bazel_compatibility")
239                {
240                    extra_data.insert("bazel_compatibility".to_string(), bazel_compatibility);
241                }
242            }
243            "bazel_dep" => {
244                if let Some(dep) = extract_bazel_dependency(&call) {
245                    dependencies.push(dep);
246                }
247            }
248            "archive_override"
249            | "git_override"
250            | "local_path_override"
251            | "single_version_override"
252            | "multiple_version_override" => {
253                overrides.push(extract_override(function_name, &call));
254            }
255            _ => {}
256        }
257    }
258
259    if package.name.is_none() {
260        let mut fallback = default_bazel_module_package_data();
261        if repaired {
262            starlark_parse::mark_parse_recovery(&mut fallback, RECOVERY_MISSING_SEPARATOR);
263        }
264        return Ok(fallback);
265    }
266
267    if !overrides.is_empty() {
268        extra_data.insert("overrides".to_string(), JsonValue::Array(overrides));
269    }
270
271    package.dependencies = dependencies;
272    package.extra_data = (!extra_data.is_empty()).then(|| extra_data.into_iter().collect());
273    if repaired {
274        starlark_parse::mark_parse_recovery(&mut package, RECOVERY_MISSING_SEPARATOR);
275    }
276    Ok(package)
277}
278
279fn parse_starlark_module(filename: &str, content: String) -> Result<(AstModule, bool), String> {
280    starlark_parse::parse_with_repair(filename, content)
281}
282
283fn top_level_statements(module: &AstModule) -> &[ast::AstStmt] {
284    match &module.statement().node {
285        ast::StmtP::Statements(statements) => statements,
286        _ => std::slice::from_ref(module.statement()),
287    }
288}
289
290fn extract_call(statement: &ast::AstStmt) -> Option<StarlarkCall<'_>> {
291    match &statement.node {
292        ast::StmtP::Expression(expr) => extract_call_expr(expr),
293        ast::StmtP::Assign(assign) => extract_call_expr(&assign.rhs),
294        _ => None,
295    }
296}
297
298fn extract_call_expr(expr: &ast::AstExpr) -> Option<StarlarkCall<'_>> {
299    match &expr.node {
300        ast::ExprP::Call(func, args) => Some(StarlarkCall { func, args }),
301        _ => None,
302    }
303}
304
305fn extract_call_name<'a>(call: &'a StarlarkCall<'_>) -> Option<&'a str> {
306    match &call.func.node {
307        ast::ExprP::Identifier(identifier) => Some(identifier.node.ident.as_str()),
308        _ => None,
309    }
310}
311
312fn extract_named_kwarg<'a>(call: &'a StarlarkCall<'_>, key: &str) -> Option<&'a ast::AstExpr> {
313    call.args
314        .args
315        .iter()
316        .find_map(|argument| match &argument.node {
317            ast::ArgumentP::Named(name, value) if name.node == key => Some(value),
318            _ => None,
319        })
320}
321
322fn extract_string_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<String> {
323    extract_named_kwarg(call, key).and_then(expr_as_string)
324}
325
326fn extract_string_list_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<Vec<String>> {
327    let expr = extract_named_kwarg(call, key)?;
328    let items = match &expr.node {
329        ast::ExprP::List(items) | ast::ExprP::Tuple(items) => items,
330        _ => return None,
331    };
332    let limit = capped_iteration_limit(items.len(), "bazel string-list kwarg items");
333    let values: Vec<_> = items
334        .iter()
335        .take(limit)
336        .filter_map(expr_as_string)
337        .collect();
338    (!values.is_empty()).then_some(values)
339}
340
341fn extract_bool_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<bool> {
342    extract_named_kwarg(call, key).and_then(expr_as_bool)
343}
344
345fn extract_int_kwarg(call: &StarlarkCall<'_>, key: &str) -> Option<i64> {
346    extract_named_kwarg(call, key).and_then(expr_as_i64)
347}
348
349fn extract_kwarg_json(call: &StarlarkCall<'_>, key: &str) -> Option<JsonValue> {
350    extract_named_kwarg(call, key)
351        .and_then(|expr| expr_to_json(expr, &mut RecursionGuard::depth_only()))
352}
353
354fn extract_bazel_dependency(call: &StarlarkCall<'_>) -> Option<Dependency> {
355    let name = extract_string_kwarg(call, "name").map(truncate_field)?;
356    let version = extract_string_kwarg(call, "version").map(truncate_field);
357    let is_dev = extract_bool_kwarg(call, "dev_dependency").unwrap_or(false);
358    let mut extra_data = JsonMap::new();
359
360    let fields = ["repo_name", "max_compatibility_level", "registry"];
361    let limit = capped_iteration_limit(fields.len(), "bazel dependency extra fields");
362    for field in fields.iter().take(limit) {
363        if let Some(value) = extract_kwarg_json(call, field) {
364            extra_data.insert(field.to_string(), value);
365        }
366    }
367
368    Some(Dependency {
369        purl: build_bazel_purl(&name, version.as_deref()).map(truncate_field),
370        extracted_requirement: version.clone(),
371        scope: Some(if is_dev { "dev" } else { "dependencies" }.to_string()),
372        is_runtime: Some(!is_dev),
373        is_optional: Some(is_dev),
374        is_pinned: Some(version.is_some()),
375        is_direct: Some(true),
376        resolved_package: None,
377        extra_data: (!extra_data.is_empty()).then(|| extra_data.into_iter().collect()),
378    })
379}
380
381fn extract_override(kind: &str, call: &StarlarkCall<'_>) -> JsonValue {
382    let mut override_map = JsonMap::new();
383    override_map.insert("kind".to_string(), JsonValue::String(kind.to_string()));
384    let limit = capped_iteration_limit(call.args.args.len(), "bazel override arguments");
385    for argument in call.args.args.iter().take(limit) {
386        if let ast::ArgumentP::Named(name, value) = &argument.node
387            && let Some(value) = expr_to_json(value, &mut RecursionGuard::depth_only())
388        {
389            override_map.insert(name.node.clone(), value);
390        }
391    }
392    JsonValue::Object(override_map)
393}
394
395fn expr_as_string(expr: &ast::AstExpr) -> Option<String> {
396    match &expr.node {
397        ast::ExprP::Literal(ast::AstLiteral::String(value)) => Some(value.node.clone()),
398        _ => None,
399    }
400}
401
402fn expr_as_bool(expr: &ast::AstExpr) -> Option<bool> {
403    match &expr.node {
404        ast::ExprP::Identifier(identifier) => match identifier.node.ident.as_str() {
405            "True" => Some(true),
406            "False" => Some(false),
407            _ => None,
408        },
409        _ => None,
410    }
411}
412
413fn expr_as_i64(expr: &ast::AstExpr) -> Option<i64> {
414    match &expr.node {
415        ast::ExprP::Literal(ast::AstLiteral::Int(value)) => value.node.to_string().parse().ok(),
416        _ => None,
417    }
418}
419
420fn expr_to_json(expr: &ast::AstExpr, guard: &mut RecursionGuard<()>) -> Option<JsonValue> {
421    if guard.descend() {
422        return None;
423    }
424    let result = match &expr.node {
425        ast::ExprP::Literal(ast::AstLiteral::String(value)) => {
426            Some(JsonValue::String(value.node.clone()))
427        }
428        ast::ExprP::Literal(ast::AstLiteral::Int(value)) => value
429            .node
430            .to_string()
431            .parse::<i64>()
432            .ok()
433            .map(|value| JsonValue::Number(value.into()))
434            .or_else(|| Some(JsonValue::String(value.node.to_string()))),
435        ast::ExprP::Literal(ast::AstLiteral::Float(value)) => {
436            serde_json::Number::from_f64(value.node).map(JsonValue::Number)
437        }
438        ast::ExprP::Identifier(identifier) => match identifier.node.ident.as_str() {
439            "True" => Some(JsonValue::Bool(true)),
440            "False" => Some(JsonValue::Bool(false)),
441            "None" => Some(JsonValue::Null),
442            _ => None,
443        },
444        ast::ExprP::List(elts) | ast::ExprP::Tuple(elts) => {
445            let limit = capped_iteration_limit(elts.len(), "bazel list/tuple elements");
446            Some(JsonValue::Array(
447                elts.iter()
448                    .take(limit)
449                    .filter_map(|e| expr_to_json(e, guard))
450                    .collect(),
451            ))
452        }
453        ast::ExprP::Dict(items) => {
454            let mut map = JsonMap::new();
455            let limit = capped_iteration_limit(items.len(), "bazel dict entries");
456            for (key, value) in items.iter().take(limit) {
457                let Some(key) = expr_as_string(key) else {
458                    continue;
459                };
460                if let Some(value) = expr_to_json(value, guard) {
461                    map.insert(key, value);
462                }
463            }
464            Some(JsonValue::Object(map))
465        }
466        _ => None,
467    };
468    guard.ascend();
469    result
470}
471
472fn build_bazel_purl(name: &str, version: Option<&str>) -> Option<String> {
473    let mut purl = PackageUrl::new("bazel", name).ok()?;
474    if let Some(version) = version.filter(|value| !value.trim().is_empty()) {
475        purl.with_version(version).ok()?;
476    }
477    Some(purl.to_string())
478}
479
480fn default_bazel_module_package_data() -> PackageData {
481    PackageData {
482        package_type: Some(BazelModuleParser::PACKAGE_TYPE),
483        datasource_id: Some(DatasourceId::BazelModule),
484        ..Default::default()
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491    use crate::models::PackageType;
492    use std::path::PathBuf;
493
494    #[test]
495    fn test_is_match() {
496        assert!(BazelBuildParser::is_match(&PathBuf::from("BUILD")));
497        assert!(BazelBuildParser::is_match(&PathBuf::from("path/to/BUILD")));
498        assert!(!BazelBuildParser::is_match(&PathBuf::from("BUILD.bazel")));
499        assert!(!BazelBuildParser::is_match(&PathBuf::from("build")));
500        assert!(!BazelBuildParser::is_match(&PathBuf::from("BUCK")));
501    }
502
503    #[test]
504    fn test_check_rule_name_ending() {
505        assert!(check_rule_name_ending("cc_binary"));
506        assert!(check_rule_name_ending("cc_library"));
507        assert!(check_rule_name_ending("java_binary"));
508        assert!(check_rule_name_ending("py_library"));
509        assert!(!check_rule_name_ending("filegroup"));
510        assert!(!check_rule_name_ending("load"));
511        assert!(!check_rule_name_ending("cc_test"));
512    }
513
514    #[test]
515    fn test_fallback_package_data() {
516        let path = PathBuf::from("/path/to/myproject/BUILD");
517        let pkg = fallback_package_data(&path);
518        assert_eq!(pkg.package_type, Some(PackageType::Bazel));
519        assert_eq!(pkg.name, Some("myproject".to_string()));
520        assert_eq!(pkg.purl.as_deref(), Some("pkg:bazel/myproject"));
521    }
522}