@ttsc/paths 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/paths",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "First-party ttsc plugin that rewrites emitted module specifiers from tsconfig paths.",
5
5
  "main": "src/index.cjs",
6
6
  "exports": {
package/plugin/main.go CHANGED
@@ -1,31 +1,38 @@
1
+ // Native sidecar entrypoint for `@ttsc/paths`.
2
+ //
3
+ // The sidecar implements an output-stage transform. It reads emitted JS or
4
+ // declaration text, resolves bare specifiers covered by tsconfig `paths`, and
5
+ // rewrites them to relative runtime paths.
1
6
  package main
2
7
 
3
8
  import (
4
- "fmt"
5
- "os"
9
+ "fmt"
10
+ "os"
6
11
  )
7
12
 
8
13
  const version = "0.0.1"
9
14
 
10
15
  func main() {
11
- os.Exit(run(os.Args[1:]))
16
+ os.Exit(run(os.Args[1:]))
12
17
  }
13
18
 
14
19
  func run(args []string) int {
15
- if len(args) == 0 {
16
- fmt.Fprintln(os.Stderr, "@ttsc/paths: command required (expected output|version)")
17
- return 2
18
- }
19
- switch args[0] {
20
- case "-v", "--version", "version":
21
- fmt.Fprintf(os.Stdout, "@ttsc/paths %s\n", version)
22
- return 0
23
- case "check":
24
- return 0
25
- case "output":
26
- return RunOutput(args[1:])
27
- default:
28
- fmt.Fprintf(os.Stderr, "@ttsc/paths: unknown command %q\n", args[0])
29
- return 2
30
- }
20
+ if len(args) == 0 {
21
+ fmt.Fprintln(os.Stderr, "@ttsc/paths: command required (expected output|version)")
22
+ return 2
23
+ }
24
+ switch args[0] {
25
+ case "-v", "--version", "version":
26
+ fmt.Fprintf(os.Stdout, "@ttsc/paths %s\n", version)
27
+ return 0
28
+ case "check":
29
+ // Path rewriting depends on emitted output, so check has no standalone
30
+ // diagnostics beyond command availability.
31
+ return 0
32
+ case "output":
33
+ return RunOutput(args[1:])
34
+ default:
35
+ fmt.Fprintf(os.Stderr, "@ttsc/paths: unknown command %q\n", args[0])
36
+ return 2
37
+ }
31
38
  }
package/plugin/paths.go CHANGED
@@ -1,632 +1,662 @@
1
+ // Output transformer for tsconfig `paths` rewrites.
2
+ //
3
+ // TypeScript's `paths` option is a compile-time resolver hint. Runtime output
4
+ // still contains the original bare specifier unless a bundler handles it.
5
+ // `@ttsc/paths` rewrites those specifiers after emit, using TypeScript-Go's
6
+ // parser and parsed compiler options rather than ad hoc text replacement.
1
7
  package main
2
8
 
3
9
  import (
4
- "encoding/json"
5
- "errors"
6
- "flag"
7
- "fmt"
8
- "os"
9
- "path/filepath"
10
- "sort"
11
- "strings"
12
-
13
- shimast "github.com/microsoft/typescript-go/shim/ast"
14
- "github.com/microsoft/typescript-go/shim/bundled"
15
- shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
16
- shimcore "github.com/microsoft/typescript-go/shim/core"
17
- shimdw "github.com/microsoft/typescript-go/shim/diagnosticwriter"
18
- shimparser "github.com/microsoft/typescript-go/shim/parser"
19
- "github.com/microsoft/typescript-go/shim/tsoptions"
20
- shimtspath "github.com/microsoft/typescript-go/shim/tspath"
21
- "github.com/microsoft/typescript-go/shim/vfs/cachedvfs"
22
- "github.com/microsoft/typescript-go/shim/vfs/osvfs"
10
+ "encoding/json"
11
+ "errors"
12
+ "flag"
13
+ "fmt"
14
+ "os"
15
+ "path/filepath"
16
+ "sort"
17
+ "strings"
18
+
19
+ shimast "github.com/microsoft/typescript-go/shim/ast"
20
+ "github.com/microsoft/typescript-go/shim/bundled"
21
+ shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
22
+ shimcore "github.com/microsoft/typescript-go/shim/core"
23
+ shimdw "github.com/microsoft/typescript-go/shim/diagnosticwriter"
24
+ shimparser "github.com/microsoft/typescript-go/shim/parser"
25
+ "github.com/microsoft/typescript-go/shim/tsoptions"
26
+ shimtspath "github.com/microsoft/typescript-go/shim/tspath"
27
+ "github.com/microsoft/typescript-go/shim/vfs/cachedvfs"
28
+ "github.com/microsoft/typescript-go/shim/vfs/osvfs"
23
29
  )
24
30
 
25
31
  type pluginEntry struct {
26
- Config map[string]any `json:"config"`
27
- Name string `json:"name"`
28
- Stage string `json:"stage"`
32
+ // The host sends every plugin descriptor so ordered sidecars see the same
33
+ // manifest. This plugin needs only the presence of its own descriptor.
34
+ Config map[string]any `json:"config"`
35
+ Name string `json:"name"`
36
+ Stage string `json:"stage"`
29
37
  }
30
38
 
31
39
  type program struct {
32
- cwd string
33
- parsed *tsoptions.ParsedCommandLine
34
- tsProgram *shimcompiler.Program
40
+ // parsed owns the compilerOptions used for paths/baseUrl/rootDir/outDir.
41
+ cwd string
42
+ parsed *tsoptions.ParsedCommandLine
43
+ tsProgram *shimcompiler.Program
35
44
  }
36
45
 
37
46
  type pathsResolver struct {
38
- basePath string
39
- outDir string
40
- patterns []pathsPattern
41
- rootDir string
42
- sourceFiles map[string]string
47
+ // basePath is TypeScript's resolved base for paths entries. outDir/rootDir
48
+ // are normalized absolute paths so output-relative specifiers are stable.
49
+ basePath string
50
+ outDir string
51
+ patterns []pathsPattern
52
+ rootDir string
53
+ sourceFiles map[string]string
43
54
  }
44
55
 
45
56
  type pathsPattern struct {
46
- pattern string
47
- targets []string
57
+ // pattern is the user-facing key from compilerOptions.paths, targets are the
58
+ // corresponding target patterns in declaration order.
59
+ pattern string
60
+ targets []string
48
61
  }
49
62
 
50
63
  type textEdit struct {
51
- start int
52
- end int
53
- text string
64
+ start int
65
+ end int
66
+ text string
54
67
  }
55
68
 
56
69
  func RunOutput(args []string) int {
57
- fs := flag.NewFlagSet("output", flag.ContinueOnError)
58
- fs.SetOutput(os.Stderr)
59
- file := fs.String("file", "", "emitted file to transform")
60
- out := fs.String("out", "", "write transformed text to this file instead of updating --file")
61
- cwd := fs.String("cwd", "", "project directory")
62
- outDir := fs.String("outDir", "", "emit directory override")
63
- pluginsJSON := fs.String("plugins-json", "", "ttsc plugin manifest JSON")
64
- tsconfig := fs.String("tsconfig", "tsconfig.json", "project tsconfig")
65
- if err := fs.Parse(args); err != nil {
66
- return 2
67
- }
68
- if *file == "" {
69
- fmt.Fprintln(os.Stderr, "@ttsc/paths: output requires --file")
70
- return 2
71
- }
72
- if err := requireConfig(*pluginsJSON); err != nil {
73
- fmt.Fprintln(os.Stderr, err)
74
- return 2
75
- }
76
- resolvedCwd, err := resolveCwd(*cwd)
77
- if err != nil {
78
- fmt.Fprintln(os.Stderr, err)
79
- return 2
80
- }
81
- prog, parseDiags, err := loadProgram(resolvedCwd, *tsconfig, *outDir)
82
- if err != nil {
83
- fmt.Fprintf(os.Stderr, "@ttsc/paths: %v\n", err)
84
- return 2
85
- }
86
- if len(parseDiags) > 0 {
87
- shimdw.FormatASTDiagnosticsWithColorAndContext(os.Stderr, parseDiags, resolvedCwd)
88
- return 2
89
- }
90
- text, err := os.ReadFile(*file)
91
- if err != nil {
92
- fmt.Fprintf(os.Stderr, "@ttsc/paths: read %s: %v\n", *file, err)
93
- return 2
94
- }
95
- patched, err := Apply(prog, *file, string(text))
96
- if err != nil {
97
- fmt.Fprintln(os.Stderr, err)
98
- return 2
99
- }
100
- target := *file
101
- if *out != "" {
102
- target = *out
103
- }
104
- if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
105
- fmt.Fprintf(os.Stderr, "@ttsc/paths: mkdir: %v\n", err)
106
- return 2
107
- }
108
- if err := os.WriteFile(target, []byte(patched), 0o644); err != nil {
109
- fmt.Fprintf(os.Stderr, "@ttsc/paths: write %s: %v\n", target, err)
110
- return 2
111
- }
112
- return 0
70
+ fs := flag.NewFlagSet("output", flag.ContinueOnError)
71
+ fs.SetOutput(os.Stderr)
72
+ file := fs.String("file", "", "emitted file to transform")
73
+ out := fs.String("out", "", "write transformed text to this file instead of updating --file")
74
+ cwd := fs.String("cwd", "", "project directory")
75
+ outDir := fs.String("outDir", "", "emit directory override")
76
+ pluginsJSON := fs.String("plugins-json", "", "ttsc plugin manifest JSON")
77
+ tsconfig := fs.String("tsconfig", "tsconfig.json", "project tsconfig")
78
+ if err := fs.Parse(args); err != nil {
79
+ return 2
80
+ }
81
+ if *file == "" {
82
+ fmt.Fprintln(os.Stderr, "@ttsc/paths: output requires --file")
83
+ return 2
84
+ }
85
+ if err := requireConfig(*pluginsJSON); err != nil {
86
+ fmt.Fprintln(os.Stderr, err)
87
+ return 2
88
+ }
89
+ resolvedCwd, err := resolveCwd(*cwd)
90
+ if err != nil {
91
+ fmt.Fprintln(os.Stderr, err)
92
+ return 2
93
+ }
94
+ prog, parseDiags, err := loadProgram(resolvedCwd, *tsconfig, *outDir)
95
+ if err != nil {
96
+ fmt.Fprintf(os.Stderr, "@ttsc/paths: %v\n", err)
97
+ return 2
98
+ }
99
+ if len(parseDiags) > 0 {
100
+ shimdw.FormatASTDiagnosticsWithColorAndContext(os.Stderr, parseDiags, resolvedCwd)
101
+ return 2
102
+ }
103
+ text, err := os.ReadFile(*file)
104
+ if err != nil {
105
+ fmt.Fprintf(os.Stderr, "@ttsc/paths: read %s: %v\n", *file, err)
106
+ return 2
107
+ }
108
+ patched, err := Apply(prog, *file, string(text))
109
+ if err != nil {
110
+ fmt.Fprintln(os.Stderr, err)
111
+ return 2
112
+ }
113
+ target := *file
114
+ if *out != "" {
115
+ target = *out
116
+ }
117
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
118
+ fmt.Fprintf(os.Stderr, "@ttsc/paths: mkdir: %v\n", err)
119
+ return 2
120
+ }
121
+ if err := os.WriteFile(target, []byte(patched), 0o644); err != nil {
122
+ fmt.Fprintf(os.Stderr, "@ttsc/paths: write %s: %v\n", target, err)
123
+ return 2
124
+ }
125
+ return 0
113
126
  }
