diff --git a/libs/language-server/src/lib/jayvee-module.ts b/libs/language-server/src/lib/jayvee-module.ts index 9f50daec1..2ca8d8f79 100644 --- a/libs/language-server/src/lib/jayvee-module.ts +++ b/libs/language-server/src/lib/jayvee-module.ts @@ -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, @@ -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), diff --git a/libs/language-server/src/lib/lsp/index.ts b/libs/language-server/src/lib/lsp/index.ts index 7e4d7c3e4..1907e2f15 100644 --- a/libs/language-server/src/lib/lsp/index.ts +++ b/libs/language-server/src/lib/lsp/index.ts @@ -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'; diff --git a/libs/language-server/src/lib/lsp/jayvee-code-action-provider.ts b/libs/language-server/src/lib/lsp/jayvee-code-action-provider.ts new file mode 100644 index 000000000..6f4e136dd --- /dev/null +++ b/libs/language-server/src/lib/lsp/jayvee-code-action-provider.ts @@ -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> { + 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; + } +} diff --git a/libs/language-server/src/lib/lsp/jayvee-completion-provider.ts b/libs/language-server/src/lib/lsp/jayvee-completion-provider.ts index 9f3fc3256..c7fdbb768 100644 --- a/libs/language-server/src/lib/lsp/jayvee-completion-provider.ts +++ b/libs/language-server/src/lib/lsp/jayvee-completion-provider.ts @@ -5,7 +5,13 @@ // 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, @@ -13,17 +19,19 @@ import { 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, @@ -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; } @@ -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); } @@ -87,7 +101,7 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider { acceptor: CompletionAcceptor, ): MaybePromise { const blockTypes = getAllBuiltinBlockTypes( - this.langiumDocumentService, + this.langiumDocuments, this.wrapperFactories, ); blockTypes.forEach((blockType) => { @@ -113,7 +127,7 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider { acceptor: CompletionAcceptor, ): MaybePromise { const constraintTypes = getAllBuiltinConstraintTypes( - this.langiumDocumentService, + this.langiumDocuments, this.wrapperFactories, ); constraintTypes.forEach((constraintType) => { @@ -139,7 +153,7 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider { context: CompletionContext, acceptor: CompletionAcceptor, ): MaybePromise { - this.langiumDocumentService.all + this.langiumDocuments.all .map((document) => document.parseResult.value) .forEach((parsedDocument) => { if (!isJayveeModel(parsedDocument)) { @@ -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, + ): 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[], diff --git a/libs/language-server/src/lib/lsp/jayvee-definition-provider.ts b/libs/language-server/src/lib/lsp/jayvee-definition-provider.ts new file mode 100644 index 000000000..63acf6316 --- /dev/null +++ b/libs/language-server/src/lib/lsp/jayvee-definition-provider.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +import { + GrammarUtils, + type LangiumDocuments, + type LeafCstNode, + type MaybePromise, +} from 'langium'; +import { DefaultDefinitionProvider } from 'langium/lsp'; +import { + type DefinitionParams, + LocationLink, + Range, +} from 'vscode-languageserver-protocol'; + +import { isImportDefinition } from '../ast'; +import { type JayveeServices } from '../jayvee-module'; +import { type JayveeImportResolver } from '../services/import-resolver'; + +export class JayveeDefinitionProvider extends DefaultDefinitionProvider { + protected documents: LangiumDocuments; + protected importResolver: JayveeImportResolver; + + constructor(services: JayveeServices) { + super(services); + this.documents = services.shared.workspace.LangiumDocuments; + this.importResolver = services.ImportResolver; + } + + protected override collectLocationLinks( + sourceCstNode: LeafCstNode, + params: DefinitionParams, + ): MaybePromise { + const sourceAstNode = sourceCstNode.astNode; + if ( + isImportDefinition(sourceAstNode) && + GrammarUtils.findAssignment(sourceCstNode)?.feature === 'path' + ) { + const importedModel = this.importResolver.resolveImport(sourceAstNode); + + if (importedModel?.$document === undefined) { + return undefined; + } + + const jumpTarget = importedModel; + + const selectionRange = + this.nameProvider.getNameNode(jumpTarget)?.range ?? + Range.create(0, 0, 0, 0); + const previewRange = + jumpTarget.$cstNode?.range ?? Range.create(0, 0, 0, 0); + + return [ + LocationLink.create( + importedModel.$document.uri.toString(), + previewRange, + selectionRange, + sourceCstNode.range, + ), + ]; + } + return super.collectLocationLinks(sourceCstNode, params); + } +}