Skip to content

Commit 2197f1e

Browse files
kirkouimetahraiaclaude
committed
tsoptions: a config's top-level "sourceExtensions" makes files with those extensions TypeScript source under their own names
A program can now read files whose extension TypeScript doesn't know, opted into by its config: { "sourceExtensions": [".a"], "include": ["**/*.a", "**/*.ts"] } The files keep their names everywhere. Nothing is aliased to X.a.ts. The key rides the plumbing content mappers already use for their extensions (ExtraExtensions), with no mapper and no external process: - include enumeration (getFileNamesFromConfigSpecs); - the file loader's supported extensions and the program's reference check; - module resolution, where `import … from './geometry.a'` resolves to geometry.a itself, through the extra-extensions branch, never to geometry.a.ts; - the language service's source definition and string completions; - the language server's include globs (fileGlobPatterns). The loader's dispatch to a content mapper still keys on mapper extensions alone, so a source extension goes to host.GetSourceFile, which already parses an unknown extension as TypeScript (EnsureScriptKindFromFileName). - Top level, not a compiler option, so tsc and stock tsgo ignore it. Checked: tsc 5.9.3 and tsgo 7.0.0-dev.20260707.2 build a tsconfig carrying it, exit 0. It's inherited through extends, like contentMappers. - Each entry must begin with a dot, must not be TypeScript's own or a content mapper's, and must appear once. A bad one is reported (TS6046, Argument for sourceExtensions option must be: …) and left out. - A config without the key never enumerates such a file. That's the reason for the opt-in: `.a` is also the static-library extension, and `include: ["**/*"]` must not parse libfoo.a as TypeScript. - GetOutputJSFileNameWorker gives X.a.js. ChangeExtension doesn't strip an extension it doesn't know, so it used to name the source as its own output. The declaration path, X.d.a.ts, was already right. The generator's source (tools/scripts/tsc/options.ts) declares the key. Its two outputs that change meaning, the root option in declarations_generated.go and the tsconfig and jsconfig schema entries, are here. The schema entries are the generator's own output, written before its dprint step, which needs dprint, absent on this machine. The two declaration entries mirror contentMappers'. A regeneration with dprint should produce no diff. Tests (compiler/sourceextensions_test.go): - opted in: geometry.a, main.a and plain.ts are the program, main.a parses as TypeScript, its one planted TS2322 is reported under its own name, and ./geometry.a resolves to /src/geometry.a; - not opted in: include **/* takes plain.ts alone, and a binary libfoo.a is never read; - inherited through extends; - invalid entries (no dot, .ts, a duplicate) reported and left out; - output paths /out/main.a.js and /out/main.d.a.ts. Three mutants fail them: the loader's list, the resolver's list, the output-path fix. ParsedOptions equality covers the new field. go vet ./... is clean. tsoptions, compiler, outputpaths, module, execute, ls and tspath tests pass. For cohere's #6mhafvb (Adamic's .a). Co-Authored-By: Ahra <[email protected]> Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 8d550c8 commit 2197f1e

15 files changed

Lines changed: 251 additions & 13 deletions

