@cplieger/deadset-ts 8.0.0-dev.2 → 8.0.0-dev.20

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.
Files changed (402) hide show
  1. package/README.md +3 -1
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/dist/bin/mapper-connection.js +2 -1
  4. package/dist/bin/node-host.js +24 -5
  5. package/dist/src/alias-chain.js +2 -7
  6. package/dist/src/amd.js +81 -0
  7. package/dist/src/analysis.js +269 -432
  8. package/dist/src/augmentations.js +64 -3
  9. package/dist/src/automatic-types.js +10 -29
  10. package/dist/src/bindings.js +382 -0
  11. package/dist/src/calls.js +279 -43
  12. package/dist/src/commands/held.js +499 -0
  13. package/dist/src/commands/read.js +161 -0
  14. package/dist/src/commands/shell.js +169 -0
  15. package/dist/src/commands/tools.js +1847 -0
  16. package/dist/src/component-files.js +3 -2
  17. package/dist/src/component-layer.js +4 -2
  18. package/dist/src/config.js +6 -82
  19. package/dist/src/conformance-record.js +1 -1
  20. package/dist/src/consumers.js +10 -38
  21. package/dist/src/contextual-uses.js +57 -26
  22. package/dist/src/contract/config.generated.js +561 -0
  23. package/dist/src/contract/contract.generated.js +1369 -0
  24. package/dist/src/contract/report.generated.js +866 -0
  25. package/dist/src/dependencies.js +164 -121
  26. package/dist/src/destructuring.js +1 -19
  27. package/dist/src/edges.js +38 -122
  28. package/dist/src/element-access.js +181 -168
  29. package/dist/src/entries/build-scripts.js +222 -0
  30. package/dist/src/{configuration-files.js → entries/configuration-files.js} +52 -256
  31. package/dist/src/{convention-rows.js → entries/convention-rows.js} +170 -30
  32. package/dist/src/entries/conventions.js +740 -0
  33. package/dist/src/{html-entries.js → entries/html-entries.js} +12 -42
  34. package/dist/src/{mdx-entries.js → entries/mdx-entries.js} +5 -28
  35. package/dist/src/entries/run-directories.js +77 -0
  36. package/dist/src/entries/sources.js +144 -0
  37. package/dist/src/entries/static-module.js +670 -0
  38. package/dist/src/entries/test-runners.js +650 -0
  39. package/dist/src/entries/workflow-steps.js +345 -0
  40. package/dist/src/enum-group.js +3 -10
  41. package/dist/src/erased-stores.js +4 -6
  42. package/dist/src/exempt-classes.js +1 -63
  43. package/dist/src/exempt.js +21 -66
  44. package/dist/src/exposure.js +400 -0
  45. package/dist/src/extensions.js +37 -0
  46. package/dist/src/facts.js +38 -0
  47. package/dist/src/failure.js +36 -0
  48. package/dist/src/file-facts.js +3 -26
  49. package/dist/src/findings/completion.js +109 -56
  50. package/dist/src/findings/dependencies-and-module-machinery.js +4 -5
  51. package/dist/src/findings/emitters.js +27 -41
  52. package/dist/src/{implementations.js → findings/implementations.js} +3 -3
  53. package/dist/src/{in-operands.js → findings/in-operands.js} +3 -2
  54. package/dist/src/findings/interfaces.js +4 -5
  55. package/dist/src/findings/intra-function.js +16 -13
  56. package/dist/src/findings/non-code-artifacts.js +17 -12
  57. package/dist/src/findings/reads-and-writes.js +86 -27
  58. package/dist/src/findings/self-check.js +44 -30
  59. package/dist/src/findings/unused-declarations.js +29 -45
  60. package/dist/src/findings/visibility-narrowing.js +23 -31
  61. package/dist/src/findings-pass.js +13 -21
  62. package/dist/src/framework-lifecycle.js +46 -19
  63. package/dist/src/fs/document.js +52 -0
  64. package/dist/src/fs/manifest.js +67 -0
  65. package/dist/src/fs/tree.js +88 -0
  66. package/dist/src/generated-file.js +174 -28
  67. package/dist/src/git-ignore.js +39 -26
  68. package/dist/src/import-bindings.js +73 -28
  69. package/dist/src/index/sites.js +557 -0
  70. package/dist/src/index.js +2 -2
  71. package/dist/src/indexed-access.js +523 -478
  72. package/dist/src/injection-container.js +2 -2
  73. package/dist/src/interface-satisfaction.js +1 -1
  74. package/dist/src/intra-function-parts.js +63 -43
  75. package/dist/src/inventory/commonjs.js +507 -0
  76. package/dist/src/inventory/enumerate.js +167 -0
  77. package/dist/src/inventory/syntax.js +236 -0
  78. package/dist/src/inventory.js +323 -724
  79. package/dist/src/javascript-file.js +48 -0
  80. package/dist/src/json-document.js +198 -67
  81. package/dist/src/keyof-comparisons.js +126 -107
  82. package/dist/src/kinds.js +3 -366
  83. package/dist/src/{components.js → liveness/components.js} +2 -3
  84. package/dist/src/{graph.js → liveness/graph.js} +52 -28
  85. package/dist/src/liveness/matrix.js +99 -0
  86. package/dist/src/liveness/sweep.js +558 -0
  87. package/dist/src/load/classify.js +289 -0
  88. package/dist/src/load/diagnostics.js +87 -0
  89. package/dist/src/load/discover.js +285 -0
  90. package/dist/src/{emit-map.js → load/emit-map.js} +1 -1
  91. package/dist/src/load/expected-module.js +136 -0
  92. package/dist/src/load/load.js +27 -0
  93. package/dist/src/load/module-lookup.js +37 -0
  94. package/dist/src/load/package-scope.js +56 -0
  95. package/dist/src/load/plan.js +313 -0
  96. package/dist/src/load/specifiers.js +113 -0
  97. package/dist/src/load/synthesize.js +152 -0
  98. package/dist/src/load/test-globals.js +207 -0
  99. package/dist/src/load/test-joins.js +82 -0
  100. package/dist/src/{workspace-manifests.js → load/workspace-manifests.js} +4 -4
  101. package/dist/src/{workspace-programs.js → load/workspace-programs.js} +89 -66
  102. package/dist/src/{workspace-resolution.js → load/workspace-resolution.js} +24 -66
  103. package/dist/src/{workspace-run.js → load/workspace-run.js} +7 -7
  104. package/dist/src/{workspace.js → load/workspace.js} +62 -124
  105. package/dist/src/module-declarations.js +70 -85
  106. package/dist/src/overrides.js +134 -112
  107. package/dist/src/partial.js +2 -1
  108. package/dist/src/paths.js +82 -16
  109. package/dist/src/platform-calls.js +78 -0
  110. package/dist/src/platform-symbols.js +134 -0
  111. package/dist/src/position.js +43 -1
  112. package/dist/src/print.js +11 -79
  113. package/dist/src/product.js +13 -0
  114. package/dist/src/query.js +47 -0
  115. package/dist/src/ref.js +12 -20
  116. package/dist/src/references/read-rule.js +1 -0
  117. package/dist/src/references/read-rules.js +48 -0
  118. package/dist/src/references/sink.js +237 -0
  119. package/dist/src/references/sources.js +450 -0
  120. package/dist/src/references.js +117 -1074
  121. package/dist/src/report-schema.js +51 -0
  122. package/dist/src/report.js +57 -41
  123. package/dist/src/reporters/annotations.js +15 -8
  124. package/dist/src/reporters/sarif.js +16 -10
  125. package/dist/src/reporters/template.js +4 -19
  126. package/dist/src/resolve.js +246 -730
  127. package/dist/src/{configured-declarations.js → roots/configured-declarations.js} +22 -24
  128. package/dist/src/{entry-point-gaps.js → roots/entry-point-gaps.js} +6 -15
  129. package/dist/src/roots/manifest.js +216 -0
  130. package/dist/src/roots/published-reach.js +412 -0
  131. package/dist/src/roots/root-set.js +487 -0
  132. package/dist/src/roots/unseen-loads.js +236 -0
  133. package/dist/src/run.js +66 -77
  134. package/dist/src/schema.js +320 -343
  135. package/dist/src/scope.js +38 -115
  136. package/dist/src/serialization-contract.js +601 -22
  137. package/dist/src/session.js +37 -29
  138. package/dist/src/source-files.js +6 -10
  139. package/dist/src/stores.js +4 -2
  140. package/dist/src/suppress-file.js +25 -28
  141. package/dist/src/suppress-inline.js +36 -83
  142. package/dist/src/suppress.js +50 -71
  143. package/dist/src/symbol-keys.js +92 -88
  144. package/dist/src/template-field.js +9 -19
  145. package/dist/src/tests/file-class.js +76 -0
  146. package/dist/src/{outside-tests.js → tests/outside.js} +7 -7
  147. package/dist/src/{test-support.js → tests/support.js} +5 -12
  148. package/dist/src/text/compare.js +26 -0
  149. package/dist/src/text/glob.js +194 -0
  150. package/dist/src/text/named-files.js +298 -0
  151. package/dist/src/text/yaml.js +684 -0
  152. package/dist/src/type-errors.js +11 -232
  153. package/dist/src/type-parameter-reads.js +178 -139
  154. package/dist/src/unimplemented-callees.js +262 -242
  155. package/dist/src/unwrap.js +23 -0
  156. package/dist/src/usage.js +10 -0
  157. package/dist/src/vendor/unbash/ansi-c.js +132 -0
  158. package/dist/src/vendor/unbash/arithmetic-parts.js +37 -0
  159. package/dist/src/vendor/unbash/arithmetic.js +591 -0
  160. package/dist/src/vendor/unbash/assignment.js +131 -0
  161. package/dist/src/vendor/unbash/chars.js +48 -0
  162. package/dist/src/vendor/unbash/command.js +41 -0
  163. package/dist/src/vendor/unbash/expansion.js +80 -0
  164. package/dist/src/vendor/unbash/glob.js +268 -0
  165. package/dist/src/vendor/unbash/heredoc.js +27 -0
  166. package/dist/src/vendor/unbash/internal-types.js +1 -0
  167. package/dist/src/vendor/unbash/lexer.js +4274 -0
  168. package/dist/src/vendor/unbash/limits.js +3 -0
  169. package/dist/src/vendor/unbash/parser.js +1366 -0
  170. package/dist/src/vendor/unbash/parts.js +57 -0
  171. package/dist/src/vendor/unbash/substitution-context.js +8 -0
  172. package/dist/src/vendor/unbash/traversal.js +212 -0
  173. package/dist/src/vendor/unbash/types.js +1 -0
  174. package/dist/src/vendor/unbash/word-value.js +61 -0
  175. package/dist/src/vendor/unbash/word.js +40 -0
  176. package/dist/src/verbs/analyze.js +194 -127
  177. package/dist/src/verbs/describe.js +13 -18
  178. package/dist/src/verbs/explain.js +85 -139
  179. package/dist/src/verbs/print-config.js +2 -1
  180. package/dist/src/verbs/print-projects.js +6 -4
  181. package/dist/src/verbs/print-retained.js +7 -5
  182. package/dist/src/verbs/print-roots.js +4 -2
  183. package/dist/src/verbs/verb.js +1 -5
  184. package/dist/src/verbs/version.js +14 -3
  185. package/jsr.json +2 -1
  186. package/package.json +14 -8
  187. package/src/alias-chain.ts +2 -9
  188. package/src/amd.ts +130 -0
  189. package/src/analysis.ts +424 -650
  190. package/src/augmentations.ts +93 -4
  191. package/src/automatic-types.ts +12 -30
  192. package/src/bindings.ts +460 -0
  193. package/src/calls.ts +338 -46
  194. package/src/commands/held.ts +632 -0
  195. package/src/commands/read.ts +242 -0
  196. package/src/commands/shell.ts +228 -0
  197. package/src/commands/tools.ts +2382 -0
  198. package/src/component-files.ts +4 -2
  199. package/src/component-layer.ts +4 -2
  200. package/src/config.ts +31 -302
  201. package/src/conformance-record.ts +1 -1
  202. package/src/consumers.ts +16 -66
  203. package/src/contextual-uses.ts +66 -34
  204. package/src/contract/config.generated.ts +821 -0
  205. package/src/contract/contract.generated.ts +1452 -0
  206. package/src/contract/report.generated.ts +1241 -0
  207. package/src/dependencies.ts +222 -127
  208. package/src/destructuring.ts +1 -31
  209. package/src/edges.ts +41 -140
  210. package/src/element-access.ts +190 -187
  211. package/src/entries/build-scripts.ts +292 -0
  212. package/src/{configuration-files.ts → entries/configuration-files.ts} +68 -278
  213. package/src/{convention-rows.ts → entries/convention-rows.ts} +227 -31
  214. package/src/{conventions.ts → entries/conventions.ts} +367 -583
  215. package/src/{html-entries.ts → entries/html-entries.ts} +22 -48
  216. package/src/{mdx-entries.ts → entries/mdx-entries.ts} +7 -30
  217. package/src/entries/run-directories.ts +108 -0
  218. package/src/entries/sources.ts +266 -0
  219. package/src/entries/static-module.ts +858 -0
  220. package/src/entries/test-runners.ts +835 -0
  221. package/src/entries/workflow-steps.ts +432 -0
  222. package/src/enum-group.ts +2 -11
  223. package/src/erased-stores.ts +3 -7
  224. package/src/exempt-classes.ts +1 -75
  225. package/src/exempt.ts +70 -98
  226. package/src/exposure.ts +492 -0
  227. package/src/extensions.ts +45 -0
  228. package/src/facts.ts +46 -0
  229. package/src/failure.ts +63 -0
  230. package/src/file-facts.ts +15 -25
  231. package/src/finding.ts +10 -10
  232. package/src/findings/boundary.ts +11 -0
  233. package/src/findings/completion.ts +133 -64
  234. package/src/findings/dependencies-and-module-machinery.ts +4 -7
  235. package/src/findings/emitter.ts +9 -10
  236. package/src/findings/emitters.ts +38 -52
  237. package/src/{implementations.ts → findings/implementations.ts} +4 -4
  238. package/src/{in-operands.ts → findings/in-operands.ts} +4 -3
  239. package/src/findings/interfaces.ts +7 -9
  240. package/src/findings/intra-function.ts +16 -14
  241. package/src/findings/non-code-artifacts.ts +24 -15
  242. package/src/findings/reads-and-writes.ts +118 -41
  243. package/src/findings/self-check.ts +51 -38
  244. package/src/findings/unused-declarations.ts +31 -49
  245. package/src/findings/visibility-narrowing.ts +36 -40
  246. package/src/findings-pass.ts +15 -31
  247. package/src/framework-lifecycle.ts +47 -19
  248. package/src/fs/document.ts +70 -0
  249. package/src/fs/manifest.ts +96 -0
  250. package/src/fs/tree.ts +110 -0
  251. package/src/generated-file.ts +242 -35
  252. package/src/git-ignore.ts +50 -26
  253. package/src/host.ts +12 -5
  254. package/src/import-bindings.ts +98 -29
  255. package/src/index/sites.ts +781 -0
  256. package/src/index.ts +2 -2
  257. package/src/indexed-access.ts +570 -527
  258. package/src/injection-container.ts +2 -2
  259. package/src/interface-satisfaction.ts +2 -2
  260. package/src/intra-function-parts.ts +73 -44
  261. package/src/inventory/commonjs.ts +696 -0
  262. package/src/inventory/enumerate.ts +203 -0
  263. package/src/inventory/syntax.ts +311 -0
  264. package/src/inventory.ts +442 -895
  265. package/src/javascript-file.ts +54 -0
  266. package/src/json-document.ts +241 -76
  267. package/src/keyof-comparisons.ts +133 -122
  268. package/src/kinds.ts +5 -413
  269. package/src/{components.ts → liveness/components.ts} +4 -6
  270. package/src/{graph.ts → liveness/graph.ts} +100 -35
  271. package/src/liveness/matrix.ts +162 -0
  272. package/src/liveness/sweep.ts +855 -0
  273. package/src/load/classify.ts +433 -0
  274. package/src/load/diagnostics.ts +154 -0
  275. package/src/load/discover.ts +471 -0
  276. package/src/{emit-map.ts → load/emit-map.ts} +1 -1
  277. package/src/load/expected-module.ts +194 -0
  278. package/src/load/load.ts +73 -0
  279. package/src/load/module-lookup.ts +50 -0
  280. package/src/load/package-scope.ts +74 -0
  281. package/src/load/plan.ts +513 -0
  282. package/src/load/specifiers.ts +171 -0
  283. package/src/load/synthesize.ts +230 -0
  284. package/src/load/test-globals.ts +251 -0
  285. package/src/load/test-joins.ts +98 -0
  286. package/src/{workspace-manifests.ts → load/workspace-manifests.ts} +5 -5
  287. package/src/{workspace-programs.ts → load/workspace-programs.ts} +128 -85
  288. package/src/{workspace-resolution.ts → load/workspace-resolution.ts} +41 -77
  289. package/src/{workspace-run.ts → load/workspace-run.ts} +18 -16
  290. package/src/{workspace.ts → load/workspace.ts} +90 -134
  291. package/src/module-declarations.ts +74 -98
  292. package/src/overrides.ts +162 -134
  293. package/src/partial.ts +3 -2
  294. package/src/paths.ts +92 -16
  295. package/src/platform-calls.ts +119 -0
  296. package/src/platform-symbols.ts +189 -0
  297. package/src/position.ts +44 -1
  298. package/src/print.ts +12 -92
  299. package/src/product.ts +18 -0
  300. package/src/query.ts +92 -0
  301. package/src/ref.ts +12 -20
  302. package/src/references/read-rule.ts +60 -0
  303. package/src/references/read-rules.ts +55 -0
  304. package/src/references/sink.ts +314 -0
  305. package/src/references/sources.ts +632 -0
  306. package/src/references.ts +187 -1450
  307. package/src/report-schema.ts +68 -0
  308. package/src/report.ts +103 -226
  309. package/src/reporters/annotations.ts +22 -11
  310. package/src/reporters/json.ts +1 -1
  311. package/src/reporters/reporter.ts +2 -4
  312. package/src/reporters/reporters.ts +1 -1
  313. package/src/reporters/sarif.ts +29 -16
  314. package/src/reporters/template.ts +4 -20
  315. package/src/reporters/text.ts +3 -3
  316. package/src/resolve.ts +284 -1115
  317. package/src/{configured-declarations.ts → roots/configured-declarations.ts} +27 -29
  318. package/src/{entry-point-gaps.ts → roots/entry-point-gaps.ts} +7 -16
  319. package/src/roots/manifest.ts +303 -0
  320. package/src/roots/published-reach.ts +486 -0
  321. package/src/{roots.ts → roots/root-set.ts} +286 -469
  322. package/src/roots/unseen-loads.ts +345 -0
  323. package/src/run.ts +75 -91
  324. package/src/schema.ts +391 -388
  325. package/src/scope.ts +46 -138
  326. package/src/serialization-contract.ts +769 -23
  327. package/src/session.ts +87 -55
  328. package/src/source-files.ts +6 -11
  329. package/src/stores.ts +15 -3
  330. package/src/suppress-file.ts +24 -33
  331. package/src/suppress-inline.ts +55 -91
  332. package/src/suppress.ts +78 -79
  333. package/src/symbol-keys.ts +104 -107
  334. package/src/template-field.ts +15 -25
  335. package/src/tests/file-class.ts +135 -0
  336. package/src/{outside-tests.ts → tests/outside.ts} +16 -10
  337. package/src/{test-support.ts → tests/support.ts} +15 -21
  338. package/src/text/compare.ts +27 -0
  339. package/src/text/glob.ts +210 -0
  340. package/src/text/named-files.ts +388 -0
  341. package/src/text/yaml.ts +747 -0
  342. package/src/type-errors.ts +18 -288
  343. package/src/type-parameter-reads.ts +187 -158
  344. package/src/unimplemented-callees.ts +297 -280
  345. package/src/unwrap.ts +34 -0
  346. package/src/usage.ts +10 -0
  347. package/src/vendor/unbash/LICENSE +15 -0
  348. package/src/vendor/unbash/VENDORED.md +16 -0
  349. package/src/vendor/unbash/ansi-c.ts +132 -0
  350. package/src/vendor/unbash/arithmetic-parts.ts +53 -0
  351. package/src/vendor/unbash/arithmetic.ts +641 -0
  352. package/src/vendor/unbash/assignment.ts +163 -0
  353. package/src/vendor/unbash/chars.ts +48 -0
  354. package/src/vendor/unbash/command.ts +47 -0
  355. package/src/vendor/unbash/expansion.ts +144 -0
  356. package/src/vendor/unbash/glob.ts +284 -0
  357. package/src/vendor/unbash/heredoc.ts +34 -0
  358. package/src/vendor/unbash/internal-types.ts +94 -0
  359. package/src/vendor/unbash/lexer.ts +4352 -0
  360. package/src/vendor/unbash/limits.ts +3 -0
  361. package/src/vendor/unbash/parser.ts +1612 -0
  362. package/src/vendor/unbash/parts.ts +96 -0
  363. package/src/vendor/unbash/substitution-context.ts +20 -0
  364. package/src/vendor/unbash/traversal.ts +182 -0
  365. package/src/vendor/unbash/types.ts +660 -0
  366. package/src/vendor/unbash/word-value.ts +64 -0
  367. package/src/vendor/unbash/word.ts +50 -0
  368. package/src/verbs/analyze.ts +255 -164
  369. package/src/verbs/describe.ts +19 -17
  370. package/src/verbs/explain.ts +93 -169
  371. package/src/verbs/print-config.ts +2 -1
  372. package/src/verbs/print-projects.ts +6 -4
  373. package/src/verbs/print-retained.ts +7 -5
  374. package/src/verbs/print-roots.ts +4 -2
  375. package/src/verbs/verb.ts +2 -8
  376. package/src/verbs/version.ts +15 -3
  377. package/dist/src/base-url.js +0 -18
  378. package/dist/src/conventions.js +0 -937
  379. package/dist/src/discover.js +0 -468
  380. package/dist/src/entry-points.js +0 -529
  381. package/dist/src/glob.js +0 -65
  382. package/dist/src/manifest.js +0 -165
  383. package/dist/src/matrix.js +0 -246
  384. package/dist/src/pnpm-workspace.js +0 -120
  385. package/dist/src/roots.js +0 -672
  386. package/dist/src/setup-failure.js +0 -23
  387. package/dist/src/sweep.js +0 -224
  388. package/dist/src/test-joins.js +0 -101
  389. package/dist/src/version.js +0 -7
  390. package/dist/src/workflow-steps.js +0 -185
  391. package/src/base-url.ts +0 -24
  392. package/src/discover.ts +0 -653
  393. package/src/entry-points.ts +0 -687
  394. package/src/glob.ts +0 -67
  395. package/src/manifest.ts +0 -234
  396. package/src/matrix.ts +0 -423
  397. package/src/pnpm-workspace.ts +0 -135
  398. package/src/setup-failure.ts +0 -39
  399. package/src/sweep.ts +0 -347
  400. package/src/test-joins.ts +0 -120
  401. package/src/version.ts +0 -7
  402. package/src/workflow-steps.ts +0 -216
