@devaloop/prettier-plugin-devalang
Version:
Prettier plugin for Devalang formatting
756 lines (662 loc) • 20.2 kB
text/typescript
import { Doc, AstPath, doc } from "prettier";
import {
ProgramNode,
BlockContext,
BlankLine,
BpmDeclaration,
BankDeclaration,
LoopBlock,
TriggerCall,
GroupBlock,
CallStatement,
SpawnStatement,
IfStatement,
ObjectProperty,
UsePluginStatement,
OnBlock,
EmitStatement,
PrintStatement,
FnBlock,
ArrowCallStatement,
} from "./interfaces/statement";
import { print } from "./printer";
import { DurationValue } from "./types/duration";
import { Expression } from "./types/expression";
import { Node } from "./types/node";
const { hardline } = doc.builders;
// exported placeholders filled by parse()
export let SOURCE_TEXT = "";
export let SOURCE_LINES: string[] = [];
/**
* Parses a value string into an Expression.
* @param value string
* @returns Expression
*/
const parseValue = (value: string): Expression => {
if (!value) {
return { type: "ObjectLiteral", properties: [] };
}
value = value.trim();
if (value === "true") {
return { type: "BooleanLiteral", value: true };
}
if (value === "false") {
return { type: "BooleanLiteral", value: false };
}
if (value === "auto") {
return { type: "Identifier", name: "auto" };
}
if (/^\d+(\.\d+)?$/.test(value)) {
return { type: "NumberLiteral", value: parseFloat(value) };
}
if (/^".*"$/.test(value)) {
return { type: "StringLiteral", value: value.slice(1, -1) };
}
if (value.startsWith("synth ")) {
return { type: "SynthReference", name: value.slice(6).trim() };
}
const objectMatch = value.match(/^\{(.*)\}$/);
if (objectMatch) {
const body = objectMatch[1].trim();
if (body === "") {
return { type: "ObjectLiteral", properties: [] };
}
const props: ObjectProperty[] = body
.split(",")
.map((pair) => pair.trim())
.filter((pair) => pair.includes(":"))
.map((pair) => {
const [k, v] = pair.split(":").map((s) => s.trim());
return {
type: "ObjectProperty",
key: k,
value: parseValue(v),
};
});
return {
type: "ObjectLiteral",
properties: props,
};
}
return { type: "Identifier", name: value };
};
/**
* Parses a block of nodes
* @param block Node[]
* @returns Doc[]
*/
export const parseBlock = (block: Node[]): string => {
const parts: string[] = [];
for (const node of block) {
const printed = print(
{ getValue: () => node } as AstPath<Node>,
{},
() => "",
);
if (printed === undefined || printed === "") continue;
// printed may be a string or an array; coerce to string
const str = Array.isArray(printed) ? printed.join("") : String(printed);
parts.push(str);
}
// join with newline so parent will handle block separation
return parts.join("\n");
};
/**
* Parses a Devalang program from a string into an AST.
* @param text string
* @returns ProgramNode
*/
export const parse = (text: string): ProgramNode => {
// expose original source for the printer to reconstruct exact line breaks/whitespace
exports.SOURCE_TEXT = text;
exports.SOURCE_LINES = text.split(/\r?\n/);
const lines = text.split("\n");
const body: Node[] = [];
const stack: (BlockContext & { bodyIndent: number | null })[] = [];
let lastNonBlankLineIndex: number | null = null;
function pushToBodyOrBlock(
indent: number,
node: Node,
lineContent: string,
lineIndex: number,
) {
// attach original indentation and line/leading info to the node for the printer to reuse
(node as any)._indent = indent;
(node as any)._leading = (lineContent || "").slice(0, indent);
(node as any)._line = lineIndex;
for (let j = stack.length - 1; j >= 0; j--) {
const parent = stack[j];
if ("body" in parent.node) {
if (parent.bodyIndent === null && node.type !== "BlankLine") {
parent.bodyIndent = indent;
}
if (parent.bodyIndent !== null && indent >= parent.bodyIndent) {
parent.node.body.push(node);
return;
}
}
}
body.push(node);
}
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const indent = line.search(/\S|$/);
const trimmedLine = line.trim();
if (trimmedLine === "") {
const blank: BlankLine = { type: "BlankLine" };
pushToBodyOrBlock(indent, blank, lines[i], i);
// If we are inside a "flat" block (bodyIndent === parent.indent), a blank line ends that flat block
while (
stack.length > 0 &&
stack[stack.length - 1].bodyIndent !== null &&
stack[stack.length - 1].bodyIndent === stack[stack.length - 1].indent
) {
stack.pop();
}
continue;
}
lastNonBlankLineIndex = i;
// Pre-close blocks for same-indent header lines (avoid nesting control blocks under flat bodies)
const headerStarts = [
"if ",
"else if ",
"else",
"group ",
"loop ",
"on ",
"fn ",
];
const isHeaderLike = headerStarts.some((h) => trimmedLine.startsWith(h));
if (isHeaderLike) {
while (
stack.length > 0 &&
stack[stack.length - 1].bodyIndent !== null &&
stack[stack.length - 1].bodyIndent === stack[stack.length - 1].indent &&
indent === stack[stack.length - 1].indent
) {
stack.pop();
}
}
if (trimmedLine.startsWith("#")) {
pushToBodyOrBlock(
indent,
{ type: "Comment", value: trimmedLine },
lines[i],
i,
);
continue;
}
if (trimmedLine.startsWith("bpm ")) {
const decl: BpmDeclaration = {
type: "BpmDeclaration",
identifier: trimmedLine.split(" ")[1],
};
pushToBodyOrBlock(indent, decl, lines[i], i);
continue;
}
if (trimmedLine.startsWith("bank ")) {
const bankMatch = trimmedLine.match(
/^bank\s+([^\s]+)(?:\s+as\s+([a-zA-Z_][\w]*))?\s*$/,
);
if (bankMatch) {
const [, identifier, alias] = bankMatch;
const decl: BankDeclaration = {
type: "BankDeclaration",
identifier,
...(alias ? { alias } : {}),
};
pushToBodyOrBlock(indent, decl, lines[i], i);
continue;
}
}
if (trimmedLine.startsWith("@use ")) {
const useMatch = trimmedLine.match(
/^@use\s+([^\s]+)(?:\s+as\s+([a-zA-Z_][\w]*))?\s*$/,
);
if (useMatch) {
const [, name, alias] = useMatch;
const useStmt: UsePluginStatement = {
type: "UsePlugin",
name,
...(alias ? { alias } : {}),
};
pushToBodyOrBlock(indent, useStmt, lines[i], i);
continue;
}
}
if (trimmedLine.startsWith("let ")) {
const letMatch = trimmedLine.match(/^let\s+([a-zA-Z_][\w]*)\s*=\s*(.*)$/);
if (!letMatch) {
continue;
}
const [, name, afterEq] = letMatch;
let rawValue = afterEq;
// Support multi-line value blocks (e.g., synth sine { ... }) preserving raw
let braceDepth = 0;
for (const ch of rawValue) {
if (ch === "{") braceDepth++;
if (ch === "}") braceDepth--;
}
if (braceDepth > 0) {
let j = i + 1;
while (braceDepth > 0 && j < lines.length) {
const nextLineRaw = lines[j];
for (const c of nextLineRaw) {
if (c === "{") braceDepth++;
if (c === "}") braceDepth--;
}
rawValue += "\n" + nextLineRaw;
j++;
}
i = j - 1;
pushToBodyOrBlock(
indent,
{
type: "LetDeclaration",
name,
value: { type: "RawLiteral", value: rawValue } as any,
},
lines[i],
i,
);
continue;
} else {
const value = parseValue(rawValue);
pushToBodyOrBlock(
indent,
{ type: "LetDeclaration", name, value },
lines[i],
i,
);
continue;
}
}
// on <event>:
const onMatch = trimmedLine.match(/^on\s+(.+):$/);
if (onMatch) {
const onNode: OnBlock = {
type: "On",
event: onMatch[1].trim(),
body: [],
};
pushToBodyOrBlock(indent, onNode, lines[i], i);
stack.push({ type: "On", indent, node: onNode, bodyIndent: null });
continue;
}
// fn name(params):
const fnMatch = trimmedLine.match(
/^fn\s+([a-zA-Z_][\w]*)\s*\((.*)\)\s*:\s*$/,
);
if (fnMatch) {
const [, fname, params] = fnMatch;
const fnNode: FnBlock = {
type: "Fn",
name: fname,
params: params.trim(),
body: [],
};
pushToBodyOrBlock(indent, fnNode, lines[i], i);
stack.push({ type: "Fn", indent, node: fnNode, bodyIndent: null });
continue;
}
// emit <name> [payload]
const emitMatch = trimmedLine.match(
/^emit\s+([a-zA-Z_$][\w$]*)(?:\s+(.*))?$/,
);
if (emitMatch) {
const [, ename, payloadRaw] = emitMatch;
const emitStmt: EmitStatement = {
type: "Emit",
name: ename,
...(payloadRaw ? { payload: payloadRaw } : {}),
};
pushToBodyOrBlock(indent, emitStmt, lines[i], i);
continue;
}
// print <expression...>
const printMatch = trimmedLine.match(/^print\s+(.+)$/);
if (printMatch) {
const printStmt: PrintStatement = {
type: "Print",
expression: printMatch[1],
};
pushToBodyOrBlock(indent, printStmt, lines[i], i);
continue;
}
// IF
const ifMatch = trimmedLine.match(/^if\s+(.+):$/);
if (ifMatch) {
const ifBody: Node[] = [];
const ifNode: IfStatement = {
type: "If",
condition: ifMatch[1].trim(),
body: ifBody,
elseIfs: [],
};
pushToBodyOrBlock(indent, ifNode, lines[i], i);
stack.push({ type: "If", indent, node: ifNode, bodyIndent: null });
continue;
}
const elseIfMatch = trimmedLine.match(/^else if\s+(.+):$/);
if (elseIfMatch) {
const parentIf = stack
.slice()
.reverse()
.find((s) => s.type === "If")?.node as IfStatement | undefined;
if (parentIf) {
const block: Node[] = [];
parentIf.elseIfs.push({
condition: elseIfMatch[1].trim(),
body: block,
});
pushToBodyOrBlock(indent, { type: "BlankLine" }, lines[i], i);
stack.push({
type: "ElseIf",
indent,
node: { body: block },
bodyIndent: null,
});
continue;
}
}
const elseMatch = trimmedLine.match(/^else\s*:\s*$/);
if (elseMatch) {
const parentIf = stack
.slice()
.reverse()
.find((s) => s.type === "If")?.node as IfStatement | undefined;
if (parentIf) {
parentIf.alternate = [];
pushToBodyOrBlock(indent, { type: "BlankLine" }, lines[i], i);
stack.push({
type: "Else",
indent,
node: { body: parentIf.alternate },
bodyIndent: null,
});
continue;
}
}
const loopMatch = trimmedLine.match(/^loop\s+([^\s:]+)\s*:/);
if (loopMatch) {
const rawIterator = parseValue(loopMatch[1]);
const loop: LoopBlock = {
type: "Loop",
iterator: {
type:
rawIterator.type === "Identifier" ? "Identifier" : "NumberLiteral",
value:
rawIterator.type === "NumberLiteral"
? rawIterator.value
: rawIterator.type === "Identifier"
? rawIterator.name
: 0,
},
body: [],
};
pushToBodyOrBlock(indent, loop, lines[i], i);
stack.push({ type: "Loop", indent, node: loop, bodyIndent: null });
continue;
}
const triggerMatch = trimmedLine.match(/^\.(\S+)(?:\s+(.*))?$/);
if (triggerMatch) {
const [, name, rawArgsInitial] = triggerMatch;
let rawArgs = rawArgsInitial ?? "";
let braceDepth = 0;
for (const char of rawArgs) {
if (char === "{") braceDepth++;
if (char === "}") braceDepth--;
}
let j = i + 1;
while (braceDepth > 0 && j < lines.length) {
const nextLine = lines[j].trim();
rawArgs += " " + nextLine;
for (const char of nextLine) {
if (char === "{") braceDepth++;
if (char === "}") braceDepth--;
}
j++;
}
i = j - 1;
const parts = splitArguments(rawArgs);
let duration: DurationValue | undefined = undefined;
const parsedArgs: Expression[] = [];
for (const part of parts) {
if (/^\{.*\}$/.test(part)) {
const expr = parseValue(part);
if (expr.type === "ObjectLiteral") {
for (const prop of expr.properties) {
if (prop.key === "duration") {
const val = prop.value;
if (val.type === "NumberLiteral") {
duration = { type: "Milliseconds", value: val.value };
} else if (
(val.type === "StringLiteral" &&
/^\d+\/\d+$/.test(val.value)) ||
(val.type === "Identifier" && /^\d+\/\d+$/.test(val.name))
) {
duration = {
type: "BeatDuration",
value: val.type === "StringLiteral" ? val.value : val.name,
};
}
} else {
parsedArgs.push({
type: "ObjectProperty",
key: prop.key,
value: prop.value,
});
}
}
} else if (expr.type === "Identifier") {
parsedArgs.push({ type: "Identifier", name: expr.name });
}
} else if (/^\d+\/\d+$/.test(part)) {
if (!duration) {
duration = { type: "BeatDuration", value: part };
}
} else if (/^\d+(\.\d+)?$/.test(part)) {
if (!duration) {
duration = { type: "Milliseconds", value: parseFloat(part) };
}
} else if (/^auto$/i.test(part)) {
if (!duration) {
duration = { type: "AutoDuration", value: "auto" };
}
} else {
parsedArgs.push({ type: "Identifier", name: part });
}
}
const trigger: TriggerCall = {
type: "Trigger",
name: `.${name}`,
args: parsedArgs,
...(duration ? { duration } : {}),
};
pushToBodyOrBlock(indent, trigger, lines[i], i);
continue;
}
const importMatch = trimmedLine.match(
/^@import\s*\{\s*([^}]+)\s*\}\s*from\s*"([^"]+)"/,
);
if (importMatch) {
const [, idList, fromPath] = importMatch;
const identifiers = idList.split(",").map((s) => s.trim());
pushToBodyOrBlock(
indent,
{ type: "ImportStatement", identifiers, from: fromPath },
lines[i],
i,
);
continue;
}
const exportMatch = trimmedLine.match(/^@export\s*\{\s*([^}]+)\s*\}/);
if (exportMatch) {
const [, idList] = exportMatch;
const identifiers = idList.split(",").map((s) => s.trim());
pushToBodyOrBlock(
indent,
{ type: "ExportStatement", identifiers },
lines[i],
i,
);
continue;
}
const loadMatch = trimmedLine.match(
/^@load\s+"([^"]+)"\s+as\s+([a-zA-Z_][\w]*)/,
);
if (loadMatch) {
const [, path, alias] = loadMatch;
pushToBodyOrBlock(
indent,
{ type: "LoadSample", path, alias },
lines[i],
i,
);
continue;
}
const groupMatch = trimmedLine.match(/^group\s+([a-zA-Z_][\w]*)\s*:/);
if (groupMatch) {
const group: GroupBlock = {
type: "Group",
name: groupMatch[1],
body: [],
};
body.push(group);
stack.push({ type: "Group", indent, node: group, bodyIndent: null });
continue;
}
const callMatch = trimmedLine.match(/^call\s+([a-zA-Z_][\w]*)$/);
if (callMatch) {
const call: CallStatement = { type: "Call", identifier: callMatch[1] };
pushToBodyOrBlock(indent, call, lines[i], i);
continue;
}
const spawnMatch = trimmedLine.match(/^spawn\s+([a-zA-Z_][\w]*)$/);
if (spawnMatch) {
const spawn: SpawnStatement = {
type: "Spawn",
identifier: spawnMatch[1],
};
pushToBodyOrBlock(indent, spawn, lines[i], i);
continue;
}
const sleepMatch = trimmedLine.match(/^sleep\s+(.*)$/);
if (sleepMatch) {
const expr = parseValue(sleepMatch[1]);
pushToBodyOrBlock(indent, { type: "Sleep", value: expr }, lines[i], i);
continue;
}
const arrowStart = trimmedLine.match(
/^([a-zA-Z_][\w]*)\s*->\s*([a-zA-Z_][\w]*)\((.*)$/,
);
if (arrowStart) {
const [, target, method, firstArgsPart] = arrowStart;
let argsAccum = firstArgsPart;
let depth = 0;
let inString = false;
for (let k = 0; k < argsAccum.length; k++) {
const ch = argsAccum[k];
if (ch === '"' && argsAccum[k - 1] !== "\\") inString = !inString;
if (!inString) {
if (ch === "(" || ch === "{" || ch === "[") depth++;
if (ch === ")" || ch === "}" || ch === "]") depth--;
}
}
// do not force depth; if it closed on same line, keep depth = 0
let j = i + 1;
while (depth > 0 && j < lines.length) {
const next = lines[j].trim();
for (let k = 0; k < next.length; k++) {
const c = next[k];
if (c === '"' && next[k - 1] !== "\\") inString = !inString;
if (!inString) {
if (c === "(" || c === "{" || c === "[") depth++;
if (c === ")" || c === "}" || c === "]") depth--;
}
}
argsAccum += " " + next;
j++;
}
i = j - 1;
if (argsAccum.endsWith(")")) {
const idx = argsAccum.lastIndexOf(")");
argsAccum = argsAccum.slice(0, idx);
}
const args = splitCallArguments(argsAccum);
const arrow: ArrowCallStatement = {
type: "ArrowCall",
target,
method,
argsRaw: args,
};
pushToBodyOrBlock(indent, arrow, lines[i], i);
continue;
}
while (
stack.length > 0 &&
indent <
(stack[stack.length - 1].bodyIndent ?? stack[stack.length - 1].indent)
) {
stack.pop();
}
pushToBodyOrBlock(
indent,
{ type: "Unknown", value: trimmedLine },
lines[i],
i,
);
continue;
}
return { type: "Program", body };
};
const splitArguments = (argStr: string): string[] => {
const args: string[] = [];
let current = "";
let depth = 0;
let inString = false;
for (let i = 0; i < argStr.length; i++) {
const char = argStr[i];
if (char === '"' && argStr[i - 1] !== "\\") {
inString = !inString;
}
if (!inString) {
if (char === "{") depth++;
if (char === "}") depth--;
}
const isSplitter =
(char === " " || char === ",") && depth === 0 && !inString;
if (isSplitter) {
if (current.trim()) args.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current.trim()) args.push(current.trim());
return args;
};
// Split arrow call arguments by commas at top level (ignoring nested (), {}, [] and strings)
const splitCallArguments = (argStr: string): string[] => {
const args: string[] = [];
let current = "";
let depth = 0;
let inString = false;
for (let i = 0; i < argStr.length; i++) {
const char = argStr[i];
if (char === '"' && argStr[i - 1] !== "\\") inString = !inString;
if (!inString) {
if (char === "(" || char === "{" || char === "[") depth++;
if (char === ")" || char === "}" || char === "]") depth--;
}
if (char === "," && depth === 0 && !inString) {
if (current.trim()) args.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current.trim()) args.push(current.trim());
return args;
};
export const astFormat = "devalang";