fusion-lang 0.0.1.alpha1 → 0.0.1

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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -6
  3. data/Rakefile +9 -0
  4. data/docs/lang/design.md +418 -28
  5. data/docs/lang/implementation.md +238 -0
  6. data/docs/lang/roadmap.md +20 -57
  7. data/docs/user/explanation.md +5 -10
  8. data/docs/user/how-to-guides.md +62 -23
  9. data/docs/user/reference.md +596 -168
  10. data/docs/user/tutorial.md +32 -29
  11. data/examples/double.fsn +1 -1
  12. data/examples/ends.fsn +4 -0
  13. data/examples/factorial.fsn +2 -2
  14. data/examples/fizzbuzz.fsn +1 -4
  15. data/examples/json_test.fsn +4 -0
  16. data/examples/palindrome.fsn +2 -1
  17. data/exe/fusion +17 -44
  18. data/lib/fusion/ast.rb +97 -0
  19. data/lib/fusion/atom.rb +17 -0
  20. data/lib/fusion/cli/decoder.rb +84 -0
  21. data/lib/fusion/cli/encoder.rb +28 -0
  22. data/lib/fusion/cli/options.rb +212 -0
  23. data/lib/fusion/cli/parser.rb +38 -0
  24. data/lib/fusion/cli/repl.rb +78 -0
  25. data/lib/fusion/cli/serializer.rb +70 -0
  26. data/lib/fusion/cli.rb +207 -0
  27. data/lib/fusion/interpreter/builtins.rb +465 -0
  28. data/lib/fusion/interpreter/env.rb +89 -0
  29. data/lib/fusion/interpreter/error_val.rb +71 -0
  30. data/lib/fusion/interpreter/func.rb +22 -0
  31. data/lib/fusion/interpreter/native_func.rb +22 -0
  32. data/lib/fusion/interpreter/thunk.rb +53 -0
  33. data/lib/fusion/interpreter.rb +752 -0
  34. data/lib/fusion/lexer.rb +249 -0
  35. data/lib/fusion/null.rb +9 -0
  36. data/lib/fusion/parser.rb +542 -0
  37. data/lib/fusion/token.rb +22 -0
  38. data/lib/fusion/typed_data.rb +23 -0
  39. data/lib/fusion/version.rb +1 -1
  40. data/lib/fusion/wire_pair.rb +11 -0
  41. data/lib/fusion.rb +11 -1122
  42. data/stdlib/all.fsn +13 -0
  43. data/stdlib/any.fsn +12 -0
  44. data/stdlib/chars.fsn +5 -0
  45. data/stdlib/compact.fsn +6 -0
  46. data/stdlib/concat.fsn +5 -0
  47. data/stdlib/falsey.fsn +6 -0
  48. data/stdlib/filter.fsn +12 -0
  49. data/stdlib/flatten.fsn +7 -0
  50. data/stdlib/gt.fsn +9 -0
  51. data/stdlib/gte.fsn +9 -0
  52. data/stdlib/lt.fsn +9 -0
  53. data/stdlib/lte.fsn +9 -0
  54. data/stdlib/map.fsn +6 -2
  55. data/stdlib/range.fsn +2 -1
  56. data/stdlib/reduce.fsn +8 -0
  57. data/stdlib/sanitize.fsn +12 -0
  58. data/stdlib/truthy.fsn +7 -0
  59. metadata +41 -2
  60. data/stdlib/math/square.fsn +0 -1
@@ -0,0 +1,238 @@
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.
data/docs/lang/roadmap.md CHANGED
@@ -5,43 +5,17 @@ live in [design.md](./design.md); this file is only for things still ahead.
5
5
 
6
6
  ---
7
7
 
8
- ## 1. Ergonomics: the most-wanted improvements
8
+ ## 1. Ergonomics
9
9
 
10
- **Operator sugar (planned).** Reintroduce infix `+ - * / % == != < <= > >= && || !`
11
- and string `++`, desugaring to the existing built-ins over pairs. Pure ergonomics,
12
- no semantic change. This is the single biggest readability win available and was
13
- always intended. Open question: exact precedence table and how it interleaves with
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
 
23
17
  ## 2. Error model
24
18
 
25
- **Payload shape consistency** *(open)*. The payload *shape* is inconsistent:
26
- built-ins use bare strings (`"divide: division by zero"`) while runtime
27
- mechanics use structured objects (`{"kind":"missing_key","key":"foo"}`). The
28
- string form is human-friendlier; the structured form is machine-friendlier
29
- (catchable via `!{"kind": k}`). Three plausible resolutions:
30
-
31
- - (a) all errors get structured payloads with a `kind` and an optional `message`;
32
- - (b) all errors get human strings, and structured matching is left to user code;
33
- - (c) keep both, document the rule that built-in operations use strings and
34
- runtime mechanics use objects.
35
-
36
- This is a small but irreversible decision (it shapes how catch clauses are
37
- written) and should be decided before any external code grows that depends on
38
- the current shapes.
39
-
40
- **Better diagnostics.** `FUSION_DEBUG` exists for file/parse errors; extend
41
- principled diagnostics to runtime error origins (where did this error first
42
- arise?). One option: attaching a source position to the payload (an extra
43
- `"at": "file.fsn:L:C"` field) when `FUSION_DEBUG` is set.
44
-
45
19
  **Stack traces** *(deferred)*. A propagated error tells you what happened, but
46
20
  not the chain of function applications it passed through. A capped trace
47
21
  (last N frames, accessible as an extra payload field, opt-in via env) would
@@ -49,33 +23,26 @@ help in deep pipelines.
49
23
 
50
24
  ---
51
25
 
52
- ## 3. Standard library completion
53
-
54
- Populate Tier 1 (written in Fusion): `filter`, `reduce`/`fold`, `reverse`,
55
- `head`, `tail`, `last`, `init`, `take`, `drop`, `zip`, `flatten`, `member`,
56
- `find`, `all`, `any`, `count`; comparison derivatives `lessEq`, `greaterThan`,
57
- `greaterEq`, `notEquals`; object helpers `entries`, `get`, `set`, `merge`; an
58
- `if` helper. This is also the best stress test of whether the language is
59
- pleasant to *write* in, not just to implement.
60
-
61
- ---
62
-
63
- ## 4. Runtime and tooling
26
+ ## 3. Runtime and tooling
64
27
 
65
- - **Streaming I/O** (NDJSON): map a program over a stream of JSON values.
66
28
  - **A faster implementation** once semantics are frozen.
67
-
68
- ## 5. Open semantic questions to settle
69
-
70
- - Numeric tower: keep int/float split, or move to a single number type /
71
- arbitrary precision? Affects `divide`, `floor`, `equals`, and the
72
- `Integer`/`Float` predicates.
73
- - Function equality: `equals` on two functions always `false`, or an error?
74
- (Function equality is undecidable beyond trivial identity.)
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 itcopies 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.
75
42
 
76
43
  ---
77
44
 
78
- ## 6. Bigger experiments
45
+ ## 4. Bigger experiments
79
46
 
80
47
  **Destructuring functions (homoiconicity).** Treat a function as a list of
81
48
  `(pattern, output)` clause-pairs and pattern-match on it, enabling macros and
@@ -91,7 +58,3 @@ invertible functions; hopeless for many-to-one. Would change Fusion from
91
58
  functional to relational and needs backtracking search. The most exciting and
92
59
  most disruptive possible direction; best pursued as a separate mode or sibling
93
60
  project rather than folded into the core.
