Skip to main content

ridl_core/
workspace.rs

1//! Filesystem discovery: from an entry path to a loaded [`Workspace`]
2//! (ADR-0002 §1, §4–5).
3//!
4//! This is the only module in the crate that touches the filesystem, and it
5//! sits behind the default-on `fs` feature (ADR-0007 decision 5) so the crate
6//! still builds for `wasm32-unknown-unknown` with `--no-default-features`.
7//!
8//! [`load_workspace`] walks from an entry path — a source file, a package
9//! directory, or a workspace root — reads the `ridl.toml` manifests, loads
10//! every source file (`.typl`, `.ridl` and `.rsdl` alike — a package may mix
11//! them) into [`InputFile`] inputs, and enforces the package↔directory law
12//! (typl reference §3.1): every file in a package directory must declare that
13//! directory's package name (TYPL-002), and more than one `package`
14//! declaration in a file is TYPL-001. A `.rxdl` or `.rmdl` file in a package
15//! directory is not loaded: those profiles have no implementation, so each
16//! such file draws the warning RIDL-417 on the file itself. A bare `.typl`,
17//! `.ridl` or `.rsdl` file with no manifest anywhere up the tree loads in
18//! **single-file mode**: one synthetic package named from the file's
19//! declared package, exempt from TYPL-002. A bare `.rxdl` or `.rmdl` file is
20//! an [`io::ErrorKind::InvalidInput`] error that names the extension, because
21//! nothing in it can be compiled. A unit's manifest directory is read for an
22//! `interfaces.lock`, which rides on every [`Package`] of the unit as its
23//! [`PackageLock`]; the bare file's directory is read the same way. A lock in
24//! any other directory is not read (RIDL-416), and a malformed one is
25//! RIDL-410 on the file's own line (lock design §2, §8).
26//!
27//! Problems in loaded content — manifest diagnostics, the law violations, a
28//! nested workspace (MANI-004), a broken member (MANI-008), a file that is
29//! not valid UTF-8 — are accumulated [`Diagnostic`]s, never an error return
30//! (ADR-0004 §5). `std::io::Error` is reserved for real filesystem failures
31//! and for an entry the loader cannot compile at all, the bare `.rxdl` or
32//! `.rmdl` file.
33
34use std::collections::BTreeMap;
35use std::fs;
36use std::io;
37use std::path::{Component, Path, PathBuf};
38
39use ridl_ir::codegen::{header_control_character, normalise_header};
40use ridl_syntax::ast::{AstNode as _, SourceFile};
41use rowan::{TextRange, TextSize};
42
43use crate::db::{InputFile, RidlDatabase, parse_file};
44use crate::diag::{DiagCode, Diagnostic, FileId, Severity, SourceMap, Span};
45use crate::interface_lock;
46use crate::lint::{LintLevels, LintScopes};
47use crate::manifest::{Manifest, ManifestKind, TimingDefaults, parse_manifest};
48use crate::package::{Package, PackageLock, PackageOrigin, Workspace, package_declarations};
49
50/// The result of [`load_workspace`]: the salsa [`Workspace`] input, the
51/// diagnostics the load accumulated, the interned path+text table the
52/// diagnostics' [`Span`]s point into (what the caller hands to
53/// [`render`](crate::diag::render())), and the effective lint levels by
54/// directory (ADR-0024 decision 10): one scope for the workspace root,
55/// one per member, one for a standalone package, none in single-file mode.
56/// The scope keys are the directories in the same path form as the file
57/// paths in `sources`, so [`LintScopes::for_path`] resolves a recorded path.
58pub struct LoadedWorkspace {
59    pub workspace: Workspace,
60    pub diagnostics: Vec<Diagnostic>,
61    pub sources: SourceMap,
62    pub lints: LintScopes,
63    /// The text of the file named by the root manifest's `[codegen]
64    /// header-file`, normalised by [`ridl_ir::codegen::normalise_header`];
65    /// `None` when no file is named, when the file holds no text, and in
66    /// single-file mode.
67    pub codegen_header: Option<String>,
68    /// The member directory the entry lies in, when the entry is inside a
69    /// member of the loaded workspace ([`find_root`] walked from the member to
70    /// its workspace, or the entry named a path below a member); `None` for
71    /// an entry at the workspace root, a standalone package, or single-file
72    /// mode. The whole workspace is loaded and checked either way; a command
73    /// that reports diagnostics reports only those under this directory
74    /// (ADR-0024 decision 9). The path is in the same form as the file paths
75    /// in `sources`.
76    pub report_scope: Option<PathBuf>,
77    /// The loaded units: each `[package]` manifest's `name` mapped to its
78    /// directory, in the path form of the file paths in `sources`. A
79    /// workspace root is not a unit; its members are. In single-file mode the
80    /// one unit is the file's package, mapped to the file's directory.
81    pub units: BTreeMap<String, PathBuf>,
82}
83
84/// Unsaved source text for a file in a loaded package directory.
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub struct Overlay {
87    pub path: PathBuf,
88    pub text: String,
89}
90
91/// A filesystem failure or an overlay that cannot belong to the workspace.
92///
93/// `Io` also carries the refusal of a lone `.rxdl` or `.rmdl` entry in
94/// single-file mode: an [`io::ErrorKind::InvalidInput`] error that names the
95/// extension (see [`load_workspace`]).
96#[derive(Debug)]
97pub enum LoadError {
98    Io(io::Error),
99    OverlayNotSource(PathBuf),
100    OverlayOutsideWorkspace {
101        path: PathBuf,
102        missing_directory: bool,
103    },
104}
105
106impl std::fmt::Display for LoadError {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            Self::Io(e) => write!(f, "{e}"),
110            Self::OverlayNotSource(p) => write!(
111                f,
112                "overlay `{}` is not a `.typl`, `.ridl`, `.rsdl`, `.rxdl` or `.rmdl` file",
113                p.display()
114            ),
115            Self::OverlayOutsideWorkspace {
116                path,
117                missing_directory: true,
118            } => write!(
119                f,
120                "overlay `{}` is in a directory that does not exist; create the directory first",
121                path.display()
122            ),
123            Self::OverlayOutsideWorkspace {
124                path,
125                missing_directory: false,
126            } => write!(
127                f,
128                "overlay `{}` is not in a package directory of the workspace loaded from this path",
129                path.display()
130            ),
131        }
132    }
133}
134
135impl std::error::Error for LoadError {
136    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
137        match self {
138            Self::Io(e) => Some(e),
139            _ => None,
140        }
141    }
142}
143
144impl From<io::Error> for LoadError {
145    fn from(e: io::Error) -> Self {
146        Self::Io(e)
147    }
148}
149
150/// The extension of a file the loader recognises but does not compile,
151/// `rxdl` or `rmdl`: those profiles have no implementation. `None` for any
152/// other path.
153fn unsupported_extension(path: &Path) -> Option<&str> {
154    path.extension()
155        .and_then(|ext| ext.to_str())
156        .filter(|ext| *ext == "rxdl" || *ext == "rmdl")
157}
158
159/// The comparison key for a path: its parent directory canonicalised, joined
160/// with its file name. `None` when the parent directory does not exist.
161fn overlay_key(path: &Path) -> Option<PathBuf> {
162    let absolute = if path.is_absolute() {
163        path.to_path_buf()
164    } else {
165        std::env::current_dir().ok()?.join(path)
166    };
167    Some(
168        absolute
169            .parent()?
170            .canonicalize()
171            .ok()?
172            .join(absolute.file_name()?),
173    )
174}
175
176/// Loads the workspace reachable from `entry` into `db`.
177///
178/// `entry` may be:
179///
180/// - a `.typl`, `.ridl` or `.rsdl` file — [`find_root`] from the file's
181///   directory is the root; with no manifest anywhere up the tree the file
182///   loads in single-file mode. A `.rxdl` or `.rmdl` entry in single-file
183///   mode is an [`io::ErrorKind::InvalidInput`] error that names the
184///   extension; inside a package directory it draws RIDL-417, as every
185///   such file in the directory does;
186/// - a package directory or workspace root — [`find_root`] from the
187///   directory is the root; a `[package]` manifest loads that package's
188///   directory tree, a `[workspace]` manifest loads every member.
189///
190/// An entry inside a workspace member loads the whole workspace, so the
191/// root's `[lints]`, `[defaults]` and `[imports]` apply to the member
192/// and its imports of sibling members resolve;
193/// [`LoadedWorkspace::report_scope`] records the member.
194///
195/// `[imports]` maps stay scoped per ADR-0002 §5: each [`Package`] carries the
196/// `[imports]` of the manifest governing its directory tree (step 2), and
197/// [`Workspace::imports`] holds only the workspace root's `[imports]` (step
198/// 3, the shared default). Nothing is merged — a member's pin never leaks to
199/// a sibling member; the task 9 resolver walks the order itself. In a
200/// standalone package load the manifest's `[imports]` ride on its packages
201/// and [`Workspace::imports`] is empty.
202pub fn load_workspace(db: &mut RidlDatabase, entry: &Path) -> io::Result<LoadedWorkspace> {
203    load_workspace_with(db, entry, &[]).map_err(|error| match error {
204        LoadError::Io(e) => e,
205        _ => unreachable!("no overlay error is possible without overlays"),
206    })
207}
208
209/// Loads a workspace with unsaved source text substituted before parsing.
210pub fn load_workspace_with(
211    db: &mut RidlDatabase,
212    entry: &Path,
213    overlays: &[Overlay],
214) -> Result<LoadedWorkspace, LoadError> {
215    let mut loader = Loader::default();
216    for overlay in overlays {
217        // A `.rxdl` or `.rmdl` overlay is accepted so that the loader reports
218        // it as RIDL-417, as it reports such a file on disk; it is not compiled.
219        if unsupported_extension(&overlay.path).is_none()
220            && !overlay
221                .path
222                .extension()
223                .is_some_and(|ext| ext == "typl" || ext == "ridl" || ext == "rsdl")
224        {
225            return Err(LoadError::OverlayNotSource(overlay.path.clone()));
226        }
227        let key = overlay_key(&overlay.path).ok_or_else(|| LoadError::OverlayOutsideWorkspace {
228            path: overlay.path.clone(),
229            missing_directory: true,
230        })?;
231        loader.overlays.push((key, overlay.clone(), false));
232    }
233
234    if entry.is_file() {
235        match entry.parent().and_then(find_root) {
236            Some(root) => loader.load_root(db, &root)?,
237            None => loader.load_single_file(db, entry)?,
238        }
239    } else if entry.is_dir() {
240        match find_root(entry) {
241            Some(root) => loader.load_root(db, &root)?,
242            None => {
243                if let Some(error) = unreadable_manifest(entry) {
244                    return Err(error.into());
245                }
246                return Err(io::Error::new(
247                    io::ErrorKind::NotFound,
248                    format!("no `ridl.toml` found at or above `{}`", entry.display()),
249                )
250                .into());
251            }
252        }
253    } else {
254        return Err(io::Error::new(
255            io::ErrorKind::NotFound,
256            format!("`{}` does not exist", entry.display()),
257        )
258        .into());
259    }
260
261    if let Some((_, overlay, _)) = loader.overlays.iter().find(|(_, _, consumed)| !consumed) {
262        return Err(LoadError::OverlayOutsideWorkspace {
263            path: overlay.path.clone(),
264            missing_directory: false,
265        });
266    }
267    let report_scope = absolute(entry).and_then(|entry| {
268        loader
269            .member_dirs
270            .iter()
271            .find(|member| absolute(member).is_some_and(|member| entry.starts_with(member)))
272            .cloned()
273    });
274    let workspace = Workspace::new(&*db, loader.packages, loader.workspace_imports);
275    Ok(LoadedWorkspace {
276        workspace,
277        diagnostics: loader.diagnostics,
278        sources: loader.sources,
279        lints: loader.lints,
280        codegen_header: loader.codegen_header,
281        report_scope,
282        units: loader.units,
283    })
284}
285
286/// The directory [`load_workspace`] loads from for an entry at or below
287/// `dir` (ADR-0002 §4). It starts at the nearest directory at or above `dir`
288/// that contains a `ridl.toml`. When that manifest is a `[package]`, the walk
289/// continues upward:
290///
291/// - at the first `[workspace]` manifest it stops; that workspace is the root
292///   when its `members` names the package directory, and the package is the
293///   root otherwise;
294/// - a `[package]` manifest above does not stop the walk;
295/// - a manifest that cannot be read or parsed stops the walk and is the root,
296///   so the loader reports why it failed;
297/// - a directory that holds `.git` stops the walk after its own `ridl.toml`
298///   is checked, and so does the filesystem root; the package is then the
299///   root.
300///
301/// A relative `dir` gives a root in the same relative form, built with `..`
302/// when the root is above the current directory. `None` means there is no
303/// `ridl.toml` at or above `dir`. The command line, the language server and
304/// the MCP server all call this, so every entry point loads the same root.
305pub fn find_root(dir: &Path) -> Option<PathBuf> {
306    let package = dir
307        .ancestors()
308        .find(|candidate| candidate.join("ridl.toml").is_file())?
309        .to_path_buf();
310    if !matches!(
311        read_manifest_kind(&package),
312        Some(ManifestKind::Package { .. })
313    ) {
314        return Some(package);
315    }
316    let Some(absolute_package) = absolute(&package) else {
317        return Some(package);
318    };
319    for (levels, parent) in absolute_package.ancestors().skip(1).enumerate() {
320        if parent.join("ridl.toml").is_file() {
321            match read_manifest_kind(parent) {
322                None => return Some(up(&package, levels + 1)),
323                Some(ManifestKind::Workspace { members }) => {
324                    let listed = members
325                        .iter()
326                        .any(|member| normalize(&parent.join(member)) == absolute_package);
327                    return Some(if listed {
328                        up(&package, levels + 1)
329                    } else {
330                        package
331                    });
332                }
333                Some(ManifestKind::Package { .. }) => {}
334            }
335        }
336        if parent.join(".git").exists() {
337            break;
338        }
339    }
340    Some(package)
341}
342
343/// The error of the first `ridl.toml` at or above `dir` that cannot be
344/// inspected for a reason other than being absent, naming the manifest.
345/// `find_root` treats such a manifest as missing, so the caller uses this to
346/// report the real cause. The walk up the ancestors matters for the errors that
347/// can hit an ancestor's manifest while the entry itself is reachable, such as
348/// a symbolic link loop; a directory that cannot be searched also hides the
349/// entry below it.
350fn unreadable_manifest(dir: &Path) -> Option<io::Error> {
351    dir.ancestors().find_map(|candidate| {
352        let manifest = candidate.join("ridl.toml");
353        match fs::metadata(&manifest) {
354            Err(e) if e.kind() != io::ErrorKind::NotFound => Some(io::Error::new(
355                e.kind(),
356                format!("cannot read `{}`: {e}", manifest.display()),
357            )),
358            _ => None,
359        }
360    })
361}
362
363/// `error` with the path that failed added to its message.
364fn cannot_read(path: &Path, error: io::Error) -> io::Error {
365    io::Error::new(
366        error.kind(),
367        format!("cannot read `{}`: {error}", path.display()),
368    )
369}
370
371/// The manifest kind of `dir/ridl.toml`, or `None` when the file cannot be
372/// read as UTF-8 or does not parse as a manifest.
373fn read_manifest_kind(dir: &Path) -> Option<ManifestKind> {
374    let text = fs::read_to_string(dir.join("ridl.toml")).ok()?;
375    parse_manifest(FileId::DETACHED, &text)
376        .0
377        .map(|manifest| manifest.kind)
378}
379
380/// `path` made absolute against the current directory and normalised
381/// lexically; `None` when the current directory cannot be read.
382fn absolute(path: &Path) -> Option<PathBuf> {
383    if path.is_absolute() {
384        Some(normalize(path))
385    } else {
386        Some(normalize(&std::env::current_dir().ok()?.join(path)))
387    }
388}
389
390/// `path` with every `.` removed and every `..` applied to the component
391/// before it, without reading the filesystem. A trailing `/` is dropped.
392fn normalize(path: &Path) -> PathBuf {
393    let mut normalized = PathBuf::new();
394    for component in path.components() {
395        match component {
396            Component::CurDir => {}
397            Component::ParentDir => {
398                if matches!(
399                    normalized.components().next_back(),
400                    Some(Component::Normal(_))
401                ) {
402                    normalized.pop();
403                } else if !normalized.has_root() {
404                    normalized.push("..");
405                }
406            }
407            other => normalized.push(other),
408        }
409    }
410    normalized
411}
412
413/// The directory `levels` levels above `path`, in the path form of `path`:
414/// a trailing name is removed, and `..` is added once no name is left to
415/// remove. Removing the last name of a relative path yields `.`, not the
416/// empty path [`Path::ancestors`] yields: the empty path joins like the
417/// current directory, but `read_dir`, `is_dir` and `exists` fail on it, so
418/// a caller that walks the root's files would read nothing.
419fn up(path: &Path, levels: usize) -> PathBuf {
420    let mut result = path.to_path_buf();
421    for _ in 0..levels {
422        match result.components().next_back() {
423            Some(Component::Normal(_)) => {
424                result.pop();
425                if result.as_os_str().is_empty() {
426                    result = PathBuf::from(".");
427                }
428            }
429            Some(Component::RootDir | Component::Prefix(_)) => {}
430            Some(Component::CurDir) | None => result = PathBuf::from(".."),
431            Some(Component::ParentDir) => result.push(".."),
432        }
433    }
434    result
435}
436
437/// One loaded file plus its `package` declarations (dotted name, source
438/// range), as [`Loader::load_file`] returns them.
439type LoadedFile = (InputFile, Vec<(String, TextRange)>);
440
441/// The scope of one unit that every package in its directory tree shares.
442#[derive(Clone, Copy)]
443struct TreeScope<'a> {
444    /// The directory that holds the unit's manifest.
445    unit_dir: &'a Path,
446    /// The manifest's `name`.
447    unit: &'a str,
448    /// The governing manifest's `[imports]`.
449    imports: &'a BTreeMap<String, String>,
450    /// The timing defaults that apply to the unit's packages.
451    defaults: &'a TimingDefaults,
452    /// The unit's `interfaces.lock`, read from the manifest directory.
453    lock: &'a Option<PackageLock>,
454}
455
456/// The accumulating state of one [`load_workspace`] run.
457#[derive(Default)]
458struct Loader {
459    /// Source package name to the unit that claimed it and that unit's
460    /// manifest directory.
461    claims: BTreeMap<String, (String, PathBuf)>,
462    overlays: Vec<(PathBuf, Overlay, bool)>,
463    sources: SourceMap,
464    diagnostics: Vec<Diagnostic>,
465    packages: Vec<Package>,
466    /// The workspace root's own `[imports]` (ADR-0002 §5 step 3). Stays empty
467    /// in a standalone package load and in single-file mode.
468    workspace_imports: BTreeMap<String, String>,
469    /// The workspace root's `[defaults]` (ridl §9.1). A member's own
470    /// `[defaults]` shadows it per key; a key the member leaves unset rides on
471    /// the member's packages. Stays empty in a standalone package load and in
472    /// single-file mode.
473    workspace_defaults: TimingDefaults,
474    /// The workspace root's effective lint levels: the registry defaults
475    /// overlaid with the root `[lints]` (ADR-0002 §4).
476    /// Each member's own table is overlaid on a clone. Stays at the defaults
477    /// in a standalone package load and in single-file mode.
478    workspace_lints: LintLevels,
479    /// The effective lint levels by directory: one scope for the root, one per
480    /// member directory, one for a standalone package, none in single-file mode
481    /// (ADR-0024 decision 10). Each key is the directory in the path form the
482    /// loader records for the files under it.
483    lints: LintScopes,
484    /// The workspace root's (or standalone package's) normalised header text.
485    codegen_header: Option<String>,
486    /// Every member directory of a loaded workspace, in the path form of the
487    /// files recorded under it. Empty outside workspace mode.
488    member_dirs: Vec<PathBuf>,
489    /// Unit name to manifest directory, for [`LoadedWorkspace::units`].
490    units: BTreeMap<String, PathBuf>,
491    /// Unit name to its manifest's file id and the range of the manifest's
492    /// quoted `[package] name`, where a MANI-014 against the unit points.
493    unit_name_spans: BTreeMap<String, (FileId, TextRange)>,
494}
495
496impl Loader {
497    /// Loads from a directory known to contain a `ridl.toml`, in whichever
498    /// mode its manifest declares.
499    fn load_root(&mut self, db: &mut RidlDatabase, root: &Path) -> io::Result<()> {
500        let manifest_path = root.join("ridl.toml");
501        // The error names the manifest: the root may be a workspace above the
502        // entry (`find_root`), so the reader cannot assume which file failed.
503        let text = fs::read_to_string(&manifest_path).map_err(|e| {
504            io::Error::new(
505                e.kind(),
506                format!("cannot read `{}`: {e}", manifest_path.display()),
507            )
508        })?;
509        let file_id = self.sources.file_id(&path_string(&manifest_path), &text);
510        let (manifest, diags) = parse_manifest(file_id, &text);
511        self.diagnostics.extend(diags);
512        let Some(Manifest {
513            kind,
514            imports,
515            defaults,
516            lints,
517            codegen_header_file,
518        }) = manifest
519        else {
520            return Ok(());
521        };
522        if let Some((relative, range)) = codegen_header_file {
523            let path = root.join(&relative);
524            match fs::read_to_string(&path) {
525                Ok(header) => match header_control_character(&header) {
526                    None => self.codegen_header = normalise_header(&header),
527                    Some(c) => self.diagnostics.push(error(
528                        DiagCode::MANI_011,
529                        file_id,
530                        byte_range(range.start, range.end),
531                        format!(
532                            "`[codegen] header-file` contains a control character \
533                             (U+{:04X}): `{}`",
534                            u32::from(c),
535                            path.display()
536                        ),
537                    )),
538                },
539                Err(e) => self.diagnostics.push(error(
540                    DiagCode::MANI_011,
541                    file_id,
542                    byte_range(range.start, range.end),
543                    format!(
544                        "`[codegen] header-file` cannot be read: `{}`: {e}",
545                        path.display()
546                    ),
547                )),
548            }
549        }
550        // The root directory's scope: the registry defaults overlaid with the
551        // root `[lints]`. In workspace mode it is also the base every member
552        // overlays its own table on (ADR-0002 §4).
553        let mut root_lints = LintLevels::default();
554        root_lints.overlay(&lints);
555        self.lints.insert(root.to_path_buf(), root_lints.clone());
556        match kind {
557            ManifestKind::Package {
558                name, name_span, ..
559            } => {
560                // A standalone package: the manifest's `[imports]` and
561                // `[defaults]` ride on its packages; the workspace maps
562                // stay empty.
563                self.units.insert(name.clone(), root.to_path_buf());
564                self.unit_name_spans.insert(
565                    name.clone(),
566                    (file_id, byte_range(name_span.start, name_span.end)),
567                );
568                let lock = self.read_lock(root)?;
569                let scope = TreeScope {
570                    unit_dir: root,
571                    unit: &name,
572                    imports: &imports,
573                    defaults: &defaults,
574                    lock: &lock,
575                };
576                self.load_package_tree(db, root, &name, &scope)?;
577            }
578            ManifestKind::Workspace { members } => {
579                // ADR-0002 §5 step 3: the workspace root's `[imports]` and
580                // `[defaults]` are the shared defaults. Member maps are
581                // never merged into them.
582                self.workspace_imports = imports;
583                self.workspace_defaults = defaults;
584                self.workspace_lints = root_lints;
585                for member in &members {
586                    // A member listed twice in `[workspace] members` is
587                    // loaded once: its later listings are skipped before
588                    // its manifest is read, with no diagnostic. Two
589                    // spellings of one directory (`a`, `b/../a`) are one
590                    // listing: the paths are compared lexically normalized.
591                    let member_dir = root.join(member);
592                    let normalized = normalize(&member_dir);
593                    if self
594                        .member_dirs
595                        .iter()
596                        .any(|dir| normalize(dir) == normalized)
597                    {
598                        continue;
599                    }
600                    self.member_dirs.push(member_dir);
601                    self.load_member(db, root, member, file_id, &text)?;
602                }
603            }
604        }
605        Ok(())
606    }
607
608    /// Loads one workspace member directory: its manifest, then its package
609    /// tree. A member manifest that declares `[workspace]` is a nested
610    /// workspace — MANI-004 — and loads nothing.
611    fn load_member(
612        &mut self,
613        db: &mut RidlDatabase,
614        workspace_root: &Path,
615        member: &str,
616        workspace_file: FileId,
617        workspace_text: &str,
618    ) -> io::Result<()> {
619        let manifest_path = workspace_root.join(member).join("ridl.toml");
620        if !manifest_path.is_file() {
621            // T7 records member paths unvalidated; the loader validates them
622            // against the filesystem (MANI-008).
623            self.diagnostics.push(error(
624                DiagCode::MANI_008,
625                workspace_file,
626                member_entry_range(workspace_text, member),
627                format!("workspace member `{member}` has no `ridl.toml`"),
628            ));
629            return Ok(());
630        }
631        let text =
632            fs::read_to_string(&manifest_path).map_err(|e| cannot_read(&manifest_path, e))?;
633        let file_id = self.sources.file_id(&path_string(&manifest_path), &text);
634        let (manifest, diags) = parse_manifest(file_id, &text);
635        self.diagnostics.extend(diags);
636        let Some(Manifest {
637            kind,
638            imports,
639            defaults,
640            lints,
641            codegen_header_file,
642        }) = manifest
643        else {
644            return Ok(());
645        };
646        if let Some((_, range)) = codegen_header_file {
647            self.diagnostics.push(error(
648                DiagCode::MANI_012,
649                file_id,
650                byte_range(range.start, range.end),
651                format!(
652                    "`[codegen] header-file` is set in workspace member `{member}`; set it in the workspace root's `ridl.toml`"
653                ),
654            ));
655        }
656        // The member directory's scope: the root levels overlaid with the
657        // member's own `[lints]` (ADR-0002 §4). The key
658        // is the member directory in the same path form as the file paths
659        // recorded under it, so `for_path` finds them by prefix.
660        let mut member_lints = self.workspace_lints.clone();
661        member_lints.overlay(&lints);
662        self.lints.insert(workspace_root.join(member), member_lints);
663        match kind {
664            ManifestKind::Workspace { .. } => {
665                self.diagnostics.push(error(
666                    DiagCode::MANI_004,
667                    file_id,
668                    workspace_section_range(&text),
669                    format!(
670                        "workspace member `{member}` declares `[workspace]`; nested workspaces are forbidden"
671                    ),
672                ));
673            }
674            ManifestKind::Package {
675                name, name_span, ..
676            } => {
677                // ADR-0002 §5 step 2: the member's `[imports]` ride on the
678                // member's packages only — never merged into the workspace
679                // map, never visible to a sibling member. Its
680                // `[defaults]` shadow the workspace defaults per key (ridl §9.1);
681                // a key the member leaves unset takes the workspace value.
682                let member_defaults = defaults.or(&self.workspace_defaults);
683                let member_dir = workspace_root.join(member);
684                // Two members with one `[package] name` are two units that
685                // claim the same source package: MANI-014 on the second
686                // manifest in load order, whose tree is not loaded, so the
687                // first unit keeps its directory in `units`. A member listed
688                // twice never reaches this point twice: the `members` loop
689                // skips its later listings, so `first_dir` is always another
690                // directory.
691                let name_range = byte_range(name_span.start, name_span.end);
692                if let Some(first_dir) = self.units.get(&name) {
693                    let first_dir = first_dir.clone();
694                    self.diagnostics.push(error(
695                        DiagCode::MANI_014,
696                        file_id,
697                        name_range,
698                        format!(
699                            "source package `{name}` is already declared by the unit in `{}`; the unit in `{}` declares it too. A source package belongs to one unit",
700                            first_dir.display(),
701                            member_dir.display()
702                        ),
703                    ));
704                    return Ok(());
705                }
706                self.units.insert(name.clone(), member_dir.clone());
707                self.unit_name_spans
708                    .insert(name.clone(), (file_id, name_range));
709                let lock = self.read_lock(&member_dir)?;
710                let scope = TreeScope {
711                    unit_dir: &member_dir,
712                    unit: &name,
713                    imports: &imports,
714                    defaults: &member_defaults,
715                    lock: &lock,
716                };
717                self.load_package_tree(db, &member_dir, &name, &scope)?;
718            }
719        }
720        Ok(())
721    }
722
723    /// Loads the package rooted at `dir` under the package name `name` and the
724    /// unit name `unit` (the manifest's `name`, whose directory is
725    /// `unit_dir`), then every subdirectory as its own package named by its
726    /// path — the package↔directory law's "the name mirrors the directory
727    /// path relative to the manifest root" (ADR-0002 §1). `unit`, `unit_dir`,
728    /// `imports`, `defaults` and `lock` come from `scope`, which is the same
729    /// for every package of the tree. Every package in the tree carries
730    /// `imports`, the governing manifest's `[imports]`.
731    /// Directories are visited in name order; hidden directories and
732    /// symlinked directories (following them could revisit the tree in a
733    /// cycle) are skipped, and a directory with its own `ridl.toml` is
734    /// MANI-013 and is not entered. A source package that another unit's
735    /// directory already claims is MANI-014 and is not loaded. Every package
736    /// of the tree carries `lock`, the unit's `interfaces.lock` read from the
737    /// manifest directory; a lock in any other directory is not read and is
738    /// RIDL-416. A `.rxdl` or `.rmdl` file, on disk or as an overlay, is not
739    /// compiled and is RIDL-417, once per file, in path order. A member
740    /// listed twice in `[workspace] members` reaches this function once: the
741    /// `members` loop skips its later listings. Two
742    /// directories of one unit that give one package name (`a.b/` and
743    /// `a/b/`) are not MANI-014: both are loaded.
744    fn load_package_tree(
745        &mut self,
746        db: &mut RidlDatabase,
747        dir: &Path,
748        name: &str,
749        scope: &TreeScope<'_>,
750    ) -> io::Result<()> {
751        let TreeScope {
752            unit_dir,
753            unit,
754            imports,
755            defaults,
756            lock,
757        } = *scope;
758        let mut source_files = Vec::new();
759        let mut unsupported_files = Vec::new();
760        let mut subdirs = Vec::new();
761        let named = |e: io::Error| {
762            io::Error::new(e.kind(), format!("cannot read `{}`: {e}", dir.display()))
763        };
764        for entry in fs::read_dir(dir).map_err(named)? {
765            let entry = entry.map_err(named)?;
766            let path = entry.path();
767            let is_symlink = entry.file_type().map_err(named)?.is_symlink();
768            if path.is_dir() {
769                if !is_symlink {
770                    subdirs.push(path);
771                }
772            } else if path
773                .extension()
774                .is_some_and(|ext| ext == "typl" || ext == "ridl" || ext == "rsdl")
775            {
776                source_files.push(path);
777            } else if unsupported_extension(&path).is_some() {
778                unsupported_files.push(path);
779            }
780        }
781        if !self.overlays.is_empty() {
782            let directory_key = dir.canonicalize()?;
783            for (key, _, _) in &self.overlays {
784                let files = if unsupported_extension(key).is_some() {
785                    &mut unsupported_files
786                } else {
787                    &mut source_files
788                };
789                if key.parent() == Some(directory_key.as_path())
790                    && !files.iter().any(|p| overlay_key(p).as_ref() == Some(key))
791                {
792                    let added = dir.join(key.file_name().expect("overlay keys have a file name"));
793                    if !files.contains(&added) {
794                        files.push(added);
795                    }
796                }
797            }
798        }
799        source_files.sort();
800        unsupported_files.sort();
801        for path in &unsupported_files {
802            self.report_unsupported_file(path);
803        }
804        subdirs.sort();
805
806        let mut claimed_elsewhere = false;
807        if !source_files.is_empty() {
808            match self.claims.get(name) {
809                // The directory comparison keeps a same-unit collision out of
810                // MANI-014: two directories of one unit can give one package
811                // name (`a.b/` and `a/b/`), and both are loaded, as today.
812                Some((first, first_dir)) if normalize(first_dir) != normalize(unit_dir) => {
813                    claimed_elsewhere = true;
814                    // Every unit whose tree is loaded has its entry,
815                    // inserted beside its entry in `units`.
816                    let (file, name_range) = self.unit_name_spans[unit];
817                    self.diagnostics.push(error(
818                        DiagCode::MANI_014,
819                        file,
820                        name_range,
821                        format!(
822                            "source package `{name}` is already declared by unit `{first}` (`{}`); unit `{unit}` declares it too, in `{}`. A source package belongs to one unit",
823                            first_dir.display(),
824                            unit_dir.display()
825                        ),
826                    ));
827                }
828                _ => {
829                    self.claims
830                        .insert(name.to_string(), (unit.to_string(), unit_dir.to_path_buf()));
831                }
832            }
833        }
834        if !source_files.is_empty() && !claimed_elsewhere {
835            let mut files = Vec::new();
836            for path in &source_files {
837                if let Some((input, _)) = self.load_file(db, path, Some(name))? {
838                    files.push(input);
839                }
840            }
841            self.packages.push(Package::new(
842                &*db,
843                name.to_string(),
844                unit.to_string(),
845                files,
846                PackageOrigin::WorkspaceMember,
847                imports.clone(),
848                defaults.clone(),
849                lock.clone(),
850            ));
851        }
852
853        for subdir in subdirs {
854            let Some(dir_name) = subdir.file_name().map(|n| n.to_string_lossy().into_owned())
855            else {
856                continue;
857            };
858            if dir_name.starts_with('.') {
859                continue;
860            }
861            let nested_manifest = subdir.join("ridl.toml");
862            if nested_manifest.is_file() {
863                let nested_text = fs::read_to_string(&nested_manifest)
864                    .map_err(|e| cannot_read(&nested_manifest, e))?;
865                let nested_id = self
866                    .sources
867                    .file_id(&path_string(&nested_manifest), &nested_text);
868                self.diagnostics.push(error(
869                    DiagCode::MANI_013,
870                    nested_id,
871                    byte_range(0, 0),
872                    format!(
873                        "`{}` is a `ridl.toml` inside the tree of unit `{unit}`; a unit holds one manifest. Move the directory beside the unit, or delete the manifest",
874                        nested_manifest.display()
875                    ),
876                ));
877                continue;
878            }
879            let ignored = subdir.join(interface_lock::FILE_NAME);
880            if ignored.is_file() {
881                let ignored_text = fs::read_to_string(&ignored).unwrap_or_default();
882                let ignored_id = self.sources.file_id(&path_string(&ignored), &ignored_text);
883                self.diagnostics.push(warning(
884                    DiagCode::RIDL_416,
885                    ignored_id,
886                    byte_range(0, 0),
887                    format!(
888                        "`{}` is an `interfaces.lock` inside the tree of unit `{unit}`; only the `interfaces.lock` beside the unit's `ridl.toml` is read, so this file is ignored",
889                        ignored.display()
890                    ),
891                ));
892            }
893            self.load_package_tree(db, &subdir, &format!("{name}.{dir_name}"), scope)?;
894        }
895        Ok(())
896    }
897
898    /// Loads one bare source file as a synthetic package named from its
899    /// declared package — single-file mode, exempt from TYPL-002 (TYPL-001
900    /// still applies). With no usable declaration the file stem names the
901    /// package; the parser's FORM-104 for the missing declaration lives on
902    /// `parse_file(..).errors()`, like every parse error — loader diagnostics
903    /// carry only the manifest and law findings. A bare `.rxdl` or `.rmdl`
904    /// file is an [`io::ErrorKind::InvalidInput`] error that names the
905    /// extension, whether or not an overlay replaces its text: nothing in it
906    /// can be compiled, and a warning would let a command report success over
907    /// an empty build.
908    fn load_single_file(&mut self, db: &mut RidlDatabase, path: &Path) -> io::Result<()> {
909        if let Some(extension) = unsupported_extension(path) {
910            return Err(io::Error::new(
911                io::ErrorKind::InvalidInput,
912                format!(
913                    "`{}` is not compiled: `.{extension}` is not a supported source file extension (the {extension} profile has no implementation)",
914                    path.display()
915                ),
916            ));
917        }
918        let Some((input, decls)) = self.load_file(db, path, None)? else {
919            // A non-UTF8 file: the diagnostic is recorded, nothing loads.
920            return Ok(());
921        };
922        let name = decls
923            .first()
924            .map(|(name, _)| name.clone())
925            .filter(|name| !name.is_empty())
926            .unwrap_or_else(|| {
927                path.file_stem()
928                    .map(|stem| stem.to_string_lossy().into_owned())
929                    .unwrap_or_else(|| "package".to_string())
930            });
931        // The file's directory is the package directory, so the lock is read
932        // there too (plan decision PD-8).
933        let lock = match path.parent() {
934            Some(dir) => self.read_lock(dir)?,
935            None => None,
936        };
937        self.packages.push(Package::new(
938            &*db,
939            name.clone(),
940            name.clone(),
941            vec![input],
942            PackageOrigin::WorkspaceMember,
943            BTreeMap::new(),
944            TimingDefaults::default(),
945            lock,
946        ));
947        if let Some(dir) = path.parent() {
948            self.units.insert(name, dir.to_path_buf());
949        }
950        Ok(())
951    }
952
953    /// Reports the `.rxdl` or `.rmdl` file at `path` as RIDL-417 on the file
954    /// itself, at the empty range 0..0, consuming any overlay keyed to it.
955    /// The file is not compiled. The span is interned with the overlay's text
956    /// when an overlay replaces the file, and with no text otherwise: an
957    /// empty range needs none, so the file on disk is not read.
958    fn report_unsupported_file(&mut self, path: &Path) {
959        let text = self.take_overlay(path).unwrap_or_default();
960        let file_id = self.sources.file_id(&path_string(path), &text);
961        let extension = unsupported_extension(path).expect("only unsupported files are reported");
962        self.diagnostics.push(warning(
963            DiagCode::RIDL_417,
964            file_id,
965            byte_range(0, 0),
966            format!(
967                "`{}` is a `.{extension}` file; the {extension} profile is not supported yet, so this file is not compiled",
968                path.display()
969            ),
970        ));
971    }
972
973    /// Reads `dir/interfaces.lock` for the unit whose manifest directory is
974    /// `dir`, or for the directory of a bare source file (lock design §2). An
975    /// absent file is `None`. A malformed file — one that is
976    /// not valid UTF-8 included — is RIDL-410 on the offending line of the
977    /// lock file itself, through this loader's source map, at the empty range
978    /// 0..0 when there is no line to point at (plan decision PD-3); the
979    /// package then carries no lock. Any other I/O failure is the error.
980    fn read_lock(&mut self, dir: &Path) -> io::Result<Option<PackageLock>> {
981        let path = path_string(&dir.join(interface_lock::FILE_NAME));
982        let text = match interface_lock::read(dir) {
983            Ok(Some(text)) => text,
984            Ok(None) => return Ok(None),
985            Err(err) if err.kind() == io::ErrorKind::InvalidData => {
986                let file_id = self.sources.file_id(&path, "");
987                self.diagnostics.push(error(
988                    DiagCode::RIDL_410,
989                    file_id,
990                    byte_range(0, 0),
991                    malformed_lock_message("the file is not valid UTF-8"),
992                ));
993                return Ok(None);
994            }
995            Err(err) => return Err(err),
996        };
997        match interface_lock::parse(&text) {
998            Ok(lock) => Ok(Some(PackageLock { path, text, lock })),
999            Err(malformed) => {
1000                let file_id = self.sources.file_id(&path, &text);
1001                self.diagnostics.push(error(
1002                    DiagCode::RIDL_410,
1003                    file_id,
1004                    malformed.range,
1005                    malformed_lock_message(&malformed.message),
1006                ));
1007                Ok(None)
1008            }
1009        }
1010    }
1011
1012    /// The text of the last overlay keyed to `path`, marking every overlay
1013    /// keyed to it consumed; `None` when no overlay is.
1014    fn take_overlay(&mut self, path: &Path) -> Option<String> {
1015        let path_key = overlay_key(path)?;
1016        self.overlays
1017            .iter_mut()
1018            .filter(|(key, _, _)| *key == path_key)
1019            .map(|(_, overlay, consumed)| {
1020                *consumed = true;
1021                overlay.text.clone()
1022            })
1023            .last()
1024    }
1025
1026    /// Reads one source file into an [`InputFile`], parses it through the
1027    /// salsa query, and enforces the package↔directory law: every `package`
1028    /// declaration after the first is TYPL-001; when `expected` is given and
1029    /// the first declared name differs, TYPL-002 with the declaration line as
1030    /// the primary span. Returns the input plus the file's declarations, or
1031    /// `None` for a file that is not valid UTF-8 — recorded as a diagnostic
1032    /// and skipped, never an abort of the whole load (ADR-0004 §5).
1033    fn load_file(
1034        &mut self,
1035        db: &mut RidlDatabase,
1036        path: &Path,
1037        expected: Option<&str>,
1038    ) -> io::Result<Option<LoadedFile>> {
1039        let path_str = path_string(path);
1040        let replacement = self.take_overlay(path);
1041        let text = match replacement
1042            .map(Ok)
1043            .unwrap_or_else(|| fs::read_to_string(path).map_err(|e| cannot_read(path, e)))
1044        {
1045            Ok(text) => text,
1046            Err(err) if err.kind() == io::ErrorKind::InvalidData => {
1047                // No text means no spans; the diagnostic points at the start
1048                // of the interned (empty) file. No code is cataloged for a
1049                // broken source encoding, so it carries the `NONE` sentinel.
1050                let file_id = self.sources.file_id(&path_str, "");
1051                self.diagnostics.push(error(
1052                    DiagCode::NONE,
1053                    file_id,
1054                    byte_range(0, 0),
1055                    format!("`{path_str}` is not valid UTF-8; the file is skipped"),
1056                ));
1057                return Ok(None);
1058            }
1059            Err(err) => return Err(err),
1060        };
1061        let file_id = self.sources.file_id(&path_str, &text);
1062        let input = InputFile::new(&*db, path_str, text);
1063
1064        let parse = parse_file(&*db, input);
1065        let source =
1066            SourceFile::cast(parse.syntax()).expect("parser roots every tree in a SourceFile");
1067        let decls = package_declarations(&source);
1068
1069        for (_, range) in decls.iter().skip(1) {
1070            self.diagnostics.push(error(
1071                DiagCode::TYPL_001,
1072                file_id,
1073                *range,
1074                "more than one `package` declaration in this file".to_string(),
1075            ));
1076        }
1077        if let Some((declared, range)) = decls.first()
1078            && crate::std_lib::is_reserved_package_name(declared)
1079        {
1080            // Reported on the declaration rather than on the manifest or the
1081            // directory, because that is the one place both paths meet: a
1082            // workspace member and single-file mode both arrive here, and the
1083            // issue this closes (driftsys/ridl#203) names both.
1084            //
1085            // The message states only the unreachability, which always holds.
1086            // The artifact overwrite that issue reports is a consequence in
1087            // package and workspace mode, where the output base is the package
1088            // name; in single-file mode the base is the file stem, so it
1089            // collides only when that stem is itself `ridl.std`, whatever the
1090            // extension. That distinction belongs in the catalogue entry, not
1091            // in a message that would then be false for some of the inputs it
1092            // greets.
1093            self.diagnostics.push(error(
1094                DiagCode::TYPL_010,
1095                file_id,
1096                *range,
1097                format!(
1098                    "`{declared}` is provided by the compiler, so a package cannot declare it; every package already imports all of `{declared}` implicitly (typl §3.2), which leaves these declarations unreachable under their own name. Rename the package"
1099                ),
1100            ));
1101        }
1102        if let (Some(expected), Some((declared, range))) = (expected, decls.first())
1103            && !declared.is_empty()
1104            && declared != expected
1105        {
1106            self.diagnostics.push(error(
1107                DiagCode::TYPL_002,
1108                file_id,
1109                *range,
1110                format!(
1111                    "package name `{declared}` does not mirror the directory path; every file in this directory must declare `package {expected}`"
1112                ),
1113            ));
1114        }
1115        Ok(Some((input, decls)))
1116    }
1117}
1118
1119/// The interned string form of a filesystem path.
1120fn path_string(path: &Path) -> String {
1121    path.to_string_lossy().into_owned()
1122}
1123
1124/// The RIDL-410 message: what is wrong with the lock file, then the fix the
1125/// lock design §8 names.
1126fn malformed_lock_message(reason: &str) -> String {
1127    format!(
1128        "`{}` is malformed: {reason} — resolve the conflict or restore the file from version \
1129         control, then run `ridl lock`",
1130        interface_lock::FILE_NAME
1131    )
1132}
1133
1134/// The byte range of the quoted `member` entry inside a workspace manifest's
1135/// text, or the whole file when it cannot be found (T7 does not retain member
1136/// spans).
1137fn member_entry_range(text: &str, member: &str) -> TextRange {
1138    let quoted = format!("\"{member}\"");
1139    match text.find(&quoted) {
1140        Some(start) => byte_range(start, start + quoted.len()),
1141        None => byte_range(0, text.len()),
1142    }
1143}
1144
1145/// The byte range of the `[workspace]` section header inside a manifest's
1146/// text, or the whole file as a fallback.
1147fn workspace_section_range(text: &str) -> TextRange {
1148    const HEADER: &str = "[workspace]";
1149    match text.find(HEADER) {
1150        Some(start) => byte_range(start, start + HEADER.len()),
1151        None => byte_range(0, text.len()),
1152    }
1153}
1154
1155/// A `rowan::TextRange` over byte offsets.
1156fn byte_range(start: usize, end: usize) -> TextRange {
1157    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
1158}
1159
1160/// Builds an error [`Diagnostic`]; loader diagnostics carry no secondary
1161/// labels or fix-its.
1162fn error(code: DiagCode, file: FileId, range: TextRange, message: String) -> Diagnostic {
1163    Diagnostic {
1164        code,
1165        severity: Severity::Error,
1166        message,
1167        primary: Span { file, range },
1168        labels: Vec::new(),
1169        fixits: Vec::new(),
1170    }
1171}
1172
1173fn warning(code: DiagCode, file: FileId, range: TextRange, message: String) -> Diagnostic {
1174    Diagnostic {
1175        severity: Severity::Warning,
1176        ..error(code, file, range, message)
1177    }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182    use std::path::PathBuf;
1183    use std::sync::atomic::{AtomicUsize, Ordering};
1184
1185    use salsa::Setter;
1186    use salsa::plumbing::AsId;
1187
1188    use super::*;
1189    use crate::lint::{LintLevel, apply_lint_levels, lint_by_name};
1190
1191    /// A unique directory under the system temp dir, removed on drop.
1192    struct TempDir(PathBuf);
1193
1194    impl TempDir {
1195        fn new(label: &str) -> Self {
1196            static COUNTER: AtomicUsize = AtomicUsize::new(0);
1197            let mut path = std::env::temp_dir();
1198            path.push(format!(
1199                "ridl-core-workspace-{label}-{}-{}",
1200                std::process::id(),
1201                COUNTER.fetch_add(1, Ordering::SeqCst),
1202            ));
1203            fs::create_dir_all(&path).expect("create the temp dir");
1204            Self(path)
1205        }
1206
1207        fn path(&self) -> &Path {
1208            &self.0
1209        }
1210
1211        /// Writes `text` at `relative`, creating parent directories.
1212        fn write(&self, relative: &str, text: &str) -> PathBuf {
1213            let path = self.0.join(relative);
1214            fs::create_dir_all(path.parent().expect("relative paths have a parent"))
1215                .expect("create parent directories");
1216            fs::write(&path, text).expect("write the fixture file");
1217            path
1218        }
1219    }
1220
1221    impl Drop for TempDir {
1222        fn drop(&mut self) {
1223            let _ = fs::remove_dir_all(&self.0);
1224        }
1225    }
1226
1227    fn codes(diags: &[Diagnostic]) -> Vec<&str> {
1228        diags.iter().map(|d| d.code.as_str()).collect()
1229    }
1230
1231    fn overlay_fixture() -> (TempDir, PathBuf) {
1232        let dir = TempDir::new("overlay");
1233        dir.write(
1234            "p/ridl.toml",
1235            "[package]\nname = \"p\"\nversion = \"1.0.0\"\n",
1236        );
1237        let path = dir.write("p/a.typl", "package p\ntype A: integer [0..1]\n");
1238        (dir, path)
1239    }
1240
1241    fn overlay(path: PathBuf, text: &str) -> Overlay {
1242        Overlay {
1243            path,
1244            text: text.to_string(),
1245        }
1246    }
1247
1248    #[test]
1249    fn overlay_replaces_the_text_of_a_file_on_disk() {
1250        let (dir, path) = overlay_fixture();
1251        let text = "package p\n\ntype Other: integer [0..1]\n";
1252        let mut db = RidlDatabase::default();
1253        let loaded = load_workspace_with(
1254            &mut db,
1255            &dir.path().join("p"),
1256            &[overlay(path.clone(), text)],
1257        )
1258        .unwrap();
1259        let files = loaded.workspace.packages(&db)[0].files(&db);
1260        assert_eq!(files.len(), 1);
1261        let file = files[0];
1262        assert_eq!(file.text(&db), text);
1263        let entries: Vec<_> = loaded.sources.iter_files().collect();
1264        assert!(entries.contains(&(path_string(&path).as_str(), text)));
1265    }
1266
1267    #[test]
1268    fn an_added_file_sorts_with_the_disk_files() {
1269        let (dir, _) = overlay_fixture();
1270        let mut db = RidlDatabase::default();
1271        let loaded = load_workspace_with(
1272            &mut db,
1273            &dir.path().join("p"),
1274            &[overlay(dir.path().join("p/0.typl"), "package p\n")],
1275        )
1276        .unwrap();
1277        let files = loaded.workspace.packages(&db)[0].files(&db);
1278        assert_eq!(files.len(), 2);
1279        assert!(files[0].path(&db).ends_with("0.typl"));
1280        assert!(files[1].path(&db).ends_with("a.typl"));
1281    }
1282    #[test]
1283    fn an_overlay_replaces_rather_than_adds() {
1284        let (dir, path) = overlay_fixture();
1285        let mut db = RidlDatabase::default();
1286        let loaded = load_workspace_with(
1287            &mut db,
1288            &dir.path().join("p"),
1289            &[overlay(
1290                path,
1291                "package p\ntype Replacement: integer [0..1]\n",
1292            )],
1293        )
1294        .unwrap();
1295        assert_eq!(loaded.workspace.packages(&db)[0].files(&db).len(), 1);
1296    }
1297
1298    #[test]
1299    fn overlay_adds_a_file_to_the_package_of_its_directory() {
1300        let (dir, _) = overlay_fixture();
1301        let mut db = RidlDatabase::default();
1302        let loaded = load_workspace_with(
1303            &mut db,
1304            &dir.path().join("p"),
1305            &[overlay(dir.path().join("p/b.typl"), "package p\n")],
1306        )
1307        .unwrap();
1308        let files = loaded.workspace.packages(&db)[0].files(&db);
1309        assert_eq!(files.len(), 2);
1310        assert!(files[0].path(&db).ends_with("a.typl"));
1311        assert!(files[1].path(&db).ends_with("b.typl"));
1312    }
1313
1314    #[test]
1315    fn overlay_in_an_existing_subdirectory_joins_its_package() {
1316        let (dir, _) = overlay_fixture();
1317        dir.write("p/sub/c.typl", "package p.sub\n");
1318        let mut db = RidlDatabase::default();
1319        let loaded = load_workspace_with(
1320            &mut db,
1321            &dir.path().join("p"),
1322            &[overlay(dir.path().join("p/sub/d.typl"), "package p.sub\n")],
1323        )
1324        .unwrap();
1325        let package = loaded
1326            .workspace
1327            .packages(&db)
1328            .iter()
1329            .find(|p| p.name(&db) == "p.sub")
1330            .unwrap();
1331        let files = package.files(&db);
1332        assert_eq!(files.len(), 2);
1333        assert!(files[0].path(&db).ends_with("c.typl"));
1334        assert!(files[1].path(&db).ends_with("d.typl"));
1335    }
1336
1337    #[test]
1338    fn an_added_file_with_the_wrong_package_name_draws_typl_002() {
1339        let (dir, _) = overlay_fixture();
1340        let mut db = RidlDatabase::default();
1341        let loaded = load_workspace_with(
1342            &mut db,
1343            &dir.path().join("p"),
1344            &[overlay(dir.path().join("p/b.typl"), "package q\n")],
1345        )
1346        .unwrap();
1347        let diag = loaded
1348            .diagnostics
1349            .iter()
1350            .find(|d| d.code == DiagCode::TYPL_002)
1351            .unwrap();
1352        assert!(
1353            loaded
1354                .sources
1355                .path(diag.primary.file)
1356                .unwrap()
1357                .ends_with("b.typl")
1358        );
1359    }
1360
1361    #[test]
1362    fn an_overlay_in_a_missing_directory_is_refused() {
1363        let (dir, _) = overlay_fixture();
1364        let result = load_workspace_with(
1365            &mut RidlDatabase::default(),
1366            &dir.path().join("p"),
1367            &[overlay(
1368                dir.path().join("p/nope/e.typl"),
1369                "package p.nope\n",
1370            )],
1371        );
1372        assert!(matches!(
1373            result,
1374            Err(LoadError::OverlayOutsideWorkspace {
1375                missing_directory: true,
1376                ..
1377            })
1378        ));
1379    }
1380
1381    #[test]
1382    fn an_overlay_outside_the_workspace_is_refused() {
1383        let (dir, _) = overlay_fixture();
1384        let path = dir.write("sibling/a.typl", "package sibling\n");
1385        let result = load_workspace_with(
1386            &mut RidlDatabase::default(),
1387            &dir.path().join("p"),
1388            &[overlay(path, "package sibling\n")],
1389        );
1390        assert!(matches!(
1391            result,
1392            Err(LoadError::OverlayOutsideWorkspace {
1393                missing_directory: false,
1394                ..
1395            })
1396        ));
1397    }
1398
1399    #[test]
1400    fn an_overlay_under_a_hidden_directory_is_refused() {
1401        let (dir, _) = overlay_fixture();
1402        fs::create_dir(dir.path().join("p/.hidden")).unwrap();
1403        let result = load_workspace_with(
1404            &mut RidlDatabase::default(),
1405            &dir.path().join("p"),
1406            &[overlay(
1407                dir.path().join("p/.hidden/f.typl"),
1408                "package p.hidden\n",
1409            )],
1410        );
1411        assert!(matches!(
1412            result,
1413            Err(LoadError::OverlayOutsideWorkspace {
1414                missing_directory: false,
1415                ..
1416            })
1417        ));
1418    }
1419
1420    #[test]
1421    fn a_non_source_overlay_is_refused() {
1422        let (dir, _) = overlay_fixture();
1423        let result = load_workspace_with(
1424            &mut RidlDatabase::default(),
1425            &dir.path().join("p"),
1426            &[overlay(dir.path().join("p/ridl.toml"), "")],
1427        );
1428        assert!(matches!(result, Err(LoadError::OverlayNotSource(_))));
1429    }
1430
1431    #[test]
1432    fn single_file_mode_takes_an_overlay_for_the_entry() {
1433        let dir = TempDir::new("overlay-single");
1434        let path = dir.write("x.typl", "package x\n");
1435        let text = "package x\ntype Other: integer [0..1]\n";
1436        let mut db = RidlDatabase::default();
1437        let loaded = load_workspace_with(&mut db, &path, &[overlay(path.clone(), text)]).unwrap();
1438        assert_eq!(
1439            loaded.workspace.packages(&db)[0].files(&db)[0].text(&db),
1440            text
1441        );
1442        let result = load_workspace_with(
1443            &mut db,
1444            &path,
1445            &[overlay(dir.path().join("y.typl"), "package y\n")],
1446        );
1447        assert!(matches!(
1448            result,
1449            Err(LoadError::OverlayOutsideWorkspace {
1450                missing_directory: false,
1451                ..
1452            })
1453        ));
1454    }
1455
1456    #[cfg(unix)]
1457    #[test]
1458    fn overlay_matches_a_file_named_by_a_relative_entry() {
1459        let (dir, path) = overlay_fixture();
1460        let cwd = std::env::current_dir().unwrap();
1461        let mut relative = PathBuf::new();
1462        for part in cwd.components() {
1463            if matches!(part, std::path::Component::Normal(_)) {
1464                relative.push("..");
1465            }
1466        }
1467        relative.push(path.strip_prefix("/").unwrap());
1468        for (entry, overlay_path) in [(&relative, &path), (&path, &relative)] {
1469            let mut db = RidlDatabase::default();
1470            let text = "package p\ntype Other: integer [0..1]\n";
1471            let loaded =
1472                load_workspace_with(&mut db, entry, &[overlay(overlay_path.clone(), text)])
1473                    .unwrap();
1474            assert_eq!(
1475                loaded.workspace.packages(&db)[0].files(&db)[0].text(&db),
1476                text
1477            );
1478        }
1479        drop(dir);
1480    }
1481
1482    #[test]
1483    fn load_workspace_without_overlays_is_unchanged() {
1484        let (dir, _) = overlay_fixture();
1485        let mut db = RidlDatabase::default();
1486        let first = load_workspace_with(&mut db, &dir.path().join("p"), &[]).unwrap();
1487        let describe = |db: &RidlDatabase, loaded: &LoadedWorkspace| {
1488            loaded
1489                .workspace
1490                .packages(db)
1491                .iter()
1492                .map(|p| {
1493                    (
1494                        p.name(db).clone(),
1495                        p.files(db)
1496                            .iter()
1497                            .map(|f| f.path(db).clone())
1498                            .collect::<Vec<_>>(),
1499                    )
1500                })
1501                .collect::<Vec<_>>()
1502        };
1503        let expected = describe(&db, &first);
1504        let mut other = RidlDatabase::default();
1505        let second = load_workspace(&mut other, &dir.path().join("p")).unwrap();
1506        assert_eq!(expected, describe(&other, &second));
1507    }
1508
1509    const PACKAGE_MANIFEST: &str = "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n";
1510
1511    /// TYPL-010: a workspace member cannot declare a name the compiler
1512    /// provides (driftsys/ridl#203). Before this check the member compiled
1513    /// clean, its declarations were unreachable because every package already
1514    /// imports all of `ridl.std` implicitly, and its generated artifact was
1515    /// overwritten by the standard package's own.
1516    #[test]
1517    fn a_package_declaring_a_reserved_name_is_refused() {
1518        const SOURCE: &str = "package ridl.std\ntype MyOwnType: m\n";
1519        let dir = TempDir::new("reserved-name");
1520        dir.write(
1521            "ridl.toml",
1522            "[package]\nname = \"ridl.std\"\nversion = \"1.0.0\"\n",
1523        );
1524        dir.write("own.typl", SOURCE);
1525
1526        let mut db = RidlDatabase::default();
1527        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1528        assert_eq!(
1529            codes(&loaded.diagnostics),
1530            vec!["TYPL-010"],
1531            "got: {:?}",
1532            loaded.diagnostics
1533        );
1534        let diagnostic = &loaded.diagnostics[0];
1535        assert!(
1536            diagnostic.message.contains("ridl.std"),
1537            "the message names the package: {}",
1538            diagnostic.message
1539        );
1540        // The message states only what holds for every input that draws it.
1541        // The artifact collision does not: in single-file mode the output base
1542        // is the file stem. Asserting the absence keeps that clause from
1543        // returning to the message without the test noticing.
1544        assert!(
1545            diagnostic.message.contains("unreachable"),
1546            "the message states the consequence that always holds: {}",
1547            diagnostic.message
1548        );
1549        for conditional in ["artifact", "overwrit"] {
1550            assert!(
1551                !diagnostic.message.contains(conditional),
1552                "`{conditional}` is true only where the output base is the package name, \
1553                 so it belongs in the catalogue entry, not the message: {}",
1554                diagnostic.message
1555            );
1556        }
1557        // The declaration is what is pointed at, not the manifest or the
1558        // directory: it is the one place a workspace member and single-file
1559        // mode both pass through. Both ends are asserted, against the source
1560        // itself, so an over-wide span covering the whole file cannot pass.
1561        let declaration = SOURCE
1562            .lines()
1563            .next()
1564            .expect("the declaration is the first line");
1565        assert_eq!(
1566            (
1567                usize::from(diagnostic.primary.range.start()),
1568                usize::from(diagnostic.primary.range.end()),
1569            ),
1570            (0, declaration.len()),
1571            "reported on `{declaration}` exactly"
1572        );
1573    }
1574
1575    /// The same refusal in single-file mode — `ridlc build ridl_std.typl`,
1576    /// the second form driftsys/ridl#203 names. Single-file mode is exempt
1577    /// from TYPL-002, so nothing else would have caught it.
1578    #[test]
1579    fn a_single_file_declaring_a_reserved_name_is_refused() {
1580        let dir = TempDir::new("reserved-name-single");
1581        let file = dir.write("ridl_std.typl", "package ridl.std\ntype MyOwnType: m\n");
1582
1583        let mut db = RidlDatabase::default();
1584        let loaded = load_workspace(&mut db, &file).expect("single-file mode loads");
1585        assert_eq!(
1586            codes(&loaded.diagnostics),
1587            vec!["TYPL-010"],
1588            "got: {:?}",
1589            loaded.diagnostics
1590        );
1591    }
1592
1593    /// A name that merely starts with `ridl.` is not reserved: the reservation
1594    /// is the set of packages the compiler provides, not a namespace policy.
1595    #[test]
1596    fn only_a_compiler_provided_name_is_reserved() {
1597        let dir = TempDir::new("near-reserved");
1598        dir.write(
1599            "ridl.toml",
1600            "[package]\nname = \"ridl.stdlib\"\nversion = \"1.0.0\"\n",
1601        );
1602        dir.write("own.typl", "package ridl.stdlib\ntype MyOwnType: m\n");
1603
1604        let mut db = RidlDatabase::default();
1605        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1606        assert_eq!(
1607            loaded.diagnostics,
1608            Vec::new(),
1609            "`ridl.stdlib` is not a package the compiler provides"
1610        );
1611    }
1612
1613    /// (a) A two-file package loads, both files parse, and editing one
1614    /// re-parses only it — asserted by the re-executed query's `database_key`
1615    /// (issue #102).
1616    #[test]
1617    fn two_file_package_loads_and_edit_reparses_only_the_edited_file() {
1618        let dir = TempDir::new("two-file");
1619        dir.write("ridl.toml", PACKAGE_MANIFEST);
1620        dir.write("a.typl", "package veh.common\ntype A: m\n");
1621        dir.write("b.typl", "package veh.common\ntype B: s\n");
1622
1623        let mut db = RidlDatabase::default();
1624        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1625        assert_eq!(
1626            loaded.diagnostics,
1627            Vec::new(),
1628            "a clean package, no diagnostics"
1629        );
1630
1631        let packages = loaded.workspace.packages(&db).clone();
1632        assert_eq!(packages.len(), 1, "one package directory, one package");
1633        assert_eq!(packages[0].name(&db).as_str(), "veh.common");
1634        assert_eq!(*packages[0].origin(&db), PackageOrigin::WorkspaceMember);
1635        assert_eq!(
1636            packages[0].imports(&db),
1637            &BTreeMap::new(),
1638            "a manifest without `[imports]` yields an empty package map",
1639        );
1640        assert_eq!(
1641            loaded.workspace.imports(&db),
1642            &BTreeMap::new(),
1643            "a standalone load leaves the workspace map empty",
1644        );
1645
1646        let files = packages[0].files(&db).clone();
1647        assert_eq!(files.len(), 2, "both .typl files load");
1648        for file in &files {
1649            assert_eq!(
1650                parse_file(&db, *file).errors(),
1651                &[],
1652                "both files parse clean"
1653            );
1654        }
1655        let a = files
1656            .iter()
1657            .copied()
1658            .find(|f| f.path(&db).ends_with("a.typl"))
1659            .expect("a.typl is loaded");
1660        let b = files
1661            .iter()
1662            .copied()
1663            .find(|f| f.path(&db).ends_with("b.typl"))
1664            .expect("b.typl is loaded");
1665
1666        // Drain the executions the load itself ran; unchanged inputs are then
1667        // pure memo hits.
1668        db.take_executed_queries();
1669        let _ = parse_file(&db, a);
1670        let _ = parse_file(&db, b);
1671        assert_eq!(
1672            db.take_executed_queries(),
1673            Vec::new(),
1674            "re-querying unchanged inputs must run no executions",
1675        );
1676
1677        // Edit A's text only: exactly one re-execution, and it is A's parse.
1678        a.set_text(&mut db)
1679            .to("package veh.common\ntype A: kg\n".to_string());
1680        let _ = parse_file(&db, a);
1681        let _ = parse_file(&db, b);
1682        let executed = db.take_executed_queries();
1683        assert_eq!(
1684            executed.len(),
1685            1,
1686            "editing one file re-parses exactly one file"
1687        );
1688        assert_eq!(
1689            salsa::attach(&db, || format!("{:?}", executed[0])),
1690            format!("parse_file({:?})", a.as_id()),
1691            "the re-executed query is the parse of the edited file",
1692        );
1693    }
1694
1695    /// (b) A file that declares a different package than its directory
1696    /// requires is TYPL-002, primary span on the `package` line.
1697    #[test]
1698    fn typl_002_on_a_mismatching_file() {
1699        let dir = TempDir::new("mismatch");
1700        dir.write("ridl.toml", PACKAGE_MANIFEST);
1701        let bad_text = "package veh.wrong\ntype B: s\n";
1702        let bad_path = dir.write("bad.typl", bad_text);
1703
1704        let mut db = RidlDatabase::default();
1705        let mut loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1706        assert_eq!(codes(&loaded.diagnostics), vec!["TYPL-002"]);
1707
1708        let diag = &loaded.diagnostics[0];
1709        assert_eq!(diag.severity, Severity::Error);
1710        assert_eq!(
1711            diag.primary.range,
1712            byte_range(0, "package veh.wrong".len()),
1713            "the primary span is the mismatching `package` line",
1714        );
1715        assert_eq!(
1716            diag.primary.file,
1717            loaded.sources.file_id(&path_string(&bad_path), bad_text),
1718            "the span points into the mismatching file",
1719        );
1720
1721        // The law is a diagnostic, not an exclusion: the file stays loaded.
1722        let packages = loaded.workspace.packages(&db).clone();
1723        assert_eq!(packages.len(), 1);
1724        assert_eq!(packages[0].files(&db).len(), 1);
1725    }
1726
1727    /// (c) More than one `package` declaration in a file is TYPL-001 on each
1728    /// declaration after the first.
1729    #[test]
1730    fn typl_001_on_a_double_package_declaration() {
1731        let dir = TempDir::new("double-decl");
1732        dir.write("ridl.toml", PACKAGE_MANIFEST);
1733        dir.write(
1734            "dup.typl",
1735            "package veh.common\npackage veh.extra\ntype A: m\n",
1736        );
1737
1738        let mut db = RidlDatabase::default();
1739        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1740        assert_eq!(codes(&loaded.diagnostics), vec!["TYPL-001"]);
1741        assert_eq!(
1742            loaded.diagnostics[0].primary.range,
1743            byte_range(19, 36),
1744            "the primary span is the second `package` declaration",
1745        );
1746    }
1747
1748    /// (d) Workspace mode loads every member and keeps `[imports]` scoped per
1749    /// ADR-0002 §5: each member package carries only its own manifest's map
1750    /// (step 2 — a member's pin never leaks to a sibling), and the workspace
1751    /// map holds only the root's `[imports]` (step 3), never a merge.
1752    #[test]
1753    fn workspace_mode_scopes_imports_per_package() {
1754        let dir = TempDir::new("workspace");
1755        dir.write(
1756            "ridl.toml",
1757            "[workspace]\nmembers = [\"m-one\", \"m-two\"]\n\n[imports]\n\"third.dep\" = \"https://registry.example.com/third/dep@v1.0.0\"\n\"shared.util\" = \"https://registry.example.com/shared/util@v1.0.0\"\n",
1758        );
1759        dir.write(
1760            "m-one/ridl.toml",
1761            "[package]\nname = \"veh.one\"\nversion = \"1.0.0\"\n\n[imports]\n\"third.dep\" = \"https://mirror.example.com/third/dep@v2.0.0\"\n\"member.only\" = \"https://registry.example.com/member/only@v1.0.0\"\n",
1762        );
1763        dir.write("m-one/one.typl", "package veh.one\ntype A: m\n");
1764        dir.write(
1765            "m-two/ridl.toml",
1766            "[package]\nname = \"veh.two\"\nversion = \"1.0.0\"\n\n[imports]\n\"two.only\" = \"https://registry.example.com/two/only@v1.0.0\"\n",
1767        );
1768        dir.write("m-two/two.typl", "package veh.two\ntype B: s\n");
1769
1770        let mut db = RidlDatabase::default();
1771        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
1772        assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
1773
1774        let packages = loaded.workspace.packages(&db).clone();
1775        let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
1776        assert_eq!(
1777            names,
1778            vec!["veh.one", "veh.two"],
1779            "both members load, in member order"
1780        );
1781
1782        // Step 3: the workspace map is the root's `[imports]`, un-merged —
1783        // the member pin for `third.dep` must NOT overwrite the root's.
1784        let workspace_imports = loaded.workspace.imports(&db).clone();
1785        assert_eq!(
1786            workspace_imports.get("third.dep").map(String::as_str),
1787            Some("https://registry.example.com/third/dep@v1.0.0"),
1788            "the workspace map keeps the root pin, not the member pin",
1789        );
1790        assert_eq!(
1791            workspace_imports.get("shared.util").map(String::as_str),
1792            Some("https://registry.example.com/shared/util@v1.0.0"),
1793        );
1794        assert_eq!(workspace_imports.len(), 2, "no member entry leaks upward");
1795
1796        // Step 2: each member package carries its own manifest's map only.
1797        let one_imports = packages[0].imports(&db).clone();
1798        assert_eq!(
1799            one_imports.get("third.dep").map(String::as_str),
1800            Some("https://mirror.example.com/third/dep@v2.0.0"),
1801            "the member's own pin shadows the workspace default for it alone",
1802        );
1803        assert_eq!(
1804            one_imports.get("member.only").map(String::as_str),
1805            Some("https://registry.example.com/member/only@v1.0.0"),
1806        );
1807        assert!(
1808            !one_imports.contains_key("two.only"),
1809            "a sibling's pin never leaks into another member",
1810        );
1811        assert_eq!(one_imports.len(), 2, "no workspace entry is merged in");
1812
1813        let two_imports = packages[1].imports(&db).clone();
1814        assert_eq!(
1815            two_imports.get("two.only").map(String::as_str),
1816            Some("https://registry.example.com/two/only@v1.0.0"),
1817        );
1818        assert!(
1819            !two_imports.contains_key("member.only"),
1820            "the sibling's pin never leaks into this member",
1821        );
1822        assert!(
1823            !two_imports.contains_key("third.dep"),
1824            "neither the root default nor the sibling's pin is merged in",
1825        );
1826        assert_eq!(two_imports.len(), 1);
1827    }
1828
1829    /// `[defaults].timing` follows the ADR-0002 §5 precedence merged at load:
1830    /// a member's own `[defaults]` shadows the workspace `[defaults]`; a member
1831    /// without one inherits the workspace default (ridl §9.1).
1832    #[test]
1833    fn defaults_timing_precedence_package_shadows_workspace() {
1834        let dir = TempDir::new("defaults-timing");
1835        dir.write(
1836            "ridl.toml",
1837            "[workspace]\nmembers = [\"m-own\", \"m-inherit\"]\n\n[defaults]\ntiming = \"[100ms..1000ms]\"\n",
1838        );
1839        // m-own configures its own default — it shadows the workspace default.
1840        dir.write(
1841            "m-own/ridl.toml",
1842            "[package]\nname = \"veh.own\"\nversion = \"1.0.0\"\n\n[defaults]\ntiming = \"[50ms..2s]\"\n",
1843        );
1844        dir.write("m-own/own.typl", "package veh.own\ntype A: m\n");
1845        // m-inherit configures none — it inherits the workspace default.
1846        dir.write(
1847            "m-inherit/ridl.toml",
1848            "[package]\nname = \"veh.inherit\"\nversion = \"1.0.0\"\n",
1849        );
1850        dir.write("m-inherit/inherit.typl", "package veh.inherit\ntype B: s\n");
1851
1852        let mut db = RidlDatabase::default();
1853        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
1854        assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
1855
1856        let packages = loaded.workspace.packages(&db).clone();
1857        let own = packages
1858            .iter()
1859            .find(|p| p.name(&db) == "veh.own")
1860            .expect("m-own loads");
1861        assert_eq!(
1862            own.defaults(&db).timing.as_deref(),
1863            Some("[50ms..2s]"),
1864            "the member's own `[defaults]` shadows the workspace default",
1865        );
1866        let inherit = packages
1867            .iter()
1868            .find(|p| p.name(&db) == "veh.inherit")
1869            .expect("m-inherit loads");
1870        assert_eq!(
1871            inherit.defaults(&db).timing.as_deref(),
1872            Some("[100ms..1000ms]"),
1873            "a member without `[defaults]` inherits the workspace default",
1874        );
1875    }
1876
1877    /// Each `[defaults]` key resolves on its own: a member's value shadows the
1878    /// workspace's for that key only.
1879    #[test]
1880    fn defaults_precedence_is_per_key() {
1881        let dir = TempDir::new("defaults-per-key");
1882        dir.write(
1883            "ridl.toml",
1884            "[workspace]\nmembers = [\"m\"]\n\n[defaults]\ncommand_timing = \"[..2s]\"\nquery_timing = \"[..4s]\"\n",
1885        );
1886        dir.write(
1887            "m/ridl.toml",
1888            "[package]\nname = \"veh.m\"\nversion = \"1.0.0\"\n\n[defaults]\nquery_timing = \"[..5s]\"\n",
1889        );
1890        dir.write("m/m.typl", "package veh.m\ntype A: m\n");
1891
1892        let mut db = RidlDatabase::default();
1893        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
1894        assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
1895        let packages = loaded.workspace.packages(&db).clone();
1896        let member = packages
1897            .iter()
1898            .find(|p| p.name(&db) == "veh.m")
1899            .expect("the member loads");
1900        let defaults = member.defaults(&db);
1901        assert_eq!(defaults.command_timing.as_deref(), Some("[..2s]"));
1902        assert_eq!(defaults.query_timing.as_deref(), Some("[..5s]"));
1903        assert_eq!(defaults.timing, None);
1904    }
1905
1906    /// Per-key precedence in the other direction: a member's `command_timing`
1907    /// is not overridden by the workspace's, and a member that leaves
1908    /// `query_timing` unset inherits the workspace's.
1909    #[test]
1910    fn defaults_precedence_is_per_key_in_both_directions() {
1911        let dir = TempDir::new("defaults-per-key-reverse");
1912        dir.write(
1913            "ridl.toml",
1914            "[workspace]\nmembers = [\"m\"]\n\n[defaults]\ncommand_timing = \"[..2s]\"\nquery_timing = \"[..4s]\"\n",
1915        );
1916        dir.write(
1917            "m/ridl.toml",
1918            "[package]\nname = \"veh.m\"\nversion = \"1.0.0\"\n\n[defaults]\ncommand_timing = \"[..7s]\"\n",
1919        );
1920        dir.write("m/m.typl", "package veh.m\ntype A: m\n");
1921
1922        let mut db = RidlDatabase::default();
1923        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
1924        assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
1925        let packages = loaded.workspace.packages(&db).clone();
1926        let member = packages
1927            .iter()
1928            .find(|p| p.name(&db) == "veh.m")
1929            .expect("the member loads");
1930        let defaults = member.defaults(&db);
1931        assert_eq!(
1932            defaults.command_timing.as_deref(),
1933            Some("[..7s]"),
1934            "the member's own `command_timing` wins over the workspace's",
1935        );
1936        assert_eq!(
1937            defaults.query_timing.as_deref(),
1938            Some("[..4s]"),
1939            "a member without `query_timing` inherits the workspace's",
1940        );
1941        assert_eq!(defaults.timing, None);
1942    }
1943
1944    /// A standalone package's `command_timing` and `query_timing` ride on the
1945    /// root package and on a nested package directory alike (ridl §9.3).
1946    #[test]
1947    fn standalone_rpc_defaults_ride_on_the_tree() {
1948        let dir = TempDir::new("standalone-rpc-defaults");
1949        dir.write(
1950            "ridl.toml",
1951            "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n[defaults]\ncommand_timing = \"[..2s]\"\nquery_timing = \"[5ms..4s]\"\n",
1952        );
1953        dir.write("a.typl", "package veh.common\ntype A: m\n");
1954        dir.write("sub/s.typl", "package veh.common.sub\ntype S: s\n");
1955
1956        let mut db = RidlDatabase::default();
1957        let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
1958        assert_eq!(loaded.diagnostics, Vec::new());
1959        let packages = loaded.workspace.packages(&db).clone();
1960        let names: Vec<_> = packages.iter().map(|p| p.name(&db).clone()).collect();
1961        assert!(
1962            names.iter().any(|name| name == "veh.common")
1963                && names.iter().any(|name| name == "veh.common.sub"),
1964            "the root and the nested package both load: {names:?}",
1965        );
1966        for package in &packages {
1967            let defaults = package.defaults(&db);
1968            assert_eq!(
1969                defaults.command_timing.as_deref(),
1970                Some("[..2s]"),
1971                "{}",
1972                package.name(&db),
1973            );
1974            assert_eq!(
1975                defaults.query_timing.as_deref(),
1976                Some("[5ms..4s]"),
1977                "{}",
1978                package.name(&db),
1979            );
1980        }
1981    }
1982
1983    /// A standalone package's `[defaults].timing` rides on every package in its
1984    /// directory tree, and single-file mode carries none (ridl §9.1).
1985    #[test]
1986    fn standalone_defaults_timing_rides_on_the_tree() {
1987        let dir = TempDir::new("standalone-defaults");
1988        dir.write(
1989            "ridl.toml",
1990            "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n[defaults]\ntiming = \"[20ms..200ms]\"\n",
1991        );
1992        dir.write("a.typl", "package veh.common\ntype A: m\n");
1993        dir.write("sub/s.typl", "package veh.common.sub\ntype S: s\n");
1994
1995        let mut db = RidlDatabase::default();
1996        let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
1997        assert_eq!(loaded.diagnostics, Vec::new());
1998        for package in loaded.workspace.packages(&db) {
1999            assert_eq!(
2000                package.defaults(&db).timing.as_deref(),
2001                Some("[20ms..200ms]"),
2002                "every package in the tree carries the manifest default",
2003            );
2004        }
2005
2006        // Single-file mode: a bare file with no manifest anywhere up the tree.
2007        let bare = TempDir::new("standalone-defaults-bare");
2008        let single = bare.write("iface.ridl", "package solo\ntype A: m\n");
2009        let mut single_db = RidlDatabase::default();
2010        let single_loaded =
2011            load_workspace(&mut single_db, &single).expect("single-file mode loads");
2012        assert_eq!(
2013            single_loaded.workspace.packages(&single_db)[0].defaults(&single_db),
2014            &TimingDefaults::default(),
2015            "single-file mode carries no configured default",
2016        );
2017    }
2018
2019    /// The lint scopes the loader builds in workspace mode: the root
2020    /// directory gets the defaults overlaid with the root `[lints]`, and
2021    /// each member directory gets the root levels overlaid with the member's
2022    /// own table (ADR-0002 §4, ADR-0024 decision 10). The scope keys share the
2023    /// path form of the file paths the loader records, so the path recorded
2024    /// for a member file resolves to the member's scope.
2025    #[test]
2026    fn lint_scopes_follow_root_then_member() {
2027        let dir = TempDir::new("lint-scopes");
2028        dir.write(
2029            "ridl.toml",
2030            "[workspace]\nmembers = [\"a\", \"b\"]\n\n[lints]\nmissing-timing = \"deny\"\nshared-error-type = \"allow\"\n",
2031        );
2032        dir.write(
2033            "a/ridl.toml",
2034            "[package]\nname = \"a\"\nversion = \"1.0.0\"\n\n[lints]\nmissing-timing = \"warn\"\n",
2035        );
2036        let a_file = dir.write("a/a.typl", "package a\ntype A: m\n");
2037        dir.write(
2038            "b/ridl.toml",
2039            "[package]\nname = \"b\"\nversion = \"1.0.0\"\n",
2040        );
2041        let b_file = dir.write("b/b.typl", "package b\ntype B: s\n");
2042
2043        let mut db = RidlDatabase::default();
2044        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
2045        assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
2046
2047        let missing_timing = lint_by_name("missing-timing").expect("missing-timing is a lint");
2048        let shared_error_type =
2049            lint_by_name("shared-error-type").expect("shared-error-type is a lint");
2050        let levels = |path: &Path| {
2051            loaded
2052                .lints
2053                .for_path(path)
2054                .unwrap_or_else(|| panic!("`{}` is in a scope", path.display()))
2055        };
2056
2057        let in_a = levels(&a_file);
2058        assert_eq!(in_a.level(missing_timing), Some(LintLevel::Warn));
2059        assert_eq!(in_a.level(shared_error_type), Some(LintLevel::Allow));
2060        assert_eq!(levels(&b_file).level(missing_timing), Some(LintLevel::Deny));
2061        let root = levels(&dir.path().join("ridl.toml"));
2062        assert_eq!(root.level(missing_timing), Some(LintLevel::Deny));
2063        assert_eq!(root.level(shared_error_type), Some(LintLevel::Allow));
2064        assert_eq!(
2065            levels(&dir.path().join("a/ridl.toml")).level(missing_timing),
2066            Some(LintLevel::Warn),
2067        );
2068
2069        // The path the loader recorded for the member file, not one built by
2070        // hand, is in the member's scope.
2071        let packages = loaded.workspace.packages(&db).clone();
2072        let a = packages
2073            .iter()
2074            .find(|p| p.name(&db) == "a")
2075            .expect("member a loads");
2076        let recorded = a.files(&db)[0].path(&db).clone();
2077        assert_eq!(
2078            levels(Path::new(&recorded)).level(missing_timing),
2079            Some(LintLevel::Warn),
2080            "the recorded path `{recorded}` resolves to the member's scope",
2081        );
2082    }
2083
2084    /// A manifest diagnostic's file path resolves to the scope of the
2085    /// manifest's own directory, so a member's `[lints]` table sets the level
2086    /// of the MANI-010 it causes (ADR-0024 decision 11).
2087    #[test]
2088    fn a_member_manifest_diagnostic_is_in_the_member_scope() {
2089        let dir = TempDir::new("lint-scope-manifest");
2090        dir.write(
2091            "ridl.toml",
2092            "[workspace]\nmembers = [\"a\"]\n\n[lints]\nunknown-lint = \"deny\"\n",
2093        );
2094        dir.write(
2095            "a/ridl.toml",
2096            "[package]\nname = \"a\"\nversion = \"1.0.0\"\n\n[lints]\nunknown-lint = \"allow\"\nnope = \"warn\"\n",
2097        );
2098        dir.write("a/a.typl", "package a\ntype A: m\n");
2099
2100        let mut db = RidlDatabase::default();
2101        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
2102        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-010"]);
2103        let unknown_lint = lint_by_name("unknown-lint").expect("unknown-lint is a lint");
2104        let path = loaded
2105            .sources
2106            .path(loaded.diagnostics[0].primary.file)
2107            .expect("the manifest has a path");
2108        assert_eq!(
2109            loaded
2110                .lints
2111                .for_path(Path::new(path))
2112                .expect("the member manifest is in a scope")
2113                .level(unknown_lint),
2114            Some(LintLevel::Allow),
2115            "the recorded manifest path `{path}` resolves to the member's scope",
2116        );
2117
2118        let mut diagnostics = loaded.diagnostics.clone();
2119        apply_lint_levels(&mut diagnostics, &loaded.sources, &loaded.lints);
2120        assert_eq!(
2121            diagnostics,
2122            Vec::new(),
2123            "`unknown-lint = \"allow\"` silences the MANI-010"
2124        );
2125    }
2126
2127    /// A standalone package gets one scope for its directory, which covers
2128    /// every package in its tree; single-file mode gets none.
2129    #[test]
2130    fn standalone_lint_scope_covers_the_tree_and_single_file_has_none() {
2131        let dir = TempDir::new("lint-scope-standalone");
2132        dir.write(
2133            "ridl.toml",
2134            "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n[lints]\nmissing-timing = \"deny\"\n",
2135        );
2136        let top = dir.write("a.typl", "package veh.common\ntype A: m\n");
2137        let nested = dir.write("sub/s.typl", "package veh.common.sub\ntype S: s\n");
2138
2139        let mut db = RidlDatabase::default();
2140        let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
2141        assert_eq!(loaded.diagnostics, Vec::new());
2142        let missing_timing = lint_by_name("missing-timing").expect("missing-timing is a lint");
2143        for path in [&top, &nested] {
2144            assert_eq!(
2145                loaded
2146                    .lints
2147                    .for_path(path)
2148                    .unwrap_or_else(|| panic!("`{}` is in a scope", path.display()))
2149                    .level(missing_timing),
2150                Some(LintLevel::Deny),
2151            );
2152        }
2153
2154        let bare = TempDir::new("lint-scope-bare");
2155        let single = bare.write("iface.ridl", "package solo\ntype A: m\n");
2156        let mut single_db = RidlDatabase::default();
2157        let single_loaded =
2158            load_workspace(&mut single_db, &single).expect("single-file mode loads");
2159        assert!(
2160            single_loaded.lints.for_path(&single).is_none(),
2161            "single-file mode has no lint scope",
2162        );
2163    }
2164
2165    /// (e) A member manifest that declares `[workspace]` is a nested
2166    /// workspace: MANI-004, and the member loads nothing.
2167    #[test]
2168    fn mani_004_on_a_nested_workspace_member() {
2169        let dir = TempDir::new("nested");
2170        dir.write(
2171            "ridl.toml",
2172            "[workspace]\nmembers = [\"m-bad\", \"m-good\"]\n",
2173        );
2174        let bad_text = "[workspace]\nmembers = []\n";
2175        let bad_path = dir.write("m-bad/ridl.toml", bad_text);
2176        dir.write(
2177            "m-good/ridl.toml",
2178            "[package]\nname = \"veh.good\"\nversion = \"1.0.0\"\n",
2179        );
2180        dir.write("m-good/good.typl", "package veh.good\ntype A: m\n");
2181
2182        let mut db = RidlDatabase::default();
2183        let mut loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
2184        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-004"]);
2185
2186        let diag = &loaded.diagnostics[0];
2187        assert_eq!(diag.severity, Severity::Error);
2188        assert_eq!(
2189            diag.primary.file,
2190            loaded.sources.file_id(&path_string(&bad_path), bad_text),
2191            "the span points into the member's own manifest",
2192        );
2193        assert_eq!(
2194            diag.primary.range,
2195            byte_range(0, "[workspace]".len()),
2196            "the span is the `[workspace]` section header",
2197        );
2198
2199        let packages = loaded.workspace.packages(&db).clone();
2200        let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
2201        assert_eq!(names, vec!["veh.good"], "the nested member loads nothing");
2202    }
2203
2204    /// A workspace member path with no manifest is MANI-008 and skipped; the
2205    /// rest of the workspace still loads.
2206    #[test]
2207    fn mani_008_on_a_missing_member_directory() {
2208        let dir = TempDir::new("missing-member");
2209        dir.write(
2210            "ridl.toml",
2211            "[workspace]\nmembers = [\"m-gone\", \"m-good\"]\n",
2212        );
2213        dir.write(
2214            "m-good/ridl.toml",
2215            "[package]\nname = \"veh.good\"\nversion = \"1.0.0\"\n",
2216        );
2217        dir.write("m-good/good.typl", "package veh.good\ntype A: m\n");
2218
2219        let mut db = RidlDatabase::default();
2220        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
2221        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-008"]);
2222        assert_eq!(loaded.diagnostics[0].severity, Severity::Error);
2223        assert!(loaded.diagnostics[0].message.contains("m-gone"));
2224
2225        let packages = loaded.workspace.packages(&db).clone();
2226        assert_eq!(packages.len(), 1);
2227        assert_eq!(packages[0].name(&db).as_str(), "veh.good");
2228    }
2229
2230    /// A subdirectory of a package root is its own package, named by its
2231    /// directory path relative to the manifest root (ADR-0002 §1); every
2232    /// package in the tree carries the governing manifest's `[imports]`.
2233    #[test]
2234    fn a_subdirectory_is_its_own_package_named_by_its_path() {
2235        let dir = TempDir::new("subdir");
2236        dir.write(
2237            "ridl.toml",
2238            "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n[imports]\n\"some.dep\" = \"https://registry.example.com/some/dep@v1.0.0\"\n",
2239        );
2240        dir.write("a.typl", "package veh.common\ntype A: m\n");
2241        dir.write("types/t.typl", "package veh.common.types\ntype T: s\n");
2242
2243        let mut db = RidlDatabase::default();
2244        let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
2245        assert_eq!(loaded.diagnostics, Vec::new());
2246
2247        let packages = loaded.workspace.packages(&db).clone();
2248        let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
2249        assert_eq!(names, vec!["veh.common", "veh.common.types"]);
2250        for package in &packages {
2251            assert_eq!(
2252                package.imports(&db).get("some.dep").map(String::as_str),
2253                Some("https://registry.example.com/some/dep@v1.0.0"),
2254                "every package in the manifest's tree carries its `[imports]`",
2255            );
2256        }
2257        assert_eq!(
2258            loaded.workspace.imports(&db),
2259            &BTreeMap::new(),
2260            "a standalone load leaves the workspace map empty",
2261        );
2262    }
2263
2264    /// A package directory may mix `.typl` and `.ridl`
2265    /// files — `.ridl` is accepted everywhere `.typl` is, under the same
2266    /// package↔directory law.
2267    #[test]
2268    fn a_package_directory_mixes_typl_and_ridl_files() {
2269        let dir = TempDir::new("mixed");
2270        dir.write("ridl.toml", PACKAGE_MANIFEST);
2271        dir.write("a.typl", "package veh.common\ntype A: m\n");
2272        dir.write("b.ridl", "package veh.common\ntype B: s\n");
2273
2274        let mut db = RidlDatabase::default();
2275        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
2276        assert_eq!(loaded.diagnostics, Vec::new(), "a clean mixed package");
2277
2278        let packages = loaded.workspace.packages(&db).clone();
2279        assert_eq!(packages.len(), 1, "one package directory, one package");
2280        let files = packages[0].files(&db).clone();
2281        assert_eq!(files.len(), 2, "both the .typl and the .ridl file load");
2282        for file in &files {
2283            assert_eq!(
2284                parse_file(&db, *file).errors(),
2285                &[],
2286                "both files parse clean"
2287            );
2288        }
2289        assert!(files.iter().any(|f| f.path(&db).ends_with("a.typl")));
2290        assert!(files.iter().any(|f| f.path(&db).ends_with("b.ridl")));
2291    }
2292
2293    /// A package directory loads its `.rsdl` files beside its `.typl` and
2294    /// `.ridl` files (rsdl reference §2: a package may hold all three), each
2295    /// parsed under the profile its extension selects.
2296    #[test]
2297    fn a_package_directory_loads_its_rsdl_files() {
2298        let dir = TempDir::new("rsdl");
2299        dir.write("ridl.toml", PACKAGE_MANIFEST);
2300        dir.write("a.typl", "package veh.common\ntype A: m\n");
2301        dir.write("b.ridl", "package veh.common\ntype B: s\n");
2302        dir.write("c.rsdl", "package veh.common\n");
2303
2304        let mut db = RidlDatabase::default();
2305        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
2306        assert_eq!(loaded.diagnostics, Vec::new(), "a clean mixed package");
2307
2308        let packages = loaded.workspace.packages(&db).clone();
2309        assert_eq!(packages.len(), 1, "one package directory, one package");
2310        let files = packages[0].files(&db).clone();
2311        let paths: Vec<&str> = files.iter().map(|f| f.path(&db).as_str()).collect();
2312        assert_eq!(files.len(), 3, "the .rsdl file loads too: {paths:?}");
2313        let rsdl = files
2314            .iter()
2315            .find(|f| f.path(&db).ends_with("c.rsdl"))
2316            .expect("the .rsdl file is a package file");
2317        assert_eq!(
2318            crate::db::profile_of_path(rsdl.path(&db)),
2319            ridl_syntax::Profile::Rsdl
2320        );
2321        assert_eq!(
2322            parse_file(&db, *rsdl).errors(),
2323            &[],
2324            "the .rsdl file parses clean"
2325        );
2326    }
2327
2328    /// Single-file mode accepts a bare `.ridl` entry, like a bare `.typl`.
2329    #[test]
2330    fn single_file_mode_accepts_a_bare_ridl_entry() {
2331        let dir = TempDir::new("single-ridl");
2332        let path = dir.write("iface.ridl", "package veh.iface\ntype A: m\n");
2333
2334        let mut db = RidlDatabase::default();
2335        let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
2336        assert_eq!(loaded.diagnostics, Vec::new(), "exempt from TYPL-002");
2337
2338        let packages = loaded.workspace.packages(&db).clone();
2339        assert_eq!(packages.len(), 1, "one synthetic package");
2340        assert_eq!(
2341            packages[0].name(&db).as_str(),
2342            "veh.iface",
2343            "named from the file's declared package",
2344        );
2345    }
2346
2347    #[test]
2348    fn every_package_of_a_tree_carries_the_manifest_name_as_its_unit() {
2349        let dir = TempDir::new("unit-tree");
2350        dir.write(
2351            "ridl.toml",
2352            "[package]\nname = \"veh.hmi\"\nversion = \"1.0.0\"\n",
2353        );
2354        dir.write("hmi.ridl", "package veh.hmi\ntype A: m\n");
2355        dir.write("cluster/speed.ridl", "package veh.hmi.cluster\ntype S: m\n");
2356
2357        let mut db = RidlDatabase::default();
2358        let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
2359        assert_eq!(loaded.diagnostics, Vec::new());
2360
2361        let packages = loaded.workspace.packages(&db).clone();
2362        assert_eq!(packages.len(), 2);
2363        for package in &packages {
2364            assert_eq!(package.unit(&db).as_str(), "veh.hmi");
2365        }
2366        assert_eq!(
2367            loaded.units,
2368            BTreeMap::from([("veh.hmi".to_string(), dir.path().to_path_buf())]),
2369        );
2370    }
2371
2372    #[test]
2373    fn a_workspace_member_is_a_unit_and_the_root_is_not() {
2374        let dir = TempDir::new("unit-members");
2375        dir.write("ridl.toml", "[workspace]\nmembers = [\"a\", \"b\"]\n");
2376        dir.write(
2377            "a/ridl.toml",
2378            "[package]\nname = \"x.a\"\nversion = \"1.0.0\"\n",
2379        );
2380        dir.write("a/a.ridl", "package x.a\ntype A: m\n");
2381        dir.write(
2382            "b/ridl.toml",
2383            "[package]\nname = \"x.b\"\nversion = \"1.0.0\"\n",
2384        );
2385        dir.write("b/b.ridl", "package x.b\ntype B: m\n");
2386
2387        let mut db = RidlDatabase::default();
2388        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
2389        assert_eq!(loaded.diagnostics, Vec::new());
2390
2391        assert_eq!(
2392            loaded.units,
2393            BTreeMap::from([
2394                ("x.a".to_string(), dir.path().join("a")),
2395                ("x.b".to_string(), dir.path().join("b")),
2396            ]),
2397        );
2398        let units: Vec<String> = loaded
2399            .workspace
2400            .packages(&db)
2401            .iter()
2402            .map(|p| p.unit(&db).clone())
2403            .collect();
2404        assert_eq!(units, vec!["x.a", "x.b"]);
2405    }
2406
2407    #[test]
2408    fn a_single_file_is_its_own_unit() {
2409        let dir = TempDir::new("unit-single");
2410        let path = dir.write("p.ridl", "package p\ntype A: m\n");
2411
2412        let mut db = RidlDatabase::default();
2413        let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
2414        assert_eq!(loaded.diagnostics, Vec::new());
2415
2416        let packages = loaded.workspace.packages(&db).clone();
2417        assert_eq!(packages.len(), 1);
2418        assert_eq!(packages[0].unit(&db).as_str(), "p");
2419        assert_eq!(
2420            loaded.units,
2421            BTreeMap::from([("p".to_string(), dir.path().to_path_buf())]),
2422        );
2423    }
2424
2425    /// A symlinked directory is not followed by the tree walk — following it
2426    /// could revisit the tree in a cycle and duplicate packages endlessly.
2427    #[cfg(unix)]
2428    #[test]
2429    fn a_symlinked_directory_is_not_followed() {
2430        let dir = TempDir::new("symlink");
2431        dir.write("ridl.toml", PACKAGE_MANIFEST);
2432        dir.write("a.typl", "package veh.common\ntype A: m\n");
2433        // A symlink pointing back at the package root: a cycle.
2434        std::os::unix::fs::symlink(dir.path(), dir.path().join("loop"))
2435            .expect("create the directory symlink");
2436
2437        let mut db = RidlDatabase::default();
2438        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
2439        assert_eq!(loaded.diagnostics, Vec::new());
2440
2441        let packages = loaded.workspace.packages(&db).clone();
2442        assert_eq!(packages.len(), 1, "the symlink cycle adds no packages");
2443        assert_eq!(packages[0].name(&db).as_str(), "veh.common");
2444    }
2445
2446    /// A `.typl` file that is not valid UTF-8 becomes a diagnostic and is
2447    /// skipped; the rest of the package still loads (ADR-0004 §5 — never a
2448    /// hard error for content problems).
2449    #[test]
2450    fn a_non_utf8_file_is_reported_and_skipped() {
2451        let dir = TempDir::new("non-utf8");
2452        dir.write("ridl.toml", PACKAGE_MANIFEST);
2453        dir.write("a.typl", "package veh.common\ntype A: m\n");
2454        fs::write(dir.path().join("bad.typl"), [0xFF, 0xFE, 0x00, 0x9F])
2455            .expect("write the non-UTF8 fixture");
2456
2457        let mut db = RidlDatabase::default();
2458        let loaded = load_workspace(&mut db, dir.path()).expect("the load continues");
2459        assert_eq!(loaded.diagnostics.len(), 1);
2460        assert!(
2461            loaded.diagnostics[0].message.contains("UTF-8"),
2462            "the diagnostic names the encoding problem",
2463        );
2464
2465        let packages = loaded.workspace.packages(&db).clone();
2466        assert_eq!(packages.len(), 1);
2467        let files = packages[0].files(&db).clone();
2468        assert_eq!(files.len(), 1, "only the valid file loads");
2469        assert!(files[0].path(&db).ends_with("a.typl"));
2470    }
2471
2472    /// (g) Single-file mode: a bare `.typl` file with no manifest up the tree
2473    /// loads as one synthetic package named from its declared package, exempt
2474    /// from TYPL-002. The E0 walking-skeleton fixture is the contract input.
2475    #[test]
2476    fn single_file_mode_loads_the_e0_fixture() {
2477        let fixture = concat!(
2478            env!("CARGO_MANIFEST_DIR"),
2479            "/../ridl-syntax/fixtures/walking_skeleton.typl",
2480        );
2481        let text = fs::read_to_string(fixture).expect("the E0 fixture exists");
2482        assert!(
2483            text.contains("package fixtures"),
2484            "the fixture declares `package fixtures`",
2485        );
2486
2487        // Copied into an empty temp dir so no manifest exists up the tree —
2488        // and the directory name never matches the declared package, which
2489        // proves the TYPL-002 exemption.
2490        let dir = TempDir::new("single-file");
2491        let path = dir.write("walking_skeleton.typl", &text);
2492
2493        let mut db = RidlDatabase::default();
2494        let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
2495        assert_eq!(loaded.diagnostics, Vec::new(), "exempt from TYPL-002");
2496
2497        let packages = loaded.workspace.packages(&db).clone();
2498        assert_eq!(packages.len(), 1, "one synthetic package");
2499        assert_eq!(
2500            packages[0].name(&db).as_str(),
2501            "fixtures",
2502            "named from the file's declared package",
2503        );
2504        assert_eq!(*packages[0].origin(&db), PackageOrigin::WorkspaceMember);
2505
2506        let files = packages[0].files(&db).clone();
2507        assert_eq!(files.len(), 1);
2508        assert_eq!(
2509            parse_file(&db, files[0]).errors(),
2510            &[],
2511            "the fixture parses clean"
2512        );
2513    }
2514
2515    // --- the interface lock (lock design §2, §8) --------------------------
2516
2517    const LOCK_TEXT: &str = "\
2518# interfaces.lock — written by ridl lock; do not edit by hand.
2519next 3
2520Cabin 1
2521service:veh.common.climate 2
2522";
2523
2524    /// A well-formed `interfaces.lock` beside the sources rides on the
2525    /// package: its path, its text and the parsed table (the text and path
2526    /// travel so a later checker diagnostic can point into the file).
2527    #[test]
2528    fn a_lock_beside_the_sources_rides_on_the_package() {
2529        let dir = TempDir::new("lock");
2530        dir.write("ridl.toml", PACKAGE_MANIFEST);
2531        dir.write("a.ridl", "package veh.common\ntype A: m\n");
2532        let lock_path = dir.write("interfaces.lock", LOCK_TEXT);
2533
2534        let mut db = RidlDatabase::default();
2535        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
2536        assert_eq!(
2537            loaded.diagnostics,
2538            Vec::new(),
2539            "a well-formed lock draws nothing"
2540        );
2541
2542        let packages = loaded.workspace.packages(&db).clone();
2543        let lock = packages[0]
2544            .lock(&db)
2545            .as_ref()
2546            .expect("the lock rides on the package");
2547        assert_eq!(lock.path, path_string(&lock_path));
2548        assert_eq!(lock.text, LOCK_TEXT);
2549        assert_eq!(lock.lock.next, 3);
2550        assert_eq!(lock.lock.entries.len(), 2);
2551        assert_eq!(
2552            lock.lock.entries[1].key,
2553            crate::interface_lock::LockKey::Service("veh.common.climate".to_string())
2554        );
2555    }
2556
2557    #[test]
2558    fn a_package_with_no_lock_has_none() {
2559        let dir = TempDir::new("no-lock");
2560        dir.write("ridl.toml", PACKAGE_MANIFEST);
2561        dir.write("a.ridl", "package veh.common\ntype A: m\n");
2562
2563        let mut db = RidlDatabase::default();
2564        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
2565        assert_eq!(loaded.diagnostics, Vec::new());
2566        let packages = loaded.workspace.packages(&db).clone();
2567        assert_eq!(*packages[0].lock(&db), None);
2568    }
2569
2570    /// RIDL-410 is reported on the offending line of the lock file itself,
2571    /// through the loader's source map (PD-3), and the package then carries
2572    /// no lock.
2573    #[test]
2574    fn a_malformed_lock_is_ridl_410_on_its_own_line() {
2575        let dir = TempDir::new("bad-lock");
2576        dir.write("ridl.toml", PACKAGE_MANIFEST);
2577        dir.write("a.ridl", "package veh.common\ntype A: m\n");
2578        let text = "# interfaces.lock — written by ridl lock; do not edit by hand.\n\
2579                    next 2\n\
2580                    Cabin 1\n\
2581                    Door 1\n";
2582        let lock_path = dir.write("interfaces.lock", text);
2583
2584        let mut db = RidlDatabase::default();
2585        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
2586        assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
2587        let diagnostic = &loaded.diagnostics[0];
2588        assert_eq!(diagnostic.severity, Severity::Error);
2589        assert_eq!(
2590            loaded.sources.path(diagnostic.primary.file),
2591            Some(path_string(&lock_path).as_str()),
2592            "the span is in the lock file"
2593        );
2594        assert_eq!(loaded.sources.text(diagnostic.primary.file), Some(text));
2595        let line_start = text
2596            .find("Door 1")
2597            .expect("the offending line is in the text");
2598        assert_eq!(
2599            diagnostic.primary.range,
2600            byte_range(line_start, line_start + "Door 1".len()),
2601            "the span is the offending line"
2602        );
2603        assert_eq!(
2604            diagnostic.message,
2605            "`interfaces.lock` is malformed: number 1 is on two entries: `Cabin` and `Door` — \
2606             resolve the conflict or restore the file from version control, then run `ridl lock`"
2607        );
2608
2609        let packages = loaded.workspace.packages(&db).clone();
2610        assert_eq!(
2611            *packages[0].lock(&db),
2612            None,
2613            "a malformed lock does not ride on the package"
2614        );
2615    }
2616
2617    /// PD-3: an empty lock file, which has no `next` line, is reported at
2618    /// 0..0 of the file.
2619    #[test]
2620    fn an_empty_lock_is_ridl_410_at_the_start_of_the_file() {
2621        let dir = TempDir::new("empty-lock");
2622        dir.write("ridl.toml", PACKAGE_MANIFEST);
2623        dir.write("a.ridl", "package veh.common\ntype A: m\n");
2624        dir.write("interfaces.lock", "");
2625
2626        let mut db = RidlDatabase::default();
2627        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
2628        assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
2629        assert_eq!(loaded.diagnostics[0].primary.range, byte_range(0, 0));
2630        assert!(
2631            loaded.diagnostics[0].message.contains("no `next` line"),
2632            "got: {}",
2633            loaded.diagnostics[0].message
2634        );
2635    }
2636
2637    /// A lock file that is not valid UTF-8 is RIDL-410 at 0..0 too — the
2638    /// treatment the loader gives a source file that is not valid UTF-8.
2639    #[test]
2640    fn a_lock_that_is_not_utf8_is_ridl_410() {
2641        let dir = TempDir::new("binary-lock");
2642        dir.write("ridl.toml", PACKAGE_MANIFEST);
2643        dir.write("a.ridl", "package veh.common\ntype A: m\n");
2644        fs::write(dir.path().join("interfaces.lock"), [0xff, 0xfe, b'\n'])
2645            .expect("write the bytes");
2646
2647        let mut db = RidlDatabase::default();
2648        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
2649        assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
2650        assert!(
2651            loaded.diagnostics[0].message.contains("not valid UTF-8"),
2652            "got: {}",
2653            loaded.diagnostics[0].message
2654        );
2655        assert_eq!(loaded.diagnostics[0].primary.range, byte_range(0, 0));
2656        let packages = loaded.workspace.packages(&db).clone();
2657        assert_eq!(*packages[0].lock(&db), None);
2658    }
2659
2660    /// PD-8: a bare `.ridl` file with no manifest reads `interfaces.lock`
2661    /// from the file's directory.
2662    #[test]
2663    fn single_file_mode_reads_the_lock_beside_the_file() {
2664        let dir = TempDir::new("single-lock");
2665        let path = dir.write("iface.ridl", "package veh.iface\ntype A: m\n");
2666        dir.write("interfaces.lock", LOCK_TEXT);
2667
2668        let mut db = RidlDatabase::default();
2669        let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
2670        assert_eq!(loaded.diagnostics, Vec::new());
2671        let packages = loaded.workspace.packages(&db).clone();
2672        let lock = packages[0]
2673            .lock(&db)
2674            .as_ref()
2675            .expect("the lock rides on the synthetic package");
2676        assert_eq!(lock.lock.next, 3);
2677    }
2678
2679    /// The file is per unit: every package of the unit carries the lock of the
2680    /// manifest directory.
2681    #[test]
2682    fn every_package_of_a_unit_carries_the_lock_of_the_manifest_directory() {
2683        let dir = TempDir::new("unit-lock");
2684        dir.write(
2685            "ridl.toml",
2686            "[package]\nname = \"veh.hmi\"\nversion = \"1.0.0\"\n",
2687        );
2688        dir.write("hmi.ridl", "package veh.hmi\ntype A: m\n");
2689        let lock_path = dir.write("interfaces.lock", "next 1\n");
2690        dir.write("cluster/speed.ridl", "package veh.hmi.cluster\ntype B: m\n");
2691
2692        let mut db = RidlDatabase::default();
2693        let loaded = load_workspace(&mut db, dir.path()).expect("the tree loads");
2694        assert_eq!(loaded.diagnostics, Vec::new());
2695        let packages = loaded.workspace.packages(&db).clone();
2696        assert_eq!(packages.len(), 2);
2697        for package in &packages {
2698            let lock = package
2699                .lock(&db)
2700                .as_ref()
2701                .unwrap_or_else(|| panic!("`{}` has the unit's lock", package.name(&db)));
2702            assert_eq!(lock.path, path_string(&lock_path));
2703        }
2704    }
2705
2706    /// Only the lock beside the manifest is read: a lock in a subdirectory is
2707    /// ignored, even a malformed one, and reported as a warning.
2708    #[test]
2709    fn a_lock_in_a_subdirectory_is_not_read() {
2710        let dir = TempDir::new("subdir-lock-ignored");
2711        dir.write(
2712            "ridl.toml",
2713            "[package]\nname = \"veh.hmi\"\nversion = \"1.0.0\"\n",
2714        );
2715        dir.write("hmi.ridl", "package veh.hmi\ntype A: m\n");
2716        dir.write("cluster/speed.ridl", "package veh.hmi.cluster\ntype B: m\n");
2717        let ignored = dir.write("cluster/interfaces.lock", "x");
2718
2719        let mut db = RidlDatabase::default();
2720        let loaded = load_workspace(&mut db, dir.path()).expect("the tree loads");
2721        assert_eq!(codes(&loaded.diagnostics), vec!["RIDL-416"]);
2722        let diag = &loaded.diagnostics[0];
2723        assert_eq!(diag.severity, Severity::Warning);
2724        assert_eq!(
2725            loaded.sources.path(diag.primary.file),
2726            Some(path_string(&ignored).as_str())
2727        );
2728        assert_eq!(diag.primary.range, byte_range(0, 0));
2729        assert!(diag.message.contains("unit `veh.hmi`"), "{}", diag.message);
2730        let packages = loaded.workspace.packages(&db).clone();
2731        assert_eq!(packages.len(), 2);
2732        for package in &packages {
2733            assert_eq!(*package.lock(&db), None);
2734        }
2735    }
2736
2737    /// The paths of the files the loaded packages compile, as strings.
2738    fn compiled_paths(db: &RidlDatabase, loaded: &LoadedWorkspace) -> Vec<String> {
2739        loaded
2740            .workspace
2741            .packages(db)
2742            .iter()
2743            .flat_map(|package| package.files(db).iter().map(|file| file.path(db).clone()))
2744            .collect()
2745    }
2746
2747    /// A `.rxdl` file in a package directory draws one RIDL-417 warning per
2748    /// file, anchored on the file, and is not compiled.
2749    #[test]
2750    fn a_rxdl_file_is_reported_and_not_compiled() {
2751        let dir = TempDir::new("rxdl-on-disk");
2752        dir.write(
2753            "ridl.toml",
2754            "[package]\nname = \"veh.hmi\"\nversion = \"1.0.0\"\n",
2755        );
2756        dir.write("hmi.ridl", "package veh.hmi\ntype A: m\n");
2757        let top = dir.write("hmi.rxdl", "package veh.hmi\ntype B: m\n");
2758        let beside = dir.write("menu.rxdl", "package veh.hmi\ntype C: m\n");
2759        let nested = dir.write("cluster/speed.rxdl", "package veh.hmi.cluster\n");
2760
2761        let mut db = RidlDatabase::default();
2762        let loaded = load_workspace(&mut db, dir.path()).expect("the tree loads");
2763        assert_eq!(
2764            codes(&loaded.diagnostics),
2765            vec!["RIDL-417", "RIDL-417", "RIDL-417"]
2766        );
2767        let mut anchored: Vec<_> = loaded
2768            .diagnostics
2769            .iter()
2770            .map(|diag| {
2771                assert_eq!(diag.severity, Severity::Warning);
2772                assert_eq!(diag.primary.range, byte_range(0, 0));
2773                loaded.sources.path(diag.primary.file).map(str::to_string)
2774            })
2775            .collect();
2776        anchored.sort();
2777        let mut expected = vec![
2778            Some(path_string(&nested)),
2779            Some(path_string(&top)),
2780            Some(path_string(&beside)),
2781        ];
2782        expected.sort();
2783        assert_eq!(anchored, expected);
2784        assert_eq!(
2785            compiled_paths(&db, &loaded),
2786            vec![path_string(&dir.path().join("hmi.ridl"))]
2787        );
2788    }
2789
2790    /// An overlay keyed to a `.rxdl` path draws the same warning, once for
2791    /// the file whether or not the file is on disk, and is not compiled.
2792    #[test]
2793    fn a_rxdl_overlay_is_reported_and_not_compiled() {
2794        let (dir, typl) = overlay_fixture();
2795        let on_disk = dir.write("p/b.rxdl", "package p\n");
2796        let added = dir.path().join("p/c.rxdl");
2797        let mut db = RidlDatabase::default();
2798        let result = load_workspace_with(
2799            &mut db,
2800            &dir.path().join("p"),
2801            &[
2802                overlay(on_disk.clone(), "package p\n"),
2803                overlay(added.clone(), "package p\n"),
2804            ],
2805        );
2806        assert!(
2807            result.is_ok(),
2808            "a `.rxdl` overlay is accepted: {:?}",
2809            result.err()
2810        );
2811        let loaded = result.unwrap();
2812        assert_eq!(codes(&loaded.diagnostics), vec!["RIDL-417", "RIDL-417"]);
2813        let anchored: Vec<_> = loaded
2814            .diagnostics
2815            .iter()
2816            .map(|diag| loaded.sources.path(diag.primary.file).map(str::to_string))
2817            .collect();
2818        assert_eq!(
2819            anchored,
2820            vec![Some(path_string(&on_disk)), Some(path_string(&added))]
2821        );
2822        assert_eq!(compiled_paths(&db, &loaded), vec![path_string(&typl)]);
2823    }
2824
2825    /// A `.rmdl` file in a package directory, on disk or as an overlay, draws
2826    /// RIDL-417 on the file, as a `.rxdl` file does, and is not compiled.
2827    #[test]
2828    fn a_rmdl_file_is_reported_and_not_compiled() {
2829        let (dir, typl) = overlay_fixture();
2830        let on_disk = dir.write("p/b.rmdl", "package p\n");
2831        let added = dir.path().join("p/c.rmdl");
2832        let mut db = RidlDatabase::default();
2833        let loaded = load_workspace_with(
2834            &mut db,
2835            &dir.path().join("p"),
2836            &[overlay(added.clone(), "package p\n")],
2837        )
2838        .expect("a `.rmdl` overlay is accepted");
2839        assert_eq!(codes(&loaded.diagnostics), vec!["RIDL-417", "RIDL-417"]);
2840        let anchored: Vec<_> = loaded
2841            .diagnostics
2842            .iter()
2843            .map(|diag| {
2844                assert_eq!(diag.severity, Severity::Warning);
2845                assert!(
2846                    diag.message.contains("is a `.rmdl` file; the rmdl profile"),
2847                    "{}",
2848                    diag.message
2849                );
2850                loaded.sources.path(diag.primary.file).map(str::to_string)
2851            })
2852            .collect();
2853        assert_eq!(
2854            anchored,
2855            vec![Some(path_string(&on_disk)), Some(path_string(&added))]
2856        );
2857        assert_eq!(compiled_paths(&db, &loaded), vec![path_string(&typl)]);
2858    }
2859
2860    /// The RIDL-417 span of a `.rxdl` overlay points into the overlay text,
2861    /// not into the text on disk.
2862    #[test]
2863    fn a_rxdl_overlay_interns_the_overlay_text() {
2864        let (dir, _) = overlay_fixture();
2865        let on_disk = dir.write("p/b.rxdl", "package p\n");
2866        let text = "package p\n// unsaved\n";
2867        let mut db = RidlDatabase::default();
2868        let loaded = load_workspace_with(
2869            &mut db,
2870            &dir.path().join("p"),
2871            &[overlay(on_disk.clone(), text)],
2872        )
2873        .expect("the overlay is accepted");
2874        assert_eq!(codes(&loaded.diagnostics), vec!["RIDL-417"]);
2875        assert_eq!(
2876            loaded.sources.text(loaded.diagnostics[0].primary.file),
2877            Some(text)
2878        );
2879    }
2880
2881    /// Without an overlay the RIDL-417 span is interned with no text: the
2882    /// span is empty, so the file on disk is not read.
2883    #[test]
2884    fn a_rxdl_file_on_disk_interns_no_text() {
2885        let (dir, _) = overlay_fixture();
2886        dir.write("p/b.rxdl", "package p\n");
2887        let mut db = RidlDatabase::default();
2888        let loaded = load_workspace(&mut db, &dir.path().join("p")).expect("the tree loads");
2889        assert_eq!(codes(&loaded.diagnostics), vec!["RIDL-417"]);
2890        assert_eq!(
2891            loaded.sources.text(loaded.diagnostics[0].primary.file),
2892            Some("")
2893        );
2894    }
2895
2896    /// A `.rxdl` or `.rmdl` file given as the entry inside a package
2897    /// directory is not a load error: the package loads, and the file draws
2898    /// RIDL-417 as the other files of the directory do.
2899    #[test]
2900    fn a_rxdl_or_rmdl_entry_in_a_package_directory_is_a_warning() {
2901        for extension in ["rxdl", "rmdl"] {
2902            let (dir, typl) = overlay_fixture();
2903            let entry = dir.write(&format!("p/b.{extension}"), "package p\n");
2904            let mut db = RidlDatabase::default();
2905            let loaded = load_workspace(&mut db, &entry).expect("the package loads");
2906            assert_eq!(codes(&loaded.diagnostics), vec!["RIDL-417"]);
2907            assert_eq!(
2908                loaded.sources.path(loaded.diagnostics[0].primary.file),
2909                Some(path_string(&entry).as_str())
2910            );
2911            assert_eq!(compiled_paths(&db, &loaded), vec![path_string(&typl)]);
2912        }
2913    }
2914
2915    /// RIDL-417 is reported in path order for the files of one directory,
2916    /// whatever order the files are found in. The new files come from
2917    /// overlays given in reverse name order, which the loader appends in the
2918    /// order given, so the found order does not depend on the filesystem.
2919    #[test]
2920    fn ridl_417_is_reported_in_path_order() {
2921        let (dir, _) = overlay_fixture();
2922        let z = dir.write("p/z.rxdl", "package p\n");
2923        let y = dir.write("p/y.rmdl", "package p\n");
2924        let c = dir.path().join("p/c.rxdl");
2925        let b = dir.path().join("p/b.rmdl");
2926        let a = dir.path().join("p/a.rxdl");
2927        let mut db = RidlDatabase::default();
2928        let loaded = load_workspace_with(
2929            &mut db,
2930            &dir.path().join("p"),
2931            &[
2932                overlay(c.clone(), "package p\n"),
2933                overlay(b.clone(), "package p\n"),
2934                overlay(a.clone(), "package p\n"),
2935            ],
2936        )
2937        .expect("the overlays are accepted");
2938        let anchored: Vec<_> = loaded
2939            .diagnostics
2940            .iter()
2941            .map(|diag| loaded.sources.path(diag.primary.file).map(str::to_string))
2942            .collect();
2943        assert_eq!(
2944            anchored,
2945            [&a, &b, &c, &y, &z]
2946                .map(|path| Some(path_string(path)))
2947                .to_vec()
2948        );
2949    }
2950
2951    /// A lone `.rxdl` or `.rmdl` entry in single-file mode is a load error
2952    /// that names the extension: the profile has no implementation, so there
2953    /// is nothing to compile, and a warning would let a command report
2954    /// success over an empty build.
2955    #[test]
2956    fn a_single_rxdl_or_rmdl_file_is_a_load_error() {
2957        for extension in ["rxdl", "rmdl"] {
2958            let dir = TempDir::new("unsupported-single");
2959            let path = dir.write(&format!("hmi.{extension}"), "package veh.hmi\n");
2960
2961            let mut db = RidlDatabase::default();
2962            let error = load_workspace(&mut db, &path)
2963                .err()
2964                .unwrap_or_else(|| panic!("a lone `.{extension}` entry is a load error"));
2965            assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
2966            let message = error.to_string();
2967            assert!(
2968                message.contains(&format!("`.{extension}` is not a supported")),
2969                "{message}"
2970            );
2971        }
2972    }
2973
2974    /// An overlay of a lone `.rxdl` or `.rmdl` entry does not change that:
2975    /// the entry is still a load error.
2976    #[test]
2977    fn a_single_rxdl_or_rmdl_overlay_is_a_load_error() {
2978        for extension in ["rxdl", "rmdl"] {
2979            let dir = TempDir::new("unsupported-single-overlay");
2980            let path = dir.write(&format!("hmi.{extension}"), "package veh.hmi\n");
2981
2982            let mut db = RidlDatabase::default();
2983            let result = load_workspace_with(
2984                &mut db,
2985                &path,
2986                &[overlay(path.clone(), "package veh.hmi\ntype B: m\n")],
2987            );
2988            let Err(LoadError::Io(error)) = result else {
2989                panic!("a lone `.{extension}` entry with an overlay is a load error");
2990            };
2991            assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
2992            assert!(
2993                error
2994                    .to_string()
2995                    .contains(&format!("`.{extension}` is not a supported")),
2996                "{error}"
2997            );
2998        }
2999    }
3000
3001    // Root discovery from a member (ADR-0002 §4, issue #529).
3002
3003    const PACKAGE_A: &str = "[package]\nname = \"a\"\nversion = \"1.0.0\"\n";
3004    const PACKAGE_B: &str = "[package]\nname = \"b\"\nversion = \"1.0.0\"\n";
3005
3006    #[test]
3007    fn find_root_walks_from_a_member_to_its_workspace() {
3008        let dir = TempDir::new("find-root-member");
3009        dir.write("ridl.toml", "[workspace]\nmembers = [\"a\", \"b\"]\n");
3010        dir.write("a/ridl.toml", PACKAGE_A);
3011        dir.write("a/src/a.typl", "package a.src\n");
3012        dir.write("b/ridl.toml", PACKAGE_B);
3013
3014        assert_eq!(
3015            find_root(&dir.path().join("a/src")),
3016            Some(dir.path().to_path_buf())
3017        );
3018        assert_eq!(
3019            find_root(&dir.path().join("a")),
3020            Some(dir.path().to_path_buf())
3021        );
3022    }
3023
3024    /// A member listed as `./a/` still names the package directory `a`.
3025    #[test]
3026    fn find_root_normalises_the_member_path() {
3027        let dir = TempDir::new("find-root-normalise");
3028        dir.write("ridl.toml", "[workspace]\nmembers = [\"./a/\"]\n");
3029        dir.write("a/ridl.toml", PACKAGE_A);
3030
3031        assert_eq!(
3032            find_root(&dir.path().join("a")),
3033            Some(dir.path().to_path_buf())
3034        );
3035    }
3036
3037    #[test]
3038    fn find_root_keeps_an_unlisted_package_standalone() {
3039        let dir = TempDir::new("find-root-unlisted");
3040        dir.write("ridl.toml", "[workspace]\nmembers = [\"b\"]\n");
3041        dir.write("a/ridl.toml", PACKAGE_A);
3042        dir.write("b/ridl.toml", PACKAGE_B);
3043
3044        assert_eq!(find_root(&dir.path().join("a")), Some(dir.path().join("a")));
3045    }
3046
3047    #[test]
3048    fn find_root_stops_at_the_first_workspace() {
3049        let dir = TempDir::new("find-root-first-workspace");
3050        dir.write("ridl.toml", "[workspace]\nmembers = [\"inner/a\"]\n");
3051        dir.write("inner/ridl.toml", "[workspace]\nmembers = [\"b\"]\n");
3052        dir.write("inner/a/ridl.toml", PACKAGE_A);
3053
3054        assert_eq!(
3055            find_root(&dir.path().join("inner/a")),
3056            Some(dir.path().join("inner/a"))
3057        );
3058    }
3059
3060    #[test]
3061    fn find_root_stops_at_a_git_directory() {
3062        let dir = TempDir::new("find-root-git");
3063        dir.write("ridl.toml", "[workspace]\nmembers = [\"repo/a\"]\n");
3064        fs::create_dir_all(dir.path().join("repo/.git")).expect("create .git");
3065        dir.write("repo/a/ridl.toml", PACKAGE_A);
3066
3067        assert_eq!(
3068            find_root(&dir.path().join("repo/a")),
3069            Some(dir.path().join("repo/a"))
3070        );
3071    }
3072
3073    /// The directory that holds `.git` is still checked for its own
3074    /// `ridl.toml` before the walk stops.
3075    #[test]
3076    fn find_root_checks_the_manifest_beside_a_git_directory() {
3077        let dir = TempDir::new("find-root-git-root");
3078        dir.write("ridl.toml", "[workspace]\nmembers = [\"a\"]\n");
3079        fs::create_dir_all(dir.path().join(".git")).expect("create .git");
3080        dir.write("a/ridl.toml", PACKAGE_A);
3081
3082        assert_eq!(
3083            find_root(&dir.path().join("a")),
3084            Some(dir.path().to_path_buf())
3085        );
3086    }
3087
3088    /// A manifest above the package that cannot be read stops the walk and is
3089    /// returned, so the loader reports why.
3090    #[test]
3091    fn find_root_returns_an_unreadable_manifest_above_the_package() {
3092        let dir = TempDir::new("find-root-unreadable");
3093        fs::write(dir.path().join("ridl.toml"), [0xff, 0xfe]).expect("write the manifest");
3094        dir.write("a/ridl.toml", PACKAGE_A);
3095
3096        assert_eq!(
3097            find_root(&dir.path().join("a")),
3098            Some(dir.path().to_path_buf())
3099        );
3100    }
3101
3102    /// A relative entry inside the current directory's member reaches the
3103    /// workspace above the current directory, and the root keeps the
3104    /// relative form.
3105    #[test]
3106    fn up_keeps_the_relative_form_of_the_entry() {
3107        assert_eq!(up(Path::new("members/a"), 2), PathBuf::from("."));
3108        assert_eq!(up(Path::new("a"), 1), PathBuf::from("."));
3109        assert_eq!(up(Path::new("a"), 2), PathBuf::from(".."));
3110        assert_eq!(up(Path::new("."), 2), PathBuf::from("../.."));
3111        assert_eq!(up(Path::new(""), 1), PathBuf::from(".."));
3112        assert_eq!(up(Path::new("a/.."), 1), PathBuf::from("a/../.."));
3113        assert_eq!(up(Path::new("/w/a"), 1), PathBuf::from("/w"));
3114    }
3115
3116    #[test]
3117    fn member_entry_sets_report_scope() {
3118        let dir = TempDir::new("report-scope");
3119        dir.write("ridl.toml", "[workspace]\nmembers = [\"a\", \"b\"]\n");
3120        dir.write("a/ridl.toml", PACKAGE_A);
3121        dir.write("a/a.typl", "package a\ntype A: integer [0..1]\n");
3122        dir.write("b/ridl.toml", PACKAGE_B);
3123        let b_file = dir.write("b/b.typl", "package b\ntype B: integer [0..1]\n");
3124
3125        let mut db = RidlDatabase::default();
3126        let loaded = load_workspace(&mut db, &dir.path().join("a")).expect("the workspace loads");
3127        assert_eq!(loaded.report_scope, Some(dir.path().join("a")));
3128        let mut names: Vec<_> = loaded
3129            .workspace
3130            .packages(&db)
3131            .iter()
3132            .map(|p| p.name(&db).clone())
3133            .collect();
3134        names.sort();
3135        assert_eq!(names, vec!["a", "b"], "both members compile");
3136
3137        let mut db = RidlDatabase::default();
3138        let loaded = load_workspace(&mut db, &b_file).expect("the workspace loads");
3139        assert_eq!(loaded.report_scope, Some(dir.path().join("b")));
3140
3141        let mut db = RidlDatabase::default();
3142        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
3143        assert_eq!(loaded.report_scope, None, "the root reports on everything");
3144    }
3145
3146    #[test]
3147    fn codegen_header_is_read_and_normalised() {
3148        let dir = TempDir::new("codegen-header");
3149        dir.write(
3150            "ridl.toml",
3151            &format!("{PACKAGE_A}\n[codegen]\nheader-file = \"H.txt\"\n"),
3152        );
3153        dir.write("a.typl", "package a\ntype A: integer [0..1]\n");
3154        dir.write("H.txt", "SPDX-License-Identifier: MIT\r\n");
3155        let mut db = RidlDatabase::default();
3156        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
3157        assert!(loaded.diagnostics.is_empty(), "{:?}", loaded.diagnostics);
3158        assert_eq!(
3159            loaded.codegen_header.as_deref(),
3160            Some("SPDX-License-Identifier: MIT")
3161        );
3162    }
3163
3164    #[test]
3165    fn codegen_header_file_missing_is_mani_011() {
3166        let dir = TempDir::new("codegen-header-missing");
3167        let manifest = format!("{PACKAGE_A}\n[codegen]\nheader-file = \"nope.txt\"\n");
3168        dir.write("ridl.toml", &manifest);
3169        dir.write("a.typl", "package a\ntype A: integer [0..1]\n");
3170        let mut db = RidlDatabase::default();
3171        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
3172        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-011"]);
3173        let diag = &loaded.diagnostics[0];
3174        let start = usize::from(diag.primary.range.start());
3175        let end = usize::from(diag.primary.range.end());
3176        assert_eq!(&manifest[start..end], "\"nope.txt\"");
3177        let resolved = dir.path().join("nope.txt");
3178        assert!(
3179            diag.message.contains(&resolved.display().to_string()),
3180            "{}",
3181            diag.message
3182        );
3183        assert_eq!(loaded.codegen_header, None);
3184    }
3185
3186    #[test]
3187    fn codegen_header_file_that_is_not_utf8_is_mani_011() {
3188        let dir = TempDir::new("codegen-header-not-utf8");
3189        dir.write(
3190            "ridl.toml",
3191            &format!("{PACKAGE_A}\n[codegen]\nheader-file = \"H.txt\"\n"),
3192        );
3193        dir.write("a.typl", "package a\ntype A: integer [0..1]\n");
3194        fs::write(dir.path().join("H.txt"), [0xffu8, 0xfe]).expect("write the header");
3195        let mut db = RidlDatabase::default();
3196        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
3197        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-011"]);
3198        assert!(
3199            loaded.diagnostics[0].message.contains("H.txt"),
3200            "{}",
3201            loaded.diagnostics[0].message
3202        );
3203        assert_eq!(loaded.codegen_header, None);
3204    }
3205
3206    #[test]
3207    fn codegen_header_file_with_a_control_character_is_mani_011() {
3208        let dir = TempDir::new("codegen-header-control");
3209        dir.write(
3210            "ridl.toml",
3211            &format!("{PACKAGE_A}\n[codegen]\nheader-file = \"H.txt\"\n"),
3212        );
3213        dir.write("a.typl", "package a\ntype A: integer [0..1]\n");
3214        dir.write("H.txt", "A\u{2028}B\u{0}\n");
3215        let mut db = RidlDatabase::default();
3216        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
3217        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-011"]);
3218        let diag = &loaded.diagnostics[0];
3219        let manifest = fs::read_to_string(dir.path().join("ridl.toml")).unwrap();
3220        let start = usize::from(diag.primary.range.start());
3221        let end = usize::from(diag.primary.range.end());
3222        assert_eq!(&manifest[start..end], "\"H.txt\"");
3223        let message = &diag.message;
3224        assert!(message.contains("control character"), "{message}");
3225        assert!(message.contains("U+2028"), "{message}");
3226        assert!(
3227            message.contains(&dir.path().join("H.txt").display().to_string()),
3228            "{message}"
3229        );
3230        assert_eq!(loaded.codegen_header, None);
3231    }
3232
3233    #[test]
3234    fn codegen_header_file_in_a_member_is_mani_012() {
3235        let dir = TempDir::new("codegen-header-member");
3236        dir.write(
3237            "ridl.toml",
3238            "[workspace]\nmembers = [\"a\"]\n\n[codegen]\nheader-file = \"H.txt\"\n",
3239        );
3240        dir.write("H.txt", "root header\n");
3241        let member = format!("{PACKAGE_A}\n[codegen]\nheader-file = \"M.txt\"\n");
3242        dir.write("a/ridl.toml", &member);
3243        dir.write("a/a.typl", "package a\ntype A: integer [0..1]\n");
3244        let mut db = RidlDatabase::default();
3245        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
3246        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-012"]);
3247        let diag = &loaded.diagnostics[0];
3248        let start = usize::from(diag.primary.range.start());
3249        let end = usize::from(diag.primary.range.end());
3250        assert_eq!(&member[start..end], "\"M.txt\"");
3251        assert_eq!(loaded.codegen_header.as_deref(), Some("root header"));
3252    }
3253
3254    #[test]
3255    fn a_manifest_below_a_unit_is_mani_013_and_its_tree_is_not_loaded() {
3256        let dir = TempDir::new("nested-manifest");
3257        dir.write(
3258            "ridl.toml",
3259            "[package]\nname = \"veh.hmi\"\nversion = \"1.0.0\"\n",
3260        );
3261        dir.write("hmi.ridl", "package veh.hmi\n");
3262        let nested = dir.write(
3263            "cluster/ridl.toml",
3264            "[package]\nname = \"veh.hmi.cluster\"\nversion = \"1.0.0\"\n",
3265        );
3266        dir.write("cluster/x.ridl", "package veh.hmi.cluster\n");
3267        let mut db = RidlDatabase::default();
3268        let loaded = load_workspace(&mut db, dir.path()).expect("the unit loads");
3269        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-013"]);
3270        let diag = &loaded.diagnostics[0];
3271        assert_eq!(
3272            loaded.sources.path(diag.primary.file),
3273            Some(path_string(&nested).as_str())
3274        );
3275        assert_eq!(diag.primary.range, byte_range(0, 0));
3276        assert!(diag.message.contains("unit `veh.hmi`"), "{}", diag.message);
3277        let packages = loaded.workspace.packages(&db);
3278        assert_eq!(packages.len(), 1);
3279        assert_eq!(packages[0].name(&db), "veh.hmi");
3280    }
3281
3282    #[test]
3283    fn a_member_whose_tree_holds_another_member_is_mani_013() {
3284        let dir = TempDir::new("nested-member");
3285        dir.write("ridl.toml", "[workspace]\nmembers = [\"a\", \"a/b\"]\n");
3286        dir.write(
3287            "a/ridl.toml",
3288            "[package]\nname = \"a\"\nversion = \"1.0.0\"\n",
3289        );
3290        dir.write("a/a.typl", "package a\n");
3291        dir.write(
3292            "a/b/ridl.toml",
3293            "[package]\nname = \"b\"\nversion = \"1.0.0\"\n",
3294        );
3295        dir.write("a/b/b.typl", "package b\n");
3296        let mut db = RidlDatabase::default();
3297        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
3298        let count = codes(&loaded.diagnostics)
3299            .iter()
3300            .filter(|c| **c == "MANI-013")
3301            .count();
3302        assert_eq!(count, 1, "{:?}", codes(&loaded.diagnostics));
3303    }
3304
3305    #[test]
3306    fn a_root_package_already_claimed_by_a_sibling_tree_is_mani_014() {
3307        let dir = TempDir::new("claimed-twice");
3308        dir.write("ridl.toml", "[workspace]\nmembers = [\"base\", \"hmi\"]\n");
3309        dir.write(
3310            "base/ridl.toml",
3311            "[package]\nname = \"com.example\"\nversion = \"1.0.0\"\n",
3312        );
3313        dir.write("base/hmi/x.ridl", "package com.example.hmi\n");
3314        let second = "[package]\nname = \"com.example.hmi\"\nversion = \"1.0.0\"\n";
3315        let second_path = dir.write("hmi/ridl.toml", second);
3316        dir.write("hmi/y.ridl", "package com.example.hmi\n");
3317        let mut db = RidlDatabase::default();
3318        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
3319        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-014"]);
3320        let diag = &loaded.diagnostics[0];
3321        assert_eq!(
3322            loaded.sources.path(diag.primary.file),
3323            Some(path_string(&second_path).as_str())
3324        );
3325        let start = usize::from(diag.primary.range.start());
3326        let end = usize::from(diag.primary.range.end());
3327        assert_eq!(&second[start..end], "\"com.example.hmi\"");
3328        let order = format!(
3329            "already declared by unit `com.example` (`{}`); unit `com.example.hmi` declares it too, in `{}`",
3330            dir.path().join("base").display(),
3331            dir.path().join("hmi").display()
3332        );
3333        assert!(diag.message.contains(&order), "{}", diag.message);
3334        let packages = loaded.workspace.packages(&db);
3335        let claimed: Vec<_> = packages
3336            .iter()
3337            .filter(|p| p.name(&db) == "com.example.hmi")
3338            .collect();
3339        assert_eq!(claimed.len(), 1);
3340        assert_eq!(claimed[0].unit(&db), "com.example");
3341    }
3342
3343    /// Two members whose manifests carry one `[package] name`, in the member
3344    /// order `members`: MANI-014 on the manifest of `second`, whose tree is
3345    /// not loaded, while `first` keeps its directory in `units` and its
3346    /// interface in the package.
3347    fn two_members_with_one_name(members: &str, first: &str, second: &str) {
3348        let dir = TempDir::new("one-name-twice");
3349        dir.write("ridl.toml", &format!("[workspace]\nmembers = {members}\n"));
3350        let manifest = "[package]\nname = \"x\"\nversion = \"1.0.0\"\n";
3351        dir.write(&format!("{first}/ridl.toml"), manifest);
3352        dir.write(
3353            &format!("{first}/x.ridl"),
3354            &format!("package x\n\ninterface {}A {{}}\n", first.to_uppercase()),
3355        );
3356        let second_path = dir.write(&format!("{second}/ridl.toml"), manifest);
3357        dir.write(
3358            &format!("{second}/x.ridl"),
3359            &format!("package x\n\ninterface {}A {{}}\n", second.to_uppercase()),
3360        );
3361        let mut db = RidlDatabase::default();
3362        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
3363        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-014"]);
3364        let diag = &loaded.diagnostics[0];
3365        assert_eq!(
3366            loaded.sources.path(diag.primary.file),
3367            Some(path_string(&second_path).as_str())
3368        );
3369        let start = usize::from(diag.primary.range.start());
3370        let end = usize::from(diag.primary.range.end());
3371        assert_eq!(&manifest[start..end], "\"x\"");
3372        let order = format!(
3373            "source package `x` is already declared by the unit in `{}`; the unit in `{}` declares it too",
3374            dir.path().join(first).display(),
3375            dir.path().join(second).display()
3376        );
3377        assert!(diag.message.contains(&order), "{}", diag.message);
3378        assert_eq!(loaded.units.get("x"), Some(&dir.path().join(first)));
3379        let packages = loaded.workspace.packages(&db);
3380        assert_eq!(packages.len(), 1);
3381        let files = packages[0].files(&db);
3382        assert_eq!(files.len(), 1);
3383        assert!(
3384            files[0]
3385                .text(&db)
3386                .contains(&format!("{}A", first.to_uppercase())),
3387            "the package holds `{first}`'s file"
3388        );
3389    }
3390
3391    #[test]
3392    fn two_members_with_one_name_are_mani_014_on_the_second() {
3393        two_members_with_one_name("[\"a\", \"b\"]", "a", "b");
3394    }
3395
3396    #[test]
3397    fn two_members_with_one_name_are_mani_014_on_the_second_in_reverse_order() {
3398        two_members_with_one_name("[\"b\", \"a\"]", "b", "a");
3399    }
3400
3401    /// Two directories of one unit, `a.b/` and `a/b/`, both give the package
3402    /// name `x.a.b`. The claim is the unit's own, so it is not MANI-014. The
3403    /// current behaviour loads both directories silently; no diagnostic is
3404    /// dedicated to this collision yet.
3405    #[test]
3406    fn two_directories_of_one_unit_naming_one_package_are_not_mani_014() {
3407        let dir = TempDir::new("one-unit-one-name");
3408        dir.write("ridl.toml", "[workspace]\nmembers = [\"x\"]\n");
3409        dir.write(
3410            "x/ridl.toml",
3411            "[package]\nname = \"x\"\nversion = \"1.0.0\"\n",
3412        );
3413        dir.write("x/a.b/p.ridl", "package x.a.b\n\ninterface P {}\n");
3414        dir.write("x/a/b/q.ridl", "package x.a.b\n\ninterface Q {}\n");
3415        let mut db = RidlDatabase::default();
3416        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
3417        let codes = codes(&loaded.diagnostics);
3418        assert!(!codes.contains(&"MANI-014"), "{codes:?}");
3419    }
3420
3421    #[test]
3422    fn a_member_listed_twice_is_loaded_once() {
3423        member_listed_twice_is_loaded_once("[\"a\", \"a\"]");
3424    }
3425
3426    /// The second listing spells the same directory through `..`; the two
3427    /// paths are equal once lexically normalized.
3428    #[test]
3429    fn a_member_listed_twice_through_a_parent_step_is_loaded_once() {
3430        member_listed_twice_is_loaded_once("[\"a\", \"b/../a\"]");
3431    }
3432
3433    /// Loads a workspace whose `members` is `members`, every listing of which
3434    /// names the directory `a` (the directory `b` exists so that `b/../a`
3435    /// resolves on the filesystem). The member is one unit: no MANI-014, its
3436    /// own MANI-005 raised once, no diagnostic twice, one package `x`. The
3437    /// repeated listing raises no diagnostic of its own: the code list equals
3438    /// the list of the same fixture with `a` listed once.
3439    fn member_listed_twice_is_loaded_once(members: &str) {
3440        let (codes, named_x) = load_member_a(members);
3441        assert!(!codes.contains(&"MANI-014".to_string()), "{codes:?}");
3442        let mani_005 = codes.iter().filter(|c| *c == "MANI-005").count();
3443        assert_eq!(mani_005, 1, "{codes:?}");
3444        assert_eq!(named_x, 1);
3445        let (listed_once, _) = load_member_a("[\"a\"]");
3446        assert_eq!(codes, listed_once);
3447    }
3448
3449    /// The fixture of [`member_listed_twice_is_loaded_once`] with `members`
3450    /// as the `[workspace] members` list: the diagnostic codes, in order, and
3451    /// the number of packages named `x`. Asserts that no diagnostic appears
3452    /// twice.
3453    fn load_member_a(members: &str) -> (Vec<String>, usize) {
3454        let dir = TempDir::new("member-twice");
3455        std::fs::create_dir_all(dir.path().join("b")).expect("create `b`");
3456        dir.write("ridl.toml", &format!("[workspace]\nmembers = {members}\n"));
3457        dir.write(
3458            "a/ridl.toml",
3459            "[package]\nname = \"x\"\nversion = \"1.0.0\"\nunknown = 1\n",
3460        );
3461        dir.write("a/x.ridl", "package x\n\ninterface A {}\n");
3462        let mut db = RidlDatabase::default();
3463        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
3464        for (i, diag) in loaded.diagnostics.iter().enumerate() {
3465            assert!(
3466                !loaded.diagnostics[..i].contains(diag),
3467                "{} appears twice",
3468                diag.code.as_str()
3469            );
3470        }
3471        let packages = loaded.workspace.packages(&db);
3472        let named_x = packages.iter().filter(|p| p.name(&db) == "x").count();
3473        let codes = codes(&loaded.diagnostics)
3474            .into_iter()
3475            .map(str::to_string)
3476            .collect();
3477        (codes, named_x)
3478    }
3479
3480    #[test]
3481    fn two_units_with_a_shared_prefix_and_no_overlap_both_load() {
3482        let dir = TempDir::new("shared-prefix");
3483        dir.write("ridl.toml", "[workspace]\nmembers = [\"base\", \"hmi\"]\n");
3484        dir.write(
3485            "base/ridl.toml",
3486            "[package]\nname = \"com.example\"\nversion = \"1.0.0\"\n",
3487        );
3488        dir.write("base/ids.typl", "package com.example\n");
3489        dir.write(
3490            "hmi/ridl.toml",
3491            "[package]\nname = \"com.example.hmi\"\nversion = \"1.0.0\"\n",
3492        );
3493        dir.write("hmi/y.ridl", "package com.example.hmi\n");
3494        let mut db = RidlDatabase::default();
3495        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
3496        assert!(
3497            loaded.diagnostics.is_empty(),
3498            "{:?}",
3499            codes(&loaded.diagnostics)
3500        );
3501        let units: std::collections::BTreeSet<String> = loaded
3502            .workspace
3503            .packages(&db)
3504            .iter()
3505            .map(|p| p.unit(&db).to_string())
3506            .collect();
3507        assert_eq!(
3508            units,
3509            ["com.example", "com.example.hmi"].map(String::from).into()
3510        );
3511    }
3512}