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