Skip to main content

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