UNPKG

jambda-calc

Version:

Convert JavaScript functions to the Lambda Calculus, and visualize them with Tromp diagrams.

359 lines 15.8 kB
// inspired Haskell project, which has more functionality: https://github.com/polux/lambda-diagrams/blob/master/src/Main.hs import * as fs from 'fs'; import * as path from 'path'; import sharp from 'sharp'; import { Parser } from './parser.js'; export var TermType; (function (TermType) { TermType["VAR"] = "var"; TermType["LAM"] = "lam"; TermType["APP"] = "app"; })(TermType || (TermType = {})); export class Term { constructor(type, data) { this.type = type; // each type of term has some unique props if (type === TermType.VAR && typeof data === 'number') { this.index = data; } else if (type === TermType.LAM && data instanceof Term) { this.body = data; } else if (type === TermType.APP && typeof data === 'object' && data !== null) { const appData = data; this.func = appData.func; this.arg = appData.arg; } } /** * Calculate the size (number of nodes) of the term */ size() { if (this.type === TermType.VAR) { return 1; } else if (this.type === TermType.LAM && this.body) { return 1 + this.body.size(); } else if (this.type === TermType.APP && this.func && this.arg) { return this.func.size() + this.arg.size(); } return 0; } } /** * Convert a parsed term to our internal representation. */ function convertParsedTerm(parsedTerm) { //// VARIABLES //// if (parsedTerm.type === 'variable') { const variable = new Term(TermType.VAR, 0); // defaults to 0 debrujin idx, will be updated variable.name = parsedTerm.name; // for labels // for number constants, convert to church numeral if (variable.name && !isNaN(Number(variable.name))) { const numValue = parseInt(variable.name); return createChurchNumeral(numValue); } return variable; //// LAMBDA ABSTRACTIONS //// } else if (parsedTerm.type === 'abstraction' && parsedTerm.body) { const body = convertParsedTerm(parsedTerm.body); const lambda = new Term(TermType.LAM, body); lambda.variable = parsedTerm.variable; // for labels const varName = lambda.variable || 'x'; lambda.binderId = `lambda-${varName}-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; body.parent = lambda; return lambda; //// APPLICATIONS //// } else if (parsedTerm.type === 'application' && parsedTerm.left && parsedTerm.right) { const func = convertParsedTerm(parsedTerm.left); const arg = convertParsedTerm(parsedTerm.right); const app = new Term(TermType.APP, { func, arg }); func.parent = app; arg.parent = app; app.appId = `app-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; return app; } else { throw new Error(`Unknown term type: ${parsedTerm.type}`); } } /** * Create a Church numeral term representation for a number. * Church numeral n is λf.λx.f^n(x). */ // TODO: handle church numerals over 100 by breaking them multiple numerals with an operator function createChurchNumeral(n) { // special case for 0 - λf.λx.x if (n === 0) { const x = new Term(TermType.VAR, 0); x.name = 'x'; const lambdaX = new Term(TermType.LAM, x); lambdaX.variable = 'x'; const lambdaF = new Term(TermType.LAM, lambdaX); lambdaF.variable = 'f'; return lambdaF; } // n > 0: λf.λx.f(f(...(f(x))...)) // start with the innermost term let innerTerm = new Term(TermType.VAR, 0); innerTerm.name = 'x'; // apply f n times for (let i = 0; i < n; i++) { const f = new Term(TermType.VAR, 1); // f is one level up from x f.name = 'f'; // application of f to the inner term const app = new Term(TermType.APP, { func: f, arg: innerTerm, }); // set parent references f.parent = app; innerTerm.parent = app; innerTerm = app; } // wrap in λx const lambdaX = new Term(TermType.LAM, innerTerm); lambdaX.variable = 'x'; innerTerm.parent = lambdaX; // wrap in λf const lambdaF = new Term(TermType.LAM, lambdaX); lambdaF.variable = 'f'; lambdaX.parent = lambdaF; // mark as church numeral lambdaF.isChurchNumeral = true; lambdaF.numValue = n; return lambdaF; } /** * Update variable indices to use De Bruijn notation to connect variables to their binding lambdas in the diagram. */ function assignIndices(term, depth = 0, env = new Map(), binderIds = new Map()) { if (term.type === TermType.VAR) { //// VARIABLES //// const name = term.name || 'x'; if (env.has(name)) { // idx is distance (in lambda abstractions) from the variable to its binder const envValue = env.get(name); if (envValue !== undefined) { term.index = depth - envValue - 1; // get binder id for this variable const bindingDepth = envValue; if (binderIds.has(bindingDepth)) { term.binderId = binderIds.get(bindingDepth); } } } } else if (term.type === TermType.LAM && term.body) { //// LAMBDA ABSTRACTIONS //// if (!term.binderId) { term.binderId = `lambda-${term.variable || 'x'}-${depth}-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`; } // create env and add var to it const newEnv = new Map(env); newEnv.set(term.variable || 'x', depth); // store the binder id so vars can ref it const newBinderIds = new Map(binderIds); newBinderIds.set(depth, term.binderId); // recursively process the lambda's body assignIndices(term.body, depth + 1, newEnv, newBinderIds); } else if (term.type === TermType.APP && term.func && term.arg) { //// APPLICATIONS //// // process both sides assignIndices(term.func, depth, env, binderIds); assignIndices(term.arg, depth, env, binderIds); } if (term.isChurchNumeral) { if (term.body && term.body.type === TermType.LAM) { // set ref in child terms so they can find parent term.body.churchParent = term; } } return term; } /** * Main tromp diagram generator class. */ export class TrompDiagramGenerator { constructor(options = {}) { this.options = { unitSize: options.unitSize || 30, lineWidth: options.lineWidth || 3, padding: options.padding || 60, backgroundColor: options.backgroundColor || '#ffffff', }; this.seenOperations = new Set(); this.labelPositions = []; } /** * Generate diagram from a lambda expression string. */ generateDiagram(lambdaExpression) { this.seenOperations.clear(); this.labelPositions = []; try { // process with our parser and convert to our representation const parser = new Parser(lambdaExpression); const parsedExpression = parser.parse(); const expression = convertParsedTerm(parsedExpression); assignIndices(expression); // draw diagram lines const figure = this.drawTerm(expression); // calc dims const totalWidth = figure.width * this.options.unitSize + this.options.padding * 2; const totalHeight = figure.height * this.options.unitSize + this.options.padding * 2; let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${totalHeight}" preserveAspectRatio="xMidYMid meet">`; // create svg container svg += `<rect width="100%" height="100%" fill="${this.options.backgroundColor}" />`; // add background const offsetX = this.options.padding; const offsetY = this.options.padding; svg += `<g transform="translate(${offsetX}, ${offsetY})">`; // container for the diagram itself svg += figure.svg; svg += '</g></svg>'; return svg; } catch (error) { throw new Error(`Error drawing diagram:${error}`); } } /** * Converts parsed expression/term into svg lines * Returns { svg, height, width } object */ drawTerm(term) { const horizontalGap = this.options.unitSize; if (term.type === TermType.VAR) { //// VARIABLES //// const h = 0; const w = 1; const varX = horizontalGap; const varY = 0; const varHeight = (term.index !== undefined ? term.index + 1 : 0) * this.options.unitSize; // de bruijn idx determines height let svg = `<line x1="${varX}" y1="${varY}" x2="${varX}" y2="${varY - varHeight}" stroke="#000000" stroke-width="${this.options.lineWidth}" stroke-linecap="round" />`; return { svg, height: h, width: w }; } else if (term.type === TermType.LAM && term.body) { //// LAMBDA ABSTRACTIONS //// // draw body first const bodyResult = this.drawTerm(term.body); const h = bodyResult.height + 1; const w = bodyResult.width; const lineColor = '#000000'; // TODO: make this dynamic (assign colors to components and use it here) const binderWidth = w * this.options.unitSize; // shorten the width of each binder to show space between lambda abstractions const binderX = horizontalGap; const binderY = 0; // draw the lambda binder over the body let svg = `<line x1="${binderX}" y1="${binderY}" x2="${binderX + binderWidth}" y2="${binderY}" stroke="${lineColor}" stroke-width="${this.options.lineWidth}" />`; const verticalSpacing = this.options.unitSize; const translatedBodySVG = this._translateSVG(bodyResult.svg, 0, verticalSpacing); svg += translatedBodySVG; // combine return { svg, height: h, width: w }; } if (term.type === TermType.APP && term.func && term.arg) { //// APPLICATIONS //// // calling the left side function, right side argument for simplicity // first draw both sides of the app const func = this.drawTerm(term.func); const arg = this.drawTerm(term.arg); const h1 = func.height; const w1 = func.width; const h2 = arg.height; const w2 = arg.width; // calc dimensions of this app const h = Math.max(h1, h2) + 1; // Ensure consistent spacing between vertical lines const w = w1 + w2 + 1; // Adjusted to ensure even spacing between vertical lines const unitSize = this.options.unitSize; // draw svg let svg = ''; const funcBottom = h1 * unitSize; // find bottom y coord (removing padding) const argBottom = h2 * unitSize; // find bottom y coord (removing padding) const barY = Math.max(funcBottom, argBottom); // v line for function side (if function is shorter than argument, usually the case) if (funcBottom < barY) { const funcVLineX = horizontalGap; // align with lambda (removing padding) const funcVLineY = funcBottom; const funcVLineHeight = barY - funcBottom; svg += `<line x1="${funcVLineX}" y1="${funcVLineY}" x2="${funcVLineX}" y2="${funcVLineY + funcVLineHeight}" stroke="#000000" stroke-width="${this.options.lineWidth}" stroke-linecap="round" />`; } // v line for argument side (if argument is shorter than function) if (argBottom < barY) { // calc right side position with consistent spacing const argVXPos = horizontalGap + (w1 + 1) * unitSize; // Adjusted for even spacing (removing padding) const argVLineY = argBottom; const argVLineHeight = barY - argBottom; svg += `<line x1="${argVXPos}" y1="${argVLineY}" x2="${argVXPos}" y2="${argVLineY + argVLineHeight}" stroke="#000000" stroke-width="${this.options.lineWidth}" stroke-linecap="round" />`; } // draw h connecting bar at the bottom-left corners with consistent spacing const barX = horizontalGap; // align with lambda (removing padding) const barWidth = (w1 + 1) * unitSize; // Adjusted for even spacing svg += `<line x1="${barX}" y1="${barY}" x2="${barX + barWidth}" y2="${barY}" stroke="#000000" stroke-width="${this.options.lineWidth}" stroke-linecap="round" />`; // vertical bar from func to the arg (rarely needed) const funcConnectionX = barX; // left edge of hbar const funcConnectionY1 = barY; // top of hbar const funcConnectionY2 = Math.max(0, funcBottom - unitSize); // defaults to 0, or goes up to the bottom of the function (usually not the case) svg += `<line x1="${funcConnectionX}" y1="${funcConnectionY1}" x2="${funcConnectionX}" y2="${funcConnectionY2}" stroke="#000000" stroke-width="${this.options.lineWidth}" stroke-linecap="round" />`; // right v connection from the app bar up to its argument const argConnectionX = barX + barWidth; // right edge of h bar const argConnectionY1 = barY; // top of hbar const argConnectionY2 = Math.max(0, argBottom - unitSize); // goes up to the bottom left of the argument svg += `<line x1="${argConnectionX}" y1="${argConnectionY1}" x2="${argConnectionX}" y2="${argConnectionY2}" stroke="#000000" stroke-width="${this.options.lineWidth}" stroke-linecap="round" />`; // draw function side svg += func.svg; // draw argument side (shifted to the right with consistent spacing) const translatedArgSVG = this._translateSVG(arg.svg, (w1 + 1) * unitSize, 0); // Adjusted for even spacing svg += translatedArgSVG; return { svg, height: h, width: w }; } return { svg: '', height: 0, width: 0 }; } _translateSVG(svg, x, y) { if (!svg) return ''; return svg .replace(/(x1|x2|cx)="([^"]+)"/g, (match, attr, val) => { return `${attr}="${parseFloat(val) + x}"`; }) .replace(/(y1|y2|cy)="([^"]+)"/g, (match, attr, val) => { return `${attr}="${parseFloat(val) + y}"`; }); } /** * Save the SVG diagram to a file */ saveSVG(lambdaExpression, filePath) { const svg = this.generateDiagram(lambdaExpression); const dir = path.dirname(filePath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(filePath, svg); return filePath; } /** * Convert SVG to PNG using sharp library */ savePNG(lambdaExpression, filePath) { const svg = this.generateDiagram(lambdaExpression); const sharpFn = sharp.default || sharp; sharpFn(Buffer.from(svg)) .png() .toFile(filePath) .then(() => { }) .catch((err) => { console.error('Error converting SVG to PNG:', err); }); return filePath; } } //# sourceMappingURL=tromp-diagram.js.map