114
127
 
115
128
  func Apply(prog *program, fileName string, text string) (string, error) {
116
- resolver := newPathsResolver(prog)
117
- return resolver.apply(fileName, text)
129
+ // Apply is pure with respect to the file system. The caller provides the
130
+ // already-loaded program and output text; all writes happen in RunOutput.
131
+ resolver := newPathsResolver(prog)
132
+ return resolver.apply(fileName, text)
118
133
  }
119
134
 
120
135
  func loadProgram(cwd, tsconfigPath string, outDir string) (*program, []*shimast.Diagnostic, error) {
121
- if !filepath.IsAbs(cwd) {
122
- abs, err := filepath.Abs(cwd)
123
- if err != nil {
124
- return nil, nil, fmt.Errorf("cwd: %w", err)
125
- }
126
- cwd = abs
127
- }
128
- resolved := tsconfigPath
129
- if !filepath.IsAbs(resolved) {
130
- resolved = filepath.Join(cwd, resolved)
131
- }
132
- fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
133
- host := shimcompiler.NewCompilerHost(cwd, fs, bundled.LibPath(), nil, nil)
134
- parsed, parseDiags := tsoptions.GetParsedCommandLineOfConfigFile(
135
- resolved,
136
- &shimcore.CompilerOptions{},
137
- nil,
138
- host,
139
- nil,
140
- )
141
- if parsed == nil {
142
- return nil, nil, fmt.Errorf("tsoptions: parsed command line was nil for %s", resolved)
143
- }
144
- if len(parseDiags) > 0 {
145
- return nil, parseDiags, nil
146
- }
147
- if len(parsed.Errors) > 0 {
148
- return nil, parsed.Errors, nil
149
- }
150
- if outDir != "" {
151
- overrideOutDir(cwd, parsed, outDir)
152
- }
153
- tsProgram := shimcompiler.NewProgram(shimcompiler.ProgramOptions{
154
- Config: parsed,
155
- SingleThreaded: shimcore.TSTrue,
156
- Host: host,
157
- UseSourceOfProjectReference: true,
158
- })
159
- if tsProgram == nil {
160
- return nil, nil, errors.New("compiler.NewProgram returned nil")
161
- }
162
- return &program{cwd: cwd, parsed: parsed, tsProgram: tsProgram}, nil, nil
136
+ if !filepath.IsAbs(cwd) {
137
+ abs, err := filepath.Abs(cwd)
138
+ if err != nil {
139
+ return nil, nil, fmt.Errorf("cwd: %w", err)
140
+ }
141
+ cwd = abs
142
+ }
143
+ resolved := tsconfigPath
144
+ if !filepath.IsAbs(resolved) {
145
+ resolved = filepath.Join(cwd, resolved)
146
+ }
147
+ fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
148
+ host := shimcompiler.NewCompilerHost(cwd, fs, bundled.LibPath(), nil, nil)
149
+ parsed, parseDiags := tsoptions.GetParsedCommandLineOfConfigFile(
150
+ resolved,
151
+ &shimcore.CompilerOptions{},
152
+ nil,
153
+ host,
154
+ nil,
155
+ )
156
+ if parsed == nil {
157
+ return nil, nil, fmt.Errorf("tsoptions: parsed command line was nil for %s", resolved)
158
+ }
159
+ if len(parseDiags) > 0 {
160
+ return nil, parseDiags, nil
161
+ }
162
+ if len(parsed.Errors) > 0 {
163
+ return nil, parsed.Errors, nil
164
+ }
165
+ if outDir != "" {
166
+ // The host may override outDir for runtime/cache builds. The resolver must
167
+ // observe the same effective outDir that produced the file being rewritten.
168
+ overrideOutDir(cwd, parsed, outDir)
169
+ }
170
+ tsProgram := shimcompiler.NewProgram(shimcompiler.ProgramOptions{
171
+ Config: parsed,
172
+ SingleThreaded: shimcore.TSTrue,
173
+ Host: host,
174
+ UseSourceOfProjectReference: true,
175
+ })
176
+ if tsProgram == nil {
177
+ return nil, nil, errors.New("compiler.NewProgram returned nil")
178
+ }
179
+ return &program{cwd: cwd, parsed: parsed, tsProgram: tsProgram}, nil, nil
163
180
  }
