ridl-core 0.6.0

The RIDL family compiler core: the incremental database, diagnostics, and the package and workspace model.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
//! The salsa package model (ADR-0002 §1).
//!
//! A **package is a directory** (ADR-0002 §1): a named set of [`InputFile`]s
//! loaded as one unit, carrying its own manifest's `[imports]`. A
//! [`Workspace`] holds every loaded [`Package`] plus the workspace root's
//! `[imports]`, kept separate so the ADR-0002 §5 resolution order stays
//! expressible per member. Both are salsa inputs — the filesystem loader
//! ([`load_workspace`](crate::workspace::load_workspace), behind the `fs`
//! feature) builds them; queries downstream read them through the database,
//! so an edit to one file invalidates only that file's parse.
//!
//! This module touches no filesystem: it defines the inputs, the
//! [`package_of`] lookup query, and the pure package-declaration reader the
//! loader's law checks build on.

use std::collections::{BTreeMap, HashMap, HashSet};

use ridl_syntax::ast::{AstNode as _, PathType, ServiceDef, SourceFile};
use ridl_syntax::{SyntaxKind, SyntaxNode};
use rowan::TextRange;

use crate::db::InputFile;
use crate::diag::{DiagCode, Diagnostic, Label, Severity, SourceMap, Span};
use crate::interface_lock::InterfaceLock;
use crate::manifest::TimingDefaults;

/// Where a package's sources come from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PackageOrigin {
    /// Loaded from the local filesystem: a workspace member, a standalone
    /// package, or a single-file synthetic package.
    WorkspaceMember,
    /// Fetched from a remote URL.
    Remote,
    /// The embedded `ridl.std` (ADR-0007 decision 15).
    Std,
}

/// The package's `interfaces.lock` as the loader read it (lock design §2): the
/// file's path and text, kept so a checker diagnostic can point into the file
/// (RIDL-409 is reported on an entry's line), and the parsed table.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PackageLock {
    /// The file's path, in the loader's path form; its parent directory is
    /// the package directory `ridl lock` names.
    pub path: String,
    /// The file's text, byte for byte.
    pub text: String,
    /// The parsed table.
    pub lock: InterfaceLock,
}

/// One loaded package: its dotted name (e.g. `veh.common`), its files, where
/// it came from, its own manifest's `[imports]` map, and its interface lock.
///
/// `imports` is ADR-0002 §5 step 2: the `[imports]` of the manifest governing
/// this package's directory tree. It shadows the workspace `[imports]` for
/// this package only — a member's pin is never visible to a sibling member.
/// Every package loaded from one manifest's tree (the root and its
/// subdirectory packages) carries that manifest's map; a single-file package
/// and `ridl.std` carry an empty map.
#[salsa::input(debug)]
pub struct Package {
    pub name: String,
    #[returns(ref)]
    pub files: Vec<InputFile>,
    pub origin: PackageOrigin,
    #[returns(ref)]
    pub imports: BTreeMap<String, String>,
    /// The raw `[defaults]` timing strings that govern this package: `timing`,
    /// `command_timing` and `query_timing` (ADR-0002 §5 precedence: package
    /// `[defaults]` shadows workspace `[defaults]`, merged per key at load).
    /// A key no manifest sets is `None`; the checker then applies the built-in
    /// default (ridl §9.1). Stored unparsed: `ridl-core` cannot depend on
    /// `ridl-sem`.
    #[returns(ref)]
    pub defaults: TimingDefaults,
    /// The package's `interfaces.lock`, read by the loader from the package
    /// directory (lock design §2), or `None` when the directory has no such
    /// file, when the file is malformed (the loader reports RIDL-410 and
    /// drops it), or when the package was not loaded from a directory (a
    /// source string, `ridl.std`). The checker reads it to give every
    /// interface its number; without it every number is provisional.
    #[returns(ref)]
    pub lock: Option<PackageLock>,
}

/// Every loaded package plus the workspace root's own `[imports]` map.
///
/// `imports` is ADR-0002 §5 step 3 only — the shared default for every
/// member. It is **not** merged with member maps: the task 9 resolver walks
/// the order itself — workspace member ([`package_of`]) → the referencing
/// package's own [`Package::imports`] → this map → error. In a standalone
/// package load the manifest's `[imports]` ride on the packages and this map
/// is empty.
#[salsa::input(debug)]
pub struct Workspace {
    #[returns(ref)]
    pub packages: Vec<Package>,
    #[returns(ref)]
    pub imports: BTreeMap<String, String>,
}

