@runtimestudio/tailwind-sort-php 0.3.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 The Trustee for G&J Sevastos Family Trust t/a Runtime Studio
3
+ Copyright (c) 2026 G&J Sevastos Family Trust t/a Runtime Studio
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -91,6 +91,8 @@ npx tailwind-sort-php init
91
91
  | `--php-source <glob>` | Also sort class strings in PHP declarations in matching files (repeatable). Merged with `tailwindPhpSources`. See [Sorting classes in PHP declarations](#sorting-classes-in-php-declarations). |
92
92
  | `--check` | Don't write; exit 1 if any file needs sorting. |
93
93
  | `--no-short-tags` | Don't treat bare `<?` as a PHP open tag. |
94
+ | `-h, --help` | Show usage. |
95
+ | `--version` | Print the version. |
94
96
 
95
97
  Default globs are all `.php` files under the cwd; `node_modules`, `vendor`, `dist`, and `.git` are always skipped.
96
98
 
@@ -278,6 +280,8 @@ Also handled correctly:
278
280
  - `?>` inside `//` and `#` line comments (island ends — genuine PHP behavior)
279
281
  - `#[Attributes]`, `<?PHP` case-insensitivity, `<?xml` exclusion, files ending in PHP mode
280
282
  - PHP islands as standalone attributes: `<div <?php post_class(); ?> class="...">`
283
+ - With `tailwindPhpSources`, string values inside an island in a class attribute
284
+ (`class="p-4 <?= $on ? 'flex z-10' : '' ?>"`) sort in the same single pass
281
285
  - `<script>`/`<style>` content, HTML comments, and `echo '<div class="...">'` strings are left alone (to sort class
282
286
  strings declared in PHP, see [Sorting classes in PHP declarations](#sorting-classes-in-php-declarations))
283
287
 
@@ -316,10 +320,11 @@ bun test # or: node --test "test/*.test.ts"
316
320
  bun run build # compile src → dist (tsc); the published artifact
317
321
  ```
318
322
 
319
- 74 tests: 59 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`),
323
+ 84 tests: 63 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`),
320
324
  7 integration tests that exercise the real `prettier-plugin-tailwindcss` sorter and skip automatically when the
321
- Tailwind toolchain isn't installed, and 8 `init` tests that run against throwaway git repositories and skip when
322
- `git` is unavailable.
325
+ Tailwind toolchain isn't installed, 8 `init` tests that run against throwaway git repositories and skip when
326
+ `git` is unavailable, and 6 CLI tests covering argument validation, help/version output, and file-scanning rules
327
+ (the scan test also skips without the Tailwind toolchain).
323
328
 
324
329
  ## License
325
330
 
package/dist/cli.d.ts CHANGED
@@ -1,19 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * tailwind-sort-php CLI
3
+ * tailwind-sort-php CLI — see `USAGE` for the flag reference.
4
4
  *
5
- * Usage:
6
- * tailwind-sort-php [options] [glob ...]
7
- * tailwind-sort-php init [--fix] [--force] [--dry-run]
8
- *
9
- * Options:
10
- * --stylesheet <path> Tailwind v4 CSS entry
11
- * --attr <name> Extra attribute to sort (repeatable)
12
- * --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
13
- * --check Don't write; exit 1 if any file needs sorting
14
- * --no-short-tags Don't treat bare `<?` as a PHP open tag
15
- *
16
- * Defaults to all `.php` files under `cwd` when no globs are given.
17
- * Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
5
+ * Option values fall back to the resolved Prettier config — the same source of truth `prettier-plugin-tailwindcss`
6
+ * uses; see `fromPrettierConfig()`.
7
+ * The `init` subcommand installs the pre-commit hook; see `init.ts`.
18
8
  */
19
9
  export {};
package/dist/cli.js CHANGED
@@ -1,25 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * tailwind-sort-php CLI
3
+ * tailwind-sort-php CLI — see `USAGE` for the flag reference.
4
4
  *
5
- * Usage:
6
- * tailwind-sort-php [options] [glob ...]
7
- * tailwind-sort-php init [--fix] [--force] [--dry-run]
8
- *
9
- * Options:
10
- * --stylesheet <path> Tailwind v4 CSS entry
11
- * --attr <name> Extra attribute to sort (repeatable)
12
- * --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
13
- * --check Don't write; exit 1 if any file needs sorting
14
- * --no-short-tags Don't treat bare `<?` as a PHP open tag
15
- *
16
- * Defaults to all `.php` files under `cwd` when no globs are given.
17
- * Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
5
+ * Option values fall back to the resolved Prettier config — the same source of truth `prettier-plugin-tailwindcss`
6
+ * uses; see `fromPrettierConfig()`.
7
+ * The `init` subcommand installs the pre-commit hook; see `init.ts`.
18
8
  */
19
9
  import { readFile, writeFile } from 'node:fs/promises';
20
10
  import { transform } from "./transform.js";
21
11
  import { createTailwindSortFn } from "./sorter.js";
22
12
  import { runInit } from "./init.js";
13
+ const USAGE = `Usage:
14
+ tailwind-sort-php [options] [glob ...]
15
+ tailwind-sort-php init [--fix] [--force] [--dry-run]
16
+
17
+ Options:
18
+ --stylesheet <path> Tailwind v4 CSS entry (default: tailwindStylesheet from your Prettier config)
19
+ --attr <name> Extra attribute to sort (repeatable; merged with tailwindAttributes)
20
+ --php-source <glob> Also sort class strings in PHP declarations in matching files
21
+ (repeatable; merged with tailwindPhpSources)
22
+ --check Don't write; exit 1 if any file needs sorting
23
+ --no-short-tags Don't treat bare <? as a PHP open tag
24
+ -h, --help Show this help
25
+ --version Print the version
26
+
27
+ Defaults to all .php files under the cwd when no globs are given;
28
+ node_modules, vendor, dist and .git are always skipped.`;
23
29
  const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
24
30
  /**
25
31
  * Parse command-line arguments.
@@ -37,18 +43,18 @@ function parseArgs(argv) {
37
43
  };
38
44
  for (let i = 0; i < argv.length; i++) {
39
45
  const a = argv[i];
40
- if (a === '--check')
46
+ if (a === '--stylesheet')
47
+ cli.stylesheet = requireValue(argv, ++i, a);
48
+ else if (a === '--attr')
49
+ cli.attrs.push(requireValue(argv, ++i, a));
50
+ else if (a === '--php-source')
51
+ cli.phpSources.push(requireValue(argv, ++i, a));
52
+ else if (a === '--check')
41
53
  cli.check = true;
42
54
  else if (a === '--no-short-tags')
43
55
  cli.shortTags = false;
44
- else if (a === '--stylesheet')
45
- cli.stylesheet = argv[++i];
46
- else if (a === '--attr')
47
- cli.attrs.push(argv[++i]);
48
- else if (a === '--php-source')
49
- cli.phpSources.push(argv[++i]);
50
56
  else if (a.startsWith('--')) {
51
- console.error(`Unknown option: ${a}`);
57
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
52
58
  process.exit(2);
53
59
  }
54
60
  else
@@ -58,6 +64,17 @@ function parseArgs(argv) {
58
64
  cli.globs.push('**/*.php');
59
65
  return cli;
60
66
  }
67
+ /**
68
+ * Return a flag's value argument or exit with a usage error when it is missing or is itself a flag.
69
+ */
70
+ function requireValue(argv, i, flag) {
71
+ const v = argv[i];
72
+ if (v === undefined || v.startsWith('--')) {
73
+ console.error(`Missing value for ${flag}`);
74
+ process.exit(2);
75
+ }
76
+ return v;
77
+ }
61
78
  /**
62
79
  * Yield file paths matching the given globs, using `Bun.Glob` under Bun and `node:fs` glob (Node >= 22) otherwise.
63
80
  *
@@ -73,8 +90,10 @@ async function* scanFiles(globs) {
73
90
  }
74
91
  else {
75
92
  const { glob } = await import('node:fs/promises');
93
+ // Prunes ignored directories during traversal; newer Node versions pass a Dirent instead of a path string.
94
+ const exclude = (entry) => ignored(typeof entry === 'string' ? entry : entry.name);
76
95
  for (const pattern of globs) {
77
- for await (const f of glob(pattern))
96
+ for await (const f of glob(pattern, { exclude }))
78
97
  yield f;
79
98
  }
80
99
  }
@@ -85,7 +104,7 @@ async function* scanFiles(globs) {
85
104
  * @param file Path to test, relative to `cwd`.
86
105
  * @returns True when the path is inside an ignored directory and should be skipped.
87
106
  */
88
- const ignored = (file) => IGNORE.some((d) => file.includes(`${d}/`) || file.startsWith(d));
107
+ const ignored = (file) => file.split(/[\\/]/).some((seg) => IGNORE.includes(seg));
89
108
  /**
90
109
  * Best-effort read of the resolved Prettier config — the shared source of truth with `prettier-plugin-tailwindcss`.
91
110
  * Picks up `tailwindStylesheet`, `tailwindAttributes`, and `tailwindPhpSources`.
@@ -120,6 +139,16 @@ async function fromPrettierConfig() {
120
139
  }
121
140
  async function main() {
122
141
  const argv = process.argv.slice(2);
142
+ // Help/version take precedence everywhere, including after `init`.
143
+ if (argv.includes('--help') || argv.includes('-h')) {
144
+ console.log(USAGE);
145
+ return;
146
+ }
147
+ if (argv.includes('--version')) {
148
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
149
+ console.log(pkg.version);
150
+ return;
151
+ }
123
152
  if (argv[0] === 'init')
124
153
  return runInit(argv.slice(1));
125
154
  const cli = parseArgs(argv);
package/dist/html.js CHANGED
@@ -47,6 +47,7 @@ export function findClassAttributes(masked, opts = {}) {
47
47
  const wanted = new Set((opts.attributes ?? ['class', 'classname']).map((a) => a.toLowerCase()));
48
48
  const out = [];
49
49
  const len = masked.length;
50
+ const lower = masked.toLowerCase();
50
51
  let i = 0;
51
52
  while (i < len) {
52
53
  const lt = masked.indexOf('<', i);
@@ -75,12 +76,12 @@ export function findClassAttributes(masked, opts = {}) {
75
76
  let j = lt + 1;
76
77
  while (j < len && /[A-Za-z0-9:-]/.test(masked[j]))
77
78
  j++;
78
- const tagName = masked.slice(lt + 1, j).toLowerCase();
79
+ const tagName = lower.slice(lt + 1, j);
79
80
  j = scanTagAttributes(masked, j, wanted, out);
80
81
  // Skip raw-text element content up to its closing tag.
81
82
  if (RAW_TEXT_TAGS.has(tagName)) {
82
83
  const closer = `</${tagName}`;
83
- const idx = masked.toLowerCase().indexOf(closer, j);
84
+ const idx = lower.indexOf(closer, j);
84
85
  j = idx === -1 ? len : idx;
85
86
  }
86
87
  i = j;
package/dist/islands.js CHANGED
@@ -9,8 +9,7 @@
9
9
  *
10
10
  * @see html.ts - second pass; consumes islands via `maskIslands()`.
11
11
  */
12
- const isIdentStart = (c) => /[A-Za-z_\u0080-\uffff]/.test(c);
13
- const isIdent = (c) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
12
+ import { isIdent, scanHeredoc, scanLineComment, scanQuoted } from "./php-lexer.js";
14
13
  /**
15
14
  * Find every PHP island in a mixed PHP/HTML template source.
16
15
  *
@@ -69,23 +68,23 @@ function scanPhpBody(src, i) {
69
68
  return i + 2;
70
69
  // Single-quoted string.
71
70
  if (c === "'") {
72
- i = scanQuoted(src, i + 1, "'");
71
+ i = Math.min(scanQuoted(src, i + 1, "'", len) + 1, len);
73
72
  continue;
74
73
  }
75
74
  // Double-quoted string.
76
75
  // Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer; see README.
77
76
  if (c === '"') {
78
- i = scanQuoted(src, i + 1, '"');
77
+ i = Math.min(scanQuoted(src, i + 1, '"', len) + 1, len);
79
78
  continue;
80
79
  }
81
80
  // Backtick (shell exec) string — same escaping rules.
82
81
  if (c === '`') {
83
- i = scanQuoted(src, i + 1, '`');
82
+ i = Math.min(scanQuoted(src, i + 1, '`', len) + 1, len);
84
83
  continue;
85
84
  }
86
85
  // Comments.
87
86
  if (c === '/' && src[i + 1] === '/') {
88
- i = scanLineComment(src, i + 2);
87
+ i = scanLineComment(src, i + 2, len);
89
88
  if (src.startsWith('?>', i))
90
89
  return i + 2;
91
90
  continue;
@@ -96,7 +95,7 @@ function scanPhpBody(src, i) {
96
95
  i += 2;
97
96
  continue;
98
97
  }
99
- i = scanLineComment(src, i + 1);
98
+ i = scanLineComment(src, i + 1, len);
100
99
  if (src.startsWith('?>', i))
101
100
  return i + 2;
102
101
  continue;
@@ -108,7 +107,7 @@ function scanPhpBody(src, i) {
108
107
  }
109
108
  // Heredoc / nowdoc.
110
109
  if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
111
- const here = scanHeredoc(src, i + 3);
110
+ const here = scanHeredoc(src, i + 3, len);
112
111
  if (here !== -1) {
113
112
  i = here;
114
113
  continue;
@@ -118,89 +117,3 @@ function scanPhpBody(src, i) {
118
117
  }
119
118
  return len; // file ends while still in PHP mode
120
119
  }
121
- /**
122
- * Scan a quoted string body; `i` is just past the open quote.
123
- * Returns offset just past the closing quote (or EOF).
124
- */
125
- function scanQuoted(src, i, quote) {
126
- const len = src.length;
127
- while (i < len) {
128
- const c = src[i];
129
- if (c === '\\') {
130
- i += 2;
131
- continue;
132
- }
133
- if (c === quote)
134
- return i + 1;
135
- i++;
136
- }
137
- return len;
138
- }
139
- /**
140
- * Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
141
- * Returns the offset past a consumed newline, or the offset of an unconsumed `?>`
142
- * (left for the caller, since `?>` closes both the comment and the island).
143
- */
144
- function scanLineComment(src, i) {
145
- const len = src.length;
146
- while (i < len) {
147
- if (src[i] === '\n')
148
- return i + 1;
149
- if (src[i] === '?' && src[i + 1] === '>')
150
- return i;
151
- i++;
152
- }
153
- return len;
154
- }
155
- /**
156
- * Scan a heredoc/nowdoc; `i` is just past `<<<`.
157
- * Returns the offset past the closing identifier line, or -1 if `<<<` isn't followed by a valid heredoc identifier.
158
- */
159
- function scanHeredoc(src, i) {
160
- const len = src.length;
161
- // Optional whitespace (PHP allows spaces/tabs after `<<<`).
162
- while (i < len && (src[i] === ' ' || src[i] === '\t'))
163
- i++;
164
- // Optional quote around the identifier.
165
- let quote = '';
166
- if (src[i] === "'" || src[i] === '"') {
167
- quote = src[i];
168
- i++;
169
- }
170
- if (i >= len || !isIdentStart(src[i]))
171
- return -1;
172
- const idStart = i;
173
- while (i < len && isIdent(src[i]))
174
- i++;
175
- const id = src.slice(idStart, i);
176
- if (quote) {
177
- if (src[i] !== quote)
178
- return -1;
179
- i++;
180
- }
181
- // After the identifier, the line must end immediately — PHP disallows trailing whitespace,
182
- // but we tolerate `\r` so `\r\n` endings still work.
183
- while (i < len && src[i] === '\r')
184
- i++;
185
- if (src[i] !== '\n')
186
- return -1;
187
- i++;
188
- // Find a line that starts with optional indentation,
189
- // then the identifier followed by a non-identifier character
190
- // (PHP 7.3 flexible syntax).
191
- while (i < len) {
192
- let j = i;
193
- while (j < len && (src[j] === ' ' || src[j] === '\t'))
194
- j++;
195
- if (src.startsWith(id, j)) {
196
- const k = j + id.length;
197
- if (k >= len || !isIdent(src[k]))
198
- return k;
199
- }
200
- const nl = src.indexOf('\n', i);
201
- if (nl === -1)
202
- return len;
203
- i = nl + 1;
204
- }
205
- return len;
206
- }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared low-level PHP lexing helpers.
3
+ *
4
+ * Used by both passes that read PHP code — island detection (`islands.ts`) and string harvesting (`php-strings.ts`)
5
+ * — so the two can never drift apart on string/comment/heredoc boundary rules.
6
+ *
7
+ * All scanners operate on the `[i, end)` window of the source and return offsets within it.
8
+ */
9
+ export declare const isIdentStart: (c: string) => boolean;
10
+ export declare const isIdent: (c: string) => boolean;
11
+ /**
12
+ * Scan a quoted string body; `i` is just past the open quote.
13
+ *
14
+ * @returns The offset of the closing quote, or `end` when unterminated.
15
+ */
16
+ export declare function scanQuoted(src: string, i: number, quote: string, end: number): number;
17
+ /**
18
+ * Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
19
+ *
20
+ * @returns The offset past a consumed newline, or the offset of an unconsumed `?>`
21
+ * (left for the caller, since `?>` closes both the comment and the island).
22
+ */
23
+ export declare function scanLineComment(src: string, i: number, end: number): number;
24
+ /**
25
+ * Scan a heredoc/nowdoc; `i` is just past `<<<`.
26
+ *
27
+ * @returns The offset past the closing identifier, or -1 if `<<<` isn't followed by a valid heredoc identifier.
28
+ */
29
+ export declare function scanHeredoc(src: string, i: number, end: number): number;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared low-level PHP lexing helpers.
3
+ *
4
+ * Used by both passes that read PHP code — island detection (`islands.ts`) and string harvesting (`php-strings.ts`)
5
+ * — so the two can never drift apart on string/comment/heredoc boundary rules.
6
+ *
7
+ * All scanners operate on the `[i, end)` window of the source and return offsets within it.
8
+ */
9
+ export const isIdentStart = (c) => /[A-Za-z_\u0080-\uffff]/.test(c);
10
+ export const isIdent = (c) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
11
+ /**
12
+ * Scan a quoted string body; `i` is just past the open quote.
13
+ *
14
+ * @returns The offset of the closing quote, or `end` when unterminated.
15
+ */
16
+ export function scanQuoted(src, i, quote, end) {
17
+ while (i < end) {
18
+ const c = src[i];
19
+ if (c === '\\') {
20
+ i += 2;
21
+ continue;
22
+ }
23
+ if (c === quote)
24
+ return i;
25
+ i++;
26
+ }
27
+ return end;
28
+ }
29
+ /**
30
+ * Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
31
+ *
32
+ * @returns The offset past a consumed newline, or the offset of an unconsumed `?>`
33
+ * (left for the caller, since `?>` closes both the comment and the island).
34
+ */
35
+ export function scanLineComment(src, i, end) {
36
+ while (i < end) {
37
+ if (src[i] === '\n')
38
+ return i + 1;
39
+ if (src[i] === '?' && src[i + 1] === '>')
40
+ return i;
41
+ i++;
42
+ }
43
+ return end;
44
+ }
45
+ /**
46
+ * Scan a heredoc/nowdoc; `i` is just past `<<<`.
47
+ *
48
+ * @returns The offset past the closing identifier, or -1 if `<<<` isn't followed by a valid heredoc identifier.
49
+ */
50
+ export function scanHeredoc(src, i, end) {
51
+ // Optional whitespace (PHP allows spaces/tabs after `<<<`).
52
+ while (i < end && (src[i] === ' ' || src[i] === '\t'))
53
+ i++;
54
+ // Optional quote around the identifier.
55
+ let quote = '';
56
+ if (src[i] === "'" || src[i] === '"') {
57
+ quote = src[i];
58
+ i++;
59
+ }
60
+ if (i >= end || !isIdentStart(src[i]))
61
+ return -1;
62
+ const idStart = i;
63
+ while (i < end && isIdent(src[i]))
64
+ i++;
65
+ const id = src.slice(idStart, i);
66
+ if (quote) {
67
+ if (src[i] !== quote)
68
+ return -1;
69
+ i++;
70
+ }
71
+ // After the identifier, the line must end immediately — PHP disallows trailing whitespace,
72
+ // but we tolerate `\r` so `\r\n` endings still work.
73
+ while (i < end && src[i] === '\r')
74
+ i++;
75
+ if (src[i] !== '\n')
76
+ return -1;
77
+ i++;
78
+ // Find a line that starts with optional indentation,
79
+ // then the identifier followed by a non-identifier character
80
+ // (PHP 7.3 flexible syntax).
81
+ while (i < end) {
82
+ let j = i;
83
+ while (j < end && (src[j] === ' ' || src[j] === '\t'))
84
+ j++;
85
+ if (src.startsWith(id, j)) {
86
+ const k = j + id.length;
87
+ if (k >= end || !isIdent(src[k]))
88
+ return k;
89
+ }
90
+ const nl = src.indexOf('\n', i);
91
+ if (nl === -1 || nl >= end)
92
+ return end;
93
+ i = nl + 1;
94
+ }
95
+ return end;
96
+ }
@@ -19,8 +19,7 @@
19
19
  * @see islands.ts - first pass; produces the islands consumed here.
20
20
  * @see transform.ts - splices the sorted strings back via the shared byte-replacement path.
21
21
  */
22
- const isIdentStart = (c) => /[A-Za-z_€-￿]/.test(c);
23
- const isIdent = (c) => /[A-Za-z0-9_€-￿]/.test(c);
22
+ import { isIdent, isIdentStart, scanHeredoc, scanLineComment, scanQuoted } from "./php-lexer.js";
24
23
  /**
25
24
  * Find every sortable string-literal value within the given PHP islands.
26
25
  *
@@ -74,11 +73,11 @@ function tokenizeIsland(src, start, end) {
74
73
  }
75
74
  // Line comments (`//`, `#`) — but `#[` opens a PHP 8 attribute, which is code.
76
75
  if (c === '/' && src[i + 1] === '/') {
77
- i = skipLineComment(src, i + 2, end);
76
+ i = scanLineComment(src, i + 2, end);
78
77
  continue;
79
78
  }
80
79
  if (c === '#' && src[i + 1] !== '[') {
81
- i = skipLineComment(src, i + 1, end);
80
+ i = scanLineComment(src, i + 1, end);
82
81
  continue;
83
82
  }
84
83
  // Block comment.
@@ -89,7 +88,7 @@ function tokenizeIsland(src, start, end) {
89
88
  }
90
89
  // Heredoc / nowdoc — never harvested; skip the whole construct.
91
90
  if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
92
- const here = skipHeredoc(src, i + 3, end);
91
+ const here = scanHeredoc(src, i + 3, end);
93
92
  if (here !== -1) {
94
93
  i = here;
95
94
  pushOther();
@@ -142,35 +141,6 @@ function tokenizeIsland(src, start, end) {
142
141
  }
143
142
  return tokens;
144
143
  }
145
- /**
146
- * Scan a quoted string body; `i` is just past the open quote. Returns the offset of the closing quote (or `end`).
147
- */
148
- function scanQuoted(src, i, quote, end) {
149
- while (i < end) {
150
- const c = src[i];
151
- if (c === '\\') {
152
- i += 2;
153
- continue;
154
- }
155
- if (c === quote)
156
- return i;
157
- i++;
158
- }
159
- return end;
160
- }
161
- /**
162
- * Skip a `//`/`#` line comment body, ending at a newline or `?>`. Returns the offset to resume scanning from.
163
- */
164
- function skipLineComment(src, i, end) {
165
- while (i < end) {
166
- if (src[i] === '\n')
167
- return i + 1;
168
- if (src[i] === '?' && src[i + 1] === '>')
169
- return i;
170
- i++;
171
- }
172
- return end;
173
- }
174
144
  /**
175
145
  * True if a double-quoted body contains interpolation (an unescaped `$`) or any escape sequence.
176
146
  */
@@ -184,47 +154,3 @@ function hasInterpolationOrEscape(body) {
184
154
  }
185
155
  return false;
186
156
  }
187
- /**
188
- * Skip a heredoc/nowdoc; `i` is just past `<<<`. Returns the offset past the closing identifier line,
189
- * or -1 if `<<<` isn't followed by a valid heredoc identifier. Mirrors the boundary rules of the island lexer.
190
- */
191
- function skipHeredoc(src, i, end) {
192
- while (i < end && (src[i] === ' ' || src[i] === '\t'))
193
- i++;
194
- let quote = '';
195
- if (src[i] === "'" || src[i] === '"') {
196
- quote = src[i];
197
- i++;
198
- }
199
- if (i >= end || !isIdentStart(src[i]))
200
- return -1;
201
- const idStart = i;
202
- while (i < end && isIdent(src[i]))
203
- i++;
204
- const id = src.slice(idStart, i);
205
- if (quote) {
206
- if (src[i] !== quote)
207
- return -1;
208
- i++;
209
- }
210
- while (i < end && src[i] === '\r')
211
- i++;
212
- if (src[i] !== '\n')
213
- return -1;
214
- i++;
215
- while (i < end) {
216
- let j = i;
217
- while (j < end && (src[j] === ' ' || src[j] === '\t'))
218
- j++;
219
- if (src.startsWith(id, j)) {
220
- const k = j + id.length;
221
- if (k >= end || !isIdent(src[k]))
222
- return k;
223
- }
224
- const nl = src.indexOf('\n', i);
225
- if (nl === -1 || nl >= end)
226
- return end;
227
- i = nl + 1;
228
- }
229
- return end;
230
- }
package/dist/transform.js CHANGED
@@ -36,43 +36,64 @@ export function transform(src, sortFn, opts = {}) {
36
36
  const islands = findIslands(src, opts);
37
37
  const masked = maskIslands(src, islands);
38
38
  const attrs = findClassAttributes(masked, opts);
39
- // Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
40
- // HTML class-attribute values live outside islands; PHP string values live inside them, so the two sets of
41
- // ranges never overlap.
42
- const edits = [];
43
- for (const { valueStart, valueEnd } of attrs) {
44
- const original = src.slice(valueStart, valueEnd);
45
- const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
46
- const rewritten = rewriteValue(original, valueStart, inner, sortFn);
47
- if (rewritten !== original)
48
- edits.push({ start: valueStart, end: valueEnd, text: rewritten });
49
- }
39
+ // Computed before the attribute pass: an edit inside a class-attribute island must be folded into that
40
+ // attribute's rewrite, or the enclosing rewrite would overwrite it and a second pass would be needed.
41
+ const phpEdits = [];
50
42
  if (opts.sortPhpStrings) {
51
43
  for (const { start, end } of findSortablePhpStrings(src, islands)) {
52
44
  const original = src.slice(start, end);
53
45
  const tokens = original.split(/\s+/).filter(Boolean);
54
46
  if (tokens.length < 2)
55
47
  continue; // nothing to reorder; leave byte-identical
56
- const rewritten = sortFn(tokens).join(' ');
57
- if (rewritten !== original)
58
- edits.push({ start, end, text: rewritten });
48
+ const text = sortFn(tokens).join(' ');
49
+ if (text !== original)
50
+ phpEdits.push({ start, end, text });
59
51
  }
60
52
  }
53
+ // Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
54
+ const edits = [];
55
+ const absorbed = new Set();
56
+ for (const { valueStart, valueEnd } of attrs) {
57
+ const original = src.slice(valueStart, valueEnd);
58
+ const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
59
+ const innerEdits = phpEdits.filter((e) => e.start >= valueStart && e.end <= valueEnd);
60
+ const rewritten = rewriteValue(original, valueStart, inner, sortFn, innerEdits);
61
+ if (rewritten !== original)
62
+ edits.push({ start: valueStart, end: valueEnd, text: rewritten });
63
+ for (const e of innerEdits)
64
+ absorbed.add(e);
65
+ }
66
+ for (const e of phpEdits)
67
+ if (!absorbed.has(e))
68
+ edits.push(e);
61
69
  edits.sort((a, b) => b.start - a.start);
62
70
  let out = src;
63
71
  for (const e of edits)
64
72
  out = out.slice(0, e.start) + e.text + out.slice(e.end);
65
73
  return out;
66
74
  }
67
- function rewriteValue(value, base, islands, sortFn) {
68
- // Build alternating static/island parts.
75
+ /**
76
+ * Apply the edits that fall entirely within `[offset, offset + text.length)` to `text`.
77
+ * Edits arrive in document order and never overlap, so a back-to-front pass keeps offsets valid.
78
+ */
79
+ function spliceEdits(text, offset, edits) {
80
+ for (let k = edits.length - 1; k >= 0; k--) {
81
+ const e = edits[k];
82
+ if (e.start < offset || e.end > offset + text.length)
83
+ continue;
84
+ text = text.slice(0, e.start - offset) + e.text + text.slice(e.end - offset);
85
+ }
86
+ return text;
87
+ }
88
+ function rewriteValue(value, base, islands, sortFn, phpEdits) {
89
+ // Build alternating static/island parts; island text carries any PHP-string edits it contains.
69
90
  const parts = [];
70
91
  let pos = 0;
71
92
  for (const isl of islands) {
72
93
  const s = isl.start - base;
73
94
  const e = isl.end - base;
74
95
  parts.push({ type: 'static', text: value.slice(pos, s) });
75
- parts.push({ type: 'island', text: value.slice(s, e) });
96
+ parts.push({ type: 'island', text: spliceEdits(value.slice(s, e), isl.start, phpEdits) });
76
97
  pos = e;
77
98
  }
78
99
  parts.push({ type: 'static', text: value.slice(pos) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtimestudio/tailwind-sort-php",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Tailwind CSS Class Sorter for PHP",
5
5
  "keywords": [
6
6
  "class-sorting",
@@ -39,9 +39,9 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "latest",
42
- "prettier": "^3.8",
42
+ "prettier": "^3.9",
43
43
  "prettier-plugin-tailwindcss": "^0.8",
44
- "tailwindcss": "^4",
44
+ "tailwindcss": "^4.3",
45
45
  "typescript": ">=5.7"
46
46
  },
47
47
  "peerDependencies": {
package/src/cli.ts CHANGED
@@ -1,20 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * tailwind-sort-php CLI
3
+ * tailwind-sort-php CLI — see `USAGE` for the flag reference.
4
4
  *
5
- * Usage:
6
- * tailwind-sort-php [options] [glob ...]
7
- * tailwind-sort-php init [--fix] [--force] [--dry-run]
8
- *
9
- * Options:
10
- * --stylesheet <path> Tailwind v4 CSS entry
11
- * --attr <name> Extra attribute to sort (repeatable)
12
- * --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
13
- * --check Don't write; exit 1 if any file needs sorting
14
- * --no-short-tags Don't treat bare `<?` as a PHP open tag
15
- *
16
- * Defaults to all `.php` files under `cwd` when no globs are given.
17
- * Skips `node_modules`, `vendor`, `dist` and `.git`. The `init` subcommand installs the pre-commit hook; see `init.ts`.
5
+ * Option values fall back to the resolved Prettier config — the same source of truth `prettier-plugin-tailwindcss`
6
+ * uses; see `fromPrettierConfig()`.
7
+ * The `init` subcommand installs the pre-commit hook; see `init.ts`.
18
8
  */
19
9
 
20
10
  import { readFile, writeFile } from 'node:fs/promises';
@@ -22,6 +12,23 @@ import { transform, type TransformOptions } from './transform.ts';
22
12
  import { createTailwindSortFn } from './sorter.ts';
23
13
  import { runInit } from './init.ts';
24
14
 
15
+ const USAGE = `Usage:
16
+ tailwind-sort-php [options] [glob ...]
17
+ tailwind-sort-php init [--fix] [--force] [--dry-run]
18
+
19
+ Options:
20
+ --stylesheet <path> Tailwind v4 CSS entry (default: tailwindStylesheet from your Prettier config)
21
+ --attr <name> Extra attribute to sort (repeatable; merged with tailwindAttributes)
22
+ --php-source <glob> Also sort class strings in PHP declarations in matching files
23
+ (repeatable; merged with tailwindPhpSources)
24
+ --check Don't write; exit 1 if any file needs sorting
25
+ --no-short-tags Don't treat bare <? as a PHP open tag
26
+ -h, --help Show this help
27
+ --version Print the version
28
+
29
+ Defaults to all .php files under the cwd when no globs are given;
30
+ node_modules, vendor, dist and .git are always skipped.`;
31
+
25
32
  const IGNORE = ['node_modules', 'vendor', 'dist', '.git'];
26
33
 
27
34
  interface Cli {
@@ -49,13 +56,13 @@ function parseArgs(argv: string[]): Cli {
49
56
  };
50
57
  for (let i = 0; i < argv.length; i++) {
51
58
  const a = argv[i];
52
- if (a === '--check') cli.check = true;
59
+ if (a === '--stylesheet') cli.stylesheet = requireValue(argv, ++i, a);
60
+ else if (a === '--attr') cli.attrs.push(requireValue(argv, ++i, a));
61
+ else if (a === '--php-source') cli.phpSources.push(requireValue(argv, ++i, a));
62
+ else if (a === '--check') cli.check = true;
53
63
  else if (a === '--no-short-tags') cli.shortTags = false;
54
- else if (a === '--stylesheet') cli.stylesheet = argv[++i];
55
- else if (a === '--attr') cli.attrs.push(argv[++i]);
56
- else if (a === '--php-source') cli.phpSources.push(argv[++i]);
57
64
  else if (a.startsWith('--')) {
58
- console.error(`Unknown option: ${a}`);
65
+ console.error(`Unknown option: ${a}\n\n${USAGE}`);
59
66
  process.exit(2);
60
67
  } else cli.globs.push(a);
61
68
  }
@@ -63,6 +70,18 @@ function parseArgs(argv: string[]): Cli {
63
70
  return cli;
64
71
  }
65
72
 
73
+ /**
74
+ * Return a flag's value argument or exit with a usage error when it is missing or is itself a flag.
75
+ */
76
+ function requireValue(argv: string[], i: number, flag: string): string {
77
+ const v = argv[i];
78
+ if (v === undefined || v.startsWith('--')) {
79
+ console.error(`Missing value for ${flag}`);
80
+ process.exit(2);
81
+ }
82
+ return v;
83
+ }
84
+
66
85
  /**
67
86
  * Yield file paths matching the given globs, using `Bun.Glob` under Bun and `node:fs` glob (Node >= 22) otherwise.
68
87
  *
@@ -76,8 +95,10 @@ async function* scanFiles(globs: string[]): AsyncGenerator<string> {
76
95
  }
77
96
  } else {
78
97
  const { glob } = await import('node:fs/promises');
98
+ // Prunes ignored directories during traversal; newer Node versions pass a Dirent instead of a path string.
99
+ const exclude = (entry: string | { name: string }) => ignored(typeof entry === 'string' ? entry : entry.name);
79
100
  for (const pattern of globs) {
80
- for await (const f of glob(pattern)) yield f as string;
101
+ for await (const f of glob(pattern, { exclude })) yield f as string;
81
102
  }
82
103
  }
83
104
  }
@@ -88,7 +109,7 @@ async function* scanFiles(globs: string[]): AsyncGenerator<string> {
88
109
  * @param file Path to test, relative to `cwd`.
89
110
  * @returns True when the path is inside an ignored directory and should be skipped.
90
111
  */
91
- const ignored = (file: string) => IGNORE.some((d) => file.includes(`${d}/`) || file.startsWith(d));
112
+ const ignored = (file: string) => file.split(/[\\/]/).some((seg) => IGNORE.includes(seg));
92
113
 
93
114
  /**
94
115
  * Best-effort read of the resolved Prettier config — the shared source of truth with `prettier-plugin-tailwindcss`.
@@ -128,6 +149,18 @@ async function fromPrettierConfig(): Promise<{
128
149
 
129
150
  async function main() {
130
151
  const argv = process.argv.slice(2);
152
+
153
+ // Help/version take precedence everywhere, including after `init`.
154
+ if (argv.includes('--help') || argv.includes('-h')) {
155
+ console.log(USAGE);
156
+ return;
157
+ }
158
+ if (argv.includes('--version')) {
159
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
160
+ console.log(pkg.version);
161
+ return;
162
+ }
163
+
131
164
  if (argv[0] === 'init') return runInit(argv.slice(1));
132
165
 
133
166
  const cli = parseArgs(argv);
package/src/html.ts CHANGED
@@ -79,6 +79,7 @@ export function findClassAttributes(masked: string, opts: HtmlScanOptions = {}):
79
79
  const wanted = new Set((opts.attributes ?? ['class', 'classname']).map((a) => a.toLowerCase()));
80
80
  const out: ClassAttr[] = [];
81
81
  const len = masked.length;
82
+ const lower = masked.toLowerCase();
82
83
  let i = 0;
83
84
 
84
85
  while (i < len) {
@@ -110,14 +111,14 @@ export function findClassAttributes(masked: string, opts: HtmlScanOptions = {}):
110
111
  if (lt + 1 < len && /[A-Za-z]/.test(masked[lt + 1])) {
111
112
  let j = lt + 1;
112
113
  while (j < len && /[A-Za-z0-9:-]/.test(masked[j])) j++;
113
- const tagName = masked.slice(lt + 1, j).toLowerCase();
114
+ const tagName = lower.slice(lt + 1, j);
114
115
 
115
116
  j = scanTagAttributes(masked, j, wanted, out);
116
117
 
117
118
  // Skip raw-text element content up to its closing tag.
118
119
  if (RAW_TEXT_TAGS.has(tagName)) {
119
120
  const closer = `</${tagName}`;
120
- const idx = masked.toLowerCase().indexOf(closer, j);
121
+ const idx = lower.indexOf(closer, j);
121
122
  j = idx === -1 ? len : idx;
122
123
  }
123
124
  i = j;
package/src/islands.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  * @see html.ts - second pass; consumes islands via `maskIslands()`.
11
11
  */
12
12
 
13
+ import { isIdent, scanHeredoc, scanLineComment, scanQuoted } from './php-lexer.ts';
14
+
13
15
  /**
14
16
  * A contiguous region of PHP code within a mixed template source.
15
17
  */
@@ -35,9 +37,6 @@ export interface IslandOptions {
35
37
  shortOpenTags?: boolean;
36
38
  }
37
39
 
38
- const isIdentStart = (c: string) => /[A-Za-z_\u0080-\uffff]/.test(c);
39
- const isIdent = (c: string) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
40
-
41
40
  /**
42
41
  * Find every PHP island in a mixed PHP/HTML template source.
43
42
  *
@@ -99,26 +98,26 @@ function scanPhpBody(src: string, i: number): number {
99
98
 
100
99
  // Single-quoted string.
101
100
  if (c === "'") {
102
- i = scanQuoted(src, i + 1, "'");
101
+ i = Math.min(scanQuoted(src, i + 1, "'", len) + 1, len);
103
102
  continue;
104
103
  }
105
104
 
106
105
  // Double-quoted string.
107
106
  // Limitation: nested double quotes in complex `{$a["k"]}` interpolation can desync the lexer; see README.
108
107
  if (c === '"') {
109
- i = scanQuoted(src, i + 1, '"');
108
+ i = Math.min(scanQuoted(src, i + 1, '"', len) + 1, len);
110
109
  continue;
111
110
  }
112
111
 
113
112
  // Backtick (shell exec) string — same escaping rules.
114
113
  if (c === '`') {
115
- i = scanQuoted(src, i + 1, '`');
114
+ i = Math.min(scanQuoted(src, i + 1, '`', len) + 1, len);
116
115
  continue;
117
116
  }
118
117
 
119
118
  // Comments.
120
119
  if (c === '/' && src[i + 1] === '/') {
121
- i = scanLineComment(src, i + 2);
120
+ i = scanLineComment(src, i + 2, len);
122
121
  if (src.startsWith('?>', i)) return i + 2;
123
122
  continue;
124
123
  }
@@ -128,7 +127,7 @@ function scanPhpBody(src: string, i: number): number {
128
127
  i += 2;
129
128
  continue;
130
129
  }
131
- i = scanLineComment(src, i + 1);
130
+ i = scanLineComment(src, i + 1, len);
132
131
  if (src.startsWith('?>', i)) return i + 2;
133
132
  continue;
134
133
  }
@@ -140,7 +139,7 @@ function scanPhpBody(src: string, i: number): number {
140
139
 
141
140
  // Heredoc / nowdoc.
142
141
  if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
143
- const here = scanHeredoc(src, i + 3);
142
+ const here = scanHeredoc(src, i + 3, len);
144
143
  if (here !== -1) {
145
144
  i = here;
146
145
  continue;
@@ -152,85 +151,3 @@ function scanPhpBody(src: string, i: number): number {
152
151
 
153
152
  return len; // file ends while still in PHP mode
154
153
  }
155
-
156
- /**
157
- * Scan a quoted string body; `i` is just past the open quote.
158
- * Returns offset just past the closing quote (or EOF).
159
- */
160
- function scanQuoted(src: string, i: number, quote: string): number {
161
- const len = src.length;
162
- while (i < len) {
163
- const c = src[i];
164
- if (c === '\\') {
165
- i += 2;
166
- continue;
167
- }
168
- if (c === quote) return i + 1;
169
- i++;
170
- }
171
- return len;
172
- }
173
-
174
- /**
175
- * Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
176
- * Returns the offset past a consumed newline, or the offset of an unconsumed `?>`
177
- * (left for the caller, since `?>` closes both the comment and the island).
178
- */
179
- function scanLineComment(src: string, i: number): number {
180
- const len = src.length;
181
- while (i < len) {
182
- if (src[i] === '\n') return i + 1;
183
- if (src[i] === '?' && src[i + 1] === '>') return i;
184
- i++;
185
- }
186
- return len;
187
- }
188
-
189
- /**
190
- * Scan a heredoc/nowdoc; `i` is just past `<<<`.
191
- * Returns the offset past the closing identifier line, or -1 if `<<<` isn't followed by a valid heredoc identifier.
192
- */
193
- function scanHeredoc(src: string, i: number): number {
194
- const len = src.length;
195
- // Optional whitespace (PHP allows spaces/tabs after `<<<`).
196
- while (i < len && (src[i] === ' ' || src[i] === '\t')) i++;
197
-
198
- // Optional quote around the identifier.
199
- let quote = '';
200
- if (src[i] === "'" || src[i] === '"') {
201
- quote = src[i];
202
- i++;
203
- }
204
-
205
- if (i >= len || !isIdentStart(src[i])) return -1;
206
- const idStart = i;
207
- while (i < len && isIdent(src[i])) i++;
208
- const id = src.slice(idStart, i);
209
-
210
- if (quote) {
211
- if (src[i] !== quote) return -1;
212
- i++;
213
- }
214
-
215
- // After the identifier, the line must end immediately — PHP disallows trailing whitespace,
216
- // but we tolerate `\r` so `\r\n` endings still work.
217
- while (i < len && src[i] === '\r') i++;
218
- if (src[i] !== '\n') return -1;
219
- i++;
220
-
221
- // Find a line that starts with optional indentation,
222
- // then the identifier followed by a non-identifier character
223
- // (PHP 7.3 flexible syntax).
224
- while (i < len) {
225
- let j = i;
226
- while (j < len && (src[j] === ' ' || src[j] === '\t')) j++;
227
- if (src.startsWith(id, j)) {
228
- const k = j + id.length;
229
- if (k >= len || !isIdent(src[k])) return k;
230
- }
231
- const nl = src.indexOf('\n', i);
232
- if (nl === -1) return len;
233
- i = nl + 1;
234
- }
235
- return len;
236
- }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Shared low-level PHP lexing helpers.
3
+ *
4
+ * Used by both passes that read PHP code — island detection (`islands.ts`) and string harvesting (`php-strings.ts`)
5
+ * — so the two can never drift apart on string/comment/heredoc boundary rules.
6
+ *
7
+ * All scanners operate on the `[i, end)` window of the source and return offsets within it.
8
+ */
9
+
10
+ export const isIdentStart = (c: string) => /[A-Za-z_\u0080-\uffff]/.test(c);
11
+ export const isIdent = (c: string) => /[A-Za-z0-9_\u0080-\uffff]/.test(c);
12
+
13
+ /**
14
+ * Scan a quoted string body; `i` is just past the open quote.
15
+ *
16
+ * @returns The offset of the closing quote, or `end` when unterminated.
17
+ */
18
+ export function scanQuoted(src: string, i: number, quote: string, end: number): number {
19
+ while (i < end) {
20
+ const c = src[i];
21
+ if (c === '\\') {
22
+ i += 2;
23
+ continue;
24
+ }
25
+ if (c === quote) return i;
26
+ i++;
27
+ }
28
+ return end;
29
+ }
30
+
31
+ /**
32
+ * Scan a `//` or `#` line comment body, which ends at a newline or at `?>`.
33
+ *
34
+ * @returns The offset past a consumed newline, or the offset of an unconsumed `?>`
35
+ * (left for the caller, since `?>` closes both the comment and the island).
36
+ */
37
+ export function scanLineComment(src: string, i: number, end: number): number {
38
+ while (i < end) {
39
+ if (src[i] === '\n') return i + 1;
40
+ if (src[i] === '?' && src[i + 1] === '>') return i;
41
+ i++;
42
+ }
43
+ return end;
44
+ }
45
+
46
+ /**
47
+ * Scan a heredoc/nowdoc; `i` is just past `<<<`.
48
+ *
49
+ * @returns The offset past the closing identifier, or -1 if `<<<` isn't followed by a valid heredoc identifier.
50
+ */
51
+ export function scanHeredoc(src: string, i: number, end: number): number {
52
+ // Optional whitespace (PHP allows spaces/tabs after `<<<`).
53
+ while (i < end && (src[i] === ' ' || src[i] === '\t')) i++;
54
+
55
+ // Optional quote around the identifier.
56
+ let quote = '';
57
+ if (src[i] === "'" || src[i] === '"') {
58
+ quote = src[i];
59
+ i++;
60
+ }
61
+
62
+ if (i >= end || !isIdentStart(src[i])) return -1;
63
+ const idStart = i;
64
+ while (i < end && isIdent(src[i])) i++;
65
+ const id = src.slice(idStart, i);
66
+
67
+ if (quote) {
68
+ if (src[i] !== quote) return -1;
69
+ i++;
70
+ }
71
+
72
+ // After the identifier, the line must end immediately — PHP disallows trailing whitespace,
73
+ // but we tolerate `\r` so `\r\n` endings still work.
74
+ while (i < end && src[i] === '\r') i++;
75
+ if (src[i] !== '\n') return -1;
76
+ i++;
77
+
78
+ // Find a line that starts with optional indentation,
79
+ // then the identifier followed by a non-identifier character
80
+ // (PHP 7.3 flexible syntax).
81
+ while (i < end) {
82
+ let j = i;
83
+ while (j < end && (src[j] === ' ' || src[j] === '\t')) j++;
84
+ if (src.startsWith(id, j)) {
85
+ const k = j + id.length;
86
+ if (k >= end || !isIdent(src[k])) return k;
87
+ }
88
+ const nl = src.indexOf('\n', i);
89
+ if (nl === -1 || nl >= end) return end;
90
+ i = nl + 1;
91
+ }
92
+ return end;
93
+ }
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { Island } from './islands.ts';
24
+ import { isIdent, isIdentStart, scanHeredoc, scanLineComment, scanQuoted } from './php-lexer.ts';
24
25
 
25
26
  /**
26
27
  * Inner byte range of a sortable string literal — the span *between* the quotes, in original-source offsets.
@@ -36,9 +37,6 @@ export interface PhpStringRange {
36
37
  end: number;
37
38
  }
38
39
 
39
- const isIdentStart = (c: string) => /[A-Za-z_€-￿]/.test(c);
40
- const isIdent = (c: string) => /[A-Za-z0-9_€-￿]/.test(c);
41
-
42
40
  interface Token {
43
41
  kind: 'string' | 'arrow' | 'dot' | 'other';
44
42
  /**
@@ -105,11 +103,11 @@ function tokenizeIsland(src: string, start: number, end: number): Token[] {
105
103
 
106
104
  // Line comments (`//`, `#`) — but `#[` opens a PHP 8 attribute, which is code.
107
105
  if (c === '/' && src[i + 1] === '/') {
108
- i = skipLineComment(src, i + 2, end);
106
+ i = scanLineComment(src, i + 2, end);
109
107
  continue;
110
108
  }
111
109
  if (c === '#' && src[i + 1] !== '[') {
112
- i = skipLineComment(src, i + 1, end);
110
+ i = scanLineComment(src, i + 1, end);
113
111
  continue;
114
112
  }
115
113
 
@@ -122,7 +120,7 @@ function tokenizeIsland(src: string, start: number, end: number): Token[] {
122
120
 
123
121
  // Heredoc / nowdoc — never harvested; skip the whole construct.
124
122
  if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
125
- const here = skipHeredoc(src, i + 3, end);
123
+ const here = scanHeredoc(src, i + 3, end);
126
124
  if (here !== -1) {
127
125
  i = here;
128
126
  pushOther();
@@ -178,34 +176,6 @@ function tokenizeIsland(src: string, start: number, end: number): Token[] {
178
176
  return tokens;
179
177
  }
180
178
 
181
- /**
182
- * Scan a quoted string body; `i` is just past the open quote. Returns the offset of the closing quote (or `end`).
183
- */
184
- function scanQuoted(src: string, i: number, quote: string, end: number): number {
185
- while (i < end) {
186
- const c = src[i];
187
- if (c === '\\') {
188
- i += 2;
189
- continue;
190
- }
191
- if (c === quote) return i;
192
- i++;
193
- }
194
- return end;
195
- }
196
-
197
- /**
198
- * Skip a `//`/`#` line comment body, ending at a newline or `?>`. Returns the offset to resume scanning from.
199
- */
200
- function skipLineComment(src: string, i: number, end: number): number {
201
- while (i < end) {
202
- if (src[i] === '\n') return i + 1;
203
- if (src[i] === '?' && src[i + 1] === '>') return i;
204
- i++;
205
- }
206
- return end;
207
- }
208
-
209
179
  /**
210
180
  * True if a double-quoted body contains interpolation (an unescaped `$`) or any escape sequence.
211
181
  */
@@ -217,44 +187,3 @@ function hasInterpolationOrEscape(body: string): boolean {
217
187
  }
218
188
  return false;
219
189
  }
220
-
221
- /**
222
- * Skip a heredoc/nowdoc; `i` is just past `<<<`. Returns the offset past the closing identifier line,
223
- * or -1 if `<<<` isn't followed by a valid heredoc identifier. Mirrors the boundary rules of the island lexer.
224
- */
225
- function skipHeredoc(src: string, i: number, end: number): number {
226
- while (i < end && (src[i] === ' ' || src[i] === '\t')) i++;
227
-
228
- let quote = '';
229
- if (src[i] === "'" || src[i] === '"') {
230
- quote = src[i];
231
- i++;
232
- }
233
-
234
- if (i >= end || !isIdentStart(src[i])) return -1;
235
- const idStart = i;
236
- while (i < end && isIdent(src[i])) i++;
237
- const id = src.slice(idStart, i);
238
-
239
- if (quote) {
240
- if (src[i] !== quote) return -1;
241
- i++;
242
- }
243
-
244
- while (i < end && src[i] === '\r') i++;
245
- if (src[i] !== '\n') return -1;
246
- i++;
247
-
248
- while (i < end) {
249
- let j = i;
250
- while (j < end && (src[j] === ' ' || src[j] === '\t')) j++;
251
- if (src.startsWith(id, j)) {
252
- const k = j + id.length;
253
- if (k >= end || !isIdent(src[k])) return k;
254
- }
255
- const nl = src.indexOf('\n', i);
256
- if (nl === -1 || nl >= end) return end;
257
- i = nl + 1;
258
- }
259
- return end;
260
- }
package/src/transform.ts CHANGED
@@ -55,27 +55,33 @@ export function transform(src: string, sortFn: SortFn, opts: TransformOptions =
55
55
  const masked = maskIslands(src, islands);
56
56
  const attrs = findClassAttributes(masked, opts);
57
57
 
58
+ // Computed before the attribute pass: an edit inside a class-attribute island must be folded into that
59
+ // attribute's rewrite, or the enclosing rewrite would overwrite it and a second pass would be needed.
60
+ const phpEdits: Edit[] = [];
61
+ if (opts.sortPhpStrings) {
62
+ for (const { start, end } of findSortablePhpStrings(src, islands)) {
63
+ const original = src.slice(start, end);
64
+ const tokens = original.split(/\s+/).filter(Boolean);
65
+ if (tokens.length < 2) continue; // nothing to reorder; leave byte-identical
66
+ const text = sortFn(tokens).join(' ');
67
+ if (text !== original) phpEdits.push({ start, end, text });
68
+ }
69
+ }
70
+
58
71
  // Collect every edit as a {start, end, text} range, then apply them back-to-front so offsets stay valid.
59
- // HTML class-attribute values live outside islands; PHP string values live inside them, so the two sets of
60
- // ranges never overlap.
61
- const edits: { start: number; end: number; text: string }[] = [];
72
+ const edits: Edit[] = [];
73
+ const absorbed = new Set<Edit>();
62
74
 
63
75
  for (const { valueStart, valueEnd } of attrs) {
64
76
  const original = src.slice(valueStart, valueEnd);
65
77
  const inner = islands.filter((isl) => isl.start >= valueStart && isl.end <= valueEnd);
66
- const rewritten = rewriteValue(original, valueStart, inner, sortFn);
78
+ const innerEdits = phpEdits.filter((e) => e.start >= valueStart && e.end <= valueEnd);
79
+ const rewritten = rewriteValue(original, valueStart, inner, sortFn, innerEdits);
67
80
  if (rewritten !== original) edits.push({ start: valueStart, end: valueEnd, text: rewritten });
81
+ for (const e of innerEdits) absorbed.add(e);
68
82
  }
69
83
 
70
- if (opts.sortPhpStrings) {
71
- for (const { start, end } of findSortablePhpStrings(src, islands)) {
72
- const original = src.slice(start, end);
73
- const tokens = original.split(/\s+/).filter(Boolean);
74
- if (tokens.length < 2) continue; // nothing to reorder; leave byte-identical
75
- const rewritten = sortFn(tokens).join(' ');
76
- if (rewritten !== original) edits.push({ start, end, text: rewritten });
77
- }
78
- }
84
+ for (const e of phpEdits) if (!absorbed.has(e)) edits.push(e);
79
85
 
80
86
  edits.sort((a, b) => b.start - a.start);
81
87
  let out = src;
@@ -83,20 +89,39 @@ export function transform(src: string, sortFn: SortFn, opts: TransformOptions =
83
89
  return out;
84
90
  }
85
91
 
92
+ interface Edit {
93
+ start: number;
94
+ end: number;
95
+ text: string;
96
+ }
97
+
86
98
  interface Part {
87
99
  type: 'static' | 'island';
88
100
  text: string;
89
101
  }
90
102
 
91
- function rewriteValue(value: string, base: number, islands: Island[], sortFn: SortFn): string {
92
- // Build alternating static/island parts.
103
+ /**
104
+ * Apply the edits that fall entirely within `[offset, offset + text.length)` to `text`.
105
+ * Edits arrive in document order and never overlap, so a back-to-front pass keeps offsets valid.
106
+ */
107
+ function spliceEdits(text: string, offset: number, edits: Edit[]): string {
108
+ for (let k = edits.length - 1; k >= 0; k--) {
109
+ const e = edits[k];
110
+ if (e.start < offset || e.end > offset + text.length) continue;
111
+ text = text.slice(0, e.start - offset) + e.text + text.slice(e.end - offset);
112
+ }
113
+ return text;
114
+ }
115
+
116
+ function rewriteValue(value: string, base: number, islands: Island[], sortFn: SortFn, phpEdits: Edit[]): string {
117
+ // Build alternating static/island parts; island text carries any PHP-string edits it contains.
93
118
  const parts: Part[] = [];
94
119
  let pos = 0;
95
120
  for (const isl of islands) {
96
121
  const s = isl.start - base;
97
122
  const e = isl.end - base;
98
123
  parts.push({ type: 'static', text: value.slice(pos, s) });
99
- parts.push({ type: 'island', text: value.slice(s, e) });
124
+ parts.push({ type: 'island', text: spliceEdits(value.slice(s, e), isl.start, phpEdits) });
100
125
  pos = e;
101
126
  }
102
127
  parts.push({ type: 'static', text: value.slice(pos) });