164
181
 
165
182
  func (p *program) userSourceFiles() []*shimast.SourceFile {
166
- out := make([]*shimast.SourceFile, 0)
167
- for _, f := range p.tsProgram.SourceFiles() {
168
- if f == nil || f.IsDeclarationFile {
169
- continue
170
- }
171
- out = append(out, f)
172
- }
173
- return out
183
+ out := make([]*shimast.SourceFile, 0)
184
+ for _, f := range p.tsProgram.SourceFiles() {
185
+ if f == nil || f.IsDeclarationFile {
186
+ continue
187
+ }
188
+ out = append(out, f)
189
+ }
190
+ return out
174
191
  }
175
192
 
176
193
  func newPathsResolver(prog *program) *pathsResolver {
177
- resolver := &pathsResolver{sourceFiles: map[string]string{}}
178
- if prog == nil || prog.parsed == nil || prog.parsed.ParsedConfig == nil || prog.parsed.ParsedConfig.CompilerOptions == nil {
179
- return resolver
180
- }
181
- options := prog.parsed.ParsedConfig.CompilerOptions
182
- resolver.basePath = options.GetPathsBasePath(prog.cwd)
183
- resolver.outDir = normalizeOptionalPath(options.OutDir, prog.cwd)
184
- resolver.rootDir = normalizeOptionalPath(options.RootDir, prog.cwd)
185
- files := prog.userSourceFiles()
186
- if resolver.rootDir == "" {
187
- resolver.rootDir = commonSourceDir(files)
188
- }
189
- for _, file := range files {
190
- if file == nil {
191
- continue
192
- }
193
- name := normalizePath(file.FileName())
194
- resolver.sourceFiles[name] = name
195
- resolver.sourceFiles[stripKnownSourceExtension(name)] = name
196
- }
197
- if options.Paths != nil {
198
- for pattern, targets := range options.Paths.Entries() {
199
- resolver.patterns = append(resolver.patterns, pathsPattern{
200
- pattern: pattern,
201
- targets: append([]string(nil), targets...),
202
- })
203
- }
204
- }
205
- sort.SliceStable(resolver.patterns, func(i, j int) bool {
206
- return pathsPatternRank(resolver.patterns[i].pattern) > pathsPatternRank(resolver.patterns[j].pattern)
207
- })
208
- return resolver
194
+ resolver := &pathsResolver{sourceFiles: map[string]string{}}
195
+ if prog == nil || prog.parsed == nil || prog.parsed.ParsedConfig == nil || prog.parsed.ParsedConfig.CompilerOptions == nil {
196
+ return resolver
197
+ }
198
+ options := prog.parsed.ParsedConfig.CompilerOptions
199
+ resolver.basePath = options.GetPathsBasePath(prog.cwd)
200
+ resolver.outDir = normalizeOptionalPath(options.OutDir, prog.cwd)
201
+ resolver.rootDir = normalizeOptionalPath(options.RootDir, prog.cwd)
202
+ files := prog.userSourceFiles()
203
+ if resolver.rootDir == "" {
204
+ // TypeScript derives a common source directory when rootDir is absent.
205
+ // Matching that rule keeps rewritten output paths aligned with tsgo emit.
206
+ resolver.rootDir = commonSourceDir(files)
207
+ }
208
+ for _, file := range files {
209
+ if file == nil {
210
+ continue
211
+ }
212
+ name := normalizePath(file.FileName())
213
+ resolver.sourceFiles[name] = name
214
+ resolver.sourceFiles[stripKnownSourceExtension(name)] = name
215
+ }
216
+ if options.Paths != nil {
217
+ for pattern, targets := range options.Paths.Entries() {
218
+ resolver.patterns = append(resolver.patterns, pathsPattern{
219
+ pattern: pattern,
220
+ targets: append([]string(nil), targets...),
221
+ })
222
+ }
223
+ }
224
+ sort.SliceStable(resolver.patterns, func(i, j int) bool {
225
+ // Specific patterns win over broad wildcard patterns, mirroring the usual
226
+ // TypeScript path matching expectation.
227
+ return pathsPatternRank(resolver.patterns[i].pattern) > pathsPatternRank(resolver.patterns[j].pattern)
228
+ })
229
+ return resolver
209
230
  }
