mirror of https://github.com/tycrek/ass
parent
db32bb1c29
commit
3660b6cc8b
@ -0,0 +1,9 @@
|
||||
import { TemplateCommandOp, TemplateCommandSchema } from 'ass';
|
||||
import { TemplateContext } from './executor';
|
||||
|
||||
export type TemplateCommand<N extends string, S extends TemplateCommandSchema> = {
|
||||
readonly name: N;
|
||||
readonly schema: S;
|
||||
|
||||
exec: (op: TemplateCommandOp<N, S>, ctx: TemplateContext) => string;
|
||||
};
|
@ -0,0 +1,28 @@
|
||||
import { TemplateSourceRange } from "ass";
|
||||
|
||||
export class TemplateError extends Error {
|
||||
range?: TemplateSourceRange;
|
||||
|
||||
constructor(msg: string, range?: TemplateSourceRange) {
|
||||
super(msg);
|
||||
|
||||
this.range = range;
|
||||
}
|
||||
|
||||
public format(): string {
|
||||
let format = '';
|
||||
|
||||
if (this.range) {
|
||||
format += this.range.file.code + '\n';
|
||||
format += ' '.repeat(this.range.from) + '^' + '~'.repeat(Math.max(this.range.to - this.range.from, 0)) + '\n';
|
||||
}
|
||||
|
||||
format += `${this.name}: ${this.message}`;
|
||||
|
||||
return format;
|
||||
}
|
||||
}
|
||||
|
||||
// template syntax error with token range, token range converted to source position
|
||||
// outside of prepareTemplate
|
||||
export class TemplateSyntaxError extends TemplateError {};
|
@ -0,0 +1,121 @@
|
||||
import { AssFile, AssUser, TemplateCommandOp, TemplateCommandSchema, TemplateOp } from 'ass';
|
||||
import { TemplateCommand } from './command';
|
||||
import { TemplateError } from './error';
|
||||
|
||||
export class TemplateContext {
|
||||
public readonly owner: TemplateExecutor;
|
||||
|
||||
public uploader: AssUser;
|
||||
public file: AssFile;
|
||||
|
||||
constructor(owner: TemplateExecutor, uploader: AssUser, file: AssFile) {
|
||||
this.owner = owner;
|
||||
this.uploader = uploader;
|
||||
this.file = file;
|
||||
}
|
||||
}
|
||||
|
||||
export class TemplateExecutor {
|
||||
private commands: { [index: string]: TemplateCommand<any, any> } = {};
|
||||
|
||||
// register a template command globally
|
||||
public registerCommand<N extends string, S extends TemplateCommandSchema>(name: N, attrs: S, cmd: (op: TemplateCommandOp<N, S>, ctx: TemplateContext) => string) {
|
||||
if (this.commands[name] == null) {
|
||||
this.commands[name] = {
|
||||
name: name,
|
||||
schema: attrs,
|
||||
exec: cmd
|
||||
};
|
||||
} else throw new Error(`Template command "${name}" already exists`);
|
||||
}
|
||||
|
||||
public createContext(uploader: AssUser, file: AssFile) {
|
||||
return new TemplateContext(this, uploader, file);
|
||||
}
|
||||
|
||||
// expects template to be valid and does not preform runtime checks.
|
||||
// run validateTemplate first
|
||||
public executeTemplate(op: TemplateOp, ctx: TemplateContext): string {
|
||||
switch (typeof op) {
|
||||
case 'string': return op;
|
||||
case 'object': return this.commands[op.op].exec(op, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
public validateTemplate(op: TemplateOp, ctx: TemplateContext): void {
|
||||
if (typeof op == 'string') return;
|
||||
|
||||
if (this.commands[op.op] != null) {
|
||||
let cmd = this.commands[op.op].schema as TemplateCommandSchema;
|
||||
|
||||
if (cmd.named) {
|
||||
for (let name in cmd.named) {
|
||||
let arg = cmd.named[name];
|
||||
|
||||
// @ts-ignore
|
||||
if (arg.required && op.named[name] == null) {
|
||||
throw new TemplateError(`Required template argument "${name}" is missing.`, op.srcRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let arg of op.args) this.validateTemplate(arg, ctx);
|
||||
for (let name in op.named) {
|
||||
if (!cmd.named || cmd.named[name] == null) {
|
||||
let arg = (op.named as {[index:string]:TemplateOp})[name] as TemplateOp | undefined;
|
||||
|
||||
// @ts-ignore
|
||||
throw new TemplateError(`Unknown template argument "${name}".`, {
|
||||
file: op.srcRange.file,
|
||||
from: (typeof arg == 'object' && arg.srcRange.from - 1 - name.length) || op.srcRange.from,
|
||||
to: (typeof arg == 'object' && arg.srcRange.to) || op.srcRange.to
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
this.validateTemplate(op.named[name]!, ctx);
|
||||
}
|
||||
} else throw new TemplateError(`Template command "${op.op}" does not exist.`, op.srcRange);
|
||||
}
|
||||
|
||||
// creates an executor with the default commands.
|
||||
public static createExecutor(): TemplateExecutor {
|
||||
let ex = new TemplateExecutor();
|
||||
|
||||
// joins two strings
|
||||
ex.registerCommand('concat', {}, (op, ctx) => {
|
||||
return op.args.reduce((a: string, b): string => a + ctx.owner.executeTemplate(b, ctx), "");
|
||||
});
|
||||
|
||||
// converts a number to a file size
|
||||
ex.registerCommand('formatbytes', {
|
||||
named: { unit: {} }
|
||||
}, (op, ctx) => {
|
||||
let value = ctx.owner.executeTemplate(op.args[0], ctx);
|
||||
|
||||
let exponent = (op.named.unit != null && { 'b': 0, 'kb': 1, 'mb': 2, 'gb': 3, 'tb': 4 }[ctx.owner.executeTemplate(op.named.unit, ctx)])
|
||||
|| Math.max(Math.min(Math.floor(Math.log10(Number(value))/3), 4), 0);
|
||||
|
||||
return `${(Number(value) / 1000 ** exponent).toFixed(2)}${['b', 'kb', 'mb', 'gb', 'tb'][exponent]}`;
|
||||
});
|
||||
|
||||
// gets the size of the active file
|
||||
ex.registerCommand('filesize', {}, (op, ctx) => {
|
||||
return ctx.file.size.toString();
|
||||
});
|
||||
|
||||
// gets the uploader of the active file
|
||||
ex.registerCommand('uploader', {}, (op, ctx) => {
|
||||
return ctx.uploader.username;
|
||||
});
|
||||
|
||||
// selects a random argument
|
||||
ex.registerCommand('random', {}, (op, ctx) => {
|
||||
if (op.args.length > 0) {
|
||||
return ctx.owner.executeTemplate(op.args[Math.round(Math.random() * (op.args.length - 1))], ctx);
|
||||
} else throw new TemplateError('Random without arguments');
|
||||
});
|
||||
|
||||
return ex;
|
||||
}
|
||||
}
|
@ -0,0 +1,270 @@
|
||||
import { TemplateOp, TemplateSource } from 'ass';
|
||||
import { TemplateSyntaxError } from './error';
|
||||
|
||||
enum TokenType {
|
||||
T_OPEN, T_CLOSE,
|
||||
PIPE, EQUALS,
|
||||
TEXT,
|
||||
};
|
||||
|
||||
type TemplateToken = {
|
||||
type : TokenType;
|
||||
data?: string;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
// tree used by findReplacement to select the best amp-substitution
|
||||
type TemplateAmpNode = { [index: string]: TemplateAmpNode | string | undefined; }
|
||||
const TEMPLATE_AMP_SUBSTITUTIONS: TemplateAmpNode = {
|
||||
e: { q: { $: '=' } },
|
||||
p: { i: { p: { e: { $: '|' } } } },
|
||||
t: {
|
||||
c: { l: { o: { s: { e: { $: '}}' } } } } },
|
||||
o: { p: { e: { n: { $: '{{' } } } }
|
||||
}
|
||||
};
|
||||
|
||||
function getTemplateTokens(src: string): TemplateToken[] {
|
||||
let tokens: TemplateToken[] = [];
|
||||
let buf : string = '';
|
||||
let pos : number = 0;
|
||||
|
||||
// digs through TEMPLATE_AMP_SUBSTITUTIONS to find
|
||||
// longest possible string to replace
|
||||
function findReplacement() {
|
||||
let raw = "";
|
||||
let bestpos: number | null = null;
|
||||
let best: string | null = null;
|
||||
let node = TEMPLATE_AMP_SUBSTITUTIONS;
|
||||
|
||||
while (true) {
|
||||
if (pos >= src.length) break;
|
||||
if (!/[a-z]/.test(src[pos])) break;
|
||||
|
||||
if (node[src[pos]] != null) { // enter the thing
|
||||
node = node[src[pos]] as TemplateAmpNode;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (node.$ != null) {
|
||||
best = node.$ as string;
|
||||
bestpos = pos;
|
||||
}
|
||||
|
||||
raw += src[pos++];
|
||||
}
|
||||
|
||||
if (best != null) {
|
||||
pos = bestpos! + 1;
|
||||
return best;
|
||||
}
|
||||
|
||||
return `&${raw}`;
|
||||
}
|
||||
|
||||
for (; pos < src.length; pos++) {
|
||||
let lp = pos;
|
||||
|
||||
if (pos + 1 < src.length && src[pos] == '{' && src[pos + 1] == '{') {
|
||||
tokens.push({
|
||||
type: TokenType.T_OPEN,
|
||||
from: pos,
|
||||
to: pos + 1
|
||||
});
|
||||
pos++;
|
||||
} else if (pos + 1 < src.length && src[pos] == '}' && src[pos + 1] == '}') {
|
||||
tokens.push({
|
||||
type: TokenType.T_CLOSE,
|
||||
from: pos,
|
||||
to: pos + 1
|
||||
});
|
||||
pos++;
|
||||
} else if (src[pos] == '|') {
|
||||
tokens.push({
|
||||
type: TokenType.PIPE,
|
||||
from: pos,
|
||||
to: pos
|
||||
});
|
||||
} else if (src[pos] == '=') {
|
||||
tokens.push({
|
||||
type: TokenType.EQUALS,
|
||||
from: pos,
|
||||
to: pos
|
||||
});
|
||||
} else if (src[pos] == '&') {
|
||||
pos++;
|
||||
buf += findReplacement();
|
||||
pos--; continue;
|
||||
} else {
|
||||
buf += src[pos];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (buf.length) {
|
||||
tokens.splice(-1, 0, {
|
||||
type: TokenType.TEXT,
|
||||
data: buf,
|
||||
from: lp - buf.length,
|
||||
to: lp - 1
|
||||
});
|
||||
buf = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (buf.length) tokens.push({
|
||||
type: TokenType.TEXT,
|
||||
data: buf,
|
||||
from: src.length - buf.length,
|
||||
to: src.length
|
||||
});
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function prepareTemplate(src: string): TemplateOp {
|
||||
type ParserStackEntry = {
|
||||
pos: number
|
||||
};
|
||||
|
||||
let tokens = getTemplateTokens(src);
|
||||
let pos = 0;
|
||||
|
||||
let stack: ParserStackEntry[] = [];
|
||||
|
||||
function stackPush() {
|
||||
stack.push({ pos: pos });
|
||||
}
|
||||
|
||||
function stackDrop() {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
let file: TemplateSource = { code: src };
|
||||
|
||||
// parse the text part of stuff. like uh
|
||||
// you know uh like uh this part
|
||||
// V---V V V-V V
|
||||
// Hello {Concat|W|ORL|D}
|
||||
function parseConcat(root: boolean = false): TemplateOp {
|
||||
let joined: TemplateOp[] = [];
|
||||
|
||||
let start = pos;
|
||||
|
||||
stackPush();
|
||||
|
||||
out: while (pos < tokens.length) {
|
||||
switch (tokens[pos].type) {
|
||||
case TokenType.TEXT:
|
||||
joined.push(tokens[pos++].data!);
|
||||
continue out;
|
||||
case TokenType.EQUALS:
|
||||
if (root == true) throw new TemplateSyntaxError('Unexpected equals', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
case TokenType.PIPE:
|
||||
if (root == true) throw new TemplateSyntaxError('Unexpected pipe', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
case TokenType.T_CLOSE:
|
||||
if (root == true) throw new TemplateSyntaxError('Unexpected closing tag', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
break out;
|
||||
case TokenType.T_OPEN:
|
||||
joined.push(parseTemplate());
|
||||
}
|
||||
}
|
||||
|
||||
stackDrop();
|
||||
|
||||
return joined.length == 1 ? joined[0] : {
|
||||
op: "concat",
|
||||
named: {},
|
||||
args: joined,
|
||||
srcRange: {
|
||||
file: file,
|
||||
from: tokens[start].from,
|
||||
to: tokens[pos - 1].to
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// parse templates
|
||||
function parseTemplate(): TemplateOp {
|
||||
let name: string;
|
||||
let args: TemplateOp[] = [];
|
||||
let nargs: {[index: string]: TemplateOp} = {};
|
||||
let start = pos;
|
||||
|
||||
stackPush();
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type == TokenType.T_OPEN) {
|
||||
pos++;
|
||||
} else throw new Error('Catastrophic failure');
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type == TokenType.TEXT) {
|
||||
name = tokens[pos++].data!;
|
||||
} else if (pos < tokens.length) {
|
||||
if (tokens[pos].type == TokenType.T_CLOSE) {
|
||||
throw new TemplateSyntaxError('Template name missing', { file: file, from: tokens[pos - 1].from, to: tokens[pos].to });
|
||||
} else throw new TemplateSyntaxError('Expected template name', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
} else throw new TemplateSyntaxError('Unexpected end of file');
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type == TokenType.PIPE) {
|
||||
pos++;
|
||||
|
||||
out: while (pos < tokens.length) {
|
||||
let argStart = pos;
|
||||
|
||||
let arg = parseConcat();
|
||||
|
||||
// this is some really nasty control flow im so sorry
|
||||
if (pos < tokens.length) {
|
||||
switch (tokens[pos].type) {
|
||||
case TokenType.EQUALS: // named arguments
|
||||
if (typeof arg != 'string') {
|
||||
throw new TemplateSyntaxError('Argument name must be a plain string', { file: file, from: tokens[argStart].from, to: tokens[pos - 1].to });
|
||||
}
|
||||
|
||||
pos++;
|
||||
if (pos < tokens.length) {
|
||||
let arg2 = parseConcat();
|
||||
nargs[arg] = arg2;
|
||||
if (pos < tokens.length) {
|
||||
switch (tokens[pos].type) {
|
||||
case TokenType.T_CLOSE: break out;
|
||||
case TokenType.PIPE: pos++;
|
||||
}
|
||||
} else throw new TemplateSyntaxError('syntax error');
|
||||
} else throw new TemplateSyntaxError('syntax error');
|
||||
break;
|
||||
case TokenType.T_CLOSE:
|
||||
args.push(arg);
|
||||
break out;
|
||||
case TokenType.PIPE:
|
||||
args.push(arg);
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (pos < tokens.length && tokens[pos].type != TokenType.T_CLOSE) {
|
||||
throw new TemplateSyntaxError('Expected arguments or closing tag', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
}
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type == TokenType.T_CLOSE) {
|
||||
pos++;
|
||||
} else throw new TemplateSyntaxError('Template closing tag missing');
|
||||
|
||||
stackDrop();
|
||||
|
||||
return {
|
||||
op: name.toLocaleLowerCase(),
|
||||
named: nargs,
|
||||
args: args,
|
||||
srcRange: {
|
||||
file: file,
|
||||
from: tokens[start].from,
|
||||
to: tokens[pos - 1].to
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let result = parseConcat(true);
|
||||
return result;
|
||||
}
|
Loading…
Reference in new issue