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 new file mode 100644 index 0000000..80d248a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,65 @@ +# Changelog + +## 0.3.0 + +### New + +- **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). +- 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()`. +- `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 (`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`, + `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`. +- GitHub Actions builds and tests every change on Linux, macOS and Windows, + and uploads the built interpreters. + +### 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..3f802d2 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) @@ -10,15 +10,27 @@ 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 + src/types.cpp + src/utf8.cpp + src/numeric.cpp + src/typecheck.cpp + src/stack_limit.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..0253042 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Ascend -Ascend is a small, friendly, dynamically typed scripting language with an -interpreter written in C++17. +[![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) { +string fizzbuzz(i32 n) { if n % 15 == 0 { return "FizzBuzz" } if n % 3 == 0 { return "Fizz" } if n % 5 == 0 { return "Buzz" } @@ -15,37 +17,51 @@ for i in range(1, 16) { print(fizzbuzz(i)) } +u8 level = 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 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 -- 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: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 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 +69,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 +78,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..32f804f 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,18 @@ 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, +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..d591394 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 C-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 + "!" +string greet(string name, string greeting = "Hello") { + 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 put a +type in front of the name, like in C, to fix the variable's type: -`type(value)` returns the type name as a string. +``` +count := 0 // no declared type +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 +``` -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: + +``` +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) +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 `u8 age = 300` are reported before the program +starts running. -`false`, `null`, `0`, `0.0`, `""`, `[]` and `{}` are falsy. Everything else is -truthy. +### The types -## Variables +| 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 f64 RATE = 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. -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: +### Truthiness + +`false`, `null`, `0`, `0.0`, `'\0'`, `""`, `[]` and `{}` are falsy. Everything +else is truthy. + +## 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 `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 `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 + 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)`. +Notes: -Arithmetic details: - -- `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 := "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:%}") ``` -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 +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,22 +261,66 @@ 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 +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, height = width) { +f64 area(f64 width, f64 height = width) { return width * height } -area(3) // 9 -area(3, 4) // 12 +void log(string message) { + print("log:", message) +} + +area(3) // 9.0 +area(3, 4) // 12.0 ``` +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. ``` @@ -214,9 +351,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 +f64 circle_area(f64 r) { 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 +402,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..aa326bf --- /dev/null +++ b/editors/vscode/syntaxes/ascend.tmLanguage.json @@ -0,0 +1,126 @@ +{ + "$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": "#typed-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" } + } + }, + "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": [ + { + "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..fd36bf2 --- /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. + +u8 READ = 0b001 +u8 WRITE = 0b010 +u8 EXECUTE = 0b100 + +u8 permissions = 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. +u32 pack(u8 r, u8 g, u8 b) { + 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. +i32 popcount(u64 n) { + 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..b9ba6e3 --- /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 + +f64 circle_area(f64 radius) { + return PI * radius ** 2 +} + +f64 circle_circumference(f64 radius) { + return PI_2 * radius +} + +f64 distance(map a, map b) { + return hypot(b.x - a.x, b.y - a.y) +} + +void _check(f64 value) { + 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..8b615f5 --- /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. +string shift(string text, i32 by) { + 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..e93f606 --- /dev/null +++ b/examples/types.asc @@ -0,0 +1,62 @@ +// 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)) + +// 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. +u8 small = 250 +try { + small += 10 +} catch e { + print("error:", e) +} + +// Mixing types widens to a type that holds both. +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 u8 MAX_PLAYERS = 4 +print("max players:", MAX_PLAYERS) + +// 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 + } + return sum(values) / f64(len(values)) +} +print("average:", average([3, 4, 8])) + +string describe(i64 n) { + 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..5feba07 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 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; - ExprPtr value; + TypeSpec type; // `any` when no type was written + bool hasType = false; + bool constant = false; + ExprPtr value; // null for `type name`, 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..6dfad08 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; @@ -13,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 @@ -20,12 +34,15 @@ 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; } 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..4d14e6a 100644 --- a/src/include/interpreter.hpp +++ b/src/include/interpreter.hpp @@ -3,6 +3,8 @@ #include "ast.hpp" #include "environment.hpp" +#include "error.hpp" +#include "stack_limit.hpp" #include "value.hpp" #include @@ -11,6 +13,8 @@ #include #include #include +#include +#include #include namespace ascend { @@ -20,6 +24,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 +41,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 +63,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 +89,20 @@ class Interpreter { private: 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); 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 +111,17 @@ 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 noteErrorSite(int line, int column); + void applyErrorSite(RuntimeError& error); + void clearErrorSite(); void checkStack() const; + std::string importDirectory() const; template std::shared_ptr track(std::shared_ptr object); @@ -103,15 +129,24 @@ 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 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_; - uintptr_t stackBase_; - size_t stackBudget_; + StackLimit stack_; std::vector> heap_; size_t nextCollection_; 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..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 @@ -43,8 +44,17 @@ class Parser { StmtPtr tryStatement(const Token& keyword); StmtPtr throwStatement(const Token& keyword); StmtPtr functionDeclaration(const Token& keyword); - std::unique_ptr functionRest(const Token& keyword, const std::string& name); + 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, + 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); ExprPtr condition(const std::string& keyword); void expectStatementEnd(); @@ -58,6 +68,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 +83,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; @@ -89,6 +105,7 @@ class Parser { std::vector errors_; bool incomplete_ = false; int depth_ = 0; + StackLimit stack_; }; } // namespace ascend 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..837b588 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: `u8 x = 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/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/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..da466a0 --- /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 `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; } +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 `u8 x = 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..d62db53 100644 --- a/src/interpreter.cpp +++ b/src/interpreter.cpp @@ -1,6 +1,14 @@ #include "interpreter.hpp" +#include "lexer.hpp" +#include "numeric.hpp" +#include "parser.hpp" +#include "runner.hpp" +#include "semantic_analyzer.hpp" +#include "stack_limit.hpp" #include "stdlib.hpp" +#include "typecheck.hpp" +#include "utf8.hpp" #include #include @@ -10,25 +18,15 @@ #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; -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 +35,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 +51,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 +75,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,41 +165,70 @@ std::string arityMessage(const std::string& name, size_t min, long long max, siz return who + " expects " + expected + " but got " + std::to_string(got); } -// 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 -} +std::string functionLabel(const std::string& name) { return name.empty() ? "this function" : name + "()"; } -// 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] == ':'); } -void locate(RuntimeError& error, int line, int column) { - if (!error.hasLocation()) { - error.line = line; - error.column = column; - } +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_; +}; + +// 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 @@ -204,21 +236,21 @@ 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{}()); } 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,35 +340,167 @@ 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::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 { + if (stack_.exceeded()) { + 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(); + clearErrorSite(); + try { + runTopLevel(*programs_.back(), globals_, replMode_); + } catch (RuntimeError& error) { + applyErrorSite(error); + currentSource_ = saved; + throw; + } 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); } } -Interpreter::Flow Interpreter::exec(const Stmt& stmt, const EnvPtr& 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 { - return execStmt(stmt, env); - } catch (RuntimeError& error) { - locate(error, stmt.line, stmt.column); + 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) { + ErrorSite site(*this, stmt.line, stmt.column); + return execStmt(stmt, env); } Interpreter::Flow Interpreter::execStmt(const Stmt& stmt, const EnvPtr& env) { @@ -345,19 +509,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 +544,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 +555,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 +591,53 @@ void Interpreter::hoistFunctions(const Block& block, const EnvPtr& env) { } } +// The starting value of `type name` 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 +645,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 +665,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 +690,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 +712,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) + " returns 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,16 +753,14 @@ 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; - 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))); @@ -560,8 +786,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 +806,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); } @@ -589,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; @@ -598,25 +827,29 @@ 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(); - 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) { @@ -686,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); } @@ -700,37 +929,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 +975,20 @@ 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); + 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); +} + // 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 +1004,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 +1024,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(); @@ -789,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); @@ -811,25 +1059,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 +1103,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 +1122,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 +1141,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 +1151,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 +1193,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 +1218,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 +1227,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 +1244,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 +1253,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 +1271,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..e4c274c 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; @@ -34,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_; } @@ -76,16 +83,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 +117,26 @@ 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: { + // 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); @@ -117,12 +149,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"); @@ -251,6 +293,150 @@ StmtPtr Parser::throwStatement(const Token& keyword) { return stmt; } +// 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& 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 '=' in a typed declaration, like '" + stmt->type.name() + " " + name.lexeme + " = ...'"); + } + if (match(TokenType::Equal)) { + skipNewlines(); + stmt->value = expression(); + } + expectStatementEnd(); + return stmt; +} + +// 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) { + auto stmt = nodeAt(keyword); + stmt->constant = true; + 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(); + 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, unknownTypeMessage(name)); + 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); @@ -258,15 +444,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 (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; @@ -279,6 +475,11 @@ std::unique_ptr Parser::functionRest(const Token& keyword, const s if (!match(TokenType::Comma)) break; } consume(TokenType::RightParen, "')' after the parameters"); + 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; } @@ -348,6 +549,8 @@ void Parser::synchronize() { case TokenType::Throw: case TokenType::Break: case TokenType::Continue: + case TokenType::Const: + case TokenType::Match: return; default: advance(); @@ -371,6 +574,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 +643,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 +664,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 +761,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 +859,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..6b11d71 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 + " 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 { + 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/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/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..39b62bd 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,274 @@ TEST(interp_cycle_collector_frees_closures) { interpreter.collectGarbage(); CHECK(interpreter.trackedObjectCount() < 100); } + +// --------------------------------------------------------------------------- +// Types + +TEST(interp_typed_variables) { + CHECK_OUTPUT( + "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" + "i8 u16 u64 f32 f64 char string bool u8 int float\n"); +} + +TEST(interp_zero_values) { + 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("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\")\nu8 x = 300"); + CHECK(result.status == RunStatus::SyntaxError); + CHECK_EQ(result.out, std::string("")); + 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("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("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("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("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("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"); +} + +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("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)"); + 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 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"); + 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(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(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() 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) { + 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(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"); + 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, "u8 x = 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..20563f0 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,100 @@ 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("u8 age = 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("string? name"); + CHECK(zero.errors.empty()); + const auto& noValue = onlyStatement(zero).as(); + CHECK(noValue.type.nullable); + CHECK(noValue.value == nullptr); + + 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("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(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)")); +} + +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()); +} 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; }