210
231
 
211
232
  func (r *pathsResolver) apply(fileName string, text string) (string, error) {
212
- if r == nil || len(r.patterns) == 0 || !isPathsOutput(fileName) {
213
- return text, nil
214
- }
215
- file := parseModuleSpecifierFile(fileName, text)
216
- if file == nil {
217
- return text, nil
218
- }
219
- edits := make([]textEdit, 0)
220
- addEdit := func(lit *shimast.Node) {
221
- if lit == nil || lit.Kind != shimast.KindStringLiteral {
222
- return
223
- }
224
- specifier := lit.Text()
225
- rewritten, ok := r.rewriteSpecifier(fileName, specifier)
226
- if !ok || rewritten == specifier {
227
- return
228
- }
229
- start, end, quote, ok := stringLiteralRange(text, lit)
230
- if !ok {
231
- return
232
- }
233
- edits = append(edits, textEdit{
234
- start: start,
235
- end: end,
236
- text: quoteJSString(quote, rewritten),
237
- })
238
- }
239
- var walk func(*shimast.Node)
240
- walk = func(node *shimast.Node) {
241
- if node == nil {
242
- return
243
- }
244
- switch node.Kind {
245
- case shimast.KindImportDeclaration:
246
- addEdit(node.AsImportDeclaration().ModuleSpecifier)
247
- case shimast.KindExportDeclaration:
248
- addEdit(node.AsExportDeclaration().ModuleSpecifier)
249
- case shimast.KindImportEqualsDeclaration:
250
- ref := node.AsImportEqualsDeclaration().ModuleReference
251
- if ref != nil && ref.Kind == shimast.KindExternalModuleReference {
252
- addEdit(ref.AsExternalModuleReference().Expression)
253
- }
254
- case shimast.KindImportType:
255
- arg := node.AsImportTypeNode().Argument
256
- if arg != nil && arg.Kind == shimast.KindLiteralType {
257
- addEdit(arg.AsLiteralTypeNode().Literal)
258
- }
259
- case shimast.KindModuleDeclaration:
260
- addEdit(node.AsModuleDeclaration().Name())
261
- case shimast.KindCallExpression:
262
- call := node.AsCallExpression()
263
- if call != nil && (isRequireCall(call) || isDynamicImportCall(call)) && call.Arguments != nil && len(call.Arguments.Nodes) > 0 {
264
- addEdit(call.Arguments.Nodes[0])
265
- }
266
- }
267
- node.ForEachChild(func(child *shimast.Node) bool {
268
- walk(child)
269
- return false
270
- })
271
- }
272
- for _, stmt := range file.Statements.Nodes {
273
- walk(stmt)
274
- }
275
- return applyTextEdits(text, edits), nil
233
+ if r == nil || len(r.patterns) == 0 || !isPathsOutput(fileName) {
234
+ return text, nil
235
+ }
236
+ file := parseModuleSpecifierFile(fileName, text)
237
+ if file == nil {
238
+ return text, nil
239
+ }
240
+ edits := make([]textEdit, 0)
241
+ addEdit := func(lit *shimast.Node) {
242
+ if lit == nil || lit.Kind != shimast.KindStringLiteral {
243
+ return
244
+ }
245
+ specifier := lit.Text()
246
+ rewritten, ok := r.rewriteSpecifier(fileName, specifier)
247
+ if !ok || rewritten == specifier {
248
+ return
249
+ }
250
+ start, end, quote, ok := stringLiteralRange(text, lit)
251
+ if !ok {
252
+ return
253
+ }
254
+ edits = append(edits, textEdit{
255
+ start: start,
256
+ end: end,
257
+ text: quoteJSString(quote, rewritten),
258
+ })
259
+ }
260
+ // Walk only syntactic module-specifier positions. Ordinary string literals
261
+ // are deliberately ignored, even if their text happens to match a path alias.
262
+ var walk func(*shimast.Node)
263
+ walk = func(node *shimast.Node) {
264
+ if node == nil {
265
+ return
266
+ }
267
+ switch node.Kind {
268
+ case shimast.KindImportDeclaration:
269
+ addEdit(node.AsImportDeclaration().ModuleSpecifier)
270
+ case shimast.KindExportDeclaration:
271
+ addEdit(node.AsExportDeclaration().ModuleSpecifier)
272
+ case shimast.KindImportEqualsDeclaration:
273
+ ref := node.AsImportEqualsDeclaration().ModuleReference
274
+ if ref != nil && ref.Kind == shimast.KindExternalModuleReference {
275
+ addEdit(ref.AsExternalModuleReference().Expression)
276
+ }
277
+ case shimast.KindImportType:
278
+ arg := node.AsImportTypeNode().Argument
279
+ if arg != nil && arg.Kind == shimast.KindLiteralType {
280
+ addEdit(arg.AsLiteralTypeNode().Literal)
281
+ }
282
+ case shimast.KindModuleDeclaration:
283
+ addEdit(node.AsModuleDeclaration().Name())
284
+ case shimast.KindCallExpression:
285
+ call := node.AsCallExpression()
286
+ if call != nil && (isRequireCall(call) || isDynamicImportCall(call)) && call.Arguments != nil && len(call.Arguments.Nodes) > 0 {
287
+ addEdit(call.Arguments.Nodes[0])
288
+ }
289
+ }
290
+ node.ForEachChild(func(child *shimast.Node) bool {
291
+ walk(child)
292
+ return false
293
+ })
294
+ }
295
+ for _, stmt := range file.Statements.Nodes {
296
+ walk(stmt)
297
+ }
298
+ return applyTextEdits(text, edits), nil
276
299
  }
277
300
 
278
301
  func (r *pathsResolver) rewriteSpecifier(outputFile string, specifier string) (string, bool) {
279
- if isExternalModuleNameRelative(specifier) || strings.HasPrefix(specifier, "/") {
280
- return specifier, false
281
- }
282
- for _, pattern := range r.patterns {
283
- capture, ok := matchPathPattern(pattern.pattern, specifier)
284
- if !ok || len(pattern.targets) == 0 {
285
- continue
286
- }
287
- for _, targetPattern := range pattern.targets {
288
- target := strings.ReplaceAll(targetPattern, "*", capture)
289
- source, ok := r.resolveTargetSource(target)
290
- if !ok {
291
- continue
292
- }
293
- targetOutput := r.outputPathForSource(source)
294
- relative, err := filepath.Rel(filepath.Dir(normalizePath(outputFile)), targetOutput)
295
- if err != nil {
296
- return specifier, false
297
- }
298
- relative = filepath.ToSlash(relative)
299
- if relative == "." {
300
- relative = "./" + filepath.Base(targetOutput)
301
- }
302
- if !strings.HasPrefix(relative, ".") {
303
- relative = "./" + relative
304
- }
305
- return relative, true
306
- }
307
- }
308
- return specifier, false
302
+ // Relative and absolute specifiers are already runtime-addressable.
303
+ if isExternalModuleNameRelative(specifier) || strings.HasPrefix(specifier, "/") {
304
+ return specifier, false
305
+ }
306
+ for _, pattern := range r.patterns {
307
+ capture, ok := matchPathPattern(pattern.pattern, specifier)
308
+ if !ok || len(pattern.targets) == 0 {
309
+ continue
310
+ }
311
+ for _, targetPattern := range pattern.targets {
312
+ target := strings.ReplaceAll(targetPattern, "*", capture)
313
+ source, ok := r.resolveTargetSource(target)
314
+ if !ok {
315
+ continue
316
+ }
317
+ targetOutput := r.outputPathForSource(source)
318
+ // The emitted file imports another emitted file, not the source file.
319
+ // Relative paths are therefore calculated from outputFile to targetOutput.
320
+ relative, err := filepath.Rel(filepath.Dir(normalizePath(outputFile)), targetOutput)
321
+ if err != nil {
322
+ return specifier, false
323
+ }
324
+ relative = filepath.ToSlash(relative)
325
+ if relative == "." {
326
+ relative = "./" + filepath.Base(targetOutput)
327
+ }
328
+ if !strings.HasPrefix(relative, ".") {
329
+ relative = "./" + relative
330
+ }
331
+ return relative, true
332
+ }
333
+ }
334
+ return specifier, false
309
335
  }
310
336
 
311
337
  func (r *pathsResolver) resolveTargetSource(target string) (string, bool) {
312
- base := r.basePath
313
- if base == "" {
314
- base = "."
315
- }
316
- raw := normalizePath(filepath.Join(base, target))
317
- candidates := []string{raw}
318
- if stripKnownSourceExtension(raw) == raw {
319
- for _, ext := range knownResolvableExtensions() {
320
- candidates = append(candidates, raw+ext)
321
- }
322
- for _, ext := range knownResolvableExtensions() {
323
- candidates = append(candidates, filepath.ToSlash(filepath.Join(raw, "index"+ext)))
324
- }
325
- }
326
- for _, candidate := range candidates {
327
- normalized := normalizePath(candidate)
328
- if source, ok := r.sourceFiles[normalized]; ok {
329
- return source, true
330
- }
331
- if source, ok := r.sourceFiles[stripKnownSourceExtension(normalized)]; ok {
332
- return source, true
333
- }
334
- }
335
- return "", false
338
+ base := r.basePath
339
+ if base == "" {
340
+ base = "."
341
+ }
342
+ raw := normalizePath(filepath.Join(base, target))
343
+ candidates := []string{raw}
344
+ if stripKnownSourceExtension(raw) == raw {
345
+ // Match Node/TypeScript-style extension and index resolution against the
346
+ // program source-file set; no direct filesystem probe is needed.
347
+ for _, ext := range knownResolvableExtensions() {
348
+ candidates = append(candidates, raw+ext)
349
+ }
350
+ for _, ext := range knownResolvableExtensions() {
351
+ candidates = append(candidates, filepath.ToSlash(filepath.Join(raw, "index"+ext)))
352
+ }
353
+ }
354
+ for _, candidate := range candidates {
355
+ normalized := normalizePath(candidate)
356
+ if source, ok := r.sourceFiles[normalized]; ok {
357
+ return source, true
358
+ }
359
+ if source, ok := r.sourceFiles[stripKnownSourceExtension(normalized)]; ok {
360
+ return source, true
361
+ }
362
+ }
363
+ return "", false
336
364
  }
337
365
 
338
366
  func (r *pathsResolver) outputPathForSource(source string) string {
339
- outputExt := outputExtensionForSource(source)
340
- if r.outDir == "" {
341
- return changeExtension(source, outputExt)
342
- }
343
- if r.rootDir != "" {
344
- if rel, err := filepath.Rel(r.rootDir, source); err == nil && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
345
- return normalizePath(filepath.Join(r.outDir, changeExtension(rel, outputExt)))
346
- }
347
- }
348
- return normalizePath(filepath.Join(r.outDir, filepath.Base(changeExtension(source, outputExt))))
367
+ outputExt := outputExtensionForSource(source)
368
+ if r.outDir == "" {
369
+ return changeExtension(source, outputExt)
370
+ }
371
+ if r.rootDir != "" {
372
+ if rel, err := filepath.Rel(r.rootDir, source); err == nil && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
373
+ return normalizePath(filepath.Join(r.outDir, changeExtension(rel, outputExt)))
374
+ }
375
+ }
376
+ return normalizePath(filepath.Join(r.outDir, filepath.Base(changeExtension(source, outputExt))))
349
377
  }
