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