@@ -0,0 +1,4352 @@
1
+ // Code generated by scripts/vendor-unbash.ts from unbash 6.0.0 (968c14992a43dc06ce29c9f51858abd4a87851ea). DO NOT EDIT.
2
+ // @ts-nocheck
3
+ // oxlint-disable unicorn/no-thenable
4
+ import type {
5
+ DoubleQuotedChild,
6
+ ExtGlobOperator,
7
+ HereDoc,
8
+ ParameterExpansionPart,
9
+ ParseError,
10
+ Word,
11
+ WordPart,
12
+ } from "./internal-types.ts";
13
+ import { decodeAnsiCQuoted } from "./ansi-c.ts";
14
+ import {
15
+ ArithmeticExpansionImpl,
16
+ ProcessSubstitutionImpl,
17
+ ScriptExpansionImpl,
18
+ type ScriptWindow,
19
+ } from "./expansion.ts";
20
+ import { MAX_SYNTAX_NESTING } from "./limits.ts";
21
+ import { WordImpl } from "./word.ts";
22
+ import { createSubstitutionContext, type SubstitutionContext } from "./substitution-context.ts";
23
+ import { hasGlobCharacters, parseGlobParts } from "./glob.ts";
24
+ import {
25
+ skipLineContinuations,
26
+ CH_TAB,
27
+ CH_NL,
28
+ CH_SPACE,
29
+ CH_BANG,
30
+ CH_DQUOTE,
31
+ CH_HASH,
32
+ CH_DOLLAR,
33
+ CH_PERCENT,
34
+ CH_AMP,
35
+ CH_SQUOTE,
36
+ CH_LPAREN,
37
+ CH_RPAREN,
38
+ CH_STAR,
39
+ CH_PLUS,
40
+ CH_COMMA,
41
+ CH_DASH,
42
+ CH_SLASH,
43
+ CH_0,
44
+ CH_9,
45
+ CH_COLON,
46
+ CH_SEMI,
47
+ CH_LT,
48
+ CH_EQ,
49
+ CH_GT,
50
+ CH_QUESTION,
51
+ CH_AT,
52
+ CH_A,
53
+ CH_Z,
54
+ CH_LBRACKET,
55
+ CH_BACKSLASH,
56
+ CH_RBRACKET,
57
+ CH_CARET,
58
+ CH_UNDERSCORE,
59
+ CH_BACKTICK,
60
+ CH_a,
61
+ CH_z,
62
+ CH_LBRACE,
63
+ CH_PIPE,
64
+ CH_RBRACE,
65
+ } from "./chars.ts";
66
+
67
+ type SubFieldContext = "word" | "quoted" | "embedded";
68
+
69
+ export const Token = {
70
+ Word: 0,
71
+ Assignment: 1,
72
+ Semi: 2,
73
+ Newline: 3,
74
+ Pipe: 4,
75
+ And: 5,
76
+ Or: 6,
77
+ Amp: 7,
78
+ LParen: 8,
79
+ RParen: 9,
80
+ LBrace: 10,
81
+ RBrace: 11,
82
+ Bang: 12,
83
+ If: 13,
84
+ Then: 14,
85
+ Else: 15,
86
+ Elif: 16,
87
+ Fi: 17,
88
+ Do: 18,
89
+ Done: 19,
90
+ For: 20,
91
+ While: 21,
92
+ Until: 22,
93
+ In: 23,
94
+ Case: 24,
95
+ Esac: 25,
96
+ Function: 26,
97
+ DoubleSemi: 27,
98
+ SemiAmp: 28,
99
+ DoubleSemiAmp: 29,
100
+ Select: 30,
101
+ DblLBracket: 31,
102
+ DblRBracket: 32,
103
+ EOF: 33,
104
+ ArithCmd: 34,
105
+ Coproc: 35,
106
+ Redirect: 36,
107
+ } as const;
108
+
109
+ export type Token = (typeof Token)[keyof typeof Token];
110
+
111
+ export class TokenValue {
112
+ token: Token = Token.EOF;
113
+ // Materialized token value, or null for word tokens whose value has not been
114
+ // requested yet (computed from [pos, end) on demand via the owning lexer).
115
+ _value: string | null = "";
116
+ _owner: Lexer | null;
117
+ pos: number = 0;
118
+ end: number = 0;
119
+ fileDescriptor: number | undefined = undefined;
120
+ variableName: string | undefined = undefined;
121
+ descriptorEnd = 0;
122
+ delimiterQuoted = false;
123
+ content: string | undefined = undefined;
124
+ targetPos = 0;
125
+ targetEnd = 0;
126
+ assignmentOperatorPos = -1;
127
+ // True when `value` is exactly the raw source span [pos, end) — lets consumers
128
+ // reuse the string instead of slicing the source again.
129
+ raw = false;
130
+ // True when a word contains no quoting, escaped characters, or expansions.
131
+ // Backslash-newline continuations preserve keyword eligibility.
132
+ keywordEligible = false;
133
+
134
+ constructor(owner: Lexer | null = null) {
135
+ this._owner = owner;
136
+ }
137
+
138
+ get value(): string {
139
+ return (
140
+ this._value ?? (this._value = this._owner === null ? "" : this._owner._tokenValue(this.pos, this.end, this.raw))
141
+ );
142
+ }
143
+ set value(v: string) {
144
+ this._value = v;
145
+ }
146
+
147
+ reset(): void {
148
+ this.token = Token.EOF;
149
+ this._value = "";
150
+ this.pos = 0;
151
+ this.end = 0;
152
+ this.fileDescriptor = undefined;
153
+ this.variableName = undefined;
154
+ this.descriptorEnd = 0;
155
+ this.delimiterQuoted = false;
156
+ this.content = undefined;
157
+ this.targetPos = 0;
158
+ this.targetEnd = 0;
159
+ this.assignmentOperatorPos = -1;
160
+ this.raw = false;
161
+ this.keywordEligible = false;
162
+ }
163
+
164
+ copyFrom(other: TokenValue): void {
165
+ this.token = other.token;
166
+ this._value = other._value;
167
+ this.pos = other.pos;
168
+ this.end = other.end;
169
+ this.fileDescriptor = other.fileDescriptor;
170
+ this.variableName = other.variableName;
171
+ this.descriptorEnd = other.descriptorEnd;
172
+ this.delimiterQuoted = other.delimiterQuoted;
173
+ this.content = other.content;
174
+ this.targetPos = other.targetPos;
175
+ this.targetEnd = other.targetEnd;
176
+ this.assignmentOperatorPos = other.assignmentOperatorPos;
177
+ this.raw = other.raw;
178
+ this.keywordEligible = other.keywordEligible;
179
+ }
180
+ }
181
+
182
+ const RESERVED_WORDS = new Map<string, Token>([
183
+ ["if", Token.If],
184
+ ["then", Token.Then],
185
+ ["else", Token.Else],
186
+ ["elif", Token.Elif],
187
+ ["fi", Token.Fi],
188
+ ["do", Token.Do],
189
+ ["done", Token.Done],
190
+ ["for", Token.For],
191
+ ["while", Token.While],
192
+ ["until", Token.Until],
193
+ ["in", Token.In],
194
+ ["case", Token.Case],
195
+ ["esac", Token.Esac],
196
+ ["function", Token.Function],
197
+ ["select", Token.Select],
198
+ ["coproc", Token.Coproc],
199
+ ["!", Token.Bang],
200
+ ["{", Token.LBrace],
201
+ ["}", Token.RBrace],
202
+ ]);
203
+
204
+ // `name()` is a syntax error for these; `time`, `[[` and `]]` are matched positionally
205
+ // rather than through RESERVED_WORDS, and a name spelled like an assignment reads as a prefix.
206
+ export function requiresFunctionKeyword(name: string): boolean {
207
+ return RESERVED_WORDS.has(name) || name === "time" || name === "[[" || name === "]]" || name.includes("=");
208
+ }
209
+
210
+ // Combined character type table — bit 0: metachar, bit 1: word-special
211
+ const charType = new Uint8Array(128);
212
+ charType[CH_PIPE] = 1;
213
+ charType[CH_AMP] = 1;
214
+ charType[CH_SEMI] = 1;
215
+ charType[CH_LPAREN] = 1;
216
+ charType[CH_RPAREN] = 1;
217
+ charType[CH_LT] = 1;
218
+ charType[CH_GT] = 1;
219
+ charType[CH_SPACE] = 1;
220
+ charType[CH_TAB] = 1;
221
+ charType[CH_NL] = 1;
222
+ charType[CH_BACKSLASH] = 2;
223
+ charType[CH_SQUOTE] = 2;
224
+ charType[CH_DQUOTE] = 2;
225
+ charType[CH_DOLLAR] = 2;
226
+ charType[CH_BACKTICK] = 2;
227
+ charType[CH_LBRACE] = 2;
228
+
229
+ function skipBlanks(source: string, pos: number, end: number): number {
230
+ while (pos < end) {
231
+ const ch = source.charCodeAt(pos);
232
+ if (ch === CH_SPACE || ch === CH_TAB) pos++;
233
+ else if (ch === CH_BACKSLASH && pos + 1 < end && source.charCodeAt(pos + 1) === CH_NL) pos += 2;
234
+ else break;
235
+ }
236
+ return pos;
237
+ }
238
+
239
+ const arithmeticWordDelimiter = new Uint8Array(128);
240
+ for (const ch of [
241
+ CH_TAB,
242
+ CH_NL,
243
+ CH_SPACE,
244
+ CH_BANG,
245
+ CH_PERCENT,
246
+ CH_AMP,
247
+ CH_LPAREN,
248
+ CH_RPAREN,
249
+ CH_STAR,
250
+ CH_PLUS,
251
+ CH_COMMA,
252
+ CH_DASH,
253
+ CH_SLASH,
254
+ CH_COLON,
255
+ CH_LT,
256
+ CH_EQ,
257
+ CH_GT,
258
+ CH_QUESTION,
259
+ CH_CARET,
260
+ CH_PIPE,
261
+ ]) {
262
+ arithmeticWordDelimiter[ch] = 1;
263
+ }
264
+
265
+ export function hasEmbeddedWordStructure(source: string, start: number, end: number): boolean {
266
+ for (let pos = start; pos < end; pos++) {
267
+ const ch = source.charCodeAt(pos);
268
+ if (
269
+ ch === CH_BACKSLASH ||
270
+ ch === CH_SQUOTE ||
271
+ ch === CH_DQUOTE ||
272
+ ch === CH_DOLLAR ||
273
+ ch === CH_BACKTICK ||
274
+ ((ch === CH_LT || ch === CH_GT) && pos + 1 < end && source.charCodeAt(pos + 1) === CH_LPAREN)
275
+ ) {
276
+ return true;
277
+ }
278
+ }
279
+ return false;
280
+ }
281
+
282
+ function findUnnested(
283
+ s: string,
284
+ target: number,
285
+ pairTernaries = false,
286
+ findNestedEnd?: (index: number, quoted: boolean) => number,
287
+ ): number {
288
+ let depth = 0;
289
+ let ternaryDepth = 0;
290
+ for (let i = 0; i < s.length; i++) {
291
+ const c = s.charCodeAt(i);
292
+ if (c === CH_BACKSLASH) {
293
+ i++;
294
+ continue;
295
+ }
296
+ if (c === CH_LBRACE) {
297
+ depth++;
298
+ continue;
299
+ }
300
+ if (c === CH_RBRACE) {
301
+ if (depth > 0) depth--;
302
+ continue;
303
+ }
304
+ if (c === CH_SQUOTE) {
305
+ i++;
306
+ while (i < s.length && s.charCodeAt(i) !== CH_SQUOTE) i++;
307
+ continue;
308
+ }
309
+ if (c === CH_DQUOTE) {
310
+ i++;
311
+ while (i < s.length) {
312
+ const quoted = s.charCodeAt(i);
313
+ if (quoted === CH_DQUOTE) break;
314
+ if (quoted === CH_BACKSLASH) {
315
+ i += 2;
316
+ continue;
317
+ }
318
+ const nestedEnd = findNestedEnd?.(i, true) ?? i;
319
+ if (nestedEnd > i) {
320
+ i = nestedEnd;
321
+ continue;
322
+ }
323
+ i++;
324
+ }
325
+ continue;
326
+ }
327
+ const nestedEnd = findNestedEnd?.(i, false) ?? i;
328
+ if (nestedEnd > i) {
329
+ i = nestedEnd - 1;
330
+ continue;
331
+ }
332
+ if (pairTernaries && depth === 0) {
333
+ if (c === CH_QUESTION) {
334
+ ternaryDepth++;
335
+ continue;
336
+ }
337
+ if (c === CH_COLON && ternaryDepth > 0) {
338
+ ternaryDepth--;
339
+ continue;
340
+ }
341
+ }
342
+ if (c === target && depth === 0) return i;
343
+ }
344
+ return -1;
345
+ }
346
+
347
+ // Lookup: identifier chars (a-z, A-Z, 0-9, _) — bit 0: start, bit 1: continue
348
+ const isIdChar = new Uint8Array(128);
349
+ for (let i = CH_a; i <= CH_z; i++) isIdChar[i] = 3;
350
+ for (let i = CH_A; i <= CH_Z; i++) isIdChar[i] = 3;
351
+ for (let i = CH_0; i <= CH_9; i++) isIdChar[i] = 2;
352
+ isIdChar[CH_UNDERSCORE] = 3;
353
+
354
+ const extglobPrefix = new Uint8Array(128);
355
+ extglobPrefix[CH_QUESTION] = 1;
356
+ extglobPrefix[CH_AT] = 1;
357
+ extglobPrefix[CH_STAR] = 1;
358
+ extglobPrefix[CH_PLUS] = 1;
359
+ extglobPrefix[CH_BANG] = 1;
360
+ extglobPrefix[CH_EQ] = 1;
361
+
362
+ // `=(` opens an array value, not a pattern.
363
+ function isExtglobOpen(prefix: number): boolean {
364
+ return prefix < 128 && prefix !== CH_EQ && extglobPrefix[prefix] === 1;
365
+ }
366
+
367
+ const extglobOp: Record<number, ExtGlobOperator> = {
368
+ [CH_QUESTION]: "?",
369
+ [CH_AT]: "@",
370
+ [CH_STAR]: "*",
371
+ [CH_PLUS]: "+",
372
+ [CH_BANG]: "!",
373
+ };
374
+
375
+ function isDQChild(p: WordPart): p is DoubleQuotedChild {
376
+ const t = p.type;
377
+ return (
378
+ t === "Literal" ||
379
+ t === "SimpleExpansion" ||
380
+ t === "ParameterExpansion" ||
381
+ t === "CommandExpansion" ||
382
+ t === "ArithmeticExpansion"
383
+ );
384
+ }
385
+
386
+ function isAllDigitsRange(src: string, start: number, end: number): boolean {
387
+ for (let i = start; i < end; i++) {
388
+ const c = src.charCodeAt(i);
389
+ if (c < CH_0 || c > CH_9) return false;
390
+ }
391
+ return end > start;
392
+ }
393
+
394
+ const ASSIGNMENT_INVALID = -1;
395
+ const ASSIGNMENT_NAME_START = 0;
396
+ const ASSIGNMENT_NAME = 1;
397
+ const ASSIGNMENT_AFTER_INDEX = 2;
398
+ const ASSIGNMENT_AFTER_PLUS = 3;
399
+ const ASSIGNMENT_INDEX_BASE = 4;
400
+
401
+ function joinWordParts(head: WordPart[], tail: WordPart[]): WordPart[] {
402
+ const last = head[head.length - 1];
403
+ const first = tail[0];
404
+ let start = 0;
405
+ if (last?.type === "Literal" && first?.type === "Literal" && last.end === first.pos) {
406
+ head[head.length - 1] = {
407
+ type: "Literal",
408
+ pos: last.pos,
409
+ end: first.end,
410
+ text: last.text + first.text,
411
+ value: last.value + first.value,
412
+ };
413
+ start = 1;
414
+ }
415
+ for (let i = start; i < tail.length; i++) head.push(tail[i]);
416
+ return head;
417
+ }
418
+
419
+ function isMatchedAssignment(state: number): boolean {
420
+ return state < ASSIGNMENT_INVALID;
421
+ }
422
+
423
+ function assignmentOperatorPos(state: number): number {
424
+ return -state - 2;
425
+ }
426
+
427
+ function scanAssignmentPrefix(src: string, start: number, end: number, initialState: number): number {
428
+ let state = initialState;
429
+ for (let i = start; i < end && state >= 0; i++) {
430
+ const c = src.charCodeAt(i);
431
+ if (state >= ASSIGNMENT_INDEX_BASE) {
432
+ if (c === CH_LBRACKET) state++;
433
+ else if (c === CH_RBRACKET && --state === ASSIGNMENT_INDEX_BASE) state = ASSIGNMENT_AFTER_INDEX;
434
+ } else if (state === ASSIGNMENT_NAME_START) {
435
+ state = c < 128 && isIdChar[c] & 1 ? ASSIGNMENT_NAME : ASSIGNMENT_INVALID;
436
+ } else if (state === ASSIGNMENT_NAME) {
437
+ if (c < 128 && isIdChar[c] & 2) continue;
438
+ if (c === CH_LBRACKET) state = ASSIGNMENT_INDEX_BASE + 1;
439
+ else if (c === CH_PLUS) state = ASSIGNMENT_AFTER_PLUS;
440
+ else state = c === CH_EQ ? -i - 2 : ASSIGNMENT_INVALID;
441
+ } else if (state === ASSIGNMENT_AFTER_INDEX) {
442
+ if (c === CH_PLUS) state = ASSIGNMENT_AFTER_PLUS;
443
+ else state = c === CH_EQ ? -i - 2 : ASSIGNMENT_INVALID;
444
+ } else {
445
+ state = c === CH_EQ ? -i - 2 : ASSIGNMENT_INVALID;
446
+ }
447
+ }
448
+ return state;
449
+ }
450
+
451
+ interface PendingHereDoc {
452
+ delimiter: string;
453
+ strip: boolean;
454
+ quoted: boolean;
455
+ target?: HereDoc;
456
+ }
457
+
458
+ function setToken(out: TokenValue, token: Token, value: string, pos: number = 0, end: number = 0): void {
459
+ out.token = token;
460
+ out._value = value;
461
+ out.pos = pos;
462
+ out.end = end;
463
+ out.fileDescriptor = undefined;
464
+ out.variableName = undefined;
465
+ out.descriptorEnd = 0;
466
+ out.delimiterQuoted = false;
467
+ out.content = undefined;
468
+ out.assignmentOperatorPos = -1;
469
+ out.raw = false;
470
+ out.keywordEligible = false;
471
+ }
472
+
473
+ // Word token over [pos, end) whose value materializes on first access.
474
+ function setSpanToken(out: TokenValue, token: Token, pos: number, end: number, raw: boolean): void {
475
+ out.token = token;
476
+ out._value = null;
477
+ out.pos = pos;
478
+ out.end = end;
479
+ out.fileDescriptor = undefined;
480
+ out.variableName = undefined;
481
+ out.descriptorEnd = 0;
482
+ out.delimiterQuoted = false;
483
+ out.content = undefined;
484
+ out.assignmentOperatorPos = -1;
485
+ out.raw = raw;
486
+ out.keywordEligible = false;
487
+ }
488
+
489
+ // Positional reserved-word match over src[start, start+len) — no slice. Mirrors
490
+ // RESERVED_WORDS; first-char dispatch plus length checks reject fast.
491
+ function matchReservedWord(src: string, start: number, len: number): Token | undefined {
492
+ switch (src.charCodeAt(start)) {
493
+ case CH_BANG:
494
+ return len === 1 ? Token.Bang : undefined;
495
+ case CH_LBRACE:
496
+ return len === 1 ? Token.LBrace : undefined;
497
+ case CH_RBRACE:
498
+ return len === 1 ? Token.RBrace : undefined;
499
+ case 0x69 /* i */: {
500
+ if (len !== 2) return undefined;
501
+ const c = src.charCodeAt(start + 1);
502
+ return c === 0x66 /* f */ ? Token.If : c === 0x6e /* n */ ? Token.In : undefined;
503
+ }
504
+ case 0x66 /* f */:
505
+ if (len === 2) return src.charCodeAt(start + 1) === 0x69 /* i */ ? Token.Fi : undefined;
506
+ if (len === 3) return src.startsWith("for", start) ? Token.For : undefined;
507
+ if (len === 8) return src.startsWith("function", start) ? Token.Function : undefined;
508
+ return undefined;
509
+ case 0x74 /* t */:
510
+ return len === 4 && src.startsWith("then", start) ? Token.Then : undefined;
511
+ case 0x65 /* e */:
512
+ if (len !== 4) return undefined;
513
+ if (src.startsWith("else", start)) return Token.Else;
514
+ if (src.startsWith("elif", start)) return Token.Elif;
515
+ if (src.startsWith("esac", start)) return Token.Esac;
516
+ return undefined;
517
+ case 0x64 /* d */:
518
+ if (len === 2) return src.charCodeAt(start + 1) === 0x6f /* o */ ? Token.Do : undefined;
519
+ if (len === 4) return src.startsWith("done", start) ? Token.Done : undefined;
520
+ return undefined;
521
+ case 0x63 /* c */:
522
+ if (len === 4) return src.startsWith("case", start) ? Token.Case : undefined;
523
+ if (len === 6) return src.startsWith("coproc", start) ? Token.Coproc : undefined;
524
+ return undefined;
525
+ case 0x77 /* w */:
526
+ return len === 5 && src.startsWith("while", start) ? Token.While : undefined;
527
+ case 0x75 /* u */:
528
+ return len === 5 && src.startsWith("until", start) ? Token.Until : undefined;
529
+ case 0x73 /* s */:
530
+ return len === 6 && src.startsWith("select", start) ? Token.Select : undefined;
531
+ default:
532
+ return undefined;
533
+ }
534
+ }
535
+
536
+ export const LexContext = {
537
+ Normal: 0,
538
+ CommandStart: 1,
539
+ TestMode: 2,
540
+ // After a prefix element: assignments still recognized, reserved words not.
541
+ CommandPrefix: 3,
542
+ // Assignment arguments retain ordinary word boundaries inside subscripts.
543
+ Declaration: 4,
544
+ ArrayElement: 5,
545
+ FunctionName: 6,
546
+ CasePattern: 7,
547
+ // Arguments of eval and let: ordinary words, but `name=(…)` keeps its array value as in Bash.
548
+ EvalArgument: 8,
549
+ } as const;
550
+ export type LexContext = (typeof LexContext)[keyof typeof LexContext];
551
+
552
+ export class Lexer {
553
+ static _findSubstitutionEnd:
554
+ | ((source: string, start: number, end: number, depth: number, context: SubstitutionContext) => number)
555
+ | undefined;
556
+ private src: string;
557
+ private srcEnd: number;
558
+ private parenBoundary: boolean;
559
+ private pos: number;
560
+ private current: TokenValue;
561
+ private nextState: TokenValue;
562
+ private hasPeek: boolean;
563
+ private pendingHereDocs: PendingHereDoc[] | null;
564
+ private substitutionContext: SubstitutionContext;
565
+ _errors: ParseError[] | null = null;
566
+ _buildParts = false;
567
+ // Build processed text while scanning. Off on the normal token path (values
568
+ // materialize lazily); on for redirect targets, heredoc delimiters, arithmetic
569
+ // command bodies, and bounded value re-lexes. _buildParts implies it.
570
+ _buildValue = false;
571
+ // Nesting depth of the window being lexed (enclosing sub-fields plus substitution
572
+ // scripts), sharing the MAX_SYNTAX_NESTING budget across lazily created lexers.
573
+ _nestingDepth = 0;
574
+
575
+ // `start`/`end` bound the lexer to a window of `src` so substitution scripts can be
576
+ // parsed in place against the original source — every position is then absolute, with
577
+ // no slicing or re-basing. Defaults cover the whole string (the common top-level parse).
578
+ constructor(
579
+ src: string,
580
+ start = 0,
581
+ end = src.length,
582
+ parenBoundary = false,
583
+ context: SubstitutionContext = createSubstitutionContext(),
584
+ ) {
585
+ this.src = src;
586
+ this.srcEnd = end;
587
+ this.parenBoundary = parenBoundary;
588
+ this.pos = start;
589
+ this.current = new TokenValue(this);
590
+ this.nextState = new TokenValue(this);
591
+ this.hasPeek = false;
592
+ this.pendingHereDocs = null;
593
+ this.substitutionContext = context;
594
+
595
+ if (start === 0 && src.charCodeAt(0) === CH_HASH && src.charCodeAt(1) === CH_BANG) {
596
+ const nl = src.indexOf("\n");
597
+ this.pos = nl === -1 ? this.srcEnd : nl + 1;
598
+ }
599
+ }
600
+
601
+ getSource(): string {
602
+ return this.src;
603
+ }
604
+
605
+ // Fresh window sharing source, depth and substitution context; tokens, errors, heredocs and build flags reset.
606
+ fork(start: number, end: number, parenBoundary = false): Lexer {
607
+ const lexer = new Lexer(this.src, start, end, parenBoundary, this.substitutionContext);
608
+ lexer._nestingDepth = this._nestingDepth;
609
+ return lexer;
610
+ }
611
+
612
+ getSubstitutionContext(): SubstitutionContext {
613
+ return this.substitutionContext;
614
+ }
615
+
616
+ get errors(): ParseError[] {
617
+ return this._errors ?? (this._errors = []);
618
+ }
619
+
620
+ // A substitution's script parses lazily, one level below the window it was found in, in
621
+ // place over `inner` at `start`, sharing this scan's substitution boundaries.
622
+ private commandExpansion(
623
+ pos: number,
624
+ text: string,
625
+ start: number,
626
+ inner: string,
627
+ parenBoundary = false,
628
+ ): ScriptExpansionImpl<"CommandExpansion"> {
629
+ const window = this.scriptWindow(this.src, start, start + inner.length, parenBoundary);
630
+ return new ScriptExpansionImpl("CommandExpansion", pos, this.pos, text, window);
631
+ }
632
+
633
+ // Escaped backticks rebuild `inner` with the escapes removed, so it is no longer a verbatim
634
+ // substring of the source: that script parses the decoded body as its own source.
635
+ private decodedCommandExpansion(pos: number, text: string, inner: string): ScriptExpansionImpl<"CommandExpansion"> {
636
+ const window = this.scriptWindow(inner, 0, inner.length, false, true);
637
+ return new ScriptExpansionImpl("CommandExpansion", pos, this.pos, text, window);
638
+ }
639
+
640
+ private processSubstitution(pos: number, text: string, operator: "<" | ">", inner: string): ProcessSubstitutionImpl {
641
+ const window = this.scriptWindow(this.src, pos + 2, pos + 2 + inner.length, !this._unbalanced);
642
+ return new ProcessSubstitutionImpl(pos, this.pos, text, operator, window);
643
+ }
644
+
645
+ // Past the nesting budget the part gets no window, so lazy materialization bottoms out.
646
+ private scriptWindow(
647
+ source: string,
648
+ start: number,
649
+ end: number,
650
+ parenBoundary: boolean,
651
+ decoded = false,
652
+ ): ScriptWindow | undefined {
653
+ if (this._nestingDepth > MAX_SYNTAX_NESTING) return undefined;
654
+ const context = decoded ? undefined : this.substitutionContext;
655
+ return { source, start, end, depth: this._nestingDepth + 1, parenBoundary, context };
656
+ }
657
+
658
+ getPos(): number {
659
+ return this.pos;
660
+ }
661
+
662
+ protected findSubstitutionEnd(start: number, end: number, depth = this._nestingDepth + 1): number {
663
+ if (Lexer._findSubstitutionEnd === undefined) return -1;
664
+ return Lexer._findSubstitutionEnd(this.src, start, end, depth, this.substitutionContext);
665
+ }
666
+
667
+ /** Materialize a word token's value: raw spans slice directly, others re-lex the span. */
668
+ _tokenValue(pos: number, end: number, raw: boolean): string {
669
+ return raw ? this.src.slice(pos, end) : this.wordValueOf(pos, end);
670
+ }
671
+
672
+ // Re-lex [start, end) in value mode to produce the processed word text. Bounded
673
+ // to the span, so every expansion the original scan consumed closes within it.
674
+ // Errors were already reported by the original scan; suppress duplicates.
675
+ private wordValueOf(start: number, end: number): string {
676
+ const savedPos = this.pos;
677
+ const savedEnd = this.srcEnd;
678
+ const savedBuildValue = this._buildValue;
679
+ const savedUnbalanced = this._unbalanced;
680
+ const errorCount = this._errors === null ? 0 : this._errors.length;
681
+ this.pos = start;
682
+ this.srcEnd = end;
683
+ this._buildValue = true;
684
+ this.readWordText();
685
+ if (
686
+ this.pos < end &&
687
+ this._wordAssignmentState !== undefined &&
688
+ this._wordAssignmentState >= ASSIGNMENT_INDEX_BASE
689
+ ) {
690
+ this.pos = start;
691
+ this.readWordText(true);
692
+ }
693
+ const value = this._wordText;
694
+ this.pos = savedPos;
695
+ this.srcEnd = savedEnd;
696
+ this._buildValue = savedBuildValue;
697
+ this._unbalanced = savedUnbalanced;
698
+ if (this._errors !== null) this._errors.length = errorCount;
699
+ return value;
700
+ }
701
+
702
+ /** Find the closing bracket for a shell subscript, ignoring brackets inside nested shell syntax. */
703
+ findClosingBracket(start: number, end: number = this.srcEnd): number {
704
+ // A failed search scans to the end and every unclosed subscript at a command position
705
+ // retries it, so answer from the last bracket in the window first.
706
+ const limit = Math.min(end, this.srcEnd);
707
+ let window = this._bracketWindow;
708
+ if (!window || window.limit !== limit) {
709
+ window = { limit, last: this.src.lastIndexOf("]", limit - 1) };
710
+ this._bracketWindow = window;
711
+ }
712
+ if (start > window.last) return -1;
713
+ return this.findClosingShellDelimiter(start, end, CH_RBRACKET);
714
+ }
715
+
716
+ /** Find the closing bracket of `$[ … ]`, which unlike a subscript does not recurse into `${ }`. */
717
+ private findClosingArithmeticBracket(start: number, end: number = this.srcEnd): number {
718
+ return this.findClosingShellDelimiter(start, end, CH_RBRACKET, false, false);
719
+ }
720
+
721
+ /** Find the closing brace for a parameter expansion, ignoring braces inside nested shell syntax. */
722
+ findClosingBrace(start: number, end: number = this.srcEnd): number {
723
+ return this.findClosingShellDelimiter(start, end, CH_RBRACE);
724
+ }
725
+
726
+ /** Find the closing parenthesis for a shell substitution using the command-aware scanner. */
727
+ findClosingParenthesis(start: number, end: number = this.srcEnd): number {
728
+ const savedPos = this.pos;
729
+ const savedEnd = this.srcEnd;
730
+ const savedUnbalanced = this._unbalanced;
731
+ this.pos = start;
732
+ this.srcEnd = Math.min(end, this.srcEnd);
733
+ this.extractBalanced();
734
+ const close = this._unbalanced ? -1 : this.pos - 1;
735
+ this.pos = savedPos;
736
+ this.srcEnd = savedEnd;
737
+ this._unbalanced = savedUnbalanced;
738
+ return close;
739
+ }
740
+
741
+ /** Find the end of one arithmetic expansion using the canonical lexer scanner. */
742
+ findArithmeticExpansionEnd(start: number, end: number = this.srcEnd): number {
743
+ const scanner = this.fork(start, end);
744
+ scanner.pos = start + 1;
745
+ scanner.scanArithmeticBody();
746
+ return scanner.pos;
747
+ }
748
+
749
+ /** Find the end of one shell-expanded arithmetic word using the canonical lexer scanners. */
750
+ findArithmeticWordEnd(start: number, end: number = this.srcEnd): number {
751
+ const scanner = this.fork(start, end);
752
+ scanner.pos = start;
753
+ return scanner.scanArithmeticWordEnd();
754
+ }
755
+
756
+ private scanArithmeticWordEnd(): number {
757
+ while (this.pos < this.srcEnd) {
758
+ const ch = this.src.charCodeAt(this.pos);
759
+ if (ch === CH_DOLLAR) {
760
+ this.readDollar();
761
+ continue;
762
+ }
763
+ if (ch === CH_BACKTICK) {
764
+ this.readBacktickExpansion();
765
+ continue;
766
+ }
767
+ if (ch === CH_SQUOTE) {
768
+ this.pos++;
769
+ this.skipSQ();
770
+ continue;
771
+ }
772
+ if (ch === CH_DQUOTE) {
773
+ this.pos++;
774
+ this.skipDQ();
775
+ continue;
776
+ }
777
+ if (ch === CH_BACKSLASH) {
778
+ this.pos += 2;
779
+ continue;
780
+ }
781
+ if (ch === CH_LBRACKET) {
782
+ const close = this.findClosingBracket(this.pos + 1);
783
+ if (close !== -1) {
784
+ this.pos = close + 1;
785
+ continue;
786
+ }
787
+ }
788
+ if ((ch === CH_LT || ch === CH_GT) && this.src.charCodeAt(this.pos + 1) === CH_LPAREN) {
789
+ this.pos += 2;
790
+ this.extractBalanced();
791
+ continue;
792
+ }
793
+ if (ch < 128 && arithmeticWordDelimiter[ch]) break;
794
+ this.pos++;
795
+ }
796
+ return this.pos;
797
+ }
798
+
799
+ // Only array assignment bodies take comments and leading `[` subscripts and reject operators;
800
+ // extglob shares this scanner, and `#` is pattern data there.
801
+ private findClosingShellDelimiter(start: number, end: number, closing: number, array = false, braces = true): number {
802
+ const savedPos = this.pos;
803
+ const savedEnd = this.srcEnd;
804
+ const savedUnbalanced = this._unbalanced;
805
+ this.srcEnd = Math.min(end, this.srcEnd);
806
+ const delimiters = [closing];
807
+ let pos = start;
808
+ let wordStart = true;
809
+ let invalid = -1;
810
+
811
+ while (pos < this.srcEnd) {
812
+ const ch = this.src.charCodeAt(pos);
813
+ if (ch === CH_BACKSLASH) {
814
+ if (pos + 1 < this.srcEnd && this.src.charCodeAt(pos + 1) !== CH_NL) wordStart = false;
815
+ pos += 2;
816
+ continue;
817
+ }
818
+ if (ch === CH_HASH && array && delimiters.length === 1 && wordStart) {
819
+ while (pos < this.srcEnd && this.src.charCodeAt(pos) !== CH_NL) pos++;
820
+ continue;
821
+ }
822
+ if (ch === CH_SQUOTE) {
823
+ this.pos = pos + 1;
824
+ this.skipSQ();
825
+ pos = this.pos;
826
+ wordStart = false;
827
+ continue;
828
+ }
829
+ if (ch === CH_DQUOTE) {
830
+ this.pos = pos + 1;
831
+ this.skipDQ();
832
+ pos = this.pos;
833
+ wordStart = false;
834
+ continue;
835
+ }
836
+ if (ch === CH_BACKTICK) {
837
+ pos++;
838
+ while (pos < this.srcEnd && this.src.charCodeAt(pos) !== CH_BACKTICK) {
839
+ if (this.src.charCodeAt(pos) === CH_BACKSLASH) pos++;
840
+ pos++;
841
+ }
842
+ if (pos < this.srcEnd) pos++;
843
+ wordStart = false;
844
+ continue;
845
+ }
846
+ if (
847
+ (ch === CH_DOLLAR && pos + 1 < this.srcEnd && this.src.charCodeAt(pos + 1) === CH_LPAREN) ||
848
+ ((ch === CH_LT || ch === CH_GT) && pos + 1 < this.srcEnd && this.src.charCodeAt(pos + 1) === CH_LPAREN)
849
+ ) {
850
+ this.pos = pos + 2;
851
+ this.extractBalanced();
852
+ pos = this.pos;
853
+ wordStart = false;
854
+ continue;
855
+ }
856
+ const expected = delimiters[delimiters.length - 1];
857
+ if (ch === CH_DOLLAR && pos + 1 < this.srcEnd) {
858
+ const after = this.src.charCodeAt(pos + 1);
859
+ if (after === CH_SQUOTE) {
860
+ pos = decodeAnsiCQuoted(this.src, pos + 2, this.srcEnd).end;
861
+ wordStart = false;
862
+ continue;
863
+ }
864
+ if (after === CH_DOLLAR) {
865
+ pos += 2; // `$$` consumes its second `$`, so a `{` after it opens nothing
866
+ wordStart = false;
867
+ continue;
868
+ }
869
+ if (after === CH_LBRACE && braces) {
870
+ delimiters.push(CH_RBRACE);
871
+ pos += 2;
872
+ wordStart = false;
873
+ continue;
874
+ }
875
+ }
876
+ if (array && delimiters.length === 1) {
877
+ if (ch === CH_LBRACKET && wordStart) {
878
+ delimiters.push(CH_RBRACKET);
879
+ } else if (
880
+ invalid === -1 &&
881
+ (ch === CH_SEMI ||
882
+ ch === CH_AMP ||
883
+ ch === CH_PIPE ||
884
+ ch === CH_LT ||
885
+ ch === CH_GT ||
886
+ (ch === CH_LPAREN && !isExtglobOpen(this.src.charCodeAt(pos - 1))))
887
+ ) {
888
+ invalid = pos;
889
+ }
890
+ }
891
+ if (expected === CH_RBRACKET && ch === CH_LBRACKET) {
892
+ delimiters.push(CH_RBRACKET);
893
+ } else if (expected === CH_RPAREN && ch === CH_LPAREN) {
894
+ delimiters.push(CH_RPAREN);
895
+ } else if (ch === expected) {
896
+ delimiters.pop();
897
+ if (delimiters.length === 0) {
898
+ this.pos = savedPos;
899
+ this.srcEnd = savedEnd;
900
+ this._unbalanced = savedUnbalanced;
901
+ if (invalid !== -1)
902
+ this.errors.push({ message: `unexpected '${this.src[invalid]}' in array assignment`, pos: invalid });
903
+ return pos;
904
+ }
905
+ wordStart = false;
906
+ pos++;
907
+ continue;
908
+ }
909
+ wordStart = ch < 128 && (charType[ch] & 1) !== 0;
910
+ pos++;
911
+ }
912
+
913
+ this.pos = savedPos;
914
+ this.srcEnd = savedEnd;
915
+ this._unbalanced = savedUnbalanced;
916
+ return -1;
917
+ }
918
+
919
+ // End of the brace expansion opened at `start`, or -1. As in Bash's brace_gobbler, quotes,
920
+ // escapes, parameter expansions and substitutions are opaque; an unquoted metacharacter
921
+ // ends the word before the brace closes. The word scan reports errors in the skipped text.
922
+ private scanBraceExpansion(start: number): number {
923
+ const src = this.src;
924
+ const len = this.srcEnd;
925
+ const first = start + 1 < len ? src.charCodeAt(start + 1) : 0;
926
+ if (first <= CH_SPACE || first === CH_RBRACE) return -1;
927
+ const savedPos = this.pos;
928
+ const savedUnbalanced = this._unbalanced;
929
+ const errorCount = this._errors === null ? 0 : this._errors.length;
930
+ let depth = 1;
931
+ let hasSep = false;
932
+ let end = -1;
933
+ let pos = start + 1;
934
+ while (pos < len) {
935
+ const ch = src.charCodeAt(pos);
936
+ const type = ch < 128 ? charType[ch] : 0;
937
+ if (type === 0) {
938
+ pos++;
939
+ if (ch === CH_RBRACE) {
940
+ if (--depth === 0) {
941
+ end = pos;
942
+ break;
943
+ }
944
+ } else if (depth === 1 && (ch === 0x2c /* , */ || (ch === 0x2e /* . */ && src.charCodeAt(pos) === 0x2e))) {
945
+ hasSep = true;
946
+ }
947
+ continue;
948
+ }
949
+ const next = pos + 1 < len ? src.charCodeAt(pos + 1) : 0;
950
+ if (type & 1) {
951
+ if (next === CH_LPAREN && (ch === CH_LT || ch === CH_GT)) {
952
+ this.pos = pos + 2;
953
+ this.extractBalanced();
954
+ pos = this.pos;
955
+ } else if (ch === CH_LPAREN && isExtglobOpen(src.charCodeAt(pos - 1))) {
956
+ const close = this.findClosingShellDelimiter(pos + 1, len, CH_RPAREN);
957
+ if (close === -1) break;
958
+ pos = close + 1;
959
+ } else {
960
+ break;
961
+ }
962
+ } else if (ch === CH_BACKSLASH) {
963
+ pos += 2;
964
+ } else if (ch === CH_SQUOTE) {
965
+ this.pos = pos + 1;
966
+ this.skipSQ();
967
+ pos = this.pos;
968
+ } else if (ch === CH_DQUOTE) {
969
+ this.pos = pos + 1;
970
+ this.skipDQ();
971
+ pos = this.pos;
972
+ } else if (ch === CH_BACKTICK) {
973
+ pos++;
974
+ while (pos < len && src.charCodeAt(pos) !== CH_BACKTICK) pos += src.charCodeAt(pos) === CH_BACKSLASH ? 2 : 1;
975
+ pos++;
976
+ } else if (ch === CH_LBRACE) {
977
+ depth++;
978
+ pos++;
979
+ } else if (next === CH_SQUOTE) {
980
+ pos = decodeAnsiCQuoted(src, pos + 2, len).end;
981
+ } else if (next === CH_LBRACE) {
982
+ const close = this.findClosingBrace(pos + 2, len);
983
+ if (close === -1) break;
984
+ pos = close + 1;
985
+ } else if (next === CH_LPAREN) {
986
+ this.pos = pos + 2;
987
+ this.extractBalanced();
988
+ pos = this.pos;
989
+ } else {
990
+ pos++;
991
+ }
992
+ }
993
+ this.pos = savedPos;
994
+ this._unbalanced = savedUnbalanced;
995
+ if (this._errors !== null) this._errors.length = errorCount;
996
+ return hasSep ? end : -1;
997
+ }
998
+
999
+ skipSubshellBody(): number {
1000
+ this.extractBalanced(false);
1001
+ return this._unbalanced ? -1 : this.pos;
1002
+ }
1003
+
1004
+ skipCompoundBody(closeToken: Token): number {
1005
+ type RecoveryPhase =
1006
+ | "commands"
1007
+ | "for-header"
1008
+ | "case-word"
1009
+ | "case-in"
1010
+ | "case-pattern"
1011
+ | "function-name"
1012
+ | "function-body"
1013
+ | "coproc-command"
1014
+ | "coproc-body"
1015
+ | "time-command"
1016
+ | "time-command-after-p";
1017
+ type RecoveryFrame = { close: Token; phase: RecoveryPhase };
1018
+
1019
+ const frames: RecoveryFrame[] = [
1020
+ { close: closeToken, phase: closeToken === Token.Esac ? "case-pattern" : "commands" },
1021
+ ];
1022
+ let commandStart = true;
1023
+
1024
+ for (;;) {
1025
+ const phase = frames[frames.length - 1].phase;
1026
+ let ctx: LexContext = commandStart ? LexContext.CommandStart : LexContext.Normal;
1027
+ if (phase === "function-name") ctx = LexContext.FunctionName;
1028
+ else if (phase === "case-pattern" && commandStart) ctx = LexContext.CasePattern;
1029
+ const value = this.next(ctx);
1030
+ const token = value.token;
1031
+ if (token === Token.EOF) return -1;
1032
+
1033
+ const last = frames.length - 1;
1034
+ const frame = frames[last];
1035
+ if (frame.phase === "function-name") {
1036
+ if (token === Token.Newline) continue;
1037
+ frame.phase = "function-body";
1038
+ commandStart = true;
1039
+ continue;
1040
+ } else if (frame.phase === "function-body") {
1041
+ if (token === Token.Newline) continue;
1042
+ frame.phase = "commands";
1043
+ commandStart = true;
1044
+ if (token === Token.LParen && this.peek(LexContext.Normal).token === Token.RParen) {
1045
+ this.next(LexContext.Normal);
1046
+ frame.phase = "function-body";
1047
+ continue;
1048
+ }
1049
+ } else if (frame.phase === "coproc-command") {
1050
+ if (token === Token.Newline) continue;
1051
+ if (token === Token.Word) {
1052
+ frame.phase = "coproc-body";
1053
+ commandStart = true;
1054
+ continue;
1055
+ }
1056
+ frame.phase = "commands";
1057
+ commandStart = true;
1058
+ } else if (frame.phase === "coproc-body") {
1059
+ if (token === Token.Newline) continue;
1060
+ frame.phase =
1061
+ token === Token.Word && value.keywordEligible && value.value === "time" ? "time-command" : "commands";
1062
+ commandStart = true;
1063
+ if (frame.phase === "time-command") continue;
1064
+ } else if (frame.phase === "time-command") {
1065
+ if (token === Token.Word && value.keywordEligible && value.value === "-p") {
1066
+ frame.phase = "time-command-after-p";
1067
+ continue;
1068
+ }
1069
+ if (token === Token.Word && value.keywordEligible && value.value === "--") {
1070
+ frame.phase = "commands";
1071
+ continue;
1072
+ }
1073
+ frame.phase = "commands";
1074
+ commandStart = true;
1075
+ } else if (frame.phase === "time-command-after-p") {
1076
+ if (token === Token.Word && value.keywordEligible && value.value === "--") {
1077
+ frame.phase = "commands";
1078
+ continue;
1079
+ }
1080
+ frame.phase = "commands";
1081
+ commandStart = true;
1082
+ } else if (frame.phase === "for-header") {
1083
+ if (token === Token.ArithCmd || token === Token.Semi || token === Token.Newline) {
1084
+ commandStart = true;
1085
+ continue;
1086
+ }
1087
+ if (token === Token.Do || token === Token.LBrace) {
1088
+ frame.close = token === Token.Do ? Token.Done : Token.RBrace;
1089
+ frame.phase = "commands";
1090
+ commandStart = true;
1091
+ continue;
1092
+ }
1093
+ } else if (frame.phase === "case-word") {
1094
+ if (token === Token.Newline) continue;
1095
+ frame.phase = "case-in";
1096
+ commandStart = false;
1097
+ continue;
1098
+ } else if (frame.phase === "case-in") {
1099
+ if (token === Token.Newline) {
1100
+ commandStart = true;
1101
+ continue;
1102
+ }
1103
+ frame.phase = "case-pattern";
1104
+ commandStart = true;
1105
+ continue;
1106
+ } else if (frame.phase === "case-pattern") {
1107
+ if (token === Token.Esac && commandStart) {
1108
+ frames.pop();
1109
+ if (frames.length === 0) return value.end;
1110
+ commandStart = false;
1111
+ continue;
1112
+ }
1113
+ if (token === Token.RParen) {
1114
+ frame.phase = "commands";
1115
+ commandStart = true;
1116
+ } else {
1117
+ commandStart = token === Token.Newline;
1118
+ }
1119
+ continue;
1120
+ }
1121
+
1122
+ if (token === frame.close) {
1123
+ frames.pop();
1124
+ if (frames.length === 0) return value.end;
1125
+ commandStart = false;
1126
+ continue;
1127
+ }
1128
+
1129
+ if (commandStart) {
1130
+ switch (token) {
1131
+ case Token.LParen:
1132
+ frames.push({ close: Token.RParen, phase: "commands" });
1133
+ break;
1134
+ case Token.LBrace:
1135
+ frames.push({ close: Token.RBrace, phase: "commands" });
1136
+ break;
1137
+ case Token.If:
1138
+ frames.push({ close: Token.Fi, phase: "commands" });
1139
+ break;
1140
+ case Token.For:
1141
+ frames.push({ close: Token.Done, phase: "for-header" });
1142
+ break;
1143
+ case Token.While:
1144
+ case Token.Until:
1145
+ case Token.Select:
1146
+ frames.push({ close: Token.Done, phase: "commands" });
1147
+ break;
1148
+ case Token.Case:
1149
+ frames.push({ close: Token.Esac, phase: "case-word" });
1150
+ break;
1151
+ case Token.DblLBracket:
1152
+ if (!this.skipTestCommandBody()) return -1;
1153
+ commandStart = false;
1154
+ continue;
1155
+ case Token.Assignment:
1156
+ case Token.Redirect:
1157
+ case Token.Bang:
1158
+ case Token.Then:
1159
+ case Token.Else:
1160
+ case Token.Elif:
1161
+ case Token.Do:
1162
+ case Token.In:
1163
+ break;
1164
+ case Token.Semi:
1165
+ case Token.Newline:
1166
+ case Token.Pipe:
1167
+ case Token.And:
1168
+ case Token.Or:
1169
+ case Token.Amp:
1170
+ case Token.DoubleSemi:
1171
+ case Token.SemiAmp:
1172
+ case Token.DoubleSemiAmp:
1173
+ break;
1174
+ case Token.Function:
1175
+ frame.phase = "function-name";
1176
+ break;
1177
+ case Token.Coproc:
1178
+ frame.phase = "coproc-command";
1179
+ break;
1180
+ default:
1181
+ if (token === Token.Word && value.keywordEligible && value.value === "time") {
1182
+ frame.phase = "time-command";
1183
+ commandStart = true;
1184
+ } else {
1185
+ commandStart = false;
1186
+ }
1187
+ continue;
1188
+ }
1189
+ }
1190
+
1191
+ switch (token) {
1192
+ case Token.Semi:
1193
+ case Token.Newline:
1194
+ case Token.Pipe:
1195
+ case Token.And:
1196
+ case Token.Or:
1197
+ case Token.Amp:
1198
+ commandStart = true;
1199
+ break;
1200
+ case Token.DoubleSemi:
1201
+ case Token.SemiAmp:
1202
+ case Token.DoubleSemiAmp:
1203
+ if (frame.close === Token.Esac) frame.phase = "case-pattern";
1204
+ commandStart = true;
1205
+ break;
1206
+ case Token.RParen:
1207
+ commandStart = true;
1208
+ break;
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ skipTestGroup(): number {
1214
+ let depth = 1;
1215
+ for (;;) {
1216
+ const value = this.next(LexContext.TestMode);
1217
+ if (value.token === Token.EOF) return -1;
1218
+ if (value.token === Token.DblRBracket) {
1219
+ this.unshift(value);
1220
+ return -1;
1221
+ }
1222
+ if (value.token === Token.LParen) depth++;
1223
+ else if (value.token === Token.RParen && --depth === 0) return value.end;
1224
+ }
1225
+ }
1226
+
1227
+ private skipTestCommandBody(): boolean {
1228
+ for (;;) {
1229
+ const token = this.next(LexContext.TestMode).token;
1230
+ if (token === Token.DblRBracket) return true;
1231
+ if (token === Token.EOF) return false;
1232
+ }
1233
+ }
1234
+
1235
+ /** Set position and scan a word, building parts. Used by computeWordParts. */
1236
+ buildWordParts(startPos: number): WordPart[] | null {
1237
+ this._buildParts = true;
1238
+ this.pos = startPos;
1239
+ // Handle process substitution words <(...) and >(...)
1240
+ const ch = this.src.charCodeAt(startPos);
1241
+ if (
1242
+ (ch === 0x3c /* < */ || ch === 0x3e) /* > */ &&
1243
+ startPos + 1 < this.srcEnd &&
1244
+ this.src.charCodeAt(startPos + 1) === 0x28 /* ( */
1245
+ ) {
1246
+ this.pos = startPos + 2;
1247
+ const inner = this.extractBalanced();
1248
+ const text = this.src.slice(startPos, this.pos);
1249
+ const part = this.processSubstitution(startPos, text, ch === 0x3c ? "<" : ">", inner);
1250
+ // Continue reading any trailing word text (e.g., suffix after proc sub)
1251
+ if (this.pos < this.srcEnd) {
1252
+ this.readWordText();
1253
+ if (this._wordParts) {
1254
+ this._wordParts.unshift(part);
1255
+ } else {
1256
+ this._wordParts = [part];
1257
+ }
1258
+ } else {
1259
+ this._wordParts = [part];
1260
+ }
1261
+ } else {
1262
+ this.readWordText();
1263
+ // A command word whose subscript holds a blank was bounded by the matched-pair rescan, so
1264
+ // its parts need the same scan.
1265
+ if (
1266
+ this.pos < this.srcEnd &&
1267
+ this._wordAssignmentState !== undefined &&
1268
+ this._wordAssignmentState >= ASSIGNMENT_INDEX_BASE
1269
+ ) {
1270
+ this.pos = startPos;
1271
+ this.readWordText(true);
1272
+ }
1273
+ }
1274
+ return this._wordParts;
1275
+ }
1276
+
1277
+ /** Scan a bounded word-like span without treating shell operators or whitespace as terminators. */
1278
+ buildEmbeddedWordParts(startPos: number, arithmetic = false): WordPart[] | null {
1279
+ this._buildParts = true;
1280
+ this.pos = startPos;
1281
+ this.readInnerWordText(false, arithmetic);
1282
+ return this._wordParts;
1283
+ }
1284
+
1285
+ buildArrayElementParts(startPos: number): WordPart[] | null {
1286
+ const close = this.findClosingBracket(startPos + 1);
1287
+ if (close === -1) return this.buildWordParts(startPos);
1288
+ const src = this.src;
1289
+ this._buildParts = true;
1290
+ const head = hasEmbeddedWordStructure(src, startPos, close + 1)
1291
+ ? this.parseSubFieldWord(startPos, close + 1, "embedded")
1292
+ : undefined;
1293
+ this.pos = close + 1;
1294
+ this.readWordText(false, LexContext.ArrayElement);
1295
+ const end = this.pos;
1296
+ // Only `[key]=` and `[key]+=` select an element; its key is never a pattern. Any other element
1297
+ // is a value that undergoes pathname expansion, so one glob pass owns the whole element and a
1298
+ // leading bracket is a pattern as in a command word.
1299
+ const operator = skipLineContinuations(src, close + 1, end);
1300
+ const after = src.charCodeAt(operator);
1301
+ const keyed = after === CH_EQ || (after === CH_PLUS && src.charCodeAt(operator + 1) === CH_EQ);
1302
+ let headParts = head?.parts;
1303
+ let tailParts = this._wordParts;
1304
+ if (keyed) tailParts = parseGlobParts(src, close + 1, end, tailParts) ?? tailParts;
1305
+ if (!headParts && !tailParts) return keyed ? null : (parseGlobParts(src, startPos, end, undefined) ?? null);
1306
+
1307
+ const headText = src.slice(startPos, close + 1);
1308
+ if (!headParts)
1309
+ headParts = [{ type: "Literal", pos: startPos, end: close + 1, text: headText, value: head?.value ?? headText }];
1310
+ if (!tailParts)
1311
+ tailParts =
1312
+ end > close + 1
1313
+ ? [{ type: "Literal", pos: close + 1, end, text: src.slice(close + 1, end), value: this._wordText }]
1314
+ : [];
1315
+ let parts = joinWordParts(headParts, tailParts);
1316
+ if (!keyed) parts = parseGlobParts(src, startPos, end, parts) ?? parts;
1317
+ return parts.length > 1 || parts[0].type !== "Literal" ? parts : null;
1318
+ }
1319
+
1320
+ /** Scan a heredoc body for expansions, building parts. Spaces/newlines are literal. */
1321
+ buildHereDocParts(bodyPos: number, bodyEnd: number): WordPart[] | null {
1322
+ this._buildParts = true;
1323
+ const src = this.src;
1324
+ const parts: WordPart[] = [];
1325
+ let hasExpansion = false;
1326
+ let litBuf = "";
1327
+ let litStart = bodyPos;
1328
+ let i = bodyPos;
1329
+
1330
+ const flushLit = () => {
1331
+ if (i > litStart) {
1332
+ parts.push({ type: "Literal", pos: litStart, end: i, value: litBuf, text: src.slice(litStart, i) });
1333
+ litBuf = "";
1334
+ }
1335
+ };
1336
+
1337
+ while (i < bodyEnd) {
1338
+ const ch = src.charCodeAt(i);
1339
+
1340
+ if (ch === 0x5c /* \\ */) {
1341
+ // Backslash escape — in unquoted heredoc, \\$, \\`, \\\\ are special
1342
+ if (i + 1 < bodyEnd) {
1343
+ const nc = src.charCodeAt(i + 1);
1344
+ if (nc === CH_NL) {
1345
+ i += 2;
1346
+ continue;
1347
+ }
1348
+ if (nc === 0x24 /* $ */ || nc === 0x60 /* ` */ || nc === 0x5c /* \\ */) {
1349
+ litBuf += String.fromCharCode(nc);
1350
+ i += 2;
1351
+ continue;
1352
+ }
1353
+ }
1354
+ litBuf += "\\";
1355
+ i++;
1356
+ continue;
1357
+ }
1358
+
1359
+ if (ch === 0x24 /* $ */ && src.charCodeAt(i + 1) !== CH_SQUOTE && src.charCodeAt(i + 1) !== CH_DQUOTE) {
1360
+ flushLit();
1361
+ litStart = i;
1362
+ this.pos = i;
1363
+ this.readDollar();
1364
+ if (this._resultPart) {
1365
+ hasExpansion = true;
1366
+ parts.push(this._resultPart);
1367
+ litStart = this.pos;
1368
+ } else {
1369
+ litBuf += src.slice(i, this.pos);
1370
+ }
1371
+ i = this.pos;
1372
+ continue;
1373
+ }
1374
+
1375
+ if (ch === 0x60 /* ` */) {
1376
+ flushLit();
1377
+ litStart = i;
1378
+ this.pos = i;
1379
+ this.readBacktickExpansion();
1380
+ if (this._resultPart) {
1381
+ hasExpansion = true;
1382
+ parts.push(this._resultPart);
1383
+ litStart = this.pos;
1384
+ } else {
1385
+ litBuf += src.slice(i, this.pos);
1386
+ }
1387
+ i = this.pos;
1388
+ continue;
1389
+ }
1390
+
1391
+ litBuf += src[i];
1392
+ i++;
1393
+ }
1394
+
1395
+ flushLit();
1396
+ return hasExpansion ? parts : null;
1397
+ }
1398
+
1399
+ registerHereDocTarget(target: HereDoc): void {
1400
+ if (this.pendingHereDocs === null) return;
1401
+ for (const hd of this.pendingHereDocs) {
1402
+ if (!hd.target) {
1403
+ hd.target = target;
1404
+ return;
1405
+ }
1406
+ }
1407
+ }
1408
+
1409
+ // Read the right-hand operand of =~ in [[ ]]. Bash ends the operand at
1410
+ // depth-zero whitespace, `)`, `;`, `&`, `<`, or `>` (but `<(`/`>(` open a
1411
+ // process substitution and `|` never delimits). An unquoted `(` opens a
1412
+ // group that consumes everything — `]]`, newlines, and metacharacters
1413
+ // included — until its matching `)`; inside a group only quotes stay opaque
1414
+ // and expansion parens count naively, matching bash. Quotes and expansions
1415
+ // at depth zero skip via the same readers normal words use, so their errors
1416
+ // and spans stay identical. The token is the raw source span; value and
1417
+ // parts resolve lazily like every other word.
1418
+ readTestRegexWord(): TokenValue {
1419
+ this.hasPeek = false;
1420
+ this.skipSpacesAndTabs();
1421
+ const src = this.src;
1422
+ const len = this.srcEnd;
1423
+ const start = this.pos;
1424
+ let depth = 0;
1425
+ while (this.pos < len) {
1426
+ const ch = src.charCodeAt(this.pos);
1427
+ if (ch === CH_LPAREN) {
1428
+ depth++;
1429
+ this.pos++;
1430
+ continue;
1431
+ }
1432
+ if (ch === CH_BACKSLASH) {
1433
+ this.pos += this.pos + 1 < len ? 2 : 1;
1434
+ continue;
1435
+ }
1436
+ if (ch === CH_SQUOTE) {
1437
+ const quotePos = this.pos++;
1438
+ const ansiC = quotePos > start && src.charCodeAt(quotePos - 1) === CH_DOLLAR;
1439
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_SQUOTE) {
1440
+ if (ansiC && src.charCodeAt(this.pos) === CH_BACKSLASH && this.pos + 1 < len) this.pos++;
1441
+ this.pos++;
1442
+ }
1443
+ if (this.pos < len) this.pos++;
1444
+ else
1445
+ this.errors.push({
1446
+ message: ansiC ? "unterminated ANSI-C quote" : "unterminated single quote",
1447
+ pos: quotePos,
1448
+ });
1449
+ continue;
1450
+ }
1451
+ if (ch === CH_DQUOTE) {
1452
+ this.pos++;
1453
+ this.readDoubleQuoted();
1454
+ continue;
1455
+ }
1456
+ if (ch === CH_BACKTICK) {
1457
+ this.readBacktickExpansion();
1458
+ continue;
1459
+ }
1460
+ if (depth > 0) {
1461
+ if (ch === CH_RPAREN) depth--;
1462
+ this.pos++;
1463
+ continue;
1464
+ }
1465
+ if (ch === CH_DOLLAR) {
1466
+ this.readDollar();
1467
+ continue;
1468
+ }
1469
+ if ((ch === CH_LT || ch === CH_GT) && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
1470
+ const subPos = this.pos;
1471
+ this.pos += 2;
1472
+ this.extractBalanced();
1473
+ if (this._unbalanced) this.errors.push({ message: "unterminated process substitution", pos: subPos });
1474
+ continue;
1475
+ }
1476
+ if (ch < 128 && charType[ch] & 1 && ch !== CH_PIPE) break;
1477
+ this.pos++;
1478
+ }
1479
+ setToken(this.current, Token.Word, src.slice(start, this.pos), start, this.pos);
1480
+ return this.current;
1481
+ }
1482
+
1483
+ // Read C-style for expressions: called after first '(' consumed by parser.
1484
+ // Expects pos at second '('. Returns [init, test, update] raw text.
1485
+ readCStyleForExprs(): [string, string, string, number, number, number] {
1486
+ this.hasPeek = false; // discard any peeked token
1487
+ const src = this.src;
1488
+ const len = this.srcEnd;
1489
+ // Skip spaces to second '('
1490
+ while (this.pos < len && (src.charCodeAt(this.pos) === CH_SPACE || src.charCodeAt(this.pos) === CH_TAB)) this.pos++;
1491
+ const openPos = this.pos;
1492
+ if (this.pos < len && src.charCodeAt(this.pos) === CH_LPAREN) this.pos++;
1493
+ const starts: [number, number, number] = [this.pos, 0, 0];
1494
+ const parts: [string, string, string, number, number, number] = ["", "", "", 0, 0, 0];
1495
+ let partIdx = 0;
1496
+ let depth = 1;
1497
+ let partStart = this.pos;
1498
+ let extra = false;
1499
+ const setPart = (end: number) => {
1500
+ const raw = src.slice(partStart, end);
1501
+ parts[partIdx] = raw.trim();
1502
+ parts[3 + partIdx] = starts[partIdx] + raw.length - raw.trimStart().length;
1503
+ };
1504
+ while (this.pos < len && depth > 0) {
1505
+ const c = src.charCodeAt(this.pos);
1506
+ if (c === CH_LPAREN) {
1507
+ depth++;
1508
+ this.pos++;
1509
+ } else if (c === CH_RPAREN) {
1510
+ depth--;
1511
+ if (depth === 0) {
1512
+ if (!extra) setPart(this.pos);
1513
+ if (partIdx < 2)
1514
+ this.errors.push({ message: "expected three arithmetic expressions in for header", pos: this.pos });
1515
+ this.pos++; // skip closing )
1516
+ // Skip the outer ) as well
1517
+ while (this.pos < len && (src.charCodeAt(this.pos) === CH_SPACE || src.charCodeAt(this.pos) === CH_TAB))
1518
+ this.pos++;
1519
+ if (this.pos < len && src.charCodeAt(this.pos) === CH_RPAREN) this.pos++;
1520
+ break;
1521
+ }
1522
+ this.pos++;
1523
+ } else if (c === CH_SEMI && depth === 1) {
1524
+ // Bash requires exactly three expressions; keep the first three and report the rest.
1525
+ if (partIdx === 2) {
1526
+ if (!extra) {
1527
+ setPart(this.pos);
1528
+ this.errors.push({ message: "expected three arithmetic expressions in for header", pos: this.pos });
1529
+ }
1530
+ extra = true;
1531
+ this.pos++;
1532
+ continue;
1533
+ }
1534
+ setPart(this.pos);
1535
+ partIdx++;
1536
+ this.pos++;
1537
+ partStart = this.pos;
1538
+ starts[partIdx] = partStart;
1539
+ } else if (c === CH_DOLLAR) {
1540
+ this.readDollar();
1541
+ } else if (c === CH_BACKTICK) {
1542
+ this.readBacktickExpansion();
1543
+ } else if (c === CH_BACKSLASH) {
1544
+ this.pos += this.pos + 1 < len ? 2 : 1;
1545
+ } else if (c === CH_SQUOTE) {
1546
+ this.pos++;
1547
+ this.skipSQ();
1548
+ } else if (c === CH_DQUOTE) {
1549
+ this.pos++;
1550
+ this.skipDQ();
1551
+ } else {
1552
+ this.pos++;
1553
+ }
1554
+ }
1555
+ if (depth > 0) {
1556
+ if (!extra) setPart(this.pos);
1557
+ this.errors.push({ message: "unterminated arithmetic for header", pos: openPos });
1558
+ }
1559
+ return parts;
1560
+ }
1561
+
1562
+ peek(ctx: LexContext = LexContext.Normal): TokenValue {
1563
+ if (!this.hasPeek) {
1564
+ this.readNext(this.nextState, ctx);
1565
+ this.hasPeek = true;
1566
+ }
1567
+ return this.nextState;
1568
+ }
1569
+
1570
+ // Peek where the context depends on the token just consumed. peek() ignores its ctx once a
1571
+ // token is cached, so the caller's context must only be derived when a read actually happens.
1572
+ peekFollow(closers: Uint8Array): TokenValue {
1573
+ if (!this.hasPeek) {
1574
+ const ctx = closers[this.current.token] ? LexContext.CommandStart : LexContext.Normal;
1575
+ this.readNext(this.nextState, ctx);
1576
+ this.hasPeek = true;
1577
+ }
1578
+ return this.nextState;
1579
+ }
1580
+
1581
+ next(ctx: LexContext = LexContext.Normal): TokenValue {
1582
+ if (this.hasPeek) {
1583
+ this.hasPeek = false;
1584
+ const temp = this.current;
1585
+ this.current = this.nextState;
1586
+ this.nextState = temp;
1587
+ return this.current;
1588
+ }
1589
+ this.readNext(this.current, ctx);
1590
+ return this.current;
1591
+ }
1592
+
1593
+ unshift(tok: TokenValue): void {
1594
+ this.nextState.copyFrom(tok);
1595
+ this.hasPeek = true;
1596
+ }
1597
+
1598
+ private readNext(out: TokenValue, ctx: LexContext): void {
1599
+ const src = this.src;
1600
+ const len = this.srcEnd;
1601
+
1602
+ let pos = skipBlanks(src, this.pos, len);
1603
+ if (ctx === LexContext.TestMode) {
1604
+ while (pos < len && src.charCodeAt(pos) === CH_NL) pos = skipBlanks(src, pos + 1, len);
1605
+ }
1606
+ this.pos = pos;
1607
+
1608
+ if (pos >= len) {
1609
+ this.consumePendingHereDocs();
1610
+ setToken(out, Token.EOF, "", pos, pos);
1611
+ return;
1612
+ }
1613
+
1614
+ const tokenStart = pos;
1615
+ const ch = src.charCodeAt(pos);
1616
+
1617
+ if (ch === CH_HASH) {
1618
+ // Skip comment
1619
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_NL) this.pos++;
1620
+ this.readNext(out, ctx);
1621
+ return;
1622
+ }
1623
+
1624
+ if (ch === CH_NL) {
1625
+ this.pos++;
1626
+ this.consumePendingHereDocs();
1627
+ setToken(out, Token.Newline, "\n", tokenStart, this.pos);
1628
+ return;
1629
+ }
1630
+
1631
+ // In test mode (inside [[ ]]), < and > are string comparison operators, not redirects —
1632
+ // except for an adjacent '(', which still opens a process substitution
1633
+ if (
1634
+ ctx === LexContext.TestMode &&
1635
+ (ch === CH_LT || ch === CH_GT) &&
1636
+ !(this.pos + 1 < this.srcEnd && src.charCodeAt(this.pos + 1) === CH_LPAREN)
1637
+ ) {
1638
+ this.pos++;
1639
+ setToken(out, Token.Word, ch === CH_LT ? "<" : ">", tokenStart, this.pos);
1640
+ out.keywordEligible = true; // written unquoted, so it is the comparison operator
1641
+ return;
1642
+ }
1643
+
1644
+ // Operators all start with a metachar; anything else is a word
1645
+ if (ch < 128 && charType[ch] & 1 && this.tryReadOperator(out, ch, ctx, tokenStart)) return;
1646
+
1647
+ if (ch === CH_LBRACKET && ctx === LexContext.ArrayElement && this.readArrayElement(out, tokenStart)) return;
1648
+ this.readWord(out, ctx, tokenStart);
1649
+ }
1650
+
1651
+ private tryReadOperator(out: TokenValue, ch: number, ctx: LexContext, tokenStart: number): boolean {
1652
+ const src = this.src;
1653
+ const pos = this.pos;
1654
+ const next = pos + 1 < this.srcEnd ? src.charCodeAt(pos + 1) : 0;
1655
+
1656
+ switch (ch) {
1657
+ case CH_SEMI:
1658
+ if (next === CH_SEMI) {
1659
+ if (pos + 2 < this.srcEnd && src.charCodeAt(pos + 2) === CH_AMP) {
1660
+ this.pos += 3;
1661
+ setToken(out, Token.DoubleSemiAmp, ";;&", tokenStart, this.pos);
1662
+ return true;
1663
+ }
1664
+ this.pos += 2;
1665
+ setToken(out, Token.DoubleSemi, ";;", tokenStart, this.pos);
1666
+ return true;
1667
+ }
1668
+ if (next === CH_AMP) {
1669
+ this.pos += 2;
1670
+ setToken(out, Token.SemiAmp, ";&", tokenStart, this.pos);
1671
+ return true;
1672
+ }
1673
+ this.pos++;
1674
+ setToken(out, Token.Semi, ";", tokenStart, this.pos);
1675
+ return true;
1676
+ case CH_PIPE:
1677
+ if (next === CH_PIPE) {
1678
+ this.pos += 2;
1679
+ setToken(out, Token.Or, "||", tokenStart, this.pos);
1680
+ return true;
1681
+ }
1682
+ if (next === CH_AMP) {
1683
+ this.pos += 2;
1684
+ setToken(out, Token.Pipe, "|&", tokenStart, this.pos);
1685
+ return true;
1686
+ } // |& → pipe (stderr merge)
1687
+ this.pos++;
1688
+ setToken(out, Token.Pipe, "|", tokenStart, this.pos);
1689
+ return true;
1690
+ case CH_AMP:
1691
+ if (next === CH_AMP) {
1692
+ this.pos += 2;
1693
+ setToken(out, Token.And, "&&", tokenStart, this.pos);
1694
+ return true;
1695
+ }
1696
+ if (next === CH_GT) {
1697
+ // &> or &>> — redirect, not background
1698
+ this.pos += 2;
1699
+ const append = this.pos < this.srcEnd && src.charCodeAt(this.pos) === CH_GT;
1700
+ if (append) this.pos++;
1701
+ this.skipSpacesAndTabs();
1702
+ const targetPos = this.pos;
1703
+ if (this.pos < this.srcEnd && src.charCodeAt(this.pos) !== CH_NL && src.charCodeAt(this.pos) !== CH_HASH) {
1704
+ this.readRedirectTargetText();
1705
+ }
1706
+ this.redirectToken(out, append ? "&>>" : "&>", tokenStart, targetPos);
1707
+ return true;
1708
+ }
1709
+ this.pos++;
1710
+ setToken(out, Token.Amp, "&", tokenStart, this.pos);
1711
+ return true;
1712
+ case CH_LPAREN:
1713
+ if (ctx === LexContext.CommandStart && next === CH_LPAREN) {
1714
+ const savedErrors = this.errors.length;
1715
+ this.readArithmeticCommand(out, tokenStart);
1716
+ if (!this._notArithmetic) return true;
1717
+ this.errors.length = savedErrors;
1718
+ this.pos = tokenStart;
1719
+ }
1720
+ this.pos++;
1721
+ setToken(out, Token.LParen, "(", tokenStart, this.pos);
1722
+ return true;
1723
+ case CH_RPAREN:
1724
+ this.pos++;
1725
+ setToken(out, Token.RParen, ")", tokenStart, this.pos);
1726
+ return true;
1727
+ case CH_LT:
1728
+ case CH_GT:
1729
+ return this.readRedirection(out, tokenStart);
1730
+ default:
1731
+ return false;
1732
+ }
1733
+ }
1734
+
1735
+ private readRedirection(out: TokenValue, tokenStart: number): boolean {
1736
+ const src = this.src;
1737
+ const ch = src.charCodeAt(this.pos);
1738
+ let op = "";
1739
+
1740
+ if (ch === CH_LT) {
1741
+ this.pos++;
1742
+ const next = this.pos < this.srcEnd ? src.charCodeAt(this.pos) : 0;
1743
+ if (next === CH_LT) {
1744
+ this.pos++;
1745
+ const third = this.pos < this.srcEnd ? src.charCodeAt(this.pos) : 0;
1746
+ if (third === CH_LT) {
1747
+ // <<< herestring
1748
+ this.pos++;
1749
+ this.skipSpacesAndTabs();
1750
+ const targetPos = this.pos;
1751
+ if (this.pos < this.srcEnd && src.charCodeAt(this.pos) !== CH_NL && src.charCodeAt(this.pos) !== CH_HASH) {
1752
+ this.readRedirectTargetText();
1753
+ }
1754
+ this.redirectToken(out, "<<<", tokenStart, targetPos);
1755
+ return true;
1756
+ }
1757
+ const dash = third === CH_DASH;
1758
+ if (dash) this.pos++;
1759
+ this.skipSpacesAndTabs();
1760
+ const targetPos = this.pos;
1761
+ if (this.pos >= this.srcEnd || src.charCodeAt(this.pos) !== CH_HASH) this.readHereDocDelimiter();
1762
+ const hasTarget = this.pos > targetPos;
1763
+ if (hasTarget) {
1764
+ (this.pendingHereDocs ?? (this.pendingHereDocs = [])).push({
1765
+ delimiter: this._hereDelim,
1766
+ strip: dash,
1767
+ quoted: this._hereQuoted,
1768
+ });
1769
+ }
1770
+ setToken(out, Token.Redirect, dash ? "<<-" : "<<", tokenStart, this.pos);
1771
+ out.content = hasTarget ? this._hereDelim : undefined;
1772
+ out.delimiterQuoted = hasTarget && this._hereQuoted;
1773
+ out.targetPos = targetPos;
1774
+ out.targetEnd = hasTarget ? this.pos : targetPos;
1775
+ return true;
1776
+ }
1777
+ if (next === CH_LPAREN) {
1778
+ this.readProcessSubstitution(out, "<", tokenStart);
1779
+ return true;
1780
+ }
1781
+ if (next === CH_GT) {
1782
+ op = "<>";
1783
+ this.pos++;
1784
+ } else if (next === CH_AMP) {
1785
+ op = "<&";
1786
+ this.pos++;
1787
+ } else {
1788
+ op = "<";
1789
+ }
1790
+ } else if (ch === CH_GT) {
1791
+ this.pos++;
1792
+ const next = this.pos < this.srcEnd ? src.charCodeAt(this.pos) : 0;
1793
+ if (next === CH_LPAREN) {
1794
+ this.readProcessSubstitution(out, ">", tokenStart);
1795
+ return true;
1796
+ }
1797
+ if (next === CH_GT) {
1798
+ op = ">>";
1799
+ this.pos++;
1800
+ } else if (next === CH_AMP) {
1801
+ op = ">&";
1802
+ this.pos++;
1803
+ } else if (next === CH_PIPE) {
1804
+ op = ">|";
1805
+ this.pos++;
1806
+ } else {
1807
+ op = ">";
1808
+ }
1809
+ }
1810
+
1811
+ this.skipSpacesAndTabs();
1812
+ if (this.pos < this.srcEnd) {
1813
+ const nc = src.charCodeAt(this.pos);
1814
+ if ((nc === CH_LT || nc === CH_GT) && this.pos + 1 < this.srcEnd && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
1815
+ const psStart = this.pos;
1816
+ this.pos += 2;
1817
+ this.extractBalanced();
1818
+ if (this._unbalanced) this.errors.push({ message: "unterminated process substitution", pos: psStart });
1819
+ const psText = src.slice(psStart, this.pos);
1820
+ setToken(out, Token.Redirect, op, tokenStart, this.pos);
1821
+ out.content = psText;
1822
+ out.targetPos = psStart;
1823
+ out.targetEnd = this.pos;
1824
+ return true;
1825
+ }
1826
+ const targetPos = this.pos;
1827
+ if (nc !== CH_NL && nc !== CH_HASH) this.readRedirectTargetText();
1828
+ this.redirectToken(out, op, tokenStart, targetPos);
1829
+ return true;
1830
+ }
1831
+
1832
+ this.redirectToken(out, op, tokenStart, this.pos);
1833
+ return true;
1834
+ }
1835
+
1836
+ // Redirect targets keep their processed text eagerly (it becomes the target
1837
+ // word's content), so scan them in value mode.
1838
+ private readRedirectTargetText(): void {
1839
+ const savedBuildValue = this._buildValue;
1840
+ this._buildValue = true;
1841
+ this.readWordText();
1842
+ this._buildValue = savedBuildValue;
1843
+ }
1844
+
1845
+ private redirectToken(out: TokenValue, operator: string, tokenStart: number, targetPos: number): void {
1846
+ const hasTarget = this.pos > targetPos && (this._wordText.length > 0 || this._wordQuoted);
1847
+ setToken(out, Token.Redirect, operator, tokenStart, this.pos);
1848
+ out.content = hasTarget ? this._wordText : undefined;
1849
+ out.targetPos = targetPos;
1850
+ out.targetEnd = hasTarget ? this.pos : targetPos;
1851
+ }
1852
+
1853
+ private readProcessSubstitution(out: TokenValue, operator: "<" | ">", tokenStart: number): void {
1854
+ this.pos++; // skip (
1855
+ this.extractBalanced();
1856
+ if (this._unbalanced) this.errors.push({ message: "unterminated process substitution", pos: tokenStart });
1857
+ const text = this.src.slice(tokenStart, this.pos);
1858
+ setToken(out, Token.Word, text, tokenStart, this.pos);
1859
+ }
1860
+
1861
+ // The delimiter is the word after quote removal: quote and escape segments may
1862
+ // appear anywhere in the word, any of them makes the heredoc quoted, and inside
1863
+ // double quotes a backslash is removed only before $ ` " \.
1864
+ private readHereDocDelimiter(): void {
1865
+ const src = this.src;
1866
+ const len = this.srcEnd;
1867
+ const savedBuildValue = this._buildValue;
1868
+ this._buildValue = true; // the delimiter is assembled from processed segments
1869
+ let delimiter = "";
1870
+ let quoted = false;
1871
+
1872
+ while (this.pos < len) {
1873
+ const c = src.charCodeAt(this.pos);
1874
+ if (c === CH_SQUOTE) {
1875
+ quoted = true;
1876
+ this.pos++;
1877
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_SQUOTE) {
1878
+ delimiter += src[this.pos];
1879
+ this.pos++;
1880
+ }
1881
+ if (this.pos < len) this.pos++;
1882
+ } else if (c === CH_DQUOTE) {
1883
+ quoted = true;
1884
+ this.pos++;
1885
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_DQUOTE) {
1886
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH && this.pos + 1 < len) {
1887
+ const next = src.charCodeAt(this.pos + 1);
1888
+ if (next === CH_NL) {
1889
+ this.pos += 2;
1890
+ continue;
1891
+ }
1892
+ if (next === CH_DOLLAR || next === CH_BACKTICK || next === CH_DQUOTE || next === CH_BACKSLASH) this.pos++;
1893
+ }
1894
+ delimiter += src[this.pos];
1895
+ this.pos++;
1896
+ }
1897
+ if (this.pos < len) this.pos++;
1898
+ } else if (c === CH_BACKSLASH) {
1899
+ if (this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_NL) {
1900
+ this.pos += 2;
1901
+ continue;
1902
+ }
1903
+ quoted = true;
1904
+ this.pos++;
1905
+ if (this.pos < len) {
1906
+ delimiter += src[this.pos];
1907
+ this.pos++;
1908
+ } else {
1909
+ delimiter += "\\"; // trailing backslash escapes nothing and stays as itself
1910
+ }
1911
+ } else if (c === CH_BACKTICK) {
1912
+ const btStart = this.pos;
1913
+ this.pos++;
1914
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
1915
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH) this.pos++;
1916
+ this.pos++;
1917
+ }
1918
+ if (this.pos < len) this.pos++;
1919
+ delimiter += src.slice(btStart, this.pos);
1920
+ } else if (c === CH_DOLLAR) {
1921
+ const next = this.pos + 1 < len ? src.charCodeAt(this.pos + 1) : 0;
1922
+ if (next === CH_SQUOTE || next === CH_DQUOTE) quoted = true;
1923
+ this.readDollar();
1924
+ delimiter += this._resultText;
1925
+ } else if (c < 128 && charType[c] & 1) {
1926
+ break;
1927
+ } else {
1928
+ delimiter += src[this.pos];
1929
+ this.pos++;
1930
+ }
1931
+ }
1932
+ this._buildValue = savedBuildValue;
1933
+ this._hereDelim = delimiter;
1934
+ this._hereQuoted = quoted;
1935
+ }
1936
+
1937
+ private consumePendingHereDocs(): void {
1938
+ const pending = this.pendingHereDocs;
1939
+ if (pending === null || pending.length === 0) return;
1940
+ for (const hd of pending) {
1941
+ const bodyPos = this.pos;
1942
+ const bodyEnd = this.skipHereDocBody(hd.delimiter, hd.strip, this.parenBoundary, hd.quoted);
1943
+ if (hd.target) {
1944
+ hd.target.body.pos = bodyPos;
1945
+ hd.target.body.end = bodyEnd;
1946
+ hd.target.body.text = this.src.slice(bodyPos, bodyEnd);
1947
+ if (this.hereDocClosingPos !== -1)
1948
+ hd.target.closing = { pos: this.hereDocClosingPos, end: this.hereDocClosingEnd };
1949
+ }
1950
+ }
1951
+ pending.length = 0;
1952
+ }
1953
+
1954
+ private hereDocClosingPos = -1;
1955
+ private hereDocClosingEnd = -1;
1956
+
1957
+ // Advance past the heredoc body and its delimiter line; return the body end
1958
+ // (start of the delimiter line, or srcEnd when delimited by end-of-input).
1959
+ // With parenEnds (inside $(...)), a line starting with the delimiter directly
1960
+ // followed by ")" also terminates the body, resuming at the ")" — bash treats
1961
+ // the substitution's closing paren as end-of-file for its heredocs.
1962
+ // `join` crosses `\`+newline pairs, which an unquoted delimiter may straddle.
1963
+ private matchHereDocDelimiter(delimiter: string, lineStart: number, end: number, join: boolean): number {
1964
+ const src = this.src;
1965
+ let pos = lineStart;
1966
+ for (let i = 0; i < delimiter.length;) {
1967
+ if (join && src.charCodeAt(pos) === CH_BACKSLASH && pos + 1 < end && src.charCodeAt(pos + 1) === CH_NL) {
1968
+ pos += 2;
1969
+ continue;
1970
+ }
1971
+ if (pos >= end || src.charCodeAt(pos) !== delimiter.charCodeAt(i)) return -1;
1972
+ pos++;
1973
+ i++;
1974
+ }
1975
+ return pos;
1976
+ }
1977
+
1978
+ // A `\` consumes the next character, so `\`+newline continues the line and `\\` does not.
1979
+ private logicalLineEnd(from: number, end: number, join: boolean): number {
1980
+ const src = this.src;
1981
+ let lineEnd = src.indexOf("\n", from);
1982
+ while (lineEnd !== -1 && lineEnd < end) {
1983
+ if (!join) return lineEnd;
1984
+ let backslashes = lineEnd;
1985
+ while (backslashes > from && src.charCodeAt(backslashes - 1) === CH_BACKSLASH) backslashes--;
1986
+ if ((lineEnd - backslashes) % 2 === 0) return lineEnd;
1987
+ from = lineEnd + 1;
1988
+ lineEnd = src.indexOf("\n", from);
1989
+ }
1990
+ return end;
1991
+ }
1992
+
1993
+ private skipHereDocBody(delimiter: string, strip: boolean, parenEnds = false, quoted = false): number {
1994
+ this.hereDocClosingPos = -1;
1995
+ this.hereDocClosingEnd = -1;
1996
+ const src = this.src;
1997
+ const len = this.srcEnd;
1998
+ while (this.pos < len) {
1999
+ let lineStart = this.pos;
2000
+ const lineEnd = this.logicalLineEnd(lineStart, len, !quoted);
2001
+
2002
+ for (;;) {
2003
+ const before = lineStart;
2004
+ if (strip) while (lineStart < lineEnd && src.charCodeAt(lineStart) === CH_TAB) lineStart++;
2005
+ if (!quoted) lineStart = skipLineContinuations(src, lineStart, lineEnd);
2006
+ if (lineStart === before) break;
2007
+ }
2008
+
2009
+ let afterDelim = this.matchHereDocDelimiter(delimiter, lineStart, lineEnd, !quoted);
2010
+ if (afterDelim !== -1 && !quoted) afterDelim = skipLineContinuations(src, afterDelim, lineEnd);
2011
+ if (afterDelim === lineEnd) {
2012
+ const bodyEnd = this.pos;
2013
+ if (!parenEnds || !this.parenBoundary || lineEnd !== len) {
2014
+ this.hereDocClosingPos = lineStart;
2015
+ this.hereDocClosingEnd = lineEnd;
2016
+ }
2017
+ this.pos = lineEnd < len ? lineEnd + 1 : lineEnd;
2018
+ return bodyEnd;
2019
+ }
2020
+
2021
+ // A line starting with the delimiter also ends the body when a `)` follows on the same
2022
+ // logical line; the scan then resumes mid-line, right after the delimiter.
2023
+ if (parenEnds && afterDelim !== -1) {
2024
+ let paren = afterDelim;
2025
+ while (paren < lineEnd && src.charCodeAt(paren) !== CH_RPAREN) paren++;
2026
+ if (paren < lineEnd || (this.parenBoundary && lineEnd === len)) {
2027
+ const bodyEnd = this.pos;
2028
+ this.pos = afterDelim;
2029
+ return bodyEnd;
2030
+ }
2031
+ }
2032
+
2033
+ this.pos = lineEnd < len ? lineEnd + 1 : lineEnd;
2034
+ }
2035
+ return this.pos;
2036
+ }
2037
+
2038
+ private _wordText = "";
2039
+ private _wordRaw = false;
2040
+ private _wordQuoted = false;
2041
+ private _wordHasExpansions = false;
2042
+ private _wordKeywordEligible = false;
2043
+ private _wordAssignmentState: number | undefined;
2044
+ private _bracketWindow: { limit: number; last: number } | undefined;
2045
+ _wordParts: WordPart[] | null = null;
2046
+ private _resultText = "";
2047
+ // True when the last $-construct's value is exactly its raw source span.
2048
+ private _resultIsRaw = true;
2049
+ private _resultHasExpansion = false;
2050
+ private _resultPart: WordPart | undefined;
2051
+ // Set by extractBalanced when the input ended before the closing paren, so
2052
+ // callers can report the construct they were scanning.
2053
+ private _unbalanced = false;
2054
+ // Set by scanArithmeticBody when the construct turns out not to be arithmetic, so the
2055
+ // caller can re-read it as a subshell or command substitution instead.
2056
+ private _notArithmetic = false;
2057
+ private _arithUnterminated = false;
2058
+ private _dqText = "";
2059
+ private _dqHasExpansions = false;
2060
+ private _dqParts: DoubleQuotedChild[] | null = null;
2061
+ // Content end (before the closing quote, or end of input when unterminated).
2062
+ private _dqEnd = 0;
2063
+ private _hereDelim = "";
2064
+ private _hereQuoted = false;
2065
+
2066
+ private readWord(out: TokenValue, ctx: LexContext, tokenStart: number = 0): void {
2067
+ const errorStart = this._errors === null ? 0 : this._errors.length;
2068
+ this.readWordText(false, ctx, true);
2069
+ if (ctx === LexContext.Normal) {
2070
+ const next = this.pos < this.srcEnd ? this.src.charCodeAt(this.pos) : 0;
2071
+ if (!this._wordRaw || (next !== CH_LT && next !== CH_GT)) {
2072
+ setSpanToken(out, Token.Word, tokenStart, this.pos, this._wordRaw);
2073
+ out.keywordEligible = this._wordKeywordEligible;
2074
+ return;
2075
+ }
2076
+ }
2077
+ this.classifyWord(out, ctx, tokenStart, errorStart);
2078
+ }
2079
+
2080
+ private isRedirectVariable(start: number, end: number): boolean {
2081
+ const src = this.src;
2082
+ for (let i = start; i < end; i++) {
2083
+ const c = src.charCodeAt(i);
2084
+ if (c === CH_LBRACKET && i > start) {
2085
+ return (
2086
+ i + 2 < end && src.charCodeAt(end - 1) === CH_RBRACKET && this.findClosingBracket(i + 1, end) === end - 1
2087
+ );
2088
+ }
2089
+ if (c >= 128 || !(isIdChar[c] & (i === start ? 1 : 2))) return false;
2090
+ }
2091
+ return end > start;
2092
+ }
2093
+
2094
+ private readArrayElement(out: TokenValue, tokenStart: number): boolean {
2095
+ const close = this.findClosingBracket(tokenStart + 1);
2096
+ if (close === -1) return false;
2097
+ this.pos = close + 1;
2098
+ this.readWordText();
2099
+ setSpanToken(out, Token.Word, tokenStart, this.pos, true);
2100
+ return true;
2101
+ }
2102
+
2103
+ private classifyWord(out: TokenValue, ctx: LexContext, tokenStart: number, errorStart: number): void {
2104
+ const src = this.src;
2105
+ const raw = this._wordRaw;
2106
+ const hasExpansions = this._wordHasExpansions;
2107
+ const quoted = this._wordQuoted;
2108
+ const keywordEligible = this._wordKeywordEligible;
2109
+ let assignmentState = this._wordAssignmentState;
2110
+ const wordEnd = this.pos;
2111
+ const wordLen = wordEnd - tokenStart;
2112
+
2113
+ if (ctx === LexContext.CasePattern) {
2114
+ let token: Token = Token.Word;
2115
+ if (keywordEligible) {
2116
+ const reserved = raw
2117
+ ? matchReservedWord(src, tokenStart, wordLen)
2118
+ : RESERVED_WORDS.get(this.wordValueOf(tokenStart, wordEnd));
2119
+ if (reserved === Token.Esac) token = reserved;
2120
+ }
2121
+ setSpanToken(out, token, tokenStart, wordEnd, raw);
2122
+ out.keywordEligible = keywordEligible;
2123
+ return;
2124
+ }
2125
+
2126
+ // Only keyword classification needs a non-raw word's processed value.
2127
+ let value: string | null = null;
2128
+ if (!raw && !hasExpansions && keywordEligible && (ctx === LexContext.CommandStart || ctx === LexContext.TestMode)) {
2129
+ const nextCh = wordEnd < this.srcEnd ? src.charCodeAt(wordEnd) : 0;
2130
+ if ((!quoted && wordLen <= 16) || nextCh === CH_LT || nextCh === CH_GT) {
2131
+ value = this.wordValueOf(tokenStart, wordEnd);
2132
+ }
2133
+ }
2134
+
2135
+ if (ctx === LexContext.CommandStart && keywordEligible) {
2136
+ if (raw) {
2137
+ if (wordLen <= 8) {
2138
+ const reserved = matchReservedWord(src, tokenStart, wordLen);
2139
+ if (reserved !== undefined) {
2140
+ setSpanToken(out, reserved, tokenStart, wordEnd, true);
2141
+ return;
2142
+ }
2143
+ }
2144
+ if (
2145
+ wordLen === 2 &&
2146
+ src.charCodeAt(tokenStart) === CH_LBRACKET &&
2147
+ src.charCodeAt(tokenStart + 1) === CH_LBRACKET
2148
+ ) {
2149
+ setSpanToken(out, Token.DblLBracket, tokenStart, wordEnd, true);
2150
+ return;
2151
+ }
2152
+ } else if (value !== null && value.length > 0) {
2153
+ const fc = value.charCodeAt(0);
2154
+ if ((fc >= CH_a && fc <= CH_z && value.length <= 8) || fc === CH_BANG || fc === CH_LBRACE || fc === CH_RBRACE) {
2155
+ const reserved = RESERVED_WORDS.get(value);
2156
+ if (reserved !== undefined) {
2157
+ setToken(out, reserved, value, tokenStart, wordEnd);
2158
+ return;
2159
+ }
2160
+ }
2161
+ if (fc === CH_LBRACKET && value === "[[") {
2162
+ setToken(out, Token.DblLBracket, value, tokenStart, wordEnd);
2163
+ return;
2164
+ }
2165
+ }
2166
+ }
2167
+ if (ctx === LexContext.CommandStart || ctx === LexContext.CommandPrefix || ctx === LexContext.Declaration) {
2168
+ if (assignmentState === undefined) {
2169
+ // Fast-path word (value === raw span): detect assignment positionally.
2170
+ let eq = -1;
2171
+ let bracket = false;
2172
+ for (let i = tokenStart + 1; i < wordEnd; i++) {
2173
+ const c = src.charCodeAt(i);
2174
+ if (c === CH_EQ) {
2175
+ eq = i;
2176
+ break;
2177
+ }
2178
+ if (c === CH_LBRACKET) bracket = true;
2179
+ }
2180
+ if (eq !== -1 || (ctx !== LexContext.Declaration && bracket)) {
2181
+ assignmentState = scanAssignmentPrefix(src, tokenStart, wordEnd, ASSIGNMENT_NAME_START);
2182
+ }
2183
+ }
2184
+ if (assignmentState !== undefined) {
2185
+ if (ctx !== LexContext.Declaration && assignmentState >= ASSIGNMENT_INDEX_BASE) {
2186
+ if (wordEnd < this.srcEnd) {
2187
+ if (this._errors !== null) this._errors.length = errorStart;
2188
+ this.pos = tokenStart;
2189
+ this.readWordText(true, ctx, true);
2190
+ if (this.pos > wordEnd) {
2191
+ this.classifyWord(out, ctx, tokenStart, errorStart);
2192
+ return;
2193
+ }
2194
+ }
2195
+ if (this._errors === null || this._errors.length === errorStart) {
2196
+ this.errors.push({ message: "unterminated array subscript", pos: src.indexOf("[", tokenStart) });
2197
+ }
2198
+ }
2199
+ if (isMatchedAssignment(assignmentState)) {
2200
+ setSpanToken(out, Token.Assignment, tokenStart, wordEnd, raw);
2201
+ if (value !== null) out._value = value;
2202
+ out.assignmentOperatorPos = assignmentOperatorPos(assignmentState);
2203
+ return;
2204
+ }
2205
+ }
2206
+ }
2207
+ // `]]` is reserved only where it can close a `[[`; operands and wordlist entries, which
2208
+ // read Normal, are ordinary words, as is a `]]` demoted by a command prefix.
2209
+ if ((ctx === LexContext.CommandStart || ctx === LexContext.TestMode) && keywordEligible) {
2210
+ if (raw) {
2211
+ if (
2212
+ wordLen === 2 &&
2213
+ src.charCodeAt(tokenStart) === CH_RBRACKET &&
2214
+ src.charCodeAt(tokenStart + 1) === CH_RBRACKET
2215
+ ) {
2216
+ setSpanToken(out, Token.DblRBracket, tokenStart, wordEnd, true);
2217
+ return;
2218
+ }
2219
+ } else if (value === "]]") {
2220
+ setToken(out, Token.DblRBracket, value, tokenStart, wordEnd);
2221
+ return;
2222
+ }
2223
+ }
2224
+
2225
+ // FD number prefix: all-digit word followed by < or > → redirect with fd
2226
+ if (!hasExpansions && this.pos < this.srcEnd) {
2227
+ const nc = src.charCodeAt(this.pos);
2228
+ if (nc === CH_LT || nc === CH_GT) {
2229
+ if (raw) {
2230
+ const fc = src.charCodeAt(tokenStart);
2231
+ if (fc >= CH_0 && fc <= CH_9 && isAllDigitsRange(src, tokenStart, wordEnd)) {
2232
+ const fd = Number.parseInt(src.slice(tokenStart, wordEnd), 10);
2233
+ if (this.readRedirection(out, tokenStart)) {
2234
+ out.fileDescriptor = fd;
2235
+ out.descriptorEnd = wordEnd;
2236
+ return;
2237
+ }
2238
+ }
2239
+ if (
2240
+ fc === CH_LBRACE &&
2241
+ src.charCodeAt(wordEnd - 1) === CH_RBRACE &&
2242
+ this.isRedirectVariable(tokenStart + 1, wordEnd - 1)
2243
+ ) {
2244
+ const varname = src.slice(tokenStart + 1, wordEnd - 1);
2245
+ if (this.readRedirection(out, tokenStart)) {
2246
+ out.variableName = varname;
2247
+ out.descriptorEnd = wordEnd;
2248
+ return;
2249
+ }
2250
+ }
2251
+ }
2252
+ }
2253
+ }
2254
+
2255
+ setSpanToken(out, Token.Word, tokenStart, wordEnd, raw);
2256
+ if (value !== null) out._value = value;
2257
+ out.keywordEligible = keywordEligible;
2258
+ }
2259
+
2260
+ // `subscripts` re-reads a word known to stop inside an array subscript, where
2261
+ // metacharacters are ordinary text.
2262
+ // `scanning` marks the first scan of a token, which decides where the word ends.
2263
+ private readWordText(subscripts = false, ctx: LexContext = LexContext.Normal, scanning = false): void {
2264
+ const src = this.src;
2265
+ const len = this.srcEnd;
2266
+ let pos = this.pos;
2267
+
2268
+ // Fast path: scan a single run of plain chars (covers most words)
2269
+ const fastStart = pos;
2270
+ let exitCh = 0;
2271
+ while (pos < len) {
2272
+ const c = src.charCodeAt(pos);
2273
+ if (c < 128 && charType[c]) {
2274
+ exitCh = c;
2275
+ break;
2276
+ }
2277
+ pos++;
2278
+ }
2279
+ if (
2280
+ pos >= len ||
2281
+ (charType[exitCh] & 1 &&
2282
+ !(exitCh === CH_LPAREN && pos > fastStart && extglobPrefix[src.charCodeAt(pos - 1)]) &&
2283
+ !subscripts)
2284
+ ) {
2285
+ this.pos = pos;
2286
+ this._wordText = (this._buildParts || this._buildValue) && pos > fastStart ? src.slice(fastStart, pos) : "";
2287
+ this._wordRaw = true;
2288
+ this._wordQuoted = false;
2289
+ this._wordHasExpansions = false;
2290
+ this._wordKeywordEligible = true;
2291
+ this._wordAssignmentState = undefined;
2292
+ if (this._buildParts) {
2293
+ this._wordParts =
2294
+ hasGlobCharacters(this._wordText) && ctx !== LexContext.ArrayElement
2295
+ ? (parseGlobParts(src, fastStart, pos, undefined) ?? null)
2296
+ : null;
2297
+ }
2298
+ return;
2299
+ }
2300
+
2301
+ // Slow path: word contains quotes, expansions, escapes, etc. `text` (the
2302
+ // processed value) is only assembled when building; the token path tracks
2303
+ // positions and flags plus the last value char (for extglob prefixes) and
2304
+ // whether the value still equals the raw span.
2305
+ const bp = this._buildParts;
2306
+ const bt = bp || this._buildValue;
2307
+ let text = bt && pos > fastStart ? src.slice(fastStart, pos) : "";
2308
+ let quoted = false;
2309
+ let hasExpansions = false;
2310
+ let keywordEligible = true;
2311
+ let valueIsRaw = true;
2312
+ let lastValueChar = pos > fastStart ? src.charCodeAt(pos - 1) : 0;
2313
+ let assignmentState = scanAssignmentPrefix(src, fastStart, pos, ASSIGNMENT_NAME_START);
2314
+ let parts: WordPart[] | undefined;
2315
+ let litBuf = "";
2316
+ let litStart = 0;
2317
+ let hasGlobs = false;
2318
+ if (bp) {
2319
+ parts = [];
2320
+ litBuf = text; // fast-path prefix is literal
2321
+ litStart = fastStart;
2322
+ hasGlobs = text.length > 0 && hasGlobCharacters(text);
2323
+ }
2324
+
2325
+ while (pos < len) {
2326
+ const ch = src.charCodeAt(pos);
2327
+
2328
+ if (ch >= 128 || !charType[ch]) {
2329
+ const runStart = pos;
2330
+ pos++;
2331
+ while (pos < len) {
2332
+ const c = src.charCodeAt(pos);
2333
+ if (c < 128 && charType[c]) break;
2334
+ pos++;
2335
+ }
2336
+ lastValueChar = src.charCodeAt(pos - 1);
2337
+ assignmentState = scanAssignmentPrefix(src, runStart, pos, assignmentState);
2338
+ if (bt) {
2339
+ const chunk = src.slice(runStart, pos);
2340
+ text += chunk;
2341
+ if (bp) {
2342
+ litBuf += chunk;
2343
+ hasGlobs = hasGlobs || hasGlobCharacters(chunk);
2344
+ }
2345
+ }
2346
+ continue;
2347
+ }
2348
+
2349
+ if (charType[ch] & 1) {
2350
+ if (subscripts && assignmentState >= ASSIGNMENT_INDEX_BASE) {
2351
+ const close = this.findClosingBracket(pos);
2352
+ if (close !== -1) {
2353
+ const spanEnd = close + 1;
2354
+ // The scanner matched the innermost open bracket past quotes, escapes and nested
2355
+ // substitutions, so the jump closes exactly one subscript level.
2356
+ assignmentState =
2357
+ assignmentState === ASSIGNMENT_INDEX_BASE + 1 ? ASSIGNMENT_AFTER_INDEX : assignmentState - 1;
2358
+ lastValueChar = src.charCodeAt(close);
2359
+ if (bt) {
2360
+ const chunk = src.slice(pos, spanEnd);
2361
+ text += chunk;
2362
+ if (bp) {
2363
+ litBuf += chunk;
2364
+ hasGlobs = hasGlobs || hasGlobCharacters(chunk);
2365
+ }
2366
+ }
2367
+ pos = spanEnd;
2368
+ continue;
2369
+ }
2370
+ }
2371
+ if (ch === CH_LPAREN && lastValueChar < 128 && extglobPrefix[lastValueChar]) {
2372
+ if (
2373
+ ctx === LexContext.FunctionName ||
2374
+ (assignmentState >= ASSIGNMENT_INDEX_BASE &&
2375
+ (ctx === LexContext.CommandStart || ctx === LexContext.CommandPrefix))
2376
+ )
2377
+ break;
2378
+ // Default Bash command syntax takes precedence over the extglob extension.
2379
+ if (
2380
+ ctx === LexContext.CommandStart &&
2381
+ lastValueChar === CH_BANG &&
2382
+ keywordEligible &&
2383
+ src.charCodeAt(fastStart) === CH_BANG &&
2384
+ skipLineContinuations(src, fastStart + 1, len) === pos
2385
+ )
2386
+ break;
2387
+ const prefixChar = lastValueChar;
2388
+ // Bash opens an array value only right after the operator of a word that can assign;
2389
+ // elsewhere `(` ends the word. Rescans of a known word span keep its value.
2390
+ if (
2391
+ prefixChar === CH_EQ &&
2392
+ scanning &&
2393
+ !(
2394
+ (ctx === LexContext.CommandStart ||
2395
+ ctx === LexContext.CommandPrefix ||
2396
+ ctx === LexContext.Declaration ||
2397
+ ctx === LexContext.EvalArgument) &&
2398
+ isMatchedAssignment(assignmentState) &&
2399
+ skipLineContinuations(src, assignmentOperatorPos(assignmentState) + 1, len) === pos
2400
+ )
2401
+ )
2402
+ break;
2403
+ const innerStart = pos + 1;
2404
+ const close = this.findClosingShellDelimiter(innerStart, len, CH_RPAREN, prefixChar === CH_EQ);
2405
+ if (close === -1 && !scanning && !subscripts && assignmentState >= ASSIGNMENT_INDEX_BASE) break;
2406
+ if (
2407
+ ctx === LexContext.CommandStart &&
2408
+ prefixChar !== CH_EQ &&
2409
+ close !== -1 &&
2410
+ skipBlanks(src, innerStart, close) === close
2411
+ )
2412
+ break;
2413
+ keywordEligible = false;
2414
+ const patternEnd = close === -1 ? len : close;
2415
+ pos = close === -1 ? len : close + 1;
2416
+ if (close === -1) this.errors.push({ message: "unterminated extended glob", pos: innerStart - 2 });
2417
+ lastValueChar = src.charCodeAt(pos - 1);
2418
+ if (bt) {
2419
+ const eg = "(" + src.slice(innerStart, pos);
2420
+ text += eg;
2421
+ // Create ExtendedGlob part for real extglob operators (not = which is array assignment)
2422
+ if (bp && prefixChar !== CH_EQ) {
2423
+ let prefixPos = innerStart - 2;
2424
+ while (src.charCodeAt(prefixPos) === CH_NL && src.charCodeAt(prefixPos - 1) === CH_BACKSLASH) {
2425
+ prefixPos -= 2;
2426
+ }
2427
+ // Remove the prefix char from litBuf (it was appended in the previous iteration)
2428
+ if (litBuf.length > 0) {
2429
+ const trimmed = litBuf.slice(0, -1);
2430
+ if (prefixPos > litStart)
2431
+ parts!.push({
2432
+ type: "Literal",
2433
+ pos: litStart,
2434
+ end: prefixPos,
2435
+ value: trimmed,
2436
+ text: src.slice(litStart, prefixPos),
2437
+ });
2438
+ litBuf = "";
2439
+ }
2440
+ const op = extglobOp[prefixChar];
2441
+ parts!.push({
2442
+ type: "ExtendedGlob",
2443
+ pos: prefixPos,
2444
+ end: pos,
2445
+ text: src.slice(prefixPos, pos),
2446
+ operator: op,
2447
+ pattern: src.slice(innerStart, patternEnd),
2448
+ parts: this.subFieldParts(innerStart, patternEnd),
2449
+ });
2450
+ litStart = pos;
2451
+ } else if (bp) {
2452
+ litBuf += eg;
2453
+ hasGlobs = hasGlobs || hasGlobCharacters(eg);
2454
+ }
2455
+ }
2456
+ continue;
2457
+ }
2458
+
2459
+ break;
2460
+ }
2461
+
2462
+ if (ch === CH_BACKSLASH) {
2463
+ pos++;
2464
+ if (pos < len) {
2465
+ if (src.charCodeAt(pos) === CH_NL) {
2466
+ pos++;
2467
+ valueIsRaw = false;
2468
+ } else {
2469
+ if (assignmentState >= 0 && assignmentState < ASSIGNMENT_INDEX_BASE) assignmentState = ASSIGNMENT_INVALID;
2470
+ quoted = true;
2471
+ keywordEligible = false;
2472
+ valueIsRaw = false;
2473
+ lastValueChar = src.charCodeAt(pos);
2474
+ if (bt) {
2475
+ text += src[pos];
2476
+ if (bp) litBuf += src[pos];
2477
+ }
2478
+ pos++;
2479
+ }
2480
+ } else {
2481
+ // A backslash at end of input escapes nothing and stays as itself.
2482
+ if (assignmentState >= 0 && assignmentState < ASSIGNMENT_INDEX_BASE) assignmentState = ASSIGNMENT_INVALID;
2483
+ quoted = true;
2484
+ keywordEligible = false;
2485
+ lastValueChar = CH_BACKSLASH;
2486
+ if (bt) {
2487
+ text += "\\";
2488
+ if (bp) litBuf += "\\";
2489
+ }
2490
+ }
2491
+ continue;
2492
+ }
2493
+
2494
+ if (ch === CH_SQUOTE) {
2495
+ const sqStart = pos;
2496
+ if (assignmentState >= 0 && assignmentState < ASSIGNMENT_INDEX_BASE) assignmentState = ASSIGNMENT_INVALID;
2497
+ quoted = true;
2498
+ keywordEligible = false;
2499
+ valueIsRaw = false;
2500
+ pos++;
2501
+ const start = pos;
2502
+ while (pos < len && src.charCodeAt(pos) !== CH_SQUOTE) pos++;
2503
+ if (pos > start) lastValueChar = src.charCodeAt(pos - 1);
2504
+ const value = bt ? src.slice(start, pos) : "";
2505
+ if (bt) text += value;
2506
+ if (pos < len) pos++;
2507
+ else this.errors.push({ message: "unterminated single quote", pos: start - 1 });
2508
+ if (bp) {
2509
+ if (sqStart > litStart) {
2510
+ parts!.push({
2511
+ type: "Literal",
2512
+ pos: litStart,
2513
+ end: sqStart,
2514
+ value: litBuf,
2515
+ text: src.slice(litStart, sqStart),
2516
+ });
2517
+ litBuf = "";
2518
+ }
2519
+ parts!.push({ type: "SingleQuoted", pos: sqStart, end: pos, value, text: src.slice(sqStart, pos) });
2520
+ litStart = pos;
2521
+ }
2522
+ continue;
2523
+ }
2524
+
2525
+ if (ch === CH_DQUOTE) {
2526
+ const dqStart = pos;
2527
+ if (assignmentState >= 0 && assignmentState < ASSIGNMENT_INDEX_BASE) assignmentState = ASSIGNMENT_INVALID;
2528
+ quoted = true;
2529
+ keywordEligible = false;
2530
+ valueIsRaw = false;
2531
+ pos++;
2532
+ this.pos = pos;
2533
+ this.readDoubleQuoted();
2534
+ pos = this.pos;
2535
+ if (this._dqEnd > dqStart + 1) lastValueChar = src.charCodeAt(this._dqEnd - 1);
2536
+ if (this._dqHasExpansions) hasExpansions = true;
2537
+ if (bt) text += this._dqText;
2538
+ if (bp) {
2539
+ if (dqStart > litStart) {
2540
+ parts!.push({
2541
+ type: "Literal",
2542
+ pos: litStart,
2543
+ end: dqStart,
2544
+ value: litBuf,
2545
+ text: src.slice(litStart, dqStart),
2546
+ });
2547
+ litBuf = "";
2548
+ }
2549
+ const dqText = src.slice(dqStart, pos);
2550
+ parts!.push({
2551
+ type: "DoubleQuoted",
2552
+ pos: dqStart,
2553
+ end: pos,
2554
+ text: dqText,
2555
+ parts: this._dqParts ?? [
2556
+ {
2557
+ type: "Literal",
2558
+ pos: dqStart + 1,
2559
+ end: this._dqEnd,
2560
+ value: this._dqText,
2561
+ text: src.slice(dqStart + 1, this._dqEnd),
2562
+ },
2563
+ ],
2564
+ });
2565
+ litStart = pos;
2566
+ }
2567
+ continue;
2568
+ }
2569
+
2570
+ if (ch === CH_DOLLAR) {
2571
+ keywordEligible = false;
2572
+ const dollarStart = pos;
2573
+ if (assignmentState >= 0 && assignmentState < ASSIGNMENT_INDEX_BASE) assignmentState = ASSIGNMENT_INVALID;
2574
+ this.pos = pos;
2575
+ this.readDollar();
2576
+ pos = this.pos;
2577
+ if (!this._resultIsRaw) valueIsRaw = false;
2578
+ if (pos > dollarStart) lastValueChar = src.charCodeAt(pos - 1);
2579
+ if (this._resultHasExpansion) hasExpansions = true;
2580
+ if (bt) text += this._resultText;
2581
+ if (bp) {
2582
+ if (this._resultPart) {
2583
+ if (dollarStart > litStart) {
2584
+ parts!.push({
2585
+ type: "Literal",
2586
+ pos: litStart,
2587
+ end: dollarStart,
2588
+ value: litBuf,
2589
+ text: src.slice(litStart, dollarStart),
2590
+ });
2591
+ litBuf = "";
2592
+ }
2593
+ parts!.push(this._resultPart);
2594
+ litStart = pos;
2595
+ } else {
2596
+ litBuf += this._resultText;
2597
+ }
2598
+ }
2599
+ continue;
2600
+ }
2601
+
2602
+ if (ch === CH_BACKTICK) {
2603
+ keywordEligible = false;
2604
+ const btStart = pos;
2605
+ if (assignmentState >= 0 && assignmentState < ASSIGNMENT_INDEX_BASE) assignmentState = ASSIGNMENT_INVALID;
2606
+ this.pos = pos;
2607
+ this.readBacktickExpansion();
2608
+ pos = this.pos;
2609
+ valueIsRaw = false;
2610
+ lastValueChar = src.charCodeAt(pos - 1);
2611
+ hasExpansions = true;
2612
+ if (bt) text += this._resultText;
2613
+ if (bp) {
2614
+ if (btStart > litStart) {
2615
+ parts!.push({
2616
+ type: "Literal",
2617
+ pos: litStart,
2618
+ end: btStart,
2619
+ value: litBuf,
2620
+ text: src.slice(litStart, btStart),
2621
+ });
2622
+ litBuf = "";
2623
+ }
2624
+ parts!.push(this._resultPart!);
2625
+ litStart = pos;
2626
+ }
2627
+ continue;
2628
+ }
2629
+
2630
+ if (ch === CH_LBRACE) {
2631
+ if (assignmentState >= 0 && assignmentState < ASSIGNMENT_INDEX_BASE) assignmentState = ASSIGNMENT_INVALID;
2632
+ const braceEnd = this.scanBraceExpansion(pos);
2633
+ if (braceEnd > 0) {
2634
+ keywordEligible = false;
2635
+ lastValueChar = src.charCodeAt(braceEnd - 1);
2636
+ if (bt) {
2637
+ const braceText = src.slice(pos, braceEnd);
2638
+ text += braceText;
2639
+ if (bp) {
2640
+ if (pos > litStart) {
2641
+ parts!.push({
2642
+ type: "Literal",
2643
+ pos: litStart,
2644
+ end: pos,
2645
+ value: litBuf,
2646
+ text: src.slice(litStart, pos),
2647
+ });
2648
+ litBuf = "";
2649
+ }
2650
+ parts!.push({
2651
+ type: "BraceExpansion",
2652
+ pos,
2653
+ end: braceEnd,
2654
+ text: braceText,
2655
+ parts: this.subFieldParts(pos + 1, braceEnd - 1),
2656
+ });
2657
+ litStart = braceEnd;
2658
+ }
2659
+ }
2660
+ pos = braceEnd;
2661
+ continue;
2662
+ }
2663
+ lastValueChar = CH_LBRACE;
2664
+ if (bt) {
2665
+ text += "{";
2666
+ if (bp) litBuf += "{";
2667
+ }
2668
+ pos++;
2669
+ continue;
2670
+ }
2671
+
2672
+ pos++;
2673
+ }
2674
+
2675
+ if (bp && pos > litStart)
2676
+ parts!.push({ type: "Literal", pos: litStart, end: pos, value: litBuf, text: src.slice(litStart, pos) });
2677
+
2678
+ this.pos = pos;
2679
+ this._wordText = text;
2680
+ this._wordRaw = valueIsRaw;
2681
+ this._wordQuoted = quoted;
2682
+ this._wordHasExpansions = hasExpansions;
2683
+ this._wordKeywordEligible = keywordEligible;
2684
+ this._wordAssignmentState = assignmentState;
2685
+ if (bp) {
2686
+ // Only store parts if they add structure beyond a single literal
2687
+ this._wordParts = parts!.length > 1 || (parts!.length === 1 && parts![0].type !== "Literal") ? parts! : null;
2688
+ if (hasGlobs && ctx !== LexContext.ArrayElement)
2689
+ this._wordParts = parseGlobParts(src, fastStart, pos, this._wordParts) ?? null;
2690
+ }
2691
+ }
2692
+
2693
+ // Arithmetic text gets expansions and quote removal but no process substitution.
2694
+ private readInnerWordText(insideDoubleQuotes = false, arithmetic = false, globs = false): void {
2695
+ const src = this.src;
2696
+ const len = this.srcEnd;
2697
+ let pos = this.pos;
2698
+ const start = pos;
2699
+ let text = "";
2700
+ const bp = this._buildParts;
2701
+ let parts: WordPart[] | undefined;
2702
+ let litBuf = "";
2703
+ let litStart = 0;
2704
+ let preserveLiteral = false;
2705
+ let hasGlobs = false;
2706
+ if (bp) {
2707
+ parts = [];
2708
+ litStart = pos;
2709
+ }
2710
+
2711
+ while (pos < len) {
2712
+ const ch = src.charCodeAt(pos);
2713
+
2714
+ if (ch === CH_BACKSLASH) {
2715
+ pos++;
2716
+ if (pos < len) {
2717
+ if (src.charCodeAt(pos) === CH_NL) {
2718
+ pos++;
2719
+ } else {
2720
+ const next = src.charCodeAt(pos);
2721
+ if (
2722
+ insideDoubleQuotes &&
2723
+ next !== CH_DOLLAR &&
2724
+ next !== CH_BACKTICK &&
2725
+ next !== CH_DQUOTE &&
2726
+ next !== CH_BACKSLASH &&
2727
+ next !== CH_RBRACE
2728
+ ) {
2729
+ text += "\\";
2730
+ if (bp) litBuf += "\\";
2731
+ preserveLiteral = true;
2732
+ }
2733
+ const escaped = src[pos++];
2734
+ text += escaped;
2735
+ if (bp) litBuf += escaped;
2736
+ }
2737
+ }
2738
+ continue;
2739
+ }
2740
+
2741
+ if (ch === CH_SQUOTE && !insideDoubleQuotes) {
2742
+ const sqStart = pos;
2743
+ pos++;
2744
+ const start = pos;
2745
+ while (pos < len && src.charCodeAt(pos) !== CH_SQUOTE) pos++;
2746
+ const value = src.slice(start, pos);
2747
+ text += value;
2748
+ if (pos < len) pos++;
2749
+ if (bp) {
2750
+ if (sqStart > litStart) {
2751
+ parts!.push({
2752
+ type: "Literal",
2753
+ pos: litStart,
2754
+ end: sqStart,
2755
+ value: litBuf,
2756
+ text: src.slice(litStart, sqStart),
2757
+ });
2758
+ litBuf = "";
2759
+ }
2760
+ parts!.push({ type: "SingleQuoted", pos: sqStart, end: pos, value, text: src.slice(sqStart, pos) });
2761
+ litStart = pos;
2762
+ }
2763
+ continue;
2764
+ }
2765
+
2766
+ if (ch === CH_DQUOTE) {
2767
+ const dqStart = pos;
2768
+ pos++;
2769
+ this.pos = pos;
2770
+ this.readDoubleQuoted(insideDoubleQuotes);
2771
+ pos = this.pos;
2772
+ text += this._dqText;
2773
+ if (bp) {
2774
+ if (dqStart > litStart) {
2775
+ parts!.push({
2776
+ type: "Literal",
2777
+ pos: litStart,
2778
+ end: dqStart,
2779
+ value: litBuf,
2780
+ text: src.slice(litStart, dqStart),
2781
+ });
2782
+ litBuf = "";
2783
+ }
2784
+ const dqText = src.slice(dqStart, pos);
2785
+ parts!.push({
2786
+ type: "DoubleQuoted",
2787
+ pos: dqStart,
2788
+ end: pos,
2789
+ text: dqText,
2790
+ parts: this._dqParts ?? [
2791
+ {
2792
+ type: "Literal",
2793
+ pos: dqStart + 1,
2794
+ end: this._dqEnd,
2795
+ value: this._dqText,
2796
+ text: src.slice(dqStart + 1, this._dqEnd),
2797
+ },
2798
+ ],
2799
+ });
2800
+ litStart = pos;
2801
+ }
2802
+ continue;
2803
+ }
2804
+
2805
+ if (ch === CH_DOLLAR) {
2806
+ const dollarStart = pos;
2807
+ this.pos = pos;
2808
+ this.readDollar(insideDoubleQuotes);
2809
+ pos = this.pos;
2810
+ text += this._resultText;
2811
+ if (bp) {
2812
+ if (this._resultPart) {
2813
+ if (dollarStart > litStart) {
2814
+ parts!.push({
2815
+ type: "Literal",
2816
+ pos: litStart,
2817
+ end: dollarStart,
2818
+ value: litBuf,
2819
+ text: src.slice(litStart, dollarStart),
2820
+ });
2821
+ litBuf = "";
2822
+ }
2823
+ parts!.push(this._resultPart);
2824
+ litStart = pos;
2825
+ } else {
2826
+ litBuf += this._resultText;
2827
+ }
2828
+ }
2829
+ continue;
2830
+ }
2831
+
2832
+ if (ch === CH_BACKTICK) {
2833
+ const btStart = pos;
2834
+ this.pos = pos;
2835
+ this.readBacktickExpansion(insideDoubleQuotes);
2836
+ pos = this.pos;
2837
+ text += this._resultText;
2838
+ if (bp) {
2839
+ if (btStart > litStart) {
2840
+ parts!.push({
2841
+ type: "Literal",
2842
+ pos: litStart,
2843
+ end: btStart,
2844
+ value: litBuf,
2845
+ text: src.slice(litStart, btStart),
2846
+ });
2847
+ litBuf = "";
2848
+ }
2849
+ parts!.push(this._resultPart!);
2850
+ litStart = pos;
2851
+ }
2852
+ continue;
2853
+ }
2854
+
2855
+ if (
2856
+ !insideDoubleQuotes &&
2857
+ !arithmetic &&
2858
+ (ch === CH_LT || ch === CH_GT) &&
2859
+ pos + 1 < len &&
2860
+ src.charCodeAt(pos + 1) === CH_LPAREN
2861
+ ) {
2862
+ const psStart = pos;
2863
+ this.pos = pos + 2;
2864
+ const inner = this.extractBalanced();
2865
+ pos = this.pos;
2866
+ const raw = src.slice(psStart, pos);
2867
+ text += raw;
2868
+ if (bp) {
2869
+ if (psStart > litStart) {
2870
+ parts!.push({
2871
+ type: "Literal",
2872
+ pos: litStart,
2873
+ end: psStart,
2874
+ value: litBuf,
2875
+ text: src.slice(litStart, psStart),
2876
+ });
2877
+ litBuf = "";
2878
+ }
2879
+ parts!.push(this.processSubstitution(psStart, raw, ch === CH_LT ? "<" : ">", inner));
2880
+ litStart = pos;
2881
+ }
2882
+ continue;
2883
+ }
2884
+
2885
+ text += src[pos];
2886
+ if (bp) {
2887
+ litBuf += src[pos];
2888
+ if (globs && (ch === CH_STAR || ch === CH_QUESTION || ch === CH_LBRACKET)) hasGlobs = true;
2889
+ }
2890
+ pos++;
2891
+ }
2892
+
2893
+ if (bp && pos > litStart)
2894
+ parts!.push({ type: "Literal", pos: litStart, end: pos, value: litBuf, text: src.slice(litStart, pos) });
2895
+
2896
+ this.pos = pos;
2897
+ this._wordText = text;
2898
+ this._wordRaw = false;
2899
+ this._wordQuoted = false;
2900
+ this._wordHasExpansions = false;
2901
+ this._wordKeywordEligible = false;
2902
+ if (bp) {
2903
+ this._wordParts =
2904
+ parts!.length > 1 || (parts!.length === 1 && (parts![0].type !== "Literal" || preserveLiteral)) ? parts! : null;
2905
+ if (hasGlobs) this._wordParts = parseGlobParts(src, start, pos, this._wordParts) ?? null;
2906
+ }
2907
+ }
2908
+
2909
+ private subFieldParts(start: number, end: number): WordPart[] | undefined {
2910
+ if (this._nestingDepth >= MAX_SYNTAX_NESTING) return undefined;
2911
+ return hasEmbeddedWordStructure(this.src, start, end)
2912
+ ? this.parseSubFieldWord(start, end).parts
2913
+ : parseGlobParts(this.src, start, end, undefined);
2914
+ }
2915
+
2916
+ // Parse a parameter-expansion sub-field (operand, slice bound, replacement pattern) over
2917
+ // the window [start, end) of the original source. Parsing in place — rather than against a
2918
+ // detached slice — gives the word and any nested substitutions absolute offsets, and
2919
+ // composes through nested ${...}. The `${...}` inner is a verbatim substring of the
2920
+ // source, so every sub-field offset maps straight back.
2921
+ private parseSubFieldWord(start: number, end: number, context: SubFieldContext = "word", arithmetic = false): Word {
2922
+ if (start >= end) return new WordImpl("", start, start);
2923
+ // Past the shared budget the sub-field keeps its raw text without parts.
2924
+ if (this._nestingDepth >= MAX_SYNTAX_NESTING) return new WordImpl(this.src.slice(start, end), start, end);
2925
+ this._nestingDepth++;
2926
+ const savedEnd = this.srcEnd;
2927
+ const savedPos = this.pos;
2928
+ const savedText = this._wordText;
2929
+ const savedParts = this._wordParts;
2930
+ const savedQuoted = this._wordQuoted;
2931
+ const savedKeywordEligible = this._wordKeywordEligible;
2932
+
2933
+ this.srcEnd = end;
2934
+ this.pos = start;
2935
+ this.readInnerWordText(context === "quoted", arithmetic, context === "word");
2936
+
2937
+ // text is the raw source span (so source.slice(pos, end) === text holds, as for every
2938
+ // other word); value resolves quotes via parts.
2939
+ const word = new WordImpl(this.src.slice(start, end), start, end);
2940
+ if (this._buildParts) {
2941
+ word.parts = this._wordParts ?? undefined;
2942
+ }
2943
+
2944
+ this.srcEnd = savedEnd;
2945
+ this.pos = savedPos;
2946
+ this._wordText = savedText;
2947
+ this._wordParts = savedParts;
2948
+ this._wordQuoted = savedQuoted;
2949
+ this._wordKeywordEligible = savedKeywordEligible;
2950
+ this._nestingDepth--;
2951
+ return word;
2952
+ }
2953
+
2954
+ private skipSQ(): void {
2955
+ while (this.pos < this.srcEnd && this.src.charCodeAt(this.pos) !== CH_SQUOTE) this.pos++;
2956
+ if (this.pos < this.srcEnd) this.pos++;
2957
+ }
2958
+
2959
+ private skipAnsiCQuoted(): void {
2960
+ const quotePos = this.pos - 1;
2961
+ const result = decodeAnsiCQuoted(this.src, this.pos, this.srcEnd);
2962
+ this.pos = result.end;
2963
+ if (!result.closed) this.errors.push({ message: "unterminated ANSI-C quote", pos: quotePos });
2964
+ }
2965
+
2966
+ // Plain quoted text is skipped here; the first `$(`, `${`, `$$`, backtick or the end of input
2967
+ // hands the rest to the quote scanner, which owns substitution and nesting-limit decisions.
2968
+ private skipDQ(): void {
2969
+ const src = this.src;
2970
+ const len = this.srcEnd;
2971
+ this._unbalanced = false;
2972
+ if (this._nestingDepth < MAX_SYNTAX_NESTING) {
2973
+ while (this.pos < len) {
2974
+ const ch = src.charCodeAt(this.pos);
2975
+ if (ch === CH_DQUOTE) {
2976
+ this.pos++;
2977
+ return;
2978
+ }
2979
+ if (ch === CH_BACKSLASH) {
2980
+ this.pos = Math.min(this.pos + 2, len);
2981
+ continue;
2982
+ }
2983
+ if (ch === CH_BACKTICK) break;
2984
+ if (ch === CH_DOLLAR) {
2985
+ const next = src.charCodeAt(this.pos + 1);
2986
+ if (next === CH_LPAREN || next === CH_LBRACE || next === CH_DOLLAR) break;
2987
+ }
2988
+ this.pos++;
2989
+ }
2990
+ }
2991
+ this.extractBalanced(true, true);
2992
+ }
2993
+
2994
+ private skipSpacesAndTabs(): void {
2995
+ this.pos = skipBlanks(this.src, this.pos, this.srcEnd);
2996
+ }
2997
+
2998
+ private readDoubleQuoted(unescapeAll = false): void {
2999
+ const src = this.src;
3000
+ const len = this.srcEnd;
3001
+ const contentStart = this.pos;
3002
+ let hasExpansions = false;
3003
+ const bp = this._buildParts;
3004
+ const bt = bp || this._buildValue;
3005
+
3006
+ // Fast path: pure literal content (no $, `, or \ — just find closing ")
3007
+ if (!bp) {
3008
+ let p = this.pos;
3009
+ while (p < len) {
3010
+ const c = src.charCodeAt(p);
3011
+ if (c === CH_DQUOTE) {
3012
+ this._dqText = bt ? src.slice(contentStart, p) : "";
3013
+ this._dqEnd = p;
3014
+ this.pos = p + 1;
3015
+ this._dqHasExpansions = false;
3016
+ this._dqParts = null;
3017
+ return;
3018
+ }
3019
+ if (c === CH_DOLLAR || c === CH_BACKTICK || c === CH_BACKSLASH) break;
3020
+ p++;
3021
+ }
3022
+ // Fall through to general path
3023
+ }
3024
+
3025
+ let text = "";
3026
+ let parts: DoubleQuotedChild[] | null = null;
3027
+ let litBuf = "";
3028
+ let litStart = bp ? this.pos : 0;
3029
+
3030
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_DQUOTE) {
3031
+ // Scan run of plain chars inside double quotes
3032
+ const runStart = this.pos;
3033
+ while (this.pos < len) {
3034
+ const c = src.charCodeAt(this.pos);
3035
+ if (c === CH_DQUOTE || c === CH_BACKSLASH || c === CH_DOLLAR || c === CH_BACKTICK) break;
3036
+ this.pos++;
3037
+ }
3038
+ if (bt && this.pos > runStart) {
3039
+ const chunk = src.slice(runStart, this.pos);
3040
+ text += chunk;
3041
+ if (bp) litBuf += chunk;
3042
+ }
3043
+
3044
+ if (this.pos >= len || src.charCodeAt(this.pos) === CH_DQUOTE) break;
3045
+
3046
+ const ch = src.charCodeAt(this.pos);
3047
+
3048
+ if (ch === CH_BACKSLASH) {
3049
+ this.pos++;
3050
+ if (this.pos < len) {
3051
+ const next = src.charCodeAt(this.pos);
3052
+ if (next === CH_NL) {
3053
+ this.pos++;
3054
+ continue;
3055
+ }
3056
+ if (bt) {
3057
+ if (
3058
+ unescapeAll ||
3059
+ next === CH_DOLLAR ||
3060
+ next === CH_BACKTICK ||
3061
+ next === CH_DQUOTE ||
3062
+ next === CH_BACKSLASH
3063
+ ) {
3064
+ const c = src[this.pos];
3065
+ text += c;
3066
+ if (bp) litBuf += c;
3067
+ } else {
3068
+ const pair = "\\" + src[this.pos];
3069
+ text += pair;
3070
+ if (bp) litBuf += pair;
3071
+ }
3072
+ }
3073
+ this.pos++;
3074
+ }
3075
+ continue;
3076
+ }
3077
+
3078
+ if (ch === CH_DOLLAR) {
3079
+ // Inside double quotes `$"` and `$'` are a literal $: no locale string, no ANSI-C quote.
3080
+ const afterDollar = this.pos + 1 < len ? src.charCodeAt(this.pos + 1) : 0;
3081
+ if (afterDollar === CH_DQUOTE || afterDollar === CH_SQUOTE) {
3082
+ if (bt) {
3083
+ text += "$";
3084
+ if (bp) litBuf += "$";
3085
+ }
3086
+ this.pos++;
3087
+ continue;
3088
+ }
3089
+ const expStart = this.pos;
3090
+ this.readDollar(true);
3091
+ if (bt) text += this._resultText;
3092
+ if (this._resultHasExpansion) hasExpansions = true;
3093
+ if (bp) {
3094
+ const rp = this._resultPart;
3095
+ if (rp && isDQChild(rp)) {
3096
+ if (!parts) parts = [];
3097
+ if (expStart > litStart) {
3098
+ parts.push({
3099
+ type: "Literal",
3100
+ pos: litStart,
3101
+ end: expStart,
3102
+ value: litBuf,
3103
+ text: src.slice(litStart, expStart),
3104
+ });
3105
+ litBuf = "";
3106
+ }
3107
+ parts.push(rp);
3108
+ litStart = this.pos;
3109
+ } else {
3110
+ litBuf += this._resultText;
3111
+ }
3112
+ }
3113
+ continue;
3114
+ }
3115
+
3116
+ if (ch === CH_BACKTICK) {
3117
+ const btStart = this.pos;
3118
+ this.readBacktickExpansion(true);
3119
+ if (bt) text += this._resultText;
3120
+ hasExpansions = true;
3121
+ if (bp && this._resultPart && isDQChild(this._resultPart)) {
3122
+ if (!parts) parts = [];
3123
+ if (btStart > litStart) {
3124
+ parts.push({
3125
+ type: "Literal",
3126
+ pos: litStart,
3127
+ end: btStart,
3128
+ value: litBuf,
3129
+ text: src.slice(litStart, btStart),
3130
+ });
3131
+ litBuf = "";
3132
+ }
3133
+ parts.push(this._resultPart);
3134
+ litStart = this.pos;
3135
+ }
3136
+ continue;
3137
+ }
3138
+ }
3139
+
3140
+ if (bp && parts && this.pos > litStart)
3141
+ parts.push({ type: "Literal", pos: litStart, end: this.pos, value: litBuf, text: src.slice(litStart, this.pos) });
3142
+
3143
+ this._dqEnd = this.pos;
3144
+ if (this.pos < len) this.pos++; // closing "
3145
+ else this.errors.push({ message: "unterminated double quote", pos: contentStart - 1 });
3146
+ this._dqText = text;
3147
+ this._dqHasExpansions = hasExpansions;
3148
+ this._dqParts = parts;
3149
+ }
3150
+
3151
+ private readDollar(insideDoubleQuotes = false): void {
3152
+ const dollarPos = this.pos;
3153
+ this.pos++; // skip $
3154
+ const src = this.src;
3155
+ const len = this.srcEnd;
3156
+ const bt = this._buildParts || this._buildValue;
3157
+ if (this.pos >= len) {
3158
+ this._resultText = "$";
3159
+ this._resultIsRaw = true;
3160
+ this._resultHasExpansion = false;
3161
+ this._resultPart = undefined;
3162
+ return;
3163
+ }
3164
+
3165
+ const logicalPos = skipLineContinuations(src, this.pos, len);
3166
+ if (
3167
+ logicalPos > this.pos &&
3168
+ src.charCodeAt(logicalPos) === CH_LPAREN &&
3169
+ src.charCodeAt(logicalPos + 1) !== CH_LPAREN
3170
+ )
3171
+ this.pos = logicalPos;
3172
+ const ch = src.charCodeAt(this.pos);
3173
+
3174
+ if (ch === CH_LPAREN) {
3175
+ if (this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
3176
+ const savedPos = this.pos;
3177
+ const savedErrors = this.errors.length;
3178
+ this.readArithmeticExpansion(dollarPos);
3179
+ if (!this._notArithmetic) return;
3180
+ this.errors.length = savedErrors;
3181
+ this.pos = savedPos;
3182
+ }
3183
+ this.readCommandSubstitution(dollarPos);
3184
+ return;
3185
+ }
3186
+
3187
+ if (ch === CH_LBRACE) {
3188
+ const after = this.pos + 1 < len ? src.charCodeAt(this.pos + 1) : 0;
3189
+ if (after === CH_SPACE || after === CH_TAB || after === CH_NL) {
3190
+ this.readBraceCommandSubstitution();
3191
+ return;
3192
+ }
3193
+ if (after === CH_PIPE) {
3194
+ this.readValueSubstitution();
3195
+ return;
3196
+ }
3197
+ this.readParameterExpansion(insideDoubleQuotes);
3198
+ return;
3199
+ }
3200
+
3201
+ if (ch === CH_SQUOTE) {
3202
+ this.pos++;
3203
+ if (bt) {
3204
+ const value = this.readAnsiCQuoted();
3205
+ this._resultText = value;
3206
+ this._resultPart = this._buildParts
3207
+ ? { type: "AnsiCQuoted", pos: dollarPos, end: this.pos, text: src.slice(dollarPos, this.pos), value }
3208
+ : undefined;
3209
+ } else {
3210
+ this.skipAnsiCQuoted();
3211
+ this._resultText = "";
3212
+ this._resultPart = undefined;
3213
+ }
3214
+ this._resultIsRaw = false;
3215
+ this._resultHasExpansion = false;
3216
+ return;
3217
+ }
3218
+
3219
+ if (ch === CH_DQUOTE) {
3220
+ this.pos++;
3221
+ this.readDoubleQuoted(insideDoubleQuotes);
3222
+ this._resultText = this._dqText;
3223
+ this._resultIsRaw = false;
3224
+ this._resultHasExpansion = this._dqHasExpansions;
3225
+ if (this._buildParts) {
3226
+ const text = src.slice(dollarPos, this.pos);
3227
+ this._resultPart = {
3228
+ type: "LocaleString",
3229
+ pos: dollarPos,
3230
+ end: this.pos,
3231
+ text,
3232
+ parts: this._dqParts ?? [
3233
+ {
3234
+ type: "Literal",
3235
+ pos: dollarPos + 2,
3236
+ end: this._dqEnd,
3237
+ value: this._dqText,
3238
+ text: src.slice(dollarPos + 2, this._dqEnd),
3239
+ },
3240
+ ],
3241
+ };
3242
+ } else {
3243
+ this._resultPart = undefined;
3244
+ }
3245
+ return;
3246
+ }
3247
+
3248
+ if (
3249
+ ch === CH_AT ||
3250
+ ch === CH_STAR ||
3251
+ ch === CH_HASH ||
3252
+ ch === CH_QUESTION ||
3253
+ ch === CH_DASH ||
3254
+ ch === CH_DOLLAR ||
3255
+ ch === CH_BANG ||
3256
+ (ch >= CH_0 && ch <= CH_9)
3257
+ ) {
3258
+ this.pos++;
3259
+ const text = bt ? src.slice(this.pos - 2, this.pos) : "";
3260
+ this._resultText = text;
3261
+ this._resultIsRaw = true;
3262
+ this._resultHasExpansion = false;
3263
+ this._resultPart = this._buildParts
3264
+ ? { type: "SimpleExpansion", pos: dollarPos, end: this.pos, text }
3265
+ : undefined;
3266
+ return;
3267
+ }
3268
+
3269
+ if (ch < 128 && isIdChar[ch] & 1) {
3270
+ const namePos = this.pos - 1;
3271
+ while (this.pos < len) {
3272
+ const c = src.charCodeAt(this.pos);
3273
+ if (c < 128 && isIdChar[c] & 2) this.pos++;
3274
+ else break;
3275
+ }
3276
+ const text = bt ? src.slice(namePos, this.pos) : "";
3277
+ this._resultText = text;
3278
+ this._resultIsRaw = true;
3279
+ this._resultHasExpansion = false;
3280
+ this._resultPart = this._buildParts
3281
+ ? { type: "SimpleExpansion", pos: dollarPos, end: this.pos, text }
3282
+ : undefined;
3283
+ return;
3284
+ }
3285
+
3286
+ // $[ expr ] is bash's deprecated spelling of $(( expr )). It runs to the matching ']',
3287
+ // so parentheses inside belong to the expansion instead of ending the word.
3288
+ if (ch === CH_LBRACKET) {
3289
+ const errorCount = this._errors === null ? 0 : this._errors.length;
3290
+ const close = this.findClosingArithmeticBracket(this.pos + 1);
3291
+ if (close !== -1) {
3292
+ const bodyStart = this.pos + 1;
3293
+ const body = src.slice(bodyStart, close);
3294
+ this.pos = close + 1;
3295
+ const text = bt ? src.slice(dollarPos, this.pos) : "";
3296
+ this._resultText = text;
3297
+ this._resultIsRaw = true;
3298
+ this._resultHasExpansion = false;
3299
+ this._resultPart = this._buildParts
3300
+ ? this.arithmeticExpansion(bodyStart - 2, text, body, bodyStart)
3301
+ : undefined;
3302
+ return;
3303
+ }
3304
+ // A failed lookahead leaves `$` literal and the token path re-reads the scanned text.
3305
+ if (this._errors !== null) this._errors.length = errorCount;
3306
+ }
3307
+
3308
+ this._resultText = "$";
3309
+ this._resultIsRaw = true;
3310
+ this._resultHasExpansion = false;
3311
+ this._resultPart = undefined;
3312
+ }
3313
+
3314
+ private scanArithmeticBody(): string {
3315
+ this._notArithmetic = false;
3316
+ this._arithUnterminated = false;
3317
+ this.pos += 2;
3318
+ let depth = 1;
3319
+ let parenDepth = 0;
3320
+ let parentParenDepth = 0;
3321
+ let parenDepths: number[] | undefined;
3322
+ let reported = false;
3323
+ const src = this.src;
3324
+ const len = this.srcEnd;
3325
+ const start = this.pos;
3326
+ while (this.pos < len && depth > 0) {
3327
+ const c = src.charCodeAt(this.pos);
3328
+ if (c === CH_BACKSLASH) {
3329
+ this.pos += this.pos + 1 < len ? 2 : 1;
3330
+ } else if (c === CH_SQUOTE) {
3331
+ this.pos++;
3332
+ this.skipSQ();
3333
+ } else if (c === CH_DQUOTE) {
3334
+ this.pos++;
3335
+ this.skipDQ();
3336
+ } else if (c === CH_BACKTICK) {
3337
+ this.pos++;
3338
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
3339
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH) this.pos++;
3340
+ this.pos++;
3341
+ }
3342
+ if (this.pos < len) this.pos++;
3343
+ } else if (
3344
+ c === CH_DOLLAR &&
3345
+ this.pos + 2 < len &&
3346
+ src.charCodeAt(this.pos + 1) === CH_LPAREN &&
3347
+ src.charCodeAt(this.pos + 2) !== CH_LPAREN
3348
+ ) {
3349
+ const dollarPos = this.pos;
3350
+ this.pos += 2;
3351
+ this.extractBalanced();
3352
+ if (this._unbalanced) this.errors.push({ message: "unterminated command substitution", pos: dollarPos });
3353
+ } else if (c === CH_DOLLAR && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LBRACE) {
3354
+ const close = this.findClosingBrace(this.pos + 2, len);
3355
+ this.pos = close === -1 ? len : close + 1;
3356
+ } else if ((c === CH_LT || c === CH_GT) && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
3357
+ this.pos += 2;
3358
+ this.extractBalanced();
3359
+ } else if (c === CH_LPAREN) {
3360
+ if (src.charCodeAt(this.pos - 1) === CH_DOLLAR && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
3361
+ if (depth === 1) parentParenDepth = parenDepth;
3362
+ else (parenDepths ?? (parenDepths = [])).push(parenDepth);
3363
+ depth++;
3364
+ parenDepth = 0;
3365
+ if (depth + this._nestingDepth > MAX_SYNTAX_NESTING && !reported) {
3366
+ this.errors.push({ message: "maximum arithmetic expansion nesting depth exceeded", pos: this.pos - 1 });
3367
+ reported = true;
3368
+ }
3369
+ this.pos += 2;
3370
+ } else {
3371
+ // The expression parser stops building structure at this depth; report it here, while
3372
+ // scanning, since the expression itself resolves lazily. Each enclosing `$((` of this
3373
+ // scan parses one level deeper, so its parentheses count from there.
3374
+ if (++parenDepth + depth - 1 + this._nestingDepth > MAX_SYNTAX_NESTING && !reported) {
3375
+ this.errors.push({ message: "maximum arithmetic expression nesting depth exceeded", pos: this.pos });
3376
+ reported = true;
3377
+ }
3378
+ this.pos++;
3379
+ }
3380
+ } else if (c === CH_RPAREN && parenDepth > 0) {
3381
+ parenDepth--;
3382
+ this.pos++;
3383
+ } else if (c === CH_RPAREN && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_RPAREN) {
3384
+ if (--depth === 0) {
3385
+ this.pos += 2;
3386
+ break;
3387
+ }
3388
+ parenDepth = depth === 1 ? parentParenDepth : parenDepths!.pop()!;
3389
+ this.pos += 2;
3390
+ } else if (c === CH_RPAREN && depth === 1) {
3391
+ // Bash reads `((` and `$((` as arithmetic only when the inner parenthesis pair is
3392
+ // immediately followed by `)`. It closed without one, so `((a) )` is nested subshells
3393
+ // and `$((a) || (b))` a command substitution; callers re-read from the start.
3394
+ this._notArithmetic = true;
3395
+ return "";
3396
+ } else {
3397
+ this.pos++;
3398
+ }
3399
+ }
3400
+ const bt = this._buildParts || this._buildValue;
3401
+ if (depth > 0) {
3402
+ this._arithUnterminated = true;
3403
+ return bt ? src.slice(start, this.pos) : "";
3404
+ }
3405
+ return bt ? src.slice(start, this.pos - 2) : "";
3406
+ }
3407
+
3408
+ private readArithmeticExpansion(dollarPos: number): void {
3409
+ const bodyStart = this.pos + 2; // absolute offset of the body, past the "((" at this.pos
3410
+ const body = this.scanArithmeticBody();
3411
+ if (this._notArithmetic) return;
3412
+ if (this._arithUnterminated) this.errors.push({ message: "unterminated arithmetic expansion", pos: dollarPos });
3413
+ const text = this._buildParts || this._buildValue ? this.src.slice(dollarPos, this.pos) : "";
3414
+ this._resultText = text;
3415
+ this._resultIsRaw = true;
3416
+ this._resultHasExpansion = false;
3417
+ this._resultPart = this._buildParts ? this.arithmeticExpansion(dollarPos, text, body, bodyStart) : undefined;
3418
+ }
3419
+
3420
+ private arithmeticExpansion(
3421
+ pos: number,
3422
+ text: string,
3423
+ body: string,
3424
+ bodyStart: number,
3425
+ ): import("./internal-types.ts").ArithmeticExpansionPart {
3426
+ return new ArithmeticExpansionImpl(pos, this.pos, text, {
3427
+ source: this.src,
3428
+ body,
3429
+ offset: bodyStart,
3430
+ depth: this._nestingDepth,
3431
+ context: this.substitutionContext,
3432
+ });
3433
+ }
3434
+
3435
+ private readArithmeticCommand(out: TokenValue, tokenStart: number): void {
3436
+ const savedBuildValue = this._buildValue;
3437
+ this._buildValue = true;
3438
+ const body = this.scanArithmeticBody();
3439
+ this._buildValue = savedBuildValue;
3440
+ if (this._arithUnterminated) this.errors.push({ message: "unterminated arithmetic command", pos: tokenStart });
3441
+ setToken(out, Token.ArithCmd, body, tokenStart, this.pos);
3442
+ }
3443
+
3444
+ private readCommandSubstitution(dollarPos: number): void {
3445
+ const openPos = this.pos;
3446
+ this.pos++; // skip (
3447
+ // extractBalanced returns the inner text without the closing paren, so it
3448
+ // stays correct when the input ended before that paren was reached.
3449
+ const inner = this.extractBalanced();
3450
+ if (this._unbalanced) this.errors.push({ message: "unterminated command substitution", pos: dollarPos });
3451
+ const bt = this._buildParts || this._buildValue;
3452
+ const rawText = bt ? this.src.slice(dollarPos, this.pos) : "";
3453
+ const text = !bt || openPos === dollarPos + 1 ? rawText : "$" + this.src.slice(openPos, this.pos);
3454
+ this._resultText = text;
3455
+ this._resultIsRaw = openPos === dollarPos + 1;
3456
+ this._resultHasExpansion = true;
3457
+ if (this._buildParts) {
3458
+ this._resultPart = this.commandExpansion(dollarPos, rawText, openPos + 1, inner, !this._unbalanced);
3459
+ } else {
3460
+ this._resultPart = undefined;
3461
+ }
3462
+ }
3463
+
3464
+ private readBraceCommandSubstitution(): void {
3465
+ this.readBraceSubstitution(1);
3466
+ }
3467
+
3468
+ private readValueSubstitution(): void {
3469
+ this.readBraceSubstitution(2);
3470
+ }
3471
+
3472
+ private readBraceSubstitution(skip: number): void {
3473
+ const dollarPos = this.pos - 1;
3474
+ this.pos += skip;
3475
+ const src = this.src;
3476
+ const len = this.srcEnd;
3477
+ let depth = 1;
3478
+ const start = this.pos;
3479
+ while (this.pos < len) {
3480
+ const c = src.charCodeAt(this.pos);
3481
+ if (c === CH_LBRACE) depth++;
3482
+ else if (c === CH_RBRACE) {
3483
+ if (--depth === 0) {
3484
+ this.pos++;
3485
+ break;
3486
+ }
3487
+ } else if (c === CH_SQUOTE) {
3488
+ this.pos++;
3489
+ this.skipSQ();
3490
+ continue;
3491
+ } else if (c === CH_DQUOTE) {
3492
+ this.pos++;
3493
+ this.skipDQ();
3494
+ continue;
3495
+ } else if (c === CH_BACKSLASH) this.pos++;
3496
+ this.pos++;
3497
+ }
3498
+ this._resultIsRaw = true;
3499
+ this._resultHasExpansion = true;
3500
+ if (this._buildParts || this._buildValue) {
3501
+ const rawInner = src.slice(start, this.pos - 1);
3502
+ const inner = rawInner.trim();
3503
+ const text = src.slice(dollarPos, this.pos);
3504
+ this._resultText = text;
3505
+ if (this._buildParts) {
3506
+ const innerStart = start + (rawInner.length - rawInner.trimStart().length);
3507
+ this._resultPart = this.commandExpansion(dollarPos, text, innerStart, inner);
3508
+ } else {
3509
+ this._resultPart = undefined;
3510
+ }
3511
+ } else {
3512
+ this._resultText = "";
3513
+ this._resultPart = undefined;
3514
+ }
3515
+ }
3516
+
3517
+ private readBacktickExpansion(insideDoubleQuotes = false): void {
3518
+ this.pos++; // skip opening `
3519
+ const src = this.src;
3520
+ const len = this.srcEnd;
3521
+ const start = this.pos;
3522
+ if (!this._buildParts && !this._buildValue) {
3523
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
3524
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH && this.pos + 1 < len) this.pos++;
3525
+ this.pos++;
3526
+ }
3527
+ if (this.pos < len) this.pos++;
3528
+ else this.errors.push({ message: "unterminated backtick", pos: start - 1 });
3529
+ this._resultText = "";
3530
+ this._resultIsRaw = false;
3531
+ this._resultHasExpansion = true;
3532
+ this._resultPart = undefined;
3533
+ return;
3534
+ }
3535
+ let inner = "";
3536
+ let hasEscapes = false;
3537
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
3538
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH) {
3539
+ hasEscapes = true;
3540
+ break;
3541
+ }
3542
+ this.pos++;
3543
+ }
3544
+
3545
+ if (!hasEscapes) {
3546
+ inner = src.slice(start, this.pos);
3547
+ } else {
3548
+ inner = src.slice(start, this.pos);
3549
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
3550
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH) {
3551
+ this.pos++;
3552
+ if (this.pos < len) {
3553
+ const c = src.charCodeAt(this.pos);
3554
+ if (c === CH_DOLLAR || c === CH_BACKTICK || c === CH_BACKSLASH || (insideDoubleQuotes && c === CH_DQUOTE)) {
3555
+ inner += src[this.pos];
3556
+ } else {
3557
+ inner += "\\" + src[this.pos];
3558
+ }
3559
+ this.pos++;
3560
+ }
3561
+ } else {
3562
+ const runStart = this.pos;
3563
+ while (this.pos < len) {
3564
+ const c = src.charCodeAt(this.pos);
3565
+ if (c === CH_BACKTICK || c === CH_BACKSLASH) break;
3566
+ this.pos++;
3567
+ }
3568
+ inner += src.slice(runStart, this.pos);
3569
+ }
3570
+ }
3571
+ }
3572
+ if (this.pos < len) this.pos++; // closing `
3573
+ else this.errors.push({ message: "unterminated backtick", pos: start - 1 });
3574
+
3575
+ const text = src.slice(start - 1, this.pos); // raw source including backticks
3576
+ this._resultText = inner;
3577
+ this._resultHasExpansion = true;
3578
+ if (this._buildParts) {
3579
+ this._resultPart = hasEscapes
3580
+ ? this.decodedCommandExpansion(start - 1, text, inner)
3581
+ : this.commandExpansion(start - 1, text, start, inner);
3582
+ } else {
3583
+ this._resultPart = undefined;
3584
+ }
3585
+ }
3586
+
3587
+ private readParameterExpansion(insideDoubleQuotes: boolean): void {
3588
+ const src = this.src;
3589
+ const len = this.srcEnd;
3590
+ const start = this.pos; // at {
3591
+ this.pos++;
3592
+ let depth = 1;
3593
+ let reported = false;
3594
+ while (this.pos < len && depth > 0) {
3595
+ const ch = src.charCodeAt(this.pos);
3596
+ if (ch === CH_DOLLAR) {
3597
+ // Dispatch forward: `$$` consumes its own second `$`, so a `{` after it is literal.
3598
+ const next = this.pos + 1 < len ? src.charCodeAt(this.pos + 1) : 0;
3599
+ if (next === CH_LBRACE) {
3600
+ depth++;
3601
+ if (this._nestingDepth + depth > MAX_SYNTAX_NESTING && !reported) {
3602
+ this.errors.push({ message: "maximum parameter expansion nesting depth exceeded", pos: this.pos });
3603
+ reported = true;
3604
+ }
3605
+ this.pos += 2;
3606
+ continue;
3607
+ }
3608
+ if (next === CH_DOLLAR) {
3609
+ this.pos += 2;
3610
+ continue;
3611
+ }
3612
+ if (next === CH_LPAREN) {
3613
+ const dollarPos = this.pos;
3614
+ this.pos += 2;
3615
+ this.extractBalanced();
3616
+ if (this._unbalanced) this.errors.push({ message: "unterminated command substitution", pos: dollarPos });
3617
+ continue;
3618
+ }
3619
+ } else if (ch === CH_BACKTICK) {
3620
+ this.pos++;
3621
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
3622
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH) this.pos++;
3623
+ this.pos++;
3624
+ }
3625
+ if (this.pos < len) this.pos++;
3626
+ continue;
3627
+ } else if (ch === CH_RBRACE) {
3628
+ if (--depth === 0) {
3629
+ this.pos++;
3630
+ break;
3631
+ }
3632
+ } else if (ch === CH_BACKSLASH) {
3633
+ if (this.pos + 1 < len) this.pos++;
3634
+ } else if (ch === CH_SQUOTE) {
3635
+ this.pos++;
3636
+ if (this.pos > start + 1 && src.charCodeAt(this.pos - 2) === CH_DOLLAR) this.skipAnsiCQuoted();
3637
+ else this.skipSQ();
3638
+ continue;
3639
+ } else if (ch === CH_DQUOTE) {
3640
+ this.pos++;
3641
+ this.skipDQ();
3642
+ continue;
3643
+ }
3644
+ this.pos++;
3645
+ }
3646
+ const closed = depth === 0;
3647
+ if (!closed) this.errors.push({ message: "unterminated parameter expansion", pos: start - 1 });
3648
+ const text = this._buildParts || this._buildValue ? src.slice(start - 1, this.pos) : "";
3649
+ this._resultText = text;
3650
+ this._resultIsRaw = true;
3651
+ this._resultHasExpansion = false;
3652
+ if (this._buildParts) {
3653
+ const inner = src.slice(start + 1, closed ? this.pos - 1 : this.pos);
3654
+ this._resultPart = this.parseParamInner(text, inner, start + 1, insideDoubleQuotes);
3655
+ } else {
3656
+ this._resultPart = undefined;
3657
+ }
3658
+ }
3659
+
3660
+ // `innerStart` is the absolute offset of `inner` in the original source, so each sub-field
3661
+ // word is parsed in place at its true position. `sub(a, b)` maps inner-relative offsets to
3662
+ // that absolute window.
3663
+ private parseParamInner(
3664
+ text: string,
3665
+ inner: string,
3666
+ innerStart: number,
3667
+ insideDoubleQuotes: boolean,
3668
+ ): ParameterExpansionPart {
3669
+ const result: ParameterExpansionPart = {
3670
+ type: "ParameterExpansion",
3671
+ pos: innerStart - 2,
3672
+ end: this.pos,
3673
+ text,
3674
+ parameter: "",
3675
+ parameterPos: innerStart,
3676
+ parameterEnd: innerStart,
3677
+ prefix: undefined,
3678
+ index: undefined,
3679
+ operation: undefined,
3680
+ };
3681
+ const ilen = inner.length;
3682
+ if (ilen === 0) return result;
3683
+ const sub = (a: number, b: number, context: SubFieldContext = "word"): Word =>
3684
+ this.parseSubFieldWord(innerStart + a, innerStart + b, context);
3685
+ const closeBracket = (start: number): number => {
3686
+ const close = this.findClosingBracket(innerStart + start, innerStart + ilen);
3687
+ return close === -1 ? -1 : close - innerStart;
3688
+ };
3689
+
3690
+ let i = 0;
3691
+ if (ilen > 1 && inner.charCodeAt(0) === CH_BANG) {
3692
+ result.prefix = "!";
3693
+ i = 1;
3694
+ }
3695
+
3696
+ if (ilen > 1 && inner.charCodeAt(0) === CH_HASH) {
3697
+ const nameEnd = this.scanParamName(inner, 1);
3698
+ let parameterEnd = nameEnd;
3699
+ let indexEnd = -1;
3700
+ if (parameterEnd < ilen && inner.charCodeAt(parameterEnd) === CH_LBRACKET) {
3701
+ indexEnd = closeBracket(parameterEnd + 1);
3702
+ if (indexEnd !== -1) parameterEnd = indexEnd + 1;
3703
+ }
3704
+ if (nameEnd > 1 && parameterEnd === ilen) {
3705
+ result.prefix = "#";
3706
+ result.parameter = inner.slice(1, nameEnd);
3707
+ result.parameterPos = innerStart + 1;
3708
+ result.parameterEnd = innerStart + nameEnd;
3709
+ if (indexEnd !== -1) result.index = sub(nameEnd + 1, indexEnd, "embedded");
3710
+ return result;
3711
+ }
3712
+ }
3713
+
3714
+ const nameStart = i;
3715
+ i = this.scanParamName(inner, i);
3716
+ result.parameter = inner.slice(nameStart, i);
3717
+ result.parameterPos = innerStart + nameStart;
3718
+ result.parameterEnd = innerStart + i;
3719
+
3720
+ if (i < ilen && inner.charCodeAt(i) === CH_LBRACKET) {
3721
+ const closeB = closeBracket(i + 1);
3722
+ if (closeB !== -1) {
3723
+ result.index = sub(i + 1, closeB, "embedded");
3724
+ i = closeB + 1;
3725
+ }
3726
+ }
3727
+ if (i >= ilen) return result;
3728
+
3729
+ const pos = innerStart + i;
3730
+ const end = innerStart + ilen;
3731
+ const opChar = inner.charCodeAt(i);
3732
+ const nextChar = inner.charCodeAt(i + 1);
3733
+ const defaultChar = inner[opChar === CH_COLON ? i + 1 : i];
3734
+ if (defaultChar === "-" || defaultChar === "=" || defaultChar === "+" || defaultChar === "?") {
3735
+ const operandStart = i + (opChar === CH_COLON ? 2 : 1);
3736
+ result.operation = {
3737
+ type: "Default",
3738
+ pos,
3739
+ end,
3740
+ operatorEnd: innerStart + operandStart,
3741
+ operator: opChar === CH_COLON ? `:${defaultChar}` : defaultChar,
3742
+ operand: sub(operandStart, ilen, insideDoubleQuotes ? "quoted" : "word"),
3743
+ };
3744
+ return result;
3745
+ }
3746
+
3747
+ if (opChar === CH_COLON) {
3748
+ i++;
3749
+ const sliceStart = innerStart + i;
3750
+ const colonIdx = findUnnested(inner.slice(i), CH_COLON, true, (index, quoted) => {
3751
+ return this.findNestedShellEnd(sliceStart + index, end, quoted) - sliceStart;
3752
+ });
3753
+ result.operation = {
3754
+ type: "Slice",
3755
+ pos,
3756
+ end,
3757
+ operatorEnd: sliceStart,
3758
+ operator: ":",
3759
+ offset: sub(i, colonIdx === -1 ? ilen : i + colonIdx, "embedded"),
3760
+ length: colonIdx === -1 ? undefined : sub(i + colonIdx + 1, ilen, "embedded"),
3761
+ };
3762
+ return result;
3763
+ }
3764
+
3765
+ if (opChar === CH_HASH || opChar === CH_PERCENT) {
3766
+ const doubled = nextChar === opChar;
3767
+ const operator = opChar === CH_HASH ? (doubled ? "##" : "#") : doubled ? "%%" : "%";
3768
+ const operandStart = i + (doubled ? 2 : 1);
3769
+ result.operation = {
3770
+ type: "Remove",
3771
+ pos,
3772
+ end,
3773
+ operatorEnd: innerStart + operandStart,
3774
+ operator,
3775
+ operand: sub(operandStart, ilen),
3776
+ };
3777
+ return result;
3778
+ }
3779
+
3780
+ if (opChar === CH_SLASH) {
3781
+ i++;
3782
+ let operator: import("./internal-types.ts").ParameterReplaceOperation["operator"] = "/";
3783
+ if (nextChar === CH_SLASH || nextChar === CH_HASH || nextChar === CH_PERCENT) {
3784
+ operator = nextChar === CH_SLASH ? "//" : nextChar === CH_HASH ? "/#" : "/%";
3785
+ i++;
3786
+ }
3787
+ let separatorStart = i;
3788
+ if (nextChar === CH_SLASH) {
3789
+ // After // a leading slash belongs to the pattern; /# and /% allow empty patterns.
3790
+ separatorStart = skipLineContinuations(inner, i, ilen);
3791
+ if (inner.charCodeAt(separatorStart) === CH_SLASH) separatorStart++;
3792
+ }
3793
+ const sepIdx = findUnnested(inner.slice(separatorStart), CH_SLASH);
3794
+ const patternEnd = sepIdx === -1 ? ilen : separatorStart + sepIdx;
3795
+ result.operation = {
3796
+ type: "Replace",
3797
+ pos,
3798
+ end,
3799
+ operatorEnd: innerStart + i,
3800
+ operator,
3801
+ pattern: sub(i, patternEnd),
3802
+ replacement: sub(sepIdx === -1 ? ilen : patternEnd + 1, ilen),
3803
+ };
3804
+ return result;
3805
+ }
3806
+
3807
+ if (opChar === CH_CARET || opChar === CH_COMMA) {
3808
+ const doubled = nextChar === opChar;
3809
+ const operator = opChar === CH_CARET ? (doubled ? "^^" : "^") : doubled ? ",," : ",";
3810
+ const operandStart = i + (doubled ? 2 : 1);
3811
+ result.operation = {
3812
+ type: "CaseModification",
3813
+ pos,
3814
+ end,
3815
+ operatorEnd: innerStart + operandStart,
3816
+ operator,
3817
+ operand: operandStart === ilen ? undefined : sub(operandStart, ilen),
3818
+ };
3819
+ return result;
3820
+ }
3821
+
3822
+ if (
3823
+ result.prefix === "!" &&
3824
+ result.index === undefined &&
3825
+ i + 1 === ilen &&
3826
+ (opChar === CH_AT || opChar === CH_STAR)
3827
+ ) {
3828
+ result.operation = { type: "Names", pos, end, operatorEnd: end, operator: opChar === CH_AT ? "@" : "*" };
3829
+ return result;
3830
+ }
3831
+
3832
+ if (opChar === CH_AT) {
3833
+ result.operation = {
3834
+ type: "Transform",
3835
+ pos,
3836
+ end,
3837
+ operatorEnd: pos + 1,
3838
+ operator: "@",
3839
+ operand: sub(i + 1, ilen),
3840
+ };
3841
+ return result;
3842
+ }
3843
+
3844
+ result.operation = { type: "Unknown", pos, end, operatorEnd: end, operator: inner.slice(i) };
3845
+ return result;
3846
+ }
3847
+
3848
+ private findNestedShellEnd(start: number, end: number, quoted: boolean): number {
3849
+ const ch = this.src.charCodeAt(start);
3850
+ if (ch === CH_BACKTICK) {
3851
+ let pos = start + 1;
3852
+ while (pos < end) {
3853
+ const current = this.src.charCodeAt(pos);
3854
+ if (current === CH_BACKSLASH) {
3855
+ pos += 2;
3856
+ continue;
3857
+ }
3858
+ pos++;
3859
+ if (current === CH_BACKTICK) return pos;
3860
+ }
3861
+ return end;
3862
+ }
3863
+
3864
+ let close: number;
3865
+ if (ch === CH_DOLLAR) {
3866
+ const next = start + 1;
3867
+ if (next < end && this.src.charCodeAt(next) === CH_LBRACE) {
3868
+ close = this.findClosingBrace(next + 1, end);
3869
+ } else {
3870
+ const open = skipLineContinuations(this.src, next, end);
3871
+ if (open >= end || this.src.charCodeAt(open) !== CH_LPAREN) return start;
3872
+ close = this.findClosingParenthesis(open + 1, end);
3873
+ }
3874
+ } else {
3875
+ const open = start + 1;
3876
+ if (quoted || (ch !== CH_LT && ch !== CH_GT) || open >= end || this.src.charCodeAt(open) !== CH_LPAREN) {
3877
+ return start;
3878
+ }
3879
+ close = this.findClosingParenthesis(open + 1, end);
3880
+ }
3881
+
3882
+ return close === -1 ? end : close + 1;
3883
+ }
3884
+
3885
+ private scanParamName(s: string, start: number): number {
3886
+ let i = start;
3887
+ if (i >= s.length) return i;
3888
+ const c = s.charCodeAt(i);
3889
+ // Special single-char params
3890
+ if (
3891
+ c === CH_AT ||
3892
+ c === CH_STAR ||
3893
+ c === CH_HASH ||
3894
+ c === CH_QUESTION ||
3895
+ c === CH_DASH ||
3896
+ c === CH_DOLLAR ||
3897
+ c === CH_BANG
3898
+ ) {
3899
+ return i + 1;
3900
+ }
3901
+ // Digits
3902
+ if (c >= CH_0 && c <= CH_9) {
3903
+ while (i < s.length && s.charCodeAt(i) >= CH_0 && s.charCodeAt(i) <= CH_9) i++;
3904
+ return i;
3905
+ }
3906
+ // Regular name: [a-zA-Z_][a-zA-Z0-9_]*
3907
+ if ((c >= CH_a && c <= CH_z) || (c >= CH_A && c <= CH_Z) || c === CH_UNDERSCORE) {
3908
+ i++;
3909
+ while (i < s.length) {
3910
+ const ch = s.charCodeAt(i);
3911
+ if (
3912
+ (ch >= CH_a && ch <= CH_z) ||
3913
+ (ch >= CH_A && ch <= CH_Z) ||
3914
+ (ch >= CH_0 && ch <= CH_9) ||
3915
+ ch === CH_UNDERSCORE
3916
+ )
3917
+ i++;
3918
+ else break;
3919
+ }
3920
+ }
3921
+ return i;
3922
+ }
3923
+
3924
+ private readAnsiCQuoted(): string {
3925
+ const quotePos = this.pos - 1;
3926
+ const result = decodeAnsiCQuoted(this.src, this.pos, this.srcEnd);
3927
+ this.pos = result.end;
3928
+ if (!result.closed) this.errors.push({ message: "unterminated ANSI-C quote", pos: quotePos });
3929
+ return result.value;
3930
+ }
3931
+
3932
+ // Quotes resume when a nested substitution returns to their parenthesis depth.
3933
+ private extractBalanced(useGrammar = true, quoteOnly = false): string {
3934
+ const src = this.src;
3935
+ const len = this.srcEnd;
3936
+ const bt = this._buildParts || this._buildValue;
3937
+ let depth = quoteOnly ? 0 : 1;
3938
+ const start = this.pos;
3939
+ this._unbalanced = false;
3940
+ const knownEnd = useGrammar && !quoteOnly ? this.substitutionContext.boundaries?.get(start) : undefined;
3941
+ if (knownEnd !== undefined && knownEnd <= len && this._nestingDepth < MAX_SYNTAX_NESTING) {
3942
+ this.pos = knownEnd;
3943
+ return bt ? src.slice(start, knownEnd - 1) : "";
3944
+ }
3945
+ let wordStart = true;
3946
+
3947
+ // Fast path: scan for simple cases with no nested quotes/parens/case. Nothing here
3948
+ // opens a paren — `(` breaks to the slow path — so depth stays 1 and the first `)`
3949
+ // closes the construct.
3950
+ while (!quoteOnly && this.pos < len) {
3951
+ const c = src.charCodeAt(this.pos);
3952
+ if (c === CH_RPAREN) {
3953
+ const result = bt ? src.slice(start, this.pos) : "";
3954
+ this.pos++;
3955
+ return result;
3956
+ } else if (
3957
+ c === CH_LPAREN ||
3958
+ c === CH_BACKSLASH ||
3959
+ c === CH_SQUOTE ||
3960
+ c === CH_DQUOTE ||
3961
+ c === CH_BACKTICK ||
3962
+ (c === CH_DOLLAR && (src.charCodeAt(this.pos + 1) === CH_SQUOTE || src.charCodeAt(this.pos + 1) === CH_DOLLAR))
3963
+ ) {
3964
+ break;
3965
+ } else if (c === CH_LT && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LT) {
3966
+ break;
3967
+ } else if (c === CH_HASH && wordStart) {
3968
+ break;
3969
+ } else if (
3970
+ c === 99 /* c */ &&
3971
+ wordStart &&
3972
+ this.pos + 3 < len &&
3973
+ src.charCodeAt(this.pos + 1) === 97 /* a */ &&
3974
+ src.charCodeAt(this.pos + 2) === 115 /* s */ &&
3975
+ src.charCodeAt(this.pos + 3) === 101 /* e */ &&
3976
+ (this.pos + 4 >= len || (src.charCodeAt(this.pos + 4) < 128 && charType[src.charCodeAt(this.pos + 4)] & 1))
3977
+ ) {
3978
+ break;
3979
+ } else {
3980
+ wordStart = c < 128 && (charType[c] & 1) !== 0;
3981
+ this.pos++;
3982
+ }
3983
+ }
3984
+
3985
+ // Slow path: just track position (source is copied verbatim, so slice at end)
3986
+ let caseDepth = 0;
3987
+ // Parentheses opened since the innermost `case` began. A `)` only terminates a case
3988
+ // pattern when nothing is open — otherwise it closes a pattern's leading `(`, an
3989
+ // extglob group, or a subshell in the case body, and must rebalance `depth`.
3990
+ let caseParens = 0;
3991
+ // Word-form parens resume mid-word; grammar parens resume where `#` can open a comment.
3992
+ const wordStartAfterParen: boolean[] = [];
3993
+ type Delimiter = { delimiter: string; strip: boolean; quoted: boolean };
3994
+ type ScriptScope = {
3995
+ start: number;
3996
+ dollar: number;
3997
+ depth: number;
3998
+ parens: number;
3999
+ caseDepth: number;
4000
+ caseParens: number;
4001
+ arithBase: number;
4002
+ arithExtent: boolean;
4003
+ substitutions: number;
4004
+ checkedGrammar: boolean;
4005
+ pendingDelims: Delimiter[] | null;
4006
+ };
4007
+ let pendingDelims: Delimiter[] | null = null;
4008
+ let scripts: ScriptScope[] | undefined;
4009
+ let arithBase = -1;
4010
+ // A `$((` extent is found by bash's arithmetic scanner, where `#` is ordinary, and keeps
4011
+ // that extent even for a command body. The two chars before `start` are the delimiter.
4012
+ let arithExtent =
4013
+ start >= 2 &&
4014
+ src.charCodeAt(start) === CH_LPAREN &&
4015
+ src.charCodeAt(start - 1) === CH_LPAREN &&
4016
+ src.charCodeAt(start - 2) === CH_DOLLAR;
4017
+ let substitutions = 0;
4018
+ let reported = false;
4019
+ type QuoteScope = { close: number; depth: number };
4020
+ const quotes: QuoteScope[] = quoteOnly ? [{ close: CH_DQUOTE, depth: 0 }] : [];
4021
+ let quotedDepth = quoteOnly ? 1 : 0;
4022
+ let parameterDepth = 0;
4023
+ let arithmeticOpens: number[] | undefined;
4024
+ let continuedDollarPos = -1;
4025
+ let continuedParenPos = -1;
4026
+ let escapedEnd = -1;
4027
+ let lastParenEnd = -1;
4028
+ let checkedGrammar = false;
4029
+
4030
+ while (this.pos < len && (depth > 0 || quotes.length > 0)) {
4031
+ let ch = src.charCodeAt(this.pos);
4032
+ // A negative index would leave V8's fast array path, so guard the empty stack.
4033
+ const quote = quotes.length === 0 ? undefined : quotes[quotes.length - 1];
4034
+ if (quote !== undefined && quote.depth === depth) {
4035
+ let commandOpen = false;
4036
+ if (ch === CH_BACKSLASH) {
4037
+ this.pos = Math.min(this.pos + 2, len);
4038
+ continue;
4039
+ }
4040
+ if (ch === quote.close) {
4041
+ quotes.pop();
4042
+ if (ch === CH_DQUOTE) quotedDepth--;
4043
+ else parameterDepth--;
4044
+ this.pos++;
4045
+ if (quoteOnly && depth === 0 && quotes.length === 0) return "";
4046
+ continue;
4047
+ }
4048
+ if (ch === CH_DQUOTE) {
4049
+ quotes.push({ close: CH_DQUOTE, depth });
4050
+ quotedDepth++;
4051
+ } else if (ch === CH_SQUOTE && quote.close === CH_RBRACE) {
4052
+ this.pos++;
4053
+ this.skipSQ();
4054
+ continue;
4055
+ } else if (ch === CH_BACKTICK) {
4056
+ this.pos++;
4057
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
4058
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH) this.pos++;
4059
+ if (this.pos < len) this.pos++;
4060
+ }
4061
+ if (this.pos < len) this.pos++;
4062
+ continue;
4063
+ } else if (ch === CH_DOLLAR) {
4064
+ const next = src.charCodeAt(this.pos + 1);
4065
+ if (next === CH_LBRACE) {
4066
+ quotes.push({ close: CH_RBRACE, depth });
4067
+ parameterDepth++;
4068
+ this.pos++;
4069
+ } else if (next === CH_LPAREN) {
4070
+ this.pos++;
4071
+ ch = CH_LPAREN;
4072
+ commandOpen = true;
4073
+ } else if (next === CH_DOLLAR) {
4074
+ // `$$` is one parameter unless `(` follows: Bash's quote scanner then reads `$` + `$(`.
4075
+ this.pos += src.charCodeAt(this.pos + 2) === CH_LPAREN ? 1 : 2;
4076
+ continue;
4077
+ }
4078
+ }
4079
+ if (!reported && this._nestingDepth + Math.max(quotedDepth, parameterDepth) > MAX_SYNTAX_NESTING) {
4080
+ this.errors.push({ message: "maximum word nesting depth exceeded", pos: this.pos });
4081
+ reported = true;
4082
+ }
4083
+ if (!commandOpen) {
4084
+ // Plain text changes no depth, so the rest of the run needs no further checks.
4085
+ this.pos++;
4086
+ while (this.pos < len) {
4087
+ const c = src.charCodeAt(this.pos);
4088
+ if (
4089
+ c === CH_BACKSLASH ||
4090
+ c === CH_DQUOTE ||
4091
+ c === CH_DOLLAR ||
4092
+ c === CH_BACKTICK ||
4093
+ c === CH_RBRACE ||
4094
+ c === CH_SQUOTE
4095
+ )
4096
+ break;
4097
+ this.pos++;
4098
+ }
4099
+ continue;
4100
+ }
4101
+ }
4102
+ // The grammar resolves whether '#' after ')' is a comment, including inside array subscripts.
4103
+ if (
4104
+ ch === CH_HASH &&
4105
+ lastParenEnd !== -1 &&
4106
+ !arithExtent &&
4107
+ arithBase < 0 &&
4108
+ useGrammar &&
4109
+ !checkedGrammar &&
4110
+ this._nestingDepth + (scripts?.length ?? 0) < MAX_SYNTAX_NESTING &&
4111
+ skipBlanks(src, lastParenEnd, len) === this.pos
4112
+ ) {
4113
+ checkedGrammar = true;
4114
+ const scope = scripts?.[scripts.length - 1];
4115
+ const end = this.findSubstitutionEnd(
4116
+ scope?.start ?? start,
4117
+ len,
4118
+ this._nestingDepth + (scripts?.length ?? 0) + 1,
4119
+ );
4120
+ if (end !== -1) {
4121
+ if (scope === undefined) {
4122
+ this.pos = end;
4123
+ return bt ? src.slice(start, end - 1) : "";
4124
+ }
4125
+ // Rejoin the closing-parenthesis path so the proven script end restores its enclosing scope.
4126
+ this.pos = end - 1;
4127
+ depth = scope.depth + 1;
4128
+ caseDepth = 0;
4129
+ caseParens = 0;
4130
+ wordStartAfterParen.length = scope.parens + 1;
4131
+ ch = CH_RPAREN;
4132
+ }
4133
+ }
4134
+ if (ch === CH_LPAREN) {
4135
+ // An escaped character (`\$(`) is text, not a substitution or extglob prefix.
4136
+ const prev = this.pos > start && this.pos !== escapedEnd ? src.charCodeAt(this.pos - 1) : 0;
4137
+ const commandDollarPos =
4138
+ prev === CH_DOLLAR ? this.pos - 1 : this.pos === continuedParenPos ? continuedDollarPos : -1;
4139
+ const wordParen =
4140
+ commandDollarPos !== -1 ||
4141
+ prev === CH_LT ||
4142
+ prev === CH_GT ||
4143
+ prev === CH_EQ ||
4144
+ prev === CH_AT ||
4145
+ prev === CH_QUESTION ||
4146
+ prev === CH_STAR ||
4147
+ prev === CH_PLUS ||
4148
+ prev === CH_BANG ||
4149
+ (prev === CH_LPAREN && wordStartAfterParen[wordStartAfterParen.length - 1] === false) ||
4150
+ (arithExtent && this.pos === start);
4151
+ const script =
4152
+ (commandDollarPos !== -1 && src.charCodeAt(this.pos + 1) !== CH_LPAREN) ||
4153
+ ((prev === CH_LT || prev === CH_GT) && arithBase < 0 && !arithExtent);
4154
+ if (script) {
4155
+ (scripts ?? (scripts = [])).push({
4156
+ start: this.pos + 1,
4157
+ dollar: commandDollarPos,
4158
+ depth,
4159
+ parens: wordStartAfterParen.length,
4160
+ caseDepth,
4161
+ caseParens,
4162
+ arithBase,
4163
+ arithExtent,
4164
+ substitutions,
4165
+ checkedGrammar,
4166
+ pendingDelims,
4167
+ });
4168
+ caseDepth = 0;
4169
+ caseParens = 0;
4170
+ arithBase = -1;
4171
+ arithExtent = false;
4172
+ checkedGrammar = false;
4173
+ pendingDelims = null;
4174
+ }
4175
+ wordStartAfterParen.push(!wordParen);
4176
+ wordStart = true;
4177
+ // (( opens arithmetic — suppress heredoc detection until the parens
4178
+ // rebalance so shift operators inside aren't mistaken for heredocs
4179
+ if (!script && arithBase < 0 && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LPAREN) {
4180
+ arithBase = depth;
4181
+ }
4182
+ if (commandDollarPos !== -1) {
4183
+ if (script) substitutions++;
4184
+ else (arithmeticOpens ?? (arithmeticOpens = [])).push(depth);
4185
+ if (
4186
+ substitutions + (arithmeticOpens?.length ?? 0) + this._nestingDepth + (quoteOnly ? 0 : 1) >
4187
+ MAX_SYNTAX_NESTING &&
4188
+ !reported
4189
+ ) {
4190
+ this.errors.push({ message: "maximum command substitution nesting depth exceeded", pos: commandDollarPos });
4191
+ reported = true;
4192
+ }
4193
+ }
4194
+ continuedDollarPos = -1;
4195
+ continuedParenPos = -1;
4196
+ depth++;
4197
+ if (caseDepth > 0) caseParens++;
4198
+ this.pos++;
4199
+ } else if (ch === CH_RPAREN) {
4200
+ if (caseDepth > 0 && caseParens === 0) {
4201
+ this.pos++;
4202
+ wordStart = true;
4203
+ } else {
4204
+ if (caseDepth > 0) caseParens--;
4205
+ depth--;
4206
+ if (depth === 0 && !quoteOnly) {
4207
+ const result = bt ? src.slice(start, this.pos) : "";
4208
+ this.pos++;
4209
+ return result;
4210
+ }
4211
+ if (depth <= arithBase) arithBase = -1;
4212
+ if (arithmeticOpens !== undefined) {
4213
+ while (arithmeticOpens.length > 0 && arithmeticOpens[arithmeticOpens.length - 1] >= depth)
4214
+ arithmeticOpens.pop();
4215
+ }
4216
+ wordStart = wordStartAfterParen.pop() ?? true;
4217
+ this.pos++;
4218
+ const scope = scripts?.[scripts.length - 1];
4219
+ if (scope !== undefined && scope.depth === depth) {
4220
+ scripts!.pop();
4221
+ caseDepth = scope.caseDepth;
4222
+ caseParens = scope.caseParens;
4223
+ arithBase = scope.arithBase;
4224
+ arithExtent = scope.arithExtent;
4225
+ substitutions = scope.substitutions;
4226
+ checkedGrammar = scope.checkedGrammar;
4227
+ pendingDelims = scope.pendingDelims;
4228
+ }
4229
+ }
4230
+ lastParenEnd = this.pos;
4231
+ } else if (ch === CH_BACKSLASH) {
4232
+ if (this.pos > start && src.charCodeAt(this.pos - 1) === CH_DOLLAR) {
4233
+ const logicalPos = skipLineContinuations(src, this.pos, len);
4234
+ if (
4235
+ logicalPos > this.pos &&
4236
+ src.charCodeAt(logicalPos) === CH_LPAREN &&
4237
+ src.charCodeAt(logicalPos + 1) !== CH_LPAREN
4238
+ ) {
4239
+ continuedDollarPos = this.pos - 1;
4240
+ continuedParenPos = logicalPos;
4241
+ }
4242
+ }
4243
+ this.pos++;
4244
+ if (this.pos < len) {
4245
+ if (src.charCodeAt(this.pos) !== CH_NL) {
4246
+ wordStart = false;
4247
+ }
4248
+ this.pos++;
4249
+ }
4250
+ escapedEnd = this.pos;
4251
+ } else if (ch === CH_DOLLAR) {
4252
+ const next = src.charCodeAt(++this.pos);
4253
+ if (next === CH_SQUOTE) {
4254
+ this.pos++;
4255
+ this.skipAnsiCQuoted();
4256
+ } else if (next === CH_DOLLAR) {
4257
+ this.pos++;
4258
+ }
4259
+ wordStart = false;
4260
+ } else if (ch === CH_SQUOTE) {
4261
+ this.pos++;
4262
+ this.skipSQ();
4263
+ wordStart = false;
4264
+ } else if (ch === CH_DQUOTE) {
4265
+ quotes.push({ close: CH_DQUOTE, depth });
4266
+ quotedDepth++;
4267
+ this.pos++;
4268
+ wordStart = false;
4269
+ } else if (ch === CH_BACKTICK) {
4270
+ this.pos++;
4271
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_BACKTICK) {
4272
+ if (src.charCodeAt(this.pos) === CH_BACKSLASH) this.pos++;
4273
+ if (this.pos < len) this.pos++;
4274
+ }
4275
+ if (this.pos < len) this.pos++;
4276
+ wordStart = false;
4277
+ } else if (ch === CH_LT && arithBase < 0 && this.pos + 1 < len && src.charCodeAt(this.pos + 1) === CH_LT) {
4278
+ // << is a heredoc operator even in case-pattern position — unquoted <<
4279
+ // inside a pattern is a bash syntax error, so no caseDepth gate here
4280
+ if (this.pos + 2 < len && src.charCodeAt(this.pos + 2) === CH_LT) {
4281
+ this.pos += 3; // <<< herestring
4282
+ } else {
4283
+ this.pos += 2;
4284
+ const strip = this.pos < len && src.charCodeAt(this.pos) === CH_DASH;
4285
+ if (strip) this.pos++;
4286
+ this.skipSpacesAndTabs();
4287
+ this.readHereDocDelimiter();
4288
+ // Empty unquoted delimiter means there was no delimiter word (`<< )`)
4289
+ if (this._hereDelim || this._hereQuoted) {
4290
+ (pendingDelims ?? (pendingDelims = [])).push({
4291
+ delimiter: this._hereDelim,
4292
+ strip,
4293
+ quoted: this._hereQuoted,
4294
+ });
4295
+ }
4296
+ }
4297
+ wordStart = false;
4298
+ } else if (ch === CH_NL && pendingDelims) {
4299
+ this.pos++;
4300
+ for (const hd of pendingDelims) this.skipHereDocBody(hd.delimiter, hd.strip, true, hd.quoted);
4301
+ pendingDelims = null;
4302
+ wordStart = true;
4303
+ } else if (ch === CH_HASH && arithBase < 0 && !arithExtent && wordStart) {
4304
+ // Word-boundary # opens a comment — opaque up to (not including) the
4305
+ // newline, so quotes and << inside it stay inert
4306
+ while (this.pos < len && src.charCodeAt(this.pos) !== CH_NL) this.pos++;
4307
+ } else {
4308
+ const wStart = this.pos;
4309
+ while (this.pos < len) {
4310
+ const wc = src.charCodeAt(this.pos);
4311
+ if (wc < 128 && charType[wc]) break;
4312
+ this.pos++;
4313
+ }
4314
+ if (this.pos > wStart) {
4315
+ const wLen = this.pos - wStart;
4316
+ if (wLen === 4 && wordStart && arithBase < 0 && !arithExtent) {
4317
+ const c0 = src.charCodeAt(wStart);
4318
+ if (
4319
+ c0 === 99 &&
4320
+ src.charCodeAt(wStart + 1) === 97 &&
4321
+ src.charCodeAt(wStart + 2) === 115 &&
4322
+ src.charCodeAt(wStart + 3) === 101
4323
+ ) {
4324
+ caseDepth++;
4325
+ } else if (
4326
+ c0 === 101 &&
4327
+ src.charCodeAt(wStart + 1) === 115 &&
4328
+ src.charCodeAt(wStart + 2) === 97 &&
4329
+ src.charCodeAt(wStart + 3) === 99 &&
4330
+ caseDepth > 0
4331
+ ) {
4332
+ caseDepth--;
4333
+ if (caseDepth === 0) caseParens = 0;
4334
+ }
4335
+ }
4336
+ wordStart = false;
4337
+ } else {
4338
+ const wc = src.charCodeAt(this.pos);
4339
+ wordStart = wc < 128 && (charType[wc] & 1) !== 0;
4340
+ this.pos++;
4341
+ }
4342
+ }
4343
+ }
4344
+ // Ran out of input before the closing paren. A quoted scan owns the `$(` it opened: the
4345
+ // outermost one left open is reported here, since no token read revisits this text.
4346
+ this._unbalanced = true;
4347
+ if (quoteOnly && scripts !== undefined && scripts.length > 0) {
4348
+ this.errors.push({ message: "unterminated command substitution", pos: scripts[0].dollar });
4349
+ }
4350
+ return bt ? src.slice(start, this.pos) : "";
4351
+ }
4352
+ }