350
378
 
351
379
  func requireConfig(pluginsJSON string) error {
352
- if strings.TrimSpace(pluginsJSON) == "" {
353
- return fmt.Errorf("@ttsc/paths: missing --plugins-json")
354
- }
355
- var entries []pluginEntry
356
- if err := json.Unmarshal([]byte(pluginsJSON), &entries); err != nil {
357
- return fmt.Errorf("@ttsc/paths: invalid --plugins-json: %w", err)
358
- }
359
- for _, entry := range entries {
360
- if entry.Name == "@ttsc/paths" {
361
- return nil
362
- }
363
- }
364
- return fmt.Errorf("@ttsc/paths: plugin entry not found")
380
+ if strings.TrimSpace(pluginsJSON) == "" {
381
+ return fmt.Errorf("@ttsc/paths: missing --plugins-json")
382
+ }
383
+ var entries []pluginEntry
384
+ if err := json.Unmarshal([]byte(pluginsJSON), &entries); err != nil {
385
+ return fmt.Errorf("@ttsc/paths: invalid --plugins-json: %w", err)
386
+ }
387
+ for _, entry := range entries {
388
+ if entry.Name == "@ttsc/paths" {
389
+ return nil
390
+ }
391
+ }
392
+ return fmt.Errorf("@ttsc/paths: plugin entry not found")
365
393
  }
366
394
 
367
395
  func resolveCwd(override string) (string, error) {
368
- if override != "" {
369
- abs, err := filepath.Abs(override)
370
- if err != nil {
371
- return "", fmt.Errorf("@ttsc/paths: --cwd: %w", err)
372
- }
373
- return abs, nil
374
- }
375
- wd, err := os.Getwd()
376
- if err != nil {
377
- return "", fmt.Errorf("@ttsc/paths: cwd: %w", err)
378
- }
379
- return wd, nil
396
+ if override != "" {
397
+ abs, err := filepath.Abs(override)
398
+ if err != nil {
399
+ return "", fmt.Errorf("@ttsc/paths: --cwd: %w", err)
400
+ }
401
+ return abs, nil
402
+ }
403
+ wd, err := os.Getwd()
404
+ if err != nil {
405
+ return "", fmt.Errorf("@ttsc/paths: cwd: %w", err)
406
+ }
407
+ return wd, nil
380
408
  }
381
409
 
382
410
  func overrideOutDir(cwd string, parsed *tsoptions.ParsedCommandLine, outDir string) {
383
- if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
384
- return
385
- }
386
- if filepath.IsAbs(outDir) {
387
- parsed.ParsedConfig.CompilerOptions.OutDir = filepath.ToSlash(outDir)
388
- return
389
- }
390
- parsed.ParsedConfig.CompilerOptions.OutDir = filepath.ToSlash(filepath.Join(cwd, outDir))
411
+ if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
412
+ return
413
+ }
414
+ if filepath.IsAbs(outDir) {
415
+ parsed.ParsedConfig.CompilerOptions.OutDir = filepath.ToSlash(outDir)
416
+ return
417
+ }
418
+ parsed.ParsedConfig.CompilerOptions.OutDir = filepath.ToSlash(filepath.Join(cwd, outDir))
391
419
  }
392
420
 
393
421
  func parseModuleSpecifierFile(fileName string, text string) *shimast.SourceFile {
394
- normalized := normalizePath(fileName)
395
- if !filepath.IsAbs(normalized) {
396
- if abs, err := filepath.Abs(normalized); err == nil {
397
- normalized = normalizePath(abs)
398
- }
399
- }
400
- opts := shimast.SourceFileParseOptions{FileName: normalized}
401
- kind := shimcore.ScriptKindJS
402
- if isDeclarationOutput(fileName) {
403
- kind = shimcore.ScriptKindTS
404
- }
405
- return shimparser.ParseSourceFile(opts, text, kind)
422
+ normalized := normalizePath(fileName)
423
+ if !filepath.IsAbs(normalized) {
424
+ if abs, err := filepath.Abs(normalized); err == nil {
425
+ normalized = normalizePath(abs)
426
+ }
427
+ }
428
+ opts := shimast.SourceFileParseOptions{FileName: normalized}
429
+ kind := shimcore.ScriptKindJS
430
+ if isDeclarationOutput(fileName) {
431
+ kind = shimcore.ScriptKindTS
432
+ }
433
+ return shimparser.ParseSourceFile(opts, text, kind)
406
434
  }
407
435
 
408
436
  func isRequireCall(call *shimast.CallExpression) bool {
409
- if call == nil || call.Expression == nil || call.Expression.Kind != shimast.KindIdentifier {
410
- return false
411
- }
412
- return call.Expression.Text() == "require"
437
+ if call == nil || call.Expression == nil || call.Expression.Kind != shimast.KindIdentifier {
438
+ return false
439
+ }
440
+ return call.Expression.Text() == "require"
413
441
  }
414
442
 
415
443
  func isDynamicImportCall(call *shimast.CallExpression) bool {
416
- if call == nil || call.Expression == nil || call.Expression.Kind != shimast.KindImportKeyword {
417
- return false
418
- }
419
- return call.Arguments != nil && len(call.Arguments.Nodes) == 1
444
+ if call == nil || call.Expression == nil || call.Expression.Kind != shimast.KindImportKeyword {
445
+ return false
446
+ }
447
+ return call.Arguments != nil && len(call.Arguments.Nodes) == 1
420
448
  }
421
449
 
