use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use ridl_syntax::ast::{AstNode as _, SourceFile};
use rowan::{TextRange, TextSize};
use crate::db::{InputFile, RidlDatabase, parse_file};
use crate::diag::{DiagCode, Diagnostic, FileId, Severity, SourceMap, Span};
use crate::interface_lock;
use crate::manifest::{Manifest, ManifestKind, parse_manifest};
use crate::package::{Package, PackageLock, PackageOrigin, Workspace, package_declarations};
pub struct LoadedWorkspace {
pub workspace: Workspace,
pub diagnostics: Vec<Diagnostic>,
pub sources: SourceMap,
}
pub fn load_workspace(db: &mut RidlDatabase, entry: &Path) -> io::Result<LoadedWorkspace> {
let mut loader = Loader::default();
if entry.is_file() {
match entry.parent().and_then(find_manifest_root) {
Some(root) => loader.load_root(db, &root)?,
None => loader.load_single_file(db, entry)?,
}
} else if entry.is_dir() {
match find_manifest_root(entry) {
Some(root) => loader.load_root(db, &root)?,
None => {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no `ridl.toml` found at or above `{}`", entry.display()),
));
}
}
} else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("`{}` does not exist", entry.display()),
));
}
let workspace = Workspace::new(&*db, loader.packages, loader.workspace_imports);
Ok(LoadedWorkspace {
workspace,
diagnostics: loader.diagnostics,
sources: loader.sources,
})
}
pub fn find_manifest_root(dir: &Path) -> Option<PathBuf> {
dir.ancestors()
.find(|candidate| candidate.join("ridl.toml").is_file())
.map(Path::to_path_buf)
}
type LoadedFile = (InputFile, Vec<(String, TextRange)>);
#[derive(Default)]
struct Loader {
sources: SourceMap,
diagnostics: Vec<Diagnostic>,
packages: Vec<Package>,
workspace_imports: BTreeMap<String, String>,
workspace_default_timing: Option<String>,
}
impl Loader {
fn load_root(&mut self, db: &mut RidlDatabase, root: &Path) -> io::Result<()> {
let manifest_path = root.join("ridl.toml");
let text = fs::read_to_string(&manifest_path)?;
let file_id = self.sources.file_id(&path_string(&manifest_path), &text);
let (manifest, diags) = parse_manifest(file_id, &text);
self.diagnostics.extend(diags);
let Some(Manifest {
kind,
imports,
default_timing,
}) = manifest
else {
return Ok(());
};
match kind {
ManifestKind::Package { name, .. } => {
self.load_package_tree(db, root, &name, &imports, &default_timing)?;
}
ManifestKind::Workspace { members } => {
self.workspace_imports = imports;
self.workspace_default_timing = default_timing;
for member in &members {
self.load_member(db, root, member, file_id, &text)?;
}
}
}
Ok(())
}
fn load_member(
&mut self,
db: &mut RidlDatabase,
workspace_root: &Path,
member: &str,
workspace_file: FileId,
workspace_text: &str,
) -> io::Result<()> {
let manifest_path = workspace_root.join(member).join("ridl.toml");
if !manifest_path.is_file() {
self.diagnostics.push(error(
DiagCode::MANI_008,
workspace_file,
member_entry_range(workspace_text, member),
format!("workspace member `{member}` has no `ridl.toml`"),
));
return Ok(());
}
let text = fs::read_to_string(&manifest_path)?;
let file_id = self.sources.file_id(&path_string(&manifest_path), &text);
let (manifest, diags) = parse_manifest(file_id, &text);
self.diagnostics.extend(diags);
let Some(Manifest {
kind,
imports,
default_timing,
}) = manifest
else {
return Ok(());
};
match kind {
ManifestKind::Workspace { .. } => {
self.diagnostics.push(error(
DiagCode::MANI_004,
file_id,
workspace_section_range(&text),
format!(
"workspace member `{member}` declares `[workspace]`; nested workspaces are forbidden"
),
));
}
ManifestKind::Package { name, .. } => {
let member_default_timing =
default_timing.or_else(|| self.workspace_default_timing.clone());
self.load_package_tree(
db,
&workspace_root.join(member),
&name,
&imports,
&member_default_timing,
)?;
}
}
Ok(())
}
fn load_package_tree(
&mut self,
db: &mut RidlDatabase,
dir: &Path,
name: &str,
imports: &BTreeMap<String, String>,
default_timing: &Option<String>,
) -> io::Result<()> {
let mut source_files = Vec::new();
let mut subdirs = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let is_symlink = entry.file_type()?.is_symlink();
if path.is_dir() {
if !is_symlink {
subdirs.push(path);
}
} else if path
.extension()
.is_some_and(|ext| ext == "typl" || ext == "ridl" || ext == "rsdl")
{
source_files.push(path);
}
}
source_files.sort();
subdirs.sort();
if !source_files.is_empty() {
let mut files = Vec::new();
for path in &source_files {
if let Some((input, _)) = self.load_file(db, path, Some(name))? {
files.push(input);
}
}
let lock = self.read_lock(dir)?;
self.packages.push(Package::new(
&*db,
name.to_string(),
files,
PackageOrigin::WorkspaceMember,
imports.clone(),
default_timing.clone(),
lock,
));
}
for subdir in subdirs {
let Some(dir_name) = subdir.file_name().map(|n| n.to_string_lossy().into_owned())
else {
continue;
};
if dir_name.starts_with('.') || subdir.join("ridl.toml").is_file() {
continue;
}
self.load_package_tree(
db,
&subdir,
&format!("{name}.{dir_name}"),
imports,
default_timing,
)?;
}
Ok(())
}
fn load_single_file(&mut self, db: &mut RidlDatabase, path: &Path) -> io::Result<()> {
let Some((input, decls)) = self.load_file(db, path, None)? else {
return Ok(());
};
let name = decls
.first()
.map(|(name, _)| name.clone())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| {
path.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "package".to_string())
});
let lock = match path.parent() {
Some(dir) => self.read_lock(dir)?,
None => None,
};
self.packages.push(Package::new(
&*db,
name,
vec![input],
PackageOrigin::WorkspaceMember,
BTreeMap::new(),
None,
lock,
));
Ok(())
}
fn read_lock(&mut self, dir: &Path) -> io::Result<Option<PackageLock>> {
let path = path_string(&dir.join(interface_lock::FILE_NAME));
let text = match interface_lock::read(dir) {
Ok(Some(text)) => text,
Ok(None) => return Ok(None),
Err(err) if err.kind() == io::ErrorKind::InvalidData => {
let file_id = self.sources.file_id(&path, "");
self.diagnostics.push(error(
DiagCode::RIDL_410,
file_id,
byte_range(0, 0),
malformed_lock_message("the file is not valid UTF-8"),
));
return Ok(None);
}
Err(err) => return Err(err),
};
match interface_lock::parse(&text) {
Ok(lock) => Ok(Some(PackageLock { path, text, lock })),
Err(malformed) => {
let file_id = self.sources.file_id(&path, &text);
self.diagnostics.push(error(
DiagCode::RIDL_410,
file_id,
malformed.range,
malformed_lock_message(&malformed.message),
));
Ok(None)
}
}
}
fn load_file(
&mut self,
db: &mut RidlDatabase,
path: &Path,
expected: Option<&str>,
) -> io::Result<Option<LoadedFile>> {
let path_str = path_string(path);
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::InvalidData => {
let file_id = self.sources.file_id(&path_str, "");
self.diagnostics.push(error(
DiagCode::NONE,
file_id,
byte_range(0, 0),
format!("`{path_str}` is not valid UTF-8; the file is skipped"),
));
return Ok(None);
}
Err(err) => return Err(err),
};
let file_id = self.sources.file_id(&path_str, &text);
let input = InputFile::new(&*db, path_str, text);
let parse = parse_file(&*db, input);
let source =
SourceFile::cast(parse.syntax()).expect("parser roots every tree in a SourceFile");
let decls = package_declarations(&source);
for (_, range) in decls.iter().skip(1) {
self.diagnostics.push(error(
DiagCode::TYPL_001,
file_id,
*range,
"more than one `package` declaration in this file".to_string(),
));
}
if let Some((declared, range)) = decls.first()
&& crate::std_lib::is_reserved_package_name(declared)
{
self.diagnostics.push(error(
DiagCode::TYPL_010,
file_id,
*range,
format!(
"`{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"
),
));
}
if let (Some(expected), Some((declared, range))) = (expected, decls.first())
&& !declared.is_empty()
&& declared != expected
{
self.diagnostics.push(error(
DiagCode::TYPL_002,
file_id,
*range,
format!(
"package name `{declared}` does not mirror the directory path; every file in this directory must declare `package {expected}`"
),
));
}
Ok(Some((input, decls)))
}
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
fn malformed_lock_message(reason: &str) -> String {
format!(
"`{}` is malformed: {reason} — resolve the conflict or restore the file from version \
control, then run `ridl lock`",
interface_lock::FILE_NAME
)
}
fn member_entry_range(text: &str, member: &str) -> TextRange {
let quoted = format!("\"{member}\"");
match text.find("ed) {
Some(start) => byte_range(start, start + quoted.len()),
None => byte_range(0, text.len()),
}
}
fn workspace_section_range(text: &str) -> TextRange {
const HEADER: &str = "[workspace]";
match text.find(HEADER) {
Some(start) => byte_range(start, start + HEADER.len()),
None => byte_range(0, text.len()),
}
}
fn byte_range(start: usize, end: usize) -> TextRange {
TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
}
fn error(code: DiagCode, file: FileId, range: TextRange, message: String) -> Diagnostic {
Diagnostic {
code,
severity: Severity::Error,
message,
primary: Span { file, range },
labels: Vec::new(),
fixits: Vec::new(),
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use salsa::Setter;
use salsa::plumbing::AsId;
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let mut path = std::env::temp_dir();
path.push(format!(
"ridl-core-workspace-{label}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst),
));
fs::create_dir_all(&path).expect("create the temp dir");
Self(path)
}
fn path(&self) -> &Path {
&self.0
}
fn write(&self, relative: &str, text: &str) -> PathBuf {
let path = self.0.join(relative);
fs::create_dir_all(path.parent().expect("relative paths have a parent"))
.expect("create parent directories");
fs::write(&path, text).expect("write the fixture file");
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn codes(diags: &[Diagnostic]) -> Vec<&str> {
diags.iter().map(|d| d.code.as_str()).collect()
}
const PACKAGE_MANIFEST: &str = "[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n";
#[test]
fn a_package_declaring_a_reserved_name_is_refused() {
const SOURCE: &str = "package ridl.std\ntype MyOwnType: m\n";
let dir = TempDir::new("reserved-name");
dir.write(
"ridl.toml",
"[package]\nname = \"ridl.std\"\nversion = \"1.0.0\"\n",
);
dir.write("own.typl", SOURCE);
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(
codes(&loaded.diagnostics),
vec!["TYPL-010"],
"got: {:?}",
loaded.diagnostics
);
let diagnostic = &loaded.diagnostics[0];
assert!(
diagnostic.message.contains("ridl.std"),
"the message names the package: {}",
diagnostic.message
);
assert!(
diagnostic.message.contains("unreachable"),
"the message states the consequence that always holds: {}",
diagnostic.message
);
for conditional in ["artifact", "overwrit"] {
assert!(
!diagnostic.message.contains(conditional),
"`{conditional}` is true only where the output base is the package name, \
so it belongs in the catalogue entry, not the message: {}",
diagnostic.message
);
}
let declaration = SOURCE
.lines()
.next()
.expect("the declaration is the first line");
assert_eq!(
(
usize::from(diagnostic.primary.range.start()),
usize::from(diagnostic.primary.range.end()),
),
(0, declaration.len()),
"reported on `{declaration}` exactly"
);
}
#[test]
fn a_single_file_declaring_a_reserved_name_is_refused() {
let dir = TempDir::new("reserved-name-single");
let file = dir.write("ridl_std.typl", "package ridl.std\ntype MyOwnType: m\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, &file).expect("single-file mode loads");
assert_eq!(
codes(&loaded.diagnostics),
vec!["TYPL-010"],
"got: {:?}",
loaded.diagnostics
);
}
#[test]
fn only_a_compiler_provided_name_is_reserved() {
let dir = TempDir::new("near-reserved");
dir.write(
"ridl.toml",
"[package]\nname = \"ridl.stdlib\"\nversion = \"1.0.0\"\n",
);
dir.write("own.typl", "package ridl.stdlib\ntype MyOwnType: m\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(
loaded.diagnostics,
Vec::new(),
"`ridl.stdlib` is not a package the compiler provides"
);
}
#[test]
fn two_file_package_loads_and_edit_reparses_only_the_edited_file() {
let dir = TempDir::new("two-file");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.typl", "package veh.common\ntype A: m\n");
dir.write("b.typl", "package veh.common\ntype B: s\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(
loaded.diagnostics,
Vec::new(),
"a clean package, no diagnostics"
);
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1, "one package directory, one package");
assert_eq!(packages[0].name(&db).as_str(), "veh.common");
assert_eq!(*packages[0].origin(&db), PackageOrigin::WorkspaceMember);
assert_eq!(
packages[0].imports(&db),
&BTreeMap::new(),
"a manifest without `[imports]` yields an empty package map",
);
assert_eq!(
loaded.workspace.imports(&db),
&BTreeMap::new(),
"a standalone load leaves the workspace map empty",
);
let files = packages[0].files(&db).clone();
assert_eq!(files.len(), 2, "both .typl files load");
for file in &files {
assert_eq!(
parse_file(&db, *file).errors(),
&[],
"both files parse clean"
);
}
let a = files
.iter()
.copied()
.find(|f| f.path(&db).ends_with("a.typl"))
.expect("a.typl is loaded");
let b = files
.iter()
.copied()
.find(|f| f.path(&db).ends_with("b.typl"))
.expect("b.typl is loaded");
db.take_executed_queries();
let _ = parse_file(&db, a);
let _ = parse_file(&db, b);
assert_eq!(
db.take_executed_queries(),
Vec::new(),
"re-querying unchanged inputs must run no executions",
);
a.set_text(&mut db)
.to("package veh.common\ntype A: kg\n".to_string());
let _ = parse_file(&db, a);
let _ = parse_file(&db, b);
let executed = db.take_executed_queries();
assert_eq!(
executed.len(),
1,
"editing one file re-parses exactly one file"
);
assert_eq!(
salsa::attach(&db, || format!("{:?}", executed[0])),
format!("parse_file({:?})", a.as_id()),
"the re-executed query is the parse of the edited file",
);
}
#[test]
fn typl_002_on_a_mismatching_file() {
let dir = TempDir::new("mismatch");
dir.write("ridl.toml", PACKAGE_MANIFEST);
let bad_text = "package veh.wrong\ntype B: s\n";
let bad_path = dir.write("bad.typl", bad_text);
let mut db = RidlDatabase::default();
let mut loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(codes(&loaded.diagnostics), vec!["TYPL-002"]);
let diag = &loaded.diagnostics[0];
assert_eq!(diag.severity, Severity::Error);
assert_eq!(
diag.primary.range,
byte_range(0, "package veh.wrong".len()),
"the primary span is the mismatching `package` line",
);
assert_eq!(
diag.primary.file,
loaded.sources.file_id(&path_string(&bad_path), bad_text),
"the span points into the mismatching file",
);
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].files(&db).len(), 1);
}
#[test]
fn typl_001_on_a_double_package_declaration() {
let dir = TempDir::new("double-decl");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write(
"dup.typl",
"package veh.common\npackage veh.extra\ntype A: m\n",
);
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(codes(&loaded.diagnostics), vec!["TYPL-001"]);
assert_eq!(
loaded.diagnostics[0].primary.range,
byte_range(19, 36),
"the primary span is the second `package` declaration",
);
}
#[test]
fn workspace_mode_scopes_imports_per_package() {
let dir = TempDir::new("workspace");
dir.write(
"ridl.toml",
"[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",
);
dir.write(
"m-one/ridl.toml",
"[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",
);
dir.write("m-one/one.typl", "package veh.one\ntype A: m\n");
dir.write(
"m-two/ridl.toml",
"[package]\nname = \"veh.two\"\nversion = \"1.0.0\"\n\n[imports]\n\"two.only\" = \"https://registry.example.com/two/only@v1.0.0\"\n",
);
dir.write("m-two/two.typl", "package veh.two\ntype B: s\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
let packages = loaded.workspace.packages(&db).clone();
let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
assert_eq!(
names,
vec!["veh.one", "veh.two"],
"both members load, in member order"
);
let workspace_imports = loaded.workspace.imports(&db).clone();
assert_eq!(
workspace_imports.get("third.dep").map(String::as_str),
Some("https://registry.example.com/third/dep@v1.0.0"),
"the workspace map keeps the root pin, not the member pin",
);
assert_eq!(
workspace_imports.get("shared.util").map(String::as_str),
Some("https://registry.example.com/shared/util@v1.0.0"),
);
assert_eq!(workspace_imports.len(), 2, "no member entry leaks upward");
let one_imports = packages[0].imports(&db).clone();
assert_eq!(
one_imports.get("third.dep").map(String::as_str),
Some("https://mirror.example.com/third/dep@v2.0.0"),
"the member's own pin shadows the workspace default for it alone",
);
assert_eq!(
one_imports.get("member.only").map(String::as_str),
Some("https://registry.example.com/member/only@v1.0.0"),
);
assert!(
!one_imports.contains_key("two.only"),
"a sibling's pin never leaks into another member",
);
assert_eq!(one_imports.len(), 2, "no workspace entry is merged in");
let two_imports = packages[1].imports(&db).clone();
assert_eq!(
two_imports.get("two.only").map(String::as_str),
Some("https://registry.example.com/two/only@v1.0.0"),
);
assert!(
!two_imports.contains_key("member.only"),
"the sibling's pin never leaks into this member",
);
assert!(
!two_imports.contains_key("third.dep"),
"neither the root default nor the sibling's pin is merged in",
);
assert_eq!(two_imports.len(), 1);
}
#[test]
fn defaults_timing_precedence_package_shadows_workspace() {
let dir = TempDir::new("defaults-timing");
dir.write(
"ridl.toml",
"[workspace]\nmembers = [\"m-own\", \"m-inherit\"]\n\n[defaults]\ntiming = \"[100ms..1000ms]\"\n",
);
dir.write(
"m-own/ridl.toml",
"[package]\nname = \"veh.own\"\nversion = \"1.0.0\"\n\n[defaults]\ntiming = \"[50ms..2s]\"\n",
);
dir.write("m-own/own.typl", "package veh.own\ntype A: m\n");
dir.write(
"m-inherit/ridl.toml",
"[package]\nname = \"veh.inherit\"\nversion = \"1.0.0\"\n",
);
dir.write("m-inherit/inherit.typl", "package veh.inherit\ntype B: s\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
assert_eq!(loaded.diagnostics, Vec::new(), "a clean workspace");
let packages = loaded.workspace.packages(&db).clone();
let own = packages
.iter()
.find(|p| p.name(&db) == "veh.own")
.expect("m-own loads");
assert_eq!(
own.default_timing(&db).as_deref(),
Some("[50ms..2s]"),
"the member's own `[defaults]` shadows the workspace default",
);
let inherit = packages
.iter()
.find(|p| p.name(&db) == "veh.inherit")
.expect("m-inherit loads");
assert_eq!(
inherit.default_timing(&db).as_deref(),
Some("[100ms..1000ms]"),
"a member without `[defaults]` inherits the workspace default",
);
}
#[test]
fn standalone_defaults_timing_rides_on_the_tree() {
let dir = TempDir::new("standalone-defaults");
dir.write(
"ridl.toml",
"[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n[defaults]\ntiming = \"[20ms..200ms]\"\n",
);
dir.write("a.typl", "package veh.common\ntype A: m\n");
dir.write("sub/s.typl", "package veh.common.sub\ntype S: s\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
assert_eq!(loaded.diagnostics, Vec::new());
for package in loaded.workspace.packages(&db) {
assert_eq!(
package.default_timing(&db).as_deref(),
Some("[20ms..200ms]"),
"every package in the tree carries the manifest default",
);
}
let bare = TempDir::new("standalone-defaults-bare");
let single = bare.write("iface.ridl", "package solo\ntype A: m\n");
let mut single_db = RidlDatabase::default();
let single_loaded =
load_workspace(&mut single_db, &single).expect("single-file mode loads");
assert_eq!(
single_loaded.workspace.packages(&single_db)[0].default_timing(&single_db),
&None,
"single-file mode carries no configured default",
);
}
#[test]
fn mani_004_on_a_nested_workspace_member() {
let dir = TempDir::new("nested");
dir.write(
"ridl.toml",
"[workspace]\nmembers = [\"m-bad\", \"m-good\"]\n",
);
let bad_text = "[workspace]\nmembers = []\n";
let bad_path = dir.write("m-bad/ridl.toml", bad_text);
dir.write(
"m-good/ridl.toml",
"[package]\nname = \"veh.good\"\nversion = \"1.0.0\"\n",
);
dir.write("m-good/good.typl", "package veh.good\ntype A: m\n");
let mut db = RidlDatabase::default();
let mut loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
assert_eq!(codes(&loaded.diagnostics), vec!["MANI-004"]);
let diag = &loaded.diagnostics[0];
assert_eq!(diag.severity, Severity::Error);
assert_eq!(
diag.primary.file,
loaded.sources.file_id(&path_string(&bad_path), bad_text),
"the span points into the member's own manifest",
);
assert_eq!(
diag.primary.range,
byte_range(0, "[workspace]".len()),
"the span is the `[workspace]` section header",
);
let packages = loaded.workspace.packages(&db).clone();
let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
assert_eq!(names, vec!["veh.good"], "the nested member loads nothing");
}
#[test]
fn mani_008_on_a_missing_member_directory() {
let dir = TempDir::new("missing-member");
dir.write(
"ridl.toml",
"[workspace]\nmembers = [\"m-gone\", \"m-good\"]\n",
);
dir.write(
"m-good/ridl.toml",
"[package]\nname = \"veh.good\"\nversion = \"1.0.0\"\n",
);
dir.write("m-good/good.typl", "package veh.good\ntype A: m\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the workspace loads");
assert_eq!(codes(&loaded.diagnostics), vec!["MANI-008"]);
assert_eq!(loaded.diagnostics[0].severity, Severity::Error);
assert!(loaded.diagnostics[0].message.contains("m-gone"));
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name(&db).as_str(), "veh.good");
}
#[test]
fn a_subdirectory_is_its_own_package_named_by_its_path() {
let dir = TempDir::new("subdir");
dir.write(
"ridl.toml",
"[package]\nname = \"veh.common\"\nversion = \"1.0.0\"\n\n[imports]\n\"some.dep\" = \"https://registry.example.com/some/dep@v1.0.0\"\n",
);
dir.write("a.typl", "package veh.common\ntype A: m\n");
dir.write("types/t.typl", "package veh.common.types\ntype T: s\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package tree loads");
assert_eq!(loaded.diagnostics, Vec::new());
let packages = loaded.workspace.packages(&db).clone();
let names: Vec<String> = packages.iter().map(|p| p.name(&db).clone()).collect();
assert_eq!(names, vec!["veh.common", "veh.common.types"]);
for package in &packages {
assert_eq!(
package.imports(&db).get("some.dep").map(String::as_str),
Some("https://registry.example.com/some/dep@v1.0.0"),
"every package in the manifest's tree carries its `[imports]`",
);
}
assert_eq!(
loaded.workspace.imports(&db),
&BTreeMap::new(),
"a standalone load leaves the workspace map empty",
);
}
#[test]
fn a_package_directory_mixes_typl_and_ridl_files() {
let dir = TempDir::new("mixed");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.typl", "package veh.common\ntype A: m\n");
dir.write("b.ridl", "package veh.common\ntype B: s\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(loaded.diagnostics, Vec::new(), "a clean mixed package");
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1, "one package directory, one package");
let files = packages[0].files(&db).clone();
assert_eq!(files.len(), 2, "both the .typl and the .ridl file load");
for file in &files {
assert_eq!(
parse_file(&db, *file).errors(),
&[],
"both files parse clean"
);
}
assert!(files.iter().any(|f| f.path(&db).ends_with("a.typl")));
assert!(files.iter().any(|f| f.path(&db).ends_with("b.ridl")));
}
#[test]
fn a_package_directory_loads_its_rsdl_files() {
let dir = TempDir::new("rsdl");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.typl", "package veh.common\ntype A: m\n");
dir.write("b.ridl", "package veh.common\ntype B: s\n");
dir.write("c.rsdl", "package veh.common\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(loaded.diagnostics, Vec::new(), "a clean mixed package");
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1, "one package directory, one package");
let files = packages[0].files(&db).clone();
let paths: Vec<&str> = files.iter().map(|f| f.path(&db).as_str()).collect();
assert_eq!(files.len(), 3, "the .rsdl file loads too: {paths:?}");
let rsdl = files
.iter()
.find(|f| f.path(&db).ends_with("c.rsdl"))
.expect("the .rsdl file is a package file");
assert_eq!(
crate::db::profile_of_path(rsdl.path(&db)),
ridl_syntax::Profile::Rsdl
);
assert_eq!(
parse_file(&db, *rsdl).errors(),
&[],
"the .rsdl file parses clean"
);
}
#[test]
fn single_file_mode_accepts_a_bare_ridl_entry() {
let dir = TempDir::new("single-ridl");
let path = dir.write("iface.ridl", "package veh.iface\ntype A: m\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
assert_eq!(loaded.diagnostics, Vec::new(), "exempt from TYPL-002");
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1, "one synthetic package");
assert_eq!(
packages[0].name(&db).as_str(),
"veh.iface",
"named from the file's declared package",
);
}
#[cfg(unix)]
#[test]
fn a_symlinked_directory_is_not_followed() {
let dir = TempDir::new("symlink");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.typl", "package veh.common\ntype A: m\n");
std::os::unix::fs::symlink(dir.path(), dir.path().join("loop"))
.expect("create the directory symlink");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(loaded.diagnostics, Vec::new());
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1, "the symlink cycle adds no packages");
assert_eq!(packages[0].name(&db).as_str(), "veh.common");
}
#[test]
fn a_non_utf8_file_is_reported_and_skipped() {
let dir = TempDir::new("non-utf8");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.typl", "package veh.common\ntype A: m\n");
fs::write(dir.path().join("bad.typl"), [0xFF, 0xFE, 0x00, 0x9F])
.expect("write the non-UTF8 fixture");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the load continues");
assert_eq!(loaded.diagnostics.len(), 1);
assert!(
loaded.diagnostics[0].message.contains("UTF-8"),
"the diagnostic names the encoding problem",
);
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1);
let files = packages[0].files(&db).clone();
assert_eq!(files.len(), 1, "only the valid file loads");
assert!(files[0].path(&db).ends_with("a.typl"));
}
#[test]
fn single_file_mode_loads_the_e0_fixture() {
let fixture = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../ridl-syntax/fixtures/walking_skeleton.typl",
);
let text = fs::read_to_string(fixture).expect("the E0 fixture exists");
assert!(
text.contains("package fixtures"),
"the fixture declares `package fixtures`",
);
let dir = TempDir::new("single-file");
let path = dir.write("walking_skeleton.typl", &text);
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
assert_eq!(loaded.diagnostics, Vec::new(), "exempt from TYPL-002");
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 1, "one synthetic package");
assert_eq!(
packages[0].name(&db).as_str(),
"fixtures",
"named from the file's declared package",
);
assert_eq!(*packages[0].origin(&db), PackageOrigin::WorkspaceMember);
let files = packages[0].files(&db).clone();
assert_eq!(files.len(), 1);
assert_eq!(
parse_file(&db, files[0]).errors(),
&[],
"the fixture parses clean"
);
}
const LOCK_TEXT: &str = "\
# interfaces.lock — written by ridl lock; do not edit by hand.
next 3
Cabin 1
service:veh.common.climate 2
";
#[test]
fn a_lock_beside_the_sources_rides_on_the_package() {
let dir = TempDir::new("lock");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.ridl", "package veh.common\ntype A: m\n");
let lock_path = dir.write("interfaces.lock", LOCK_TEXT);
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(
loaded.diagnostics,
Vec::new(),
"a well-formed lock draws nothing"
);
let packages = loaded.workspace.packages(&db).clone();
let lock = packages[0]
.lock(&db)
.as_ref()
.expect("the lock rides on the package");
assert_eq!(lock.path, path_string(&lock_path));
assert_eq!(lock.text, LOCK_TEXT);
assert_eq!(lock.lock.next, 3);
assert_eq!(lock.lock.entries.len(), 2);
assert_eq!(
lock.lock.entries[1].key,
crate::interface_lock::LockKey::Service("veh.common.climate".to_string())
);
}
#[test]
fn a_package_with_no_lock_has_none() {
let dir = TempDir::new("no-lock");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.ridl", "package veh.common\ntype A: m\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(loaded.diagnostics, Vec::new());
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(*packages[0].lock(&db), None);
}
#[test]
fn a_malformed_lock_is_ridl_410_on_its_own_line() {
let dir = TempDir::new("bad-lock");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.ridl", "package veh.common\ntype A: m\n");
let text = "# interfaces.lock — written by ridl lock; do not edit by hand.\n\
next 2\n\
Cabin 1\n\
Door 1\n";
let lock_path = dir.write("interfaces.lock", text);
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
let diagnostic = &loaded.diagnostics[0];
assert_eq!(diagnostic.severity, Severity::Error);
assert_eq!(
loaded.sources.path(diagnostic.primary.file),
Some(path_string(&lock_path).as_str()),
"the span is in the lock file"
);
assert_eq!(loaded.sources.text(diagnostic.primary.file), Some(text));
let line_start = text
.find("Door 1")
.expect("the offending line is in the text");
assert_eq!(
diagnostic.primary.range,
byte_range(line_start, line_start + "Door 1".len()),
"the span is the offending line"
);
assert_eq!(
diagnostic.message,
"`interfaces.lock` is malformed: number 1 is on two entries: `Cabin` and `Door` — \
resolve the conflict or restore the file from version control, then run `ridl lock`"
);
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(
*packages[0].lock(&db),
None,
"a malformed lock does not ride on the package"
);
}
#[test]
fn an_empty_lock_is_ridl_410_at_the_start_of_the_file() {
let dir = TempDir::new("empty-lock");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.ridl", "package veh.common\ntype A: m\n");
dir.write("interfaces.lock", "");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
assert_eq!(loaded.diagnostics[0].primary.range, byte_range(0, 0));
assert!(
loaded.diagnostics[0].message.contains("no `next` line"),
"got: {}",
loaded.diagnostics[0].message
);
}
#[test]
fn a_lock_that_is_not_utf8_is_ridl_410() {
let dir = TempDir::new("binary-lock");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.ridl", "package veh.common\ntype A: m\n");
fs::write(dir.path().join("interfaces.lock"), [0xff, 0xfe, b'\n'])
.expect("write the bytes");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the package loads");
assert_eq!(codes(&loaded.diagnostics), ["RIDL-410"]);
assert!(
loaded.diagnostics[0].message.contains("not valid UTF-8"),
"got: {}",
loaded.diagnostics[0].message
);
assert_eq!(loaded.diagnostics[0].primary.range, byte_range(0, 0));
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(*packages[0].lock(&db), None);
}
#[test]
fn single_file_mode_reads_the_lock_beside_the_file() {
let dir = TempDir::new("single-lock");
let path = dir.write("iface.ridl", "package veh.iface\ntype A: m\n");
dir.write("interfaces.lock", LOCK_TEXT);
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, &path).expect("single-file mode loads");
assert_eq!(loaded.diagnostics, Vec::new());
let packages = loaded.workspace.packages(&db).clone();
let lock = packages[0]
.lock(&db)
.as_ref()
.expect("the lock rides on the synthetic package");
assert_eq!(lock.lock.next, 3);
}
#[test]
fn a_subdirectory_package_reads_its_own_lock() {
let dir = TempDir::new("subdir-lock");
dir.write("ridl.toml", PACKAGE_MANIFEST);
dir.write("a.ridl", "package veh.common\ntype A: m\n");
dir.write("interfaces.lock", LOCK_TEXT);
dir.write("sub/b.ridl", "package veh.common.sub\ntype B: m\n");
let mut db = RidlDatabase::default();
let loaded = load_workspace(&mut db, dir.path()).expect("the tree loads");
assert_eq!(loaded.diagnostics, Vec::new());
let packages = loaded.workspace.packages(&db).clone();
assert_eq!(packages.len(), 2);
assert!(
packages[0].lock(&db).is_some(),
"the root package has a lock"
);
assert_eq!(
*packages[1].lock(&db),
None,
"the subdirectory package has none"
);
}
}