DEV Community

Jason Barr
Jason Barr

Posted on

Create Your Own Programming Language 8: Conditionals

It's time for the next installment in the Create Your Own Programming Language series. This time we're going to add conditionals to the Wanda language.

We're actually going to add 3 different kinds of conditionals: if, cond, and when.

if is a standard if expression, with 2 branches based on whether a test comes back truthy or falsy.

cond lets you chain together multiple conditions and expressions, with an :else branch at the end in case nothing matches. The :else branch is always required. cond expressions desugar to a chain of if expressions.

A cond expression looks like this. Note that each branch is enclosed within its own set of parentheses:

(cond
  ((= x 0) "zero")
  ((< x 10) "less than 10")
  ((< x 0) "less than zero")
  (:else "10 or greater"))
Enter fullscreen mode Exit fullscreen mode

when is imperative and lets you execute a block of code if the test is truthy. It evaluates to nil either way, so you wouldn't want to use it to assign a toplevel variable. It's more of an imperative construct, ideally for containing side effects.

When also allows you to nest any number of expressions inside it, whereas each branch of an if expression can only take a single expression. And since when doesn't have an else branch, you can use it in cases where you don't need an alternate execution path if the main one isn't taken.

Here's an example of a when expression:

(when (= 1 1)
  (println "Wow, one equals one?")
  (println "Who woulda thunk it???"))
Enter fullscreen mode Exit fullscreen mode

We're also going to add a new form, the LogicalExpression. This is for expressions with the and and or forms.

Some Lisps will let you use any number of arguments to the and and or forms, but we're going to restrict users to 2 arguments for each form to simplify things.

Finally, we'll add 2 new node types to help the type checker that will both desugar to call expressions: BinaryExpression which will include the equal? and not-equal? operations, and UnaryExpression which is only used with not.

These typechecker-only nodes will help with a process known as type narrowing, which allows the type checker to better understand things like which arm of a union to check against in branches of a conditional.

As always, if you haven't read the previous article in which we added several new types to the type checker, you should do that before reading ahead.

Ok? Let's go!

Changes to The Lexer and Reader

None! Both are fine just the way they are for today.

Changes to The Core Library

We need to add a not-equal? function to the core library to match up with the not-equal? binary expression.

Ordinarily in a Lisp when you want to check if a value is not equal you'd just prefix equal? with not, as in (not (equal? x y)), but to give the type checker some more help with narrowing we're adding this additional form.

In lib/js/core.js go down to the equal? function and add this between it and is?:

    "not-equal?": rt.makeFunction(
      (a, b) => {
        if (rt.hasDict(a) && rt.hasDict(b)) {
          return !equal(rt.getMetaField(a, "dict"), rt.getMetaField(b, "dict"));
        }
        return !equal(a, b);
      },
      { contract: "(any, any) -> boolean", name: "not-equal?" }
    ),
Enter fullscreen mode Exit fullscreen mode

That's it for changes to the core library, so now we can move on to changes to the parser.

Changes to The Parser

We need to define new AST node types and constructors for those nodes.

In src/parser/ast.js add the following to the ASTTypes enum:

export const ASTTypes = {
  // other members...
  IfExpression: "IfExpression",
  CondExpression: "CondExpression",
  WhenExpression: "WhenExpression",
  BinaryExpression: "BinaryExpression",
  LogicalExpression: "LogicalExpression",
  UnaryExpression: "UnaryExpression",
};
Enter fullscreen mode Exit fullscreen mode

And in the AST object add the following node constructors:

export const AST = {
  // other constructors...
  IfExpression(test, then, elseBranch, srcloc) {
    return {
      kind: ASTTypes.IfExpression,
      test,
      then,
      else: elseBranch,
      srcloc,
    };
  },

  CondExpression(clauses, elseBranch, srcloc) {
    return {
      kind: ASTTypes.CondExpression,
      clauses,
      else: elseBranch,
      srcloc,
    };
  },

  WhenExpression(test, body, srcloc) {
    return {
      kind: ASTTypes.WhenExpression,
      test,
      body,
      srcloc,
    };
  },

  BinaryExpression(left, op, right, srcloc) {
    return {
      kind: ASTTypes.BinaryExpression,
      left,
      op,
      right,
      srcloc,
    };
  },

  LogicalExpression(left, op, right, srcloc) {
    return {
      kind: ASTTypes.LogicalExpression,
      left,
      op,
      right,
      srcloc,
    };
  },

  UnaryExpression(op, operand, srcloc) {
    return {
      kind: ASTTypes.UnaryExpression,
      op,
      operand,
      srcloc,
    };
  },
}
Enter fullscreen mode Exit fullscreen mode

We'll need 6 new parse functions in src/parser/parse.js:

  • parseIfExpression
  • parseCondExpression
  • parseWhenExpression
  • parseBinaryExpression
  • parseLogicalExpression
  • parseUnaryExpression

Add the following cases to the switch statement in parseList:

    // other cases
    case "if":
      return parseIfExpression(form);
    case "cond":
      return parseCondExpression(form);
    case "when":
      return parseWhenExpression(form);
    case "equal?":
    case "not-equal?":
      return parseBinaryExpression(form);
    case "and":
    case "or":
      return parseLogicalExpression(form);
    case "not":
      return parseUnaryExpression(form);
    // default case
Enter fullscreen mode Exit fullscreen mode

parseIfExpression is pretty simple. Just grab the test, then, and elseBranch forms off the main form, parse each form, and pass them to the constructor.

const parseIfExpression = (form) => {
  const [_, test, then, elseBranch] = form;
  const srcloc = form.srcloc;
  const parsedTest = parseExpr(test);
  const parsedThen = parseExpr(then);
  const parsedElse = parseExpr(elseBranch);

  return AST.IfExpression(parsedTest, parsedThen, parsedElse, srcloc);
};
Enter fullscreen mode Exit fullscreen mode

parseCondExpression is a little more complicated. We have to grab all the clauses off the main form, parse each one individually, and then when we get to the else branch handle that one separately since it gets its own property on the AST node object. Also, since the else branch is required, we throw an error if there isn't one.