94
-
95
- **A static checker.** Because "types" are predicates, an optional static layer
96
- could attempt to verify predicate-guarded clauses and exhaustiveness without
97
- changing the dynamic semantics. Speculative.
@@ -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": ..., "xs": ...}`. The "arguments" become a value you can also store, inspect,
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] | @add` instead of `a + b`) and a little
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: `@add` and
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 (and one we're still tempted by)
140
+ ## The roads not taken
141
141
 
142
- Two ideas were explored and deliberately set aside, both documented in the design doc.
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
@@ -8,18 +8,13 @@ basics. Scan for the problem you have and copy the solution.*
8
8
  ## Diagnose a program that returns an error unexpectedly
9
9
 
10
10
  When you run a program and see an error payload on stderr, the payload itself
11
- usually tells you what went wrong (e.g. `"divide: division by zero"` or
12
- `{"kind":"missing_key","key":"email"}`). For errors arising *during reference
13
- resolution* typically a missing file or a parse error in an `@`-referenced file —
14
- enable extra diagnostics:
11
+ tells you what went wrong. Interpreter errors carry a standardized object whose
12
+ fields (`kind`, `origin`, `file`, `operation`, `status`, `input`, `expected`,
13
+ `message`) are documented in
14
+ [reference §6.5](./reference.md#65-the-standardized-error-payload).
15
15
 
16
- ```sh
17
- FUSION_DEBUG=1 fusion program.fsn '...'
18
- ```
19
-
20
- With `FUSION_DEBUG` set, the interpreter prints to stderr the exact path it failed
21
- to find or the parse error it hit. The most common cause is that the `stdlib/`
22
- folder is not where the interpreter expects it, so `@`-references can't be resolved.
16
+ For a missing file or a parse error in an `@`-referenced file, the `file` and
17
+ `input` fields name the path that failed.
23
18
 
24
19
  ---
25
20
 
@@ -31,7 +26,7 @@ Compute a boolean, then pipe it into a two-clause function:
31
26
 
32
27
  ```fusion
