Skip to content

Commit

Permalink
Remove duplicate entries for auto-completion suggestions and sort
Browse files Browse the repository at this point in the history
- Prefer package local names over global names
- Sort local names before global name in textual and diagram editor

Fixes #66
  • Loading branch information
martin-fleck-at committed Oct 8, 2024
1 parent a38f61d commit e6c6f8f
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 46 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
********************************************************************************/
import { quote } from '@crossbreeze/protocol';
import { AstNodeDescription, AstUtils, GrammarAST, GrammarUtils, MaybePromise, ReferenceInfo, Stream } from 'langium';
import { CompletionAcceptor, CompletionContext, DefaultCompletionProvider, NextFeature } from 'langium/lsp';
import { CompletionAcceptor, CompletionContext, CompletionValueItem, DefaultCompletionProvider, NextFeature } from 'langium/lsp';
import { v4 as uuid } from 'uuid';
import { CompletionItemKind, InsertTextFormat, TextEdit } from 'vscode-languageserver-protocol';
import type { Range } from 'vscode-languageserver-types';
import { CrossModelServices } from './cross-model-module.js';
import { CrossModelScopeProvider } from './cross-model-scope-provider.js';
import { AttributeMapping, isAttributeMapping, RelationshipAttribute } from './generated/ast.js';
import { fixDocument } from './util/ast-util.js';

Expand All @@ -21,6 +22,8 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider {
triggerCharacters: ['\n', ' ', '{']
};

protected declare readonly scopeProvider: CrossModelScopeProvider;

constructor(
protected services: CrossModelServices,
protected packageManager = services.shared.workspace.PackageManager
Expand Down Expand Up @@ -212,17 +215,7 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider {
}

protected override getReferenceCandidates(refInfo: ReferenceInfo, context: CompletionContext): Stream<AstNodeDescription> {
return super
.getReferenceCandidates(refInfo, context)
.filter(description =>
this.services.references.ScopeProvider.filterCompletion(
description,
context.document,
this.packageId!,
context.node,
context.features[context.features.length - 1].property
)
);
return this.services.references.ScopeProvider.getCompletionScope(refInfo).elementScope.getAllElements();
}

protected filterRelationshipAttribute(node: RelationshipAttribute, context: CompletionContext, desc: AstNodeDescription): boolean {
Expand All @@ -235,4 +228,12 @@ export class CrossModelCompletionProvider extends DefaultCompletionProvider {
}
return true;
}

protected override createReferenceCompletionItem(description: AstNodeDescription): CompletionValueItem {
const item = super.createReferenceCompletionItem(description);
return {
...item,
sortText: this.scopeProvider.sortText(description)
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export class CrossModelPackageManager {
await this.initializePackages(entry.uri);
} else if (entry.isFile && isPackageUri(entry.uri)) {
const text = await this.fileSystemProvider.readFile(entry.uri);
this.updatePackage(entry.uri, text);
await this.updatePackage(entry.uri, text);
}
})
);
Expand Down Expand Up @@ -190,15 +190,15 @@ export class CrossModelPackageManager {
return visible;
}

