Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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?

UserTypical use
Embedded / systems engineerArithmetic, hex/bin conversions, bit masks
DevOps / SREQuick calculations in scripts and pipelines
Scientist / studentInteractive session with variables and math functions
MATLAB / Octave userFamiliar syntax, no heavy installation

Project structure

CrateRole
crates/ccalcCLI binary: argument parsing, REPL, pipe mode
crates/ccalc-engineLibrary: 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

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

TopicWhat you will find
Getting StartedInstallation, first session, key concepts
REPL ModeInteractive session: history, tab completion, workspace
Pipe & Script ModeOne-liners, shell pipelines, running .m files
Arithmetic & OperatorsPrecedence, bitwise ops, the ans variable
VariablesAssignment, who, clear, workspace save/load
Number BasesHex 0x, binary 0b, octal 0o input and display
Number Display Formatformat short/long/rat/hex and custom precision
Formatted Outputfprintf, sprintf, %d/%f/%g/%s specifiers
Configuration~/.config/ccalc/config.toml reference
MatricesLiterals, arithmetic, indexing, built-in constructors
Vector & Data Utilitiessum, sort, find, reshape, unique, …
Comparison & Logical Operators==, ~=, &&, |, element-wise ops
Complex Numbers3+4i, abs, angle, conj, complex matrices
StringsChar arrays, string objects, built-in string functions
File I/Ofopen/fclose, dlmread/dlmwrite, isfile
Control Flowif, for, while, switch, break, continue
User-defined FunctionsNamed functions, lambdas, nargin/nargout
Cell Arrays{...}, brace indexing, cellfun, arrayfun
Structs and Struct Arrays.field access, struct(...), fieldnames
Error Handlingerror, try/catch, pcall, lasterr
Variable Scopingglobal, persistent, private/ directories
Statistics & Random Numbersmean, std, rand, randn, distributions
Linear Algebraeig, svd, lu, qr, chol, pinv
JSONjsondecode, jsonencode
CSV — Tables and Matricesreadtable, writetable, csvread, csvwrite
MAT Filesload/save with .mat format
Datetime & Durationdatetime, duration, formatting, arithmetic
Matrix Utilities & Set Operationsintersect, union, ismember, kron
Polynomial Operations & Interpolationpolyval, polyfit, roots, interp1
FFT & Signal Processingfft, ifft, fftshift, freqz
Dynamic Evaluation & Timingeval, feval, tic/toc
PluginsThe Plugin trait and custom built-ins
Plot Functionsplot, 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

FlagDescription
-h, --helpPrint help and exit
-v, --versionPrint 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

CommandAction
exit, quitQuit
clsClear 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
whoShow all defined variables
clearClear all variables
clear <name>Clear a single variable
pShow current decimal precision
p<N>Set precision to N decimal places (0–15)
hex / dec / bin / octSwitch display base
baseShow ans in all four bases
wsSave workspace to file
wlLoad workspace from file
disp(expr)Print value without updating ans
fprintf('fmt')Print formatted string (\n, \t, \\ supported)
configShow config file path and active settings
config reloadRe-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

KeyAction
/ Browse input history
Ctrl+RReverse history search
← → / Home / EndCursor movement
Ctrl+AGo to beginning of line
Ctrl+EGo to end of line
Ctrl+WDelete word before cursor
Ctrl+UDelete from cursor to beginning of line
Ctrl+KDelete from cursor to end of line
Ctrl+LClear screen
Ctrl+C / Ctrl+DQuit

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 typeDefault colourExamples
Keywordsyellowif, for, while, end, function, else, …
Numberscyan42, 3.14, 1e-3, 0xFF
Stringsgreen'hello', "world"
Commentsdark gray% a comment, # also a comment
Built-insbright cyansin, plot, zeros, reshape, …
ErrorsredUnclosed ', ", [, (
User variables / operatorsdefaulteverything 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

OperatorOperationExample
+Addition3 + 47
-Subtraction / unary minus10 - 46, -5
*Multiplication3 * 721
/Division10 / 42.5
^Exponentiation (right-associative)2 ^ 101024

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.

OperatorMeaning
==Equal
~=Not equal
<Less than
>Greater than
<=Less or equal
>=Greater or equal

Logical operators

OperatorMeaning
~exprLogical NOT
&&Logical AND
||Logical OR

See Comparison & Logical Operators for full details.

Precedence (high → low)

  1. postfix ' — transpose
  2. ^, .^ — right-associative
  3. unary -, ~ — negation, logical NOT
  4. *, /, .*, ./, .^, implicit multiplication
  5. +, -
  6. : — range
  7. ==, ~=, <, >, <=, >= — comparison (non-associative)
  8. && — logical AND
  9. || — logical OR (lowest)

Use parentheses to override: (2 + 3) * 420.

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:

ExpressionSemantics
scalar + matrixAdd scalar to every element
matrix + matrixElement-wise (shapes must match)
scalar * matrixScale every element
matrix / scalarDivide every element
matrix ^ scalarRaise every element to the power

See Matrices for full details.

Functions & Constants

One-argument functions

