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

Remove duplicate entries for auto-completion suggestions and sort #70

Merged
merged 3 commits into from
Oct 18, 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
53 changes: 35 additions & 18 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,26 +115,23 @@
"name": "Debug All CJS Jest Tests",
"type": "node",
"request": "launch",
"runtimeArgs": [
"--inspect-brk",
"${workspaceRoot}/node_modules/jest/bin/jest.js",
"--runInBand",
"--config=${workspaceRoot}/configs/jest.config.js"
],
"cwd": "${workspaceRoot}",
"runtimeArgs": ["--inspect-brk", "node_modules/jest/bin/jest.js"],
"args": ["--runInBand", "--config=configs/jest.config.js"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"name": "Debug All ESM Jest Tests",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeArgs": [
"--experimental-vm-modules",
"--inspect-brk",
"${workspaceRoot}/node_modules/jest/bin/jest.js",
"--runInBand",
"--config=${workspaceRoot}/configs/esm.jest.config.js"
"node_modules/jest/bin/jest.js"
],
"args": ["--runInBand", "--config=configs/esm.jest.config.js"],
"env": {
"NODE_NO_WARNINGS": "1"
},
Expand All @@ -145,37 +142,57 @@
"name": "Debug Open CJS Jest Test",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeArgs": ["--inspect-brk", "node_modules/jest/bin/jest.js"],
"args": ["--runInBand", "--config=configs/jest.config.js", "${fileBasenameNoExtension}"],
"env": {
"NODE_NO_WARNINGS": "1"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"name": "Debug Open ESM Jest Test",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeArgs": [
"--experimental-vm-modules",
"--inspect-brk",
"${workspaceRoot}/node_modules/jest/bin/jest.js",
"--runInBand",
"--config=${workspaceRoot}/configs/jest.config.js",
"${file}"
"node_modules/jest/bin/jest.js"
],
"args": ["--runInBand", "--config=configs/esm.jest.config.js", "${fileBasenameNoExtension}"],
"env": {
"NODE_NO_WARNINGS": "1"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"name": "Debug Open ESM Jest Test",
"name": "vscode-jest-tests.v2.crossmodel",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeArgs": [
"--experimental-vm-modules",
"--inspect-brk",
"${workspaceRoot}/node_modules/jest/bin/jest.js",
"node_modules/jest/bin/jest.js"
],
"args": [
"--runInBand",
"--config=${workspaceRoot}/configs/esm.jest.config.js",
"${file}"
"--watchAll=false",
"--config=configs/esm.jest.config.js",
"--testNamePattern",
"${jest.testNamePattern}",
"--runTestsByPath",
"${jest.testFile}"
],
"env": {
"NODE_NO_WARNINGS": "1"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"outFiles": []
"disableOptimisticBPs": true
}
],
"compounds": [
Expand Down
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
34 changes: 29 additions & 5 deletions extensions/crossmodel-lang/src/language-server/util/uri-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,44 @@
* Copyright (c) 2023 CrossBreeze.
********************************************************************************/
import * as fs from 'fs';
import * as nodePath from 'path';
import * as path from 'path';
import { URI, Utils as UriUtils } from 'vscode-uri';

const posixPath = nodePath.posix || nodePath;

export namespace Utils {
/**
* @param parent parent URI
* @param child child URI
* @returns true if the child URI is actually a child of the parent URI
*/
export function isChildOf(parent: URI, child: URI): boolean {
const relative = posixPath.relative(parent.fsPath, child.fsPath);
return !!relative && !relative.startsWith('..') && !posixPath.isAbsolute(relative);
// 1. Schemes and auhorities must match
if (parent.scheme !== child.scheme || parent.authority !== child.authority) {
return false;
}
// 2. Both URIs must have hierarchical paths
if (!parent.path || !child.path) {
return false;
}
// 3. Handle 'file' scheme separately to account for filesystem specifics
if (parent.scheme === 'file') {
const relative = path.relative(parent.fsPath, child.fsPath);
return !!relative && !relative.startsWith('..') && !path.isAbsolute(relative);
}
// 4. Handle other hierarchical schemes
const childPath = normalizePath(child.path);
const parentPath = normalizePath(parent.path);
return childPath.startsWith(parentPath + '/');
}

/**
* Normalizes a URI path by resolving '.' and '..' segments and removing trailing slashes.
* Converts backslashes to forward slashes for consistency.
* @param toNormalize The path to normalize.
* @returns The normalized path.
*/
function normalizePath(toNormalize: string): string {
// Replace backslashes with forward slashes, normalize and remove trailing slashes
return path.posix.normalize(toNormalize.replace(/\\/g, '/')).replace(/\/+$/, '');
}

/**
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({
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({
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
Loading
Loading