1use std::collections::{BTreeMap, HashMap, HashSet};
17
18use ridl_syntax::ast::{AstNode as _, PathType, ServiceDef, SourceFile};
19use ridl_syntax::{SyntaxKind, SyntaxNode};
20use rowan::TextRange;
21
22use crate::db::InputFile;
23use crate::diag::{DiagCode, Diagnostic, Label, Severity, SourceMap, Span};
24use crate::interface_lock::InterfaceLock;
25use crate::manifest::TimingDefaults;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum PackageOrigin {
30 WorkspaceMember,
33 Remote,
35 Std,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct PackageLock {
44 pub path: String,
47 pub text: String,
49 pub lock: InterfaceLock,
51}
52
53#[salsa::input(debug)]
63pub struct Package {
64 pub name: String,
65 #[returns(ref)]
66 pub files: Vec<InputFile>,
67 pub origin: PackageOrigin,
68 #[returns(ref)]
69 pub imports: BTreeMap<String, String>,
70 #[returns(ref)]
77 pub defaults: TimingDefaults,
78 #[returns(ref)]
85 pub lock: Option<PackageLock>,
86}
87
88#[salsa::input(debug)]
97pub struct Workspace {
98 #[returns(ref)]
99 pub packages: Vec<Package>,
100 #[returns(ref)]
101 pub imports: BTreeMap<String, String>,
102}
103
104#[salsa::tracked(returns(clone))]
108pub fn package_of(db: &dyn salsa::Database, ws: Workspace, name: String) -> Option<Package> {
109 ws.packages(db)
110 .iter()
111 .copied()
112 .find(|package| package.name(db) == &name)
113}
114
115#[cfg_attr(not(feature = "fs"), allow(dead_code))]
124pub(crate) fn package_declarations(file: &SourceFile) -> Vec<(String, TextRange)> {
125 file.syntax()
126 .children()
127 .filter_map(ridl_syntax::ast::PackageDecl::cast)
128 .map(|decl| {
129 let name = decl
130 .qualified_name()
131 .map(|name| dotted_name(&name))
132 .unwrap_or_default();
133 (name, decl.syntax().text_range())
134 })
135 .collect()
136}
137
138#[cfg_attr(not(feature = "fs"), allow(dead_code))]
141fn dotted_name(name: &ridl_syntax::ast::QualifiedName) -> String {
142 name.syntax()
143 .children_with_tokens()
144 .filter_map(|element| element.into_token())
145 .filter(|token| !token.kind().is_trivia())
146 .map(|token| token.text().to_string())
147 .collect()
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct CatalogEntry {
168 pub package: String,
169 pub interface_refs: Vec<String>,
170 pub inline: bool,
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq)]
183pub struct ServiceCatalog {
184 pub entries: BTreeMap<String, CatalogEntry>,
185 pub diagnostics: Vec<Diagnostic>,
186}
187
188#[salsa::tracked(returns(clone))]
199pub fn service_catalog(db: &dyn salsa::Database, ws: Workspace, std: Package) -> ServiceCatalog {
200 let _ = std;
203
204 let mut sources = SourceMap::new();
208
209 let mut entries: BTreeMap<String, CatalogEntry> = BTreeMap::new();
210 let mut first_span: HashMap<String, Span> = HashMap::new();
211 let mut diagnostics = Vec::new();
212
213 for package in ws.packages(db) {
214 let package_name = package.name(db).clone();
215 let names = package_names(db, package);
219 for file in package.files(db) {
220 let file_id = sources.file_id(file.path(db), file.text(db));
221 let source = service_source(db, *file);
222 for service in source.services() {
223 let Some(dotted) = service.name() else {
224 continue;
225 };
226 let name = significant_node_text(dotted.syntax());
227 if name.is_empty() {
228 continue;
229 }
230 let span = Span {
231 file: file_id,
232 range: dotted.syntax().text_range(),
233 };
234 if let Some(&first) = first_span.get(&name) {
235 diagnostics.push(Diagnostic {
239 code: DiagCode::RIDL_140,
240 severity: Severity::Error,
241 message: format!(
242 "duplicate service name `{name}` — the service catalog is a flat global namespace"
243 ),
244 primary: span,
245 labels: vec![Label {
246 span: first,
247 message: format!("`{name}` is first declared here"),
248 }],
249 fixits: Vec::new(),
250 });
251 continue;
252 }
253 first_span.insert(name.clone(), span);
254 entries.insert(
255 name,
256 CatalogEntry {
257 package: package_name.clone(),
258 interface_refs: canonical_interface_refs(&names, &service),
259 inline: service.colon_token().is_none(),
260 },
261 );
262 }
263 }
264 }
265
266 ServiceCatalog {
267 entries,
268 diagnostics,
269 }
270}
271
272fn service_source(db: &dyn salsa::Database, file: InputFile) -> SourceFile {
274 let parse = crate::parse_file(db, file);
275 SourceFile::cast(parse.syntax()).expect("the parser roots every tree in a SourceFile")
276}
277
278fn canonical_interface_refs(names: &PackageNames, service: &ServiceDef) -> Vec<String> {
290 if service.colon_token().is_none() {
291 return Vec::new();
292 }
293 service
294 .shapes()
295 .map(|path| canonical_ref(names, &path))
296 .collect()
297}
298
299fn canonical_ref(names: &PackageNames, path: &PathType) -> String {
301 let written = significant_node_text(path.syntax());
302 if written.is_empty() || written.contains('.') {
303 return written;
304 }
305 if names.locals.contains(&written) {
308 return written;
309 }
310 if let Some(full) = names.imports.get(&written) {
311 return full.clone();
312 }
313 written
314}
315
316struct PackageNames {
319 locals: HashSet<String>,
320 imports: HashMap<String, String>,
321}
322
323fn package_names(db: &dyn salsa::Database, package: &Package) -> PackageNames {
324 let mut locals = HashSet::new();
325 let mut imports: HashMap<String, String> = HashMap::new();
326 for file in package.files(db) {
327 let source = service_source(db, *file);
328 for definition in source.definitions() {
329 if let Some(name) = declaration_name(definition.syntax()) {
330 locals.insert(name);
331 }
332 }
333 for interface in source.interfaces() {
334 if let Some(name) = declaration_name(interface.syntax()) {
335 locals.insert(name);
336 }
337 }
338 for import in source.imports() {
339 let Some(qualified) = import.qualified_name() else {
340 continue;
341 };
342 let full = significant_node_text(qualified.syntax());
343 let base = full.rsplit('.').next().unwrap_or(full.as_str()).to_string();
344 let local = import
345 .alias()
346 .and_then(|alias| alias.ident_token())
347 .map(|token| token.text().to_string())
348 .unwrap_or(base);
349 imports.entry(local).or_insert(full);
351 }
352 }
353 PackageNames { locals, imports }
354}
355
356fn declaration_name(node: &SyntaxNode) -> Option<String> {
358 node.children()
359 .find(|child| child.kind() == SyntaxKind::Name)?
360 .children_with_tokens()
361 .filter_map(|element| element.into_token())
362 .find(|token| token.kind() == SyntaxKind::Ident)
363 .map(|token| token.text().to_string())
364}
365
366fn significant_node_text(node: &SyntaxNode) -> String {
370 node.descendants_with_tokens()
371 .filter_map(|element| element.into_token())
372 .filter(|token| !token.kind().is_trivia())
373 .map(|token| token.text().to_string())
374 .collect()
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use crate::db::RidlDatabase;
381
382 fn file(db: &RidlDatabase, path: &str, text: &str) -> InputFile {
383 InputFile::new(db, path.to_string(), text.to_string())
384 }
385
386 #[test]
387 fn package_of_finds_a_package_by_name() {
388 let db = RidlDatabase::default();
389 let common = Package::new(
390 &db,
391 "veh.common".to_string(),
392 vec![file(&db, "veh-common/a.typl", "package veh.common")],
393 PackageOrigin::WorkspaceMember,
394 BTreeMap::new(),
395 TimingDefaults::default(),
396 None,
397 );
398 let cluster = Package::new(
399 &db,
400 "veh.cluster".to_string(),
401 vec![file(&db, "veh-cluster/b.typl", "package veh.cluster")],
402 PackageOrigin::WorkspaceMember,
403 BTreeMap::new(),
404 TimingDefaults::default(),
405 None,
406 );
407 let ws = Workspace::new(&db, vec![common, cluster], BTreeMap::new());
408
409 assert_eq!(
410 package_of(&db, ws, "veh.cluster".to_string()),
411 Some(cluster)
412 );
413 assert_eq!(package_of(&db, ws, "veh.common".to_string()), Some(common));
414 assert_eq!(package_of(&db, ws, "veh.absent".to_string()), None);
415 }
416
417 #[test]
418 fn package_declarations_reads_names_and_ranges() {
419 let parse = ridl_syntax::parse(
420 "package veh.common\npackage veh.extra\ntype A: m\n",
421 ridl_syntax::Profile::Typl,
422 );
423 let source = SourceFile::cast(parse.syntax()).expect("root is a SourceFile");
424 let decls = package_declarations(&source);
425 assert_eq!(decls.len(), 2, "both declarations are read");
426 assert_eq!(decls[0].0, "veh.common");
427 assert_eq!(decls[0].1, TextRange::new(0.into(), 18.into()));
428 assert_eq!(decls[1].0, "veh.extra");
429 assert_eq!(decls[1].1, TextRange::new(19.into(), 36.into()));
430 }
431
432 fn ridl_package(db: &RidlDatabase, name: &str, text: &str) -> Package {
435 Package::new(
436 db,
437 name.to_string(),
438 vec![file(db, &format!("{}.ridl", name.replace('.', "/")), text)],
439 PackageOrigin::WorkspaceMember,
440 BTreeMap::new(),
441 TimingDefaults::default(),
442 None,
443 )
444 }
445
446 #[test]
447 fn service_catalog_flags_a_duplicate_name_across_packages() {
448 use crate::std_lib::std_package;
449 let mut db = RidlDatabase::default();
450 let std = std_package(&mut db);
451 let adas = ridl_package(
454 &db,
455 "veh.adas",
456 "package veh.adas\nservice veh.adas.cruise : Foo\n",
457 );
458 let redundant = ridl_package(
459 &db,
460 "veh.redundant",
461 "package veh.redundant\nservice veh.adas.cruise : Bar\n",
462 );
463 let ws = Workspace::new(&db, vec![adas, redundant], BTreeMap::new());
464
465 let catalog = service_catalog(&db, ws, std);
466
467 assert_eq!(catalog.diagnostics.len(), 1);
468 let diagnostic = &catalog.diagnostics[0];
469 assert_eq!(diagnostic.code, DiagCode::RIDL_140);
470 assert_eq!(diagnostic.labels.len(), 1);
473 assert!(catalog.entries.contains_key("veh.adas.cruise"));
475 assert_eq!(catalog.entries["veh.adas.cruise"].package, "veh.adas");
476 }
477
478 #[test]
479 fn service_catalog_resolves_a_cross_package_interface_ref_through_imports() {
480 use crate::std_lib::std_package;
481 let mut db = RidlDatabase::default();
482 let std = std_package(&mut db);
483 let common = ridl_package(
484 &db,
485 "veh.common",
486 "package veh.common\ninterface CruiseControl {\n signal engaged : boolean\n}\n",
487 );
488 let adas = ridl_package(
489 &db,
490 "veh.adas",
491 "package veh.adas\nimport veh.common.CruiseControl\nservice veh.adas.cruise : CruiseControl\n",
492 );
493 let ws = Workspace::new(&db, vec![common, adas], BTreeMap::new());
494
495 let catalog = service_catalog(&db, ws, std);
496
497 assert!(catalog.diagnostics.is_empty());
498 let entry = &catalog.entries["veh.adas.cruise"];
499 assert_eq!(entry.package, "veh.adas");
500 assert_eq!(entry.interface_refs, ["veh.common.CruiseControl"]);
503 }
504
505 #[test]
508 fn service_catalog_records_every_shape_of_a_composed_service() {
509 use crate::std_lib::std_package;
510 let mut db = RidlDatabase::default();
511 let std = std_package(&mut db);
512 let common = ridl_package(
513 &db,
514 "veh.common",
515 "package veh.common\ninterface DiagBlock {\n query readFault(code: boolean): boolean\n}\n",
516 );
517 let body = ridl_package(
518 &db,
519 "veh.body",
520 "package veh.body\nimport veh.common.DiagBlock\n\
521 interface DoorControl {\n signal locked : boolean\n}\n\
522 service veh.body.doors : DoorControl, DiagBlock\n",
523 );
524 let ws = Workspace::new(&db, vec![common, body], BTreeMap::new());
525
526 let catalog = service_catalog(&db, ws, std);
527
528 assert!(catalog.diagnostics.is_empty());
529 let entry = &catalog.entries["veh.body.doors"];
530 assert_eq!(
531 entry.interface_refs,
532 ["DoorControl", "veh.common.DiagBlock"],
533 "source order, local bare, import canonicalized",
534 );
535 assert!(!entry.inline);
536 }
537
538 #[test]
539 fn service_catalog_records_an_inline_shape_with_an_empty_ref() {
540 use crate::std_lib::std_package;
541 let mut db = RidlDatabase::default();
542 let std = std_package(&mut db);
543 let hvac = ridl_package(
544 &db,
545 "veh.hvac",
546 "package veh.hvac\nservice veh.hvac.cabin {\n signal temperature : boolean\n}\n",
547 );
548 let ws = Workspace::new(&db, vec![hvac], BTreeMap::new());
549
550 let catalog = service_catalog(&db, ws, std);
551
552 assert!(catalog.diagnostics.is_empty());
553 let entry = &catalog.entries["veh.hvac.cabin"];
554 assert_eq!(entry.package, "veh.hvac");
555 assert_eq!(entry.interface_refs, Vec::<String>::new());
556 assert!(entry.inline);
557 }
558
559 #[test]
562 fn service_catalog_tells_a_tombstone_only_list_from_an_inline_shape() {
563 use crate::std_lib::std_package;
564 let mut db = RidlDatabase::default();
565 let std = std_package(&mut db);
566 let body = ridl_package(
567 &db,
568 "veh.body",
569 "package veh.body\nservice veh.body.legacy : reserved LegacyDoorDiag\n",
570 );
571 let ws = Workspace::new(&db, vec![body], BTreeMap::new());
572
573 let catalog = service_catalog(&db, ws, std);
574
575 let entry = &catalog.entries["veh.body.legacy"];
576 assert_eq!(entry.interface_refs, Vec::<String>::new());
577 assert!(!entry.inline);
578 }
579}