1mod check;
9pub mod config;
10mod features;
11mod placement;
12pub mod style;
13
14pub use check::{Signature, location};
15pub use config::{Config, SourceFile};
16
17use bern_ir::{self as ir, IR_VERSION};
18use bern_protocol::{
19 EnumDef, FieldDef, LocalizedText, ProtocolDescriptor, RecordDef, Type, TypeTable, Value,
20 VariantDef, compose,
21};
22use bern_syntax::{self as ast, ValueKind};
23use bern_target_api::Diagnostic;
24use check::{Checker, ContributedPage, Contributions, PageSlot, shared_diagnostic};
25use std::collections::{BTreeMap, BTreeSet};
26
27pub struct Compiled {
29 pub app: ir::Application,
30 pub protocol_module: String,
32 pub module_protocols: Vec<String>,
34 pub libraries: Vec<ir::ComponentLibrary>,
36 pub warnings: Vec<Diagnostic>,
37}
38
39struct Located<T> {
40 file: String,
41 module: Vec<String>,
42 home: String,
44 item: T,
45}
46
47struct Clients {
50 protocols: Vec<(String, String)>,
52}
53
54impl Clients {
55 fn namespace(&self, path: &[String], home: &str) -> Option<String> {
59 if path.len() == 1 {
60 return Some(home.to_string());
61 }
62 let module = &path[path.len() - 2];
63 self.protocols
64 .iter()
65 .find(|(name, _)| name == module)
66 .map(|(_, namespace)| namespace.clone())
67 }
68}
69
70fn syn_error(file: &str, error: syn::Error) -> Vec<Diagnostic> {
71 error
72 .into_iter()
73 .map(|error| shared_diagnostic(file, error.span(), &error.to_string()))
74 .collect()
75}
76
77fn read(file: &SourceFile) -> Result<ast::SourceItems, Vec<Diagnostic>> {
78 read_selected(file, &bern_syntax::build::Modules::default())
79}
80
81fn read_selected(
82 file: &SourceFile,
83 modules: &bern_syntax::build::Modules,
84) -> Result<ast::SourceItems, Vec<Diagnostic>> {
85 let text = std::fs::read_to_string(&file.path).map_err(|error| {
86 vec![Diagnostic::error(format!(
87 "cannot read {}: {error}",
88 file.path.display()
89 ))]
90 })?;
91 bern_syntax::items::parse_source_file_with_modules(&text, modules)
92 .map_err(|error| syn_error(&file.display, error))
93}
94
95fn resolve_type(ty: &ast::TypeRef, types: &TypeTable) -> Result<Type, String> {
97 Ok(match ty {
98 ast::TypeRef::Unit => Type::Unit,
99 ast::TypeRef::Bool => Type::Bool,
100 ast::TypeRef::Int => Type::Int,
101 ast::TypeRef::Float => Type::Float,
102 ast::TypeRef::Text => Type::Text,
103 ast::TypeRef::Optional(inner) => Type::optional(resolve_type(inner, types)?),
104 ast::TypeRef::List(inner) => Type::list(resolve_type(inner, types)?),
105 ast::TypeRef::Named(path) => {
106 let name = path.last().unwrap().clone();
107 if types.enum_def(&name).is_some() {
108 Type::Enum { name }
109 } else if types.record_def(&name).is_some() {
110 Type::Record { name }
111 } else {
112 return Err(format!(
113 "unknown type `{name}`; declare it with #[bern::value] in a listed source"
114 ));
115 }
116 }
117 })
118}
119
120fn initial_value(expr: &ast::Expr, ty: &Type, types: &TypeTable) -> Result<Value, String> {
121 let value = match expr {
122 ast::Expr::Literal { value, .. } => Checker::literal(value).0,
123 ast::Expr::Unary {
124 op: ast::UnaryOp::Neg,
125 expr,
126 ..
127 } => match initial_value(expr, ty, types)? {
128 Value::Int(value) => Value::Int(-value),
129 Value::Float(value) => Value::Float(-value),
130 _ => return Err("only numbers can be negated".into()),
131 },
132 ast::Expr::Variant(variant) if variant.fields.is_empty() => {
133 let def = types
134 .enum_def(&variant.ty.to_string())
135 .ok_or_else(|| format!("unknown enum `{}`", variant.ty))?;
136 let index = def
137 .variant_index(&variant.variant.to_string())
138 .ok_or_else(|| format!("`{}` has no variant `{}`", variant.ty, variant.variant))?;
139 Value::Enum {
140 variant: index as u32,
141 fields: vec![],
142 }
143 }
144 _ => return Err("an initial value is a literal or a unit enum variant".into()),
145 };
146 value
147 .check(ty, types)
148 .map_err(|error| format!("initial value does not match the field: {error}"))?;
149 Ok(value)
150}
151
152fn value_types(value: &ast::ValueItem) -> Vec<&ast::TypeRef> {
154 match &value.kind {
155 ValueKind::Enum(variants) => variants
156 .iter()
157 .flat_map(|variant| variant.fields.iter().map(|field| &field.ty))
158 .collect(),
159 ValueKind::Record(fields) => fields.iter().map(|field| &field.ty).collect(),
160 }
161}
162
163fn add_framework_types(types: &mut TypeTable, mentioned: &[&ast::TypeRef]) {
166 if types.record_def(bern_media::MEDIA_SOURCE).is_none()
167 && mentioned
168 .iter()
169 .any(|ty| bern_syntax::mentions(ty, bern_media::MEDIA_SOURCE))
170 {
171 types.records.push(bern_media::media_source_def());
172 }
173 let date = bern_protocol::format::CALENDAR_DATE;
174 if types.record_def(date).is_none()
175 && mentioned.iter().any(|ty| bern_syntax::mentions(ty, date))
176 {
177 types
178 .records
179 .push(bern_protocol::format::calendar_date_def());
180 }
181}
182
183fn merge_ui_types(
184 base: &TypeTable,
185 values: &[Located<ast::ValueItem>],
186 languages: (&[String], bool),
187 diagnostics: &mut Vec<Diagnostic>,
188) -> TypeTable {
189 let mut types = base.clone();
190 let protocol_names: BTreeSet<String> = types
191 .enums
192 .iter()
193 .map(|def| def.name.clone())
194 .chain(types.records.iter().map(|def| def.name.clone()))
195 .collect();
196 let mut seen = BTreeSet::new();
197 for value in values {
198 let name = value.item.name.to_string();
199 if name == bern_media::MEDIA_SOURCE || name == bern_protocol::format::CALENDAR_DATE {
200 diagnostics.push(
201 Diagnostic::error(format!(
202 "`{name}` is a framework type; use it from `bern::prelude` instead of declaring it",
203 ))
204 .at(location(&value.file, value.item.span)),
205 );
206 continue;
207 }
208 if protocol_names.contains(&name) || !seen.insert(name.clone()) {
209 diagnostics.push(
210 Diagnostic::error(format!(
211 "type `{name}` is declared more than once; Bern type names are unique per application"
212 ))
213 .at(location(&value.file, value.item.span)),
214 );
215 continue;
216 }
217 match &value.item.kind {
218 ValueKind::Enum(_) => types.enums.push(EnumDef {
219 name,
220 variants: vec![],
221 }),
222 ValueKind::Record(_) => types.records.push(RecordDef {
223 name,
224 fields: vec![],
225 }),
226 }
227 }
228 let lookup = types.clone();
229 let languages = Some(languages);
230 for value in values {
231 let name = value.item.name.to_string();
232 let field = |field: &ast::FieldItem, diagnostics: &mut Vec<Diagnostic>| match resolve_type(
233 &field.ty, &lookup,
234 ) {
235 Ok(ty) => {
236 let content = bern_syntax::lower::content(field, &ty).unwrap_or_else(|error| {
237 diagnostics.extend(syn_error(&value.file, error));
238 false
239 });
240 Some(FieldDef {
241 name: field.name.to_string(),
242 ty,
243 doc: field.doc.clone(),
244 content,
245 })
246 }
247 Err(message) => {
248 diagnostics.push(Diagnostic::error(message).at(location(&value.file, field.span)));
249 None
250 }
251 };
252 match &value.item.kind {
253 ValueKind::Enum(variants) => {
254 let mut defs = Vec::new();
255 for variant in variants {
256 let fields: Vec<FieldDef> = variant
257 .fields
258 .iter()
259 .filter_map(|f| field(f, diagnostics))
260 .collect();
261 let label = variant.label.as_ref().and_then(|tr| {
262 for (span, message) in bern_syntax::lower::check_languages(tr, languages) {
263 diagnostics.push(shared_diagnostic(&value.file, span, &message));
264 }
265 bern_syntax::lower::label(&name, &variant.name.to_string(), tr, &fields)
266 .map_err(|error| diagnostics.extend(syn_error(&value.file, error)))
267 .ok()
268 });
269 defs.push(VariantDef {
270 name: variant.name.to_string(),
271 fields,
272 label,
273 });
274 }
275 if let Some(def) = types.enums.iter_mut().find(|def| def.name == name) {
276 def.variants = defs;
277 }
278 }
279 ValueKind::Record(fields) => {
280 let defs = fields
281 .iter()
282 .filter_map(|f| field(f, diagnostics))
283 .collect();
284 if let Some(def) = types.records.iter_mut().find(|def| def.name == name) {
285 def.fields = defs;
286 }
287 }
288 }
289 }
290 if let Err(error) = types.validate() {
291 diagnostics.push(Diagnostic::error(error.message));
292 }
293 types
294}
295
296fn param_kind(
297 param: &ast::Param,
298 clients: Option<(&Clients, &str)>,
299 windows_handle: Option<&str>,
300 players_handle: Option<&str>,
301 sessions: &[ir::SessionDef],
302 types: &TypeTable,
303) -> Result<ir::ParamKind, String> {
304 let name = param.ty.name().unwrap_or_default().to_string();
305 match param.kind {
306 ast::ParamKind::Session => {
307 if sessions.iter().any(|session| session.name == name) {
308 Ok(ir::ParamKind::Session { session: name })
309 } else {
310 Err(format!(
311 "`#[session]` needs a #[bern::session] struct; `{name}` is not one"
312 ))
313 }
314 }
315 ast::ParamKind::Prop => match ¶m.ty {
316 ast::TypeRef::Named(path)
317 if name == "Client"
318 && clients
319 .is_some_and(|(clients, home)| clients.namespace(path, home).is_some()) =>
320 {
321 let (clients, home) = clients.expect("checked");
322 Ok(ir::ParamKind::Client {
323 namespace: clients.namespace(path, home).expect("checked"),
324 })
325 }
326 ast::TypeRef::Named(_) if Some(name.as_str()) == windows_handle => {
327 Ok(ir::ParamKind::Windows)
328 }
329 ast::TypeRef::Named(_) if Some(name.as_str()) == players_handle => {
330 Ok(ir::ParamKind::Players)
331 }
332 ast::TypeRef::Named(_) if sessions.iter().any(|session| session.name == name) => Err(
333 format!("session state `{name}` must be marked `#[session]`"),
334 ),
335 other => resolve_type(other, types).map(|ty| ir::ParamKind::Value { ty }),
336 },
337 }
338}
339
340fn check_content_param(
342 param: &ast::Param,
343 kind: &ir::ParamKind,
344 file: &str,
345 diagnostics: &mut Vec<Diagnostic>,
346) {
347 if !param.content {
348 return;
349 }
350 match kind {
351 ir::ParamKind::Value { ty } if bern_syntax::lower::is_text(ty) => {}
352 _ => diagnostics.push(
353 Diagnostic::error(format!(
354 "`#[content]` marks text that is data, such as a title; `{}` is not text",
355 param.name
356 ))
357 .at(location(file, param.span)),
358 ),
359 }
360}
361
362fn content_params(component: &ast::ComponentItem) -> BTreeSet<String> {
364 component
365 .params
366 .iter()
367 .filter(|param| param.content)
368 .map(|param| param.name.to_string())
369 .collect()
370}
371
372fn root_windows(root: &ast::ComponentItem) -> Option<(String, Vec<String>)> {
374 let ast::Node::Element(set) = root.body.nodes.first()? else {
375 return None;
376 };
377 if set.name != "WindowSet" {
378 return None;
379 }
380 let handle = match &set.prop("handle")?.value {
381 ast::Expr::Path(path) => path.single()?.to_string(),
382 _ => return None,
383 };
384 let ids = set
385 .children
386 .iter()
387 .flatten()
388 .filter_map(|child| match child {
389 ast::Node::Element(window) => match &window.prop("id")?.value {
390 ast::Expr::Path(path) => path.single().map(ToString::to_string),
391 _ => None,
392 },
393 _ => None,
394 })
395 .collect();
396 Some((handle, ids))
397}
398
399fn component_refs(node: &ir::Node, out: &mut Vec<String>) {
400 node.walk(&mut |node| {
401 if let ir::Node::Component(reference) = node {
402 out.push(reference.component.clone());
403 }
404 });
405}
406
407fn read_sessions(node: &ir::Node) -> Vec<String> {
410 let mut sessions = Vec::new();
411 let mut exprs: Vec<&ir::Expr> = Vec::new();
412 node.walk(&mut |node| match node {
413 ir::Node::Element(element) => {
414 for prop in element.props.values() {
415 match prop {
416 ir::Prop::Text { text } => exprs.extend(text.exprs()),
417 ir::Prop::Expr { expr } => exprs.push(expr),
418 ir::Prop::Field { field } => sessions.push(field.session.clone()),
419 ir::Prop::Query { query } => exprs.extend(&query.args),
420 _ => {}
421 }
422 }
423 match &element.action {
424 Some(ir::Action::Engine { args, .. } | ir::Action::Media { args, .. }) => {
425 exprs.extend(args);
426 }
427 Some(ir::Action::Session { field, op }) => {
428 sessions.push(field.session.clone());
429 if let ir::SessionOp::Set { value } | ir::SessionOp::PushStr { value } = op {
430 exprs.push(value);
431 }
432 }
433 Some(ir::Action::Window { .. }) | None => {}
434 }
435 }
436 ir::Node::If(node) => exprs.extend(node.branches.iter().map(|(when, _)| when)),
437 ir::Node::IfLet(node) => exprs.push(&node.value),
438 ir::Node::Match(node) => exprs.push(&node.value),
439 ir::Node::Component(_) => {}
440 });
441 sessions.extend(exprs.into_iter().flat_map(ir::Expr::sessions));
442 let mut seen = BTreeSet::new();
443 sessions.retain(|session| seen.insert(session.clone()));
444 sessions
445}
446
447fn find_cycle(components: &BTreeMap<String, Vec<String>>) -> Option<Vec<String>> {
448 fn visit(
449 name: &str,
450 graph: &BTreeMap<String, Vec<String>>,
451 stack: &mut Vec<String>,
452 done: &mut BTreeSet<String>,
453 ) -> Option<Vec<String>> {
454 if let Some(position) = stack.iter().position(|entry| entry == name) {
455 let mut cycle = stack[position..].to_vec();
456 cycle.push(name.to_string());
457 return Some(cycle);
458 }
459 if done.contains(name) {
460 return None;
461 }
462 stack.push(name.to_string());
463 for callee in graph.get(name).into_iter().flatten() {
464 if let Some(cycle) = visit(callee, graph, stack, done) {
465 return Some(cycle);
466 }
467 }
468 stack.pop();
469 done.insert(name.to_string());
470 None
471 }
472 let mut done = BTreeSet::new();
473 for name in components.keys() {
474 if let Some(cycle) = visit(name, components, &mut Vec::new(), &mut done) {
475 return Some(cycle);
476 }
477 }
478 None
479}
480
481fn window_catalog(
482 root: &ir::Element,
483 handle: String,
484 graph: &BTreeMap<String, Vec<String>>,
485 signatures: &BTreeMap<String, Signature>,
486 diagnostics: &mut Vec<Diagnostic>,
487) -> Option<ir::WindowCatalog> {
488 let primary = match root.prop("primary") {
489 Some(ir::Prop::Window { id }) => id.clone(),
490 _ => return None,
491 };
492 let mut windows = Vec::new();
493 for child in &root.children {
494 let ir::Node::Element(window) = child else {
495 continue;
496 };
497 let id = match window.prop("id") {
498 Some(ir::Prop::Window { id }) => id.clone(),
499 _ => continue,
500 };
501 let title = match window.text("title") {
502 Some(ir::Text::Localized { text, .. }) => text.clone(),
503 _ => continue,
504 };
505 let content = window.children.first()?.clone();
506 let mut reachable = Vec::new();
507 component_refs(&content, &mut reachable);
508 let mut queue = reachable.clone();
509 while let Some(name) = queue.pop() {
510 for callee in graph.get(&name).into_iter().flatten() {
511 if !reachable.contains(callee) {
512 reachable.push(callee.clone());
513 queue.push(callee.clone());
514 }
515 }
516 }
517 let mut sessions: Vec<String> = Vec::new();
518 for name in &reachable {
519 for (_, kind) in signatures
520 .get(name)
521 .map(|s| s.params.as_slice())
522 .unwrap_or(&[])
523 {
524 if let ir::ParamKind::Session { session } = kind
525 && !sessions.contains(session)
526 {
527 sessions.push(session.clone());
528 }
529 }
530 }
531 for session in read_sessions(&content) {
533 if !sessions.contains(&session) {
534 sessions.push(session);
535 }
536 }
537 let keyword = |name: &str, default: &str| -> String {
538 window.keyword(name).unwrap_or(default).to_string()
539 };
540 let initial = match keyword("initial", "closed").as_str() {
541 "open" => ir::WindowInitial::Open,
542 _ => ir::WindowInitial::Closed,
543 };
544 let presentation = match keyword("presentation", "auto").as_str() {
545 "virtual" => ir::Presentation::Virtual,
546 "floating" => ir::Presentation::Floating,
547 "native_required" => ir::Presentation::NativeRequired,
548 _ => ir::Presentation::Auto,
549 };
550 let role = match keyword("role", "standard").as_str() {
551 "settings" => ir::WindowRole::Settings,
552 "dialog" => ir::WindowRole::Dialog,
553 _ => ir::WindowRole::Standard,
554 };
555 let close = match (window.expr("confirm_close"), window.text("close_message")) {
556 (Some(when), Some(message)) => Some(ir::CloseCheck {
557 when: when.clone(),
558 message: message.clone(),
559 save: window.action.clone(),
560 }),
561 _ => None,
562 };
563 if let Some(check) = &close
564 && matches!(
565 check.save,
566 Some(
567 ir::Action::Window { .. }
568 | ir::Action::Media { .. }
569 | ir::Action::Session { .. }
570 )
571 )
572 {
573 diagnostics.push(
574 Diagnostic::error(format!(
575 "window `{id}`: the save action must be an engine command"
576 ))
577 .at(window.source.clone()),
578 );
579 }
580 let mut reads: Vec<(String, &ir::Expr)> = close
583 .iter()
584 .flat_map(ir::CloseCheck::exprs)
585 .map(|expr| ("close check".to_string(), expr))
586 .collect();
587 for name in ["immersive", "idle_enabled", "landscape"] {
588 if let Some(expr) = window.expr(name) {
589 reads.push((format!("`{name}`"), expr));
590 }
591 }
592 for (what, expr) in reads {
593 for session in expr.sessions() {
594 if !sessions.contains(&session) {
595 diagnostics.push(
596 Diagnostic::error(format!(
597 "window `{id}`'s {what} reads `{session}`, which this window does not own; its components own: {}",
598 if sessions.is_empty() { "none".to_string() } else { sessions.join(", ") }
599 ))
600 .at(window.source.clone()),
601 );
602 }
603 }
604 }
605 let mut content_sessions = Vec::new();
608 content.walk(&mut |node| {
609 if let ir::Node::Component(reference) = node {
610 for (_, arg) in &reference.args {
611 if let ir::ComponentArg::Value { expr } = arg {
612 content_sessions.extend(expr.sessions());
613 }
614 }
615 }
616 });
617 if let Some(session) = content_sessions.first() {
618 diagnostics.push(
619 Diagnostic::error(format!(
620 "window `{id}` passes `{session}` state from the root; components take their own `#[session]` parameters"
621 ))
622 .at(window.source.clone()),
623 );
624 }
625 windows.push(ir::WindowDef {
626 id,
627 title,
628 initial,
629 presentation,
630 role,
631 width: window.number("width"),
632 height: window.number("height"),
633 min_width: window.number("min_width"),
634 min_height: window.number("min_height"),
635 content,
636 sessions,
637 close,
638 chrome: match window.keyword("chrome") {
639 Some("unified") => ir::WindowChrome::Unified,
640 _ => ir::WindowChrome::Standard,
641 },
642 immersive: window.expr("immersive").cloned(),
643 idle_after: window.number("idle_after"),
644 idle_enabled: window.expr("idle_enabled").cloned(),
645 landscape: window.expr("landscape").cloned(),
646 });
647 }
648 if windows
649 .iter()
650 .filter(|window| window.role == ir::WindowRole::Settings)
651 .count()
652 > 1
653 {
654 diagnostics.push(
655 Diagnostic::error("only one window may have `role: settings`").at(root.source.clone()),
656 );
657 }
658 if windows
659 .iter()
660 .find(|window| window.id == primary)
661 .is_some_and(|window| window.role == ir::WindowRole::Settings)
662 {
663 diagnostics.push(
664 Diagnostic::error("the primary window cannot be the settings window")
665 .at(root.source.clone()),
666 );
667 }
668 let bars: Vec<&ir::Element> = root
669 .children
670 .iter()
671 .filter_map(|child| match child {
672 ir::Node::Element(bar) if bar.kind == "MenuBar" => Some(bar),
673 _ => None,
674 })
675 .collect();
676 if let [_, second, ..] = bars.as_slice() {
677 diagnostics.push(
678 Diagnostic::error("a `WindowSet` has one `MenuBar`; put every menu in it")
679 .at(second.source.clone()),
680 );
681 }
682 let menu_bar = bars.first().map(|bar| (*bar).clone());
683 if let Some(bar) = &menu_bar
686 && let Some(window) = windows.iter_mut().find(|window| window.id == primary)
687 {
688 for session in read_sessions(&ir::Node::Element(bar.clone())) {
689 if !window.sessions.contains(&session) {
690 window.sessions.push(session);
691 }
692 }
693 }
694 let items: Vec<&ir::Element> = root
695 .children
696 .iter()
697 .filter_map(|child| match child {
698 ir::Node::Element(item) if item.kind == "StatusItem" => Some(item),
699 _ => None,
700 })
701 .collect();
702 if let [_, second, ..] = items.as_slice() {
703 diagnostics.push(
704 Diagnostic::error("a `WindowSet` has one `StatusItem`; put every item in its menu")
705 .at(second.source.clone()),
706 );
707 }
708 let status_item = items.first().map(|item| (*item).clone());
709 if let Some(item) = &status_item {
710 check_status_item(item, diagnostics);
711 }
712 Some(ir::WindowCatalog {
713 handle,
714 primary,
715 windows,
716 accent: root.expr("accent").cloned(),
717 palette: root.expr("palette").cloned(),
718 appearance: root.expr("appearance").cloned(),
719 icon: root.expr("icon").cloned(),
720 menu_bar,
721 status_item,
722 })
723}
724
725fn check_status_item(item: &ir::Element, diagnostics: &mut Vec<Diagnostic>) {
729 let node = ir::Node::Element(item.clone());
730 if let Some(session) = read_sessions(&node).first() {
731 diagnostics.push(
732 Diagnostic::error(format!(
733 "the `StatusItem`'s menu runs without a window, so it has no drafts: read engine state and run commands instead of `{session}`"
734 ))
735 .at(item.source.clone()),
736 );
737 }
738 node.walk(&mut |node| {
739 let exprs: Vec<&ir::Expr> = match node {
740 ir::Node::Element(element) => {
741 for prop in ["pick", "shortcut"] {
742 if element.prop(prop).is_some() {
743 diagnostics.push(
744 Diagnostic::error(format!(
745 "a `CommandItem` of the `StatusItem` takes no `{prop}:`: its menu belongs to no window"
746 ))
747 .at(element.source.clone()),
748 );
749 }
750 }
751 element
752 .props
753 .values()
754 .flat_map(|prop| match prop {
755 ir::Prop::Text { text } => text.exprs(),
756 ir::Prop::Expr { expr } => vec![expr],
757 _ => Vec::new(),
758 })
759 .chain(match &element.action {
760 Some(ir::Action::Engine { args, .. } | ir::Action::Media { args, .. }) => {
761 args.iter().collect()
762 }
763 _ => Vec::new(),
764 })
765 .collect()
766 }
767 ir::Node::If(node) => node.branches.iter().map(|(when, _)| when).collect(),
768 ir::Node::IfLet(node) => vec![&node.value],
769 ir::Node::Match(node) => vec![&node.value],
770 ir::Node::Component(_) => Vec::new(),
771 };
772 for expr in exprs {
773 expr.walk(&mut |expr| {
774 if let ir::ExprKind::Binding {
775 binding: ir::Binding::Window { field },
776 } = &expr.kind
777 && !matches!(field.as_str(), "menu_bar" | "webview" | "tv")
779 {
780 diagnostics.push(
781 Diagnostic::error(format!(
782 "the `StatusItem`'s menu belongs to no window, so it cannot read `window.{field}`"
783 ))
784 .at(item.source.clone()),
785 );
786 }
787 });
788 }
789 });
790}
791
792fn source_fingerprint(files: &[&std::path::Path]) -> String {
793 let mut bytes = Vec::new();
794 for file in files {
795 bytes.extend(file.display().to_string().into_bytes());
796 bytes.extend(std::fs::read(file).unwrap_or_default());
797 }
798 format!("{:016x}", bern_protocol::fingerprint_bytes(&bytes))
799}
800
801pub fn compile_library(
805 library: &config::LibraryConfig,
806) -> Result<ir::ComponentLibrary, Vec<Diagnostic>> {
807 let mut diagnostics = Vec::new();
808 let mut values = Vec::new();
809 let mut components = Vec::new();
810 let crate_name = library.package.crate_name();
811 let home = String::new();
812 for file in &library.sources {
813 let items = match read(file) {
814 Ok(items) => items,
815 Err(errors) => {
816 diagnostics.extend(errors);
817 continue;
818 }
819 };
820 if !items.protocols.is_empty() || !items.sessions.is_empty() {
821 diagnostics.push(Diagnostic::error(format!(
822 "{}: component libraries cannot declare protocols or session state",
823 file.display
824 )));
825 }
826 let module = |relative: &Vec<String>| {
827 let mut module = vec![crate_name.clone()];
828 module.extend(file.module.iter().cloned());
829 module.extend(relative.iter().cloned());
830 module
831 };
832 for (path, item) in items.values {
833 values.push(Located {
834 file: file.display.clone(),
835 module: module(&path),
836 home: home.clone(),
837 item,
838 });
839 }
840 for (path, item) in items.components {
841 components.push(Located {
842 file: file.display.clone(),
843 module: module(&path),
844 home: home.clone(),
845 item,
846 });
847 }
848 }
849 let mut base = TypeTable::default();
850 let mentioned: Vec<&ast::TypeRef> = values
851 .iter()
852 .flat_map(|value| value_types(&value.item))
853 .chain(
854 components
855 .iter()
856 .flat_map(|component| component.item.params.iter().map(|param| ¶m.ty)),
857 )
858 .collect();
859 add_framework_types(&mut base, &mentioned);
860 let types = merge_ui_types(
861 &base,
862 &values,
863 (&library.languages, library.strict),
864 &mut diagnostics,
865 );
866 let mut signatures = BTreeMap::new();
867 for component in &components {
868 let mut params = Vec::new();
869 for param in &component.item.params {
870 match param_kind(param, None, None, None, &[], &types) {
871 Ok(kind) => {
872 check_content_param(param, &kind, &component.file, &mut diagnostics);
873 params.push((param.name.to_string(), kind));
874 }
875 Err(message) => diagnostics.push(
876 Diagnostic::error(format!(
877 "{message}; library components take value parameters only"
878 ))
879 .at(location(&component.file, param.span)),
880 ),
881 }
882 }
883 let name = component.item.name.to_string();
884 signatures.insert(
885 name.clone(),
886 Signature {
887 name,
888 module: component.module.clone(),
889 params,
890 content: content_params(&component.item),
891 },
892 );
893 }
894 let protocol = ProtocolDescriptor {
895 modules: Vec::new(),
896 events: Vec::new(),
897 descriptor_version: bern_protocol::DESCRIPTOR_VERSION,
898 namespace: "library".into(),
899 version: 0,
900 types: TypeTable::default(),
901 state: vec![],
902 commands: vec![],
903 };
904 let no_contracts = BTreeMap::new();
905 let mut checker = Checker::new(
906 &types,
907 &protocol,
908 &[],
909 &signatures,
910 &[],
911 &[],
912 &library.languages,
913 &library.default_language,
914 library.strict,
915 &no_contracts,
916 None,
917 );
918 let mut lowered = Vec::new();
919 for component in &components {
920 let name = component.item.name.to_string();
921 let signature = &signatures[&name];
922 if let Some(body) = checker.component(&component.file, &component.item, signature) {
923 lowered.push(ir::ComponentDef {
924 name,
925 module: component.module.clone(),
926 params: signature
927 .params
928 .iter()
929 .map(|(name, kind)| ir::ParamDef {
930 name: name.clone(),
931 kind: kind.clone(),
932 content: signature.content.contains(name),
933 })
934 .collect(),
935 body,
936 source: location(&component.file, component.item.span),
937 });
938 }
939 }
940 diagnostics.append(&mut checker.diagnostics);
941 if diagnostics.iter().any(Diagnostic::is_error) {
942 return Err(diagnostics);
943 }
944 let mut texts = std::mem::take(&mut checker.texts);
945 for def in &types.enums {
946 texts.extend(
947 def.variants
948 .iter()
949 .filter_map(|variant| variant.label.clone()),
950 );
951 }
952 let mut files: Vec<&std::path::Path> = vec![&library.manifest];
953 files.extend(library.sources.iter().map(|source| source.path.as_path()));
954 Ok(ir::ComponentLibrary {
955 descriptor_version: ir::LIBRARY_DESCRIPTOR_VERSION,
956 ir_version: IR_VERSION,
957 package: library.package.name.clone(),
958 version: library.version.clone(),
959 source_fingerprint: source_fingerprint(&files),
960 languages: library.languages.clone(),
961 types,
962 components: lowered,
963 texts,
964 })
965}
966
967pub fn check_library(library: &ir::ComponentLibrary, config: &Config) -> Vec<Diagnostic> {
970 let mut diagnostics = Vec::new();
971 if library.descriptor_version != ir::LIBRARY_DESCRIPTOR_VERSION
972 || library.ir_version != IR_VERSION
973 {
974 diagnostics.push(Diagnostic::error(format!(
975 "library `{}` was described with descriptor {} / IR {}; this compiler uses {} / {}",
976 library.package,
977 library.descriptor_version,
978 library.ir_version,
979 ir::LIBRARY_DESCRIPTOR_VERSION,
980 IR_VERSION
981 )));
982 }
983 if config.strict {
984 for text in &library.texts {
985 let missing: Vec<_> = config
986 .languages
987 .iter()
988 .filter(|language| !text.translations.contains_key(*language))
989 .cloned()
990 .collect();
991 if !missing.is_empty() {
992 diagnostics.push(
993 Diagnostic::error(format!(
994 "Missing translation\nLibrary: {} {}\nText: {}\nMissing language: {}\nRequired languages: {}",
995 library.package,
996 library.version,
997 text.key,
998 missing.join(", "),
999 config.languages.join(", ")
1000 ))
1001 .code("B102"),
1002 );
1003 }
1004 }
1005 }
1006 diagnostics
1007}
1008
1009struct ProtocolSource {
1012 path: Vec<String>,
1014 item: ast::ProtocolItem,
1015 descriptor: ProtocolDescriptor,
1016}
1017
1018impl ProtocolSource {
1019 fn name(&self) -> String {
1021 self.item.module.ident.to_string()
1022 }
1023
1024 fn rust_path(&self, package: &config::Package, file: &SourceFile) -> String {
1026 let mut path = vec![package.crate_name()];
1027 path.extend(file.module.iter().cloned());
1028 path.extend(self.path.iter().cloned());
1029 path.push(self.name());
1030 path.join("::")
1031 }
1032}
1033
1034fn read_protocol(
1038 file: &SourceFile,
1039 modules: &bern_syntax::build::Modules,
1040 imported: &TypeTable,
1041) -> Result<ProtocolSource, Vec<Diagnostic>> {
1042 let items = read_selected(file, modules)?;
1043 let Ok([(path, item)]) = <[_; 1]>::try_from(items.protocols) else {
1044 return Err(vec![Diagnostic::error(format!(
1045 "{} must contain exactly one #[bern::protocol] module",
1046 file.display
1047 ))]);
1048 };
1049 let descriptor = bern_syntax::lower::protocol_descriptor_with(&item, imported)
1050 .map_err(|error| syn_error(&file.display, error))?;
1051 Ok(ProtocolSource {
1052 path,
1053 item,
1054 descriptor,
1055 })
1056}
1057
1058fn check_protocol_labels(
1060 item: &ast::ProtocolItem,
1061 file: &str,
1062 config: &Config,
1063 diagnostics: &mut Vec<Diagnostic>,
1064) {
1065 let languages = Some((config.languages.as_slice(), config.strict));
1066 for value in &item.values {
1067 if let ValueKind::Enum(variants) = &value.kind {
1068 for tr in variants.iter().filter_map(|variant| variant.label.as_ref()) {
1069 for (span, message) in bern_syntax::lower::check_languages(tr, languages) {
1070 diagnostics.push(shared_diagnostic(file, span, &message));
1071 }
1072 }
1073 }
1074 }
1075}
1076
1077struct Protocols {
1080 protocol: ProtocolDescriptor,
1081 clients: Clients,
1082 module: String,
1084 modules: Vec<String>,
1086}
1087
1088fn compose_protocols(
1093 config: &Config,
1094 diagnostics: &mut Vec<Diagnostic>,
1095) -> Result<Protocols, Vec<Diagnostic>> {
1096 let protocol_file = &config.protocol_source;
1097 let shell = read_protocol(protocol_file, &config.build_modules, &TypeTable::default())?;
1098 check_protocol_labels(&shell.item, &protocol_file.display, config, diagnostics);
1099 let mut clients = Clients {
1100 protocols: vec![(shell.name(), shell.descriptor.namespace.clone())],
1101 };
1102 let mut module_descriptors = Vec::new();
1103 let mut module_protocols = Vec::new();
1104 let mut module_types: BTreeMap<&str, TypeTable> = BTreeMap::new();
1107 for module in &config.modules {
1108 let file = &module.protocol_source;
1109 let mut imported = TypeTable::default();
1110 for dependency in &module.depends {
1111 if let Some(types) = module_types.get(dependency.as_str())
1112 && let Err(error) = imported.merge(types)
1113 {
1114 diagnostics.push(Diagnostic::error(format!(
1115 "{}: the modules `{}` depends on declare one type in two shapes: {}",
1116 file.display, module.id, error.message
1117 )));
1118 }
1119 }
1120 let source = read_protocol(file, &config.build_modules, &imported)?;
1121 module_types.insert(&module.id, source.descriptor.types.clone());
1122 if source.descriptor.namespace != module.id {
1123 diagnostics.push(Diagnostic::error(format!(
1124 "{}: the protocol of module `{}` must use `namespace = \"{}\"`, not `{}`",
1125 file.display, module.id, module.id, source.descriptor.namespace
1126 )));
1127 }
1128 check_protocol_labels(&source.item, &file.display, config, diagnostics);
1129 let name = source.name();
1130 if clients.protocols.iter().any(|(other, _)| *other == name) {
1131 diagnostics.push(Diagnostic::error(format!(
1132 "{}: protocol module `{name}` has the name of another protocol module; name it after the module",
1133 file.display
1134 )));
1135 }
1136 clients
1137 .protocols
1138 .push((name, source.descriptor.namespace.clone()));
1139 module_protocols.push(source.rust_path(&module.protocol_package, file));
1140 module_descriptors.push(source.descriptor);
1141 }
1142 check_event_routes(config, &shell.descriptor, &module_descriptors, diagnostics);
1143 let module = shell.rust_path(&config.protocol_package, protocol_file);
1144 let protocol = if module_descriptors.is_empty() {
1145 shell.descriptor
1146 } else {
1147 let parts: Vec<bern_protocol::ModulePart<'_>> = module_descriptors
1148 .iter()
1149 .zip(&config.modules)
1150 .map(|(descriptor, module)| bern_protocol::ModulePart {
1151 descriptor,
1152 optional: module.optional,
1153 })
1154 .collect();
1155 match compose(&shell.descriptor, &parts) {
1156 Ok(protocol) => protocol,
1157 Err(error) => {
1158 diagnostics.push(Diagnostic::error(error.0));
1159 return Err(std::mem::take(diagnostics));
1160 }
1161 }
1162 };
1163 Ok(Protocols {
1164 protocol,
1165 clients,
1166 module,
1167 modules: module_protocols,
1168 })
1169}
1170
1171fn check_event_routes(
1174 config: &Config,
1175 shell: &ProtocolDescriptor,
1176 modules: &[ProtocolDescriptor],
1177 diagnostics: &mut Vec<Diagnostic>,
1178) {
1179 for (descriptor, module) in modules.iter().zip(&config.modules) {
1180 for command in &descriptor.commands {
1181 let Some(on) = &command.on else { continue };
1182 let emitter = on.split('.').next().unwrap_or_default();
1183 if !module
1184 .depends
1185 .iter()
1186 .any(|dependency| dependency == emitter)
1187 {
1188 diagnostics.push(Diagnostic::error(format!(
1189 "{}: `{}` answers `{on}`, but module `{}` does not depend on `{emitter}`; add it to `depends`",
1190 module.protocol_source.display, command.id, module.id
1191 )));
1192 }
1193 }
1194 }
1195 for command in &shell.commands {
1196 if let Some(on) = &command.on
1197 && on.split('.').next() == Some(shell.namespace.as_str())
1198 {
1199 diagnostics.push(Diagnostic::error(format!(
1200 "{}: `{}` answers the application's own event `{on}`; events reach the modules and the application above the emitter",
1201 config.protocol_source.display, command.id
1202 )));
1203 }
1204 }
1205}
1206
1207#[derive(Default)]
1209struct Sources {
1210 values: Vec<Located<ast::ValueItem>>,
1211 sessions: Vec<Located<ast::SessionItem>>,
1212 players: Vec<Located<ast::PlayersItem>>,
1213 components: Vec<Located<ast::ComponentItem>>,
1214 features: Vec<Located<ast::FeaturesItem>>,
1215 styles: Vec<(String, ast::StyleItem)>,
1217}
1218
1219fn collect_sources(config: &Config, namespace: &str, diagnostics: &mut Vec<Diagnostic>) -> Sources {
1223 let mut sources = Sources::default();
1224 let files = config
1225 .ui_sources
1226 .iter()
1227 .map(|file| (file, namespace.to_string()))
1228 .chain(config.modules.iter().flat_map(|module| {
1229 module
1230 .ui_sources
1231 .iter()
1232 .map(move |file| (file, module.id.clone()))
1233 }));
1234 for (file, home) in files {
1235 let items = match read_selected(file, &config.build_modules) {
1236 Ok(items) => items,
1237 Err(errors) => {
1238 diagnostics.extend(errors);
1239 continue;
1240 }
1241 };
1242 if !items.protocols.is_empty() {
1243 diagnostics.push(Diagnostic::error(format!(
1244 "{} declares a protocol; protocols live in the [protocol] source",
1245 file.display
1246 )));
1247 }
1248 sources.values.extend(locate(file, &home, items.values));
1249 sources.sessions.extend(locate(file, &home, items.sessions));
1250 sources.players.extend(locate(file, &home, items.players));
1251 sources
1252 .components
1253 .extend(locate(file, &home, items.components));
1254 for (_, item) in items.styles {
1255 if home == namespace {
1256 sources.styles.push((file.display.clone(), item));
1257 } else {
1258 diagnostics.push(Diagnostic::error(format!(
1259 "{}: modules cannot declare `style!`; the application's interface declares one for every module",
1260 file.display
1261 )));
1262 }
1263 }
1264 sources.features.extend(locate(file, &home, items.features));
1265 }
1266 sources
1267}
1268
1269fn locate<T>(
1272 file: &SourceFile,
1273 home: &str,
1274 items: Vec<(Vec<String>, T)>,
1275) -> impl Iterator<Item = Located<T>> {
1276 items.into_iter().map(move |(path, item)| {
1277 let mut module = file.module.clone();
1278 module.extend(path);
1279 Located {
1280 file: file.display.clone(),
1281 module,
1282 home: home.to_string(),
1283 item,
1284 }
1285 })
1286}
1287
1288fn player_catalog(
1291 declared: &[Located<ast::PlayersItem>],
1292 diagnostics: &mut Vec<Diagnostic>,
1293) -> (Option<String>, Vec<ir::PlayerDef>) {
1294 for extra in declared.iter().skip(1) {
1295 diagnostics.push(
1296 Diagnostic::error(
1297 "an application declares at most one #[bern::players] catalog; add players as fields",
1298 )
1299 .at(location(&extra.file, extra.item.span)),
1300 );
1301 }
1302 let Some(catalog) = declared.first() else {
1303 return (None, Vec::new());
1304 };
1305 let players = catalog
1306 .item
1307 .players
1308 .iter()
1309 .map(|player| ir::PlayerDef {
1310 id: player.name.to_string(),
1311 doc: player.doc.clone(),
1312 })
1313 .collect();
1314 (Some(catalog.item.name.to_string()), players)
1315}
1316
1317fn load_libraries(config: &Config, diagnostics: &mut Vec<Diagnostic>) -> Vec<ir::ComponentLibrary> {
1320 let mut libraries = Vec::new();
1321 for manifest in &config.libraries {
1322 match config::load_library(manifest).and_then(|library| compile_library(&library)) {
1323 Ok(library) => {
1324 diagnostics.extend(check_library(&library, config));
1325 libraries.push(library);
1326 }
1327 Err(errors) => diagnostics.extend(errors),
1328 }
1329 }
1330 libraries
1331}
1332
1333fn application_types(
1336 protocol: &ProtocolDescriptor,
1337 libraries: &[ir::ComponentLibrary],
1338 sources: &Sources,
1339 config: &Config,
1340 diagnostics: &mut Vec<Diagnostic>,
1341) -> TypeTable {
1342 let mut base_types = protocol.types.clone();
1343 for library in libraries {
1344 if let Err(error) = base_types.merge(&library.types) {
1345 diagnostics.push(Diagnostic::error(format!(
1346 "library `{}`: {}",
1347 library.package, error.message
1348 )));
1349 }
1350 }
1351 let mentioned: Vec<&ast::TypeRef> = sources
1352 .values
1353 .iter()
1354 .flat_map(|value| value_types(&value.item))
1355 .chain(
1356 sources
1357 .sessions
1358 .iter()
1359 .flat_map(|session| session.item.fields.iter().map(|field| &field.ty)),
1360 )
1361 .chain(
1362 sources
1363 .components
1364 .iter()
1365 .flat_map(|component| component.item.params.iter().map(|param| ¶m.ty)),
1366 )
1367 .collect();
1368 add_framework_types(&mut base_types, &mentioned);
1369 merge_ui_types(
1370 &base_types,
1371 &sources.values,
1372 (&config.languages, config.strict),
1373 diagnostics,
1374 )
1375}
1376
1377fn module_contributions(
1381 config: &Config,
1382 namespace: &str,
1383 values: &[Located<ast::ValueItem>],
1384 types: &mut TypeTable,
1385 diagnostics: &mut Vec<Diagnostic>,
1386) -> Contributions {
1387 let mut page_enums: BTreeMap<String, String> = BTreeMap::new();
1388 for value in values {
1389 if let Some(slot) = &value.item.pages
1390 && let Some(other) = page_enums.insert(slot.clone(), value.item.name.to_string())
1391 {
1392 diagnostics.push(
1393 Diagnostic::error(format!(
1394 "page slot `{slot}` is already selected by `{other}`; one enum per page slot"
1395 ))
1396 .at(location(&value.file, value.item.span)),
1397 );
1398 }
1399 }
1400 let mut starts: BTreeMap<String, (String, String)> = BTreeMap::new();
1401 let mut contributions = Contributions {
1402 namespaces: std::iter::once(namespace.to_string())
1403 .chain(config.modules.iter().map(|module| module.id.clone()))
1404 .collect(),
1405 optional: config
1406 .modules
1407 .iter()
1408 .filter(|module| module.optional)
1409 .map(|module| module.id.clone())
1410 .collect(),
1411 ..Contributions::default()
1412 };
1413 for module in &config.modules {
1414 for page in &module.pages {
1415 let Some(enum_name) = page_enums.get(&page.slot) else {
1416 diagnostics.push(Diagnostic::error(format!(
1417 "module `{}` adds page `{}` to slot `{}`, but no enum in the application's UI is marked `#[bern::value(pages = \"{}\")]`",
1418 module.id, page.id, page.slot, page.slot
1419 )));
1420 continue;
1421 };
1422 let variant = ast::pascal_case(&page.id);
1423 let Some(def) = types.enums.iter_mut().find(|def| &def.name == enum_name) else {
1424 continue;
1425 };
1426 if def.variant_index(&variant).is_some() {
1427 diagnostics.push(Diagnostic::error(format!(
1428 "module `{}` adds page `{}`, but `{enum_name}` already has a variant `{variant}`",
1429 module.id, page.id
1430 )));
1431 continue;
1432 }
1433 if config.strict {
1434 for language in &config.languages {
1435 if !page.label.contains_key(language) {
1436 diagnostics.push(
1437 Diagnostic::error(format!(
1438 "Missing translation\nProperty: label of page `{}` in {}\nMissing language: {language}",
1439 page.id,
1440 module.manifest.display()
1441 ))
1442 .code("B102"),
1443 );
1444 }
1445 }
1446 }
1447 let added = VariantDef {
1448 name: variant.clone(),
1449 fields: Vec::new(),
1450 label: Some(LocalizedText {
1451 key: bern_protocol::label_key(enum_name, &variant),
1452 translations: page.label.clone(),
1453 params: Vec::new(),
1454 }),
1455 };
1456 if page.start {
1458 if let Some(first) =
1459 starts.insert(enum_name.clone(), (module.id.clone(), page.id.clone()))
1460 {
1461 diagnostics.push(Diagnostic::error(format!(
1462 "pages `{}` of module `{}` and `{}` of module `{}` both start slot `{}`; one page per slot can",
1463 first.1, first.0, page.id, module.id, page.slot
1464 )));
1465 }
1466 def.variants.insert(0, added);
1467 } else {
1468 def.variants.push(added);
1469 }
1470 contributions
1471 .pages
1472 .entry(page.slot.clone())
1473 .or_insert_with(|| PageSlot {
1474 enum_name: enum_name.clone(),
1475 pages: Vec::new(),
1476 })
1477 .pages
1478 .push(ContributedPage {
1479 module: module.id.clone(),
1480 variant,
1481 label: page.label.clone(),
1482 icon: page.icon.clone(),
1483 badge: page.badge.clone(),
1484 when: page.when.clone(),
1485 tab: page.tab,
1486 component: page.component.clone(),
1487 });
1488 }
1489 for (slot, placed) in &module.contributes {
1490 for component in placed {
1491 contributions
1492 .slots
1493 .entry(slot.clone())
1494 .or_default()
1495 .push((module.id.clone(), component.clone()));
1496 }
1497 }
1498 }
1499 for (slot, placed) in config.contributes.iter().rev() {
1501 let entries = contributions.slots.entry(slot.clone()).or_default();
1502 for component in placed.iter().rev() {
1503 entries.insert(0, (namespace.to_string(), component.clone()));
1504 }
1505 }
1506 contributions
1507}
1508
1509fn lower_sessions(
1511 declared: &[Located<ast::SessionItem>],
1512 types: &TypeTable,
1513 diagnostics: &mut Vec<Diagnostic>,
1514) -> Vec<ir::SessionDef> {
1515 let mut sessions = Vec::new();
1516 for located in declared {
1517 let session = &located.item;
1518 let name = session.name.to_string();
1519 if sessions.iter().any(|def: &ir::SessionDef| def.name == name)
1520 || types.enum_def(&name).is_some()
1521 || types.record_def(&name).is_some()
1522 {
1523 diagnostics.push(
1524 Diagnostic::error(format!("type `{name}` is declared more than once"))
1525 .at(location(&located.file, session.span)),
1526 );
1527 continue;
1528 }
1529 let mut fields = Vec::new();
1530 for field in &session.fields {
1531 let ty = match resolve_type(&field.ty, types) {
1532 Ok(ty) => ty,
1533 Err(message) => {
1534 diagnostics
1535 .push(Diagnostic::error(message).at(location(&located.file, field.span)));
1536 continue;
1537 }
1538 };
1539 let initial = match &field.initial {
1540 Some(expr) => match initial_value(expr, &ty, types) {
1541 Ok(value) => value,
1542 Err(message) => {
1543 diagnostics.push(
1544 Diagnostic::error(message).at(location(&located.file, field.span)),
1545 );
1546 continue;
1547 }
1548 },
1549 None => Value::default_for(&ty, types).unwrap_or(Value::Unit),
1550 };
1551 fields.push(ir::SessionFieldDef {
1552 name: field.name.to_string(),
1553 ty,
1554 initial,
1555 });
1556 }
1557 sessions.push(ir::SessionDef { name, fields });
1558 }
1559 sessions
1560}
1561
1562fn root_window_set(
1566 config: &Config,
1567 components: &[Located<ast::ComponentItem>],
1568 diagnostics: &mut Vec<Diagnostic>,
1569) -> Option<(String, Vec<String>)> {
1570 let Some(root) = components
1571 .iter()
1572 .find(|component| component.item.name == config.root)
1573 else {
1574 diagnostics.push(Diagnostic::error(format!(
1575 "root component `{}` from [ui].root is not declared in the listed sources",
1576 config.root
1577 )));
1578 return None;
1579 };
1580 let Some(windows) = root_windows(&root.item) else {
1581 diagnostics.push(
1582 Diagnostic::error(
1583 "the root component's body must be a `WindowSet(handle: …, primary: …)`",
1584 )
1585 .at(location(&root.file, root.item.span)),
1586 );
1587 return None;
1588 };
1589 Some(windows)
1590}
1591
1592fn component_signatures(
1596 libraries: &[ir::ComponentLibrary],
1597 components: &[Located<ast::ComponentItem>],
1598 kind: impl Fn(&ast::Param, &str) -> Result<ir::ParamKind, String>,
1599 diagnostics: &mut Vec<Diagnostic>,
1600) -> BTreeMap<String, Signature> {
1601 let mut signatures = BTreeMap::new();
1602 for component in libraries.iter().flat_map(|library| &library.components) {
1603 signatures.insert(
1604 component.name.clone(),
1605 Signature {
1606 name: component.name.clone(),
1607 module: component.module.clone(),
1608 params: component
1609 .params
1610 .iter()
1611 .map(|param| (param.name.clone(), param.kind.clone()))
1612 .collect(),
1613 content: component
1614 .params
1615 .iter()
1616 .filter(|param| param.content)
1617 .map(|param| param.name.clone())
1618 .collect(),
1619 },
1620 );
1621 }
1622 for component in components {
1623 let name = component.item.name.to_string();
1624 if signatures.contains_key(&name) {
1625 diagnostics.push(
1626 Diagnostic::error(format!(
1627 "component `{name}` is declared twice; component names are unique per application"
1628 ))
1629 .at(location(&component.file, component.item.span)),
1630 );
1631 continue;
1632 }
1633 let mut params = Vec::new();
1634 for param in &component.item.params {
1635 match kind(param, &component.home) {
1636 Ok(kind) => {
1637 check_content_param(param, &kind, &component.file, diagnostics);
1638 params.push((param.name.to_string(), kind));
1639 }
1640 Err(message) => diagnostics
1641 .push(Diagnostic::error(message).at(location(&component.file, param.span))),
1642 }
1643 }
1644 signatures.insert(
1645 name.clone(),
1646 Signature {
1647 name,
1648 module: component.module.clone(),
1649 params,
1650 content: content_params(&component.item),
1651 },
1652 );
1653 }
1654 signatures
1655}
1656
1657fn check_placed_components(
1660 config: &Config,
1661 namespace: &str,
1662 components: &[Located<ast::ComponentItem>],
1663 diagnostics: &mut Vec<Diagnostic>,
1664) {
1665 for component in config.contributes.values().flatten() {
1666 let home = components
1667 .iter()
1668 .find(|located| located.item.name == component.as_str())
1669 .map(|located| located.home.as_str());
1670 if home != Some(namespace) {
1671 diagnostics.push(Diagnostic::error(format!(
1672 "the application places component `{component}`, which its UI sources do not declare"
1673 )));
1674 }
1675 }
1676 for module in &config.modules {
1677 let placed = module
1678 .pages
1679 .iter()
1680 .map(|page| &page.component)
1681 .chain(module.contributes.values().flatten());
1682 for component in placed {
1683 let home = components
1684 .iter()
1685 .find(|located| located.item.name == component.as_str())
1686 .map(|located| located.home.as_str());
1687 if home != Some(module.id.as_str()) {
1688 diagnostics.push(Diagnostic::error(format!(
1689 "module `{}` places component `{component}`, which its UI sources do not declare",
1690 module.id
1691 )));
1692 }
1693 }
1694 }
1695}
1696
1697fn declare_contracts(
1700 declared: &[Located<ast::FeaturesItem>],
1701 signatures: &BTreeMap<String, Signature>,
1702 diagnostics: &mut Vec<Diagnostic>,
1703) -> BTreeMap<String, Vec<String>> {
1704 let mut contract_features: BTreeMap<String, Vec<String>> = BTreeMap::new();
1705 for located in declared {
1706 let name = located.item.name.to_string();
1707 if contract_features.contains_key(&name) || signatures.contains_key(&name) {
1708 diagnostics.push(
1709 Diagnostic::error(format!("contract `{name}` is declared more than once"))
1710 .at(location(&located.file, located.item.span)),
1711 );
1712 continue;
1713 }
1714 contract_features.insert(
1715 name,
1716 located
1717 .item
1718 .features
1719 .iter()
1720 .map(|feature| feature.name.to_string())
1721 .collect(),
1722 );
1723 }
1724 contract_features
1725}
1726
1727fn scope_contracts(
1730 sources: &Sources,
1731 contract_features: &BTreeMap<String, Vec<String>>,
1732 diagnostics: &mut Vec<Diagnostic>,
1733) -> BTreeMap<String, String> {
1734 let mut contract_scopes: BTreeMap<String, String> = BTreeMap::new();
1735 for component in &sources.components {
1736 let Some(path) = &component.item.features else {
1737 continue;
1738 };
1739 let contract = path
1740 .segments
1741 .last()
1742 .map(|segment| segment.ident.to_string())
1743 .unwrap_or_default();
1744 let span = path.segments.last().map(|segment| segment.ident.span());
1745 let at = location(&component.file, span.unwrap_or(component.item.span));
1746 if !contract_features.contains_key(&contract) {
1747 diagnostics.push(
1748 Diagnostic::error(format!(
1749 "`{contract}` is not a `#[bern::features]` contract in the listed sources"
1750 ))
1751 .at(at),
1752 );
1753 } else if let Some(other) = contract_scopes.get(&contract) {
1754 diagnostics.push(
1755 Diagnostic::error(format!(
1756 "contract `{contract}` already scopes component `{other}`; a contract has one scope"
1757 ))
1758 .at(at),
1759 );
1760 } else {
1761 contract_scopes.insert(contract, component.item.name.to_string());
1762 }
1763 }
1764 for located in &sources.features {
1765 let name = located.item.name.to_string();
1766 if !contract_scopes.contains_key(&name) {
1767 diagnostics.push(
1768 Diagnostic::error(format!(
1769 "contract `{name}` scopes no component; add `#[component(features = {name})]` to the screen it describes"
1770 ))
1771 .at(location(&located.file, located.item.span)),
1772 );
1773 }
1774 }
1775 contract_scopes
1776}
1777
1778fn use_style(checker: &mut Checker<'_>, style: &ir::style::StyleSheet) {
1780 checker.variants = style::variants(style);
1781 checker.transitions = style
1782 .transitions
1783 .iter()
1784 .map(|transition| transition.name.clone())
1785 .collect();
1786 checker.tokens = style
1787 .motion
1788 .iter()
1789 .map(|token| token.name.clone())
1790 .collect();
1791 checker.patterns = style
1792 .patterns
1793 .iter()
1794 .map(|pattern| pattern.name.clone())
1795 .collect();
1796 checker.corners = style
1797 .corners
1798 .iter()
1799 .filter(|corner| corner.targets.is_empty())
1800 .map(|corner| (corner.name.clone(), corner.radius))
1801 .collect();
1802}
1803
1804fn lower_components(
1808 checker: &mut Checker<'_>,
1809 config: &Config,
1810 components: &[Located<ast::ComponentItem>],
1811 libraries: &[ir::ComponentLibrary],
1812) -> (Vec<ir::ComponentDef>, Option<ir::Element>) {
1813 let signatures = checker.signatures;
1814 let mut lowered: Vec<ir::ComponentDef> = libraries
1815 .iter()
1816 .flat_map(|library| library.components.clone())
1817 .collect();
1818 let mut root_element = None;
1819 for component in components {
1820 let name = component.item.name.to_string();
1821 let Some(signature) = signatures.get(&name) else {
1822 continue;
1823 };
1824 let Some(body) = checker.component(&component.file, &component.item, signature) else {
1825 continue;
1826 };
1827 if name == config.root {
1828 if let ir::Node::Element(element) = body {
1829 root_element = Some(element);
1830 }
1831 continue;
1832 }
1833 lowered.push(ir::ComponentDef {
1834 name,
1835 module: component.module.clone(),
1836 params: signature
1837 .params
1838 .iter()
1839 .map(|(name, kind)| ir::ParamDef {
1840 name: name.clone(),
1841 kind: kind.clone(),
1842 content: signature.content.contains(name),
1843 })
1844 .collect(),
1845 body,
1846 source: location(&component.file, component.item.span),
1847 });
1848 }
1849 (lowered, root_element)
1850}
1851
1852fn add_page_sessions(
1855 page_sessions: BTreeSet<(String, String)>,
1856 lowered: &mut [ir::ComponentDef],
1857 signatures: &mut BTreeMap<String, Signature>,
1858) {
1859 for (name, session) in page_sessions {
1860 let kind = ir::ParamKind::Session { session };
1861 if let Some(component) = lowered.iter_mut().find(|component| component.name == name)
1862 && !component.params.iter().any(|param| param.kind == kind)
1863 {
1864 component.params.push(ir::ParamDef {
1865 name: "bern_pages".into(),
1866 kind: kind.clone(),
1867 content: false,
1868 });
1869 }
1870 if let Some(signature) = signatures.get_mut(&name)
1871 && !signature
1872 .params
1873 .iter()
1874 .any(|(_, declared)| *declared == kind)
1875 {
1876 signature.params.push(("bern_pages".into(), kind));
1877 }
1878 }
1879}
1880
1881fn component_graph(
1885 components: &[ir::ComponentDef],
1886 root: Option<&ir::Element>,
1887 root_name: &str,
1888 diagnostics: &mut Vec<Diagnostic>,
1889) -> BTreeMap<String, Vec<String>> {
1890 let mut graph = BTreeMap::new();
1891 for component in components {
1892 let mut refs = Vec::new();
1893 component_refs(&component.body, &mut refs);
1894 graph.insert(component.name.clone(), refs);
1895 }
1896 if let Some(cycle) = find_cycle(&graph) {
1897 diagnostics.push(Diagnostic::error(format!(
1898 "components reference each other recursively ({}); recursive structures need a dedicated design",
1899 cycle.join(" → ")
1900 )));
1901 }
1902 let mut windows = Vec::new();
1903 if let Some(root) = root {
1904 component_refs(&ir::Node::Element(root.clone()), &mut windows);
1905 }
1906 if graph
1907 .values()
1908 .chain([&windows])
1909 .any(|refs| refs.iter().any(|name| name == root_name))
1910 {
1911 diagnostics.push(Diagnostic::error(
1912 "the root component cannot be placed by a component or a window",
1913 ));
1914 }
1915 graph
1916}
1917
1918fn check_accent_and_appearance(
1921 catalog: &ir::WindowCatalog,
1922 root: &ir::Element,
1923 types: &TypeTable,
1924 style: &ir::style::StyleSheet,
1925 diagnostics: &mut Vec<Diagnostic>,
1926) {
1927 if catalog
1928 .palette
1929 .as_ref()
1930 .is_some_and(|value| value.ty != Type::Text)
1931 {
1932 diagnostics.push(
1933 Diagnostic::error("`palette` is a text binding containing semantic color pairs")
1934 .at(root.source.clone()),
1935 );
1936 }
1937 let variants = |expr: &ir::Expr| match &expr.ty {
1938 Type::Enum { name } => types.enum_def(name).map(|def| {
1939 def.variants
1940 .iter()
1941 .map(|v| v.name.clone())
1942 .collect::<Vec<_>>()
1943 }),
1944 _ => None,
1945 };
1946 if let Some(accent) = &catalog.accent {
1947 match variants(accent) {
1948 Some(names) => {
1949 for name in names.iter().filter(|name| style.accent(name).is_none()) {
1950 diagnostics.push(
1951 Diagnostic::error(format!(
1952 "accent `{name}` is not declared; add it to the `accents` block of `style!`"
1953 ))
1954 .at(root.source.clone()),
1955 );
1956 }
1957 }
1958 None => diagnostics.push(
1959 Diagnostic::error("`accent` is an enum whose variants name accents of `style!`")
1960 .at(root.source.clone()),
1961 ),
1962 }
1963 }
1964 if let Some(appearance) = &catalog.appearance {
1965 let fits = variants(appearance).is_some_and(|names| {
1966 names
1967 .iter()
1968 .all(|name| ["system", "light", "dark"].contains(&name.to_lowercase().as_str()))
1969 });
1970 if !fits {
1971 diagnostics.push(
1972 Diagnostic::error("`appearance` is an enum of `System`, `Light` and `Dark`")
1973 .at(root.source.clone()),
1974 );
1975 }
1976 }
1977}
1978
1979fn check_icon_choice(
1982 catalog: &ir::WindowCatalog,
1983 root: &ir::Element,
1984 types: &TypeTable,
1985 alternates: &BTreeMap<String, BTreeMap<String, String>>,
1986 diagnostics: &mut Vec<Diagnostic>,
1987) {
1988 let Some(icon) = &catalog.icon else {
1989 return;
1990 };
1991 let variants = match &icon.ty {
1992 Type::Enum { name } => types.enum_def(name).map(|def| {
1993 def.variants
1994 .iter()
1995 .map(|variant| variant.name.clone())
1996 .collect::<Vec<_>>()
1997 }),
1998 _ => None,
1999 };
2000 let Some(variants) = variants else {
2001 diagnostics.push(
2002 Diagnostic::error(
2003 "`icon` is an enum: `Default` shows `[app.icon]`, and every other variant names an icon of `[app.alternate_icons]`",
2004 )
2005 .at(root.source.clone()),
2006 );
2007 return;
2008 };
2009 for variant in variants.iter().filter(|variant| *variant != "Default") {
2010 let name = bern_target_api::icon::alternate_name(variant);
2011 if !alternates.contains_key(&name) {
2012 diagnostics.push(
2013 Diagnostic::error(format!(
2014 "icon `{variant}` names `[app.alternate_icons.{name}]` in Bern.toml, which is not declared"
2015 ))
2016 .at(root.source.clone()),
2017 );
2018 }
2019 }
2020}
2021
2022fn prove_contracts(
2025 declared: &[Located<ast::FeaturesItem>],
2026 contract_scopes: &BTreeMap<String, String>,
2027 components: &[ir::ComponentDef],
2028 windows: Option<&ir::WindowCatalog>,
2029 protocol: &ProtocolDescriptor,
2030 diagnostics: &mut Vec<Diagnostic>,
2031) -> Vec<ir::FeatureContract> {
2032 let mut contracts: Vec<ir::FeatureContract> = declared
2033 .iter()
2034 .filter_map(|located| {
2035 let name = located.item.name.to_string();
2036 let component = contract_scopes.get(&name)?.clone();
2037 Some(features::lower(
2038 &features::Declared {
2039 file: &located.file,
2040 item: &located.item,
2041 },
2042 component,
2043 protocol,
2044 diagnostics,
2045 ))
2046 })
2047 .collect();
2048 features::prove(&mut contracts, components, windows, protocol, diagnostics);
2049 contracts
2050}
2051
2052fn check_resources(
2055 components: &[ir::ComponentDef],
2056 root: Option<&ir::Element>,
2057 resources: &[ir::ResourceDef],
2058 diagnostics: &mut Vec<Diagnostic>,
2059) {
2060 let root = root.cloned().map(ir::Node::Element);
2061 let trees = components
2062 .iter()
2063 .map(|component| &component.body)
2064 .chain(root.as_ref());
2065 for tree in trees {
2066 tree.walk(&mut |node| {
2067 let ir::Node::Element(element) = node else {
2068 return;
2069 };
2070 for prop in element.props.values() {
2071 if let ir::Prop::Resource { name } = prop
2072 && !resources.iter().any(|resource| &resource.name == name)
2073 {
2074 diagnostics.push(
2075 Diagnostic::error(format!(
2076 "resource `{name}` is not declared; list it under [resources] in Bern.toml"
2077 ))
2078 .at(element.source.clone()),
2079 );
2080 }
2081 }
2082 });
2083 }
2084}
2085
2086fn collect_texts(
2090 mut texts: Vec<LocalizedText>,
2091 libraries: &[ir::ComponentLibrary],
2092 types: &TypeTable,
2093 name: &LocalizedText,
2094) -> Vec<LocalizedText> {
2095 for library in libraries {
2096 texts.extend(library.texts.iter().cloned());
2097 }
2098 for def in &types.enums {
2100 for variant in &def.variants {
2101 if let Some(label) = &variant.label {
2102 texts.push(label.clone());
2103 }
2104 }
2105 }
2106 texts.push(name.clone());
2107 let mut keys = BTreeMap::new();
2108 texts.retain(|text| keys.insert(text.key.clone(), text.clone()).is_none());
2109 texts
2110}
2111
2112pub fn compile(config: &Config) -> Result<Compiled, Vec<Diagnostic>> {
2114 let mut diagnostics = Vec::new();
2115
2116 let Protocols {
2118 protocol,
2119 clients,
2120 module: protocol_module,
2121 modules: module_protocols,
2122 } = compose_protocols(config, &mut diagnostics)?;
2123 let sources = collect_sources(config, &protocol.namespace, &mut diagnostics);
2124 let (players_handle, players) = player_catalog(&sources.players, &mut diagnostics);
2125 let player_ids: Vec<String> = players.iter().map(|player| player.id.clone()).collect();
2126 let libraries = load_libraries(config, &mut diagnostics);
2127 let mut types = application_types(&protocol, &libraries, &sources, config, &mut diagnostics);
2128 let contributions = module_contributions(
2129 config,
2130 &protocol.namespace,
2131 &sources.values,
2132 &mut types,
2133 &mut diagnostics,
2134 );
2135 let sessions = lower_sessions(&sources.sessions, &types, &mut diagnostics);
2136 let Some((handle, window_ids)) = root_window_set(config, &sources.components, &mut diagnostics)
2137 else {
2138 return Err(diagnostics);
2139 };
2140 let mut signatures = component_signatures(
2141 &libraries,
2142 &sources.components,
2143 |param, home| {
2144 param_kind(
2145 param,
2146 Some((&clients, home)),
2147 Some(&handle),
2148 players_handle.as_deref(),
2149 &sessions,
2150 &types,
2151 )
2152 },
2153 &mut diagnostics,
2154 );
2155 check_placed_components(
2156 config,
2157 &protocol.namespace,
2158 &sources.components,
2159 &mut diagnostics,
2160 );
2161 let contract_features = declare_contracts(&sources.features, &signatures, &mut diagnostics);
2162 let contract_scopes = scope_contracts(&sources, &contract_features, &mut diagnostics);
2163
2164 let style = style::lower(&sources.styles, &mut diagnostics);
2166 let mut checker = Checker::new(
2167 &types,
2168 &protocol,
2169 &sessions,
2170 &signatures,
2171 &window_ids,
2172 &player_ids,
2173 &config.languages,
2174 &config.default_language,
2175 config.strict,
2176 &contract_features,
2177 config.layouts,
2178 );
2179 checker.contributions = contributions;
2180 use_style(&mut checker, &style);
2181 let (mut lowered, root_element) =
2182 lower_components(&mut checker, config, &sources.components, &libraries);
2183 diagnostics.append(&mut checker.diagnostics);
2184 let texts = std::mem::take(&mut checker.texts);
2185 let page_sessions = std::mem::take(&mut checker.page_sessions);
2186 add_page_sessions(page_sessions, &mut lowered, &mut signatures);
2187
2188 let graph = component_graph(
2190 &lowered,
2191 root_element.as_ref(),
2192 &config.root,
2193 &mut diagnostics,
2194 );
2195 let windows = root_element
2196 .as_ref()
2197 .and_then(|root| window_catalog(root, handle, &graph, &signatures, &mut diagnostics));
2198 if let (Some(catalog), Some(root)) = (&windows, &root_element) {
2199 check_accent_and_appearance(catalog, root, &types, &style, &mut diagnostics);
2200 check_icon_choice(
2201 catalog,
2202 root,
2203 &types,
2204 &config.alternate_icons,
2205 &mut diagnostics,
2206 );
2207 }
2208 let contracts = prove_contracts(
2209 &sources.features,
2210 &contract_scopes,
2211 &lowered,
2212 windows.as_ref(),
2213 &protocol,
2214 &mut diagnostics,
2215 );
2216 placement::check(&lowered, root_element.as_ref(), &mut diagnostics);
2217 check_resources(
2218 &lowered,
2219 root_element.as_ref(),
2220 &config.resources,
2221 &mut diagnostics,
2222 );
2223
2224 let name = LocalizedText {
2225 key: "app.name".into(),
2226 translations: config.name.clone(),
2227 params: vec![],
2228 };
2229 let texts = collect_texts(texts, &libraries, &types, &name);
2230 if diagnostics.iter().any(Diagnostic::is_error) {
2231 return Err(diagnostics);
2232 }
2233 let Some(windows) = windows else {
2234 return Err(vec![Diagnostic::error(
2235 "the root WindowSet could not be lowered",
2236 )]);
2237 };
2238 Ok(Compiled {
2239 app: ir::Application {
2240 ir_version: IR_VERSION,
2241 app: ir::AppInfo {
2242 id: config.app_id.clone(),
2243 bundle_id: config.bundle_id.clone(),
2244 name,
2245 version: config.version.clone(),
2246 icon: config.icon.clone(),
2247 platform_icons: config.platform_icons.clone(),
2248 alternate_icons: config.alternate_icons.clone(),
2249 publisher: config.publisher.clone(),
2250 files_folder: config.files_folder,
2251 background_audio: config.background_audio,
2252 overlays: config.overlays,
2253 photos: config.photos,
2254 },
2255 languages: ir::Languages {
2256 supported: config.languages.clone(),
2257 default: config.default_language.clone(),
2258 strict: config.strict,
2259 },
2260 theme: ir::Theme {
2261 mode: config.theme_mode,
2262 brand_seed: config.brand_seed,
2263 },
2264 protocol,
2265 types,
2266 sessions,
2267 windows,
2268 players,
2269 components: lowered,
2270 texts,
2271 resources: config.resources.clone(),
2272 layouts: config.layouts,
2273 contracts,
2274 style,
2275 },
2276 protocol_module,
2277 module_protocols,
2278 libraries,
2279 warnings: diagnostics,
2280 })
2281}
2282
2283#[cfg(test)]
2284mod tests;