Skip to main content

ridl_core/
package.rs

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