1use 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 pub old_head: &'a BTreeMap<SigId, StageId>,
40 pub old_name_to_sig: &'a BTreeMap<String, SigId>,
43 pub old_effects: &'a BTreeMap<SigId, EffectSet>,
45 pub old_imports: &'a ImportMap,
47 pub new_stages: &'a [Stage],
49 pub new_imports: &'a ImportMap,
51 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 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 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 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 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 let budget_cost = crate::operation::budget_from_effects(&effects);
138 out.push(OperationKind::AddFunction {
139 sig_id: sig, stage_id: stg, effects, budget_cost,
140 });
141 }
142 Stage::TypeDecl(_) => {
143 out.push(OperationKind::AddType { sig_id: sig, stage_id: stg });
144 }
145 Stage::Import(_) => unreachable!(),
146 }
147 }
148
149 for r in &inputs.diff.renamed {
151 let Some(from_sig) = inputs.old_name_to_sig.get(&r.from) else {
152 return Err(DiffMappingError::MissingOldSigForName(r.from.clone()));
153 };
154 let Some(stage) = new_by_name.get(r.to.as_str()) else {
155 return Err(DiffMappingError::MissingNewStageForName(r.to.clone()));
156 };
157 let Some(to_sig) = sig_id(stage) else {
158 return Err(DiffMappingError::NoSigIdForStage(r.to.clone()));
159 };
160 let Some(body_id) = stage_id(stage) else {
161 return Err(DiffMappingError::NoStageIdForStage(r.to.clone()));
162 };
163 out.push(OperationKind::RenameSymbol {
164 from: from_sig.clone(),
165 to: to_sig,
166 body_stage_id: body_id,
167 });
168 }
169
170 for m in &inputs.diff.modified {
172 let Some(sig) = inputs.old_name_to_sig.get(&m.name) else {
173 return Err(DiffMappingError::MissingOldSigForName(m.name.clone()));
174 };
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 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 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
235fn 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 n2s: BTreeMap<String, SigId> = BTreeMap::new();
263 let eff: BTreeMap<SigId, EffectSet> = BTreeMap::new();
264 let oi: ImportMap = ImportMap::new();
265 let ni: ImportMap = ImportMap::new();
266 let stages: Vec<Stage> = Vec::new();
267 let d = dr();
268 let ops = diff_to_ops(DiffInputs {
269 old_head: &head,
270 old_name_to_sig: &n2s,
271 old_effects: &eff,
272 old_imports: &oi,
273 new_stages: &stages,
274 new_imports: &ni,
275 diff: &d,
276 }).expect("ok");
277 assert!(ops.is_empty());
278 }
279
280 #[test]
281 fn rename_emits_a_single_rename_op() {
282 let src = "fn parse_int(s :: Str) -> Int { 0 }";
284 let prog = lex_syntax::load_program_from_str(src).unwrap();
285 let stages = lex_ast::canonicalize_program(&prog);
286 let parse_int = stages.iter()
287 .find(|s| matches!(s, Stage::FnDecl(fd) if fd.name == "parse_int"))
288 .cloned().unwrap();
289 let to_sig = sig_id(&parse_int).unwrap();
290 let to_stage = stage_id(&parse_int).unwrap();
291
292 let mut head = BTreeMap::new();
293 head.insert("parse-old-sig".to_string(), to_stage.clone());
294 let mut n2s = BTreeMap::new();
295 n2s.insert("parse".to_string(), "parse-old-sig".to_string());
296
297 let mut diff = dr();
298 diff.renamed.push(Renamed {
299 from: "parse".into(),
300 to: "parse_int".into(),
301 signature: "fn parse_int(s :: Str) -> Int".into(),
302 });
303
304 let eff = BTreeMap::new();
305 let oi = ImportMap::new();
306 let ni = ImportMap::new();
307 let ops = diff_to_ops(DiffInputs {
308 old_head: &head,
309 old_name_to_sig: &n2s,
310 old_effects: &eff,
311 old_imports: &oi,
312 new_stages: &[parse_int],
313 new_imports: &ni,
314 diff: &diff,
315 }).expect("ok");
316 assert_eq!(ops.len(), 1);
317 match &ops[0] {
318 OperationKind::RenameSymbol { from, to, body_stage_id } => {
319 assert_eq!(from, "parse-old-sig");
320 assert_eq!(to, &to_sig);
321 assert_eq!(body_stage_id, &to_stage);
322 }
323 other => panic!("expected RenameSymbol, got {other:?}"),
324 }
325 }
326
327 #[test]
328 fn body_only_modify_emits_modify_body() {
329 let src = "fn fac(n :: Int) -> Int { 1 }";
330 let prog = lex_syntax::load_program_from_str(src).unwrap();
331 let stages = lex_ast::canonicalize_program(&prog);
332 let fac = stages.iter().find(|s| matches!(s, Stage::FnDecl(fd) if fd.name == "fac"))
333 .cloned().unwrap();
334 let sig = sig_id(&fac).unwrap();
335 let new_stg = stage_id(&fac).unwrap();
336
337 let mut head = BTreeMap::new();
338 head.insert(sig.clone(), "old-stage-id".to_string());
339 let mut n2s = BTreeMap::new();
340 n2s.insert("fac".to_string(), sig.clone());
341
342 let mut diff = dr();
343 diff.modified.push(Modified {
344 name: "fac".into(),
345 signature_before: "fn fac(n :: Int) -> Int".into(),
346 signature_after: "fn fac(n :: Int) -> Int".into(),
347 signature_changed: false,
348 effect_changes: EffectChanges::default(),
349 body_patches: Vec::new(),
350 });
351
352 let eff = BTreeMap::new();
353 let oi = ImportMap::new();
354 let ni = ImportMap::new();
355 let ops = diff_to_ops(DiffInputs {
356 old_head: &head, old_name_to_sig: &n2s, old_effects: &eff,
357 old_imports: &oi, new_stages: &[fac], new_imports: &ni, diff: &diff,
358 }).expect("ok");
359 assert_eq!(ops.len(), 1);
360 match &ops[0] {
361 OperationKind::ModifyBody { sig_id: s, from_stage_id, to_stage_id, .. } => {
362 assert_eq!(s, &sig);
363 assert_eq!(from_stage_id, "old-stage-id");
364 assert_eq!(to_stage_id, &new_stg);
365 }
366 other => panic!("expected ModifyBody, got {other:?}"),
367 }
368 }
369
370 #[test]
371 fn import_added_emits_add_import() {
372 let mut new_imports = ImportMap::new();
373 new_imports.insert("main.lex".into(),
374 std::iter::once("std.io".to_string()).collect());
375 let head = BTreeMap::new();
376 let n2s = BTreeMap::new();
377 let eff = BTreeMap::new();
378 let oi = ImportMap::new();
379 let stages: Vec<Stage> = Vec::new();
380 let diff = dr();
381 let ops = diff_to_ops(DiffInputs {
382 old_head: &head, old_name_to_sig: &n2s, old_effects: &eff,
383 old_imports: &oi, new_stages: &stages, new_imports: &new_imports, diff: &diff,
384 }).expect("ok");
385 assert_eq!(ops.len(), 1);
386 match &ops[0] {
387 OperationKind::AddImport { in_file, module } => {
388 assert_eq!(in_file, "main.lex");
389 assert_eq!(module, "std.io");
390 }
391 other => panic!("expected AddImport, got {other:?}"),
392 }
393 }
394
395 #[test]
396 fn missing_old_sig_for_removed_name_errors() {
397 let head: BTreeMap<SigId, StageId> = BTreeMap::new();
398 let n2s: BTreeMap<String, SigId> = BTreeMap::new(); let eff: BTreeMap<SigId, EffectSet> = BTreeMap::new();
400 let oi = ImportMap::new();
401 let ni = ImportMap::new();
402 let stages: Vec<Stage> = Vec::new();
403 let mut diff = dr();
404 diff.removed.push(crate::diff_report::AddRemove {
405 name: "ghost".into(),
406 signature: "fn ghost() -> Int".into(),
407 });
408 let err = diff_to_ops(DiffInputs {
409 old_head: &head, old_name_to_sig: &n2s, old_effects: &eff,
410 old_imports: &oi, new_stages: &stages, new_imports: &ni, diff: &diff,
411 }).unwrap_err();
412 match err {
413 DiffMappingError::MissingOldSigForName(n) => assert_eq!(n, "ghost"),
414 other => panic!("expected MissingOldSigForName, got {other:?}"),
415 }
416 }
417
418 #[test]
425 fn bare_effect_set_string_is_unchanged_from_pre_223() {
426 let src = "fn f() -> [net] Int { 0 }";
427 let prog = lex_syntax::load_program_from_str(src).unwrap();
428 let stages = lex_ast::canonicalize_program(&prog);
429 let fd = match &stages[0] {
430 Stage::FnDecl(fd) => fd,
431 other => panic!("{other:?}"),
432 };
433 let set = effect_set(&fd.effects);
434 assert_eq!(set, ["net".to_string()].into_iter().collect::<EffectSet>(),
435 "bare [net] must canonicalize to {{\"net\"}} so existing \
436 op logs keep their OpIds across the #223 change");
437 }
438
439 #[test]
444 fn parameterized_effect_label_is_distinct_from_bare() {
445 let bare_src = "fn f() -> [net] Int { 0 }";
446 let scoped_src = r#"fn f() -> [net("wttr.in")] Int { 0 }"#;
447 for (src, expected) in [
448 (bare_src, vec!["net"]),
449 (scoped_src, vec!["net(\"wttr.in\")"]),
450 ] {
451 let prog = lex_syntax::load_program_from_str(src).unwrap();
452 let stages = lex_ast::canonicalize_program(&prog);
453 let fd = match &stages[0] {
454 Stage::FnDecl(fd) => fd,
455 other => panic!("{other:?}"),
456 };
457 let want: EffectSet = expected.into_iter().map(String::from).collect();
458 assert_eq!(effect_set(&fd.effects), want);
459 }
460 }
461
462 #[test]
468 fn changing_bare_to_parameterized_emits_change_effect_sig() {
469 let bare_src = "fn weather() -> [net] Str { \"\" }";
470 let scoped_src = r#"fn weather() -> [net("wttr.in")] Str { "" }"#;
471
472 let bare_stage = match &lex_ast::canonicalize_program(
473 &lex_syntax::load_program_from_str(bare_src).unwrap())[0] {
474 Stage::FnDecl(fd) => fd.clone(),
475 _ => unreachable!(),
476 };
477 let scoped_stage = match &lex_ast::canonicalize_program(
478 &lex_syntax::load_program_from_str(scoped_src).unwrap())[0] {
479 Stage::FnDecl(fd) => fd.clone(),
480 _ => unreachable!(),
481 };
482
483 let sig = sig_id(&Stage::FnDecl(bare_stage.clone())).unwrap();
484 let from_stage_id = stage_id(&Stage::FnDecl(bare_stage.clone())).unwrap();
485
486 let mut head = BTreeMap::new();
487 head.insert(sig.clone(), from_stage_id.clone());
488 let mut n2s = BTreeMap::new();
489 n2s.insert("weather".to_string(), sig.clone());
490 let mut eff = BTreeMap::new();
491 eff.insert(sig.clone(), effect_set(&bare_stage.effects));
492
493 let mut diff = dr();
494 diff.modified.push(Modified {
495 name: "weather".into(),
496 signature_before: "fn weather() -> [net] Str".into(),
497 signature_after: "fn weather() -> [net(\"wttr.in\")] Str".into(),
498 signature_changed: true,
499 body_patches: Vec::new(),
500 effect_changes: EffectChanges {
501 before: vec!["net".into()],
502 after: vec!["net(\"wttr.in\")".into()],
503 added: vec!["net(\"wttr.in\")".into()],
504 removed: vec!["net".into()],
505 },
506 });
507
508 let oi = ImportMap::new();
509 let ni = ImportMap::new();
510 let new_stage = Stage::FnDecl(scoped_stage);
511 let ops = diff_to_ops(DiffInputs {
512 old_head: &head,
513 old_name_to_sig: &n2s,
514 old_effects: &eff,
515 old_imports: &oi,
516 new_stages: &[new_stage],
517 new_imports: &ni,
518 diff: &diff,
519 }).expect("diff_to_ops should succeed");
520
521 let change = ops.iter().find(|op| matches!(op, OperationKind::ChangeEffectSig { .. }));
522 let change = change.expect(
523 "expected a ChangeEffectSig op when going [net] → [net(\"wttr.in\")] — \
524 pre-#223 both sides flattened to {\"net\"} and the op was incorrectly \
525 skipped");
526 match change {
527 OperationKind::ChangeEffectSig { from_effects, to_effects, .. } => {
528 let from: Vec<_> = from_effects.iter().cloned().collect();
529 let to: Vec<_> = to_effects.iter().cloned().collect();
530 assert_eq!(from, vec!["net".to_string()]);
531 assert_eq!(to, vec!["net(\"wttr.in\")".to_string()]);
532 }
533 _ => unreachable!(),
534 }
535 }
536}