nodemda
Version:
UML to Source code generation engine
829 lines (666 loc) • 24.3 kB
JavaScript
/*
Copyright (C) 2016 by Joel Kozikowski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
;
const NodeMDA = require('../nodemda-core.js');
const MetaModel = require('../meta/meta-model.js');
require('../meta/meta-validator.js');
const fsx = require('fs-extra');
const path = require('path');
const Handlebars = require('handlebars');
const glob = require('glob');
const winston = require('winston');
const pluralize = require('pluralize');
/**
* Context for the current run of the code generator
*/
class GeneratorContext {
constructor() {
this.requires = [];
this.contributes = [];
this.plugins = [];
}
}
class PluginDefinition {
constructor(name, absoluteFilePath, moduleObject) {
this.name = name;
this.absoluteFilePath = absoluteFilePath;
this.moduleObject = moduleObject;
}
}
class FileEntry {
constructor(virtualPath, absolutePath, fromModule) {
this.virtualPath = virtualPath;
this.absolutePath = absolutePath;
this.fromModule = fromModule;
}
}
(function(mda){
let ctxGen = null;
let allFiles = null;
function initGeneratorContext(pluginList) {
ctxGen = new GeneratorContext();
const processedPlugins = new Set();
// Try to resolve `packageName` using fallback search paths
function tryResolve(packageName, searchPaths = []) {
// First, try normal resolution from the calling script’s node_modules
try {
return require.resolve(packageName);
} catch (err) {
// Then, try using Node’s “paths” option if any custom paths are given
if (searchPaths.length) {
try {
return require.resolve(packageName, { paths: searchPaths });
} catch (err2) {
return null;
}
}
return null;
}
}
// Load a package.json by name + optional extra paths to search
function loadPackageJson(packageName, searchPaths = []) {
const resolvedPath = tryResolve(packageName, searchPaths);
if (!resolvedPath) {
console.warn(`Warning: Could not resolve ${packageName}. Skipping.`);
return null;
}
const packageDir = path.dirname(resolvedPath);
const packageJsonPath = path.join(packageDir, 'package.json');
try {
return JSON.parse(fsx.readFileSync(packageJsonPath, 'utf8'));
} catch (error) {
console.warn(`Warning: Could not load package.json at ${packageJsonPath}. Skipping.`);
return null;
}
}
function processPlugin(pluginName, searchPaths = []) {
if (processedPlugins.has(pluginName)) return;
processedPlugins.add(pluginName);
// Prepend "nodemda-" if missing
let pluginFileName = pluginName.startsWith('nodemda-')
? pluginName
: 'nodemda-' + pluginName;
const packageJson = loadPackageJson(pluginFileName, searchPaths);
if (!packageJson) return; // Couldn’t find / read package.json
// Actually load the module’s main entry
let moduleObject = null;
const resolvedPath = tryResolve(pluginFileName, searchPaths);
if (!resolvedPath) {
console.warn(`Warning: Could not require ${pluginFileName}. Skipping.`);
return;
}
try {
moduleObject = require(resolvedPath);
} catch (error) {
console.warn(`Warning: Error requiring ${pluginFileName}: ${error}`);
return;
}
// Validate module
if (!moduleObject.type || !moduleObject.name || !moduleObject.version) {
console.warn(`Warning: ${pluginFileName} is missing required properties. Skipping.`);
return;
}
const validTypes = ['templates', 'mixin', 'stack'];
if (!validTypes.includes(moduleObject.type)) {
console.warn(`Warning: ${pluginFileName} has invalid type "${moduleObject.type}". Skipping.`);
return;
}
const pluginDefinition = new PluginDefinition(pluginFileName, resolvedPath, moduleObject);
if (Array.isArray(moduleObject.contributes)) {
ctxGen.contributes.push(...moduleObject.contributes);
}
if (Array.isArray(moduleObject.requires)) {
ctxGen.requires.push(...moduleObject.requires);
}
winston.info(' Using plugin: ' + pluginFileName);
ctxGen.plugins.push(pluginDefinition);
// If type is "stack", process its dependencies
if (moduleObject.type === 'stack' && packageJson.dependencies) {
// The path to the plugin’s own node_modules
const stackDir = path.dirname(resolvedPath);
const nodeModulesDir = path.join(stackDir, 'node_modules');
const newSearchPaths = [nodeModulesDir];
for (const dep of Object.keys(packageJson.dependencies)) {
processPlugin(dep, newSearchPaths);
}
}
}
// Process each plugin in the initial list
pluginList.forEach(name => processPlugin(name));
}
function validateContext() {
let isValid = true;
// Every entry in ctxGen.requires exists in ctxGen.contributes
ctxGen.requires.forEach(required => {
if (!ctxGen.contributes.includes(required)) {
// Find the plugin(s) that require this item
const requestingPlugins = ctxGen.plugins
.filter(plugin => plugin.moduleObject.requires?.includes(required))
.map(plugin => plugin.name);
console.error(
`Error: Required item "${required}" is missing from contributes. ` +
`Requested by plugin(s): ${requestingPlugins.join(', ')}`
);
isValid = false;
}
});
return isValid;
}
function sortFileEntriesByDependency(fileEntries) {
// Build a directed graph for plugin dependencies
const graph = new Map();
const contributesMap = new Map();
// Create nodes for all plugins
fileEntries.forEach(entry => {
const plugin = entry.fromModule;
if (!graph.has(plugin)) {
graph.set(plugin, new Set());
}
// Map contributions to the plugin for reverse lookup
if (plugin.contributes) {
plugin.contributes.forEach(contribute => contributesMap.set(contribute, plugin));
}
});
// Add edges based on "requires"
graph.forEach((_, plugin) => {
if (plugin.requires) {
plugin.requires.forEach(required => {
const contributingPlugin = contributesMap.get(required);
if (contributingPlugin && contributingPlugin !== plugin) {
graph.get(plugin).add(contributingPlugin);
}
});
}
});
// Perform topological sort
const visited = new Set();
const tempVisited = new Set();
const sortedPlugins = [];
function visit(plugin) {
if (tempVisited.has(plugin)) {
throw new Error("Cyclic dependency detected");
}
if (!visited.has(plugin)) {
tempVisited.add(plugin);
graph.get(plugin).forEach(visit);
tempVisited.delete(plugin);
visited.add(plugin);
sortedPlugins.push(plugin);
}
}
graph.forEach((_, plugin) => visit(plugin));
// Create a mapping of plugin to its topological order
const pluginOrder = new Map();
sortedPlugins.reverse().forEach((plugin, index) => pluginOrder.set(plugin, index));
// Sort file entries
return fileEntries.sort((a, b) => {
const pluginAOrder = pluginOrder.get(a.fromModule);
const pluginBOrder = pluginOrder.get(b.fromModule);
if (pluginAOrder !== pluginBOrder) {
return pluginBOrder - pluginAOrder;
}
return a.virtualPath.localeCompare(b.virtualPath);
});
}
function getPluginFiles() {
if (allFiles !== null) {
// We have already read in all of the files.
return;
}
const fileEntries = [];
// Helper function to recursively gather files
function collectFiles(baseDir, rootVirtualPath, fromModule) {
const entries = fsx.readdirSync(baseDir, { withFileTypes: true });
entries.forEach(entry => {
const absolutePath = path.join(baseDir, entry.name);
const virtualPath = path.join(rootVirtualPath, entry.name);
if (entry.isDirectory()) {
collectFiles(absolutePath, virtualPath, fromModule);
} else {
fileEntries.push(new FileEntry(virtualPath, absolutePath, fromModule));
}
});
}
// Add the "plugins" directories from each plugin in ctxGen.plugins
ctxGen.plugins.forEach(plugin => {
const pluginPluginsPath = path.join(path.dirname(plugin.absoluteFilePath), 'plugins');
if (fsx.existsSync(pluginPluginsPath)) {
collectFiles(pluginPluginsPath, 'plugins', plugin.moduleObject);
}
});
allFiles = sortFileEntriesByDependency(fileEntries)
}
function getFilesForPath(dirPath, ext) {
// Validate inputs
if (!dirPath) {
console.error("Directory path must be specified.");
return [];
}
if (!ext) {
console.error("File extension must be specified.");
return [];
}
const targetPath = `plugins/${dirPath}`;
const fileList = allFiles
.filter(file => file.virtualPath.startsWith(targetPath) && file.virtualPath.endsWith(ext))
.map(file => file.absolutePath);
return fileList;
}
function getFilesForStereotype(dirPath, stereotype, ext) {
let marker = "{";
if (stereotype !== null) {
if (typeof(stereotype) === "object") {
marker += stereotype.getName();
}
else {
marker += stereotype;
}
}
let marker1 = marker + "}";
let marker2 = marker + "_s}";
const targetPath = `plugins/${dirPath}`;
let files =
allFiles.filter(file => {
const matchesMarker =
(file.virtualPath.includes(marker1)) ||
(file.virtualPath.includes(marker2));
const inTargetDir = file.virtualPath.startsWith(targetPath);
const matchesExtension = file.virtualPath.endsWith(ext);
return matchesMarker && matchesExtension && inTargetDir;
})
.map(file => file.absolutePath);
files.sort();
return files;
};
/**
* Checks each parent directory of the specified file name (if any) and
* makes sure they exist.
*/
var checkDirectories = function(fileName) {
var path = fileName.split("/");
if (path.length >= 2) {
// Check all but the last path component to make sure that it exists...
var currentPath = "";
var startNdx = 0;
if (path[0].length > 0) {
// The "root" was not specified as the first parameter
if (path[0].startsWith(".")) {
currentPath = path[0];
startNdx = 1;
}
else {
currentPath = ".";
}
}
else {
startNdx = 1;
}
for (var i = startNdx; i < path.length-1; i++) {
currentPath = currentPath + "/" + path[i];
if (!fsx.existsSync(currentPath)) {
fsx.mkdirSync(currentPath);
}
} // for
}
};
var aggFileList = [];
var getAggregateFd = function(outputFileName) {
// See if we have a file already open...
for (var a = 0; a < aggFileList.length; a++) {
if (aggFileList[a].fileName === outputFileName) {
return aggFileList[a].fd;
}
} // for
// If we get here, no file is open yet...
var fd = fsx.openSync(outputFileName, "w");
aggFileList.push({ "fileName" : outputFileName, "fd" : fd});
return fd;
};
var writeToAggregateFile = function(outputFileName, result) {
var fd = getAggregateFd(outputFileName);
fsx.writeSync(fd, result);
};
var closeAllAggregateFiles = function() {
for (var a = 0; a < aggFileList.length; a++) {
fsx.closeSync(aggFileList[a].fd);
} // for
aggFileList = [];
};
var fileNameToProperty = function(fileName) {
var propName = fileName;
var lastSlash = fileName.lastIndexOf('/');
if (lastSlash >= 0) {
propName = fileName.slice(lastSlash+1, -1);
}
var lastDot = propName.indexOf(".");
if (lastDot > 0) {
propName = propName.slice(0, lastDot);
}
return propName;
};
function getDefaultOutputFileName(rootOutputPath, templateFile, metaClass) {
// Constants
const templatePrefix = 'plugins/templates';
const hbsExtension = '.hbs';
// Ensure the templateFile ends with '.hbs'
let isHbsFile = templateFile.endsWith(hbsExtension);
// Find the start of the relevant path in the templateFile
const startIndex = templateFile.indexOf(templatePrefix);
if (startIndex === -1) {
throw new Error(`Template file must include '${templatePrefix}'`);
}
// Extract the relative path after 'plugins/templates'
let relativePath = templateFile.slice(startIndex + templatePrefix.length);
// Perform substitutions if metaClass is provided
if (metaClass) {
relativePath = relativePath
.replace('{_package_}', metaClass.packageDirName || '') // Replace package placeholder
.replace(`{${metaClass.stereotypeName}}`, metaClass.name || '') // Replace stereotype placeholder
.replace(`{${metaClass.stereotypeName}_s}`, pluralize(metaClass.name || '')); // Replace plural stereotype placeholder
}
// Remove the '.hbs' extension if present
let outputFileName;
if (isHbsFile) {
outputFileName = relativePath.slice(0, -hbsExtension.length);
}
else {
outputFileName = relativePath;
}
// Combine the root output path with the transformed relative path
return path.join(rootOutputPath, outputFileName);
}
var processTemplateFile = function(metaClass, templateFile, context) {
var templateData = fsx.readFileSync(templateFile, "utf8");
context["class"] = metaClass ;
context.output = mda.Options.output;
winston.debug(" Processing " + (metaClass !== null ? metaClass.getName() : "project") + " with template " + templateFile);
var render;
var result;
try {
render = Handlebars.compile(templateData);
result = render(context);
}
catch (error) {
winston.error(`\n\nRender error in template file ${templateFile}:\n`);
winston.error(error.stack);
process.exit(-1);
}
var outputFileName = "";
var outputMode = "overwrite";
if (result.indexOf("##output") >= 0) {
var lineBreak = result.indexOf("\n");
var outputDirective = result.slice(0, lineBreak).trim();
result = result.slice(lineBreak+1, -1);
var params = outputDirective.split(" ");
if (params.length === 2) {
outputMode= params[1];
}
else {
outputMode = params[1];
outputFileName = params[2];
}
}
else {
winston.warn("WARNING - No output directive found in tempalte file " + templateFile);
}
if (outputFileName.length === 0) {
// No output directive specified. Create a default output file name...
outputFileName = getDefaultOutputFileName(mda.Options.output, templateFile, metaClass);
}
const templatePrefix = 'plugins/templates';
const startIndex = templateFile.indexOf(templatePrefix);
const abbrevTemplate = templateFile.slice(startIndex + templatePrefix.length);
winston.info(` Generating ${outputFileName} from ${abbrevTemplate} with output mode "${outputMode}"`);
if (outputMode.toLowerCase() === "overwrite") {
checkDirectories(outputFileName);
fsx.writeFileSync(outputFileName, result);
}
else if (outputMode.toLowerCase().startsWith('preserve')) {
// mode 'preserve' can be overridden with the 'forceOverwrite' option,
// unless preserve ends with '!', which forces the preservation.
let forceOW = mda.Options.forceOverwrite;
if (outputMode.endsWith('!')) {
forceOW = false;
}
if (!fsx.existsSync(outputFileName) || forceOW) {
checkDirectories(outputFileName);
fsx.writeFileSync(outputFileName, result);
}
else {
winston.info(" >>> Preserved previous contents - Nothing saved.");
}
}
else if (outputMode.toLowerCase() === "aggregate") {
checkDirectories(outputFileName);
writeToAggregateFile(outputFileName, result);
}
else if (outputMode.toLowerCase() === "property") {
var propName = fileNameToProperty(outputFileName);
if (!context.hasOwnProperty(propName)) {
context[propName] = [];
}
context[propName].push(result);
}
else if (outputMode.toLowerCase() === "ignore") {
// Do nothing...
winston.info(" >>> Nothing saved to file.");
}
else {
throw new Error("Unknown output directive '" + outputMode + "' in template " + templateFile);
}
};
let uniquePackages = new Set();
var processTemplateFileList = function(metaClass, templates, context) {
uniquePackages.add(metaClass.getPackageName());
templates.forEach(function(templateFile) {
processTemplateFile(metaClass, templateFile, context);
});
};
var execOnce = function(script, initType, typeName, typeValue, context) {
if (typeof(script.execCache) === "undefined") {
script.execCache = {};
}
var typeCache = script.execCache[initType];
if (typeCache === undefined) {
typeCache = {};
script.execCache[initType] = typeCache;
}
var execFlag = typeCache[typeName];
if (execFlag !== true) {
// We have never executed this combination yet...
typeCache[typeName] = true;
var funcName = "init" + initType;
var fn = script[funcName];
if (typeof(fn) === "function") {
var params = [];
if (context !== null) {
params.push(context);
}
params.push(typeValue);
fn.apply(script, params);
}
}
};
var loadPartials = function() {
winston.debug("Loading partials...");
var templateList = getFilesForPath("_partials", ".hbs");
templateList.forEach(function(templateFile) {
var parts = path.parse(templateFile);
var partialName = parts.name;
var templateData = fsx.readFileSync(templateFile, "utf8");
var partial = Handlebars.compile(templateData);
Handlebars.registerPartial(partialName, partial);
});
};
var fixFileName = function(fileName) {
if (fileName.startsWith(".")) {
// Relative file names need to be adjusted to absolute file names...
fileName = process.cwd() + "/" + fileName;
}
return fileName;
};
/**
* Supplements the "require()" module loader to fix up relative paths in scripts
* loaded at runtime.
*/
var requireScript = function(fileName) {
return require(fixFileName(fileName));
};
var loadHelpers = function() {
winston.debug("Loading helpers...");
var helperList = getFilesForPath("_helpers", ".js");
helperList.forEach(function(helperFile) {
var parts = path.parse(helperFile);
var helperName = parts.name;
var fn = requireScript(helperFile);
Handlebars.registerHelper(helperName, fn);
});
};
var validateModel = function(metaModel, projectScripts) {
let validationStatus = {
warnCount: 0,
errorCount: 0
}
// First, model integrity validation...
if (NodeMDA.Meta.validate(metaModel, validationStatus)) {
// Next, stack convention validation...
projectScripts.forEach(function(s) {
var script = requireScript(s);
if (typeof(script.validateModel) === "function") {
if (!script.validateModel(metaModel, validationStatus)) {
winston.error("Model failed validation by script " + s);
validationStatus.errorCount++;
}
}
});
}
var logLevel;
if (validationStatus.errorCount > 0) {
logLevel = "error";
}
else if (validationStatus.warnCount > 0) {
logLevel = "warn";
}
else {
logLevel = "info";
}
winston.log(logLevel, "Validation completed: " + validationStatus.errorCount + " errors, " + validationStatus.warnCount + " warnings");
return validationStatus;
}
mda.gen = function(modelFileName) {
winston.info("Starting code generation.");
winston.info("Discovering plugins for stack " + JSON.stringify(mda.Options.stack) + "...");
initGeneratorContext(mda.Options.stack);
winston.info("Validating stack...");
if (!validateContext()) {
return;
}
winston.info("Discovering plugin contents...");
getPluginFiles();
var scriptCount = 0;
var projectScripts = getFilesForPath("_mixin", ".js");
winston.info("Loading support scripts...");
projectScripts.forEach(function(s) {
scriptCount++;
var script = requireScript(s);
});
winston.info("Reading meta model...");
var metaModel = MetaModel.Reader.getMeta(modelFileName);
global._nodemda_runtime_model_ = metaModel;
winston.info("Validating model...");
let validationStatus = validateModel(metaModel, projectScripts);
if (validationStatus.errorCount > 0) {
return;
}
// Auto-load any Handlebar partials...
winston.debug("Loading helpers...");
loadHelpers();
winston.debug("Loading partials...");
loadPartials();
var handlebarsContext = { model: metaModel, options: NodeMDA.Options };
winston.info("Initializing platform support scripts...");
projectScripts.forEach(function(s) {
var script = requireScript(s);
if (typeof(script.initPlatform) === "function") {
script.initPlatform(handlebarsContext);
}
});
// Process each class...
winston.info("Processing classes...");
metaModel.classes.forEach(function(metaClass) {
winston.info(` Processing class ${metaClass.name}:`);
metaClass.stereotypes.forEach(function(stereotype) {
// Process any project scripts...
projectScripts.forEach(function(ps) {
var script = requireScript(ps);
execOnce(script, "Stereotype", stereotype.getName(), stereotype, handlebarsContext);
execOnce(script, "Class", metaClass.getName(), metaClass, handlebarsContext);
});
// Generate the templates...
var templates = getFilesForStereotype("templates", stereotype, ".hbs");
if (templates.length > 0) {
scriptCount++;
}
processTemplateFileList(metaClass, templates, handlebarsContext);
});
});
// Process any project template scripts...
winston.info("Initializing project wide templates...");
projectScripts.forEach(function(s) {
var script = requireScript(s);
if (typeof(script.initProjectTemplates) === "function") {
script.initProjectTemplates(handlebarsContext);
}
});
// Finally, processall remaining template files...
winston.info("Processing remaining templates...");
// Regular expression to detect files with `{...}` in the file name slot
const stereotypeMarkerRegex = /\/[^/]*\{[a-zA-Z0-9_]+?\}[^/]*$/;
allFiles.forEach(function(file) {
const virtualPath = file.virtualPath;
if (virtualPath.startsWith('plugins/templates') && !stereotypeMarkerRegex.test(virtualPath)) {
// This is a template file that is NOT stereotype specific...
const sourceFile = file.absolutePath;
if (sourceFile.endsWith(".hbs")) {
// This is a handlebarstemplate file to be processed...
processTemplateFile(null, sourceFile, handlebarsContext);
}
else {
// This is a boilerplate file to be copied...
const targetFile = getDefaultOutputFileName(mda.Options.output, sourceFile, null);
if (mda.Options.forceOverwrite ||!fsx.existsSync(targetFile)) {
winston.info("Creating boilerplate file " + targetFile);
checkDirectories(targetFile);
fsx.copyFileSync(sourceFile, targetFile);
}
}
}
});
closeAllAggregateFiles();
if (scriptCount === 0) {
winston.error("No script or template files located. Is this stack valid?");
}
else if (validationStatus.warnCount > 0) {
winston.warn(`Done (with ${validationStatus.warnCount} warnings). Be sure to review`);
}
else {
winston.info("Done.");
}
};
})(NodeMDA);