Skip to main content

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}