const parseCondExpression = (form) => {
  const [_, ...clauses] = form;
  const srcloc = form.srcloc;
  /** @type {import("./ast.js").CondClause[]} */
  const parsedClauses = [];
  let hasElse = false;

  if (!(clauses.length >= 2)) {
    throw new Exception(
      `Cond expression must have at least 2 clauses; ${clauses.length} given at ${srcloc.file}, (${srcloc.line}:${srcloc.col})`
    );
  }

  for (let clause of clauses) {
    const [test, expr] = clause;

    if (test.type === TokenTypes.Keyword && test.value === ":else") {
      hasElse = true;
      break;
    }

    const parsedTest = parseExpr(test);
    const parsedExpr = parseExpr(expr);

    parsedClauses.push({ test: parsedTest, expression: parsedExpr });
  }

  if (!hasElse) {
    throw new Exception(
      `Cond expression expects an :else clause; none given at ${srcloc.file}, (${srcloc.line}:${srcloc.col})`
    );
  }

  /** @type {Cons} */
  const elseClause = clauses[clauses.length - 1];
  // we need the first element of the tail of the else clause
  const parsedElse = parseExpr(elseClause.cdr.car);

  return AST.CondExpression(parsedClauses, parsedElse, srcloc);
};
Enter fullscreen mode Exit fullscreen mode

The other 4 parse functions, parseWhen, parseBinaryExpression, parseLogicalExpression, and parseUnaryExpression, are all simple:

const parseWhenExpression = (form) => {
  const [_, test, ...body] = form;
  const srcloc = form.srcloc;
  const parsedTest = parseExpr(test);
  const parsedBody = body.map(parseExpr);

  return AST.WhenExpression(parsedTest, parsedBody, srcloc);
};

const parseBinaryExpression = (form) => {
  const [op, left, right] = form;
  const srcloc = form.srcloc;
  const parsedLeft = parseExpr(left);
  const parsedRight = parseExpr(right);

  return AST.BinaryExpression(parsedLeft, op.value, parsedRight, srcloc);
};

const parseLogicalExpression = (form) => {
  const [op, left, right] = form;
  const srcloc = form.srcloc;
  const parsedLeft = parseExpr(left);
  const parsedRight = parseExpr(right);

  return AST.LogicalExpression(parsedLeft, op.value, parsedRight, srcloc);
};

const parseUnaryExpression = (form) => {
  const [op, operand] = form;
  const srcloc = form.srcloc;
  const parsedOperand = parseExpr(operand);

  return AST.UnaryExpression(op.value, parsedOperand, srcloc);
};
Enter fullscreen mode Exit fullscreen mode

Changes to The Type Checker

Obviously we need to infer and check types for the new nodes, but the most significant change we'll be implementing in the type checker today is the addition of type narrowing.

Type narrowing allows the type checker to check certain expressions as if a certain condition were true or false, which allows it to make deductions about types it otherwise would not be able to make.

For example, let's say we have a discriminated union type:

(type Coord
  { type: "cartesian", x: number, y: number }
  || { type: "polar", radius: number, magnitude: number })

(def (point: Coord) { type: "cartesian", x: 5, y: 2.5 })
Enter fullscreen mode Exit fullscreen mode

Given this when expression:

