Skip to content
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
2 changes: 1 addition & 1 deletion packages/ide/vscode/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "zenstack-v3",
"publisher": "zenstack",
"version": "3.0.11",
"version": "3.0.12",
"displayName": "ZenStack V3 Language Tools",
"description": "VSCode extension for ZenStack (v3) ZModel language",
"private": true,
Expand Down
3 changes: 2 additions & 1 deletion packages/language/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"dependencies": {
"langium": "catalog:",
"pluralize": "^8.0.0",
"ts-pattern": "catalog:"
"ts-pattern": "catalog:",
"vscode-languageserver": "^9.0.1"
},
"devDependencies": {
"@types/pluralize": "^0.0.33",
Expand Down
1 change: 1 addition & 0 deletions packages/language/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { loadDocument } from './document';
export * from './module';
export { ZModelCodeGenerator } from './zmodel-code-generator';
37 changes: 29 additions & 8 deletions packages/language/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,15 @@ import type { Model } from './ast';
import { ZModelGeneratedModule, ZModelGeneratedSharedModule, ZModelLanguageMetaData } from './generated/module';
import { getPluginDocuments } from './utils';
import { registerValidationChecks, ZModelValidator } from './validator';
import { ZModelCommentProvider } from './zmodel-comment-provider';
import { ZModelCompletionProvider } from './zmodel-completion-provider';
import { ZModelDefinitionProvider } from './zmodel-definition';
import { ZModelDocumentBuilder } from './zmodel-document-builder';
import { ZModelDocumentationProvider } from './zmodel-documentation-provider';
import { ZModelFormatter } from './zmodel-formatter';
import { ZModelLinker } from './zmodel-linker';
import { ZModelScopeComputation, ZModelScopeProvider } from './zmodel-scope';
import { ZModelSemanticTokenProvider } from './zmodel-semantic';
import { ZModelWorkspaceManager } from './zmodel-workspace-manager';
export { ZModelLanguageMetaData };

Expand Down Expand Up @@ -49,6 +55,16 @@ export const ZModelLanguageModule: Module<ZModelServices, PartialLangiumServices
validation: {
ZModelValidator: (services) => new ZModelValidator(services),
},
lsp: {
Formatter: (services) => new ZModelFormatter(services),
DefinitionProvider: (services) => new ZModelDefinitionProvider(services),
CompletionProvider: (services) => new ZModelCompletionProvider(services),
SemanticTokenProvider: (services) => new ZModelSemanticTokenProvider(services),
},
documentation: {
CommentProvider: (services) => new ZModelCommentProvider(services),
DocumentationProvider: (services) => new ZModelDocumentationProvider(services),
},
};

