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 Nov 23, 2023
1 parent 4a963b5 commit 7b0a010
Show file tree
Hide file tree
Showing 12 changed files with 407 additions and 67 deletions.
92 changes: 57 additions & 35 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,6 +19,7 @@ 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;
Expand All @@ -31,13 +33,18 @@ 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 +60,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 +69,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 +136,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 +170,11 @@ class LangiumGenerator extends Generator {
);
const languageId = _.kebabCase(this.answers.rawLanguageName);

const templateCopyOptions: CopyOptions = {
process: content => this._replaceTemplateWords(fileExtensionGlob, languageName, languageId, 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 +183,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 +199,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 +212,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 +226,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 +266,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 +299,7 @@ class LangiumGenerator extends Generator {
]
});
if (answer?.openWith) {
this.spawnCommand(answer.openWith, [this._extensionPath()]);
this.spawn(answer.openWith, [this._extensionPath()]);
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions packages/generator-langium/templates/test/.package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"devDependencies": {
"vitest": "0.34"
},
"scripts": {
"build": "tsc -b tsconfig.src.json",
"watch": "tsc -b tsconfig.src.json --watch",
"test": "vitest run"
}
}
10 changes: 10 additions & 0 deletions packages/generator-langium/templates/test/.tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"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 { 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 && await services.shared.workspace.DocumentBuilder.update([], [ document?.uri ]);
});

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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { beforeAll, describe, expect, test } from "vitest";
import { EmptyFileSystem, type LangiumDocument } from "langium";
import { expandToString as s} from "langium/generate";
import { 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([]);
});

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

test('parse simple model', async () => {
document = await parse(`
person Langium
Hello Langium!
`);

// check for absensce of parser errors the classic way:
// deacivated, find a much more human readable way below!
// expect(document.parseResult.parserErrors).toHaveLength(0);

expect(
// here we use a (tagged) template expression to create a human readable representation
// of the AST part we are interested in and that is to be compared to our expectation;
// prior to the tagged template expression we check for validity of the parsed document object
// by means of the reusable function 'checkDocumentValid()' to sort out (critical) typos first;
checkDocumentValid(document) || s`
Persons:
${document.parseResult.value?.persons?.map(p => p.name)?.join('\n ')}
Greetings to:
${document.parseResult.value?.greetings?.map(g => g.person.$refText)?.join('\n ')}
`
).toBe(s`
Persons:
Langium
Greetings to:
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 7b0a010

Please sign in to comment.