(when (equal? point.type "cartesian")
  ; assume point is the bottom-right point of
  ; a square that begins at the origin (0, 0)
  (def area (* point.x point.y))
  ; continue
Enter fullscreen mode Exit fullscreen mode

Without type narrowing, the above when expression would have failed to type check because due to the union type we couldn't be certain the object would have x and y properties. Thanks to narrowing, since we know type is "cartesian" we know which arm of the union the object satisfies.

As Jake Donham says in his article about type narrowing, it can be helpful to think of the type environment as a logical statement that asserts facts about the types of values. Narrowing allows us to supplement the known facts with assumptions guaranteed to be correct if the right conditions are met.

Adding The Negation Type

To implement narrowing we're going to add a type that is only used in the type checker: the negation type, or not. The not type is only used in the type checker, it can't be annotated, and in fact it is only used in type narrowing and it's limited just to that functionality so we don't have to rewrite the entire checker just to account for it.

Let's add it in src/typechecker/types.js to the TypeTypes enum:

export const TypeTypes = {
  // other members...
  Not: "Not",
};
Enter fullscreen mode Exit fullscreen mode

We'll also need a constructor in src/typechecker/constructors.js:

export const not = (base) => ({ kind: TypeTypes.Not, base });
Enter fullscreen mode Exit fullscreen mode

and a validator in src/typechecker/validators.js:

export const isNot = (type) => {
  return type.kind === TypeTypes.Not;
};
Enter fullscreen mode Exit fullscreen mode

We'll also need some utility functions and types for checking truthiness and falsiness of a value, when it can be known at compile time. We'll create those in src/typechecker/utils.js:

export const isTruthy = (type) => {
  switch (type.kind) {
    case TypeTypes.Nil:
      return false;
    case TypeTypes.Singleton:
      if (type.value === "false") {
        return false;
      }
    default:
      if (type.kind !== TypeTypes.Boolean) {
        return true;
      }
  }
};

export const isFalsy = (type) => {
  switch (type.kind) {
    case TypeTypes.Nil:
      return true;
    case TypeTypes.Singleton:
      if (type.value === "false") {
        return true;
      }
    default:
      if (type.kind !== TypeTypes.Boolean) {
        return false;
      }
  }
};

export const falsy = union(singleton("Boolean", "false"), nil);

export const truthy = intersection(
  not(singleton("Boolean", "false")),
  not(nil)
);
Enter fullscreen mode Exit fullscreen mode

Remember that in Wanda all values except false and nil are truthy.

Let's add our utils to the Type module in src/typechecker/Type.js:

import { TypeTypes } from "./types.js";
import * as Validators from "./validators.js";
import * as Constructors from "./constructors.js";
import { typeToString } from "./typeToString.js";
import { union } from "./union.js";
import { map } from "./map.js";
import { intersection } from "./intersection.js";
import { isTruthy, isFalsy, truthy, falsy } from "./utils.js";

export const Type = {
  Type: TypeTypes,
  ...Constructors,
  ...Validators,
  toString: typeToString,
  union,
  map,
  intersection,
  isTruthy,
  isFalsy,
  truthy,
  falsy,
};
Enter fullscreen mode Exit fullscreen mode

Just a quick note: I had a weird circular reference bug that popped up sometimes in Node when trying to use the type checker. The solution turned out to be removing the import of the Type module into src/typechecker/isSubtype.js and instead importing each of the type validators used in isSubtype one at a time and removing the Type. prefix for validators within isSubtype. If you have errors like "Cannot use Type before its initialization" after adding the rest of the code in this section that's how you fix them.

Narrowing

Now let's actually implement the narrowing functions. It's actually a lot of functions! But the thing most of them have in common is they restrict the applications of a type within an environment and return that modified environment to be used by the caller.

For a fuller explanation of what I'm about to show you, see Donham's post about type narrowing.

First is the narrow function.

When we type check a branch of a conditional, we call narrow with the test expression, the current environment, and a flag according to whether we're assuming the test to be true or false. It returns a new environment incorporating the information of the test and assumption.

When we deduce that a subexpression must satisfy a type, we call narrowUnary, narrowLogical, narrowBinary, or narrowPath to incorporate the appropriate expression type into the environment. When we reach a variable we call narrowType, which performs an operation similar to subtype checking between the current type from the environment and the deduced type in order to update the variable's type with the result.

Here are the narrowing functions, including the function widenNots that keeps not types from being returned in the environments produced by narrowing, in src/typechecker/narrow.js:

import { AST, ASTTypes } from "../parser/ast.js";
import { Exception, TypeException } from "../shared/exceptions.js";
import { Type } from "./Type.js";
import { TypeEnvironment } from "./TypeEnvironment.js";
import { infer } from "./infer.js";
import { isSubtype } from "./isSubtype.js";
import { propType } from "./propType.js";
import { TypeTypes } from "./types.js";

export const narrow = (ast, env, assume) => {
  switch (ast.kind) {
    case ASTTypes.UnaryExpression:
      return narrowUnary(ast, env, assume);

    case ASTTypes.LogicalExpression:
      return narrowLogical(ast, env, assume);

    case ASTTypes.BinaryExpression:
      return narrowBinary(ast, env, assume);

    default:
      return narrowPath(ast, env, assume ? Type.truthy : Type.falsy);
  }
};

const narrowUnary = (ast, env, assume) => {
  switch (ast.op) {
    case "not":
      return narrow(ast.operand, env, !assume);

    default:
      throw new Exception(`Unknown unary operator ${ast.op}`);
  }
};

const narrowLogical = (ast, env, assume) => {
  switch (ast.op) {
    case "and":
      if (assume) {
        env = narrow(ast.left, env, true);
        return narrow(ast.right, env, true);
      } else {
        if (Type.isTruthy(infer(ast.left, env))) {
          return narrow(ast.right, env, false);
        } else if (Type.isTruthy(infer(ast.right, env))) {
          return narrow(ast.left, env, false);
        } else {
          return env;
        }
      }

    case "or":
      if (!assume) {
        env = narrow(ast.left, env, false);
        return narrow(ast.right, env, false);
      } else {
        if (Type.isFalsy(infer(ast.left, env))) {
          return narrow(ast.right, env, true);
        } else if (Type.isFalsy(infer(ast.right, env))) {
          return narrow(ast.left, env, true);
        } else {
          return env;
        }
      }

    default:
      throw new Exception(`Unknown logical operator ${ast.op}`);
  }
};

const narrowBinary = (ast, env, assume) => {
  const left = infer(ast.left, env);
  const right = infer(ast.right, env);

  if ((ast.op === "equal?" && assume) || (ast.op === "not-equal?" && !assume)) {
    env = narrowPath(ast.left, env, right);
    return narrowPath(ast.right, env, left);
  } else if (
    (ast.op === "not-equal?" && assume) ||
    (ast.op === "equal?" && !assume)
  ) {
    if (Type.isSingleton(right)) {
      env = narrowPath(ast.left, env, Type.not(right));
    }

    if (Type.isSingleton(left)) {
      env = narrowPath(ast.right, env, Type.not(left));
    }

    return env;
  } else {
    return env;
  }
};

const narrowPath = (ast, env, type) => {
  switch (ast.kind) {
    case ASTTypes.Symbol:
      return narrowPathSymbol(ast, env, type);

    case ASTTypes.MemberExpression:
      return narrowPathMember(ast, env, type);

    default:
      return env;
  }
};

const narrowPathSymbol = (ast, env, type) => {
  const idType = env.get(ast.name);

  if (!idType) {
    throw new TypeException(
      `Expected bound identifier, got undefined`,
      ast.srcloc
    );
  }

  env.set(ast.name, narrowType(idType, type));
  return env;
};

const narrowPathMember = (ast, env, type) => {
  return narrowPath(
    ast.object,
    env,
    Type.object([{ name: ast.property.name, type: type }])
  );
};

export const narrowType = (x, y) => {
  if (Type.isAny(x) || Type.isAny(y)) return Type.any;
  if (Type.isUndefined(x) && Type.isUndefined(y)) return Type.undefinedType;
  if (Type.isUndefined(x)) return widenNots(y);
  if (Type.isUndefined(y)) return x;
  if (Type.isNever(x) || Type.isNever(y)) return Type.never;
  if (Type.isUnknown(x)) return widenNots(y);
  if (Type.isUnknown(y)) return x;

  if (Type.isTuple(x)) {
    return Type.tuple(x.types.map((a) => narrowType(a, y)));
  }

  if (Type.isTuple(y)) {
    return Type.tuple(y.types.map((b) => narrowType(x, b)));
  }

  if (Type.isUnion(x)) {
    return Type.union(...x.types.map((a) => narrowType(a, y)));
  }

  if (Type.isUnion(y)) {
    return Type.union(...y.types.map((b) => narrowType(x, b)));
  }

  if (Type.isIntersection(x)) {
    return Type.intersection(...x.types.map((a) => narrowType(a, y)));
  }

  if (Type.isIntersection(y)) {
    return Type.intersection(...y.types.map((b) => narrowType(x, b)));
  }

  if (Type.isNot(y)) {
    if (isSubtype(x, y.base)) {
      return Type.never;
    } else if (
      Type.isBoolean(x) &&
      Type.isSingleton(y.base) &&
      Type.isBoolean(y.base.base)
    ) {
      return Type.singleton(
        "Boolean",
        y.base.value === "true" ? "false" : "true"
      );
    } else {
      return x;
    }
  }

  if (Type.isSingleton(x) && Type.isSingleton(y)) {
    return x.value === y.value ? x : Type.never;
  }

  if (Type.isSingleton(x)) {
    return x.base === y.kind ? x : Type.never;
  }

  if (Type.isSingleton(y)) {
    return y.base === x.kind ? y : Type.never;
  }

  if (Type.isObject(x) && Type.isObject(y)) {
    const properties = x.properties.map(({ name, type: xType }) => {
      const yType = propType(y, name);
      const type = yType ? narrowType(xType, yType) : xType;
      return { name, type };
    });

    if (properties.some(({ type }) => Type.isNever(type))) {
      return Type.never;
    } else {
      return Type.object(properties);
    }
  }

  return Type.intersection(x, y);
};

const widenNots = (type) => {
  switch (type.kind) {
    case TypeTypes.Not:
      return Type.unknown;

    case TypeTypes.Union:
      return Type.union(...type.types.map(widenNots));

    case TypeTypes.Object:
      return Type.object(
        type.properties.map(({ name, type }) => ({
          name,
          type: widenNots(type),
        }))
      );

    default:
      return type;
  }
};
Enter fullscreen mode Exit fullscreen mode

That's actually a lot of code! Please check out Jake's article if you don't understand anything—he explains the details quite well.

Inference

We need to make some changes to the map function in src/typechecker/map.js before we move on to the inference functions. Namely, we need to make it so map can accept either 2 or 3 arguments based on whether we're mapping over 1 type or 2. That way we can use map with binary expressions.

Rename map to map1, then add this new function in as map and export it:

export const map = (...args) => {
  switch (args.length) {
    case 2:
      return map1(args[0], args[1]);
    case 3:
      return map2(args[0], args[1], args[2]);
    default:
      throw new Exception(`Unexpected args length ${args.length}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

Here's the map2 function:

export const map2 = (t1, t2, fn) => {
  if (isUnion(t1) || isUnion(t2)) {
    const t1s = isUnion(t1) ? t1.types : [t1];
    const t2s = isUnion(t2) ? t2.types : [t2];
    const ts = [];
    for (const t1 of t1s) {
      for (const t2 of t2s) {
        ts.push(map(t1, t2, fn));
      }
    }
    return union(...ts);
  } else if (isIntersection(t1) || isIntersection(t2)) {
    const t1s = isIntersection(t1) ? t1.types : [t1];
    const t2s = isIntersection(t2) ? t2.types : [t2];
    const ts = [];
    let error = undefined;
    for (const t1 of t1s) {
      for (const t2 of t2s) {
        try {
          ts.push(fn(t1, t2));
        } catch (e) {
          if (!error) error = e;
        }
      }
    }
    if (ts.length === 0) {
      throw error;
    } else {
      return intersection(...ts);
    }
  } else {
    return fn(t1, t2);
  }
};
Enter fullscreen mode Exit fullscreen mode

Now we need 6 new cases in the switch statement in infer in src/typechecker/infer.js:

    // other cases...
    case ASTTypes.IfExpression:
      return inferIfExpression(ast, env, constant);
    case ASTTypes.WhenExpression:
      return inferWhenExpression(ast, env, constant);
    case ASTTypes.CondExpression:
      return inferCondExpression(ast, env, constant);
    case ASTTypes.UnaryExpression:
      return inferUnaryExpression(ast, env, constant);
    case ASTTypes.BinaryExpression:
      return inferBinaryExpression(ast, env, constant);
    case ASTTypes.LogicalExpression:
      return inferLogicalExpression(ast, env, constant);
    // default case
Enter fullscreen mode Exit fullscreen mode

For inferring if expressions, we first infer the test. Then we use the test to narrow for each of the consequent and alternate branches, wrapping each in a thunk so inference only happens if it's needed.

If the test is known to be truthy or falsy, we infer only the appropriate branch. Otherwise, we unify the two branches.

TypeScript constructs a union type of the two branches if they produce different types, but I want to enforce some degree of similarity between the branches so we're unifying the two types instead of going with a simple union. That means the type of one branch must be the same as or a subtype of the other.

Here's inferIfExpression:

const inferIfExpression = (node, env, constant) => {
  const test = infer(node.test, env, constant);
  const consequent = () => infer(node.then, narrow(node.test, env, true));
  const alternate = () => infer(node.else, narrow(node.test, env, false));

  if (Type.isTruthy(test)) {
    return consequent();
  } else if (Type.isFalsy(test)) {
    return alternate();
  } else {
    return unify(consequent(), alternate());
  }
};
Enter fullscreen mode Exit fullscreen mode

inferWhenExpression is a little bit strange. It returns the nil type no matter what. But we still infer a type for the test then loop over the body block and infer a type for each expression to make sure there are no inference errors:

const inferWhenExpression = (node, env, constant) => {
  const test = infer(node.test, env, constant);

  // if the test is falsy, this will never run
  if (!Type.isFalsy(test)) {
    // check expressions in body
    for (let expr of node.body) {
      infer(expr, narrow(node.test, env, true), constant);
    }
  }

  return Type.nil;
};
Enter fullscreen mode Exit fullscreen mode

inferCondExpression loops over all the clauses. If one of the clause tests produces a known truthy type, we return the type of its expression. Otherwise we collect the types of all clauses, including the :else clause, and unify them all. That means the common subtype is produced as the type for the expression so, like with an if expression, the types for each branch need to be subtypes of each other.

const inferCondExpression = (node, env, constant) => {
  let types = [];

  for (let clause of node.clauses) {
    const test = infer(clause.test, env, constant);
    const type = infer(
      clause.expression,
      narrow(clause.test, env, true),
      constant
    );

    if (Type.isTruthy(test)) {
      // the first truthy condition type will trigger the clause's expression
      return type;
    }

    types.push(type);
  }

  const elseType = infer(node.else, env, constant);

  return unifyAll(...types, elseType);
};
Enter fullscreen mode Exit fullscreen mode

Since not is the only unary expression, that's the only case inferUnaryExpression handles. If the type of its operand is truthy or falsy it returns a singleton type of the opposite boolean. Otherwise, if it can't be determined at compile time, it returns the type boolean.

const inferUnaryExpression = (node, env, constant) => {
  const operand = infer(node.operand, env, constant);

  return Type.map(operand, (operand) => {
    switch (node.op) {
      case "not":
        if (Type.isTruthy(operand)) {
          return Type.singleton("Boolean", "false");
        } else if (Type.isFalsy(operand)) {
          return Type.singleton("Boolean", true);
        } else {
          return Type.boolean;
        }

      default:
        throw new Exception(`Unknown unary operator ${node.op}`);
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

inferBinaryExpression is similar, but it has 2 cases to handle and each case has 2 operands:

const inferBinaryExpression = (node, env, constant) => {
  const left = infer(node.left, env, constant);
  const right = infer(node.right, env, constant);

  return Type.map(left, right, (left, right) => {
    switch (node.op) {
      case "equal?":
        if (Type.isSingleton(left) && Type.isSingleton(right)) {
          return Type.singleton(left.value === right.value ? "true" : "false");
        } else {
          return Type.boolean;
        }

      case "not-equal?":
        if (Type.isSingleton(left) && Type.isSingleton(right)) {
          return Type.singleton(left.value !== right.value ? "true" : "false");
        } else {
          return Type.boolean;
        }

      default:
        throw new Exception(`Unknown binary operator ${node.op}`);
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

Finally, with inferLogicalExpression we can make assumptions to narrow the types based on the truthiness or falsiness of the left operand. For and, we unify the right with the result of narrowing the left type with the falsy type union, and for or we do the opposite:

const inferLogicalExpression = (node, env, constant) => {
  const left = infer(node.left, env, constant);
  const right = () => infer(node.right, env, constant);

  switch (node.op) {
    case "and":
      if (Type.isFalsy(left)) {
        return left;
      } else if (Type.isTruthy(left)) {
        return right();
      } else {
        return unify(narrowType(left, Type.falsy), right());
      }

    case "or":
      if (Type.isTruthy(left)) {
        return left;
      } else if (Type.isFalsy(left)) {
        return right();
      } else {
        return unify(narrowType(left, Type.truthy), right());
      }

    default:
      throw new Exception(`Unknown logical operator ${node.op}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

Checking Against If and Cond Expressions

We need to add cases to the check function in src/typechecker/check.js for if and cond expressions:

  // other cases
  if (ast.kind === ASTTypes.IfExpression) {
    return checkIfExpression(ast, type, env);
  }

  if (ast.kind === ASTTypes.CondExpression) {
    return checkCondExpression(ast, type, env);
  }
  // const inferredType etc...
Enter fullscreen mode Exit fullscreen mode

The checking functions work similiarly to their respective inference functions:

const checkIfExpression = (ast, type, env) => {
  const test = infer(ast.test, env);
  const consequent = () => check(ast.then, type, narrow(ast.test, env, true));
  const alternate = () => check(ast.else, type, narrow(ast.test, env, false));

  if (Type.isTruthy(test)) {
    consequent();
  } else if (Type.isFalsy(test)) {
    alternate();
  } else {
    consequent();
    alternate();
  }
};

const checkCondExpression = (ast, type, env) => {
  for (let clause of ast.clauses) {
    const test = infer(clause.test, env);
    check(clause.expression, type, narrow(ast.test, env, true));

    if (Type.isTruthy(test)) {
      // we can stop here if test is truthy
      return;
    }
  }

  check(ast.else, type, env);
};
Enter fullscreen mode Exit fullscreen mode

Checking with The Type Checker

First we need to add the relevant cases to the switch statement in checkNode in src/typechecker/TypeChecker.js:

      // other cases
      case ASTTypes.UnaryExpression:
        return this.checkUnaryExpression(node, env);
      case ASTTypes.BinaryExpression:
        return this.checkBinaryExpression(node, env);
      case ASTTypes.LogicalExpression:
        return this.checkLogicalExpression(node, env);
      case ASTTypes.IfExpression:
        return this.checkIfExpression(node, env);
      case ASTTypes.CondExpression:
        return this.checkCondExpression(node, env);
      case ASTTypes.WhenExpression:
        return this.checkWhenExpression(node, env);
      // default case
Enter fullscreen mode Exit fullscreen mode

checkUnaryExpression checks the operand then returns a new node annotated with the inferred node type:

  checkUnaryExpression(node, env) {
    const operand = this.checkNode(node.operand, env);
    return { ...node, operand, type: infer(node, env) };
  }
Enter fullscreen mode Exit fullscreen mode

checkBinaryExpression does the same thing except with left and right operands:

  checkBinaryExpression(node, env) {
    const left = this.checkNode(node.left, env);
    const right = this.checkNode(node.right, env);
    return { ...node, left, right, type: infer(node, env) };
  }
Enter fullscreen mode Exit fullscreen mode

checkLogicalExpression is the same as checkBinaryExpression:

  checkLogicalExpression(node, env) {
    const left = this.checkNode(node.left, env);
    const right = this.checkNode(node.right, env);
    return { ...node, left, right, type: infer(node, env) };
  }
Enter fullscreen mode Exit fullscreen mode

`checkIfExpression is similar, but with all 3 expressions:

`js
checkIfExpression(node, env) {
const test = this.checkNode(node.test, env);
const then = this.checkNode(node.then, env);
const elseBranch = this.checkNode(node.else, env);

return { ...node, test, then, else: elseBranch, type: infer(node, env) };
Enter fullscreen mode Exit fullscreen mode

}
`

The checkCondExpression method loops over the node's clauses and checks each test and expression, then checks the else branch and returns the fully checked node:

`js
checkCondExpression(node, env) {
/** @type {TypedAST[]} */
let clauses = [];

for (let clause of node.clauses) {
  const test = this.checkNode(clause.test, env);
  const expression = this.checkNode(clause.expression, env);

  clauses.push({ test, expression });
}

const elseBranch = this.checkNode(node.else, env);

return { ...node, clauses, else: elseBranch, type: infer(node, env) };
Enter fullscreen mode Exit fullscreen mode

}
`

Finally, checkWhenExpression checks the test, then checks each node of the body, then returns the annotated node:

`js
checkWhenExpression(node, env) {
if (!node.env) {
node.env = env.extend("WhenExpression");
}

const whenEnv = node.env;
const test = this.checkNode(node.test, env);
let body = [];

for (let expr of node.body) {
  body.push(this.checkNode(expr, whenEnv));
}

return { ...node, test, body, type: infer(node, whenEnv) };
Enter fullscreen mode Exit fullscreen mode

}
`

And that's it for changes to the type checker!

Changes to The Visitor

We need to add new cases and methods for each of the new AST nodes in src/visitor/Visitor.js. First, let's add the cases to the switch statement in visit:

js
// other cases...
case ASTTypes.IfExpression:
return this.visitIfExpression(node);
case ASTTypes.CondExpression:
return this.visitCondExpression(node);
case ASTTypes.WhenExpression:
return this.visitWhenExpression(node);
case ASTTypes.UnaryExpression:
return this.visitUnaryExpression(node);
case ASTTypes.BinaryExpression:
return this.visitBinaryExpression(node);
case ASTTypes.LogicalExpression:
return this.visitLogicalExpression(node);
// default case

Here's visitIfExpression:

`js
visitIfExpression(node) {
const test = this.visit(node.test);
const then = this.visit(node.then);
const elseBranch = this.visit(node.else);

return { ...node, test, then, else: elseBranch };
Enter fullscreen mode Exit fullscreen mode

}
`

and visitCondExpression:

`js
visitCondExpression(node) {
let clauses = [];

for (let clause of node.clauses) {
  const test = this.visit(clause.test);
  const expression = this.visit(clause.expression);

  clauses.push({ test, expression });
}

const elseBranch = this.visit(node.else);

return { ...node, clauses, else: elseBranch };
Enter fullscreen mode Exit fullscreen mode

}
`

and visitWhenExpression:

`js
visitWhenExpression(node) {
/** @type {AST[]} */
let body = [];
const test = this.visit(node.test);

for (let expr of node.body) {
  body.push(this.visit(expr));
}

return { ...node, test, body };
Enter fullscreen mode Exit fullscreen mode

}
`

and visitUnaryExpression:

js
visitUnaryExpression(node) {
const operand = this.visit(node.operand);
return { ...node, operand };
}

and visitBinaryExpression:

`js
visitBinaryExpression(node) {
const left = this.visit(node.left);
const right = this.visit(node.right);

return { ...node, left, right };
Enter fullscreen mode Exit fullscreen mode

}
`

and, finally, visitLogicalExpression:

`js
visitLogicalExpression(node) {
const left = this.visit(node.left);
const right = this.visit(node.right);

return { ...node, left, right };
Enter fullscreen mode Exit fullscreen mode

}
`

With the new Visitor methods in place, let's look at the desugarer.

Changes to The Desugarer

We need to desugar binary expressions, unary expressions, and cond expressions, so we need methods for each of them in src/desugarer/Desugarer.js. The first two are simple.

Here's visitBinaryExpression:

`js
visitBinaryExpression(node) {
const left = this.visit(node.left);
const right = this.visit(node.right);
const func = AST.Symbol(Token.new(TokenTypes.Symbol, node.op, node.srcloc));

return AST.CallExpression(func, [left, right], node.srcloc);
Enter fullscreen mode Exit fullscreen mode

}
`

and visitUnaryExpression:

`js
visitUnaryExpression(node) {
const operand = this.visit(node.operand);

return AST.CallExpression(
  AST.Symbol(Token.new(TokenTypes.Symbol, node.op, node.srcloc)),
  [operand],
  node.srcloc
);
Enter fullscreen mode Exit fullscreen mode

}
`

visitCondExpression is a bit more complicated. We're going to create a recursive helper function called condVisitHelper that takes in an array of clauses and the accumulated nested IfExpression node.

If the clauses array has more than one element, we return the accumulated IfExpression (or, if it's the first time calling the function, we create it) with a recursive call to condVisitHelper in the else branch position.

Otherwise, if clauses has one element, we return the base case which is an IfExpression node with the else branch from the original CondExpression node in the else branch position.

Here's visitCondExpression:

`js
visitCondExpression(node) {
const srcloc = node.srcloc;

/**
 * Recursive helper to construct IfExpression from CondExpression
 * @param {import("../parser/ast.js").CondClause[]} clauses
 * @param {import("../parser/ast.js").IfExpression} accum
 */
const condVisitHelper = (clauses, accum) => {
  if (clauses.length > 1) {
    const clause = clauses[0];
    accum = AST.IfExpression(
      clause.test,
      clause.expression,
      condVisitHelper(clauses.slice(1), accum),
      srcloc
    );

    return accum;
  } else if (clauses.length === 1) {
    const clause = clauses[0];
    const elseBranch = node.else;

    return AST.IfExpression(
      clause.test,
      clause.expression,
      elseBranch,
      srcloc
    );
  }
};

return condVisitHelper(node.clauses, {});
Enter fullscreen mode Exit fullscreen mode

}
`

That's it for changes to the desugarer. Now let's look at changes to the emitter.

Changes to The Emitter

Since we're desugaring away 3 of the new AST node types, we only need emitter methods for IfExpression, WhenExpression, and LogicalExpression nodes.

First let's add the cases to the switch statement in emit in src/emitter/Emitter.js:

js
// other cases...
case ASTTypes.LogicalExpression:
return this.emitLogicalExpression(node, ns);
case ASTTypes.IfExpression:
return this.emitIfExpression(node, ns);
case ASTTypes.WhenExpression:
return this.emitWhenExpression(node, ns);
// default case

emitLogicalExpression switches on the operator and calls a helper method depending on which operator the node has:

`js
emitLogicalExpression(node, ns) {
switch (node.op) {
case "and":
return this.emitLogicalAnd(node, ns);

  case "or":
    return this.emitLogicalOr(node, ns);

  default:
    throw new Exception(`Unknown logical operator ${node.op}`);
}
Enter fullscreen mode Exit fullscreen mode

}

emitLogicalAnd(node, ns) {
const left = this.emit(node.left, ns);
const right = this.emit(node.right, ns);

return `rt.isTruthy(${left}) ? ${right} : ${left}`;
Enter fullscreen mode Exit fullscreen mode

}

emitLogicalOr(node, ns) {
const left = this.emit(node.left, ns);
const right = this.emit(node.right, ns);

return `rt.isFalsy(${left}) ? ${left} : ${right}`;
Enter fullscreen mode Exit fullscreen mode

}
`

We have to compile to a ternary expression and use rt.isTruthy and rt.isFalsy since the runtime semantics of Wanda are slightly different from those of JavaScript with regards to the truthiness and falsiness of values.

emitIfExpression emits a ternary expression:

js
emitIfExpression(node, ns) {
return
rt.isTruthy(${this.emit(node.test, ns)}) ? ${this.emit(
node.then,
ns
)} : ${this.emit(node.else, ns)};
}

Since a when expression is imperative, it returns nil instead of a type based on the expression itself. Because of that, we need to emit a little bit of ternary expression magic to cover the case where the test is falsy so the body code is not executed. We wrap the consequent branch of the ternary statement in an IIFE so we can execute its contents and then return nil from the expression body. Here's emitWhenExpression:

js
emitWhenExpression(node, ns) {
const whenNs = ns.extend("WhenExpression");
let code =
(rt.isTruthy(${this.emit(node.test, ns)}))
? (() => {\n`;

for (let expr of node.body) {
  code += `    ${this.emit(expr, whenNs)};\n`;
}

code += "    return null";
code += "  })()\n";
code += "  : null\n";
return code;
Enter fullscreen mode Exit fullscreen mode

}
`

That's all the changes to the emitter, so let's look at printing the new AST nodes.

Changes to The Printer

We don't have any new types of values to print, but we do have 6 new node types.

First, let's add them to be dispatched from the switch statement in print in src/printer/printAST.js:

js
// other cases...
case ASTTypes.IfExpression:
return this.printIfExpression(node, indent);
case ASTTypes.CondExpression:
return this.printCondExpression(node, indent);
case ASTTypes.WhenExpression:
return this.printWhenExpression(node, indent);
case ASTTypes.BinaryExpression:
return this.printBinaryExpression(node, indent);
case ASTTypes.LogicalExpression:
return this.printLogicalExpression(node, indent);
case ASTTypes.UnaryExpression:
return this.printUnaryExpression(node, indent);
// default case

The methods themselves are pretty simple, so I won't belabor the point. Here they are:

js
printIfExpression(node, indent) {
let prStr =
${prIndent(indent)}IfExpression:\n;
prStr +=
${prIndent(indent + 2)}Test:\n;
prStr += this.print(node.test, indent + 4) + "\n";
prStr +=
${prIndent(indent + 2)}Then:\n+ "\n";
prStr += this.print(node.then, indent + 4) + "\n";
prStr +=
${prIndent(indent + 2)}Else:\n`;
prStr += this.print(node.else, indent + 4) + "\n";

return prStr;
Enter fullscreen mode Exit fullscreen mode

}

printCondExpression(node, indent) {
let prStr = ${prIndent(indent)}CondExpression:\n;

for (let clause of node.clauses) {
  prStr += `${prIndent(indent + 2)}Test:\n`;
  prStr += this.print(clause.test, indent + 4) + "\n";
  prStr += `${prIndent(indent + 2)}Expression:\n`;
  prStr += this.print(clause.expression, indent + 4) + "\n";
}

prStr += `${prIndent(indent + 2)}Else:\n`;
prStr += this.print(node.else, indent + 4) + "\n";

return prStr;
Enter fullscreen mode Exit fullscreen mode

}

printWhenExpression(node, indent) {
let prStr = ${prIndent(indent)}WhenExpression:\n;
prStr += ${prIndent(indent + 2)}Test:\n;
prStr += this.print(node.test, indent + 4) + "\n";
prStr += ${prIndent(indent + 2)}Body:;

for (let expr of node.body) {
  prStr += this.print(expr, indent + 4) + "\n";
}

return prStr;
Enter fullscreen mode Exit fullscreen mode

}

printBinaryExpression(node, indent) {
let prStr = ${prIndent(indent)}BinaryExpression:\n;
prStr += ${prIndent(indent + 2)}Left:\n;
prStr += this.print(node.left, indent + 4) + "\n";
prStr += ${prIndent(indent + 2)}Operator:\n;
prStr += ${prIndent(indent + 4)}${node.op}\n;
prStr += ${prIndent(indent + 2)}Right:\n;
prStr += this.print(node.right, indent + 4) + "\n";

return prStr;
Enter fullscreen mode Exit fullscreen mode

}

printLogicalExpression(node, indent) {
let prStr = ${prIndent(indent)}LogicalExpression:\n;
prStr += ${prIndent(indent + 2)}Left:\n;
prStr += this.print(node.left, indent + 4) + "\n";
prStr += ${prIndent(indent + 2)}Operator:\n;
prStr += ${prIndent(indent + 4)}${node.op}\n;
prStr += ${prIndent(indent + 2)}Right:\n;
prStr += this.print(node.right, indent + 4) + "\n";

return prStr;
Enter fullscreen mode Exit fullscreen mode

}

printUnaryExpression(node, indent) {
let prStr = ${prIndent(indent)}UnaryExpression:\n;
prStr += ${prIndent(indent + 2)}Operator:\n;
prStr += ${prIndent(indent + 4)}${node.op}\n;
prStr += ${prIndent(indent + 2)}Operand:\n;
prStr += this.print(node.operand, indent + 4) + "\n";

return prStr;
Enter fullscreen mode Exit fullscreen mode

}
`

That's all the changes to the printer. Now we're going to make some changes to the CLI to make it more flexible.

Changes to The CLI

We're going to make 3 major changes to the CLI:

  • Allow running a Wanda file directly from the command line (compiling and executing it in one step)
  • Interactively load a Wanda file as a script and use its definitions in the REPL
  • Output a compiled file and optionally build the whole bundle

In the case of a compiled file, you have the option to either use ES2015 imports to import the global library or to use ESBuild to bundle everything into one giant JavaScript file.

Changes to The REPL

First, we're going to change the arguments the REPL function takes. Instead of a single string argument mode, we're going to give it an options object with properties mode (string) and path (string). Everything has defaults, so it can still be called simply by running the bare function with no arguments.

The way mode works isn't changing. We will, however, enable the ability to turn off printing the AST from within the REPL if you've enabled it. Not doing that sooner was a silly oversight on my part.

The big change is if there's a value for the path option it will grab the contents of that file, compile it, and execute the result in the global environment so you'll have access to any toplevel definitions in that file within the REPL.

Here's the new version of the repl function in src/cli/repl.js:

`js
import os from "os";
import vm from "vm";
import fs from "fs";
import readlineSync from "readline-sync";
import { pprintAST, pprintDesugaredAST } from "./pprint.js";
import { println } from "../printer/println.js";
import { makeGlobalNameMap } from "../runtime/makeGlobals.js";
import { emitGlobalEnv } from "../emitter/emitGlobalEnv.js";
import { build } from "./build.js";
import { compile } from "./compile.js";
import { makeGlobalTypeEnv } from "../typechecker/makeGlobalTypeEnv.js";
import { countIndent, inputFinished } from "./utils.js";

const read = (prompt) => readlineSync.question(prompt);

export const repl = ({ mode = "repl", path = "" } = {}) => {
// Create global compile environment
const compileEnv = makeGlobalNameMap();
const typeEnv = makeGlobalTypeEnv();

// Build global module and instantiate in REPL context
// This should make all compiled global symbols available
const globalCode = build(emitGlobalEnv());
vm.runInThisContext(globalCode);

if (path) {
// load file in REPL interactively
const fileContents = fs.readFileSync(path, { encoding: "utf-8" });
const compiledFile = compile(fileContents, path, compileEnv, typeEnv);
vm.runInThisContext(compiledFile);
}

let prompt = "wanda> ";
let input = "";
let indent = 0;

while (true) {
try {
input += read(prompt + " ".repeat(indent));

  switch (input) {
    // If it's a command, execute it
    case ":quit":
      process.exit(0);
    case ":print-ast":
      mode = "printAST";
      input = "";
      break;
    case ":print-desugared":
      mode = "printDesugared";
      input = "";
      break;
    case ":no-print-ast":
      mode = "repl";
      input = "";
      break;
    // If it's code, compile and run it
    default:
      if (inputFinished(input)) {
        let compiled = compile(input, "stdin", compileEnv, typeEnv);
        let result = vm.runInThisContext(compiled);

        if (mode === "printAST") {
          console.log(pprintAST(input));
        } else if (mode === "printDesugared") {
          console.log(pprintDesugaredAST(input));
        }

        println(result);
        input = "";
        indent = 0;
      } else {
        input += os.EOL;
        indent = countIndent(input);
      }
  }
} catch (e) {
  console.error(e.stack);
  input = "";
  indent = 0;
}
Enter fullscreen mode Exit fullscreen mode

}
};
`

Changes to The run Function

I've removed the print command from the run function, now if you want to print the AST from within the REPL you have to enable it inside the REPL. You can decide whether or not you want to do the same—it's all the same to me either way.

We need to add a function that can compile and run a Wanda file, and parse the run command to do it. I've also replaced throwing exceptions with logging an error message and passing 1 to process.exit to fall more in line with UNIX shell script conventions. Here's the new run function and the runFile function that goes with it in src/cli/run.js:

`js
import vm from "vm";
import fs from "fs";
import { join } from "path";
import { repl } from "./repl.js";
import { makeGlobalNameMap } from "../runtime/makeGlobals.js";
import { emitGlobalEnv } from "../emitter/emitGlobalEnv.js";
import { build } from "./build.js";
import { compile } from "./compile.js";
import { makeGlobalTypeEnv } from "../typechecker/makeGlobalTypeEnv.js";

export const run = () => {
switch (process.argv[2]) {
case "-i":
if (!process.argv[3]) {
console.log(-i option requires file path as argument);
process.exit(1);
}
const path = join(process.cwd(), process.argv[3]);
repl({ path });
break;
case undefined:
case "repl":
return repl();
case "run":
if (!process.argv[3]) {
console.log(run command requires file path as argument);
process.exit(1);
}
return runFile(join(process.cwd(), process.argv[3]));
default:
console.log("Invalid command specified");
process.exit(1);
}
};

const runFile = (path) => {
const fileContents = fs.readFileSync(path, { encoding: "utf-8" });
const globalNs = makeGlobalNameMap();
const typeEnv = makeGlobalTypeEnv();
const globalCode = build(emitGlobalEnv());
const compiledCode = compile(fileContents, path, globalNs, typeEnv);

vm.runInThisContext(globalCode);
return vm.runInThisContext(compiledCode);
};
`

Compiling and Building Files

We also need the ability to compile Wanda files and output the results, with the optional step of building the whole program instead of just using ES2015 import statements for the global library.

We'll call the main function wandac after compilers like javac and rustc, and add an auxiliary compileFile function to handle the actual compilation. Here are those functions, in src/cli/wandac.js:

`js
import os from "os";
import fs from "fs";
import { join, basename } from "path";
import { compile } from "./compile.js";
import { build } from "./build.js";
import { makeGlobalNameMap } from "../runtime/makeGlobals.js";
import { makeGlobalTypeEnv } from "../typechecker/makeGlobalTypeEnv.js";
import { emitGlobalEnv } from "../emitter/emitGlobalEnv.js";

const compileFile = (path) => {
const contents = fs.readFileSync(path, { encoding: "utf-8" });
return compile(contents, path, makeGlobalNameMap(), makeGlobalTypeEnv());
};

export const wandac = () => {
if (!process.argv[2]) {
console.log(wandac requires either a file path or command argument);
process.exit(1);
}

switch (process.argv[2]) {
case "build": {
const pathname = join(process.cwd(), process.argv[3]);
const compiledFile = compileFile(pathname);
const globals = emitGlobalEnv();
const code = globals + os.EOL + os.EOL + compiledFile;
const bName = basename(pathname).split(".")[0];
const outfile = bName + ".build" + ".js";
const built = build(code, outfile, bName);

  fs.writeFileSync(outfile, built, { encoding: "utf-8" });
  break;
}
default: {
  // should be a file path
  const pathname = join(process.cwd(), process.argv[2]);
  const compiledFile = compileFile(pathname);
  const globals = emitGlobalEnv();
  const code = globals + os.EOL + os.EOL + compiledFile;
  const outfile = basename(pathname).split(".")[0] + ".js";

  fs.writeFileSync(outfile, code, { encoding: "utf-8" });
  break;
}
Enter fullscreen mode Exit fullscreen mode

}
};
`

Now let's create a new Node shell script in the bin directory (not src/bin) and link it up to run as a global system command. First, add this to bin/wandac.js:

`js

!/usr/bin/env node

import { wandac } from "../src/cli/wandac.js";

wandac();
`

Next, we need to make a slight change to package.json to account for the fact that there are now 2 executable scripts. Change

json
"bin": "bin/wanda.js",

to:

json
"bin": {
"wanda": "bin/wanda.js",
"wandac": "bin/wandac.js"
},

Now make sure you're in the compiler's root directory and run the command npm link. Now you should have both wanda and wandac available as global shell commands.

Example Files

Finally, we need a couple of example files to load into the REPL and play with! I've created a couple of simple ones just to get you started.

I'm using .wanda as the file extension.

Create a new directory in the compiler root called examples and add the following 2 files:

examples/inc.wanda:

clojure
(def inc (x: number) -> number (+ x 1))

examples/fib.wanda:

clojure
(def fib (n: number) -> number
(if (= n 1)
n
(* n (fib (- n 1)))))

Now if you run the command wanda -i examples/inc.wanda you can run the inc function from within the REPL! You can also compile them if you want to see the output.

Conclusion

We've made some major strides today! While our language was technically Turing complete with just functions (it's amazing what you can do with just functions), it's a lot easier to consider it so now that we have branching enabled through conditional expressions. Recursion allows for repeating code, so the combination of conditionals and functions makes the language fully Turing complete.

The CLI is also now much more fully functional compared to just being able to run a simple REPL.

Please feel free to add your own examples. If you come up with anything cool, by all means send a pull request and I'll incorporate it into the examples I've got!

As always, you can find the code as of this point on the GitHub repo under the relevant tag.

Next time we're going to add 2 iterative constructs: loop and for. I'll see you then!

Top comments (0)