@ascent-lang/dev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +68 -0
  2. package/dist/errors/index.d.ts +4 -0
  3. package/dist/errors/index.d.ts.map +1 -0
  4. package/dist/errors/index.js +35 -0
  5. package/dist/errors/index.js.map +1 -0
  6. package/dist/errors/types.d.ts +9 -0
  7. package/dist/errors/types.d.ts.map +1 -0
  8. package/dist/errors/types.js +5 -0
  9. package/dist/errors/types.js.map +1 -0
  10. package/dist/index.d.ts +3 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +200 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/interpreter.d.ts +35 -0
  15. package/dist/interpreter.d.ts.map +1 -0
  16. package/dist/interpreter.js +305 -0
  17. package/dist/interpreter.js.map +1 -0
  18. package/dist/lexer/chars.d.ts +6 -0
  19. package/dist/lexer/chars.d.ts.map +1 -0
  20. package/dist/lexer/chars.js +6 -0
  21. package/dist/lexer/chars.js.map +1 -0
  22. package/dist/lexer/cursor.d.ts +16 -0
  23. package/dist/lexer/cursor.d.ts.map +1 -0
  24. package/dist/lexer/cursor.js +43 -0
  25. package/dist/lexer/cursor.js.map +1 -0
  26. package/dist/lexer/index.d.ts +21 -0
  27. package/dist/lexer/index.d.ts.map +1 -0
  28. package/dist/lexer/index.js +163 -0
  29. package/dist/lexer/index.js.map +1 -0
  30. package/dist/lexer/keywords.d.ts +5 -0
  31. package/dist/lexer/keywords.d.ts.map +1 -0
  32. package/dist/lexer/keywords.js +39 -0
  33. package/dist/lexer/keywords.js.map +1 -0
  34. package/dist/lexer/token.d.ts +20 -0
  35. package/dist/lexer/token.d.ts.map +1 -0
  36. package/dist/lexer/token.js +2 -0
  37. package/dist/lexer/token.js.map +1 -0
  38. package/dist/lib.d.ts +14 -0
  39. package/dist/lib.d.ts.map +1 -0
  40. package/dist/lib.js +17 -0
  41. package/dist/lib.js.map +1 -0
  42. package/dist/parser/ast.d.ts +128 -0
  43. package/dist/parser/ast.d.ts.map +1 -0
  44. package/dist/parser/ast.js +2 -0
  45. package/dist/parser/ast.js.map +1 -0
  46. package/dist/parser/expr.d.ts +4 -0
  47. package/dist/parser/expr.d.ts.map +1 -0
  48. package/dist/parser/expr.js +277 -0
  49. package/dist/parser/expr.js.map +1 -0
  50. package/dist/parser/index.d.ts +12 -0
  51. package/dist/parser/index.d.ts.map +1 -0
  52. package/dist/parser/index.js +40 -0
  53. package/dist/parser/index.js.map +1 -0
  54. package/dist/parser/printer.d.ts +7 -0
  55. package/dist/parser/printer.d.ts.map +1 -0
  56. package/dist/parser/printer.js +130 -0
  57. package/dist/parser/printer.js.map +1 -0
  58. package/dist/parser/stmt.d.ts +7 -0
  59. package/dist/parser/stmt.d.ts.map +1 -0
  60. package/dist/parser/stmt.js +153 -0
  61. package/dist/parser/stmt.js.map +1 -0
  62. package/dist/parser/token-stream.d.ts +19 -0
  63. package/dist/parser/token-stream.d.ts.map +1 -0
  64. package/dist/parser/token-stream.js +111 -0
  65. package/dist/parser/token-stream.js.map +1 -0
  66. package/dist/parser/type-expr.d.ts +5 -0
  67. package/dist/parser/type-expr.d.ts.map +1 -0
  68. package/dist/parser/type-expr.js +54 -0
  69. package/dist/parser/type-expr.js.map +1 -0
  70. package/dist/parser/typechecker.d.ts +9 -0
  71. package/dist/parser/typechecker.d.ts.map +1 -0
  72. package/dist/parser/typechecker.js +446 -0
  73. package/dist/parser/typechecker.js.map +1 -0
  74. package/dist/parser/typed-ast.d.ts +130 -0
  75. package/dist/parser/typed-ast.d.ts.map +1 -0
  76. package/dist/parser/typed-ast.js +2 -0
  77. package/dist/parser/typed-ast.js.map +1 -0
  78. package/dist/parser/typed-printer.d.ts +3 -0
  79. package/dist/parser/typed-printer.d.ts.map +1 -0
  80. package/dist/parser/typed-printer.js +91 -0
  81. package/dist/parser/typed-printer.js.map +1 -0
  82. package/dist/types/types.d.ts +28 -0
  83. package/dist/types/types.d.ts.map +1 -0
  84. package/dist/types/types.js +48 -0
  85. package/dist/types/types.js.map +1 -0
  86. package/package.json +70 -0
  87. package/src/errors/index.ts +38 -0
  88. package/src/errors/lexical.yml +16 -0
  89. package/src/errors/name.yml +11 -0
  90. package/src/errors/syntactic.yml +66 -0
  91. package/src/errors/typechecker.yml +61 -0
  92. package/src/errors/types.ts +13 -0
  93. package/src/index.ts +213 -0
  94. package/src/interpreter.ts +332 -0
  95. package/src/lexer/chars.ts +12 -0
  96. package/src/lexer/cursor.ts +51 -0
  97. package/src/lexer/index.ts +174 -0
  98. package/src/lexer/keywords.ts +43 -0
  99. package/src/lexer/token.ts +62 -0
  100. package/src/lib.ts +33 -0
  101. package/src/parser/ast.ts +64 -0
  102. package/src/parser/expr.ts +313 -0
  103. package/src/parser/index.ts +50 -0
  104. package/src/parser/printer.ts +146 -0
  105. package/src/parser/stmt.ts +176 -0
  106. package/src/parser/token-stream.ts +121 -0
  107. package/src/parser/type-expr.ts +63 -0
  108. package/src/parser/typechecker.ts +406 -0
  109. package/src/parser/typed-ast.ts +63 -0
  110. package/src/parser/typed-printer.ts +105 -0
  111. package/src/types/types.ts +65 -0
