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