fusion-lang 0.0.1.alpha2 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +24 -0
- data/.simplecov +11 -0
- data/CHANGELOG.md +42 -0
- data/README.md +11 -9
- data/Rakefile +8 -0
- data/docs/lang/design.md +289 -51
- data/docs/lang/implementation.md +279 -0
- data/docs/lang/roadmap.md +20 -36
- data/docs/user/explanation.md +5 -10
- data/docs/user/how-to-guides.md +145 -15
- data/docs/user/reference.md +365 -140
- data/docs/user/tutorial.md +22 -19
- data/examples/double.fsn +4 -1
- data/examples/factorial.fsn +6 -3
- data/examples/fizzbuzz.fsn +1 -4
- data/examples/gcd.fsn +9 -0
- data/examples/json_test.fsn +4 -0
- data/examples/matrix/OP.fsn +2 -0
- data/examples/matrix/average.fsn +2 -0
- data/examples/matrix/solve.fsn +2 -0
- data/examples/palindrome.fsn +1 -1
- data/exe/fusion +12 -12
- data/lib/fusion/ast.rb +76 -27
- data/lib/fusion/atom.rb +1 -1
- data/lib/fusion/cli/decoder.rb +13 -8
- data/lib/fusion/cli/encoder.rb +2 -2
- data/lib/fusion/cli/options.rb +134 -61
- data/lib/fusion/cli/parser.rb +4 -4
- data/lib/fusion/cli/repl.rb +32 -27
- data/lib/fusion/cli/serializer.rb +11 -11
- data/lib/fusion/cli.rb +120 -49
- data/lib/fusion/interpreter/builtins.rb +298 -160
- data/lib/fusion/interpreter/env.rb +42 -12
- data/lib/fusion/interpreter/error_val.rb +42 -20
- data/lib/fusion/interpreter/thunk.rb +53 -0
- data/lib/fusion/interpreter.rb +263 -98
- data/lib/fusion/lexer.rb +125 -37
- data/lib/fusion/parser.rb +245 -70
- data/lib/fusion/version.rb +3 -1
- data/lib/fusion.rb +0 -1
- data/stdlib/all.fsn +13 -0
- data/stdlib/any.fsn +12 -0
- data/stdlib/chars.fsn +5 -0
- data/stdlib/compact.fsn +5 -0
- data/stdlib/concat.fsn +6 -0
- data/stdlib/entries.fsn +6 -0
- data/stdlib/falsey.fsn +6 -0
- data/stdlib/filter.fsn +12 -0
- data/stdlib/flatten.fsn +7 -0
- data/stdlib/map.fsn +7 -4
- data/stdlib/matrix/Matrix.fsn +8 -0
- data/stdlib/matrix/OP.fsn +18 -0
- data/stdlib/matrix/add.fsn +7 -0
- data/stdlib/matrix/column.fsn +6 -0
- data/stdlib/matrix/determinant.fsn +16 -0
- data/stdlib/matrix/dimensions.fsn +5 -0
- data/stdlib/matrix/identity.fsn +6 -0
- data/stdlib/matrix/invert.fsn +26 -0
- data/stdlib/matrix/minor.fsn +15 -0
- data/stdlib/matrix/multiply.fsn +10 -0
- data/stdlib/matrix/negate.fsn +5 -0
- data/stdlib/matrix/product.fsn +11 -0
- data/stdlib/matrix/rotate.fsn +10 -0
- data/stdlib/matrix/row.fsn +6 -0
- data/stdlib/matrix/scale.fsn +6 -0
- data/stdlib/matrix/subtract.fsn +7 -0
- data/stdlib/matrix/sum.fsn +14 -0
- data/stdlib/matrix/transpose.fsn +5 -0
- data/stdlib/range.fsn +2 -2
- data/stdlib/reduce.fsn +8 -0
- data/stdlib/safe.fsn +7 -0
- data/stdlib/sanitize.fsn +2 -3
- data/stdlib/toObject.fsn +7 -0
- data/stdlib/truthy.fsn +7 -0
- data/stdlib/vector/Vector.fsn +7 -0
- data/stdlib/vector/add.fsn +6 -0
- data/stdlib/vector/cross.fsn +6 -0
- data/stdlib/vector/dot.fsn +6 -0
- data/stdlib/vector/norm.fsn +6 -0
- data/stdlib/vector/scale.fsn +5 -0
- data/stdlib/vector/subtract.fsn +6 -0
- data/stdlib/zip.fsn +6 -0
- metadata +50 -4
- data/lib/fusion/interpreter/file_thunk.rb +0 -39
- data/stdlib/mapValues.fsn +0 -5
- data/stdlib/math/square.fsn +0 -4
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Implementation notes — Fusion
|
|
2
|
+
|
|
3
|
+
Companion to `design.md`. That file records *decisions*; this one explains *how* a
|
|
4
|
+
few non-obvious mechanisms in the interpreter actually work.
|
|
5
|
+
|
|
6
|
+
## Thunks: laziness, memoization, and bare `@`
|
|
7
|
+
|
|
8
|
+
A top-level **unit** — a file, an inline (`-e`) program, or a REPL entry — is a
|
|
9
|
+
single expression whose value is computed lazily and at most once. That is a `Thunk`
|
|
10
|
+
(`lib/fusion/interpreter/thunk.rb`): a small state machine over a compute closure.
|
|
11
|
+
|
|
12
|
+
- `:unforced` → on the first `force`, it runs the closure, stores the result, and
|
|
13
|
+
becomes `:done`. The closure takes **no arguments**, so the stored result is the
|
|
14
|
+
same for every reference.
|
|
15
|
+
- `:done` → returns the stored value. **This is the memoization.** It lives on the
|
|
16
|
+
thunk object itself, not in any lookup table.
|
|
17
|
+
- `:forcing` → re-entered while still computing ⇒ a non-productive data cycle,
|
|
18
|
+
reported as a `reference_error` at the point the cycle closes.
|
|
19
|
+
|
|
20
|
+
The subtlety: a unit's *value* is independent of the reference that forced it, but a
|
|
21
|
+
**read failure** is not — a missing file reached as `@a` from one place and `@../a`
|
|
22
|
+
from another must report each reference's own `operation`/`input`/`site`. So the
|
|
23
|
+
closure can't bake those in (it would memoize the *first* reference's error and hand
|
|
24
|
+
it to every later one). Instead, when the unit's own source can't be read the closure
|
|
25
|
+
*raises* `Thunk::ReadFailure` (carrying only the message, no reference). `force`
|
|
26
|
+
catches it and stores the `ReadFailure` itself as the memoized `@value`; then — since
|
|
27
|
+
it alone knows the reference — it builds a fresh `reference_error` from that stored
|
|
28
|
+
failure per call. So the thunk still memoizes once (the read happens once) and caches
|
|
29
|
+
nothing reference-specific. (A parse or evaluation error *is* part of the file's value
|
|
30
|
+
and is memoized and returned as-is.)
|
|
31
|
+
|
|
32
|
+
Raising — rather than returning a half-built error value — does two things. A read
|
|
33
|
+
failure can never *be* a value, so an uncompleted one can never leak into the value
|
|
34
|
+
space. And it couples `evaluate_file` to the Thunk: run outside one, the `ReadFailure`
|
|
35
|
+
bubbles up and becomes an `internal_error`, instead of silently yielding a placeholder.
|
|
36
|
+
|
|
37
|
+
A bare `@` means "the value of the current unit", resolved by forcing that unit's
|
|
38
|
+
own thunk.
|
|
39
|
+
|
|
40
|
+
### Two separate jobs — only one was ever keyed by filename
|
|
41
|
+
|
|
42
|
+
It is tempting to think memoization is keyed by filename. It is not. There are two
|
|
43
|
+
distinct mechanisms:
|
|
44
|
+
|
|
45
|
+
1. **Sharing a file's thunk across references.** `@a` reached from many places must
|
|
46
|
+
load `a.fsn` once. The interpreter keeps `@file_cache` (`abspath → Thunk`); a
|
|
47
|
+
given file always resolves to the same thunk object. *This* is keyed by the
|
|
48
|
+
absolute path.
|
|
49
|
+
2. **Memoizing a unit's value, and detecting cycles.** This is the thunk's own
|
|
50
|
+
`@state`/`@value`. It never consults a filename; it just remembers what it
|
|
51
|
+
computed.
|
|
52
|
+
|
|
53
|
+
So the filename is the key for *finding and sharing* a file's thunk — never for the
|
|
54
|
+
memoization itself.
|
|
55
|
+
|
|
56
|
+
### How a bare `@` reaches its thunk
|
|
57
|
+
|
|
58
|
+
Each unit puts its own thunk into the **interpreter context** of its environment
|
|
59
|
+
(see below) under `:self`, and `@` forces `env.context(:self)`:
|
|
60
|
+
|
|
61
|
+
- **Files**: the `:self` thunk is the very same object held in `@file_cache` for
|
|
62
|
+
that path (`set_context(:self, load_file(abspath))`). So `@` and a sibling `@a`
|
|
63
|
+
naming the same file share one memoized thunk, and a file that references itself
|
|
64
|
+
mid-load is caught as a cycle.
|
|
65
|
+
- **Inline / REPL**: there is no path, so the thunk is *only* reachable through the
|
|
66
|
+
environment binding. A closure captures the env where it was defined, so a bare
|
|
67
|
+
`@` inside a function resolves to that unit's thunk even when the function is
|
|
68
|
+
applied much later — which is what makes recursion work. The older filename-only
|
|
69
|
+
scheme could not express this, which is why inline/REPL had no `@`.
|
|
70
|
+
|
|
71
|
+
The REPL builds a fresh interpreter per entry, but the thunk object lives in the
|
|
72
|
+
captured environment and outlives any single interpreter: once `:done`, forcing it
|
|
73
|
+
from a later entry simply returns the stored value. So `f = (… @ …)` defined in one
|
|
74
|
+
entry and applied by `5 | f` in the next resolves `@` to `f`.
|
|
75
|
+
|
|
76
|
+
### When `@` produces something useful
|
|
77
|
+
|
|
78
|
+
`@` only forces when it is actually evaluated, so the outcome depends on the program,
|
|
79
|
+
not on whether stdin is present:
|
|
80
|
+
|
|
81
|
+
- In a **function** unit, `@` sits in the unevaluated body, so the unit's value is
|
|
82
|
+
just the function. Applying it externally — stdin for `-e`, a later entry in the
|
|
83
|
+
REPL — forces `@` and recurses. With nothing to apply it, the value is the function
|
|
84
|
+
itself (a `serialization_error` on stdout, or a lenient `"<function>"` in the
|
|
85
|
+
REPL). Both are valid; neither is a special case.
|
|
86
|
+
- In a **data** position (`[1, @]`), `@` is forced as the unit loads, while its own
|
|
87
|
+
thunk is still `:forcing` — a non-productive data cycle.
|
|
88
|
+
|
|
89
|
+
## Environment: bindings vs. interpreter context
|
|
90
|
+
|
|
91
|
+
`Env` (`lib/fusion/interpreter/env.rb`) holds two separate maps, both walking the
|
|
92
|
+
parent chain:
|
|
93
|
+
|
|
94
|
+
- **Bindings** (`@vars`) — user-visible identifiers. Pattern binders insert here via
|
|
95
|
+
`bind`; the REPL keeps a name across entries via `bind(…, checked: false)`. A bare
|
|
96
|
+
identifier in an expression is resolved here (`lookup`); unbound ⇒ `binding_error`.
|
|
97
|
+
- **Interpreter context** (`@context`) — ambient state the evaluator needs, written
|
|
98
|
+
with `set_context` and read with `context`, keyed by symbol:
|
|
99
|
+
|
|
100
|
+
| key | contents | set for |
|
|
101
|
+
| ------- | ------------------------------------------------------------ | --------------------------------------- |
|
|
102
|
+
| `:dir` | the directory `@name` / `@../a` resolve against (a `String`) | every unit (file's dir, else `Dir.pwd`) |
|
|
103
|
+
| `:file` | the absolute path, for error origins (a `String`) | files only (absent ⇒ file `"<inline>"`) |
|
|
104
|
+
| `:self` | the unit's own `Thunk`, forced by a bare `@` | every unit |
|
|
105
|
+
| `:jail` | the run's jail root (a `String`, or nil for unconfined) | once, on the run's root env |
|
|
106
|
+
| `:call_site` | the innermost user-code `file`, stamped onto built-in/stdlib errors (a `String`) | a stdlib function's clause env, in `apply` |
|
|
107
|
+
|
|
108
|
+
The two channels are deliberately separate, and a program reads only the first one.
|
|
109
|
+
So the context names are **not** identifiers: `__dir__`, `__file__`, and `__self__`
|
|
110
|
+
are unbound like any other unknown name (a `binding_error`), not readable values.
|
|
111
|
+
|
|
112
|
+
In particular `__self__` is **not** a synonym for `@`. `@` *forces* the `:self`
|
|
113
|
+
thunk down to the unit's value; the thunk object itself is not a Fusion value, and
|
|
114
|
+
exposing it would put a non-value into the value space (where it would crash
|
|
115
|
+
serialization). Keeping interpreter context out of the binding namespace is what
|
|
116
|
+
prevents that.
|
|
117
|
+
|
|
118
|
+
## The jail: confining `@`-resolution
|
|
119
|
+
|
|
120
|
+
The jail root lives in the environment's `:jail` context (set once on the run's root
|
|
121
|
+
env). Every file reached through an `@`-reference is checked with `within_jail?`, which
|
|
122
|
+
reads the jail from the interpreter's `@env` (`@env.context(:jail)`): the target's
|
|
123
|
+
expanded absolute path must be inside the jail root, or inside the stdlib directory
|
|
124
|
+
(always reachable, since it lives outside any project). A target outside both is a
|
|
125
|
+
`reference_error` (`outside the jail`).
|
|
126
|
+
|
|
127
|
+
The time of check differs by reference form, because `@name`/`@dir/a` carry a
|
|
128
|
+
built-in/stdlib fallback and `@../a`/`@load` do not:
|
|
129
|
+
|
|
130
|
+
- `@../a` and `@load` check the jail *before* the file is touched, so an out-of-jail
|
|
131
|
+
target errors whether or not it exists (it is never probed for existence).
|
|
132
|
+
- `@name`/`@dir/a` resolves *sibling-first*, so the sibling is `File.exist?`-ed to
|
|
133
|
+
choose between it and the built-in/stdlib fallback. An existing sibling outside the
|
|
134
|
+
jail then errors (`outside the jail`); a missing one falls through to the fallback.
|
|
135
|
+
|
|
136
|
+
The jail does **not** cover two things:
|
|
137
|
+
|
|
138
|
+
- The top-level program file (given explicitly on the CLI) is loaded directly, not
|
|
139
|
+
through `@`-resolution, so it is never jail-checked — the jail is about what the
|
|
140
|
+
program may *reach*, not whether it may run.
|
|
141
|
+
- stdin — it is decoded as JSON, never evaluated as Fusion source, so it holds no
|
|
142
|
+
`@`-references at all.
|
|
143
|
+
|
|
144
|
+
The check is lexical: `File.expand_path` normalises `..` and existing symlinks are
|
|
145
|
+
followed. The jail confines references to a directory tree; it is not a security
|
|
146
|
+
sandbox. Fusion cannot write files, so no symlink can be planted to escape, but
|
|
147
|
+
existing ones are part of the legitimate project layout. A nil/unset jail means
|
|
148
|
+
unconfined. `CLI.root_environment` defaults it to `Dir.pwd`, and a real CLI run
|
|
149
|
+
instead supplies the program's directory.
|
|
150
|
+
|
|
151
|
+
The jail rides the environment: the CLI builds the root env once (`root_environment`)
|
|
152
|
+
and passes it to both program loading and `apply`, so every interpreter the run builds
|
|
153
|
+
reads the same `:jail` from its `@env`.
|
|
154
|
+
|
|
155
|
+
## The error `file`: the innermost user-code call site
|
|
156
|
+
|
|
157
|
+
A standardized error's `file` is the **innermost user-code file** on the call chain
|
|
158
|
+
when the operation failed — a stdlib frame like `@map` is transparent, so a built-in
|
|
159
|
+
failing inside it reports *your* file, not the stdlib's. It is `Dir.pwd`-relative, or
|
|
160
|
+
`"<inline>"` for an `-e`/REPL entry, or `"<fusion>"` above all user code (a bare
|
|
161
|
+
operation applied straight to a value). Present for `code`/`builtin`/`stdlib` errors;
|
|
162
|
+
absent for the channel/runtime ones (`input`/`output`/`interpreter`), which have no
|
|
163
|
+
call site.
|
|
164
|
+
|
|
165
|
+
It is filled by two complementary mechanisms, split by *where the error is born*.
|
|
166
|
+
|
|
167
|
+
### Born in user code → set at birth, from `code_site`
|
|
168
|
+
|
|
169
|
+
An error raised while evaluating user code (`.name`, `[]`, an unresolved `@`-ref, an
|
|
170
|
+
unbound identifier) is created in `eval_expr` with the env in hand, so it takes its
|
|
171
|
+
`file` straight from `code_site(env)`. This *must* happen at birth: for such an error
|
|
172
|
+
the innermost user file is its own birth location — the deepest user frame, strictly
|
|
173
|
+
below any caller — so no later step could recover it. These errors also routinely
|
|
174
|
+
surface where no `apply` runs (`[1, @undefined]` as a whole program, emitted directly
|
|
175
|
+
with no input applied), so they couldn't be stamped even if we wanted to. (`code_site`
|
|
176
|
+
labels any file under the stdlib directory as `origin: "stdlib"`, so an `origin:
|
|
177
|
+
"code"` error only ever arises in user code — where `code_site`'s file already *is*
|
|
178
|
+
the innermost user file.)
|
|
179
|
+
|
|
180
|
+
### Born outside user code → stamped at `apply`
|
|
181
|
+
|
|
182
|
+
A built-in error is built in Ruby; a stdlib error is a `!{…}` raised deep in a stdlib
|
|
183
|
+
body. Neither has the user's env in hand. But both are always produced *inside* an
|
|
184
|
+
`apply` (a built-in runs as `f.fn.call`; a stdlib body runs in `apply`'s `Func`
|
|
185
|
+
branch), and `apply` knows the call site — so `apply` wraps `dispatch_apply` and
|
|
186
|
+
stamps the result with `ErrorVal#with_call_site(call_site)`. The call site is the
|
|
187
|
+
`:call_site` context (`call_site(env)`); a stdlib function's clause env inherits its
|
|
188
|
+
*caller's* `:call_site`, so a built-in failing several stdlib frames deep still reports
|
|
189
|
+
the user's file. Stamping is idempotent (a no-op once a `file` is present), so an error
|
|
190
|
+
is stamped once — at the innermost `apply` that produced it — and outer applies leave
|
|
191
|
+
it alone. No "already-stamped" flag is needed: in the innermost-user-file model the
|
|
192
|
+
call site is constant all the way up a stdlib chain.
|
|
193
|
+
|
|
194
|
+
### Why the stamp keys on `runtime?`, never `origin`
|
|
195
|
+
|
|
196
|
+
The stamp must fire for interpreter/stdlib errors but never for an arbitrary user
|
|
197
|
+
`!{…}`. Keying on the payload's `origin` is **unsound** — `origin` is just data, so a
|
|
198
|
+
user writing `!{"origin": "builtin"}` would get a spurious `file`. Provenance isn't
|
|
199
|
+
payload; it is interpreter state, recorded by `ErrorVal#runtime?` (the `@runtime`
|
|
200
|
+
flag). So the gate is two parts:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
return self unless @runtime && @payload.is_a?(Hash) && !@payload.key?("file")
|
|
204
|
+
origin = @payload["origin"]
|
|
205
|
+
return self unless origin == "builtin" || origin == "stdlib"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`@runtime` is the **soundness gate** — a user error is never runtime, so a forged
|
|
209
|
+
`origin` can never trigger a stamp. The `origin` check runs only *past* that gate,
|
|
210
|
+
where `origin` is interpreter-set and therefore safe to read; its narrower job is to
|
|
211
|
+
skip runtime errors that have no call site (a JSON-`input` parse `syntax_error` is
|
|
212
|
+
`runtime?` but `origin: "input"`, and rightly gets no `file`).
|
|
213
|
+
|
|
214
|
+
### Why marking stdlib errors `@runtime` is watertight
|
|
215
|
+
|
|
216
|
+
`@runtime` is set true in exactly two places, both keyed on interpreter state, never
|
|
217
|
+
on payload content:
|
|
218
|
+
|
|
219
|
+
1. `ErrorVal.from_runtime` — every interpreter-built error.
|
|
220
|
+
2. An `ErrLit` evaluated **where `code_site(env).origin == "stdlib"`**.
|
|
221
|
+
|
|
222
|
+
For (2), `env`'s `:file` must lie under `@stdlib_dir` (`file_site` checks
|
|
223
|
+
`start_with?`), and `:file` is set there only by `evaluate_file` loading a file
|
|
224
|
+
resolved via `resolve_builtin_or_stdlib`'s `File.join(@stdlib_dir, …)`. Every user
|
|
225
|
+
route (`@name` siblings, `@../a`, `@load`) resolves into the user's own tree;
|
|
226
|
+
inline/REPL has no `:file` at all. So user code always runs under `origin: "code"`;
|
|
227
|
+
the only way to reach `origin: "stdlib"` is for the code to physically live in the
|
|
228
|
+
interpreter's stdlib directory — which a Fusion program cannot arrange (it can't write
|
|
229
|
+
files, and a project isn't installed there), and which *would be* stdlib if it did.
|
|
230
|
+
Because the flag is set from *where the code runs*, a user writing `!{"origin":
|
|
231
|
+
"stdlib", "runtime": true}` changes only payload fields; the `@runtime` ivar is
|
|
232
|
+
untouched, so the error is never stamped.
|
|
233
|
+
|
|
234
|
+
Marking the flag at construction (a constructor argument, not a later mutation) keeps
|
|
235
|
+
`@runtime` write-once. A consequence of stdlib errors being runtime errors is that
|
|
236
|
+
they serialize **leniently** (functions → `"<function>"`, non-finite → `"<Infinity>"`),
|
|
237
|
+
which made the old `| @sanitize` in stdlib error payloads redundant; it was dropped,
|
|
238
|
+
and `sanitize.fsn` remains as a standalone utility.
|
|
239
|
+
|
|
240
|
+
## `Unreachable` is relative to the `exe/fusion` entry point
|
|
241
|
+
|
|
242
|
+
`raise Unreachable` asserts "no input can steer execution here **from `exe/fusion`**":
|
|
243
|
+
the lexer/parser only emit the known token/node classes and `Options.parse` only
|
|
244
|
+
produces the known use cases and modes, so from the binary the guarded arms are dead
|
|
245
|
+
— reaching one is by definition an interpreter bug, the one deliberate exception to
|
|
246
|
+
"no raw Ruby errors on stderr" (design §2.9). The claim says nothing about the more
|
|
247
|
+
granular Ruby seams: calling `Fusion::CLI` or `Fusion::Interpreter` directly *can*
|
|
248
|
+
reach the guards by handing over a bogus node or use case, which is exactly how the
|
|
249
|
+
specs prove they exist (`cli_spec.rb`, `error_kinds_spec.rb` "internal invariant
|
|
250
|
+
guards"). For the same reason the guards carry no `:nocov:` markers: the four with
|
|
251
|
+
such specs count as covered, the rest as honest misses.
|
|
252
|
+
|
|
253
|
+
## Mutant test selection: the `mutant_expression` tags
|
|
254
|
+
|
|
255
|
+
Mutation testing (`.mutant.yml`, subjects `Fusion*`) kills a mutation by re-running
|
|
256
|
+
specs **inside the mutant worker's process** — the mutated method exists only in that
|
|
257
|
+
process's memory, never on disk. Two consequences shape how the specs are wired up:
|
|
258
|
+
|
|
259
|
+
- The subprocess-driven specs (`cli_subprocess_spec.rb`, `repl_pty_spec.rb`) can never
|
|
260
|
+
kill a mutation: they spawn `exe/fusion`, which loads the pristine code from disk.
|
|
261
|
+
Their prose describe titles keep them out of selection, deliberately.
|
|
262
|
+
- Mutant does not select tests by coverage. mutant-rspec parses the first word of each
|
|
263
|
+
example's description into an expression (`RSpec.describe Fusion::CLI` →
|
|
264
|
+
`Fusion::CLI`); a prose title is unaddressable. The `mutant_expression` metadata is
|
|
265
|
+
the explicit override, which is why every `expect_pipe` spec file is tagged
|
|
266
|
+
`mutant_expression: "Fusion::CLI*"` — the entry point the spec harness drives.
|
|
267
|
+
|
|
268
|
+
For each subject, mutant walks the subject's match expressions most-specific-first —
|
|
269
|
+
`Fusion::Interpreter#load_file`, then `Fusion::Interpreter*`, then `Fusion*` — and
|
|
270
|
+
**stops at the first level that matches any tests**. The tagged battery matches at the
|
|
271
|
+
`Fusion::CLI*` level (CLI subjects, alongside `cli_spec.rb`) and at the `Fusion*`
|
|
272
|
+
fallback (all other subjects).
|
|
273
|
+
|
|
274
|
+
**Caveat — preemption.** The fallback reaches the tagged specs only because no spec
|
|
275
|
+
file describes an inner scope directly. Adding a `RSpec.describe Fusion::Interpreter`
|
|
276
|
+
file would match at the `Fusion::Interpreter*` level and silently preempt the whole
|
|
277
|
+
tagged battery for every interpreter mutation — most kills would vanish. When adding
|
|
278
|
+
such a file, extend the language specs' tags to cover that scope too
|
|
279
|
+
(`mutant_expression` accepts an array).
|
data/docs/lang/roadmap.md
CHANGED
|
@@ -5,18 +5,12 @@ live in [design.md](./design.md); this file is only for things still ahead.
|
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
## 1. Ergonomics
|
|
8
|
+
## 1. Ergonomics
|
|
9
9
|
|
|
10
|
-
**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
`|` and `=>`.
|
|
15
|
-
|
|
16
|
-
**`@`-namespace resolution polish.** Decide on project-root confinement
|
|
17
|
-
(sandboxing) for `@../` escapes; consider a configurable standard-library search
|
|
18
|
-
path; consider tooling to surface *which* target a given `@name` resolves to in
|
|
19
|
-
a directory, since shadowing is invisible at the call site.
|
|
10
|
+
**Exposing the current call site** *(use case found; needs a sigil)*. The
|
|
11
|
+
interpreter tracks the current `:file`/`:dir`/`:call_site` as internal context,
|
|
12
|
+
unreadable from a program. User code should be able to mimick our *standardized*
|
|
13
|
+
error payloads. It needs access to `:file` for that.
|
|
20
14
|
|
|
21
15
|
---
|
|
22
16
|
|
|
@@ -29,32 +23,26 @@ help in deep pipelines.
|
|
|
29
23
|
|
|
30
24
|
---
|
|
31
25
|
|
|
32
|
-
## 3.
|
|
33
|
-
|
|
34
|
-
Populate Tier 1 (written in Fusion): `filter`, `reduce`/`fold`, `reverse`,
|
|
35
|
-
`head`, `tail`, `last`, `init`, `take`, `drop`, `zip`, `flatten`, `member`,
|
|
36
|
-
`find`, `all`, `any`, `count`; comparison derivatives `lessEq`, `greaterThan`,
|
|
37
|
-
`greaterEq`, `notEquals`; object helpers `entries`, `merge`; an
|
|
38
|
-
`if` helper. This is also the best stress test of whether the language is
|
|
39
|
-
pleasant to *write* in, not just to implement.
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
## 4. Runtime and tooling
|
|
26
|
+
## 3. Runtime and tooling
|
|
44
27
|
|
|
45
28
|
- **A faster implementation** once semantics are frozen.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
29
|
+
- **`fusion --stdlib-path`** *(planned)*. Print the absolute path of the bundled
|
|
30
|
+
standard library so a user can find, read, copy, or symlink its `.fsn` files —
|
|
31
|
+
e.g. to make a derived helper like `@range` follow a local `@OP` override:
|
|
32
|
+
`cp "$(fusion --stdlib-path)/range.fsn" .`. This is the one ergonomics gap in the
|
|
33
|
+
"reskin the operators" workflow (see how-to-guides). Needs a stable path: on a
|
|
34
|
+
versioned install the returned directory changes across upgrades, which dangles
|
|
35
|
+
any symlink made against it — copies are unaffected.
|
|
36
|
+
- **`fusion vendor <name>…`** *(planned)*. Scaffold command: copy the named stdlib
|
|
37
|
+
files into the current directory as real, editable files. Ergonomic front-end
|
|
38
|
+
over `--stdlib-path` + `cp` for when a directory reskins `@OP` and wants several
|
|
39
|
+
helpers that derive from `@OP` (`range`, …) to follow the local override
|
|
40
|
+
at once. Copies (portable, frozen) rather than symlinks, so it survives upgrades;
|
|
41
|
+
the copied one-liners can then be hand-edited.
|
|
54
42
|
|
|
55
43
|
---
|
|
56
44
|
|
|
57
|
-
##
|
|
45
|
+
## 4. Bigger experiments
|
|
58
46
|
|
|
59
47
|
**Destructuring functions (homoiconicity).** Treat a function as a list of
|
|
60
48
|
`(pattern, output)` clause-pairs and pattern-match on it, enabling macros and
|
|
@@ -70,7 +58,3 @@ invertible functions; hopeless for many-to-one. Would change Fusion from
|
|
|
70
58
|
functional to relational and needs backtracking search. The most exciting and
|
|
71
59
|
most disruptive possible direction; best pursued as a separate mode or sibling
|
|
72
60
|
project rather than folded into the core.
|
|
73
|
-
|
|
74
|
-
**A static checker.** Because "types" are predicates, an optional static layer
|
|
75
|
-
could attempt to verify predicate-guarded clauses and exhaustiveness without
|
|
76
|
-
changing the dynamic semantics. Speculative.
|
data/docs/user/explanation.md
CHANGED
|
@@ -47,12 +47,12 @@ everything else line up. With exactly one input and one output:
|
|
|
47
47
|
- **Application has one shape:** `value | function`. There is no argument list, no
|
|
48
48
|
call syntax, no arity to track. A pipeline `a | f | g | h` reads like a sentence.
|
|
49
49
|
- **Multi-argument needs are met by data:** pass an array `[a, b]` or an object
|
|
50
|
-
`{"f": ..., "
|
|
50
|
+
`{"f": ..., "c": ...}`. The "arguments" become a value you can also store, inspect,
|
|
51
51
|
and destructure — there's no separate notion of "argument tuple."
|
|
52
52
|
- **Pattern matching has one job:** match the single input. A function's clauses are
|
|
53
53
|
just alternative shapes that one input might have.
|
|
54
54
|
|
|
55
|
-
The cost is verbosity in arithmetic (`[a, b] | @
|
|
55
|
+
The cost is verbosity in arithmetic (`[a, b] | @OP.sum` instead of `a + b`) and a little
|
|
56
56
|
ceremony for multi-argument library functions. The first is a candidate for later
|
|
57
57
|
syntactic sugar; the second is mild. In exchange, the evaluation model is almost
|
|
58
58
|
trivially simple, which is exactly what you want in a language meant to be small.
|
|
@@ -110,7 +110,7 @@ This also resolved the module system for free. If a file is a value, then refere
|
|
|
110
110
|
a file *is* importing a value — no separate `import` construct, no namespace syntax.
|
|
111
111
|
The directory tree becomes the namespace. The standard library is just a folder of
|
|
112
112
|
files. One mechanism (`@`-references) now does top-level structure, modules, and
|
|
113
|
-
library delivery — and, in the current design, built-in access too: `@
|
|
113
|
+
library delivery — and, in the current design, built-in access too: `@OP` and
|
|
114
114
|
`@Integer` are looked up through the very same `@name` machinery as files. A bare
|
|
115
115
|
`@name` checks for a sibling file, then a built-in, then a standard-library file, so
|
|
116
116
|
your own files can locally shadow a built-in or a stdlib function without affecting
|
|
@@ -137,14 +137,9 @@ productive structure surrounds it.
|
|
|
137
137
|
|
|
138
138
|
---
|
|
139
139
|
|
|
140
|
-
## The roads not taken
|
|
140
|
+
## The roads not taken
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
**Operator sugar.** We could write `a + b` and desugar it to `[a, b] | @add`. We rolled
|
|
145
|
-
this back early to keep the core honest, with the explicit intent to reintroduce it
|
|
146
|
-
once the semantics were settled. It is a pure ergonomics layer; it changes nothing
|
|
147
|
-
underneath.
|
|
142
|
+
Some ideas were explored and set aside for future experiments or a different language.
|
|
148
143
|
|
|
149
144
|
**Destructuring functions.** The tantalizing one. Since a function literal is visibly
|
|
150
145
|
a list of `pattern => output` clauses, could we pattern-match *on a function*, taking
|
data/docs/user/how-to-guides.md
CHANGED
|
@@ -9,11 +9,12 @@ basics. Scan for the problem you have and copy the solution.*
|
|
|
9
9
|
|
|
10
10
|
When you run a program and see an error payload on stderr, the payload itself
|
|
11
11
|
tells you what went wrong. Interpreter errors carry a standardized object whose
|
|
12
|
-
fields (`kind`, `
|
|
12
|
+
fields (`kind`, `origin`, `file`, `operation`, `status`, `input`, `expected`,
|
|
13
|
+
`message`) are documented in
|
|
13
14
|
[reference §6.5](./reference.md#65-the-standardized-error-payload).
|
|
14
15
|
|
|
15
|
-
For a missing file or a parse error in an `@`-referenced file, the `
|
|
16
|
-
`input` fields name the
|
|
16
|
+
For a missing file or a parse error in an `@`-referenced file, the `file` and
|
|
17
|
+
`input` fields name the path that failed.
|
|
17
18
|
|
|
18
19
|
---
|
|
19
20
|
|
|
@@ -25,7 +26,7 @@ Compute a boolean, then pipe it into a two-clause function:
|
|
|
25
26
|
|
|
26
27
|
```fusion
|
|
27
28
|
(n =>
|
|
28
|
-
|
|
29
|
+
(n < 0) | (
|
|
29
30
|
true => "negative",
|
|
30
31
|
false => "non-negative"
|
|
31
32
|
)
|
|
@@ -39,8 +40,8 @@ Here's an elegant way of writing FizzBuzz:
|
|
|
39
40
|
(
|
|
40
41
|
n =>
|
|
41
42
|
[
|
|
42
|
-
[n, 3] | @
|
|
43
|
-
[n, 5] | @
|
|
43
|
+
[n, 3] | @OP.modulo,
|
|
44
|
+
[n, 5] | @OP.modulo,
|
|
44
45
|
]
|
|
45
46
|
|
|
|
46
47
|
(
|
|
@@ -60,7 +61,7 @@ You could write `sum` like this:
|
|
|
60
61
|
```fusion
|
|
61
62
|
(
|
|
62
63
|
[] => 0,
|
|
63
|
-
[x, ...rest] => [x, rest | @] | @
|
|
64
|
+
[x, ...rest] => [x, rest | @] | @OP.sum
|
|
64
65
|
)
|
|
65
66
|
```
|
|
66
67
|
|
|
@@ -77,7 +78,7 @@ A Fusion function takes exactly one input. Functions that require multiple input
|
|
|
77
78
|
them into an array (or object) and destructure that in the pattern:
|
|
78
79
|
|
|
79
80
|
```fusion
|
|
80
|
-
([a, b] => [a, b] | @
|
|
81
|
+
([a, b] => [a, b] | @OP.sum)
|
|
81
82
|
```
|
|
82
83
|
|
|
83
84
|
Call it as `[3, 4] | @thatFunction`
|
|
@@ -88,7 +89,7 @@ needs the remaining arguments (`y` in the example below). Each `=>` consumes one
|
|
|
88
89
|
and hands back a function waiting for the next:
|
|
89
90
|
|
|
90
91
|
```fusion
|
|
91
|
-
(x => (y => [x, y] | @
|
|
92
|
+
(x => (y => [x, y] | @OP.sum))
|
|
92
93
|
```
|
|
93
94
|
|
|
94
95
|
Call it as `4 | (3 | @thatFunction)`. `3 | f` yields a one-argument function that adds 3,
|
|
@@ -113,24 +114,153 @@ so guard the whole array instead:
|
|
|
113
114
|
(
|
|
114
115
|
[] => "palindrome",
|
|
115
116
|
[_] => "palindrome",
|
|
116
|
-
[_, ...rest, _] ? ([first, ..., last] => [first, last] | @
|
|
117
|
+
[_, ...rest, _] ? ([first, ..., last] => [first, last] | @OP.equal) => rest | @,
|
|
117
118
|
_ => "not a palindrome"
|
|
118
119
|
)
|
|
119
120
|
```
|
|
120
121
|
|
|
121
122
|
The outer pattern `[_, ...rest, _]` is what you destructure for retrieving the middle
|
|
122
123
|
of the array which you need to continue your recursion. The inline predicate
|
|
123
|
-
`([first, ..., last] => [first, last] | @
|
|
124
|
+
`([first, ..., last] => [first, last] | @OP.equal)` independently destructures the same
|
|
124
125
|
array again to compare its two ends.
|
|
125
126
|
|
|
126
127
|
---
|
|
127
128
|
|
|
129
|
+
## Short-circuit a chain of checks
|
|
130
|
+
|
|
131
|
+
`&&` and `||` are **eager**. `a && b` desugars to `[a, b] | @OP.and` — an array
|
|
132
|
+
piped into a function — so both operands are computed before the operator sees
|
|
133
|
+
them. The same goes for a hand-written conditions array `[a, b, c] | @OP.and`:
|
|
134
|
+
an array literal evaluates all of its elements, and if one of them is an error,
|
|
135
|
+
the error propagates out of the literal immediately. No piped form can keep its
|
|
136
|
+
input from being computed.
|
|
137
|
+
|
|
138
|
+
The one place where Fusion is lazy is a **clause body**: it evaluates only when
|
|
139
|
+
its clause's pattern matches. To run check B only if check A passed, put B in a
|
|
140
|
+
body that A's success selects. For boolean conditions:
|
|
141
|
+
|
|
142
|
+
```fusion
|
|
143
|
+
a | (true => b, _ => false) # short-circuiting a && b
|
|
144
|
+
a | (true => true, _ => b) # short-circuiting a || b
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
For example, `x | @Array | (true => x | @size > 0, _ => false)` safely tests
|
|
148
|
+
"a non-empty array": fed `5`, it yields `false` without ever piping the number
|
|
149
|
+
into `@size`.
|
|
150
|
+
|
|
151
|
+
In a guard, the same sequencing falls out of the stages pattern → predicate →
|
|
152
|
+
body, each of which runs only after the previous one succeeded. `@zip`
|
|
153
|
+
validates "a pair of equal-length arrays" like this:
|
|
154
|
+
|
|
155
|
+
```fusion
|
|
156
|
+
[xs, ys] ? ([a ? @Array, b ? @Array] => a | @size == b | @size)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The size comparison lives in the predicate's body, so it is computed only after
|
|
160
|
+
the pattern has established that both elements are arrays — `@size` can never
|
|
161
|
+
see a non-array. Sequencing matters because a predicate that *errors* does not
|
|
162
|
+
fall through to the next clause — the error propagates.
|
|
163
|
+
|
|
164
|
+
For a longer chain of checks you have two options. You can keep sequencing by
|
|
165
|
+
nesting another predicate between pattern and body — the checks stay lazy, and
|
|
166
|
+
a broken predicate still fails loudly. Or you can accept that every condition
|
|
167
|
+
is computed and route any error into "no match" by piping a conditions array
|
|
168
|
+
through `@OP.and | @safe`, one condition per line. `@matrix/sum` checks "an
|
|
169
|
+
array, non-empty, all matrices, all of equal dimensions" like this:
|
|
170
|
+
|
|
171
|
+
```fusion
|
|
172
|
+
matrices ? (ms => [
|
|
173
|
+
ms | @Array,
|
|
174
|
+
ms | @size > 0,
|
|
175
|
+
{"c": ms, "f": @Matrix} | @all,
|
|
176
|
+
ms |: @dimensions | @OP.equal,
|
|
177
|
+
] | @OP.and | @safe)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Fed `[5]`, the last condition errors (`@dimensions` of the number `5`), the
|
|
181
|
+
error collapses the conditions array — an array literal propagates an error
|
|
182
|
+
element, and `@OP.and` passes an error through untouched — and `@safe` turns
|
|
183
|
+
it into `false`: the clause simply doesn't match, and the input falls through
|
|
184
|
+
to the function's own error clause. Two things to keep in mind:
|
|
185
|
+
|
|
186
|
+
- `@safe` is just the two-clause function `(! => false, v => v)`, and it needs
|
|
187
|
+
both clauses: the error clause alone would turn every *successful* condition
|
|
188
|
+
into `null` — a value that matches no clause yields `null` — and the guard
|
|
189
|
+
would go falsey for valid inputs too.
|
|
190
|
+
- Catching trades loudness for flatness. Errors-as-false means a genuinely
|
|
191
|
+
broken condition (a typo'd reference, a wrong shape) reads as "the guard is
|
|
192
|
+
false for every input" instead of crashing. Prefer sequenced clauses where
|
|
193
|
+
the checks order themselves structurally anyway.
|
|
194
|
+
|
|
195
|
+
Eager conditions are fine when they are safe on the already-matched input.
|
|
196
|
+
After `[x ? @Matrix, row ? @Integer, col ? @Integer]` has matched, bounds
|
|
197
|
+
checks like `row >= 0 && row < x | @size` cannot error, and the flat `&&`
|
|
198
|
+
chain reads best — the structural pattern is what bought that safety.
|
|
199
|
+
|
|
200
|
+
(`@all` and `@any` do short-circuit, but at the level of applying their
|
|
201
|
+
predicate to already-computed items: the first falsey/truthy item stops the
|
|
202
|
+
testing. That protects against a predicate erroring on a later item, not
|
|
203
|
+
against computing the items themselves.)
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
128
207
|
## Shadow a built-in or stdlib function locally
|
|
129
208
|
|
|
130
209
|
Because a sibling file wins over a built-in or the standard library, you can override
|
|
131
210
|
either — but only for files in the same directory, so you can't break things
|
|
132
|
-
globally. Put
|
|
133
|
-
uses yours; files elsewhere still get the
|
|
211
|
+
globally. Put a `map.fsn` next to your program and every `@map` *in that directory*
|
|
212
|
+
uses yours; files elsewhere still get the standard one.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## Reskin the operators (`@OP`) for a directory
|
|
217
|
+
|
|
218
|
+
The arithmetic, comparison, and boolean operators live in one built-in object,
|
|
219
|
+
`@OP` (`@OP.sum`, `@OP.compare`, `@OP.and`, …), and — like any `@`-name — it is
|
|
220
|
+
resolved per directory. To change what the operators mean for the files in one
|
|
221
|
+
directory (complex numbers, matrices, …), drop an `OP.fsn` there that overrides the
|
|
222
|
+
members you want, reaching the originals with `@@`:
|
|
223
|
+
|
|
224
|
+
```fusion
|
|
225
|
+
# OP.fsn — this directory's arithmetic
|
|
226
|
+
{ ...@@, "sum": (p => "my sum"), "product": (p => "my product") }
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Only files in *that* directory are affected; everything else keeps the defaults. To
|
|
230
|
+
check whether a directory changed the operators, look for an `OP.fsn` — there is no
|
|
231
|
+
other way to change them.
|
|
232
|
+
|
|
233
|
+
The standard library ships one ready-made reskin: an `OP.fsn` containing just
|
|
234
|
+
`@matrix/OP` gives the directory matrix arithmetic — `a + b` elementwise, `a * b`
|
|
235
|
+
the matrix product, `a / b` multiplication by the inverse, `%`/`//` always an
|
|
236
|
+
error — built on the helpers `@matrix/multiply`, `@matrix/determinant`,
|
|
237
|
+
`@matrix/scale`, and `@matrix/rotate`.
|
|
238
|
+
|
|
239
|
+
### Making a named derived helper follow your override
|
|
240
|
+
|
|
241
|
+
Most stdlib helpers are deliberately **immune** to your override, so a reskin can't
|
|
242
|
+
break them by accident: `@truthy`/`@falsey` decide truthiness by pattern matching,
|
|
243
|
+
and `@compact` drops nulls by pattern matching too. The comparison readers
|
|
244
|
+
`@OP.lt`/`@OP.gt`/`@OP.lte`/`@OP.gte` interpret an `@OP.compare` *result*, and both
|
|
245
|
+
steps of `a < b` (= `[a, b] | @OP.compare | @OP.lt`) resolve through `@OP` — so
|
|
246
|
+
overriding `compare` alone already reskins the comparisons, and an `OP.fsn` that
|
|
247
|
+
spreads `...@@` keeps the original readers.
|
|
248
|
+
|
|
249
|
+
One helper still calls `@OP` internally — `@range` uses `@OP.sum` and `@OP.compare` —
|
|
250
|
+
and, like `@`-names everywhere, it resolves `@OP` in *its own* directory (the stdlib),
|
|
251
|
+
so it keeps the default even where you overrode `@OP`. To make it follow your override,
|
|
252
|
+
copy the stdlib file to the directory containing your other overrides. It then
|
|
253
|
+
resolves `@OP` locally:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
# CAUTION: `fusion --stdlib-path` not implemented yet, determine manually
|
|
257
|
+
|
|
258
|
+
# copy — portable, a frozen snapshot
|
|
259
|
+
cp "$(fusion --stdlib-path)/range.fsn" .
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Your copy of `range.fsn` now resolves `@OP` in its own directory first and will find
|
|
263
|
+
your overrides before the original builtin implementations.
|
|
134
264
|
|
|
135
265
|
---
|
|
136
266
|
|
|
@@ -179,7 +309,7 @@ to be called) instead:
|
|
|
179
309
|
|
|
180
310
|
```fusion
|
|
181
311
|
# factorial
|
|
182
|
-
(0 => 1, n ? @Integer => [n, [n, 1] | @
|
|
312
|
+
(0 => 1, n ? @Integer => [n, [n, -1] | @OP.sum | @] | @OP.product)
|
|
183
313
|
```
|
|
184
314
|
|
|
185
315
|
If the file evaluates to an **object** and a recursive *helper* lives inside it as a
|
|
@@ -190,7 +320,7 @@ by a `.member` access — rather than `@filename.helper`:
|
|
|
190
320
|
{
|
|
191
321
|
"sumTo": (
|
|
192
322
|
0 => 0,
|
|
193
|
-
n ? @Integer => [n, [n, 1] | @
|
|
323
|
+
n ? @Integer => [n, [n, -1] | @OP.sum | @.sumTo] | @OP.sum
|
|
194
324
|
)
|
|
195
325
|
}
|
|
196
326
|
```
|