diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 634044c6..ad823edd 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -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, diff --git a/packages/language/package.json b/packages/language/package.json index aadc945d..d0bd1f4c 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -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", diff --git a/packages/language/src/index.ts b/packages/language/src/index.ts index 6edc0494..f11ede5e 100644 --- a/packages/language/src/index.ts +++ b/packages/language/src/index.ts @@ -1,2 +1,3 @@ export { loadDocument } from './document'; export * from './module'; +export { ZModelCodeGenerator } from './zmodel-code-generator'; diff --git a/packages/language/src/module.ts b/packages/language/src/module.ts index df83d4e0..51146949 100644 --- a/packages/language/src/module.ts +++ b/packages/language/src/module.ts @@ -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 }; @@ -49,6 +55,16 @@ export const ZModelLanguageModule: Module 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; @@ -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}`); + } + } } } } diff --git a/packages/language/src/validators/attribute-application-validator.ts b/packages/language/src/validators/attribute-application-validator.ts index c7563bab..80fa1668 100644 --- a/packages/language/src/validators/attribute-application-validator.ts +++ b/packages/language/src/validators/attribute-application-validator.ts @@ -158,7 +158,6 @@ export default class AttributeApplicationValidator implements AstValidator | 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); + } +} diff --git a/packages/sdk/src/zmodel-code-generator.ts b/packages/language/src/zmodel-code-generator.ts similarity index 99% rename from packages/sdk/src/zmodel-code-generator.ts rename to packages/language/src/zmodel-code-generator.ts index 2e010884..2a63969f 100644 --- a/packages/sdk/src/zmodel-code-generator.ts +++ b/packages/language/src/zmodel-code-generator.ts @@ -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. diff --git a/packages/language/src/zmodel-comment-provider.ts b/packages/language/src/zmodel-comment-provider.ts new file mode 100644 index 00000000..9353f225 --- /dev/null +++ b/packages/language/src/zmodel-comment-provider.ts @@ -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; + } +} diff --git a/packages/language/src/zmodel-completion-provider.ts b/packages/language/src/zmodel-completion-provider.ts new file mode 100644 index 00000000..eff2aad4 --- /dev/null +++ b/packages/language/src/zmodel-completion-provider.ts @@ -0,0 +1,397 @@ +import { type AstNode, type AstNodeDescription, type LangiumDocument, type MaybePromise } from 'langium'; +import { + DefaultCompletionProvider, + type CompletionAcceptor, + type CompletionContext, + type CompletionProviderOptions, + type CompletionValueItem, + type LangiumServices, + type NextFeature, +} from 'langium/lsp'; +import fs from 'node:fs'; +import { P, match } from 'ts-pattern'; +import { CompletionItemKind, CompletionList, MarkupContent, type CompletionParams } from 'vscode-languageserver'; +import { + DataFieldAttribute, + DataModelAttribute, + ReferenceExpr, + StringLiteral, + isArrayExpr, + isAttribute, + isDataField, + isDataFieldAttribute, + isDataModel, + isDataModelAttribute, + isEnum, + isEnumField, + isFunctionDecl, + isInvocationExpr, + isMemberAccessExpr, + isTypeDef, +} from './ast'; +import { getAttribute, isEnumFieldReference, isFromStdlib } from './utils'; +import { ZModelCodeGenerator } from './zmodel-code-generator'; + +export class ZModelCompletionProvider extends DefaultCompletionProvider { + constructor(private readonly services: LangiumServices) { + super(services); + } + + override readonly completionOptions?: CompletionProviderOptions = { + triggerCharacters: ['@', '(', ',', '.'], + }; + + override async getCompletion( + document: LangiumDocument, + params: CompletionParams, + ): Promise { + try { + return await super.getCompletion(document, params); + } catch (e) { + console.error('Completion error:', (e as Error).message); + return undefined; + } + } + + override completionFor( + context: CompletionContext, + next: NextFeature, + acceptor: CompletionAcceptor, + ): MaybePromise { + if (isDataModelAttribute(context.node) || isDataFieldAttribute(context.node)) { + const completions = this.getCompletionFromHint(context.node); + if (completions) { + completions.forEach((c) => acceptor(context, c)); + return; + } + } + return super.completionFor(context, next, acceptor); + } + + private getCompletionFromHint( + contextNode: DataModelAttribute | DataFieldAttribute, + ): CompletionValueItem[] | undefined { + // get completion based on the hint on the next unfilled parameter + const unfilledParams = this.getUnfilledAttributeParams(contextNode); + const nextParam = unfilledParams[0]; + if (!nextParam) { + return undefined; + } + + const hintAttr = getAttribute(nextParam, '@@@completionHint'); + if (hintAttr) { + const hint = hintAttr.args[0]; + if (hint?.value) { + if (isArrayExpr(hint.value)) { + return hint.value.items.map((item) => { + return { + label: `${(item as StringLiteral).value}`, + kind: CompletionItemKind.Value, + detail: 'Parameter', + sortText: '0', + }; + }); + } + } + } + return undefined; + } + + // TODO: this doesn't work when the file contains parse errors + private getUnfilledAttributeParams(contextNode: DataModelAttribute | DataFieldAttribute) { + try { + const params = contextNode.decl.ref?.params; + if (params) { + const args = contextNode.args; + let unfilledParams = [...params]; + args.forEach((arg) => { + if (arg.name) { + unfilledParams = unfilledParams.filter((p) => p.name !== arg.name); + } else { + unfilledParams.shift(); + } + }); + + return unfilledParams; + } + } catch { + // noop + } + return []; + } + + override completionForCrossReference( + context: CompletionContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + crossRef: any, + acceptor: CompletionAcceptor, + ): MaybePromise { + if (crossRef.property === 'member' && !isMemberAccessExpr(context.node)) { + // for guarding an error in the base implementation + return; + } + + const customAcceptor = (context: CompletionContext, item: CompletionValueItem) => { + // attributes starting with @@@ are for internal use only + if (item.insertText?.startsWith('@@@') || item.label?.startsWith('@@@')) { + return; + } + + if ('nodeDescription' in item) { + const node = this.getAstNode(item.nodeDescription); + if (!node) { + return; + } + + // enums in stdlib are not supposed to be referenced directly + if ((isEnum(node) || isEnumField(node)) && isFromStdlib(node)) { + return; + } + + if ( + (isDataModelAttribute(context.node) || isDataFieldAttribute(context.node)) && + !this.filterAttributeApplicationCompletion(context.node, node) + ) { + // node not matching attribute context + return; + } + } + acceptor(context, item); + }; + + return super.completionForCrossReference(context, crossRef, customAcceptor); + } + + override completionForKeyword( + context: CompletionContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + keyword: any, + acceptor: CompletionAcceptor, + ): MaybePromise { + const customAcceptor = (context: CompletionContext, item: CompletionValueItem) => { + if (!this.filterKeywordForContext(context, keyword.value)) { + return; + } + acceptor(context, item); + }; + return super.completionForKeyword(context, keyword, customAcceptor); + } + + private filterKeywordForContext(context: CompletionContext, keyword: string) { + if (isInvocationExpr(context.node)) { + return ['true', 'false', 'null', 'this'].includes(keyword); + } else if (isDataModelAttribute(context.node) || isDataFieldAttribute(context.node)) { + const exprContext = this.getAttributeContextType(context.node); + if (exprContext === 'DefaultValue') { + return ['true', 'false', 'null'].includes(keyword); + } else { + return ['true', 'false', 'null', 'this'].includes(keyword); + } + } else { + return true; + } + } + + private filterAttributeApplicationCompletion(contextNode: DataModelAttribute | DataFieldAttribute, node: AstNode) { + const attrContextType = this.getAttributeContextType(contextNode); + + if (isFunctionDecl(node) && attrContextType) { + // functions are excluded if they are not allowed in the current context + const funcExprContextAttr = getAttribute(node, '@@@expressionContext'); + if (funcExprContextAttr && funcExprContextAttr.args[0]) { + const arg = funcExprContextAttr.args[0]; + if (isArrayExpr(arg.value)) { + return arg.value.items.some( + (item) => + isEnumFieldReference(item) && (item as ReferenceExpr).target.$refText === attrContextType, + ); + } + } + return false; + } + + if (isDataField(node)) { + // model fields are not allowed in @default + return attrContextType !== 'DefaultValue'; + } + + return true; + } + + private getAttributeContextType(node: DataModelAttribute | DataFieldAttribute) { + return match(node.decl.$refText) + .with('@default', () => 'DefaultValue') + .with(P.union('@@allow', '@allow', '@@deny', '@deny'), () => 'AccessPolicy') + .with('@@validate', () => 'ValidationRule') + .otherwise(() => undefined); + } + + override createReferenceCompletionItem(nodeDescription: AstNodeDescription): CompletionValueItem { + const node = this.getAstNode(nodeDescription); + const documentation = this.getNodeDocumentation(node); + + return match(node) + .when(isDataModel, () => ({ + nodeDescription, + kind: CompletionItemKind.Class, + detail: 'Model', + sortText: '1', + documentation, + })) + .when(isTypeDef, () => ({ + nodeDescription, + kind: CompletionItemKind.Class, + detail: 'Type', + sortText: '1', + documentation, + })) + .when(isDataField, () => ({ + nodeDescription, + kind: CompletionItemKind.Field, + detail: 'Field', + sortText: '0', + documentation, + })) + .when(isEnum, () => ({ + nodeDescription, + kind: CompletionItemKind.Class, + detail: 'Enum', + sortText: '1', + documentation, + })) + .when(isEnumField, (d) => { + const container = d.$container; + return { + nodeDescription, + kind: CompletionItemKind.Enum, + detail: `Value of enum "${container.name}"`, + sortText: '1', + documentation, + }; + }) + .when(isFunctionDecl, () => ({ + nodeDescription, + insertText: this.getFunctionInsertText(nodeDescription), + kind: CompletionItemKind.Function, + detail: 'Function', + sortText: '1', + documentation, + })) + .when(isAttribute, () => ({ + nodeDescription, + insertText: this.getAttributeInsertText(nodeDescription), + kind: CompletionItemKind.Property, + detail: 'Attribute', + sortText: '1', + documentation, + })) + .otherwise(() => ({ + nodeDescription, + kind: CompletionItemKind.Reference, + detail: nodeDescription.type, + sortText: '2', + documentation, + })); + } + + private getFunctionInsertText(nodeDescription: AstNodeDescription): string { + const node = this.getAstNode(nodeDescription); + if (isFunctionDecl(node)) { + if (node.params.some((p) => !p.optional)) { + return nodeDescription.name; + } + } + return `${nodeDescription.name}()`; + } + + private getAttributeInsertText(nodeDescription: AstNodeDescription): string { + const node = this.getAstNode(nodeDescription); + if (isAttribute(node)) { + if (node.name === '@relation') { + return `${nodeDescription.name}(fields: [], references: [])`; + } + } + return nodeDescription.name; + } + + private getAstNode(nodeDescription: AstNodeDescription) { + let node = nodeDescription.node; + if (!node) { + const doc = this.getOrCreateDocumentSync(nodeDescription); + if (!doc) { + return undefined; + } + node = this.services.workspace.AstNodeLocator.getAstNode(doc.parseResult.value, nodeDescription.path); + if (!node) { + return undefined; + } + } + return node; + } + + private getOrCreateDocumentSync(nodeDescription: AstNodeDescription) { + let doc = this.services.shared.workspace.LangiumDocuments.getDocument(nodeDescription.documentUri); + if (!doc) { + try { + const content = fs.readFileSync(nodeDescription.documentUri.fsPath, 'utf-8'); + doc = this.services.shared.workspace.LangiumDocuments.createDocument( + nodeDescription.documentUri, + content, + ); + } catch { + console.warn('Failed to read or create document:', nodeDescription.documentUri); + return undefined; + } + } + return doc; + } + + private getNodeDocumentation(node?: AstNode): MarkupContent | undefined { + if (!node) { + return undefined; + } + const md = this.commentsToMarkdown(node); + return { + kind: 'markdown', + value: md, + }; + } + + private commentsToMarkdown(node: AstNode): string { + const md = this.services.documentation.DocumentationProvider.getDocumentation(node) ?? ''; + const zModelGenerator = new ZModelCodeGenerator(); + const docs: string[] = []; + + try { + match(node) + .when(isAttribute, (attr) => { + docs.push('```prisma', zModelGenerator.generate(attr), '```'); + }) + .when(isFunctionDecl, (func) => { + docs.push('```ts', zModelGenerator.generate(func), '```'); + }) + .when(isDataModel, (model) => { + docs.push('```prisma', `model ${model.name} { ... }`, '```'); + }) + .when(isEnum, (enumDecl) => { + docs.push('```prisma', zModelGenerator.generate(enumDecl), '```'); + }) + .when(isDataField, (field) => { + docs.push(`${field.name}: ${field.type.type ?? field.type.reference?.$refText}`); + }) + .otherwise((ast) => { + const name = (ast as any).name; + if (name) { + docs.push(name); + } + }); + } catch { + // noop + } + + if (md) { + docs.push('___', md); + } + return docs.join('\n'); + } +} diff --git a/packages/language/src/zmodel-definition.ts b/packages/language/src/zmodel-definition.ts new file mode 100644 index 00000000..f1e8984e --- /dev/null +++ b/packages/language/src/zmodel-definition.ts @@ -0,0 +1,37 @@ +import type { LangiumDocuments, LeafCstNode, MaybePromise } from 'langium'; +import { DefaultDefinitionProvider, type LangiumServices } from 'langium/lsp'; +import { type DefinitionParams, LocationLink, Range } from 'vscode-languageserver'; +import { isModelImport } from './ast'; +import { resolveImport } from './utils'; + +export class ZModelDefinitionProvider extends DefaultDefinitionProvider { + protected documents: LangiumDocuments; + + constructor(services: LangiumServices) { + super(services); + this.documents = services.shared.workspace.LangiumDocuments; + } + protected override collectLocationLinks( + sourceCstNode: LeafCstNode, + _params: DefinitionParams, + ): MaybePromise { + if (isModelImport(sourceCstNode.astNode)) { + const importedModel = resolveImport(this.documents, sourceCstNode.astNode); + if (importedModel?.$document) { + const targetObject = importedModel; + const selectionRange = this.nameProvider.getNameNode(targetObject)?.range ?? Range.create(0, 0, 0, 0); + const previewRange = targetObject.$cstNode?.range ?? Range.create(0, 0, 0, 0); + return [ + LocationLink.create( + importedModel.$document.uri.toString(), + previewRange, + selectionRange, + sourceCstNode.range, + ), + ]; + } + return undefined; + } + return super.collectLocationLinks(sourceCstNode, _params); + } +} diff --git a/packages/language/src/zmodel-documentation-provider.ts b/packages/language/src/zmodel-documentation-provider.ts new file mode 100644 index 00000000..5b21213f --- /dev/null +++ b/packages/language/src/zmodel-documentation-provider.ts @@ -0,0 +1,15 @@ +import { type AstNode, JSDocDocumentationProvider } from 'langium'; + +/** + * Documentation provider that first tries to use triple-slash comments and falls back to JSDoc comments. + */ +export class ZModelDocumentationProvider extends JSDocDocumentationProvider { + override getDocumentation(node: AstNode): string | undefined { + // prefer to use triple-slash comments + if ('comments' in node && Array.isArray(node.comments) && node.comments.length > 0) { + return node.comments.map((c: string) => c.replace(/^[/]*\s*/, '')).join('\n'); + } + + return super.getDocumentation(node); + } +} diff --git a/packages/language/src/zmodel-formatter.ts b/packages/language/src/zmodel-formatter.ts new file mode 100644 index 00000000..efcd7806 --- /dev/null +++ b/packages/language/src/zmodel-formatter.ts @@ -0,0 +1,114 @@ +import { type AstNode, type ConfigurationProvider, type LangiumDocument, type MaybePromise } from 'langium'; +import { AbstractFormatter, Formatting, type LangiumServices } from 'langium/lsp'; +import type { DocumentFormattingParams, FormattingOptions, TextEdit } from 'vscode-languageserver'; +import * as ast from './ast'; +import { ZModelLanguageMetaData } from './generated/module'; + +export class ZModelFormatter extends AbstractFormatter { + private formatOptions?: FormattingOptions; + + protected readonly configurationProvider: ConfigurationProvider; + + constructor(services: LangiumServices) { + super(); + this.configurationProvider = services.shared.workspace.ConfigurationProvider; + } + + protected format(node: AstNode): void { + const formatter = this.getNodeFormatter(node); + + if (ast.isDataField(node)) { + if (ast.isDataModel(node.$container) || ast.isTypeDef(node.$container)) { + const dataModel = node.$container; + + const compareFn = (a: number, b: number) => b - a; + const maxNameLength = dataModel.fields.map((x) => x.name.length).sort(compareFn)[0] ?? 0; + const maxTypeLength = dataModel.fields.map(this.getFieldTypeLength).sort(compareFn)[0] ?? 0; + + formatter.property('type').prepend(Formatting.spaces(maxNameLength - node.name.length + 1)); + if (node.attributes.length > 0) { + formatter + .node(node.attributes[0]!) + .prepend(Formatting.spaces(maxTypeLength - this.getFieldTypeLength(node) + 1)); + + formatter.nodes(...node.attributes.slice(1)).prepend(Formatting.oneSpace()); + } + } else { + formatter.property('type').prepend(Formatting.oneSpace()); + if (node.attributes.length > 0) { + formatter.properties('attributes').prepend(Formatting.oneSpace()); + } + } + } else if (ast.isDataFieldAttribute(node)) { + formatter.keyword('(').surround(Formatting.noSpace()); + formatter.keyword(')').prepend(Formatting.noSpace()); + formatter.keyword(',').append(Formatting.oneSpace()); + if (node.args.length > 1) { + formatter.nodes(...node.args.slice(1)).prepend(Formatting.oneSpace()); + } + } else if (ast.isAttributeArg(node)) { + formatter.keyword(':').prepend(Formatting.noSpace()); + formatter.keyword(':').append(Formatting.oneSpace()); + } else if (ast.isAbstractDeclaration(node)) { + const bracesOpen = formatter.keyword('{'); + const bracesClose = formatter.keyword('}'); + // allow extra blank lines between declarations + formatter.interior(bracesOpen, bracesClose).prepend(Formatting.indent({ allowMore: true })); + bracesOpen.prepend(Formatting.oneSpace()); + bracesClose.prepend(Formatting.newLine()); + } else if (ast.isModel(node)) { + const model = node as ast.Model; + const nodes = formatter.nodes(...model.declarations); + nodes.prepend(Formatting.noIndent()); + } + } + + override formatDocument( + document: LangiumDocument, + params: DocumentFormattingParams, + ): MaybePromise { + this.formatOptions = params.options; + + this.configurationProvider.getConfiguration(ZModelLanguageMetaData.languageId, 'format').then((config) => { + if (config) { + // configuration for future use + } + }); + + return super.formatDocument(document, params); + } + + public getFormatOptions(): FormattingOptions | undefined { + return this.formatOptions; + } + + public getIndent() { + return 1; + } + + private getFieldTypeLength(field: ast.DataField) { + let length: number; + + if (field.type.type) { + length = field.type.type.length; + } else if (field.type.reference) { + length = field.type.reference.$refText.length; + } else if (ast.isDataField(field) && field.type.unsupported) { + const name = `Unsupported("${field.type.unsupported.value.value}")`; + length = name.length; + } else { + // we shouldn't get here + length = 1; + } + + if (field.type.optional) { + length += 1; + } + + if (field.type.array) { + length += 2; + } + + return length; + } +} diff --git a/packages/language/src/zmodel-semantic.ts b/packages/language/src/zmodel-semantic.ts new file mode 100644 index 00000000..7910c78a --- /dev/null +++ b/packages/language/src/zmodel-semantic.ts @@ -0,0 +1,111 @@ +import { AbstractSemanticTokenProvider, type SemanticTokenAcceptor } from 'langium/lsp'; +import { SemanticTokenTypes } from 'vscode-languageserver'; +import { + isAttribute, + isAttributeArg, + isConfigField, + isDataField, + isDataFieldAttribute, + isDataFieldType, + isDataModel, + isDataModelAttribute, + isDataSource, + isEnum, + isEnumField, + isFunctionDecl, + isGeneratorDecl, + isInternalAttribute, + isInvocationExpr, + isMemberAccessExpr, + isPlugin, + isPluginField, + isReferenceExpr, + isTypeDef, + type AstNode, +} from './ast'; + +export class ZModelSemanticTokenProvider extends AbstractSemanticTokenProvider { + protected highlightElement(node: AstNode, acceptor: SemanticTokenAcceptor): void { + if (isDataModel(node)) { + acceptor({ + node, + property: 'name', + type: SemanticTokenTypes.type, + }); + + acceptor({ + node, + property: 'mixins', + type: SemanticTokenTypes.type, + }); + + acceptor({ + node, + property: 'baseModel', + type: SemanticTokenTypes.type, + }); + } else if (isDataSource(node) || isGeneratorDecl(node) || isPlugin(node) || isEnum(node) || isTypeDef(node)) { + acceptor({ + node, + property: 'name', + type: SemanticTokenTypes.type, + }); + } else if ( + isDataField(node) || + isConfigField(node) || + isAttributeArg(node) || + isPluginField(node) || + isEnumField(node) + ) { + acceptor({ + node, + property: 'name', + type: SemanticTokenTypes.variable, + }); + } else if (isDataFieldType(node)) { + if (node.type) { + acceptor({ + node, + property: 'type', + type: SemanticTokenTypes.type, + }); + } else { + acceptor({ + node, + property: 'reference', + type: SemanticTokenTypes.macro, + }); + } + } else if (isDataModelAttribute(node) || isDataFieldAttribute(node) || isInternalAttribute(node)) { + acceptor({ + node, + property: 'decl', + type: SemanticTokenTypes.function, + }); + } else if (isInvocationExpr(node)) { + acceptor({ + node, + property: 'function', + type: SemanticTokenTypes.function, + }); + } else if (isFunctionDecl(node) || isAttribute(node)) { + acceptor({ + node, + property: 'name', + type: SemanticTokenTypes.function, + }); + } else if (isReferenceExpr(node)) { + acceptor({ + node, + property: 'target', + type: SemanticTokenTypes.variable, + }); + } else if (isMemberAccessExpr(node)) { + acceptor({ + node, + property: 'member', + type: SemanticTokenTypes.property, + }); + } + } +} diff --git a/packages/language/tsconfig.json b/packages/language/tsconfig.json index 8ef64682..accdce5e 100644 --- a/packages/language/tsconfig.json +++ b/packages/language/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "@zenstackhq/typescript-config/base.json", - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "compilerOptions": { + "noUnusedLocals": false + } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 649a7201..b213ff89 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -2,5 +2,4 @@ import * as ModelUtils from './model-utils'; export * from './cli-plugin'; export { PrismaSchemaGenerator } from './prisma/prisma-schema-generator'; export * from './ts-schema-generator'; -export * from './zmodel-code-generator'; export { ModelUtils }; diff --git a/packages/sdk/src/prisma/prisma-schema-generator.ts b/packages/sdk/src/prisma/prisma-schema-generator.ts index 179ee480..3f3ba823 100644 --- a/packages/sdk/src/prisma/prisma-schema-generator.ts +++ b/packages/sdk/src/prisma/prisma-schema-generator.ts @@ -1,4 +1,5 @@ import { lowerCaseFirst } from '@zenstackhq/common-helpers'; +import { ZModelCodeGenerator } from '@zenstackhq/language'; import { AttributeArg, BooleanLiteral, @@ -33,7 +34,7 @@ import { import { getAllAttributes, getAllFields, isAuthInvocation, isDelegateModel } from '@zenstackhq/language/utils'; import { AstUtils } from 'langium'; import { match } from 'ts-pattern'; -import { ModelUtils, ZModelCodeGenerator } from '..'; +import { ModelUtils } from '..'; import { DELEGATE_AUX_RELATION_PREFIX, getIdFields } from '../model-utils'; import { AttributeArgValue, diff --git a/packages/server/src/api/rpc/index.ts b/packages/server/src/api/rpc/index.ts index 715a455d..586fea33 100644 --- a/packages/server/src/api/rpc/index.ts +++ b/packages/server/src/api/rpc/index.ts @@ -191,7 +191,7 @@ export class RPCApiHandler implements ApiHandler { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cea73983..b36a2e6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -345,6 +345,9 @@ importers: ts-pattern: specifier: 'catalog:' version: 5.7.1 + vscode-languageserver: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@types/pluralize': specifier: ^0.0.33