‎packages/typescript/schemas/jsconfig.schema.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@
105105
"description": "External content mapper packages and the file extensions they handle. Execution must be enabled separately with --runExternalCode.",
106106
"markdownDescription": "External content mapper packages and the file extensions they handle. Execution must be enabled separately with --runExternalCode."
107107
},
108+
"sourceExtensions": {
109+
"anyOf": [
110+
{
111+
"type": "array",
112+
"items": {
113+
"type": "string"
114+
}
115+
},
116+
{
117+
"type": "null"
118+
}
119+
]
120+
},
108121
"files": {
109122
"anyOf": [
110123
{

‎packages/typescript/schemas/tsconfig.schema.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,19 @@
105105
"description": "External content mapper packages and the file extensions they handle. Execution must be enabled separately with --runExternalCode.",
106106
"markdownDescription": "External content mapper packages and the file extensions they handle. Execution must be enabled separately with --runExternalCode."
107107
},
108+
"sourceExtensions": {
109+
"anyOf": [
110+
{
111+
"type": "array",
112+
"items": {
113+
"type": "string"
114+
}
115+
},
116+
{
117+
"type": "null"
118+
}
119+
]
120+
},
108121
"files": {
109122
"anyOf": [
110123
{

‎tools/scripts/tsc/options.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,7 @@ export const options: OptionsModel = {
18541854
{ name: "extends", kind: "ListOrElement", variable: "extendsOptionDeclaration", category: diagnostic("File Management"), elementOptions: "extends" },
18551855
{ name: "references", kind: "List" },
18561856
{ name: "contentMappers", kind: "List", documentationAnchor: false },
1857+
{ name: "sourceExtensions", kind: "List", documentationAnchor: false },
18571858
{ name: "files", kind: "List" },
18581859
{ name: "include", kind: "List" },
18591860
{ name: "exclude", kind: "List" },
@@ -1899,6 +1900,10 @@ export const options: OptionsModel = {
18991900
name: "contentMappers",
19001901
kind: "Object",
19011902
},
1903+
sourceExtensions: {
1904+
name: "sourceExtensions",
1905+
kind: "String",
1906+
},
19021907
files: {
19031908
name: "files",
19041909
kind: "String",

‎tsc/internal/compiler/fileloader.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func processAllProgramFiles(
154154
) (processedFiles, *module.ResolutionData, error) {
155155
compilerOptions := opts.Config.CompilerOptions()
156156
rootFiles := opts.Config.FileNames()
157-
supportedExtensions := tsoptions.GetSupportedExtensions(compilerOptions, opts.Config.ContentMapperExtensions())
157+
supportedExtensions := tsoptions.GetSupportedExtensions(compilerOptions, opts.Config.ExtraExtensions())
158158
supportedExtensionsWithJsonIfResolveJsonModule := tsoptions.GetSupportedExtensionsWithJsonIfResolveJsonModule(compilerOptions, supportedExtensions)
159159
var maxNodeModuleJsDepth int
160160
if p := opts.Config.CompilerOptions().MaxNodeModuleJsDepth; p != nil {
@@ -184,7 +184,7 @@ func processAllProgramFiles(
184184
CompilerOptions: compilerOptions,
185185
TypingsLocation: opts.TypingsLocation,
186186
ProjectName: opts.ProjectName,
187-
ExtraExtensions: opts.Config.ContentMapperExtensions(),
187+
ExtraExtensions: opts.Config.ExtraExtensions(),
188188
}
189189
if opts.CreateModuleResolver != nil {
190190
loader.resolver = opts.CreateModuleResolver(resolverOptions)

‎tsc/internal/compiler/program.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ func (p *Program) GetSourceFileFromReference(origin *ast.SourceFile, ref *ast.Fi
265265
// Still, without the failed lookup reporting that only the loader does, this isn't terribly complicated
266266

267267
fileName := tspath.ResolvePath(tspath.GetDirectoryPath(origin.FileName()), ref.FileName)
268-
supportedExtensionsBase := tsoptions.GetSupportedExtensions(p.Options(), p.CommandLine().ContentMapperExtensions())
268+
supportedExtensionsBase := tsoptions.GetSupportedExtensions(p.Options(), p.CommandLine().ExtraExtensions())
269269
supportedExtensions := tsoptions.GetSupportedExtensionsWithJsonIfResolveJsonModule(p.Options(), supportedExtensionsBase)
270270
allowNonTsExtensions := p.Options().AllowNonTsExtensions.IsTrue()
271271
if tspath.HasExtension(fileName) {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package compiler
2+
3+
import (
4+
"context"
5+
"slices"
6+
"strings"
7+
"testing"
8+
9+
"github.com/microsoft/TypeScript/tsc/internal/core"
10+
"github.com/microsoft/TypeScript/tsc/internal/outputpaths"
11+
"github.com/microsoft/TypeScript/tsc/internal/tsoptions"
12+
"github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest"
13+
"gotest.tools/v3/assert"
14+
)
15+
16+
// sourceExtensionsProgram builds the program /src/tsconfig.json defines over files.
17+
func sourceExtensionsProgram(t *testing.T, files map[string]any) (*Program, *tsoptions.ParsedCommandLine, []string) {
18+
t.Helper()
19+
host := NewCompilerHost("/", vfstest.FromMap(files, true), "", nil, nil, nil)
20+
config, configDiagnostics := tsoptions.GetParsedCommandLineOfConfigFile("/src/tsconfig.json", nil, nil, host, nil)
21+
messages := []string{}
22+
for _, diagnostic := range append(configDiagnostics, config.GetConfigFileParsingDiagnostics()...) {
23+
messages = append(messages, diagnostic.String())
24+
}
25+
program := NewProgram(ProgramOptions{Config: config, Host: host})
26+
return program, config, messages
27+
}
28+
29+
// fileNames are a program's own files: no lib.
30+
func fileNames(program *Program) []string {
31+
names := []string{}
32+
for _, file := range program.GetSourceFiles() {
33+
if strings.HasPrefix(file.FileName(), "/src/") {
34+
names = append(names, file.FileName())
35+
}
36+
}
37+
slices.Sort(names)
38+
return names
39+
}
40+
41+
// A config's "sourceExtensions" makes files with that extension TypeScript source in its program, under their
42+
// own names: enumerated by include, parsed as TypeScript, and reached by an import that names the extension,
43+
// which resolves to the file itself, never to an X.a.ts. A config without the key never reads such a file,
44+
// so a static library named libfoo.a beside the source is not parsed as TypeScript. The key is inherited
45+
// through extends, and an invalid entry is reported and left out.
46+
func TestSourceExtensions(t *testing.T) {
47+
t.Parallel()
48+
source := map[string]any{
49+
"/src/geometry.a": "export function area(width: number, height: number): number { return width * height; }\n",
50+
"/src/main.a": "import { area } from './geometry.a';\nexport const total: number = area(2, 3);\nexport const wrong: string = area(1, 1);\n",
51+
"/src/plain.ts": "export const plain = 1;\n",
52+
"/src/libfoo.a": "!<arch>\nnot TypeScript at all {{{\n",
53+
}
54+
with := func(config string) map[string]any {
55+
files := map[string]any{"/src/tsconfig.json": config}
56+
for name, contents := range source {
57+
files[name] = contents
58+
}
59+
return files
60+
}
61+
62+
t.Run("opted in", func(t *testing.T) {
63+
t.Parallel()
64+
files := with(`{"sourceExtensions": [".a"], "compilerOptions": {"noLib": true, "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "module": "esnext", "moduleResolution": "bundler"}, "include": ["*.a", "*.ts"], "exclude": ["libfoo.a"]}`)
65+
program, config, configMessages := sourceExtensionsProgram(t, files)
66+
assert.Equal(t, len(configMessages), 0, configMessages)
67+
assert.DeepEqual(t, config.SourceExtensions(), []string{".a"})
68+
assert.DeepEqual(t, fileNames(program), []string{"/src/geometry.a", "/src/main.a", "/src/plain.ts"})
69+
70+
main := program.GetSourceFile("/src/main.a")
71+
assert.Assert(t, main != nil)
72+
assert.Equal(t, main.ScriptKind, core.ScriptKindTS)
73+
// The one error is the planted one, in main.a under its own name: the import resolved, so area's
74+
// return type is known, and nothing about geometry.a.ts or an unresolved module appears.
75+
diagnostics := program.GetSemanticDiagnostics(context.Background(), main)
76+
assert.Equal(t, len(diagnostics), 1)
77+
assert.Equal(t, diagnostics[0].Code(), int32(2322))
78+
assert.Equal(t, diagnostics[0].File().FileName(), "/src/main.a")
79+
resolvedFileName := ""
80+
for key, resolved := range program.GetResolvedModules()[main.Path()] {
81+
if key.Name == "./geometry.a" && resolved.IsResolved() {
82+
resolvedFileName = resolved.ResolvedFileName
83+
}
84+
}
85+
assert.Equal(t, resolvedFileName, "/src/geometry.a")
86+
})
87+
88+
t.Run("not opted in", func(t *testing.T) {
89+
t.Parallel()
90+
files := with(`{"compilerOptions": {"noLib": true, "noEmit": true}, "include": ["**/*"]}`)
91+
program, _, _ := sourceExtensionsProgram(t, files)
92+
assert.DeepEqual(t, fileNames(program), []string{"/src/plain.ts"})
93+
})
94+
95+
t.Run("inherited through extends", func(t *testing.T) {
96+
t.Parallel()
97+
files := with(`{"extends": "./base.json", "compilerOptions": {"noLib": true, "noEmit": true}, "include": ["main.a", "geometry.a"]}`)
98+
files["/src/base.json"] = `{"sourceExtensions": [".a"]}`
99+
_, config, configMessages := sourceExtensionsProgram(t, files)
100+
assert.Equal(t, len(configMessages), 0, configMessages)
101+
assert.DeepEqual(t, config.SourceExtensions(), []string{".a"})
102+
assert.DeepEqual(t, config.FileNames(), []string{"/src/main.a", "/src/geometry.a"})
103+
})
104+
105+
t.Run("invalid entries are reported and left out", func(t *testing.T) {
106+
t.Parallel()
107+
files := with(`{"sourceExtensions": ["a", ".ts", ".a", ".a"], "compilerOptions": {"noLib": true, "noEmit": true}, "include": ["*.ts"]}`)
108+
_, config, configMessages := sourceExtensionsProgram(t, files)
109+
assert.DeepEqual(t, config.SourceExtensions(), []string{".a"})
110+
assert.Equal(t, len(configMessages), 3, configMessages)
111+
})
112+
}
113+
114+
// A source extension's JavaScript output is X.a.js and its declaration output X.d.a.ts. ChangeExtension
115+
// doesn't strip an extension TypeScript doesn't know, so the JavaScript name used to be the input's own.
116+
func TestSourceExtensionOutputPaths(t *testing.T) {
117+
t.Parallel()
118+
files := map[string]any{
119+
"/src/tsconfig.json": `{"sourceExtensions": [".a"], "compilerOptions": {"noLib": true, "declaration": true, "outDir": "/out"}, "include": ["*.a"]}`,
120+
"/src/main.a": "export const value = 1;\n",
121+
}
122+
program, _, _ := sourceExtensionsProgram(t, files)
123+
options := program.Options()
124+
assert.Equal(t, outputpaths.GetOutputJSFileName("/src/main.a", options, program), "/out/main.a.js")
125+
assert.Equal(t, outputpaths.GetOutputDeclarationFileNameWorker("/src/main.a", options, program), "/out/main.d.a.ts")
126+
}

‎tsc/internal/ls/sourcedefinition.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func (l *LanguageService) newSourceDefResolver(
156156
Host: program.Host(),
157157
CompilerOptions: noDtsOptions,
158158
TypingsLocation: program.GetGlobalTypingsCacheLocation(),
159-
ExtraExtensions: program.CommandLine().ContentMapperExtensions(),
159+
ExtraExtensions: program.CommandLine().ExtraExtensions(),
160160
}),
161161
}
162162
}

‎tsc/internal/ls/string_completions.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1036,7 +1036,7 @@ func (l *LanguageService) getExtensionOptions(
10361036
mode core.ResolutionMode,
10371037
checker *checker.Checker,
10381038
) *extensionOptions {
1039-
extensionsToSearch := getSupportedExtensionsForModuleResolution(options, l.GetProgram().CommandLine().ContentMapperExtensions(), checker)
1039+
extensionsToSearch := getSupportedExtensionsForModuleResolution(options, l.GetProgram().CommandLine().ExtraExtensions(), checker)
10401040

10411041
return &extensionOptions{
10421042
extensionsToSearch: extensionsToSearch,

‎tsc/internal/outputpaths/outputpaths.go‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,14 @@ func isContentMappedFileName(fileName string, host OutputPathsHost) bool {
9999
}
100100

101101
func GetOutputJSFileNameWorker(inputFileName string, options *core.CompilerOptions, host OutputPathsHost) string {
102-
return tspath.ChangeExtension(
103-
getOutputPathWithoutChangingExtension(inputFileName, options.OutDir, host),
104-
GetOutputExtension(inputFileName, options.Jsx),
105-
)
102+
outputPath := getOutputPathWithoutChangingExtension(inputFileName, options.OutDir, host)
103+
// An extension TypeScript doesn't strip (a config's source extension, X.a) keeps it and gains the output
104+
// extension, X.a.js, as getOwnEmitOutputFilePath computes for the emit itself. ChangeExtension would
105+
// return the input path unchanged, naming the source as its own output.
106+
if tspath.TryGetExtensionFromPath(outputPath) == "" {
107+
return outputPath + GetOutputExtension(inputFileName, options.Jsx)
108+
}
109+
return tspath.ChangeExtension(outputPath, GetOutputExtension(inputFileName, options.Jsx))
106110
}
107111

108112
func GetOutputDeclarationFileNameWorker(inputFileName string, options *core.CompilerOptions, host OutputPathsHost) string {

‎tsc/internal/tsoptions/declarations_generated.go‎

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)