1use 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#[derive(Clone, Debug)]
25pub struct Signature {
26 pub name: String,
27 pub module: Vec<String>,
28 pub params: Vec<(String, ir::ParamKind)>,
29 pub content: BTreeSet<String>,
31}
32
33#[derive(Clone, Debug)]
34enum Root {
35 Client(String),
37 Windows,
38 Players,
39 Session(String),
40 Local(Type, bool),
43}
44
45#[derive(Default)]
46struct Scope(Vec<(String, Root)>);
47
48#[derive(Default)]
50pub struct Contributions {
51 pub slots: BTreeMap<String, Vec<(String, String)>>,
53 pub pages: BTreeMap<String, PageSlot>,
56 pub namespaces: Vec<String>,
59 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 pub when: Option<String>,
80 pub tab: bool,
82 pub component: String,
83}
84
85fn slot_client(namespace: &str) -> String {
87 format!("__bern_client_{namespace}")
88}
89
90impl Contributions {
91 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 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
150pub 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
160pub 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
172const CODES: [&str; 8] = [
174 "B101", "B102", "B103", "B104", "B105", "B301", "B401", "B402",
175];
176
177fn 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 pub contracts: &'a BTreeMap<String, Vec<String>>,
213 pub layouts: Option<ir::LayoutConfig>,
215 pub contributions: Contributions,
218 pub variants: Vec<(String, String)>,
220 pub transitions: Vec<String>,
222 pub tokens: Vec<String>,
223 pub patterns: Vec<String>,
224 pub corners: Vec<(String, f64)>,
226 corner_names: bool,
229 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 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}