1use std::collections::{BTreeMap, BTreeSet, HashSet};
12use std::marker::PhantomData;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use crate::compat::arg_shape::ArgShape;
17use crate::compat::call_sites::{DynamicCall, scan_hunk};
18use crate::compat::diff;
19use crate::compat::evaluate::{AnalyzedInputs, PinEvalOptions, PostimageTally, evaluate_pin};
20use crate::compat::patch_facts::{
21 classify as classify_patch_facts, is_only_test_visibility_change, suggest_suites,
22};
23use crate::compat::scope::{PinScope, UntrackedTally};
24use crate::config::FamilyDefaults;
25use crate::erlang_macros::MacroTable;
26use crate::errors::PatchError;
27use crate::model::names::{Mfa, ModuleName, ProjectName, RecordName, RelativePath};
28use crate::model::pin::Pin;
29use crate::model::snapshot::{Snapshot, state};
30use crate::model::symbol::{RefOrigin, SymbolKind, SymbolRef};
31use crate::model::verdict::{
32 AlreadyPresent, ContentPresence, Diagnostics, IndirectCallTally, MacroValueTally, PinVerdict,
33 SeriesEvaluation, SeriesVerdict, ShapeCheckTally, TargetAxisSlot, Unanalyzed,
34};
35
36pub use crate::compat::diff::PATCH_SIZE_LIMIT;
37
38pub mod patch_state {
39 mod private {
40 pub trait Sealed {}
41 }
42
43 pub trait PatchState: private::Sealed {}
45
46 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47 pub struct Raw;
48
49 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50 pub struct Analyzed;
51
52 impl private::Sealed for Raw {}
53 impl private::Sealed for Analyzed {}
54 impl PatchState for Raw {}
55 impl PatchState for Analyzed {}
56}
57
58pub use patch_state::{Analyzed, Raw};
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum SourceKind {
62 Erlang,
63 Elixir,
64 CuttlefishSchema,
71 Other,
72}
73
74impl SourceKind {
75 pub fn from_path(path: &str) -> Self {
76 if path.ends_with(".erl") || path.ends_with(".hrl") {
77 Self::Erlang
78 } else if path.ends_with(".ex") || path.ends_with(".exs") {
79 Self::Elixir
80 } else if path.ends_with(".schema")
81 || path.ends_with(".snippets")
82 || path.ends_with(".partial")
83 {
84 Self::CuttlefishSchema
85 } else {
86 Self::Other
87 }
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct PatchedFile {
93 pub old_path: Option<PathBuf>,
94 pub new_path: Option<PathBuf>,
95 pub language: SourceKind,
96 pub binary: bool,
97 pub hunks: Vec<Hunk>,
98}
99
100impl PatchedFile {
101 pub fn primary_path(&self) -> Option<&Path> {
104 self.new_path.as_deref().or(self.old_path.as_deref())
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Hunk {
110 pub old_start: usize,
111 pub old_count: usize,
112 pub new_start: usize,
113 pub new_count: usize,
114 pub lines: Vec<HunkLine>,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum HunkLine {
119 Context(String),
120 Added(String),
121 Removed(String),
122}
123
124impl HunkLine {
125 pub fn is_addition(&self) -> bool {
126 matches!(self, Self::Added(_))
127 }
128
129 pub fn text(&self) -> &str {
130 match self {
131 Self::Context(s) | Self::Added(s) | Self::Removed(s) => s.as_str(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Patch<S: patch_state::PatchState = Raw> {
138 files: Vec<PatchedFile>,
139 referenced: Vec<SymbolRef>,
140 defined: Vec<SymbolRef>,
141 dynamic_calls: Vec<DynamicCall>,
142 unsupported_files: Vec<PathBuf>,
143 call_args: Vec<(Mfa, Vec<ArgShape>)>,
144 _state: PhantomData<S>,
145}
146
147impl<S: patch_state::PatchState> Patch<S> {
148 pub fn files(&self) -> &[PatchedFile] {
149 &self.files
150 }
151}
152
153impl Patch<Raw> {
154 pub fn parse(input: &[u8]) -> Result<Self, PatchError> {
155 let files = diff::parse(input)?;
156 Ok(Self {
157 files,
158 referenced: Vec::new(),
159 defined: Vec::new(),
160 dynamic_calls: Vec::new(),
161 unsupported_files: Vec::new(),
162 call_args: Vec::new(),
163 _state: PhantomData,
164 })
165 }
166
167 #[must_use]
170 pub fn analyze(self) -> Patch<Analyzed> {
171 self.analyze_with_macros(&BTreeMap::new())
172 }
173
174 #[must_use]
188 pub fn analyze_with_macros(
189 self,
190 macros_by_path: &BTreeMap<PathBuf, MacroTable>,
191 ) -> Patch<Analyzed> {
192 let mut referenced: Vec<SymbolRef> = Vec::new();
193 let mut defined: Vec<SymbolRef> = Vec::new();
194 let mut dynamic_calls: Vec<DynamicCall> = Vec::new();
195 let mut unsupported_files: Vec<PathBuf> = Vec::new();
196 let mut call_args: Vec<(Mfa, Vec<ArgShape>)> = Vec::new();
197 let empty = MacroTable::new();
198 for file in &self.files {
199 if file.binary {
200 continue;
201 }
202 let file_macros = file
203 .primary_path()
204 .and_then(|p| macros_by_path.get(p))
205 .unwrap_or(&empty);
206 match file.language {
207 SourceKind::Erlang => {
208 for hunk in &file.hunks {
209 let lines: Vec<(RefOrigin, &str)> = hunk
210 .lines
211 .iter()
212 .filter_map(|line| match line {
213 HunkLine::Added(text) => Some((RefOrigin::Added, text.as_str())),
214 HunkLine::Context(text) => {
215 Some((RefOrigin::Context, text.as_str()))
216 }
217 HunkLine::Removed(_) => None,
218 })
219 .collect();
220 let scan = scan_hunk(&lines, file_macros);
221 referenced.extend(scan.referenced);
222 defined.extend(scan.defined);
223 dynamic_calls.extend(scan.dynamic_calls);
224 call_args.extend(scan.call_args);
225 }
226 }
227 SourceKind::CuttlefishSchema => {}
230 SourceKind::Elixir => {
231 if let Some(path) = file.primary_path() {
232 unsupported_files.push(path.to_path_buf());
233 }
234 }
235 SourceKind::Other => {}
236 }
237 }
238 referenced.sort();
239 referenced.dedup_by(|a, b| a.kind == b.kind);
241 defined.sort();
242 defined.dedup();
243 unsupported_files.sort();
244 unsupported_files.dedup();
245 Patch {
246 files: self.files,
247 referenced,
248 defined,
249 dynamic_calls,
250 unsupported_files,
251 call_args,
252 _state: PhantomData,
253 }
254 }
255}
256
257#[derive(Debug, Clone, Default, PartialEq, Eq)]
262pub struct EvaluationFiles {
263 files: BTreeMap<PathBuf, Option<Vec<u8>>>,
264}
265
266impl EvaluationFiles {
267 pub fn new() -> Self {
268 Self::default()
269 }
270
271 #[must_use]
272 pub fn with(mut self, path: impl Into<PathBuf>, contents: Option<Vec<u8>>) -> Self {
273 self.files.insert(path.into(), contents);
274 self
275 }
276
277 pub fn get(&self, path: &Path) -> Option<&Option<Vec<u8>>> {
278 self.files.get(path)
279 }
280
281 pub fn is_empty(&self) -> bool {
282 self.files.is_empty()
283 }
284
285 pub fn len(&self) -> usize {
286 self.files.len()
287 }
288}
289
290pub mod evaluation_state {
291 use super::{EvaluationFiles, PinScope};
292
293 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
298 pub struct Pinned;
299
300 #[derive(Debug, Clone, PartialEq, Eq)]
301 pub struct Scoped {
302 pub(super) scope: PinScope,
303 }
304
305 #[derive(Debug, Clone, PartialEq, Eq)]
306 pub struct Sourced {
307 pub(super) scope: PinScope,
308 pub(super) files: EvaluationFiles,
309 }
310}
311
312pub use evaluation_state::{Pinned, Scoped, Sourced};
313
314#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct EvaluationContext<S = Scoped> {
328 pin: Pin,
329 snapshot: Arc<Snapshot<state::Canonical>>,
331 source_snapshot: Option<Arc<Snapshot<state::Canonical>>>,
332 family_defaults: Option<FamilyDefaults>,
333 extras: S,
334}
335
336impl EvaluationContext<Pinned> {
337 pub fn for_pin(pin: Pin, snapshot: impl Into<Arc<Snapshot<state::Canonical>>>) -> Self {
338 Self {
339 pin,
340 snapshot: snapshot.into(),
341 source_snapshot: None,
342 family_defaults: None,
343 extras: Pinned,
344 }
345 }
346
347 #[must_use]
348 pub fn with_scope(self, scope: PinScope) -> EvaluationContext<Scoped> {
349 EvaluationContext {
350 pin: self.pin,
351 snapshot: self.snapshot,
352 source_snapshot: self.source_snapshot,
353 family_defaults: self.family_defaults,
354 extras: Scoped { scope },
355 }
356 }
357}
358
359impl EvaluationContext<Scoped> {
360 pub fn new(
362 pin: Pin,
363 snapshot: impl Into<Arc<Snapshot<state::Canonical>>>,
364 scope: PinScope,
365 ) -> Self {
366 EvaluationContext::for_pin(pin, snapshot).with_scope(scope)
367 }
368
369 #[must_use]
370 pub fn with_files(self, files: EvaluationFiles) -> EvaluationContext<Sourced> {
371 EvaluationContext {
372 pin: self.pin,
373 snapshot: self.snapshot,
374 source_snapshot: self.source_snapshot,
375 family_defaults: self.family_defaults,
376 extras: Sourced {
377 scope: self.extras.scope,
378 files,
379 },
380 }
381 }
382}
383
384impl<S> EvaluationContext<S> {
385 #[must_use]
386 pub fn with_source_snapshot(
387 mut self,
388 source: impl Into<Arc<Snapshot<state::Canonical>>>,
389 ) -> Self {
390 self.source_snapshot = Some(source.into());
391 self
392 }
393
394 #[must_use]
397 pub fn with_family_defaults(mut self, defaults: FamilyDefaults) -> Self {
398 self.family_defaults = Some(defaults);
399 self
400 }
401}
402
403pub trait EvaluationInput {
406 fn pin(&self) -> &Pin;
407 fn snapshot(&self) -> &Snapshot<state::Canonical>;
408 fn scope(&self) -> &PinScope;
409 fn files(&self) -> Option<&EvaluationFiles>;
410 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>>;
411 fn family_defaults(&self) -> Option<&FamilyDefaults>;
412}
413
414impl EvaluationInput for EvaluationContext<Scoped> {
415 fn pin(&self) -> &Pin {
416 &self.pin
417 }
418 fn snapshot(&self) -> &Snapshot<state::Canonical> {
419 &self.snapshot
420 }
421 fn scope(&self) -> &PinScope {
422 &self.extras.scope
423 }
424 fn files(&self) -> Option<&EvaluationFiles> {
425 None
426 }
427 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
428 self.source_snapshot.as_deref()
429 }
430 fn family_defaults(&self) -> Option<&FamilyDefaults> {
431 self.family_defaults.as_ref()
432 }
433}
434
435impl EvaluationInput for EvaluationContext<Sourced> {
436 fn pin(&self) -> &Pin {
437 &self.pin
438 }
439 fn snapshot(&self) -> &Snapshot<state::Canonical> {
440 &self.snapshot
441 }
442 fn scope(&self) -> &PinScope {
443 &self.extras.scope
444 }
445 fn files(&self) -> Option<&EvaluationFiles> {
446 Some(&self.extras.files)
447 }
448 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
449 self.source_snapshot.as_deref()
450 }
451 fn family_defaults(&self) -> Option<&FamilyDefaults> {
452 self.family_defaults.as_ref()
453 }
454}
455
456impl Patch<Analyzed> {
457 pub fn referenced(&self) -> &[SymbolRef] {
458 &self.referenced
459 }
460
461 pub fn defined(&self) -> &[SymbolRef] {
462 &self.defined
463 }
464
465 #[must_use]
471 pub fn with_extra_references(mut self, refs: impl IntoIterator<Item = SymbolRef>) -> Self {
472 self.referenced.extend(refs);
473 self.referenced.sort();
474 self.referenced.dedup_by(|a, b| a.kind == b.kind);
475 self
476 }
477
478 pub fn is_only_test_visibility_change(&self) -> bool {
484 is_only_test_visibility_change(&self.files)
485 }
486
487 fn analyzed_inputs(&self) -> AnalyzedInputs<'_> {
488 AnalyzedInputs {
489 files: &self.files,
490 referenced: &self.referenced,
491 defined: &self.defined,
492 unsupported_files: &self.unsupported_files,
493 call_args: &self.call_args,
494 }
495 }
496
497 pub fn evaluate_series<C: EvaluationInput>(self, contexts: &[C]) -> SeriesEvaluation {
503 let series_modules: BTreeSet<ModuleName> = contexts
504 .iter()
505 .flat_map(|c| c.scope().modules().iter().cloned())
506 .collect();
507 let series_records: BTreeSet<RecordName> = contexts
508 .iter()
509 .flat_map(|c| c.scope().records().iter().cloned())
510 .collect();
511 let mut untracked_calls = UntrackedTally::default();
512 let mut untracked_records: BTreeMap<RecordName, usize> = BTreeMap::new();
513 let defined_index: HashSet<&SymbolKind> = self.defined.iter().map(|d| &d.kind).collect();
514 for r in &self.referenced {
515 if defined_index.contains(&r.kind) {
516 continue;
517 }
518 match &r.kind {
519 SymbolKind::Function { mfa } if !series_modules.contains(&mfa.module) => {
520 untracked_calls.record(mfa.module.clone());
521 }
522 SymbolKind::FunctionAnyArity { module, .. } if !series_modules.contains(module) => {
523 untracked_calls.record(module.clone());
524 }
525 SymbolKind::Record { name } if !series_records.contains(name) => {
526 *untracked_records.entry(name.clone()).or_insert(0) += 1;
527 }
528 _ => {}
529 }
530 }
531 let mut results = Vec::with_capacity(contexts.len());
532 let mut context_refs_missing: BTreeMap<ModuleName, usize> = BTreeMap::new();
533 let mut best_presence: Option<ContentPresence> = None;
536 for ctx in contexts {
537 let r = evaluate_pin(
538 self.analyzed_inputs(),
539 ctx.snapshot(),
540 PinEvalOptions {
541 source_snapshot: ctx.source_snapshot(),
542 pin_files: ctx.files(),
543 scope: Some(ctx.scope()),
544 family_defaults: ctx.family_defaults(),
545 },
546 );
547 if let Some(tally) = r.postimage {
548 let denser = best_presence
549 .as_ref()
550 .is_none_or(|b| tally.considered > b.hunks_considered);
551 if denser {
552 best_presence = Some(content_presence_from_tally(
553 ctx.pin().project.clone(),
554 tally,
555 ));
556 }
557 }
558 for (module, count) in r.context_missing {
560 *context_refs_missing.entry(module).or_insert(0) += count;
561 }
562 results.push(
563 PinVerdict::new(ctx.pin().clone(), r.verdict)
564 .with_tracked_ref_details(r.tracked_refs)
565 .with_source_delta_details(r.source_deltas),
566 );
567 }
568 let mut unanalyzed = Unanalyzed::default();
569 for d in &self.dynamic_calls {
570 match d {
571 DynamicCall::Apply => unanalyzed.apply += 1,
572 DynamicCall::VariableDispatch => unanalyzed.variable_dispatch += 1,
573 }
574 }
575 let already_present = best_presence.map(|content| AlreadyPresent {
576 identical: None,
577 content: Some(content),
578 });
579 SeriesEvaluation {
580 verdict: SeriesVerdict::from_results(results),
581 diagnostics: Diagnostics {
582 untracked_calls: untracked_calls.into_map(),
583 untracked_records,
584 context_refs_missing,
585 unattributed_paths: BTreeMap::new(),
586 unanalyzed,
587 suggested_suites: suggest_suites(&self.files),
588 missing_test_modules: BTreeMap::new(),
589 already_present,
590 already_present_skipped: None,
591 dep_pin_divergence: Vec::new(),
592 pin_bumps: Vec::new(),
593 qualified_call_shape_checks: ShapeCheckTally::default(),
594 local_call_shape_checks: ShapeCheckTally::default(),
595 macro_value_checks: MacroValueTally::default(),
596 indirect_call_checks: IndirectCallTally::default(),
597 dormant_detectors: Vec::new(),
598 },
599 patch_facts: classify_patch_facts(&self.files),
600 touched_paths: collect_touched_paths(&self.files),
601 pr_commits: None,
602 target: TargetAxisSlot::absent(),
603 }
604 }
605}
606
607fn content_presence_from_tally(pin: ProjectName, tally: PostimageTally) -> ContentPresence {
611 let mut per_file = BTreeMap::new();
612 for (path, counts) in tally.per_file {
613 let Some(s) = path.to_str() else { continue };
614 let Ok(rel) = RelativePath::new(s.to_owned()) else {
615 continue;
616 };
617 per_file.insert(rel, counts);
618 }
619 ContentPresence {
620 pin,
621 hunks_already_applied: tally.applied,
622 hunks_considered: tally.considered,
623 hunks_ambiguous: tally.ambiguous,
624 hunks_low_confidence: tally.low_confidence,
625 per_file,
626 }
627}
628
629fn collect_touched_paths(files: &[PatchedFile]) -> Vec<RelativePath> {
630 let mut seen: BTreeSet<RelativePath> = BTreeSet::new();
631 let mut out = Vec::with_capacity(files.len());
632 for file in files {
633 for p in [file.new_path.as_ref(), file.old_path.as_ref()]
634 .into_iter()
635 .flatten()
636 {
637 let Some(s) = p.to_str() else { continue };
638 let Ok(rel) = RelativePath::new(s.to_owned()) else {
639 continue;
640 };
641 if seen.insert(rel.clone()) {
642 out.push(rel);
643 }
644 }
645 }
646 out
647}