Skip to main content

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}