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 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 pub old_head: &'a BTreeMap<SigId, StageId>,
40 pub old_effects: &'a BTreeMap<SigId, EffectSet>,
42 pub old_imports: &'a ImportMap,
44 pub new_stages: &'a [Stage],
46 pub new_imports: &'a ImportMap,
48 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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}