Skip to content

Commit

Permalink
Merge pull request #565 from jvalue/file-import-path-completion
Browse files Browse the repository at this point in the history
Import Code Actions
  • Loading branch information
georg-schwarz authored May 23, 2024
2 parents 84d9503 + 82ddcd7 commit b20fcd9
Show file tree
Hide file tree
Showing 5 changed files with 366 additions and 7 deletions.
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

0 comments on commit b20fcd9

Please sign in to comment.