Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions packages/typescript/src/api/async/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -102,6 +103,7 @@ import type {
EmitOutput,
EmitOutputFile,
EmitResult,
FormatDiagnosticsHost,
FreshableType,
GetImportEditsForSymbolsOptions,
IdentifierTypePredicate,
Expand Down Expand Up @@ -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 {
Expand All @@ -154,6 +157,7 @@ export type {
EmitOutput,
EmitOutputFile,
EmitResult,
FormatDiagnosticsHost,
FreshableType,
GetImportEditsForSymbolsOptions,
IdentifierTypePredicate,
Expand Down Expand Up @@ -205,10 +209,12 @@ export interface TranspileOutput {
sourceMapText?: string;
}

export class API<FromLSP extends boolean = false> {
export class API<FromLSP extends boolean = false> 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<Snapshot> = new Set();
private latestSnapshot: Snapshot | undefined;
Expand All @@ -235,11 +241,31 @@ export class API<FromLSP extends boolean = false> {
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<ParsedCommandLine> {
await this.ensureInitialized();
return this.client.apiRequest("parseConfigFile", { file });
Expand Down Expand Up @@ -304,6 +330,7 @@ export class API<FromLSP extends boolean = false> {
this.client,
this.sourceFileCache,
this.toPath!,
this,
() => {
this.activeSnapshots.delete(snapshot);
if (snapshot !== this.latestSnapshot) {
Expand Down Expand Up @@ -353,6 +380,7 @@ export class API<FromLSP extends boolean = false> {
this.client,
this.sourceFileCache,
this.toPath!,
this,
() => {
this.activeSnapshots.delete(snapshot);
this.sourceFileCache.releaseSnapshot(snapshot.id);
Expand Down Expand Up @@ -432,6 +460,7 @@ export class Snapshot {
client: Client,
sourceFileCache: SourceFileCache,
toPath: (fileName: string) => Path,
formatDiagnosticsHost: FormatDiagnosticsHost,
onDispose: () => void,
) {
this.id = data.snapshot;
Expand All @@ -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);
}

Expand Down Expand Up @@ -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;
Expand All @@ -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`);
}
Expand All @@ -810,6 +842,7 @@ export class Project {
client,
sourceFileCache,
toPath,
formatDiagnosticsHost,
);
const objectRegistry = new ProjectObjectRegistry(client, snapshotId, this, snapshotRegistry);
this.checker = new Checker(
Expand Down Expand Up @@ -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<Path, Promise<SourceFileMetadata | undefined>>();

Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions packages/typescript/src/api/async/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
203 changes: 203 additions & 0 deletions packages/typescript/src/api/diagnosticFormatter.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading