diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index ad787fb62495d..57c397d7380fa 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -4,6 +4,7 @@ import { DiagnosticCategory } from "#enums/diagnosticCategory"; import { ElementFlags } from "#enums/elementFlags"; import { EmitOnly } from "#enums/emitOnly"; import { ModuleKind } from "#enums/moduleKind"; +import { NewLineKind } from "#enums/newLineKind"; import { NodeBuilderFlags } from "#enums/nodeBuilderFlags"; import { ObjectFlags } from "#enums/objectFlags"; import { SignatureFlags } from "#enums/signatureFlags"; @@ -102,6 +103,7 @@ import type { EmitOutput, EmitOutputFile, EmitResult, + FormatDiagnosticsHost, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, @@ -132,6 +134,7 @@ import type { UnionType, } from "./types.ts"; +export { formatDiagnostics, formatDiagnosticsWithColorAndContext } from "../diagnosticFormatter.ts"; export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypeFormatFlags, TypePredicateKind }; export type { @@ -154,6 +157,7 @@ export type { EmitOutput, EmitOutputFile, EmitResult, + FormatDiagnosticsHost, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, @@ -205,10 +209,12 @@ export interface TranspileOutput { sourceMapText?: string; } -export class API { +export class API implements FormatDiagnosticsHost { private client: Client; private sourceFileCache: SourceFileCache; private toPath: ((fileName: string) => Path) | undefined; + private currentDirectory: string | undefined; + private getCanonicalFileNameWorker: ((fileName: string) => string) | undefined; private initialized: boolean = false; private activeSnapshots: Set = new Set(); private latestSnapshot: Snapshot | undefined; @@ -235,11 +241,31 @@ export class API { const response = await this.client.apiRequest("initialize", null); const getCanonicalFileName = createGetCanonicalFileName(response.useCaseSensitiveFileNames); const currentDirectory = response.currentDirectory; + this.getCanonicalFileNameWorker = getCanonicalFileName; + this.currentDirectory = currentDirectory; this.toPath = (fileName: string) => toPath(fileName, currentDirectory, getCanonicalFileName) as Path; this.initialized = true; } } + getCurrentDirectory(): string { + if (this.currentDirectory === undefined) { + throw new Error("API has not been initialized"); + } + return this.currentDirectory; + } + + getCanonicalFileName(fileName: string): string { + if (this.getCanonicalFileNameWorker === undefined) { + throw new Error("API has not been initialized"); + } + return this.getCanonicalFileNameWorker(fileName); + } + + getNewLine(): string { + return "\n"; + } + async parseConfigFile(file: DocumentIdentifier): Promise { await this.ensureInitialized(); return this.client.apiRequest("parseConfigFile", { file }); @@ -304,6 +330,7 @@ export class API { this.client, this.sourceFileCache, this.toPath!, + this, () => { this.activeSnapshots.delete(snapshot); if (snapshot !== this.latestSnapshot) { @@ -353,6 +380,7 @@ export class API { this.client, this.sourceFileCache, this.toPath!, + this, () => { this.activeSnapshots.delete(snapshot); this.sourceFileCache.releaseSnapshot(snapshot.id); @@ -432,6 +460,7 @@ export class Snapshot { client: Client, sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, + formatDiagnosticsHost: FormatDiagnosticsHost, onDispose: () => void, ) { this.id = data.snapshot; @@ -442,7 +471,7 @@ export class Snapshot { this.snapshotRegistry = new SnapshotObjectRegistry(client, this.id, projectId => this.projectMap.get(projectId)); for (const projData of data.projects) { - const project = new Project(projData, this.id, client, sourceFileCache, toPath, this.snapshotRegistry); + const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry); this.projectMap.set(toPath(projData.configFileName), project); } @@ -773,6 +802,7 @@ class ProjectObjectRegistry { export class Project { readonly id: Path; readonly configFileName: string; + readonly currentDirectory: string; readonly parsedCommandLine: ParsedCommandLine; /** @deprecated Use `parsedCommandLine.options`. */ readonly compilerOptions: CompilerOptions; @@ -792,10 +822,12 @@ export class Project { client: Client, sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, + formatDiagnosticsHost: FormatDiagnosticsHost, snapshotRegistry: SnapshotObjectRegistry, ) { this.id = data.id as Path; this.configFileName = data.configFileName; + this.currentDirectory = data.currentDirectory; if (!data.parsedCommandLine?.options) { throw new Error(`Project '${data.configFileName}' has no parsed command line`); } @@ -810,6 +842,7 @@ export class Project { client, sourceFileCache, toPath, + formatDiagnosticsHost, ); const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry); this.checker = new Checker( @@ -946,12 +979,13 @@ export class LanguageService { } } -export class Program { +export class Program implements FormatDiagnosticsHost { private snapshotId: number; private project: Project; private client: Client; private sourceFileCache: SourceFileCache; private toPath: (fileName: string) => Path; + private formatDiagnosticsHost: FormatDiagnosticsHost; private decoder = new Wtf8Decoder(); private sourceFileMetadataCache = new Map>(); @@ -961,12 +995,26 @@ export class Program { client: Client, sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, + formatDiagnosticsHost: FormatDiagnosticsHost, ) { this.snapshotId = snapshotId; this.project = project; this.client = client; this.sourceFileCache = sourceFileCache; this.toPath = toPath; + this.formatDiagnosticsHost = formatDiagnosticsHost; + } + + getCurrentDirectory(): string { + return this.project.currentDirectory; + } + + getCanonicalFileName(fileName: string): string { + return this.formatDiagnosticsHost.getCanonicalFileName(fileName); + } + + getNewLine(): string { + return this.project.compilerOptions.newLine === NewLineKind.CRLF ? "\r\n" : "\n"; } getCompilerOptions(): CompilerOptions { 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..32bfbb57c8a6c --- /dev/null +++ b/packages/typescript/src/api/diagnosticFormatter.ts @@ -0,0 +1,203 @@ +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 endCharacter = startPosition.line === endPosition.line && + startPosition.character === endPosition.character + ? endPosition.character + 1 + : endPosition.character; + 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 + ? endCharacter + : lineContent.length; + context += " ".repeat(startPosition.character); + context += "~".repeat(Math.max(0, lastCharacter - startPosition.character)); + } + else if (sourceLine.line === endPosition.line) { + context += "~".repeat(endCharacter); + } + 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 (let i = 0; i < diagnostics.length; i++) { + if (i > 0) { + output += host.getNewLine(); + } + const diagnostic = diagnostics[i]; + 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); + output += host.getNewLine(); + } + + if (diagnostic.relatedInformation?.length) { + for (const related of diagnostic.relatedInformation) { + if (related.fileName && related.startPosition) { + output += host.getNewLine(); + output += halfIndent + formatLocation(related, host); + output += " - " + flattenDiagnosticMessage(related, host.getNewLine()); + output += formatCodeSpan(related, indent, foregroundColorEscapeCyan, host); + } + 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..f08c52ec657c1 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -281,6 +281,7 @@ export interface GetDefaultProjectForFileParams { export interface ProjectResponse { id: string; configFileName: string; + currentDirectory: string; parsedCommandLine: ConfigFileResponse; /** @deprecated Use parsedCommandLine.fileNames. */ rootFiles: string[]; @@ -763,10 +764,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 +1043,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..73cd24110ff68 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -12,6 +12,7 @@ import { DiagnosticCategory } from "#enums/diagnosticCategory"; import { ElementFlags } from "#enums/elementFlags"; import { EmitOnly } from "#enums/emitOnly"; import { ModuleKind } from "#enums/moduleKind"; +import { NewLineKind } from "#enums/newLineKind"; import { NodeBuilderFlags } from "#enums/nodeBuilderFlags"; import { ObjectFlags } from "#enums/objectFlags"; import { SignatureFlags } from "#enums/signatureFlags"; @@ -110,6 +111,7 @@ import type { EmitOutput, EmitOutputFile, EmitResult, + FormatDiagnosticsHost, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, @@ -140,6 +142,7 @@ import type { UnionType, } from "./types.ts"; +export { formatDiagnostics, formatDiagnosticsWithColorAndContext } from "../diagnosticFormatter.ts"; export { documentURIToFileName, fileNameToDocumentURI } from "../path.ts"; export { CheckFlags, CompletionItemKind, DiagnosticCategory, ElementFlags, EmitOnly, ModifierFlags, ModuleKind, NodeBuilderFlags, ObjectFlags, SignatureFlags, SignatureKind, SymbolFlags, TypeFlags, TypeFormatFlags, TypePredicateKind }; export type { @@ -162,6 +165,7 @@ export type { EmitOutput, EmitOutputFile, EmitResult, + FormatDiagnosticsHost, FreshableType, GetImportEditsForSymbolsOptions, IdentifierTypePredicate, @@ -213,10 +217,12 @@ export interface TranspileOutput { sourceMapText?: string; } -export class API { +export class API implements FormatDiagnosticsHost { private client: Client; private sourceFileCache: SourceFileCache; private toPath: ((fileName: string) => Path) | undefined; + private currentDirectory: string | undefined; + private getCanonicalFileNameWorker: ((fileName: string) => string) | undefined; private initialized: boolean = false; private activeSnapshots: Set = new Set(); private latestSnapshot: Snapshot | undefined; @@ -243,11 +249,31 @@ export class API { const response = this.client.apiRequest("initialize", null); const getCanonicalFileName = createGetCanonicalFileName(response.useCaseSensitiveFileNames); const currentDirectory = response.currentDirectory; + this.getCanonicalFileNameWorker = getCanonicalFileName; + this.currentDirectory = currentDirectory; this.toPath = (fileName: string) => toPath(fileName, currentDirectory, getCanonicalFileName) as Path; this.initialized = true; } } + getCurrentDirectory(): string { + if (this.currentDirectory === undefined) { + throw new Error("API has not been initialized"); + } + return this.currentDirectory; + } + + getCanonicalFileName(fileName: string): string { + if (this.getCanonicalFileNameWorker === undefined) { + throw new Error("API has not been initialized"); + } + return this.getCanonicalFileNameWorker(fileName); + } + + getNewLine(): string { + return "\n"; + } + parseConfigFile(file: DocumentIdentifier): ParsedCommandLine { this.ensureInitialized(); return this.client.apiRequest("parseConfigFile", { file }); @@ -312,6 +338,7 @@ export class API { this.client, this.sourceFileCache, this.toPath!, + this, () => { this.activeSnapshots.delete(snapshot); if (snapshot !== this.latestSnapshot) { @@ -361,6 +388,7 @@ export class API { this.client, this.sourceFileCache, this.toPath!, + this, () => { this.activeSnapshots.delete(snapshot); this.sourceFileCache.releaseSnapshot(snapshot.id); @@ -440,6 +468,7 @@ export class Snapshot { client: Client, sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, + formatDiagnosticsHost: FormatDiagnosticsHost, onDispose: () => void, ) { this.id = data.snapshot; @@ -450,7 +479,7 @@ export class Snapshot { this.snapshotRegistry = new SnapshotObjectRegistry(client, this.id, projectId => this.projectMap.get(projectId)); for (const projData of data.projects) { - const project = new Project(projData, this.id, client, sourceFileCache, toPath, this.snapshotRegistry); + const project = new Project(projData, this.id, client, sourceFileCache, toPath, formatDiagnosticsHost, this.snapshotRegistry); this.projectMap.set(toPath(projData.configFileName), project); } @@ -781,6 +810,7 @@ class ProjectObjectRegistry { export class Project { readonly id: Path; readonly configFileName: string; + readonly currentDirectory: string; readonly parsedCommandLine: ParsedCommandLine; /** @deprecated Use `parsedCommandLine.options`. */ readonly compilerOptions: CompilerOptions; @@ -800,10 +830,12 @@ export class Project { client: Client, sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, + formatDiagnosticsHost: FormatDiagnosticsHost, snapshotRegistry: SnapshotObjectRegistry, ) { this.id = data.id as Path; this.configFileName = data.configFileName; + this.currentDirectory = data.currentDirectory; if (!data.parsedCommandLine?.options) { throw new Error(`Project '${data.configFileName}' has no parsed command line`); } @@ -818,6 +850,7 @@ export class Project { client, sourceFileCache, toPath, + formatDiagnosticsHost, ); const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry); this.checker = new Checker( @@ -954,12 +987,13 @@ export class LanguageService { } } -export class Program { +export class Program implements FormatDiagnosticsHost { private snapshotId: number; private project: Project; private client: Client; private sourceFileCache: SourceFileCache; private toPath: (fileName: string) => Path; + private formatDiagnosticsHost: FormatDiagnosticsHost; private decoder = new Wtf8Decoder(); private sourceFileMetadataCache = new Map(); @@ -969,12 +1003,26 @@ export class Program { client: Client, sourceFileCache: SourceFileCache, toPath: (fileName: string) => Path, + formatDiagnosticsHost: FormatDiagnosticsHost, ) { this.snapshotId = snapshotId; this.project = project; this.client = client; this.sourceFileCache = sourceFileCache; this.toPath = toPath; + this.formatDiagnosticsHost = formatDiagnosticsHost; + } + + getCurrentDirectory(): string { + return this.project.currentDirectory; + } + + getCanonicalFileName(fileName: string): string { + return this.formatDiagnosticsHost.getCanonicalFileName(fileName); + } + + getNewLine(): string { + return this.project.compilerOptions.newLine === NewLineKind.CRLF ? "\r\n" : "\n"; } getCompilerOptions(): CompilerOptions { 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..a63cff7fa6f04 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, @@ -5848,7 +5851,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 +5883,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 +5961,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 +6741,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/diagnosticFormatter.test.ts b/packages/typescript/test/diagnosticFormatter.test.ts new file mode 100644 index 0000000000000..bce9f0f98371a --- /dev/null +++ b/packages/typescript/test/diagnosticFormatter.test.ts @@ -0,0 +1,133 @@ +import { + API, + formatDiagnostics, + formatDiagnosticsWithColorAndContext, +} from "@typescript/typescript/unstable/async"; +import { createVirtualFileSystem } from "@typescript/typescript/unstable/fs"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; + +describe("diagnosticFormatter", () => { + test("formats diagnostics with a configured program host", async () => { + const source = `const x: number = "oops";\n`; + const api = spawnAPI({ + "/project/tsconfig.json": `{ "compilerOptions": { "strict": true, "newLine": "crlf" } }`, + "/project/index.ts": source, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/project/tsconfig.json" }); + const program = snapshot.getProject("/project/tsconfig.json")!.program; + const diagnostics = await program.getSemanticDiagnostics("/project/index.ts"); + assert.equal(diagnostics.length, 1); + assert.equal(api.getCurrentDirectory(), "/workspace"); + assert.equal(api.getNewLine(), "\n"); + assert.equal(program.getCurrentDirectory(), "/project"); + assert.equal(program.getNewLine(), "\r\n"); + + const plain = formatDiagnostics(diagnostics, program); + assert.equal( + plain, + "index.ts(1,7): error TS2322: Type 'string' is not assignable to type 'number'.\r\n", + ); + + const color = formatDiagnosticsWithColorAndContext(diagnostics, program); + assert.ok(color.includes("TS2322: "), color); + assert.ok(color.includes(source.trim()), color); + assert.ok(color.includes("~"), color); + assert.ok(color.includes("\x1b["), color); + assert.ok(color.endsWith("\r\n"), color); + const doubled = formatDiagnosticsWithColorAndContext([diagnostics[0], diagnostics[0]], program); + assert.equal(doubled, color + "\r\n" + color); + + const zeroWidth = { + ...diagnostics[0], + end: diagnostics[0].pos, + endPosition: diagnostics[0].startPosition!, + }; + const zeroWidthColor = formatDiagnosticsWithColorAndContext([zeroWidth], program); + assert.match(zeroWidthColor, /\x1b\[91m +~\x1b\[0m/); + + const relatedText = "Related information"; + const withRelated = { + ...diagnostics[0], + relatedInformation: [{ ...diagnostics[0], text: relatedText }], + }; + const relatedColor = formatDiagnosticsWithColorAndContext([withRelated], program); + const relatedMessage = relatedColor.indexOf(` - ${relatedText}`); + assert.notEqual(relatedMessage, -1, relatedColor); + assert.ok(relatedColor.indexOf(source.trim(), relatedMessage) > relatedMessage, relatedColor); + + const multiline = { + ...diagnostics[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 = formatDiagnosticsWithColorAndContext([multiline], program); + assert.ok(multilineColor.includes("..."), multilineColor); + assert.ok(multilineColor.includes("seven"), multilineColor); + } + finally { + await api.close(); + } + }); + + test("uses the API host for standalone diagnostics", async () => { + const configText = `{ "compilerOptions": { "target": "invalid" } }`; + const api = spawnAPI({ + "/workspace/tsconfig.json": configText, + "/workspace/index.ts": `const x: number = "oops";`, + }); + try { + const snapshot = await api.updateSnapshot({ openProject: "/workspace/tsconfig.json" }); + const program = snapshot.getProject("/workspace/tsconfig.json")!.program; + const diagnostics = await program.getSemanticDiagnostics("/workspace/index.ts"); + const configDiagnostics = (await api.parseConfigFile("/workspace/tsconfig.json")).errors; + const clonedDiagnostics = [ + [{ ...diagnostics[0] }], + structuredClone(diagnostics), + JSON.parse(JSON.stringify(diagnostics)), + ]; + + for (const cloned of clonedDiagnostics) { + assert.ok(formatDiagnostics(cloned, api).includes("TS2322")); + } + assert.ok(formatDiagnostics(configDiagnostics, api).includes("tsconfig.json(1,34): error TS6046: ")); + assert.ok(formatDiagnosticsWithColorAndContext(configDiagnostics, api).includes(configText)); + } + finally { + await api.close(); + } + }); + + test("uses the API directory and LF defaults for inferred projects", async () => { + const api = spawnAPI({ + "/workspace/index.ts": `const x: number = "oops";`, + }); + try { + const snapshot = await api.updateSnapshot({ openFiles: ["/workspace/index.ts"] }); + const project = await snapshot.getDefaultProjectForFile("/workspace/index.ts"); + assert.ok(project); + assert.equal(project.program.getCurrentDirectory(), api.getCurrentDirectory()); + assert.equal(project.program.getNewLine(), "\n"); + } + finally { + await api.close(); + } + }); +}); + +function spawnAPI(files: Record): API { + return new API({ + cwd: "/workspace", + fs: createVirtualFileSystem(files), + }); +} diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index 6974061a4a6fb..614c2b08f69b6 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, @@ -5856,7 +5859,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 +5891,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 +5969,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 +6726,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..65b3cb35d6c7a 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" @@ -646,6 +647,7 @@ type GetDefaultProjectForFileParams struct { type ProjectResponse struct { Id ProjectID `json:"id"` ConfigFileName string `json:"configFileName"` + CurrentDirectory string `json:"currentDirectory"` ParsedCommandLine *ConfigFileResponse `json:"parsedCommandLine" nonnil:"true"` // Deprecated: Use parsedCommandLine.fileNames. RootFiles []string `json:"rootFiles" nonnil:"true"` @@ -713,6 +715,7 @@ func NewProjectResponse(p *project.Project) *ProjectResponse { return &ProjectResponse{ Id: ProjectHandle(p), ConfigFileName: p.Name(), + CurrentDirectory: p.CurrentDirectory(), ParsedCommandLine: NewConfigFileResponse(p.CommandLine), RootFiles: p.CommandLine.FileNames(), CompilerOptions: p.CommandLine.CompilerOptions(), @@ -1419,10 +1422,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 +1446,95 @@ 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() + if sourceFile, ok := file.(*ast.SourceFile); ok { + positionMap := sourceFile.GetPositionMap() + resp.Pos = positionMap.UTF8ToUTF16(pos) + resp.End = positionMap.UTF8ToUTF16(end) + } else { + 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/project/project.go b/tsc/internal/project/project.go index 8f64ee3303274..c57d655aa171d 100644 --- a/tsc/internal/project/project.go +++ b/tsc/internal/project/project.go @@ -195,6 +195,10 @@ func (p *Project) Name() string { return p.configFileName } +func (p *Project) CurrentDirectory() string { + return p.currentDirectory +} + // DisplayName returns a short, human-readable name for the project, // relative to the given workspace root directory. // For configured projects, this is the config file path made relative.