From 3e47512984d34a3c780519389d259398ad3cd57d Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:56:15 -0500 Subject: [PATCH 1/8] add command-line config apis --- _packages/native-preview/src/api/async/api.ts | 24 ++- .../native-preview/src/api/async/types.ts | 30 +-- _packages/native-preview/src/api/proto.ts | 45 ++++ _packages/native-preview/src/api/sync/api.ts | 24 ++- .../native-preview/src/api/sync/types.ts | 30 +-- .../native-preview/test/async/api.test.ts | 193 ++++++++++++++++++ .../native-preview/test/sync/api.test.ts | 193 ++++++++++++++++++ internal/api/proto.go | 162 +++++++++++++++ internal/api/proto_test.go | 18 ++ internal/api/session.go | 63 ++++++ internal/tsoptions/commandlineparser.go | 37 +++- internal/tsoptions/commandlineparser_test.go | 28 +++ internal/tsoptions/declswatch.go | 2 + internal/tsoptions/tsconfigparsing.go | 152 +++++++++++--- internal/tsoptions/tsconfigparsing_test.go | 93 +++++++++ ...ow-TSConfig-with-compileOnSave-and-more.js | 3 +- 16 files changed, 1012 insertions(+), 85 deletions(-) diff --git a/_packages/native-preview/src/api/async/api.ts b/_packages/native-preview/src/api/async/api.ts index 77e19bf93f9..844bb334614 100644 --- a/_packages/native-preview/src/api/async/api.ts +++ b/_packages/native-preview/src/api/async/api.ts @@ -60,6 +60,7 @@ import type { ProfileResult, ProjectReference, ProjectResponse, + ReadConfigFileResult, SignatureResponse, SourceFileMetadata, SymbolResponse, @@ -69,6 +70,7 @@ import type { TypeResponse, UpdateSnapshotParams, UpdateSnapshotResponse, + WatchOptions, } from "../proto.ts"; import { resolveFileName, @@ -129,7 +131,7 @@ import type { export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypePredicateKind }; -export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType }; +export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, ReadConfigFileResult, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType, WatchOptions }; interface EmitOutputResponse { readonly emitSkipped: boolean; @@ -177,6 +179,26 @@ export class API { return this.client.apiRequest("parseConfigFile", { file }); } + async parseCommandLine(commandLine: readonly string[]): Promise { + await this.ensureInitialized(); + return this.client.apiRequest("parseCommandLine", { commandLine }); + } + + async readConfigFile(file: DocumentIdentifier): Promise { + await this.ensureInitialized(); + return this.client.apiRequest("readConfigFile", { file }); + } + + async parseJsonConfigFileContent( + json: any, + options: + | { configDirectory: string; configFileName?: never; } + | { configFileName: DocumentIdentifier; configDirectory?: never; }, + ): Promise { + await this.ensureInitialized(); + return this.client.apiRequest("parseJsonConfigFileContent", { json, ...options }); + } + async updateSnapshot(params?: FromLSP extends true ? LSPUpdateSnapshotParams : UpdateSnapshotParams): Promise { await this.ensureInitialized(); diff --git a/_packages/native-preview/src/api/async/types.ts b/_packages/native-preview/src/api/async/types.ts index 5b5c4e6544e..002de954191 100644 --- a/_packages/native-preview/src/api/async/types.ts +++ b/_packages/native-preview/src/api/async/types.ts @@ -1,15 +1,17 @@ import type { CompletionItemKind } from "#enums/completionItemKind"; -import type { DiagnosticCategory } from "#enums/diagnosticCategory"; import type { ElementFlags } from "#enums/elementFlags"; import type { ObjectFlags } from "#enums/objectFlags"; import type { TypeFlags } from "#enums/typeFlags"; import type { TypePredicateKind } from "#enums/typePredicateKind"; +import type { Diagnostic } from "../proto.ts"; import type { NodeHandle, Signature, Symbol, } from "./api.ts"; +export type { Diagnostic } from "../proto.ts"; + /** * A TypeScript type. * @@ -361,32 +363,6 @@ export interface CompletionInfo { readonly entries: readonly CompletionEntry[]; } -/** - * A diagnostic message from the TypeScript compiler. - */ -export interface Diagnostic { - /** File name of the source file this diagnostic belongs to, if any */ - readonly fileName?: string | undefined; - /** Start position of the diagnostic */ - readonly pos: number; - /** End position of the diagnostic */ - readonly end: number; - /** Diagnostic error code */ - readonly code: number; - /** Diagnostic category (error, warning, suggestion, message) */ - readonly category: DiagnosticCategory; - /** Localized diagnostic message text */ - readonly text: string; - /** Whether this diagnostic highlights unnecessary code */ - readonly reportsUnnecessary?: boolean | undefined; - /** Whether this diagnostic highlights deprecated code */ - readonly reportsDeprecated?: boolean | undefined; - /** Chained diagnostic messages */ - readonly messageChain?: readonly Diagnostic[] | undefined; - /** Related diagnostic information */ - readonly relatedInformation?: readonly Diagnostic[] | undefined; -} - export interface EmitOutputFile { readonly text: string; readonly sourceFileName?: string | undefined; diff --git a/_packages/native-preview/src/api/proto.ts b/_packages/native-preview/src/api/proto.ts index cf947a35ef8..2c738d0fb14 100644 --- a/_packages/native-preview/src/api/proto.ts +++ b/_packages/native-preview/src/api/proto.ts @@ -1,5 +1,6 @@ import type { CheckFlags } from "#enums/checkFlags"; import type { CompletionItemKind } from "#enums/completionItemKind"; +import type { DiagnosticCategory } from "#enums/diagnosticCategory"; import type { ModuleKind } from "#enums/moduleKind"; import type { __String, @@ -97,12 +98,56 @@ export interface ProjectReference { circular?: boolean; } +/** + * A diagnostic message from the TypeScript compiler. + */ +export interface Diagnostic { + /** File name of the source file this diagnostic belongs to, if any */ + readonly fileName?: string | undefined; + /** Start position of the diagnostic */ + readonly pos: number; + /** End position of the diagnostic */ + readonly end: number; + /** Diagnostic error code */ + readonly code: number; + /** Diagnostic category (error, warning, suggestion, message) */ + readonly category: DiagnosticCategory; + /** Localized diagnostic message text */ + readonly text: string; + /** Whether this diagnostic highlights unnecessary code */ + readonly reportsUnnecessary?: boolean | undefined; + /** Whether this diagnostic highlights deprecated code */ + readonly reportsDeprecated?: boolean | undefined; + /** Chained diagnostic messages */ + readonly messageChain?: readonly Diagnostic[] | undefined; + /** Related diagnostic information */ + readonly relatedInformation?: readonly Diagnostic[] | undefined; +} + +export interface WatchOptions { + watchInterval?: number; + watchFile?: number; + watchDirectory?: number; + fallbackPolling?: number; + synchronousWatchDirectory?: boolean; + excludeDirectories?: string[]; + excludeFiles?: string[]; +} + export interface ParsedCommandLine { options: CompilerOptions; fileNames: string[]; + watchOptions?: WatchOptions; projectReferences?: ProjectReference[]; typeAcquisition?: TypeAcquisition; compileOnSave?: boolean; + raw?: any; + errors: readonly Diagnostic[]; +} + +export interface ReadConfigFileResult { + config: any; + error?: Diagnostic; } export interface LSPUpdateSnapshotParams { diff --git a/_packages/native-preview/src/api/sync/api.ts b/_packages/native-preview/src/api/sync/api.ts index 447656f2a57..43f99286480 100644 --- a/_packages/native-preview/src/api/sync/api.ts +++ b/_packages/native-preview/src/api/sync/api.ts @@ -68,6 +68,7 @@ import type { ProfileResult, ProjectReference, ProjectResponse, + ReadConfigFileResult, SignatureResponse, SourceFileMetadata, SymbolResponse, @@ -77,6 +78,7 @@ import type { TypeResponse, UpdateSnapshotParams, UpdateSnapshotResponse, + WatchOptions, } from "../proto.ts"; import { resolveFileName, @@ -137,7 +139,7 @@ import type { export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypePredicateKind }; -export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType }; +export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, ReadConfigFileResult, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType, WatchOptions }; interface EmitOutputResponse { readonly emitSkipped: boolean; @@ -185,6 +187,26 @@ export class API { return this.client.apiRequest("parseConfigFile", { file }); } + parseCommandLine(commandLine: readonly string[]): ParsedCommandLine { + this.ensureInitialized(); + return this.client.apiRequest("parseCommandLine", { commandLine }); + } + + readConfigFile(file: DocumentIdentifier): ReadConfigFileResult { + this.ensureInitialized(); + return this.client.apiRequest("readConfigFile", { file }); + } + + parseJsonConfigFileContent( + json: any, + options: + | { configDirectory: string; configFileName?: never; } + | { configFileName: DocumentIdentifier; configDirectory?: never; }, + ): ParsedCommandLine { + this.ensureInitialized(); + return this.client.apiRequest("parseJsonConfigFileContent", { json, ...options }); + } + updateSnapshot(params?: FromLSP extends true ? LSPUpdateSnapshotParams : UpdateSnapshotParams): Snapshot { this.ensureInitialized(); diff --git a/_packages/native-preview/src/api/sync/types.ts b/_packages/native-preview/src/api/sync/types.ts index 36c3658daaf..2c1c26d393c 100644 --- a/_packages/native-preview/src/api/sync/types.ts +++ b/_packages/native-preview/src/api/sync/types.ts @@ -7,17 +7,19 @@ // Regenerate: npm run generate (from _packages/native-preview) // import type { CompletionItemKind } from "#enums/completionItemKind"; -import type { DiagnosticCategory } from "#enums/diagnosticCategory"; import type { ElementFlags } from "#enums/elementFlags"; import type { ObjectFlags } from "#enums/objectFlags"; import type { TypeFlags } from "#enums/typeFlags"; import type { TypePredicateKind } from "#enums/typePredicateKind"; +import type { Diagnostic } from "../proto.ts"; import type { NodeHandle, Signature, Symbol, } from "./api.ts"; +export type { Diagnostic } from "../proto.ts"; + /** * A TypeScript type. * @@ -369,32 +371,6 @@ export interface CompletionInfo { readonly entries: readonly CompletionEntry[]; } -/** - * A diagnostic message from the TypeScript compiler. - */ -export interface Diagnostic { - /** File name of the source file this diagnostic belongs to, if any */ - readonly fileName?: string | undefined; - /** Start position of the diagnostic */ - readonly pos: number; - /** End position of the diagnostic */ - readonly end: number; - /** Diagnostic error code */ - readonly code: number; - /** Diagnostic category (error, warning, suggestion, message) */ - readonly category: DiagnosticCategory; - /** Localized diagnostic message text */ - readonly text: string; - /** Whether this diagnostic highlights unnecessary code */ - readonly reportsUnnecessary?: boolean | undefined; - /** Whether this diagnostic highlights deprecated code */ - readonly reportsDeprecated?: boolean | undefined; - /** Chained diagnostic messages */ - readonly messageChain?: readonly Diagnostic[] | undefined; - /** Related diagnostic information */ - readonly relatedInformation?: readonly Diagnostic[] | undefined; -} - export interface EmitOutputFile { readonly text: string; readonly sourceFileName?: string | undefined; diff --git a/_packages/native-preview/test/async/api.test.ts b/_packages/native-preview/test/async/api.test.ts index e084ebc2731..d5b308b4123 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -89,6 +89,162 @@ const defaultFiles = { }; describe("API", () => { + test("parseCommandLine", async () => { + const api = spawnAPI(); + try { + const commandLine = await api.parseCommandLine([ + "--strict", + "--watchFile", + "useFsEvents", + "--outDir", + "dist", + "/src/index.ts", + ]); + assert.deepEqual(commandLine.fileNames, ["/src/index.ts"]); + assert.equal(commandLine.options.strict, true); + assert.equal(commandLine.options.outDir, "dist"); + assert.deepEqual(commandLine.watchOptions, { watchFile: 4 }); + assert.deepEqual(commandLine.raw, { + strict: true, + watchFile: 4, + outDir: "dist", + }); + assert.deepEqual(commandLine.errors, []); + } + finally { + await api.close(); + } + }); + + test("parseCommandLine reports diagnostics", async () => { + const api = spawnAPI(); + try { + const commandLine = await api.parseCommandLine(["--notAnOption"]); + assert.deepEqual(commandLine.fileNames, []); + assert.equal(commandLine.watchOptions, undefined); + assert.equal(commandLine.errors.length, 1); + assert.equal(commandLine.errors[0].code, 5023); + } + finally { + await api.close(); + } + }); + + test("readConfigFile", async () => { + const api = spawnAPI({ + "/tsconfig.json": `{ + // Comments and trailing commas are supported. + "compilerOptions": { "strict": true, }, + }`, + }); + try { + const result = await api.readConfigFile("/tsconfig.json"); + assert.deepEqual(result, { + config: { compilerOptions: { strict: true } }, + }); + } + finally { + await api.close(); + } + }); + + test("readConfigFile reports read and parse errors", async () => { + const api = spawnAPI({ + "/invalid.json": `{ "compilerOptions": { "strict": true,, } }`, + }); + try { + const invalid = await api.readConfigFile("/invalid.json"); + assert.deepEqual(invalid.config, { compilerOptions: { strict: true } }); + assert.ok(invalid.error); + + const missing = await api.readConfigFile("/missing.json"); + assert.deepEqual(missing.config, {}); + assert.equal(missing.error?.code, 5083); + } + finally { + await api.close(); + } + }); + + test("parseJsonConfigFileContent with configDirectory", async () => { + const api = spawnAPI(); + try { + const config = await api.parseJsonConfigFileContent( + { + compilerOptions: { strict: true }, + files: ["index.ts"], + }, + { configDirectory: "/src" }, + ); + assert.deepEqual(config.fileNames, ["/src/index.ts"]); + assert.equal(config.options.strict, true); + assert.equal("configFilePath" in config.options, false); + assert.deepEqual(config.errors, []); + } + finally { + await api.close(); + } + }); + + test("parseJsonConfigFileContent accepts non-object JSON", async () => { + const api = spawnAPI(); + try { + const config = await api.parseJsonConfigFileContent(null, { configDirectory: "/src" }); + assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual(config.errors, []); + } + finally { + await api.close(); + } + }); + + test("parseJsonConfigFileContent with configFileName", async () => { + const api = spawnAPI(); + try { + const config = await api.parseJsonConfigFileContent( + { + compilerOptions: { strict: true }, + files: ["index.ts"], + }, + { configFileName: "/src/tsconfig.json" }, + ); + assert.deepEqual(config.fileNames, ["/src/index.ts"]); + assert.equal(config.options.strict, true); + assert.equal((config.options as Record).configFilePath, "/src/tsconfig.json"); + assert.deepEqual(config.errors, []); + } + finally { + await api.close(); + } + }); + + test("parseJsonConfigFileContent preserves raw config and parses watch options", async () => { + const api = spawnAPI(); + try { + const input = { + compileOnSave: true, + customSetting: { enabled: true }, + files: ["index.ts"], + watchOptions: { + watchFile: "useFsEvents", + watchInterval: 250, + synchronousWatchDirectory: false, + }, + }; + const config = await api.parseJsonConfigFileContent(input, { configDirectory: "/src" }); + assert.deepEqual(config.raw, input); + assert.equal(config.compileOnSave, true); + assert.deepEqual(config.watchOptions, { + watchInterval: 250, + watchFile: 4, + synchronousWatchDirectory: false, + }); + } + finally { + await api.close(); + } + }); + test("parseConfigFile", async () => { const api = spawnAPI(); try { @@ -122,6 +278,43 @@ describe("API", () => { } }); + test("parseConfigFile parses watch options and preserves raw config", async () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compileOnSave: true, + customSetting: { enabled: true }, + files: ["/src/index.ts"], + watchOptions: { + watchDirectory: "fixedPollingInterval", + fallbackPolling: "dynamicPriority", + excludeFiles: ["${configDir}/generated.ts"], + }, + }), + }); + try { + const config = await api.parseConfigFile("/tsconfig.json"); + assert.deepEqual(config.watchOptions, { + watchDirectory: 1, + fallbackPolling: 2, + excludeFiles: ["/generated.ts"], + }); + assert.equal(config.compileOnSave, true); + assert.deepEqual(config.raw, { + compileOnSave: true, + customSetting: { enabled: true }, + files: ["/src/index.ts"], + watchOptions: { + watchDirectory: "fixedPollingInterval", + fallbackPolling: "dynamicPriority", + excludeFiles: ["${configDir}/generated.ts"], + }, + }); + } + finally { + await api.close(); + } + }); + test("parseConfigFile includes compileOnSave", async () => { const api = spawnAPI({ "/tsconfig.json": JSON.stringify({ compileOnSave: true }), diff --git a/_packages/native-preview/test/sync/api.test.ts b/_packages/native-preview/test/sync/api.test.ts index 0203aa28ffe..4e14f948fd8 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -97,6 +97,162 @@ const defaultFiles = { }; describe("API", () => { + test("parseCommandLine", () => { + const api = spawnAPI(); + try { + const commandLine = api.parseCommandLine([ + "--strict", + "--watchFile", + "useFsEvents", + "--outDir", + "dist", + "/src/index.ts", + ]); + assert.deepEqual(commandLine.fileNames, ["/src/index.ts"]); + assert.equal(commandLine.options.strict, true); + assert.equal(commandLine.options.outDir, "dist"); + assert.deepEqual(commandLine.watchOptions, { watchFile: 4 }); + assert.deepEqual(commandLine.raw, { + strict: true, + watchFile: 4, + outDir: "dist", + }); + assert.deepEqual(commandLine.errors, []); + } + finally { + api.close(); + } + }); + + test("parseCommandLine reports diagnostics", () => { + const api = spawnAPI(); + try { + const commandLine = api.parseCommandLine(["--notAnOption"]); + assert.deepEqual(commandLine.fileNames, []); + assert.equal(commandLine.watchOptions, undefined); + assert.equal(commandLine.errors.length, 1); + assert.equal(commandLine.errors[0].code, 5023); + } + finally { + api.close(); + } + }); + + test("readConfigFile", () => { + const api = spawnAPI({ + "/tsconfig.json": `{ + // Comments and trailing commas are supported. + "compilerOptions": { "strict": true, }, + }`, + }); + try { + const result = api.readConfigFile("/tsconfig.json"); + assert.deepEqual(result, { + config: { compilerOptions: { strict: true } }, + }); + } + finally { + api.close(); + } + }); + + test("readConfigFile reports read and parse errors", () => { + const api = spawnAPI({ + "/invalid.json": `{ "compilerOptions": { "strict": true,, } }`, + }); + try { + const invalid = api.readConfigFile("/invalid.json"); + assert.deepEqual(invalid.config, { compilerOptions: { strict: true } }); + assert.ok(invalid.error); + + const missing = api.readConfigFile("/missing.json"); + assert.deepEqual(missing.config, {}); + assert.equal(missing.error?.code, 5083); + } + finally { + api.close(); + } + }); + + test("parseJsonConfigFileContent with configDirectory", () => { + const api = spawnAPI(); + try { + const config = api.parseJsonConfigFileContent( + { + compilerOptions: { strict: true }, + files: ["index.ts"], + }, + { configDirectory: "/src" }, + ); + assert.deepEqual(config.fileNames, ["/src/index.ts"]); + assert.equal(config.options.strict, true); + assert.equal("configFilePath" in config.options, false); + assert.deepEqual(config.errors, []); + } + finally { + api.close(); + } + }); + + test("parseJsonConfigFileContent accepts non-object JSON", () => { + const api = spawnAPI(); + try { + const config = api.parseJsonConfigFileContent(null, { configDirectory: "/src" }); + assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual(config.errors, []); + } + finally { + api.close(); + } + }); + + test("parseJsonConfigFileContent with configFileName", () => { + const api = spawnAPI(); + try { + const config = api.parseJsonConfigFileContent( + { + compilerOptions: { strict: true }, + files: ["index.ts"], + }, + { configFileName: "/src/tsconfig.json" }, + ); + assert.deepEqual(config.fileNames, ["/src/index.ts"]); + assert.equal(config.options.strict, true); + assert.equal((config.options as Record).configFilePath, "/src/tsconfig.json"); + assert.deepEqual(config.errors, []); + } + finally { + api.close(); + } + }); + + test("parseJsonConfigFileContent preserves raw config and parses watch options", () => { + const api = spawnAPI(); + try { + const input = { + compileOnSave: true, + customSetting: { enabled: true }, + files: ["index.ts"], + watchOptions: { + watchFile: "useFsEvents", + watchInterval: 250, + synchronousWatchDirectory: false, + }, + }; + const config = api.parseJsonConfigFileContent(input, { configDirectory: "/src" }); + assert.deepEqual(config.raw, input); + assert.equal(config.compileOnSave, true); + assert.deepEqual(config.watchOptions, { + watchInterval: 250, + watchFile: 4, + synchronousWatchDirectory: false, + }); + } + finally { + api.close(); + } + }); + test("parseConfigFile", () => { const api = spawnAPI(); try { @@ -130,6 +286,43 @@ describe("API", () => { } }); + test("parseConfigFile parses watch options and preserves raw config", () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compileOnSave: true, + customSetting: { enabled: true }, + files: ["/src/index.ts"], + watchOptions: { + watchDirectory: "fixedPollingInterval", + fallbackPolling: "dynamicPriority", + excludeFiles: ["${configDir}/generated.ts"], + }, + }), + }); + try { + const config = api.parseConfigFile("/tsconfig.json"); + assert.deepEqual(config.watchOptions, { + watchDirectory: 1, + fallbackPolling: 2, + excludeFiles: ["/generated.ts"], + }); + assert.equal(config.compileOnSave, true); + assert.deepEqual(config.raw, { + compileOnSave: true, + customSetting: { enabled: true }, + files: ["/src/index.ts"], + watchOptions: { + watchDirectory: "fixedPollingInterval", + fallbackPolling: "dynamicPriority", + excludeFiles: ["${configDir}/generated.ts"], + }, + }); + } + finally { + api.close(); + } + }); + test("parseConfigFile includes compileOnSave", () => { const api = spawnAPI({ "/tsconfig.json": JSON.stringify({ compileOnSave: true }), diff --git a/internal/api/proto.go b/internal/api/proto.go index fd064deec70..8d5effffcba 100644 --- a/internal/api/proto.go +++ b/internal/api/proto.go @@ -72,6 +72,9 @@ const ( MethodInitialize Method = "initialize" MethodUpdateSnapshot Method = "updateSnapshot" MethodUpdateTemporarySnapshot Method = "updateTemporarySnapshot" + MethodParseCommandLine Method = "parseCommandLine" + MethodReadConfigFile Method = "readConfigFile" + MethodParseJsonConfigFile Method = "parseJsonConfigFileContent" MethodParseConfigFile Method = "parseConfigFile" MethodGetDefaultProjectForFile Method = "getDefaultProjectForFile" MethodGetSymbolAtPosition Method = "getSymbolAtPosition" @@ -389,6 +392,9 @@ var unmarshalers = map[Method]func([]byte) (any, error){ MethodInitialize: noParams, MethodUpdateSnapshot: unmarshallerFor[UpdateSnapshotParams], MethodUpdateTemporarySnapshot: unmarshallerFor[UpdateTemporarySnapshotParams], + MethodParseCommandLine: unmarshallerFor[ParseCommandLineParams], + MethodReadConfigFile: unmarshallerFor[ReadConfigFileParams], + MethodParseJsonConfigFile: unmarshallerFor[ParseJsonConfigFileContentParams], MethodParseConfigFile: unmarshallerFor[ParseConfigFileParams], MethodGetDefaultProjectForFile: unmarshallerFor[GetDefaultProjectForFileParams], MethodGetSourceFile: unmarshallerFor[GetSourceFileParams], @@ -519,6 +525,75 @@ type ParseConfigFileParams struct { File DocumentIdentifier `json:"file"` } +type ParseCommandLineParams struct { + CommandLine []string `json:"commandLine"` +} + +type ReadConfigFileParams struct { + File DocumentIdentifier `json:"file"` +} + +type OrderedJSONValue struct { + Value any +} + +var _ json.UnmarshalerFrom = (*OrderedJSONValue)(nil) + +func (v *OrderedJSONValue) UnmarshalJSONFrom(dec *json.Decoder) error { + switch dec.PeekKind() { + case 'n': + _, err := dec.ReadToken() + v.Value = nil + return err + case '{': + if _, err := dec.ReadToken(); err != nil { + return err + } + object := &collections.OrderedMap[string, any]{} + for dec.PeekKind() != '}' { + var key string + if err := json.UnmarshalDecode(dec, &key); err != nil { + return err + } + var child OrderedJSONValue + if err := json.UnmarshalDecode(dec, &child); err != nil { + return err + } + object.Set(key, child.Value) + } + if _, err := dec.ReadToken(); err != nil { + return err + } + v.Value = object + return nil + case '[': + if _, err := dec.ReadToken(); err != nil { + return err + } + var array []any + for dec.PeekKind() != ']' { + var child OrderedJSONValue + if err := json.UnmarshalDecode(dec, &child); err != nil { + return err + } + array = append(array, child.Value) + } + if _, err := dec.ReadToken(); err != nil { + return err + } + v.Value = array + return nil + default: + return json.UnmarshalDecode(dec, &v.Value) + } +} + +type ParseJsonConfigFileContentParams struct { + JSON OrderedJSONValue `json:"json"` + ConfigDirectory *string `json:"configDirectory,omitempty"` + ConfigFileName *DocumentIdentifier `json:"configFileName,omitempty"` +} + // ReleaseParams are the parameters for the release method. type ReleaseParams struct { Snapshot SnapshotID `json:"snapshot"` @@ -535,9 +610,27 @@ type ProfileResult struct { type ConfigFileResponse struct { FileNames []string `json:"fileNames"` Options *core.CompilerOptions `json:"options"` + WatchOptions *WatchOptionsResponse `json:"watchOptions,omitempty"` ProjectReferences []*core.ProjectReference `json:"projectReferences,omitempty"` TypeAcquisition *core.TypeAcquisition `json:"typeAcquisition,omitempty"` CompileOnSave *bool `json:"compileOnSave,omitempty"` + Raw any `json:"raw,omitempty"` + Errors []*DiagnosticResponse `json:"errors"` +} + +type WatchOptionsResponse struct { + Interval *int `json:"watchInterval,omitempty"` + FileKind *core.WatchFileKind `json:"watchFile,omitempty"` + DirectoryKind *core.WatchDirectoryKind `json:"watchDirectory,omitempty"` + FallbackPolling *core.PollingKind `json:"fallbackPolling,omitempty"` + SyncWatchDir *bool `json:"synchronousWatchDirectory,omitempty"` + ExcludeDir []string `json:"excludeDirectories,omitempty"` + ExcludeFiles []string `json:"excludeFiles,omitempty"` +} + +type ReadConfigFileResponse struct { + Config any `json:"config"` + Error *DiagnosticResponse `json:"error,omitempty"` } type GetDefaultProjectForFileParams struct { @@ -566,13 +659,82 @@ func NewConfigFileResponse(parsedCommandLine *tsoptions.ParsedCommandLine) *Conf } } compilerOptions := parsedCommandLine.CompilerOptions() + errors := NewDiagnosticResponses(parsedCommandLine.Errors) + if errors == nil { + errors = []*DiagnosticResponse{} + } return &ConfigFileResponse{ FileNames: parsedCommandLine.FileNames(), Options: compilerOptions, + WatchOptions: NewWatchOptionsResponse(parsedCommandLine.ParsedConfig.WatchOptions), ProjectReferences: parsedCommandLine.ProjectReferences(), TypeAcquisition: parsedCommandLine.TypeAcquisition(), CompileOnSave: compileOnSave, + Raw: toProtocolJSONValue(parsedCommandLine.Raw), + Errors: errors, + } +} + +func toProtocolJSONValue(value any) any { + switch value := value.(type) { + case core.WatchFileKind: + return int(value) - 1 + case core.WatchDirectoryKind: + return int(value) - 1 + case core.PollingKind: + return int(value) - 1 + case *collections.OrderedMap[string, any]: + result := collections.NewOrderedMapWithSizeHint[string, any](value.Size()) + for key, child := range value.Entries() { + result.Set(key, toProtocolJSONValue(child)) + } + return result + case []any: + result := make([]any, len(value)) + for i, child := range value { + result[i] = toProtocolJSONValue(child) + } + return result + default: + return value + } +} + +func NewWatchOptionsResponse(options *core.WatchOptions) *WatchOptionsResponse { + if options == nil { + return nil + } + response := &WatchOptionsResponse{ + Interval: options.Interval, + ExcludeDir: options.ExcludeDir, + ExcludeFiles: options.ExcludeFiles, + } + if options.FileKind != core.WatchFileKindNone { + fileKind := options.FileKind - 1 + response.FileKind = &fileKind + } + if options.DirectoryKind != core.WatchDirectoryKindNone { + directoryKind := options.DirectoryKind - 1 + response.DirectoryKind = &directoryKind + } + if options.FallbackPolling != core.PollingKindNone { + fallbackPolling := options.FallbackPolling - 1 + response.FallbackPolling = &fallbackPolling + } + if !options.SyncWatchDir.IsUnknown() { + syncWatchDir := options.SyncWatchDir.IsTrue() + response.SyncWatchDir = &syncWatchDir + } + if response.Interval == nil && + response.FileKind == nil && + response.DirectoryKind == nil && + response.FallbackPolling == nil && + response.SyncWatchDir == nil && + len(response.ExcludeDir) == 0 && + len(response.ExcludeFiles) == 0 { + return nil } + return response } func NewProjectResponse(p *project.Project) *ProjectResponse { diff --git a/internal/api/proto_test.go b/internal/api/proto_test.go index 6e17411e0c0..f4dc4e174c7 100644 --- a/internal/api/proto_test.go +++ b/internal/api/proto_test.go @@ -1,11 +1,13 @@ package api_test import ( + "slices" "strings" "testing" "github.com/microsoft/typescript-go/internal/api" "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/json" @@ -13,6 +15,22 @@ import ( "gotest.tools/v3/assert" ) +func TestOrderedJSONValueUnmarshalJSON(t *testing.T) { + t.Parallel() + + var value api.OrderedJSONValue + err := json.Unmarshal([]byte(`{"z":1,"a":{"y":2,"x":3},"m":[{"b":4,"a":5}]}`), &value) + assert.NilError(t, err) + + root := value.Value.(*collections.OrderedMap[string, any]) + assert.DeepEqual(t, slices.Collect(root.Keys()), []string{"z", "a", "m"}) + nested := root.GetOrZero("a").(*collections.OrderedMap[string, any]) + assert.DeepEqual(t, slices.Collect(nested.Keys()), []string{"y", "x"}) + array := root.GetOrZero("m").([]any) + arrayObject := array[0].(*collections.OrderedMap[string, any]) + assert.DeepEqual(t, slices.Collect(arrayObject.Keys()), []string{"b", "a"}) +} + func TestDocumentIdentifierUnmarshalJSON(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/api/session.go b/internal/api/session.go index 21060a91cbe..978e655304e 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -18,6 +18,7 @@ import ( "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/compiler" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/json" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/ls/autoimport" @@ -587,6 +588,12 @@ func (s *Session) HandleRequest(ctx context.Context, method string, params json. return s.handleUpdateSnapshot(ctx, parsed.(*UpdateSnapshotParams)) case string(MethodUpdateTemporarySnapshot): return s.handleUpdateTemporarySnapshot(ctx, parsed.(*UpdateTemporarySnapshotParams)) + case string(MethodParseCommandLine): + return s.handleParseCommandLine(ctx, parsed.(*ParseCommandLineParams)) + case string(MethodReadConfigFile): + return s.handleReadConfigFile(ctx, parsed.(*ReadConfigFileParams)) + case string(MethodParseJsonConfigFile): + return s.handleParseJsonConfigFileContent(ctx, parsed.(*ParseJsonConfigFileContentParams)) case string(MethodParseConfigFile): return s.handleParseConfigFile(ctx, parsed.(*ParseConfigFileParams)) case string(MethodGetDefaultProjectForFile): @@ -1110,6 +1117,62 @@ func (s *Session) handleGetDefaultProjectForFile(ctx context.Context, params *Ge return NewProjectResponse(proj), nil } +// handleParseCommandLine parses command-line arguments without compiler execution path normalization. +func (s *Session) handleParseCommandLine(ctx context.Context, params *ParseCommandLineParams) (*ConfigFileResponse, error) { + return NewConfigFileResponse(tsoptions.ParseCommandLineWithoutPathNormalization(params.CommandLine, s.projectSession)), nil +} + +// handleReadConfigFile reads and parses a JSON configuration file. +func (s *Session) handleReadConfigFile(ctx context.Context, params *ReadConfigFileParams) (*ReadConfigFileResponse, error) { + configFileName := params.File.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) + configFileContent, ok := s.projectSession.FS().ReadFile(configFileName) + if !ok { + return &ReadConfigFileResponse{ + Config: map[string]any{}, + Error: NewDiagnosticResponse(ast.NewCompilerDiagnostic(diagnostics.Cannot_read_file_0, configFileName)), + }, nil + } + + config, parseErrors := tsoptions.ParseConfigFileTextToJson( + configFileName, + s.toPath(configFileName), + configFileContent, + ) + response := &ReadConfigFileResponse{Config: config} + if len(parseErrors) > 0 { + response.Error = NewDiagnosticResponse(parseErrors[0]) + } + return response, nil +} + +// handleParseJsonConfigFileContent parses an in-memory JSON configuration. +func (s *Session) handleParseJsonConfigFileContent(ctx context.Context, params *ParseJsonConfigFileContentParams) (*ConfigFileResponse, error) { + if (params.ConfigDirectory == nil) == (params.ConfigFileName == nil) { + return nil, fmt.Errorf("%w: exactly one of configDirectory or configFileName is required", ErrClientError) + } + + var basePath string + var configFileName string + if params.ConfigDirectory != nil { + basePath = tspath.GetNormalizedAbsolutePath(*params.ConfigDirectory, s.projectSession.GetCurrentDirectory()) + } else { + configFileName = params.ConfigFileName.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) + basePath = tspath.GetDirectoryPath(configFileName) + } + + parsedCommandLine := tsoptions.ParseJsonConfigFileContent( + params.JSON.Value, + s.projectSession, + basePath, + nil, /*existingOptions*/ + configFileName, + nil, /*resolutionStack*/ + nil, /*extraFileExtensions*/ + nil, /*extendedConfigCache*/ + ) + return NewConfigFileResponse(parsedCommandLine), nil +} + // handleParseConfigFile parses a tsconfig.json file and returns its contents. func (s *Session) handleParseConfigFile(ctx context.Context, params *ParseConfigFileParams) (*ConfigFileResponse, error) { configFileName := params.File.ToAbsoluteFileName(s.projectSession.GetCurrentDirectory()) diff --git a/internal/tsoptions/commandlineparser.go b/internal/tsoptions/commandlineparser.go index 0359f74065b..752d6497236 100644 --- a/internal/tsoptions/commandlineparser.go +++ b/internal/tsoptions/commandlineparser.go @@ -37,19 +37,41 @@ type commandLineParser struct { options *collections.OrderedMap[string, any] fileNames []string errors []*ast.Diagnostic + responseFileStack collections.Set[tspath.Path] } func ParseCommandLine( commandLine []string, host ParseConfigHost, ) *ParsedCommandLine { + return parseCommandLine(commandLine, host, true) +} + +// ParseCommandLineWithoutPathNormalization preserves relative option values for API compatibility. +func ParseCommandLineWithoutPathNormalization( + commandLine []string, + host ParseConfigHost, +) *ParsedCommandLine { + return parseCommandLine(commandLine, host, false) +} + +func parseCommandLine(commandLine []string, host ParseConfigHost, normalizePaths bool) *ParsedCommandLine { if commandLine == nil { commandLine = []string{} } parser := parseCommandLineWorker(CompilerOptionsDidYouMeanDiagnostics, commandLine, host.FS(), host.GetCurrentDirectory()) - optionsWithAbsolutePaths := convertToOptionsWithAbsolutePaths(parser.options.Clone(), CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) - compilerOptions := convertMapToOptions(optionsWithAbsolutePaths, &compilerOptionsParser{&core.CompilerOptions{}}).CompilerOptions - watchOptions := convertMapToOptions(optionsWithAbsolutePaths, &watchOptionsParser{&core.WatchOptions{}}).WatchOptions + options := parser.options + if normalizePaths { + options = convertToOptionsWithAbsolutePaths(options.Clone(), CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) + } + compilerOptions := convertMapToOptions(options, &compilerOptionsParser{&core.CompilerOptions{}}).CompilerOptions + var watchOptions *core.WatchOptions + for key := range options.Keys() { + if WatchNameMap.Get(key) != nil { + watchOptions = convertMapToOptions(options, &watchOptionsParser{&core.WatchOptions{}}).WatchOptions + break + } + } result := NewParsedCommandLine(compilerOptions, parser.fileNames, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), CurrentDirectory: host.GetCurrentDirectory(), @@ -167,6 +189,13 @@ func getInputOptionName(input string) string { func (p *commandLineParser) parseResponseFile(fileName string) { fileName = tspath.GetNormalizedAbsolutePath(fileName, p.currentDirectory) + path := tspath.ToPath(fileName, p.currentDirectory, p.fs.UseCaseSensitiveFileNames()) + if p.responseFileStack.Has(path) { + return + } + p.responseFileStack.Add(path) + defer p.responseFileStack.Delete(path) + fileContents, errors := tryReadFile(fileName, func(fileName string) (string, bool) { if p.fs == nil { return "", false @@ -204,7 +233,7 @@ func (p *commandLineParser) parseResponseFile(fileName string) { p.errors = append(p.errors, ast.NewCompilerDiagnostic(diagnostics.Unterminated_quoted_string_in_response_file_0, fileName)) } } else { - for text[pos] > ' ' { + for pos < textLength && text[pos] > ' ' { pos++ } args = append(args, string(text[start:pos])) diff --git a/internal/tsoptions/commandlineparser_test.go b/internal/tsoptions/commandlineparser_test.go index 474329931f8..709be3e4f00 100644 --- a/internal/tsoptions/commandlineparser_test.go +++ b/internal/tsoptions/commandlineparser_test.go @@ -106,6 +106,34 @@ func TestResponseFileDoesNotPanic(t *testing.T) { }) } +func TestResponseFileParsing(t *testing.T) { + t.Parallel() + + t.Run("final token without trailing whitespace", func(t *testing.T) { + t.Parallel() + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/args.txt": "--strict --outDir dist", + }, "/project", true) + parsed := tsoptions.ParseCommandLine([]string{"@args.txt"}, host) + assert.Equal(t, len(parsed.Errors), 0) + assert.Assert(t, parsed.CompilerOptions().Strict.IsTrue()) + assert.Equal(t, parsed.CompilerOptions().OutDir, "/project/dist") + assert.Assert(t, parsed.ParsedConfig.WatchOptions == nil) + }) + + t.Run("cyclic response files", func(t *testing.T) { + t.Parallel() + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/a.txt": "@/project/b.txt --strict", + "/project/b.txt": "@/project/a.txt --outDir dist", + }, "/project", true) + parsed := tsoptions.ParseCommandLine([]string{"@a.txt"}, host) + assert.Equal(t, len(parsed.Errors), 0) + assert.Assert(t, parsed.CompilerOptions().Strict.IsTrue()) + assert.Equal(t, parsed.CompilerOptions().OutDir, "/project/dist") + }) +} + func TestParseCommandLineTypeRootsRelativePath(t *testing.T) { t.Parallel() diff --git a/internal/tsoptions/declswatch.go b/internal/tsoptions/declswatch.go index b190a6641c5..bdaf1379d66 100644 --- a/internal/tsoptions/declswatch.go +++ b/internal/tsoptions/declswatch.go @@ -86,3 +86,5 @@ var OptionsForWatch = []*CommandLineOption{ Description: diagnostics.Remove_a_list_of_files_from_the_watch_mode_s_processing, }, } + +var CommandLineWatchOptionsMap CommandLineOptionNameMap = commandLineOptionsToMap(OptionsForWatch) diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index 44668c91462..6ed47bf3a6c 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -22,8 +22,8 @@ import ( ) type extendsResult struct { - options *core.CompilerOptions - // watchOptions compiler.WatchOptions + options *core.CompilerOptions + watchOptions *core.WatchOptions watchOptionsCopied bool include []any exclude []any @@ -44,6 +44,13 @@ var compileOnSaveCommandLineOption = &CommandLineOption{ DefaultValueDescription: false, } +var watchOptionsDeclaration = &CommandLineOption{ + Name: "watchOptions", + Kind: CommandLineOptionTypeObject, + ElementOptions: CommandLineWatchOptionsMap, + DefaultValueDescription: nil, +} + var extendsOptionDeclaration = &CommandLineOption{ Name: "extends", Kind: CommandLineOptionTypeListOrElement, @@ -58,7 +65,7 @@ var tsconfigRootOptionsMap = &CommandLineOption{ Kind: CommandLineOptionTypeObject, ElementOptions: commandLineOptionsToMap([]*CommandLineOption{ compilerOptionsDeclaration, - // watchOptionsDeclaration, + watchOptionsDeclaration, typeAcquisitionDeclaration, extendsOptionDeclaration, { @@ -170,9 +177,9 @@ func (e *ExtendedConfigCacheEntry) ExtendedFileNames() []string { } type parsedTsconfig struct { - raw any - options *core.CompilerOptions - // watchOptions *core.WatchOptions + raw any + options *core.CompilerOptions + watchOptions *core.WatchOptions typeAcquisition *core.TypeAcquisition // Note that the case of the config path has not yet been normalized, as no files have been imported into the project yet extendedConfigPath any @@ -186,7 +193,7 @@ func parseOwnConfigOfJsonSourceFile( ) (*parsedTsconfig, []*ast.Diagnostic) { compilerOptions := getDefaultCompilerOptions(configFileName) typeAcquisition := getDefaultTypeAcquisition(configFileName) - // var watchOptions *compiler.WatchOptions + var watchOptions *core.WatchOptions var extendedConfigPath any var rootCompilerOptions []*ast.PropertyName var errors []*ast.Diagnostic @@ -208,6 +215,11 @@ func parseOwnConfigOfJsonSourceFile( switch parentOption.Name { case "compilerOptions": parseDiagnostics = ParseCompilerOptions(option.Name, value, compilerOptions) + case "watchOptions": + if watchOptions == nil { + watchOptions = &core.WatchOptions{} + } + parseDiagnostics = ParseWatchOptions(option.Name, value, watchOptions) case "typeAcquisition": parseDiagnostics = ParseTypeAcquisition(option.Name, value, typeAcquisition) } @@ -244,7 +256,9 @@ func parseOwnConfigOfJsonSourceFile( } } } else if parentOption == tsconfigRootOptionsMap { - if option == extendsOptionDeclaration { + if option == watchOptionsDeclaration && watchOptions == nil { + watchOptions = &core.WatchOptions{} + } else if option == extendsOptionDeclaration { configPath, err := getExtendsConfigPathOrArray(value, host, basePath, configFileName, propertyAssignment, propertyAssignment.Initializer, sourceFile) extendedConfigPath = configPath propertySetErrors = append(propertySetErrors, err...) @@ -277,9 +291,9 @@ func parseOwnConfigOfJsonSourceFile( )) } return &parsedTsconfig{ - raw: json, - options: compilerOptions, - // watchOptions: watchOptions, + raw: json, + options: compilerOptions, + watchOptions: watchOptions, typeAcquisition: typeAcquisition, extendedConfigPath: extendedConfigPath, }, errors @@ -891,10 +905,35 @@ func convertPropertyValueToJson(sourceFile *ast.SourceFile, valueExpression *ast // host: Instance of ParseConfigHost used to enumerate files in folder. // basePath: A root directory to resolve relative path entries in the config file to. e.g. outDir func ParseJsonConfigFileContent(json any, host ParseConfigHost, basePath string, existingOptions *core.CompilerOptions, configFileName string, resolutionStack []tspath.Path, extraFileExtensions []FileExtensionInfo, extendedConfigCache ExtendedConfigCache) *ParsedCommandLine { - result := parseJsonConfigFileContentWorker(parseJsonToStringKey(json), nil /*sourceFile*/, host, basePath, existingOptions, nil /*existingOptionsRaw*/, configFileName, resolutionStack, extraFileExtensions, extendedConfigCache) + normalized := normalizeJsonValue(json) + jsonObject, ok := normalized.(*collections.OrderedMap[string, any]) + if !ok { + jsonObject = &collections.OrderedMap[string, any]{} + } + result := parseJsonConfigFileContentWorker(jsonObject, nil /*sourceFile*/, host, basePath, existingOptions, nil /*existingOptionsRaw*/, configFileName, resolutionStack, extraFileExtensions, extendedConfigCache) return result } +func normalizeJsonValue(value any) any { + switch value := value.(type) { + case map[string]any: + result := collections.NewOrderedMapWithSizeHint[string, any](len(value)) + for _, key := range slices.Sorted(maps.Keys(value)) { + child := value[key] + result.Set(key, normalizeJsonValue(child)) + } + return result + case []any: + result := make([]any, len(value)) + for i, child := range value { + result[i] = normalizeJsonValue(child) + } + return result + default: + return value + } +} + // convertToObject converts the json syntax tree into the json value func convertToObject(sourceFile *ast.SourceFile) (any, []*ast.Diagnostic) { var rootExpression *ast.Expression @@ -941,6 +980,46 @@ func convertTypeAcquisitionFromJsonWorker(jsonOptions any, basePath string, conf return options, errors } +func convertWatchOptionsFromJsonWorker(jsonOptions any, basePath string) (*core.WatchOptions, []*ast.Diagnostic) { + if jsonOptions == nil { + return nil, nil + } + options := &core.WatchOptions{} + _, errors := convertOptionsFromJson(CommandLineWatchOptionsMap, jsonOptions, basePath, &watchOptionsParser{options}) + return options, errors +} + +func mergeWatchOptions(target, source *core.WatchOptions) *core.WatchOptions { + if source == nil { + return target + } + if target == nil { + target = &core.WatchOptions{} + } + if source.Interval != nil { + target.Interval = source.Interval + } + if source.FileKind != core.WatchFileKindNone { + target.FileKind = source.FileKind + } + if source.DirectoryKind != core.WatchDirectoryKindNone { + target.DirectoryKind = source.DirectoryKind + } + if source.FallbackPolling != core.PollingKindNone { + target.FallbackPolling = source.FallbackPolling + } + if !source.SyncWatchDir.IsUnknown() { + target.SyncWatchDir = source.SyncWatchDir + } + if source.ExcludeDir != nil { + target.ExcludeDir = source.ExcludeDir + } + if source.ExcludeFiles != nil { + target.ExcludeFiles = source.ExcludeFiles + } + return target +} + func parseOwnConfigOfJson( json *collections.OrderedMap[string, any], host ParseConfigHost, @@ -953,9 +1032,13 @@ func parseOwnConfigOfJson( } options, err := convertCompilerOptionsFromJsonWorker(json.GetOrZero("compilerOptions"), basePath, configFileName) typeAcquisition, err2 := convertTypeAcquisitionFromJsonWorker(json.GetOrZero("typeAcquisition"), basePath, configFileName) - errors = append(append(errors, err...), err2...) - // watchOptions := convertWatchOptionsFromJsonWorker(json.watchOptions, basePath, errors) - // json.compileOnSave = convertCompileOnSaveOptionFromJson(json, basePath, errors) + watchOptions, err3 := convertWatchOptionsFromJsonWorker(json.GetOrZero("watchOptions"), basePath) + errors = append(append(append(errors, err...), err2...), err3...) + if compileOnSave, ok := json.Get("compileOnSave"); ok { + converted, compileOnSaveErrors := convertJsonOption(compileOnSaveCommandLineOption, compileOnSave, basePath, nil, nil, nil) + errors = append(errors, compileOnSaveErrors...) + json.Set("compileOnSave", converted) + } var extendedConfigPath []string if extends := json.GetOrZero("extends"); extends != nil && extends != "" { extendedConfigPath, err = getExtendsConfigPathOrArray(extends, host, basePath, configFileName, nil, nil, nil) @@ -964,6 +1047,7 @@ func parseOwnConfigOfJson( parsedConfig := &parsedTsconfig{ raw: json, options: options, + watchOptions: watchOptions, typeAcquisition: typeAcquisition, extendedConfigPath: extendedConfigPath, } @@ -1089,6 +1173,7 @@ func parseConfig( ownConfig, err = parseOwnConfigOfJsonSourceFile(tsconfigToSourceFile(sourceFile), host, basePath, configFileName) } errors = append(errors, err...) + handleWatchOptionsConfigDirTemplateSubstitution(ownConfig.watchOptions, basePath) if ownConfig.options != nil && ownConfig.options.Paths != nil { // If we end up needing to resolve relative paths from 'paths' relative to // the config file location, we'll need to know where that config file was. @@ -1149,6 +1234,7 @@ func parseConfig( } } mergeCompilerOptions(result.options, extendedConfig.options, extendsRaw) + result.watchOptions = mergeWatchOptions(result.watchOptions, extendedConfig.watchOptions) } } @@ -1183,9 +1269,7 @@ func parseConfig( } } ownConfig.options = mergeCompilerOptions(result.options, ownConfig.options, ownConfig.raw) - // ownConfig.watchOptions = ownConfig.watchOptions && result.watchOptions ? - // assignWatchOptions(result, ownConfig.watchOptions) : - // ownConfig.watchOptions || result.watchOptions; + ownConfig.watchOptions = mergeWatchOptions(result.watchOptions, ownConfig.watchOptions) } return ownConfig, errors } @@ -1341,6 +1425,7 @@ func parseJsonConfigFileContentWorker( validatedIncludeSpecsBeforeSubstitution, isDefaultIncludeSpec, } + handleWatchOptionsConfigDirTemplateSubstitution(parsedConfig.watchOptions, basePath) if sourceFile != nil { sourceFile.configFileSpecs = &configFileSpecs @@ -1395,17 +1480,24 @@ func parseJsonConfigFileContentWorker( } fileNames, literalFileNamesLen := getFileNames(basePathForFileNames) + var compileOnSave *bool + if raw, ok := parsedConfig.raw.(*collections.OrderedMap[string, any]); ok { + if value, ok := raw.GetOrZero("compileOnSave").(bool); ok { + compileOnSave = &value + } + } return &ParsedCommandLine{ ParsedConfig: &core.ParsedOptions{ - CompilerOptions: parsedConfig.options, - TypeAcquisition: parsedConfig.typeAcquisition, - // WatchOptions: nil, + CompilerOptions: parsedConfig.options, + WatchOptions: parsedConfig.watchOptions, + TypeAcquisition: parsedConfig.typeAcquisition, FileNames: fileNames, ProjectReferences: getProjectReferences(basePathForFileNames), }, - ConfigFile: sourceFile, - Raw: parsedConfig.raw, - Errors: errors, + ConfigFile: sourceFile, + Raw: parsedConfig.raw, + Errors: errors, + CompileOnSave: compileOnSave, extraFileExtensions: extraFileExtensions, comparePathsOptions: tspath.ComparePathsOptions{ @@ -1416,6 +1508,18 @@ func parseJsonConfigFileContentWorker( } } +func handleWatchOptionsConfigDirTemplateSubstitution(watchOptions *core.WatchOptions, basePath string) { + if watchOptions == nil { + return + } + if excludeDir := getSubstitutedStringArrayWithConfigDirTemplate(watchOptions.ExcludeDir, basePath); excludeDir != nil { + watchOptions.ExcludeDir = excludeDir + } + if excludeFiles := getSubstitutedStringArrayWithConfigDirTemplate(watchOptions.ExcludeFiles, basePath); excludeFiles != nil { + watchOptions.ExcludeFiles = excludeFiles + } +} + func canJsonReportNoInputFiles(rawConfig *collections.OrderedMap[string, any]) bool { filesExists := rawConfig.Has("files") referencesExists := rawConfig.Has("references") diff --git a/internal/tsoptions/tsconfigparsing_test.go b/internal/tsoptions/tsconfigparsing_test.go index 345cd191a71..27d6ced1d5a 100644 --- a/internal/tsoptions/tsconfigparsing_test.go +++ b/internal/tsoptions/tsconfigparsing_test.go @@ -6,11 +6,13 @@ import ( "io/fs" "maps" "path/filepath" + "slices" "strings" "testing" "github.com/google/go-cmp/cmp/cmpopts" "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/diagnosticwriter" @@ -828,6 +830,97 @@ func TestParseJsonConfigFileContent(t *testing.T) { } } +func TestParseJsonConfigFileContentAcceptsJsonRepresentations(t *testing.T) { + t.Parallel() + + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/index.ts": "export {};", + }, "/project", true /*useCaseSensitiveFileNames*/) + + orderedMap, parseErrors := tsoptions.ParseConfigFileTextToJson( + "/project/tsconfig.json", + "/project/tsconfig.json", + `{"compilerOptions":{"strict":true},"files":["index.ts"]}`, + ) + assert.Equal(t, len(parseErrors), 0) + + tests := map[string]any{ + "ordered map": orderedMap, + "plain map": map[string]any{ + "compilerOptions": map[string]any{"strict": true}, + "files": []any{"index.ts"}, + }, + } + for name, json := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + parsed := tsoptions.ParseJsonConfigFileContent( + json, + host, + "/project", + nil, + "/project/tsconfig.json", + nil, /*resolutionStack*/ + nil, /*extraFileExtensions*/ + nil, /*extendedConfigCache*/ + ) + assert.DeepEqual(t, parsed.FileNames(), []string{"/project/index.ts"}) + assert.Assert(t, parsed.CompilerOptions().Strict.IsTrue()) + assert.Equal(t, len(parsed.Errors), 0) + }) + } +} + +func TestParseJsonConfigFileContentPreservesRawAndParsesWatchOptions(t *testing.T) { + t.Parallel() + + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/index.ts": "export {};", + "/project/config/base.json": `{ + "watchOptions": { + "watchFile": "useFsEvents", + "synchronousWatchDirectory": true, + "excludeDirectories": ["${configDir}/generated"], + "excludeFiles": ["${configDir}/base.ts"] + } + }`, + }, "/project", true /*useCaseSensitiveFileNames*/) + + parsed := tsoptions.ParseJsonConfigFileContent( + map[string]any{ + "watchOptions": map[string]any{ + "watchInterval": float64(250), + "synchronousWatchDirectory": false, + "excludeFiles": []any{}, + }, + "files": []any{"index.ts"}, + "extends": "./config/base.json", + "customSetting": map[string]any{"enabled": true}, + "compileOnSave": true, + }, + host, + "/project", + nil, + "/project/tsconfig.json", + nil, /*resolutionStack*/ + nil, /*extraFileExtensions*/ + nil, /*extendedConfigCache*/ + ) + + assert.Equal(t, len(parsed.Errors), 0) + assert.Assert(t, parsed.CompileOnSave != nil && *parsed.CompileOnSave) + assert.Equal(t, *parsed.ParsedConfig.WatchOptions.Interval, 250) + assert.Equal(t, parsed.ParsedConfig.WatchOptions.FileKind, core.WatchFileKindUseFsEvents) + assert.Assert(t, parsed.ParsedConfig.WatchOptions.SyncWatchDir.IsFalse()) + assert.DeepEqual(t, parsed.ParsedConfig.WatchOptions.ExcludeDir, []string{"/project/config/generated"}) + assert.DeepEqual(t, parsed.ParsedConfig.WatchOptions.ExcludeFiles, []string{}) + + raw := parsed.Raw.(*collections.OrderedMap[string, any]) + assert.DeepEqual(t, slices.Collect(raw.Keys()), []string{"compileOnSave", "customSetting", "extends", "files", "watchOptions"}) + assert.Assert(t, raw.Has("customSetting")) + assert.Assert(t, raw.Has("watchOptions")) +} + 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()) diff --git a/testdata/baselines/reference/tsc/showConfig/Show-TSConfig-with-compileOnSave-and-more.js b/testdata/baselines/reference/tsc/showConfig/Show-TSConfig-with-compileOnSave-and-more.js index cca9cd00863..da7ad5d09e4 100644 --- a/testdata/baselines/reference/tsc/showConfig/Show-TSConfig-with-compileOnSave-and-more.js +++ b/testdata/baselines/reference/tsc/showConfig/Show-TSConfig-with-compileOnSave-and-more.js @@ -48,5 +48,6 @@ Output:: ], "exclude": [ "dist" - ] + ], + "compileOnSave": true } From 8c934f0d587ee967d2cf940117989f73dc2fa85f Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:51:11 -0500 Subject: [PATCH 2/8] addressed feedback --- .../native-preview/test/async/api.test.ts | 18 +++ .../native-preview/test/sync/api.test.ts | 18 +++ internal/api/proto.go | 10 +- internal/api/proto_test.go | 17 ++- internal/tsoptions/tsconfigparsing.go | 126 ++++++++++++------ internal/tsoptions/tsconfigparsing_test.go | 59 ++++++++ 6 files changed, 197 insertions(+), 51 deletions(-) diff --git a/_packages/native-preview/test/async/api.test.ts b/_packages/native-preview/test/async/api.test.ts index 0ad07f2e623..b56ea6298b9 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -229,6 +229,7 @@ describe("API", () => { watchFile: "useFsEvents", watchInterval: 250, synchronousWatchDirectory: false, + excludeFiles: [], }, }; const config = await api.parseJsonConfigFileContent(input, { configDirectory: "/src" }); @@ -238,6 +239,7 @@ describe("API", () => { watchInterval: 250, watchFile: 4, synchronousWatchDirectory: false, + excludeFiles: [], }); } finally { @@ -245,6 +247,22 @@ describe("API", () => { } }); + test("parseJsonConfigFileContent preserves an empty files list", async () => { + const api = spawnAPI(); + try { + const config = await api.parseJsonConfigFileContent( + { files: [] }, + { configDirectory: "/src" }, + ); + assert.deepEqual(config.fileNames, []); + assert.equal(config.errors.length, 1); + assert.equal(config.errors[0].code, 18002); + } + finally { + await api.close(); + } + }); + test("transpile", async () => { const api = spawnAPI({ "/input.ts": "export const x: number = 1;", diff --git a/_packages/native-preview/test/sync/api.test.ts b/_packages/native-preview/test/sync/api.test.ts index a203d23e08a..2756e8fa3bb 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -237,6 +237,7 @@ describe("API", () => { watchFile: "useFsEvents", watchInterval: 250, synchronousWatchDirectory: false, + excludeFiles: [], }, }; const config = api.parseJsonConfigFileContent(input, { configDirectory: "/src" }); @@ -246,6 +247,7 @@ describe("API", () => { watchInterval: 250, watchFile: 4, synchronousWatchDirectory: false, + excludeFiles: [], }); } finally { @@ -253,6 +255,22 @@ describe("API", () => { } }); + test("parseJsonConfigFileContent preserves an empty files list", () => { + const api = spawnAPI(); + try { + const config = api.parseJsonConfigFileContent( + { files: [] }, + { configDirectory: "/src" }, + ); + assert.deepEqual(config.fileNames, []); + assert.equal(config.errors.length, 1); + assert.equal(config.errors[0].code, 18002); + } + finally { + api.close(); + } + }); + test("transpile", () => { const api = spawnAPI({ "/input.ts": "export const x: number = 1;", diff --git a/internal/api/proto.go b/internal/api/proto.go index 9077b62322b..3f61c45b59e 100644 --- a/internal/api/proto.go +++ b/internal/api/proto.go @@ -578,7 +578,7 @@ func (v *OrderedJSONValue) UnmarshalJSONFrom(dec *json.Decoder) error { if _, err := dec.ReadToken(); err != nil { return err } - var array []any + array := []any{} for dec.PeekKind() != ']' { var child OrderedJSONValue if err := json.UnmarshalDecode(dec, &child); err != nil { @@ -654,8 +654,8 @@ type WatchOptionsResponse struct { DirectoryKind *core.WatchDirectoryKind `json:"watchDirectory,omitempty"` FallbackPolling *core.PollingKind `json:"fallbackPolling,omitempty"` SyncWatchDir *bool `json:"synchronousWatchDirectory,omitempty"` - ExcludeDir []string `json:"excludeDirectories,omitempty"` - ExcludeFiles []string `json:"excludeFiles,omitempty"` + ExcludeDir []string `json:"excludeDirectories,omitzero"` + ExcludeFiles []string `json:"excludeFiles,omitzero"` } type ReadConfigFileResponse struct { @@ -760,8 +760,8 @@ func NewWatchOptionsResponse(options *core.WatchOptions) *WatchOptionsResponse { response.DirectoryKind == nil && response.FallbackPolling == nil && response.SyncWatchDir == nil && - len(response.ExcludeDir) == 0 && - len(response.ExcludeFiles) == 0 { + response.ExcludeDir == nil && + response.ExcludeFiles == nil { return nil } return response diff --git a/internal/api/proto_test.go b/internal/api/proto_test.go index f4dc4e174c7..7620abedc38 100644 --- a/internal/api/proto_test.go +++ b/internal/api/proto_test.go @@ -19,16 +19,29 @@ func TestOrderedJSONValueUnmarshalJSON(t *testing.T) { t.Parallel() var value api.OrderedJSONValue - err := json.Unmarshal([]byte(`{"z":1,"a":{"y":2,"x":3},"m":[{"b":4,"a":5}]}`), &value) + err := json.Unmarshal([]byte(`{"z":1,"a":{"y":2,"x":3},"m":[{"b":4,"a":5}],"e":[]}`), &value) assert.NilError(t, err) root := value.Value.(*collections.OrderedMap[string, any]) - assert.DeepEqual(t, slices.Collect(root.Keys()), []string{"z", "a", "m"}) + assert.DeepEqual(t, slices.Collect(root.Keys()), []string{"z", "a", "m", "e"}) nested := root.GetOrZero("a").(*collections.OrderedMap[string, any]) assert.DeepEqual(t, slices.Collect(nested.Keys()), []string{"y", "x"}) array := root.GetOrZero("m").([]any) arrayObject := array[0].(*collections.OrderedMap[string, any]) assert.DeepEqual(t, slices.Collect(arrayObject.Keys()), []string{"b", "a"}) + empty := root.GetOrZero("e").([]any) + assert.Assert(t, empty != nil) + assert.Equal(t, len(empty), 0) +} + +func TestNewWatchOptionsResponsePreservesEmptyArrays(t *testing.T) { + t.Parallel() + + response := api.NewWatchOptionsResponse(&core.WatchOptions{ExcludeFiles: []string{}}) + assert.Assert(t, response != nil) + data, err := json.Marshal(response) + assert.NilError(t, err) + assert.Equal(t, string(data), `{"excludeFiles":[]}`) } func TestDocumentIdentifierUnmarshalJSON(t *testing.T) { diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index 6ed47bf3a6c..e8d3d5676dd 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -24,7 +24,7 @@ import ( type extendsResult struct { options *core.CompilerOptions watchOptions *core.WatchOptions - watchOptionsCopied bool + watchOptionsSet *collections.Set[string] include []any exclude []any files []any @@ -180,6 +180,7 @@ type parsedTsconfig struct { raw any options *core.CompilerOptions watchOptions *core.WatchOptions + watchOptionsSet *collections.Set[string] typeAcquisition *core.TypeAcquisition // Note that the case of the config path has not yet been normalized, as no files have been imported into the project yet extendedConfigPath any @@ -294,6 +295,7 @@ func parseOwnConfigOfJsonSourceFile( raw: json, options: compilerOptions, watchOptions: watchOptions, + watchOptionsSet: getWatchOptionsSet(json), typeAcquisition: typeAcquisition, extendedConfigPath: extendedConfigPath, }, errors @@ -672,25 +674,10 @@ func convertOptionsFromJson[O optionParser](optionsNameMap CommandLineOptionName continue } - commandLineOptionEnumMapVal := opt.EnumMap() - if commandLineOptionEnumMapVal != nil { - if value, ok := value.(string); ok { - val, ok := commandLineOptionEnumMapVal.Get(strings.ToLower(value)) - if ok { - errors = result.ParseOption(key, val) - } - } else { - convertJson, err := convertJsonOption(opt, value, basePath, nil, nil, nil) - errors = append(errors, err...) - compilerOptionsErr := result.ParseOption(key, convertJson) - errors = append(errors, compilerOptionsErr...) - } - } else { - convertJson, err := convertJsonOption(opt, value, basePath, nil, nil, nil) - errors = append(errors, err...) - compilerOptionsErr := result.ParseOption(key, convertJson) - errors = append(errors, compilerOptionsErr...) - } + convertJson, err := convertJsonOption(opt, value, basePath, nil, nil, nil) + errors = append(errors, err...) + compilerOptionsErr := result.ParseOption(key, convertJson) + errors = append(errors, compilerOptionsErr...) } return result, errors } @@ -930,7 +917,18 @@ func normalizeJsonValue(value any) any { } return result default: - return value + reflected := reflect.ValueOf(value) + if !reflected.IsValid() || (reflected.Kind() != reflect.Slice && reflected.Kind() != reflect.Array) { + return value + } + if reflected.Kind() == reflect.Slice && reflected.IsNil() { + return nil + } + result := make([]any, reflected.Len()) + for i := range reflected.Len() { + result[i] = normalizeJsonValue(reflected.Index(i).Interface()) + } + return result } } @@ -989,35 +987,64 @@ func convertWatchOptionsFromJsonWorker(jsonOptions any, basePath string) (*core. return options, errors } -func mergeWatchOptions(target, source *core.WatchOptions) *core.WatchOptions { - if source == nil { - return target - } - if target == nil { - target = &core.WatchOptions{} +func getWatchOptionsSet(raw any) *collections.Set[string] { + rawMap, ok := raw.(*collections.OrderedMap[string, any]) + if !ok { + return nil } - if source.Interval != nil { - target.Interval = source.Interval + rawWatchOptions, exists := rawMap.Get("watchOptions") + if !exists { + return nil } - if source.FileKind != core.WatchFileKindNone { - target.FileKind = source.FileKind + watchOptionsMap, ok := rawWatchOptions.(*collections.OrderedMap[string, any]) + if !ok { + return nil } - if source.DirectoryKind != core.WatchDirectoryKindNone { - target.DirectoryKind = source.DirectoryKind + result := collections.NewSetWithSizeHint[string](watchOptionsMap.Size()) + for key := range watchOptionsMap.Keys() { + option := CommandLineWatchOptionsMap.Get(key) + if option != nil && option.Name == key { + result.Add(key) + } } - if source.FallbackPolling != core.PollingKindNone { - target.FallbackPolling = source.FallbackPolling + return result +} + +func mergeWatchOptions( + target *core.WatchOptions, + targetSet *collections.Set[string], + source *core.WatchOptions, + sourceSet *collections.Set[string], +) (*core.WatchOptions, *collections.Set[string]) { + if sourceSet == nil { + return target, targetSet } - if !source.SyncWatchDir.IsUnknown() { - target.SyncWatchDir = source.SyncWatchDir + if target == nil { + target = &core.WatchOptions{} } - if source.ExcludeDir != nil { - target.ExcludeDir = source.ExcludeDir + if targetSet == nil { + targetSet = collections.NewSetWithSizeHint[string](sourceSet.Len()) } - if source.ExcludeFiles != nil { - target.ExcludeFiles = source.ExcludeFiles + for key := range sourceSet.Keys() { + switch key { + case "watchInterval": + target.Interval = source.Interval + case "watchFile": + target.FileKind = source.FileKind + case "watchDirectory": + target.DirectoryKind = source.DirectoryKind + case "fallbackPolling": + target.FallbackPolling = source.FallbackPolling + case "synchronousWatchDirectory": + target.SyncWatchDir = source.SyncWatchDir + case "excludeDirectories": + target.ExcludeDir = source.ExcludeDir + case "excludeFiles": + target.ExcludeFiles = source.ExcludeFiles + } + targetSet.Add(key) } - return target + return target, targetSet } func parseOwnConfigOfJson( @@ -1048,6 +1075,7 @@ func parseOwnConfigOfJson( raw: json, options: options, watchOptions: watchOptions, + watchOptionsSet: getWatchOptionsSet(json), typeAcquisition: typeAcquisition, extendedConfigPath: extendedConfigPath, } @@ -1234,7 +1262,12 @@ func parseConfig( } } mergeCompilerOptions(result.options, extendedConfig.options, extendsRaw) - result.watchOptions = mergeWatchOptions(result.watchOptions, extendedConfig.watchOptions) + result.watchOptions, result.watchOptionsSet = mergeWatchOptions( + result.watchOptions, + result.watchOptionsSet, + extendedConfig.watchOptions, + extendedConfig.watchOptionsSet, + ) } } @@ -1269,7 +1302,12 @@ func parseConfig( } } ownConfig.options = mergeCompilerOptions(result.options, ownConfig.options, ownConfig.raw) - ownConfig.watchOptions = mergeWatchOptions(result.watchOptions, ownConfig.watchOptions) + ownConfig.watchOptions, ownConfig.watchOptionsSet = mergeWatchOptions( + result.watchOptions, + result.watchOptionsSet, + ownConfig.watchOptions, + ownConfig.watchOptionsSet, + ) } return ownConfig, errors } diff --git a/internal/tsoptions/tsconfigparsing_test.go b/internal/tsoptions/tsconfigparsing_test.go index 27d6ced1d5a..86cc415f989 100644 --- a/internal/tsoptions/tsconfigparsing_test.go +++ b/internal/tsoptions/tsconfigparsing_test.go @@ -850,6 +850,13 @@ func TestParseJsonConfigFileContentAcceptsJsonRepresentations(t *testing.T) { "compilerOptions": map[string]any{"strict": true}, "files": []any{"index.ts"}, }, + "typed slices": map[string]any{ + "compilerOptions": map[string]any{"strict": true}, + "files": []string{"index.ts"}, + "watchOptions": map[string]any{ + "excludeFiles": []string{}, + }, + }, } for name, json := range tests { t.Run(name, func(t *testing.T) { @@ -919,6 +926,58 @@ func TestParseJsonConfigFileContentPreservesRawAndParsesWatchOptions(t *testing. assert.DeepEqual(t, slices.Collect(raw.Keys()), []string{"compileOnSave", "customSetting", "extends", "files", "watchOptions"}) assert.Assert(t, raw.Has("customSetting")) assert.Assert(t, raw.Has("watchOptions")) + + cleared := tsoptions.ParseJsonConfigFileContent( + map[string]any{ + "extends": "./config/base.json", + "files": []any{"index.ts"}, + "watchOptions": map[string]any{ + "watchInterval": float64(250), + "watchFile": nil, + "synchronousWatchDirectory": nil, + "excludeDirectories": nil, + "excludeFiles": nil, + }, + }, + host, + "/project", + nil, + "/project/tsconfig.json", + nil, /*resolutionStack*/ + nil, /*extraFileExtensions*/ + nil, /*extendedConfigCache*/ + ) + assert.Equal(t, len(cleared.Errors), 0) + assert.Equal(t, *cleared.ParsedConfig.WatchOptions.Interval, 250) + assert.Equal(t, cleared.ParsedConfig.WatchOptions.FileKind, core.WatchFileKindNone) + assert.Assert(t, cleared.ParsedConfig.WatchOptions.SyncWatchDir.IsUnknown()) + assert.Assert(t, cleared.ParsedConfig.WatchOptions.ExcludeDir == nil) + assert.Assert(t, cleared.ParsedConfig.WatchOptions.ExcludeFiles == nil) +} + +func TestParseJsonConfigFileContentReportsInvalidWatchOption(t *testing.T) { + t.Parallel() + + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/index.ts": "export {};", + }, "/project", true /*useCaseSensitiveFileNames*/) + parsed := tsoptions.ParseJsonConfigFileContent( + map[string]any{ + "files": []any{"index.ts"}, + "watchOptions": map[string]any{ + "watchFile": "invalid", + }, + }, + host, + "/project", + nil, + "/project/tsconfig.json", + nil, /*resolutionStack*/ + nil, /*extraFileExtensions*/ + nil, /*extendedConfigCache*/ + ) + assert.Equal(t, len(parsed.Errors), 1) + assert.Equal(t, parsed.Errors[0].Code(), diagnostics.Argument_for_0_option_must_be_Colon_1.Code()) } func getParsedWithJsonApi(config testConfig, host tsoptions.ParseConfigHost, basePath string) *tsoptions.ParsedCommandLine { From f5300950d82c876fcc2de2f48227fa310ee7e0f9 Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:28:46 -0500 Subject: [PATCH 3/8] fixed testing and feedback --- internal/tsoptions/tsconfigparsing.go | 5 +++++ internal/tsoptions/tsconfigparsing_test.go | 7 ++++++- ...ong type option and invalid enum value with json api.js | 4 +++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index e8d3d5676dd..82cc7c4ca13 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -903,6 +903,11 @@ func ParseJsonConfigFileContent(json any, host ParseConfigHost, basePath string, func normalizeJsonValue(value any) any { switch value := value.(type) { + case *collections.OrderedMap[string, any]: + for key, child := range value.Entries() { + value.Set(key, normalizeJsonValue(child)) + } + return value case map[string]any: result := collections.NewOrderedMapWithSizeHint[string, any](len(value)) for _, key := range slices.Sorted(maps.Keys(value)) { diff --git a/internal/tsoptions/tsconfigparsing_test.go b/internal/tsoptions/tsconfigparsing_test.go index 86cc415f989..e65d4082b6a 100644 --- a/internal/tsoptions/tsconfigparsing_test.go +++ b/internal/tsoptions/tsconfigparsing_test.go @@ -844,8 +844,13 @@ func TestParseJsonConfigFileContentAcceptsJsonRepresentations(t *testing.T) { ) assert.Equal(t, len(parseErrors), 0) + orderedMapWithTypedSlices := &collections.OrderedMap[string, any]{} + orderedMapWithTypedSlices.Set("compilerOptions", map[string]any{"strict": true}) + orderedMapWithTypedSlices.Set("files", []string{"index.ts"}) + tests := map[string]any{ - "ordered map": orderedMap, + "ordered map": orderedMap, + "ordered map with typed slices": orderedMapWithTypedSlices, "plain map": map[string]any{ "compilerOptions": map[string]any{"strict": true}, "files": []any{"index.ts"}, diff --git a/testdata/baselines/reference/config/tsconfigParsing/reports errors for wrong type option and invalid enum value with json api.js b/testdata/baselines/reference/config/tsconfigParsing/reports errors for wrong type option and invalid enum value with json api.js index 0e039cbeeaa..67eb586dda1 100644 --- a/testdata/baselines/reference/config/tsconfigParsing/reports errors for wrong type option and invalid enum value with json api.js +++ b/testdata/baselines/reference/config/tsconfigParsing/reports errors for wrong type option and invalid enum value with json api.js @@ -24,4 +24,6 @@ TypeAcquisition:: FileNames:: /app.ts Errors:: -error TS5024: Compiler option 'removeComments' requires a value of type boolean. +error TS6046: Argument for '--target' option must be: 'es6', 'es2015', 'es2016', 'es2017', 'es2018', 'es2019', 'es2020', 'es2021', 'es2022', 'es2023', 'es2024', 'es2025', 'esnext'. +error TS5024: Compiler option 'removeComments' requires a value of type boolean. +error TS6046: Argument for '--moduleResolution' option must be: 'node16', 'nodenext', 'bundler'. From c7aa6ebf33093ae369300c088eab8135df065baf Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:10:22 -0500 Subject: [PATCH 4/8] addressed feedback --- .../native-preview/test/async/api.test.ts | 18 +++++++- .../native-preview/test/sync/api.test.ts | 18 +++++++- internal/tsoptions/tsconfigparsing.go | 26 ++++++----- internal/tsoptions/tsconfigparsing_test.go | 45 +++++++++++++++++++ 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/_packages/native-preview/test/async/api.test.ts b/_packages/native-preview/test/async/api.test.ts index b56ea6298b9..e377984eea9 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -179,6 +179,7 @@ describe("API", () => { assert.deepEqual(config.fileNames, ["/src/index.ts"]); assert.equal(config.options.strict, true); assert.equal("configFilePath" in config.options, false); + assert.equal(config.compileOnSave, false); assert.deepEqual(config.errors, []); } finally { @@ -263,6 +264,21 @@ describe("API", () => { } }); + test("parseJsonConfigFileContent reports null array elements", async () => { + const api = spawnAPI(); + try { + const config = await api.parseJsonConfigFileContent( + { files: [null], include: [null], exclude: [null] }, + { configDirectory: "/src" }, + ); + assert.equal(config.errors.length, 3); + assert.ok(config.errors.every(diagnostic => diagnostic.code === 5024)); + } + finally { + await api.close(); + } + }); + test("transpile", async () => { const api = spawnAPI({ "/input.ts": "export const x: number = 1;", @@ -295,7 +311,7 @@ describe("API", () => { const config = await api.parseConfigFile("/tsconfig.json"); assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); assert.deepEqual(config.options, { configFilePath: "/tsconfig.json" }); - assert.equal(config.compileOnSave, undefined); + assert.equal(config.compileOnSave, false); assert.equal(config.typeAcquisition, undefined); assert.equal(config.projectReferences, undefined); } diff --git a/_packages/native-preview/test/sync/api.test.ts b/_packages/native-preview/test/sync/api.test.ts index 2756e8fa3bb..20e9da6b833 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -187,6 +187,7 @@ describe("API", () => { assert.deepEqual(config.fileNames, ["/src/index.ts"]); assert.equal(config.options.strict, true); assert.equal("configFilePath" in config.options, false); + assert.equal(config.compileOnSave, false); assert.deepEqual(config.errors, []); } finally { @@ -271,6 +272,21 @@ describe("API", () => { } }); + test("parseJsonConfigFileContent reports null array elements", () => { + const api = spawnAPI(); + try { + const config = api.parseJsonConfigFileContent( + { files: [null], include: [null], exclude: [null] }, + { configDirectory: "/src" }, + ); + assert.equal(config.errors.length, 3); + assert.ok(config.errors.every(diagnostic => diagnostic.code === 5024)); + } + finally { + api.close(); + } + }); + test("transpile", () => { const api = spawnAPI({ "/input.ts": "export const x: number = 1;", @@ -303,7 +319,7 @@ describe("API", () => { const config = api.parseConfigFile("/tsconfig.json"); assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); assert.deepEqual(config.options, { configFilePath: "/tsconfig.json" }); - assert.equal(config.compileOnSave, undefined); + assert.equal(config.compileOnSave, false); assert.equal(config.typeAcquisition, undefined); assert.equal(config.projectReferences, undefined); } diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index 82cc7c4ca13..56939303dbf 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -1324,6 +1324,11 @@ type propOfRaw struct { wrongValue string } +func isStringValue(value any) bool { + _, ok := value.(string) + return ok +} + // parseJsonConfigFileContentWorker parses the contents of a config file from json or json source file (tsconfig.json). // json: The contents of the config file to parse // sourceFile: sourceFile corresponding to the Json @@ -1378,7 +1383,7 @@ func parseJsonConfigFileContentWorker( return propOfRaw{sliceValue: nil, wrongValue: "no-prop"} } referencesOfRaw := getPropFromRaw("references", func(element any) bool { return reflect.TypeOf(element) == orderedMapType }, "object") - fileSpecs := getPropFromRaw("files", func(element any) bool { return reflect.TypeOf(element).Kind() == reflect.String }, "string") + fileSpecs := getPropFromRaw("files", isStringValue, "string") if fileSpecs.sliceValue != nil || fileSpecs.wrongValue == "" { hasZeroOrNoReferences := false if referencesOfRaw.wrongValue == "no-prop" || referencesOfRaw.wrongValue == "not-array" || len(referencesOfRaw.sliceValue) == 0 { @@ -1401,8 +1406,8 @@ func parseJsonConfigFileContentWorker( } } } - includeSpecs := getPropFromRaw("include", func(element any) bool { return reflect.TypeOf(element).Kind() == reflect.String }, "string") - excludeSpecs := getPropFromRaw("exclude", func(element any) bool { return reflect.TypeOf(element).Kind() == reflect.String }, "string") + includeSpecs := getPropFromRaw("include", isStringValue, "string") + excludeSpecs := getPropFromRaw("exclude", isStringValue, "string") isDefaultIncludeSpec := false if excludeSpecs.wrongValue == "no-prop" && parsedConfig.options != nil { outDir := parsedConfig.options.OutDir @@ -1447,7 +1452,7 @@ func parseJsonConfigFileContentWorker( } } if fileSpecs.sliceValue != nil { - fileSpecs := core.Filter(fileSpecs.sliceValue, func(spec any) bool { return reflect.TypeOf(spec).Kind() == reflect.String }) + fileSpecs := core.Filter(fileSpecs.sliceValue, isStringValue) for _, spec := range fileSpecs { if spec, ok := spec.(string); ok { validatedFilesSpecBeforeSubstitution = append(validatedFilesSpecBeforeSubstitution, spec) @@ -1523,7 +1528,7 @@ func parseJsonConfigFileContentWorker( } fileNames, literalFileNamesLen := getFileNames(basePathForFileNames) - var compileOnSave *bool + compileOnSave := new(false) if raw, ok := parsedConfig.raw.(*collections.OrderedMap[string, any]); ok { if value, ok := raw.GetOrZero("compileOnSave").(bool); ok { compileOnSave = &value @@ -1584,15 +1589,16 @@ func validateSpecs(specs any, disallowTrailingRecursion bool, jsonSourceFile *as } var errors []*ast.Diagnostic var finalSpecs []string - for _, spec := range specs.([]any) { - if reflect.TypeOf(spec).Kind() != reflect.String { + for _, value := range specs.([]any) { + spec, ok := value.(string) + if !ok { continue } - diag := specToDiagnostic(spec.(string), disallowTrailingRecursion) + diag := specToDiagnostic(spec, disallowTrailingRecursion) if diag != nil { - errors = append(errors, createDiagnostic(diag, spec.(string))) + errors = append(errors, createDiagnostic(diag, spec)) } else { - finalSpecs = append(finalSpecs, spec.(string)) + finalSpecs = append(finalSpecs, spec) } } return finalSpecs, errors diff --git a/internal/tsoptions/tsconfigparsing_test.go b/internal/tsoptions/tsconfigparsing_test.go index e65d4082b6a..f024afbb7ba 100644 --- a/internal/tsoptions/tsconfigparsing_test.go +++ b/internal/tsoptions/tsconfigparsing_test.go @@ -985,6 +985,51 @@ func TestParseJsonConfigFileContentReportsInvalidWatchOption(t *testing.T) { assert.Equal(t, parsed.Errors[0].Code(), diagnostics.Argument_for_0_option_must_be_Colon_1.Code()) } +func TestParseJsonConfigFileContentHandlesNullArrayElements(t *testing.T) { + t.Parallel() + + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/index.ts": "export {};", + }, "/project", true /*useCaseSensitiveFileNames*/) + for _, property := range []string{"files", "include", "exclude"} { + t.Run(property, func(t *testing.T) { + t.Parallel() + parsed := tsoptions.ParseJsonConfigFileContent( + map[string]any{property: []any{nil}}, + host, + "/project", + nil, + "/project/tsconfig.json", + nil, /*resolutionStack*/ + nil, /*extraFileExtensions*/ + nil, /*extendedConfigCache*/ + ) + assert.Assert(t, len(parsed.Errors) > 0) + assert.Equal(t, parsed.Errors[0].Code(), diagnostics.Compiler_option_0_requires_a_value_of_type_1.Code()) + }) + } +} + +func TestParseJsonConfigFileContentDefaultsCompileOnSaveToFalse(t *testing.T) { + t.Parallel() + + host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ + "/project/index.ts": "export {};", + }, "/project", true /*useCaseSensitiveFileNames*/) + parsed := tsoptions.ParseJsonConfigFileContent( + map[string]any{"files": []any{"index.ts"}}, + host, + "/project", + nil, + "/project/tsconfig.json", + nil, /*resolutionStack*/ + nil, /*extraFileExtensions*/ + nil, /*extendedConfigCache*/ + ) + assert.Assert(t, parsed.CompileOnSave != nil) + assert.Equal(t, *parsed.CompileOnSave, false) +} + 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()) From 469f112f1e65d9ed00e3cc3527297076aed11cde Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:22:19 -0500 Subject: [PATCH 5/8] use normalized paths in parsecommandline api --- _packages/native-preview/test/async/api.test.ts | 5 ++++- _packages/native-preview/test/sync/api.test.ts | 5 ++++- internal/api/session.go | 4 ++-- internal/tsoptions/commandlineparser.go | 17 +---------------- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/_packages/native-preview/test/async/api.test.ts b/_packages/native-preview/test/async/api.test.ts index e377984eea9..e1d8cf88050 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -102,7 +102,10 @@ describe("API", () => { ]); assert.deepEqual(commandLine.fileNames, ["/src/index.ts"]); assert.equal(commandLine.options.strict, true); - assert.equal(commandLine.options.outDir, "dist"); + assert.equal( + commandLine.options.outDir, + resolve(fileURLToPath(new URL("../../../../", import.meta.url)), "dist"), + ); assert.deepEqual(commandLine.watchOptions, { watchFile: 4 }); assert.deepEqual(commandLine.raw, { strict: true, diff --git a/_packages/native-preview/test/sync/api.test.ts b/_packages/native-preview/test/sync/api.test.ts index 20e9da6b833..f1776dd3e46 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -110,7 +110,10 @@ describe("API", () => { ]); assert.deepEqual(commandLine.fileNames, ["/src/index.ts"]); assert.equal(commandLine.options.strict, true); - assert.equal(commandLine.options.outDir, "dist"); + assert.equal( + commandLine.options.outDir, + resolve(fileURLToPath(new URL("../../../../", import.meta.url)), "dist"), + ); assert.deepEqual(commandLine.watchOptions, { watchFile: 4 }); assert.deepEqual(commandLine.raw, { strict: true, diff --git a/internal/api/session.go b/internal/api/session.go index 70242043b7f..9dac13c6933 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -1126,9 +1126,9 @@ func (s *Session) handleGetDefaultProjectForFile(ctx context.Context, params *Ge return NewProjectResponse(proj), nil } -// handleParseCommandLine parses command-line arguments without compiler execution path normalization. +// handleParseCommandLine parses command-line arguments. func (s *Session) handleParseCommandLine(ctx context.Context, params *ParseCommandLineParams) (*ConfigFileResponse, error) { - return NewConfigFileResponse(tsoptions.ParseCommandLineWithoutPathNormalization(params.CommandLine, s.projectSession)), nil + return NewConfigFileResponse(tsoptions.ParseCommandLine(params.CommandLine, s.projectSession)), nil } // handleReadConfigFile reads and parses a JSON configuration file. diff --git a/internal/tsoptions/commandlineparser.go b/internal/tsoptions/commandlineparser.go index 752d6497236..215c48cb845 100644 --- a/internal/tsoptions/commandlineparser.go +++ b/internal/tsoptions/commandlineparser.go @@ -44,26 +44,11 @@ func ParseCommandLine( commandLine []string, host ParseConfigHost, ) *ParsedCommandLine { - return parseCommandLine(commandLine, host, true) -} - -// ParseCommandLineWithoutPathNormalization preserves relative option values for API compatibility. -func ParseCommandLineWithoutPathNormalization( - commandLine []string, - host ParseConfigHost, -) *ParsedCommandLine { - return parseCommandLine(commandLine, host, false) -} - -func parseCommandLine(commandLine []string, host ParseConfigHost, normalizePaths bool) *ParsedCommandLine { if commandLine == nil { commandLine = []string{} } parser := parseCommandLineWorker(CompilerOptionsDidYouMeanDiagnostics, commandLine, host.FS(), host.GetCurrentDirectory()) - options := parser.options - if normalizePaths { - options = convertToOptionsWithAbsolutePaths(options.Clone(), CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) - } + options := convertToOptionsWithAbsolutePaths(parser.options.Clone(), CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) compilerOptions := convertMapToOptions(options, &compilerOptionsParser{&core.CompilerOptions{}}).CompilerOptions var watchOptions *core.WatchOptions for key := range options.Keys() { From f6be8073471a61231794e94d4c3bf4e759aad8a2 Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:52:44 -0500 Subject: [PATCH 6/8] remove legacy watch options from config apis --- _packages/native-preview/src/api/async/api.ts | 3 +- _packages/native-preview/src/api/proto.ts | 11 -- _packages/native-preview/src/api/sync/api.ts | 3 +- .../native-preview/test/async/api.test.ts | 36 +---- .../native-preview/test/sync/api.test.ts | 36 +---- internal/api/proto.go | 49 ------- internal/api/proto_test.go | 10 -- internal/tsoptions/commandlineparser.go | 8 +- internal/tsoptions/commandlineparser_test.go | 1 - internal/tsoptions/declswatch.go | 2 - internal/tsoptions/tsconfigparsing.go | 125 +----------------- internal/tsoptions/tsconfigparsing_test.go | 79 +---------- 12 files changed, 11 insertions(+), 352 deletions(-) diff --git a/_packages/native-preview/src/api/async/api.ts b/_packages/native-preview/src/api/async/api.ts index 0adae81ddbf..4d3eec64ebf 100644 --- a/_packages/native-preview/src/api/async/api.ts +++ b/_packages/native-preview/src/api/async/api.ts @@ -70,7 +70,6 @@ import type { TypeResponse, UpdateSnapshotParams, UpdateSnapshotResponse, - WatchOptions, } from "../proto.ts"; import { resolveFileName, @@ -131,7 +130,7 @@ import type { export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypePredicateKind }; -export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, ReadConfigFileResult, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType, WatchOptions }; +export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, ReadConfigFileResult, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType }; interface EmitOutputResponse { readonly emitSkipped: boolean; diff --git a/_packages/native-preview/src/api/proto.ts b/_packages/native-preview/src/api/proto.ts index 2c738d0fb14..1624f0e14d9 100644 --- a/_packages/native-preview/src/api/proto.ts +++ b/_packages/native-preview/src/api/proto.ts @@ -124,20 +124,9 @@ export interface Diagnostic { readonly relatedInformation?: readonly Diagnostic[] | undefined; } -export interface WatchOptions { - watchInterval?: number; - watchFile?: number; - watchDirectory?: number; - fallbackPolling?: number; - synchronousWatchDirectory?: boolean; - excludeDirectories?: string[]; - excludeFiles?: string[]; -} - export interface ParsedCommandLine { options: CompilerOptions; fileNames: string[]; - watchOptions?: WatchOptions; projectReferences?: ProjectReference[]; typeAcquisition?: TypeAcquisition; compileOnSave?: boolean; diff --git a/_packages/native-preview/src/api/sync/api.ts b/_packages/native-preview/src/api/sync/api.ts index a1687f4cbe3..378d940e580 100644 --- a/_packages/native-preview/src/api/sync/api.ts +++ b/_packages/native-preview/src/api/sync/api.ts @@ -78,7 +78,6 @@ import type { TypeResponse, UpdateSnapshotParams, UpdateSnapshotResponse, - WatchOptions, } from "../proto.ts"; import { resolveFileName, @@ -139,7 +138,7 @@ import type { export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypePredicateKind }; -export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, ReadConfigFileResult, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType, WatchOptions }; +export type { APIOptions, AssertsIdentifierTypePredicate, AssertsThisTypePredicate, BigIntLiteralType, BooleanLiteralType, ClientSocketOptions, ClientSpawnOptions, CompilerOptions, CompletionEntry, CompletionInfo, CompletionOptions, ConditionalType, Diagnostic, DocumentIdentifier, DocumentPosition, EmitOutput, EmitOutputFile, EmitResult, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, ImportAdderAction, IndexedAccessType, IndexInfo, IndexType, InterfaceType, IntersectionType, IntrinsicType, JSDocTagInfo, LiteralType, LSPConnectionOptions, NumberLiteralType, ObjectType, ParsedCommandLine, ProjectReference, ReadConfigFileResult, RequestTiming, SourceFileMetadata, StringLiteralType, StringMappingType, SubstitutionType, TemplateLiteralType, TextEdit, ThisTypePredicate, TimingAccumulators, TimingInfo, TupleType, Type, TypeAcquisition, TypeParameter, TypePredicate, TypePredicateBase, TypeReference, UnionOrIntersectionType, UnionType }; interface EmitOutputResponse { readonly emitSkipped: boolean; diff --git a/_packages/native-preview/test/async/api.test.ts b/_packages/native-preview/test/async/api.test.ts index e1d8cf88050..cc1f30b1bbe 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -94,8 +94,6 @@ describe("API", () => { try { const commandLine = await api.parseCommandLine([ "--strict", - "--watchFile", - "useFsEvents", "--outDir", "dist", "/src/index.ts", @@ -106,10 +104,8 @@ describe("API", () => { commandLine.options.outDir, resolve(fileURLToPath(new URL("../../../../", import.meta.url)), "dist"), ); - assert.deepEqual(commandLine.watchOptions, { watchFile: 4 }); assert.deepEqual(commandLine.raw, { strict: true, - watchFile: 4, outDir: "dist", }); assert.deepEqual(commandLine.errors, []); @@ -124,7 +120,6 @@ describe("API", () => { try { const commandLine = await api.parseCommandLine(["--notAnOption"]); assert.deepEqual(commandLine.fileNames, []); - assert.equal(commandLine.watchOptions, undefined); assert.equal(commandLine.errors.length, 1); assert.equal(commandLine.errors[0].code, 5023); } @@ -222,29 +217,17 @@ describe("API", () => { } }); - test("parseJsonConfigFileContent preserves raw config and parses watch options", async () => { + test("parseJsonConfigFileContent preserves raw config", async () => { const api = spawnAPI(); try { const input = { compileOnSave: true, customSetting: { enabled: true }, files: ["index.ts"], - watchOptions: { - watchFile: "useFsEvents", - watchInterval: 250, - synchronousWatchDirectory: false, - excludeFiles: [], - }, }; const config = await api.parseJsonConfigFileContent(input, { configDirectory: "/src" }); assert.deepEqual(config.raw, input); assert.equal(config.compileOnSave, true); - assert.deepEqual(config.watchOptions, { - watchInterval: 250, - watchFile: 4, - synchronousWatchDirectory: false, - excludeFiles: [], - }); } finally { await api.close(); @@ -341,36 +324,21 @@ describe("API", () => { } }); - test("parseConfigFile parses watch options and preserves raw config", async () => { + test("parseConfigFile preserves raw config", async () => { const api = spawnAPI({ "/tsconfig.json": JSON.stringify({ compileOnSave: true, customSetting: { enabled: true }, files: ["/src/index.ts"], - watchOptions: { - watchDirectory: "fixedPollingInterval", - fallbackPolling: "dynamicPriority", - excludeFiles: ["${configDir}/generated.ts"], - }, }), }); try { const config = await api.parseConfigFile("/tsconfig.json"); - assert.deepEqual(config.watchOptions, { - watchDirectory: 1, - fallbackPolling: 2, - excludeFiles: ["/generated.ts"], - }); assert.equal(config.compileOnSave, true); assert.deepEqual(config.raw, { compileOnSave: true, customSetting: { enabled: true }, files: ["/src/index.ts"], - watchOptions: { - watchDirectory: "fixedPollingInterval", - fallbackPolling: "dynamicPriority", - excludeFiles: ["${configDir}/generated.ts"], - }, }); } finally { diff --git a/_packages/native-preview/test/sync/api.test.ts b/_packages/native-preview/test/sync/api.test.ts index f1776dd3e46..ab6949bf10e 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -102,8 +102,6 @@ describe("API", () => { try { const commandLine = api.parseCommandLine([ "--strict", - "--watchFile", - "useFsEvents", "--outDir", "dist", "/src/index.ts", @@ -114,10 +112,8 @@ describe("API", () => { commandLine.options.outDir, resolve(fileURLToPath(new URL("../../../../", import.meta.url)), "dist"), ); - assert.deepEqual(commandLine.watchOptions, { watchFile: 4 }); assert.deepEqual(commandLine.raw, { strict: true, - watchFile: 4, outDir: "dist", }); assert.deepEqual(commandLine.errors, []); @@ -132,7 +128,6 @@ describe("API", () => { try { const commandLine = api.parseCommandLine(["--notAnOption"]); assert.deepEqual(commandLine.fileNames, []); - assert.equal(commandLine.watchOptions, undefined); assert.equal(commandLine.errors.length, 1); assert.equal(commandLine.errors[0].code, 5023); } @@ -230,29 +225,17 @@ describe("API", () => { } }); - test("parseJsonConfigFileContent preserves raw config and parses watch options", () => { + test("parseJsonConfigFileContent preserves raw config", () => { const api = spawnAPI(); try { const input = { compileOnSave: true, customSetting: { enabled: true }, files: ["index.ts"], - watchOptions: { - watchFile: "useFsEvents", - watchInterval: 250, - synchronousWatchDirectory: false, - excludeFiles: [], - }, }; const config = api.parseJsonConfigFileContent(input, { configDirectory: "/src" }); assert.deepEqual(config.raw, input); assert.equal(config.compileOnSave, true); - assert.deepEqual(config.watchOptions, { - watchInterval: 250, - watchFile: 4, - synchronousWatchDirectory: false, - excludeFiles: [], - }); } finally { api.close(); @@ -349,36 +332,21 @@ describe("API", () => { } }); - test("parseConfigFile parses watch options and preserves raw config", () => { + test("parseConfigFile preserves raw config", () => { const api = spawnAPI({ "/tsconfig.json": JSON.stringify({ compileOnSave: true, customSetting: { enabled: true }, files: ["/src/index.ts"], - watchOptions: { - watchDirectory: "fixedPollingInterval", - fallbackPolling: "dynamicPriority", - excludeFiles: ["${configDir}/generated.ts"], - }, }), }); try { const config = api.parseConfigFile("/tsconfig.json"); - assert.deepEqual(config.watchOptions, { - watchDirectory: 1, - fallbackPolling: 2, - excludeFiles: ["/generated.ts"], - }); assert.equal(config.compileOnSave, true); assert.deepEqual(config.raw, { compileOnSave: true, customSetting: { enabled: true }, files: ["/src/index.ts"], - watchOptions: { - watchDirectory: "fixedPollingInterval", - fallbackPolling: "dynamicPriority", - excludeFiles: ["${configDir}/generated.ts"], - }, }); } finally { diff --git a/internal/api/proto.go b/internal/api/proto.go index 3f61c45b59e..7c1c0ca00bd 100644 --- a/internal/api/proto.go +++ b/internal/api/proto.go @@ -640,7 +640,6 @@ type ProfileResult struct { type ConfigFileResponse struct { FileNames []string `json:"fileNames"` Options *core.CompilerOptions `json:"options"` - WatchOptions *WatchOptionsResponse `json:"watchOptions,omitempty"` ProjectReferences []*core.ProjectReference `json:"projectReferences,omitempty"` TypeAcquisition *core.TypeAcquisition `json:"typeAcquisition,omitempty"` CompileOnSave *bool `json:"compileOnSave,omitempty"` @@ -648,16 +647,6 @@ type ConfigFileResponse struct { Errors []*DiagnosticResponse `json:"errors"` } -type WatchOptionsResponse struct { - Interval *int `json:"watchInterval,omitempty"` - FileKind *core.WatchFileKind `json:"watchFile,omitempty"` - DirectoryKind *core.WatchDirectoryKind `json:"watchDirectory,omitempty"` - FallbackPolling *core.PollingKind `json:"fallbackPolling,omitempty"` - SyncWatchDir *bool `json:"synchronousWatchDirectory,omitempty"` - ExcludeDir []string `json:"excludeDirectories,omitzero"` - ExcludeFiles []string `json:"excludeFiles,omitzero"` -} - type ReadConfigFileResponse struct { Config any `json:"config"` Error *DiagnosticResponse `json:"error,omitempty"` @@ -696,7 +685,6 @@ func NewConfigFileResponse(parsedCommandLine *tsoptions.ParsedCommandLine) *Conf return &ConfigFileResponse{ FileNames: parsedCommandLine.FileNames(), Options: compilerOptions, - WatchOptions: NewWatchOptionsResponse(parsedCommandLine.ParsedConfig.WatchOptions), ProjectReferences: parsedCommandLine.ProjectReferences(), TypeAcquisition: parsedCommandLine.TypeAcquisition(), CompileOnSave: compileOnSave, @@ -730,43 +718,6 @@ func toProtocolJSONValue(value any) any { } } -func NewWatchOptionsResponse(options *core.WatchOptions) *WatchOptionsResponse { - if options == nil { - return nil - } - response := &WatchOptionsResponse{ - Interval: options.Interval, - ExcludeDir: options.ExcludeDir, - ExcludeFiles: options.ExcludeFiles, - } - if options.FileKind != core.WatchFileKindNone { - fileKind := options.FileKind - 1 - response.FileKind = &fileKind - } - if options.DirectoryKind != core.WatchDirectoryKindNone { - directoryKind := options.DirectoryKind - 1 - response.DirectoryKind = &directoryKind - } - if options.FallbackPolling != core.PollingKindNone { - fallbackPolling := options.FallbackPolling - 1 - response.FallbackPolling = &fallbackPolling - } - if !options.SyncWatchDir.IsUnknown() { - syncWatchDir := options.SyncWatchDir.IsTrue() - response.SyncWatchDir = &syncWatchDir - } - if response.Interval == nil && - response.FileKind == nil && - response.DirectoryKind == nil && - response.FallbackPolling == nil && - response.SyncWatchDir == nil && - response.ExcludeDir == nil && - response.ExcludeFiles == nil { - return nil - } - return response -} - func NewProjectResponse(p *project.Project) *ProjectResponse { if p == nil || p.CommandLine == nil { panic("NewProjectResponse called with unloaded project") diff --git a/internal/api/proto_test.go b/internal/api/proto_test.go index 7620abedc38..f27318289cd 100644 --- a/internal/api/proto_test.go +++ b/internal/api/proto_test.go @@ -34,16 +34,6 @@ func TestOrderedJSONValueUnmarshalJSON(t *testing.T) { assert.Equal(t, len(empty), 0) } -func TestNewWatchOptionsResponsePreservesEmptyArrays(t *testing.T) { - t.Parallel() - - response := api.NewWatchOptionsResponse(&core.WatchOptions{ExcludeFiles: []string{}}) - assert.Assert(t, response != nil) - data, err := json.Marshal(response) - assert.NilError(t, err) - assert.Equal(t, string(data), `{"excludeFiles":[]}`) -} - func TestDocumentIdentifierUnmarshalJSON(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/tsoptions/commandlineparser.go b/internal/tsoptions/commandlineparser.go index 215c48cb845..297505e0c55 100644 --- a/internal/tsoptions/commandlineparser.go +++ b/internal/tsoptions/commandlineparser.go @@ -50,13 +50,7 @@ func ParseCommandLine( parser := parseCommandLineWorker(CompilerOptionsDidYouMeanDiagnostics, commandLine, host.FS(), host.GetCurrentDirectory()) options := convertToOptionsWithAbsolutePaths(parser.options.Clone(), CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) compilerOptions := convertMapToOptions(options, &compilerOptionsParser{&core.CompilerOptions{}}).CompilerOptions - var watchOptions *core.WatchOptions - for key := range options.Keys() { - if WatchNameMap.Get(key) != nil { - watchOptions = convertMapToOptions(options, &watchOptionsParser{&core.WatchOptions{}}).WatchOptions - break - } - } + watchOptions := convertMapToOptions(options, &watchOptionsParser{&core.WatchOptions{}}).WatchOptions result := NewParsedCommandLine(compilerOptions, parser.fileNames, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), CurrentDirectory: host.GetCurrentDirectory(), diff --git a/internal/tsoptions/commandlineparser_test.go b/internal/tsoptions/commandlineparser_test.go index 709be3e4f00..999008c00fb 100644 --- a/internal/tsoptions/commandlineparser_test.go +++ b/internal/tsoptions/commandlineparser_test.go @@ -118,7 +118,6 @@ func TestResponseFileParsing(t *testing.T) { assert.Equal(t, len(parsed.Errors), 0) assert.Assert(t, parsed.CompilerOptions().Strict.IsTrue()) assert.Equal(t, parsed.CompilerOptions().OutDir, "/project/dist") - assert.Assert(t, parsed.ParsedConfig.WatchOptions == nil) }) t.Run("cyclic response files", func(t *testing.T) { diff --git a/internal/tsoptions/declswatch.go b/internal/tsoptions/declswatch.go index bdaf1379d66..b190a6641c5 100644 --- a/internal/tsoptions/declswatch.go +++ b/internal/tsoptions/declswatch.go @@ -86,5 +86,3 @@ var OptionsForWatch = []*CommandLineOption{ Description: diagnostics.Remove_a_list_of_files_from_the_watch_mode_s_processing, }, } - -var CommandLineWatchOptionsMap CommandLineOptionNameMap = commandLineOptionsToMap(OptionsForWatch) diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index 56939303dbf..d152a7b958b 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -23,8 +23,6 @@ import ( type extendsResult struct { options *core.CompilerOptions - watchOptions *core.WatchOptions - watchOptionsSet *collections.Set[string] include []any exclude []any files []any @@ -44,13 +42,6 @@ var compileOnSaveCommandLineOption = &CommandLineOption{ DefaultValueDescription: false, } -var watchOptionsDeclaration = &CommandLineOption{ - Name: "watchOptions", - Kind: CommandLineOptionTypeObject, - ElementOptions: CommandLineWatchOptionsMap, - DefaultValueDescription: nil, -} - var extendsOptionDeclaration = &CommandLineOption{ Name: "extends", Kind: CommandLineOptionTypeListOrElement, @@ -65,7 +56,6 @@ var tsconfigRootOptionsMap = &CommandLineOption{ Kind: CommandLineOptionTypeObject, ElementOptions: commandLineOptionsToMap([]*CommandLineOption{ compilerOptionsDeclaration, - watchOptionsDeclaration, typeAcquisitionDeclaration, extendsOptionDeclaration, { @@ -179,8 +169,6 @@ func (e *ExtendedConfigCacheEntry) ExtendedFileNames() []string { type parsedTsconfig struct { raw any options *core.CompilerOptions - watchOptions *core.WatchOptions - watchOptionsSet *collections.Set[string] typeAcquisition *core.TypeAcquisition // Note that the case of the config path has not yet been normalized, as no files have been imported into the project yet extendedConfigPath any @@ -194,7 +182,6 @@ func parseOwnConfigOfJsonSourceFile( ) (*parsedTsconfig, []*ast.Diagnostic) { compilerOptions := getDefaultCompilerOptions(configFileName) typeAcquisition := getDefaultTypeAcquisition(configFileName) - var watchOptions *core.WatchOptions var extendedConfigPath any var rootCompilerOptions []*ast.PropertyName var errors []*ast.Diagnostic @@ -216,11 +203,6 @@ func parseOwnConfigOfJsonSourceFile( switch parentOption.Name { case "compilerOptions": parseDiagnostics = ParseCompilerOptions(option.Name, value, compilerOptions) - case "watchOptions": - if watchOptions == nil { - watchOptions = &core.WatchOptions{} - } - parseDiagnostics = ParseWatchOptions(option.Name, value, watchOptions) case "typeAcquisition": parseDiagnostics = ParseTypeAcquisition(option.Name, value, typeAcquisition) } @@ -257,9 +239,7 @@ func parseOwnConfigOfJsonSourceFile( } } } else if parentOption == tsconfigRootOptionsMap { - if option == watchOptionsDeclaration && watchOptions == nil { - watchOptions = &core.WatchOptions{} - } else if option == extendsOptionDeclaration { + if option == extendsOptionDeclaration { configPath, err := getExtendsConfigPathOrArray(value, host, basePath, configFileName, propertyAssignment, propertyAssignment.Initializer, sourceFile) extendedConfigPath = configPath propertySetErrors = append(propertySetErrors, err...) @@ -294,8 +274,6 @@ func parseOwnConfigOfJsonSourceFile( return &parsedTsconfig{ raw: json, options: compilerOptions, - watchOptions: watchOptions, - watchOptionsSet: getWatchOptionsSet(json), typeAcquisition: typeAcquisition, extendedConfigPath: extendedConfigPath, }, errors @@ -983,75 +961,6 @@ func convertTypeAcquisitionFromJsonWorker(jsonOptions any, basePath string, conf return options, errors } -func convertWatchOptionsFromJsonWorker(jsonOptions any, basePath string) (*core.WatchOptions, []*ast.Diagnostic) { - if jsonOptions == nil { - return nil, nil - } - options := &core.WatchOptions{} - _, errors := convertOptionsFromJson(CommandLineWatchOptionsMap, jsonOptions, basePath, &watchOptionsParser{options}) - return options, errors -} - -func getWatchOptionsSet(raw any) *collections.Set[string] { - rawMap, ok := raw.(*collections.OrderedMap[string, any]) - if !ok { - return nil - } - rawWatchOptions, exists := rawMap.Get("watchOptions") - if !exists { - return nil - } - watchOptionsMap, ok := rawWatchOptions.(*collections.OrderedMap[string, any]) - if !ok { - return nil - } - result := collections.NewSetWithSizeHint[string](watchOptionsMap.Size()) - for key := range watchOptionsMap.Keys() { - option := CommandLineWatchOptionsMap.Get(key) - if option != nil && option.Name == key { - result.Add(key) - } - } - return result -} - -func mergeWatchOptions( - target *core.WatchOptions, - targetSet *collections.Set[string], - source *core.WatchOptions, - sourceSet *collections.Set[string], -) (*core.WatchOptions, *collections.Set[string]) { - if sourceSet == nil { - return target, targetSet - } - if target == nil { - target = &core.WatchOptions{} - } - if targetSet == nil { - targetSet = collections.NewSetWithSizeHint[string](sourceSet.Len()) - } - for key := range sourceSet.Keys() { - switch key { - case "watchInterval": - target.Interval = source.Interval - case "watchFile": - target.FileKind = source.FileKind - case "watchDirectory": - target.DirectoryKind = source.DirectoryKind - case "fallbackPolling": - target.FallbackPolling = source.FallbackPolling - case "synchronousWatchDirectory": - target.SyncWatchDir = source.SyncWatchDir - case "excludeDirectories": - target.ExcludeDir = source.ExcludeDir - case "excludeFiles": - target.ExcludeFiles = source.ExcludeFiles - } - targetSet.Add(key) - } - return target, targetSet -} - func parseOwnConfigOfJson( json *collections.OrderedMap[string, any], host ParseConfigHost, @@ -1064,8 +973,7 @@ func parseOwnConfigOfJson( } options, err := convertCompilerOptionsFromJsonWorker(json.GetOrZero("compilerOptions"), basePath, configFileName) typeAcquisition, err2 := convertTypeAcquisitionFromJsonWorker(json.GetOrZero("typeAcquisition"), basePath, configFileName) - watchOptions, err3 := convertWatchOptionsFromJsonWorker(json.GetOrZero("watchOptions"), basePath) - errors = append(append(append(errors, err...), err2...), err3...) + errors = append(append(errors, err...), err2...) if compileOnSave, ok := json.Get("compileOnSave"); ok { converted, compileOnSaveErrors := convertJsonOption(compileOnSaveCommandLineOption, compileOnSave, basePath, nil, nil, nil) errors = append(errors, compileOnSaveErrors...) @@ -1079,8 +987,6 @@ func parseOwnConfigOfJson( parsedConfig := &parsedTsconfig{ raw: json, options: options, - watchOptions: watchOptions, - watchOptionsSet: getWatchOptionsSet(json), typeAcquisition: typeAcquisition, extendedConfigPath: extendedConfigPath, } @@ -1206,7 +1112,6 @@ func parseConfig( ownConfig, err = parseOwnConfigOfJsonSourceFile(tsconfigToSourceFile(sourceFile), host, basePath, configFileName) } errors = append(errors, err...) - handleWatchOptionsConfigDirTemplateSubstitution(ownConfig.watchOptions, basePath) if ownConfig.options != nil && ownConfig.options.Paths != nil { // If we end up needing to resolve relative paths from 'paths' relative to // the config file location, we'll need to know where that config file was. @@ -1267,12 +1172,6 @@ func parseConfig( } } mergeCompilerOptions(result.options, extendedConfig.options, extendsRaw) - result.watchOptions, result.watchOptionsSet = mergeWatchOptions( - result.watchOptions, - result.watchOptionsSet, - extendedConfig.watchOptions, - extendedConfig.watchOptionsSet, - ) } } @@ -1307,12 +1206,6 @@ func parseConfig( } } ownConfig.options = mergeCompilerOptions(result.options, ownConfig.options, ownConfig.raw) - ownConfig.watchOptions, ownConfig.watchOptionsSet = mergeWatchOptions( - result.watchOptions, - result.watchOptionsSet, - ownConfig.watchOptions, - ownConfig.watchOptionsSet, - ) } return ownConfig, errors } @@ -1473,7 +1366,6 @@ func parseJsonConfigFileContentWorker( validatedIncludeSpecsBeforeSubstitution, isDefaultIncludeSpec, } - handleWatchOptionsConfigDirTemplateSubstitution(parsedConfig.watchOptions, basePath) if sourceFile != nil { sourceFile.configFileSpecs = &configFileSpecs @@ -1537,7 +1429,6 @@ func parseJsonConfigFileContentWorker( return &ParsedCommandLine{ ParsedConfig: &core.ParsedOptions{ CompilerOptions: parsedConfig.options, - WatchOptions: parsedConfig.watchOptions, TypeAcquisition: parsedConfig.typeAcquisition, FileNames: fileNames, ProjectReferences: getProjectReferences(basePathForFileNames), @@ -1556,18 +1447,6 @@ func parseJsonConfigFileContentWorker( } } -func handleWatchOptionsConfigDirTemplateSubstitution(watchOptions *core.WatchOptions, basePath string) { - if watchOptions == nil { - return - } - if excludeDir := getSubstitutedStringArrayWithConfigDirTemplate(watchOptions.ExcludeDir, basePath); excludeDir != nil { - watchOptions.ExcludeDir = excludeDir - } - if excludeFiles := getSubstitutedStringArrayWithConfigDirTemplate(watchOptions.ExcludeFiles, basePath); excludeFiles != nil { - watchOptions.ExcludeFiles = excludeFiles - } -} - func canJsonReportNoInputFiles(rawConfig *collections.OrderedMap[string, any]) bool { filesExists := rawConfig.Has("files") referencesExists := rawConfig.Has("references") diff --git a/internal/tsoptions/tsconfigparsing_test.go b/internal/tsoptions/tsconfigparsing_test.go index f024afbb7ba..61e9c87d884 100644 --- a/internal/tsoptions/tsconfigparsing_test.go +++ b/internal/tsoptions/tsconfigparsing_test.go @@ -858,9 +858,6 @@ func TestParseJsonConfigFileContentAcceptsJsonRepresentations(t *testing.T) { "typed slices": map[string]any{ "compilerOptions": map[string]any{"strict": true}, "files": []string{"index.ts"}, - "watchOptions": map[string]any{ - "excludeFiles": []string{}, - }, }, } for name, json := range tests { @@ -883,30 +880,16 @@ func TestParseJsonConfigFileContentAcceptsJsonRepresentations(t *testing.T) { } } -func TestParseJsonConfigFileContentPreservesRawAndParsesWatchOptions(t *testing.T) { +func TestParseJsonConfigFileContentPreservesRaw(t *testing.T) { t.Parallel() host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ "/project/index.ts": "export {};", - "/project/config/base.json": `{ - "watchOptions": { - "watchFile": "useFsEvents", - "synchronousWatchDirectory": true, - "excludeDirectories": ["${configDir}/generated"], - "excludeFiles": ["${configDir}/base.ts"] - } - }`, }, "/project", true /*useCaseSensitiveFileNames*/) parsed := tsoptions.ParseJsonConfigFileContent( map[string]any{ - "watchOptions": map[string]any{ - "watchInterval": float64(250), - "synchronousWatchDirectory": false, - "excludeFiles": []any{}, - }, "files": []any{"index.ts"}, - "extends": "./config/base.json", "customSetting": map[string]any{"enabled": true}, "compileOnSave": true, }, @@ -921,68 +904,10 @@ func TestParseJsonConfigFileContentPreservesRawAndParsesWatchOptions(t *testing. assert.Equal(t, len(parsed.Errors), 0) assert.Assert(t, parsed.CompileOnSave != nil && *parsed.CompileOnSave) - assert.Equal(t, *parsed.ParsedConfig.WatchOptions.Interval, 250) - assert.Equal(t, parsed.ParsedConfig.WatchOptions.FileKind, core.WatchFileKindUseFsEvents) - assert.Assert(t, parsed.ParsedConfig.WatchOptions.SyncWatchDir.IsFalse()) - assert.DeepEqual(t, parsed.ParsedConfig.WatchOptions.ExcludeDir, []string{"/project/config/generated"}) - assert.DeepEqual(t, parsed.ParsedConfig.WatchOptions.ExcludeFiles, []string{}) raw := parsed.Raw.(*collections.OrderedMap[string, any]) - assert.DeepEqual(t, slices.Collect(raw.Keys()), []string{"compileOnSave", "customSetting", "extends", "files", "watchOptions"}) + assert.DeepEqual(t, slices.Collect(raw.Keys()), []string{"compileOnSave", "customSetting", "files"}) assert.Assert(t, raw.Has("customSetting")) - assert.Assert(t, raw.Has("watchOptions")) - - cleared := tsoptions.ParseJsonConfigFileContent( - map[string]any{ - "extends": "./config/base.json", - "files": []any{"index.ts"}, - "watchOptions": map[string]any{ - "watchInterval": float64(250), - "watchFile": nil, - "synchronousWatchDirectory": nil, - "excludeDirectories": nil, - "excludeFiles": nil, - }, - }, - host, - "/project", - nil, - "/project/tsconfig.json", - nil, /*resolutionStack*/ - nil, /*extraFileExtensions*/ - nil, /*extendedConfigCache*/ - ) - assert.Equal(t, len(cleared.Errors), 0) - assert.Equal(t, *cleared.ParsedConfig.WatchOptions.Interval, 250) - assert.Equal(t, cleared.ParsedConfig.WatchOptions.FileKind, core.WatchFileKindNone) - assert.Assert(t, cleared.ParsedConfig.WatchOptions.SyncWatchDir.IsUnknown()) - assert.Assert(t, cleared.ParsedConfig.WatchOptions.ExcludeDir == nil) - assert.Assert(t, cleared.ParsedConfig.WatchOptions.ExcludeFiles == nil) -} - -func TestParseJsonConfigFileContentReportsInvalidWatchOption(t *testing.T) { - t.Parallel() - - host := tsoptionstest.NewVFSParseConfigHost(map[string]string{ - "/project/index.ts": "export {};", - }, "/project", true /*useCaseSensitiveFileNames*/) - parsed := tsoptions.ParseJsonConfigFileContent( - map[string]any{ - "files": []any{"index.ts"}, - "watchOptions": map[string]any{ - "watchFile": "invalid", - }, - }, - host, - "/project", - nil, - "/project/tsconfig.json", - nil, /*resolutionStack*/ - nil, /*extraFileExtensions*/ - nil, /*extendedConfigCache*/ - ) - assert.Equal(t, len(parsed.Errors), 1) - assert.Equal(t, parsed.Errors[0].Code(), diagnostics.Argument_for_0_option_must_be_Colon_1.Code()) } func TestParseJsonConfigFileContentHandlesNullArrayElements(t *testing.T) { From 3ec316c84d1ac2b7d57e4f6d55e6db8413f5ef2f Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:08:38 -0500 Subject: [PATCH 7/8] reuse packagejson.jsonvalue for config api input --- internal/api/jsonvalue_test.go | 35 ++++++++++++++++ internal/api/proto.go | 75 +++++++++++----------------------- internal/api/proto_test.go | 21 ---------- internal/api/session.go | 2 +- 4 files changed, 59 insertions(+), 74 deletions(-) create mode 100644 internal/api/jsonvalue_test.go diff --git a/internal/api/jsonvalue_test.go b/internal/api/jsonvalue_test.go new file mode 100644 index 00000000000..9375fb5e9f1 --- /dev/null +++ b/internal/api/jsonvalue_test.go @@ -0,0 +1,35 @@ +package api + +import ( + "slices" + "testing" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/json" + "github.com/microsoft/typescript-go/internal/packagejson" + "gotest.tools/v3/assert" +) + +func TestJSONValueToAny(t *testing.T) { + t.Parallel() + + var value packagejson.JSONValue + err := json.Unmarshal([]byte(`{"z":1,"a":{"y":2,"x":3},"m":[{"b":4,"a":5},null],"e":[]}`), &value) + assert.NilError(t, err) + + root := jsonValueToAny(value).(*collections.OrderedMap[string, any]) + assert.DeepEqual(t, slices.Collect(root.Keys()), []string{"z", "a", "m", "e"}) + assert.Equal(t, root.GetOrZero("z"), float64(1)) + + nested := root.GetOrZero("a").(*collections.OrderedMap[string, any]) + assert.DeepEqual(t, slices.Collect(nested.Keys()), []string{"y", "x"}) + + array := root.GetOrZero("m").([]any) + arrayObject := array[0].(*collections.OrderedMap[string, any]) + assert.DeepEqual(t, slices.Collect(arrayObject.Keys()), []string{"b", "a"}) + assert.Equal(t, array[1], nil) + + empty := root.GetOrZero("e").([]any) + assert.Assert(t, empty != nil) + assert.Equal(t, len(empty), 0) +} diff --git a/internal/api/proto.go b/internal/api/proto.go index 7c1c0ca00bd..1e436ab1db8 100644 --- a/internal/api/proto.go +++ b/internal/api/proto.go @@ -14,6 +14,7 @@ import ( "github.com/microsoft/typescript-go/internal/locale" "github.com/microsoft/typescript-go/internal/ls/lsconv" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/packagejson" "github.com/microsoft/typescript-go/internal/project" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" @@ -541,67 +542,37 @@ type ReadConfigFileParams struct { File DocumentIdentifier `json:"file"` } -type OrderedJSONValue struct { - Value any +type ParseJsonConfigFileContentParams struct { + JSON packagejson.JSONValue `json:"json"` + ConfigDirectory *string `json:"configDirectory,omitempty"` + ConfigFileName *DocumentIdentifier `json:"configFileName,omitempty"` } -var _ json.UnmarshalerFrom = (*OrderedJSONValue)(nil) - -func (v *OrderedJSONValue) UnmarshalJSONFrom(dec *json.Decoder) error { - switch dec.PeekKind() { - case 'n': - _, err := dec.ReadToken() - v.Value = nil - return err - case '{': - if _, err := dec.ReadToken(); err != nil { - return err - } - object := &collections.OrderedMap[string, any]{} - for dec.PeekKind() != '}' { - var key string - if err := json.UnmarshalDecode(dec, &key); err != nil { - return err - } - var child OrderedJSONValue - if err := json.UnmarshalDecode(dec, &child); err != nil { - return err - } - object.Set(key, child.Value) - } - if _, err := dec.ReadToken(); err != nil { - return err - } - v.Value = object +func jsonValueToAny(value packagejson.JSONValue) any { + switch value.Type { + case packagejson.JSONValueTypeNotPresent, packagejson.JSONValueTypeNull: return nil - case '[': - if _, err := dec.ReadToken(); err != nil { - return err + case packagejson.JSONValueTypeString, packagejson.JSONValueTypeNumber, packagejson.JSONValueTypeBoolean: + return value.Value + case packagejson.JSONValueTypeArray: + array := value.AsArray() + result := make([]any, len(array)) + for i, child := range array { + result[i] = jsonValueToAny(child) } - array := []any{} - for dec.PeekKind() != ']' { - var child OrderedJSONValue - if err := json.UnmarshalDecode(dec, &child); err != nil { - return err - } - array = append(array, child.Value) - } - if _, err := dec.ReadToken(); err != nil { - return err + return result + case packagejson.JSONValueTypeObject: + object := value.AsObject() + result := collections.NewOrderedMapWithSizeHint[string, any](object.Size()) + for key, child := range object.Entries() { + result.Set(key, jsonValueToAny(child)) } - v.Value = array - return nil + return result default: - return json.UnmarshalDecode(dec, &v.Value) + panic(fmt.Sprintf("unexpected JSON value type %v", value.Type)) } } -type ParseJsonConfigFileContentParams struct { - JSON OrderedJSONValue `json:"json"` - ConfigDirectory *string `json:"configDirectory,omitempty"` - ConfigFileName *DocumentIdentifier `json:"configFileName,omitempty"` -} - type TranspileOptions struct { CompilerOptions *core.CompilerOptions `json:"compilerOptions,omitempty"` FileName string `json:"fileName,omitempty"` diff --git a/internal/api/proto_test.go b/internal/api/proto_test.go index f27318289cd..6e17411e0c0 100644 --- a/internal/api/proto_test.go +++ b/internal/api/proto_test.go @@ -1,13 +1,11 @@ package api_test import ( - "slices" "strings" "testing" "github.com/microsoft/typescript-go/internal/api" "github.com/microsoft/typescript-go/internal/ast" - "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/json" @@ -15,25 +13,6 @@ import ( "gotest.tools/v3/assert" ) -func TestOrderedJSONValueUnmarshalJSON(t *testing.T) { - t.Parallel() - - var value api.OrderedJSONValue - err := json.Unmarshal([]byte(`{"z":1,"a":{"y":2,"x":3},"m":[{"b":4,"a":5}],"e":[]}`), &value) - assert.NilError(t, err) - - root := value.Value.(*collections.OrderedMap[string, any]) - assert.DeepEqual(t, slices.Collect(root.Keys()), []string{"z", "a", "m", "e"}) - nested := root.GetOrZero("a").(*collections.OrderedMap[string, any]) - assert.DeepEqual(t, slices.Collect(nested.Keys()), []string{"y", "x"}) - array := root.GetOrZero("m").([]any) - arrayObject := array[0].(*collections.OrderedMap[string, any]) - assert.DeepEqual(t, slices.Collect(arrayObject.Keys()), []string{"b", "a"}) - empty := root.GetOrZero("e").([]any) - assert.Assert(t, empty != nil) - assert.Equal(t, len(empty), 0) -} - func TestDocumentIdentifierUnmarshalJSON(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/api/session.go b/internal/api/session.go index 9dac13c6933..8b5de9c771b 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -1170,7 +1170,7 @@ func (s *Session) handleParseJsonConfigFileContent(ctx context.Context, params * } parsedCommandLine := tsoptions.ParseJsonConfigFileContent( - params.JSON.Value, + jsonValueToAny(params.JSON), s.projectSession, basePath, nil, /*existingOptions*/ From 3514e108974f9c461b102ee90c4a792ea926095e Mon Sep 17 00:00:00 2001 From: John Favret <64748847+johnfav03@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:38:59 -0500 Subject: [PATCH 8/8] expose configfilepath in compiler options --- .../native-preview/src/api/compilerOptions.ts | 1 + .../native-preview/test/async/api.test.ts | 2 +- .../test/compilerOptions.test.ts | 19 ++++++++++++------- .../native-preview/test/sync/api.test.ts | 2 +- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/_packages/native-preview/src/api/compilerOptions.ts b/_packages/native-preview/src/api/compilerOptions.ts index 38e1c63cebb..8290b43b0d8 100644 --- a/_packages/native-preview/src/api/compilerOptions.ts +++ b/_packages/native-preview/src/api/compilerOptions.ts @@ -19,6 +19,7 @@ export interface CompilerOptions { checkJs?: boolean; customConditions?: string[]; composite?: boolean; + configFilePath?: string; emitDeclarationOnly?: boolean; emitBOM?: boolean; emitDecoratorMetadata?: boolean; diff --git a/_packages/native-preview/test/async/api.test.ts b/_packages/native-preview/test/async/api.test.ts index cc1f30b1bbe..8fde59fd3b9 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -209,7 +209,7 @@ describe("API", () => { ); assert.deepEqual(config.fileNames, ["/src/index.ts"]); assert.equal(config.options.strict, true); - assert.equal((config.options as Record).configFilePath, "/src/tsconfig.json"); + assert.equal(config.options.configFilePath, "/src/tsconfig.json"); assert.deepEqual(config.errors, []); } finally { diff --git a/_packages/native-preview/test/compilerOptions.test.ts b/_packages/native-preview/test/compilerOptions.test.ts index c374b2c90f7..a5ddeda3f9f 100644 --- a/_packages/native-preview/test/compilerOptions.test.ts +++ b/_packages/native-preview/test/compilerOptions.test.ts @@ -14,15 +14,14 @@ const testDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(testDir, "..", "..", ".."); const goOptionsPath = join(repoRoot, "internal", "core", "compileroptions.go"); const tsOptionsPath = join(testDir, "..", "src", "api", "compilerOptions.ts"); +const exposedInternalOptions = new Set(["configFilePath"]); /** - * Extracts the JSON tag names of the *public, non-deprecated* fields of the Go - * `core.CompilerOptions` struct — i.e. every field declared before the - * `// Internal fields` marker that is not annotated with a `// Deprecated:` - * comment. Fields at/after the internal marker are CLI/debug/internal options, - * and deprecated fields are intentionally omitted from the public API type. + * Extracts the JSON tag names of the fields exposed in the TypeScript API. + * These are the public, non-deprecated Go fields plus explicitly selected + * internal fields. */ -function getGoPublicOptionNames(): Set { +function getGoApiOptionNames(): Set { const source = readFileSync(goOptionsPath, "utf-8"); const structMatch = source.match(/type CompilerOptions struct \{([\s\S]*?)\n\}/); assert.ok(structMatch, "Could not find `type CompilerOptions struct` in compileroptions.go"); @@ -30,6 +29,7 @@ function getGoPublicOptionNames(): Set { let body = structMatch[1]; const internalMarker = body.indexOf("// Internal fields"); assert.notStrictEqual(internalMarker, -1, "Could not find `// Internal fields` marker in compileroptions.go"); + const internalBody = body.slice(internalMarker); body = body.slice(0, internalMarker); const names = new Set(); @@ -50,6 +50,11 @@ function getGoPublicOptionNames(): Set { prevDeprecated = line.startsWith("// Deprecated:"); } } + for (const match of internalBody.matchAll(/`json:"([^",]+)/g)) { + if (exposedInternalOptions.has(match[1])) { + names.add(match[1]); + } + } return names; } @@ -72,7 +77,7 @@ function getTsOptionNames(): Set { } describe("CompilerOptions type stays in sync with Go", () => { - const goNames = getGoPublicOptionNames(); + const goNames = getGoApiOptionNames(); const tsNames = getTsOptionNames(); test("sanity: both sides parsed a plausible number of options", () => { diff --git a/_packages/native-preview/test/sync/api.test.ts b/_packages/native-preview/test/sync/api.test.ts index ab6949bf10e..b6a0274079d 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -217,7 +217,7 @@ describe("API", () => { ); assert.deepEqual(config.fileNames, ["/src/index.ts"]); assert.equal(config.options.strict, true); - assert.equal((config.options as Record).configFilePath, "/src/tsconfig.json"); + assert.equal(config.options.configFilePath, "/src/tsconfig.json"); assert.deepEqual(config.errors, []); } finally {