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:: +error TS6231: Could 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 + tsconfig.json:2:31 - File is matched by 'files' list specified here. + 2 "files": ["./src/script"] +    ~~~~~~~~~~~~~~ + + +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:: +error TS6231: Could 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; }; +