422
450
  func stringLiteralRange(text string, node *shimast.Node) (int, int, byte, bool) {
423
- start := clamp(node.Pos(), 0, len(text))
424
- end := clamp(node.End(), start, len(text))
425
- for start < end && text[start] != '"' && text[start] != '\'' {
426
- start++
427
- }
428
- if start >= end {
429
- return 0, 0, 0, false
430
- }
431
- quote := text[start]
432
- for end > start+1 && text[end-1] != quote {
433
- end--
434
- }
435
- if end <= start+1 {
436
- return 0, 0, 0, false
437
- }
438
- return start, end, quote, true
451
+ start := clamp(node.Pos(), 0, len(text))
452
+ end := clamp(node.End(), start, len(text))
453
+ for start < end && text[start] != '"' && text[start] != '\'' {
454
+ start++
455
+ }
456
+ if start >= end {
457
+ return 0, 0, 0, false
458
+ }
459
+ quote := text[start]
460
+ for end > start+1 && text[end-1] != quote {
461
+ end--
462
+ }
463
+ if end <= start+1 {
464
+ return 0, 0, 0, false
465
+ }
466
+ return start, end, quote, true
439
467
  }
440
468
 
441
469
  func quoteJSString(quote byte, value string) string {
442
- var b strings.Builder
443
- b.WriteByte(quote)
444
- for _, r := range value {
445
- switch r {
446
- case '\\':
447
- b.WriteString(`\\`)
448
- case '\n':
449
- b.WriteString(`\n`)
450
- case '\r':
451
- b.WriteString(`\r`)
452
- case '\t':
453
- b.WriteString(`\t`)
454
- default:
455
- if byte(r) == quote && r < utf8RuneSelf {
456
- b.WriteByte('\\')
457
- b.WriteByte(byte(r))
458
- } else {
459
- b.WriteRune(r)
460
- }
461
- }
462
- }
463
- b.WriteByte(quote)
464
- return b.String()
470
+ var b strings.Builder
471
+ b.WriteByte(quote)
472
+ for _, r := range value {
473
+ switch r {
474
+ case '\\':
475
+ b.WriteString(`\\`)
476
+ case '\n':
477
+ b.WriteString(`\n`)
478
+ case '\r':
479
+ b.WriteString(`\r`)
480
+ case '\t':
481
+ b.WriteString(`\t`)
482
+ default:
483
+ if byte(r) == quote && r < utf8RuneSelf {
484
+ b.WriteByte('\\')
485
+ b.WriteByte(byte(r))
486
+ } else {
487
+ b.WriteRune(r)
488
+ }
489
+ }
490
+ }
491
+ b.WriteByte(quote)
492
+ return b.String()
465
493
  }
466
494
 
467
495
  const utf8RuneSelf = 0x80
468
496
 
469
497
  func applyTextEdits(text string, edits []textEdit) string {
470
- if len(edits) == 0 {
471
- return text
472
- }
473
- sort.SliceStable(edits, func(i, j int) bool {
474
- if edits[i].start == edits[j].start {
475
- return edits[i].end > edits[j].end
476
- }
477
- return edits[i].start > edits[j].start
478
- })
479
- out := text
480
- lastStart := len(text) + 1
481
- for _, edit := range edits {
482
- if edit.start < 0 || edit.end < edit.start || edit.start > len(out) {
483
- continue
484
- }
485
- if edit.end > lastStart {
486
- edit.end = lastStart
487
- }
488
- if edit.end > len(out) {
489
- edit.end = len(out)
490
- }
491
- out = out[:edit.start] + edit.text + out[edit.end:]
492
- lastStart = edit.start
493
- }
494
- return out
498
+ if len(edits) == 0 {
499
+ return text
500
+ }
501
+ sort.SliceStable(edits, func(i, j int) bool {
502
+ if edits[i].start == edits[j].start {
503
+ return edits[i].end > edits[j].end
504
+ }
505
+ return edits[i].start > edits[j].start
506
+ })
507
+ out := text
508
+ lastStart := len(text) + 1
509
+ for _, edit := range edits {
510
+ if edit.start < 0 || edit.end < edit.start || edit.start > len(out) {
511
+ continue
512
+ }
513
+ if edit.end > lastStart {
514
+ // Overlapping edits can happen only if TypeScript-Go exposes nested
515
+ // specifier nodes. The outer edit wins by trimming the later range.
516
+ edit.end = lastStart
517
+ }
518
+ if edit.end > len(out) {
519
+ edit.end = len(out)
520
+ }
521
+ out = out[:edit.start] + edit.text + out[edit.end:]
522
+ lastStart = edit.start
523
+ }
524
+ return out
495
525
  }
496
526
 
497
527
  func matchPathPattern(pattern string, specifier string) (string, bool) {
498
- star := strings.Index(pattern, "*")
499
- if star < 0 {
500
- return "", pattern == specifier
501
- }
502
- prefix := pattern[:star]
503
- suffix := pattern[star+1:]
504
- if !strings.HasPrefix(specifier, prefix) || !strings.HasSuffix(specifier, suffix) {
505
- return "", false
506
- }
507
- return specifier[len(prefix) : len(specifier)-len(suffix)], true
528
+ star := strings.Index(pattern, "*")
529
+ if star < 0 {
530
+ return "", pattern == specifier
531
+ }
532
+ prefix := pattern[:star]
533
+ suffix := pattern[star+1:]
534
+ if !strings.HasPrefix(specifier, prefix) || !strings.HasSuffix(specifier, suffix) {
535
+ return "", false
536
+ }
537
+ return specifier[len(prefix) : len(specifier)-len(suffix)], true
508
538
  }
509
539
 
510
540
  func commonSourceDir(files []*shimast.SourceFile) string {
511
- var common string
512
- for _, file := range files {
513
- if file == nil {
514
- continue
515
- }
516
- dir := filepath.Dir(normalizePath(file.FileName()))
517
- if common == "" {
518
- common = dir
519
- continue
520
- }
521
- common = commonPathPrefix(common, dir)
522
- }
523
- return common
541
+ var common string
542
+ for _, file := range files {
543
+ if file == nil {
544
+ continue
545
+ }
546
+ dir := filepath.Dir(normalizePath(file.FileName()))
547
+ if common == "" {
548
+ common = dir
549
+ continue
550
+ }
551
+ common = commonPathPrefix(common, dir)
552
+ }
553
+ return common
524
554
  }
525
555
 
