Skip to content

Commit bccbc36

Browse files
committed
Add CLI functionality
1 parent a8067df commit bccbc36

6 files changed

Lines changed: 316 additions & 11 deletions

File tree

bin/cli.mjs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
#!/usr/bin/env node
2+
3+
import { readFileSync } from 'node:fs';
4+
import { createInterface } from 'node:readline';
5+
import { stdin, stdout, stderr, exit, argv, version } from 'node:process';
6+
import { fileURLToPath } from 'node:url';
7+
import { dirname, resolve } from 'node:path';
8+
9+
const __dirname = dirname(fileURLToPath(import.meta.url));
10+
11+
let pkg;
12+
try {
13+
pkg = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8'));
14+
} catch {
15+
pkg = { version: 'unknown' };
16+
}
17+
18+
let Exprify;
19+
try {
20+
const mod = await import('../src/core/Exprify.js');
21+
Exprify = mod.default || mod.Exprify;
22+
} catch {
23+
stderr.write('Error: Could not load Exprify module\n');
24+
exit(1);
25+
}
26+
27+
const expr = new Exprify();
28+
29+
const USAGE = `Usage: exprify [options] [expression...]
30+
31+
Options:
32+
--help Show this help message
33+
--version Show version number
34+
--parse <expr> Parse expression and show token/AST structure
35+
--tokens <expr> Tokenize expression and show tokens
36+
37+
If no expression is provided and stdin is a TTY, starts interactive REPL.
38+
If stdin is piped, reads expression from stdin.
39+
40+
Examples:
41+
exprify "2 + 2"
42+
exprify "sqrt(16)" "5 * 3"
43+
exprify --parse "x ^ 2 + 2 * x + 1"
44+
echo "2 + 2" | exprify
45+
`;
46+
47+
const COLORS = {
48+
reset: '\x1b[0m',
49+
red: '\x1b[31m',
50+
green: '\x1b[32m',
51+
yellow: '\x1b[33m',
52+
cyan: '\x1b[36m',
53+
bold: '\x1b[1m',
54+
dim: '\x1b[2m',
55+
};
56+
57+
function formatResult(value) {
58+
if (value === null) return 'null';
59+
if (value === undefined) return 'undefined';
60+
if (typeof value === 'object' || Array.isArray(value)) {
61+
return JSON.stringify(value, null, 2);
62+
}
63+
return String(value);
64+
}
65+
66+
function printError(msg) {
67+
stderr.write(COLORS.red + 'Error: ' + COLORS.reset + msg + '\n');
68+
}
69+
70+
function evaluateAndPrint(expression, mode) {
71+
try {
72+
if (mode === 'parse') {
73+
const result = expr.parse(expression);
74+
console.log(JSON.stringify(result, null, 2));
75+
} else if (mode === 'tokens') {
76+
const result = expr.tokenize(expression);
77+
console.log(JSON.stringify(result, null, 2));
78+
} else {
79+
const result = expr.evaluate(expression);
80+
console.log(formatResult(result));
81+
}
82+
} catch (err) {
83+
printError(err.message);
84+
exit(1);
85+
}
86+
}
87+
88+
const args = argv.slice(2);
89+
90+
if (args.length === 0) {
91+
if (stdin.isTTY) {
92+
startREPL();
93+
} else {
94+
let input = '';
95+
stdin.setEncoding('utf-8');
96+
stdin.on('data', (chunk) => { input += chunk; });
97+
stdin.on('end', () => {
98+
const exprStr = input.trim();
99+
if (exprStr) evaluateAndPrint(exprStr);
100+
});
101+
}
102+
exit(0);
103+
}
104+
105+
const flag = args[0];
106+
107+
if (flag === '--help' || flag === '-h') {
108+
console.log(USAGE);
109+
exit(0);
110+
}
111+
112+
if (flag === '--version' || flag === '-v') {
113+
console.log(pkg.version);
114+
exit(0);
115+
}
116+
117+
if (flag === '--parse' || flag === '--tokens') {
118+
if (args.length < 2) {
119+
printError('Missing expression argument');
120+
console.log(USAGE);
121+
exit(2);
122+
}
123+
const mode = flag.slice(2);
124+
for (let i = 1; i < args.length; i++) {
125+
evaluateAndPrint(args[i], mode);
126+
}
127+
exit(0);
128+
}
129+
130+
for (const arg of args) {
131+
evaluateAndPrint(arg);
132+
}
133+
134+
function startREPL() {
135+
console.log(`Exprify v${pkg.version} — interactive REPL`);
136+
console.log('Type an expression or .help for commands\n');
137+
138+
const rl = createInterface({
139+
input: stdin,
140+
output: stdout,
141+
prompt: COLORS.cyan + '» ' + COLORS.reset,
142+
completer: (line) => {
143+
const completions = [
144+
'help', '.help', '.exit',
145+
'pi', 'e', 'i', 'PHI', 'TAU', 'INFINITY', 'NaN',
146+
'sin', 'cos', 'tan', 'sqrt', 'abs', 'log', 'exp',
147+
'map', 'filter', 'sum', 'prod', 'mean', 'max', 'min',
148+
'if', 'parse', 'leafCount', 'random',
149+
'simplify', 'expand', 'factor', 'solve', 'derivative',
150+
'integral', 'sigma', 'limit', 'substitute',
151+
'det', 'transpose', 'inverse', 'trace', 'rank',
152+
];
153+
const hits = completions.filter((c) => c.startsWith(line));
154+
return [hits.length ? hits : completions, line];
155+
},
156+
});
157+
158+
rl.on('line', (line) => {
159+
const input = line.trim();
160+
161+
if (!input) {
162+
rl.prompt();
163+
return;
164+
}
165+
166+
if (input === '.exit' || input === 'exit' || input === 'quit') {
167+
rl.close();
168+
return;
169+
}
170+
171+
if (input === '.help' || input === 'help') {
172+
console.log(`\n ${COLORS.bold}Commands:${COLORS.reset}`);
173+
console.log(' .exit Exit the REPL');
174+
console.log(' .help Show this message');
175+
console.log(' <expr> Evaluate an expression');
176+
console.log(' Ctrl+C Cancel / exit');
177+
console.log('');
178+
rl.prompt();
179+
return;
180+
}
181+
182+
try {
183+
const result = expr.evaluate(input);
184+
console.log(COLORS.green + formatResult(result) + COLORS.reset);
185+
} catch (err) {
186+
console.log(COLORS.red + 'Error: ' + COLORS.reset + err.message);
187+
}
188+
189+
rl.prompt();
190+
});
191+
192+
rl.on('close', () => {
193+
console.log('');
194+
exit(0);
195+
});
196+
197+
rl.prompt();
198+
}

