Skip to content

Commit

Permalink
generator-langium: extended yeoman generator extension to offer parsi…
Browse files Browse the repository at this point in the history
…ng, linking, and validation test stubs (#1282)

* providing an additional automated test running 'npm test' and checking its proper termination
  • Loading branch information
sailingKieler committed Dec 21, 2023
1 parent ed5c055 commit 2ca2b3a
Show file tree
Hide file tree
Showing 16 changed files with 444 additions and 76 deletions.
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([]);
});

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')
).toBe(s`
Langium
`);
});
});

function checkDocumentValid(document: LangiumDocument): string | undefined {
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

0 comments on commit 2ca2b3a

Please sign in to comment.