ttsc 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/cmd/platform/main.go +9 -0
  2. package/cmd/ttsc/api_compile.go +122 -101
  3. package/cmd/ttsc/api_compile_branches_test.go +55 -57
  4. package/cmd/ttsc/api_transform.go +67 -49
  5. package/cmd/ttsc/api_transform_branches_test.go +30 -32
  6. package/cmd/ttsc/build.go +141 -108
  7. package/cmd/ttsc/build_branches_test.go +95 -97
  8. package/cmd/ttsc/filter.go +70 -0
  9. package/cmd/ttsc/filter_strips_unknown_forwarded_flag_test.go +61 -0
  10. package/cmd/ttsc/flags_gen.go +30 -0
  11. package/cmd/ttsc/helpers_test.go +39 -28
  12. package/cmd/ttsc/main.go +111 -104
  13. package/cmd/ttsc/main_alias_branches_test.go +21 -23
  14. package/cmd/ttscserver/main.go +96 -90
  15. package/cmd/ttscserver/run_lsp_prefers_tsgo_flag_test.go +22 -22
  16. package/cmd/ttscserver/run_lsp_reports_server_error_test.go +22 -22
  17. package/cmd/ttscserver/run_lsp_uses_tsgo_environment_test.go +23 -23
  18. package/cmd/utility-host/main.go +30 -23
  19. package/driver/lsp.go +53 -7
  20. package/driver/plugins.go +88 -72
  21. package/driver/program.go +464 -340
  22. package/driver/rewrite.go +477 -380
  23. package/internal/lspserver/lsp_envelope.go +64 -64
  24. package/internal/lspserver/lsp_frame.go +60 -60
  25. package/internal/lspserver/lsp_plugin.go +57 -57
  26. package/internal/lspserver/lsp_proxy.go +265 -251
  27. package/internal/lspserver/lsp_server.go +155 -144
  28. package/lib/TtscCompiler.d.ts +7 -0
  29. package/lib/TtscCompiler.js +7 -0
  30. package/lib/TtscCompiler.js.map +1 -1
  31. package/lib/compiler/internal/buildNativeCompiler.d.ts +11 -1
  32. package/lib/compiler/internal/buildNativeCompiler.js +23 -3
  33. package/lib/compiler/internal/buildNativeCompiler.js.map +1 -1
  34. package/lib/compiler/internal/compileProjectInMemory.d.ts +9 -0
  35. package/lib/compiler/internal/compileProjectInMemory.js +36 -21
  36. package/lib/compiler/internal/compileProjectInMemory.js.map +1 -1
  37. package/lib/compiler/internal/paths.d.ts +27 -0
  38. package/lib/compiler/internal/paths.js +70 -0
  39. package/lib/compiler/internal/paths.js.map +1 -0
  40. package/lib/compiler/internal/project/readProjectConfig.d.ts +9 -1
  41. package/lib/compiler/internal/project/readProjectConfig.js +80 -1
  42. package/lib/compiler/internal/project/readProjectConfig.js.map +1 -1
  43. package/lib/compiler/internal/project/resolveProjectConfig.d.ts +13 -1
  44. package/lib/compiler/internal/project/resolveProjectConfig.js +42 -1
  45. package/lib/compiler/internal/project/resolveProjectConfig.js.map +1 -1
  46. package/lib/compiler/internal/resolveBinary.d.ts +9 -1
  47. package/lib/compiler/internal/resolveBinary.js +17 -1
  48. package/lib/compiler/internal/resolveBinary.js.map +1 -1
  49. package/lib/compiler/internal/resolveEmittedJavaScript.d.ts +16 -1
  50. package/lib/compiler/internal/resolveEmittedJavaScript.js +42 -10
  51. package/lib/compiler/internal/resolveEmittedJavaScript.js.map +1 -1
  52. package/lib/compiler/internal/resolveTsgo.d.ts +21 -1
  53. package/lib/compiler/internal/resolveTsgo.js +23 -1
  54. package/lib/compiler/internal/resolveTsgo.js.map +1 -1
  55. package/lib/compiler/internal/runBuild.d.ts +24 -4
  56. package/lib/compiler/internal/runBuild.js +355 -93
  57. package/lib/compiler/internal/runBuild.js.map +1 -1
  58. package/lib/compiler/internal/runSingleFileEmit.d.ts +10 -1
  59. package/lib/compiler/internal/runSingleFileEmit.js +14 -1
  60. package/lib/compiler/internal/runSingleFileEmit.js.map +1 -1
  61. package/lib/compiler/internal/sharedHostHelpers.d.ts +5 -0
  62. package/lib/compiler/internal/sharedHostHelpers.js +6 -2
  63. package/lib/compiler/internal/sharedHostHelpers.js.map +1 -1
  64. package/lib/compiler/internal/spawnNative.d.ts +21 -0
  65. package/lib/compiler/internal/spawnNative.js +59 -0
  66. package/lib/compiler/internal/spawnNative.js.map +1 -0
  67. package/lib/compiler/internal/transformProjectInMemory.d.ts +15 -1
  68. package/lib/compiler/internal/transformProjectInMemory.js +57 -61
  69. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  70. package/lib/flags/parser.d.ts +63 -0
  71. package/lib/flags/parser.js +254 -0
  72. package/lib/flags/parser.js.map +1 -0
  73. package/lib/flags/schema.d.ts +147 -0
  74. package/lib/flags/schema.js +405 -0
  75. package/lib/flags/schema.js.map +1 -0
  76. package/lib/launcher/internal/getCompilerVersionText.js +7 -0
  77. package/lib/launcher/internal/getCompilerVersionText.js.map +1 -1
  78. package/lib/launcher/internal/prepareExecution.js +35 -0
  79. package/lib/launcher/internal/prepareExecution.js.map +1 -1
  80. package/lib/launcher/internal/resolveCacheDir.d.ts +9 -0
  81. package/lib/launcher/internal/resolveCacheDir.js +22 -0
  82. package/lib/launcher/internal/resolveCacheDir.js.map +1 -0
  83. package/lib/launcher/internal/resolveTtscserverBinary.js +2 -0
  84. package/lib/launcher/internal/resolveTtscserverBinary.js.map +1 -1
  85. package/lib/launcher/internal/runTtsc.d.ts +10 -0
  86. package/lib/launcher/internal/runTtsc.js +90 -153
  87. package/lib/launcher/internal/runTtsc.js.map +1 -1
  88. package/lib/launcher/internal/runTtscserver.js +10 -35
  89. package/lib/launcher/internal/runTtscserver.js.map +1 -1
  90. package/lib/launcher/internal/runTtsx.d.ts +8 -0
  91. package/lib/launcher/internal/runTtsx.js +137 -79
  92. package/lib/launcher/internal/runTtsx.js.map +1 -1
  93. package/lib/plugin/internal/buildSourcePlugin.d.ts +38 -0
  94. package/lib/plugin/internal/buildSourcePlugin.js +40 -17
  95. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  96. package/lib/plugin/internal/loadProjectPlugins.d.ts +26 -0
  97. package/lib/plugin/internal/loadProjectPlugins.js +90 -41
  98. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  99. package/lib/structures/ITtscCompilerTransformation.d.ts +15 -2
  100. package/lib/structures/ITtscPlugin.d.ts +24 -0
  101. package/lib/structures/ITtscPluginCapabilities.d.ts +29 -0
  102. package/lib/structures/ITtscPluginCapabilities.js +3 -0
  103. package/lib/structures/ITtscPluginCapabilities.js.map +1 -0
  104. package/lib/structures/index.d.ts +1 -0
  105. package/lib/structures/index.js +1 -0
  106. package/lib/structures/index.js.map +1 -1
  107. package/lib/structures/internal/ITtscLoadedNativePlugin.d.ts +5 -0
  108. package/lib/structures/internal/TtscCommonOptions.d.ts +19 -0
  109. package/lib/structures/internal/TtscSingleFileEmitOptions.d.ts +0 -2
  110. package/package.json +9 -9
  111. package/shim/ast/shim.go +24 -0
  112. package/shim/checker/shim.go +35 -0
  113. package/shim/core/shim.go +17 -0
  114. package/shim/diagnosticwriter/shim.go +4 -0
  115. package/shim/lsp/shim.go +3 -3
  116. package/shim/printer/shim.go +20 -0
  117. package/src/TtscCompiler.ts +7 -0
  118. package/src/compiler/internal/buildNativeCompiler.ts +23 -3
  119. package/src/compiler/internal/compileProjectInMemory.ts +34 -25
  120. package/src/compiler/internal/paths.ts +68 -0
  121. package/src/compiler/internal/project/readProjectConfig.ts +86 -1
  122. package/src/compiler/internal/project/resolveProjectConfig.ts +44 -1
  123. package/src/compiler/internal/resolveBinary.ts +17 -1
  124. package/src/compiler/internal/resolveEmittedJavaScript.ts +43 -12
  125. package/src/compiler/internal/resolveTsgo.ts +29 -1
  126. package/src/compiler/internal/runBuild.ts +398 -112
  127. package/src/compiler/internal/runSingleFileEmit.ts +14 -1
  128. package/src/compiler/internal/sharedHostHelpers.ts +7 -7
  129. package/src/compiler/internal/spawnNative.ts +64 -0
  130. package/src/compiler/internal/transformProjectInMemory.ts +47 -67
  131. package/src/flags/parser.ts +367 -0
  132. package/src/flags/schema.ts +570 -0
  133. package/src/launcher/internal/getCompilerVersionText.ts +7 -0
  134. package/src/launcher/internal/prepareExecution.ts +35 -0
  135. package/src/launcher/internal/resolveCacheDir.ts +19 -0
  136. package/src/launcher/internal/resolveTtscserverBinary.ts +2 -0
  137. package/src/launcher/internal/runTtsc.ts +97 -143
  138. package/src/launcher/internal/runTtscserver.ts +9 -1
  139. package/src/launcher/internal/runTtsx.ts +149 -82
  140. package/src/plugin/internal/buildSourcePlugin.ts +40 -14
  141. package/src/plugin/internal/loadProjectPlugins.ts +116 -45
  142. package/src/structures/ITtscCompilerTransformation.ts +15 -2
  143. package/src/structures/ITtscPlugin.ts +26 -1
  144. package/src/structures/ITtscPluginCapabilities.ts +29 -0
  145. package/src/structures/index.ts +1 -0
  146. package/src/structures/internal/ITtscLoadedNativePlugin.ts +5 -0
  147. package/src/structures/internal/TtscCommonOptions.ts +19 -0
  148. package/src/structures/internal/TtscSingleFileEmitOptions.ts +0 -2
  149. package/tools/gen_shims/main.go +43 -8
  150. package/utility/flags_gen.go +30 -0
  151. package/utility/host.go +304 -245
