Skip to content

Commit

Permalink
Implement CodeActions
Browse files Browse the repository at this point in the history
This introduces CodeActions to the GlintLanguageServer. It is
responsible for translating LSP requests into TS lanaguage server
CodeFixActions and then back CodeActions
  • Loading branch information
chadhietala committed Jan 14, 2023
1 parent 2cb0894 commit 1b207a8
Show file tree
Hide file tree
Showing 10 changed files with 520 additions and 7 deletions.
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function analyzeProject(projectDirectory: string = process.cwd()): Projec
let documents = new DocumentCache(glintConfig);
let transformManager = new TransformManager(glintConfig, documents);
let languageServer = new GlintLanguageServer(glintConfig, documents, transformManager);

let shutdown = (): void => languageServer.dispose();

return { glintConfig, transformManager, languageServer, shutdown };
Expand Down
89 changes: 87 additions & 2 deletions packages/core/src/language-server/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import {
SymbolInformation,
TextDocuments,
TextDocumentSyncKind,
InitializeParams as BaseInitializeParams,
CodeActionTriggerKind,
CodeActionKind,
} from 'vscode-languageserver';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { GlintCompletionItem } from './glint-language-server.js';
import { LanguageServerPool } from './pool.js';
import { GetIRRequest } from './messages.cjs';
import { ConfigManager } from './config-manager.js';
import type * as ts from 'typescript';

