@uniflowed/vite 0.0.0-alpha.1 → 0.0.0-alpha.10

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,241 @@
1
+ // @noflow
2
+ //
3
+ // The Flow syntax a JavaScript grammar cannot parse, shown to it as
4
+ // JavaScript it can, and taken back afterwards.
5
+ //
6
+ // # Why re-tagging the word is not enough
7
+ //
8
+ // `internal/flow-keywords.js` colours Flow's words after the grammar has run.
9
+ // That works for a word the grammar tokenised and mis-labelled. It cannot work
10
+ // for syntax that stops the grammar, because there are then no tokens to
11
+ // re-label — the whole construct arrives as one unstyled run. Two pieces of
12
+ // Flow do that, and both are common enough that this repository's own
13
+ // documentation hit them on its first page about Flow.
14
+ //
15
+ // **A `component` or `hook` declaration.** A JavaScript grammar has no
16
+ // production for an identifier where a declaration keyword belongs, so it
17
+ // gives up on the rest of the line:
18
+ //
19
+ // export component Avatar(src: string, size: number = 32) {
20
+ // └ keyword ┘ └ name ┘ └───────── one grey token ─────────┘
21
+ //
22
+ // The parameter names, their types and the default value were all the same
23
+ // undifferentiated grey. `export hook useNow(…) {` was worse: the grammar's
24
+ // state did not recover, and all eight lines of that sample came out as one
25
+ // grey token each — a code block with no highlighting at all, on the page
26
+ // whose subject is Flow's syntax.
27
+ //
28
+ // **An exact object type, `{| … |}`.** This one is quieter and travels
29
+ // further. The grammar reads the `{|` as a brace and a bitwise or, and every
30
+ // line *after* it in the same block is then mis-scoped: `export` came out in
31
+ // the colour of a function call, `return` likewise, and a JSX tag lost its
32
+ // element colour. One type annotation discoloured the rest of the sample.
33
+ //
34
+ // # What this does instead
35
+ //
36
+ // It hands the grammar `function` where the source says `component` or `hook`,
37
+ // and a plain brace where the source says `{|`, takes the tokens that
38
+ // produces, and rebuilds them over the original text. The grammar then walks
39
+ // the parameter list, the return type and the body the way it does for any
40
+ // function, and `flow-keywords.js` recolours the restored words.
41
+ //
42
+ // # Why this cannot corrupt the sample
43
+ //
44
+ // Because {@link restoreLine} never copies from the text the grammar saw. It
45
+ // maps each token's boundaries back into the original line and slices *that*,
46
+ // so the concatenation of a restored line is the original line by
47
+ // construction, whatever the grammar decided to do with a stand-in. A
48
+ // mis-shimmed line can come out with the wrong colours. It cannot come out
49
+ // saying `function`.
50
+ //
51
+ // # What it does not cover
52
+ //
53
+ // The declaration rewrite is anchored to the start of a line, after an
54
+ // optional `export` or `export default`, which is where a declaration begins
55
+ // and where the grammar breaks. A declaration written anywhere else is left to
56
+ // the ordinary path.
57
+ //
58
+ // Neither rewrite asks whether the line is inside a string or a comment, so a
59
+ // line of quoted sample code that opens with `component Name(` is rewritten
60
+ // too. The text still survives exactly; only its colours are a function's
61
+ // rather than a string's, and `flow-keywords.js` still refuses to call the
62
+ // word a keyword. Buying the remaining fidelity would mean lexing the block
63
+ // twice, once here and once there, to fix a case that is a code sample inside
64
+ // a code sample.
65
+
66
+ /**
67
+ * A `component` or `hook` declaration head at the start of a line.
68
+ *
69
+ * The name and the opening bracket are matched but not captured: requiring
70
+ * them is what distinguishes a declaration from `const component = 1`, and
71
+ * consuming them would mean putting them back.
72
+ */
73
+ const DECLARATION_HEAD =
74
+ /^([ \t]*(?:export[ \t]+(?:default[ \t]+)?)?)(component|hook)(?=[ \t]+[A-Za-z_$][\w$]*[ \t]*[(<])/;
75
+
76
+ /**
77
+ * The word a declaration keyword is shown as.
78
+ *
79
+ * `function` and not `function*`: a generator tokenises identically here, and
80
+ * the length no longer has to match now that restoration maps positions rather
81
+ * than assuming they line up.
82
+ */
83
+ const DECLARATION_STAND_IN = "function";
84
+
85
+ /**
86
+ * The braces of an exact object type, and the ordinary braces they are shown
87
+ * as.
88
+ *
89
+ * Same length in both directions, so the rest of the line does not move; the
90
+ * mapping would cope either way, but a rewrite that cannot shift anything is
91
+ * one less thing to reason about. `{||}` — the empty exact object — is two
92
+ * adjacent rewrites rather than an overlapping one, which is why these are
93
+ * matched as a pair of two-character sequences rather than as one bracket.
94
+ */
95
+ const EXACT_OBJECT = /\{\||\|\}/g;
96
+
97
+ const EXACT_OBJECT_STAND_INS = { "{|": "{ ", "|}": " }" };
98
+
99
+ /**
100
+ * `source` rewritten for the grammar, with the undo that belongs to it.
101
+ *
102
+ * The undo is returned rather than exported separately because it closes over
103
+ * the original lines, and pairing the wrong undo with a rewrite is the one
104
+ * mistake that would matter. `restore` is the identity when nothing was
105
+ * rewritten, so the caller has no case to distinguish.
106
+ *
107
+ * @param {string} source
108
+ * @returns {{code: string, restore: (lines: Array<Array<object>>) => Array<Array<object>>}}
109
+ */
110
+ export function shimFlowGrammar(source) {
111
+ const original = source.split("\n");
112
+ const edits = new Map();
113
+
114
+ const shimmed = original.map((line, index) => {
115
+ const lineEdits = editsFor(line);
116
+ if (lineEdits.length === 0) {
117
+ return line;
118
+ }
119
+ edits.set(index, lineEdits);
120
+ return rewrite(line, lineEdits);
121
+ });
122
+
123
+ if (edits.size === 0) {
124
+ return { code: source, restore: (lines) => lines };
125
+ }
126
+ return {
127
+ code: shimmed.join("\n"),
128
+ restore: (lines) => restore(lines, edits, original),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Every rewrite one line needs, in the order they occur.
134
+ *
135
+ * Ordered and non-overlapping, because {@link mapping} walks them once and
136
+ * accumulates the shift each one makes. The declaration head is found first
137
+ * and starts at the line's indentation, so it can never overlap an exact
138
+ * object brace, which needs a `{` or a `|`.
139
+ */
140
+ function editsFor(line) {
141
+ const found = [];
142
+ const head = DECLARATION_HEAD.exec(line);
143
+ if (head != null) {
144
+ found.push({ column: head[1].length, text: head[2], standIn: DECLARATION_STAND_IN });
145
+ }
146
+ for (const brace of line.matchAll(EXACT_OBJECT)) {
147
+ found.push({
148
+ column: brace.index ?? 0,
149
+ text: brace[0],
150
+ standIn: EXACT_OBJECT_STAND_INS[brace[0]],
151
+ });
152
+ }
153
+ return found;
154
+ }
155
+
156
+ /** `line` with every rewrite applied, left to right. */
157
+ function rewrite(line, edits) {
158
+ let out = "";
159
+ let at = 0;
160
+ for (const edit of edits) {
161
+ out += line.slice(at, edit.column) + edit.standIn;
162
+ at = edit.column + edit.text.length;
163
+ }
164
+ return out + line.slice(at);
165
+ }
166
+
167
+ /**
168
+ * The tokenised block, rebuilt over the original source.
169
+ *
170
+ * Offsets are recomputed rather than adjusted: a stand-in that is not the
171
+ * length of the text it stands for moves everything after it on the line, and
172
+ * every line after that one, so there is no correct delta to add. Walking the
173
+ * restored lines gives the right answer directly.
174
+ */
175
+ function restore(lines, edits, original) {
176
+ let offset = 0;
177
+ return lines.map((tokens, index) => {
178
+ const text = original[index] ?? tokens.map((token) => token.content).join("");
179
+ const lineEdits = edits.get(index);
180
+ const restored =
181
+ lineEdits == null ? reoffset(tokens, offset) : restoreLine(tokens, text, lineEdits, offset);
182
+ offset += text.length + 1;
183
+ return restored;
184
+ });
185
+ }
186
+
187
+ /** A line the grammar saw unchanged, with its offsets moved to the original. */
188
+ function reoffset(tokens, offset) {
189
+ let at = 0;
190
+ return tokens.map((token) => {
191
+ const placed = { ...token, offset: offset + at };
192
+ at += token.content.length;
193
+ return placed;
194
+ });
195
+ }
196
+
197
+ /**
198
+ * One rewritten line's tokens, re-cut over the original text.
199
+ *
200
+ * Every token boundary is a position in the line the grammar saw; {@link
201
+ * mapping} turns it into a position in the line the reader gets. A boundary
202
+ * that falls *inside* a stand-in maps to the end of the text it stands for, so
203
+ * that text always lands in exactly one piece and no character of the line is
204
+ * dropped — the pieces still tile the line end to end even when the grammar
205
+ * split a stand-in or swallowed it into a longer token.
206
+ */
207
+ function restoreLine(tokens, text, edits, offset) {
208
+ const map = mapping(edits);
209
+ const out = [];
210
+ let seen = 0;
211
+ let at = 0;
212
+ for (const token of tokens) {
213
+ const from = map(seen);
214
+ seen += token.content.length;
215
+ const to = map(seen);
216
+ if (to <= from) {
217
+ continue;
218
+ }
219
+ out.push({ ...token, content: text.slice(from, to), offset: offset + at });
220
+ at += to - from;
221
+ }
222
+ return out;
223
+ }
224
+
225
+ /** A position in the rewritten line, as a position in the original one. */
226
+ function mapping(edits) {
227
+ return (at) => {
228
+ let shift = 0;
229
+ for (const edit of edits) {
230
+ const from = edit.column + shift;
231
+ if (at <= from) {
232
+ return at - shift;
233
+ }
234
+ if (at < from + edit.standIn.length) {
235
+ return edit.column + edit.text.length;
236
+ }
237
+ shift += edit.standIn.length - edit.text.length;
238
+ }
239
+ return at - shift;
240
+ };
241
+ }