Skip to content
Draft
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
5 changes: 5 additions & 0 deletions tsc/internal/tsoptions/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import (
"github.com/microsoft/TypeScript/tsc/internal/vfs"
)

// GetExactFileTestWorker exposes getExactFile for direct unit testing.
func GetExactFileTestWorker(m *collections.OrderedMap[string, string], keyMapper func(value string) string, fileName string) (string, bool) {
return getExactFile(m, keyMapper, fileName)
}

func getTestParseCommandLineWorkerDiagnostics(decls []*CommandLineOption) *ParseCommandLineWorkerDiagnostics {
if len(decls) == 0 {
return CompilerOptionsDidYouMeanDiagnostics
Expand Down
38 changes: 32 additions & 6 deletions tsc/internal/tsoptions/tsconfigparsing.go
Original file line number Diff line number Diff line change
Expand Up @@ -1871,7 +1871,10 @@ func handleOptionConfigDirTemplateSubstitution(compilerOptions *core.CompilerOpt
}

// hasFileWithHigherPriorityExtension determines whether a literal or wildcard file has already been included that has a higher extension priority.
// file is the path to the file.
// file is the path to the file. hasFile is called with the exact (case-sensitive) file name that would need to exist to take priority over
// file; it should only return true if a file with that precise name has already been included. This ensures that the extension-priority
// de-duplication (e.g. preferring foo.ts over foo.d.ts or foo.js) never fires across files whose base names differ only by case, since
// those are genuinely distinct files, not an input/output pair, even on a case-insensitive file system.
func hasFileWithHigherPriorityExtension(file string, extensions [][]string, hasFile func(fileName string) bool) bool {
var extensionGroup []string
for _, group := range extensions {
Expand Down Expand Up @@ -1902,8 +1905,21 @@ func hasFileWithHigherPriorityExtension(file string, extensions [][]string, hasF
return false
}

// getExactFile looks up fileName in m using keyMapper's (possibly case-folding) canonical key, but
// only returns a match if the entry found actually has that exact (case-sensitive) file name. This
// prevents a canonical-key collision on case-insensitive file systems from being mistaken for a
// match between two files whose base names merely differ in case.
func getExactFile(m *collections.OrderedMap[string, string], keyMapper func(value string) string, fileName string) (string, bool) {
canonicalFileName := keyMapper(fileName)
if existing, ok := m.Get(canonicalFileName); ok && existing == fileName {
return canonicalFileName, true
}
return "", false
}

// Removes files included via wildcard expansion with a lower extension priority that have already been included.
// file is the path to the file.
// file is the path to the file. Only removes an entry if its exact (case-sensitive) file name matches the lower-priority
// name derived from file, so that files whose base names differ only by case are never treated as an input/output pair.
func removeWildcardFilesWithLowerPriorityExtension(file string, wildcardFiles *collections.OrderedMap[string, string], extensions [][]string, keyMapper func(value string) string) {
var extensionGroup []string
for _, group := range extensions {
Expand All @@ -1919,8 +1935,10 @@ func removeWildcardFilesWithLowerPriorityExtension(file string, wildcardFiles *c
if tspath.FileExtensionIs(file, ext) {
return
}
lowerPriorityPath := keyMapper(tspath.ChangeExtension(file, ext))
wildcardFiles.Delete(lowerPriorityPath)
lowerPriorityFile := tspath.ChangeExtension(file, ext)
if canonicalFileName, ok := getExactFile(wildcardFiles, keyMapper, lowerPriorityFile); ok {
wildcardFiles.Delete(canonicalFileName)
}
}
}

Expand Down Expand Up @@ -1993,9 +2011,17 @@ func getFileNamesFromConfigSpecs(
// This handles cases where we may encounter both <file>.ts and
// <file>.d.ts (or <file>.js if "allowJs" is enabled) in the same
// directory when they are compilation outputs.
//
// The candidate file name must match exactly (case-sensitively) so that
// this de-duplication never conflates two files whose base names differ
// only by case on a case-insensitive file system (e.g. "foo.ts" and
// "Foo.tsx" are different files, not an input/output pair).
if hasFileWithHigherPriorityExtension(file, supportedExtensions, func(fileName string) bool {
canonicalFileName := keyMappper(fileName)
return literalFileMap.Has(canonicalFileName) || wildcardFileMap.Has(canonicalFileName)
if _, ok := getExactFile(&literalFileMap, keyMappper, fileName); ok {
return true
}
_, ok := getExactFile(&wildcardFileMap, keyMappper, fileName)
return ok
}) {
continue
}
Expand Down
106 changes: 106 additions & 0 deletions tsc/internal/tsoptions/tsconfigparsing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,112 @@ func TestParseJsonConfigFileContentDefaultsCompileOnSaveToFalse(t *testing.T) {
assert.Equal(t, *parsed.CompileOnSave, false)
}

// TestGetExactFile verifies the contract of the getExactFile helper directly: a lookup only
// succeeds if the entry found via the (possibly case-folding) canonical key is an exact,
// case-sensitive match for the requested file name.
func TestGetExactFile(t *testing.T) {
t.Parallel()

caseInsensitiveKeyMapper := func(value string) string {
return tspath.GetCanonicalFileName(value, false /*useCaseSensitiveFileNames*/)
}
caseSensitiveKeyMapper := func(value string) string {
return tspath.GetCanonicalFileName(value, true /*useCaseSensitiveFileNames*/)
}

t.Run("exact match is found", func(t *testing.T) {
t.Parallel()
var m collections.OrderedMap[string, string]
m.Set(caseInsensitiveKeyMapper("/project/src/brand.ts"), "/project/src/brand.ts")
key, ok := tsoptions.GetExactFileTestWorker(&m, caseInsensitiveKeyMapper, "/project/src/brand.ts")
assert.Assert(t, ok)
assert.Equal(t, key, caseInsensitiveKeyMapper("/project/src/brand.ts"))
})

t.Run("canonical key collision without exact match is rejected", func(t *testing.T) {
t.Parallel()
var m collections.OrderedMap[string, string]
m.Set(caseInsensitiveKeyMapper("/project/src/brand.ts"), "/project/src/brand.ts")
_, ok := tsoptions.GetExactFileTestWorker(&m, caseInsensitiveKeyMapper, "/project/src/Brand.ts")
assert.Assert(t, !ok)
})

t.Run("no entry for canonical key", func(t *testing.T) {
t.Parallel()
var m collections.OrderedMap[string, string]
_, ok := tsoptions.GetExactFileTestWorker(&m, caseInsensitiveKeyMapper, "/project/src/brand.ts")
assert.Assert(t, !ok)
})

t.Run("case sensitive key mapper never collides", func(t *testing.T) {
t.Parallel()
var m collections.OrderedMap[string, string]
m.Set(caseSensitiveKeyMapper("/project/src/brand.ts"), "/project/src/brand.ts")
_, ok := tsoptions.GetExactFileTestWorker(&m, caseSensitiveKeyMapper, "/project/src/Brand.ts")
assert.Assert(t, !ok)
})
}

// TestGetFileNamesFromConfigSpecsCaseInsensitiveBaseNameMismatch verifies that wildcard "include"
// expansion never drops a file just because its base name case-insensitively collides with another,
// differently-cased, unrelated file's base name once its extension is changed to look for a
// higher-priority sibling (e.g. "Brand.tsx" should not be treated as an output of "brand.ts").
// See https://github.com/microsoft/TypeScript/issues/64098.
func TestGetFileNamesFromConfigSpecsCaseInsensitiveBaseNameMismatch(t *testing.T) {
t.Parallel()

host := tsoptionstest.NewVFSParseConfigHost(map[string]string{
"/project/src/brand.ts": `export const brand = "ok";`,
"/project/src/Brand.tsx": `export const oops: number = 1;`,
}, "/project", false /*useCaseSensitiveFileNames*/)

parsed := tsoptions.ParseJsonConfigFileContent(
map[string]any{
"compilerOptions": map[string]any{"jsx": "react-jsx"},
"include": []any{"src/**/*"},
},
host,
"/project",
nil,
"/project/tsconfig.json",
nil, /*resolutionStack*/
nil, /*extendedConfigCache*/
)
assert.Equal(t, len(parsed.Errors), 0)
fileNames := slices.Clone(parsed.FileNames())
slices.Sort(fileNames)
assert.DeepEqual(t, fileNames, []string{"/project/src/Brand.tsx", "/project/src/brand.ts"})
}

// TestGetFileNamesFromConfigSpecsSameCaseExtensionPriorityStillApplies verifies that the fix for
// case-insensitive base name mismatches does not regress the legitimate extension-priority
// de-duplication for files that share the exact same base name (e.g. a ".ts" source file should
// still win over its own ".d.ts" and ".js" compilation outputs).
func TestGetFileNamesFromConfigSpecsSameCaseExtensionPriorityStillApplies(t *testing.T) {
t.Parallel()

host := tsoptionstest.NewVFSParseConfigHost(map[string]string{
"/project/src/foo.ts": `export const foo = 1;`,
"/project/src/foo.d.ts": `export declare const foo: number;`,
"/project/src/foo.js": `exports.foo = 1;`,
}, "/project", false /*useCaseSensitiveFileNames*/)

parsed := tsoptions.ParseJsonConfigFileContent(
map[string]any{
"compilerOptions": map[string]any{"allowJs": true},
"include": []any{"src/**/*"},
},
host,
"/project",
nil,
"/project/tsconfig.json",
nil, /*resolutionStack*/
nil, /*extendedConfigCache*/
)
assert.Equal(t, len(parsed.Errors), 0)
assert.DeepEqual(t, parsed.FileNames(), []string{"/project/src/foo.ts"})
}

func getParsedWithJsonApi(config testConfig, host tsoptions.ParseConfigHost, basePath string) *tsoptions.ParsedCommandLine {
configFileName := tspath.GetNormalizedAbsolutePath(config.configFileName, basePath)
path := tspath.ToPath(config.configFileName, basePath, host.FS().UseCaseSensitiveFileNames())
Expand Down