Skip to main content

lanekeep_types/
types.rs

1//! What the oracle can say about an expression.
2//!
3//! This crate owns the vocabulary as well as the analysis, because the two move together: a
4//! variant added here is a question the oracle must then answer, and a question it cannot
5//! answer has no business being spellable.
6
7/// A primitive type the oracle recognizes.
8///
9/// Exactly the set the authoring surface's `TypeInfo.primitive` names, and no more. `any`
10/// and `unknown` are deliberately absent: they are the absence of a claim, and giving them
11/// a variant would let the oracle assert something TypeScript does not.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub enum Primitive {
14    /// TypeScript's `number`.
15    Number,
16    /// TypeScript's `string`.
17    String,
18    /// TypeScript's `boolean`.
19    Boolean,
20    /// TypeScript's `bigint`.
21    BigInt,
22    /// TypeScript's `symbol`.
23    Symbol,
24    /// TypeScript's `null`.
25    Null,
26    /// TypeScript's `undefined`.
27    Undefined,
28}
29
30impl Primitive {
31    /// The name a rule sees, which is the name TypeScript uses.
32    #[must_use]
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Self::Number => "number",
36            Self::String => "string",
37            Self::Boolean => "boolean",
38            Self::BigInt => "bigint",
39            Self::Symbol => "symbol",
40            Self::Null => "null",
41            Self::Undefined => "undefined",
42        }
43    }
44}
45
46/// Where a name came from.
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
48pub struct Symbol {
49    /// The name as it appears at the use site the oracle read, not at the declaration.
50    ///
51    /// For a renamed import — `import { Decimal as Money }` — this is the local alias
52    /// `Money`. It stays the use-site spelling because it is what a message should quote: the
53    /// reader has `Money` in front of them, and a violation naming `Decimal` would send them
54    /// looking for text that is not in their file. [`Symbol::exported`] is the field to
55    /// compare against an expected export name.
56    pub name: String,
57    /// The name the module exports this under, when it was imported.
58    ///
59    /// | Binding | `exported` |
60    /// | --- | --- |
61    /// | `import { a } from 'm'`, `import { a as b } from 'm'`, `m`'s declaration file unreadable | `Some("a")` |
62    /// | `import { a } from 'm'`, `import { a as b } from 'm'`, `m`'s declaration file readable | the name that file declares |
63    /// | `import d from 'm'`, `m`'s declaration file unreadable | `Some("default")` |
64    /// | `import d from 'm'`, `m`'s declaration file readable | the name that file declares |
65    /// | `import * as ns from 'm'` | `None` |
66    /// | a local declaration | `None` |
67    ///
68    /// **Copied even when nothing was renamed**, rather than `None` standing for "same as
69    /// `name`". The consumer is a comparison against a required export name, and under a
70    /// `None`-when-unrenamed contract a caller who forgot the `?? name` fallback would
71    /// silently accept every plain import — the most ordinary spelling there is, and a
72    /// failure that only ever *removes* reports, so nothing about the output would look
73    /// wrong. A copy costs one `String` per symbol and cannot fail that way.
74    ///
75    /// A namespace import binds the module object, which no single export names, so it is
76    /// `None` rather than a `"*"` sentinel: a sentinel is a string a comparison can match,
77    /// and there is nothing here for a name comparison to be right about.
78    ///
79    /// `Binding::is_import_of`, the predicate behind `ctx.resolvesToImport`, does spell a
80    /// namespace import `"*"`, and the two do not conflict: there `"*"` is what a rule
81    /// *writes* to ask for the namespace form, a query vocabulary; here the value is an
82    /// *answer* a rule compares against, and an answer of `"*"` would satisfy any comparison
83    /// that happened to write it.
84    pub exported: Option<String>,
85    /// The module it was imported from, when it was imported. `None` for a local
86    /// declaration, which is what distinguishes an imported `Decimal` from a local class
87    /// that happens to share the name.
88    pub module: Option<String>,
89}
90
91/// What the oracle established about an expression.
92///
93/// There is no `Unknown` variant on purpose: not knowing is `None` at the API boundary, so
94/// there is exactly one spelling of it. A variant would give callers two, and the
95/// interesting bug is a rule that treats one of them as an answer.
96#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
97pub enum Type {
98    /// A primitive.
99    Primitive(Primitive),
100    /// A type named by an identifier — `Decimal`, `Account`. Nominal, by name: the oracle
101    /// compares names and does not compute structural assignability.
102    Nominal {
103        /// The name as written.
104        name: String,
105        /// Where that name came from, when the resolver could say.
106        symbol: Option<Symbol>,
107    },
108    /// Two or more distinct members, flattened one level, in canonical order.
109    Union(Vec<Type>),
110}
111
112impl Type {
113    /// Build a union from its members, or the single type if that is what it comes to.
114    ///
115    /// Members are flattened one level, deduplicated and sorted. Sorting is not the
116    /// determinism requirement — source order is deterministic too — it is that
117    /// `string | number` and `number | string` are the same type and must produce one
118    /// answer. `Ord` on [`Type`] puts primitives before nominals and orders each group by
119    /// name, which is a total order over everything this crate can build.
120    ///
121    /// Returns `None` for an empty union, which is not a type and must not be reported as
122    /// one.
123    #[must_use]
124    pub fn union(members: Vec<Self>) -> Option<Self> {
125        let mut flattened: Vec<Self> = Vec::with_capacity(members.len());
126        for member in members {
127            match member {
128                // One level. A member that is itself a union was already flattened when it
129                // was built, so its own members are never unions.
130                Self::Union(inner) => flattened.extend(inner),
131                other => flattened.push(other),
132            }
133        }
134        flattened.sort();
135        flattened.dedup();
136
137        match flattened.len() {
138            0 => None,
139            1 => flattened.pop(),
140            _ => Some(Self::Union(flattened)),
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn a_union_of_one_is_that_type() {
151        assert_eq!(
152            Type::union(vec![Type::Primitive(Primitive::Number)]),
153            Some(Type::Primitive(Primitive::Number))
154        );
155    }
156
157    #[test]
158    fn a_union_of_none_is_nothing() {
159        assert_eq!(Type::union(Vec::new()), None);
160    }
161
162    /// The canonical-order claim, asserted head-on.
163    ///
164    /// `string | number` and `number | string` are the same type, so they must produce the
165    /// same answer. Source order is deterministic too, which is why this is not a
166    /// determinism test: it is a correctness one about what a union *is*.
167    #[test]
168    fn union_members_do_not_depend_on_the_order_they_were_written() {
169        let one = Type::union(vec![
170            Type::Primitive(Primitive::String),
171            Type::Primitive(Primitive::Number),
172        ]);
173        let other = Type::union(vec![
174            Type::Primitive(Primitive::Number),
175            Type::Primitive(Primitive::String),
176        ]);
177        assert_eq!(one, other);
178    }
179
180    #[test]
181    fn primitives_sort_before_nominals() {
182        let Some(Type::Union(members)) = Type::union(vec![
183            Type::Nominal {
184                name: "Decimal".to_owned(),
185                symbol: None,
186            },
187            Type::Primitive(Primitive::Number),
188        ]) else {
189            panic!("two distinct members make a union");
190        };
191        assert_eq!(members[0], Type::Primitive(Primitive::Number));
192    }
193
194    #[test]
195    fn a_repeated_member_appears_once() {
196        assert_eq!(
197            Type::union(vec![
198                Type::Primitive(Primitive::Number),
199                Type::Primitive(Primitive::Number),
200            ]),
201            Some(Type::Primitive(Primitive::Number))
202        );
203    }
204
205    /// Flattening is one level, matching what the authoring surface documents.
206    #[test]
207    fn a_nested_union_flattens_one_level() {
208        let inner = Type::union(vec![
209            Type::Primitive(Primitive::Number),
210            Type::Primitive(Primitive::String),
211        ])
212        .expect("two members");
213        let Some(Type::Union(members)) =
214            Type::union(vec![inner, Type::Primitive(Primitive::Boolean)])
215        else {
216            panic!("three distinct members make a union");
217        };
218        assert_eq!(members.len(), 3);
219    }
220
221    /// The identity digest is populated and constant within a process.
222    ///
223    /// The property that matters — it changes when the oracle's source changes — is
224    /// structural rather than testable: `build.rs` hashes all of `src/` under
225    /// `rerun-if-changed`, and a test able to fail would have to edit its own source. This
226    /// asserts what can be asserted: the build script ran and produced something.
227    #[test]
228    fn the_oracle_identity_is_populated_and_stable() {
229        let once = crate::oracle_identity();
230        assert_ne!(once, [0_u8; 32], "the build script did not write a digest");
231        assert_eq!(once, crate::oracle_identity());
232    }
233}