bin/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "commonjs"
3+
}

bin/repl.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env node
2+
3+
/*
4+
* help interactive debugging.
5+
**/
6+
7+
global.exprify = require('../dist/exprify.min.js')
8+
const repl = require('repl')
9+
10+
repl.start({ useGlobal: true })

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
"publishConfig": {
1717
"access": "public"
1818
},
19+
"bin": {
20+
"exprify": "./bin/cli.mjs"
21+
},
1922
"engines": {
2023
"node": ">=18.0.0"
2124
},

test/cli.test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { resolve, dirname } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
const cliPath = resolve(__dirname, '../bin/cli.mjs');
7+
8+
function run(args = []) {
9+
return execFileSync(process.execPath, [cliPath, ...args], {
10+
encoding: 'utf-8',
11+
});
12+
}
13+
14+
describe('CLI', () => {
15+
// EVALUATION
16+
test('evaluates a simple expression', () => {
17+
const out = run(['2 + 3']);
18+
expect(out.trim()).toBe('5');
19+
});
20+
21+
test('evaluates multiple expressions', () => {
22+
const out = run(['1 + 1', '2 + 2', '3 + 3']);
23+
const lines = out.trim().split('\n');
24+
expect(lines).toEqual(['2', '4', '6']);
25+
});
26+
27+
test('evaluates expressions with functions', () => {
28+
const out = run(['sqrt(16)']);
29+
expect(out.trim()).toBe('4');
30+
});
31+
32+
// FLAGS
33+
test('--help prints usage and exits 0', () => {
34+
expect(() => run(['--help'])).not.toThrow();
35+
const out = run(['--help']);
36+
expect(out).toMatch(/Usage:/);
37+
});
38+
39+
test('-h prints usage', () => {
40+
const out = run(['-h']);
41+
expect(out).toMatch(/Usage:/);
42+
});
43+
44+
test('--version prints version', () => {
45+
const out = run(['--version']);
46+
expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/);
47+
});
48+
49+
test('-v prints version', () => {
50+
const out = run(['-v']);
51+
expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/);
52+
});
53+
54+
test('--tokens outputs token JSON', () => {
55+
const out = run(['--tokens', '2 + 2']);
56+
const parsed = JSON.parse(out.trim());
57+
expect(Array.isArray(parsed)).toBe(true);
58+
expect(parsed.length).toBeGreaterThanOrEqual(3);
59+
});
60+
61+
test('--parse outputs AST JSON', () => {
62+
const out = run(['--parse', 'x + 2']);
63+
const parsed = JSON.parse(out.trim());
64+
expect(parsed).toHaveProperty('tokens');
65+
expect(parsed).toHaveProperty('ast');
66+
});
67+
68+
// ERROR HANDLING
69+
test('throws on invalid expression', () => {
70+
expect(() => run(['2 + +'])).toThrow();
71+
});
72+
73+
test('throws on missing arg for --parse', () => {
74+
expect(() => run(['--parse'])).toThrow();
75+
});
76+
77+
test('throws on missing arg for --tokens', () => {
78+
expect(() => run(['--tokens'])).toThrow();
79+
});
80+
81+
// EDGE CASES
82+
test('handles complex expression with spaces', () => {
83+
const out = run(['(2 + 3) * 4']);
84+
expect(out.trim()).toBe('20');
85+
});
86+
87+
test('handles expression with unicode', () => {
88+
const out = run(['"hello" + " " + "world"']);
89+
expect(out.trim()).toBe('hello world');
90+
});
91+
});

0 commit comments

Comments
 (0)