1use crate::diff_report::DiffReport;
16use crate::operation::{default_import_alias, EffectSet, OperationKind, SigId, StageId};
17use lex_ast::{sig_id, stage_id, Effect, Stage};
18use std::collections::{BTreeMap, BTreeSet};
19
20#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)]
25pub struct ImportRef {
26 pub reference: String,
27 pub alias: String,
28}
29
30pub type ImportMap = BTreeMap<String, BTreeSet<ImportRef>>;
31
32#[derive(Debug, thiserror::Error)]
33pub enum DiffMappingError {
34 #[error("diff mentions removed/modified name `{0}` but no old_sig_id was resolved for it")]
35 MissingOldSigForName(String),
36 #[error("diff mentions added/renamed name `{0}` but new_stages has no matching stage")]
37 MissingNewStageForName(String),
38 #[error("sig `{0}` is in the diff's old_sig_id but not in old_head")]
39 MissingOldHeadForSig(SigId),
40 #[error("stage for `{0}` produces no sig_id (likely an Import that slipped through)")]
41 NoSigIdForStage(String),
42 #[error("stage for `{0}` produces no stage_id (likely an Import that slipped through)")]
43 NoStageIdForStage(String),
44}
45
46#[derive(Debug)]
47pub struct DiffInputs<'a> {
48 pub old_head: &'a BTreeMap<SigId, StageId>,
50 pub old_effects: &'a BTreeMap<SigId, EffectSet>,
52 pub old_imports: &'a ImportMap,
54 pub new_stages: &'a [Stage],
56 pub new_imports: &'a ImportMap,
58 pub diff: &'a DiffReport,
67 pub module_prefixes: &'a BTreeMap<String, String>,
75}
76
77pub fn diff_to_ops(inputs: DiffInputs<'_>) -> Result<Vec<OperationKind>, DiffMappingError> {
78 let mut out = Vec::new();
79 let new_by_name: BTreeMap<&str, &Stage> = inputs.new_stages.iter()
80 .filter_map(|s| {
81 let n = match s {
82 Stage::FnDecl(fd) => fd.name.as_str(),
83 Stage::TypeDecl(td) => td.name.as_str(),
84 Stage::Import(_) => return None,
85 };
86 Some((n, s))
87 })
88 .collect();
89
90 for (file, modules) in inputs.new_imports {
93 let old = inputs.old_imports.get(file).cloned().unwrap_or_default();
94 for m in modules.difference(&old) {
95 out.push(OperationKind::AddImport {
96 in_file: file.clone(),
97 module: m.reference.clone(),
98 alias: explicit_alias(m),
99 });
100 }
101 for m in old.difference(modules) {
102 out.push(OperationKind::RemoveImport {
103 in_file: file.clone(),
104 module: m.reference.clone(),
105 });
106 }
107 }
108 for (file, old) in inputs.old_imports {
109 if !inputs.new_imports.contains_key(file) {
110 for m in old {
111 out.push(OperationKind::RemoveImport {
112 in_file: file.clone(),
113 module: m.reference.clone(),
114 });
115 }
116 }
117 }
118
119 for r in &inputs.diff.removed {
121 let Some(sig) = r.old_sig_id.as_ref() else {
122 return Err(DiffMappingError::MissingOldSigForName(r.name.clone()));
123 };
124 let Some(last) = inputs.old_head.get(sig) else {
125 return Err(DiffMappingError::MissingOldHeadForSig(sig.clone()));
126 };
127 if r.signature.starts_with("type ") {
130 out.push(OperationKind::RemoveType {
131 sig_id: sig.clone(),
132 last_stage_id: last.clone(),
133 });
134 } else {
135 out.push(OperationKind::RemoveFunction {
136 sig_id: sig.clone(),
137 last_stage_id: last.clone(),
138 });
139 }
140 }
141
142 for a in &inputs.diff.added {
144 let Some(stage) = new_by_name.get(a.name.as_str()) else {
145 return Err(DiffMappingError::MissingNewStageForName(a.name.clone()));
146 };
147 let Some(sig) = sig_id(stage) else {
148 return Err(DiffMappingError::NoSigIdForStage(a.name.clone()));
149 };
150 let Some(stg) = stage_id(stage) else {
151 return Err(DiffMappingError::NoStageIdForStage(a.name.clone()));
152 };
153 let in_file = origin_file(&a.name, inputs.module_prefixes);
154 match stage {
155 Stage::FnDecl(fd) => {
156 let effects = effect_set(&fd.effects);
157 let budget_cost = crate::operation::budget_from_effects(&effects);
162 out.push(OperationKind::AddFunction {
163 sig_id: sig, stage_id: stg, effects, budget_cost, in_file,
164 });
165 }
166 Stage::TypeDecl(_) => {
167 out.push(OperationKind::AddType { sig_id: sig, stage_id: stg, in_file });
168 }
169 Stage::Import(_) => unreachable!(),
170 }
171 }
172
173 for r in &inputs.diff.renamed {
175 let from_sig = &r.old_sig_id;
176 let Some(stage) = new_by_name.get(r.to.as_str()) else {
177 return Err(DiffMappingError::MissingNewStageForName(r.to.clone()));
178 };
179 let Some(to_sig) = sig_id(stage) else {
180 return Err(DiffMappingError::NoSigIdForStage(r.to.clone()));
181 };
182 let Some(body_id) = stage_id(stage) else {
183 return Err(DiffMappingError::NoStageIdForStage(r.to.clone()));
184 };
185 out.push(OperationKind::RenameSymbol {
186 from: from_sig.clone(),
187 to: to_sig,
188 body_stage_id: body_id,
189 });
190 }
191
192 for m in &inputs.diff.modified {
194 let sig = &m.old_sig_id;
195 let Some(from_id) = inputs.old_head.get(sig) else {
196 return Err(DiffMappingError::MissingOldHeadForSig(sig.clone()));
197 };
198 let Some(stage) = new_by_name.get(m.name.as_str()) else {
199 return Err(DiffMappingError::MissingNewStageForName(m.name.clone()));
200 };
201 let Some(to_id) = stage_id(stage) else {
202 return Err(DiffMappingError::NoStageIdForStage(m.name.clone()));
203 };
204 let effects_changed =
205 !m.effect_changes.added.is_empty() || !m.effect_changes.removed.is_empty();
206 let to_sig_id = sig_id(stage).filter(|s| s != sig);
213 match stage {
214 Stage::FnDecl(fd) if effects_changed => {
215 let from_effects = inputs.old_effects.get(sig).cloned().unwrap_or_default();
216 let to_effects = effect_set(&fd.effects);
217 let from_budget = crate::operation::budget_from_effects(&from_effects);
221 let to_budget = crate::operation::budget_from_effects(&to_effects);
222 out.push(OperationKind::ChangeEffectSig {
223 sig_id: sig.clone(),
224 from_stage_id: from_id.clone(),
225 to_stage_id: to_id,
226 from_effects,
227 to_effects,
228 from_budget,
229 to_budget,
230 to_sig_id,
231 });
232 }
233 Stage::FnDecl(fd) => {
234 let to_effects = effect_set(&fd.effects);
240 let budget = crate::operation::budget_from_effects(&to_effects);
241 out.push(OperationKind::ModifyBody {
242 sig_id: sig.clone(),
243 from_stage_id: from_id.clone(),
244 to_stage_id: to_id,
245 from_budget: budget,
246 to_budget: budget,
247 to_sig_id,
248 });
249 }
250 Stage::TypeDecl(_) => {
251 out.push(OperationKind::ModifyType {
252 sig_id: sig.clone(),
253 from_stage_id: from_id.clone(),
254 to_stage_id: to_id,
255 to_sig_id,
256 });
257 }
258 Stage::Import(_) => unreachable!(),
259 }
260 }
261
262 Ok(out)
263}
264
265fn effect_set(effs: &[Effect]) -> EffectSet {
279 effs.iter().map(crate::compute_diff::effect_label).collect()
280}
281
282fn origin_file(name: &str, module_prefixes: &BTreeMap<String, String>) -> Option<String> {
288 let prefix = name.split_once('.')?.0;
289 module_prefixes.get(prefix).cloned()
290}
291
292fn explicit_alias(m: &ImportRef) -> Option<String> {
298 if m.alias == default_import_alias(&m.reference) {
299 None
300 } else {
301 Some(m.alias.clone())
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::diff_report::{DiffReport, EffectChanges, Modified, Renamed};
309
310 fn dr() -> DiffReport { DiffReport::default() }
311
312 #[test]
313 fn empty_diff_yields_no_ops() {
314 let head: BTreeMap<SigId, StageId> = BTreeMap::new();
315 let eff: BTreeMap<SigId, EffectSet> = BTreeMap::new();
316 let oi: ImportMap = ImportMap::new();
317 let ni: ImportMap = ImportMap::new();
318 let stages: Vec<Stage> = Vec::new();
319 let d = dr();
320 let ops = diff_to_ops(DiffInputs {
321 old_head: &head,
322 old_effects: &eff,
323 old_imports: &oi,
324 new_stages: &stages,
325 new_imports: &ni,
326 diff: &d,
327 module_prefixes: &BTreeMap::new(),
328 }).expect("ok");
329 assert!(ops.is_empty());
330 }
331
332 #[test]
333 fn rename_emits_a_single_rename_op() {
334 let src = "fn parse_int(s :: Str) -> Int { 0 }";
336 let prog = lex_syntax::load_program_from_str(src).unwrap();
337 let stages = lex_ast::canonicalize_program(&prog);
338 let parse_int = stages.iter()
339 .find(|s| matches!(s, Stage::FnDecl(fd) if fd.name == "parse_int"))
340 .cloned().unwrap();
341 let to_sig = sig_id(&parse_int).unwrap();
342 let to_stage = stage_id(&parse_int).unwrap();
343
344 let mut head = BTreeMap::new();
345 head.insert("parse-old-sig".to_string(), to_stage.clone());
346
347 let mut diff = dr();
348 diff.renamed.push(Renamed {
349 from: "parse".into(),
350 to: "parse_int".into(),
351 signature: "fn parse_int(s :: Str) -> Int".into(),
352 old_sig_id: "parse-old-sig".into(),
353 });
354
355 let eff = BTreeMap::new();
356 let oi = ImportMap::new();
357 let ni = ImportMap::new();
358 let ops = diff_to_ops(DiffInputs {
359 old_head: &head,
360 old_effects: &eff,
361 old_imports: &oi,
362 new_stages: &[parse_int],
363 new_imports: &ni,
364 diff: &diff,
365 module_prefixes: &BTreeMap::new(),
366 }).expect("ok");
367 assert_eq!(ops.len(), 1);
368 match &ops[0] {
369 OperationKind::RenameSymbol { from, to, body_stage_id } => {
370 assert_eq!(from, "parse-old-sig");
371 assert_eq!(to, &to_sig);
372 assert_eq!(body_stage_id, &to_stage);
373 }
374 other => panic!("expected RenameSymbol, got {other:?}"),
375 }
376 }
377
378 #[test]
379 fn body_only_modify_emits_modify_body() {
380 let src = "fn fac(n :: Int) -> Int { 1 }";
381 let prog = lex_syntax::load_program_from_str(src).unwrap();
382 let stages = lex_ast::canonicalize_program(&prog);
383 let fac = stages.iter().find(|s| matches!(s, Stage::FnDecl(fd) if fd.name == "fac"))
384 .cloned().unwrap();
385 let sig = sig_id(&fac).unwrap();
386 let new_stg = stage_id(&fac).unwrap();
387
388 let mut head = BTreeMap::new();
389 head.insert(sig.clone(), "old-stage-id".to_string());
390
391 let mut diff = dr();
392 diff.modified.push(Modified {
393 name: "fac".into(),
394 signature_before: "fn fac(n :: Int) -> Int".into(),
395 signature_after: "fn fac(n :: Int) -> Int".into(),
396 signature_changed: false,
397 effect_changes: EffectChanges::default(),
398 body_patches: Vec::new(),
399 old_sig_id: sig.clone(),
400 });
401
402 let eff = BTreeMap::new();
403 let oi = ImportMap::new();
404 let ni = ImportMap::new();
405 let ops = diff_to_ops(DiffInputs {
406 old_head: &head, old_effects: &eff,
407 old_imports: &oi, new_stages: &[fac], new_imports: &ni, diff: &diff,
408 module_prefixes: &BTreeMap::new(),
409 }).expect("ok");
410 assert_eq!(ops.len(), 1);
411 match &ops[0] {
412 OperationKind::ModifyBody { sig_id: s, from_stage_id, to_stage_id, .. } => {
413 assert_eq!(s, &sig);
414 assert_eq!(from_stage_id, "old-stage-id");
415 assert_eq!(to_stage_id, &new_stg);
416 }
417 other => panic!("expected ModifyBody, got {other:?}"),
418 }
419 }
420
421 #[test]
422 fn import_added_emits_add_import() {
423 let mut new_imports = ImportMap::new();
424 new_imports.insert("main.lex".into(),
425 std::iter::once(ImportRef { reference: "std.io".into(), alias: "io".into() }).collect());
426 let head = BTreeMap::new();
427 let eff = BTreeMap::new();
428 let oi = ImportMap::new();
429 let stages: Vec<Stage> = Vec::new();
430 let diff = dr();
431 let ops = diff_to_ops(DiffInputs {
432 old_head: &head, old_effects: &eff,
433 old_imports: &oi, new_stages: &stages, new_imports: &new_imports, diff: &diff,
434 module_prefixes: &BTreeMap::new(),
435 }).expect("ok");
436 assert_eq!(ops.len(), 1);
437 match &ops[0] {
438 OperationKind::AddImport { in_file, module, alias } => {
439 assert_eq!(in_file, "main.lex");
440 assert_eq!(module, "std.io");
441 assert_eq!(alias, &None);
443 }
444 other => panic!("expected AddImport, got {other:?}"),
445 }
446 }
447
448 #[test]
449 fn import_with_nondefault_alias_carries_it() {
450 let mut new_imports = ImportMap::new();
451 new_imports.insert("main.lex".into(),
452 std::iter::once(ImportRef { reference: "./error".into(), alias: "e".into() }).collect());
453 let head = BTreeMap::new();
454 let eff = BTreeMap::new();
455 let oi = ImportMap::new();
456 let stages: Vec<Stage> = Vec::new();
457 let diff = dr();
458 let ops = diff_to_ops(DiffInputs {
459 old_head: &head, old_effects: &eff,
460 old_imports: &oi, new_stages: &stages, new_imports: &new_imports, diff: &diff,
461 module_prefixes: &BTreeMap::new(),
462 }).expect("ok");
463 match &ops[0] {
464 OperationKind::AddImport { module, alias, .. } => {
465 assert_eq!(module, "./error");
466 assert_eq!(alias, &Some("e".to_string()));
468 }
469 other => panic!("expected AddImport, got {other:?}"),
470 }
471 }
472
473 #[test]
474 fn missing_old_sig_for_removed_name_errors() {
475 let head: BTreeMap<SigId, StageId> = BTreeMap::new();
476 let eff: BTreeMap<SigId, EffectSet> = BTreeMap::new();
477 let oi = ImportMap::new();
478 let ni = ImportMap::new();
479 let stages: Vec<Stage> = Vec::new();
480 let mut diff = dr();
481 diff.removed.push(crate::diff_report::AddRemove {
485 name: "ghost".into(),
486 signature: "fn ghost() -> Int".into(),
487 old_sig_id: None,
488 });
489 let err = diff_to_ops(DiffInputs {
490 old_head: &head, old_effects: &eff,
491 old_imports: &oi, new_stages: &stages, new_imports: &ni, diff: &diff,
492 module_prefixes: &BTreeMap::new(),
493 }).unwrap_err();
494 match err {
495 DiffMappingError::MissingOldSigForName(n) => assert_eq!(n, "ghost"),
496 other => panic!("expected MissingOldSigForName, got {other:?}"),
497 }
498 }
499
500 #[test]
507 fn bare_effect_set_string_is_unchanged_from_pre_223() {
508 let src = "fn f() -> [net] Int { 0 }";
509 let prog = lex_syntax::load_program_from_str(src).unwrap();
510 let stages = lex_ast::canonicalize_program(&prog);
511 let fd = match &stages[0] {
512 Stage::FnDecl(fd) => fd,
513 other => panic!("{other:?}"),
514 };
515 let set = effect_set(&fd.effects);
516 assert_eq!(set, ["net".to_string()].into_iter().collect::<EffectSet>(),
517 "bare [net] must canonicalize to {{\"net\"}} so existing \
518 op logs keep their OpIds across the #223 change");
519 }
520
521 #[test]
526 fn parameterized_effect_label_is_distinct_from_bare() {
527 let bare_src = "fn f() -> [net] Int { 0 }";
528 let scoped_src = r#"fn f() -> [net("wttr.in")] Int { 0 }"#;
529 for (src, expected) in [
530 (bare_src, vec!["net"]),
531 (scoped_src, vec!["net(\"wttr.in\")"]),
532 ] {
533 let prog = lex_syntax::load_program_from_str(src).unwrap();
534 let stages = lex_ast::canonicalize_program(&prog);
535 let fd = match &stages[0] {
536 Stage::FnDecl(fd) => fd,
537 other => panic!("{other:?}"),
538 };
539 let want: EffectSet = expected.into_iter().map(String::from).collect();
540 assert_eq!(effect_set(&fd.effects), want);
541 }
542 }
543
544 #[test]
550 fn changing_bare_to_parameterized_emits_change_effect_sig() {
551 let bare_src = "fn weather() -> [net] Str { \"\" }";
552 let scoped_src = r#"fn weather() -> [net("wttr.in")] Str { "" }"#;
553
554 let bare_stage = match &lex_ast::canonicalize_program(
555 &lex_syntax::load_program_from_str(bare_src).unwrap())[0] {
556 Stage::FnDecl(fd) => fd.clone(),
557 _ => unreachable!(),
558 };
559 let scoped_stage = match &lex_ast::canonicalize_program(
560 &lex_syntax::load_program_from_str(scoped_src).unwrap())[0] {
561 Stage::FnDecl(fd) => fd.clone(),
562 _ => unreachable!(),
563 };
564
565 let sig = sig_id(&Stage::FnDecl(bare_stage.clone())).unwrap();
566 let from_stage_id = stage_id(&Stage::FnDecl(bare_stage.clone())).unwrap();
567
568 let mut head = BTreeMap::new();
569 head.insert(sig.clone(), from_stage_id.clone());
570 let mut eff = BTreeMap::new();
571 eff.insert(sig.clone(), effect_set(&bare_stage.effects));
572
573 let mut diff = dr();
574 diff.modified.push(Modified {
575 name: "weather".into(),
576 signature_before: "fn weather() -> [net] Str".into(),
577 signature_after: "fn weather() -> [net(\"wttr.in\")] Str".into(),
578 signature_changed: true,
579 body_patches: Vec::new(),
580 effect_changes: EffectChanges {
581 before: vec!["net".into()],
582 after: vec!["net(\"wttr.in\")".into()],
583 added: vec!["net(\"wttr.in\")".into()],
584 removed: vec!["net".into()],
585 },
586 old_sig_id: sig.clone(),
587 });
588
589 let oi = ImportMap::new();
590 let ni = ImportMap::new();
591 let new_stage = Stage::FnDecl(scoped_stage);
592 let ops = diff_to_ops(DiffInputs {
593 old_head: &head,
594 old_effects: &eff,
595 old_imports: &oi,
596 new_stages: &[new_stage],
597 new_imports: &ni,
598 diff: &diff,
599 module_prefixes: &BTreeMap::new(),
600 }).expect("diff_to_ops should succeed");
601
602 let change = ops.iter().find(|op| matches!(op, OperationKind::ChangeEffectSig { .. }));
603 let change = change.expect(
604 "expected a ChangeEffectSig op when going [net] → [net(\"wttr.in\")] — \
605 pre-#223 both sides flattened to {\"net\"} and the op was incorrectly \
606 skipped");
607 match change {
608 OperationKind::ChangeEffectSig { from_effects, to_effects, .. } => {
609 let from: Vec<_> = from_effects.iter().cloned().collect();
610 let to: Vec<_> = to_effects.iter().cloned().collect();
611 assert_eq!(from, vec!["net".to_string()]);
612 assert_eq!(to, vec!["net(\"wttr.in\")".to_string()]);
613 }
614 _ => unreachable!(),
615 }
616 }
617}