@runtimestudio/tailwind-sort-php 0.2.1 → 0.3.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/README.md CHANGED
@@ -18,6 +18,15 @@ values, using a real PHP-aware lexer, and leaves everything else byte-identical.
18
18
  - **Zero-config** — reads `tailwindStylesheet` from your Prettier config, the same source of truth that
19
19
  `prettier-plugin-tailwindcss` uses.
20
20
 
21
+ ## Contents
22
+
23
+ - [Requirements](#requirements) · [Install](#install) · [Setup](#setup) · [Usage](#usage)
24
+ - [Editor integration](#editor-integration) — sort-on-save & pre-commit gate
25
+ - [Sorting classes in PHP declarations](#sorting-classes-in-php-declarations) — the per-file opt-in
26
+ - [WordPress themes & plugins](#wordpress-themes--plugins)
27
+ - [How it handles mixed templates](#how-it-handles-mixed-templates) · [Programmatic API](#programmatic-api) · [Known limitations](#known-limitations)
28
+ - [Development](#development) · [License](#license)
29
+
21
30
  ## Requirements
22
31
 
23
32
  - **Node ≥ 22.18**, or **Bun** — both run the CLI and the programmatic API.
@@ -44,8 +53,8 @@ vocabulary. Any config format Prettier supports works (`.prettierrc`, `prettier.
44
53
 
45
54
  ```js
46
55
  export default {
47
- plugins: ['prettier-plugin-tailwindcss'],
48
- tailwindStylesheet: './resources/css/main.css',
56
+ plugins: ['prettier-plugin-tailwindcss'],
57
+ tailwindStylesheet: './resources/css/main.css',
49
58
  };
50
59
  ```
51
60
 
@@ -75,18 +84,22 @@ npx tailwind-sort-php init
75
84
 
76
85
  ### Options
77
86
 
78
- | Flag | Description |
79
- |-----------------------|---------------------------------------------------------------------------------------------------|
80
- | `--stylesheet <path>` | Tailwind v4 CSS entry. Defaults to `tailwindStylesheet` from your Prettier config. |
81
- | `--attr <name>` | Extra attribute to sort (repeatable). Merged with `tailwindAttributes` from your Prettier config. |
82
- | `--check` | Don't write; exit 1 if any file needs sorting. |
83
- | `--no-short-tags` | Don't treat bare `<?` as a PHP open tag. |
87
+ | Flag | Description |
88
+ |-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
89
+ | `--stylesheet <path>` | Tailwind v4 CSS entry. Defaults to `tailwindStylesheet` from your Prettier config. |
90
+ | `--attr <name>` | Extra attribute to sort (repeatable). Merged with `tailwindAttributes` from your Prettier config. |
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
+ | `--check` | Don't write; exit 1 if any file needs sorting. |
93
+ | `--no-short-tags` | Don't treat bare `<?` as a PHP open tag. |
84
94
 
85
95
  Default globs are all `.php` files under the cwd; `node_modules`, `vendor`, `dist`, and `.git` are always skipped.
86
96
 
87
97
  ## Editor integration
88
98
 
89
- No IDE plugin is needed — two small setups cover the common workflows.
99
+ No IDE plugin is needed — two small setups cover the common workflows: sort-on-save and a pre-commit gate.
100
+
101
+ <details>
102
+ <summary><b>Sort on save</b> (PhpStorm / IntelliJ, VS Code) and a <b>pre-commit gate</b> — click to expand</summary>
90
103
 
91
104
  ### Sort on save (PhpStorm / IntelliJ)
92
105
 
@@ -107,14 +120,14 @@ to `.vscode/settings.json`:
107
120
 
108
121
  ```json
109
122
  {
110
- "emeraldwalk.runonsave": {
111
- "commands": [
112
- {
113
- "match": "\\.php$",
114
- "cmd": "${workspaceFolder}/node_modules/.bin/tailwind-sort-php ${relativeFile}"
115
- }
116
- ]
117
- }
123
+ "emeraldwalk.runonsave": {
124
+ "commands": [
125
+ {
126
+ "match": "\\.php$",
127
+ "cmd": "${workspaceFolder}/node_modules/.bin/tailwind-sort-php ${relativeFile}"
128
+ }
129
+ ]
130
+ }
118
131
  }
119
132
  ```
120
133
 
@@ -146,11 +159,103 @@ git diff --cached --name-only -z --diff-filter=ACMR -- '*.php' | xargs -0 ./node
146
159
 
147
160
  In CI there's no staged diff — just sweep the whole project with `npx tailwind-sort-php --check`.
148
161
 
162
+ </details>
163
+
164
+ ## Sorting classes in PHP declarations
165
+
166
+ By default, the tool only sorts classes inside HTML `class="..."` attributes. Class strings declared in **PHP itself** —
167
+ constants, static properties, config arrays — sit inside PHP code the tool treats as opaque, so they're left alone.
168
+
169
+ Opt in **per file** by listing the files whose PHP string values are Tailwind class lists, via `tailwindPhpSources` in
170
+ your Prettier config (or the repeatable `--php-source <glob>` flag):
171
+
172
+ ```js
173
+ export default {
174
+ plugins: ['prettier-plugin-tailwindcss'],
175
+ tailwindStylesheet: './resources/css/main.css',
176
+ tailwindPhpSources: ['src/classes/*.php'],
177
+ };
178
+ ```
179
+
180
+ With this set, every **string value** in a matched file is sorted with the exact same engine and order as the HTML
181
+ side. In `key => value` arrays only the **value** is sorted — keys are never touched:
182
+
183
+ ```php
184
+ // before
185
+ public const array VARIANTS = array(
186
+ 'primary' => 'text-white px-4 bg-blue-600 rounded py-2',
187
+ 'secondary' => 'text-gray-900 px-4 bg-gray-100 rounded py-2',
188
+ );
189
+
190
+ // after
191
+ public const array VARIANTS = array(
192
+ 'primary' => 'rounded bg-blue-600 px-4 py-2 text-white',
193
+ 'secondary' => 'rounded bg-gray-100 px-4 py-2 text-gray-900',
194
+ );
195
+ ```
196
+
197
+ Scalar declarations work the same way (`const string CARD = '...'`, `static $x = '...'`, `$x = '...'`), as do nested
198
+ and list-style arrays.
199
+
200
+ > **⚠️ Point `tailwindPhpSources` only at files whose string values are all Tailwind class lists.** The tool does
201
+ > **not** guess whether a string "looks like" classes — within a matched file it sorts **every** eligible string value.
202
+ > Aimed at a general file, it **will** reorder the words inside non-class strings (labels, URLs, SQL). This is a
203
+ > deliberate design contract, not a bug: safety comes from your file-level opt-in, which is why a dedicated
204
+ > directory of class-holder files (e.g. `src/classes/`) is the intended target.
205
+
206
+ The opt-in is **inert at runtime** — it lives in formatter config only, never in your source. Your PHP stays vanilla,
207
+ with zero coupling to this tool (no marker comments, no helper functions, no attributes).
208
+
209
+ **Skipped automatically** (left byte-identical, even in a matched file):
210
+
211
+ - Concatenated literals (`'btn-' . $variant`) — a fragment joined to dynamic code, unsafe to reorder.
212
+ - Interpolated double-quoted strings (`"p-4 {$dynamic} flex"`).
213
+ - Heredoc/nowdoc and backtick (shell-exec) strings, and strings containing escape sequences.
214
+
215
+ **Off by default:** without `tailwindPhpSources` (and `--php-source`), behavior is identical to 0.2.x — no PHP
216
+ declaration is ever touched.
217
+
218
+ ## WordPress themes & plugins
219
+
220
+ Most WordPress sorting needs **no opt-in at all**. Template files and partials output markup, and the `class="..."`
221
+ in that markup is sorted by the default HTML pass — even when the value is interrupted by PHP:
222
+
223
+ ```php
224
+ <article <?php post_class( 'z-10 flex' ); ?>>
225
+ <h2 class="text-2xl font-bold <?= $featured ? 'text-amber-600' : '' ?> tracking-tight">
226
+ ```
227
+
228
+ `tailwindPhpSources` is only for classes you store in **PHP values** — a variant map, a config array, theme defaults.
229
+ For that, **don't opt in a general partial.** Partials are full of non-class strings — `__()` translations,
230
+ `get_template_part()` names, query args, URLs — and an opted-in file sorts _every_ multi-word string value. The Tailwind
231
+ sorter leaves most prose alone (unknown words keep their order), but it **will** reorder any string containing words
232
+ that are also utilities (`grid`, `block`, `flex`, `hidden`, `container`, `table`, …), so `'Switch to grid view'`
233
+ becomes `'Switch to view grid'`.
234
+
235
+ Instead, keep class maps in a **dedicated file** whose every value is a class list, and opt in only that file:
236
+
237
+ ```php
238
+ // inc/ui-classes.php → tailwindPhpSources: ['inc/ui-classes.php']
239
+ return array(
240
+ 'button' => array(
241
+ 'primary' => 'rounded bg-blue-600 px-4 py-2 text-white',
242
+ 'secondary' => 'rounded bg-gray-100 px-4 py-2 text-gray-900',
243
+ ),
244
+ 'card' => 'rounded-lg border bg-white p-6 shadow-sm',
245
+ );
246
+ ```
247
+
248
+ `require` that map from your partials. The map file is 100% class strings (safe to sort); the partials stay out of
249
+ `tailwindPhpSources` and get their markup sorted by the HTML pass as usual.
250
+
149
251
  ## How it handles mixed templates
150
252
 
151
253
  PHP islands inside a class attribute are treated as opaque atoms that never move. Static text between islands is sorted
152
254
  independently — the same model the official plugin uses for `${}` interpolations in template literals.
153
255
 
256
+ <details>
257
+ <summary>Glued-fragment pinning, whitespace handling, and the full edge-case list — click to expand</summary>
258
+
154
259
  ```php
155
260
  <!-- before -->
156
261
  <h2 class="text-2xl font-bold <?= $featured ? 'text-amber-600' : '' ?> tracking-tight leading-snug">
@@ -173,8 +278,10 @@ Also handled correctly:
173
278
  - `?>` inside `//` and `#` line comments (island ends — genuine PHP behavior)
174
279
  - `#[Attributes]`, `<?PHP` case-insensitivity, `<?xml` exclusion, files ending in PHP mode
175
280
  - PHP islands as standalone attributes: `<div <?php post_class(); ?> class="...">`
176
- - `<script>`/`<style>` content, HTML comments, and `echo '<div class="...">'` strings are left alone (sorting
177
- `class="..."` inside PHP string literals could be added later as an opt-in)
281
+ - `<script>`/`<style>` content, HTML comments, and `echo '<div class="...">'` strings are left alone (to sort class
282
+ strings declared in PHP, see [Sorting classes in PHP declarations](#sorting-classes-in-php-declarations))
283
+
284
+ </details>
178
285
 
179
286
  ## Programmatic API
180
287
 
@@ -190,6 +297,9 @@ const out = transform(source, sortFn);
190
297
 
191
298
  ## Known limitations
192
299
 
300
+ <details>
301
+ <summary>Edge cases and unsupported syntax — click to expand</summary>
302
+
193
303
  - Complex string interpolation containing double quotes (`"{$arr["key"]}"`) can desync the PHP string lexer in rare
194
304
  cases. Use `{$arr['key']}` style or extract to a variable.
195
305
  - Unquoted attribute values (`class=foo`) are skipped.
@@ -197,6 +307,8 @@ const out = transform(source, sortFn);
197
307
  classes).
198
308
  - Whitespace inside multi-line class attributes is normalized to single spaces (matches Prettier behavior).
199
309
 
310
+ </details>
311
+
200
312
  ## Development
201
313
 
202
314
  ```sh
@@ -204,10 +316,10 @@ bun test # or: node --test "test/*.test.ts"
204
316
  bun run build # compile src → dist (tsc); the published artifact
205
317
  ```
206
318
 
207
- 54 tests: 41 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`), 5
208
- integration tests that exercise the real `prettier-plugin-tailwindcss` sorter and skip automatically when the Tailwind
209
- toolchain isn't installed, and 8 `init` tests that run against throwaway git repositories and skip when `git` is
210
- unavailable.
319
+ 74 tests: 59 core tests that are dependency-free (the sorter is injected, so they run against a mock `SortFn`),
320
+ 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.
211
323
 
212
324
  ## License
213
325
 
package/dist/cli.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  * Options:
10
10
  * --stylesheet <path> Tailwind v4 CSS entry
11
11
  * --attr <name> Extra attribute to sort (repeatable)
12
+ * --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
12
13
  * --check Don't write; exit 1 if any file needs sorting
13
14
  * --no-short-tags Don't treat bare `<?` as a PHP open tag
14
15
  *
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * Options:
10
10
  * --stylesheet <path> Tailwind v4 CSS entry
11
11
  * --attr <name> Extra attribute to sort (repeatable)
12
+ * --php-source <glob> Also sort class strings in PHP declarations in matching files (repeatable)
12
13
  * --check Don't write; exit 1 if any file needs sorting
13
14
  * --no-short-tags Don't treat bare `<?` as a PHP open tag
14
15
  *
@@ -30,6 +31,7 @@ function parseArgs(argv) {
30
31
  const cli = {
31
32
  globs: [],
32
33
  attrs: ['class', 'className'],
34
+ phpSources: [],
33
35
  check: false,
34
36
  shortTags: true,
35
37
  };
@@ -43,6 +45,8 @@ function parseArgs(argv) {
43
45
  cli.stylesheet = argv[++i];
44
46
  else if (a === '--attr')
45
47
  cli.attrs.push(argv[++i]);
48
+ else if (a === '--php-source')
49
+ cli.phpSources.push(argv[++i]);
46
50
  else if (a.startsWith('--')) {
47
51
  console.error(`Unknown option: ${a}`);
48
52
  process.exit(2);
@@ -83,11 +87,10 @@ async function* scanFiles(globs) {
83
87
  */
84
88
  const ignored = (file) => IGNORE.some((d) => file.includes(`${d}/`) || file.startsWith(d));
85
89
  /**
86
- * Best-effort read of the project's resolved Prettier config, so this tool shares one source of truth with
87
- * `prettier-plugin-tailwindcss`. Picks up `tailwindStylesheet` (resolved relative to the config file) and
88
- * `tailwindAttributes` (merged into the attribute list).
90
+ * Best-effort read of the resolved Prettier config the shared source of truth with `prettier-plugin-tailwindcss`.
91
+ * Picks up `tailwindStylesheet`, `tailwindAttributes`, and `tailwindPhpSources`.
89
92
  *
90
- * @returns The resolved stylesheet path and attributes, or an empty object if none are available.
93
+ * @returns The found settings, or an empty object if none are available.
91
94
  */
92
95
  async function fromPrettierConfig() {
93
96
  try {
@@ -106,6 +109,9 @@ async function fromPrettierConfig() {
106
109
  if (Array.isArray(cfg.tailwindAttributes)) {
107
110
  out.attributes = cfg.tailwindAttributes.filter((a) => typeof a === 'string' && !a.startsWith('/'));
108
111
  }
112
+ if (Array.isArray(cfg.tailwindPhpSources)) {
113
+ out.phpSources = cfg.tailwindPhpSources.filter((p) => typeof p === 'string');
114
+ }
109
115
  return out;
110
116
  }
111
117
  catch {
@@ -135,24 +141,32 @@ async function main() {
135
141
  attributes: cli.attrs,
136
142
  shortOpenTags: cli.shortTags,
137
143
  };
144
+ // Pre-scan the opt-in `tailwindPhpSources` globs; a file gets `sortPhpStrings` only if it matches one.
145
+ const phpSources = [...cli.phpSources, ...(pc.phpSources ?? [])];
146
+ const phpSourceFiles = new Set();
147
+ for await (const file of scanFiles(phpSources)) {
148
+ if (!ignored(file))
149
+ phpSourceFiles.add(file);
150
+ }
138
151
  let scanned = 0;
139
152
  let changed = 0;
140
- for (const pattern of cli.globs) {
141
- for await (const file of scanFiles([pattern])) {
142
- if (ignored(file))
143
- continue;
144
- scanned++;
145
- const src = await readFile(file, 'utf8');
146
- const out = transform(src, sortFn, opts);
147
- if (out !== src) {
148
- changed++;
149
- if (cli.check) {
150
- console.log(`needs sorting: ${file}`);
151
- }
152
- else {
153
- await writeFile(file, out);
154
- console.log(`sorted: ${file}`);
155
- }
153
+ const seen = new Set();
154
+ // Include php-source files so a designated holder is sorted even outside the main globs; `seen` dedupes.
155
+ for await (const file of scanFiles([...cli.globs, ...phpSources])) {
156
+ if (ignored(file) || seen.has(file))
157
+ continue;
158
+ seen.add(file);
159
+ scanned++;
160
+ const src = await readFile(file, 'utf8');
161
+ const out = transform(src, sortFn, { ...opts, sortPhpStrings: phpSourceFiles.has(file) });
162
+ if (out !== src) {
163
+ changed++;
164
+ if (cli.check) {
165
+ console.log(`needs sorting: ${file}`);
166
+ }
167
+ else {
168
+ await writeFile(file, out);
169
+ console.log(`sorted: ${file}`);
156
170
  }
157
171
  }
158
172
  }
package/dist/index.d.ts CHANGED
@@ -10,4 +10,5 @@
10
10
  export { transform, type SortFn, type TransformOptions } from './transform.ts';
11
11
  export { findIslands, type Island, type IslandOptions } from './islands.ts';
12
12
  export { maskIslands, findClassAttributes, type ClassAttr, type HtmlScanOptions } from './html.ts';
13
+ export { findSortablePhpStrings, type PhpStringRange } from './php-strings.ts';
13
14
  export { createTailwindSortFn, type SorterOptions } from './sorter.ts';
package/dist/index.js CHANGED
@@ -10,4 +10,5 @@
10
10
  export { transform } from "./transform.js";
11
11
  export { findIslands } from "./islands.js";
12
12
  export { maskIslands, findClassAttributes } from "./html.js";
13
+ export { findSortablePhpStrings } from "./php-strings.js";
13
14
  export { createTailwindSortFn } from "./sorter.js";
package/dist/init.js CHANGED
@@ -132,7 +132,11 @@ export async function runInit(argv) {
132
132
  let repairMode = false;
133
133
  if (current !== null && current !== hookBody) {
134
134
  if (!cli.force) {
135
- const installed = current === HOOK_CHECK ? 'the check variant' : current === HOOK_FIX ? 'the --fix variant' : 'a custom hook';
135
+ const installed = current === HOOK_CHECK
136
+ ? 'the check variant'
137
+ : current === HOOK_FIX
138
+ ? 'the --fix variant'
139
+ : 'a custom hook';
136
140
  fail(`${HOOK_PATH} already exists and differs (${installed} is installed). Re-run with --force to overwrite.`);
137
141
  }
138
142
  writeHook = true;
@@ -148,7 +152,9 @@ export async function runInit(argv) {
148
152
  await writeFile(hookAbs, hookBody);
149
153
  await chmod(hookAbs, 0o755);
150
154
  }
151
- done.push(cli.dryRun ? `would install ${HOOK_PATH} (${variant} variant)` : `installed ${HOOK_PATH} (${variant} variant)`);
155
+ done.push(cli.dryRun
156
+ ? `would install ${HOOK_PATH} (${variant} variant)`
157
+ : `installed ${HOOK_PATH} (${variant} variant)`);
152
158
  }
153
159
  if (repairMode) {
154
160
  if (!cli.dryRun)
@@ -0,0 +1,47 @@
1
+ /**
2
+ * PHP string-literal harvester — the optional third pass.
3
+ *
4
+ * Given the source and the PHP islands already found by `findIslands()`, this locates the byte ranges of every
5
+ * string literal *value* inside PHP code that is eligible for class sorting, applying these structural rules:
6
+ *
7
+ * - Only the *value* of a `key => value` pair is eligible; array keys are never sorted.
8
+ * - Bare list-style elements (`['a', 'b']`) and scalar assignments (`const X = '...'`) are values.
9
+ * - Strings that are part of a concatenation expression (`'btn-' . $v`) are skipped — a literal joined to dynamic
10
+ * code may be a partial class fragment, and reordering it would corrupt the rendered string.
11
+ * - Double-quoted strings containing interpolation (`"p-4 {$x}"`) are skipped, mirroring the HTML side's
12
+ * conservatism around dynamic content.
13
+ * - Strings whose body contains a backslash escape are skipped, so escape sequences can never be mangled.
14
+ * - Heredoc/nowdoc and backtick (shell-exec) strings are never harvested.
15
+ *
16
+ * This pass is opt-in per file (the caller decides which files are class-string holders); it does NOT judge whether
17
+ * a given string "looks like" Tailwind classes — within a matched file, every eligible value is sorted.
18
+ *
19
+ * @see islands.ts - first pass; produces the islands consumed here.
20
+ * @see transform.ts - splices the sorted strings back via the shared byte-replacement path.
21
+ */
22
+ import type { Island } from './islands.ts';
23
+ /**
24
+ * Inner byte range of a sortable string literal — the span *between* the quotes, in original-source offsets.
25
+ */
26
+ export interface PhpStringRange {
27
+ /**
28
+ * Offset of the first character inside the quotes.
29
+ */
30
+ start: number;
31
+ /**
32
+ * Offset just past the last character inside the quotes (exclusive).
33
+ */
34
+ end: number;
35
+ }
36
+ /**
37
+ * Find every sortable string-literal value within the given PHP islands.
38
+ *
39
+ * @param src Original template source.
40
+ * @param islands Island ranges from `findIslands()`.
41
+ * @returns Inner ranges of eligible string values, in document order.
42
+ *
43
+ * @example
44
+ * const islands = findIslands(`<?php $x = 'z a'; ?>`);
45
+ * findSortablePhpStrings(`<?php $x = 'z a'; ?>`, islands); // [{ start: 11, end: 14 }]
46
+ */
47
+ export declare function findSortablePhpStrings(src: string, islands: Island[]): PhpStringRange[];
@@ -0,0 +1,230 @@
1
+ /**
2
+ * PHP string-literal harvester — the optional third pass.
3
+ *
4
+ * Given the source and the PHP islands already found by `findIslands()`, this locates the byte ranges of every
5
+ * string literal *value* inside PHP code that is eligible for class sorting, applying these structural rules:
6
+ *
7
+ * - Only the *value* of a `key => value` pair is eligible; array keys are never sorted.
8
+ * - Bare list-style elements (`['a', 'b']`) and scalar assignments (`const X = '...'`) are values.
9
+ * - Strings that are part of a concatenation expression (`'btn-' . $v`) are skipped — a literal joined to dynamic
10
+ * code may be a partial class fragment, and reordering it would corrupt the rendered string.
11
+ * - Double-quoted strings containing interpolation (`"p-4 {$x}"`) are skipped, mirroring the HTML side's
12
+ * conservatism around dynamic content.
13
+ * - Strings whose body contains a backslash escape are skipped, so escape sequences can never be mangled.
14
+ * - Heredoc/nowdoc and backtick (shell-exec) strings are never harvested.
15
+ *
16
+ * This pass is opt-in per file (the caller decides which files are class-string holders); it does NOT judge whether
17
+ * a given string "looks like" Tailwind classes — within a matched file, every eligible value is sorted.
18
+ *
19
+ * @see islands.ts - first pass; produces the islands consumed here.
20
+ * @see transform.ts - splices the sorted strings back via the shared byte-replacement path.
21
+ */
22
+ const isIdentStart = (c) => /[A-Za-z_€-￿]/.test(c);
23
+ const isIdent = (c) => /[A-Za-z0-9_€-￿]/.test(c);
24
+ /**
25
+ * Find every sortable string-literal value within the given PHP islands.
26
+ *
27
+ * @param src Original template source.
28
+ * @param islands Island ranges from `findIslands()`.
29
+ * @returns Inner ranges of eligible string values, in document order.
30
+ *
31
+ * @example
32
+ * const islands = findIslands(`<?php $x = 'z a'; ?>`);
33
+ * findSortablePhpStrings(`<?php $x = 'z a'; ?>`, islands); // [{ start: 11, end: 14 }]
34
+ */
35
+ export function findSortablePhpStrings(src, islands) {
36
+ const ranges = [];
37
+ for (const isl of islands) {
38
+ const tokens = tokenizeIsland(src, isl.start, isl.end);
39
+ for (let k = 0; k < tokens.length; k++) {
40
+ const token = tokens[k];
41
+ if (token.kind !== 'string' || token.skip)
42
+ continue;
43
+ const next = tokens[k + 1];
44
+ const prev = tokens[k - 1];
45
+ // Array key (`'left' => ...`) — never sorted.
46
+ if (next && next.kind === 'arrow')
47
+ continue;
48
+ // Part of a concatenation expression (`'btn-' . $v` / `$v . 'suffix'`).
49
+ if ((next && next.kind === 'dot') || (prev && prev.kind === 'dot'))
50
+ continue;
51
+ ranges.push({ start: token.start, end: token.end });
52
+ }
53
+ }
54
+ return ranges;
55
+ }
56
+ /**
57
+ * Tokenize one PHP island into the minimal token stream needed for value classification: string literals
58
+ * (with their inner range and a skip flag), the `=>` arrow, the `.` concatenation operator, and a coalesced `other`
59
+ * marker for everything else. Whitespace and comments are dropped so adjacency is judged across them.
60
+ */
61
+ function tokenizeIsland(src, start, end) {
62
+ const tokens = [];
63
+ const pushOther = () => {
64
+ if (tokens.length === 0 || tokens[tokens.length - 1].kind !== 'other')
65
+ tokens.push({ kind: 'other' });
66
+ };
67
+ let i = start;
68
+ while (i < end) {
69
+ const c = src[i];
70
+ // Whitespace.
71
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v') {
72
+ i++;
73
+ continue;
74
+ }
75
+ // Line comments (`//`, `#`) — but `#[` opens a PHP 8 attribute, which is code.
76
+ if (c === '/' && src[i + 1] === '/') {
77
+ i = skipLineComment(src, i + 2, end);
78
+ continue;
79
+ }
80
+ if (c === '#' && src[i + 1] !== '[') {
81
+ i = skipLineComment(src, i + 1, end);
82
+ continue;
83
+ }
84
+ // Block comment.
85
+ if (c === '/' && src[i + 1] === '*') {
86
+ const close = src.indexOf('*/', i + 2);
87
+ i = close === -1 || close + 2 > end ? end : close + 2;
88
+ continue;
89
+ }
90
+ // Heredoc / nowdoc — never harvested; skip the whole construct.
91
+ if (c === '<' && src[i + 1] === '<' && src[i + 2] === '<') {
92
+ const here = skipHeredoc(src, i + 3, end);
93
+ if (here !== -1) {
94
+ i = here;
95
+ pushOther();
96
+ continue;
97
+ }
98
+ }
99
+ // Quoted strings.
100
+ if (c === "'") {
101
+ const close = scanQuoted(src, i + 1, "'", end);
102
+ const inner = src.slice(i + 1, close);
103
+ tokens.push({ kind: 'string', start: i + 1, end: close, skip: inner.includes('\\') });
104
+ i = close + 1;
105
+ continue;
106
+ }
107
+ if (c === '"') {
108
+ const close = scanQuoted(src, i + 1, '"', end);
109
+ const inner = src.slice(i + 1, close);
110
+ // Skip interpolation (any unescaped `$`) and escapes.
111
+ tokens.push({ kind: 'string', start: i + 1, end: close, skip: hasInterpolationOrEscape(inner) });
112
+ i = close + 1;
113
+ continue;
114
+ }
115
+ if (c === '`') {
116
+ // Shell-exec string — never a class list.
117
+ i = scanQuoted(src, i + 1, '`', end) + 1;
118
+ pushOther();
119
+ continue;
120
+ }
121
+ // Operators that matter for classification.
122
+ if (c === '=' && src[i + 1] === '>') {
123
+ tokens.push({ kind: 'arrow' });
124
+ i += 2;
125
+ continue;
126
+ }
127
+ if (c === '.') {
128
+ tokens.push({ kind: 'dot' });
129
+ i++;
130
+ continue;
131
+ }
132
+ // Everything else (identifiers, punctuation, the `<?php`/`?>` tags themselves).
133
+ pushOther();
134
+ if (isIdentStart(c)) {
135
+ i++;
136
+ while (i < end && isIdent(src[i]))
137
+ i++;
138
+ }
139
+ else {
140
+ i++;
141
+ }
142
+ }
143
+ return tokens;
144
+ }
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
+ /**
175
+ * True if a double-quoted body contains interpolation (an unescaped `$`) or any escape sequence.
176
+ */
177
+ function hasInterpolationOrEscape(body) {
178
+ for (let i = 0; i < body.length; i++) {
179
+ const c = body[i];
180
+ if (c === '\\')
181
+ return true;
182
+ if (c === '$')
183
+ return true;
184
+ }
185
+ return false;
186
+ }
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
+ }