From 6b626cdb4e49d456ff480cff9717304220e5e579 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:08:20 +0000 Subject: [PATCH 1/4] Add optional types (i8..u64, f32, f64, char, ...) and new language tools Types - Optional type annotations: `age: u8 = 30`, `name: string?`, and `count: i32` (starts at the zero value). Types: i8 i16 i32 i64 u8 u16 u32 u64 byte int f32 f64 float bool char string list map func any, plus `void` as a return type and `T?` for nullable types. - Every number carries its type. Arithmetic keeps the type, literals adapt to the other operand, mixed types widen to a type holding both, and overflow is an error instead of a silent wrap. f32 keeps 32-bit precision. - Typed parameters and return types (`func f(a: i32) -> f64`), `const`, and conversion functions named after each type (`u8(x)`, `f32(x)`, ...). - Literal type mismatches (`x: u8 = 300`) and wrong return values are reported before the program runs. Language - `char` type and literals ('a', '\u{1F600}'); single quotes now mean a char. - Strings are Unicode: len, indexing, slicing and loops count characters. - f-strings with format specs (f"{price:>8.2f}") and format(). - match statements. - Hex/binary/octal literals, type suffixes (255u8), and bitwise operators & | ^ ~ << >> with compound assignments. - Modules via import("file"), with errors reported in the module's file. Tools - 124 builtins, including file I/O, JSON, string/list/map helpers, math, time and system functions. - `ascend --check file.asc` finds errors without running. - VS Code syntax highlighting in editors/vscode, checked with the real TextMate engine. Also adds 34 tests (110 total), six examples, a CHANGELOG listing the behavior changes, and updated docs. Builds without warnings on GCC and Clang; tests and examples are clean under ASan, UBSan and LSan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014xWEy3pVr7ofMaomGRbiuo --- CHANGELOG.md | 61 ++ CMakeLists.txt | 8 +- README.md | 51 +- docs/installation.md | 7 + docs/standard_library.md | 106 ++- docs/syntax.md | 259 ++++-- editors/vscode/README.md | 14 + editors/vscode/language-configuration.json | 30 + editors/vscode/package.json | 31 + .../vscode/syntaxes/ascend.tmLanguage.json | 116 +++ examples/bits.asc | 38 + examples/geometry.asc | 20 + examples/json_and_files.asc | 25 + examples/modules.asc | 8 + examples/text.asc | 44 ++ examples/types.asc | 61 ++ src/format.cpp | 258 ++++++ src/include/ast.hpp | 43 +- src/include/environment.hpp | 29 +- src/include/error.hpp | 7 + src/include/interpreter.hpp | 34 +- src/include/lexer.hpp | 32 +- src/include/numeric.hpp | 49 ++ src/include/parser.hpp | 11 + src/include/runner.hpp | 7 +- src/include/semantic_analyzer.hpp | 14 +- src/include/stdlib.hpp | 17 +- src/include/typecheck.hpp | 33 + src/include/types.hpp | 45 ++ src/include/utf8.hpp | 29 + src/include/value.hpp | 72 +- src/interpreter.cpp | 670 +++++++++++----- src/json.cpp | 325 ++++++++ src/lexer.cpp | 430 ++++++++-- src/main.cpp | 21 +- src/numeric.cpp | 369 +++++++++ src/parser.cpp | 254 +++++- src/runner.cpp | 27 +- src/semantic_analyzer.cpp | 92 ++- src/stdlib.cpp | 735 +++++++++++++++--- src/typecheck.cpp | 107 +++ src/types.cpp | 121 +++ src/utf8.cpp | 104 +++ src/value.cpp | 199 +++-- tests/interpreter_tests.cpp | 283 ++++++- tests/lexer_tests.cpp | 65 +- tests/parser_tests.cpp | 90 ++- 47 files changed, 4795 insertions(+), 656 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 editors/vscode/README.md create mode 100644 editors/vscode/language-configuration.json create mode 100644 editors/vscode/package.json create mode 100644 editors/vscode/syntaxes/ascend.tmLanguage.json create mode 100644 examples/bits.asc create mode 100644 examples/geometry.asc create mode 100644 examples/json_and_files.asc create mode 100644 examples/modules.asc create mode 100644 examples/text.asc create mode 100644 examples/types.asc create mode 100644 src/format.cpp create mode 100644 src/include/numeric.hpp create mode 100644 src/include/typecheck.hpp create mode 100644 src/include/types.hpp create mode 100644 src/include/utf8.hpp create mode 100644 src/json.cpp create mode 100644 src/numeric.cpp create mode 100644 src/typecheck.cpp create mode 100644 src/types.cpp create mode 100644 src/utf8.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f3831ff --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +## 0.3.0 + +### New + +- **Optional types**: `name: type = value` declarations with `i8` `i16` `i32` + `i64` `u8` `u16` `u32` `u64` `byte` `int` `f32` `f64` `float` `bool` `char` + `string` `list` `map` `func` `any`, and nullable types like `string?`. + Typed variables reject values of the wrong type, and numbers that don't fit. + `name: type` without a value starts at the type's zero value. +- Every number carries its type. Arithmetic stays in that type and reports + overflow as an error (`250u8 + 10` fails instead of wrapping). +- Typed function parameters and return types: `func f(a: i32) -> f64 { }`, + plus `-> void`. +- `const` declarations. +- `match` statements. +- f-strings with format specs: `f"{name:<10} {price:>8.2f}"`, and `format()`. +- `char` type and literals: `'a'`, `'\n'`, `'\u{1F600}'`; char arithmetic. +- Hex, binary and octal literals (`0xFF`, `0b1010`, `0o17`) and type + suffixes (`255u8`, `1.5f32`). +- Bitwise operators `& | ^ ~ << >>` and their compound assignments. +- Unicode strings: `len`, indexing, slicing and iteration count characters. +- Modules: `import("file")` returns a file's top-level names as a map. +- Type mismatches in literals (`x: u8 = 300`) and in `return` values are + reported before the program runs. +- New builtins: type conversions (`i8()` ... `u64()`, `f32()`, `f64()`, + `char()`, `string()`, `byte()`), `format`, `hex`, `bin`, `chars`, `bytes`, + `from_bytes`, `lines`, `pad_left`, `pad_right`, `count`, `is_digit` and + friends, `zip`, `unique`, `flatten`, `extend`, `clear`, `find`, `any`, `all`, + `items`, `merge`, `clamp`, `sign`, `gcd`, trigonometry and logarithms, + `choice`, `shuffle`, `INF`, `NAN`, file functions (`read_file`, + `write_file`, `append_file`, `read_lines`, `file_exists`, `delete_file`), + `json_parse`, `json_stringify`, `clock`, `sleep`, `date`, `env`, `system`. +- `ascend --check file.asc` reports errors without running the program. +- Syntax highlighting for VS Code in `editors/vscode`. + +### Changed (may affect existing programs) + +- Single quotes now make a `char`, not a string: write `"it's"` instead of + `'it\'s'`. +- `&` and `|` are now bitwise operators (they used to be errors). Use `and` / + `or` (or `&&` / `||`) for logic. +- Indexing a string gives a `char` instead of a one-character string. Chars + compare equal to the matching one-character string, so `s[0] == "a"` still + works. +- String lengths, indexes and slices count characters instead of bytes. +- `\xHH` in a string is now the character with that code (as in Python), + encoded as UTF-8. +- `type()` returns `"func"` for functions (was `"function"`). +- Builtins now live in a scope above the program's globals, so a program can + reuse their names for its own variables. + +## 0.2.0 + +- Rewrote the interpreter as a lexer, parser, semantic checker and + tree-walking interpreter, fixing the bugs in `!=`, `while`, `for` and + function calls. +- Added floats, strings, bools, lists, maps, closures, `try` / `catch`, + `else if`, `break` / `continue`, method-call syntax, slices, a REPL, error + messages with source locations, a test suite and documentation. diff --git a/CMakeLists.txt b/CMakeLists.txt index acbfa89..8a89100 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.10) -project(ascend VERSION 0.2.0 LANGUAGES CXX) +project(ascend VERSION 0.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -13,12 +13,18 @@ endif() # Everything except main() lives in a library so the tests can use it too. add_library(ascend_core STATIC src/error.cpp + src/types.cpp + src/utf8.cpp + src/numeric.cpp + src/typecheck.cpp src/lexer.cpp src/parser.cpp src/semantic_analyzer.cpp src/value.cpp src/interpreter.cpp src/stdlib.cpp + src/format.cpp + src/json.cpp src/runner.cpp src/repl.cpp ) diff --git a/README.md b/README.md index 0f57ce6..a7d3e65 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Ascend -Ascend is a small, friendly, dynamically typed scripting language with an -interpreter written in C++17. +Ascend is a small, friendly scripting language with optional types, written in +C++17. ``` -func fizzbuzz(n) { +func fizzbuzz(n: i32) -> string { if n % 15 == 0 { return "FizzBuzz" } if n % 3 == 0 { return "Fizz" } if n % 5 == 0 { return "Buzz" } @@ -15,37 +15,50 @@ for i in range(1, 16) { print(fizzbuzz(i)) } +level: u8 = 2 +match level { + 1 => print("easy") + 2, 3 => print("normal") + else => print("hard") +} + scores := {ada: 95, linus: 88} -scores.grace = 91 for name, score in scores { - print(name, score >= 90 ? "top" : "ok") + print(f"{name:<6} {score:>3}") } ``` ## Features -- Ints, floats, strings, bools, `null`, lists and insertion-ordered maps -- `if` / `else if` / `else`, `while`, `for ... in` over ranges, lists, strings and maps, `break` / `continue` +- **Optional types**: `i8`–`i64`, `u8`–`u64`, `byte`, `f32`, `f64`, `bool`, + `char`, `string`, `list`, `map`, `func` and nullable `T?`. Typed variables, + parameters and return values are checked, and integer overflow is an error + instead of a silent wrap. +- Constants, `match`, `if` / `else if`, `while`, `for ... in` over ranges, + lists, strings and maps, `break` / `continue` - Functions with default parameters, recursion, closures and anonymous functions -- Method-call syntax for builtins: `items.push(4)`, `"a,b".split(",")` +- f-strings with format specs: `f"{price:>8.2f}"` +- Hex, binary and octal literals, type suffixes (`255u8`) and bitwise operators +- Unicode strings and a `char` type +- Modules with `import("file")` - `try` / `catch` / `throw` error handling -- 50+ builtins for strings, lists, maps, math and I/O -- Clear error messages that point at the problem: +- 100+ builtins for strings, lists, maps, math, files, JSON and the system +- Clear error messages that point at the problem, many caught before running: ``` - script.asc:2:7: runtime error: undefined variable 'cuont' (did you mean 'count'?) - 2 | print(cuont) - | ^ + script.asc:3:9: error: 'age' is declared as u8, so it can't hold 300 (u8 is 0 to 255) + 3 | age: u8 = 300 + | ^ ``` -- An interactive REPL with multi-line input +- An interactive REPL, a `--check` mode, and syntax highlighting for VS Code ## Quick start ```sh cmake -S . -B build cmake --build build -./build/ascend examples/fizzbuzz.asc +./build/ascend examples/types.asc ./build/ascend # interactive REPL ``` @@ -53,7 +66,7 @@ cmake --build build - [Language guide](docs/syntax.md) - [Standard library](docs/standard_library.md) -- [Building, installing and testing](docs/installation.md) +- [Building, installing, testing and editor support](docs/installation.md) - [Example programs](examples/) ## How it works @@ -62,10 +75,10 @@ cmake --build build |-------|--------| | Lexer: source text to tokens | `src/lexer.cpp` | | Parser: tokens to a syntax tree | `src/parser.cpp`, `src/include/ast.hpp` | -| Semantic checks (e.g. `break` outside a loop) | `src/semantic_analyzer.cpp` | +| Checks before running (types of literals, `break` outside loops, ...) | `src/semantic_analyzer.cpp`, `src/typecheck.cpp` | | Tree-walking interpreter | `src/interpreter.cpp` | -| Values, and a cycle collector for closures | `src/value.cpp`, `src/include/environment.hpp` | -| Builtin functions | `src/stdlib.cpp` | +| Values, numeric types and checked arithmetic | `src/value.cpp`, `src/numeric.cpp`, `src/types.cpp` | +| Builtin functions, formatting and JSON | `src/stdlib.cpp`, `src/format.cpp`, `src/json.cpp` | | Command line and REPL | `src/main.cpp`, `src/repl.cpp`, `src/runner.cpp` | ## License diff --git a/docs/installation.md b/docs/installation.md index 3e47b9f..4e71a82 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -23,6 +23,7 @@ with Visual Studio). Builds default to the optimized `Release` configuration. ./build/ascend examples/fizzbuzz.asc # run a program ./build/ascend # start the interactive REPL ./build/ascend -e 'print(2 ** 10)' # run a one-liner +./build/ascend --check my_program.asc # find errors without running ``` To run `ascend` from anywhere, copy `build/ascend` into a directory on your @@ -46,3 +47,9 @@ filter them by name: ``` To skip building the tests, configure with `-DASCEND_BUILD_TESTS=OFF`. + +## Editor support + +`editors/vscode` is a Visual Studio Code extension with syntax highlighting, +bracket matching and comment toggling for `.asc` files. See its +[README](../editors/vscode/README.md) to install it. diff --git a/docs/standard_library.md b/docs/standard_library.md index 603ed6c..4991201 100644 --- a/docs/standard_library.md +++ b/docs/standard_library.md @@ -5,8 +5,8 @@ syntax, where the value before the dot becomes the first argument: `items.push(4)` is `push(items, 4)` and `"hi".upper()` is `upper("hi")`. Functions that return a list or string always return a **new** one unless -noted otherwise; only `push`, `pop`, `insert` and `remove` change their -argument. +noted otherwise; only `push`, `pop`, `insert`, `remove`, `extend` and `clear` +change their argument. ## Input and output @@ -14,89 +14,149 @@ argument. |----------|-------------| | `print(values...)` | Prints the values separated by spaces, then a newline. `print()` prints an empty line. | | `input(prompt?)` | Prints `prompt` (if given) and reads a line from standard input. Returns `null` at end of input. | +| `format(template, values...)` | Fills each `{}` in the template with the next value; `{0}` picks a value by position and `{:spec}` formats it like an f-string: `format("{:>6.2f}", x)`. | ## Types and conversion +Each type name converts a value to that type, and fails if it doesn't fit. + | Function | Description | |----------|-------------| -| `type(x)` | `"null"`, `"bool"`, `"int"`, `"float"`, `"string"`, `"list"`, `"map"` or `"function"`. | -| `str(x)` | `x` as a string, the way `print` shows it. | -| `repr(x)` | `x` as source-like text (strings are quoted and escaped). | -| `int(x)` | Converts a float (truncating), bool or numeric string to an int. | -| `float(x)` | Converts an int, bool or numeric string to a float. | +| `type(x)` | The type's name: `"int"`, `"u8"`, `"f32"`, `"char"`, `"string"`, `"list"`, `"map"`, `"func"`, `"bool"`, `"null"`, ... | +| `int(x)`, `i8(x)`, `i16(x)`, `i32(x)`, `i64(x)` | Convert to a signed integer type. Floats drop their fraction; chars give their code; strings are parsed (decimal, or `0x`/`0b`/`0o`). | +| `u8(x)`, `u16(x)`, `u32(x)`, `u64(x)`, `byte(x)` | The same for unsigned types. `u8(-1)` and `u8(256)` are errors. | +| `float(x)`, `f32(x)`, `f64(x)` | Convert a number, bool or numeric string to a float type. | +| `char(x)` | A char from a character code (`char(65)` is `'A'`) or a one-character string. | +| `str(x)`, `string(x)` | `x` as a string, the way `print` shows it. | +| `repr(x)` | `x` as source-like text (strings and chars are quoted and escaped). | | `bool(x)` | `true` if `x` is truthy. | -| `len(x)` | Length of a string, list or map. | +| `hex(n)`, `bin(n)` | An integer in hex or binary: `hex(255)` is `"0xff"`. | ## Lists | Function | Description | |----------|-------------| +| `len(list)` | Number of elements. | | `range(stop)`, `range(start, stop, step?)` | A list of ints from `start` (default 0) up to but not including `stop`. In a `for` loop no list is built, so huge ranges are fine there. | | `push(list, values...)` | Appends values to the end of the list (in place). | | `pop(list, index?)` | Removes and returns the element at `index` (default: the last one). | | `insert(list, index, value)` | Inserts `value` before position `index` (in place). | | `remove(list, index)` | Removes and returns the element at `index`. | +| `extend(list, other)` | Appends all elements of `other` (in place). | +| `clear(list)` | Removes all elements (in place). | | `contains(list, value)` | `true` if the list has an element equal to `value` (same as `value in list`). | | `index_of(list, value)` | Position of the first element equal to `value`, or `-1`. | +| `count(list, value)` | How many elements equal `value`. | | `slice(list, start, end?)` | Same as `list[start:end]`. | | `reverse(list)` | The elements in reverse order. | -| `sort(list, key?)` | The elements in ascending order. With a `key` function, sorts by `key(element)`: `sort(words, len)`. Numbers, strings and lists can be sorted. | +| `sort(list, key?)` | The elements in ascending order. With a `key` function, sorts by `key(element)`: `sort(words, len)`. Numbers, strings, chars and lists can be sorted. | +| `unique(list)` | The elements without duplicates, in their original order. | +| `flatten(list)` | Lists inside the list are spliced in: `flatten([[1, 2], 3])` is `[1, 2, 3]`. | +| `zip(a, b, ...)` | Pairs up elements: `zip([1, 2], ["a", "b"])` is `[[1, "a"], [2, "b"]]`. | | `join(list, separator?)` | The elements converted to strings and joined: `join([1, 2], ", ")` is `"1, 2"`. | | `map(list, f)` | A list of `f(element)` for each element. | | `filter(list, f)` | The elements for which `f(element)` is truthy. | | `reduce(list, f, initial?)` | Combines the elements: `reduce([1, 2, 3], func(a, b) { return a + b })` is `6`. | +| `find(list, f)` | The first element for which `f(element)` is truthy, or `null`. | +| `any(list, f?)`, `all(list, f?)` | Whether any / all elements (or `f(element)`) are truthy. | | `copy(x)` | A shallow copy of a list or map. | ## Maps | Function | Description | |----------|-------------| -| `keys(map)` | List of keys, in insertion order. | -| `values(map)` | List of values, in insertion order. | +| `len(map)` | Number of keys. | +| `keys(map)`, `values(map)` | Lists of keys or values, in insertion order. | +| `items(map)` | A list of `[key, value]` pairs. | | `get(map, key, default?)` | The value for `key`, or `default` (`null` if not given) when it's missing. Also works with a list or string and an index. | | `contains(map, key)` | `true` if the key exists (same as `key in map`). | | `remove(map, key)` | Removes the key and returns its value. | +| `merge(a, b, ...)` | A new map with the keys of all maps; later maps win. | +| `clear(map)` | Removes all keys (in place). | + +## Strings and chars -## Strings +Lengths and positions count characters, not bytes. | Function | Description | |----------|-------------| -| `split(s, separator?)` | Splits on `separator`; without one, splits on whitespace. `split(s, "")` gives the characters. | +| `len(s)` | Number of characters. | +| `split(s, separator?)` | Splits on `separator`; without one, splits on whitespace. `split(s, "")` gives the characters as strings. | +| `lines(s)` | Splits into lines (handles `\n` and `\r\n`). | +| `chars(s)` | A list of the string's chars. | +| `bytes(s)` | A list of the UTF-8 bytes as `u8` values. | +| `from_bytes(list)` | A string from a list of byte values (0-255). | | `join(list, separator?)` | See Lists. | -| `upper(s)`, `lower(s)` | Upper/lower case (ASCII). | +| `upper(s)`, `lower(s)` | Upper/lower case (ASCII letters). Also work on a char. | | `trim(s)` | Removes leading and trailing whitespace. | | `replace(s, old, new)` | Replaces every occurrence of `old`. | | `starts_with(s, prefix)`, `ends_with(s, suffix)` | Prefix/suffix tests. | | `contains(s, part)` | `true` if `part` occurs in `s` (same as `part in s`). | | `index_of(s, part)` | Position of the first occurrence of `part`, or `-1`. | +| `count(s, part)` | How many times `part` occurs. | | `slice(s, start, end?)` | Same as `s[start:end]`. | | `reverse(s)` | The string reversed. | -| `ord(c)` | The byte value of a one-character string. | -| `chr(n)` | The one-character string for byte value `n` (0-255). | +| `pad_left(x, width, fill?)`, `pad_right(x, width, fill?)` | `x` as a string, padded to `width` characters with spaces (or `fill`): `pad_left(7, 3, '0')` is `"007"`. | +| `is_digit(s)`, `is_alpha(s)`, `is_alnum(s)`, `is_space(s)`, `is_upper(s)`, `is_lower(s)` | `true` if every character of the string (or the char) is in that ASCII class. | +| `ord(c)` | The character code of a char or one-character string. | +| `chr(n)` | The char with character code `n` (same as `char(n)`). | ## Math | Name | Description | |------|-------------| -| `PI`, `E` | The constants π and e. | -| `abs(x)` | Absolute value. | +| `PI`, `E`, `INF`, `NAN` | The constants π, e, infinity and not-a-number. | +| `abs(x)` | Absolute value (keeps the type). | | `min(a, b, ...)`, `min(list)` | Smallest value. | | `max(a, b, ...)`, `max(list)` | Largest value. | -| `sum(list)` | Sum of a list of numbers. | -| `sqrt(x)` | Square root (a float). | +| `clamp(x, low, high)` | `x` limited to the range `low`..`high`. | +| `sum(list)` | Sum of a list of numbers. Small integer types are widened, so the sum of `u8` values is an `int`. | +| `sign(x)` | `-1`, `0` or `1`. | +| `gcd(a, b)` | Greatest common divisor. | +| `sqrt(x)`, `cbrt(x)` | Square and cube root. | | `pow(a, b)` | Same as `a ** b`. | +| `exp(x)`, `log(x, base?)`, `log2(x)`, `log10(x)` | Exponential and logarithms (`log` is natural without a base). | +| `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2(y, x)` | Trigonometry, in radians. | +| `hypot(x, y)` | `sqrt(x*x + y*y)`. | | `floor(x)`, `ceil(x)` | Round down / up to an int. | | `round(x)` | Round to the nearest int (halves round away from zero). | -| `round(x, digits)` | Round to `digits` decimal places (a float). | +| `round(x, digits)` | Round to `digits` decimal places (keeps the float type). | +| `is_nan(x)`, `is_inf(x)` | Tests for not-a-number and infinity. | | `random()` | A random float from 0 (inclusive) to 1 (exclusive). | | `randint(low, high)` | A random int from `low` to `high`, both inclusive. | +| `choice(list)` | A random element. | +| `shuffle(list)` | The elements in random order. | | `seed(n)` | Seeds the random number generator for repeatable results. | -## Program control +## Files and JSON + +| Function | Description | +|----------|-------------| +| `read_file(path)` | The whole file as a string. | +| `read_lines(path)` | The file as a list of lines. | +| `write_file(path, text)` | Writes `text` (any value, converted with `str`) to the file, replacing it. | +| `append_file(path, text)` | Adds `text` to the end of the file. | +| `file_exists(path)` | `true` if the file can be opened. | +| `delete_file(path)` | Deletes the file; returns `true` if it was deleted. | +| `json_parse(text)` | Converts JSON to Ascend values (objects become maps, arrays become lists). | +| `json_stringify(value, indent?)` | Converts a value to JSON; with `indent`, pretty-prints with that many spaces. | + +## Modules and program control | Function | Description | |----------|-------------| +| `import(path)` | Runs another Ascend file once and returns its top-level names as a map. Relative paths start from the importing file; `.asc` is optional. | | `assert(condition, message?)` | Throws an error if `condition` is falsy. | | `exit(code?)` | Ends the program with exit code `code` (default 0). | -| `time()` | Seconds since 1970-01-01 as a float; useful for timing code. | | `args` | List of the extra command line arguments given after the script name. | + +## Time and system + +| Function | Description | +|----------|-------------| +| `time()` | Seconds since 1970-01-01 as a float. | +| `clock()` | Seconds from a steady clock; subtract two readings to time code. | +| `sleep(seconds)` | Pauses the program. | +| `date(format?)` | The local date and time, formatted with `strftime` codes (default `"%Y-%m-%d %H:%M:%S"`). | +| `env(name)` | The value of an environment variable, or `null`. | +| `system(command)` | Runs a shell command and returns its exit code. | diff --git a/docs/syntax.md b/docs/syntax.md index 0364042..97bce0a 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -1,12 +1,12 @@ # Ascend language guide -Ascend is a small, dynamically typed scripting language. This page covers the -whole language; the builtin functions are listed in +Ascend is a small scripting language with optional static-style types. This +page covers the whole language; the builtin functions are listed in [standard_library.md](standard_library.md). ``` -func greet(name, greeting = "Hello") { - return greeting + ", " + name + "!" +func greet(name: string, greeting: string = "Hello") -> string { + return f"{greeting}, {name}!" } for name in ["Ada", "Grace"] { @@ -28,53 +28,111 @@ statements on one line: `a := 1; b := 2`. A statement can continue on the next line after an operator (`x := 1 +`) or inside `( )` and `[ ]`, so long calls and lists can span several lines. -## Values +## Variables and types -| Type | Examples | Notes | -|------------|--------------------------------|-------| -| `null` | `null` | the absence of a value | -| `bool` | `true`, `false` | | -| `int` | `42`, `-7`, `1_000_000` | 64-bit; overflow is an error, not a silent wrap | -| `float` | `3.14`, `2.5e-3`, `1.0` | 64-bit IEEE double | -| `string` | `"hi\n"`, `'it\'s'` | escapes: `\n \t \r \0 \\ \" \' \xHH` | -| `list` | `[1, "two", 3.0]` | | -| `map` | `{name: "Ada", "a key": 1}` | insertion ordered | -| `function` | `func(x) { return x * 2 }` | | +Types are optional. Write `name := value` to let the value decide, or +`name: type = value` to fix the variable's type: -`type(value)` returns the type name as a string. +``` +count := 0 // no declared type +age: u8 = 30 // always a u8 +ratio: f32 = 0.5 +initial: char = 'A' +title: string = "Ascend" +nickname: string? // the ? allows null; without a value this starts as null +total: i64 // without a value it starts at zero +``` -Lists, maps and functions are **references**: `b := a` makes `b` refer to the -same list as `a`. Use `copy(a)` for a shallow copy. +`=` changes an existing variable (`count = 5`, `count += 1`). Assigning to a +name that was never declared is an error, which catches typos. -### Truthiness +A typed variable only ever holds values of its type. Numbers convert +automatically when nothing is lost; anything else is an error: + +``` +age: u8 = 30 +age = 31 // fine +age = 300 // error: 'age' is declared as u8, so it can't hold 300 (u8 is 0 to 255) +age = "old" // error: ... can't hold a string (use u8(...) to convert a string) +x: i32 = 2.0 // fine: 2.0 is a whole number +x = 2.5 // error: ... (use i32(...) to drop the fraction) +``` -`false`, `null`, `0`, `0.0`, `""`, `[]` and `{}` are falsy. Everything else is -truthy. +Mistakes in literals like `age: u8 = 300` are reported before the program +starts running. -## Variables +### The types + +| Type | Values | +|------|--------| +| `i8` `i16` `i32` `i64` | signed integers of 8, 16, 32 and 64 bits | +| `u8` `u16` `u32` `u64` | unsigned integers; `byte` is another name for `u8` | +| `int` | the type of integer literals like `42`: a 64-bit signed integer | +| `f32` `f64` | 32- and 64-bit floating point numbers | +| `float` | the type of float literals like `1.5`: a 64-bit float | +| `bool` | `true`, `false` | +| `char` | one Unicode character: `'a'`, `'\n'`, `'é'` | +| `string` | text: `"hello"` | +| `list` | `[1, "two", 3.0]` | +| `map` | `{name: "Ada", "a key": 1}` | +| `func` | a function | +| `any` | anything (the same as writing no type) | +| `void` | only as a return type: the function returns nothing | +| `T?` | `T` or `null`, e.g. `string?` | + +`type(value)` returns the type's name: `type(5)` is `"int"`, `type(u8(5))` is +`"u8"`, `type('a')` is `"char"`. + +Without a value, a typed variable starts at its type's zero value: `0`, +`0.0`, `false`, `""`, `'\0'`, `[]` or `{}` (and `null` for `T?` types). + +### Constants ``` -count := 0 // declare a new variable -count = 5 // change an existing variable -count += 1 // also -=, *=, /=, %= +const MAX_PLAYERS = 4 +const RATE: f64 = 0.07 +MAX_PLAYERS = 5 // error: can't change 'MAX_PLAYERS': it's a constant ``` -`:=` creates a variable in the current scope; `=` changes one that already -exists (assigning to an undeclared name is an error, which catches typos). +### Converting between types + +Every type name is also a conversion function: `i32("42")`, `u8(3.9)` (drops +the fraction), `f32(1)`, `char(65)`, `string(12)`, `byte('A')`, `int("0xFF")`. +A conversion fails if the value doesn't fit: `u8(300)` and `i8(-129)` are +errors. + +### Truthiness + +`false`, `null`, `0`, `0.0`, `'\0'`, `""`, `[]` and `{}` are falsy. Everything +else is truthy. -Every `{ }` block is a scope. A `:=` inside a block creates a new variable -that disappears at the end of the block, even if a variable with the same -name exists outside: +## Numbers ``` -x := 1 -if true { - x := 2 // a new x, only inside this block - x = 3 // changes the inner x -} -print(x) // 1 +42 -7 1_000_000 // int +3.14 2.5e-3 1.0 // float +0xFF 0b1010 0o17 // hex, binary and octal +255u8 -128i8 7i64 1.5f32 // a suffix sets the type ``` +How arithmetic picks its result type: + +- A plain literal adapts to the other side: with `x: u8`, `x + 1` is a `u8`. +- Two different integer types give the smallest type that holds both: + `u8 + i32` is an `i32`, `u8 + i8` is an `i16`. (`u64` can't be mixed with + signed types; convert one side.) +- An integer and a float give the float's type: `i32 + f32` is an `f32`. +- `f32` and `f64` give `f64`. + +**Overflow is an error**, never a silent wrap: with `x: u8 = 250`, `x + 10` +reports `integer overflow: 250 + 10 doesn't fit in u8 (0 to 255)`. + +- `int / int` truncates toward zero (`7 / 2 == 3`). If either side is a float + the result is a float (`7 / 2.0 == 3.5`). +- `%` has the sign of the left side (`-7 % 3 == -1`), like C and JavaScript. +- Dividing by zero is an error. +- `f32` values are kept at 32-bit precision: `f32(1) / 3` is `0.33333334`. + ## Operators From lowest to highest precedence: @@ -87,36 +145,70 @@ From lowest to highest precedence: | `not`, `!` | logical not | | `==`, `!=` | equality (deep for lists and maps; `1 == 1.0`) | | `<`, `<=`, `>`, `>=`, `in` | comparison; `x in list`, `key in map`, `"sub" in string` | +| `\|` | bitwise or | +| `^` | bitwise xor | +| `&` | bitwise and | +| `<<`, `>>` | shifts | | `+`, `-` | add, subtract, concatenate | | `*`, `/`, `%` | multiply, divide, remainder | -| `-x` | negation | +| `-x`, `~x` | negation, bitwise not | | `**` | power (right associative, `-2 ** 2 == -4`) | | `f(x)`, `a[i]`, `a[i:j]`, `m.k` | call, index, slice, field | -`and`, `or` and `not` always produce `true` or `false`. - -`not` applies to the whole comparison after it, so `not x in items` means -`not (x in items)`. - -Arithmetic details: +Notes: -- `int / int` truncates toward zero (`7 / 2 == 3`). If either side is a float - the result is a float (`7 / 2.0 == 3.5`). -- `%` has the sign of the left side (`-7 % 3 == -1`), like C and JavaScript. -- Dividing by zero is an error. +- Bitwise operators bind tighter than comparisons (unlike C), so + `flags & 1 == 0` means `(flags & 1) == 0`. +- Shifts keep the left side's type and drop bits shifted out of it: + `0xF0u8 << 1` is `224`. `>>` is arithmetic for signed types. +- `~x` flips the bits within the type: `~0u8` is `255`, `~0` is `-1`. +- Compound assignment works for every operator: `+= -= *= /= %= &= |= ^= <<= >>=`. +- `and`, `or` and `not` always produce `true` or `false`. `not` applies to the + whole comparison after it: `not x in items` means `not (x in items)`. - `+` with a string on either side converts the other side to a string: `"total: " + 5`. - `"ab" * 3` repeats a string; `[0] * 3` repeats a list; `[1] + [2]` joins lists. -## Strings, lists and maps +## Strings and chars + +Strings use double quotes; single quotes are for a single `char`. ``` -s := "hello" -s[0] // "h" -s[-1] // "o" (negative indexes count from the end) -s[1:3] // "el" (slices; either bound may be left out: s[2:], s[:2]) -len(s) // 5 +s := "héllo" +len(s) // 5: lengths, indexes and slices count characters (Unicode code points) +s[1] // 'é' (a char) +s[-1] // 'o' (negative indexes count from the end) +s[1:3] // "él" +"ll" in s // true +``` + +Escapes: `\n \t \r \0 \\ \" \' \{ \}`, `\xHH` (a character code in hex) and +`\u{1F600}` (any Unicode character). + +Chars support a little arithmetic: `'a' + 1 == 'b'`, `'z' - 'a' == 25`, +`'-' * 3 == "---"`. A char equals the one-character string holding it +(`'a' == "a"`), and `char + string` concatenates. + +Strings are immutable. Use `bytes(s)` for the raw UTF-8 bytes. + +### f-strings + +An `f` before a string makes `{...}` insert the value of an expression: +``` +name := "Ada" +print(f"Hello, {name}! Next year you'll be {age + 1}.") +print(f"{price:.2f} {count:>5} {name:<10}| {255:x} {1234567:,} {0.5:%}") +``` + +The part after `:` is a format spec: `[[fill]align][sign][0][width][,][.precision][type]` +with align `<` `>` `^`, and type `d` `x` `X` `o` `b` `f` `e` `g` `%` `s` `c`. +`{{` and `}}` are literal braces. Put a conditional expression in parentheses: +`{(ok ? "yes" : "no")}`. The same specs work with `format("{:>5}", x)`. + +## Lists and maps + +``` items := [10, 20, 30] items[0] = 11 items.push(40) // see "Method calls" below @@ -131,12 +223,13 @@ person.city = "London" // adds a key In a map literal, a bare name before `:` is a string key (`{name: 1}` is `{"name": 1}`). Put an expression in parentheses to use its value as the key: -`{(key): 1}`. Keys may be strings, numbers, bools or `null`. +`{(key): 1}`. Keys may be strings, numbers, chars, bools or `null`. Reading a missing map key or an out-of-range index is an error. Use `get(map, key, default)` when a key may be missing. -Strings are immutable sequences of bytes. +Lists, maps and functions are **references**: `b := a` makes `b` refer to the +same list as `a`. Use `copy(a)` for a shallow copy. ## Control flow @@ -159,7 +252,7 @@ for i in range(5) { } // 0, 1, 2, 3, 4 for i in range(2, 10, 2) { } // 2, 4, 6, 8 for item in items { } for i, item in items { } // index and item -for ch in "text" { } +for c in "text" { } // chars for key in person { } // map keys for key, value in person { } ``` @@ -168,17 +261,41 @@ for key, value in person { } Each loop iteration gets fresh loop variables, so closures created in a loop capture the value from their own iteration. +### match + +`match` runs the first arm whose value equals the subject: + +``` +match command { + "start", "go" => start() + "stop" => { + save() + stop() + } + else => print("unknown command") // `_ =>` works too +} +``` + +Each arm is a single statement or a `{ }` block. If nothing matches and there +is no `else` arm, nothing happens. `break`, `continue` and `return` inside an +arm affect the surrounding loop or function. + ## Functions ``` -func area(width, height = width) { +func area(width: f64, height: f64 = width) -> f64 { return width * height } -area(3) // 9 -area(3, 4) // 12 +area(3) // 9.0 +area(3, 4) // 12.0 ``` +- Parameter and return types are optional. Arguments are converted to the + parameter's type (an `int` passed to an `f64` parameter becomes a float) or + rejected: `area("big")` is an error. +- `-> void` means the function returns nothing; `-> T?` allows returning `null`. + A function with a return type that ends without `return` is an error. - A function without `return` (or with a bare `return`) returns `null`. - Parameters with default values must come after the ones without. - Functions declared with `func name(...)` can be called anywhere in the block @@ -214,9 +331,24 @@ dog := {name: "Rex", speak: func() { return "Woof" }} dog.speak() ``` +## Modules + +`import(path)` runs another Ascend file once and returns its top-level names +as a map. Paths are relative to the importing file, and `.asc` can be left out. +Names starting with `_` stay private. + +``` +// geometry.asc +func circle_area(r: f64) -> f64 { return PI * r ** 2 } + +// main.asc +geometry := import("geometry") +print(geometry.circle_area(2)) +``` + ## Errors -Runtime errors stop the program with a message that points at the problem: +Errors stop the program with a message that points at the problem: ``` script.asc:2:9: runtime error: division by zero @@ -250,11 +382,12 @@ as a "stack overflow" error instead of crashing. ascend interactive REPL ascend file.asc [args...] run a program; extra arguments are in the list `args` ascend -e 'print(1 + 2)' run code from the command line +ascend --check file.asc report errors without running the program ``` -Exit codes: `0` success, `65` syntax error, `70` runtime error, or the value -passed to `exit()`. A first line starting with `#!` is ignored, so scripts can -start with `#!/usr/bin/env ascend`. +Exit codes: `0` success, `65` syntax or type error found before running, `70` +runtime error, or the value passed to `exit()`. A first line starting with +`#!` is ignored, so scripts can start with `#!/usr/bin/env ascend`. In the REPL, the value of an expression is printed automatically, and unfinished input (like an open `{`) continues on a `...` prompt. diff --git a/editors/vscode/README.md b/editors/vscode/README.md new file mode 100644 index 0000000..18ddf41 --- /dev/null +++ b/editors/vscode/README.md @@ -0,0 +1,14 @@ +# Ascend for Visual Studio Code + +Syntax highlighting, bracket matching, comment toggling and auto-indent for +Ascend (`.asc`) files. + +## Install + +Copy this folder into your VS Code extensions folder and restart VS Code: + +- Linux / macOS: `cp -r editors/vscode ~/.vscode/extensions/ascend-language` +- Windows (PowerShell): `Copy-Item -Recurse editors\vscode "$env:USERPROFILE\.vscode\extensions\ascend-language"` + +To build a `.vsix` package you can share instead, run `npx @vscode/vsce package` +in this folder and install the result with `code --install-extension`. diff --git a/editors/vscode/language-configuration.json b/editors/vscode/language-configuration.json new file mode 100644 index 0000000..51fa5b2 --- /dev/null +++ b/editors/vscode/language-configuration.json @@ -0,0 +1,30 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"", "notIn": ["string", "comment"] }, + { "open": "'", "close": "'", "notIn": ["string", "comment"] }, + { "open": "/*", "close": " */", "notIn": ["string"] } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"] + ], + "indentationRules": { + "increaseIndentPattern": "^.*\\{[^}\"']*$", + "decreaseIndentPattern": "^\\s*\\}" + } +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json new file mode 100644 index 0000000..e561924 --- /dev/null +++ b/editors/vscode/package.json @@ -0,0 +1,31 @@ +{ + "name": "ascend-language", + "displayName": "Ascend", + "description": "Syntax highlighting for the Ascend programming language", + "version": "0.3.0", + "publisher": "ascend", + "license": "Apache-2.0", + "engines": { + "vscode": "^1.60.0" + }, + "categories": [ + "Programming Languages" + ], + "contributes": { + "languages": [ + { + "id": "ascend", + "aliases": ["Ascend", "ascend"], + "extensions": [".asc"], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "ascend", + "scopeName": "source.ascend", + "path": "./syntaxes/ascend.tmLanguage.json" + } + ] + } +} diff --git a/editors/vscode/syntaxes/ascend.tmLanguage.json b/editors/vscode/syntaxes/ascend.tmLanguage.json new file mode 100644 index 0000000..64e90f2 --- /dev/null +++ b/editors/vscode/syntaxes/ascend.tmLanguage.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Ascend", + "scopeName": "source.ascend", + "fileTypes": ["asc"], + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#chars" }, + { "include": "#numbers" }, + { "include": "#function-declaration" }, + { "include": "#keywords" }, + { "include": "#types" }, + { "include": "#constants" }, + { "include": "#function-call" }, + { "include": "#operators" } + ], + "repository": { + "comments": { + "patterns": [ + { "name": "comment.line.double-slash.ascend", "match": "//.*$" }, + { "name": "comment.block.ascend", "begin": "/\\*", "end": "\\*/" } + ] + }, + "escapes": { + "name": "constant.character.escape.ascend", + "match": "\\\\(u\\{[0-9a-fA-F]+\\}|x[0-9a-fA-F]{2}|[ntr0\\\\\"'{}])" + }, + "strings": { + "patterns": [ + { + "name": "string.interpolated.ascend", + "begin": "\\bf\"", + "end": "\"", + "patterns": [ + { "include": "#escapes" }, + { "name": "constant.character.escape.ascend", "match": "\\{\\{|\\}\\}" }, + { + "name": "meta.interpolation.ascend", + "begin": "\\{", + "end": "\\}", + "beginCaptures": { "0": { "name": "punctuation.section.interpolation.begin.ascend" } }, + "endCaptures": { "0": { "name": "punctuation.section.interpolation.end.ascend" } }, + "patterns": [{ "include": "$self" }] + } + ] + }, + { + "name": "string.quoted.double.ascend", + "begin": "\"", + "end": "\"", + "patterns": [{ "include": "#escapes" }] + } + ] + }, + "chars": { + "name": "string.quoted.single.ascend", + "match": "'(?:\\\\(?:u\\{[0-9a-fA-F]+\\}|x[0-9a-fA-F]{2}|.)|[^'\\\\])'" + }, + "numbers": { + "patterns": [ + { "name": "constant.numeric.hex.ascend", "match": "\\b0[xX][0-9a-fA-F_]+(?:[iu](?:8|16|32|64))?\\b" }, + { "name": "constant.numeric.binary.ascend", "match": "\\b0[bB][01_]+(?:[iu](?:8|16|32|64))?\\b" }, + { "name": "constant.numeric.octal.ascend", "match": "\\b0[oO][0-7_]+(?:[iu](?:8|16|32|64))?\\b" }, + { + "name": "constant.numeric.decimal.ascend", + "match": "\\b[0-9][0-9_]*(?:\\.[0-9][0-9_]*)?(?:[eE][+-]?[0-9]+)?(?:_?(?:[iu](?:8|16|32|64)|f32|f64))?\\b" + } + ] + }, + "function-declaration": { + "match": "\\b(func)\\s+([A-Za-z_][A-Za-z0-9_]*)", + "captures": { + "1": { "name": "storage.type.function.ascend" }, + "2": { "name": "entity.name.function.ascend" } + } + }, + "keywords": { + "patterns": [ + { + "name": "keyword.control.ascend", + "match": "\\b(if|else|while|for|in|break|continue|return|match|try|catch|throw)\\b" + }, + { "name": "storage.type.function.ascend", "match": "\\bfunc\\b" }, + { "name": "storage.modifier.ascend", "match": "\\bconst\\b" }, + { "name": "keyword.operator.logical.ascend", "match": "\\b(and|or|not)\\b" } + ] + }, + "types": { + "name": "storage.type.ascend", + "match": "\\b(i8|i16|i32|i64|u8|u16|u32|u64|int|byte|f32|f64|float|bool|char|string|list|map|any|void)\\b\\??" + }, + "constants": { + "patterns": [ + { "name": "constant.language.ascend", "match": "\\b(true|false|null)\\b" }, + { "name": "support.constant.ascend", "match": "\\b(PI|E|INF|NAN|args)\\b" } + ] + }, + "function-call": { + "match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()", + "captures": { "1": { "name": "entity.name.function.call.ascend" } } + }, + "operators": { + "patterns": [ + { "name": "keyword.operator.assignment.ascend", "match": ":=|<<=|>>=|[-+*/%&|^]=" }, + { "name": "keyword.operator.arrow.ascend", "match": "->|=>" }, + { "name": "keyword.operator.bitwise.ascend", "match": "<<|>>" }, + { "name": "keyword.operator.comparison.ascend", "match": "==|!=|<=|>=|<|>" }, + { "name": "keyword.operator.logical.ascend", "match": "&&|\\|\\||!" }, + { "name": "keyword.operator.bitwise.ascend", "match": "[&|^~]" }, + { "name": "keyword.operator.arithmetic.ascend", "match": "\\*\\*|[-+*/%]" }, + { "name": "keyword.operator.ascend", "match": "[=?:]" } + ] + } + } +} diff --git a/examples/bits.asc b/examples/bits.asc new file mode 100644 index 0000000..403fbde --- /dev/null +++ b/examples/bits.asc @@ -0,0 +1,38 @@ +// Bitwise operators: & | ^ ~ << >> +// Unlike C, they bind tighter than comparisons, so `x & 1 == 0` works as expected. + +READ: u8 = 0b001 +WRITE: u8 = 0b010 +EXECUTE: u8 = 0b100 + +permissions: u8 = READ | WRITE +print("can write?", permissions & WRITE != 0) +print("can execute?", permissions & EXECUTE != 0) + +permissions |= EXECUTE // grant +permissions &= ~WRITE // revoke +print(f"permissions = {permissions:03b}") + +// Packing a color into 24 bits and unpacking it again. +func pack(r: u8, g: u8, b: u8) -> u32 { + return u32(r) << 16 | u32(g) << 8 | u32(b) +} + +color := pack(255, 128, 0) +print(f"color = {hex(color)}, red = {color >> 16 & 0xFF}, green = {color >> 8 & 0xFF}, blue = {color & 0xFF}") + +// Count the 1 bits in a number. +func popcount(n: u64) -> i32 { + count := 0 + while n != 0 { + count += n & 1 + n >>= 1 + } + return count +} +print("bits set in 0xFF00FF:", popcount(0xFF00FF)) + +// The bytes of a string (UTF-8). +for b in bytes("Hi!") { + print(f"{b:3} = {b:08b} = {char(b)}") +} diff --git a/examples/geometry.asc b/examples/geometry.asc new file mode 100644 index 0000000..85f1e61 --- /dev/null +++ b/examples/geometry.asc @@ -0,0 +1,20 @@ +// A module: other files load it with import("geometry"). +// Every top-level name is exported, except names starting with an underscore. + +const PI_2 = PI * 2 + +func circle_area(radius: f64) -> f64 { + return PI * radius ** 2 +} + +func circle_circumference(radius: f64) -> f64 { + return PI_2 * radius +} + +func distance(a: map, b: map) -> f64 { + return hypot(b.x - a.x, b.y - a.y) +} + +func _check(value: f64) { + assert(value >= 0, "expected a non-negative value") +} diff --git a/examples/json_and_files.asc b/examples/json_and_files.asc new file mode 100644 index 0000000..af1940c --- /dev/null +++ b/examples/json_and_files.asc @@ -0,0 +1,25 @@ +// Save some data as JSON, read it back and use it. +path := "ascend_example_scores.json" + +scores := { + players: [ + {name: "Ada", score: 120}, + {name: "Grace", score: 95}, + {name: "Linus", score: 110}, + ], + saved: date("%Y-%m-%d"), +} +write_file(path, json_stringify(scores, 2)) +print("wrote", len(read_lines(path)), "lines to", path) + +loaded := json_parse(read_file(path)) +ranking := sort(loaded.players, func(p) { return -p.score }) +for place, player in ranking { + print(f"{place + 1}. {player.name:<6} {player.score:>4}") +} + +best := ranking[0] +print(f"best: {best.name}, total: {sum(map(loaded.players, func(p) { return p.score }))}") + +delete_file(path) +print("cleaned up:", not file_exists(path)) diff --git a/examples/modules.asc b/examples/modules.asc new file mode 100644 index 0000000..2f22ccc --- /dev/null +++ b/examples/modules.asc @@ -0,0 +1,8 @@ +// import() runs another file once and returns its top-level names as a map. +// Paths are relative to this file; ".asc" can be left out. +geometry := import("geometry") + +print(f"area of a circle with radius 2: {geometry.circle_area(2):.3f}") +print(f"circumference: {geometry.circle_circumference(2):.3f}") +print("distance:", geometry.distance({x: 0, y: 0}, {x: 3, y: 4})) +print("exports:", keys(geometry)) diff --git a/examples/text.asc b/examples/text.asc new file mode 100644 index 0000000..4a0b435 --- /dev/null +++ b/examples/text.asc @@ -0,0 +1,44 @@ +// f-strings put expressions inside {}; a format spec can follow a colon. +item := "coffee" +price := 3.5 +quantity := 3 +print(f"{quantity} x {item} = ${price * quantity:.2f}") + +// Tables with alignment: < left, > right, ^ center +rows := [["apples", 3, 0.5], ["bananas", 12, 0.25], ["cherries", 100, 0.1]] +print(f"{"fruit":<10}|{"count":>6}|{"price":>7}") +print("-" * 25) +for row in rows { + print(f"{row[0]:<10}|{row[1]:>6}|{row[2]:>7.2f}") +} + +print(f"{1234567:,} | {0.875:.1%} | {255:x} | {255:08b} | {42:+}") +print(format("{} has {} letters", "Ascend", len("Ascend"))) + +// Chars are single characters, written with single quotes. +letter := 'a' +print(letter + 1, 'z' - 'a', upper(letter), '=' * 10) + +// Strings are Unicode: indexing, slicing and len() count characters. +word := "naïve café" +print(len(word), word[2], word[6:], reverse(word)) +for i, c in "héllo" { + if not is_alpha(c) { + print(f"position {i} has a non-ASCII letter: {c}") + } +} + +// A simple Caesar cipher using char arithmetic. +func shift(text: string, by: i32) -> string { + result := "" + for c in text { + if c >= 'a' and c <= 'z' { + result += char('a' + (c - 'a' + by) % 26) + } else { + result += c + } + } + return result +} +secret := shift("hello world", 3) +print(secret, "->", shift(secret, 23)) diff --git a/examples/types.asc b/examples/types.asc new file mode 100644 index 0000000..3f613f0 --- /dev/null +++ b/examples/types.asc @@ -0,0 +1,61 @@ +// Typed variables: name: type = value +age: u8 = 30 +temperature: i16 = -12 +population: u64 = 8_100_000_000 +ratio: f32 = 0.75 +precise: f64 = 3.141592653589793 +grade: char = 'A' +name: string = "Ada" +active: bool = true +nickname: string? // the ? allows null; this starts as null +count: i32 // without a value it starts at zero + +print(name, age, temperature, population, ratio, precise, grade, active, nickname, count) +print(type(age), type(ratio), type(grade), type(42), type(4.2)) + +// A typed variable only ever holds values of its type. +age += 1 +print(f"{name} is now {age} ({type(age)})") +try { + age = 300 +} catch e { + print("error:", e) +} + +// Arithmetic stays in the type, and overflow is an error instead of a silent wrap. +small: u8 = 250 +try { + small += 10 +} catch e { + print("error:", e) +} + +// Mixing types widens to a type that holds both. +a: u8 = 200 +b: i32 = -1000 +print(type(a + b), a + b) + +// Explicit conversions use the type name. +print(u8(65), char(66), i32("42") + 1, f32(1) / 3, int(9.99), byte('A')) + +// Constants can't be changed. +const MAX_PLAYERS: u8 = 4 +print("max players:", MAX_PLAYERS) + +// Typed parameters and return values. +func average(values: list) -> f64 { + if len(values) == 0 { + return 0 + } + return sum(values) / f64(len(values)) +} +print("average:", average([3, 4, 8])) + +func describe(n: i64) -> string { + match n { + 0 => return "zero" + 1, 2, 3 => return "a few" + else => return n < 0 ? "negative" : "many" + } +} +print(describe(0), describe(2), describe(-5), describe(100)) diff --git a/src/format.cpp b/src/format.cpp new file mode 100644 index 0000000..c251c3c --- /dev/null +++ b/src/format.cpp @@ -0,0 +1,258 @@ +// Format specs, used by f-strings (f"{x:>8.2f}") and format(). +// +// [[fill]align][sign][0][width][,][.precision][type] +// +// align < left, > right, ^ center (numbers default to right, text to left) +// sign + always show a sign +// 0 pad numbers with zeros after the sign +// , group thousands: 1,234,567 +// precision digits after the point for numbers; maximum length for text +// type d integer x X o b hex, octal, binary +// f fixed point e exponent g general % percent +// s text c character (from a char code) + +#include "numeric.hpp" +#include "stdlib.hpp" +#include "utf8.hpp" + +#include +#include +#include +#include + +namespace ascend { + +namespace { + +struct Spec { + char fill = ' '; + char align = 0; + char sign = '-'; + bool zeroPad = false; + int width = 0; + bool grouping = false; + int precision = -1; + char type = 0; +}; + +[[noreturn]] void badSpec(const std::string& spec, const std::string& why) { + throw RuntimeError("invalid format spec \"" + spec + "\": " + why); +} + +Spec parseSpec(const std::string& text) { + Spec spec; + size_t i = 0; + const size_t n = text.size(); + auto isAlign = [](char c) { return c == '<' || c == '>' || c == '^'; }; + if (n >= 2 && isAlign(text[1])) { + spec.fill = text[0]; + spec.align = text[1]; + i = 2; + } else if (n >= 1 && isAlign(text[0])) { + spec.align = text[0]; + i = 1; + } + if (i < n && (text[i] == '+' || text[i] == '-' || text[i] == ' ')) spec.sign = text[i++]; + if (i < n && text[i] == '0') { + spec.zeroPad = true; + ++i; + } + while (i < n && std::isdigit(static_cast(text[i]))) { + spec.width = spec.width * 10 + (text[i++] - '0'); + if (spec.width > 10000) badSpec(text, "width is too large"); + } + if (i < n && text[i] == ',') { + spec.grouping = true; + ++i; + } + if (i < n && text[i] == '.') { + ++i; + if (i >= n || !std::isdigit(static_cast(text[i]))) badSpec(text, "expected digits after '.'"); + spec.precision = 0; + while (i < n && std::isdigit(static_cast(text[i]))) { + spec.precision = spec.precision * 10 + (text[i++] - '0'); + if (spec.precision > 100) badSpec(text, "precision is too large"); + } + } + if (i < n) { + spec.type = text[i++]; + if (std::string("sdxXobfFeEgG%c").find(spec.type) == std::string::npos) { + badSpec(text, std::string("unknown type '") + spec.type + "'"); + } + } + if (i != n) badSpec(text, "unexpected '" + text.substr(i) + "'"); + return spec; +} + +std::string toBase(uint64_t value, int base, bool upper) { + if (value == 0) return "0"; + const char* digits = upper ? "0123456789ABCDEF" : "0123456789abcdef"; + std::string out; + while (value > 0) { + out += digits[value % static_cast(base)]; + value /= static_cast(base); + } + return std::string(out.rbegin(), out.rend()); +} + +// Inserts commas into the integer part of a digit string. +std::string group(const std::string& digits) { + size_t end = digits.find_first_of(".eE"); + if (end == std::string::npos) end = digits.size(); + std::string integer = digits.substr(0, end); + std::string grouped; + for (size_t i = 0; i < integer.size(); ++i) { + if (i > 0 && (integer.size() - i) % 3 == 0) grouped += ','; + grouped += integer[i]; + } + return grouped + digits.substr(end); +} + +std::string pad(const std::string& text, const Spec& spec, char defaultAlign) { + size_t length = utf8Length(text); + if (spec.width <= 0 || length >= static_cast(spec.width)) return text; + size_t padding = static_cast(spec.width) - length; + char align = spec.align != 0 ? spec.align : defaultAlign; + if (align == '<') return text + std::string(padding, spec.fill); + if (align == '>') return std::string(padding, spec.fill) + text; + size_t left = padding / 2; + return std::string(left, spec.fill) + text + std::string(padding - left, spec.fill); +} + +std::string formatNumber(const Value& value, const Spec& spec, const std::string& specText) { + bool negative = false; + std::string digits; + char type = spec.type; + if (type == 0 && spec.precision >= 0) type = 'f'; // {x:.2} means two decimals + + if (type == 'd' || type == 'x' || type == 'X' || type == 'o' || type == 'b') { + if (!value.isInt()) badSpec(specText, std::string("'") + type + "' needs an integer, got " + aTypeName(value)); + negative = value.isNegativeInt(); + uint64_t magnitude = negative ? uint64_t(0) - value.asUInt() : value.asUInt(); + int base = type == 'd' ? 10 : (type == 'o' ? 8 : (type == 'b' ? 2 : 16)); + digits = toBase(magnitude, base, type == 'X'); + } else if (type == 0) { + if (value.isInt()) { + negative = value.isNegativeInt(); + digits = toBase(negative ? uint64_t(0) - value.asUInt() : value.asUInt(), 10, false); + } else { + double d = value.asFloat(); + negative = std::signbit(d) && !std::isnan(d); + digits = formatFloat(std::fabs(d), value.numKind() == NumKind::F32); + } + } else { + double d = value.toDouble(); + negative = std::signbit(d) && !std::isnan(d); + double magnitude = std::fabs(d); + int precision = spec.precision >= 0 ? spec.precision : 6; + char buffer[512]; + if (std::isnan(magnitude) || std::isinf(magnitude)) { + digits = std::isnan(magnitude) ? "nan" : "inf"; + } else if (type == '%') { + std::snprintf(buffer, sizeof buffer, "%.*f", precision, magnitude * 100); + digits = std::string(buffer) + "%"; + } else { + char format[8] = {'%', '.', '*', type, '\0'}; + if (type == 'F') format[3] = 'f'; + if (magnitude >= 1e100 && (type == 'f' || type == 'F')) format[3] = 'e'; // keep the buffer bounded + std::snprintf(buffer, sizeof buffer, format, precision, magnitude); + digits = buffer; + } + } + + if (spec.grouping) digits = group(digits); + std::string sign = negative ? "-" : (spec.sign == '+' ? "+" : (spec.sign == ' ' ? " " : "")); + + if (spec.zeroPad && spec.align == 0 && spec.width > 0) { + size_t length = sign.size() + utf8Length(digits); + if (length < static_cast(spec.width)) digits = std::string(static_cast(spec.width) - length, '0') + digits; + return sign + digits; + } + return pad(sign + digits, spec, '>'); +} + +} // namespace + +std::string formatValue(const Value& value, const std::string& specText) { + Spec spec = parseSpec(specText); + + if (spec.type == 'c') { + char32_t code = 0; + if (value.isChar()) { + code = value.asChar(); + } else { + int64_t number = 0; + if (!toInt64(value, number) || number < 0 || number > 0x10FFFF || + !isValidCodePoint(static_cast(number))) { + badSpec(specText, "'c' needs a char or a valid character code"); + } + code = static_cast(number); + } + return pad(encodeUtf8(code), spec, '<'); + } + + if (value.isNumber() && spec.type != 's') return formatNumber(value, spec, specText); + if (spec.type != 0 && spec.type != 's') { + bool integerType = std::string("dxXob").find(spec.type) != std::string::npos; + badSpec(specText, std::string("'") + spec.type + "' needs " + (integerType ? "an integer" : "a number") + ", got " + + aTypeName(value)); + } + + std::string text = toString(value); + if (spec.precision >= 0 && utf8Length(text) > static_cast(spec.precision)) { + text = text.substr(0, utf8Offset(text, static_cast(spec.precision))); + } + return pad(text, spec, '<'); +} + +std::string formatTemplate(const std::string& tpl, const std::vector& args, size_t first) { + std::string out; + size_t next = first; + for (size_t i = 0; i < tpl.size(); ++i) { + char c = tpl[i]; + if (c == '}') { + if (i + 1 < tpl.size() && tpl[i + 1] == '}') { + out += '}'; + ++i; + continue; + } + throw RuntimeError("format(): single '}' in the template (write '}}' for a literal brace)"); + } + if (c != '{') { + out += c; + continue; + } + if (i + 1 < tpl.size() && tpl[i + 1] == '{') { + out += '{'; + ++i; + continue; + } + size_t close = tpl.find('}', i); + if (close == std::string::npos) throw RuntimeError("format(): '{' without a matching '}'"); + std::string field = tpl.substr(i + 1, close - i - 1); + size_t colon = field.find(':'); + std::string position = field.substr(0, colon); + std::string spec = colon == std::string::npos ? "" : field.substr(colon + 1); + + size_t index; + if (position.empty()) { + index = next++; + } else { + for (char digit : position) { + if (!std::isdigit(static_cast(digit))) { + throw RuntimeError("format(): invalid field {" + field + "} (use {}, {0} or {:spec})"); + } + } + index = first + static_cast(std::stoul(position)); + } + if (index >= args.size()) { + throw RuntimeError("format(): the template needs more arguments than the " + + std::to_string(args.size() - first) + " given"); + } + out += spec.empty() ? toString(args[index]) : formatValue(args[index], spec); + i = close; + } + return out; +} + +} // namespace ascend diff --git a/src/include/ast.hpp b/src/include/ast.hpp index 751ff67..1a62788 100644 --- a/src/include/ast.hpp +++ b/src/include/ast.hpp @@ -2,6 +2,7 @@ #define ASCEND_AST_HPP #include "lexer.hpp" +#include "types.hpp" #include "value.hpp" #include @@ -21,7 +22,7 @@ using StmtPtr = std::unique_ptr; enum class ExprKind { Literal, Variable, Unary, Binary, Logical, Ternary, - Call, Index, Slice, Member, List, Map, Function + Call, Index, Slice, Member, List, Map, Function, FString }; struct Expr { @@ -48,7 +49,7 @@ struct VariableExpr final : Expr { struct UnaryExpr final : Expr { UnaryExpr(int line, int column) : Expr(ExprKind::Unary, line, column) {} - TokenType op = TokenType::Minus; // Minus or Not + TokenType op = TokenType::Minus; // Minus, Not or Tilde ExprPtr operand; }; @@ -111,12 +112,24 @@ struct MapExpr final : Expr { std::vector> entries; }; +// f"text {expression:spec} more text" +struct FStringPart { + std::string text; // literal text (when expr is null) + ExprPtr expr; // an interpolated expression + std::string spec; // its format spec, e.g. ">8" or ".2f" +}; + +struct FStringExpr final : Expr { + FStringExpr(int line, int column) : Expr(ExprKind::FString, line, column) {} + std::vector parts; +}; + // --------------------------------------------------------------------------- // Statements enum class StmtKind { Expression, Declare, Assign, Block, If, While, For, - Break, Continue, Return, Function, Try, Throw + Break, Continue, Return, Function, Try, Throw, Match }; struct Stmt { @@ -140,6 +153,7 @@ struct Block { struct Param { std::string name; + TypeSpec type; // `any` when the parameter has no type ExprPtr defaultValue; // may be null }; @@ -148,6 +162,7 @@ struct FunctionExpr final : Expr { std::string name; // empty for anonymous functions std::vector params; size_t requiredParams = 0; // parameters without a default value + TypeSpec returnType; // `any` when the function has no return type Block body; }; @@ -156,11 +171,14 @@ struct ExpressionStmt final : Stmt { ExprPtr expr; }; -// name := value +// name := value name: type = value name: type const name = value struct DeclareStmt final : Stmt { DeclareStmt(int line, int column) : Stmt(StmtKind::Declare, line, column) {} std::string name; - ExprPtr value; + TypeSpec type; // `any` when no type was written + bool hasType = false; + bool constant = false; + ExprPtr value; // null for `name: type`, which starts at the type's zero value }; // target = value, target += value, ... @@ -228,6 +246,21 @@ struct ThrowStmt final : Stmt { ExprPtr value; }; +// match value { +// 1, 2 => print("small") +// else => { print("other") } +// } +struct MatchArm { + std::vector patterns; // empty for the default arm (`else` or `_`) + Block body; // a { block } or a single statement +}; + +struct MatchStmt final : Stmt { + MatchStmt(int line, int column) : Stmt(StmtKind::Match, line, column) {} + ExprPtr subject; + std::vector arms; +}; + struct Program { Block body; }; diff --git a/src/include/environment.hpp b/src/include/environment.hpp index 96eb320..71b6e1e 100644 --- a/src/include/environment.hpp +++ b/src/include/environment.hpp @@ -1,6 +1,7 @@ #ifndef ASCEND_ENVIRONMENT_HPP #define ASCEND_ENVIRONMENT_HPP +#include "types.hpp" #include "value.hpp" #include @@ -10,6 +11,14 @@ namespace ascend { +// A variable: its current value, the type it was declared with (`any` when +// it has none) and whether it is a constant. +struct Variable { + Value value; + TypeSpec type; + bool constant = false; +}; + // One scope of variables. Scopes form a chain through their parent: lookups // walk outward until the name is found. class Environment final : public GcObject { @@ -17,11 +26,16 @@ class Environment final : public GcObject { explicit Environment(std::shared_ptr parent = nullptr) : parent_(std::move(parent)) {} // Creates (or replaces) a variable in this scope. - void define(const std::string& name, Value value) { values_[name] = std::move(value); } + void define(const std::string& name, Value value, const TypeSpec& type = TypeSpec(), bool constant = false) { + Variable& variable = values_[name]; + variable.value = std::move(value); + variable.type = type; + variable.constant = constant; + } // Finds a variable in this scope or any enclosing one. The pointer is only // valid until the next variable is defined. - Value* find(const std::string& name) { + Variable* find(const std::string& name) { for (Environment* env = this; env != nullptr; env = env->parent_.get()) { auto it = env->values_.find(name); if (it != env->values_.end()) return &it->second; @@ -29,6 +43,13 @@ class Environment final : public GcObject { return nullptr; } + Variable* findLocal(const std::string& name) { + auto it = values_.find(name); + return it == values_.end() ? nullptr : &it->second; + } + + const std::unordered_map& locals() const { return values_; } + // All names visible from this scope (used for "did you mean ...?" hints). void collectNames(std::vector& out) const { for (const Environment* env = this; env != nullptr; env = env->parent_.get()) { @@ -39,7 +60,7 @@ class Environment final : public GcObject { void forEachChild(const std::function& visit) const override { if (parent_) visit(parent_.get()); for (const auto& entry : values_) { - if (GcObject* child = entry.second.gcObject()) visit(child); + if (GcObject* child = entry.second.value.gcObject()) visit(child); } } @@ -49,7 +70,7 @@ class Environment final : public GcObject { } private: - std::unordered_map values_; + std::unordered_map values_; std::shared_ptr parent_; }; diff --git a/src/include/error.hpp b/src/include/error.hpp index e648b27..6fec0b5 100644 --- a/src/include/error.hpp +++ b/src/include/error.hpp @@ -6,6 +6,12 @@ namespace ascend { +// A program's source code, kept for error messages. +struct SourceFile { + std::string name; // shown in error messages, e.g. "script.asc" or "" + std::string text; +}; + // A problem found before the program runs (while lexing, parsing or analyzing). struct Diagnostic { int line = 0; @@ -26,6 +32,7 @@ class RuntimeError : public std::runtime_error { int line; int column; + const SourceFile* source = nullptr; // the file the location refers to }; // Formats an error the way the command line tools print it: diff --git a/src/include/interpreter.hpp b/src/include/interpreter.hpp index 217e641..3c83520 100644 --- a/src/include/interpreter.hpp +++ b/src/include/interpreter.hpp @@ -3,6 +3,7 @@ #include "ast.hpp" #include "environment.hpp" +#include "error.hpp" #include "value.hpp" #include @@ -11,6 +12,8 @@ #include #include #include +#include +#include #include namespace ascend { @@ -20,6 +23,7 @@ struct ThrownValue { Value value; int line; int column; + const SourceFile* source; }; // Raised by exit(). Unwinds the whole program; `try` does not catch it. @@ -36,13 +40,17 @@ class Interpreter { Interpreter& operator=(const Interpreter&) = delete; // Runs a parsed (and analyzed) program in the global scope. The - // interpreter keeps the program alive, since functions point into it. - // Throws RuntimeError, ThrownValue or ExitRequest. - void execute(std::unique_ptr program); + // interpreter keeps the program and its source alive, since functions + // point into them. Throws RuntimeError, ThrownValue or ExitRequest. + void execute(std::unique_ptr program, std::unique_ptr source = nullptr); // Calls an Ascend or builtin function. `line`/`column` locate errors. Value call(const Value& callee, std::vector args, int line = 0, int column = 0); + // Runs another Ascend file (once) and returns its top-level names as a + // map. Relative paths are relative to the file that is running. + Value importModule(const std::string& path); + // Operations shared with the standard library. Value binaryOp(TokenType op, const Value& left, const Value& right); Value indexGet(const Value& object, const Value& index); @@ -54,7 +62,10 @@ class Interpreter { std::shared_ptr newList(std::vector items = {}); std::shared_ptr newMap(); + // Builtins live in a scope above the program's globals, so a program + // (or module) can reuse their names for its own variables. void defineNative(const std::string& name, int minArgs, int maxArgs, NativeFn fn); + void defineBuiltin(const std::string& name, Value value); void defineGlobal(const std::string& name, Value value); const std::shared_ptr& globals() const { return globals_; } @@ -77,14 +88,19 @@ class Interpreter { private: enum class Flow { Normal, Break, Continue, Return }; using EnvPtr = std::shared_ptr; + class FrameGuard; + void runTopLevel(const Program& program, const EnvPtr& env, bool echo); Flow exec(const Stmt& stmt, const EnvPtr& env); Flow execStmt(const Stmt& stmt, const EnvPtr& env); Flow execBlock(const Block& block, const EnvPtr& env); Flow execScoped(const Block& block, const EnvPtr& env); + Flow execDeclare(const DeclareStmt& stmt, const EnvPtr& env); Flow execAssign(const AssignStmt& stmt, const EnvPtr& env); Flow execFor(const ForStmt& stmt, const EnvPtr& env); Flow execTry(const TryStmt& stmt, const EnvPtr& env); + Flow execMatch(const MatchStmt& stmt, const EnvPtr& env); + Flow execReturn(const ReturnStmt& stmt, const EnvPtr& env); void hoistFunctions(const Block& block, const EnvPtr& env); Value eval(const Expr& expr, const EnvPtr& env); @@ -93,9 +109,15 @@ class Interpreter { Value evalVariable(const VariableExpr& expr, const EnvPtr& env); Value evalUnary(const UnaryExpr& expr, const EnvPtr& env); Value evalMember(const MemberExpr& expr, const EnvPtr& env); + Value evalFString(const FStringExpr& expr, const EnvPtr& env); Value makeFunction(const FunctionExpr& expr, const EnvPtr& env, const std::string& name); void indexSet(const Value& object, const Value& index, Value value); + + Value zeroValue(const TypeSpec& type); + Value coerceVariable(const std::string& name, const TypeSpec& type, Value value); + void locate(RuntimeError& error, int line, int column) const; void checkStack() const; + std::string importDirectory() const; template std::shared_ptr track(std::shared_ptr object); @@ -103,10 +125,16 @@ class Interpreter { std::ostream& out_; std::istream& in_; std::mt19937_64 random_; + EnvPtr builtins_; EnvPtr globals_; std::vector> programs_; + std::vector> sources_; + std::unordered_map modules_; + std::unordered_set loadingModules_; Value returnValue_; const Callable* rangeFunction_ = nullptr; + const SourceFile* currentSource_ = nullptr; + const FunctionExpr* currentFunction_ = nullptr; bool replMode_ = false; int callDepth_ = 0; int maxCallDepth_; diff --git a/src/include/lexer.hpp b/src/include/lexer.hpp index 2412f6f..ac97202 100644 --- a/src/include/lexer.hpp +++ b/src/include/lexer.hpp @@ -2,6 +2,7 @@ #define ASCEND_LEXER_HPP #include "error.hpp" +#include "types.hpp" #include #include @@ -13,21 +14,28 @@ namespace ascend { enum class TokenType { // Punctuation LeftParen, RightParen, LeftBracket, RightBracket, LeftBrace, RightBrace, - Comma, Dot, Colon, Semicolon, Question, + Comma, Dot, Colon, Semicolon, Question, Arrow /* -> */, FatArrow /* => */, // Operators Plus, Minus, Star, StarStar, Slash, Percent, + Ampersand, Pipe, Caret, Tilde, ShiftLeft, ShiftRight, Equal, EqualEqual, NotEqual, Less, LessEqual, Greater, GreaterEqual, Declare, // := PlusEqual, MinusEqual, StarEqual, SlashEqual, PercentEqual, + AmpersandEqual, PipeEqual, CaretEqual, ShiftLeftEqual, ShiftRightEqual, // Literals - Identifier, Int, Float, String, + Identifier, Int, Float, String, Char, + + // f"..." strings are lexed as FStringStart, then String tokens for the + // literal text and InterpStart [FormatSpec] InterpEnd + // for each {...}, then FStringEnd. + FStringStart, FStringEnd, InterpStart, InterpEnd, FormatSpec, // Keywords. `&&`, `||` and `!` are lexed as And, Or and Not. And, Or, Not, If, Else, While, For, In, Break, Continue, Return, Func, - True, False, Null, Try, Catch, Throw, + True, False, Null, Try, Catch, Throw, Const, Match, // Structure Newline, Eof @@ -35,10 +43,13 @@ enum class TokenType { struct Token { TokenType type = TokenType::Eof; - std::string lexeme; // the exact source text of the token - std::string text; // decoded contents of a string literal - int64_t intValue = 0; // value of an Int literal - double floatValue = 0; // value of a Float literal + std::string lexeme; // the exact source text of the token + std::string text; // decoded String text, or a FormatSpec + uint64_t intValue = 0; // magnitude of an Int literal (range-checked by the parser) + double floatValue = 0; // value of a Float literal + char32_t charValue = 0; // value of a Char literal + NumKind numKind = NumKind::Int; // type of a number literal (from a suffix like 10u8) + bool hasSuffix = false; int line = 1; int column = 1; }; @@ -73,10 +84,15 @@ class Lexer { void blockComment(); void scanToken(); void identifier(); - void number(); + void number(char first); void string(char quote); + void character(); + void fString(); + bool escape(char32_t& out); + char32_t sourceChar(); void add(TokenType type); + void addAt(TokenType type, int line, int column, std::string lexeme); void emitNewline(); void closeBracket(); void error(int line, int column, std::string message); diff --git a/src/include/numeric.hpp b/src/include/numeric.hpp new file mode 100644 index 0000000..3c54183 --- /dev/null +++ b/src/include/numeric.hpp @@ -0,0 +1,49 @@ +#ifndef ASCEND_NUMERIC_HPP +#define ASCEND_NUMERIC_HPP + +#include "types.hpp" +#include "value.hpp" + +#include +#include + +namespace ascend { + +// Arithmetic on numbers of any type. +// +// The result type follows these rules: +// - int and float literals adapt to the other operand: u8 + 1 is a u8 +// - two different integer types give the smallest type that holds both: +// u8 + i32 is an i32, u8 + i8 is an i16 (u64 can't mix with signed types) +// - an integer and a float give the float's type: i32 + f32 is an f32 +// - f32 and f64 give f64 +// Integer results that don't fit their type are an error, never a silent wrap. +enum class NumOp { Add, Sub, Mul, Div, Mod, Pow, BitAnd, BitOr, BitXor, Shl, Shr }; + +Value numericOp(NumOp op, const Value& left, const Value& right); // both must be numbers +Value negateNumber(const Value& value); +Value bitwiseNot(const Value& value); // value must be an int + +// Compares two numbers by their mathematical value. NaN sorts first. +int compareNumbers(const Value& a, const Value& b); +// Numeric equality across types (1 == 1.0 == u8(1)); NaN is never equal. +bool numbersEqual(const Value& a, const Value& b); + +enum class ConvertStatus { Ok, OutOfRange, NotFinite, NotANumber, FractionLost }; + +// Converts a number to another numeric type. With `truncate`, floats convert +// to integers by dropping the fraction; otherwise that's FractionLost. +ConvertStatus convertNumber(const Value& value, NumKind kind, bool truncate, Value& out); + +// Like convertNumber with truncation, but throws a RuntimeError explaining +// any failure (used by the conversion functions i8(), u64(), f32(), ...). +Value convertNumberOrThrow(const Value& value, NumKind kind); + +// Reads an int that fits in i64 (any integer type). False otherwise. +bool toInt64(const Value& value, int64_t& out); + +std::string intToString(const Value& value); + +} // namespace ascend + +#endif // ASCEND_NUMERIC_HPP diff --git a/src/include/parser.hpp b/src/include/parser.hpp index a46e954..e5a3ccc 100644 --- a/src/include/parser.hpp +++ b/src/include/parser.hpp @@ -43,8 +43,13 @@ class Parser { StmtPtr tryStatement(const Token& keyword); StmtPtr throwStatement(const Token& keyword); StmtPtr functionDeclaration(const Token& keyword); + StmtPtr typedDeclaration(); + StmtPtr constDeclaration(const Token& keyword); + StmtPtr matchStatement(const Token& keyword); std::unique_ptr functionRest(const Token& keyword, const std::string& name); + TypeSpec parseType(bool allowVoid); Block block(const std::string& what); + static void noteStatement(Block& block, const Stmt& stmt); void parseStatementInto(Block& block); ExprPtr condition(const std::string& keyword); void expectStatementEnd(); @@ -58,6 +63,10 @@ class Parser { ExprPtr logicNot(); ExprPtr equality(); ExprPtr comparison(); + ExprPtr bitOr(); + ExprPtr bitXor(); + ExprPtr bitAnd(); + ExprPtr shift(); ExprPtr additive(); ExprPtr multiplicative(); ExprPtr unary(); @@ -69,6 +78,8 @@ class Parser { ExprPtr finishIndex(ExprPtr object, const Token& bracket); ExprPtr listLiteral(const Token& bracket); ExprPtr mapLiteral(const Token& brace); + ExprPtr fString(const Token& start); + ExprPtr numberLiteral(const Token& token, bool negate); // Token helpers const Token& peek(size_t ahead = 0) const; diff --git a/src/include/runner.hpp b/src/include/runner.hpp index 15aa0da..44ee5ad 100644 --- a/src/include/runner.hpp +++ b/src/include/runner.hpp @@ -8,7 +8,7 @@ namespace ascend { -constexpr const char* kVersion = "0.2.0"; +constexpr const char* kVersion = "0.3.0"; // Process exit codes (following the BSD sysexits convention). constexpr int kExitSyntaxError = 65; @@ -25,6 +25,7 @@ enum class RunStatus { struct RunOptions { std::string filename = ""; // shown in error messages bool repl = false; + bool checkOnly = false; // only check for errors; don't run the program }; struct RunResult { @@ -36,6 +37,10 @@ struct RunResult { RunResult runSource(Interpreter& interpreter, const std::string& source, const RunOptions& options, std::ostream& err); +// Prepares a file's text for running: drops a UTF-8 byte order mark and +// turns a first line starting with #! into a comment. +void prepareSource(std::string& text); + } // namespace ascend #endif // ASCEND_RUNNER_HPP diff --git a/src/include/semantic_analyzer.hpp b/src/include/semantic_analyzer.hpp index faa616f..c0d9267 100644 --- a/src/include/semantic_analyzer.hpp +++ b/src/include/semantic_analyzer.hpp @@ -3,7 +3,10 @@ #include "ast.hpp" #include "error.hpp" +#include "typecheck.hpp" +#include +#include #include namespace ascend { @@ -11,8 +14,10 @@ namespace ascend { // Checks a parsed program for mistakes that are grammatically valid but can // never work, so they are reported before anything runs: // - `break` / `continue` outside a loop -// - `return` outside a function +// - `return` outside a function, or a return that doesn't match the +// function's declared return type // - a function with two parameters of the same name +// - a literal that doesn't fit its declared type: `x: u8 = 300` class SemanticAnalyzer { public: std::vector analyze(const Program& program); @@ -24,8 +29,13 @@ class SemanticAnalyzer { void visitFunction(const FunctionExpr& function); void report(int line, int column, std::string message); + // Checks `expr` against `type` when its type is known without running it + // (a literal, list, map, function or f-string). `describe` builds the + // error message from the offending value's description. + void checkType(const Expr* expr, const TypeSpec& type, const std::function& describe); + int loopDepth_ = 0; - int functionDepth_ = 0; + std::vector functions_; std::vector diagnostics_; }; diff --git a/src/include/stdlib.hpp b/src/include/stdlib.hpp index 5d2ed71..a5839d5 100644 --- a/src/include/stdlib.hpp +++ b/src/include/stdlib.hpp @@ -4,6 +4,7 @@ #include "value.hpp" #include +#include #include namespace ascend { @@ -11,7 +12,7 @@ namespace ascend { class Interpreter; // Defines the builtin functions (print, len, range, push, ...) and constants -// (PI, E) in the interpreter's global scope. +// (PI, E, ...) in the interpreter's builtin scope. void registerStandardLibrary(Interpreter& interpreter); // The integers produced by range(start, stop, step). @@ -29,6 +30,20 @@ struct RangeSpec { // Validates range() arguments: range(stop), range(start, stop) or range(start, stop, step). RangeSpec makeRange(const std::vector& args); +// Formats one value with a format spec like ">8", ".2f" or "08x" (format.cpp). +std::string formatValue(const Value& value, const std::string& spec); + +// format("{} is {:.1f}", args...): fills each {} with the next argument, +// starting at args[first] (format.cpp). +std::string formatTemplate(const std::string& tpl, const std::vector& args, size_t first); + +// JSON conversion (json.cpp). +Value jsonParse(Interpreter& interpreter, const std::string& text); +std::string jsonStringify(const Value& value, int indent); + +// Reads a whole file. Returns false if it can't be opened. +bool readTextFile(const std::string& path, std::string& out); + } // namespace ascend #endif // ASCEND_STDLIB_HPP diff --git a/src/include/typecheck.hpp b/src/include/typecheck.hpp new file mode 100644 index 0000000..e41c54c --- /dev/null +++ b/src/include/typecheck.hpp @@ -0,0 +1,33 @@ +#ifndef ASCEND_TYPECHECK_HPP +#define ASCEND_TYPECHECK_HPP + +#include "types.hpp" +#include "value.hpp" + +#include + +namespace ascend { + +// Why a value can't be stored in a typed variable, parameter or return value. +struct CoerceFailure { + std::string what; // the offending value, e.g. "a string" or "300" + std::string hint; // extra advice, e.g. "u8 is 0 to 255" (may be empty) +}; + +// Converts `value` so it can be stored with type `type`: +// - any number converts to another numeric type if no information is lost +// (300 doesn't fit in a u8, and 2.5 can't become an i32 implicitly) +// - a char converts to a string +// - null is only allowed for nullable types like `string?` +// Lists, maps and functions are not converted. Returns false on failure. +bool coerceToType(const Value& value, const TypeSpec& type, Value& out, CoerceFailure& failure); + +// "a string", "300", "null", "'a'"... for error messages. +std::string describeValue(const Value& value); + +// Appends " (hint)" if there is a hint. +std::string withHint(const std::string& message, const CoerceFailure& failure); + +} // namespace ascend + +#endif // ASCEND_TYPECHECK_HPP diff --git a/src/include/types.hpp b/src/include/types.hpp new file mode 100644 index 0000000..da8d8de --- /dev/null +++ b/src/include/types.hpp @@ -0,0 +1,45 @@ +#ifndef ASCEND_TYPES_HPP +#define ASCEND_TYPES_HPP + +#include +#include + +namespace ascend { + +// The numeric types. Every number value carries one of these. +// +// `Int` and `Float` are the types of plain literals like `42` and `1.5`. +// They behave like i64 and f64, but adapt to the other operand in arithmetic, +// so with `x: u8 = 200`, `x + 1` is a u8 (like untyped constants in Go). +enum class NumKind : uint8_t { Int, I8, I16, I32, I64, U8, U16, U32, U64, Float, F32, F64 }; + +inline bool isIntKind(NumKind kind) { return kind <= NumKind::U64; } +inline bool isFloatKind(NumKind kind) { return kind >= NumKind::Float; } +inline bool isUnsignedKind(NumKind kind) { return kind >= NumKind::U8 && kind <= NumKind::U64; } + +int bitWidth(NumKind kind); // 64 for Int and Float +int64_t signedMin(NumKind kind); // for signed integer kinds +int64_t signedMax(NumKind kind); // for signed integer kinds +uint64_t unsignedMax(NumKind kind); // for unsigned integer kinds +const char* numKindName(NumKind kind); +std::string rangeText(NumKind kind); // e.g. "0 to 255" + +enum class TypeKind : uint8_t { Any, Void, Bool, Number, Char, String, List, Map, Function }; + +// A type annotation, such as the `u8` in `x: u8 = 1` or `string?` (nullable). +struct TypeSpec { + TypeKind kind = TypeKind::Any; + NumKind num = NumKind::Int; // used when kind == Number + bool nullable = false; + + bool isAny() const { return kind == TypeKind::Any; } + std::string name() const; +}; + +// Looks up a type name such as "i32", "byte" or "string". Returns false for +// names that aren't types. +bool lookupType(const std::string& name, TypeSpec& out); + +} // namespace ascend + +#endif // ASCEND_TYPES_HPP diff --git a/src/include/utf8.hpp b/src/include/utf8.hpp new file mode 100644 index 0000000..541859a --- /dev/null +++ b/src/include/utf8.hpp @@ -0,0 +1,29 @@ +#ifndef ASCEND_UTF8_HPP +#define ASCEND_UTF8_HPP + +#include +#include + +namespace ascend { + +// Strings are stored as UTF-8. Lengths, indexes and slices count characters +// (Unicode code points), not bytes. Invalid bytes count as one character each +// and decode as U+FFFD. + +constexpr char32_t kReplacementChar = 0xFFFD; + +bool isValidCodePoint(char32_t cp); +void appendUtf8(std::string& out, char32_t cp); +std::string encodeUtf8(char32_t cp); + +// Decodes the character starting at byte `pos` and moves `pos` past it. +char32_t decodeUtf8(const std::string& text, size_t& pos); + +size_t utf8Length(const std::string& text); + +// Byte offset of character number `index` (text.size() if index == length). +size_t utf8Offset(const std::string& text, size_t index); + +} // namespace ascend + +#endif // ASCEND_UTF8_HPP diff --git a/src/include/value.hpp b/src/include/value.hpp index 5befbb6..9c64d07 100644 --- a/src/include/value.hpp +++ b/src/include/value.hpp @@ -2,6 +2,7 @@ #define ASCEND_VALUE_HPP #include "error.hpp" +#include "types.hpp" #include #include @@ -20,6 +21,7 @@ struct FunctionExpr; struct ListObj; struct MapObj; struct Callable; +struct SourceFile; // Heap objects that can be part of a reference cycle (lists, maps, functions // and environments). Reference counting frees everything else; the @@ -32,18 +34,30 @@ class GcObject { virtual void clearReferences() = 0; }; +struct IntData { + int64_t bits; // the value; for u64 the bit pattern (read it with asUInt) + NumKind kind; +}; + +struct FloatData { + double value; // f32 values are kept rounded to f32 precision + NumKind kind; +}; + // A dynamically typed Ascend value. Lists, maps and functions are reference // types: copying a Value copies the reference, not the contents. class Value { public: // The order must match the alternatives of Data below. - enum class Type { Null, Bool, Int, Float, String, List, Map, Function }; + enum class Type { Null, Bool, Int, Float, Char, String, List, Map, Function }; Value() = default; // null static Value fromBool(bool value); - static Value fromInt(int64_t value); - static Value fromFloat(double value); + static Value fromInt(int64_t value, NumKind kind = NumKind::Int); + static Value fromUInt(uint64_t value, NumKind kind = NumKind::U64); + static Value fromFloat(double value, NumKind kind = NumKind::Float); + static Value fromChar(char32_t value); static Value fromString(std::string value); static Value fromList(std::shared_ptr value); static Value fromMap(std::shared_ptr value); @@ -55,30 +69,36 @@ class Value { bool isInt() const { return type() == Type::Int; } bool isFloat() const { return type() == Type::Float; } bool isNumber() const { return isInt() || isFloat(); } + bool isChar() const { return type() == Type::Char; } bool isString() const { return type() == Type::String; } bool isList() const { return type() == Type::List; } bool isMap() const { return type() == Type::Map; } bool isFunction() const { return type() == Type::Function; } bool asBool() const { return std::get<1>(data_); } - int64_t asInt() const { return std::get<2>(data_); } - double asFloat() const { return std::get<3>(data_); } - double toDouble() const { return isInt() ? static_cast(asInt()) : asFloat(); } - const std::string& asString() const { return std::get<4>(data_); } - ListObj& asList() const { return *std::get<5>(data_); } - MapObj& asMap() const { return *std::get<6>(data_); } - Callable& asFunction() const { return *std::get<7>(data_); } - const std::shared_ptr& listPtr() const { return std::get<5>(data_); } - const std::shared_ptr& mapPtr() const { return std::get<6>(data_); } + NumKind numKind() const { return isInt() ? std::get<2>(data_).kind : std::get<3>(data_).kind; } + int64_t asInt() const { return std::get<2>(data_).bits; } + uint64_t asUInt() const { return static_cast(std::get<2>(data_).bits); } + bool isNegativeInt() const { return numKind() != NumKind::U64 && asInt() < 0; } + bool fitsInt64() const { return numKind() != NumKind::U64 || asInt() >= 0; } + double asFloat() const { return std::get<3>(data_).value; } + double toDouble() const; + char32_t asChar() const { return std::get<4>(data_); } + const std::string& asString() const { return std::get<5>(data_); } + ListObj& asList() const { return *std::get<6>(data_); } + MapObj& asMap() const { return *std::get<7>(data_); } + Callable& asFunction() const { return *std::get<8>(data_); } + const std::shared_ptr& listPtr() const { return std::get<6>(data_); } + const std::shared_ptr& mapPtr() const { return std::get<7>(data_); } bool truthy() const; - const char* typeName() const; + const char* typeName() const; // "int", "u8", "f32", "char", "string", ... // The collectable object this value refers to, if any. GcObject* gcObject() const; private: - using Data = std::variant, std::shared_ptr, std::shared_ptr>; Data data_; @@ -91,8 +111,9 @@ struct ListObj final : GcObject { void clearReferences() override { items.clear(); } }; -// An insertion-ordered hash map. Keys may be null, bools, numbers or strings. -// Integral floats and ints are the same key, so m[1] and m[1.0] match. +// An insertion-ordered hash map. Keys may be null, bools, numbers, chars or +// strings. Equal numbers are the same key (m[1] and m[1.0]), and so are a char +// and the one-character string holding it. struct MapObj final : GcObject { std::vector> entries; @@ -135,8 +156,13 @@ struct NativeFunction final : Callable { // A function written in Ascend, together with the scope it was created in. struct FunctionObj final : Callable, GcObject { - FunctionObj(std::string name, const FunctionExpr* declaration, std::shared_ptr closure) - : Callable(Kind::User), name(std::move(name)), declaration(declaration), closure(std::move(closure)) {} + FunctionObj(std::string name, const FunctionExpr* declaration, std::shared_ptr closure, + const SourceFile* source) + : Callable(Kind::User), + name(std::move(name)), + declaration(declaration), + closure(std::move(closure)), + source(source) {} GcObject* gcObject() override { return this; } void forEachChild(const std::function& visit) const override; @@ -145,15 +171,19 @@ struct FunctionObj final : Callable, GcObject { std::string name; const FunctionExpr* declaration; // owned by the interpreter's list of programs std::shared_ptr closure; + const SourceFile* source; // the file the function was written in }; -// str(): how print() shows a value (strings without quotes). +// str(): how print() shows a value (strings and chars without quotes). std::string toString(const Value& value); -// repr(): how the REPL and containers show a value (strings quoted). +// repr(): how the REPL and containers show a value (strings and chars quoted). std::string toRepr(const Value& value); -std::string formatFloat(double value); +std::string formatFloat(double value, bool singlePrecision = false); std::string quoteString(const std::string& text); +std::string quoteChar(char32_t c); std::string functionName(const Callable& callable); +// The value's type name with "a" or "an": "an int", "a u8", "a string". +std::string aTypeName(const Value& value); // Converts an index into a position in a sequence of `length` elements. // Negative indexes count from the end (-1 is the last element). Throws diff --git a/src/interpreter.cpp b/src/interpreter.cpp index ef58644..01b3026 100644 --- a/src/interpreter.cpp +++ b/src/interpreter.cpp @@ -1,6 +1,13 @@ #include "interpreter.hpp" +#include "lexer.hpp" +#include "numeric.hpp" +#include "parser.hpp" +#include "runner.hpp" +#include "semantic_analyzer.hpp" #include "stdlib.hpp" +#include "typecheck.hpp" +#include "utf8.hpp" #include #include @@ -26,9 +33,6 @@ constexpr size_t kMiB = 1024 * 1024; constexpr size_t kMinCollectionThreshold = 10000; constexpr size_t kMaxRepeatSize = size_t(1) << 28; -constexpr int64_t kIntMax = std::numeric_limits::max(); -constexpr int64_t kIntMin = std::numeric_limits::min(); - const char* operatorSymbol(TokenType op) { switch (op) { case TokenType::Plus: return "+"; @@ -37,6 +41,11 @@ const char* operatorSymbol(TokenType op) { case TokenType::StarStar: return "**"; case TokenType::Slash: return "/"; case TokenType::Percent: return "%"; + case TokenType::Ampersand: return "&"; + case TokenType::Pipe: return "|"; + case TokenType::Caret: return "^"; + case TokenType::ShiftLeft: return "<<"; + case TokenType::ShiftRight: return ">>"; case TokenType::EqualEqual: return "=="; case TokenType::NotEqual: return "!="; case TokenType::Less: return "<"; @@ -48,6 +57,23 @@ const char* operatorSymbol(TokenType op) { } } +bool toNumOp(TokenType op, NumOp& out) { + switch (op) { + case TokenType::Plus: out = NumOp::Add; return true; + case TokenType::Minus: out = NumOp::Sub; return true; + case TokenType::Star: out = NumOp::Mul; return true; + case TokenType::Slash: out = NumOp::Div; return true; + case TokenType::Percent: out = NumOp::Mod; return true; + case TokenType::StarStar: out = NumOp::Pow; return true; + case TokenType::Ampersand: out = NumOp::BitAnd; return true; + case TokenType::Pipe: out = NumOp::BitOr; return true; + case TokenType::Caret: out = NumOp::BitXor; return true; + case TokenType::ShiftLeft: out = NumOp::Shl; return true; + case TokenType::ShiftRight: out = NumOp::Shr; return true; + default: return false; + } +} + TokenType compoundToBinary(TokenType op) { switch (op) { case TokenType::PlusEqual: return TokenType::Plus; @@ -55,64 +81,47 @@ TokenType compoundToBinary(TokenType op) { case TokenType::StarEqual: return TokenType::Star; case TokenType::SlashEqual: return TokenType::Slash; case TokenType::PercentEqual: return TokenType::Percent; + case TokenType::AmpersandEqual: return TokenType::Ampersand; + case TokenType::PipeEqual: return TokenType::Pipe; + case TokenType::CaretEqual: return TokenType::Caret; + case TokenType::ShiftLeftEqual: return TokenType::ShiftLeft; + case TokenType::ShiftRightEqual: return TokenType::ShiftRight; default: return op; } } -[[noreturn]] void overflow() { throw RuntimeError("integer overflow (the result doesn't fit in a 64-bit int)"); } - -int64_t checkedAdd(int64_t a, int64_t b) { - if ((b > 0 && a > kIntMax - b) || (b < 0 && a < kIntMin - b)) overflow(); - return a + b; -} - -int64_t checkedSub(int64_t a, int64_t b) { - if ((b < 0 && a > kIntMax + b) || (b > 0 && a < kIntMin + b)) overflow(); - return a - b; -} - -int64_t checkedMul(int64_t a, int64_t b) { - if (a == 0 || b == 0) return 0; - bool overflows; - if (a > 0) { - overflows = b > 0 ? a > kIntMax / b : b < kIntMin / a; - } else { - overflows = b > 0 ? a < kIntMin / b : a < kIntMax / b; +int64_t repeatCount(const Value& count, size_t unitSize) { + int64_t n = 0; + if (!toInt64(count, n)) throw RuntimeError("the repeated result would be too large"); + if (n <= 0) return 0; + if (unitSize != 0 && static_cast(n) > kMaxRepeatSize / unitSize) { + throw RuntimeError("the repeated result would be too large"); } - if (overflows) overflow(); - return a * b; + return n; } -Value power(const Value& base, const Value& exponent) { - if (base.isInt() && exponent.isInt() && exponent.asInt() >= 0) { - int64_t result = 1; - int64_t factor = base.asInt(); - int64_t remaining = exponent.asInt(); - while (remaining > 0) { - if (remaining & 1) result = checkedMul(result, factor); - remaining >>= 1; - if (remaining > 0) factor = checkedMul(factor, factor); - } - return Value::fromInt(result); - } - return Value::fromFloat(std::pow(base.toDouble(), exponent.toDouble())); +std::string repeatString(const std::string& text, const Value& count) { + int64_t n = repeatCount(count, text.size()); + std::string result; + result.reserve(text.size() * static_cast(n)); + for (int64_t i = 0; i < n; ++i) result += text; + return result; } -int64_t repeatCount(int64_t count, size_t unitSize) { - if (count <= 0) return 0; - if (unitSize != 0 && static_cast(count) > kMaxRepeatSize / unitSize) { - throw RuntimeError("the repeated result would be too large"); +// 'a' + 1 == 'b' +Value shiftChar(const Value& c, const Value& amount, int sign) { + int64_t n = 0; + if (!toInt64(amount, n) || n > 0x10FFFF || n < -0x10FFFF) { + throw RuntimeError(toRepr(c) + (sign > 0 ? " + " : " - ") + toString(amount) + " is not a valid character code"); + } + int64_t result = static_cast(c.asChar()) + sign * n; + if (result < 0 || !isValidCodePoint(static_cast(result))) { + throw RuntimeError(std::to_string(result) + " is not a valid character code"); } - return count; + return Value::fromChar(static_cast(result)); } -std::string repeatString(const std::string& text, int64_t count) { - count = repeatCount(count, text.size()); - std::string result; - result.reserve(text.size() * static_cast(count)); - for (int64_t i = 0; i < count; ++i) result += text; - return result; -} +std::string textOf(const Value& v) { return v.isChar() ? encodeUtf8(v.asChar()) : v.asString(); } size_t editDistance(const std::string& a, const std::string& b) { std::vector row(b.size() + 1); @@ -162,6 +171,8 @@ std::string arityMessage(const std::string& name, size_t min, long long max, siz return who + " expects " + expected + " but got " + std::to_string(got); } +std::string functionLabel(const std::string& name) { return name.empty() ? "this function" : name + "()"; } + // Address of the current stack frame, used to measure how much stack is in use. uintptr_t stackAddress() { #if defined(__GNUC__) || defined(__clang__) @@ -188,15 +199,46 @@ size_t stackBudget() { return total > 2 * kMiB ? total - std::max(kMiB, total / 8) : total / 2; } -void locate(RuntimeError& error, int line, int column) { - if (!error.hasLocation()) { - error.line = line; - error.column = column; - } +bool isAbsolutePath(const std::string& path) { + return (!path.empty() && (path[0] == '/' || path[0] == '\\')) || (path.size() > 1 && path[1] == ':'); +} + +bool hasExtension(const std::string& path) { + size_t slash = path.find_last_of("/\\"); + size_t dot = path.find_last_of('.'); + return dot != std::string::npos && (slash == std::string::npos || dot > slash); } } // namespace +// Switches the current function and source file for the duration of a call, +// and restores them afterwards, even when an error unwinds the call. +class Interpreter::FrameGuard { +public: + FrameGuard(Interpreter& interpreter, const SourceFile* source, const FunctionExpr* function) + : interpreter_(interpreter), + savedSource_(interpreter.currentSource_), + savedFunction_(interpreter.currentFunction_) { + interpreter_.currentSource_ = source; + interpreter_.currentFunction_ = function; + ++interpreter_.callDepth_; + } + ~FrameGuard() { + interpreter_.currentSource_ = savedSource_; + interpreter_.currentFunction_ = savedFunction_; + --interpreter_.callDepth_; + } + FrameGuard(const FrameGuard&) = delete; + FrameGuard& operator=(const FrameGuard&) = delete; + + const SourceFile* callerSource() const { return savedSource_; } + +private: + Interpreter& interpreter_; + const SourceFile* savedSource_; + const FunctionExpr* savedFunction_; +}; + // --------------------------------------------------------------------------- // Setup and memory management @@ -212,13 +254,15 @@ Interpreter::Interpreter(std::ostream& out, std::istream& in) } catch (...) { random_.seed(static_cast(std::chrono::steady_clock::now().time_since_epoch().count())); } - globals_ = newEnvironment(nullptr); + builtins_ = newEnvironment(nullptr); + globals_ = newEnvironment(builtins_); registerStandardLibrary(*this); - if (Value* range = globals_->find("range")) rangeFunction_ = &range->asFunction(); + if (Variable* range = builtins_->find("range")) rangeFunction_ = &range->value.asFunction(); } Interpreter::~Interpreter() { // Break every remaining reference cycle so all memory is released. + modules_.clear(); std::vector> live; for (auto& weak : heap_) { if (auto strong = weak.lock()) live.push_back(std::move(strong)); @@ -308,28 +352,143 @@ size_t Interpreter::collectGarbage() { } void Interpreter::defineNative(const std::string& name, int minArgs, int maxArgs, NativeFn fn) { - globals_->define(name, Value::fromFunction(std::make_shared(name, minArgs, maxArgs, std::move(fn)))); + builtins_->define(name, Value::fromFunction(std::make_shared(name, minArgs, maxArgs, std::move(fn)))); } +void Interpreter::defineBuiltin(const std::string& name, Value value) { builtins_->define(name, std::move(value)); } + void Interpreter::defineGlobal(const std::string& name, Value value) { globals_->define(name, std::move(value)); } +void Interpreter::locate(RuntimeError& error, int line, int column) const { + if (!error.hasLocation()) { + error.line = line; + error.column = column; + } + if (error.source == nullptr) error.source = currentSource_; +} + +void Interpreter::checkStack() const { + uintptr_t here = stackAddress(); + size_t used = here > stackBase_ ? here - stackBase_ : stackBase_ - here; + if (used > stackBudget_) { + throw RuntimeError("stack overflow: too much recursion (is there a recursive call without a base case?)"); + } +} + // --------------------------------------------------------------------------- -// Statements +// Programs and modules -void Interpreter::execute(std::unique_ptr program) { +void Interpreter::execute(std::unique_ptr program, std::unique_ptr source) { + if (!source) { + source = std::make_unique(); + source->name = ""; + } + sources_.push_back(std::move(source)); programs_.push_back(std::move(program)); - const Block& body = programs_.back()->body; - hoistFunctions(body, globals_); - for (const auto& stmt : body.statements) { - if (replMode_ && stmt->kind == StmtKind::Expression) { - Value result = eval(*stmt->as().expr, globals_); + const SourceFile* saved = currentSource_; + currentSource_ = sources_.back().get(); + try { + runTopLevel(*programs_.back(), globals_, replMode_); + } catch (...) { + currentSource_ = saved; + throw; + } + currentSource_ = saved; +} + +void Interpreter::runTopLevel(const Program& program, const EnvPtr& env, bool echo) { + hoistFunctions(program.body, env); + for (const auto& stmt : program.body.statements) { + if (echo && stmt->kind == StmtKind::Expression) { + Value result = eval(*stmt->as().expr, env); if (!result.isNull()) out_ << toRepr(result) << '\n'; continue; } - exec(*stmt, globals_); + exec(*stmt, env); } } +// Relative imports are resolved from the directory of the running file. +std::string Interpreter::importDirectory() const { + if (currentSource_ == nullptr || currentSource_->name.empty() || currentSource_->name[0] == '<') return ""; + size_t slash = currentSource_->name.find_last_of("/\\"); + return slash == std::string::npos ? "" : currentSource_->name.substr(0, slash + 1); +} + +Value Interpreter::importModule(const std::string& path) { + std::string resolved = isAbsolutePath(path) ? path : importDirectory() + path; + if (!hasExtension(resolved)) resolved += ".asc"; + + auto cached = modules_.find(resolved); + if (cached != modules_.end()) return cached->second; + if (loadingModules_.count(resolved) != 0) { + throw RuntimeError("can't import \"" + path + "\": it imports itself (directly or through other files)"); + } + + std::string text; + if (!readTextFile(resolved, text)) throw RuntimeError("can't import \"" + path + "\": file '" + resolved + "' not found"); + prepareSource(text); + + // Check the whole module before running any of it. + std::string problems; + auto collect = [&](const std::vector& diagnostics, const char* kind) { + for (const auto& d : diagnostics) problems += formatDiagnostic(text, resolved, d.line, d.column, kind, d.message); + }; + Lexer lexer(text); + std::vector tokens = lexer.tokenize(); + std::unique_ptr program; + if (!lexer.errors().empty()) { + collect(lexer.errors(), "syntax error"); + } else { + Parser parser(std::move(tokens)); + program = parser.parseProgram(); + if (!parser.errors().empty()) { + collect(parser.errors(), "syntax error"); + } else { + SemanticAnalyzer analyzer; + collect(analyzer.analyze(*program), "error"); + } + } + if (!problems.empty()) { + if (problems.back() == '\n') problems.pop_back(); + throw RuntimeError("can't import \"" + path + "\" because it has errors:\n" + problems); + } + + auto file = std::make_unique(); + file->name = resolved; + file->text = std::move(text); + sources_.push_back(std::move(file)); + programs_.push_back(std::move(program)); + const SourceFile* source = sources_.back().get(); + const Program& module = *programs_.back(); + + auto env = newEnvironment(builtins_); + loadingModules_.insert(resolved); + try { + FrameGuard frame(*this, source, nullptr); + runTopLevel(module, env, false); + } catch (...) { + loadingModules_.erase(resolved); + throw; + } + loadingModules_.erase(resolved); + + // Export every top-level name that doesn't start with an underscore. + std::vector names; + for (const auto& entry : env->locals()) { + if (entry.first.empty() || entry.first[0] != '_') names.push_back(entry.first); + } + std::sort(names.begin(), names.end()); + auto exports = newMap(); + for (const auto& name : names) exports->set(Value::fromString(name), env->findLocal(name)->value); + Value result = Value::fromMap(exports); + modules_[resolved] = result; + return result; +} + +// --------------------------------------------------------------------------- +// Statements + Interpreter::Flow Interpreter::exec(const Stmt& stmt, const EnvPtr& env) { try { return execStmt(stmt, env); @@ -345,19 +504,8 @@ Interpreter::Flow Interpreter::execStmt(const Stmt& stmt, const EnvPtr& env) { eval(*stmt.as().expr, env); return Flow::Normal; - case StmtKind::Declare: { - const auto& decl = stmt.as(); - Value value; - if (decl.value->kind == ExprKind::Function) { - // `name := func(...) {...}` gives the function a name for messages. - const auto& function = decl.value->as(); - value = makeFunction(function, env, function.name.empty() ? decl.name : function.name); - } else { - value = eval(*decl.value, env); - } - env->define(decl.name, std::move(value)); - return Flow::Normal; - } + case StmtKind::Declare: + return execDeclare(stmt.as(), env); case StmtKind::Assign: return execAssign(stmt.as(), env); @@ -391,11 +539,8 @@ Interpreter::Flow Interpreter::execStmt(const Stmt& stmt, const EnvPtr& env) { case StmtKind::Continue: return Flow::Continue; - case StmtKind::Return: { - const auto& ret = stmt.as(); - returnValue_ = ret.value ? eval(*ret.value, env) : Value(); - return Flow::Return; - } + case StmtKind::Return: + return execReturn(stmt.as(), env); case StmtKind::Function: return Flow::Normal; // already defined when its block started (hoisting) @@ -405,8 +550,11 @@ Interpreter::Flow Interpreter::execStmt(const Stmt& stmt, const EnvPtr& env) { case StmtKind::Throw: { Value value = eval(*stmt.as().value, env); - throw ThrownValue{std::move(value), stmt.line, stmt.column}; + throw ThrownValue{std::move(value), stmt.line, stmt.column, currentSource_}; } + + case StmtKind::Match: + return execMatch(stmt.as(), env); } return Flow::Normal; } @@ -438,6 +586,53 @@ void Interpreter::hoistFunctions(const Block& block, const EnvPtr& env) { } } +// The starting value of `name: type` without an initializer. +Value Interpreter::zeroValue(const TypeSpec& type) { + if (type.nullable) return Value(); + switch (type.kind) { + case TypeKind::Bool: return Value::fromBool(false); + case TypeKind::Number: + return isFloatKind(type.num) ? Value::fromFloat(0.0, type.num) : Value::fromInt(0, type.num); + case TypeKind::Char: return Value::fromChar(0); + case TypeKind::String: return Value::fromString(""); + case TypeKind::List: return Value::fromList(newList()); + case TypeKind::Map: return Value::fromMap(newMap()); + default: return Value(); + } +} + +Value Interpreter::coerceVariable(const std::string& name, const TypeSpec& type, Value value) { + if (type.isAny()) return value; + Value converted; + CoerceFailure failure; + if (!coerceToType(value, type, converted, failure)) { + throw RuntimeError( + withHint("'" + name + "' is declared as " + type.name() + ", so it can't hold " + failure.what, failure)); + } + return converted; +} + +Interpreter::Flow Interpreter::execDeclare(const DeclareStmt& decl, const EnvPtr& env) { + Variable* existing = env->findLocal(decl.name); + if (existing != nullptr && existing->constant) { + throw RuntimeError("'" + decl.name + "' is a constant and can't be declared again"); + } + + Value value; + if (!decl.value) { + value = zeroValue(decl.type); + } else if (decl.value->kind == ExprKind::Function) { + // `name := func(...) {...}` gives the function a name for messages. + const auto& function = decl.value->as(); + value = makeFunction(function, env, function.name.empty() ? decl.name : function.name); + } else { + value = eval(*decl.value, env); + } + if (decl.hasType) value = coerceVariable(decl.name, decl.type, std::move(value)); + env->define(decl.name, std::move(value), decl.type, decl.constant); + return Flow::Normal; +} + Interpreter::Flow Interpreter::execAssign(const AssignStmt& stmt, const EnvPtr& env) { const bool compound = stmt.op != TokenType::Equal; const TokenType op = compoundToBinary(stmt.op); @@ -445,15 +640,19 @@ Interpreter::Flow Interpreter::execAssign(const AssignStmt& stmt, const EnvPtr& switch (stmt.target->kind) { case ExprKind::Variable: { const std::string& name = stmt.target->as().name; - Value* slot = env->find(name); - if (slot == nullptr) { + Variable* variable = env->find(name); + if (variable == nullptr) { std::string hint = suggestName(name, *env); if (hint.empty()) hint = " (to create it, write '" + name + " := ...')"; throw RuntimeError("undefined variable '" + name + "'" + hint, stmt.target->line, stmt.target->column); } + if (variable->constant) { + throw RuntimeError("can't change '" + name + "': it's a constant", stmt.target->line, + stmt.target->column); + } Value result; if (compound) { - Value current = *slot; + Value current = variable->value; Value operand = eval(*stmt.value, env); result = binaryOp(op, current, operand); } else { @@ -461,7 +660,8 @@ Interpreter::Flow Interpreter::execAssign(const AssignStmt& stmt, const EnvPtr& } // Evaluating the right side may have defined new variables, which // can move existing ones, so look the variable up again. - *env->find(name) = std::move(result); + variable = env->find(name); + variable->value = coerceVariable(name, variable->type, std::move(result)); return Flow::Normal; } @@ -485,7 +685,7 @@ Interpreter::Flow Interpreter::execAssign(const AssignStmt& stmt, const EnvPtr& const auto& target = stmt.target->as(); Value object = eval(*target.object, env); if (!object.isMap()) { - throw RuntimeError(std::string("can't set field '") + target.name + "' on a " + object.typeName() + + throw RuntimeError("can't set field '" + target.name + "' on " + aTypeName(object) + " value (only maps have fields)", target.line, target.column); } @@ -507,6 +707,29 @@ Interpreter::Flow Interpreter::execAssign(const AssignStmt& stmt, const EnvPtr& } } +Interpreter::Flow Interpreter::execReturn(const ReturnStmt& stmt, const EnvPtr& env) { + Value value = stmt.value ? eval(*stmt.value, env) : Value(); + if (currentFunction_ != nullptr && !currentFunction_->returnType.isAny()) { + const TypeSpec& type = currentFunction_->returnType; + if (type.kind == TypeKind::Void) { + if (!value.isNull()) { + throw RuntimeError(functionLabel(currentFunction_->name) + " is declared '-> void', so it can't return a value"); + } + } else { + Value converted; + CoerceFailure failure; + if (!coerceToType(value, type, converted, failure)) { + throw RuntimeError(withHint(functionLabel(currentFunction_->name) + " must return " + type.name() + + ", but returned " + failure.what, + failure)); + } + value = std::move(converted); + } + } + returnValue_ = std::move(value); + return Flow::Return; +} + Interpreter::Flow Interpreter::execFor(const ForStmt& loop, const EnvPtr& env) { // Runs one iteration. With one loop variable it gets `item`; with two, // the first gets `key` (an index for lists and strings) and the second `item`. @@ -525,8 +748,8 @@ Interpreter::Flow Interpreter::execFor(const ForStmt& loop, const EnvPtr& env) { if (loop.iterable->kind == ExprKind::Call && rangeFunction_ != nullptr) { const auto& call = loop.iterable->as(); if (call.callee->kind == ExprKind::Variable) { - Value* callee = env->find(call.callee->as().name); - if (callee != nullptr && callee->isFunction() && &callee->asFunction() == rangeFunction_) { + Variable* callee = env->find(call.callee->as().name); + if (callee != nullptr && callee->value.isFunction() && &callee->value.asFunction() == rangeFunction_) { std::vector args; for (const auto& arg : call.args) args.push_back(eval(*arg, env)); RangeSpec range; @@ -560,8 +783,10 @@ Interpreter::Flow Interpreter::execFor(const ForStmt& loop, const EnvPtr& env) { } case Value::Type::String: { const std::string text = iterable.asString(); - for (size_t i = 0; i < text.size(); ++i) { - Flow flow = iteration(Value::fromInt(static_cast(i)), Value::fromString(std::string(1, text[i]))); + int64_t index = 0; + for (size_t pos = 0; pos < text.size(); ++index) { + char32_t c = decodeUtf8(text, pos); + Flow flow = iteration(Value::fromInt(index), Value::fromChar(c)); if (flow == Flow::Break) break; if (flow == Flow::Return) return flow; } @@ -578,7 +803,7 @@ Interpreter::Flow Interpreter::execFor(const ForStmt& loop, const EnvPtr& env) { return Flow::Normal; } default: - throw RuntimeError(std::string("can't loop over a ") + iterable.typeName() + + throw RuntimeError("can't loop over " + aTypeName(iterable) + " value (expected a list, map, string or range)", loop.iterable->line, loop.iterable->column); } @@ -598,17 +823,25 @@ Interpreter::Flow Interpreter::execTry(const TryStmt& stmt, const EnvPtr& env) { return execBlock(stmt.handler, scope); } -// --------------------------------------------------------------------------- -// Expressions - -void Interpreter::checkStack() const { - uintptr_t here = stackAddress(); - size_t used = here > stackBase_ ? here - stackBase_ : stackBase_ - here; - if (used > stackBudget_) { - throw RuntimeError("stack overflow: too much recursion (is there a recursive call without a base case?)"); +// Runs the first arm with a pattern equal to the subject, or the `else` arm. +Interpreter::Flow Interpreter::execMatch(const MatchStmt& stmt, const EnvPtr& env) { + Value subject = eval(*stmt.subject, env); + const MatchArm* fallback = nullptr; + for (const auto& arm : stmt.arms) { + if (arm.patterns.empty()) { + fallback = &arm; + continue; + } + for (const auto& pattern : arm.patterns) { + if (valuesEqual(subject, eval(*pattern, env))) return execScoped(arm.body, env); + } } + return fallback != nullptr ? execScoped(fallback->body, env) : Flow::Normal; } +// --------------------------------------------------------------------------- +// Expressions + Value Interpreter::eval(const Expr& expr, const EnvPtr& env) { try { checkStack(); @@ -700,37 +933,45 @@ Value Interpreter::evalExpr(const Expr& expr, const EnvPtr& env) { const auto& function = expr.as(); return makeFunction(function, env, function.name); } + + case ExprKind::FString: + return evalFString(expr.as(), env); } return Value(); } Value Interpreter::evalVariable(const VariableExpr& expr, const EnvPtr& env) { - Value* value = env->find(expr.name); - if (value == nullptr) { + Variable* variable = env->find(expr.name); + if (variable == nullptr) { throw RuntimeError("undefined variable '" + expr.name + "'" + suggestName(expr.name, *env), expr.line, expr.column); } - return *value; + return variable->value; } Value Interpreter::evalUnary(const UnaryExpr& expr, const EnvPtr& env) { Value operand = eval(*expr.operand, env); - if (expr.op == TokenType::Not) return Value::fromBool(!operand.truthy()); - if (operand.isInt()) { - if (operand.asInt() == kIntMin) overflow(); - return Value::fromInt(-operand.asInt()); + switch (expr.op) { + case TokenType::Not: + return Value::fromBool(!operand.truthy()); + case TokenType::Tilde: + if (!operand.isInt()) throw RuntimeError("'~' needs an integer, not " + aTypeName(operand)); + return bitwiseNot(operand); + default: + if (!operand.isNumber()) throw RuntimeError("can't negate " + aTypeName(operand) + " value"); + return negateNumber(operand); } - if (operand.isFloat()) return Value::fromFloat(-operand.asFloat()); - throw RuntimeError(std::string("can't negate a ") + operand.typeName() + " value"); } Value Interpreter::evalMember(const MemberExpr& expr, const EnvPtr& env) { Value object = eval(*expr.object, env); if (!object.isMap()) { std::string hint; - Value* function = env->find(expr.name); - if (function != nullptr && function->isFunction()) hint = " (to call " + expr.name + "(), write ." + expr.name + "())"; - throw RuntimeError(std::string("can't read field '") + expr.name + "' of a " + object.typeName() + " value" + hint, + Variable* function = env->find(expr.name); + if (function != nullptr && function->value.isFunction()) { + hint = " (to call " + expr.name + "(), write ." + expr.name + "())"; + } + throw RuntimeError(std::string("can't read field '") + expr.name + "' of " + aTypeName(object) + " value" + hint, expr.line, expr.column); } const Value* field = object.asMap().find(Value::fromString(expr.name)); @@ -738,6 +979,24 @@ Value Interpreter::evalMember(const MemberExpr& expr, const EnvPtr& env) { return *field; } +Value Interpreter::evalFString(const FStringExpr& expr, const EnvPtr& env) { + std::string result; + for (const auto& part : expr.parts) { + if (!part.expr) { + result += part.text; + continue; + } + Value value = eval(*part.expr, env); + try { + result += part.spec.empty() ? toString(value) : formatValue(value, part.spec); + } catch (RuntimeError& error) { + locate(error, part.expr->line, part.expr->column); + throw; + } + } + return Value::fromString(result); +} + // Calls `f(args)`. A method-style call `x.f(args)` calls the function stored // in map field `f` if there is one, and otherwise calls the function `f` with // `x` as the first argument, so `items.push(4)` means `push(items, 4)`. @@ -753,14 +1012,15 @@ Value Interpreter::evalCall(const CallExpr& expr, const EnvPtr& env) { if (field != nullptr) { callee = *field; } else { - Value* function = env->find(member.name); - if (function == nullptr || !function->isFunction()) { - std::string message = object.isMap() - ? "map has no key \"" + member.name + "\" and there is no function named '" + member.name + "'" - : std::string("a ") + object.typeName() + " value has no method '" + member.name + "'"; + Variable* function = env->find(member.name); + if (function == nullptr || !function->value.isFunction()) { + std::string message = + object.isMap() + ? "map has no key \"" + member.name + "\" and there is no function named '" + member.name + "'" + : aTypeName(object) + " value has no method '" + member.name + "'"; throw RuntimeError(message + suggestName(member.name, *env), member.line, member.column); } - callee = *function; + callee = function->value; args.push_back(std::move(object)); } } else { @@ -772,12 +1032,12 @@ Value Interpreter::evalCall(const CallExpr& expr, const EnvPtr& env) { } Value Interpreter::makeFunction(const FunctionExpr& expr, const EnvPtr& env, const std::string& name) { - return Value::fromFunction(track(std::make_shared(name, &expr, env))); + return Value::fromFunction(track(std::make_shared(name, &expr, env, currentSource_))); } Value Interpreter::call(const Value& callee, std::vector args, int line, int column) { if (!callee.isFunction()) { - throw RuntimeError(std::string("a ") + callee.typeName() + " value can't be called like a function", line, column); + throw RuntimeError(aTypeName(callee) + " value can't be called like a function", line, column); } Callable& callable = callee.asFunction(); @@ -811,25 +1071,42 @@ Value Interpreter::call(const Value& callee, std::vector args, int line, } checkStack(); - struct DepthGuard { - explicit DepthGuard(int& counter) : depth(counter) { ++depth; } - ~DepthGuard() { --depth; } - int& depth; - } guard(callDepth_); - auto scope = newEnvironment(function.closure); - for (size_t i = 0; i < decl.params.size(); ++i) { - if (i < args.size()) { - scope->define(decl.params[i].name, std::move(args[i])); - } else { - scope->define(decl.params[i].name, eval(*decl.params[i].defaultValue, scope)); + { + FrameGuard frame(*this, function.source, &decl); + for (size_t i = 0; i < decl.params.size(); ++i) { + const Param& param = decl.params[i]; + Value arg = i < args.size() ? std::move(args[i]) : eval(*param.defaultValue, scope); + if (!param.type.isAny()) { + Value converted; + CoerceFailure failure; + if (!coerceToType(arg, param.type, converted, failure)) { + // Reported at the call site, so it belongs to the caller's file. + RuntimeError error(withHint(functionLabel(function.name) + " expects '" + param.name + "' to be " + param.type.name() + + ", but got " + failure.what, + failure), + line, column); + error.source = frame.callerSource(); + throw error; + } + arg = std::move(converted); + } + scope->define(param.name, std::move(arg), param.type); + } + + if (execBlock(decl.body, scope) == Flow::Return) { + Value result = std::move(returnValue_); + returnValue_ = Value(); + return result; } } - if (execBlock(decl.body, scope) == Flow::Return) { - Value result = std::move(returnValue_); - returnValue_ = Value(); - return result; + // The function ended without `return`. + const TypeSpec& type = decl.returnType; + if (!type.isAny() && type.kind != TypeKind::Void && !type.nullable) { + throw RuntimeError(functionLabel(function.name) + " must return " + type.name() + + ", but it ended without returning a value", + line, column); } return Value(); } @@ -838,11 +1115,16 @@ Value Interpreter::call(const Value& callee, std::vector args, int line, // Operators Value Interpreter::binaryOp(TokenType op, const Value& a, const Value& b) { + NumOp numOp; + if (toNumOp(op, numOp) && a.isNumber() && b.isNumber()) return numericOp(numOp, a, b); + switch (op) { case TokenType::Plus: - if (a.isInt() && b.isInt()) return Value::fromInt(checkedAdd(a.asInt(), b.asInt())); - if (a.isNumber() && b.isNumber()) return Value::fromFloat(a.toDouble() + b.toDouble()); - if (a.isString() || b.isString()) return Value::fromString(toString(a) + toString(b)); + if (a.isChar() && b.isInt()) return shiftChar(a, b, 1); + if (a.isInt() && b.isChar()) return shiftChar(b, a, 1); + if (a.isString() || b.isString() || (a.isChar() && b.isChar())) { + return Value::fromString(toString(a) + toString(b)); + } if (a.isList() && b.isList()) { std::vector items = a.asList().items; const auto& more = b.asList().items; @@ -852,18 +1134,18 @@ Value Interpreter::binaryOp(TokenType op, const Value& a, const Value& b) { break; case TokenType::Minus: - if (a.isInt() && b.isInt()) return Value::fromInt(checkedSub(a.asInt(), b.asInt())); - if (a.isNumber() && b.isNumber()) return Value::fromFloat(a.toDouble() - b.toDouble()); + if (a.isChar() && b.isInt()) return shiftChar(a, b, -1); + if (a.isChar() && b.isChar()) { + return Value::fromInt(static_cast(a.asChar()) - static_cast(b.asChar())); + } break; case TokenType::Star: - if (a.isInt() && b.isInt()) return Value::fromInt(checkedMul(a.asInt(), b.asInt())); - if (a.isNumber() && b.isNumber()) return Value::fromFloat(a.toDouble() * b.toDouble()); - if (a.isString() && b.isInt()) return Value::fromString(repeatString(a.asString(), b.asInt())); - if (a.isInt() && b.isString()) return Value::fromString(repeatString(b.asString(), a.asInt())); + if ((a.isString() || a.isChar()) && b.isInt()) return Value::fromString(repeatString(textOf(a), b)); + if (a.isInt() && (b.isString() || b.isChar())) return Value::fromString(repeatString(textOf(b), a)); if ((a.isList() && b.isInt()) || (a.isInt() && b.isList())) { const auto& items = (a.isList() ? a : b).asList().items; - int64_t count = repeatCount((a.isInt() ? a : b).asInt(), items.size()); + int64_t count = repeatCount(a.isInt() ? a : b, items.size()); std::vector result; result.reserve(items.size() * static_cast(count)); for (int64_t i = 0; i < count; ++i) result.insert(result.end(), items.begin(), items.end()); @@ -871,32 +1153,6 @@ Value Interpreter::binaryOp(TokenType op, const Value& a, const Value& b) { } break; - case TokenType::Slash: - if (a.isNumber() && b.isNumber()) { - if (b.toDouble() == 0.0) throw RuntimeError("division by zero"); - if (a.isInt() && b.isInt()) { - if (a.asInt() == kIntMin && b.asInt() == -1) overflow(); - return Value::fromInt(a.asInt() / b.asInt()); // truncates toward zero - } - return Value::fromFloat(a.toDouble() / b.toDouble()); - } - break; - - case TokenType::Percent: - if (a.isNumber() && b.isNumber()) { - if (b.toDouble() == 0.0) throw RuntimeError("modulo by zero"); - if (a.isInt() && b.isInt()) { - if (b.asInt() == -1) return Value::fromInt(0); - return Value::fromInt(a.asInt() % b.asInt()); - } - return Value::fromFloat(std::fmod(a.toDouble(), b.toDouble())); - } - break; - - case TokenType::StarStar: - if (a.isNumber() && b.isNumber()) return power(a, b); - break; - case TokenType::EqualEqual: return Value::fromBool(valuesEqual(a, b)); @@ -907,36 +1163,38 @@ Value Interpreter::binaryOp(TokenType op, const Value& a, const Value& b) { case TokenType::LessEqual: case TokenType::Greater: case TokenType::GreaterEqual: { - bool less; - bool equal; - if (a.isInt() && b.isInt()) { - less = a.asInt() < b.asInt(); - equal = a.asInt() == b.asInt(); - } else if (a.isNumber() && b.isNumber()) { - double x = a.toDouble(); - double y = b.toDouble(); - if (std::isnan(x) || std::isnan(y)) return Value::fromBool(false); - less = x < y; - equal = x == y; - } else if ((a.isString() && b.isString()) || (a.isList() && b.isList())) { - int order = compareValues(a, b); - less = order < 0; - equal = order == 0; + int order; + if (a.isNumber() && b.isNumber()) { + if ((a.isFloat() && std::isnan(a.asFloat())) || (b.isFloat() && std::isnan(b.asFloat()))) { + return Value::fromBool(false); + } + order = compareNumbers(a, b); + } else if (((a.isString() || a.isChar()) && (b.isString() || b.isChar())) || (a.isList() && b.isList())) { + order = compareValues(a, b); } else { throw RuntimeError(std::string("can't compare ") + a.typeName() + " and " + b.typeName() + " with '" + operatorSymbol(op) + "'"); } switch (op) { - case TokenType::Less: return Value::fromBool(less); - case TokenType::LessEqual: return Value::fromBool(less || equal); - case TokenType::Greater: return Value::fromBool(!less && !equal); - default: return Value::fromBool(!less); + case TokenType::Less: return Value::fromBool(order < 0); + case TokenType::LessEqual: return Value::fromBool(order <= 0); + case TokenType::Greater: return Value::fromBool(order > 0); + default: return Value::fromBool(order >= 0); } } case TokenType::In: return Value::fromBool(contains(b, a)); + case TokenType::Ampersand: + case TokenType::Pipe: + if (a.isBool() && b.isBool()) { + throw RuntimeError(std::string("'") + operatorSymbol(op) + + "' works on the bits of integers; for true/false use '" + + (op == TokenType::Ampersand ? "and" : "or") + "'"); + } + break; + default: break; } @@ -947,10 +1205,11 @@ Value Interpreter::binaryOp(TokenType op, const Value& a, const Value& b) { bool Interpreter::contains(const Value& container, const Value& item) { switch (container.type()) { case Value::Type::String: - if (!item.isString()) { - throw RuntimeError(std::string("'in ' needs a string on the left, not ") + item.typeName()); + if (!item.isString() && !item.isChar()) { + throw RuntimeError(std::string("'in ' needs a string or char on the left, not ") + + item.typeName()); } - return container.asString().find(item.asString()) != std::string::npos; + return container.asString().find(textOf(item)) != std::string::npos; case Value::Type::List: for (const Value& element : container.asList().items) { if (valuesEqual(element, item)) return true; @@ -971,7 +1230,8 @@ Value Interpreter::indexGet(const Value& object, const Value& index) { } case Value::Type::String: { const std::string& text = object.asString(); - return Value::fromString(std::string(1, text[resolveIndex(index, text.size(), "string")])); + size_t position = utf8Offset(text, resolveIndex(index, utf8Length(text), "string")); + return Value::fromChar(decodeUtf8(text, position)); } case Value::Type::Map: { const Value* value = object.asMap().find(index); @@ -979,7 +1239,7 @@ Value Interpreter::indexGet(const Value& object, const Value& index) { return *value; } default: - throw RuntimeError(std::string("can't index into a ") + object.typeName() + " value"); + throw RuntimeError("can't index into " + aTypeName(object) + " value"); } } @@ -996,7 +1256,7 @@ void Interpreter::indexSet(const Value& object, const Value& index, Value value) case Value::Type::String: throw RuntimeError("strings can't be changed in place; build a new string instead"); default: - throw RuntimeError(std::string("can't set an element of a ") + object.typeName() + " value"); + throw RuntimeError("can't set an element of " + aTypeName(object) + " value"); } } @@ -1005,16 +1265,17 @@ Value Interpreter::slice(const Value& object, const Value& start, const Value& e if (object.isList()) { length = object.asList().items.size(); } else if (object.isString()) { - length = object.asString().size(); + length = utf8Length(object.asString()); } else { - throw RuntimeError(std::string("can't slice a ") + object.typeName() + " value"); + throw RuntimeError("can't slice " + aTypeName(object) + " value"); } const int64_t size = static_cast(length); auto bound = [size](const Value& value, int64_t fallback) -> int64_t { if (value.isNull()) return fallback; - if (!value.isInt()) throw RuntimeError(std::string("slice bounds must be ints, not ") + value.typeName()); - int64_t i = value.asInt(); + if (!value.isInt()) throw RuntimeError("slice bounds must be integers, not " + aTypeName(value)); + int64_t i = 0; + if (!toInt64(value, i)) return size; // a huge u64 if (i < 0) i += size; return std::max(0, std::min(i, size)); }; @@ -1022,7 +1283,10 @@ Value Interpreter::slice(const Value& object, const Value& start, const Value& e int64_t to = std::max(from, bound(end, size)); if (object.isString()) { - return Value::fromString(object.asString().substr(static_cast(from), static_cast(to - from))); + const std::string& text = object.asString(); + size_t first = utf8Offset(text, static_cast(from)); + size_t last = utf8Offset(text, static_cast(to)); + return Value::fromString(text.substr(first, last - first)); } const auto& items = object.asList().items; return Value::fromList(newList(std::vector(items.begin() + from, items.begin() + to))); diff --git a/src/json.cpp b/src/json.cpp new file mode 100644 index 0000000..acc8ab1 --- /dev/null +++ b/src/json.cpp @@ -0,0 +1,325 @@ +// json_parse() and json_stringify(). + +#include "interpreter.hpp" +#include "numeric.hpp" +#include "stdlib.hpp" +#include "utf8.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace ascend { + +namespace { + +constexpr int kMaxJsonDepth = 500; + +class JsonParser { +public: + JsonParser(Interpreter& interpreter, const std::string& text) : interpreter_(interpreter), text_(text) {} + + Value parse() { + skipSpace(); + Value value = parseValue(0); + skipSpace(); + if (pos_ < text_.size()) fail("unexpected text after the JSON value"); + return value; + } + +private: + [[noreturn]] void fail(const std::string& message) const { + int line = 1; + int column = 1; + for (size_t i = 0; i < pos_ && i < text_.size(); ++i) { + if (text_[i] == '\n') { + ++line; + column = 1; + } else { + ++column; + } + } + throw RuntimeError("json_parse(): " + message + " (line " + std::to_string(line) + ", column " + + std::to_string(column) + ")"); + } + + void skipSpace() { + while (pos_ < text_.size() && + (text_[pos_] == ' ' || text_[pos_] == '\t' || text_[pos_] == '\n' || text_[pos_] == '\r')) { + ++pos_; + } + } + + bool consumeWord(const char* word) { + size_t length = std::char_traits::length(word); + if (text_.compare(pos_, length, word) != 0) return false; + pos_ += length; + return true; + } + + Value parseValue(int depth) { + if (depth > kMaxJsonDepth) fail("the JSON is nested too deeply"); + if (pos_ >= text_.size()) fail("unexpected end of input"); + char c = text_[pos_]; + if (c == '{') return parseObject(depth); + if (c == '[') return parseArray(depth); + if (c == '"') return Value::fromString(parseString()); + if (c == '-' || (c >= '0' && c <= '9')) return parseNumber(); + if (consumeWord("true")) return Value::fromBool(true); + if (consumeWord("false")) return Value::fromBool(false); + if (consumeWord("null")) return Value(); + fail(std::string("unexpected character '") + c + "'"); + } + + Value parseObject(int depth) { + ++pos_; // { + auto map = interpreter_.newMap(); + Value result = Value::fromMap(map); + skipSpace(); + if (pos_ < text_.size() && text_[pos_] == '}') { + ++pos_; + return result; + } + while (true) { + skipSpace(); + if (pos_ >= text_.size() || text_[pos_] != '"') fail("expected a string key"); + std::string key = parseString(); + skipSpace(); + if (pos_ >= text_.size() || text_[pos_] != ':') fail("expected ':' after the key"); + ++pos_; + skipSpace(); + map->set(Value::fromString(key), parseValue(depth + 1)); + skipSpace(); + if (pos_ < text_.size() && text_[pos_] == ',') { + ++pos_; + continue; + } + if (pos_ < text_.size() && text_[pos_] == '}') { + ++pos_; + return result; + } + fail("expected ',' or '}' in the object"); + } + } + + Value parseArray(int depth) { + ++pos_; // [ + auto list = interpreter_.newList(); + Value result = Value::fromList(list); + skipSpace(); + if (pos_ < text_.size() && text_[pos_] == ']') { + ++pos_; + return result; + } + while (true) { + skipSpace(); + list->items.push_back(parseValue(depth + 1)); + skipSpace(); + if (pos_ < text_.size() && text_[pos_] == ',') { + ++pos_; + continue; + } + if (pos_ < text_.size() && text_[pos_] == ']') { + ++pos_; + return result; + } + fail("expected ',' or ']' in the array"); + } + } + + unsigned hex4() { + if (pos_ + 4 > text_.size()) fail("incomplete \\u escape"); + unsigned value = 0; + for (int i = 0; i < 4; ++i) { + char c = text_[pos_++]; + value <<= 4; + if (c >= '0' && c <= '9') { + value |= static_cast(c - '0'); + } else if (c >= 'a' && c <= 'f') { + value |= static_cast(c - 'a' + 10); + } else if (c >= 'A' && c <= 'F') { + value |= static_cast(c - 'A' + 10); + } else { + fail("invalid \\u escape"); + } + } + return value; + } + + std::string parseString() { + ++pos_; // opening quote + std::string out; + while (true) { + if (pos_ >= text_.size()) fail("unterminated string"); + char c = text_[pos_++]; + if (c == '"') return out; + if (static_cast(c) < 0x20) fail("control character in a string"); + if (c != '\\') { + out += c; + continue; + } + if (pos_ >= text_.size()) fail("unterminated string"); + char e = text_[pos_++]; + switch (e) { + case '"': out += '"'; break; + case '\\': out += '\\'; break; + case '/': out += '/'; break; + case 'b': out += '\b'; break; + case 'f': out += '\f'; break; + case 'n': out += '\n'; break; + case 'r': out += '\r'; break; + case 't': out += '\t'; break; + case 'u': { + char32_t cp = hex4(); + if (cp >= 0xD800 && cp <= 0xDBFF && text_.compare(pos_, 2, "\\u") == 0) { + pos_ += 2; + char32_t low = hex4(); + if (low >= 0xDC00 && low <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00); + } else { + cp = kReplacementChar; + } + } + appendUtf8(out, cp); + break; + } + default: fail(std::string("invalid escape '\\") + e + "'"); + } + } + } + + Value parseNumber() { + size_t start = pos_; + if (text_[pos_] == '-') ++pos_; + bool isFloat = false; + while (pos_ < text_.size()) { + char c = text_[pos_]; + if (c >= '0' && c <= '9') { + ++pos_; + } else if (c == '.' || c == 'e' || c == 'E' || ((c == '+' || c == '-') && isFloat)) { + isFloat = true; + ++pos_; + } else { + break; + } + } + std::string number = text_.substr(start, pos_ - start); + if (number == "-") fail("invalid number"); + if (!isFloat) { + errno = 0; + char* end = nullptr; + long long value = std::strtoll(number.c_str(), &end, 10); + if (errno != ERANGE && end == number.c_str() + number.size()) return Value::fromInt(value); + } + char* end = nullptr; + double value = std::strtod(number.c_str(), &end); + if (end != number.c_str() + number.size()) fail("invalid number '" + number + "'"); + return Value::fromFloat(value); + } + + Interpreter& interpreter_; + const std::string& text_; + size_t pos_ = 0; +}; + +void writeJsonString(std::string& out, const std::string& text) { + out += '"'; + for (char c : text) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + default: + if (static_cast(c) < 0x20) { + char buffer[8]; + std::snprintf(buffer, sizeof buffer, "\\u%04x", static_cast(c)); + out += buffer; + } else { + out += c; + } + } + } + out += '"'; +} + +void writeJson(std::string& out, const Value& value, int indent, int level, std::vector& active) { + auto newline = [&](int depth) { + if (indent <= 0) return; + out += '\n'; + out.append(static_cast(indent * depth), ' '); + }; + switch (value.type()) { + case Value::Type::Null: out += "null"; return; + case Value::Type::Bool: out += value.asBool() ? "true" : "false"; return; + case Value::Type::Int: out += intToString(value); return; + case Value::Type::Float: + if (!std::isfinite(value.asFloat())) { + throw RuntimeError("json_stringify(): JSON can't represent " + toString(value)); + } + out += formatFloat(value.asFloat(), value.numKind() == NumKind::F32); + return; + case Value::Type::Char: writeJsonString(out, encodeUtf8(value.asChar())); return; + case Value::Type::String: writeJsonString(out, value.asString()); return; + case Value::Type::Function: throw RuntimeError("json_stringify(): functions can't be converted to JSON"); + case Value::Type::List: + case Value::Type::Map: break; + } + + const void* identity = value.isList() ? static_cast(value.listPtr().get()) + : static_cast(value.mapPtr().get()); + if (std::find(active.begin(), active.end(), identity) != active.end()) { + throw RuntimeError("json_stringify(): the value contains itself, which JSON can't represent"); + } + if (active.size() > static_cast(kMaxJsonDepth)) throw RuntimeError("json_stringify(): value is nested too deeply"); + active.push_back(identity); + + if (value.isList()) { + const auto& items = value.asList().items; + out += '['; + for (size_t i = 0; i < items.size(); ++i) { + if (i > 0) out += ','; + newline(level + 1); + writeJson(out, items[i], indent, level + 1, active); + } + if (!items.empty()) newline(level); + out += ']'; + } else { + const auto& entries = value.asMap().entries; + out += '{'; + for (size_t i = 0; i < entries.size(); ++i) { + const Value& key = entries[i].first; + if (!key.isString() && !key.isChar() && !key.isNumber() && !key.isBool()) { + throw RuntimeError(std::string("json_stringify(): a ") + key.typeName() + " can't be a JSON object key"); + } + if (i > 0) out += ','; + newline(level + 1); + writeJsonString(out, toString(key)); + out += indent > 0 ? ": " : ":"; + writeJson(out, entries[i].second, indent, level + 1, active); + } + if (!entries.empty()) newline(level); + out += '}'; + } + active.pop_back(); +} + +} // namespace + +Value jsonParse(Interpreter& interpreter, const std::string& text) { return JsonParser(interpreter, text).parse(); } + +std::string jsonStringify(const Value& value, int indent) { + std::string out; + std::vector active; + writeJson(out, value, indent, 0, active); + return out; +} + +} // namespace ascend diff --git a/src/lexer.cpp b/src/lexer.cpp index c1021f1..e6bb78c 100644 --- a/src/lexer.cpp +++ b/src/lexer.cpp @@ -1,5 +1,7 @@ #include "lexer.hpp" +#include "utf8.hpp" + #include #include #include @@ -21,6 +23,7 @@ const std::unordered_map& keywordTable() { {"true", TokenType::True}, {"false", TokenType::False}, {"null", TokenType::Null}, {"try", TokenType::Try}, {"catch", TokenType::Catch}, {"throw", TokenType::Throw}, + {"const", TokenType::Const}, {"match", TokenType::Match}, }; return table; } @@ -29,11 +32,11 @@ bool isDigit(char c) { return c >= '0' && c <= '9'; } bool isIdentifierStart(char c) { return std::isalpha(static_cast(c)) || c == '_'; } bool isIdentifierChar(char c) { return std::isalnum(static_cast(c)) || c == '_'; } -int hexValue(char c) { +int digitValue(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; + return 99; } } // namespace @@ -46,6 +49,12 @@ std::string describeToken(const Token& token) { case TokenType::Int: case TokenType::Float: return "number " + token.lexeme; case TokenType::String: return "string " + token.lexeme; + case TokenType::Char: return "char " + token.lexeme; + case TokenType::FStringStart: return "f-string"; + case TokenType::FStringEnd: return "end of the f-string"; + case TokenType::InterpStart: return "'{'"; + case TokenType::InterpEnd: return "'}'"; + case TokenType::FormatSpec: return "format spec"; default: return "'" + token.lexeme + "'"; } } @@ -76,7 +85,7 @@ std::vector Lexer::tokenize() { for (auto it = tokens_.rbegin(); it != tokens_.rend(); ++it) { if (it->type != TokenType::Newline) { eof.line = it->line; - eof.column = it->column + static_cast(it->lexeme.size()); + eof.column = it->column + static_cast(utf8Length(it->lexeme)); break; } } @@ -115,12 +124,7 @@ void Lexer::error(int line, int column, std::string message) { void Lexer::emitNewline() { if (!brackets_.empty() && brackets_.back() != '{') return; // inside ( ) or [ ] if (tokens_.empty() || tokens_.back().type == TokenType::Newline) return; - Token token; - token.type = TokenType::Newline; - token.lexeme = "\n"; - token.line = line_; - token.column = column_; - tokens_.push_back(token); + addAt(TokenType::Newline, line_, column_, "\n"); } void Lexer::closeBracket() { @@ -163,12 +167,14 @@ void Lexer::blockComment() { error(startLine, startColumn, "unterminated block comment (missing '*/')"); } -void Lexer::add(TokenType type) { +void Lexer::add(TokenType type) { addAt(type, startLine_, startColumn_, source_.substr(start_, pos_ - start_)); } + +void Lexer::addAt(TokenType type, int line, int column, std::string lexeme) { Token token; token.type = type; - token.lexeme = source_.substr(start_, pos_ - start_); - token.line = startLine_; - token.column = startColumn_; + token.lexeme = std::move(lexeme); + token.line = line; + token.column = column; tokens_.push_back(std::move(token)); } @@ -178,12 +184,16 @@ void Lexer::scanToken() { startColumn_ = column_; char c = advance(); + if (c == 'f' && peek() == '"') { + fString(); + return; + } if (isIdentifierStart(c)) { identifier(); return; } if (isDigit(c)) { - number(); + number(c); return; } @@ -198,9 +208,16 @@ void Lexer::scanToken() { case '.': add(TokenType::Dot); return; case ';': add(TokenType::Semicolon); return; case '?': add(TokenType::Question); return; + case '~': add(TokenType::Tilde); return; case ':': add(match('=') ? TokenType::Declare : TokenType::Colon); return; case '+': add(match('=') ? TokenType::PlusEqual : TokenType::Plus); return; - case '-': add(match('=') ? TokenType::MinusEqual : TokenType::Minus); return; + case '-': + if (match('>')) { + add(TokenType::Arrow); + } else { + add(match('=') ? TokenType::MinusEqual : TokenType::Minus); + } + return; case '*': if (match('*')) { add(TokenType::StarStar); @@ -210,28 +227,49 @@ void Lexer::scanToken() { return; case '/': add(match('=') ? TokenType::SlashEqual : TokenType::Slash); return; case '%': add(match('=') ? TokenType::PercentEqual : TokenType::Percent); return; + case '^': add(match('=') ? TokenType::CaretEqual : TokenType::Caret); return; case '!': add(match('=') ? TokenType::NotEqual : TokenType::Not); return; - case '=': add(match('=') ? TokenType::EqualEqual : TokenType::Equal); return; - case '<': add(match('=') ? TokenType::LessEqual : TokenType::Less); return; - case '>': add(match('=') ? TokenType::GreaterEqual : TokenType::Greater); return; + case '=': + if (match('>')) { + add(TokenType::FatArrow); + } else { + add(match('=') ? TokenType::EqualEqual : TokenType::Equal); + } + return; + case '<': + if (match('<')) { + add(match('=') ? TokenType::ShiftLeftEqual : TokenType::ShiftLeft); + } else { + add(match('=') ? TokenType::LessEqual : TokenType::Less); + } + return; + case '>': + if (match('>')) { + add(match('=') ? TokenType::ShiftRightEqual : TokenType::ShiftRight); + } else { + add(match('=') ? TokenType::GreaterEqual : TokenType::Greater); + } + return; case '&': if (match('&')) { add(TokenType::And); } else { - error(startLine_, startColumn_, "unexpected character '&' (use '&&' or 'and' for logical AND)"); + add(match('=') ? TokenType::AmpersandEqual : TokenType::Ampersand); } return; case '|': if (match('|')) { add(TokenType::Or); } else { - error(startLine_, startColumn_, "unexpected character '|' (use '||' or 'or' for logical OR)"); + add(match('=') ? TokenType::PipeEqual : TokenType::Pipe); } return; case '"': - case '\'': string(c); return; + case '\'': + character(); + return; default: break; } @@ -258,104 +296,324 @@ void Lexer::identifier() { add(it == keywordTable().end() ? TokenType::Identifier : it->second); } -void Lexer::number() { - auto digits = [this] { - while (isDigit(peek()) || (peek() == '_' && isDigit(peek(1)))) advance(); - }; +// Numbers: 42, 1_000, 3.14, 1e9, 0xFF, 0b1010, 0o17, with an optional type +// suffix: 200u8, 7i64, 1.5f32. +void Lexer::number(char first) { + int base = 10; + if (first == '0' && (peek() == 'x' || peek() == 'X' || peek() == 'b' || peek() == 'B' || peek() == 'o' || + peek() == 'O')) { + char prefix = static_cast(std::tolower(static_cast(advance()))); + base = prefix == 'x' ? 16 : (prefix == 'b' ? 2 : 8); + } - digits(); + std::string digits; + if (base == 10) digits += first; bool isFloat = false; - if (peek() == '.' && isDigit(peek(1))) { - isFloat = true; - advance(); - digits(); - } - if ((peek() == 'e' || peek() == 'E') && - (isDigit(peek(1)) || ((peek(1) == '+' || peek(1) == '-') && isDigit(peek(2))))) { - isFloat = true; - advance(); - if (peek() == '+' || peek() == '-') advance(); - digits(); - } - if (isIdentifierChar(peek())) { + auto readDigits = [&](int digitBase) { + while (true) { + char c = peek(); + if (c == '_' && digitValue(peek(1)) < digitBase) { + advance(); + continue; + } + if (digitValue(c) >= digitBase) return; + // In decimal numbers, 'e' is an exponent and other letters are suffixes. + if (digitBase == 10 && !isDigit(c)) return; + digits += advance(); + } + }; + readDigits(base); + + if (base == 10) { + if (peek() == '.' && isDigit(peek(1))) { + isFloat = true; + digits += advance(); + readDigits(10); + } + if ((peek() == 'e' || peek() == 'E') && + (isDigit(peek(1)) || ((peek(1) == '+' || peek(1) == '-') && isDigit(peek(2))))) { + isFloat = true; + digits += advance(); + if (peek() == '+' || peek() == '-') digits += advance(); + readDigits(10); + } + } else if (digits.empty() || isDigit(peek())) { while (isIdentifierChar(peek())) advance(); - error(startLine_, startColumn_, - "invalid number '" + source_.substr(start_, pos_ - start_) + "'"); + error(startLine_, startColumn_, "invalid number '" + source_.substr(start_, pos_ - start_) + "'"); return; } - std::string text; - for (size_t i = start_; i < pos_; ++i) { - if (source_[i] != '_') text += source_[i]; + // Optional type suffix, possibly after an underscore: 255u8 or 255_u8. + NumKind kind = isFloat ? NumKind::Float : NumKind::Int; + bool hasSuffix = false; + if (peek() == '_' && isIdentifierStart(peek(1))) advance(); + if (isIdentifierStart(peek())) { + size_t suffixStart = pos_; + while (isIdentifierChar(peek())) advance(); + std::string suffix = source_.substr(suffixStart, pos_ - suffixStart); + TypeSpec spec; + bool validSuffix = lookupType(suffix, spec) && spec.kind == TypeKind::Number && suffix != "int" && + suffix != "float" && suffix != "byte"; + if (!validSuffix) { + error(startLine_, startColumn_, "invalid number '" + source_.substr(start_, pos_ - start_) + "'"); + return; + } + if (isFloat && isIntKind(spec.num)) { + error(startLine_, startColumn_, + "'" + suffix + "' is an integer type, but " + source_.substr(start_, suffixStart - start_) + + " has a fraction or exponent"); + return; + } + if (base != 10 && isFloatKind(spec.num)) { + error(startLine_, startColumn_, "hex, binary and octal numbers can't have a float suffix"); + return; + } + kind = spec.num; + hasSuffix = true; + if (isFloatKind(kind)) isFloat = true; } if (isFloat) { add(TokenType::Float); - tokens_.back().floatValue = std::strtod(text.c_str(), nullptr); + tokens_.back().floatValue = std::strtod(digits.c_str(), nullptr); + tokens_.back().numKind = kind; + tokens_.back().hasSuffix = hasSuffix; return; } - const int64_t max = std::numeric_limits::max(); - int64_t value = 0; - for (char digit : text) { - int d = digit - '0'; - if (value > (max - d) / 10) { + uint64_t value = 0; + const uint64_t max = std::numeric_limits::max(); + for (char digit : digits) { + uint64_t d = static_cast(digitValue(digit)); + if (value > (max - d) / static_cast(base)) { error(startLine_, startColumn_, - "integer " + text + " is too large (the maximum is " + std::to_string(max) + ")"); + "number " + source_.substr(start_, pos_ - start_) + " is too large (the maximum is " + + std::to_string(max) + ")"); return; } - value = value * 10 + d; + value = value * static_cast(base) + d; } add(TokenType::Int); tokens_.back().intValue = value; + tokens_.back().numKind = kind; + tokens_.back().hasSuffix = hasSuffix; +} + +// Reads one escape sequence after a backslash. +bool Lexer::escape(char32_t& out) { + int escapeLine = line_; + int escapeColumn = column_ - 1; + if (atEnd() || peek() == '\n') return false; // reported as unterminated by the caller + char c = advance(); + switch (c) { + case 'n': out = '\n'; return true; + case 't': out = '\t'; return true; + case 'r': out = '\r'; return true; + case '0': out = '\0'; return true; + case '\\': out = '\\'; return true; + case '"': out = '"'; return true; + case '\'': out = '\''; return true; + case '{': out = '{'; return true; + case '}': out = '}'; return true; + case 'x': { + int high = digitValue(peek()); + int low = high < 16 ? digitValue(peek(1)) : 99; + if (high >= 16 || low >= 16) { + error(escapeLine, escapeColumn, "invalid escape '\\x' (expected two hex digits, like \\x41)"); + out = kReplacementChar; + return true; + } + advance(); + advance(); + out = static_cast(high * 16 + low); + return true; + } + case 'u': { + if (!match('{')) { + error(escapeLine, escapeColumn, "invalid escape '\\u' (write it like \\u{1F600})"); + out = kReplacementChar; + return true; + } + uint32_t cp = 0; + int count = 0; + while (digitValue(peek()) < 16 && count < 7) { + cp = cp * 16 + static_cast(digitValue(advance())); + ++count; + } + if (count == 0 || !match('}') || !isValidCodePoint(cp)) { + error(escapeLine, escapeColumn, "invalid unicode escape (write it like \\u{1F600})"); + out = kReplacementChar; + return true; + } + out = cp; + return true; + } + default: + error(escapeLine, escapeColumn, std::string("unknown escape sequence '\\") + c + "'"); + out = static_cast(c); + return true; + } +} + +// Reads one (possibly multi-byte) character from the source. +char32_t Lexer::sourceChar() { + size_t before = pos_; + char32_t cp = decodeUtf8(source_, before); + while (pos_ < before) advance(); + return cp; } void Lexer::string(char quote) { std::string value; while (true) { if (atEnd() || peek() == '\n') { - error(startLine_, startColumn_, - std::string("unterminated string (missing closing ") + quote + ")"); + error(startLine_, startColumn_, std::string("unterminated string (missing closing ") + quote + ")"); return; } - char c = advance(); - if (c == quote) break; - if (c != '\\') { - value += c; + char c = peek(); + if (c == quote) { + advance(); + break; + } + if (c == '\\') { + advance(); + char32_t escaped; + if (escape(escaped)) appendUtf8(value, escaped); continue; } + value += advance(); + } + add(TokenType::String); + tokens_.back().text = std::move(value); +} - int escapeLine = line_; - int escapeColumn = column_ - 1; - if (atEnd() || peek() == '\n') continue; // reported as unterminated above - char escaped = advance(); - switch (escaped) { - case 'n': value += '\n'; break; - case 't': value += '\t'; break; - case 'r': value += '\r'; break; - case '0': value += '\0'; break; - case '\\': value += '\\'; break; - case '"': value += '"'; break; - case '\'': value += '\''; break; - case 'x': { - int high = hexValue(peek()); - int low = high < 0 ? -1 : hexValue(peek(1)); - if (high < 0 || low < 0) { - error(escapeLine, escapeColumn, "invalid escape '\\x' (expected two hex digits, like \\x41)"); - break; - } - advance(); - advance(); - value += static_cast(high * 16 + low); - break; +// 'a', '\n', '\u{1F600}': exactly one character. +void Lexer::character() { + char32_t value = 0; + if (atEnd() || peek() == '\n' || peek() == '\'') { + error(startLine_, startColumn_, + peek() == '\'' ? "empty char literal ''" : "unterminated char literal (missing closing ')"); + if (peek() == '\'') advance(); + return; + } + if (peek() == '\\') { + advance(); + if (!escape(value)) { + error(startLine_, startColumn_, "unterminated char literal (missing closing ')"); + return; + } + } else { + value = sourceChar(); + } + if (!match('\'')) { + // Probably a string written with single quotes; skip to the closing quote. + while (!atEnd() && peek() != '\n' && peek() != '\'') { + if (peek() == '\\') advance(); + if (!atEnd() && peek() != '\n') advance(); + } + bool closed = match('\''); + error(startLine_, startColumn_, + closed ? "a char literal holds exactly one character; use double quotes for strings" + : "unterminated char literal (missing closing ')"); + return; + } + add(TokenType::Char); + tokens_.back().charValue = value; +} + +// f"Hello, {name}! You are {age:>3} years old." +void Lexer::fString() { + const int fLine = startLine_; + const int fColumn = startColumn_; + advance(); // opening quote + addAt(TokenType::FStringStart, fLine, fColumn, "f\""); + + std::string literal; + int literalLine = line_; + int literalColumn = column_; + auto flushLiteral = [&] { + if (!literal.empty()) { + addAt(TokenType::String, literalLine, literalColumn, literal); + tokens_.back().text = literal; + literal.clear(); + } + literalLine = line_; + literalColumn = column_; + }; + + while (true) { + if (atEnd() || peek() == '\n') { + error(fLine, fColumn, "unterminated f-string (missing closing \")"); + return; + } + char c = peek(); + if (c == '"') { + flushLiteral(); + addAt(TokenType::FStringEnd, line_, column_, "\""); + advance(); + return; + } + if (c == '\\') { + advance(); + char32_t escaped; + if (escape(escaped)) appendUtf8(literal, escaped); + continue; + } + if (c == '}') { + advance(); + if (match('}')) { + literal += '}'; + } else { + error(line_, column_ - 1, "single '}' in f-string (write '}}' for a literal brace)"); } - default: - error(escapeLine, escapeColumn, std::string("unknown escape sequence '\\") + escaped + "'"); - break; + continue; + } + if (c != '{') { + literal += advance(); + continue; } + if (peek(1) == '{') { + advance(); + advance(); + literal += '{'; + continue; + } + + // An interpolation: {expression} or {expression:spec} + flushLiteral(); + addAt(TokenType::InterpStart, line_, column_, "{"); + advance(); + const size_t depth = brackets_.size(); + bool sawExpression = false; + while (true) { + while (peek() == ' ' || peek() == '\t') advance(); + if (atEnd() || peek() == '\n') { + error(fLine, fColumn, "unterminated f-string (missing closing \")"); + return; + } + if (brackets_.size() == depth && (peek() == '}' || (peek() == ':' && peek(1) != '='))) break; + scanToken(); + sawExpression = true; + } + if (!sawExpression) error(line_, column_, "empty {} in f-string (write {{ for a literal brace)"); + if (peek() == ':') { + int specLine = line_; + int specColumn = column_; + advance(); + std::string spec; + while (!atEnd() && peek() != '}' && peek() != '\n' && peek() != '"') spec += advance(); + addAt(TokenType::FormatSpec, specLine, specColumn, ":" + spec); + tokens_.back().text = spec; + if (peek() != '}') { + error(fLine, fColumn, "unterminated {...} in f-string (missing '}')"); + return; + } + } + addAt(TokenType::InterpEnd, line_, column_, "}"); + advance(); + literalLine = line_; + literalColumn = column_; } - add(TokenType::String); - tokens_.back().text = std::move(value); } } // namespace ascend diff --git a/src/main.cpp b/src/main.cpp index ab9d51d..d89a7f8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,7 @@ void printUsage(std::ostream& out) { " ascend start the interactive REPL\n" " ascend [args...] run a program\n" " ascend -e [args...] run code given on the command line\n" + " ascend --check check a program for errors without running it\n" " ascend -h | --help show this help\n" " ascend -v | --version show the version\n" "\n" @@ -26,8 +27,7 @@ bool readFile(const std::string& path, std::string& contents) { std::ifstream file(path, std::ios::binary); if (!file) return false; contents.assign(std::istreambuf_iterator(file), std::istreambuf_iterator()); - if (contents.compare(0, 3, "\xEF\xBB\xBF") == 0) contents.erase(0, 3); // UTF-8 byte order mark - if (contents.compare(0, 2, "#!") == 0) contents.replace(0, 2, "//"); // allow #!/usr/bin/env ascend + ascend::prepareSource(contents); return true; } @@ -55,6 +55,23 @@ int main(int argc, char* argv[]) { std::string source; ascend::RunOptions options; size_t scriptArgsStart; + if (first == "--check") { + if (args.size() != 2) { + std::cerr << "error: --check needs exactly one file\n\n"; + printUsage(std::cerr); + return 1; + } + if (!readFile(args[1], source)) { + std::cerr << "error: can't open file '" << args[1] << "'\n"; + return 1; + } + options.filename = args[1]; + options.checkOnly = true; + ascend::Interpreter interpreter; + ascend::RunResult result = ascend::runSource(interpreter, source, options, std::cerr); + if (result.status == ascend::RunStatus::Ok) std::cout << args[1] << ": no errors found\n"; + return result.exitCode; + } if (first == "-e" || first == "--eval") { if (args.size() < 2) { std::cerr << "error: " << first << " needs some code to run\n\n"; diff --git a/src/numeric.cpp b/src/numeric.cpp new file mode 100644 index 0000000..e37061f --- /dev/null +++ b/src/numeric.cpp @@ -0,0 +1,369 @@ +#include "numeric.hpp" + +#include +#include + +namespace ascend { + +namespace { + +constexpr int64_t kI64Min = std::numeric_limits::min(); +constexpr int64_t kI64Max = std::numeric_limits::max(); +constexpr uint64_t kU64Max = std::numeric_limits::max(); + +const char* symbol(NumOp op) { + switch (op) { + case NumOp::Add: return "+"; + case NumOp::Sub: return "-"; + case NumOp::Mul: return "*"; + case NumOp::Div: return "/"; + case NumOp::Mod: return "%"; + case NumOp::Pow: return "**"; + case NumOp::BitAnd: return "&"; + case NumOp::BitOr: return "|"; + case NumOp::BitXor: return "^"; + case NumOp::Shl: return "<<"; + case NumOp::Shr: return ">>"; + } + return "?"; +} + +std::string describeKind(NumKind kind) { return std::string(numKindName(kind)) + " (" + rangeText(kind) + ")"; } + +[[noreturn]] void overflow(NumOp op, const Value& a, const Value& b, NumKind kind) { + throw RuntimeError("integer overflow: " + toString(a) + " " + symbol(op) + " " + toString(b) + + " doesn't fit in " + describeKind(kind)); +} + +uint64_t widthMask(int bits) { return bits >= 64 ? kU64Max : ((uint64_t(1) << bits) - 1); } + +bool fitsKind(const Value& v, NumKind kind) { + if (v.numKind() == NumKind::U64) { + uint64_t u = v.asUInt(); + if (isUnsignedKind(kind)) return u <= unsignedMax(kind); + return u <= static_cast(signedMax(kind)); + } + int64_t s = v.asInt(); + if (isUnsignedKind(kind)) return s >= 0 && static_cast(s) <= unsignedMax(kind); + return s >= signedMin(kind) && s <= signedMax(kind); +} + +// Reinterprets an integer that fits `kind` as that kind. The bit patterns of +// all integer kinds agree for values they have in common. +Value retag(const Value& v, NumKind kind) { return Value::fromInt(v.asInt(), kind); } + +// The type two integer operands are converted to. +NumKind peerIntKind(NumKind a, NumKind b) { + if (a == b) return a; + if (a == NumKind::Int) return b; + if (b == NumKind::Int) return a; + bool aUnsigned = isUnsignedKind(a); + bool bUnsigned = isUnsignedKind(b); + if (aUnsigned == bUnsigned) return bitWidth(a) >= bitWidth(b) ? a : b; + NumKind u = aUnsigned ? a : b; + NumKind s = aUnsigned ? b : a; + if (bitWidth(s) > bitWidth(u)) return s; + switch (bitWidth(u)) { + case 8: return NumKind::I16; + case 16: return NumKind::I32; + case 32: return NumKind::I64; + default: + throw RuntimeError(std::string("can't mix ") + numKindName(a) + " and " + numKindName(b) + + ": no integer type can hold both; convert one side first, like i64(x) or u64(x)"); + } +} + +NumKind peerFloatKind(const Value& a, const Value& b) { + NumKind x = a.isFloat() ? a.numKind() : NumKind::Float; + NumKind y = b.isFloat() ? b.numKind() : NumKind::Float; + if (x == y) return x; + if (x == NumKind::Float) return y; + if (y == NumKind::Float) return x; + return NumKind::F64; // f32 with f64 +} + +// Converts an operand to the operation's type, failing if it doesn't fit +// (for example the literal 300 used with a u8). +Value adopt(const Value& v, NumKind kind) { + if (v.numKind() == kind) return v; + if (!fitsKind(v, kind)) { + throw RuntimeError(toString(v) + " doesn't fit in " + describeKind(kind)); + } + return retag(v, kind); +} + +int64_t checkedSigned(NumOp op, int64_t x, int64_t y, bool& overflowed) { + overflowed = false; + switch (op) { + case NumOp::Add: + if ((y > 0 && x > kI64Max - y) || (y < 0 && x < kI64Min - y)) overflowed = true; + return overflowed ? 0 : x + y; + case NumOp::Sub: + if ((y < 0 && x > kI64Max + y) || (y > 0 && x < kI64Min + y)) overflowed = true; + return overflowed ? 0 : x - y; + case NumOp::Mul: { + if (x == 0 || y == 0) return 0; + if (x > 0) { + overflowed = y > 0 ? x > kI64Max / y : y < kI64Min / x; + } else { + overflowed = y > 0 ? x < kI64Min / y : x < kI64Max / y; + } + return overflowed ? 0 : x * y; + } + case NumOp::Div: + if (x == kI64Min && y == -1) { + overflowed = true; + return 0; + } + return x / y; // truncates toward zero + case NumOp::Mod: + return y == -1 ? 0 : x % y; + default: + return 0; + } +} + +uint64_t checkedUnsigned(NumOp op, uint64_t x, uint64_t y, bool& overflowed) { + overflowed = false; + switch (op) { + case NumOp::Add: + overflowed = x > kU64Max - y; + return x + y; + case NumOp::Sub: + overflowed = x < y; + return x - y; + case NumOp::Mul: + overflowed = x != 0 && y > kU64Max / x; + return x * y; + case NumOp::Div: return x / y; + case NumOp::Mod: return x % y; + default: return 0; + } +} + +Value intPower(const Value& base, const Value& exponent) { + if (exponent.isNegativeInt()) { + return Value::fromFloat(std::pow(base.toDouble(), exponent.toDouble())); + } + const NumKind kind = base.numKind(); + uint64_t remaining = exponent.asUInt(); + bool overflowed = false; + if (kind == NumKind::U64) { + uint64_t result = 1; + uint64_t factor = base.asUInt(); + while (remaining > 0 && !overflowed) { + if (remaining & 1) result = checkedUnsigned(NumOp::Mul, result, factor, overflowed); + remaining >>= 1; + if (remaining > 0 && !overflowed) factor = checkedUnsigned(NumOp::Mul, factor, factor, overflowed); + } + if (overflowed) overflow(NumOp::Pow, base, exponent, kind); + return Value::fromUInt(result, kind); + } + int64_t result = 1; + int64_t factor = base.asInt(); + while (remaining > 0 && !overflowed) { + if (remaining & 1) result = checkedSigned(NumOp::Mul, result, factor, overflowed); + remaining >>= 1; + if (remaining > 0 && !overflowed) factor = checkedSigned(NumOp::Mul, factor, factor, overflowed); + } + if (overflowed || result < signedMin(kind) || result > signedMax(kind) || + (isUnsignedKind(kind) && static_cast(result) > unsignedMax(kind))) { + overflow(NumOp::Pow, base, exponent, kind); + } + return Value::fromInt(result, kind); +} + +Value shift(NumOp op, const Value& value, const Value& amount) { + const NumKind kind = value.numKind(); + const int width = bitWidth(kind); + if (amount.isNegativeInt() || amount.asUInt() >= static_cast(width)) { + throw RuntimeError("shift amount " + toString(amount) + " is out of range for " + numKindName(kind) + + " (0 to " + std::to_string(width - 1) + ")"); + } + const int n = static_cast(amount.asUInt()); + const uint64_t mask = widthMask(width); + uint64_t bits = value.asUInt(); + + if (op == NumOp::Shl) { + // Bits shifted out of the type's width are dropped. + uint64_t result = (bits << n) & mask; + if (!isUnsignedKind(kind) && width < 64 && ((result >> (width - 1)) & 1)) result |= ~mask; // sign-extend + return Value::fromInt(static_cast(result), kind); + } + if (isUnsignedKind(kind)) return Value::fromInt(static_cast((bits & mask) >> n), kind); + int64_t signedValue = value.asInt(); + int64_t result = signedValue >= 0 ? (signedValue >> n) : ~(~signedValue >> n); // arithmetic shift + return Value::fromInt(result, kind); +} + +Value intOp(NumOp op, const Value& a, const Value& b) { + if (op == NumOp::Pow) return intPower(a, b); + if (op == NumOp::Shl || op == NumOp::Shr) return shift(op, a, b); + + // Fast path for the common case: arithmetic on two 64-bit signed ints of the same type. + if (a.numKind() == b.numKind() && (a.numKind() == NumKind::Int || a.numKind() == NumKind::I64) && + (op == NumOp::Add || op == NumOp::Sub || op == NumOp::Mul || op == NumOp::Div || op == NumOp::Mod)) { + if (op == NumOp::Div && b.asInt() == 0) throw RuntimeError("division by zero"); + if (op == NumOp::Mod && b.asInt() == 0) throw RuntimeError("modulo by zero"); + bool overflowed = false; + int64_t result = checkedSigned(op, a.asInt(), b.asInt(), overflowed); + if (overflowed) overflow(op, a, b, a.numKind()); + return Value::fromInt(result, a.numKind()); + } + + const NumKind kind = peerIntKind(a.numKind(), b.numKind()); + Value x = adopt(a, kind); + Value y = adopt(b, kind); + + if (op == NumOp::BitAnd || op == NumOp::BitOr || op == NumOp::BitXor) { + // Both operands fit the type, so the result does too. + uint64_t p = x.asUInt(); + uint64_t q = y.asUInt(); + uint64_t result = op == NumOp::BitAnd ? (p & q) : op == NumOp::BitOr ? (p | q) : (p ^ q); + return Value::fromInt(static_cast(result), kind); + } + + bool yIsZero = y.asInt() == 0; + if (op == NumOp::Div && yIsZero) throw RuntimeError("division by zero"); + if (op == NumOp::Mod && yIsZero) throw RuntimeError("modulo by zero"); + + bool overflowed = false; + if (kind == NumKind::U64) { + uint64_t result = checkedUnsigned(op, x.asUInt(), y.asUInt(), overflowed); + if (overflowed) overflow(op, a, b, kind); + return Value::fromUInt(result, kind); + } + int64_t result = checkedSigned(op, x.asInt(), y.asInt(), overflowed); + bool outOfRange = isUnsignedKind(kind) ? (result < 0 || static_cast(result) > unsignedMax(kind)) + : (result < signedMin(kind) || result > signedMax(kind)); + if (overflowed || outOfRange) overflow(op, a, b, kind); + return Value::fromInt(result, kind); +} + +Value floatOp(NumOp op, const Value& a, const Value& b) { + const NumKind kind = peerFloatKind(a, b); + double x = a.toDouble(); + double y = b.toDouble(); + switch (op) { + case NumOp::Add: return Value::fromFloat(x + y, kind); + case NumOp::Sub: return Value::fromFloat(x - y, kind); + case NumOp::Mul: return Value::fromFloat(x * y, kind); + case NumOp::Div: + if (y == 0.0) throw RuntimeError("division by zero"); + return Value::fromFloat(x / y, kind); + case NumOp::Mod: + if (y == 0.0) throw RuntimeError("modulo by zero"); + return Value::fromFloat(std::fmod(x, y), kind); + case NumOp::Pow: return Value::fromFloat(std::pow(x, y), kind); + default: + throw RuntimeError(std::string("'") + symbol(op) + "' needs integers, not " + a.typeName() + " and " + + b.typeName()); + } +} + +} // namespace + +Value numericOp(NumOp op, const Value& left, const Value& right) { + if (left.isInt() && right.isInt()) return intOp(op, left, right); + return floatOp(op, left, right); +} + +Value negateNumber(const Value& value) { + if (value.isFloat()) return Value::fromFloat(-value.asFloat(), value.numKind()); + const NumKind kind = value.numKind(); + if (isUnsignedKind(kind)) { + if (value.asInt() == 0) return value; + throw RuntimeError("can't negate " + toString(value) + ": " + numKindName(kind) + " is unsigned"); + } + if (value.asInt() == signedMin(kind)) { + throw RuntimeError("integer overflow: -(" + toString(value) + ") doesn't fit in " + describeKind(kind)); + } + return Value::fromInt(-value.asInt(), kind); +} + +Value bitwiseNot(const Value& value) { + const NumKind kind = value.numKind(); + if (isUnsignedKind(kind)) { + return Value::fromInt(static_cast(~value.asUInt() & widthMask(bitWidth(kind))), kind); + } + return Value::fromInt(~value.asInt(), kind); +} + +int compareNumbers(const Value& a, const Value& b) { + if (a.isInt() && b.isInt()) { + bool aNegative = a.isNegativeInt(); + bool bNegative = b.isNegativeInt(); + if (aNegative != bNegative) return aNegative ? -1 : 1; + if (aNegative) return a.asInt() < b.asInt() ? -1 : (a.asInt() > b.asInt() ? 1 : 0); + return a.asUInt() < b.asUInt() ? -1 : (a.asUInt() > b.asUInt() ? 1 : 0); + } + double x = a.toDouble(); + double y = b.toDouble(); + bool xNan = std::isnan(x); + bool yNan = std::isnan(y); + if (xNan || yNan) return xNan == yNan ? 0 : (xNan ? -1 : 1); + return x < y ? -1 : (x > y ? 1 : 0); +} + +bool numbersEqual(const Value& a, const Value& b) { + if (a.isInt() && b.isInt()) return compareNumbers(a, b) == 0; + return a.toDouble() == b.toDouble(); +} + +ConvertStatus convertNumber(const Value& value, NumKind kind, bool truncate, Value& out) { + if (!value.isNumber()) return ConvertStatus::NotANumber; + + if (isFloatKind(kind)) { + out = Value::fromFloat(value.toDouble(), kind); + return ConvertStatus::Ok; + } + + if (value.isInt()) { + if (!fitsKind(value, kind)) return ConvertStatus::OutOfRange; + out = retag(value, kind); + return ConvertStatus::Ok; + } + + double d = value.asFloat(); + if (!std::isfinite(d)) return ConvertStatus::NotFinite; + double whole = std::trunc(d); + if (!truncate && whole != d) return ConvertStatus::FractionLost; + if (isUnsignedKind(kind)) { + if (whole < 0 || whole >= 18446744073709551616.0) return ConvertStatus::OutOfRange; + uint64_t u = static_cast(whole); + if (u > unsignedMax(kind)) return ConvertStatus::OutOfRange; + out = Value::fromUInt(u, kind); + return ConvertStatus::Ok; + } + if (whole < -9223372036854775808.0 || whole >= 9223372036854775808.0) return ConvertStatus::OutOfRange; + int64_t s = static_cast(whole); + if (s < signedMin(kind) || s > signedMax(kind)) return ConvertStatus::OutOfRange; + out = Value::fromInt(s, kind); + return ConvertStatus::Ok; +} + +Value convertNumberOrThrow(const Value& value, NumKind kind) { + Value out; + switch (convertNumber(value, kind, true, out)) { + case ConvertStatus::Ok: return out; + case ConvertStatus::OutOfRange: + throw RuntimeError(toString(value) + " doesn't fit in " + describeKind(kind)); + case ConvertStatus::NotFinite: + throw RuntimeError("can't convert " + toString(value) + " to " + numKindName(kind)); + default: + throw RuntimeError(std::string("can't convert a ") + value.typeName() + " to " + numKindName(kind)); + } +} + +bool toInt64(const Value& value, int64_t& out) { + if (!value.isInt() || !value.fitsInt64()) return false; + out = value.asInt(); + return true; +} + +std::string intToString(const Value& value) { + if (value.numKind() == NumKind::U64) return std::to_string(value.asUInt()); + return std::to_string(value.asInt()); +} + +} // namespace ascend diff --git a/src/parser.cpp b/src/parser.cpp index 6e0fac1..d7a9f6a 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -16,6 +16,11 @@ bool isAssignmentOperator(TokenType type) { case TokenType::StarEqual: case TokenType::SlashEqual: case TokenType::PercentEqual: + case TokenType::AmpersandEqual: + case TokenType::PipeEqual: + case TokenType::CaretEqual: + case TokenType::ShiftLeftEqual: + case TokenType::ShiftRightEqual: return true; default: return false; @@ -76,16 +81,21 @@ std::unique_ptr Parser::parseProgram() { return program; } +// Records facts about a block's statements that the interpreter relies on. +void Parser::noteStatement(Block& block, const Stmt& stmt) { + if (stmt.kind == StmtKind::Declare) block.declaresNames = true; + if (stmt.kind == StmtKind::Function) { + block.declaresNames = true; + block.hasFunctions = true; + } +} + // Parses one statement into `block`, recovering from syntax errors. void Parser::parseStatementInto(Block& block) { size_t start = current_; try { StmtPtr stmt = statement(); - if (stmt->kind == StmtKind::Declare) block.declaresNames = true; - if (stmt->kind == StmtKind::Function) { - block.declaresNames = true; - block.hasFunctions = true; - } + noteStatement(block, *stmt); block.statements.push_back(std::move(stmt)); } catch (const ParseError&) { synchronize(); @@ -105,6 +115,11 @@ StmtPtr Parser::statement() { case TokenType::Return: advance(); return returnStatement(token); case TokenType::Try: advance(); return tryStatement(token); case TokenType::Throw: advance(); return throwStatement(token); + case TokenType::Const: advance(); return constDeclaration(token); + case TokenType::Match: advance(); return matchStatement(token); + case TokenType::Identifier: + if (peek(1).type == TokenType::Colon) return typedDeclaration(); + break; case TokenType::Break: { advance(); auto stmt = nodeAt(token); @@ -251,6 +266,124 @@ StmtPtr Parser::throwStatement(const Token& keyword) { return stmt; } +// name: type = value or name: type (which starts at the type's zero value) +StmtPtr Parser::typedDeclaration() { + const Token& name = advance(); + advance(); // ':' + auto stmt = nodeAt(name); + stmt->name = name.lexeme; + stmt->type = parseType(false); + stmt->hasType = true; + if (check(TokenType::Declare)) { + error(peek(), "use '=' after a type, like '" + name.lexeme + ": " + stmt->type.name() + " = ...'"); + } + if (match(TokenType::Equal)) { + skipNewlines(); + stmt->value = expression(); + } + expectStatementEnd(); + return stmt; +} + +// const NAME = value or const NAME: type = value +StmtPtr Parser::constDeclaration(const Token& keyword) { + const Token& name = consume(TokenType::Identifier, "a name after 'const'"); + auto stmt = nodeAt(name); + stmt->name = name.lexeme; + stmt->constant = true; + if (match(TokenType::Colon)) { + stmt->type = parseType(false); + stmt->hasType = true; + } + if (!match(TokenType::Equal) && !match(TokenType::Declare)) { + error(peek(), "a constant needs a value: 'const " + name.lexeme + " = ...'"); + } + skipNewlines(); + stmt->value = expression(); + expectStatementEnd(); + (void)keyword; + return stmt; +} + +// match value { +// 1, 2 => print("small") +// else => { print("other") } +// } +StmtPtr Parser::matchStatement(const Token& keyword) { + auto stmt = nodeAt(keyword); + stmt->subject = condition("match"); + skipNewlines(); + const Token& open = consume(TokenType::LeftBrace, "'{' to start the match arms"); + NestingGuard guard(*this, open); + bool sawDefault = false; + while (true) { + skipSeparators(); + if (check(TokenType::RightBrace) || isAtEnd()) break; + size_t start = current_; + try { + MatchArm arm; + bool isDefault = check(TokenType::Else) || + (check(TokenType::Identifier) && peek().lexeme == "_" && peek(1).type == TokenType::FatArrow); + if (isDefault) { + const Token& defaultToken = advance(); + if (sawDefault) error(defaultToken, "a match can only have one default ('else') arm"); + sawDefault = true; + } else { + do { + arm.patterns.push_back(expression()); + } while (match(TokenType::Comma)); + } + consume(TokenType::FatArrow, "'=>' after the match pattern"); + skipNewlines(); + if (check(TokenType::LeftBrace)) { + arm.body = block("match arm"); + } else { + StmtPtr body = statement(); + noteStatement(arm.body, *body); + arm.body.statements.push_back(std::move(body)); + } + stmt->arms.push_back(std::move(arm)); + } catch (const ParseError&) { + synchronize(); + if (current_ == start && !isAtEnd()) advance(); + } + } + if (!check(TokenType::RightBrace)) { + error(peek(), "expected '}' to close the match that starts on line " + std::to_string(open.line) + + ", found " + describeToken(peek())); + } + advance(); + return stmt; +} + +// A type name like `i32`, `string` or `u8?` (the ? allows null). +TypeSpec Parser::parseType(bool allowVoid) { + const Token& token = peek(); + std::string name; + if (token.type == TokenType::Identifier) { + name = token.lexeme; + } else if (token.type == TokenType::Func) { + name = "func"; + } else { + error(token, "expected a type name (like i32, f64, string or bool), found " + describeToken(token)); + } + TypeSpec type; + if (!lookupType(name, type)) { + error(token, "unknown type '" + name + + "' (the types are: i8 i16 i32 i64 u8 u16 u32 u64 int byte f32 f64 float bool char string " + "list map func any)"); + } + if (type.kind == TypeKind::Void && !allowVoid) error(token, "'void' can only be used as a return type"); + advance(); + if (match(TokenType::Question)) { + if (type.kind == TypeKind::Any || type.kind == TypeKind::Void) { + error(previous(), "'" + name + "' can't be made nullable with '?'"); + } + type.nullable = true; + } + return type; +} + StmtPtr Parser::functionDeclaration(const Token& keyword) { const Token& name = advance(); auto stmt = nodeAt(keyword); @@ -267,6 +400,7 @@ std::unique_ptr Parser::functionRest(const Token& keyword, const s const Token& param = consume(TokenType::Identifier, "a parameter name"); Param parameter; parameter.name = param.lexeme; + if (match(TokenType::Colon)) parameter.type = parseType(false); if (match(TokenType::Equal)) { parameter.defaultValue = expression(); sawDefault = true; @@ -279,6 +413,7 @@ std::unique_ptr Parser::functionRest(const Token& keyword, const s if (!match(TokenType::Comma)) break; } consume(TokenType::RightParen, "')' after the parameters"); + if (match(TokenType::Arrow)) function->returnType = parseType(true); function->body = block("function body"); return function; } @@ -348,6 +483,8 @@ void Parser::synchronize() { case TokenType::Throw: case TokenType::Break: case TokenType::Continue: + case TokenType::Const: + case TokenType::Match: return; default: advance(); @@ -371,6 +508,9 @@ ExprPtr Parser::ternary() { skipNewlines(); expr->thenExpr = expression(); skipNewlines(); + if (check(TokenType::FormatSpec)) { + error(peek(), "in an f-string, put a conditional expression in parentheses: {(a ? b : c)}"); + } consume(TokenType::Colon, "':' in the conditional expression"); skipNewlines(); expr->elseExpr = ternary(); @@ -437,10 +577,20 @@ ExprPtr Parser::equality() { } ExprPtr Parser::comparison() { - return binary(&Parser::additive, {TokenType::Less, TokenType::LessEqual, TokenType::Greater, - TokenType::GreaterEqual, TokenType::In}); + return binary(&Parser::bitOr, {TokenType::Less, TokenType::LessEqual, TokenType::Greater, + TokenType::GreaterEqual, TokenType::In}); } +// Bitwise operators bind tighter than comparisons (unlike C), so +// `flags & 1 == 0` means `(flags & 1) == 0`. +ExprPtr Parser::bitOr() { return binary(&Parser::bitXor, {TokenType::Pipe}); } + +ExprPtr Parser::bitXor() { return binary(&Parser::bitAnd, {TokenType::Caret}); } + +ExprPtr Parser::bitAnd() { return binary(&Parser::shift, {TokenType::Ampersand}); } + +ExprPtr Parser::shift() { return binary(&Parser::additive, {TokenType::ShiftLeft, TokenType::ShiftRight}); } + ExprPtr Parser::additive() { return binary(&Parser::multiplicative, {TokenType::Plus, TokenType::Minus}); } ExprPtr Parser::multiplicative() { @@ -448,7 +598,22 @@ ExprPtr Parser::multiplicative() { } ExprPtr Parser::unary() { - if (check(TokenType::Minus) || check(TokenType::Not)) { + // A minus directly before a number literal is part of the literal, so + // `-128i8` works even though 128 alone doesn't fit in an i8. Not when the + // number is used further: -2 ** 2 is -(2 ** 2). + if (check(TokenType::Minus) && (peek(1).type == TokenType::Int || peek(1).type == TokenType::Float)) { + TokenType after = peek(2).type; + if (after != TokenType::StarStar && after != TokenType::Dot && after != TokenType::LeftParen && + after != TokenType::LeftBracket) { + const Token& minus = advance(); + const Token& number = advance(); + ExprPtr literal = numberLiteral(number, true); + auto expr = nodeAt(minus); + expr->value = literal->as().value; + return expr; + } + } + if (check(TokenType::Minus) || check(TokenType::Not) || check(TokenType::Tilde)) { const Token& op = advance(); NestingGuard guard(*this, op); auto expr = nodeAt(op); @@ -530,18 +695,19 @@ ExprPtr Parser::finishIndex(ExprPtr object, const Token& bracket) { ExprPtr Parser::primary() { const Token& token = peek(); switch (token.type) { - case TokenType::Int: { + case TokenType::Int: + case TokenType::Float: advance(); - auto literal = nodeAt(token); - literal->value = Value::fromInt(token.intValue); - return literal; - } - case TokenType::Float: { + return numberLiteral(token, false); + case TokenType::Char: { advance(); auto literal = nodeAt(token); - literal->value = Value::fromFloat(token.floatValue); + literal->value = Value::fromChar(token.charValue); return literal; } + case TokenType::FStringStart: + advance(); + return fString(token); case TokenType::String: { advance(); auto literal = nodeAt(token); @@ -627,6 +793,64 @@ ExprPtr Parser::mapLiteral(const Token& brace) { return map; } +// Builds a number literal, checking that it fits its type. +ExprPtr Parser::numberLiteral(const Token& token, bool negate) { + auto literal = nodeAt(token); + std::string text = (negate ? "-" : "") + token.lexeme; + if (token.type == TokenType::Float) { + literal->value = Value::fromFloat(negate ? -token.floatValue : token.floatValue, token.numKind); + return literal; + } + + const NumKind kind = token.numKind; + const uint64_t magnitude = token.intValue; + if (isUnsignedKind(kind)) { + if (negate && magnitude != 0) error(token, text + " is negative, but " + numKindName(kind) + " is unsigned"); + if (magnitude > unsignedMax(kind)) { + error(token, text + " doesn't fit in " + numKindName(kind) + " (" + rangeText(kind) + ")"); + } + literal->value = Value::fromUInt(magnitude, kind); + return literal; + } + + const uint64_t limit = negate ? static_cast(signedMax(kind)) + 1 : static_cast(signedMax(kind)); + if (magnitude > limit) { + if (kind == NumKind::Int) { + error(token, text + " is too large for int (" + rangeText(kind) + ")" + + (negate ? "" : "; add a suffix to use another type, like " + token.lexeme + "u64")); + } + error(token, text + " doesn't fit in " + numKindName(kind) + " (" + rangeText(kind) + ")"); + } + int64_t value = negate ? static_cast(0 - magnitude) : static_cast(magnitude); + literal->value = Value::fromInt(value, kind); + return literal; +} + +// f"Hello, {name}!" was lexed into its literal text and interpolations. +ExprPtr Parser::fString(const Token& start) { + auto expr = nodeAt(start); + while (!check(TokenType::FStringEnd)) { + if (check(TokenType::String)) { + FStringPart part; + part.text = advance().text; + expr->parts.push_back(std::move(part)); + continue; + } + consume(TokenType::InterpStart, "'{' or the end of the f-string"); + FStringPart part; + part.expr = expression(); + if (check(TokenType::FormatSpec)) part.spec = advance().text; + if (!check(TokenType::InterpEnd)) { + error(peek(), "expected '}' after the expression in the f-string, found " + describeToken(peek()) + + " (put a conditional expression in parentheses)"); + } + advance(); + expr->parts.push_back(std::move(part)); + } + advance(); // FStringEnd + return expr; +} + // --------------------------------------------------------------------------- // Token helpers diff --git a/src/runner.cpp b/src/runner.cpp index 822db4c..d636ec2 100644 --- a/src/runner.cpp +++ b/src/runner.cpp @@ -10,6 +10,11 @@ namespace ascend { +void prepareSource(std::string& text) { + if (text.compare(0, 3, "\xEF\xBB\xBF") == 0) text.erase(0, 3); // UTF-8 byte order mark + if (text.compare(0, 2, "#!") == 0) text.replace(0, 2, "//"); // allow #!/usr/bin/env ascend +} + RunResult runSource(Interpreter& interpreter, const std::string& source, const RunOptions& options, std::ostream& err) { auto report = [&](const std::vector& diagnostics, const char* kind) { @@ -19,6 +24,16 @@ RunResult runSource(Interpreter& interpreter, const std::string& source, const R } err.flush(); }; + // Runtime errors may come from an imported file. + auto reportAt = [&](const SourceFile* file, int line, int column, const std::string& message) { + interpreter.out().flush(); + if (file != nullptr) { + err << formatDiagnostic(file->text, file->name, line, column, "runtime error", message); + } else { + err << formatDiagnostic(source, options.filename, line, column, "runtime error", message); + } + err.flush(); + }; Lexer lexer(source); std::vector tokens = lexer.tokenize(); @@ -44,20 +59,24 @@ RunResult runSource(Interpreter& interpreter, const std::string& source, const R report(problems, "error"); return {RunStatus::SyntaxError, kExitSyntaxError}; } + if (options.checkOnly) return {RunStatus::Ok, 0}; + auto file = std::make_unique(); + file->name = options.filename; + file->text = source; try { - interpreter.execute(std::move(program)); + interpreter.execute(std::move(program), std::move(file)); } catch (const RuntimeError& error) { - report({{error.line, error.column, error.what()}}, "runtime error"); + reportAt(error.source, error.line, error.column, error.what()); return {RunStatus::RuntimeError, kExitRuntimeError}; } catch (const ThrownValue& thrown) { - report({{thrown.line, thrown.column, "uncaught error: " + toString(thrown.value)}}, "runtime error"); + reportAt(thrown.source, thrown.line, thrown.column, "uncaught error: " + toString(thrown.value)); return {RunStatus::RuntimeError, kExitRuntimeError}; } catch (const ExitRequest& exit) { interpreter.out().flush(); return {RunStatus::Exited, exit.code}; } catch (const std::bad_alloc&) { - report({{0, 0, "out of memory"}}, "runtime error"); + reportAt(nullptr, 0, 0, "out of memory"); return {RunStatus::RuntimeError, kExitRuntimeError}; } diff --git a/src/semantic_analyzer.cpp b/src/semantic_analyzer.cpp index 338bf4e..4a8d16b 100644 --- a/src/semantic_analyzer.cpp +++ b/src/semantic_analyzer.cpp @@ -7,11 +7,40 @@ namespace ascend { std::vector SemanticAnalyzer::analyze(const Program& program) { diagnostics_.clear(); loopDepth_ = 0; - functionDepth_ = 0; + functions_.clear(); visitBlock(program.body); return diagnostics_; } +void SemanticAnalyzer::checkType(const Expr* expr, const TypeSpec& type, + const std::function& describe) { + if (expr == nullptr || type.isAny()) return; + CoerceFailure failure; + Value value; + switch (expr->kind) { + case ExprKind::Literal: { + Value converted; + if (!coerceToType(expr->as().value, type, converted, failure)) { + report(expr->line, expr->column, describe(failure)); + } + return; + } + case ExprKind::List: value = Value::fromList(std::make_shared()); break; + case ExprKind::Map: value = Value::fromMap(std::make_shared()); break; + case ExprKind::FString: value = Value::fromString(""); break; + case ExprKind::Function: { + if (type.kind == TypeKind::Function) return; + failure.what = "a function"; + report(expr->line, expr->column, describe(failure)); + return; + } + default: + return; // only known when the program runs + } + Value converted; + if (!coerceToType(value, type, converted, failure)) report(expr->line, expr->column, describe(failure)); +} + void SemanticAnalyzer::report(int line, int column, std::string message) { diagnostics_.push_back({line, column, std::move(message)}); } @@ -25,9 +54,22 @@ void SemanticAnalyzer::visitStmt(const Stmt& stmt) { case StmtKind::Expression: visitExpr(stmt.as().expr.get()); break; - case StmtKind::Declare: - visitExpr(stmt.as().value.get()); + case StmtKind::Declare: { + const auto& decl = stmt.as(); + visitExpr(decl.value.get()); + if (decl.hasType) { + if (!decl.value && decl.type.kind == TypeKind::Function && !decl.type.nullable) { + report(stmt.line, stmt.column, + "'" + decl.name + "' is declared as func, so it needs a value (or declare it as func?)"); + } + checkType(decl.value.get(), decl.type, [&](const CoerceFailure& failure) { + return withHint("'" + decl.name + "' is declared as " + decl.type.name() + ", so it can't hold " + + failure.what, + failure); + }); + } break; + } case StmtKind::Assign: { const auto& assign = stmt.as(); visitExpr(assign.target.get()); @@ -69,10 +111,27 @@ void SemanticAnalyzer::visitStmt(const Stmt& stmt) { case StmtKind::Continue: if (loopDepth_ == 0) report(stmt.line, stmt.column, "'continue' can only be used inside a loop"); break; - case StmtKind::Return: - if (functionDepth_ == 0) report(stmt.line, stmt.column, "'return' can only be used inside a function"); - visitExpr(stmt.as().value.get()); + case StmtKind::Return: { + const auto& ret = stmt.as(); + visitExpr(ret.value.get()); + if (functions_.empty()) { + report(stmt.line, stmt.column, "'return' can only be used inside a function"); + break; + } + const FunctionExpr& function = *functions_.back(); + const TypeSpec& type = function.returnType; + std::string who = function.name.empty() ? "this function" : function.name + "()"; + if (type.kind == TypeKind::Void) { + if (ret.value) report(stmt.line, stmt.column, who + " is declared '-> void', so it can't return a value"); + } else if (!ret.value && !type.isAny() && !type.nullable) { + report(stmt.line, stmt.column, who + " must return " + type.name() + ", but this returns nothing"); + } else { + checkType(ret.value.get(), type, [&](const CoerceFailure& failure) { + return withHint(who + " must return " + type.name() + ", but this returns " + failure.what, failure); + }); + } break; + } case StmtKind::Function: visitFunction(*stmt.as().function); break; @@ -85,6 +144,15 @@ void SemanticAnalyzer::visitStmt(const Stmt& stmt) { case StmtKind::Throw: visitExpr(stmt.as().value.get()); break; + case StmtKind::Match: { + const auto& match = stmt.as(); + visitExpr(match.subject.get()); + for (const auto& arm : match.arms) { + for (const auto& pattern : arm.patterns) visitExpr(pattern.get()); + visitBlock(arm.body); + } + break; + } } } @@ -144,6 +212,9 @@ void SemanticAnalyzer::visitExpr(const Expr* expr) { case ExprKind::Function: visitFunction(expr->as()); break; + case ExprKind::FString: + for (const auto& part : expr->as().parts) visitExpr(part.expr.get()); + break; } } @@ -154,13 +225,18 @@ void SemanticAnalyzer::visitFunction(const FunctionExpr& function) { report(function.line, function.column, "parameter '" + param.name + "' is listed more than once"); } visitExpr(param.defaultValue.get()); + checkType(param.defaultValue.get(), param.type, [&](const CoerceFailure& failure) { + return withHint("parameter '" + param.name + "' is declared as " + param.type.name() + + ", so its default can't be " + failure.what, + failure); + }); } // A loop outside the function doesn't make `break` valid inside it. int savedLoopDepth = loopDepth_; loopDepth_ = 0; - ++functionDepth_; + functions_.push_back(&function); visitBlock(function.body); - --functionDepth_; + functions_.pop_back(); loopDepth_ = savedLoopDepth; } diff --git a/src/stdlib.cpp b/src/stdlib.cpp index 45a4ead..8df00c8 100644 --- a/src/stdlib.cpp +++ b/src/stdlib.cpp @@ -1,16 +1,26 @@ #include "stdlib.hpp" #include "interpreter.hpp" +#include "numeric.hpp" +#include "utf8.hpp" #include #include #include #include #include +#include #include +#include +#include #include #include #include +#include + +#if defined(__unix__) || defined(__APPLE__) +#include +#endif namespace ascend { @@ -20,14 +30,23 @@ using Args = std::vector; constexpr uint64_t kMaxRangeList = 10000000; +// --------------------------------------------------------------------------- +// Argument helpers + +std::string article(const Value& value) { return aTypeName(value); } + [[noreturn]] void typeError(const char* fn, size_t index, const char* expected, const Value& got) { throw RuntimeError(std::string(fn) + "() expects " + expected + " as argument " + std::to_string(index + 1) + - ", not " + (got.isNull() ? "null" : std::string("a ") + got.typeName())); + ", not " + article(got)); } int64_t intArg(const Args& args, size_t i, const char* fn) { - if (!args[i].isInt()) typeError(fn, i, "an int", args[i]); - return args[i].asInt(); + if (!args[i].isInt()) typeError(fn, i, "an integer", args[i]); + int64_t value = 0; + if (!toInt64(args[i], value)) { + throw RuntimeError(std::string(fn) + "(): argument " + std::to_string(i + 1) + " is too large"); + } + return value; } double numberArg(const Args& args, size_t i, const char* fn) { @@ -40,6 +59,13 @@ const std::string& stringArg(const Args& args, size_t i, const char* fn) { return args[i].asString(); } +// A string or a char, as text. +std::string textArg(const Args& args, size_t i, const char* fn) { + if (args[i].isChar()) return encodeUtf8(args[i].asChar()); + if (!args[i].isString()) typeError(fn, i, "a string", args[i]); + return args[i].asString(); +} + ListObj& listArg(const Args& args, size_t i, const char* fn) { if (!args[i].isList()) typeError(fn, i, "a list", args[i]); return args[i].asList(); @@ -55,16 +81,6 @@ const Value& functionArg(const Args& args, size_t i, const char* fn) { return args[i]; } -int64_t floatToInt(double value, const char* fn) { - if (!std::isfinite(value)) { - throw RuntimeError(std::string(fn) + "() can't convert " + formatFloat(value) + " to an int"); - } - if (value >= 9223372036854775808.0 || value < -9223372036854775808.0) { - throw RuntimeError(std::string(fn) + "(): " + formatFloat(value) + " is too large for an int"); - } - return static_cast(value); -} - std::string trim(const std::string& text) { const char* space = " \t\r\n\f\v"; size_t first = text.find_first_not_of(space); @@ -73,35 +89,117 @@ std::string trim(const std::string& text) { return text.substr(first, last - first + 1); } -bool parseInt(const std::string& text, int64_t& out) { - std::string s = trim(text); - if (s.empty()) return false; +std::string toUpper(std::string text) { + for (char& c : text) c = static_cast(std::toupper(static_cast(c))); + return text; +} + +std::string toLower(std::string text) { + for (char& c : text) c = static_cast(std::tolower(static_cast(c))); + return text; +} + +// Splits text into lines, without the line endings (\n or \r\n). +std::vector splitLines(const std::string& text) { + std::vector lines; + size_t start = 0; + while (start < text.size()) { + size_t end = text.find('\n', start); + if (end == std::string::npos) end = text.size(); + std::string line = text.substr(start, end - start); + if (!line.empty() && line.back() == '\r') line.pop_back(); + lines.push_back(Value::fromString(line)); + start = end + 1; + } + return lines; +} + +std::vector decodeAll(const std::string& text) { + std::vector chars; + for (size_t pos = 0; pos < text.size();) chars.push_back(decodeUtf8(text, pos)); + return chars; +} + +// Parses an integer written in decimal, or with a 0x / 0b / 0o prefix. +bool parseInteger(const std::string& input, Value& out) { + std::string text = trim(input); + bool negative = false; + size_t i = 0; + if (i < text.size() && (text[i] == '+' || text[i] == '-')) negative = text[i++] == '-'; + int base = 10; + if (text.size() > i + 1 && text[i] == '0') { + char prefix = static_cast(std::tolower(static_cast(text[i + 1]))); + if (prefix == 'x' || prefix == 'b' || prefix == 'o') { + base = prefix == 'x' ? 16 : (prefix == 'b' ? 2 : 8); + i += 2; + } + } + std::string digits; + for (; i < text.size(); ++i) { + if (text[i] != '_') digits += text[i]; + } + if (digits.empty() || digits[0] == '+' || digits[0] == '-') return false; errno = 0; char* end = nullptr; - long long value = std::strtoll(s.c_str(), &end, 10); - if (errno == ERANGE || end != s.c_str() + s.size()) return false; - out = value; + unsigned long long magnitude = std::strtoull(digits.c_str(), &end, base); + if (errno == ERANGE || end != digits.c_str() + digits.size()) return false; + if (negative) { + if (magnitude > 9223372036854775808ULL) return false; + out = Value::fromInt(static_cast(0 - static_cast(magnitude))); + } else if (magnitude > static_cast(std::numeric_limits::max())) { + out = Value::fromUInt(magnitude); + } else { + out = Value::fromInt(static_cast(magnitude)); + } return true; } -bool parseFloat(const std::string& text, double& out) { - std::string s = trim(text); - if (s.empty()) return false; +bool parseFloat(const std::string& input, double& out) { + std::string text = trim(input); + if (text.empty()) return false; char* end = nullptr; - double value = std::strtod(s.c_str(), &end); - if (end != s.c_str() + s.size()) return false; + double value = std::strtod(text.c_str(), &end); + if (end != text.c_str() + text.size()) return false; out = value; return true; } -std::string toUpper(std::string text) { - for (char& c : text) c = static_cast(std::toupper(static_cast(c))); - return text; +// The conversion functions int(), i8(), ..., u64(), byte(). +Value toIntegerKind(const Value& value, NumKind kind, const char* fn) { + switch (value.type()) { + case Value::Type::Int: + case Value::Type::Float: return convertNumberOrThrow(value, kind); + case Value::Type::Bool: return Value::fromInt(value.asBool() ? 1 : 0, kind); + case Value::Type::Char: return convertNumberOrThrow(Value::fromInt(static_cast(value.asChar())), kind); + case Value::Type::String: { + Value parsed; + if (parseInteger(value.asString(), parsed)) return convertNumberOrThrow(parsed, kind); + double number; + if (parseFloat(value.asString(), number)) { + throw RuntimeError(std::string(fn) + "() can't convert " + quoteString(value.asString()) + + " to an integer (it has a fraction; use float() first)"); + } + throw RuntimeError(std::string(fn) + "() can't convert " + quoteString(value.asString()) + " to an integer"); + } + default: + throw RuntimeError(std::string(fn) + "() can't convert " + article(value) + " to an integer"); + } } -std::string toLower(std::string text) { - for (char& c : text) c = static_cast(std::tolower(static_cast(c))); - return text; +// The conversion functions float(), f32(), f64(). +Value toFloatKind(const Value& value, NumKind kind, const char* fn) { + switch (value.type()) { + case Value::Type::Int: + case Value::Type::Float: return Value::fromFloat(value.toDouble(), kind); + case Value::Type::Bool: return Value::fromFloat(value.asBool() ? 1.0 : 0.0, kind); + case Value::Type::String: { + double number; + if (parseFloat(value.asString(), number)) return Value::fromFloat(number, kind); + throw RuntimeError(std::string(fn) + "() can't convert " + quoteString(value.asString()) + " to a float"); + } + default: + throw RuntimeError(std::string(fn) + "() can't convert " + article(value) + " to a float"); + } } Value extreme(const Args& args, const char* fn, int wanted) { @@ -118,6 +216,36 @@ Value extreme(const Args& args, const char* fn, int wanted) { return best; } +// Checks every character of a string or char with an ASCII classifier. +Value classify(const Args& args, const char* fn, int (*test)(int)) { + std::string text = textArg(args, 0, fn); + if (text.empty()) return Value::fromBool(false); + for (char c : text) { + unsigned char byte = static_cast(c); + if (byte >= 0x80 || !test(byte)) return Value::fromBool(false); + } + return Value::fromBool(true); +} + +std::string padText(const Args& args, const char* fn, bool left) { + std::string text = toString(args[0]); + int64_t width = intArg(args, 1, fn); + std::string fill = " "; + if (args.size() > 2) { + fill = textArg(args, 2, fn); + if (utf8Length(fill) != 1) throw RuntimeError(std::string(fn) + "() fill must be a single character"); + } + size_t length = utf8Length(text); + if (width <= static_cast(length)) return text; + if (width > (int64_t(1) << 24)) throw RuntimeError(std::string(fn) + "() width is too large"); + std::string padding; + for (int64_t i = static_cast(length); i < width; ++i) padding += fill; + return left ? padding + text : text + padding; +} + +// --------------------------------------------------------------------------- +// Builtins + void registerCore(Interpreter& interp) { interp.defineNative("print", 0, -1, [](Interpreter& in, Args& args) { std::ostream& out = in.out(); @@ -139,49 +267,15 @@ void registerCore(Interpreter& interp) { }); interp.defineNative("type", 1, 1, [](Interpreter&, Args& args) { return Value::fromString(args[0].typeName()); }); - interp.defineNative("str", 1, 1, [](Interpreter&, Args& args) { return Value::fromString(toString(args[0])); }); - + interp.defineNative("string", 1, 1, [](Interpreter&, Args& args) { return Value::fromString(toString(args[0])); }); interp.defineNative("repr", 1, 1, [](Interpreter&, Args& args) { return Value::fromString(toRepr(args[0])); }); - - interp.defineNative("int", 1, 1, [](Interpreter&, Args& args) { - const Value& value = args[0]; - switch (value.type()) { - case Value::Type::Int: return value; - case Value::Type::Float: return Value::fromInt(floatToInt(value.asFloat(), "int")); - case Value::Type::Bool: return Value::fromInt(value.asBool() ? 1 : 0); - case Value::Type::String: { - int64_t result; - if (parseInt(value.asString(), result)) return Value::fromInt(result); - throw RuntimeError("int() can't convert " + quoteString(value.asString()) + " to an int"); - } - default: - throw RuntimeError(std::string("int() can't convert a ") + value.typeName() + " to an int"); - } - }); - - interp.defineNative("float", 1, 1, [](Interpreter&, Args& args) { - const Value& value = args[0]; - switch (value.type()) { - case Value::Type::Int: - case Value::Type::Float: return Value::fromFloat(value.toDouble()); - case Value::Type::Bool: return Value::fromFloat(value.asBool() ? 1.0 : 0.0); - case Value::Type::String: { - double result; - if (parseFloat(value.asString(), result)) return Value::fromFloat(result); - throw RuntimeError("float() can't convert " + quoteString(value.asString()) + " to a float"); - } - default: - throw RuntimeError(std::string("float() can't convert a ") + value.typeName() + " to a float"); - } - }); - interp.defineNative("bool", 1, 1, [](Interpreter&, Args& args) { return Value::fromBool(args[0].truthy()); }); interp.defineNative("len", 1, 1, [](Interpreter&, Args& args) { const Value& value = args[0]; switch (value.type()) { - case Value::Type::String: return Value::fromInt(static_cast(value.asString().size())); + case Value::Type::String: return Value::fromInt(static_cast(utf8Length(value.asString()))); case Value::Type::List: return Value::fromInt(static_cast(value.asList().items.size())); case Value::Type::Map: return Value::fromInt(static_cast(value.asMap().size())); default: typeError("len", 0, "a string, list or map", value); @@ -212,9 +306,72 @@ void registerCore(Interpreter& interp) { throw ExitRequest{code}; }); - interp.defineNative("time", 0, 0, [](Interpreter&, Args&) { - auto now = std::chrono::system_clock::now().time_since_epoch(); - return Value::fromFloat(std::chrono::duration(now).count()); + interp.defineNative("import", 1, 1, [](Interpreter& in, Args& args) { + return in.importModule(stringArg(args, 0, "import")); + }); + + interp.defineNative("format", 1, -1, [](Interpreter&, Args& args) { + return Value::fromString(formatTemplate(stringArg(args, 0, "format"), args, 1)); + }); +} + +void registerConversions(Interpreter& interp) { + struct IntConversion { + const char* name; + NumKind kind; + }; + static const IntConversion intConversions[] = { + {"int", NumKind::Int}, {"i8", NumKind::I8}, {"i16", NumKind::I16}, {"i32", NumKind::I32}, + {"i64", NumKind::I64}, {"u8", NumKind::U8}, {"u16", NumKind::U16}, {"u32", NumKind::U32}, + {"u64", NumKind::U64}, {"byte", NumKind::U8}, + }; + for (const auto& conversion : intConversions) { + const char* name = conversion.name; + NumKind kind = conversion.kind; + interp.defineNative(name, 1, 1, [name, kind](Interpreter&, Args& args) { return toIntegerKind(args[0], kind, name); }); + } + + struct FloatConversion { + const char* name; + NumKind kind; + }; + static const FloatConversion floatConversions[] = { + {"float", NumKind::Float}, {"f32", NumKind::F32}, {"f64", NumKind::F64}}; + for (const auto& conversion : floatConversions) { + const char* name = conversion.name; + NumKind kind = conversion.kind; + interp.defineNative(name, 1, 1, [name, kind](Interpreter&, Args& args) { return toFloatKind(args[0], kind, name); }); + } + + interp.defineNative("char", 1, 1, [](Interpreter&, Args& args) { + const Value& value = args[0]; + if (value.isChar()) return value; + if (value.isString()) { + if (utf8Length(value.asString()) != 1) { + throw RuntimeError("char() needs a string with exactly one character, got " + quoteString(value.asString())); + } + size_t pos = 0; + return Value::fromChar(decodeUtf8(value.asString(), pos)); + } + int64_t code = intArg(args, 0, "char"); + if (code < 0 || code > 0x10FFFF || !isValidCodePoint(static_cast(code))) { + throw RuntimeError("char(): " + std::to_string(code) + " is not a valid character code"); + } + return Value::fromChar(static_cast(code)); + }); + + interp.defineNative("hex", 1, 1, [](Interpreter&, Args& args) { + if (!args[0].isInt()) typeError("hex", 0, "an integer", args[0]); + bool negative = args[0].isNegativeInt(); + return Value::fromString((negative ? "-0x" : "0x") + + formatValue(negative ? negateNumber(Value::fromInt(args[0].asInt())) : args[0], "x")); + }); + + interp.defineNative("bin", 1, 1, [](Interpreter&, Args& args) { + if (!args[0].isInt()) typeError("bin", 0, "an integer", args[0]); + bool negative = args[0].isNegativeInt(); + return Value::fromString((negative ? "-0b" : "0b") + + formatValue(negative ? negateNumber(Value::fromInt(args[0].asInt())) : args[0], "b")); }); } @@ -240,8 +397,7 @@ void registerCollections(Interpreter& interp) { int64_t index = intArg(args, 1, "insert"); if (index < 0) index += size; if (index < 0 || index > size) { - throw RuntimeError("insert() index " + std::to_string(args[1].asInt()) + " is out of range (length " + - std::to_string(size) + ")"); + throw RuntimeError("insert() index " + toString(args[1]) + " is out of range (length " + std::to_string(size) + ")"); } items.insert(items.begin() + index, args[2]); return Value(); @@ -262,14 +418,32 @@ void registerCollections(Interpreter& interp) { return removed; }); + interp.defineNative("extend", 2, 2, [](Interpreter&, Args& args) { + auto& items = listArg(args, 0, "extend").items; + std::vector more = listArg(args, 1, "extend").items; // copy: extending a list with itself is fine + items.insert(items.end(), more.begin(), more.end()); + return Value(); + }); + + interp.defineNative("clear", 1, 1, [](Interpreter&, Args& args) { + if (args[0].isMap()) { + args[0].asMap().clearReferences(); + } else { + listArg(args, 0, "clear").items.clear(); + } + return Value(); + }); + interp.defineNative("contains", 2, 2, [](Interpreter& in, Args& args) { return Value::fromBool(in.contains(args[0], args[1])); }); interp.defineNative("index_of", 2, 2, [](Interpreter&, Args& args) { if (args[0].isString()) { - size_t position = args[0].asString().find(stringArg(args, 1, "index_of")); - return Value::fromInt(position == std::string::npos ? -1 : static_cast(position)); + const std::string& text = args[0].asString(); + size_t position = text.find(textArg(args, 1, "index_of")); + if (position == std::string::npos) return Value::fromInt(-1); + return Value::fromInt(static_cast(utf8Length(text.substr(0, position)))); } const auto& items = listArg(args, 0, "index_of").items; for (size_t i = 0; i < items.size(); ++i) { @@ -278,14 +452,30 @@ void registerCollections(Interpreter& interp) { return Value::fromInt(-1); }); + interp.defineNative("count", 2, 2, [](Interpreter&, Args& args) { + int64_t total = 0; + if (args[0].isString()) { + const std::string& text = args[0].asString(); + std::string part = textArg(args, 1, "count"); + if (part.empty()) throw RuntimeError("count() can't count empty strings"); + for (size_t pos = text.find(part); pos != std::string::npos; pos = text.find(part, pos + part.size())) ++total; + return Value::fromInt(total); + } + for (const Value& item : listArg(args, 0, "count").items) { + if (valuesEqual(item, args[1])) ++total; + } + return Value::fromInt(total); + }); + interp.defineNative("slice", 2, 3, [](Interpreter& in, Args& args) { return in.slice(args[0], args[1], args.size() > 2 ? args[2] : Value()); }); interp.defineNative("reverse", 1, 1, [](Interpreter& in, Args& args) { if (args[0].isString()) { - std::string text = args[0].asString(); - std::reverse(text.begin(), text.end()); + std::vector chars = decodeAll(args[0].asString()); + std::string text; + for (auto it = chars.rbegin(); it != chars.rend(); ++it) appendUtf8(text, *it); return Value::fromString(text); } std::vector items = listArg(args, 0, "reverse").items; @@ -300,9 +490,8 @@ void registerCollections(Interpreter& interp) { std::vector> keyed; keyed.reserve(items.size()); for (Value& item : items) keyed.emplace_back(in.call(key, {item}), item); - std::stable_sort(keyed.begin(), keyed.end(), [](const auto& a, const auto& b) { - return compareValues(a.first, b.first) < 0; - }); + std::stable_sort(keyed.begin(), keyed.end(), + [](const auto& a, const auto& b) { return compareValues(a.first, b.first) < 0; }); for (size_t i = 0; i < keyed.size(); ++i) items[i] = std::move(keyed[i].second); } else { std::stable_sort(items.begin(), items.end(), @@ -311,6 +500,46 @@ void registerCollections(Interpreter& interp) { return Value::fromList(in.newList(std::move(items))); }); + interp.defineNative("unique", 1, 1, [](Interpreter& in, Args& args) { + std::vector result; + for (const Value& item : listArg(args, 0, "unique").items) { + bool seen = false; + for (const Value& kept : result) { + if (valuesEqual(kept, item)) { + seen = true; + break; + } + } + if (!seen) result.push_back(item); + } + return Value::fromList(in.newList(std::move(result))); + }); + + interp.defineNative("flatten", 1, 1, [](Interpreter& in, Args& args) { + std::vector result; + for (const Value& item : listArg(args, 0, "flatten").items) { + if (item.isList()) { + const auto& inner = item.asList().items; + result.insert(result.end(), inner.begin(), inner.end()); + } else { + result.push_back(item); + } + } + return Value::fromList(in.newList(std::move(result))); + }); + + interp.defineNative("zip", 2, -1, [](Interpreter& in, Args& args) { + size_t length = std::numeric_limits::max(); + for (size_t i = 0; i < args.size(); ++i) length = std::min(length, listArg(args, i, "zip").items.size()); + std::vector result; + for (size_t row = 0; row < length; ++row) { + std::vector tuple; + for (const Value& list : args) tuple.push_back(list.asList().items[row]); + result.push_back(Value::fromList(in.newList(std::move(tuple)))); + } + return Value::fromList(in.newList(std::move(result))); + }); + interp.defineNative("keys", 1, 1, [](Interpreter& in, Args& args) { std::vector keys; for (const auto& entry : mapArg(args, 0, "keys").entries) keys.push_back(entry.first); @@ -323,6 +552,22 @@ void registerCollections(Interpreter& interp) { return Value::fromList(in.newList(std::move(values))); }); + interp.defineNative("items", 1, 1, [](Interpreter& in, Args& args) { + std::vector pairs; + for (const auto& entry : mapArg(args, 0, "items").entries) { + pairs.push_back(Value::fromList(in.newList({entry.first, entry.second}))); + } + return Value::fromList(in.newList(std::move(pairs))); + }); + + interp.defineNative("merge", 1, -1, [](Interpreter& in, Args& args) { + auto result = in.newMap(); + for (size_t i = 0; i < args.size(); ++i) { + for (const auto& entry : mapArg(args, i, "merge").entries) result->set(entry.first, entry.second); + } + return Value::fromMap(result); + }); + interp.defineNative("get", 2, 3, [](Interpreter&, Args& args) { Value fallback = args.size() > 2 ? args[2] : Value(); if (args[0].isMap()) { @@ -330,13 +575,15 @@ void registerCollections(Interpreter& interp) { return value != nullptr ? *value : fallback; } if (!args[0].isList() && !args[0].isString()) typeError("get", 0, "a map, list or string", args[0]); - if (!args[1].isInt()) typeError("get", 1, "an int index", args[1]); - size_t length = args[0].isList() ? args[0].asList().items.size() : args[0].asString().size(); - int64_t index = args[1].asInt(); + if (!args[1].isInt()) typeError("get", 1, "an integer index", args[1]); + size_t length = args[0].isList() ? args[0].asList().items.size() : utf8Length(args[0].asString()); + int64_t index = 0; + if (!toInt64(args[1], index)) return fallback; if (index < 0) index += static_cast(length); if (index < 0 || index >= static_cast(length)) return fallback; if (args[0].isList()) return args[0].asList().items[static_cast(index)]; - return Value::fromString(std::string(1, args[0].asString()[static_cast(index)])); + size_t pos = utf8Offset(args[0].asString(), static_cast(index)); + return Value::fromChar(decodeUtf8(args[0].asString(), pos)); }); interp.defineNative("copy", 1, 1, [](Interpreter& in, Args& args) { @@ -383,12 +630,41 @@ void registerCollections(Interpreter& interp) { for (size_t i = start; i < items.size(); ++i) accumulator = in.call(fn, {accumulator, items[i]}); return accumulator; }); + + interp.defineNative("find", 2, 2, [](Interpreter& in, Args& args) { + std::vector items = listArg(args, 0, "find").items; + const Value& fn = functionArg(args, 1, "find"); + for (const Value& item : items) { + if (in.call(fn, {item}).truthy()) return item; + } + return Value(); + }); + + interp.defineNative("any", 1, 2, [](Interpreter& in, Args& args) { + std::vector items = listArg(args, 0, "any").items; + for (const Value& item : items) { + if (args.size() > 1 ? in.call(functionArg(args, 1, "any"), {item}).truthy() : item.truthy()) { + return Value::fromBool(true); + } + } + return Value::fromBool(false); + }); + + interp.defineNative("all", 1, 2, [](Interpreter& in, Args& args) { + std::vector items = listArg(args, 0, "all").items; + for (const Value& item : items) { + if (!(args.size() > 1 ? in.call(functionArg(args, 1, "all"), {item}).truthy() : item.truthy())) { + return Value::fromBool(false); + } + } + return Value::fromBool(true); + }); } void registerStrings(Interpreter& interp) { interp.defineNative("join", 1, 2, [](Interpreter&, Args& args) { const auto& items = listArg(args, 0, "join").items; - std::string separator = args.size() > 1 ? stringArg(args, 1, "join") : ""; + std::string separator = args.size() > 1 ? textArg(args, 1, "join") : ""; std::string result; for (size_t i = 0; i < items.size(); ++i) { if (i > 0) result += separator; @@ -410,9 +686,9 @@ void registerStrings(Interpreter& interp) { if (i > start) parts.push_back(Value::fromString(text.substr(start, i - start))); } } else { - const std::string& separator = stringArg(args, 1, "split"); + std::string separator = textArg(args, 1, "split"); if (separator.empty()) { - for (char c : text) parts.push_back(Value::fromString(std::string(1, c))); + for (char32_t c : decodeAll(text)) parts.push_back(Value::fromString(encodeUtf8(c))); } else { size_t start = 0; while (true) { @@ -427,22 +703,56 @@ void registerStrings(Interpreter& interp) { return Value::fromList(in.newList(std::move(parts))); }); + interp.defineNative("lines", 1, 1, [](Interpreter& in, Args& args) { + return Value::fromList(in.newList(splitLines(stringArg(args, 0, "lines")))); + }); + + interp.defineNative("chars", 1, 1, [](Interpreter& in, Args& args) { + std::vector chars; + for (char32_t c : decodeAll(stringArg(args, 0, "chars"))) chars.push_back(Value::fromChar(c)); + return Value::fromList(in.newList(std::move(chars))); + }); + + interp.defineNative("bytes", 1, 1, [](Interpreter& in, Args& args) { + std::vector bytes; + for (char c : textArg(args, 0, "bytes")) bytes.push_back(Value::fromInt(static_cast(c), NumKind::U8)); + return Value::fromList(in.newList(std::move(bytes))); + }); + + interp.defineNative("from_bytes", 1, 1, [](Interpreter&, Args& args) { + std::string text; + for (const Value& item : listArg(args, 0, "from_bytes").items) { + int64_t byte = -1; + if (!toInt64(item, byte) || byte < 0 || byte > 255) { + throw RuntimeError("from_bytes() needs a list of numbers from 0 to 255, found " + toRepr(item)); + } + text += static_cast(byte); + } + return Value::fromString(text); + }); + interp.defineNative("upper", 1, 1, [](Interpreter&, Args& args) { + if (args[0].isChar()) { + char32_t c = args[0].asChar(); + return Value::fromChar(c < 0x80 ? static_cast(std::toupper(static_cast(c))) : c); + } return Value::fromString(toUpper(stringArg(args, 0, "upper"))); }); interp.defineNative("lower", 1, 1, [](Interpreter&, Args& args) { + if (args[0].isChar()) { + char32_t c = args[0].asChar(); + return Value::fromChar(c < 0x80 ? static_cast(std::tolower(static_cast(c))) : c); + } return Value::fromString(toLower(stringArg(args, 0, "lower"))); }); - interp.defineNative("trim", 1, 1, [](Interpreter&, Args& args) { - return Value::fromString(trim(stringArg(args, 0, "trim"))); - }); + interp.defineNative("trim", 1, 1, [](Interpreter&, Args& args) { return Value::fromString(trim(stringArg(args, 0, "trim"))); }); interp.defineNative("replace", 3, 3, [](Interpreter&, Args& args) { const std::string& text = stringArg(args, 0, "replace"); - const std::string& from = stringArg(args, 1, "replace"); - const std::string& to = stringArg(args, 2, "replace"); + std::string from = textArg(args, 1, "replace"); + std::string to = textArg(args, 2, "replace"); if (from.empty()) throw RuntimeError("replace() can't replace an empty string"); std::string result; size_t start = 0; @@ -459,57 +769,105 @@ void registerStrings(Interpreter& interp) { interp.defineNative("starts_with", 2, 2, [](Interpreter&, Args& args) { const std::string& text = stringArg(args, 0, "starts_with"); - const std::string& prefix = stringArg(args, 1, "starts_with"); + std::string prefix = textArg(args, 1, "starts_with"); return Value::fromBool(text.compare(0, prefix.size(), prefix) == 0); }); interp.defineNative("ends_with", 2, 2, [](Interpreter&, Args& args) { const std::string& text = stringArg(args, 0, "ends_with"); - const std::string& suffix = stringArg(args, 1, "ends_with"); + std::string suffix = textArg(args, 1, "ends_with"); return Value::fromBool(text.size() >= suffix.size() && text.compare(text.size() - suffix.size(), suffix.size(), suffix) == 0); }); + interp.defineNative("pad_left", 2, 3, [](Interpreter&, Args& args) { return Value::fromString(padText(args, "pad_left", true)); }); + interp.defineNative("pad_right", 2, 3, [](Interpreter&, Args& args) { return Value::fromString(padText(args, "pad_right", false)); }); + + interp.defineNative("is_digit", 1, 1, [](Interpreter&, Args& args) { return classify(args, "is_digit", std::isdigit); }); + interp.defineNative("is_alpha", 1, 1, [](Interpreter&, Args& args) { return classify(args, "is_alpha", std::isalpha); }); + interp.defineNative("is_alnum", 1, 1, [](Interpreter&, Args& args) { return classify(args, "is_alnum", std::isalnum); }); + interp.defineNative("is_space", 1, 1, [](Interpreter&, Args& args) { return classify(args, "is_space", std::isspace); }); + interp.defineNative("is_upper", 1, 1, [](Interpreter&, Args& args) { return classify(args, "is_upper", std::isupper); }); + interp.defineNative("is_lower", 1, 1, [](Interpreter&, Args& args) { return classify(args, "is_lower", std::islower); }); + interp.defineNative("ord", 1, 1, [](Interpreter&, Args& args) { + if (args[0].isChar()) return Value::fromInt(static_cast(args[0].asChar())); const std::string& text = stringArg(args, 0, "ord"); - if (text.size() != 1) throw RuntimeError("ord() expects a single character, got a string of length " + std::to_string(text.size())); - return Value::fromInt(static_cast(text[0])); + if (utf8Length(text) != 1) { + throw RuntimeError("ord() expects a single character, got a string of length " + std::to_string(utf8Length(text))); + } + size_t pos = 0; + return Value::fromInt(static_cast(decodeUtf8(text, pos))); }); interp.defineNative("chr", 1, 1, [](Interpreter&, Args& args) { int64_t code = intArg(args, 0, "chr"); - if (code < 0 || code > 255) throw RuntimeError("chr() expects a number from 0 to 255, got " + std::to_string(code)); - return Value::fromString(std::string(1, static_cast(code))); + if (code < 0 || code > 0x10FFFF || !isValidCodePoint(static_cast(code))) { + throw RuntimeError("chr(): " + std::to_string(code) + " is not a valid character code"); + } + return Value::fromChar(static_cast(code)); }); } void registerMath(Interpreter& interp) { - interp.defineGlobal("PI", Value::fromFloat(3.14159265358979323846)); - interp.defineGlobal("E", Value::fromFloat(2.71828182845904523536)); + interp.defineBuiltin("PI", Value::fromFloat(3.14159265358979323846)); + interp.defineBuiltin("E", Value::fromFloat(2.71828182845904523536)); + interp.defineBuiltin("INF", Value::fromFloat(std::numeric_limits::infinity())); + interp.defineBuiltin("NAN", Value::fromFloat(std::numeric_limits::quiet_NaN())); interp.defineNative("abs", 1, 1, [](Interpreter&, Args& args) { - if (args[0].isInt()) { - int64_t value = args[0].asInt(); - if (value == std::numeric_limits::min()) throw RuntimeError("integer overflow in abs()"); - return Value::fromInt(value < 0 ? -value : value); - } - return Value::fromFloat(std::fabs(numberArg(args, 0, "abs"))); + if (!args[0].isNumber()) typeError("abs", 0, "a number", args[0]); + if (args[0].isInt() ? args[0].isNegativeInt() : std::signbit(args[0].asFloat())) return negateNumber(args[0]); + return args[0]; }); interp.defineNative("min", 1, -1, [](Interpreter&, Args& args) { return extreme(args, "min", -1); }); interp.defineNative("max", 1, -1, [](Interpreter&, Args& args) { return extreme(args, "max", 1); }); + interp.defineNative("clamp", 3, 3, [](Interpreter&, Args& args) { + for (size_t i = 0; i < 3; ++i) numberArg(args, i, "clamp"); + if (compareNumbers(args[1], args[2]) > 0) throw RuntimeError("clamp() needs low <= high"); + if (compareNumbers(args[0], args[1]) < 0) return args[1]; + if (compareNumbers(args[0], args[2]) > 0) return args[2]; + return args[0]; + }); + interp.defineNative("sum", 1, 1, [](Interpreter& in, Args& args) { + // Small integer types are widened so totals don't overflow: the sum of + // a list of u8 values is an int. Value total = Value::fromInt(0); for (const Value& item : listArg(args, 0, "sum").items) { if (!item.isNumber()) { - throw RuntimeError(std::string("sum() can only add numbers, but the list contains a ") + item.typeName()); + throw RuntimeError(std::string("sum() can only add numbers, but the list contains ") + article(item)); } - total = in.binaryOp(TokenType::Plus, total, item); + int64_t small = 0; + Value widened = (item.isInt() && toInt64(item, small)) ? Value::fromInt(small) : item; + total = in.binaryOp(TokenType::Plus, total, widened); } return total; }); + interp.defineNative("sign", 1, 1, [](Interpreter&, Args& args) { + numberArg(args, 0, "sign"); + if (args[0].isFloat() && std::isnan(args[0].asFloat())) return args[0]; + int order = compareNumbers(args[0], Value::fromInt(0)); + return Value::fromInt(order); + }); + + interp.defineNative("gcd", 2, 2, [](Interpreter&, Args& args) { + int64_t a = intArg(args, 0, "gcd"); + int64_t b = intArg(args, 1, "gcd"); + uint64_t x = a < 0 ? uint64_t(0) - static_cast(a) : static_cast(a); + uint64_t y = b < 0 ? uint64_t(0) - static_cast(b) : static_cast(b); + while (y != 0) { + uint64_t r = x % y; + x = y; + y = r; + } + if (x > static_cast(std::numeric_limits::max())) return Value::fromUInt(x); + return Value::fromInt(static_cast(x)); + }); + interp.defineNative("sqrt", 1, 1, [](Interpreter&, Args& args) { double value = numberArg(args, 0, "sqrt"); if (value < 0) throw RuntimeError("sqrt() of a negative number"); @@ -522,24 +880,69 @@ void registerMath(Interpreter& interp) { return in.binaryOp(TokenType::StarStar, args[0], args[1]); }); + interp.defineNative("log", 1, 2, [](Interpreter&, Args& args) { + double value = numberArg(args, 0, "log"); + if (value <= 0) throw RuntimeError("log() of a number that isn't positive"); + if (args.size() == 1) return Value::fromFloat(std::log(value)); + double base = numberArg(args, 1, "log"); + if (base <= 0 || base == 1) throw RuntimeError("log() base must be positive and not 1"); + return Value::fromFloat(std::log(value) / std::log(base)); + }); + + struct MathFunction { + const char* name; + double (*fn)(double); + }; + static const MathFunction functions[] = { + {"sin", [](double x) { return std::sin(x); }}, {"cos", [](double x) { return std::cos(x); }}, + {"tan", [](double x) { return std::tan(x); }}, {"asin", [](double x) { return std::asin(x); }}, + {"acos", [](double x) { return std::acos(x); }}, {"atan", [](double x) { return std::atan(x); }}, + {"exp", [](double x) { return std::exp(x); }}, {"log2", [](double x) { return std::log2(x); }}, + {"log10", [](double x) { return std::log10(x); }}, {"cbrt", [](double x) { return std::cbrt(x); }}, + }; + for (const auto& function : functions) { + const char* name = function.name; + double (*fn)(double) = function.fn; + interp.defineNative(name, 1, 1, [name, fn](Interpreter&, Args& args) { + return Value::fromFloat(fn(numberArg(args, 0, name))); + }); + } + + interp.defineNative("atan2", 2, 2, [](Interpreter&, Args& args) { + return Value::fromFloat(std::atan2(numberArg(args, 0, "atan2"), numberArg(args, 1, "atan2"))); + }); + + interp.defineNative("hypot", 2, 2, [](Interpreter&, Args& args) { + return Value::fromFloat(std::hypot(numberArg(args, 0, "hypot"), numberArg(args, 1, "hypot"))); + }); + + interp.defineNative("is_nan", 1, 1, [](Interpreter&, Args& args) { + return Value::fromBool(std::isnan(numberArg(args, 0, "is_nan"))); + }); + + interp.defineNative("is_inf", 1, 1, [](Interpreter&, Args& args) { + return Value::fromBool(std::isinf(numberArg(args, 0, "is_inf"))); + }); + interp.defineNative("floor", 1, 1, [](Interpreter&, Args& args) { if (args[0].isInt()) return args[0]; - return Value::fromInt(floatToInt(std::floor(numberArg(args, 0, "floor")), "floor")); + return convertNumberOrThrow(Value::fromFloat(std::floor(numberArg(args, 0, "floor"))), NumKind::Int); }); interp.defineNative("ceil", 1, 1, [](Interpreter&, Args& args) { if (args[0].isInt()) return args[0]; - return Value::fromInt(floatToInt(std::ceil(numberArg(args, 0, "ceil")), "ceil")); + return convertNumberOrThrow(Value::fromFloat(std::ceil(numberArg(args, 0, "ceil"))), NumKind::Int); }); interp.defineNative("round", 1, 2, [](Interpreter&, Args& args) { double value = numberArg(args, 0, "round"); if (args.size() == 1) { if (args[0].isInt()) return args[0]; - return Value::fromInt(floatToInt(std::round(value), "round")); + return convertNumberOrThrow(Value::fromFloat(std::round(value)), NumKind::Int); } double factor = std::pow(10.0, static_cast(intArg(args, 1, "round"))); - return Value::fromFloat(std::round(value * factor) / factor); + NumKind kind = args[0].isFloat() ? args[0].numKind() : NumKind::Float; + return Value::fromFloat(std::round(value * factor) / factor, kind); }); interp.defineNative("random", 0, 0, [](Interpreter& in, Args&) { @@ -553,14 +956,123 @@ void registerMath(Interpreter& interp) { return Value::fromInt(std::uniform_int_distribution(low, high)(in.random())); }); + interp.defineNative("choice", 1, 1, [](Interpreter& in, Args& args) { + const auto& items = listArg(args, 0, "choice").items; + if (items.empty()) throw RuntimeError("choice() from an empty list"); + return items[std::uniform_int_distribution(0, items.size() - 1)(in.random())]; + }); + + interp.defineNative("shuffle", 1, 1, [](Interpreter& in, Args& args) { + std::vector items = listArg(args, 0, "shuffle").items; + std::shuffle(items.begin(), items.end(), in.random()); + return Value::fromList(in.newList(std::move(items))); + }); + interp.defineNative("seed", 1, 1, [](Interpreter& in, Args& args) { in.random().seed(static_cast(intArg(args, 0, "seed"))); return Value(); }); } +void registerSystem(Interpreter& interp) { + interp.defineNative("time", 0, 0, [](Interpreter&, Args&) { + auto now = std::chrono::system_clock::now().time_since_epoch(); + return Value::fromFloat(std::chrono::duration(now).count()); + }); + + interp.defineNative("clock", 0, 0, [](Interpreter&, Args&) { + auto now = std::chrono::steady_clock::now().time_since_epoch(); + return Value::fromFloat(std::chrono::duration(now).count()); + }); + + interp.defineNative("sleep", 1, 1, [](Interpreter&, Args& args) { + double seconds = numberArg(args, 0, "sleep"); + if (seconds > 0) std::this_thread::sleep_for(std::chrono::duration(seconds)); + return Value(); + }); + + interp.defineNative("date", 0, 1, [](Interpreter&, Args& args) { + std::string format = args.empty() ? "%Y-%m-%d %H:%M:%S" : stringArg(args, 0, "date"); + std::time_t now = std::time(nullptr); + std::tm local = *std::localtime(&now); + char buffer[256]; + size_t length = std::strftime(buffer, sizeof buffer, format.c_str(), &local); + if (length == 0 && !format.empty()) throw RuntimeError("date(): the formatted date is too long or the format is invalid"); + return Value::fromString(std::string(buffer, length)); + }); + + interp.defineNative("env", 1, 1, [](Interpreter&, Args& args) { + const char* value = std::getenv(stringArg(args, 0, "env").c_str()); + return value == nullptr ? Value() : Value::fromString(value); + }); + + interp.defineNative("system", 1, 1, [](Interpreter& in, Args& args) { + in.out().flush(); + int status = std::system(stringArg(args, 0, "system").c_str()); +#if defined(__unix__) || defined(__APPLE__) + if (status != -1 && WIFEXITED(status)) status = WEXITSTATUS(status); +#endif + return Value::fromInt(status); + }); +} + +void registerFiles(Interpreter& interp) { + interp.defineNative("read_file", 1, 1, [](Interpreter&, Args& args) { + const std::string& path = stringArg(args, 0, "read_file"); + std::string text; + if (!readTextFile(path, text)) throw RuntimeError("read_file(): can't open '" + path + "'"); + return Value::fromString(text); + }); + + interp.defineNative("read_lines", 1, 1, [](Interpreter& in, Args& args) { + const std::string& path = stringArg(args, 0, "read_lines"); + std::string text; + if (!readTextFile(path, text)) throw RuntimeError("read_lines(): can't open '" + path + "'"); + return Value::fromList(in.newList(splitLines(text))); + }); + + auto writer = [](const char* name, std::ios::openmode mode) { + return [name, mode](Interpreter&, Args& args) { + const std::string& path = stringArg(args, 0, name); + std::ofstream file(path, mode); + if (!file) throw RuntimeError(std::string(name) + "(): can't open '" + path + "' for writing"); + file << toString(args[1]); + if (!file) throw RuntimeError(std::string(name) + "(): writing to '" + path + "' failed"); + return Value(); + }; + }; + interp.defineNative("write_file", 2, 2, writer("write_file", std::ios::binary | std::ios::trunc)); + interp.defineNative("append_file", 2, 2, writer("append_file", std::ios::binary | std::ios::app)); + + interp.defineNative("file_exists", 1, 1, [](Interpreter&, Args& args) { + std::ifstream file(stringArg(args, 0, "file_exists")); + return Value::fromBool(static_cast(file)); + }); + + interp.defineNative("delete_file", 1, 1, [](Interpreter&, Args& args) { + return Value::fromBool(std::remove(stringArg(args, 0, "delete_file").c_str()) == 0); + }); + + interp.defineNative("json_parse", 1, 1, [](Interpreter& in, Args& args) { + return jsonParse(in, stringArg(args, 0, "json_parse")); + }); + + interp.defineNative("json_stringify", 1, 2, [](Interpreter&, Args& args) { + int64_t indent = args.size() > 1 ? intArg(args, 1, "json_stringify") : 0; + if (indent < 0 || indent > 16) throw RuntimeError("json_stringify() indent must be from 0 to 16"); + return Value::fromString(jsonStringify(args[0], static_cast(indent))); + }); +} + } // namespace +bool readTextFile(const std::string& path, std::string& out) { + std::ifstream file(path, std::ios::binary); + if (!file) return false; + out.assign(std::istreambuf_iterator(file), std::istreambuf_iterator()); + return true; +} + RangeSpec makeRange(const std::vector& args) { if (args.empty() || args.size() > 3) { throw RuntimeError("range() expects 1 to 3 arguments but got " + std::to_string(args.size())); @@ -588,9 +1100,12 @@ RangeSpec makeRange(const std::vector& args) { void registerStandardLibrary(Interpreter& interpreter) { registerCore(interpreter); + registerConversions(interpreter); registerCollections(interpreter); registerStrings(interpreter); registerMath(interpreter); + registerSystem(interpreter); + registerFiles(interpreter); } } // namespace ascend diff --git a/src/typecheck.cpp b/src/typecheck.cpp new file mode 100644 index 0000000..aead71c --- /dev/null +++ b/src/typecheck.cpp @@ -0,0 +1,107 @@ +#include "typecheck.hpp" + +#include "numeric.hpp" +#include "utf8.hpp" + +namespace ascend { + +std::string describeValue(const Value& value) { + switch (value.type()) { + case Value::Type::Null: return "null"; + case Value::Type::Bool: return value.asBool() ? "true" : "false"; + case Value::Type::Int: + case Value::Type::Float: return toString(value); + case Value::Type::Char: return "the char " + toRepr(value); + default: return aTypeName(value); + } + return "a value"; +} + +std::string withHint(const std::string& message, const CoerceFailure& failure) { + return failure.hint.empty() ? message : message + " (" + failure.hint + ")"; +} + +bool coerceToType(const Value& value, const TypeSpec& type, Value& out, CoerceFailure& failure) { + if (type.kind == TypeKind::Any) { + out = value; + return true; + } + if (value.isNull()) { + if (type.nullable) { + out = value; + return true; + } + failure.what = "null"; + if (type.kind != TypeKind::Void) failure.hint = "declare it as " + type.name() + "? to allow null"; + return false; + } + + failure.what = describeValue(value); + switch (type.kind) { + case TypeKind::Bool: + if (!value.isBool()) break; + out = value; + return true; + + case TypeKind::Number: { + if (!value.isNumber()) { + if (value.isString()) failure.hint = "use " + type.name() + "(...) to convert a string"; + if (value.isBool()) failure.hint = "use " + type.name() + "(...) to convert a bool"; + if (value.isChar()) failure.hint = "use " + type.name() + "(...) to get the character code"; + break; + } + switch (convertNumber(value, type.num, false, out)) { + case ConvertStatus::Ok: return true; + case ConvertStatus::OutOfRange: + failure.hint = std::string(numKindName(type.num)) + " is " + rangeText(type.num); + return false; + case ConvertStatus::FractionLost: + failure.hint = "use " + std::string(numKindName(type.num)) + "(...) to drop the fraction"; + return false; + default: + return false; + } + } + + case TypeKind::Char: + if (value.isChar()) { + out = value; + return true; + } + if (value.isString()) failure.hint = "use single quotes for a char, like 'a'"; + break; + + case TypeKind::String: + if (value.isString()) { + out = value; + return true; + } + if (value.isChar()) { + out = Value::fromString(encodeUtf8(value.asChar())); + return true; + } + failure.hint = "use str(...) to convert it to a string"; + break; + + case TypeKind::List: + if (!value.isList()) break; + out = value; + return true; + + case TypeKind::Map: + if (!value.isMap()) break; + out = value; + return true; + + case TypeKind::Function: + if (!value.isFunction()) break; + out = value; + return true; + + default: + break; + } + return false; +} + +} // namespace ascend diff --git a/src/types.cpp b/src/types.cpp new file mode 100644 index 0000000..f96bc01 --- /dev/null +++ b/src/types.cpp @@ -0,0 +1,121 @@ +#include "types.hpp" + +#include +#include + +namespace ascend { + +int bitWidth(NumKind kind) { + switch (kind) { + case NumKind::I8: + case NumKind::U8: return 8; + case NumKind::I16: + case NumKind::U16: return 16; + case NumKind::I32: + case NumKind::U32: + case NumKind::F32: return 32; + default: return 64; + } +} + +int64_t signedMin(NumKind kind) { + switch (kind) { + case NumKind::I8: return std::numeric_limits::min(); + case NumKind::I16: return std::numeric_limits::min(); + case NumKind::I32: return std::numeric_limits::min(); + default: return std::numeric_limits::min(); + } +} + +int64_t signedMax(NumKind kind) { + switch (kind) { + case NumKind::I8: return std::numeric_limits::max(); + case NumKind::I16: return std::numeric_limits::max(); + case NumKind::I32: return std::numeric_limits::max(); + default: return std::numeric_limits::max(); + } +} + +uint64_t unsignedMax(NumKind kind) { + switch (kind) { + case NumKind::U8: return std::numeric_limits::max(); + case NumKind::U16: return std::numeric_limits::max(); + case NumKind::U32: return std::numeric_limits::max(); + default: return std::numeric_limits::max(); + } +} + +const char* numKindName(NumKind kind) { + switch (kind) { + case NumKind::Int: return "int"; + case NumKind::I8: return "i8"; + case NumKind::I16: return "i16"; + case NumKind::I32: return "i32"; + case NumKind::I64: return "i64"; + case NumKind::U8: return "u8"; + case NumKind::U16: return "u16"; + case NumKind::U32: return "u32"; + case NumKind::U64: return "u64"; + case NumKind::Float: return "float"; + case NumKind::F32: return "f32"; + case NumKind::F64: return "f64"; + } + return "number"; +} + +std::string rangeText(NumKind kind) { + if (isUnsignedKind(kind)) return "0 to " + std::to_string(unsignedMax(kind)); + return std::to_string(signedMin(kind)) + " to " + std::to_string(signedMax(kind)); +} + +std::string TypeSpec::name() const { + std::string text; + switch (kind) { + case TypeKind::Any: text = "any"; break; + case TypeKind::Void: text = "void"; break; + case TypeKind::Bool: text = "bool"; break; + case TypeKind::Number: text = numKindName(num); break; + case TypeKind::Char: text = "char"; break; + case TypeKind::String: text = "string"; break; + case TypeKind::List: text = "list"; break; + case TypeKind::Map: text = "map"; break; + case TypeKind::Function: text = "func"; break; + } + if (nullable) text += '?'; + return text; +} + +bool lookupType(const std::string& name, TypeSpec& out) { + static const std::unordered_map types = [] { + auto number = [](NumKind kind) { + TypeSpec spec; + spec.kind = TypeKind::Number; + spec.num = kind; + return spec; + }; + auto simple = [](TypeKind kind) { + TypeSpec spec; + spec.kind = kind; + return spec; + }; + return std::unordered_map{ + {"int", number(NumKind::Int)}, {"i8", number(NumKind::I8)}, + {"i16", number(NumKind::I16)}, {"i32", number(NumKind::I32)}, + {"i64", number(NumKind::I64)}, {"u8", number(NumKind::U8)}, + {"byte", number(NumKind::U8)}, {"u16", number(NumKind::U16)}, + {"u32", number(NumKind::U32)}, {"u64", number(NumKind::U64)}, + {"float", number(NumKind::Float)}, {"f32", number(NumKind::F32)}, + {"f64", number(NumKind::F64)}, {"bool", simple(TypeKind::Bool)}, + {"char", simple(TypeKind::Char)}, {"string", simple(TypeKind::String)}, + {"list", simple(TypeKind::List)}, {"map", simple(TypeKind::Map)}, + {"func", simple(TypeKind::Function)}, {"any", simple(TypeKind::Any)}, + {"void", simple(TypeKind::Void)}, + }; + }(); + auto it = types.find(name); + if (it == types.end()) return false; + out = it->second; + return true; +} + +} // namespace ascend diff --git a/src/utf8.cpp b/src/utf8.cpp new file mode 100644 index 0000000..9dfd1ae --- /dev/null +++ b/src/utf8.cpp @@ -0,0 +1,104 @@ +#include "utf8.hpp" + +namespace ascend { + +namespace { + +bool isAscii(const std::string& text) { + for (char c : text) { + if (static_cast(c) >= 0x80) return false; + } + return true; +} + +} // namespace + +bool isValidCodePoint(char32_t cp) { return cp <= 0x10FFFF && (cp < 0xD800 || cp > 0xDFFF); } + +void appendUtf8(std::string& out, char32_t cp) { + if (!isValidCodePoint(cp)) cp = kReplacementChar; + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } +} + +std::string encodeUtf8(char32_t cp) { + std::string out; + appendUtf8(out, cp); + return out; +} + +char32_t decodeUtf8(const std::string& text, size_t& pos) { + unsigned char lead = static_cast(text[pos]); + if (lead < 0x80) { + ++pos; + return lead; + } + + int length; + char32_t cp; + char32_t minimum; + if (lead >= 0xC2 && lead <= 0xDF) { + length = 2; + cp = lead & 0x1F; + minimum = 0x80; + } else if (lead >= 0xE0 && lead <= 0xEF) { + length = 3; + cp = lead & 0x0F; + minimum = 0x800; + } else if (lead >= 0xF0 && lead <= 0xF4) { + length = 4; + cp = lead & 0x07; + minimum = 0x10000; + } else { + ++pos; + return kReplacementChar; + } + + if (pos + static_cast(length) > text.size()) { + ++pos; + return kReplacementChar; + } + for (int i = 1; i < length; ++i) { + unsigned char next = static_cast(text[pos + static_cast(i)]); + if ((next & 0xC0) != 0x80) { + ++pos; + return kReplacementChar; + } + cp = (cp << 6) | (next & 0x3F); + } + if (cp < minimum || !isValidCodePoint(cp)) { + ++pos; + return kReplacementChar; + } + pos += static_cast(length); + return cp; +} + +size_t utf8Length(const std::string& text) { + if (isAscii(text)) return text.size(); + size_t count = 0; + for (size_t pos = 0; pos < text.size(); ++count) decodeUtf8(text, pos); + return count; +} + +size_t utf8Offset(const std::string& text, size_t index) { + if (isAscii(text)) return index < text.size() ? index : text.size(); + size_t pos = 0; + for (size_t i = 0; i < index && pos < text.size(); ++i) decodeUtf8(text, pos); + return pos; +} + +} // namespace ascend diff --git a/src/value.cpp b/src/value.cpp index 4e51598..470e442 100644 --- a/src/value.cpp +++ b/src/value.cpp @@ -1,12 +1,15 @@ #include "value.hpp" #include "environment.hpp" +#include "numeric.hpp" +#include "utf8.hpp" #include #include #include #include #include +#include namespace ascend { @@ -18,12 +21,27 @@ void checkNesting(int depth) { if (depth > kMaxNesting) throw RuntimeError("value is nested too deeply (is it cyclic?)"); } +double roundToF32(double value) { + if (std::isnan(value) || std::isinf(value)) return value; + if (std::fabs(value) > static_cast(std::numeric_limits::max())) { + return value > 0 ? std::numeric_limits::infinity() : -std::numeric_limits::infinity(); + } + return static_cast(static_cast(value)); +} + void write(std::string& out, const Value& value, bool repr, std::vector& active) { switch (value.type()) { case Value::Type::Null: out += "null"; return; case Value::Type::Bool: out += value.asBool() ? "true" : "false"; return; - case Value::Type::Int: out += std::to_string(value.asInt()); return; - case Value::Type::Float: out += formatFloat(value.asFloat()); return; + case Value::Type::Int: out += intToString(value); return; + case Value::Type::Float: out += formatFloat(value.asFloat(), value.numKind() == NumKind::F32); return; + case Value::Type::Char: + if (repr) { + out += quoteChar(value.asChar()); + } else { + appendUtf8(out, value.asChar()); + } + return; case Value::Type::String: out += repr ? quoteString(value.asString()) : value.asString(); return; @@ -78,15 +96,21 @@ void write(std::string& out, const Value& value, bool repr, std::vector b.asInt() ? 1 : 0); - } - if (a.isNumber() && b.isNumber()) { - double x = a.toDouble(); - double y = b.toDouble(); - // NaN sorts before every other number so sorting stays well defined. - bool xNan = std::isnan(x); - bool yNan = std::isnan(y); - if (xNan || yNan) return xNan == yNan ? 0 : (xNan ? -1 : 1); - return x < y ? -1 : (x > y ? 1 : 0); - } - if (a.isString() && b.isString()) { - int result = a.asString().compare(b.asString()); + if (a.isNumber() && b.isNumber()) return compareNumbers(a, b); + if (a.isChar() && b.isChar()) return a.asChar() < b.asChar() ? -1 : (a.asChar() > b.asChar() ? 1 : 0); + if (isText(a) && isText(b)) { + // UTF-8 byte order is the same as code point order. + int result = textOf(a).compare(textOf(b)); return result < 0 ? -1 : (result > 0 ? 1 : 0); } if (a.isList() && b.isList()) { @@ -146,6 +161,27 @@ int compareAt(const Value& a, const Value& b, int depth) { throw RuntimeError(std::string("can't compare ") + a.typeName() + " with " + b.typeName()); } +void appendEscaped(std::string& out, char c, char quote) { + switch (c) { + case '\\': out += "\\\\"; return; + case '\n': out += "\\n"; return; + case '\t': out += "\\t"; return; + case '\r': out += "\\r"; return; + case '\0': out += "\\0"; return; + default: break; + } + if (c == quote) { + out += '\\'; + out += c; + } else if (static_cast(c) < 0x20 || c == 0x7f) { + char buffer[8]; + std::snprintf(buffer, sizeof buffer, "\\x%02x", static_cast(c)); + out += buffer; + } else { + out += c; + } +} + } // namespace // --------------------------------------------------------------------------- @@ -157,48 +193,67 @@ Value Value::fromBool(bool value) { return v; } -Value Value::fromInt(int64_t value) { +Value Value::fromInt(int64_t value, NumKind kind) { + Value v; + v.data_.emplace<2>(IntData{value, kind}); + return v; +} + +Value Value::fromUInt(uint64_t value, NumKind kind) { + Value v; + v.data_.emplace<2>(IntData{static_cast(value), kind}); + return v; +} + +Value Value::fromFloat(double value, NumKind kind) { Value v; - v.data_.emplace<2>(value); + v.data_.emplace<3>(FloatData{kind == NumKind::F32 ? roundToF32(value) : value, kind}); return v; } -Value Value::fromFloat(double value) { +Value Value::fromChar(char32_t value) { Value v; - v.data_.emplace<3>(value); + v.data_.emplace<4>(value); return v; } Value Value::fromString(std::string value) { Value v; - v.data_.emplace<4>(std::move(value)); + v.data_.emplace<5>(std::move(value)); return v; } Value Value::fromList(std::shared_ptr value) { Value v; - v.data_.emplace<5>(std::move(value)); + v.data_.emplace<6>(std::move(value)); return v; } Value Value::fromMap(std::shared_ptr value) { Value v; - v.data_.emplace<6>(std::move(value)); + v.data_.emplace<7>(std::move(value)); return v; } Value Value::fromFunction(std::shared_ptr value) { Value v; - v.data_.emplace<7>(std::move(value)); + v.data_.emplace<8>(std::move(value)); return v; } +double Value::toDouble() const { + if (isFloat()) return asFloat(); + if (numKind() == NumKind::U64) return static_cast(asUInt()); + return static_cast(asInt()); +} + bool Value::truthy() const { switch (type()) { case Type::Null: return false; case Type::Bool: return asBool(); case Type::Int: return asInt() != 0; case Type::Float: return asFloat() != 0.0; + case Type::Char: return asChar() != 0; case Type::String: return !asString().empty(); case Type::List: return !asList().items.empty(); case Type::Map: return asMap().size() != 0; @@ -211,12 +266,13 @@ const char* Value::typeName() const { switch (type()) { case Type::Null: return "null"; case Type::Bool: return "bool"; - case Type::Int: return "int"; - case Type::Float: return "float"; + case Type::Int: + case Type::Float: return numKindName(numKind()); + case Type::Char: return "char"; case Type::String: return "string"; case Type::List: return "list"; case Type::Map: return "map"; - case Type::Function: return "function"; + case Type::Function: return "func"; } return "unknown"; } @@ -243,19 +299,29 @@ bool MapObj::hashKey(const Value& key, std::string& out) { switch (key.type()) { case Value::Type::Null: out = "n"; return true; case Value::Type::Bool: out = key.asBool() ? "b1" : "b0"; return true; - case Value::Type::Int: out = "i" + std::to_string(key.asInt()); return true; + case Value::Type::Int: out = "i" + intToString(key); return true; case Value::Type::Float: { double d = key.asFloat(); - if (std::isfinite(d) && d == std::floor(d) && d >= -9223372036854775808.0 && - d < 9223372036854775808.0) { - out = "i" + std::to_string(static_cast(d)); // 1.0 is the same key as 1 - return true; + if (std::isfinite(d) && d == std::floor(d)) { + // Integral floats are the same key as the equal int: m[1.0] is m[1]. + if (d >= -9223372036854775808.0 && d < 9223372036854775808.0) { + out = "i" + std::to_string(static_cast(d)); + return true; + } + if (d >= 0 && d < 18446744073709551616.0) { + out = "i" + std::to_string(static_cast(d)); + return true; + } } uint64_t bits; std::memcpy(&bits, &d, sizeof bits); out = "f" + std::to_string(bits); return true; } + case Value::Type::Char: + out = "s"; + appendUtf8(out, key.asChar()); + return true; case Value::Type::String: out.reserve(key.asString().size() + 1); out = "s"; @@ -279,8 +345,8 @@ Value* MapObj::find(const Value& key) { void MapObj::set(const Value& key, Value value) { std::string hashed; if (!hashKey(key, hashed)) { - throw RuntimeError(std::string("a ") + key.typeName() + - " can't be used as a map key (use a string, number, bool or null)"); + throw RuntimeError(aTypeName(key) + + " can't be used as a map key (use a string, number, char, bool or null)"); } auto it = index_.find(hashed); if (it != index_.end()) { @@ -325,14 +391,16 @@ void FunctionObj::forEachChild(const std::function& visit) cons // --------------------------------------------------------------------------- // Formatting and comparison -std::string formatFloat(double value) { +std::string formatFloat(double value, bool singlePrecision) { if (std::isnan(value)) return "nan"; if (std::isinf(value)) return value > 0 ? "inf" : "-inf"; // Use the shortest representation that reads back as the same number. char buffer[32]; - for (int precision = 1; precision <= 17; ++precision) { + const int maxPrecision = singlePrecision ? 9 : 17; + for (int precision = 1; precision <= maxPrecision; ++precision) { std::snprintf(buffer, sizeof buffer, "%.*g", precision, value); - if (std::strtod(buffer, nullptr) == value) break; + double parsed = std::strtod(buffer, nullptr); + if ((singlePrecision ? roundToF32(parsed) : parsed) == value) break; } std::string text = buffer; if (text.find_first_of(".eE") == std::string::npos) text += ".0"; // keep floats looking like floats @@ -341,28 +409,30 @@ std::string formatFloat(double value) { std::string quoteString(const std::string& text) { std::string out = "\""; - for (char c : text) { - switch (c) { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\n': out += "\\n"; break; - case '\t': out += "\\t"; break; - case '\r': out += "\\r"; break; - case '\0': out += "\\0"; break; - default: - if (static_cast(c) < 0x20 || c == 0x7f) { - char buffer[8]; - std::snprintf(buffer, sizeof buffer, "\\x%02x", static_cast(c)); - out += buffer; - } else { - out += c; - } - } - } + for (char c : text) appendEscaped(out, c, '"'); out += '"'; return out; } +std::string quoteChar(char32_t c) { + std::string out = "'"; + if (c < 0x80) { + appendEscaped(out, static_cast(c), '\''); + } else { + appendUtf8(out, c); + } + out += '\''; + return out; +} + +std::string aTypeName(const Value& value) { + std::string name = value.typeName(); + if (value.isNull()) return "null"; + bool vowelSound = name[0] == 'a' || name[0] == 'e' || name[0] == 'i' || name[0] == 'o' || + (name[0] == 'f' && name.size() > 1 && name[1] >= '0' && name[1] <= '9'); // "an f32" + return (vowelSound ? "an " : "a ") + name; +} + std::string functionName(const Callable& callable) { if (callable.kind == Callable::Kind::Native) return static_cast(callable).name; return static_cast(callable).name; @@ -384,12 +454,13 @@ std::string toRepr(const Value& value) { } size_t resolveIndex(const Value& index, size_t length, const char* what) { - if (!index.isInt()) throw RuntimeError(std::string(what) + " index must be an int, not " + index.typeName()); + if (!index.isInt()) throw RuntimeError(std::string(what) + " index must be an integer, not " + aTypeName(index)); const int64_t size = static_cast(length); - int64_t i = index.asInt(); - if (i < 0) i += size; - if (i < 0 || i >= size) { - throw RuntimeError(std::string(what) + " index " + std::to_string(index.asInt()) + " is out of range (length " + + int64_t i = 0; + bool valid = toInt64(index, i); + if (valid && i < 0) i += size; + if (!valid || i < 0 || i >= size) { + throw RuntimeError(std::string(what) + " index " + toString(index) + " is out of range (length " + std::to_string(length) + ")"); } return static_cast(i); diff --git a/tests/interpreter_tests.cpp b/tests/interpreter_tests.cpp index 221cd38..1ab97a1 100644 --- a/tests/interpreter_tests.cpp +++ b/tests/interpreter_tests.cpp @@ -3,6 +3,8 @@ #include "runner.hpp" #include "test_framework.hpp" +#include +#include #include #include @@ -46,6 +48,15 @@ std::string error(const std::string& code) { bool contains(const std::string& text, const std::string& fragment) { return text.find(fragment) != std::string::npos; } +// A file in the working directory that is deleted when the test ends. +struct TempFile { + TempFile(std::string name, const std::string& contents) : path(std::move(name)) { + std::ofstream(path, std::ios::binary) << contents; + } + ~TempFile() { std::remove(path.c_str()); } + std::string path; +}; + } // namespace #define CHECK_OUTPUT(code, expected) CHECK_EQ(output(code), std::string(expected)) @@ -183,7 +194,7 @@ TEST(interp_for_range_forms) { CHECK_OUTPUT("for i in range(0) { print(i) }\nprint(range(5, 0))", "[]\n"); CHECK_OUTPUT("r := range\nfor i in r(2) { print(i) }", "0\n1\n"); CHECK_ERROR("for i in range(1, 2, 0) { }", "range() step can't be zero"); - CHECK_ERROR("for i in range(1.5) { }", "range() expects an int as argument 1, not a float"); + CHECK_ERROR("for i in range(1.5) { }", "range() expects an integer as argument 1, not a float"); } TEST(interp_for_over_collections) { @@ -191,7 +202,7 @@ TEST(interp_for_over_collections) { CHECK_OUTPUT("for i, x in [\"a\", \"b\"] { print(i, x) }", "0 a\n1 b\n"); CHECK_OUTPUT("for c in \"hi\" { print(c) }", "h\ni\n"); CHECK_OUTPUT("m := {b: 2, a: 1}\nfor k in m { print(k) }\nfor k, v in m { print(k, v) }", "b\na\nb 2\na 1\n"); - CHECK_ERROR("for x in 5 { }", "can't loop over a int value"); + CHECK_ERROR("for x in 5 { }", "can't loop over an int value"); } TEST(interp_for_loop_body_can_modify_collection) { @@ -228,7 +239,7 @@ TEST(interp_arity_errors) { CHECK_ERROR("func f(a, b = 1) { }\nf()", "f() expects 1 to 2 arguments but got 0"); CHECK_ERROR("len()", "len() expects 1 argument but got 0"); CHECK_ERROR("push([])", "push() expects at least 2 arguments but got 1"); - CHECK_ERROR("x := 5\nx()", "a int value can't be called like a function"); + CHECK_ERROR("x := 5\nx()", "an int value can't be called like a function"); } TEST(interp_closures) { @@ -276,7 +287,7 @@ TEST(interp_lists) { "[10, 2, 8] 3 [2, 8] [10, 2, 8, 4] [0, 0, 0]\n"); CHECK_OUTPUT("a := [1, 2]\nb := a\nb.push(3)\nc := copy(a)\nc.push(4)\nprint(a, c)", "[1, 2, 3] [1, 2, 3, 4]\n"); CHECK_ERROR("a := [1, 2]\nprint(a[2])", "list index 2 is out of range (length 2)"); - CHECK_ERROR("a := [1]\nprint(a[\"x\"])", "list index must be an int, not string"); + CHECK_ERROR("a := [1]\nprint(a[\"x\"])", "list index must be an integer, not a string"); } TEST(interp_list_builtins) { @@ -307,7 +318,7 @@ TEST(interp_maps) { CHECK_ERROR("m := {}\nprint(m[\"missing\"])", "key \"missing\" not found in map"); CHECK_ERROR("m := {}\nprint(m.missing)", "map has no key \"missing\""); CHECK_ERROR("m := {}\nm[[1]] = 2", "a list can't be used as a map key"); - CHECK_ERROR("x := 5\nx.field = 1", "can't set field 'field' on a int value"); + CHECK_ERROR("x := 5\nx.field = 1", "can't set field 'field' on an int value"); } TEST(interp_printing_nested_and_cyclic_values) { @@ -374,7 +385,7 @@ TEST(interp_conversions) { CHECK_OUTPUT("print(int(\"42\") + 1, int(3.9), int(-3.9), int(true), float(\"2.5\"), float(2), str(12) + \"!\")", "43 3 -3 1 2.5 2.0 12!\n"); CHECK_OUTPUT("print(type(1), type(1.5), type(\"s\"), type([]), type({}), type(null), type(true), type(len))", - "int float string list map null bool function\n"); + "int float string list map null bool func\n"); CHECK_OUTPUT("print(bool(0), bool(\"x\"), repr(\"a\\tb\"), repr([1]))", "false true \"a\\tb\" [1]\n"); CHECK_ERROR("int(\"abc\")", "int() can't convert \"abc\" to an int"); CHECK_ERROR("float(\"1.2.3\")", "float() can't convert \"1.2.3\" to a float"); @@ -459,3 +470,263 @@ TEST(interp_cycle_collector_frees_closures) { interpreter.collectGarbage(); CHECK(interpreter.trackedObjectCount() < 100); } + +// --------------------------------------------------------------------------- +// Types + +TEST(interp_typed_variables) { + CHECK_OUTPUT( + "a: i8 = -5\nb: u16 = 60000\nc: u64 = 18446744073709551615u64\nd: f32 = 0.1\ne: f64 = 0.1\n" + "f: char = 'x'\ng: string = \"s\"\nh: bool = true\nk: byte = 255\n" + "print(a, b, c, d, e, f, g, h, k)\n" + "print(type(a), type(b), type(c), type(d), type(e), type(f), type(g), type(h), type(k), type(1), type(1.0))", + "-5 60000 18446744073709551615 0.1 0.1 x s true 255\n" + "i8 u16 u64 f32 f64 char string bool u8 int float\n"); +} + +TEST(interp_zero_values) { + CHECK_OUTPUT("a: i32\nb: f64\nc: bool\nd: string\ne: char\nf: list\ng: map\nh: string?\n" + "print(a, b, c, repr(d), ord(e), f, g, h)", + "0 0.0 false \"\" 0 [] {} null\n"); +} + +TEST(interp_typed_variables_keep_their_type) { + CHECK_OUTPUT("x: u8 = 10\nx = 20\nx += 5\nprint(x, type(x))", "25 u8\n"); + CHECK_OUTPUT("x: f64 = 1\nprint(x, type(x))", "1.0 f64\n"); // ints convert to floats + CHECK_OUTPUT("x: i32 = 3.0\nprint(x)", "3\n"); // whole floats convert to ints + CHECK_OUTPUT("s: string = 'c'\nprint(s, type(s))", "c string\n"); + CHECK_ERROR("x: u8 = 10\nx = 256", "'x' is declared as u8, so it can't hold 256 (u8 is 0 to 255)"); + CHECK_ERROR("x: i32 = 1\nx = 2.5", "can't hold 2.5 (use i32(...) to drop the fraction)"); + CHECK_ERROR("x: i32 = 1\nx = \"5\"", "can't hold a string (use i32(...) to convert a string)"); + CHECK_ERROR("x: string = \"a\"\nx = null", "can't hold null (declare it as string? to allow null)"); + CHECK_ERROR("x: char = 'a'\nx = \"b\"", "use single quotes for a char, like 'a'"); + CHECK_OUTPUT("x: string? = \"a\"\nx = null\nprint(x)", "null\n"); + CHECK_OUTPUT("x: u8 = 1\nx := \"now untyped\"\nprint(x)", "now untyped\n"); // := declares a new variable +} + +TEST(interp_static_type_errors) { + Result result = run("print(\"never runs\")\nx: u8 = 300"); + CHECK(result.status == RunStatus::SyntaxError); + CHECK_EQ(result.out, std::string("")); + CHECK(contains(result.err, "test.asc:2:9: error: 'x' is declared as u8, so it can't hold 300 (u8 is 0 to 255)")); + CHECK_ERROR("x: bool = 1", "'x' is declared as bool, so it can't hold 1"); + CHECK_ERROR("x: list = {}", "'x' is declared as list, so it can't hold a map"); + CHECK_ERROR("f: func", "'f' is declared as func, so it needs a value"); +} + +TEST(interp_integer_overflow_per_type) { + CHECK_ERROR("x: u8 = 250\nx += 10", "integer overflow: 250 + 10 doesn't fit in u8 (0 to 255)"); + CHECK_ERROR("x: i8 = 100\nprint(x * 2)", "integer overflow: 100 * 2 doesn't fit in i8 (-128 to 127)"); + CHECK_ERROR("x: u32 = 0\nprint(x - 1)", "integer overflow: 0 - 1 doesn't fit in u32"); + CHECK_ERROR("x: i8 = -128\nprint(-x)", "integer overflow: -(-128) doesn't fit in i8"); + CHECK_ERROR("x: u8 = 1\nprint(-x)", "can't negate 1: u8 is unsigned"); + CHECK_ERROR("x: u8 = 1\nprint(x + 300)", "300 doesn't fit in u8 (0 to 255)"); + CHECK_OUTPUT("x: u64 = 18446744073709551614u64\nx += 1\nprint(x)", "18446744073709551615\n"); + CHECK_ERROR("x: u64 = 18446744073709551615u64\nx += 1", "integer overflow"); + CHECK_ERROR("print(2i32 ** 31)", "doesn't fit in i32"); + CHECK_OUTPUT("print(2i32 ** 30, 3u8 ** 5)", "1073741824 243\n"); +} + +TEST(interp_mixed_type_arithmetic) { + CHECK_OUTPUT("a: u8 = 1\nb: i32 = 2\nc: i8 = 3\nd: f32 = 1.5\ne: f64 = 2.0\n" + "print(type(a + 1), type(a + b), type(a + c), type(b + d), type(d + e), type(d * 2), type(a + 1.5))", + "u8 i32 i16 f32 f64 f32 float\n"); + CHECK_ERROR("a: u64 = 1\nb: i64 = 2\nprint(a + b)", "can't mix u64 and i64"); + CHECK_OUTPUT("print(7u8 / 2, 7i32 % 3, 7.0f32 / 2)", "3 1 3.5\n"); + CHECK_OUTPUT("a: u8 = 200\nb: i8 = -100\nprint(a > b, a == 200, 200 == a, 1u8 == 1.0)", "true true true true\n"); +} + +TEST(interp_f32_precision) { + CHECK_OUTPUT("x: f32 = 0.1\ny: f64 = 0.1\nprint(x, y, f64(x), x == y)", "0.1 0.1 0.10000000149011612 false\n"); + CHECK_OUTPUT("print(f32(1) / 3, 1.0 / 3)", "0.33333334 0.3333333333333333\n"); +} + +TEST(interp_conversion_functions) { + CHECK_OUTPUT("print(i8(100), u8(3.9), u8('A'), int(\"0xff\"), int(\" -42 \"), u64(\"18446744073709551615\"))", + "100 3 65 255 -42 18446744073709551615\n"); + CHECK_OUTPUT("print(type(i16(5)), type(byte(5)), type(f32(5)), f64(\"2.5\"), float(true), char(97), char(\"z\"))", + "i16 u8 f32 2.5 1.0 a z\n"); + CHECK_OUTPUT("print(hex(255), hex(-16), bin(5), string(12) + \"!\")", "0xff -0x10 0b101 12!\n"); + CHECK_ERROR("i8(300)", "300 doesn't fit in i8 (-128 to 127)"); + CHECK_ERROR("u8(-1)", "-1 doesn't fit in u8 (0 to 255)"); + CHECK_ERROR("int(\"2.5\")", "int() can't convert \"2.5\" to an integer (it has a fraction; use float() first)"); + CHECK_ERROR("char(\"ab\")", "char() needs a string with exactly one character"); + CHECK_ERROR("char(-1)", "char(): -1 is not a valid character code"); +} + +TEST(interp_number_literals) { + CHECK_OUTPUT("print(0xFF, 0b1010, 0o17, 1_000_000, 255u8, type(255u8), -128i8, 1.5f32, type(2f64))", + "255 10 15 1000000 255 u8 -128 1.5 f64\n"); +} + +TEST(interp_bitwise_operators) { + CHECK_OUTPUT("print(12 & 10, 12 | 10, 12 ^ 10, ~0, 1 << 10, -16 >> 2, 0xF0 >> 4)", "8 14 6 -1 1024 -4 15\n"); + CHECK_OUTPUT("x: u8 = 0b1111_0000\nprint(~x, x << 1, x >> 4, type(x << 1))", "15 224 15 u8\n"); + CHECK_OUTPUT("x: i8 = -1\nprint(x << 1, x >> 1)", "-2 -1\n"); + CHECK_OUTPUT("flags := 0\nflags |= 4\nflags |= 1\nflags &= ~4\nflags ^= 2\nflags <<= 1\nprint(flags, flags & 1 == 0)", + "6 true\n"); + CHECK_ERROR("print(1u8 << 8)", "shift amount 8 is out of range for u8 (0 to 7)"); + CHECK_ERROR("print(1.5 & 1)", "'&' needs integers, not float and int"); + CHECK_ERROR("print(true | false)", "for true/false use 'or'"); +} + +TEST(interp_chars) { + CHECK_OUTPUT("c := 'a'\nprint(c, type(c), repr(c), c + 1, 'z' - 'a', 'a' < 'b', c == \"a\", '-' * 3, 'h' + \"i\")", + "a char 'a' b 25 true true --- hi\n"); + CHECK_OUTPUT("s := \"hey\"\nprint(s[0], type(s[0]), s[-1] == 'y', 'e' in s, ord('A'), chr(66), upper('q'))", + "h char true true 65 B Q\n"); + CHECK_OUTPUT("for c in \"ab\" { print(c, type(c)) }", "a char\nb char\n"); + CHECK_OUTPUT("print(repr('\\n'), repr('\\''), '\\u{1F600}' == \"\\u{1F600}\")", "'\\n' '\\'' true\n"); + CHECK_ERROR("print('a' + 1114112)", "is not a valid character code"); +} + +TEST(interp_unicode_strings) { + CHECK_OUTPUT("s := \"héllo wörld\"\nprint(len(s), s[1], s[7:], reverse(\"añb\"), index_of(s, \"w\"), len(bytes(\"é\")))", + "11 é örld bña 6 2\n"); + CHECK_OUTPUT("print(chars(\"añ\"), from_bytes([72, 105]), split(\"añ\", \"\"))", "['a', 'ñ'] Hi [\"a\", \"ñ\"]\n"); +} + +TEST(interp_constants) { + CHECK_OUTPUT("const MAX = 10\nconst NAME: string = \"x\"\nprint(MAX, NAME)", "10 x\n"); + CHECK_ERROR("const MAX = 10\nMAX = 11", "can't change 'MAX': it's a constant"); + CHECK_ERROR("const MAX = 10\nMAX += 1", "can't change 'MAX': it's a constant"); + CHECK_ERROR("const MAX = 10\nMAX := 11", "'MAX' is a constant and can't be declared again"); + CHECK_OUTPUT("const MAX = 10\nif true {\n MAX := 1\n print(MAX)\n}", "1\n"); // shadowing in a block is allowed +} + +TEST(interp_typed_functions) { + CHECK_OUTPUT("func area(w: f64, h: f64) -> f64 { return w * h }\nprint(area(2, 3), type(area(2, 3)))", "6.0 f64\n"); + CHECK_OUTPUT("func half(n: i32) -> i32 { return n / 2 }\nprint(half(7))", "3\n"); + CHECK_OUTPUT("func find(xs: list, x) -> i32? {\n for i, v in xs { if v == x { return i } }\n return null\n}\n" + "print(find([5, 6], 6), find([], 1))", + "1 null\n"); + CHECK_OUTPUT("func log(msg: string) -> void { print(\"log:\", msg) }\nlog(\"hi\")", "log: hi\n"); + CHECK_ERROR("func f(n: u8) { }\nf(256)", "f() expects 'n' to be u8, but got 256 (u8 is 0 to 255)"); + CHECK_ERROR("func f(s: string) { }\nf(1)", "f() expects 's' to be string, but got 1"); + CHECK_ERROR("func f(x) -> i32 { return x }\nf(\"no\")", "f() must return i32, but returned a string"); + CHECK_ERROR("func f() -> i32 { }\nf()", "f() must return i32, but it ended without returning a value"); + CHECK_ERROR("func f() -> void { return 1 }", "f() is declared '-> void', so it can't return a value"); + CHECK_ERROR("func f(n: u8) {\n n = 300\n}\nf(1)", "'n' is declared as u8, so it can't hold 300"); +} + +TEST(interp_match) { + std::string program = + "func describe(x) {\n" + " match x {\n" + " 0 => return \"zero\"\n" + " 1, 2, 3 => return \"small\"\n" + " \"hi\" => {\n greeting := \"a greeting\"\n return greeting\n }\n" + " else => return \"other\"\n" + " }\n" + "}\n" + "print(describe(0), describe(2), describe(\"hi\"), describe(99))"; + CHECK_OUTPUT(program, "zero small a greeting other\n"); + CHECK_OUTPUT("match 5 { 1 => print(1) }\nprint(\"no match is fine\")", "no match is fine\n"); + CHECK_OUTPUT("for i in range(5) {\n match i {\n 1 => continue\n 3 => break\n _ => print(i)\n }\n}", "0\n2\n"); + CHECK_OUTPUT("c := 'b'\nmatch c {\n 'a' => print(\"A\")\n 'b' => print(\"B\")\n}", "B\n"); +} + +TEST(interp_f_strings) { + CHECK_OUTPUT("name := \"Ada\"\nage := 36\nprint(f\"{name} is {age} years old, next year {age + 1}\")", + "Ada is 36 years old, next year 37\n"); + CHECK_OUTPUT("pi := 3.14159\nprint(f\"[{pi:.2f}] [{pi:8.3f}] [{42:>5}] [{42:<5}] [{42:^6}] [{7:03}] [{255:x}] [{255:#^8X}]\")", + "[3.14] [ 3.142] [ 42] [42 ] [ 42 ] [007] [ff] [###FF###]\n"); + CHECK_OUTPUT("print(f\"{1234567:,} {0.256:.1%} {-5:+} {5:+} {3:b} {\"abc\":.2} {{literal}}\")", + "1,234,567 25.6% -5 +5 11 ab {literal}\n"); + CHECK_OUTPUT("m := {k: \"v\"}\nprint(f\"{m[\"k\"]} {[1, 2]} {(1 > 0 ? \"yes\" : \"no\")} {f\"in{1}ner\"}\")", + "v [1, 2] yes in1ner\n"); + CHECK_ERROR("print(f\"{\"x\":d}\")", "invalid format spec \"d\": 'd' needs an integer, got a string"); + CHECK_ERROR("print(f\"{1:q}\")", "invalid format spec \"q\": unknown type 'q'"); +} + +TEST(interp_format_function) { + CHECK_OUTPUT("print(format(\"{} + {} = {}\", 1, 2, 3), format(\"{1} {0}\", \"a\", \"b\"), format(\"{:>6.2f}|\", 2.5))", + "1 + 2 = 3 b a 2.50|\n"); + CHECK_ERROR("format(\"{} {}\", 1)", "format(): the template needs more arguments than the 1 given"); +} + +// --------------------------------------------------------------------------- +// New builtins + +TEST(interp_collection_tools) { + CHECK_OUTPUT("print(zip([1, 2, 3], [\"a\", \"b\"]), unique([1, 2, 1, 3, 2]), flatten([[1, 2], 3, [4]]))", + "[[1, \"a\"], [2, \"b\"]] [1, 2, 3] [1, 2, 3, 4]\n"); + CHECK_OUTPUT("a := [1]\nextend(a, [2, 3])\nprint(a, count(a, 2), count(\"banana\", \"an\"))\nclear(a)\nprint(a)", + "[1, 2, 3] 1 2\n[]\n"); + CHECK_OUTPUT("evens := func(x) { return x % 2 == 0 }\n" + "print(find([1, 4, 6], evens), find([1], evens), any([1, 3], evens), all([2, 4], evens), any([0, \"\"]), all([]))", + "4 null false true false true\n"); + CHECK_OUTPUT("m := {a: 1, b: 2}\nprint(items(m), merge(m, {b: 3, c: 4}), m)", + "[[\"a\", 1], [\"b\", 2]] {\"a\": 1, \"b\": 3, \"c\": 4} {\"a\": 1, \"b\": 2}\n"); +} + +TEST(interp_string_tools) { + CHECK_OUTPUT("print(pad_left(\"7\", 3, '0'), pad_right(\"ab\", 4, \".\") + \"|\", pad_left(\"long\", 2))", + "007 ab..| long\n"); + CHECK_OUTPUT("print(lines(\"a\\r\\nb\\n\"), is_digit(\"123\"), is_digit(\"12a\"), is_alpha('x'), is_space(\" \\t\"), is_upper(\"AB\"))", + "[\"a\", \"b\"] true false true true true\n"); +} + +TEST(interp_math_tools) { + CHECK_OUTPUT("print(clamp(15, 0, 10), clamp(-1, 0, 10), sign(-3), sign(0), sign(2.5), gcd(12, 18), gcd(-4, 6))", + "10 0 -1 0 1 6 2\n"); + CHECK_OUTPUT("print(round(sin(PI / 2), 6), round(cos(0)), round(log(E), 6), log(8, 2), log10(1000), sqrt(2) ** 2 > 1.99)", + "1.0 1 1.0 3.0 3.0 true\n"); + CHECK_OUTPUT("print(is_nan(NAN), is_inf(INF), INF > 1e308, hypot(3, 4), atan2(0, 1))", "true true true 5.0 0.0\n"); + CHECK_OUTPUT("seed(1)\nxs := shuffle([1, 2, 3, 4])\nprint(len(xs), sum(xs), choice([7]) == 7)", "4 10 true\n"); + CHECK_OUTPUT("print(sum([200u8, 100u8]), type(sum([1u8])))", "300 int\n"); // sums are widened +} + +TEST(interp_json) { + CHECK_OUTPUT("d := json_parse(\"{\\\"a\\\": [1, 2.5, true, null, \\\"x\\\\u00e9\\\"], \\\"b\\\": {}}\")\n" + "print(d, type(d.a[0]), type(d.a[1]))", + "{\"a\": [1, 2.5, true, null, \"xé\"], \"b\": {}} int float\n"); + CHECK_OUTPUT("print(json_stringify({name: \"Ada\", tags: [\"a\", 'b'], n: null, ok: true, pi: 3.5}))", + "{\"name\":\"Ada\",\"tags\":[\"a\",\"b\"],\"n\":null,\"ok\":true,\"pi\":3.5}\n"); + CHECK_OUTPUT("print(json_stringify({a: [1, 2], b: {}}, 2))", "{\n \"a\": [\n 1,\n 2\n ],\n \"b\": {}\n}\n"); + CHECK_OUTPUT("v := {list: [1, {x: \"y\"}]}\nprint(json_parse(json_stringify(v)) == v)", "true\n"); + CHECK_ERROR("json_parse(\"{\\\"a\\\": }\")", "json_parse(): unexpected character '}' (line 1, column 7)"); + CHECK_ERROR("json_stringify(NAN)", "JSON can't represent nan"); + CHECK_ERROR("a := []\na.push(a)\njson_stringify(a)", "the value contains itself"); +} + +TEST(interp_files) { + const std::string path = "ascend_test_file.txt"; + std::remove(path.c_str()); + CHECK_OUTPUT("p := \"" + path + "\"\nprint(file_exists(p))\nwrite_file(p, \"one\\n\")\nappend_file(p, \"two\\n\")\n" + "print(file_exists(p), repr(read_file(p)), read_lines(p))\nprint(delete_file(p), file_exists(p))", + "false\ntrue \"one\\ntwo\\n\" [\"one\", \"two\"]\ntrue false\n"); + CHECK_ERROR("read_file(\"no_such_file_here.txt\")", "read_file(): can't open 'no_such_file_here.txt'"); +} + +TEST(interp_system_tools) { + CHECK_OUTPUT("print(type(clock()), type(time()), len(date(\"%Y\")), env(\"ASCEND_SURELY_UNSET_VARIABLE\"))", + "float float 4 null\n"); +} + +TEST(interp_imports) { + TempFile module("ascend_test_module.asc", + "func square(n: i32) -> i32 { return n * n }\n" + "VERSION := \"1.0\"\n_private := 1\n" + "func fail() { return 1 / 0 }\n"); + TempFile broken("ascend_test_broken.asc", "x := (\n"); + CHECK_OUTPUT("m := import(\"ascend_test_module\")\nprint(m.square(9), m.VERSION, keys(m))\n" + "again := import(\"ascend_test_module.asc\")\nprint(again == m)", + "81 1.0 [\"VERSION\", \"fail\", \"square\"]\ntrue\n"); + // Errors inside a module point at the module's own file. + CHECK_ERROR("m := import(\"ascend_test_module\")\nm.fail()", "ascend_test_module.asc:4:24: runtime error: division by zero"); + CHECK_ERROR("import(\"ascend_test_broken\")", "can't import \"ascend_test_broken\" because it has errors"); + CHECK_ERROR("import(\"ascend_no_such_module\")", "file 'ascend_no_such_module.asc' not found"); +} + +TEST(interp_check_only_mode) { + std::ostringstream out; + std::ostringstream err; + std::istringstream in; + Interpreter interpreter(out, in); + RunOptions options; + options.checkOnly = true; + CHECK(runSource(interpreter, "print(\"not run\")", options, err).status == RunStatus::Ok); + CHECK_EQ(out.str(), std::string("")); + CHECK(runSource(interpreter, "x: u8 = 999", options, err).status == RunStatus::SyntaxError); +} diff --git a/tests/lexer_tests.cpp b/tests/lexer_tests.cpp index 196f7b3..b283569 100644 --- a/tests/lexer_tests.cpp +++ b/tests/lexer_tests.cpp @@ -60,13 +60,13 @@ TEST(lexer_keywords_and_word_operators) { TEST(lexer_numbers) { auto result = tokens("42 3.25 1e3 2.5e-2 1_000_000"); CHECK(result[0].type == TokenType::Int); - CHECK_EQ(result[0].intValue, 42); + CHECK_EQ(result[0].intValue, 42u); CHECK(result[1].type == TokenType::Float); CHECK_EQ(result[1].floatValue, 3.25); CHECK(result[2].type == TokenType::Float); CHECK_EQ(result[2].floatValue, 1000.0); CHECK_EQ(result[3].floatValue, 0.025); - CHECK_EQ(result[4].intValue, 1000000); + CHECK_EQ(result[4].intValue, 1000000u); } TEST(lexer_number_followed_by_method_call) { @@ -75,11 +75,22 @@ TEST(lexer_number_followed_by_method_call) { } TEST(lexer_strings_and_escapes) { - auto result = tokens(R"("a\tb\n" 'it\'s' "\x41\\")"); + auto result = tokens(R"("a\tb\n" "it's \"quoted\"" "\x41\\" "\u{e9}")"); CHECK(result[0].type == TokenType::String); CHECK_EQ(result[0].text, std::string("a\tb\n")); - CHECK_EQ(result[1].text, std::string("it's")); + CHECK_EQ(result[1].text, std::string("it's \"quoted\"")); CHECK_EQ(result[2].text, std::string("A\\")); + CHECK_EQ(result[3].text, std::string("\xC3\xA9")); // U+00E9 in UTF-8 +} + +TEST(lexer_char_literals) { + auto result = tokens(R"('a' '\n' '\'' 'é' '\u{1F600}')"); + CHECK(result[0].type == TokenType::Char); + CHECK(result[0].charValue == U'a'); + CHECK(result[1].charValue == U'\n'); + CHECK(result[2].charValue == U'\''); + CHECK(result[3].charValue == U'\u00e9'); + CHECK(result[4].charValue == U'\U0001F600'); } TEST(lexer_comments_are_skipped) { @@ -112,7 +123,9 @@ TEST(lexer_positions) { TEST(lexer_errors) { CHECK_EQ(firstError("\"abc"), std::string("unterminated string (missing closing \")")); CHECK_EQ(firstError("/* never closed"), std::string("unterminated block comment (missing '*/')")); - CHECK_EQ(firstError("a & b"), std::string("unexpected character '&' (use '&&' or 'and' for logical AND)")); + CHECK_EQ(firstError("'ab'"), std::string("a char literal holds exactly one character; use double quotes for strings")); + CHECK_EQ(firstError("''"), std::string("empty char literal ''")); + CHECK_EQ(firstError("x := 3$"), std::string("unexpected character '$'")); CHECK_EQ(firstError("x := @"), std::string("unexpected character '@'")); CHECK_EQ(firstError("\"\\q\""), std::string("unknown escape sequence '\\q'")); CHECK_EQ(firstError("12abc"), std::string("invalid number '12abc'")); @@ -124,3 +137,45 @@ TEST(lexer_reports_unterminated_comment_for_repl) { lexer.tokenize(); CHECK(lexer.endedInsideComment()); } + +TEST(lexer_number_bases_and_suffixes) { + auto result = tokens("0xFF 0b1010 0o17 1_000u16 255u8 7i64 2.5f32 3f64 0xFFu8 0x1f32"); + CHECK_EQ(result[0].intValue, 255u); + CHECK_EQ(result[1].intValue, 10u); + CHECK_EQ(result[2].intValue, 15u); + CHECK_EQ(result[3].intValue, 1000u); + CHECK(result[3].numKind == ascend::NumKind::U16); + CHECK(result[4].numKind == ascend::NumKind::U8); + CHECK(result[5].numKind == ascend::NumKind::I64); + CHECK(result[6].type == TokenType::Float); + CHECK(result[6].numKind == ascend::NumKind::F32); + CHECK(result[7].type == TokenType::Float); // a float suffix makes an integer literal a float + CHECK(result[8].numKind == ascend::NumKind::U8); + CHECK_EQ(result[9].intValue, 0x1f32u); // 'f' is a hex digit, not a suffix + CHECK_EQ(firstError("10u9"), std::string("invalid number '10u9'")); + CHECK_EQ(firstError("0b102"), std::string("invalid number '0b102'")); + CHECK_EQ(firstError("1.5i32"), std::string("'i32' is an integer type, but 1.5 has a fraction or exponent")); +} + +TEST(lexer_bitwise_and_arrow_operators) { + std::vector expected = { + TokenType::Ampersand, TokenType::Pipe, TokenType::Caret, TokenType::Tilde, + TokenType::ShiftLeft, TokenType::ShiftRight, TokenType::AmpersandEqual, TokenType::PipeEqual, + TokenType::CaretEqual, TokenType::ShiftLeftEqual, TokenType::ShiftRightEqual, TokenType::Arrow, + TokenType::FatArrow, TokenType::Const, TokenType::Match, TokenType::Eof}; + CHECK(types("& | ^ ~ << >> &= |= ^= <<= >>= -> => const match") == expected); +} + +TEST(lexer_f_strings) { + std::vector expected = { + TokenType::FStringStart, TokenType::String, TokenType::InterpStart, TokenType::Identifier, + TokenType::InterpEnd, TokenType::String, TokenType::InterpStart, TokenType::Identifier, + TokenType::LeftBracket, TokenType::String, TokenType::RightBracket, TokenType::FormatSpec, + TokenType::InterpEnd, TokenType::FStringEnd, TokenType::Eof}; + CHECK(types(R"(f"Hi {name}, {{x}} {m["k"]:>5}")") == expected); + auto result = tokens(R"(f"a{{b}}c {x:>5}")"); + CHECK_EQ(result[1].text, std::string("a{b}c ")); + CHECK_EQ(result[4].text, std::string(">5")); + CHECK_EQ(firstError("f\"{x\""), std::string("unterminated string (missing closing \")")); + CHECK_EQ(firstError("f\"oops }\""), std::string("single '}' in f-string (write '}}' for a literal brace)")); +} diff --git a/tests/parser_tests.cpp b/tests/parser_tests.cpp index a8c896e..c0edc00 100644 --- a/tests/parser_tests.cpp +++ b/tests/parser_tests.cpp @@ -44,6 +44,12 @@ std::string op(TokenType type) { case TokenType::And: return "and"; case TokenType::Or: return "or"; case TokenType::Not: return "not"; + case TokenType::Tilde: return "~"; + case TokenType::Ampersand: return "&"; + case TokenType::Pipe: return "|"; + case TokenType::Caret: return "^"; + case TokenType::ShiftLeft: return "<<"; + case TokenType::ShiftRight: return ">>"; default: return "?"; } } @@ -106,7 +112,19 @@ std::string dump(const Expr* expr) { case ExprKind::Function: { const auto& function = expr->as(); std::string out = "(func"; - for (const auto& param : function.params) out += " " + param.name; + for (const auto& param : function.params) { + out += " " + param.name; + if (!param.type.isAny()) out += ":" + param.type.name(); + } + if (!function.returnType.isAny()) out += " -> " + function.returnType.name(); + return out + ")"; + } + case ExprKind::FString: { + std::string out = "(f"; + for (const auto& part : expr->as().parts) { + out += " " + (part.expr ? dump(part.expr.get()) + (part.spec.empty() ? "" : ":" + part.spec) + : toRepr(Value::fromString(part.text))); + } return out + ")"; } } @@ -142,7 +160,8 @@ TEST(parser_arithmetic_precedence) { TEST(parser_power_and_unary_minus) { CHECK_EQ(expr("-2 ** 2"), std::string("(- (** 2 2))")); CHECK_EQ(expr("2 ** 3 ** 2"), std::string("(** 2 (** 3 2))")); - CHECK_EQ(expr("2 ** -1"), std::string("(** 2 (- 1))")); + CHECK_EQ(expr("2 ** -1"), std::string("(** 2 -1)")); // a minus before a number is part of the literal + CHECK_EQ(expr("-x ** 2"), std::string("(- (** x 2))")); } TEST(parser_logic_and_comparison) { @@ -288,3 +307,70 @@ TEST(parser_survives_deep_nesting) { deep += std::string(5000, ')'); CHECK_EQ(firstError(deep), std::string("code is nested too deeply")); } + +TEST(parser_bitwise_precedence) { + CHECK_EQ(expr("a & 1 == 0"), std::string("(== (& a 1) 0)")); // unlike C, & binds tighter than == + CHECK_EQ(expr("1 << 2 + 3"), std::string("(<< 1 (+ 2 3))")); + CHECK_EQ(expr("a | b ^ c & d"), std::string("(| a (^ b (& c d)))")); + CHECK_EQ(expr("~x & 0xFF"), std::string("(& (~ x) 255)")); +} + +TEST(parser_typed_declarations) { + Parsed typed = parse("age: u8 = 30"); + CHECK(typed.errors.empty()); + const auto& decl = onlyStatement(typed).as(); + CHECK(decl.hasType); + CHECK(decl.type.kind == TypeKind::Number); + CHECK(decl.type.num == NumKind::U8); + CHECK(decl.value != nullptr); + + Parsed zero = parse("name: string?"); + CHECK(zero.errors.empty()); + const auto& noValue = onlyStatement(zero).as(); + CHECK(noValue.type.nullable); + CHECK(noValue.value == nullptr); + + Parsed constant = parse("const MAX: i64 = 10"); + CHECK(constant.errors.empty()); + CHECK(onlyStatement(constant).as().constant); + CHECK(parse("const LIMIT := 3").errors.empty()); + + CHECK(firstError("x: int32 = 1").find("unknown type 'int32'") == 0); + CHECK_EQ(firstError("x: void"), std::string("'void' can only be used as a return type")); + CHECK_EQ(firstError("x: i32 := 5"), std::string("use '=' after a type, like 'x: i32 = ...'")); + CHECK_EQ(firstError("const X"), std::string("a constant needs a value: 'const X = ...'")); +} + +TEST(parser_typed_functions) { + CHECK_EQ(expr("func(a: i32, b: string? = null) -> f64 { return 1.0 }"), std::string("(func a:i32 b:string? -> f64)")); + CHECK_EQ(expr("func() -> void { }"), std::string("(func -> void)")); +} + +TEST(parser_number_literal_ranges) { + CHECK_EQ(expr("-128i8"), std::string("-128")); + CHECK_EQ(expr("-9223372036854775808"), std::string("-9223372036854775808")); + CHECK_EQ(expr("18446744073709551615u64"), std::string("18446744073709551615")); + CHECK_EQ(firstError("300u8"), std::string("300u8 doesn't fit in u8 (0 to 255)")); + CHECK_EQ(firstError("-1u8"), std::string("-1u8 is negative, but u8 is unsigned")); + CHECK(firstError("9223372036854775808").find("9223372036854775808 is too large for int") == 0); +} + +TEST(parser_match) { + Parsed parsed = parse("match x {\n 1, 2 => print(1)\n 3 => {\n y := 2\n }\n else => print(0)\n}"); + CHECK(parsed.errors.empty()); + const auto& match = onlyStatement(parsed).as(); + CHECK_EQ(match.arms.size(), size_t(3)); + CHECK_EQ(match.arms[0].patterns.size(), size_t(2)); + CHECK(match.arms[1].body.declaresNames); + CHECK(match.arms[2].patterns.empty()); + CHECK(parse("match x { _ => print(0) }").errors.empty()); + CHECK_EQ(firstError("match x {\n else => 1\n _ => 2\n}"), std::string("a match can only have one default ('else') arm")); + CHECK_EQ(firstError("match x { 1 print(1) }"), std::string("expected '=>' after the match pattern, found identifier 'print'")); +} + +TEST(parser_f_strings) { + CHECK_EQ(expr("f\"x = {x + 1:>4}!\""), std::string("(f \"x = \" (+ x 1):>4 \"!\")")); + CHECK_EQ(expr("f\"{a}{b}\""), std::string("(f a b)")); + CHECK(firstError("f\"{a ? b : c}\"").find("put a conditional expression in parentheses") != std::string::npos); + CHECK(parse("f\"{(a ? b : c)}\"").errors.empty()); +} From a4f570d53b77b8ca7120ce763d3ba4313807992a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:21:57 +0000 Subject: [PATCH 2/4] Use C-style types and add a GitHub Actions build Types are now written before the name, like in C: u8 age = 30 string? nickname const f64 RATE = 0.07 i32 add(i32 a, i32 b = 1) { return a + b } void log(string message) { print(message) } Typed parameters are `type name`, and a typed function can put its return type in front of its name. Untyped code is unchanged (`x := 5`, `func f(a, b) { }`), anonymous functions can still use `-> type`, and `func handler = print` declares a variable holding a function. The old `name: type` form is rejected with a hint showing the C-style spelling. CI: .github/workflows/build.yml builds and tests on Linux (GCC and Clang), macOS and Windows (MSVC), runs the tests under ASan and UBSan, and uploads the built interpreters as artifacts. MSVC now compiles with /utf-8, since the tests contain UTF-8 text. Tests, examples, docs, the changelog and the VS Code grammar are updated for the new syntax (114 tests). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014xWEy3pVr7ofMaomGRbiuo --- .github/workflows/build.yml | 82 +++++++++++++ CHANGELOG.md | 20 ++-- CMakeLists.txt | 5 + README.md | 21 ++-- docs/installation.md | 9 ++ docs/syntax.md | 72 +++++++---- .../vscode/syntaxes/ascend.tmLanguage.json | 10 ++ examples/bits.asc | 12 +- examples/geometry.asc | 8 +- examples/text.asc | 2 +- examples/types.asc | 37 +++--- src/include/ast.hpp | 4 +- src/include/parser.hpp | 6 +- src/include/semantic_analyzer.hpp | 2 +- src/include/types.hpp | 4 +- src/interpreter.cpp | 4 +- src/parser.cpp | 112 ++++++++++++++---- src/semantic_analyzer.cpp | 2 +- tests/interpreter_tests.cpp | 99 +++++++++------- tests/parser_tests.cpp | 44 +++++-- 20 files changed, 399 insertions(+), 156 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2dac50c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,82 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux (GCC) + os: ubuntu-latest + compiler: g++ + artifact: ascend-linux-x64 + - name: Linux (Clang) + os: ubuntu-latest + compiler: clang++ + - name: macOS + os: macos-latest + artifact: ascend-macos-arm64 + - name: Windows (MSVC) + os: windows-latest + artifact: ascend-windows-x64 + + steps: + - uses: actions/checkout@v4 + + - name: Configure + if: ${{ !matrix.compiler }} + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + + - name: Configure with ${{ matrix.compiler }} + if: ${{ matrix.compiler }} + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=${{ matrix.compiler }} + + - name: Build + run: cmake --build build --config Release --parallel + + - name: Test + run: ctest --test-dir build --build-config Release --output-on-failure + + - name: Upload the interpreter + if: ${{ matrix.artifact }} + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + build/ascend + build/Release/ascend.exe + if-no-files-found: error + + sanitizers: + name: Sanitizers (ASan + UBSan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Newer kernels use more address randomization than older ASan runtimes expect. + - name: Make address randomization compatible with ASan + run: sudo sysctl vm.mmap_rnd_bits=28 + + - name: Configure + run: > + cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_COMPILER=g++ + "-DCMAKE_CXX_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=all" + + - name: Build + run: cmake --build build --parallel + + - name: Test + run: ctest --test-dir build --output-on-failure + env: + ASAN_OPTIONS: detect_leaks=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f3831ff..80d248a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,17 @@ ### New -- **Optional types**: `name: type = value` declarations with `i8` `i16` `i32` - `i64` `u8` `u16` `u32` `u64` `byte` `int` `f32` `f64` `float` `bool` `char` - `string` `list` `map` `func` `any`, and nullable types like `string?`. - Typed variables reject values of the wrong type, and numbers that don't fit. - `name: type` without a value starts at the type's zero value. +- **Optional C-style types**: `type name = value` declarations, like + `u8 age = 30`, with `i8` `i16` `i32` `i64` `u8` `u16` `u32` `u64` `byte` + `int` `f32` `f64` `float` `bool` `char` `string` `list` `map` `func` `any`, + and nullable types like `string?`. Typed variables reject values of the + wrong type, and numbers that don't fit. `type name` without a value starts + at the type's zero value. - Every number carries its type. Arithmetic stays in that type and reports overflow as an error (`250u8 + 10` fails instead of wrapping). -- Typed function parameters and return types: `func f(a: i32) -> f64 { }`, - plus `-> void`. +- C-style typed functions: `f64 area(f64 w, f64 h) { }` and + `void log(string message) { }`. Untyped functions still use `func`, and + anonymous functions can declare a return type with `func(i32 x) -> i32 { }`. - `const` declarations. - `match` statements. - f-strings with format specs: `f"{name:<10} {price:>8.2f}"`, and `format()`. @@ -22,7 +24,7 @@ - Bitwise operators `& | ^ ~ << >>` and their compound assignments. - Unicode strings: `len`, indexing, slicing and iteration count characters. - Modules: `import("file")` returns a file's top-level names as a map. -- Type mismatches in literals (`x: u8 = 300`) and in `return` values are +- Type mismatches in literals (`u8 x = 300`) and in `return` values are reported before the program runs. - New builtins: type conversions (`i8()` ... `u64()`, `f32()`, `f64()`, `char()`, `string()`, `byte()`), `format`, `hex`, `bin`, `chars`, `bytes`, @@ -34,6 +36,8 @@ `json_parse`, `json_stringify`, `clock`, `sleep`, `date`, `env`, `system`. - `ascend --check file.asc` reports errors without running the program. - Syntax highlighting for VS Code in `editors/vscode`. +- GitHub Actions builds and tests every change on Linux, macOS and Windows, + and uploads the built interpreters. ### Changed (may affect existing programs) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a89100..fce48ee 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,6 +10,11 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) endif() +# The sources and tests contain UTF-8 text; MSVC otherwise assumes the system code page. +if(MSVC) + add_compile_options(/utf-8) +endif() + # Everything except main() lives in a library so the tests can use it too. add_library(ascend_core STATIC src/error.cpp diff --git a/README.md b/README.md index a7d3e65..0253042 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Ascend +[![Build](https://github.com/csubbdev/Ascend/actions/workflows/build.yml/badge.svg)](https://github.com/csubbdev/Ascend/actions/workflows/build.yml) + Ascend is a small, friendly scripting language with optional types, written in C++17. ``` -func fizzbuzz(n: i32) -> string { +string fizzbuzz(i32 n) { if n % 15 == 0 { return "FizzBuzz" } if n % 3 == 0 { return "Fizz" } if n % 5 == 0 { return "Buzz" } @@ -15,7 +17,7 @@ for i in range(1, 16) { print(fizzbuzz(i)) } -level: u8 = 2 +u8 level = 2 match level { 1 => print("easy") 2, 3 => print("normal") @@ -30,10 +32,11 @@ for name, score in scores { ## Features -- **Optional types**: `i8`–`i64`, `u8`–`u64`, `byte`, `f32`, `f64`, `bool`, - `char`, `string`, `list`, `map`, `func` and nullable `T?`. Typed variables, - parameters and return values are checked, and integer overflow is an error - instead of a silent wrap. +- **Optional C-style types**: `u8 age = 30`, `i32 add(i32 a, i32 b) { }` with + `i8`–`i64`, `u8`–`u64`, `byte`, `f32`, `f64`, `bool`, `char`, `string`, + `list`, `map`, `func`, `void` and nullable types like `string?`. Typed + variables, parameters and return values are checked, and integer overflow is + an error instead of a silent wrap. - Constants, `match`, `if` / `else if`, `while`, `for ... in` over ranges, lists, strings and maps, `break` / `continue` - Functions with default parameters, recursion, closures and anonymous functions @@ -46,9 +49,9 @@ for name, score in scores { - Clear error messages that point at the problem, many caught before running: ``` - script.asc:3:9: error: 'age' is declared as u8, so it can't hold 300 (u8 is 0 to 255) - 3 | age: u8 = 300 - | ^ + script.asc:3:10: error: 'age' is declared as u8, so it can't hold 300 (u8 is 0 to 255) + 3 | u8 age = 300 + | ^ ``` - An interactive REPL, a `--check` mode, and syntax highlighting for VS Code diff --git a/docs/installation.md b/docs/installation.md index 4e71a82..32f804f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -48,6 +48,15 @@ filter them by name: To skip building the tests, configure with `-DASCEND_BUILD_TESTS=OFF`. +## Continuous integration + +Every push to `main` and every pull request is built and tested by GitHub +Actions (`.github/workflows/build.yml`) on Linux (GCC and Clang), macOS and +Windows (MSVC), plus a run with AddressSanitizer and UndefinedBehaviorSanitizer. +Each run also uploads ready-to-use interpreters for Linux, macOS and Windows: +open the run on the repository's **Actions** tab and download them under +**Artifacts**. + ## Editor support `editors/vscode` is a Visual Studio Code extension with syntax highlighting, diff --git a/docs/syntax.md b/docs/syntax.md index 97bce0a..d591394 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -1,11 +1,11 @@ # Ascend language guide -Ascend is a small scripting language with optional static-style types. This -page covers the whole language; the builtin functions are listed in +Ascend is a small scripting language with optional C-style types. This page +covers the whole language; the builtin functions are listed in [standard_library.md](standard_library.md). ``` -func greet(name: string, greeting: string = "Hello") -> string { +string greet(string name, string greeting = "Hello") { return f"{greeting}, {name}!" } @@ -30,17 +30,17 @@ inside `( )` and `[ ]`, so long calls and lists can span several lines. ## Variables and types -Types are optional. Write `name := value` to let the value decide, or -`name: type = value` to fix the variable's type: +Types are optional. Write `name := value` to let the value decide, or put a +type in front of the name, like in C, to fix the variable's type: ``` count := 0 // no declared type -age: u8 = 30 // always a u8 -ratio: f32 = 0.5 -initial: char = 'A' -title: string = "Ascend" -nickname: string? // the ? allows null; without a value this starts as null -total: i64 // without a value it starts at zero +u8 age = 30 // always a u8 +f32 ratio = 0.5 +char initial = 'A' +string title = "Ascend" +string? nickname // the ? allows null; without a value this starts as null +i64 total // without a value it starts at zero ``` `=` changes an existing variable (`count = 5`, `count += 1`). Assigning to a @@ -50,15 +50,15 @@ A typed variable only ever holds values of its type. Numbers convert automatically when nothing is lost; anything else is an error: ``` -age: u8 = 30 +u8 age = 30 age = 31 // fine age = 300 // error: 'age' is declared as u8, so it can't hold 300 (u8 is 0 to 255) age = "old" // error: ... can't hold a string (use u8(...) to convert a string) -x: i32 = 2.0 // fine: 2.0 is a whole number +i32 x = 2.0 // fine: 2.0 is a whole number x = 2.5 // error: ... (use i32(...) to drop the fraction) ``` -Mistakes in literals like `age: u8 = 300` are reported before the program +Mistakes in literals like `u8 age = 300` are reported before the program starts running. ### The types @@ -90,7 +90,7 @@ Without a value, a typed variable starts at its type's zero value: `0`, ``` const MAX_PLAYERS = 4 -const RATE: f64 = 0.07 +const f64 RATE = 0.07 MAX_PLAYERS = 5 // error: can't change 'MAX_PLAYERS': it's a constant ``` @@ -117,14 +117,14 @@ else is truthy. How arithmetic picks its result type: -- A plain literal adapts to the other side: with `x: u8`, `x + 1` is a `u8`. +- A plain literal adapts to the other side: with `u8 x`, `x + 1` is a `u8`. - Two different integer types give the smallest type that holds both: `u8 + i32` is an `i32`, `u8 + i8` is an `i16`. (`u64` can't be mixed with signed types; convert one side.) - An integer and a float give the float's type: `i32 + f32` is an `f32`. - `f32` and `f64` give `f64`. -**Overflow is an error**, never a silent wrap: with `x: u8 = 250`, `x + 10` +**Overflow is an error**, never a silent wrap: with `u8 x = 250`, `x + 10` reports `integer overflow: 250 + 10 doesn't fit in u8 (0 to 255)`. - `int / int` truncates toward zero (`7 / 2 == 3`). If either side is a float @@ -282,25 +282,45 @@ arm affect the surrounding loop or function. ## Functions +A typed function is written like in C: the return type first, then the name, +then the parameters, each with its type before its name: + ``` -func area(width: f64, height: f64 = width) -> f64 { +f64 area(f64 width, f64 height = width) { return width * height } +void log(string message) { + print("log:", message) +} + area(3) // 9.0 area(3, 4) // 12.0 ``` -- Parameter and return types are optional. Arguments are converted to the - parameter's type (an `int` passed to an `f64` parameter becomes a float) or - rejected: `area("big")` is an error. -- `-> void` means the function returns nothing; `-> T?` allows returning `null`. - A function with a return type that ends without `return` is an error. +Functions without types start with `func`: + +``` +func add(a, b) { + return a + b +} +``` + +- Types are optional, and can be mixed: `func f(i32 count, label) { }`. + Arguments are converted to the parameter's type (an `int` passed to an `f64` + parameter becomes a float) or rejected: `area("big")` is an error. +- `void` means the function returns nothing; `string?` and other nullable + return types allow returning `null`. A function with a return type that + ends without `return` is an error. - A function without `return` (or with a bare `return`) returns `null`. - Parameters with default values must come after the ones without. -- Functions declared with `func name(...)` can be called anywhere in the block - that declares them, even before the declaration, so they can call each other. +- Functions can be called anywhere in the block that declares them, even + before the declaration, so they can call each other. - Anonymous functions are expressions: `double := func(x) { return x * 2 }`. + They can have types too, with the return type after an arrow: + `func(i32 x) -> i32 { return x * 2 }`. +- To store a function in a typed variable, use the type `func`: + `func handler = print`. - Functions are closures: they keep access to the variables around them. ``` @@ -339,7 +359,7 @@ Names starting with `_` stay private. ``` // geometry.asc -func circle_area(r: f64) -> f64 { return PI * r ** 2 } +f64 circle_area(f64 r) { return PI * r ** 2 } // main.asc geometry := import("geometry") diff --git a/editors/vscode/syntaxes/ascend.tmLanguage.json b/editors/vscode/syntaxes/ascend.tmLanguage.json index 64e90f2..aa326bf 100644 --- a/editors/vscode/syntaxes/ascend.tmLanguage.json +++ b/editors/vscode/syntaxes/ascend.tmLanguage.json @@ -9,6 +9,7 @@ { "include": "#chars" }, { "include": "#numbers" }, { "include": "#function-declaration" }, + { "include": "#typed-function-declaration" }, { "include": "#keywords" }, { "include": "#types" }, { "include": "#constants" }, @@ -75,6 +76,15 @@ "2": { "name": "entity.name.function.ascend" } } }, + "typed-function-declaration": { + "comment": "C-style function: the return type, then the name, e.g. i32 add(i32 a, i32 b)", + "match": "\\b(i8|i16|i32|i64|u8|u16|u32|u64|int|byte|f32|f64|float|bool|char|string|list|map|any|void)(\\?)?\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()", + "captures": { + "1": { "name": "storage.type.ascend" }, + "2": { "name": "storage.type.ascend" }, + "3": { "name": "entity.name.function.ascend" } + } + }, "keywords": { "patterns": [ { diff --git a/examples/bits.asc b/examples/bits.asc index 403fbde..fd36bf2 100644 --- a/examples/bits.asc +++ b/examples/bits.asc @@ -1,11 +1,11 @@ // Bitwise operators: & | ^ ~ << >> // Unlike C, they bind tighter than comparisons, so `x & 1 == 0` works as expected. -READ: u8 = 0b001 -WRITE: u8 = 0b010 -EXECUTE: u8 = 0b100 +u8 READ = 0b001 +u8 WRITE = 0b010 +u8 EXECUTE = 0b100 -permissions: u8 = READ | WRITE +u8 permissions = READ | WRITE print("can write?", permissions & WRITE != 0) print("can execute?", permissions & EXECUTE != 0) @@ -14,7 +14,7 @@ permissions &= ~WRITE // revoke print(f"permissions = {permissions:03b}") // Packing a color into 24 bits and unpacking it again. -func pack(r: u8, g: u8, b: u8) -> u32 { +u32 pack(u8 r, u8 g, u8 b) { return u32(r) << 16 | u32(g) << 8 | u32(b) } @@ -22,7 +22,7 @@ color := pack(255, 128, 0) print(f"color = {hex(color)}, red = {color >> 16 & 0xFF}, green = {color >> 8 & 0xFF}, blue = {color & 0xFF}") // Count the 1 bits in a number. -func popcount(n: u64) -> i32 { +i32 popcount(u64 n) { count := 0 while n != 0 { count += n & 1 diff --git a/examples/geometry.asc b/examples/geometry.asc index 85f1e61..b9ba6e3 100644 --- a/examples/geometry.asc +++ b/examples/geometry.asc @@ -3,18 +3,18 @@ const PI_2 = PI * 2 -func circle_area(radius: f64) -> f64 { +f64 circle_area(f64 radius) { return PI * radius ** 2 } -func circle_circumference(radius: f64) -> f64 { +f64 circle_circumference(f64 radius) { return PI_2 * radius } -func distance(a: map, b: map) -> f64 { +f64 distance(map a, map b) { return hypot(b.x - a.x, b.y - a.y) } -func _check(value: f64) { +void _check(f64 value) { assert(value >= 0, "expected a non-negative value") } diff --git a/examples/text.asc b/examples/text.asc index 4a0b435..8b615f5 100644 --- a/examples/text.asc +++ b/examples/text.asc @@ -29,7 +29,7 @@ for i, c in "héllo" { } // A simple Caesar cipher using char arithmetic. -func shift(text: string, by: i32) -> string { +string shift(string text, i32 by) { result := "" for c in text { if c >= 'a' and c <= 'z' { diff --git a/examples/types.asc b/examples/types.asc index 3f613f0..e93f606 100644 --- a/examples/types.asc +++ b/examples/types.asc @@ -1,14 +1,14 @@ -// Typed variables: name: type = value -age: u8 = 30 -temperature: i16 = -12 -population: u64 = 8_100_000_000 -ratio: f32 = 0.75 -precise: f64 = 3.141592653589793 -grade: char = 'A' -name: string = "Ada" -active: bool = true -nickname: string? // the ? allows null; this starts as null -count: i32 // without a value it starts at zero +// Typed variables, C style: type name = value +u8 age = 30 +i16 temperature = -12 +u64 population = 8_100_000_000 +f32 ratio = 0.75 +f64 precise = 3.141592653589793 +char grade = 'A' +string name = "Ada" +bool active = true +string? nickname // the ? allows null; this starts as null +i32 count // without a value it starts at zero print(name, age, temperature, population, ratio, precise, grade, active, nickname, count) print(type(age), type(ratio), type(grade), type(42), type(4.2)) @@ -23,7 +23,7 @@ try { } // Arithmetic stays in the type, and overflow is an error instead of a silent wrap. -small: u8 = 250 +u8 small = 250 try { small += 10 } catch e { @@ -31,19 +31,20 @@ try { } // Mixing types widens to a type that holds both. -a: u8 = 200 -b: i32 = -1000 +u8 a = 200 +i32 b = -1000 print(type(a + b), a + b) // Explicit conversions use the type name. print(u8(65), char(66), i32("42") + 1, f32(1) / 3, int(9.99), byte('A')) // Constants can't be changed. -const MAX_PLAYERS: u8 = 4 +const u8 MAX_PLAYERS = 4 print("max players:", MAX_PLAYERS) -// Typed parameters and return values. -func average(values: list) -> f64 { +// Typed functions: the return type goes before the name, and each +// parameter has its type before its name. +f64 average(list values) { if len(values) == 0 { return 0 } @@ -51,7 +52,7 @@ func average(values: list) -> f64 { } print("average:", average([3, 4, 8])) -func describe(n: i64) -> string { +string describe(i64 n) { match n { 0 => return "zero" 1, 2, 3 => return "a few" diff --git a/src/include/ast.hpp b/src/include/ast.hpp index 1a62788..5feba07 100644 --- a/src/include/ast.hpp +++ b/src/include/ast.hpp @@ -171,14 +171,14 @@ struct ExpressionStmt final : Stmt { ExprPtr expr; }; -// name := value name: type = value name: type const name = value +// name := value type name = value type name const [type] name = value struct DeclareStmt final : Stmt { DeclareStmt(int line, int column) : Stmt(StmtKind::Declare, line, column) {} std::string name; TypeSpec type; // `any` when no type was written bool hasType = false; bool constant = false; - ExprPtr value; // null for `name: type`, which starts at the type's zero value + ExprPtr value; // null for `type name`, which starts at the type's zero value }; // target = value, target += value, ... diff --git a/src/include/parser.hpp b/src/include/parser.hpp index e5a3ccc..c165505 100644 --- a/src/include/parser.hpp +++ b/src/include/parser.hpp @@ -44,10 +44,14 @@ class Parser { StmtPtr throwStatement(const Token& keyword); StmtPtr functionDeclaration(const Token& keyword); StmtPtr typedDeclaration(); + StmtPtr typedFunctionDeclaration(); StmtPtr constDeclaration(const Token& keyword); StmtPtr matchStatement(const Token& keyword); - std::unique_ptr functionRest(const Token& keyword, const std::string& name); + std::unique_ptr functionRest(const Token& keyword, const std::string& name, + const TypeSpec* returnType = nullptr); TypeSpec parseType(bool allowVoid); + size_t typeLength(size_t offset) const; + static std::string unknownTypeMessage(const std::string& name); Block block(const std::string& what); static void noteStatement(Block& block, const Stmt& stmt); void parseStatementInto(Block& block); diff --git a/src/include/semantic_analyzer.hpp b/src/include/semantic_analyzer.hpp index c0d9267..837b588 100644 --- a/src/include/semantic_analyzer.hpp +++ b/src/include/semantic_analyzer.hpp @@ -17,7 +17,7 @@ namespace ascend { // - `return` outside a function, or a return that doesn't match the // function's declared return type // - a function with two parameters of the same name -// - a literal that doesn't fit its declared type: `x: u8 = 300` +// - a literal that doesn't fit its declared type: `u8 x = 300` class SemanticAnalyzer { public: std::vector analyze(const Program& program); diff --git a/src/include/types.hpp b/src/include/types.hpp index da8d8de..da466a0 100644 --- a/src/include/types.hpp +++ b/src/include/types.hpp @@ -10,7 +10,7 @@ namespace ascend { // // `Int` and `Float` are the types of plain literals like `42` and `1.5`. // They behave like i64 and f64, but adapt to the other operand in arithmetic, -// so with `x: u8 = 200`, `x + 1` is a u8 (like untyped constants in Go). +// so with `u8 x = 200`, `x + 1` is a u8 (like untyped constants in Go). enum class NumKind : uint8_t { Int, I8, I16, I32, I64, U8, U16, U32, U64, Float, F32, F64 }; inline bool isIntKind(NumKind kind) { return kind <= NumKind::U64; } @@ -26,7 +26,7 @@ std::string rangeText(NumKind kind); // e.g. "0 to 255" enum class TypeKind : uint8_t { Any, Void, Bool, Number, Char, String, List, Map, Function }; -// A type annotation, such as the `u8` in `x: u8 = 1` or `string?` (nullable). +// A type annotation, such as the `u8` in `u8 x = 1` or `string?` (nullable). struct TypeSpec { TypeKind kind = TypeKind::Any; NumKind num = NumKind::Int; // used when kind == Number diff --git a/src/interpreter.cpp b/src/interpreter.cpp index 01b3026..1203ac0 100644 --- a/src/interpreter.cpp +++ b/src/interpreter.cpp @@ -586,7 +586,7 @@ void Interpreter::hoistFunctions(const Block& block, const EnvPtr& env) { } } -// The starting value of `name: type` without an initializer. +// The starting value of `type name` without an initializer. Value Interpreter::zeroValue(const TypeSpec& type) { if (type.nullable) return Value(); switch (type.kind) { @@ -713,7 +713,7 @@ Interpreter::Flow Interpreter::execReturn(const ReturnStmt& stmt, const EnvPtr& const TypeSpec& type = currentFunction_->returnType; if (type.kind == TypeKind::Void) { if (!value.isNull()) { - throw RuntimeError(functionLabel(currentFunction_->name) + " is declared '-> void', so it can't return a value"); + throw RuntimeError(functionLabel(currentFunction_->name) + " returns void, so it can't return a value"); } } else { Value converted; diff --git a/src/parser.cpp b/src/parser.cpp index d7a9f6a..7e80913 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -117,9 +117,24 @@ StmtPtr Parser::statement() { case TokenType::Throw: advance(); return throwStatement(token); case TokenType::Const: advance(); return constDeclaration(token); case TokenType::Match: advance(); return matchStatement(token); - case TokenType::Identifier: - if (peek(1).type == TokenType::Colon) return typedDeclaration(); + case TokenType::Identifier: { + // C-style declarations: `u8 age = 30` and functions `i32 add(i32 a, i32 b) { }`. + size_t length = typeLength(0); + if (length > 0 && peek(length).type == TokenType::Identifier) { + if (peek(length + 1).type == TokenType::LeftParen) return typedFunctionDeclaration(); + return typedDeclaration(); + } + if (peek(1).type == TokenType::Colon) { + // The other common style, `age: u8 = 30`: suggest the C-style form. + std::string type = typeLength(2) > 0 ? peek(2).lexeme : "i32"; + error(token, "write the type before the name, like '" + type + " " + token.lexeme + " = ...'"); + } + if (length == 0 && peek(1).type == TokenType::Identifier) { + TypeSpec unused; + if (!lookupType(token.lexeme, unused)) error(token, unknownTypeMessage(token.lexeme)); + } break; + } case TokenType::Break: { advance(); auto stmt = nodeAt(token); @@ -132,12 +147,22 @@ StmtPtr Parser::statement() { expectStatementEnd(); return stmt; } - case TokenType::Func: + case TokenType::Func: { + // `func name(...)` declares a function; `func callback = ...` and + // `func? callback` declare a variable that holds one. + TokenType afterName = peek(2).type; + bool variable = peek(1).type == TokenType::Question || + (peek(1).type == TokenType::Identifier && + (afterName == TokenType::Equal || afterName == TokenType::Newline || + afterName == TokenType::Semicolon || afterName == TokenType::Eof || + afterName == TokenType::RightBrace)); + if (variable) return typedDeclaration(); if (peek(1).type == TokenType::Identifier) { advance(); return functionDeclaration(token); } break; // an anonymous function used as an expression + } case TokenType::LeftBrace: { auto stmt = nodeAt(token); stmt->body = block("block"); @@ -266,16 +291,32 @@ StmtPtr Parser::throwStatement(const Token& keyword) { return stmt; } -// name: type = value or name: type (which starts at the type's zero value) +// How many tokens the type name starting `offset` tokens ahead takes up +// (1 for `u8`, 2 for `string?`), or 0 if there is no type name there. +size_t Parser::typeLength(size_t offset) const { + const Token& token = peek(offset); + TypeSpec type; + bool isType = token.type == TokenType::Func || (token.type == TokenType::Identifier && lookupType(token.lexeme, type)); + if (!isType) return 0; + return peek(offset + 1).type == TokenType::Question ? 2 : 1; +} + +std::string Parser::unknownTypeMessage(const std::string& name) { + return "unknown type '" + name + + "' (the types are: i8 i16 i32 i64 u8 u16 u32 u64 int byte f32 f64 float bool char string list map " + "func any)"; +} + +// type name = value or type name (which starts at the type's zero value) StmtPtr Parser::typedDeclaration() { - const Token& name = advance(); - advance(); // ':' - auto stmt = nodeAt(name); - stmt->name = name.lexeme; + const Token& start = peek(); + auto stmt = nodeAt(start); stmt->type = parseType(false); stmt->hasType = true; + const Token& name = consume(TokenType::Identifier, "a variable name after the type"); + stmt->name = name.lexeme; if (check(TokenType::Declare)) { - error(peek(), "use '=' after a type, like '" + name.lexeme + ": " + stmt->type.name() + " = ...'"); + error(peek(), "use '=' in a typed declaration, like '" + stmt->type.name() + " " + name.lexeme + " = ...'"); } if (match(TokenType::Equal)) { skipNewlines(); @@ -285,23 +326,37 @@ StmtPtr Parser::typedDeclaration() { return stmt; } -// const NAME = value or const NAME: type = value +// A function with its return type in front, C style: `i32 add(i32 a, i32 b) { ... }` +StmtPtr Parser::typedFunctionDeclaration() { + const Token& start = peek(); + TypeSpec returnType = parseType(true); + const Token& name = advance(); + auto stmt = nodeAt(start); + stmt->function = functionRest(name, name.lexeme, &returnType); + return stmt; +} + +// const NAME = value or const type NAME = value StmtPtr Parser::constDeclaration(const Token& keyword) { - const Token& name = consume(TokenType::Identifier, "a name after 'const'"); - auto stmt = nodeAt(name); - stmt->name = name.lexeme; + auto stmt = nodeAt(keyword); stmt->constant = true; - if (match(TokenType::Colon)) { + size_t length = typeLength(0); + if (length > 0 && peek(length).type == TokenType::Identifier) { stmt->type = parseType(false); stmt->hasType = true; } + const Token& name = consume(TokenType::Identifier, "a name after 'const'"); + stmt->name = name.lexeme; + if (check(TokenType::Colon)) { + std::string type = typeLength(1) > 0 ? peek(1).lexeme : "i32"; + error(peek(), "write the type before the name, like 'const " + type + " " + name.lexeme + " = ...'"); + } if (!match(TokenType::Equal) && !match(TokenType::Declare)) { error(peek(), "a constant needs a value: 'const " + name.lexeme + " = ...'"); } skipNewlines(); stmt->value = expression(); expectStatementEnd(); - (void)keyword; return stmt; } @@ -368,11 +423,7 @@ TypeSpec Parser::parseType(bool allowVoid) { error(token, "expected a type name (like i32, f64, string or bool), found " + describeToken(token)); } TypeSpec type; - if (!lookupType(name, type)) { - error(token, "unknown type '" + name + - "' (the types are: i8 i16 i32 i64 u8 u16 u32 u64 int byte f32 f64 float bool char string " - "list map func any)"); - } + if (!lookupType(name, type)) error(token, unknownTypeMessage(name)); if (type.kind == TypeKind::Void && !allowVoid) error(token, "'void' can only be used as a return type"); advance(); if (match(TokenType::Question)) { @@ -391,16 +442,25 @@ StmtPtr Parser::functionDeclaration(const Token& keyword) { return stmt; } -std::unique_ptr Parser::functionRest(const Token& keyword, const std::string& name) { +// Parameters, optional `-> type` and the body of a function. A typed +// parameter is written C style: `i32 count`. +std::unique_ptr Parser::functionRest(const Token& keyword, const std::string& name, + const TypeSpec* returnType) { auto function = nodeAt(keyword); function->name = name; + if (returnType != nullptr) function->returnType = *returnType; consume(TokenType::LeftParen, name.empty() ? "'(' after 'func'" : "'(' after the function name"); bool sawDefault = false; while (!check(TokenType::RightParen)) { - const Token& param = consume(TokenType::Identifier, "a parameter name"); Param parameter; + size_t length = typeLength(0); + if (length > 0 && peek(length).type == TokenType::Identifier) parameter.type = parseType(false); + const Token& param = consume(TokenType::Identifier, "a parameter name"); parameter.name = param.lexeme; - if (match(TokenType::Colon)) parameter.type = parseType(false); + if (check(TokenType::Colon)) { + std::string type = typeLength(1) > 0 ? peek(1).lexeme : "i32"; + error(peek(), "write the parameter's type before its name, like '" + type + " " + param.lexeme + "'"); + } if (match(TokenType::Equal)) { parameter.defaultValue = expression(); sawDefault = true; @@ -413,7 +473,11 @@ std::unique_ptr Parser::functionRest(const Token& keyword, const s if (!match(TokenType::Comma)) break; } consume(TokenType::RightParen, "')' after the parameters"); - if (match(TokenType::Arrow)) function->returnType = parseType(true); + if (check(TokenType::Arrow)) { + if (returnType != nullptr) error(peek(), "the return type is already written before the function name"); + advance(); + function->returnType = parseType(true); + } function->body = block("function body"); return function; } diff --git a/src/semantic_analyzer.cpp b/src/semantic_analyzer.cpp index 4a8d16b..6b11d71 100644 --- a/src/semantic_analyzer.cpp +++ b/src/semantic_analyzer.cpp @@ -122,7 +122,7 @@ void SemanticAnalyzer::visitStmt(const Stmt& stmt) { const TypeSpec& type = function.returnType; std::string who = function.name.empty() ? "this function" : function.name + "()"; if (type.kind == TypeKind::Void) { - if (ret.value) report(stmt.line, stmt.column, who + " is declared '-> void', so it can't return a value"); + if (ret.value) report(stmt.line, stmt.column, who + " returns void, so it can't return a value"); } else if (!ret.value && !type.isAny() && !type.nullable) { report(stmt.line, stmt.column, who + " must return " + type.name() + ", but this returns nothing"); } else { diff --git a/tests/interpreter_tests.cpp b/tests/interpreter_tests.cpp index 1ab97a1..39b62bd 100644 --- a/tests/interpreter_tests.cpp +++ b/tests/interpreter_tests.cpp @@ -476,8 +476,8 @@ TEST(interp_cycle_collector_frees_closures) { TEST(interp_typed_variables) { CHECK_OUTPUT( - "a: i8 = -5\nb: u16 = 60000\nc: u64 = 18446744073709551615u64\nd: f32 = 0.1\ne: f64 = 0.1\n" - "f: char = 'x'\ng: string = \"s\"\nh: bool = true\nk: byte = 255\n" + "i8 a = -5\nu16 b = 60000\nu64 c = 18446744073709551615u64\nf32 d = 0.1\nf64 e = 0.1\n" + "char f = 'x'\nstring g = \"s\"\nbool h = true\nbyte k = 255\n" "print(a, b, c, d, e, f, g, h, k)\n" "print(type(a), type(b), type(c), type(d), type(e), type(f), type(g), type(h), type(k), type(1), type(1.0))", "-5 60000 18446744073709551615 0.1 0.1 x s true 255\n" @@ -485,59 +485,59 @@ TEST(interp_typed_variables) { } TEST(interp_zero_values) { - CHECK_OUTPUT("a: i32\nb: f64\nc: bool\nd: string\ne: char\nf: list\ng: map\nh: string?\n" + CHECK_OUTPUT("i32 a\nf64 b\nbool c\nstring d\nchar e\nlist f\nmap g\nstring? h\n" "print(a, b, c, repr(d), ord(e), f, g, h)", "0 0.0 false \"\" 0 [] {} null\n"); } TEST(interp_typed_variables_keep_their_type) { - CHECK_OUTPUT("x: u8 = 10\nx = 20\nx += 5\nprint(x, type(x))", "25 u8\n"); - CHECK_OUTPUT("x: f64 = 1\nprint(x, type(x))", "1.0 f64\n"); // ints convert to floats - CHECK_OUTPUT("x: i32 = 3.0\nprint(x)", "3\n"); // whole floats convert to ints - CHECK_OUTPUT("s: string = 'c'\nprint(s, type(s))", "c string\n"); - CHECK_ERROR("x: u8 = 10\nx = 256", "'x' is declared as u8, so it can't hold 256 (u8 is 0 to 255)"); - CHECK_ERROR("x: i32 = 1\nx = 2.5", "can't hold 2.5 (use i32(...) to drop the fraction)"); - CHECK_ERROR("x: i32 = 1\nx = \"5\"", "can't hold a string (use i32(...) to convert a string)"); - CHECK_ERROR("x: string = \"a\"\nx = null", "can't hold null (declare it as string? to allow null)"); - CHECK_ERROR("x: char = 'a'\nx = \"b\"", "use single quotes for a char, like 'a'"); - CHECK_OUTPUT("x: string? = \"a\"\nx = null\nprint(x)", "null\n"); - CHECK_OUTPUT("x: u8 = 1\nx := \"now untyped\"\nprint(x)", "now untyped\n"); // := declares a new variable + CHECK_OUTPUT("u8 x = 10\nx = 20\nx += 5\nprint(x, type(x))", "25 u8\n"); + CHECK_OUTPUT("f64 x = 1\nprint(x, type(x))", "1.0 f64\n"); // ints convert to floats + CHECK_OUTPUT("i32 x = 3.0\nprint(x)", "3\n"); // whole floats convert to ints + CHECK_OUTPUT("string s = 'c'\nprint(s, type(s))", "c string\n"); + CHECK_ERROR("u8 x = 10\nx = 256", "'x' is declared as u8, so it can't hold 256 (u8 is 0 to 255)"); + CHECK_ERROR("i32 x = 1\nx = 2.5", "can't hold 2.5 (use i32(...) to drop the fraction)"); + CHECK_ERROR("i32 x = 1\nx = \"5\"", "can't hold a string (use i32(...) to convert a string)"); + CHECK_ERROR("string x = \"a\"\nx = null", "can't hold null (declare it as string? to allow null)"); + CHECK_ERROR("char x = 'a'\nx = \"b\"", "use single quotes for a char, like 'a'"); + CHECK_OUTPUT("string? x = \"a\"\nx = null\nprint(x)", "null\n"); + CHECK_OUTPUT("u8 x = 1\nx := \"now untyped\"\nprint(x)", "now untyped\n"); // := declares a new variable } TEST(interp_static_type_errors) { - Result result = run("print(\"never runs\")\nx: u8 = 300"); + Result result = run("print(\"never runs\")\nu8 x = 300"); CHECK(result.status == RunStatus::SyntaxError); CHECK_EQ(result.out, std::string("")); - CHECK(contains(result.err, "test.asc:2:9: error: 'x' is declared as u8, so it can't hold 300 (u8 is 0 to 255)")); - CHECK_ERROR("x: bool = 1", "'x' is declared as bool, so it can't hold 1"); - CHECK_ERROR("x: list = {}", "'x' is declared as list, so it can't hold a map"); - CHECK_ERROR("f: func", "'f' is declared as func, so it needs a value"); + CHECK(contains(result.err, "test.asc:2:8: error: 'x' is declared as u8, so it can't hold 300 (u8 is 0 to 255)")); + CHECK_ERROR("bool x = 1", "'x' is declared as bool, so it can't hold 1"); + CHECK_ERROR("list x = {}", "'x' is declared as list, so it can't hold a map"); + CHECK_ERROR("func f", "'f' is declared as func, so it needs a value"); } TEST(interp_integer_overflow_per_type) { - CHECK_ERROR("x: u8 = 250\nx += 10", "integer overflow: 250 + 10 doesn't fit in u8 (0 to 255)"); - CHECK_ERROR("x: i8 = 100\nprint(x * 2)", "integer overflow: 100 * 2 doesn't fit in i8 (-128 to 127)"); - CHECK_ERROR("x: u32 = 0\nprint(x - 1)", "integer overflow: 0 - 1 doesn't fit in u32"); - CHECK_ERROR("x: i8 = -128\nprint(-x)", "integer overflow: -(-128) doesn't fit in i8"); - CHECK_ERROR("x: u8 = 1\nprint(-x)", "can't negate 1: u8 is unsigned"); - CHECK_ERROR("x: u8 = 1\nprint(x + 300)", "300 doesn't fit in u8 (0 to 255)"); - CHECK_OUTPUT("x: u64 = 18446744073709551614u64\nx += 1\nprint(x)", "18446744073709551615\n"); - CHECK_ERROR("x: u64 = 18446744073709551615u64\nx += 1", "integer overflow"); + CHECK_ERROR("u8 x = 250\nx += 10", "integer overflow: 250 + 10 doesn't fit in u8 (0 to 255)"); + CHECK_ERROR("i8 x = 100\nprint(x * 2)", "integer overflow: 100 * 2 doesn't fit in i8 (-128 to 127)"); + CHECK_ERROR("u32 x = 0\nprint(x - 1)", "integer overflow: 0 - 1 doesn't fit in u32"); + CHECK_ERROR("i8 x = -128\nprint(-x)", "integer overflow: -(-128) doesn't fit in i8"); + CHECK_ERROR("u8 x = 1\nprint(-x)", "can't negate 1: u8 is unsigned"); + CHECK_ERROR("u8 x = 1\nprint(x + 300)", "300 doesn't fit in u8 (0 to 255)"); + CHECK_OUTPUT("u64 x = 18446744073709551614u64\nx += 1\nprint(x)", "18446744073709551615\n"); + CHECK_ERROR("u64 x = 18446744073709551615u64\nx += 1", "integer overflow"); CHECK_ERROR("print(2i32 ** 31)", "doesn't fit in i32"); CHECK_OUTPUT("print(2i32 ** 30, 3u8 ** 5)", "1073741824 243\n"); } TEST(interp_mixed_type_arithmetic) { - CHECK_OUTPUT("a: u8 = 1\nb: i32 = 2\nc: i8 = 3\nd: f32 = 1.5\ne: f64 = 2.0\n" + CHECK_OUTPUT("u8 a = 1\ni32 b = 2\ni8 c = 3\nf32 d = 1.5\nf64 e = 2.0\n" "print(type(a + 1), type(a + b), type(a + c), type(b + d), type(d + e), type(d * 2), type(a + 1.5))", "u8 i32 i16 f32 f64 f32 float\n"); - CHECK_ERROR("a: u64 = 1\nb: i64 = 2\nprint(a + b)", "can't mix u64 and i64"); + CHECK_ERROR("u64 a = 1\ni64 b = 2\nprint(a + b)", "can't mix u64 and i64"); CHECK_OUTPUT("print(7u8 / 2, 7i32 % 3, 7.0f32 / 2)", "3 1 3.5\n"); - CHECK_OUTPUT("a: u8 = 200\nb: i8 = -100\nprint(a > b, a == 200, 200 == a, 1u8 == 1.0)", "true true true true\n"); + CHECK_OUTPUT("u8 a = 200\ni8 b = -100\nprint(a > b, a == 200, 200 == a, 1u8 == 1.0)", "true true true true\n"); } TEST(interp_f32_precision) { - CHECK_OUTPUT("x: f32 = 0.1\ny: f64 = 0.1\nprint(x, y, f64(x), x == y)", "0.1 0.1 0.10000000149011612 false\n"); + CHECK_OUTPUT("f32 x = 0.1\nf64 y = 0.1\nprint(x, y, f64(x), x == y)", "0.1 0.1 0.10000000149011612 false\n"); CHECK_OUTPUT("print(f32(1) / 3, 1.0 / 3)", "0.33333334 0.3333333333333333\n"); } @@ -561,8 +561,8 @@ TEST(interp_number_literals) { TEST(interp_bitwise_operators) { CHECK_OUTPUT("print(12 & 10, 12 | 10, 12 ^ 10, ~0, 1 << 10, -16 >> 2, 0xF0 >> 4)", "8 14 6 -1 1024 -4 15\n"); - CHECK_OUTPUT("x: u8 = 0b1111_0000\nprint(~x, x << 1, x >> 4, type(x << 1))", "15 224 15 u8\n"); - CHECK_OUTPUT("x: i8 = -1\nprint(x << 1, x >> 1)", "-2 -1\n"); + CHECK_OUTPUT("u8 x = 0b1111_0000\nprint(~x, x << 1, x >> 4, type(x << 1))", "15 224 15 u8\n"); + CHECK_OUTPUT("i8 x = -1\nprint(x << 1, x >> 1)", "-2 -1\n"); CHECK_OUTPUT("flags := 0\nflags |= 4\nflags |= 1\nflags &= ~4\nflags ^= 2\nflags <<= 1\nprint(flags, flags & 1 == 0)", "6 true\n"); CHECK_ERROR("print(1u8 << 8)", "shift amount 8 is out of range for u8 (0 to 7)"); @@ -587,7 +587,7 @@ TEST(interp_unicode_strings) { } TEST(interp_constants) { - CHECK_OUTPUT("const MAX = 10\nconst NAME: string = \"x\"\nprint(MAX, NAME)", "10 x\n"); + CHECK_OUTPUT("const MAX = 10\nconst string NAME = \"x\"\nprint(MAX, NAME)", "10 x\n"); CHECK_ERROR("const MAX = 10\nMAX = 11", "can't change 'MAX': it's a constant"); CHECK_ERROR("const MAX = 10\nMAX += 1", "can't change 'MAX': it's a constant"); CHECK_ERROR("const MAX = 10\nMAX := 11", "'MAX' is a constant and can't be declared again"); @@ -595,18 +595,29 @@ TEST(interp_constants) { } TEST(interp_typed_functions) { - CHECK_OUTPUT("func area(w: f64, h: f64) -> f64 { return w * h }\nprint(area(2, 3), type(area(2, 3)))", "6.0 f64\n"); - CHECK_OUTPUT("func half(n: i32) -> i32 { return n / 2 }\nprint(half(7))", "3\n"); - CHECK_OUTPUT("func find(xs: list, x) -> i32? {\n for i, v in xs { if v == x { return i } }\n return null\n}\n" + CHECK_OUTPUT("func area(f64 w, f64 h) -> f64 { return w * h }\nprint(area(2, 3), type(area(2, 3)))", "6.0 f64\n"); + CHECK_OUTPUT("func half(i32 n) -> i32 { return n / 2 }\nprint(half(7))", "3\n"); + CHECK_OUTPUT("func find(list xs, x) -> i32? {\n for i, v in xs { if v == x { return i } }\n return null\n}\n" "print(find([5, 6], 6), find([], 1))", "1 null\n"); - CHECK_OUTPUT("func log(msg: string) -> void { print(\"log:\", msg) }\nlog(\"hi\")", "log: hi\n"); - CHECK_ERROR("func f(n: u8) { }\nf(256)", "f() expects 'n' to be u8, but got 256 (u8 is 0 to 255)"); - CHECK_ERROR("func f(s: string) { }\nf(1)", "f() expects 's' to be string, but got 1"); + CHECK_OUTPUT("func log(string msg) -> void { print(\"log:\", msg) }\nlog(\"hi\")", "log: hi\n"); + CHECK_ERROR("func f(u8 n) { }\nf(256)", "f() expects 'n' to be u8, but got 256 (u8 is 0 to 255)"); + CHECK_ERROR("func f(string s) { }\nf(1)", "f() expects 's' to be string, but got 1"); CHECK_ERROR("func f(x) -> i32 { return x }\nf(\"no\")", "f() must return i32, but returned a string"); CHECK_ERROR("func f() -> i32 { }\nf()", "f() must return i32, but it ended without returning a value"); - CHECK_ERROR("func f() -> void { return 1 }", "f() is declared '-> void', so it can't return a value"); - CHECK_ERROR("func f(n: u8) {\n n = 300\n}\nf(1)", "'n' is declared as u8, so it can't hold 300"); + CHECK_ERROR("func f() -> void { return 1 }", "f() returns void, so it can't return a value"); + CHECK_ERROR("void f() { return 1 }", "f() returns void, so it can't return a value"); + CHECK_ERROR("func f(u8 n) {\n n = 300\n}\nf(1)", "'n' is declared as u8, so it can't hold 300"); +} + +TEST(interp_c_style_functions) { + CHECK_OUTPUT("i32 add(i32 a, i32 b = 1) {\n return a + b\n}\nprint(add(2, 3), add(4), type(add(1, 1)))", "5 5 i32\n"); + CHECK_OUTPUT("void greet(string name) {\n print(f\"hi {name}\")\n}\ngreet(\"Ada\")", "hi Ada\n"); + CHECK_OUTPUT("string? lookup(map m, string key) {\n return get(m, key)\n}\nprint(lookup({a: \"x\"}, \"a\"), lookup({}, \"b\"))", + "x null\n"); + CHECK_OUTPUT("print(twice(21))\ni64 twice(i64 n) { return n * 2 }", "42\n"); // hoisted like `func` + CHECK_OUTPUT("func handler = print\nhandler(\"called\")\nfunc? maybe\nprint(maybe)", "called\nnull\n"); + CHECK_ERROR("u8 small(i32 n) { return n }\nsmall(300)", "small() must return u8, but returned 300 (u8 is 0 to 255)"); } TEST(interp_match) { @@ -706,7 +717,7 @@ TEST(interp_system_tools) { TEST(interp_imports) { TempFile module("ascend_test_module.asc", - "func square(n: i32) -> i32 { return n * n }\n" + "func square(i32 n) -> i32 { return n * n }\n" "VERSION := \"1.0\"\n_private := 1\n" "func fail() { return 1 / 0 }\n"); TempFile broken("ascend_test_broken.asc", "x := (\n"); @@ -728,5 +739,5 @@ TEST(interp_check_only_mode) { options.checkOnly = true; CHECK(runSource(interpreter, "print(\"not run\")", options, err).status == RunStatus::Ok); CHECK_EQ(out.str(), std::string("")); - CHECK(runSource(interpreter, "x: u8 = 999", options, err).status == RunStatus::SyntaxError); + CHECK(runSource(interpreter, "u8 x = 999", options, err).status == RunStatus::SyntaxError); } diff --git a/tests/parser_tests.cpp b/tests/parser_tests.cpp index c0edc00..20563f0 100644 --- a/tests/parser_tests.cpp +++ b/tests/parser_tests.cpp @@ -316,7 +316,7 @@ TEST(parser_bitwise_precedence) { } TEST(parser_typed_declarations) { - Parsed typed = parse("age: u8 = 30"); + Parsed typed = parse("u8 age = 30"); CHECK(typed.errors.empty()); const auto& decl = onlyStatement(typed).as(); CHECK(decl.hasType); @@ -324,25 +324,55 @@ TEST(parser_typed_declarations) { CHECK(decl.type.num == NumKind::U8); CHECK(decl.value != nullptr); - Parsed zero = parse("name: string?"); + Parsed zero = parse("string? name"); CHECK(zero.errors.empty()); const auto& noValue = onlyStatement(zero).as(); CHECK(noValue.type.nullable); CHECK(noValue.value == nullptr); - Parsed constant = parse("const MAX: i64 = 10"); + Parsed constant = parse("const i64 MAX = 10"); CHECK(constant.errors.empty()); CHECK(onlyStatement(constant).as().constant); CHECK(parse("const LIMIT := 3").errors.empty()); - CHECK(firstError("x: int32 = 1").find("unknown type 'int32'") == 0); - CHECK_EQ(firstError("x: void"), std::string("'void' can only be used as a return type")); - CHECK_EQ(firstError("x: i32 := 5"), std::string("use '=' after a type, like 'x: i32 = ...'")); + CHECK(firstError("int32 x = 1").find("unknown type 'int32'") == 0); + CHECK_EQ(firstError("void x"), std::string("'void' can only be used as a return type")); + CHECK_EQ(firstError("i32 x := 5"), std::string("use '=' in a typed declaration, like 'i32 x = ...'")); CHECK_EQ(firstError("const X"), std::string("a constant needs a value: 'const X = ...'")); } +TEST(parser_old_style_types_get_a_hint) { + CHECK_EQ(firstError("age: u8 = 30"), std::string("write the type before the name, like 'u8 age = ...'")); + CHECK_EQ(firstError("const MAX: i64 = 1"), std::string("write the type before the name, like 'const i64 MAX = ...'")); + CHECK_EQ(firstError("func f(n: u8) { }"), std::string("write the parameter's type before its name, like 'u8 n'")); +} + +TEST(parser_c_style_functions) { + Parsed parsed = parse("i32 add(i32 a, i32 b = 1) {\n return a + b\n}"); + CHECK(parsed.errors.empty()); + CHECK(parsed.program->body.hasFunctions); + const auto& function = *onlyStatement(parsed).as().function; + CHECK_EQ(function.name, std::string("add")); + CHECK_EQ(function.returnType.name(), std::string("i32")); + CHECK_EQ(function.params[0].type.name(), std::string("i32")); + CHECK_EQ(function.requiredParams, size_t(1)); + CHECK(parse("void log(string message) { }").errors.empty()); + CHECK(parse("string? find(list items, any target) { return null }").errors.empty()); + CHECK_EQ(firstError("i32 f() -> i32 { }"), std::string("the return type is already written before the function name")); + // A type name followed by '(' is still a call: string(5), list(...) are conversions. + CHECK_EQ(expr("string(5)"), std::string("(call string 5)")); +} + +TEST(parser_func_typed_variables) { + Parsed holder = parse("func handler = print"); + CHECK(holder.errors.empty()); + CHECK(onlyStatement(holder).kind == StmtKind::Declare); + CHECK(parse("func? callback").errors.empty()); + CHECK(onlyStatement(parse("func greet() { }")).kind == StmtKind::Function); +} + TEST(parser_typed_functions) { - CHECK_EQ(expr("func(a: i32, b: string? = null) -> f64 { return 1.0 }"), std::string("(func a:i32 b:string? -> f64)")); + CHECK_EQ(expr("func(i32 a, string? b = null) -> f64 { return 1.0 }"), std::string("(func a:i32 b:string? -> f64)")); CHECK_EQ(expr("func() -> void { }"), std::string("(func -> void)")); } From d7c677d572bc76a9fab855aab5815d9000b20c17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:27:35 +0000 Subject: [PATCH 3/4] Measure the real stack size on every platform The Windows (MSVC) CI job crashed partway through the unit tests. On Windows the recursion guard assumed an 8 MB stack instead of measuring it, and the parser limited nesting only by a level count, so a smaller stack or larger MSVC stack frames could overflow before either check fired. Add StackLimit, which reads the current thread's actual stack bounds (GetCurrentThreadStackLimits on Windows, the stack rlimit elsewhere) and keeps a safety margin, and use it in both the interpreter and the parser. Runaway recursion and deeply nested code are now reported as errors at any stack size; locally the test suite no longer crashes even with a 256 KB stack. The test runner now prints the available stack and each test's name before running it, so a crash in CI shows which test caused it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014xWEy3pVr7ofMaomGRbiuo --- CMakeLists.txt | 1 + src/include/interpreter.hpp | 4 +- src/include/parser.hpp | 2 + src/include/stack_limit.hpp | 33 +++++++++++++++++ src/interpreter.cpp | 40 +------------------- src/parser.cpp | 6 ++- src/stack_limit.cpp | 74 +++++++++++++++++++++++++++++++++++++ tests/test_main.cpp | 12 ++++-- 8 files changed, 127 insertions(+), 45 deletions(-) create mode 100644 src/include/stack_limit.hpp create mode 100644 src/stack_limit.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index fce48ee..3f802d2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,7 @@ add_library(ascend_core STATIC src/utf8.cpp src/numeric.cpp src/typecheck.cpp + src/stack_limit.cpp src/lexer.cpp src/parser.cpp src/semantic_analyzer.cpp diff --git a/src/include/interpreter.hpp b/src/include/interpreter.hpp index 3c83520..1f4e0f4 100644 --- a/src/include/interpreter.hpp +++ b/src/include/interpreter.hpp @@ -4,6 +4,7 @@ #include "ast.hpp" #include "environment.hpp" #include "error.hpp" +#include "stack_limit.hpp" #include "value.hpp" #include @@ -138,8 +139,7 @@ class Interpreter { bool replMode_ = false; int callDepth_ = 0; int maxCallDepth_; - uintptr_t stackBase_; - size_t stackBudget_; + StackLimit stack_; std::vector> heap_; size_t nextCollection_; diff --git a/src/include/parser.hpp b/src/include/parser.hpp index c165505..4b30c03 100644 --- a/src/include/parser.hpp +++ b/src/include/parser.hpp @@ -4,6 +4,7 @@ #include "ast.hpp" #include "error.hpp" #include "lexer.hpp" +#include "stack_limit.hpp" #include #include @@ -104,6 +105,7 @@ class Parser { std::vector errors_; bool incomplete_ = false; int depth_ = 0; + StackLimit stack_; }; } // namespace ascend diff --git a/src/include/stack_limit.hpp b/src/include/stack_limit.hpp new file mode 100644 index 0000000..624a825 --- /dev/null +++ b/src/include/stack_limit.hpp @@ -0,0 +1,33 @@ +#ifndef ASCEND_STACK_LIMIT_HPP +#define ASCEND_STACK_LIMIT_HPP + +#include +#include + +namespace ascend { + +// Tracks how much of the current thread's stack is in use, so very deep +// recursion (in a program, or in deeply nested code being parsed) is +// reported as an error instead of crashing the process. +class StackLimit { +public: + // Measures from the current position, keeping a safety margin below the + // real end of the stack. + StackLimit(); + + bool exceeded() const; + size_t budget() const { return budget_; } + +private: + uintptr_t base_; + size_t budget_; +}; + +// Bytes of stack left between the current position and the end of the +// current thread's stack (estimated from the stack size limit where the +// platform can't tell exactly). +size_t availableStack(); + +} // namespace ascend + +#endif // ASCEND_STACK_LIMIT_HPP diff --git a/src/interpreter.cpp b/src/interpreter.cpp index 1203ac0..6783dce 100644 --- a/src/interpreter.cpp +++ b/src/interpreter.cpp @@ -5,6 +5,7 @@ #include "parser.hpp" #include "runner.hpp" #include "semantic_analyzer.hpp" +#include "stack_limit.hpp" #include "stdlib.hpp" #include "typecheck.hpp" #include "utf8.hpp" @@ -17,19 +18,12 @@ #include #include -#if defined(__unix__) || defined(__APPLE__) -#include -#endif -#if defined(_MSC_VER) -#include -#endif namespace ascend { namespace { constexpr int kDefaultMaxCallDepth = 100000; -constexpr size_t kMiB = 1024 * 1024; constexpr size_t kMinCollectionThreshold = 10000; constexpr size_t kMaxRepeatSize = size_t(1) << 28; @@ -173,32 +167,6 @@ std::string arityMessage(const std::string& name, size_t min, long long max, siz std::string functionLabel(const std::string& name) { return name.empty() ? "this function" : name + "()"; } -// Address of the current stack frame, used to measure how much stack is in use. -uintptr_t stackAddress() { -#if defined(__GNUC__) || defined(__clang__) - return reinterpret_cast(__builtin_frame_address(0)); -#elif defined(_MSC_VER) - return reinterpret_cast(_AddressOfReturnAddress()); -#else - volatile char marker = 0; - return reinterpret_cast(&marker); -#endif -} - -// How much stack the interpreter may use before reporting a stack overflow. -// Leaves a safety margin below the real limit so the error can be reported -// cleanly instead of crashing. -size_t stackBudget() { - size_t total = 8 * kMiB; // the default on Linux and macOS; CMake links Windows builds with 8 MB too -#if defined(__unix__) || defined(__APPLE__) - struct rlimit limit; - if (getrlimit(RLIMIT_STACK, &limit) == 0 && limit.rlim_cur != RLIM_INFINITY) { - total = static_cast(limit.rlim_cur); - } -#endif - return total > 2 * kMiB ? total - std::max(kMiB, total / 8) : total / 2; -} - bool isAbsolutePath(const std::string& path) { return (!path.empty() && (path[0] == '/' || path[0] == '\\')) || (path.size() > 1 && path[1] == ':'); } @@ -246,8 +214,6 @@ Interpreter::Interpreter(std::ostream& out, std::istream& in) : out_(out), in_(in), maxCallDepth_(kDefaultMaxCallDepth), - stackBase_(stackAddress()), - stackBudget_(stackBudget()), nextCollection_(kMinCollectionThreshold) { try { random_.seed(std::random_device{}()); @@ -368,9 +334,7 @@ void Interpreter::locate(RuntimeError& error, int line, int column) const { } void Interpreter::checkStack() const { - uintptr_t here = stackAddress(); - size_t used = here > stackBase_ ? here - stackBase_ : stackBase_ - here; - if (used > stackBudget_) { + if (stack_.exceeded()) { throw RuntimeError("stack overflow: too much recursion (is there a recursive call without a base case?)"); } } diff --git a/src/parser.cpp b/src/parser.cpp index 7e80913..e4c274c 100644 --- a/src/parser.cpp +++ b/src/parser.cpp @@ -39,11 +39,13 @@ std::unique_ptr nodeAt(const Token& token) { } // namespace // Limits how deeply blocks and expressions may nest, so pathological input -// produces an error instead of overflowing the stack. +// produces an error instead of overflowing the stack. Both the nesting depth +// and the real stack use are checked, since stack frames differ in size +// between compilers. class Parser::NestingGuard { public: NestingGuard(Parser& parser, const Token& at) : parser_(parser) { - if (parser_.depth_ >= kMaxNesting) parser_.error(at, "code is nested too deeply"); + if (parser_.depth_ >= kMaxNesting || parser_.stack_.exceeded()) parser_.error(at, "code is nested too deeply"); ++parser_.depth_; } ~NestingGuard() { --parser_.depth_; } diff --git a/src/stack_limit.cpp b/src/stack_limit.cpp new file mode 100644 index 0000000..2933e20 --- /dev/null +++ b/src/stack_limit.cpp @@ -0,0 +1,74 @@ +#include "stack_limit.hpp" + +#include + +#if defined(_WIN32) +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#elif defined(__unix__) || defined(__APPLE__) +#include +#endif + +namespace ascend { + +namespace { + +constexpr size_t kKiB = 1024; +constexpr size_t kMiB = 1024 * kKiB; + +// Address of the current stack frame. +uintptr_t stackPosition() { +#if defined(__GNUC__) || defined(__clang__) + return reinterpret_cast(__builtin_frame_address(0)); +#elif defined(_MSC_VER) + return reinterpret_cast(_AddressOfReturnAddress()); +#else + volatile char marker = 0; + return reinterpret_cast(&marker); +#endif +} + +} // namespace + +size_t availableStack() { +#if defined(_WIN32) + // The exact bounds of this thread's stack (Windows 8 and later). + ULONG_PTR low = 0; + ULONG_PTR high = 0; + GetCurrentThreadStackLimits(&low, &high); + uintptr_t here = stackPosition(); + return here > low ? static_cast(here - low) : 0; +#elif defined(__unix__) || defined(__APPLE__) + // The main thread's stack can grow up to the soft limit; the interpreter + // starts near the top of it. + struct rlimit limit; + if (getrlimit(RLIMIT_STACK, &limit) == 0 && limit.rlim_cur != RLIM_INFINITY) { + return static_cast(limit.rlim_cur); + } + return 8 * kMiB; +#else + return 1 * kMiB; // assume the smallest common default +#endif +} + +StackLimit::StackLimit() : base_(stackPosition()) { + size_t available = availableStack(); + // Leave room for the frames between two checks and for deep library + // calls (formatting a nested list, sorting, ...). + size_t margin = std::max(256 * kKiB, available / 8); + budget_ = available > 2 * margin ? available - margin : available / 2; +} + +bool StackLimit::exceeded() const { + uintptr_t here = stackPosition(); + size_t used = here > base_ ? here - base_ : base_ - here; + return used > budget_; +} + +} // namespace ascend diff --git a/tests/test_main.cpp b/tests/test_main.cpp index a1e622a..04980a2 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -1,3 +1,4 @@ +#include "stack_limit.hpp" #include "test_framework.hpp" #include @@ -5,23 +6,28 @@ #include // Runs every registered test. Pass a substring to run only matching tests. +// +// Each test's name is printed (and flushed) before it runs, so if a test +// crashes the process, the log shows which one it was. int main(int argc, char* argv[]) { std::string filter = argc > 1 ? argv[1] : ""; + std::cout << "stack available: " << ascend::availableStack() / 1024 << " KiB" << std::endl; int passed = 0; int failed = 0; for (const auto& test : asctest::registry()) { if (!filter.empty() && test.name.find(filter) == std::string::npos) continue; + std::cout << "[ RUN ] " << test.name << std::endl; try { test.body(); ++passed; } catch (const asctest::Failure& failure) { ++failed; - std::cerr << "FAIL " << test.name << "\n " << failure.message << "\n"; + std::cout << "[ FAIL ] " << test.name << "\n " << failure.message << std::endl; } catch (const std::exception& error) { ++failed; - std::cerr << "FAIL " << test.name << "\n unexpected exception: " << error.what() << "\n"; + std::cout << "[ FAIL ] " << test.name << "\n unexpected exception: " << error.what() << std::endl; } } - std::cout << passed << " passed, " << failed << " failed\n"; + std::cout << passed << " passed, " << failed << " failed" << std::endl; return failed == 0 ? 0 : 1; } From ae765a55b1c9b72372024aa53eb39c6e0aaa92c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:33:34 +0000 Subject: [PATCH 4/4] Stop rethrowing errors at every level of evaluation The Windows (MSVC) unit tests still crashed, in interp_recursion_limit_is_an_error_not_a_crash, with the full 8 MB stack available. The stack guard did fire, but each eval/exec level then caught the error to attach a source location and rethrew it. With MSVC, a catch block runs on top of the frames being unwound, so every rethrow starts a new exception dispatch deeper on the stack. Thousands of nested rethrows from deep recursion overflowed it. GCC and Clang unwind differently, so Linux and macOS passed. Record the location in a destructor instead (ErrorSite): creating a RuntimeError sets a flag, and the innermost expression or statement being unwound notes its position and file. The location is applied once at the top level, and `try` clears it. Error messages and locations are unchanged; all error-location tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014xWEy3pVr7ofMaomGRbiuo --- src/include/error.hpp | 12 ++++- src/include/interpreter.hpp | 9 +++- src/interpreter.cpp | 102 ++++++++++++++++++++++-------------- 3 files changed, 82 insertions(+), 41 deletions(-) diff --git a/src/include/error.hpp b/src/include/error.hpp index 6fec0b5..6dfad08 100644 --- a/src/include/error.hpp +++ b/src/include/error.hpp @@ -19,6 +19,14 @@ struct Diagnostic { std::string message; }; +// Set whenever a RuntimeError is created. As the error unwinds, the +// interpreter notes the innermost expression or statement that was running +// and clears this (see Interpreter::ErrorSite). +inline bool& runtimeErrorPending() { + static thread_local bool pending = false; + return pending; +} + // An error raised while a program runs. Errors created deep inside the runtime // (for example inside a builtin function) don't know where in the source they // happened, so they start with line == 0 and the interpreter fills in the @@ -26,7 +34,9 @@ struct Diagnostic { class RuntimeError : public std::runtime_error { public: explicit RuntimeError(const std::string& message, int line = 0, int column = 0) - : std::runtime_error(message), line(line), column(column) {} + : std::runtime_error(message), line(line), column(column) { + runtimeErrorPending() = true; + } bool hasLocation() const { return line > 0; } diff --git a/src/include/interpreter.hpp b/src/include/interpreter.hpp index 1f4e0f4..4d14e6a 100644 --- a/src/include/interpreter.hpp +++ b/src/include/interpreter.hpp @@ -90,6 +90,7 @@ class Interpreter { enum class Flow { Normal, Break, Continue, Return }; using EnvPtr = std::shared_ptr; class FrameGuard; + class ErrorSite; void runTopLevel(const Program& program, const EnvPtr& env, bool echo); Flow exec(const Stmt& stmt, const EnvPtr& env); @@ -116,7 +117,9 @@ class Interpreter { Value zeroValue(const TypeSpec& type); Value coerceVariable(const std::string& name, const TypeSpec& type, Value value); - void locate(RuntimeError& error, int line, int column) const; + void noteErrorSite(int line, int column); + void applyErrorSite(RuntimeError& error); + void clearErrorSite(); void checkStack() const; std::string importDirectory() const; @@ -136,6 +139,10 @@ class Interpreter { const Callable* rangeFunction_ = nullptr; const SourceFile* currentSource_ = nullptr; const FunctionExpr* currentFunction_ = nullptr; + bool hasErrorSite_ = false; // where the error being raised happened (see ErrorSite) + int errorLine_ = 0; + int errorColumn_ = 0; + const SourceFile* errorSource_ = nullptr; bool replMode_ = false; int callDepth_ = 0; int maxCallDepth_; diff --git a/src/interpreter.cpp b/src/interpreter.cpp index 6783dce..d62db53 100644 --- a/src/interpreter.cpp +++ b/src/interpreter.cpp @@ -207,6 +207,28 @@ class Interpreter::FrameGuard { const FunctionExpr* savedFunction_; }; +// Notes where a runtime error happened, as the error unwinds through the +// expressions and statements being evaluated: the innermost one is the +// location reported. Doing this in a destructor instead of catching and +// rethrowing at every level matters for deep recursion: with MSVC, each +// rethrow happens on top of the frames being unwound, so thousands of them +// overflow the stack. +class Interpreter::ErrorSite { +public: + ErrorSite(Interpreter& interpreter, int line, int column) + : interpreter_(interpreter), line_(line), column_(column) {} + ~ErrorSite() { + if (runtimeErrorPending()) interpreter_.noteErrorSite(line_, column_); + } + ErrorSite(const ErrorSite&) = delete; + ErrorSite& operator=(const ErrorSite&) = delete; + +private: + Interpreter& interpreter_; + int line_; + int column_; +}; + // --------------------------------------------------------------------------- // Setup and memory management @@ -325,12 +347,30 @@ void Interpreter::defineBuiltin(const std::string& name, Value value) { builtins void Interpreter::defineGlobal(const std::string& name, Value value) { globals_->define(name, std::move(value)); } -void Interpreter::locate(RuntimeError& error, int line, int column) const { - if (!error.hasLocation()) { - error.line = line; - error.column = column; +void Interpreter::noteErrorSite(int line, int column) { + runtimeErrorPending() = false; + hasErrorSite_ = true; + errorLine_ = line; + errorColumn_ = column; + errorSource_ = currentSource_; +} + +// Fills in where an error happened, if it doesn't say already. +void Interpreter::applyErrorSite(RuntimeError& error) { + if (hasErrorSite_) { + if (!error.hasLocation()) { + error.line = errorLine_; + error.column = errorColumn_; + } + if (error.source == nullptr) error.source = errorSource_; } if (error.source == nullptr) error.source = currentSource_; + clearErrorSite(); +} + +void Interpreter::clearErrorSite() { + runtimeErrorPending() = false; + hasErrorSite_ = false; } void Interpreter::checkStack() const { @@ -351,8 +391,13 @@ void Interpreter::execute(std::unique_ptr program, std::unique_ptr args; for (const auto& arg : call.args) args.push_back(eval(*arg, env)); RangeSpec range; - try { + { + ErrorSite site(*this, call.line, call.column); // a bad range() argument range = makeRange(args); - } catch (RuntimeError& error) { - locate(error, call.line, call.column); - throw; } for (uint64_t i = 0; i < range.count; ++i) { Flow flow = iteration(Value::fromInt(static_cast(i)), Value::fromInt(range.at(i))); @@ -778,6 +817,7 @@ Interpreter::Flow Interpreter::execTry(const TryStmt& stmt, const EnvPtr& env) { try { return execScoped(stmt.body, env); } catch (const RuntimeError& error) { + clearErrorSite(); // handled, so where it happened no longer matters caught = Value::fromString(error.what()); } catch (const ThrownValue& thrown) { caught = thrown.value; @@ -807,13 +847,9 @@ Interpreter::Flow Interpreter::execMatch(const MatchStmt& stmt, const EnvPtr& en // Expressions Value Interpreter::eval(const Expr& expr, const EnvPtr& env) { - try { - checkStack(); - return evalExpr(expr, env); - } catch (RuntimeError& error) { - locate(error, expr.line, expr.column); - throw; - } + ErrorSite site(*this, expr.line, expr.column); + checkStack(); + return evalExpr(expr, env); } Value Interpreter::evalExpr(const Expr& expr, const EnvPtr& env) { @@ -883,12 +919,8 @@ Value Interpreter::evalExpr(const Expr& expr, const EnvPtr& env) { for (const auto& entry : literal.entries) { Value key = eval(*entry.first, env); Value value = eval(*entry.second, env); - try { - map->set(key, std::move(value)); - } catch (RuntimeError& error) { - locate(error, entry.first->line, entry.first->column); - throw; - } + ErrorSite site(*this, entry.first->line, entry.first->column); // e.g. a list used as a key + map->set(key, std::move(value)); } return Value::fromMap(map); } @@ -951,12 +983,8 @@ Value Interpreter::evalFString(const FStringExpr& expr, const EnvPtr& env) { continue; } Value value = eval(*part.expr, env); - try { - result += part.spec.empty() ? toString(value) : formatValue(value, part.spec); - } catch (RuntimeError& error) { - locate(error, part.expr->line, part.expr->column); - throw; - } + ErrorSite site(*this, part.expr->line, part.expr->column); // e.g. a bad format spec + result += part.spec.empty() ? toString(value) : formatValue(value, part.spec); } return Value::fromString(result); } @@ -1013,12 +1041,8 @@ Value Interpreter::call(const Value& callee, std::vector args, int line, throw RuntimeError(arityMessage(native.name, static_cast(native.minArgs), native.maxArgs, count), line, column); } - try { - return native.fn(*this, args); - } catch (RuntimeError& error) { - locate(error, line, column); - throw; - } + // Errors from the builtin are reported at the call (the enclosing ErrorSite). + return native.fn(*this, args); } auto& function = static_cast(callable);