/// The package named `name` in `ws`, or `None` when no loaded package carries
/// that name. The embedded `ridl.std` is not part of `ws` — the resolver
/// reaches it through [`std_package`](crate::std_lib::std_package).
#[salsa::tracked(returns(clone))]
pub fn package_of(db: &dyn salsa::Database, ws: Workspace, name: String) -> Option<Package> {
    ws.packages(db)
        .iter()
        .copied()
        .find(|package| package.name(db) == &name)
}

/// Every `package` declaration in a parsed file, in source order, as the
/// dotted name plus the declaration's source range. The loader's law checks
/// read this: the first entry names the file's package, every later entry is a
/// TYPL-001, and a first entry that does not match the directory's expected
/// name is a TYPL-002.
///
/// The only caller outside the tests is the `fs`-gated loader, so the
/// `--no-default-features` build allows the helper to be unused.
#[cfg_attr(not(feature = "fs"), allow(dead_code))]
pub(crate) fn package_declarations(file: &SourceFile) -> Vec<(String, TextRange)> {
    file.syntax()
        .children()
        .filter_map(ridl_syntax::ast::PackageDecl::cast)
        .map(|decl| {
            let name = decl
                .qualified_name()
                .map(|name| dotted_name(&name))
                .unwrap_or_default();
            (name, decl.syntax().text_range())
        })
        .collect()
}

/// The dotted text of a qualified name, with any trivia between its tokens
/// dropped (`veh . common` reads as `veh.common`).
#[cfg_attr(not(feature = "fs"), allow(dead_code))]
fn dotted_name(name: &ridl_syntax::ast::QualifiedName) -> String {
    name.syntax()
        .children_with_tokens()
        .filter_map(|element| element.into_token())
        .filter(|token| !token.kind().is_trivia())
        .map(|token| token.text().to_string())
        .collect()
}

// ==========================================================================
// The service catalog (ridl reference §14.5)
// ==========================================================================

/// One entry of the [`ServiceCatalog`]: the package that declared a service
/// and the interfaces it names.
///
/// `interface_refs` holds the canonical interface references of the shape
/// list, in slot order (ADR-0015 decision 12) — each a bare `Name` for a
/// same-package or unresolved shape, a fully qualified `pkg.Name` when the
/// name resolves through the declaring package's imports. Service-level
/// `reserved` tombstones hold a slot but name no interface, so they do not
/// appear here; a service with an inline shape carries an empty list
/// (ridl §14.5). `inline` tells the inline shape from a list that names no
/// interface, one holding only tombstones: rsdl's `requires` accepts the first
/// and not the second (rsdl reference §3.2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogEntry {
    pub package: String,
    pub interface_refs: Vec<String>,
    pub inline: bool,
}

/// The system-wide service catalog: every `service` declaration across the
/// workspace keyed by its dotted global name (ridl §14.5).
///
/// The catalog is the flat global namespace the whole system agrees on. A
/// dotted name declared twice anywhere in the workspace is a RIDL-140 error
/// (both declarations labeled). The diagnostics carry a [`FileId`] indexing
/// the workspace's files in package-then-file order — the order
/// [`service_catalog`] interns them — so a driver remaps them onto its render
/// [`SourceMap`] exactly as it does the package-scoped passes.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServiceCatalog {
    pub entries: BTreeMap<String, CatalogEntry>,
    pub diagnostics: Vec<Diagnostic>,
}

