Compare commits

..

2 Commits

Author SHA1 Message Date
0983524ae6 add docs 2026-03-11 11:45:01 +01:00
cad88a6729 add bool 2026-03-11 11:44:55 +01:00
9 changed files with 365 additions and 47 deletions

View File

@ -0,0 +1,225 @@
# Tagged union pattern in Typescript
To show the pattern, let's implement a simple interpreter.
Start by defining each union variant, or *kind*, of the type:
```ts
type ExprKind =
| { tag: "Int", value: number }
| { tag: "Add", left: Expr, right: Expr }
```
Then define the type itself as a class:
```ts
class Expr {
constructor(
public line: number,
public kind: ExprKind,
) {}
// ...
}
```
Each instance of `Expr` will have a `line` (source line number), but only *IntExpr* will have `value`, and only *AddExpr* will have `left` and `right`.
To *match* each variant, a type representing an `Expr` with a specific variant:
```ts
type ExprTag = ExprKind["tag"];
type ExprWithKind<Tag extends ExprTag> = Expr & { kind: { tag: Tag } };
```
The type `ExprKind["Tag"]` is a union of each `tag` value, i.e. in this example `"Int" | "Add"`. Saying `Tag extends ExprTag` means that `Tag` (type) is either `"Int"` or `"Add"`, i.e. for `.is("Int")`, `Tag` will be `"Int"` and for `.is("Add")`, `Tag` will be `"Add"`. The *narrowed* type `ExprWithKind` is defined by saying that we want an `Expr` that specifically has the `kind.tag` value of `Tag`.
Then define a methed that with a *type predicate*:
```ts
class Expr {
// ...
is<Tag extends ExprTag>(tag: Tag): this is ExprWithKind<Tag> {
return this.kind.tag === tag;
}
// ...
}
```
The type predicate tells Typescript that it can expect the `Expr` to conform to `ExprWithKind<Tag>` if `.is` evaluates to `true`. Using this, access can be gained to the variant fields in a type safe manner.
Now, it's possible to match each variant:
```ts
function eval(expr: Expr): number {
if (expr.is("Int")) {
return expr.kind.value;
}
if (expr.is("Add")) {
return eval(expr.kind.left) + eval(expr.kind.right);
}
throw new Error(`'${expr.kind.tag}' not handled`);
}
```
By having `Expr` be a class, we can also define functions like this as methods:
```ts
class Expr {
// ...
eval(): number {
if (expr.is("Int"))
return expr.kind.value;
if (expr.is("Add"))
return expr.kind.left.eval() + expr.kind.right.eval();
throw new Error(`'${expr.kind.tag}' not handled`);
}
// ...
}
```
By having the `tag` invariant, the Typescript compiler can check that you exhaust each variant in a match. To match each variant, I use this pattern ('k-pattern'):
```ts
class Expr {
// ...
eval(): number {
const k = this.kind;
switch (k.tag) { // Typescript language servers can auto-complete all variants
case "Int":
return k.value;
case "Add":
return k.right.eval() + k.left.eval();
}
const _: never = k; // compile time exhaustiveness check
}
// ...
}
```
Sometimes you need to narrow to a variant that you already know is the correct variant. For that purpose, define these helper functions:
```ts
class Expr {
// ...
assertIs<Tag extends ExprTag>(tag: Tag): asserts this is ExprWithKind<Tag> {
if (!this.is(tag)) {
throw new Error();
}
}
as<Tag extends ExprTag>(tag: Tag): ExprWithKind<Tag> {
this.assertIs(tag);
return this;
}
// ...
}
```
Using these, narrowing can be done more conveniently:
```ts
// this will always be an IntExpr
let expr: Expr = ...;
expr.kind.value; // Property 'value' does not exist ...
expr.as("Int").kind.value; // ok
expr.assertIs("Int"); // will throw if expr isn't an IntExpr
expr.kind.value; // ok
```
For some applications, it might be more conveniently with a factory-function like this:
```ts
type ExprKindData<Tag extends ExprTag> = Omit<ExprWithKind<Tag>, "tag">;
class Expr {
// ...
static create<Tag extends ExprTag>(
tag: Tag,
kind: ExprKindData<Tag>,
line: number,
): ExprWithKind<Tag> {
return new Expr(line, {...kind, tag} as ExprKind & { tag: Ty });
}
// ...
}
```
And use it like this:
```ts
const myInt = Expr.create("Int", { value: 123 }, line);
```
For often-used variants, it might be more convenient with a specific type and constructor:
```ts
type IntExpr = ExprWithKind<"Int">;
const IntExpr = (line: number, value: number): IntExpr =>
Expr.create("Int", { value }, line);
```
That can be used like this:
```ts
function exprIdentity(expr: Expr): Expr { return expr; }
function intExprIdentity(expr: IntExpr): IntExpr { return expr; }
const myIntExpr = IntExpr(123, line);
myIntExpr.kind.value;
exprIdentity(myIntExpr);
intExprIdentity(myIntExpr);
const myOtherExpr = Expr.create("Add", ...);
exprIdentity(myIntExpr);
intExprIdentity(myIntExpr); // error
```
Using this pattern, it's easy to implement a visitor pattern:
```ts
interface ExprVisitor {
visit(expr: Expr): void
}
class Expr {
// ...
visit(v: ExprVisitor) {
v.visit(this);
const k = this.kind;
switch (k.tag) {
case "Int":
return;
case "Add":
k.left.visit(v);
k.right.visit(v);
return
}
const _: never = k;
}
// ...
}
```
The visitor interface can be used like:
```ts
const expr: Expr = parse("2 + (3 + 4)");
const intValues: number[] = [];
expr.visit({
visit(expr) {
if (expr.is("Int")) {
intValues.push(expr.kind.value);
}
}
});
```

