Skip to content

Commit 05bf237

Browse files
authored
fix: clean up plugin loading and fix VSCode extension (#312)
* fix: clean up plugin loading and fix VSCode extension * address pr comments
1 parent 6f48c74 commit 05bf237

File tree

9 files changed

+353
-374
lines changed

9 files changed

+353
-374
lines changed

packages/cli/src/actions/action-utils.ts

Lines changed: 3 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import { createZModelServices, loadDocument, type ZModelServices } from '@zenstackhq/language';
2-
import { isDataSource, isPlugin, Model } from '@zenstackhq/language/ast';
3-
import { getLiteral } from '@zenstackhq/language/utils';
1+
import { loadDocument } from '@zenstackhq/language';
2+
import { isDataSource } from '@zenstackhq/language/ast';
43
import { PrismaSchemaGenerator } from '@zenstackhq/sdk';
54
import colors from 'colors';
65
import fs from 'node:fs';
76
import path from 'node:path';
8-
import { fileURLToPath } from 'node:url';
97
import { CliError } from '../cli-error';
10-
import { PLUGIN_MODULE_NAME } from '../constants';
118

129
export function getSchemaFile(file?: string) {
1310
if (file) {
@@ -37,9 +34,7 @@ export function getSchemaFile(file?: string) {
3734
}
3835

3936
export async function loadSchemaDocument(schemaFile: string) {
40-
const { ZModelLanguage: services } = createZModelServices();
41-
const pluginDocs = await getPluginDocuments(services, schemaFile);
42-
const loadResult = await loadDocument(schemaFile, pluginDocs);
37+
const loadResult = await loadDocument(schemaFile);
4338
if (!loadResult.success) {
4439
loadResult.errors.forEach((err) => {
4540
console.error(colors.red(err));
@@ -52,63 +47,6 @@ export async function loadSchemaDocument(schemaFile: string) {
5247
return loadResult.model;
5348
}
5449

55-
export async function getPluginDocuments(services: ZModelServices, fileName: string): Promise<string[]> {
56-
// parse the user document (without validation)
57-
const parseResult = services.parser.LangiumParser.parse(fs.readFileSync(fileName, { encoding: 'utf-8' }));
58-
const parsed = parseResult.value as Model;
59-
60-
// balk if there are syntax errors
61-
if (parseResult.lexerErrors.length > 0 || parseResult.parserErrors.length > 0) {
62-
return [];
63-
}
64-
65-
// traverse plugins and collect "plugin.zmodel" documents
66-
const result: string[] = [];
67-
for (const decl of parsed.declarations.filter(isPlugin)) {
68-
const providerField = decl.fields.find((f) => f.name === 'provider');
69-
if (!providerField) {
70-
continue;
71-
}
72-
73-
const provider = getLiteral<string>(providerField.value);
74-
if (!provider) {
75-
continue;
76-
}
77-
78-
let pluginModelFile: string | undefined;
79-
80-
// first try to treat provider as a path
81-
let providerPath = path.resolve(path.dirname(fileName), provider);
82-
if (fs.existsSync(providerPath)) {
83-
if (fs.statSync(providerPath).isDirectory()) {
84-
providerPath = path.join(providerPath, 'index.js');
85-
}
86-
87-
// try plugin.zmodel next to the provider file
88-
pluginModelFile = path.resolve(path.dirname(providerPath), PLUGIN_MODULE_NAME);
89-
if (!fs.existsSync(pluginModelFile)) {
90-
// try to find upwards
91-
pluginModelFile = findUp([PLUGIN_MODULE_NAME], path.dirname(providerPath));
92-
}
93-
}
94-
95-
if (!pluginModelFile) {
96-
// try loading it as a ESM module
97-
try {
98-
const resolvedUrl = import.meta.resolve(`${provider}/${PLUGIN_MODULE_NAME}`);
99-
pluginModelFile = fileURLToPath(resolvedUrl);
100-
} catch {
101-
// noop
102-
}
103-
}
104-
105-
if (pluginModelFile && fs.existsSync(pluginModelFile)) {
106-
result.push(pluginModelFile);
107-
}
108-
}
109-
return result;
110-
}
111-
11250
export function handleSubProcessError(err: unknown) {
11351
if (err instanceof Error && 'status' in err && typeof err.status === 'number') {
11452
process.exit(err.status);

packages/ide/vscode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
},
1212
"scripts": {
1313
"build": "tsc --noEmit && tsup",
14+
"watch": "tsup --watch",
1415
"lint": "eslint src --ext ts",
1516
"vscode:publish": "pnpm build && vsce publish --no-dependencies --follow-symlinks",
1617
"vscode:package": "pnpm build && vsce package --no-dependencies --follow-symlinks"

packages/ide/vscode/src/language-server/main.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import { createConnection, ProposedFeatures } from 'vscode-languageserver/node.j
77
const connection = createConnection(ProposedFeatures.all);
88

99
// Inject the shared services and language-specific services
10-
const { shared } = createZModelLanguageServices({
11-
connection,
12-
...NodeFileSystem,
13-
});
10+
const { shared } = createZModelLanguageServices(
11+
{
12+
connection,
13+
...NodeFileSystem,
14+
},
15+
true,
16+
);
1417

1518
// Start the language server with the shared services
1619
startLanguageServer(shared);

packages/language/src/document.ts

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { isAstNode, URI, type AstNode, type LangiumDocument, type LangiumDocuments, type Mutable } from 'langium';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import { isDataSource, type Model } from './ast';
6+
import { STD_LIB_MODULE_NAME } from './constants';
7+
import { createZModelServices } from './module';
8+
import { getDataModelAndTypeDefs, getDocument, hasAttribute, resolveImport, resolveTransitiveImports } from './utils';
9+
10+
/**
11+
* Loads ZModel document from the given file name. Include the additional document
12+
* files if given.
13+
*/
14+
export async function loadDocument(
15+
fileName: string,
16+
additionalModelFiles: string[] = [],
17+
): Promise<
18+
{ success: true; model: Model; warnings: string[] } | { success: false; errors: string[]; warnings: string[] }
19+
> {
20+
const { ZModelLanguage: services } = createZModelServices(false);
21+
const extensions = services.LanguageMetaData.fileExtensions;
22+
if (!extensions.includes(path.extname(fileName))) {
23+
return {
24+
success: false,
25+
errors: ['invalid schema file extension'],
26+
warnings: [],
27+
};
28+
}
29+
30+
if (!fs.existsSync(fileName)) {
31+
return {
32+
success: false,
33+
errors: ['schema file does not exist'],
34+
warnings: [],
35+
};
36+
}
37+
38+
// load standard library
39+
40+
// isomorphic __dirname
41+
const _dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
42+
const stdLib = await services.shared.workspace.LangiumDocuments.getOrCreateDocument(
43+
URI.file(path.resolve(path.join(_dirname, '../res', STD_LIB_MODULE_NAME))),
44+
);
45+
46+
// load the document
47+
const langiumDocuments = services.shared.workspace.LangiumDocuments;
48+
const document = await langiumDocuments.getOrCreateDocument(URI.file(path.resolve(fileName)));
49+
50+
// load imports
51+
const importedURIs = await loadImports(document, langiumDocuments);
52+
const importedDocuments: LangiumDocument[] = [];
53+
for (const uri of importedURIs) {
54+
importedDocuments.push(await langiumDocuments.getOrCreateDocument(uri));
55+
}
56+
57+
// build the document together with standard library, additional modules, and imported documents
58+
59+
// load additional model files
60+
const additionalDocs = await Promise.all(
61+
additionalModelFiles.map((file) =>
62+
services.shared.workspace.LangiumDocuments.getOrCreateDocument(URI.file(path.resolve(file))),
63+
),
64+
);
65+
66+
await services.shared.workspace.DocumentBuilder.build([stdLib, ...additionalDocs, document, ...importedDocuments], {
67+
validation: {
68+
stopAfterLexingErrors: true,
69+
stopAfterParsingErrors: true,
70+
stopAfterLinkingErrors: true,
71+
},
72+
});
73+
74+
const diagnostics = langiumDocuments.all
75+
.flatMap((doc) => (doc.diagnostics ?? []).map((diag) => ({ doc, diag })))
76+
.filter(({ diag }) => diag.severity === 1 || diag.severity === 2)
77+
.toArray();
78+
79+
const errors: string[] = [];
80+
const warnings: string[] = [];
81+
82+
if (diagnostics.length > 0) {
83+
for (const { doc, diag } of diagnostics) {
84+
const message = `${path.relative(process.cwd(), doc.uri.fsPath)}:${
85+
diag.range.start.line + 1
86+
}:${diag.range.start.character + 1} - ${diag.message}`;
87+
88+
if (diag.severity === 1) {
89+
errors.push(message);
90+
} else {
91+
warnings.push(message);
92+
}
93+
}
94+
}
95+
96+
if (errors.length > 0) {
97+
return {
98+
success: false,
99+
errors,
100+
warnings,
101+
};
102+
}
103+
104+
const model = document.parseResult.value as Model;
105+
106+
// merge all declarations into the main document
107+
const imported = mergeImportsDeclarations(langiumDocuments, model);
108+
109+
// remove imported documents
110+
imported.forEach((model) => {
111+
langiumDocuments.deleteDocument(model.$document!.uri);
112+
services.shared.workspace.IndexManager.remove(model.$document!.uri);
113+
});
114+
115+
// extra validation after merging imported declarations
116+
const additionalErrors = validationAfterImportMerge(model);
117+
if (additionalErrors.length > 0) {
118+
return {
119+
success: false,
120+
errors: additionalErrors,
121+
warnings,
122+
};
123+
}
124+
125+
return {
126+
success: true,
127+
model: document.parseResult.value as Model,
128+
warnings,
129+
};
130+
}
131+
132+
async function loadImports(document: LangiumDocument, documents: LangiumDocuments, uris: Set<string> = new Set()) {
133+
const uriString = document.uri.toString();
134+
if (!uris.has(uriString)) {
135+
uris.add(uriString);
136+
const model = document.parseResult.value as Model;
137+
for (const imp of model.imports) {
138+
const importedModel = resolveImport(documents, imp);
139+
if (importedModel) {
140+
const importedDoc = getDocument(importedModel);
141+
await loadImports(importedDoc, documents, uris);
142+
}
143+
}
144+
}
145+
return Array.from(uris)
146+
.filter((x) => uriString != x)
147+
.map((e) => URI.parse(e));
148+
}
149+
150+
function mergeImportsDeclarations(documents: LangiumDocuments, model: Model) {
151+
const importedModels = resolveTransitiveImports(documents, model);
152+
153+
const importedDeclarations = importedModels.flatMap((m) => m.declarations);
154+
model.declarations.push(...importedDeclarations);
155+
156+
// remove import directives
157+
model.imports = [];
158+
159+
// fix $container, $containerIndex, and $containerProperty
160+
linkContentToContainer(model);
161+
162+
return importedModels;
163+
}
164+
165+
function linkContentToContainer(node: AstNode): void {
166+
for (const [name, value] of Object.entries(node)) {
167+
if (!name.startsWith('$')) {
168+
if (Array.isArray(value)) {
169+
value.forEach((item, index) => {
170+
if (isAstNode(item)) {
171+
(item as Mutable<AstNode>).$container = node;
172+
(item as Mutable<AstNode>).$containerProperty = name;
173+
(item as Mutable<AstNode>).$containerIndex = index;
174+
}
175+
});
176+
} else if (isAstNode(value)) {
177+
(value as Mutable<AstNode>).$container = node;
178+
(value as Mutable<AstNode>).$containerProperty = name;
179+
}
180+
}
181+
}
182+
}
183+
184+
function validationAfterImportMerge(model: Model) {
185+
const errors: string[] = [];
186+
const dataSources = model.declarations.filter((d) => isDataSource(d));
187+
if (dataSources.length === 0) {
188+
errors.push('Validation error: schema must have a datasource declaration');
189+
} else {
190+
if (dataSources.length > 1) {
191+
errors.push('Validation error: multiple datasource declarations are not allowed');
192+
}
193+
}
194+
195+
// at most one `@@auth` model
196+
const decls = getDataModelAndTypeDefs(model, true);
197+
const authDecls = decls.filter((d) => hasAttribute(d, '@@auth'));
198+
if (authDecls.length > 1) {
199+
errors.push('Validation error: Multiple `@@auth` declarations are not allowed');
200+
}
201+
return errors;
202+
}

0 commit comments

Comments
 (0)