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