Oct 26, 2024
I mentioned before that getting an interpreter working is a nerd-snipe. Once it works a little, it's so much fun to keep adding functionality you end up working on it despite yourself. In this spirit, here is a writeup about my latest changes to ts-wolfram.
I initially wrote the interpreter using OOP. There were three AST
types (Integer, Symbol, and Form), each one derived from a
Node interface. This worked but bothered me, primarily as an
aesthetic matter. I prefer to think of programs in terms of pipelines
of transformations between data types. You can do this with classes,
but stylistically it doesn't quite fit and tends to leave a feeling of
dissatisfaction. So I got rid of classes in favor of an algebraic data
type, which to me looks much nicer:
export type Expr = Integer | Symbol | Form | String;
export enum Types {
Form, Integer, Symbol, String,
}
export type Form = {
type: Types.Form,
head: Expr,
parts: Expr[],
}
export type Integer = {
type: Types.Integer,
val: number,
}
// ...
Many small changes downstream of dropping OOP also made the code much
nicer. For example, I got rid of ugly instanceof calls. But overall,
code organization could still use one more pass of deliberate thinking
through the file structure, and putting code that belongs together in
dedicated files.
Second, I changed the interpreter to support mixing "kernel" and
"userspace" code in expression evaluation. Previously a function could
either be defined in Typescript, or in Mathematica code, but not both.
For example, Times is defined in Typescript, which meant I couldn't
put convenient transformation code into the prelude. Typing -a (-b)
in the REPL produced Times[Minus[a], Minus[b]]. It would be
convenient to add a rule to transform this into Times[a, b], but the
evaluator didn't support that.
Supporting this ended up being a small change. Once the evaluator supported mixed definitions I added the following line to the prelude:
Times[Minus[x_], Minus[y_]] := Times[x, y];
And voilĂ ! Typing -a (-b) now produces Times[a, b] without any
changes to Typescript code.
Finally, I wanted to improve printing. Typing something like Hold[a
/. a->b] printed the full form Hold[ReplaceAll[a, Rule[a, b]]]. I
wanted to print shorthand. My original plan was to add string support
and then use the same trick as with Times[Minus[...], Minus[...]] to
do pretty printing in userspace. I added strings, ToString and
<>/StringJoin, but then realized adding userspace rules to
ToString doesn't quite work. Calling ToString from the interpreter
to pretty print caused additional evaluation and printed incorrect
results.
Instead of going against the grain and trying to get this to work, I
just kept the string commands and implemented pretty printing in
Typescript. I added support for basic syntax so expressions like
Hold[a /. a->b] print correctly in shorthand. I also added the
FullForm command to help with debugging when I do need to see the
full form.
However, getting a good printer working is non-trivial. For example,
Sin[Cos[x]] Sin[x] currently prints (Sin[Cos[x]]) (Sin[x])-- the
printer doesn't know to drop parentheses. I didn't look into this too
closely, but it seems like the naive approach would require lots of
special cases to print expressions nicely. To get the printer working
well, I need either to add many of the special cases, or find a more
general approach (assuming one exists).