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