Compare commits

..

5 commits

Author SHA1 Message Date
Lephenixnoir
aa736bd12a
juic: intended implementation of record ctors, working for now 2024-08-28 19:55:38 +02:00
Lephenixnoir
5f198ff6b0
juic: small improvements to record ctors, not working yet 2024-08-28 15:38:52 +02:00
Lephenixnoir
ce39929bb4
juic: fix all errors raised by mypy 2024-08-28 14:59:55 +02:00
Lephenixnoir
4579acc0f4
juic: add some record processing, start checking with mypy 2024-08-28 14:59:55 +02:00
Lephenixnoir
e79ba0056b
juic: add basic unit tests to consolidate
This way when I get the record constructors right (which I think I have
in my mind now) I can keep around non-regression tests.
2024-08-28 10:43:23 +02:00
8 changed files with 417 additions and 232 deletions

View file

@ -156,6 +156,8 @@ BIG TODO:
- Don't have a scope and ability to let- or fun-declare inside function body!! - Don't have a scope and ability to let- or fun-declare inside function body!!
- `rec x() = y {}; x {}` is it of record type `x` or `y`? How to say that `x` - `rec x() = y {}; x {}` is it of record type `x` or `y`? How to say that `x`
is based on `y`? is based on `y`?
-> it's of type y, which is derived from x and may or may not have custom
codegen that overrides that of x
IMPLEMENTATION TODO: IMPLEMENTATION TODO:
- List syntax - List syntax
@ -177,17 +179,6 @@ The syntax for assigning at labels is a bit dodgy at the moment. It should be
-> function-based interface param("FX") -> function-based interface param("FX")
- "Control-flow": for(each) loops, what else needed for basic ops? - "Control-flow": for(each) loops, what else needed for basic ops?
Limitation of lazy record construction: if you use a function in the middle of
a record construction, the record update `<{ }` will force the evaluation of
fields even though it would be possible not to force that.
```
fun f(e) = e <{ width: e.width + 10 };
rec r1(x) = f <| base { width: x };
rec r2(x) = r1 { height: this.width; x };
r2 { 10; }
```
Capturing "this" in a local variable to get a snapshot is required, otherwise Capturing "this" in a local variable to get a snapshot is required, otherwise
we can't refer to parents. we can't refer to parents.

View file

@ -2,19 +2,28 @@ from juic.datatypes import *
from juic.eval import * from juic.eval import *
def juiPrint(*args): def juiPrint(*args):
print("[print]", *args) print("[print]", *(juiValueString(a) for a in args))
def juiLen(x): def juiLen(x):
if type(x) not in [str, list]: if type(x) not in [str, list]:
raise JuiTypeError("len") raise JuiTypeError("len")
return len(x) return len(x)
R_record = RecordType("record", None)
R_subrecord = RecordType("subrecord", R_record)
R_jwidget = RecordType("jwidget", None)
R_jlabel = RecordType("jlabel", R_jwidget)
R_jfkeys = RecordType("jfkeys", R_jwidget)
builtinClosure = Closure(parent=None, scope=MutableScopeData(defs={ builtinClosure = Closure(parent=None, scope=MutableScopeData(defs={
"print": (0, BuiltinFunction(juiPrint)), "print": (0, BuiltinFunction(juiPrint)),
"len": (0, BuiltinFunction(juiLen)), "len": (0, BuiltinFunction(juiLen)),
# TODO: Remove the built-in record type "record" used for testing # TODO: Remove the built-in record type "record" used for testing
"record": (0, RecordType.makePlainRecordType()), "record": (0, R_record),
"subrecord": (0, RecordType.makePlainRecordType()), "subrecord": (0, R_subrecord),
"jwidget": (0, R_jwidget),
"jlabel": (0, R_jlabel),
"jfkeys": (0, R_jfkeys),
}, timestamp=1)) }, timestamp=1))

View file