export type ZModelSharedServices = LangiumSharedServices;
Expand Down Expand Up @@ -109,15 +125,20 @@ export function createZModelLanguageServices(

const schemaPath = fileURLToPath(doc.uri.toString());
const pluginSchemas = getPluginDocuments(doc.parseResult.value as Model, schemaPath);

// ensure plugin docs are loaded
for (const plugin of pluginSchemas) {
// load the plugin model document
const pluginDoc = await shared.workspace.LangiumDocuments.getOrCreateDocument(
URI.file(path.resolve(plugin)),
);
// add to indexer so the plugin model's definitions are globally visible
shared.workspace.IndexManager.updateContent(pluginDoc);
if (logToConsole) {
console.log(`Loaded plugin model: ${plugin}`);
const pluginDocUri = URI.file(path.resolve(plugin));
let pluginDoc = shared.workspace.LangiumDocuments.getDocument(pluginDocUri);
if (!pluginDoc) {
pluginDoc = await shared.workspace.LangiumDocuments.getOrCreateDocument(pluginDocUri);
if (pluginDoc) {
// add to indexer so the plugin model's definitions are globally visible
shared.workspace.IndexManager.updateContent(pluginDoc);
if (logToConsole) {
console.log(`Loaded plugin model: ${plugin}`);
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,6 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
// TODO: design a way to let plugin register validation
@check('@@allow')
@check('@@deny')
// @ts-expect-error
private _checkModelLevelPolicy(attr: AttributeApplication, accept: ValidationAcceptor) {
const kind = getStringLiteral(attr.args[0]?.value);
if (!kind) {
Expand Down Expand Up @@ -247,7 +246,6 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
// TODO: design a way to let plugin register validation
@check('@allow')
@check('@deny')
// @ts-expect-error
private _checkFieldLevelPolicy(attr: AttributeApplication, accept: ValidationAcceptor) {
const kind = getStringLiteral(attr.args[0]?.value);
if (!kind) {
Expand Down Expand Up @@ -277,7 +275,6 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
}

@check('@@validate')
// @ts-expect-error
private _checkValidate(attr: AttributeApplication, accept: ValidationAcceptor) {
const condition = attr.args[0]?.value;
if (
Expand All @@ -293,7 +290,6 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
@check('@@id')
@check('@@index')
@check('@@unique')
// @ts-expect-error
private _checkConstraint(attr: AttributeApplication, accept: ValidationAcceptor) {
const fields = attr.args[0]?.value;
const attrName = attr.decl.ref?.name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ export default class FunctionInvocationValidator implements AstValidator<Express
}

@func('length')
// @ts-expect-error
private _checkLength(expr: InvocationExpr, accept: ValidationAcceptor) {
const msg = 'argument must be a string or list field';
const fieldArg = expr.args[0]!.value;
Expand All @@ -206,7 +205,6 @@ export default class FunctionInvocationValidator implements AstValidator<Express
}

@func('regex')
// @ts-expect-error
private _checkRegex(expr: InvocationExpr, accept: ValidationAcceptor) {
const regex = expr.args[1]?.value;
if (!isStringLiteral(regex)) {
Expand All @@ -228,7 +226,6 @@ export default class FunctionInvocationValidator implements AstValidator<Express

// TODO: move this to policy plugin
@func('check')
// @ts-expect-error
private _checkCheck(expr: InvocationExpr, accept: ValidationAcceptor) {
let valid = true;

Expand Down
169 changes: 169 additions & 0 deletions packages/language/src/zmodel-code-action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import {
type AstReflection,
type IndexManager,
type LangiumDocument,
type LangiumDocuments,
type MaybePromise,
} from 'langium';
import { DataField, DataModel, Model, isDataModel } from './ast';

import type { CodeActionProvider, LangiumServices } from 'langium/lsp';
import { CodeAction, CodeActionKind, type CodeActionParams, Command, Diagnostic } from 'vscode-languageserver';
import { IssueCodes } from './constants';
import { getAllFields, getDocument } from './utils';
import type { MissingOppositeRelationData } from './validators/datamodel-validator';
import { ZModelFormatter } from './zmodel-formatter';

export class ZModelCodeActionProvider implements CodeActionProvider {
protected readonly reflection: AstReflection;
protected readonly indexManager: IndexManager;
protected readonly formatter: ZModelFormatter;
protected readonly documents: LangiumDocuments;

constructor(services: LangiumServices) {
this.reflection = services.shared.AstReflection;
this.indexManager = services.shared.workspace.IndexManager;
this.formatter = services.lsp.Formatter as ZModelFormatter;
this.documents = services.shared.workspace.LangiumDocuments;
}

getCodeActions(
document: LangiumDocument,
params: CodeActionParams,
): MaybePromise<Array<Command | CodeAction> | undefined> {
const result: CodeAction[] = [];
const acceptor = (ca: CodeAction | undefined) => ca && result.push(ca);
for (const diagnostic of params.context.diagnostics) {
this.createCodeActions(diagnostic, document, acceptor);
}
return result;
}

private createCodeActions(
diagnostic: Diagnostic,
document: LangiumDocument,
accept: (ca: CodeAction | undefined) => void,
) {
switch (diagnostic.code) {
case IssueCodes.MissingOppositeRelation:
accept(this.fixMissingOppositeRelation(diagnostic, document));
}

return undefined;
}

private fixMissingOppositeRelation(diagnostic: Diagnostic, document: LangiumDocument): CodeAction | undefined {
const data = diagnostic.data as MissingOppositeRelationData;

const rootCst =
data.relationFieldDocUri == document.textDocument.uri
? document.parseResult.value
: this.documents.all.find((doc) => doc.textDocument.uri === data.relationFieldDocUri)?.parseResult
.value;

if (rootCst) {
const fieldModel = rootCst as Model;
const fieldAstNode = (
fieldModel.declarations.find(
(x) => isDataModel(x) && x.name === data.relationDataModelName,
) as DataModel
)?.fields.find((x) => x.name === data.relationFieldName) as DataField;

if (!fieldAstNode) return undefined;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const oppositeModel = fieldAstNode.type.reference!.ref! as DataModel;

const currentModel = document.parseResult.value as Model;

const container = currentModel.declarations.find(
(decl) => decl.name === data.dataModelName && isDataModel(decl),
) as DataModel;

if (container && container.$cstNode) {
// indent
let indent = '\t';
const formatOptions = this.formatter.getFormatOptions();
if (formatOptions?.insertSpaces) {
indent = ' '.repeat(formatOptions.tabSize);
}
indent = indent.repeat(this.formatter.getIndent());

let newText = '';
if (fieldAstNode.type.array) {
// post Post[]
const idField = getAllFields(container).find((f) =>
f.attributes.find((attr) => attr.decl.ref?.name === '@id'),
);

// if no id field, we can't generate reference
if (!idField) {
return undefined;
}

const typeName = container.name;
const fieldName = this.lowerCaseFirstLetter(typeName);

// might already exist
let referenceField = '';

const idFieldName = idField.name;
const referenceIdFieldName = fieldName + this.upperCaseFirstLetter(idFieldName);

if (!getAllFields(oppositeModel).find((f) => f.name === referenceIdFieldName)) {
referenceField = '\n' + indent + `${referenceIdFieldName} ${idField.type.type}`;
}

newText =
'\n' +
indent +
`${fieldName} ${typeName} @relation(fields: [${referenceIdFieldName}], references: [${idFieldName}])` +
referenceField +
'\n';
} else {
// user User @relation(fields: [userAbc], references: [id])
const typeName = container.name;
const fieldName = this.lowerCaseFirstLetter(typeName);
newText = '\n' + indent + `${fieldName} ${typeName}[]` + '\n';
}

// the opposite model might be in the imported file
const targetDocument = getDocument(oppositeModel);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const endOffset = oppositeModel.$cstNode!.end - 1;
const position = targetDocument.textDocument.positionAt(endOffset);

return {
title: `Add opposite relation fields on ${oppositeModel.name}`,
kind: CodeActionKind.QuickFix,
diagnostics: [diagnostic],
isPreferred: false,
edit: {
changes: {
[targetDocument.textDocument.uri]: [
{
range: {
start: position,
end: position,
},
newText,
},
],
},
},
};
}
}

return undefined;
}

private lowerCaseFirstLetter(str: string) {
return str.charAt(0).toLowerCase() + str.slice(1);
}

private upperCaseFirstLetter(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ import {
TypeDef,
UnaryExpr,
type AstNode,
} from '@zenstackhq/language/ast';
import { resolved } from './model-utils';
} from './ast';
import { resolved } from './utils';

/**
* Options for the generator.
Expand Down
21 changes: 21 additions & 0 deletions packages/language/src/zmodel-comment-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { DefaultCommentProvider, type AstNode } from 'langium';
import { match } from 'ts-pattern';
import { isDataField, isDataModel, isEnum, isEnumField, isFunctionDecl, isTypeDef } from './ast';

export class ZModelCommentProvider extends DefaultCommentProvider {
override getComment(node: AstNode): string | undefined {
let comment = super.getComment(node);
if (!comment) {
// default comment
comment = match(node)
.when(isDataModel, (d) => `/**\n * Model *${d.name}*\n */`)
.when(isTypeDef, (d) => `/**\n * Type *${d.name}*\n */`)
.when(isEnum, (e) => `/**\n * Enum *${e.name}*\n */`)
.when(isEnumField, (f) => `/**\n * Value of enum *${f.$container?.name}*\n */`)
.when(isDataField, (f) => `/**\n * Field of *${f.$container?.name}*\n */`)
.when(isFunctionDecl, (f) => `/**\n * Function *${f.name}*\n */`)
.otherwise(() => '');
}
return comment;
}
}
Loading