ttsc 0.7.3 → 0.8.0-dev.20260506

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 (33) hide show
  1. package/README.md +3 -4
  2. package/cmd/ttsc/main.go +1 -1
  3. package/driver/program.go +56 -3
  4. package/driver/rewrite.go +22 -2
  5. package/lib/compiler/internal/runBuild.js +46 -49
  6. package/lib/compiler/internal/runBuild.js.map +1 -1
  7. package/lib/compiler/internal/transformProjectInMemory.js +43 -4
  8. package/lib/compiler/internal/transformProjectInMemory.js.map +1 -1
  9. package/lib/launcher/internal/runTtsc.js +20 -2
  10. package/lib/launcher/internal/runTtsc.js.map +1 -1
  11. package/lib/plugin/internal/buildSourcePlugin.js +6 -0
  12. package/lib/plugin/internal/buildSourcePlugin.js.map +1 -1
  13. package/lib/plugin/internal/loadProjectPlugins.js +12 -6
  14. package/lib/plugin/internal/loadProjectPlugins.js.map +1 -1
  15. package/lib/structures/ITtscPlugin.d.ts +4 -3
  16. package/lib/structures/ITtscPlugin.js +4 -0
  17. package/lib/structures/ITtscPlugin.js.map +1 -1
  18. package/lib/structures/TtscPluginStage.d.ts +3 -11
  19. package/lib/structures/internal/ITtscLoadedNativePlugin.d.ts +2 -0
  20. package/package.json +11 -10
  21. package/shim/ast/lint.go +1 -0
  22. package/shim/ast/shim.go +5 -4
  23. package/shim/core/shim.go +3 -0
  24. package/src/compiler/internal/runBuild.ts +53 -85
  25. package/src/compiler/internal/transformProjectInMemory.ts +50 -6
  26. package/src/launcher/internal/runTtsc.ts +18 -2
  27. package/src/plugin/internal/buildSourcePlugin.ts +23 -0
  28. package/src/plugin/internal/loadProjectPlugins.ts +17 -6
  29. package/src/structures/ITtscPlugin.ts +12 -3
  30. package/src/structures/TtscPluginStage.ts +3 -11
  31. package/src/structures/internal/ITtscLoadedNativePlugin.ts +2 -0
  32. package/test/tsgo.test.ts +9 -6
  33. package/utility/host.go +735 -0