@ -1,5 +1,9 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Callable, Union from typing import Callable, Union, TYPE_CHECKING
import juic
if TYPE_CHECKING:
import juic.eval
# Mapping of Jui types to Python types: # Mapping of Jui types to Python types:
# Null None # Null None
@ -29,41 +33,31 @@ class Function:
# Expression node to evaluate when calling the function # Expression node to evaluate when calling the function
body: "juic.parser.Node" body: "juic.parser.Node"
# Parameter names, must all be unique. May be empty # Parameter names, must all be unique. May be empty
params: list[str] = field(default_factory=[]) params: list[str] = field(default_factory=list)
# Name of variadic argument if one; must also be unique # Name of variadic argument if one; must also be unique
variadic: str | None = None variadic: str | None = None
@dataclass @dataclass
class RecordType: class RecordType:
# Record name
name: str
# Base type for inheritance/subconstructor logic
base: Union[None, "RecordType"]
# TODO: Record prototypes? # TODO: Record prototypes?
@staticmethod @dataclass
def makePlainRecordType(): class RecordCtor:
return RecordType() func: Function
# @dataclass
# class RecordCtor:
# # Context in which the record body is evaluated
# closure: "juic.eval.Closure"
# # List of entries to produce, of type `REC_ATTR` or `REC_VALUE`
# entries: list["juic.parser.Node"]
# # Parameter names, must all be unique. May be empty
# params: list[str] = field(default_factory=[])
# # Name of variadic argument if one; must also be unique
# variadic: str | None = None
# @staticmethod
# def makePlainRecordCtor():
# return RecordCtor(closure=None, entries=[], params=[], variadic=None)
@dataclass @dataclass
class Record: class Record:
# A record type if it's pure, or a thunk with the base object otherwise. # A record type if it's pure, or a thunk with the base object otherwise.
base: Union[RecordType, "juic.eval.Thunk"] base: Union[RecordType, "juic.eval.Thunk"]
# Standard key-value attribute pairs # Standard key-value attribute pairs
attr: dict[str, "juic.value.Thunk"] attr: dict[str, Union["JuiValue", "juic.eval.Thunk"]]
# Children elements # Children elements
children: list["juic.value.Thunk"] children: list[Union["JuiValue", "juic.eval.Thunk"]]
# TODO: Keep track of variables that are not fields, i.e. "methods"? # TODO: Keep track of variables that are not fields, i.e. "methods"?
# scope: dict[str, "JuiValue"] # scope: dict[str, "JuiValue"]
# TODO: Labels # TODO: Labels
@ -71,7 +65,7 @@ class Record:
JuiValue = Union[None, bool, int, float, str, CXXQualid, list, JuiValue = Union[None, bool, int, float, str, CXXQualid, list,
"juic.eval.PartialRecordSnapshot", BuiltinFunction, Function, "juic.eval.PartialRecordSnapshot", BuiltinFunction, Function,
RecordType, Record] RecordType, RecordCtor, Record]
def juiIsArith(value): def juiIsArith(value):
return type(value) in [bool, int, float] return type(value) in [bool, int, float]
@ -92,10 +86,110 @@ def juiIsCallable(value):
return type(value) in [BuiltinFunction, Function] return type(value) in [BuiltinFunction, Function]
def juiIsProjectable(value): def juiIsProjectable(value):
return type(value) in [ThisRef, Record] return type(value) in [Record, juic.eval.PartialRecordSnapshot]
def juiIsConstructible(value): def juiIsConstructible(value):
return type(value) in [RecordType, Function] return type(value) in [RecordType, RecordCtor]
def juiIsRecordUpdatable(value): def juiIsRecordUpdatable(value):
return type(value) == Record return type(value) == Record
# Generic functions on values
# String representation of a value
def juiValueString(v):
match v:
case None:
return "null"
case bool():
return str(v).lower()
case int() | float():
return str(v)
case str():
return repr(v)
case CXXQualid():
return "&" + v.qualid
case list():
return "[" + ", ".join(juiValueString(x) for x in v) + "]"
case BuiltinFunction():
return str(v)
case Function() as f:
p = f.params + (["..." + f.variadic] if f.variadic else [])
s = "fun(" + ", ".join(p) + ") => " + str(f.body)
s += " (in some closure)"
return s
case RecordCtor() as rc:
f = rc.func
p = f.params + (["..." + f.variadic] if f.variadic else [])
s = "rec(" + ", ".join(p) + ") => " + str(f.body)
s += " (in some closure)"
return s
case RecordType() as rt:
return str(rt)
case Record() as r:
s = r.base.name + " {"
s += "; ".join(x + ": " + juiValueString(y) for x, y in r.attr.items())
if len(r.attr) and len(r.children):
s += "; "
s += "; ".join(juiValueString(x) for x in r.children)
return s + "}"
raise NotImplementedError
case juic.eval.Thunk() as th:
return str(th)
case juic.eval.PartialRecordSnapshot() as prs:
return str(prs)
case _:
raise NotImplementedError
# Check whether two *forced* values are equal
def juiValuesEqual(v1, v2):
def unwrapThunk(v):
match v:
case juic.eval.Thunk() as th:
assert th.evaluated and not th.invalid
return unwrapThunk(th.result)
case _:
return v
v1 = unwrapThunk(v1)
v2 = unwrapThunk(v2)
match v1, v2:
case None, None:
return True
case bool(), bool():
return v1 == v2
case int(), int():
return v1 == v2
case float(), float():
return v1 == v2
case str(), str():
return v1 == v2
case CXXQualid(), CXXQualid():
return v1.qualid == v2.qualid
case list() as l1, list() as l2:
return len(l1) == len(l2) and \
all(juiValuesEqual(v1, v2) for v1, v2 in zip(l1, l2))
case BuiltinFunction(), BuiltinFunction():
return id(v1) == id(v2)
case Function(), Function():
raise Exception("cannot compare functions")
case RecordType(), RecordType():
return id(v1) == id(v2)
case Record() as r1, Record() as r2:
if not juiValuesEqual(r1.base, r2.base):
return False
if r1.attr.keys() != r2.attr.keys():
return False
if any(not juiValuesEqual(r1.attr[k], r2.attr[k]) for k in r1.attr):
return False
if len(r1.children) != len(r2.children):
return False
if any(not juiValuesEqual(v1, v2)
for v1, v2 in zip(r1.children, r2.children)):
return False
return True
case juic.eval.PartialRecordSnapshot(), \
juic.eval.PartialRecordSnapshot():
raise Exception("cannot compare PRSs")
case _, _:
raise Exception(f"invalid types for comparison: {type(v1)}, {type(v2)}")

View file