526
556
  func commonPathPrefix(a string, b string) string {
527
- aParts := strings.Split(normalizePath(a), "/")
528
- bParts := strings.Split(normalizePath(b), "/")
529
- n := len(aParts)
530
- if len(bParts) < n {
531
- n = len(bParts)
532
- }
533
- i := 0
534
- for i < n && aParts[i] == bParts[i] {
535
- i++
536
- }
537
- if i == 0 {
538
- return ""
539
- }
540
- return strings.Join(aParts[:i], "/")
557
+ aParts := strings.Split(normalizePath(a), "/")
558
+ bParts := strings.Split(normalizePath(b), "/")
559
+ n := len(aParts)
560
+ if len(bParts) < n {
561
+ n = len(bParts)
562
+ }
563
+ i := 0
564
+ for i < n && aParts[i] == bParts[i] {
565
+ i++
566
+ }
567
+ if i == 0 {
568
+ return ""
569
+ }
570
+ return strings.Join(aParts[:i], "/")
541
571
  }
542
572
 
543
573
  func normalizeOptionalPath(value string, cwd string) string {
544
- if value == "" {
545
- return ""
546
- }
547
- if filepath.IsAbs(value) {
548
- return normalizePath(value)
549
- }
550
- return normalizePath(filepath.Join(cwd, value))
574
+ if value == "" {
575
+ return ""
576
+ }
577
+ if filepath.IsAbs(value) {
578
+ return normalizePath(value)
579
+ }
580
+ return normalizePath(filepath.Join(cwd, value))
551
581
  }
552
582
 
553
583
  func normalizePath(value string) string {
554
- return filepath.ToSlash(shimtspath.NormalizePath(value))
584
+ return filepath.ToSlash(shimtspath.NormalizePath(value))
555
585
  }
556
586
 
557
587
  func stripKnownSourceExtension(value string) string {
558
- for _, ext := range []string{".d.ts", ".d.mts", ".d.cts", ".tsx", ".ts", ".mts", ".cts", ".jsx", ".js", ".json"} {
559
- if strings.HasSuffix(value, ext) {
560
- return strings.TrimSuffix(value, ext)
561
- }
562
- }
563
- return value
588
+ for _, ext := range []string{".d.ts", ".d.mts", ".d.cts", ".tsx", ".ts", ".mts", ".cts", ".jsx", ".js", ".json"} {
589
+ if strings.HasSuffix(value, ext) {
590
+ return strings.TrimSuffix(value, ext)
591
+ }
592
+ }
593
+ return value
564
594
  }
565
595
 
566
596
  func outputExtensionForSource(source string) string {
567
- switch strings.ToLower(filepath.Ext(source)) {
568
- case ".mts":
569
- return ".mjs"
570
- case ".cts":
571
- return ".cjs"
572
- case ".json":
573
- return ".json"
574
- default:
575
- return ".js"
576
- }
597
+ switch strings.ToLower(filepath.Ext(source)) {
598
+ case ".mts":
599
+ return ".mjs"
600
+ case ".cts":
601
+ return ".cjs"
602
+ case ".json":
603
+ return ".json"
604
+ default:
605
+ return ".js"
606
+ }
577
607
  }
578
608
 
579
609
  func changeExtension(value string, ext string) string {
580
- return strings.TrimSuffix(value, filepath.Ext(value)) + ext
610
+ return strings.TrimSuffix(value, filepath.Ext(value)) + ext
581
611
  }
582
612
 
583
613
  func isExternalModuleNameRelative(specifier string) bool {
584
- return strings.HasPrefix(specifier, "./") ||
585
- strings.HasPrefix(specifier, "../") ||
586
- specifier == "." ||
587
- specifier == ".."
614
+ return strings.HasPrefix(specifier, "./") ||
615
+ strings.HasPrefix(specifier, "../") ||
616
+ specifier == "." ||
617
+ specifier == ".."
588
618
  }
589
619
 
590
620
  func isPathsOutput(fileName string) bool {
591
- return isJavaScriptOutput(fileName) || isDeclarationOutput(fileName)
621
+ return isJavaScriptOutput(fileName) || isDeclarationOutput(fileName)
592
622
  }
593
623
 
594
624
  func isJavaScriptOutput(fileName string) bool {
595
- switch strings.ToLower(filepath.Ext(fileName)) {
596
- case ".js", ".mjs", ".cjs":
597
- return true
598
- default:
599
- return false
600
- }
625
+ switch strings.ToLower(filepath.Ext(fileName)) {
626
+ case ".js", ".mjs", ".cjs":
627
+ return true
628
+ default:
629
+ return false
630
+ }
601
631
  }
602
632
 
603
633
  func isDeclarationOutput(fileName string) bool {
604
- lower := strings.ToLower(fileName)
605
- return strings.HasSuffix(lower, ".d.ts") ||
606
- strings.HasSuffix(lower, ".d.mts") ||
607
- strings.HasSuffix(lower, ".d.cts")
634
+ lower := strings.ToLower(fileName)
635
+ return strings.HasSuffix(lower, ".d.ts") ||
636
+ strings.HasSuffix(lower, ".d.mts") ||
637
+ strings.HasSuffix(lower, ".d.cts")
608
638
  }
609
639
 
610
640
  func knownResolvableExtensions() []string {
611
- return []string{".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".json"}
641
+ return []string{".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".json"}
612
642
  }
613
643
 
614
644
  func pathsPatternRank(pattern string) int {
615
- star := strings.Index(pattern, "*")
616
- if star < 0 {
617
- return 1_000_000 + len(pattern)
618
- }
619
- prefix := len(pattern[:star])
620
- suffix := len(pattern[star+1:])
621
- return prefix*1_000 + suffix*10 + len(pattern)
645
+ star := strings.Index(pattern, "*")
646
+ if star < 0 {
647
+ return 1_000_000 + len(pattern)
648
+ }
649
+ prefix := len(pattern[:star])
650
+ suffix := len(pattern[star+1:])
651
+ return prefix*1_000 + suffix*10 + len(pattern)
622
652
  }
623
653
 
624
654
  func clamp(value int, min int, max int) int {
625
- if value < min {
626
- return min
627
- }
628
- if value > max {
629
- return max
630
- }
631
- return value
655
+ if value < min {
656
+ return min
657
+ }
658
+ if value > max {
659
+ return max
660
+ }
661
+ return value
632
662
  }