Skip to main content

ridl_core/
workspace.rs

1//! Filesystem discovery: from an entry path to a loaded [`Workspace`]
2//! (docs/ROADMAP.md epic E1.3, 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, E2
11//! task 2) into [`InputFile`] inputs, and enforces the package↔directory law
12//! (typl reference §3.1): every file in a package directory must declare that
13//! directory's package name (TYPL-002), and more than one `package`
14//! declaration in a file is TYPL-001. A 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::{Path, PathBuf};
31
32use ridl_syntax::ast::{AstNode as _, SourceFile};
33use rowan::{TextRange, TextSize};
34
35use crate::db::{InputFile, RidlDatabase, parse_file};
36use crate::diag::{DiagCode, Diagnostic, FileId, Severity, SourceMap, Span};
37use crate::interface_lock;
38use crate::manifest::{Manifest, ManifestKind, parse_manifest};
39use crate::package::{Package, PackageLock, PackageOrigin, Workspace, package_declarations};
40
41/// The result of [`load_workspace`]: the salsa [`Workspace`] input, the
42/// diagnostics the load accumulated, and the interned path+text table the
43/// diagnostics' [`Span`]s point into (what the caller hands to
44/// [`render`](crate::diag::render())).
45pub struct LoadedWorkspace {
46    pub workspace: Workspace,
47    pub diagnostics: Vec<Diagnostic>,
48    pub sources: SourceMap,
49}
50
51/// Loads the workspace reachable from `entry` into `db`.
52///
53/// `entry` may be:
54///
55/// - a `.typl` or `.ridl` file — the nearest `ridl.toml` up the tree is the
56///   root; with no manifest anywhere up the tree the file loads in
57///   single-file mode;
58/// - a package directory or workspace root — the nearest `ridl.toml` at or
59///   above the directory is the root; a `[package]` manifest loads that
60///   package's directory tree, a `[workspace]` manifest loads every member.
61///
62/// `[imports]` maps stay scoped per ADR-0002 §5: each [`Package`] carries the
63/// `[imports]` of the manifest governing its directory tree (step 2), and
64/// [`Workspace::imports`] holds only the workspace root's `[imports]` (step
65/// 3, the shared default). Nothing is merged — a member's pin never leaks to
66/// a sibling member; the task 9 resolver walks the order itself. In a
67/// standalone package load the manifest's `[imports]` ride on its packages
68/// and [`Workspace::imports`] is empty.
69pub fn load_workspace(db: &mut RidlDatabase, entry: &Path) -> io::Result<LoadedWorkspace> {
70    let mut loader = Loader::default();
71
72    if entry.is_file() {
73        match entry.parent().and_then(find_manifest_root) {
74            Some(root) => loader.load_root(db, &root)?,
75            None => loader.load_single_file(db, entry)?,
76        }
77    } else if entry.is_dir() {
78        match find_manifest_root(entry) {
79            Some(root) => loader.load_root(db, &root)?,
80            None => {
81                return Err(io::Error::new(
82                    io::ErrorKind::NotFound,
83                    format!("no `ridl.toml` found at or above `{}`", entry.display()),
84                ));
85            }
86        }
87    } else {
88        return Err(io::Error::new(
89            io::ErrorKind::NotFound,
90            format!("`{}` does not exist", entry.display()),
91        ));
92    }
93
94    let workspace = Workspace::new(&*db, loader.packages, loader.workspace_imports);
95    Ok(LoadedWorkspace {
96        workspace,
97        diagnostics: loader.diagnostics,
98        sources: loader.sources,
99    })
100}
101
102/// The nearest directory at or above `dir` that contains a `ridl.toml`.
103fn find_manifest_root(dir: &Path) -> Option<PathBuf> {
104    dir.ancestors()
105        .find(|candidate| candidate.join("ridl.toml").is_file())
106        .map(Path::to_path_buf)
107}
108
109/// One loaded file plus its `package` declarations (dotted name, source
110/// range), as [`Loader::load_file`] returns them.
111type LoadedFile = (InputFile, Vec<(String, TextRange)>);
112
113/// The accumulating state of one [`load_workspace`] run.
114#[derive(Default)]
115struct Loader {
116    sources: SourceMap,
117    diagnostics: Vec<Diagnostic>,
118    packages: Vec<Package>,
119    /// The workspace root's own `[imports]` (ADR-0002 §5 step 3). Stays empty
120    /// in a standalone package load and in single-file mode.
121    workspace_imports: BTreeMap<String, String>,
122    /// The workspace root's `[defaults].timing` (ridl §9.1). A member's own
123    /// `[defaults].timing` shadows it; when the member has none, this rides on
124    /// the member's packages. Stays `None` in a standalone package load and in
125    /// single-file mode (E2 task 9).
126    workspace_default_timing: Option<String>,
127}
128
129impl Loader {
130    /// Loads from a directory known to contain a `ridl.toml`, in whichever
131    /// mode its manifest declares.
132    fn load_root(&mut self, db: &mut RidlDatabase, root: &Path) -> io::Result<()> {
133        let manifest_path = root.join("ridl.toml");
134        let text = fs::read_to_string(&manifest_path)?;
135        let file_id = self.sources.file_id(&path_string(&manifest_path), &text);
136        let (manifest, diags) = parse_manifest(file_id, &text);
137        self.diagnostics.extend(diags);
138        let Some(Manifest {
139            kind,
140            imports,
141            default_timing,
142        }) = manifest
143        else {
144            return Ok(());
145        };
146        match kind {
147            ManifestKind::Package { name, .. } => {
148                // A standalone package: the manifest's `[imports]` and
149                // `[defaults].timing` ride on its packages; the workspace maps
150                // stay empty.
151                self.load_package_tree(db, root, &name, &imports, &default_timing)?;
152            }
153            ManifestKind::Workspace { members } => {
154                // ADR-0002 §5 step 3: the workspace root's `[imports]` and
155                // `[defaults].timing` are the shared defaults. Member maps are
156                // never merged into them.
157                self.workspace_imports = imports;
158                self.workspace_default_timing = default_timing;
159                for member in &members {
160                    self.load_member(db, root, member, file_id, &text)?;
161                }
162            }
163        }
164        Ok(())
165    }
166
167    /// Loads one workspace member directory: its manifest, then its package
168    /// tree. A member manifest that declares `[workspace]` is a nested
169    /// workspace — MANI-004 — and loads nothing.
170    fn load_member(
171        &mut self,
172        db: &mut RidlDatabase,
173        workspace_root: &Path,
174        member: &str,
175        workspace_file: FileId,
176        workspace_text: &str,
177    ) -> io::Result<()> {
178        let manifest_path = workspace_root.join(member).join("ridl.toml");
179        if !manifest_path.is_file() {
180            // T7 records member paths unvalidated; the loader validates them
181            // against the filesystem (MANI-008).
182            self.diagnostics.push(error(
183                DiagCode::MANI_008,
184                workspace_file,
185                member_entry_range(workspace_text, member),
186                format!("workspace member `{member}` has no `ridl.toml`"),
187            ));
188            return Ok(());
189        }
190        let text = fs::read_to_string(&manifest_path)?;
191        let file_id = self.sources.file_id(&path_string(&manifest_path), &text);
192        let (manifest, diags) = parse_manifest(file_id, &text);
193        self.diagnostics.extend(diags);
194        let Some(Manifest {
195            kind,
196            imports,
197            default_timing,
198        }) = manifest
199        else {
200            return Ok(());
201        };
202        match kind {
203            ManifestKind::Workspace { .. } => {
204                self.diagnostics.push(error(
205                    DiagCode::MANI_004,
206                    file_id,
207                    workspace_section_range(&text),
208                    format!(
209                        "workspace member `{member}` declares `[workspace]`; nested workspaces are forbidden"
210                    ),
211                ));
212            }
213            ManifestKind::Package { name, .. } => {
214                // ADR-0002 §5 step 2: the member's `[imports]` ride on the
215                // member's packages only — never merged into the workspace
216                // map, never visible to a sibling member. Its
217                // `[defaults].timing` shadows the workspace default (ridl §9.1);
218                // when the member configures none, the workspace default rides
219                // on the member's packages.
220                let member_default_timing =
221                    default_timing.or_else(|| self.workspace_default_timing.clone());
222                self.load_package_tree(
223                    db,
224                    &workspace_root.join(member),
225                    &name,
226                    &imports,
227                    &member_default_timing,
228                )?;
229            }
230        }
231        Ok(())
232    }
233
234    /// Loads the package rooted at `dir` under the package name `name`, then
235    /// every subdirectory as its own package named by its path — the
236    /// package↔directory law's "the name mirrors the directory path relative
237    /// to the manifest root" (ADR-0002 §1). Every package in the tree carries
238    /// `imports`, the governing manifest's `[imports]`. Directories are
239    /// visited in name order; hidden directories, symlinked directories
240    /// (following them could revisit the tree in a cycle), and directories
241    /// with their own `ridl.toml` (separate package roots) are skipped.
242    fn load_package_tree(
243        &mut self,
244        db: &mut RidlDatabase,
245        dir: &Path,
246        name: &str,
247        imports: &BTreeMap<String, String>,
248        default_timing: &Option<String>,
249    ) -> io::Result<()> {
250        let mut source_files = Vec::new();
251        let mut subdirs = Vec::new();
252        for entry in fs::read_dir(dir)? {
253            let entry = entry?;
254            let path = entry.path();
255            let is_symlink = entry.file_type()?.is_symlink();
256            if path.is_dir() {
257                if !is_symlink {
258                    subdirs.push(path);
259                }
260            } else if path
261                .extension()
262                .is_some_and(|ext| ext == "typl" || ext == "ridl" || ext == "rsdl")
263            {
264                source_files.push(path);
265            }
266        }
267        source_files.sort();
268        subdirs.sort();
269
270        if !source_files.is_empty() {
271            let mut files = Vec::new();
272            for path in &source_files {
273                if let Some((input, _)) = self.load_file(db, path, Some(name))? {
274                    files.push(input);
275                }
276            }
277            let lock = self.read_lock(dir)?;
278            self.packages.push(Package::new(
279                &*db,
280                name.to_string(),
281                files,
282                PackageOrigin::WorkspaceMember,
283                imports.clone(),
284                default_timing.clone(),
285                lock,
286            ));
287        }
288
289        for subdir in subdirs {
290            let Some(dir_name) = subdir.file_name().map(|n| n.to_string_lossy().into_owned())
291            else {
292                continue;
293            };
294            if dir_name.starts_with('.') || subdir.join("ridl.toml").is_file() {
295                continue;
296            }
297            self.load_package_tree(
298                db,
299                &subdir,
300                &format!("{name}.{dir_name}"),
301                imports,
302                default_timing,
303            )?;
304        }
305        Ok(())
306    }
307
308    /// Loads one bare source file as a synthetic package named from its
309    /// declared package — single-file mode, exempt from TYPL-002 (TYPL-001
310    /// still applies). With no usable declaration the file stem names the
311    /// package; the parser's FORM-104 for the missing declaration lives on
312    /// `parse_file(..).errors()`, like every parse error — loader diagnostics
313    /// carry only the manifest and law findings.
314    fn load_single_file(&mut self, db: &mut RidlDatabase, path: &Path) -> io::Result<()> {
315        let Some((input, decls)) = self.load_file(db, path, None)? else {
316            // A non-UTF8 file: the diagnostic is recorded, nothing loads.
317            return Ok(());
318        };
319        let name = decls
320            .first()
321            .map(|(name, _)| name.clone())
322            .filter(|name| !name.is_empty())
323            .unwrap_or_else(|| {
324                path.file_stem()
325                    .map(|stem| stem.to_string_lossy().into_owned())
326                    .unwrap_or_else(|| "package".to_string())
327            });
328        // The file's directory is the package directory, so the lock is read
329        // there too (plan decision PD-8).
330        let lock = match path.parent() {
331            Some(dir) => self.read_lock(dir)?,
332            None => None,
333        };
334        self.packages.push(Package::new(
335            &*db,
336            name,
337            vec![input],
338            PackageOrigin::WorkspaceMember,
339            BTreeMap::new(),
340            None,
341            lock,
342        ));
343        Ok(())
344    }
345
346    /// Reads `dir/interfaces.lock` for the package rooted at `dir` (lock
347    /// design §2). An absent file is `None`. A malformed file — one that is
348    /// not valid UTF-8 included — is RIDL-410 on the offending line of the
349    /// lock file itself, through this loader's source map, at the empty range
350    /// 0..0 when there is no line to point at (plan decision PD-3); the
351    /// package then carries no lock. Any other I/O failure is the error.
352    fn read_lock(&mut self, dir: &Path) -> io::Result<Option<PackageLock>> {
353        let path = path_string(&dir.join(interface_lock::FILE_NAME));
354        let text = match interface_lock::read(dir) {
355            Ok(Some(text)) => text,
356            Ok(None) => return Ok(None),
357            Err(err) if err.kind() == io::ErrorKind::InvalidData => {
358                let file_id = self.sources.file_id(&path, "");
359                self.diagnostics.push(error(
360                    DiagCode::RIDL_410,
361                    file_id,
362                    byte_range(0, 0),
363                    malformed_lock_message("the file is not valid UTF-8"),
364                ));
365                return Ok(None);
366            }
367            Err(err) => return Err(err),
368        };
369        match interface_lock::parse(&text) {
370            Ok(lock) => Ok(Some(PackageLock { path, text, lock })),
371            Err(malformed) => {
372                let file_id = self.sources.file_id(&path, &text);
373                self.diagnostics.push(error(
374                    DiagCode::RIDL_410,
375                    file_id,
376                    malformed.range,
377                    malformed_lock_message(&malformed.message),
378                ));
379                Ok(None)
380            }
381        }
382    }
383
384    /// Reads one source file into an [`InputFile`], parses it through the
385    /// salsa query, and enforces the package↔directory law: every `package`
386    /// declaration after the first is TYPL-001; when `expected` is given and
387    /// the first declared name differs, TYPL-002 with the declaration line as
388    /// the primary span. Returns the input plus the file's declarations, or
389    /// `None` for a file that is not valid UTF-8 — recorded as a diagnostic
390    /// and skipped, never an abort of the whole load (ADR-0004 §5).
391    fn load_file(
392        &mut self,
393        db: &mut RidlDatabase,
394        path: &Path,
395        expected: Option<&str>,
396    ) -> io::Result<Option<LoadedFile>> {
397        let path_str = path_string(path);
398        let text = match fs::read_to_string(path) {
399            Ok(text) => text,
400            Err(err) if err.kind() == io::ErrorKind::InvalidData => {
401                // No text means no spans; the diagnostic points at the start
402                // of the interned (empty) file. No code is cataloged for a
403                // broken source encoding, so it carries the `NONE` sentinel.
404                let file_id = self.sources.file_id(&path_str, "");
405                self.diagnostics.push(error(
406                    DiagCode::NONE,
407                    file_id,
408                    byte_range(0, 0),
409                    format!("`{path_str}` is not valid UTF-8; the file is skipped"),
410                ));
411                return Ok(None);
412            }
413            Err(err) => return Err(err),
414        };
415        let file_id = self.sources.file_id(&path_str, &text);
416        let input = InputFile::new(&*db, path_str, text);
417
418        let parse = parse_file(&*db, input);
419        let source =
420            SourceFile::cast(parse.syntax()).expect("parser roots every tree in a SourceFile");
421        let decls = package_declarations(&source);
422
423        for (_, range) in decls.iter().skip(1) {
424            self.diagnostics.push(error(
425                DiagCode::TYPL_001,
426                file_id,
427                *range,
428                "more than one `package` declaration in this file".to_string(),
429            ));
430        }
431        if let Some((declared, range)) = decls.first()
432            && crate::std_lib::is_reserved_package_name(declared)
433        {
434            // Reported on the declaration rather than on the manifest or the
435            // directory, because that is the one place both paths meet: a
436            // workspace member and single-file mode both arrive here, and the
437            // issue this closes (driftsys/ridl#203) names both.
438            //
439            // The message states only the unreachability, which always holds.
440            // The artifact overwrite that issue reports is a consequence in
441            // package and workspace mode, where the output base is the package
442            // name; in single-file mode the base is the file stem, so it
443            // collides only when that stem is itself `ridl.std`, whatever the
444            // extension. That distinction belongs in the catalogue entry, not
445            // in a message that would then be false for some of the inputs it
446            // greets.
447            self.diagnostics.push(error(
448                DiagCode::TYPL_010,
449                file_id,
450                *range,
451                format!(
452                    "`{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"
453                ),
454            ));
455        }
456        if let (Some(expected), Some((declared, range))) = (expected, decls.first())
457            && !declared.is_empty()
458            && declared != expected
459        {
460            self.diagnostics.push(error(
461                DiagCode::TYPL_002,
462                file_id,
463                *range,
464                format!(
465                    "package name `{declared}` does not mirror the directory path; every file in this directory must declare `package {expected}`"
466                ),
467            ));
468        }
469        Ok(Some((input, decls)))
470    }
471}
472
473/// The interned string form of a filesystem path.
474fn path_string(path: &Path) -> String {
475    path.to_string_lossy().into_owned()
476}
477
478/// The RIDL-410 message: what is wrong with the lock file, then the fix the
479/// lock design §8 names.
480fn malformed_lock_message(reason: &str) -> String {
481    format!(
482        "`{}` is malformed: {reason} — resolve the conflict or restore the file from version \
483         control, then run `ridl lock`",
484        interface_lock::FILE_NAME
485    )
486}
487
488/// The byte range of the quoted `member` entry inside a workspace manifest's
489/// text, or the whole file when it cannot be found (T7 does not retain member
490/// spans).
491fn member_entry_range(text: &str, member: &str) -> TextRange {
492    let quoted = format!("\"{member}\"");
493    match text.find(&quoted) {
494        Some(start) => byte_range(start, start + quoted.len()),
495        None => byte_range(0, text.len()),
496    }
497}
498
499/// The byte range of the `[workspace]` section header inside a manifest's
500/// text, or the whole file as a fallback.
501fn workspace_section_range(text: &str) -> TextRange {
502    const HEADER: &str = "[workspace]";
503    match text.find(HEADER) {
504        Some(start) => byte_range(start, start + HEADER.len()),
505        None => byte_range(0, text.len()),
506    }
507}
508
509/// A `rowan::TextRange` over byte offsets.
510fn byte_range(start: usize, end: usize) -> TextRange {
511    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
512}
513
514/// Builds an error [`Diagnostic`]; loader diagnostics carry no secondary
515/// labels or fix-its.
516fn error(code: DiagCode, file: FileId, range: TextRange, message: String) -> Diagnostic {
517    Diagnostic {
518        code,
519        severity: Severity::Error,
520        message,
521        primary: Span { file, range },
522        labels: Vec::new(),
523        fixits: Vec::new(),
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use std::path::PathBuf;
530    use std::sync::atomic::{AtomicUsize, Ordering};
531
532    use salsa::Setter;
533    use salsa::plumbing::AsId;
534
535    use super::*;
536
537    /// A unique directory under the system temp dir, removed on drop.
538    struct TempDir(PathBuf);
539
540    impl TempDir {
541        fn new(label: &str) -> Self {
542            static COUNTER: AtomicUsize = AtomicUsize::new(0);
543            let mut path = std::env::temp_dir();
544            path.push(format!(
545                "ridl-core-workspace-{label}-{}-{}",
546                std::process::id(),
547                COUNTER.fetch_add(1, Ordering::SeqCst),
548            ));
549            fs::create_dir_all(&path).expect("create the temp dir");
550            Self(path)
551        }
552
553        fn path(&self) -> &Path {
554            &self.0
555        }
556
557        /// Writes `text` at `relative`, creating parent directories.
558        fn write(&self, relative: &str, text: &str) -> PathBuf {
559            let path = self.0.join(relative);
560            fs::create_dir_all(path.parent().expect("relative paths have a parent"))
561                .expect("create parent directories");
562            fs::write(&path, text).expect("write the fixture file");
563            path
564        }
565    }
566
567    impl Drop for TempDir {
568        fn drop(&mut self) {
569            let _ = fs::remove_dir_all(&self.0);
570        }
571    }
572
573    fn codes(diags: &[Diagnostic]) -> Vec<&str> {
574        diags.iter().map(|d| d.code.as_str()).collect()
575    }
576
577    const PACKAGE_MANIFEST: &str = "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n";
578
579    /// TYPL-010: a workspace member cannot declare a name the compiler
580    /// provides (driftsys/ridl#203). Before this check the member compiled
581    /// clean, its declarations were unreachable because every package already
582    /// imports all of `ridl.std` implicitly, and its generated artifact was
583    /// overwritten by the standard package's own.
584    #[test]
585    fn a_package_declaring_a_reserved_name_is_refused() {
586        const SOURCE: &str = "package ridl.std\ntype MyOwnType: m\n";
587        let dir = TempDir::new("reserved-name");
588        dir.write(
589            "ridl.toml",
590            "[package]\nname = \"ridl.std\"\nversion = \"1.0.0\"\n",
591        );
592        dir.write("own.typl", SOURCE);
593
594        let mut db = RidlDatabase::default();
595        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
596        assert_eq!(
597            codes(&loaded.diagnostics),
598            vec!["TYPL-010"],
599            "got: {:?}",
600            loaded.diagnostics
601        );
602        let diagnostic = &loaded.diagnostics[0];
603        assert!(
604            diagnostic.message.contains("ridl.std"),
605            "the message names the package: {}",
606            diagnostic.message
607        );
608        // The message states only what holds for every input that draws it.
609        // The artifact collision does not: in single-file mode the output base
610        // is the file stem. Asserting the absence keeps that clause from
611        // returning to the message without the test noticing.
612        assert!(
613            diagnostic.message.contains("unreachable"),
614            "the message states the consequence that always holds: {}",
615            diagnostic.message
616        );
617        for conditional in ["artifact", "overwrit"] {
618            assert!(
619                !diagnostic.message.contains(conditional),
620                "`{conditional}` is true only where the output base is the package name, \
621                 so it belongs in the catalogue entry, not the message: {}",
622                diagnostic.message
623            );
624        }
625        // The declaration is what is pointed at, not the manifest or the
626        // directory: it is the one place a workspace member and single-file
627        // mode both pass through. Both ends are asserted, against the source
628        // itself, so an over-wide span covering the whole file cannot pass.
629        let declaration = SOURCE
630            .lines()
631            .next()
632            .expect("the declaration is the first line");
633        assert_eq!(
634            (
635                usize::from(diagnostic.primary.range.start()),
636                usize::from(diagnostic.primary.range.end()),
637            ),
638            (0, declaration.len()),
639            "reported on `{declaration}` exactly"
640        );
641    }
642
643    /// The same refusal in single-file mode — `ridlc build ridl_std.typl`,
644    /// the second form driftsys/ridl#203 names. Single-file mode is exempt
645    /// from TYPL-002, so nothing else would have caught it.
646    #[test]
647    fn a_single_file_declaring_a_reserved_name_is_refused() {
648        let dir = TempDir::new("reserved-name-single");
649        let file = dir.write("ridl_std.typl", "package ridl.std\ntype MyOwnType: m\n");
650
651        let mut db = RidlDatabase::default();
652        let loaded = load_workspace(&mut db, &file).expect("single-file mode loads");
653        assert_eq!(
654            codes(&loaded.diagnostics),
655            vec!["TYPL-010"],
656            "got: {:?}",
657            loaded.diagnostics
658        );
659    }
660
661    /// A name that merely starts with `ridl.` is not reserved: the reservation
662    /// is the set of packages the compiler provides, not a namespace policy.
663    #[test]
664    fn only_a_compiler_provided_name_is_reserved() {
665        let dir = TempDir::new("near-reserved");
666        dir.write(
667            "ridl.toml",
668            "[package]\nname = \"ridl.stdlib\"\nversion = \"1.0.0\"\n",
669        );
670        dir.write("own.typl", "package ridl.stdlib\ntype MyOwnType: m\n");
671
672        let mut db = RidlDatabase::default();
673        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
674        assert_eq!(
675            loaded.diagnostics,
676            Vec::new(),
677            "`ridl.stdlib` is not a package the compiler provides"
678        );
679    }
680
681    /// (a) A two-file package loads, both files parse, and editing one
682    /// re-parses only it — asserted by the re-executed query's `database_key`
683    /// (issue #102).
684    #[test]
685    fn two_file_package_loads_and_edit_reparses_only_the_edited_file() {
686        let dir = TempDir::new("two-file");
687        dir.write("ridl.toml", PACKAGE_MANIFEST);
688        dir.write("a.typl", "package veh.common\ntype A: m\n");
689        dir.write("b.typl", "package veh.common\ntype B: s\n");
690
691        let mut db = RidlDatabase::default();
692        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
693        assert_eq!(
694            loaded.diagnostics,
695            Vec::new(),
696            "a clean package, no diagnostics"
697        );
698
699        let packages = loaded.workspace.packages(&db).clone();
700        assert_eq!(packages.len(), 1, "one package directory, one package");
701        assert_eq!(packages[0].name(&db).as_str(), "veh.common");
702        assert_eq!(*packages[0].origin(&db), PackageOrigin::WorkspaceMember);
703        assert_eq!(
704            packages[0].imports(&db),
705            &BTreeMap::new(),
706            "a manifest without `[imports]` yields an empty package map",
707        );
708        assert_eq!(
709            loaded.workspace.imports(&db),
710            &BTreeMap::new(),
711            "a standalone load leaves the workspace map empty",
712        );
713
714        let files = packages[0].files(&db).clone();
715        assert_eq!(files.len(), 2, "both .typl files load");
716        for file in &files {
717            assert_eq!(
718                parse_file(&db, *file).errors(),
719                &[],
720                "both files parse clean"
721            );
722        }
723        let a = files
724            .iter()
725            .copied()
726            .find(|f| f.path(&db).ends_with("a.typl"))
727            .expect("a.typl is loaded");
728        let b = files
729            .iter()
730            .copied()
731            .find(|f| f.path(&db).ends_with("b.typl"))
732            .expect("b.typl is loaded");
733
734        // Drain the executions the load itself ran; unchanged inputs are then
735        // pure memo hits.
736        db.take_executed_queries();
737        let _ = parse_file(&db, a);
738        let _ = parse_file(&db, b);
739        assert_eq!(
740            db.take_executed_queries(),
741            Vec::new(),
742            "re-querying unchanged inputs must run no executions",
743        );
744
745        // Edit A's text only: exactly one re-execution, and it is A's parse.
746        a.set_text(&mut db)
747            .to("package veh.common\ntype A: kg\n".to_string());
748        let _ = parse_file(&db, a);
749        let _ = parse_file(&db, b);
750        let executed = db.take_executed_queries();
751        assert_eq!(
752            executed.len(),
753            1,
754            "editing one file re-parses exactly one file"
755        );
756        assert_eq!(
757            salsa::attach(&db, || format!("{:?}", executed[0])),
758            format!("parse_file({:?})", a.as_id()),
759            "the re-executed query is the parse of the edited file",
760        );
761    }
762
763    /// (b) A file that declares a different package than its directory
764    /// requires is TYPL-002, primary span on the `package` line.
765    #[test]
766    fn typl_002_on_a_mismatching_file() {
767        let dir = TempDir::new("mismatch");
768        dir.write("ridl.toml", PACKAGE_MANIFEST);
769        let bad_text = "package veh.wrong\ntype B: s\n";
770        let bad_path = dir.write("bad.typl", bad_text);
771
772        let mut db = RidlDatabase::default();
773        let mut loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
774        assert_eq!(codes(&loaded.diagnostics), vec!["TYPL-002"]);
775
776        let diag = &loaded.diagnostics[0];
777        assert_eq!(diag.severity, Severity::Error);
778        assert_eq!(
779            diag.primary.range,
780            byte_range(0, "package veh.wrong".len()),
781            "the primary span is the mismatching `package` line",
782        );
783        assert_eq!(
784            diag.primary.file,
785            loaded.sources.file_id(&path_string(&bad_path), bad_text),
786            "the span points into the mismatching file",
787        );
788
789        // The law is a diagnostic, not an exclusion: the file stays loaded.
790        let packages = loaded.workspace.packages(&db).clone();
791        assert_eq!(packages.len(), 1);
792        assert_eq!(packages[0].files(&db).len(), 1);
793    }
794
795    /// (c) More than one `package` declaration in a file is TYPL-001 on each
796    /// declaration after the first.
797    #[test]
798    fn typl_001_on_a_double_package_declaration() {
799        let dir = TempDir::new("double-decl");
800        dir.write("ridl.toml", PACKAGE_MANIFEST);
801        dir.write(
802            "dup.typl",
803            "package veh.common\npackage veh.extra\ntype A: m\n",
804        );
805
806        let mut db = RidlDatabase::default();
807        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
808        assert_eq!(codes(&loaded.diagnostics), vec!["TYPL-001"]);
809        assert_eq!(
810            loaded.diagnostics[0].primary.range,
811            byte_range(19, 36),
812            "the primary span is the second `package` declaration",
813        );
814    }
815
816    /// (d) Workspace mode loads every member and keeps `[imports]` scoped per
817    /// ADR-0002 §5: each member package carries only its own manifest's map
818    /// (step 2 — a member's pin never leaks to a sibling), and the workspace
819    /// map holds only the root's `[imports]` (step 3), never a merge.
820    #[test]
821    fn workspace_mode_scopes_imports_per_package() {
822        let dir = TempDir::new("workspace");
823        dir.write(
824            "ridl.toml",
825            "[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",
826        );
827        dir.write(
828            "m-one/ridl.toml",
829            "[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",
830        );
831        dir.write("m-one/one.typl", "package veh.one\ntype A: m\n");
832        dir.write(
833            "m-two/ridl.toml",
834            "[package]\nname = \"veh.two\"\nversion = \"1.0.0\"\n\n[imports]\n\"two.only\" = \"https://registry.example.com/two/only@v1.0.0\"\n",
835        );
836        dir.write("m-two/two.typl", "package veh.two\ntype B: s\n");
837
838        let mut db = RidlDatabase::default();
839        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
840        assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
841
842        let packages = loaded.workspace.packages(&db).clone();
843        let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
844        assert_eq!(
845            names,
846            vec!["veh.one", "veh.two"],
847            "both members load, in member order"
848        );
849
850        // Step 3: the workspace map is the root's `[imports]`, un-merged —
851        // the member pin for `third.dep` must NOT overwrite the root's.
852        let workspace_imports = loaded.workspace.imports(&db).clone();
853        assert_eq!(
854            workspace_imports.get("third.dep").map(String::as_str),
855            Some("https://registry.example.com/third/dep@v1.0.0"),
856            "the workspace map keeps the root pin, not the member pin",
857        );
858        assert_eq!(
859            workspace_imports.get("shared.util").map(String::as_str),
860            Some("https://registry.example.com/shared/util@v1.0.0"),
861        );
862        assert_eq!(workspace_imports.len(), 2, "no member entry leaks upward");
863
864        // Step 2: each member package carries its own manifest's map only.
865        let one_imports = packages[0].imports(&db).clone();
866        assert_eq!(
867            one_imports.get("third.dep").map(String::as_str),
868            Some("https://mirror.example.com/third/dep@v2.0.0"),
869            "the member's own pin shadows the workspace default for it alone",
870        );
871        assert_eq!(
872            one_imports.get("member.only").map(String::as_str),
873            Some("https://registry.example.com/member/only@v1.0.0"),
874        );
875        assert!(
876            !one_imports.contains_key("two.only"),
877            "a sibling's pin never leaks into another member",
878        );
879        assert_eq!(one_imports.len(), 2, "no workspace entry is merged in");
880
881        let two_imports = packages[1].imports(&db).clone();
882        assert_eq!(
883            two_imports.get("two.only").map(String::as_str),
884            Some("https://registry.example.com/two/only@v1.0.0"),
885        );
886        assert!(
887            !two_imports.contains_key("member.only"),
888            "the sibling's pin never leaks into this member",
889        );
890        assert!(
891            !two_imports.contains_key("third.dep"),
892            "neither the root default nor the sibling's pin is merged in",
893        );
894        assert_eq!(two_imports.len(), 1);
895    }
896
897    /// `[defaults].timing` follows the ADR-0002 §5 precedence merged at load:
898    /// a member's own `[defaults]` shadows the workspace `[defaults]`; a member
899    /// without one inherits the workspace default (ridl §9.1, E2 task 9).
900    #[test]
901    fn defaults_timing_precedence_package_shadows_workspace() {
902        let dir = TempDir::new("defaults-timing");
903        dir.write(
904            "ridl.toml",
905            "[workspace]\nmembers = [\"m-own\", \"m-inherit\"]\n\n[defaults]\ntiming = \"[100ms..1000ms]\"\n",
906        );
907        // m-own configures its own default — it shadows the workspace default.
908        dir.write(
909            "m-own/ridl.toml",
910            "[package]\nname = \"veh.own\"\nversion = \"1.0.0\"\n\n[defaults]\ntiming = \"[50ms..2s]\"\n",
911        );
912        dir.write("m-own/own.typl", "package veh.own\ntype A: m\n");
913        // m-inherit configures none — it inherits the workspace default.
914        dir.write(
915            "m-inherit/ridl.toml",
916            "[package]\nname = \"veh.inherit\"\nversion = \"1.0.0\"\n",
917        );
918        dir.write("m-inherit/inherit.typl", "package veh.inherit\ntype B: s\n");
919
920        let mut db = RidlDatabase::default();
921        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
922        assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
923
924        let packages = loaded.workspace.packages(&db).clone();
925        let own = packages
926            .iter()
927            .find(|p| p.name(&db) == "veh.own")
928            .expect("m-own loads");
929        assert_eq!(
930            own.default_timing(&db).as_deref(),
931            Some("[50ms..2s]"),
932            "the member's own `[defaults]` shadows the workspace default",
933        );
934        let inherit = packages
935            .iter()
936            .find(|p| p.name(&db) == "veh.inherit")
937            .expect("m-inherit loads");
938        assert_eq!(
939            inherit.default_timing(&db).as_deref(),
940            Some("[100ms..1000ms]"),
941            "a member without `[defaults]` inherits the workspace default",
942        );
943    }
944
945    /// A standalone package's `[defaults].timing` rides on every package in its
946    /// directory tree, and single-file mode carries none (ridl §9.1).
947    #[test]
948    fn standalone_defaults_timing_rides_on_the_tree() {
949        let dir = TempDir::new("standalone-defaults");
950        dir.write(
951            "ridl.toml",
952            "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n[defaults]\ntiming = \"[20ms..200ms]\"\n",
953        );
954        dir.write("a.typl", "package veh.common\ntype A: m\n");
955        dir.write("sub/s.typl", "package veh.common.sub\ntype S: s\n");
956
957        let mut db = RidlDatabase::default();
958        let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
959        assert_eq!(loaded.diagnostics, Vec::new());
960        for package in loaded.workspace.packages(&db) {
961            assert_eq!(
962                package.default_timing(&db).as_deref(),
963                Some("[20ms..200ms]"),
964                "every package in the tree carries the manifest default",
965            );
966        }
967
968        // Single-file mode: a bare file with no manifest anywhere up the tree.
969        let bare = TempDir::new("standalone-defaults-bare");
970        let single = bare.write("iface.ridl", "package solo\ntype A: m\n");
971        let mut single_db = RidlDatabase::default();
972        let single_loaded =
973            load_workspace(&mut single_db, &single).expect("single-file mode loads");
974        assert_eq!(
975            single_loaded.workspace.packages(&single_db)[0].default_timing(&single_db),
976            &None,
977            "single-file mode carries no configured default",
978        );
979    }
980
981    /// (e) A member manifest that declares `[workspace]` is a nested
982    /// workspace: MANI-004, and the member loads nothing.
983    #[test]
984    fn mani_004_on_a_nested_workspace_member() {
985        let dir = TempDir::new("nested");
986        dir.write(
987            "ridl.toml",
988            "[workspace]\nmembers = [\"m-bad\", \"m-good\"]\n",
989        );
990        let bad_text = "[workspace]\nmembers = []\n";
991        let bad_path = dir.write("m-bad/ridl.toml", bad_text);
992        dir.write(
993            "m-good/ridl.toml",
994            "[package]\nname = \"veh.good\"\nversion = \"1.0.0\"\n",
995        );
996        dir.write("m-good/good.typl", "package veh.good\ntype A: m\n");
997
998        let mut db = RidlDatabase::default();
999        let mut loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
1000        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-004"]);
1001
1002        let diag = &loaded.diagnostics[0];
1003        assert_eq!(diag.severity, Severity::Error);
1004        assert_eq!(
1005            diag.primary.file,
1006            loaded.sources.file_id(&path_string(&bad_path), bad_text),
1007            "the span points into the member's own manifest",
1008        );
1009        assert_eq!(
1010            diag.primary.range,
1011            byte_range(0, "[workspace]".len()),
1012            "the span is the `[workspace]` section header",
1013        );
1014
1015        let packages = loaded.workspace.packages(&db).clone();
1016        let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
1017        assert_eq!(names, vec!["veh.good"], "the nested member loads nothing");
1018    }
1019
1020    /// A workspace member path with no manifest is MANI-008 and skipped; the
1021    /// rest of the workspace still loads.
1022    #[test]
1023    fn mani_008_on_a_missing_member_directory() {
1024        let dir = TempDir::new("missing-member");
1025        dir.write(
1026            "ridl.toml",
1027            "[workspace]\nmembers = [\"m-gone\", \"m-good\"]\n",
1028        );
1029        dir.write(
1030            "m-good/ridl.toml",
1031            "[package]\nname = \"veh.good\"\nversion = \"1.0.0\"\n",
1032        );
1033        dir.write("m-good/good.typl", "package veh.good\ntype A: m\n");
1034
1035        let mut db = RidlDatabase::default();
1036        let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
1037        assert_eq!(codes(&loaded.diagnostics), vec!["MANI-008"]);
1038        assert_eq!(loaded.diagnostics[0].severity, Severity::Error);
1039        assert!(loaded.diagnostics[0].message.contains("m-gone"));
1040
1041        let packages = loaded.workspace.packages(&db).clone();
1042        assert_eq!(packages.len(), 1);
1043        assert_eq!(packages[0].name(&db).as_str(), "veh.good");
1044    }
1045
1046    /// A subdirectory of a package root is its own package, named by its
1047    /// directory path relative to the manifest root (ADR-0002 §1); every
1048    /// package in the tree carries the governing manifest's `[imports]`.
1049    #[test]
1050    fn a_subdirectory_is_its_own_package_named_by_its_path() {
1051        let dir = TempDir::new("subdir");
1052        dir.write(
1053            "ridl.toml",
1054            "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n[imports]\n\"some.dep\" = \"https://registry.example.com/some/dep@v1.0.0\"\n",
1055        );
1056        dir.write("a.typl", "package veh.common\ntype A: m\n");
1057        dir.write("types/t.typl", "package veh.common.types\ntype T: s\n");
1058
1059        let mut db = RidlDatabase::default();
1060        let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
1061        assert_eq!(loaded.diagnostics, Vec::new());
1062
1063        let packages = loaded.workspace.packages(&db).clone();
1064        let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
1065        assert_eq!(names, vec!["veh.common", "veh.common.types"]);
1066        for package in &packages {
1067            assert_eq!(
1068                package.imports(&db).get("some.dep").map(String::as_str),
1069                Some("https://registry.example.com/some/dep@v1.0.0"),
1070                "every package in the manifest's tree carries its `[imports]`",
1071            );
1072        }
1073        assert_eq!(
1074            loaded.workspace.imports(&db),
1075            &BTreeMap::new(),
1076            "a standalone load leaves the workspace map empty",
1077        );
1078    }
1079
1080    /// E2 task 2 step (g): a package directory may mix `.typl` and `.ridl`
1081    /// files — `.ridl` is accepted everywhere `.typl` is, under the same
1082    /// package↔directory law.
1083    #[test]
1084    fn a_package_directory_mixes_typl_and_ridl_files() {
1085        let dir = TempDir::new("mixed");
1086        dir.write("ridl.toml", PACKAGE_MANIFEST);
1087        dir.write("a.typl", "package veh.common\ntype A: m\n");
1088        dir.write("b.ridl", "package veh.common\ntype B: s\n");
1089
1090        let mut db = RidlDatabase::default();
1091        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1092        assert_eq!(loaded.diagnostics, Vec::new(), "a clean mixed package");
1093
1094        let packages = loaded.workspace.packages(&db).clone();
1095        assert_eq!(packages.len(), 1, "one package directory, one package");
1096        let files = packages[0].files(&db).clone();
1097        assert_eq!(files.len(), 2, "both the .typl and the .ridl file load");
1098        for file in &files {
1099            assert_eq!(
1100                parse_file(&db, *file).errors(),
1101                &[],
1102                "both files parse clean"
1103            );
1104        }
1105        assert!(files.iter().any(|f| f.path(&db).ends_with("a.typl")));
1106        assert!(files.iter().any(|f| f.path(&db).ends_with("b.ridl")));
1107    }
1108
1109    /// A package directory loads its `.rsdl` files beside its `.typl` and
1110    /// `.ridl` files (rsdl reference §2: a package may hold all three), each
1111    /// parsed under the profile its extension selects.
1112    #[test]
1113    fn a_package_directory_loads_its_rsdl_files() {
1114        let dir = TempDir::new("rsdl");
1115        dir.write("ridl.toml", PACKAGE_MANIFEST);
1116        dir.write("a.typl", "package veh.common\ntype A: m\n");
1117        dir.write("b.ridl", "package veh.common\ntype B: s\n");
1118        dir.write("c.rsdl", "package veh.common\n");
1119
1120        let mut db = RidlDatabase::default();
1121        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1122        assert_eq!(loaded.diagnostics, Vec::new(), "a clean mixed package");
1123
1124        let packages = loaded.workspace.packages(&db).clone();
1125        assert_eq!(packages.len(), 1, "one package directory, one package");
1126        let files = packages[0].files(&db).clone();
1127        let paths: Vec<&str> = files.iter().map(|f| f.path(&db).as_str()).collect();
1128        assert_eq!(files.len(), 3, "the .rsdl file loads too: {paths:?}");
1129        let rsdl = files
1130            .iter()
1131            .find(|f| f.path(&db).ends_with("c.rsdl"))
1132            .expect("the .rsdl file is a package file");
1133        assert_eq!(
1134            crate::db::profile_of_path(rsdl.path(&db)),
1135            ridl_syntax::Profile::Rsdl
1136        );
1137        assert_eq!(
1138            parse_file(&db, *rsdl).errors(),
1139            &[],
1140            "the .rsdl file parses clean"
1141        );
1142    }
1143
1144    /// Single-file mode accepts a bare `.ridl` entry, like a bare `.typl`.
1145    #[test]
1146    fn single_file_mode_accepts_a_bare_ridl_entry() {
1147        let dir = TempDir::new("single-ridl");
1148        let path = dir.write("iface.ridl", "package veh.iface\ntype A: m\n");
1149
1150        let mut db = RidlDatabase::default();
1151        let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
1152        assert_eq!(loaded.diagnostics, Vec::new(), "exempt from TYPL-002");
1153
1154        let packages = loaded.workspace.packages(&db).clone();
1155        assert_eq!(packages.len(), 1, "one synthetic package");
1156        assert_eq!(
1157            packages[0].name(&db).as_str(),
1158            "veh.iface",
1159            "named from the file's declared package",
1160        );
1161    }
1162
1163    /// A symlinked directory is not followed by the tree walk — following it
1164    /// could revisit the tree in a cycle and duplicate packages endlessly.
1165    #[cfg(unix)]
1166    #[test]
1167    fn a_symlinked_directory_is_not_followed() {
1168        let dir = TempDir::new("symlink");
1169        dir.write("ridl.toml", PACKAGE_MANIFEST);
1170        dir.write("a.typl", "package veh.common\ntype A: m\n");
1171        // A symlink pointing back at the package root: a cycle.
1172        std::os::unix::fs::symlink(dir.path(), dir.path().join("loop"))
1173            .expect("create the directory symlink");
1174
1175        let mut db = RidlDatabase::default();
1176        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1177        assert_eq!(loaded.diagnostics, Vec::new());
1178
1179        let packages = loaded.workspace.packages(&db).clone();
1180        assert_eq!(packages.len(), 1, "the symlink cycle adds no packages");
1181        assert_eq!(packages[0].name(&db).as_str(), "veh.common");
1182    }
1183
1184    /// A `.typl` file that is not valid UTF-8 becomes a diagnostic and is
1185    /// skipped; the rest of the package still loads (ADR-0004 §5 — never a
1186    /// hard error for content problems).
1187    #[test]
1188    fn a_non_utf8_file_is_reported_and_skipped() {
1189        let dir = TempDir::new("non-utf8");
1190        dir.write("ridl.toml", PACKAGE_MANIFEST);
1191        dir.write("a.typl", "package veh.common\ntype A: m\n");
1192        fs::write(dir.path().join("bad.typl"), [0xFF, 0xFE, 0x00, 0x9F])
1193            .expect("write the non-UTF8 fixture");
1194
1195        let mut db = RidlDatabase::default();
1196        let loaded = load_workspace(&mut db, dir.path()).expect("the load continues");
1197        assert_eq!(loaded.diagnostics.len(), 1);
1198        assert!(
1199            loaded.diagnostics[0].message.contains("UTF-8"),
1200            "the diagnostic names the encoding problem",
1201        );
1202
1203        let packages = loaded.workspace.packages(&db).clone();
1204        assert_eq!(packages.len(), 1);
1205        let files = packages[0].files(&db).clone();
1206        assert_eq!(files.len(), 1, "only the valid file loads");
1207        assert!(files[0].path(&db).ends_with("a.typl"));
1208    }
1209
1210    /// (g) Single-file mode: a bare `.typl` file with no manifest up the tree
1211    /// loads as one synthetic package named from its declared package, exempt
1212    /// from TYPL-002. The E0 walking-skeleton fixture is the contract input.
1213    #[test]
1214    fn single_file_mode_loads_the_e0_fixture() {
1215        let fixture = concat!(
1216            env!("CARGO_MANIFEST_DIR"),
1217            "/../ridl-syntax/fixtures/walking_skeleton.typl",
1218        );
1219        let text = fs::read_to_string(fixture).expect("the E0 fixture exists");
1220        assert!(
1221            text.contains("package fixtures"),
1222            "the fixture declares `package fixtures`",
1223        );
1224
1225        // Copied into an empty temp dir so no manifest exists up the tree —
1226        // and the directory name never matches the declared package, which
1227        // proves the TYPL-002 exemption.
1228        let dir = TempDir::new("single-file");
1229        let path = dir.write("walking_skeleton.typl", &text);
1230
1231        let mut db = RidlDatabase::default();
1232        let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
1233        assert_eq!(loaded.diagnostics, Vec::new(), "exempt from TYPL-002");
1234
1235        let packages = loaded.workspace.packages(&db).clone();
1236        assert_eq!(packages.len(), 1, "one synthetic package");
1237        assert_eq!(
1238            packages[0].name(&db).as_str(),
1239            "fixtures",
1240            "named from the file's declared package",
1241        );
1242        assert_eq!(*packages[0].origin(&db), PackageOrigin::WorkspaceMember);
1243
1244        let files = packages[0].files(&db).clone();
1245        assert_eq!(files.len(), 1);
1246        assert_eq!(
1247            parse_file(&db, files[0]).errors(),
1248            &[],
1249            "the fixture parses clean"
1250        );
1251    }
1252
1253    // --- the interface lock (lock design §2, §8) --------------------------
1254
1255    const LOCK_TEXT: &str = "\
1256# interfaces.lock — written by ridl lock; do not edit by hand.
1257next 3
1258Cabin 1
1259service:veh.common.climate 2
1260";
1261
1262    /// A well-formed `interfaces.lock` beside the sources rides on the
1263    /// package: its path, its text and the parsed table (the text and path
1264    /// travel so a later checker diagnostic can point into the file).
1265    #[test]
1266    fn a_lock_beside_the_sources_rides_on_the_package() {
1267        let dir = TempDir::new("lock");
1268        dir.write("ridl.toml", PACKAGE_MANIFEST);
1269        dir.write("a.ridl", "package veh.common\ntype A: m\n");
1270        let lock_path = dir.write("interfaces.lock", LOCK_TEXT);
1271
1272        let mut db = RidlDatabase::default();
1273        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1274        assert_eq!(
1275            loaded.diagnostics,
1276            Vec::new(),
1277            "a well-formed lock draws nothing"
1278        );
1279
1280        let packages = loaded.workspace.packages(&db).clone();
1281        let lock = packages[0]
1282            .lock(&db)
1283            .as_ref()
1284            .expect("the lock rides on the package");
1285        assert_eq!(lock.path, path_string(&lock_path));
1286        assert_eq!(lock.text, LOCK_TEXT);
1287        assert_eq!(lock.lock.next, 3);
1288        assert_eq!(lock.lock.entries.len(), 2);
1289        assert_eq!(
1290            lock.lock.entries[1].key,
1291            crate::interface_lock::LockKey::Service("veh.common.climate".to_string())
1292        );
1293    }
1294
1295    #[test]
1296    fn a_package_with_no_lock_has_none() {
1297        let dir = TempDir::new("no-lock");
1298        dir.write("ridl.toml", PACKAGE_MANIFEST);
1299        dir.write("a.ridl", "package veh.common\ntype A: m\n");
1300
1301        let mut db = RidlDatabase::default();
1302        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1303        assert_eq!(loaded.diagnostics, Vec::new());
1304        let packages = loaded.workspace.packages(&db).clone();
1305        assert_eq!(*packages[0].lock(&db), None);
1306    }
1307
1308    /// RIDL-410 is reported on the offending line of the lock file itself,
1309    /// through the loader's source map (PD-3), and the package then carries
1310    /// no lock.
1311    #[test]
1312    fn a_malformed_lock_is_ridl_410_on_its_own_line() {
1313        let dir = TempDir::new("bad-lock");
1314        dir.write("ridl.toml", PACKAGE_MANIFEST);
1315        dir.write("a.ridl", "package veh.common\ntype A: m\n");
1316        let text = "# interfaces.lock — written by ridl lock; do not edit by hand.\n\
1317                    next 2\n\
1318                    Cabin 1\n\
1319                    Door 1\n";
1320        let lock_path = dir.write("interfaces.lock", text);
1321
1322        let mut db = RidlDatabase::default();
1323        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1324        assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
1325        let diagnostic = &loaded.diagnostics[0];
1326        assert_eq!(diagnostic.severity, Severity::Error);
1327        assert_eq!(
1328            loaded.sources.path(diagnostic.primary.file),
1329            Some(path_string(&lock_path).as_str()),
1330            "the span is in the lock file"
1331        );
1332        assert_eq!(loaded.sources.text(diagnostic.primary.file), Some(text));
1333        let line_start = text
1334            .find("Door 1")
1335            .expect("the offending line is in the text");
1336        assert_eq!(
1337            diagnostic.primary.range,
1338            byte_range(line_start, line_start + "Door 1".len()),
1339            "the span is the offending line"
1340        );
1341        assert_eq!(
1342            diagnostic.message,
1343            "`interfaces.lock` is malformed: number 1 is on two entries: `Cabin` and `Door` — \
1344             resolve the conflict or restore the file from version control, then run `ridl lock`"
1345        );
1346
1347        let packages = loaded.workspace.packages(&db).clone();
1348        assert_eq!(
1349            *packages[0].lock(&db),
1350            None,
1351            "a malformed lock does not ride on the package"
1352        );
1353    }
1354
1355    /// PD-3: an empty lock file, which has no `next` line, is reported at
1356    /// 0..0 of the file.
1357    #[test]
1358    fn an_empty_lock_is_ridl_410_at_the_start_of_the_file() {
1359        let dir = TempDir::new("empty-lock");
1360        dir.write("ridl.toml", PACKAGE_MANIFEST);
1361        dir.write("a.ridl", "package veh.common\ntype A: m\n");
1362        dir.write("interfaces.lock", "");
1363
1364        let mut db = RidlDatabase::default();
1365        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1366        assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
1367        assert_eq!(loaded.diagnostics[0].primary.range, byte_range(0, 0));
1368        assert!(
1369            loaded.diagnostics[0].message.contains("no `next` line"),
1370            "got: {}",
1371            loaded.diagnostics[0].message
1372        );
1373    }
1374
1375    /// A lock file that is not valid UTF-8 is RIDL-410 at 0..0 too — the
1376    /// treatment the loader gives a source file that is not valid UTF-8.
1377    #[test]
1378    fn a_lock_that_is_not_utf8_is_ridl_410() {
1379        let dir = TempDir::new("binary-lock");
1380        dir.write("ridl.toml", PACKAGE_MANIFEST);
1381        dir.write("a.ridl", "package veh.common\ntype A: m\n");
1382        fs::write(dir.path().join("interfaces.lock"), [0xff, 0xfe, b'\n'])
1383            .expect("write the bytes");
1384
1385        let mut db = RidlDatabase::default();
1386        let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
1387        assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
1388        assert!(
1389            loaded.diagnostics[0].message.contains("not valid UTF-8"),
1390            "got: {}",
1391            loaded.diagnostics[0].message
1392        );
1393        assert_eq!(loaded.diagnostics[0].primary.range, byte_range(0, 0));
1394        let packages = loaded.workspace.packages(&db).clone();
1395        assert_eq!(*packages[0].lock(&db), None);
1396    }
1397
1398    /// PD-8: a bare `.ridl` file with no manifest reads `interfaces.lock`
1399    /// from the file's directory.
1400    #[test]
1401    fn single_file_mode_reads_the_lock_beside_the_file() {
1402        let dir = TempDir::new("single-lock");
1403        let path = dir.write("iface.ridl", "package veh.iface\ntype A: m\n");
1404        dir.write("interfaces.lock", LOCK_TEXT);
1405
1406        let mut db = RidlDatabase::default();
1407        let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
1408        assert_eq!(loaded.diagnostics, Vec::new());
1409        let packages = loaded.workspace.packages(&db).clone();
1410        let lock = packages[0]
1411            .lock(&db)
1412            .as_ref()
1413            .expect("the lock rides on the synthetic package");
1414        assert_eq!(lock.lock.next, 3);
1415    }
1416
1417    /// The file is per package (lock design §2): a subdirectory package reads
1418    /// its own directory's lock, not its parent's.
1419    #[test]
1420    fn a_subdirectory_package_reads_its_own_lock() {
1421        let dir = TempDir::new("subdir-lock");
1422        dir.write("ridl.toml", PACKAGE_MANIFEST);
1423        dir.write("a.ridl", "package veh.common\ntype A: m\n");
1424        dir.write("interfaces.lock", LOCK_TEXT);
1425        dir.write("sub/b.ridl", "package veh.common.sub\ntype B: m\n");
1426
1427        let mut db = RidlDatabase::default();
1428        let loaded = load_workspace(&mut db, dir.path()).expect("the tree loads");
1429        assert_eq!(loaded.diagnostics, Vec::new());
1430        let packages = loaded.workspace.packages(&db).clone();
1431        assert_eq!(packages.len(), 2);
1432        assert!(
1433            packages[0].lock(&db).is_some(),
1434            "the root package has a lock"
1435        );
1436        assert_eq!(
1437            *packages[1].lock(&db),
1438            None,
1439            "the subdirectory package has none"
1440        );
1441    }
1442}