33
28
  (n =>
34
- [n, 0] | @lessThan | (
29
+ [n, 0] | @OP.compare | @lt | (
35
30
  true => "negative",
36
31
  false => "non-negative"
37
32
  )
@@ -45,8 +40,8 @@ Here's an elegant way of writing FizzBuzz:
45
40
  (
46
41
  n =>
47
42
  [
48
- [n, 3] | @mod,
49
- [n, 5] | @mod,
43
+ [n, 3] | @OP.modulo,
44
+ [n, 5] | @OP.modulo,
50
45
  ]
51
46
  |
52
47
  (
@@ -66,7 +61,7 @@ You could write `sum` like this:
66
61
  ```fusion
67
62
  (
68
63
  [] => 0,
69
- [x, ...rest] => [x, rest | @] | @add
64
+ [x, ...rest] => [x, rest | @] | @OP.sum
70
65
  )
71
66
  ```
72
67
 
@@ -83,7 +78,7 @@ A Fusion function takes exactly one input. Functions that require multiple input
83
78
  them into an array (or object) and destructure that in the pattern:
84
79
 
85
80
  ```fusion
86
- ([a, b] => [a, b] | @add)
81
+ ([a, b] => [a, b] | @OP.sum)
87
82
  ```
88
83
 
89
84
  Call it as `[3, 4] | @thatFunction`
@@ -94,7 +89,7 @@ needs the remaining arguments (`y` in the example below). Each `=>` consumes one
94
89
  and hands back a function waiting for the next:
95
90
 
96
91
  ```fusion
97
- (x => (y => [x, y] | @add))
92
+ (x => (y => [x, y] | @OP.sum))
98
93
  ```
99
94
 
100
95
  Call it as `4 | (3 | @thatFunction)`. `3 | f` yields a one-argument function that adds 3,
@@ -119,14 +114,14 @@ so guard the whole array instead:
119
114
  (
120
115
  [] => "palindrome",
121
116
  [_] => "palindrome",
122
- [_, ...rest, _] ? ([first, ..., last] => [first, last] | @equals) => rest | @,
117
+ [_, ...rest, _] ? ([first, ..., last] => [first, last] | @OP.equal) => rest | @,
123
118
  _ => "not a palindrome"
124
119
  )
125
120
  ```
126
121
 
127
122
  The outer pattern `[_, ...rest, _]` is what you destructure for retrieving the middle
128
123
  of the array which you need to continue your recursion. The inline predicate
129
- `([first, ..., last] => [first, last] | @equals)` independently destructures the same
124
+ `([first, ..., last] => [first, last] | @OP.equal)` independently destructures the same
130
125
  array again to compare its two ends.
131
126
 
132
127
  ---
@@ -135,8 +130,52 @@ array again to compare its two ends.
135
130
 
136
131
  Because a sibling file wins over a built-in or the standard library, you can override
137
132
  either — but only for files in the same directory, so you can't break things
138
- globally. Put an `add.fsn` next to your program and every `@add` *in that directory*
139
- uses yours; files elsewhere still get the built-in.
133
+ globally. Put a `map.fsn` next to your program and every `@map` *in that directory*
134
+ uses yours; files elsewhere still get the standard one.
135
+
136
+ ---
137
+
138
+ ## Reskin the operators (`@OP`) for a directory
139
+
140
+ The arithmetic, comparison, and boolean operators live in one built-in object,
141
+ `@OP` (`@OP.sum`, `@OP.compare`, `@OP.and`, …), and — like any `@`-name — it is
142
+ resolved per directory. To change what the operators mean for the files in one
143
+ directory (complex numbers, matrices, …), drop an `OP.fsn` there that overrides the
144
+ members you want, reaching the originals with `@@`:
145
+
146
+ ```fusion
147
+ # OP.fsn — this directory's arithmetic
148
+ { ...@@, "sum": (p => "my sum"), "product": (p => "my product") }
149
+ ```
150
+
151
+ Only files in *that* directory are affected; everything else keeps the defaults. To
152
+ check whether a directory changed the operators, look for an `OP.fsn` — there is no
153
+ other way to change them.
154
+
155
+ ### Making a named derived helper follow your override
156
+
157
+ Most stdlib helpers are deliberately **immune** to your override, so a reskin can't
158
+ break them by accident: `@truthy`/`@falsey` decide truthiness by pattern matching,
159
+ `@compact` drops nulls by pattern matching too, and the comparison helpers
160
+ `@lt`/`@gt`/`@lte`/`@gte` interpret an `@OP.compare` *result* (you write
161
+ `[a, b] | @OP.compare | @lt`, so the compare step already follows your override while
162
+ the interpretation stays fixed).
163
+
164
+ One helper still calls `@OP` internally — `@range` uses `@OP.sum` and `@OP.compare` —
165
+ and, like `@`-names everywhere, it resolves `@OP` in *its own* directory (the stdlib),
166
+ so it keeps the default even where you overrode `@OP`. To make it follow your override,
167
+ copy the stdlib file to the directory containing your other overrides. It then
168
+ resolves `@OP` locally:
169
+
170
+ ```sh
171
+ # CAUTION: `fusion --stdlib-path` not implemented yet, determine manually
172
+
173
+ # copy — portable, a frozen snapshot
174
+ cp "$(fusion --stdlib-path)/range.fsn" .
175
+ ```
176
+
177
+ Your copy of `range.fsn` now resolves `@OP` in its own directory first and will find
178
+ your overrides before the original builtin implementations.
140
179
 
141
180
  ---
142
181
 
@@ -185,7 +224,7 @@ to be called) instead:
185
224
 
186
225
  ```fusion
187
226
  # factorial
188
- (0 => 1, n ? @Integer => [n, [n, 1] | @subtract | @] | @multiply)
227
+ (0 => 1, n ? @Integer => [n, [n, -1] | @OP.sum | @] | @OP.product)
189
228
  ```
190
229
 
191
230
  If the file evaluates to an **object** and a recursive *helper* lives inside it as a
@@ -196,7 +235,7 @@ by a `.member` access — rather than `@filename.helper`:
196
235
  {
197
236
  "sumTo": (
198
237
  0 => 0,
199
- n ? @Integer => [n, [n, 1] | @subtract | @.sumTo] | @add
238
+ n ? @Integer => [n, [n, -1] | @OP.sum | @.sumTo] | @OP.sum
200
239
  )
201
240
  }
202
241
  ```