Skip to content

Add an option to generate a minimal project with Yeoman #1967

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

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
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
48 changes: 39 additions & 9 deletions packages/generator-langium/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ const __dirname = url.fileURLToPath(new URL('.', import.meta.url));

const BASE_DIR = '../templates';
const PACKAGE_LANGUAGE = 'packages/language';
const PACKAGE_LANGUAGE_EXAMPLE = 'packages/language-example';
const PACKAGE_LANGUAGE_MINIMAL = 'packages/language-minimal';
const PACKAGE_CLI = 'packages/cli';
const PACKAGE_CLI_EXAMPLE = 'packages/cli-example';
const PACKAGE_CLI_MINIMAL = 'packages/cli-minimal';
const PACKAGE_EXTENSION = 'packages/extension';
const USER_DIR = '.';

Expand All @@ -30,6 +34,8 @@ const LANGUAGE_NAME = /<%= LanguageName %>/g;
const LANGUAGE_ID = /<%= language-id %>/g;
const LANGUAGE_PATH_ID = /language-id/g;

const ENTRY_NAME = /<%= EntryName %>/g;

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

export interface Answers {
Expand All @@ -38,7 +44,9 @@ export interface Answers {
fileExtensions: string;
includeVSCode: boolean;
includeCLI: boolean;
includeExampleProject: boolean;
includeTest: boolean;
entryName: string;
}

export interface PostAnwers {
Expand Down Expand Up @@ -89,7 +97,7 @@ export class LangiumGenerator extends Generator {
name: 'extensionName',
prefix: description(
'Welcome to Langium!',
'This tool generates a VS Code extension with a "Hello World" language to get started quickly.',
'This tool generates one or more npm packages to create your own language based on Langium.',
'The extension name is an identifier used in the extension marketplace or package registry.'
),
message: 'Your extension name:',
Expand Down Expand Up @@ -124,6 +132,15 @@ export class LangiumGenerator extends Generator {
? true
: 'A file extension can start with . and must contain only letters and digits. Extensions must be separated by commas.',
} as PromptQuestion<Answers>,
{
type: 'input',
name: 'entryName',
prefix: description(
'The name of the entry rule in your grammar file.'
),
message: 'Your grammar entry rule name:',
default: 'Model',
} as PromptQuestion<Answers>,
{
type: 'confirm',
name: 'includeVSCode',
Expand All @@ -133,6 +150,16 @@ export class LangiumGenerator extends Generator {
message: 'Include VSCode extension?',
default: true
} as PromptQuestion<Answers>,
{
type: 'confirm',
name: 'includeExampleProject',
prefix: description(
'You can generate an example project to play around with Langium (with a "Hello world" grammar, generator and validator).',
'If not, a minimal project will be generated.'
),
message: 'Generate example project?',
default: true
} as PromptQuestion<Answers>,
{
type: 'confirm',
name: 'includeCLI',
Expand All @@ -149,8 +176,8 @@ export class LangiumGenerator extends Generator {
'You can add the setup for language tests using Vitest.'
),
message: 'Include language tests?',
default: true
} as PromptQuestion<Answers>
default: true,
} as PromptQuestion<Answers>,
]);
}

