@udraft/core
Version:
uDraft is a language and stack agnostic code-generation tool that simplifies full-stack development by converting a single YAML file into code for rapid development.
759 lines (665 loc) • 24.4 kB
text/typescript
import * as fs from "fs";
import * as path from "path";
import _ from "lodash";
import { cwd } from "process";
import { PipelineCursor, RenderContent, RenderPath } from "../types/renderer";
import { UModule } from "./module";
import { URenderer } from "./renderer";
import { RendererRequiredError } from "../errors/renderer-required-error";
import { terminal as term } from "terminal-kit";
import { UDraftError } from "../errors/udraft-error";
import { parseDocument } from "yaml";
import { UModel } from "./model";
import { UFeature } from "./feature";
import { UAttribute } from "./attribute";
import { _array, _enum, _ref, _rootModule } from "../shortcuts/attributes";
import { UField } from "./field";
import { ParsingError } from "../errors/parsing-error";
import { $attr } from "../shortcuts/queries";
import {
JsonAttribute,
JsonDraft,
JsonFeature,
JsonField,
JsonModel,
JsonModule,
} from "../types/json";
export class UDraft {
private _modules: UModule[] = [];
private _attributes: UAttribute<any>[] = [];
private _workingDir?: string;
private _renderers: URenderer[] = [];
constructor() {}
$modules() {
return [...this._modules];
}
$workingDir() {
return this._workingDir ?? "";
}
$renderers() {
return this._renderers;
}
$renderer<Type = URenderer>(name: string): Type | null {
return (this.$renderers().find((r) => r.$name() == name) as Type) || null;
}
$requireRenderer<Type extends URenderer>(
fromRendererClass: URenderer,
name: string
): Type {
const renderer = this.$renderer<Type>(name);
if (renderer) return renderer;
throw new RendererRequiredError(fromRendererClass.$name(), name);
}
$attributes() {
return [...this._attributes];
}
attributes(attributes: UAttribute<any>[]) {
this.removeAttributes(attributes);
this._attributes = this._attributes.concat(attributes);
return this;
}
removeAttributes(attributes: UAttribute<any>[]) {
this._attributes = this._attributes.filter(
(attribute) => !attributes.some((a) => a.$name() == attribute.$name())
);
return this;
}
static load(filePath: string) {
const ext = path.extname(filePath);
const content = fs.readFileSync(filePath, "utf-8");
switch (ext) {
case ".yaml":
return UDraft.yaml(content);
case ".json":
return UDraft.json(content);
default:
throw new UDraftError(`Unsupported file extension: ${ext}`);
}
}
static yaml(yamlDraft: string) {
try {
const rawDraft = parseDocument(yamlDraft).toJSON();
return UDraft._parse(rawDraft);
} catch (e) {
if (e instanceof ParsingError)
term.red(`[uDraft] Parsing Error: `).red.bold(e.message + "\n");
else if (
["YAMLParseError", "YAMLWarning"].includes((e as any).name) ||
e instanceof ReferenceError
)
term
.red(`[uDraft] Error in YAML file: `)
.red.bold(`${(e as any).message}\n`);
else throw e;
return null;
}
}
static json(jsonDraft: string) {
try {
const rawDraft = JSON.parse(jsonDraft);
return UDraft._parse(rawDraft);
} catch (e) {
if (e instanceof ParsingError)
term.red(`[uDraft] Parsing Error: `).red.bold(e.message + "\n");
else if (e instanceof SyntaxError)
term.red(`[uDraft] Error in JSON file: `).red.bold(`${e.stack}\n`);
else throw e;
return null;
}
}
static _parse(rawDraft: any) {
if (!rawDraft?.draft) throw new ParsingError(`No draft found in the file`);
const draft = new UDraft();
const simpleTypes = ["string", "number", "int", "float", "boolean", "date"];
const modelTriggers: Record<string, ((model: UModel | null) => void)[]> =
{};
const models: Record<string, UModel> = {};
const addModelTrigger = (
modelName: string,
trigger: (model: UModel | null) => void
) => {
if (models[modelName]) {
trigger(models[modelName]);
return;
}
if (!modelTriggers[modelName]) modelTriggers[modelName] = [];
modelTriggers[modelName].push(trigger);
};
const emitModelUpdate = (modelName: string, model: UModel) => {
if (modelTriggers[modelName])
modelTriggers[modelName].forEach((trigger) => trigger(model));
};
const parseCallSignature = (signature: string) => {
const match = signature.trim().match(/^\$([^\(]+)(?:\(*([^\)]*)\))*$/);
if (!match) return null;
return {
fn: match[1].trim(),
args: (match[2] || "")
.split(",")
.map((arg) => arg.trim())
.filter((v) => !!v),
};
};
const parseFieldSignature = (signature: string) => {
const match = signature.trim().match(/^([^\(]+)\[([^\)]+)\]$/);
if (!match) return null;
return { name: match[1].trim(), type: match[2].trim() };
};
const parseFieldAttributeSignature = (signature: string) => {
const match = signature.trim().match(/^([^\(]+)(?:\(*(.+)\))*$/);
if (!match) return null;
if (match[1].match(/[\(\)]/g)) return null;
return {
name: match[1].trim(),
value: (match[2] || "").trim().replace(/\)$/, "") as any,
};
};
const parseModelSignature = (signature: string) => {
const match = signature.match(/[\~\+]([^\(]+)\(*([^\)]*)\)*/);
if (!match) return null;
return {
name: match[1].trim(),
extends: (match[2] || "")
.split(",")
.map((arg) => arg.trim())
.filter((arg) => !!arg),
};
};
const parseAttribute = (attributeKey: string, rawAttribute: any) => {
const match = attributeKey.match(/\/([^\(]+)\(*([^\)]*)\)*/);
if (!match) return null;
const attributeName = match[1].trim();
const extendsAttributes = (match[2] || "")
.split(",")
.map((arg) => arg.trim())
.filter((arg) => !!arg)
.map((arg) => {
const source = models[arg];
if (!source)
throw new ParsingError(
`Source model ${arg} not found when extending the ${attributeName} attribute`
);
const extended = source.$attribute(attributeName);
if (!extended)
throw new ParsingError(
`Attribute ${attributeName} not found in source model ${arg} when extending the ${attributeName} attribute`
);
return extended;
});
let finalRawAttribute =
typeof rawAttribute == "object" ? {} : rawAttribute;
const mergeAttributeValue = (value: any) => {
return _.mergeWith(finalRawAttribute, value, (objValue, srcValue) => {
if (_.isArray(objValue)) {
return objValue.concat(srcValue);
}
});
};
if (extendsAttributes.length > 0) {
extendsAttributes.forEach((extended) => {
const extendedValue = extended.$value();
if (
typeof extendedValue == "object" &&
typeof rawAttribute == "object"
) {
mergeAttributeValue(extendedValue);
} else finalRawAttribute = extendedValue;
});
if (typeof rawAttribute == "object") mergeAttributeValue(rawAttribute);
return new UAttribute(attributeName, finalRawAttribute);
} else return new UAttribute(attributeName, rawAttribute);
};
const parseModel = (modelKey: string, rawModel: any) => {
const isModel = modelKey[0] == "+";
const isEnum = modelKey[0] == "~";
if (!isModel && !isEnum) return null;
const modelSignature = parseModelSignature(modelKey);
if (!modelSignature)
throw new ParsingError(`Invalid model declaration: ${modelKey}`);
const modelName = modelSignature.name;
const model = new UModel(modelName);
if (isEnum) model.attributes([new UAttribute("enum", rawModel)]);
else {
const initialFieldNames = Object.keys(rawModel || {})
.map((fieldName) => {
const fieldSignature = parseFieldSignature(fieldName);
return fieldSignature?.name;
})
.filter((name) => !!name);
Object.keys(rawModel || {}).forEach((subModelKey: string) => {
const subModelData = rawModel[subModelKey];
const attr = parseAttribute(subModelKey, subModelData);
if (attr) return model.attributes([attr]);
const call = parseCallSignature(subModelKey);
if (call) {
switch (call.fn) {
case "pick":
const srcModelName = call.args[0];
let didPick = false;
addModelTrigger(srcModelName, (srcModel: UModel | null) => {
if (!srcModel)
throw new ParsingError(
`Source model ${srcModelName} not found when pick fields to ${modelName} model: ${subModelKey}}`
);
const fieldsToPick = (subModelData as string[])
.map((fieldName) => {
const renameField = fieldName.match(/([^>]+)\>([^>]*)/);
if (renameField)
return {
from: renameField[1].trim(),
to: renameField[2].trim(),
};
return { from: fieldName, to: fieldName };
})
.filter(
(fieldToPick) =>
!initialFieldNames.includes(fieldToPick.to)
);
const pickField = ({
from,
to,
}: {
from: string;
to: string;
}) => {
const srcField = srcModel.$field(from);
if (!srcField)
throw new ParsingError(
`Field ${from} not found in source model ${srcModelName} when picking fields to ${modelName} model: ${subModelKey}}`
);
if (from === to) model.fields([srcField]);
else model.fields([srcField.$clone(to)]);
};
if (!didPick) {
fieldsToPick.forEach(pickField);
didPick = true;
} else {
// Refresh picked fields that were not removed
fieldsToPick.forEach(({ from, to }) => {
if (model.$field(to)) pickField({ from, to });
});
}
});
break;
case "remove":
addModelTrigger(modelName, (updatedModel) => {
if (updatedModel)
updatedModel.remove(
(subModelData as string[]).filter(
(fieldToRemove) =>
!initialFieldNames.includes(fieldToRemove)
)
);
});
break;
default:
throw new ParsingError(
`Invalid call inside Model ${modelName}: ${subModelKey}`
);
}
return;
}
const signature = parseFieldSignature(subModelKey);
if (!signature)
throw new ParsingError(
`Invalid field signature inside Model ${modelName} : ${subModelKey}`
);
let refModelName = "";
if (!simpleTypes.includes(signature.type)) {
refModelName = signature.type;
if (signature.type[0] == "&") {
refModelName = signature.type.slice(1);
signature.type = "reference";
} else signature.type = "nested";
}
const field = new UField(signature.name, signature.type);
(subModelData || []).forEach((attrKey: string) => {
const attrSignature = parseFieldAttributeSignature(attrKey);
if (!attrSignature)
throw new ParsingError(
`Invalid field attribute inside field ${subModelKey} from ${modelName} model: ${attrKey}`
);
if (attrSignature.value)
attrSignature.value = eval(attrSignature.value);
else attrSignature.value = null;
field.attributes([
new UAttribute(attrSignature.name, attrSignature.value),
]);
});
if (refModelName) {
addModelTrigger(refModelName, (refModel: UModel | null) => {
if (!refModel)
throw new ParsingError(
`Model ${refModelName} not found to reference inside Model ${modelName}: ${subModelKey}`
);
field.attributes([new UAttribute("ref", refModel)]);
});
}
model.fields([field]);
});
modelSignature.extends.forEach((baseModelName) => {
let didExtend = false;
addModelTrigger(baseModelName, (baseModel: UModel | null) => {
if (!baseModel)
throw new ParsingError(
`Base model ${baseModelName} not found when extending the ${modelName} model: ${modelKey}}`
);
if (!didExtend) {
model.fields(
baseModel
.$fields()
.filter((f) => !initialFieldNames.includes(f.$name()))
);
didExtend = true;
} else {
// Refresh extended fields that were not removed
model.fields(
baseModel.$fields().filter((field) => baseModel.$field(field))
);
}
emitModelUpdate(modelName, model);
});
});
}
models[modelName] = model;
emitModelUpdate(modelName, model);
return model;
};
const parseModule = (moduleKey: string, rawModule: any) => {
const mod = new UModule(moduleKey);
Object.keys(rawModule || {}).forEach((subModKey: string) => {
const subModData = rawModule[subModKey];
const attr = parseAttribute(subModKey, subModData);
if (attr) return mod.attributes([attr]);
const model = parseModel(subModKey, subModData);
if (model) return mod.models([model]);
const feature = new UFeature(subModKey);
mod.features([feature]);
Object.keys(subModData || {}).forEach((subFeatKey: string) => {
const subFeatData = subModData[subFeatKey];
const featAttr = parseAttribute(subFeatKey, subFeatData);
if (featAttr) return feature.attributes([featAttr]);
if (subFeatKey == "input") {
let didSetInput = false;
Object.keys(subFeatData || {}).forEach((subInputKey: string) => {
if (didSetInput) return;
const subInputData = subFeatData[subInputKey];
const inputModel = parseModel(subInputKey, subInputData);
if (inputModel) {
feature.input(inputModel);
didSetInput = true;
}
});
if (!didSetInput)
addModelTrigger(subFeatData, (inputModel) => {
if (!inputModel)
throw new ParsingError(
`Model ${subFeatData} not found when setting input for ${feature.$name()} feature`
);
feature.input(inputModel);
});
}
if (subFeatKey == "output") {
let didSetOutput = false;
Object.keys(subFeatData || {}).forEach((subOutputKey: string) => {
if (didSetOutput) return;
const subOutputData = subFeatData[subOutputKey];
const outputModel = parseModel(subOutputKey, subOutputData);
if (outputModel) {
feature.output(outputModel);
didSetOutput = true;
}
});
if (!didSetOutput)
addModelTrigger(subFeatData, (outputModel) => {
if (!outputModel)
throw new ParsingError(
`Model ${subFeatData} not found when setting output for ${feature.$name()} feature`
);
feature.output(outputModel);
});
}
});
});
return mod;
};
Object.keys(rawDraft.draft || {}).forEach((rootKey: string) => {
const rootData = rawDraft.draft[rootKey];
const rootAttr = parseAttribute(rootKey, rootData);
if (rootAttr) return draft.attributes([rootAttr]);
const module = parseModule(rootKey, rootData);
if (module) return draft.modules([module]);
});
Object.keys(modelTriggers || {}).forEach((modelName) => {
if (models[modelName]) return;
modelTriggers[modelName].forEach((trigger) => {
trigger(null);
});
});
return draft;
}
extends(seed: UDraft) {
return this.modules(seed.$modules());
}
modules(modules: UModule[]) {
this.remove(modules);
this._modules = this._modules.concat(modules);
return this;
}
remove(modules: UModule[]) {
this._modules = this._modules.filter(
(module) => !modules.some((m) => m.$name() == module.$name())
);
return this;
}
private _goTo(workingDir: string) {
term.blue(`[uDraft] Working Directory: `).bold.magenta(`${workingDir}\n`);
this._workingDir = workingDir;
return this;
}
private _clear() {
term.blue(`[uDraft] Clear Renderers\n`);
this._renderers.forEach((renderer) => {
renderer.clear();
});
this._renderers = [];
return this;
}
begin(workingDir: string) {
return this._pipeline([]).goTo(workingDir);
}
private _pipeline(
renderers: URenderer[],
_controls?: {
waitFor: Promise<void>;
executionError: Promise<void>;
start: () => void;
error: (err: any) => void;
}
): PipelineCursor {
if (!_controls) {
_controls = {
start: () => {},
error: (err) => {},
waitFor: Promise.resolve(),
executionError: Promise.resolve(),
};
_controls.waitFor = new Promise((resolve, reject) => {
_controls!.start = resolve;
});
_controls.executionError = new Promise((resolve, reject) => {
_controls!.error = reject;
});
}
const execution = _controls.waitFor
.then(
() =>
new Promise<void>(async (resolve, reject) => {
try {
for (const renderer of renderers) {
await this.render(renderer);
}
resolve();
} catch (err) {
reject(err);
}
})
)
.catch((err) => {
if (!_controls) throw err;
if (err instanceof UDraftError) {
term.red(`[uDraft] Pipeline Error: `).black.bold(err.message + "\n");
} else _controls.error(err);
const halt = new Promise<void>(() => {});
return halt;
});
const cursor: PipelineCursor = {
goTo: (workingDir: string) => {
execution.then(() => this._goTo(workingDir));
return cursor;
},
clear: () => {
execution.then(() => this._clear());
return cursor;
},
pipeline: (renderers: URenderer[]) => {
return this._pipeline(renderers, {
..._controls,
waitFor: execution,
});
},
exec: () => {
term.blue(`[uDraft] uDraft: `).bold.green(`${$attr(this, "name")}\n`);
_controls.start();
return Promise.race([execution, _controls.executionError]).then(() => {
term.bold.green(`\n[uDraft] Done!\n\n`);
});
},
};
return cursor;
}
async render(renderer: URenderer) {
term.blue(`[uDraft] Rendering: `).bold.yellow(`${renderer.$name()}\n`);
await renderer.init(this);
const paths: RenderPath[] = renderer.$paths();
const contents: RenderContent[] = [];
for (const renderPath of paths) {
renderPath.path = renderPath.path.startsWith("/")
? renderPath.path
: path.join(cwd(), this.$workingDir(), renderPath.path);
const renderDir = path.dirname(renderPath.path);
if (!fs.existsSync(renderDir))
fs.mkdirSync(renderDir, { recursive: true });
let content = "";
if (fs.existsSync(renderPath.path))
content = fs.readFileSync(renderPath.path, "utf-8");
contents.push({
key: renderPath.key,
content,
meta: renderPath.meta,
});
}
await renderer.run(contents);
const modules = renderer.$selection().modules || [];
const models = renderer.$selection().models || [];
const features = renderer.$selection().features || [];
if (modules.length) {
term.white(`[uDraft] Selected Modules: `);
term.white.bold(
modules.map((module) => module.$name()).join(", ") + "\n"
);
}
if (models.length) {
term.white(`[uDraft] Selected Models: `);
term.white.bold(models.map((model) => model.$name()).join(", ") + "\n");
}
if (features.length) {
term.white(`[uDraft] Selected Features: `);
term.white.bold(
features.map((feature) => feature.$name()).join(", ") + "\n"
);
}
for (const renderPath of paths) {
const output = renderer.$output(renderPath.key);
if (output === null) continue;
const dir = path.dirname(renderPath.path);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(renderPath.path, output.content, "utf-8");
term
.white(`[uDraft] Output: `)
.bold.white(`${renderPath.key} `)
.black(
`${renderer
.$resolveRelativePath(cwd() + "/index.js", renderPath.path)
.replace(/^\.\//, "")}\n`
);
}
this._renderers.push(renderer);
return this;
}
$json(): JsonDraft {
const json: JsonDraft = {
attributes: [],
models: {},
modules: {},
};
const attrToJson = (attr: UAttribute<any>): JsonAttribute => ({
name: attr.$name(),
value: attr.$value(),
});
const fieldToJson = (field: UField): JsonField => ({
name: field.$name(),
type: field.$type(),
isArray: !!$attr(field, _array()),
ref: $attr(field, _ref())?.$name(),
attributes: field
.$attributes()
.filter((attr) => !["ref", "array"].includes(attr.$name()))
.map(attrToJson),
});
const modelToJson = (model: UModel): JsonModel => {
const enumData = $attr(model, _enum());
const jsonModel: JsonModel = {
name: model.$name(),
module: $attr(model, _rootModule())?.$name() ?? "",
enum: enumData ?? undefined,
fields: !enumData ? model.$fields().map(fieldToJson) : undefined,
attributes: model
.$attributes()
.filter((attr) => !["enum", "rootModule"].includes(attr.$name()))
.map(attrToJson),
};
if (!json.models[model.$name()]) json.models[model.$name()] = jsonModel;
return jsonModel;
};
this.$modules().forEach((mod) => {
const models: Record<string, JsonModel> = {};
const features: Record<string, JsonFeature> = {};
mod.$models().forEach((model) => {
if ($attr(mod, _rootModule()) != mod || !!models[model.$name()]) return;
models[model.$name()] = modelToJson(model);
});
mod.$features().forEach((feature) => {
const input = feature.$input();
const output = feature.$output();
features[feature.$name()] = {
name: feature.$name(),
attributes: feature.$attributes().map(attrToJson),
module: $attr(feature, _rootModule())?.$name() ?? "",
input: input ? modelToJson(input) : undefined,
output: output ? modelToJson(output) : undefined,
};
});
const jsonModule: JsonModule = {
name: mod.$name(),
attributes: mod.$attributes().map(attrToJson),
features,
models,
};
json.modules[mod.$name()] = jsonModule;
});
return json;
}
}