Skip to main content

lex_vcs/
diff_to_ops.rs

1//! Convert a `DiffReport` (+ import set deltas + old head info)
2//! into a sequence of typed operations.
3//!
4//! NOTE: `lex-cli`'s `compute_diff` (the only producer of `DiffReport`
5//! today) only diffs `Stage::FnDecl` — types are not yet surfaced.
6//! The `RemoveType`, `AddType`, and `ModifyType` branches below are
7//! forward-looking placeholders that will activate when type-decl
8//! diffing lands. The fn-vs-type heuristic uses
9//! `signature.starts_with("type ")` which depends on the renderer
10//! in `lex-cli/src/diff.rs::render_signature` for `TypeDecl` to
11//! produce strings beginning with "type ". When types come online,
12//! consider extending `AddRemove` with a `kind: SymbolKind` field
13//! to make this typed rather than string-prefix-based.
14
15use crate::diff_report::DiffReport;
16use crate::operation::{EffectSet, ModuleRef, OperationKind, SigId, StageId};
17use lex_ast::{sig_id, stage_id, Effect, Stage};
18use std::collections::{BTreeMap, BTreeSet};
19
20pub type ImportMap = BTreeMap<String, BTreeSet<ModuleRef>>;
21
22#[derive(Debug, thiserror::Error)]
23pub enum DiffMappingError {
24    #[error("diff mentions removed/modified name `{0}` but no old_sig_id was resolved for it")]
25    MissingOldSigForName(String),
26    #[error("diff mentions added/renamed name `{0}` but new_stages has no matching stage")]
27    MissingNewStageForName(String),
28    #[error("sig `{0}` is in the diff's old_sig_id but not in old_head")]
29    MissingOldHeadForSig(SigId),
30    #[error("stage for `{0}` produces no sig_id (likely an Import that slipped through)")]
31    NoSigIdForStage(String),
32    #[error("stage for `{0}` produces no stage_id (likely an Import that slipped through)")]
33    NoStageIdForStage(String),
34}
35
36#[derive(Debug)]
37pub struct DiffInputs<'a> {
38    /// Current head SigId → StageId map.
39    pub old_head: &'a BTreeMap<SigId, StageId>,
40    /// Effect set per sig at the current head.
41    pub old_effects: &'a BTreeMap<SigId, EffectSet>,
42    /// Per-file imports at the current head.
43    pub old_imports: &'a ImportMap,
44    /// Stages of the new program (post-canonicalize).
45    pub new_stages: &'a [Stage],
46    /// Per-file imports of the new program.
47    pub new_imports: &'a ImportMap,
48    /// AST-diff between old and new sources, by name. Each removed/
49    /// renamed/modified entry carries its own resolved `old_sig_id`
50    /// (see `diff_report`'s doc comments) rather than this module
51    /// re-deriving one from a name-keyed lookup: a bare function name
52    /// is not unique across a package's files (#818 — e.g. two files
53    /// legitimately both declaring a local `validate` helper with
54    /// different signatures), so a `name -> SigId` map here would
55    /// silently collapse distinct functions onto one SigId.
56    pub diff: &'a DiffReport,
57}
58
59pub fn diff_to_ops(inputs: DiffInputs<'_>) -> Result<Vec<OperationKind>, DiffMappingError> {
60    let mut out = Vec::new();
61    let new_by_name: BTreeMap<&str, &Stage> = inputs.new_stages.iter()
62        .filter_map(|s| {
63            let n = match s {
64                Stage::FnDecl(fd) => fd.name.as_str(),
65                Stage::TypeDecl(td) => td.name.as_str(),
66                Stage::Import(_) => return None,
67            };
68            Some((n, s))
69        })
70        .collect();
71
72    // 1. Imports — separate from stage ops; emit first so importer
73    //    state is consistent before any sig ops apply.
74    for (file, modules) in inputs.new_imports {
75        let old = inputs.old_imports.get(file).cloned().unwrap_or_default();
76        for m in modules.difference(&old) {
77            out.push(OperationKind::AddImport {
78                in_file: file.clone(),
79                module: m.clone(),
80            });
81        }
82        for m in old.difference(modules) {
83            out.push(OperationKind::RemoveImport {
84                in_file: file.clone(),
85                module: m.clone(),
86            });
87        }
88    }
89    for (file, old) in inputs.old_imports {
90        if !inputs.new_imports.contains_key(file) {
91            for m in old {
92                out.push(OperationKind::RemoveImport {
93                    in_file: file.clone(),
94                    module: m.clone(),
95                });
96            }
97        }
98    }
99
100    // 2. Removed → RemoveFunction / RemoveType.
101    for r in &inputs.diff.removed {
102        let Some(sig) = r.old_sig_id.as_ref() else {
103            return Err(DiffMappingError::MissingOldSigForName(r.name.clone()));
104        };
105        let Some(last) = inputs.old_head.get(sig) else {
106            return Err(DiffMappingError::MissingOldHeadForSig(sig.clone()));
107        };
108        // Decide fn vs type by looking at the diff signature string:
109        // type signatures start with "type ".
110        if r.signature.starts_with("type ") {
111            out.push(OperationKind::RemoveType {
112                sig_id: sig.clone(),
113                last_stage_id: last.clone(),
114            });
115        } else {
116            out.push(OperationKind::RemoveFunction {
117                sig_id: sig.clone(),
118                last_stage_id: last.clone(),
119            });
120        }
121    }
122
123    // 3. Added → AddFunction / AddType.
124    for a in &inputs.diff.added {
125        let Some(stage) = new_by_name.get(a.name.as_str()) else {
126            return Err(DiffMappingError::MissingNewStageForName(a.name.clone()));
127        };
128        let Some(sig) = sig_id(stage) else {
129            return Err(DiffMappingError::NoSigIdForStage(a.name.clone()));
130        };
131        let Some(stg) = stage_id(stage) else {
132            return Err(DiffMappingError::NoStageIdForStage(a.name.clone()));
133        };
134        match stage {
135            Stage::FnDecl(fd) => {
136                let effects = effect_set(&fd.effects);
137                // #247: extract the function's declared `[budget(N)]`
138                // from its effect set so the op log carries the
139                // initial cost without rehydrating the stage at
140                // query time.
141                let budget_cost = crate::operation::budget_from_effects(&effects);
142                out.push(OperationKind::AddFunction {
143                    sig_id: sig, stage_id: stg, effects, budget_cost,
144                });
145            }
146            Stage::TypeDecl(_) => {
147                out.push(OperationKind::AddType { sig_id: sig, stage_id: stg });
148            }
149            Stage::Import(_) => unreachable!(),
150        }
151    }
152
153    // 4. Renamed → RenameSymbol.
154    for r in &inputs.diff.renamed {
155        let from_sig = &r.old_sig_id;
156        let Some(stage) = new_by_name.get(r.to.as_str()) else {
157            return Err(DiffMappingError::MissingNewStageForName(r.to.clone()));
158        };
159        let Some(to_sig) = sig_id(stage) else {
160            return Err(DiffMappingError::NoSigIdForStage(r.to.clone()));
161        };
162        let Some(body_id) = stage_id(stage) else {
163            return Err(DiffMappingError::NoStageIdForStage(r.to.clone()));
164        };
165        out.push(OperationKind::RenameSymbol {
166            from: from_sig.clone(),
167            to: to_sig,
168            body_stage_id: body_id,
169        });
170    }
171
172    // 5. Modified → ChangeEffectSig | ModifyBody | ModifyType.
173    for m in &inputs.diff.modified {
174        let sig = &m.old_sig_id;
175        let Some(from_id) = inputs.old_head.get(sig) else {
176            return Err(DiffMappingError::MissingOldHeadForSig(sig.clone()));
177        };
178        let Some(stage) = new_by_name.get(m.name.as_str()) else {
179            return Err(DiffMappingError::MissingNewStageForName(m.name.clone()));
180        };
181        let Some(to_id) = stage_id(stage) else {
182            return Err(DiffMappingError::NoStageIdForStage(m.name.clone()));
183        };
184        let effects_changed =
185            !m.effect_changes.added.is_empty() || !m.effect_changes.removed.is_empty();
186        match stage {
187            Stage::FnDecl(fd) if effects_changed => {
188                let from_effects = inputs.old_effects.get(sig).cloned().unwrap_or_default();
189                let to_effects = effect_set(&fd.effects);
190                // #247: the budget delta. ChangeEffectSig fires
191                // because the effect set changed, which often
192                // includes the `[budget(N)]` declaration itself.
193                let from_budget = crate::operation::budget_from_effects(&from_effects);
194                let to_budget = crate::operation::budget_from_effects(&to_effects);
195                out.push(OperationKind::ChangeEffectSig {
196                    sig_id: sig.clone(),
197                    from_stage_id: from_id.clone(),
198                    to_stage_id: to_id,
199                    from_effects,
200                    to_effects,
201                    from_budget,
202                    to_budget,
203                });
204            }
205            Stage::FnDecl(fd) => {
206                // #247: ModifyBody fires when only the body changed
207                // — effects (including budget) are unchanged. Pull
208                // the budget from the new stage's effect set; the
209                // old effect set in `old_effects[sig]` would yield
210                // the same value.
211                let to_effects = effect_set(&fd.effects);
212                let budget = crate::operation::budget_from_effects(&to_effects);
213                out.push(OperationKind::ModifyBody {
214                    sig_id: sig.clone(),
215                    from_stage_id: from_id.clone(),
216                    to_stage_id: to_id,
217                    from_budget: budget,
218                    to_budget: budget,
219                });
220            }
221            Stage::TypeDecl(_) => {
222                out.push(OperationKind::ModifyType {
223                    sig_id: sig.clone(),
224                    from_stage_id: from_id.clone(),
225                    to_stage_id: to_id,
226                });
227            }
228            Stage::Import(_) => unreachable!(),
229        }
230    }
231
232    Ok(out)
233}
234
235/// Project a slice of effects into the canonical `EffectSet` (sorted
236/// label strings).
237///
238/// Effect args are preserved via the canonical pretty-print form
239/// (e.g. `fs_read("/tmp")`, `net("wttr.in")`) — see
240/// `compute_diff::effect_label`. This makes `[net]` → `[net("wttr.in")]`
241/// a real `ChangeEffectSig` op (the strings differ), satisfying #207's
242/// third acceptance criterion via #223.
243///
244/// **OpId stability**: bare effects still produce just `"net"` (not
245/// `"net()"` or any other suffix), so every pre-#223 op log retains
246/// its existing OpIds. Only ops *introducing* parameterized effects
247/// see new hashes — and those are by definition new ops.
248fn effect_set(effs: &[Effect]) -> EffectSet {
249    effs.iter().map(crate::compute_diff::effect_label).collect()
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::diff_report::{DiffReport, EffectChanges, Modified, Renamed};
256
257    fn dr() -> DiffReport { DiffReport::default() }
258
259    #[test]
260    fn empty_diff_yields_no_ops() {
261        let head: BTreeMap<SigId, StageId> = BTreeMap::new();
262        let eff: BTreeMap<SigId, EffectSet> = BTreeMap::new();
263        let oi: ImportMap = ImportMap::new();
264        let ni: ImportMap = ImportMap::new();
265        let stages: Vec<Stage> = Vec::new();
266        let d = dr();
267        let ops = diff_to_ops(DiffInputs {
268            old_head: &head,
269            old_effects: &eff,
270            old_imports: &oi,
271            new_stages: &stages,
272            new_imports: &ni,
273            diff: &d,
274        }).expect("ok");
275        assert!(ops.is_empty());
276    }
277
278    #[test]
279    fn rename_emits_a_single_rename_op() {
280        // Build a tiny new program with one fn under the new name.
281        let src = "fn parse_int(s :: Str) -> Int { 0 }";
282        let prog = lex_syntax::load_program_from_str(src).unwrap();
283        let stages = lex_ast::canonicalize_program(&prog);
284        let parse_int = stages.iter()
285            .find(|s| matches!(s, Stage::FnDecl(fd) if fd.name == "parse_int"))
286            .cloned().unwrap();
287        let to_sig = sig_id(&parse_int).unwrap();
288        let to_stage = stage_id(&parse_int).unwrap();
289
290        let mut head = BTreeMap::new();
291        head.insert("parse-old-sig".to_string(), to_stage.clone());
292
293        let mut diff = dr();
294        diff.renamed.push(Renamed {
295            from: "parse".into(),
296            to: "parse_int".into(),
297            signature: "fn parse_int(s :: Str) -> Int".into(),
298            old_sig_id: "parse-old-sig".into(),
299        });
300
301        let eff = BTreeMap::new();
302        let oi = ImportMap::new();
303        let ni = ImportMap::new();
304        let ops = diff_to_ops(DiffInputs {
305            old_head: &head,
306            old_effects: &eff,
307            old_imports: &oi,
308            new_stages: &[parse_int],
309            new_imports: &ni,
310            diff: &diff,
311        }).expect("ok");
312        assert_eq!(ops.len(), 1);
313        match &ops[0] {
314            OperationKind::RenameSymbol { from, to, body_stage_id } => {
315                assert_eq!(from, "parse-old-sig");
316                assert_eq!(to, &to_sig);
317                assert_eq!(body_stage_id, &to_stage);
318            }
319            other => panic!("expected RenameSymbol, got {other:?}"),
320        }
321    }
322
323    #[test]
324    fn body_only_modify_emits_modify_body() {
325        let src = "fn fac(n :: Int) -> Int { 1 }";
326        let prog = lex_syntax::load_program_from_str(src).unwrap();
327        let stages = lex_ast::canonicalize_program(&prog);
328        let fac = stages.iter().find(|s| matches!(s, Stage::FnDecl(fd) if fd.name == "fac"))
329            .cloned().unwrap();
330        let sig = sig_id(&fac).unwrap();
331        let new_stg = stage_id(&fac).unwrap();
332
333        let mut head = BTreeMap::new();
334        head.insert(sig.clone(), "old-stage-id".to_string());
335
336        let mut diff = dr();
337        diff.modified.push(Modified {
338            name: "fac".into(),
339            signature_before: "fn fac(n :: Int) -> Int".into(),
340            signature_after:  "fn fac(n :: Int) -> Int".into(),
341            signature_changed: false,
342            effect_changes: EffectChanges::default(),
343            body_patches: Vec::new(),
344            old_sig_id: sig.clone(),
345        });
346
347        let eff = BTreeMap::new();
348        let oi = ImportMap::new();
349        let ni = ImportMap::new();
350        let ops = diff_to_ops(DiffInputs {
351            old_head: &head, old_effects: &eff,
352            old_imports: &oi, new_stages: &[fac], new_imports: &ni, diff: &diff,
353        }).expect("ok");
354        assert_eq!(ops.len(), 1);
355        match &ops[0] {
356            OperationKind::ModifyBody { sig_id: s, from_stage_id, to_stage_id, .. } => {
357                assert_eq!(s, &sig);
358                assert_eq!(from_stage_id, "old-stage-id");
359                assert_eq!(to_stage_id, &new_stg);
360            }
361            other => panic!("expected ModifyBody, got {other:?}"),
362        }
363    }
364
365    #[test]
366    fn import_added_emits_add_import() {
367        let mut new_imports = ImportMap::new();
368        new_imports.insert("main.lex".into(),
369            std::iter::once("std.io".to_string()).collect());
370        let head = BTreeMap::new();
371        let eff = BTreeMap::new();
372        let oi = ImportMap::new();
373        let stages: Vec<Stage> = Vec::new();
374        let diff = dr();
375        let ops = diff_to_ops(DiffInputs {
376            old_head: &head, old_effects: &eff,
377            old_imports: &oi, new_stages: &stages, new_imports: &new_imports, diff: &diff,
378        }).expect("ok");
379        assert_eq!(ops.len(), 1);
380        match &ops[0] {
381            OperationKind::AddImport { in_file, module } => {
382                assert_eq!(in_file, "main.lex");
383                assert_eq!(module, "std.io");
384            }
385            other => panic!("expected AddImport, got {other:?}"),
386        }
387    }
388
389    #[test]
390    fn missing_old_sig_for_removed_name_errors() {
391        let head: BTreeMap<SigId, StageId> = BTreeMap::new();
392        let eff: BTreeMap<SigId, EffectSet> = BTreeMap::new();
393        let oi = ImportMap::new();
394        let ni = ImportMap::new();
395        let stages: Vec<Stage> = Vec::new();
396        let mut diff = dr();
397        // No `old_sig_id` was resolved for this removal — the caller
398        // (e.g. a hand-assembled report, or a bug in the resolver)
399        // failed to find the old side.
400        diff.removed.push(crate::diff_report::AddRemove {
401            name: "ghost".into(),
402            signature: "fn ghost() -> Int".into(),
403            old_sig_id: None,
404        });
405        let err = diff_to_ops(DiffInputs {
406            old_head: &head, old_effects: &eff,
407            old_imports: &oi, new_stages: &stages, new_imports: &ni, diff: &diff,
408        }).unwrap_err();
409        match err {
410            DiffMappingError::MissingOldSigForName(n) => assert_eq!(n, "ghost"),
411            other => panic!("expected MissingOldSigForName, got {other:?}"),
412        }
413    }
414
415    // ----------------------------- #223 acceptance ---------------------
416
417    /// Bare effects must produce identical strings to pre-#223
418    /// behavior — preserves OpId stability for every existing op log.
419    /// Pre-#223 `effect_set` was `effs.iter().map(|e| e.name.clone())`,
420    /// so the canonical form for `[net]` was `"net"`. Confirm that.
421    #[test]
422    fn bare_effect_set_string_is_unchanged_from_pre_223() {
423        let src = "fn f() -> [net] Int { 0 }";
424        let prog = lex_syntax::load_program_from_str(src).unwrap();
425        let stages = lex_ast::canonicalize_program(&prog);
426        let fd = match &stages[0] {
427            Stage::FnDecl(fd) => fd,
428            other => panic!("{other:?}"),
429        };
430        let set = effect_set(&fd.effects);
431        assert_eq!(set, ["net".to_string()].into_iter().collect::<EffectSet>(),
432            "bare [net] must canonicalize to {{\"net\"}} so existing \
433             op logs keep their OpIds across the #223 change");
434    }
435
436    /// Parameterized effects produce a distinct, parens-quoted string
437    /// — `[net("wttr.in")]` becomes `"net(\"wttr.in\")"`. This is the
438    /// fulcrum that makes `[net]` → `[net("wttr.in")]` a real
439    /// `ChangeEffectSig` op rather than a no-op.
440    #[test]
441    fn parameterized_effect_label_is_distinct_from_bare() {
442        let bare_src = "fn f() -> [net] Int { 0 }";
443        let scoped_src = r#"fn f() -> [net("wttr.in")] Int { 0 }"#;
444        for (src, expected) in [
445            (bare_src, vec!["net"]),
446            (scoped_src, vec!["net(\"wttr.in\")"]),
447        ] {
448            let prog = lex_syntax::load_program_from_str(src).unwrap();
449            let stages = lex_ast::canonicalize_program(&prog);
450            let fd = match &stages[0] {
451                Stage::FnDecl(fd) => fd,
452                other => panic!("{other:?}"),
453            };
454            let want: EffectSet = expected.into_iter().map(String::from).collect();
455            assert_eq!(effect_set(&fd.effects), want);
456        }
457    }
458
459    /// End-to-end: when a function's effect declaration changes from
460    /// `[net]` to `[net("wttr.in")]`, `diff_to_ops` must emit a
461    /// `ChangeEffectSig` op carrying the parameterized form in
462    /// `to_effects`. Pre-#223 this was a no-op (both flattened to
463    /// `{"net"}`), defeating #207's reason to exist.
464    #[test]
465    fn changing_bare_to_parameterized_emits_change_effect_sig() {
466        let bare_src   = "fn weather() -> [net] Str { \"\" }";
467        let scoped_src = r#"fn weather() -> [net("wttr.in")] Str { "" }"#;
468
469        let bare_stage = match &lex_ast::canonicalize_program(
470            &lex_syntax::load_program_from_str(bare_src).unwrap())[0] {
471            Stage::FnDecl(fd) => fd.clone(),
472            _ => unreachable!(),
473        };
474        let scoped_stage = match &lex_ast::canonicalize_program(
475            &lex_syntax::load_program_from_str(scoped_src).unwrap())[0] {
476            Stage::FnDecl(fd) => fd.clone(),
477            _ => unreachable!(),
478        };
479
480        let sig = sig_id(&Stage::FnDecl(bare_stage.clone())).unwrap();
481        let from_stage_id = stage_id(&Stage::FnDecl(bare_stage.clone())).unwrap();
482
483        let mut head = BTreeMap::new();
484        head.insert(sig.clone(), from_stage_id.clone());
485        let mut eff = BTreeMap::new();
486        eff.insert(sig.clone(), effect_set(&bare_stage.effects));
487
488        let mut diff = dr();
489        diff.modified.push(Modified {
490            name: "weather".into(),
491            signature_before: "fn weather() -> [net] Str".into(),
492            signature_after:  "fn weather() -> [net(\"wttr.in\")] Str".into(),
493            signature_changed: true,
494            body_patches: Vec::new(),
495            effect_changes: EffectChanges {
496                before: vec!["net".into()],
497                after: vec!["net(\"wttr.in\")".into()],
498                added: vec!["net(\"wttr.in\")".into()],
499                removed: vec!["net".into()],
500            },
501            old_sig_id: sig.clone(),
502        });
503
504        let oi = ImportMap::new();
505        let ni = ImportMap::new();
506        let new_stage = Stage::FnDecl(scoped_stage);
507        let ops = diff_to_ops(DiffInputs {
508            old_head: &head,
509            old_effects: &eff,
510            old_imports: &oi,
511            new_stages: &[new_stage],
512            new_imports: &ni,
513            diff: &diff,
514        }).expect("diff_to_ops should succeed");
515
516        let change = ops.iter().find(|op| matches!(op, OperationKind::ChangeEffectSig { .. }));
517        let change = change.expect(
518            "expected a ChangeEffectSig op when going [net] → [net(\"wttr.in\")] — \
519             pre-#223 both sides flattened to {\"net\"} and the op was incorrectly \
520             skipped");
521        match change {
522            OperationKind::ChangeEffectSig { from_effects, to_effects, .. } => {
523                let from: Vec<_> = from_effects.iter().cloned().collect();
524                let to:   Vec<_> = to_effects.iter().cloned().collect();
525                assert_eq!(from, vec!["net".to_string()]);
526                assert_eq!(to,   vec!["net(\"wttr.in\")".to_string()]);
527            }
528            _ => unreachable!(),
529        }
530    }
531}