_diffctx/edges/semantic/
bazel.rs1use std::path::{Path, PathBuf};
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::weights::EDGE_WEIGHTS;
8use crate::types::Fragment;
9
10use super::super::EdgeDict;
11use super::super::base::{
12 self, EdgeBuilder, FragmentIndex, add_edge, discover_files_by_refs, link_by_path_match,
13};
14
15static BAZEL_NAMES: Lazy<FxHashSet<&str>> = Lazy::new(|| {
16 ["BUILD", "BUILD.bazel", "WORKSPACE", "WORKSPACE.bazel"]
17 .iter()
18 .copied()
19 .collect()
20});
21
22static DEPS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"["']([/@][^"']{1,300})["']"#).unwrap());
23static LOAD_RE: Lazy<Regex> =
24 Lazy::new(|| Regex::new(r#"load\(\s*["']([^"']{1,300})["']\s*,"#).unwrap());
25static SRCS_RE: Lazy<Regex> =
26 Lazy::new(|| Regex::new(r#"["']([^"']{1,300}\.\w{1,10})["']"#).unwrap());
27static LABEL_RE: Lazy<Regex> =
28 Lazy::new(|| Regex::new(r#"//([^:"']{1,200}):([^"'\s,\]]{1,200})"#).unwrap());
29
30fn is_bazel_file(path: &Path) -> bool {
31 let name = path
32 .file_name()
33 .map(|n| n.to_string_lossy().to_string())
34 .unwrap_or_default();
35 if BAZEL_NAMES.contains(name.as_str()) {
36 return true;
37 }
38 let ext = base::file_ext(path);
39 ext == ".bzl" || ext == ".bazel"
40}
41
42fn extract_labels(content: &str) -> FxHashSet<String> {
43 DEPS_RE
44 .captures_iter(content)
45 .map(|c| c[1].to_string())
46 .collect()
47}
48
49fn extract_loads(content: &str) -> FxHashSet<String> {
50 LOAD_RE
51 .captures_iter(content)
52 .map(|c| c[1].to_string())
53 .collect()
54}
55
56fn extract_srcs(content: &str) -> FxHashSet<String> {
57 let mut srcs = FxHashSet::default();
58 let mut in_srcs = false;
59 for line in content.lines() {
60 let stripped = line.trim();
61 if stripped.contains("srcs") && stripped.contains('=') {
62 in_srcs = true;
63 }
64 if in_srcs {
65 for m in SRCS_RE.captures_iter(line) {
66 srcs.insert(m[1].to_string());
67 }
68 if stripped.contains(']') {
69 in_srcs = false;
70 }
71 }
72 }
73 srcs
74}
75
76fn label_to_path(label: &str) -> Option<String> {
77 if let Some(c) = LABEL_RE.captures(label) {
78 return Some(c[1].to_string());
79 }
80 if label.starts_with("//") {
81 let cleaned = label
82 .trim_start_matches('/')
83 .split(':')
84 .next()
85 .unwrap_or("");
86 if !cleaned.is_empty() {
87 return Some(cleaned.to_string());
88 }
89 }
90 None
91}
92
93fn ref_to_filename(r: &str) -> String {
94 r.trim_end_matches('/')
95 .split('/')
96 .next_back()
97 .unwrap_or(r)
98 .split(':')
99 .next_back()
100 .unwrap_or(r)
101 .to_lowercase()
102}
103
104pub struct BazelEdgeBuilder;
105
106impl EdgeBuilder for BazelEdgeBuilder {
107 fn build(&self, fragments: &[Fragment], repo_root: Option<&Path>) -> EdgeDict {
108 let frags: Vec<&Fragment> = fragments
109 .iter()
110 .filter(|f| is_bazel_file(Path::new(f.path())))
111 .collect();
112 if frags.is_empty() {
113 return FxHashMap::default();
114 }
115
116 let deps_w = EDGE_WEIGHTS["bazel_deps"].forward;
117 let load_w = EDGE_WEIGHTS["bazel_load"].forward;
118 let srcs_w = EDGE_WEIGHTS["bazel_srcs"].forward;
119 let rev = EDGE_WEIGHTS["bazel_deps"].reverse_factor;
120
121 let idx = FragmentIndex::new(fragments, repo_root);
122 let mut edges: EdgeDict = FxHashMap::default();
123
124 for bf in &frags {
125 for label in extract_labels(&bf.content) {
126 if let Some(path) = label_to_path(&label) {
127 for build_name in ["BUILD", "BUILD.bazel"] {
128 link_by_path_match(
129 &bf.id,
130 &format!("{}/{}", path, build_name),
131 &idx,
132 &mut edges,
133 deps_w,
134 rev,
135 );
136 }
137 link_by_path_match(&bf.id, &path, &idx, &mut edges, deps_w, rev);
138 }
139 }
140
141 for load in extract_loads(&bf.content) {
142 let filename = ref_to_filename(&load);
143 let stem = filename.strip_suffix(".bzl").unwrap_or(&filename);
144 let mut linked = false;
145 for (name, frag_ids) in &idx.by_name {
146 if name == &filename || name == stem {
147 for fid in frag_ids {
148 if fid != &bf.id {
149 add_edge(&mut edges, &bf.id, fid, load_w, rev);
150 linked = true;
151 break;
152 }
153 }
154 if linked {
155 break;
156 }
157 }
158 }
159 if !linked {
160 if let Some(path) = label_to_path(&load) {
161 link_by_path_match(&bf.id, &path, &idx, &mut edges, load_w, rev);
162 }
163 }
164 }
165
166 let build_parent = Path::new(bf.path()).parent().unwrap_or(Path::new(""));
167 for src in extract_srcs(&bf.content) {
168 let src_lower = src.to_lowercase();
169 let mut found = false;
170 if let Some(frag_ids) = idx.by_name.get(&src_lower) {
171 for fid in frag_ids {
172 if fid == &bf.id {
173 continue;
174 }
175 let frag_parent = Path::new(fid.path.as_ref()).parent();
176 if frag_parent == Some(build_parent) {
177 add_edge(&mut edges, &bf.id, fid, srcs_w, rev);
178 found = true;
179 break;
180 }
181 }
182 }
183 if !found {
184 let rel = build_parent.join(&src).to_string_lossy().to_string();
185 link_by_path_match(&bf.id, &rel, &idx, &mut edges, srcs_w, rev);
186 }
187 }
188 }
189 edges
190 }
191
192 fn discover_related_files(
193 &self,
194 changed: &[PathBuf],
195 candidates: &[PathBuf],
196 repo_root: Option<&Path>,
197 file_cache: Option<&FxHashMap<PathBuf, String>>,
198 ) -> Vec<PathBuf> {
199 let bazel_changed: Vec<&PathBuf> = changed.iter().filter(|p| is_bazel_file(p)).collect();
200 if bazel_changed.is_empty() {
201 return vec![];
202 }
203
204 let mut refs = FxHashSet::default();
205 for f in &bazel_changed {
206 let content = match base::read_file_cached(f, file_cache) {
207 Some(c) => c,
208 None => continue,
209 };
210 for label in extract_labels(&content) {
211 if let Some(path) = label_to_path(&label) {
212 refs.insert(format!("{}/BUILD", path));
213 refs.insert(format!("{}/BUILD.bazel", path));
214 refs.insert(path);
215 }
216 }
217 for load in extract_loads(&content) {
218 if let Some(path) = label_to_path(&load) {
219 refs.insert(path);
220 }
221 refs.insert(ref_to_filename(&load));
222 }
223 for src in extract_srcs(&content) {
224 let parent = f.parent().unwrap_or(Path::new(""));
225 refs.insert(parent.join(&src).to_string_lossy().to_string());
226 refs.insert(src);
227 }
228 }
229
230 let changed_names: FxHashSet<String> = bazel_changed
231 .iter()
232 .filter_map(|f| f.file_name().map(|n| n.to_string_lossy().to_lowercase()))
233 .collect();
234 let mut changed_paths: FxHashSet<String> = FxHashSet::default();
235 for f in &bazel_changed {
236 changed_paths.insert(f.to_string_lossy().to_string());
237 if base::file_ext(f) == ".bzl" {
238 if let Some(stem) = f.file_stem() {
239 changed_paths.insert(stem.to_string_lossy().to_string());
240 }
241 }
242 }
243
244 for candidate in candidates {
245 if !is_bazel_file(candidate) {
246 continue;
247 }
248 if let Some(content) = base::read_file_cached(candidate, file_cache) {
249 for load in extract_loads(&content) {
250 let load_file = ref_to_filename(&load);
251 if changed_names.contains(&load_file)
252 || changed_paths.iter().any(|cp| load.contains(cp.as_str()))
253 {
254 if let Some(name) = candidate.file_name() {
255 refs.insert(name.to_string_lossy().to_lowercase());
256 }
257 }
258 }
259 }
260 }
261
262 discover_files_by_refs(&refs, changed, candidates, repo_root)
263 }
264
265 fn category_label(&self) -> Option<&str> {
266 Some("semantic")
267 }
268}