ttsc 0.12.1 → 0.12.2

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 (2) hide show
  1. package/driver/rewrite.go +148 -56
  2. package/package.json +8 -8
package/driver/rewrite.go CHANGED
@@ -19,7 +19,9 @@ import (
19
19
  "fmt"
20
20
  "os"
21
21
  "path/filepath"
22
+ "regexp"
22
23
  "strings"
24
+ "sync"
23
25
  "unicode"
24
26
 
25
27
  "github.com/microsoft/typescript-go/shim/ast"
@@ -195,51 +197,95 @@ func applyRewrites(outputName, text string, rs *RewriteSet, cursors map[string]i
195
197
  return out, nil
196
198
  }
197
199
 
200
+ // findSourceForOutput recovers which registered source file produced a given
201
+ // emitted output, using the source paths in rs.byPath as the universe.
202
+ //
203
+ // The match is anchored on the source's path relative to the common directory
204
+ // shared by all registered sources. The output's stem must end with that exact
205
+ // relative path (with a leading "/" boundary unless the source sits at the
206
+ // common directory root). This is stricter than a generic suffix match: a
207
+ // barrel file like `lib/api/x/index.js` will not accidentally collide with an
208
+ // unrelated `src/.../y/index.ts` that happens to share the basename. The bug
209
+ // surfaced when typia ran across shopping-backend's nestia-generated barrel
210
+ // files; the looser match steered the rewriter at the wrong source and threw
211
+ // `driver: could not locate typia.random(…) call in …`.
212
+ //
213
+ // Ambiguous matches (two or more registered sources with the same tail) return
214
+ // no match so the caller treats the output as having no rewrites.
198
215
  func findSourceForOutput(outputName string, rs *RewriteSet) (string, bool) {
199
- outSlash := strings.TrimSuffix(filepath.ToSlash(outputName), filepath.Ext(outputName))
200
- var best string
201
- bestScore := 0
202
- for path := range rs.byPath {
203
- srcStem := strings.TrimSuffix(filepath.ToSlash(path), filepath.Ext(path))
204
- score := commonSuffixSegments(srcStem, outSlash)
205
- if score > bestScore {
206
- best = path
207
- bestScore = score
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
208
227
  }
228
+ if outStem == tail || strings.HasSuffix(outStem, "/"+tail) {
229
+ matched = srcPath
230
+ hits++
231
+ }
232
+ }
233
+ if hits != 1 {
234
+ return "", false
209
235
  }
210
- return best, bestScore > 0
236
+ return matched, true
211
237
  }
212
238
 
213
- func commonSuffixSegments(a, b string) int {
214
- as := strings.Split(a, "/")
215
- bs := strings.Split(b, "/")
216
- n := len(as)
217
- if len(bs) < n {
218
- n = len(bs)
219
- }
220
- shared := 0
221
- for i := 1; i <= n; i++ {
222
- if as[len(as)-i] != bs[len(bs)-i] {
239
+ // commonSourceDirectoryFor returns the deepest directory (with trailing "/")
240
+ // shared by every source path in rs.byPath. When rs has a single source this is
241
+ // just that source's directory.
242
+ 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 {
223
265
  break
224
266
  }
225
- shared++
226
267
  }
227
- return shared
268
+ if len(common) == 0 {
269
+ return ""
270
+ }
271
+ return strings.Join(common, "/") + "/"
272
+ }
273
+
274
+ // sourceTail returns the source stem (extension dropped) without the common
275
+ // directory prefix. The leading "/" is also stripped so callers can match it as
276
+ // a suffix segment.
277
+ 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, "/")
228
283
  }
229
284
 
