ccalc
A fast terminal calculator with Octave/MATLAB syntax and script support — one binary, no runtime.
Octave is hundreds of megabytes. Python requires a runtime. ccalc is a single self-contained binary that starts instantly and works anywhere: interactive sessions, shell scripts, CI pipelines, Docker containers.
Quick start
# Interactive REPL
ccalc
# Single expression
ccalc "2 ^ 32"
# Script file
ccalc script.m
# Pipe mode
echo "sqrt(2)" | ccalc
Who is it for?
| User | Typical use |
|---|---|
| Embedded / systems engineer | Arithmetic, hex/bin conversions, bit masks |
| DevOps / SRE | Quick calculations in scripts and pipelines |
| Scientist / student | Interactive session with variables and math functions |
| MATLAB / Octave user | Familiar syntax, no heavy installation |
Project structure
| Crate | Role |
|---|---|
crates/ccalc | CLI binary: argument parsing, REPL, pipe mode |
crates/ccalc-engine | Library: tokenizer, parser, AST evaluator, variable environment |
The engine crate is the computation foundation. It has no I/O dependencies and is the target for all Octave/MATLAB compatibility work (Phases 1–10).
Compatibility standard
Where MATLAB and Octave differ, ccalc follows the modern MATLAB standard (R2016b+). See Architecture → Overview for design principles.
Source
- Repository: https://github.com/holgertkey/ccalc
- Changelog: see
CHANGELOG.mdin the repository root
User Guide
This guide covers everything you need to use ccalc effectively: from the first expression in the REPL to scripts with functions, matrices, structs, and plots.
Contents
| Topic | What you will find |
|---|---|
| Getting Started | Installation, first session, key concepts |
| REPL Mode | Interactive session: history, tab completion, workspace |
| Pipe & Script Mode | One-liners, shell pipelines, running .m files |
| Arithmetic & Operators | Precedence, bitwise ops, the ans variable |
| Variables | Assignment, who, clear, workspace save/load |
| Number Bases | Hex 0x, binary 0b, octal 0o input and display |
| Number Display Format | format short/long/rat/hex and custom precision |
| Formatted Output | fprintf, sprintf, %d/%f/%g/%s specifiers |
| Configuration | ~/.config/ccalc/config.toml reference |
| Matrices | Literals, arithmetic, indexing, built-in constructors |
| Vector & Data Utilities | sum, sort, find, reshape, unique, … |
| Comparison & Logical Operators | ==, ~=, &&, |, element-wise ops |
| Complex Numbers | 3+4i, abs, angle, conj, complex matrices |
| Strings | Char arrays, string objects, built-in string functions |
| File I/O | fopen/fclose, dlmread/dlmwrite, isfile |
| Control Flow | if, for, while, switch, break, continue |
| User-defined Functions | Named functions, lambdas, nargin/nargout |
| Cell Arrays | {...}, brace indexing, cellfun, arrayfun |
| Structs and Struct Arrays | .field access, struct(...), fieldnames |
| Error Handling | error, try/catch, pcall, lasterr |
| Variable Scoping | global, persistent, private/ directories |
| Statistics & Random Numbers | mean, std, rand, randn, distributions |
| Linear Algebra | eig, svd, lu, qr, chol, pinv |
| JSON | jsondecode, jsonencode |
| CSV — Tables and Matrices | readtable, writetable, csvread, csvwrite |
| MAT Files | load/save with .mat format |
| Datetime & Duration | datetime, duration, formatting, arithmetic |
| Matrix Utilities & Set Operations | intersect, union, ismember, kron |
| Polynomial Operations & Interpolation | polyval, polyfit, roots, interp1 |
| FFT & Signal Processing | fft, ifft, fftshift, freqz |
| Dynamic Evaluation & Timing | eval, feval, tic/toc |
| Plugins | The Plugin trait and custom built-ins |
| Plot Functions | plot, scatter, surf, contour, subplot, … |
Getting Started
Installation
Build from source (requires Rust):
git clone https://github.com/holgertkey/ccalc
cd ccalc
cargo build --release
# binary: target/release/ccalc
Usage modes
Interactive REPL
ccalc
A prompt shows the current value of ans. Type an expression and press Enter.
Type help for a reference, or help <topic> for a specific section.
[ 0 ]: 2 ^ 32
[ 4294967296 ]: / 1024
[ 4194304 ]: sqrt()
[ 2048 ]: help functions
...
[ 2048 ]: exit
Single expression (argument mode)
ccalc "EXPR"
Evaluates the expression, prints the result, and exits. Useful for shell scripts.
$ ccalc "2 ^ 32"
4294967296
$ ccalc "sqrt(2)"
1.4142135624
Script file
Pass a .m or .ccalc file as an argument:
ccalc script.m
ccalc examples/mortgage.ccalc
Pipe / non-interactive mode
When stdin is not a terminal, ccalc reads lines one by one and prints one result
per line. ans carries over across lines.
$ echo "sin(pi / 6)" | ccalc
0.5
$ printf "100\n/ 4\n+ 5" | ccalc
100
25
30
$ ccalc < formula.txt
Command-line options
| Flag | Description |
|---|---|
-h, --help | Print help and exit |
-v, --version | Print version and exit |
REPL Mode
Start with ccalc (no arguments, stdin is a terminal).
Prompt
The prompt always shows the current value of ans:
[ 0 ]:
[ 42 ]:
[ 0xFF ]:
ans
Every expression result is stored in ans. Expressions that start with
an operator use ans as the left-hand operand (partial expressions):
[ 0 ]: 100
[ 100 ]: * 2
[ 200 ]: + 50
[ 250 ]: / 5
[ 50 ]:
REPL commands
| Command | Action |
|---|---|
exit, quit | Quit |
cls | Clear the screen (also Ctrl+L) |
help, ? | Show cheatsheet |
help <topic> | Detailed help (see topic list below) |
help <name> | Show doc comment of a user-defined function |
who | Show all defined variables |
clear | Clear all variables |
clear <name> | Clear a single variable |
p | Show current decimal precision |
p<N> | Set precision to N decimal places (0–15) |
hex / dec / bin / oct | Switch display base |
base | Show ans in all four bases |
ws | Save workspace to file |
wl | Load workspace from file |
disp(expr) | Print value without updating ans |
fprintf('fmt') | Print formatted string (\n, \t, \\ supported) |
config | Show config file path and active settings |
config reload | Re-read config.toml and apply changes |
Help topics for help <topic>:
syntax functions userfuncs testing bases vars script matrices highlight prompt examples
Tab completion
Press Tab in the REPL to complete the current word:
- Variable names defined in the current session.
- Built-in function names (
sqrt,mean,assert, …).
When multiple candidates match, they are listed and the longest common prefix
is inserted. Press Tab again to cycle or type more characters to narrow down.
>> inv<Tab> → inv(
>> my_fun<Tab> → my_function (if defined)
Inline help for user functions
Place %-comment lines immediately after the function header (MATLAB
H1-line style) to attach a doc string. help <name> prints it:
function t = tri(n)
% Return the nth triangular number T(n) = n*(n+1)/2.
% Usage: t = tri(n)
t = n * (n + 1) / 2;
end
>> help tri
Return the nth triangular number T(n) = n*(n+1)/2.
Usage: t = tri(n)
help <name> searches the current workspace first, then — for functions on the
session path — loads the file on demand, so help bisect works even before
bisect() has been called.
“Did you mean?” error hints
When a name is not found, ccalc compares it against known variables and built-in names using edit distance. If a close match exists (at most 2 edits), it is shown as a suggestion:
>> sqrtt(4)
Error: Unknown function 'sqrtt'; did you mean 'sqrt'?
>> my_valu + 1
Error: Undefined variable 'my_valu'; did you mean 'my_value'?
Keyboard shortcuts
| Key | Action |
|---|---|
↑ / ↓ | Browse input history |
Ctrl+R | Reverse history search |
← → / Home / End | Cursor movement |
Ctrl+A | Go to beginning of line |
Ctrl+E | Go to end of line |
Ctrl+W | Delete word before cursor |
Ctrl+U | Delete from cursor to beginning of line |
Ctrl+K | Delete from cursor to end of line |
Ctrl+L | Clear screen |
Ctrl+C / Ctrl+D | Quit |
Silencing a line
Append ; to suppress output. For expressions, ans is still updated.
For assignments, ans is never updated regardless of ;.
[ 0 ]: 0.06 / 12; % expression — ans updated, output suppressed
[ 0.005 ]: rate = 0.07; % assignment — silent, ans unchanged
[ 0.005 ]:
Multiple ;-separated statements on one line — all but the last are silent:
[ 0 ]: a = 1; b = 2; c = 3; % all silent
[ 0 ]: a = 1; b = 2 % a = 1 silent, b = 2 shown
b = 2
[ 0 ]:
Configuration
Settings that persist across sessions (precision, default base, prompt) live in
config.toml. The config command shows the active
values; config reload applies any edits without restarting.
[ 0 ]: config
config file: /home/user/.config/ccalc/config.toml
precision: 10
base: dec
Syntax highlighting
ccalc highlights the current input line in real time as you type:
| Token type | Default colour | Examples |
|---|---|---|
| Keywords | yellow | if, for, while, end, function, else, … |
| Numbers | cyan | 42, 3.14, 1e-3, 0xFF |
| Strings | green | 'hello', "world" |
| Comments | dark gray | % a comment, # also a comment |
| Built-ins | bright cyan | sin, plot, zeros, reshape, … |
| Errors | red | Unclosed ', ", [, ( |
| User variables / operators | default | everything else |
Highlighting is active by default. To disable it, set enabled = false in the
[highlight] section of config.toml:
[highlight]
enabled = false
To change a colour, add the corresponding key:
[highlight]
keywords = "bold:blue"
numbers = "color256(208)"
comments = "#808080"
Supported formats: named ("yellow"), 8-bit ("color256(N)"), 24-bit truecolor
("#RRGGBB"), and a "bold:" prefix for any of them. See
Configuration for the full reference.
Custom prompt
Edit ~/.config/ccalc/config.toml to customise the prompt:
[repl]
prompt1 = "{gray}({line}){reset} [ {ans} ]: "
Supported placeholders: {ans}, {line}, {user}, {host}, {cwd},
{cwd_short}, {time}, color names ({red}, {green}, {reset}, …), and
24-bit truecolor ({#FF8800}). See Configuration for the
full placeholder reference and examples.
History
Input history is saved to ~/.config/ccalc/history and restored on the next
session. Each session is marked with a timestamp comment:
% --- Session: 2026-04-01 14:22:07 UTC ---
rate = 0.06 / 12
n = 360
% --- Session: 2026-04-01 15:10:44 UTC ---
hypot(3, 4)
The marker uses % so it is harmless if accidentally recalled and executed.
Pipe & Script Mode
When stdin is not a terminal (pipe or file redirect), or when a script file is passed as an argument, ccalc runs in non-interactive mode: no prompts, one result printed per line.
Pipe
echo "1 + 1" | ccalc
printf "100\n/ 4\n+ 5" | ccalc
Script files
Pass a file as an argument:
ccalc script.m
ccalc examples/mortgage.ccalc
Or redirect stdin:
ccalc < formula.txt
The entire file is parsed before execution begins, so helper functions may be defined at the bottom of the script and called from code above them — the same layout used by MATLAB/Octave scripts. See Function hoisting for details.
Comments
% starts a line comment (Octave/MATLAB convention):
% full-line comment — line is skipped entirely
10 * 5 % inline comment — expression still evaluates
# hash-style comment — same behaviour
Multi-line block comments use %{ … %} (or #{ … #}). The opening
and closing markers must be the leading non-whitespace content on their line:
%{
This entire block is ignored by the parser.
Useful for commenting out sections of code.
%}
x = 42; % this line executes normally
%{ also works on a single line %}
Semicolon — suppress output
A trailing ; suppresses output. Expressions still update ans;
assignments never update ans regardless of ;.
rate = 0.06 / 12; % silent assignment — ans unchanged
n = 360; % silent assignment — ans unchanged
factor = (1 + rate) ^ n;
Multiple ;-separated statements on one line are also supported:
a = 1; b = 2; c = 3; % all silent
a = 1; b = 2 % a = 1 silent, b = 2 printed
disp(expr) — print value
disp(expr) evaluates the expression and prints the result.
It does not update ans.
disp(ans) % print current ans value
disp(rate * 12) % print expression result
fprintf('fmt') — print formatted text
fprintf('fmt') prints a string with escape sequences (\n, \t, \\).
No newline is added automatically — include \n explicitly.
fprintf('=== Monthly mortgage ===\n')
fprintf('Result: ')
disp(ans)
Output:
=== Monthly mortgage ===
Result: 1199.1010503
Supported commands in pipe mode
All REPL commands except cls (which is ignored):
exit, quit, who, clear, clear <name>, ws, wl,
p, p<N>, hex, dec, bin, oct, base.
Example — mortgage script
% Monthly mortgage payment
% Principal: 200 000, annual rate: 6%, term: 30 years
rate = 0.06 / 12; % monthly interest rate
n = 360; % 30 years * 12 months
p = 200000; % principal
factor = (1 + rate) ^ n;
p * rate * factor / (factor - 1)
fprintf('Monthly payment ($): ')
disp(ans)
Output:
1199.1010503
Monthly payment ($): 1199.1010503
Arithmetic & Operators
Scalar operators
| Operator | Operation | Example |
|---|---|---|
+ | Addition | 3 + 4 → 7 |
- | Subtraction / unary minus | 10 - 4 → 6, -5 |
* | Multiplication | 3 * 7 → 21 |
/ | Division | 10 / 4 → 2.5 |
^ | Exponentiation (right-associative) | 2 ^ 10 → 1024 |
For modulo use the mod(a, b) function. % is a comment character, not a modulo operator.
Comments
% and # start line comments. Everything to the right is ignored:
% full-line comment
x = 5; % inline comment — x is still assigned
Multi-line block comments span from %{ to %} (each on its own line):
%{
Everything inside this block is ignored.
The %{ and %} must be the only non-whitespace content on their line.
%}
y = 10;
A same-line form %{ text %} is also valid. Hash-style #{ … #} works identically.
Comparison operators
Return 1.0 (true) or 0.0 (false). Work element-wise on matrices.
| Operator | Meaning |
|---|---|
== | Equal |
~= | Not equal |
< | Less than |
> | Greater than |
<= | Less or equal |
>= | Greater or equal |
Logical operators
| Operator | Meaning |
|---|---|
~expr | Logical NOT |
&& | Logical AND |
|| | Logical OR |
See Comparison & Logical Operators for full details.
Precedence (high → low)
- postfix
'— transpose ^,.^— right-associative- unary
-,~— negation, logical NOT *,/,.*,./,.^, implicit multiplication+,-:— range==,~=,<,>,<=,>=— comparison (non-associative)&&— logical AND||— logical OR (lowest)
Use parentheses to override: (2 + 3) * 4 → 20.
Special values: Inf, NaN, and division by zero
Division by zero follows IEEE 754 — it produces Inf or NaN rather than an
error:
1 / 0 % Inf
-1 / 0 % -Inf
0 / 0 % NaN
0 \ 1 % Inf (left division: 1/0)
These values propagate through arithmetic in the expected way:
Inf + 1 % Inf
Inf - Inf % NaN
1 / Inf % 0
isnan(NaN) % 1
isinf(Inf) % 1
Partial expressions
An expression starting with an operator uses ans as the left operand:
[ 100 ]: / 4
[ 25 ]: ^ 2
[ 625 ]:
Implicit multiplication
A number, variable, or closing parenthesis immediately before ( multiplies:
2(3 + 1) → 8 (same as 2 * (3 + 1))
(2 + 1)(4) → 12
2(3)(4) → 24
Unary minus
-5
-(3 + 2) → -5
--5 → 5
Unary minus has lower precedence than ^ and .^, matching MATLAB/Octave:
-3 ^ 2 → -9 % same as -(3^2), not (-3)^2
-x .^ 2 → -(x .^ 2)
(-3) ^ 2 → 9 % use parentheses to negate before raising
Matrix operators
When one or both operands are matrices, the same operators apply with element-wise or broadcast semantics:
| Expression | Semantics |
|---|---|
scalar + matrix | Add scalar to every element |
matrix + matrix | Element-wise (shapes must match) |
scalar * matrix | Scale every element |
matrix / scalar | Divide every element |
matrix ^ scalar | Raise every element to the power |
See Matrices for full details.
Functions & Constants
One-argument functions
| Function | Description | Example |
|---|---|---|
sqrt(x) | Square root of x | sqrt(144) → 12 |
abs(x) | Absolute value of x | abs(-7) → 7 |
floor(x) | Largest integer ≤ x (round toward −∞) | floor(2.9) → 2 |
ceil(x) | Smallest integer ≥ x (round toward +∞) | ceil(2.1) → 3 |
round(x) | Nearest integer; ties round away from zero | round(2.5) → 3 |
sign(x) | −1 if x < 0, 0 if x = 0, 1 if x > 0 | sign(-5) → -1 |
log(x) | Natural logarithm of x, base e (requires x > 0) | log(e) → 1 |
log2(x) | Base-2 logarithm of x (requires x > 0) | log2(8) → 3 |
log10(x) | Base-10 logarithm of x (requires x > 0) | log10(1000) → 3 |
exp(x) | e raised to the power x | exp(1) → 2.71828… |
sin(x) | Sine of x, where x is in radians | sin(pi/6) → 0.5 |
cos(x) | Cosine of x, where x is in radians | cos(0) → 1 |
tan(x) | Tangent of x, where x is in radians | tan(pi/4) → 1 |
asin(x) | Inverse sine of x ∈ [−1, 1]; result in [−π/2, π/2] | asin(1) * 180/pi → 90 |
acos(x) | Inverse cosine of x ∈ [−1, 1]; result in [0, π] | acos(0) * 180/pi → 90 |
atan(x) | Inverse tangent of x; result in (−π/2, π/2) | atan(1) * 180/pi → 45 |
Notes
sqrt(x) — x must be ≥ 0. Passing a negative value returns NaN (no error is raised).
floor, ceil, round — All three return a floating-point result, not an integer type.round uses round-half-away-from-zero: round(0.5) → 1, round(-0.5) → -1.
Compare: floor(-2.5) → -3, ceil(-2.5) → -2, round(-2.5) → -3.
sign(x) — Returns NaN when x is NaN (sign of NaN is undefined).
log(x) — Natural logarithm (base e), MATLAB/Octave-compatible. Returns NaN for x < 0 and -Inf for x = 0. No error is raised.log2(x) — Base-2 logarithm. log10(x) — Base-10 logarithm.
For an arbitrary base see log(x, base).
sin, cos, tan — Expect x in radians. To convert from degrees: deg * pi / 180.tan(x) is undefined at x = π/2 + n·π; it returns ±Inf at those points.
asin(x), acos(x) — Domain is [−1, 1]; values outside return NaN.
To get degrees: multiply the result by 180/pi.
atan(x) — Handles all finite inputs; returns a value in the open interval (−π/2, π/2).
It cannot determine the quadrant because it only sees the ratio y/x.
Use atan2(y, x) when you need a four-quadrant result.
sqrt(144) → 12
abs(-7) → 7
floor(2.9) → 2
ceil(2.1) → 3
round(2.5) → 3
sign(-5) → -1
log(e) → 1 (natural log)
log2(8) → 3
log10(1000) → 3
exp(log(5)) → 5 (round-trip)
sin(pi / 6) → 0.5
cos(pi / 3) → 0.5
tan(pi / 4) → 1
asin(0.5) * 180/pi → 30
acos(0.5) * 180/pi → 60
atan(1) * 180/pi → 45
Two-argument functions
| Function | Description | Example |
|---|---|---|
atan2(y, x) | Four-quadrant inverse tangent; result in (−π, π] | atan2(1,1)*180/pi → 45 |
mod(a, b) | Remainder of a ÷ b; result has the sign of b | mod(370, 360) → 10 |
rem(a, b) | Remainder of a ÷ b; result has the sign of a | rem(-1, 3) → -1 |
max(a, b) | Larger of two scalar values | max(3, 7) → 7 |
min(a, b) | Smaller of two scalar values | min(3, 7) → 3 |
hypot(a, b) | Euclidean distance √(a²+b²), numerically stable | hypot(3, 4) → 5 |
log(x, base) | Logarithm of x to an arbitrary base (both must be > 0) | log(8, 2) → 3 |
Notes
atan2(y, x) — First argument is y (numerator), second is x (denominator).
Returns a value in the range (−π, π], correctly determining the quadrant from the signs of both arguments.atan2(0, -1) * 180/pi → 180, whereas atan(-0/-1) * 180/pi → 0.
mod(a, b) vs rem(a, b) — Both compute the remainder after division, but differ in sign
when the operands have opposite signs:
mod(-1, 3) → 2 (result has the sign of 3, in range [0, 3))
rem(-1, 3) → -1 (result has the sign of -1)
mod guarantees the result is in [0, b) for positive b, making it useful for angle wrapping and modular arithmetic.
rem follows IEEE 754 remainder convention.
b = 0 produces NaN.
max(a, b), min(a, b) — These two-argument forms work with scalars only.
To find the maximum or minimum element of a vector or matrix, use the one-argument form max(v) / min(v) (see Vector & Data Utilities).
hypot(a, b) — Computes √(a²+b²) without intermediate overflow or underflow.
Prefer hypot over sqrt(a^2 + b^2) when the values may be very large or very small.
log(x, base) — Both x and base must be positive; base must not equal 1.
Negative or zero values return NaN or -Inf as with the single-argument form.
atan2(1, 1) * 180/pi → 45
atan2(0, -1) * 180/pi → 180
mod(370, 360) → 10
mod(-1, 3) → 2 (result in [0, 3))
rem(-1, 3) → -1 (same sign as dividend)
max(3, 7) → 7
min(3, 7) → 3
hypot(3, 4) → 5
hypot(5, 12) → 13
log(8, 2) → 3 (log base 2 of 8)
log(100, 10) → 2 (same as log10(100))
mod vs rem
Both compute the remainder after division, but differ in sign when the operands have opposite signs:
mod(-1, 3) → 2 (result has the sign of 3)
rem(-1, 3) → -1 (result has the sign of -1)
Use mod when you want a value always in [0, b), e.g. for angle wrapping.
Use rem when you need the IEEE 754 remainder.
Bitwise functions
All bitwise functions require non-negative integer arguments.
They pair naturally with hex (0xFF), binary (0b1010), and octal (0o17)
input literals.
| Function | Description |
|---|---|
bitand(a, b) | Bitwise AND of a and b |
bitor(a, b) | Bitwise OR of a and b |
bitxor(a, b) | Bitwise XOR of a and b |
bitshift(a, n) | Shift a left by n bits (n > 0) or right by ` |
bitnot(a) | Bitwise NOT of a within a 32-bit window |
bitnot(a, bits) | Bitwise NOT of a within an explicit bits-wide window; bits ∈ [1, 53] |
Notes
bitand, bitor, bitxor — Both arguments must be non-negative integers.
Floating-point values are truncated toward zero before the operation.
bitshift(a, n) — Positive n shifts left (multiply by 2ⁿ); negative n shifts right (logical, fills with zeros).
Returns 0 when |n| ≥ 64. The shift count n may be negative; a must be non-negative.
bitnot(a) — Flips all bits within a 32-bit window (Octave uint32 default).
Result is 2³² − 1 − a for values that fit in 32 bits.
bitnot(a, bits) — Flips bits within a window of bits width.
bits must be in [1, 53] (limited to the integer precision of IEEE 754 doubles).
Result is 2^bits − 1 − a.
bitand(0xFF, 0x0F) → 15
bitor(0b1010, 0b0101) → 15
bitxor(0xFF, 0x0F) → 240 (0xF0)
bitshift(1, 8) → 256 (1 << 8)
bitshift(256, -4) → 16 (256 >> 4)
bitnot(5, 8) → 250 (~5 within 8 bits = 0b11111010)
bitnot(0, 32) → 4294967295 (0xFFFFFFFF)
Combining shifts and masks:
bitshift(1, 4) - 1 → 15 (0b00001111 — 4-bit all-ones mask)
bitand(0xDEAD, 0xFF00) → 56832 (0xDE00 — extract high byte)
Empty-argument shorthand
Calling a function with empty parentheses uses ans as the argument:
[ 144 ]: sqrt() → 12 (same as sqrt(144))
[ -7 ]: abs() → 7
[ 0 ]: sin() → 0
Constants
| Name | Value |
|---|---|
pi | 3.14159265358979… |
e | 2.71828182845904… |
nan / NaN | IEEE 754 Not-a-Number — propagates through all arithmetic |
inf / Inf | Positive infinity; use -inf for negative infinity |
ans | Result of the last expression |
nan — Not a number. Any arithmetic operation involving nan returns nan.
nan == nan evaluates to 0 (IEEE 754: NaN is never equal to itself).
Use isnan(x) to test for NaN (see Vector & Data Utilities).
inf — Positive infinity. -inf is negative infinity.
1 / inf → 0, -inf < inf → 1, inf + inf → inf, inf - inf → nan.
ans — Holds the result of the most recent expression (assignments do not update it).
ans can appear anywhere in an expression.
nan and inf are parser-level constants and cannot be overwritten by assignment.
nan + 5 % → NaN
nan == nan % → 0 (IEEE 754: NaN is never equal to itself)
1 / inf % → 0
-inf < inf % → 1
ans can appear anywhere in an expression:
[ 9 ]: ans * 2 + 1 → 19
[ 9 ]: sqrt(ans) → 3
Nesting
Functions can be nested freely:
sqrt(abs(-16)) → 4
log(exp(1)) → 1
floor(sqrt(10)) → 3
max(hypot(3,4), 6) → 6
Functions in expressions
sqrt(144) + 3 → 15
2 * sin(pi / 6) → 1
log10(1000) ^ 2 → 9
hypot(3, 4) * 2 → 10
atan2(1, 1) * 180 / pi → 45
See also: Vector & Data Utilities for sum, prod, mean, norm, sort, find, and related functions.
Number Bases
Input literals
Any numeric literal can be written in hex, binary, or octal:
| Prefix | Base | Example | Value |
|---|---|---|---|
0x / 0X | Hexadecimal | 0xFF | 255 |
0b / 0B | Binary | 0b1010 | 10 |
0o / 0O | Octal | 0o17 | 15 |
Mixed-base expressions work naturally:
0xFF + 0b1010 → 265
0x10 + 0o10 + 0b10 → 26
Display base
By default results are shown in decimal. Switch with a command (session-local):
| Command | Effect |
|---|---|
dec | Decimal (default) |
hex | Hexadecimal |
bin | Binary |
oct | Octal |
The display base persists until changed and affects both the prompt and all results.
To make a non-decimal base the default across all sessions, set it in
config.toml.
[ 0 ]: 255
[ 255 ]: hex
[ 0xFF ]: + 1
[ 0x100 ]: dec
[ 256 ]:
Inline base suffix
Write a base keyword after an expression to evaluate it and switch the display in one step:
[ 0 ]: 0xFF + 0b1010 hex
[ 0x109 ]:
base — show all representations
[ 10 ]: base
2 - 0b1010
8 - 0o12
10 - 10
16 - 0xA
base can also be used as an inline suffix to show one result in all four bases
without changing the active display base:
[ 0 ]: 255 base
2 - 0b11111111
8 - 0o377
10 - 255
16 - 0xFF
[ 255 ]:
Mixed-base display conversion
When the active display base is non-decimal and the input contains literals in other bases, the expression is automatically reprinted with all literals converted to the active base before the result is shown:
[ 0b110 ]: 2 + 0b110 + 0xa
0b10 + 0b110 + 0b1010
[ 0b10010 ]:
Variables
ccalc supports named variables. Any valid identifier can store a value.
Assignment
Use name = expr to assign. Assignments never update ans (MATLAB semantics).
Without ;, the result is displayed:
[ 0 ]: rate = 0.06 / 12
rate = 0.005
[ 0 ]: n = 360
n = 360
[ 0 ]: factor = (1 + rate) ^ n
factor = 10.9357
[ 0 ]: 200000 * rate * factor / (factor - 1)
[ 1199.10 ]:
Append ; to suppress output:
rate = 0.06 / 12;
n = 360;
Using variables
Any defined variable can appear inside an expression:
[ 0 ]: rate = 0.07
rate = 0.07
[ 0 ]: 1000 * (1 + rate) ^ 10
[ 1967.1513573 ]:
ans
ans is the implicit result variable — set automatically after every
standalone expression (not after assignments). It is initialized to 0
at startup.
Expressions starting with an operator use ans as the left-hand operand:
[ 0 ]: 100
[ 100 ]: / 4
[ 25 ]: + 5
[ 30 ]:
Empty-argument function calls use ans as the argument:
[ 144 ]: sqrt() → 12 (same as sqrt(144))
Constants
pi and e are pre-defined read-only constants:
| Name | Value |
|---|---|
pi | 3.14159265358979… |
e | 2.71828182845904… |
View and clear
| Command | Action |
|---|---|
who | Show all defined variables and their values |
clear | Clear all variables |
clear name | Clear a single variable by name |
[ 0 ]: x = 10
[ 0 ]: y = 3.14
[ 0 ]: x + y
[ 13.14 ]: who
ans = 13.14
x = 10
y = 3.14
[ 13.14 ]: clear x
[ 13.14 ]: who
ans = 13.14
y = 3.14
[ 13.14 ]: clear
Workspace persistence
| Command | Action |
|---|---|
ws | Save all variables to ~/.config/ccalc/workspace.toml |
wl | Load variables from file (replaces current workspace) |
The workspace file is plain text, one name = value entry per line:
ans = 13.14
n = 360
rate = 0.005
Example — monthly mortgage
% REPL session
[ 0 ]: rate = 0.06 / 12;
[ 0 ]: n = 360;
[ 0 ]: factor = (1 + rate) ^ n;
[ 0 ]: 200000 * rate * factor / (factor - 1)
[ 1199.10 ]:
As a script file (assignments print unless ; suppresses them):
% Monthly mortgage payment
rate = 0.06 / 12;
n = 360;
factor = (1 + rate) ^ n;
200000 * rate * factor / (factor - 1)
fprintf('Monthly payment ($): ')
disp(ans)
Number Display Format
The format command controls how numbers are displayed in the REPL and
script/pipe output. It does not affect computation — all arithmetic is done in
f64 (IEEE 754 double precision).
Commands
| Command | Description |
|---|---|
format | Reset to short (5 significant digits) |
format short | 5 significant digits, auto fixed/scientific |
format long | 15 significant digits, auto fixed/scientific |
format shortE | Always scientific, 4 decimal places |
format longE | Always scientific, 14 decimal places |
format shortG | Same as short (MATLAB shortG alias) |
format longG | Same as long (MATLAB longG alias) |
format bank | Fixed 2 decimal places (currency) |
format rat | Rational approximation p/q |
format hex | IEEE 754 double-precision bit pattern (16 hex digits) |
format + | Sign only: + positive, - negative, space for 0 |
format compact | Suppress blank lines between outputs |
format loose | Add blank line after every output (default) |
format N | N decimal places (e.g. format 4) |
Examples
>> format short
>> pi
3.1416
>> format long
>> pi
3.14159265358979
>> format shortE
>> pi
3.1416e+00
>> format bank
>> 1/3
0.33
>> format rat
>> pi
355/113
>> format hex
>> 1.0
3FF0000000000000
>> format +
>> [-2 0 5]
- +
>> format 4
>> 1/3
0.3333
Scope
format affects:
disp()output- Variable assignment display (
x = 3.1416) - The REPL prompt value
format does not affect fprintf / sprintf — those functions use
their own per-call format specifiers (e.g. %f, %e, %.3f).
Automatic scientific notation
In short and long modes, numbers switch to scientific notation when:
- The exponent is less than −3 (e.g.
0.001→1e-03) - The exponent is ≥ the number of significant digits
Persistent default
The default precision (used by format N and the startup default) is set in
config.toml:
[display]
precision = 10
Note: format hex vs hex
These are different commands:
format hex— shows the IEEE 754 raw bit pattern of any floating-point number as 16 uppercase hex digits (e.g.400921FB54442D18forpi).hex— switches the display base to hexadecimal for integer values (e.g.0xFF→255shown as0xFF).
Formatted Output
ccalc supports C-style formatted output via fprintf and sprintf, matching
Octave/MATLAB semantics.
fprintf — print to stdout
fprintf(fmt, v1, v2, ...)
Prints formatted text to stdout. No return value — result display is suppressed.
No newline is added automatically; include \n explicitly.
fprintf('pi = %.4f\n', pi) % pi = 3.1416
fprintf('n = %d items\n', 42) % n = 42 items
sprintf — format to string
s = sprintf(fmt, v1, v2, ...)
Same format engine as fprintf, but returns the result as a char array instead
of printing it.
label = sprintf('R = %.1f Ohm', 47.5);
disp(label) % R = 47.5 Ohm
Format specifiers
| Specifier | Meaning |
|---|---|
%d, %i | Integer (value truncated to whole number) |
%f | Fixed-point decimal, default 6 places |
%e | Scientific notation (1.23e+04) |
%g | Shorter of %f and %e |
%x | Hexadecimal, lowercase (ff) |
%X | Hexadecimal, uppercase (FF) |
%s | String (char array or string object) |
%% | Literal % |
Width, precision, and flags
The general form is:
%[flags][width][.precision]specifier
| Flag | Meaning |
|---|---|
- | Left-align within field width |
+ | Always show sign (+ or −) |
0 | Zero-pad to field width |
| Space in place of + for non-negative values |
Examples:
fprintf('%8.3f\n', pi) % 3.142
fprintf('%-10s|\n', 'hi') % hi |
fprintf('%+.4e\n', 1000) % +1.0000e+03
fprintf('%05d\n', 42) % 00042
fprintf('% d\n', 5) % 5
fprintf('%x\n', 255) % ff
fprintf('%04X\n', 255) % 00FF
Escape sequences
| Sequence | Character |
|---|---|
\n | Newline |
\t | Tab |
\\ | Backslash |
Multiple arguments and repeat behaviour
When there are more arguments than conversion specifiers in the format string, the format string repeats for the remaining arguments (Octave behaviour):
fprintf('%d\n', 1, 2, 3)
% 1
% 2
% 3
fprintf('x=%.1f y=%.1f\n', 1, 2, 3, 4)
% x=1.0 y=2.0
% x=3.0 y=4.0
Formatted data table example
fprintf('%6s %12s %12s\n', 'time', 'position', 'velocity')
fprintf('%6s %12s %12s\n', '(s)', '(m)', '(m/s)')
fprintf('%s\n', repmat('-', 1, 32))
t = 0:0.5:2;
pos = 0.5 * 9.81 * t.^2;
vel = 9.81 * t;
for k = 1:length(t)
fprintf('%6.1f %12.3f %12.3f\n', t(k), pos(k), vel(k))
end
Output:
time position velocity
(s) (m) (m/s)
--------------------------------
0.0 0.000 0.000
0.5 1.226 4.905
1.0 4.905 9.810
1.5 11.036 14.715
2.0 19.620 19.620
See also
formatcommand — controls default display format fordisp()and assignment outputhelp io— concise in-REPL referencehelp script— full format specifier referenceexamples/formatted_output.calc— runnable example covering all specifiers
Configuration
ccalc stores persistent settings in a plain-text TOML file:
~/.config/ccalc/config.toml # Linux / macOS
%APPDATA%\ccalc\config.toml # Windows
The file is created automatically with defaults the first time you start the interactive REPL. You can edit it with any text editor.
Default config.toml
# ccalc configuration
# Edit this file and run 'config reload' in the REPL to apply changes.
[display]
# Default decimal precision (number of digits after the decimal point, 0–15).
precision = 10
# Default number base for output: "dec", "hex", "bin", "oct"
base = "dec"
[repl]
# Prompt templates — see the "Prompt customization" section below.
# prompt1 = "[ {ans} ]: "
# prompt2 = " >> "
[highlight]
# Set to false to disable real-time syntax highlighting.
enabled = true
# Uncomment and set to override default colours. Formats: named ("yellow"),
# 8-bit ("color256(220)"), truecolor ("#FFD700"), or "bold:<colour>".
# keywords = "yellow"
# numbers = "cyan"
# strings = "green"
# comments = "dark_gray"
# builtins = "bright_cyan"
# errors = "red"
Settings
display.precision
Number of decimal places shown in the output. Range: 0–15. Default: 10.
Values above 15 are silently clamped to 15.
This is the same value controlled by p<N> during a session. Changes in
config take effect on the next REPL start (or after config reload).
display.base
Default output base. Accepted values: "dec", "hex", "bin", "oct".
Default: "dec".
Unknown values fall back to "dec" without error.
Prompt customization
The [repl] section lets you set custom prompt templates for prompt1 (the
primary prompt, shown when ready for new input) and prompt2 (the secondary
prompt, shown inside multi-line blocks such as if/for/while).
[repl]
prompt1 = "[ {ans} ]: " # default
prompt2 = " >> " # default
Content placeholders
| Placeholder | Expands to |
|---|---|
{ans} | Formatted value of ans — the default prompt content |
{line} | Session command counter (increments after each input) |
{user} | Current OS username |
{host} | Short hostname (before the first dot) |
{cwd} | Full current working directory |
{cwd_short} | Last path component of the current directory |
{time} | Current time as HH:MM:SS (UTC) |
Color placeholders
Color codes are emitted only for the displayed prompt and do not affect cursor positioning. Any number of color/style placeholders can be combined.
| Placeholder | Effect |
|---|---|
{reset} | Turn off all colour/style |
{bold} | Bold text |
{dim} | Dim/faint text |
{black} | Black foreground |
{red} | Red foreground |
{green} | Green foreground |
{yellow} | Yellow foreground |
{blue} | Blue foreground |
{magenta} | Magenta foreground |
{cyan} | Cyan foreground |
{white} | White foreground |
{gray} | Bright black (dark gray) foreground |
{bright_red} | Bright red foreground |
{bright_green} | Bright green foreground |
{bright_yellow} | Bright yellow foreground |
{bright_blue} | Bright blue foreground |
{bright_magenta} | Bright magenta foreground |
{bright_cyan} | Bright cyan foreground |
{bright_white} | Bright white foreground |
{#RRGGBB} | 24-bit truecolor foreground (e.g. {#FF8800} for orange) |
Examples
[repl]
# Minimal: show counter and ans
prompt1 = "{line} [ {ans} ]: "
# Counter dimmed, ans in default colour
prompt1 = "{gray}({line}){reset} [ {ans} ]: "
# Shell-style: user@host:dir$
prompt1 = "{green}{user}@{host}{reset}:{cyan}{cwd_short}{reset}$ "
# Bold blue name, dimmed counter, ans
prompt1 = "{bold}{blue}ccalc{reset} {gray}[{line}]{reset} {ans} > "
# 24-bit orange accent colour
prompt1 = "{#FF8800}ccalc{reset} [{line}] {ans} > "
After editing config.toml, apply changes without restarting:
[ 0 ]: config reload
Config reloaded.
Syntax highlighting
The [highlight] section controls real-time input highlighting in the REPL.
[highlight]
enabled = true # set to false to disable highlighting entirely
# Colour formats:
# Named 4-bit — black, red, green, yellow, blue, magenta, cyan, white
# bright_black (dark_gray), bright_red, bright_green,
# bright_yellow, bright_blue, bright_magenta,
# bright_cyan, bright_white
# 8-bit — color256(N) where N = 0..255
# True color — #RRGGBB (hex, requires a true-color terminal)
#
# Prefix any value with "bold:" for bold text, e.g. "bold:yellow"
# keywords = "yellow"
# numbers = "cyan"
# strings = "green"
# comments = "dark_gray"
# builtins = "bright_cyan"
# errors = "red"
Colour categories
| Key | Default | Highlighted tokens |
|---|---|---|
keywords | yellow | if, for, while, end, function, else, elseif, return, break, continue, do, until, switch, case, otherwise, try, catch, global, persistent |
numbers | cyan | Integer, decimal, scientific, and hex literals (42, 3.14, 1e-3, 0xFF) |
strings | green | Single-quoted '...' and double-quoted "..." string literals |
comments | dark gray | % and # to end of line |
builtins | bright cyan | All built-in function names (sin, plot, zeros, …) and plugin functions |
errors | red | Unclosed string literals or brackets |
User-defined variables and operators are shown in the terminal’s default colour.
Shadowing rules
If a name from a keyword or built-in list is assigned as a variable (e.g. end = 42),
the highlighting uses default colour for that name — matching evaluation semantics.
Colour format reference
| Format | Example | Notes |
|---|---|---|
| Named 4-bit | "yellow", "bright_cyan" | 16 standard terminal colours |
| 8-bit palette | "color256(220)" | 256-colour extended palette |
| 24-bit truecolor | "#FFD700" | Requires a true-color terminal |
| Bold prefix | "bold:yellow" | Combines bold with any colour |
Unknown values are silently ignored and the built-in default is used instead.
REPL commands
| Command | Action |
|---|---|
config | Show config file path and currently active settings |
config reload | Re-read config.toml and apply changes immediately |
Changes made with p<N>, hex, dec, bin, oct during a session are
session-local and are not written back to config.toml.
Example
Set precision to 4 and default base to hex, then apply without restarting:
- Edit
config.toml:[display] precision = 4 base = "hex" - In the REPL:
[ 0 ]: config reload Config reloaded. precision: 4 base: hex [ 0x0 ]:
Config file location
The config command shows the full path of the file on the current system:
[ 0 ]: config
config file: /home/user/.config/ccalc/config.toml
precision: 10
base: dec
Matrices
ccalc supports matrix literals using Octave/MATLAB bracket syntax.
Creating matrices
Separate elements with spaces or commas; separate rows with ; or a bare newline:
[1 2 3] % row vector (1×3)
[1; 2; 3] % column vector (3×1)
[1 2; 3 4] % 2×2 matrix
[1, 2, 3] % commas work too
A bare newline inside [...] is a row separator, identical to ;:
A = [1 2 3
4 5 6] % same as [1 2 3; 4 5 6]
v = [10
20
30] % column vector (3×1)
Trailing % comments on a row are stripped before the newline is interpreted:
B = [100 200 % first row
300 400] % second row
Line continuation (...) joins the next line into the same row — no row break occurs:
D = [1 2 ...
3 4] % same as [1 2 3 4] (1×4 row vector)
Elements can be arbitrary expressions:
[sqrt(4), 2^3, mod(10,3)] % [2, 8, 1]
[pi/2, pi; -pi, 0]
Assignment
[ 0 ]: A = [1 2; 3 4]
A =
1 2
3 4
[ [2×2] ]: B = [5 6; 7 8]
B =
5 6
7 8
Assignment does not update ans. The prompt shows the matrix size.
Arithmetic
Scalar operations
All four arithmetic operators apply element-wise between a scalar and a matrix:
2 * A % multiply every element by 2
A / 10 % divide every element by 10
A + 1 % add 1 to every element
A ^ 2 % raise every element to the power 2
Matrix addition and subtraction
+ and - between two matrices of the same size are element-wise:
A + B
A - B
Size must match; otherwise you get an error:
[1 2] + [1 2 3] % Error: Matrix size mismatch for '+'
Matrix multiplication
* between two matrices performs standard matrix multiplication (inner
dimensions must agree):
A = [1 2; 3 4];
B = [1 0; 0 1];
A * B % → same as A (multiply by identity)
v = [1; 2; 3];
v' * v % dot product → 14 (1×3 times 3×1 = 1×1)
v * v' % outer product → 3×3 matrix
Transpose
Postfix ' transposes a matrix. It binds tighter than any binary operator:
A' % transpose of A
[1 2 3]' % row vector → column vector (3×1)
R' * R % for orthogonal R: gives identity
Element-wise operators
.*, ./, .^ apply the operation to each pair of corresponding elements
(shapes must match):
A .* B % element-wise product (Hadamard product)
A ./ B % element-wise division
A .^ 2 % square every element
v .^ 2 % same as v .* v
Note: * is matrix multiplication; .* is element-wise.
Range operator
Generate row vectors with the : operator. Range has lower precedence than
arithmetic, so 1+1:5 evaluates as 2:5.
1:5 % [1 2 3 4 5]
1:2:9 % [1 3 5 7 9] (start:step:stop)
0:0.5:2 % [0 0.5 1 1.5 2]
5:-1:1 % [5 4 3 2 1]
5:1 % [] (empty — step in wrong direction)
Ranges work inside matrix literals — they are concatenated horizontally:
[1:4] % [1 2 3 4]
[0, 1:3, 10] % [0 1 2 3 10]
[1:2:7] % [1 3 5 7]
[1:3; 4:6] % 2×3 matrix: [1 2 3; 4 5 6]
linspace
linspace(a, b, n) generates n evenly spaced values from a to b
(both endpoints included):
linspace(0, 1, 5) % [0 0.25 0.5 0.75 1]
linspace(1, 5, 5) % [1 2 3 4 5]
linspace(0, 1, 1) % [1] (single element returns b)
linspace(0, 1, 0) % [] (empty)
Built-in functions
| Function | Description |
|---|---|
zeros(m, n) | m×n matrix of zeros |
zeros(n) | n×n matrix of zeros |
ones(m, n) | m×n matrix of ones |
ones(n) | n×n matrix of ones |
eye(n) | n×n identity matrix |
size(A) | [rows cols] as a 1×2 row vector |
size(A, dim) | Rows (dim=1) or columns (dim=2) as scalar |
length(A) | max(rows, cols) |
numel(A) | Total element count |
trace(A) | Sum of diagonal elements |
det(A) | Determinant (square matrices only) |
inv(A) | Inverse (square, non-singular) |
eye(3) % 3×3 identity
det([1 2; 3 4]) % → -2
inv([1 2; 3 4]) % → 2×2 inverse matrix
size([1 2 3]) % → [1 3]
numel(zeros(3,4)) % → 12
Display
Matrices are displayed with right-aligned columns:
ans =
1 2 3
4 5 6
7 8 9
The REPL prompt shows the size of the current ans when it is a matrix:
[ [3×3] ]:
who and workspace
who shows matrix dimensions:
A = [2×2 double]
x = 3.14
ws (workspace save) saves only scalar variables. Matrices are not persisted.
Indexing
All indices are 1-based (Octave/MATLAB convention).
If a name exists as a variable in the workspace, name(...) is always
treated as indexing — variables shadow built-in function names.
Vector indexing
v = [10 20 30 40 50];
v(3) % → 30 scalar element
v(2:4) % → [20 30 40] sub-vector via range
v(:) % → [10;20;30;40;50] all elements, column vector
Matrix indexing
A = [1 2 3; 4 5 6; 7 8 9];
A(2, 3) % → 6 scalar at row 2, col 3
A(1, :) % → [1 2 3] entire row 1 (1×3)
A(:, 2) % → [2;5;8] entire column 2 (3×1)
A(1:2, 2:3) % → [2 3; 5 6] submatrix
Index expressions
Index arguments can be arbitrary expressions:
n = size(A, 2); % number of columns
A(1, n) % last element of row 1
A(1:2, 1+1) % rows 1-2, column 2
end keyword
Inside any index expression, end resolves to the size of the dimension
being indexed. Arithmetic on end is supported.
v = [10 20 30 40 50];
v(end) % → 50 last element
v(end-1) % → 40 second to last
v(end-2:end) % → [30 40 50] last three
A = [1 2 3; 4 5 6; 7 8 9];
A(end, :) % → [7 8 9] last row
A(:, end) % → [3;6;9] last column
A(1:end-1, 2:end) % → [2 3; 5 6] all but last row, columns 2 onward
Indexed Assignment
All index forms that work for reading also work for writing. The right-hand side can be a single scalar (broadcast to all selected positions) or a matrix/vector matching the selected size.
Scalar and slice assignment
v = zeros(1, 6);
v(3) = 42; % set one element
v(1:2) = [10, 20]; % set a slice from a vector
v(4:6) = 99; % broadcast scalar to three positions
v(:) = 0; % reset all elements at once
2-D matrix assignment
A = zeros(4);
A(2, 3) = 7; % single element
A(:, 1) = [1; 2; 3; 4]; % entire column
A(1, :) = [10, 20, 30, 40]; % entire row
A(2:3, 2:3) = eye(2); % submatrix
Growing vectors
Assigning beyond the current length extends the vector and fills gaps with
zeros. end+1 is the canonical Octave idiom for appending:
squares = [];
for k = 1:8
squares(end+1) = k^2;
end
% squares = [1 4 9 16 25 36 49 64]
v = [1, 2, 3];
v(7) = 99; % → [1 2 3 0 0 0 99] (zeros fill the gap)
Assigning to a non-existent variable creates a new 1×N row vector:
fib(1) = 1; % creates a 1×1 vector
fib(2) = 1; % extends to 1×2
for k = 3:10
fib(end+1) = fib(end) + fib(end-1);
end
Logical (boolean mask) indexing
A 0/1 vector whose length equals the dimension selects positions where the mask is 1. Masks can be produced by any comparison expression.
temps = [18, 22, 35, 12, 29, 41, 8, 33];
% Read: extract elements where mask is true
hot = temps(temps >= 30); % → [35 41 33]
% Write: modify elements where mask is true
temps(temps >= 30) = 30; % cap all hot days at 30
% Using a separate mask variable
mask = signal < 0;
signal(mask) = 0; % half-wave rectifier
2-D matrices support logical masks as well — elements are selected in column-major order (same as Octave/MATLAB):
M = [1 2 3; 4 5 6; 7 8 9];
M(M > 5) % → [7 8 6 9] (column-major order)
M(M > 5) = 0; % zero out those elements
Row separators inside matrix literals
Both ; and bare newlines act as row separators inside [...]; they are never
statement separators there:
A = [1 2; 3 4]; % ; after ] suppresses output; ; inside is part of the matrix
B = [1 2
3 4]; % newline inside [...] is a row separator; ; after ] suppresses output
Vector & Data Utilities
Special constants
nan and inf are built-in constants — they behave like numeric literals
and cannot be overwritten.
| Constant | Value |
|---|---|
nan | IEEE 754 Not-a-Number |
inf | Positive infinity (-inf for negative) |
nan + 5 % → NaN (NaN propagates through all arithmetic)
nan == nan % → 0 (IEEE 754: NaN is never equal to itself)
inf * 2 % → inf
-inf < inf % → 1
NaN predicates (element-wise)
| Function | Signature | Returns 1 when… |
|---|---|---|
isnan(x) | isnan(x) | x is NaN |
isinf(x) | isinf(x) | x is ±Inf |
isfinite(x) | isfinite(x) | x is neither NaN nor ±Inf |
All three accept a scalar or a matrix and apply element-wise, returning a result of the same shape
with each element replaced by 1.0 (true) or 0.0 (false).
Use these instead of == nan or == inf, which do not work as expected:
nan == nan always returns 0.
isnan(nan) % → 1
isinf(inf) % → 1
isfinite(42) % → 1
isfinite(nan) % → 0
v = [1 nan 3 inf];
isnan(v) % → [0 1 0 0]
isfinite(v) % → [1 0 1 0]
NaN matrix constructor
nan(n) % n×n matrix of NaN
nan(m, n) % m×n matrix of NaN
nan(n) is shorthand for nan(n, n).
The result is a matrix where every element is NaN, useful for pre-allocating
arrays that must be filled before use.
Reductions
For vectors (1×N or N×1) these return a scalar.
For M×N matrices (M>1, N>1) they operate column-wise and return a 1×N row vector.
| Function | Signature | Description |
|---|---|---|
sum(v) | sum(v) | Sum of all elements |
prod(v) | prod(v) | Product of all elements |
mean(v) | mean(v) | Arithmetic mean (sum divided by element count) |
min(v) | min(v) | Minimum element; for 2-scalar form see min(a, b) |
max(v) | max(v) | Maximum element; for 2-scalar form see max(a, b) |
any(v) | any(v) | 1.0 if at least one element is non-zero, else 0.0 |
all(v) | all(v) | 1.0 if every element is non-zero, else 0.0 |
norm(v) | norm(v) | Euclidean (L2) norm: √(Σxᵢ²) |
norm(v, p) | norm(v, p) | General Lp norm; p = inf → max of absolute values |
Notes
min(v), max(v) — The 1-argument form finds the extreme element of a vector or matrix.
The 2-argument forms min(a, b) and max(a, b) compare two scalars.
Both forms are available; which is used depends on the number of arguments.
any(v), all(v) — Treat any non-zero value (including negative numbers) as true,
and zero as false. NaN is non-zero, so any([nan]) → 1 and all([0 nan]) → 0.
norm(v, p) — Common values of p:
p = 1→ L1 norm: sum of absolute valuesp = 2(default) → L2 Euclidean normp = inf→ L∞ norm: maximum absolute value
For scalars, norm(x) returns abs(x).
v = [1 2 3 4 5];
sum(v) % → 15
prod(v) % → 120
mean(v) % → 3
min(v) % → 1
max(v) % → 5
any(v > 4) % → 1
all(v > 0) % → 1
norm(v) % → sqrt(1+4+9+16+25) ≈ 7.416
norm([3 4]) % → 5
norm([1 2 3], 1) % → 6 (L1 = sum of absolute values)
Column-wise on a matrix:
M = [1 2 3; 4 5 6];
sum(M) % → [5 7 9] one sum per column
mean(M) % → [2.5 3.5 4.5]
min(M) % → [1 2 3]
max(M) % → [4 5 6]
Cumulative operations
These return an array of the same shape as the input.
| Function | Signature | Description |
|---|---|---|
cumsum(v) | cumsum(v) | Running sum: element i = sum of first i elements |
cumprod(v) | cumprod(v) | Running product: element i = product of first i |
Both functions accept a scalar (returned unchanged) or a vector/matrix. For a matrix the operation runs along all elements in column-major order, returning a matrix of the same shape.
cumsum([1 2 3 4]) % → [1 3 6 10]
cumprod([1 2 3 4]) % → [1 2 6 24]
% Compound interest: balance after each year
rates = [1.05, 1.08, 1.03, 1.10];
cumprod(rates) % → cumulative growth factors
Sorting and searching
| Function | Signature | Description |
|---|---|---|
sort(v) | sort(v) | Sort elements in ascending order; vectors only |
find(v) | find(v) | 1-based column-major indices of all non-zero elements |
find(v, k) | find(v, k) | First k non-zero indices; k must be non-negative |
unique(v) | unique(v) | Sorted unique elements as a 1×N row vector |
Notes
sort(v) — Sorts in ascending order only. Accepts a scalar (returned unchanged) or a vector.
Passing a 2D matrix (more than one row and more than one column) returns an error;
use sort on individual rows or columns instead.
find(v) — Returns a row vector of 1-based indices of elements that are non-zero (including ±Inf and NaN).
Indices follow column-major order (columns first), matching MATLAB/Octave convention.
Returns an empty matrix [] when no elements match.
find(v, k) — Limits the result to the first k indices. k = 0 returns [].
k must be a non-negative integer.
unique(v) — Returns a 1×N row vector of distinct values, sorted in ascending order.
Accepts scalars, vectors, or matrices (elements are flattened in column-major order before deduplication).
v = [3 1 4 1 5 9 2 6];
sort(v) % → [1 1 2 3 4 5 6 9]
unique(v) % → [1 2 3 4 5 6 9]
find(v > 4) % → [5 6 8] indices where v > 4
find(v > 4, 2) % → [5 6] first 2 such indices
% Typical pattern: use find with a comparison mask
idx = find(v > 3);
v(idx) % → elements of v greater than 3
Reshape and flip
| Function | Signature | Description |
|---|---|---|
reshape(A, m, n) | reshape(A, m, n) | Reshape to m×n using column-major element order |
fliplr(v) | fliplr(A) | Reverse column order (left↔right mirror) |
flipud(v) | flipud(A) | Reverse row order (up↔down mirror) |
Notes
reshape(A, m, n) — Rearranges the elements of A into a matrix with m rows and n columns.
The total number of elements must be preserved: m * n must equal numel(A), otherwise an error is raised.
Elements are read and written in column-major order (column by column), matching MATLAB/Octave.
fliplr(A) — Reverses the order of columns. For a row vector this reverses all elements.
A scalar is returned unchanged.
flipud(A) — Reverses the order of rows. For a column vector this reverses all elements.
A scalar is returned unchanged.
reshape(1:6, 2, 3) % fills column-by-column:
% [1 3 5]
% [2 4 6]
reshape(1:6, 3, 2) % [1 4]
% [2 5]
% [3 6]
fliplr([1 2 3]) % → [3 2 1]
fliplr([1 2 3; 4 5 6]) % → [3 2 1; 6 5 4]
flipud([1 2; 3 4]) % → [3 4; 1 2]
Diagonal
| Function | Description |
|---|---|
diag(v) | Vector → N×N diagonal matrix with the elements of v on the main diagonal |
diag(A) | Matrix → column vector of the main diagonal of A |
Notes
diag(v) — When v is a row or column vector of length N, creates an N×N
matrix with v on the main diagonal and zeros everywhere else. A scalar input
returns a 1×1 matrix.
diag(A) — When A is a matrix, extracts its main diagonal as an N×1 column
vector, where N = min(rows, cols). Works on both square and non-square matrices.
These two forms are inverses of each other: diag(diag(v)) reconstructs the diagonal
matrix from its diagonal.
diag([1 2 3]) % row vector → 3×3 diagonal matrix:
% [1 0 0]
% [0 2 0]
% [0 0 3]
diag([4; 5; 6]) % column vector → same result
A = [1 2 3; 4 5 6; 7 8 9]
diag(A) % → [1; 5; 9] (main diagonal as column vector)
B = [1 2 3 4; 5 6 7 8]
diag(B) % → [1; 6] (min(2,4) = 2 elements)
diag(diag([1 2 3])) % → [1; 2; 3] round-trip
Example file
examples/vector_utils.calc demonstrates all of these features:
ccalc examples/vector_utils.calc
Comparison & Logical Operators
Comparison operators
Comparison operators evaluate to 1 (true) or 0 (false).
They work on scalars and element-wise on matrices.
| Operator | Meaning |
|---|---|
== | Equal |
~= | Not equal |
< | Less than |
> | Greater than |
<= | Less or equal |
>= | Greater or equal |
3 > 2 % 1
3 == 4 % 0
5 ~= 3 % 1
4 <= 4 % 1
Comparison has lower precedence than arithmetic — operands are fully evaluated before the comparison:
1 + 1 == 2 % 1 (1+1 = 2, then 2 == 2)
2 * 3 > 5 % 1 (2*3 = 6, then 6 > 5)
Logical NOT — ~
~expr negates a truth value:
0→1- any non-zero →
0
~0 % 1
~1 % 0
~(3 == 3) % 0
~(3 ~= 3) % 1
Short-circuit AND and OR — &&, ||
&& returns 1 when both operands are non-zero.
|| returns 1 when at least one operand is non-zero.
&& binds more tightly than ||.
Both operators short-circuit and are intended for scalar conditions.
1 && 1 % 1
1 && 0 % 0
0 || 1 % 1
0 || 0 % 0
1 || 0 && 0 % 1 (1 || (0 && 0))
Combining conditions
x = 2.7;
x >= 0 && x <= 3.3 % 1 — in-range check
x < 0 || x > 3.3 % 0 — out-of-range flag
% Negate the condition:
~(x >= 0 && x <= 3.3) % 0 — fault flag (0 = OK)
Element-wise logical operators — &, |, xor, not
& and | are element-wise operators — they work on matrices, always
evaluate both sides (no short-circuit), and return a 0/1 matrix:
a = [1 0 1 0];
b = [1 1 0 0];
a & b % [1 0 0 0] element-wise AND
a | b % [1 1 1 0] element-wise OR
xor(a, b) % [0 1 1 0] element-wise XOR
not(a) % [0 1 0 1] element-wise NOT (alias for ~)
Use &/| for matrix logical masks; use &&/|| for scalar conditions in if.
Logical mask pattern
v = [3, -1, 8, 0, 5, -2, 7];
mask = v > 0 & v < 6 % [1 0 0 0 1 0 0]
Element-wise on matrices (comparison)
When one or both operands are matrices, all comparison operators
apply element-wise and return a 0/1 matrix of the same size:
v = [1 2 3 4 5];
v > 3 % [0 0 0 1 1]
v <= 3 % [1 1 1 0 0]
v == 3 % [0 0 1 0 0]
~(v > 3) % [1 1 1 0 0]
Scalar–matrix comparison broadcasts the scalar to every element:
3 < v % [0 0 0 1 1]
v >= 3 % [0 0 1 1 1]
Soft masking
Because masks are 0/1 matrices, multiplying a matrix by its mask
zeroes out the elements that failed the condition — a pattern often
called soft masking or logical selection:
v = [1 2 3 4 5];
v .* (v > 3) % [0 0 0 4 5] keep elements > 3
% Keep elements in [2, 4]:
lo = v >= 2;
hi = v <= 4;
v .* (lo .* hi) % [0 2 3 4 0]
lo .* hi works as element-wise AND because the values are already 0/1.
Precedence
From lowest to highest priority:
|| logical OR (short-circuit)
&& logical AND (short-circuit)
| element-wise OR
& element-wise AND
== ~= < > <= >= comparison (non-associative)
: range
+ - additive
* / .* ./ multiplicative
^ .^ ** power (right-associative)
unary + - ~ negation / logical NOT
postfix ' .' transpose / plain transpose
REPL session
[ 0 ]: 3 > 2
[ 1 ]:
[ 0 ]: 5 ~= 5
[ 0 ]:
[ 0 ]: 2 > 1 && 10 > 5
[ 1 ]:
[ 0 ]: v = [10 20 30 40 50];
[ 0 ]: v > 25
ans =
0 0 0 1 1
[ [1×5] ]: v .* (v > 25)
ans =
0 0 0 40 50
See also
help logic— REPL reference with examplesccalc examples/logic.calc— ADC validation and resistor tolerance demo- Matrices — element-wise operators
Complex Numbers
ccalc supports complex numbers using the same syntax as Octave/MATLAB.
No special mode is needed — i and j are always available as the
imaginary unit.
Creating complex numbers
3 + 4i % 3 + 4i — Ni suffix (no space before i/j)
3 + 4*i % same — explicit multiply also works
3 + 4*j % j is also the imaginary unit
complex(3, 4) % construct from real and imaginary parts
5i % pure imaginary: 5i
2 - 3i % 2 - 3i
Ni suffix syntax: any decimal number immediately followed by i or j
(no space, no further alphanumeric characters) is treated as a complex literal.
The tokenizer expands 4i to 4 * i — the imaginary unit i must be in
scope (it is always pre-seeded at startup).
Arithmetic
All standard operators work on complex numbers:
z1 = 3 + 4*i
z2 = 1 - 2*i
z1 + z2 % 4 + 2i
z1 - z2 % 2 + 6i
z1 * z2 % 11 - 2i (a+bi)(c+di) = (ac-bd) + (ad+bc)i
z1 / z2 % -1 + 2i
Mixing complex and real scalars works naturally:
z1 + 10 % 13 + 4i
2 * z1 % 6 + 8i
z1 ^ 2 % -7 + 24i
When the imaginary part of a result is exactly zero, the value is shown and stored as a real scalar:
(1+i) * (1-i) % 2 (not 2 + 0i)
Powers
Integer powers use binary exponentiation for exact results:
i^2 % -1 (exact)
i^3 % -i (exact)
i^4 % 1 (exact)
(1+i)^4 % -4
(1+i)^-1 % 0.5 - 0.5i
Non-integer powers use the polar form exp((c+di)·ln(a+bi)):
i^0.5 % 0.7071067812 + 0.7071067812i (sqrt of i)
2^(1+i) % 1.5384778027 + 1.2779225526i
Polar form
Every complex number z = re + im*i has a polar representation
z = r * (cos θ + i * sin θ), where r = abs(z) and θ = angle(z):
z = 3 + 4*i
abs(z) % 5 (modulus |z| = sqrt(3² + 4²))
angle(z) % 0.9272... (argument in radians)
angle(z) * 180/pi % 53.13° (in degrees)
Reconstruct from polar using exp:
r = abs(z);
t = angle(z);
r * exp(1i * t) % 3 + 4i (Euler's formula: e^(it) = cos t + i·sin t)
complex(r*cos(t), r*sin(t)) % 3 + 4i (equivalent, without exp)
Euler’s identity:
exp(1i * pi) + 1 % ≈ 0 (≈ 0 + 1.22e-16i — floating-point rounding)
Built-in functions
| Function | Description |
|---|---|
real(z) | Real part (real(5) → 5, real(3+4i) → 3) |
imag(z) | Imaginary part (imag(5) → 0, imag(3+4i) → 4) |
abs(z) | Modulus (also works on real scalars and matrices) |
angle(z) | Argument in radians |
conj(z) | Complex conjugate: re - im*i |
complex(re, im) | Construct from two real scalars |
isreal(z) | 1 if imaginary part is zero, else 0 |
exp(z) | Complex exponential: e^a·(cos b + i·sin b) where z = a + bi |
z = 3 + 4*i
real(z) % 3
imag(z) % 4
conj(z) % 3 - 4i
abs(z) % 5
angle(z) % 0.927...
isreal(z) % 0
isreal(5) % 1
imag(7) % 0
Conjugate and plain transpose
The postfix ' operator returns the conjugate of a complex scalar
(matching the matrix Hermitian-transpose convention):
z = 3 + 4i
z' % 3 - 4i conjugate — flips imaginary sign
conj(z) % 3 - 4i same result
The postfix .' operator returns the plain transpose — no conjugation:
z.' % 3 + 4i plain transpose — imaginary part unchanged
For real scalars and matrices ' and .' give identical results.
The distinction only matters for complex values.
Comparison
== and ~= compare both real and imaginary parts:
(3 + 4*i) == (3 + 4*i) % 1
(3 + 4*i) == (3 - 4*i) % 0
(3 + 4*i) ~= (3 - 4*i) % 1
Ordering operators (<, >, <=, >=) return an error for complex
numbers — ordering is not defined for the complex plane.
Imaginary unit variables
i and j are pre-set to 0 + 1i at startup. You can reassign them
(e.g. i = 5 for a loop counter), in which case the original value is
no longer available until you restart ccalc.
Complex Matrices
Any matrix literal that contains at least one complex element becomes a
ComplexMatrix. Real elements are promoted automatically.
A = [1+2i, 3-4i; 5, 6+1i] % 2×2 complex matrix
v = [1+i, 2-i, 3] % 1×3 complex row vector
isreal distinguishes the two kinds:
isreal([1 2; 3 4]) % 1 (real matrix — no imaginary parts)
isreal(A) % 0 (complex matrix)
Display
Every cell always shows both parts:
A(1,1) % 1 + 2i (not just "1+2i")
A(2,1) % 5 + 0i (imaginary part printed even when zero)
Arithmetic
+, -, .*, ./, .^ work element-wise.
* performs matrix multiplication. Scalar / complex scalar broadcast
applies to both operands:
A + B % element-wise addition
A .* B % element-wise multiply
A * B % matrix multiply
2 * A % scalar broadcast
A + [10, 20; 30, 40] % mixed real + complex matrix
Transpose
M = [1+2i, 3+4i; 5+6i, 7+8i]
M' % conjugate transpose (Hermitian adjoint)
M.' % plain transpose (no conjugation)
M * M' is Hermitian — its diagonal is always real.
Element-wise built-ins on matrices
All of the scalar complex built-ins work element-wise on ComplexMatrix:
| Function | Input | Output |
|---|---|---|
real(A) | ComplexMatrix | real Matrix (one real part per element) |
imag(A) | ComplexMatrix | real Matrix (one imaginary part per element) |
abs(A) | ComplexMatrix | real Matrix (element-wise modulus) |
conj(A) | ComplexMatrix | ComplexMatrix (element-wise conjugate) |
angle(A) | ComplexMatrix | real Matrix (argument in radians) |
isreal(A) | ComplexMatrix | 0 (always) |
Shape functions
size, numel, length, and norm (Frobenius) all work:
C = [1+1i, 2-1i, 3; 4, 5+2i, 6-3i]
size(C) % [2 3]
numel(C) % 6
norm(C) % Frobenius norm
Indexing
1-based, column-major — the same conventions as real matrices:
w = [10+1i, 20+2i, 30+3i, 40+4i]
w(2) % 20 + 2i
w(2:3) % [20+2i, 30+3i]
G(1,:) % first row
G(:,2) % second column
Indexed assignment and auto-upcast
You can assign into an existing ComplexMatrix with any index expression:
A = [1+2i, 3-4i; 5, 6+1i]
A(1,1) = 0 % write a real scalar — stays ComplexMatrix
A(2,2) = 7 - 3i % write a complex scalar
A(1,:) = [2+i, -1+0i] % range assignment with a complex row vector
Assigning a complex value into a real matrix automatically promotes it
to ComplexMatrix (MATLAB/Octave auto-upcast semantics). Existing real
entries are preserved as x + 0i:
B = zeros(3, 3) % real Matrix
B(2, 2) = 1 + 2i % → B is now a ComplexMatrix; B(2,2) = 1 + 2i
B(1, :) = [3-1i, 0, 2i] % range assignment; all other entries are x + 0i
Once a variable has been promoted to ComplexMatrix, assigning a real
scalar back into an element leaves it as ComplexMatrix:
B(2, 2) = 9 % stays ComplexMatrix; B(2,2) = 9 + 0i
Reduction functions
trace, diag, sum, prod, and mean all work on ComplexMatrix,
following the same conventions as for real matrices.
| Function | On vector | On M×N matrix |
|---|---|---|
sum(A) | single Complex scalar | ComplexMatrix 1×N of column sums |
prod(A) | single Complex scalar | ComplexMatrix 1×N of column products |
mean(A) | single Complex scalar | ComplexMatrix 1×N of column means |
trace(A) | (N/A) | Complex scalar (sum of diagonal) |
diag(A) | diagonal matrix | ComplexMatrix N×1 column vector of diagonal |
M = [1+2i, 3+4i; 5+6i, 7+8i]
trace(M) % 8 + 10i (sum of diagonal: (1+2i) + (7+8i))
diag(M) % [1+2i; 7+8i] — diagonal as a column vector
diag(diag(M)) % 2×2 diagonal ComplexMatrix
sum(M) % [6+8i, 10+12i] — column sums
prod(M) % [(1+2i)*(5+6i), (3+4i)*(7+8i)]
mean(M) % [3+4i, 5+6i]
sum([1+2i, 3+4i, 5+6i]) % 9 + 12i — vector collapses to scalar
Block concatenation
ComplexMatrix blocks mix freely with real Matrix blocks in horizontal
and vertical concatenation:
Z = [1+2i, 3-4i; 5, 6+1i] % 2×2 ComplexMatrix
O = ones(2, 2)
[Z, O] % 2×4 ComplexMatrix — horizontal
[Z; O] % 4×2 ComplexMatrix — vertical
[Z, Z; O, O] % 4×4 block matrix
FFT output
Since Phase 27, fft() returns a ComplexMatrix (1×N row vector) rather
than a cell array. Access individual bins with X(k):
X = fft([1 2 3 4])
X(1) % 10 + 0i (DC component)
abs(X) % real Matrix of bin magnitudes
Example
% Euler's identity: e^(i*pi) + 1 ≈ 0
e^(i * pi) + 1 % ≈ 0 (tiny floating-point residual from sin(π))
% Roots of x^2 + 1 = 0
x1 = i
x2 = -i
% AC impedance of a series RL circuit
R = 100; L = 0.05; f = 1000;
w = 2 * pi * f;
Z = complex(R, w * L) % 100 + 314.159i
abs(Z) % impedance magnitude
angle(Z) * 180/pi % phase angle in degrees
See examples/complex_numbers.calc for a complete annotated example.
Strings
ccalc supports two string types that match MATLAB/Octave:
| Type | Syntax | Semantic |
|---|---|---|
| Char array | 'single quotes' | 1×N array of characters, numeric-compatible |
| String object | "double quotes" | Scalar string, concatenation with + |
Char arrays
A char array is a 1×N row of character codes. Single quotes delimit it.
Inside a char array, '' (two consecutive single quotes) represents a
literal single quote character.
[ 0 ]: s = 'Hello'
s = Hello
[ 'Hello' ]: length(s)
[ 5 ]: size(s)
ans =
1 5
Arithmetic — characters as ASCII codes
Char arrays convert to their ASCII codes before any arithmetic operation. The result is a numeric scalar or row vector, not a string.
[ 0 ]: 'A' + 0 % ASCII code of 'A'
[ 65 ]:
[ 0 ]: 'a' + 1 % shift by one position
[ 98 ]:
[ 0 ]: 'abc' + 0 % codes for 'a', 'b', 'c'
ans =
97 98 99
[ 0 ]: 'abc' + 1 % shift every character
ans =
98 99 100
Element-wise comparison returns a 0/1 row vector:
[ 0 ]: 'abc' == 'aXc'
ans =
1 0 1
Escaped single quote
Use '' inside a char array to include a literal ':
[ 0 ]: disp('it''s fine')
it's fine
Char-array concatenation with [...]
The bracket operator concatenates char arrays horizontally — the standard MATLAB/Octave idiom for building strings dynamically:
['hello' ' world'] % → 'hello world'
['a' 'b' 'c'] % → 'abc'
['prefix_' num2str(k)] % → 'prefix_3' (when k = 3)
String context (first element is a char array): numeric elements are treated as Unicode code points and become characters.
['A' 66 67] % → 'ABC' (65='A', 66='B', 67='C')
['A' [66 67]] % → 'ABC' (matrix of codes)
Numeric context (first element is numeric): char-array elements contribute their code values.
[65 'B'] % → [65 66]
[1 'AB'] % → [1 65 66]
Note: each space before a ' signals the start of a new string literal,
matching MATLAB whitespace-aware disambiguation. ['a' 'b'] with a space
between the two char arrays correctly produces 'ab'.
String objects
A string object is a scalar container — one string, not a character-by-character array.
Double quotes delimit it. "" inside a string object represents a literal ".
Backslash escape sequences work: \n, \t, \\, \".
[ 0 ]: t = "Hello"
t = Hello
[ '"Hello"' ]: t + ", World!"
[ '"Hello, World!"' ]:
length and numel return 1 (it is a 1×1 scalar string):
[ 0 ]: length("hello")
[ 1 ]: numel("hello")
[ 1 ]: size("hello")
ans =
1 1
Concatenation with +
[ 0 ]: "foo" + "bar"
[ '"foobar"' ]:
[ 0 ]: a = "left"; b = " right";
[ 0 ]: a + b
[ '"left right"' ]:
Comparison
== and ~= compare entire string objects:
[ 0 ]: "hello" == "hello"
[ 1 ]:
[ 0 ]: "hello" == "world"
[ 0 ]:
[ 0 ]: "abc" ~= "ABC"
[ 1 ]:
Type checks
[ 0 ]: ischar('hello') % 1 — it's a char array
[ 1 ]:
[ 0 ]: isstring("hello") % 1 — it's a string object
[ 1 ]:
[ 0 ]: ischar("hello") % 0 — string object is NOT a char array
[ 0 ]:
[ 0 ]: ischar(42) % 0
[ 0 ]:
String built-ins
Number conversions
[ 0 ]: num2str(42)
42
[ 0 ]: num2str(3.14159)
3.1416
[ 0 ]: num2str(3.14159, 2) % 2 decimal digits
3.14
[ 0 ]: str2double('2.718')
[ 2.718 ]:
[ 0 ]: str2double('abc') % NaN on failure
[ NaN ]:
[ 0 ]: str2num('100')
[ 100 ]:
Concatenation
strcat works on both char arrays and string objects:
[ 0 ]: strcat('foo', 'bar')
foobar
[ 0 ]: strcat("unit: ", num2str(42), " Hz")
unit: 42 Hz
Comparison functions
[ 0 ]: strcmp('abc', 'abc') % 1 — case-sensitive equal
[ 1 ]:
[ 0 ]: strcmp('abc', 'ABC') % 0
[ 0 ]:
[ 0 ]: strcmpi('abc', 'ABC') % 1 — case-insensitive
[ 1 ]:
Case and whitespace
[ 0 ]: upper('hello')
HELLO
[ 0 ]: lower('WORLD')
world
[ 0 ]: strtrim(' spaces ')
spaces
Search and replace
[ 0 ]: strrep('the cat sat', 'cat', 'dog')
the dog sat
[ 0 ]: strrep("Hello World", "World", "ccalc")
Hello ccalc
Predicates — containment, prefix, suffix
[ 0 ]: contains('hello world', 'world')
[ 1 ]:
[ 0 ]: contains('hello', 'xyz')
[ 0 ]:
[ 0 ]: contains('Hello', 'hello', 'IgnoreCase', true)
[ 1 ]:
[ 0 ]: startsWith('hello', 'he')
[ 1 ]:
[ 0 ]: endsWith('hello', 'lo')
[ 1 ]:
Splitting and joining strings
strsplit splits a string on a delimiter and returns a cell array of char arrays:
[ 0 ]: parts = strsplit('alpha,beta,gamma', ',')
[ 0 ]: numel(parts)
[ 3 ]:
[ 0 ]: parts{1}
alpha
[ 0 ]: parts{2}
beta
Without a delimiter, strsplit splits on whitespace:
[ 0 ]: words = strsplit('hello world')
[ 0 ]: words{1}
hello
strjoin is the inverse — it joins a cell array of strings into one string:
[ 0 ]: strjoin({'a', 'b', 'c'}, ',')
a,b,c
[ 0 ]: strjoin({'x', 'y'})
x y
Integer and matrix string conversion
[ 0 ]: int2str(3.2) % round to nearest integer, return string
3
[ 0 ]: int2str(3.7)
4
[ 0 ]: int2str(-1.5)
-2
[ 0 ]: mat2str([1 2; 3 4]) % matrix → MATLAB literal syntax
[1 2;3 4]
[ 0 ]: mat2str([10 20 30])
[10 20 30]
sprintf
Single-argument form: returns a char array with escape sequences processed.
[ 0 ]: disp(sprintf('line 1\nline 2\n'))
line 1
line 2
[ 0 ]: disp(sprintf('A\tB\tC'))
A B C
Regular expressions
Regular expression support is available when ccalc is built with
--features regex. Without the feature, calling these functions returns
an informative error message. Both names are always available for tab
completion.
% Find start index of first match (1-based); [] if no match:
regexp('abc 123 def', '\d+') % → 5
% Extract all matched substrings as a cell array:
regexp('abc 123 def 456', '\d+', 'match') % → {'123', '456'}
% Case-insensitive search:
regexpi('Hello World', 'hello') % → 1
% Replace all matches (replacement is always a literal string):
regexprep('foo bar', '\s+', '_') % → 'foo_bar'
regexprep('2024-01-15', '-', '/') % → '2024/01/15'
regexprep('a', 'a', '$1') % → '$1' (not expanded)
Build with regex support:
cargo build --features regex
Displaying strings
String values display as plain text — no surrounding quotes in the output:
[ 0 ]: 'hello'
hello
[ 0 ]: "world"
world
[ 0 ]: x = strcat('value: ', num2str(42))
x = value: 42
The REPL prompt shows the string content (truncated at 15 characters) when
ans is a string.
who annotates string types:
[ 0 ]: s = 'abc'; t = "hello";
[ 0 ]: who
Variables visible from the current scope:
ans = 0
s [1×3 char]
t [string]
Workspace
ws and wl do not persist string variables — the same policy as
matrices and complex numbers. Only scalars are saved.
Practical example — labelled output
R = 4700;
C = 2.2e-9;
f0 = 1 / (2 * pi * R * C);
fprintf('RC filter\n')
fprintf(' R = ')
disp(strcat(num2str(R), ' Ohm'))
fprintf(' C = ')
disp(strcat(num2str(C * 1e9, 3), ' nF'))
fprintf(' f0 = ')
disp(strcat(num2str(f0, 5), ' Hz'))
Output:
RC filter
R = 4700 Ohm
C = 2.2 nF
f0 = 15392 Hz
See examples/strings.calc for the full demo: ccalc examples/strings.calc
File I/O
ccalc supports file I/O using MATLAB/Octave-compatible functions. You can read and write files using low-level file handles, load and save delimiter-separated data, query the filesystem, and persist workspace variables to named files.
File handles
Open a file with fopen, write or read, then close with fclose:
fd = fopen('log.txt', 'w');
fprintf(fd, 'result: %.4f\n', 3.14159);
fclose(fd);
Supported modes:
| Mode | Description |
|---|---|
'r' | Read (file must exist) |
'w' | Write — create or truncate |
'a' | Append — create if missing |
'r+' | Read and write |
fopen returns a file descriptor (integer ≥ 3) on success, or -1 on failure.
File descriptor 1 is stdout and 2 is stderr — they can be used with fprintf directly.
Reading lines
fd = fopen('data.txt', 'r');
line1 = fgetl(fd); % strip trailing newline; returns -1 at EOF
line2 = fgetl(fd);
raw = fgets(fd); % keep trailing newline
fclose(fd);
Closing all handles
fclose('all'); % close every open file handle
Delimiter-separated data
Write and read numeric matrices as CSV or TSV files:
data = [0, 3.30, 0.012; 0.5, 3.28, 0.015; 1.0, 3.25, 0.018];
dlmwrite('measurements.csv', data); % comma-separated (default)
dlmwrite('measurements.tsv', data, '\t'); % tab-separated
loaded = dlmread('measurements.csv'); % auto-detect delimiter
loaded = dlmread('measurements.tsv', '\t'); % explicit delimiter
dlmread returns a Matrix. All values in the file must be numeric; non-numeric data returns an error with the offending line number.
Auto-detection order: try , first, then \t, then whitespace.
Filesystem queries
Check whether a file or directory exists before opening it:
if isfile('data.csv')
data = dlmread('data.csv');
end
isfolder('output/') % 1 if directory exists, 0 otherwise
cwd = pwd() % current working directory as a char array
exist checks variables or files:
exist('x', 'var') % 1 if variable x is in the workspace, 0 otherwise
exist('log.txt', 'file') % 2 if file found, 0 otherwise
exist('x') % checks workspace first, then filesystem
The numeric codes for exist match MATLAB: 1 = variable, 2 = file.
Directory listing
dir returns a struct array where every element describes one filesystem entry.
entries = dir('.') % list current directory
entries = dir('/path/to/dir') % list a specific directory
entries = dir('*.csv') % glob pattern — current directory
entries = dir('data/*.toml') % glob with parent path
entries = dir() % same as dir('.')
Each element has four fields:
| Field | Type | Description |
|---|---|---|
name | char array | File or directory name |
folder | char array | Absolute path of the containing directory |
isdir | Scalar | 1.0 for directories, 0.0 for files |
bytes | Scalar | File size in bytes (0 for directories) |
MATLAB compatibility: dir(path) always prepends . and .. as the first two entries (both with isdir = 1). Glob patterns do not include . or ...
A non-existent path returns an empty struct array — no error is raised.
% Print all files in examples/
entries = dir('examples');
for k = 1:numel(entries)
e = entries(k);
if ~e.isdir
fprintf('%s (%d bytes)\n', e.name, e.bytes);
end
end
% Count .csv files in the current directory
csvs = dir('*.csv');
fprintf('%d CSV file(s) found\n', numel(csvs));
% Non-existent path → 0 entries, no error
missing = dir('/no/such/path');
fprintf('entries: %d\n', numel(missing)); % → 0
The folder field is always an absolute path using OS-native separators.
Path generation
genpath(dir) recursively walks a directory tree and returns a path string
containing the root directory and all of its subdirectories (depth-first,
sorted alphabetically). On Unix the entries are joined with :; on Windows
with ;. Non-existent paths return an empty string.
% Get a path string covering crates/ and all of its subdirectories
p = genpath('crates');
fprintf('%s\n', p)
% Typical use: add all subdirectories of a library to the search path
addpath(genpath('libs'))
Workspace with explicit path
Save and load workspace variables to a named file instead of the default path:
R = 4700;
C = 220e-9;
label = 'RC filter';
save('session.mat'); % save all to named file
save('session.mat', 'R', 'C'); % save specific variables only
clear R
clear C
load('session.mat'); % load back
fprintf('R = %g\n', R)
The path argument can be a variable holding the path string:
path = 'session.mat';
save(path);
load(path);
save and load without arguments use the default workspace path ~/.config/ccalc/workspace.toml — the same as ws and wl.
What gets saved
| Type | Persisted |
|---|---|
| Scalar | Yes |
Char array ('text') | Yes |
String object ("text") | Yes |
| Matrix | No |
| Complex | No |
Example
The examples/file_io.calc file demonstrates all File I/O features end-to-end:
ccalc examples/file_io.calc
It covers: filesystem queries, writing to files with fprintf, line-by-line reading with fgetl/fgets, CSV/TSV with dlmread/dlmwrite, append mode, save/load with explicit paths and variable selection, and fopen error handling.
Control Flow
ccalc supports multi-line control flow blocks in both the interactive REPL and
in script/pipe mode. All block constructs use end as the closing keyword.
REPL multi-line input
The REPL detects unclosed blocks and buffers incoming lines, displaying a
continuation prompt >> until the block is complete. Press Ctrl+C to
cancel an incomplete block.
[ 0 ]: for k = 1:3
>> fprintf('%d\n', k)
>> end
1
2
3
if / elseif / else
score = 73;
if score >= 90
grade = 'A';
elseif score >= 80
grade = 'B';
elseif score >= 70
grade = 'C';
elseif score >= 60
grade = 'D';
else
grade = 'F';
end
fprintf('score %d -> grade %s\n', score, grade)
A condition is truthy when:
| Value type | Truthy when |
|---|---|
| Scalar | non-zero and not NaN |
| Matrix | all elements non-zero and not NaN |
| Str/StringObj | non-empty |
| Void | never |
for
for var = range_expr
% body
end
The range expression is evaluated once. Iteration is column-by-column:
- Row vector → each element as a scalar
- M×N matrix → each column as an M×1 column vector
% Simple range
for k = 1:5
fprintf('%d\n', k)
end
% Step range
for k = 10:-2:0
fprintf('%d ', k) % 10 8 6 4 2 0
end
while
while cond
% body
end
x = 1.0;
while abs(x ^ 2 - 2) > 1e-12
x = (x + 2 / x) / 2;
end
fprintf('sqrt(2) ≈ %.15f\n', x)
break and continue
break exits the innermost loop immediately. continue skips to the next
iteration.
for n = 1:20
if mod(n, 2) == 0
continue % skip even numbers
end
if n > 9
break % stop after first odd > 9
end
fprintf('%d ', n) % 1 3 5 7 9
end
Compound assignment operators
| Operator | Equivalent to |
|---|---|
x += e | x = x + e |
x -= e | x = x - e |
x *= e | x = x * e |
x /= e | x = x / e |
x++ | x = x + 1 |
x-- | x = x - 1 |
++x | x = x + 1 |
--x | x = x - 1 |
All forms desugar at parse time to a plain Stmt::Assign — no new AST nodes.
The RHS is a full expression: x *= 2 + 3 desugars to x = x * (2 + 3).
Limitation:
++/--are statement-level only. Using them inside a larger expression (b = a - b--) is not supported.
switch / case / otherwise
switch expr
case val1
% ...
case val2
% ...
otherwise % optional
% ...
end
No fall-through — only the first matching case executes. Works with scalars
(exact ==) and strings (Str/StringObj interchangeable). break/continue
propagate to the nearest enclosing loop.
switch code
case 200
msg = 'OK';
case 404
msg = 'Not Found';
otherwise
msg = 'Unknown';
end
fprintf('%d: %s\n', code, msg)
do…until
Octave post-test loop — body always runs at least once:
do
body
until (cond)
Parentheses around cond are optional. Closed by until, not end.
break and continue work as in while.
x = 1;
do
x *= 2;
until (x > 100)
fprintf('%d\n', x) % 128
run() / source()
Execute a script file in the current workspace. Variables defined in the script
persist in the caller’s scope (MATLAB run semantics — not a function call):
a = 252; b = 105;
run('euclid_helper') % looks for euclid_helper.calc, then .m
fprintf('gcd = %d\n', g) % g was set by the helper
source('euclid_helper') % Octave alias — identical behaviour
Extension resolution for bare names: .calc is tried first (native ccalc
format), then .m (Octave/MATLAB compatibility).
Examples
See the example scripts for self-contained demos:
ccalc examples/control_flow.calc # if/for/while/break/continue
ccalc examples/extended_control_flow.calc # switch/do-until/run/source
User-defined Functions
ccalc supports user-defined named functions, multiple return values, and anonymous functions (lambdas) using Octave/MATLAB syntax.
Named functions
function result = name(p1, p2)
...
result = expr;
end
Define a function at the top level in the REPL or in a .calc / .m script
file. The function is stored in the workspace and can be called like any built-in.
In script files, functions may appear anywhere — before or after the code
that calls them (see Function hoisting in scripts).
Single return value
function y = square(x)
y = x ^ 2;
end
square(5) % 25
Multiple return values
function [mn, mx, avg] = stats(v)
mn = min(v);
mx = max(v);
avg = mean(v);
end
[lo, hi, mu] = stats([4 7 2 9 1 5 8 3 6]);
% lo = 1 hi = 9 mu = 5
Discarding outputs
Use ~ in the assignment target to ignore individual outputs:
[~, top, ~] = stats([10 30 20]); % top = 30
nargin — optional parameters
nargin holds the number of arguments actually passed:
function y = power_fn(base, exp)
if nargin < 2
exp = 2; % default exponent
end
y = base ^ exp;
end
power_fn(5) % 25 (exp = 2 by default)
power_fn(2, 8) % 256
return — early exit
function result = factorial_r(n)
if n <= 1
result = 1;
return % exit immediately — no further code runs
end
result = n * factorial_r(n - 1);
end
factorial_r(7) % 5040
Scope
Each call gets its own isolated scope:
- The caller’s data variables are not visible inside the function.
- Parameters are bound locally.
- Other functions and lambdas from the caller’s workspace are forwarded, enabling self-recursion and mutual recursion.
function g = gcd_fn(a, b)
while b ~= 0
r = mod(a, b);
a = b;
b = r;
end
g = a;
end
gcd_fn(252, 105) % 21
Anonymous functions
@(params) expr creates an anonymous function (lambda):
sq = @(x) x ^ 2;
hyp = @(a, b) sqrt(a^2 + b^2);
sq(7) % 49
hyp(3, 4) % 5
Lexical capture
A lambda captures the value of free variables at definition time:
rate = 0.05;
interest = @(p, n) p * (1 + rate) ^ n;
interest(1000, 10) % 1628.89 (uses captured rate = 0.05)
rate = 0.99; % does not affect the already-created lambda
interest(1000, 10) % still 1628.89
Passing functions as arguments
Use @name to pass an existing function, or @(x) expr inline:
function s = midpoint(f, a, b, n)
h = (b - a) / n;
s = 0;
for k = 1:n
s += f(a + (k - 0.5) * h);
end
s *= h;
end
midpoint(@(x) x^2, 0, 1, 1000) % ≈ 0.333333 (∫₀¹ x² dx)
midpoint(@(x) sin(x), 0, pi, 1000) % ≈ 2.000001 (∫₀ᵖⁱ sin x dx)
Functions returning functions
function f = make_adder(c)
f = @(x) x + c;
end
add5 = make_adder(5);
add10 = make_adder(10);
add5(3) % 8
add10(7) % 17
add5(add10(1)) % 16
Documentation comments
Place %-prefixed lines immediately after the function header to
document a function (MATLAB H1-line convention). The REPL command help <name>
displays them:
function t = tri(n)
% Return the nth triangular number T(n) = n*(n+1)/2.
% Usage: t = tri(n)
%
% Example:
% tri(4) → 10
t = n * (n + 1) / 2;
end
>> help tri
Return the nth triangular number T(n) = n*(n+1)/2.
Usage: t = tri(n)
Example:
tri(4) → 10
- Any number of consecutive
%lines form the doc block. - A blank line between the
functionheader and the first%breaks the association — only lines that immediately follow the header are collected. - One leading space after
%is stripped; remaining indentation is preserved, so% exampledisplays asexample. #-style comments work the same way.help <name>works for autoloaded functions on the path before the first call — ccalc loads the file on demand to extract the doc.
Function hoisting in scripts
In a script file (one that does not begin with a function definition), helper
functions may be placed anywhere in the file — including after the code that calls them.
ccalc pre-registers all top-level function definitions before executing the script body,
matching MATLAB/Octave script semantics:
% main code at the top — calls a helper defined further down
result = double_it(7);
fprintf("double_it(7) = %d\n", result); % prints: double_it(7) = 14
% helper function at the bottom
function y = double_it(x)
y = x * 2;
end
This is the standard layout for MATLAB/Octave scripts: keep the main logic at the top and put helper functions at the bottom, where they are out of the way.
REPL difference: In the interactive REPL, functions take effect immediately when entered — a function must be defined before its first call.
Function files and autoload
A .calc (or .m) file that begins with a function definition is a
function file. ccalc handles it differently from a script:
- Only the primary function (the first one) is exposed to the caller’s workspace.
- Any additional functions in the file are local helpers — invisible outside the file, but available to the primary function (MATLAB-style scoping).
- When a function name is called that is not in the workspace, ccalc
automatically searches for
<name>.calc/<name>.mon the current directory and the session path, loads it, and calls it — no explicitsource()required.
% bisect.calc — primary function + private helper
function [c, k] = bisect(fun, a, b, tol)
% help text goes here, right after the function line
steps = ceil(log2((b - a) / tol));
[c, k] = bisect_r(fun, a, b, 0, steps); % calls local helper
end
function [c, k] = bisect_r(fun, a, b, k, maxSteps)
% bisect_r is local — not visible outside bisect.calc
...
end
If bisect.calc is on the path, calling bisect(...) without any source()
works automatically:
[c, k] = bisect(@(x) x^2 - 2, 1, 2, 1e-8) % bisect.calc auto-loaded
source('bisect.calc') still works for explicit loading.
Testing with assert
assert checks a condition and throws an error if it is false.
Use it in scripts and function files to catch programming mistakes early.
% assert(cond) — error if cond is 0, NaN, or empty
assert(1 == 1) % passes — silently returns
assert(2 > 3) % fails: "assert: condition is false"
% assert(expected, actual) — error if values differ (element-wise)
assert(sqrt(4), 2) % passes
assert([1 2], [1 3]) % fails: "assert: values differ"
% assert(expected, actual, tol) — error if |expected - actual| > tol
assert(pi, 3.14159, 1e-4) % passes — within tolerance
assert(pi, 3.14, 1e-4) % fails — difference 0.00159 > 1e-4
assert works on scalars, vectors, and matrices. For numeric comparisons
the element-wise absolute difference is checked; for matrices the check
applies to every element.
Full example
ccalc examples/user_functions.calc
See also: help userfuncs for the
in-REPL reference, and Control Flow for if, for,
while, break, and return.
Cell Arrays
A cell array is a heterogeneous 1-D container: each element can hold any value — scalar, matrix, string, complex number, function handle, or even another cell array.
Creating cell arrays
c = {1, 'hello', [1 2 3]}; % cell literal — comma-separated expressions
d = cell(5); % 1×5 cell pre-filled with zeros
e = cell(2, 4); % 1×8 cell pre-filled with zeros (1-D, m*n slots)
Brace indexing — reading elements
Use c{i} (curly braces, 1-based) to retrieve the content of element i:
c = {42, 'hello', [1 2 3]};
c{1} % → 42 (scalar)
c{2} % → hello (char array)
c{3} % → [1 2 3] (matrix)
Note:
c(i)with round parentheses returns an error — brace indexingc{i}is required to get the element’s value.
Assigning to elements
c{2} = 'world'; % replace existing element
c{5} = pi; % auto-grows: elements 4–5 are zero-filled
numel(c) % 5
Predicates and size
| Function | Description |
|---|---|
iscell(c) | 1 if c is a cell array, else 0 |
numel(c) | Number of elements |
length(c) | Same as numel(c) for 1-D cells |
size(c) | [1 numel(c)] as a 1×2 matrix |
varargin — variadic input
Declare the last parameter as varargin to collect all extra call arguments
into a cell array:
function s = sum_all(varargin)
s = 0;
for k = 1:numel(varargin)
s += varargin{k};
end
end
sum_all(1, 2, 3) % 6
sum_all(10, 20) % 30
sum_all() % 0 (empty varargin cell)
Fixed and variadic parameters can be mixed:
function show(label, varargin)
fprintf('[%s]', label)
for k = 1:numel(varargin)
fprintf(' %g', varargin{k})
end
fprintf('\n')
end
show('A', 1, 2, 3) % [A] 1 2 3
show('B', 100) % [B] 100
varargout — variadic output
Declare the sole output variable as varargout (a cell array) and the caller
receives one output value per cell element:
function varargout = first_n(v, n)
for k = 1:n
varargout{k} = v(k);
end
end
[a, b, c] = first_n([10 20 30 40], 3) % a=10 b=20 c=30
case {v1, v2} — multi-value switch cases
Inside a switch block, a cell array case matches if the switch expression
equals any element of the cell:
switch x
case {1, 2, 3}
disp('small')
case {4, 5, 6}
disp('medium')
otherwise
disp('large')
end
cellfun — apply a function to a cell
cellfun(f, c) applies f to each element of cell c.
Returns a Matrix when all results are scalar; otherwise returns a Cell.
c = {1, 4, 9, 16, 25};
cellfun(@sqrt, c) % [1 2 3 4 5]
cellfun(@(x) x * 2, c) % [2 8 18 32 50]
arrayfun — apply a function to a numeric vector
arrayfun(f, v) applies f to each element of matrix v.
Returns a same-shape matrix (function must return a scalar per element).
arrayfun(@(x) x^2, [1 2 3 4]) % [1 4 9 16]
arrayfun(@(x) x > 2, [1 2 3 4]) % [0 0 1 1]
@funcname — function handles
@funcname creates a callable that forwards its arguments to funcname.
Works with any built-in or user-defined function:
f = @sqrt;
g = @abs;
f(16) % 4
g(-7.5) % 7.5
cellfun(@sqrt, {1, 4, 9}) % [1 2 3]
arrayfun(@abs, [-1 -2 3]) % [1 2 3]
Compose handles via a capturing lambda:
compose = @(f, g) @(x) f(g(x));
sqrt_abs = compose(@sqrt, @abs);
sqrt_abs(-9) % 3 ( sqrt(abs(-9)) )
Function pipelines
Store a sequence of function handles in a cell array and apply them in order:
function y = apply_pipeline(x, pipeline)
y = x;
for k = 1:numel(pipeline)
f = pipeline{k};
y = f(y);
end
end
pipeline = {@(x) x + 1, @(x) x * 2, @sqrt};
apply_pipeline(5, pipeline) % sqrt((5+1)*2) = sqrt(12) ≈ 3.4641
Workspace
Cell arrays are not saved by ws/save — same policy as matrices and
complex values. who shows them as:
c = {1×4 cell}
See also
help cells— in-REPL referencehelp userfuncs— varargin/varargout in the context of user functionsccalc examples/cell_arrays.calc— annotated 9-section example
containers.Map
containers.Map is a string-keyed associative array — a lookup table that
maps string keys to values of any type. It is the ccalc equivalent of Python’s
dict or JavaScript’s Map.
Creating a map
% Empty map
m = containers.Map();
% From two cell arrays — keys cell + values cell (must be equal length)
prices = containers.Map({'apple', 'banana', 'cherry'}, {1.5, 0.75, 2.0});
All keys must be strings (char arrays or string objects).
Values can be any type: scalar, matrix, string, cell, struct, etc.
Reading values
Use parenthesis indexing with a string key:
prices('apple') % → 1.5
prices('banana') % → 0.75
Accessing an absent key is an error:
prices('mango') % error: Map key 'mango' not found
Writing values
prices('date') = 3.5; % insert new key
prices('banana') = 0.99; % update existing key
Count property
prices.Count % → number of entries (read-only)
Built-in functions
| Function | Description |
|---|---|
isKey(m, 'key') | 1 if key is present, 0 otherwise |
keys(m) | Cell array of all keys, sorted alphabetically |
values(m) | Cell array of values in the same sorted-key order |
remove(m, 'key') | Remove a key in-place (no assignment needed) |
m = containers.Map({'c', 'a', 'b'}, {3, 1, 2});
isKey(m, 'a') % → 1
isKey(m, 'z') % → 0
k = keys(m) % → {'a', 'b', 'c'} (sorted)
v = values(m) % → {1, 2, 3} (matching key order)
remove(m, 'b');
m.Count % → 2
Iterating over a map
m = containers.Map({'x', 'y', 'z'}, {10, 20, 30});
k = keys(m);
for i = 1:m.Count
fprintf('%s = %g\n', k{i}, m(k{i}));
end
Display
m =
Map with 3 entries:
'apple' → 1.5
'banana' → 0.75
'cherry' → 2
Notes
- String keys only. Numeric-key maps are not supported.
- Value semantics. Assigning
m2 = mcreates a copy (unlike MATLAB handle semantics). Mutations tom2do not affectm. - Maps are not persisted by
ws/save— same policy as matrices and cells. remove(m, k)mutatesmin-place without an assignment statement, matching MATLAB handle-class behaviour as closely as possible under value semantics.
See also
Structs and Struct Arrays
A scalar struct groups named fields into a single value. Each field can hold any type — scalar, matrix, string, complex, cell array, or another struct. Fields are stored in insertion order (MATLAB-compatible behaviour using an ordered map).
Creating structs
Field assignment
Assign to name.field to create the struct and set the field in one step:
pt.x = 3;
pt.y = 4;
pt.z = 0;
If the variable does not yet exist it is created as an empty struct and then the field is added. Assigning to a non-existent nested path creates all intermediate levels automatically:
car.engine.hp = 190; % car and car.engine are both created here
car.dims.length_m = 4.76;
struct() constructor
Build a struct from key–value pairs:
s = struct('x', 1, 'y', 2) % two fields
p = struct('name', 'Alice', 'score', 98.5)
e = struct() % empty struct (zero fields)
Arguments must come in pairs (name, value). The name must be a string
('single-quoted' or "double-quoted").
Reading fields
Use the same . notation:
pt.x % 3
pt.y % 4
car.engine.hp % 190
car.dims.length_m % 4.76
Chaining works to any depth. Accessing a field that does not exist is an error.
Dynamic field access
Use s.(expr) to read or write a field whose name is computed at runtime.
The expression inside .(...) must evaluate to a string:
fname = 'x';
s.x = 10;
s.(fname) % 10 — equivalent to s.x
s.(fname) = 99; % write: equivalent to s.x = 99
s.x % 99
This is especially useful when iterating over a list of field names:
stats.min = -3.14;
stats.max = 9.81;
stats.mean = 2.71;
fields = {'min', 'max', 'mean'};
for k = 1:numel(fields)
fprintf(' %s = %g\n', fields{k}, stats.(fields{k}))
end
Or when building a struct from parallel name/value arrays:
keys = {'x', 'y', 'z'};
values = {10, 20, 30};
pt = struct();
for k = 1:numel(keys)
pt.(keys{k}) = values{k};
end
pt.y % 20
An inline string literal also works: s.('fieldname').
The field expression must evaluate to a string; passing a number produces an
error: "Dynamic field name must be a string".
Built-in utilities
| Function | Description |
|---|---|
fieldnames(s) | Cell array of field names in insertion order |
isfield(s, 'x') | 1 if field 'x' exists, else 0 |
rmfield(s, 'x') | Copy of s with field 'x' removed; error if absent |
isstruct(v) | 1 if v is a struct, else 0 |
s.a = 1; s.b = 2; s.c = 3;
fn = fieldnames(s) % {'a'; 'b'; 'c'}
fn{1} % a
numel(fn) % 3
isfield(s, 'b') % 1
isfield(s, 'z') % 0
s2 = rmfield(s, 'b');
fieldnames(s2) % {'a'; 'c'}
isstruct(s) % 1
isstruct(42) % 0
Display
s =
scalar structure containing the fields:
x: 3
y: 4
engine: [1×1 struct]
data: [1×100 double]
Nested structs and non-scalar values are shown inline as
[1×1 struct], [M×N double], or {1×N cell}. Access the field directly
to see its full contents.
Structs in functions
Pass and return structs like any other value:
function d = distance(pt)
d = sqrt(pt.x^2 + pt.y^2 + pt.z^2);
end
p = struct('x', 1, 'y', 2, 'z', 2);
distance(p) % 3
Build structs inside functions the same way:
function v = make_vec3(x, y, z)
v.x = x; v.y = y; v.z = z;
end
u = make_vec3(1, 0, 0);
u.x % 1
Nested structs
config.server.host = 'localhost';
config.server.port = 8080;
config.db.name = 'prod';
config.db.timeout = 30;
config.server.host % localhost
config.db.timeout % 30
fieldnames returns only the top-level fields:
fieldnames(config) % {'server'; 'db'}
Workspace
Structs are not saved by ws / save — the same policy as matrices, complex
values, and cell arrays.
who displays structs as:
s = [1×1 struct]
Struct arrays
A struct array is a 1-D array of structs that all share the same field schema. Use indexed assignment to create and grow the array:
pts(1).x = 1; pts(1).y = 0;
pts(2).x = 3; pts(2).y = 4;
pts(3).x = 0; pts(3).y = 5;
numel(pts) % 3
isstruct(pts) % 1
pts(2).x % 3
Access an element by index — it returns a scalar struct:
p = pts(1);
p.x % 1
p.y % 0
pts(3).y % 5 (chained access also works)
Field collection
Applying .field to the array (without an index) collects that field across
all elements:
xs = pts.x; % [1 3 0] — 1×3 row vector (all scalars)
ys = pts.y; % [0 4 5]
dists = (xs .^ 2 + ys .^ 2) .^ 0.5; % [1 5 5]
If the field holds non-scalar values, the result is a cell array instead of a matrix.
Building in a loop
Struct arrays grow automatically:
for k = 1:5
data(k).value = k * k;
data(k).label = num2str(k);
end
vals = data.value; % [1 4 9 16 25]
sum(vals) % 55
String fields → cell array
When a collected field holds strings, the result is a cell array:
roster(1).name = 'Alice'; roster(1).score = 92;
roster(2).name = 'Bob'; roster(2).score = 78;
names = roster.name; % {'Alice', 'Bob'} — cell array
scores = roster.score; % [92 78] — matrix
names{1} % Alice
mean(scores) % 85
Built-in utilities on struct arrays
fieldnames, isfield, rmfield, numel, size, length, and isstruct
all work on struct arrays the same way they do on scalar structs.
fn = fieldnames(pts);
fn{1} % x
numel(fn) % 2
isfield(pts, 'x') % 1
isfield(pts, 'z') % 0
Display
pts =
1×3 struct array with fields:
x
y
A single-element struct array ([1×1 struct]) displays its full field values
like a scalar struct.
See also
help structs— in-REPL referencehelp cells— cell arrays,varargin/varargoutccalc examples/structs.calc— annotated scalar struct exampleccalc examples/struct_arrays.calc— annotated struct array exampleccalc examples/dyn_field_demo.m— dynamic field access examples
Error Handling
ccalc provides MATLAB-compatible error handling so scripts can recover from runtime errors without crashing the session.
Raising errors
error('message') % plain message
error('expected %d, got %d', 2, n) % formatted (same as fprintf)
warning('result may be inaccurate') % prints to stderr, continues
try / catch / end
try
result = risky_computation(x)
catch e
fprintf('failed: %s\n', e.message)
result = default_value
end
- If the
trybody succeeds,catchis skipped. catch ebinds a struct with fieldmessageto the catch variable.- Anonymous
catch(no variable) silently handles the error. trywith nocatchsilently swallows errors.
Inline fallback: try(expr, default)
n = try(str2num(s), 0) % 0 if s is not a valid number
x = try(inv(A), eye(n)) % identity matrix if A is singular
The default is only evaluated if expr raises an error.
Protected call: pcall
[ok, val] = pcall(@func, arg1, arg2)
if ok
% use val
else
fprintf('error: %s\n', val)
end
Returns [1, result] on success and [0, message] on failure.
Last error message
lasterr() % message from most recent error
lasterr('') % clear
“Did you mean?” hints
When a name is not found, ccalc compares it against all known variable names and built-in function names using edit distance. If a close match exists (at most 2 edits away), it is shown as a suggestion:
>> sqrtt(4)
Error: Unknown function 'sqrtt'; did you mean 'sqrt'?
>> my_valu + 1
Error: Undefined variable 'my_valu'; did you mean 'my_value'?
No suggestion is shown when no close match exists.
Source location in error messages
Errors inside block statements, function bodies, and scripts executed via
run()/source() include a near line N suffix pointing to the failing line:
Error: Undefined variable: 'v' near line 3
The line number is 1-based and relative to the immediately enclosing block or function body, matching Octave’s convention.
When an error propagates through nested blocks the innermost location is
kept — outer wrappers do not overwrite it. Inside a catch block, e.message
contains the original message without the near line suffix.
See help errors for the full reference.
Variable Scoping
ccalc provides four mechanisms to control visibility and lifetime of variables across function calls and files.
global — shared workspace storage
Declare the same name in every function that needs to share it. Changes in one function are immediately visible in all others and in the base workspace.
function reset_counter()
global g_count
g_count = 0;
end
function increment(step)
global g_count
g_count = g_count + step;
end
function n = read_counter()
global g_count
n = g_count;
end
reset_counter()
increment(1)
increment(1)
increment(1)
read_counter() % 3
Typical use cases: configuration objects, counters, accumulators shared by multiple functions without threading the value through every argument list.
persistent — per-function long-lived storage
A persistent variable keeps its value between calls to the same function.
On the very first call the variable is []; use isempty() to initialise it.
function n = how_many_calls()
persistent call_count
if isempty(call_count)
call_count = 0;
end
call_count += 1;
n = call_count;
end
how_many_calls() % 1
how_many_calls() % 2
how_many_calls() % 3
Memoization
Persistent variables are ideal for caching computed results. The write-through semantics ensure that recursive calls see each other’s cache updates immediately:
function f = fib_memo(n)
persistent cache
if isempty(cache)
cache = zeros(1, 100);
cache(1) = 1; cache(2) = 1;
end
if cache(n) ~= 0
f = cache(n);
return
end
cache(n) = fib_memo(n-1) + fib_memo(n-2);
f = cache(n);
end
fib_memo(30) % 832040 (computed in O(n) time, not O(2^n))
Contrast with global: a persistent variable is private to its function —
no other function can read or write it. A global variable is shared by any
function that declares it.
private/ — directory-scoped helpers
Functions placed in a private/ sub-directory are visible only to scripts
and functions in the parent directory. Any other caller receives an “Unknown
function” error.
mylib/
normalize.calc ← can call clamp() and lerp()
private/
clamp.calc ← invisible outside mylib/
lerp.calc ← invisible outside mylib/
% normalize.calc — parent can call private helpers directly
function y = normalize(data, lo, hi)
span = hi - lo;
for k = 1:numel(data)
y(k) = lerp(0, 1, (clamp(data(k), lo, hi) - lo) / span);
end
end
private/ directories are not added to the session search path even when a
parent directory is included in config.toml or via addpath. The privacy
boundary is enforced by the file-system layout, not by any configuration.
Packages (+pkg/) — named namespaces
A directory whose name starts with + is a package. Functions inside are
invisible at the top level and must be called with the package prefix:
pkg.function(args)
Layout
+utils/
clamp.calc % utils.clamp(x, lo, hi)
lerp.calc % utils.lerp(a, b, t)
+geom/
circle_area.calc % geom.circle_area(r)
rect_area.calc % geom.rect_area(w, h)
Usage
utils.clamp(-3, 0, 10) % 0
utils.clamp( 5, 0, 10) % 5
utils.lerp(0, 100, 0.25) % 25
geom.circle_area(1) % 3.14159...
geom.rect_area(4, 5) % 20
% Packages compose naturally with each other and with regular expressions
x = utils.clamp(utils.lerp(-10, 20, 0.5), 0, 10); % 5
Nested packages
Sub-directories inside a package directory that also start with + form nested
packages:
+geom/
+solid/
sphere_vol.calc % geom.solid.sphere_vol(r)
geom.solid.sphere_vol(3) % 4/3 * pi * 27
Autoload
Package functions are loaded on the first call. The search follows the standard
path order: calling script’s directory → CWD → session path. No source()
call is needed.
Summary
| Mechanism | Visibility | Lifetime |
|---|---|---|
global | Any function that declares it | Until clear or session end |
persistent | Private to the declaring function | Until session end |
private/ | Parent directory only | File exists on disk |
+pkg/ | Anyone, via pkg.func() syntax | Autoloaded on first call |
Full example
ccalc examples/scoping/scoping.calc
See also: help scoping for the in-REPL
reference, and User-defined Functions.
Statistics & Random Numbers
Random number generation
Use rng(seed) at the start of any script that needs reproducible output.
| Function | Description |
|---|---|
rand() | scalar uniform in [0, 1) |
rand(n) | n×n uniform matrix |
rand(m, n) | m×n uniform matrix |
randn() | scalar standard-normal sample |
randn(n) / randn(m, n) | standard-normal matrix |
randi(max) | random integer in [1, max] |
randi(max, n) / randi(max, m, n) | matrix of random integers |
randi([lo hi], ...) | integers from [lo, hi] |
rng(seed) | seed RNG — same seed → same sequence |
rng('shuffle') | reseed from OS entropy |
rng(42)
x = randn(1, 5) % reproducible 5-element sequence
d = randi(6, 1, 10) % ten dice rolls
Descriptive statistics
All functions operate column-wise on M×N matrices and collapse to a scalar for vectors.
| Function | Description |
|---|---|
std(v) | sample standard deviation (n-1 denominator) |
std(v, 1) | population standard deviation (n denominator) |
var(v) / var(v, 1) | sample / population variance |
median(v) | median (linear interpolation for even length) |
mode(v) | most frequent value; smallest wins on ties |
cov(v) | variance of a vector |
cov(A) | N×N covariance matrix of an m×N data matrix |
v = [2 4 4 4 5 5 7 9];
mean(v) % 5.0
std(v) % sample std ≈ 2.138
std(v, 1) % population std = 2.0
median(v) % 4.5
mode(v) % 4
Shape statistics
These functions measure the shape of a distribution (symmetry and peakedness). Both use the population (biased) central-moment formula.
| Function | Description |
|---|---|
skewness(v) | m3 / m2^(3/2) — zero for symmetric data, positive for long right tail |
kurtosis(v) | m4 / m2^2 — ≈ 1.8 for uniform, ≈ 3 for normal, > 3 for heavy tails |
Returns 0 for a scalar or constant vector; kurtosis returns NaN for constant data.
Column-wise on M×N matrices, same as std / var.
v = [2 4 4 4 5 5 7 9];
skewness(v) % 0.656 (slight right skew)
kurtosis(v) % 2.781 (slightly platykurtic)
% Symmetric data → skewness exactly 0:
skewness(1:10) % 0
kurtosis(1:10) % 1.776
Percentiles and spread
| Function | Description |
|---|---|
prctile(v, p) | p-th percentile; p can be a vector |
iqr(v) | interquartile range: prctile(75) - prctile(25) |
zscore(v) | standardise: (v - mean) / std, same shape |
v = [1 2 3 4 5 6 7 8];
prctile(v, 50) % 4.5 (median)
prctile(v, [25 75]) % [2.75 6.25] (quartiles)
iqr(v) % 3.5
z = zscore([2 4 6]); % z = [-1 0 1]
Outlier detection (1.5 × IQR rule)
q1 = prctile(data, 25);
q3 = prctile(data, 75);
fence_lo = q1 - 1.5 * iqr(data);
fence_hi = q3 + 1.5 * iqr(data);
outliers = data(data < fence_lo | data > fence_hi);
Histogram
hist prints an ASCII bar chart to stdout and returns Void.
histc returns a count vector for user-supplied bin edges.
hist(data) % 10 bins (default)
hist(data, 20) % 20 bins
edges = [0 10 20 30 40 50];
counts = histc(data, edges)
histc bin semantics: bin i counts elements where
edges(i) <= x < edges(i+1); the last bin counts x == edges(end) exactly.
Normal distribution
| Function | Description |
|---|---|
normcdf(x) | P(Z ≤ x), Z ~ N(0, 1) |
normcdf(x, mu, s) | P(X ≤ x), X ~ N(mu, s²) |
normpdf(x) | standard normal PDF |
normpdf(x, mu, s) | general normal PDF |
erf(x) | Gauss error function |
erfc(x) | 1 − erf(x) |
All six functions work element-wise on scalars and matrices.
normcdf(0) % 0.5
normcdf(1) - normcdf(-1) % 0.6827 (68% rule)
normcdf(2) - normcdf(-2) % 0.9545 (95% rule)
normcdf(3) - normcdf(-3) % 0.9973 (99.7% rule)
% Probability that X ~ N(50, 10) falls between 40 and 60:
normcdf(60, 50, 10) - normcdf(40, 50, 10) % ≈ 0.6827
The relationship between normcdf and erf:
normcdf(x) = 0.5 * (1 + erf(x / sqrt(2)))
Full example
% Generate 200 samples from N(50, 10) and analyse them.
rng(7)
n = 200;
data = 50 + 10 * randn(1, n);
fprintf('mean = %.4f\n', mean(data))
fprintf('std = %.4f\n', std(data))
fprintf('median = %.4f\n', median(data))
fprintf('IQR = %.4f\n', iqr(data))
fprintf('skewness = %.4f\n', skewness(data))
fprintf('kurtosis = %.4f\n', kurtosis(data))
% Percentile table
pct = prctile(data, [5 25 50 75 95]);
fprintf('P5/P25/P50/P75/P95 = %.1f %.1f %.1f %.1f %.1f\n', ...
pct(1), pct(2), pct(3), pct(4), pct(5))
% ASCII histogram
hist(data, 12)
See the full demo at examples/statistics.calc.
Linear Algebra
ccalc supports a comprehensive set of matrix decompositions and properties. By default all operations are implemented in pure Rust (no external dependencies). An optional BLAS build links against the system OpenBLAS for faster matrix multiply and solve on larger matrices — see Performance / BLAS below.
All decompositions use [a, b] = f(x) multi-output assignment syntax.
Single-output forms are also available for convenience.
QR decomposition
qr(A) factors a matrix as A = Q * R, where Q is orthogonal and R is
upper triangular.
[Q, R] = qr(A) % Q: m×m orthogonal, R: m×n upper triangular
R = qr(A) % single-output: R only
The full Q returned by ccalc is always m×m. For least-squares problems with an overdetermined system, extract the “thin” (economy) factors:
A = [1 2; 3 4; 5 6]; % 3×2 overdetermined
[Q, R] = qr(A);
Q1 = Q(:, 1:2); % first n columns
R1 = R(1:2, :); % first n rows (2×2 square)
b = [1; 2; 3];
c = R1 \ (Q1' * b); % least-squares solution
Verification:
norm(Q' * Q - eye(3), 'fro') % ≈ 0 (Q orthogonal)
norm(Q * R - A, 'fro') % ≈ 0 (exact factorisation)
LU decomposition
lu(A) factors a square matrix with partial pivoting: PA = LU,
where P is a permutation matrix, L is unit lower triangular, and U is
upper triangular.
[L, U, P] = lu(A) % PA = LU
U = lu(A) % single-output: U only
B = [2, 1, -1; -3, -1, 2; -2, 1, 2];
[L, U, P] = lu(B);
norm(P * B - L * U, 'fro') % ≈ 0
x = B \ [8; -11; -3]; % backslash uses LU internally
Cholesky decomposition
chol(A) returns the upper triangular Cholesky factor R such that
A = R’ * R. The input must be symmetric positive definite (SPD).
An error is returned otherwise.
A = [4 2 2; 2 5 3; 2 3 6];
R = chol(A);
norm(R' * R - A, 'fro') % ≈ 0
Cholesky is about twice as fast as LU for SPD systems and also serves as a positive-definiteness test.
Singular value decomposition (SVD)
svd(A) computes the decomposition A = U * S * V’.
s = svd(A) % singular values as a column vector (descending)
[U, S, V] = svd(A) % full: U (m×m), S (m×n diagonal), V (n×n)
[U, S, V] = svd(A, 'econ') % economy: U (m×k), S (k×k), V (n×k)
C = [1 2 3; 4 5 6; 7 8 9]; % rank-2 matrix
s = svd(C);
fprintf('rank = %d\n', rank(C)) % 2
[U, S, V] = svd(C);
norm(U * S * V' - C, 'fro') % ≈ 0
norm(U' * U - eye(3), 'fro') % ≈ 0 (U orthogonal)
% Rank-1 approximation (best in Frobenius sense)
C1 = S(1,1) * (U(:,1) * V(:,1)');
Eigendecomposition
eig(A) returns eigenvalues and eigenvectors.
d = eig(A) % eigenvalues as a column vector
[V, D] = eig(A) % V: eigenvectors (columns), D: diagonal eigenvalue matrix
The decomposition satisfies A * V = V * D, i.e.
A * V(:,k) = D(k,k) * V(:,k) for each eigenpair k.
S = [4 1 0; 1 3 1; 0 1 2]; % symmetric
[V, D] = eig(S);
% Check residual for each eigenpair
for k = 1:3
r = norm(S * V(:,k) - D(k,k) * V(:,k));
fprintf('residual %d: %.2e\n', k, r)
end
Complex eigenvalues
Non-symmetric real matrices can have complex conjugate eigenvalue pairs.
eig(A) detects these automatically and returns a ComplexMatrix N×1
column vector. Use real() and imag() to inspect the parts, and
all(real(d) < 0) for continuous-time stability checks.
% Rotation matrix — eigenvalues are exactly ±i
Rot = [0, -1; 1, 0];
d = eig(Rot) % ComplexMatrix [0+1i; 0-1i]
% Damped oscillator (omega=2, zeta=0.3) — stable complex pair
A = [0, 1; -4, -1.2];
d = eig(A)
fprintf('stable: %d\n', all(real(d) < 0)) % 1
% Unstable system (trace > 0 → at least one Re(λ) > 0)
U = [0.5, 1; -1, 0.3];
d = eig(U)
fprintf('stable: %d\n', all(real(d) < 0)) % 0
When all eigenvalues are real (e.g. for symmetric matrices), eig returns
a plain real Matrix column vector as before. The [V, D] = eig(A)
two-output form is available for real eigenvalues only; it returns an error
when complex pairs are detected.
% Polynomial roots via companion matrix
% p(x) = x^4 + 2x^3 + 4x^2 + 3x + 1 → coefficients [c0,c1,c2,c3] = [1,3,4,2]
c = [1, 3, 4, 2];
n = length(c);
C = zeros(n, n);
for k = 1:n-1
C(k+1, k) = 1;
end
C(:, n) = -c';
roots_p = eig(C) % ComplexMatrix — roots of the polynomial
Matrix properties
Numerical rank
rank(A) counts the singular values above the threshold
ε × σ_max × max(m, n) (where ε = 2.2×10⁻¹⁶, the double precision machine epsilon).
rank([1 2 3; 4 5 6; 7 8 9]) % → 2 (third row is sum of first two)
rank(eye(4)) % → 4
Null space
null(A) returns an orthonormal basis for the null space of A —
the set of vectors x such that A*x = 0.
N = null([1 2 3; 4 5 6; 7 8 9]);
norm(([1 2 3; 4 5 6; 7 8 9]) * N) % ≈ 0
Column-space basis
orth(A) returns an orthonormal basis for the column space of A
(the range or image of A).
Q = orth([1 2 3; 4 5 6; 7 8 9]); % 3×2 (rank 2 matrix → 2 basis vectors)
norm(Q' * Q - eye(2), 'fro') % ≈ 0 (Q has orthonormal columns)
Condition number
cond(A) returns the 2-norm condition number σ_max / σ_min.
A large condition number means the matrix is nearly singular and linear
systems involving it may be sensitive to small perturbations.
cond(eye(3)) % → 1.0 (perfectly conditioned)
cond([1 1; 1 1.0001]) % → ~40000 (nearly singular)
Pseudoinverse
pinv(A) computes the Moore-Penrose pseudoinverse via SVD.
For full-rank square matrices it coincides with inv(A).
For rank-deficient or non-square matrices it gives the minimum-norm
least-squares solution.
A = [1 2 3; 4 5 6; 7 8 9]; % rank 2
Ap = pinv(A);
norm(A * Ap * A - A, 'fro') % ≈ 0 (fundamental property)
rank(Ap) % → 2 (same as rank(A))
Matrix norms
| Call | Description |
|---|---|
norm(v) | Vector Euclidean (L2) norm |
norm(v, p) | Vector Lp norm |
norm(A) | Matrix spectral 2-norm (largest singular value) |
norm(A, 'fro') | Frobenius norm: √(Σ aᵢⱼ²) |
norm(A, 1) | Max column-sum norm |
norm(A, inf) | Max row-sum norm |
M = [1 2; 3 4; 5 6];
norm(M) % 9.5255 (largest singular value)
norm(M, 'fro') % 9.5394 (sqrt(1+4+9+16+25+36))
norm(M, 1) % 12.0 (max column sum: max(1+3+5, 2+4+6))
norm(M, inf) % 11.0 (max row sum: max(1+2, 3+4, 5+6))
Tip: negative elements in matrix literals
A space before a minus sign inside [...] can be parsed as subtraction
rather than a negative element. Use commas to be unambiguous:
A = [2, 1, -1; -3, -1, 2] % safe: comma disambiguates
A = [2 1 -1; ...] % risky: '1 -1' parses as 1 - 1 = 0
Performance / BLAS
By default ccalc uses pure-Rust matrix arithmetic. This is fast enough for matrices up to a few hundred rows, but for larger work (500×500 and above) linking against the system BLAS gives a significant speedup.
| Operation | Pure Rust | BLAS build | Notes |
|---|---|---|---|
50×50 A*B | ~4 ms | ~0.3 ms | BLAS overhead dominates at small sizes |
500×500 A*B | ~3 s | ~50 ms | ~60× speedup |
inv, \, lu, qr, svd, eig | pure Rust | LAPACK | All benefit at large N |
Building with BLAS
Requires OpenBLAS installed on the system:
# Linux (Debian/Ubuntu)
sudo apt install libopenblas-dev
# macOS (Homebrew)
brew install openblas
# Windows — install via vcpkg or use blas-static (see below)
Then build ccalc with the feature enabled:
cargo build --release --features blas
For a fully static binary with no OpenBLAS runtime dependency:
cargo build --release --features blas-static
All functions work identically in both builds — --features blas only
changes the underlying kernel for *, inv, \, and the decompositions;
the API is unchanged.
Example
ccalc examples/linear_algebra.calc
The example script covers all functions above with numerical verification of each decomposition and matrix property.
JSON
ccalc can encode and decode JSON data using two built-in functions. These functions
are available when ccalc is built with the json feature flag.
Requires the
jsonfeature:cargo build --release --features jsonWithout this flag, calling
jsondecodeorjsonencodereturns an error message explaining how to enable the feature.
Decoding JSON
jsondecode(str) parses a JSON string and returns a ccalc value.
s = jsondecode('{"x": 1, "y": [1, 2, 3]}')
% s is a struct with fields x and y
s.x % → 1
s.y % → [1 2 3] (1×3 matrix row vector)
Type mapping
| JSON type | ccalc value |
|---|---|
object {…} | Struct |
| all-numeric array | Matrix (1×N row vector) |
| mixed array | Cell |
| string | Str (char array) |
| number | Scalar |
true / false | Scalar (1 / 0) |
null | Scalar(NaN) |
Arrays containing only numbers (and null values, which become NaN) decode to
a Matrix row vector. Arrays with mixed types (numbers, strings, nested objects,
etc.) decode to a Cell.
jsondecode('[1, 2, 3]') % → [1 2 3] (Matrix)
jsondecode('[1, "two", 3]') % → {1, 'two', 3} (Cell)
jsondecode('null') % → NaN
jsondecode('true') % → 1
Nested data
Nested JSON objects become nested structs:
data = jsondecode('{"person": {"name": "Alice", "age": 30}}');
data.person.name % → 'Alice'
data.person.age % → 30
Reading from a file
Combine with fileread to decode a JSON file:
raw = fileread('data.json');
data = jsondecode(raw);
Encoding JSON
jsonencode(val) encodes a ccalc value to a compact JSON string.
s.x = 1;
s.y = [1 2 3];
jsonencode(s) % → '{"x":1.0,"y":[1.0,2.0,3.0]}'
Type mapping
| ccalc value | JSON output |
|---|---|
Struct | object {…} |
Matrix (1×N row vector) | flat array […] |
Matrix (M×N) | array of row arrays |
Cell | array […] |
Scalar | number |
Scalar(NaN) | null |
Str / StringObj | string |
Scalar(Inf) and Scalar(-Inf) cannot be represented in JSON and produce an error.
Complex, Lambda, and Function values also produce an error.
jsonencode(42) % → '42.0'
jsonencode('hello') % → '"hello"'
jsonencode([1 2 3]) % → '[1.0,2.0,3.0]'
jsonencode({1, 'a'}) % → '[1.0,"a"]'
Writing to a file
s.result = 3.14;
fid = fopen('output.json', 'w');
fprintf(fid, '%s\n', jsonencode(s));
fclose(fid);
Roundtrip example
original = '{"name":"Bob","scores":[88,92,75]}';
data = jsondecode(original);
data.name % → 'Bob'
data.scores % → [88 92 75]
% Re-encode (field order preserved via IndexMap):
jsonencode(data)
% → '{"name":"Bob","scores":[88.0,92.0,75.0]}'
CSV — Tables and Matrices
ccalc provides three built-in functions for reading and writing delimiter-separated
files. They extend the lower-level dlmread/dlmwrite primitives
with automatic header handling, mixed-type columns, and RFC 4180 quoting.
readmatrix
readmatrix(path) reads a numeric CSV file and returns a Matrix.
A = readmatrix('data.csv')
Behaviour:
- Auto-detects the delimiter: comma → tab → whitespace.
- If the first row contains any non-numeric text it is skipped as a header. A purely numeric first row is treated as data.
- Empty cells become
NaN(unlikedlmread, which uses0.0).
% data.csv: x,y,z\n1,2,3\n4,5,6
A = readmatrix('data.csv')
% → [1 2 3; 4 5 6] (header row skipped)
Explicit delimiter:
A = readmatrix('data.tsv', 'Delimiter', '\t')
readtable
readtable(path) reads a CSV file where the first row is always the header
and returns a Struct of columns.
T = readtable('people.csv')
T.name % Cell of Str — one element per row
T.age % Matrix (N×1) — numeric column
Column type rules:
| Column content | ccalc type |
|---|---|
All cells parseable as numbers (empty → NaN) | Matrix N×1 |
| Any non-numeric cell | Cell of Str |
Quoted fields (RFC 4180):
Fields may be enclosed in double-quotes. A comma inside a quoted field is part
of the value, not a delimiter. Two consecutive "" inside a quoted field encode
a literal ".
% people.csv:
% name,city
% "Smith, John","New York"
T = readtable('people.csv')
T.name{1} % → 'Smith, John'
T.city{1} % → 'New York'
Explicit delimiter:
T = readtable('data.tsv', 'Delimiter', '\t')
writetable
writetable(T, path) writes a struct table to a CSV file with a header row.
T.name = {'Alice'; 'Bob'};
T.score = [95; 87];
writetable(T, 'output.csv')
Output:
name,score
Alice,95
Bob,87
Accepted column types:
| ccalc type | Written as |
|---|---|
Matrix (N×1) | One number per row |
Cell | Each element formatted (strings or numbers) |
Scalar | Single-row value |
Str / StringObj | Single-row string |
Quoting: any cell value that contains the delimiter, a ", or a newline
is automatically wrapped in double-quotes (RFC 4180). Embedded " are doubled.
T.desc = {'hello, world'; 'plain'};
T.n = [1; 2];
writetable(T, 'out.csv')
% out.csv:
% desc,n
% "hello, world",1
% plain,2
Explicit delimiter:
writetable(T, 'out.tsv', 'Delimiter', '\t')
Roundtrip example
% Write
T.city = {'Paris'; 'Berlin'; 'Tokyo'};
T.pop = [2161000; 3645000; 13960000];
writetable(T, 'cities.csv')
% Read back
T2 = readtable('cities.csv')
T2.city{2} % → 'Berlin'
T2.pop(3) % → 13960000
Differences from dlmread / dlmwrite
| Feature | dlmread | readmatrix | readtable |
|---|---|---|---|
| Header row | error | auto-skip | always first row |
| Empty cells | 0.0 | NaN | NaN (numeric cols) |
| String columns | error | error | Cell |
| Quoted fields | no | yes | yes |
| Return type | Matrix | Matrix | Struct of columns |
MAT Files
ccalc can read MATLAB Level 5/7 .mat files using load. This lets you
exchange data with MATLAB, Octave, SciPy, and any other tool that writes the
standard MAT format.
Requires the
matfeature:cargo build --release --features matWithout this flag, calling
load('*.mat')returns an error message explaining how to enable the feature.
Loading a MAT file
Assignment form
data = load('file.mat') reads all variables from the file and returns
a Struct whose fields are the variable names.
data = load('results.mat');
data.score % scalar variable from the file
data.readings % matrix variable from the file
data.label % char-array variable from the file
data.sensor.id % struct field — nested access works directly
Access the shape of a loaded matrix:
fprintf('%dx%d\n', size(data.A, 1), size(data.A, 2))
Bare form
load('file.mat') (without an assignment) injects all variables directly
into the current workspace:
load('results.mat')
% All variables are now in scope:
score
readings
sensor.gain
This is equivalent to the assignment form followed by assigning each field to a workspace variable.
Type mapping
| MATLAB type | ccalc value |
|---|---|
scalar double | Scalar |
M×N double matrix | Matrix |
char array (string) | Str (char array) |
struct | Struct |
| struct array | StructArray |
cell array | Cell |
| empty / null | Scalar(NaN) |
Complex and sparse matrices are not yet supported.
Working with loaded data
Scalar
data = load('results.mat');
s = data.score;
fprintf('score = %g, score^2 = %g\n', s, s^2)
Matrix
A = data.A;
fprintf('A is %dx%d\n', size(A, 1), size(A, 2))
fprintf('trace(A''*A) = %.1f\n', trace(A'*A))
Char array
lbl = data.label;
fprintf('label = %s\n', upper(lbl))
Struct fields
sen = data.sensor;
scaled = data.readings * sen.gain;
fprintf('scaled mean = %.2f\n', mean(scaled))
Saving MAT files
Writing .mat files is not yet supported. save('out.mat', ...) returns
an informative error. Use save without a .mat extension (or ws) to
persist workspace variables in ccalc’s native TOML format.
Example
The examples/mat/mat.calc file demonstrates all MAT-file features:
cargo run --release --features mat -- examples/mat/mat.calc
It covers: assignment form, scalar arithmetic, row-vector statistics, matrix algebra, char-array built-ins, struct field access, bare workspace merge, and a simple signal-analysis routine.
Generating the fixture
The example uses examples/mat/fixtures/sample.mat, which can be
regenerated with:
cargo test --features mat create_example_fixture -- --ignored
Datetime & Duration
ccalc supports UTC datetime values and durations as first-class types. All timestamps are stored internally as seconds since the Unix epoch (1970-01-01 00:00:00 UTC).
Constructors
datetime('2024-06-01') % from ISO 8601 date string
datetime('2024-06-01 09:30:00') % date + time
datetime(2024, 6, 1) % year, month, day
datetime(2024, 6, 1, 9, 30, 0) % year, month, day, hour, min, sec
datetime(ts, 'ConvertFrom', 'posixtime') % from Unix timestamp scalar
NaT is the Not-a-Time constant, analogous to NaN for scalars.
Duration constructors
duration(1, 30, 0) % 1 hour 30 minutes → 5400 seconds
hours(2) % 2 h → Duration
minutes(90) % 90 min → Duration
seconds(45) % 45 s → Duration
days(1) % 1 day → Duration
milliseconds(500) % 500 ms → Duration
years(1) % 365.2425 days → Duration
Arithmetic
| Expression | Result type |
|---|---|
datetime + duration | DateTime |
datetime - duration | DateTime |
datetime - datetime | Duration |
duration + duration | Duration |
duration * scalar | Duration |
t = datetime(2024, 1, 1);
d = hours(1);
t2 = t + d; % 2024-01-01 01:00:00
elapsed = t2 - t; % Duration: 01:00:00
Component extractors
year(dt) month(dt) day(dt)
hour(dt) minute(dt) second(dt)
All extractors also work on DateTimeArray, returning a column vector.
Duration extractors
hours(d) % Duration → hours as scalar
minutes(d) % Duration → minutes as scalar
seconds(d) % Duration → seconds as scalar
days(d) % Duration → days as scalar
milliseconds(d) % Duration → milliseconds as scalar
Predicates
isdatetime(x) % 1 if x is DateTime or DateTimeArray
isduration(x) % 1 if x is Duration or DurationArray
isnat(x) % 1 if x is NaT (DateTime(NaN))
Formatting and conversion
datestr(dt) % "15-Jan-2024 09:30:00"
datestr(dt, 'yyyy/MM/dd') % custom pattern
datevec(dt) % [y m d H M S] row vector
datenum(dt) % MATLAB serial date number
datenum(y, m, d) % MATLAB serial date from components
posixtime(dt) % Unix timestamp as scalar
datestr pattern tokens
| Token | Description |
|---|---|
yyyy | 4-digit year |
MMM | 3-letter month abbreviation (Jan, Feb, …) |
MM | 2-digit month |
dd | 2-digit day |
HH | 2-digit hour (24 h) |
mm | 2-digit minute |
ss | 2-digit second |
SSS | 3-digit milliseconds |
Array operations
Matrix literals build DateTimeArray or DurationArray when all elements are the same type:
t = [datetime(2024,1,1); datetime(2024,1,2); datetime(2024,1,3)]; % DateTimeArray
d = [hours(1); hours(2); hours(3)]; % DurationArray
diff(arr) computes successive differences:
DateTimeArray→DurationArrayDurationArray→DurationArray
t = [datetime(2024,1,1); datetime(2024,1,2); datetime(2024,1,3)];
d = diff(t); % DurationArray of two 1-day durations
fprintf and sprintf
DateTime and Duration values format as strings with %s:
dt = datetime(2024, 6, 1);
dur = hours(2);
fprintf('%s\n', dt) % 2024-06-01 00:00:00
fprintf('%s\n', dur) % 02:00:00
s = sprintf('elapsed: %s', dur);
Matrix Utilities & Set Operations
Phase 23 adds triangular-matrix extraction, tiling, Kronecker products, vector products, set-theoretic operations on vectors, and index-conversion utilities.
Triangular extraction
A = [1 2 3; 4 5 6; 7 8 9];
triu(A) % [1 2 3; 0 5 6; 0 0 9] upper triangular
triu(A, 1) % [0 2 3; 0 0 6; 0 0 0] above main diagonal
tril(A) % [1 0 0; 4 5 0; 7 8 9] lower triangular
tril(A, -1) % [0 0 0; 4 0 0; 7 8 0] below main diagonal
The optional offset k:
k | keeps elements where … |
|---|---|
0 (default) | col − row ≥ 0 (triu) / col − row ≤ 0 (tril) |
k > 0 | strictly above the main diagonal |
k < 0 | extends into the sub-diagonals |
Tiling and Kronecker product
repmat([1 2; 3 4], 2, 3) % 4×6 block matrix
kron([1 0; 0 1], [1 2; 3 4]) % 4×4 block-diagonal (identity scaling)
repmat(A, m, n) tiles matrix A in an m × n grid of blocks.
kron(A, B) replaces each scalar element a[i,j] of A with the block
a[i,j] * B, producing a (rows_A × rows_B) by (cols_A × cols_B) result.
Vector products
cross([1 0 0], [0 1 0]) % [0 0 1]
cross([1 2 3], [4 5 6]) % [-3 6 -3]
dot([1 2 3], [4 5 6]) % 32
cross(a, b) requires both vectors to have exactly 3 elements. The result
orientation (row or column) matches argument a.
dot(a, b) computes the inner product sum(a .* b) and returns a scalar.
Set operations
All set functions return sorted, unique results. NaN is never considered a
member (IEEE semantics: NaN ≠ NaN).
intersect([1 3 5 7], [3 5 9]) % [3 5]
union([1 3 5], [3 5 7]) % [1 3 5 7]
setdiff([1 2 3 4 5], [2 4]) % [1 3 5]
ismember(3, [1 2 3 4]) % 1
ismember([1 6 3], [1 2 3 4]) % [1 0 1] (element-wise)
ismember(nan, [nan]) % 0 (NaN is never a member)
Index conversion
sub2ind and ind2sub convert between row/column subscripts and 1-based
column-major linear indices (MATLAB convention).
sub2ind([3 4], 2, 3) % 8 (scalar)
sub2ind([3 4], [1 2], [1 3]) % [1 8] (vectorised)
[r, c] = ind2sub([3 4], 8) % r=2, c=3
[r, c] = ind2sub([3 4], [1 7]) % r=[1 1], c=[1 3]
Element repetition
repelem([1 2 3], 3) % [1 1 1 2 2 2 3 3 3]
repelem([1 2 3], [2 1 3]) % [1 1 2 3 3 3] (per-element counts)
repelem([1 2; 3 4], 2, 3) % 4×6 (each element repeated 2 rows × 3 cols)
repelem(v, n) — repeat each element n times (scalar n).repelem(v, nv) — repeat v(i) by nv(i) times (vector nv).repelem(A, m, n) — 2-D form: repeat each element m rows and n columns.
See also
help matrices— matrix literals and arithmetichelp vectors— sort, find, unique, reshapehelp linalg— QR, LU, SVD, eigenvectors
Polynomial Operations & Interpolation
Polynomials are represented as row vectors of coefficients in descending degree order.
p(x) = x² − 3x + 2 → [1, -3, 2]
p(x) = x³ − 6x² + 11x − 6 → [1, -6, 11, -6]
Evaluation — polyval
polyval(p, x) evaluates polynomial p at scalar or vector x using
Horner’s method (numerically stable, O(n) multiplications).
p = [1 0 1]; % x² + 1
polyval(p, 0) % → 1
polyval(p, [0 1 2]) % → [1 2 5]
Fitting — polyfit
polyfit(x, y, n) returns the degree-n polynomial (n+1 coefficients) that
best fits the data points (x, y) in the least-squares sense.
The fit is computed via a Vandermonde matrix and QR decomposition.
x = [0 1 2 3 4];
y = [1 2 5 10 17];
p = polyfit(x, y, 2) % → [1.0 0.0 1.0] (≈ x² + 1)
% Evaluate the fit at finer points:
xi = linspace(0, 4, 100);
yi = polyval(p, xi);
Roots — roots
roots(p) finds all roots of polynomial p using the Durand–Kerner
(Weierstrass) iteration in complex arithmetic.
- All roots real → returns a real column vector (
Matrix). - Any root complex → returns a
CellofScalar/Complexvalues.
roots([1 0 1]) % → {0+1i; 0-1i} (complex pair — Cell)
roots([1 2 1]) % → [-1; -1] (repeated real root)
Monic polynomial — poly
poly(r) expands the product (x − r₁)(x − r₂)… into a coefficient vector.
poly(A) computes the characteristic polynomial of square matrix A via the
Faddeev–LeVerrier algorithm.
poly([1 2 3]) % → [1 -6 11 -6] (x-1)(x-2)(x-3)
poly([1 2; 0 3]) % → [1 -4 3] characteristic polynomial of A
Convolution — conv
conv(a, b) computes the discrete linear convolution of vectors a and b.
For polynomials this is equivalent to polynomial multiplication.
Result length = length(a) + length(b) − 1.
conv([1 2 3], [1 1]) % → [1 3 5 3]
Deconvolution — deconv
[q, r] = deconv(c, b) performs polynomial long division c / b.
Returns quotient q and remainder r (same length as c) satisfying:
conv(b, q) + r == c
[q, r] = deconv([1 3 5 3], [1 1]) % q=[1 2 3], r=[0 0 0 0]
Interpolation — interp1
interp1(x, y, xi) interpolates the data (x, y) at query points xi.
x must be strictly monotonically increasing. Queries outside [x(1), x(end)]
return NaN (no extrapolation).
| Method | Description |
|---|---|
'linear' (default) | Linear interpolation between bracketing knots |
'nearest' | Snap to the closest knot (ties go left) |
'previous' | Zero-order hold — left step (floor to left knot) |
'next' | Right step (ceil to right knot) |
x = [0 1 2 3];
y = [0 1 4 9];
interp1(x, y, 1.5) % → 2.5 (linear)
interp1(x, y, [0.5 1.5 2.5]) % → [0.5 2.5 6.5]
interp1(x, y, 1.5, 'nearest') % → 1 (closest knot)
interp1(x, y, 1.5, 'previous') % → 1 (left step)
interp1(x, y, 1.5, 'next') % → 4 (right step)
interp1(x, y, 99) % → NaN (out of range)
FFT & Signal Processing
ccalc provides FFT-based frequency analysis through five built-in functions.
fft and ifft require the fft feature flag:
Requires the
fftfeature:cargo build --release --features fftWithout this flag, calling
fftorifftreturns an error message explaining how to enable the feature.fftshift,ifftshift, andfftfreqare always available.
Forward FFT — fft
fft(x) computes the Discrete Fourier Transform (DFT) of real vector x.
Returns a ComplexMatrix (1×N row vector) where each element is a
complex number re+im·i. Access individual bins with X(k).
x = [1 2 3 4];
X = fft(x)
% X(1) = 10 + 0i (DC component: sum of all samples)
% X(2) = -2 + 2i
% X(3) = -2 + 0i
% X(4) = -2 - 2i
Zero-padded FFT
fft(x, n) pads x with zeros to length n before the transform (or truncates
if n < length(x)). Use to control the frequency resolution:
X = fft([1 2 3 4], 8) % 8-point FFT of a 4-sample signal
Inverse FFT — ifft
ifft(X) computes the inverse DFT, normalised by 1/N.
Accepts a ComplexMatrix (as returned by fft).
When all imaginary parts are negligibly small (< 1e-12), returns a real matrix:
x = [1 2 3 4];
X = fft(x);
y = ifft(X) % → [1 2 3 4] (real matrix; imaginary parts dropped)
Shift DC to centre — fftshift / ifftshift
fftshift(x) performs a circular shift by floor(N/2) so that the DC
component (index 1) moves to the centre of the array. Used to produce a
zero-centred spectrum plot.
ifftshift(x) undoes the shift (ceil(N/2)).
fftshift([1 2 3 4 5 6]) % → [4 5 6 1 2 3]
ifftshift([4 5 6 1 2 3]) % → [1 2 3 4 5 6]
fftshift([1 2 3 4 5]) % → [4 5 1 2 3] (odd length)
ifftshift(fftshift([1 2 3 4 5])) % → [1 2 3 4 5]
For 2-D matrices both dimensions are shifted simultaneously.
Frequency axis — fftfreq
fftfreq(n, d) returns a 1×n row vector of DFT sample frequencies for n
points with sample spacing d seconds. The result is in cycles per unit of d.
n = 8;
fs = 1000; % sampling rate in Hz
d = 1/fs; % sample spacing in seconds
f = fftfreq(n, d)
% → [0 125 250 375 -500 -375 -250 -125] Hz
The formula matches NumPy/MATLAB:
f = [0, 1, ..., floor((n-1)/2), -floor(n/2), ..., -1] / (n·d)
Worked example — power spectrum
Two-tone signal: 10 Hz (amplitude 1.0) and 25 Hz (amplitude 0.5), sampled at 100 Hz for 100 points (1 second). Both tones land exactly on FFT bins (frequency resolution = 1 Hz), so there is no spectral leakage.
For a real sine of amplitude A, the one-sided magnitude is A × n/2.
fft returns a ComplexMatrix, so abs(S) gives a real matrix of
element-wise magnitudes directly — no loop needed:
n = 100;
fs = 100;
t = (0:n-1) / fs; % 0, 0.01, …, 0.99 s
s = sin(2*pi*10*t) + 0.5*sin(2*pi*25*t);
S = fft(s);
f = fftfreq(n, 1/fs);
% abs() on a ComplexMatrix returns a real Matrix of element-wise magnitudes.
mag = abs(S);
% Bins: 10 Hz → bin 11, 25 Hz → bin 26 (1-based; resolution = 1 Hz)
fprintf('Bin 11 @ 10 Hz : |S| = %.2f (expected %.2f)\n', mag(11), 1.0 * n/2)
fprintf('Bin 26 @ 25 Hz : |S| = %.2f (expected %.2f)\n', mag(26), 0.5 * n/2)
% Bin 11 @ 10 Hz : |S| = 50.00 (expected 50.00)
% Bin 26 @ 25 Hz : |S| = 25.00 (expected 25.00)
% Centred spectrum view using fftshift
f_centred = fftshift(f);
mag_centred = fftshift(mag);
Summary
| Function | Args | Feature flag |
|---|---|---|
fft(x) | real vector | fft |
fft(x, n) | real vector, length | fft |
ifft(X) | ComplexMatrix | fft |
fftshift(x) | real or complex matrix | always |
ifftshift(x) | real or complex matrix | always |
fftfreq(n, d) | count, spacing | always |
Dynamic Evaluation & Timing
eval — string execution
eval(str) executes a string as ccalc code in the current workspace.
Variables defined inside the string persist in the caller’s scope, matching
MATLAB/Octave semantics.
eval('x = sqrt(2)') % x is now defined in the workspace
x % → 1.4142…
eval('disp(pi)') % prints 3.14159…
Dynamic variable naming
A common idiom is building variable names at runtime with sprintf:
for k = 1:3
eval(sprintf('v%d = k*k', k))
end
v1 % → 1
v2 % → 4
v3 % → 9
Two-argument form — catching errors
eval(try_str, catch_str) executes catch_str if try_str raises an error.
The original error message is available via lasterr() inside the catch string.
eval('error(''oops'')', 'fprintf(''caught: %s\n'', lasterr())')
eval in expression context
When eval is used on the right-hand side of an assignment, it returns ans
from the inner execution. Variable mutations inside do not propagate back
to the caller’s workspace.
y = eval('2 + 2') % y = 4
Nesting
eval calls can be nested. The depth limit is 64 (shared with run/source).
tic / toc — elapsed time
tic starts (or restarts) a timer. toc reads the elapsed time in seconds
since the last tic.
tic
A = rand(500) * rand(500);
t = toc % → e.g. 0.0042 (seconds)
tic
for k = 1:1000
x = k^2;
end
fprintf('loop: %.4f s\n', toc)
Both tic and toc can be written with or without parentheses:
tic
t = toc
% same as
tic()
t = toc()
Multiple toc calls after a single tic are valid — the timer is not reset
by toc. Calling toc before any tic is an error.
See also
help eval— reference pagehelp control— control flowhelp errors— error handling
Plugins
ccalc’s built-in list is extended via a lightweight plugin system. A plugin
is a separate Rust crate that implements the Plugin trait and registers itself
at startup. The engine checks the plugin registry before its own built-in table,
so plugins can shadow existing built-ins when needed.
Writing a plugin
Add ccalc-engine as a dependency and implement the Plugin trait:
# my-plugin/Cargo.toml
[dependencies]
ccalc-engine = { path = "../ccalc-engine" }
#![allow(unused)]
fn main() {
use ccalc_engine::env::{Env, Value};
use ccalc_engine::plugin::Plugin;
pub struct MyPlugin;
impl Plugin for MyPlugin {
fn name(&self) -> &str { "myfunc" }
fn call(&self, _name: &str, args: &[Value], _env: &Env) -> Result<Value, String> {
if args.is_empty() {
return Err("myfunc: at least one argument required".into());
}
Ok(args[0].clone())
}
}
}
Exporting multiple names
A single plugin registration can expose several function names. Override
exported_names with a const-backed slice:
#![allow(unused)]
fn main() {
const NAMES: &[&str] = &["myfunc", "myother", "mythird"];
impl Plugin for MyPlugin {
fn name(&self) -> &str { "myfunc" }
fn exported_names(&self) -> &[&str] { NAMES }
fn call(&self, name: &str, args: &[Value], _env: &Env) -> Result<Value, String> {
// dispatch internally based on which name was called
match name {
"myfunc" => Ok(Value::Void),
"myother" => Ok(Value::Void),
_ => Err(format!("{name}: not implemented")),
}
}
}
}
All exported names appear in tab completion automatically.
Registering a plugin
In your fork of crates/ccalc/src/main.rs, call register_plugin after
exec::init():
#![allow(unused)]
fn main() {
fn run() {
ccalc_engine::exec::init();
ccalc_engine::plugin::register_plugin(Box::new(MyPlugin));
// …
}
}
Add your crate to the workspace and as a dependency of ccalc:
# Cargo.toml (workspace root)
[workspace]
members = ["crates/ccalc", "crates/ccalc-engine", "crates/my-plugin"]
# crates/ccalc/Cargo.toml
[dependencies]
my-plugin = { path = "../my-plugin" }
Built-in plugins
ccalc-plot is the reference plugin shipped with ccalc. It registers the
plot, scatter, bar, stem, xlabel, ylabel, and title names.
See Phase 29 — Plot engine for rendering details.
Plot Functions
ccalc supports terminal and file-based plotting via the ccalc-plot plugin crate.
Two rendering tiers are available:
| Feature flag | Backend | Enables |
|---|---|---|
plot | textplots | ASCII Braille chart printed to terminal |
plot-svg | plotters | SVG and PNG file export (default 800 × 600 px, customisable via figure) |
plot-all | both | terminal + file export |
Build with the desired tier:
cargo build --release --features plot # ASCII only
cargo build --release --features plot-svg # file export only
cargo build --release --features plot-all # both
Without a feature flag, calling a render function returns a helpful error suggesting
the rebuild command. Annotation functions (title, xlabel, ylabel, xlim, ylim,
legend, grid) always succeed in every build configuration.
Chart types
All chart functions accept an optional trailing file path. When the last string argument
ends in .svg or .png the chart is saved to that file (requires plot-svg).
Without a file path the chart is rendered to the terminal (requires plot).
plot(y) / plot(x, y) / plot(x, M)
Connected line chart.
y— row or column vector;xinferred as1:numel(y)when omitted.M— M×N matrix: each row is drawn as a separate series. In SVG/PNG mode each series gets a distinct colour from the 7-colour Octave palette;legendlabels the series.
x = linspace(0, 2*pi, 80);
plot(x, sin(x))
% multi-series
M = [sin(x); cos(x); 0.5*sin(2*x)];
legend('sin', 'cos', '0.5 sin(2x)')
plot(x, M)
scatter(y) / scatter(x, y)
Individual point cloud — use when connecting data points would imply false continuity.
t = linspace(-2, 2, 50);
scatter(t, t.^2 + 0.3*randn(size(t)))
bar(y) / bar(x, y)
Vertical bar chart. Bars extend from y = 0; negative values drop below the baseline.
Bar width is 40 % of the minimum x-spacing.
months = 1:12;
rain = [42 38 55 61 72 80 95 90 73 58 44 40];
xlabel('month')
ylabel('mm')
bar(months, rain)
stem(y) / stem(x, y)
Discrete-sequence plot: a vertical line from y = 0 to each tip, plus a circle marker.
Typical use: impulse/frequency responses and sampled signals.
n = 0:15;
stem(n, 0.8 .^ n)
stairs(y) / stairs(x, y)
Piecewise-constant (step-function) chart — each value is held until the next sample. Useful for zero-order-hold signals, quantised waveforms, and control outputs.
t = 0:0.5:4.5;
v = [0 0 1 1 2 2 1 1 0 0];
stairs(t, v)
hist(v) / hist(v, n) / hist(v, edges)
Histogram. ASCII output (character bars) requires no feature flag; SVG/PNG requires plot-svg.
| Call | Bin specification |
|---|---|
hist(v) | Sturges heuristic: max(1, round(sqrt(numel(v)))) bins |
hist(v, n) | Exactly n uniform bins |
hist(v, edges) | Caller-supplied edge vector (length k+1 defines k bins) |
data = randn(1, 200);
hist(data) % auto bins
hist(data, 20) % 20 uniform bins
hist(data, -3:0.5:3) % explicit edges
loglog(x, y) / semilogx(x, y) / semilogy(x, y)
Log-scale plots. Data is transformed with log₁₀ before rendering; non-positive values
are silently excluded. Axis labels are annotated with [log₁₀].
| Function | X axis | Y axis |
|---|---|---|
loglog | log₁₀ | log₁₀ |
semilogx | log₁₀ | linear |
semilogy | linear | log₁₀ |
f = 10 .^ linspace(1, 5, 80); % 10 Hz – 100 kHz
G = 1e6 * f .^ (-2);
loglog(f, G)
3D plots
plot3(x, y, z) / scatter3(x, y, z)
Three-dimensional line and point cloud plots. All three vectors must have the same length.
ASCII tier (--features plot): projects (x, y, z) onto a 2D plane using an
orthographic projection with MATLAB-compatible default view angles
(azimuth = −37.5°, elevation = 30°). The projected points are rendered with textplots.
xlabel / ylabel / zlabel appear as labeled footer lines below the chart.
File tier (--features plot-svg): uses the plotters 3D Cartesian chart engine
(build_cartesian_3d). plot3 draws a connected LineSeries; scatter3 draws
filled circles at each point.
% 3D helix — ASCII
t = linspace(0, 4*pi, 120);
title('3D helix')
xlabel('x = cos(t)')
ylabel('y = sin(t)')
zlabel('z = t/(4π)')
plot3(cos(t), sin(t), t/(4*pi))
% Lissajous 3D — save to SVG
t2 = linspace(0, 2*pi, 200);
title('Lissajous 3D')
plot3(sin(3*t2), sin(2*t2), cos(t2), 'lissajous.svg')
% 3D scatter
scatter3(randn(1,80), randn(1,80), randn(1,80), 'cloud.svg')
3D surface plots
meshgrid(x) / meshgrid(x, y)
Generates coordinate matrices for evaluating functions on a 2D grid —
the standard prerequisite for surf and mesh.
| Call | Result |
|---|---|
[X, Y] = meshgrid(x, y) | X is M×N (each row copies x); Y is M×N (each column copies y) |
[X, Y] = meshgrid(x) | square N×N grid using x for both axes |
X = meshgrid(x, y) | single-output form — returns only the X matrix |
[X, Y] = meshgrid(-2:0.1:2, -2:0.1:2);
Z = exp(-(X.^2 + Y.^2)); % Gaussian bell
surf(X, Y, Z) / surf(X, Y, Z, 'file.svg')
Colored 3D surface plot. X, Y, Z must all have the same dimensions (M×N
from meshgrid).
ASCII tier (--features plot): projects each column’s maximum Z as a
vertical bar — an elevation silhouette. Prints title, xlabel, ylabel,
zlabel as header/footer. colormap is ignored.
File tier (--features plot-svg): renders the surface as a colored grid
of row and column LineSeries, each segment colored by local Z value through
the active colormap. Chart axes: X horizontal, Z (our height) vertical, Y depth.
[X, Y] = meshgrid(-3:0.2:3, -3:0.2:3);
Z = sin(sqrt(X.^2 + Y.^2));
title('Sine wave surface')
colormap('viridis')
surf(X, Y, Z) % ASCII preview
surf(X, Y, Z, 'surface.svg') % SVG file
mesh(X, Y, Z) / mesh(X, Y, Z, 'file.png')
Wireframe 3D surface. Same API as surf; in ASCII mode the output is
identical. In file mode only row lines are drawn (no column fill lines),
giving a sparser wireframe appearance.
[X, Y] = meshgrid(-2:0.2:2, -2:0.2:2);
Z = X.^2 - Y.^2; % saddle surface
colormap('jet')
mesh(X, Y, Z, 'saddle.svg')
Both functions accept the same annotations as other plot functions
(title, xlabel, ylabel, zlabel, xlim, ylim, zlim,
colormap).
Contour plots
Render 2D isolines (contour lines) or filled contour regions for a scalar field defined on a meshgrid.
contour(X, Y, Z) / contour(X, Y, Z, n) / contour(X, Y, Z, n, 'file')
Draws n evenly-spaced contour isolines.
X,Y— coordinate matrices frommeshgrid.Z— scalar-field matrix, same size asXandY.n— number of contour levels (default10). Levels are placed evenly inside the Z range (never at the exact min/max).- Without a path: ASCII tier prints a character-art density map (dimensions
from
$COLUMNS×$LINES, default 80 × 24) where each character encodes the Z band of the corresponding sample point (palette:" .:-=+*#"). - With a
.svgor.pngpath: file tier draws each isoline as a coloredLineSeries, with colors cycling through the active colormap.
contourf(X, Y, Z) / contourf(X, Y, Z, n) / contourf(X, Y, Z, n, 'file')
Filled contour chart. Same API as contour.
- ASCII tier: identical to
contour(character-art density map). - File tier: colors each grid cell by its Z band using the active colormap, then draws the contour isolines on top.
Algorithm: marching squares (classic isoline extraction per 2×2 cell). The saddle-point ambiguity is resolved with the simple split convention.
[X, Y] = meshgrid(-2:0.05:2, -2:0.05:2);
Z = exp(-X .^ 2 - Y .^ 2);
% ASCII density map (10 levels)
contour(X, Y, Z)
% SVG with 8 levels
title('Gaussian bell')
xlabel('x')
ylabel('y')
contour(X, Y, Z, 8, 'gauss.svg')
% PNG filled contour
colormap('viridis')
contourf(X, Y, Z, 8, 'gauss_filled.png')
% Saddle function — shows both positive and negative regions
Z2 = X .* exp(-X .^ 2 - Y .^ 2);
colormap('hot')
contour(X, Y, Z2, 12, 'saddle.svg')
Both functions accept title, xlabel, ylabel, xlim, ylim, and
colormap annotations, which are consumed by the render call.
clabel()
Enables contour level labels for the next contour or contourf call.
The flag is consumed (cleared) by the render, matching the single-shot semantics
of grid, colorbar, and similar state annotations.
ASCII tier: prints a Levels: … footer line after the chart listing all level
values formatted to 2 decimal places.
File tier: places a text label at the midpoint of the longest marching-squares
segment for each level. Label color matches the isoline color; font size scales with
fontsize(n) (default 10 pt, proportional to the axis-label size).
[X, Y] = meshgrid(-2:0.05:2, -2:0.05:2);
Z = exp(-X .^ 2 - Y .^ 2);
% ASCII with level footer
clabel()
contour(X, Y, Z, 6)
% SVG with inline labels
title('Gaussian bell — labeled contours')
xlabel('x')
ylabel('y')
clabel()
contour(X, Y, Z, 8, 'gauss_labeled.svg')
% contourf also respects clabel()
colormap('viridis')
clabel()
contourf(X, Y, Z, 8, 'gauss_filled_labeled.svg')
Multi-panel layout
subplot, hold, and savefig work together to compose figures with multiple panels
or overlaid series.
subplot(rows, cols, index)
Activates panel index (1-based, row-major) in a rows × cols grid.
Once called, ccalc enters accumulating mode: all subsequent plot calls
(plot, scatter, bar, stem, stairs, hist, fill, area, quiver) are
buffered instead of rendered immediately. Annotations (title, xlabel, ylabel, xlim, ylim,
legend, grid, text) set after the render call are collected for the current panel
and consumed at commit time.
Calling subplot a second time commits the current panel and starts the next one.
savefig commits the last panel and writes the composed figure.
x = linspace(0, 2*pi, 60);
subplot(2, 2, 1);
title('sin(x)');
plot(x, sin(x));
subplot(2, 2, 2);
title('cos(x)');
plot(x, cos(x));
subplot(2, 2, 3);
bar([3 1 4 1 5 9 2 6]);
subplot(2, 2, 4);
hist(randn(1, 200), 20);
savefig('out.svg');
hold('on') / hold('off')
Overlay multiple series in a single chart panel.
hold('on')— enables accumulating mode; subsequent plot calls push series into the current panel without rendering.hold('off')— disables accumulating mode and, if nosubplotis active, immediately renders the accumulated series to the terminal (ASCII tier). For file output, callsavefigbeforehold('off').
x = linspace(0, 2*pi, 80);
% ASCII overlay: both series rendered at hold('off')
hold('on');
plot(x, sin(x));
plot(x, cos(x));
hold('off');
% File overlay via subplot + savefig
subplot(1, 1, 1);
title('sin and cos overlay');
hold('on');
plot(x, sin(x));
plot(x, cos(x));
hold('off');
savefig('overlay.svg');
savefig('path')
Commits the last pending panel and renders all accumulated panels to a single
SVG or PNG file (requires --features plot-svg). The grid layout is determined
by the rows × cols dimensions passed to the subplot calls.
When used without subplot (only with hold), the single panel fills the
entire canvas.
False-colour images (imagesc)
Render a matrix as a heat-map — each cell is coloured according to its value.
imagesc(Z) / imagesc(Z, path)
Z— any numeric matrix.- Without a path: ASCII tier prints a character-art grid using 10 density
characters (
" .:-=+*#@█") mapped fromZ_mintoZ_max. Grid dimensions adapt to terminal width ($COLUMNS, default 80). - With a
.svgor.pngpath: file tier draws one filledRectangleper cell, scaled to the canvas. Canvas size comes fromfigure(w, h)(default 800 × 600 px). Requires--features plot-svg.
colormap('name') / colormap(M)
Set the active colormap for the next imagesc call (consumed and cleared
together with other FigureState annotations). Case-insensitive.
Named colormaps:
| Name | Description |
|---|---|
viridis | Perceptually uniform, blue → green → yellow (default) |
inferno | Black → purple → orange → white |
magma | Black → purple → pink → white |
plasma | Blue-purple → orange → yellow |
hot | Black → red → yellow → white |
cool | Cyan → magenta |
jet | Classic MATLAB: blue → cyan → green → yellow → red |
gray | Black → white (monochrome) |
Custom colormap from matrix:
Pass an N×3 matrix where each row is an RGB control point with values in [0, 1]. The colormap is linearly interpolated between control points.
% Two-stop blue → red
colormap([0 0 1; 1 0 0])
imagesc(Z, 'heat.svg')
% Three-stop blue → yellow → red
colormap([0 0 1; 1 1 0; 1 0 0])
imagesc(Z, 'custom.svg')
colorbar()
Appends a colour-scale legend strip to the right side of the exported image (80 px wide, with 5 tick labels at 0 %, 25 %, 50 %, 75 %, 100 % of the data range). Silently ignored in ASCII mode.
% ASCII heat-map
Z = reshape(1:100, 10, 10);
imagesc(Z)
% SVG with viridis colormap and colorbar
colormap('viridis')
colorbar()
title('Signal strength')
imagesc(Z, 'heat.svg')
% Mandelbrot set — colormap changes false-colour appearance
N = 200; max_iter = 60;
x = linspace(-2.5, 1.0, N);
y = linspace(-1.2, 1.2, N);
Z = zeros(N, N);
for row = 1:N
for col = 1:N
c = x(col) + y(row)*1i;
z = 0;
for k = 1:max_iter
if abs(z) > 2, break; end
z = z^2 + c;
end
Z(row, col) = k;
end
end
colormap('inferno')
colorbar()
title('Mandelbrot set')
imagesc(Z, 'mandelbrot.svg')
image(Z) / image(Z, path)
MATLAB alias for imagesc — identical behaviour in every way. Use image when
compatibility with MATLAB scripts is preferred.
colormap('hot')
image(Z, 'heat.svg') % same as imagesc(Z, 'heat.svg') with hot colormap
imshow(Z) / imshow(Z, path)
Displays Z as a grayscale image using clamp-to-[0,1] normalisation.
Unlike imagesc, the data is not min/max scaled: values above 1.0 map to
white and values below 0.0 map to black. Values in [0, 1] map directly to
gray intensity.
- Typical use: images already normalised to a
[0, 1]intensity range. - ASCII tier: 10-level density palette
" .:-=+*#@█". - File tier: one gray
Rectangleper cell;gray = clamp(v, 0, 1) × 255.
% Load / generate a normalised grayscale image
n = 64;
Z = rand(n, n); % random noise in [0,1]
imshow(Z, 'noise.png') % displayed as-is — no scaling applied
% Values outside [0,1] are clamped, not scaled
Z2 = 2 * rand(n, n); % values in [0,2] — upper half maps to white
imshow(Z2, 'bright.png')
imshow(R, G, B) / imshow(R, G, B, path)
Displays a colour image from three separate channel matrices. R, G, and B
must all have the same dimensions; each component is clamped to [0, 1].
- ASCII tier: computes luminance
L = 0.299·R + 0.587·G + 0.114·Bper pixel and renders the equivalent grayscale with the density palette. This produces the same output asimshow(L). - File tier: one filled
Rectangleper pixel, RGB colour from the three channel values. For a 128×128 image this produces 16 384 rectangles — use PNG output to keep file size reasonable.
n = 128;
[X, Y] = meshgrid(linspace(0, 4*pi, n), linspace(0, 4*pi, n));
C = sqrt((X - 2*pi).^2 + (Y - 2*pi).^2); % radial ripple component
R = 0.5 + 0.5 * sin(X + C);
G = 0.5 + 0.5 * sin(Y + C / 2);
B = 0.5 + 0.5 * sin(X/2 + Y/2 + C);
title('Plasma interference (128×128)')
imshow(R, G, B, 'plasma.png')
Comparison — imagesc vs imshow:
| Behaviour | imagesc(Z) | imshow(Z) |
|---|---|---|
| Normalisation | min/max scale to [0,1] | clamp to [0,1] |
| Colormap | active colormap (default viridis) | gray only |
| Values > 1.0 | map to colormap maximum | white |
| Values < 0.0 | map to colormap minimum | black |
| RGB channels | no | yes (imshow(R, G, B)) |
Style strings and colors
Color specification forms
Five ways to specify a color, accepted by all plot functions that support styling:
| Form | Example | Description |
|---|---|---|
| Single-letter code | 'r', 'b' | MATLAB-compatible short codes |
| Full color name | 'red', 'orange' | Full English names |
Hex #RRGGBB | '#FF4400' | 24-bit hex color |
| 1×3 RGB matrix | [1 0.27 0] | Row vector with values in [0, 1] |
'color', value | 'color', 'red' | Named argument (for bar/stem/hist/quiver) |
Single-letter codes:
| Code | Color | Code | Color |
|---|---|---|---|
r | red | c | cyan |
g | green | m | magenta |
b | blue | y | yellow |
k | black | w | white |
Additional named colors (full names only, not single-letter):
orange, purple, gray / grey
Style strings for plot, scatter, fill, area
These functions accept an optional MATLAB-compatible style string before the file path. The string combines a color code, a marker code, and/or a line-style code in any order.
| Code | Meaning |
|---|---|
r g b c m y k w | Single-letter color |
full name or #RRGGBB | Full color name or hex (style string is the entire argument) |
. o x + * s d ^ | Marker (file export only) |
- | Solid line (default) |
-- | Dashed line |
-. | Dash-dot line |
: | Dotted line |
x = linspace(0, 2*pi, 80);
% Single-letter code: red dashed line
plot(x, sin(x), 'r--')
% Full color name
plot(x, sin(x), 'orange')
% Hex color
plot(x, cos(x), '#1A6ECC')
% 1×3 RGB matrix (values in [0, 1])
plot(x, sin(x), [0.8 0.2 0.1])
% Blue scatter with dot markers
scatter(x, cos(x), 'b.')
% Green solid line to SVG
plot(x, sin(x), 'g-', 'wave.svg')
% Red fill
fill([0, 1, 0.5], [0, 0, 1], 'r', 'tri.svg')
Color for bar, stem, hist, quiver
These functions do not use a trailing style string (to avoid ambiguity with
data arguments). Use the 'color' named argument instead:
% Color name
bar([1 3 2 5 4], 'color', 'red')
% Hex color
stem(x, sin(x), 'color', '#FF8800')
% Full name in hist
hist(randn(1, 500), 20, 'color', 'purple')
% Quiver with named color
[X, Y] = meshgrid(-2:2, -2:2);
quiver(X, Y, -Y, X, 'color', 'blue')
% RGB matrix form also works
bar([1 3 2 5 4], 'color', [0.2 0.6 1.0])
Note: In ASCII (textplots) mode, color and line-style are ignored because the backend is monochrome Braille. Style specifications still parse without error.
Filled polygons and areas
fill(x, y) / fill(x, y, style) / fill(x, y, style, 'file')
Filled polygon. x and y are coordinate vectors of the polygon vertices; the
shape is automatically closed (last vertex connects back to the first).
ASCII tier: prints a bounding-box density block with a ░ fill character plus
an outline using textplots.
File tier: draws a plotters Polygon element filled at 40 % opacity, with the
full-opacity outline drawn as a LineSeries on top.
% Filled triangle
fill([0, 1, 0.5], [0, 0, 1])
% Red-filled triangle → SVG
fill([0, 1, 0.5], [0, 0, 1], 'r', 'triangle.svg')
area(y) / area(x, y) / area(x, y, style) / area(x, y, style, 'file')
Filled area under a curve. The curve is closed along y = 0 to form a polygon
(equivalent to fill with an added baseline segment).
x = linspace(0, 2*pi, 80);
% ASCII area preview
area(x, sin(x) + 1)
% Blue area under sine wave → SVG
area(x, sin(x) + 1, 'b', 'area_sine.svg')
Drawing primitives
Phase 32a adds three low-level drawing functions that complement fill and area.
All three participate in hold/subplot accumulation and savefig exactly like
the other chart functions.
line(x, y) / line(x, y, style) / line(x, y, style, 'file')
MATLAB-compatible alias for plot.
Accepts the same arguments, style strings, and file-export path.
x = linspace(0, 2*pi, 64);
line(x, sin(x), 'b-', 'sine.svg')
patch(x, y) / patch(x, y, color) / patch(x, y, color, 'file')
MATLAB-compatible alias for fill.
Draws a filled polygon from vertex vectors x and y.
% Cyan-filled triangle → SVG
patch([0, 1, 0.5], [0, 0, 1], 'c', 'triangle.svg')
rectangle(x, y, w, h) / rectangle([x y w h]) / rectangle(..., color) / rectangle(..., color, 'file')
Draws an axis-aligned filled rectangle defined by its origin (x, y),
width, and height. The bounding box is converted to a 4-vertex polygon
[x, x+w, x+w, x] × [y, y, y+h, y+h] and rendered via render_fill_xy.
Two input forms:
| Form | Syntax |
|---|---|
| 4-scalar | rectangle(x, y, w, h) |
| vector | rectangle([x y w h]) |
% Green rectangle (4-scalar form) → SVG
rectangle(0.1, 0.2, 0.6, 0.4, 'g', 'rect.svg')
% Magenta rectangle (vector form) → SVG
rectangle([0.1, 0.2, 0.6, 0.4], 'm', 'rect_vec.svg')
% Combined: sine curve inside a bounding box
hold('on')
line(x, sin(x), 'b-')
rectangle(0, -1, 2*pi, 2, 'k--')
title('sine + bounding box')
savefig('sine_box.svg')
See also: examples/primitives_demo/primitives_demo.calc
Statistical extensions
Phase 32b adds two statistical chart functions.
errorbar(x, y, e) / errorbar(x, y, e, style) / errorbar(x, y, e, style, 'file')
Draws a line plot with symmetric error bars: each point (x[i], y[i]) gets
a vertical cap spanning [y[i] - e[i], y[i] + e[i]].
errorbar(x, y, e_low, e_high) / errorbar(x, y, e_low, e_high, style) / errorbar(x, y, e_low, e_high, style, 'file')
Asymmetric form: the lower extent is y[i] - e_low[i] and the upper extent
is y[i] + e_high[i], allowing different uncertainties in each direction.
All arrays must have the same length. The optional style argument accepts the
same color/line-style strings as plot. Without a file path the result is
printed as a compact ASCII table with ± notation.
ASCII tier: compact table x | y ± e (or x | y + e_high - e_low).
File tier: three PathElement segments per point (vertical shaft, lower cap,
upper cap) plus a Circle centre dot. Cap width = 3 % of the x-axis range.
x = 1:5;
y = [2.1, 3.4, 2.8, 4.2, 3.7];
% Symmetric — same error on each side
e = [0.3, 0.2, 0.4, 0.25, 0.35];
xlabel('Sample')
ylabel('Value')
title('Symmetric error bars')
errorbar(x, y, e, 'b', 'errorbar_sym.svg')
% Asymmetric
e_low = [0.1, 0.3, 0.2, 0.15, 0.4];
e_high = [0.4, 0.1, 0.5, 0.3, 0.2];
errorbar(x, y, e_low, e_high, 'r', 'errorbar_asym.svg')
% Overlay errorbar on a line plot (hold mode)
hold('on')
plot(1:5, 0.8*(1:5) + 0.5, 'k--')
errorbar(x, y, e, 'b')
title('Line + error bars')
savefig('errorbar_with_line.svg')
scatter(x, y, sz, c) — per-point color form
When scatter receives four numeric arguments (x, y, sz, c), each point
is colored individually by mapping the scalar c[i] through the active colormap
(default: viridis).
sz— marker radius in pixels. Either a scalar (broadcast to all points) or a vector of the same length asx.c— scalar color values; automatically normalized to[min(c), max(c)]before the colormap lookup.- Change the colormap with
colormap(name)before thescattercall.
ASCII tier: degrades gracefully to a monochrome textplots scatter chart.
File tier: each point is a Circle element whose fill color comes from
apply_colormap_spec(c_normalized).
n = 20;
x = linspace(0, 2*pi, n);
y = sin(x);
c = cos(x); % values drive the colormap
% Uniform size, viridis (default)
scatter(x, y, 6, c, 'scatter_viridis.svg')
% Per-point size, jet colormap
colormap('jet')
sz = 3 + 7 * (c - min(c)) / (max(c) - min(c));
scatter(x, y, sz, c, 'scatter_jet.svg')
% Two ColorScatter series in hold mode
x2 = linspace(0, 2*pi, n);
y2 = cos(x2);
hold('on')
scatter(x, y, 5, c)
scatter(x2, y2, 5, sin(x2))
title('Two ColorScatter series')
savefig('scatter_hold.svg')
See also: examples/errorbar_demo/errorbar_demo.calc,
examples/scatter_color_demo/scatter_color_demo.calc
Pie charts
Phase 32c adds pie chart support through the pie function.
pie(v) / pie(v, labels) / pie(v, explode) / pie(v, explode, labels) / pie(v, ..., 'file')
Renders a proportional pie chart from the numeric vector v. Each slice covers
an angular fraction equal to v[i] / sum(v). Values must be non-negative and
their sum must be positive.
Argument type detection (flexible ordering):
Cellarray of strings → slice labels.- Numeric vector (same length as
v) → per-slice explode offsets (see below). - String ending in
.svg/.png→ output file path (requiresplot-svg).
Explode: when explode[i] > 0, slice i is shifted radially outward by
explode[i] × 0.08 × r from the chart center.
ASCII tier: horizontal bar-art table with a 20-character bar per slice.
Four rotating fill characters (█ ▓ ▒ ░) visually distinguish slices; empty
bar space is filled with ·; a : marker appears at the midpoint (position 10)
of every bar for scale reference; exploded slices get a ◄ suffix after the label.
pie chart:
Work ████████··········:·········· 30.0% ◄
Sleep █████·············:·········· 20.0%
Exercise ████··············:·········· 15.0%
Leisure ██████████········:·········· 25.0%
Eating ██················:·········· 10.0%
File tier: one Polygon wedge per slice built from 64 arc points plus the
center point (65 vertices total). The chart is drawn in a (-1..1) × (-1..1)
Cartesian space with axes and mesh hidden. Labels are placed at radius
r × 1.18 using Text elements. Slices cycle through the 7-color Octave
palette; set colormap('name') before calling pie to use a different palette.
v = [30, 20, 15, 25, 10];
labels = {'Work', 'Sleep', 'Exercise', 'Leisure', 'Eating'};
% ASCII output
pie(v)
pie(v, labels)
% Explode first slice outward
explode = [0.1, 0, 0, 0, 0];
pie(v, explode, labels)
% File export
pie(v, 'pie_basic.svg')
pie(v, labels, 'pie_labels.svg')
pie(v, explode, labels, 'pie_explode.svg')
See also: examples/pie_demo/pie_demo.calc
Dual Y axis
Phase 32d adds dual Y-axis support through yyaxis.
yyaxis('left') / yyaxis('right')
Switches the active Y axis. All subsequent plot, scatter, ylabel, and
ylim calls are routed to that axis until the axis is switched again.
Both calls implicitly enable hold mode so that series from both sides accumulate before rendering. The chart is flushed automatically when:
yyaxis('left')is called again while right-axis series are pending (i.e. at the start of the next dual-axis block), orsavefig('path.svg')commits all pending panels to a file.
Call hold('off') to render the chart to the terminal immediately without
starting a new block.
ASCII rendering draws both curves on a single character grid; left-axis
series use . and right-axis series use *. The footer lines show the actual
Y range for each axis:
Temperature and Humidity
+------------------------------------------------------------------------+
| ***** |
| .*** ******** |
| ..*** ***** |
| ...*** *** |
+------------------------------------------------------------------------+
x: Time (h)
y (left) . : Temperature (C) [18 .. 23]
y (right) * : Humidity (%) [60 .. 70]
SVG / PNG rendering uses the plotters DualCoordChartContext so the left
and right Y axes each carry independent tick labels and optional grid lines.
t = [0, 1, 2, 3, 4, 5];
temp_C = [18, 19, 21, 23, 22, 20];
humid_p = [60, 62, 65, 70, 68, 64];
% ASCII output — renders automatically when the next yyaxis block begins
yyaxis('left');
ylabel('Temperature (C)');
plot(t, temp_C, 'b-');
yyaxis('right');
ylabel('Humidity (%)');
plot(t, humid_p, 'r--');
xlabel('Time (h)');
title('Temperature and Humidity');
% SVG output
yyaxis('left'); % <-- also flushes the ASCII chart above
ylabel('Temperature (C)');
plot(t, temp_C, 'b-');
yyaxis('right');
ylabel('Humidity (%)');
plot(t, humid_p, 'r--');
xlabel('Time (h)');
title('Temperature and Humidity');
savefig('examples/yyaxis_demo/output/yyaxis_basic.svg');
See also: examples/yyaxis_demo/yyaxis_demo.calc
Polar plots
polar(theta, r) / polar(theta, r, style) / polar(theta, r, 'file')
Converts polar coordinates (r, theta) to Cartesian (x, y) using
x = r·cos(θ), y = r·sin(θ) and renders a connected line plot.
theta is in radians.
theta = linspace(0, 2*pi, 200);
% Unit circle
polar(theta, ones(size(theta)))
% Rose curve: r = |cos(2θ)|
polar(theta, abs(cos(2*theta)), 'rose.svg')
% Archimedean spiral: r = θ/(2π)
polar(theta, theta / (2*pi), 'spiral.svg')
Vector field plots
quiver(x, y, u, v) / quiver(x, y, u, v, 'file')
Draws a vector field: at each point (x[i], y[i]) an arrow is drawn in the
direction (u[i], v[i]).
- All four arrays must have the same length (or the same total element count when meshgrid matrices are passed — they are flattened in row-major order).
- Arrow scale: the longest arrow is normalised to 80 % of the minimum grid spacing, so arrows never overlap adjacent grid cells.
ASCII tier: places a Unicode directional arrow character (→ ↗ ↑ ↖ ← ↙ ↓ ↘)
at the grid position of each origin point.
File tier: each arrow is drawn as a shaft (PathElement) plus a filled
triangular arrowhead at the tip.
% Simple rotational flow: u = -y, v = x
[X, Y] = meshgrid(-2:1:2, -2:1:2);
U = -Y;
V = X;
% ASCII render
title('Rotational flow')
quiver(X, Y, U, V)
% SVG export
quiver(X, Y, U, V, 'flow.svg')
Text annotations
text(x, y, 'str') / text(x, y, 'str', 'file')
Places a text label at the data coordinates (x, y).
Text annotations are stored in FigureState.annotations and are flushed
alongside plot data at the next render call or at savefig / hold('off').
They do not trigger an immediate render on their own.
ASCII tier: annotations are printed below the chart as
(x, y): label lines.
File tier: annotations are drawn as Text elements at their data
coordinates using a 12-pt sans-serif font.
% Annotate a quiver plot
text(0.0, 0.0, 'origin')
text(2.0, 2.0, 'tip region')
quiver(x, y, u, v, 'annotated.svg')
% Annotate any plot
x = linspace(0, 2*pi, 80);
text(pi/2, 1.0, 'peak')
text(3*pi/2, -1.0, 'trough')
plot(x, sin(x), 'sine.svg')
Canvas size
File export: figure(width, height)
Sets the output canvas size in pixels for the next SVG or PNG export.
Applies to all file-export functions: plot, scatter, bar, hist, fill,
area, polar, quiver, surf, mesh, contour, contourf, and savefig.
- Width and height must be integers in the range 1–16384.
- The size persists across panels (like
colormap) and is cleared when the figure state resets after a render. - Has no effect in ASCII (terminal) mode — ASCII chart dimensions follow the terminal size instead (see below).
% Wide landscape chart
figure(1200, 400)
plot(x, sin(x), 'wide.svg')
% Square heatmap
figure(600, 600)
colormap('viridis')
imagesc(Z, 'square.svg')
% Multi-panel at HD resolution
figure(1920, 1080)
subplot(2, 2, 1); plot(x, sin(x)); title('sin')
subplot(2, 2, 2); plot(x, cos(x)); title('cos')
subplot(2, 2, 3); bar([1 2 3 4]);
subplot(2, 2, 4); hist(randn(1, 200), 20);
savefig('hd_grid.png')
ASCII output: terminal auto-detection
ASCII charts automatically adapt to the terminal size by reading the standard
environment variables $COLUMNS (width, default 80) and $LINES (height,
default 24) at render time.
| Chart type | Uses $COLUMNS | Uses $LINES |
|---|---|---|
plot, scatter, bar, stem, stairs | Yes (Braille canvas width) | Yes (Braille canvas height) |
fill, area | Yes (character grid) | Yes |
hist | Yes (bar width) | — |
contour, contourf | Yes | Yes |
surf, mesh | — | Yes (elevation height) |
quiver | Yes | Yes |
Set these variables in your shell before running ccalc to get larger charts:
export COLUMNS=120
export LINES=40
ccalc
Or inline for a single script:
COLUMNS=120 LINES=40 ccalc -q myscript.calc
Figure appearance
The following functions adjust the visual appearance of the next rendered figure.
Like other annotations, they are stored in FigureState and consumed by the next render call.
These settings apply to SVG/PNG file output only; ASCII charts are monochrome and their
geometry is fixed by the terminal size.
Theme and background color
| Function | Effect |
|---|---|
theme('light') | Light theme: white background, black text and axes (default) |
theme('dark') | Dark theme: Catppuccin Mocha palette (#1E1E2E bg, #CDD6F4 text) |
bgcolor(color) | Override the figure background color only (beats the theme) |
bgcolor accepts any color specification: a color name string,
a hex code '#RRGGBB', or a 1×3 RGB matrix with values in [0, 1].
theme('dark')
plot(x, sin(x), 'sin_dark.svg')
bgcolor('#F5F5F5') % light grey background, keeps other defaults
plot(x, cos(x), 'cos_grey.svg')
Font and stroke sizes
| Function | Effect |
|---|---|
fontsize(n) | Override title and axis-label font size (pixels) |
linewidth(f) | Override default line stroke width for all series (pixels) |
markersize(n) | Override default marker radius for all series (pixels) |
Per-series overrides are applied via named arguments appended to a single plot call:
plot(x, y, 'r--', 'linewidth', 2) % thick red dashed line
scatter(x, y, 'markersize', 5) % larger dot markers
plot(x, y, 'linewidth', 1.5, 'markersize', 4)
Figure-level overrides apply to all series unless a per-series value is present:
fontsize(14)
linewidth(2)
title('Thick lines')
plot(x, sin(x), 'sin_thick.svg')
Grid color and width
| Function | Effect |
|---|---|
gridcolor(color) | Override both bold and light grid line color |
gridwidth(n) | Override grid line stroke width (pixels) |
Requires grid('on') to have any visible effect.
grid('on')
gridcolor('#4080FF')
gridwidth(0.5)
plot(x, sin(x), 'blue_grid.svg')
Axis mode
| Call | Effect |
|---|---|
axis('equal') | Equal scaling — same data-units per pixel on both axes |
axis('tight') | No margin — data range fills the chart area exactly |
axis('off') | Hide all axis decorations (lines, ticks, labels) |
axis('on') | Restore default axis display (cancels a previous axis call) |
t = linspace(0, 2*pi, 120);
axis('equal')
plot(cos(t), sin(t), 'circle.svg') % unit circle appears as a circle
axis('tight')
bar([3 1 4 1 5], 'tight_bar.svg') % bars fill the chart with no margin
axis('off')
imagesc(Z, 'clean.svg') % image only, no axis decorations
axis('equal') expands the tighter axis so data-units-per-pixel are equal on both axes.
axis('tight') removes the default 5 % margin around the data range.
Both apply to SVG/PNG output; ASCII charts are unaffected.
File export
Append a file path as the last string argument (after the optional style string):
| Extension | Format | Notes |
|---|---|---|
'.svg' | SVG vector graphic | Opens in any browser |
'.png' | PNG raster | Default 800 × 600 px; override with figure(w, h) |
'ascii' | Terminal chart | Forces ASCII even with plot-svg active |
imagesc always writes to a file (never prints a file path to the terminal).
The colormap and colorbar annotations apply only to imagesc.
x = linspace(0, 2*pi, 200);
title('sin(x)')
xlabel('x (radians)')
ylabel('amplitude')
plot(x, sin(x), 'wave.svg')
hist(randn(1, 500), 'dist.png')
Annotation functions
Set annotations before the render call. All annotations are stored in a thread-local
FigureState and consumed (cleared) by the next render call.
title('My Chart')
xlabel('time (s)')
ylabel('amplitude')
xlim([0, 10])
ylim([-1.2, 1.2])
grid('on')
plot(t, y) % all annotations applied here, then cleared
| Function | Effect | Works without feature |
|---|---|---|
title('text') | Chart title | Yes |
xlabel('text') | X-axis label | Yes |
ylabel('text') | Y-axis label | Yes |
zlabel('text') | Z-axis label (consumed by plot3/scatter3) | Yes |
xlim([lo, hi]) | Override x-axis range | Yes |
ylim([lo, hi]) | Override y-axis range | Yes |
zlim([lo, hi]) | Override z-axis range (3D file export) | Yes |
legend(s1, s2, …) | Series labels — applied in SVG/PNG multi-series charts | Yes |
grid | Toggle grid on/off | Yes |
grid('on') | Enable grid | Yes |
grid('off') | Disable grid | Yes |
colormap('name') | Set colormap for next imagesc / surf / mesh / contour | Yes |
colorbar() | Append colour-scale strip (file export only, imagesc) | Yes |
clabel() | Enable level labels on the next contour / contourf render | Yes |
figure(w, h) | Set SVG/PNG canvas size in pixels (1–16384); ASCII ignores it | Yes |
text(x, y, 's') | Add label at data coordinate — flushed with next render | Yes |
theme('light'|'dark') | Set colour theme (SVG/PNG only) | Yes |
bgcolor(color) | Override figure background color (beats theme) | Yes |
fontsize(n) | Override title and axis-label font size in pixels | Yes |
linewidth(f) | Override default line stroke width for all series | Yes |
markersize(n) | Override default marker radius for all series | Yes |
gridcolor(color) | Override grid line color (requires grid('on')) | Yes |
gridwidth(n) | Override grid line stroke width (requires grid('on')) | Yes |
axis('equal'|'tight'|'off'|'on') | Axis scale mode / visibility (SVG/PNG only) | Yes |
Grid defaults to off. The grid is visible in SVG/PNG output only; ASCII charts ignore it.
Annotations not consumed before a second render call are not carried over:
title('First plot')
plot(x, y1, 'a.svg') % title applied
plot(x, y2, 'b.svg') % no title — state was cleared by first render
SVG/PNG chart properties
- Size (file export): 800 × 600 px by default; override with
figure(width, height)(1–16384 px). - Size (ASCII): adapts to terminal
$COLUMNS×$LINES(defaults 80 × 24). - Colours (multi-series): 7-colour Octave palette — blue, orange, yellow, purple, green, cyan, dark red — cycling as needed.
- Line plots:
LineSeries(1 px, series colour). - Scatter plots: filled circles, 3 px radius.
- Per-point color scatter (
scatter(x,y,sz,c)):Circleelements; each fill color mapped through the active colormap; radius fromsz(scalar or per-point vector). - Contour labels (
clabel()beforecontour/contourf): oneTextelement per level, placed at the midpoint of the longest segment; color matches the isoline; font size scales withfontsize(n). - Error bars (
errorbar): threePathElementsegments (shaft + two caps) plus aCirclecentre dot per data point; cap width = 3 % of x-range. - Pie charts (
pie): onePolygonwedge per slice (64 arc points + center); axes and mesh disabled; labels viaTextat radius × 1.18; explode offsets along slice bisector. - Bar charts: edge-to-edge
Rectangleseries; negative bars extend below baseline. - Stem plots:
PathElementvertical lines +Circletip markers (4 px). - Histograms: edge-to-edge
Rectanglebins (blue fill). - 3D line plots (
plot3):LineSeriesover(f64, f64, f64)tuples viaplotters3D Cartesian chart (build_cartesian_3d). - 3D scatter plots (
scatter3):Circleelements at each 3D coordinate. - 3D surface plots (
surf): colored row + columnLineSeriesgrid on a 3D Cartesian chart; each line colored by local Z mean through the active colormap. - 3D wireframe plots (
mesh): row-onlyLineSeriesgrid (sparser thansurf). - False-colour images (
imagesc/image): oneRectangleper matrix cell, RGB colour from the active colormap LUT; optional 80 px colorbar strip on the right. - Grayscale images (
imshow(Z)): one grayRectangleper cell; intensity =clamp(v, 0, 1) × 255— no min/max scaling. - RGB images (
imshow(R, G, B)): oneRectangleper pixel; colour from the three channel values clamped to[0, 1]. PNG output recommended for large images. - Axis range: auto-computed from data with 5 % margin by default.
axis('tight')removes the margin;axis('equal')enforces equal data-units/pixel;axis('off')hides all axis decorations. Single-point data uses ± 1. - Legend: shown when
legend(...)is set; drawn in the upper-right corner with a black border.
Examples
examples/plot_file/plot_demo.calc— ASCIIplot/scatter, annotationsexamples/plot_file/plot_file.calc—plot/scatterto SVG/PNGexamples/plot_extended_file/plot_extended.calc—bar,stem,stairs,hist,loglog/semilogx/semilogy, multi-series,xlim/ylim/grid(ASCII)examples/plot_extended_file/plot_extended_file.calc— same chart types exported to SVG/PNG, multi-series withlegend+grid, histogram variantsexamples/plot3_file/plot3_demo.calc—plot3/scatter3ASCII 3D plotsexamples/plot3_file/plot3_file.calc—plot3/scatter3exported to SVG/PNGexamples/colormap/imagesc_demo.calc—imagescwith all 8 colormaps + colorbarexamples/colormap/mandelbrot.calc— Mandelbrot set rendered withcolormap('inferno')examples/colormap/julia.calc— Julia set rendered withcolormap('magma')examples/surf_demo/surf_demo.calc— sine wave surface + Gaussian bell (surf)examples/surf_demo/mesh_demo.calc— sine wave wireframe + saddle surface (mesh)examples/contour_demo/contour_demo.calc—contour,contourf, andclabel()level labels on Gaussian bell + saddleexamples/subplot_demo/subplot_demo.calc— 2×2 grid: sin, cos, bar, hist (SVG export)examples/hold_demo/hold_demo.calc— overlaid sin and cos series usinghold on/offexamples/fill_area_polar_demo/fill_area_polar_demo.calc—fill,area,polar, style stringsexamples/quiver_demo/quiver_demo.calc— vector field with Unicode arrow gridexamples/color_system_demo/color_system_demo.calc— Phase 30.5 unified color system: custom colormaps, full names, hex, RGB matrix,'color'named arg for bar/stem/hist/quiverexamples/figure_appearance_demo/figure_appearance_demo.calc— Phase 30.6 figure appearance:theme,bgcolor,fontsize,linewidth,markersize,gridcolor,gridwidth,axisexamples/primitives_demo/primitives_demo.calc— Phase 32a:line,patch,rectanglein hold mode and standaloneexamples/errorbar_demo/errorbar_demo.calc— Phase 32b: symmetric and asymmetricerrorbar, hold-mode overlay withplotexamples/scatter_color_demo/scatter_color_demo.calc— Phase 32b: per-point colorscatter(x,y,sz,c)with viridis/jet colormaps and hold modeexamples/pie_demo/pie_demo.calc— Phase 32c:piechart with labels, explode, and file exportexamples/yyaxis_demo/yyaxis_demo.calc— Phase 32d: dual Y-axis — temperature vs humidity (ASCII + SVG), population vs growth rate (SVG)examples/contour_demo/contour_demo.calcalready covers Phase 32e (clabel()calls included)examples/imshow_demo/imshow_demo.calc— Phase 32f:image/imshowgrayscale and RGB; includes a 128×128 plasma interference pattern saved as PNG
See also
- Plugins — how the
ccalc-plotplugin is registered - Run
help plotin the REPL for a compact quick reference
Architecture
Internal design of ccalc: workspace layout, data flow, module responsibilities, and design principles that guide every implementation decision.
Contents
| Topic | What you will find |
|---|---|
| Overview | Workspace layout, data flow, dependency graph, design principles |
| Engine Crate | ccalc-engine public API, Value enum, Env type |
| Parser | Tokenizer, recursive-descent grammar, Stmt enum |
| Evaluator | AST evaluation, built-in dispatch, exec_stmts |
Architecture Overview
Workspace layout
ccalc/
├── Cargo.toml ← [workspace] — single version source
├── crates/
│ ├── ccalc/ ← binary crate (CLI)
│ │ └── src/
│ │ ├── main.rs ← entry point, mode detection
│ │ ├── repl.rs ← REPL loop, pipe mode, evaluate()
│ │ └── help.rs ← help text
│ └── ccalc-engine/ ← library crate (computation)
│ ├── src/
│ │ ├── lib.rs ← public API
│ │ ├── env.rs ← Env/Value types, workspace save/load
│ │ ├── eval.rs ← AST + evaluator + formatters + Base enum
│ │ ├── parser.rs ← tokenizer + recursive-descent parser, Stmt enum
│ │ ├── exec.rs ← block executor, exec_stmts(), BODY_CHUNK_CACHE
│ │ ├── io.rs ← IoContext — file descriptor table for fopen/fgetl/etc.
│ │ └── vm/ ← bytecode compiler + stack VM (Phase 34b)
│ │ ├── mod.rs ← Opcode, Instr (8 B), Chunk, IterState, CompileError
│ │ ├── compile.rs ← compile(&[StmtEntry]) → Chunk; is_compilable()
│ │ └── exec.rs ← vm_exec(chunk, env, …)
│ └── benches/
│ └── engine.rs ← Criterion benchmark suite
└── docs/ ← this mdBook
Data flow
User input (String)
│
▼
parser::parse_stmts(input) → Vec<StmtEntry> (AST — unchanged)
│
▼
vm::compile::compile(&stmts)
│ Ok(Chunk) ──────────────────────────────┐
│ Err(Unsupported) │
▼ ▼
exec::exec_stmts (tree-walker) vm::exec::vm_exec(chunk, env, …)
│ │
└─────────────────────┬─────────────────────┘
▼
Value → format → stdout
exec_stmts attempts to compile the statement block on every call.
If compilation succeeds the VM executes it without per-statement recursion;
otherwise the tree-walker runs unchanged. The public API is unaffected.
Module responsibilities
| Module | Responsibility |
|---|---|
main.rs | Parse CLI args, detect stdin mode (REPL / pipe / file / arg), dispatch |
repl.rs | REPL event loop, pipe line-reader, shared evaluate(), display logic |
help.rs | Static help text |
env.rs | Value enum, Env type (HashMap<String, Value>), workspace save/load |
eval.rs | Expr AST, Op, Base; evaluator, formatters, built-ins, FnCallHook |
parser.rs | Tokenizer, recursive-descent parser, parse_stmts(), Stmt enum |
exec.rs | exec_stmts(), exec_script(), Signal, user function dispatch, BODY_CHUNK_CACHE |
io.rs | IoContext — file descriptor table for fopen/fgetl/fprintf/etc. |
vm/mod.rs | Opcode, Instr (8-byte fixed-width), Chunk, IterState, CompileError |
vm/compile.rs | compile(&[StmtEntry]) → Result<Chunk, CompileError>; is_compilable() |
vm/exec.rs | vm_exec(chunk, env, io, …) — bytecode dispatch loop, arithmetic fast paths |
benches/engine.rs | Criterion benchmarks: scalar ops, fib, loop throughput, matmul, fn calls |
Dependency graph
ccalc (binary)
├── ccalc-engine (local)
│ ├── dirs
│ ├── ndarray
│ └── indexmap
├── rustyline
├── toml
└── serde
ccalc-engine (dev / benches only)
└── criterion
Design principles
- One binary, no runtime. The release binary is self-contained. Every new dependency requires explicit justification.
- The library is pure.
ccalc-enginehas no I/O, no terminal codes, norustyline. The binary owns all user-facing interaction. - Modern MATLAB standard (R2016b+). Where MATLAB and Octave differ,
ccalc follows modern MATLAB. Example:
'text'is a char array (numeric),"text"is a string object. - Version is defined once in
[workspace.package]; both crates inherit it. - No runtime allocations on the hot path beyond the input string itself.
Engine Crate (ccalc-engine)
The ccalc-engine crate is a pure computation library with no terminal
I/O dependencies. It contains the full language pipeline.
Execution pipeline
parse_stmts(src) → Vec<StmtEntry> (AST)
│
▼
vm::compile::compile(&stmts)
│ Ok(Chunk) Err(Unsupported)
▼ ▼
vm::exec::vm_exec(chunk, env, …) exec::exec_stmts (tree-walker)
exec_stmts is the public execution entry point. It tries to compile the
statement block to bytecode first; if any construct is not yet supported
(CompileError::Unsupported), it falls back to the recursive tree-walker
transparently.
Key public types
#![allow(unused)]
fn main() {
// Statement AST — produced by the parser
pub enum parser::Stmt { Assign(..), Expr(..), For { .. }, While { .. }, … }
pub type parser::StmtEntry = (Stmt, /*silent*/ bool, /*line*/ usize);
// Value enum — result of evaluation (sizeof = 32 bytes, Phase 35b)
pub enum env::Value {
// ── unboxed (small) ────────────────────────────────────────────────
Void,
Scalar(f64),
Complex(f64, f64),
DateTime(f64), Duration(f64),
Str(String), StringObj(String),
Tuple(Vec<Value>), DateTimeArray(Vec<f64>), DurationArray(Vec<f64>),
// ── boxed (large, one heap pointer each) ──────────────────────────
Matrix(Box<Array2<f64>>),
ComplexMatrix(Box<Array2<Complex<f64>>>),
Function(Box<FunctionData>), // outputs, params, body_source, locals, doc
Lambda(Box<LambdaFn>),
Cell(Box<Vec<Value>>),
Struct(Box<IndexMap<String, Value>>),
StructArray(Box<Vec<IndexMap<String, Value>>>),
Map(Box<IndexMap<String, Value>>),
}
// Associated struct for named user functions (behind Box in Value::Function)
pub struct env::FunctionData {
pub outputs: Vec<String>,
pub params: Vec<String>,
pub body_source: String,
pub locals: IndexMap<String, Value>,
pub doc: Option<String>,
}
// Variable environment
pub type env::Env = IndexMap<String, Value>;
// Execute a parsed block (tries VM, falls back to tree-walker)
pub fn exec::exec_stmts(stmts, env, io, fmt, base, compact)
-> Result<Option<Signal>, String>;
// Execute a top-level script (hoists function defs, then exec_stmts)
pub fn exec::exec_script(stmts, env, io, fmt, base, compact)
-> Result<Option<Signal>, String>;
}
Bytecode VM (vm/)
Added in Phase 34b. Three modules:
| Module | Role |
|---|---|
vm/mod.rs | Shared types: Opcode (u8), Instr (8 bytes, compile-time size assert), Chunk, IterState, CompileError |
vm/compile.rs | compile(&[StmtEntry]) and compile_fn_body(stmts, params, outputs) — single-pass lowering; is_compilable — zero-allocation pre-check; is_leaf_fn — Vec-frame eligibility predicate |
vm/exec.rs | vm_exec (env-init path) and vm_exec_with_frame (pre-built Vec<Value> path) — both thin wrappers around vm_exec_inner |
Instr is always 8 bytes: 1-byte opcode + 7-byte little-endian payload.
This fits thousands of instructions in L1-D cache.
Supported compiled statements: Assign, Expr, For, While, If/elseif/else,
Break, Continue, Return, FunctionDef (→ DefineFunc), IndexSet
(→ IndexSetOp).
Arithmetic fast paths: Scalar×Scalar (direct f64), Complex power via
num_complex::powi/powf/powc, Matrix broadcast via ndarray.
Phase 35 — Interpreter Performance 2
Three sub-phases reduced loop overhead from ~4.7 ms/10k-iter to ~0.56 ms:
35a — Slot-indexed locals
Variables that are only assigned in the current chunk and never referenced
inside an EvalExpr expression receive consecutive slot indices instead of
HashMap keys. New opcodes LoadSlot/StoreSlot/IterNextSlot access a
Vec<Value> by integer index — O(1) with zero hashing. The compiler performs
two passes: collect assignment-LHS/loop-var candidates, filter out any name
that appears free inside an EvalExpr sub-expression, assign slots to the rest.
Entry and exit of vm_exec sync slots to/from env in O(slots) passes.
35c — Native CallBuiltin opcode
A COMPILABLE_BUILTINS whitelist (57 pure-math functions: abs, sqrt,
sin/cos, real/imag, sum, size, zeros, …) marks calls as pure.
is_pure() returns true for whitelisted calls, so their arguments are no
longer EvalExpr-referenced. The CallBuiltin(name_idx, argc) opcode pops
arguments directly from the VM stack and calls call_builtin — no env lookup,
no AST traversal.
Side-effect: once abs(z) becomes CallBuiltin, z is no longer
EvalExpr-referenced → 35a assigns it a slot → Julia-set inner loop is
fully slot-indexed.
35b — Value boxing
sizeof(Value) reduced from 168 → 32 bytes by placing eight large variants
behind Box<T> (see the Value enum listing above). Benefits:
| Impact | Detail |
|---|---|
Slot Vec<Value> | 5–7× smaller; fits in a single cache line for typical functions |
| VM operand stack | Same reduction; push/pop memcopy 32 B not 168 B |
for k = 1:256 iterator | 256 × 32 B = 8 KB (was 43 KB) |
A compile-time assertion const _VALUE_SIZE: () = assert!(size_of::<Value>() <= 32)
prevents future size regressions.
Benchmark summary (release, Windows 11)
| Benchmark | v0.45 (Phase 34b) | v0.46 (Phase 35) | v0.47 (Phase 36) | Overall |
|---|---|---|---|---|
loop_10k | 4.68 ms | 0.56 ms | 0.55 ms | 8.5× |
fn_calls_1000 | 3.10 ms | 2.92 ms | 0.70 ms | 4.4× |
scalar_ops_sum_1M | 8.05 ms | 9.40 ms | ~9.0 ms | within budget |
Phase 36 — Interpreter Performance 3
Three sub-phases reduced function-call overhead to meet the ≤1.0 ms target:
36a — Constant folding
Invariant sub-expressions (e.g. 2 * pi, 0.5 * dt) that appear inside loop
bodies are evaluated at compile time and replaced with a single PushConst.
The compiler builds a const_map from top-level assignments before the first
loop, then calls const_eval(expr, &const_map) before emitting any pure
expression.
36b — Scalar inline arithmetic fast path
scalar_binop! and scalar_cmp! macros peek at the top two stack elements by
reference; when both are Value::Scalar(f64), the result is computed inline
(f64 arithmetic + truncate + push) without calling vm_binop. Neg and
Not use stack.last_mut() for in-place mutation. Non-scalar operands fall
through to the existing general path.
36c — Function call frames
Two-level fast path for user-function calls:
CallUser opcode. Non-builtin calls with pure arguments now compile to
CallUser(name_idx, argc) instead of EvalExpr. This eliminates the
eval_with_io dispatch overhead and unblocks slotting of loop variables
(e.g. k in for k=1:N; s=inc(k); end is now a slot).
Vec-frame fast path for leaf functions. A leaf function has an empty
name pool (chunk.names.is_empty()) — its body only accesses slotted variables.
For leaf functions call_user_function skips Env::new() and instead seeds
a pre-allocated Vec<Value> frame from the parameter list, runs
vm_exec_with_frame against a shared empty scratch env, and reads outputs
directly from the returned slot vector. Recursive or I/O-bearing functions
fall back to the full-Env path.
Key additions: compile_fn_body(stmts, params, outputs) pre-slots params at
chunk.slot_names[0..n_params]; is_leaf_fn(chunk) tests the predicate;
BODY_FRAME_CACHE caches leaf chunks; LEAF_SCRATCH_ENV is the reusable
empty env; MAX_CALL_DEPTH = 64 with RAII CallDepthGuard prevents stack
overflow on infinite recursion.
Why a separate crate?
- Testable in isolation — 1 000+ unit tests, no CLI coupling.
- Embeddable — WASM or other frontends can link
ccalc-enginedirectly. - Clean boundary — the binary owns all user-facing interaction;
the engine has no
rustyline, no terminal codes, noprintln!in hot paths.
Parser (parser.rs)
The parser converts an input string into an Expr AST through two stages:
tokenization and recursive-descent parsing.
Tokenizer
tokenize(input) produces a Vec<Token>. Token types:
#![allow(unused)]
fn main() {
enum Token {
Number(f64), // decimal, hex (0x), binary (0b), octal (0o), scientific
Ident(String), // function names and constants: sqrt, pi, e, acc, …
Plus, Minus, Star, Slash, Caret, Percent,
LParen, RParen,
}
}
Numeric literals
The tokenizer handles all four bases and scientific notation:
| Literal | Example |
|---|---|
| Decimal | 3.14, 100 |
| Scientific | 1e5, 2.5e-3, 1E+10 |
| Hexadecimal | 0xFF, 0X1A |
| Binary | 0b1010, 0B11 |
| Octal | 0o17, 0O377 |
Scientific notation uses lookahead to avoid treating e (Euler’s number) as
an exponent when it appears as a standalone identifier.
Grammar
expr = term ( ('+' | '-') term )*
term = unary ( ('*' | '/' | '.*' | './' | implicit_mul) unary )*
unary = ('-' | '+' | '~') unary | power -- unary lower than power
power = primary (('^' | '.^' | '**') unary)? -- right-associative
primary = ident '(' expr? ')' -- function call or index
| '(' expr ')' -- grouping
| '[' matrix ']' -- matrix literal
| number | ident -- literal or variable
| primary '\'' -- postfix conjugate transpose (highest)
| primary '.\' -- postfix non-conjugate transpose
Precedence follows MATLAB/Octave: ' (transpose) > ^/.^ > unary -/~ > *// > +/-.
Implicit multiplication
parse_term detects an LParen token following a completed expression and
inserts a * without consuming an explicit operator token. This allows
2(3 + 1) and (a)(b).
Percentage (%) disambiguation
% is right-context-sensitive inside parse_term:
- If the next token can start an expression → modulo (
BinOp(Mod)) - Otherwise → postfix percentage (
BinOp(Mul, Number(acc / 100)))
Accumulator in parsing
parse accepts accumulator: f64. This value is:
- Substituted for
accidentifiers - Substituted for empty-argument function calls:
sqrt()→sqrt(acc) - Used for postfix
%:20%→20 * (acc / 100)
Entry points
#![allow(unused)]
fn main() {
// Full parse — returns Err if any tokens remain after the expression
pub fn parse(input: &str, accumulator: f64) -> Result<Expr, String>
// True if input starts with an operator (caller should prepend acc)
pub fn is_partial(input: &str) -> bool
}
Evaluator (eval.rs)
The evaluator walks an Expr AST and produces a Value, given a variable environment.
Value type
#![allow(unused)]
fn main() {
// defined in env.rs
pub enum Value {
Scalar(f64),
Matrix(ndarray::Array2<f64>),
}
pub type Env = HashMap<String, Value>;
}
Scalar is the common case. Matrix was introduced in Phase 3.
AST types
#![allow(unused)]
fn main() {
pub enum Expr {
Number(f64),
Var(String),
UnaryMinus(Box<Expr>),
BinOp(Box<Expr>, Op, Box<Expr>),
Call(String, Vec<Expr>),
Matrix(Vec<Vec<Expr>>), // outer = rows, inner = elements per row
}
pub enum Op {
Add, Sub, Mul, Div, Pow,
}
}
eval(expr, env) semantics
| Variant | Semantics |
|---|---|
Number(n) | Returns Scalar(n) |
Var(name) | Clones value from env; Err if not defined |
UnaryMinus(e) | Negates scalar or every matrix element |
BinOp(l, Div, r) | Err if scalar divisor is 0.0 |
BinOp(l, Pow, r) | Uses f64::powf for scalars; element-wise for matrix ^ scalar |
Call(name, args) | Dispatches to built-in; currently all built-ins require scalar args |
Matrix(rows) | Evaluates each element (must be scalar), builds Array2 |
Scalar × Matrix arithmetic
| Left | Op | Right | Result |
|---|---|---|---|
Scalar | + - * / | Matrix | element-wise broadcast |
Matrix | + - * / ^ | Scalar | element-wise broadcast |
Matrix | + - | Matrix | element-wise (shapes must match) |
Matrix | * / ^ | Matrix | error — matrix multiplication is Phase 4 |
Number display
Three formatters serve different purposes:
format_value(v, precision, base) — compact single-line
For scalars: respects the active display base (decimal, hex, bin, oct).
For matrices: returns [N×M double].
Used in REPL prompts, who output, and assignment echo for scalars.
format_scalar(n, precision, base) — scalar-only
Same as format_value for Scalar values. Used where a scalar is guaranteed
(prompt display, base conversion output).
format_value_full(v, precision) — full multi-line
Returns None for scalars. For matrices returns a right-aligned column string:
1 2 3
4 5 6
Used by the REPL and pipe runner when printing matrix results.
format_number(n) — internal / re-parsing
Always decimal, always round-trips through the parser. Used by repl.rs
when displaying partial-expression context (e.g. expanding variable names).
Base enum
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Base { Dec, Hex, Bin, Oct }
}
Carried in repl.rs as the active display mode, passed to format_scalar
at output time, switched by hex, dec, bin, oct commands.
Octave Compatibility
Implementation history of ccalc’s Octave/MATLAB compatibility, organized by development phase. Each phase documents what was added, the design decisions made, and the test coverage.
Phase summary
| Phase | Feature area | Version |
|---|---|---|
| Roadmap | Future work and open design questions | — |
| 1 | Named variables | v0.1.0 |
| 2 | Multi-argument functions | v0.7.0 |
| 3 | Matrix literals | v0.8.0 |
| 4 | Matrix operations (*, ', .*) | v0.9.0 |
| 5 | Range operator (1:5, 0:0.1:1) | v0.10.0 |
| 6 | Indexing (A(i,j), v(:)) | v0.11.0 |
| 7 | Comparison and logical operators | v0.11.0 |
| 7.5 | Vector utilities, end, special constants | v0.11.0 |
| 8 | Complex numbers | v0.12.0 |
| 9 | String data types | v0.13.0 |
| 10 | C-style I/O and format | v0.14.0 |
| 10.5 | File I/O and filesystem queries | v0.14.0 |
| 11 | Core control flow | v0.15.0 |
| 11.5 | Extended control flow, run/source | v0.16.0 |
| 12 | User-defined functions | v0.17.0 |
| 12.5 | Cell arrays | v0.17.0 |
| 12.6 | Language polish | v0.18.0 |
| 13 | Structs | v0.19.0 |
| 13.5 | Struct arrays | v0.19.0 |
| 13.6 | Backslash operator and path system | v0.20.0 |
| 14 | Error handling | v0.20.0 |
| 15 | Indexed assignment | v0.21.0 |
| 15.6 | Variable scoping | v0.21.0 |
| 16 | Package namespaces | v0.21.0 |
| 17 | Statistics and random numbers | v0.21.0 |
| 18 | Advanced linear algebra | v0.22.0 |
| 19 | REPL tooling | v0.23.0 |
| 20a | JSON encode/decode | v0.24.0 |
| 20c | CSV improvements | v0.24.0 |
| 20.5 | MAT file read | v0.25.0 |
| 21 | String completions and regex | v0.26.0 |
| 22 | Datetime and duration | v0.27.0 |
| 23 | Matrix utilities and set operations | v0.28.0 |
| 24 | Polynomial operations and interpolation | v0.29.0 |
| 25 | Dynamic evaluation and timing | v0.30.0 |
| 26 | FFT and signal processing | v0.31.0 |
| 27 | Complex matrices | v0.32.0 |
| 28 | Plugin architecture | v0.33.0 |
| 29 | Plot engine | v0.34.0 |
| 30 | Colormaps, imagesc, surf, style strings | v0.35.0 |
Octave Compatibility Roadmap
ccalc aims for maximum practical compatibility with Octave/MATLAB .m files.
The work is divided into phases in order of architectural dependency.
Phase summary
| Phase | Goal | Status |
|---|---|---|
| 1 | Variables and assignment (x = 5, who, clear, ws/wl) | ✅ Done |
| 2 | Multi-argument functions (atan2, mod, max, min) | ✅ Done |
| 3 | Matrix literals ([1 2 3], [1; 2; 3]) | ✅ Done |
| 4 | Matrix operations (A * B, A', A .* B) | ✅ Done |
| 5 | Range operator (1:5, 1:2:10, linspace) | ✅ Done |
| 6 | Indexing (A(1,1), v(2:4)) | ✅ Done |
| 7 | Comparison and logical operators (==, ~=, &&) | ✅ Done |
| 7.5 | Vector utilities, end indexing, NaN/Inf, sort, find | ✅ Done |
| 8 | Complex numbers (3 + 4i, abs(z), angle(z)) | ✅ Done |
| 9 | String data types ('char array', "string object") | ✅ Done |
| 10 | C-style I/O (fprintf('%.2f\n', x), sprintf) | ✅ Done |
| 10.5 | File I/O (fopen, dlmread, isfile, save/load with path) | ✅ Done |
| 11 | Core control flow (if, for, while, break, continue, +=) | ✅ Done |
| 11.5 | Extended control flow (switch, do...until, run/source; try/catch deferred to Phase 14) | ✅ Done |
| 12 | User-defined functions, multiple return values, @(x) lambdas | ✅ Done |
| 12.5 | Cell arrays, varargin/varargout, cellfun/arrayfun, @funcname | ✅ Done |
| 12.6 | Language polish: &/|, ..., single-line blocks, .', **, string utils | ✅ Done |
| 13 | Scalar structs (s.field, struct(), fieldnames, isfield, rmfield) | ✅ Done |
| 13.5 | Struct arrays (s(i).field, field collection, numel/isstruct extended) | ✅ Done |
| — | Criterion benchmark suite (benches/engine.rs): scalar, fib, loop, matmul, inv, fn-calls | ✅ Done |
| — | inv/det upgraded to partial pivoting (pure Rust, LAPACK-equivalent stability) | ✅ Done |
| 13.6 | Backslash \ operator + session search path (addpath/rmpath/genpath) | ✅ Done |
| 14 | Error handling (error, warning, try/catch, pcall, lasterr) | ✅ Done |
| 15 | Indexed assignment (v(i) = x, growing vectors, logical mask write) | ✅ Done |
| 15.5 | Compatibility fixes: log natural log, Inf/NaN aliases, autoload, local function scoping | ✅ Done |
| 15.6 | Variable scoping: global, persistent, private/ directories | ✅ Done |
| 16 | Package namespaces (+pkg/ directories, pkg.func(args) call syntax) | ✅ Done |
| 17 | Random numbers and statistics (rand, randn, std, var, median, skewness, kurtosis) | ✅ Done |
| 18 | Advanced linear algebra (qr, lu, chol, svd, eig, rank, null, orth, cond, pinv, matrix norm) | ✅ Done |
| 19 | REPL tooling: tab completion, inline help <fn>, “did you mean?” hints, assert built-ins | ✅ Done |
| 20a | JSON: jsondecode / jsonencode behind --features json | ✅ Done |
| 20c | CSV: readmatrix, readtable, writetable with headers and RFC 4180 quoting | ✅ Done |
| 20.5 | MAT file read: load('file.mat') behind --features mat | ✅ Done |
| 21 | String completions and regex (regexp, regexprep, strsplit upgrades) | ✅ Done |
| 22 | Datetime & duration types (datetime, duration, tic/toc, arithmetic) | ✅ Done |
| 23 | Matrix utilities & set operations (unique, intersect, union, repmat, kron, cross) | ✅ Done |
| 24 | Polynomial operations & interpolation (polyval, polyfit, roots, poly, conv, deconv, interp1) | ✅ Done |
| 25 | Dynamic evaluation & timing (eval, tic/toc, feval) | ✅ Done |
| 26 | FFT & signal processing (fft, ifft, fftshift, ifftshift, fftfreq) | ✅ Done |
| 27 | Complex matrices ([1+2i, 3] literals, ComplexMatrix arithmetic, angle, abs, conj) | ✅ Done |
| 27.5 | ComplexMatrix gaps: eig, svd, norm, cond, indexed assignment on ComplexMatrix | ✅ Done |
| 28 | Plugin architecture: Plugin trait, register_plugin, dynamic dispatch | ✅ Done |
| 29 | Plot engine (ASCII + SVG/PNG): plot, scatter, bar, stem, hist, loglog, plot3, scatter3 | ✅ Done |
| 30 | Plot engine extensions: colormap, imagesc, surf, mesh, contour, contourf, subplot, hold, savefig, style strings, quiver, text | ✅ Done |
| 30.5 | Unified color system: ColormapSpec, extended style strings (#RRGGBB, full color names, RGB matrix) | ✅ Done |
| 30.6 | Visual style system: theme, bgcolor, fontsize, linewidth, markersize, gridcolor, gridwidth, axis mode | ✅ Done |
| 31 | Configurable REPL prompt & syntax highlighting | ✅ Done |
| 32 | Plot primitives & statistical charts: line, patch, rectangle, errorbar, scatter color, pie, yyaxis, clabel, image, imshow | ✅ Done |
| 33 | Language polish: newline as matrix row separator, s.(fname) dynamic field access, dir, containers.Map, mdBook update | ✅ Done |
| 34 | Interpreter performance: bytecode compiler + register VM (vm/), IndexSetOp, Complex power fast path | ✅ Done |
| 35 | Interpreter performance 2: slot-indexed locals (LoadSlot/StoreSlot), CallBuiltin opcode, Value boxing (168 → 32 bytes) | ✅ Done |
Key architectural decisions
Phase 1 introduced Env (HashMap<String, f64>) and Stmt
(assignment vs expression), which are load-bearing for every subsequent phase.
Phase 2 migrated Expr::Call(String, Box<Expr>) to
Expr::Call(String, Vec<Expr>) and introduced call_builtin dispatch.
New functions: atan2, mod, rem, max, min, hypot, log(x,base),
asin, acos, atan, sign. Empty args fn() still passes ans.
Phase 3 adds ndarray and a Value enum (Scalar(f64) | Matrix(...)),
migrating Env from f64 to Value. Matrix literals [1 2; 3 4],
element-wise arithmetic with scalars, and matrix +/- are implemented.
split_stmts() in repl.rs became bracket-depth-aware so ; inside
[...] is parsed as a row separator, not a statement separator.
Phase 4 adds matrix multiplication (A * B via ndarray .dot()),
postfix transpose (A' — new token Apostrophe, new Expr::Transpose),
and element-wise operators .*, ./, .^ (new tokens DotStar,
DotSlash, DotCaret). New built-ins: zeros(m,n), ones(m,n), eye(n),
size(A), size(A,dim), length(A), numel(A), trace(A), det(A)
(Gaussian elimination), inv(A) (Gauss-Jordan). split_stmts() updated to
distinguish transpose ' from string-literal ' by left-context.
call_builtin refactored to return Result<Value, String> directly.
Phase 5 adds Token::Colon and Expr::Range(start, step?, stop). A new
parse_range() layer sits above parse_expr() with lower precedence, so
1+1:5 = 2:5. The Expr::Matrix evaluator is updated to concatenate
row-vector elements horizontally, making [1:5] work. New built-in:
linspace(a, b, n).
Phase 6 adds Expr::Colon and parse_call_arg(). The Expr::Call
evaluator checks Env first: if the name resolves to a variable, the
expression is treated as indexing (variables shadow built-in function names,
matching Octave semantics). eval_index() + resolve_dim() handle 1D
(column-major linear) and 2D indexing, all 1-based. A bug fix also landed
here: range expressions inside grouping parentheses (a:b) now parse
correctly.
Phase 7 adds comparison tokens (==, ~=, <, >, <=, >=) and
logical operators (~, &&, ||). Comparisons return 0.0/1.0 and work
element-wise on matrices. New parse levels parse_logical_or →
parse_logical_and → parse_comparison sit above parse_range in the
precedence hierarchy. Expr::UnaryNot and Op::Eq/NotEq/Lt/Gt/LtEq/GtEq/And/Or
are added to the AST.
Phase 7.5 adds special floating-point constants (nan, inf as
parser-level constants), isnan/isinf/isfinite built-ins, vector
reductions (sum, prod, cumsum, any, all, 1-arg min/max,
mean, norm), the end keyword in indexing contexts (v(end),
A(1:end, 2) — env_with_end() injects the dimension size into a cloned
env before evaluating index expressions), and data utility functions
(sort, reshape, fliplr, flipud, find, unique).
No new Value variants are needed.
Phase 8 adds Value::Complex(f64, f64) as a third Value variant.
No new tokens are required — 4i already parses as implicit multiplication
4 * i, where i and j are pre-seeded in Env as Complex(0.0, 1.0).
complex_binop() handles all arithmetic combinations; integer powers use
binary exponentiation for exact results (i^2 = -1 exactly); non-integer
powers use the polar form exp((c+di)·ln(a+bi)).
make_complex(re, im) collapses to Scalar(re) when im == 0.0 exactly.
z' (conjugate transpose) returns the complex conjugate for scalar complex.
Built-ins added: real, imag, abs (overloaded), angle, conj,
complex, isreal. scalar_arg accepts Complex with im == 0 as a
real scalar. Complex matrices are out of scope and deferred.
Phase 9 adds two string value types. Value::Str(String) represents
single-quoted char arrays; Value::StringObj(String) represents double-quoted
string objects. The ' disambiguation — transpose vs char array literal — is
resolved by one token of left context in the tokenizer: after
Number/Ident/RParen/RBracket/Apostrophe/Str it is a transpose;
otherwise it opens a char array. Char array arithmetic converts characters to
their ASCII codes before the operation, matching MATLAB behaviour.
String objects use + for concatenation and ==/~= for comparison.
New built-ins: num2str, str2num, str2double, strcat, strcmp,
strcmpi, lower, upper, strtrim, strrep, sprintf (1-arg),
ischar, isstring. length/numel/size updated for strings.
Phase 10 adds fprintf(fmt, ...) and sprintf(fmt, ...) using the
string infrastructure from Phase 9. The ad-hoc p/p<N> precision command
is deprecated and removed in this phase. Placed before control flow so that
loop scripts have formatted output from the start.
Phase 10 adds fprintf(fmt, ...) and sprintf(fmt, ...).
The ad-hoc p/p<N> precision command is removed. Phase 10.5 extends this
with file I/O (fopen/fclose/fgetl/fgets), data files (dlmread/dlmwrite),
filesystem queries (isfile, isfolder, pwd, exist), and workspace
persistence with explicit paths (save('f.mat'), load('f.mat')).
Phase 11 adds multi-line REPL buffering and core control flow constructs:
if/elseif/else, for (column iteration), while, break, continue.
Compound assignment operators (+=, -=, *=, /=, ++, --) are
desugared at parse time to plain Stmt::Assign — no new AST nodes.
Syntax aliases: # for % (comment), ! for ~ (NOT), != for ~=.
Extended control flow (switch, do...until, run/source) was completed in Phase 11.5.
Phase 11.5 adds switch/case/otherwise (no fall-through; scalar exact ==,
string equality), do...until (Octave post-test loop; until closes the block
without end), and run()/source() script sourcing (MATLAB run semantics —
scripts execute in the caller’s workspace). Extension resolution for bare names
tries .calc (native ccalc format) before .m (Octave/MATLAB compatibility).
A thread_local! RUN_DEPTH counter caps recursion at 64 levels.
try/catch is deferred to Phase 14 (after structs).
Phase 12 adds user-defined functions with single and multiple return
values ([a, b] = f(x)), and anonymous functions @(x) expr (closures).
Named functions use function [out1, out2] = name(p1, p2) ... end syntax and
are stored as Value::Function { outputs, params, body_source } in Env.
body_source is stored as a string and re-parsed on each call to avoid a
circular dependency between eval.rs and parser.rs.
Anonymous functions are stored as Value::Lambda(Rc<dyn Fn>) — a closure
compiled at definition time that captures the enclosing environment lexically.
A thread-local FnCallHook bridges eval.rs (which dispatches the call) and
exec.rs (which executes the body in an isolated scope).
Each call gets a fresh environment seeded with i, j, ans, the declared
parameters, nargin, and all callable values (Function/Lambda) from the
caller’s workspace — the last point enables self-recursion and mutual recursion
without exposing caller data.
Stmt::FunctionDef, Stmt::Return, Stmt::MultiAssign, and Token::At were
added to the parser; Signal::Return to the executor.
Phase 12.5 adds Value::Cell(Vec<Value>) — a heterogeneous 1-D cell array.
Cell literals {e1, e2}, brace indexing c{i}, and brace assignment c{i} = v
use new Token::LBrace/RBrace and Expr::CellLiteral/CellIndex/Stmt::CellSet.
varargin/varargout collect extra call arguments into a Value::Cell.
case {v1, v2} multi-value switch cases iterate the cell and test each element.
cellfun(f, c) and arrayfun(f, v) apply a function to each element.
@funcname desugars to Expr::FuncHandle(name) — a lambda wrapping any named function.
split_stmts() updated to track brace depth so ; inside {...} is not a separator.
Phase 12.6 delivers language polish across nine sub-items (12.6h deferred):
- 12.6a Single-line blocks:
if cond; body; end—is_single_line_block()detects self-contained blocks; REPL/pipe bypass the block buffer for them. - 12.6b
...line continuation:cont_bufin REPL andrun_pipe;join_line_continuations()pre-pass inparse_stmts; tokenizer drains rest of line. - 12.6c
&/|element-wise logical: newToken::Amp/Pipe,Op::ElemAnd/ElemOr, andparse_elem_or/parse_elem_andprecedence levels betweenparse_logical_andandparse_comparison. - 12.6d
xor(a,b)andnot(a)built-ins. - 12.6e Lambda display:
LambdaFncarries a source string;expr_to_string()helper reconstructs readable source text from the AST at parse time. - 12.6f
strsplit(s[,delim]),int2str(x),mat2str(A)built-ins. - 12.6g
.'plain transpose:Token::DotApostrophe,Expr::PlainTranspose— no complex conjugation (contrast with'which is the Hermitian conjugate transpose). - 12.6j Unary
+(no-op),**alias for^(Octave),,non-silent separator. - Bug fixes:
4iimaginary literal now works via tokenizerpush_imag_suffix();split_stmts'disambiguation extended to recognise.as a transpose indicator (fixingB.';mis-parse);run_pipegainedcont_buffor...continuation.
Phase 13 adds Value::Struct(IndexMap<String, Value>) — scalar structs
with insertion-order-preserving fields (using the indexmap crate).
Token::Dot is emitted only when . is followed by an ASCII letter/underscore,
leaving DotStar/DotSlash/DotCaret/DotApostrophe unaffected.
Expr::FieldGet handles chained reads (s.a.b); Stmt::FieldSet(String, Vec<String>, Expr) handles writes with arbitrary depth paths via the
set_nested() recursive helper in exec.rs.
Built-ins: struct(), fieldnames, isfield, rmfield, isstruct.
19 regression tests added; 488 total.
Phase 13.5 adds Value::StructArray(Vec<IndexMap<String, Value>>) — a
separate variant for 1-D arrays of structs, keeping Value::Struct for scalar
structs unchanged. s(i).field = val is intercepted at string level by
try_split_struct_array_field_assign() before tokenization and parsed into a
new Stmt::StructArrayFieldSet(base, idx_expr, path, rhs) statement. The
executor in exec.rs resolves the index, grows the array if needed (filling
gaps with empty field maps), and calls the existing set_nested() helper to
write nested field paths. s(i) indexing returns a Value::Struct for a
single element and a Value::StructArray for a slice or :. s.field on a
struct array collects the field across all elements, returning Value::Matrix
when all elements are scalar, or Value::Cell when types are mixed.
Extended built-ins: isstruct, fieldnames, isfield, rmfield, numel,
size, length. 8 regression tests added.
Phase 17 adds random number generation (rand, randn, randi, rng) using
a thread-local SmallRng (from the rand crate). rand_normal() uses the
Box-Muller transform to avoid an extra crate. Descriptive statistics (std, var,
median, mode, cov, hist, histc), percentile functions (prctile, iqr,
zscore), normal distribution functions (normcdf, normpdf, erf, erfc via
the libm crate), and shape statistics (skewness, kurtosis) are added using
the existing apply_stat column-wise helper. No new tokens or AST nodes are needed.
Phase 18 adds pure-Rust matrix decompositions and properties via six new
private helper functions in eval.rs: qr_decompose (Householder reflectors),
lu_decompose (Gaussian elimination with partial pivoting), chol_decompose
(standard row-by-row Cholesky), svd_compute (one-sided Jacobi with Golub–Van
Loan rotation convention), eig_compute (QR iteration with Wilkinson shift for
cubic convergence on symmetric matrices), and complete_orthonormal_basis
(Gram-Schmidt for extending an economy U to full m×m). The nargout thread-local
(set_nargout / get_nargout in eval.rs, called at both exec_stmts and
evaluate() sites) lets multi-output built-ins return a Value::Tuple or a single
value depending on the number of LHS targets. Matrix norm is updated to use SVD
for the 2-norm of non-vector matrices.
Phase 19 adds four developer-experience improvements:
- 19a — Tab completion:
rustylineis upgraded fromDefaultEditorto a typedEditor<CcalcHelper, DefaultHistory>with a customCcalcHelperthat implements theCompletertrait. Completion matches any prefix against all variable names in the currentEnvplus the ~90 built-in names returned bybuiltin_names(). Hinter, Highlighter, and Validator are no-op stubs (rustyline requires all four traits to be implemented). - 19b — Inline help for user functions:
Stmt::FunctionDefandValue::Functiongain anOption<String>docfield.parse_stmts_from_linesscans backward from thefunctionkeyword through consecutive%/#-prefixed lines and assembles the doc string (empty lines break the scan).help <name>in the REPL checks this field before falling through to the built-in topic list. - 19c — “Did you mean?” hints:
suggest_similar(name, env)ineval.rscomputes the Levenshtein edit distance from the misspelled name to each env key and built-in name. The closest match within distance 2 is appended to “Undefined variable” and “Unknown function” error messages. - 19d —
assertbuilt-ins: Three overloads are added tocall_builtin:assert(cond)— truthy check;assert(expected, actual)— exact equality;assert(expected, actual, tol)— tolerance check. All three work on scalars and matrices. The implementation lives inassert_values_equal().
Phase 20a adds jsondecode and jsonencode behind an optional json
feature flag (serde_json = "1"). A new crates/ccalc-engine/src/json.rs
module provides json_to_value and value_to_json converters. Both built-in
names are unconditionally registered in builtin_names() for tab completion;
without the feature they return a helpful “rebuild with –features json” error.
22 tests in json_tests. Example: examples/json/json.calc.
Phase 20c extends the CSV infrastructure from Phase 10.5b with three new
built-ins: readmatrix (header auto-skip, empty → NaN), readtable (returns
a Struct of typed columns — Matrix N×1 for numeric, Cell of Str for
mixed), and writetable (RFC 4180 quoting). 15 tests in csv_tests. Example:
examples/csv/csv.calc.
Phase 20.5 adds load('file.mat') behind an optional mat feature flag
(matrw = "=0.1.4" pinned to prevent silent breakage). A new
crates/ccalc-engine/src/mat.rs module provides mat_load(path) using
matrw::load_matfile() with a recursive mat_var_to_value() converter that
maps each MatVariable variant to the appropriate Value. Column-major matrix
data is converted to ndarray row-major via Array2::from_shape_vec((cols, rows), data).t().to_owned(). The assignment form (data = load('f.mat')) returns a
Struct; the bare form (load('f.mat')) injects all fields directly into the
current workspace. save('*.mat', ...) returns a clear “not yet supported”
error. 5 roundtrip tests using matvar!/matfile! macros. Example:
examples/mat/mat.calc.
Phase 34 adds a bytecode compiler and register VM in crates/ccalc-engine/src/vm/.
exec_stmts calls is_compilable() (zero-allocation pre-scan), then compile() to
produce a Chunk of fixed-width 8-byte Instr values. If any construct is unsupported
(CompileError::Unsupported), the tree-walker fallback is used transparently. The
Chunk is cached in a CHUNK_CACHE thread-local keyed by the raw source string.
Supported compiled constructs: Assign, Expr, For, While, If/elseif/else,
Break, Continue, Return, FunctionDef (→ DefineFunc), IndexSet
(→ IndexSetOp). Benchmarks: loop_10k improved from ~50 ms (tree-walker) to
4.68 ms; fn_calls_1000 improved to 3.10 ms.
Phase 35 adds three layered optimisations targeting hot-loop throughput.
35a — Slot-indexed locals: the compiler identifies variables that are only assigned
inside the chunk and never referenced via EvalExpr (i.e., they only appear in pure
expressions). Those variables receive consecutive slot indices stored in
chunk.slot_names. vm_exec keeps a Vec<Value> (locals) instead of the
HashMap<String, Value> env for these variables. New opcodes LoadSlot, StoreSlot,
IterNextSlot replace LoadVar/StoreVar/IterNext for slotted names.
Result: loop_10k 4.68 ms → 1.95 ms.
35c — Native CallBuiltin opcode: a COMPILABLE_BUILTINS whitelist of 57
pure-math function names extends is_pure(). Calls to whitelisted functions compile to
CallBuiltin(name_idx, argc) which pops arguments from the stack and invokes
call_builtin directly — no EvalExpr, no env-lookup for arguments. Side-effect:
variables used only in builtin calls become slottable under 35a.
35b — Value boxing: sizeof(Value) reduced from 168 → 32 bytes by boxing
eight variants behind Box<T>: Matrix, ComplexMatrix, Function (→
Box<FunctionData>), Lambda, Cell, Struct, StructArray, Map.
A compile-time assertion const _VALUE_SIZE: () = assert!(size_of::<Value>() <= 32)
prevents regression. Combined result (35a + 35c + 35b): loop_10k = 0.56 ms
(8.4× faster than Phase 34b).
Compatibility notes
%is a comment character (Octave/MATLAB convention). It terminates tokenization at that point. This is already implemented.ansis the sole implicit variable (Octave/MATLAB convention). The old accumulator (acc) and memory cells (m1–m9) were removed in Phase 1.- 1-based indexing for matrices (Octave/MATLAB convention) — implemented in Phase 6.
- Where MATLAB and Octave differ, ccalc follows the modern MATLAB standard (R2016b+).
- Full toolbox compatibility (Signal Processing, Optimization, etc.) is out of scope.
Phase 1 — Variables and Assignment
Status: ✅ Done (v0.7.0)
Goal: x = 5, then x + 1 → 6
What was implemented
Variable environment (Env)
#![allow(unused)]
fn main() {
pub type Env = HashMap<String, f64>;
}
eval takes &Env. The REPL holds one Env per session, initialized with
ans = 0.0 on startup.
Assignment statement
x = expr is a top-level statement, separate from an expression:
#![allow(unused)]
fn main() {
pub enum Stmt {
Assign(String, Expr), // x = expr
Expr(Expr), // standalone expression → stored in ans
}
}
Variable lookup in expressions
#![allow(unused)]
fn main() {
Expr::Var(String)
}
When the evaluator encounters Var(name), it looks up name in Env.
Unknown names produce an error.
ans — implicit last result
ans is the reserved name for the result of the last standalone expression.
It is initialized to 0.0 on startup and updated after every expression
that is not assigned to a named variable — exactly as in Octave/MATLAB.
Partial expressions (starting with an operator) automatically prepend ans:
[ 100 ]: / 4
[ 25 ]: + 5
[ 30 ]:
Commands
| Command | Action |
|---|---|
who | List all variables and their values |
clear | Delete all variables (reinitializes ans = 0) |
clear x | Delete variable x |
ws | Save workspace to ~/.config/ccalc/workspace.toml |
wl | Load workspace from file |
Workspace persistence
Variables are saved and loaded via env::save_workspace_default() /
load_workspace_default(). The file format is plain name = value lines.
What was removed in this phase
acc(old accumulator) — replaced byans.m1–m9(memory cells) — replaced by named variables.ccommand — replaced byclear.
Octave/MATLAB alignment
ansfollows Octave/MATLAB convention exactly.piandeare resolved in the parser (not stored inEnv).%terminates tokenization — everything after%on a line is a comment.
Phase 2 — Multi-argument Functions
Status: ✅ Done (v0.7.0+011)
Goal: atan2(y, x), mod(a, b), max(a, b), hypot(a, b), …
What was implemented
Expr::Call — variadic arguments
Expr::Call(String, Box<Expr>) was replaced by Expr::Call(String, Vec<Expr>).
The parser now handles comma-separated argument lists:
fn() → passes ans (backward-compatible)
fn(x) → single argument
fn(a, b) → two arguments
fn(a, b, c) → three arguments (future use)
call_builtin dispatcher
The inline match name in the evaluator was replaced by call_builtin(name, args: &[f64]),
which uses slice pattern matching to dispatch on both the function name and the
argument count:
#![allow(unused)]
fn main() {
("log", [x]) => x.log10()
("log", [x, base]) => x.log(*base)
}
This makes it trivial to add overloaded functions and keeps the evaluator clean.
New one-argument functions
| Function | Description |
|---|---|
asin(x) | Inverse sine (radians) |
acos(x) | Inverse cosine (radians) |
atan(x) | Inverse tangent (radians) |
sign(x) | Sign: −1.0, 0.0, or 1.0 |
New two-argument functions
| Function | Description |
|---|---|
atan2(y, x) | Four-quadrant inverse tangent (radians) |
mod(a, b) | Remainder, sign follows divisor (Octave/MATLAB mod) |
rem(a, b) | Remainder, sign follows dividend (IEEE 754 truncation) |
max(a, b) | Larger of two values |
min(a, b) | Smaller of two values |
hypot(a, b) | √(a²+b²), numerically stable |
log(x, base) | Logarithm of x to an arbitrary base |
mod vs rem — sign convention
mod( 10, 3) → 1 rem( 10, 3) → 1
mod(-1, 3) → 2 rem(-1, 3) → -1
mod( 1, -3) → -2 rem( 1, -3) → 1
mod is implemented as a - b * floor(a / b) — sign follows the divisor.
rem is implemented as a - b * trunc(a / b) — sign follows the dividend.
Octave/MATLAB alignment
atan2,mod,rem,max,min,hypotmatch Octave/MATLAB exactly.log(x, base)— Octave useslog(x)for the natural log; ccalc keepslog(x)as base-10 (legacy). Useln(x)for natural log, orlog(x, e)for the two-argument form.
Example
See examples/ac_impedance.ccalc for a practical use of hypot, atan2,
mod, max, min, log, and log(x, base) in an AC circuit calculation.
Phase 3 — Matrix Literals
Version: v0.8.0
What was added
Matrix literals using Octave/MATLAB bracket syntax:
[1 2 3] % row vector
[1; 2; 3] % column vector
[1 2; 3 4] % 2×2 matrix
Elements can be arbitrary expressions. Spaces and commas are both valid element separators within a row.
Arithmetic
scalar op matrixandmatrix op scalar— element-wise for+,-,*,/,^matrix + matrixandmatrix - matrix— element-wise (shapes must match)matrix * matrix— not yet (Phase 4)
Type system changes
Value enum introduced in env.rs:
#![allow(unused)]
fn main() {
pub enum Value {
Scalar(f64),
Matrix(ndarray::Array2<f64>),
}
pub type Env = HashMap<String, Value>;
}
eval() now returns Result<Value, String> (was Result<f64, String>).
Display
Matrices print with right-aligned columns:
A =
1 2
3 4
The REPL prompt shows matrix size when ans is a matrix: [ [2×2] ]: .
Workspace save (ws) skips matrix variables — only scalars are persisted.
Parser changes
New tokens: LBracket ([), RBracket (]), Semicolon (;).
New AST node: Expr::Matrix(Vec<Vec<Expr>>).
split_stmts() in repl.rs is now bracket-depth-aware: a ; inside
[...] is treated as a row separator by the parser, not a statement
separator by the REPL.
Dependency added
ndarray = "0.16" in crates/ccalc-engine/Cargo.toml.
Phase 4 — Matrix Operations
Version: v0.9.0
What was added
Matrix multiplication
* between two matrices now performs standard matrix multiplication via
ndarray’s .dot(). Inner dimensions must agree; otherwise an error is
returned.
A = [1 2; 3 4];
b = [1; 1];
A * b % → [3; 7]
Transpose
Postfix ' transposes a matrix. It binds tighter than any binary operator,
so A' * B parses as (A') * B.
v = [1; 2; 3];
v' % → [1 2 3] (1×3 row vector)
v' * v % → 14 (dot product, result is 1×1 matrix)
Element-wise operators
.*, ./, .^ apply the operation to each pair of corresponding elements.
Both operands must have the same shape (or one must be a scalar).
A .* B % Hadamard product
A ./ B % element-wise division
A .^ 2 % square each element
Note: * is matrix multiplication; .* is element-wise product.
New built-in functions
| Function | Description |
|---|---|
zeros(m, n) | m×n matrix of zeros |
ones(m, n) | m×n matrix of ones |
eye(n) | n×n identity matrix |
size(A) | [rows cols] as a 1×2 row vector |
size(A, dim) | Rows (dim=1) or columns (dim=2) as scalar |
length(A) | max(rows, cols) |
numel(A) | Total element count |
trace(A) | Sum of diagonal elements |
det(A) | Determinant |
inv(A) | Inverse matrix |
det and inv are implemented via Gaussian / Gauss-Jordan elimination
with no external BLAS/LAPACK dependency.
Parser changes
New tokens:
| Token | Input | Usage |
|---|---|---|
Apostrophe | ' | Postfix transpose |
DotStar | .* | Element-wise * |
DotSlash | ./ | Element-wise / |
DotCaret | .^ | Element-wise ^ |
New AST node: Expr::Transpose(Box<Expr>).
New Op variants: ElemMul, ElemDiv, ElemPow.
is_partial extended: .*, ./, .^ prefixes are now recognised as
partial expressions (e.g. .* 2 expands to ans .* 2).
The number tokenizer no longer absorbs . into a number when it is
immediately followed by *, /, or ^. This means 3.*2 correctly
tokenizes as Number(3), DotStar, Number(2) rather than
Number(3.0), Star, Number(2).
split_stmts fix
split_stmts in repl.rs previously toggled in_sq on every ',
which caused R' * q; to hide the ; inside a “string”. The function
now checks the left context: ' preceded by an identifier character,
digit, ), ], or another ' is treated as a transpose operator and
does not affect the string-tracking state.
Evaluator changes
eval_binop updated:
Matrix * Matrix→ ndarray.dot()(was an error in Phase 3)Matrix .* Matrix→ element-wise&lm * &rmMatrix ./ Matrix→ element-wise&lm / &rmMatrix .^ Matrix→ element-wiseZip::map_collect(a.powf(b))
call_builtin refactored: the function now returns Result<Value, String>
directly from each match arm, replacing the old pattern that extracted an
f64 result and always wrapped it in Value::Scalar. This allows matrix-
returning built-ins (zeros, ones, eye, size, inv) to coexist with
scalar-returning ones in the same match.
Phase 5 — Range Operator
Version: v0.10.0
What was added
Range expressions
The : operator generates row vectors. Two forms are supported:
| Form | Meaning |
|---|---|
a:b | start a, stop b, step 1 |
a:step:b | start a, stop b, explicit step |
1:5 % [1 2 3 4 5]
1:2:9 % [1 3 5 7 9]
0:0.5:2 % [0 0.5 1 1.5 2]
5:-1:1 % [5 4 3 2 1]
5:1 % [] (empty — step in wrong direction)
The range operator has lower precedence than arithmetic:
1+1:2+2 % 2:4 → [2 3 4]
Ranges inside matrix literals
Range elements inside [...] are evaluated to row vectors and concatenated
horizontally into the containing row:
[1:4] % [1 2 3 4] (1×4)
[0, 1:3, 10] % [0 1 2 3 10] (1×5)
[1:2:7] % [1 3 5 7] (1×4)
[1:3; 4:6] % [1 2 3; 4 5 6] (2×3)
linspace
linspace(a, b, n) generates n evenly spaced values from a to b
(both endpoints included). This is numerically superior to range expressions
when the number of points matters more than the step size.
linspace(0, 1, 5) % [0 0.25 0.5 0.75 1]
linspace(1, 5, 5) % [1 2 3 4 5]
linspace(0, 1, 1) % [1] (single element returns b, MATLAB convention)
linspace(0, 1, 0) % [] (empty)
Parser changes
New token: Token::Colon — produced by the : character.
New AST node: Expr::Range(Box<Expr>, Option<Box<Expr>>, Box<Expr>) where
the middle field is the optional step expression.
New parser function parse_range() sits above parse_expr() in the
precedence hierarchy:
parse_range (lowest)
parse_expr (+, -)
parse_term (*, /, .*, ./)
parse_power (^, .^)
parse_unary (unary -)
parse_primary (atoms)
parse() and parse_matrix() (element parsing) now call parse_range()
instead of parse_expr().
Evaluator changes
Expr::Range evaluation:
- Evaluate
start,stop, andstep(must all be scalars). - Compute
n = floor((stop - start) / step + ε) + 1. - If
n ≤ 0, return a 1×0 empty matrix. - Generate values as
start + i * stepfori = 0..n.
Expr::Matrix evaluation updated: when a row element evaluates to a
Value::Matrix, its values are appended to the current row (horizontal
concatenation). This enables ranges inside [...]. Column vectors and
higher-dimensional sub-matrices are rejected with an error.
Phase 6 — Indexing
Version: v0.11.0
What was added
Vector indexing
All indices are 1-based (Octave/MATLAB convention).
v = [10 20 30 40 50];
v(3) % → 30 scalar element
v(2:4) % → [20 30 40] sub-vector via range
v(:) % → column vector [10;20;30;40;50]
v(:) follows Octave convention: all elements are returned as a column vector
in column-major order.
Matrix indexing
A = [1 2 3; 4 5 6; 7 8 9];
A(2, 3) % → 6 scalar at row 2, col 3
A(1, :) % → [1 2 3] entire row 1 (1×3)
A(:, 2) % → [2;5;8] entire column 2 (3×1)
A(1:2, 2:3) % → [2 3; 5 6] submatrix
A(:, :) % → copy of A (all rows, all cols)
Scalar result: A(i,j) returns Value::Scalar when both i and j are
scalars. Sub-matrix results return Value::Matrix.
Linear (1D) indexing
v(i) on a matrix uses column-major linear indexing — columns are counted
before rows. For a 1×N row vector this is simply sequential:
v = [10 20 30 40 50];
v(3) % 3rd element → 30
For a 2D matrix, linear indexing counts down each column before moving to the next:
A = [1 2; 3 4]; % linear order: 1, 3, 2, 4
A(3) % → 2 (3rd in column-major order)
Call vs. index disambiguation
When name(args) is parsed, the evaluator checks Env at eval time:
- Name is in
Env→ indexing (eval_index) - Name is not in
Env→ built-in function call (call_builtin)
This means variables shadow built-in function names, matching Octave
semantics. Assigning zeros = [1 2; 3 4] makes zeros(1,2) an indexing
operation on that variable, not a call to zeros(m,n).
Parser changes
New AST node: Expr::Colon — represents a bare : used as an
all-elements index selector. Evaluating Expr::Colon outside an indexing
context returns an error.
New parser function parse_call_arg():
parse_call_arg:
if current token is ':' → consume, return Expr::Colon
else → parse_range(...)
All function call / index argument positions now use parse_call_arg
instead of parse_expr. This enables:
A(:, j)— bare colon as first argA(1:3, :)— range as first arg, colon as secondf(1:5)— range as function argument
Bug fix: range in grouping parentheses
parse_primary for grouped expressions (...) previously called
parse_expr, which does not handle :. This was changed to parse_range,
enabling expressions like 2 .^ (0:7).
Evaluator changes
eval_index(val, args, env) — dispatches based on argument count:
| Args | Form | Result |
|---|---|---|
1, Colon | v(:) | All elements as column vector (column-major) |
| 1, scalar | v(i) | Value::Scalar |
| 1, vector | v(1:3) | Value::Matrix 1×N |
| 2, both scalar | A(i,j) | Value::Scalar |
| 2, otherwise | A(:,j), A(i,:), A(1:2,2:3) | Value::Matrix |
resolve_dim(expr, dim_size, env) — converts one index argument to a list
of 0-based indices:
Expr::Colon→DimIdx::AllValue::Scalar(n)→ validates1 ≤ n ≤ dim_size, returns[n-1]Value::Matrix(vector) → validates each element, returns[i-1, ...]
Phase 7 — Comparison & Logical Operators
Version: v0.11.0+001
What was added
Comparison operators
All six relational operators from Octave/MATLAB are now supported.
They return 1.0 (true) or 0.0 (false):
3 > 2 % → 1
3 == 3 % → 1
3 == 4 % → 0
5 ~= 3 % → 1 (not equal)
4 <= 4 % → 1
4 >= 5 % → 0
Comparison has lower precedence than arithmetic, so the operands are evaluated first:
1 + 1 == 2 % → 1 (evaluates as (1+1) == 2)
2 * 3 > 5 % → 1 (evaluates as (2*3) > 5)
Comparison is non-associative — chaining like a < b < c is a parse
error. Write a < b && b < c instead.
Logical NOT — ~
Unary ~ negates a truth value: zero becomes 1, any non-zero becomes 0.
It binds at the same precedence level as unary minus:
~0 % → 1
~1 % → 0
~(3 == 3) % → 0
~(3 == 4) % → 1
Short-circuit logical AND/OR — &&, ||
&& and || evaluate both operands and return 0.0 or 1.0.
&& binds tighter than ||:
1 && 1 % → 1
1 && 0 % → 0
0 || 1 % → 1
0 || 0 % → 0
% '&&' before '||':
1 || 0 && 0 % → 1 (1 || (0 && 0))
Combining comparisons:
x = 2.7;
x >= 0 && x <= 3.3 % → 1 (in range check)
x < 0 || x > 3.3 % → 0 (out-of-range flag)
Element-wise on matrices
All operators work element-wise on matrices of the same shape, producing
a 0/1 mask of the same dimensions:
v = [1 2 3 4 5];
v > 3 % → [0 0 0 1 1]
v == 3 % → [0 0 1 0 0]
v ~= 3 % → [1 1 0 1 1]
~(v > 3) % → [1 1 1 0 0]
Scalar–matrix mixed comparisons broadcast the scalar:
v >= 2 % → [0 1 1 1 1]
3 < v % → [0 0 0 1 1]
Soft masking via .*
Since masks are 0/1 matrices, multiplying by them zeroes out unwanted
elements — a common pattern for conditional selection:
v .* (v > 3) % → [0 0 0 4 5] keep elements > 3
% Combine two masks (element-wise AND):
lo = v >= 2;
hi = v <= 4;
v .* (lo .* hi) % → [0 2 3 4 0] keep 2–4 only
Precedence summary
Full precedence table from lowest to highest:
| Level | Operators | Notes |
|---|---|---|
| 1 (lowest) | || | logical OR |
| 2 | && | logical AND |
| 3 | == ~= < > <= >= | comparison, non-associative |
| 4 | : | range (a:b, a:step:b) |
| 5 | + - | additive |
| 6 | * / .* ./ | multiplicative |
| 7 | ^ .^ | power (right-associative) |
| 8 | unary - ~ | negation, logical NOT |
| 9 (highest) | postfix ' | transpose |
Parser changes
Three new parser levels were inserted above parse_range:
parse_logical_or % '||'
parse_logical_and % '&&'
parse_comparison % == ~= < > <= >=
parse_range % a:b, a:step:b (existing)
parse_expr % + -
...
parse_logical_or is now the top-level entry point for parse(),
parse_call_arg(), and grouped expressions (...) in parse_primary.
New tokens: EqEq, NotEq, Lt, Gt, LtEq, GtEq, AmpAmp,
PipePipe, Tilde.
A bare = in expression context (not a valid assignment left-hand side)
is now a tokenizer error, making 3 = 3 produce a clear message instead
of a silent parse failure.
Evaluator changes
New Expr variant:
Expr::UnaryNot(Box<Expr>)— evaluates to1.0if inner is0.0, else0.0. Works element-wise on matrices.
New Op variants:
| Variant | Operation |
|---|---|
Op::Eq | == |
Op::NotEq | ~= |
Op::Lt | < |
Op::Gt | > |
Op::LtEq | <= |
Op::GtEq | >= |
Op::And | && |
Op::Or | || |
eval_binop handles all four combinations (Scalar×Scalar,
Matrix×Matrix, Scalar×Matrix, Matrix×Scalar) for every new operator.
Scalar×Scalar returns a Value::Scalar(0.0|1.0); any matrix combination
returns a Value::Matrix of the same shape.
Helper functions added to eval.rs:
bool_to_f64(b: bool) -> f64— converts a boolean to0.0/1.0cmp_op(op: &Op, a: f64, b: f64) -> bool— applies comparison or logical op to two scalar values
Phase 7.5 — Vector Utilities
Version: v0.11.0+003
What was added
7.5a — Special floating-point constants
nan and inf are parser-level constants (like pi and e) — they require
no variable definition and cannot be overwritten by assignment.
nan % Not-a-Number
inf % positive infinity
-inf % negative infinity
nan + 5 % → NaN (NaN propagates through all arithmetic)
nan == nan % → 0 (always false in IEEE 754)
Matrix constructor:
nan(3) % 3×3 matrix filled with NaN
nan(2, 4) % 2×4 matrix filled with NaN
Element-wise predicates — work on both scalars and matrices:
| Function | Description |
|---|---|
isnan(x) | 1.0 if NaN, else 0.0 |
isinf(x) | 1.0 if ±Inf, else 0.0 |
isfinite(x) | 1.0 if finite (not NaN, not Inf) |
isnan(nan) % → 1
isinf(inf) % → 1
isfinite(42) % → 1
isfinite(nan) % → 0
v = [1 nan 3 inf];
isnan(v) % → [0 1 0 0]
isfinite(v) % → [1 0 1 0]
7.5b — Vector reductions
All reduction functions follow the same rule:
- Vector (1×N or N×1): collapse all elements to a scalar.
- M×N matrix (M>1, N>1): operate column-wise, return a 1×N row vector.
This matches Octave/MATLAB behaviour.
| Function | Description |
|---|---|
sum(v) | Sum of elements |
prod(v) | Product of elements |
mean(v) | Arithmetic mean |
min(v) | Minimum element (1-arg form; min(a,b) still works) |
max(v) | Maximum element (1-arg form) |
any(v) | 1.0 if any element is non-zero |
all(v) | 1.0 if all elements are non-zero |
norm(v) | Euclidean (L2) norm |
norm(v, p) | General Lp norm; p = inf → max of absolute values |
sum([1 2 3 4]) % → 10
mean([1 2 3 4]) % → 2.5
min([3 1 4 1 5]) % → 1
max([3 1 4 1 5]) % → 5
any([0 0 1 0]) % → 1
all([1 2 3] > 0) % → 1
norm([3 4]) % → 5
norm([1 2 3], 1) % → 6 (L1 = sum of absolute values)
Column-wise on a matrix:
M = [1 2 3; 4 5 6]
sum(M) % → [5 7 9]
mean(M) % → [2.5 3.5 4.5]
Cumulative operations — return the same shape as the input:
| Function | Description |
|---|---|
cumsum(v) | Cumulative sum along the vector / each column |
cumprod(v) | Cumulative product |
cumsum([1 2 3 4]) % → [1 3 6 10]
cumprod([1 2 3 4]) % → [1 2 6 24]
7.5c — end keyword in indexing
Inside any index expression (...), the keyword end resolves to the size
of the dimension being indexed. Arithmetic on end is fully supported.
v = [10 20 30 40 50];
v(end) % → 50
v(end-1) % → 40
v(end-2:end) % → [30 40 50]
v(1:2:end) % → [10 30 50]
A = [1 2 3; 4 5 6; 7 8 9];
A(end, :) % → [7 8 9] last row
A(:, end) % → [3; 6; 9] last column
A(1:end-1, 2:end) % → [2 3; 5 6] submatrix
Implementation note: end is a context-sensitive value injected into the
evaluation environment by eval_index. Outside an indexing context it is
an undefined variable.
7.5d — Sort, reshape, and find
| Function | Description |
|---|---|
sort(v) | Sort ascending (vectors only) |
reshape(A, m, n) | Reshape to m×n using column-major (MATLAB) element order |
fliplr(v) | Reverse column order (left↔right mirror) |
flipud(v) | Reverse row order (up↔down mirror) |
find(v) | 1-based column-major indices of non-zero elements |
find(v, k) | First k non-zero indices |
unique(v) | Sorted unique elements as a 1×N row vector |
sort([3 1 4 1 5 9]) % → [1 1 3 4 5 9]
reshape(1:6, 2, 3) % → [1 3 5; 2 4 6]
fliplr([1 2 3]) % → [3 2 1]
flipud([1;2;3]) % → [3; 2; 1]
find([0 3 0 5 0]) % → [2 4]
find([1 0 2 0 3], 2) % → [1 3]
unique([3 1 4 1 5 3]) % → [1 3 4 5]
reshape uses column-major order — elements fill the output matrix
column-by-column, matching MATLAB/Octave behaviour:
reshape([1 2 3 4 5 6], 2, 3)
% col 0: [1 2], col 1: [3 4], col 2: [5 6]
% → [1 3 5]
% [2 4 6]
Example file
examples/vector_utils.calc covers all Phase 7.5 features with annotated
output. Run it with:
ccalc examples/vector_utils.calc
Implementation details
nan/infare handled inparse_primary(parser.rs) as named constants →Expr::Number(f64::NAN)/Expr::Number(f64::INFINITY), exactly likepiande. This allowsnan(m,n)to work as a builtin call without the variable shadowing the function.apply_elem,apply_reduction,apply_cumulative,find_nonzeroare private helpers ineval.rsthat keep the builtin match arms concise.endsupport:eval_indexcreates a cloned environment with"end" = dim_sizeviaenv_with_end()before callingresolve_dim. No new AST node is required —endis just a variable that exists only within the scope of the index evaluation.
Phase 8 — Complex Numbers
Version: v0.12.0
What was added
Value::Complex
A third variant was added to the Value enum in env.rs:
#![allow(unused)]
fn main() {
pub enum Value {
Scalar(f64),
Matrix(Array2<f64>),
Complex(f64, f64), // re, im
}
}
make_complex(re, im) in eval.rs collapses the result to Scalar(re)
when im == 0.0 exactly, so operations like (1+i)*(1-i) produce Scalar(2)
rather than Complex(2, 0).
Imaginary units i and j
i and j are pre-seeded in Env at startup as Complex(0.0, 1.0),
matching Octave/MATLAB behaviour. The user can overwrite them with
i = 5 (the new value shadows the imaginary unit for the session).
clear i removes the variable; the imaginary unit is not automatically
restored (document limitation).
Syntax — no new tokens
4i tokenizes as Number(4) Ident("i"), which triggers implicit
multiplication. With i = Complex(0.0, 1.0) in Env:
4*i → Complex(0.0, 4.0)
3 + 4*i → Complex(3.0, 4.0)
No tokenizer or parser changes were needed.
Arithmetic
complex_binop(re1, im1, op, re2, im2) handles all four combination groups:
| Left | Right | Routing |
|---|---|---|
| Complex | Complex | complex_binop directly |
| Complex | Scalar | complex_binop(re, im, op, s, 0.0) |
| Scalar | Complex | complex_binop(s, 0.0, op, re, im) |
| Complex | Matrix | error |
Supported operations:
| Op | Formula |
|---|---|
+ | (a+c) + (b+d)i |
- | (a-c) + (b-d)i |
* / .* | (ac-bd) + (ad+bc)i |
/ / ./ | ((ac+bd) + (bc-ad)i) / (c²+d²) |
^ / .^ integer | binary exponentiation (exact) |
^ / .^ general | polar form exp((c+di)·ln(a+bi)) |
== | 1 if both parts equal |
~= | 1 if either part differs |
</>/<=/>= | error: “Ordering is not defined for complex numbers” |
&& / || | nonzero test on `re != 0 |
Powers — integer exactness
Integer powers use binary exponentiation (repeated squaring) to avoid polar-form floating-point error:
i^2 = -1 (exact, not -1 + 1.22e-16i)
i^3 = -i (exact)
i^4 = 1 (exact)
General non-integer powers fall back to the polar form
exp((c+di) * (ln|z| + i*arg(z))).
Unary operators
| Op | Result |
|---|---|
-z | Complex(-re, -im) |
~z | 0 if re≠0 or im≠0, else 1 |
z' | Complex(re, -im) — conjugate transpose |
Display
format_complex(re, im, precision) in eval.rs:
| Condition | Display |
|---|---|
im == 0 | a (same as scalar) |
re == 0, im > 0 | bi (or i when im == 1) |
re == 0, im < 0 | -bi (or -i) |
im > 0 | a + bi |
im < 0 | a - |b|i |
The REPL prompt shows the complex value directly (e.g. [ 3 + 4i ]:).
Built-in functions
| Function | Description |
|---|---|
real(z) | Real part; returns z unchanged for scalars |
imag(z) | Imaginary part; returns 0 for scalars |
abs(z) | Modulus sqrt(re²+im²); overloads scalar and matrix abs |
angle(z) | Argument atan2(im, re) in radians |
conj(z) | Complex conjugate re - im*i |
complex(re, im) | Construct from two real scalars |
isreal(z) | 1 if im == 0, else 0 |
scalar_arg compatibility
scalar_arg(v, name, pos) now accepts Complex with im == 0 as a real
scalar, so sqrt(complex(4, 0)) returns 2 without error.
Scope boundary
Complex matrices (e.g. [1+2i, 3+4i]) are out of scope for this phase.
A matrix literal containing a Complex element returns:
Error: Complex elements in matrix literals are not supported yet
Complex variables are not persisted by ws/wl (same policy as matrices).
Files changed
| File | Change |
|---|---|
crates/ccalc-engine/src/env.rs | Added Value::Complex(f64, f64) |
crates/ccalc-engine/src/eval.rs | complex_binop, make_complex, format_complex, built-ins, exhaustive match arms |
crates/ccalc/src/repl.rs | new_env seeds i/j; all output paths handle Value::Complex |
crates/ccalc-engine/src/eval_tests.rs | 38 new tests covering all operations |
examples/complex_numbers.calc | New annotated example file |
Phase 9 — String Data Types
Version: v0.13.0
What was added
Two new Value variants
Two variants were added to the Value enum in env.rs:
#![allow(unused)]
fn main() {
pub enum Value {
Scalar(f64),
Matrix(Array2<f64>),
Complex(f64, f64),
/// Char array (single-quoted). Represents a 1×N row of characters.
Str(String),
/// String object (double-quoted). Scalar container of arbitrary text.
StringObj(String),
}
}
save_workspace uses as_scalar() to filter variables before writing —
the new variants return None from as_scalar() and are automatically
skipped, matching the policy for matrices and complex numbers.
Tokenizer changes — ' disambiguation
The ' character has two meanings:
- Transpose operator — when preceded by an rvalue context
- Char array literal — at the start of an expression or after an operator
The tokenizer tracks the last emitted token and applies this rule:
| Preceding whitespace | Last token | ' is |
|---|---|---|
| yes (or start of input) | any | Char array literal start |
| no | Number, Ident, RParen, RBracket, Apostrophe, Str | Transpose (Token::Apostrophe) |
| no | anything else | Char array literal start |
The whitespace rule (added in v0.30.0+001) is the key to making ['a' 'b']
work correctly: the space before 'b' signals a new string, not a
transpose of 'a'.
When a char array literal is detected, the tokenizer consumes characters
until the next '. The sequence '' (two consecutive single quotes)
represents a literal single-quote inside the string.
'hello' → Token::Str("hello")
'it''s ok' → Token::Str("it's ok")
x' → Ident("x") Apostrophe (transpose, no space)
['a' 'b'] → LBracket Str("a") Str("b") RBracket (space → new string)
"..." string object tokens
A new arm handles double-quoted string objects. Escape sequences are processed at tokenization time:
| Sequence | Result |
|---|---|
"" | Literal " |
\n | Newline |
\t | Tab |
\\ | Literal \ |
\" | Literal " |
New AST nodes
#![allow(unused)]
fn main() {
pub enum Expr {
// ...existing variants...
/// Single-quoted char array literal.
StrLiteral(String),
/// Double-quoted string object literal.
StringObjLiteral(String),
}
}
parse_primary handles Token::Str → Expr::StrLiteral and
Token::StringObj → Expr::StringObjLiteral. The existing postfix
transpose loop then applies: 'hello'' produces
Expr::Transpose(Box::new(Expr::StrLiteral("hello"))).
Arithmetic on char arrays
str_to_numeric(s: &str) -> Value converts a char array to its numeric
representation before binary operations:
| Input length | Result |
|---|---|
| 0 | Value::Matrix 1×0 |
| 1 | Value::Scalar(code) |
| N | Value::Matrix 1×N |
In eval_binop, the arms for Str appear before all others:
#![allow(unused)]
fn main() {
(Value::Str(s), r) => eval_binop(str_to_numeric(&s), op, r),
(l, Value::Str(s)) => eval_binop(l, op, str_to_numeric(&s)),
}
This means 'abc' + 1 → str_to_numeric("abc") = Matrix([97,98,99])
→ [98, 99, 100], and 'a' + 0 → Scalar(97) → Scalar(97).
String object operations
String objects support only + (concatenation) and ==/~= (comparison).
All other operators return an error:
#![allow(unused)]
fn main() {
(Value::StringObj(a), Value::StringObj(b)) => match op {
Op::Add => Ok(Value::StringObj(a + &b)),
Op::Eq => Ok(Value::Scalar(bool_to_f64(a == b))),
Op::NotEq => Ok(Value::Scalar(bool_to_f64(a != b))),
_ => Err("Operator not supported on string objects"),
},
}
New built-in functions
| Function | Arity | Description |
|---|---|---|
num2str | 1 | Number → char array |
num2str | 2 | Number → char array, N decimal digits |
str2num | 1 | Char array → number, error on failure |
str2double | 1 | Char array → number, NaN on failure |
strcat | ≥2 | Concatenate strings |
strcmp | 2 | Case-sensitive equality → 0/1 |
strcmpi | 2 | Case-insensitive equality → 0/1 |
lower | 1 | Lowercase |
upper | 1 | Uppercase |
strtrim | 1 | Strip whitespace |
strrep | 3 | Find-and-replace |
sprintf | 1 | Process escape sequences, return Str |
ischar | 1 | 1 if Str, else 0 |
isstring | 1 | 1 if StringObj, else 0 |
length, numel, and size were extended to handle both new variants:
length(Str(s))→ number of characters inslength(StringObj(_))→ 1 (scalar element)numelandsizefollow the same pattern
Helper functions
string_arg(v, fname, pos) extracts a string slice from Str or StringObj,
returning a descriptive error for any other type.
process_escape_sequences(s) handles \n, \t, \\, \', \" —
factored out of repl.rs into eval.rs for use by sprintf.
Display
format_valuereturns the string content as-is for both variants.format_value_fullreturnsNone(strings are displayed inline like scalars).- The REPL prompt shows the first 15 characters with surrounding quotes when
ansis a string. whoannotates type:name [1×N char]forStr,name [string]forStringObj.
Exhaustiveness
All existing functions that matched Value variants (eval_index,
resolve_dim, apply_elem, apply_reduction, apply_cumulative,
find_nonzero, scalar_arg) were updated to handle Str and StringObj —
either with string-specific logic or a clear error message.
What was not changed
- Workspace save/load for strings is intentionally skipped (same policy as matrices and complex).
strsplitrequires cell arrays (not yet implemented) and is deferred.- The
split_stmts()function inrepl.rsalready tracked single-quoted and double-quoted string boundaries (from earlier disambiguation work in Phase 4). No changes were needed there.
Enhancement — v0.30.0+001: char-array matrix literals
What was added
['str' expr 'str'] — horizontal concatenation of char arrays inside
bracket literals, matching MATLAB/Octave semantics.
String context (first element is Str/StringObj): the result is a
single Value::Str. Numeric scalars and matrices are treated as Unicode
code points (e.g., ['A' 66] → 'AB').
Numeric context (first element is numeric): Str/StringObj elements
contribute their code values to the numeric row (e.g., [65 'B'] → [65 66]).
Multi-row char-array literals are not supported; building a 2-D char matrix
would require a new type that doesn’t exist in ccalc’s Value enum.
Files changed
| File | Change |
|---|---|
crates/ccalc-engine/src/eval.rs | Added MatKind::Str arm to matrix literal evaluator; extended MatKind::Numeric to handle Str/StringObj elements |
crates/ccalc-engine/src/parser.rs | tokenize(): added prev_was_ws flag; ' after whitespace always starts a string |
crates/ccalc-engine/src/eval_tests.rs | mod char_array_literal_tests — 11 new tests |
docs/src/guide/strings.md | New section “Char-array concatenation with [...]” |
crates/ccalc/src/help.rs | Added [...] concatenation to help strings |
Test count: 877 total (11 new).
Phase 10 — C-style I/O and format command
Status: Complete ✅
Version: v0.14.0 / v0.14.0+001
Prerequisite: Phase 9 (string types)
What was implemented
10a — fprintf with format specifiers
fprintf(fmt, arg1, arg2, ...) built-in, interpreting a format string with
C-style conversion specifiers:
| Specifier | Meaning |
|---|---|
%d, %i | decimal integer (value truncated to i64) |
%f | fixed-point decimal, default 6 places |
%e | scientific notation (1.23e+04) |
%g | shorter of %f and %e |
%s | string (char array or string object) |
%% | literal % |
Width, precision, and flags (-, +, 0, space) follow C printf.
When there are more arguments than specifiers, the format string repeats for remaining args (Octave behaviour).
fprintf is implemented as a case in call_builtin in eval.rs — not as a
REPL special case. Returns Value::Void; the REPL suppresses display for Void.
10b — sprintf
Same format engine (format_printf in eval.rs), but returns the formatted
result as Value::Str (char array) instead of printing.
Single-arg sprintf(fmt) processes escape sequences — this replaces the former
escape-only form.
10c — Precision system overhaul
The p / p<N> precision directive was removed from the language.
Replaced by the format command (v0.14.0+001).
10d — format command (v0.14.0+001)
MATLAB-compatible number display modes:
| Mode | Description |
|---|---|
short | 5 significant digits, auto fixed/scientific (default) |
long | 15 significant digits |
shortE | always scientific, 4 decimal places |
longE | always scientific, 14 decimal places |
shortG | alias for short |
longG | alias for long |
bank | fixed 2 decimal places |
rat | rational approximation via continued fractions |
hex | IEEE 754 double bit pattern (16 uppercase hex digits) |
+ | sign only: +, -, or space |
compact | suppress blank lines between outputs |
loose | add blank line after every output |
N | N decimal places (legacy custom mode) |
format alone resets to short.
Key implementation details
Value::Void
New variant added to the Value enum in env.rs. Returned by side-effectful
functions (fprintf). The REPL checks for Value::Void and skips display.
format_printf function
Core format engine in eval.rs (public). Processes a format string and a slice
of Value args, returns a String. Handles repeat-format semantics, all
specifiers, all flags.
FormatMode enum
#![allow(unused)]
fn main() {
pub enum FormatMode {
Short, Long, ShortE, LongE, ShortG, LongG,
Bank, Rat, Hex, Plus, Custom(usize),
}
}
Added after Base enum in eval.rs. Default is Custom(10) (preserves
existing test baseline). format command in REPL resets to Short.
Changed public API signatures:
#![allow(unused)]
fn main() {
pub fn format_scalar(n: f64, base: Base, mode: &FormatMode) -> String
pub fn format_complex(re: f64, im: f64, mode: &FormatMode) -> String
pub fn format_value(v: &Value, base: Base, mode: &FormatMode) -> String
pub fn format_value_full(v: &Value, mode: &FormatMode) -> Option<String>
}
fmt_rat — rational approximation
Continued fractions algorithm. Tolerance: 1e-6. Max denominator: 10,000.
Gives 355/113 for pi.
trim_sci — exponent normalization
Normalizes Rust’s e0/e-3 format to MATLAB-style e+00/e-03 with
two-digit exponent and explicit sign.
Scope boundary
- File I/O (
fopen/fclose/fgetl,dlmread/dlmwrite) — implemented in Phase 10.5. fprintf(fd, ...)with file descriptor — implemented in Phase 10.5.- Complex matrices in
fprintf— not supported (same boundary as Phase 8).
Tests
eval_tests.rs: 9 newtest_format_*tests covering all modes.repl_tests.rs: updated harness (FormatMode::default()replacesprecision: usize).- Total: 427 tests passing (97 repl + 330 engine).
Phase 10.5 — File I/O and Filesystem Queries
Status: Complete ✅
Version: v0.14.0+006
Prerequisite: Phase 10 (string types for filename arguments)
What was implemented
10.5a — File handles
Low-level file I/O using integer file descriptors, matching the MATLAB/Octave fopen/fclose model.
| Function | Description |
|---|---|
fopen(path, mode) | Open file; returns fd (≥3) or -1 on failure |
fclose(fd) | Close by fd; returns 0 on success, -1 on error |
fclose('all') | Close all open file handles |
fgetl(fd) | Read one line, strip trailing newline; returns -1 at EOF |
fgets(fd) | Read one line, keep trailing newline; returns -1 at EOF |
fprintf(fd, fmt, ...) | Write formatted output to file descriptor |
Supported modes: 'r' read, 'w' write (create/truncate), 'a' append, 'r+' read+write.
Pre-opened virtual descriptors: fd 1 = stdout, fd 2 = stderr (Octave convention).
10.5b — Delimiter-separated data
Read and write numeric data in CSV or TSV format.
| Function | Description |
|---|---|
dlmread(path) | Read; auto-detect , / \t / whitespace |
dlmread(path, delim) | Explicit delimiter |
dlmwrite(path, A) | Write matrix with , separator |
dlmwrite(path, A, delim) | Explicit delimiter |
Returns Value::Matrix; all values must be numeric. Non-numeric data returns an error with the line number.
10.5c — Filesystem queries
| Function | Description |
|---|---|
isfile(path) | 1 if the path exists and is a regular file |
isfolder(path) | 1 if the path exists and is a directory |
pwd() | Current working directory as a char array |
exist(name) | 1 if variable in workspace; 2 if a file exists on disk |
exist(name, 'var') | Check workspace only |
exist(name, 'file') | Check filesystem only; returns 2 (MATLAB numeric code) |
10.5d — Workspace with explicit path
Extension of the existing ws/wl commands to accept an explicit file path.
| Syntax | Description |
|---|---|
save | Save all variables to default path (alias for ws) |
save('path.mat') | Save all variables to named file |
save('path.mat', 'x', 'y') | Save specific variables only |
load | Load from default path (alias for wl) |
load('path.mat') | Load from named file |
Path argument can be a variable reference: save(path_var).
Persisted types: Scalar, Str (char array), StringObj. Matrices, complex values, and Void are always skipped.
Key implementation details
IoContext
New struct in ccalc-engine/src/io.rs. Holds a HashMap<i32, FileHandle> and next_fd: i32 (starts at 3). FileHandle is an enum: Read(BufReader<File>) | Write(File). fd 1 and 2 are handled virtually in write_to_fd.
eval_with_io / eval
#![allow(unused)]
fn main() {
pub fn eval(expr: &Expr, env: &Env) -> Result<Value, String>
pub fn eval_with_io(expr: &Expr, env: &Env, io: &mut IoContext) -> Result<Value, String>
}
Both are wrappers around private eval_inner(expr, env, mut io: Option<&mut IoContext>). The Option<&mut T> pattern with as_deref_mut() reborrowing allows sequential mutable borrows in recursive calls without cloning.
call_builtin signature change
#![allow(unused)]
fn main() {
fn call_builtin(name: &str, args: &[Value], env: &Env, io: Option<&mut IoContext>)
}
env: &Env was added for exist('x', 'var') which needs to inspect the workspace.
try_parse_save_load
Intercepts save/load statements in repl.rs before they reach evaluate(). Accepts env: &Env for resolving variable-reference path arguments (save(mat_path) where mat_path is a Str/StringObj in the workspace).
#![allow(unused)]
fn main() {
enum SaveLoadCmd {
Save { path: Option<String>, vars: Vec<String> },
Load { path: Option<String> },
}
fn try_parse_save_load(stmt: &str, env: &Env) -> Option<SaveLoadCmd>
}
Workspace serialization
name = 3.14 # Scalar
name = 'text' # Str (char array)
name = "text" # StringObj
Strings containing ' or " characters (matching the quote style) or \n are skipped to preserve the line-based format.
Tests
eval_tests.rs:test_fopen_write_and_fclose,test_fgetl_reads_lines,test_fgets_keeps_newline,test_fprintf_to_file,test_fclose_all,test_fopen_nonexistent_returns_minus_one,test_dlmwrite_and_dlmread_comma,test_dlmwrite_tab_delimiter,test_dlmread_empty_file,test_dlmread_non_numeric_error,test_dlmread_whitespace_auto,test_dlmwrite_scalar,test_isfile_existing_file,test_isfile_nonexistent,test_isfile_directory_is_false,test_isfolder_existing_dir,test_isfolder_file_is_false,test_exist_var_found,test_exist_var_not_found,test_exist_file_found,test_exist_file_not_found,test_exist_one_arg_checks_var_then_file,test_pwd_returns_string,test_fprintf_returns_voidrepl_tests.rs: 9 new Phase 10.5d tests includingtest_pipe_save_load_roundtripandtest_pipe_save_selective_vars- Total: 461 tests passing
Phase 11 — Core Control Flow
Version: 0.15.x
Status: ✅ Complete
Motivation
Phase 10 (formatted output) made it practical to write loop scripts. Phase 11
adds the block statements that make those scripts possible: if, for,
while, break, continue, and compound assignment operators.
Phase 11a — Multi-line input and if / for / while (v0.15.0)
Architecture
A new exec.rs module houses exec_stmts(), which avoids circular dependency:
parser.rs → eval.rs, and exec.rs → both. The Stmt enum gains five
new variants:
#![allow(unused)]
fn main() {
pub enum Stmt {
Assign(String, Expr),
Expr(Expr),
If {
cond: Expr,
body: Vec<(Stmt, bool)>,
elseif_branches: Vec<(Expr, Vec<(Stmt, bool)>)>,
else_body: Option<Vec<(Stmt, bool)>>,
},
For { var: String, range_expr: Expr, body: Vec<(Stmt, bool)> },
While { cond: Expr, body: Vec<(Stmt, bool)> },
Break,
Continue,
}
}
exec_stmts returns Result<Option<Signal>, String> where Signal is
Break | Continue. Loops catch these signals; a signal that escapes to the
top level is reported as an error.
REPL buffering
block_depth_delta(line) returns +1 for if/for/while and -1 for
end. The REPL accumulates lines into block_buf while block_depth > 0,
then calls parse_stmts() + exec_stmts() when the block closes.
[ 0 ]: for k = 1:3
>> fprintf('%d\n', k)
>> end
1
2
3
For loop semantics
The range expression is evaluated once before any iteration. Iteration is column-by-column (Octave convention):
- Row vector (1×N): each element becomes a
Scalar - M×N matrix: each column becomes an M×1
Matrix
is_truthy
#![allow(unused)]
fn main() {
fn is_truthy(val: &Value) -> bool {
match val {
Value::Scalar(n) => *n != 0.0 && !n.is_nan(),
Value::Matrix(m) => m.iter().all(|&x| x != 0.0 && !x.is_nan()),
Value::Complex(re, im) => *re != 0.0 || *im != 0.0,
Value::Str(s) | Value::StringObj(s) => !s.is_empty(),
Value::Void => false,
}
}
}
Known limitation
Single-line blocks (if cond; body; end on one line) are not supported.
Use multi-line form.
Phase 11b — Compound assignment operators (v0.15.1)
Six new token variants are added to the lexer:
| Token | Source |
|---|---|
PlusEq | += |
MinusEq | -= |
StarEq | *= |
SlashEq | /= |
PlusPlus | ++ |
MinusMinus | -- |
try_parse_compound(tokens) is called first in parse(). All forms desugar
to Stmt::Assign at parse time — no new AST nodes are needed.
| Input | Desugared to |
|---|---|
x += e | x = x + e |
x -= e | x = x - e |
x *= e | x = x * e |
x /= e | x = x / e |
x++ | x = x + 1 |
x-- | x = x - 1 |
++x | x = x + 1 |
--x | x = x - 1 |
The RHS of op= is a full expression (parse_logical_or), so
x *= 2 + 3 desugars to x = x * (2 + 3).
is_partial() was updated so ++x/--x are not mistakenly treated as
partial binary expressions.
Limitation: ++/-- are statement-level only.
Syntax aliases (v0.15.2)
| Alias | Equivalent |
|---|---|
# | % (comment) |
! | ~ (logical NOT) |
!= | ~= (not equal) |
# is recognised in the tokenizer, strip_line_comment(), and
split_stmts(). ! and != are emitted as Token::Tilde and
Token::NotEq respectively — no changes downstream.
Phase 11.5 — Extended Control Flow
Version: 0.16.0
Status: ✅ Complete
Motivation
Phase 11 added the core control flow constructs (if, for, while). Phase 11.5
fills in the remaining Octave constructs most commonly found in real .m scripts:
switch, do...until, and the ability to source another script file with run().
Phase 11.5a — switch / case / otherwise (v0.16.0)
Semantics
switch in Octave/MATLAB has no fall-through: the first matching case
executes and control jumps directly to end. otherwise is the optional
default branch.
switch expr
case val1
% ...
case val2
% ...
otherwise
% ...
end
Matching rules
| Switch value | Case value | Match |
|---|---|---|
| Scalar | Scalar | exact == |
Str or StringObj | Str or StringObj | string equality (Str and StringObj interchangeable) |
| Any other combination | — | no match |
break and continue
break/continue inside a switch body propagate outward to the nearest
enclosing loop. switch itself is not a loop.
AST
#![allow(unused)]
fn main() {
Stmt::Switch {
expr: Expr,
cases: Vec<(Vec<Expr>, Vec<(Stmt, bool)>)>,
otherwise_body: Option<Vec<(Stmt, bool)>>,
}
}
Each case carries a Vec<Expr> for future multi-value case support (case {1,2,3}
from Phase 11.5b, deferred until cell arrays are introduced in a later phase).
Example
switch code
case 200
msg = 'OK';
case 301
msg = 'Moved Permanently';
case 404
msg = 'Not Found';
otherwise
msg = 'Unknown';
end
fprintf('HTTP %d: %s\n', code, msg)
Phase 11.5b — Multi-value cases (deferred)
case {val1, val2} syntax requires Value::Cell, which is not yet implemented.
Deferred until cell arrays are introduced.
Phase 11.5c — do...until (v0.16.0)
Semantics
Octave-specific post-test loop. The body always executes at least once, then the condition is tested. If truthy, the loop exits.
do
body
until (cond)
Parentheses around cond are optional. break exits the loop immediately;
continue re-tests the condition.
until closes the block without a separate end. In the REPL,
block_depth_delta("until …") returns −1.
AST
#![allow(unused)]
fn main() {
Stmt::DoUntil {
body: Vec<(Stmt, bool)>,
cond: Expr,
}
}
Execution (exec.rs)
#![allow(unused)]
fn main() {
Stmt::DoUntil { body, cond } => loop {
match exec_stmts(body, env, io, fmt, base, compact)? {
Some(Signal::Break) => break,
Some(Signal::Continue) | None => {}
}
if is_truthy(&eval_with_io(cond, env, io)?) {
break;
}
},
}
Example
% Smallest power of 2 >= n
n = 100;
p = 1;
do
p *= 2;
until (p >= n)
fprintf('%d\n', p) % 128
Phase 11.5d — try/catch (deferred)
Deferred to Phase 14 (after structs). try/catch requires Value::Struct
for the error object, and the error-handling model is under design review.
Phase 11.5e — Script sourcing run() / source() (v0.16.0)
Semantics
Execute a script file in the caller’s workspace (MATLAB run semantics).
Variables defined in the script persist after run returns. This is the opposite
of a function call, which would have an isolated scope.
run('script') % search for script.calc, then script.m in CWD
run('script.calc') % explicit .calc extension
run('script.m') % explicit .m extension
source('script') % Octave alias — identical behaviour
Extension resolution for bare names
When no file extension is given, ccalc tries:
<name>.calc— native ccalc script format (preferred)<name>.m— Octave/MATLAB compatibility
Explicit extensions (.calc, .m, or any other) are used verbatim.
Implementation
run()/source() are intercepted in exec_stmts by pattern-matching
Stmt::Expr(Expr::Call("run"|"source", args)) before calling eval_with_io.
This avoids needing a new AST node:
#![allow(unused)]
fn main() {
if let Expr::Call(fn_name, args) = expr
&& matches!(fn_name.as_str(), "run" | "source")
&& args.len() == 1
{
// resolve path → read file → parse_stmts → exec_stmts (recursive)
}
}
In pipe/script mode, single-line statements bypass exec_stmts and go through
evaluate(). A try_run_source() helper in repl.rs bridges that gap by
routing run/source calls through exec_stmts before evaluate() is reached.
Recursion limit
A thread_local! RUN_DEPTH counter prevents infinite recursion. Maximum depth
is 64; exceeding it returns an error.
Example
% euclid_helper.calc — reads a, b from workspace; writes g = gcd(a, b)
g = a;
r = b;
while r ~= 0
temp = mod(g, r);
g = r;
r = temp;
end
% caller
a = 252; b = 105;
run('euclid_helper')
fprintf('gcd(252, 105) = %d\n', g) % 21
REPL block depth
block_depth_delta was updated for the new keywords:
| Line starts with | Delta |
|---|---|
switch | +1 |
do | +1 |
until … | −1 |
end | −1 (unchanged) |
Demo
cd examples
ccalc extended_control_flow.calc
The example covers all constructs from this phase: switch integer and string
dispatch, do...until with break/continue, Euclidean GCD via run(), and the
source() alias.
Phase 12 — User-defined Functions
Version: v0.17.0
Prerequisite: Phase 11.5 (extended control flow, return, run/source)
Overview
Phase 12 introduces user-defined named functions, multiple return values,
the return statement for early exit, and anonymous functions (lambdas) created
with @(params) expr.
Named functions
function result = name(p1, p2)
...
result = expr;
end
Functions are defined at the top level — in the REPL or in a script file.
They are stored in the workspace like any variable and persist until clear is called.
Single return value
function y = square(x)
y = x ^ 2;
end
square(5) % 25
square(12) % 144
Multiple return values
function [mn, mx, avg] = stats(v)
mn = min(v);
mx = max(v);
avg = mean(v);
end
data = [4 7 2 9 1 5 8 3 6];
[lo, hi, mu] = stats(data);
% lo = 1 hi = 9 mu = 5
Discarding outputs with ~
[~, top, ~] = stats([10 30 20]); % top = 30
nargin — optional arguments
nargin is injected into every function body and holds the number of arguments
actually passed by the caller. Use it to implement default parameter values:
function y = power_fn(base, exp)
if nargin < 2
exp = 2;
end
y = base ^ exp;
end
power_fn(5) % 25 (exp defaults to 2)
power_fn(2, 8) % 256
power_fn(3, 3) % 27
return — early exit
return exits the function immediately. All output variables must be assigned
before return is reached:
function g = gcd_fn(a, b)
while b ~= 0
r = mod(a, b);
a = b;
b = r;
end
g = a;
end
Recursive early return:
function result = factorial_r(n)
if n <= 1
result = 1;
return
end
result = n * factorial_r(n - 1);
end
factorial_r(7) % 5040
Scope
Each function call creates a fresh isolated scope:
- The caller’s data variables (scalars, matrices, strings, etc.) are not visible.
- Declared parameters are bound in the local scope.
i,j, andansare pre-seeded.narginandnargoutare injected.- All
FunctionandLambdavalues from the caller’s workspace are forwarded, enabling self-recursion and mutual recursion without leaking data.
Anonymous functions (lambdas)
f = @(params) expr
@(x) expr creates a closure. The current environment is captured at
definition time (lexical scoping):
sq = @(x) x ^ 2;
cube = @(x) x ^ 3;
hyp = @(a, b) sqrt(a^2 + b^2);
sq(7) % 49
cube(4) % 64
hyp(3, 4) % 5
Lexical capture
Changing a variable after a lambda is defined does not affect the lambda:
rate = 0.05;
interest = @(principal, years) principal * (1 + rate) ^ years;
interest(1000, 10) % 1628.89 (captured rate = 0.05)
rate = 0.99; % lambda is unaffected
interest(1000, 10) % still 1628.89
Lambdas as arguments
Pass a lambda to a named function using @:
function s = midpoint(f, a, b, n)
h = (b - a) / n;
s = 0;
for k = 1:n
xm = a + (k - 0.5) * h;
s += f(xm);
end
s *= h;
end
% integral of x^2 from 0 to 1 = 1/3
midpoint(@(x) x^2, 0, 1, 1000) % 0.333333
% integral of sin(x) from 0 to pi = 2
midpoint(@(x) sin(x), 0, pi, 1000) % 2.000001
Functions returning functions
Named functions can return lambdas (higher-order programming):
function f = make_adder(c)
f = @(x) x + c;
end
add5 = make_adder(5);
add10 = make_adder(10);
add5(3) % 8
add10(7) % 17
add5(add10(1)) % 16
Implementation details
| Concern | Solution |
|---|---|
Circular dependency (eval.rs ↔ parser.rs) | Named functions store body_source: String; re-parsed on each call in exec.rs |
| Cross-module dispatch | Thread-local FnCallHook in eval.rs, registered by exec::init() |
| Lexical closure | Lambda captures Env clone at @ parse time; stored as Value::Lambda(Rc<dyn Fn>) |
| Recursion | call_user_function copies all Function/Lambda entries from caller’s env into local scope |
| Multi-return | Value::Tuple(Vec<Value>) returned and destructured by Stmt::MultiAssign |
Empty call f() | Parser injects Expr::Var("ans"); both call sites trim 1 extra arg silently |
New AST nodes and tokens
| Name | Kind | Description |
|---|---|---|
Stmt::FunctionDef | Statement | function [outs] = name(params) body end |
Stmt::Return | Statement | return inside a function |
Stmt::MultiAssign | Statement | [a, b] = expr destructuring |
Expr::Lambda | Expression | @(params) expr |
Token::At | Token | @ prefix for lambdas |
Signal::Return | Signal | propagates early return through exec_stmts |
New Value variants
| Variant | Description |
|---|---|
Value::Function { outputs, params, body_source } | Named user-defined function |
Value::Lambda(LambdaFn) | Anonymous function / closure |
Value::Tuple(Vec<Value>) | Internal multi-return value |
Example
Run the full demo:
ccalc examples/user_functions.calc
Phase 12.5 — Cell Arrays
Version: v0.17.0+005
Prerequisite: Phase 12 (lambdas needed for cellfun/arrayfun)
Trigger: Three features blocked on Value::Cell:
case {2, 3} (Phase 11.5b), varargin/varargout (deferred from Phase 12),
and cellfun/arrayfun (higher-order built-ins).
12.5a — Core cell array infrastructure
Value::Cell
Added to env.rs after Value::Tuple:
#![allow(unused)]
fn main() {
/// Heterogeneous 1-D container: each element may be any `Value`.
Cell(Vec<Value>),
}
Only 1-D cell vectors for now (Vec<Value>). 2-D cell arrays deferred.
New tokens and AST nodes
Tokens (parser.rs):
Token::LBrace({) andToken::RBrace(})
Expr variants (eval.rs):
#![allow(unused)]
fn main() {
CellLiteral(Vec<Expr>) // {e1, e2, e3}
CellIndex(Box<Expr>, Box<Expr>) // c{i}
FuncHandle(String) // @funcname
}
Stmt variant (parser.rs):
#![allow(unused)]
fn main() {
CellSet(String, Expr, Expr) // c{i} = v
}
Parser changes
parse_primary:{→ comma-separatedparse_logical_orelements →Expr::CellLiteral.- After parsing an identifier, if next token is
LBrace→Expr::CellIndex. c{i} = vdetected inparse()lookahead viatry_split_cell_assign(), producesStmt::CellSet.split_stmts()now tracksbrace_depthalongsidebracket_depthso;inside{...}is not treated as a statement separator.
Evaluator changes (eval.rs)
Expr::CellLiteral→Value::Cell(vals)Expr::CellIndex→ bounds-check (1-based), returns element valueExpr::FuncHandle(name)→Value::Lambdathat looks upnamein caller env then falls back to builtins
Executor changes (exec.rs)
Stmt::CellSet→ look up cell in env, update element at index, auto-grow if neededis_truthyextended:Value::Cell(v) => !v.is_empty()
Built-ins
| Function | Description |
|---|---|
iscell(v) | 1.0 if v is Value::Cell, else 0.0 |
cell(n) | Value::Cell of n slots, each Scalar(0.0) |
cell(m, n) | Value::Cell of m*n slots |
numel(c) | element count of a cell |
length(c) | same as numel for 1-D |
size(c) | [1, numel(c)] as 1×2 matrix |
Display
c =
{
[1,1]: 42
[1,2]: hello
[1,3]: [1×3 double]
}
12.5b — varargin / varargout
varargin
When a user function’s last parameter is named varargin, all extra call
arguments are collected into a Value::Cell and bound to varargin in the
local scope. Fixed parameters are bound normally.
function s = sum_all(varargin)
s = 0;
for k = 1:numel(varargin)
s += varargin{k};
end
end
sum_all(1, 2, 3) % varargin = {1, 2, 3} → 6
sum_all() % varargin = {} → 0
varargout
When the sole output variable is varargout, after the function body executes,
its cell elements are unpacked as return values:
function varargout = swap(a, b)
varargout{1} = b;
varargout{2} = a;
end
[x, y] = swap(10, 20) % x = 20, y = 10
Implementation note (injection fix, v0.17.0+005)
The parser previously injected Expr::Var("ans") into every empty f() call
at the AST level. This made sum_all() and sum_all(1) indistinguishable
inside call_user_function when fixed_params.is_empty(), causing varargin
to always be empty.
Fix: injection moved from parser to eval-time:
- Builtins and lambdas: inject
answhen called with empty args (f()=f(ans)) Value::Function(user functions): no injection — empty call = no arguments
12.5c — case {val1, val2} in switch
Completes Phase 11.5b. When a switch case value evaluates to Value::Cell,
the evaluator iterates its elements and tests each with == against the switch
expression. First match wins; no fall-through (same semantics as scalar case).
switch x
case {2, 3}
disp('two or three')
case {10, 20, 30}
disp('ten, twenty, or thirty')
end
No parser change needed: case {2, 3} was already parsed as Expr::CellLiteral
once LBrace/RBrace tokens existed (12.5a).
12.5d — cellfun and arrayfun
Higher-order built-ins that apply a function to each element of a collection.
cellfun(f, c)
Applies f to each element of cell c. Returns Value::Matrix when all
results are scalar; returns Value::Cell otherwise.
cellfun(@sqrt, {1, 4, 9}) % [1 2 3]
cellfun(@(x) x * 2, {1, 4, 9}) % [2 8 18]
arrayfun(f, v)
Applies f to each element of numeric vector v. Returns same-shape Value::Matrix.
arrayfun(@(x) x^2, [1 2 3 4]) % [1 4 9 16]
arrayfun(@abs, [-1 2 -3 4]) % [1 2 3 4]
Both implemented as cases in call_builtin; no new AST nodes.
@funcname function handles
@funcname (without parentheses) creates a Value::Lambda that forwards its
arguments to funcname — either a builtin or a named user function stored in
the environment.
Expr::FuncHandle(name) → Value::Lambda wrapping name lookup + call
The lambda captures the caller’s env at creation time. On each call it first
checks the captured env for a user function, then falls back to call_builtin.
f = @sqrt;
g = @myFunc; % wraps a user-defined function
cellfun(@sqrt, {1, 4, 9}) % works: @sqrt passes sqrt as a Lambda
Tests
17 new tests in parser_tests.rs covering:
- Cell literal and indexing
- Cell assignment and auto-grow
iscell,cell(),numel,lengthcellfun,arrayfunswitchwithcase {…}varargin- Out-of-bounds index error
Known limitations
- 2-D cell arrays are not supported (all cells are 1-D
Vec<Value>) c{i}is only supported whereiis a simple expression — postfix chainingc{k}(args)is not yet supported (usef = c{k}; f(args)instead)- Workspace save/load skips cell variables (same policy as matrices)
Phase 12.6 — Language Polish and Small Completions
Version: v0.18.0+001
Prerequisite: Phase 12.5 (cell arrays — required for strsplit return type)
This phase closes accumulated gaps that are each small in isolation but collectively leave visible holes in Octave/MATLAB compatibility.
12.6a — Single-line blocks
if, for, while, and switch can now appear on a single line with their
body and closing end separated by semicolons:
if x > 5; label = 'big'; end
for k = 1:5; total += k; end
while mod(n,2) == 0; n = n/2; end
switch day; case 1; name='Mon'; case 2; name='Tue'; otherwise; name='?'; end
Implementation: is_single_line_block(line) detects a complete block by
splitting on ; and checking whether the last segment’s leading keyword is
end or until. The REPL and pipe mode bypass block buffering for these lines.
12.6b — Line continuation ...
A line ending with ... (after stripping comments) continues on the next line:
result = 1 + ...
2 + ...
3; % result = 6
A = [1 2 3; ...
4 5 6]; % 2×3 matrix
if value > 0 && ...
value < 100
disp('in range')
end
Three implementation points:
- REPL:
cont_bufaccumulates partial lines; not dispatched until continuation ends. - Pipe/file mode: same
cont_buflogic inrun_pipe. - Block parser:
join_line_continuations()pre-pass inparse_stmtsjoins...-continued lines before statement splitting.
12.6c — Element-wise logical operators & and |
&& and || are short-circuit scalar operators. & and | are element-wise
operators that work on matrices and always evaluate both sides:
a = [1 0 1 0];
b = [1 1 0 0];
a & b % [1 0 0 0] element-wise AND
a | b % [1 1 1 0] element-wise OR
% Logical mask — common pattern
v = [3, -1, 8, 0, 5, -2, 7];
mask = v > 0 & v < 6 % [1 0 0 0 1 0 0]
New tokens Token::Amp / Token::Pipe and Op::ElemAnd / Op::ElemOr.
New parse levels parse_elem_or and parse_elem_and sit between
parse_logical_and and parse_comparison in the precedence hierarchy.
12.6d — xor and not built-ins
xor(1, 0) % 1
xor(0, 0) % 0
xor([1 0 1 0], [1 1 0 0]) % [0 1 1 0]
not(0) % 1 (alias for ~)
not(5) % 0
not([1 0 1]) % [0 1 0]
12.6e — Lambda source display
Lambdas now display their source expression instead of @<lambda>:
>> f = @(x) x^2 + 1
f = @(x) x ^ 2 + 1
>> g = @(a, b) sqrt(a^2 + b^2)
g = @(a, b) sqrt(a ^ 2 + b ^ 2)
>> h = @sin
h = @sin
LambdaFn carries a second field with the source string, populated at parse
time by expr_to_string() which reconstructs a readable expression from the AST.
12.6f — String utilities
% strsplit — returns a cell array
parts = strsplit('alpha,beta,gamma', ',')
parts{1} % 'alpha'
parts{2} % 'beta'
words = strsplit('hello world') % split on whitespace
numel(words) % 2
% int2str — round to integer, return string
int2str(3.2) % '3'
int2str(3.7) % '4'
int2str(-1.5) % '-2'
% mat2str — matrix to MATLAB literal string
mat2str([1 2; 3 4]) % '[1 2;3 4]'
mat2str([10 20 30]) % '[10 20 30]'
strsplit returns Value::Cell of Value::Str — requires Phase 12.5.
12.6g — .' non-conjugate transpose
A' is the conjugate transpose (Hermitian) — it flips the sign of complex imaginary
parts. A.' is the plain transpose with no conjugation:
% Real matrices — identical result
B = [1 2 3; 4 5 6];
B.' % [1 4; 2 5; 3 6] (same as B')
% Complex — different result
z = 3 + 4i
z' % 3 - 4i (conjugate)
z.' % 3 + 4i (plain)
Token::DotApostrophe is emitted when . is immediately followed by '.
Expr::PlainTranspose(Box<Expr>) evaluates identically to Expr::Transpose for
real values, but skips complex conjugation for Value::Complex.
12.6j — Minor syntax completions
Unary +
+x is a no-op (returns x unchanged). Previously caused a parse error.
+5 % 5
+(-3) % -3
+[1 2 3] % [1 2 3]
** exponentiation alias
Octave accepts ** as a synonym for ^:
2 ** 10 % 1024
3 ** 3 % 27
2 ** 0.5 % 1.41421...
, as non-silent statement separator
A comma between statements is like a newline — the result is shown (unlike ;
which suppresses output):
a = 1, b = 2 % shows: a = 1 then b = 2
a = 1; b = 2 % a silent, b shown
Bug fixes (v0.18.0+001)
-
4iimaginary literal —3 + 4inow works in pipe and file mode. The tokenizer’spush_imag_suffix()helper emits* itokens after any decimal literal followed immediately byiorj. -
B.';split incorrectly —split_stmtsnow recognises.as a transpose indicator, preventingB.'from being mis-parsed as a string start. -
...in pipe mode —run_pipenow has the samecont_buflogic asrun_repl, so multi-line scripts using...work correctly.
Example
ccalc examples/language_polish.calc
The example covers all ten sections with expected output annotations.
Phase 13 — Structs
Version: 0.19.0 / 0.19.0+001 Status: Complete (13a — scalar structs, 13.5 — struct arrays)
Motivation
Scalar structs are required by:
- Real
.mscripts that pass labelled data between functions - Phase 14 (
try/catch) — the caught exception objecteis a struct with fieldsmessageandidentifier dir()— returns a struct array of file entries (Phase 13.5, deferred)
13a — Scalar structs (complete)
Value type
Value::Struct(IndexMap<String, Value>) in env.rs. Uses the indexmap
crate to preserve insertion order (MATLAB-compatible). Added dependency:
# crates/ccalc-engine/Cargo.toml
indexmap = "2"
Token
Token::Dot emitted in the tokenizer only when . is followed by an ASCII
letter or underscore. The existing DotStar / DotSlash / DotCaret /
DotApostrophe tokens are unaffected — no ambiguity.
AST changes
| Node | Description |
|---|---|
Expr::FieldGet(Box<Expr>, String) | s.x — postfix field read; chained via a loop in parse_primary: s.a.b → FieldGet(FieldGet(Var("s"),"a"),"b") |
Stmt::FieldSet(String, Vec<String>, Expr) | s.x = rhs → ("s", ["x"], rhs); s.a.b = rhs → ("s", ["a","b"], rhs) |
Parser
try_split_field_assign() — byte-level string scan that detects the pattern
ident (.ident)+ = before tokenization. Called first in parse(), before
try_split_cell_assign.
parse_primary() has a postfix loop that handles Token::Dot after any
expression to build a FieldGet chain.
Execution — Stmt::FieldSet
Implemented in exec.rs:
- Remove the root variable from
Env(or start with an emptyIndexMapif the variable doesn’t exist yet). - Call
set_nested(map, path, value)— a recursive, ownership-by-value helper that walks theVec<String>path, creating intermediate structs where needed. - Re-insert the updated
Value::StructintoEnv. - Display the struct if not silent.
Built-ins
| Function | Behaviour |
|---|---|
struct('k1',v1,...) | Constructor; requires an even number of arguments; struct() returns empty struct |
fieldnames(s) | Returns Value::Cell of Value::Str names in insertion order |
isfield(s, 'x') | Scalar(1.0) or Scalar(0.0) |
rmfield(s, 'x') | Copy of struct without the named field; error if absent |
isstruct(v) | Scalar(1.0) if Value::Struct, else Scalar(0.0) |
struct() and the other struct built-ins skip the ans-injection logic
(zero-argument built-in calls normally inject ans as the first argument —
this is suppressed for struct/cell utilities via the no_ans_inject list in
eval.rs).
Display
s =
scalar structure containing the fields:
x: 1
y: [1×3 double]
inner: [1×1 struct]
- Inline format (
format_value):[1×1 struct] - Full format (
format_value_full): thescalar structure containing the fields:block above - Nested struct fields: always shown inline as
[1×1 struct]
Exhaustive match coverage
Value::Struct(_) was added to every exhaustive match arm across
eval.rs, exec.rs, repl.rs, and repl_tests.rs:
- Arithmetic, comparison, unary ops → error
size/length/numel→ returns 1 /[1 1](treats struct as 1×1)eval_indexwith()→ helpful error messageis_truthy→true- Display arms in test harness → delegates to
format_value_full
Tests
19 regression tests in crates/ccalc-engine/src/parser_tests.rs:
| Test | What it checks |
|---|---|
test_struct_field_assign_basic | s.x = 42 stores scalar |
test_struct_field_read | s.x = 7; ans = s.x returns 7 |
test_struct_multiple_fields | Three fields stored correctly |
test_struct_field_overwrite | Re-assigning a field updates it |
test_struct_nested_assign | s.a.b = 5 creates nested struct |
test_struct_nested_read | s.a.b = 10; ans = s.a.b returns 10 |
test_struct_constructor_basic | struct('x',1,'y',2) |
test_struct_constructor_empty | struct() returns empty struct |
test_struct_fieldnames | Returns correct Cell of Str |
test_struct_isfield_true/false | Both cases |
test_struct_rmfield | Field removed, others intact |
test_struct_isstruct_true/false | Both cases |
test_struct_field_missing_error | Access of absent field → error |
test_struct_field_on_non_struct_error | .field on non-struct → error |
test_struct_constructor_odd_args_error | struct('x') → error |
test_struct_rmfield_missing_error | rmfield(s,'z') → error |
test_struct_field_insertion_order | IndexMap preserves order |
13.5 — Struct arrays (complete, v0.19.0+001)
Value type
Value::StructArray(Vec<IndexMap<String, Value>>) — a separate enum variant
for 1-D arrays of structs. Scalar Value::Struct remains unchanged.
AST changes
| Node | Description |
|---|---|
Stmt::StructArrayFieldSet(String, Expr, Vec<String>, Expr) | s(i).field = rhs — base name, index expr, field path, right-hand side |
Parser
try_split_struct_array_field_assign() — byte-level string scan detecting
name(...)(.ident)+ = before tokenization. Called before
try_split_field_assign in parse() (order matters to prevent mis-parsing).
Execution — Stmt::StructArrayFieldSet
Implemented in exec.rs:
- Evaluate the index expression; resolve to a 1-based
usize. - Remove the root variable from
Env— acceptStructArray, promoteStructto a 1-element array, or start with an emptyVec. - Auto-grow: push empty
IndexMaps untilarr.len() >= idx. - Call existing
set_nested(elem, path, rhs)on the target element. - Re-insert the updated
Value::StructArray.
Field read — Expr::FieldGet on StructArray
s(i).field uses existing eval_index (returns Value::Struct) then
FieldGet reads the field from the returned scalar struct.
s.field (no index) on a StructArray collects the field across all
elements:
- All elements are
Scalar→Value::Matrix1×N row vector. - Mixed types →
Value::Cell.
Built-ins extended
isstruct, fieldnames, isfield, rmfield, numel, size(1),
size(2), length — all handle Value::StructArray.
Display
- Inline (
format_value):[1×N struct] - Full (
format_value_full): field names list for N > 1; full values for N = 1 (same as scalar struct).
Tests
8 new regression tests in parser_tests.rs:
| Test | What it checks |
|---|---|
test_struct_array_create_and_read | s(1).x = 1; s(2).x = 3 |
test_struct_array_numel | numel(s) returns 2 |
test_struct_array_isstruct | isstruct(s) returns 1 |
test_struct_array_field_collection_scalar | s.x → [1 3] matrix |
test_struct_array_auto_grow | s(3).x fills gap |
test_struct_array_nested_field | s(1).reading.temp = 22.5 |
test_struct_array_fieldnames | fieldnames on struct array |
test_struct_array_isfield | isfield on struct array |
13c — Dynamic field access (deferred → §3)
fname = 'x';
v = s.(fname); % read via string variable
s.(fname) = 1; % write via string variable
Phase 13.5 — Struct Arrays
Version: 0.19.0+001 Status: Complete
Motivation
s(i).field syntax is required by:
- Real
.mscripts that work with collections of labelled records (e.g. measurement series, roster data, inventory ledgers) - Phase 14 (
try/catch) — thestackfield of a caught exception objecteis a struct array of call frames dir()— returns a struct array of directory entries (planned)
Value type
Value::StructArray(Vec<IndexMap<String, Value>>) in env.rs.
A new variant separate from scalar Value::Struct. Each Vec element is one
struct (an IndexMap mapping field name → Value). Using a separate variant
keeps Value::Struct paths unchanged and makes pattern matching unambiguous.
AST changes
| Node | Description |
|---|---|
Stmt::StructArrayFieldSet(String, Expr, Vec<String>, Expr) | s(i).f1.f2 = rhs — base name, index expression, field path, right-hand side |
Parser
try_split_struct_array_field_assign(input) — byte-level scan detecting the
pattern ident(...)(.ident)+ = before tokenization. It tracks bracket depth
to correctly skip any expression inside (...). Called first in parse(),
before try_split_field_assign, so that s(1).x = val is never mistakenly
parsed as a plain field assignment.
parse_primary() handles s(i).field reads via existing FieldGet postfix
loop: eval_index is called for s(i), returning a Value::Struct, and
the .field suffix is then applied normally.
Execution
Write — Stmt::StructArrayFieldSet
Implemented in exec.rs:
- Evaluate the index expression; convert to 1-based
usize. - Remove the root variable from
Env:Value::StructArray(v)→ use as-is.Value::Struct(m)→ promote tovec.- Missing → start with empty
Vec.
- Auto-grow: append empty
IndexMaps untilarr.len() >= idx. - Call existing
set_nested(elem, path, rhs)onarr[idx - 1]. - Re-insert
Value::StructArray(arr)intoEnv.
Read — eval_index on StructArray
- Single index
s(i)→Value::Struct(clone of element). - Range or
:→Value::StructArray(cloned sub-array).
Field collection — Expr::FieldGet on StructArray
s.field with no index: iterates all elements, collects the named field:
- All elements yield
Value::Scalar→Value::Matrix1×N row vector. - Any non-scalar element →
Value::Cell.
Built-ins extended for StructArray
| Built-in | Behaviour on StructArray |
|---|---|
isstruct(s) | Returns 1.0 (same as scalar struct) |
fieldnames(s) | Uses field names of the first element |
isfield(s, 'x') | Tests first element’s field map |
rmfield(s, 'x') | Removes field from every element; returns new StructArray |
numel(s) | Returns element count as Scalar |
size(s) | Returns [1, N] as 1×2 matrix |
size(s, 1) | Returns 1 |
size(s, 2) | Returns N |
length(s) | Returns max(1, N) |
Display
- Inline (
format_value):[1×N struct] - Full (
format_value_full):- N > 1: field names list, e.g.
1×3 struct array with fields: x y - N = 1: full
scalar structure containing the fields:block (same as scalar struct)
- N > 1: field names list, e.g.
Tests
8 regression tests added in crates/ccalc-engine/src/parser_tests.rs:
| Test | What it checks |
|---|---|
test_struct_array_create_and_read | Basic s(1).x / s(2).x round-trip |
test_struct_array_numel | numel returns element count |
test_struct_array_isstruct | isstruct returns 1 |
test_struct_array_field_collection_scalar | s.x → Matrix when all scalar |
test_struct_array_auto_grow | s(3).x grows array past current length |
test_struct_array_nested_field | Nested path s(1).reading.temp = 22.5 |
test_struct_array_fieldnames | fieldnames on struct array |
test_struct_array_isfield | isfield on struct array |
Example
See examples/struct_arrays.calc for a comprehensive 8-section example
covering creation, element access, field collection, loop building,
fieldnames/isfield, string field collection into a cell, nested fields,
and a practical inventory-ledger calculation.
Phase 13.6 — Backslash Operator and Path System
Version: 0.20.0
Two independent features that fill gaps left by earlier phases: the
backslash left-division operator (\) for linear system solving, and a
session search path (addpath / rmpath / path()) for script lookup.
13.6a — Backslash operator \
What it does
A \ b solves the linear system A * x = b without computing inv(A)
explicitly. This is the standard MATLAB idiom: more numerically stable and
more concise than inv(A) * b.
A = [2 1; 5 7];
b = [11; 13];
x1 = inv(A) * b; % explicit inverse — less stable
x2 = A \ b; % left division — preferred
fprintf('x via inv: '); disp(x1')
fprintf('x via \\: '); disp(x2')
Scalar form
For scalars a \ b is equivalent to b / a:
4 \ 20 % → 5 (same as 20 / 4)
3 \ [6; 9; 12] % → [2; 3; 4] (scalar divides into each element)
Multiple right-hand sides
When b is a matrix, each column is solved independently — equivalent to
[A\b1, A\b2, ...] in a single operation:
C = [4 1; 2 3];
B2 = [5 1; 10 0]; % two right-hand sides
X = C \ B2; % solve both columns at once
disp(C * X - B2) % residual should be ~0
Precedence
\ has the same precedence as * and /, evaluated left to right:
2 \ 8 / 2 % → (8/2) / 2 = 2 (same level, left-to-right)
Implementation
- Token:
Token::Backslash - AST:
Op::LDiv - Evaluator cases:
Scalar \ Scalar→b / a(error ifa == 0)Matrix \ Matrix→ Gaussian elimination with partial pivoting (augmented matrix)Scalar \ Matrix→ divide each element by the scalarMatrix \ Scalar→ solveA * x = [s; s; ...](treated as 1-column RHS)
13.6b — Session search path
What it does
The session search path controls where run() and source() look for
script files. Without it, scripts must live in the current working directory.
Search order:
- Current working directory (always first)
- Session path entries, in order
Commands
addpath('/my/scripts') % prepend — highest priority
addpath('/my/utils', '-end') % append — lowest priority
rmpath('/my/scripts') % remove an entry
path() % display all entries
Duplicate entries are silently deduplicated. Adding an existing path moves
it to the front (or keeps it at the end with -end). ~ is expanded to the
user’s home directory on all platforms.
Persistence
addpath / rmpath affect the current session only. To make a path
permanent, add it to ~/.config/ccalc/config.toml:
path = [
"~/.config/ccalc/lib",
"/home/user/scripts",
]
Config paths are loaded at startup before any session addpath calls.
Example session
addpath('/tmp/mylib');
addpath('/tmp/utils', '-end');
path() % /tmp/mylib /tmp/utils
addpath('/tmp/mylib'); % duplicate → moved to front, no second copy
path()
rmpath('/tmp/utils');
path() % /tmp/mylib only
Implementation
SESSION_PATH: RefCell<Vec<PathBuf>>thread-local inexec.rssession_path_init/session_path_add/session_path_remove/session_path_list— public functionsresolve_script_pathchecksSESSION_PATHentries after the CWD- Config
path = [...]array:#[serde(default)]field inConfig, loaded at startup viasession_path_init(cfg.search_path()) addpath/rmpath/path()intercepted inexec_stmts(block mode) andtry_path_cmd()inrepl.rs(pipe / single-line REPL mode)
Files changed
| File | Change |
|---|---|
ccalc-engine/src/parser.rs | Token::Backslash, parse_term case for \, split_stmts ''-escape fix |
ccalc-engine/src/eval.rs | Op::LDiv, solve_linear(), eval_binop cases |
ccalc-engine/src/exec.rs | SESSION_PATH, path functions, addpath/rmpath/path() intercept |
ccalc/src/config.rs | path: Vec<String>, search_path(), expand_tilde() |
ccalc/src/repl.rs | session_path_init calls, try_path_cmd() |
examples/matrix_ops.calc | Updated to demo A\b and multiple RHS |
examples/path_system.calc | New file — full path system demo |
Examples
ccalc examples/matrix_ops.calc # linear solve, element-wise, det/inv
ccalc examples/path_system.calc # addpath/rmpath/path() demo
Phase 14 — Error Handling
Version: 0.20.0+002
Prerequisite: Phase 13 (structs — for catch e with e.message).
Scripts can now raise, catch, and recover from runtime errors without crashing the session. Two complementary mechanisms are provided: MATLAB-compatible try/catch block syntax and functional forms (pcall, try(expr, default)) as idiomatic ccalc alternatives.
14a — error and warning
error(fmt, args...)
Raises a runtime error with a printf-formatted message. Execution of the current block stops immediately; the error propagates to the nearest enclosing try/catch or to the REPL prompt.
error('value must be positive')
error('expected %d arguments, got %d', 2, nargin)
error('singular matrix detected at step %d', k)
warning(fmt, args...)
Prints a warning message to stderr and continues execution normally.
warning('result may be inaccurate')
warning('condition number = %.1e exceeds threshold', cond(A))
Both functions use the same printf format specifiers as fprintf and sprintf (%d, %f, %g, %s, %e, %%, width/precision flags).
14b — lasterr
lasterr stores the message from the most recent runtime error, whether caught by try/catch or displayed at the REPL prompt.
lasterr() % return last error message
lasterr('') % clear (returns previous value)
lasterr(msg) % set message; returns previous value
Example:
lasterr('');
try
inv([1 0; 0 0])
catch
end
msg = lasterr() % 'singular matrix'
14c — try/catch/end
MATLAB-compatible protected block. If any statement in the try body raises an error, execution jumps immediately to the catch body; remaining try statements are skipped. lasterr is set on entry to the catch body.
Anonymous catch
try
risky_code()
catch
fallback_code()
end
Named catch
catch e binds a struct with field message to the catch variable:
try
result = risky_function(data)
catch e
fprintf('caught: %s\n', e.message)
result = default_value
end
try with no catch
Silently swallows any error from the try body:
try
might_fail()
end
Nesting
try/catch blocks may be nested to any depth. An error re-raised from a catch body propagates to the next outer handler:
try
try
error('inner')
catch e
fprintf('inner caught: %s\n', e.message)
error('re-raised: %s', e.message)
end
catch e
fprintf('outer caught: %s\n', e.message)
end
In loops
break, continue, and return work normally inside try and catch bodies:
for k = 1:numel(data)
try
result = process(data(k))
catch e
fprintf('step %d failed: %s\n', k, e.message)
continue
end
fprintf('step %d: %g\n', k, result)
end
14d — try(expr, default)
Inline functional fallback. Evaluates expr; returns its value on success. If expr raises an error, evaluates and returns default instead. The default expression is not evaluated unless expr fails (lazy semantics).
x = try(inv(A), eye(n)) % fallback to identity if singular
n = try(str2num(s), 0) % fallback to 0 if s is not a number
v = try(risky(data), NaN) % NaN sentinel on error
This is a special form — try(expr, default) looks like a function call but its arguments are not pre-evaluated.
14e — pcall
Protected call: invoke any callable and capture success/failure as a value. Composable with if, multi-assign, and loops.
[ok, val] = pcall(@func, arg1, arg2, ...)
Return values:
- Success:
ok = 1,val =function return value - Failure:
ok = 0,val =error message string
[ok, x] = pcall(@inv, A)
if ~ok
fprintf('inv failed: %s\n', x)
x = eye(n)
end
[ok, y] = pcall(@(x) sqrt(x), -1) % ok=0, y='sqrt of negative'
pcall is particularly useful in loops where you want to continue processing after a failed step:
for k = 1:numel(data)
[ok, v] = pcall(@process, data(k))
if ok
results(k) = v
else
fprintf('step %d: %s\n', k, v)
results(k) = 0
end
end
Note on e as a variable
The constant e (Euler’s number, ≈ 2.718) and a catch variable named e do not conflict. Variable assignments always shadow built-in constants:
try
error('oops')
catch e
fprintf('message: %s\n', e.message) % e is a struct here
end
% After the block, 'e' is no longer in scope (try/catch does not leak)
Summary
| Feature | Description |
|---|---|
error(fmt, args...) | Raise a runtime error |
warning(fmt, args...) | Print warning, continue |
lasterr() | Get last error message |
lasterr(msg) | Set last error message |
try / catch / end | Anonymous protected block |
try / catch e / end | Named: e.message = error string |
try(expr, default) | Inline fallback (lazy) |
pcall(@f, args...) | Protected call → [ok, val] |
See also: Phase 13 — Structs · Phase 11 — Control Flow
Example file: ccalc examples/error_handling.calc
Phase 15 — Indexed Assignment
Version: 0.21.0
Status: Complete
Overview
Phase 15 adds in-place modification of matrix elements: the write counterpart to Phase 6’s read-only indexing. It unlocks the full MATLAB/Octave programming model — building vectors in loops, updating submatrices, and applying boolean masks to filter or clamp data.
New syntax
name(index) = rhs
name(i, j) = rhs
name(range) = rhs
name(:) = rhs
name(mask) = rhs
Parsed as Stmt::IndexSet { name, indices, value }, detected at parse time by
try_split_index_assign — the same string-level lookahead strategy used for
FieldSet (Phase 13) and CellSet (Phase 12.5).
15a — Scalar and slice assignment
The right-hand side can be a scalar (broadcast to all selected positions) or a vector/matrix matching the selection size.
v = zeros(1, 6);
v(3) = 42; % single element
v(1:2) = [10, 20]; % slice from a vector
v(4:6) = 99; % scalar broadcast
v(:) = 0; % reset all elements
A = zeros(4);
A(2, 3) = 7; % 2-D element
A(:, 1) = [1; 2; 3; 4]; % full column
A(1, :) = [10 20 30 40]; % full row
A(2:3, 2:3) = eye(2); % submatrix
15b — Growing vectors
Assigning to an index beyond the current length extends the storage and fills
gaps with zeros. end+1 is the idiomatic append:
squares = [];
for k = 1:8
squares(end+1) = k^2;
end
% [1 4 9 16 25 36 49 64]
v = [1, 2, 3];
v(7) = 99; % → [1 2 3 0 0 0 99]
A variable that doesn’t yet exist is auto-created as a 1×N row vector.
15c — Cell element assignment
Cell array grow via c{end+1} = val was already supported as Stmt::CellSet
from Phase 12.5. Phase 15 ensures end is correctly injected in the write
path so the idiom works reliably.
15d — Logical (boolean mask) indexing
A 0/1 vector or matrix whose element count equals the dimension size is interpreted as a boolean mask rather than an index list. This allows conditional reads and writes with a single expression.
temps = [18, 22, 35, 12, 29, 41, 8, 33];
% Read with mask
hot = temps(temps >= 30); % → [35 41 33]
% Write with mask
temps(temps >= 30) = 30; % cap at 30
% Separate mask variable
noise = signal < 0;
signal(noise) = 0; % half-wave rectifier
% 2-D mask (elements selected in column-major order)
M = [1 2 3; 4 5 6; 7 8 9];
M(M > 5) = 0;
Bug fix: zeros(n) / ones(n)
Single-argument forms now correctly create an n×n matrix (previously required the two-argument form).
Tests added
14 regression tests added in parser_tests.rs:
- Scalar/slice/broadcast assignment (1-D and 2-D)
- Row/column/submatrix assignment
- Vector growth with
end+1and out-of-range index - Logical mask read and write (1-D and 2-D)
zeros(n)/ones(n)single-argument formc{end+1}cell grow
Example file
ccalc examples/indexed_assignment.calc
Covers all sub-phases: element and slice assignment, 2-D matrix assignment,
vector growth with end+1, cell array growth, and logical mask indexing with
a half-wave rectifier and practical filter examples.
Phase 15.6 — Variable Scoping
Version: v0.21.0+006–010
Status: Complete
Overview
Phase 15.6 adds MATLAB/Octave-compatible variable scoping: global and
persistent variables, private/ directory isolation, and package namespaces
(+pkg/). These mechanisms were implemented together because they form a
coherent scoping hierarchy.
global variables
global x declares x as a variable shared across all functions (and the
base workspace) that also declare global x. The implementation uses a
thread-local GLOBAL_STORE: RefCell<HashMap<String, Value>> in eval.rs
with a GLOBAL_NAMES_STACK that tracks which names each function frame has
declared as global.
Key functions in eval.rs:
global_declare(name)— addsnameto the current frame’s setglobal_set(name, val)/global_get(name)— read/write the shared storeglobal_frame_push()/global_frame_pop()— manage per-call framesglobal_refresh_into_env(env)— copies current globals into local env on call entry
persistent variables
persistent x keeps a per-function value between calls. The implementation
uses PERSISTENT_STORE: RefCell<HashMap<(String, String), Value>> keyed by
(function_name, variable_name).
Write-through semantics (critical for memoization): when Stmt::Assign
or Stmt::IndexSet targets a persistent variable, the new value is written to
PERSISTENT_STORE immediately — not only when the function returns. This
ensures recursive calls see the updated value:
% Without write-through, fib_memo(n) would be O(2^n) because recursive calls
% see a stale copy of cache. With write-through, it is O(n).
cache(n) = fib_memo(n-1) + fib_memo(n-2); % written through immediately
For IndexSet, the implementation also refreshes from the store before
applying the partial update, so recursive writes are not overwritten by a
stale parent frame.
private/ directory scoping
Functions in a private/ sub-directory are visible only to scripts in the
parent directory. Two changes enforce this:
collect_dirs_recursiveinconfig.rsskips directories namedprivateso they are never added to the session search path.resolve_script_pathinexec.rsonly prependsdir/private/to the search whendircomes fromSCRIPT_DIR_STACK(the calling script’s own directory), never fromSESSION_PATHor CWD.
Output suppression fix (silence_all)
Function bodies must suppress all output. The pre-existing silencing only
covered top-level statements; nested bodies inside if/for/while/switch
still printed. The new silence_all(stmts) function in exec.rs recursively
walks the full statement tree and sets every (Stmt, bool) to (stmt, true).
Single-line block fix inside function bodies
The REPL’s single-line block bypass (is_single_line_block detection) was
executing if cond; body; end immediately even when block_depth > 0 (inside
a buffered function definition). The fix adds a block_depth == 0 guard so
single-line blocks inside a function body are appended to the buffer rather
than executed at the top level.
Tests
cargo test — all 667 tests pass.
Example
ccalc examples/scoping/scoping.calc
Phase 16 — Package Namespaces
Version: v0.21.0+011
Status: Complete
Overview
Packages are directories whose name starts with + (e.g., +utils, +geom).
Functions inside are invisible at the top level; callers use the package prefix:
utils.clamp(x, 0, 10)
geom.circle_area(r)
This mirrors MATLAB’s package system and eliminates function-name collisions across libraries.
Implementation
New AST node: Expr::DotCall
#![allow(unused)]
fn main() {
DotCall(Vec<String>, Vec<Expr>)
}
segments holds the dot-separated name components, e.g. ["utils", "clamp"].
Arguments follow as a normal expression list.
Parser change
The postfix loop in parse_primary (parser.rs) now handles Token::LParen
after a FieldGet/Var chain. The new field_chain_segments(e: &Expr)
helper extracts the segment list from a pure Var/FieldGet chain; if the
result has two or more segments, a DotCall node is produced.
a.b(args) → DotCall(["a", "b"], [args])
a.b.c(args) → DotCall(["a", "b", "c"], [args])
If the chain contains any non-Var/FieldGet node (e.g. a Call), the
LParen is left in the token stream for the caller to handle.
Evaluator
Expr::DotCall is evaluated in two branches:
-
Struct field call — if
segments[0]is in the environment, the segment chain is followed as field accesses (FieldGetsemantics) and the resulting value is called with the evaluated arguments. SupportsLambdaandFunctionfield values. -
Package call — if
segments[0]is not in the environment, the qualified name ("utils.clamp") is looked up inAUTOLOAD_CACHE. On a cache miss,try_autoloadis called with the qualified name, which delegates to the newtry_autoload_pkg.
try_autoload_pkg (exec.rs)
Splits the qualified name into package segments and a function name, builds
the relative path +pkg1/+pkg2/.../func, and searches:
SCRIPT_DIR_STACKentries (calling script’s directory)- CWD (
.) SESSION_PATHentries
On success, the function is loaded from the .calc (or .m) file and cached
in AUTOLOAD_CACHE under the qualified name (e.g. "utils.clamp").
Directory structure
+utils/
clamp.calc % function y = clamp(x, lo, hi)
lerp.calc % function y = lerp(a, b, t)
+geom/
circle_area.calc % function a = circle_area(r)
+solid/
sphere_vol.calc % function v = sphere_vol(r) → geom.solid.sphere_vol(r)
Example
ccalc examples/scoping/scoping.calc
Section 8 of the scoping example demonstrates:
utils.clampandutils.lerpfrom+utils/geom.circle_areaandgeom.rect_areafrom+geom/- Packages composed in expressions:
utils.clamp(utils.lerp(-10, 20, 0.5), 0, 10)
Tests
cargo test — all 667 tests pass.
Phase 17 — Statistics & Random Numbers
Version: v0.21.0+015 – v0.21.0+017
Purely a built-in library addition — no new tokens, AST nodes, or parser changes. Depends on Phase 15 (indexed assignment) for statistical algorithms that build result matrices element-by-element.
17a — Random number generation (v0.21.0+015)
New crate dependency: rand = { version = "0.8", features = ["small_rng"] }
added to crates/ccalc-engine/Cargo.toml.
Thread-local RNG (eval.rs):
#![allow(unused)]
fn main() {
thread_local! {
static RNG: RefCell<SmallRng> = RefCell::new(SmallRng::from_entropy());
}
}
rand_uniform() uses gen_range(0.0_f64..1.0) (not gen::<f64>() — gen is
a reserved keyword in Rust 2024 edition).
rand_normal() uses the Box-Muller transform — avoids the rand_distr crate:
#![allow(unused)]
fn main() {
fn rand_normal() -> f64 {
let u1 = rand_uniform().max(f64::EPSILON);
let u2 = rand_uniform();
(-2.0 * u1.ln()).sqrt() * (2.0 * PI * u2).cos()
}
}
no_ans_inject list updated to include "rand" | "randn" | "rng" — without
this, zero-argument calls like rand() injected ans (scalar 0.0) and matched
the 1-argument case (n=0), producing a 0×0 matrix instead of a scalar.
New built-ins: rand, randn, randi, rng
17b — Descriptive statistics (v0.21.0+016)
New crate dependency: libm = "0.2" added for erf/erfc (Rust std does
not expose these). Used in Phase 17d.
Helpers added to eval.rs:
numeric_vec(v, fname)— extracts aVec<f64>from Scalar/Matrix, error on Complex/Strapply_stat(v, f, fname)— column-wise reduction helper (same shape rules asapply_reduction)stat_var_vec(vals, population)— shared variance/std computation
apply_stat reuses the same column-wise pattern as apply_reduction: vectors
collapse to a scalar, M×N matrices collapse each column to produce a 1×N row vector.
hist terminal-width awareness: reads COLUMNS env var, falls back to 80.
New built-ins: std, var, cov, median, mode, hist, histc
17c — Percentiles and distributions (v0.21.0+016)
percentile_sorted(sorted, p) — linear interpolation using index p/100 * (n-1):
#![allow(unused)]
fn main() {
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
let idx = p / 100.0 * (sorted.len() - 1) as f64;
let lo = sorted[idx.floor() as usize];
let hi = sorted[idx.ceil() as usize];
lo + (hi - lo) * idx.fract()
}
}
zscore returns a zero vector for constant input to avoid division by zero.
prctile handles both scalar p and vector p as the second argument.
New built-ins: prctile, iqr, zscore
17d — Normal distribution functions (v0.21.0+017)
erf and erfc delegate to libm::erf / libm::erfc.
Element-wise on matrices via the existing apply_elem helper.
normcdf and normpdf are pure one-liners on top of erf:
normcdf(x) = 0.5 * (1 + erf(x / sqrt(2)))
normpdf(x) = exp(-x^2 / 2) / sqrt(2 * pi)
No additional dependencies beyond libm.
New built-ins: erf, erfc, normcdf, normpdf
Tests
54 new tests in crates/ccalc-engine/src/eval_tests.rs:
- 11 for Phase 17a (rand/randn/randi/rng reproducibility and shape)
- 21 for Phase 17b (std/var/cov/median/mode/hist/histc)
- 22 for Phase 17c+17d (prctile/iqr/zscore/erf/erfc/normcdf/normpdf)
Example
See examples/statistics.calc for a full demo (200-sample simulation,
percentile table, ASCII histogram, covariance matrix, normal distribution checks).
Phase 18 — Advanced Linear Algebra
Version: v0.22.0
Pure-Rust implementations — no BLAS/LAPACK dependency required.
All new functions extend the existing call_builtin dispatch in eval.rs.
18a — QR decomposition
Algorithm: Householder reflectors applied from the left.
For each column k, a reflector H_k zeroes the sub-diagonal entries.
Q = H_1 * H_2 * ... * H_k is accumulated as a full m×m orthogonal matrix.
#![allow(unused)]
fn main() {
fn qr_decompose(a: &Array2<f64>) -> Result<(Array2<f64>, Array2<f64>), String>
}
Interface:
[Q, R] = qr(A) % A = Q * R; Q: m×m orthogonal, R: m×n upper triangular
R = qr(A) % single-output: R only
get_nargout() selects between single-value and tuple return.
18b — LU decomposition
Algorithm: Gaussian elimination with partial pivoting. At each step, the row with the largest absolute pivot is swapped into position.
#![allow(unused)]
fn main() {
fn lu_decompose(a: &Array2<f64>) -> Result<(Array2<f64>, Array2<f64>, Array2<f64>), String>
}
Returns (L, U, P) — unit lower triangular, upper triangular, permutation matrix
(as a dense Array2<f64>, column-major encoding).
Interface:
[L, U, P] = lu(A) % PA = LU
U = lu(A) % single-output: U only
18c — Cholesky decomposition
Algorithm: Standard row-by-row Cholesky. Returns an error if any diagonal entry would be ≤ 0 (i.e., the matrix is not positive definite).
#![allow(unused)]
fn main() {
fn chol_decompose(a: &Array2<f64>) -> Result<Array2<f64>, String>
}
Returns upper triangular R such that R' * R = A.
Interface:
R = chol(A) % errors if A is not symmetric positive definite
18d — SVD
Algorithm: One-sided Jacobi SVD (Golub–Van Loan convention).
Iterates Givens rotations on pairs of columns (p, q) until orthogonality
is achieved: γ² ≤ ε² * α * β where α = ‖b_p‖², β = ‖b_q‖², γ = b_p · b_q.
Maximum 200 sweeps; convergence guaranteed for well-conditioned matrices.
For m < n the input is transposed and U/V swapped on return.
#![allow(unused)]
fn main() {
fn svd_compute(a: &Array2<f64>) -> Result<(Array2<f64>, Vec<f64>, Array2<f64>), String>
}
Returns (U_economy, s_vec, V_economy) — economy form.
Full SVD extends U to m×m via complete_orthonormal_basis (Gram-Schmidt
against the existing columns, using standard basis vectors as candidates).
S is built as a full m×n diagonal matrix.
Interface:
s = svd(A) % singular values as a column vector
[U, S, V] = svd(A) % full SVD: U (m×m), S (m×n), V (n×n); A = U*S*V'
[U, S, V] = svd(A,'econ') % economy: U (m×k), S (k×k), V (n×k); k = min(m,n)
18e — Eigendecomposition
Algorithm: QR iteration with Wilkinson shift. Shifts converge cubically for symmetric matrices.
Wilkinson shift for the trailing 2×2 submatrix:
δ = (a - d) / 2
μ = d - b² / (δ + sign(δ) · √(δ² + b²)) (δ ≠ 0)
μ = d - |b| (δ = 0)
Each iteration: subtract μI, deflate (zero sub-diagonal entries below threshold), then QR-step and re-add μI. Eigenvectors are accumulated via the Q factors.
#![allow(unused)]
fn main() {
fn eig_compute(a: &Array2<f64>) -> Result<(Vec<f64>, Array2<f64>), String>
}
Returns (eigenvalues, eigenvectors).
Interface:
d = eig(A) % eigenvalues as a column vector
[V, D] = eig(A) % V: eigenvectors (columns), D: diagonal eigenvalue matrix
18f — Matrix properties
All five functions are thin wrappers over svd_compute.
| Function | Description |
|---|---|
rank(A) | Count of singular values > ε * s_max * max(m, n) (ε = 2.2e-16) |
null(A) | Right singular vectors corresponding to near-zero singular values |
orth(A) | Left singular vectors corresponding to non-negligible svals |
cond(A) | s_max / s_min; Inf if any singular value is zero |
pinv(A) | V * diag(1/sᵢ for sᵢ > ε) * U' |
18g — Updated norm
Previously norm(A) for a non-vector matrix returned an error.
| Call | Result |
|---|---|
norm(A) | Largest singular value (spectral 2-norm) |
norm(A, 'fro') | sqrt(sum of squared elements) |
norm(A, 1) | Max column-sum |
norm(A, inf) | Max row-sum |
norm(v) / norm(v,p) | Vector Lp norm — unchanged |
nargout thread-local
Multi-output built-ins (qr, lu, svd, eig) return either a single
value or a Value::Tuple depending on get_nargout().
set_nargout(n) (public, in eval.rs) is called at two sites:
exec_stmts(exec.rs):Stmt::Assign→ 1;Stmt::MultiAssign→ targets.len()evaluate()(repl.rs):Stmt::Assign→ 1
This mirrors the NARGOUT thread-local pattern used by FN_CALL_HOOK,
AUTOLOAD_HOOK, and RUN_DEPTH.
Tests
25 new tests in crates/ccalc-engine/src/eval_tests.rs:
- QR: orthogonality of Q, reconstruction A = Q*R
- LU: PA = LU verification for 3×3 and ill-conditioned matrices
- Cholesky: R’*R = A for SPD; error for non-SPD
- SVD: singular values, U/V orthogonality, reconstruction A = USV’
- Eigendecomposition: AV = VD residual < 1e-10
- rank/null/orth/cond/pinv: correctness and fundamental properties
- Matrix norms: 2-norm, Frobenius, column-sum, row-sum
Example
See examples/linear_algebra.calc for a full demo covering all Phase 18
functions with mathematical verification of each result.
Phase 19 — REPL Tooling
Introduced in v0.23.0.
Phase 19 adds four developer-experience features: tab completion, inline function help, “did you mean?” error hints, and assertion built-ins.
19a — Tab completion
Press Tab in the REPL to complete the current word against:
- All variable names defined in the current session.
- All ~90 built-in function names (
sqrt,mean,assert, …).
When multiple candidates match, they are listed and the longest common prefix is
inserted. Type more characters and press Tab again to narrow down.
>> inv<Tab> → inv(
>> my_fun<Tab> → my_function (if defined)
Tab completion is an interactive REPL feature and cannot be demonstrated in a script.
Implementation: rustyline is upgraded from DefaultEditor to a typed
Editor<CcalcHelper, DefaultHistory>. CcalcHelper implements the Completer
trait with prefix-based matching over env.keys() and builtin_names().
Hinter, Highlighter, and Validator are required no-op stubs (rustyline
demands all four traits). The helper is updated before each readline() call
so newly defined variables appear immediately.
19b — Inline help for user functions
Any function prefixed by consecutive %-comment lines (with no blank line
between the comments and the function keyword) gets those lines as its doc
string. help <name> in the REPL prints it.
% Return the nth triangular number T(n) = n*(n+1)/2.
% Usage: t = tri(n)
%
% Example:
% tri(4) → 10
function t = tri(n)
t = n * (n + 1) / 2;
end
>> help tri
Return the nth triangular number T(n) = n*(n+1)/2.
Usage: t = tri(n)
Example:
tri(4) → 10
- Any number of consecutive
%(or#) lines form the doc block. - A blank line between the comment block and the
functionkeyword breaks the association — only lines that directly precede the keyword are collected.
Implementation: Stmt::FunctionDef and Value::Function gain an
Option<String> doc field. parse_stmts_from_lines scans backward from the
function keyword through raw (un-stripped) lines, collecting comment text until
it hits a non-comment line. The REPL help <name> handler checks
Value::Function { doc: Some(d), .. } before falling through to built-in topics.
19c — “Did you mean?” error hints
When a name is not found, ccalc computes the Levenshtein edit distance from the misspelled name to every variable in the current environment and every built-in function name. If the closest match is within 2 edits, it is appended to the error message.
>> sqrtt(4)
Error: Unknown function 'sqrtt'; did you mean 'sqrt'?
>> my_valu + 1
Error: Undefined variable 'my_valu'; did you mean 'my_value'?
No suggestion is printed when no close match exists.
Implementation: levenshtein(a, b) — O(m × n) DP implementation, no external
crate. suggest_similar(name, env) in eval.rs iterates env.keys() and
builtin_names(), picks the minimum, and returns Some(name) when ≤ 2.
The hint is appended inline in the “Undefined variable” branch of eval and in
the “Unknown function” fallthrough of call_builtin.
19d — assert built-ins
Three overloads for lightweight unit testing inside scripts:
| Call | Behaviour |
|---|---|
assert(cond) | Pass when cond is truthy; error otherwise |
assert(expected, actual) | Exact element-wise equality check |
assert(expected, actual, tol) | Tolerance check: ` |
All three work on scalars, vectors, and matrices.
assert(pi > 3)
assert(4, 2 + 2)
assert(0.3333, 1/3, 1e-4)
assert([1 4 9], [1 2 3].^2)
Implementation: Three cases added to call_builtin in eval.rs keyed on
("assert", 1), ("assert", 2), ("assert", 3). The shared
assert_values_equal(a, b, tol) helper handles shape checking and element-wise
comparison for both scalars and matrices.
19e — near line N in error messages
Introduced in v0.30.0+002.
Runtime errors that occur inside block statements, function bodies, or scripts
executed via run()/source() now include the 1-based source line number of
the failing statement:
Error: Undefined variable: 'bad_var' near line 3
Where it applies
| Context | Has line number? |
|---|---|
Inside for/while/if/switch/do-until body | ✓ |
Inside try/catch body | ✓ |
| Inside a user-defined function body | ✓ |
Script run via run('file.m') or source('file.m') | ✓ |
| Single statements typed at the REPL or piped line-by-line | — |
try/catch and e.message
The catch variable stores the original message without the line suffix,
matching MATLAB/Octave semantics. Location information in Octave lives in
e.stack.line; in ccalc it is only surfaced in the printed error.
try
x = bad_var;
catch e
disp(e.message) % "Undefined variable: 'bad_var'" (no "near line")
end
Innermost location wins
When errors propagate through nested blocks, the location of the innermost failing statement is reported and outer wrappers do not re-annotate:
for k = 1:3
if bad_var > 0 % line 2 — this line is reported
x = 1;
end
end
% Error: Undefined variable: 'bad_var' near line 2
Implementation: (Stmt, bool) throughout the AST became (Stmt, bool, usize).
parse_stmts_from_lines records *pos + 1 (1-based) at the start of each loop
iteration. Single-line block expansions (if cond; body; end) remap all virtual
inner lines back to the physical source line. exec_stmts wraps eval_with_io
calls with .map_err(|e| annotate_line(e, stmt_line))?; annotate_line is a
no-op when line == 0 (synthetic REPL statements) or when the message already
contains "near line".
Example
ccalc examples/repl_tooling.calc
The example file demonstrates assert forms, doc-comment-driven test harnesses, and “did you mean?” error recovery.
See also: User-defined Functions,
Error Handling, help testing.
Phase 20a — JSON
Introduced in v0.24.0.
Phase 20a adds jsondecode and jsonencode built-ins, backed by serde_json,
behind an optional json feature flag. The default binary is unaffected.
Feature flag
# Build with JSON support:
cargo build --release --features json
# Pass through from the top-level workspace:
cargo build --release --features json
The json feature is declared in both ccalc-engine/Cargo.toml and
ccalc/Cargo.toml (as a pass-through). The engine crate adds
serde_json = { version = "1", optional = true } as an optional dependency.
When the feature is disabled, calling either built-in returns:
jsondecode: not available — rebuild with --features json
Both names still appear in tab completion regardless of the feature flag.
Built-ins
jsondecode(str)
Parses the JSON string str and returns a ccalc Value.
Mapping:
| JSON | ccalc |
|---|---|
| object | Struct (fields in insertion order via IndexMap) |
| all-numbers array (+ nulls) | Matrix 1×N row vector (null → NaN) |
| mixed array | Cell |
| string | Str |
| number | Scalar |
true / false | Scalar(1.0 / 0.0) |
null | Scalar(NaN) |
Errors on invalid JSON ("jsondecode: invalid JSON: …").
Errors if the argument is not a string ("jsondecode: argument must be a string").
jsonencode(val)
Encodes a ccalc Value to a compact JSON string (Value::Str).
Mapping:
| ccalc | JSON |
|---|---|
Struct | object |
Matrix 1×N | flat array |
Matrix M×N | array of row arrays |
Cell | array |
StructArray | array of objects |
Scalar(NaN) | null |
Scalar(finite) | number |
Str / StringObj | string |
Errors for Complex, Lambda, Function, Void, Tuple, and Scalar(±Inf).
Implementation
-
crates/ccalc-engine/src/json.rs— new module (compiled only under#[cfg(feature = "json")]). Contains:pub(crate) fn json_to_value(v: &serde_json::Value) -> Valuepub(crate) fn value_to_json(v: &Value) -> Result<serde_json::Value, String>fn decode_array(arr: &[serde_json::Value]) -> Value— separates all-numeric from mixed arraysfn encode_f64(x: f64) -> Result<serde_json::Value, String>— handlesNaN → null,Inf → error
-
crates/ccalc-engine/src/eval.rs— changes:jsondecodeandjsonencodeadded tobuiltin_names()(unconditional — always in tab completion)("jsondecode", 1)and("jsonencode", 1)match arms incall_builtindispatch tojsondecode_impl/jsonencode_impljsondecode_impl/jsonencode_impl: dual#[cfg(feature = "json")]/#[cfg(not(feature = "json"))]implementations
-
crates/ccalc-engine/src/lib.rs—#[cfg(feature = "json")] pub(crate) mod json; -
crates/ccalc/Cargo.toml—json = ["ccalc-engine/json"]feature pass-through
Tests
22 tests in eval_tests.rs under mod json_tests (gated with #[cfg(feature = "json")]):
cargo test --features json
Test coverage: scalar decode, null→NaN, bool→0/1, string, numeric array, empty array, mixed→Cell, object→Struct, nested struct, invalid JSON error, non-string arg error, scalar encode, NaN→null, Inf error, string, row vector, 2-D matrix, struct, cell, and a full roundtrip test.
Phase 20c — CSV Improvements
Introduced in v0.24.0+001.
Phase 20c adds readmatrix, readtable, and writetable built-ins, extending
the existing dlmread/dlmwrite infrastructure with header handling, mixed-type
columns, and RFC 4180 quoting.
Built-ins
readmatrix(path) / readmatrix(path, 'Delimiter', d)
Reads a delimiter-separated numeric file and returns a Value::Matrix.
- Delimiter auto-detection: comma (CSV-aware, respects quoted fields) → tab → whitespace.
- First row heuristic: if any non-empty field fails
f64::parse, the row is skipped as a header. A purely numeric first row is treated as data. - Empty cells →
f64::NAN(differs fromdlmread’s0.0). - Errors if any data cell is non-numeric.
readtable(path) / readtable(path, 'Delimiter', d)
Reads a CSV file with a mandatory header row and returns a Value::Struct of columns.
Column type rules:
| Condition | ccalc value |
|---|---|
All cells parseable as f64 (empty → NaN) | Matrix N×1 column vector |
| Any non-numeric cell | Cell of Value::Str |
Header names are sanitized: non-alphanumeric runs collapse to _, leading digits
get an x prefix, empty headers become x{N}. Duplicate names get _1, _2, …
suffixes.
Returns an empty Struct for an empty file; returns a struct of zero-row Matrix
columns for a header-only file.
writetable(T, path) / writetable(T, path, 'Delimiter', d)
Writes a Value::Struct table to a CSV file with a header row.
- Accepted column types:
Matrix(N×1),Cell,Scalar,Str/StringObj. - Non-column matrices (M×N where N≠1) are rejected.
- All columns must have the same row count.
- RFC 4180 quoting: cells containing the delimiter,
", or\nare wrapped in"..."with internal"doubled. - Returns
Value::Void.
Implementation
All new code lives in crates/ccalc-engine/src/eval.rs under a
// --- CSV read/write helpers --- comment block after dlmwrite_impl.
Helper functions
| Function | Purpose |
|---|---|
auto_detect_delim(lines) | CSV-aware comma check, then tab, then None |
split_csv_row(line, delim) | RFC 4180 field split with "" escape support |
split_csv_row_opt(line, delim) | Wraps split_csv_row; None → split_whitespace |
row_is_header(fields) | true if any non-empty field is non-numeric |
sanitize_header(s, col) | Converts raw header to identifier-like name |
deduplicate_headers(headers) | Appends _N suffixes to duplicate names |
parse_delimiter_opt(fn, args, start) | Parses optional ('Delimiter', d) arg pair |
readmatrix_impl(path, delim) | Core readmatrix logic |
readtable_impl(path, delim) | Core readtable logic |
csv_quote_cell(s, delim) | RFC 4180 quoting |
col_nrows(v) | Row count for a struct column value |
col_cell_str(v, row, delim) | Formatted CSV cell for one row |
writetable_impl(tbl, path, delim) | Core writetable logic |
Match arms added in call_builtin near the existing dlmread/dlmwrite arms.
All three names added to builtin_names() (alphabetical order).
Tests
15 tests in eval_tests.rs under mod csv_tests:
cargo test csv_tests
Coverage: numeric matrix, header skip, numeric-first-row-not-header, explicit tab delimiter, empty cells → NaN, empty file, numeric columns, mixed columns, header-only file, quoted field with embedded comma, empty file (readtable), basic write, quoting, roundtrip (writetable → readtable), wrong column type error.
Phase 20.5 — MAT File Read
Introduced in v0.25.0.
Phase 20.5 adds load('file.mat') support, backed by matrw = "=0.1.4",
behind an optional mat feature flag. The default binary is unaffected.
Feature flag
# Build with MAT support:
cargo build --release --features mat
# Run a script that uses load('*.mat'):
cargo run --release --features mat -- examples/mat/mat.calc
The mat feature is declared in both ccalc-engine/Cargo.toml and
ccalc/Cargo.toml (as a pass-through). The engine crate adds
matrw = { version = "=0.1.4", optional = true } as an optional dependency.
When the feature is disabled, calling load('*.mat') returns:
load: .mat support not available — rebuild with --features mat
The load built-in always appears in tab completion regardless of the feature flag.
Built-in: load
Assignment form
data = load('results.mat')
Returns a Struct whose fields are the variable names stored in the file.
data = load('examples/mat/fixtures/sample.mat');
data.score % → 92.5 (Scalar)
data.label % → 'experiment-1' (Str)
data.readings % → [23.1 21.8 24.3 ...] (1×6 Matrix)
data.sensor.gain % → 0.5 (nested Struct field)
Bare form
load('results.mat')
Merges all variables from the file directly into the current workspace — each variable name becomes a variable in the calling scope.
load('examples/mat/fixtures/sample.mat')
score % → 92.5
readings % → [23.1 21.8 ...]
sensor.gain % → 0.5
save with .mat extension
Writing .mat files is not yet implemented. save('out.mat', ...) returns a
clear error message instead of silently producing a corrupt file.
Type mapping
| MAT type | ccalc Value |
|---|---|
double (1×1 scalar) | Scalar |
double (M×N matrix) | Matrix (column-major → row-major conversion) |
char array | Str |
struct (scalar) | Struct |
| struct array (1 element) | Struct (unwrapped) |
| struct array (N elements) | StructArray |
cell array | Cell |
[] / null | Scalar(NaN) |
Complex and sparse matrices are not yet supported and produce an error.
Implementation
-
crates/ccalc-engine/src/mat.rs— new module (compiled only under#[cfg(feature = "mat")]). Contains:pub(crate) fn mat_load(path: &str) -> Result<Value, String>— iterates overmatrw::load_matfile()entries and builds aValue::Struct.fn mat_var_to_value(var: &MatVariable) -> Result<Value, String>— recursive converter:NumericArray→Scalar/Matrix/Str,Structure→Struct,StructureArray→Struct/StructArray,CellArray→Cell,Null→Scalar(NaN).- Column-major conversion:
Array2::from_shape_vec((cols, rows), data).t().to_owned().
-
crates/ccalc-engine/src/eval.rs— changes:"load"added tobuiltin_names()(unconditional).("load", 1)match arm incall_builtindispatches toload_mat_file().pub fn load_mat_file(path)with dual#[cfg]/#[cfg(not)]stubs, sorepl.rscan call it without importing thematmodule.
-
crates/ccalc-engine/src/lib.rs—#[cfg(feature = "mat")] pub(crate) mod mat; -
crates/ccalc/Cargo.toml—mat = ["ccalc-engine/mat"]feature pass-through. -
crates/ccalc/src/repl.rs—.matextension check in both REPL and pipeSaveLoadCmd::Loadhandlers: injects each field of the returnedStructinto the workspace.SaveLoadCmd::Savewith.matpath emits an error.
Tests
5 roundtrip tests in eval_tests.rs under mod mat_tests
(gated with #[cfg(feature = "mat")]):
cargo test --features mat
Test coverage: scalar roundtrip, row-vector roundtrip, 2×3 matrix with
correct column-major conversion, char array → Str, nested struct fields.
A create_example_fixture test (marked #[ignore]) generates the fixture
used by the example script:
cargo test --features mat create_example_fixture -- --ignored
Example
cargo run --release --features mat -- examples/mat/mat.calc
The script covers: assignment form, scalar access, row-vector statistics and normalization, matrix display and algebra, char-array built-ins, struct field access, bare form workspace merge, and a moving-average signal analysis.
Phase 21 — String Completions and Regex
Version: v0.26.0
Prerequisite: Phase 9 (string types), Phase 12.5 (cell arrays)
21a — String predicates and joining
contains
contains(s, pat) % substring check
contains(s, pat, 'IgnoreCase', true) % case-insensitive
Returns 1 if pat is found anywhere in s, 0 otherwise.
startsWith / endsWith
startsWith(s, pat) % 1 if s begins with pat
endsWith(s, pat) % 1 if s ends with pat
strjoin
strjoin(c) % join with space (default)
strjoin(c, delim) % join with explicit delimiter
c must be a cell array of char arrays or string objects.
Returns a char array (Value::Str).
21b — Regular expressions
Feature-gated behind --features regex. Without the feature, calling
any of these functions returns an error; their names always appear in
tab completion.
regexp
regexp(s, pat) % 1-based start index of first match, or []
regexp(s, pat, 'match') % cell array of all matched substrings
regexpi
Case-insensitive variants; same signatures as regexp.
regexprep
regexprep(s, pat, rep) % replace all matches with literal string rep
The replacement string is always treated as a literal — $1, ${name},
etc. are not expanded as capture group references.
Implementation notes
- 21a functions are in
call_builtinwith no feature gate. - 21b dispatches through
regexp_impl/regexprep_implhelpers gated with#[cfg(feature = "regex")]/#[cfg(not(feature = "regex"))]. - The
regexcrate (NFA engine) is an optional dependency; no risk of catastrophic backtracking. regexpbyte-to-char offset conversion:s[..m.start()].chars().count() + 1.
Phase 22 — Datetime & Duration
Adds UTC datetime and duration types as first-class values.
New value types
| Variant | Storage | Notes |
|---|---|---|
DateTime(f64) | Unix timestamp (seconds) | NaN = NaT |
Duration(f64) | Seconds (fractional) | |
DateTimeArray(Vec<f64>) | Flat timestamp vec | Display as N×1 |
DurationArray(Vec<f64>) | Flat seconds vec | Display as N×1 |
New module: ccalc-engine::datetime
Pure-Rust UTC calendar arithmetic; no external crate. Uses the Howard Hinnant proleptic Gregorian algorithm.
Key functions: days_from_civil, civil_from_days, timestamp_to_civil,
civil_to_timestamp, parse_iso8601, format_datetime, format_duration,
format_datestr, now_timestamp, today_timestamp, to_datenum, from_datenum.
Parser change
NaT added as a parser-level constant (like pi, nan) in parse_primary →
Expr::NaT → Value::DateTime(f64::NAN). Not env-seeded so user cannot overwrite it.
New builtins
datetime, duration, hours, minutes, seconds, days, milliseconds, years,
year, month, day, hour, minute, second,
isdatetime, isduration, isnat,
datestr, datevec, datenum, posixtime, diff (extended).
v0.27.0+001 fixes
Three follow-up fixes applied after the initial phase release:
- Matrix literals —
[datetime(...); datetime(...)]and[hours(1); hours(2)]now produceDateTimeArray/DurationArray. The matrix builder uses a two-pass approach: evaluate all elements first, then dispatch on the type of the first element. Mixing datetime with duration (or numeric) raises an error. fprintf/sprintf%s—DateTimeformats as"yyyy-MM-dd HH:mm:ss";Durationformats as"HH:MM:SS".isnaton non-datetime — now returns0instead of an error (MATLAB-compatible).
Tests
62 tests in eval_tests.rs::datetime_tests:
constructors, extractors, predicates, arithmetic, formatting, array operations,
matrix literals, printf formatting.
Phase 23 — Matrix Utilities & Set Operations
Version: 0.28.0
Prerequisites: Phase 7.5 (basic matrix utilities), Phase 15 (indexed assignment), Phase 18 (linear algebra context)
Pure built-in additions — no new tokens, AST nodes, or Value variants.
23a — Triangular extraction and tiling
| Function | Description |
|---|---|
triu(A) / triu(A, k) | Upper triangular; zero elements where col − row < k |
tril(A) / tril(A, k) | Lower triangular; zero elements where col − row > k |
repmat(A, m, n) | Tile A in an m × n block grid |
kron(A, B) | Kronecker product |
23b — Vector products
| Function | Description |
|---|---|
cross(a, b) | Cross product of two length-3 vectors; result orientation matches a |
dot(a, b) | Inner product sum(a .* b) → scalar |
23c — Set operations
Results are always sorted ascending and contain no duplicates. NaN is never a member (IEEE semantics).
| Function | Description |
|---|---|
intersect(a, b) | Elements present in both vectors |
union(a, b) | All unique elements from both vectors |
setdiff(a, b) | Elements of a not in b |
ismember(x, v) | 1 if x ∈ v; element-wise for vector x |
23d — Index utilities and element repetition
| Function | Description |
|---|---|
sub2ind(sz, r, c) | Subscripts → linear index (1-based, column-major) |
ind2sub(sz, idx) | Linear index → [r; c] tuple |
repelem(v, n) | Repeat each element of v exactly n times |
repelem(v, nv) | Repeat v(i) by nv(i) times |
repelem(A, m, n) | 2-D: repeat each element m rows × n cols |
Phase 24 — Polynomial Operations & Interpolation
Version: 0.29.0
Prerequisite: Phase 8 (complex numbers — roots can be complex); Phase 18 (QR decomposition — used by polyfit).
Overview
Phase 24 adds 7 new built-in functions for polynomial arithmetic, root finding, and piecewise interpolation. No new tokens or AST nodes were needed.
| Function | Signature | Notes |
|---|---|---|
polyval | polyval(p, x) | Horner evaluation |
polyfit | polyfit(x, y, n) | Vandermonde + QR solve |
roots | roots(p) | Durand–Kerner iteration |
poly | poly(r) / poly(A) | Expand from roots / char. poly |
conv | conv(a, b) | O(mn) convolution |
deconv | deconv(c, b) | Polynomial long division |
interp1 | interp1(x, y, xi[, method]) | Piecewise interpolation |
24a — Polynomial evaluation, fitting, and roots
polyval(p, x) — Horner evaluation
Polynomials are row vectors [c_n, c_{n-1}, …, c_0] (highest degree first).
Evaluation uses Horner’s method for numerical stability:
p(x) = c_n x^n + … + c_1 x + c_0
= (…((c_n x + c_{n-1}) x + c_{n-2}) x + …) x + c_0
x can be a scalar or any-shape matrix; the result has the same shape as x.
polyfit(x, y, n) — Least-squares polynomial fit
Builds an m×(n+1) Vandermonde matrix V (rows [x_i^n, …, x_i, 1]), then
solves V c ≈ y via qr_decompose (Phase 18) + back-substitution. Returns
the (n+1) coefficients as a 1×(n+1) row vector.
roots(p) — Root finding via Durand–Kerner
The CDP plan called for building a companion matrix and calling eig_compute,
but eig_compute only handles real eigenvalues (real Wilkinson shifts). For
polynomials with complex roots (e.g. x² + 1), a companion matrix approach
would stall.
Implementation deviation: uses the Durand–Kerner (Weierstrass) iteration
directly in complex (f64, f64) arithmetic (~70 lines, no eig dependency).
Key implementation details:
- Normalizes to monic polynomial first.
- Cauchy root bound as initial radius:
r = 1 + max|c_i|. - Initial guesses rotated by
0.25/nturns to avoid the real axis, preventing stall on polynomials with purely imaginary roots. - 2000 iterations max; terminates when max correction falls below
1e-12. - Sorted by descending real part, then descending imaginary part (MATLAB order).
Return type: since Value::Matrix is Array2<f64> (real only):
- All roots real (imaginary parts <
1e-9) →Value::Matrix(n×1 column). - Any root complex →
Value::CellofValue::Scalar/Value::Complexelements.
poly(r) — Monic polynomial from roots or characteristic polynomial
- Vector argument: iteratively convolves
[1.0]with[1.0, -r_i]for each root. Requirespoly_conv(see 24b). - Square matrix argument: Faddeev–LeVerrier algorithm, O(n³) matrix multiplications, no eigenvalue computation needed.
24b — Convolution, deconvolution, interpolation
conv(a, b) — Discrete linear convolution
Direct O(mn) double loop. Result length = m + n − 1. Accepts row or column
vectors; always returns a row vector.
deconv(c, b) — Polynomial long division
Returns Value::Tuple(vec![q_val, r_val]) for [q, r] = deconv(c, b).
The remainder r has the same length as c (MATLAB convention), so the
invariant conv(b, q) + r == c holds element-wise.
Near-zero rounding residuals (< 1e-10 × max input coefficient) are zeroed.
interp1(x, y, xi[, method]) — Piecewise interpolation
Uses partition_point for O(log n) bracket search. Four methods:
| Method | Implementation |
|---|---|
linear | y[lo] + t*(y[lo+1]-y[lo]) where t=(xi-x[lo])/(x[lo+1]-x[lo]) |
nearest | Snap to x[lo] or x[lo+1], tie goes left |
previous | y[lo] (left step / ZOH) |
next | y[lo+1] unless at exact knot, then y[lo] |
The last-knot edge case (xi == x[n-1]) is handled specially to ensure all
methods return y[n-1] correctly.
Extrapolation (query outside [x[0], x[n-1]]) always returns NaN.
Tests
33 new tests in mod phase24_tests (855 total).
A ep_p(src, coeffs) helper is used throughout to pre-seed the environment
with a polynomial variable p, bypassing the parser ambiguity where
[1 -3 2] is tokenized as [1-3, 2] = [-2, 2] (binary minus in matrix
context). This is a known pre-existing parser limitation.
Phase 25 — Dynamic Evaluation & Timing
Trigger: eval() is used in parameter-sweep scripts, metaprogramming
patterns, and anywhere variable names are constructed at runtime. tic/toc
appear in virtually every performance-sensitive script.
Prerequisite: Phase 12 (full evaluator pipeline — needed for the recursive
exec_stmts call inside eval); Phase 11.5 (RUN_DEPTH recursion guard).
25a — eval — string execution
eval(str) executes a string as code in the current workspace. Variable
mutations persist in the caller’s scope, matching MATLAB/Octave semantics.
eval(str, catch_str) is the two-argument form: if str raises an error,
catch_str is executed and the original error is suppressed (stored in
lasterr()).
Implementation
-
Statement context (
eval(...)as a standalone statement): intercepted inexec_stmtsinsideexec.rs, just after therun/sourceintercept. Uses the sameRUN_DEPTHthread-local (max 64) to prevent infinite recursion. Callsparse_stmts→exec_stmtswith the caller’senvandio, so all mutations persist. -
Expression context (
y = eval('...')): falls through tocall_builtinineval.rs. UsesEVAL_STR_HOOK(registered byexec::init()) which clonesenv, runsexec_stmtsagainst the clone, and returnsans. Variable mutations inside the string are discarded — onlyansis returned. -
EVAL_STR_HOOKfollows the same hook pattern asFN_CALL_HOOKto avoid a circular dependency betweeneval.rsandexec.rs.
25b — tic / toc — elapsed time
tic stores Instant::now() in a thread-local TIC_TIME. toc reads the
elapsed duration and returns it as a Scalar in seconds. Multiple toc calls
after a single tic are valid; the timer is not reset by toc.
Both names are added to the no_ans_inject list so that tic() / toc()
called with empty parentheses do not have ans injected as an argument.
The Expr::Var handler in eval_inner is extended to fall back to
call_builtin(name, &[], ...) when a name is not found in the environment,
so that bare tic and toc (without parentheses) are recognized as
zero-argument function calls.
Files changed
| File | Change |
|---|---|
crates/ccalc-engine/src/eval.rs | EVAL_STR_HOOK + TIC_TIME thread-locals; tic/toc/eval in call_builtin; zero-arg fallback in Expr::Var; tic/toc/eval added to builtin_names() and no_ans_inject |
crates/ccalc-engine/src/exec.rs | eval_str_impl; set_eval_str_hook registered in init(); eval intercept in Stmt::Expr |
crates/ccalc-engine/src/eval_tests.rs | mod phase25_tests — 11 tests |
crates/ccalc/src/help.rs | print_eval() topic |
docs/src/guide/eval.md | User guide page |
docs/src/SUMMARY.md | Added entries |
Test count: 866 total (11 new in phase25_tests).
Phase 26 — FFT & Signal Processing
Trigger: Signal processing workflows — spectrum analysis, filter design,
frequency-domain operations — require an FFT. The standard interface is
fft(x) / ifft(X) (MATLAB/Octave/NumPy compatible).
Prerequisite: Phase 7.5 (vector utilities — length, numel, zeros);
Phase 8 (complex scalars — FFT output is complex).
Feature flag:
fftandifftare gated behind thefftCargo feature (pulls inrustfft). Build with:cargo build --release --features fft
fftshift,ifftshift, andfftfreqare always available.
26a — Forward and inverse FFT
fft(x) computes the DFT of a real row vector using the Cooley-Tukey
radix-2 algorithm (rustfft). fft(x, n) zero-pads (or truncates) to length
n before transforming.
ifft(X) computes the inverse DFT, normalised by 1/N. When all imaginary
parts are < 1e-12, the result is returned as a real Matrix instead.
Note (Phase 27): the return type changed from Cell to ComplexMatrix
in v0.32.0. Access bins with X(k) (parenthesis indexing), not X{k}.
26b — fftshift / ifftshift
fftshift(x) performs a circular shift by floor(N/2) so that the DC
component moves from index 1 to the centre. Used to produce a zero-centred
spectrum plot.
ifftshift(x) undoes the shift by ceil(N/2). Works on row vectors,
column vectors, and 2-D matrices (shifts both dimensions).
26c — fftfreq
fftfreq(n, d) returns a 1×n row vector of DFT sample frequencies for n
points with sample spacing d seconds (so d = 1/fs). The formula matches
NumPy and MATLAB:
f = [0, 1, …, floor((n-1)/2), -floor(n/2), …, -1] / (n·d)
Files changed
| File | Change |
|---|---|
crates/ccalc-engine/src/eval.rs | fft, fft(x,n), ifft, fftshift, ifftshift, fftfreq in call_builtin; complex_pairs_to_complex_matrix helper; gated under #[cfg(feature = "fft")] |
crates/ccalc-engine/src/eval_tests.rs | FFT regression tests |
crates/ccalc/src/help.rs | print_fft() topic |
docs/src/guide/fft.md | User guide page |
docs/src/SUMMARY.md | Added entry |
examples/fft_demo.calc | Full worked example |
Version: v0.31.0
Phase 27 — Complex Matrices
Trigger: FFT output (Phase 26) is naturally complex; control-theory transfer functions and non-symmetric eigenvalue problems also produce complex matrix results. Phase 8 complex scalars are insufficient once matrix-level complex output is needed.
Prerequisite: Phase 8 (complex scalars — arithmetic contract); Phase 26 (FFT — primary consumer of complex matrix output).
27a — Value::ComplexMatrix and literals
A new Value::ComplexMatrix(Array2<Complex<f64>>) variant is added to env.rs.
Requires num-complex = "0.4" in ccalc-engine/Cargo.toml.
Any matrix literal where at least one element evaluates to Value::Complex
causes all elements to be upcast to Complex<f64> and the whole literal
returns Value::ComplexMatrix. Pure-real literals remain Value::Matrix.
A = [1+2i, 3-4i; 5, 6+1i] % 2×2 ComplexMatrix
v = [1+i, 2-i, 3] % 1×3 ComplexMatrix
R = [1, 2; 3, 4] % 2×2 Matrix (stays real)
isreal returns 0 for any ComplexMatrix, 1 for a plain Matrix.
Display: each cell always shows both parts — 5 + 0i, 1 + 1i, 0 + 2i.
FFT integration: fft output switches from the interim Cell
representation (Phase 26) to Value::ComplexMatrix. Access bins with
X(k) (parenthesis indexing), not X{k}.
Workspace: ComplexMatrix is skipped on ws/wl save (same policy as
all non-scalar types).
27b — Arithmetic and decomposition
eval_binop is extended for all combinations involving ComplexMatrix:
| Left | Right | Operation |
|---|---|---|
| ComplexMatrix | ComplexMatrix | element-wise or matrix multiply |
| ComplexMatrix | Matrix | auto-promote right to complex |
| Matrix | ComplexMatrix | auto-promote left to complex |
| ComplexMatrix | Scalar / Complex | scalar broadcast |
| Scalar / Complex | ComplexMatrix | scalar broadcast |
Expr::Transpose (conjugate, A') and Expr::PlainTranspose (A.') both
handle ComplexMatrix. The conjugate transpose is the Hermitian adjoint.
Element-wise built-ins extended to ComplexMatrix:
| Function | Returns |
|---|---|
real(A) | real Matrix |
imag(A) | real Matrix |
abs(A) | real Matrix (element-wise modulus) |
conj(A) | ComplexMatrix |
angle(A) | real Matrix (argument in radians) |
isreal(A) | Scalar(0.0) |
Shape functions size, numel, length, and norm (Frobenius) all work.
Column-major 1-based indexing (both scalar and range) works identically to real matrices.
Files changed
| File | Change |
|---|---|
crates/ccalc-engine/src/env.rs | Value::ComplexMatrix(Array2<Complex<f64>>) variant |
crates/ccalc-engine/src/eval.rs | Literal upcasting; format_complex_cell / format_complex_matrix; all arithmetic combinations; conjugate & plain transpose; real/imag/abs/conj/angle/isreal element-wise; size/numel/length/norm; indexing; complex_pairs_to_complex_matrix for FFT |
crates/ccalc-engine/src/exec.rs | is_truthy and print_value for ComplexMatrix |
crates/ccalc/src/repl.rs | Prompt display, who, handle_disp for ComplexMatrix |
crates/ccalc/src/repl_tests.rs | Pattern updates for ComplexMatrix |
crates/ccalc-engine/src/eval_tests.rs | Updated fft_of helper; 16 tests in mod phase27_tests |
crates/ccalc/src/help.rs | print_complex() — removed limitations note; added matrix section |
docs/src/guide/complex.md | Added “Complex Matrices” section; removed outdated “Limitations” |
docs/src/guide/fft.md | Updated Cell → ComplexMatrix; X{k} → X(k); abs(S) example |
docs/src/SUMMARY.md | Added Phase 26 and 27 entries |
examples/complex_matrix.m | Full Phase 27 demo script (Octave-compatible) |
examples/fft_demo.calc | Updated Cell → ComplexMatrix API |
Version: v0.32.0 | Test count: 16 new in phase27_tests
Phase 28 — Plugin Architecture
Version: 0.34.0
Introduces a Plugin trait and thread-local PluginRegistry so extensions can
live in separate crates and register at startup without touching the engine.
The ccalc-plot crate is the reference plugin.
What’s new
Plugintrait (ccalc-engine::plugin) — implementname(), optionallyexported_names(), andcall().PluginRegistry— maps exported names to plugin implementations; checked before the built-in table so plugins can shadow any built-in.register_plugin(p)— registers aBox<dyn Plugin>in the thread-local registry.ccalc-plotcrate — stub plugin that registersplot,scatter,bar,stem,xlabel,ylabel, andtitle. Real rendering is added in Phase 29.- Tab completion — plugin exported names appear alongside built-ins in the REPL’s tab completer.
Completion criteria
plot(1)prints the stub message and returns without error.sin(1)continues to work (built-in fallthrough unchanged).- An empty
PluginRegistryproduces identical behaviour to v0.33.0. - All existing tests pass unmodified.
See also
- Plugins guide — how to write and register a plugin.
- Phase 29 — Plot engine (fills
PlotPluginwith real rendering).
Phase 29 — Plot Engine
Fills PlotPlugin with real rendering logic, building on the plugin
infrastructure added in Phase 28.
Phase 29a — ASCII terminal rendering (v0.35.0) ✅
What’s new
plot(x, y)— connected line chart viatextplots 0.8Braille canvas; requires theplotCargo feature.scatter(x, y)— point-cloud chart using the same renderer.xlabel("text"),ylabel("text"),title("text")— annotate the next plot; state is consumed and reset after each render.FigureState— thread-local struct that accumulates annotation state between calls.Plugin::callsignature —name: &strparameter added as the first argument so a single plugin instance can dispatch multiple exported names. Existing plugin implementations must add_name: &stras their first parameter.ccalc-plotfeature flags —plot(textplots/ASCII),plot-svg(plotters/SVG+PNG),plot-all(both).
Rendering notes
textplots 0.8 renders data using Braille characters (U+2800–U+28FF).
The canvas is populated only via the method-chain call
.lineplot(&data).display(). Calling Display::fmt directly on a Chart
outputs the frame/axes but leaves the Braille canvas blank.
Completion criteria
plot(1:10, (1:10).^2)renders a parabola in the terminal.title("T"); xlabel("x"); ylabel("y"); plot(x, y)shows annotations.plot(x, y)without theplotfeature returns an actionable error.
Phase 29b — SVG/PNG file export (v0.36.0) ✅
What’s new
plot(x, y, 'file.svg')— saves a connected line chart as an SVG vector graphic (800 × 600). Requires theplot-svgfeature.plot(x, y, 'file.png')— same but produces a 800 × 600 PNG raster image.scatter(x, y, 'file.svg')/scatter(x, y, 'file.png')— scatter (point cloud) file variants.- 1-arg inferred-x form —
plot(y, 'f.svg')infers x = 1:numel(y), matching the terminal behaviour. - Annotations in file output —
title,xlabel,ylabelare embedded in the SVG/PNG and cleared after each render call. - Auto-range — x and y extents are computed from data with a 5 % margin; single-point inputs use ± 1 padding.
plotters 0.3additional features required —line_series(forLineSeries) andttf(for TrueType text rendering in the bitmap backend).
Dispatch rules
plot(v) → ASCII to terminal requires plot
plot(x, y) → ASCII to terminal requires plot
plot(x, y, 'ascii') → ASCII to terminal requires plot
plot(x, y, 'f.svg') → SVG file export requires plot-svg
plot(x, y, 'f.png') → PNG file export requires plot-svg
scatter follows identical dispatch rules.
Implementation
All four file paths (line/scatter × SVG/PNG) share a single render_file
function in crates/ccalc-plot/src/file.rs (compiled under
#[cfg(feature = "plot-svg")]). The backend differs (SVGBackend vs
BitMapBackend); the chart-building logic is shared.
Completion criteria
plot(x, sin(x), 'wave.svg')creates a valid SVG file containing<svg.plot(x, sin(x), 'wave.png')creates a file whose first 8 bytes match the PNG magic number\x89PNG\r\n\x1a\n.scatterproduces equivalent files.title/xlabel/ylabeltext appears verbatim in the SVG output.FigureStateis cleared after each file render (second render does not inherit annotations from the first).- 9 integration tests in
crates/ccalc-plot/tests/svg_png_tests.rs(gated#[cfg(feature = "plot-svg")]).
Phase 29c — Bar, stem, log-scale, multi-series (v0.36.0) ✅
bar(v), stem(x, v), hist(v, n, 'f.svg'), stairs, loglog,
semilogx, semilogy, multi-series plot(x, Y) with colour cycle.
xlim/ylim/legend/grid annotation support added.
Phase 29d — 3-D plots (v0.36.0) ✅
plot3(x, y, z) and scatter3(x, y, z) using orthographic projection
(az = −37.5°, el = 30°, matching MATLAB defaults). Infrastructure lives in
crates/ccalc-plot/src/proj3d.rs (no feature gate).
zlabel/zlim footer annotations; 7+7 new tests.
See also
Phase 30 — Colormaps, imagesc & 3D Surfaces
Matrix-to-image rendering: false-colour heat-maps with configurable colormaps
and an optional colour-scale legend (colorbar). Builds on the PlotPlugin
infrastructure from Phase 29.
Phase 30a — colormap + imagesc + colorbar (v0.37.0) ✅
What’s new
imagesc(Z)— renders a numeric matrix as a false-colour ASCII image using 10 density characters. Requires theplotfeature.imagesc(Z, 'file.svg')/imagesc(Z, 'file.png')— saves a full-colour heat-map to a file. OneRectangleper matrix cell, RGB colour from the active colormap LUT. Canvas size fromfigure(w, h)(default 800 × 600 px). Requiresplot-svg.colormap('name')— sets the active colormap, consumed by the nextimagesccall. Validates against the list of supported names; returns an error for unknown names.colorbar()— sets a flag that tells the next file-exportimagesccall to append an 80 px colour-scale strip with five tick labels. Silently ignored in ASCII mode.
Supported colormaps
Eight colormaps implemented as 8-stop LUTs in colormap.rs, interpolated
linearly between stops via lut_lerp:
| Name | Description |
|---|---|
viridis | Perceptually uniform, blue → green → yellow (default) |
inferno | Black → purple → orange → white |
magma | Black → purple → pink → white |
plasma | Blue-purple → orange → yellow |
hot | Black → red → yellow → white |
cool | Cyan → magenta |
jet | Classic MATLAB: blue → cyan → green → yellow → red |
gray | Black → white (monochrome) |
Accepted argument forms:
| Call | Canvas | Feature |
|---|---|---|
imagesc(Z) | — (ASCII) | plot |
imagesc(Z, path) | from figure(w,h), else 800 × 600 px | plot-svg |
FigureState additions
#![allow(unused)]
fn main() {
pub colormap: Option<String>, // active colormap name; None → "viridis"
pub colorbar: bool, // draw colorbar strip in file export
}
Both fields are cleared (reset to defaults) after each imagesc render,
together with the existing annotation fields (title, xlabel, etc.).
ASCII tier
render_imagesc_ascii in colormap.rs (gated #[cfg(feature = "plot")]):
- Find
z_min/z_maxover all cells. - Map each cell to one of 10 density characters:
" .:-=+*#@█". - Print title (if set), then the character grid row by row.
colormapandcolorbarannotations are silently ignored.
File tier
render_imagesc_file in colormap.rs (gated #[cfg(feature = "plot-svg")]):
- If
colorbaris set, callroot.split_horizontally(w - CB_WIDTH)to produce(main_area, colorbar_area). Otherwise use the full canvas. - Call
draw_imagesc_cellsonmain_area:- Scale each cell value to
[0.0, 1.0]. - Map through
apply_colormap(t, name)→(u8, u8, u8). - Draw one
Rectangleper cell; MATLAB row-order (row 0 = top-left) is preserved by mapping rowrto y-range[(nrows-1-r), (nrows-r)].
- Scale each cell value to
- If
colorbaris set, calldraw_colorbaroncolorbar_area:- Draw 200 thin horizontal rectangles from bottom (
z_min) to top (z_max). - Add a right y-axis with 5 tick labels.
- Draw 200 thin horizontal rectangles from bottom (
Implementation
| Source file | Role |
|---|---|
crates/ccalc-plot/src/colormap.rs | LUT data, apply_colormap, ASCII + file render |
crates/ccalc-plot/src/dispatch.rs | extract_matrix helper (returns flat Vec<f64> + dims) |
crates/ccalc-plot/src/lib.rs | FigureState fields; match arms for colormap/colorbar/imagesc |
extract_matrix returns a plain Vec<f64> with (nrows, ncols) so that
colormap.rs never needs to name the ndarray type directly.
Tests
Unit tests in lib.rs (12 tests):
colormap("viridis")setsFigureState.colormap.colormap("unknown")returns an error naming the valid options.colorbar()setsFigureState.colorbar = true.imagescwith a non-matrix argument returns an error.imagescwith no arguments returns an error.imagescreturnsVoid(no feature builds).- ASCII
imagesccompletes without error (withplotfeature). - ASCII
imagescwith colorbar completes without error. - Gray colormap extremes return black / white exactly.
Integration tests in svg_png_tests.rs (3 tests, gated plot-svg):
imagesc(magic(8), 'heat.svg')→ file contains<svg.imagesc(magic(8), 'heat.png')→ PNG magic bytes\x89PNG.imagescwithcolorbar()+colormap("jet")→ SVG file created.
Example scripts
examples/colormap/imagesc_demo.calc— gradient matrix + all 8 colormaps + colorbarexamples/colormap/mandelbrot.calc— Mandelbrot escape-count map withcolormap('inferno')examples/colormap/julia.calc— Julia set withcolormap('magma')
Phase 30b — meshgrid + surf + mesh (v0.37.0+001) ✅
3D surface visualisation: surf draws a colored surface, mesh draws a
wireframe. Both require meshgrid to generate the coordinate matrices.
meshgrid — engine change
meshgrid is a new engine built-in (added to builtin_names() and
call_builtin in eval.rs). Uses NARGOUT to select single or multi-output:
| Call | Returns |
|---|---|
[X, Y] = meshgrid(x, y) | Value::Tuple([X_mat, Y_mat]) |
X = meshgrid(x, y) | Value::Matrix(X_mat) (X only) |
[X, Y] = meshgrid(x) | square N×N grid (x used for both axes) |
X is M×N where every row is a copy of x; Y is M×N where every column is a
copy of y. The 1-argument form uses x for both dimensions (MATLAB
compatible).
surf and mesh — plot plugin
Both functions are dispatched by the PlotPlugin (added to EXPORTED).
Argument forms:
| Call | Output |
|---|---|
surf(X, Y, Z) | ASCII elevation map (requires plot feature) |
surf(X, Y, Z, 'f.svg') | SVG file (requires plot-svg) |
surf(X, Y, Z, 'f.png') | PNG file (requires plot-svg) |
mesh(X, Y, Z) | wireframe ASCII (same as surf in ASCII mode) |
mesh(X, Y, Z, 'f.svg') | wireframe SVG |
X, Y, Z must all have the same dimensions (M×N). A clear error is returned if dimensions differ.
ASCII tier
render_surf_ascii in surface.rs (gated #[cfg(feature = "plot")]):
- Compute the maximum Z over each column (
col_max). - Print a character grid of height 20: row
kprints#for columns wherecol_max[c] ≥ z_min + z_range * (k / 20). - Print x-axis tick labels (first and last x value).
- Print
xlabel/ylabel/zlabelfooter lines when set.
Both surf and mesh produce identical ASCII output.
File tier
draw_surface in surface.rs (gated #[cfg(feature = "plot-svg")]).
Axis mapping — chart (X, Y, Z) = our (X, Z_height, Y_depth):
| Chart dim | plotters role | our value |
|---|---|---|
| First (X) | horizontal left–right | x_vals |
| Second (Y) | visual height (up) | z values |
| Third (Z) | depth (into page) | y_vals |
Points: (x_vals[c], z[r*nc+c], y_vals[r]) ensure our Z (function value)
is the visual height and our Y (spatial coordinate) is depth.
This matches the conventional MATLAB surf view.
surf: draws all row lines and all column lines, each colored by the
mean Z of that row or column through the active colormap.
mesh: draws only row lines (sparser wireframe appearance).
Note: SurfaceSeries was evaluated but rejected — its axis-mapping convention
((xi, yi, f(xi,yi)) → (chart_X, chart_Y_height, chart_Z_depth)) placed our
spatial Y values on the height axis, causing a flat-wall artifact. LineSeries
with explicit point ordering is simpler and correct.
Implementation
| Source file | Role |
|---|---|
crates/ccalc-engine/src/eval.rs | meshgrid cases in call_builtin; entry in builtin_names() |
crates/ccalc-plot/src/surface.rs | ASCII + SVG/PNG renderers for surf and mesh |
crates/ccalc-plot/src/lib.rs | surf/mesh in EXPORTED; dispatch to render_surface |
Tests
Engine tests (eval_tests.rs, mod phase30b_tests): 5 tests —
meshgrid dimensions, X row equality, Y column equality, single-output form,
single-argument square form.
Plot tests (lib.rs, mod tests): 7 tests —
missing arguments error, dimension mismatch error (surf + mesh), ASCII no-error
(surf + mesh), SVG file creation (surf), PNG magic bytes (mesh).
Example scripts
examples/surf_demo/surf_demo.calc— sine wave surface + Gaussian bellexamples/surf_demo/mesh_demo.calc— sine wave wireframe + saddle surface
Both write output files to examples/surf_demo/tmp/.
Phase 30c — contour + contourf (v0.37.0+002) ✅
2D contour plots using the marching squares algorithm.
Functions
| Call | Output |
|---|---|
contour(X, Y, Z) | ASCII char-art isolines (requires plot) |
contour(X, Y, Z, n) | ASCII with n levels |
contour(X, Y, Z, n, 'f.svg') | SVG isoline chart (requires plot-svg) |
contour(X, Y, Z, n, 'f.png') | PNG isoline chart |
contourf(X, Y, Z, n, 'f.png') | PNG filled-contour chart |
X, Y, Z are M×N matrices from meshgrid. Default level count is 10.
Algorithm
compute_levels(z_min, z_max, n) — returns n interior levels evenly spaced
inside (z_min, z_max) at positions z_min + (z_max − z_min) × k / (n+1) for
k = 1..=n. Levels never equal the data extrema.
marching_squares — 16-case lookup table over every 2×2 cell.
Bit assignment: bit 0 = BL (z[r][c]), bit 1 = BR, bit 2 = TR, bit 3 = TL.
Edge crossings use linear interpolation. Saddle cases 5 and 10 split into two
separate islands (no centre-value disambiguation).
ASCII tier
render_contour_ascii (gated #[cfg(feature = "plot")]):
80 × 24 char grid. Each character is chosen from " .:-=+*#" by the Z band of
the sampled cell (band 0 = lowest = space, band 7 = highest = #).
File tier
draw_contour (gated #[cfg(feature = "plot-svg")]), called by both
render_contour_file and render_contourf_file:
- Build a
ChartBuilderwith the actual data coordinate range (x_lo..x_hi,y_lo..y_hi). - If
filled: draw oneRectangleper grid cell, colored by the cell’s mean Z mapped through the active colormap. Band index = count of levels ≤z_mean; normalisedt = band / n_levels. - Draw one
LineSeriesper marching-squares segment, colored by level index through the colormap.
Bug fix (v0.37.0+003)
A parser precedence bug caused -X .^ 2 to be evaluated as (-X) .^ 2 = X^2
instead of -(X .^ 2) = -X^2. This made exp(-X .^ 2 - Y .^ 2) compute
exp(X^2 + Y^2) — inverted — so contour plots of the Gaussian bell showed peaks
at the corners rather than the centre.
Fix: reordered the recursive-descent parse chain so unary minus has lower
precedence than ^/.^, matching MATLAB/Octave semantics.
Implementation
| Source file | Role |
|---|---|
crates/ccalc-plot/src/contour.rs | compute_levels, marching_squares, ASCII + file renderers |
crates/ccalc-plot/src/lib.rs | contour/contourf in EXPORTED; dispatch to render_contour |
crates/ccalc-engine/src/parser.rs | Precedence fix: parse_term → parse_unary → parse_power → parse_primary |
crates/ccalc-engine/src/parser_tests.rs | Regression test test_unary_minus_lower_precedence_than_power |
Tests
Unit tests in contour.rs (4 tests): compute_levels zero/one/five counts;
marching squares case 1, no-crossing, saddle case 5, too-small grid.
Plot tests in lib.rs (7 tests): missing args, dimension mismatch, wrong
level type (contour + contourf), ASCII no-error, SVG file creation, PNG magic
bytes.
Example
examples/contour_demo/contour_demo.calc — Gaussian bell + saddle surface;
writes four files to examples/contour_demo/tmp/.
Phase 30.5 — Unified color system (v0.41.0) ✅
Closes the color gaps: colormap() gains custom matrix input; style strings
gain full color names, hex codes, and RGB matrices; bar/stem/hist/quiver
gain a 'color' named argument. All plot types now share a consistent two-layer
color model.
Phase 30.5a — ColormapSpec enum + colormap(M) N×3 matrix input (v0.41.0) ✅
Goal: colormap() accepts a custom N×3 matrix (rows = control points,
columns = R G B in [0, 1]) in addition to named colormaps.
New types in colormap.rs:
#![allow(unused)]
fn main() {
pub enum ColormapSpec {
Named(String), // one of 8 built-in names
Custom(Vec<(u8, u8, u8)>), // user-supplied control points, 0–255
}
pub fn apply_colormap_spec(t: f64, spec: &ColormapSpec) -> (u8, u8, u8)
pub fn validate_colormap_spec(spec: &ColormapSpec) -> Result<(), String>
}
FigureState.colormap changed from Option<String> → Option<ColormapSpec>.
Engine dispatch (eval.rs):
colormap([0 0 1; 1 0 0]) % two-stop blue → red
colormap([0 0 1; 1 1 0; 1 0 0]) % three-stop blue → yellow → red
The matrix must be N×3 with values in [0, 1]; a 1-row matrix returns an error (“custom colormap must have at least 2 rows”).
Tests: 6 tests — custom 2-point, 3-point, too-short LUT, Named→apply_colormap
parity, matrix dispatch via engine, wrong column count.
Phase 30.5b — Extended style strings: full names, hex, 1×3 RGB matrix (v0.41.0+001) ✅
Goal: plot(x, y, 'red'), plot(x, y, '#FF4400'), and
plot(x, y, [1 0.27 0]) all work. Same extensions reach bar, stem,
scatter, fill, area.
parse_color_token in style.rs — central color parser used by both
parse_style_str and the 'color' named-argument handler:
| Input | Example | Resolves to |
|---|---|---|
| Single letter | 'r' | StyleColor(255,0,0) |
| Full name | 'orange' | StyleColor(255,165,0) |
gray/grey | both spellings | StyleColor(128,128,128) |
Hex #RRGGBB | '#FF4400' | StyleColor(255,68,0) |
Full names supported: red, green, blue, cyan, magenta, yellow,
black, white, orange, purple, gray/grey.
1×3 RGB matrix detection in extract_style_and_file_arg (dispatch.rs):
a trailing [r g b] matrix with all values ∈ [0, 1] is consumed as a
StyleSpec { color: Some(StyleColor) }.
'color' named argument — trailing ('color', <value>) pair in arg list
builds a minimal StyleSpec. The value can be a string or a 1×3 RGB matrix.
bar(x, y, 'color', 'red')
hist(v, 20, 'color', '#FF8800')
bar(v, 'color', [0.2 0.6 1.0])
Tests: 8 tests — full name red/orange, gray/grey alias, hex parse, bad hex
format, RGB matrix dispatch, 'color' named arg for bar (ASCII), 'color' hex
via named arg.
Phase 30.5c — Option<StyleSpec> for Bar / Stem / Hist / Quiver (v0.41.0+002) ✅
Goal: all PendingSeries variants carry an Option<StyleSpec>; both the
accumulating (draw_panel) and standalone render paths use the color when
present, falling back to SERIES_COLORS[i % 7].
PendingSeries enum changes (lib.rs):
#![allow(unused)]
fn main() {
Bar(Vec<f64>, Vec<f64>, Option<StyleSpec>),
Stem(Vec<f64>, Vec<f64>, Option<StyleSpec>),
Hist { counts: Vec<usize>, edges: Vec<f64>, style: Option<StyleSpec> },
Quiver(Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>, Option<StyleSpec>),
}
New dispatch helpers in lib.rs:
render_bar_xy(x, y, path, style, state)— dispatches bar to ASCII or filerender_stem_xy(x, y, path, style, state)— dispatches stem to ASCII or file
extract_style_and_file_arg_min(args, min_data) in dispatch.rs:
Variant of the style extractor with a configurable guard: the 1×3 RGB matrix
detection only fires when args.len() > min_data. Quiver uses min_data = 4
to prevent a data vector from being mistaken for a color spec.
Color resolution in file.rs (draw_panel):
#![allow(unused)]
fn main() {
PendingSeries::Bar(xs, ys, style) => {
let color = style_to_rgb(style)
.unwrap_or(SERIES_COLORS[series_idx % SERIES_COLORS.len()]);
// draw rectangles with color
}
}
Same pattern applied to Stem, Hist, and Quiver arms.
Tests: 6 tests — bar red, bar default color cycle, stem blue, hist hex orange, quiver green, structural exhaustiveness check.
Two-layer color model summary
┌─────────────────────────────────────────────────────────┐
│ Discrete layer (per-series) │
│ StyleColor(r,g,b) ← style string / RGB matrix / │
│ 'color' named arg │
│ Fallback: SERIES_COLORS[i % 7] │
├─────────────────────────────────────────────────────────┤
│ Continuous layer (per-value) │
│ ColormapSpec::Named(s) → apply_colormap(t, s) │
│ ColormapSpec::Custom(v) → lut_lerp(t, &v) │
│ Output: (u8,u8,u8) for imagesc/surf/mesh/contour │
└─────────────────────────────────────────────────────────┘
The layers are independent: a scatter series can carry its own StyleColor
while an imagesc underneath uses ColormapSpec.
See also
Phase 31 — Configurable REPL Prompt + Syntax Highlighting
Introduced in v0.42.0.
Phase 31 adds two user-experience improvements to the interactive REPL: a fully configurable prompt (31a) and real-time syntax highlighting (31b + 31c).
31a — Configurable prompt
The prompt template is controlled by two keys in ~/.config/ccalc/config.toml:
[repl]
prompt1 = "[ {ans} ]: " # primary prompt (default)
prompt2 = " >> " # continuation prompt inside multi-line blocks
Content placeholders
| Placeholder | Expands to |
|---|---|
{ans} | Formatted value of ans |
{line} | Session command counter |
{user} | Current OS username |
{host} | Short hostname (before the first dot) |
{cwd} | Full current working directory |
{cwd_short} | Last path component of the current directory |
{time} | Current time as HH:MM:SS (UTC) |
Colour placeholders
Named ANSI colours: {reset}, {bold}, {dim}, {black}, {red},
{green}, {yellow}, {blue}, {magenta}, {cyan}, {white}, {gray},
and eight {bright_*} variants.
24-bit truecolor: {#RRGGBB} (e.g. {#FF8800} for orange).
[repl]
prompt1 = "{gray}({line}){reset} [ {ans} ]: "
prompt1 = "{green}{user}@{host}{reset}:{cyan}{cwd_short}{reset}$ "
prompt1 = "{#FF8800}ccalc{reset} [{line}] {ans} > "
Architecture: dual-output render_prompt()
rustyline computes cursor position from the plain prompt string. Injecting ANSI
escape codes into the string passed to readline() shifts input text sideways.
The fix uses the Highlighter trait’s highlight_prompt() instead:
render_prompt()returns(plain: String, colored: String).plain(no ANSI) is passed toreadline()for correct cursor math.coloredis stored inCcalcHelper.colored_promptand returned byhighlight_prompt()so the terminal renders the colours.
Implementation files:
crates/ccalc/src/repl.rs—render_prompt(),parse_rgb_placeholder(),CcalcHelper.colored_prompt,update_prompt(),highlight_prompt().crates/ccalc/src/config.rs—ReplConfig { prompt1, prompt2 },[repl]section inDEFAULT_CONFIG,Config::prompt1()/prompt2().
31b — Syntax highlighting
ccalc highlights the current input line in real time as you type. Highlighting
is implemented via rustyline::Highlighter on CcalcHelper.
Colour categories
| Category | Default colour | Tokens |
|---|---|---|
| Keywords | yellow | if for while end function else elseif return break continue do until switch case otherwise try catch global persistent |
| Numbers | cyan | 42, 3.14, 1e-3, 0xFF |
| Strings | green | 'hello', "world" |
| Comments | dark gray | % comment, # comment |
| Built-ins | bright cyan | sin, plot, zeros, reshape, … |
| Errors | red | Unclosed ', ", [, ( |
| User variables / operators | default | everything else |
Shadowing
If a keyword or built-in name is assigned as a user variable (e.g. end = 42),
it gets default colour — consistent with evaluation semantics where the variable
shadows the keyword.
Implementation
highlight_line(line, env_keys, builtin_keys, colors) -> String is a standalone
function in crates/ccalc/src/highlight.rs. It uses a character-level scanner
with a Prev state enum to correctly distinguish ' as a transpose operator
(after an identifier, number, ), ]) from the start of a char-array string
literal.
31c — Configurable colour scheme
Colours are set in the [highlight] section of config.toml:
[highlight]
enabled = true # set to false to disable highlighting
# keywords = "yellow"
# numbers = "cyan"
# strings = "green"
# comments = "dark_gray"
# builtins = "bright_cyan"
# errors = "red"
Colour formats
| Format | Example | Notes |
|---|---|---|
| Named 4-bit | "yellow", "bright_cyan", "dark_gray" | 16 standard colours |
| 8-bit palette | "color256(220)" | 256-colour extended palette |
| 24-bit truecolor | "#FFD700" | Requires a true-color terminal |
| Bold prefix | "bold:yellow" | Combines bold with any colour above |
Unknown values are silently ignored and the built-in default is used.
Applying changes
[ 0 ]: config reload
Config reloaded.
Colour scheme changes take effect immediately without restarting ccalc.
REPL help
[ 0 ]: help highlight