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}