View File

@ -61,6 +61,9 @@ export class Checker {
if (sym.tag === "Fn") { if (sym.tag === "Fn") {
return this.check(sym.stmt); return this.check(sym.stmt);
} }
if (sym.tag === "Bool") {
return Ty.Bool;
}
if (sym.tag === "Builtin") { if (sym.tag === "Builtin") {
return builtins.find((s) => s.id === sym.id)!.ty; return builtins.find((s) => s.id === sym.id)!.ty;
} }
@ -106,6 +109,8 @@ export class Checker {
return Ty.Void; return Ty.Void;
case "int": case "int":
return Ty.Int; return Ty.Int;
case "bool":
return Ty.Bool;
default: default:
this.error(node.line, `unknown type '${node.kind.ident}'`); this.error(node.line, `unknown type '${node.kind.ident}'`);
} }
@ -148,9 +153,9 @@ export class Checker {
private checkCall(node: ast.NodeWithKind<"CallExpr">): Ty { private checkCall(node: ast.NodeWithKind<"CallExpr">): Ty {
const calleeTy = this.check(node.kind.expr); const calleeTy = this.check(node.kind.expr);
const callableTy = calleeTy.isKind("Fn") const callableTy = calleeTy.is("Fn")
? calleeTy ? calleeTy
: calleeTy.isKind("FnStmt") : calleeTy.is("FnStmt")
? calleeTy.kind.ty as Ty & { kind: { tag: "Fn" } } ? calleeTy.kind.ty as Ty & { kind: { tag: "Fn" } }
: null; : null;
@ -170,7 +175,7 @@ export class Checker {
node.line, node.line,
`incorrect amount of arguments. got ${args.length} expected ${params.length}`, `incorrect amount of arguments. got ${args.length} expected ${params.length}`,
); );
if (calleeTy.isKind("FnStmt")) { if (calleeTy.is("FnStmt")) {
this.info( this.info(
calleeTy.kind.stmt.line, calleeTy.kind.stmt.line,
"function defined here", "function defined here",
@ -186,7 +191,7 @@ export class Checker {
params[i] params[i]
}', for argument ${i}`, }', for argument ${i}`,
); );
if (calleeTy.isKind("FnStmt")) { if (calleeTy.is("FnStmt")) {
this.info( this.info(
calleeTy.kind.stmt.kind.params[i].line, calleeTy.kind.stmt.kind.params[i].line,
`parameter '${ `parameter '${
@ -246,4 +251,14 @@ type BinaryOpPattern = {
const binaryOpPatterns: BinaryOpPattern[] = [ const binaryOpPatterns: BinaryOpPattern[] = [
{ op: "Add", left: Ty.Int, right: Ty.Int, result: Ty.Int }, { op: "Add", left: Ty.Int, right: Ty.Int, result: Ty.Int },
{ op: "Subtract", left: Ty.Int, right: Ty.Int, result: Ty.Int }, { op: "Subtract", left: Ty.Int, right: Ty.Int, result: Ty.Int },
{ op: "Multiply", left: Ty.Int, right: Ty.Int, result: Ty.Int },
{ op: "Divide", left: Ty.Int, right: Ty.Int, result: Ty.Int },
{ op: "Remainder", left: Ty.Int, right: Ty.Int, result: Ty.Int },
{ op: "Eq", left: Ty.Int, right: Ty.Int, result: Ty.Bool },
{ op: "Ne", left: Ty.Int, right: Ty.Int, result: Ty.Bool },
{ op: "Lt", left: Ty.Int, right: Ty.Int, result: Ty.Bool },
{ op: "Gt", left: Ty.Int, right: Ty.Int, result: Ty.Bool },
{ op: "Lte", left: Ty.Int, right: Ty.Int, result: Ty.Bool },
{ op: "Gte", left: Ty.Int, right: Ty.Int, result: Ty.Bool },
]; ];

View File

@ -1,6 +1,5 @@
import * as ast from "../ast.ts"; import * as ast from "../ast.ts";
import { printDiagnostics } from "../diagnostics.ts"; import { printDiagnostics } from "../diagnostics.ts";
import { Ty } from "../ty.ts";
import { builtins } from "./builtins.ts"; import { builtins } from "./builtins.ts";
export class ResolveMap { export class ResolveMap {
@ -18,6 +17,7 @@ export class ResolveMap {
export type Sym = export type Sym =
| { tag: "Error" } | { tag: "Error" }
| { tag: "Bool"; value: boolean }
| { tag: "Builtin"; id: string } | { tag: "Builtin"; id: string }
| { tag: "Fn"; stmt: ast.NodeWithKind<"FnStmt"> } | { tag: "Fn"; stmt: ast.NodeWithKind<"FnStmt"> }
| { | {
@ -79,7 +79,7 @@ export function resolve(
} }
if (k.tag === "IdentExpr") { if (k.tag === "IdentExpr") {
const sym = syms.resolve(k.ident); const sym = syms.resolveExpr(k.ident);
if (sym === null) { if (sym === null) {
printDiagnostics( printDiagnostics(
filename, filename,
@ -126,12 +126,16 @@ class ResolverSyms {
this.syms.set(ident, sym); this.syms.set(ident, sym);
} }
resolve(ident: string): Sym | null { resolveExpr(ident: string): Sym | null {
if (ident === "false" || ident === "true") {
return { tag: "Bool", value: ident === "true" };
}
if (this.syms.has(ident)) { if (this.syms.has(ident)) {
return this.syms.get(ident)!; return this.syms.get(ident)!;
} }
if (this.parent) { if (this.parent) {
return this.parent.resolve(ident); return this.parent.resolveExpr(ident);
} }
return null; return null;
} }

View File

@ -112,6 +112,9 @@ class FnLowerer {
} }
return this.pushInst(local.ty, "LocalLoad", { source: local }); return this.pushInst(local.ty, "LocalLoad", { source: local });
} }
if (sym.tag === "Bool") {
return this.pushInst(Ty.Bool, "Bool", { value: sym.value });
}
throw new Error(`'${sym.tag}' not handled`); throw new Error(`'${sym.tag}' not handled`);
} }
if (expr.is("IntExpr")) { if (expr.is("IntExpr")) {
@ -140,17 +143,24 @@ class FnLowerer {
return this.pushInst(ty, "Call", { callee, args }); return this.pushInst(ty, "Call", { callee, args });
} }
if (expr.is("BinaryExpr")) { if (expr.is("BinaryExpr")) {
const ty = this.checker.check(expr); const resultTy = this.checker.check(expr);
const leftTy = this.checker.check(expr.kind.left);
const rightTy = this.checker.check(expr.kind.right);
const binaryOp = binaryOpPatterns const binaryOp = binaryOpPatterns
.find((pat) => .find((pat) =>
expr.kind.op === pat.op && ty.compatibleWith(pat.ty) expr.kind.op === pat.op &&
resultTy.compatibleWith(pat.result) &&
leftTy.compatibleWith(pat.left ?? pat.result) &&
rightTy.compatibleWith(pat.right ?? pat.left ?? pat.result)
); );
if (!binaryOp) { if (!binaryOp) {
throw new Error(); throw new Error(
`'${expr.kind.op}' with '${resultTy.pretty()}' not handled`,
);
} }
const left = this.lowerExpr(expr.kind.left); const left = this.lowerExpr(expr.kind.left);
const right = this.lowerExpr(expr.kind.right); const right = this.lowerExpr(expr.kind.right);
return this.pushInst(ty, binaryOp.tag, { left, right }); return this.pushInst(resultTy, binaryOp.tag, { left, right });
} }
throw new Error(`'${expr.kind.tag}' not handled`); throw new Error(`'${expr.kind.tag}' not handled`);
} }
@ -174,13 +184,25 @@ class FnLowerer {
type BinaryOpPattern = { type BinaryOpPattern = {
op: ast.BinaryOp; op: ast.BinaryOp;
ty: Ty;
tag: BinaryOp; tag: BinaryOp;
result: Ty;
left?: Ty;
right?: Ty;
}; };
const binaryOpPatterns: BinaryOpPattern[] = [ const binaryOpPatterns: BinaryOpPattern[] = [
{ op: "Add", ty: Ty.Int, tag: "Add" }, { op: "Add", tag: "Add", result: Ty.Int, left: Ty.Int },
{ op: "Subtract", ty: Ty.Int, tag: "Sub" }, { op: "Subtract", tag: "Sub", result: Ty.Int, left: Ty.Int },
{ op: "Multiply", tag: "Mul", result: Ty.Int, left: Ty.Int },
{ op: "Divide", tag: "Div", result: Ty.Int, left: Ty.Int },
{ op: "Remainder", tag: "Rem", result: Ty.Int },
{ op: "Eq", tag: "Eq", result: Ty.Bool, left: Ty.Int },
{ op: "Ne", tag: "Ne", result: Ty.Bool, left: Ty.Int },
{ op: "Lt", tag: "Lt", result: Ty.Bool, left: Ty.Int },
{ op: "Gt", tag: "Gt", result: Ty.Bool, left: Ty.Int },
{ op: "Lte", tag: "Lte", result: Ty.Bool, left: Ty.Int },
{ op: "Gte", tag: "Gte", result: Ty.Bool, left: Ty.Int },
]; ];
export class Fn { export class Fn {
@ -191,7 +213,7 @@ export class Fn {
) {} ) {}
pretty(): string { pretty(): string {
const fnTy = this.ty.isKind("FnStmt") && this.ty.kind.ty.isKind("Fn") const fnTy = this.ty.is("FnStmt") && this.ty.kind.ty.is("Fn")
? this.ty.kind.ty ? this.ty.kind.ty
: null; : null;
if (!fnTy) { if (!fnTy) {
@ -267,6 +289,7 @@ export class Inst {
case "Void": case "Void":
return ""; return "";
case "Int": case "Int":
case "Bool":
return ` ${k.value}`; return ` ${k.value}`;
case "Fn": case "Fn":
return ` ${k.fn.stmt.kind.ident}`; return ` ${k.fn.stmt.kind.ident}`;
@ -312,6 +335,7 @@ export type InstKind =
| { tag: "Error" } | { tag: "Error" }
| { tag: "Void" } | { tag: "Void" }
| { tag: "Int"; value: number } | { tag: "Int"; value: number }
| { tag: "Bool"; value: boolean }
| { tag: "Fn"; fn: Fn } | { tag: "Fn"; fn: Fn }
| { tag: "Param"; idx: number } | { tag: "Param"; idx: number }
| { tag: "Call"; callee: Inst; args: Inst[] } | { tag: "Call"; callee: Inst; args: Inst[] }

View File

@ -20,6 +20,7 @@ export class MirInterpreter {
throw new Error(); throw new Error();
case "Void": case "Void":
case "Int": case "Int":
case "Bool":
case "Fn": case "Fn":
regs.set(inst, new Val(k)); regs.set(inst, new Val(k));
continue; continue;
@ -79,14 +80,28 @@ export class MirInterpreter {
const rk = right.kind; const rk = right.kind;
if (lk.tag === "Int" && rk.tag === "Int") { if (lk.tag === "Int" && rk.tag === "Int") {
const left = lk.value;
const right = lk.value;
const value = (() => { const value = (() => {
const Int = (value: number) =>
new Val({ tag: "Int", value });
const Bool = (value: boolean) =>
new Val({ tag: "Bool", value });
switch (k.tag) { switch (k.tag) {
case "Eq": case "Eq":
return Bool(left === right);
case "Ne": case "Ne":
return Bool(left !== right);
case "Lt": case "Lt":
return Bool(left < right);
case "Gt": case "Gt":
return Bool(left > right);
case "Lte": case "Lte":
return Bool(left <= right);
case "Gte": case "Gte":
return Bool(left >= right);
case "BitOr": case "BitOr":
case "BitXor": case "BitXor":
case "BitAnd": case "BitAnd":
@ -94,17 +109,19 @@ export class MirInterpreter {
case "Shr": case "Shr":
break; break;
case "Add": case "Add":
return lk.value + rk.value; return Int(left + right);
case "Sub": case "Sub":
return lk.value - rk.value; return Int(left - right);
case "Mul": case "Mul":
return Int(left * right);
case "Div": case "Div":
return Int(Math.floor(left / right));
case "Rem": case "Rem":
break; return Int(left % right);
} }
throw new Error(`'${k.tag}' not handled`); throw new Error(`'${k.tag}' not handled`);
})(); })();
regs.set(inst, new Val({ tag: "Int", value })); regs.set(inst, value);
continue; continue;
} }
throw new Error(`'${k.tag}' not handled`); throw new Error(`'${k.tag}' not handled`);
@ -136,6 +153,7 @@ class Val {
case "Void": case "Void":
return "void"; return "void";
case "Int": case "Int":
case "Bool":
return `${k.value}`; return `${k.value}`;
case "Fn": case "Fn":
return `<${k.fn.ty.pretty()}>`; return `<${k.fn.ty.pretty()}>`;
@ -147,4 +165,5 @@ class Val {
type ValKind = type ValKind =
| { tag: "Void" } | { tag: "Void" }
| { tag: "Int"; value: number } | { tag: "Int"; value: number }
| { tag: "Bool"; value: boolean }
| { tag: "Fn"; fn: mir.Fn }; | { tag: "Fn"; fn: mir.Fn };

View File

@ -24,6 +24,7 @@ export class Ty {
static Error = Ty.create("Error", {}); static Error = Ty.create("Error", {});
static Void = Ty.create("Void", {}); static Void = Ty.create("Void", {});
static Int = Ty.create("Int", {}); static Int = Ty.create("Int", {});
static Bool = Ty.create("Bool", {});
private internHash(): string { private internHash(): string {
return JSON.stringify(this.kind); return JSON.stringify(this.kind);
@ -34,24 +35,27 @@ export class Ty {
public kind: TyKind, public kind: TyKind,
) {} ) {}
isKind< is<
Tag extends TyKind["tag"], Tag extends TyKind["tag"],
>(tag: Tag): this is Ty & { kind: { tag: Tag } } { >(tag: Tag): this is Ty & { kind: { tag: Tag } } {
return this.kind.tag === tag; return this.kind.tag === tag;
} }
compatibleWith(other: Ty): boolean { compatibleWith(other: Ty): boolean {
if (this.isKind("Error")) { if (this.is("Error")) {
return false; return false;
} }
if (this.isKind("Void")) { if (this.is("Void")) {
return other.isKind("Void"); return other.is("Void");
} }
if (this.isKind("Int")) { if (this.is("Int")) {
return other.isKind("Int"); return other.is("Int");
} }
if (this.isKind("Fn")) { if (this.is("Bool")) {
if (!other.isKind("Fn")) { return other.is("Bool");
}
if (this.is("Fn")) {
if (!other.is("Fn")) {
return false; return false;
} }
for (const i of this.kind.params.keys()) { for (const i of this.kind.params.keys()) {
@ -64,8 +68,8 @@ export class Ty {
} }
return true; return true;
} }
if (this.isKind("FnStmt")) { if (this.is("FnStmt")) {
if (!other.isKind("FnStmt")) { if (!other.is("FnStmt")) {
return false; return false;
} }
if (!this.kind.ty.compatibleWith(other.kind.ty)) { if (!this.kind.ty.compatibleWith(other.kind.ty)) {
@ -81,22 +85,25 @@ export class Ty {
} }
pretty(): string { pretty(): string {
if (this.isKind("Error")) { if (this.is("Error")) {
return "<error>"; return "<error>";
} }
if (this.isKind("Void")) { if (this.is("Void")) {
return "void"; return "void";
} }
if (this.isKind("Int")) { if (this.is("Int")) {
return "int"; return "int";
} }
if (this.isKind("Fn")) { if (this.is("Bool")) {
return "bool";
}
if (this.is("Fn")) {
return `fn (${ return `fn (${
this.kind.params.map((param) => param.pretty()).join(", ") this.kind.params.map((param) => param.pretty()).join(", ")
}) -> ${this.kind.retTy.pretty()}`; }) -> ${this.kind.retTy.pretty()}`;
} }
if (this.isKind("FnStmt")) { if (this.is("FnStmt")) {
if (!this.kind.ty.isKind("Fn")) throw new Error(); if (!this.kind.ty.is("Fn")) throw new Error();
return `fn ${this.kind.stmt.kind.ident}(${ return `fn ${this.kind.stmt.kind.ident}(${
this.kind.ty.kind.params.map((param) => param.pretty()).join( this.kind.ty.kind.params.map((param) => param.pretty()).join(
", ", ", ",
@ -111,5 +118,6 @@ export type TyKind =
| { tag: "Error" } | { tag: "Error" }
| { tag: "Void" } | { tag: "Void" }
| { tag: "Int" } | { tag: "Int" }
| { tag: "Bool" }
| { tag: "Fn"; params: Ty[]; retTy: Ty } | { tag: "Fn"; params: Ty[]; retTy: Ty }
| { tag: "FnStmt"; ty: Ty; stmt: ast.NodeWithKind<"FnStmt"> }; | { tag: "FnStmt"; ty: Ty; stmt: ast.NodeWithKind<"FnStmt"> };

8
tests/bool.ethlang Normal file
View File

@ -0,0 +1,8 @@
fn main()
{
let my_bool = false;
let my_other_bool: bool = true;
let cond: bool = 1 == 2;
}

View File

@ -1,5 +1,8 @@
// expect: 8 // expect: 8
// expect: 2 // expect: 2
// expect: 15
// expect: 7
// expect: 2
fn main() fn main()
{ {
@ -8,5 +11,8 @@ fn main()
print_int(a + b); print_int(a + b);
print_int(a - b); print_int(a - b);
print_int(a * b);
print_int(a * b / 2);
print_int(a % b);
} }

View File

@ -10,11 +10,12 @@ TEST_SRC=$(fd '\.ethlang' $TEST_DIR)
count_total=0 count_total=0
count_succeeded=0 count_succeeded=0
for test_file in $TEST_SRC run_test_file() {
do local file=$1
echo "- $(basename $test_file)"
echo "- $(basename $file)"
set +e set +e
output=$(deno run -A $SRC_DIR/main.ts $test_file --test) output=$(deno run -A $SRC_DIR/main.ts $file --test)
status=$? status=$?
set -e set -e
@ -26,9 +27,9 @@ do
if [[ status -eq 0 ]] if [[ status -eq 0 ]]
then then
if grep -q '// expect:' $test_file if grep -q '// expect:' $file
then then
expected=$(grep '// expect:' $test_file | sed -E 's/\/\/ expect: (.*?)/\1/g') expected=$(grep '// expect:' $file | sed -E 's/\/\/ expect: (.*?)/\1/g')
if [[ $output != $expected ]] if [[ $output != $expected ]]
then then
echo "-- failed: incorrect output --" echo "-- failed: incorrect output --"
@ -45,17 +46,25 @@ do
if [[ status -eq 0 ]] if [[ status -eq 0 ]]
then then
count_succeeded=$(($count_succeeded + 1)) count_succeeded=$(($count_succeeded + 1))
else
echo "failed"
fi fi
}
done if [[ $1 == "" ]]
then
for file in $TEST_SRC
do
run_test_file $file
done
else
run_test_file $1
fi
if [[ $count_succeeded -eq $count_total ]] if [[ $count_succeeded -eq $count_total ]]
then then
echo "=== all tests passed ($count_succeeded/$count_total passed) ===" echo "== all tests passed ($count_succeeded/$count_total passed) =="
else else
echo "=== tests failed ($count_succeeded/$count_total passed) ===" echo "== tests failed ($count_succeeded/$count_total passed) =="
exit 1
fi fi