Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/compiler/fileloader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
6 changes: 6 additions & 0 deletions internal/compiler/filesparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type parseTask struct {
loaded bool
startedSubTasks bool
isForAutomaticTypeDirective bool
failedLookup bool
includeReason *FileIncludeReason
packageId module.PackageId

Expand Down Expand Up @@ -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)()
}
Expand Down
2 changes: 1 addition & 1 deletion internal/compiler/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the only place we need to do this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, it wasn't. I went through the other callers that derive a script kind from a file name and found one more live crash: getOrParseSourceFile in internal/ls/sourcedefinition.go. A declaration map's sources entries are arbitrary strings, so a .d.ts.map naming an existing extensionless file panicked custom/textDocument/sourceDefinition with the same assert:

panic handling request custom/textDocument/sourceDefinition: ScriptKind must be specified when parsing source file: /lib/helper

Fixed in the follow-up commit, with a fourslash regression test; it now navigates into the mapped file instead of crashing.

The rest of what I looked at:

  • internal/execute/tsc.go (fmtMain) has the same pattern, but it's dead code today since its only caller, the -f case, is commented out. Happy to switch it over too if you want.
  • internal/project/api.go deliberately returns "unsupported file extension" instead of parsing, so I left it alone.
  • The GetScriptKind field on the ATA request in session.go is never read by the installer.
  • harnessutil panics on purpose in the test harness.

return parser.ParseSourceFile(opts, text, core.EnsureScriptKindFromFileName(opts.FileName))
}

func (h *compilerHost) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine {
Expand Down
10 changes: 10 additions & 0 deletions internal/core/core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
19 changes: 19 additions & 0 deletions internal/execute/tsctests/tsc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
4 changes: 3 additions & 1 deletion internal/ls/sourcedefinition.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/project/overlayfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down
30 changes: 30 additions & 0 deletions internal/project/overlayfs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions internal/project/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// === goToSourceDefinition ===
// === /lib/helper ===
// <|export function [|helper|](): string { return ""; }|>

// === /index.ts ===
// import { /*GOTO SOURCE DEF*/helper } from "./lib/helper";
// helper();
Original file line number Diff line number Diff line change
@@ -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*
/// <reference no-default-lib="true"/>
interface Boolean {}
interface Function {}
interface CallableFunction {}
interface NewableFunction {}
interface IArguments {}
interface Number { toExponential: any; }
interface Object {}
interface RegExp {}
interface String { charAt: any; }
interface Array<T> { length: number; [n: number]: T; }
interface ReadonlyArray<T> {}
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; };

Original file line number Diff line number Diff line change
@@ -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*
/// <reference no-default-lib="true"/>
interface Boolean {}
interface Function {}
interface CallableFunction {}
interface NewableFunction {}
interface IArguments {}
interface Number { toExponential: any; }
interface Object {}
interface RegExp {}
interface String { charAt: any; }
interface Array<T> { length: number; [n: number]: T; }
interface ReadonlyArray<T> {}
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; };