230
285
  func spliceCall(text string, r Rewrite, searchFrom int) (string, int, bool, error) {
231
286
  aliases := candidateRoots(r.RootName)
232
- tail := needleTail(r)
233
- idx := -1
234
- needleLen := 0
235
- for _, cand := range aliases {
236
- candNeedle := cand + tail
237
- if i := indexAtCallStart(text, candNeedle, searchFrom); i >= 0 {
238
- idx = i
239
- needleLen = len(candNeedle) - 1
240
- break
241
- }
242
- }
287
+ pattern := callRegexFor(aliases, r.Namespaces, r.Method)
288
+ idx, needleLen := findCallMatch(text, pattern, searchFrom)
243
289
  if idx < 0 {
244
290
  return text, searchFrom, false, nil
245
291
  }
@@ -256,6 +302,75 @@ func spliceCall(text string, r Rewrite, searchFrom int) (string, int, bool, erro
256
302
  return replaced, idx + len(r.Replacement), true, nil
257
303
  }
258
304
 
305
+ // callRegexFor compiles the loose-match needle pattern used by spliceCall.
306
+ //
307
+ // tsgo's emitter preserves source line breaks in property-access chains, so a
308
+ // source-side `typia.misc\n .literals<T>()` lands in the output as
309
+ // `typia_1.default.misc\n .literals()`. A literal needle would miss it; the
310
+ // pattern instead allows any whitespace (spaces, tabs, newlines) between
311
+ // segments, around the trailing dot before the method, and before the opening
312
+ // paren. Group 1 captures the trailing `(` so callers can compute the call
313
+ // site's text length precisely (regexes can't return per-byte segment widths
314
+ // otherwise).
315
+ //
316
+ // Results are cached by candidate-aliases × namespaces × method because the
317
+ // same rewrite descriptor is re-checked once per emitted file in incremental
318
+ // watch builds.
319
+ 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
344
+ }
345
+
346
+ var callRegexCache sync.Map
347
+
348
+ // findCallMatch scans `text` from `searchFrom` for the next call expression
349
+ // matched by the loose-match `pattern`, applying the same "must start outside
350
+ // an identifier" rule as the old literal indexAtCallStart so generated locals
351
+ // like `mytypia.foo(` don't shadow `typia.foo(`. Returns the start byte of the
352
+ // match and the length up to (but not including) the captured `(`.
353
+ 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
372
+ }
373
+
259
374
  func candidateRoots(root string) []string {
260
375
  return []string{
261
376
  root,
@@ -281,29 +396,6 @@ func needleTail(r Rewrite) string {
281
396
  return "." + strings.Join(r.Namespaces, ".") + "." + r.Method + "("
282
397
  }
283
398
 
284
- func indexAtCallStart(text, needle string, searchFrom int) int {
285
- start := searchFrom
286
- if start < 0 {
287
- start = 0
288
- }
289
- for {
290
- hit := strings.Index(text[start:], needle)
291
- if hit < 0 {
292
- return -1
293
- }
294
- pos := start + hit
295
- if pos == 0 {
296
- return pos
297
- }
298
- prev := rune(text[pos-1])
299
- if isIdentifierPart(prev) {
300
- start = pos + 1
301
- continue
302
- }
303
- return pos
304
- }
305
- }
306
-
307
399
  func isIdentifierPart(r rune) bool {
308
400
  return r == '_' || r == '$' || unicode.IsLetter(r) || unicode.IsDigit(r)
309
401
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ttsc",
3
- "version": "0.12.1",
3
+ "version": "0.12.2",
4
4
  "description": "General-purpose TypeScript-Go compiler, plugin host, and runtime.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -32,13 +32,13 @@
32
32
  "rimraf": "^6.1.2"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@ttsc/linux-x64": "0.12.1",
36
- "@ttsc/linux-arm64": "0.12.1",
37
- "@ttsc/darwin-x64": "0.12.1",
38
- "@ttsc/linux-arm": "0.12.1",
39
- "@ttsc/darwin-arm64": "0.12.1",
40
- "@ttsc/win32-x64": "0.12.1",
41
- "@ttsc/win32-arm64": "0.12.1"
35
+ "@ttsc/linux-x64": "0.12.2",
36
+ "@ttsc/linux-arm": "0.12.2",
37
+ "@ttsc/linux-arm64": "0.12.2",
38
+ "@ttsc/darwin-arm64": "0.12.2",
39
+ "@ttsc/win32-x64": "0.12.2",
40
+ "@ttsc/darwin-x64": "0.12.2",
41
+ "@ttsc/win32-arm64": "0.12.2"
42
42
  },
43
43
  "sideEffects": false,
44
44
  "files": [