Expand Down Expand Up @@ -201,7 +228,8 @@ export class LangiumGenerator extends Generator {
// .gitignore files don't get published to npm, so we need to copy it under a different name
this.fs.copy(this.templatePath('gitignore.txt'), this._extensionPath('.gitignore'));

this.sourceRoot(path.join(__dirname, `${BASE_DIR}/${PACKAGE_LANGUAGE}`));
this.sourceRoot(path.join(__dirname, `${BASE_DIR}/${this.answers.includeExampleProject ? PACKAGE_LANGUAGE_EXAMPLE : PACKAGE_LANGUAGE_MINIMAL}`));

const languageFiles = [
'package.json',
'README.md',
Expand Down Expand Up @@ -241,6 +269,9 @@ export * from './generated/ast.js';
export * from './generated/grammar.js';
export * from './generated/module.js';
`;
// Write language index.ts and langium-config.json
this.fs.write(this._extensionPath('packages/language/src/index.ts'), languageIndex);
this.fs.writeJSON(this._extensionPath('packages/language/langium-config.json'), langiumConfigJson, undefined, 4);

if (this.answers.includeTest) {
mainPackageJson.scripts.test = 'npm run --workspace packages/language test';
Expand All @@ -261,7 +292,9 @@ export * from './generated/module.js';
}

if (this.answers.includeCLI) {
this.sourceRoot(path.join(__dirname, `${BASE_DIR}/${PACKAGE_CLI}`));
this.sourceRoot(path.join(__dirname, `${BASE_DIR}/${
this.answers.includeExampleProject ? PACKAGE_CLI_EXAMPLE : PACKAGE_CLI_MINIMAL
}`));
const cliFiles = [
'package.json',
'tsconfig.json',
Expand All @@ -280,10 +313,6 @@ export * from './generated/module.js';
tsConfigBuildJson.references.push({ path: './packages/cli/tsconfig.json' });
}

// Write language index.ts and langium-config.json
this.fs.write(this._extensionPath('packages/language/src/index.ts'), languageIndex);
this.fs.writeJSON(this._extensionPath('packages/language/langium-config.json'), langiumConfigJson, undefined, 4);

if (this.answers.includeVSCode) {
this.sourceRoot(path.join(__dirname, `${BASE_DIR}/${PACKAGE_EXTENSION}`));
const extensionFiles = [
Expand Down Expand Up @@ -363,6 +392,7 @@ export * from './generated/module.js';
.replace(FILE_EXTENSION_GLOB, fileExtensionGlob)
.replace(LANGUAGE_NAME, languageName)
.replace(LANGUAGE_ID, languageId)
.replace(ENTRY_NAME, this.answers.entryName)
.replace(NEWLINES, EOL);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Command-line interface (CLI)

Check [this part](https://langium.org/docs/learn/minilogo/customizing_cli/) of the Langium Minilogo Tutorial as a useful guide to the CLI.

## What's in the folder?

- [package.json](./package.json) - The manifest file of your cli package.
- [tsconfig.src.json](./tsconfig.src.json) - The package specific TypeScript compiler configuration extending the [base config](../../tsconfig.json).
- [tsconfig.json](./tsconfig.json) - TypeScript compiler configuration options required for proper functionality of VSCode.
- [bin/cli.js](bin/cli/cli.js) - Script referenced in the [package.json](./package.json) and used to execute the command-line interface.
- [src/cli/main.ts](src/cli/main.ts) - The entry point of the command line interface (CLI) of your language.
- [src/cli/generator.ts](src/cli/generator.ts) - The code generator used by the CLI to write output files from DSL documents.
- [src/cli/util.ts](src/cli/util.ts) - Utility code for the CLI.

## Instructions

Run `node ./bin/cli` to see options for the CLI; `node ./bin/cli generate <file>` generates code for a given DSL file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/usr/bin/env node

import main from '../out/main.js';
main();
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "<%= language-id %>-cli",
"description": "The cli specific package",
"version": "0.0.1",
"type": "module",
"engines": {
"node": ">=20.10.0",
"npm": ">=10.2.3"
},
"files": [
"bin",
"out",
"src"
],
"bin": {
"<%= language-id %>-cli": "./bin/cli.js"
},
"scripts": {
"clean": "shx rm -fr *.tsbuildinfo out",
"build": "echo 'No build step'",
"build:clean": "npm run clean && npm run build"
},
"dependencies": {
"<%= language-id %>-language": "0.0.1",
"chalk": "~5.3.0",
"commander": "~11.1.0"
},
"volta": {
"node": "20.19.2",
"npm": "10.8.2"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { <%= EntryName %> } from '<%= language-id %>-language';
import { expandToNode, toString } from 'langium/generate';
import * as fs from 'node:fs';
import { extractDestinationAndName } from './util.js';

export function generateOutput(model: <%= EntryName %>, source: string, destination: string): string {
const data = extractDestinationAndName(destination);

const fileNode = expandToNode`
// TODO : place here generated code
`.appendNewLineIfNotEmpty();

if (!fs.existsSync(data.destination)) {
fs.mkdirSync(data.destination, { recursive: true });
}
fs.writeFileSync(destination, toString(fileNode));
return destination;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { <%= EntryName %> } from '<%= language-id %>-language';
import { create<%= LanguageName %>Services, <%= LanguageName %>LanguageMetaData } from '<%= language-id %>-language';
import chalk from 'chalk';
import { Command } from 'commander';
import { extractAstNode } from './util.js';
import { generateOutput } from './generator.js';
import { NodeFileSystem } from 'langium/node';
import * as url from 'node:url';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));

const packagePath = path.resolve(__dirname, '..', 'package.json');
const packageContent = await fs.readFile(packagePath, 'utf-8');

export const generateAction = async (source: string, destination: string): Promise<void> => {
const services = create<%= LanguageName %>Services(NodeFileSystem).<%= LanguageName %>;
const model = await extractAstNode<<%= EntryName %>>(source, services);
const generatedFilePath = generateOutput(model, source, destination);
console.log(chalk.green(`Code generated succesfully: ${generatedFilePath}`));
};

export default function(): void {
const program = new Command();

program.version(JSON.parse(packageContent).version);

// TODO: use Program API to declare the CLI
const fileExtensions = <%= LanguageName %>LanguageMetaData.fileExtensions.join(', ');
program
.command('generate')
.argument('<file>', `source file (possible file extensions: ${fileExtensions})`)
.argument('<destination>', 'destination file')
.description('Generates code for a provided source file.')
.action(generateAction);

program.parse(process.argv);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { AstNode, LangiumCoreServices, LangiumDocument } from 'langium';
import chalk from 'chalk';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { URI } from 'langium';

export async function extractDocument(fileName: string, services: LangiumCoreServices): Promise<LangiumDocument> {
const extensions = services.LanguageMetaData.fileExtensions;
if (!extensions.includes(path.extname(fileName))) {
console.error(chalk.yellow(`Please choose a file with one of these extensions: ${extensions}.`));
process.exit(1);
}

if (!fs.existsSync(fileName)) {
console.error(chalk.red(`File ${fileName} does not exist.`));
process.exit(1);
}

const document = await services.shared.workspace.LangiumDocuments.getOrCreateDocument(URI.file(path.resolve(fileName)));
await services.shared.workspace.DocumentBuilder.build([document], { validation: true });

const validationErrors = (document.diagnostics ?? []).filter(e => e.severity === 1);
if (validationErrors.length > 0) {
console.error(chalk.red('There are validation errors:'));
for (const validationError of validationErrors) {
console.error(chalk.red(
`line ${validationError.range.start.line + 1}: ${validationError.message} [${document.textDocument.getText(validationError.range)}]`
));
}
process.exit(1);
}

return document;
}

export async function extractAstNode<T extends AstNode>(fileName: string, services: LangiumCoreServices): Promise<T> {
return (await extractDocument(fileName, services)).parseResult?.value as T;
}

interface FilePathData {
destination: string,
name: string
}

export function extractDestinationAndName(destination: string): FilePathData {
return {
destination: path.dirname(destination),
name: path.basename(destination)
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "out",
"declarationDir": "out"
},
"references": [
{
"path": "../language/tsconfig.src.json"
}
],
"include": [
"src/**/*.ts"
]
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
grammar <%= LanguageName %>

entry Model:
entry <%= EntryName %>:
(persons+=Person | greetings+=Greeting)*;

Person:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ 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 type { Model } from "<%= language-id %>-language";
import { create<%= LanguageName %>Services, isModel } from "<%= language-id %>-language";
import type { <%= EntryName %> } from "<%= language-id %>-language";
import { create<%= LanguageName %>Services, is<%= EntryName %> } from "<%= language-id %>-language";

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

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

// activate the following if your linking test requires elements from a built-in library, for example
// await services.shared.workspace.WorkspaceManager.initializeWorkspace([]);
Expand Down Expand Up @@ -48,6 +48,6 @@ function checkDocumentValid(document: LangiumDocument): string | undefined {
${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'.`
|| !is<%= EntryName %>(document.parseResult.value) && `Root AST object is a ${document.parseResult.value.$type}, expected a '<%= EntryName %>'.`
|| undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@ 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 { Model } from "<%= language-id %>-language";
import { create<%= LanguageName %>Services, isModel } from "<%= language-id %>-language";
import type { <%= EntryName %> } from "<%= language-id %>-language";
import { create<%= LanguageName %>Services, is<%= EntryName %> } from "<%= language-id %>-language";

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

beforeAll(async () => {
services = create<%= LanguageName %>Services(EmptyFileSystem);
parse = parseHelper<Model>(services.<%= LanguageName %>);
parse = parseHelper<<%= EntryName %>>(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 () => {
test('parse simple <%= EntryName %>', async () => {
document = await parse(`
person Langium
Hello Langium!
Expand Down Expand Up @@ -55,6 +55,6 @@ function checkDocumentValid(document: LangiumDocument): string | undefined {
${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'.`
|| !is<%= EntryName %>(document.parseResult.value) && `Root AST object is a ${document.parseResult.value.$type}, expected a '<%= EntryName %>'.`
|| undefined;
}
Loading
Loading