diff --git a/_packages/native-preview/src/api/async/api.ts b/_packages/native-preview/src/api/async/api.ts index f63d500fb3a..4d3eec64ebf 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, @@ -129,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, 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 }; interface EmitOutputResponse { readonly emitSkipped: boolean; @@ -189,6 +190,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 transpileModule(input: string, options: TranspileOptions = {}): Promise { await this.ensureInitialized(); return this.client.apiRequest("transpileModule", { input, options }); 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/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/src/api/proto.ts b/_packages/native-preview/src/api/proto.ts index cf947a35ef8..1624f0e14d9 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,45 @@ 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 ParsedCommandLine { options: CompilerOptions; fileNames: string[]; 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 22aacc48737..378d940e580 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, @@ -137,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, 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 }; interface EmitOutputResponse { readonly emitSkipped: boolean; @@ -197,6 +198,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 }); + } + transpileModule(input: string, options: TranspileOptions = {}): TranspileOutput { this.ensureInitialized(); return this.client.apiRequest("transpileModule", { input, options }); 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 ce4e6f53af3..8fde59fd3b9 100644 --- a/_packages/native-preview/test/async/api.test.ts +++ b/_packages/native-preview/test/async/api.test.ts @@ -89,6 +89,182 @@ const defaultFiles = { }; describe("API", () => { + test("parseCommandLine", async () => { + const api = spawnAPI(); + try { + const commandLine = await api.parseCommandLine([ + "--strict", + "--outDir", + "dist", + "/src/index.ts", + ]); + assert.deepEqual(commandLine.fileNames, ["/src/index.ts"]); + assert.equal(commandLine.options.strict, true); + assert.equal( + commandLine.options.outDir, + resolve(fileURLToPath(new URL("../../../../", import.meta.url)), "dist"), + ); + assert.deepEqual(commandLine.raw, { + strict: true, + 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.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.equal(config.compileOnSave, 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.configFilePath, "/src/tsconfig.json"); + assert.deepEqual(config.errors, []); + } + finally { + await api.close(); + } + }); + + test("parseJsonConfigFileContent preserves raw config", async () => { + const api = spawnAPI(); + try { + const input = { + compileOnSave: true, + customSetting: { enabled: true }, + files: ["index.ts"], + }; + const config = await api.parseJsonConfigFileContent(input, { configDirectory: "/src" }); + assert.deepEqual(config.raw, input); + assert.equal(config.compileOnSave, true); + } + finally { + await api.close(); + } + }); + + 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("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;", @@ -121,7 +297,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); } @@ -148,6 +324,28 @@ describe("API", () => { } }); + test("parseConfigFile preserves raw config", async () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compileOnSave: true, + customSetting: { enabled: true }, + files: ["/src/index.ts"], + }), + }); + try { + const config = await api.parseConfigFile("/tsconfig.json"); + assert.equal(config.compileOnSave, true); + assert.deepEqual(config.raw, { + compileOnSave: true, + customSetting: { enabled: true }, + files: ["/src/index.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/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 9fe20fb9192..b6a0274079d 100644 --- a/_packages/native-preview/test/sync/api.test.ts +++ b/_packages/native-preview/test/sync/api.test.ts @@ -97,6 +97,182 @@ const defaultFiles = { }; describe("API", () => { + test("parseCommandLine", () => { + const api = spawnAPI(); + try { + const commandLine = api.parseCommandLine([ + "--strict", + "--outDir", + "dist", + "/src/index.ts", + ]); + assert.deepEqual(commandLine.fileNames, ["/src/index.ts"]); + assert.equal(commandLine.options.strict, true); + assert.equal( + commandLine.options.outDir, + resolve(fileURLToPath(new URL("../../../../", import.meta.url)), "dist"), + ); + assert.deepEqual(commandLine.raw, { + strict: true, + 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.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.equal(config.compileOnSave, 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.configFilePath, "/src/tsconfig.json"); + assert.deepEqual(config.errors, []); + } + finally { + api.close(); + } + }); + + test("parseJsonConfigFileContent preserves raw config", () => { + const api = spawnAPI(); + try { + const input = { + compileOnSave: true, + customSetting: { enabled: true }, + files: ["index.ts"], + }; + const config = api.parseJsonConfigFileContent(input, { configDirectory: "/src" }); + assert.deepEqual(config.raw, input); + assert.equal(config.compileOnSave, true); + } + finally { + api.close(); + } + }); + + 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("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;", @@ -129,7 +305,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); } @@ -156,6 +332,28 @@ describe("API", () => { } }); + test("parseConfigFile preserves raw config", () => { + const api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ + compileOnSave: true, + customSetting: { enabled: true }, + files: ["/src/index.ts"], + }), + }); + try { + const config = api.parseConfigFile("/tsconfig.json"); + assert.equal(config.compileOnSave, true); + assert.deepEqual(config.raw, { + compileOnSave: true, + customSetting: { enabled: true }, + files: ["/src/index.ts"], + }); + } + finally { + api.close(); + } + }); + test("parseConfigFile includes compileOnSave", () => { const api = spawnAPI({ "/tsconfig.json": JSON.stringify({ compileOnSave: true }), 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 67bf8dc19ca..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" @@ -72,6 +73,9 @@ const ( MethodInitialize Method = "initialize" MethodUpdateSnapshot Method = "updateSnapshot" MethodUpdateTemporarySnapshot Method = "updateTemporarySnapshot" + MethodParseCommandLine Method = "parseCommandLine" + MethodReadConfigFile Method = "readConfigFile" + MethodParseJsonConfigFile Method = "parseJsonConfigFileContent" MethodParseConfigFile Method = "parseConfigFile" MethodTranspileModule Method = "transpileModule" MethodTranspileModuleFromFile Method = "transpileModuleFromFile" @@ -393,6 +397,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], MethodTranspileModule: unmarshallerFor[TranspileParams], MethodTranspileModuleFromFile: unmarshallerFor[TranspileFromFileParams], @@ -527,6 +534,45 @@ type ParseConfigFileParams struct { File DocumentIdentifier `json:"file"` } +type ParseCommandLineParams struct { + CommandLine []string `json:"commandLine"` +} + +type ReadConfigFileParams struct { + File DocumentIdentifier `json:"file"` +} + +type ParseJsonConfigFileContentParams struct { + JSON packagejson.JSONValue `json:"json"` + ConfigDirectory *string `json:"configDirectory,omitempty"` + ConfigFileName *DocumentIdentifier `json:"configFileName,omitempty"` +} + +func jsonValueToAny(value packagejson.JSONValue) any { + switch value.Type { + case packagejson.JSONValueTypeNotPresent, packagejson.JSONValueTypeNull: + return nil + 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) + } + 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)) + } + return result + default: + panic(fmt.Sprintf("unexpected JSON value type %v", value.Type)) + } +} + type TranspileOptions struct { CompilerOptions *core.CompilerOptions `json:"compilerOptions,omitempty"` FileName string `json:"fileName,omitempty"` @@ -568,6 +614,13 @@ type ConfigFileResponse struct { 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 ReadConfigFileResponse struct { + Config any `json:"config"` + Error *DiagnosticResponse `json:"error,omitempty"` } type GetDefaultProjectForFileParams struct { @@ -596,12 +649,43 @@ 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, 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 } } diff --git a/internal/api/session.go b/internal/api/session.go index 80b114c3748..8b5de9c771b 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" @@ -588,6 +589,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(MethodTranspileModule): @@ -1119,6 +1126,62 @@ func (s *Session) handleGetDefaultProjectForFile(ctx context.Context, params *Ge return NewProjectResponse(proj), nil } +// handleParseCommandLine parses command-line arguments. +func (s *Session) handleParseCommandLine(ctx context.Context, params *ParseCommandLineParams) (*ConfigFileResponse, error) { + return NewConfigFileResponse(tsoptions.ParseCommandLine(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( + jsonValueToAny(params.JSON), + 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..297505e0c55 100644 --- a/internal/tsoptions/commandlineparser.go +++ b/internal/tsoptions/commandlineparser.go @@ -37,6 +37,7 @@ type commandLineParser struct { options *collections.OrderedMap[string, any] fileNames []string errors []*ast.Diagnostic + responseFileStack collections.Set[tspath.Path] } func ParseCommandLine( @@ -47,9 +48,9 @@ func ParseCommandLine( 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 := convertToOptionsWithAbsolutePaths(parser.options.Clone(), CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) + compilerOptions := convertMapToOptions(options, &compilerOptionsParser{&core.CompilerOptions{}}).CompilerOptions + watchOptions := convertMapToOptions(options, &watchOptionsParser{&core.WatchOptions{}}).WatchOptions result := NewParsedCommandLine(compilerOptions, parser.fileNames, tspath.ComparePathsOptions{ UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), CurrentDirectory: host.GetCurrentDirectory(), @@ -167,6 +168,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 +212,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..999008c00fb 100644 --- a/internal/tsoptions/commandlineparser_test.go +++ b/internal/tsoptions/commandlineparser_test.go @@ -106,6 +106,33 @@ 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") + }) + + 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/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index 44668c91462..d152a7b958b 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -22,9 +22,7 @@ import ( ) type extendsResult struct { - options *core.CompilerOptions - // watchOptions compiler.WatchOptions - watchOptionsCopied bool + options *core.CompilerOptions include []any exclude []any files []any @@ -58,7 +56,6 @@ var tsconfigRootOptionsMap = &CommandLineOption{ Kind: CommandLineOptionTypeObject, ElementOptions: commandLineOptionsToMap([]*CommandLineOption{ compilerOptionsDeclaration, - // watchOptionsDeclaration, typeAcquisitionDeclaration, extendsOptionDeclaration, { @@ -170,9 +167,8 @@ func (e *ExtendedConfigCacheEntry) ExtendedFileNames() []string { } type parsedTsconfig struct { - raw any - options *core.CompilerOptions - // watchOptions *core.WatchOptions + raw any + options *core.CompilerOptions 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 +182,6 @@ func parseOwnConfigOfJsonSourceFile( ) (*parsedTsconfig, []*ast.Diagnostic) { compilerOptions := getDefaultCompilerOptions(configFileName) typeAcquisition := getDefaultTypeAcquisition(configFileName) - // var watchOptions *compiler.WatchOptions var extendedConfigPath any var rootCompilerOptions []*ast.PropertyName var errors []*ast.Diagnostic @@ -277,9 +272,8 @@ func parseOwnConfigOfJsonSourceFile( )) } return &parsedTsconfig{ - raw: json, - options: compilerOptions, - // watchOptions: watchOptions, + raw: json, + options: compilerOptions, typeAcquisition: typeAcquisition, extendedConfigPath: extendedConfigPath, }, errors @@ -658,25 +652,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 } @@ -891,10 +870,51 @@ 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 *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)) { + 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: + 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 + } +} + // convertToObject converts the json syntax tree into the json value func convertToObject(sourceFile *ast.SourceFile) (any, []*ast.Diagnostic) { var rootExpression *ast.Expression @@ -954,8 +974,11 @@ 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) + 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) @@ -1183,9 +1206,6 @@ 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; } return ownConfig, errors } @@ -1197,6 +1217,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 @@ -1251,7 +1276,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 { @@ -1274,8 +1299,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 @@ -1320,7 +1345,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) @@ -1395,17 +1420,23 @@ func parseJsonConfigFileContentWorker( } fileNames, literalFileNamesLen := getFileNames(basePathForFileNames) + compileOnSave := new(false) + 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, + 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{ @@ -1437,15 +1468,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 345cd191a71..61e9c87d884 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,131 @@ 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) + + 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 with typed slices": orderedMapWithTypedSlices, + "plain map": map[string]any{ + "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"}, + }, + } + 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 TestParseJsonConfigFileContentPreservesRaw(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"}, + "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) + + raw := parsed.Raw.(*collections.OrderedMap[string, any]) + assert.DeepEqual(t, slices.Collect(raw.Keys()), []string{"compileOnSave", "customSetting", "files"}) + assert.Assert(t, raw.Has("customSetting")) +} + +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()) 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'. 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 }