@radicool/throughline 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ // Is an emitted value a well-formed Swift or Kotlin literal?
2
+ //
3
+ // A CSS function and a native call expression look alike until you check the
4
+ // callee. `linear-gradient` is not a valid identifier — the hyphen disqualifies
5
+ // it — while `UIColor` is. `calc` IS a valid identifier, but `1rem + 2px` is
6
+ // not a literal. Parsing rather than pattern-matching rejects all of them
7
+ // without naming any of them, which is the point: the next unanticipated case
8
+ // is caught by the same rule.
9
+ //
10
+ // Two consumers — sd-native.mjs's output filter, and
11
+ // validate-token-output.mjs's invalid-literal rule. Its own module for the same
12
+ // reason lib/dtcg.mjs is one: shared by both token gates.
13
+ //
14
+ // This asserts LITERAL well-formedness, not that the file compiles. A call to
15
+ // an undefined function still parses.
16
+
17
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*/;
18
+
19
+ // Swift and Kotlin disagree about numeric literals in OPPOSITE directions, so
20
+ // one shared rule necessarily over-accepts on one platform or false-fails on
21
+ // the other. Both sides measured rather than assumed:
22
+ //
23
+ // Swift, via `swiftc -parse`: `.5` and `-.5` are rejected — the compiler says
24
+ // "it must be written '0.5'" — while `0100` and `00` compile.
25
+ //
26
+ // Kotlin, via `kotlinc` 2.4.10: `0100` and `00` are rejected outright —
27
+ // "leading zeros are not allowed in integer literals" — while `.5` and `-.5`
28
+ // compile, which is why they stay accepted here. This matches the spec's
29
+ // lexical grammar exactly: IntegerLiteral is
30
+ // `DecDigitNoZero {DecDigitOrSeparator} DecDigit | DecDigit`, and
31
+ // DoubleLiteral is `[DecDigits] '.' DecDigits [DoubleExponent]`.
32
+ //
33
+ // Hex compiles on both and stays. A leading-zero integer is caught by the
34
+ // trailing-input rule at the end of parseLiteral rather than by the regex:
35
+ // `0100` matches only `0`, leaving `100` unconsumed.
36
+ const NUMBER_SWIFT = /^-?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?)/;
37
+ const NUMBER_KOTLIN = /^-?(?:0[xX][0-9a-fA-F]+|(?:0|[1-9]\d*)(?:\.\d+)?|\.\d+)/;
38
+
39
+ // The union of both, for a caller that names no platform. Every real consumer
40
+ // passes a GRAMMAR entry, which overrides this.
41
+ const NUMBER = /^-?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?|\.\d+)/;
42
+
43
+ // `escapes` are the characters legal after a backslash inside a string.
44
+ // \$ escapes Kotlin's template interpolation; it is not a valid Swift escape,
45
+ // so a shared set would over-accept on iOS.
46
+ export const GRAMMAR = {
47
+ 'ios-swift': {
48
+ number: NUMBER_SWIFT,
49
+ suffixes: [],
50
+ units: [],
51
+ escapes: ['0', '\\', 't', 'n', 'r', '"', "'", 'u'],
52
+ },
53
+ 'android-kotlin': {
54
+ number: NUMBER_KOTLIN,
55
+ suffixes: ['f', 'F', 'L'],
56
+ units: ['dp', 'sp', 'em'],
57
+ escapes: ['\\', 't', 'n', 'r', '"', "'", '$', 'u'],
58
+ },
59
+ };
60
+
61
+ // CSS constructs that validate-token-output.mjs diagnoses BY NAME.
62
+ //
63
+ // These are unimplemented rescues, not values with no native form: `calc` and
64
+ // `var` are valid identifiers, and `color-mix` has a rescue in sd-native.mjs
65
+ // that merely did not match this variant. So they must reach the output and
66
+ // fail loudly under no-foreign-syntax, never be silently dropped by a filter.
67
+ // Kept here, beside the grammar, so the build and the gate cannot drift apart.
68
+ export const CSS_CONSTRUCT = /^(?:color-mix|calc|var)\s*\(/;
69
+
70
+ export function parseLiteral(value, grammar = {}) {
71
+ const s = String(value);
72
+ const suffixes = grammar.suffixes ?? [];
73
+ const units = grammar.units ?? [];
74
+ const escapes = new Set(grammar.escapes ?? []);
75
+ const numberRe = grammar.number ?? NUMBER;
76
+ let i = 0;
77
+
78
+ const ws = () => {
79
+ while (i < s.length && /\s/.test(s[i])) i += 1;
80
+ };
81
+
82
+ // Longest match wins, so a short unit cannot shadow a longer one.
83
+ const take = (words) => {
84
+ let best = null;
85
+ for (const w of words) {
86
+ if (s.startsWith(w, i) && (best === null || w.length > best.length)) best = w;
87
+ }
88
+ if (best !== null) i += best.length;
89
+ return best !== null;
90
+ };
91
+
92
+ const string = () => {
93
+ i += 1; // opening quote
94
+ while (i < s.length) {
95
+ if (s[i] === '\\') {
96
+ if (!escapes.has(s[i + 1])) return false;
97
+ i += 2;
98
+ continue;
99
+ }
100
+ if (s[i] === '"') {
101
+ i += 1;
102
+ return true;
103
+ }
104
+ if (s[i] === '\n') return false;
105
+ i += 1;
106
+ }
107
+ return false; // unterminated
108
+ };
109
+
110
+ const number = () => {
111
+ const m = s.slice(i).match(numberRe);
112
+ if (!m) return false;
113
+ i += m[0].length;
114
+ if (take(units.map((u) => `.${u}`))) return true;
115
+ take(suffixes);
116
+ return true;
117
+ };
118
+
119
+ const literal = () => {
120
+ ws();
121
+ if (i >= s.length) return false;
122
+ if (s[i] === '"') return string();
123
+
124
+ // A parenthesised NUMBER, optionally with a unit. Compose letter spacing
125
+ // needs `(-0.03).em`, because `-0.03.em` parses as `-(0.03.em)` and
126
+ // kotlinc rejects it with "unresolved reference 'unaryMinus'" unless
127
+ // TextUnit defines that operator. The parenthesised form compiles either
128
+ // way, so it is what the transform emits.
129
+ //
130
+ // Deliberately not expression support: only a single number may sit inside
131
+ // the parens. Accepting `(1 + 2)` would make this gate vouch for
132
+ // arithmetic it cannot evaluate, which is the failure class the module
133
+ // exists to prevent.
134
+ if (s[i] === '(') {
135
+ const save = i;
136
+ i += 1;
137
+ ws();
138
+ const inner = s.slice(i).match(numberRe);
139
+ if (!inner) {
140
+ i = save;
141
+ return false;
142
+ }
143
+ i += inner[0].length;
144
+ ws();
145
+ if (s[i] !== ')') {
146
+ i = save;
147
+ return false;
148
+ }
149
+ i += 1;
150
+ if (take(units.map((u) => `.${u}`))) return true;
151
+ take(suffixes);
152
+ return true;
153
+ }
154
+
155
+ const rest = s.slice(i);
156
+ const bool = rest.match(/^(?:true|false)(?![A-Za-z0-9_])/);
157
+ if (bool) {
158
+ i += bool[0].length;
159
+ return true;
160
+ }
161
+ if (numberRe.test(rest)) return number();
162
+
163
+ const id = rest.match(IDENT);
164
+ if (!id) return false;
165
+ i += id[0].length;
166
+ ws();
167
+ if (s[i] !== '(') return false; // a bare identifier is not a literal
168
+ i += 1;
169
+ ws();
170
+ if (s[i] === ')') {
171
+ i += 1;
172
+ return true;
173
+ }
174
+ for (;;) {
175
+ ws();
176
+ const save = i;
177
+ const label = s.slice(i).match(IDENT);
178
+ if (label) {
179
+ i += label[0].length;
180
+ ws();
181
+ if (s[i] === ':') i += 1;
182
+ else i = save; // not a label after all; re-read as a literal
183
+ }
184
+ if (!literal()) return false;
185
+ ws();
186
+ if (s[i] === ',') {
187
+ i += 1;
188
+ continue;
189
+ }
190
+ if (s[i] === ')') {
191
+ i += 1;
192
+ return true;
193
+ }
194
+ return false;
195
+ }
196
+ };
197
+
198
+ if (!literal()) return { ok: false, offset: i, rest: s.slice(i) };
199
+ ws();
200
+ // Trailing input after a complete literal is a failure: `400 garbage`.
201
+ if (i !== s.length) return { ok: false, offset: i, rest: s.slice(i) };
202
+ return { ok: true };
203
+ }
204
+
205
+ export const isValidLiteral = (value, grammar) => parseLiteral(value, grammar).ok;