/// Builds the workspace-wide [`ServiceCatalog`] and reports the RIDL-140
/// duplicate-name diagnostics (ridl §14.5, §16.4).
///
/// The `std` package is threaded in for signature parity with the
/// package-scoped passes (`resolve_package`, `check_package`); `ridl.std`
/// declares no services, so it contributes nothing here. The catalog owns the
/// global namespace: a service's dotted name is deliberately not a
/// [`SymbolKind`](../../ridl_sem/resolve/enum.SymbolKind.html) — it lives in
/// this namespace, not the type namespace — so the resolver never sees it and
/// the catalog is the sole authority on service uniqueness.
#[salsa::tracked(returns(clone))]
pub fn service_catalog(db: &dyn salsa::Database, ws: Workspace, std: Package) -> ServiceCatalog {
    // `ridl.std` carries no services; the parameter keeps the signature
    // uniform with the package-scoped passes the callers already drive.
    let _ = std;

    // Diagnostics point into the workspace's files; their FileId indexes every
    // file in package-then-file order, the order interned here. A driver
    // rebuilds the same order to remap onto its render source map.
    let mut sources = SourceMap::new();

    let mut entries: BTreeMap<String, CatalogEntry> = BTreeMap::new();
    let mut first_span: HashMap<String, Span> = HashMap::new();
    let mut diagnostics = Vec::new();

    for package in ws.packages(db) {
        let package_name = package.name(db).clone();
        // The package-wide binding view the resolver builds: local
        // declarations and imports are collected across every file of the
        // package, and a local declaration shadows an import.
        let names = package_names(db, package);
        for file in package.files(db) {
            let file_id = sources.file_id(file.path(db), file.text(db));
            let source = service_source(db, *file);
            for service in source.services() {
                let Some(dotted) = service.name() else {
                    continue;
                };
                let name = significant_node_text(dotted.syntax());
                if name.is_empty() {
                    continue;
                }
                let span = Span {
                    file: file_id,
                    range: dotted.syntax().text_range(),
                };
                if let Some(&first) = first_span.get(&name) {
                    // RIDL-140: the flat global namespace already holds this
                    // name. Label both declarations (the first via a secondary
                    // label, the duplicate as the primary span).
                    diagnostics.push(Diagnostic {
                        code: DiagCode::RIDL_140,
                        severity: Severity::Error,
                        message: format!(
                            "duplicate service name `{name}` — the service catalog is a flat global namespace"
                        ),
                        primary: span,
                        labels: vec![Label {
                            span: first,
                            message: format!("`{name}` is first declared here"),
                        }],
                        fixits: Vec::new(),
                    });
                    continue;
                }
                first_span.insert(name.clone(), span);
                entries.insert(
                    name,
                    CatalogEntry {
                        package: package_name.clone(),
                        interface_refs: canonical_interface_refs(&names, &service),
                        inline: service.colon_token().is_none(),
                    },
                );
            }
        }
    }

    ServiceCatalog {
        entries,
        diagnostics,
    }
}

/// The parsed [`SourceFile`] of one input, through the memoized parse query.
fn service_source(db: &dyn salsa::Database, file: InputFile) -> SourceFile {
    let parse = crate::parse_file(db, file);
    SourceFile::cast(parse.syntax()).expect("the parser roots every tree in a SourceFile")
}

/// The canonical interface references a service's list names, in source
/// order, or the empty list for an inline shape (ADR-0015 decision 12). The
/// `:` token discriminates the two forms.
///
/// A multi-segment reference is already package-qualified and is kept as
/// written. A single-segment reference resolves against the **package-wide**
/// binding view ([`package_names`]), in the resolver's precedence: a local
/// declaration anywhere in the package shadows an import, and an import bound
/// in any file of the package canonicalizes the name to its full `pkg.Name`
/// path. An unresolved name stays bare. This is the same bare/qualified split
/// the checker's IR lowering produces, so the catalog and the IR always agree.
fn canonical_interface_refs(names: &PackageNames, service: &ServiceDef) -> Vec<String> {
    if service.colon_token().is_none() {
        return Vec::new();
    }
    service
        .shapes()
        .map(|path| canonical_ref(names, &path))
        .collect()
}

/// One canonical reference — see [`canonical_interface_refs`] for the rule.
fn canonical_ref(names: &PackageNames, path: &PathType) -> String {
    let written = significant_node_text(path.syntax());
    if written.is_empty() || written.contains('.') {
        return written;
    }
    // A local declaration shadows an import (ridl-sem `apply_imports`), so a
    // name declared anywhere in this package stays bare.
    if names.locals.contains(&written) {
        return written;
    }
    if let Some(full) = names.imports.get(&written) {
        return full.clone();
    }
    written
}

/// The package-wide names a reference can bind to, mirroring the resolver's
/// precedence: local declarations (any file) shadow imports (any file).
struct PackageNames {
    locals: HashSet<String>,
    imports: HashMap<String, String>,
}

