use std::collections::{BTreeMap, HashMap, HashSet};
use ridl_syntax::ast::{AstNode as _, PathType, ServiceDef, SourceFile};
use ridl_syntax::{SyntaxKind, SyntaxNode};
use rowan::TextRange;
use crate::db::InputFile;
use crate::diag::{DiagCode, Diagnostic, Label, Severity, SourceMap, Span};
use crate::interface_lock::InterfaceLock;
use crate::manifest::TimingDefaults;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PackageOrigin {
WorkspaceMember,
Remote,
Std,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PackageLock {
pub path: String,
pub text: String,
pub lock: InterfaceLock,
}
#[salsa::input(debug)]
pub struct Package {
pub name: String,
#[returns(ref)]
pub files: Vec<InputFile>,
pub origin: PackageOrigin,
#[returns(ref)]
pub imports: BTreeMap<String, String>,
#[returns(ref)]
pub defaults: TimingDefaults,
#[returns(ref)]
pub lock: Option<PackageLock>,
}
#[salsa::input(debug)]
pub struct Workspace {
#[returns(ref)]
pub packages: Vec<Package>,
#[returns(ref)]
pub imports: BTreeMap<String, String>,
}
#[salsa::tracked(returns(clone))]
pub fn package_of(db: &dyn salsa::Database, ws: Workspace, name: String) -> Option<Package> {
ws.packages(db)
.iter()
.copied()
.find(|package| package.name(db) == &name)
}
#[cfg_attr(not(feature = "fs"), allow(dead_code))]
pub(crate) fn package_declarations(file: &SourceFile) -> Vec<(String, TextRange)> {
file.syntax()
.children()
.filter_map(ridl_syntax::ast::PackageDecl::cast)
.map(|decl| {
let name = decl
.qualified_name()
.map(|name| dotted_name(&name))
.unwrap_or_default();
(name, decl.syntax().text_range())
})
.collect()
}
#[cfg_attr(not(feature = "fs"), allow(dead_code))]
fn dotted_name(name: &ridl_syntax::ast::QualifiedName) -> String {
name.syntax()
.children_with_tokens()
.filter_map(|element| element.into_token())
.filter(|token| !token.kind().is_trivia())
.map(|token| token.text().to_string())
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogEntry {
pub package: String,
pub interface_refs: Vec<String>,
pub inline: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServiceCatalog {
pub entries: BTreeMap<String, CatalogEntry>,
pub diagnostics: Vec<Diagnostic>,
}
#[salsa::tracked(returns(clone))]
pub fn service_catalog(db: &dyn salsa::Database, ws: Workspace, std: Package) -> ServiceCatalog {
let _ = std;
let mut sources = SourceMap::new();
let mut entries: BTreeMap<String, CatalogEntry> = BTreeMap::new();
let mut first_span: HashMap<String, Span> = HashMap::new();
let mut diagnostics = Vec::new();
for package in ws.packages(db) {
let package_name = package.name(db).clone();
let names = package_names(db, package);
for file in package.files(db) {
let file_id = sources.file_id(file.path(db), file.text(db));
let source = service_source(db, *file);
for service in source.services() {
let Some(dotted) = service.name() else {
continue;
};
let name = significant_node_text(dotted.syntax());
if name.is_empty() {
continue;
}
let span = Span {
file: file_id,
range: dotted.syntax().text_range(),
};
if let Some(&first) = first_span.get(&name) {
diagnostics.push(Diagnostic {
code: DiagCode::RIDL_140,
severity: Severity::Error,
message: format!(
"duplicate service name `{name}` — the service catalog is a flat global namespace"
),
primary: span,
labels: vec![Label {
span: first,
message: format!("`{name}` is first declared here"),
}],
fixits: Vec::new(),
});
continue;
}
first_span.insert(name.clone(), span);
entries.insert(
name,
CatalogEntry {
package: package_name.clone(),
interface_refs: canonical_interface_refs(&names, &service),
inline: service.colon_token().is_none(),
},
);
}
}
}
ServiceCatalog {
entries,
diagnostics,
}
}
fn service_source(db: &dyn salsa::Database, file: InputFile) -> SourceFile {
let parse = crate::parse_file(db, file);
SourceFile::cast(parse.syntax()).expect("the parser roots every tree in a SourceFile")
}
fn canonical_interface_refs(names: &PackageNames, service: &ServiceDef) -> Vec<String> {
if service.colon_token().is_none() {
return Vec::new();
}
service
.shapes()
.map(|path| canonical_ref(names, &path))
.collect()
}
fn canonical_ref(names: &PackageNames, path: &PathType) -> String {
let written = significant_node_text(path.syntax());
if written.is_empty() || written.contains('.') {
return written;
}
if names.locals.contains(&written) {
return written;
}
if let Some(full) = names.imports.get(&written) {
return full.clone();
}
written
}
struct PackageNames {
locals: HashSet<String>,
imports: HashMap<String, String>,
}
fn package_names(db: &dyn salsa::Database, package: &Package) -> PackageNames {
let mut locals = HashSet::new();
let mut imports: HashMap<String, String> = HashMap::new();
for file in package.files(db) {
let source = service_source(db, *file);
for definition in source.definitions() {
if let Some(name) = declaration_name(definition.syntax()) {
locals.insert(name);
}
}
for interface in source.interfaces() {
if let Some(name) = declaration_name(interface.syntax()) {
locals.insert(name);
}
}
for import in source.imports() {
let Some(qualified) = import.qualified_name() else {
continue;
};
let full = significant_node_text(qualified.syntax());
let base = full.rsplit('.').next().unwrap_or(full.as_str()).to_string();
let local = import
.alias()
.and_then(|alias| alias.ident_token())
.map(|token| token.text().to_string())
.unwrap_or(base);
imports.entry(local).or_insert(full);
}
}
PackageNames { locals, imports }
}
fn declaration_name(node: &SyntaxNode) -> Option<String> {
node.children()
.find(|child| child.kind() == SyntaxKind::Name)?
.children_with_tokens()
.filter_map(|element| element.into_token())
.find(|token| token.kind() == SyntaxKind::Ident)
.map(|token| token.text().to_string())
}
fn significant_node_text(node: &SyntaxNode) -> String {
node.descendants_with_tokens()
.filter_map(|element| element.into_token())
.filter(|token| !token.kind().is_trivia())
.map(|token| token.text().to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::RidlDatabase;
fn file(db: &RidlDatabase, path: &str, text: &str) -> InputFile {
InputFile::new(db, path.to_string(), text.to_string())
}
#[test]
fn package_of_finds_a_package_by_name() {
let db = RidlDatabase::default();
let common = Package::new(
&db,
"veh.common".to_string(),
vec![file(&db, "veh-common/a.typl", "package veh.common")],
PackageOrigin::WorkspaceMember,
BTreeMap::new(),
TimingDefaults::default(),
None,
);
let cluster = Package::new(
&db,
"veh.cluster".to_string(),
vec![file(&db, "veh-cluster/b.typl", "package veh.cluster")],
PackageOrigin::WorkspaceMember,
BTreeMap::new(),
TimingDefaults::default(),
None,
);
let ws = Workspace::new(&db, vec![common, cluster], BTreeMap::new());
assert_eq!(
package_of(&db, ws, "veh.cluster".to_string()),
Some(cluster)
);
assert_eq!(package_of(&db, ws, "veh.common".to_string()), Some(common));
assert_eq!(package_of(&db, ws, "veh.absent".to_string()), None);
}
#[test]
fn package_declarations_reads_names_and_ranges() {
let parse = ridl_syntax::parse(
"package veh.common\npackage veh.extra\ntype A: m\n",
ridl_syntax::Profile::Typl,
);
let source = SourceFile::cast(parse.syntax()).expect("root is a SourceFile");
let decls = package_declarations(&source);
assert_eq!(decls.len(), 2, "both declarations are read");
assert_eq!(decls[0].0, "veh.common");
assert_eq!(decls[0].1, TextRange::new(0.into(), 18.into()));
assert_eq!(decls[1].0, "veh.extra");
assert_eq!(decls[1].1, TextRange::new(19.into(), 36.into()));
}
fn ridl_package(db: &RidlDatabase, name: &str, text: &str) -> Package {
Package::new(
db,
name.to_string(),
vec![file(db, &format!("{}.ridl", name.replace('.', "/")), text)],
PackageOrigin::WorkspaceMember,
BTreeMap::new(),
TimingDefaults::default(),
None,
)
}
#[test]
fn service_catalog_flags_a_duplicate_name_across_packages() {
use crate::std_lib::std_package;
let mut db = RidlDatabase::default();
let std = std_package(&mut db);
let adas = ridl_package(
&db,
"veh.adas",
"package veh.adas\nservice veh.adas.cruise : Foo\n",
);
let redundant = ridl_package(
&db,
"veh.redundant",
"package veh.redundant\nservice veh.adas.cruise : Bar\n",
);
let ws = Workspace::new(&db, vec![adas, redundant], BTreeMap::new());
let catalog = service_catalog(&db, ws, std);
assert_eq!(catalog.diagnostics.len(), 1);
let diagnostic = &catalog.diagnostics[0];
assert_eq!(diagnostic.code, DiagCode::RIDL_140);
assert_eq!(diagnostic.labels.len(), 1);
assert!(catalog.entries.contains_key("veh.adas.cruise"));
assert_eq!(catalog.entries["veh.adas.cruise"].package, "veh.adas");
}
#[test]
fn service_catalog_resolves_a_cross_package_interface_ref_through_imports() {
use crate::std_lib::std_package;
let mut db = RidlDatabase::default();
let std = std_package(&mut db);
let common = ridl_package(
&db,
"veh.common",
"package veh.common\ninterface CruiseControl {\n signal engaged : boolean\n}\n",
);
let adas = ridl_package(
&db,
"veh.adas",
"package veh.adas\nimport veh.common.CruiseControl\nservice veh.adas.cruise : CruiseControl\n",
);
let ws = Workspace::new(&db, vec![common, adas], BTreeMap::new());
let catalog = service_catalog(&db, ws, std);
assert!(catalog.diagnostics.is_empty());
let entry = &catalog.entries["veh.adas.cruise"];
assert_eq!(entry.package, "veh.adas");
assert_eq!(entry.interface_refs, ["veh.common.CruiseControl"]);
}
#[test]
fn service_catalog_records_every_shape_of_a_composed_service() {
use crate::std_lib::std_package;
let mut db = RidlDatabase::default();
let std = std_package(&mut db);
let common = ridl_package(
&db,
"veh.common",
"package veh.common\ninterface DiagBlock {\n query readFault(code: boolean): boolean\n}\n",
);
let body = ridl_package(
&db,
"veh.body",
"package veh.body\nimport veh.common.DiagBlock\n\
interface DoorControl {\n signal locked : boolean\n}\n\
service veh.body.doors : DoorControl, DiagBlock\n",
);
let ws = Workspace::new(&db, vec![common, body], BTreeMap::new());
let catalog = service_catalog(&db, ws, std);
assert!(catalog.diagnostics.is_empty());
let entry = &catalog.entries["veh.body.doors"];
assert_eq!(
entry.interface_refs,
["DoorControl", "veh.common.DiagBlock"],
"source order, local bare, import canonicalized",
);
assert!(!entry.inline);
}
#[test]
fn service_catalog_records_an_inline_shape_with_an_empty_ref() {
use crate::std_lib::std_package;
let mut db = RidlDatabase::default();
let std = std_package(&mut db);
let hvac = ridl_package(
&db,
"veh.hvac",
"package veh.hvac\nservice veh.hvac.cabin {\n signal temperature : boolean\n}\n",
);
let ws = Workspace::new(&db, vec![hvac], BTreeMap::new());
let catalog = service_catalog(&db, ws, std);
assert!(catalog.diagnostics.is_empty());
let entry = &catalog.entries["veh.hvac.cabin"];
assert_eq!(entry.package, "veh.hvac");
assert_eq!(entry.interface_refs, Vec::<String>::new());
assert!(entry.inline);
}
#[test]
fn service_catalog_tells_a_tombstone_only_list_from_an_inline_shape() {
use crate::std_lib::std_package;
let mut db = RidlDatabase::default();
let std = std_package(&mut db);
let body = ridl_package(
&db,
"veh.body",
"package veh.body\nservice veh.body.legacy : reserved LegacyDoorDiag\n",
);
let ws = Workspace::new(&db, vec![body], BTreeMap::new());
let catalog = service_catalog(&db, ws, std);
let entry = &catalog.entries["veh.body.legacy"];
assert_eq!(entry.interface_refs, Vec::<String>::new());
assert!(!entry.inline);
}
}