brink_analyzer/harvest.rs
1//! Project-wide harvest index over cue payloads and inline-markup span
2//! kinds/attributes (`docs/prose-dialect-spec.md` §5, issue #2114).
3//!
4//! §5's ruling is "harvest by default, declaration upgrades": every
5//! `@NAME` cue and every markup span kind/attribute name actually written
6//! anywhere in the project completes everywhere, and an optional
7//! declaration (a cast roster for cues, the host manifest for markup)
8//! *upgrades* a harvested name with richer metadata — it never gates
9//! whether the name completes at all. The ruling names the mechanism
10//! explicitly: "harvest is a project-db index obligation — cue payloads
11//! and span kinds are indexed project-wide (sibling of the symbol index),
12//! so completion crosses files." [`harvest`] is that merge — the same
13//! shape as [`crate::symbol_index_with_modules`], a pure function of every
14//! file's already-lowered HIR, so `brink-db`'s `harvest_index_query` gets
15//! the identical incrementality `symbol_index_query` gets from
16//! `lowered_query`'s per-file memoization: an edit only invalidates this
17//! merge when some file's `LoweredFile` output actually changes.
18//!
19//! # The two upgrade paths are not symmetric yet
20//!
21//! **Markup spans upgrade from the [`HostManifest`]** (`markup` field),
22//! folded in here directly: the manifest is an ordinary project input (no
23//! comptime evaluation), so nothing blocks reading it project-wide today.
24//! [`SpanHarvest::declared`] carries the manifest's [`ManifestSpanKind`]
25//! **verbatim** — not degraded to a bare `Vec<String>` — because §5 also
26//! rules that "the manifest and conventions files carry editor-consumed
27//! fields the compiler ignores (descriptions, attr types, display
28//! metadata)... a declaration format is a documentation format", and issue
29//! #1997/PR #2016 widened `ManifestSpanKind::attrs` from `Vec<String>` to
30//! `Vec<ManifestSpanAttr>` specifically so a `required` flag (and headroom
31//! for a future attribute-value type) has somewhere to live. Stripping
32//! back to names here would silently discard exactly the fields #1997
33//! added and the ruling says not to drop.
34//!
35//! **Cue names have no upgrade path here.** §5 names an optional "cast
36//! roster" that upgrades a harvested character name with typo validation,
37//! display name, editor color, and a voice ref — but no such type or
38//! registration point exists anywhere in the compiler yet (grep turns up
39//! nothing), and the roster is explicitly named as "a natural early tenant
40//! of the §3.5 module door" — the same comptime-evaluated conventions
41//! machinery issue #1840 has not landed. [`CueHarvest`] is therefore
42//! harvest-only by construction; a `declared` field is additive, future
43//! work once that mechanism exists, not something this issue can build
44//! ahead of it.
45
46use std::collections::{BTreeMap, BTreeSet};
47
48use brink_ir::hir::{Content, ContentContext, ContentPart, HirFile, HirVisitor, SpanPart};
49use brink_ir::{FileId, HostManifest, ManifestSpanKind};
50use rowan::TextRange;
51
52/// One occurrence of a harvested name — lets a completion consumer answer
53/// "where is this used", not just "does this name exist".
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct HarvestSite {
56 pub file: FileId,
57 pub range: TextRange,
58}
59
60/// One `@NAME` cue's harvest record: every occurrence project-wide, in no
61/// particular merge order (a completion consumer sorts as it needs).
62///
63/// See this module's doc for why there is no `declared` field yet.
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct CueHarvest {
66 pub sites: Vec<HarvestSite>,
67}
68
69/// One markup span kind's harvest record.
70#[derive(Debug, Clone, Default, PartialEq, Eq)]
71pub struct SpanHarvest {
72 /// Where this span kind's tag itself was written.
73 pub sites: Vec<HarvestSite>,
74 /// Attribute name -> every site it was written at, on any occurrence of
75 /// this kind. Harvested regardless of whether the manifest (if any)
76 /// declares it — an undeclared attribute is still real usage, worth
77 /// completing, exactly like an undeclared tag is (freeform stays the
78 /// default; `markup_check` is the separate pass that diagnoses it).
79 ///
80 /// The recorded site is the *enclosing span's* range, not the
81 /// attribute's own, even though HIR's `SpanPart::attrs` (`SpanAttr`,
82 /// issue #1829) now carries per-attribute provenance — narrowing this
83 /// harvest's ranges to it is a deliberately separate, unrequested
84 /// change, kept out of #1829's diagnostic-ranging fix. A consumer
85 /// highlighting or renaming a specific attribute will select the whole
86 /// `<tag …>` node, and two occurrences of the same attribute name on
87 /// one tag (e.g. `<wave a="1" a="2">`) report two byte-identical
88 /// sites. This is a stated limitation of this harvest pass, not a bug.
89 pub attrs: BTreeMap<String, Vec<HarvestSite>>,
90 /// The host manifest's own declaration of this kind, when registered —
91 /// the "declaration upgrades" half of §5's ruling, carried verbatim
92 /// (see module doc). `None` for a harvest-only (freeform) kind.
93 pub declared: Option<ManifestSpanKind>,
94}
95
96/// The project-wide harvest index (issue #2114): every `@NAME` cue payload
97/// and every inline-markup span kind/attribute name, keyed by name so
98/// completion crosses files — the compiler-side sibling of
99/// [`crate::SymbolIndex`](crate::symbol_index).
100#[derive(Debug, Clone, Default, PartialEq, Eq)]
101pub struct HarvestIndex {
102 pub cues: BTreeMap<String, CueHarvest>,
103 pub spans: BTreeMap<String, SpanHarvest>,
104}
105
106/// Range-free completion projection of a [`HarvestIndex`] (issue #2134).
107///
108/// Every [`HarvestSite`] carries a `TextRange`, so the raw index can never
109/// `Eq`-cutoff — nearly any edit shifts a site's range and would defeat
110/// early cutoff for every dependent, exactly the property that forced
111/// `resolution_index_query` to exist as a range-zeroed early-cutoff
112/// projection of the symbol index (see `brink-db`'s
113/// `queries/mod.rs` module doc, "The `resolution_index` cutoff seam"). A
114/// completion consumer only needs to know *that* a name exists project-wide,
115/// never *where* — so this projection keeps just the name sets (and, for
116/// spans, the manifest's `declared` metadata, which carries no ranges
117/// either). `harvest_completion_index_query` in `brink-db` is this
118/// projection's own tracked-query wrapper, the harvest-index sibling of
119/// `resolution_index_query`.
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
121pub struct HarvestNames {
122 pub cues: BTreeSet<String>,
123 pub spans: BTreeMap<String, SpanNames>,
124}
125
126/// One markup span kind's range-free completion record — see [`HarvestNames`].
127#[derive(Debug, Clone, Default, PartialEq, Eq)]
128pub struct SpanNames {
129 pub attrs: BTreeSet<String>,
130 /// The host manifest's declaration of this kind, when registered — see
131 /// [`SpanHarvest::declared`].
132 pub declared: Option<ManifestSpanKind>,
133}
134
135impl HarvestIndex {
136 /// Project this index down to the range-free name sets a completion
137 /// consumer needs (issue #2134) — see [`HarvestNames`]'s doc for why the
138 /// projection exists. Backdates across any edit that shifts a site's
139 /// range without adding or removing a name/attribute.
140 #[must_use]
141 pub fn names(&self) -> HarvestNames {
142 HarvestNames {
143 cues: self.cues.keys().cloned().collect(),
144 spans: self
145 .spans
146 .iter()
147 .map(|(name, span)| {
148 (
149 name.clone(),
150 SpanNames {
151 attrs: span.attrs.keys().cloned().collect(),
152 declared: span.declared.clone(),
153 },
154 )
155 })
156 .collect(),
157 }
158 }
159}
160
161/// Build the project-wide harvest index from every file's HIR, upgrading
162/// any markup span kind the host manifest declares.
163///
164/// The manifest's vocabulary is folded in *first* so a declared-but-never-
165/// used kind still completes (§5: "declaration upgrades", not "declaration
166/// replaces harvest") — mirroring `markup_check::check`'s own reading of
167/// the same field. `manifest: None`, or one that declares no `markup` key
168/// at all, contributes nothing beyond harvested usage — the same
169/// freeform-by-default posture `markup_check` holds.
170#[must_use]
171pub fn harvest(files: &[(FileId, &HirFile)], manifest: Option<&HostManifest>) -> HarvestIndex {
172 let mut index = HarvestIndex::default();
173
174 if let Some(manifest) = manifest {
175 for kind in &manifest.markup {
176 let entry = index.spans.entry(kind.name.clone()).or_default();
177 entry.declared = Some(match entry.declared.take() {
178 // A duplicate kind declaration's attrs merge additively,
179 // never overwrite — the same never-loosens-on-merge
180 // posture `markup_check::check`'s own vocab builder has.
181 Some(existing) => merge_declared(&existing, kind),
182 None => kind.clone(),
183 });
184 }
185 }
186
187 for &(file, hir) in files {
188 for cue in &hir.cue_names {
189 index
190 .cues
191 .entry(cue.name.clone())
192 .or_default()
193 .sites
194 .push(HarvestSite {
195 file,
196 range: cue.range,
197 });
198 }
199 let mut walker = SpanHarvestWalker {
200 file,
201 index: &mut index,
202 };
203 brink_ir::hir::visit::visit(hir, &mut walker);
204 }
205
206 index
207}
208
209/// Merge a duplicate `markup` declaration of the same kind name: attribute
210/// names union, and `required` only ever turns on, never off (matching
211/// `markup_check::check`'s vocab-merge doc).
212fn merge_declared(existing: &ManifestSpanKind, incoming: &ManifestSpanKind) -> ManifestSpanKind {
213 let mut attrs = existing.attrs.clone();
214 for attr in &incoming.attrs {
215 match attrs.iter_mut().find(|a| a.name == attr.name) {
216 Some(present) => present.required |= attr.required,
217 None => attrs.push(attr.clone()),
218 }
219 }
220 ManifestSpanKind {
221 name: existing.name.clone(),
222 attrs,
223 }
224}
225
226/// Collects markup span/attribute harvest facts for one file.
227///
228/// Descends by hand rather than relying purely on the shared walker for
229/// the same reason `markup_check::SpanWalker` does: `HirVisitor`'s content
230/// hook hands over the whole [`Content`], and the shared `walk_content_part`
231/// recurses *through* a span into its children without exposing the
232/// [`SpanPart`] itself.
233struct SpanHarvestWalker<'a> {
234 file: FileId,
235 index: &'a mut HarvestIndex,
236}
237
238impl SpanHarvestWalker<'_> {
239 fn harvest_span(&mut self, span: &SpanPart) {
240 let range = span.ptr.text_range();
241 let entry = self.index.spans.entry(span.name.clone()).or_default();
242 entry.sites.push(HarvestSite {
243 file: self.file,
244 range,
245 });
246 // Attribute *values* are never harvested — span attributes are
247 // static text by construction (`SyntaxKind::SPAN_ATTR_VALUE`), and
248 // §4.2's schema (mirrored by `ManifestSpanAttr`) never models them
249 // either; only the attribute *name* is a completion candidate.
250 //
251 // Recorded at the enclosing span's range, not `attr.ptr`, even
252 // though `SpanAttr` now carries its own provenance (issue #1829) —
253 // that fix is scoped to diagnostic ranging (`E165`) only; widening
254 // completion sites to attribute-level ranges is a separate,
255 // unrequested change (see this struct's doc for the stated
256 // limitation this choice keeps in place).
257 for attr in &span.attrs {
258 entry
259 .attrs
260 .entry(attr.name.clone())
261 .or_default()
262 .push(HarvestSite {
263 file: self.file,
264 range,
265 });
266 }
267 for child in &span.children {
268 self.harvest_part(child);
269 }
270 }
271
272 fn harvest_part(&mut self, part: &ContentPart) {
273 match part {
274 ContentPart::Span(span) => self.harvest_span(span),
275 // Logic nests freely inside markup and vice versa (§4.3): a
276 // branch's content is its own `Content` node, delivered through
277 // `enter_content` in turn — nothing to recurse into here.
278 ContentPart::Text(_)
279 | ContentPart::Glue
280 | ContentPart::Spring
281 | ContentPart::Interpolation(_)
282 | ContentPart::InlineConditional(_)
283 | ContentPart::InlineSequence(_) => {}
284 }
285 }
286}
287
288impl HirVisitor for SpanHarvestWalker<'_> {
289 fn enter_content(&mut self, content: &Content, _ctx: ContentContext) {
290 // `content.parts` only, not `content.tags` — see
291 // `markup_check::SpanWalker::enter_content`'s doc: native's
292 // `lower_tag` flattens a tag's raw tokens into one `Text` part, so
293 // there is never a `Span` under a tag to harvest.
294 for part in &content.parts {
295 self.harvest_part(part);
296 }
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use brink_ir::{ExternalKind, ManifestExternal, ManifestSpanAttr, SemanticTypeDef, TypeRef};
304
305 fn lower_native(src: &str) -> HirFile {
306 let parse = brink_syntax_native::parse(src);
307 assert!(
308 parse.errors().is_empty(),
309 "parse errors: {:?}",
310 parse.errors()
311 );
312 let (hir, _manifest, _diags) = brink_ir::hir::lower_native::lower(FileId(0), &parse.tree());
313 hir
314 }
315
316 fn lower_native_at(file: FileId, src: &str) -> HirFile {
317 let parse = brink_syntax_native::parse(src);
318 assert!(
319 parse.errors().is_empty(),
320 "parse errors: {:?}",
321 parse.errors()
322 );
323 let (hir, _manifest, _diags) = brink_ir::hir::lower_native::lower(file, &parse.tree());
324 hir
325 }
326
327 fn attr(name: &str) -> ManifestSpanAttr {
328 ManifestSpanAttr {
329 name: name.to_string(),
330 required: false,
331 ty: None,
332 }
333 }
334
335 fn required_attr(name: &str) -> ManifestSpanAttr {
336 ManifestSpanAttr {
337 name: name.to_string(),
338 required: true,
339 ty: None,
340 }
341 }
342
343 // ── Cues: harvested regardless of any declared handler ──────────────
344
345 #[test]
346 fn an_unclaimed_cue_is_still_harvested() {
347 // No conventions module claims this — the line reports E129 and
348 // produces no `ElementMatch`/`Stmt` — but the cue's own name is
349 // still harvested (§5: "harvest by default").
350 let hir = lower_native("flow a() {\n @KID\n Says who?\n}\n");
351 let index = harvest(&[(FileId(0), &hir)], None);
352 assert!(
353 index.cues.contains_key("KID"),
354 "an unclaimed cue must still be harvested: {index:?}"
355 );
356 }
357
358 #[test]
359 fn a_claimed_cue_is_harvested_the_same_way() {
360 // Whether a conventions handler claims the line changes nothing
361 // about the raw harvest — the whole-tree scan is independent of
362 // `element::try_claim`.
363 let hir = lower_native(
364 "@[convention(claims = \"^(?<name>[A-Z][A-Z]*)$\", order = 10, block)]\nfn cue(name: string, body: content) >{\n {name}\n {body}\n}\n\nflow a() {\n @KID\n Says who?\n}\n",
365 );
366 let index = harvest(&[(FileId(0), &hir)], None);
367 assert!(
368 index.cues.contains_key("KID"),
369 "a claimed cue must still be harvested: {index:?}"
370 );
371 }
372
373 #[test]
374 fn a_compact_cue_name_is_harvested_too() {
375 let hir = lower_native("flow a() {\n @VENDOR: Something for the road?\n}\n");
376 let index = harvest(&[(FileId(0), &hir)], None);
377 assert!(
378 index.cues.contains_key("VENDOR"),
379 "a compact cue's name must be harvested: {index:?}"
380 );
381 }
382
383 #[test]
384 fn the_same_cue_name_in_two_files_completes_from_either() {
385 // The load-bearing property (§5: "so completion crosses files").
386 let a = lower_native_at(FileId(0), "flow a() {\n @KID\n Hey.\n}\n");
387 let b = lower_native_at(FileId(1), "flow b() {\n @KID\n You again.\n}\n");
388 let index = harvest(&[(FileId(0), &a), (FileId(1), &b)], None);
389 let sites = &index.cues.get("KID").expect("KID harvested").sites;
390 assert_eq!(sites.len(), 2, "one site per file: {sites:?}");
391 let files: std::collections::BTreeSet<_> = sites.iter().map(|s| s.file).collect();
392 assert_eq!(
393 files,
394 [FileId(0), FileId(1)].into_iter().collect(),
395 "both files' occurrences must be present: {sites:?}"
396 );
397 }
398
399 #[test]
400 fn cue_names_are_never_harvested_from_the_ink_frontend() {
401 let parse = brink_syntax::parse("=== knot ===\nHello.\n-> END\n");
402 let (hir, _manifest, _diags) = brink_ir::hir::lower(FileId(0), &parse.tree());
403 assert!(hir.cue_names.is_empty(), "ink grammar has no cue channel");
404 let index = harvest(&[(FileId(0), &hir)], None);
405 assert!(index.cues.is_empty(), "{index:?}");
406 }
407
408 // ── Markup spans: harvested by default, upgraded by the manifest ────
409
410 #[test]
411 fn an_undeclared_span_still_harvests_its_kind_and_attribute() {
412 let hir = lower_native("flow a() {\n <wave amount=\"3\">shimmer</wave>\n}\n");
413 let index = harvest(&[(FileId(0), &hir)], None);
414 let wave = index.spans.get("wave").expect("wave harvested");
415 assert_eq!(wave.sites.len(), 1);
416 assert!(wave.attrs.contains_key("amount"));
417 assert!(wave.declared.is_none(), "no manifest registered: {wave:?}");
418 }
419
420 #[test]
421 fn a_declared_but_never_used_span_kind_still_completes() {
422 // The other half of "declaration upgrades": a kind the manifest
423 // declares must complete even with zero occurrences anywhere.
424 let manifest = HostManifest {
425 markup: vec![ManifestSpanKind {
426 name: "sfx".to_string(),
427 attrs: vec![attr("name"), required_attr("volume")],
428 }],
429 ..HostManifest::default()
430 };
431 let hir = lower_native("flow a() {\n Nothing here.\n}\n");
432 let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
433 let sfx = index.spans.get("sfx").expect("declared kind must appear");
434 assert!(sfx.sites.is_empty(), "never used: {sfx:?}");
435 assert_eq!(
436 sfx.declared.as_ref().expect("declared").attrs,
437 vec![attr("name"), required_attr("volume")],
438 "the manifest's attribute records must survive verbatim, \
439 `required` included — not degraded to bare names"
440 );
441 }
442
443 #[test]
444 fn a_harvested_span_kind_the_manifest_also_declares_merges_both_halves() {
445 let manifest = HostManifest {
446 markup: vec![ManifestSpanKind {
447 name: "wave".to_string(),
448 attrs: vec![attr("amount")],
449 }],
450 ..HostManifest::default()
451 };
452 let hir = lower_native("flow a() {\n <wave amount=\"3\" speed=\"2\">shimmer</wave>\n}\n");
453 let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
454 let wave = index.spans.get("wave").expect("wave present");
455 assert_eq!(wave.sites.len(), 1, "harvested occurrence: {wave:?}");
456 // Both the declared attribute and the undeclared one actually
457 // written are harvested — freeform stays the default even under a
458 // manifest (that's `markup_check`'s job to flag, not this index's).
459 assert!(wave.attrs.contains_key("amount"));
460 assert!(wave.attrs.contains_key("speed"));
461 assert_eq!(
462 wave.declared.as_ref().expect("declared").attrs,
463 vec![attr("amount")]
464 );
465 }
466
467 #[test]
468 fn duplicate_declared_kinds_merge_attrs_and_never_unrequire() {
469 let manifest = HostManifest {
470 markup: vec![
471 ManifestSpanKind {
472 name: "sfx".to_string(),
473 attrs: vec![required_attr("volume")],
474 },
475 ManifestSpanKind {
476 name: "sfx".to_string(),
477 attrs: vec![attr("name")],
478 },
479 ],
480 ..HostManifest::default()
481 };
482 let hir = lower_native("flow a() {\n Nothing here.\n}\n");
483 let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
484 let sfx = index.spans.get("sfx").expect("declared kind");
485 let declared = sfx.declared.as_ref().expect("declared");
486 let volume = declared
487 .attrs
488 .iter()
489 .find(|a| a.name == "volume")
490 .expect("volume present");
491 assert!(volume.required, "must still be required after the merge");
492 }
493
494 #[test]
495 fn a_manifest_with_no_markup_key_contributes_nothing_beyond_harvest() {
496 let manifest = HostManifest {
497 externals: vec![ManifestExternal {
498 name: "play_sfx".to_string(),
499 params: Vec::new(),
500 returns: TypeRef::default(),
501 kind: ExternalKind::default(),
502 doc: None,
503 widgets: Vec::new(),
504 path: Vec::new(),
505 }],
506 types: vec![SemanticTypeDef {
507 name: "actor_id".to_string(),
508 base: brink_ir::BaseType::Int,
509 constraint: None,
510 values: None,
511 widget: None,
512 }],
513 markup: Vec::new(),
514 };
515 let hir = lower_native("flow a() {\n <glow>shine</glow>\n}\n");
516 let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
517 let glow = index.spans.get("glow").expect("harvested regardless");
518 assert!(glow.declared.is_none(), "externals-only manifest: {glow:?}");
519 }
520
521 #[test]
522 fn a_nested_span_is_harvested_not_just_the_outermost() {
523 let hir = lower_native("flow a() {\n <b><glitch>hi</glitch></b>\n}\n");
524 let index = harvest(&[(FileId(0), &hir)], None);
525 assert!(index.spans.contains_key("b"));
526 assert!(index.spans.contains_key("glitch"));
527 }
528
529 // ── HarvestNames: the range-free completion projection (#2134) ──────
530
531 #[test]
532 fn names_projection_drops_ranges_but_keeps_every_cue_and_span_name() {
533 let hir = lower_native(
534 "flow a() {\n @KID\n <wave amount=\"3\">shimmer</wave>\n Says hi.\n}\n",
535 );
536 let index = harvest(&[(FileId(0), &hir)], None);
537 let names = index.names();
538 assert!(names.cues.contains("KID"));
539 let wave = names.spans.get("wave").expect("wave harvested");
540 assert!(wave.attrs.contains("amount"));
541 }
542
543 #[test]
544 fn names_projection_is_eq_stable_across_a_range_only_change() {
545 // The load-bearing property (#2134): two harvests of the *same*
546 // name from different byte offsets must produce identical
547 // `HarvestNames` output, even though the raw `HarvestIndex` (whose
548 // sites carry real ranges) differs.
549 let a = lower_native("flow a() {\n @KID\n Hi.\n}\n");
550 let b = lower_native("flow a() {\n\n\n @KID\n Hi.\n}\n");
551 let index_a = harvest(&[(FileId(0), &a)], None);
552 let index_b = harvest(&[(FileId(0), &b)], None);
553 assert_ne!(
554 index_a, index_b,
555 "sanity: the raw indexes differ by range, so this test is real"
556 );
557 assert_eq!(
558 index_a.names(),
559 index_b.names(),
560 "the range-free projection must be Eq-stable across a pure range shift"
561 );
562 }
563
564 #[test]
565 fn names_projection_preserves_declared_span_metadata() {
566 let manifest = HostManifest {
567 markup: vec![ManifestSpanKind {
568 name: "sfx".to_string(),
569 attrs: vec![required_attr("volume")],
570 }],
571 ..HostManifest::default()
572 };
573 let hir = lower_native("flow a() {\n Nothing here.\n}\n");
574 let index = harvest(&[(FileId(0), &hir)], Some(&manifest));
575 let names = index.names();
576 let sfx = names.spans.get("sfx").expect("declared kind must appear");
577 assert!(sfx.declared.as_ref().expect("declared").attrs[0].required);
578 }
579}