ridl_core/package.rs
1//! The salsa package model (ADR-0002 §1).
2//!
3//! A **package is a directory** (ADR-0002 §1): a named set of [`InputFile`]s
4//! loaded as one unit, carrying its own manifest's `[imports]`. A
5//! [`Workspace`] holds every loaded [`Package`] plus the workspace root's
6//! `[imports]`, kept separate so the ADR-0002 §5 resolution order stays
7//! expressible per member. Both are salsa inputs — the filesystem loader
8//! ([`load_workspace`](crate::workspace::load_workspace), behind the `fs`
9//! feature) builds them; queries downstream read them through the database,
10//! so an edit to one file invalidates only that file's parse.
11//!
12//! This module touches no filesystem: it defines the inputs, the
13//! [`package_of`] lookup query, and the pure package-declaration reader the
14//! loader's law checks build on.
15
16use std::collections::{BTreeMap, HashMap, HashSet};
17
18use ridl_syntax::ast::{AstNode as _, PathType, ServiceDef, SourceFile};
19use ridl_syntax::{SyntaxKind, SyntaxNode};
20use rowan::TextRange;
21
22use crate::db::InputFile;
23use crate::diag::{DiagCode, Diagnostic, Label, Severity, SourceMap, Span};
24use crate::interface_lock::InterfaceLock;
25use crate::manifest::TimingDefaults;
26
27/// Where a package's sources come from.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum PackageOrigin {
30 /// Loaded from the local filesystem: a workspace member, a standalone
31 /// package, or a single-file synthetic package.
32 WorkspaceMember,
33 /// Fetched from a remote URL.
34 Remote,
35 /// The embedded `ridl.std` (ADR-0007 decision 15).
36 Std,
37}
38
39/// The unit's `interfaces.lock` as the loader read it (lock design §2): the
40/// file's path and text, kept so a checker diagnostic can point into the file
41/// (RIDL-409 is reported on an entry's line), and the parsed table.
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct PackageLock {
44 /// The file's path, in the loader's path form; its parent directory is
45 /// the manifest directory `ridl lock` names.
46 pub path: String,
47 /// The file's text, byte for byte.
48 pub text: String,
49 /// The parsed table.
50 pub lock: InterfaceLock,
51}
52
53/// One loaded package: its dotted name (e.g. `veh.common`), its files, where
54/// it came from, its own manifest's `[imports]` map, and its interface lock.
55///
56/// `imports` is ADR-0002 §5 step 2: the `[imports]` of the manifest governing
57/// this package's directory tree. It shadows the workspace `[imports]` for
58/// this package only — a member's pin is never visible to a sibling member.
59/// Every package loaded from one manifest's tree (the root and its
60/// subdirectory packages) carries that manifest's map; a single-file package
61/// and `ridl.std` carry an empty map.
62#[salsa::input(debug)]
63pub struct Package {
64 pub name: String,
65 /// The unit this package belongs to: the `name` of the `[package]`
66 /// manifest whose directory tree holds it. Every package of one tree
67 /// shares it. A single-file package and the overlay package of the
68 /// language server carry their own name; `ridl.std` carries `ridl.std`.
69 #[returns(ref)]
70 pub unit: String,
71 #[returns(ref)]
72 pub files: Vec<InputFile>,
73 pub origin: PackageOrigin,
74 #[returns(ref)]
75 pub imports: BTreeMap<String, String>,
76 /// The raw `[defaults]` timing strings that govern this package: `timing`,
77 /// `command_timing` and `query_timing` (ridl §9.1 precedence: package
78 /// `[defaults]` shadows workspace `[defaults]`, merged per key at load;
79 /// ADR-0002 §4 applies the workspace root's `[defaults]` to a member).
80 /// A key no manifest sets is `None`; the checker then applies the built-in
81 /// default (ridl §9.1). Stored unparsed: `ridl-core` cannot depend on
82 /// `ridl-sem`.
83 #[returns(ref)]
84 pub defaults: TimingDefaults,
85 /// The unit's `interfaces.lock`, read by the loader from the manifest
86 /// directory (lock design §2) and shared by every package of the unit,
87 /// or `None` when the directory has no such
88 /// file, when the file is malformed (the loader reports RIDL-410 and
89 /// drops it), or when the package was not loaded from a directory (a
90 /// source string, `ridl.std`). The checker reads it to give every
91 /// interface its number; without it every number is provisional.
92 #[returns(ref)]
93 pub lock: Option<PackageLock>,
94}
95
96/// Every loaded package plus the workspace root's own `[imports]` map.
97///
98/// `imports` is ADR-0002 §5 step 3 only — the shared default for every
99/// member. It is **not** merged with member maps: the task 9 resolver walks
100/// the order itself — workspace member ([`package_of`]) → the referencing
101/// package's own [`Package::imports`] → this map → error. In a standalone
102/// package load the manifest's `[imports]` ride on the packages and this map
103/// is empty.
104#[salsa::input(debug)]
105pub struct Workspace {
106 #[returns(ref)]
107 pub packages: Vec<Package>,
108 #[returns(ref)]
109 pub imports: BTreeMap<String, String>,
110}
111
112/// The package named `name` in `ws`, or `None` when no loaded package carries
113/// that name. The embedded `ridl.std` is not part of `ws` — the resolver
114/// reaches it through [`std_package`](crate::std_lib::std_package).
115#[salsa::tracked(returns(clone))]
116pub fn package_of(db: &dyn salsa::Database, ws: Workspace, name: String) -> Option<Package> {
117 ws.packages(db)
118 .iter()
119 .copied()
120 .find(|package| package.name(db) == &name)
121}
122
123/// Every `package` declaration in a parsed file, in source order, as the
124/// dotted name plus the declaration's source range. The loader's law checks
125/// read this: the first entry names the file's package, every later entry is a
126/// TYPL-001, and a first entry that does not match the directory's expected
127/// name is a TYPL-002.
128///
129/// The only caller outside the tests is the `fs`-gated loader, so the
130/// `--no-default-features` build allows the helper to be unused.
131#[cfg_attr(not(feature = "fs"), allow(dead_code))]
132pub(crate) fn package_declarations(file: &SourceFile) -> Vec<(String, TextRange)> {
133 file.syntax()
134 .children()
135 .filter_map(ridl_syntax::ast::PackageDecl::cast)
136 .map(|decl| {
137 let name = decl
138 .qualified_name()
139 .map(|name| dotted_name(&name))
140 .unwrap_or_default();
141 (name, decl.syntax().text_range())
142 })
143 .collect()
144}
145
146/// The dotted text of a qualified name, with any trivia between its tokens
147/// dropped (`veh . common` reads as `veh.common`).
148#[cfg_attr(not(feature = "fs"), allow(dead_code))]
149fn dotted_name(name: &ridl_syntax::ast::QualifiedName) -> String {
150 name.syntax()
151 .children_with_tokens()
152 .filter_map(|element| element.into_token())
153 .filter(|token| !token.kind().is_trivia())
154 .map(|token| token.text().to_string())
155 .collect()
156}
157
158// ==========================================================================
159// The service catalog (ridl reference §14.5)
160// ==========================================================================
161
162/// One entry of the [`ServiceCatalog`]: the package that declared a service
163/// and the interfaces it names.
164///
165/// `interface_refs` holds the canonical interface references of the shape
166/// list, in slot order (ADR-0015 decision 12) — each a bare `Name` for a
167/// same-package or unresolved shape, a fully qualified `pkg.Name` when the
168/// name resolves through the declaring package's imports. Service-level
169/// `reserved` tombstones hold a slot but name no interface, so they do not
170/// appear here; a service with an inline shape carries an empty list
171/// (ridl §14.5). `inline` tells the inline shape from a list that names no
172/// interface, one holding only tombstones: rsdl's `requires` accepts the first
173/// and not the second (rsdl reference §3.2).
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct CatalogEntry {
176 pub package: String,
177 pub interface_refs: Vec<String>,
178 pub inline: bool,
179}
180
181/// The system-wide service catalog: every `service` declaration across the
182/// workspace keyed by its dotted global name (ridl §14.5).
183///
184/// The catalog is the flat global namespace the whole system agrees on. A
185/// dotted name declared twice anywhere in the workspace is a RIDL-140 error
186/// (both declarations labeled). The diagnostics carry a [`FileId`] indexing
187/// the workspace's files in package-then-file order — the order
188/// [`service_catalog`] interns them — so a driver remaps them onto its render
189/// [`SourceMap`] exactly as it does the package-scoped passes.
190#[derive(Debug, Clone, Default, PartialEq, Eq)]
191pub struct ServiceCatalog {
192 pub entries: BTreeMap<String, CatalogEntry>,
193 pub diagnostics: Vec<Diagnostic>,
194}
195
196/// Builds the workspace-wide [`ServiceCatalog`] and reports the RIDL-140
197/// duplicate-name diagnostics (ridl §14.5, §16.4).
198///
199/// The `std` package is threaded in for signature parity with the
200/// package-scoped passes (`resolve_package`, `check_package`); `ridl.std`
201/// declares no services, so it contributes nothing here. The catalog owns the
202/// global namespace: a service's dotted name is deliberately not a
203/// [`SymbolKind`](../../ridl_sem/resolve/enum.SymbolKind.html) — it lives in
204/// this namespace, not the type namespace — so the resolver never sees it and
205/// the catalog is the sole authority on service uniqueness.
206#[salsa::tracked(returns(clone))]
207pub fn service_catalog(db: &dyn salsa::Database, ws: Workspace, std: Package) -> ServiceCatalog {
208 // `ridl.std` carries no services; the parameter keeps the signature
209 // uniform with the package-scoped passes the callers already drive.
210 let _ = std;
211
212 // Diagnostics point into the workspace's files; their FileId indexes every
213 // file in package-then-file order, the order interned here. A driver
214 // rebuilds the same order to remap onto its render source map.
215 let mut sources = SourceMap::new();
216
217 let mut entries: BTreeMap<String, CatalogEntry> = BTreeMap::new();
218 let mut first_span: HashMap<String, Span> = HashMap::new();
219 let mut diagnostics = Vec::new();
220
221 for package in ws.packages(db) {
222 let package_name = package.name(db).clone();
223 // The package-wide binding view the resolver builds: local
224 // declarations and imports are collected across every file of the
225 // package, and a local declaration shadows an import.
226 let names = package_names(db, package);
227 for file in package.files(db) {
228 let file_id = sources.file_id(file.path(db), file.text(db));
229 let source = service_source(db, *file);
230 for service in source.services() {
231 let Some(dotted) = service.name() else {
232 continue;
233 };
234 let name = significant_node_text(dotted.syntax());
235 if name.is_empty() {
236 continue;
237 }
238 let span = Span {
239 file: file_id,
240 range: dotted.syntax().text_range(),
241 };
242 if let Some(&first) = first_span.get(&name) {
243 // RIDL-140: the flat global namespace already holds this
244 // name. Label both declarations (the first via a secondary
245 // label, the duplicate as the primary span).
246 diagnostics.push(Diagnostic {
247 code: DiagCode::RIDL_140,
248 severity: Severity::Error,
249 message: format!(
250 "duplicate service name `{name}` — the service catalog is a flat global namespace"
251 ),
252 primary: span,
253 labels: vec![Label {
254 span: first,
255 message: format!("`{name}` is first declared here"),
256 }],
257 fixits: Vec::new(),
258 });
259 continue;
260 }
261 first_span.insert(name.clone(), span);
262 entries.insert(
263 name,
264 CatalogEntry {
265 package: package_name.clone(),
266 interface_refs: canonical_interface_refs(&names, &service),
267 inline: service.colon_token().is_none(),
268 },
269 );
270 }
271 }
272 }
273
274 ServiceCatalog {
275 entries,
276 diagnostics,
277 }
278}
279
280/// The parsed [`SourceFile`] of one input, through the memoized parse query.
281fn service_source(db: &dyn salsa::Database, file: InputFile) -> SourceFile {
282 let parse = crate::parse_file(db, file);
283 SourceFile::cast(parse.syntax()).expect("the parser roots every tree in a SourceFile")
284}
285
286/// The canonical interface references a service's list names, in source
287/// order, or the empty list for an inline shape (ADR-0015 decision 12). The
288/// `:` token discriminates the two forms.
289///
290/// A multi-segment reference is already package-qualified and is kept as
291/// written. A single-segment reference resolves against the **package-wide**
292/// binding view ([`package_names`]), in the resolver's precedence: a local
293/// declaration anywhere in the package shadows an import, and an import bound
294/// in any file of the package canonicalizes the name to its full `pkg.Name`
295/// path. An unresolved name stays bare. This is the same bare/qualified split
296/// the checker's IR lowering produces, so the catalog and the IR always agree.
297fn canonical_interface_refs(names: &PackageNames, service: &ServiceDef) -> Vec<String> {
298 if service.colon_token().is_none() {
299 return Vec::new();
300 }
301 service
302 .shapes()
303 .map(|path| canonical_ref(names, &path))
304 .collect()
305}
306
307/// One canonical reference — see [`canonical_interface_refs`] for the rule.
308fn canonical_ref(names: &PackageNames, path: &PathType) -> String {
309 let written = significant_node_text(path.syntax());
310 if written.is_empty() || written.contains('.') {
311 return written;
312 }
313 // A local declaration shadows an import (ridl-sem `apply_imports`), so a
314 // name declared anywhere in this package stays bare.
315 if names.locals.contains(&written) {
316 return written;
317 }
318 if let Some(full) = names.imports.get(&written) {
319 return full.clone();
320 }
321 written
322}
323
324/// The package-wide names a reference can bind to, mirroring the resolver's
325/// precedence: local declarations (any file) shadow imports (any file).
326struct PackageNames {
327 locals: HashSet<String>,
328 imports: HashMap<String, String>,
329}
330
331fn package_names(db: &dyn salsa::Database, package: &Package) -> PackageNames {
332 let mut locals = HashSet::new();
333 let mut imports: HashMap<String, String> = HashMap::new();
334 for file in package.files(db) {
335 let source = service_source(db, *file);
336 for definition in source.definitions() {
337 if let Some(name) = declaration_name(definition.syntax()) {
338 locals.insert(name);
339 }
340 }
341 for interface in source.interfaces() {
342 if let Some(name) = declaration_name(interface.syntax()) {
343 locals.insert(name);
344 }
345 }
346 for import in source.imports() {
347 let Some(qualified) = import.qualified_name() else {
348 continue;
349 };
350 let full = significant_node_text(qualified.syntax());
351 let base = full.rsplit('.').next().unwrap_or(full.as_str()).to_string();
352 let local = import
353 .alias()
354 .and_then(|alias| alias.ident_token())
355 .map(|token| token.text().to_string())
356 .unwrap_or(base);
357 // Among colliding imports the first wins, as in `apply_imports`.
358 imports.entry(local).or_insert(full);
359 }
360 }
361 PackageNames { locals, imports }
362}
363
364/// The identifier of a declaration's `Name` child.
365fn declaration_name(node: &SyntaxNode) -> Option<String> {
366 node.children()
367 .find(|child| child.kind() == SyntaxKind::Name)?
368 .children_with_tokens()
369 .filter_map(|element| element.into_token())
370 .find(|token| token.kind() == SyntaxKind::Ident)
371 .map(|token| token.text().to_string())
372}
373
374/// The concatenation of every non-trivia token in `node`'s subtree — the
375/// node's text with whitespace and comments removed (`veh . adas . cruise`
376/// reads as `veh.adas.cruise`).
377fn significant_node_text(node: &SyntaxNode) -> String {
378 node.descendants_with_tokens()
379 .filter_map(|element| element.into_token())
380 .filter(|token| !token.kind().is_trivia())
381 .map(|token| token.text().to_string())
382 .collect()
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::db::RidlDatabase;
389
390 fn file(db: &RidlDatabase, path: &str, text: &str) -> InputFile {
391 InputFile::new(db, path.to_string(), text.to_string())
392 }
393
394 #[test]
395 fn package_of_finds_a_package_by_name() {
396 let db = RidlDatabase::default();
397 let common = Package::new(
398 &db,
399 "veh.common".to_string(),
400 "veh.common".to_string(),
401 vec![file(&db, "veh-common/a.typl", "package veh.common")],
402 PackageOrigin::WorkspaceMember,
403 BTreeMap::new(),
404 TimingDefaults::default(),
405 None,
406 );
407 let cluster = Package::new(
408 &db,
409 "veh.cluster".to_string(),
410 "veh.cluster".to_string(),
411 vec![file(&db, "veh-cluster/b.typl", "package veh.cluster")],
412 PackageOrigin::WorkspaceMember,
413 BTreeMap::new(),
414 TimingDefaults::default(),
415 None,
416 );
417 let ws = Workspace::new(&db, vec![common, cluster], BTreeMap::new());
418
419 assert_eq!(
420 package_of(&db, ws, "veh.cluster".to_string()),
421 Some(cluster)
422 );
423 assert_eq!(package_of(&db, ws, "veh.common".to_string()), Some(common));
424 assert_eq!(package_of(&db, ws, "veh.absent".to_string()), None);
425 }
426
427 #[test]
428 fn package_declarations_reads_names_and_ranges() {
429 let parse = ridl_syntax::parse(
430 "package veh.common\npackage veh.extra\ntype A: m\n",
431 ridl_syntax::Profile::Typl,
432 );
433 let source = SourceFile::cast(parse.syntax()).expect("root is a SourceFile");
434 let decls = package_declarations(&source);
435 assert_eq!(decls.len(), 2, "both declarations are read");
436 assert_eq!(decls[0].0, "veh.common");
437 assert_eq!(decls[0].1, TextRange::new(0.into(), 18.into()));
438 assert_eq!(decls[1].0, "veh.extra");
439 assert_eq!(decls[1].1, TextRange::new(19.into(), 36.into()));
440 }
441
442 // --- the service catalog (ridl reference §14.5) ---------------
443
444 fn ridl_package(db: &RidlDatabase, name: &str, text: &str) -> Package {
445 Package::new(
446 db,
447 name.to_string(),
448 name.to_string(),
449 vec![file(db, &format!("{}.ridl", name.replace('.', "/")), text)],
450 PackageOrigin::WorkspaceMember,
451 BTreeMap::new(),
452 TimingDefaults::default(),
453 None,
454 )
455 }
456
457 #[test]
458 fn service_catalog_flags_a_duplicate_name_across_packages() {
459 use crate::std_lib::std_package;
460 let mut db = RidlDatabase::default();
461 let std = std_package(&mut db);
462 // Two packages both declare `veh.adas.cruise` — the flat global
463 // namespace forbids it (RIDL-140).
464 let adas = ridl_package(
465 &db,
466 "veh.adas",
467 "package veh.adas\nservice veh.adas.cruise : Foo\n",
468 );
469 let redundant = ridl_package(
470 &db,
471 "veh.redundant",
472 "package veh.redundant\nservice veh.adas.cruise : Bar\n",
473 );
474 let ws = Workspace::new(&db, vec![adas, redundant], BTreeMap::new());
475
476 let catalog = service_catalog(&db, ws, std);
477
478 assert_eq!(catalog.diagnostics.len(), 1);
479 let diagnostic = &catalog.diagnostics[0];
480 assert_eq!(diagnostic.code, DiagCode::RIDL_140);
481 // Both declarations are labeled: the duplicate as the primary span, the
482 // first via one secondary label.
483 assert_eq!(diagnostic.labels.len(), 1);
484 // The name survives in the catalog once (the first declaration).
485 assert!(catalog.entries.contains_key("veh.adas.cruise"));
486 assert_eq!(catalog.entries["veh.adas.cruise"].package, "veh.adas");
487 }
488
489 #[test]
490 fn service_catalog_resolves_a_cross_package_interface_ref_through_imports() {
491 use crate::std_lib::std_package;
492 let mut db = RidlDatabase::default();
493 let std = std_package(&mut db);
494 let common = ridl_package(
495 &db,
496 "veh.common",
497 "package veh.common\ninterface CruiseControl {\n signal engaged : boolean\n}\n",
498 );
499 let adas = ridl_package(
500 &db,
501 "veh.adas",
502 "package veh.adas\nimport veh.common.CruiseControl\nservice veh.adas.cruise : CruiseControl\n",
503 );
504 let ws = Workspace::new(&db, vec![common, adas], BTreeMap::new());
505
506 let catalog = service_catalog(&db, ws, std);
507
508 assert!(catalog.diagnostics.is_empty());
509 let entry = &catalog.entries["veh.adas.cruise"];
510 assert_eq!(entry.package, "veh.adas");
511 // The bare `CruiseControl` reference canonicalizes to its full
512 // `pkg.Name` path through the declaring file's import.
513 assert_eq!(entry.interface_refs, ["veh.common.CruiseControl"]);
514 }
515
516 /// A shape list (ADR-0015 decision 12) records every named reference in
517 /// source order, each canonicalized on its own.
518 #[test]
519 fn service_catalog_records_every_shape_of_a_composed_service() {
520 use crate::std_lib::std_package;
521 let mut db = RidlDatabase::default();
522 let std = std_package(&mut db);
523 let common = ridl_package(
524 &db,
525 "veh.common",
526 "package veh.common\ninterface DiagBlock {\n query readFault(code: boolean): boolean\n}\n",
527 );
528 let body = ridl_package(
529 &db,
530 "veh.body",
531 "package veh.body\nimport veh.common.DiagBlock\n\
532 interface DoorControl {\n signal locked : boolean\n}\n\
533 service veh.body.doors : DoorControl, DiagBlock\n",
534 );
535 let ws = Workspace::new(&db, vec![common, body], BTreeMap::new());
536
537 let catalog = service_catalog(&db, ws, std);
538
539 assert!(catalog.diagnostics.is_empty());
540 let entry = &catalog.entries["veh.body.doors"];
541 assert_eq!(
542 entry.interface_refs,
543 ["DoorControl", "veh.common.DiagBlock"],
544 "source order, local bare, import canonicalized",
545 );
546 assert!(!entry.inline);
547 }
548
549 #[test]
550 fn service_catalog_records_an_inline_shape_with_an_empty_ref() {
551 use crate::std_lib::std_package;
552 let mut db = RidlDatabase::default();
553 let std = std_package(&mut db);
554 let hvac = ridl_package(
555 &db,
556 "veh.hvac",
557 "package veh.hvac\nservice veh.hvac.cabin {\n signal temperature : boolean\n}\n",
558 );
559 let ws = Workspace::new(&db, vec![hvac], BTreeMap::new());
560
561 let catalog = service_catalog(&db, ws, std);
562
563 assert!(catalog.diagnostics.is_empty());
564 let entry = &catalog.entries["veh.hvac.cabin"];
565 assert_eq!(entry.package, "veh.hvac");
566 assert_eq!(entry.interface_refs, Vec::<String>::new());
567 assert!(entry.inline);
568 }
569
570 /// A list holding only a tombstone names no interface, as an inline shape
571 /// does; `inline` tells the two forms apart.
572 #[test]
573 fn service_catalog_tells_a_tombstone_only_list_from_an_inline_shape() {
574 use crate::std_lib::std_package;
575 let mut db = RidlDatabase::default();
576 let std = std_package(&mut db);
577 let body = ridl_package(
578 &db,
579 "veh.body",
580 "package veh.body\nservice veh.body.legacy : reserved LegacyDoorDiag\n",
581 );
582 let ws = Workspace::new(&db, vec![body], BTreeMap::new());
583
584 let catalog = service_catalog(&db, ws, std);
585
586 let entry = &catalog.entries["veh.body.legacy"];
587 assert_eq!(entry.interface_refs, Vec::<String>::new());
588 assert!(!entry.inline);
589 }
590}