1use std::path::{Path, PathBuf};
4
5use fallow_config::WorkspaceInfo;
6use fallow_output::{DiffIndex, MAX_DIFF_BYTES};
7use fallow_types::path_util::is_absolute_path_any_platform;
8use globset::Glob;
9use rustc_hash::FxHashSet;
10
11use crate::{AnalysisOptions, ProgrammaticError};
12
13type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
14
15pub struct ProgrammaticAnalysisContext {
21 pub(crate) root: PathBuf,
22 pub(crate) config_path: Option<PathBuf>,
23 pub(crate) no_cache: bool,
24 pub(crate) threads: usize,
25 pub(crate) pool: rayon::ThreadPool,
26 pub(crate) diff: Option<DiffIndex>,
27 pub(crate) production_override: Option<bool>,
28 pub(crate) changed_since: Option<String>,
29 pub(crate) workspace: Option<Vec<String>>,
30 pub(crate) changed_workspaces: Option<String>,
31 pub(crate) workspace_roots: Option<Vec<PathBuf>>,
32 pub(crate) explain: bool,
33}
34
35pub fn resolve_programmatic_analysis_context(
42 options: &AnalysisOptions,
43) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
44 validate_analysis_option_shape(options)?;
45 let root = resolve_analysis_root(options.root.as_deref())?;
46 validate_analysis_config_path(options.config_path.as_deref())?;
47 let threads = options.threads.unwrap_or_else(default_threads);
48 let pool = rayon::ThreadPoolBuilder::new()
49 .num_threads(threads)
50 .build()
51 .map_err(|err| {
52 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
53 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
54 .with_context("analysis.threads")
55 })?;
56 let diff = options
57 .diff_file
58 .as_deref()
59 .map(|path| load_explicit_diff_file(path, &root))
60 .transpose()?;
61 let workspace_roots = resolve_workspace_scope(
62 &root,
63 options.workspace.as_deref(),
64 options.changed_workspaces.as_deref(),
65 )?;
66 Ok(ProgrammaticAnalysisContext {
67 root,
68 config_path: options.config_path.clone(),
69 no_cache: options.no_cache,
70 threads,
71 pool,
72 diff,
73 production_override: options
74 .production_override
75 .or_else(|| options.production.then_some(true)),
76 changed_since: options.changed_since.clone(),
77 workspace: options.workspace.clone(),
78 changed_workspaces: options.changed_workspaces.clone(),
79 workspace_roots,
80 explain: options.explain,
81 })
82}
83
84fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
85 if options.threads == Some(0) {
86 return Err(
87 ProgrammaticError::new("`threads` must be greater than 0", 2)
88 .with_code("FALLOW_INVALID_THREADS")
89 .with_context("analysis.threads"),
90 );
91 }
92 if options.workspace.is_some() && options.changed_workspaces.is_some() {
93 return Err(ProgrammaticError::new(
94 "`workspace` and `changed_workspaces` are mutually exclusive",
95 2,
96 )
97 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
98 .with_context("analysis.workspace"));
99 }
100 Ok(())
101}
102
103fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
104 let root = match root {
105 Some(root) => root.to_path_buf(),
106 None => std::env::current_dir().map_err(|err| {
107 ProgrammaticError::new(
108 format!("failed to resolve current working directory: {err}"),
109 2,
110 )
111 .with_code("FALLOW_CWD_UNAVAILABLE")
112 .with_context("analysis.root")
113 })?,
114 };
115 fallow_engine::validate::validate_root(&root).map_err(|err| {
116 ProgrammaticError::new(err, 2)
117 .with_code("FALLOW_INVALID_ROOT")
118 .with_context("analysis.root")
119 })
120}
121
122fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
123 if let Some(config_path) = config_path
124 && !config_path.exists()
125 {
126 return Err(ProgrammaticError::new(
127 format!("config file does not exist: {}", config_path.display()),
128 2,
129 )
130 .with_code("FALLOW_INVALID_CONFIG_PATH")
131 .with_context("analysis.configPath"));
132 }
133 Ok(())
134}
135
136impl ProgrammaticAnalysisContext {
137 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
139 self.pool.install(f)
140 }
141
142 #[must_use]
144 pub fn root(&self) -> &Path {
145 &self.root
146 }
147
148 #[must_use]
150 pub fn config_path(&self) -> &Option<PathBuf> {
151 &self.config_path
152 }
153
154 #[must_use]
156 pub const fn no_cache(&self) -> bool {
157 self.no_cache
158 }
159
160 #[must_use]
162 pub const fn threads(&self) -> usize {
163 self.threads
164 }
165
166 #[must_use]
168 pub const fn diff_index(&self) -> Option<&DiffIndex> {
169 self.diff.as_ref()
170 }
171
172 #[must_use]
174 pub const fn production_override(&self) -> Option<bool> {
175 self.production_override
176 }
177
178 #[must_use]
180 pub fn changed_since(&self) -> Option<&str> {
181 self.changed_since.as_deref()
182 }
183
184 #[must_use]
186 pub fn workspace(&self) -> Option<&[String]> {
187 self.workspace.as_deref()
188 }
189
190 #[must_use]
192 pub fn changed_workspaces(&self) -> Option<&str> {
193 self.changed_workspaces.as_deref()
194 }
195
196 #[must_use]
198 pub const fn explain_enabled(&self) -> bool {
199 self.explain
200 }
201}
202
203fn default_threads() -> usize {
204 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
205}
206
207fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
208 if path == Path::new("-") {
209 return Err(ProgrammaticError::new(
210 "`diff_file` does not support stdin; pass a file path",
211 2,
212 )
213 .with_code("FALLOW_INVALID_DIFF_FILE")
214 .with_context("analysis.diffFile"));
215 }
216 let abs = if is_absolute_path_any_platform(path) {
217 path.to_path_buf()
218 } else {
219 root.join(path)
220 };
221 let meta = std::fs::metadata(&abs).map_err(|err| {
222 ProgrammaticError::new(
223 format!(
224 "diff file does not exist or cannot be read: {} ({err})",
225 abs.display()
226 ),
227 2,
228 )
229 .with_code("FALLOW_INVALID_DIFF_FILE")
230 .with_context("analysis.diffFile")
231 })?;
232 if !meta.is_file() {
233 return Err(ProgrammaticError::new(
234 format!("diff path is not a file: {}", abs.display()),
235 2,
236 )
237 .with_code("FALLOW_INVALID_DIFF_FILE")
238 .with_context("analysis.diffFile"));
239 }
240 if meta.len() > MAX_DIFF_BYTES {
241 return Err(ProgrammaticError::new(
242 format!(
243 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
244 meta.len(),
245 abs.display()
246 ),
247 2,
248 )
249 .with_code("FALLOW_INVALID_DIFF_FILE")
250 .with_context("analysis.diffFile"));
251 }
252 let text = std::fs::read_to_string(&abs).map_err(|err| {
253 ProgrammaticError::new(
254 format!("failed to read diff file {}: {err}", abs.display()),
255 2,
256 )
257 .with_code("FALLOW_INVALID_DIFF_FILE")
258 .with_context("analysis.diffFile")
259 })?;
260 Ok(DiffIndex::from_unified_diff(&text))
261}
262
263pub fn changed_files_for_run(
264 resolved: &ProgrammaticAnalysisContext,
265) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
266 let Some(git_ref) = resolved.changed_since.as_deref() else {
267 return Ok(None);
268 };
269 fallow_engine::changed_files(&resolved.root, git_ref)
270 .map(Some)
271 .map_err(|err| {
272 ProgrammaticError::new(
273 format!(
274 "failed to resolve changed files for ref `{git_ref}`: {}",
275 err.describe()
276 ),
277 2,
278 )
279 .with_code("FALLOW_CHANGED_FILES_FAILED")
280 .with_context("analysis.changedSince")
281 })
282}
283
284fn resolve_workspace_scope(
285 root: &Path,
286 workspace: Option<&[String]>,
287 changed_workspaces: Option<&str>,
288) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
289 match (workspace, changed_workspaces) {
290 (Some(patterns), None) => resolve_workspace_filters(root, patterns).map(Some),
291 (None, Some(git_ref)) => resolve_changed_workspaces(root, git_ref).map(Some),
292 (None, None) => Ok(None),
293 (Some(_), Some(_)) => Err(ProgrammaticError::new(
294 "`workspace` and `changed_workspaces` are mutually exclusive",
295 2,
296 )
297 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
298 .with_context("analysis.workspace")),
299 }
300}
301
302pub fn resolve_workspace_filters(
303 root: &Path,
304 patterns: &[String],
305) -> ProgrammaticResult<Vec<PathBuf>> {
306 let workspaces = fallow_config::discover_workspaces(root);
307 if workspaces.is_empty() {
308 let joined = patterns
309 .iter()
310 .map(|pattern| format!("'{pattern}'"))
311 .collect::<Vec<_>>()
312 .join(", ");
313 return Err(ProgrammaticError::new(
314 format!(
315 "`workspace` {joined} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
316 ),
317 2,
318 )
319 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
320 .with_context("analysis.workspace"));
321 }
322
323 let rel_paths = workspaces
324 .iter()
325 .map(|workspace| relative_workspace_path(&workspace.root, root))
326 .collect::<Vec<_>>();
327 let (positive, negative) = split_workspace_patterns(patterns);
328 let mut matched = match_positive_workspace_patterns(&positive, &workspaces, &rel_paths)?;
329
330 for pattern in &negative {
331 for index in find_workspace_matches(pattern, &workspaces, &rel_paths)? {
332 matched.remove(&index);
333 }
334 }
335
336 if matched.is_empty() {
337 return Err(
338 ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
339 .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
340 .with_context("analysis.workspace"),
341 );
342 }
343
344 let mut roots = matched
345 .into_iter()
346 .map(|index| workspaces[index].root.clone())
347 .collect::<Vec<_>>();
348 roots.sort();
349 Ok(roots)
350}
351
352fn resolve_changed_workspaces(root: &Path, git_ref: &str) -> ProgrammaticResult<Vec<PathBuf>> {
353 let workspaces = fallow_config::discover_workspaces(root);
354 if workspaces.is_empty() {
355 return Err(ProgrammaticError::new(
356 format!(
357 "`changed_workspaces` '{git_ref}' specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
358 ),
359 2,
360 )
361 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
362 .with_context("analysis.changedWorkspaces"));
363 }
364 let changed_files = fallow_engine::changed_files(root, git_ref).map_err(|err| {
365 ProgrammaticError::new(
366 format!(
367 "failed to resolve changed workspaces for ref `{git_ref}`: {}",
368 err.describe()
369 ),
370 2,
371 )
372 .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
373 .with_context("analysis.changedWorkspaces")
374 })?;
375 let mut roots = workspaces
376 .into_iter()
377 .filter(|workspace| {
378 changed_files
379 .iter()
380 .any(|file| file.starts_with(&workspace.root))
381 })
382 .map(|workspace| workspace.root)
383 .collect::<Vec<_>>();
384 roots.sort();
385 Ok(roots)
386}
387
388fn match_positive_workspace_patterns(
389 positive: &[&str],
390 workspaces: &[WorkspaceInfo],
391 rel_paths: &[String],
392) -> ProgrammaticResult<FxHashSet<usize>> {
393 let mut matched = FxHashSet::default();
394 let mut unmatched = Vec::new();
395
396 if positive.is_empty() {
397 matched.extend(0..workspaces.len());
398 } else {
399 for pattern in positive {
400 let hits = find_workspace_matches(pattern, workspaces, rel_paths)?;
401 if hits.is_empty() {
402 unmatched.push((*pattern).to_string());
403 }
404 matched.extend(hits);
405 }
406 }
407
408 if !unmatched.is_empty() {
409 return Err(ProgrammaticError::new(
410 format!(
411 "`workspace` matched no workspace for pattern{}: {}. Available: {}",
412 if unmatched.len() == 1 { "" } else { "s" },
413 unmatched
414 .iter()
415 .map(|pattern| format!("'{pattern}'"))
416 .collect::<Vec<_>>()
417 .join(", "),
418 format_available_workspaces(workspaces),
419 ),
420 2,
421 )
422 .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
423 .with_context("analysis.workspace"));
424 }
425
426 Ok(matched)
427}
428
429fn find_workspace_matches(
430 pattern: &str,
431 workspaces: &[WorkspaceInfo],
432 rel_paths: &[String],
433) -> ProgrammaticResult<Vec<usize>> {
434 if let Some(index) = workspaces
435 .iter()
436 .position(|workspace| workspace.name == pattern)
437 {
438 return Ok(vec![index]);
439 }
440 if let Some(index) = rel_paths.iter().position(|path| path == pattern) {
441 return Ok(vec![index]);
442 }
443
444 let glob = Glob::new(pattern).map_err(|err| {
445 ProgrammaticError::new(format!("invalid `workspace` pattern '{pattern}': {err}"), 2)
446 .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
447 .with_context("analysis.workspace")
448 })?;
449 let matcher = glob.compile_matcher();
450 let hits = workspaces
451 .iter()
452 .enumerate()
453 .filter_map(|(index, workspace)| {
454 (matcher.is_match(&workspace.name) || matcher.is_match(&rel_paths[index]))
455 .then_some(index)
456 })
457 .collect();
458 Ok(hits)
459}
460
461fn split_workspace_patterns(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
462 let mut positive = Vec::new();
463 let mut negative = Vec::new();
464 for pattern in patterns {
465 let trimmed = pattern.trim();
466 if trimmed.is_empty() {
467 continue;
468 }
469 if let Some(negative_pattern) = trimmed.strip_prefix('!') {
470 let negative_pattern = negative_pattern.trim();
471 if !negative_pattern.is_empty() {
472 negative.push(negative_pattern);
473 }
474 } else {
475 positive.push(trimmed);
476 }
477 }
478 (positive, negative)
479}
480
481fn format_available_workspaces(workspaces: &[WorkspaceInfo]) -> String {
482 const MAX_SHOWN: usize = 10;
483 let total = workspaces.len();
484 if total <= MAX_SHOWN {
485 return workspaces
486 .iter()
487 .map(|workspace| workspace.name.as_str())
488 .collect::<Vec<_>>()
489 .join(", ");
490 }
491 let shown = workspaces
492 .iter()
493 .take(MAX_SHOWN)
494 .map(|workspace| workspace.name.as_str())
495 .collect::<Vec<_>>()
496 .join(", ");
497 format!(
498 "{shown}, ... and {} more ({total} total)",
499 total - MAX_SHOWN
500 )
501}
502
503fn relative_workspace_path(workspace_root: &Path, root: &Path) -> String {
504 workspace_root
505 .strip_prefix(root)
506 .unwrap_or(workspace_root)
507 .to_string_lossy()
508 .replace('\\', "/")
509}