1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
//! Near-miss rejection for misspelled field attributes (cratestack#679's
//! typo half).
//!
//! `.cstack` attributes parse generically into an opaque `Attribute { raw,
//! span }`, and an unrecognised one is simply inert. That is how a typo'd
//! `@raedonly` used to report `schema OK` while quietly leaving a field
//! ordinary and writable — the schema author gets positive confirmation
//! that a protection is in place when it is not. #679 calls this out as
//! failing in the unsafe direction, and it is the half
//! `crate::validate::removed_attributes` deliberately left open (see that
//! module's doc).
//!
//! # Why near-miss, and not a closed attribute set
//!
//! Maintainer decision on #679, choosing option (b) over option (a).
//!
//! Option (a) — reject *any* attribute not on an allowlist — matches the
//! ticket's first acceptance criterion literally, but commits the language
//! to a closed set and carries real blast radius: the supported set has to
//! be reconstructed from scattered `raw == "@…"` comparisons and validator
//! match arms with no in-repo spec to derive it from, it must be correct
//! for all five field-bearing declaration kinds (`model`, `view`, `mixin`,
//! `type`, `auth`), and a too-narrow list breaks every affected user's
//! schema on upgrade. That is a worse failure than the silent no-op it
//! replaces.
//!
//! Option (b), implemented here: an unknown attribute is rejected **only**
//! when it is a near-miss of a name the language knows. So `@raedonly`
//! fails and names `@readonly`, while `@totallyBogusAttribute` stays inert
//! exactly as before. This covers the case #679's Summary actually argues
//! about — a typo silently dropping an intended protection — at a fraction
//! of the risk.
//!
//! # Why a generous reference set is the safe direction
//!
//! [`KNOWN_ATTRIBUTE_NAMES`] deliberately lists every attribute name the
//! language knows *at any position*, not just the ones valid on a field.
//! Under option (b) that asymmetry is safe in the right direction:
//!
//! - An **extra** name only ever *reduces* detections (it makes an input
//! "known", so this module stays silent) — it can never cause a false
//! rejection.
//! - A **missing** name is the only real hazard: a genuinely supported
//! attribute absent from the list could be flagged as a near-miss of
//! some other listed name.
//!
//! So the list errs toward inclusion. It also deliberately contains the
//! names `crate::validate::removed_attributes` rejects outright (`@allow`,
//! `@deny`, `@pb`, `@custom`): including them makes this module silent on
//! an exact use, leaving that module's specific, more useful guidance to
//! fire instead — while a *typo* of one still gets pointed at the real
//! spelling, which then draws the real explanation.
//!
//! This is **not** a claim that every listed name is valid on a field.
//! Suggesting a procedure-position attribute for a field typo is a
//! slightly imprecise hint, not a wrong rejection: it still names the
//! spelling the author meant, which is the whole job here.
use Field;
use crate;
/// Every attribute name the `.cstack` language knows, at any declaration
/// position. See this module's doc for why this is deliberately generous
/// rather than field-scoped.
///
/// Derived from the union of (1) every `raw == "@…"` / `starts_with("@…")`
/// comparison across the workspace, (2) `crate::validate::validators`'
/// match arms, (3) `crate::validate::removed_attributes`'
/// `REJECTED_FIELD_ATTRIBUTES`, and (4) every single-`@` name appearing in
/// the repo's committed `.cstack` files. Sources (1) and (4) are both
/// necessary and neither is sufficient: `@uri` is accepted by
/// `validators.rs` but appears in no schema, while `@from` and
/// `@authorize` appear in schemas and in neither of the first two sets.
const KNOWN_ATTRIBUTE_NAMES: & = &;
/// Inputs shorter than this are never considered for a suggestion.
///
/// At one or two characters, almost anything is within edit distance 1 of
/// something (`@ix` would "mean" `@id`), so the suggestion stops being
/// evidence of a typo and starts being noise. Every real attribute this
/// short (`@id`, `@use`, `@pb`) is in [`KNOWN_ATTRIBUTE_NAMES`] and so
/// never reaches this path anyway.
const MIN_LENGTH_FOR_SUGGESTION: usize = 3;
/// The bare name of an attribute, with any `(...)` argument list and the
/// leading `@` stripped: `@length(min: 1)` -> `length`.
/// How far apart two names may be before a suggestion stops being
/// credible. Scaled by length so a short name needs a closer match: two
/// edits on a six-character name is a plausible typo, two edits on a
/// four-character one is usually a different word.
/// Optimal string alignment distance — Levenshtein plus adjacent
/// transposition as a single edit.
///
/// The transposition case is load-bearing rather than a refinement:
/// `raedonly` -> `readonly` is a plain transposition, which costs 2 under
/// Levenshtein but 1 here. #679's own worked example is exactly that
/// shape, so without transposition support the canonical case would need
/// the looser distance-2 threshold and drag in far more noise with it.
/// The closest known attribute name to `name`, if one is close enough to
/// be worth suggesting.
///
/// Comparison is case-insensitive so a pure case error (`@ReadOnly`)
/// surfaces too — that reaches here only when the exact, case-sensitive
/// membership test has already failed, so a distance of 0 means the name
/// differs *only* by case and is unambiguously a typo.
/// Rejects a field attribute that is a near-miss of a known attribute
/// name.
///
/// Runs *after* [`crate::validate::removed_attributes`] at every call
/// site, so an exact `@allow`/`@deny`/`@pb`/`@custom` gets that module's
/// specific guidance rather than this one's generic suggestion. (Those
/// names are in [`KNOWN_ATTRIBUTE_NAMES`], so this module is silent on
/// them regardless — the ordering is belt-and-braces, and documented so a
/// future reader does not "simplify" it by removing one of the two.)
///
/// An unknown attribute that is *not* a near-miss is left inert, which is
/// the pre-existing behaviour and the deliberate limit of option (b).
pub