diff --git a/libs/language-server/src/grammar/main.langium b/libs/language-server/src/grammar/main.langium index f7650eb8e..4a24aa706 100644 --- a/libs/language-server/src/grammar/main.langium +++ b/libs/language-server/src/grammar/main.langium @@ -36,4 +36,7 @@ ExportDefinition: 'publish' element=[ExportableElement] ('as' alias=ID)? ';'; ImportDefinition: - 'use' '*' 'from' path=STRING ';'; + 'use' ( + useAll?='*' + | '{' (usedElements+=ID) (',' usedElements+=ID)* '}' + ) 'from' path=STRING ';'; diff --git a/libs/language-server/src/lib/ast/model-util.ts b/libs/language-server/src/lib/ast/model-util.ts index f1a5b04db..b570caf41 100644 --- a/libs/language-server/src/lib/ast/model-util.ts +++ b/libs/language-server/src/lib/ast/model-util.ts @@ -2,13 +2,22 @@ // // SPDX-License-Identifier: AGPL-3.0-only +// eslint-disable-next-line unicorn/prefer-node-protocol +import { strict as assert } from 'assert'; + import { type AstNode, AstUtils, type LangiumDocuments } from 'langium'; import { type BuiltinBlockTypeDefinition, type BuiltinConstrainttypeDefinition, + type ExportDefinition, + type ExportableElement, + type JayveeModel, isBuiltinBlockTypeDefinition, isBuiltinConstrainttypeDefinition, + isExportDefinition, + isExportableElement, + isExportableElementDefinition, isJayveeModel, } from './generated/ast'; import { @@ -121,3 +130,89 @@ export function getAllBuiltinConstraintTypes( }); return allBuiltinConstraintTypes; } + +export interface ExportDetails { + /** + * The exported element + */ + element: ExportableElement; + + /** + * The name which the exported element is available under. + */ + alias: string; +} + +/** + * Gets all exported elements from a document. + * This logic cannot reside in a {@link ScopeComputationProvider} but should be handled here: + * https://github.com/eclipse-langium/langium/discussions/1508#discussioncomment-9524544 + */ +export function getExportedElements(model: JayveeModel): ExportDetails[] { + const exportedElements: ExportDetails[] = []; + + for (const node of AstUtils.streamAllContents(model)) { + if (isExportableElementDefinition(node) && node.isPublished) { + assert( + isExportableElement(node), + 'Exported node is not an ExportableElement', + ); + exportedElements.push({ + element: node, + alias: node.name, + }); + } + + if (isExportDefinition(node)) { + const originalDefinition = followExportDefinitionChain(node); + if (originalDefinition !== undefined) { + const exportName = node.alias ?? originalDefinition.name; + exportedElements.push({ + element: originalDefinition, + alias: exportName, + }); + } + } + } + return exportedElements; +} + +/** + * Follow an export statement to its original definition. + */ +export function followExportDefinitionChain( + exportDefinition: ExportDefinition, +): ExportableElement | undefined { + const referenced = exportDefinition.element.ref; + + if (referenced === undefined) { + return undefined; // Cannot follow reference to original definition + } + + if (!isElementExported(referenced)) { + return undefined; + } + + return referenced; // Reached original definition +} + +/** + * Checks whether an exportable @param element is exported (either in definition or via an delayed export definition). + */ +export function isElementExported(element: ExportableElement): boolean { + if (isExportableElementDefinition(element) && element.isPublished) { + return true; + } + + const model = AstUtils.getContainerOfType(element, isJayveeModel); + assert( + model !== undefined, + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + `Could not get container of exportable element ${element.name ?? ''}`, + ); + + const isExported = model.exports.some( + (exportDefinition) => exportDefinition.element.ref === element, + ); + return isExported; +} 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 0aebde26f..bba532d62 100644 --- a/libs/language-server/src/lib/lsp/jayvee-completion-provider.ts +++ b/libs/language-server/src/lib/lsp/jayvee-completion-provider.ts @@ -41,20 +41,24 @@ import { import { getAllBuiltinBlockTypes, getAllBuiltinConstraintTypes, + getExportedElements, } from '../ast/model-util'; import { LspDocGenerator } from '../docs/lsp-doc-generator'; import { type JayveeServices } from '../jayvee-module'; +import { type JayveeImportResolver } from '../services'; const RIGHT_ARROW_SYMBOL = '\u{2192}'; export class JayveeCompletionProvider extends DefaultCompletionProvider { protected langiumDocuments: LangiumDocuments; protected readonly wrapperFactories: WrapperFactoryProvider; + protected readonly importResolver: JayveeImportResolver; constructor(services: JayveeServices) { super(services); this.langiumDocuments = services.shared.workspace.LangiumDocuments; this.wrapperFactories = services.WrapperFactories; + this.importResolver = services.ImportResolver; } override completionFor( @@ -94,6 +98,12 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider { if (isImportPathCompletion) { return this.completionForImportPath(astNode, context, acceptor); } + + const isImportElementCompletion = + isImportDefinition(astNode) && next.property === 'usedElements'; + if (isImportElementCompletion) { + return this.completionForImportElement(astNode, context, acceptor); + } } return super.completionFor(context, next, acceptor); } @@ -261,6 +271,47 @@ export class JayveeCompletionProvider extends DefaultCompletionProvider { } } + private completionForImportElement( + importDefinition: ImportDefinition, + context: CompletionContext, + acceptor: CompletionAcceptor, + ) { + const resolvedModel = this.importResolver.resolveImport(importDefinition); + if (resolvedModel === undefined) { + return; + } + + const documentText = context.textDocument.getText(); + const existingElementName = documentText.substring( + context.tokenOffset, + context.offset, + ); + + const exportedElementNames = getExportedElements(resolvedModel).map( + (x) => x.alias, + ); + + const suggestedElementNames = exportedElementNames.filter((x) => + x.startsWith(existingElementName), + ); + + const insertRange: Range = { + start: context.textDocument.positionAt(context.tokenOffset), + end: context.textDocument.positionAt(context.tokenEndOffset), + }; + + for (const elementName of suggestedElementNames) { + acceptor(context, { + label: elementName, + textEdit: { + newText: elementName, + range: insertRange, + }, + kind: CompletionItemKind.Reference, + }); + } + } + /** * 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. diff --git a/libs/language-server/src/lib/lsp/jayvee-definition-provider.ts b/libs/language-server/src/lib/lsp/jayvee-definition-provider.ts index 63acf6316..3a9650d62 100644 --- a/libs/language-server/src/lib/lsp/jayvee-definition-provider.ts +++ b/libs/language-server/src/lib/lsp/jayvee-definition-provider.ts @@ -2,7 +2,12 @@ // // SPDX-License-Identifier: AGPL-3.0-only +// eslint-disable-next-line unicorn/prefer-node-protocol +import { strict as assert } from 'assert'; + import { + type AstNode, + AstUtils, GrammarUtils, type LangiumDocuments, type LeafCstNode, @@ -15,7 +20,7 @@ import { Range, } from 'vscode-languageserver-protocol'; -import { isImportDefinition } from '../ast'; +import { getExportedElements, isImportDefinition, isJayveeModel } from '../ast'; import { type JayveeServices } from '../jayvee-module'; import { type JayveeImportResolver } from '../services/import-resolver'; @@ -34,6 +39,7 @@ export class JayveeDefinitionProvider extends DefaultDefinitionProvider { params: DefinitionParams, ): MaybePromise { const sourceAstNode = sourceCstNode.astNode; + if ( isImportDefinition(sourceAstNode) && GrammarUtils.findAssignment(sourceCstNode)?.feature === 'path' @@ -44,23 +50,76 @@ export class JayveeDefinitionProvider extends DefaultDefinitionProvider { 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 this.getLocationLink(sourceCstNode, importedModel); + } + + if ( + isImportDefinition(sourceAstNode) && + GrammarUtils.findAssignment(sourceCstNode)?.feature === 'usedElements' + ) { + const clickedIndex = + GrammarUtils.findAssignment(sourceCstNode)?.$container?.$containerIndex; + assert( + clickedIndex !== undefined, + 'Could not read index of selected element', + ); + const indexOfElement = clickedIndex - 1; + assert( + indexOfElement < sourceAstNode.usedElements.length, + 'Index of selected element is not correctly computed', + ); + const refString = sourceAstNode.usedElements[indexOfElement]; + assert( + refString !== undefined, + 'Could not read reference text to imported element', + ); + + const importedModel = this.importResolver.resolveImport(sourceAstNode); + + if (importedModel?.$document === undefined) { + return undefined; + } + + const allExportDefinitions = getExportedElements(importedModel); + + const referencedExport = allExportDefinitions.find((x) => { + return x.alias === refString; + }); + if (referencedExport === undefined) { + return; + } + + return this.getLocationLink(sourceCstNode, referencedExport.element); } return super.collectLocationLinks(sourceCstNode, params); } + + protected getLocationLink( + sourceCstNode: LeafCstNode, + jumpTarget: AstNode, + ): LocationLink[] | undefined { + // need to go over model as jumpTarget might not have $document associated + const containingDocument = AstUtils.getContainerOfType( + jumpTarget, + isJayveeModel, + )?.$document; + + if (containingDocument === undefined) { + return undefined; + } + + 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( + containingDocument.uri.toString(), + previewRange, + selectionRange, + sourceCstNode.range, + ), + ]; + } } diff --git a/libs/language-server/src/lib/lsp/jayvee-scope-provider.ts b/libs/language-server/src/lib/lsp/jayvee-scope-provider.ts index 2c3f63cd2..a3ff0bfd7 100644 --- a/libs/language-server/src/lib/lsp/jayvee-scope-provider.ts +++ b/libs/language-server/src/lib/lsp/jayvee-scope-provider.ts @@ -2,10 +2,8 @@ // // SPDX-License-Identifier: AGPL-3.0-only -// eslint-disable-next-line unicorn/prefer-node-protocol -import { strict as assert } from 'assert'; - import { + type AstNode, type AstNodeDescription, AstUtils, DefaultScopeProvider, @@ -20,27 +18,16 @@ import { } from 'langium'; import { - type ExportDefinition, - type ExportableElement, + type ExportDetails, + type ImportDefinition, type JayveeModel, - isExportDefinition, - isExportableElement, - isExportableElementDefinition, + getExportedElements, isJayveeModel, } from '../ast'; import { getStdLib } from '../builtin-library'; import { type JayveeServices } from '../jayvee-module'; import { type JayveeImportResolver } from '../services/import-resolver'; -interface ExportDetails { - element: ExportableElement; - - /** - * The name which the exported element is available under. - */ - alias: string; -} - export class JayveeScopeProvider extends DefaultScopeProvider { protected readonly langiumDocuments: LangiumDocuments; protected readonly importResolver: JayveeImportResolver; @@ -68,114 +55,103 @@ export class JayveeScopeProvider extends DefaultScopeProvider { return EMPTY_SCOPE; } - const importedUris = new Set(); - this.gatherImports(jayveeModel, importedUris); - this.gatherBuiltins(importedUris); + const importedElements: AstNodeDescription[] = []; + importedElements.push(...this.getBuiltinElements()); + importedElements.push(...this.getExplicitlyImportedElements(jayveeModel)); - const importedDocuments = [...importedUris].map((importedUri) => - this.langiumDocuments.getDocument(URI.parse(importedUri)), - ); + return new MapScope(importedElements); + } + protected getExplicitlyImportedElements( + model: JayveeModel, + ): AstNodeDescription[] { const importedElements: AstNodeDescription[] = []; - for (const importedDocument of importedDocuments) { + for (const importDefinition of model.imports) { + const importedDocument = this.getImportedDocument(importDefinition); if (importedDocument === undefined) { continue; } - const publishedElements = this.availableElementsPerDocumentCache.get( - importedDocument.uri, - 'exports', // we only need one key here as it is on document basis - () => this.getExportedElements(importedDocument), - ); importedElements.push( - ...publishedElements.map((e) => - this.descriptions.createDescription(e.element, e.alias), + ...this.getImportedElementsFromDocument( + importDefinition, + importedDocument, ), ); } - - return new MapScope(importedElements); + return importedElements; } - /** - * Gets all exported elements from a document. - * This logic cannot reside in a {@link ScopeComputationProvider} but should be handled here: - * https://github.com/eclipse-langium/langium/discussions/1508#discussioncomment-9524544 - */ - protected getExportedElements(document: LangiumDocument): ExportDetails[] { - const model = document.parseResult.value as JayveeModel; - const exportedElements: ExportDetails[] = []; - - for (const node of AstUtils.streamAllContents(model)) { - if (isExportableElementDefinition(node) && node.isPublished) { - assert( - isExportableElement(node), - 'Exported node is not an ExportableElement', - ); - exportedElements.push({ - element: node, - alias: node.name, - }); - } + protected getImportedElementsFromDocument( + importDefinition: ImportDefinition, + importedDocument: LangiumDocument, + ): AstNodeDescription[] { + const publishedElements = + this.getPublishedElementsFromDocument(importedDocument); - if (isExportDefinition(node)) { - const originalDefinition = this.followExportDefinitionChain(node); - if (originalDefinition !== undefined) { - const exportName = node.alias ?? originalDefinition.name; - exportedElements.push({ - element: originalDefinition, - alias: exportName, - }); - } + if (importDefinition.useAll) { + return publishedElements; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const importedIdentifiers = importDefinition.usedElements ?? []; + + const importedElements: AstNodeDescription[] = []; + for (const importedIdentifier of importedIdentifiers) { + const matchingExportedElement = publishedElements.find( + (x) => x.name === importedIdentifier, + ); + if (matchingExportedElement === undefined) { + continue; } + + importedElements.push(matchingExportedElement); } - return exportedElements; + return importedElements; } - /** - * Follow an export statement to its original definition. - */ - protected followExportDefinitionChain( - exportDefinition: ExportDefinition, - ): ExportableElement | undefined { - const referenced = exportDefinition.element.ref; + protected getBuiltinElements(): AstNodeDescription[] { + const builtinUris = this.getBuiltins(); - if (referenced === undefined) { - return undefined; // Cannot follow reference to original definition - } + const importedDocuments = [...builtinUris].map((importedUri) => + this.langiumDocuments.getDocument(URI.parse(importedUri)), + ); - if (!this.isElementExported(referenced)) { - return undefined; + const importedElements: AstNodeDescription[] = []; + for (const importedDocument of importedDocuments) { + if (importedDocument === undefined) { + continue; + } + + importedElements.push( + ...this.getPublishedElementsFromDocument(importedDocument), + ); } - return referenced; // Reached original definition + return importedElements; } - /** - * Checks whether an exportable @param element is exported (either in definition or via an delayed export definition). - */ - protected isElementExported(element: ExportableElement): boolean { - if (isExportableElementDefinition(element) && element.isPublished) { - return true; - } + protected getPublishedElementsFromDocument( + document: LangiumDocument, + ): AstNodeDescription[] { + const model = document.parseResult.value as JayveeModel; - const model = AstUtils.getContainerOfType(element, isJayveeModel); - assert( - model !== undefined, - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - `Could not get container of exportable element ${element.name ?? ''}`, + const publishedElements = this.availableElementsPerDocumentCache.get( + document.uri, + 'exports', // we only need one key here as it is on document basis + () => getExportedElements(model), ); - - const isExported = model.exports.some( - (exportDefinition) => exportDefinition.element.ref === element, + return publishedElements.map((e) => + this.descriptions.createDescription(e.element, e.alias), ); - return isExported; } /** * Add all builtins' URIs to @param importedUris */ - protected gatherBuiltins(importedUris: Set) { + protected getBuiltins(): Set { + const importedUris: Set = new Set(); + const builtins = getStdLib(); const uris = Object.keys(builtins); @@ -184,30 +160,20 @@ export class JayveeScopeProvider extends DefaultScopeProvider { const formattedUri = URI.parse(uri).toString(); importedUris.add(formattedUri); } + + return importedUris; } - /** - * Add all imported URIs of the given @jayveeModel to @param importedUris - */ - protected gatherImports( - jayveeModel: JayveeModel, - importedUris: Set, - ): void { - for (const importDefinition of jayveeModel.imports) { - const uri = this.importResolver.resolveImportUri(importDefinition); - if (uri === undefined) { - continue; - } + protected getImportedDocument( + importDefinition: ImportDefinition, + ): LangiumDocument | undefined { + const uri = this.importResolver.resolveImportUri(importDefinition); + if (uri === undefined) { + return undefined; + } - if (importedUris.has(uri.toString())) { - continue; - } + const importedDocument = this.langiumDocuments.getDocument(uri); - importedUris.add(uri.toString()); - const importedDocument = this.langiumDocuments.getDocument(uri); - if (importedDocument === undefined) { - continue; - } - } + return importedDocument; } } diff --git a/libs/language-server/src/lib/validation/checks/export-definition.ts b/libs/language-server/src/lib/validation/checks/export-definition.ts index a1f6f23eb..8bbf453ce 100644 --- a/libs/language-server/src/lib/validation/checks/export-definition.ts +++ b/libs/language-server/src/lib/validation/checks/export-definition.ts @@ -8,11 +8,9 @@ import { strict as assert } from 'assert'; import { AstUtils } from 'langium'; +import { getExportedElements } from '../../ast'; import { type ExportDefinition, - type ExportableElement, - type JayveeModel, - isExportableElement, isExportableElementDefinition, isJayveeModel, } from '../../ast/generated/ast'; @@ -65,7 +63,7 @@ function checkUniqueAlias( const model = AstUtils.getContainerOfType(exportDefinition, isJayveeModel); assert(model !== undefined); - const allExports = collectAllExportsWithinSameFile(model); + const allExports = getExportedElements(model); const elementsWithSameName = allExports.filter( (e) => e.alias === exportDefinition.alias, @@ -87,48 +85,3 @@ function checkUniqueAlias( ); } } - -function collectAllExportsWithinSameFile(model: JayveeModel): { - alias: string; - element: ExportDefinition | ExportableElement; -}[] { - const exportedElementNames: { - alias: string; - element: ExportDefinition | ExportableElement; - }[] = []; - - for (const node of model.exportableElements) { - if (node.isPublished) { - assert( - isExportableElement(node), - 'Exported node is not an ExportableElement', - ); - exportedElementNames.push({ - alias: node.name, - element: node, - }); - } - } - - for (const node of model.exports) { - if (node.alias !== undefined) { - exportedElementNames.push({ - alias: node.alias, - element: node, - }); - continue; - } - - const originalDefinition = node.element?.ref; - if ( - originalDefinition !== undefined && - originalDefinition.name !== undefined - ) { - exportedElementNames.push({ - alias: originalDefinition.name, - element: originalDefinition, - }); - } - } - return exportedElementNames; -} diff --git a/libs/language-server/src/lib/validation/checks/import-definition.spec.ts b/libs/language-server/src/lib/validation/checks/import-definition.spec.ts index cb2e0dd75..e821df16b 100644 --- a/libs/language-server/src/lib/validation/checks/import-definition.spec.ts +++ b/libs/language-server/src/lib/validation/checks/import-definition.spec.ts @@ -62,10 +62,10 @@ describe('Validation of ImportDefinition', () => { validationAcceptorMock.mockReset(); }); - describe('ImportDefinition syntax', () => { + describe('ImportDefinition wildcard syntax', () => { it('should have no error if file exists in same directory', async () => { const relativeTestFilePath = - 'import-definition/valid-imported-file-exists-same-dir.jv'; + 'import-definition/wildcard-import/valid-imported-file-exists-same-dir.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -74,7 +74,7 @@ describe('Validation of ImportDefinition', () => { it('should have no error if file exists in deeper directory', async () => { const relativeTestFilePath = - 'import-definition/valid-imported-file-exists-deeper-dir.jv'; + 'import-definition/wildcard-import/valid-imported-file-exists-deeper-dir.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -83,7 +83,7 @@ describe('Validation of ImportDefinition', () => { it('should have no error if file exists in deeper directory', async () => { const relativeTestFilePath = - 'import-definition/deeper/valid-imported-file-exists-higher-dir.jv'; + 'import-definition/wildcard-import/deeper/valid-imported-file-exists-higher-dir.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -92,7 +92,7 @@ describe('Validation of ImportDefinition', () => { it('should diagnose error on imported file that does not exist', async () => { const relativeTestFilePath = - 'import-definition/invalid-imported-not-existing-file.jv'; + 'import-definition/wildcard-import/invalid-imported-not-existing-file.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -113,7 +113,7 @@ describe('Validation of ImportDefinition', () => { it('should diagnose error on unsupported file ending', async () => { const relativeTestFilePath = - 'import-definition/invalid-imported-file-with-wrong-file-ending.jv'; + 'import-definition/wildcard-import/invalid-imported-file-with-wrong-file-ending.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -134,7 +134,7 @@ describe('Validation of ImportDefinition', () => { it('should diagnose error on cyclic import', async () => { const relativeTestFilePath = - 'import-definition/invalid-dependency-cycle-1.jv'; + 'import-definition/wildcard-import/invalid-dependency-cycle-1.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -149,7 +149,7 @@ describe('Validation of ImportDefinition', () => { it('should diagnose error on cyclic import when importing itself', async () => { const relativeTestFilePath = - 'import-definition/invalid-dependency-cycle-self-import.jv'; + 'import-definition/wildcard-import/invalid-dependency-cycle-self-import.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -164,7 +164,7 @@ describe('Validation of ImportDefinition', () => { it('should diagnose error on cyclic import even when cycle resides in imported file', async () => { const relativeTestFilePath = - 'import-definition/invalid-dependency-cycle-deeper-1.jv'; + 'import-definition/wildcard-import/invalid-dependency-cycle-deeper-1.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -179,7 +179,7 @@ describe('Validation of ImportDefinition', () => { it('should diagnose error on cyclic import spans multiple files', async () => { const relativeTestFilePath = - 'import-definition/invalid-dependency-cycle-transitive-1.jv'; + 'import-definition/wildcard-import/invalid-dependency-cycle-transitive-1.jv'; await parseAndValidateImportDefinition(relativeTestFilePath); @@ -192,4 +192,96 @@ describe('Validation of ImportDefinition', () => { ); }); }); + + describe('ImportDefinition named element syntax', () => { + it('should diagnose no error on specific element use that is published via element definition', async () => { + const relativeTestFilePath = + 'import-definition/named-import/valid-imported-element-exists.jv'; + + await parseAndValidateImportDefinition(relativeTestFilePath); + + expect(validationAcceptorMock).toHaveBeenCalledTimes(0); + }); + + it('should diagnose no error on specific element use that is published via export definition', async () => { + const relativeTestFilePath = + 'import-definition/named-import/valid-imported-aliased-element-exists.jv'; + + await parseAndValidateImportDefinition(relativeTestFilePath); + + expect(validationAcceptorMock).toHaveBeenCalledTimes(0); + }); + + it('should diagnose error on specific element use that does not exist', async () => { + const relativeTestFilePath = + 'import-definition/named-import/invalid-imported-element-not-existing.jv'; + + await parseAndValidateImportDefinition(relativeTestFilePath); + + expect(validationAcceptorMock).toHaveBeenCalledTimes(1); + expect(validationAcceptorMock).toHaveBeenNthCalledWith( + 1, + 'error', + 'Could not find published element A in file "./publishing.jv". Check if the element exists and has been correctly published.', + expect.any(Object), + ); + }); + + it('should diagnose error on specific element use that exists but is not published', async () => { + const relativeTestFilePath = + 'import-definition/named-import/invalid-imported-element-not-published.jv'; + + await parseAndValidateImportDefinition(relativeTestFilePath); + + expect(validationAcceptorMock).toHaveBeenCalledTimes(1); + expect(validationAcceptorMock).toHaveBeenNthCalledWith( + 1, + 'error', + 'Could not find published element Y in file "./publishing.jv". Check if the element exists and has been correctly published.', + expect.any(Object), + ); + }); + + it('should diagnose error on specific element being imported multiple times in one statement', async () => { + const relativeTestFilePath = + 'import-definition/named-import/invalid-imported-element-multiple-times-same-statement.jv'; + + await parseAndValidateImportDefinition(relativeTestFilePath); + + expect(validationAcceptorMock).toHaveBeenCalledTimes(2); + expect(validationAcceptorMock).toHaveBeenNthCalledWith( + 1, + 'error', + 'Element X is imported 2 times from file "./publishing.jv". Remove the duplicate import.', + expect.any(Object), + ); + expect(validationAcceptorMock).toHaveBeenNthCalledWith( + 2, + 'error', + 'Element X is imported 2 times from file "./publishing.jv". Remove the duplicate import.', + expect.any(Object), + ); + }); + + it('should diagnose error on specific file being imported multiple times', async () => { + const relativeTestFilePath = + 'import-definition/named-import/invalid-imported-file-multiple-times.jv'; + + await parseAndValidateImportDefinition(relativeTestFilePath); + + expect(validationAcceptorMock).toHaveBeenCalledTimes(2); + expect(validationAcceptorMock).toHaveBeenNthCalledWith( + 1, + 'error', + 'Found 2 import statements for file "./publishing.jv". Combine both import statements.', + expect.any(Object), + ); + expect(validationAcceptorMock).toHaveBeenNthCalledWith( + 2, + 'error', + 'Found 2 import statements for file "publishing.jv". Combine both import statements.', + expect.any(Object), + ); + }); + }); }); diff --git a/libs/language-server/src/lib/validation/checks/import-definition.ts b/libs/language-server/src/lib/validation/checks/import-definition.ts index cac0ba6f7..79f01bc68 100644 --- a/libs/language-server/src/lib/validation/checks/import-definition.ts +++ b/libs/language-server/src/lib/validation/checks/import-definition.ts @@ -5,7 +5,13 @@ // eslint-disable-next-line unicorn/prefer-node-protocol import { strict as assert } from 'assert'; -import { type ImportDefinition } from '../../ast/generated/ast'; +import { AstUtils, UriUtils } from 'langium'; + +import { + type ImportDefinition, + isExportableElement, + isJayveeModel, +} from '../../ast/generated/ast'; import { type JayveeValidationProps } from '../validation-registry'; export function validateImportDefinition( @@ -17,7 +23,69 @@ export function validateImportDefinition( return; } + checkImportedElementsExist(importDefinition, props); checkCyclicImportChain(importDefinition, props); + checkElementImportedOnlyOnce(importDefinition, props); + checkFileImportedOnlyOnce(importDefinition, props); +} + +function checkElementImportedOnlyOnce( + importDefinition: ImportDefinition, + props: JayveeValidationProps, +): void { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const importedElements = importDefinition.usedElements ?? []; + + for (const [i, importedElement] of importedElements.entries()) { + const occurrencesInSameImportDefinition = importedElements.filter( + (x) => x === importedElement, + ).length; + + if (occurrencesInSameImportDefinition > 1) { + props.validationContext.accept( + 'error', + `Element ${importedElement} is imported ${occurrencesInSameImportDefinition} times from file "${ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + importDefinition.path ?? '' + }". Remove the duplicate import.`, + { + node: importDefinition, + property: 'usedElements', + index: i, + }, + ); + } + } +} + +function checkFileImportedOnlyOnce( + importDefinition: ImportDefinition, + props: JayveeValidationProps, +): void { + const currentImportUri = + props.importResolver.resolveImportUri(importDefinition); + const allImportsInDocument = + AstUtils.getContainerOfType(importDefinition, isJayveeModel)?.imports ?? []; + + const occurrencesImportsFromPath = allImportsInDocument.filter((x) => + UriUtils.equals(props.importResolver.resolveImportUri(x), currentImportUri), + ).length; + + if (occurrencesImportsFromPath <= 1) { + return; + } + + props.validationContext.accept( + 'error', + `Found ${occurrencesImportsFromPath} import statements for file "${ + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + importDefinition.path ?? '' + }". Combine both import statements.`, + { + node: importDefinition, + property: 'path', + }, + ); } function checkPathExists( @@ -37,6 +105,59 @@ function checkPathExists( } } +function checkImportedElementsExist( + importDefinition: ImportDefinition, + props: JayveeValidationProps, +): void { + const resolvedImport = props.importResolver.resolveImport(importDefinition); + if (resolvedImport === undefined) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const exportedViaElementDefinition = (resolvedImport.exportableElements ?? []) + .filter((x) => x.isPublished) + .map((x) => { + assert( + isExportableElement(x), + 'Exported an element that is not an ExportableElement', + ); + return x.name; + }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const exportedViaExportDefinition = (resolvedImport.exports ?? []) + .map((x) => { + if (x.alias !== undefined) { + return x.alias; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + return x.element?.ref?.name; + }) + .filter((x) => x !== undefined); + + const allExports = [ + ...exportedViaElementDefinition, + ...exportedViaExportDefinition, + ]; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + for (const [i, importedElement] of importDefinition.usedElements?.entries() ?? + []) { + if (!allExports.includes(importedElement)) { + props.validationContext.accept( + 'error', + `Could not find published element ${importedElement} in file "${importDefinition.path}". Check if the element exists and has been correctly published.`, + { + node: importDefinition, + property: 'usedElements', + index: i, + }, + ); + } + } +} + function checkCyclicImportChain( importDefinition: ImportDefinition, props: JayveeValidationProps, diff --git a/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-multiple-times-same-statement.jv b/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-multiple-times-same-statement.jv new file mode 100644 index 000000000..4f1d0b132 --- /dev/null +++ b/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-multiple-times-same-statement.jv @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +use { X, X } from './publishing.jv'; diff --git a/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-not-existing.jv b/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-not-existing.jv new file mode 100644 index 000000000..3060416e6 --- /dev/null +++ b/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-not-existing.jv @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +use { A } from './publishing.jv'; diff --git a/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-not-published.jv b/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-not-published.jv new file mode 100644 index 000000000..f8437f321 --- /dev/null +++ b/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-element-not-published.jv @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +use { Y } from './publishing.jv'; diff --git a/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-file-multiple-times.jv b/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-file-multiple-times.jv new file mode 100644 index 000000000..13e18b00f --- /dev/null +++ b/libs/language-server/src/test/assets/import-definition/named-import/invalid-imported-file-multiple-times.jv @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +use { X } from './publishing.jv'; +use { X } from 'publishing.jv'; diff --git a/libs/language-server/src/test/assets/import-definition/named-import/publishing.jv b/libs/language-server/src/test/assets/import-definition/named-import/publishing.jv new file mode 100644 index 000000000..70eac9fa5 --- /dev/null +++ b/libs/language-server/src/test/assets/import-definition/named-import/publishing.jv @@ -0,0 +1,8 @@ +// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +publish constraint X on integer: value > 0; + +constraint Y on integer: value > 0; +publish Y as Z; \ No newline at end of file diff --git a/libs/language-server/src/test/assets/import-definition/named-import/valid-imported-aliased-element-exists.jv b/libs/language-server/src/test/assets/import-definition/named-import/valid-imported-aliased-element-exists.jv new file mode 100644 index 000000000..f76cffe34 --- /dev/null +++ b/libs/language-server/src/test/assets/import-definition/named-import/valid-imported-aliased-element-exists.jv @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +use { Z } from './publishing.jv'; diff --git a/libs/language-server/src/test/assets/import-definition/named-import/valid-imported-element-exists.jv b/libs/language-server/src/test/assets/import-definition/named-import/valid-imported-element-exists.jv new file mode 100644 index 000000000..bcda1e779 --- /dev/null +++ b/libs/language-server/src/test/assets/import-definition/named-import/valid-imported-element-exists.jv @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2023 Friedrich-Alexander-Universitat Erlangen-Nurnberg +// +// SPDX-License-Identifier: AGPL-3.0-only + +use { X } from './publishing.jv'; diff --git a/libs/language-server/src/test/assets/import-definition/deeper/existing-imported-file-deeper.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/deeper/existing-imported-file-deeper.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/deeper/existing-imported-file-deeper.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/deeper/existing-imported-file-deeper.jv diff --git a/libs/language-server/src/test/assets/import-definition/deeper/valid-imported-file-exists-higher-dir.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/deeper/valid-imported-file-exists-higher-dir.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/deeper/valid-imported-file-exists-higher-dir.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/deeper/valid-imported-file-exists-higher-dir.jv diff --git a/libs/language-server/src/test/assets/import-definition/existing-imported-file.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/existing-imported-file.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/existing-imported-file.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/existing-imported-file.jv diff --git a/libs/language-server/src/test/assets/import-definition/existing-imported-file.njv b/libs/language-server/src/test/assets/import-definition/wildcard-import/existing-imported-file.njv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/existing-imported-file.njv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/existing-imported-file.njv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-1.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-1.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-1.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-1.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-2.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-2.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-2.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-2.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-deeper-1.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-deeper-1.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-deeper-1.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-deeper-1.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-deeper-2.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-deeper-2.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-deeper-2.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-deeper-2.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-deeper-3.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-deeper-3.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-deeper-3.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-deeper-3.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-self-import.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-self-import.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-self-import.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-self-import.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-transitive-1.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-transitive-1.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-transitive-1.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-transitive-1.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-transitive-2.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-transitive-2.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-transitive-2.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-transitive-2.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-transitive-3.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-transitive-3.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-dependency-cycle-transitive-3.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-dependency-cycle-transitive-3.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-imported-file-with-wrong-file-ending.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-imported-file-with-wrong-file-ending.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-imported-file-with-wrong-file-ending.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-imported-file-with-wrong-file-ending.jv diff --git a/libs/language-server/src/test/assets/import-definition/invalid-imported-not-existing-file.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-imported-not-existing-file.jv similarity index 100% rename from libs/language-server/src/test/assets/import-definition/invalid-imported-not-existing-file.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/invalid-imported-not-existing-file.jv diff --git a/libs/language-server/src/test/assets/import-definition/valid-imported-file-exists-deeper-dir.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/valid-imported-file-exists-deeper-dir.jv similarity index 76% rename from libs/language-server/src/test/assets/import-definition/valid-imported-file-exists-deeper-dir.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/valid-imported-file-exists-deeper-dir.jv index 5e8a4eb3e..23c449f8a 100644 --- a/libs/language-server/src/test/assets/import-definition/valid-imported-file-exists-deeper-dir.jv +++ b/libs/language-server/src/test/assets/import-definition/wildcard-import/valid-imported-file-exists-deeper-dir.jv @@ -3,4 +3,3 @@ // SPDX-License-Identifier: AGPL-3.0-only use * from 'deeper/existing-imported-file-deeper.jv'; -use * from './deeper/existing-imported-file-deeper.jv'; diff --git a/libs/language-server/src/test/assets/import-definition/valid-imported-file-exists-same-dir.jv b/libs/language-server/src/test/assets/import-definition/wildcard-import/valid-imported-file-exists-same-dir.jv similarity index 80% rename from libs/language-server/src/test/assets/import-definition/valid-imported-file-exists-same-dir.jv rename to libs/language-server/src/test/assets/import-definition/wildcard-import/valid-imported-file-exists-same-dir.jv index d24ee6178..fed06a340 100644 --- a/libs/language-server/src/test/assets/import-definition/valid-imported-file-exists-same-dir.jv +++ b/libs/language-server/src/test/assets/import-definition/wildcard-import/valid-imported-file-exists-same-dir.jv @@ -3,4 +3,3 @@ // SPDX-License-Identifier: AGPL-3.0-only use * from 'existing-imported-file.jv'; -use * from './existing-imported-file.jv';