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