fn package_names(db: &dyn salsa::Database, package: &Package) -> PackageNames {
    let mut locals = HashSet::new();
    let mut imports: HashMap<String, String> = HashMap::new();
    for file in package.files(db) {
        let source = service_source(db, *file);
        for definition in source.definitions() {
            if let Some(name) = declaration_name(definition.syntax()) {
                locals.insert(name);
            }
        }
        for interface in source.interfaces() {
            if let Some(name) = declaration_name(interface.syntax()) {
                locals.insert(name);
            }
        }
        for import in source.imports() {
            let Some(qualified) = import.qualified_name() else {
                continue;
            };
            let full = significant_node_text(qualified.syntax());
            let base = full.rsplit('.').next().unwrap_or(full.as_str()).to_string();
            let local = import
                .alias()
                .and_then(|alias| alias.ident_token())
                .map(|token| token.text().to_string())
                .unwrap_or(base);
            // Among colliding imports the first wins, as in `apply_imports`.
            imports.entry(local).or_insert(full);
        }
    }
    PackageNames { locals, imports }
}

/// The identifier of a declaration's `Name` child.
fn declaration_name(node: &SyntaxNode) -> Option<String> {
    node.children()
        .find(|child| child.kind() == SyntaxKind::Name)?
        .children_with_tokens()
        .filter_map(|element| element.into_token())
        .find(|token| token.kind() == SyntaxKind::Ident)
        .map(|token| token.text().to_string())
}

