diff --git a/README.md b/README.md index c744c7e..f851255 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,16 @@ When `_` is added _after_ a numeric symbol, the symbol becomes 1.000 times large All integer symbols except `I` may be given a `_`. +### SCRIPTA + +The `SCRIPTA` module adds file I/O to your `CENTVRION` program. It adds 3 new built-in functions: `LEGE string`, `SCRIBE string string`, and `ADIVNGE string string`. + +`LEGE string` reads the contents of the file at the given path and returns them as a string. + +`SCRIBE string string` writes the second argument to the file at the path given by the first argument, overwriting any existing content. + +`ADIVNGE string string` appends the second argument to the file at the path given by the first argument. + ### SVBNVLLA ![CVM SVBNVLLA](snippets/svbnvlla.png) diff --git a/centvrion/ast_nodes.py b/centvrion/ast_nodes.py index 8244a54..296fcd1 100644 --- a/centvrion/ast_nodes.py +++ b/centvrion/ast_nodes.py @@ -1152,6 +1152,28 @@ class BuiltIn(Node): raise CentvrionError("DORMI requires a number or NVLLVS") time.sleep(seconds) return vtable, ValNul() + case "LEGE": + if "SCRIPTA" not in vtable["#modules"]: + raise CentvrionError("Cannot use 'LEGE' without module 'SCRIPTA'") + path = make_string(params[0], magnvm, svbnvlla) + with open(path, "r") as f: + return vtable, ValStr(f.read()) + case "SCRIBE": + if "SCRIPTA" not in vtable["#modules"]: + raise CentvrionError("Cannot use 'SCRIBE' without module 'SCRIPTA'") + path = make_string(params[0], magnvm, svbnvlla) + content = make_string(params[1], magnvm, svbnvlla) + with open(path, "w") as f: + f.write(content) + return vtable, ValNul() + case "ADIVNGE": + if "SCRIPTA" not in vtable["#modules"]: + raise CentvrionError("Cannot use 'ADIVNGE' without module 'SCRIPTA'") + path = make_string(params[0], magnvm, svbnvlla) + content = make_string(params[1], magnvm, svbnvlla) + with open(path, "a") as f: + f.write(content) + return vtable, ValNul() case _: raise NotImplementedError(self.builtin) diff --git a/centvrion/compiler/emit_expr.py b/centvrion/compiler/emit_expr.py index dbe847b..84473bd 100644 --- a/centvrion/compiler/emit_expr.py +++ b/centvrion/compiler/emit_expr.py @@ -265,6 +265,29 @@ def _emit_builtin(node, ctx): lines.append(f"cent_dormi({param_vars[0]});") lines.append(f"CentValue {tmp} = cent_null();") + case "LEGE": + if not ctx.has_module("SCRIPTA"): + lines.append('cent_runtime_error("SCRIPTA module required for LEGE");') + lines.append(f"CentValue {tmp} = cent_null();") + else: + lines.append(f"CentValue {tmp} = cent_lege({param_vars[0]});") + + case "SCRIBE": + if not ctx.has_module("SCRIPTA"): + lines.append('cent_runtime_error("SCRIPTA module required for SCRIBE");') + lines.append(f"CentValue {tmp} = cent_null();") + else: + lines.append(f"cent_scribe({param_vars[0]}, {param_vars[1]});") + lines.append(f"CentValue {tmp} = cent_null();") + + case "ADIVNGE": + if not ctx.has_module("SCRIPTA"): + lines.append('cent_runtime_error("SCRIPTA module required for ADIVNGE");') + lines.append(f"CentValue {tmp} = cent_null();") + else: + lines.append(f"cent_adivnge({param_vars[0]}, {param_vars[1]});") + lines.append(f"CentValue {tmp} = cent_null();") + case _: raise NotImplementedError(node.builtin) diff --git a/centvrion/compiler/runtime/cent_runtime.c b/centvrion/compiler/runtime/cent_runtime.c index 6002bec..d48be47 100644 --- a/centvrion/compiler/runtime/cent_runtime.c +++ b/centvrion/compiler/runtime/cent_runtime.c @@ -576,6 +576,40 @@ void cent_dormi(CentValue n) { nanosleep(&ts, NULL); } +/* ---- SCRIPTA module ---- */ + +CentValue cent_lege(CentValue path) { + const char *p = cent_make_string(path); + FILE *f = fopen(p, "r"); + if (!f) cent_runtime_error("LEGE: cannot open file"); + fseek(f, 0, SEEK_END); + long len = ftell(f); + fseek(f, 0, SEEK_SET); + char *buf = cent_arena_alloc(cent_arena, len + 1); + fread(buf, 1, len, f); + buf[len] = '\0'; + fclose(f); + return cent_str(buf); +} + +void cent_scribe(CentValue path, CentValue content) { + const char *p = cent_make_string(path); + const char *c = cent_make_string(content); + FILE *f = fopen(p, "w"); + if (!f) cent_runtime_error("SCRIBE: cannot open file"); + fputs(c, f); + fclose(f); +} + +void cent_adivnge(CentValue path, CentValue content) { + const char *p = cent_make_string(path); + const char *c = cent_make_string(content); + FILE *f = fopen(p, "a"); + if (!f) cent_runtime_error("ADIVNGE: cannot open file"); + fputs(c, f); + fclose(f); +} + CentValue cent_fortis_numerus(CentValue lo, CentValue hi) { if (lo.type != CENT_INT || hi.type != CENT_INT) cent_type_error("'FORTIS_NVMERVS' requires two integers"); diff --git a/centvrion/compiler/runtime/cent_runtime.h b/centvrion/compiler/runtime/cent_runtime.h index 7145b28..c40cfa2 100644 --- a/centvrion/compiler/runtime/cent_runtime.h +++ b/centvrion/compiler/runtime/cent_runtime.h @@ -223,6 +223,9 @@ CentValue cent_senatus(CentValue *args, int n); /* SENATVS */ CentValue cent_typvs(CentValue v); /* TYPVS */ void cent_dormi(CentValue n); /* DORMI */ CentValue cent_ordina(CentValue lst); /* ORDINA */ +CentValue cent_lege(CentValue path); /* LEGE */ +void cent_scribe(CentValue path, CentValue content); /* SCRIBE */ +void cent_adivnge(CentValue path, CentValue content); /* ADIVNGE */ /* ------------------------------------------------------------------ */ /* Array helpers */ diff --git a/centvrion/lexer.py b/centvrion/lexer.py index 4d96e77..cafa264 100644 --- a/centvrion/lexer.py +++ b/centvrion/lexer.py @@ -52,7 +52,10 @@ builtin_tokens = [("BUILTIN", i) for i in [ "ORDINA", "SEMEN", "SENATVS", - "TYPVS" + "TYPVS", + "LEGE", + "SCRIBE", + "ADIVNGE" ]] data_tokens = [ @@ -65,6 +68,7 @@ module_tokens = [("MODULE", i) for i in [ "FORS", "FRACTIO", "MAGNVM", + "SCRIPTA", "SVBNVLLA" ]] diff --git a/language/main.tex b/language/main.tex index 415083f..0ee241d 100644 --- a/language/main.tex +++ b/language/main.tex @@ -94,7 +94,7 @@ \newpage \begin{itemize} \item \textbf{newline}: \\ Newlines are combined, so a single newline is the same as multiple. - \item \textbf{module-name}: \\ Modules are flags given to the interpreter/compiler, to let it know you want to be using certain rules, functions, or features. + \item \textbf{module-name}: \\ Modules are flags given to the interpreter/compiler, to let it know you want to be using certain rules, functions, or features. Available modules: \texttt{FORS} (randomness), \texttt{FRACTIO} (fractions), \texttt{MAGNVM} (large integers), \texttt{SCRIPTA} (file I/O: \texttt{LEGE}, \texttt{SCRIBE}, \texttt{ADIVNGE}), \texttt{SVBNVLLA} (negative literals). \item \textbf{id}: \\ Variable. Can only consist of lowercase characters and underscores, but not the letters j, u, or w. \item \textbf{builtin}: \\ Builtin functions are uppercase latin words. \item \textbf{string}: \\ Any text encased in \texttt{"} or \texttt{'} characters. Single-quoted strings are always literal. diff --git a/snippets/syntaxes/centvrion.sublime-syntax b/snippets/syntaxes/centvrion.sublime-syntax index ebce99e..d74b0f8 100644 --- a/snippets/syntaxes/centvrion.sublime-syntax +++ b/snippets/syntaxes/centvrion.sublime-syntax @@ -70,11 +70,11 @@ contexts: scope: constant.language.centvrion builtins: - - match: '\b(AVDI_NVMERVS|AVDI|CLAVES|DECIMATIO|DICE|EVERRO|FORTIS_NVMERVS|FORTIS_ELECTIONIS|LONGITVDO|ORDINA|SEMEN|SENATVS)\b' + - match: '\b(ADIVNGE|AVDI_NVMERVS|AVDI|CLAVES|DECIMATIO|DICE|EVERRO|FORTIS_NVMERVS|FORTIS_ELECTIONIS|LEGE|LONGITVDO|ORDINA|SCRIBE|SEMEN|SENATVS)\b' scope: support.function.builtin.centvrion modules: - - match: '\b(FORS|FRACTIO|MAGNVM|SVBNVLLA)\b' + - match: '\b(FORS|FRACTIO|MAGNVM|SCRIPTA|SVBNVLLA)\b' scope: support.class.module.centvrion keywords: diff --git a/tests.py b/tests.py index 1c3bf1a..66d0944 100644 --- a/tests.py +++ b/tests.py @@ -655,6 +655,9 @@ error_tests = [ ("SENATVS(I)", CentvrionError), # SENATVS requires booleans ("SENATVS(VERITAS, I)", CentvrionError), # SENATVS mixed types ("SENATVS([I, II, III])", CentvrionError), # SENATVS array of non-bools + ('LEGE("x.txt")', CentvrionError), # SCRIPTA required for LEGE + ('SCRIBE("x.txt", "hi")', CentvrionError), # SCRIPTA required for SCRIBE + ('ADIVNGE("x.txt", "hi")', CentvrionError), # SCRIPTA required for ADIVNGE ("DESIGNA x VT I\nINVOCA x ()", CentvrionError), # invoking a non-function ("SI I TVNC { DESIGNA r VT I }", CentvrionError), # non-bool SI condition: int ("IIIS", CentvrionError), # fraction without FRACTIO module @@ -2329,5 +2332,152 @@ class TestDormi(unittest.TestCase): os.unlink(tmp_bin_path) +class TestScripta(unittest.TestCase): + def _run_scripta(self, source): + lexer = Lexer().get_lexer() + tokens = lexer.lex(source + "\n") + program = Parser().parse(tokens) + # printer round-trip + new_text = program.print() + new_tokens = Lexer().get_lexer().lex(new_text + "\n") + new_nodes = Parser().parse(new_tokens) + self.assertEqual(program, new_nodes, f"Printer test\n{source}\n{new_text}") + return program + + def _compile_and_run(self, program): + c_source = compile_program(program) + with tempfile.NamedTemporaryFile(suffix=".c", delete=False, mode="w") as tmp_c: + tmp_c.write(c_source) + tmp_c_path = tmp_c.name + with tempfile.NamedTemporaryFile(suffix="", delete=False) as tmp_bin: + tmp_bin_path = tmp_bin.name + try: + subprocess.run( + ["gcc", "-O2", tmp_c_path, _RUNTIME_C, "-o", tmp_bin_path], + check=True, capture_output=True, + ) + proc = subprocess.run([tmp_bin_path], capture_output=True, text=True) + self.assertEqual(proc.returncode, 0, f"Compiler binary exited non-zero:\n{proc.stderr}") + return proc.stdout + finally: + os.unlink(tmp_c_path) + os.unlink(tmp_bin_path) + + def test_scribe_and_lege(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + path = f.name + try: + source = f'CVM SCRIPTA\nSCRIBE("{path}", "SALVE MVNDE")\nDICE(LEGE("{path}"))' + program = self._run_scripta(source) + captured = StringIO() + with patch("sys.stdout", captured): + program.eval() + self.assertEqual(captured.getvalue(), "SALVE MVNDE\n") + with open(path) as f: + self.assertEqual(f.read(), "SALVE MVNDE") + finally: + if os.path.exists(path): + os.unlink(path) + + def test_scribe_and_lege_compiled(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + path = f.name + try: + source = f'CVM SCRIPTA\nSCRIBE("{path}", "SALVE MVNDE")\nDICE(LEGE("{path}"))' + program = self._run_scripta(source) + output = self._compile_and_run(program) + self.assertEqual(output, "SALVE MVNDE\n") + with open(path) as f: + self.assertEqual(f.read(), "SALVE MVNDE") + finally: + if os.path.exists(path): + os.unlink(path) + + def test_adivnge(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + path = f.name + try: + source = f'CVM SCRIPTA\nSCRIBE("{path}", "SALVE")\nADIVNGE("{path}", " MVNDE")\nDICE(LEGE("{path}"))' + program = self._run_scripta(source) + captured = StringIO() + with patch("sys.stdout", captured): + program.eval() + self.assertEqual(captured.getvalue(), "SALVE MVNDE\n") + finally: + if os.path.exists(path): + os.unlink(path) + + def test_adivnge_compiled(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + path = f.name + try: + source = f'CVM SCRIPTA\nSCRIBE("{path}", "SALVE")\nADIVNGE("{path}", " MVNDE")\nDICE(LEGE("{path}"))' + program = self._run_scripta(source) + output = self._compile_and_run(program) + self.assertEqual(output, "SALVE MVNDE\n") + finally: + if os.path.exists(path): + os.unlink(path) + + def test_scribe_overwrites(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + path = f.name + try: + source = f'CVM SCRIPTA\nSCRIBE("{path}", "first")\nSCRIBE("{path}", "second")\nLEGE("{path}")' + program = self._run_scripta(source) + result = program.eval() + self.assertEqual(result, ValStr("second")) + finally: + if os.path.exists(path): + os.unlink(path) + + def test_lege_empty_file(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w") as f: + path = f.name + try: + source = f'CVM SCRIPTA\nLEGE("{path}")' + program = self._run_scripta(source) + result = program.eval() + self.assertEqual(result, ValStr("")) + finally: + os.unlink(path) + + def test_lege_preexisting_content(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w") as f: + f.write("hello from python") + path = f.name + try: + source = f'CVM SCRIPTA\nLEGE("{path}")' + program = self._run_scripta(source) + result = program.eval() + self.assertEqual(result, ValStr("hello from python")) + finally: + os.unlink(path) + + def test_scribe_returns_nullus(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + path = f.name + try: + source = f'CVM SCRIPTA\nSCRIBE("{path}", "x")' + program = self._run_scripta(source) + result = program.eval() + self.assertEqual(result, ValNul()) + finally: + if os.path.exists(path): + os.unlink(path) + + def test_adivnge_returns_nullus(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: + path = f.name + try: + source = f'CVM SCRIPTA\nADIVNGE("{path}", "x")' + program = self._run_scripta(source) + result = program.eval() + self.assertEqual(result, ValNul()) + finally: + if os.path.exists(path): + os.unlink(path) + + if __name__ == "__main__": unittest.main()