@ -104,6 +104,21 @@ class Thunk:
# Whether the thunk is currently under evaluation # Whether the thunk is currently under evaluation
_running: bool = False _running: bool = False
def __str__(self):
s = "Thunk("
if self._running:
s += "running, "
if self.evaluated:
s += "evaluated"
if self.invalid:
s += ", invalid"
else:
s += " = " + juiValueString(self.result)
else:
s += "unevaluated"
s += "){ " + str(self.ast) + " }"
return s
# Object representing the interpretation of a partially-built record. In a # Object representing the interpretation of a partially-built record. In a
# record construction or update operation, fields of `this` refer to the latest # record construction or update operation, fields of `this` refer to the latest
# definition before the point of use, if there is one, or the definition in the # definition before the point of use, if there is one, or the definition in the
@ -121,58 +136,12 @@ class PartialRecordSnapshot:
# Base object for fields not captured in `fieldThunks` # Base object for fields not captured in `fieldThunks`
base: None | Thunk = None base: None | Thunk = None
def __str__(self):
return "<PartialRecordSnapshot>" # TODO
def copy(self): def copy(self):
return PartialRecordSnapshot(self.fieldThunks.copy(), self.base) return PartialRecordSnapshot(self.fieldThunks.copy(), self.base)
# TODO: Make this a general value printing function
def juiValueString(v):
match v:
case None:
return "null"
case bool():
return str(v).lower()
case int() | float():
return str(v)
case str():
return repr(v)
case CXXQualid():
return "&" + v.qualid
case list():
return "[" + ", ".join(juiValueString(x) for x in v) + "]"
case BuiltinFunction():
return str(v)
case Function():
p = v.params + (["..." + v.variadic] if v.variadic else [])
s = "fun(" + ", ".join(p) + ") => " + str(v.body)
s += " (in some closure)"
return s
case RecordType():
return "<RecordType>"
case Record() as r:
s = juiValueString(r.base) + " " + "{"
s += "; ".join(x + ": " + juiValueString(y) for x, y in r.attr.items())
s += "; ".join(juiValueString(x) for x in r.children)
return s + "}"
raise NotImplementedError
case Thunk() as th:
s = "Thunk("
if th._running:
s += "running, "
if th.evaluated:
s += "evaluated"
if th.invalid:
s += ", invalid"
else:
s += " = " + juiValueString(th.result)
else:
s += "unevaluated"
s += "){ " + str(th.ast) + " }"
return s
case PartialRecordSnapshot():
return "<A PRS AAAAAAH>"
case _:
raise NotImplementedError
class JuiTypeError(Exception): class JuiTypeError(Exception):
pass pass
@ -284,16 +253,21 @@ class Context:
raise JuiStaticError(f"multiple variadic arguments: {names}") raise JuiStaticError(f"multiple variadic arguments: {names}")
if variadics and variadics != [len(params) - 1]: if variadics and variadics != [len(params) - 1]:
name = params[variadic][0] name = params[variadics[0]][0]
raise JuiStaticError(f"variadic argument {name} not at the end") raise JuiStaticError(f"variadic argument {name} not at the end")
variadic = params[variadics[0]][0] if variadics else None variadic = params[variadics[0]][0] if variadics else None
# Build function object # Build function object
# TODO: Make a full scope not just an expr
return Function(closure = self.currentStateClosure(), return Function(closure = self.currentStateClosure(),
body = body, body = Node(Node.T.SCOPE_EXPR, [body]),
params = [name for (name, v) in params if not v], params = [name for (name, v) in params if not v],
variadic = variadic) variadic = variadic)
def makeRecordCtor(self, params: list[Tuple[str, bool]], body: Node) \
-> RecordCtor:
return RecordCtor(func=self.makeFunction(params, body))
#=== Main evaluation functions ===# #=== Main evaluation functions ===#
# Expression evaluator; this function is pure. It can cause thunks to be # Expression evaluator; this function is pure. It can cause thunks to be
@ -328,7 +302,10 @@ class Context:
case "+": return x + y case "+": return x + y
case "-": return x - y case "-": return x - y
case "*": return x * y case "*": return x * y
case "/": return x / y if type(x) == float else x // y case "/":
match x, y:
case float(), float(): return (x / y)
case _, _: return (x // y)
case "%": return x % y case "%": return x % y
case Node.T.OP, [("!" | "+" | "-") as op, X]: case Node.T.OP, [("!" | "+" | "-") as op, X]:
@ -375,10 +352,21 @@ class Context:
return self.evalCall(f, [X]) return self.evalCall(f, [X])
case Node.T.THIS, []: case Node.T.THIS, []:
raise NotImplementedError v = self.lookup("this")
if v is None:
raise JuiNameError(f"no 'this' in current context")
assert isinstance(v, PartialRecordSnapshot)
return v
case Node.T.PROJ, [R, field]:
r = self.evalExpr(R)
requireType(r, juiIsProjectable)
f = self.project(r, field)
if isinstance(f, Thunk):
return self.evalThunk(f)
else:
return f
case Node.T.PROJ, []:
raise NotImplementedError
case Node.T.CALL, [F, *A]: case Node.T.CALL, [F, *A]:
f = self.evalExpr(F) f = self.evalExpr(F)
@ -401,28 +389,60 @@ class Context:
case Node.T.REC_ATTR, _: case Node.T.REC_ATTR, _:
raise NotImplementedError raise NotImplementedError
case Node.T.REC_VALUE, _: case Node.T.REC_VALUE, args:
raise NotImplementedError raise NotImplementedError
case _, _: raise Exception("invalid expr o(x_x)o: " + str(node))
raise Exception("invalid expr o(x_x)o: " + str(node))
def project(self, v: JuiValue, field: str) -> JuiValue:
match v:
case Record() as r:
if field not in r.attr:
raise Exception(f"access to undefined field {field}")
return self.evalValueOrThunk(r.attr[field])
case PartialRecordSnapshot() as prs:
if field in prs.fieldThunks:
return self.evalThunk(prs.fieldThunks[field])
elif prs.base is None:
raise Exception(f"access to undefined field {field} of 'this'")
else:
return self.project(self.evalThunk(prs.base), field)
case _:
raise NotImplementedError # unreachable
def execStmt(self, node: Node) -> JuiValue: def execStmt(self, node: Node) -> JuiValue:
match node.ctor, node.args: match node.ctor, node.args:
case Node.T.LET_DECL, [name, X]: case Node.T.LET_DECL, [name, X]:
self.addDefinition(name, self.evalExpr(X)) self.addDefinition(name, self.evalExpr(X))
return None
case Node.T.FUN_DECL, [name, params, body]: case Node.T.FUN_DECL, [name, params, body]:
self.addDefinition(name, self.makeFunction(params, body)) self.addDefinition(name, self.makeFunction(params, body))
return None
case Node.T.REC_DECL, _: case Node.T.REC_DECL, [name, params, body]:
raise NotImplementedError self.addDefinition(name, self.makeRecordCtor(params, body))
return None
case Node.T.SET_STMT, _: case Node.T.SET_STMT, _:
raise NotImplementedError raise NotImplementedError
case _, _: case Node.T.UNIT_TEST, [subject, expected]:
return self.evalExpr(node) vs = self.evalExpr(subject)
ve = self.evalExpr(expected)
vs = self.force(vs)
ve = self.force(ve)
if not juiValuesEqual(vs, ve):
print("unit test failed:")
print(" " + juiValueString(vs))
print("vs.")
print(" " + juiValueString(ve))
return None
case Node.T.SCOPE_EXPR, [e]:
return self.evalExpr(e)
raise Exception(f"execStmt: unrecognized node {node.ctor.name}")
# TODO: Context.eval*: continue failed computations to find other errors? # TODO: Context.eval*: continue failed computations to find other errors?
def evalThunk(self, th: Thunk) -> JuiValue: def evalThunk(self, th: Thunk) -> JuiValue:
@ -446,6 +466,9 @@ class Context:
th.result = result th.result = result
return result return result
def evalValueOrThunk(self, v: JuiValue | Thunk) -> JuiValue:
return self.evalThunk(v) if isinstance(v, Thunk) else v
def evalCall(self, f: JuiValue, args: list[Node]) -> JuiValue: def evalCall(self, f: JuiValue, args: list[Node]) -> JuiValue:
# Built-in functions: just evaluate arguments and go # Built-in functions: just evaluate arguments and go
# TODO: Check types of built-in function calls # TODO: Check types of built-in function calls
@ -472,140 +495,113 @@ class Context:
self.addDefinition(name, th) self.addDefinition(name, th)
# self.currentScope.dump() # self.currentScope.dump()
# self.currentClosure.dump() # self.currentClosure.dump()
assert f.body.ctor == Node.T.SCOPE_EXPR
return self.execStmt(f.body) return self.execStmt(f.body)
def evalRecordConstructor(self, ctor: JuiValue, entries: list[Node]) \ def evalRecordConstructor(self, ctor: JuiValue, entries: list[Node]) \
-> JuiValue: -> JuiValue:
# Collect indices of fields and arguments separately; keep the order
args = []
attr = []
for i, e in enumerate(entries):
if e.ctor == Node.T.REC_ATTR:
attr.append(i)
elif e.ctor == Node.T.REC_VALUE:
args.append(i)
else:
assert False and "record node has weird children o(x_x)o"
# Base record constructor: starts out with an empty record. All # Base record constructor: starts out with an empty record. All
# arguments are children. Easy. # arguments are children. Easy.
if type(ctor) == RecordType: if isinstance(ctor, RecordType):
r = Record(base=ctor, attr=dict(), children=dict()) r = Record(base=ctor, attr=dict(), children=[])
if len(args) > 0:
raise JuiRuntimeError(f"arguments given to type rec ctor")
# Create thunks for all entries while providing them with # Create thunks for all entries while providing them with
# progressively more complete PRS of r. # progressively more complete PRS of r.
prs = PartialRecordSnapshot() prs = PartialRecordSnapshot()
for i in attr: for i, e in enumerate(entries):
name, label, node = entries[i].args if e.ctor == Node.T.REC_ATTR:
name, label, node = e.args
elif e.ctor == Node.T.REC_VALUE:
name, label, node = None, None, e.args[0]
th = Thunk(ast=node, closure=self.currentStateClosure()) th = Thunk(ast=node, closure=self.currentStateClosure())
th.thisReference = prs.copy() th.thisReference = prs.copy()
r.attr[name] = th if name is not None:
prs.fieldThunks[name] = th r.attr[name] = th
prs.fieldThunks[name] = th
else:
r.children.append(th)
return r return r
# TODO: Factor this with function. In fact, this should reduce to a call # NOTE: NO WAY TO SPECIFY AN ATTRIBUTE IN A NON-STATIC WAY.
# Create thunks for all entries that have everything but the "this".
entry_thunks = []
for e in entries:
if e.ctor == Node.T.REC_ATTR:
name, label, node = e.args
elif e.ctor == Node.T.REC_VALUE:
name, label, node = None, None, e.args[0]
th = Thunk(ast=node, closure=self.currentStateClosure())
entry_thunks.append(th)
# Collect arguments to the constructor and build a thunk the call.
args = [entry_thunks[i]
for i, e in enumerate(entries) if e.ctor == Node.T.REC_VALUE]
# TODO: Merge with an internal version of evalCall()
#---
assert isinstance(ctor, RecordCtor)
f = ctor.func
# Check number of arguments # Check number of arguments
req = str(len(ctor.params)) + ("+" if ctor.variadic is not None else "") req = str(len(f.params)) + ("+" if f.variadic is not None else "")
if len(args) < len(ctor.params): if len(args) < len(f.params):
raise JuiRuntimeError(f"not enough args (need {req}, got {len(args)})") raise JuiRuntimeError(f"not enough args (need {req}, got {len(args)})")
if len(args) > len(ctor.params) and ctor.variadic is None: if len(args) > len(f.params) and f.variadic is None:
raise JuiRuntimeError(f"too many args (need {req}, got {len(args)})") raise JuiRuntimeError(f"too many args (need {req}, got {len(args)})")
# TODO: In order to build variadic set I need a LIST node # TODO: In order to build variadic set I need a LIST node
if ctor.variadic is not None: if f.variadic is not None:
raise NotImplementedError("list node for building varargs o(x_x)o") raise NotImplementedError("list node for building varargs o(x_x)o")
# Otherwise, it's a function so it might use # Run into the function's scope to build the thunk
raise NotImplementedError with self.contextSwitchAndPushNewScope(f.closure):
for name, th in zip(f.params, args):
self.addDefinition(name, th)
assert f.body.ctor == Node.T.SCOPE_EXPR
call_thunk = Thunk(ast=f.body.args[0],
closure=self.currentStateClosure())
#---
assert type(r) == Function and \ # Use the call as base for a PRS and assign "this" in all thunks.
"evalRecordConstructor: not a record type nor a function" prs = PartialRecordSnapshot()
prs.base = call_thunk
# Thunks should be created from a closure of the entire environment for i, e in enumerate(entries):
# -> This environment is always known, _except_ for entry_thunks[i].thisReference = prs.copy()
# partially-constructed records if e.ctor == Node.T.REC_ATTR:
# So, naturally... patch the PRS for "this" later on? name, label, node = e.args
# prs.fieldThunks[name] = th
# ## In a normal function call
#
# Invariant: context of evaluating the expression that has the call is
# complete. Use it for all the arguments. Done.
#
# ## In a record constructor with no parameters
#
# All attribute values have the same context when excluding "this",
# it's just the context surrounding the record constructor, and
# (invariant) it is complete. Use it to create all of the thunks.
#
# Later, iterate through the thunks in top-down order and patch the
# "this" by constructing PRSs.
#
# Invariant is satisified because each thunk's closure is complete and
# thus the context for evaluating their contents will also be
# complete.
#
# ## In a record constructor with parameters
#
# Again, the entire context except for "this" is well-defined and
# complete. Use it to create thunks for all record entries, inline or
# not.
#
# Prepare a thunk for the call's evaluation, based on the function's
# environment, assigning parameter-related thunks (or lists thereof) as
# definitions for the function's arguments.
#
# Use the call's thunk as base for the PRS and sweep top-to-bottom
# assigning PRSs in every record-entry thunk.
#
# Return a literal record with all non-parameter entries as elements.
# Erm, remove duplicates.
#
# NOTE: NO WAY TO SPECIFY AN ATTRIBUTE IN A NON-STATIC WAY.
# - Build thunks for all entries, with one layer of "this" data baseRecord = self.evalThunk(call_thunk)
# - Call ctor1, pass it the argument thunks if not isinstance(baseRecord, Record):
# - Move into ctor1's scope, add new scope with arguments raise Exception("record ctor did not return a record")
# - Build a thunk for ctor1's body, this is second layer of "this"
# /!\ It's not per-field! It's not per-field!
# - Go back to toplevel, add second layer of "this" to all thunks
# - Return a PartialRecord object that has ctor1's returned thunk as
# base and the other fields as overrides.
#
# ... Looks too opaque at ctor1's level.
# ... But then again for lazy evaluation all we need to know is it's a
# record, and then we evaluated only when we project fields. If the
# same thing happens with <{} there should be no problem.
# ... Having exactly two layers is suspicious. What happens when we try
# to get a field?
#
# 1. Lookup the field in the PartialRecord. If it has it, it's been
# added by the latest constructor, and we know which it is. Evaluate
# the thunk inside.
# 2. Otherwise, evaluate the base thunk. In the "f <| rec { ... }" it's
# a record update. This will yield another PartialRecord. Use it.
# 3. This should work, but we need a PartialRecord.
#
# When do we convert back to a full Record?
# How about that's what Record does, and it just has accessors?
raise NotImplementedError
def force(self, v: JuiValue) -> JuiValue: for i, e in enumerate(entries):
if e.ctor == Node.T.REC_ATTR:
name, label, node = e.args
baseRecord.attr[name] = entry_thunks[i]
return baseRecord
def force(self, v: JuiValue | Thunk) -> JuiValue:
match v: match v:
case Record() as r: case Record() as r:
for a in r.attr: for a in r.attr:
r.attr[a] = self.force(r.attr[a]) r.attr[a] = self.force(r.attr[a])
for i, e in enumerate(r.children):
r.children[i] = self.force(e)
return r return r
case Thunk() as th: case Thunk() as th:
self.evalThunk(th) self.evalThunk(th)
if th.evaluated: if th.evaluated:
return self.force(th.result) return self.force(th.result)
return th return th.result
case _: case _:
return v return v

View file

@ -31,18 +31,26 @@ record { attr: 2 + 3; };
record { x: 1; y: 2; z: subrecord { u: "42"; }; }; record { x: 1; y: 2; z: subrecord { u: "42"; }; };
/* fun stack(x) = x;
record { fun stretch(x) = x;
jwidget {
fullscreen: true; fullscreen: true;
@title jlabel { title; }; @title jlabel { "title"; };
@stack jwidget {} | stack | stretch; @stack jwidget {} | stack | stretch;
let x = 4; // let x = 4;
jfkeys { x }; // jfkeys { x };
jfkeys { 4 };
fun test(x, y, ...all) = x + y + sum(all); // fun test(x, y, ...all) = x + y + sum(all);
}; };
rec jlabel2(str) = jwidget { text: str };
jlabel2 {"Hello"};
/*
fun _(fx, cg) = if(param("FX")) fx else cg; fun _(fx, cg) = if(param("FX")) fx else cg;
fun stack(elem) = elem <{ layout: stack }; fun stack(elem) = elem <{ layout: stack };
fun stretch(elem) = elem <{ stretch_x: 1; stretch_y: 1; strech_beyond_limits: false }; fun stretch(elem) = elem <{ stretch_x: 1; stretch_y: 1; strech_beyond_limits: false };

View file

@ -0,0 +1,42 @@
null;
//^ null;
1+2;
//^ 3;
let zero = 0;
zero + 2 * 20 - 8 / 4;
//^ 38;
"hello" + "," + "world";
//^ "hello,world";
if(2 < 4) 8;
//^ 8;
if(2 > 4) 8;
//^ null;
if(2 > 4) 8 else 14;
//^ 14;
let z = 4;
(z + 10) * 3;
//^ 42;
let helloworld = "Hello, World!";
len("xyz" + helloworld) + 1;
//^ 17;
record {};
//^ record {};
let r = record {x: 2; y: 3+5};
r;
//^ record {y:8; x:2};
r.x;
//^ 2;
record {x: r.x; y: this.x + 2; x: 4; z: this.x + 5};
//^ record {x: 4; y: 4; z: 9};

View file

@ -11,8 +11,9 @@ usage: juic [OPTIONS] INPUT
JustUI high-level description compiler. JustUI high-level description compiler.
Options: Options:
--debug=lexer Dump the lexer output and stop. --debug=lexer Dump the lexer output and stop
=parser Dump the parser output and stop. =parser Dump the parser output and stop
--unit-tests Check unit tests specified by "//^" comments
""".strip() """.strip()
def usage(exitcode=None): def usage(exitcode=None):
@ -24,7 +25,8 @@ def usage(exitcode=None):
def main(argv): def main(argv):
# Read command-line arguments # Read command-line arguments
try: try:
opts, args = getopt.gnu_getopt(argv[1:], "", ["help", "debug="]) opts, args = getopt.gnu_getopt(argv[1:], "",
["help", "debug=", "unit-tests"])
opts = dict(opts) opts = dict(opts)
if len(argv) == 1 or "-h" in opts or "--help" in opts: if len(argv) == 1 or "-h" in opts or "--help" in opts:
@ -45,7 +47,8 @@ def main(argv):
source = fp.read() source = fp.read()
try: try:
lexer = juic.parser.JuiLexer(source, args[0]) lexer = juic.parser.JuiLexer(source, args[0],
keepUnitTests="--unit-tests" in opts)
if opts.get("--debug") == "lexer": if opts.get("--debug") == "lexer":
lexer.dump() lexer.dump()
return 0 return 0
@ -53,8 +56,8 @@ def main(argv):
parser = juic.parser.JuiParser(lexer) parser = juic.parser.JuiParser(lexer)
ast = parser.scope() ast = parser.scope()
if opts.get("--debug") == "parser": if opts.get("--debug") == "parser":
for e in ast.args: for node in ast.args:
e.dump() node.dump()
print("---") print("---")
return 0 return 0
@ -65,11 +68,16 @@ def main(argv):
ctx = juic.eval.Context(juic.builtins.builtinClosure) ctx = juic.eval.Context(juic.builtins.builtinClosure)
for e in ast.args: if "--unit-tests" in opts:
e.dump() for node in ast.args:
v = ctx.execStmt(e) ctx.execStmt(node)
v = ctx.force(v) return 0
print(">>>>>>>", juic.eval.juiValueString(v))
for node in ast.args:
v = ctx.execStmt(node)
if node.ctor == juic.parser.Node.T.SCOPE_EXPR:
v = ctx.force(v)
print(">>>>>>>", juic.eval.juiValueString(v))
ctx.currentScope.dump() ctx.currentScope.dump()
ctx.currentClosure.dump() ctx.currentClosure.dump()

View file

@ -1,5 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
import typing from typing import Any, Tuple
import enum import enum
import sys import sys
import re import re
@ -23,8 +23,8 @@ class SyntaxError(Exception):
@dataclass @dataclass
class Token: class Token:
type: typing.Any type: Any
value: typing.Any value: Any
loc: Loc loc: Loc
def __str__(self): def __str__(self):
@ -45,7 +45,8 @@ class NaiveRegexLexer:
# Override with list of (regex, token type, token value). Both the token # Override with list of (regex, token type, token value). Both the token
# type and value can be functions, in which case they'll be called with the # type and value can be functions, in which case they'll be called with the
# match object as parameter. # match object as parameter.
TOKEN_REGEX = [] Rule = Tuple[str, Any, Any] | Tuple[str, Any, Any, int]
TOKEN_REGEX: list[Rule] = []
# Override with token predicate that matches token to be discarded and not # Override with token predicate that matches token to be discarded and not
# sent to the parser (typically, whitespace and comments). # sent to the parser (typically, whitespace and comments).
TOKEN_DISCARD = lambda _: False TOKEN_DISCARD = lambda _: False
@ -76,12 +77,18 @@ class NaiveRegexLexer:
if not len(self.input): if not len(self.input):
return None return None
highestPriority = 0
longestMatch = None longestMatch = None
longestMatchIndex = -1 longestMatchIndex = -1
for i, (regex, _, _) in enumerate(self.TOKEN_REGEX): for i, (regex, _, _, *rest) in enumerate(self.TOKEN_REGEX):
priority = rest[0] if len(rest) else 0
if (m := re.match(regex, self.input)): if (m := re.match(regex, self.input)):
if longestMatch is None or len(m[0]) > len(longestMatch[0]): score = (priority, len(m[0]))
if longestMatch is None or \
score > (highestPriority, len(longestMatch[0])):
highestPriority = priority
longestMatch = m longestMatch = m
longestMatchIndex = i longestMatchIndex = i
@ -90,7 +97,7 @@ class NaiveRegexLexer:
self.raiseError(f"unknown syntax '{nextWord}'") self.raiseError(f"unknown syntax '{nextWord}'")
# Build the token # Build the token
_, type_info, value_info = self.TOKEN_REGEX[longestMatchIndex] _, type_info, value_info, *rest = self.TOKEN_REGEX[longestMatchIndex]
m = longestMatch m = longestMatch
typ = type_info(m) if callable(type_info) else type_info typ = type_info(m) if callable(type_info) else type_info
@ -169,6 +176,7 @@ class LL1Parser:
# Rule combinators implementing unary and binary operators with precedence # Rule combinators implementing unary and binary operators with precedence
@staticmethod
def binaryOpsLeft(ctor, ops): def binaryOpsLeft(ctor, ops):
def decorate(f): def decorate(f):
def symbol(self): def symbol(self):
@ -179,6 +187,7 @@ class LL1Parser:
return symbol return symbol
return decorate return decorate
@staticmethod
def binaryOps(ctor, ops, *, rassoc=False): def binaryOps(ctor, ops, *, rassoc=False):
def decorate(f): def decorate(f):
def symbol(self): def symbol(self):
@ -191,9 +200,11 @@ class LL1Parser:
return symbol return symbol
return decorate return decorate
@staticmethod
def binaryOpsRight(ctor, ops): def binaryOpsRight(ctor, ops):
return LL1Parser.binaryOps(ctor, ops, rassoc=True) return LL1Parser.binaryOps(ctor, ops, rassoc=True)
@staticmethod
def unaryOps(ctor, ops, assoc=True): def unaryOps(ctor, ops, assoc=True):
def decorate(f): def decorate(f):
def symbol(self): def symbol(self):
@ -212,15 +223,17 @@ def unescape(s: str) -> str:
class JuiLexer(NaiveRegexLexer): class JuiLexer(NaiveRegexLexer):
T = enum.Enum("T", T = enum.Enum("T",
["WS", "KW", "COMMENT", ["WS", "KW", "COMMENT", "UNIT_TEST_MARKER",
"INT", "FLOAT", "STRING", "TEXTLIT", "INT", "FLOAT", "STRING",
"IDENT", "ATTR", "VAR", "LABEL", "FIELD", "CXXIDENT"]) "IDENT", "ATTR", "VAR", "LABEL", "FIELD", "CXXIDENT"])
RE_UTMARKER = r'//\^'
RE_COMMENT = r'(#|//)[^\n]*|/\*([^/]|/[^*])+\*/' RE_COMMENT = r'(#|//)[^\n]*|/\*([^/]|/[^*])+\*/'
RE_INT = r'0|[1-9][0-9]*|0b[0-1]+|0o[0-7]+|0[xX][0-9a-fA-F]+' RE_INT = r'0|[1-9][0-9]*|0b[0-1]+|0o[0-7]+|0[xX][0-9a-fA-F]+'
# RE_FLOAT = r'([0-9]*\.[0-9]+|[0-9]+\.[0-9]*|[0-9]+)([eE][+-]?{INT})?f?' # RE_FLOAT = r'([0-9]*\.[0-9]+|[0-9]+\.[0-9]*|[0-9]+)([eE][+-]?{INT})?f?'
RE_KW = r'\b(else|fun|if|let|rec|set|this)\b' RE_KW = r'\b(else|fun|if|let|rec|set|this|null|true|false)\b'
RE_IDENT = r'[\w_][\w0-9_]*' RE_IDENT = r'[\w_][\w0-9_]*'
RE_ATTR = r'({})\s*(?:@({}))?\s*:'.format(RE_IDENT, RE_IDENT) RE_ATTR = r'({})\s*(?:@({}))?\s*:'.format(RE_IDENT, RE_IDENT)
RE_VAR = r'\$(\.)?' + RE_IDENT RE_VAR = r'\$(\.)?' + RE_IDENT
@ -253,14 +266,22 @@ class JuiLexer(NaiveRegexLexer):
] ]
TOKEN_DISCARD = lambda t: t.type in [JuiLexer.T.WS, JuiLexer.T.COMMENT] TOKEN_DISCARD = lambda t: t.type in [JuiLexer.T.WS, JuiLexer.T.COMMENT]
def __init__(self, input, inputFilename, *, keepUnitTests):
if keepUnitTests:
unit_rule = (self.RE_UTMARKER, JuiLexer.T.UNIT_TEST_MARKER, None, 1)
self.TOKEN_REGEX.insert(0, unit_rule)
super().__init__(input, inputFilename)
@dataclass @dataclass
class Node: class Node:
T = enum.Enum("T", [ T = enum.Enum("T", [
"LIT", "IDENT", "OP", "THIS", "PROJ", "CALL", "IF", "SCOPE", "LIT", "IDENT", "OP", "THIS", "PROJ", "CALL", "IF", "SCOPE",
"RECORD", "REC_ATTR", "REC_VALUE", "RECORD", "REC_ATTR", "REC_VALUE",
"LET_DECL", "FUN_DECL", "REC_DECL", "SET_STMT"]) "LET_DECL", "FUN_DECL", "REC_DECL", "SET_STMT",
"SCOPE_EXPR", "UNIT_TEST"])
ctor: T ctor: T
args: list[typing.Any] args: list[Any]
def dump(self, indent=0): def dump(self, indent=0):
print(" " * indent + self.ctor.name, end=" ") print(" " * indent + self.ctor.name, end=" ")
@ -339,7 +360,7 @@ class JuiParser(LL1Parser):
case T.KW if t.value == "null": case T.KW if t.value == "null":
node = Node(Node.T.LIT, [None]) node = Node(Node.T.LIT, [None])
case T.KW if t.value in ["true", "false"]: case T.KW if t.value in ["true", "false"]:
node = Node(Node.T.LIT, t.value == "true") node = Node(Node.T.LIT, [t.value == "true"])
case "(": case "(":
node = self.expr() node = self.expr()
self.expect(")") self.expect(")")
@ -352,7 +373,7 @@ class JuiParser(LL1Parser):
# | expr0 "<{" record_entry,* "}" (record update) # | expr0 "<{" record_entry,* "}" (record update)
# | expr0 "(" expr,* ")" (function call) # | expr0 "(" expr,* ")" (function call)
# | expr0 "." ident (projection, same prec as call) # | expr0 "." ident (projection, same prec as call)
@LL1Parser.binaryOpsRight(mkOpNode, ["|"]) @LL1Parser.binaryOpsLeft(mkOpNode, ["|"])
@LL1Parser.binaryOpsLeft(mkOpNode, ["<|"]) @LL1Parser.binaryOpsLeft(mkOpNode, ["<|"])
@LL1Parser.binaryOpsLeft(mkOpNode, ["||"]) @LL1Parser.binaryOpsLeft(mkOpNode, ["||"])
@LL1Parser.binaryOpsLeft(mkOpNode, ["&&"]) @LL1Parser.binaryOpsLeft(mkOpNode, ["&&"])
@ -375,7 +396,7 @@ class JuiParser(LL1Parser):
node = Node(Node.T.CALL, [node, *args]) node = Node(Node.T.CALL, [node, *args])
# Postfix update or record creation operation # Postfix update or record creation operation
while self.la.type in ["{", "<{"]: while self.la is not None and self.la.type in ["{", "<{"]:
entries = self.record_literal() entries = self.record_literal()
node = Node(Node.T.RECORD, [node, *entries]) node = Node(Node.T.RECORD, [node, *entries])
@ -480,7 +501,20 @@ class JuiParser(LL1Parser):
def scope(self): def scope(self):
isNone = lambda t: t is None isNone = lambda t: t is None
entries = self.separatedList(self.scope_stmt, sep=";", term=isNone) entries = self.separatedList(self.scope_stmt, sep=";", term=isNone)
return Node(Node.T.SCOPE, entries)
# Rearrange unit tests around their predecessors
entries2 = []
i = 0
while i < len(entries):
if i < len(entries) - 1 and entries[i+1].ctor == Node.T.UNIT_TEST:
entries[i+1].args[0] = entries[i]
entries2.append(entries[i+1])
i += 2
else:
entries2.append(entries[i])
i += 1
return Node(Node.T.SCOPE, entries2)
def scope_stmt(self): def scope_stmt(self):
match self.la.type, self.la.value: match self.la.type, self.la.value:
@ -490,5 +524,8 @@ class JuiParser(LL1Parser):
return self.fun_rec_decl() return self.fun_rec_decl()
case JuiLexer.T.KW, "set": case JuiLexer.T.KW, "set":
return self.set_stmt() return self.set_stmt()
case JuiLexer.T.UNIT_TEST_MARKER, _:
self.expect(JuiLexer.T.UNIT_TEST_MARKER)
return Node(Node.T.UNIT_TEST, [None, self.expr()])
case _: case _:
return self.expr() return Node(Node.T.SCOPE_EXPR, [self.expr()])