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
68 changes: 3 additions & 65 deletions packages/cli/src/actions/action-utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { createZModelServices, loadDocument, type ZModelServices } from '@zenstackhq/language';
import { isDataSource, isPlugin, Model } from '@zenstackhq/language/ast';
import { getLiteral } from '@zenstackhq/language/utils';
import { loadDocument } from '@zenstackhq/language';
import { isDataSource } from '@zenstackhq/language/ast';
import { PrismaSchemaGenerator } from '@zenstackhq/sdk';
import colors from 'colors';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CliError } from '../cli-error';
import { PLUGIN_MODULE_NAME } from '../constants';

export function getSchemaFile(file?: string) {
if (file) {
Expand Down Expand Up @@ -37,9 +34,7 @@ export function getSchemaFile(file?: string) {
}

export async function loadSchemaDocument(schemaFile: string) {
const { ZModelLanguage: services } = createZModelServices();
const pluginDocs = await getPluginDocuments(services, schemaFile);
const loadResult = await loadDocument(schemaFile, pluginDocs);
const loadResult = await loadDocument(schemaFile);
if (!loadResult.success) {
loadResult.errors.forEach((err) => {
console.error(colors.red(err));
Expand All @@ -52,63 +47,6 @@ export async function loadSchemaDocument(schemaFile: string) {
return loadResult.model;
}

export async function getPluginDocuments(services: ZModelServices, fileName: string): Promise<string[]> {
// parse the user document (without validation)
const parseResult = services.parser.LangiumParser.parse(fs.readFileSync(fileName, { encoding: 'utf-8' }));
const parsed = parseResult.value as Model;

// balk if there are syntax errors
if (parseResult.lexerErrors.length > 0 || parseResult.parserErrors.length > 0) {
return [];
}

// traverse plugins and collect "plugin.zmodel" documents
const result: string[] = [];
for (const decl of parsed.declarations.filter(isPlugin)) {
const providerField = decl.fields.find((f) => f.name === 'provider');
if (!providerField) {
continue;
}

const provider = getLiteral<string>(providerField.value);
if (!provider) {
continue;
}

let pluginModelFile: string | undefined;

// first try to treat provider as a path
let providerPath = path.resolve(path.dirname(fileName), provider);
if (fs.existsSync(providerPath)) {
if (fs.statSync(providerPath).isDirectory()) {
providerPath = path.join(providerPath, 'index.js');
}

// try plugin.zmodel next to the provider file
pluginModelFile = path.resolve(path.dirname(providerPath), PLUGIN_MODULE_NAME);
if (!fs.existsSync(pluginModelFile)) {
// try to find upwards
pluginModelFile = findUp([PLUGIN_MODULE_NAME], path.dirname(providerPath));
}
}

if (!pluginModelFile) {
// try loading it as a ESM module
try {
const resolvedUrl = import.meta.resolve(`${provider}/${PLUGIN_MODULE_NAME}`);
pluginModelFile = fileURLToPath(resolvedUrl);
} catch {
// noop
}
}

if (pluginModelFile && fs.existsSync(pluginModelFile)) {
result.push(pluginModelFile);
}
}
return result;
}

export function handleSubProcessError(err: unknown) {
if (err instanceof Error && 'status' in err && typeof err.status === 'number') {
process.exit(err.status);
Expand Down
1 change: 1 addition & 0 deletions packages/ide/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"scripts": {
"build": "tsc --noEmit && tsup",
"watch": "tsup --watch",
"lint": "eslint src --ext ts",
"vscode:publish": "pnpm build && vsce publish --no-dependencies --follow-symlinks",
"vscode:package": "pnpm build && vsce package --no-dependencies --follow-symlinks"
Expand Down
11 changes: 7 additions & 4 deletions packages/ide/vscode/src/language-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import { createConnection, ProposedFeatures } from 'vscode-languageserver/node.j
const connection = createConnection(ProposedFeatures.all);

// Inject the shared services and language-specific services
const { shared } = createZModelLanguageServices({
connection,
...NodeFileSystem,
});
const { shared } = createZModelLanguageServices(
{
connection,
...NodeFileSystem,
},
true,
);

// Start the language server with the shared services
startLanguageServer(shared);
202 changes: 202 additions & 0 deletions packages/language/src/document.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { isAstNode, URI, type AstNode, type LangiumDocument, type LangiumDocuments, type Mutable } from 'langium';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isDataSource, type Model } from './ast';
import { STD_LIB_MODULE_NAME } from './constants';
import { createZModelServices } from './module';
import { getDataModelAndTypeDefs, getDocument, hasAttribute, resolveImport, resolveTransitiveImports } from './utils';

/**
* Loads ZModel document from the given file name. Include the additional document
* files if given.
*/
export async function loadDocument(
fileName: string,
additionalModelFiles: string[] = [],
): Promise<
{ success: true; model: Model; warnings: string[] } | { success: false; errors: string[]; warnings: string[] }
> {
const { ZModelLanguage: services } = createZModelServices(false);
const extensions = services.LanguageMetaData.fileExtensions;
if (!extensions.includes(path.extname(fileName))) {
return {
success: false,
errors: ['invalid schema file extension'],
warnings: [],
};
}

if (!fs.existsSync(fileName)) {
return {
success: false,
errors: ['schema file does not exist'],
warnings: [],
};
}

// load standard library

// isomorphic __dirname
const _dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const stdLib = await services.shared.workspace.LangiumDocuments.getOrCreateDocument(
URI.file(path.resolve(path.join(_dirname, '../res', STD_LIB_MODULE_NAME))),
);

// load the document
const langiumDocuments = services.shared.workspace.LangiumDocuments;
const document = await langiumDocuments.getOrCreateDocument(URI.file(path.resolve(fileName)));

// load imports
const importedURIs = await loadImports(document, langiumDocuments);
const importedDocuments: LangiumDocument[] = [];
for (const uri of importedURIs) {
importedDocuments.push(await langiumDocuments.getOrCreateDocument(uri));
}

// build the document together with standard library, additional modules, and imported documents

// load additional model files
const additionalDocs = await Promise.all(
additionalModelFiles.map((file) =>
services.shared.workspace.LangiumDocuments.getOrCreateDocument(URI.file(path.resolve(file))),
),
);

await services.shared.workspace.DocumentBuilder.build([stdLib, ...additionalDocs, document, ...importedDocuments], {
validation: {
stopAfterLexingErrors: true,
stopAfterParsingErrors: true,
stopAfterLinkingErrors: true,
},
});

const diagnostics = langiumDocuments.all
.flatMap((doc) => (doc.diagnostics ?? []).map((diag) => ({ doc, diag })))
.filter(({ diag }) => diag.severity === 1 || diag.severity === 2)
.toArray();

const errors: string[] = [];
const warnings: string[] = [];

if (diagnostics.length > 0) {
for (const { doc, diag } of diagnostics) {
const message = `${path.relative(process.cwd(), doc.uri.fsPath)}:${
diag.range.start.line + 1
}:${diag.range.start.character + 1} - ${diag.message}`;

if (diag.severity === 1) {
errors.push(message);
} else {
warnings.push(message);
}
}
}

if (errors.length > 0) {
return {
success: false,
errors,
warnings,
};
}

const model = document.parseResult.value as Model;

// merge all declarations into the main document
const imported = mergeImportsDeclarations(langiumDocuments, model);

// remove imported documents
imported.forEach((model) => {
langiumDocuments.deleteDocument(model.$document!.uri);
services.shared.workspace.IndexManager.remove(model.$document!.uri);
});

// extra validation after merging imported declarations
const additionalErrors = validationAfterImportMerge(model);
if (additionalErrors.length > 0) {
return {
success: false,
errors: additionalErrors,
warnings,
};
}

return {
success: true,
model: document.parseResult.value as Model,
warnings,
};
}

async function loadImports(document: LangiumDocument, documents: LangiumDocuments, uris: Set<string> = new Set()) {
const uriString = document.uri.toString();
if (!uris.has(uriString)) {
uris.add(uriString);
const model = document.parseResult.value as Model;
for (const imp of model.imports) {
const importedModel = resolveImport(documents, imp);
if (importedModel) {
const importedDoc = getDocument(importedModel);
await loadImports(importedDoc, documents, uris);
}
}
}
return Array.from(uris)
.filter((x) => uriString != x)
.map((e) => URI.parse(e));
}

function mergeImportsDeclarations(documents: LangiumDocuments, model: Model) {
const importedModels = resolveTransitiveImports(documents, model);

const importedDeclarations = importedModels.flatMap((m) => m.declarations);
model.declarations.push(...importedDeclarations);

// remove import directives
model.imports = [];

// fix $container, $containerIndex, and $containerProperty
linkContentToContainer(model);

return importedModels;
}

function linkContentToContainer(node: AstNode): void {
for (const [name, value] of Object.entries(node)) {
if (!name.startsWith('$')) {
if (Array.isArray(value)) {
value.forEach((item, index) => {
if (isAstNode(item)) {
(item as Mutable<AstNode>).$container = node;
(item as Mutable<AstNode>).$containerProperty = name;
(item as Mutable<AstNode>).$containerIndex = index;
}
});
} else if (isAstNode(value)) {
(value as Mutable<AstNode>).$container = node;
(value as Mutable<AstNode>).$containerProperty = name;
}
}
}
}

function validationAfterImportMerge(model: Model) {
const errors: string[] = [];
const dataSources = model.declarations.filter((d) => isDataSource(d));
if (dataSources.length === 0) {
errors.push('Validation error: schema must have a datasource declaration');
} else {
if (dataSources.length > 1) {
errors.push('Validation error: multiple datasource declarations are not allowed');
}
}

// at most one `@@auth` model
const decls = getDataModelAndTypeDefs(model, true);
const authDecls = decls.filter((d) => hasAttribute(d, '@@auth'));
if (authDecls.length > 1) {
errors.push('Validation error: Multiple `@@auth` declarations are not allowed');
}
return errors;
}
Loading