bynk_check/expr_types.rs
1//! v0.30.2 (ADR 0063): the expression-type sink.
2//!
3//! The checker computes `expr_types: HashMap<ExprId, TypedExpr>` per file as
4//! it types each expression (T3.4, R2.4 — keyed by node identity, not
5//! position). On the Ok path that map rides inside the `TypedCommons`; on the
6//! error path inside `RecordCheck::typed_despite_errors`, the program as
7//! checked so far.
8//! This sink carries it out to the analysis so completion can ask *"what is
9//! the type of the expression at this offset?"* (the receiver before a `.`),
10//! mirroring [`HintSink`](crate::hints::HintSink).
11//!
12//! Capture is at **every per-file exit** of the check, clean or not (ADR 0094,
13//! via [`record_analyse_types`](crate::check_pipeline::record_analyse_types)):
14//! a mid-edit file with an error still yields the best-effort types of its
15//! other expressions. ADR 0094 lifted the slice-3 "clean-file ceiling" of
16//! ADR 0063, under which a file with errors yielded nothing.
17//! Unlike hints, **test/integration files are not muted** (completion runs in
18//! them); only synthetic toolchain-injected files are.
19
20use crate::checker::{TyId, TypedExpr, Types};
21use bynk_syntax::ast::ExprId;
22use bynk_syntax::span::Span;
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26/// Project-relative source path → that file's `(expr span, type)` entries,
27/// ordered by span (innermost-last within a start, so a containment search can
28/// prefer the tightest match).
29pub type FileExprTypes = HashMap<PathBuf, Vec<(Span, TyId)>>;
30
31/// Records per-file expression types. A fresh sink records nothing until
32/// [`enter_file`](Self::enter_file) attributes it.
33#[derive(Debug, Default)]
34pub struct ExprTypeSink {
35 files: FileExprTypes,
36 file: Option<PathBuf>,
37 /// Set for synthetic (toolchain-injected) files — their types never serve
38 /// a user-visible completion.
39 muted: bool,
40}
41
42impl ExprTypeSink {
43 pub fn new() -> Self {
44 Self::default()
45 }
46
47 /// Enter a per-file recording context.
48 pub fn enter_file(&mut self, file: &Path, muted: bool) {
49 self.file = Some(file.to_path_buf());
50 self.muted = muted;
51 }
52
53 /// Record a whole file's `expr_types` map — the Ok path's, or the error
54 /// path's best-effort partial one (ADR 0094). Dropped when muted or before
55 /// any `enter_file`.
56 ///
57 /// T3.4: the checker's own map is keyed by [`ExprId`] (R2.4) — position
58 /// is never identity there. This sink's own storage stays position-keyed
59 /// on purpose: an editor asks "what's at this cursor offset," a
60 /// position-shaped question asked at the LSP boundary, not the
61 /// checker's. `TypedExpr` carries the span the checker computed it
62 /// against, so the join needs no separate id→span table.
63 pub fn record_file(&mut self, expr_types: &HashMap<ExprId, TypedExpr>) {
64 if self.muted {
65 return;
66 }
67 let Some(file) = &self.file else {
68 return;
69 };
70 let entry = self.files.entry(file.clone()).or_default();
71 entry.extend(expr_types.values().map(|te| (te.span, te.ty)));
72 }
73
74 /// Drain the recorded types, each file's entries ordered by span (start
75 /// ascending, then **widest first** so a forward scan ends on the tightest
76 /// containing span).
77 pub fn take_files(&mut self) -> FileExprTypes {
78 let mut files = std::mem::take(&mut self.files);
79 for entries in files.values_mut() {
80 entries.sort_by_key(|(span, _)| (span.start, std::cmp::Reverse(span.end)));
81 }
82 files
83 }
84}
85
86/// The type of the **innermost** expression whose span contains `offset`, if
87/// any — the receiver-typing query for `.`-member completion.
88///
89/// `None` when that innermost expression failed to type (its entry is
90/// [`Ty::Error`](crate::checker::Ty::Error)). The search does not skip past it
91/// to an enclosing expression: that is a *different* expression, and its type
92/// would answer the question confidently wrong.
93///
94/// The innermost span's type is read through [`type_at_span`], so entries
95/// that disagree at that exact position also answer `None`.
96pub fn type_at_offset(entries: &[(Span, TyId)], offset: usize, tys: &Types) -> Option<TyId> {
97 let (innermost, _) = entries
98 .iter()
99 .filter(|(span, _)| span.start <= offset && offset <= span.end)
100 .min_by_key(|(span, _)| span.end - span.start)?;
101 type_at_span(entries, *innermost).filter(|ty| !ty.is_error(tys))
102}
103
104/// The type recorded for the expression at exactly `span`'s position, or
105/// `None` if nothing is recorded there or the entries there disagree.
106///
107/// Compares `start`/`end` only, never the `FileId`: an editor reparses the
108/// buffer into spans with no file identity, while these entries carry the
109/// file's real one (T3.5). Several entries can share one position (the same
110/// expression recorded more than once, or two nodes sharing a source span),
111/// and their order follows the checker's `HashMap`, so picking the first
112/// would vary from run to run. Disagreeing entries are ambiguous, and
113/// ambiguity answers "no type" rather than an arbitrary one.
114pub fn type_at_span(entries: &[(Span, TyId)], span: Span) -> Option<TyId> {
115 let mut at = entries
116 .iter()
117 .filter(|(s, _)| s.start == span.start && s.end == span.end)
118 .map(|(_, ty)| *ty);
119 let first = at.next()?;
120 at.all(|ty| ty == first).then_some(first)
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::checker::{Ty, Types};
127 use bynk_syntax::ast::BaseType;
128
129 fn span(start: usize, end: usize) -> Span {
130 Span::new(start, end)
131 }
132
133 #[test]
134 fn type_at_offset_prefers_the_innermost_span() {
135 let tys = Types::new();
136 let int = tys.intern(Ty::Base(BaseType::Int));
137 let string = tys.intern(Ty::Base(BaseType::String));
138 // An outer `String` expression 0..10 with an inner `Int` 2..4.
139 let entries = vec![(span(0, 10), string), (span(2, 4), int)];
140 assert_eq!(type_at_offset(&entries, 3, &tys), Some(int)); // inside the inner span
141 assert_eq!(type_at_offset(&entries, 7, &tys), Some(string)); // outer span only
142 assert_eq!(type_at_offset(&entries, 20, &tys), None); // outside everything
143 }
144
145 #[test]
146 fn disagreeing_entries_at_one_position_are_no_type() {
147 let tys = Types::new();
148 let int = tys.intern(Ty::Base(BaseType::Int));
149 let string = tys.intern(Ty::Base(BaseType::String));
150 // A repeated identical entry is still one answer...
151 let agreeing = vec![(span(0, 10), int), (span(0, 10), int)];
152 assert_eq!(type_at_span(&agreeing, span(0, 10)), Some(int));
153 assert_eq!(type_at_offset(&agreeing, 3, &tys), Some(int));
154 // ...but two types recorded at one position are ambiguous, in
155 // either order.
156 for entries in [
157 vec![(span(0, 10), int), (span(0, 10), string)],
158 vec![(span(0, 10), string), (span(0, 10), int)],
159 ] {
160 assert_eq!(type_at_span(&entries, span(0, 10)), None);
161 assert_eq!(type_at_offset(&entries, 3, &tys), None);
162 }
163 }
164
165 #[test]
166 fn an_error_typed_innermost_span_is_no_type_not_its_parent() {
167 let tys = Types::new();
168 let string = tys.intern(Ty::Base(BaseType::String));
169 let error = tys.intern(Ty::Error);
170 // A well-typed outer expression 0..10 around an inner one 2..4 whose
171 // typing failed: the inner offset has no type, rather than the
172 // outer expression's.
173 let entries = vec![(span(0, 10), string), (span(2, 4), error)];
174 assert_eq!(type_at_offset(&entries, 3, &tys), None);
175 assert_eq!(type_at_offset(&entries, 7, &tys), Some(string));
176 }
177
178 /// Each expression is recorded once per file. A file that fails
179 /// `check_record` but keeps being checked (#1663) used to be recorded at
180 /// the `Err` exit and again at the final `failed` exit, doubling every
181 /// entry.
182 ///
183 /// The assertion is that no two entries share a span, which is stronger
184 /// than "recorded once": the checker does not guarantee that two typed
185 /// nodes never share a span (see `check_record_in`'s finding #28, bug
186 /// #844 and the else-less `if`). A fixture here must avoid such pairs.
187 #[test]
188 fn each_expression_is_recorded_once() {
189 for src in [
190 "context c\n\nfn f(num: Int) -> Int {\n num * 2\n}\n",
191 "context c\n\nfn f(num: Int) -> Int {\n num + \"s\"\n}\n",
192 // A record error, then a handler body typed by the declaration
193 // checks, which also fails.
194 "context c\n\nfn f(num: Int) -> Int {\n num + \"s\"\n}\n\n\
195 service api from http {\n on GET(\"/x\") () -> Effect[HttpResult[String]] by Visitor {\n Ok(1 + \"s\")\n }\n}\n",
196 ] {
197 let a = crate::testkit::analyse(&[("c.bynk", src)]);
198 let (_, entries) = a
199 .analysis
200 .expr_types
201 .iter()
202 .find(|(path, _)| path.ends_with("c.bynk"))
203 .expect("types recorded for c.bynk");
204 let mut spans: Vec<_> = entries
205 .iter()
206 .map(|(span, _)| (span.start, span.end))
207 .collect();
208 assert!(!spans.is_empty(), "no types recorded for {src:?}");
209 let total = spans.len();
210 spans.dedup();
211 assert_eq!(
212 spans.len(),
213 total,
214 "duplicate spans for {src:?}: {entries:?}"
215 );
216 if src.contains("service") {
217 // The handler body was typed and recorded, not just `f`.
218 assert_eq!(a.type_at("c.bynk", "1"), "Int");
219 }
220 }
221 }
222}