Skip to content
Merged
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
42 changes: 42 additions & 0 deletions scanner/filegraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"os"
pathpkg "path"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -706,6 +707,15 @@ func tryExactMatch(path string, idx *fileIndex, sourceLanguage string) []string
if idx.byExact[path] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(path)) {
return []string{path}
}
// Under ESM and NodeNext, TypeScript requires the specifier to name the
// emitted JavaScript file, so "./helper.js" is written in a file whose
// only on-disk counterpart is helper.ts. The specifier already carries an
// extension, so appending one below never matches and the edge was lost.
for _, candidate := range typescriptSourceCandidates(path, sourceLanguage) {
if idx.byExact[candidate] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(candidate)) {
return []string{candidate}
}
}
for _, ext := range resolverExtensions[:len(resolverExtensions)-1] {
candidate := path + ext
if idx.byExact[candidate] == 1 && languagesCompatible(sourceLanguage, DetectLanguage(candidate)) {
Expand All @@ -716,6 +726,38 @@ func tryExactMatch(path string, idx *fileIndex, sourceLanguage string) []string
return nil
}

// typescriptEmitExtensions maps an emitted JavaScript extension to the
// TypeScript sources that produce it, in the order the compiler prefers.
//
// ".mjs" and ".cjs" are deliberately absent: they emit from ".mts" and
// ".cts", which are not recognized source extensions here, so the scanner
// never indexes such a file and a mapping for them would be unreachable code
// that looks like support. Adding those extensions is a separate change.
var typescriptEmitExtensions = map[string][]string{
".js": {".ts", ".tsx", ".d.ts"},
".jsx": {".tsx", ".d.ts"},
}

// typescriptSourceCandidates returns the TypeScript files a JavaScript
// specifier may name. It applies only to JS-family importers, and only when
// the specifier already carries an emitted extension: for anything else the
// ordinary extension-appending search is correct and this returns nothing.
func typescriptSourceCandidates(path, sourceLanguage string) []string {
if !isJavaScriptLanguage(sourceLanguage) {
return nil
}
sources, emitted := typescriptEmitExtensions[strings.ToLower(pathpkg.Ext(path))]
if !emitted {
return nil
}
stem := strings.TrimSuffix(path, pathpkg.Ext(path))
candidates := make([]string, 0, len(sources))
for _, ext := range sources {
candidates = append(candidates, stem+ext)
}
return candidates
}

// trySuffixMatch finds files where the path ends with the normalized import
func trySuffixMatch(normalized string, idx *fileIndex, sourceLanguage string) []string {
for _, ext := range resolverExtensions {
Expand Down
87 changes: 87 additions & 0 deletions scanner/tsesm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package scanner

import (
"context"
"reflect"
"sort"
"testing"
)

// Under ESM and NodeNext, TypeScript requires the specifier to name the
// emitted JavaScript file, so "./helper.js" appears in a project whose only
// on-disk counterpart is helper.ts. The specifier already carries an
// extension, so the extension-appending search never matched and most imports
// in such a project were lost.
func TestTypeScriptESMSpecifiersResolve(t *testing.T) {
graph, err := BuildFileGraph(context.Background(), "../testdata/typescript-esm-specifiers", Filters{})
if err != nil {
t.Fatalf("build typescript fixture graph: %v", err)
}

for _, tc := range []struct {
file string
want []string
why string
}{
{"src/helper.ts", []string{"src/a_js_to_ts.ts", "src/d_extensionless.ts"}, "./helper.js and ./helper both reach helper.ts"},
{"src/widget.tsx", []string{"src/b_jsx_to_tsx.ts"}, "./widget.jsx reaches widget.tsx"},
// A real .js file must win over a same-named .ts: the specifier names
// a file that exists, so rewriting it to TypeScript would be a wrong
// edge rather than a missing one.
{"src/real.js", []string{"src/c_real_js_wins.ts"}, "an existing .js beats the .ts counterpart"},
{"src/real.ts", nil, "the .ts counterpart must not steal the edge"},
} {
got := append([]string(nil), graph.Importers[tc.file]...)
sort.Strings(got)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("%s importers = %v, want exactly %v (%s)", tc.file, got, tc.want, tc.why)
}
}
}

// A specifier naming a file that exists in neither form resolves to nothing.
func TestTypeScriptESMMissingSpecifierResolvesToNothing(t *testing.T) {
graph, err := BuildFileGraph(context.Background(), "../testdata/typescript-esm-specifiers", Filters{})
if err != nil {
t.Fatalf("build typescript fixture graph: %v", err)
}
if got := graph.Imports["src/e_missing.ts"]; len(got) != 0 {
t.Fatalf("src/e_missing.ts imports = %v, want none", got)
}
}

func TestTypeScriptSourceCandidates(t *testing.T) {
for _, tc := range []struct {
name string
path string
language string
want []string
}{
{"js maps to ts, tsx and a declaration", "src/helper.js", "typescript",
[]string{"src/helper.ts", "src/helper.tsx", "src/helper.d.ts"}},
{"jsx maps to tsx", "src/widget.jsx", "typescript", []string{"src/widget.tsx", "src/widget.d.ts"}},
{"javascript importers get the same mapping", "src/helper.js", "javascript",
[]string{"src/helper.ts", "src/helper.tsx", "src/helper.d.ts"}},
{"uppercase extension still maps", "src/helper.JS", "typescript",
[]string{"src/helper.ts", "src/helper.tsx", "src/helper.d.ts"}},
// Anything without an emitted extension is the ordinary
// extension-appending search's job, not this one's.
{"extensionless is left alone", "src/helper", "typescript", nil},
{"a ts specifier is left alone", "src/helper.ts", "typescript", nil},
// mts and cts are not recognized source extensions, so mjs and cjs
// deliberately map to nothing rather than to files that can never be
// indexed.
{"mjs has no reachable counterpart", "src/mod.mjs", "typescript", nil},
{"cjs has no reachable counterpart", "src/mod.cjs", "typescript", nil},
// Only JS-family importers use TypeScript emit semantics.
{"go importers are left alone", "src/helper.js", "go", nil},
{"python importers are left alone", "src/helper.js", "python", nil},
} {
t.Run(tc.name, func(t *testing.T) {
got := typescriptSourceCandidates(tc.path, tc.language)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("typescriptSourceCandidates(%q, %q) = %v, want %v", tc.path, tc.language, got, tc.want)
}
})
}
}
2 changes: 2 additions & 0 deletions testdata/typescript-esm-specifiers/src/a_js_to_ts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { helper } from "./helper.js";
export function a(): string { return helper(); }
2 changes: 2 additions & 0 deletions testdata/typescript-esm-specifiers/src/b_jsx_to_tsx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { widget } from "./widget.jsx";
export function b(): string { return widget(); }
2 changes: 2 additions & 0 deletions testdata/typescript-esm-specifiers/src/c_real_js_wins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { real } from "./real.js";
export function c(): string { return real(); }
2 changes: 2 additions & 0 deletions testdata/typescript-esm-specifiers/src/d_extensionless.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { helper } from "./helper";
export function d(): string { return helper(); }
2 changes: 2 additions & 0 deletions testdata/typescript-esm-specifiers/src/e_missing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { nothing } from "./absent.js";
export function e(): string { return nothing; }
1 change: 1 addition & 0 deletions testdata/typescript-esm-specifiers/src/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function helper(): string { return "h"; }
1 change: 1 addition & 0 deletions testdata/typescript-esm-specifiers/src/real.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function real() { return "js"; }
1 change: 1 addition & 0 deletions testdata/typescript-esm-specifiers/src/real.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function real(): string { return "ts"; }
1 change: 1 addition & 0 deletions testdata/typescript-esm-specifiers/src/widget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export function widget(): string { return "w"; }
1 change: 1 addition & 0 deletions testdata/typescript-esm-specifiers/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext"}}
Loading