1use std::collections::{BTreeMap, BTreeSet};
44
45use beck_diag::{Diagnostic, Diagnostics, Span};
46
47use crate::check::{check_module_with, Mode, Program};
48use crate::iface::Interface;
49use crate::place;
50use crate::split::Placed;
51
52#[derive(Clone, Debug, Default)]
54pub struct Sources {
55 pub module: Option<String>,
57 pub interface: Option<String>,
60 pub path: Option<String>,
64}
65
66pub trait Loader {
68 fn load(&self, name: &str) -> Option<Sources>;
69}
70
71impl<F: Fn(&str) -> Option<Sources>> Loader for F {
72 fn load(&self, name: &str) -> Option<Sources> {
73 self(name)
74 }
75}
76
77pub struct Checked {
79 pub program: Program,
80 pub interface: Interface,
81}
82
83pub fn check_one(
85 name: &str,
86 src: &str,
87 imports: &[(String, Interface)],
88 lock: Option<&place::Lock>,
89 diags: &mut Diagnostics,
90) -> Checked {
91 let mut map = beck_diag::SourceMap::new();
92 let file = map.add(name, src);
93 check_one_in(file, name, src, imports, lock, diags)
94}
95
96pub fn check_one_in(
98 file: beck_diag::FileId,
99 name: &str,
100 src: &str,
101 imports: &[(String, Interface)],
102 lock: Option<&place::Lock>,
103 diags: &mut Diagnostics,
104) -> Checked {
105 let parsed = beck_syntax::parse_file(file, name, src, diags);
106 let expanded = beck_macro::expand_module(&parsed, diags);
107 let mut program = check_module_with(&expanded, Mode::Module, imports, diags);
108 let solution = place::solve(&program, lock);
109 place::apply(&mut program, &solution);
110 place::check_placement(&program, diags);
111 crate::secure::check_boundaries(&program, diags);
115 let interface = Interface::of(&program);
116 Checked { program, interface }
117}
118
119pub fn imports_of(file: beck_diag::FileId, name: &str, src: &str) -> Vec<String> {
121 let mut diags = Diagnostics::new();
122 let parsed = beck_syntax::parse_file(file, name, src, &mut diags);
123 parsed
124 .args
125 .iter()
126 .skip(1)
127 .filter(|n| n.is_form(beck_syntax::sym::IMPORT))
128 .filter_map(|n| n.args.first().and_then(|a| a.as_var()))
129 .map(|s| s.as_str().to_string())
130 .collect()
131}
132
133pub struct Project {
140 pub program: Program,
141 pub solution: place::Solution,
143 pub interface: Interface,
145}
146
147pub fn check_project(
149 root: &str,
150 loader: &dyn Loader,
151 lock: Option<&place::Lock>,
152 map: &mut beck_diag::SourceMap,
153 diags: &mut Diagnostics,
154) -> Option<Project> {
155 let mut order: Vec<String> = Vec::new();
156 let mut visiting: Vec<String> = Vec::new();
157 let mut sources: BTreeMap<String, (Sources, beck_diag::FileId)> = BTreeMap::new();
158 let mut from_library: BTreeSet<String> = BTreeSet::new();
161
162 #[allow(clippy::too_many_arguments)]
165 fn visit(
166 name: &str,
167 loader: &dyn Loader,
168 map: &mut beck_diag::SourceMap,
169 sources: &mut BTreeMap<String, (Sources, beck_diag::FileId)>,
170 from_library: &mut BTreeSet<String>,
171 order: &mut Vec<String>,
172 visiting: &mut Vec<String>,
173 diags: &mut Diagnostics,
174 ) {
175 if order.iter().any(|n| n == name) {
176 return;
177 }
178 if visiting.iter().any(|n| n == name) {
179 diags.push(
180 Diagnostic::error(
181 "B0602",
182 format!("module `{name}` imports itself, directly or through a cycle"),
183 Span::NONE,
184 )
185 .with_note(format!("the cycle is {} → {name}", visiting.join(" → ")))
186 .with_note(
187 "a module's interface is derived from its body, so a cycle would mean each \
188 module needed the other's contract before either had one",
189 ),
190 );
191 return;
192 }
193 let loaded = loader.load(name).or_else(|| {
196 crate::stdlib::sources(name).inspect(|_| {
197 from_library.insert(name.to_string());
198 })
199 });
200 let Some(src) = loaded else {
201 diags.push(
202 Diagnostic::error("B0603", format!("cannot find module `{name}`"), Span::NONE)
203 .with_note(format!(
204 "looked for `{name}.becki` and `{name}.beck` beside the root module, and \
205 for a standard-library module called `{name}`"
206 )),
207 );
208 return;
209 };
210 let text = src
211 .module
212 .clone()
213 .or_else(|| src.interface.clone())
214 .unwrap_or_default();
215 let display = src.path.clone().unwrap_or_else(|| format!("{name}.beck"));
216 let file = map.add(display.clone(), text.clone());
217 visiting.push(name.to_string());
218 for dep in imports_of(file, &display, &text) {
219 visit(
220 &dep,
221 loader,
222 map,
223 sources,
224 from_library,
225 order,
226 visiting,
227 diags,
228 );
229 }
230 visiting.pop();
231 sources.insert(name.to_string(), (src, file));
232 order.push(name.to_string());
233 }
234
235 visit(
236 root,
237 loader,
238 map,
239 &mut sources,
240 &mut from_library,
241 &mut order,
242 &mut visiting,
243 diags,
244 );
245 if diags.has_errors() {
246 return None;
247 }
248
249 let mut interfaces: BTreeMap<String, Interface> = BTreeMap::new();
250 let mut checked: Vec<Checked> = Vec::new();
251
252 for name in &order {
253 let Some((src, file)) = sources.get(name) else {
254 continue;
255 };
256 let display = src.path.clone().unwrap_or_else(|| format!("{name}.beck"));
257 let deps: Vec<(String, Interface)> = {
258 let text = src.module.clone().or_else(|| src.interface.clone());
259 imports_of(*file, &display, text.as_deref().unwrap_or(""))
260 .into_iter()
261 .filter_map(|d| interfaces.get(&d).map(|i| (d, i.clone())))
262 .collect()
263 };
264
265 if let Some(text) = &src.interface {
269 let published = Interface::parse(name, text, map, diags);
270 interfaces.insert(name.clone(), published);
271 }
272
273 let Some(module_src) = &src.module else {
274 if name == root {
276 diags.push(
277 Diagnostic::error(
278 "B0604",
279 format!("`{name}` has an interface but no implementation"),
280 Span::NONE,
281 )
282 .with_note("an interface is enough to compile against and never enough to run"),
283 );
284 }
285 continue;
286 };
287
288 let mut one = check_one_in(*file, &display, module_src, &deps, lock, diags);
289 if from_library.contains(name) {
296 one.program.tests.clear();
297 }
298 if let Some(published) = interfaces.get(name) {
300 if published.digest() != one.interface.digest() {
301 diags.push(
302 Diagnostic::error(
303 "B0605",
304 format!("`{name}` does not match its published interface"),
305 Span::NONE,
306 )
307 .with_note(format!(
308 "`{name}.becki` says {} and the module compiles to {}",
309 published.digest(),
310 one.interface.digest()
311 ))
312 .with_fix("regenerate it with `beck iface`, and review the diff"),
313 );
314 }
315 } else {
316 interfaces.insert(name.clone(), one.interface.clone());
317 }
318 checked.push(one);
319 }
320
321 if diags.has_errors() {
322 return None;
323 }
324
325 let interface = interfaces.get(root).cloned().unwrap_or_default();
326 let mut merged = link(root, checked, diags)?;
327 crate::liveness::mark_program(&mut merged);
329 crate::frames::reserve_program(&mut merged);
330 crate::fields::order_program(&mut merged);
331 crate::secure::check_capabilities(&merged, diags);
333 if diags.has_errors() {
334 return None;
335 }
336 let solution = place::solve(&merged, lock);
339 Some(Project {
340 program: merged,
341 solution,
342 interface,
343 })
344}
345
346pub fn slice(project: Project, diags: &mut Diagnostics) -> Option<Placed> {
352 let solution = project.solution;
353 crate::split::split(project.program, diags).map(|mut p| {
354 p.placement = solution;
355 p
356 })
357}
358
359pub fn slice_or_library(project: Project, diags: &mut Diagnostics) -> Option<Placed> {
370 let program = project.program.clone();
371 let solution = project.solution.clone();
372 let mut slicing = Diagnostics::new();
373 if let Some(mut placed) = crate::split::split(project.program, &mut slicing) {
374 diags.extend(slicing);
375 placed.placement = solution;
376 return Some(placed);
377 }
378 if !slicing.iter().all(|d| NOT_AN_APPLICATION.contains(&d.code)) {
379 diags.extend(slicing);
380 return None;
381 }
382 let graph = crate::signal::Graph::build(&program, diags)?;
385 let wire_id = format!("lib:{}", program.name);
386 Some(Placed::library(program, graph, wire_id))
387}
388
389pub const NOT_AN_APPLICATION: [&str; 3] = ["B0500", "B0501", "B0505"];
394
395pub fn compile_project(
400 root: &str,
401 loader: &dyn Loader,
402 lock: Option<&place::Lock>,
403 map: &mut beck_diag::SourceMap,
404 diags: &mut Diagnostics,
405) -> Option<Placed> {
406 let project = check_project(root, loader, lock, map, diags)?;
407 slice(project, diags)
408}
409
410fn link(root: &str, modules: Vec<Checked>, diags: &mut Diagnostics) -> Option<Program> {
412 let mut out: Option<Program> = None;
413 let mut seen: BTreeSet<std::sync::Arc<str>> = BTreeSet::new();
414
415 for Checked { mut program, .. } in modules {
416 for def in program.defs.values_mut() {
419 def.tier_is_annotated = true;
420 }
421 for s in program.signals.iter_mut() {
422 s.tier_is_annotated = true;
423 }
424
425 let Some(acc) = out.as_mut() else {
426 seen.extend(program.defs.keys().cloned());
427 out = Some(program);
428 continue;
429 };
430 for (name, def) in program.defs {
431 if !seen.insert(name.clone()) {
432 diags.push(
433 Diagnostic::error(
434 "B0601",
435 format!("`{name}` is defined in more than one module"),
436 def.span,
437 )
438 .with_note(
439 "Phase 2 links modules into one namespace and has no qualified reference \
440 to tell two definitions apart, so a clash is an error rather than a \
441 shadowing rule",
442 ),
443 );
444 continue;
445 }
446 acc.def_order.push(name.clone());
447 acc.defs.insert(name, def);
448 }
449 for (n, t) in program.types {
450 acc.types.entry(n).or_insert(t);
451 }
452 acc.own_types.extend(program.own_types);
453 acc.signals.extend(program.signals);
454 acc.tests.extend(program.tests);
455 acc.docs.extend(program.docs);
461 match (acc.identity.is_some(), program.identity) {
467 (false, Some(decl)) => acc.identity = Some(decl),
468 (true, Some(decl)) => diags.push(
469 Diagnostic::error(
470 "B0359",
471 "identity is declared in more than one module",
472 decl.span(),
473 )
474 .with_note(
475 "who authenticates a program's clients is one answer for the whole program, \
476 and a linked module set is one program",
477 ),
478 ),
479 (_, None) => {}
480 }
481 }
482
483 let mut merged = out?;
484 merged.name = root.to_string();
485 (!diags.has_errors()).then_some(merged)
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::ty::Tier;
492
493 fn project() -> BTreeMap<String, Sources> {
495 let domain = r#"
496type Id = newtype[Str]
497
498model Todo:
499 id: Id
500 text: Str
501 done: Bool
502 owner: Str
503
504model State:
505 todos: Map[Id, Todo]
506
507union Command:
508 Add(id: Id, text: Str)
509 Toggle(id: Id)
510
511union Event:
512 Added(id: Id, text: Str)
513 Toggled(id: Id)
514
515union Rejection:
516 BlankText
517 NotOwner
518
519def apply_event(s: State, env: Envelope[Event]) -> State:
520 match env.body:
521 case Added(id, text):
522 return s.with(todos=map_insert(s.todos, id, Todo(id=id, text=text, done=False, owner=env.actor)))
523 case Toggled(id):
524 return s
525"#;
526 let policy = r#"
527import domain
528
529def validate(s: State, p: Proposal) -> Result[list[Event], Rejection]:
530 match p.command:
531 case Add(id, text):
532 if str_is_empty(str_trim(text)):
533 return Err(error=BlankText)
534 return Ok(value=[Added(id=id, text=text)])
535 case Toggle(id):
536 return Ok(value=[Toggled(id=id)])
537"#;
538 let app = r#"
539import domain
540import policy
541
542def view(s: State, session: Session) -> Html:
543 return ui:
544 main:
545 h1: "todos"
546 footer: (str(map_len(s.todos)) + " todos")
547
548proposals: Stream[Proposal] = merge_clients()
549events: Stream[Event] = decide(proposals, todos, validate)
550todos: Signal[State] = durable(fold(apply_event, State(todos={}), events))
551page: Signal[Html] = per_session(todos, view)
552"#;
553 BTreeMap::from([
554 (
555 "domain".to_string(),
556 Sources {
557 module: Some(domain.into()),
558 interface: None,
559 path: None,
560 },
561 ),
562 (
563 "policy".to_string(),
564 Sources {
565 module: Some(policy.into()),
566 interface: None,
567 path: None,
568 },
569 ),
570 (
571 "app".to_string(),
572 Sources {
573 module: Some(app.into()),
574 interface: None,
575 path: None,
576 },
577 ),
578 ])
579 }
580
581 fn compile(files: &BTreeMap<String, Sources>) -> (Option<Placed>, Diagnostics) {
582 let mut diags = Diagnostics::new();
583 let mut map = beck_diag::SourceMap::new();
584 let out = compile_project(
585 "app",
586 &|n: &str| files.get(n).cloned(),
587 None,
588 &mut map,
589 &mut diags,
590 );
591 (out, diags)
592 }
593
594 #[test]
595 fn a_three_module_project_compiles_links_and_places() {
596 let files = project();
597 let (placed, d) = compile(&files);
598 assert!(
599 !d.has_errors(),
600 "{:?}",
601 d.iter().map(|x| (x.code, &x.message)).collect::<Vec<_>>()
602 );
603 let placed = placed.expect("it links");
604 for name in ["apply_event", "validate", "view"] {
606 assert!(placed.program.defs.contains_key(name), "missing {name}");
607 }
608 let tier = |n: &str| {
610 placed
611 .program
612 .signals
613 .iter()
614 .find(|s| s.name.as_ref() == n)
615 .map(|s| s.tier)
616 };
617 assert_eq!(tier("proposals"), Some(Tier::Server));
618 assert_eq!(tier("todos"), Some(Tier::Data));
619 assert_eq!(tier("page"), Some(Tier::Client));
620 }
621
622 #[test]
623 fn a_body_edit_upstream_does_not_change_any_downstream_contract() {
624 let files = project();
627 let before = {
628 let mut d = Diagnostics::new();
629 check_one(
630 "domain",
631 files["domain"].module.as_ref().unwrap(),
632 &[],
633 None,
634 &mut d,
635 )
636 .interface
637 };
638
639 let mut edited = files.clone();
640 let body = files["domain"].module.as_ref().unwrap().replace(
641 "case Toggled(id):\n return s",
642 "case Toggled(id):\n return s.with(todos=s.todos)",
643 );
644 edited.get_mut("domain").unwrap().module = Some(body);
645
646 let after = {
647 let mut d = Diagnostics::new();
648 check_one(
649 "domain",
650 edited["domain"].module.as_ref().unwrap(),
651 &[],
652 None,
653 &mut d,
654 )
655 .interface
656 };
657 assert_eq!(before.digest(), after.digest());
658
659 let (placed, d) = compile(&edited);
662 assert!(!d.has_errors());
663 assert!(placed.is_some());
664 }
665
666 #[test]
667 fn a_checked_in_interface_that_the_module_no_longer_meets_is_an_error() {
668 let mut files = project();
671 let mut d = Diagnostics::new();
672 let iface = check_one(
673 "domain",
674 files["domain"].module.as_ref().unwrap(),
675 &[],
676 None,
677 &mut d,
678 )
679 .interface;
680 files.get_mut("domain").unwrap().interface = Some(iface.render());
682 let widened = files["domain"].module.as_ref().unwrap().replace(
683 "def apply_event(s: State, env: Envelope[Event]) -> State:",
684 "def apply_event(s: State, env: Envelope[Event]) -> State uses log:\n return apply(s, env)\n\ndef apply(s: State, env: Envelope[Event]) -> State:",
685 );
686 files.get_mut("domain").unwrap().module = Some(widened);
687 let (_, d) = compile(&files);
688 let codes: Vec<&str> = d.iter().map(|x| x.code).collect();
689 assert!(codes.contains(&"B0605"), "got {codes:?}");
690 }
691
692 #[test]
693 fn an_import_cycle_is_reported_rather_than_looped_on() {
694 let files = BTreeMap::from([
695 (
696 "a".to_string(),
697 Sources {
698 module: Some("import b\n\ndef f() -> Int:\n return 1\n".into()),
699 interface: None,
700 path: None,
701 },
702 ),
703 (
704 "b".to_string(),
705 Sources {
706 module: Some("import a\n\ndef g() -> Int:\n return 2\n".into()),
707 interface: None,
708 path: None,
709 },
710 ),
711 ]);
712 let mut diags = Diagnostics::new();
713 let mut map = beck_diag::SourceMap::new();
714 compile_project(
715 "a",
716 &|n: &str| files.get(n).cloned(),
717 None,
718 &mut map,
719 &mut diags,
720 );
721 assert!(diags.iter().any(|d| d.code == "B0602"), "{:?}", diags.len());
722 }
723
724 #[test]
725 fn a_missing_module_says_what_it_looked_for() {
726 let mut diags = Diagnostics::new();
727 let files: BTreeMap<String, Sources> = BTreeMap::from([(
728 "a".to_string(),
729 Sources {
730 module: Some("import nowhere\n\ndef f() -> Int:\n return 1\n".into()),
731 interface: None,
732 path: None,
733 },
734 )]);
735 let mut map = beck_diag::SourceMap::new();
736 compile_project(
737 "a",
738 &|n: &str| files.get(n).cloned(),
739 None,
740 &mut map,
741 &mut diags,
742 );
743 assert!(diags.iter().any(|d| d.code == "B0603"));
744 }
745
746 #[test]
748 fn the_standard_library_resolves_with_no_file_beside_the_root() {
749 let files: BTreeMap<String, Sources> = BTreeMap::from([(
750 "app".to_string(),
751 Sources {
752 module: Some(
753 "import format\n\ndef nine(x: Float) -> Str:\n return fixed(x, 9)\n".into(),
754 ),
755 interface: None,
756 path: None,
757 },
758 )]);
759 let mut diags = Diagnostics::new();
760 let mut map = beck_diag::SourceMap::new();
761 let project = check_project(
762 "app",
763 &|n: &str| files.get(n).cloned(),
764 None,
765 &mut map,
766 &mut diags,
767 );
768 assert!(
769 !diags.has_errors(),
770 "{:?}",
771 diags
772 .iter()
773 .map(|d| (d.code, &d.message))
774 .collect::<Vec<_>>()
775 );
776 let project = project.expect("it links");
777 assert!(project.program.defs.contains_key("fixed"));
778 assert!(
780 project.program.tests.is_empty(),
781 "{} imported test(s) from the standard library",
782 project.program.tests.len()
783 );
784 }
785
786 #[test]
788 fn a_module_beside_the_root_shadows_the_standard_library_module_of_the_same_name() {
789 let files = BTreeMap::from([
790 (
791 "format".to_string(),
792 Sources {
793 module: Some(
794 "def fixed(x: Float, places: Int) -> Str:\n return \"mine\"\n".into(),
795 ),
796 interface: None,
797 path: None,
798 },
799 ),
800 (
801 "app".to_string(),
802 Sources {
803 module: Some(
804 "import format\n\ndef nine(x: Float) -> Str:\n return fixed(x, 9)\n"
805 .into(),
806 ),
807 interface: None,
808 path: None,
809 },
810 ),
811 ]);
812 let mut diags = Diagnostics::new();
813 let mut map = beck_diag::SourceMap::new();
814 let project = check_project(
815 "app",
816 &|n: &str| files.get(n).cloned(),
817 None,
818 &mut map,
819 &mut diags,
820 );
821 assert!(
824 !diags.has_errors(),
825 "{:?}",
826 diags
827 .iter()
828 .map(|d| (d.code, &d.message))
829 .collect::<Vec<_>>()
830 );
831 assert!(project
832 .expect("it links")
833 .program
834 .defs
835 .contains_key("fixed"));
836 }
837
838 #[test]
839 fn two_modules_defining_one_name_is_an_error_and_not_a_shadowing_rule() {
840 let files = BTreeMap::from([
841 (
842 "lib".to_string(),
843 Sources {
844 module: Some("def helper() -> Int:\n return 1\n".into()),
845 interface: None,
846 path: None,
847 },
848 ),
849 (
850 "app".to_string(),
851 Sources {
852 module: Some("import lib\n\ndef helper() -> Int:\n return 2\n".into()),
853 interface: None,
854 path: None,
855 },
856 ),
857 ]);
858 let mut diags = Diagnostics::new();
859 let mut map = beck_diag::SourceMap::new();
860 compile_project(
861 "app",
862 &|n: &str| files.get(n).cloned(),
863 None,
864 &mut map,
865 &mut diags,
866 );
867 assert!(
868 diags.iter().any(|d| d.code == "B0601"),
869 "{:?}",
870 diags.iter().map(|d| d.code).collect::<Vec<_>>()
871 );
872 }
873}