tailwint 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -1,8 +1,37 @@
1
- # tailwint
1
+ <p align="center">
2
+ <img src="assets/header.svg" alt="tailwint">
3
+ </p>
2
4
 
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.
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/tailwint"><img src="https://img.shields.io/npm/v/tailwint?color=0ea5e9&label=npm" alt="npm version"></a>
7
+ <a href="https://github.com/peterwangsc/tailwint/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/tailwint?color=a78bfa" alt="license"></a>
8
+ <a href="https://www.npmjs.com/package/tailwint"><img src="https://img.shields.io/npm/dm/tailwint?color=f472b6" alt="downloads"></a>
9
+ </p>
4
10
 
5
- Works with Tailwind CSS v4.
11
+ ---
12
+
13
+ The same diagnostics VS Code shows — but from the command line. Catches class conflicts, suggests canonical rewrites, and auto-fixes everything. Built on the official `@tailwindcss/language-server`.
14
+
15
+ **Works with Tailwind CSS v4.**
16
+
17
+ ## What it catches
18
+
19
+ tailwint detects two categories of issues:
20
+
21
+ **⚡ Conflicts** — classes that apply the same CSS properties, where the last one wins and the rest are dead code:
22
+
23
+ ```
24
+ ⚡ 3:21 conflict 'w-full' applies the same CSS properties as 'w-auto'
25
+ ⚡ 3:28 conflict 'w-auto' applies the same CSS properties as 'w-full'
26
+ ```
27
+
28
+ **○ Canonical** — classes that can be written in a shorter or more idiomatic form:
29
+
30
+ ```
31
+ ○ 3:21 canonical The class `flex-shrink-0` can be written as `shrink-0`
32
+ ○ 3:35 canonical The class `z-[1]` can be written as `z-1`
33
+ ○ 3:41 canonical The class `min-w-[200px]` can be written as `min-w-50`
34
+ ```
6
35
 
7
36
  ## Install
8
37
 
@@ -13,18 +42,82 @@ npm install -D tailwint @tailwindcss/language-server
13
42
  ## Usage
14
43
 
15
44
  ```bash
16
- # Check default file types (tsx, jsx, html, vue, svelte)
45
+ # Scan default file types (tsx, jsx, html, vue, svelte, css)
17
46
  npx tailwint
18
47
 
19
- # Check specific files
48
+ # Scan specific files
20
49
  npx tailwint "src/**/*.tsx"
21
50
 
22
- # Auto-fix issues
51
+ # Auto-fix all issues
23
52
  npx tailwint --fix
24
53
 
25
54
  # Fix specific files
26
55
  npx tailwint --fix "app/**/*.tsx"
56
+
57
+ # Verbose LSP logging
58
+ DEBUG=1 npx tailwint
59
+ ```
60
+
61
+ ## Example output
62
+
27
63
  ```
64
+ ~≈∼〜~≈∼〜~≈ tailwint ∼〜~≈∼〜~≈∼~
65
+ tailwind css linter // powered by the official lsp
66
+
67
+ ✔ language server ready ~≈∼〜~≈∼〜~≈∼〜
68
+ ✔ 42 files analyzed ~≈∼〜~≈∼〜~≈∼〜~≈
69
+
70
+ 42 files scanned // 8 conflicts │ 12 canonical
71
+
72
+ ┌ components/Card.tsx (3)
73
+ ⚡ 5:21 conflict 'w-full' applies the same CSS properties as 'w-auto'
74
+ ○ 5:35 canonical The class `flex-shrink-0` can be written as `shrink-0`
75
+ ○ 5:49 canonical The class `z-[1]` can be written as `z-1`
76
+ └~≈∼
77
+
78
+ ≈∼〜~≈ ✘ FAIL 20 issues in 3 files 2.1s ≈∼〜~≈
79
+ run with --fix to auto-fix
80
+ ```
81
+
82
+ With `--fix`:
83
+
84
+ ```
85
+ ⚙ FIX conflicts first, then canonical
86
+
87
+ ✔ ┃━━━━━━━━━━━━━━━━━━┃ Card.tsx 3 fixed
88
+ ✔ ┃━━━━━━━━━━━━━━━━━━┃ Header.tsx 12 fixed
89
+ ✔ ┃━━━━━━━━━━━━━━━━━━┃ Sidebar.tsx 5 fixed
90
+
91
+ ≈∼〜~≈ ✔ FIXED 20 of 20 issues across 3 files 3.4s ≈∼〜~≈
92
+ ```
93
+
94
+ ## Supported file types
95
+
96
+ | Extension | Language ID | Notes |
97
+ |-----------|-----------|-------|
98
+ | `.tsx` | typescriptreact | React / Next.js components |
99
+ | `.jsx` | javascriptreact | React components |
100
+ | `.html` | html | Static HTML files |
101
+ | `.vue` | html | Vue single-file components |
102
+ | `.svelte` | html | Svelte components |
103
+ | `.css` | css | `@apply` directives and Tailwind at-rules |
104
+
105
+ ## Tailwind v4 support
106
+
107
+ tailwint fully supports Tailwind CSS v4 features:
108
+
109
+ - **Opacity shorthand** — `bg-red-500/50`, `text-blue-500/75`
110
+ - **`size-*` utility** — `size-10`, `size-full`
111
+ - **Container queries** — `@container`, `@lg:flex`, `@md:grid`
112
+ - **`has-*` / `not-*` variants** — `has-checked:bg-blue-500`, `not-disabled:opacity-100`
113
+ - **`aria-*` variants** — `aria-expanded:bg-blue-500`, `aria-disabled:opacity-50`
114
+ - **`data-*` variants** — `data-[state=open]:bg-blue-500`
115
+ - **`supports-*` variants** — `supports-[display:grid]:grid`
116
+ - **`forced-colors` variant** — `forced-colors:bg-[ButtonFace]`
117
+ - **Logical properties** — `ms-4`, `me-4`, `ps-4`, `pe-4`
118
+ - **Text wrap utilities** — `text-balance`, `text-pretty`, `text-nowrap`
119
+ - **Named groups/peers** — `group/sidebar`, `group-hover/sidebar:bg-blue-500`
120
+ - **CSS-first config** — `@import "tailwindcss"` with `@theme` directive
28
121
 
