Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import Code Actions #565

Merged
merged 7 commits into from
May 23, 2024
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
4 changes: 4 additions & 0 deletions libs/language-server/src/lib/jayvee-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import { WrapperFactoryProvider } from './ast/wrappers/wrapper-factory-provider'
import { JayveeWorkspaceManager } from './builtin-library/jayvee-workspace-manager';
import { JayveeValueConverter } from './jayvee-value-converter';
import {
JayveeCodeActionProvider,
JayveeCompletionProvider,
JayveeDefinitionProvider,
JayveeFormatter,
JayveeHoverProvider,
JayveeScopeComputation,
Expand Down Expand Up @@ -84,6 +86,8 @@ export const JayveeModule: Module<
HoverProvider: (services: JayveeServices) =>
new JayveeHoverProvider(services),
Formatter: () => new JayveeFormatter(),
DefinitionProvider: (services) => new JayveeDefinitionProvider(services),
CodeActionProvider: (services) => new JayveeCodeActionProvider(services),
},
references: {
ScopeProvider: (services) => new JayveeScopeProvider(services),
Expand Down
2 changes: 2 additions & 0 deletions libs/language-server/src/lib/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export * from './jayvee-formatter';
export * from './jayvee-hover-provider';
export * from './jayvee-scope-computation';
export * from './jayvee-scope-provider';
export * from './jayvee-definition-provider';
export * from './jayvee-code-action-provider';
191 changes: 191 additions & 0 deletions libs/language-server/src/lib/lsp/jayvee-code-action-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg
//
// SPDX-License-Identifier: AGPL-3.0-only

// eslint-disable-next-line unicorn/prefer-node-protocol
import { strict as assert } from 'assert';

import {
type AstNodeDescription,
type AstReflection,
DocumentValidator,
type IndexManager,
type LangiumDocument,
type LinkingErrorData,
type MaybePromise,
type Reference,
type ReferenceInfo,
type URI,
UriUtils,
} from 'langium';
import { type CodeActionProvider } from 'langium/lsp';
import {
type CodeAction,
CodeActionKind,
type CodeActionParams,
type Command,
type Diagnostic,
type Position,
} from 'vscode-languageserver-protocol';

import { type JayveeModel } from '../ast';
import { type JayveeServices } from '../jayvee-module';

export class JayveeCodeActionProvider implements CodeActionProvider {
protected readonly reflection: AstReflection;
protected readonly indexManager: IndexManager;

constructor(services: JayveeServices) {
this.reflection = services.shared.AstReflection;
this.indexManager = services.shared.workspace.IndexManager;
}

getCodeActions(
document: LangiumDocument,
params: CodeActionParams,
): MaybePromise<Array<Command | CodeAction>> {
const actions: CodeAction[] = [];

for (const diagnostic of params.context.diagnostics) {
const diagnosticActions = this.getCodeActionsForDiagnostic(
diagnostic,
document,
);
actions.push(...diagnosticActions);
}
return actions;
}

protected getCodeActionsForDiagnostic(
diagnostic: Diagnostic,
document: LangiumDocument,
): CodeAction[] {
const actions: CodeAction[] = [];

const diagnosticData = diagnostic.data as unknown;
const diagnosticCode = (diagnosticData as { code?: string } | undefined)
?.code;
if (diagnosticData === undefined || diagnosticCode === undefined) {
return actions;
}

switch (diagnosticCode) {
case DocumentValidator.LinkingError: {
const linkingData = diagnosticData as LinkingErrorData;
actions.push(
...this.getCodeActionsForLinkingError(
diagnostic,
linkingData,
document,
),
);
}
}

return actions;
}

protected getCodeActionsForLinkingError(
diagnostic: Diagnostic,
linkingData: LinkingErrorData,
document: LangiumDocument,
): CodeAction[] {
const refInfo: ReferenceInfo = {
container: {
$type: linkingData.containerType,
},
property: linkingData.property,
reference: {
$refText: linkingData.refText,
} as Reference,
};
const refType = this.reflection.getReferenceType(refInfo);
const importCandidates = this.indexManager
.allElements(refType)
.filter((e) => e.name === linkingData.refText);

return [
...(importCandidates
.map((c) => this.getActionForImportCandidate(c, diagnostic, document))
.filter((a) => a !== undefined) as unknown as CodeAction[]),
];
}

protected getActionForImportCandidate(
importCandidate: AstNodeDescription,
diagnostic: Diagnostic,
document: LangiumDocument,
): CodeAction | undefined {
const isInCurrentFile = UriUtils.equals(
importCandidate.documentUri,
document.uri,
);
if (isInCurrentFile) {
return;
}

const importPath = this.getRelativeImportPath(
document.uri,
importCandidate.documentUri,
);

const importPosition = this.getImportLinePosition(
document.parseResult.value as JayveeModel,
);
if (importPosition === undefined) {
return;
}

return {
title: `Use from '${importPath}'`,
kind: CodeActionKind.QuickFix,
diagnostics: [diagnostic],
isPreferred: false,
edit: {
changes: {
[document.textDocument.uri]: [
{
range: {
start: importPosition,
end: importPosition,
},
newText: `use * from "${importPath}";\n`,
},
],
},
},
};
}

protected getImportLinePosition(
javeeModel: JayveeModel,
): Position | undefined {
const currentModelImports = javeeModel.imports;

// Put the new import after the last import
if (currentModelImports.length > 0) {
const lastImportEnd =
currentModelImports[currentModelImports.length - 1]?.$cstNode?.range
.end;
assert(
lastImportEnd !== undefined,
'Could not find end of last import statement.',
);
return { line: lastImportEnd.line + 1, character: 0 };
}

// For now, we just add it in the first row if there is no import yet
return { line: 0, character: 0 };
}

private getRelativeImportPath(source: URI, target: URI): string {
const sourceDir = UriUtils.dirname(source);
const relativePath = UriUtils.relative(sourceDir, target);

if (!relativePath.startsWith('./') && !relativePath.startsWith('../')) {
return `./${relativePath}`;
}

return relativePath;
}
}
110 changes: 103 additions & 7 deletions libs/language-server/src/lib/lsp/jayvee-completion-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,33 @@
// eslint-disable-next-line unicorn/prefer-node-protocol
import { strict as assert } from 'assert';

import { type LangiumDocuments, type MaybePromise } from 'langium';
import {
type AstNode,
type LangiumDocument,
type LangiumDocuments,
type MaybePromise,
UriUtils,
} from 'langium';
import {
type CompletionAcceptor,
type CompletionContext,
type CompletionValueItem,
DefaultCompletionProvider,
type NextFeature,
} from 'langium/lsp';
import { CompletionItemKind } from 'vscode-languageserver';
import { CompletionItemKind, type Range } from 'vscode-languageserver';

import { type TypedObjectWrapper, type WrapperFactoryProvider } from '../ast';
import {
type BlockDefinition,
type ConstraintDefinition,
type ImportDefinition,
PropertyAssignment,
type PropertyBody,
ValueTypeReference,
isBlockDefinition,
isConstraintDefinition,
isImportDefinition,
isJayveeModel,
isPropertyAssignment,
isPropertyBody,
Expand All @@ -38,12 +46,12 @@ import { type JayveeServices } from '../jayvee-module';
const RIGHT_ARROW_SYMBOL = '\u{2192}';

export class JayveeCompletionProvider extends DefaultCompletionProvider {
protected langiumDocumentService: LangiumDocuments;
protected langiumDocuments: LangiumDocuments;
protected readonly wrapperFactories: WrapperFactoryProvider;

constructor(services: JayveeServices) {
super(services);
this.langiumDocumentService = services.shared.workspace.LangiumDocuments;
this.langiumDocuments = services.shared.workspace.LangiumDocuments;
this.wrapperFactories = services.WrapperFactories;
}

Expand Down Expand Up @@ -78,6 +86,12 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider {
if (isFirstPropertyCompletion || isOtherPropertyCompletion) {
return this.completionForPropertyName(astNode, context, acceptor);
}

const isImportPathCompletion =
isImportDefinition(astNode) && next.property === 'path';
if (isImportPathCompletion) {
return this.completionForImportPath(astNode, context, acceptor);
}
}
return super.completionFor(context, next, acceptor);
}
Expand All @@ -87,7 +101,7 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider {
acceptor: CompletionAcceptor,
): MaybePromise<void> {
const blockTypes = getAllBuiltinBlockTypes(
this.langiumDocumentService,
this.langiumDocuments,
this.wrapperFactories,
);
blockTypes.forEach((blockType) => {
Expand All @@ -113,7 +127,7 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider {
acceptor: CompletionAcceptor,
): MaybePromise<void> {
const constraintTypes = getAllBuiltinConstraintTypes(
this.langiumDocumentService,
this.langiumDocuments,
this.wrapperFactories,
);
constraintTypes.forEach((constraintType) => {
Expand All @@ -139,7 +153,7 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider {
context: CompletionContext,
acceptor: CompletionAcceptor,
): MaybePromise<void> {
this.langiumDocumentService.all
this.langiumDocuments.all
.map((document) => document.parseResult.value)
.forEach((parsedDocument) => {
if (!isJayveeModel(parsedDocument)) {
Expand Down Expand Up @@ -194,6 +208,88 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider {
}
}

private completionForImportPath(
astNode: ImportDefinition,
context: CompletionContext,
acceptor: CompletionAcceptor,
) {
const documentText = context.textDocument.getText();
const existingImportPath = documentText.substring(
context.tokenOffset,
context.offset,
);

const hasSemicolonAfterPath =
documentText.substring(
context.tokenEndOffset,
context.tokenEndOffset + 1,
) === ';';
const pathDelimiter = existingImportPath.startsWith("'") ? "'" : '"';

const existingImportPathWithoutDelimiter = existingImportPath.replace(
pathDelimiter,
'',
);

const allPaths = this.getImportPathsFormatted(context.document);
const insertRange: Range = {
start: context.textDocument.positionAt(context.tokenOffset),
end: context.textDocument.positionAt(context.tokenEndOffset),
};

const suitablePaths = allPaths.filter((path) =>
path.startsWith(existingImportPathWithoutDelimiter),
);

for (const path of suitablePaths) {
const completionValue = `${pathDelimiter}${path}${pathDelimiter}${
hasSemicolonAfterPath ? '' : ';'
}`;
acceptor(context, {
label: completionValue, // using path here somehow doesn't work
textEdit: {
newText: completionValue,
range: insertRange,
},
kind: CompletionItemKind.File,
});
}
}

/**
* Gets all paths to available documents, formatted as relative paths.
* Does not include path to stdlib files as they don't need to be imported.
* The paths don't include string delimiters.
*/
private getImportPathsFormatted(
currentDocument: LangiumDocument<AstNode>,
): string[] {
const allDocuments = this.langiumDocuments.all;
const currentDocumentUri = currentDocument.uri.toString();

const currentDocumentDir = UriUtils.dirname(currentDocument.uri).toString();

const paths: string[] = [];
for (const doc of allDocuments) {
if (UriUtils.equals(doc.uri, currentDocumentUri)) {
continue;
}

const docUri = doc.uri.toString();
if (docUri.includes('builtin:/stdlib')) {
continue; // builtins don't need to be imported
}

const relativePath = UriUtils.relative(currentDocumentDir, docUri);

const relativePathFormatted = relativePath.startsWith('.')
? relativePath
: `./${relativePath}`;
paths.push(relativePathFormatted);
}
return paths;
}

private constructPropertyCompletionValueItems(
wrapper: TypedObjectWrapper,
propertyNames: string[],
Expand Down
Loading
Loading