FunctionDescriptionExample
sqrt(x)Square root of xsqrt(144)12
abs(x)Absolute value of xabs(-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 zeroround(2.5)3
sign(x)−1 if x < 0, 0 if x = 0, 1 if x > 0sign(-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 xexp(1)2.71828…
sin(x)Sine of x, where x is in radianssin(pi/6)0.5
cos(x)Cosine of x, where x is in radianscos(0)1
tan(x)Tangent of x, where x is in radianstan(pi/4)1
asin(x)Inverse sine of x ∈ [−1, 1]; result in [−π/2, π/2]asin(1) * 180/pi90
acos(x)Inverse cosine of x ∈ [−1, 1]; result in [0, π]acos(0) * 180/pi90
atan(x)Inverse tangent of x; result in (−π/2, π/2)atan(1) * 180/pi45

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

FunctionDescriptionExample
atan2(y, x)Four-quadrant inverse tangent; result in (−π, π]atan2(1,1)*180/pi45
mod(a, b)Remainder of a ÷ b; result has the sign of bmod(370, 360)10
rem(a, b)Remainder of a ÷ b; result has the sign of arem(-1, 3)-1
max(a, b)Larger of two scalar valuesmax(3, 7)7
min(a, b)Smaller of two scalar valuesmin(3, 7)3
hypot(a, b)Euclidean distance √(a²+b²), numerically stablehypot(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.

FunctionDescription
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

NameValue
pi3.14159265358979…
e2.71828182845904…
nan / NaNIEEE 754 Not-a-Number — propagates through all arithmetic
inf / InfPositive infinity; use -inf for negative infinity
ansResult 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:

PrefixBaseExampleValue
0x / 0XHexadecimal0xFF255
0b / 0BBinary0b101010
0o / 0OOctal0o1715

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):

CommandEffect
decDecimal (default)
hexHexadecimal
binBinary
octOctal

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:

NameValue
pi3.14159265358979…
e2.71828182845904…

View and clear

CommandAction
whoShow all defined variables and their values
clearClear all variables
clear nameClear 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

CommandAction
wsSave all variables to ~/.config/ccalc/workspace.toml
wlLoad 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

CommandDescription
formatReset to short (5 significant digits)
format short5 significant digits, auto fixed/scientific
format long15 significant digits, auto fixed/scientific
format shortEAlways scientific, 4 decimal places
format longEAlways scientific, 14 decimal places
format shortGSame as short (MATLAB shortG alias)
format longGSame as long (MATLAB longG alias)
format bankFixed 2 decimal places (currency)
format ratRational approximation p/q
format hexIEEE 754 double-precision bit pattern (16 hex digits)
format +Sign only: + positive, - negative, space for 0
format compactSuppress blank lines between outputs
format looseAdd blank line after every output (default)
format NN 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.0011e-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. 400921FB54442D18 for pi).
  • hex — switches the display base to hexadecimal for integer values (e.g. 0xFF255 shown as 0xFF).

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

SpecifierMeaning
%d, %iInteger (value truncated to whole number)
%fFixed-point decimal, default 6 places
%eScientific notation (1.23e+04)
%gShorter of %f and %e
%xHexadecimal, lowercase (ff)
%XHexadecimal, uppercase (FF)
%sString (char array or string object)
%%Literal %

Width, precision, and flags

The general form is:

%[flags][width][.precision]specifier
FlagMeaning
-Left-align within field width
+Always show sign (+ or −)
0Zero-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

SequenceCharacter
\nNewline
\tTab
\\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

  • format command — controls default display format for disp() and assignment output
  • help io — concise in-REPL reference
  • help script — full format specifier reference
  • examples/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

PlaceholderExpands 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.

PlaceholderEffect
{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

KeyDefaultHighlighted tokens
keywordsyellowif, for, while, end, function, else, elseif, return, break, continue, do, until, switch, case, otherwise, try, catch, global, persistent
numberscyanInteger, decimal, scientific, and hex literals (42, 3.14, 1e-3, 0xFF)
stringsgreenSingle-quoted '...' and double-quoted "..." string literals
commentsdark gray% and # to end of line
builtinsbright cyanAll built-in function names (sin, plot, zeros, …) and plugin functions
errorsredUnclosed 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

FormatExampleNotes
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

CommandAction
configShow config file path and currently active settings
config reloadRe-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:

  1. Edit config.toml:
    [display]
    precision = 4
    base = "hex"
    
  2. 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

FunctionDescription
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.

ConstantValue
nanIEEE 754 Not-a-Number
infPositive 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)

FunctionSignatureReturns 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.

FunctionSignatureDescription
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 values
  • p = 2 (default) → L2 Euclidean norm
  • p = 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.

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionSignatureDescription
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

FunctionDescription
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.

OperatorMeaning
==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:

  • 01
  • 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 examples
  • ccalc 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

FunctionDescription
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:

FunctionInputOutput
real(A)ComplexMatrixreal Matrix (one real part per element)
imag(A)ComplexMatrixreal Matrix (one imaginary part per element)
abs(A)ComplexMatrixreal Matrix (element-wise modulus)
conj(A)ComplexMatrixComplexMatrix (element-wise conjugate)
angle(A)ComplexMatrixreal Matrix (argument in radians)
isreal(A)ComplexMatrix0 (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.

FunctionOn vectorOn M×N matrix
sum(A)single Complex scalarComplexMatrix 1×N of column sums
prod(A)single Complex scalarComplexMatrix 1×N of column products
mean(A)single Complex scalarComplexMatrix 1×N of column means
trace(A)(N/A)Complex scalar (sum of diagonal)
diag(A)diagonal matrixComplexMatrix 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:

TypeSyntaxSemantic
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:

ModeDescription
'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:

FieldTypeDescription
namechar arrayFile or directory name
folderchar arrayAbsolute path of the containing directory
isdirScalar1.0 for directories, 0.0 for files
bytesScalarFile 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

TypePersisted
ScalarYes
Char array ('text')Yes
String object ("text")Yes
MatrixNo
ComplexNo

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 typeTruthy when
Scalarnon-zero and not NaN
Matrixall elements non-zero and not NaN
Str/StringObjnon-empty
Voidnever

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

OperatorEquivalent to
x += ex = x + e
x -= ex = x - e
x *= ex = x * e
x /= ex = x / e
x++x = x + 1
x--x = x - 1
++xx = x + 1
--xx = 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 function header 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 % example displays as example.
  • #-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>.m on the current directory and the session path, loads it, and calls it — no explicit source() 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 indexing c{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

FunctionDescription
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 reference
  • help userfuncs — varargin/varargout in the context of user functions
  • ccalc 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

FunctionDescription
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 = m creates a copy (unlike MATLAB handle semantics). Mutations to m2 do not affect m.
  • Maps are not persisted by ws/save — same policy as matrices and cells.
  • remove(m, k) mutates m in-place without an assignment statement, matching MATLAB handle-class behaviour as closely as possible under value semantics.

See also

Cell Arrays · Structs

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

FunctionDescription
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 reference
  • help cells — cell arrays, varargin/varargout
  • ccalc examples/structs.calc — annotated scalar struct example
  • ccalc examples/struct_arrays.calc — annotated struct array example
  • ccalc 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 try body succeeds, catch is skipped.
  • catch e binds a struct with field message to the catch variable.
  • Anonymous catch (no variable) silently handles the error.
  • try with no catch silently 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

MechanismVisibilityLifetime
globalAny function that declares itUntil clear or session end
persistentPrivate to the declaring functionUntil session end
private/Parent directory onlyFile exists on disk
+pkg/Anyone, via pkg.func() syntaxAutoloaded 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.

FunctionDescription
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.

FunctionDescription
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.

FunctionDescription
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

FunctionDescription
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

FunctionDescription
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

CallDescription
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.

OperationPure RustBLAS buildNotes
50×50 A*B~4 ms~0.3 msBLAS overhead dominates at small sizes
500×500 A*B~3 s~50 ms~60× speedup
inv, \, lu, qr, svd, eigpure RustLAPACKAll 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 json feature:

cargo build --release --features json

Without this flag, calling jsondecode or jsonencode returns 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 typeccalc value
object {…}Struct
all-numeric arrayMatrix (1×N row vector)
mixed arrayCell
stringStr (char array)
numberScalar
true / falseScalar (1 / 0)
nullScalar(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 valueJSON output
Structobject {…}
Matrix (1×N row vector)flat array […]
Matrix (M×N)array of row arrays
Cellarray […]
Scalarnumber
Scalar(NaN)null
Str / StringObjstring

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 (unlike dlmread, which uses 0.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 contentccalc type
All cells parseable as numbers (empty → NaN)Matrix N×1
Any non-numeric cellCell 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 typeWritten as
Matrix (N×1)One number per row
CellEach element formatted (strings or numbers)
ScalarSingle-row value
Str / StringObjSingle-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

Featuredlmreadreadmatrixreadtable
Header rowerrorauto-skipalways first row
Empty cells0.0NaNNaN (numeric cols)
String columnserrorerrorCell
Quoted fieldsnoyesyes
Return typeMatrixMatrixStruct 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 mat feature:

cargo build --release --features mat

Without 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 typeccalc value
scalar doubleScalar
M×N double matrixMatrix
char array (string)Str (char array)
structStruct
struct arrayStructArray
cell arrayCell
empty / nullScalar(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

ExpressionResult type
datetime + durationDateTime
datetime - durationDateTime
datetime - datetimeDuration
duration + durationDuration
duration * scalarDuration
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

TokenDescription
yyyy4-digit year
MMM3-letter month abbreviation (Jan, Feb, …)
MM2-digit month
dd2-digit day
HH2-digit hour (24 h)
mm2-digit minute
ss2-digit second
SSS3-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:

  • DateTimeArrayDurationArray
  • DurationArrayDurationArray
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:

kkeeps elements where …
0 (default)col − row ≥ 0 (triu) / col − row ≤ 0 (tril)
k > 0strictly above the main diagonal
k < 0extends 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

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 Cell of Scalar/Complex values.
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).

MethodDescription
'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 fft feature:

cargo build --release --features fft

Without this flag, calling fft or ifft returns an error message explaining how to enable the feature. fftshift, ifftshift, and fftfreq are 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

FunctionArgsFeature flag
fft(x)real vectorfft
fft(x, n)real vector, lengthfft
ifft(X)ComplexMatrixfft
fftshift(x)real or complex matrixalways
ifftshift(x)real or complex matrixalways
fftfreq(n, d)count, spacingalways

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

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 flagBackendEnables
plottextplotsASCII Braille chart printed to terminal
plot-svgplottersSVG and PNG file export (default 800 × 600 px, customisable via figure)
plot-allbothterminal + 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; x inferred as 1: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; legend labels 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.

CallBin 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₁₀].

FunctionX axisY axis
logloglog₁₀log₁₀
semilogxlog₁₀linear
semilogylinearlog₁₀
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.

CallResult
[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 from meshgrid.
  • Z — scalar-field matrix, same size as X and Y.
  • n — number of contour levels (default 10). 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 .svg or .png path: file tier draws each isoline as a colored LineSeries, 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 no subplot is active, immediately renders the accumulated series to the terminal (ASCII tier). For file output, call savefig before hold('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 from Z_min to Z_max. Grid dimensions adapt to terminal width ($COLUMNS, default 80).
  • With a .svg or .png path: file tier draws one filled Rectangle per cell, scaled to the canvas. Canvas size comes from figure(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:

NameDescription
viridisPerceptually uniform, blue → green → yellow (default)
infernoBlack → purple → orange → white
magmaBlack → purple → pink → white
plasmaBlue-purple → orange → yellow
hotBlack → red → yellow → white
coolCyan → magenta
jetClassic MATLAB: blue → cyan → green → yellow → red
grayBlack → 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 Rectangle per 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·B per pixel and renders the equivalent grayscale with the density palette. This produces the same output as imshow(L).
  • File tier: one filled Rectangle per 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:

Behaviourimagesc(Z)imshow(Z)
Normalisationmin/max scale to [0,1]clamp to [0,1]
Colormapactive colormap (default viridis)gray only
Values > 1.0map to colormap maximumwhite
Values < 0.0map to colormap minimumblack
RGB channelsnoyes (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:

FormExampleDescription
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:

CodeColorCodeColor
rredccyan
ggreenmmagenta
bblueyyellow
kblackwwhite

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.

CodeMeaning
r g b c m y k wSingle-letter color
full name or #RRGGBBFull 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:

FormSyntax
4-scalarrectangle(x, y, w, h)
vectorrectangle([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 as x.
  • c — scalar color values; automatically normalized to [min(c), max(c)] before the colormap lookup.
  • Change the colormap with colormap(name) before the scatter call.

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):

  • Cell array of strings → slice labels.
  • Numeric vector (same length as v) → per-slice explode offsets (see below).
  • String ending in .svg/.png → output file path (requires plot-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), or
  • savefig('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 typeUses $COLUMNSUses $LINES
plot, scatter, bar, stem, stairsYes (Braille canvas width)Yes (Braille canvas height)
fill, areaYes (character grid)Yes
histYes (bar width)
contour, contourfYesYes
surf, meshYes (elevation height)
quiverYesYes

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

FunctionEffect
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

FunctionEffect
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

FunctionEffect
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

CallEffect
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):

ExtensionFormatNotes
'.svg'SVG vector graphicOpens in any browser
'.png'PNG rasterDefault 800 × 600 px; override with figure(w, h)
'ascii'Terminal chartForces 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
FunctionEffectWorks without feature
title('text')Chart titleYes
xlabel('text')X-axis labelYes
ylabel('text')Y-axis labelYes
zlabel('text')Z-axis label (consumed by plot3/scatter3)Yes
xlim([lo, hi])Override x-axis rangeYes
ylim([lo, hi])Override y-axis rangeYes
zlim([lo, hi])Override z-axis range (3D file export)Yes
legend(s1, s2, …)Series labels — applied in SVG/PNG multi-series chartsYes
gridToggle grid on/offYes
grid('on')Enable gridYes
grid('off')Disable gridYes
colormap('name')Set colormap for next imagesc / surf / mesh / contourYes
colorbar()Append colour-scale strip (file export only, imagesc)Yes
clabel()Enable level labels on the next contour / contourf renderYes
figure(w, h)Set SVG/PNG canvas size in pixels (1–16384); ASCII ignores itYes
text(x, y, 's')Add label at data coordinate — flushed with next renderYes
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 pixelsYes
linewidth(f)Override default line stroke width for all seriesYes
markersize(n)Override default marker radius for all seriesYes
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)): Circle elements; each fill color mapped through the active colormap; radius from sz (scalar or per-point vector).
  • Contour labels (clabel() before contour/contourf): one Text element per level, placed at the midpoint of the longest segment; color matches the isoline; font size scales with fontsize(n).
  • Error bars (errorbar): three PathElement segments (shaft + two caps) plus a Circle centre dot per data point; cap width = 3 % of x-range.
  • Pie charts (pie): one Polygon wedge per slice (64 arc points + center); axes and mesh disabled; labels via Text at radius × 1.18; explode offsets along slice bisector.
  • Bar charts: edge-to-edge Rectangle series; negative bars extend below baseline.
  • Stem plots: PathElement vertical lines + Circle tip markers (4 px).
  • Histograms: edge-to-edge Rectangle bins (blue fill).
  • 3D line plots (plot3): LineSeries over (f64, f64, f64) tuples via plotters 3D Cartesian chart (build_cartesian_3d).
  • 3D scatter plots (scatter3): Circle elements at each 3D coordinate.
  • 3D surface plots (surf): colored row + column LineSeries grid on a 3D Cartesian chart; each line colored by local Z mean through the active colormap.
  • 3D wireframe plots (mesh): row-only LineSeries grid (sparser than surf).
  • False-colour images (imagesc / image): one Rectangle per matrix cell, RGB colour from the active colormap LUT; optional 80 px colorbar strip on the right.
  • Grayscale images (imshow(Z)): one gray Rectangle per cell; intensity = clamp(v, 0, 1) × 255 — no min/max scaling.
  • RGB images (imshow(R, G, B)): one Rectangle per 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 — ASCII plot/scatter, annotations
  • examples/plot_file/plot_file.calcplot/scatter to SVG/PNG
  • examples/plot_extended_file/plot_extended.calcbar, 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 with legend+grid, histogram variants
  • examples/plot3_file/plot3_demo.calcplot3/scatter3 ASCII 3D plots
  • examples/plot3_file/plot3_file.calcplot3/scatter3 exported to SVG/PNG
  • examples/colormap/imagesc_demo.calcimagesc with all 8 colormaps + colorbar
  • examples/colormap/mandelbrot.calc — Mandelbrot set rendered with colormap('inferno')
  • examples/colormap/julia.calc — Julia set rendered with colormap('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.calccontour, contourf, and clabel() level labels on Gaussian bell + saddle
  • examples/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 using hold on/off
  • examples/fill_area_polar_demo/fill_area_polar_demo.calcfill, area, polar, style strings
  • examples/quiver_demo/quiver_demo.calc — vector field with Unicode arrow grid
  • examples/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/quiver
  • examples/figure_appearance_demo/figure_appearance_demo.calc — Phase 30.6 figure appearance: theme, bgcolor, fontsize, linewidth, markersize, gridcolor, gridwidth, axis
  • examples/primitives_demo/primitives_demo.calc — Phase 32a: line, patch, rectangle in hold mode and standalone
  • examples/errorbar_demo/errorbar_demo.calc — Phase 32b: symmetric and asymmetric errorbar, hold-mode overlay with plot
  • examples/scatter_color_demo/scatter_color_demo.calc — Phase 32b: per-point color scatter(x,y,sz,c) with viridis/jet colormaps and hold mode
  • examples/pie_demo/pie_demo.calc — Phase 32c: pie chart with labels, explode, and file export
  • examples/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.calc already covers Phase 32e (clabel() calls included)
  • examples/imshow_demo/imshow_demo.calc — Phase 32f: image/imshow grayscale and RGB; includes a 128×128 plasma interference pattern saved as PNG

See also

  • Plugins — how the ccalc-plot plugin is registered
  • Run help plot in 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

TopicWhat you will find
OverviewWorkspace layout, data flow, dependency graph, design principles
Engine Crateccalc-engine public API, Value enum, Env type
ParserTokenizer, recursive-descent grammar, Stmt enum
EvaluatorAST 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

ModuleResponsibility
main.rsParse CLI args, detect stdin mode (REPL / pipe / file / arg), dispatch
repl.rsREPL event loop, pipe line-reader, shared evaluate(), display logic
help.rsStatic help text
env.rsValue enum, Env type (HashMap<String, Value>), workspace save/load
eval.rsExpr AST, Op, Base; evaluator, formatters, built-ins, FnCallHook
parser.rsTokenizer, recursive-descent parser, parse_stmts(), Stmt enum
exec.rsexec_stmts(), exec_script(), Signal, user function dispatch, BODY_CHUNK_CACHE
io.rsIoContext — file descriptor table for fopen/fgetl/fprintf/etc.
vm/mod.rsOpcode, Instr (8-byte fixed-width), Chunk, IterState, CompileError
vm/compile.rscompile(&[StmtEntry]) → Result<Chunk, CompileError>; is_compilable()
vm/exec.rsvm_exec(chunk, env, io, …) — bytecode dispatch loop, arithmetic fast paths
benches/engine.rsCriterion 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-engine has no I/O, no terminal codes, no rustyline. 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:

ModuleRole
vm/mod.rsShared types: Opcode (u8), Instr (8 bytes, compile-time size assert), Chunk, IterState, CompileError
vm/compile.rscompile(&[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.rsvm_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:

ImpactDetail
Slot Vec<Value>5–7× smaller; fits in a single cache line for typical functions
VM operand stackSame reduction; push/pop memcopy 32 B not 168 B
for k = 1:256 iterator256 × 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)

Benchmarkv0.45 (Phase 34b)v0.46 (Phase 35)v0.47 (Phase 36)Overall
loop_10k4.68 ms0.56 ms0.55 ms8.5×
fn_calls_10003.10 ms2.92 ms0.70 ms4.4×
scalar_ops_sum_1M8.05 ms9.40 ms~9.0 mswithin 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-engine directly.
  • Clean boundary — the binary owns all user-facing interaction; the engine has no rustyline, no terminal codes, no println! 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:

LiteralExample
Decimal3.14, 100
Scientific1e5, 2.5e-3, 1E+10
Hexadecimal0xFF, 0X1A
Binary0b1010, 0B11
Octal0o17, 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 acc identifiers
  • 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

VariantSemantics
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

LeftOpRightResult
Scalar+ - * /Matrixelement-wise broadcast
Matrix+ - * / ^Scalarelement-wise broadcast
Matrix+ -Matrixelement-wise (shapes must match)
Matrix* / ^Matrixerror — 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

PhaseFeature areaVersion
RoadmapFuture work and open design questions
1Named variablesv0.1.0
2Multi-argument functionsv0.7.0
3Matrix literalsv0.8.0
4Matrix operations (*, ', .*)v0.9.0
5Range operator (1:5, 0:0.1:1)v0.10.0
6Indexing (A(i,j), v(:))v0.11.0
7Comparison and logical operatorsv0.11.0
7.5Vector utilities, end, special constantsv0.11.0
8Complex numbersv0.12.0
9String data typesv0.13.0
10C-style I/O and formatv0.14.0
10.5File I/O and filesystem queriesv0.14.0
11Core control flowv0.15.0
11.5Extended control flow, run/sourcev0.16.0
12User-defined functionsv0.17.0
12.5Cell arraysv0.17.0
12.6Language polishv0.18.0
13Structsv0.19.0
13.5Struct arraysv0.19.0
13.6Backslash operator and path systemv0.20.0
14Error handlingv0.20.0
15Indexed assignmentv0.21.0
15.6Variable scopingv0.21.0
16Package namespacesv0.21.0
17Statistics and random numbersv0.21.0
18Advanced linear algebrav0.22.0
19REPL toolingv0.23.0
20aJSON encode/decodev0.24.0
20cCSV improvementsv0.24.0
20.5MAT file readv0.25.0
21String completions and regexv0.26.0
22Datetime and durationv0.27.0
23Matrix utilities and set operationsv0.28.0
24Polynomial operations and interpolationv0.29.0
25Dynamic evaluation and timingv0.30.0
26FFT and signal processingv0.31.0
27Complex matricesv0.32.0
28Plugin architecturev0.33.0
29Plot enginev0.34.0
30Colormaps, imagesc, surf, style stringsv0.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

PhaseGoalStatus
1Variables and assignment (x = 5, who, clear, ws/wl)✅ Done
2Multi-argument functions (atan2, mod, max, min)✅ Done
3Matrix literals ([1 2 3], [1; 2; 3])✅ Done
4Matrix operations (A * B, A', A .* B)✅ Done
5Range operator (1:5, 1:2:10, linspace)✅ Done
6Indexing (A(1,1), v(2:4))✅ Done
7Comparison and logical operators (==, ~=, &&)✅ Done
7.5Vector utilities, end indexing, NaN/Inf, sort, find✅ Done
8Complex numbers (3 + 4i, abs(z), angle(z))✅ Done
9String data types ('char array', "string object")✅ Done
10C-style I/O (fprintf('%.2f\n', x), sprintf)✅ Done
10.5File I/O (fopen, dlmread, isfile, save/load with path)✅ Done
11Core control flow (if, for, while, break, continue, +=)✅ Done
11.5Extended control flow (switch, do...until, run/source; try/catch deferred to Phase 14)✅ Done
12User-defined functions, multiple return values, @(x) lambdas✅ Done
12.5Cell arrays, varargin/varargout, cellfun/arrayfun, @funcname✅ Done
12.6Language polish: &/|, ..., single-line blocks, .', **, string utils✅ Done
13Scalar structs (s.field, struct(), fieldnames, isfield, rmfield)✅ Done
13.5Struct 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.6Backslash \ operator + session search path (addpath/rmpath/genpath)✅ Done
14Error handling (error, warning, try/catch, pcall, lasterr)✅ Done
15Indexed assignment (v(i) = x, growing vectors, logical mask write)✅ Done
15.5Compatibility fixes: log natural log, Inf/NaN aliases, autoload, local function scoping✅ Done
15.6Variable scoping: global, persistent, private/ directories✅ Done
16Package namespaces (+pkg/ directories, pkg.func(args) call syntax)✅ Done
17Random numbers and statistics (rand, randn, std, var, median, skewness, kurtosis)✅ Done
18Advanced linear algebra (qr, lu, chol, svd, eig, rank, null, orth, cond, pinv, matrix norm)✅ Done
19REPL tooling: tab completion, inline help <fn>, “did you mean?” hints, assert built-ins✅ Done
20aJSON: jsondecode / jsonencode behind --features json✅ Done
20cCSV: readmatrix, readtable, writetable with headers and RFC 4180 quoting✅ Done
20.5MAT file read: load('file.mat') behind --features mat✅ Done
21String completions and regex (regexp, regexprep, strsplit upgrades)✅ Done
22Datetime & duration types (datetime, duration, tic/toc, arithmetic)✅ Done
23Matrix utilities & set operations (unique, intersect, union, repmat, kron, cross)✅ Done
24Polynomial operations & interpolation (polyval, polyfit, roots, poly, conv, deconv, interp1)✅ Done
25Dynamic evaluation & timing (eval, tic/toc, feval)✅ Done
26FFT & signal processing (fft, ifft, fftshift, ifftshift, fftfreq)✅ Done
27Complex matrices ([1+2i, 3] literals, ComplexMatrix arithmetic, angle, abs, conj)✅ Done
27.5ComplexMatrix gaps: eig, svd, norm, cond, indexed assignment on ComplexMatrix✅ Done
28Plugin architecture: Plugin trait, register_plugin, dynamic dispatch✅ Done
29Plot engine (ASCII + SVG/PNG): plot, scatter, bar, stem, hist, loglog, plot3, scatter3✅ Done
30Plot engine extensions: colormap, imagesc, surf, mesh, contour, contourf, subplot, hold, savefig, style strings, quiver, text✅ Done
30.5Unified color system: ColormapSpec, extended style strings (#RRGGBB, full color names, RGB matrix)✅ Done
30.6Visual style system: theme, bgcolor, fontsize, linewidth, markersize, gridcolor, gridwidth, axis mode✅ Done
31Configurable REPL prompt & syntax highlighting✅ Done
32Plot primitives & statistical charts: line, patch, rectangle, errorbar, scatter color, pie, yyaxis, clabel, image, imshow✅ Done
33Language polish: newline as matrix row separator, s.(fname) dynamic field access, dir, containers.Map, mdBook update✅ Done
34Interpreter performance: bytecode compiler + register VM (vm/), IndexSetOp, Complex power fast path✅ Done
35Interpreter 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_orparse_logical_andparse_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; endis_single_line_block() detects self-contained blocks; REPL/pipe bypass the block buffer for them.
  • 12.6b ... line continuation: cont_buf in REPL and run_pipe; join_line_continuations() pre-pass in parse_stmts; tokenizer drains rest of line.
  • 12.6c &/| element-wise logical: new Token::Amp/Pipe, Op::ElemAnd/ElemOr, and parse_elem_or/parse_elem_and precedence levels between parse_logical_and and parse_comparison.
  • 12.6d xor(a,b) and not(a) built-ins.
  • 12.6e Lambda display: LambdaFn carries 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: 4i imaginary literal now works via tokenizer push_imag_suffix(); split_stmts ' disambiguation extended to recognise . as a transpose indicator (fixing B.'; mis-parse); run_pipe gained cont_buf for ... 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: rustyline is upgraded from DefaultEditor to a typed Editor<CcalcHelper, DefaultHistory> with a custom CcalcHelper that implements the Completer trait. Completion matches any prefix against all variable names in the current Env plus the ~90 built-in names returned by builtin_names(). Hinter, Highlighter, and Validator are no-op stubs (rustyline requires all four traits to be implemented).
  • 19b — Inline help for user functions: Stmt::FunctionDef and Value::Function gain an Option<String> doc field. parse_stmts_from_lines scans backward from the function keyword 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) in eval.rs computes 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 — assert built-ins: Three overloads are added to call_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 in assert_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.
  • ans is the sole implicit variable (Octave/MATLAB convention). The old accumulator (acc) and memory cells (m1m9) 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 + 16

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

CommandAction
whoList all variables and their values
clearDelete all variables (reinitializes ans = 0)
clear xDelete variable x
wsSave workspace to ~/.config/ccalc/workspace.toml
wlLoad 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 by ans.
  • m1m9 (memory cells) — replaced by named variables.
  • c command — replaced by clear.

Octave/MATLAB alignment

  • ans follows Octave/MATLAB convention exactly.
  • pi and e are resolved in the parser (not stored in Env).
  • % 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

FunctionDescription
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

FunctionDescription
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, hypot match Octave/MATLAB exactly.
  • log(x, base) — Octave uses log(x) for the natural log; ccalc keeps log(x) as base-10 (legacy). Use ln(x) for natural log, or log(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 matrix and matrix op scalar — element-wise for +, -, *, /, ^
  • matrix + matrix and matrix - 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

FunctionDescription
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:

TokenInputUsage
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 * &rm
  • Matrix ./ Matrix → element-wise &lm / &rm
  • Matrix .^ Matrix → element-wise Zip::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:

FormMeaning
a:bstart a, stop b, step 1
a:step:bstart 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:

  1. Evaluate start, stop, and step (must all be scalars).
  2. Compute n = floor((stop - start) / step + ε) + 1.
  3. If n ≤ 0, return a 1×0 empty matrix.
  4. Generate values as start + i * step for i = 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 arg
  • A(1:3, :) — range as first arg, colon as second
  • f(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:

ArgsFormResult
1, Colonv(:)All elements as column vector (column-major)
1, scalarv(i)Value::Scalar
1, vectorv(1:3)Value::Matrix 1×N
2, both scalarA(i,j)Value::Scalar
2, otherwiseA(:,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::ColonDimIdx::All
  • Value::Scalar(n) → validates 1 ≤ 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:

LevelOperatorsNotes
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)
8unary - ~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 to 1.0 if inner is 0.0, else 0.0. Works element-wise on matrices.

New Op variants:

VariantOperation
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 to 0.0/1.0
  • cmp_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:

FunctionDescription
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.

FunctionDescription
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:

FunctionDescription
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

FunctionDescription
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 / inf are handled in parse_primary (parser.rs) as named constants → Expr::Number(f64::NAN) / Expr::Number(f64::INFINITY), exactly like pi and e. This allows nan(m,n) to work as a builtin call without the variable shadowing the function.
  • apply_elem, apply_reduction, apply_cumulative, find_nonzero are private helpers in eval.rs that keep the builtin match arms concise.
  • end support: eval_index creates a cloned environment with "end" = dim_size via env_with_end() before calling resolve_dim. No new AST node is required — end is 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:

LeftRightRouting
ComplexComplexcomplex_binop directly
ComplexScalarcomplex_binop(re, im, op, s, 0.0)
ScalarComplexcomplex_binop(s, 0.0, op, re, im)
ComplexMatrixerror

Supported operations:

OpFormula
+(a+c) + (b+d)i
-(a-c) + (b-d)i
* / .*(ac-bd) + (ad+bc)i
/ / ./((ac+bd) + (bc-ad)i) / (c²+d²)
^ / .^ integerbinary exponentiation (exact)
^ / .^ generalpolar 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

OpResult
-zComplex(-re, -im)
~z0 if re≠0 or im≠0, else 1
z'Complex(re, -im) — conjugate transpose

Display

format_complex(re, im, precision) in eval.rs:

ConditionDisplay
im == 0a (same as scalar)
re == 0, im > 0bi (or i when im == 1)
re == 0, im < 0-bi (or -i)
im > 0a + bi
im < 0a - |b|i

The REPL prompt shows the complex value directly (e.g. [ 3 + 4i ]:).

Built-in functions

FunctionDescription
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

FileChange
crates/ccalc-engine/src/env.rsAdded Value::Complex(f64, f64)
crates/ccalc-engine/src/eval.rscomplex_binop, make_complex, format_complex, built-ins, exhaustive match arms
crates/ccalc/src/repl.rsnew_env seeds i/j; all output paths handle Value::Complex
crates/ccalc-engine/src/eval_tests.rs38 new tests covering all operations
examples/complex_numbers.calcNew 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 whitespaceLast token' is
yes (or start of input)anyChar array literal start
noNumber, Ident, RParen, RBracket, Apostrophe, StrTranspose (Token::Apostrophe)
noanything elseChar 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:

SequenceResult
""Literal "
\nNewline
\tTab
\\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::StrExpr::StrLiteral and Token::StringObjExpr::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 lengthResult
0Value::Matrix 1×0
1Value::Scalar(code)
NValue::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' + 1str_to_numeric("abc") = Matrix([97,98,99])[98, 99, 100], and 'a' + 0Scalar(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

FunctionArityDescription
num2str1Number → char array
num2str2Number → char array, N decimal digits
str2num1Char array → number, error on failure
str2double1Char array → number, NaN on failure
strcat≥2Concatenate strings
strcmp2Case-sensitive equality → 0/1
strcmpi2Case-insensitive equality → 0/1
lower1Lowercase
upper1Uppercase
strtrim1Strip whitespace
strrep3Find-and-replace
sprintf1Process escape sequences, return Str
ischar11 if Str, else 0
isstring11 if StringObj, else 0

length, numel, and size were extended to handle both new variants:

  • length(Str(s)) → number of characters in s
  • length(StringObj(_)) → 1 (scalar element)
  • numel and size follow 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_value returns the string content as-is for both variants.
  • format_value_full returns None (strings are displayed inline like scalars).
  • The REPL prompt shows the first 15 characters with surrounding quotes when ans is a string.
  • who annotates type: name [1×N char] for Str, name [string] for StringObj.

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).
  • strsplit requires cell arrays (not yet implemented) and is deferred.
  • The split_stmts() function in repl.rs already 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

FileChange
crates/ccalc-engine/src/eval.rsAdded MatKind::Str arm to matrix literal evaluator; extended MatKind::Numeric to handle Str/StringObj elements
crates/ccalc-engine/src/parser.rstokenize(): added prev_was_ws flag; ' after whitespace always starts a string
crates/ccalc-engine/src/eval_tests.rsmod char_array_literal_tests — 11 new tests
docs/src/guide/strings.mdNew section “Char-array concatenation with [...]
crates/ccalc/src/help.rsAdded [...] 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:

SpecifierMeaning
%d, %idecimal integer (value truncated to i64)
%ffixed-point decimal, default 6 places
%escientific notation (1.23e+04)
%gshorter of %f and %e
%sstring (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:

ModeDescription
short5 significant digits, auto fixed/scientific (default)
long15 significant digits
shortEalways scientific, 4 decimal places
longEalways scientific, 14 decimal places
shortGalias for short
longGalias for long
bankfixed 2 decimal places
ratrational approximation via continued fractions
hexIEEE 754 double bit pattern (16 uppercase hex digits)
+sign only: +, -, or space
compactsuppress blank lines between outputs
looseadd blank line after every output
NN 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 new test_format_* tests covering all modes.
  • repl_tests.rs: updated harness (FormatMode::default() replaces precision: 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.

FunctionDescription
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.

FunctionDescription
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

FunctionDescription
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.

SyntaxDescription
saveSave 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
loadLoad 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_void
  • repl_tests.rs: 9 new Phase 10.5d tests including test_pipe_save_load_roundtrip and test_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.rseval.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:

TokenSource
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.

InputDesugared to
x += ex = x + e
x -= ex = x - e
x *= ex = x * e
x /= ex = x / e
x++x = x + 1
x--x = x - 1
++xx = x + 1
--xx = 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)

AliasEquivalent
#% (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 valueCase valueMatch
ScalarScalarexact ==
Str or StringObjStr or StringObjstring equality (Str and StringObj interchangeable)
Any other combinationno 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:

  1. <name>.calc — native ccalc script format (preferred)
  2. <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 withDelta
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, and ans are pre-seeded.
  • nargin and nargout are injected.
  • All Function and Lambda values 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

ConcernSolution
Circular dependency (eval.rsparser.rs)Named functions store body_source: String; re-parsed on each call in exec.rs
Cross-module dispatchThread-local FnCallHook in eval.rs, registered by exec::init()
Lexical closureLambda captures Env clone at @ parse time; stored as Value::Lambda(Rc<dyn Fn>)
Recursioncall_user_function copies all Function/Lambda entries from caller’s env into local scope
Multi-returnValue::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

NameKindDescription
Stmt::FunctionDefStatementfunction [outs] = name(params) body end
Stmt::ReturnStatementreturn inside a function
Stmt::MultiAssignStatement[a, b] = expr destructuring
Expr::LambdaExpression@(params) expr
Token::AtToken@ prefix for lambdas
Signal::ReturnSignalpropagates early return through exec_stmts

New Value variants

VariantDescription
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 ({) and Token::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-separated parse_logical_or elements → Expr::CellLiteral.
  • After parsing an identifier, if next token is LBraceExpr::CellIndex.
  • c{i} = v detected in parse() lookahead via try_split_cell_assign(), produces Stmt::CellSet.
  • split_stmts() now tracks brace_depth alongside bracket_depth so ; inside {...} is not treated as a statement separator.

Evaluator changes (eval.rs)

  • Expr::CellLiteralValue::Cell(vals)
  • Expr::CellIndex → bounds-check (1-based), returns element value
  • Expr::FuncHandle(name)Value::Lambda that looks up name in 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 needed
  • is_truthy extended: Value::Cell(v) => !v.is_empty()

Built-ins

FunctionDescription
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 ans when 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, length
  • cellfun, arrayfun
  • switch with case {…}
  • 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 where i is a simple expression — postfix chaining c{k}(args) is not yet supported (use f = 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_buf accumulates partial lines; not dispatched until continuation ends.
  • Pipe/file mode: same cont_buf logic in run_pipe.
  • Block parser: join_line_continuations() pre-pass in parse_stmts joins ...-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)

  • 4i imaginary literal3 + 4i now works in pipe and file mode. The tokenizer’s push_imag_suffix() helper emits * i tokens after any decimal literal followed immediately by i or j.

  • B.'; split incorrectlysplit_stmts now recognises . as a transpose indicator, preventing B.' from being mis-parsed as a string start.

  • ... in pipe moderun_pipe now has the same cont_buf logic as run_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 .m scripts that pass labelled data between functions
  • Phase 14 (try/catch) — the caught exception object e is a struct with fields message and identifier
  • 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

NodeDescription
Expr::FieldGet(Box<Expr>, String)s.x — postfix field read; chained via a loop in parse_primary: s.a.bFieldGet(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:

  1. Remove the root variable from Env (or start with an empty IndexMap if the variable doesn’t exist yet).
  2. Call set_nested(map, path, value) — a recursive, ownership-by-value helper that walks the Vec<String> path, creating intermediate structs where needed.
  3. Re-insert the updated Value::Struct into Env.
  4. Display the struct if not silent.

Built-ins

FunctionBehaviour
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): the scalar 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_index with () → helpful error message
  • is_truthytrue
  • Display arms in test harness → delegates to format_value_full

Tests

19 regression tests in crates/ccalc-engine/src/parser_tests.rs:

TestWhat it checks
test_struct_field_assign_basics.x = 42 stores scalar
test_struct_field_reads.x = 7; ans = s.x returns 7
test_struct_multiple_fieldsThree fields stored correctly
test_struct_field_overwriteRe-assigning a field updates it
test_struct_nested_assigns.a.b = 5 creates nested struct
test_struct_nested_reads.a.b = 10; ans = s.a.b returns 10
test_struct_constructor_basicstruct('x',1,'y',2)
test_struct_constructor_emptystruct() returns empty struct
test_struct_fieldnamesReturns correct Cell of Str
test_struct_isfield_true/falseBoth cases
test_struct_rmfieldField removed, others intact
test_struct_isstruct_true/falseBoth cases
test_struct_field_missing_errorAccess of absent field → error
test_struct_field_on_non_struct_error.field on non-struct → error
test_struct_constructor_odd_args_errorstruct('x') → error
test_struct_rmfield_missing_errorrmfield(s,'z') → error
test_struct_field_insertion_orderIndexMap 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

NodeDescription
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:

  1. Evaluate the index expression; resolve to a 1-based usize.
  2. Remove the root variable from Env — accept StructArray, promote Struct to a 1-element array, or start with an empty Vec.
  3. Auto-grow: push empty IndexMaps until arr.len() >= idx.
  4. Call existing set_nested(elem, path, rhs) on the target element.
  5. 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 ScalarValue::Matrix 1×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:

TestWhat it checks
test_struct_array_create_and_reads(1).x = 1; s(2).x = 3
test_struct_array_numelnumel(s) returns 2
test_struct_array_isstructisstruct(s) returns 1
test_struct_array_field_collection_scalars.x[1 3] matrix
test_struct_array_auto_grows(3).x fills gap
test_struct_array_nested_fields(1).reading.temp = 22.5
test_struct_array_fieldnamesfieldnames on struct array
test_struct_array_isfieldisfield 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 .m scripts that work with collections of labelled records (e.g. measurement series, roster data, inventory ledgers)
  • Phase 14 (try/catch) — the stack field of a caught exception object e is 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

NodeDescription
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:

  1. Evaluate the index expression; convert to 1-based usize.
  2. Remove the root variable from Env:
    • Value::StructArray(v) → use as-is.
    • Value::Struct(m) → promote to vec![m] (1-element array).
    • Missing → start with empty Vec.
  3. Auto-grow: append empty IndexMaps until arr.len() >= idx.
  4. Call existing set_nested(elem, path, rhs) on arr[idx - 1].
  5. Re-insert Value::StructArray(arr) into Env.

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::ScalarValue::Matrix 1×N row vector.
  • Any non-scalar element → Value::Cell.

Built-ins extended for StructArray

Built-inBehaviour 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)

Tests

8 regression tests added in crates/ccalc-engine/src/parser_tests.rs:

TestWhat it checks
test_struct_array_create_and_readBasic s(1).x / s(2).x round-trip
test_struct_array_numelnumel returns element count
test_struct_array_isstructisstruct returns 1
test_struct_array_field_collection_scalars.xMatrix when all scalar
test_struct_array_auto_grows(3).x grows array past current length
test_struct_array_nested_fieldNested path s(1).reading.temp = 22.5
test_struct_array_fieldnamesfieldnames on struct array
test_struct_array_isfieldisfield 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 \ Scalarb / a (error if a == 0)
    • Matrix \ Matrix → Gaussian elimination with partial pivoting (augmented matrix)
    • Scalar \ Matrix → divide each element by the scalar
    • Matrix \ Scalar → solve A * 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:

  1. Current working directory (always first)
  2. 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 in exec.rs
  • session_path_init / session_path_add / session_path_remove / session_path_list — public functions
  • resolve_script_path checks SESSION_PATH entries after the CWD
  • Config path = [...] array: #[serde(default)] field in Config, loaded at startup via session_path_init(cfg.search_path())
  • addpath / rmpath / path() intercepted in exec_stmts (block mode) and try_path_cmd() in repl.rs (pipe / single-line REPL mode)

Files changed

FileChange
ccalc-engine/src/parser.rsToken::Backslash, parse_term case for \, split_stmts ''-escape fix
ccalc-engine/src/eval.rsOp::LDiv, solve_linear(), eval_binop cases
ccalc-engine/src/exec.rsSESSION_PATH, path functions, addpath/rmpath/path() intercept
ccalc/src/config.rspath: Vec<String>, search_path(), expand_tilde()
ccalc/src/repl.rssession_path_init calls, try_path_cmd()
examples/matrix_ops.calcUpdated to demo A\b and multiple RHS
examples/path_system.calcNew 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 formtry(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

FeatureDescription
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 / endAnonymous protected block
try / catch e / endNamed: 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+1 and out-of-range index
  • Logical mask read and write (1-D and 2-D)
  • zeros(n) / ones(n) single-argument form
  • c{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) — adds name to the current frame’s set
  • global_set(name, val) / global_get(name) — read/write the shared store
  • global_frame_push() / global_frame_pop() — manage per-call frames
  • global_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:

  1. collect_dirs_recursive in config.rs skips directories named private so they are never added to the session search path.
  2. resolve_script_path in exec.rs only prepends dir/private/ to the search when dir comes from SCRIPT_DIR_STACK (the calling script’s own directory), never from SESSION_PATH or 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:

  1. Struct field call — if segments[0] is in the environment, the segment chain is followed as field accesses (FieldGet semantics) and the resulting value is called with the evaluated arguments. Supports Lambda and Function field values.

  2. Package call — if segments[0] is not in the environment, the qualified name ("utils.clamp") is looked up in AUTOLOAD_CACHE. On a cache miss, try_autoload is called with the qualified name, which delegates to the new try_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:

  1. SCRIPT_DIR_STACK entries (calling script’s directory)
  2. CWD (.)
  3. SESSION_PATH entries

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.clamp and utils.lerp from +utils/
  • geom.circle_area and geom.rect_area from +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 a Vec<f64> from Scalar/Matrix, error on Complex/Str
  • apply_stat(v, f, fname) — column-wise reduction helper (same shape rules as apply_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.

FunctionDescription
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.

CallResult
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 function keyword 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:

CallBehaviour
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

ContextHas 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:

JSONccalc
objectStruct (fields in insertion order via IndexMap)
all-numbers array (+ nulls)Matrix 1×N row vector (nullNaN)
mixed arrayCell
stringStr
numberScalar
true / falseScalar(1.0 / 0.0)
nullScalar(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:

ccalcJSON
Structobject
Matrix 1×Nflat array
Matrix M×Narray of row arrays
Cellarray
StructArrayarray of objects
Scalar(NaN)null
Scalar(finite)number
Str / StringObjstring

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) -> Value
    • pub(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 arrays
    • fn encode_f64(x: f64) -> Result<serde_json::Value, String> — handles NaN → null, Inf → error
  • crates/ccalc-engine/src/eval.rs — changes:

    • jsondecode and jsonencode added to builtin_names() (unconditional — always in tab completion)
    • ("jsondecode", 1) and ("jsonencode", 1) match arms in call_builtin dispatch to jsondecode_impl / jsonencode_impl
    • jsondecode_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.tomljson = ["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 from dlmread’s 0.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:

Conditionccalc value
All cells parseable as f64 (empty → NaN)Matrix N×1 column vector
Any non-numeric cellCell 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 \n are 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

FunctionPurpose
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; Nonesplit_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 typeccalc Value
double (1×1 scalar)Scalar
double (M×N matrix)Matrix (column-major → row-major conversion)
char arrayStr
struct (scalar)Struct
struct array (1 element)Struct (unwrapped)
struct array (N elements)StructArray
cell arrayCell
[] / nullScalar(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 over matrw::load_matfile() entries and builds a Value::Struct.
    • fn mat_var_to_value(var: &MatVariable) -> Result<Value, String> — recursive converter: NumericArrayScalar/Matrix/Str, StructureStruct, StructureArrayStruct/StructArray, CellArrayCell, NullScalar(NaN).
    • Column-major conversion: Array2::from_shape_vec((cols, rows), data).t().to_owned().
  • crates/ccalc-engine/src/eval.rs — changes:

    • "load" added to builtin_names() (unconditional).
    • ("load", 1) match arm in call_builtin dispatches to load_mat_file().
    • pub fn load_mat_file(path) with dual #[cfg]/#[cfg(not)] stubs, so repl.rs can call it without importing the mat module.
  • crates/ccalc-engine/src/lib.rs#[cfg(feature = "mat")] pub(crate) mod mat;

  • crates/ccalc/Cargo.tomlmat = ["ccalc-engine/mat"] feature pass-through.

  • crates/ccalc/src/repl.rs.mat extension check in both REPL and pipe SaveLoadCmd::Load handlers: injects each field of the returned Struct into the workspace. SaveLoadCmd::Save with .mat path 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_builtin with no feature gate.
  • 21b dispatches through regexp_impl / regexprep_impl helpers gated with #[cfg(feature = "regex")] / #[cfg(not(feature = "regex"))].
  • The regex crate (NFA engine) is an optional dependency; no risk of catastrophic backtracking.
  • regexp byte-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

VariantStorageNotes
DateTime(f64)Unix timestamp (seconds)NaN = NaT
Duration(f64)Seconds (fractional)
DateTimeArray(Vec<f64>)Flat timestamp vecDisplay as N×1
DurationArray(Vec<f64>)Flat seconds vecDisplay 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_primaryExpr::NaTValue::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 produce DateTimeArray / 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 %sDateTime formats as "yyyy-MM-dd HH:mm:ss"; Duration formats as "HH:MM:SS".
  • isnat on non-datetime — now returns 0 instead 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

FunctionDescription
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

FunctionDescription
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).

FunctionDescription
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 xv; element-wise for vector x

23d — Index utilities and element repetition

FunctionDescription
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.

FunctionSignatureNotes
polyvalpolyval(p, x)Horner evaluation
polyfitpolyfit(x, y, n)Vandermonde + QR solve
rootsroots(p)Durand–Kerner iteration
polypoly(r) / poly(A)Expand from roots / char. poly
convconv(a, b)O(mn) convolution
deconvdeconv(c, b)Polynomial long division
interp1interp1(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/n turns 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::Cell of Value::Scalar/Value::Complex elements.

poly(r) — Monic polynomial from roots or characteristic polynomial

  • Vector argument: iteratively convolves [1.0] with [1.0, -r_i] for each root. Requires poly_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:

MethodImplementation
lineary[lo] + t*(y[lo+1]-y[lo]) where t=(xi-x[lo])/(x[lo+1]-x[lo])
nearestSnap to x[lo] or x[lo+1], tie goes left
previousy[lo] (left step / ZOH)
nexty[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 in exec_stmts inside exec.rs, just after the run/source intercept. Uses the same RUN_DEPTH thread-local (max 64) to prevent infinite recursion. Calls parse_stmtsexec_stmts with the caller’s env and io, so all mutations persist.

  • Expression context (y = eval('...')): falls through to call_builtin in eval.rs. Uses EVAL_STR_HOOK (registered by exec::init()) which clones env, runs exec_stmts against the clone, and returns ans. Variable mutations inside the string are discarded — only ans is returned.

  • EVAL_STR_HOOK follows the same hook pattern as FN_CALL_HOOK to avoid a circular dependency between eval.rs and exec.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

FileChange
crates/ccalc-engine/src/eval.rsEVAL_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.rseval_str_impl; set_eval_str_hook registered in init(); eval intercept in Stmt::Expr
crates/ccalc-engine/src/eval_tests.rsmod phase25_tests — 11 tests
crates/ccalc/src/help.rsprint_eval() topic
docs/src/guide/eval.mdUser guide page
docs/src/SUMMARY.mdAdded 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: fft and ifft are gated behind the fft Cargo feature (pulls in rustfft). Build with:

cargo build --release --features fft

fftshift, ifftshift, and fftfreq are 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

FileChange
crates/ccalc-engine/src/eval.rsfft, 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.rsFFT regression tests
crates/ccalc/src/help.rsprint_fft() topic
docs/src/guide/fft.mdUser guide page
docs/src/SUMMARY.mdAdded entry
examples/fft_demo.calcFull 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:

LeftRightOperation
ComplexMatrixComplexMatrixelement-wise or matrix multiply
ComplexMatrixMatrixauto-promote right to complex
MatrixComplexMatrixauto-promote left to complex
ComplexMatrixScalar / Complexscalar broadcast
Scalar / ComplexComplexMatrixscalar 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:

FunctionReturns
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

FileChange
crates/ccalc-engine/src/env.rsValue::ComplexMatrix(Array2<Complex<f64>>) variant
crates/ccalc-engine/src/eval.rsLiteral 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.rsis_truthy and print_value for ComplexMatrix
crates/ccalc/src/repl.rsPrompt display, who, handle_disp for ComplexMatrix
crates/ccalc/src/repl_tests.rsPattern updates for ComplexMatrix
crates/ccalc-engine/src/eval_tests.rsUpdated fft_of helper; 16 tests in mod phase27_tests
crates/ccalc/src/help.rsprint_complex() — removed limitations note; added matrix section
docs/src/guide/complex.mdAdded “Complex Matrices” section; removed outdated “Limitations”
docs/src/guide/fft.mdUpdated Cell → ComplexMatrix; X{k}X(k); abs(S) example
docs/src/SUMMARY.mdAdded Phase 26 and 27 entries
examples/complex_matrix.mFull Phase 27 demo script (Octave-compatible)
examples/fft_demo.calcUpdated 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

  • Plugin trait (ccalc-engine::plugin) — implement name(), optionally exported_names(), and call().
  • PluginRegistry — maps exported names to plugin implementations; checked before the built-in table so plugins can shadow any built-in.
  • register_plugin(p) — registers a Box<dyn Plugin> in the thread-local registry.
  • ccalc-plot crate — stub plugin that registers plot, scatter, bar, stem, xlabel, ylabel, and title. 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 PluginRegistry produces 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 PlotPlugin with 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 via textplots 0.8 Braille canvas; requires the plot Cargo 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::call signaturename: &str parameter added as the first argument so a single plugin instance can dispatch multiple exported names. Existing plugin implementations must add _name: &str as their first parameter.
  • ccalc-plot feature flagsplot (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 the plot feature 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 the plot-svg feature.
  • 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 formplot(y, 'f.svg') infers x = 1:numel(y), matching the terminal behaviour.
  • Annotations in file outputtitle, xlabel, ylabel are 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.3 additional features requiredline_series (for LineSeries) and ttf (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.
  • scatter produces equivalent files.
  • title/xlabel/ylabel text appears verbatim in the SVG output.
  • FigureState is 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 the plot feature.
  • imagesc(Z, 'file.svg') / imagesc(Z, 'file.png') — saves a full-colour heat-map to a file. One Rectangle per matrix cell, RGB colour from the active colormap LUT. Canvas size from figure(w, h) (default 800 × 600 px). Requires plot-svg.
  • colormap('name') — sets the active colormap, consumed by the next imagesc call. Validates against the list of supported names; returns an error for unknown names.
  • colorbar() — sets a flag that tells the next file-export imagesc call 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:

NameDescription
viridisPerceptually uniform, blue → green → yellow (default)
infernoBlack → purple → orange → white
magmaBlack → purple → pink → white
plasmaBlue-purple → orange → yellow
hotBlack → red → yellow → white
coolCyan → magenta
jetClassic MATLAB: blue → cyan → green → yellow → red
grayBlack → white (monochrome)

Accepted argument forms:

CallCanvasFeature
imagesc(Z)— (ASCII)plot
imagesc(Z, path)from figure(w,h), else 800 × 600 pxplot-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")]):

  1. Find z_min / z_max over all cells.
  2. Map each cell to one of 10 density characters: " .:-=+*#@█".
  3. Print title (if set), then the character grid row by row.
  4. colormap and colorbar annotations are silently ignored.

File tier

render_imagesc_file in colormap.rs (gated #[cfg(feature = "plot-svg")]):

  1. If colorbar is set, call root.split_horizontally(w - CB_WIDTH) to produce (main_area, colorbar_area). Otherwise use the full canvas.
  2. Call draw_imagesc_cells on main_area:
    • Scale each cell value to [0.0, 1.0].
    • Map through apply_colormap(t, name)(u8, u8, u8).
    • Draw one Rectangle per cell; MATLAB row-order (row 0 = top-left) is preserved by mapping row r to y-range [(nrows-1-r), (nrows-r)].
  3. If colorbar is set, call draw_colorbar on colorbar_area:
    • Draw 200 thin horizontal rectangles from bottom (z_min) to top (z_max).
    • Add a right y-axis with 5 tick labels.

Implementation

Source fileRole
crates/ccalc-plot/src/colormap.rsLUT data, apply_colormap, ASCII + file render
crates/ccalc-plot/src/dispatch.rsextract_matrix helper (returns flat Vec<f64> + dims)
crates/ccalc-plot/src/lib.rsFigureState 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") sets FigureState.colormap.
  • colormap("unknown") returns an error naming the valid options.
  • colorbar() sets FigureState.colorbar = true.
  • imagesc with a non-matrix argument returns an error.
  • imagesc with no arguments returns an error.
  • imagesc returns Void (no feature builds).
  • ASCII imagesc completes without error (with plot feature).
  • ASCII imagesc with 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.
  • imagesc with colorbar() + colormap("jet") → SVG file created.

Example scripts

  • examples/colormap/imagesc_demo.calc — gradient matrix + all 8 colormaps + colorbar
  • examples/colormap/mandelbrot.calc — Mandelbrot escape-count map with colormap('inferno')
  • examples/colormap/julia.calc — Julia set with colormap('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:

CallReturns
[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:

CallOutput
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")]):

  1. Compute the maximum Z over each column (col_max).
  2. Print a character grid of height 20: row k prints # for columns where col_max[c] ≥ z_min + z_range * (k / 20).
  3. Print x-axis tick labels (first and last x value).
  4. Print xlabel / ylabel / zlabel footer 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 dimplotters roleour value
First (X)horizontal left–rightx_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 fileRole
crates/ccalc-engine/src/eval.rsmeshgrid cases in call_builtin; entry in builtin_names()
crates/ccalc-plot/src/surface.rsASCII + SVG/PNG renderers for surf and mesh
crates/ccalc-plot/src/lib.rssurf/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 bell
  • examples/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

CallOutput
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:

  1. Build a ChartBuilder with the actual data coordinate range (x_lo..x_hi, y_lo..y_hi).
  2. If filled: draw one Rectangle per grid cell, colored by the cell’s mean Z mapped through the active colormap. Band index = count of levels ≤ z_mean; normalised t = band / n_levels.
  3. Draw one LineSeries per 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 fileRole
crates/ccalc-plot/src/contour.rscompute_levels, marching_squares, ASCII + file renderers
crates/ccalc-plot/src/lib.rscontour/contourf in EXPORTED; dispatch to render_contour
crates/ccalc-engine/src/parser.rsPrecedence fix: parse_term → parse_unary → parse_power → parse_primary
crates/ccalc-engine/src/parser_tests.rsRegression 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:

InputExampleResolves to
Single letter'r'StyleColor(255,0,0)
Full name'orange'StyleColor(255,165,0)
gray/greyboth spellingsStyleColor(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 file
  • render_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

PlaceholderExpands 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 to readline() for correct cursor math.
  • colored is stored in CcalcHelper.colored_prompt and returned by highlight_prompt() so the terminal renders the colours.

Implementation files:

  • crates/ccalc/src/repl.rsrender_prompt(), parse_rgb_placeholder(), CcalcHelper.colored_prompt, update_prompt(), highlight_prompt().
  • crates/ccalc/src/config.rsReplConfig { prompt1, prompt2 }, [repl] section in DEFAULT_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

CategoryDefault colourTokens
Keywordsyellowif for while end function else elseif return break continue do until switch case otherwise try catch global persistent
Numberscyan42, 3.14, 1e-3, 0xFF
Stringsgreen'hello', "world"
Commentsdark gray% comment, # comment
Built-insbright cyansin, plot, zeros, reshape, …
ErrorsredUnclosed ', ", [, (
User variables / operatorsdefaulteverything 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

FormatExampleNotes
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