Skip to main content

bern_compiler/check/
mod.rs

1//! Semantic checking of component declarations and lowering to IR.
2//!
3//! The checker walks a component body node by node (`nodes`), expanding
4//! the slots modules fill (`slots`). Each element (`element`) checks its
5//! props (`props`), the bindings and expressions they hold (`bindings`,
6//! `expr`) and its action (`action`).
7
8use bern_ir::{self as ir, SourceLocation};
9use bern_protocol::{LocalizedText, ProtocolDescriptor, Type, TypeTable};
10use bern_syntax as ast;
11use bern_target_api::Diagnostic;
12use proc_macro2::Span;
13use std::collections::{BTreeMap, BTreeSet};
14
15mod action;
16mod bindings;
17mod element;
18mod expr;
19mod nodes;
20mod props;
21mod slots;
22
23/// Parameters of a component as seen by its callers.
24#[derive(Clone, Debug)]
25pub struct Signature {
26    pub name: String,
27    pub module: Vec<String>,
28    pub params: Vec<(String, ir::ParamKind)>,
29    /// Parameters marked `#[content]`: text the component shows as data.
30    pub content: BTreeSet<String>,
31}
32
33#[derive(Clone, Debug)]
34enum Root {
35    /// A typed engine client, by the namespace of its protocol.
36    Client(String),
37    Windows,
38    Players,
39    Session(String),
40    /// A template variable, pattern binding or parameter, and whether the
41    /// value itself is content: text that is data, shown as it is.
42    Local(Type, bool),
43}
44
45#[derive(Default)]
46struct Scope(Vec<(String, Root)>);
47
48/// What the application's modules contribute to the shell's slots.
49#[derive(Default)]
50pub struct Contributions {
51    /// Slot id to `(module namespace, component)`, in module order.
52    pub slots: BTreeMap<String, Vec<(String, String)>>,
53    /// Page slot id to the enum selecting its pages and the pages the
54    /// modules add, in module order.
55    pub pages: BTreeMap<String, PageSlot>,
56    /// Namespaces of every protocol, whose clients expansions pass on;
57    /// the application's first.
58    pub namespaces: Vec<String>,
59    /// Modules that can be switched off: what they contribute shows only
60    /// while they run.
61    pub optional: BTreeSet<String>,
62}
63
64#[derive(Clone)]
65pub struct PageSlot {
66    pub enum_name: String,
67    pub pages: Vec<ContributedPage>,
68}
69
70#[derive(Clone)]
71pub struct ContributedPage {
72    pub module: String,
73    pub variant: String,
74    pub label: BTreeMap<String, String>,
75    pub icon: Option<String>,
76    pub badge: Option<String>,
77    /// Boolean state field of the module while which the navigation item
78    /// shows.
79    pub when: Option<String>,
80    /// A `Tabs` lists the page.
81    pub tab: bool,
82    pub component: String,
83}
84
85/// Scope name under which an expansion passes the `namespace` client.
86fn slot_client(namespace: &str) -> String {
87    format!("__bern_client_{namespace}")
88}
89
90impl Contributions {
91    /// `declaration`, shown only while `module` runs when it is optional.
92    fn guarded(&self, module: &str, declaration: String) -> String {
93        match self.namespaces.first() {
94            Some(shell) if self.optional.contains(module) => format!(
95                "if {}.{}.{module} {{ {declaration} }}",
96                slot_client(shell),
97                bern_protocol::MODULES_NAMESPACE
98            ),
99            _ => declaration,
100        }
101    }
102}
103
104impl Scope {
105    /// The first name bound to a root of the same kind as `root`.
106    fn name_of(&self, wanted: fn(&Root) -> bool) -> Option<String> {
107        self.0
108            .iter()
109            .rev()
110            .find(|(_, root)| wanted(root))
111            .map(|(name, _)| name.clone())
112    }
113
114    fn get(&self, name: &str) -> Option<&Root> {
115        self.0
116            .iter()
117            .rev()
118            .find(|(entry, _)| entry == name)
119            .map(|(_, root)| root)
120    }
121    fn push(&mut self, name: String, root: Root) {
122        self.0.push((name, root));
123    }
124    fn pop(&mut self) {
125        self.0.pop();
126    }
127}
128
129#[derive(Clone, Default)]
130struct Ctx {
131    parent: Option<String>,
132    is_root: bool,
133    selection: Option<Type>,
134    route: Option<Type>,
135}
136
137#[derive(Clone, Copy)]
138enum FieldExpect {
139    Text,
140    Bool,
141    Number,
142    File,
143    Position,
144    Picker,
145    Selection,
146    Path,
147    Keys,
148}
149
150/// Locations are reported relative to the project, like rustc does.
151pub fn location(file: &str, span: Span) -> SourceLocation {
152    let start = span.start();
153    SourceLocation {
154        file: file.to_string(),
155        line: start.line as u32,
156        column: start.column as u32 + 1,
157    }
158}
159
160/// Turn a message from the shared checks into a diagnostic, lifting a
161/// leading `error[Bxxx]: ` into the diagnostic code.
162pub fn shared_diagnostic(file: &str, span: Span, message: &str) -> Diagnostic {
163    for code in CODES {
164        let prefix = format!("error[{code}]: ");
165        if let Some(rest) = message.strip_prefix(&prefix) {
166            return Diagnostic::error(rest).code(code).at(location(file, span));
167        }
168    }
169    Diagnostic::error(message).at(location(file, span))
170}
171
172/// Diagnostic codes the shared checks put in front of their messages.
173const CODES: [&str; 8] = [
174    "B101", "B102", "B103", "B104", "B105", "B301", "B401", "B402",
175];
176
177/// B105 for text shown as it is that nothing declares as data.
178fn not_content(path: &str) -> String {
179    format!(
180        "error[B105]: `{path}` is shown as it is, but is not marked `#[content]`; mark the field or parameter `#[content]` if it is data such as a title or a file name, otherwise have the engine give a number, date or enum and write the sentence with `tr!`"
181    )
182}
183
184pub fn is_labeled_enum(ty: &Type, types: &TypeTable) -> bool {
185    match ty {
186        Type::Enum { name } => types
187            .enum_def(name)
188            .is_some_and(|def| def.variants.iter().all(|variant| variant.label.is_some())),
189        _ => false,
190    }
191}
192
193pub fn is_displayable(ty: &Type, types: &TypeTable) -> bool {
194    matches!(ty, Type::Text | Type::Int | Type::Float) || is_labeled_enum(ty, types)
195}
196
197pub struct Checker<'a> {
198    pub types: &'a TypeTable,
199    pub protocol: &'a ProtocolDescriptor,
200    pub sessions: &'a [ir::SessionDef],
201    pub signatures: &'a BTreeMap<String, Signature>,
202    pub window_ids: &'a [String],
203    pub player_ids: &'a [String],
204    pub languages: &'a [String],
205    pub default_language: &'a str,
206    pub strict: bool,
207    pub file: String,
208    pub component: String,
209    pub diagnostics: Vec<Diagnostic>,
210    pub texts: Vec<LocalizedText>,
211    /// Feature contracts by enum name, with their feature names.
212    pub contracts: &'a BTreeMap<String, Vec<String>>,
213    /// Breakpoints from `[layouts]`, if declared.
214    pub layouts: Option<ir::LayoutConfig>,
215    /// What modules contribute to slots, expanded where the shell places
216    /// `Slot`, `Pages` and `PageContent`.
217    pub contributions: Contributions,
218    /// `(control, variant)` pairs `style!` declares.
219    pub variants: Vec<(String, String)>,
220    /// Transition, motion token and pattern names `style!` provides.
221    pub transitions: Vec<String>,
222    pub tokens: Vec<String>,
223    pub patterns: Vec<String>,
224    /// Corner names `style!` declares, with their radius on every platform.
225    pub corners: Vec<(String, f64)>,
226    /// Whether the expression being lowered is a `corner`, where those
227    /// names stand for their radii.
228    corner_names: bool,
229    /// `(component, session)`: components that show a page with
230    /// `pages.<id>.show()`, and the session holding the selection they set.
231    pub page_sessions: BTreeSet<(String, String)>,
232    ids: BTreeSet<String>,
233}
234
235impl<'a> Checker<'a> {
236    #[allow(clippy::too_many_arguments)]
237    pub fn new(
238        types: &'a TypeTable,
239        protocol: &'a ProtocolDescriptor,
240        sessions: &'a [ir::SessionDef],
241        signatures: &'a BTreeMap<String, Signature>,
242        window_ids: &'a [String],
243        player_ids: &'a [String],
244        languages: &'a [String],
245        default_language: &'a str,
246        strict: bool,
247        contracts: &'a BTreeMap<String, Vec<String>>,
248        layouts: Option<ir::LayoutConfig>,
249    ) -> Self {
250        Self {
251            contracts,
252            layouts,
253            types,
254            protocol,
255            sessions,
256            signatures,
257            window_ids,
258            player_ids,
259            languages,
260            default_language,
261            strict,
262            file: String::new(),
263            component: String::new(),
264            diagnostics: Vec::new(),
265            texts: Vec::new(),
266            contributions: Contributions::default(),
267            variants: Vec::new(),
268            transitions: Vec::new(),
269            tokens: Vec::new(),
270            patterns: Vec::new(),
271            corners: Vec::new(),
272            corner_names: false,
273            page_sessions: BTreeSet::new(),
274            ids: BTreeSet::new(),
275        }
276    }
277
278    fn loc(&self, span: Span) -> SourceLocation {
279        location(&self.file, span)
280    }
281
282    fn error(&mut self, span: Span, message: impl Into<String>) {
283        let message = message.into();
284        self.diagnostics
285            .push(shared_diagnostic(&self.file, span, &message));
286    }
287
288    /// Check one component body and lower it to an IR node.
289    pub fn component(
290        &mut self,
291        file: &str,
292        item: &ast::ComponentItem,
293        signature: &Signature,
294    ) -> Option<ir::Node> {
295        self.file = file.to_string();
296        self.component = item.name.to_string();
297        self.ids.clear();
298        let mut scope = Scope::default();
299        for (name, kind) in &signature.params {
300            let root = match kind {
301                ir::ParamKind::Client { namespace } => Root::Client(namespace.clone()),
302                ir::ParamKind::Windows => Root::Windows,
303                ir::ParamKind::Players => Root::Players,
304                ir::ParamKind::Value { ty } => {
305                    Root::Local(ty.clone(), signature.content.contains(name))
306                }
307                ir::ParamKind::Session { session } => Root::Session(session.clone()),
308            };
309            scope.push(name.clone(), root);
310        }
311        if item.body.nodes.len() != 1 {
312            self.error(
313                item.span,
314                "a component body has exactly one root node; wrap siblings in `Column { … }`",
315            );
316            return None;
317        }
318        let ctx = Ctx {
319            is_root: true,
320            ..Ctx::default()
321        };
322        self.node(&item.body.nodes[0], "0", &ctx, &mut scope)
323    }
324}