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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # tailwint
2
+
3
+ Tailwind CSS linter for CI. Drives the official `@tailwindcss/language-server` to catch class errors and auto-fix them — the same diagnostics VS Code shows, but from the command line.
4
+
5
+ Works with Tailwind CSS v4.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -D tailwint @tailwindcss/language-server
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```bash
16
+ # Check default file types (tsx, jsx, html, vue, svelte)
17
+ npx tailwint
18
+
19
+ # Check specific files
20
+ npx tailwint "src/**/*.tsx"
21
+
22
+ # Auto-fix issues
23
+ npx tailwint --fix
24
+
25
+ # Fix specific files
26
+ npx tailwint --fix "app/**/*.tsx"
27
+ ```
28
+
29
+ ## Programmatic API
30
+
31
+ ```ts
32
+ import { run } from "tailwint";
33
+
34
+ const exitCode = await run({
35
+ patterns: ["src/**/*.tsx"],
36
+ fix: true,
37
+ cwd: "/path/to/project",
38
+ });
39
+ ```
40
+
41
+ ## How it works
42
+
43
+ tailwint spawns the official Tailwind CSS language server over stdio, opens your files via LSP, and collects the published diagnostics. In `--fix` mode it requests quickfix code actions and applies the resulting edits.
44
+
45
+ ## Requirements
46
+
47
+ - Node.js 18+
48
+ - `@tailwindcss/language-server` >= 0.14.0 (peer dependency)
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/index.js";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,133 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyEdits } from "./index.js";
4
+ // Helper to build a TextEdit concisely
5
+ function edit(startLine, startChar, endLine, endChar, newText) {
6
+ return {
7
+ range: {
8
+ start: { line: startLine, character: startChar },
9
+ end: { line: endLine, character: endChar },
10
+ },
11
+ newText,
12
+ };
13
+ }
14
+ describe("applyEdits", () => {
15
+ it("returns content unchanged when edits array is empty", () => {
16
+ assert.equal(applyEdits("hello\nworld", []), "hello\nworld");
17
+ });
18
+ it("replaces a single word on a single line", () => {
19
+ const result = applyEdits("bg-red-999", [edit(0, 0, 0, 10, "bg-red-500")]);
20
+ assert.equal(result, "bg-red-500");
21
+ });
22
+ it("replaces a substring in the middle of a line", () => {
23
+ const result = applyEdits("class=\"bg-red-999 p-4\"", [
24
+ edit(0, 7, 0, 17, "bg-red-500"),
25
+ ]);
26
+ assert.equal(result, "class=\"bg-red-500 p-4\"");
27
+ });
28
+ it("handles insertion (zero-width range)", () => {
29
+ const result = applyEdits("hello world", [edit(0, 5, 0, 5, " beautiful")]);
30
+ assert.equal(result, "hello beautiful world");
31
+ });
32
+ it("handles deletion (empty newText)", () => {
33
+ const result = applyEdits("hello beautiful world", [edit(0, 5, 0, 15, "")]);
34
+ assert.equal(result, "hello world");
35
+ });
36
+ it("handles multi-line replacement", () => {
37
+ const content = "line one\nline two\nline three";
38
+ const result = applyEdits(content, [edit(0, 5, 1, 5, "ONE\nline TWO\nline")]);
39
+ // Replaced range (0,5)-(1,5) is " one\nline ", so suffix is "two"
40
+ assert.equal(result, "line ONE\nline TWO\nlinetwo\nline three");
41
+ });
42
+ it("handles edit that spans multiple lines and collapses them", () => {
43
+ const content = "aaa\nbbb\nccc\nddd";
44
+ // Delete from middle of line 1 to middle of line 2
45
+ const result = applyEdits(content, [edit(1, 1, 2, 2, "")]);
46
+ assert.equal(result, "aaa\nbc\nddd");
47
+ });
48
+ it("applies multiple non-overlapping edits on the same line", () => {
49
+ const content = "class=\"bg-red-999 text-blue-999\"";
50
+ const result = applyEdits(content, [
51
+ edit(0, 7, 0, 17, "bg-red-500"),
52
+ edit(0, 18, 0, 31, "text-blue-500"),
53
+ ]);
54
+ assert.equal(result, "class=\"bg-red-500 text-blue-500\"");
55
+ });
56
+ it("applies multiple edits on different lines", () => {
57
+ const content = "line0\nline1\nline2";
58
+ const result = applyEdits(content, [
59
+ edit(0, 0, 0, 5, "LINE0"),
60
+ edit(2, 0, 2, 5, "LINE2"),
61
+ ]);
62
+ assert.equal(result, "LINE0\nline1\nLINE2");
63
+ });
64
+ it("handles edit at the very end of file (no trailing newline)", () => {
65
+ const content = "hello";
66
+ const result = applyEdits(content, [edit(0, 5, 0, 5, " world")]);
67
+ assert.equal(result, "hello world");
68
+ });
69
+ it("handles edit on empty string", () => {
70
+ const result = applyEdits("", [edit(0, 0, 0, 0, "inserted")]);
71
+ assert.equal(result, "inserted");
72
+ });
73
+ it("handles replacement that introduces new lines", () => {
74
+ const content = "one line";
75
+ const result = applyEdits(content, [edit(0, 3, 0, 4, "\n")]);
76
+ assert.equal(result, "one\nline");
77
+ });
78
+ it("handles replacement that removes newlines (joining lines)", () => {
79
+ const content = "one\nline";
80
+ const result = applyEdits(content, [edit(0, 3, 1, 0, " ")]);
81
+ assert.equal(result, "one line");
82
+ });
83
+ it("handles edit past the actual content length of a line", () => {
84
+ // What if the range references characters beyond the line length?
85
+ const content = "hi";
86
+ const result = applyEdits(content, [edit(0, 2, 0, 100, " there")]);
87
+ assert.equal(result, "hi there");
88
+ });
89
+ it("handles edit referencing a line beyond file length", () => {
90
+ // File has 1 line, edit references line 5.
91
+ // Line 5 maps to content.length, so this is an append at the end — no newline inserted.
92
+ // An LSP server wouldn't actually send this, but we shouldn't crash.
93
+ const content = "only line";
94
+ const result = applyEdits(content, [edit(5, 0, 5, 0, "new line")]);
95
+ assert.equal(result, "only linenew line");
96
+ });
97
+ it("handles multiple edits where earlier edit changes line count", () => {
98
+ // This tests whether applying in reverse order is correct.
99
+ // Edit on line 2 first (applied first in reverse), then edit on line 0.
100
+ const content = "aaa\nbbb\nccc";
101
+ const result = applyEdits(content, [
102
+ edit(0, 0, 0, 3, "AAA"),
103
+ edit(2, 0, 2, 3, "CCC"),
104
+ ]);
105
+ assert.equal(result, "AAA\nbbb\nCCC");
106
+ });
107
+ it("handles adjacent edits (end of one = start of next)", () => {
108
+ const content = "abcdef";
109
+ const result = applyEdits(content, [
110
+ edit(0, 0, 0, 3, "ABC"),
111
+ edit(0, 3, 0, 6, "DEF"),
112
+ ]);
113
+ assert.equal(result, "ABCDEF");
114
+ });
115
+ it("does not mutate the original edits array", () => {
116
+ const edits = [
117
+ edit(1, 0, 1, 3, "BBB"),
118
+ edit(0, 0, 0, 3, "AAA"),
119
+ ];
120
+ const original = JSON.stringify(edits);
121
+ applyEdits("aaa\nbbb", edits);
122
+ assert.equal(JSON.stringify(edits), original);
123
+ });
124
+ // Stress test: many edits
125
+ it("handles a large number of single-line edits", () => {
126
+ const lines = Array.from({ length: 100 }, (_, i) => `line${i}`);
127
+ const content = lines.join("\n");
128
+ const edits = lines.map((_, i) => edit(i, 0, i, `line${i}`.length, `LINE${i}`));
129
+ const result = applyEdits(content, edits);
130
+ const expected = Array.from({ length: 100 }, (_, i) => `LINE${i}`).join("\n");
131
+ assert.equal(result, expected);
132
+ });
133
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,132 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyEdits } from "./index.js";
4
+ function edit(startLine, startChar, endLine, endChar, newText) {
5
+ return {
6
+ range: {
7
+ start: { line: startLine, character: startChar },
8
+ end: { line: endLine, character: endChar },
9
+ },
10
+ newText,
11
+ };
12
+ }
13
+ describe("batched edits — multiple diagnostics fixed at once", () => {
14
+ // Scenario: two cssConflict warnings on separate lines, both fixed in one pass
15
+ it("removes conflicting classes on separate lines", () => {
16
+ const content = [
17
+ 'export default function App() {',
18
+ ' return (',
19
+ ' <div className="text-red-500 text-blue-500 p-4">',
20
+ ' <span className="font-bold font-semibold">hello</span>',
21
+ ' </div>',
22
+ ' )',
23
+ '}',
24
+ ].join("\n");
25
+ // Simulate: LSP says remove "text-blue-500 " on line 2, and " font-semibold" on line 3
26
+ const result = applyEdits(content, [
27
+ edit(2, 33, 2, 47, ""),
28
+ edit(3, 32, 3, 46, ""),
29
+ ]);
30
+ assert.ok(result.includes('"text-red-500 p-4"'), "should keep text-red-500");
31
+ assert.ok(!result.includes("text-blue-500"), "should remove text-blue-500");
32
+ assert.ok(result.includes('"font-bold"'), "should keep font-bold");
33
+ assert.ok(!result.includes("font-semibold"), "should remove font-semibold");
34
+ });
35
+ // Scenario: two conflicting pairs on the SAME line
36
+ it("removes two conflicting classes on the same line", () => {
37
+ // 0123456789...
38
+ const content = '<div className="text-red-500 text-blue-500 bg-red-500 bg-blue-500 p-4">';
39
+ const result = applyEdits(content, [
40
+ edit(0, 29, 0, 43, ""),
41
+ edit(0, 54, 0, 66, ""),
42
+ ]);
43
+ assert.ok(result.includes("text-red-500"), "should keep text-red-500");
44
+ assert.ok(!result.includes("text-blue-500"), "should remove text-blue-500");
45
+ assert.ok(result.includes("bg-red-500"), "should keep bg-red-500");
46
+ assert.ok(!result.includes("bg-blue-500"), "should remove bg-blue-500");
47
+ assert.ok(result.includes("p-4"), "should keep p-4");
48
+ });
49
+ // Scenario: edits that are adjacent (one right after the other, no gap)
50
+ it("handles adjacent removals with no gap between them", () => {
51
+ const content = "aabbcc";
52
+ // Remove "bb" (chars 2-4) and "cc" (chars 4-6) — adjacent edits
53
+ const result = applyEdits(content, [
54
+ edit(0, 2, 0, 4, ""),
55
+ edit(0, 4, 0, 6, ""),
56
+ ]);
57
+ assert.equal(result, "aa");
58
+ });
59
+ // Scenario: what happens if two code actions try to edit overlapping ranges?
60
+ // This is the dangerous case for batching.
61
+ it("overlapping edits produce unpredictable results", () => {
62
+ const content = "abcdefgh";
63
+ // Edit 1: replace chars 2-5 ("cdef") with "XX"
64
+ // Edit 2: replace chars 4-7 ("efgh") with "YY"
65
+ // These overlap at chars 4-5.
66
+ // Forward pass: cursor=0, edit1(2,5): "ab" + "XX", cursor=5
67
+ // edit2(4,7): 4 < cursor(5), so we skip the slice — start is behind cursor!
68
+ // Actually, the code does: if (e.start > cursor) parts.push(...)
69
+ // e.start=4, cursor=5, 4 > 5 is false, so no gap pushed.
70
+ // parts.push("YY"), cursor=7
71
+ // remaining: parts.push("h")
72
+ // Result: "abXXYYh"
73
+ //
74
+ // But what SHOULD happen? The LSP spec says overlapping edits are invalid.
75
+ // We just need to not crash.
76
+ const result = applyEdits(content, [
77
+ edit(0, 2, 0, 5, "XX"),
78
+ edit(0, 4, 0, 7, "YY"),
79
+ ]);
80
+ // We don't care about the exact output, just that we don't crash
81
+ // and produce a string (not undefined/null/error)
82
+ assert.equal(typeof result, "string");
83
+ console.log("Overlapping edit result:", JSON.stringify(result));
84
+ });
85
+ // Scenario: realistic Tailwind — class reordering across a whole file
86
+ it("handles edits across many lines (simulating a file-wide fix)", () => {
87
+ const lines = [];
88
+ for (let i = 0; i < 50; i++) {
89
+ lines.push(`<div className="flex items-center p-${i}">line ${i}</div>`);
90
+ }
91
+ const content = lines.join("\n");
92
+ // Simulate: replace "p-N" with "p-0" on every line
93
+ const edits = [];
94
+ for (let i = 0; i < 50; i++) {
95
+ const pClass = `p-${i}`;
96
+ const lineContent = lines[i];
97
+ const start = lineContent.indexOf(pClass);
98
+ edits.push(edit(i, start, i, start + pClass.length, "p-0"));
99
+ }
100
+ const result = applyEdits(content, edits);
101
+ const resultLines = result.split("\n");
102
+ assert.equal(resultLines.length, 50);
103
+ for (let i = 0; i < 50; i++) {
104
+ assert.ok(resultLines[i].includes("p-0"), `line ${i} should have p-0`);
105
+ if (i > 0) {
106
+ assert.ok(!resultLines[i].includes(`p-${i}`), `line ${i} should not have p-${i}`);
107
+ }
108
+ }
109
+ });
110
+ // Scenario: what if we batch a deletion that shifts everything,
111
+ // with another edit on a later line? This is the safe case.
112
+ it("handles deletion on one line + edit on later line", () => {
113
+ const content = "line0 extra\nline1\nline2 extra";
114
+ // Delete " extra" on line 0, replace "line2" with "LINE2" on line 2
115
+ const result = applyEdits(content, [
116
+ edit(0, 5, 0, 11, ""),
117
+ edit(2, 0, 2, 5, "LINE2"),
118
+ ]);
119
+ assert.equal(result, "line0\nline1\nLINE2 extra");
120
+ });
121
+ // Scenario: mixed insertions and deletions across lines
122
+ it("handles mixed insertions and deletions", () => {
123
+ const content = "aaa\nbbb\nccc\nddd";
124
+ const result = applyEdits(content, [
125
+ edit(0, 3, 0, 3, " inserted"), // insert at end of line 0
126
+ edit(1, 0, 1, 3, ""), // delete all of line 1 content
127
+ edit(2, 0, 2, 3, "CCC"), // replace line 2
128
+ edit(3, 0, 3, 0, "pre-"), // insert at start of line 3
129
+ ]);
130
+ assert.equal(result, "aaa inserted\n\nCCC\npre-ddd");
131
+ });
132
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tailwint — Tailwind CSS linter that drives the official language server.
4
+ *
5
+ * Usage: tailwint [--fix] [glob...]
6
+ * tailwint # default: **\/*.{tsx,jsx,html,vue,svelte}
7
+ * tailwint --fix # auto-fix all issues
8
+ * tailwint "src/**\/*.tsx" # custom glob
9
+ * tailwint --fix "app/**\/*.tsx" # fix specific files
10
+ *
11
+ * Set DEBUG=1 for verbose LSP message logging.
12
+ */
13
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tailwint — Tailwind CSS linter that drives the official language server.
4
+ *
5
+ * Usage: tailwint [--fix] [glob...]
6
+ * tailwint # default: **\/*.{tsx,jsx,html,vue,svelte}
7
+ * tailwint --fix # auto-fix all issues
8
+ * tailwint "src/**\/*.tsx" # custom glob
9
+ * tailwint --fix "app/**\/*.tsx" # fix specific files
10
+ *
11
+ * Set DEBUG=1 for verbose LSP message logging.
12
+ */
13
+ import { run } from "./index.js";
14
+ const args = process.argv.slice(2);
15
+ const fix = args.includes("--fix");
16
+ const patterns = args.filter((a) => a !== "--fix");
17
+ run({ fix, patterns: patterns.length > 0 ? patterns : undefined }).then((code) => process.exit(code), (err) => {
18
+ process.stderr.write("\x1b[?25h"); // restore cursor if hidden
19
+ console.error(`\n tailwint crashed: ${err}`);
20
+ process.exit(2);
21
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { applyEdits } from "./index.js";
4
+ function edit(startLine, startChar, endLine, endChar, newText) {
5
+ return {
6
+ range: {
7
+ start: { line: startLine, character: startChar },
8
+ end: { line: endLine, character: endChar },
9
+ },
10
+ newText,
11
+ };
12
+ }
13
+ describe("applyEdits edge cases", () => {
14
+ it("handles Windows-style CRLF content", () => {
15
+ const content = "hello\r\nworld";
16
+ const result = applyEdits(content, [edit(0, 0, 0, 5, "HELLO")]);
17
+ assert.equal(result, "HELLO\r\nworld");
18
+ });
19
+ it("handles file with trailing newline", () => {
20
+ const content = "hello\n";
21
+ const result = applyEdits(content, [edit(0, 0, 0, 5, "HELLO")]);
22
+ assert.equal(result, "HELLO\n");
23
+ });
24
+ it("handles file with multiple trailing newlines", () => {
25
+ const content = "hello\n\n\n";
26
+ const result = applyEdits(content, [edit(0, 0, 0, 5, "HELLO")]);
27
+ assert.equal(result, "HELLO\n\n\n");
28
+ });
29
+ it("handles edit that replaces entire file content", () => {
30
+ const content = "aaa\nbbb\nccc";
31
+ const result = applyEdits(content, [edit(0, 0, 2, 3, "replaced")]);
32
+ assert.equal(result, "replaced");
33
+ });
34
+ it("handles two edits on same line — order independence", () => {
35
+ const content = "abcdefghij";
36
+ const result = applyEdits(content, [
37
+ edit(0, 0, 0, 2, "AB"),
38
+ edit(0, 5, 0, 7, "FG"),
39
+ ]);
40
+ assert.equal(result, "ABcdeFGhij");
41
+ });
42
+ it("handles two edits on same line — provided in reverse order", () => {
43
+ const content = "abcdefghij";
44
+ const result = applyEdits(content, [
45
+ edit(0, 5, 0, 7, "FG"),
46
+ edit(0, 0, 0, 2, "AB"),
47
+ ]);
48
+ assert.equal(result, "ABcdeFGhij");
49
+ });
50
+ it("handles edit with unicode characters", () => {
51
+ const content = "café latte";
52
+ const result = applyEdits(content, [edit(0, 0, 0, 4, "CAFÉ")]);
53
+ assert.equal(result, "CAFÉ latte");
54
+ });
55
+ it("handles edit with emoji (multi-codepoint)", () => {
56
+ // 👋 is 2 UTF-16 code units. LSP uses UTF-16 offsets, as does JS string indexing.
57
+ const content = "👋 hello";
58
+ const result = applyEdits(content, [edit(0, 2, 0, 3, "-")]);
59
+ assert.equal(result, "👋-hello");
60
+ });
61
+ it("handles three same-line edits that all grow in length", () => {
62
+ const content = "class=\"aa bb cc\"";
63
+ const result = applyEdits(content, [
64
+ edit(0, 7, 0, 9, "xxxx"),
65
+ edit(0, 10, 0, 12, "yyyy"),
66
+ edit(0, 13, 0, 15, "zzzz"),
67
+ ]);
68
+ assert.equal(result, "class=\"xxxx yyyy zzzz\"");
69
+ });
70
+ });
71
+ describe("applyEdits same-line multi-edit correctness", () => {
72
+ it("correctly handles two non-overlapping edits on the same line", () => {
73
+ const content = "___foo___bar___";
74
+ const result = applyEdits(content, [
75
+ edit(0, 3, 0, 6, "FOO"),
76
+ edit(0, 9, 0, 12, "BAR"),
77
+ ]);
78
+ assert.equal(result, "___FOO___BAR___");
79
+ });
80
+ it("handles same-line edits where replacement changes length", () => {
81
+ const content = "___foo___bar___";
82
+ const result = applyEdits(content, [
83
+ edit(0, 3, 0, 6, "FOOOO"),
84
+ edit(0, 9, 0, 12, "B"),
85
+ ]);
86
+ assert.equal(result, "___FOOOO___B___");
87
+ });
88
+ it("handles three non-overlapping same-line edits with varying lengths", () => {
89
+ // Edits replace chars at positions 0('a'), 2('c'), 4('e').
90
+ // Chars at positions 1('b'), 3('d'), 5('f') are preserved.
91
+ const content = "abcdef";
92
+ const result = applyEdits(content, [
93
+ edit(0, 0, 0, 1, "XX"),
94
+ edit(0, 2, 0, 3, "YYYYYY"),
95
+ edit(0, 4, 0, 5, "ZZ"),
96
+ ]);
97
+ assert.equal(result, "XXbYYYYYYdZZf");
98
+ });
99
+ });
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Text edit application and fix orchestration.
3
+ */
4
+ export interface TextEdit {
5
+ range: {
6
+ start: {
7
+ line: number;
8
+ character: number;
9
+ };
10
+ end: {
11
+ line: number;
12
+ character: number;
13
+ };
14
+ };
15
+ newText: string;
16
+ }
17
+ export declare function applyEdits(content: string, edits: TextEdit[]): string;
18
+ export declare function fixFile(filePath: string, initialDiags: any[], fileContents: Map<string, string>, version: Map<string, number>, onPass?: (pass: number) => void): Promise<number>;
package/dist/edits.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Text edit application and fix orchestration.
3
+ */
4
+ import { writeFileSync } from "fs";
5
+ import { send, notify, fileUri, waitForDiagnostic } from "./lsp.js";
6
+ export function applyEdits(content, edits) {
7
+ if (edits.length === 0)
8
+ return content;
9
+ const lineOffsets = [0];
10
+ for (let i = 0; i < content.length; i++) {
11
+ if (content[i] === "\n")
12
+ lineOffsets.push(i + 1);
13
+ }
14
+ function toOffset(line, char) {
15
+ if (line >= lineOffsets.length)
16
+ return content.length;
17
+ return Math.min(lineOffsets[line] + char, content.length);
18
+ }
19
+ const absolute = edits.map((e) => ({
20
+ start: toOffset(e.range.start.line, e.range.start.character),
21
+ end: toOffset(e.range.end.line, e.range.end.character),
22
+ newText: e.newText,
23
+ }));
24
+ absolute.sort((a, b) => a.start - b.start);
25
+ const parts = [];
26
+ let cursor = 0;
27
+ for (const e of absolute) {
28
+ if (e.start > cursor)
29
+ parts.push(content.slice(cursor, e.start));
30
+ parts.push(e.newText);
31
+ cursor = e.end;
32
+ }
33
+ if (cursor < content.length)
34
+ parts.push(content.slice(cursor));
35
+ return parts.join("");
36
+ }
37
+ function rangeKey(range) {
38
+ return `${range.start.line}:${range.start.character}-${range.end.line}:${range.end.character}`;
39
+ }
40
+ function posLte(a, b) {
41
+ return a.line < b.line || (a.line === b.line && a.character <= b.character);
42
+ }
43
+ function rangeContains(outer, inner) {
44
+ return posLte(outer.range.start, inner.range.start) && posLte(inner.range.end, outer.range.end);
45
+ }
46
+ function filterContainedEdits(edits) {
47
+ const result = [];
48
+ for (const edit of edits) {
49
+ const containedByAnother = edits.some((other) => other !== edit && rangeContains(other, edit));
50
+ if (!containedByAnother)
51
+ result.push(edit);
52
+ }
53
+ return result;
54
+ }
55
+ async function waitForFreshDiagnostics(uri) {
56
+ return waitForDiagnostic(uri);
57
+ }
58
+ export async function fixFile(filePath, initialDiags, fileContents, version, onPass) {
59
+ const DEBUG = process.env.DEBUG === "1";
60
+ const uri = fileUri(filePath);
61
+ let content = fileContents.get(filePath);
62
+ let ver = version.get(filePath);
63
+ const issueCount = initialDiags.length;
64
+ let diags = initialDiags;
65
+ for (let pass = 0; diags.length > 0; pass++) {
66
+ onPass?.(pass + 1);
67
+ if (DEBUG)
68
+ console.error(` pass ${pass + 1}: ${diags.length} remaining`);
69
+ const actionResults = await Promise.all(diags.map((diag) => send("textDocument/codeAction", {
70
+ textDocument: { uri },
71
+ range: diag.range,
72
+ context: { diagnostics: [diag], only: ["quickfix"] },
73
+ }).catch(() => null)));
74
+ const editsByRange = new Map();
75
+ for (let i = 0; i < diags.length; i++) {
76
+ const actions = actionResults[i];
77
+ if (!actions || actions.length === 0)
78
+ continue;
79
+ const action = actions[0];
80
+ const edits = action.edit?.changes?.[uri] || action.edit?.documentChanges?.[0]?.edits || [];
81
+ if (edits.length === 0)
82
+ continue;
83
+ for (const e of edits) {
84
+ const key = rangeKey(e.range);
85
+ editsByRange.set(key, e);
86
+ }
87
+ }
88
+ let candidates = [...editsByRange.values()];
89
+ if (candidates.length === 0)
90
+ break;
91
+ candidates = filterContainedEdits(candidates);
92
+ const prev = content;
93
+ content = applyEdits(content, candidates);
94
+ if (content === prev)
95
+ break;
96
+ ver++;
97
+ notify("textDocument/didChange", {
98
+ textDocument: { uri, version: ver },
99
+ contentChanges: [{ text: content }],
100
+ });
101
+ diags = (await waitForFreshDiagnostics(uri)).filter((d) => d.severity === 1 || d.severity === 2);
102
+ }
103
+ if (content !== fileContents.get(filePath)) {
104
+ writeFileSync(filePath, content);
105
+ fileContents.set(filePath, content);
106
+ version.set(filePath, ver);
107
+ }
108
+ return issueCount;
109
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for applyEdits from tailwint/edits.ts
3
+ */
4
+ export {};