1use 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
41pub struct LoadedWorkspace {
46 pub workspace: Workspace,
47 pub diagnostics: Vec<Diagnostic>,
48 pub sources: SourceMap,
49}
50
51pub 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
102pub 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
111type LoadedFile = (InputFile, Vec<(String, TextRange)>);
114
115#[derive(Default)]
117struct Loader {
118 sources: SourceMap,
119 diagnostics: Vec<Diagnostic>,
120 packages: Vec<Package>,
121 workspace_imports: BTreeMap<String, String>,
124 workspace_default_timing: Option<String>,
129}
130
131impl Loader {
132 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 self.load_package_tree(db, root, &name, &imports, &default_timing)?;
154 }
155 ManifestKind::Workspace { members } => {
156 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 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 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 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 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 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 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 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 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 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 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 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
475fn path_string(path: &Path) -> String {
477 path.to_string_lossy().into_owned()
478}
479
480fn 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
490fn member_entry_range(text: &str, member: &str) -> TextRange {
494 let quoted = format!("\"{member}\"");
495 match text.find("ed) {
496 Some(start) => byte_range(start, start + quoted.len()),
497 None => byte_range(0, text.len()),
498 }
499}
500
501fn 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
511fn byte_range(start: usize, end: usize) -> TextRange {
513 TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
514}
515
516fn 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 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 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 #[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 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 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 #[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 #[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 #[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 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 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 #[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 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 #[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 #[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 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 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 #[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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}