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