Skip to main content

lex_types/
rules.rs

1//! Rule-tagged messages for type errors (#306 slice 2).
2//!
3//! Every `TypeError` variant maps to a stable `rule_tag` (a kebab-
4//! case identifier) and a `rule_explanation` (plain-language
5//! description of what the rule enforces). LLM repair flows that
6//! reference the `rule_tag` get measurably better repair attempts
7//! because the model can cross-reference the rule across many
8//! prior examples.
9//!
10//! The tag is stable across releases — once shipped, a rule_tag
11//! never changes meaning. New rules get new tags; existing
12//! variants that split into more specific sub-rules will be
13//! handled by adding new sibling tags, not by repurposing existing
14//! ones.
15
16use crate::error::TypeError;
17
18/// Catalog entry for one rule.
19#[derive(Debug, Clone, Copy)]
20pub struct RuleInfo {
21    pub tag: &'static str,
22    pub explanation: &'static str,
23}
24
25impl TypeError {
26    /// Stable kebab-case identifier for this error variant. See
27    /// [`all_rules`] for the full catalog.
28    pub fn rule_tag(&self) -> &'static str {
29        match self {
30            TypeError::TypeMismatch { .. } => "type-mismatch",
31            TypeError::UnknownIdentifier { .. } => "unknown-identifier",
32            TypeError::ArityMismatch { .. } => "arity-mismatch",
33            TypeError::NonExhaustiveMatch { .. } => "non-exhaustive-match",
34            TypeError::UnknownField { .. } => "unknown-field",
35            TypeError::DuplicateField { .. } => "duplicate-field",
36            TypeError::UnknownVariant { .. } => "unknown-variant",
37            TypeError::EffectNotDeclared { .. } => "effect-not-declared",
38            TypeError::InfiniteType { .. } => "infinite-type",
39            TypeError::AmbiguousType { .. } => "ambiguous-type",
40            TypeError::RecursiveTypeWithoutConstructor { .. } => "recursive-type-without-constructor",
41            TypeError::RefinementViolation { .. } => "refinement-violation",
42        }
43    }
44
45    /// Plain-language description of what the rule enforces. Aimed
46    /// at LLM repair-flow prompts: short enough to inline in a
47    /// system message, specific enough to suggest the next move.
48    pub fn rule_explanation(&self) -> &'static str {
49        explanation_for_tag(self.rule_tag())
50    }
51}
52
53fn explanation_for_tag(tag: &str) -> &'static str {
54    match tag {
55        "type-mismatch" => "An expression's inferred type doesn't match what the surrounding context requires \
56(return type, let-binding annotation, function argument, operator operand, etc.). Fix by changing \
57the expression to produce the expected type, or by adjusting the declared/inferred expected type \
58to match.",
59        "unknown-identifier" => "A name referenced in scope is not declared. Either the binding is missing, \
60the name is misspelled, or an `import` is missing. Check for typos first; then verify the relevant \
61`let`, parameter, or top-level `fn` is in scope.",
62        "arity-mismatch" => "A call site supplies a different number of arguments than the function or \
63constructor accepts. Either add the missing arguments or remove the extras.",
64        "non-exhaustive-match" => "A `match` expression doesn't cover every case of its scrutinee's type. \
65Add the missing arms listed in the error, or add a `_` wildcard if catching the remainder is intended.",
66        "unknown-field" => "A record field access or literal references a field name that isn't part of \
67the record type. Verify spelling and that the type really has that field — check the type declaration.",
68        "duplicate-field" => "A record literal lists the same field name twice. Each field must appear \
69exactly once. Remove the duplicate or rename one of them.",
70        "unknown-variant" => "A constructor pattern or expression references a variant name that isn't \
71part of the union type. Verify spelling and that the variant exists on this union.",
72        "effect-not-declared" => "A function body invokes an effect (io, fs_read, net, …) that the \
73function's signature doesn't declare. Either add the effect to the function's `[effects]` annotation \
74or remove the call that produces it.",
75        "infinite-type" => "Inference would require a type to contain itself (e.g. `t = List<t>` with no \
76constructor). Add a nominal type wrapper or restructure the data so the recursion is mediated by a \
77named type.",
78        "ambiguous-type" => "Inference couldn't pick a single concrete type for an expression. Add a type \
79annotation to disambiguate.",
80        "recursive-type-without-constructor" => "A type alias references itself with no constructor in \
81between, so no value of the type can ever be built. Make the recursive position carry a constructor \
82(e.g. `Cons<T, List<T>> | Nil`).",
83        "refinement-violation" => "A literal argument provably violates a refinement-type predicate \
84(#209). Adjust the argument to satisfy the predicate, or relax the predicate at the function \
85signature.",
86        _ => "Unknown rule. The rule_tag may have been introduced after this Lex release.",
87    }
88}
89
90/// The full rule catalog, in stable order. Used by `lex docs --rules`
91/// and by tooling that wants to enumerate every supported rule
92/// (e.g. an LSP server building a code-actions registry).
93pub fn all_rules() -> &'static [RuleInfo] {
94    &[
95        RuleInfo { tag: "type-mismatch", explanation: TYPE_MISMATCH },
96        RuleInfo { tag: "unknown-identifier", explanation: UNKNOWN_IDENT },
97        RuleInfo { tag: "arity-mismatch", explanation: ARITY_MISMATCH },
98        RuleInfo { tag: "non-exhaustive-match", explanation: NON_EXHAUSTIVE },
99        RuleInfo { tag: "unknown-field", explanation: UNKNOWN_FIELD },
100        RuleInfo { tag: "duplicate-field", explanation: DUPLICATE_FIELD },
101        RuleInfo { tag: "unknown-variant", explanation: UNKNOWN_VARIANT },
102        RuleInfo { tag: "effect-not-declared", explanation: EFFECT_NOT_DECLARED },
103        RuleInfo { tag: "infinite-type", explanation: INFINITE_TYPE },
104        RuleInfo { tag: "ambiguous-type", explanation: AMBIGUOUS_TYPE },
105        RuleInfo { tag: "recursive-type-without-constructor", explanation: RECURSIVE_NO_CTOR },
106        RuleInfo { tag: "refinement-violation", explanation: REFINEMENT_VIOLATION },
107    ]
108}
109
110/// Static (rule_tag → suggested_transform) table for #306 slice 3.
111///
112/// When `Store::apply_operation_checked` rejects an op for a
113/// `TypeError`, the gate consults this table and pre-populates the
114/// `RepairHint` attestation's `suggested_transform` payload so the
115/// LLM repair flow (or a human reading `lex repair <op>`) has a
116/// concrete starting point. `None` means no static suggestion
117/// exists for this rule; the LLM-driven `lex repair --apply` path
118/// still works.
119///
120/// The returned shape is a JSON object with:
121/// - `kind_hint`: name of the typed transform most likely to fix it
122///   (`"ReplaceMatchArm"`, `"RenameLocal"`, `"InlineLet"`,
123///   `"ChangeEffectSig"`, `"ModifyBody"`).
124/// - `rule_tag`: echo of the rule that fired (for downstream
125///   correlation).
126/// - `summary`: one-sentence direction.
127/// - `details`: longer prose suitable for an LLM repair prompt.
128pub fn suggested_transform_for(rule_tag: &str) -> Option<serde_json::Value> {
129    let (kind_hint, summary, details) = match rule_tag {
130        "type-mismatch" => (
131            "ReplaceMatchArm",
132            "Replace the offending match arm (or expression) so its body produces the expected type.",
133            "When a function body's inferred type doesn't match its signature, the easiest \
134typed-transform fix is `ReplaceMatchArm` — rebuild whichever arm produces the wrong type so it \
135returns the expected one. For non-match expressions, the LLM-driven `lex repair --apply` flow \
136can rewrite the body via `ModifyBody`.",
137        ),
138        "unknown-identifier" => (
139            "RenameLocal",
140            "If the name is a typo, rename a similarly-spelled in-scope binding to match.",
141            "An `unknown-identifier` error is most often a typo. Search the function's lexical \
142scope for a binding whose name is a single edit away and apply `RenameLocal` to switch references. \
143If no nearby name exists, the missing binding probably needs a `let` or an `import` — fall back to \
144LLM-driven repair.",
145        ),
146        "non-exhaustive-match" => (
147            "ReplaceMatchArm",
148            "Add the missing match arms (or a `_` wildcard) covering the unhandled variants.",
149            "Use `ReplaceMatchArm` to append arms for the variants listed in the error's \
150`missing` field. If catching the remainder is intended, a single `_` wildcard arm suffices; \
151otherwise add one explicit arm per missing variant so the audit trail records the new semantics.",
152        ),
153        "effect-not-declared" => (
154            "ChangeEffectSig",
155            "Add the inferred effect to the function's `[effects]` declaration.",
156            "The function body invokes an effect that the signature doesn't declare. Either add \
157the effect to the signature via `ChangeEffectSig` (preferred — the effect is genuinely needed) or \
158remove the call that produces it via `ModifyBody` (preferred when the effect was unintentional).",
159        ),
160        "arity-mismatch" => (
161            "ModifyBody",
162            "Match the call site's argument count to the function's declared arity.",
163            "The number of arguments at the call site doesn't match the declared signature. \
164Add the missing arguments or remove the extras. No typed transform directly applies — \
165use the LLM-driven `lex repair --apply` flow with `ModifyBody` to rewrite the call site.",
166        ),
167        "unknown-field" => (
168            "ModifyBody",
169            "Verify the field spelling and the record type's declaration; rewrite the access.",
170            "The field name isn't part of the record type. Either correct the spelling or add \
171the missing field to the type declaration. Use the LLM-driven `lex repair --apply` flow with \
172`ModifyBody` to rewrite the field access once the correct name is known.",
173        ),
174        "ambiguous-type" => (
175            "ModifyBody",
176            "Add a type annotation at the ambiguous expression to disambiguate inference.",
177            "Inference couldn't pick a single concrete type. Add an explicit type annotation on \
178the offending `let` binding, function parameter, or function return type via `ModifyBody`. The \
179LLM-driven `lex repair --apply` flow can synthesize the annotation from the surrounding context.",
180        ),
181        _ => return None,
182    };
183    Some(serde_json::json!({
184        "kind_hint": kind_hint,
185        "rule_tag": rule_tag,
186        "summary": summary,
187        "details": details,
188    }))
189}
190
191// Constants keyed off the tag so `all_rules` and
192// `explanation_for_tag` produce identical strings without
193// duplicating the prose.
194const TYPE_MISMATCH: &str = "An expression's inferred type doesn't match what the surrounding context requires \
195(return type, let-binding annotation, function argument, operator operand, etc.). Fix by changing \
196the expression to produce the expected type, or by adjusting the declared/inferred expected type \
197to match.";
198const UNKNOWN_IDENT: &str = "A name referenced in scope is not declared. Either the binding is missing, \
199the name is misspelled, or an `import` is missing. Check for typos first; then verify the relevant \
200`let`, parameter, or top-level `fn` is in scope.";
201const ARITY_MISMATCH: &str = "A call site supplies a different number of arguments than the function or \
202constructor accepts. Either add the missing arguments or remove the extras.";
203const NON_EXHAUSTIVE: &str = "A `match` expression doesn't cover every case of its scrutinee's type. \
204Add the missing arms listed in the error, or add a `_` wildcard if catching the remainder is intended.";
205const UNKNOWN_FIELD: &str = "A record field access or literal references a field name that isn't part of \
206the record type. Verify spelling and that the type really has that field — check the type declaration.";
207const DUPLICATE_FIELD: &str = "A record literal lists the same field name twice. Each field must appear \
208exactly once. Remove the duplicate or rename one of them.";
209const UNKNOWN_VARIANT: &str = "A constructor pattern or expression references a variant name that isn't \
210part of the union type. Verify spelling and that the variant exists on this union.";
211const EFFECT_NOT_DECLARED: &str = "A function body invokes an effect (io, fs_read, net, …) that the \
212function's signature doesn't declare. Either add the effect to the function's `[effects]` annotation \
213or remove the call that produces it.";
214const INFINITE_TYPE: &str = "Inference would require a type to contain itself (e.g. `t = List<t>` with no \
215constructor). Add a nominal type wrapper or restructure the data so the recursion is mediated by a \
216named type.";
217const AMBIGUOUS_TYPE: &str = "Inference couldn't pick a single concrete type for an expression. Add a type \
218annotation to disambiguate.";
219const RECURSIVE_NO_CTOR: &str = "A type alias references itself with no constructor in \
220between, so no value of the type can ever be built. Make the recursive position carry a constructor \
221(e.g. `Cons<T, List<T>> | Nil`).";
222const REFINEMENT_VIOLATION: &str = "A literal argument provably violates a refinement-type predicate \
223(#209). Adjust the argument to satisfy the predicate, or relax the predicate at the function \
224signature.";
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn every_variant_has_a_distinct_tag() {
232        // Tags are stable identifiers; collisions would be a bug
233        // and would silently merge two rule explanations.
234        let tags: Vec<&str> = all_rules().iter().map(|r| r.tag).collect();
235        let unique: std::collections::BTreeSet<&str> = tags.iter().copied().collect();
236        assert_eq!(unique.len(), tags.len(), "rule tags must be unique: {tags:?}");
237    }
238
239    #[test]
240    fn every_variant_has_a_nonempty_explanation() {
241        for rule in all_rules() {
242            assert!(!rule.explanation.is_empty(), "rule `{}` lacks an explanation", rule.tag);
243            assert!(
244                rule.explanation.len() > 40,
245                "rule `{}` explanation is too short to be useful for LLM repair",
246                rule.tag
247            );
248        }
249    }
250
251    #[test]
252    fn type_error_methods_match_catalog() {
253        // Pick a representative variant per rule and check the
254        // method round-trips against `all_rules`.
255        let cases: Vec<TypeError> = vec![
256            TypeError::TypeMismatch {
257                at_node: "n_0".into(),
258                expected: "Int".into(),
259                got: "Str".into(),
260                context: vec![],
261            },
262            TypeError::UnknownIdentifier { at_node: "n_0".into(), name: "x".into() },
263            TypeError::ArityMismatch { at_node: "n_0".into(), expected: 1, got: 2 },
264            TypeError::NonExhaustiveMatch { at_node: "n_0".into(), missing: vec!["None".into()] },
265            TypeError::UnknownField {
266                at_node: "n_0".into(),
267                record_type: "User".into(),
268                field: "ag".into(),
269            },
270            TypeError::DuplicateField { at_node: "n_0".into(), field: "name".into() },
271            TypeError::UnknownVariant { at_node: "n_0".into(), constructor: "Nada".into() },
272            TypeError::EffectNotDeclared { at_node: "n_0".into(), effect: "io".into() },
273            TypeError::InfiniteType { at_node: "n_0".into() },
274            TypeError::AmbiguousType { at_node: "n_0".into() },
275            TypeError::RecursiveTypeWithoutConstructor {
276                at_node: "n_0".into(),
277                name: "Bad".into(),
278            },
279            TypeError::RefinementViolation {
280                at_node: "n_0".into(),
281                fn_name: "f".into(),
282                param_index: 0,
283                binding: "x".into(),
284                reason: "x > 0".into(),
285            },
286        ];
287        let catalog: std::collections::BTreeMap<&str, &str> =
288            all_rules().iter().map(|r| (r.tag, r.explanation)).collect();
289        assert_eq!(cases.len(), catalog.len(), "every variant must be covered");
290        for e in &cases {
291            let tag = e.rule_tag();
292            let expl = catalog.get(tag).unwrap_or_else(|| panic!("tag `{tag}` not in catalog"));
293            assert_eq!(e.rule_explanation(), *expl, "tag/explanation mismatch on {tag}");
294        }
295    }
296
297    #[test]
298    fn suggested_transform_covers_at_least_five_rules() {
299        // #306 slice 3 AC: ≥5 rule_tags have a non-None
300        // suggested_transform. Catches accidental removal.
301        let mut covered = 0;
302        for rule in all_rules() {
303            if suggested_transform_for(rule.tag).is_some() {
304                covered += 1;
305            }
306        }
307        assert!(
308            covered >= 5,
309            "suggested_transform must cover ≥5 rule_tags; got {covered}"
310        );
311    }
312
313    #[test]
314    fn suggested_transform_shape_is_consistent() {
315        // Every non-None suggestion must carry the four fields
316        // documented in `suggested_transform_for`.
317        for rule in all_rules() {
318            let Some(s) = suggested_transform_for(rule.tag) else { continue };
319            for field in ["kind_hint", "rule_tag", "summary", "details"] {
320                assert!(
321                    s.get(field).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty()),
322                    "rule `{}` suggestion missing/empty `{field}`: {s}",
323                    rule.tag
324                );
325            }
326            assert_eq!(
327                s.get("rule_tag").and_then(|v| v.as_str()),
328                Some(rule.tag),
329                "suggestion's rule_tag must echo the input tag"
330            );
331        }
332    }
333
334    #[test]
335    fn unknown_rule_tag_returns_none() {
336        assert!(suggested_transform_for("does-not-exist").is_none());
337    }
338}