diff --git a/internal/compiler/fileloader.go b/internal/compiler/fileloader.go
index f8d006e9258..faf7096898c 100644
--- a/internal/compiler/fileloader.go
+++ b/internal/compiler/fileloader.go
@@ -214,6 +214,7 @@ func (p *fileLoader) addRootFileTask(fileName string, libFile *LibFile, includeR
}
if diagnostic != nil {
rootTask.normalizedFilePath = absPath
+ rootTask.failedLookup = true
rootTask.processingDiagnostics = []*processingDiagnostic{{
kind: processingDiagnosticKindExplainingFileInclude,
data: &includeExplainingDiagnostic{
diff --git a/internal/compiler/filesparser.go b/internal/compiler/filesparser.go
index 5817fc6bb86..41905aa547f 100644
--- a/internal/compiler/filesparser.go
+++ b/internal/compiler/filesparser.go
@@ -26,6 +26,7 @@ type parseTask struct {
loaded bool
startedSubTasks bool
isForAutomaticTypeDirective bool
+ failedLookup bool
includeReason *FileIncludeReason
packageId module.PackageId
@@ -60,6 +61,11 @@ func (t *parseTask) load(loader *fileLoader) {
t.loadAutomaticTypeDirectives(loader)
return
}
+ if t.failedLookup {
+ // The root file name did not resolve to a supported extension; the task
+ // exists only to carry its processing diagnostic, so nothing is parsed.
+ return
+ }
if loader.opts.Tracing != nil {
defer loader.opts.Tracing.Push(tracing.PhaseProgram, "findSourceFile", map[string]any{"fileName": t.normalizedFilePath}, false)()
}
diff --git a/internal/compiler/host.go b/internal/compiler/host.go
index 17751307fe6..ed04fe9344f 100644
--- a/internal/compiler/host.go
+++ b/internal/compiler/host.go
@@ -80,7 +80,7 @@ func (h *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.Sourc
if !ok {
return nil
}
- return parser.ParseSourceFile(opts, text, core.GetScriptKindFromFileName(opts.FileName))
+ return parser.ParseSourceFile(opts, text, core.EnsureScriptKindFromFileName(opts.FileName))
}
func (h *compilerHost) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine {
diff --git a/internal/core/core.go b/internal/core/core.go
index 1ce98a8c744..3df82f0e916 100644
--- a/internal/core/core.go
+++ b/internal/core/core.go
@@ -543,6 +543,16 @@ func GetScriptKindFromFileName(fileName string) ScriptKind {
return ScriptKindUnknown
}
+// EnsureScriptKindFromFileName is like GetScriptKindFromFileName, but defaults to
+// ScriptKindTS when the file name has no recognized extension (e.g. files included
+// with allowNonTsExtensions), so the result is always safe to hand to the parser.
+func EnsureScriptKindFromFileName(fileName string) ScriptKind {
+ if kind := GetScriptKindFromFileName(fileName); kind != ScriptKindUnknown {
+ return kind
+ }
+ return ScriptKindTS
+}
+
// Given a name and a list of names that are *not* equal to the name, return a spelling suggestion if there is one that is close enough.
// Names less than length 3 only check for case-insensitive equality.
//
diff --git a/internal/execute/tsctests/tsc_test.go b/internal/execute/tsctests/tsc_test.go
index 29cba7ba4e2..8e566bf58f3 100644
--- a/internal/execute/tsctests/tsc_test.go
+++ b/internal/execute/tsctests/tsc_test.go
@@ -273,6 +273,25 @@ func TestTscMissingFiles(t *testing.T) {
},
commandLineArgs: []string{"-p", "./tsconfig.json"},
},
+ {
+ subScenario: "extensionless file in tsconfig exists",
+ files: FileMap{
+ "/home/src/workspaces/project/tsconfig.json": stringtestutil.Dedent(
+ `{
+ "files": ["./src/script"]
+ }`,
+ ),
+ "/home/src/workspaces/project/src/script": `const n: number = "s";`,
+ },
+ commandLineArgs: []string{"-p", "./tsconfig.json"},
+ },
+ {
+ subScenario: "extensionless file on command line exists",
+ files: FileMap{
+ "/home/src/workspaces/project/script": `const n: number = "s";`,
+ },
+ commandLineArgs: []string{"script"},
+ },
{
subScenario: "extensionless file in extended tsconfig in different folder does not exist",
files: FileMap{
diff --git a/internal/fourslash/tests/goToSourceDefinitionExtensionlessMappedSource_test.go b/internal/fourslash/tests/goToSourceDefinitionExtensionlessMappedSource_test.go
new file mode 100644
index 00000000000..a97a3260616
--- /dev/null
+++ b/internal/fourslash/tests/goToSourceDefinitionExtensionlessMappedSource_test.go
@@ -0,0 +1,29 @@
+package fourslash_test
+
+import (
+ "testing"
+
+ "github.com/microsoft/typescript-go/internal/fourslash"
+ "github.com/microsoft/typescript-go/internal/testutil"
+)
+
+// A declaration map can name anything in its `sources`, including a file whose
+// name has no recognized extension. Parsing that file used to hit the parser's
+// "ScriptKind must be specified" assert and crash the request.
+func TestGoToSourceDefinitionExtensionlessMappedSource(t *testing.T) {
+ t.Parallel()
+ defer testutil.RecoverAndFail(t, "Panic on fourslash test")
+ const content = `// @Filename: /lib/helper.d.ts
+export declare function helper(): string;
+//# sourceMappingURL=helper.d.ts.map
+// @Filename: /lib/helper.d.ts.map
+{"version":3,"file":"helper.d.ts","sourceRoot":"","sources":["helper"],"names":[],"mappings":"AAAA,wBAAgB,MAAM,IAAI,MAAM,CAAC"}
+// @Filename: /lib/helper
+export function helper(): string { return ""; }
+// @Filename: /index.ts
+import { /*usage*/helper } from "./lib/helper";
+helper();`
+ f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content)
+ defer done()
+ f.VerifyBaselineGoToSourceDefinition(t, "usage")
+}
diff --git a/internal/ls/sourcedefinition.go b/internal/ls/sourcedefinition.go
index 023e6da1a59..fe3958e9fd1 100644
--- a/internal/ls/sourcedefinition.go
+++ b/internal/ls/sourcedefinition.go
@@ -421,7 +421,9 @@ func (r *sourceDefResolver) getOrParseSourceFile(fileName string) *ast.SourceFil
sourceFile = parser.ParseSourceFile(
ast.SourceFileParseOptions{FileName: fileName, Path: r.ls.toPath(fileName)},
text,
- core.GetScriptKindFromFileName(fileName),
+ // A declaration map's `sources` entries are arbitrary strings, so the
+ // file name here may not have a recognized extension.
+ core.EnsureScriptKindFromFileName(fileName),
)
binder.BindSourceFile(sourceFile)
}
diff --git a/internal/project/overlayfs.go b/internal/project/overlayfs.go
index 1b300a01312..7cde858c228 100644
--- a/internal/project/overlayfs.go
+++ b/internal/project/overlayfs.go
@@ -99,7 +99,7 @@ func (f *diskFile) IsOverlay() bool {
}
func (f *diskFile) Kind() core.ScriptKind {
- return core.GetScriptKindFromFileName(f.fileName)
+ return core.EnsureScriptKindFromFileName(f.fileName)
}
func (f *diskFile) Clone() *diskFile {
@@ -312,7 +312,7 @@ func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, ma
}
scriptKind := lsconv.LanguageKindToScriptKind(events.openChange.LanguageKind)
if scriptKind == core.ScriptKindUnknown {
- scriptKind = core.GetScriptKindFromFileName(uri.FileName())
+ scriptKind = core.EnsureScriptKindFromFileName(uri.FileName())
}
newOverlays[path] = newOverlay(
uri.FileName(),
diff --git a/internal/project/overlayfs_test.go b/internal/project/overlayfs_test.go
index 4659530ff7b..adc47ed13c2 100644
--- a/internal/project/overlayfs_test.go
+++ b/internal/project/overlayfs_test.go
@@ -17,6 +17,7 @@ func TestProcessChanges(t *testing.T) {
testFS := vfstest.FromMap(map[string]string{
"/test1.ts": "// existing content",
"/test2.ts": "// existing content",
+ "/script": "// extensionless content",
}, false /* useCaseSensitiveFileNames */)
return newOverlayFS(
testFS,
@@ -183,6 +184,35 @@ func TestProcessChanges(t *testing.T) {
assert.Equal(t, fh.Kind(), core.ScriptKindTS)
})
+ t.Run("open extensionless file with unknown language kind falls back to TS", func(t *testing.T) {
+ t.Parallel()
+ fs := createOverlayFS()
+ uri := lsproto.DocumentUri("file:///script")
+
+ fs.processChanges([]FileChange{
+ {
+ Kind: FileChangeKindOpen,
+ URI: uri,
+ Version: 1,
+ Content: "const x = 1;",
+ LanguageKind: lsproto.LanguageKind("plaintext"),
+ },
+ })
+
+ fh := fs.getFile(uri.FileName())
+ assert.Assert(t, fh != nil)
+ assert.Equal(t, fh.Kind(), core.ScriptKindTS)
+ })
+
+ t.Run("extensionless disk file falls back to TS", func(t *testing.T) {
+ t.Parallel()
+ fs := createOverlayFS()
+
+ fh := fs.getFile("/script")
+ assert.Assert(t, fh != nil)
+ assert.Equal(t, fh.Kind(), core.ScriptKindTS)
+ })
+
t.Run("watch change on overlay marks as not matching disk", func(t *testing.T) {
t.Parallel()
fs := createOverlayFS()
diff --git a/internal/project/session_test.go b/internal/project/session_test.go
index 79208dfdf49..707b36ca537 100644
--- a/internal/project/session_test.go
+++ b/internal/project/session_test.go
@@ -111,6 +111,27 @@ func TestSession(t *testing.T) {
program := ls.GetProgram()
assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/index.js") != nil)
})
+
+ t.Run("inferred project extensionless file", func(t *testing.T) {
+ t.Parallel()
+ files := map[string]any{
+ "/home/projects/TS/p1/script": `const x = 1;`,
+ }
+ session, _ := projecttestutil.Setup(files)
+
+ session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/script", 1, files["/home/projects/TS/p1/script"].(string), lsproto.LanguageKind("plaintext"))
+
+ snapshot := session.Snapshot()
+ assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1)
+ assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil)
+
+ ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/script")
+ assert.NilError(t, err)
+ program := ls.GetProgram()
+ file := program.GetSourceFile("/home/projects/TS/p1/script")
+ assert.Assert(t, file != nil)
+ assert.Equal(t, file.ScriptKind, core.ScriptKindTS)
+ })
})
t.Run("watchChange and didOpen in same batch rebuilds program", func(t *testing.T) {
diff --git a/testdata/baselines/reference/fourslash/goToSourceDefinition/goToSourceDefinitionExtensionlessMappedSource.baseline.jsonc b/testdata/baselines/reference/fourslash/goToSourceDefinition/goToSourceDefinitionExtensionlessMappedSource.baseline.jsonc
new file mode 100644
index 00000000000..d64e5499d7f
--- /dev/null
+++ b/testdata/baselines/reference/fourslash/goToSourceDefinition/goToSourceDefinitionExtensionlessMappedSource.baseline.jsonc
@@ -0,0 +1,7 @@
+// === goToSourceDefinition ===
+// === /lib/helper ===
+// <|export function [|helper|](): string { return ""; }|>
+
+// === /index.ts ===
+// import { /*GOTO SOURCE DEF*/helper } from "./lib/helper";
+// helper();
\ No newline at end of file
diff --git a/testdata/baselines/reference/tsc/commandLine/extensionless-file-in-tsconfig-exists.js b/testdata/baselines/reference/tsc/commandLine/extensionless-file-in-tsconfig-exists.js
new file mode 100644
index 00000000000..1bb69d8e84d
--- /dev/null
+++ b/testdata/baselines/reference/tsc/commandLine/extensionless-file-in-tsconfig-exists.js
@@ -0,0 +1,47 @@
+currentDirectory::/home/src/workspaces/project
+useCaseSensitiveFileNames::true
+Input::
+//// [/home/src/workspaces/project/src/script] *new*
+const n: number = "s";
+//// [/home/src/workspaces/project/tsconfig.json] *new*
+{
+ "files": ["./src/script"]
+ }
+
+tsgo -p ./tsconfig.json
+ExitStatus:: DiagnosticsPresent_OutputsGenerated
+Output::
+[91merror[0m[90m TS6231: [0mCould not resolve the path '/home/src/workspaces/project/src/script' with the extensions: '.ts', '.tsx', '.d.ts', '.cts', '.d.cts', '.mts', '.d.mts'.
+ The file is in the program because:
+ Part of 'files' list in tsconfig.json
+ [96mtsconfig.json[0m:[93m2[0m:[93m31[0m - File is matched by 'files' list specified here.
+ [7m2[0m "files": ["./src/script"]
+ [7m [0m [96m ~~~~~~~~~~~~~~[0m
+
+
+Found 1 error.
+
+//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib*
+///
+interface Boolean {}
+interface Function {}
+interface CallableFunction {}
+interface NewableFunction {}
+interface IArguments {}
+interface Number { toExponential: any; }
+interface Object {}
+interface RegExp {}
+interface String { charAt: any; }
+interface Array { length: number; [n: number]: T; }
+interface ReadonlyArray {}
+interface SymbolConstructor {
+ (desc?: string | number): symbol;
+ for(name: string): symbol;
+ readonly toStringTag: symbol;
+}
+declare var Symbol: SymbolConstructor;
+interface Symbol {
+ readonly [Symbol.toStringTag]: string;
+}
+declare const console: { log(msg: any): void; };
+
diff --git a/testdata/baselines/reference/tsc/commandLine/extensionless-file-on-command-line-exists.js b/testdata/baselines/reference/tsc/commandLine/extensionless-file-on-command-line-exists.js
new file mode 100644
index 00000000000..7689ac25359
--- /dev/null
+++ b/testdata/baselines/reference/tsc/commandLine/extensionless-file-on-command-line-exists.js
@@ -0,0 +1,39 @@
+currentDirectory::/home/src/workspaces/project
+useCaseSensitiveFileNames::true
+Input::
+//// [/home/src/workspaces/project/script] *new*
+const n: number = "s";
+
+tsgo script
+ExitStatus:: DiagnosticsPresent_OutputsGenerated
+Output::
+[91merror[0m[90m TS6231: [0mCould not resolve the path 'script' with the extensions: '.ts', '.tsx', '.d.ts', '.cts', '.d.cts', '.mts', '.d.mts'.
+ The file is in the program because:
+ Root file specified for compilation
+
+Found 1 error.
+
+//// [/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts] *Lib*
+///
+interface Boolean {}
+interface Function {}
+interface CallableFunction {}
+interface NewableFunction {}
+interface IArguments {}
+interface Number { toExponential: any; }
+interface Object {}
+interface RegExp {}
+interface String { charAt: any; }
+interface Array { length: number; [n: number]: T; }
+interface ReadonlyArray {}
+interface SymbolConstructor {
+ (desc?: string | number): symbol;
+ for(name: string): symbol;
+ readonly toStringTag: symbol;
+}
+declare var Symbol: SymbolConstructor;
+interface Symbol {
+ readonly [Symbol.toStringTag]: string;
+}
+declare const console: { log(msg: any): void; };
+