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, Unanalyzed,
34};
35
36pub use crate::compat::diff::PATCH_SIZE_LIMIT;
37
38pub mod patch_state {
39 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40 pub struct Raw;
41
42 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43 pub struct Analyzed;
44}
45
46pub use patch_state::{Analyzed, Raw};
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum SourceKind {
50 Erlang,
51 Elixir,
52 CuttlefishSchema,
59 Other,
60}
61
62impl SourceKind {
63 pub fn from_path(path: &str) -> Self {
64 if path.ends_with(".erl") || path.ends_with(".hrl") {
65 Self::Erlang
66 } else if path.ends_with(".ex") || path.ends_with(".exs") {
67 Self::Elixir
68 } else if path.ends_with(".schema")
69 || path.ends_with(".snippets")
70 || path.ends_with(".partial")
71 {
72 Self::CuttlefishSchema
73 } else {
74 Self::Other
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct PatchedFile {
81 pub old_path: Option<PathBuf>,
82 pub new_path: Option<PathBuf>,
83 pub language: SourceKind,
84 pub binary: bool,
85 pub hunks: Vec<Hunk>,
86}
87
88impl PatchedFile {
89 pub fn primary_path(&self) -> Option<&Path> {
92 self.new_path.as_deref().or(self.old_path.as_deref())
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Hunk {
98 pub old_start: usize,
99 pub old_count: usize,
100 pub new_start: usize,
101 pub new_count: usize,
102 pub lines: Vec<HunkLine>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum HunkLine {
107 Context(String),
108 Added(String),
109 Removed(String),
110}
111
112impl HunkLine {
113 pub fn is_addition(&self) -> bool {
114 matches!(self, Self::Added(_))
115 }
116
117 pub fn text(&self) -> &str {
118 match self {
119 Self::Context(s) | Self::Added(s) | Self::Removed(s) => s.as_str(),
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Patch<S = Raw> {
126 pub files: Vec<PatchedFile>,
127 referenced: Vec<SymbolRef>,
128 defined: Vec<SymbolRef>,
129 dynamic_calls: Vec<DynamicCall>,
130 unsupported_files: Vec<PathBuf>,
131 call_args: Vec<(Mfa, Vec<ArgShape>)>,
132 _state: PhantomData<S>,
133}
134
135impl Patch<Raw> {
136 pub fn parse(input: &[u8]) -> Result<Self, PatchError> {
137 let files = diff::parse(input)?;
138 Ok(Self {
139 files,
140 referenced: Vec::new(),
141 defined: Vec::new(),
142 dynamic_calls: Vec::new(),
143 unsupported_files: Vec::new(),
144 call_args: Vec::new(),
145 _state: PhantomData,
146 })
147 }
148
149 #[must_use]
152 pub fn analyze(self) -> Patch<Analyzed> {
153 self.analyze_with_macros(&BTreeMap::new())
154 }
155
156 #[must_use]
170 pub fn analyze_with_macros(
171 self,
172 macros_by_path: &BTreeMap<PathBuf, MacroTable>,
173 ) -> Patch<Analyzed> {
174 let mut referenced: Vec<SymbolRef> = Vec::new();
175 let mut defined: Vec<SymbolRef> = Vec::new();
176 let mut dynamic_calls: Vec<DynamicCall> = Vec::new();
177 let mut unsupported_files: Vec<PathBuf> = Vec::new();
178 let mut call_args: Vec<(Mfa, Vec<ArgShape>)> = Vec::new();
179 let empty = MacroTable::new();
180 for file in &self.files {
181 if file.binary {
182 continue;
183 }
184 let file_macros = file
185 .primary_path()
186 .and_then(|p| macros_by_path.get(p))
187 .unwrap_or(&empty);
188 match file.language {
189 SourceKind::Erlang => {
190 for hunk in &file.hunks {
191 let lines: Vec<(RefOrigin, &str)> = hunk
192 .lines
193 .iter()
194 .filter_map(|line| match line {
195 HunkLine::Added(text) => Some((RefOrigin::Added, text.as_str())),
196 HunkLine::Context(text) => {
197 Some((RefOrigin::Context, text.as_str()))
198 }
199 HunkLine::Removed(_) => None,
200 })
201 .collect();
202 let scan = scan_hunk(&lines, file_macros);
203 referenced.extend(scan.referenced);
204 defined.extend(scan.defined);
205 dynamic_calls.extend(scan.dynamic_calls);
206 call_args.extend(scan.call_args);
207 }
208 }
209 SourceKind::CuttlefishSchema => {}
215 SourceKind::Elixir => {
216 if let Some(path) = file.primary_path() {
217 unsupported_files.push(path.to_path_buf());
218 }
219 }
220 SourceKind::Other => {}
221 }
222 }
223 referenced.sort();
224 referenced.dedup_by(|a, b| a.kind == b.kind);
227 defined.sort();
228 defined.dedup();
229 unsupported_files.sort();
230 unsupported_files.dedup();
231 Patch {
232 files: self.files,
233 referenced,
234 defined,
235 dynamic_calls,
236 unsupported_files,
237 call_args,
238 _state: PhantomData,
239 }
240 }
241}
242
243#[derive(Debug, Clone, Default, PartialEq, Eq)]
248pub struct EvaluationFiles {
249 files: BTreeMap<PathBuf, Option<Vec<u8>>>,
250}
251
252impl EvaluationFiles {
253 pub fn new() -> Self {
254 Self::default()
255 }
256
257 #[must_use]
258 pub fn with(mut self, path: impl Into<PathBuf>, contents: Option<Vec<u8>>) -> Self {
259 self.files.insert(path.into(), contents);
260 self
261 }
262
263 pub fn get(&self, path: &Path) -> Option<&Option<Vec<u8>>> {
264 self.files.get(path)
265 }
266
267 pub fn is_empty(&self) -> bool {
268 self.files.is_empty()
269 }
270
271 pub fn len(&self) -> usize {
272 self.files.len()
273 }
274}
275
276pub mod evaluation_state {
277 use super::{EvaluationFiles, PinScope};
278
279 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
284 pub struct Pinned;
285
286 #[derive(Debug, Clone, PartialEq, Eq)]
287 pub struct Scoped {
288 pub(super) scope: PinScope,
289 }
290
291 #[derive(Debug, Clone, PartialEq, Eq)]
292 pub struct Sourced {
293 pub(super) scope: PinScope,
294 pub(super) files: EvaluationFiles,
295 }
296}
297
298pub use evaluation_state::{Pinned, Scoped, Sourced};
299
300#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct EvaluationContext<S = Scoped> {
314 pin: Pin,
315 snapshot: Arc<Snapshot<state::Canonical>>,
318 source_snapshot: Option<Arc<Snapshot<state::Canonical>>>,
319 family_defaults: Option<FamilyDefaults>,
320 extras: S,
321}
322
323impl EvaluationContext<Pinned> {
324 pub fn for_pin(pin: Pin, snapshot: impl Into<Arc<Snapshot<state::Canonical>>>) -> Self {
325 Self {
326 pin,
327 snapshot: snapshot.into(),
328 source_snapshot: None,
329 family_defaults: None,
330 extras: Pinned,
331 }
332 }
333
334 #[must_use]
335 pub fn with_scope(self, scope: PinScope) -> EvaluationContext<Scoped> {
336 EvaluationContext {
337 pin: self.pin,
338 snapshot: self.snapshot,
339 source_snapshot: self.source_snapshot,
340 family_defaults: self.family_defaults,
341 extras: Scoped { scope },
342 }
343 }
344}
345
346impl EvaluationContext<Scoped> {
347 pub fn new(
349 pin: Pin,
350 snapshot: impl Into<Arc<Snapshot<state::Canonical>>>,
351 scope: PinScope,
352 ) -> Self {
353 EvaluationContext::for_pin(pin, snapshot).with_scope(scope)
354 }
355
356 #[must_use]
357 pub fn with_files(self, files: EvaluationFiles) -> EvaluationContext<Sourced> {
358 EvaluationContext {
359 pin: self.pin,
360 snapshot: self.snapshot,
361 source_snapshot: self.source_snapshot,
362 family_defaults: self.family_defaults,
363 extras: Sourced {
364 scope: self.extras.scope,
365 files,
366 },
367 }
368 }
369}
370
371impl<S> EvaluationContext<S> {
372 #[must_use]
373 pub fn with_source_snapshot(
374 mut self,
375 source: impl Into<Arc<Snapshot<state::Canonical>>>,
376 ) -> Self {
377 self.source_snapshot = Some(source.into());
378 self
379 }
380
381 #[must_use]
384 pub fn with_family_defaults(mut self, defaults: FamilyDefaults) -> Self {
385 self.family_defaults = Some(defaults);
386 self
387 }
388}
389
390pub trait EvaluationInput {
393 fn pin(&self) -> &Pin;
394 fn snapshot(&self) -> &Snapshot<state::Canonical>;
395 fn scope(&self) -> &PinScope;
396 fn files(&self) -> Option<&EvaluationFiles>;
397 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>>;
398 fn family_defaults(&self) -> Option<&FamilyDefaults>;
399}
400
401impl EvaluationInput for EvaluationContext<Scoped> {
402 fn pin(&self) -> &Pin {
403 &self.pin
404 }
405 fn snapshot(&self) -> &Snapshot<state::Canonical> {
406 &self.snapshot
407 }
408 fn scope(&self) -> &PinScope {
409 &self.extras.scope
410 }
411 fn files(&self) -> Option<&EvaluationFiles> {
412 None
413 }
414 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
415 self.source_snapshot.as_deref()
416 }
417 fn family_defaults(&self) -> Option<&FamilyDefaults> {
418 self.family_defaults.as_ref()
419 }
420}
421
422impl EvaluationInput for EvaluationContext<Sourced> {
423 fn pin(&self) -> &Pin {
424 &self.pin
425 }
426 fn snapshot(&self) -> &Snapshot<state::Canonical> {
427 &self.snapshot
428 }
429 fn scope(&self) -> &PinScope {
430 &self.extras.scope
431 }
432 fn files(&self) -> Option<&EvaluationFiles> {
433 Some(&self.extras.files)
434 }
435 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
436 self.source_snapshot.as_deref()
437 }
438 fn family_defaults(&self) -> Option<&FamilyDefaults> {
439 self.family_defaults.as_ref()
440 }
441}
442
443impl Patch<Analyzed> {
444 pub fn referenced(&self) -> &[SymbolRef] {
445 &self.referenced
446 }
447
448 pub fn defined(&self) -> &[SymbolRef] {
449 &self.defined
450 }
451
452 #[must_use]
458 pub fn with_extra_references(mut self, refs: impl IntoIterator<Item = SymbolRef>) -> Self {
459 self.referenced.extend(refs);
460 self.referenced.sort();
461 self.referenced.dedup_by(|a, b| a.kind == b.kind);
462 self
463 }
464
465 pub fn is_only_test_visibility_change(&self) -> bool {
471 is_only_test_visibility_change(&self.files)
472 }
473
474 fn analyzed_inputs(&self) -> AnalyzedInputs<'_> {
475 AnalyzedInputs {
476 files: &self.files,
477 referenced: &self.referenced,
478 defined: &self.defined,
479 unsupported_files: &self.unsupported_files,
480 call_args: &self.call_args,
481 }
482 }
483
484 pub fn evaluate_series<C: EvaluationInput>(self, contexts: &[C]) -> SeriesEvaluation {
490 let series_modules: BTreeSet<ModuleName> = contexts
491 .iter()
492 .flat_map(|c| c.scope().modules().iter().cloned())
493 .collect();
494 let series_records: BTreeSet<RecordName> = contexts
495 .iter()
496 .flat_map(|c| c.scope().records().iter().cloned())
497 .collect();
498 let mut untracked_calls = UntrackedTally::default();
499 let mut untracked_records: BTreeMap<RecordName, usize> = BTreeMap::new();
500 let defined_index: HashSet<&SymbolKind> = self.defined.iter().map(|d| &d.kind).collect();
501 for r in &self.referenced {
502 if defined_index.contains(&r.kind) {
503 continue;
504 }
505 match &r.kind {
506 SymbolKind::Function { mfa } if !series_modules.contains(&mfa.module) => {
507 untracked_calls.record(mfa.module.clone());
508 }
509 SymbolKind::FunctionAnyArity { module, .. } if !series_modules.contains(module) => {
510 untracked_calls.record(module.clone());
511 }
512 SymbolKind::Record { name } if !series_records.contains(name) => {
513 *untracked_records.entry(name.clone()).or_insert(0) += 1;
514 }
515 _ => {}
516 }
517 }
518 let mut results = Vec::with_capacity(contexts.len());
519 let mut context_refs_missing: BTreeMap<ModuleName, usize> = BTreeMap::new();
520 let mut best_presence: Option<ContentPresence> = None;
524 for ctx in contexts {
525 let r = evaluate_pin(
526 self.analyzed_inputs(),
527 ctx.snapshot(),
528 PinEvalOptions {
529 source_snapshot: ctx.source_snapshot(),
530 pin_files: ctx.files(),
531 scope: Some(ctx.scope()),
532 family_defaults: ctx.family_defaults(),
533 },
534 );
535 if let Some(tally) = r.postimage {
536 let denser = best_presence
537 .as_ref()
538 .is_none_or(|b| tally.considered > b.hunks_considered);
539 if denser {
540 best_presence = Some(content_presence_from_tally(
541 ctx.pin().project.clone(),
542 tally,
543 ));
544 }
545 }
546 for (module, count) in r.context_missing {
549 *context_refs_missing.entry(module).or_insert(0) += count;
550 }
551 results.push(
552 PinVerdict::new(ctx.pin().clone(), r.verdict)
553 .with_tracked_ref_details(r.tracked_refs)
554 .with_source_delta_details(r.source_deltas),
555 );
556 }
557 let mut unanalyzed = Unanalyzed::default();
558 for d in &self.dynamic_calls {
559 match d {
560 DynamicCall::Apply => unanalyzed.apply += 1,
561 DynamicCall::VariableDispatch => unanalyzed.variable_dispatch += 1,
562 }
563 }
564 let already_present = best_presence.map(|content| AlreadyPresent {
565 identical: None,
566 content: Some(content),
567 });
568 SeriesEvaluation {
569 verdict: SeriesVerdict::from_results(results),
570 diagnostics: Diagnostics {
571 untracked_calls: untracked_calls.into_map(),
572 untracked_records,
573 context_refs_missing,
574 unattributed_paths: BTreeMap::new(),
575 unanalyzed,
576 suggested_suites: suggest_suites(&self.files),
577 missing_test_modules: BTreeMap::new(),
578 already_present,
579 already_present_skipped: None,
580 dep_pin_divergence: Vec::new(),
581 pin_bumps: Vec::new(),
582 qualified_call_shape_checks: ShapeCheckTally::default(),
583 local_call_shape_checks: ShapeCheckTally::default(),
584 macro_value_checks: MacroValueTally::default(),
585 indirect_call_checks: IndirectCallTally::default(),
586 },
587 patch_facts: classify_patch_facts(&self.files),
588 touched_paths: collect_touched_paths(&self.files),
589 pr_commits: None,
590 apply: None,
591 target_findings: None,
592 }
593 }
594}
595
596fn content_presence_from_tally(pin: ProjectName, tally: PostimageTally) -> ContentPresence {
600 let mut per_file = BTreeMap::new();
601 for (path, counts) in tally.per_file {
602 let Some(s) = path.to_str() else { continue };
603 let Ok(rel) = RelativePath::new(s.to_owned()) else {
604 continue;
605 };
606 per_file.insert(rel, counts);
607 }
608 ContentPresence {
609 pin,
610 hunks_already_applied: tally.applied,
611 hunks_considered: tally.considered,
612 hunks_ambiguous: tally.ambiguous,
613 hunks_low_confidence: tally.low_confidence,
614 per_file,
615 }
616}
617
618fn collect_touched_paths(files: &[PatchedFile]) -> Vec<RelativePath> {
619 let mut seen: BTreeSet<RelativePath> = BTreeSet::new();
620 let mut out = Vec::with_capacity(files.len());
621 for file in files {
622 for p in [file.new_path.as_ref(), file.old_path.as_ref()]
623 .into_iter()
624 .flatten()
625 {
626 let Some(s) = p.to_str() else { continue };
627 let Ok(rel) = RelativePath::new(s.to_owned()) else {
628 continue;
629 };
630 if seen.insert(rel.clone()) {
631 out.push(rel);
632 }
633 }
634 }
635 out
636}