1mod group;
2mod incremental;
3mod linkage;
4mod path_rule;
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use code_moniker_workspace::snapshot::{
9 CodeIndex, LinkageSnapshot, ResourceGeneration, SourceId, SymbolInventoryIndex, SymbolSet,
10};
11use rustc_hash::FxHashMap;
12use thiserror::Error;
13
14use crate::check::config::{Config, ConfigError, RuleEntry};
15use crate::check::eval::{CompiledRuleSpec, RuleReport, Violation};
16use crate::check::expr::{self, Atom, Domain, Lhs, LhsExpr, Node, NumberExpr, Op, Rhs};
17use crate::check::path::{self, Step};
18
19pub use group::{ScopeKey, WorkspaceGroupResult};
20
21#[derive(Debug)]
22struct CompiledWorkspaceSymbolRule {
23 rule_id: String,
24 raw_expr: String,
25 expanded_expr: String,
26 root: Node,
27 severity: crate::check::config::RuleSeverity,
28 message: Option<String>,
29 rationale: Option<String>,
30 capabilities: Vec<String>,
31 plan: WorkspaceRulePlan,
32}
33
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35enum WorkspaceRulePlan {
36 Inventory,
37 Linkage,
38}
39
40impl WorkspaceRulePlan {
41 fn as_str(self) -> &'static str {
42 match self {
43 Self::Inventory => "t1_inventory",
44 Self::Linkage => "t2_linkage",
45 }
46 }
47}
48
49#[derive(Debug, Default)]
50pub struct CompiledWorkspaceRules {
51 symbol: Vec<CompiledWorkspaceSymbolRule>,
52 group: Vec<group::CompiledWorkspaceGroupRule>,
53 path: Vec<path_rule::CompiledWorkspacePathRule>,
54 min_linkage_coverage: usize,
55}
56
57impl CompiledWorkspaceRules {
58 pub fn is_empty(&self) -> bool {
59 self.symbol.is_empty() && self.group.is_empty() && self.path.is_empty()
60 }
61
62 pub fn has_linkage_rules(&self) -> bool {
63 !self.path.is_empty()
64 || self
65 .symbol
66 .iter()
67 .any(|rule| rule.plan == WorkspaceRulePlan::Linkage)
68 }
69
70 pub fn specs(&self) -> Vec<CompiledRuleSpec> {
71 let mut specs = self
72 .symbol
73 .iter()
74 .map(|rule| CompiledRuleSpec {
75 rule_id: rule.rule_id.to_owned(),
76 severity: rule.severity,
77 lang: "workspace".to_string(),
78 root: "workspace".to_string(),
79 subject: "symbol".to_string(),
80 plan: rule.plan.as_str().to_string(),
81 capabilities: rule.capabilities.to_vec(),
82 group_by: Vec::new(),
83 domain: "workspace symbols".to_string(),
84 kind: None,
85 expr: rule.raw_expr.to_owned(),
86 expanded_expr: rule.expanded_expr.to_owned(),
87 message: rule.message.to_owned(),
88 rationale: rule.rationale.to_owned(),
89 require_doc_comment: None,
90 })
91 .collect();
92 group::append_group_specs(self, &mut specs);
93 path_rule::append_path_specs(self, &mut specs);
94 specs
95 }
96}
97
98#[derive(Clone, Debug)]
99pub struct WorkspaceSymbolViolation {
100 pub source: SourceId,
101 pub symbol: Option<code_moniker_workspace::snapshot::SymbolId>,
102 pub source_suppression: bool,
103 pub violation: Violation,
104}
105
106#[derive(Clone, Debug, Default)]
107pub struct WorkspaceEvaluation {
108 pub violations: Vec<WorkspaceSymbolViolation>,
109 pub violation_sets: BTreeMap<String, SymbolSet>,
110 pub groups: Vec<WorkspaceGroupResult>,
111 pub reports: Vec<RuleReport>,
112}
113
114#[derive(Debug, Error, Eq, PartialEq)]
115#[error(
116 "linkage snapshot targets index generation {linkage_index_generation:?}, current index is {index_generation:?}"
117)]
118pub struct WorkspaceLinkageError {
119 index_generation: ResourceGeneration,
120 linkage_index_generation: ResourceGeneration,
121}
122
123impl WorkspaceLinkageError {
124 pub fn index_generation(&self) -> ResourceGeneration {
125 self.index_generation
126 }
127
128 pub fn linkage_index_generation(&self) -> ResourceGeneration {
129 self.linkage_index_generation
130 }
131}
132
133pub(crate) use incremental::{WorkspaceIncrementalInput, evaluate_workspace_rules_incremental};
134
135pub fn compile_workspace_rules(
136 cfg: &Config,
137 scheme: &str,
138) -> Result<CompiledWorkspaceRules, ConfigError> {
139 let aliases = crate::check::config::resolve_aliases(&cfg.aliases)?;
140 let allowed = crate::check::config::allowed_workspace_kinds();
141 let mut symbol = Vec::with_capacity(cfg.workspace.symbol.rules.len());
142 for (index, entry) in cfg.workspace.symbol.rules.iter().enumerate() {
143 let id = entry.fallback_id(index);
144 let at = format!("workspace.symbol.{id}");
145 symbol.push(compile_symbol_rule(entry, at, scheme, &allowed, &aliases)?);
146 }
147 let group = group::compile_groups(cfg, scheme, &allowed, &aliases)?;
148 let path = path_rule::compile_paths(cfg, scheme, &allowed, &aliases)?;
149 Ok(CompiledWorkspaceRules {
150 symbol,
151 group,
152 path,
153 min_linkage_coverage: cfg.workspace.min_linkage_coverage.unwrap_or(100),
154 })
155}
156
157fn compile_symbol_rule(
158 entry: &RuleEntry,
159 at: String,
160 scheme: &str,
161 allowed_kinds: &[&str],
162 aliases: &std::collections::HashMap<String, String>,
163) -> Result<CompiledWorkspaceSymbolRule, ConfigError> {
164 let expanded = crate::check::config::substitute_aliases(&entry.expr, aliases, &at)?;
165 let parsed = match expr::parse(&expanded, scheme, allowed_kinds) {
166 Ok(parsed) => parsed,
167 Err(error) => {
168 return Err(ConfigError::InvalidExpr { at, error });
169 }
170 };
171 let (capabilities, plan) = classify_symbol_plan(&parsed.root, &at)?;
172 Ok(CompiledWorkspaceSymbolRule {
173 rule_id: at,
174 raw_expr: entry.expr.to_owned(),
175 expanded_expr: expanded,
176 root: parsed.root,
177 severity: entry.severity,
178 message: entry.message.to_owned(),
179 rationale: entry.rationale.to_owned(),
180 capabilities,
181 plan,
182 })
183}
184
185fn classify_symbol_plan(
186 node: &Node,
187 at: &str,
188) -> Result<(Vec<String>, WorkspaceRulePlan), ConfigError> {
189 let mut capabilities = BTreeSet::new();
190 let mut plan = WorkspaceRulePlan::Inventory;
191 collect_capabilities(node, at, &mut capabilities, &mut plan)?;
192 Ok((capabilities.into_iter().collect(), plan))
193}
194
195fn classify_t1(node: &Node, at: &str) -> Result<Vec<String>, ConfigError> {
196 let (capabilities, plan) = classify_symbol_plan(node, at)?;
197 if plan == WorkspaceRulePlan::Linkage {
198 return unsupported(at, "linkage.group");
199 }
200 Ok(capabilities)
201}
202
203fn collect_capabilities(
204 node: &Node,
205 at: &str,
206 capabilities: &mut BTreeSet<String>,
207 plan: &mut WorkspaceRulePlan,
208) -> Result<(), ConfigError> {
209 match node {
210 Node::Atom(atom) => collect_atom_capability(atom, at, capabilities, plan),
211 Node::And(nodes) | Node::Or(nodes) => {
212 for node in nodes {
213 collect_capabilities(node, at, capabilities, plan)?;
214 }
215 Ok(())
216 }
217 Node::Not(node) => collect_capabilities(node, at, capabilities, plan),
218 Node::Implies(left, right) => {
219 collect_capabilities(left, at, capabilities, plan)?;
220 collect_capabilities(right, at, capabilities, plan)
221 }
222 Node::Require(_) => unsupported(at, "inventory.require"),
223 Node::VerticalLayout(_) => unsupported(at, "local.vertical_layout"),
224 Node::Quantifier { .. } => unsupported(at, "inventory.quantifier"),
225 }
226}
227
228fn collect_atom_capability(
229 atom: &Atom,
230 at: &str,
231 capabilities: &mut BTreeSet<String>,
232 plan: &mut WorkspaceRulePlan,
233) -> Result<(), ConfigError> {
234 let LhsExpr::Attr(lhs) = &atom.lhs else {
235 return collect_linkage_count_capability(atom, at, capabilities, plan);
236 };
237 let facet = match lhs {
238 Lhs::Name => "name",
239 Lhs::Kind => "kind",
240 Lhs::Shape => "shape",
241 Lhs::Visibility => "visibility",
242 Lhs::Srcset => "srcset",
243 Lhs::Moniker => "uri",
244 other => return unsupported(at, &format!("projection.{}", other.as_str())),
245 };
246 let operation = match atom.op {
247 Op::Eq | Op::Ne => "exact",
248 Op::RegexMatch | Op::RegexNoMatch => "regex",
249 Op::PathMatch => "path",
250 other => return unsupported(at, &format!("operator.{other:?}")),
251 };
252 if atom.op == Op::PathMatch && *lhs != Lhs::Moniker {
253 return unsupported(at, "path.non-uri");
254 }
255 capabilities.insert(format!("{facet}.{operation}"));
256 Ok(())
257}
258
259fn collect_linkage_count_capability(
260 atom: &Atom,
261 at: &str,
262 capabilities: &mut BTreeSet<String>,
263 plan: &mut WorkspaceRulePlan,
264) -> Result<(), ConfigError> {
265 let LhsExpr::Number(NumberExpr::Count { domain, filter }) = &atom.lhs else {
266 return unsupported(at, "non-attribute-expression");
267 };
268 if filter.is_some() {
269 return unsupported(at, "linkage.filtered-count");
270 }
271 let domain = match domain {
272 Domain::InRefs => "in_refs",
273 Domain::OutRefs => "out_refs",
274 _ => return unsupported(at, "linkage.unsupported-domain"),
275 };
276 if !matches!(atom.rhs, Rhs::Number(NumberExpr::Literal(_)))
277 || !matches!(atom.op, Op::Eq | Op::Ne | Op::Lt | Op::Le | Op::Gt | Op::Ge)
278 {
279 return unsupported(at, "linkage.count-comparison");
280 }
281 capabilities.insert(format!("{domain}.count"));
282 *plan = WorkspaceRulePlan::Linkage;
283 Ok(())
284}
285
286fn unsupported<T>(at: &str, capability: &str) -> Result<T, ConfigError> {
287 Err(ConfigError::UnsupportedWorkspaceExpr {
288 at: at.to_string(),
289 capability: capability.to_string(),
290 })
291}
292
293pub fn evaluate_workspace_rules(
294 inventory: &SymbolInventoryIndex,
295 compiled: &CompiledWorkspaceRules,
296 report: bool,
297) -> WorkspaceEvaluation {
298 evaluate_workspace_rules_in(inventory, inventory.all_symbols(), compiled, report)
299}
300
301pub fn evaluate_workspace_rules_linked(
302 index: &CodeIndex,
303 linkage: &LinkageSnapshot,
304 compiled: &CompiledWorkspaceRules,
305 report: bool,
306) -> Result<WorkspaceEvaluation, WorkspaceLinkageError> {
307 evaluate_workspace_rules_linked_in(
308 index,
309 linkage,
310 index.inventory.all_symbols(),
311 compiled,
312 report,
313 )
314}
315
316pub fn evaluate_workspace_rules_linked_in(
317 index: &CodeIndex,
318 linkage: &LinkageSnapshot,
319 universe: &SymbolSet,
320 compiled: &CompiledWorkspaceRules,
321 report: bool,
322) -> Result<WorkspaceEvaluation, WorkspaceLinkageError> {
323 if linkage.index_generation != index.generation {
324 return Err(WorkspaceLinkageError {
325 index_generation: index.generation,
326 linkage_index_generation: linkage.index_generation,
327 });
328 }
329 Ok(evaluate_workspace_rules_linked_in_current(
330 index, linkage, universe, compiled, report,
331 ))
332}
333
334pub(crate) fn evaluate_workspace_rules_linked_in_current(
335 index: &CodeIndex,
336 linkage: &LinkageSnapshot,
337 universe: &SymbolSet,
338 compiled: &CompiledWorkspaceRules,
339 report: bool,
340) -> WorkspaceEvaluation {
341 let mut evaluation = evaluate_workspace_rules_in(&index.inventory, universe, compiled, report);
342 linkage::evaluate_linkage_rules(index, linkage, universe, compiled, report, &mut evaluation);
343 path_rule::evaluate_path_rules(index, linkage, universe, compiled, report, &mut evaluation);
344 sort_workspace_violations(&mut evaluation.violations);
345 evaluation
346}
347
348pub fn evaluate_workspace_rules_in(
349 inventory: &SymbolInventoryIndex,
350 universe: &SymbolSet,
351 compiled: &CompiledWorkspaceRules,
352 report: bool,
353) -> WorkspaceEvaluation {
354 let mut evaluation = WorkspaceEvaluation::default();
355 let mut atom_cache = FxHashMap::<String, SymbolSet>::default();
356 for rule in &compiled.symbol {
357 if rule.plan != WorkspaceRulePlan::Inventory {
358 continue;
359 }
360 let truth = eval_node(&rule.root, inventory, universe, &mut atom_cache);
361 let violations = universe.difference(&truth);
362 evaluation
363 .violation_sets
364 .insert(rule.rule_id.clone(), violations.clone());
365 if report {
366 let antecedent_truth = match &rule.root {
367 Node::Implies(antecedent, _) => {
368 Some(eval_node(antecedent, inventory, universe, &mut atom_cache))
369 }
370 _ => None,
371 };
372 let matches = antecedent_truth.as_ref().map_or_else(
373 || truth.len(),
374 |antecedent| truth.intersection(antecedent).len(),
375 );
376 let antecedent_matches = antecedent_truth.as_ref().map(SymbolSet::len);
377 evaluation.reports.push(rule_report(
378 rule,
379 universe,
380 matches,
381 &violations,
382 antecedent_matches,
383 ));
384 }
385 for ordinal in violations.iter() {
386 let Some(record) = inventory.record(ordinal) else {
387 continue;
388 };
389 let explanation = rule.message.as_deref().map(|message| {
390 render_template(
391 message,
392 &[
393 ("name", record.name.as_ref()),
394 ("kind", record.kind.as_ref()),
395 ("moniker", record.identity.as_ref()),
396 ("expr", &rule.raw_expr),
397 ],
398 )
399 });
400 evaluation.violations.push(WorkspaceSymbolViolation {
401 source: record.source,
402 symbol: Some(record.id),
403 source_suppression: true,
404 violation: Violation {
405 rule_id: rule.rule_id.clone(),
406 severity: rule.severity,
407 moniker: record.identity.to_string(),
408 srcset: (!record.srcset.is_empty()).then(|| record.srcset.to_string()),
409 kind: record.kind.to_string(),
410 lines: record.line_range.unwrap_or((0, 0)),
411 message: format!(
412 "{} `{}` fails workspace assertion `{}`",
413 record.kind, record.name, rule.raw_expr
414 ),
415 explanation,
416 },
417 });
418 }
419 }
420 group::evaluate_groups(
421 inventory,
422 universe,
423 compiled,
424 report,
425 &mut atom_cache,
426 &mut evaluation,
427 );
428 sort_workspace_violations(&mut evaluation.violations);
429 evaluation
430}
431
432fn sort_workspace_violations(violations: &mut [WorkspaceSymbolViolation]) {
433 violations.sort_by(|left, right| {
434 left.violation
435 .rule_id
436 .cmp(&right.violation.rule_id)
437 .then_with(|| left.violation.moniker.cmp(&right.violation.moniker))
438 });
439}
440
441fn rule_report(
442 rule: &CompiledWorkspaceSymbolRule,
443 universe: &SymbolSet,
444 matches: usize,
445 violations: &SymbolSet,
446 antecedent_matches: Option<usize>,
447) -> RuleReport {
448 RuleReport {
449 rule_id: rule.rule_id.clone(),
450 severity: rule.severity,
451 domain: "workspace symbols".to_string(),
452 evaluated: universe.len(),
453 matches,
454 violations: violations.len(),
455 antecedent_matches,
456 warning: None,
457 inconclusive: None,
458 verdict: None,
459 coverage: None,
460 path: None,
461 }
462}
463
464fn eval_node(
465 node: &Node,
466 inventory: &SymbolInventoryIndex,
467 universe: &SymbolSet,
468 atom_cache: &mut FxHashMap<String, SymbolSet>,
469) -> SymbolSet {
470 match node {
471 Node::Atom(atom) => {
472 if let Some(cached) = atom_cache.get(&atom.raw) {
473 return cached.to_owned();
474 }
475 let result = eval_atom(atom, inventory, universe);
476 atom_cache.insert(atom.raw.to_owned(), result.to_owned());
477 result
478 }
479 Node::And(nodes) => nodes
480 .iter()
481 .map(|node| eval_node(node, inventory, universe, atom_cache))
482 .reduce(|left, right| left.intersection(&right))
483 .unwrap_or_else(|| universe.clone()),
484 Node::Or(nodes) => nodes
485 .iter()
486 .map(|node| eval_node(node, inventory, universe, atom_cache))
487 .reduce(|left, right| left.union(&right))
488 .unwrap_or_default(),
489 Node::Not(node) => universe.difference(&eval_node(node, inventory, universe, atom_cache)),
490 Node::Implies(left, right) => universe
491 .difference(&eval_node(left, inventory, universe, atom_cache))
492 .union(&eval_node(right, inventory, universe, atom_cache)),
493 Node::Require(_) | Node::VerticalLayout(_) | Node::Quantifier { .. } => SymbolSet::new(),
494 }
495}
496
497fn eval_atom(atom: &Atom, inventory: &SymbolInventoryIndex, universe: &SymbolSet) -> SymbolSet {
498 let LhsExpr::Attr(lhs) = &atom.lhs else {
499 return SymbolSet::new();
500 };
501 match atom.op {
502 Op::Eq | Op::Ne => eval_exact(*lhs, &atom.rhs, atom.op, inventory, universe),
503 Op::RegexMatch | Op::RegexNoMatch => eval_regex(*lhs, atom, inventory, universe),
504 Op::PathMatch => eval_path(*lhs, &atom.rhs, inventory, universe),
505 _ => SymbolSet::new(),
506 }
507}
508
509fn eval_exact(
510 lhs: Lhs,
511 rhs: &Rhs,
512 op: Op,
513 inventory: &SymbolInventoryIndex,
514 universe: &SymbolSet,
515) -> SymbolSet {
516 let Rhs::Str(value) = rhs else {
517 return SymbolSet::new();
518 };
519 let matched = match lhs {
520 Lhs::Name => inventory.facets().symbols_by_name(value).cloned(),
521 Lhs::Kind => inventory.facets().symbols_by_kind(value).cloned(),
522 Lhs::Shape => inventory.facets().symbols_by_shape(value).cloned(),
523 Lhs::Visibility => inventory.facets().symbols_by_visibility(value).cloned(),
524 Lhs::Srcset => inventory.facets().symbols_by_srcset(value).cloned(),
525 Lhs::Moniker => inventory.facets().symbols_by_identity(value).cloned(),
526 _ => None,
527 }
528 .unwrap_or_default();
529 if op == Op::Ne {
530 universe.difference(&matched)
531 } else {
532 matched.intersection(universe)
533 }
534}
535
536fn eval_regex(
537 lhs: Lhs,
538 atom: &Atom,
539 inventory: &SymbolInventoryIndex,
540 universe: &SymbolSet,
541) -> SymbolSet {
542 let Some(regex) = atom.regex.as_ref() else {
543 return SymbolSet::new();
544 };
545 let matched = match lhs {
546 Lhs::Name => union_postings(inventory.facets().name_postings(), |value| {
547 regex.is_match(value)
548 }),
549 Lhs::Kind => union_postings(inventory.facets().kind_postings(), |value| {
550 regex.is_match(value)
551 }),
552 Lhs::Shape => union_postings(inventory.facets().shape_postings(), |value| {
553 regex.is_match(value)
554 }),
555 Lhs::Visibility => union_postings(inventory.facets().visibility_postings(), |value| {
556 regex.is_match(value)
557 }),
558 Lhs::Srcset => union_postings(inventory.facets().srcset_postings(), |value| {
559 regex.is_match(value)
560 }),
561 _ => SymbolSet::new(),
562 };
563 if atom.op == Op::RegexNoMatch {
564 universe.difference(&matched)
565 } else {
566 matched.intersection(universe)
567 }
568}
569
570fn union_postings<'a>(
571 postings: impl Iterator<Item = (&'a str, &'a SymbolSet)>,
572 matches: impl Fn(&str) -> bool,
573) -> SymbolSet {
574 let mut result = SymbolSet::new();
575 for (value, symbols) in postings {
576 if matches(value) {
577 result.union_with(symbols);
578 }
579 }
580 result
581}
582
583fn eval_path(
584 lhs: Lhs,
585 rhs: &Rhs,
586 inventory: &SymbolInventoryIndex,
587 universe: &SymbolSet,
588) -> SymbolSet {
589 if lhs != Lhs::Moniker {
590 return SymbolSet::new();
591 }
592 let Rhs::PathPattern(pattern) = rhs else {
593 return SymbolSet::new();
594 };
595 let candidates = exact_segment_candidates(pattern, inventory)
596 .map(|candidates| candidates.intersection(universe))
597 .unwrap_or_else(|| universe.clone());
598 candidates
599 .iter()
600 .filter(|ordinal| {
601 let Some(record) = inventory.record(*ordinal) else {
602 return false;
603 };
604 let segments = record
605 .segments
606 .iter()
607 .map(|segment| (segment.kind.as_ref(), segment.name.as_ref()))
608 .collect::<Vec<_>>();
609 path::matches_text_segments(pattern, &segments)
610 })
611 .collect()
612}
613
614fn exact_segment_candidates(
615 pattern: &path::Pattern,
616 inventory: &SymbolInventoryIndex,
617) -> Option<SymbolSet> {
618 pattern
619 .steps
620 .iter()
621 .filter_map(|step| match step {
622 Step::Literal { kind, name } => Some((
623 std::str::from_utf8(kind).ok()?,
624 std::str::from_utf8(name).ok()?,
625 )),
626 _ => None,
627 })
628 .filter_map(|(kind, name)| inventory.facets().symbols_by_segment(kind, name).cloned())
629 .reduce(|left, right| left.intersection(&right))
630}
631
632fn render_template(template: &str, values: &[(&str, &str)]) -> String {
633 let mut rendered = template.to_string();
634 for (name, value) in values {
635 rendered = rendered.replace(&format!("{{{name}}}"), value);
636 }
637 rendered
638}
639
640#[cfg(test)]
641mod tests {
642 use std::sync::Arc;
643
644 use code_moniker_workspace::snapshot::{
645 RecordTable, ResourceGeneration, SourceFileRecord, SymbolId, SymbolRecord,
646 };
647
648 use super::*;
649
650 fn fixture() -> SymbolInventoryIndex {
651 let sources = vec![
652 SourceFileRecord {
653 id: SourceId::at(0),
654 uri: "good".to_string(),
655 source_root: 0,
656 path: "src/main/java/acme/infra/GoodRepository.java".to_string(),
657 rel_path: "src/main/java/acme/infra/GoodRepository.java".to_string(),
658 anchor: "src/main/java/acme/infra/GoodRepository.java".to_string(),
659 language: "java".to_string(),
660 text: String::new(),
661 },
662 SourceFileRecord {
663 id: SourceId::at(1),
664 uri: "bad".to_string(),
665 source_root: 0,
666 path: "src/main/java/acme/domain/BadRepository.java".to_string(),
667 rel_path: "src/main/java/acme/domain/BadRepository.java".to_string(),
668 anchor: "src/main/java/acme/domain/BadRepository.java".to_string(),
669 language: "java".to_string(),
670 text: String::new(),
671 },
672 SourceFileRecord {
673 id: SourceId::at(2),
674 uri: "other".to_string(),
675 source_root: 0,
676 path: "src/main/java/acme/domain/Helper.java".to_string(),
677 rel_path: "src/main/java/acme/domain/Helper.java".to_string(),
678 anchor: "src/main/java/acme/domain/Helper.java".to_string(),
679 language: "java".to_string(),
680 text: String::new(),
681 },
682 SourceFileRecord {
683 id: SourceId::at(3),
684 uri: "test".to_string(),
685 source_root: 0,
686 path: "src/test/java/acme/TestHelper.java".to_string(),
687 rel_path: "src/test/java/acme/TestHelper.java".to_string(),
688 anchor: "src/test/java/acme/TestHelper.java".to_string(),
689 language: "java".to_string(),
690 text: String::new(),
691 },
692 ];
693 let symbol = |file, name: &str, dir: &str, srcset: &str| {
694 let mut symbol =
695 SymbolRecord::new(SymbolId::at(file, 0), SourceId::at(file), name, "class");
696 symbol.identity = Arc::from(format!(
697 "code+moniker://./lang:java/srcset:{srcset}/package:acme/dir:{dir}/class:{name}"
698 ));
699 symbol.line_range = Some((3, 3));
700 symbol
701 };
702 let symbols = RecordTable::from_shards(vec![
703 Arc::from(vec![symbol(0, "GoodRepository", "infra", "main")]),
704 Arc::from(vec![symbol(1, "BadRepository", "domain", "main")]),
705 Arc::from(vec![symbol(2, "Helper", "domain", "main")]),
706 Arc::from(vec![symbol(3, "TestHelper", "test", "test")]),
707 ]);
708 SymbolInventoryIndex::build(ResourceGeneration::new(1), &sources, &symbols)
709 }
710
711 #[test]
712 fn implication_uses_active_universe_and_path_posting() {
713 let cfg = crate::check::config::load_from_str(
714 r#"
715 [[workspace.symbol.where]]
716 id = "repositories-under-infra"
717 expr = "name =~ Repository$ => uri ~ '**/dir:infra/**'"
718 "#,
719 "<test>",
720 Some(false),
721 )
722 .expect("config");
723 let compiled = compile_workspace_rules(&cfg, "code+moniker://").expect("workspace compile");
724 let result = evaluate_workspace_rules(&fixture(), &compiled, true);
725 assert_eq!(result.violations.len(), 1);
726 assert!(
727 result.violations[0]
728 .violation
729 .moniker
730 .ends_with("class:BadRepository")
731 );
732 assert_eq!(result.reports[0].antecedent_matches, Some(2));
733 assert_eq!(result.reports[0].matches, 1);
734 }
735
736 #[test]
737 fn srcset_uses_inventory_postings_as_a_workspace_facet() {
738 let cfg = crate::check::config::load_from_str(
739 r#"
740 [[workspace.symbol.where]]
741 id = "main-only"
742 expr = "srcset = 'main'"
743 "#,
744 "<test>",
745 Some(false),
746 )
747 .expect("config");
748 let compiled = compile_workspace_rules(&cfg, "code+moniker://").expect("workspace compile");
749 assert_eq!(
750 compiled.specs()[0].capabilities,
751 vec!["srcset.exact".to_string()]
752 );
753 let result = evaluate_workspace_rules(&fixture(), &compiled, true);
754 assert_eq!(result.violations.len(), 1);
755 assert!(
756 result.violations[0]
757 .violation
758 .moniker
759 .ends_with("class:TestHelper")
760 );
761 }
762
763 #[test]
764 fn java_inventory_keeps_package_segments_for_path_rules() {
765 let source = "package com.acme.infra;\n\npublic class GoodRepository {}\n";
766 let graph = code_moniker_workspace::environment::extract_source_with(
767 code_moniker_core::lang::Lang::Java,
768 source,
769 std::path::Path::new("src/main/java/com/acme/infra/GoodRepository.java"),
770 &code_moniker_workspace::environment::ExtractContext::default(),
771 );
772 let symbols = code_moniker_workspace::environment::symbol_records_for_graph(
773 0,
774 SourceId::at(0),
775 &graph,
776 source,
777 code_moniker_core::lang::Lang::Java,
778 "code+moniker://",
779 );
780 let source_record = SourceFileRecord {
781 id: SourceId::at(0),
782 uri: "good".to_string(),
783 source_root: 0,
784 path: "src/main/java/com/acme/infra/GoodRepository.java".to_string(),
785 rel_path: "src/main/java/com/acme/infra/GoodRepository.java".to_string(),
786 anchor: "src/main/java/com/acme/infra/GoodRepository.java".to_string(),
787 language: "java".to_string(),
788 text: String::new(),
789 };
790 let inventory = SymbolInventoryIndex::build(
791 ResourceGeneration::new(1),
792 &[source_record],
793 &RecordTable::from_shards(vec![Arc::from(symbols)]),
794 );
795 let repository = inventory
796 .all_symbols()
797 .iter()
798 .filter_map(|ordinal| inventory.record(ordinal))
799 .find(|record| record.kind.as_ref() == "class")
800 .expect("repository class");
801 assert!(
802 repository
803 .segments
804 .iter()
805 .any(|segment| segment.kind.as_ref() == "package"
806 && segment.name.as_ref().ends_with("infra")),
807 "{repository:#?}"
808 );
809 let pattern = path::parse("**/package:infra/**").expect("package path pattern");
810 let segments = repository
811 .segments
812 .iter()
813 .map(|segment| (segment.kind.as_ref(), segment.name.as_ref()))
814 .collect::<Vec<_>>();
815 assert!(
816 path::matches_text_segments(&pattern, &segments),
817 "{segments:#?}"
818 );
819 }
820
821 #[test]
822 fn linkage_reference_counts_classify_as_t2() {
823 let cfg = crate::check::config::load_from_str(
824 r#"
825 [[workspace.symbol.where]]
826 id = "used-types"
827 expr = "shape = 'type' => count(in_refs) >= 1"
828 "#,
829 "<test>",
830 Some(false),
831 )
832 .expect("workspace linkage config");
833 let compiled =
834 compile_workspace_rules(&cfg, "code+moniker://").expect("workspace linkage plan");
835 let specs = compiled.specs();
836 assert_eq!(specs.len(), 1);
837 assert_eq!(specs[0].plan, "t2_linkage");
838 assert_eq!(
839 specs[0].capabilities,
840 vec!["in_refs.count".to_string(), "shape.exact".to_string()]
841 );
842 }
843}