protected onBuildUpdate(changed: URI[], deleted: URI[]): void {
protected async onBuildUpdate(changed: URI[], deleted: URI[]): Promise<void> {
// convert 'package.json' updates to document updates
// - remove 'package.json' updates and track necessary changes
// - build all text documents that are within updated packages

const affectedPackages: string[] = [];
const changedPackages = getAndRemovePackageUris(changed);
for (const changedPackage of changedPackages) {
affectedPackages.push(...this.updatePackage(changedPackage));
affectedPackages.push(...(await this.updatePackage(changedPackage)));
}
const deletedPackages = getAndRemovePackageUris(deleted);
for (const deletedPackage of deletedPackages) {
Expand Down Expand Up @@ -256,8 +256,9 @@ export class CrossModelPackageManager {
return [];
}

protected updatePackage(uri: URI, text = this.shared.workspace.TextDocuments.get(uri.toString())?.getText()): string[] {
const newPackageJson = parsePackageJson(text || Utils.readFile(uri));
protected async updatePackage(uri: URI, text = this.shared.workspace.TextDocuments.get(uri.toString())?.getText()): Promise<string[]> {
const documentText = text ?? (await this.shared.workspace.FileSystemProvider.readFile(uri));
const newPackageJson = parsePackageJson(documentText);
if (!newPackageJson) {
return [];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import {
CrossReference,
CrossReferenceContainer,
CrossReferenceContext,
ReferenceableElement,
isGlobalElementReference,
isRootElementReference,
isSyntheticDocument
isSyntheticDocument,
ReferenceableElement
} from '@crossbreeze/protocol';
import {
AstNode,
Expand All @@ -23,7 +23,8 @@ import {
URI
} from 'langium';
import { CrossModelServices } from './cross-model-module.js';
import { GlobalAstNodeDescription, PackageAstNodeDescription } from './cross-model-scope.js';
import { QUALIFIED_ID_SEPARATOR } from './cross-model-naming.js';
import { GlobalAstNodeDescription, isGlobalDescriptionForLocalPackage, PackageAstNodeDescription } from './cross-model-scope.js';
import {
isAttributeMapping,
isRelationshipAttribute,
Expand Down Expand Up @@ -151,18 +152,25 @@ export class CrossModelScopeProvider extends PackageScopeProvider {
return context;
}

getCompletionScope(ctx: CrossReferenceContext): CompletionScope {
const referenceInfo = this.referenceContextToInfo(ctx);
getCompletionScope(ctx: CrossReferenceContext | ReferenceInfo): CompletionScope {
const referenceInfo = 'reference' in ctx ? ctx : this.referenceContextToInfo(ctx);
const document = AstUtils.getDocument(referenceInfo.container);
const packageId = this.packageManager.getPackageIdByDocument(document);
const filteredDescriptions = this.getScope(referenceInfo)
.getAllElements()
.filter(description => this.filterCompletion(description, document, packageId, referenceInfo.container, referenceInfo.property))
.distinct(description => description.name);
.distinct(description => description.name)
.toArray()
.sort((left, right) => this.sortText(left).localeCompare(this.sortText(right)));
const elementScope = this.createScope(filteredDescriptions);
return { elementScope, source: referenceInfo };
}

sortText(description: AstNodeDescription): string {
// prefix with number of segments in the qualified name to ensure that local names are sorted first
return description.name.split(QUALIFIED_ID_SEPARATOR).length + '_' + description.name;
}

complete(ctx: CrossReferenceContext): ReferenceableElement[] {
return this.getCompletionScope(ctx)
.elementScope.getAllElements()
Expand All @@ -171,8 +179,7 @@ export class CrossModelScopeProvider extends PackageScopeProvider {
type: description.type,
label: description.name
}))
.toArray()
.sort((left, right) => left.label.localeCompare(right.label));
.toArray();
}

filterCompletion(
Expand Down Expand Up @@ -213,7 +220,7 @@ export class CrossModelScopeProvider extends PackageScopeProvider {
const allowedOwners = [sourceObject.id, ...sourceObject.dependencies.map(dependency => dependency.source.$refText)];
return !!allowedOwners.find(allowedOwner => description.name.startsWith(allowedOwner + '.'));
}
return true;
return !isGlobalDescriptionForLocalPackage(description, packageId);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/********************************************************************************
* Copyright (c) 2024 CrossBreeze.
********************************************************************************/

import { expandToString } from 'langium/generate';
import { expectCompletion } from 'langium/test';
import { address } from './test-utils/test-documents/entity/address.js';
import { customer } from './test-utils/test-documents/entity/customer.js';
import { order } from './test-utils/test-documents/entity/order.js';
import { createCrossModelTestServices, MockFileSystem, parseProject, testUri } from './test-utils/utils.js';

const services = createCrossModelTestServices(MockFileSystem);
const assertCompletion = expectCompletion(services);

describe('CrossModelCompletionProvider', () => {
const text = expandToString`
relationship:
id: Address_Customer
name: "Address - Customer"
parent: <|>
`;

beforeAll(async () => {
const packageA = await parseProject({
package: { services, uri: testUri('projectA', 'package.json'), content: { name: 'ProjectA', version: '1.0.0' } },
documents: [
{ services, text: address, documentUri: testUri('projectA', 'address.entity.cm') },
{ services, text: order, documentUri: testUri('projectA', 'order.entity.cm') }
]
});

await parseProject({
package: {
services,
uri: testUri('projectB', 'package.json'),
content: { name: 'ProjectB', version: '1.0.0', dependencies: { ...packageA } }
},
documents: [{ services, text: customer, documentUri: testUri('projectB', 'customer.entity.cm') }]
});
});

test('Completion for entity references in project A', async () => {
await assertCompletion({

Check failure on line 43 in extensions/crossmodel-lang/test/language-server/cross-model-completion-provider.test.ts

View workflow job for this annotation

GitHub Actions / build-and-test / windows-2022

CrossModelCompletionProvider › Completion for entity references in project A

assert.deepStrictEqual(received, expected) Expected value to deeply and strictly equal to: ["Address", "Order"] Received: ["Address", "Customer", "Order"] Message: Expected 2 but received 3 completion items Difference: - Expected + Received Array [ "Address", + "Customer", "Order", ] at expectedFunction (../../node_modules/langium/src/test/langium-test.ts:53:12) at ../../node_modules/langium/src/test/langium-test.ts:318:17 at Object.<anonymous> (test/language-server/cross-model-completion-provider.test.ts:43:7)
text,
parseOptions: { documentUri: testUri('projectA', 'rel.relationship.cm') },
index: 0,
expectedItems: ['Address', 'Order'],
disposeAfterCheck: true
});
});

test('Completion for entity references in project B', async () => {
await assertCompletion({

Check failure on line 53 in extensions/crossmodel-lang/test/language-server/cross-model-completion-provider.test.ts

View workflow job for this annotation

GitHub Actions / build-and-test / windows-2022

CrossModelCompletionProvider › Completion for entity references in project B

assert.deepStrictEqual(received, expected) Expected value to deeply and strictly equal to: "Customer" Received: "Address" Difference: - Expected + Received - Customer + Address at expectedFunction (../../node_modules/langium/src/test/langium-test.ts:53:12) at ../../node_modules/langium/src/test/langium-test.ts:311:25 at Object.<anonymous> (test/language-server/cross-model-completion-provider.test.ts:53:7)
text,
parseOptions: { documentUri: testUri('projectB', 'rel.relationship.cm') },
index: 0,
expectedItems: ['Customer', 'ProjectA.Address', 'ProjectA.Order'],
disposeAfterCheck: true
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ const services = createCrossModelTestServices();

describe('CrossModel language Relationship', () => {
beforeAll(async () => {
await parseDocuments(services, [order, customer, address]);
await parseDocuments([
{ services, text: order },
{ services, text: customer },
{ services, text: address }
]);
});

test('Simple file for relationship', async () => {
Expand Down
93 changes: 74 additions & 19 deletions extensions/crossmodel-lang/test/language-server/test-utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/

import { EmptyFileSystem, LangiumDocument } from 'langium';
import { LangiumServices } from 'langium/lsp';
import { EmptyFileSystem, FileSystemNode, FileSystemProvider, LangiumDocument, URI } from 'langium';
import { DefaultSharedModuleContext, LangiumServices } from 'langium/lsp';
import { ParseHelperOptions, parseDocument as langiumParseDocument } from 'langium/test';
import path from 'path';
import { PackageJson } from 'type-fest';
import { CrossModelServices, createCrossModelServices } from '../../../src/language-server/cross-model-module.js';
import {
CrossModelRoot,
Expand All @@ -19,36 +21,64 @@ import {
} from '../../../src/language-server/generated/ast.js';
import { SemanticRoot, TypeGuard, WithDocument, findSemanticRoot } from '../../../src/language-server/util/ast-util.js';

export function createCrossModelTestServices(): CrossModelServices {
return createCrossModelServices({ ...EmptyFileSystem }).CrossModel;
export function createCrossModelTestServices(context: DefaultSharedModuleContext = EmptyFileSystem): CrossModelServices {
return createCrossModelServices(context).CrossModel;
}

export const parseDocument = langiumParseDocument<CrossModelRoot>;
export interface ProjectInput {
package: PackageInput;
documents: DocumentInput[];
}

export interface PackageInput {
services: LangiumServices;
uri: string;
content: PackageJson & { name: string; version: string };
}

export type PackageDependency = Record<string, string>;

export interface ParseInput<T = string | string[]> extends ParseHelperOptions {
export interface DocumentInput extends ParseHelperOptions {
services: LangiumServices;
text: T;
text: string;
}

export interface ParseAssert {
lexerErrors?: number;
parserErrors?: number;
}

export async function parseDocuments(
services: LangiumServices,
inputs: string[],
options?: ParseHelperOptions
): Promise<LangiumDocument<CrossModelRoot>[]> {
return Promise.all(inputs.map(input => parseDocument(services, input, options)));
export async function parseProject(input: ProjectInput): Promise<PackageDependency> {
const projectDependency = await parsePackage(input.package);
await parseDocuments(input.documents);
return projectDependency;
}

export async function parsePackage(input: PackageInput): Promise<PackageDependency> {
await parseDocument({ text: JSON.stringify(input.content), documentUri: input.uri, services: input.services });
return { [input.content.name]: input.content.version };
}

export async function parseDocument(input: DocumentInput): Promise<LangiumDocument<CrossModelRoot>> {
if (input.documentUri) {
const fileSystemProvider = input.services.shared.workspace.FileSystemProvider;
if (fileSystemProvider instanceof MockFileSystemProvider) {
fileSystemProvider.setFile(URI.parse(input.documentUri), input.text);
}
}
return langiumParseDocument<CrossModelRoot>(input.services, input.text, input);
}

export async function parseDocuments(inputs: DocumentInput[]): Promise<LangiumDocument<CrossModelRoot>[]> {
return Promise.all(inputs.map(parseDocument));
}

export async function parseSemanticRoot<T extends SemanticRoot>(
input: ParseInput<string>,
input: DocumentInput,
assert: ParseAssert,
guard: TypeGuard<T>
): Promise<WithDocument<T>> {
const document = await parseDocument(input.services, input.text, input);
const document = await parseDocument(input);
expect(document.parseResult.lexerErrors).toHaveLength(assert.lexerErrors ?? 0);
expect(document.parseResult.parserErrors).toHaveLength(assert.parserErrors ?? 0);
const semanticRoot = findSemanticRoot(document, guard);
Expand All @@ -57,18 +87,43 @@ export async function parseSemanticRoot<T extends SemanticRoot>(
return semanticRoot as WithDocument<T>;
}

export async function parseEntity(input: ParseInput<string>, assert: ParseAssert = {}): Promise<WithDocument<Entity>> {
export async function parseEntity(input: DocumentInput, assert: ParseAssert = {}): Promise<WithDocument<Entity>> {
return parseSemanticRoot(input, assert, isEntity);
}

export async function parseRelationship(input: ParseInput<string>, assert: ParseAssert = {}): Promise<WithDocument<Relationship>> {
export async function parseRelationship(input: DocumentInput, assert: ParseAssert = {}): Promise<WithDocument<Relationship>> {
return parseSemanticRoot(input, assert, isRelationship);
}

export async function parseSystemDiagram(input: ParseInput<string>, assert: ParseAssert = {}): Promise<WithDocument<SystemDiagram>> {
export async function parseSystemDiagram(input: DocumentInput, assert: ParseAssert = {}): Promise<WithDocument<SystemDiagram>> {
return parseSemanticRoot(input, assert, isSystemDiagram);
}

export async function parseMapping(input: ParseInput<string>, assert: ParseAssert = {}): Promise<WithDocument<Mapping>> {
export async function parseMapping(input: DocumentInput, assert: ParseAssert = {}): Promise<WithDocument<Mapping>> {
return parseSemanticRoot(input, assert, isMapping);
}

export const MockFileSystem: DefaultSharedModuleContext = {
fileSystemProvider: () => new MockFileSystemProvider()
};

export class MockFileSystemProvider implements FileSystemProvider {
protected fileContent = new Map<string, string>();

setFile(uri: URI, content: string): void {
this.fileContent.set(uri.toString(), content);
}

async readFile(uri: URI): Promise<string> {
return this.fileContent.get(uri.toString()) ?? '';
}

async readDirectory(uri: URI): Promise<FileSystemNode[]> {
return [];
}
}

export function testUri(...segments: string[]): string {
// making sure the URI works on both Windows and Unix
return URI.file(path.resolve(process.cwd(), ...segments)).toString();
}

0 comments on commit e6c6f8f

Please sign in to comment.