@@ -0,0 +1,735 @@
1
+ package utility
2
+
3
+ import (
4
+ "encoding/json"
5
+ "flag"
6
+ "fmt"
7
+ "os"
8
+ "path/filepath"
9
+ "sort"
10
+ "strings"
11
+
12
+ shimast "github.com/microsoft/typescript-go/shim/ast"
13
+ shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
14
+ shimcore "github.com/microsoft/typescript-go/shim/core"
15
+
16
+ "github.com/samchon/ttsc/packages/ttsc/driver"
17
+ )
18
+
19
+ type pluginEntry struct {
20
+ Config map[string]any `json:"config"`
21
+ Name string `json:"name"`
22
+ Stage string `json:"stage"`
23
+ }
24
+
25
+ type hostOptions struct {
26
+ cwd string
27
+ emit bool
28
+ noEmit bool
29
+ outDir string
30
+ pluginsJSON string
31
+ quiet bool
32
+ tsconfig string
33
+ verbose bool
34
+ }
35
+
36
+ type transformState struct {
37
+ paths *pathsRewriter
38
+ strip *stripRewriter
39
+ }
40
+
41
+ type transformResult struct {
42
+ Diagnostics []any `json:"diagnostics,omitempty"`
43
+ TypeScript map[string]string `json:"typescript"`
44
+ }
45
+
46
+ // RunBuild hosts first-party utility transform plugins inside one compiler emit.
47
+ func RunBuild(args []string) int {
48
+ opts, ok := parseHostOptions("build", args)
49
+ if !ok {
50
+ return 2
51
+ }
52
+ prog, entries, _, sourcePreamble, ok := loadUtilityProgram(opts)
53
+ if !ok {
54
+ return 2
55
+ }
56
+ defer prog.Close()
57
+ if opts.noEmit {
58
+ return 0
59
+ }
60
+ if opts.verbose {
61
+ opts.quiet = false
62
+ }
63
+ if !opts.quiet {
64
+ fmt.Fprintf(os.Stdout, "// ttsc utility: plugins=%d emit=%v\n", len(entries), !opts.noEmit)
65
+ }
66
+ var (
67
+ res *shimcompiler.EmitResult
68
+ eDiags []driver.Diagnostic
69
+ err error
70
+ )
71
+ res, eDiags, err = prog.EmitAllRaw(makeSourcePreambleWriteFile(prog, sourcePreamble))
72
+ if err != nil {
73
+ fmt.Fprintf(os.Stderr, "ttsc utility: emit failed: %v\n", err)
74
+ return 3
75
+ }
76
+ for _, d := range eDiags {
77
+ fmt.Fprintln(os.Stderr, " -", d.String())
78
+ }
79
+ if res != nil && !opts.quiet {
80
+ fmt.Fprintf(os.Stdout, "// ttsc utility: emitted=%d files\n", len(res.EmittedFiles))
81
+ }
82
+ return 0
83
+ }
84
+
85
+ // RunTransform returns the project TypeScript text after source mutations.
86
+ func RunTransform(args []string) int {
87
+ opts, ok := parseHostOptions("transform", args)
88
+ if !ok {
89
+ return 2
90
+ }
91
+ prog, _, _, _, ok := loadUtilityProgram(opts)
92
+ if !ok {
93
+ return 2
94
+ }
95
+ defer prog.Close()
96
+ out := transformResult{TypeScript: map[string]string{}}
97
+ for _, file := range prog.SourceFiles() {
98
+ out.TypeScript[apiOutputKey(opts.cwd, file.FileName())] = file.Text()
99
+ }
100
+ data, err := json.Marshal(out)
101
+ if err != nil {
102
+ fmt.Fprintf(os.Stderr, "ttsc utility: transform marshal failed: %v\n", err)
103
+ return 3
104
+ }
105
+ fmt.Fprintln(os.Stdout, string(data))
106
+ return 0
107
+ }
108
+
109
+ func parseHostOptions(command string, args []string) (hostOptions, bool) {
110
+ fs := flag.NewFlagSet(command, flag.ContinueOnError)
111
+ fs.SetOutput(os.Stderr)
112
+ cwd := fs.String("cwd", "", "project directory")
113
+ emit := fs.Bool("emit", false, "force emit")
114
+ noEmit := fs.Bool("noEmit", false, "force no emit")
115
+ outDir := fs.String("outDir", "", "emit directory override")
116
+ pluginsJSON := fs.String("plugins-json", "", "ttsc plugin manifest JSON")
117
+ quiet := fs.Bool("quiet", true, "suppress summary")
118
+ tsconfig := fs.String("tsconfig", "tsconfig.json", "project tsconfig")
119
+ verbose := fs.Bool("verbose", false, "print summary")
120
+ if err := fs.Parse(args); err != nil {
121
+ return hostOptions{}, false
122
+ }
123
+ resolvedCwd := *cwd
124
+ if resolvedCwd == "" {
125
+ var err error
126
+ resolvedCwd, err = os.Getwd()
127
+ if err != nil {
128
+ fmt.Fprintf(os.Stderr, "ttsc utility: cwd: %v\n", err)
129
+ return hostOptions{}, false
130
+ }
131
+ }
132
+ if !filepath.IsAbs(resolvedCwd) {
133
+ abs, err := filepath.Abs(resolvedCwd)
134
+ if err != nil {
135
+ fmt.Fprintf(os.Stderr, "ttsc utility: cwd: %v\n", err)
136
+ return hostOptions{}, false
137
+ }
138
+ resolvedCwd = abs
139
+ }
140
+ return hostOptions{
141
+ cwd: filepath.Clean(resolvedCwd),
142
+ emit: *emit,
143
+ noEmit: *noEmit,
144
+ outDir: *outDir,
145
+ pluginsJSON: *pluginsJSON,
146
+ quiet: *quiet,
147
+ tsconfig: *tsconfig,
148
+ verbose: *verbose,
149
+ }, true
150
+ }
151
+
152
+ func loadUtilityProgram(opts hostOptions) (*driver.Program, []pluginEntry, transformState, string, bool) {
153
+ entries, err := parsePluginEntries(opts.pluginsJSON)
154
+ if err != nil {
155
+ fmt.Fprintln(os.Stderr, err)
156
+ return nil, nil, transformState{}, "", false
157
+ }
158
+ sourcePreamble, err := prepareSourcePreamble(entries)
159
+ if err != nil {
160
+ fmt.Fprintln(os.Stderr, err)
161
+ return nil, nil, transformState{}, "", false
162
+ }
163
+ prog, diags, err := driver.LoadProgram(opts.cwd, opts.tsconfig, driver.LoadProgramOptions{
164
+ ForceEmit: opts.emit,
165
+ ForceNoEmit: opts.noEmit,
166
+ OutDir: opts.outDir,
167
+ SourcePreamble: sourcePreamble,
168
+ })
169
+ if err != nil {
170
+ fmt.Fprintf(os.Stderr, "ttsc utility: %v\n", err)
171
+ return nil, nil, transformState{}, "", false
172
+ }
173
+ if len(diags) > 0 {
174
+ driver.WritePrettyDiagnostics(os.Stderr, diags, opts.cwd)
175
+ return nil, nil, transformState{}, "", false
176
+ }
177
+ if diags := prog.Diagnostics(); len(diags) > 0 {
178
+ driver.WritePrettyDiagnostics(os.Stderr, diags, opts.cwd)
179
+ return nil, nil, transformState{}, "", false
180
+ }
181
+ state, err := prepareTransforms(prog, entries)
182
+ if err != nil {
183
+ fmt.Fprintln(os.Stderr, err)
184
+ return nil, nil, transformState{}, "", false
185
+ }
186
+ if err := applySourceTransforms(prog, state); err != nil {
187
+ fmt.Fprintln(os.Stderr, err)
188
+ return nil, nil, transformState{}, "", false
189
+ }
190
+ return prog, entries, state, sourcePreamble, true
191
+ }
192
+
193
+ func parsePluginEntries(input string) ([]pluginEntry, error) {
194
+ if strings.TrimSpace(input) == "" {
195
+ return nil, nil
196
+ }
197
+ var entries []pluginEntry
198
+ if err := json.Unmarshal([]byte(input), &entries); err != nil {
199
+ return nil, fmt.Errorf("ttsc utility: invalid --plugins-json: %w", err)
200
+ }
201
+ return entries, nil
202
+ }
203
+
204
+ func prepareTransforms(prog *driver.Program, entries []pluginEntry) (transformState, error) {
205
+ state := transformState{}
206
+ for _, entry := range entries {
207
+ switch entry.Name {
208
+ case "@ttsc/paths":
209
+ state.paths = newPathsRewriter(prog)
210
+ case "@ttsc/strip":
211
+ strip, err := parseStrip(entry.Config)
212
+ if err != nil {
213
+ return state, err
214
+ }
215
+ state.strip = strip
216
+ }
217
+ }
218
+ return state, nil
219
+ }
220
+
221
+ func prepareSourcePreamble(entries []pluginEntry) (string, error) {
222
+ var preamble strings.Builder
223
+ for _, entry := range entries {
224
+ if entry.Name != "@ttsc/banner" {
225
+ continue
226
+ }
227
+ banner, err := parseBanner(entry.Config)
228
+ if err != nil {
229
+ return "", err
230
+ }
231
+ preamble.WriteString(banner)
232
+ }
233
+ return preamble.String(), nil
234
+ }
235
+
236
+ func makeSourcePreambleWriteFile(prog *driver.Program, sourcePreamble string) shimcompiler.WriteFile {
237
+ if sourcePreamble == "" || shouldRemoveComments(prog) {
238
+ return nil
239
+ }
240
+ return func(fileName, text string, _ *shimcompiler.WriteFileData) error {
241
+ if shouldEnsureSourcePreamble(fileName, text, sourcePreamble) {
242
+ text = sourcePreamble + text
243
+ }
244
+ return driver.DefaultWriteFile(fileName, text)
245
+ }
246
+ }
247
+
248
+ func shouldRemoveComments(prog *driver.Program) bool {
249
+ if prog == nil || prog.ParsedConfig == nil || prog.ParsedConfig.ParsedConfig == nil || prog.ParsedConfig.ParsedConfig.CompilerOptions == nil {
250
+ return false
251
+ }
252
+ return prog.ParsedConfig.ParsedConfig.CompilerOptions.RemoveComments.IsTrue()
253
+ }
254
+
255
+ func shouldEnsureSourcePreamble(fileName, text, sourcePreamble string) bool {
256
+ return isSourcePreambleOutputTarget(fileName) && !strings.Contains(text, sourcePreamble)
257
+ }
258
+
259
+ func isSourcePreambleOutputTarget(fileName string) bool {
260
+ lower := strings.ToLower(filepath.ToSlash(fileName))
261
+ for _, suffix := range []string{".d.ts", ".d.mts", ".d.cts", ".js", ".jsx", ".mjs", ".cjs"} {
262
+ if strings.HasSuffix(lower, suffix) {
263
+ return true
264
+ }
265
+ }
266
+ return false
267
+ }
268
+
269
+ func applySourceTransforms(prog *driver.Program, state transformState) error {
270
+ for _, file := range prog.SourceFiles() {
271
+ if state.paths != nil {
272
+ state.paths.apply(file)
273
+ }
274
+ if state.strip != nil {
275
+ state.strip.apply(file)
276
+ }
277
+ }
278
+ return nil
279
+ }
280
+
281
+ func apiOutputKey(cwd, fileName string) string {
282
+ rel, err := filepath.Rel(cwd, fileName)
283
+ if err != nil || strings.HasPrefix(rel, "..") {
284
+ return filepath.ToSlash(fileName)
285
+ }
286
+ return filepath.ToSlash(rel)
287
+ }
288
+
289
+ func parseBanner(config map[string]any) (string, error) {
290
+ raw, ok := config["banner"]
291
+ if !ok {
292
+ return "", fmt.Errorf("@ttsc/banner: \"banner\" must be a non-empty string")
293
+ }
294
+ text, ok := raw.(string)
295
+ if !ok || strings.TrimSpace(text) == "" {
296
+ return "", fmt.Errorf("@ttsc/banner: \"banner\" must be a non-empty string")
297
+ }
298
+ lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
299
+ for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
300
+ lines = lines[:len(lines)-1]
301
+ }
302
+ var b strings.Builder
303
+ sep := strings.Repeat("-", 64)
304
+ b.WriteString("/**\n")
305
+ b.WriteString(" * ")
306
+ b.WriteString(sep)
307
+ b.WriteByte('\n')
308
+ for _, line := range lines {
309
+ b.WriteString(" * ")
310
+ b.WriteString(sanitizeJSDocLine(line))
311
+ b.WriteByte('\n')
312
+ }
313
+ b.WriteString(" *\n")
314
+ b.WriteString(" * @packageDocumentation\n ")
315
+ b.WriteString("*/\n")
316
+ return b.String(), nil
317
+ }
318
+
319
+ func sanitizeJSDocLine(line string) string {
320
+ return strings.ReplaceAll(line, "*/", "* /")
321
+ }
322
+
323
+ type pathsRewriter struct {
324
+ basePath string
325
+ outDir string
326
+ patterns []pathsPattern
327
+ rootDir string
328
+ sourceFiles map[string]string
329
+ }
330
+
331
+ type pathsPattern struct {
332
+ pattern string
333
+ targets []string
334
+ }
335
+
336
+ func newPathsRewriter(prog *driver.Program) *pathsRewriter {
337
+ out := &pathsRewriter{sourceFiles: map[string]string{}}
338
+ if prog == nil || prog.ParsedConfig == nil || prog.ParsedConfig.ParsedConfig == nil || prog.ParsedConfig.ParsedConfig.CompilerOptions == nil {
339
+ return out
340
+ }
341
+ options := prog.ParsedConfig.ParsedConfig.CompilerOptions
342
+ out.basePath = filepath.Clean(options.GetPathsBasePath(prog.Host.GetCurrentDirectory()))
343
+ out.outDir = optionalPath(options.OutDir, prog.Host.GetCurrentDirectory())
344
+ out.rootDir = optionalPath(options.RootDir, prog.Host.GetCurrentDirectory())
345
+ files := prog.SourceFiles()
346
+ if out.rootDir == "" {
347
+ out.rootDir = commonSourceDir(files)
348
+ }
349
+ for _, file := range files {
350
+ name := normalizePath(file.FileName())
351
+ out.sourceFiles[name] = name
352
+ out.sourceFiles[stripKnownSourceExtension(name)] = name
353
+ }
354
+ if options.Paths != nil {
355
+ for pattern, targets := range options.Paths.Entries() {
356
+ out.patterns = append(out.patterns, pathsPattern{
357
+ pattern: pattern,
358
+ targets: append([]string(nil), targets...),
359
+ })
360
+ }
361
+ }
362
+ sort.SliceStable(out.patterns, func(i, j int) bool {
363
+ return patternRank(out.patterns[i].pattern) > patternRank(out.patterns[j].pattern)
364
+ })
365
+ return out
366
+ }
367
+
368
+ func (r *pathsRewriter) apply(file *shimast.SourceFile) {
369
+ if r == nil || file == nil || len(r.patterns) == 0 {
370
+ return
371
+ }
372
+ visitModuleSpecifiers(file.AsNode(), func(lit *shimast.Node) {
373
+ if lit == nil || lit.Kind != shimast.KindStringLiteral {
374
+ return
375
+ }
376
+ spec := lit.Text()
377
+ rewritten, ok := r.rewrite(file.FileName(), spec)
378
+ if ok && rewritten != spec {
379
+ lit.AsStringLiteral().Text = rewritten
380
+ lit.Flags |= shimast.NodeFlagsSynthesized
381
+ lit.Loc = shimcore.UndefinedTextRange()
382
+ }
383
+ })
384
+ }
385
+
386
+ func visitModuleSpecifiers(node *shimast.Node, visit func(*shimast.Node)) {
387
+ if node == nil {
388
+ return
389
+ }
390
+ switch node.Kind {
391
+ case shimast.KindImportDeclaration:
392
+ visit(node.AsImportDeclaration().ModuleSpecifier)
393
+ case shimast.KindExportDeclaration:
394
+ visit(node.AsExportDeclaration().ModuleSpecifier)
395
+ case shimast.KindImportEqualsDeclaration:
396
+ ref := node.AsImportEqualsDeclaration().ModuleReference
397
+ if ref != nil && ref.Kind == shimast.KindExternalModuleReference {
398
+ visit(ref.AsExternalModuleReference().Expression)
399
+ }
400
+ case shimast.KindImportType:
401
+ arg := node.AsImportTypeNode().Argument
402
+ if arg != nil && arg.Kind == shimast.KindLiteralType {
403
+ visit(arg.AsLiteralTypeNode().Literal)
404
+ }
405
+ case shimast.KindCallExpression:
406
+ call := node.AsCallExpression()
407
+ if call.Expression != nil && call.Expression.Kind == shimast.KindImportKeyword && call.Arguments != nil && len(call.Arguments.Nodes) > 0 {
408
+ visit(call.Arguments.Nodes[0])
409
+ }
410
+ }
411
+ node.ForEachChild(func(child *shimast.Node) bool {
412
+ visitModuleSpecifiers(child, visit)
413
+ return false
414
+ })
415
+ }
416
+
417
+ func (r *pathsRewriter) rewrite(fromSource string, specifier string) (string, bool) {
418
+ if specifier == "" || strings.HasPrefix(specifier, ".") || strings.HasPrefix(specifier, "/") {
419
+ return specifier, false
420
+ }
421
+ targetSource, ok := r.resolveSource(specifier)
422
+ if !ok {
423
+ return specifier, false
424
+ }
425
+ fromOut := r.outputPathForSource(fromSource)
426
+ targetOut := r.outputPathForSource(targetSource)
427
+ if fromOut == "" || targetOut == "" {
428
+ return specifier, false
429
+ }
430
+ rel, err := filepath.Rel(filepath.Dir(fromOut), targetOut)
431
+ if err != nil {
432
+ return specifier, false
433
+ }
434
+ rel = filepath.ToSlash(rel)
435
+ if !strings.HasPrefix(rel, ".") {
436
+ rel = "./" + rel
437
+ }
438
+ return rel, true
439
+ }
440
+
441
+ func (r *pathsRewriter) resolveSource(specifier string) (string, bool) {
442
+ for _, pattern := range r.patterns {
443
+ star, ok := matchPattern(pattern.pattern, specifier)
444
+ if !ok {
445
+ continue
446
+ }
447
+ for _, target := range pattern.targets {
448
+ candidate := strings.Replace(target, "*", star, 1)
449
+ resolved := normalizePath(filepath.Join(r.basePath, candidate))
450
+ if source, ok := r.lookupSource(resolved); ok {
451
+ return source, true
452
+ }
453
+ }
454
+ }
455
+ return "", false
456
+ }
457
+
458
+ func (r *pathsRewriter) lookupSource(candidate string) (string, bool) {
459
+ if source, ok := r.sourceFiles[normalizePath(candidate)]; ok {
460
+ return source, true
461
+ }
462
+ stem := stripKnownSourceExtension(normalizePath(candidate))
463
+ if source, ok := r.sourceFiles[stem]; ok {
464
+ return source, true
465
+ }
466
+ for _, ext := range []string{".ts", ".tsx", ".mts", ".cts"} {
467
+ if source, ok := r.sourceFiles[stem+ext]; ok {
468
+ return source, true
469
+ }
470
+ }
471
+ return "", false
472
+ }
473
+
474
+ func (r *pathsRewriter) outputPathForSource(source string) string {
475
+ if r.outDir == "" || r.rootDir == "" {
476
+ return ""
477
+ }
478
+ rel, err := filepath.Rel(r.rootDir, source)
479
+ if err != nil || strings.HasPrefix(rel, "..") {
480
+ return ""
481
+ }
482
+ return normalizePath(filepath.Join(r.outDir, replaceSourceExtension(rel, ".js")))
483
+ }
484
+
485
+ func matchPattern(pattern string, specifier string) (string, bool) {
486
+ if !strings.Contains(pattern, "*") {
487
+ return "", pattern == specifier
488
+ }
489
+ parts := strings.SplitN(pattern, "*", 2)
490
+ if !strings.HasPrefix(specifier, parts[0]) || !strings.HasSuffix(specifier, parts[1]) {
491
+ return "", false
492
+ }
493
+ return specifier[len(parts[0]) : len(specifier)-len(parts[1])], true
494
+ }
495
+
496
+ func patternRank(pattern string) int {
497
+ return len(strings.ReplaceAll(pattern, "*", ""))
498
+ }
499
+
500
+ func optionalPath(value string, cwd string) string {
501
+ if value == "" {
502
+ return ""
503
+ }
504
+ if filepath.IsAbs(value) {
505
+ return normalizePath(value)
506
+ }
507
+ return normalizePath(filepath.Join(cwd, value))
508
+ }
509
+
510
+ func commonSourceDir(files []*shimast.SourceFile) string {
511
+ if len(files) == 0 {
512
+ return ""
513
+ }
514
+ common := normalizePath(filepath.Dir(files[0].FileName()))
515
+ for _, file := range files[1:] {
516
+ dir := normalizePath(filepath.Dir(file.FileName()))
517
+ for common != "" && !strings.HasPrefix(dir+"/", common+"/") {
518
+ next := filepath.Dir(common)
519
+ if next == common {
520
+ return common
521
+ }
522
+ common = normalizePath(next)
523
+ }
524
+ }
525
+ return common
526
+ }
527
+
528
+ func normalizePath(value string) string {
529
+ if value == "" {
530
+ return ""
531
+ }
532
+ return filepath.ToSlash(filepath.Clean(value))
533
+ }
534
+
535
+ func stripKnownSourceExtension(value string) string {
536
+ lower := strings.ToLower(value)
537
+ for _, ext := range []string{".d.ts", ".d.mts", ".d.cts", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"} {
538
+ if strings.HasSuffix(lower, ext) {
539
+ return value[:len(value)-len(ext)]
540
+ }
541
+ }
542
+ return strings.TrimSuffix(value, filepath.Ext(value))
543
+ }
544
+
545
+ func replaceSourceExtension(value string, ext string) string {
546
+ return stripKnownSourceExtension(filepath.ToSlash(value)) + ext
547
+ }
548
+
549
+ type stripRewriter struct {
550
+ calls []callPattern
551
+ stripDebugger bool
552
+ }
553
+
554
+ type callPattern struct {
555
+ parts []string
556
+ wildcard bool
557
+ }
558
+
559
+ func parseStrip(config map[string]any) (*stripRewriter, error) {
560
+ calls, err := stringArrayConfig(config, "calls")
561
+ if err != nil {
562
+ return nil, fmt.Errorf("@ttsc/strip: %w", err)
563
+ }
564
+ statements, err := stringArrayConfig(config, "statements")
565
+ if err != nil {
566
+ return nil, fmt.Errorf("@ttsc/strip: %w", err)
567
+ }
568
+ out := &stripRewriter{}
569
+ for _, call := range calls {
570
+ pattern, err := parseCallPattern(call)
571
+ if err != nil {
572
+ return nil, fmt.Errorf("@ttsc/strip: %w", err)
573
+ }
574
+ out.calls = append(out.calls, pattern)
575
+ }
576
+ for _, statement := range statements {
577
+ switch statement {
578
+ case "debugger":
579
+ out.stripDebugger = true
580
+ default:
581
+ return nil, fmt.Errorf("@ttsc/strip: unsupported statement pattern %q", statement)
582
+ }
583
+ }
584
+ return out, nil
585
+ }
586
+
587
+ func (s *stripRewriter) apply(file *shimast.SourceFile) {
588
+ if s == nil || file == nil || (len(s.calls) == 0 && !s.stripDebugger) {
589
+ return
590
+ }
591
+ filterStatements(file.Statements, s)
592
+ }
593
+
594
+ func filterStatements(list *shimast.NodeList, strip *stripRewriter) {
595
+ if list == nil || len(list.Nodes) == 0 {
596
+ return
597
+ }
598
+ out := list.Nodes[:0]
599
+ for _, stmt := range list.Nodes {
600
+ if shouldStripStatement(stmt, strip) {
601
+ continue
602
+ }
603
+ filterChildStatements(stmt, strip)
604
+ out = append(out, stmt)
605
+ }
606
+ list.Nodes = out
607
+ }
608
+
609
+ func filterChildStatements(node *shimast.Node, strip *stripRewriter) {
610
+ if node == nil {
611
+ return
612
+ }
613
+ if node.CanHaveStatements() {
614
+ filterStatements(node.StatementList(), strip)
615
+ }
616
+ node.ForEachChild(func(child *shimast.Node) bool {
617
+ filterChildStatements(child, strip)
618
+ return false
619
+ })
620
+ }
621
+
622
+ func shouldStripStatement(node *shimast.Node, strip *stripRewriter) bool {
623
+ if node == nil {
624
+ return false
625
+ }
626
+ switch node.Kind {
627
+ case shimast.KindDebuggerStatement:
628
+ return strip.stripDebugger
629
+ case shimast.KindExpressionStatement:
630
+ expr := node.AsExpressionStatement().Expression
631
+ name, ok := callExpressionName(expr)
632
+ return ok && strip.matchesCall(name)
633
+ default:
634
+ return false
635
+ }
636
+ }
637
+
638
+ func (s *stripRewriter) matchesCall(name string) bool {
639
+ for _, pattern := range s.calls {
640
+ if pattern.matches(name) {
641
+ return true
642
+ }
643
+ }
644
+ return false
645
+ }
646
+
647
+ func parseCallPattern(text string) (callPattern, error) {
648
+ parts := strings.Split(text, ".")
649
+ if len(parts) == 0 {
650
+ return callPattern{}, fmt.Errorf("empty call pattern")
651
+ }
652
+ for i, part := range parts {
653
+ if part == "" {
654
+ return callPattern{}, fmt.Errorf("invalid call pattern %q", text)
655
+ }
656
+ if part == "*" && i != len(parts)-1 {
657
+ return callPattern{}, fmt.Errorf("wildcard is only supported at the end of call pattern %q", text)
658
+ }
659
+ }
660
+ wildcard := parts[len(parts)-1] == "*"
661
+ if wildcard {
662
+ parts = parts[:len(parts)-1]
663
+ }
664
+ return callPattern{parts: parts, wildcard: wildcard}, nil
665
+ }
666
+
667
+ func (p callPattern) matches(name string) bool {
668
+ parts := strings.Split(name, ".")
669
+ if p.wildcard {
670
+ if len(parts) <= len(p.parts) {
671
+ return false
672
+ }
673
+ return equalStringSlices(parts[:len(p.parts)], p.parts)
674
+ }
675
+ return equalStringSlices(parts, p.parts)
676
+ }
677
+
678
+ func callExpressionName(expr *shimast.Node) (string, bool) {
679
+ if expr == nil || expr.Kind != shimast.KindCallExpression {
680
+ return "", false
681
+ }
682
+ call := expr.AsCallExpression()
683
+ return dottedName(call.Expression)
684
+ }
685
+
686
+ func dottedName(expr *shimast.Node) (string, bool) {
687
+ if expr == nil {
688
+ return "", false
689
+ }
690
+ switch expr.Kind {
691
+ case shimast.KindIdentifier:
692
+ return expr.Text(), true
693
+ case shimast.KindPropertyAccessExpression:
694
+ prop := expr.AsPropertyAccessExpression()
695
+ left, ok := dottedName(prop.Expression)
696
+ if !ok || prop.Name() == nil {
697
+ return "", false
698
+ }
699
+ return left + "." + prop.Name().Text(), true
700
+ default:
701
+ return "", false
702
+ }
703
+ }
704
+
705
+ func stringArrayConfig(config map[string]any, key string) ([]string, error) {
706
+ raw, ok := config[key]
707
+ if !ok || raw == nil {
708
+ return nil, nil
709
+ }
710
+ values, ok := raw.([]any)
711
+ if !ok {
712
+ return nil, fmt.Errorf("%q must be an array of strings", key)
713
+ }
714
+ out := make([]string, 0, len(values))
715
+ for i, value := range values {
716
+ text, ok := value.(string)
717
+ if !ok || strings.TrimSpace(text) == "" {
718
+ return nil, fmt.Errorf("%q[%d] must be a non-empty string", key, i)
719
+ }
720
+ out = append(out, text)
721
+ }
722
+ return out, nil
723
+ }
724
+
725
+ func equalStringSlices(left, right []string) bool {
726
+ if len(left) != len(right) {
727
+ return false
728
+ }
729
+ for i := range left {
730
+ if left[i] != right[i] {
731
+ return false
732
+ }
733
+ }
734
+ return true
735
+ }