diff --git a/.vscode/launch.json b/.vscode/launch.json index 48b7c8ed1..34d30b8ba 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -87,6 +87,21 @@ "${workspaceFolder}/{packages,examples}/*/{lib,out}/**/*.js" ] }, + { + "name": "Run Yoeman Generator", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}/examples", + "runtimeExecutable": "npx", + "runtimeArgs": [ + "yo", + "langium" + ], + "console": "integratedTerminal", + "skipFiles": [ + "/**" + ], + }, { "name": "Bootstrap", "type": "node", diff --git a/packages/generator-langium/src/index.ts b/packages/generator-langium/src/index.ts index f7207d226..932f46135 100644 --- a/packages/generator-langium/src/index.ts +++ b/packages/generator-langium/src/index.ts @@ -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'; @@ -18,12 +19,14 @@ 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; @@ -31,13 +34,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 { @@ -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) { @@ -62,7 +70,7 @@ class LangiumGenerator extends Generator { async prompting(): Promise { printLogo(this.log); - this.answers = await this.prompt([ + this.answers = await this.prompt([ { type: 'input', name: 'extensionName', @@ -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' } ]); } @@ -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); @@ -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 ); } @@ -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 ); } } @@ -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 ); } } @@ -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')); } @@ -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 { const code = await which('code').catch(() => undefined); if (code) { - const answer = await this.prompt({ + const answer = await this.prompt({ type: 'list', name: 'openWith', message: 'Do you want to open the new folder with Visual Studio Code?', @@ -277,7 +301,7 @@ class LangiumGenerator extends Generator { ] }); if (answer?.openWith) { - this.spawnCommand(answer.openWith, [this._extensionPath()]); + this.spawn(answer.openWith, [this._extensionPath()]); } } } @@ -286,7 +310,7 @@ 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) @@ -294,6 +318,7 @@ class LangiumGenerator extends Generator { .replace(FILE_EXTENSION_GLOB, fileExtensionGlob) .replace(LANGUAGE_NAME, languageName) .replace(LANGUAGE_ID, languageId) + .replace(TSCONFIG_BASE_NAME, tsconfigBaseName) .replace(NEWLINES, EOL); } diff --git a/packages/generator-langium/templates/cli/.package.json b/packages/generator-langium/templates/cli/.package.json index 0dc30b058..7716782d5 100644 --- a/packages/generator-langium/templates/cli/.package.json +++ b/packages/generator-langium/templates/cli/.package.json @@ -1,6 +1,6 @@ { "engines": { - "node": ">=16.0.0" + "node": ">=18.0.0" }, "files": [ "bin" diff --git a/packages/generator-langium/templates/core/.package.json b/packages/generator-langium/templates/core/.package.json index c4c0de95c..89cc0faec 100644 --- a/packages/generator-langium/templates/core/.package.json +++ b/packages/generator-langium/templates/core/.package.json @@ -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" @@ -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", diff --git a/packages/generator-langium/templates/test/.package.json b/packages/generator-langium/templates/test/.package.json new file mode 100644 index 000000000..c21b74360 --- /dev/null +++ b/packages/generator-langium/templates/test/.package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "vitest": "~1.0.0" + }, + "scripts": { + "test": "vitest run" + } +} \ No newline at end of file diff --git a/packages/generator-langium/templates/test/.tsconfig.json b/packages/generator-langium/templates/test/.tsconfig.json new file mode 100644 index 000000000..626da1c99 --- /dev/null +++ b/packages/generator-langium/templates/test/.tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] + } + \ No newline at end of file diff --git a/packages/generator-langium/templates/test/.vscode-extensions.json b/packages/generator-langium/templates/test/.vscode-extensions.json new file mode 100644 index 000000000..1252a6cd7 --- /dev/null +++ b/packages/generator-langium/templates/test/.vscode-extensions.json @@ -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" + ] +} diff --git a/packages/generator-langium/templates/test/test/linking/linking.test.ts b/packages/generator-langium/templates/test/test/linking/linking.test.ts new file mode 100644 index 000000000..4e5196165 --- /dev/null +++ b/packages/generator-langium/templates/test/test/linking/linking.test.ts @@ -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: ReturnTypeServices>; +let parse: ReturnType>; +let document: LangiumDocument | undefined; + +beforeAll(async () => { + services = create<%= LanguageName %>Services(EmptyFileSystem); + parse = parseHelper(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; +} diff --git a/packages/generator-langium/templates/test/test/parsing/parsing.test.ts b/packages/generator-langium/templates/test/test/parsing/parsing.test.ts new file mode 100644 index 000000000..04d4f2797 --- /dev/null +++ b/packages/generator-langium/templates/test/test/parsing/parsing.test.ts @@ -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: ReturnTypeServices>; +let parse: ReturnType>; +let document: LangiumDocument | undefined; + +beforeAll(async () => { + services = create<%= LanguageName %>Services(EmptyFileSystem); + parse = parseHelper(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; +} diff --git a/packages/generator-langium/templates/test/test/validating/validating.test.ts b/packages/generator-langium/templates/test/test/validating/validating.test.ts new file mode 100644 index 000000000..7c155d926 --- /dev/null +++ b/packages/generator-langium/templates/test/test/validating/validating.test.ts @@ -0,0 +1,66 @@ +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 type { Diagnostic } from "vscode-languageserver-types"; +import { create<%= LanguageName %>Services } from "../../src/language/<%= language-id %>-module.js"; +import { Model, isModel } from "../../src/language/generated/ast.js"; + +let services: ReturnTypeServices>; +let parse: ReturnType>; +let document: LangiumDocument | undefined; + +beforeAll(async () => { + services = create<%= LanguageName %>Services(EmptyFileSystem); + const doParse = parseHelper(services.<%= LanguageName %>); + parse = (input: string) => doParse(input, { validation: true }); + + // activate the following if your linking test requires elements from a built-in library, for example + // await services.shared.workspace.WorkspaceManager.initializeWorkspace([]); +}); + +describe('Validating', () => { + + test('check no errors', async () => { + document = await parse(` + person 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 diagnostics by converting them into human readable strings; + // note that 'toHaveLength()' works for arrays and strings alike ;-) + checkDocumentValid(document) || document?.diagnostics?.map(diagnosticToString)?.join('\n') + ).toHaveLength(0); + }); + + test('check capital letter validation', async () => { + document = await parse(` + person langium + `); + + expect( + checkDocumentValid(document) || document?.diagnostics?.map(diagnosticToString)?.join('\n') + ).toEqual( + // 'expect.stringContaining()' makes our test robust against future additions of further validation rules + expect.stringContaining(s` + [1:19..1:26]: Person name should start with a capital. + `) + ); + }); +}); + +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; +} + +function diagnosticToString(d: Diagnostic) { + return `[${d.range.start.line}:${d.range.start.character}..${d.range.end.line}:${d.range.end.character}]: ${d.message}`; +} diff --git a/packages/generator-langium/templates/test/tsconfig.src.json b/packages/generator-langium/templates/test/tsconfig.src.json new file mode 100644 index 000000000..928fe6d89 --- /dev/null +++ b/packages/generator-langium/templates/test/tsconfig.src.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + }, + "include": [ + "src/**/*.ts" + ] + } + \ No newline at end of file diff --git a/packages/generator-langium/templates/test/vitest.config.ts b/packages/generator-langium/templates/test/vitest.config.ts new file mode 100644 index 000000000..47173bfcf --- /dev/null +++ b/packages/generator-langium/templates/test/vitest.config.ts @@ -0,0 +1,20 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://vitest.dev/config/ + */ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // coverage: { + // provider: 'v8', + // reporter: ['text', 'html'], + // include: ['src'], + // exclude: ['**/generated'], + // }, + deps: { + interopDefault: true + }, + include: ['**/*.test.ts'] + } +}); diff --git a/packages/generator-langium/templates/vscode/.package.json b/packages/generator-langium/templates/vscode/.package.json index a91c9fa5d..a8b896286 100644 --- a/packages/generator-langium/templates/vscode/.package.json +++ b/packages/generator-langium/templates/vscode/.package.json @@ -25,8 +25,8 @@ "main": "./out/extension/main.cjs", "scripts": { "vscode:prepublish": "npm run build && npm run lint", - "build": "tsc -b tsconfig.json && node esbuild.mjs", - "watch": "concurrently -n tsc,esbuild -c blue,yellow \"tsc -b tsconfig.json --watch\" \"node esbuild.mjs --watch\"" + "build": "tsc -b <%= tsconfig %> && node esbuild.mjs", + "watch": "concurrently -n tsc,esbuild -c blue,yellow \"tsc -b <%= tsconfig %> --watch\" \"node esbuild.mjs --watch\"" }, "dependencies": { "vscode-languageclient": "~9.0.1", diff --git a/packages/generator-langium/test/yeoman-generator.test.ts b/packages/generator-langium/test/yeoman-generator.test.ts index 0c4632508..66ad940ce 100644 --- a/packages/generator-langium/test/yeoman-generator.test.ts +++ b/packages/generator-langium/test/yeoman-generator.test.ts @@ -8,59 +8,132 @@ import { normalizeEOL } from 'langium/generate'; import * as path from 'node:path'; import * as url from 'node:url'; import { describe, test } from 'vitest'; +import type * as Generator from 'yeoman-generator'; import { createHelpers } from 'yeoman-test'; +import type { Answers, LangiumGenerator, PostAnwers } from '../src/index.js'; -const __dirname = url.fileURLToPath(new URL('../', import.meta.url)); - -const answersForCore = { +const answersForCore: Answers & PostAnwers = { extensionName: 'hello-world', rawLanguageName: 'Hello World', - fileExtension: '.hello', + fileExtensions: '.hello', includeVSCode: false, includeCLI: false, includeWeb: false, + includeTest: false, openWith: false }; describe('Check yeoman generator works', () => { - const moduleRoot = path.join(__dirname, 'app'); + const packageTestDir = url.fileURLToPath(new URL('.', import.meta.url)); + const moduleRoot = path.join(packageTestDir, '../app'); - test('Should produce files for Core', async () => { + const files = (targetRoot: string) => [ + targetRoot + '/.eslintrc.json', + targetRoot + '/.gitignore', + targetRoot + '/langium-config.json', + targetRoot + '/langium-quickstart.md', + targetRoot + '/tsconfig.json', + targetRoot + '/package.json', + targetRoot + '/.vscode/extensions.json', + targetRoot + '/.vscode/tasks.json', + targetRoot + '/src/language/hello-world-module.ts', + targetRoot + '/src/language/hello-world-validator.ts', + targetRoot + '/src/language/hello-world.langium' + ]; + + const testFiles = (targetRoot: string) => [ + targetRoot + '/tsconfig.src.json', + targetRoot + '/test/parsing/parsing.test.ts', + targetRoot + '/test/linking/linking.test.ts', + targetRoot + '/test/validating/validating.test.ts', + ]; + + test('1 Should produce files for Core', async () => { + + const context = createHelpers({}).run(path.join(moduleRoot)); + + // generate in examples + const targetRoot = path.resolve(packageTestDir, '../../../examples'); + const extensionName = answersForCore.extensionName; + + // remove examples/hello-world (if existing) now and finally (don't delete everything else in examples) + context.targetDirectory = path.resolve(targetRoot, extensionName); + context.cleanTestDirectory(true); - const context = createHelpers({}).run(moduleRoot); - context.targetDirectory = path.join(__dirname, '../../../examples/hello-world'); // generate in examples - const targetRoot = path.join(__dirname, '../../../examples'); - context.cleanTestDirectory(true); // clean-up examples/hello-world await context - .onGenerator(async (generator) => { - // will generate into examples/hello-world instead of examples/hello-world/hello-world - generator.destinationRoot(targetRoot); // types are wrong, should be string + .withOptions({ + // we need to explicitly tell the generator it's destinationRoot + destinationRoot: targetRoot + }) + .onTargetDirectory(workingDir => { + // just for double checking + console.log(`Generating into directory: ${workingDir}`); }) .withAnswers(answersForCore) .withArguments('skip-install') .then((result) => { - const files = [ - targetRoot + '/hello-world/.eslintrc.json', - targetRoot + '/hello-world/.gitignore', - targetRoot + '/hello-world/langium-config.json', - targetRoot + '/hello-world/langium-quickstart.md', - targetRoot + '/hello-world/tsconfig.json', - targetRoot + '/hello-world/package.json', - targetRoot + '/hello-world/.vscode/extensions.json', - targetRoot + '/hello-world/.vscode/tasks.json', - targetRoot + '/hello-world/src/language/hello-world-module.ts', - targetRoot + '/hello-world/src/language/hello-world-validator.ts', - targetRoot + '/hello-world/src/language/hello-world.langium' - ]; - result.assertFile(files); - result.assertJsonFileContent(targetRoot + '/hello-world/package.json', PACKAGE_JSON_EXPECTATION); - result.assertFileContent(targetRoot + '/hello-world/.vscode/tasks.json', TASKS_JSON_EXPECTATION); + const projectRoot = targetRoot + '/' + extensionName; + + result.assertFile(files(projectRoot)); + result.assertNoFile(testFiles(projectRoot)); + + result.assertJsonFileContent(projectRoot + '/package.json', PACKAGE_JSON_EXPECTATION); + result.assertFileContent(projectRoot + '/.vscode/tasks.json', TASKS_JSON_EXPECTATION); }).finally(() => { context.cleanTestDirectory(true); }); context.cleanTestDirectory(true); // clean-up examples/hello-world }, 120_000); + test('2 Should produce files for Core & CLI & test', async () => { + + const context = createHelpers({}).run(path.join(moduleRoot)); + + // generate in examples + const targetRoot = path.resolve(packageTestDir, '../../../examples'); + const extensionName = 'hello-world'; + + // remove examples/hello-world (if existing) now and finally (don't delete everything else in examples) + context.targetDirectory = path.resolve(targetRoot, extensionName); + context.cleanTestDirectory(true); + + await context + .withOptions({ + // we need to explicitly tell the generator it's destinationRoot + destinationRoot: targetRoot + }) + .onTargetDirectory(workingDir => { + // just for double checking + console.log(`Generating into directory: ${workingDir}`); + }) + .withArguments('skip-install') + .withAnswers( { + ...answersForCore, + extensionName, + includeCLI: true, + includeTest: true + }).then((result) => { + const projectRoot = targetRoot + '/' + extensionName; + result.assertJsonFileContent(projectRoot + '/package.json', { + ...PACKAGE_JSON_EXPECTATION, + files: [ 'bin' ], + scripts: { + ...PACKAGE_JSON_EXPECTATION.scripts, + build: PACKAGE_JSON_EXPECTATION.scripts.build.replace(/tsconfig.json/, 'tsconfig.src.json'), + watch: PACKAGE_JSON_EXPECTATION.scripts.watch.replace(/tsconfig.json/, 'tsconfig.src.json') + } + }); + + const returnVal = result.generator.spawnSync('npm', ['test'], { + cwd: result.generator._extensionPath() + }); + + result.assertTextEqual(String(returnVal.exitCode), '0'); + + }).finally(() => { + context.cleanTestDirectory(true); + }); + }, 120_000); }); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -85,7 +158,7 @@ const PACKAGE_JSON_EXPECTATION: Record = { 'langium': langiumVersion }, 'devDependencies': { - '@types/node': '~16.18.41', + '@types/node': '~18.0.0', '@typescript-eslint/eslint-plugin': '~6.4.1', '@typescript-eslint/parser': '~6.4.1', 'eslint': '~8.47.0', diff --git a/packages/generator-langium/tsconfig.src.json b/packages/generator-langium/tsconfig.src.json index 5d42891b8..e0f48d1f5 100644 --- a/packages/generator-langium/tsconfig.src.json +++ b/packages/generator-langium/tsconfig.src.json @@ -2,7 +2,18 @@ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src", - "outDir": "app" + "outDir": "app", + + // there is kind of a bug in the type definitions of yeoman-generator, + // namely in 'yeoman-generator/dist/generator.d.ts' that states + // import EventEmitter from 'node:events'; + // instead of + // import { EventEmitter } from 'node:events'; + // this breaks the inheritance hierarchy of class 'Generator', whose type definition + // is an implicit composition of class 'BaseGenerator' and interface 'BaseGenerator', + // both defined in 'yeoman-generator/dist/generator.d.ts' + // the following setting instructs TSC to tolerate that. + "allowSyntheticDefaultImports": true }, "include": [ "src/**/*" diff --git a/vite.config.mts b/vite.config.mts index c0769d646..cbbd3d9bd 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -12,11 +12,13 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'html'], include: ['packages/langium/src'], - exclude: ['**/generated'], + exclude: ['**/generated', '**/templates'], }, deps: { interopDefault: true }, - include: ['**/*.test.ts'] + include: ['**/test/**/*.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/generated/**', '**/templates/**'], + watchExclude: [ '**/examples/hello*/**' /* populated by the yeoman generator test */], } });