// Copyright (c) 2026 Chris Pressey, Cat's Eye Technologies.
//
// SPDX-License-Identifier: LicenseRef-MIT-X-Jaft
/*------------------------------[[ Scanner ]]------------------------------*/
class Scanner {
constructor(text) {
this.text = text;
this.pos = 0;
this.token = null;
this.scan(); // initializes the first token
}
scan() {
while (this.pos < this.text.length && /\s/.test(this.text[this.pos])) {
this.pos++;
}
if (this.pos >= this.text.length) {
this.token = null;
return;
}
const ch = this.text[this.pos];
if (this.pos + 2 < this.text.length) {
// 3-character operators
const threeChar = this.text.substring(this.pos, this.pos + 3);
if (threeChar === "===" || threeChar === "!==") {
this.token = threeChar;
this.pos += 3;
return;
}
}
if (this.pos + 1 < this.text.length) {
// 2-character operators
const twoChar = this.text.substring(this.pos, this.pos + 2);
if (
twoChar === ">=" ||
twoChar === "<=" ||
twoChar === "&&" ||
twoChar === "||"
) {
this.token = twoChar;
this.pos += 2;
return;
}
}
// Single character tokens
if (
ch === "(" ||
ch === ")" ||
ch === "[" ||
ch === "]" ||
ch === "," ||
ch === "?" ||
ch === ":" ||
ch === ";" ||
ch === "=" ||
ch === ">" ||
ch === "<" ||
ch === "+" ||
ch === "-" ||
ch === "*" ||
ch === "/" ||
ch === "%" ||
ch === "."
) {
this.token = ch;
this.pos++;
return;
}
if (
/[0-9]/.test(ch) ||
(ch === "." &&
this.pos + 1 < this.text.length &&
/[0-9]/.test(this.text[this.pos + 1]))
) {
let num = "";
while (
this.pos < this.text.length &&
/[0-9.]/.test(this.text[this.pos])
) {
num += this.text[this.pos];
this.pos++;
}
this.token = num;
return;
}
if (ch === '"' || ch === "'") {
const quote = ch;
this.pos++;
let str = "";
while (this.pos < this.text.length && this.text[this.pos] !== quote) {
if (this.text[this.pos] === "\\" && this.pos + 1 < this.text.length) {
this.pos++;
const escaped = this.text[this.pos];
if (escaped === "n") str += "\n";
else if (escaped === "t") str += "\t";
else if (escaped === "\\") str += "\\";
else if (escaped === quote) str += quote;
else str += escaped;
} else {
str += this.text[this.pos];
}
this.pos++;
}
if (this.pos < this.text.length) {
this.pos++; // skip closing quote
}
this.token = { type: "string", value: str };
return;
}
// Identifiers (function names, variables) or boolean literals
if (/[a-zA-Z_]/.test(ch)) {
let ident = "";
while (
this.pos < this.text.length &&
/[a-zA-Z0-9_]/.test(this.text[this.pos])
) {
ident += this.text[this.pos];
this.pos++;
}
if (ident === "true" || ident === "false") {
this.token = { type: "boolean", value: ident === "true" };
} else if (ident === "let" || ident === "const") {
this.token = ident;
} else {
this.token = ident;
}
return;
}
throw new Error(`Unexpected character '${ch}' at position ${this.pos}`);
}
isOn(token) {
return this.token === token;
}
expect(token) {
if (this.isOn(token)) {
this.scan();
} else {
throw new Error("Expected " + token + ", found " + this.token);
}
}
}
/*------------------------------[[ Parser ]]------------------------------*/
class Parser {
constructor(scanner) {
this.scanner = scanner;
this.boundNames = new Set();
}
isBound(name) {
return this.boundNames.has(name);
}
bind(name) {
this.boundNames.add(name);
}
withBindings(names) {
const newParser = new Parser(this.scanner);
newParser.boundNames = new Set([...this.boundNames, ...names]);
return newParser;
}
parseFunDefn() {
if (this.scanner.isOn("(")) {
this.scanner.expect("(");
const params = [];
if (!this.scanner.isOn(")")) {
params.push(this.scanner.token);
this.scanner.scan();
while (this.scanner.isOn(",")) {
this.scanner.expect(",");
params.push(this.scanner.token);
this.scanner.scan();
}
}
this.scanner.expect(")");
this.scanner.expect("=");
this.scanner.expect(">");
const body = this.parseExpr();
// Validate all input has been consumed. Note this assumes that
// "parseFunDefn" is the top-level; this might need adjusting.
if (this.scanner.token !== null) {
throw new Error("Unexpected " + this.scanner.token);
}
return {
type: "fun-defn",
params: params,
body: body,
};
}
return this.parseExpr();
}
parseExpr() {
return this.parseTernary();
}
parseTernary() {
let expr = this.parseLet();
if (this.scanner.isOn("?")) {
this.scanner.expect("?");
const consequent = this.parseTernary();
this.scanner.expect(":");
const alternate = this.parseTernary();
return {
type: "ternary",
condition: expr,
consequent: consequent,
alternate: alternate,
};
}
return expr;
}
parseLet() {
if (this.scanner.isOn("let") || this.scanner.isOn("const")) {
let allowShadowing = true;
if (this.scanner.isOn("let")) this.scanner.expect("let");
if (this.scanner.isOn("const")) {
allowShadowing = false;
this.scanner.expect("const");
}
const bindings = [];
const theseBoundNames = new Set();
const newConstNames = [];
while (!(this.scanner.isOn(",") || this.scanner.isOn(";"))) {
// two cases: destructuring binding vs. single-name binding
if (this.scanner.isOn("[")) {
this.scanner.expect("[");
const names = [];
if (!this.scanner.isOn("]")) {
names.push(this.scanner.token);
this.scanner.scan();
while (this.scanner.isOn(",")) {
this.scanner.expect(",");
names.push(this.scanner.token);
this.scanner.scan();
}
}
this.scanner.expect("]");
this.scanner.expect("=");
const value = this.parseExpr();
for (const name of names) {
if (theseBoundNames.has(name)) {
throw new Error(`Cannot re-bind '${name}' in same scope`);
}
if (this.isBound(name)) {
throw new Error(`Cannot shadow const binding '${name}'`);
}
}
bindings.push({ destructure: names, value });
theseBoundNames.add(...names);
if (!allowShadowing) {
for (const name of names) {
newConstNames.push(name);
}
}
} else {
const name = this.scanner.token;
if (theseBoundNames.has(name)) {
throw new Error(`Cannot re-bind '${name}' in same scope`);
}
if (this.isBound(name)) {
throw new Error(`Cannot shadow const binding '${name}'`);
}
this.scanner.scan();
this.scanner.expect("=");
const value = this.parseExpr();
bindings.push({ name, value });
theseBoundNames.add(name);
if (!allowShadowing) {
newConstNames.push(name);
}
}
if (this.scanner.isOn(",")) {
this.scanner.expect(",");
}
}
this.scanner.expect(";");
const bodyParser = this.withBindings(newConstNames);
const body = bodyParser.parseExpr();
return {
type: "let",
allowShadowing: allowShadowing,
bindings: bindings,
body: body,
};
}
return this.parseLogicalOr();
}
parseLogicalOr() {
let left = this.parseLogicalAnd();
while (this.scanner.isOn("||")) {
const op = this.scanner.token;
this.scanner.scan();
const right = this.parseLogicalAnd();
left = {
type: "binop",
op: op,
left: left,
right: right,
};
}
return left;
}
parseLogicalAnd() {
let left = this.parseComparison();
while (this.scanner.isOn("&&")) {
const op = this.scanner.token;
this.scanner.scan();
const right = this.parseComparison();
left = {
type: "binop",
op: op,
left: left,
right: right,
};
}
return left;
}
parseComparison() {
let left = this.parseAdditive();
while (
this.scanner.isOn("===") ||
this.scanner.isOn("!==") ||
this.scanner.isOn("<") ||
this.scanner.isOn("<=") ||
this.scanner.isOn(">") ||
this.scanner.isOn(">=")
) {
const op = this.scanner.token;
this.scanner.scan();
const right = this.parseAdditive();
left = {
type: "binop",
op: op,
left: left,
right: right,
};
}
return left;
}
parseAdditive() {
let left = this.parseMultiplicative();
while (this.scanner.isOn("+") || this.scanner.isOn("-")) {
const op = this.scanner.token;
this.scanner.scan();
const right = this.parseMultiplicative();
left = {
type: "binop",
op: op,
left: left,
right: right,
};
}
return left;
}
parseMultiplicative() {
let left = this.parsePostfix();
while (
this.scanner.isOn("*") ||
this.scanner.isOn("/") ||
this.scanner.isOn("%")
) {
const op = this.scanner.token;
this.scanner.scan();
const right = this.parsePostfix();
left = {
type: "binop",
op: op,
left: left,
right: right,
};
}
return left;
}
parsePostfix() {
let expr = this.parseTerm();
while (this.scanner.isOn(".")) {
this.scanner.scan();
const property = this.scanner.token;
if (typeof property !== "string" || /^[0-9]/.test(property)) {
throw new Error(`Expected property name after '.', found ${property}`);
}
this.scanner.scan();
expr = {
type: "binop",
op: ".",
left: expr,
right: { type: "literal", valueType: "string", value: property },
};
}
return expr;
}
parseTerm() {
if (this.scanner.isOn("[")) {
this.scanner.expect("[");
const elements = [];
if (!this.scanner.isOn("]")) {
elements.push(this.parseExpr());
while (this.scanner.isOn(",")) {
this.scanner.expect(",");
elements.push(this.parseExpr());
}
}
this.scanner.expect("]");
return { type: "tuple", elements: elements };
}
if (this.scanner.isOn("(")) {
this.scanner.expect("(");
const expr = this.parseExpr();
this.scanner.expect(")");
return expr;
}
if (typeof this.scanner.token === "object") {
const literal = this.scanner.token;
this.scanner.scan();
return { type: "literal", valueType: literal.type, value: literal.value };
}
if (
typeof this.scanner.token === "string" &&
/^[0-9]/.test(this.scanner.token)
) {
const num = parseFloat(this.scanner.token);
this.scanner.scan();
return { type: "literal", valueType: "number", value: num };
}
const ident = this.scanner.token;
this.scanner.scan();
if (this.scanner.isOn("(")) {
this.scanner.expect("(");
const args = [];
if (!this.scanner.isOn(")")) {
args.push(this.parseExpr());
while (this.scanner.isOn(",")) {
this.scanner.expect(",");
args.push(this.parseExpr());
}
}
this.scanner.expect(")");
return { type: "call", name: ident, args: args };
} else {
return { type: "variable", name: ident };
}
}
}
/*------------------------------[[ Compiler ]]------------------------------*/
function compileList(ctx, args, signature = null, params = []) {
return args.map((arg) => compile(ctx, arg, signature, params)).join(", ");
}
function compile(ctx, ast, signature = null, params = []) {
if (ast.type === "fun-defn") {
return compile(ctx, ast.body, signature, ast.params);
}
if (ast.type === "literal") {
if (ast.valueType === "string") {
return JSON.stringify(ast.value);
} else if (ast.valueType === "boolean") {
return ast.value.toString();
} else if (ast.valueType === "number") {
return ast.value.toString();
}
} else if (ast.type === "variable") {
return ast.name;
} else if (ast.type === "binop") {
const left = compile(ctx, ast.left, signature, params);
const right = compile(ctx, ast.right, signature, params);
// Special handling for property access
if (ast.op === ".") {
// Right side should be a string literal representing the property name
if (ast.right.type === "literal" && ast.right.valueType === "string") {
return `(${left}.${ast.right.value})`;
} else {
// Fallback to bracket notation if not a simple property
return `(${left}[${right}])`;
}
}
return `(${left} ${ast.op} ${right})`;
} else if (ast.type === "call") {
// Two cases to handle: calling a passed-in function (formal parameter),
// or calling a function defined in the context
const paramIndex = params.indexOf(ast.name);
if (paramIndex === -1) {
const fn = ctx.get(ast.name);
if (!fn) {
throw new Error(`Unknown function: ${ast.name}`);
}
return `${ast.name}(${compileList(ctx, ast.args, signature, params)})`;
} else {
if (!signature || !signature[paramIndex]) {
throw new Error(
`Cannot call parameter '${ast.name}': function signature required`,
);
}
if (signature[paramIndex] !== "function") {
throw new Error(
`Cannot call parameter '${ast.name}': parameter type is '${signature[paramIndex]}', not 'function'`,
);
}
// Generate code that checks if the function is a properly concoted function at runtime
const argsCode = ast.args
.map((arg) => compile(ctx, arg, signature, params))
.join(", ");
return `((f) => {
if (!f || !f.concoctAttributes) {
throw new Error('Unvetted function: ${ast.name}');
}
return f(${argsCode});
})(${ast.name})`;
}
} else if (ast.type === "ternary") {
const cond = compile(ctx, ast.condition, signature, params);
const cons = compile(ctx, ast.consequent, signature, params);
const alt = compile(ctx, ast.alternate, signature, params);
return `(${cond} ? ${cons} : ${alt})`;
} else if (ast.type === "let") {
const paramNames = ast.bindings
.map((b) => (b.destructure ? `[${b.destructure.join(", ")}]` : b.name))
.join(", ");
const args = ast.bindings
.map((b) => compile(ctx, b.value, signature, params))
.join(", ");
const body = compile(ctx, ast.body, signature, params);
return `((${paramNames}) => ${body})(${args})`;
} else if (ast.type === "tuple") {
const elements = ast.elements
.map((e) => compile(ctx, e, signature, params))
.join(", ");
return `[${elements}]`;
}
throw new Error(`Unknown AST node type: ${ast.type}`);
}
export { Scanner, Parser, compile };