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)]
70 pub unit: String,
71 #[returns(ref)]
72 pub files: Vec<InputFile>,
73 pub origin: PackageOrigin,
74 #[returns(ref)]
75 pub imports: BTreeMap<String, String>,
76 #[returns(ref)]
83 pub defaults: TimingDefaults,
84 #[returns(ref)]
92 pub lock: Option<PackageLock>,
93}
94
95#[salsa::input(debug)]
104pub struct Workspace {
105 #[returns(ref)]
106 pub packages: Vec<Package>,
107 #[returns(ref)]
108 pub imports: BTreeMap<String, String>,
109}
110
111#[salsa::tracked(returns(clone))]
115pub fn package_of(db: &dyn salsa::Database, ws: Workspace, name: String) -> Option<Package> {
116 ws.packages(db)
117 .iter()
118 .copied()
119 .find(|package| package.name(db) == &name)
120}
121
122#[cfg_attr(not(feature = "fs"), allow(dead_code))]
131pub(crate) fn package_declarations(file: &SourceFile) -> Vec<(String, TextRange)> {
132 file.syntax()
133 .children()
134 .filter_map(ridl_syntax::ast::PackageDecl::cast)
135 .map(|decl| {
136 let name = decl
137 .qualified_name()
138 .map(|name| dotted_name(&name))
139 .unwrap_or_default();
140 (name, decl.syntax().text_range())
141 })
142 .collect()
143}
144
145#[cfg_attr(not(feature = "fs"), allow(dead_code))]
148fn dotted_name(name: &ridl_syntax::ast::QualifiedName) -> String {
149 name.syntax()
150 .children_with_tokens()
151 .filter_map(|element| element.into_token())
152 .filter(|token| !token.kind().is_trivia())
153 .map(|token| token.text().to_string())
154 .collect()
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct CatalogEntry {
175 pub package: String,
176 pub interface_refs: Vec<String>,
177 pub inline: bool,
178}
179
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
190pub struct ServiceCatalog {
191 pub entries: BTreeMap<String, CatalogEntry>,
192 pub diagnostics: Vec<Diagnostic>,
193}
194
195#[salsa::tracked(returns(clone))]
206pub fn service_catalog(db: &dyn salsa::Database, ws: Workspace, std: Package) -> ServiceCatalog {
207 let _ = std;
210
211 let mut sources = SourceMap::new();
215
216 let mut entries: BTreeMap<String, CatalogEntry> = BTreeMap::new();
217 let mut first_span: HashMap<String, Span> = HashMap::new();
218 let mut diagnostics = Vec::new();
219
220 for package in ws.packages(db) {
221 let package_name = package.name(db).clone();
222 let names = package_names(db, package);
226 for file in package.files(db) {
227 let file_id = sources.file_id(file.path(db), file.text(db));
228 let source = service_source(db, *file);
229 for service in source.services() {
230 let Some(dotted) = service.name() else {
231 continue;
232 };
233 let name = significant_node_text(dotted.syntax());
234 if name.is_empty() {
235 continue;
236 }
237 let span = Span {
238 file: file_id,
239 range: dotted.syntax().text_range(),
240 };
241 if let Some(&first) = first_span.get(&name) {
242 diagnostics.push(Diagnostic {
246 code: DiagCode::RIDL_140,
247 severity: Severity::Error,
248 message: format!(
249 "duplicate service name `{name}` — the service catalog is a flat global namespace"
250 ),
251 primary: span,
252 labels: vec![Label {
253 span: first,
254 message: format!("`{name}` is first declared here"),
255 }],
256 fixits: Vec::new(),
257 });
258 continue;
259 }
260 first_span.insert(name.clone(), span);
261 entries.insert(
262 name,
263 CatalogEntry {
264 package: package_name.clone(),
265 interface_refs: canonical_interface_refs(&names, &service),
266 inline: service.colon_token().is_none(),
267 },
268 );
269 }
270 }
271 }
272
273 ServiceCatalog {
274 entries,
275 diagnostics,
276 }
277}
278
279fn service_source(db: &dyn salsa::Database, file: InputFile) -> SourceFile {
281 let parse = crate::parse_file(db, file);
282 SourceFile::cast(parse.syntax()).expect("the parser roots every tree in a SourceFile")
283}
284
285fn canonical_interface_refs(names: &PackageNames, service: &ServiceDef) -> Vec<String> {
297 if service.colon_token().is_none() {
298 return Vec::new();
299 }
300 service
301 .shapes()
302 .map(|path| canonical_ref(names, &path))
303 .collect()
304}
305
306fn canonical_ref(names: &PackageNames, path: &PathType) -> String {
308 let written = significant_node_text(path.syntax());
309 if written.is_empty() || written.contains('.') {
310 return written;
311 }
312 if names.locals.contains(&written) {
315 return written;
316 }
317 if let Some(full) = names.imports.get(&written) {
318 return full.clone();
319 }
320 written
321}
322
323struct PackageNames {
326 locals: HashSet<String>,
327 imports: HashMap<String, String>,
328}
329
330fn package_names(db: &dyn salsa::Database, package: &Package) -> PackageNames {
331 let mut locals = HashSet::new();
332 let mut imports: HashMap<String, String> = HashMap::new();
333 for file in package.files(db) {
334 let source = service_source(db, *file);
335 for definition in source.definitions() {
336 if let Some(name) = declaration_name(definition.syntax()) {
337 locals.insert(name);
338 }
339 }
340 for interface in source.interfaces() {
341 if let Some(name) = declaration_name(interface.syntax()) {
342 locals.insert(name);
343 }
344 }
345 for import in source.imports() {
346 let Some(qualified) = import.qualified_name() else {
347 continue;
348 };
349 let full = significant_node_text(qualified.syntax());
350 let base = full.rsplit('.').next().unwrap_or(full.as_str()).to_string();
351 let local = import
352 .alias()
353 .and_then(|alias| alias.ident_token())
354 .map(|token| token.text().to_string())
355 .unwrap_or(base);
356 imports.entry(local).or_insert(full);
358 }
359 }
360 PackageNames { locals, imports }
361}
362
363fn declaration_name(node: &SyntaxNode) -> Option<String> {
365 node.children()
366 .find(|child| child.kind() == SyntaxKind::Name)?
367 .children_with_tokens()
368 .filter_map(|element| element.into_token())
369 .find(|token| token.kind() == SyntaxKind::Ident)
370 .map(|token| token.text().to_string())
371}
372
373fn significant_node_text(node: &SyntaxNode) -> String {
377 node.descendants_with_tokens()
378 .filter_map(|element| element.into_token())
379 .filter(|token| !token.kind().is_trivia())
380 .map(|token| token.text().to_string())
381 .collect()
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::db::RidlDatabase;
388
389 fn file(db: &RidlDatabase, path: &str, text: &str) -> InputFile {
390 InputFile::new(db, path.to_string(), text.to_string())
391 }
392
393 #[test]
394 fn package_of_finds_a_package_by_name() {
395 let db = RidlDatabase::default();
396 let common = Package::new(
397 &db,
398 "veh.common".to_string(),
399 "veh.common".to_string(),
400 vec![file(&db, "veh-common/a.typl", "package veh.common")],
401 PackageOrigin::WorkspaceMember,
402 BTreeMap::new(),
403 TimingDefaults::default(),
404 None,
405 );
406 let cluster = Package::new(
407 &db,
408 "veh.cluster".to_string(),
409 "veh.cluster".to_string(),
410 vec![file(&db, "veh-cluster/b.typl", "package veh.cluster")],
411 PackageOrigin::WorkspaceMember,
412 BTreeMap::new(),
413 TimingDefaults::default(),
414 None,
415 );
416 let ws = Workspace::new(&db, vec![common, cluster], BTreeMap::new());
417
418 assert_eq!(
419 package_of(&db, ws, "veh.cluster".to_string()),
420 Some(cluster)
421 );
422 assert_eq!(package_of(&db, ws, "veh.common".to_string()), Some(common));
423 assert_eq!(package_of(&db, ws, "veh.absent".to_string()), None);
424 }
425
426 #[test]
427 fn package_declarations_reads_names_and_ranges() {
428 let parse = ridl_syntax::parse(
429 "package veh.common\npackage veh.extra\ntype A: m\n",
430 ridl_syntax::Profile::Typl,
431 );
432 let source = SourceFile::cast(parse.syntax()).expect("root is a SourceFile");
433 let decls = package_declarations(&source);
434 assert_eq!(decls.len(), 2, "both declarations are read");
435 assert_eq!(decls[0].0, "veh.common");
436 assert_eq!(decls[0].1, TextRange::new(0.into(), 18.into()));
437 assert_eq!(decls[1].0, "veh.extra");
438 assert_eq!(decls[1].1, TextRange::new(19.into(), 36.into()));
439 }
440
441 fn ridl_package(db: &RidlDatabase, name: &str, text: &str) -> Package {
444 Package::new(
445 db,
446 name.to_string(),
447 name.to_string(),
448 vec![file(db, &format!("{}.ridl", name.replace('.', "/")), text)],
449 PackageOrigin::WorkspaceMember,
450 BTreeMap::new(),
451 TimingDefaults::default(),
452 None,
453 )
454 }
455
456 #[test]
457 fn service_catalog_flags_a_duplicate_name_across_packages() {
458 use crate::std_lib::std_package;
459 let mut db = RidlDatabase::default();
460 let std = std_package(&mut db);
461 let adas = ridl_package(
464 &db,
465 "veh.adas",
466 "package veh.adas\nservice veh.adas.cruise : Foo\n",
467 );
468 let redundant = ridl_package(
469 &db,
470 "veh.redundant",
471 "package veh.redundant\nservice veh.adas.cruise : Bar\n",
472 );
473 let ws = Workspace::new(&db, vec![adas, redundant], BTreeMap::new());
474
475 let catalog = service_catalog(&db, ws, std);
476
477 assert_eq!(catalog.diagnostics.len(), 1);
478 let diagnostic = &catalog.diagnostics[0];
479 assert_eq!(diagnostic.code, DiagCode::RIDL_140);
480 assert_eq!(diagnostic.labels.len(), 1);
483 assert!(catalog.entries.contains_key("veh.adas.cruise"));
485 assert_eq!(catalog.entries["veh.adas.cruise"].package, "veh.adas");
486 }
487
488 #[test]
489 fn service_catalog_resolves_a_cross_package_interface_ref_through_imports() {
490 use crate::std_lib::std_package;
491 let mut db = RidlDatabase::default();
492 let std = std_package(&mut db);
493 let common = ridl_package(
494 &db,
495 "veh.common",
496 "package veh.common\ninterface CruiseControl {\n signal engaged : boolean\n}\n",
497 );
498 let adas = ridl_package(
499 &db,
500 "veh.adas",
501 "package veh.adas\nimport veh.common.CruiseControl\nservice veh.adas.cruise : CruiseControl\n",
502 );
503 let ws = Workspace::new(&db, vec![common, adas], BTreeMap::new());
504
505 let catalog = service_catalog(&db, ws, std);
506
507 assert!(catalog.diagnostics.is_empty());
508 let entry = &catalog.entries["veh.adas.cruise"];
509 assert_eq!(entry.package, "veh.adas");
510 assert_eq!(entry.interface_refs, ["veh.common.CruiseControl"]);
513 }
514
515 #[test]
518 fn service_catalog_records_every_shape_of_a_composed_service() {
519 use crate::std_lib::std_package;
520 let mut db = RidlDatabase::default();
521 let std = std_package(&mut db);
522 let common = ridl_package(
523 &db,
524 "veh.common",
525 "package veh.common\ninterface DiagBlock {\n query readFault(code: boolean): boolean\n}\n",
526 );
527 let body = ridl_package(
528 &db,
529 "veh.body",
530 "package veh.body\nimport veh.common.DiagBlock\n\
531 interface DoorControl {\n signal locked : boolean\n}\n\
532 service veh.body.doors : DoorControl, DiagBlock\n",
533 );
534 let ws = Workspace::new(&db, vec![common, body], BTreeMap::new());
535
536 let catalog = service_catalog(&db, ws, std);
537
538 assert!(catalog.diagnostics.is_empty());
539 let entry = &catalog.entries["veh.body.doors"];
540 assert_eq!(
541 entry.interface_refs,
542 ["DoorControl", "veh.common.DiagBlock"],
543 "source order, local bare, import canonicalized",
544 );
545 assert!(!entry.inline);
546 }
547
548 #[test]
549 fn service_catalog_records_an_inline_shape_with_an_empty_ref() {
550 use crate::std_lib::std_package;
551 let mut db = RidlDatabase::default();
552 let std = std_package(&mut db);
553 let hvac = ridl_package(
554 &db,
555 "veh.hvac",
556 "package veh.hvac\nservice veh.hvac.cabin {\n signal temperature : boolean\n}\n",
557 );
558 let ws = Workspace::new(&db, vec![hvac], BTreeMap::new());
559
560 let catalog = service_catalog(&db, ws, std);
561
562 assert!(catalog.diagnostics.is_empty());
563 let entry = &catalog.entries["veh.hvac.cabin"];
564 assert_eq!(entry.package, "veh.hvac");
565 assert_eq!(entry.interface_refs, Vec::<String>::new());
566 assert!(entry.inline);
567 }
568
569 #[test]
572 fn service_catalog_tells_a_tombstone_only_list_from_an_inline_shape() {
573 use crate::std_lib::std_package;
574 let mut db = RidlDatabase::default();
575 let std = std_package(&mut db);
576 let body = ridl_package(
577 &db,
578 "veh.body",
579 "package veh.body\nservice veh.body.legacy : reserved LegacyDoorDiag\n",
580 );
581 let ws = Workspace::new(&db, vec![body], BTreeMap::new());
582
583 let catalog = service_catalog(&db, ws, std);
584
585 let entry = &catalog.entries["veh.body.legacy"];
586 assert_eq!(entry.interface_refs, Vec::<String>::new());
587 assert!(!entry.inline);
588 }
589}