package/driver/rewrite.go CHANGED
@@ -14,34 +14,34 @@
14
14
  package driver
15
15
 
16
16
  import (
17
- "context"
18
- "errors"
19
- "fmt"
20
- "os"
21
- "path/filepath"
22
- "regexp"
23
- "strings"
24
- "sync"
25
- "unicode"
26
-
27
- "github.com/microsoft/typescript-go/shim/ast"
28
- shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
17
+ "context"
18
+ "errors"
19
+ "fmt"
20
+ "os"
21
+ "path/filepath"
22
+ "regexp"
23
+ "strings"
24
+ "sync"
25
+ "unicode"
26
+
27
+ "github.com/microsoft/typescript-go/shim/ast"
28
+ shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
29
29
  )
30
30
 
31
31
  // Rewrite describes one emit-time patch. Produced by CollectCallSites after
32
32
  // the engine has generated a replacement JS fragment for the call.
33
33
  type Rewrite struct {
34
- File *ast.SourceFile
35
- RootName string
36
- Namespaces []string
37
- Method string
38
- Replacement string
39
- ConsumeParens bool
34
+ File *ast.SourceFile
35
+ RootName string
36
+ Namespaces []string
37
+ Method string
38
+ Replacement string
39
+ ConsumeParens bool
40
40
  }
41
41
 
42
42
  // RewriteSet groups rewrites by file, preserving source order.
43
43
  type RewriteSet struct {
44
- byPath map[string][]Rewrite
44
+ byPath map[string][]Rewrite
45
45
  }
46
46
 
47
47
  // NewRewriteSet returns an empty set.
