diff --git a/cent b/cent index 20cd6b9..28d50d6 100755 --- a/cent +++ b/cent @@ -10,8 +10,12 @@ Options: -c Run the compiler FILE The file to compile/interpret """ -from docopt import docopt +import sys +from docopt import docopt +from rply.errors import LexingError + +from centvrion.errors import CentvrionError from centvrion.lexer import Lexer from centvrion.parser import Parser from centvrion.ast_nodes import Program @@ -25,13 +29,20 @@ def main(): lexer = Lexer().get_lexer() parser = Parser() - tokens = lexer.lex(program_text) - - program = parser.parse(tokens) + try: + tokens = lexer.lex(program_text) + program = parser.parse(tokens) + except LexingError as e: + pos = e.source_pos + char = program_text[pos.idx] if pos.idx < len(program_text) else "?" + sys.exit(f"CENTVRION error: Invalid character {char!r} at line {pos.lineno}, column {pos.colno}") if isinstance(program, Program): if args["-i"]: - program.eval() + try: + program.eval() + except CentvrionError as e: + sys.exit(f"CENTVRION error: {e}") else: raise Exception("Compiler not implemented") else: diff --git a/centvrion/ast_nodes.py b/centvrion/ast_nodes.py index 69eb1ff..96b9c09 100644 --- a/centvrion/ast_nodes.py +++ b/centvrion/ast_nodes.py @@ -1,9 +1,11 @@ import re import random +from fractions import Fraction from rply.token import BaseBox -from centvrion.values import Val, ValInt, ValStr, ValBool, ValList, ValNul, ValFunc +from centvrion.errors import CentvrionError +from centvrion.values import Val, ValInt, ValStr, ValBool, ValList, ValNul, ValFunc, ValFrac NUMERALS = { "I": 1, @@ -34,7 +36,7 @@ def rep_join(l): OP_STR = { "SYMBOL_PLUS": "+", "SYMBOL_MINUS": "-", "SYMBOL_TIMES": "*", "SYMBOL_DIVIDE": "/", - "SYMBOL_COLON": ":", + "SYMBOL_AMPERSAND": "&", "KEYWORD_EST": "EST", "KEYWORD_MINVS": "MINVS", "KEYWORD_PLVS": "PLVS", "KEYWORD_ET": "ET", "KEYWORD_AVT": "AVT", } @@ -42,12 +44,12 @@ OP_STR = { def single_num_to_int(i, m): if i[-1] == "_": if not m: - raise ValueError( + raise CentvrionError( "Cannot calculate numbers above 3999 without 'MAGNVM' module" ) if i[0] != "I": - raise ValueError( + raise CentvrionError( "Cannot use 'I' with thousands operator, use 'M' instead" ) @@ -58,16 +60,16 @@ def single_num_to_int(i, m): def num_to_int(n, m, s=False): if n.startswith("-"): if not s: - raise ValueError("Cannot use negative numbers without 'SVBNVLLA' module") + raise CentvrionError("Cannot use negative numbers without 'SVBNVLLA' module") return -num_to_int(n[1:], m, s) chars = re.findall(r"[IVXLCDM]_*", n) if ''.join(chars) != n: - raise ValueError("Invalid numeral", n) + raise CentvrionError(f"Invalid numeral: {n!r}") nums = [single_num_to_int(i, m) for i in chars] new_nums = nums.copy() for x, num in enumerate(nums[:-3]): if all(num == nums[x+i] for i in range(0,4)): - raise ValueError(n, "is not a valid roman numeral") + raise CentvrionError(f"{n!r} is not a valid roman numeral") while True: for x, num in enumerate(nums[:-1]): @@ -77,7 +79,7 @@ def num_to_int(n, m, s=False): new_nums[x+1] = 0 break else: - raise ValueError(n, "is not a valid roman numeral") + raise CentvrionError(f"{n!r} is not a valid roman numeral") if new_nums != nums: nums = [i for i in new_nums if i != 0] @@ -86,18 +88,18 @@ def num_to_int(n, m, s=False): break if nums != sorted(nums)[::-1]: - raise Exception(n, "is not a valid roman numeral") + raise CentvrionError(f"{n!r} is not a valid roman numeral") return sum(nums) def int_to_num(n, m, s=False): if n < 0: if not s: - raise ValueError("Cannot display negative numbers without 'SVBNVLLA' module") + raise CentvrionError("Cannot display negative numbers without 'SVBNVLLA' module") return "-" + int_to_num(-n, m, s) if n > 3999: if not m: - raise ValueError( + raise CentvrionError( "Cannot display numbers above 3999 without 'MAGNVM' module" ) thousands_chars = re.findall(r"[IVXLCDM]_*", int_to_num(n//1000, m, s)) @@ -123,6 +125,8 @@ def make_string(val, magnvm=False, svbnvlla=False): return val.value() elif isinstance(val, ValInt): return int_to_num(val.value(), magnvm, svbnvlla) + elif isinstance(val, ValFrac): + return fraction_to_frac(val.value(), magnvm, svbnvlla) elif isinstance(val, ValBool): return "VERVS" if val.value() else "FALSVS" elif isinstance(val, ValNul): @@ -131,7 +135,62 @@ def make_string(val, magnvm=False, svbnvlla=False): inner = ' '.join(make_string(i, magnvm, svbnvlla) for i in val.value()) return f"[{inner}]" else: - raise TypeError(f"Cannot display {val!r}") + raise CentvrionError(f"Cannot display {val!r}") + +FRAC_SYMBOLS = [("S", 6), (":", 2), (".", 1)] + +def frac_to_fraction(s, magnvm=False, svbnvlla=False): + match = re.match(r'^([IVXLCDM][IVXLCDM_]*)(.*)', s) + if match: + int_str, frac_str = match.group(1), match.group(2) + total = Fraction(num_to_int(int_str, magnvm, svbnvlla)) + else: + total = Fraction(0) + frac_str = s + + for level_idx, level in enumerate(frac_str.split('|')): + divisor = 12 ** (level_idx + 1) + level_val = 0 + prev_weight = 12 + for ch in level: + weight = next((w for sym, w in FRAC_SYMBOLS if sym == ch), None) + if weight is None: + raise CentvrionError(f"Invalid fraction symbol: {ch!r}") + if weight > prev_weight: + raise CentvrionError("Fraction symbols must be written highest to lowest") + prev_weight = weight + level_val += weight + if level_val >= 12: + raise CentvrionError("Fraction level value must be less than 1 (< 12/12)") + total += Fraction(level_val, divisor) + + return total + +def fraction_to_frac(f, magnvm=False, svbnvlla=False): + if f < 0: + if not svbnvlla: + raise CentvrionError("Cannot display negative fractions without 'SVBNVLLA' module") + return "-" + fraction_to_frac(-f, magnvm, svbnvlla) + + integer_part = int(f) + remainder = f - integer_part + int_str = int_to_num(integer_part, magnvm, svbnvlla) if integer_part > 0 else "" + + levels = [] + current = remainder + for _ in range(6): + if current == 0: + break + current = current * 12 + level_int = int(current) + current = current - level_int + s_count = level_int // 6 + remaining = level_int % 6 + colon_count = remaining // 2 + dot_count = remaining % 2 + levels.append("S" * s_count + ":" * colon_count + "." * dot_count) + + return int_str + "|".join(levels) class Node(BaseBox): @@ -200,6 +259,8 @@ class DataRangeArray(Node): def _eval(self, vtable): vtable, from_val = self.from_value.eval(vtable) vtable, to_val = self.to_value.eval(vtable) + if not isinstance(from_val, ValInt) or not isinstance(to_val, ValInt): + raise CentvrionError("Range bounds must be numbers") return vtable, ValList([ValInt(i) for i in range(from_val.value(), to_val.value())]) @@ -237,6 +298,27 @@ class Numeral(Node): return vtable, ValInt(num_to_int(self.value, "MAGNVM" in vtable["#modules"], "SVBNVLLA" in vtable["#modules"])) +class Fractio(Node): + def __init__(self, value) -> None: + self.value = value + + def __eq__(self, other): + return type(self) == type(other) and self.value == other.value + + def __repr__(self): + return f"Fractio({self.value!r})" + + def print(self): + return self.value + + def _eval(self, vtable): + if "FRACTIO" not in vtable["#modules"]: + raise CentvrionError("Cannot use fraction literals without 'FRACTIO' module") + m = "MAGNVM" in vtable["#modules"] + s = "SVBNVLLA" in vtable["#modules"] + return vtable, ValFrac(frac_to_fraction(self.value, m, s)) + + class Bool(Node): def __init__(self, value) -> None: self.value = value @@ -282,6 +364,8 @@ class ID(Node): return self.name def _eval(self, vtable): + if self.name not in vtable: + raise CentvrionError(f"Undefined variable: {self.name}") return vtable, vtable[self.name] @@ -325,10 +409,15 @@ class DesignaIndex(Node): def _eval(self, vtable): vtable, index = self.index.eval(vtable) vtable, val = self.value.eval(vtable) + if self.id.name not in vtable: + raise CentvrionError(f"Undefined variable: {self.id.name}") + target = vtable[self.id.name] + if not isinstance(target, ValList): + raise CentvrionError(f"{self.id.name} is not an array") i = index.value() - lst = list(vtable[self.id.name].value()) + lst = list(target.value()) if i < 1 or i > len(lst): - raise IndexError(f"Index {i} out of range for array of length {len(lst)}") + raise CentvrionError(f"Index {i} out of range for array of length {len(lst)}") lst[i - 1] = val vtable[self.id.name] = ValList(lst) return vtable, ValNul() @@ -440,29 +529,49 @@ class BinOp(Node): match self.op: case "SYMBOL_PLUS": if isinstance(lv, str) or isinstance(rv, str): - raise TypeError("Use : for string concatenation, not +") + raise CentvrionError("Use & for string concatenation, not +") + if isinstance(lv, list) or isinstance(rv, list): + raise CentvrionError("Cannot use + on arrays") if lv is None and rv is None: return vtable, ValNul() - return vtable, ValInt((lv or 0) + (rv or 0)) - case "SYMBOL_COLON": + result = (lv or 0) + (rv or 0) + return vtable, ValFrac(result) if isinstance(result, Fraction) else ValInt(result) + case "SYMBOL_AMPERSAND": magnvm = "MAGNVM" in vtable["#modules"] svbnvlla = "SVBNVLLA" in vtable["#modules"] def _coerce(v): if v is None: return "" if isinstance(v, bool): return "VERITAS" if v else "FALSITAS" if isinstance(v, int): return int_to_num(v, magnvm, svbnvlla) + if isinstance(v, Fraction): return fraction_to_frac(v, magnvm, svbnvlla) + if isinstance(v, list): return make_string(ValList(v), magnvm, svbnvlla) return v return vtable, ValStr(_coerce(lv) + _coerce(rv)) case "SYMBOL_MINUS": - return vtable, ValInt((lv or 0) - (rv or 0)) + if isinstance(lv, (str, list)) or isinstance(rv, (str, list)): + raise CentvrionError("Cannot use - on strings or arrays") + result = (lv or 0) - (rv or 0) + return vtable, ValFrac(result) if isinstance(result, Fraction) else ValInt(result) case "SYMBOL_TIMES": - return vtable, ValInt((lv or 0) * (rv or 0)) + if isinstance(lv, (str, list)) or isinstance(rv, (str, list)): + raise CentvrionError("Cannot use * on strings or arrays") + result = (lv or 0) * (rv or 0) + return vtable, ValFrac(result) if isinstance(result, Fraction) else ValInt(result) case "SYMBOL_DIVIDE": - # TODO: Fractio + if isinstance(lv, (str, list)) or isinstance(rv, (str, list)): + raise CentvrionError("Cannot use / on strings or arrays") + if (rv or 0) == 0: + raise CentvrionError("Division by zero") + if isinstance(lv, Fraction) or isinstance(rv, Fraction) or "FRACTIO" in vtable["#modules"]: + return vtable, ValFrac(Fraction(lv or 0) / Fraction(rv or 0)) return vtable, ValInt((lv or 0) // (rv or 0)) case "KEYWORD_MINVS": + if isinstance(lv, (str, list)) or isinstance(rv, (str, list)): + raise CentvrionError("Cannot compare strings or arrays with MINVS") return vtable, ValBool((lv or 0) < (rv or 0)) case "KEYWORD_PLVS": + if isinstance(lv, (str, list)) or isinstance(rv, (str, list)): + raise CentvrionError("Cannot compare strings or arrays with PLVS") return vtable, ValBool((lv or 0) > (rv or 0)) case "KEYWORD_EST": return vtable, ValBool(lv == rv) @@ -489,8 +598,10 @@ class UnaryMinus(Node): def _eval(self, vtable): if "SVBNVLLA" not in vtable["#modules"]: - raise ValueError("Cannot use unary minus without 'SVBNVLLA' module") + raise CentvrionError("Cannot use unary minus without 'SVBNVLLA' module") vtable, val = self.expr.eval(vtable) + if not isinstance(val, ValInt): + raise CentvrionError("Unary minus requires a number") return vtable, ValInt(-val.value()) @@ -529,10 +640,14 @@ class ArrayIndex(Node): def _eval(self, vtable): vtable, array = self.array.eval(vtable) vtable, index = self.index.eval(vtable) + if not isinstance(array, ValList): + raise CentvrionError("Cannot index a non-array value") + if not isinstance(index, ValInt): + raise CentvrionError("Array index must be a number") i = index.value() lst = array.value() if i < 1 or i > len(lst): - raise IndexError(f"Index {i} out of range for array of length {len(lst)}") + raise CentvrionError(f"Index {i} out of range for array of length {len(lst)}") return vtable, lst[i - 1] @@ -628,6 +743,8 @@ class PerStatement(Node): def _eval(self, vtable): vtable, array = self.data_list.eval(vtable) + if not isinstance(array, ValList): + raise CentvrionError("PER requires an array") variable_name = self.variable_name.name last_val = ValNul() for item in array: @@ -662,9 +779,13 @@ class Invoca(Node): def _eval(self, vtable): params = [p.eval(vtable)[1] for p in self.parameters] + if self.name.name not in vtable: + raise CentvrionError(f"Undefined function: {self.name.name}") func = vtable[self.name.name] + if not isinstance(func, ValFunc): + raise CentvrionError(f"{self.name.name} is not a function") if len(params) != len(func.params): - raise TypeError( + raise CentvrionError( f"{self.name.name} expects {len(func.params)} argument(s), got {len(params)}" ) func_vtable = vtable.copy() @@ -702,7 +823,11 @@ class BuiltIn(Node): match self.builtin: case "AVDI_NVMERVS": - return vtable, ValInt(num_to_int(input(), magnvm, svbnvlla)) + raw = input() + try: + return vtable, ValInt(num_to_int(raw, magnvm, svbnvlla)) + except ValueError: + raise CentvrionError(f"Invalid numeral input: {raw!r}") case "AVDI": return vtable, ValStr(input()) case "DICE": @@ -716,17 +841,25 @@ class BuiltIn(Node): return vtable, ValNul() case "FORTIS_NVMERVS": if "FORS" not in vtable["#modules"]: - raise ValueError( - "Cannot use 'FORTIS_NVMERVS' without module 'FORS'" - ) - return vtable, ValInt(random.randint(params[0].value(), params[1].value())) + raise CentvrionError("Cannot use 'FORTIS_NVMERVS' without module 'FORS'") + a, b = params[0].value(), params[1].value() + if not isinstance(a, int) or not isinstance(b, int): + raise CentvrionError("FORTIS_NVMERVS requires two numbers") + if a > b: + raise CentvrionError(f"FORTIS_NVMERVS: first argument ({a}) must be ≤ second ({b})") + return vtable, ValInt(random.randint(a, b)) case "FORTIS_ELECTIONIS": if "FORS" not in vtable["#modules"]: - raise ValueError( - "Cannot use 'FORTIS_ELECTIONIS' without module 'FORS'" - ) - return vtable, params[0].value()[random.randint(0, len(params[0].value()) - 1)] + raise CentvrionError("Cannot use 'FORTIS_ELECTIONIS' without module 'FORS'") + if not isinstance(params[0], ValList): + raise CentvrionError("FORTIS_ELECTIONIS requires an array") + lst = params[0].value() + if len(lst) == 0: + raise CentvrionError("FORTIS_ELECTIONIS: cannot select from an empty array") + return vtable, lst[random.randint(0, len(lst) - 1)] case "LONGITVDO": + if not isinstance(params[0], ValList): + raise CentvrionError("LONGITVDO requires an array") return vtable, ValInt(len(params[0].value())) case _: raise NotImplementedError(self.builtin) diff --git a/centvrion/errors.py b/centvrion/errors.py new file mode 100644 index 0000000..574799b --- /dev/null +++ b/centvrion/errors.py @@ -0,0 +1 @@ +class CentvrionError(Exception): pass diff --git a/centvrion/lexer.py b/centvrion/lexer.py index 21b3ff7..b024783 100644 --- a/centvrion/lexer.py +++ b/centvrion/lexer.py @@ -41,6 +41,7 @@ builtin_tokens = [("BUILTIN", i) for i in [ data_tokens = [ ("DATA_STRING", r"(\".*?\"|'.*?')"), + ("DATA_FRACTION", r"([IVXLCDM][IVXLCDM_]*)?([S][S:.|]*|:[S:.|]+|\.[S:.|]*)"), ("DATA_NUMERAL", r"[IVXLCDM][IVXLCDM_]*") ] @@ -62,7 +63,7 @@ symbol_tokens = [ ("SYMBOL_MINUS", r"\-"), ("SYMBOL_TIMES", r"\*"), ("SYMBOL_DIVIDE", r"\/"), - ("SYMBOL_COLON", r":"), + ("SYMBOL_AMPERSAND", r"&"), ("SYMBOL_COMMA", r",") ] @@ -74,8 +75,8 @@ all_tokens = ( keyword_tokens + builtin_tokens + module_tokens + - symbol_tokens + data_tokens + + symbol_tokens + whitespace_tokens + [("ID", f"({valid_characters})+")] ) diff --git a/centvrion/parser.py b/centvrion/parser.py index 090e141..68cb883 100644 --- a/centvrion/parser.py +++ b/centvrion/parser.py @@ -13,7 +13,7 @@ class Parser(): ('left', ["KEYWORD_AVT"]), ('left', ["KEYWORD_ET"]), ('left', ["KEYWORD_PLVS", "KEYWORD_MINVS", "KEYWORD_EST"]), - ('left', ["SYMBOL_COLON", "SYMBOL_PLUS", "SYMBOL_MINUS"]), + ('left', ["SYMBOL_AMPERSAND", "SYMBOL_PLUS", "SYMBOL_MINUS"]), ('left', ["SYMBOL_TIMES", "SYMBOL_DIVIDE"]), ('right', ["UMINUS", "UNOT"]), ('left', ["SYMBOL_LBRACKET", "INDEX"]), @@ -169,6 +169,10 @@ class Parser(): def expression_numeral(tokens): return ast_nodes.Numeral(tokens[0].value) + @self.pg.production('expression : DATA_FRACTION') + def expression_fraction(tokens): + return ast_nodes.Fractio(tokens[0].value) + @self.pg.production('expression : KEYWORD_FALSITAS') @self.pg.production('expression : KEYWORD_VERITAS') def expression_bool(tokens): @@ -178,7 +182,7 @@ class Parser(): def expression_nullus(_): return ast_nodes.Nullus() - @self.pg.production('expression : expression SYMBOL_COLON expression') + @self.pg.production('expression : expression SYMBOL_AMPERSAND expression') @self.pg.production('expression : expression SYMBOL_MINUS expression') @self.pg.production('expression : expression SYMBOL_PLUS expression') @self.pg.production('expression : expression SYMBOL_TIMES expression') diff --git a/centvrion/values.py b/centvrion/values.py index 87f6655..dd37821 100644 --- a/centvrion/values.py +++ b/centvrion/values.py @@ -1,4 +1,7 @@ from abc import ABC, abstractmethod +from fractions import Fraction + +from centvrion.errors import CentvrionError class Val(ABC): @abstractmethod @@ -63,12 +66,29 @@ class ValList(Val): def __iter__(self): return iter(self._v) +class ValFrac(Val): + def __init__(self, v: Fraction): + assert isinstance(v, Fraction) + self._v = v + + def value(self): + return self._v + + def __bool__(self): + return self._v != 0 + + def __lt__(self, other): + return self._v < other.value() + + def __gt__(self, other): + return self._v > other.value() + class ValNul(Val): def value(self): return None def __bool__(self): - raise TypeError("NVLLVS cannot be evaluated as a boolean") + raise CentvrionError("NVLLVS cannot be evaluated as a boolean") class ValFunc(Val): def __init__(self, params: list, body: list): diff --git a/examples/multiplication_table.cent b/examples/multiplication_table.cent index 52a616c..f73a92d 100644 --- a/examples/multiplication_table.cent +++ b/examples/multiplication_table.cent @@ -4,7 +4,7 @@ DESIGNA n VT X DONICVM i VT I VSQVE n + I FACE { DESIGNA line VT "" DONICVM k VT I VSQVE n + I FACE { - DESIGNA line VT line : i * k : " " + DESIGNA line VT line & i * k & " " } DICE(line) } diff --git a/tests.py b/tests.py index bfb2834..f84b1c7 100644 --- a/tests.py +++ b/tests.py @@ -4,16 +4,20 @@ from io import StringIO from unittest.mock import patch from parameterized import parameterized +from fractions import Fraction + from centvrion.ast_nodes import ( ArrayIndex, Bool, BinOp, BuiltIn, DataArray, DataRangeArray, Defini, Designa, DesignaIndex, DumStatement, Erumpe, ExpressionStatement, ID, Invoca, ModuleCall, Nullus, Numeral, PerStatement, Program, Redi, SiStatement, String, UnaryMinus, UnaryNot, + Fractio, frac_to_fraction, fraction_to_frac, num_to_int, int_to_num, make_string, ) +from centvrion.errors import CentvrionError from centvrion.lexer import Lexer from centvrion.parser import Parser -from centvrion.values import ValInt, ValStr, ValBool, ValList, ValNul, ValFunc +from centvrion.values import ValInt, ValStr, ValBool, ValList, ValNul, ValFunc, ValFrac def run_test(self, source, target_nodes, target_value, target_output="", input_lines=[]): random.seed(1) @@ -368,22 +372,35 @@ class TestBuiltins(unittest.TestCase): # --- Errors --- error_tests = [ - ("x", KeyError), # undefined variable - ("INVOCA f ()", KeyError), # undefined function - ("DESIGNA VT III", SyntaxError), # parse error: missing id after DESIGNA - ("DESIGNA x III", SyntaxError), # parse error: missing VT - ("DICE(M + M + M + M)", ValueError), # output > 3999 without MAGNVM - ("IIII", ValueError), # invalid Roman numeral in source - ("FORTIS_NVMERVS(I, X)", ValueError), # requires FORS module - ("DEFINI f (x) VT { REDI(x) }\nINVOCA f (I, II)", TypeError), # too many args - ("DEFINI f (x, y) VT { REDI(x) }\nINVOCA f (I)", TypeError), # too few args - ("DEFINI f () VT { REDI(I) }\nINVOCA f (I)", TypeError), # args to zero-param function - ("SI NVLLVS TVNC { DESIGNA r VT I }", TypeError), # NVLLVS cannot be used as boolean - ("NVLLVS AVT VERITAS", TypeError), # NVLLVS cannot be used as boolean in AVT - ('"hello" + " world"', TypeError), # use : for string concatenation, not + - ("[I, II][III]", IndexError), # index too high - ("CVM SVBNVLLA\n[I, II][-I]", IndexError), # negative index - ("[I, II][-I]", ValueError), # negative value + ("x", CentvrionError), # undefined variable + ("INVOCA f ()", CentvrionError), # undefined function + ("DESIGNA VT III", SyntaxError), # parse error: missing id after DESIGNA + ("DESIGNA x III", SyntaxError), # parse error: missing VT + ("DICE(M + M + M + M)", CentvrionError), # output > 3999 without MAGNVM + ("IIII", CentvrionError), # invalid Roman numeral in source + ("FORTIS_NVMERVS(I, X)", CentvrionError), # requires FORS module + ("DEFINI f (x) VT { REDI(x) }\nINVOCA f (I, II)", CentvrionError), # too many args + ("DEFINI f (x, y) VT { REDI(x) }\nINVOCA f (I)", CentvrionError), # too few args + ("DEFINI f () VT { REDI(I) }\nINVOCA f (I)", CentvrionError), # args to zero-param function + ("SI NVLLVS TVNC { DESIGNA r VT I }", CentvrionError), # NVLLVS cannot be used as boolean + ("NVLLVS AVT VERITAS", CentvrionError), # NVLLVS cannot be used as boolean in AVT + ('"hello" + " world"', CentvrionError), # use : for string concatenation, not + + ("[I, II][III]", CentvrionError), # index too high + ("CVM SVBNVLLA\n[I, II][-I]", CentvrionError), # negative index + ("[I, II][-I]", CentvrionError), # negative value + ("I / NVLLVS", CentvrionError), # division by zero (NVLLVS coerces to 0) + ("I / [I, II]", CentvrionError), # division with array operand + ("I - \"hello\"", CentvrionError), # subtraction with string + ("I * \"hello\"", CentvrionError), # multiplication with string + ("\"hello\" MINVS \"world\"", CentvrionError), # comparison with strings + ("I[I]", CentvrionError), # indexing a non-array + ("DESIGNA x VT I\nDESIGNA x[I] VT II", CentvrionError), # index-assign to non-array + ("FORTIS_ELECTIONIS([])", CentvrionError), # FORS required for FORTIS_ELECTIONIS + ("CVM FORS\nFORTIS_ELECTIONIS([])", CentvrionError), # FORTIS_ELECTIONIS on empty array + ("CVM FORS\nFORTIS_NVMERVS(X, I)", CentvrionError), # FORTIS_NVMERVS a > b + ("PER i IN I FACE { DICE(i) }", CentvrionError), # PER over non-array + ("LONGITVDO(I)", CentvrionError), # LONGITVDO on non-array + ("DESIGNA x VT I\nINVOCA x ()", CentvrionError), # invoking a non-function ] class TestErrors(unittest.TestCase): @@ -463,7 +480,7 @@ class TestNumerals(unittest.TestCase): num_to_int("IM", False) def test_negative_without_svbnvlla_raises(self): - with self.assertRaises(ValueError): + with self.assertRaises(CentvrionError): num_to_int("-IV", False) def test_negative_with_svbnvlla(self): @@ -606,14 +623,14 @@ class TestArithmeticEdge(unittest.TestCase): # --- String concatenation --- string_concat_tests = [ - ('"hello" : " world"', Program([], [ExpressionStatement(BinOp(String("hello"), String(" world"), "SYMBOL_COLON"))]), ValStr("hello world")), + ('"hello" & " world"', Program([], [ExpressionStatement(BinOp(String("hello"), String(" world"), "SYMBOL_AMPERSAND"))]), ValStr("hello world")), # NVLLVS coerces to "" in string context - ('NVLLVS : "hello"', Program([], [ExpressionStatement(BinOp(Nullus(), String("hello"), "SYMBOL_COLON"))]), ValStr("hello")), - ('"hello" : NVLLVS', Program([], [ExpressionStatement(BinOp(String("hello"), Nullus(), "SYMBOL_COLON"))]), ValStr("hello")), - ('NVLLVS : NVLLVS', Program([], [ExpressionStatement(BinOp(Nullus(), Nullus(), "SYMBOL_COLON"))]), ValStr("")), + ('NVLLVS & "hello"', Program([], [ExpressionStatement(BinOp(Nullus(), String("hello"), "SYMBOL_AMPERSAND"))]), ValStr("hello")), + ('"hello" & NVLLVS', Program([], [ExpressionStatement(BinOp(String("hello"), Nullus(), "SYMBOL_AMPERSAND"))]), ValStr("hello")), + ('NVLLVS & NVLLVS', Program([], [ExpressionStatement(BinOp(Nullus(), Nullus(), "SYMBOL_AMPERSAND"))]), ValStr("")), # integers coerce to Roman numerals in string context - ('"value: " : V', Program([], [ExpressionStatement(BinOp(String("value: "), Numeral("V"), "SYMBOL_COLON"))]), ValStr("value: V")), - ('X : " items"', Program([], [ExpressionStatement(BinOp(Numeral("X"), String(" items"), "SYMBOL_COLON"))]), ValStr("X items")), + ('"value: " & V', Program([], [ExpressionStatement(BinOp(String("value: "), Numeral("V"), "SYMBOL_AMPERSAND"))]), ValStr("value: V")), + ('X & " items"', Program([], [ExpressionStatement(BinOp(Numeral("X"), String(" items"), "SYMBOL_AMPERSAND"))]), ValStr("X items")), ] class TestStringConcat(unittest.TestCase): @@ -1174,5 +1191,129 @@ class TestNon(unittest.TestCase): run_test(self, source, nodes, value) +# --- FRACTIO module --- + +fractio_tests = [ + # Basic fraction literals + ("CVM FRACTIO\nIIIS", + Program([ModuleCall("FRACTIO")], [ExpressionStatement(Fractio("IIIS"))]), + ValFrac(Fraction(7, 2))), + ("CVM FRACTIO\nS", + Program([ModuleCall("FRACTIO")], [ExpressionStatement(Fractio("S"))]), + ValFrac(Fraction(1, 2))), + ("CVM FRACTIO\nS:.", + Program([ModuleCall("FRACTIO")], [ExpressionStatement(Fractio("S:."))]), + ValFrac(Fraction(3, 4))), + ("CVM FRACTIO\n.", + Program([ModuleCall("FRACTIO")], [ExpressionStatement(Fractio("."))]), + ValFrac(Fraction(1, 12))), + ("CVM FRACTIO\n:.", + Program([ModuleCall("FRACTIO")], [ExpressionStatement(Fractio(":."))]), + ValFrac(Fraction(1, 4))), + # Integer part with fraction + ("CVM FRACTIO\nVIIS:|::", + Program([ModuleCall("FRACTIO")], [ExpressionStatement(Fractio("VIIS:|::"))]), + ValFrac(Fraction(7) + Fraction(100, 144))), + # Arithmetic + ("CVM FRACTIO\nIIIS + S", + None, ValFrac(Fraction(4))), + ("CVM FRACTIO\nIIIS - S", + None, ValFrac(Fraction(3))), + ("CVM FRACTIO\nS * IV", + None, ValFrac(Fraction(2))), + # Division returns fraction + ("CVM FRACTIO\nI / IV", + None, ValFrac(Fraction(1, 4))), + ("CVM FRACTIO\nI / III", + None, ValFrac(Fraction(1, 3))), + # Integer division still works without fractions in operands... but with FRACTIO returns ValFrac + ("CVM FRACTIO\nX / II", + None, ValFrac(Fraction(5))), + # String concatenation with fraction + ("CVM FRACTIO\nDICE(IIIS & \"!\")", + None, ValStr("IIIS!"), "IIIS!\n"), +] + +class TestFractio(unittest.TestCase): + @parameterized.expand(fractio_tests) + def test_fractio(self, source, nodes, value, output=""): + run_test(self, source, nodes, value, output) + + +fractio_comparison_tests = [ + # fraction vs fraction + ("CVM FRACTIO\nIIIS PLVS III", None, ValBool(True)), # 3.5 > 3 + ("CVM FRACTIO\nIII MINVS IIIS", None, ValBool(True)), # 3 < 3.5 + ("CVM FRACTIO\nIIIS MINVS IV", None, ValBool(True)), # 3.5 < 4 + ("CVM FRACTIO\nIV PLVS IIIS", None, ValBool(True)), # 4 > 3.5 + ("CVM FRACTIO\nIIIS PLVS IIIS", None, ValBool(False)), # 3.5 > 3.5 is False + ("CVM FRACTIO\nIIIS MINVS IIIS", None, ValBool(False)), # 3.5 < 3.5 is False + # equality: fraction == fraction + ("CVM FRACTIO\nIIIS EST IIIS", None, ValBool(True)), + ("CVM FRACTIO\nIIIS EST IV", None, ValBool(False)), + # equality: fraction == whole number (ValFrac(4) vs ValInt(4)) + ("CVM FRACTIO\nIIIS + S EST IV", None, ValBool(True)), # 3.5+0.5 == 4 + ("CVM FRACTIO\nS + S EST I", None, ValBool(True)), # 0.5+0.5 == 1 +] + +class TestFractioComparisons(unittest.TestCase): + @parameterized.expand(fractio_comparison_tests) + def test_fractio_comparison(self, source, nodes, value): + run_test(self, source, nodes, value) + + +class TestFractioErrors(unittest.TestCase): + def test_fraction_without_module(self): + source = "IIIS\n" + lexer = Lexer().get_lexer() + tokens = lexer.lex(source) + program = Parser().parse(tokens) + with self.assertRaises(CentvrionError): + program.eval() + + def test_frac_to_fraction_ordering(self): + with self.assertRaises(CentvrionError): + frac_to_fraction(".S") # . before S violates highest-to-lowest + + def test_frac_to_fraction_level_overflow(self): + with self.assertRaises(CentvrionError): + frac_to_fraction("SSSSSS") # 6*S = 36/12 >= 1 per level... wait S can only appear once + # Actually "SS" means S twice, which is 12/12 = 1, violating < 12/12 constraint + + +class TestFractioHelpers(unittest.TestCase): + def test_frac_to_fraction_iiis(self): + self.assertEqual(frac_to_fraction("IIIS"), Fraction(7, 2)) + + def test_frac_to_fraction_s_colon_dot(self): + self.assertEqual(frac_to_fraction("S:."), Fraction(3, 4)) + + def test_frac_to_fraction_dot(self): + self.assertEqual(frac_to_fraction("."), Fraction(1, 12)) + + def test_frac_to_fraction_multilevel(self): + self.assertEqual(frac_to_fraction("VIIS:|::"), Fraction(7) + Fraction(100, 144)) + + def test_fraction_to_frac_iiis(self): + self.assertEqual(fraction_to_frac(Fraction(7, 2)), "IIIS") + + def test_fraction_to_frac_s_colon_dot(self): + self.assertEqual(fraction_to_frac(Fraction(3, 4)), "S:.") + + def test_fraction_to_frac_dot(self): + self.assertEqual(fraction_to_frac(Fraction(1, 12)), ".") + + def test_fraction_to_frac_multilevel(self): + self.assertEqual( + fraction_to_frac(Fraction(7) + Fraction(100, 144)), + "VIIS:|::" + ) + + def test_roundtrip(self): + # Only canonical forms roundtrip — fraction_to_frac always uses max colons before dots + for s in ["IIIS", "S:.", ".", "::", "VIIS:|::", "S"]: + self.assertEqual(fraction_to_frac(frac_to_fraction(s)), s) + + if __name__ == "__main__": unittest.main()