tailwint 1.0.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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/bin/tailwint.js +2 -0
- package/dist/applyEdits.test.d.ts +1 -0
- package/dist/applyEdits.test.js +133 -0
- package/dist/batchEdits.test.d.ts +1 -0
- package/dist/batchEdits.test.js +132 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +21 -0
- package/dist/edgeCases.test.d.ts +1 -0
- package/dist/edgeCases.test.js +99 -0
- package/dist/edits.d.ts +18 -0
- package/dist/edits.js +109 -0
- package/dist/edits.test.d.ts +4 -0
- package/dist/edits.test.js +410 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +210 -0
- package/dist/index.test.d.ts +4 -0
- package/dist/index.test.js +410 -0
- package/dist/lsp.d.ts +19 -0
- package/dist/lsp.js +239 -0
- package/dist/ui.d.ts +43 -0
- package/dist/ui.js +190 -0
- package/package.json +49 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for applyEdits from tailwint/edits.ts
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import { strict as assert } from "node:assert";
|
|
6
|
+
import { applyEdits } from "./edits.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Helpers
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
/** Shorthand to build a TextEdit */
|
|
11
|
+
function edit(startLine, startChar, endLine, endChar, newText) {
|
|
12
|
+
return {
|
|
13
|
+
range: {
|
|
14
|
+
start: { line: startLine, character: startChar },
|
|
15
|
+
end: { line: endLine, character: endChar },
|
|
16
|
+
},
|
|
17
|
+
newText,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Tests
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
describe("applyEdits", () => {
|
|
24
|
+
// ---- Basic operations ----
|
|
25
|
+
it("returns content unchanged for empty edits array", () => {
|
|
26
|
+
assert.equal(applyEdits("hello", []), "hello");
|
|
27
|
+
});
|
|
28
|
+
it("returns content unchanged for empty string input with no edits", () => {
|
|
29
|
+
assert.equal(applyEdits("", []), "");
|
|
30
|
+
});
|
|
31
|
+
it("replaces a single word", () => {
|
|
32
|
+
assert.equal(applyEdits("flex-shrink-0", [edit(0, 0, 0, 13, "shrink-0")]), "shrink-0");
|
|
33
|
+
});
|
|
34
|
+
it("inserts text at the beginning", () => {
|
|
35
|
+
assert.equal(applyEdits("world", [edit(0, 0, 0, 0, "hello ")]), "hello world");
|
|
36
|
+
});
|
|
37
|
+
it("inserts text at the end", () => {
|
|
38
|
+
assert.equal(applyEdits("hello", [edit(0, 5, 0, 5, " world")]), "hello world");
|
|
39
|
+
});
|
|
40
|
+
it("deletes text (empty newText)", () => {
|
|
41
|
+
assert.equal(applyEdits("hello world", [edit(0, 5, 0, 11, "")]), "hello");
|
|
42
|
+
});
|
|
43
|
+
// ---- Multiple edits on the same line ----
|
|
44
|
+
it("applies two non-overlapping edits on the same line", () => {
|
|
45
|
+
const content = "z-[1] flex-shrink-0";
|
|
46
|
+
const result = applyEdits(content, [
|
|
47
|
+
edit(0, 0, 0, 5, "z-1"),
|
|
48
|
+
edit(0, 6, 0, 19, "shrink-0"),
|
|
49
|
+
]);
|
|
50
|
+
assert.equal(result, "z-1 shrink-0");
|
|
51
|
+
});
|
|
52
|
+
it("applies edits regardless of input order (unsorted)", () => {
|
|
53
|
+
const content = "z-[1] flex-shrink-0";
|
|
54
|
+
// Provide edits in reverse order — should still work
|
|
55
|
+
const result = applyEdits(content, [
|
|
56
|
+
edit(0, 6, 0, 19, "shrink-0"),
|
|
57
|
+
edit(0, 0, 0, 5, "z-1"),
|
|
58
|
+
]);
|
|
59
|
+
assert.equal(result, "z-1 shrink-0");
|
|
60
|
+
});
|
|
61
|
+
it("applies three edits on the same line", () => {
|
|
62
|
+
const content = "z-[1] flex-shrink-0 min-w-[200px]";
|
|
63
|
+
const result = applyEdits(content, [
|
|
64
|
+
edit(0, 0, 0, 5, "z-1"),
|
|
65
|
+
edit(0, 6, 0, 19, "shrink-0"),
|
|
66
|
+
edit(0, 20, 0, 33, "min-w-50"),
|
|
67
|
+
]);
|
|
68
|
+
assert.equal(result, "z-1 shrink-0 min-w-50");
|
|
69
|
+
});
|
|
70
|
+
// ---- Adjacent edits (shared boundary) ----
|
|
71
|
+
it("handles adjacent edits that share a boundary", () => {
|
|
72
|
+
const content = "aabbcc";
|
|
73
|
+
const result = applyEdits(content, [
|
|
74
|
+
edit(0, 0, 0, 2, "AA"),
|
|
75
|
+
edit(0, 2, 0, 4, "BB"),
|
|
76
|
+
edit(0, 4, 0, 6, "CC"),
|
|
77
|
+
]);
|
|
78
|
+
assert.equal(result, "AABBCC");
|
|
79
|
+
});
|
|
80
|
+
it("handles adjacent insert-then-replace at same position", () => {
|
|
81
|
+
// Two edits at position 0: first is a zero-width insert, second replaces chars 0-2
|
|
82
|
+
// This is ambiguous — cursor advances past the first edit's end (0),
|
|
83
|
+
// so the second edit at start=0 would have start < cursor after the first.
|
|
84
|
+
// Current implementation: second edit's content replaces, but start <= cursor
|
|
85
|
+
// means the slice between them is empty.
|
|
86
|
+
const content = "abc";
|
|
87
|
+
const result = applyEdits(content, [
|
|
88
|
+
edit(0, 0, 0, 0, "X"), // insert X at position 0
|
|
89
|
+
edit(0, 0, 0, 1, "Y"), // replace 'a' with Y
|
|
90
|
+
]);
|
|
91
|
+
// Both edits start at offset 0. After sorting, they're in input order (stable sort?).
|
|
92
|
+
// First edit: start=0, end=0 → inserts "X", cursor=0
|
|
93
|
+
// Second edit: start=0 >= cursor(0), end=1 → but start is NOT > cursor, so no gap slice
|
|
94
|
+
// Result: "X" + "Y" + "bc" = "XYbc"
|
|
95
|
+
// This might be surprising — the "a" is deleted by the second edit but "X" is also inserted.
|
|
96
|
+
// Let's just verify the actual behavior.
|
|
97
|
+
assert.equal(result, "XYbc");
|
|
98
|
+
});
|
|
99
|
+
// ---- Multi-line edits ----
|
|
100
|
+
it("replaces across multiple lines", () => {
|
|
101
|
+
const content = "line1\nline2\nline3";
|
|
102
|
+
const result = applyEdits(content, [
|
|
103
|
+
edit(0, 3, 2, 3, "REPLACED"),
|
|
104
|
+
]);
|
|
105
|
+
assert.equal(result, "linREPLACEDe3");
|
|
106
|
+
});
|
|
107
|
+
it("applies edits on different lines", () => {
|
|
108
|
+
const content = "aaa\nbbb\nccc";
|
|
109
|
+
const result = applyEdits(content, [
|
|
110
|
+
edit(0, 0, 0, 3, "AAA"),
|
|
111
|
+
edit(2, 0, 2, 3, "CCC"),
|
|
112
|
+
]);
|
|
113
|
+
assert.equal(result, "AAA\nbbb\nCCC");
|
|
114
|
+
});
|
|
115
|
+
it("deletes an entire line including newline", () => {
|
|
116
|
+
const content = "keep\ndelete\nkeep";
|
|
117
|
+
const result = applyEdits(content, [
|
|
118
|
+
edit(1, 0, 2, 0, ""),
|
|
119
|
+
]);
|
|
120
|
+
assert.equal(result, "keep\nkeep");
|
|
121
|
+
});
|
|
122
|
+
it("inserts a new line", () => {
|
|
123
|
+
const content = "line1\nline3";
|
|
124
|
+
const result = applyEdits(content, [
|
|
125
|
+
edit(1, 0, 1, 0, "line2\n"),
|
|
126
|
+
]);
|
|
127
|
+
assert.equal(result, "line1\nline2\nline3");
|
|
128
|
+
});
|
|
129
|
+
// ---- Edge cases: out-of-bounds ----
|
|
130
|
+
it("handles edit past end of file (line beyond last)", () => {
|
|
131
|
+
const content = "hello";
|
|
132
|
+
const result = applyEdits(content, [
|
|
133
|
+
edit(5, 0, 5, 0, " world"),
|
|
134
|
+
]);
|
|
135
|
+
// Line 5 doesn't exist — toOffset clamps to content.length
|
|
136
|
+
assert.equal(result, "hello world");
|
|
137
|
+
});
|
|
138
|
+
it("handles edit with character past end of line", () => {
|
|
139
|
+
const content = "hi";
|
|
140
|
+
const result = applyEdits(content, [
|
|
141
|
+
edit(0, 100, 0, 100, "!"),
|
|
142
|
+
]);
|
|
143
|
+
// Character 100 on a 2-char line clamps to position 2
|
|
144
|
+
assert.equal(result, "hi!");
|
|
145
|
+
});
|
|
146
|
+
// ---- Edge cases: empty content ----
|
|
147
|
+
it("inserts into empty string", () => {
|
|
148
|
+
assert.equal(applyEdits("", [edit(0, 0, 0, 0, "hello")]), "hello");
|
|
149
|
+
});
|
|
150
|
+
it("handles edit on empty string with out-of-bounds range", () => {
|
|
151
|
+
assert.equal(applyEdits("", [edit(0, 0, 0, 10, "hello")]), "hello");
|
|
152
|
+
});
|
|
153
|
+
// ---- CRLF line endings ----
|
|
154
|
+
it("handles CRLF line endings", () => {
|
|
155
|
+
const content = "line1\r\nline2\r\nline3";
|
|
156
|
+
// With CRLF, \r is a regular character — only \n triggers new line offset.
|
|
157
|
+
// So line 1 starts after the \n at position 7 (l-i-n-e-1-\r-\n = 7 chars).
|
|
158
|
+
// "line2" on line 1 starts at offset 7, char 0.
|
|
159
|
+
const result = applyEdits(content, [
|
|
160
|
+
edit(1, 0, 1, 5, "LINE2"),
|
|
161
|
+
]);
|
|
162
|
+
assert.equal(result, "line1\r\nLINE2\r\nline3");
|
|
163
|
+
});
|
|
164
|
+
it("CRLF: replacing including \\r works correctly", () => {
|
|
165
|
+
const content = "aa\r\nbb";
|
|
166
|
+
// Line 0 is "aa\r", line 1 starts at offset 4
|
|
167
|
+
// Replace from (0,2) to (1,0) — should delete "\r\n"
|
|
168
|
+
const result = applyEdits(content, [
|
|
169
|
+
edit(0, 2, 1, 0, ""),
|
|
170
|
+
]);
|
|
171
|
+
assert.equal(result, "aabb");
|
|
172
|
+
});
|
|
173
|
+
// ---- Unicode and emoji ----
|
|
174
|
+
it("handles unicode content correctly", () => {
|
|
175
|
+
const content = "café";
|
|
176
|
+
// "café" — the é is one JS char (U+00E9)
|
|
177
|
+
const result = applyEdits(content, [
|
|
178
|
+
edit(0, 0, 0, 4, "CAFÉ"),
|
|
179
|
+
]);
|
|
180
|
+
assert.equal(result, "CAFÉ");
|
|
181
|
+
});
|
|
182
|
+
it("handles emoji content", () => {
|
|
183
|
+
// "hi 👋 there" — 👋 is 2 JS chars (surrogate pair)
|
|
184
|
+
const content = "hi 👋 there";
|
|
185
|
+
// LSP character offsets use UTF-16 code units, same as JS string length
|
|
186
|
+
// "hi " = 3 chars, "👋" = 2 chars, " there" = 6 chars
|
|
187
|
+
// Replace "👋" (chars 3-5) with "🎉" (also 2 chars)
|
|
188
|
+
const result = applyEdits(content, [
|
|
189
|
+
edit(0, 3, 0, 5, "🎉"),
|
|
190
|
+
]);
|
|
191
|
+
assert.equal(result, "hi 🎉 there");
|
|
192
|
+
});
|
|
193
|
+
// ---- Overlapping edits (potentially dangerous) ----
|
|
194
|
+
it("overlapping edits: second edit starts inside first edit's range", () => {
|
|
195
|
+
const content = "abcdef";
|
|
196
|
+
// Edit 1: replace chars 1-4 ("bcd") with "X"
|
|
197
|
+
// Edit 2: replace chars 2-5 ("cde") with "Y"
|
|
198
|
+
// After sorting: edit1 (start=1) comes first, cursor advances to 4
|
|
199
|
+
// edit2 (start=2) has start < cursor(4), so no gap, but edit2's
|
|
200
|
+
// newText "Y" is still pushed and cursor goes to 5.
|
|
201
|
+
// This produces: "a" + "X" + "Y" + "f" = "aXYf"
|
|
202
|
+
// The overlap means chars 2-4 are "deleted twice" — 'c' and 'd' appear
|
|
203
|
+
// in both edit ranges. The implementation doesn't detect this.
|
|
204
|
+
const result = applyEdits(content, [
|
|
205
|
+
edit(0, 1, 0, 4, "X"),
|
|
206
|
+
edit(0, 2, 0, 5, "Y"),
|
|
207
|
+
]);
|
|
208
|
+
assert.equal(result, "aXYf");
|
|
209
|
+
});
|
|
210
|
+
// ---- Realistic Tailwind scenarios ----
|
|
211
|
+
it("fixes className with multiple bracket notations", () => {
|
|
212
|
+
const content = `<div className="w-[1200px] h-[630px] overflow-hidden">`;
|
|
213
|
+
// w-[1200px] = chars 16-26 (end exclusive), h-[630px] = chars 27-36 (end exclusive)
|
|
214
|
+
const result = applyEdits(content, [
|
|
215
|
+
edit(0, 16, 0, 26, "w-300"),
|
|
216
|
+
edit(0, 27, 0, 36, "h-157.5"),
|
|
217
|
+
]);
|
|
218
|
+
assert.equal(result, `<div className="w-300 h-157.5 overflow-hidden">`);
|
|
219
|
+
});
|
|
220
|
+
it("fixes multi-line JSX with edits on different lines", () => {
|
|
221
|
+
const content = [
|
|
222
|
+
`<div`,
|
|
223
|
+
` className="z-[1] flex-shrink-0"`,
|
|
224
|
+
` style={{}}`,
|
|
225
|
+
`/>`,
|
|
226
|
+
].join("\n");
|
|
227
|
+
// z-[1] starts at char 13 on line 1, flex-shrink-0 at char 19
|
|
228
|
+
const result = applyEdits(content, [
|
|
229
|
+
edit(1, 13, 1, 18, "z-1"),
|
|
230
|
+
edit(1, 19, 1, 32, "shrink-0"),
|
|
231
|
+
]);
|
|
232
|
+
const expected = [
|
|
233
|
+
`<div`,
|
|
234
|
+
` className="z-1 shrink-0"`,
|
|
235
|
+
` style={{}}`,
|
|
236
|
+
`/>`,
|
|
237
|
+
].join("\n");
|
|
238
|
+
assert.equal(result, expected);
|
|
239
|
+
});
|
|
240
|
+
// ---- Trailing newline ----
|
|
241
|
+
it("preserves trailing newline", () => {
|
|
242
|
+
const content = "hello\n";
|
|
243
|
+
const result = applyEdits(content, [
|
|
244
|
+
edit(0, 0, 0, 5, "world"),
|
|
245
|
+
]);
|
|
246
|
+
assert.equal(result, "world\n");
|
|
247
|
+
});
|
|
248
|
+
it("edit on the empty last line after trailing newline", () => {
|
|
249
|
+
const content = "hello\n";
|
|
250
|
+
// Line 1 exists (empty, after the \n). Insert there.
|
|
251
|
+
const result = applyEdits(content, [
|
|
252
|
+
edit(1, 0, 1, 0, "world"),
|
|
253
|
+
]);
|
|
254
|
+
assert.equal(result, "hello\nworld");
|
|
255
|
+
});
|
|
256
|
+
// ---- Stress: many edits ----
|
|
257
|
+
it("handles 50 edits across 50 lines", () => {
|
|
258
|
+
const lines = Array.from({ length: 50 }, (_, i) => `line-${i}-old`);
|
|
259
|
+
const content = lines.join("\n");
|
|
260
|
+
const edits = lines.map((_, i) => {
|
|
261
|
+
const old = `line-${i}-old`;
|
|
262
|
+
return edit(i, 0, i, old.length, `line-${i}-new`);
|
|
263
|
+
});
|
|
264
|
+
const result = applyEdits(content, edits);
|
|
265
|
+
const expected = Array.from({ length: 50 }, (_, i) => `line-${i}-new`).join("\n");
|
|
266
|
+
assert.equal(result, expected);
|
|
267
|
+
});
|
|
268
|
+
// ---- Zero-width replacements at various positions ----
|
|
269
|
+
it("multiple zero-width inserts at different positions", () => {
|
|
270
|
+
const content = "ac";
|
|
271
|
+
const result = applyEdits(content, [
|
|
272
|
+
edit(0, 1, 0, 1, "b"), // insert 'b' between 'a' and 'c'
|
|
273
|
+
]);
|
|
274
|
+
assert.equal(result, "abc");
|
|
275
|
+
});
|
|
276
|
+
it("multiple zero-width inserts at the same position", () => {
|
|
277
|
+
// Two inserts at position 1 — both have start=end=1
|
|
278
|
+
// After sorting they're both at offset 1, first insert "X", cursor stays at 1,
|
|
279
|
+
// second insert "Y", cursor stays at 1
|
|
280
|
+
const content = "ac";
|
|
281
|
+
const result = applyEdits(content, [
|
|
282
|
+
edit(0, 1, 0, 1, "X"),
|
|
283
|
+
edit(0, 1, 0, 1, "Y"),
|
|
284
|
+
]);
|
|
285
|
+
// Both inserts land at offset 1: "a" + "X" + "Y" + "c"
|
|
286
|
+
assert.equal(result, "aXYc");
|
|
287
|
+
});
|
|
288
|
+
// ---- Edit that replaces entire content ----
|
|
289
|
+
it("replaces entire content with single edit", () => {
|
|
290
|
+
const content = "old content\nwith multiple\nlines";
|
|
291
|
+
const result = applyEdits(content, [
|
|
292
|
+
edit(0, 0, 2, 5, "new"),
|
|
293
|
+
]);
|
|
294
|
+
assert.equal(result, "new");
|
|
295
|
+
});
|
|
296
|
+
// ---- Only newlines ----
|
|
297
|
+
it("handles content that is only newlines", () => {
|
|
298
|
+
const content = "\n\n\n";
|
|
299
|
+
const result = applyEdits(content, [
|
|
300
|
+
edit(1, 0, 1, 0, "inserted"),
|
|
301
|
+
]);
|
|
302
|
+
assert.equal(result, "\ninserted\n\n");
|
|
303
|
+
});
|
|
304
|
+
// ---- Probing for subtle bugs ----
|
|
305
|
+
it("edit where replacement is longer than original (grows the line)", () => {
|
|
306
|
+
const content = "ab";
|
|
307
|
+
const result = applyEdits(content, [
|
|
308
|
+
edit(0, 0, 0, 1, "AAAA"), // replace 'a' (1 char) with 'AAAA' (4 chars)
|
|
309
|
+
]);
|
|
310
|
+
assert.equal(result, "AAAAb");
|
|
311
|
+
});
|
|
312
|
+
it("edit where replacement is shorter than original (shrinks the line)", () => {
|
|
313
|
+
const content = "aaaab";
|
|
314
|
+
const result = applyEdits(content, [
|
|
315
|
+
edit(0, 0, 0, 4, "X"), // replace 'aaaa' (4 chars) with 'X' (1 char)
|
|
316
|
+
]);
|
|
317
|
+
assert.equal(result, "Xb");
|
|
318
|
+
});
|
|
319
|
+
it("two edits where first shrinks and second uses original offsets", () => {
|
|
320
|
+
// This is the key scenario: after first edit shrinks, do second edit's
|
|
321
|
+
// original offsets still work correctly?
|
|
322
|
+
const content = "aaa bbb ccc";
|
|
323
|
+
// Replace 'aaa' (0-3) with 'x', replace 'ccc' (8-11) with 'z'
|
|
324
|
+
const result = applyEdits(content, [
|
|
325
|
+
edit(0, 0, 0, 3, "x"),
|
|
326
|
+
edit(0, 8, 0, 11, "z"),
|
|
327
|
+
]);
|
|
328
|
+
// Since we use original offsets: "x" + content[3:8]=" bbb " + "z"
|
|
329
|
+
assert.equal(result, "x bbb z");
|
|
330
|
+
});
|
|
331
|
+
it("two edits where first grows and second uses original offsets", () => {
|
|
332
|
+
const content = "a b c";
|
|
333
|
+
const result = applyEdits(content, [
|
|
334
|
+
edit(0, 0, 0, 1, "XXXX"), // 'a' → 'XXXX'
|
|
335
|
+
edit(0, 4, 0, 5, "ZZZZ"), // 'c' → 'ZZZZ'
|
|
336
|
+
]);
|
|
337
|
+
assert.equal(result, "XXXX b ZZZZ");
|
|
338
|
+
});
|
|
339
|
+
it("edit that deletes everything and inserts nothing", () => {
|
|
340
|
+
const content = "hello\nworld";
|
|
341
|
+
const result = applyEdits(content, [
|
|
342
|
+
edit(0, 0, 1, 5, ""),
|
|
343
|
+
]);
|
|
344
|
+
assert.equal(result, "");
|
|
345
|
+
});
|
|
346
|
+
it("edit range with end before start (invalid range)", () => {
|
|
347
|
+
// Pathological: end offset < start offset after conversion
|
|
348
|
+
// toOffset(0,5) = 5, toOffset(0,2) = 2 → start=5, end=2
|
|
349
|
+
// After sort, this edit has start=5 > cursor=0, so we'd push content[0:5]
|
|
350
|
+
// then push newText, then cursor=2 which is < 5...
|
|
351
|
+
// This should behave oddly. Let's see what happens.
|
|
352
|
+
const content = "abcdef";
|
|
353
|
+
const result = applyEdits(content, [
|
|
354
|
+
edit(0, 5, 0, 2, "X"), // start > end
|
|
355
|
+
]);
|
|
356
|
+
// start=5, end=2: push "abcde" (0-5), push "X", cursor=2
|
|
357
|
+
// cursor(2) < content.length(6), push content[2:] = "cdef"
|
|
358
|
+
// Result: "abcdeXcdef" — the reversed range causes duplication
|
|
359
|
+
assert.equal(result, "abcdeXcdef");
|
|
360
|
+
});
|
|
361
|
+
it("consecutive edits that delete and insert on multi-line content", () => {
|
|
362
|
+
const content = "line1\nline2\nline3\nline4\nline5";
|
|
363
|
+
// Delete line2, replace line4 with NEW4
|
|
364
|
+
const result = applyEdits(content, [
|
|
365
|
+
edit(1, 0, 2, 0, ""), // delete "line2\n"
|
|
366
|
+
edit(3, 0, 3, 5, "NEW4"), // replace "line4" with "NEW4"
|
|
367
|
+
]);
|
|
368
|
+
assert.equal(result, "line1\nline3\nNEW4\nline5");
|
|
369
|
+
});
|
|
370
|
+
it("edit at exact end of content (no trailing newline)", () => {
|
|
371
|
+
const content = "end";
|
|
372
|
+
const result = applyEdits(content, [
|
|
373
|
+
edit(0, 3, 0, 3, "!"),
|
|
374
|
+
]);
|
|
375
|
+
assert.equal(result, "end!");
|
|
376
|
+
});
|
|
377
|
+
it("many edits on the same line, varying replacement lengths", () => {
|
|
378
|
+
// Simulates what the LSP might do with a class like:
|
|
379
|
+
// "z-[1] flex-shrink-0 bg-primary/[0.06] min-w-[200px] h-[1px]"
|
|
380
|
+
const content = `className="z-[1] flex-shrink-0 bg-primary/[0.06] min-w-[200px] h-[1px]"`;
|
|
381
|
+
const result = applyEdits(content, [
|
|
382
|
+
edit(0, 11, 0, 16, "z-1"), // z-[1] → z-1
|
|
383
|
+
edit(0, 17, 0, 30, "shrink-0"), // flex-shrink-0 → shrink-0
|
|
384
|
+
edit(0, 31, 0, 48, "bg-primary/6"), // bg-primary/[0.06] → bg-primary/6
|
|
385
|
+
edit(0, 49, 0, 62, "min-w-50"), // min-w-[200px] → min-w-50
|
|
386
|
+
edit(0, 63, 0, 70, "h-px"), // h-[1px] → h-px
|
|
387
|
+
]);
|
|
388
|
+
assert.equal(result, `className="z-1 shrink-0 bg-primary/6 min-w-50 h-px"`);
|
|
389
|
+
});
|
|
390
|
+
it("single char file with replacement", () => {
|
|
391
|
+
assert.equal(applyEdits("x", [edit(0, 0, 0, 1, "y")]), "y");
|
|
392
|
+
});
|
|
393
|
+
it("edit newText contains newlines (splitting a line)", () => {
|
|
394
|
+
const content = "before after";
|
|
395
|
+
const result = applyEdits(content, [
|
|
396
|
+
edit(0, 6, 0, 7, "\n"), // replace space with newline
|
|
397
|
+
]);
|
|
398
|
+
assert.equal(result, "before\nafter");
|
|
399
|
+
});
|
|
400
|
+
it("edit that joins lines by replacing newline with space", () => {
|
|
401
|
+
const content = "before\nafter";
|
|
402
|
+
// The \n is at the end of line 0 (char 6 = the newline itself?).
|
|
403
|
+
// Actually, line 0 is "before", line 1 is "after".
|
|
404
|
+
// Range (0,6) to (1,0) covers just the \n character.
|
|
405
|
+
const result = applyEdits(content, [
|
|
406
|
+
edit(0, 6, 1, 0, " "),
|
|
407
|
+
]);
|
|
408
|
+
assert.equal(result, "before after");
|
|
409
|
+
});
|
|
410
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwint — Tailwind CSS linter powered by the official language server.
|
|
3
|
+
*
|
|
4
|
+
* Usage: tailwint [--fix] [glob...]
|
|
5
|
+
* tailwint # default: **\/*.{tsx,jsx,html,vue,svelte}
|
|
6
|
+
* tailwint --fix # auto-fix all issues
|
|
7
|
+
* tailwint "src/**\/*.tsx" # custom glob
|
|
8
|
+
*
|
|
9
|
+
* Set DEBUG=1 for verbose LSP message logging.
|
|
10
|
+
*/
|
|
11
|
+
export { applyEdits, type TextEdit } from "./edits.js";
|
|
12
|
+
export interface TailwintOptions {
|
|
13
|
+
patterns?: string[];
|
|
14
|
+
fix?: boolean;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function run(options?: TailwintOptions): Promise<number>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tailwint — Tailwind CSS linter powered by the official language server.
|
|
3
|
+
*
|
|
4
|
+
* Usage: tailwint [--fix] [glob...]
|
|
5
|
+
* tailwint # default: **\/*.{tsx,jsx,html,vue,svelte}
|
|
6
|
+
* tailwint --fix # auto-fix all issues
|
|
7
|
+
* tailwint "src/**\/*.tsx" # custom glob
|
|
8
|
+
*
|
|
9
|
+
* Set DEBUG=1 for verbose LSP message logging.
|
|
10
|
+
*/
|
|
11
|
+
import { resolve, relative } from "path";
|
|
12
|
+
import { readFileSync } from "fs";
|
|
13
|
+
import { glob } from "glob";
|
|
14
|
+
import { startServer, send, notify, shutdown, fileUri, langId, diagnosticsReceived, waitForProjectReady, waitForDiagnosticCount, resetState, } from "./lsp.js";
|
|
15
|
+
import { fixFile } from "./edits.js";
|
|
16
|
+
import { c, isTTY, setTitle, windTrail, braille, windWave, dots, tick, advanceTick, startSpinner, progressBar, banner, fileBadge, diagLine, rainbowText, celebrationAnimation, } from "./ui.js";
|
|
17
|
+
// Re-export for tests
|
|
18
|
+
export { applyEdits } from "./edits.js";
|
|
19
|
+
const DEFAULT_PATTERNS = ["**/*.{tsx,jsx,html,vue,svelte}"];
|
|
20
|
+
export async function run(options = {}) {
|
|
21
|
+
resetState();
|
|
22
|
+
const t0 = Date.now();
|
|
23
|
+
const cwd = resolve(options.cwd || process.cwd());
|
|
24
|
+
const fix = options.fix ?? false;
|
|
25
|
+
const patterns = options.patterns ?? DEFAULT_PATTERNS;
|
|
26
|
+
const files = [];
|
|
27
|
+
for (const pattern of patterns) {
|
|
28
|
+
const matches = await glob(pattern, {
|
|
29
|
+
cwd,
|
|
30
|
+
absolute: true,
|
|
31
|
+
nodir: true,
|
|
32
|
+
});
|
|
33
|
+
files.push(...matches);
|
|
34
|
+
}
|
|
35
|
+
await banner();
|
|
36
|
+
if (files.length === 0) {
|
|
37
|
+
console.log(` ${c.dim}No files matched.${c.reset}`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
// Phase 1: Boot the LSP
|
|
41
|
+
setTitle("tailwint ~ booting...");
|
|
42
|
+
const stopBoot = startSpinner(() => {
|
|
43
|
+
setTitle(`tailwint ~ booting${".".repeat(Date.now() % 4)}`);
|
|
44
|
+
return ` ${braille()} ${c.dim}booting language server${dots()}${c.reset} ${windTrail(24, tick)}`;
|
|
45
|
+
});
|
|
46
|
+
startServer(cwd);
|
|
47
|
+
await send("initialize", {
|
|
48
|
+
processId: process.pid,
|
|
49
|
+
rootUri: fileUri(cwd),
|
|
50
|
+
capabilities: {
|
|
51
|
+
textDocument: {
|
|
52
|
+
publishDiagnostics: { relatedInformation: true },
|
|
53
|
+
codeAction: {
|
|
54
|
+
codeActionLiteralSupport: {
|
|
55
|
+
codeActionKind: { valueSet: ["quickfix"] },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
workspace: { workspaceFolders: true, configuration: true },
|
|
60
|
+
},
|
|
61
|
+
workspaceFolders: [{ uri: fileUri(cwd), name: "workspace" }],
|
|
62
|
+
});
|
|
63
|
+
notify("initialized", {});
|
|
64
|
+
stopBoot();
|
|
65
|
+
console.error(` ${c.green}\u2714${c.reset} ${c.dim}language server ready${c.reset} ${windTrail(30)}`);
|
|
66
|
+
// Open files — triggers the server's project discovery
|
|
67
|
+
const fileContents = new Map();
|
|
68
|
+
const fileVersions = new Map();
|
|
69
|
+
for (const filePath of files) {
|
|
70
|
+
const content = readFileSync(filePath, "utf-8");
|
|
71
|
+
fileContents.set(filePath, content);
|
|
72
|
+
fileVersions.set(filePath, 1);
|
|
73
|
+
notify("textDocument/didOpen", {
|
|
74
|
+
textDocument: {
|
|
75
|
+
uri: fileUri(filePath),
|
|
76
|
+
languageId: langId(filePath),
|
|
77
|
+
version: 1,
|
|
78
|
+
text: content,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
// Wait for project init + diagnostics — event-driven, no polling
|
|
83
|
+
setTitle("tailwint ~ initializing...");
|
|
84
|
+
const stopAnalyze = startSpinner(() => {
|
|
85
|
+
const received = diagnosticsReceived.size;
|
|
86
|
+
const label = received > 0 ? "analyzing" : "initializing";
|
|
87
|
+
setTitle(`tailwint ~ ${label} ${received}/${files.length}`);
|
|
88
|
+
const pct = Math.round((received / files.length) * 100);
|
|
89
|
+
const bar = progressBar(pct, 18, true);
|
|
90
|
+
const totalStr = String(files.length);
|
|
91
|
+
const recvStr = String(received).padStart(totalStr.length);
|
|
92
|
+
const countText = `${recvStr}/${totalStr}`;
|
|
93
|
+
const usedCols = 2 + 1 + 1 + 20 + 1 + label.length + 3 + 1 + countText.length + 1;
|
|
94
|
+
const waveCols = Math.max(0, 56 - usedCols);
|
|
95
|
+
return ` ${braille()} ${bar} ${c.dim}${label}${dots()}${c.reset} ${c.bold}${recvStr}${c.reset}${c.dim}/${totalStr}${c.reset} ${windTrail(waveCols, tick)}`;
|
|
96
|
+
}, 80);
|
|
97
|
+
await waitForProjectReady();
|
|
98
|
+
await waitForDiagnosticCount(files.length);
|
|
99
|
+
stopAnalyze();
|
|
100
|
+
const analyzedText = `${files.length} files analyzed`;
|
|
101
|
+
const analyzePad = 54 - 2 - analyzedText.length - 1;
|
|
102
|
+
console.error(` ${c.green}\u2714${c.reset} ${c.dim}${analyzedText}${c.reset} ${windTrail(analyzePad)}`);
|
|
103
|
+
console.error("");
|
|
104
|
+
// Collect issues
|
|
105
|
+
let totalIssues = 0;
|
|
106
|
+
const issuesByFile = new Map();
|
|
107
|
+
for (const filePath of files) {
|
|
108
|
+
const diags = diagnosticsReceived.get(fileUri(filePath)) || [];
|
|
109
|
+
const meaningful = diags.filter((d) => d.severity === 1 || d.severity === 2);
|
|
110
|
+
if (meaningful.length > 0) {
|
|
111
|
+
issuesByFile.set(filePath, meaningful);
|
|
112
|
+
totalIssues += meaningful.length;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const conflicts = [...issuesByFile.values()]
|
|
116
|
+
.flat()
|
|
117
|
+
.filter((d) => d.code === "cssConflict").length;
|
|
118
|
+
const canonical = totalIssues - conflicts;
|
|
119
|
+
// All clear
|
|
120
|
+
if (totalIssues === 0) {
|
|
121
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
122
|
+
setTitle("tailwint \u2714 all clear");
|
|
123
|
+
await celebrationAnimation();
|
|
124
|
+
console.error(` ${c.green}\u2714${c.reset} ${c.bold}${files.length}${c.reset} files scanned ${c.dim}// ${rainbowText("all clear")} ${c.dim}${elapsed}s${c.reset}`);
|
|
125
|
+
console.error("");
|
|
126
|
+
await shutdown();
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
// Summary
|
|
130
|
+
console.error(` ${c.bold}${c.white}${files.length}${c.reset} files scanned ${c.dim}//${c.reset} ${c.orange}${c.bold}${conflicts}${c.reset}${c.orange} conflicts${c.reset} ${c.dim}\u2502${c.reset} ${c.yellow}${c.bold}${canonical}${c.reset}${c.yellow} canonical${c.reset}`);
|
|
131
|
+
console.error("");
|
|
132
|
+
// Report
|
|
133
|
+
let fileNum = 0;
|
|
134
|
+
for (const [filePath, diags] of issuesByFile) {
|
|
135
|
+
if (fileNum > 0)
|
|
136
|
+
console.log(` ${c.dim}${windWave()}${c.reset}`);
|
|
137
|
+
fileNum++;
|
|
138
|
+
const rel = relative(cwd, filePath);
|
|
139
|
+
console.log(` ${c.dim}\u250C${c.reset} ${fileBadge(rel)} ${c.dim}(${diags.length})${c.reset}`);
|
|
140
|
+
for (const d of diags) {
|
|
141
|
+
console.log(diagLine(d));
|
|
142
|
+
}
|
|
143
|
+
console.log(` ${c.dim}\u2514${windTrail(3)}${c.reset}`);
|
|
144
|
+
advanceTick();
|
|
145
|
+
}
|
|
146
|
+
// Fix
|
|
147
|
+
if (fix) {
|
|
148
|
+
console.error("");
|
|
149
|
+
console.error(` ${c.bgCyan}${c.bold} \u2699 FIX ${c.reset} ${c.dim}conflicts first, then canonical${c.reset}`);
|
|
150
|
+
console.error("");
|
|
151
|
+
let totalFixed = 0;
|
|
152
|
+
let fileIdx = 0;
|
|
153
|
+
for (const [filePath, diags] of issuesByFile) {
|
|
154
|
+
fileIdx++;
|
|
155
|
+
const rel = relative(cwd, filePath);
|
|
156
|
+
let pass = 0;
|
|
157
|
+
const shortName = rel.includes("/")
|
|
158
|
+
? rel.slice(rel.lastIndexOf("/") + 1)
|
|
159
|
+
: rel;
|
|
160
|
+
setTitle(`tailwint ~ fixing ${shortName} (${fileIdx}/${issuesByFile.size})`);
|
|
161
|
+
const stopFix = startSpinner(() => {
|
|
162
|
+
const pct = Math.round(((fileIdx - 1 + pass / 10) / issuesByFile.size) * 100);
|
|
163
|
+
const bar = progressBar(pct, 18, true);
|
|
164
|
+
const passText = `pass ${pass}`;
|
|
165
|
+
const fixUsed = 2 + 20 + shortName.length + 1 + passText.length + 3 + 1;
|
|
166
|
+
const fixWave = Math.max(0, 56 - fixUsed);
|
|
167
|
+
return ` ${braille()} ${bar} ${c.bold}${c.white}${shortName}${c.reset} ${c.dim}${passText}${dots()}${c.reset} ${windTrail(fixWave, tick)}`;
|
|
168
|
+
});
|
|
169
|
+
const fixed = await fixFile(filePath, diags, fileContents, fileVersions, (p) => {
|
|
170
|
+
pass = p;
|
|
171
|
+
});
|
|
172
|
+
stopFix();
|
|
173
|
+
totalFixed += fixed;
|
|
174
|
+
const pct = Math.round((fileIdx / issuesByFile.size) * 100);
|
|
175
|
+
const bar = progressBar(pct, 18);
|
|
176
|
+
console.error(` ${c.green}\u2714${c.reset} ${bar} ${c.bold}${c.white}${shortName}${c.reset} ${c.green}${diags.length} fixed${c.reset}`);
|
|
177
|
+
}
|
|
178
|
+
console.error("");
|
|
179
|
+
const fixElapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
180
|
+
setTitle(`tailwint \u2714 fixed ${totalFixed} issues`);
|
|
181
|
+
await celebrationAnimation();
|
|
182
|
+
console.error(` ${windWave()} ${c.bgGreen}${c.bold} \u2714 FIXED ${c.reset} ${c.green}${c.bold}${totalFixed}${c.reset} of ${c.bold}${totalIssues}${c.reset} issues across ${c.bold}${issuesByFile.size}${c.reset} files ${c.dim}${fixElapsed}s${c.reset} ${windWave()}`);
|
|
183
|
+
console.error("");
|
|
184
|
+
await shutdown();
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
// Fail
|
|
188
|
+
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
189
|
+
setTitle(`tailwint \u2718 ${totalIssues} issues`);
|
|
190
|
+
console.log("");
|
|
191
|
+
console.log(` ${windWave()} ${c.bgRed}${c.bold} \u2718 FAIL ${c.reset} ${c.red}${c.bold}${totalIssues}${c.reset} issues in ${c.bold}${issuesByFile.size}${c.reset} files ${c.dim}${elapsed}s${c.reset} ${windWave()}`);
|
|
192
|
+
console.log(` ${c.dim}run with ${c.white}--fix${c.dim} to auto-fix${c.reset}`);
|
|
193
|
+
console.log("");
|
|
194
|
+
await shutdown();
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
// CLI entry point — only runs when executed directly, not when imported
|
|
198
|
+
const isCLI = process.argv[1] != null &&
|
|
199
|
+
(import.meta.url === new URL(process.argv[1], "file:").href ||
|
|
200
|
+
process.argv[1].endsWith("/tailwint.js"));
|
|
201
|
+
if (isCLI) {
|
|
202
|
+
const args = process.argv.slice(2);
|
|
203
|
+
const fix = args.includes("--fix");
|
|
204
|
+
const patterns = args.filter((a) => a !== "--fix");
|
|
205
|
+
run({ fix, patterns: patterns.length > 0 ? patterns : undefined }).then((code) => process.exit(code), (err) => {
|
|
206
|
+
console.error(`\n ${c.red}${c.bold}tailwint crashed:${c.reset} ${err}`);
|
|
207
|
+
process.stderr.write(isTTY ? "\x1b[?25h" : "");
|
|
208
|
+
process.exit(2);
|
|
209
|
+
});
|
|
210
|
+
}
|