Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

generator-langium: extended yeoman generator extension to offer parsing, linking, and validation test stubs (#1282) #1298

Merged
merged 1 commit into from
Dec 21, 2023
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
15 changes: 15 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@
"${workspaceFolder}/{packages,examples}/*/{lib,out}/**/*.js"
]
},
{
"name": "Run Yeoman Generator",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}/examples",
"runtimeExecutable": "npx",
"runtimeArgs": [
"yo",
"langium"
],
"console": "integratedTerminal",
"skipFiles": [
"<node_internals>/**"
],
},
{
"name": "Bootstrap",
"type": "node",
Expand Down
97 changes: 61 additions & 36 deletions packages/generator-langium/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
******************************************************************************/

import Generator from 'yeoman-generator';
import type { CopyOptions } from 'mem-fs-editor';
import _ from 'lodash';
import chalk from 'chalk';
import * as path from 'node:path';
Expand All @@ -18,26 +19,33 @@ const TEMPLATE_CORE_DIR = '../templates/core';
const TEMPLATE_VSCODE_DIR = '../templates/vscode';
const TEMPLATE_CLI_DIR = '../templates/cli';
const TEMPLATE_WEB_DIR = '../templates/web';
const TEMPLATE_TEST_DIR = '../templates/test';
const USER_DIR = '.';

const EXTENSION_NAME = /<%= extension-name %>/g;
const RAW_LANGUAGE_NAME = /<%= RawLanguageName %>/g;
const FILE_EXTENSION = /"?<%= file-extension %>"?/g;
const FILE_EXTENSION_GLOB = /<%= file-glob-extension %>/g;
const TSCONFIG_BASE_NAME = /<%= tsconfig %>/g;

const LANGUAGE_NAME = /<%= LanguageName %>/g;
const LANGUAGE_ID = /<%= language-id %>/g;
const LANGUAGE_PATH_ID = /language-id/g;

const NEWLINES = /\r?\n/g;

interface Answers {
export interface Answers {
extensionName: string;
rawLanguageName: string;
fileExtensions: string;
includeVSCode: boolean;
includeCLI: boolean;
includeWeb: boolean;
includeTest: boolean;
}

export interface PostAnwers {
openWith: 'code' | false
}

function printLogo(log: (message: string) => void): void {
Expand All @@ -53,7 +61,7 @@ function description(...d: string[]): string {
return chalk.reset(chalk.dim(d.join(' ') + '\n')) + chalk.green('?');
}

class LangiumGenerator extends Generator {
export class LangiumGenerator extends Generator {
private answers: Answers;

constructor(args: string | string[], options: Record<string, unknown>) {
Expand All @@ -62,7 +70,7 @@ class LangiumGenerator extends Generator {

async prompting(): Promise<void> {
printLogo(this.log);
this.answers = await this.prompt([
this.answers = await this.prompt<Answers>([
{
type: 'input',
name: 'extensionName',
Expand Down Expand Up @@ -129,6 +137,15 @@ class LangiumGenerator extends Generator {
),
message: 'Include Web worker?',
default: 'yes'
},
{
type: 'confirm',
name: 'includeTest',
prefix: description(
'You can add the setup for language tests using Vitest.'
),
message: 'Include language tests?',
default: 'yes'
}
]);
}
Expand All @@ -154,6 +171,12 @@ class LangiumGenerator extends Generator {
);
const languageId = _.kebabCase(this.answers.rawLanguageName);

const referencedTsconfigBaseName = this.answers.includeTest ? 'tsconfig.src.json' : 'tsconfig.json';
const templateCopyOptions: CopyOptions = {
process: content => this._replaceTemplateWords(fileExtensionGlob, languageName, languageId, referencedTsconfigBaseName, content),
processDestinationPath: path => this._replaceTemplateNames(languageId, path)
};

this.sourceRoot(path.join(__dirname, TEMPLATE_CORE_DIR));
const pkgJson = this.fs.readJSON(path.join(this.sourceRoot(), '.package.json'));
this.fs.extendJSON(this._extensionPath('package-template.json'), pkgJson, undefined, 4);
Expand All @@ -162,12 +185,7 @@ class LangiumGenerator extends Generator {
this.fs.copy(
this.templatePath(path),
this._extensionPath(path),
{
process: content =>
this._replaceTemplateWords(fileExtensionGlob, languageName, languageId, content),
processDestinationPath: path =>
this._replaceTemplateNames(languageId, path),
}
templateCopyOptions
);
}

Expand All @@ -183,10 +201,7 @@ class LangiumGenerator extends Generator {
this.fs.copy(
this.templatePath(path),
this._extensionPath(path),
{
process: content => this._replaceTemplateWords(fileExtensionGlob, languageName, languageId, content),
processDestinationPath: path => this._replaceTemplateNames(languageId, path)
}
templateCopyOptions
);
}
}
Expand All @@ -199,10 +214,7 @@ class LangiumGenerator extends Generator {
this.fs.copy(
this.templatePath(path),
this._extensionPath(path),
{
process: content => this._replaceTemplateWords(fileExtensionGlob, languageName, languageId, content),
processDestinationPath: path => this._replaceTemplateNames(languageId, path)
}
templateCopyOptions
);
}
}
Expand All @@ -216,25 +228,37 @@ class LangiumGenerator extends Generator {
this.fs.copy(
this.templatePath(path),
this._extensionPath(path),
{
process: content =>
this._replaceTemplateWords(fileExtensionGlob, languageName, languageId, content),
processDestinationPath: path =>
this._replaceTemplateNames(languageId, path),
}
templateCopyOptions
);
}
}

if (this.answers.includeTest) {
this.sourceRoot(path.join(__dirname, TEMPLATE_TEST_DIR));

this.fs.copy(
this.templatePath('.'),
this._extensionPath(),
templateCopyOptions
);

// update the scripts section in the package.json to use 'tsconfig.src.json' for building
const pkgJson = this.fs.readJSON(this.templatePath('.package.json'));
this.fs.extendJSON(this._extensionPath('package-template.json'), pkgJson, undefined, 4);

// update the 'includes' property in the existing 'tsconfig.json' and adds '"noEmit": true'
const tsconfigJson = this.fs.readJSON(this.templatePath('.tsconfig.json'));
this.fs.extendJSON(this._extensionPath('tsconfig.json'), tsconfigJson, undefined, 4);

// the initial '.vscode/extensions.json' can't be extended as above, as it contains comments, which is tolerated by vscode,
// but not by `this.fs.extendJSON(...)`, so
this.fs.copy(this.templatePath('.vscode-extensions.json'), this._extensionPath('.vscode/extensions.json'), templateCopyOptions);
}

this.fs.copy(
this._extensionPath('package-template.json'),
this._extensionPath('package.json'),
{
process: content =>
this._replaceTemplateWords(fileExtensionGlob, languageName, languageId, content),
processDestinationPath: path =>
this._replaceTemplateNames(languageId, path),
}
templateCopyOptions
);
this.fs.delete(this._extensionPath('package-template.json'));
}
Expand All @@ -244,23 +268,23 @@ class LangiumGenerator extends Generator {

const opts = { cwd: extensionPath };
if(!this.args.includes('skip-install')) {
this.spawnCommandSync('npm', ['install'], opts);
this.spawnSync('npm', ['install'], opts);
}
this.spawnCommandSync('npm', ['run', 'langium:generate'], opts);
this.spawnSync('npm', ['run', 'langium:generate'], opts);

if (this.answers.includeVSCode || this.answers.includeCLI) {
this.spawnCommandSync('npm', ['run', 'build'], opts);
this.spawnSync('npm', ['run', 'build'], opts);
}

if (this.answers.includeWeb) {
this.spawnCommandSync('npm', ['run', 'build:web'], opts);
this.spawnSync('npm', ['run', 'build:web'], opts);
}
}

async end(): Promise<void> {
const code = await which('code').catch(() => undefined);
if (code) {
const answer = await this.prompt({
const answer = await this.prompt<PostAnwers>({
type: 'list',
name: 'openWith',
message: 'Do you want to open the new folder with Visual Studio Code?',
Expand All @@ -277,7 +301,7 @@ class LangiumGenerator extends Generator {
]
});
if (answer?.openWith) {
this.spawnCommand(answer.openWith, [this._extensionPath()]);
this.spawn(answer.openWith, [this._extensionPath()]);
}
}
}
Expand All @@ -286,14 +310,15 @@ class LangiumGenerator extends Generator {
return this.destinationPath(USER_DIR, this.answers.extensionName, ...path);
}

_replaceTemplateWords(fileExtensionGlob: string, languageName: string, languageId: string, content: string | Buffer): string {
_replaceTemplateWords(fileExtensionGlob: string, languageName: string, languageId: string, tsconfigBaseName: string, content: string | Buffer): string {
return content.toString()
.replace(EXTENSION_NAME, this.answers.extensionName)
.replace(RAW_LANGUAGE_NAME, this.answers.rawLanguageName)
.replace(FILE_EXTENSION, this.answers.fileExtensions)
.replace(FILE_EXTENSION_GLOB, fileExtensionGlob)
.replace(LANGUAGE_NAME, languageName)
.replace(LANGUAGE_ID, languageId)
.replace(TSCONFIG_BASE_NAME, tsconfigBaseName)
.replace(NEWLINES, EOL);
}

Expand Down
6 changes: 4 additions & 2 deletions packages/generator-langium/templates/cli/.package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
{
"engines": {
"node": ">=16.0.0"
"node": ">=18.0.0"
},
"files": [
"bin"
"bin",
"out",
"src"
],
"bin": {
"<%= language-id %>-cli": "./bin/cli.js"
Expand Down
6 changes: 3 additions & 3 deletions packages/generator-langium/templates/core/.package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
],
"type": "module",
"scripts": {
"build": "tsc -b tsconfig.json",
"watch": "tsc -b tsconfig.json --watch",
"build": "tsc -b <%= tsconfig %>",
"watch": "tsc -b <%= tsconfig %> --watch",
"lint": "eslint src --ext ts",
"langium:generate": "langium generate",
"langium:watch": "langium generate --watch"
Expand All @@ -18,7 +18,7 @@
"langium": "~2.1.0"
},
"devDependencies": {
"@types/node": "~16.18.41",
"@types/node": "^18.0.0",
"@typescript-eslint/parser": "~6.4.1",
"@typescript-eslint/eslint-plugin": "~6.4.1",
"eslint": "~8.47.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/generator-langium/templates/test/.package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"devDependencies": {
"vitest": "~1.0.0"
},
"scripts": {
"test": "vitest run"
}
}
11 changes: 11 additions & 0 deletions packages/generator-langium/templates/test/.tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": [
"src/**/*.ts",
"test/**/*.ts"
]
}

11 changes: 11 additions & 0 deletions packages/generator-langium/templates/test/.vscode-extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp

// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"langium.langium-vscode",
"ZixuanChen.vitest-explorer",
"kingwl.vscode-vitest-runner"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, beforeAll, describe, expect, test } from "vitest";
import { EmptyFileSystem, type LangiumDocument } from "langium";
import { expandToString as s } from "langium/generate";
import { clearDocuments, parseHelper } from "langium/test";
import { create<%= LanguageName %>Services } from "../../src/language/<%= language-id %>-module.js";
import { Model, isModel } from "../../src/language/generated/ast.js";

let services: ReturnType<typeof create<%= LanguageName %>Services>;
let parse: ReturnType<typeof parseHelper<Model>>;
let document: LangiumDocument<Model> | undefined;

beforeAll(async () => {
services = create<%= LanguageName %>Services(EmptyFileSystem);
parse = parseHelper<Model>(services.<%= LanguageName %>);

// activate the following if your linking test requires elements from a built-in library, for example
// await services.shared.workspace.WorkspaceManager.initializeWorkspace([]);
});
sailingKieler marked this conversation as resolved.
Show resolved Hide resolved

afterEach(async () => {
document && clearDocuments(services.shared, [ document ]);
});

describe('Linking tests', () => {

test('linking of greetings', async () => {
document = await parse(`
person Langium
Hello Langium!
`);

expect(
// here we first check for validity of the parsed document object by means of the reusable function
// 'checkDocumentValid()' to sort out (critical) typos first,
// and then evaluate the cross references we're interested in by checking
// the referenced AST element as well as for a potential error message;
checkDocumentValid(document)
|| document.parseResult.value.greetings.map(g => g.person.ref?.name || g.person.error?.message).join('\n')
Lotes marked this conversation as resolved.
Show resolved Hide resolved
).toBe(s`
Langium
`);
});
});

function checkDocumentValid(document: LangiumDocument): string | undefined {
Lotes marked this conversation as resolved.
Show resolved Hide resolved
return document.parseResult.parserErrors.length && s`
Parser errors:
${document.parseResult.parserErrors.map(e => e.message).join('\n ')}
`
|| document.parseResult.value === undefined && `ParseResult is 'undefined'.`
|| !isModel(document.parseResult.value) && `Root AST object is a ${document.parseResult.value.$type}, expected a '${Model}'.`
|| undefined;
}
Loading