29
122
  ## Programmatic API
30
123
 
@@ -38,9 +131,54 @@ const exitCode = await run({
38
131
  });
39
132
  ```
40
133
 
134
+ ### Options
135
+
136
+ | Option | Type | Default | Description |
137
+ |--------|------|---------|-------------|
138
+ | `patterns` | `string[]` | `["**/*.{tsx,jsx,html,vue,svelte,css}"]` | Glob patterns for files to scan |
139
+ | `fix` | `boolean` | `false` | Auto-fix issues using LSP code actions |
140
+ | `cwd` | `string` | `process.cwd()` | Working directory for glob resolution and LSP root |
141
+
142
+ ### Exports
143
+
144
+ | Export | Description |
145
+ |--------|-------------|
146
+ | `run(options?)` | Run the linter, returns exit code |
147
+ | `applyEdits(content, edits)` | Apply LSP text edits to a string |
148
+ | `TextEdit` | TypeScript type for LSP text edits |
149
+
150
+ ## CI integration
151
+
152
+ tailwint exits with meaningful codes for CI pipelines:
153
+
154
+ | Exit code | Meaning |
155
+ |-----------|---------|
156
+ | `0` | No issues found, or all issues fixed with `--fix` |
157
+ | `1` | Issues found (without `--fix`) |
158
+ | `2` | Fatal error (language server not found, crash) |
159
+
160
+ ### GitHub Actions
161
+
162
+ ```yaml
163
+ - name: Lint Tailwind classes
164
+ run: npx tailwint
165
+ ```
166
+
167
+ ### Pre-commit hook
168
+
169
+ ```bash
170
+ npx tailwint --fix && git add -u
171
+ ```
172
+
41
173
  ## How it works
42
174
 
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.
175
+ 1. **Boot** spawns `@tailwindcss/language-server` over stdio
176
+ 2. **Open** — sends all matched files to the server via `textDocument/didOpen`
177
+ 3. **Analyze** — waits for `textDocument/publishDiagnostics` notifications (event-driven, no polling)
178
+ 4. **Report** — collects diagnostics, categorizes as conflicts or canonical
179
+ 5. **Fix** *(if `--fix`)* — requests `textDocument/codeAction` quickfixes and applies edits in a loop until no diagnostics remain
180
+
181
+ The fix loop is unbounded — it keeps applying edits until the file stabilizes. A single pass may not resolve everything (e.g., fixing a conflict can reveal a canonical issue underneath), so the loop continues as long as edits produce changes.
44
182
 
45
183
  ## Requirements
46
184
 
package/bin/tailwint.js CHANGED
@@ -1,2 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import "../dist/index.js";
2
+ import { run } from "../dist/index.js";
3
+ import { c, isTTY } from "../dist/ui.js";
4
+
5
+ const args = process.argv.slice(2);
6
+ const fix = args.includes("--fix");
7
+ const patterns = args.filter((a) => a !== "--fix");
8
+
9
+ run({ fix, patterns: patterns.length > 0 ? patterns : undefined }).then(
10
+ (code) => process.exit(code),
11
+ (err) => {
12
+ console.error(`\n ${c.red}${c.bold}tailwint crashed:${c.reset} ${err}`);
13
+ process.stderr.write(isTTY ? "\x1b[?25h" : "");
14
+ process.exit(2);
15
+ },
16
+ );
package/dist/index.js CHANGED
@@ -13,10 +13,10 @@ import { readFileSync } from "fs";
13
13
  import { glob } from "glob";
14
14
  import { startServer, send, notify, shutdown, fileUri, langId, diagnosticsReceived, waitForProjectReady, waitForDiagnosticCount, resetState, } from "./lsp.js";
15
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";
16
+ import { c, setTitle, windTrail, braille, windWave, dots, tick, advanceTick, startSpinner, progressBar, banner, fileBadge, diagLine, rainbowText, celebrationAnimation, } from "./ui.js";
17
17
  // Re-export for tests
18
18
  export { applyEdits } from "./edits.js";
19
- const DEFAULT_PATTERNS = ["**/*.{tsx,jsx,html,vue,svelte}"];
19
+ const DEFAULT_PATTERNS = ["**/*.{tsx,jsx,html,vue,svelte,css}"];
20
20
  export async function run(options = {}) {
21
21
  resetState();
22
22
  const t0 = Date.now();
@@ -194,17 +194,3 @@ export async function run(options = {}) {
194
194
  await shutdown();
195
195
  return 1;
196
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
- }
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "tailwint",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Tailwind CSS linter for CI — drives the official language server to catch class issues and auto-fix them",
5
5
  "license": "MIT",
6
6
  "author": "Peter Wang",
7
+ "homepage": "https://github.com/peterwangsc/tailwint",
8
+ "bugs": "https://github.com/peterwangsc/tailwint/issues",
7
9
  "repository": {
8
10
  "type": "git",
9
11
  "url": "git+https://github.com/peterwangsc/tailwint.git"
@@ -14,25 +16,35 @@
14
16
  "lint",
15
17
  "linter",
16
18
  "ci",
19
+ "autofix",
17
20
  "language-server",
18
- "lsp"
21
+ "lsp",
22
+ "tailwind-v4",
23
+ "css",
24
+ "diagnostics",
25
+ "code-quality"
19
26
  ],
20
27
  "type": "module",
21
28
  "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
22
30
  "exports": {
23
- ".": "./dist/index.js"
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ }
24
35
  },
25
36
  "bin": {
26
- "tailwint": "./bin/tailwint.js"
37
+ "tailwint": "bin/tailwint.js"
27
38
  },
28
39
  "files": [
29
40
  "bin",
30
41
  "dist"
31
42
  ],
32
43
  "scripts": {
44
+ "clean": "rm -rf dist",
33
45
  "build": "tsc",
34
46
  "test": "tsx --test src/*.test.ts",
35
- "prepublishOnly": "npm run build"
47
+ "prepublishOnly": "npm run clean && npm run build"
36
48
  },
37
49
  "dependencies": {
38
50
  "glob": "^13.0.6"
@@ -1 +0,0 @@
1
- export {};
@@ -1,133 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,132 +0,0 @@
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 DELETED
@@ -1,13 +0,0 @@
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 DELETED
@@ -1,21 +0,0 @@
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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,99 +0,0 @@
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
- });
@@ -1,4 +0,0 @@
1
- /**
2
- * Tests for applyEdits from tailwint/edits.ts
3
- */
4
- export {};
@@ -1,410 +0,0 @@
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
- });