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 => {}
212 SourceKind::Elixir => {
213 if let Some(path) = file.primary_path() {
214 unsupported_files.push(path.to_path_buf());
215 }
216 }
217 SourceKind::Other => {}
218 }
219 }
220 referenced.sort();
221 referenced.dedup_by(|a, b| a.kind == b.kind);
223 defined.sort();
224 defined.dedup();
225 unsupported_files.sort();
226 unsupported_files.dedup();
227 Patch {
228 files: self.files,
229 referenced,
230 defined,
231 dynamic_calls,
232 unsupported_files,
233 call_args,
234 _state: PhantomData,
235 }
236 }
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq)]
244pub struct EvaluationFiles {
245 files: BTreeMap<PathBuf, Option<Vec<u8>>>,
246}
247
248impl EvaluationFiles {
249 pub fn new() -> Self {
250 Self::default()
251 }
252
253 #[must_use]
254 pub fn with(mut self, path: impl Into<PathBuf>, contents: Option<Vec<u8>>) -> Self {
255 self.files.insert(path.into(), contents);
256 self
257 }
258
259 pub fn get(&self, path: &Path) -> Option<&Option<Vec<u8>>> {
260 self.files.get(path)
261 }
262
263 pub fn is_empty(&self) -> bool {
264 self.files.is_empty()
265 }
266
267 pub fn len(&self) -> usize {
268 self.files.len()
269 }
270}
271
272pub mod evaluation_state {
273 use super::{EvaluationFiles, PinScope};
274
275 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
280 pub struct Pinned;
281
282 #[derive(Debug, Clone, PartialEq, Eq)]
283 pub struct Scoped {
284 pub(super) scope: PinScope,
285 }
286
287 #[derive(Debug, Clone, PartialEq, Eq)]
288 pub struct Sourced {
289 pub(super) scope: PinScope,
290 pub(super) files: EvaluationFiles,
291 }
292}
293
294pub use evaluation_state::{Pinned, Scoped, Sourced};
295
296#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct EvaluationContext<S = Scoped> {
310 pin: Pin,
311 snapshot: Arc<Snapshot<state::Canonical>>,
313 source_snapshot: Option<Arc<Snapshot<state::Canonical>>>,
314 family_defaults: Option<FamilyDefaults>,
315 extras: S,
316}
317
318impl EvaluationContext<Pinned> {
319 pub fn for_pin(pin: Pin, snapshot: impl Into<Arc<Snapshot<state::Canonical>>>) -> Self {
320 Self {
321 pin,
322 snapshot: snapshot.into(),
323 source_snapshot: None,
324 family_defaults: None,
325 extras: Pinned,
326 }
327 }
328
329 #[must_use]
330 pub fn with_scope(self, scope: PinScope) -> EvaluationContext<Scoped> {
331 EvaluationContext {
332 pin: self.pin,
333 snapshot: self.snapshot,
334 source_snapshot: self.source_snapshot,
335 family_defaults: self.family_defaults,
336 extras: Scoped { scope },
337 }
338 }
339}
340
341impl EvaluationContext<Scoped> {
342 pub fn new(
344 pin: Pin,
345 snapshot: impl Into<Arc<Snapshot<state::Canonical>>>,
346 scope: PinScope,
347 ) -> Self {
348 EvaluationContext::for_pin(pin, snapshot).with_scope(scope)
349 }
350
351 #[must_use]
352 pub fn with_files(self, files: EvaluationFiles) -> EvaluationContext<Sourced> {
353 EvaluationContext {
354 pin: self.pin,
355 snapshot: self.snapshot,
356 source_snapshot: self.source_snapshot,
357 family_defaults: self.family_defaults,
358 extras: Sourced {
359 scope: self.extras.scope,
360 files,
361 },
362 }
363 }
364}
365
366impl<S> EvaluationContext<S> {
367 #[must_use]
368 pub fn with_source_snapshot(
369 mut self,
370 source: impl Into<Arc<Snapshot<state::Canonical>>>,
371 ) -> Self {
372 self.source_snapshot = Some(source.into());
373 self
374 }
375
376 #[must_use]
379 pub fn with_family_defaults(mut self, defaults: FamilyDefaults) -> Self {
380 self.family_defaults = Some(defaults);
381 self
382 }
383}
384
385pub trait EvaluationInput {
388 fn pin(&self) -> &Pin;
389 fn snapshot(&self) -> &Snapshot<state::Canonical>;
390 fn scope(&self) -> &PinScope;
391 fn files(&self) -> Option<&EvaluationFiles>;
392 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>>;
393 fn family_defaults(&self) -> Option<&FamilyDefaults>;
394}
395
396impl EvaluationInput for EvaluationContext<Scoped> {
397 fn pin(&self) -> &Pin {
398 &self.pin
399 }
400 fn snapshot(&self) -> &Snapshot<state::Canonical> {
401 &self.snapshot
402 }
403 fn scope(&self) -> &PinScope {
404 &self.extras.scope
405 }
406 fn files(&self) -> Option<&EvaluationFiles> {
407 None
408 }
409 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
410 self.source_snapshot.as_deref()
411 }
412 fn family_defaults(&self) -> Option<&FamilyDefaults> {
413 self.family_defaults.as_ref()
414 }
415}
416
417impl EvaluationInput for EvaluationContext<Sourced> {
418 fn pin(&self) -> &Pin {
419 &self.pin
420 }
421 fn snapshot(&self) -> &Snapshot<state::Canonical> {
422 &self.snapshot
423 }
424 fn scope(&self) -> &PinScope {
425 &self.extras.scope
426 }
427 fn files(&self) -> Option<&EvaluationFiles> {
428 Some(&self.extras.files)
429 }
430 fn source_snapshot(&self) -> Option<&Snapshot<state::Canonical>> {
431 self.source_snapshot.as_deref()
432 }
433 fn family_defaults(&self) -> Option<&FamilyDefaults> {
434 self.family_defaults.as_ref()
435 }
436}
437
438impl Patch<Analyzed> {
439 pub fn referenced(&self) -> &[SymbolRef] {
440 &self.referenced
441 }
442
443 pub fn defined(&self) -> &[SymbolRef] {
444 &self.defined
445 }
446
447 #[must_use]
453 pub fn with_extra_references(mut self, refs: impl IntoIterator<Item = SymbolRef>) -> Self {
454 self.referenced.extend(refs);
455 self.referenced.sort();
456 self.referenced.dedup_by(|a, b| a.kind == b.kind);
457 self
458 }
459
460 pub fn is_only_test_visibility_change(&self) -> bool {
466 is_only_test_visibility_change(&self.files)
467 }
468
469 fn analyzed_inputs(&self) -> AnalyzedInputs<'_> {
470 AnalyzedInputs {
471 files: &self.files,
472 referenced: &self.referenced,
473 defined: &self.defined,
474 unsupported_files: &self.unsupported_files,
475 call_args: &self.call_args,
476 }
477 }
478
479 pub fn evaluate_series<C: EvaluationInput>(self, contexts: &[C]) -> SeriesEvaluation {
485 let series_modules: BTreeSet<ModuleName> = contexts
486 .iter()
487 .flat_map(|c| c.scope().modules().iter().cloned())
488 .collect();
489 let series_records: BTreeSet<RecordName> = contexts
490 .iter()
491 .flat_map(|c| c.scope().records().iter().cloned())
492 .collect();
493 let mut untracked_calls = UntrackedTally::default();
494 let mut untracked_records: BTreeMap<RecordName, usize> = BTreeMap::new();
495 let defined_index: HashSet<&SymbolKind> = self.defined.iter().map(|d| &d.kind).collect();
496 for r in &self.referenced {
497 if defined_index.contains(&r.kind) {
498 continue;
499 }
500 match &r.kind {
501 SymbolKind::Function { mfa } if !series_modules.contains(&mfa.module) => {
502 untracked_calls.record(mfa.module.clone());
503 }
504 SymbolKind::FunctionAnyArity { module, .. } if !series_modules.contains(module) => {
505 untracked_calls.record(module.clone());
506 }
507 SymbolKind::Record { name } if !series_records.contains(name) => {
508 *untracked_records.entry(name.clone()).or_insert(0) += 1;
509 }
510 _ => {}
511 }
512 }
513 let mut results = Vec::with_capacity(contexts.len());
514 let mut context_refs_missing: BTreeMap<ModuleName, usize> = BTreeMap::new();
515 let mut best_presence: Option<ContentPresence> = None;
518 for ctx in contexts {
519 let r = evaluate_pin(
520 self.analyzed_inputs(),
521 ctx.snapshot(),
522 PinEvalOptions {
523 source_snapshot: ctx.source_snapshot(),
524 pin_files: ctx.files(),
525 scope: Some(ctx.scope()),
526 family_defaults: ctx.family_defaults(),
527 },
528 );
529 if let Some(tally) = r.postimage {
530 let denser = best_presence
531 .as_ref()
532 .is_none_or(|b| tally.considered > b.hunks_considered);
533 if denser {
534 best_presence = Some(content_presence_from_tally(
535 ctx.pin().project.clone(),
536 tally,
537 ));
538 }
539 }
540 for (module, count) in r.context_missing {
542 *context_refs_missing.entry(module).or_insert(0) += count;
543 }
544 results.push(
545 PinVerdict::new(ctx.pin().clone(), r.verdict)
546 .with_tracked_ref_details(r.tracked_refs)
547 .with_source_delta_details(r.source_deltas),
548 );
549 }
550 let mut unanalyzed = Unanalyzed::default();
551 for d in &self.dynamic_calls {
552 match d {
553 DynamicCall::Apply => unanalyzed.apply += 1,
554 DynamicCall::VariableDispatch => unanalyzed.variable_dispatch += 1,
555 }
556 }
557 let already_present = best_presence.map(|content| AlreadyPresent {
558 identical: None,
559 content: Some(content),
560 });
561 SeriesEvaluation {
562 verdict: SeriesVerdict::from_results(results),
563 diagnostics: Diagnostics {
564 untracked_calls: untracked_calls.into_map(),
565 untracked_records,
566 context_refs_missing,
567 unattributed_paths: BTreeMap::new(),
568 unanalyzed,
569 suggested_suites: suggest_suites(&self.files),
570 missing_test_modules: BTreeMap::new(),
571 already_present,
572 already_present_skipped: None,
573 dep_pin_divergence: Vec::new(),
574 pin_bumps: Vec::new(),
575 qualified_call_shape_checks: ShapeCheckTally::default(),
576 local_call_shape_checks: ShapeCheckTally::default(),
577 macro_value_checks: MacroValueTally::default(),
578 indirect_call_checks: IndirectCallTally::default(),
579 },
580 patch_facts: classify_patch_facts(&self.files),
581 touched_paths: collect_touched_paths(&self.files),
582 pr_commits: None,
583 apply: None,
584 target_findings: None,
585 }
586 }
587}
588
589fn content_presence_from_tally(pin: ProjectName, tally: PostimageTally) -> ContentPresence {
593 let mut per_file = BTreeMap::new();
594 for (path, counts) in tally.per_file {
595 let Some(s) = path.to_str() else { continue };
596 let Ok(rel) = RelativePath::new(s.to_owned()) else {
597 continue;
598 };
599 per_file.insert(rel, counts);
600 }
601 ContentPresence {
602 pin,
603 hunks_already_applied: tally.applied,
604 hunks_considered: tally.considered,
605 hunks_ambiguous: tally.ambiguous,
606 hunks_low_confidence: tally.low_confidence,
607 per_file,
608 }
609}
610
611fn collect_touched_paths(files: &[PatchedFile]) -> Vec<RelativePath> {
612 let mut seen: BTreeSet<RelativePath> = BTreeSet::new();
613 let mut out = Vec::with_capacity(files.len());
614 for file in files {
615 for p in [file.new_path.as_ref(), file.old_path.as_ref()]
616 .into_iter()
617 .flatten()
618 {
619 let Some(s) = p.to_str() else { continue };
620 let Ok(rel) = RelativePath::new(s.to_owned()) else {
621 continue;
622 };
623 if seen.insert(rel.clone()) {
624 out.push(rel);
625 }
626 }
627 }
628 out
629}