export const capabilities: ServerCapabilities = {
textDocumentSync: TextDocumentSyncKind.Full,
Expand All @@ -21,6 +26,7 @@ export const capabilities: ServerCapabilities = {
},
referencesProvider: true,
hoverProvider: true,
codeActionProvider: true,
definitionProvider: true,
workspaceSymbolProvider: true,
renameProvider: {
Expand All @@ -32,10 +38,58 @@ export type BindingArgs = {
openDocuments: TextDocuments<TextDocument>;
connection: Connection;
pool: LanguageServerPool;
configManager: ConfigManager;
};

export function bindLanguageServerPool({ connection, pool, openDocuments }: BindingArgs): void {
connection.onInitialize(() => ({ capabilities }));
interface FormattingAndPreferences {
format?: ts.FormatCodeSettings;
preferences?: ts.UserPreferences;
}

export interface InitializeParams extends BaseInitializeParams {
initializationOptions?: {
typescript?: FormattingAndPreferences;
javascript?: FormattingAndPreferences;
};
}

export function bindLanguageServerPool({
connection,
pool,
openDocuments,
configManager,
}: BindingArgs): void {
connection.onInitialize((config: InitializeParams) => {
if (config.initializationOptions?.typescript?.format) {
configManager.updateTsJsFormatConfig(
'typescript',
config.initializationOptions.typescript.format
);
}

if (config.initializationOptions?.typescript?.preferences) {
configManager.updateTsJsUserPreferences(
'typescript',
config.initializationOptions.typescript.preferences
);
}

if (config.initializationOptions?.javascript?.format) {
configManager.updateTsJsFormatConfig(
'javascript',
config.initializationOptions.javascript.format
);
}

if (config.initializationOptions?.javascript?.preferences) {
configManager.updateTsJsUserPreferences(
'javascript',
config.initializationOptions.javascript.preferences
);
}

return { capabilities };
});

openDocuments.onDidOpen(({ document }) => {
pool.withServerForURI(document.uri, ({ server, scheduleDiagnostics }) => {
Expand All @@ -57,6 +111,37 @@ export function bindLanguageServerPool({ connection, pool, openDocuments }: Bind
});
});

connection.onCodeAction(({ textDocument, range, context }) => {
return pool.withServerForURI(textDocument.uri, ({ server }) => {
// The user actually asked for the fix
// @see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeActionTriggerKind
if (context.triggerKind === CodeActionTriggerKind.Invoked) {
let language = server.getLanguageType(textDocument.uri);
let formating = configManager.getFormatCodeSettingsFor(language);
let preferences = configManager.getUserSettingsFor(language);
let diagnosticCodes = context.diagnostics;
// "only" is only present when a user hovers over and the codefix dropdown is requested. If it is missing the user has explicitly asked for codefixes
let type =
context.only === undefined
? CodeActionKind.QuickFix
: context.only.includes(CodeActionKind.QuickFix)
? CodeActionKind.QuickFix
: undefined;

return server.getCodeActions(
textDocument.uri,
type,
range,
diagnosticCodes,
formating,
preferences
);
}

return [];
});
});

connection.onPrepareRename(({ textDocument, position }) => {
return pool.withServerForURI(textDocument.uri, ({ server }) =>
server.prepareRename(textDocument.uri, position)
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/language-server/config-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import ts from 'typescript';

export type TsUserConfigLang = 'typescript' | 'javascript';

// The ConfigManager holds TypeScript/JS formating and user preferences.
// It is only needed for the vscode binding
export class ConfigManager {
private formatCodeOptions: Record<TsUserConfigLang, ts.FormatCodeSettings> = {
javascript: ts.getDefaultFormatCodeSettings(),
typescript: ts.getDefaultFormatCodeSettings(),
};

private userPreferences: Record<TsUserConfigLang, ts.UserPreferences> = {
typescript: {},
javascript: {},
};

public updateTsJsFormatConfig(lang: TsUserConfigLang, config: ts.FormatCodeSettings): void {
this.formatCodeOptions[lang] = {
...this.formatCodeOptions[lang],
...config,
};
}

public updateTsJsUserPreferences(lang: TsUserConfigLang, config: ts.UserPreferences): void {
this.userPreferences[lang] = {
...this.userPreferences[lang],
...config,
};
}

public getUserSettingsFor(lang: TsUserConfigLang): ts.UserPreferences {
return this.userPreferences[lang];
}

public getFormatCodeSettingsFor(lang: TsUserConfigLang): ts.FormatCodeSettings {
return this.formatCodeOptions[lang];
}
}
185 changes: 184 additions & 1 deletion packages/core/src/language-server/glint-language-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import {
WorkspaceEdit,
Range,
SymbolInformation,
CodeAction,
CodeActionKind,
TextDocumentEdit,
OptionalVersionedTextDocumentIdentifier,
TextEdit,
} from 'vscode-languageserver';
import DocumentCache from '../common/document-cache.js';
import { Position, positionToOffset } from './util/position.js';
Expand All @@ -24,8 +29,8 @@ import {
severityForDiagnostic,
tagsForDiagnostic,
} from './util/protocol.js';
import { TextEdit } from 'vscode-languageserver-textdocument';
import { GetIRResult } from './messages.cjs';
import type { TsUserConfigLang } from './config-manager.js';

export interface GlintCompletionItem extends CompletionItem {
data: {
Expand All @@ -36,6 +41,12 @@ export interface GlintCompletionItem extends CompletionItem {
};
}

interface TransformedOffsets {
transformedFileName: string;
transformedStart: number;
transformedEnd: number;
}

export default class GlintLanguageServer {
private service: ts.LanguageService;
private openFileNames: Set<string>;
Expand Down Expand Up @@ -360,6 +371,174 @@ export default class GlintLanguageServer {
}
}

public getCodeActions(
uri: string,
actionType: 'quickfix' | undefined,
range: Range,
diagnosticCodes: Diagnostic[],
formatOptions: ts.FormatCodeSettings = {},
preferences: ts.UserPreferences = {}
): CodeAction[] {
if (actionType === CodeActionKind.QuickFix) {
return this.applyCodeAction(uri, range, diagnosticCodes, formatOptions, preferences);
}

return [];
}

public getLanguageType(uri: string): TsUserConfigLang {
let file = uriToFilePath(uri);
return this.glintConfig.environment.isTypedScript(file) ? 'typescript' : 'javascript';
}

private applyCodeAction(
uri: string,
range: Range,
diagnostics: Diagnostic[],
formatting: ts.FormatCodeSettings = {},
preferences: ts.UserPreferences = {}
): CodeAction[] {
let errorCodes = this.cleanDiagnosticCode(diagnostics);

let { transformedStart, transformedEnd, transformedFileName } =
this.getTransformedOffsetsFromPositions(
uri,
{
line: range.start.line,
character: range.start.character,
},
{
line: range.end.line,
character: range.end.character,
}
);

let codeFixes = this.service.getCodeFixesAtPosition(
transformedFileName,
transformedStart,
transformedEnd,
errorCodes,
formatting,
preferences
);

let codeActions = this.transformCodeFixActionToCodeAction(codeFixes, uri);

return codeActions.filter((codeAction) =>
codeAction.edit?.documentChanges?.every((change) => {
if (TextDocumentEdit.is(change)) {
return change.edits.length > 0;
}
})
);
}

private cleanDiagnosticCode(diagnostics: Diagnostic[]): number[] {
return diagnostics
.map((diag) => {
if (diag.code) {
return typeof diag.code === 'string' ? parseInt(diag.code) : diag.code;
} else if (diag.source && diag.source.startsWith('glint:ts(')) {
return parseInt(diag.source.replace('glint:ts(', '').replace(')', ''));
}

return undefined;
})
.filter(onlyNumbers);
}

private transformCodeFixActionToCodeAction(
codeFixes: readonly ts.CodeFixAction[],
uri: string
): CodeAction[] {
return codeFixes.map((fix) => {
let documentChanges = fix.changes.map((change) => {
let filePath = uriToFilePath(uri);
let version = parseInt(this.documents.getDocumentVersion(filePath));

let textChanges = change.textChanges.map((edit) => {
let { originalEnd, originalFileName, originalStart, mapping } =
this.transformManager.getOriginalRange(
change.fileName,
edit.span.start,
edit.span.start + edit.span.length
);

let contents = this.documents.getDocumentContents(originalFileName);
let start = offsetToPosition(contents, originalStart);
let end = offsetToPosition(contents, originalEnd);

// We need to re-write \@ts-ignore directives for embedded templates
// Failing to do so would replace the problematics code with \@ts-ignore
// instead of prepending it with \@glint-ignore.
if (
fix.fixName === 'disableJsDiagnostics' &&
(this.glintConfig.environment.isTemplate(originalFileName) || mapping?.sourceNode)
) {
return this.insertGlintIgnore(filePath, edit, start);
}

return TextEdit.replace(
{
start,
end,
},
edit.newText
);
});

let uriForEdit = uri;
let companion = this.documents.getCompanionDocumentPath(filePath);
if (companion && this.isFixForTS(filePath, fix.fixName)) {
uriForEdit = filePathToUri(companion);
}

return TextDocumentEdit.create(
OptionalVersionedTextDocumentIdentifier.create(uriForEdit, version),
textChanges
);
});

return CodeAction.create(fix.description, { documentChanges }, CodeActionKind.QuickFix);
});
}

// This mimics what happens in TS/JS but for when we are in an embedded template context.
// We fix up the indenting because this is the same behavior that occurs what inserting
// \@ts-ignore checks
private insertGlintIgnore(filePath: string, edit: ts.TextChange, start: Position): TextEdit {
edit.newText = '{{! @glint-ignore }}\n';

let linesOfNewText = edit.newText.split('\n');

if (/^[ \t]*$/.test(linesOfNewText[linesOfNewText.length - 1])) {
let contents = this.documents.getDocumentContents(filePath).split('\n')[start.line];
let indent = /^[ |\t]+/.exec(contents)?.[0] ?? '';
linesOfNewText[linesOfNewText.length - 1] = indent;
}

return TextEdit.insert(start, linesOfNewText.join('\n'));
}

private isFixForTS(filePath: string, fixName: string): boolean {
return this.glintConfig.environment.isTemplate(filePath) && fixName !== 'disableJsDiagnostics';
}

private getTransformedOffsetsFromPositions(
uri: string,
startPosition: Position,
endPosition: Position
): TransformedOffsets {
let start = this.getTransformedOffset(uri, startPosition);
let end = this.getTransformedOffset(uri, endPosition);

return {
transformedStart: start.transformedOffset,
transformedEnd: end.transformedOffset,
transformedFileName: start.transformedFileName,
};
}

private calculateOriginalLocations(spans: ReadonlyArray<ts.DocumentSpan>): Array<Location> {
return spans
.map((span) => this.textSpanToLocation(span.fileName, span.textSpan))
Expand Down Expand Up @@ -457,3 +636,7 @@ export default class GlintLanguageServer {
);
}
}

function onlyNumbers(entry: number | undefined): entry is number {
return entry !== undefined;
}
Loading

0 comments on commit 1b207a8

Please sign in to comment.