package/src/index.ts ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from 'node:readline/promises';
3
+ import { readFile } from 'node:fs/promises';
4
+ import chalk from 'chalk';
5
+ import { Lexer } from './lexer/index.js';
6
+ import { Parser } from './parser/index.js';
7
+ import { typecheck } from './parser/typechecker.js';
8
+ import { formatValue } from './parser/printer.js';
9
+ import { formatTypedStmt } from './parser/typed-printer.js';
10
+ import { executeStmt, executeProgram, Environment, RuntimeValue } from './interpreter.js';
11
+ import type { ArgDef } from './parser/ast.js';
12
+
13
+ // \x01 and \x02 bracket invisible bytes so readline counts the visible
14
+ // width of the prompt correctly — without them cursor positioning breaks.
15
+ const PROMPT = `\x01${chalk.bold.green('>')}\x02 `;
16
+
17
+ // Parses '--name value' pairs from argv into a name→raw-string map.
18
+ const parseCliFlags = (argv: string[]): Map<string, string> => {
19
+ const flags = new Map<string, string>();
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const arg = argv[i]!;
22
+ if (arg.startsWith('--')) {
23
+ const name = arg.slice(2);
24
+ const next = argv[i + 1];
25
+ if (next !== undefined && !next.startsWith('--')) {
26
+ flags.set(name, next);
27
+ i++;
28
+ } else {
29
+ flags.set(name, 'true'); // bare flag → boolean
30
+ }
31
+ }
32
+ }
33
+ return flags;
34
+ };
35
+
36
+ // Converts raw CLI strings to RuntimeValues according to each ArgDef,
37
+ // then declares them as fixed slots. Exits on missing or ill-typed args.
38
+ const bindArgs = (argDefs: ArgDef[], cliFlags: Map<string, string>, env: Environment): void => {
39
+ for (const def of argDefs) {
40
+ const raw = cliFlags.get(def.name);
41
+ if (raw === undefined) {
42
+ process.stderr.write(`Missing argument: --${def.name} (${def.type})\n`);
43
+ process.exit(1);
44
+ }
45
+
46
+ let value: RuntimeValue;
47
+ switch (def.type) {
48
+ case 'Int': {
49
+ if (!/^-?\d+$/.test(raw)) {
50
+ process.stderr.write(`--${def.name}: expected Int, got '${raw}'\n`);
51
+ process.exit(1);
52
+ }
53
+ value = { type: 'Int', value: BigInt(raw) };
54
+ break;
55
+ }
56
+ case 'Float': {
57
+ const n = Number(raw);
58
+ if (isNaN(n)) {
59
+ process.stderr.write(`--${def.name}: expected Float, got '${raw}'\n`);
60
+ process.exit(1);
61
+ }
62
+ value = { type: 'Float', value: n };
63
+ break;
64
+ }
65
+ case 'Bool': {
66
+ if (raw !== 'true' && raw !== 'false') {
67
+ process.stderr.write(`--${def.name}: expected Bool (true or false), got '${raw}'\n`);
68
+ process.exit(1);
69
+ }
70
+ value = { type: 'Bool', value: raw === 'true' };
71
+ break;
72
+ }
73
+ case 'String': {
74
+ value = { type: 'String', value: raw };
75
+ break;
76
+ }
77
+ }
78
+
79
+ env.declare(def.name, value, false);
80
+ }
81
+ };
82
+
83
+ const runFile = async (filePath: string): Promise<void> => {
84
+ if (!filePath.endsWith('.asc')) {
85
+ process.stderr.write(`Expected a .asc file, got '${filePath}'\n`);
86
+ process.exit(1);
87
+ }
88
+
89
+ let src: string;
90
+ try {
91
+ src = await readFile(filePath, 'utf8');
92
+ } catch {
93
+ process.stderr.write(`Cannot read file '${filePath}'\n`);
94
+ process.exit(1);
95
+ }
96
+
97
+ const lexResult = new Lexer(src).tokenize();
98
+ const parseResult = new Parser(lexResult.tokens).parse();
99
+
100
+ const errors = [...lexResult.errorMarkers, ...parseResult.errorMarkers];
101
+ if (errors.length > 0) {
102
+ for (const marker of errors) {
103
+ process.stderr.write(chalk.red(`[${marker.code}]`) + '\n');
104
+ }
105
+ process.exit(1);
106
+ }
107
+
108
+ if (parseResult.program === null) return;
109
+
110
+ const typeResult = typecheck(parseResult.program);
111
+ if (typeResult.errorMarkers.length > 0) {
112
+ for (const marker of typeResult.errorMarkers) {
113
+ process.stderr.write(chalk.red(`[${marker.code}]`) + '\n');
114
+ }
115
+ process.exit(1);
116
+ }
117
+
118
+ const env = new Environment();
119
+ if (parseResult.program.args.length > 0) {
120
+ bindArgs(parseResult.program.args, parseCliFlags(process.argv.slice(3)), env);
121
+ }
122
+
123
+ try {
124
+ const result = executeProgram(typeResult.typedProgram!, env);
125
+ if (result.type !== 'Done') {
126
+ process.stdout.write(formatValue(result) + '\n');
127
+ }
128
+ } catch (e) {
129
+ process.stderr.write(chalk.red(String(e)) + '\n');
130
+ process.exit(1);
131
+ }
132
+ };
133
+
134
+ const runRepl = async (): Promise<void> => {
135
+ process.stdout.write(chalk.bold.green('Ascent') + ' REPL\n');
136
+
137
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
138
+ const env = new Environment();
139
+
140
+ try {
141
+ while (true) {
142
+ const line = await rl.question(PROMPT);
143
+
144
+ const lexResult = new Lexer(line).tokenize();
145
+ let markerIndex = 0;
146
+ const tokenParts = lexResult.tokens
147
+ .filter(tok => tok.kind !== 'EOF')
148
+ .map(tok => {
149
+ if (tok.kind === 'ERROR') {
150
+ const code = lexResult.errorMarkers[markerIndex++]?.code ?? '?';
151
+ return chalk.red(`[${code}]`);
152
+ }
153
+ return `[${chalk.cyan(tok.kind)} ${chalk.yellow(`"${tok.value}"`)}]`;
154
+ });
155
+
156
+ process.stdout.write(tokenParts.join(` ${chalk.dim('·')} `) + '\n');
157
+
158
+ const parseResult = new Parser(lexResult.tokens).parse();
159
+
160
+ // A non-null program no longer means error-free: panic-mode
161
+ // recovery can skip a malformed statement and still finish the
162
+ // parse, so errorMarkers — not program nullness — is what decides
163
+ // whether it's safe to typecheck/run.
164
+ if (parseResult.errorMarkers.length > 0) {
165
+ // Only show parser errors when the lexer succeeded — if the lexer
166
+ // already flagged something, the parser error is a downstream echo.
167
+ if (lexResult.errorMarkers.length === 0) {
168
+ for (const marker of parseResult.errorMarkers) {
169
+ process.stdout.write(chalk.red(`[${marker.code}]`) + '\n');
170
+ }
171
+ }
172
+ } else if (parseResult.program !== null) {
173
+ const typeResult = typecheck(parseResult.program);
174
+ const typeErrors = typeResult.errorMarkers;
175
+
176
+ if (typeErrors.length > 0) {
177
+ for (const marker of typeErrors) {
178
+ process.stdout.write(chalk.red(`[${marker.code}]`) + '\n');
179
+ }
180
+ } else {
181
+ // Print the untyped parse tree; execute the typed AST.
182
+ // The two arrays are guaranteed to be the same length when
183
+ // typedProgram is non-null (every statement type-checked).
184
+ const typedStmts = typeResult.typedProgram!.stmts;
185
+ for (let i = 0; i < typedStmts.length; i++) {
186
+ process.stdout.write(formatTypedStmt(typedStmts[i]!) + '\n');
187
+ try {
188
+ const result = executeStmt(typedStmts[i]!, env);
189
+ process.stdout.write(chalk.dim('=> ') + formatValue(result) + '\n');
190
+ } catch (e) {
191
+ process.stdout.write(chalk.red(String(e)) + '\n');
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ } catch {
198
+ // stdin closed (Ctrl+D)
199
+ } finally {
200
+ rl.close();
201
+ }
202
+ };
203
+
204
+ const main = async (): Promise<void> => {
205
+ const filePath = process.argv[2];
206
+ if (filePath !== undefined) {
207
+ await runFile(filePath);
208
+ } else {
209
+ await runRepl();
210
+ }
211
+ };
212
+
213
+ main();
@@ -0,0 +1,332 @@
1
+ import type { BinaryOp } from './parser/ast.js';
2
+ import type { TypedExpr, TypedBlock, TypedStatement, TypedProgram } from './parser/typed-ast.js';
3
+ import type { AscentType } from './types/types.js';
4
+
5
+ export type RuntimeValue = (
6
+ | { type: 'Int'; value: bigint }
7
+ | { type: 'Float'; value: number }
8
+ | { type: 'Bool'; value: boolean }
9
+ | { type: 'String'; value: string }
10
+ | { type: 'List'; elements: RuntimeValue[] }
11
+ | { type: 'None' }
12
+ | { type: 'Done' }
13
+ );
14
+
15
+ type Binding = { value: RuntimeValue; mutable: boolean };
16
+
17
+ export type AssignResult = 'ok' | 'immutable' | 'undeclared';
18
+
19
+ // A chain of scopes, one per block. A lookup (or assignment) walks
20
+ // outward through parents; a declaration always writes to the current
21
+ // (innermost) scope, so a 'fix'/'mut' inside a block shadows an outer
22
+ // slot of the same name without touching it, and the shadow disappears
23
+ // once the block ends.
24
+ export class Environment {
25
+ private readonly vars = new Map<string, Binding>();
26
+
27
+ public constructor(private readonly parent: Environment | null = null) { }
28
+
29
+ public get(name: string): RuntimeValue | undefined {
30
+ return this.vars.get(name)?.value ?? this.parent?.get(name);
31
+ }
32
+
33
+ public declare(name: string, value: RuntimeValue, mutable: boolean): void {
34
+ this.vars.set(name, { value, mutable });
35
+ }
36
+
37
+ // Reassigns a slot in whichever scope actually owns it (not
38
+ // necessarily this one), mutating the binding in place so every
39
+ // Environment sharing this chain sees the new value immediately —
40
+ // this is what lets a 'while' loop's condition observe a slot its
41
+ // body just changed.
42
+ public assign(name: string, value: RuntimeValue): AssignResult {
43
+ const binding = this.vars.get(name);
44
+ if (binding === undefined) {
45
+ return this.parent?.assign(name, value) ?? 'undeclared';
46
+ }
47
+ if (!binding.mutable) return 'immutable';
48
+ binding.value = value;
49
+ return 'ok';
50
+ }
51
+
52
+ public child(): Environment {
53
+ return new Environment(this);
54
+ }
55
+ }
56
+
57
+ // Coerce a runtime value to match a target type when the target is Float
58
+ // and the value is Int — the only implicit widening the language allows.
59
+ // All other type conversions are explicit (methods like toFloat/toInt).
60
+ const coerce = (v: RuntimeValue, targetType: AscentType): RuntimeValue => {
61
+ if (targetType.kind === 'Float' && v.type === 'Int') {
62
+ return { type: 'Float', value: Number(v.value) };
63
+ }
64
+ return v;
65
+ };
66
+
67
+ export const evaluateExpr = (expr: TypedExpr, env: Environment): RuntimeValue => {
68
+ switch (expr.kind) {
69
+ case 'literal': {
70
+ switch (expr.valueType) {
71
+ case 'Int': return { type: 'Int', value: expr.value };
72
+ case 'Float': return { type: 'Float', value: expr.value };
73
+ case 'Bool': return { type: 'Bool', value: expr.value };
74
+ case 'String': return { type: 'String', value: expr.value };
75
+ case 'None': return { type: 'None' };
76
+ case 'Done': return { type: 'Done' };
77
+ }
78
+ }
79
+ case 'slot': {
80
+ // N0001 / N0002 are caught at type-check time; this is an internal guard.
81
+ const value = env.get(expr.name);
82
+ if (value === undefined) throw new Error(`internal: unbound slot '${expr.name}'`);
83
+ return value;
84
+ }
85
+ case 'call': {
86
+ // floor is the only built-in; others are rejected by the type checker.
87
+ const args = expr.args.map(a => evaluateExpr(a, env));
88
+ if (expr.callee === 'floor') {
89
+ const arg = args[0]!;
90
+ if (arg.type !== 'Float') throw new Error('internal: floor arg not Float');
91
+ return { type: 'Float', value: Math.floor(arg.value) };
92
+ }
93
+ throw new Error(`internal: unknown built-in '${expr.callee}'`);
94
+ }
95
+ case 'methodCall': {
96
+ const receiver = evaluateExpr(expr.receiver, env);
97
+ const args = expr.args.map(a => evaluateExpr(a, env));
98
+ return evalMethodCall(receiver, expr.method, args, expr.type);
99
+ }
100
+ case 'list': {
101
+ // expr.type is List<T>; coerce each element to T (handles Int → Float).
102
+ const elemType = expr.type.kind === 'List' ? expr.type.elem : null;
103
+ const elements = expr.elements.map(el => {
104
+ const v = evaluateExpr(el, env);
105
+ return elemType !== null ? coerce(v, elemType) : v;
106
+ });
107
+ return { type: 'List', elements };
108
+ }
109
+ case 'index': {
110
+ const list = evaluateExpr(expr.list, env);
111
+ const idx = evaluateExpr(expr.index, env);
112
+ if (list.type !== 'List') throw new Error('internal: index receiver not a List');
113
+ if (idx.type !== 'Int') throw new Error('internal: index not an Int');
114
+ const i = Number(idx.value);
115
+ if (i < 0 || i >= list.elements.length) {
116
+ throw new Error(`index ${i} out of bounds (length ${list.elements.length})`);
117
+ }
118
+ return list.elements[i]!;
119
+ }
120
+ case 'unary': {
121
+ const operand = evaluateExpr(expr.operand, env);
122
+ if (operand.type === 'Int') return { type: 'Int', value: -operand.value };
123
+ if (operand.type === 'Float') return { type: 'Float', value: -operand.value };
124
+ throw new Error(`internal: unary '-' on ${operand.type}`);
125
+ }
126
+ case 'binary':
127
+ return evaluateBinary(expr.op, evaluateExpr(expr.left, env), evaluateExpr(expr.right, env));
128
+ case 'block': {
129
+ return evaluateBlock(expr, env);
130
+ }
131
+ case 'if': {
132
+ const cond = evaluateExpr(expr.cond, env);
133
+ if (cond.type !== 'Bool') throw new Error('internal: if condition not Bool');
134
+ if (cond.value) return evaluateExpr(expr.then, env);
135
+ if (expr.else !== null) return evaluateExpr(expr.else, env);
136
+ return { type: 'Done' };
137
+ }
138
+ }
139
+ };
140
+
141
+ const evaluateBlock = (block: TypedBlock, env: Environment): RuntimeValue => {
142
+ const blockEnv = env.child();
143
+ let result: RuntimeValue = { type: 'Done' };
144
+ for (const stmt of block.stmts) {
145
+ result = executeStmt(stmt, blockEnv);
146
+ }
147
+ return result;
148
+ };
149
+
150
+ export const executeStmt = (stmt: TypedStatement, env: Environment): RuntimeValue => {
151
+ switch (stmt.kind) {
152
+ case 'fix':
153
+ case 'mut': {
154
+ // Coerce the init value to the declared slot type (handles Int → Float
155
+ // when the annotation says Float but the literal is an Int).
156
+ const value = coerce(evaluateExpr(stmt.init, env), stmt.slotType);
157
+ env.declare(stmt.name, value, stmt.kind === 'mut');
158
+ return { type: 'Done' };
159
+ }
160
+ case 'assign': {
161
+ const value = coerce(evaluateExpr(stmt.value, env), stmt.slotType);
162
+ const result = env.assign(stmt.name, value);
163
+ if (result !== 'ok') throw new Error(`internal: assign '${stmt.name}' → ${result}`);
164
+ return { type: 'Done' };
165
+ }
166
+ case 'expr':
167
+ return evaluateExpr(stmt.expr, env);
168
+ case 'while': {
169
+ // Each iteration evaluates the body as a block, giving it a fresh
170
+ // child scope — a 'fix' from one iteration doesn't leak into the next.
171
+ while (true) {
172
+ const cond = evaluateExpr(stmt.cond, env);
173
+ if (cond.type !== 'Bool') throw new Error('internal: while condition not Bool');
174
+ if (!cond.value) break;
175
+ evaluateBlock(stmt.body, env);
176
+ }
177
+ return { type: 'Done' };
178
+ }
179
+ }
180
+ };
181
+
182
+ // ---- Method dispatch ------------------------------------------------
183
+
184
+ const evalIntMethod = (receiver: Extract<RuntimeValue, { type: 'Int' }>, method: string, _args: RuntimeValue[]): RuntimeValue => {
185
+ switch (method) {
186
+ case 'toStr': return { type: 'String', value: String(receiver.value) };
187
+ case 'toFloat': return { type: 'Float', value: Number(receiver.value) };
188
+ case 'abs': return { type: 'Int', value: receiver.value < 0n ? -receiver.value : receiver.value };
189
+ default: throw new Error(`internal: Int has no method '${method}'`);
190
+ }
191
+ };
192
+
193
+ const evalFloatMethod = (receiver: Extract<RuntimeValue, { type: 'Float' }>, method: string, args: RuntimeValue[]): RuntimeValue => {
194
+ switch (method) {
195
+ case 'toStr': return { type: 'String', value: String(receiver.value) };
196
+ case 'toInt': return { type: 'Int', value: BigInt(Math.trunc(receiver.value)) };
197
+ case 'abs': return { type: 'Float', value: Math.abs(receiver.value) };
198
+ case 'min': {
199
+ const r = args[0]!;
200
+ return { type: 'Float', value: Math.min(receiver.value, r.type === 'Int' ? Number(r.value) : (r as Extract<RuntimeValue, { type: 'Float' }>).value) };
201
+ }
202
+ case 'max': {
203
+ const r = args[0]!;
204
+ return { type: 'Float', value: Math.max(receiver.value, r.type === 'Int' ? Number(r.value) : (r as Extract<RuntimeValue, { type: 'Float' }>).value) };
205
+ }
206
+ default: throw new Error(`internal: Float has no method '${method}'`);
207
+ }
208
+ };
209
+
210
+ const evalListMethod = (
211
+ receiver: Extract<RuntimeValue, { type: 'List' }>,
212
+ method: string, args: RuntimeValue[], resultType: AscentType,
213
+ ): RuntimeValue => {
214
+ // For methods that return a List, resultType.elem is the element type to
215
+ // coerce to (handles Int → Float when the list widens, e.g. after concat).
216
+ const elemType = resultType.kind === 'List' ? resultType.elem : null;
217
+ const coerceElem = (v: RuntimeValue) => elemType !== null ? coerce(v, elemType) : v;
218
+
219
+ switch (method) {
220
+ case 'length': return { type: 'Int', value: BigInt(receiver.elements.length) };
221
+ case 'isEmpty': return { type: 'Bool', value: receiver.elements.length === 0 };
222
+ case 'reverse': return { type: 'List', elements: [...receiver.elements].reverse().map(coerceElem) };
223
+ case 'append':
224
+ return { type: 'List', elements: [...receiver.elements.map(coerceElem), coerceElem(args[0]!)] };
225
+ case 'prepend':
226
+ return { type: 'List', elements: [coerceElem(args[0]!), ...receiver.elements.map(coerceElem)] };
227
+ case 'concat': {
228
+ const other = args[0]! as Extract<RuntimeValue, { type: 'List' }>;
229
+ return {
230
+ type: 'List',
231
+ elements: [...receiver.elements.map(coerceElem), ...other.elements.map(coerceElem)],
232
+ };
233
+ }
234
+ default: throw new Error(`internal: List has no method '${method}'`);
235
+ }
236
+ };
237
+
238
+ const evalMethodCall = (
239
+ receiver: RuntimeValue, method: string, args: RuntimeValue[], resultType: AscentType,
240
+ ): RuntimeValue => {
241
+ switch (receiver.type) {
242
+ case 'Int': return evalIntMethod(receiver, method, args);
243
+ case 'Float': return evalFloatMethod(receiver, method, args);
244
+ case 'List': return evalListMethod(receiver, method, args, resultType);
245
+ default: throw new Error(`internal: ${receiver.type} has no methods`);
246
+ }
247
+ };
248
+
249
+ // ---- Binary operators -----------------------------------------------
250
+
251
+ type Numeric = { type: 'Int'; value: bigint } | { type: 'Float'; value: number };
252
+ const isNumeric = (v: RuntimeValue): v is Numeric => v.type === 'Int' || v.type === 'Float';
253
+ const asFloat = (v: Numeric): number => (v.type === 'Int' ? Number(v.value) : v.value);
254
+
255
+ // BigInt's own '%' truncates toward zero (remainder takes the sign of
256
+ // the dividend, like C/Java/JS). 'mod' instead floors — the result
257
+ // takes the sign of the divisor — so a single correction pass covers
258
+ // the case where the truncating remainder landed on the wrong side of
259
+ // zero. 'div' is then defined from 'mod' so the identity
260
+ // `(a div b) * b + (a mod b) == a` holds by construction.
261
+ const floorDivMod = (a: bigint, b: bigint): { div: bigint; mod: bigint } => {
262
+ let mod = a % b;
263
+ if (mod !== 0n && (mod < 0n) !== (b < 0n)) mod += b;
264
+ return { div: (a - mod) / b, mod };
265
+ };
266
+
267
+ // '==' / '!=' are structural — same-type values compare by their
268
+ // contents — except Int meeting Float, which compares as numbers (the
269
+ // same one-way promotion arithmetic uses). Two Ints compare exactly, as
270
+ // BigInts, rather than going through asFloat and risking the precision
271
+ // loss a huge Int would suffer converting to a JS number.
272
+ const valuesEqual = (left: RuntimeValue, right: RuntimeValue): boolean => {
273
+ if (isNumeric(left) && isNumeric(right)) {
274
+ return left.type === 'Int' && right.type === 'Int'
275
+ ? left.value === right.value
276
+ : asFloat(left) === asFloat(right);
277
+ }
278
+ if (left.type !== right.type) return false;
279
+ if (left.type === 'Bool' && right.type === 'Bool') return left.value === right.value;
280
+ if (left.type === 'String' && right.type === 'String') return left.value === right.value;
281
+ return true; // None, Done — singleton types
282
+ };
283
+
284
+ const evaluateBinary = (op: BinaryOp, left: RuntimeValue, right: RuntimeValue): RuntimeValue => {
285
+ if (op === '==' || op === '!=') {
286
+ const eq = valuesEqual(left, right);
287
+ return { type: 'Bool', value: op === '==' ? eq : !eq };
288
+ }
289
+
290
+ if (!isNumeric(left) || !isNumeric(right)) throw new Error(`internal: '${op}' on non-numeric`);
291
+
292
+ if (op === '<' || op === '<=' || op === '>' || op === '>=') {
293
+ const useInt = left.type === 'Int' && right.type === 'Int';
294
+ const l = useInt ? left.value : asFloat(left);
295
+ const r = useInt ? right.value : asFloat(right);
296
+ const result = op === '<' ? l < r : op === '<=' ? l <= r : op === '>' ? l > r : l >= r;
297
+ return { type: 'Bool', value: result };
298
+ }
299
+
300
+ if (op === 'div' || op === 'mod') {
301
+ if (left.type !== 'Int' || right.type !== 'Int') throw new Error(`internal: '${op}' on non-Int`);
302
+ if (right.value === 0n) throw new Error(`${op} by zero`);
303
+ const { div, mod } = floorDivMod(left.value, right.value);
304
+ return { type: 'Int', value: op === 'div' ? div : mod };
305
+ }
306
+
307
+ if (op === '/') {
308
+ const divisor = asFloat(right);
309
+ if (divisor === 0) throw new Error('division by zero');
310
+ return { type: 'Float', value: asFloat(left) / divisor };
311
+ }
312
+
313
+ if (left.type === 'Int' && right.type === 'Int') {
314
+ const v = op === '+' ? left.value + right.value
315
+ : op === '-' ? left.value - right.value
316
+ : left.value * right.value;
317
+ return { type: 'Int', value: v };
318
+ }
319
+
320
+ const l = asFloat(left);
321
+ const r = asFloat(right);
322
+ const v = op === '+' ? l + r : op === '-' ? l - r : l * r;
323
+ return { type: 'Float', value: v };
324
+ };
325
+
326
+ export const executeProgram = (program: TypedProgram, env: Environment): RuntimeValue => {
327
+ let result: RuntimeValue = { type: 'Done' };
328
+ for (const stmt of program.stmts) {
329
+ result = executeStmt(stmt, env);
330
+ }
331
+ return result;
332
+ };
@@ -0,0 +1,12 @@
1
+ export const isDigit = (ch: string): boolean => ch >= '0' && ch <= '9';
2
+
3
+ export const isAlpha = (ch: string): boolean =>
4
+ (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_';
5
+
6
+ export const isAlphaNum = (ch: string): boolean => isAlpha(ch) || isDigit(ch);
7
+
8
+ export const isUpper = (ch: string): boolean => ch >= 'A' && ch <= 'Z';
9
+
10
+ export const isWhitespace = (ch: string): boolean => (
11
+ ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
12
+ );
@@ -0,0 +1,51 @@
1
+ import type { Position, Span } from './token.js';
2
+
3
+ export class Cursor {
4
+ private src: string;
5
+ private pos: number = 0;
6
+ private line: number = 1;
7
+ private col: number = 1;
8
+
9
+ public constructor(src: string) {
10
+ this.src = src;
11
+ }
12
+
13
+ public peek(offset = 0): string {
14
+ return this.src[this.pos + offset] ?? '\0';
15
+ }
16
+
17
+ public advance(): string {
18
+ const ch = this.src[this.pos++] ?? '\0';
19
+ if (ch === '\n') {
20
+ this.line++;
21
+ this.col = 1;
22
+ } else {
23
+ this.col++;
24
+ }
25
+ return ch;
26
+ }
27
+
28
+ public atEnd(): boolean {
29
+ return this.pos >= this.src.length;
30
+ }
31
+
32
+ public mark(): Position {
33
+ return { offset: this.pos, line: this.line, column: this.col };
34
+ }
35
+
36
+ public spanFrom(start: Position): Span {
37
+ return { start, end: this.mark() };
38
+ }
39
+
40
+ public match(ch: string): boolean {
41
+ if (this.src[this.pos] !== ch) {
42
+ return false;
43
+ }
44
+ this.advance();
45
+ return true;
46
+ }
47
+
48
+ public slice(start: Position): string {
49
+ return this.src.slice(start.offset, this.pos);
50
+ }
51
+ }