Skip to content

Commit 0d1faf1

Browse files
authored
fix(lsp): get language server on par with v2 (#379)
* fix(lsp): get language server on par with v2 * fix typo * addressing PR comments
1 parent ff0808d commit 0d1faf1

19 files changed

+909
-23
lines changed

packages/ide/vscode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "zenstack-v3",
33
"publisher": "zenstack",
4-
"version": "3.0.11",
4+
"version": "3.0.12",
55
"displayName": "ZenStack V3 Language Tools",
66
"description": "VSCode extension for ZenStack (v3) ZModel language",
77
"private": true,

packages/language/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@
5757
"dependencies": {
5858
"langium": "catalog:",
5959
"pluralize": "^8.0.0",
60-
"ts-pattern": "catalog:"
60+
"ts-pattern": "catalog:",
61+
"vscode-languageserver": "^9.0.1"
6162
},
6263
"devDependencies": {
6364
"@types/pluralize": "^0.0.33",

packages/language/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { loadDocument } from './document';
22
export * from './module';
3+
export { ZModelCodeGenerator } from './zmodel-code-generator';

packages/language/src/module.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,15 @@ import type { Model } from './ast';
1414
import { ZModelGeneratedModule, ZModelGeneratedSharedModule, ZModelLanguageMetaData } from './generated/module';
1515
import { getPluginDocuments } from './utils';
1616
import { registerValidationChecks, ZModelValidator } from './validator';
17+
import { ZModelCommentProvider } from './zmodel-comment-provider';
18+
import { ZModelCompletionProvider } from './zmodel-completion-provider';
19+
import { ZModelDefinitionProvider } from './zmodel-definition';
1720
import { ZModelDocumentBuilder } from './zmodel-document-builder';
21+
import { ZModelDocumentationProvider } from './zmodel-documentation-provider';
22+
import { ZModelFormatter } from './zmodel-formatter';
1823
import { ZModelLinker } from './zmodel-linker';
1924
import { ZModelScopeComputation, ZModelScopeProvider } from './zmodel-scope';
25+
import { ZModelSemanticTokenProvider } from './zmodel-semantic';
2026
import { ZModelWorkspaceManager } from './zmodel-workspace-manager';
2127
export { ZModelLanguageMetaData };
2228

@@ -49,6 +55,16 @@ export const ZModelLanguageModule: Module<ZModelServices, PartialLangiumServices
4955
validation: {
5056
ZModelValidator: (services) => new ZModelValidator(services),
5157
},
58+
lsp: {
59+
Formatter: (services) => new ZModelFormatter(services),
60+
DefinitionProvider: (services) => new ZModelDefinitionProvider(services),
61+
CompletionProvider: (services) => new ZModelCompletionProvider(services),
62+
SemanticTokenProvider: (services) => new ZModelSemanticTokenProvider(services),
63+
},
64+
documentation: {
65+
CommentProvider: (services) => new ZModelCommentProvider(services),
66+
DocumentationProvider: (services) => new ZModelDocumentationProvider(services),
67+
},
5268
};
5369

5470
export type ZModelSharedServices = LangiumSharedServices;
@@ -109,15 +125,20 @@ export function createZModelLanguageServices(
109125

110126
const schemaPath = fileURLToPath(doc.uri.toString());
111127
const pluginSchemas = getPluginDocuments(doc.parseResult.value as Model, schemaPath);
128+
129+
// ensure plugin docs are loaded
112130
for (const plugin of pluginSchemas) {
113-
// load the plugin model document
114-
const pluginDoc = await shared.workspace.LangiumDocuments.getOrCreateDocument(
115-
URI.file(path.resolve(plugin)),
116-
);
117-
// add to indexer so the plugin model's definitions are globally visible
118-
shared.workspace.IndexManager.updateContent(pluginDoc);
119-
if (logToConsole) {
120-
console.log(`Loaded plugin model: ${plugin}`);
131+
const pluginDocUri = URI.file(path.resolve(plugin));
132+
let pluginDoc = shared.workspace.LangiumDocuments.getDocument(pluginDocUri);
133+
if (!pluginDoc) {
134+
pluginDoc = await shared.workspace.LangiumDocuments.getOrCreateDocument(pluginDocUri);
135+
if (pluginDoc) {
136+
// add to indexer so the plugin model's definitions are globally visible
137+
shared.workspace.IndexManager.updateContent(pluginDoc);
138+
if (logToConsole) {
139+
console.log(`Loaded plugin model: ${plugin}`);
140+
}
141+
}
121142
}
122143
}
123144
}

packages/language/src/validators/attribute-application-validator.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,6 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
158158
// TODO: design a way to let plugin register validation
159159
@check('@@allow')
160160
@check('@@deny')
161-
// @ts-expect-error
162161
private _checkModelLevelPolicy(attr: AttributeApplication, accept: ValidationAcceptor) {
163162
const kind = getStringLiteral(attr.args[0]?.value);
164163
if (!kind) {
@@ -247,7 +246,6 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
247246
// TODO: design a way to let plugin register validation
248247
@check('@allow')
249248
@check('@deny')
250-
// @ts-expect-error
251249
private _checkFieldLevelPolicy(attr: AttributeApplication, accept: ValidationAcceptor) {
252250
const kind = getStringLiteral(attr.args[0]?.value);
253251
if (!kind) {
@@ -277,7 +275,6 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
277275
}
278276

279277
@check('@@validate')
280-
// @ts-expect-error
281278
private _checkValidate(attr: AttributeApplication, accept: ValidationAcceptor) {
282279
const condition = attr.args[0]?.value;
283280
if (
@@ -293,7 +290,6 @@ export default class AttributeApplicationValidator implements AstValidator<Attri
293290
@check('@@id')
294291
@check('@@index')
295292
@check('@@unique')
296-
// @ts-expect-error
297293
private _checkConstraint(attr: AttributeApplication, accept: ValidationAcceptor) {
298294
const fields = attr.args[0]?.value;
299295
const attrName = attr.decl.ref?.name;

packages/language/src/validators/function-invocation-validator.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ export default class FunctionInvocationValidator implements AstValidator<Express
180180
}
181181

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

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

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

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import {
2+
type AstReflection,
3+
type IndexManager,
4+
type LangiumDocument,
5+
type LangiumDocuments,
6+
type MaybePromise,
7+
} from 'langium';
8+
import { DataField, DataModel, Model, isDataModel } from './ast';
9+
10+
import type { CodeActionProvider, LangiumServices } from 'langium/lsp';
11+
import { CodeAction, CodeActionKind, type CodeActionParams, Command, Diagnostic } from 'vscode-languageserver';
12+
import { IssueCodes } from './constants';
13+
import { getAllFields, getDocument } from './utils';
14+
import type { MissingOppositeRelationData } from './validators/datamodel-validator';
15+
import { ZModelFormatter } from './zmodel-formatter';
16+
17+
export class ZModelCodeActionProvider implements CodeActionProvider {
18+
protected readonly reflection: AstReflection;
19+
protected readonly indexManager: IndexManager;
20+
protected readonly formatter: ZModelFormatter;
21+
protected readonly documents: LangiumDocuments;
22+
23+
constructor(services: LangiumServices) {
24+
this.reflection = services.shared.AstReflection;
25+
this.indexManager = services.shared.workspace.IndexManager;
26+
this.formatter = services.lsp.Formatter as ZModelFormatter;
27+
this.documents = services.shared.workspace.LangiumDocuments;
28+
}
29+
30+
getCodeActions(
31+
document: LangiumDocument,
32+
params: CodeActionParams,
33+
): MaybePromise<Array<Command | CodeAction> | undefined> {
34+
const result: CodeAction[] = [];
35+
const acceptor = (ca: CodeAction | undefined) => ca && result.push(ca);
36+
for (const diagnostic of params.context.diagnostics) {
37+
this.createCodeActions(diagnostic, document, acceptor);
38+
}
39+
return result;
40+
}
41+
42+
private createCodeActions(
43+
diagnostic: Diagnostic,
44+
document: LangiumDocument,
45+
accept: (ca: CodeAction | undefined) => void,
46+
) {
47+
switch (diagnostic.code) {
48+
case IssueCodes.MissingOppositeRelation:
49+
accept(this.fixMissingOppositeRelation(diagnostic, document));
50+
}
51+
52+
return undefined;
53+
}
54+
55+
private fixMissingOppositeRelation(diagnostic: Diagnostic, document: LangiumDocument): CodeAction | undefined {
56+
const data = diagnostic.data as MissingOppositeRelationData;
57+
58+
const rootCst =
59+
data.relationFieldDocUri == document.textDocument.uri
60+
? document.parseResult.value
61+
: this.documents.all.find((doc) => doc.textDocument.uri === data.relationFieldDocUri)?.parseResult
62+
.value;
63+
64+
if (rootCst) {
65+
const fieldModel = rootCst as Model;
66+
const fieldAstNode = (
67+
fieldModel.declarations.find(
68+
(x) => isDataModel(x) && x.name === data.relationDataModelName,
69+
) as DataModel
70+
)?.fields.find((x) => x.name === data.relationFieldName) as DataField;
71+
72+
if (!fieldAstNode) return undefined;
73+
74+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
75+
const oppositeModel = fieldAstNode.type.reference!.ref! as DataModel;
76+
77+
const currentModel = document.parseResult.value as Model;
78+
79+
const container = currentModel.declarations.find(
80+
(decl) => decl.name === data.dataModelName && isDataModel(decl),
81+
) as DataModel;
82+
83+
if (container && container.$cstNode) {
84+
// indent
85+
let indent = '\t';
86+
const formatOptions = this.formatter.getFormatOptions();
87+
if (formatOptions?.insertSpaces) {
88+
indent = ' '.repeat(formatOptions.tabSize);
89+
}
90+
indent = indent.repeat(this.formatter.getIndent());
91+
92+
let newText = '';
93+
if (fieldAstNode.type.array) {
94+
// post Post[]
95+
const idField = getAllFields(container).find((f) =>
96+
f.attributes.find((attr) => attr.decl.ref?.name === '@id'),
97+
);
98+
99+
// if no id field, we can't generate reference
100+
if (!idField) {
101+
return undefined;
102+
}
103+
104+
const typeName = container.name;
105+
const fieldName = this.lowerCaseFirstLetter(typeName);
106+
107+
// might already exist
108+
let referenceField = '';
109+
110+
const idFieldName = idField.name;
111+
const referenceIdFieldName = fieldName + this.upperCaseFirstLetter(idFieldName);
112+
113+
if (!getAllFields(oppositeModel).find((f) => f.name === referenceIdFieldName)) {
114+
referenceField = '\n' + indent + `${referenceIdFieldName} ${idField.type.type}`;
115+
}
116+
117+
newText =
118+
'\n' +
119+
indent +
120+
`${fieldName} ${typeName} @relation(fields: [${referenceIdFieldName}], references: [${idFieldName}])` +
121+
referenceField +
122+
'\n';
123+
} else {
124+
// user User @relation(fields: [userAbc], references: [id])
125+
const typeName = container.name;
126+
const fieldName = this.lowerCaseFirstLetter(typeName);
127+
newText = '\n' + indent + `${fieldName} ${typeName}[]` + '\n';
128+
}
129+
130+
// the opposite model might be in the imported file
131+
const targetDocument = getDocument(oppositeModel);
132+
133+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
134+
const endOffset = oppositeModel.$cstNode!.end - 1;
135+
const position = targetDocument.textDocument.positionAt(endOffset);
136+
137+
return {
138+
title: `Add opposite relation fields on ${oppositeModel.name}`,
139+
kind: CodeActionKind.QuickFix,
140+
diagnostics: [diagnostic],
141+
isPreferred: false,
142+
edit: {
143+
changes: {
144+
[targetDocument.textDocument.uri]: [
145+
{
146+
range: {
147+
start: position,
148+
end: position,
149+
},
150+
newText,
151+
},
152+
],
153+
},
154+
},
155+
};
156+
}
157+
}
158+
159+
return undefined;
160+
}
161+
162+
private lowerCaseFirstLetter(str: string) {
163+
return str.charAt(0).toLowerCase() + str.slice(1);
164+
}
165+
166+
private upperCaseFirstLetter(str: string) {
167+
return str.charAt(0).toUpperCase() + str.slice(1);
168+
}
169+
}

packages/sdk/src/zmodel-code-generator.ts renamed to packages/language/src/zmodel-code-generator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ import {
4040
TypeDef,
4141
UnaryExpr,
4242
type AstNode,
43-
} from '@zenstackhq/language/ast';
44-
import { resolved } from './model-utils';
43+
} from './ast';
44+
import { resolved } from './utils';
4545

4646
/**
4747
* Options for the generator.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { DefaultCommentProvider, type AstNode } from 'langium';
2+
import { match } from 'ts-pattern';
3+
import { isDataField, isDataModel, isEnum, isEnumField, isFunctionDecl, isTypeDef } from './ast';
4+
5+
export class ZModelCommentProvider extends DefaultCommentProvider {
6+
override getComment(node: AstNode): string | undefined {
7+
let comment = super.getComment(node);
8+
if (!comment) {
9+
// default comment
10+
comment = match(node)
11+
.when(isDataModel, (d) => `/**\n * Model *${d.name}*\n */`)
12+
.when(isTypeDef, (d) => `/**\n * Type *${d.name}*\n */`)
13+
.when(isEnum, (e) => `/**\n * Enum *${e.name}*\n */`)
14+
.when(isEnumField, (f) => `/**\n * Value of enum *${f.$container?.name}*\n */`)
15+
.when(isDataField, (f) => `/**\n * Field of *${f.$container?.name}*\n */`)
16+
.when(isFunctionDecl, (f) => `/**\n * Function *${f.name}*\n */`)
17+
.otherwise(() => '');
18+
}
19+
return comment;
20+
}
21+
}

0 commit comments

Comments
 (0)