diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index ad787fb62495d..09ec61f37439a 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -27,6 +27,10 @@ import { unescapeLeadingUnderscores, } from "../../ast/index.ts"; import { assertNever } from "../../internal/utils.ts"; +import { + formatDiagnostics, + formatDiagnosticsWithColorAndContext, +} from "../diagnosticFormatter.ts"; import { encodeNode, uint8ArrayToBase64, @@ -102,6 +106,7 @@ import type { EmitOutput, EmitOutputFile, EmitResult, + FormatDiagnosticsHost, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, @@ -154,6 +159,7 @@ export type { EmitOutput, EmitOutputFile, EmitResult, + FormatDiagnosticsHost, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, @@ -1205,6 +1211,26 @@ export class Program { return data ?? []; } + /** + * Formats diagnostics produced by this program. + */ + async formatDiagnostics( + diagnostics: readonly Diagnostic[], + host: FormatDiagnosticsHost, + ): Promise { + return formatDiagnostics(diagnostics, host); + } + + /** + * Formats diagnostics produced by this program with color and context. + */ + async formatDiagnosticsWithColorAndContext( + diagnostics: readonly Diagnostic[], + host: FormatDiagnosticsHost, + ): Promise { + return formatDiagnosticsWithColorAndContext(diagnostics, host); + } + /** * Emits files to the configured filesystem. * diff --git a/packages/typescript/src/api/async/types.ts b/packages/typescript/src/api/async/types.ts index 62b3dbdeb6e36..79c392aed6b63 100644 --- a/packages/typescript/src/api/async/types.ts +++ b/packages/typescript/src/api/async/types.ts @@ -370,6 +370,12 @@ export interface CompletionInfo { readonly entries: readonly CompletionEntry[]; } +export interface FormatDiagnosticsHost { + getCurrentDirectory(): string; + getCanonicalFileName(fileName: string): string; + getNewLine(): string; +} + export interface EmitOutputFile { readonly text: string; readonly sourceFileName?: string | undefined; diff --git a/packages/typescript/src/api/diagnosticFormatter.ts b/packages/typescript/src/api/diagnosticFormatter.ts new file mode 100644 index 0000000000000..905b7d04a539a --- /dev/null +++ b/packages/typescript/src/api/diagnosticFormatter.ts @@ -0,0 +1,196 @@ +import { convertToRelativePath } from "./path.ts"; +import type { DiagnosticResponse as Diagnostic } from "./proto.generated.ts"; + +export interface FormatDiagnosticsHost { + getCurrentDirectory(): string; + getCanonicalFileName(fileName: string): string; + getNewLine(): string; +} + +const foregroundColorEscapeGrey = "\x1b[90m"; +const foregroundColorEscapeRed = "\x1b[91m"; +const foregroundColorEscapeYellow = "\x1b[93m"; +const foregroundColorEscapeBlue = "\x1b[94m"; +const foregroundColorEscapeCyan = "\x1b[96m"; +const gutterStyleSequence = "\x1b[7m"; +const gutterSeparator = " "; +const resetEscapeSequence = "\x1b[0m"; +const ellipsis = "..."; +const halfIndent = " "; +const indent = " "; +const fileAppearsToBeBinaryCode = 1490; + +function diagnosticCategoryName(category: number): string { + switch (category) { + case 0: + return "warning"; + case 1: + return "error"; + case 2: + return "suggestion"; + case 3: + return "message"; + default: + throw new Error(`Unknown diagnostic category: ${category}`); + } +} + +function getCategoryFormat(category: number): string { + switch (category) { + case 0: + return foregroundColorEscapeYellow; + case 1: + return foregroundColorEscapeRed; + case 2: + return foregroundColorEscapeGrey; + case 3: + return foregroundColorEscapeBlue; + default: + throw new Error(`Unknown diagnostic category: ${category}`); + } +} + +function formatColorAndReset(text: string, formatStyle: string): string { + return formatStyle + text + resetEscapeSequence; +} + +function diagnosticPrefix(diagnostic: Diagnostic): string { + return diagnostic.source || "TS"; +} + +function flattenDiagnosticMessage(diagnostic: Diagnostic, newLine: string, indentLevel = 0): string { + let result = ""; + if (indentLevel) { + result += newLine + " ".repeat(indentLevel); + } + result += diagnostic.text; + for (const child of diagnostic.messageChain ?? []) { + result += flattenDiagnosticMessage(child, newLine, indentLevel + 1); + } + return result; +} + +function relativeFileName(fileName: string, host: FormatDiagnosticsHost): string { + return convertToRelativePath( + fileName, + host.getCurrentDirectory(), + name => host.getCanonicalFileName(name), + ); +} + +function formatLocation(diagnostic: Diagnostic, host: FormatDiagnosticsHost): string { + if (!diagnostic.fileName || !diagnostic.startPosition) return ""; + const fileName = relativeFileName(diagnostic.fileName, host); + const { line, character } = diagnostic.startPosition; + return formatColorAndReset(fileName, foregroundColorEscapeCyan) + + ":" + + formatColorAndReset(`${line + 1}`, foregroundColorEscapeYellow) + + ":" + + formatColorAndReset(`${character + 1}`, foregroundColorEscapeYellow); +} + +function formatCodeSpan( + diagnostic: Diagnostic, + lineIndent: string, + squiggleColor: string, + host: FormatDiagnosticsHost, +): string { + const { startPosition, endPosition, sourceLines } = diagnostic; + if (!startPosition || !endPosition || !sourceLines?.length) return ""; + + const hasMoreThanFiveLines = endPosition.line - startPosition.line >= 4; + const gutterWidth = hasMoreThanFiveLines + ? Math.max(ellipsis.length, `${endPosition.line + 1}`.length) + : `${endPosition.line + 1}`.length; + let context = ""; + let previousLine: number | undefined; + + for (const sourceLine of sourceLines) { + if (previousLine !== undefined && sourceLine.line > previousLine + 1) { + context += host.getNewLine(); + context += lineIndent + + formatColorAndReset(ellipsis.padStart(gutterWidth), gutterStyleSequence) + + gutterSeparator; + } + + const lineContent = sourceLine.text.trimEnd().replace(/\t/g, " "); + context += host.getNewLine(); + context += lineIndent + + formatColorAndReset(`${sourceLine.line + 1}`.padStart(gutterWidth), gutterStyleSequence) + + gutterSeparator + + lineContent + + host.getNewLine(); + context += lineIndent + + formatColorAndReset("".padStart(gutterWidth), gutterStyleSequence) + + gutterSeparator + + squiggleColor; + + if (sourceLine.line === startPosition.line) { + const lastCharacter = sourceLine.line === endPosition.line + ? endPosition.character + : lineContent.length; + context += " ".repeat(startPosition.character); + context += "~".repeat(Math.max(0, lastCharacter - startPosition.character)); + } + else if (sourceLine.line === endPosition.line) { + context += "~".repeat(endPosition.character); + } + else { + context += "~".repeat(lineContent.length); + } + context += resetEscapeSequence; + previousLine = sourceLine.line; + } + + return context; +} + +export function formatDiagnostics(diagnostics: readonly Diagnostic[], host: FormatDiagnosticsHost): string { + let output = ""; + for (const diagnostic of diagnostics) { + const errorMessage = `${diagnosticCategoryName(diagnostic.category)} ${diagnosticPrefix(diagnostic)}${diagnostic.code}: ${flattenDiagnosticMessage(diagnostic, host.getNewLine())}${host.getNewLine()}`; + if (diagnostic.fileName && diagnostic.startPosition) { + const { line, character } = diagnostic.startPosition; + output += `${relativeFileName(diagnostic.fileName, host)}(${line + 1},${character + 1}): ${errorMessage}`; + } + else { + output += errorMessage; + } + } + return output; +} + +export function formatDiagnosticsWithColorAndContext( + diagnostics: readonly Diagnostic[], + host: FormatDiagnosticsHost, +): string { + let output = ""; + for (const diagnostic of diagnostics) { + if (diagnostic.fileName && diagnostic.startPosition) { + output += formatLocation(diagnostic, host) + " - "; + } + output += formatColorAndReset(diagnosticCategoryName(diagnostic.category), getCategoryFormat(diagnostic.category)); + output += formatColorAndReset(` ${diagnosticPrefix(diagnostic)}${diagnostic.code}: `, foregroundColorEscapeGrey); + output += flattenDiagnosticMessage(diagnostic, host.getNewLine()); + + if (diagnostic.fileName && diagnostic.code !== fileAppearsToBeBinaryCode) { + output += host.getNewLine(); + output += formatCodeSpan(diagnostic, "", getCategoryFormat(diagnostic.category), host); + } + + if (diagnostic.relatedInformation?.length) { + output += host.getNewLine(); + for (const related of diagnostic.relatedInformation) { + if (related.fileName && related.startPosition) { + output += host.getNewLine(); + output += halfIndent + formatLocation(related, host); + output += formatCodeSpan(related, indent, foregroundColorEscapeCyan, host); + } + output += host.getNewLine(); + output += indent + flattenDiagnosticMessage(related, host.getNewLine()); + } + } + output += host.getNewLine(); + } + return output; +} diff --git a/packages/typescript/src/api/path.ts b/packages/typescript/src/api/path.ts index 778cbf8ecedcc..2d30b2a4a49e5 100644 --- a/packages/typescript/src/api/path.ts +++ b/packages/typescript/src/api/path.ts @@ -337,6 +337,45 @@ export function isRootedDiskPath(path: string): boolean { return getEncodedRootLength(path) > 0; } +export function convertToRelativePath( + absoluteOrRelativePath: string, + basePath: string, + getCanonicalFileName: (path: string) => string, +): string { + if (!isRootedDiskPath(absoluteOrRelativePath)) { + return absoluteOrRelativePath; + } + + const fromComponents = getPathComponents(getNormalizedAbsolutePath(basePath, "")); + const toComponents = getPathComponents(getNormalizedAbsolutePath(absoluteOrRelativePath, "")); + let start = 0; + for (; start < fromComponents.length && start < toComponents.length; start++) { + const fromComponent = getCanonicalFileName(fromComponents[start]); + const toComponent = getCanonicalFileName(toComponents[start]); + const equal = start === 0 + ? fromComponent.toLowerCase() === toComponent.toLowerCase() + : fromComponent === toComponent; + if (!equal) { + break; + } + } + + if (start === 0) { + return pathFromComponents(toComponents); + } + + const relative = Array(fromComponents.length - start).fill(".."); + return pathFromComponents(["", ...relative, ...toComponents.slice(start)]); +} + +function pathFromComponents(components: readonly string[]): string { + if (components.length === 0) { + return ""; + } + const root = components[0] && ensureTrailingDirectorySeparator(components[0]); + return root + components.slice(1).join(directorySeparator); +} + /** * Converts a file name to a normalized path. * diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 35c08679d2aa4..4fde71e49fd46 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -763,10 +763,18 @@ export interface DiagnosticResponse { pos: number; /** End is the end position of the diagnostic in the source file. */ end: number; + /** StartPosition is the zero-based line and UTF-16 character position of Pos. */ + startPosition?: DiagnosticPositionResponse; + /** EndPosition is the zero-based line and UTF-16 character position of End. */ + endPosition?: DiagnosticPositionResponse; + /** SourceLines contains the source lines needed to render this diagnostic with context. */ + sourceLines?: DiagnosticSourceLineResponse[]; /** Code is the diagnostic error code. */ code: number; /** Category is the diagnostic category (error, warning, suggestion, message). */ category: number; + /** Source is a custom diagnostic-code prefix. An empty value uses the default "TS". */ + source?: string; /** Text is the localized diagnostic message text. */ text: string; /** ReportsUnnecessary indicates this diagnostic highlights unnecessary code. */ @@ -1034,6 +1042,16 @@ export interface CompletionEntryResponse { symbol?: SymbolResponse; } +export interface DiagnosticPositionResponse { + line: number; + character: number; +} + +export interface DiagnosticSourceLineResponse { + line: number; + text: string; +} + export interface EmitOutputFile { fileName: string; text: string; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 074471e86cfbb..47079e1de8433 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -35,6 +35,10 @@ import { unescapeLeadingUnderscores, } from "../../ast/index.ts"; import { assertNever } from "../../internal/utils.ts"; +import { + formatDiagnostics, + formatDiagnosticsWithColorAndContext, +} from "../diagnosticFormatter.ts"; import { encodeNode, uint8ArrayToBase64, @@ -110,6 +114,7 @@ import type { EmitOutput, EmitOutputFile, EmitResult, + FormatDiagnosticsHost, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, @@ -162,6 +167,7 @@ export type { EmitOutput, EmitOutputFile, EmitResult, + FormatDiagnosticsHost, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, @@ -1213,6 +1219,26 @@ export class Program { return data ?? []; } + /** + * Formats diagnostics produced by this program. + */ + formatDiagnostics( + diagnostics: readonly Diagnostic[], + host: FormatDiagnosticsHost, + ): string { + return formatDiagnostics(diagnostics, host); + } + + /** + * Formats diagnostics produced by this program with color and context. + */ + formatDiagnosticsWithColorAndContext( + diagnostics: readonly Diagnostic[], + host: FormatDiagnosticsHost, + ): string { + return formatDiagnosticsWithColorAndContext(diagnostics, host); + } + /** * Emits files to the configured filesystem. * diff --git a/packages/typescript/src/api/sync/types.ts b/packages/typescript/src/api/sync/types.ts index a65b159412c14..2b7e2c19b499e 100644 --- a/packages/typescript/src/api/sync/types.ts +++ b/packages/typescript/src/api/sync/types.ts @@ -378,6 +378,12 @@ export interface CompletionInfo { readonly entries: readonly CompletionEntry[]; } +export interface FormatDiagnosticsHost { + getCurrentDirectory(): string; + getCanonicalFileName(fileName: string): string; + getNewLine(): string; +} + export interface EmitOutputFile { readonly text: string; readonly sourceFileName?: string | undefined; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index fe08206bc486b..51667a61df2d3 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -5684,7 +5684,10 @@ describe("Program - diagnostics", () => { const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = await project.program.getSyntacticDiagnostics("/src/index.ts"); - assert.deepEqual(diags, [{ + assert.deepEqual(diags[0].startPosition, { line: 0, character: 9 }); + assert.deepEqual(diags[0].endPosition, { line: 0, character: 10 }); + assert.deepEqual(diags[0].sourceLines, [{ line: 0, text: source }]); + assert.deepEqual(withoutFormattingContext(diags), [{ fileName: "/src/index.ts", ...rangeOf(source, "="), code: 1110, @@ -5709,7 +5712,7 @@ describe("Program - diagnostics", () => { const diags = await project.program.getSemanticDiagnostics("/src/index.ts"); const declRange = rangeOf(source, "callback", 0); const assignRange = rangeOf(source, "callback", 1); - assert.deepEqual(diags, [{ + assert.deepEqual(withoutFormattingContext(diags), [{ fileName: "/src/index.ts", ...assignRange, code: 2322, @@ -5753,7 +5756,7 @@ describe("Program - diagnostics", () => { const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = await project.program.getSuggestionDiagnostics("/src/index.ts"); - assert.deepEqual(diags, [{ + assert.deepEqual(withoutFormattingContext(diags), [{ fileName: "/src/index.ts", ...rangeOf(source, "_unused"), code: 6133, @@ -5777,7 +5780,7 @@ describe("Program - diagnostics", () => { const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = await project.program.getConfigFileParsingDiagnostics(); - assert.deepEqual(diags, [{ + assert.deepEqual(withoutFormattingContext(diags), [{ fileName: "/tsconfig.json", ...rangeOf(config, `"invalid"`), code: 6046, @@ -5790,6 +5793,117 @@ describe("Program - diagnostics", () => { } }); + test("formatDiagnostics and formatDiagnosticsWithColorAndContext", async () => { + const source = `const x: number = "oops";\n`; + const api = spawnAPI({ + "/tsconfig.json": `{ "compilerOptions": { "strict": true } }`, + "/src/index.ts": source, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const diags = await project.program.getSemanticDiagnostics("/src/index.ts"); + assert.equal(diags.length, 1); + const host = { + getCurrentDirectory: () => "/SRC", + getCanonicalFileName: (fileName: string) => fileName.toLowerCase(), + getNewLine: () => "\r\n", + }; + + const plain = await project.program.formatDiagnostics(diags, host); + assert.equal( + plain, + "index.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.\r\n", + ); + + const color = await project.program.formatDiagnosticsWithColorAndContext(diags, host); + assert.ok(color.includes("TS2322: "), color); + assert.ok(color.includes(`const x: number = "oops";`), color); + assert.ok(color.includes("~"), color); + assert.ok(color.includes("\x1b["), color); + assert.ok(color.endsWith("\r\n"), color); + const doubled = await project.program.formatDiagnosticsWithColorAndContext([diags[0], diags[0]], host); + assert.equal(doubled, color + color); + + const multiline = { + ...diags[0], + startPosition: { line: 0, character: 0 }, + endPosition: { line: 6, character: 5 }, + sourceLines: [ + { line: 0, text: "one\n" }, + { line: 1, text: "two\n" }, + { line: 5, text: "six\n" }, + { line: 6, text: "seven" }, + ], + }; + const multilineColor = await project.program.formatDiagnosticsWithColorAndContext([multiline], host); + assert.ok(multilineColor.includes("..."), multilineColor); + assert.ok(multilineColor.includes("seven"), multilineColor); + } + finally { + await api.close(); + } + }); + + test("formatDiagnostics resolves config-file context", async () => { + const config = `{ "compilerOptions": { "target": "invalid" } }`; + const api = spawnAPI({ + "/tsconfig.json": config, + "/src/index.ts": `export const x = 1;`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const diags = await project.program.getConfigFileParsingDiagnostics(); + const host = { + getCurrentDirectory: () => "/", + getCanonicalFileName: (fileName: string) => fileName, + getNewLine: () => "\n", + }; + + const plain = await project.program.formatDiagnostics(diags, host); + assert.ok(plain.includes("tsconfig.json(1,34): error TS6046: "), plain); + + const color = await project.program.formatDiagnosticsWithColorAndContext(diags, host); + assert.ok(color.includes(`"target": "invalid"`), color); + } + finally { + await api.close(); + } + }); + + test("formatDiagnostics accepts cloned diagnostics and parseConfigFile diagnostics", async () => { + const configText = `{ "compilerOptions": { "target": "invalid" } }`; + const api = spawnAPI({ + "/tsconfig.json": configText, + "/src/index.ts": `const x: number = "oops";`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); + const program = snapshot.getProject("/tsconfig.json")!.program; + const diagnostics = await program.getSemanticDiagnostics("/src/index.ts"); + const configDiagnostics = (await api.parseConfigFile("/tsconfig.json")).errors; + const host = { + getCurrentDirectory: () => "/", + getCanonicalFileName: (fileName: string) => fileName, + getNewLine: () => "\n", + }; + const clonedDiagnostics = [ + [{ ...diagnostics[0] }], + structuredClone(diagnostics), + JSON.parse(JSON.stringify(diagnostics)), + ]; + + for (const cloned of clonedDiagnostics) { + assert.ok((await program.formatDiagnostics(cloned, host)).includes("TS2322")); + } + assert.ok((await program.formatDiagnosticsWithColorAndContext(configDiagnostics, host)).includes(configText)); + } + finally { + await api.close(); + } + }); + test("getConfigFileNames and getConfigSourceFile", async () => { const baseConfigText = `{ "compilerOptions": { "strict": true } }`; const { api, fs } = spawnAPIWithFS({ @@ -5848,7 +5962,7 @@ describe("Program - diagnostics", () => { const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = await project.program.getBindDiagnostics("/src/index.ts"); - assert.deepEqual(diags, [ + assert.deepEqual(withoutFormattingContext(diags), [ { fileName: "/src/index.ts", ...rangeOf(source, "x", 0), @@ -5880,7 +5994,7 @@ describe("Program - diagnostics", () => { const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = await project.program.getProgramDiagnostics(); - assert.deepEqual(diags, [ + assert.deepEqual(withoutFormattingContext(diags), [ { fileName: "/tsconfig.json", ...rangeOf(config, `"bundler"`), @@ -5958,7 +6072,7 @@ describe("Program - diagnostics", () => { const snapshot = await api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = await project.program.getSyntacticDiagnostics(["/src/a.ts", "/src/b.ts"]); - assert.deepEqual(diags, [ + assert.deepEqual(withoutFormattingContext(diags), [ { fileName: "/src/a.ts", ...rangeOf(sourceA, "="), @@ -6738,6 +6852,11 @@ function rangeOf(source: string, searchString: string, occurrence: number = 0): return { pos: index, end: index + searchString.length }; } +function withoutFormattingContext(value: T): T { + const formattingKeys = new Set(["startPosition", "endPosition", "sourceLines"]); + return JSON.parse(JSON.stringify(value, (key, item) => formattingKeys.has(key) ? undefined : item)) as T; +} + function applyTextEdits(source: string, edits: readonly TextEdit[]): string { const sorted = [...edits].sort((a, b) => b.pos - a.pos); let result = source; diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 6974061a4a6fb..e0e70e594465f 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -5692,7 +5692,10 @@ describe("Program - diagnostics", () => { const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = project.program.getSyntacticDiagnostics("/src/index.ts"); - assert.deepEqual(diags, [{ + assert.deepEqual(diags[0].startPosition, { line: 0, character: 9 }); + assert.deepEqual(diags[0].endPosition, { line: 0, character: 10 }); + assert.deepEqual(diags[0].sourceLines, [{ line: 0, text: source }]); + assert.deepEqual(withoutFormattingContext(diags), [{ fileName: "/src/index.ts", ...rangeOf(source, "="), code: 1110, @@ -5717,7 +5720,7 @@ describe("Program - diagnostics", () => { const diags = project.program.getSemanticDiagnostics("/src/index.ts"); const declRange = rangeOf(source, "callback", 0); const assignRange = rangeOf(source, "callback", 1); - assert.deepEqual(diags, [{ + assert.deepEqual(withoutFormattingContext(diags), [{ fileName: "/src/index.ts", ...assignRange, code: 2322, @@ -5761,7 +5764,7 @@ describe("Program - diagnostics", () => { const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = project.program.getSuggestionDiagnostics("/src/index.ts"); - assert.deepEqual(diags, [{ + assert.deepEqual(withoutFormattingContext(diags), [{ fileName: "/src/index.ts", ...rangeOf(source, "_unused"), code: 6133, @@ -5785,7 +5788,7 @@ describe("Program - diagnostics", () => { const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = project.program.getConfigFileParsingDiagnostics(); - assert.deepEqual(diags, [{ + assert.deepEqual(withoutFormattingContext(diags), [{ fileName: "/tsconfig.json", ...rangeOf(config, `"invalid"`), code: 6046, @@ -5798,6 +5801,117 @@ describe("Program - diagnostics", () => { } }); + test("formatDiagnostics and formatDiagnosticsWithColorAndContext", () => { + const source = `const x: number = "oops";\n`; + const api = spawnAPI({ + "/tsconfig.json": `{ "compilerOptions": { "strict": true } }`, + "/src/index.ts": source, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const diags = project.program.getSemanticDiagnostics("/src/index.ts"); + assert.equal(diags.length, 1); + const host = { + getCurrentDirectory: () => "/SRC", + getCanonicalFileName: (fileName: string) => fileName.toLowerCase(), + getNewLine: () => "\r\n", + }; + + const plain = project.program.formatDiagnostics(diags, host); + assert.equal( + plain, + "index.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.\r\n", + ); + + const color = project.program.formatDiagnosticsWithColorAndContext(diags, host); + assert.ok(color.includes("TS2322: "), color); + assert.ok(color.includes(`const x: number = "oops";`), color); + assert.ok(color.includes("~"), color); + assert.ok(color.includes("\x1b["), color); + assert.ok(color.endsWith("\r\n"), color); + const doubled = project.program.formatDiagnosticsWithColorAndContext([diags[0], diags[0]], host); + assert.equal(doubled, color + color); + + const multiline = { + ...diags[0], + startPosition: { line: 0, character: 0 }, + endPosition: { line: 6, character: 5 }, + sourceLines: [ + { line: 0, text: "one\n" }, + { line: 1, text: "two\n" }, + { line: 5, text: "six\n" }, + { line: 6, text: "seven" }, + ], + }; + const multilineColor = project.program.formatDiagnosticsWithColorAndContext([multiline], host); + assert.ok(multilineColor.includes("..."), multilineColor); + assert.ok(multilineColor.includes("seven"), multilineColor); + } + finally { + api.close(); + } + }); + + test("formatDiagnostics resolves config-file context", () => { + const config = `{ "compilerOptions": { "target": "invalid" } }`; + const api = spawnAPI({ + "/tsconfig.json": config, + "/src/index.ts": `export const x = 1;`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const project = snapshot.getProject("/tsconfig.json")!; + const diags = project.program.getConfigFileParsingDiagnostics(); + const host = { + getCurrentDirectory: () => "/", + getCanonicalFileName: (fileName: string) => fileName, + getNewLine: () => "\n", + }; + + const plain = project.program.formatDiagnostics(diags, host); + assert.ok(plain.includes("tsconfig.json(1,34): error TS6046: "), plain); + + const color = project.program.formatDiagnosticsWithColorAndContext(diags, host); + assert.ok(color.includes(`"target": "invalid"`), color); + } + finally { + api.close(); + } + }); + + test("formatDiagnostics accepts cloned diagnostics and parseConfigFile diagnostics", () => { + const configText = `{ "compilerOptions": { "target": "invalid" } }`; + const api = spawnAPI({ + "/tsconfig.json": configText, + "/src/index.ts": `const x: number = "oops";`, + }); + try { + const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); + const program = snapshot.getProject("/tsconfig.json")!.program; + const diagnostics = program.getSemanticDiagnostics("/src/index.ts"); + const configDiagnostics = (api.parseConfigFile("/tsconfig.json")).errors; + const host = { + getCurrentDirectory: () => "/", + getCanonicalFileName: (fileName: string) => fileName, + getNewLine: () => "\n", + }; + const clonedDiagnostics = [ + [{ ...diagnostics[0] }], + structuredClone(diagnostics), + JSON.parse(JSON.stringify(diagnostics)), + ]; + + for (const cloned of clonedDiagnostics) { + assert.ok((program.formatDiagnostics(cloned, host)).includes("TS2322")); + } + assert.ok((program.formatDiagnosticsWithColorAndContext(configDiagnostics, host)).includes(configText)); + } + finally { + api.close(); + } + }); + test("getConfigFileNames and getConfigSourceFile", () => { const baseConfigText = `{ "compilerOptions": { "strict": true } }`; const { api, fs } = spawnAPIWithFS({ @@ -5856,7 +5970,7 @@ describe("Program - diagnostics", () => { const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = project.program.getBindDiagnostics("/src/index.ts"); - assert.deepEqual(diags, [ + assert.deepEqual(withoutFormattingContext(diags), [ { fileName: "/src/index.ts", ...rangeOf(source, "x", 0), @@ -5888,7 +6002,7 @@ describe("Program - diagnostics", () => { const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = project.program.getProgramDiagnostics(); - assert.deepEqual(diags, [ + assert.deepEqual(withoutFormattingContext(diags), [ { fileName: "/tsconfig.json", ...rangeOf(config, `"bundler"`), @@ -5966,7 +6080,7 @@ describe("Program - diagnostics", () => { const snapshot = api.updateSnapshot({ openProject: "/tsconfig.json" }); const project = snapshot.getProject("/tsconfig.json")!; const diags = project.program.getSyntacticDiagnostics(["/src/a.ts", "/src/b.ts"]); - assert.deepEqual(diags, [ + assert.deepEqual(withoutFormattingContext(diags), [ { fileName: "/src/a.ts", ...rangeOf(sourceA, "="), @@ -6723,6 +6837,11 @@ function rangeOf(source: string, searchString: string, occurrence: number = 0): return { pos: index, end: index + searchString.length }; } +function withoutFormattingContext(value: T): T { + const formattingKeys = new Set(["startPosition", "endPosition", "sourceLines"]); + return JSON.parse(JSON.stringify(value, (key, item) => formattingKeys.has(key) ? undefined : item)) as T; +} + function applyTextEdits(source: string, edits: readonly TextEdit[]): string { const sorted = [...edits].sort((a, b) => b.pos - a.pos); let result = source; diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 45793d96a61be..fa4418b945680 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -11,6 +11,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/collections" "github.com/microsoft/TypeScript/tsc/internal/core" "github.com/microsoft/TypeScript/tsc/internal/diagnostics" + "github.com/microsoft/TypeScript/tsc/internal/diagnosticwriter" "github.com/microsoft/TypeScript/tsc/internal/jsnum" "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/locale" @@ -18,6 +19,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/packagejson" "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/scanner" "github.com/microsoft/TypeScript/tsc/internal/tsoptions" "github.com/microsoft/TypeScript/tsc/internal/tspath" ) @@ -189,7 +191,6 @@ const ( MethodGetProgramDiagnostics Method = "getProgramDiagnostics" MethodGetGlobalDiagnostics Method = "getGlobalDiagnostics" MethodGetConfigFileParsingDiagnostics Method = "getConfigFileParsingDiagnostics" - // Emitter methods MethodPrintNode Method = "printNode" MethodFormatNodeForInsertion Method = "formatNodeForInsertion" @@ -1419,10 +1420,18 @@ type DiagnosticResponse struct { Pos int `json:"pos"` // End is the end position of the diagnostic in the source file. End int `json:"end"` + // StartPosition is the zero-based line and UTF-16 character position of Pos. + StartPosition *DiagnosticPositionResponse `json:"startPosition,omitempty"` + // EndPosition is the zero-based line and UTF-16 character position of End. + EndPosition *DiagnosticPositionResponse `json:"endPosition,omitempty"` + // SourceLines contains the source lines needed to render this diagnostic with context. + SourceLines []*DiagnosticSourceLineResponse `json:"sourceLines,omitempty"` // Code is the diagnostic error code. Code int32 `json:"code"` // Category is the diagnostic category (error, warning, suggestion, message). Category diagnostics.Category `json:"category"` + // Source is a custom diagnostic-code prefix. An empty value uses the default "TS". + Source string `json:"source,omitempty"` // Text is the localized diagnostic message text. Text string `json:"text"` // ReportsUnnecessary indicates this diagnostic highlights unnecessary code. @@ -1435,41 +1444,89 @@ type DiagnosticResponse struct { RelatedInformation []*DiagnosticResponse `json:"relatedInformation,omitempty"` } +type DiagnosticPositionResponse struct { + Line int `json:"line"` + Character core.UTF16Offset `json:"character"` +} + +type DiagnosticSourceLineResponse struct { + Line int `json:"line"` + Text string `json:"text"` +} + +func diagnosticSourceLines(file diagnosticwriter.FileLike, firstLine int, lastLine int) []*DiagnosticSourceLineResponse { + lineMap := file.ECMALineMap() + if len(lineMap) == 0 { + return nil + } + + lines := make([]int, 0, min(lastLine-firstLine+1, 4)) + if lastLine-firstLine >= 4 { + lines = append(lines, firstLine, firstLine+1, lastLine-1, lastLine) + } else { + for line := firstLine; line <= lastLine; line++ { + lines = append(lines, line) + } + } + + text := file.Text() + result := make([]*DiagnosticSourceLineResponse, 0, len(lines)) + for _, line := range lines { + start := int(lineMap[line]) + end := len(text) + if line+1 < len(lineMap) { + end = int(lineMap[line+1]) + } + result = append(result, &DiagnosticSourceLineResponse{Line: line, Text: text[start:end]}) + } + return result +} + // NewDiagnosticResponse converts an ast.Diagnostic to a DiagnosticResponse. func NewDiagnosticResponse(d *ast.Diagnostic) *DiagnosticResponse { - pos := d.Pos() - end := d.End() + return newDiagnosticResponse(diagnosticwriter.WrapASTDiagnostic(d)) +} + +func newDiagnosticResponse(d *diagnosticwriter.ASTDiagnostic) *DiagnosticResponse { file := d.File() + pos, end := d.Pos(), d.End() if file != nil { - positionMap := file.GetPositionMap() - pos = positionMap.UTF8ToUTF16(pos) - end = positionMap.UTF8ToUTF16(end) + pos = max(0, min(pos, len(file.Text()))) + end = max(pos, min(end, len(file.Text()))) } resp := &DiagnosticResponse{ Pos: pos, End: end, Code: d.Code(), Category: d.Category(), + Source: d.Source(), Text: d.Localize(locale.Default), - ReportsUnnecessary: d.ReportsUnnecessary(), - ReportsDeprecated: d.ReportsDeprecated(), + ReportsUnnecessary: d.Diagnostic.ReportsUnnecessary(), + ReportsDeprecated: d.Diagnostic.ReportsDeprecated(), } if file != nil { resp.FileName = file.FileName() + resp.Pos = int(core.UTF16Len(file.Text()[:pos])) + resp.End = int(core.UTF16Len(file.Text()[:end])) + startLine, startCharacter := scanner.GetECMALineAndUTF16CharacterOfPosition(file, pos) + endLine, endCharacter := scanner.GetECMALineAndUTF16CharacterOfPosition(file, end) + resp.StartPosition = &DiagnosticPositionResponse{Line: startLine, Character: startCharacter} + resp.EndPosition = &DiagnosticPositionResponse{Line: endLine, Character: endCharacter} + resp.SourceLines = diagnosticSourceLines(file, startLine, endLine) } if chain := d.MessageChain(); len(chain) > 0 { resp.MessageChain = make([]*DiagnosticResponse, len(chain)) for i, c := range chain { - resp.MessageChain[i] = NewDiagnosticResponse(c) + resp.MessageChain[i] = newDiagnosticResponse(c.(*diagnosticwriter.ASTDiagnostic)) } } if related := d.RelatedInformation(); len(related) > 0 { resp.RelatedInformation = make([]*DiagnosticResponse, len(related)) for i, r := range related { - resp.RelatedInformation[i] = NewDiagnosticResponse(r) + resp.RelatedInformation[i] = newDiagnosticResponse(r.(*diagnosticwriter.ASTDiagnostic)) } } diff --git a/tsc/internal/api/proto_test.go b/tsc/internal/api/proto_test.go index 34c9dcc13fd39..e5add0c137345 100644 --- a/tsc/internal/api/proto_test.go +++ b/tsc/internal/api/proto_test.go @@ -64,7 +64,7 @@ func TestDocumentIdentifierUnmarshalJSON(t *testing.T) { } } -func TestNewDiagnosticResponseUsesUTF16Offsets(t *testing.T) { +func TestNewDiagnosticResponseIncludesFormattingContext(t *testing.T) { t.Parallel() text := "const 💩 = 1;" @@ -78,6 +78,27 @@ func TestNewDiagnosticResponseUsesUTF16Offsets(t *testing.T) { assert.Equal(t, resp.Pos, 9) assert.Equal(t, resp.End, 10) + assert.DeepEqual(t, resp.StartPosition, &api.DiagnosticPositionResponse{Line: 0, Character: 9}) + assert.DeepEqual(t, resp.EndPosition, &api.DiagnosticPositionResponse{Line: 0, Character: 10}) + assert.DeepEqual(t, resp.SourceLines, []*api.DiagnosticSourceLineResponse{{Line: 0, Text: text}}) assert.Equal(t, resp.Pos, file.GetPositionMap().UTF8ToUTF16(pos)) assert.Equal(t, resp.End, file.GetPositionMap().UTF8ToUTF16(end)) } + +func TestNewDiagnosticResponseTruncatesLongFormattingContext(t *testing.T) { + t.Parallel() + + text := "one\ntwo\nthree\nfour\nfive\nsix\nseven" + file := parser.ParseSourceFile(ast.SourceFileParseOptions{FileName: "/multiline.ts"}, text, core.ScriptKindTS) + diag := ast.NewDiagnostic(file, core.NewTextRange(0, len(text)), diagnostics.Expression_expected) + resp := api.NewDiagnosticResponse(diag) + + assert.DeepEqual(t, resp.StartPosition, &api.DiagnosticPositionResponse{Line: 0, Character: 0}) + assert.DeepEqual(t, resp.EndPosition, &api.DiagnosticPositionResponse{Line: 6, Character: 5}) + assert.DeepEqual(t, resp.SourceLines, []*api.DiagnosticSourceLineResponse{ + {Line: 0, Text: "one\n"}, + {Line: 1, Text: "two\n"}, + {Line: 5, Text: "six\n"}, + {Line: 6, Text: "seven"}, + }) +} diff --git a/tsc/internal/diagnosticwriter/diagnosticwriter.go b/tsc/internal/diagnosticwriter/diagnosticwriter.go index 57aee4053410c..b4d00fafa2835 100644 --- a/tsc/internal/diagnosticwriter/diagnosticwriter.go +++ b/tsc/internal/diagnosticwriter/diagnosticwriter.go @@ -393,7 +393,7 @@ func writeWithStyleAndReset(output io.Writer, text string, formatStyle string) { func WriteLocation(output io.Writer, file FileLike, pos int, formatOpts *FormattingOptions, writeWithStyleAndReset FormattedWriter) { firstLine, firstChar := scanner.GetECMALineAndUTF16CharacterOfPosition(file, pos) var relativeFileName string - if formatOpts != nil { + if formatOpts != nil && !fileNameIsFormatted(file) { relativeFileName = tspath.ConvertToRelativePath(file.FileName(), formatOpts.ComparePathsOptions) } else { relativeFileName = file.FileName() @@ -556,7 +556,10 @@ func WriteFormatDiagnostic(output io.Writer, diagnostic Diagnostic, formatOpts * if diagnostic.File() != nil { line, character := scanner.GetECMALineAndUTF16CharacterOfPosition(diagnostic.File(), diagnostic.Pos()) fileName := diagnostic.File().FileName() - relativeFileName := tspath.ConvertToRelativePath(fileName, formatOpts.ComparePathsOptions) + relativeFileName := fileName + if !fileNameIsFormatted(diagnostic.File()) { + relativeFileName = tspath.ConvertToRelativePath(fileName, formatOpts.ComparePathsOptions) + } fmt.Fprintf(output, "%s(%d,%d): ", relativeFileName, line+1, int(character)+1) } @@ -565,6 +568,11 @@ func WriteFormatDiagnostic(output io.Writer, diagnostic Diagnostic, formatOpts * fmt.Fprint(output, formatOpts.NewLine) } +func fileNameIsFormatted(file FileLike) bool { + formatted, ok := file.(interface{ FileNameIsFormatted() bool }) + return ok && formatted.FileNameIsFormatted() +} + func FormatDiagnosticsStatusWithColorAndTime(output io.Writer, time string, diag Diagnostic, formatOpts *FormattingOptions) { fmt.Fprint(output, "[") writeWithStyleAndReset(output, time, foregroundColorEscapeGrey)