langium-cli
Version:
CLI for Langium - the language engineering tool
270 lines (224 loc) • 12.6 kB
text/typescript
/******************************************************************************
* Copyright 2021 TypeFox GmbH
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
******************************************************************************/
import { type Grammar, type LangiumCoreServices } from 'langium';
import { expandToNode, joinToNode, toString, type Generated } from 'langium/generate';
import type { AstTypes, Property, PropertyDefaultValue } from 'langium/grammar';
import { collectAst, collectTypeHierarchy, escapeQuotes, findReferenceTypes, isAstType, mergeTypesAndInterfaces } from 'langium/grammar';
import type { LangiumConfig, LangiumLanguageConfig } from '../package-types.js';
import { collectKeywords, collectTerminalRegexps } from './langium-util.js';
import { generatedHeader } from './node-util.js';
function generateAstHeader(langiumConfig: LangiumConfig): Generated {
const importFrom = langiumConfig.langiumInternal ? `../../syntax-tree${langiumConfig.importExtension}` : 'langium';
return expandToNode`
${generatedHeader}
/* eslint-disable */
import * as langium from '${importFrom}';
`;
}
export function generateAstSingleLanguageProject(services: LangiumCoreServices, embeddedGrammar: Grammar, config: LangiumConfig): string {
const astTypes = collectAst(embeddedGrammar, { services });
const astTypesFiltered: AstTypes = { // some operations with in-place changes are done on these filtered AsTypes!
interfaces: [...astTypes.interfaces],
unions: astTypes.unions.filter(e => isAstType(e.type)),
};
const fileNode = expandToNode`
${generateAstHeader(config)}
${generateTerminalsAndKeywords([embeddedGrammar], config.projectName)}
${joinToNode(generateTypeDefinitions(astTypes), t => t.generatedCode, { appendNewLineIfNotEmpty: true, skipNewLineAfterLastItem: true })}
${generateAstType(config.projectName, astTypesFiltered)}
${generateAstReflection(config.projectName, astTypesFiltered)}
`.appendNewLine();
return toString(fileNode);
}
export function generateAstMultiLanguageProject(services: LangiumCoreServices, languages: LanguageInfo[], config: LangiumConfig): string {
const astTypes = collectAst(languages.map(l => l.embeddedGrammar), { services });
const fileNode = expandToNode`
${generateAstHeader(config)}
${joinToNode( // a namespace for each each language with its language-specific elements: reachable terminals & keywords, complete AstType list
languages,
language => expandToNode`
/** Contains the reachable terminals & keywords and all available types of the '${language.identifier}' language. */
export namespace ${language.identifier} {
${generateTerminalsAndKeywords([language.embeddedGrammar], '')}
${generateAstType('', collectAst([language.embeddedGrammar], { services, filterNonAstTypeUnions: true }))}
}
`.appendNewLine().appendNewLine(),
)}
// the terminals, keywords and types of the whole '${config.projectName}' project
${ // reachable terminals & keywords for the whole project
generateTerminalsAndKeywordsComposed(languages.map(l => l.identifier), config.projectName)
}
${ // AstType list for the whole project
generateAstTypeComposed(languages.map(l => l.identifier), config.projectName)
}
// all type definitions of the the whole '${config.projectName}' project
${joinToNode(generateTypeDefinitions(astTypes), t => t.generatedCode, { appendNewLineIfNotEmpty: true, skipNewLineAfterLastItem: true })}
${ // reflection for the whole project
astTypes.unions = astTypes.unions.filter(e => isAstType(e.type)), // Note that the `unions` are changed in-place here!
generateAstReflection(config.projectName, astTypes) // Note that here are some more in-place changes!
}
`.appendNewLine();
return toString(fileNode);
}
interface TypeWithCode {
typeName: string;
generatedCode: string;
}
function generateTypeDefinitions(astTypes: AstTypes): TypeWithCode[] {
return [
...astTypes.unions.map(union => <TypeWithCode>{ typeName: union.name, generatedCode: union.toAstTypesString(isAstType(union.type)) }),
...astTypes.interfaces.map(iFace => <TypeWithCode>{ typeName: iFace.name, generatedCode: iFace.toAstTypesString(true) }),
].sort((l, r) => l.typeName.localeCompare(r.typeName));
}
export interface LanguageInfo {
/** the grammar which is defined as entry/main grammar */
entryGrammar: Grammar
/** copy of the entry grammar, all imports are (recursively) replaced by the content of the imported grammar(s) */
embeddedGrammar: Grammar
/** the whole configuration for this language, by default done in a langium-config.json file */
languageConfig: LangiumLanguageConfig
/** used to identify/name this language in the generated ast.ts */
identifier: string
}
export function getLanguageIdentifier(_config: LangiumConfig, grammar: Grammar): string {
return grammar.name!; // there is a check in the CLI, that the top-level grammar of a language always has a 'name' value!
}
function generateAstType(name: string, astTypes: AstTypes): Generated {
const typeNames: string[] = astTypes.interfaces.map(t => t.name)
.concat(astTypes.unions.map(t => t.name))
.sort();
return expandToNode`
export type ${name}AstType = {
${joinToNode(typeNames, name => name + ': ' + name, { appendNewLineIfNotEmpty: true })}
}
`;
}
function generateAstTypeComposed(identifiers: string[], name: string): Generated {
identifiers.sort(); // in-place, for a stable order
return expandToNode`
export type ${name}AstType = ${joinToNode(identifiers, identifier => `${identifier}.AstType`, { separator: ' & ' })}
`;
}
function generateAstReflection(name: string, astTypes: AstTypes): Generated {
const typeNames: string[] = astTypes.interfaces.map(t => t.name)
.concat(astTypes.unions.map(t => t.name))
.sort();
const typeHierarchy = collectTypeHierarchy(mergeTypesAndInterfaces(astTypes));
return expandToNode`
export class ${name}AstReflection extends langium.AbstractAstReflection {
override readonly types = {
${joinToNode(typeNames, typeName => {
const interfaceType = astTypes.interfaces.find(t => t.name === typeName);
const unionType = astTypes.unions.find(t => t.name === typeName);
if (interfaceType || unionType) {
const props = interfaceType?.superProperties ?? [];
const superTypes = typeHierarchy.superTypes.get(typeName) || [];
return expandToNode`
${typeName}: {
name: ${typeName}.$type,
properties: {
${buildPropertyMetaData(props, typeName)}
},
superTypes: [${superTypes.map(t => `${t}.$type`).join(', ')}]
}
`;
}
return undefined;
}, { separator: ',', appendNewLineIfNotEmpty: true })}
} as const satisfies langium.AstMetaData
}
export const reflection = new ${name}AstReflection();
`;
}
function buildPropertyMetaData(props: Property[], ownerTypeName: string): Generated {
const all = props.sort((a, b) => a.name.localeCompare(b.name));
return joinToNode(
all,
property => {
const defaultValue = stringifyDefaultValue(property.defaultValue);
const refTypes = findReferenceTypes(property.type);
const refType = refTypes.length > 0 ? refTypes[0] : undefined;
const attributes: string[] = [`name: ${ownerTypeName}.${property.name}`];
if (defaultValue) {
attributes.push(`defaultValue: ${defaultValue}`);
}
if (refType) {
attributes.push(`referenceType: ${refType}.$type`);
}
return expandToNode`
${property.name}: {
${joinToNode(attributes, attr => attr, { separator: ',', appendNewLineIfNotEmpty: true })}
}
`;
},
{ separator: ',', appendNewLineIfNotEmpty: true}
);
}
function stringifyDefaultValue(value?: PropertyDefaultValue): string | undefined {
if (typeof value === 'string') {
// Escape all double quotes
return `'${escapeQuotes(value, "'")}'`;
} else if (Array.isArray(value)) {
return `[${value.map(e => stringifyDefaultValue(e)).join(', ')}]`;
} else if (value !== undefined) {
return value.toString();
} else {
return undefined;
}
}
function generateTerminalsAndKeywords(grammars: Grammar[], name: string): Generated {
// Collects only reached/used terminals and keywords, i.e. elements which are not reachable, when transitively following the entry rule of the grammars.
// For grammars without entry rule, all elements are collected.
// Called terminal fragments are ignored "because referenced terminals are expanded/inlined
// before registering the relevant terminals in the lexer/generating the regexes to ast.ts.
// You might argue that generating sub terminals might still be useful.
// However, the value converter is always asked for converting terminals being accepted by a expanded terminal regex.
// Thus, using the expanded terminal definition and doing finer evaluations by checking for matched groups according to the actual terminal regex is the natural way to go"
// (https://github.com/eclipse-langium/langium/pull/1979#issuecomment-3089241029).
// Terminals are not alphabetically sorted, since the current order reflects the order of terminals during parsing.
let collection: Record<string, RegExp> = {};
const keywordTokens = new Set<string>();
grammars.forEach(grammar => {
const terminalConstants = collectTerminalRegexps(grammar); // collects only reachable terminals, ignore called terminal fragments, ignores imported grammars
collection = {...collection, ...terminalConstants};
for (const keyword of collectKeywords(grammar)) { // collects only reachable keywords, ignores imported grammars
keywordTokens.add(keyword);
}
});
const keywordStrings = Array.from(keywordTokens).sort().map((keyword) => JSON.stringify(keyword));
return expandToNode`
export const ${name}Terminals = {
${joinToNode(Object.entries(collection), ([name, regexp]) => `${name}: ${regexp.toString()},`, { appendNewLineIfNotEmpty: true })}
};
export type ${name}TerminalNames = keyof typeof ${name}Terminals;
export type ${name}KeywordNames =${keywordStrings.length > 0 ? undefined : ' never;'}
${joinToNode(
keywordStrings,
keyword => `| ${keyword}`,
{ appendNewLineIfNotEmpty: true, skipNewLineAfterLastItem: true }
)?.append(';')}
export type ${name}TokenNames = ${name}TerminalNames | ${name}KeywordNames;
`;
}
function generateTerminalsAndKeywordsComposed(identifiers: string[], name: string): Generated {
identifiers.sort(); // in-place, for a stable order
return expandToNode`
export const ${name}Terminals = {
${joinToNode(
identifiers,
identifier => `...${identifier}.Terminals,`,
{ appendNewLineIfNotEmpty: true }
)}
};
export type ${name}TerminalNames = keyof typeof ${name}Terminals;
export type ${name}KeywordNames = ${joinToNode(
identifiers,
identifier => `${identifier}.KeywordNames`,
{ separator: ' | ' }
)};
export type ${name}TokenNames = ${name}TerminalNames | ${name}KeywordNames;
`;
}