@@ -49,20 +49,20 @@ func NewRewriteSet() *RewriteSet { return &RewriteSet{byPath: map[string][]Rewri
49
49
 
50
50
  // Add registers a rewrite under the absolute path of its source file.
51
51
  func (rs *RewriteSet) Add(r Rewrite) {
52
- if r.File == nil {
53
- return
54
- }
55
- path := filepath.ToSlash(r.File.FileName())
56
- rs.byPath[path] = append(rs.byPath[path], r)
52
+ if r.File == nil {
53
+ return
54
+ }
55
+ path := filepath.ToSlash(r.File.FileName())
56
+ rs.byPath[path] = append(rs.byPath[path], r)
57
57
  }
58
58
 
59
59
  // Len returns the total number of rewrites across every file.
60
60
  func (rs *RewriteSet) Len() int {
61
- n := 0
62
- for _, rws := range rs.byPath {
63
- n += len(rws)
64
- }
65
- return n
61
+ n := 0
62
+ for _, rws := range rs.byPath {
63
+ n += len(rws)
64
+ }
65
+ return n
66
66
  }
67
67
 
68
68
  // RewriteSentinel is the marker inserted at the top of a patched file so
@@ -73,128 +73,171 @@ const RewriteSentinel = "/* @ttsc-rewritten */"
73
73
  // the output. Returns the tsgo diagnostics and any patch-time error. When
74
74
  // `writeFile` is nil, the patched JS is written to disk via the standard
75
75
  // tsgo WriteFile.
76
+ //
77
+ // `writeFile` does not need to be concurrency-safe: emit() funnels every
78
+ // invocation through one mutex, so the callback never runs on two goroutines
79
+ // at once even though TypeScript-Go emits files in parallel.
76
80
  func (p *Program) EmitAll(rs *RewriteSet, writeFile shimcompiler.WriteFile) (*shimcompiler.EmitResult, []Diagnostic, error) {
77
- return p.emit(rs, nil, writeFile)
81
+ return p.emit(rs, nil, writeFile)
78
82
  }
79
83
 
80
84
  // EmitAllRaw runs TypeScript-Go emit without ttsc output-text rewrites.
85
+ //
86
+ // `writeFile` does not need to be concurrency-safe: like EmitAll, EmitAllRaw
87
+ // funnels every invocation through one mutex, so the callback never runs on
88
+ // two goroutines at once even though TypeScript-Go emits files in parallel.
89
+ // This is the contract a plugin's output rewriter relies on — it is the
90
+ // emit-stage phase ttsc guarantees runs single-threaded (a plugin's WriteFile
91
+ // is the standard place to carry per-file cursors or an output map), so ttsc
92
+ // owns the serialization rather than pushing goroutine-safety onto every
93
+ // plugin author. See the emit-concurrency contract in the plugin docs.
81
94
  func (p *Program) EmitAllRaw(writeFile shimcompiler.WriteFile) (*shimcompiler.EmitResult, []Diagnostic, error) {
82
- if p == nil || p.TSProgram == nil {
83
- return nil, nil, errors.New("driver: nil program")
84
- }
85
- if err := p.ApplyLinkedPlugins(); err != nil {
86
- return nil, nil, err
87
- }
88
- wf := writeFile
89
- if wf == nil {
90
- wf = func(fileName, text string, data *shimcompiler.WriteFileData) error {
91
- return DefaultWriteFile(fileName, text)
92
- }
93
- }
94
- result := p.TSProgram.Emit(context.Background(), shimcompiler.EmitOptions{
95
- WriteFile: wf,
96
- })
97
- return result, convertDiagnostics(result.Diagnostics), nil
95
+ if p == nil || p.TSProgram == nil {
96
+ return nil, nil, errors.New("driver: nil program")
97
+ }
98
+ if err := p.ApplyLinkedPlugins(); err != nil {
99
+ return nil, nil, err
100
+ }
101
+ // TypeScript-Go's parallel emit invokes WriteFile once per emitted file,
102
+ // concurrently — one goroutine per source file. Serialize the whole callback
103
+ // under wfMu so a plugin's output rewriter sees one writer at a time: a
104
+ // callback that mutates shared state (e.g. @nestia/core's per-file rewrite
105
+ // cursors and runtime-alias cache) would otherwise trip `fatal error:
106
+ // concurrent map read and map write`. The callback is cheap I/O, so
107
+ // serializing it costs ~nothing while parse/check/emit-text still parallelize
108
+ // — the same trade EmitAll makes for its own WriteFile.
109
+ var wfMu sync.Mutex
110
+ wf := func(fileName, text string, data *shimcompiler.WriteFileData) error {
111
+ wfMu.Lock()
112
+ defer wfMu.Unlock()
113
+ if writeFile != nil {
114
+ return writeFile(fileName, text, data)
115
+ }
116
+ return DefaultWriteFile(fileName, text)
117
+ }
118
+ result := p.TSProgram.Emit(context.Background(), shimcompiler.EmitOptions{
119
+ WriteFile: wf,
120
+ })
121
+ return result, convertDiagnostics(result.Diagnostics), nil
98
122
  }
99
123
 
100
124
  // EmitFile runs tsgo's emitter for one source file, applying the same rewrite
101
125
  // pipeline as EmitAll.
102
126
  func (p *Program) EmitFile(rs *RewriteSet, target *ast.SourceFile, writeFile shimcompiler.WriteFile) (*shimcompiler.EmitResult, []Diagnostic, error) {
103
- return p.emit(rs, target, writeFile)
127
+ return p.emit(rs, target, writeFile)
104
128
  }
105
129
 
106
130
  func (p *Program) emit(rs *RewriteSet, target *ast.SourceFile, writeFile shimcompiler.WriteFile) (*shimcompiler.EmitResult, []Diagnostic, error) {
107
- if p == nil || p.TSProgram == nil {
108
- return nil, nil, errors.New("driver: nil program")
109
- }
110
- if err := p.ApplyLinkedPlugins(); err != nil {
111
- return nil, nil, err
112
- }
113
- if rs == nil {
114
- rs = NewRewriteSet()
115
- }
116
- cursors := map[string]int{}
117
- wf := func(fileName, text string, data *shimcompiler.WriteFileData) error {
118
- // A patched file is idempotent: once the sentinel exists, the emitted text
119
- // is passed through unchanged. This matters for watch/rebuild loops and
120
- // tests that re-run emit over the same output directory.
121
- if strings.Contains(text, RewriteSentinel) {
122
- if writeFile != nil {
123
- return writeFile(fileName, text, data)
124
- }
125
- return DefaultWriteFile(fileName, text)
126
- }
127
- // Rewrites are matched after tsgo has printed JavaScript. The source-file
128
- // association is recovered from the output path because WriteFile receives
129
- // only the final file name and text.
130
- patched, err := applyRewrites(fileName, text, rs, cursors)
131
- if err != nil {
132
- return err
133
- }
134
- if patched != text {
135
- patched = insertSentinel(patched)
136
- }
137
- if writeFile != nil {
138
- return writeFile(fileName, patched, data)
139
- }
140
- return DefaultWriteFile(fileName, patched)
141
- }
142
-
143
- result := p.TSProgram.Emit(context.Background(), shimcompiler.EmitOptions{
144
- TargetSourceFile: target,
145
- WriteFile: wf,
146
- })
147
- return result, convertDiagnostics(result.Diagnostics), nil
131
+ if p == nil || p.TSProgram == nil {
132
+ return nil, nil, errors.New("driver: nil program")
133
+ }
134
+ if err := p.ApplyLinkedPlugins(); err != nil {
135
+ return nil, nil, err
136
+ }
137
+ if rs == nil {
138
+ rs = NewRewriteSet()
139
+ }
140
+ cursors := map[string]int{}
141
+ // TypeScript-Go's parallel emit invokes this WriteFile callback once per
142
+ // emitted file, concurrently — one goroutine per source file. Serialize the
143
+ // whole callback body under wfMu: the `cursors` map would otherwise trip
144
+ // `fatal error: concurrent map writes`, and the wrapped `writeFile` (which a
145
+ // caller may back with its own non-thread-safe state, e.g. api-compile's
146
+ // output map) must likewise see one writer at a time. The patch work here is
147
+ // cheap, so serializing only the callback costs ~nothing while parsing,
148
+ // checking, and emit-text generation still parallelize.
149
+ var wfMu sync.Mutex
150
+ wf := func(fileName, text string, data *shimcompiler.WriteFileData) error {
151
+ wfMu.Lock()
152
+ defer wfMu.Unlock()
153
+ // A patched file is idempotent: once the sentinel exists, the emitted text
154
+ // is passed through unchanged. This matters for watch/rebuild loops and
155
+ // tests that re-run emit over the same output directory.
156
+ if strings.Contains(text, RewriteSentinel) {
157
+ if writeFile != nil {
158
+ return writeFile(fileName, text, data)
159
+ }
160
+ return DefaultWriteFile(fileName, text)
161
+ }
162
+ // Rewrites are matched after tsgo has printed JavaScript. The source-file
163
+ // association is recovered from the output path because WriteFile receives
164
+ // only the final file name and text.
165
+ patched, err := applyRewrites(fileName, text, rs, cursors)
166
+ if err != nil {
167
+ return err
168
+ }
169
+ if patched != text {
170
+ patched = insertSentinel(patched)
171
+ }
172
+ if writeFile != nil {
173
+ return writeFile(fileName, patched, data)
174
+ }
175
+ return DefaultWriteFile(fileName, patched)
176
+ }
177
+
178
+ result := p.TSProgram.Emit(context.Background(), shimcompiler.EmitOptions{
179
+ TargetSourceFile: target,
180
+ WriteFile: wf,
181
+ })
182
+ return result, convertDiagnostics(result.Diagnostics), nil
148
183
  }
149
184
 
150
185
  // DefaultWriteFile is the default disk writer used when EmitAll's caller does not
151
186
  // supply a custom WriteFile callback.
152
187
  func DefaultWriteFile(fileName, text string) error {
153
- if dir := filepath.Dir(fileName); dir != "" {
154
- if err := os.MkdirAll(dir, 0o755); err != nil {
155
- return err
156
- }
157
- }
158
- return os.WriteFile(fileName, []byte(text), 0o644)
188
+ if dir := filepath.Dir(fileName); dir != "" {
189
+ if err := os.MkdirAll(dir, 0o755); err != nil {
190
+ return err
191
+ }
192
+ }
193
+ return os.WriteFile(fileName, []byte(text), 0o644)
159
194
  }
160
195
 
196
+ // insertSentinel prepends RewriteSentinel to the output text. When the file
197
+ // starts with a "use strict" directive (either quote style), the sentinel is
198
+ // inserted after it so the directive remains the first statement — ES modules
199
+ // and bundlers expect it at position zero.
161
200
  func insertSentinel(text string) string {
162
- for _, prefix := range []string{"\"use strict\";\n", "'use strict';\n"} {
163
- if strings.HasPrefix(text, prefix) {
164
- return prefix + RewriteSentinel + "\n" + text[len(prefix):]
165
- }
166
- }
167
- return RewriteSentinel + "\n" + text
201
+ for _, prefix := range []string{"\"use strict\";\n", "'use strict';\n"} {
202
+ if strings.HasPrefix(text, prefix) {
203
+ return prefix + RewriteSentinel + "\n" + text[len(prefix):]
204
+ }
205
+ }
206
+ return RewriteSentinel + "\n" + text
168
207
  }
169
208
 
209
+ // applyRewrites applies all registered rewrites for the source file that
210
+ // corresponds to outputName. cursors tracks how many rewrites have already
211
+ // been applied per source path across multiple WriteFile calls so incremental
212
+ // watch rebuilds resume at the right offset rather than re-scanning from zero.
170
213
  func applyRewrites(outputName, text string, rs *RewriteSet, cursors map[string]int) (string, error) {
171
- srcPath, ok := findSourceForOutput(outputName, rs)
172
- if !ok || len(rs.byPath[srcPath]) == 0 {
173
- return text, nil
174
- }
175
- rewrites := rs.byPath[srcPath]
176
- pos := cursors[srcPath]
177
- out := text
178
- searchFrom := 0
179
- for pos < len(rewrites) {
180
- r := rewrites[pos]
181
- replaced, nextSearchFrom, ok, err := spliceCall(out, r, searchFrom)
182
- if err != nil {
183
- return "", err
184
- }
185
- if !ok {
186
- preview := out
187
- if len(preview) > 400 {
188
- preview = preview[:400] + "…"
189
- }
190
- return "", fmt.Errorf("driver: could not locate %s.%s(…) call in %s (tried roots %v; preview: %q)", joinRootAndNamespaces(r), r.Method, outputName, candidateRoots(r.RootName), preview)
191
- }
192
- out = replaced
193
- searchFrom = nextSearchFrom
194
- pos++
195
- }
196
- cursors[srcPath] = pos
197
- return out, nil
214
+ srcPath, ok := findSourceForOutput(outputName, rs)
215
+ if !ok || len(rs.byPath[srcPath]) == 0 {
216
+ return text, nil
217
+ }
218
+ rewrites := rs.byPath[srcPath]
219
+ pos := cursors[srcPath]
220
+ out := text
221
+ searchFrom := 0
222
+ for pos < len(rewrites) {
223
+ r := rewrites[pos]
224
+ replaced, nextSearchFrom, ok, err := spliceCall(out, r, searchFrom)
225
+ if err != nil {
226
+ return "", err
227
+ }
228
+ if !ok {
229
+ preview := out
230
+ if len(preview) > 400 {
231
+ preview = preview[:400] + "…"
232
+ }
233
+ return "", fmt.Errorf("driver: could not locate %s.%s(…) call in %s (tried roots %v; preview: %q)", joinRootAndNamespaces(r), r.Method, outputName, candidateRoots(r.RootName), preview)
234
+ }
235
+ out = replaced
236
+ searchFrom = nextSearchFrom
237
+ pos++
238
+ }
239
+ cursors[srcPath] = pos
240
+ return out, nil
198
241
  }
199
242
 
200
243
  // findSourceForOutput recovers which registered source file produced a given
@@ -213,93 +256,99 @@ func applyRewrites(outputName, text string, rs *RewriteSet, cursors map[string]i
213
256
  // Ambiguous matches (two or more registered sources with the same tail) return
214
257
  // no match so the caller treats the output as having no rewrites.
215
258
  func findSourceForOutput(outputName string, rs *RewriteSet) (string, bool) {
216
- if len(rs.byPath) == 0 {
217
- return "", false
218
- }
219
- outStem := strings.TrimSuffix(filepath.ToSlash(outputName), filepath.Ext(outputName))
220
- commonDir := commonSourceDirectoryFor(rs)
221
- var matched string
222
- hits := 0
223
- for srcPath := range rs.byPath {
224
- tail := sourceTail(srcPath, commonDir)
225
- if tail == "" {
226
- continue
227
- }
228
- if outStem == tail || strings.HasSuffix(outStem, "/"+tail) {
229
- matched = srcPath
230
- hits++
231
- }
232
- }
233
- if hits != 1 {
234
- return "", false
235
- }
236
- return matched, true
259
+ if len(rs.byPath) == 0 {
260
+ return "", false
261
+ }
262
+ outStem := strings.TrimSuffix(filepath.ToSlash(outputName), filepath.Ext(outputName))
263
+ commonDir := commonSourceDirectoryFor(rs)
264
+ var matched string
265
+ hits := 0
266
+ for srcPath := range rs.byPath {
267
+ tail := sourceTail(srcPath, commonDir)
268
+ if tail == "" {
269
+ continue
270
+ }
271
+ if outStem == tail || strings.HasSuffix(outStem, "/"+tail) {
272
+ matched = srcPath
273
+ hits++
274
+ }
275
+ }
276
+ if hits != 1 {
277
+ return "", false
278
+ }
279
+ return matched, true
237
280
  }
238
281
 
239
282
  // commonSourceDirectoryFor returns the deepest directory (with trailing "/")
240
283
  // shared by every source path in rs.byPath. When rs has a single source this is
241
284
  // just that source's directory.
242
285
  func commonSourceDirectoryFor(rs *RewriteSet) string {
243
- var dirs [][]string
244
- for srcPath := range rs.byPath {
245
- dirs = append(dirs, strings.Split(filepath.ToSlash(filepath.Dir(srcPath)), "/"))
246
- }
247
- if len(dirs) == 0 {
248
- return ""
249
- }
250
- common := dirs[0]
251
- for _, other := range dirs[1:] {
252
- n := len(common)
253
- if len(other) < n {
254
- n = len(other)
255
- }
256
- shared := 0
257
- for i := 0; i < n; i++ {
258
- if common[i] != other[i] {
259
- break
260
- }
261
- shared++
262
- }
263
- common = common[:shared]
264
- if len(common) == 0 {
265
- break
266
- }
267
- }
268
- if len(common) == 0 {
269
- return ""
270
- }
271
- return strings.Join(common, "/") + "/"
286
+ var dirs [][]string
287
+ for srcPath := range rs.byPath {
288
+ dirs = append(dirs, strings.Split(filepath.ToSlash(filepath.Dir(srcPath)), "/"))
289
+ }
290
+ if len(dirs) == 0 {
291
+ return ""
292
+ }
293
+ common := dirs[0]
294
+ for _, other := range dirs[1:] {
295
+ n := len(common)
296
+ if len(other) < n {
297
+ n = len(other)
298
+ }
299
+ shared := 0
300
+ for i := 0; i < n; i++ {
301
+ if common[i] != other[i] {
302
+ break
303
+ }
304
+ shared++
305
+ }
306
+ common = common[:shared]
307
+ if len(common) == 0 {
308
+ break
309
+ }
310
+ }
311
+ if len(common) == 0 {
312
+ return ""
313
+ }
314
+ return strings.Join(common, "/") + "/"
272
315
  }
273
316
 
274
317
  // sourceTail returns the source stem (extension dropped) without the common
275
318
  // directory prefix. The leading "/" is also stripped so callers can match it as
276
319
  // a suffix segment.
277
320
  func sourceTail(srcPath, commonDir string) string {
278
- stem := strings.TrimSuffix(filepath.ToSlash(srcPath), filepath.Ext(srcPath))
279
- if commonDir != "" && strings.HasPrefix(stem, commonDir) {
280
- return stem[len(commonDir):]
281
- }
282
- return strings.TrimPrefix(stem, "/")
321
+ stem := strings.TrimSuffix(filepath.ToSlash(srcPath), filepath.Ext(srcPath))
322
+ if commonDir != "" && strings.HasPrefix(stem, commonDir) {
323
+ return stem[len(commonDir):]
324
+ }
325
+ return strings.TrimPrefix(stem, "/")
283
326
  }
284
327
 
328
+ // spliceCall locates the next call expression for r in text starting at
329
+ // searchFrom and splices in r.Replacement. When ConsumeParens is true the
330
+ // replacement covers the entire call including arguments; otherwise only the
331
+ // head (root.namespaces.method) is replaced and the argument list is kept.
332
+ // Returns the patched text, the byte position to resume from on the next
333
+ // call, a found flag, and any error from the paren-matching step.
285
334
  func spliceCall(text string, r Rewrite, searchFrom int) (string, int, bool, error) {
286
- aliases := candidateRoots(r.RootName)
287
- pattern := callRegexFor(aliases, r.Namespaces, r.Method)
288
- idx, needleLen := findCallMatch(text, pattern, searchFrom)
289
- if idx < 0 {
290
- return text, searchFrom, false, nil
291
- }
292
- parenPos := idx + needleLen
293
- closePos, ok := matchParen(text, parenPos)
294
- if !ok {
295
- return text, searchFrom, false, errors.New("driver: unbalanced parens while locating plugin call")
296
- }
297
- if r.ConsumeParens {
298
- replaced := text[:idx] + r.Replacement + text[closePos+1:]
299
- return replaced, idx + len(r.Replacement), true, nil
300
- }
301
- replaced := text[:idx] + r.Replacement + text[idx+needleLen:]
302
- return replaced, idx + len(r.Replacement), true, nil
335
+ aliases := candidateRoots(r.RootName)
336
+ pattern := callRegexFor(aliases, r.Namespaces, r.Method)
337
+ idx, needleLen := findCallMatch(text, pattern, searchFrom)
338
+ if idx < 0 {
339
+ return text, searchFrom, false, nil
340
+ }
341
+ parenPos := idx + needleLen
342
+ closePos, ok := matchParen(text, parenPos)
343
+ if !ok {
344
+ return text, searchFrom, false, errors.New("driver: unbalanced parens while locating plugin call")
345
+ }
346
+ if r.ConsumeParens {
347
+ replaced := text[:idx] + r.Replacement + text[closePos+1:]
348
+ return replaced, idx + len(r.Replacement), true, nil
349
+ }
350
+ replaced := text[:idx] + r.Replacement + text[idx+needleLen:]
351
+ return replaced, idx + len(r.Replacement), true, nil
303
352
  }
304
353
 
305
354
  // callRegexFor compiles the loose-match needle pattern used by spliceCall.
@@ -317,30 +366,30 @@ func spliceCall(text string, r Rewrite, searchFrom int) (string, int, bool, erro
317
366
  // same rewrite descriptor is re-checked once per emitted file in incremental
318
367
  // watch builds.
319
368
  func callRegexFor(aliases, namespaces []string, method string) *regexp.Regexp {
320
- key := strings.Join(aliases, "|") + "\x00" + strings.Join(namespaces, ".") + "\x00" + method
321
- if cached, ok := callRegexCache.Load(key); ok {
322
- return cached.(*regexp.Regexp)
323
- }
324
- rootAlternation := make([]string, 0, len(aliases))
325
- for _, alias := range aliases {
326
- rootAlternation = append(rootAlternation, regexp.QuoteMeta(alias))
327
- }
328
- var b strings.Builder
329
- b.WriteString(`(?:`)
330
- b.WriteString(strings.Join(rootAlternation, `|`))
331
- b.WriteString(`)`)
332
- for _, ns := range namespaces {
333
- b.WriteString(`\s*\.\s*`)
334
- b.WriteString(regexp.QuoteMeta(ns))
335
- }
336
- b.WriteString(`\s*\.\s*`)
337
- b.WriteString(regexp.QuoteMeta(method))
338
- b.WriteString(`\s*(\()`)
339
- // MustCompile is safe because every contributing string was QuoteMeta'd
340
- // and the surrounding template is a fixed regex grammar.
341
- re := regexp.MustCompile(b.String())
342
- callRegexCache.Store(key, re)
343
- return re
369
+ key := strings.Join(aliases, "|") + "\x00" + strings.Join(namespaces, ".") + "\x00" + method
370
+ if cached, ok := callRegexCache.Load(key); ok {
371
+ return cached.(*regexp.Regexp)
372
+ }
373
+ rootAlternation := make([]string, 0, len(aliases))
374
+ for _, alias := range aliases {
375
+ rootAlternation = append(rootAlternation, regexp.QuoteMeta(alias))
376
+ }
377
+ var b strings.Builder
378
+ b.WriteString(`(?:`)
379
+ b.WriteString(strings.Join(rootAlternation, `|`))
380
+ b.WriteString(`)`)
381
+ for _, ns := range namespaces {
382
+ b.WriteString(`\s*\.\s*`)
383
+ b.WriteString(regexp.QuoteMeta(ns))
384
+ }
385
+ b.WriteString(`\s*\.\s*`)
386
+ b.WriteString(regexp.QuoteMeta(method))
387
+ b.WriteString(`\s*(\()`)
388
+ // MustCompile is safe because every contributing string was QuoteMeta'd
389
+ // and the surrounding template is a fixed regex grammar.
390
+ re := regexp.MustCompile(b.String())
391
+ callRegexCache.Store(key, re)
392
+ return re
344
393
  }
345
394
 
346
395
  var callRegexCache sync.Map
@@ -351,197 +400,245 @@ var callRegexCache sync.Map
351
400
  // like `mytypia.foo(` don't shadow `typia.foo(`. Returns the start byte of the
352
401
  // match and the length up to (but not including) the captured `(`.
353
402
  func findCallMatch(text string, pattern *regexp.Regexp, searchFrom int) (int, int) {
354
- start := searchFrom
355
- if start < 0 {
356
- start = 0
357
- }
358
- for start <= len(text) {
359
- loc := pattern.FindStringSubmatchIndex(text[start:])
360
- if loc == nil {
361
- return -1, 0
362
- }
363
- matchStart := start + loc[0]
364
- parenStart := start + loc[2]
365
- if matchStart > 0 && isIdentifierPart(rune(text[matchStart-1])) {
366
- start = matchStart + 1
367
- continue
368
- }
369
- return matchStart, parenStart - matchStart
370
- }
371
- return -1, 0
403
+ start := searchFrom
404
+ if start < 0 {
405
+ start = 0
406
+ }
407
+ for start <= len(text) {
408
+ loc := pattern.FindStringSubmatchIndex(text[start:])
409
+ if loc == nil {
410
+ return -1, 0
411
+ }
412
+ matchStart := start + loc[0]
413
+ parenStart := start + loc[2]
414
+ if matchStart > 0 && isIdentifierPart(rune(text[matchStart-1])) {
415
+ start = matchStart + 1
416
+ continue
417
+ }
418
+ return matchStart, parenStart - matchStart
419
+ }
420
+ return -1, 0
372
421
  }
373
422
 
423
+ // candidateRoots expands a bare module name into the set of identifier stems
424
+ // tsgo's CommonJS emitter may produce for it. For example "typia" becomes
425
+ // ["typia", "typia_1.default", "typia_2.default", "typia.default",
426
+ // "typia_1", "typia_2"]. The regex alternation built from these candidates
427
+ // matches the emitted call regardless of which deduplication suffix tsgo chose.
374
428
  func candidateRoots(root string) []string {
375
- return []string{
376
- root,
377
- root + "_1.default",
378
- root + "_2.default",
379
- root + ".default",
380
- root + "_1",
381
- root + "_2",
382
- }
429
+ return []string{
430
+ root,
431
+ root + "_1.default",
432
+ root + "_2.default",
433
+ root + ".default",
434
+ root + "_1",
435
+ root + "_2",
436
+ }
383
437
  }
384
438
 
439
+ // joinRootAndNamespaces returns the human-readable "root.ns1.ns2" form of
440
+ // the rewrite's call head, used in error messages only.
385
441
  func joinRootAndNamespaces(r Rewrite) string {
386
- if len(r.Namespaces) == 0 {
387
- return r.RootName
388
- }
389
- return r.RootName + "." + strings.Join(r.Namespaces, ".")
442
+ if len(r.Namespaces) == 0 {
443
+ return r.RootName
444
+ }
445
+ return r.RootName + "." + strings.Join(r.Namespaces, ".")
390
446
  }
391
447
 
448
+ // needleTail returns the literal suffix of a call expression head, e.g.
449
+ // ".ns.method(". This was the original literal-search needle before the regex
450
+ // rewriter was introduced; it is kept for potential future use by callers
451
+ // that do a quick pre-filter before invoking the regex path.
392
452
  func needleTail(r Rewrite) string {
393
- if len(r.Namespaces) == 0 {
394
- return "." + r.Method + "("
395
- }
396
- return "." + strings.Join(r.Namespaces, ".") + "." + r.Method + "("
453
+ if len(r.Namespaces) == 0 {
454
+ return "." + r.Method + "("
455
+ }
456
+ return "." + strings.Join(r.Namespaces, ".") + "." + r.Method + "("
397
457
  }
398
458
 
459
+ // isIdentifierPart reports whether r can appear inside a JavaScript identifier.
460
+ // Used to ensure a regex match does not begin mid-identifier (e.g. "mytypia"
461
+ // must not match as "typia").
399
462
  func isIdentifierPart(r rune) bool {
400
- return r == '_' || r == '$' || unicode.IsLetter(r) || unicode.IsDigit(r)
463
+ return r == '_' || r == '$' || unicode.IsLetter(r) || unicode.IsDigit(r)
401
464
  }
402
465
 
466
+ // matchParen finds the closing ")" that matches the "(" at text[pos],
467
+ // skipping over nested parentheses, strings, template literals, comments, and
468
+ // regex literals. Returns the byte index of the closing ")" and true on
469
+ // success; (0, false) when pos does not point at "(" or the text ends before
470
+ // the paren is closed. The lastSignificant variable tracks the most recently
471
+ // seen non-whitespace byte so canStartRegexLiteral can distinguish division
472
+ // from a regex literal opener.
403
473
  func matchParen(text string, pos int) (int, bool) {
404
- if pos >= len(text) || text[pos] != '(' {
405
- return 0, false
406
- }
407
- depth := 1
408
- lastSignificant := byte('(')
409
- for i := pos + 1; i < len(text); i++ {
410
- ch := text[i]
411
- switch ch {
412
- case ' ', '\t', '\n', '\r', '\f':
413
- continue
414
- case '(':
415
- depth++
416
- lastSignificant = ch
417
- case ')':
418
- depth--
419
- if depth == 0 {
420
- return i, true
421
- }
422
- lastSignificant = ch
423
- case '"', '\'':
424
- end, ok := skipQuoted(text, i, ch)
425
- if !ok {
426
- return 0, false
427
- }
428
- i = end
429
- lastSignificant = 'x'
430
- case '`':
431
- end, ok := skipTemplate(text, i)
432
- if !ok {
433
- return 0, false
434
- }
435
- i = end
436
- lastSignificant = 'x'
437
- case '/':
438
- if i+1 < len(text) {
439
- switch text[i+1] {
440
- case '/':
441
- i = skipLineComment(text, i+2)
442
- continue
443
- case '*':
444
- end, ok := skipBlockComment(text, i+2)
445
- if !ok {
446
- return 0, false
447
- }
448
- i = end
449
- continue
450
- }
451
- }
452
- if canStartRegexLiteral(lastSignificant) {
453
- end, ok := skipRegexLiteral(text, i)
454
- if !ok {
455
- return 0, false
456
- }
457
- i = end
458
- lastSignificant = 'x'
459
- continue
460
- }
461
- lastSignificant = ch
462
- default:
463
- lastSignificant = ch
464
- }
465
- }
466
- return 0, false
474
+ if pos >= len(text) || text[pos] != '(' {
475
+ return 0, false
476
+ }
477
+ depth := 1
478
+ lastSignificant := byte('(')
479
+ for i := pos + 1; i < len(text); i++ {
480
+ ch := text[i]
481
+ switch ch {
482
+ case ' ', '\t', '\n', '\r', '\f':
483
+ continue
484
+ case '(':
485
+ depth++
486
+ lastSignificant = ch
487
+ case ')':
488
+ depth--
489
+ if depth == 0 {
490
+ return i, true
491
+ }
492
+ lastSignificant = ch
493
+ case '"', '\'':
494
+ end, ok := skipQuoted(text, i, ch)
495
+ if !ok {
496
+ return 0, false
497
+ }
498
+ i = end
499
+ lastSignificant = 'x'
500
+ case '`':
501
+ end, ok := skipTemplate(text, i)
502
+ if !ok {
503
+ return 0, false
504
+ }
505
+ i = end
506
+ lastSignificant = 'x'
507
+ case '/':
508
+ if i+1 < len(text) {
509
+ switch text[i+1] {
510
+ case '/':
511
+ i = skipLineComment(text, i+2)
512
+ continue
513
+ case '*':
514
+ end, ok := skipBlockComment(text, i+2)
515
+ if !ok {
516
+ return 0, false
517
+ }
518
+ i = end
519
+ continue
520
+ }
521
+ }
522
+ if canStartRegexLiteral(lastSignificant) {
523
+ end, ok := skipRegexLiteral(text, i)
524
+ if !ok {
525
+ return 0, false
526
+ }
527
+ i = end
528
+ lastSignificant = 'x'
529
+ continue
530
+ }
531
+ lastSignificant = ch
532
+ default:
533
+ lastSignificant = ch
534
+ }
535
+ }
536
+ return 0, false
467
537
  }
468
538
 
539
+ // skipQuoted advances past a single- or double-quoted string literal starting
540
+ // at pos. Returns the index of the closing quote and true, or (0, false) on
541
+ // unterminated literals. Newlines inside a non-template string are illegal in
542
+ // JS and also terminate the scan as a failure.
469
543
  func skipQuoted(text string, pos int, quote byte) (int, bool) {
470
- for i := pos + 1; i < len(text); i++ {
471
- switch text[i] {
472
- case '\\':
473
- i++
474
- case quote:
475
- return i, true
476
- case '\n', '\r':
477
- return 0, false
478
- }
479
- }
480
- return 0, false
544
+ for i := pos + 1; i < len(text); i++ {
545
+ switch text[i] {
546
+ case '\\':
547
+ i++
548
+ case quote:
549
+ return i, true
550
+ case '\n', '\r':
551
+ return 0, false
552
+ }
553
+ }
554
+ return 0, false
481
555
  }
482
556
 
557
+ // skipTemplate advances past a backtick template literal starting at pos.
558
+ // Nested template expressions (${...}) are not recursed into — the rewriter
559
+ // only needs to balance the outer backtick so it does not misinterpret a
560
+ // backtick inside the template as the end of a surrounding construct.
483
561
  func skipTemplate(text string, pos int) (int, bool) {
484
- for i := pos + 1; i < len(text); i++ {
485
- switch text[i] {
486
- case '\\':
487
- i++
488
- case '`':
489
- return i, true
490
- }
491
- }
492
- return 0, false
562
+ for i := pos + 1; i < len(text); i++ {
563
+ switch text[i] {
564
+ case '\\':
565
+ i++
566
+ case '`':
567
+ return i, true
568
+ }
569
+ }
570
+ return 0, false
493
571
  }
494
572
 
573
+ // skipLineComment advances past a "//" line comment starting at pos (pos
574
+ // should be the character after the second "/"). Returns the index of the
575
+ // line terminator, or the last valid index when no newline is found.
495
576
  func skipLineComment(text string, pos int) int {
496
- for i := pos; i < len(text); i++ {
497
- if text[i] == '\n' || text[i] == '\r' {
498
- return i
499
- }
500
- }
501
- return len(text) - 1
577
+ for i := pos; i < len(text); i++ {
578
+ if text[i] == '\n' || text[i] == '\r' {
579
+ return i
580
+ }
581
+ }
582
+ return len(text) - 1
502
583
  }
503
584
 
585
+ // skipBlockComment advances past a "/* … */" block comment starting at pos
586
+ // (pos should be the character after the opening "/*"). Returns the index of
587
+ // the closing "/" and true, or (0, false) when the comment is unterminated.
504
588
  func skipBlockComment(text string, pos int) (int, bool) {
505
- for i := pos; i+1 < len(text); i++ {
506
- if text[i] == '*' && text[i+1] == '/' {
507
- return i + 1, true
508
- }
509
- }
510
- return 0, false
589
+ for i := pos; i+1 < len(text); i++ {
590
+ if text[i] == '*' && text[i+1] == '/' {
591
+ return i + 1, true
592
+ }
593
+ }
594
+ return 0, false
511
595
  }
512
596
 
597
+ // canStartRegexLiteral reports whether the byte previous (the last
598
+ // non-whitespace character seen before a "/") allows a regex literal to
599
+ // start. This is the minimal set of characters that unambiguously precede a
600
+ // regex in emitted CommonJS output; false positives are safe (they cause an
601
+ // extra skipRegexLiteral attempt that quickly fails), false negatives would
602
+ // misparse a "/" as the start of a comment or regex.
513
603
  func canStartRegexLiteral(previous byte) bool {
514
- return strings.ContainsRune("([{=,:;!&|?+-*~^<>%", rune(previous))
604
+ return strings.ContainsRune("([{=,:;!&|?+-*~^<>%", rune(previous))
515
605
  }
516
606
 
607
+ // skipRegexLiteral advances past a "/" regex literal starting at pos.
608
+ // Character classes ("[…]") are tracked so a "/" inside them is not treated
609
+ // as the closing delimiter. Returns the index of the last flag character (or
610
+ // the closing "/" when no flags follow) and true, or (0, false) for unterminated
611
+ // or newline-terminated literals.
517
612
  func skipRegexLiteral(text string, pos int) (int, bool) {
518
- inClass := false
519
- for i := pos + 1; i < len(text); i++ {
520
- switch text[i] {
521
- case '\\':
522
- i++
523
- case '[':
524
- inClass = true
525
- case ']':
526
- inClass = false
527
- case '/':
528
- if inClass {
529
- continue
530
- }
531
- for i+1 < len(text) && isRegexFlag(text[i+1]) {
532
- i++
533
- }
534
- return i, true
535
- case '\n', '\r':
536
- return 0, false
537
- }
538
- }
539
- return 0, false
613
+ inClass := false
614
+ for i := pos + 1; i < len(text); i++ {
615
+ switch text[i] {
616
+ case '\\':
617
+ i++
618
+ case '[':
619
+ inClass = true
620
+ case ']':
621
+ inClass = false
622
+ case '/':
623
+ if inClass {
624
+ continue
625
+ }
626
+ for i+1 < len(text) && isRegexFlag(text[i+1]) {
627
+ i++
628
+ }
629
+ return i, true
630
+ case '\n', '\r':
631
+ return 0, false
632
+ }
633
+ }
634
+ return 0, false
540
635
  }
541
636
 
637
+ // isRegexFlag reports whether ch is a valid regex flag character (letter,
638
+ // digit, "_" or "$"). ES2025 allows any IdentifierPart after the closing "/".
542
639
  func isRegexFlag(ch byte) bool {
543
- return ch == '_' ||
544
- ch == '$' ||
545
- unicode.IsLetter(rune(ch)) ||
546
- unicode.IsDigit(rune(ch))
640
+ return ch == '_' ||
641
+ ch == '$' ||
642
+ unicode.IsLetter(rune(ch)) ||
643
+ unicode.IsDigit(rune(ch))
547
644
  }