/// The concatenation of every non-trivia token in `node`'s subtree — the
/// node's text with whitespace and comments removed (`veh . adas . cruise`
/// reads as `veh.adas.cruise`).
fn significant_node_text(node: &SyntaxNode) -> String {
    node.descendants_with_tokens()
        .filter_map(|element| element.into_token())
        .filter(|token| !token.kind().is_trivia())
        .map(|token| token.text().to_string())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db::RidlDatabase;

    fn file(db: &RidlDatabase, path: &str, text: &str) -> InputFile {
        InputFile::new(db, path.to_string(), text.to_string())
    }

    #[test]
    fn package_of_finds_a_package_by_name() {
        let db = RidlDatabase::default();
        let common = Package::new(
            &db,
            "veh.common".to_string(),
            vec![file(&db, "veh-common/a.typl", "package veh.common")],
            PackageOrigin::WorkspaceMember,
            BTreeMap::new(),
            TimingDefaults::default(),
            None,
        );
        let cluster = Package::new(
            &db,
            "veh.cluster".to_string(),
            vec![file(&db, "veh-cluster/b.typl", "package veh.cluster")],
            PackageOrigin::WorkspaceMember,
            BTreeMap::new(),
            TimingDefaults::default(),
            None,
        );
        let ws = Workspace::new(&db, vec![common, cluster], BTreeMap::new());

        assert_eq!(
            package_of(&db, ws, "veh.cluster".to_string()),
            Some(cluster)
        );
        assert_eq!(package_of(&db, ws, "veh.common".to_string()), Some(common));
        assert_eq!(package_of(&db, ws, "veh.absent".to_string()), None);
    }

    #[test]
    fn package_declarations_reads_names_and_ranges() {
        let parse = ridl_syntax::parse(
            "package veh.common\npackage veh.extra\ntype A: m\n",
            ridl_syntax::Profile::Typl,
        );
        let source = SourceFile::cast(parse.syntax()).expect("root is a SourceFile");
        let decls = package_declarations(&source);
        assert_eq!(decls.len(), 2, "both declarations are read");
        assert_eq!(decls[0].0, "veh.common");
        assert_eq!(decls[0].1, TextRange::new(0.into(), 18.into()));
        assert_eq!(decls[1].0, "veh.extra");
        assert_eq!(decls[1].1, TextRange::new(19.into(), 36.into()));
    }

    // --- the service catalog (ridl reference §14.5) ---------------

    fn ridl_package(db: &RidlDatabase, name: &str, text: &str) -> Package {
        Package::new(
            db,
            name.to_string(),
            vec![file(db, &format!("{}.ridl", name.replace('.', "/")), text)],
            PackageOrigin::WorkspaceMember,
            BTreeMap::new(),
            TimingDefaults::default(),
            None,
        )
    }

    #[test]
    fn service_catalog_flags_a_duplicate_name_across_packages() {
        use crate::std_lib::std_package;
        let mut db = RidlDatabase::default();
        let std = std_package(&mut db);
        // Two packages both declare `veh.adas.cruise` — the flat global
        // namespace forbids it (RIDL-140).
        let adas = ridl_package(
            &db,
            "veh.adas",
            "package veh.adas\nservice veh.adas.cruise : Foo\n",
        );
        let redundant = ridl_package(
            &db,
            "veh.redundant",
            "package veh.redundant\nservice veh.adas.cruise : Bar\n",
        );
        let ws = Workspace::new(&db, vec![adas, redundant], BTreeMap::new());

        let catalog = service_catalog(&db, ws, std);

        assert_eq!(catalog.diagnostics.len(), 1);
        let diagnostic = &catalog.diagnostics[0];
        assert_eq!(diagnostic.code, DiagCode::RIDL_140);
        // Both declarations are labeled: the duplicate as the primary span, the
        // first via one secondary label.
        assert_eq!(diagnostic.labels.len(), 1);
        // The name survives in the catalog once (the first declaration).
        assert!(catalog.entries.contains_key("veh.adas.cruise"));
        assert_eq!(catalog.entries["veh.adas.cruise"].package, "veh.adas");
    }

    #[test]
    fn service_catalog_resolves_a_cross_package_interface_ref_through_imports() {
        use crate::std_lib::std_package;
        let mut db = RidlDatabase::default();
        let std = std_package(&mut db);
        let common = ridl_package(
            &db,
            "veh.common",
            "package veh.common\ninterface CruiseControl {\n  signal engaged : boolean\n}\n",
        );
        let adas = ridl_package(
            &db,
            "veh.adas",
            "package veh.adas\nimport veh.common.CruiseControl\nservice veh.adas.cruise : CruiseControl\n",
        );
        let ws = Workspace::new(&db, vec![common, adas], BTreeMap::new());

        let catalog = service_catalog(&db, ws, std);

        assert!(catalog.diagnostics.is_empty());
        let entry = &catalog.entries["veh.adas.cruise"];
        assert_eq!(entry.package, "veh.adas");
        // The bare `CruiseControl` reference canonicalizes to its full
        // `pkg.Name` path through the declaring file's import.
        assert_eq!(entry.interface_refs, ["veh.common.CruiseControl"]);
    }

    /// A shape list (ADR-0015 decision 12) records every named reference in
    /// source order, each canonicalized on its own.
    #[test]
    fn service_catalog_records_every_shape_of_a_composed_service() {
        use crate::std_lib::std_package;
        let mut db = RidlDatabase::default();
        let std = std_package(&mut db);
        let common = ridl_package(
            &db,
            "veh.common",
            "package veh.common\ninterface DiagBlock {\n  query readFault(code: boolean): boolean\n}\n",
        );
        let body = ridl_package(
            &db,
            "veh.body",
            "package veh.body\nimport veh.common.DiagBlock\n\
             interface DoorControl {\n  signal locked : boolean\n}\n\
             service veh.body.doors : DoorControl, DiagBlock\n",
        );
        let ws = Workspace::new(&db, vec![common, body], BTreeMap::new());

        let catalog = service_catalog(&db, ws, std);

        assert!(catalog.diagnostics.is_empty());
        let entry = &catalog.entries["veh.body.doors"];
        assert_eq!(
            entry.interface_refs,
            ["DoorControl", "veh.common.DiagBlock"],
            "source order, local bare, import canonicalized",
        );
        assert!(!entry.inline);
    }

    #[test]
    fn service_catalog_records_an_inline_shape_with_an_empty_ref() {
        use crate::std_lib::std_package;
        let mut db = RidlDatabase::default();
        let std = std_package(&mut db);
        let hvac = ridl_package(
            &db,
            "veh.hvac",
            "package veh.hvac\nservice veh.hvac.cabin {\n  signal temperature : boolean\n}\n",
        );
        let ws = Workspace::new(&db, vec![hvac], BTreeMap::new());

        let catalog = service_catalog(&db, ws, std);

        assert!(catalog.diagnostics.is_empty());
        let entry = &catalog.entries["veh.hvac.cabin"];
        assert_eq!(entry.package, "veh.hvac");
        assert_eq!(entry.interface_refs, Vec::<String>::new());
        assert!(entry.inline);
    }

    /// A list holding only a tombstone names no interface, as an inline shape
    /// does; `inline` tells the two forms apart.
    #[test]
    fn service_catalog_tells_a_tombstone_only_list_from_an_inline_shape() {
        use crate::std_lib::std_package;
        let mut db = RidlDatabase::default();
        let std = std_package(&mut db);
        let body = ridl_package(
            &db,
            "veh.body",
            "package veh.body\nservice veh.body.legacy : reserved LegacyDoorDiag\n",
        );
        let ws = Workspace::new(&db, vec![body], BTreeMap::new());

        let catalog = service_catalog(&db, ws, std);

        let entry = &catalog.entries["veh.body.legacy"];
        assert_eq!(entry.interface_refs, Vec::<String>::new());
        assert!(!entry.inline);
    }
}