ccalc
A fast terminal calculator with Octave/MATLAB syntax and script support — one binary, no runtime.
Octave is hundreds of megabytes. Python requires a runtime. ccalc is a single self-contained binary that starts instantly and works anywhere: interactive sessions, shell scripts, CI pipelines, Docker containers.
Quick start
# Interactive REPL
ccalc
# Single expression
ccalc "2 ^ 32"
# Script file
ccalc script.m
# Pipe mode
echo "sqrt(2)" | ccalc
Who is it for?
| User | Typical use |
|---|---|
| Embedded / systems engineer | Arithmetic, hex/bin conversions, bit masks |
| DevOps / SRE | Quick calculations in scripts and pipelines |
| Scientist / student | Interactive session with variables and math functions |
| MATLAB / Octave user | Familiar syntax, no heavy installation |
Project structure
| Crate | Role |
|---|---|
crates/ccalc | CLI binary: argument parsing, REPL, pipe mode |
crates/ccalc-engine | Library: tokenizer, parser, AST evaluator, variable environment |
The engine crate is the computation foundation. It has no I/O dependencies and is the target for all Octave/MATLAB compatibility work (Phases 1–10).
Compatibility standard
Where MATLAB and Octave differ, ccalc follows the modern MATLAB standard (R2016b+). See Architecture → Overview for design principles.
Source
- Repository: https://github.com/holgertkey/ccalc
- Changelog: see
CHANGELOG.mdin the repository root
Getting Started
Installation
Build from source (requires Rust):
git clone https://github.com/holgertkey/ccalc
cd ccalc
cargo build --release
# binary: target/release/ccalc
Usage modes
Interactive REPL
ccalc
A prompt shows the current value of ans. Type an expression and press Enter.
Type help for a reference, or help <topic> for a specific section.
[ 0 ]: 2 ^ 32
[ 4294967296 ]: / 1024
[ 4194304 ]: sqrt()
[ 2048 ]: help functions
...
[ 2048 ]: exit
Single expression (argument mode)
ccalc "EXPR"
Evaluates the expression, prints the result, and exits. Useful for shell scripts.
$ ccalc "2 ^ 32"
4294967296
$ ccalc "sqrt(2)"
1.4142135624
Script file
Pass a .m or .ccalc file as an argument:
ccalc script.m
ccalc examples/mortgage.ccalc
Pipe / non-interactive mode
When stdin is not a terminal, ccalc reads lines one by one and prints one result
per line. ans carries over across lines.
$ echo "sin(pi / 6)" | ccalc
0.5
$ printf "100\n/ 4\n+ 5" | ccalc
100
25
30
$ ccalc < formula.txt
Command-line options
| Flag | Description |
|---|---|
-h, --help | Print help and exit |
-v, --version | Print version and exit |
REPL Mode
Start with ccalc (no arguments, stdin is a terminal).
Prompt
The prompt always shows the current value of ans:
[ 0 ]:
[ 42 ]:
[ 0xFF ]:
ans
Every expression result is stored in ans. Expressions that start with
an operator use ans as the left-hand operand (partial expressions):
[ 0 ]: 100
[ 100 ]: * 2
[ 200 ]: + 50
[ 250 ]: / 5
[ 50 ]:
REPL commands
| Command | Action |
|---|---|
exit, quit | Quit |
cls | Clear the screen (also Ctrl+L) |
help, ? | Show cheatsheet |
help <topic> | Detailed help (see topic list below) |
who | Show all defined variables |
clear | Clear all variables |
clear <name> | Clear a single variable |
p | Show current decimal precision |
p<N> | Set precision to N decimal places (0–15) |
hex / dec / bin / oct | Switch display base |
base | Show ans in all four bases |
ws | Save workspace to file |
wl | Load workspace from file |
disp(expr) | Print value without updating ans |
fprintf('fmt') | Print formatted string (\n, \t, \\ supported) |
config | Show config file path and active settings |
config reload | Re-read config.toml and apply changes |
Help topics for help <topic>:
syntax functions bases vars script matrices examples
Keyboard shortcuts
| Key | Action |
|---|---|
↑ / ↓ | Browse input history |
Ctrl+R | Reverse history search |
← → / Home / End | Cursor movement |
Ctrl+A | Go to beginning of line |
Ctrl+E | Go to end of line |
Ctrl+W | Delete word before cursor |
Ctrl+U | Delete from cursor to beginning of line |
Ctrl+K | Delete from cursor to end of line |
Ctrl+L | Clear screen |
Ctrl+C / Ctrl+D | Quit |
Silencing a line
Append ; to suppress output. For expressions, ans is still updated.
For assignments, ans is never updated regardless of ;.
[ 0 ]: 0.06 / 12; % expression — ans updated, output suppressed
[ 0.005 ]: rate = 0.07; % assignment — silent, ans unchanged
[ 0.005 ]:
Multiple ;-separated statements on one line — all but the last are silent:
[ 0 ]: a = 1; b = 2; c = 3; % all silent
[ 0 ]: a = 1; b = 2 % a = 1 silent, b = 2 shown
b = 2
[ 0 ]:
Configuration
Settings that persist across sessions (precision, default base) 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
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
Comments
% starts a comment (Octave/MATLAB convention):
% full-line comment — line is skipped entirely
10 * 5 % inline comment — expression still evaluates
Semicolon — suppress output
A trailing ; suppresses output. Expressions still update ans;
assignments never update ans regardless of ;.
rate = 0.06 / 12; % silent assignment — ans unchanged
n = 360; % silent assignment — ans unchanged
factor = (1 + rate) ^ n;
Multiple ;-separated statements on one line are also supported:
a = 1; b = 2; c = 3; % all silent
a = 1; b = 2 % a = 1 silent, b = 2 printed
disp(expr) — print value
disp(expr) evaluates the expression and prints the result.
It does not update ans.
disp(ans) % print current ans value
disp(rate * 12) % print expression result
fprintf('fmt') — print formatted text
fprintf('fmt') prints a string with escape sequences (\n, \t, \\).
No newline is added automatically — include \n explicitly.
fprintf('=== Monthly mortgage ===\n')
fprintf('Result: ')
disp(ans)
Output:
=== Monthly mortgage ===
Result: 1199.1010503
Supported commands in pipe mode
All REPL commands except cls (which is ignored):
exit, quit, who, clear, clear <name>, ws, wl,
p, p<N>, hex, dec, bin, oct, base.
Example — mortgage script
% Monthly mortgage payment
% Principal: 200 000, annual rate: 6%, term: 30 years
rate = 0.06 / 12; % monthly interest rate
n = 360; % 30 years * 12 months
p = 200000; % principal
factor = (1 + rate) ^ n;
p * rate * factor / (factor - 1)
fprintf('Monthly payment ($): ')
disp(ans)
Output:
1199.1010503
Monthly payment ($): 1199.1010503
Arithmetic & Operators
Scalar operators
| Operator | Operation | Example |
|---|---|---|
+ | Addition | 3 + 4 → 7 |
- | Subtraction / unary minus | 10 - 4 → 6, -5 |
* | Multiplication | 3 * 7 → 21 |
/ | Division | 10 / 4 → 2.5 |
^ | Exponentiation (right-associative) | 2 ^ 10 → 1024 |
For modulo use the mod(a, b) function. % is a comment character.
Comparison operators
Return 1.0 (true) or 0.0 (false). Work element-wise on matrices.
| Operator | Meaning |
|---|---|
== | Equal |
~= | Not equal |
< | Less than |
> | Greater than |
<= | Less or equal |
>= | Greater or equal |
Logical operators
| Operator | Meaning |
|---|---|
~expr | Logical NOT |
&& | Logical AND |
|| | Logical OR |
See Comparison & Logical Operators for full details.
Precedence (high → low)
- postfix
'— transpose ^,.^— right-associative- unary
-,~— negation, logical NOT *,/,.*,./,.^, implicit multiplication+,-:— range==,~=,<,>,<=,>=— comparison (non-associative)&&— logical AND||— logical OR (lowest)
Use parentheses to override: (2 + 3) * 4 → 20.
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
Matrix operators
When one or both operands are matrices, the same operators apply with element-wise or broadcast semantics:
| Expression | Semantics |
|---|---|
scalar + matrix | Add scalar to every element |
matrix + matrix | Element-wise (shapes must match) |
scalar * matrix | Scale every element |
matrix / scalar | Divide every element |
matrix ^ scalar | Raise every element to the power |
See Matrices for full details.
Functions & Constants
One-argument functions
| Function | Description | Example |
|---|---|---|
sqrt(x) | Square root of x | sqrt(144) → 12 |
abs(x) | Absolute value of x | abs(-7) → 7 |
floor(x) | Largest integer ≤ x (round toward −∞) | floor(2.9) → 2 |
ceil(x) | Smallest integer ≥ x (round toward +∞) | ceil(2.1) → 3 |
round(x) | Nearest integer; ties round away from zero | round(2.5) → 3 |
sign(x) | −1 if x < 0, 0 if x = 0, 1 if x > 0 | sign(-5) → -1 |
log(x) | Base-10 logarithm of x (requires x > 0) | log(1000) → 3 |
ln(x) | Natural logarithm of x, base e (requires x > 0) | ln(e) → 1 |
exp(x) | e raised to the power x | exp(1) → 2.71828… |
sin(x) | Sine of x, where x is in radians | sin(pi/6) → 0.5 |
cos(x) | Cosine of x, where x is in radians | cos(0) → 1 |
tan(x) | Tangent of x, where x is in radians | tan(pi/4) → 1 |
asin(x) | Inverse sine of x ∈ [−1, 1]; result in [−π/2, π/2] | asin(1) * 180/pi → 90 |
acos(x) | Inverse cosine of x ∈ [−1, 1]; result in [0, π] | acos(0) * 180/pi → 90 |
atan(x) | Inverse tangent of x; result in (−π/2, π/2) | atan(1) * 180/pi → 45 |
Notes
sqrt(x) — x must be ≥ 0. Passing a negative value returns NaN (no error is raised).
floor, ceil, round — All three return a floating-point result, not an integer type.round uses round-half-away-from-zero: round(0.5) → 1, round(-0.5) → -1.
Compare: floor(-2.5) → -3, ceil(-2.5) → -2, round(-2.5) → -3.
sign(x) — Returns NaN when x is NaN (sign of NaN is undefined).
log(x), ln(x) — Return NaN for x < 0 and -Inf for x = 0. No error is raised.log is base 10; use ln for the natural 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(1000) → 3
ln(e) → 1
exp(ln(5)) → 5 (round-trip)
sin(pi / 6) → 0.5
cos(pi / 3) → 0.5
tan(pi / 4) → 1
asin(0.5) * 180/pi → 30
acos(0.5) * 180/pi → 60
atan(1) * 180/pi → 45
Two-argument functions
| Function | Description | Example |
|---|---|---|
atan2(y, x) | Four-quadrant inverse tangent; result in (−π, π] | atan2(1,1)*180/pi → 45 |
mod(a, b) | Remainder of a ÷ b; result has the sign of b | mod(370, 360) → 10 |
rem(a, b) | Remainder of a ÷ b; result has the sign of a | rem(-1, 3) → -1 |
max(a, b) | Larger of two scalar values | max(3, 7) → 7 |
min(a, b) | Smaller of two scalar values | min(3, 7) → 3 |
hypot(a, b) | Euclidean distance √(a²+b²), numerically stable | hypot(3, 4) → 5 |
log(x, base) | Logarithm of x to an arbitrary base (both must be > 0) | log(8, 2) → 3 |
Notes
atan2(y, x) — First argument is y (numerator), second is x (denominator).
Returns a value in the range (−π, π], correctly determining the quadrant from the signs of both arguments.atan2(0, -1) * 180/pi → 180, whereas atan(-0/-1) * 180/pi → 0.
mod(a, b) vs rem(a, b) — Both compute the remainder after division, but differ in sign
when the operands have opposite signs:
mod(-1, 3) → 2 (result has the sign of 3, in range [0, 3))
rem(-1, 3) → -1 (result has the sign of -1)
mod guarantees the result is in [0, b) for positive b, making it useful for angle wrapping and modular arithmetic.
rem follows IEEE 754 remainder convention.
b = 0 produces NaN.
max(a, b), min(a, b) — These two-argument forms work with scalars only.
To find the maximum or minimum element of a vector or matrix, use the one-argument form max(v) / min(v) (see Vector & Data Utilities).
hypot(a, b) — Computes √(a²+b²) without intermediate overflow or underflow.
Prefer hypot over sqrt(a^2 + b^2) when the values may be very large or very small.
log(x, base) — Both x and base must be positive; base must not equal 1.
Negative or zero values return NaN or -Inf as with the single-argument form.
atan2(1, 1) * 180/pi → 45
atan2(0, -1) * 180/pi → 180
mod(370, 360) → 10
mod(-1, 3) → 2 (result in [0, 3))
rem(-1, 3) → -1 (same sign as dividend)
max(3, 7) → 7
min(3, 7) → 3
hypot(3, 4) → 5
hypot(5, 12) → 13
log(8, 2) → 3 (log base 2 of 8)
log(100, 10) → 2 (same as log(100))
mod vs rem
Both compute the remainder after division, but differ in sign when the operands have opposite signs:
mod(-1, 3) → 2 (result has the sign of 3)
rem(-1, 3) → -1 (result has the sign of -1)
Use mod when you want a value always in [0, b), e.g. for angle wrapping.
Use rem when you need the IEEE 754 remainder.
Bitwise functions
All bitwise functions require non-negative integer arguments.
They pair naturally with hex (0xFF), binary (0b1010), and octal (0o17)
input literals.
| Function | Description |
|---|---|
bitand(a, b) | Bitwise AND of a and b |
bitor(a, b) | Bitwise OR of a and b |
bitxor(a, b) | Bitwise XOR of a and b |
bitshift(a, n) | Shift a left by n bits (n > 0) or right by ` |
bitnot(a) | Bitwise NOT of a within a 32-bit window |
bitnot(a, bits) | Bitwise NOT of a within an explicit bits-wide window; bits ∈ [1, 53] |
Notes
bitand, bitor, bitxor — Both arguments must be non-negative integers.
Floating-point values are truncated toward zero before the operation.
bitshift(a, n) — Positive n shifts left (multiply by 2ⁿ); negative n shifts right (logical, fills with zeros).
Returns 0 when |n| ≥ 64. The shift count n may be negative; a must be non-negative.
bitnot(a) — Flips all bits within a 32-bit window (Octave uint32 default).
Result is 2³² − 1 − a for values that fit in 32 bits.
bitnot(a, bits) — Flips bits within a window of bits width.
bits must be in [1, 53] (limited to the integer precision of IEEE 754 doubles).
Result is 2^bits − 1 − a.
bitand(0xFF, 0x0F) → 15
bitor(0b1010, 0b0101) → 15
bitxor(0xFF, 0x0F) → 240 (0xF0)
bitshift(1, 8) → 256 (1 << 8)
bitshift(256, -4) → 16 (256 >> 4)
bitnot(5, 8) → 250 (~5 within 8 bits = 0b11111010)
bitnot(0, 32) → 4294967295 (0xFFFFFFFF)
Combining shifts and masks:
bitshift(1, 4) - 1 → 15 (0b00001111 — 4-bit all-ones mask)
bitand(0xDEAD, 0xFF00) → 56832 (0xDE00 — extract high byte)
Empty-argument shorthand
Calling a function with empty parentheses uses ans as the argument:
[ 144 ]: sqrt() → 12 (same as sqrt(144))
[ -7 ]: abs() → 7
[ 0 ]: sin() → 0
Constants
| Name | Value |
|---|---|
pi | 3.14159265358979… |
e | 2.71828182845904… |
nan | IEEE 754 Not-a-Number — propagates through all arithmetic |
inf | Positive infinity; use -inf for negative infinity |
ans | Result of the last expression |
nan — Not a number. Any arithmetic operation involving nan returns nan.
nan == nan evaluates to 0 (IEEE 754: NaN is never equal to itself).
Use isnan(x) to test for NaN (see Vector & Data Utilities).
inf — Positive infinity. -inf is negative infinity.
1 / inf → 0, -inf < inf → 1, inf + inf → inf, inf - inf → nan.
ans — Holds the result of the most recent expression (assignments do not update it).
ans can appear anywhere in an expression.
nan and inf are parser-level constants and cannot be overwritten by assignment.
nan + 5 % → NaN
nan == nan % → 0 (IEEE 754: NaN is never equal to itself)
1 / inf % → 0
-inf < inf % → 1
ans can appear anywhere in an expression:
[ 9 ]: ans * 2 + 1 → 19
[ 9 ]: sqrt(ans) → 3
Nesting
Functions can be nested freely:
sqrt(abs(-16)) → 4
ln(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
log(1000) ^ 2 → 9
hypot(3, 4) * 2 → 10
atan2(1, 1) * 180 / pi → 45
See also: Vector & Data Utilities for sum, prod, mean, norm, sort, find, and related functions.
Number Bases
Input literals
Any numeric literal can be written in hex, binary, or octal:
| Prefix | Base | Example | Value |
|---|---|---|---|
0x / 0X | Hexadecimal | 0xFF | 255 |
0b / 0B | Binary | 0b1010 | 10 |
0o / 0O | Octal | 0o17 | 15 |
Mixed-base expressions work naturally:
0xFF + 0b1010 → 265
0x10 + 0o10 + 0b10 → 26
Display base
By default results are shown in decimal. Switch with a command (session-local):
| Command | Effect |
|---|---|
dec | Decimal (default) |
hex | Hexadecimal |
bin | Binary |
oct | Octal |
The display base persists until changed and affects both the prompt and all results.
To make a non-decimal base the default across all sessions, set it in
config.toml.
[ 0 ]: 255
[ 255 ]: hex
[ 0xFF ]: + 1
[ 0x100 ]: dec
[ 256 ]:
Inline base suffix
Write a base keyword after an expression to evaluate it and switch the display in one step:
[ 0 ]: 0xFF + 0b1010 hex
[ 0x109 ]:
base — show all representations
[ 10 ]: base
2 - 0b1010
8 - 0o12
10 - 10
16 - 0xA
base can also be used as an inline suffix to show one result in all four bases
without changing the active display base:
[ 0 ]: 255 base
2 - 0b11111111
8 - 0o377
10 - 255
16 - 0xFF
[ 255 ]:
Mixed-base display conversion
When the active display base is non-decimal and the input contains literals in other bases, the expression is automatically reprinted with all literals converted to the active base before the result is shown:
[ 0b110 ]: 2 + 0b110 + 0xa
0b10 + 0b110 + 0b1010
[ 0b10010 ]:
Variables
ccalc supports named variables. Any valid identifier can store a value.
Assignment
Use name = expr to assign. Assignments never update ans (MATLAB semantics).
Without ;, the result is displayed:
[ 0 ]: rate = 0.06 / 12
rate = 0.005
[ 0 ]: n = 360
n = 360
[ 0 ]: factor = (1 + rate) ^ n
factor = 10.9357
[ 0 ]: 200000 * rate * factor / (factor - 1)
[ 1199.10 ]:
Append ; to suppress output:
rate = 0.06 / 12;
n = 360;
Using variables
Any defined variable can appear inside an expression:
[ 0 ]: rate = 0.07
rate = 0.07
[ 0 ]: 1000 * (1 + rate) ^ 10
[ 1967.1513573 ]:
ans
ans is the implicit result variable — set automatically after every
standalone expression (not after assignments). It is initialized to 0
at startup.
Expressions starting with an operator use ans as the left-hand operand:
[ 0 ]: 100
[ 100 ]: / 4
[ 25 ]: + 5
[ 30 ]:
Empty-argument function calls use ans as the argument:
[ 144 ]: sqrt() → 12 (same as sqrt(144))
Constants
pi and e are pre-defined read-only constants:
| Name | Value |
|---|---|
pi | 3.14159265358979… |
e | 2.71828182845904… |
View and clear
| Command | Action |
|---|---|
who | Show all defined variables and their values |
clear | Clear all variables |
clear name | Clear a single variable by name |
[ 0 ]: x = 10
[ 0 ]: y = 3.14
[ 0 ]: x + y
[ 13.14 ]: who
ans = 13.14
x = 10
y = 3.14
[ 13.14 ]: clear x
[ 13.14 ]: who
ans = 13.14
y = 3.14
[ 13.14 ]: clear
Workspace persistence
| Command | Action |
|---|---|
ws | Save all variables to ~/.config/ccalc/workspace.toml |
wl | Load variables from file (replaces current workspace) |
The workspace file is plain text, one name = value entry per line:
ans = 13.14
n = 360
rate = 0.005
Example — monthly mortgage
% REPL session
[ 0 ]: rate = 0.06 / 12;
[ 0 ]: n = 360;
[ 0 ]: factor = (1 + rate) ^ n;
[ 0 ]: 200000 * rate * factor / (factor - 1)
[ 1199.10 ]:
As a script file (assignments print unless ; suppresses them):
% Monthly mortgage payment
rate = 0.06 / 12;
n = 360;
factor = (1 + rate) ^ n;
200000 * rate * factor / (factor - 1)
fprintf('Monthly payment ($): ')
disp(ans)
Number Display Format
The format command controls how numbers are displayed in the REPL and
script/pipe output. It does not affect computation — all arithmetic is done in
f64 (IEEE 754 double precision).
Commands
| Command | Description |
|---|---|
format | Reset to short (5 significant digits) |
format short | 5 significant digits, auto fixed/scientific |
format long | 15 significant digits, auto fixed/scientific |
format shortE | Always scientific, 4 decimal places |
format longE | Always scientific, 14 decimal places |
format shortG | Same as short (MATLAB shortG alias) |
format longG | Same as long (MATLAB longG alias) |
format bank | Fixed 2 decimal places (currency) |
format rat | Rational approximation p/q |
format hex | IEEE 754 double-precision bit pattern (16 hex digits) |
format + | Sign only: + positive, - negative, space for 0 |
format compact | Suppress blank lines between outputs |
format loose | Add blank line after every output (default) |
format N | N decimal places (e.g. format 4) |
Examples
>> format short
>> pi
3.1416
>> format long
>> pi
3.14159265358979
>> format shortE
>> pi
3.1416e+00
>> format bank
>> 1/3
0.33
>> format rat
>> pi
355/113
>> format hex
>> 1.0
3FF0000000000000
>> format +
>> [-2 0 5]
- +
>> format 4
>> 1/3
0.3333
Scope
format affects:
disp()output- Variable assignment display (
x = 3.1416) - The REPL prompt value
format does not affect fprintf / sprintf — those functions use
their own per-call format specifiers (e.g. %f, %e, %.3f).
Automatic scientific notation
In short and long modes, numbers switch to scientific notation when:
- The exponent is less than −3 (e.g.
0.001→1e-03) - The exponent is ≥ the number of significant digits
Persistent default
The default precision (used by format N and the startup default) is set in
config.toml:
[display]
precision = 10
Note: format hex vs hex
These are different commands:
format hex— shows the IEEE 754 raw bit pattern of any floating-point number as 16 uppercase hex digits (e.g.400921FB54442D18forpi).hex— switches the display base to hexadecimal for integer values (e.g.0xFF→255shown as0xFF).
Formatted Output
ccalc supports C-style formatted output via fprintf and sprintf, matching
Octave/MATLAB semantics.
fprintf — print to stdout
fprintf(fmt, v1, v2, ...)
Prints formatted text to stdout. No return value — result display is suppressed.
No newline is added automatically; include \n explicitly.
fprintf('pi = %.4f\n', pi) % pi = 3.1416
fprintf('n = %d items\n', 42) % n = 42 items
sprintf — format to string
s = sprintf(fmt, v1, v2, ...)
Same format engine as fprintf, but returns the result as a char array instead
of printing it.
label = sprintf('R = %.1f Ohm', 47.5);
disp(label) % R = 47.5 Ohm
Format specifiers
| Specifier | Meaning |
|---|---|
%d, %i | Integer (value truncated to whole number) |
%f | Fixed-point decimal, default 6 places |
%e | Scientific notation (1.23e+04) |
%g | Shorter of %f and %e |
%s | String (char array or string object) |
%% | Literal % |
Width, precision, and flags
The general form is:
%[flags][width][.precision]specifier
| Flag | Meaning |
|---|---|
- | Left-align within field width |
+ | Always show sign (+ or −) |
0 | Zero-pad to field width |
| Space in place of + for non-negative values |
Examples:
fprintf('%8.3f\n', pi) % 3.142
fprintf('%-10s|\n', 'hi') % hi |
fprintf('%+.4e\n', 1000) % +1.0000e+03
fprintf('%05d\n', 42) % 00042
fprintf('% d\n', 5) % 5
Escape sequences
| Sequence | Character |
|---|---|
\n | Newline |
\t | Tab |
\\ | Backslash |
Multiple arguments and repeat behaviour
When there are more arguments than conversion specifiers in the format string, the format string repeats for the remaining arguments (Octave behaviour):
fprintf('%d\n', 1, 2, 3)
% 1
% 2
% 3
fprintf('x=%.1f y=%.1f\n', 1, 2, 3, 4)
% x=1.0 y=2.0
% x=3.0 y=4.0
Formatted data table example
fprintf('%6s %12s %12s\n', 'time', 'position', 'velocity')
fprintf('%6s %12s %12s\n', '(s)', '(m)', '(m/s)')
fprintf('%s\n', repmat('-', 1, 32))
t = 0:0.5:2;
pos = 0.5 * 9.81 * t.^2;
vel = 9.81 * t;
for k = 1:length(t)
fprintf('%6.1f %12.3f %12.3f\n', t(k), pos(k), vel(k))
end
Output:
time position velocity
(s) (m) (m/s)
--------------------------------
0.0 0.000 0.000
0.5 1.226 4.905
1.0 4.905 9.810
1.5 11.036 14.715
2.0 19.620 19.620
See also
formatcommand — controls default display format fordisp()and assignment outputhelp io— concise in-REPL referencehelp script— full format specifier referenceexamples/formatted_output.calc— runnable example covering all specifiers
Configuration
ccalc stores persistent settings in a plain-text TOML file:
~/.config/ccalc/config.toml # Linux / macOS
%APPDATA%\ccalc\config.toml # Windows
The file is created automatically with defaults the first time you start the interactive REPL. You can edit it with any text editor.
Default config.toml
# ccalc configuration
# Edit this file and run 'config reload' in the REPL to apply changes.
[display]
# Default decimal precision (number of digits after the decimal point, 0–15).
precision = 10
# Default number base for output: "dec", "hex", "bin", "oct"
base = "dec"
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.
REPL commands
| Command | Action |
|---|---|
config | Show config file path and currently active settings |
config reload | Re-read config.toml and apply changes immediately |
Changes made with p<N>, hex, dec, bin, oct during a session are
session-local and are not written back to config.toml.
Example
Set precision to 4 and default base to hex, then apply without restarting:
- Edit
config.toml:[display] precision = 4 base = "hex" - In the REPL:
[ 0 ]: config reload Config reloaded. precision: 4 base: hex [ 0x0 ]:
Config file location
The config command shows the full path of the file on the current system:
[ 0 ]: config
config file: /home/user/.config/ccalc/config.toml
precision: 10
base: dec
Matrices
ccalc supports matrix literals using Octave/MATLAB bracket syntax.
Creating matrices
Separate elements with spaces or commas; separate rows with semicolons:
[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
Elements can be arbitrary expressions:
[sqrt(4), 2^3, mod(10,3)] % [2, 8, 1]
[pi/2, pi; -pi, 0]
Assignment
[ 0 ]: A = [1 2; 3 4]
A =
1 2
3 4
[ [2×2] ]: B = [5 6; 7 8]
B =
5 6
7 8
Assignment does not update ans. The prompt shows the matrix size.
Arithmetic
Scalar operations
All four arithmetic operators apply element-wise between a scalar and a matrix:
2 * A % multiply every element by 2
A / 10 % divide every element by 10
A + 1 % add 1 to every element
A ^ 2 % raise every element to the power 2
Matrix addition and subtraction
+ and - between two matrices of the same size are element-wise:
A + B
A - B
Size must match; otherwise you get an error:
[1 2] + [1 2 3] % Error: Matrix size mismatch for '+'
Matrix multiplication
* between two matrices performs standard matrix multiplication (inner
dimensions must agree):
A = [1 2; 3 4];
B = [1 0; 0 1];
A * B % → same as A (multiply by identity)
v = [1; 2; 3];
v' * v % dot product → 14 (1×3 times 3×1 = 1×1)
v * v' % outer product → 3×3 matrix
Transpose
Postfix ' transposes a matrix. It binds tighter than any binary operator:
A' % transpose of A
[1 2 3]' % row vector → column vector (3×1)
R' * R % for orthogonal R: gives identity
Element-wise operators
.*, ./, .^ apply the operation to each pair of corresponding elements
(shapes must match):
A .* B % element-wise product (Hadamard product)
A ./ B % element-wise division
A .^ 2 % square every element
v .^ 2 % same as v .* v
Note: * is matrix multiplication; .* is element-wise.
Range operator
Generate row vectors with the : operator. Range has lower precedence than
arithmetic, so 1+1:5 evaluates as 2:5.
1:5 % [1 2 3 4 5]
1:2:9 % [1 3 5 7 9] (start:step:stop)
0:0.5:2 % [0 0.5 1 1.5 2]
5:-1:1 % [5 4 3 2 1]
5:1 % [] (empty — step in wrong direction)
Ranges work inside matrix literals — they are concatenated horizontally:
[1:4] % [1 2 3 4]
[0, 1:3, 10] % [0 1 2 3 10]
[1:2:7] % [1 3 5 7]
[1:3; 4:6] % 2×3 matrix: [1 2 3; 4 5 6]
linspace
linspace(a, b, n) generates n evenly spaced values from a to b
(both endpoints included):
linspace(0, 1, 5) % [0 0.25 0.5 0.75 1]
linspace(1, 5, 5) % [1 2 3 4 5]
linspace(0, 1, 1) % [1] (single element returns b)
linspace(0, 1, 0) % [] (empty)
Built-in functions
| Function | Description |
|---|---|
zeros(m, n) | m×n matrix of zeros |
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 (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
Semicolon inside matrix literals
The ; inside [...] is always a row separator, never a statement separator:
A = [1 2; 3 4]; % the ; after ] suppresses output; the ; inside is part of the matrix
Vector & Data Utilities
Special constants
nan and inf are built-in constants — they behave like numeric literals
and cannot be overwritten.
| Constant | Value |
|---|---|
nan | IEEE 754 Not-a-Number |
inf | Positive infinity (-inf for negative) |
nan + 5 % → NaN (NaN propagates through all arithmetic)
nan == nan % → 0 (IEEE 754: NaN is never equal to itself)
inf * 2 % → inf
-inf < inf % → 1
NaN predicates (element-wise)
| Function | Signature | Returns 1 when… |
|---|---|---|
isnan(x) | isnan(x) | x is NaN |
isinf(x) | isinf(x) | x is ±Inf |
isfinite(x) | isfinite(x) | x is neither NaN nor ±Inf |
All three accept a scalar or a matrix and apply element-wise, returning a result of the same shape
with each element replaced by 1.0 (true) or 0.0 (false).
Use these instead of == nan or == inf, which do not work as expected:
nan == nan always returns 0.
isnan(nan) % → 1
isinf(inf) % → 1
isfinite(42) % → 1
isfinite(nan) % → 0
v = [1 nan 3 inf];
isnan(v) % → [0 1 0 0]
isfinite(v) % → [1 0 1 0]
NaN matrix constructor
nan(n) % n×n matrix of NaN
nan(m, n) % m×n matrix of NaN
nan(n) is shorthand for nan(n, n).
The result is a matrix where every element is NaN, useful for pre-allocating
arrays that must be filled before use.
Reductions
For vectors (1×N or N×1) these return a scalar.
For M×N matrices (M>1, N>1) they operate column-wise and return a 1×N row vector.
| Function | Signature | Description |
|---|---|---|
sum(v) | sum(v) | Sum of all elements |
prod(v) | prod(v) | Product of all elements |
mean(v) | mean(v) | Arithmetic mean (sum divided by element count) |
min(v) | min(v) | Minimum element; for 2-scalar form see min(a, b) |
max(v) | max(v) | Maximum element; for 2-scalar form see max(a, b) |
any(v) | any(v) | 1.0 if at least one element is non-zero, else 0.0 |
all(v) | all(v) | 1.0 if every element is non-zero, else 0.0 |
norm(v) | norm(v) | Euclidean (L2) norm: √(Σxᵢ²) |
norm(v, p) | norm(v, p) | General Lp norm; p = inf → max of absolute values |
Notes
min(v), max(v) — The 1-argument form finds the extreme element of a vector or matrix.
The 2-argument forms min(a, b) and max(a, b) compare two scalars.
Both forms are available; which is used depends on the number of arguments.
any(v), all(v) — Treat any non-zero value (including negative numbers) as true,
and zero as false. NaN is non-zero, so any([nan]) → 1 and all([0 nan]) → 0.
norm(v, p) — Common values of p:
p = 1→ L1 norm: sum of absolute valuesp = 2(default) → L2 Euclidean normp = inf→ L∞ norm: maximum absolute value
For scalars, norm(x) returns abs(x).
v = [1 2 3 4 5];
sum(v) % → 15
prod(v) % → 120
mean(v) % → 3
min(v) % → 1
max(v) % → 5
any(v > 4) % → 1
all(v > 0) % → 1
norm(v) % → sqrt(1+4+9+16+25) ≈ 7.416
norm([3 4]) % → 5
norm([1 2 3], 1) % → 6 (L1 = sum of absolute values)
Column-wise on a matrix:
M = [1 2 3; 4 5 6];
sum(M) % → [5 7 9] one sum per column
mean(M) % → [2.5 3.5 4.5]
min(M) % → [1 2 3]
max(M) % → [4 5 6]
Cumulative operations
These return an array of the same shape as the input.
| Function | Signature | Description |
|---|---|---|
cumsum(v) | cumsum(v) | Running sum: element i = sum of first i elements |
cumprod(v) | cumprod(v) | Running product: element i = product of first i |
Both functions accept a scalar (returned unchanged) or a vector/matrix. For a matrix the operation runs along all elements in column-major order, returning a matrix of the same shape.
cumsum([1 2 3 4]) % → [1 3 6 10]
cumprod([1 2 3 4]) % → [1 2 6 24]
% Compound interest: balance after each year
rates = [1.05, 1.08, 1.03, 1.10];
cumprod(rates) % → cumulative growth factors
Sorting and searching
| Function | Signature | Description |
|---|---|---|
sort(v) | sort(v) | Sort elements in ascending order; vectors only |
find(v) | find(v) | 1-based column-major indices of all non-zero elements |
find(v, k) | find(v, k) | First k non-zero indices; k must be non-negative |
unique(v) | unique(v) | Sorted unique elements as a 1×N row vector |
Notes
sort(v) — Sorts in ascending order only. Accepts a scalar (returned unchanged) or a vector.
Passing a 2D matrix (more than one row and more than one column) returns an error;
use sort on individual rows or columns instead.
find(v) — Returns a row vector of 1-based indices of elements that are non-zero (including ±Inf and NaN).
Indices follow column-major order (columns first), matching MATLAB/Octave convention.
Returns an empty matrix [] when no elements match.
find(v, k) — Limits the result to the first k indices. k = 0 returns [].
k must be a non-negative integer.
unique(v) — Returns a 1×N row vector of distinct values, sorted in ascending order.
Accepts scalars, vectors, or matrices (elements are flattened in column-major order before deduplication).
v = [3 1 4 1 5 9 2 6];
sort(v) % → [1 1 2 3 4 5 6 9]
unique(v) % → [1 2 3 4 5 6 9]
find(v > 4) % → [5 6 8] indices where v > 4
find(v > 4, 2) % → [5 6] first 2 such indices
% Typical pattern: use find with a comparison mask
idx = find(v > 3);
v(idx) % → elements of v greater than 3
Reshape and flip
| Function | Signature | Description |
|---|---|---|
reshape(A, m, n) | reshape(A, m, n) | Reshape to m×n using column-major element order |
fliplr(v) | fliplr(A) | Reverse column order (left↔right mirror) |
flipud(v) | flipud(A) | Reverse row order (up↔down mirror) |
Notes
reshape(A, m, n) — Rearranges the elements of A into a matrix with m rows and n columns.
The total number of elements must be preserved: m * n must equal numel(A), otherwise an error is raised.
Elements are read and written in column-major order (column by column), matching MATLAB/Octave.
fliplr(A) — Reverses the order of columns. For a row vector this reverses all elements.
A scalar is returned unchanged.
flipud(A) — Reverses the order of rows. For a column vector this reverses all elements.
A scalar is returned unchanged.
reshape(1:6, 2, 3) % fills column-by-column:
% [1 3 5]
% [2 4 6]
reshape(1:6, 3, 2) % [1 4]
% [2 5]
% [3 6]
fliplr([1 2 3]) % → [3 2 1]
fliplr([1 2 3; 4 5 6]) % → [3 2 1; 6 5 4]
flipud([1 2; 3 4]) % → [3 4; 1 2]
Example file
examples/vector_utils.calc demonstrates all of these features:
ccalc examples/vector_utils.calc
Comparison & Logical Operators
Comparison operators
Comparison operators evaluate to 1 (true) or 0 (false).
They work on scalars and element-wise on matrices.
| Operator | Meaning |
|---|---|
== | Equal |
~= | Not equal |
< | Less than |
> | Greater than |
<= | Less or equal |
>= | Greater or equal |
3 > 2 % 1
3 == 4 % 0
5 ~= 3 % 1
4 <= 4 % 1
Comparison has lower precedence than arithmetic — operands are fully evaluated before the comparison:
1 + 1 == 2 % 1 (1+1 = 2, then 2 == 2)
2 * 3 > 5 % 1 (2*3 = 6, then 6 > 5)
Logical NOT — ~
~expr negates a truth value:
0→1- any non-zero →
0
~0 % 1
~1 % 0
~(3 == 3) % 0
~(3 ~= 3) % 1
Short-circuit AND and OR — &&, ||
&& returns 1 when both operands are non-zero.
|| returns 1 when at least one operand is non-zero.
&& binds more tightly than ||.
Both operators short-circuit and are intended for scalar conditions.
1 && 1 % 1
1 && 0 % 0
0 || 1 % 1
0 || 0 % 0
1 || 0 && 0 % 1 (1 || (0 && 0))
Combining conditions
x = 2.7;
x >= 0 && x <= 3.3 % 1 — in-range check
x < 0 || x > 3.3 % 0 — out-of-range flag
% Negate the condition:
~(x >= 0 && x <= 3.3) % 0 — fault flag (0 = OK)
Element-wise logical operators — &, |, xor, not
& and | are element-wise operators — they work on matrices, always
evaluate both sides (no short-circuit), and return a 0/1 matrix:
a = [1 0 1 0];
b = [1 1 0 0];
a & b % [1 0 0 0] element-wise AND
a | b % [1 1 1 0] element-wise OR
xor(a, b) % [0 1 1 0] element-wise XOR
not(a) % [0 1 0 1] element-wise NOT (alias for ~)
Use &/| for matrix logical masks; use &&/|| for scalar conditions in if.
Logical mask pattern
v = [3, -1, 8, 0, 5, -2, 7];
mask = v > 0 & v < 6 % [1 0 0 0 1 0 0]
Element-wise on matrices (comparison)
When one or both operands are matrices, all comparison operators
apply element-wise and return a 0/1 matrix of the same size:
v = [1 2 3 4 5];
v > 3 % [0 0 0 1 1]
v <= 3 % [1 1 1 0 0]
v == 3 % [0 0 1 0 0]
~(v > 3) % [1 1 1 0 0]
Scalar–matrix comparison broadcasts the scalar to every element:
3 < v % [0 0 0 1 1]
v >= 3 % [0 0 1 1 1]
Soft masking
Because masks are 0/1 matrices, multiplying a matrix by its mask
zeroes out the elements that failed the condition — a pattern often
called soft masking or logical selection:
v = [1 2 3 4 5];
v .* (v > 3) % [0 0 0 4 5] keep elements > 3
% Keep elements in [2, 4]:
lo = v >= 2;
hi = v <= 4;
v .* (lo .* hi) % [0 2 3 4 0]
lo .* hi works as element-wise AND because the values are already 0/1.
Precedence
From lowest to highest priority:
|| logical OR (short-circuit)
&& logical AND (short-circuit)
| element-wise OR
& element-wise AND
== ~= < > <= >= comparison (non-associative)
: range
+ - additive
* / .* ./ multiplicative
^ .^ ** power (right-associative)
unary + - ~ negation / logical NOT
postfix ' .' transpose / plain transpose
REPL session
[ 0 ]: 3 > 2
[ 1 ]:
[ 0 ]: 5 ~= 5
[ 0 ]:
[ 0 ]: 2 > 1 && 10 > 5
[ 1 ]:
[ 0 ]: v = [10 20 30 40 50];
[ 0 ]: v > 25
ans =
0 0 0 1 1
[ [1×5] ]: v .* (v > 25)
ans =
0 0 0 40 50
See also
help logic— REPL reference with examplesccalc examples/logic.calc— ADC validation and resistor tolerance demo- Matrices — element-wise operators
Complex Numbers
ccalc supports complex numbers using the same syntax as Octave/MATLAB.
No special mode is needed — i and j are always available as the
imaginary unit.
Creating complex numbers
3 + 4i % 3 + 4i — Ni suffix (no space before i/j)
3 + 4*i % same — explicit multiply also works
3 + 4*j % j is also the imaginary unit
complex(3, 4) % construct from real and imaginary parts
5i % pure imaginary: 5i
2 - 3i % 2 - 3i
Ni suffix syntax: any decimal number immediately followed by i or j
(no space, no further alphanumeric characters) is treated as a complex literal.
The tokenizer expands 4i to 4 * i — the imaginary unit i must be in
scope (it is always pre-seeded at startup).
Arithmetic
All standard operators work on complex numbers:
z1 = 3 + 4*i
z2 = 1 - 2*i
z1 + z2 % 4 + 2i
z1 - z2 % 2 + 6i
z1 * z2 % 11 - 2i (a+bi)(c+di) = (ac-bd) + (ad+bc)i
z1 / z2 % -1 + 2i
Mixing complex and real scalars works naturally:
z1 + 10 % 13 + 4i
2 * z1 % 6 + 8i
z1 ^ 2 % -7 + 24i
When the imaginary part of a result is exactly zero, the value is shown and stored as a real scalar:
(1+i) * (1-i) % 2 (not 2 + 0i)
Powers
Integer powers use binary exponentiation for exact results:
i^2 % -1 (exact)
i^3 % -i (exact)
i^4 % 1 (exact)
(1+i)^4 % -4
(1+i)^-1 % 0.5 - 0.5i
Non-integer powers use the polar form exp((c+di)·ln(a+bi)):
i^0.5 % 0.7071067812 + 0.7071067812i (sqrt of i)
2^(1+i) % 1.5384778027 + 1.2779225526i
Polar form
Every complex number z = re + im*i has a polar representation
z = r * (cos θ + i * sin θ), where r = abs(z) and θ = angle(z):
z = 3 + 4*i
abs(z) % 5 (modulus |z| = sqrt(3² + 4²))
angle(z) % 0.9272... (argument in radians)
angle(z) * 180/pi % 53.13° (in degrees)
Reconstruct from polar:
r = abs(z);
t = angle(z);
complex(r*cos(t), r*sin(t)) % 3 + 4i
Built-in functions
| Function | Description |
|---|---|
real(z) | Real part (real(5) → 5, real(3+4i) → 3) |
imag(z) | Imaginary part (imag(5) → 0, imag(3+4i) → 4) |
abs(z) | Modulus (also works on real scalars and matrices) |
angle(z) | Argument in radians |
conj(z) | Complex conjugate: re - im*i |
complex(re, im) | Construct from two real scalars |
isreal(z) | 1 if imaginary part is zero, else 0 |
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.
Limitations
Complex matrices ([1+2i, 3+4i]) are not yet supported and return an error.
Use scalar complex variables until matrix complex support is added (a future phase).
Example
% Euler's identity: e^(i*pi) + 1 ≈ 0
e^(i * pi) + 1 % ≈ 0 (tiny floating-point residual from sin(π))
% Roots of x^2 + 1 = 0
x1 = i
x2 = -i
% AC impedance of a series RL circuit
R = 100; L = 0.05; f = 1000;
w = 2 * pi * f;
Z = complex(R, w * L) % 100 + 314.159i
abs(Z) % impedance magnitude
angle(Z) * 180/pi % phase angle in degrees
See examples/complex_numbers.calc for a complete annotated example.
Strings
ccalc supports two string types that match MATLAB/Octave:
| Type | Syntax | Semantic |
|---|---|---|
| Char array | 'single quotes' | 1×N array of characters, numeric-compatible |
| String object | "double quotes" | Scalar string, concatenation with + |
Char arrays
A char array is a 1×N row of character codes. Single quotes delimit it.
Inside a char array, '' (two consecutive single quotes) represents a
literal single quote character.
[ 0 ]: s = 'Hello'
s = Hello
[ 'Hello' ]: length(s)
[ 5 ]: size(s)
ans =
1 5
Arithmetic — characters as ASCII codes
Char arrays convert to their ASCII codes before any arithmetic operation. The result is a numeric scalar or row vector, not a string.
[ 0 ]: 'A' + 0 % ASCII code of 'A'
[ 65 ]:
[ 0 ]: 'a' + 1 % shift by one position
[ 98 ]:
[ 0 ]: 'abc' + 0 % codes for 'a', 'b', 'c'
ans =
97 98 99
[ 0 ]: 'abc' + 1 % shift every character
ans =
98 99 100
Element-wise comparison returns a 0/1 row vector:
[ 0 ]: 'abc' == 'aXc'
ans =
1 0 1
Escaped single quote
Use '' inside a char array to include a literal ':
[ 0 ]: disp('it''s fine')
it's fine
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
Splitting 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
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
Displaying strings
String values display as plain text — no surrounding quotes in the output:
[ 0 ]: 'hello'
hello
[ 0 ]: "world"
world
[ 0 ]: x = strcat('value: ', num2str(42))
x = value: 42
The REPL prompt shows the string content (truncated at 15 characters) when
ans is a string.
who annotates string types:
[ 0 ]: s = 'abc'; t = "hello";
[ 0 ]: who
Variables visible from the current scope:
ans = 0
s [1×3 char]
t [string]
Workspace
ws and wl do not persist string variables — the same policy as
matrices and complex numbers. Only scalars are saved.
Practical example — labelled output
R = 4700;
C = 2.2e-9;
f0 = 1 / (2 * pi * R * C);
fprintf('RC filter\n')
fprintf(' R = ')
disp(strcat(num2str(R), ' Ohm'))
fprintf(' C = ')
disp(strcat(num2str(C * 1e9, 3), ' nF'))
fprintf(' f0 = ')
disp(strcat(num2str(f0, 5), ' Hz'))
Output:
RC filter
R = 4700 Ohm
C = 2.2 nF
f0 = 15392 Hz
See examples/strings.calc for the full demo: ccalc examples/strings.calc
File I/O
ccalc supports file I/O using MATLAB/Octave-compatible functions. You can read and write files using low-level file handles, load and save delimiter-separated data, query the filesystem, and persist workspace variables to named files.
File handles
Open a file with fopen, write or read, then close with fclose:
fd = fopen('log.txt', 'w');
fprintf(fd, 'result: %.4f\n', 3.14159);
fclose(fd);
Supported modes:
| Mode | Description |
|---|---|
'r' | Read (file must exist) |
'w' | Write — create or truncate |
'a' | Append — create if missing |
'r+' | Read and write |
fopen returns a file descriptor (integer ≥ 3) on success, or -1 on failure.
File descriptor 1 is stdout and 2 is stderr — they can be used with fprintf directly.
Reading lines
fd = fopen('data.txt', 'r');
line1 = fgetl(fd); % strip trailing newline; returns -1 at EOF
line2 = fgetl(fd);
raw = fgets(fd); % keep trailing newline
fclose(fd);
Closing all handles
fclose('all'); % close every open file handle
Delimiter-separated data
Write and read numeric matrices as CSV or TSV files:
data = [0, 3.30, 0.012; 0.5, 3.28, 0.015; 1.0, 3.25, 0.018];
dlmwrite('measurements.csv', data); % comma-separated (default)
dlmwrite('measurements.tsv', data, '\t'); % tab-separated
loaded = dlmread('measurements.csv'); % auto-detect delimiter
loaded = dlmread('measurements.tsv', '\t'); % explicit delimiter
dlmread returns a Matrix. All values in the file must be numeric; non-numeric data returns an error with the offending line number.
Auto-detection order: try , first, then \t, then whitespace.
Filesystem queries
Check whether a file or directory exists before opening it:
if isfile('data.csv')
data = dlmread('data.csv');
end
isfolder('output/') % 1 if directory exists, 0 otherwise
cwd = pwd() % current working directory as a char array
exist checks variables or files:
exist('x', 'var') % 1 if variable x is in the workspace, 0 otherwise
exist('log.txt', 'file') % 2 if file found, 0 otherwise
exist('x') % checks workspace first, then filesystem
The numeric codes for exist match MATLAB: 1 = variable, 2 = file.
Workspace with explicit path
Save and load workspace variables to a named file instead of the default path:
R = 4700;
C = 220e-9;
label = 'RC filter';
save('session.mat'); % save all to named file
save('session.mat', 'R', 'C'); % save specific variables only
clear R
clear C
load('session.mat'); % load back
fprintf('R = %g\n', R)
The path argument can be a variable holding the path string:
path = 'session.mat';
save(path);
load(path);
save and load without arguments use the default workspace path ~/.config/ccalc/workspace.toml — the same as ws and wl.
What gets saved
| Type | Persisted |
|---|---|
| Scalar | Yes |
Char array ('text') | Yes |
String object ("text") | Yes |
| Matrix | No |
| Complex | No |
Example
The examples/file_io.calc file demonstrates all File I/O features end-to-end:
ccalc examples/file_io.calc
It covers: filesystem queries, writing to files with fprintf, line-by-line reading with fgetl/fgets, CSV/TSV with dlmread/dlmwrite, append mode, save/load with explicit paths and variable selection, and fopen error handling.
Control Flow
ccalc supports multi-line control flow blocks in both the interactive REPL and
in script/pipe mode. All block constructs use end as the closing keyword.
REPL multi-line input
The REPL detects unclosed blocks and buffers incoming lines, displaying a
continuation prompt >> until the block is complete. Press Ctrl+C to
cancel an incomplete block.
[ 0 ]: for k = 1:3
>> fprintf('%d\n', k)
>> end
1
2
3
if / elseif / else
score = 73;
if score >= 90
grade = 'A';
elseif score >= 80
grade = 'B';
elseif score >= 70
grade = 'C';
elseif score >= 60
grade = 'D';
else
grade = 'F';
end
fprintf('score %d -> grade %s\n', score, grade)
A condition is truthy when:
| Value type | Truthy when |
|---|---|
| Scalar | non-zero and not NaN |
| Matrix | all elements non-zero and not NaN |
| Str/StringObj | non-empty |
| Void | never |
for
for var = range_expr
% body
end
The range expression is evaluated once. Iteration is column-by-column:
- Row vector → each element as a scalar
- M×N matrix → each column as an M×1 column vector
% Simple range
for k = 1:5
fprintf('%d\n', k)
end
% Step range
for k = 10:-2:0
fprintf('%d ', k) % 10 8 6 4 2 0
end
while
while cond
% body
end
x = 1.0;
while abs(x ^ 2 - 2) > 1e-12
x = (x + 2 / x) / 2;
end
fprintf('sqrt(2) ≈ %.15f\n', x)
break and continue
break exits the innermost loop immediately. continue skips to the next
iteration.
for n = 1:20
if mod(n, 2) == 0
continue % skip even numbers
end
if n > 9
break % stop after first odd > 9
end
fprintf('%d ', n) % 1 3 5 7 9
end
Compound assignment operators
| Operator | Equivalent to |
|---|---|
x += e | x = x + e |
x -= e | x = x - e |
x *= e | x = x * e |
x /= e | x = x / e |
x++ | x = x + 1 |
x-- | x = x - 1 |
++x | x = x + 1 |
--x | x = x - 1 |
All forms desugar at parse time to a plain Stmt::Assign — no new AST nodes.
The RHS is a full expression: x *= 2 + 3 desugars to x = x * (2 + 3).
Limitation:
++/--are statement-level only. Using them inside a larger expression (b = a - b--) is not supported.
switch / case / otherwise
switch expr
case val1
% ...
case val2
% ...
otherwise % optional
% ...
end
No fall-through — only the first matching case executes. Works with scalars
(exact ==) and strings (Str/StringObj interchangeable). break/continue
propagate to the nearest enclosing loop.
switch code
case 200
msg = 'OK';
case 404
msg = 'Not Found';
otherwise
msg = 'Unknown';
end
fprintf('%d: %s\n', code, msg)
do…until
Octave post-test loop — body always runs at least once:
do
body
until (cond)
Parentheses around cond are optional. Closed by until, not end.
break and continue work as in while.
x = 1;
do
x *= 2;
until (x > 100)
fprintf('%d\n', x) % 128
run() / source()
Execute a script file in the current workspace. Variables defined in the script
persist in the caller’s scope (MATLAB run semantics — not a function call):
a = 252; b = 105;
run('euclid_helper') % looks for euclid_helper.calc, then .m
fprintf('gcd = %d\n', g) % g was set by the helper
source('euclid_helper') % Octave alias — identical behaviour
Extension resolution for bare names: .calc is tried first (native ccalc
format), then .m (Octave/MATLAB compatibility).
Examples
See the example scripts for self-contained demos:
ccalc examples/control_flow.calc # if/for/while/break/continue
ccalc examples/extended_control_flow.calc # switch/do-until/run/source
User-defined Functions
ccalc supports user-defined named functions, multiple return values, and anonymous functions (lambdas) using Octave/MATLAB syntax.
Named functions
function result = name(p1, p2)
...
result = expr;
end
Define a function at the top level in the REPL or in a .calc / .m script
file. Once defined, the function is stored in the workspace and can be called
like any built-in.
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
Full example
ccalc examples/user_functions.calc
See also: help userfuncs for the
in-REPL reference, and Control Flow for if, for,
while, break, and return.
Cell Arrays
A cell array is a heterogeneous 1-D container: each element can hold any value — scalar, matrix, string, complex number, function handle, or even another cell array.
Creating cell arrays
c = {1, 'hello', [1 2 3]}; % cell literal — comma-separated expressions
d = cell(5); % 1×5 cell pre-filled with zeros
e = cell(2, 4); % 1×8 cell pre-filled with zeros (1-D, m*n slots)
Brace indexing — reading elements
Use c{i} (curly braces, 1-based) to retrieve the content of element i:
c = {42, 'hello', [1 2 3]};
c{1} % → 42 (scalar)
c{2} % → hello (char array)
c{3} % → [1 2 3] (matrix)
Note:
c(i)with round parentheses returns an error — brace indexingc{i}is required to get the element’s value.
Assigning to elements
c{2} = 'world'; % replace existing element
c{5} = pi; % auto-grows: elements 4–5 are zero-filled
numel(c) % 5
Predicates and size
| Function | Description |
|---|---|
iscell(c) | 1 if c is a cell array, else 0 |
numel(c) | Number of elements |
length(c) | Same as numel(c) for 1-D cells |
size(c) | [1 numel(c)] as a 1×2 matrix |
varargin — variadic input
Declare the last parameter as varargin to collect all extra call arguments
into a cell array:
function s = sum_all(varargin)
s = 0;
for k = 1:numel(varargin)
s += varargin{k};
end
end
sum_all(1, 2, 3) % 6
sum_all(10, 20) % 30
sum_all() % 0 (empty varargin cell)
Fixed and variadic parameters can be mixed:
function show(label, varargin)
fprintf('[%s]', label)
for k = 1:numel(varargin)
fprintf(' %g', varargin{k})
end
fprintf('\n')
end
show('A', 1, 2, 3) % [A] 1 2 3
show('B', 100) % [B] 100
varargout — variadic output
Declare the sole output variable as varargout (a cell array) and the caller
receives one output value per cell element:
function varargout = first_n(v, n)
for k = 1:n
varargout{k} = v(k);
end
end
[a, b, c] = first_n([10 20 30 40], 3) % a=10 b=20 c=30
case {v1, v2} — multi-value switch cases
Inside a switch block, a cell array case matches if the switch expression
equals any element of the cell:
switch x
case {1, 2, 3}
disp('small')
case {4, 5, 6}
disp('medium')
otherwise
disp('large')
end
cellfun — apply a function to a cell
cellfun(f, c) applies f to each element of cell c.
Returns a Matrix when all results are scalar; otherwise returns a Cell.
c = {1, 4, 9, 16, 25};
cellfun(@sqrt, c) % [1 2 3 4 5]
cellfun(@(x) x * 2, c) % [2 8 18 32 50]
arrayfun — apply a function to a numeric vector
arrayfun(f, v) applies f to each element of matrix v.
Returns a same-shape matrix (function must return a scalar per element).
arrayfun(@(x) x^2, [1 2 3 4]) % [1 4 9 16]
arrayfun(@(x) x > 2, [1 2 3 4]) % [0 0 1 1]
@funcname — function handles
@funcname creates a callable that forwards its arguments to funcname.
Works with any built-in or user-defined function:
f = @sqrt;
g = @abs;
f(16) % 4
g(-7.5) % 7.5
cellfun(@sqrt, {1, 4, 9}) % [1 2 3]
arrayfun(@abs, [-1 -2 3]) % [1 2 3]
Compose handles via a capturing lambda:
compose = @(f, g) @(x) f(g(x));
sqrt_abs = compose(@sqrt, @abs);
sqrt_abs(-9) % 3 ( sqrt(abs(-9)) )
Function pipelines
Store a sequence of function handles in a cell array and apply them in order:
function y = apply_pipeline(x, pipeline)
y = x;
for k = 1:numel(pipeline)
f = pipeline{k};
y = f(y);
end
end
pipeline = {@(x) x + 1, @(x) x * 2, @sqrt};
apply_pipeline(5, pipeline) % sqrt((5+1)*2) = sqrt(12) ≈ 3.4641
Workspace
Cell arrays are not saved by ws/save — same policy as matrices and
complex values. who shows them as:
c = {1×4 cell}
See also
help cells— in-REPL referencehelp userfuncs— varargin/varargout in the context of user functionsccalc examples/cell_arrays.calc— annotated 9-section example
Structs
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.
Built-in utilities
| Function | Description |
|---|---|
fieldnames(s) | Cell array of field names in insertion order |
isfield(s, 'x') | 1 if field 'x' exists, else 0 |
rmfield(s, 'x') | Copy of s with field 'x' removed; error if absent |
isstruct(v) | 1 if v is a struct, else 0 |
s.a = 1; s.b = 2; s.c = 3;
fn = fieldnames(s) % {'a'; 'b'; 'c'}
fn{1} % a
numel(fn) % 3
isfield(s, 'b') % 1
isfield(s, 'z') % 0
s2 = rmfield(s, 'b');
fieldnames(s2) % {'a'; 'c'}
isstruct(s) % 1
isstruct(42) % 0
Display
s =
struct with 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]
See also
help structs— in-REPL referencehelp cells— cell arrays,varargin/varargoutccalc examples/structs.calc— annotated 9-section example
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 type, workspace save/load
│ ├── eval.rs ← AST + evaluator + formatters + Base enum
│ └── parser.rs ← tokenizer + recursive-descent parser, Stmt enum
└── docs/ ← this mdBook
Data flow
User input (String)
│
▼
parser::parse(input) → Stmt (Assign | Expr)
│ ← recursive-descent parser
│ produces an AST node
▼
eval::eval(&Expr, &Env) → f64 (Value enum from Phase 3)
│
▼
eval::format_value(n, precision, base) → String
│
▼
stdout
Module responsibilities
| Module | Responsibility |
|---|---|
main.rs | Parse CLI args, detect stdin mode (REPL / pipe / file / arg), dispatch |
repl.rs | REPL event loop, pipe line-reader, shared evaluate(), display logic |
help.rs | Static help string |
env.rs | Env type (HashMap<String, f64>), workspace save/load to disk |
eval.rs | Expr AST, Op, Base; eval(), format_value(), format_number() |
parser.rs | Tokenizer, recursive-descent parser, parse(), is_partial(), Stmt enum |
Dependency graph
ccalc (binary)
├── ccalc-engine (local)
│ └── dirs
└── rustyline
Design principles
- One binary, no runtime. The release binary is self-contained. Every new dependency requires explicit justification.
- The library is pure.
ccalc-enginehas no I/O, no terminal codes, norustyline. The binary owns all user-facing interaction. - Modern MATLAB standard (R2016b+). Where MATLAB and Octave differ,
ccalc follows modern MATLAB. Example:
'text'is a char array (numeric),"text"is a string object. - Version is defined once in
[workspace.package]; both crates inherit it. - No runtime allocations on the hot path beyond the input string itself.
Engine Crate (ccalc-engine)
The ccalc-engine crate is a pure computation library with no I/O dependencies
beyond file access for workspace persistence. It exposes three public modules.
Public API
#![allow(unused)]
fn main() {
// Parse an input string into a statement (assignment or expression)
pub fn parser::parse(input: &str) -> Result<Stmt, String>
// Check whether input is a partial expression (starts with an operator)
pub fn parser::is_partial(input: &str) -> bool
// Evaluate an AST node given a variable environment
pub fn eval::eval(expr: &Expr, env: &Env) -> Result<f64, String>
// Note: return type migrates to Result<Value, String> in Phase 3
// Format a number for user-facing display (respects base and precision)
pub fn eval::format_value(n: f64, precision: usize, base: Base) -> String
// Format a number for internal use (always decimal)
pub fn eval::format_number(n: f64) -> String
// Variable environment: maps names to scalar values
// Migrates to HashMap<String, Value> in Phase 3
pub type env::Env = HashMap<String, f64>;
// Save / load workspace to ~/.config/ccalc/workspace.toml
pub fn env::save_workspace_default(env: &Env) -> Result<(), String>
pub fn env::load_workspace_default() -> Result<Env, String>
}
Why a separate crate?
The engine crate provides a stable, testable boundary between computation logic and the CLI. This separation makes it straightforward to:
- Test the parser and evaluator in isolation with 100+ unit tests.
- Extend for Octave/MATLAB compatibility without touching the CLI code.
- Embed the calculator in other tools or a future WASM target.
Extending the engine
All Octave compatibility work (Phases 1–9) will be added to this crate. The binary crate will remain a thin CLI wrapper.
Parser (parser.rs)
The parser converts an input string into an Expr AST through two stages:
tokenization and recursive-descent parsing.
Tokenizer
tokenize(input) produces a Vec<Token>. Token types:
#![allow(unused)]
fn main() {
enum Token {
Number(f64), // decimal, hex (0x), binary (0b), octal (0o), scientific
Ident(String), // function names and constants: sqrt, pi, e, acc, …
Plus, Minus, Star, Slash, Caret, Percent,
LParen, RParen,
}
}
Numeric literals
The tokenizer handles all four bases and scientific notation:
| Literal | Example |
|---|---|
| Decimal | 3.14, 100 |
| Scientific | 1e5, 2.5e-3, 1E+10 |
| Hexadecimal | 0xFF, 0X1A |
| Binary | 0b1010, 0B11 |
| Octal | 0o17, 0O377 |
Scientific notation uses lookahead to avoid treating e (Euler’s number) as
an exponent when it appears as a standalone identifier.
Grammar
expr = term ( ('+' | '-') term )*
term = power ( ('*' | '/' | '%' | implicit_mul) power )*
power = unary ('^' power)? -- right-associative
unary = '-' unary | primary
primary = ident '(' expr? ')' -- function call (empty args → acc)
| '(' expr ')' -- grouping
| number
| ident -- constant or error
Implicit multiplication
parse_term detects an LParen token following a completed expression and
inserts a * without consuming an explicit operator token. This allows
2(3 + 1) and (a)(b).
Percentage (%) disambiguation
% is right-context-sensitive inside parse_term:
- If the next token can start an expression → modulo (
BinOp(Mod)) - Otherwise → postfix percentage (
BinOp(Mul, Number(acc / 100)))
Accumulator in parsing
parse accepts accumulator: f64. This value is:
- Substituted for
accidentifiers - Substituted for empty-argument function calls:
sqrt()→sqrt(acc) - Used for postfix
%:20%→20 * (acc / 100)
Entry points
#![allow(unused)]
fn main() {
// Full parse — returns Err if any tokens remain after the expression
pub fn parse(input: &str, accumulator: f64) -> Result<Expr, String>
// True if input starts with an operator (caller should prepend acc)
pub fn is_partial(input: &str) -> bool
}
Evaluator (eval.rs)
The evaluator walks an Expr AST and produces a Value, given a variable environment.
Value type
#![allow(unused)]
fn main() {
// defined in env.rs
pub enum Value {
Scalar(f64),
Matrix(ndarray::Array2<f64>),
}
pub type Env = HashMap<String, Value>;
}
Scalar is the common case. Matrix was introduced in Phase 3.
AST types
#![allow(unused)]
fn main() {
pub enum Expr {
Number(f64),
Var(String),
UnaryMinus(Box<Expr>),
BinOp(Box<Expr>, Op, Box<Expr>),
Call(String, Vec<Expr>),
Matrix(Vec<Vec<Expr>>), // outer = rows, inner = elements per row
}
pub enum Op {
Add, Sub, Mul, Div, Pow,
}
}
eval(expr, env) semantics
| Variant | Semantics |
|---|---|
Number(n) | Returns Scalar(n) |
Var(name) | Clones value from env; Err if not defined |
UnaryMinus(e) | Negates scalar or every matrix element |
BinOp(l, Div, r) | Err if scalar divisor is 0.0 |
BinOp(l, Pow, r) | Uses f64::powf for scalars; element-wise for matrix ^ scalar |
Call(name, args) | Dispatches to built-in; currently all built-ins require scalar args |
Matrix(rows) | Evaluates each element (must be scalar), builds Array2 |
Scalar × Matrix arithmetic
| Left | Op | Right | Result |
|---|---|---|---|
Scalar | + - * / | Matrix | element-wise broadcast |
Matrix | + - * / ^ | Scalar | element-wise broadcast |
Matrix | + - | Matrix | element-wise (shapes must match) |
Matrix | * / ^ | Matrix | error — matrix multiplication is Phase 4 |
Number display
Three formatters serve different purposes:
format_value(v, precision, base) — compact single-line
For scalars: respects the active display base (decimal, hex, bin, oct).
For matrices: returns [N×M double].
Used in REPL prompts, who output, and assignment echo for scalars.
format_scalar(n, precision, base) — scalar-only
Same as format_value for Scalar values. Used where a scalar is guaranteed
(prompt display, base conversion output).
format_value_full(v, precision) — full multi-line
Returns None for scalars. For matrices returns a right-aligned column string:
1 2 3
4 5 6
Used by the REPL and pipe runner when printing matrix results.
format_number(n) — internal / re-parsing
Always decimal, always round-trips through the parser. Used by repl.rs
when displaying partial-expression context (e.g. expanding variable names).
Base enum
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Base { Dec, Hex, Bin, Oct }
}
Carried in repl.rs as the active display mode, passed to format_scalar
at output time, switched by hex, dec, bin, oct commands.
Octave Compatibility Roadmap
ccalc aims for maximum practical compatibility with Octave/MATLAB .m files.
The work is divided into phases in order of architectural dependency.
Phase summary
| Phase | Goal | Status |
|---|---|---|
| 1 | Variables and assignment (x = 5, who, clear, ws/wl) | ✅ Done |
| 2 | Multi-argument functions (atan2, mod, max, min) | ✅ Done |
| 3 | Matrix literals ([1 2 3], [1; 2; 3]) | ✅ Done |
| 4 | Matrix operations (A * B, A', A .* B) | ✅ Done |
| 5 | Range operator (1:5, 1:2:10, linspace) | ✅ Done |
| 6 | Indexing (A(1,1), v(2:4)) | ✅ Done |
| 7 | Comparison and logical operators (==, ~=, &&) | ✅ Done |
| 7.5 | Vector utilities, end indexing, NaN/Inf, sort, find | ✅ Done |
| 8 | Complex numbers (3 + 4i, abs(z), angle(z)) | ✅ Done |
| 9 | String data types ('char array', "string object") | ✅ Done |
| 10 | C-style I/O (fprintf('%.2f\n', x), sprintf) | ✅ Done |
| 10.5 | File I/O (fopen, dlmread, isfile, save/load with path) | ✅ Done |
| 11 | Core control flow (if, for, while, break, continue, +=) | ✅ Done |
| 11.5 | Extended control flow (switch, do...until, run/source; try/catch deferred to Phase 14) | ✅ Done |
| 12 | User-defined functions, multiple return values, @(x) lambdas | ✅ Done |
| 12.5 | Cell arrays, varargin/varargout, cellfun/arrayfun, @funcname | ✅ Done |
| 12.6 | Language polish: &/|, ..., single-line blocks, .', **, string utils | ✅ Done |
| 13 | Scalar structs (s.field, struct(), fieldnames, isfield, rmfield) | ✅ Done |
Key architectural decisions
Phase 1 introduced Env (HashMap<String, f64>) and Stmt
(assignment vs expression), which are load-bearing for every subsequent phase.
Phase 2 migrated Expr::Call(String, Box<Expr>) to
Expr::Call(String, Vec<Expr>) and introduced call_builtin dispatch.
New functions: atan2, mod, rem, max, min, hypot, log(x,base),
asin, acos, atan, sign. Empty args fn() still passes ans.
Phase 3 adds ndarray and a Value enum (Scalar(f64) | Matrix(...)),
migrating Env from f64 to Value. Matrix literals [1 2; 3 4],
element-wise arithmetic with scalars, and matrix +/- are implemented.
split_stmts() in repl.rs became bracket-depth-aware so ; inside
[...] is parsed as a row separator, not a statement separator.
Phase 4 adds matrix multiplication (A * B via ndarray .dot()),
postfix transpose (A' — new token Apostrophe, new Expr::Transpose),
and element-wise operators .*, ./, .^ (new tokens DotStar,
DotSlash, DotCaret). New built-ins: zeros(m,n), ones(m,n), eye(n),
size(A), size(A,dim), length(A), numel(A), trace(A), det(A)
(Gaussian elimination), inv(A) (Gauss-Jordan). split_stmts() updated to
distinguish transpose ' from string-literal ' by left-context.
call_builtin refactored to return Result<Value, String> directly.
Phase 5 adds Token::Colon and Expr::Range(start, step?, stop). A new
parse_range() layer sits above parse_expr() with lower precedence, so
1+1:5 = 2:5. The Expr::Matrix evaluator is updated to concatenate
row-vector elements horizontally, making [1:5] work. New built-in:
linspace(a, b, n).
Phase 6 adds Expr::Colon and parse_call_arg(). The Expr::Call
evaluator checks Env first: if the name resolves to a variable, the
expression is treated as indexing (variables shadow built-in function names,
matching Octave semantics). eval_index() + resolve_dim() handle 1D
(column-major linear) and 2D indexing, all 1-based. A bug fix also landed
here: range expressions inside grouping parentheses (a:b) now parse
correctly.
Phase 7 adds comparison tokens (==, ~=, <, >, <=, >=) and
logical operators (~, &&, ||). Comparisons return 0.0/1.0 and work
element-wise on matrices. New parse levels parse_logical_or →
parse_logical_and → parse_comparison sit above parse_range in the
precedence hierarchy. Expr::UnaryNot and Op::Eq/NotEq/Lt/Gt/LtEq/GtEq/And/Or
are added to the AST.
Phase 7.5 adds special floating-point constants (nan, inf as
parser-level constants), isnan/isinf/isfinite built-ins, vector
reductions (sum, prod, cumsum, any, all, 1-arg min/max,
mean, norm), the end keyword in indexing contexts (v(end),
A(1:end, 2) — env_with_end() injects the dimension size into a cloned
env before evaluating index expressions), and data utility functions
(sort, reshape, fliplr, flipud, find, unique).
No new Value variants are needed.
Phase 8 adds Value::Complex(f64, f64) as a third Value variant.
No new tokens are required — 4i already parses as implicit multiplication
4 * i, where i and j are pre-seeded in Env as Complex(0.0, 1.0).
complex_binop() handles all arithmetic combinations; integer powers use
binary exponentiation for exact results (i^2 = -1 exactly); non-integer
powers use the polar form exp((c+di)·ln(a+bi)).
make_complex(re, im) collapses to Scalar(re) when im == 0.0 exactly.
z' (conjugate transpose) returns the complex conjugate for scalar complex.
Built-ins added: real, imag, abs (overloaded), angle, conj,
complex, isreal. scalar_arg accepts Complex with im == 0 as a
real scalar. Complex matrices are out of scope and deferred.
Phase 9 adds two string value types. Value::Str(String) represents
single-quoted char arrays; Value::StringObj(String) represents double-quoted
string objects. The ' disambiguation — transpose vs char array literal — is
resolved by one token of left context in the tokenizer: after
Number/Ident/RParen/RBracket/Apostrophe/Str it is a transpose;
otherwise it opens a char array. Char array arithmetic converts characters to
their ASCII codes before the operation, matching MATLAB behaviour.
String objects use + for concatenation and ==/~= for comparison.
New built-ins: num2str, str2num, str2double, strcat, strcmp,
strcmpi, lower, upper, strtrim, strrep, sprintf (1-arg),
ischar, isstring. length/numel/size updated for strings.
Phase 10 adds fprintf(fmt, ...) and sprintf(fmt, ...) using the
string infrastructure from Phase 9. The ad-hoc p/p<N> precision command
is deprecated and removed in this phase. Placed before control flow so that
loop scripts have formatted output from the start.
Phase 10 adds fprintf(fmt, ...) and sprintf(fmt, ...).
The ad-hoc p/p<N> precision command is removed. Phase 10.5 extends this
with file I/O (fopen/fclose/fgetl/fgets), data files (dlmread/dlmwrite),
filesystem queries (isfile, isfolder, pwd, exist), and workspace
persistence with explicit paths (save('f.mat'), load('f.mat')).
Phase 11 adds multi-line REPL buffering and core control flow constructs:
if/elseif/else, for (column iteration), while, break, continue.
Compound assignment operators (+=, -=, *=, /=, ++, --) are
desugared at parse time to plain Stmt::Assign — no new AST nodes.
Syntax aliases: # for % (comment), ! for ~ (NOT), != for ~=.
Extended control flow (switch, do...until, run/source) was completed in Phase 11.5.
Phase 11.5 adds switch/case/otherwise (no fall-through; scalar exact ==,
string equality), do...until (Octave post-test loop; until closes the block
without end), and run()/source() script sourcing (MATLAB run semantics —
scripts execute in the caller’s workspace). Extension resolution for bare names
tries .calc (native ccalc format) before .m (Octave/MATLAB compatibility).
A thread_local! RUN_DEPTH counter caps recursion at 64 levels.
try/catch is deferred to Phase 14 (after structs).
Phase 12 adds user-defined functions with single and multiple return
values ([a, b] = f(x)), and anonymous functions @(x) expr (closures).
Named functions use function [out1, out2] = name(p1, p2) ... end syntax and
are stored as Value::Function { outputs, params, body_source } in Env.
body_source is stored as a string and re-parsed on each call to avoid a
circular dependency between eval.rs and parser.rs.
Anonymous functions are stored as Value::Lambda(Rc<dyn Fn>) — a closure
compiled at definition time that captures the enclosing environment lexically.
A thread-local FnCallHook bridges eval.rs (which dispatches the call) and
exec.rs (which executes the body in an isolated scope).
Each call gets a fresh environment seeded with i, j, ans, the declared
parameters, nargin, and all callable values (Function/Lambda) from the
caller’s workspace — the last point enables self-recursion and mutual recursion
without exposing caller data.
Stmt::FunctionDef, Stmt::Return, Stmt::MultiAssign, and Token::At were
added to the parser; Signal::Return to the executor.
Phase 12.5 adds Value::Cell(Vec<Value>) — a heterogeneous 1-D cell array.
Cell literals {e1, e2}, brace indexing c{i}, and brace assignment c{i} = v
use new Token::LBrace/RBrace and Expr::CellLiteral/CellIndex/Stmt::CellSet.
varargin/varargout collect extra call arguments into a Value::Cell.
case {v1, v2} multi-value switch cases iterate the cell and test each element.
cellfun(f, c) and arrayfun(f, v) apply a function to each element.
@funcname desugars to Expr::FuncHandle(name) — a lambda wrapping any named function.
split_stmts() updated to track brace depth so ; inside {...} is not a separator.
Phase 12.6 delivers language polish across nine sub-items (12.6h deferred):
- 12.6a Single-line blocks:
if cond; body; end—is_single_line_block()detects self-contained blocks; REPL/pipe bypass the block buffer for them. - 12.6b
...line continuation:cont_bufin REPL andrun_pipe;join_line_continuations()pre-pass inparse_stmts; tokenizer drains rest of line. - 12.6c
&/|element-wise logical: newToken::Amp/Pipe,Op::ElemAnd/ElemOr, andparse_elem_or/parse_elem_andprecedence levels betweenparse_logical_andandparse_comparison. - 12.6d
xor(a,b)andnot(a)built-ins. - 12.6e Lambda display:
LambdaFncarries a source string;expr_to_string()helper reconstructs readable source text from the AST at parse time. - 12.6f
strsplit(s[,delim]),int2str(x),mat2str(A)built-ins. - 12.6g
.'plain transpose:Token::DotApostrophe,Expr::PlainTranspose— no complex conjugation (contrast with'which is the Hermitian conjugate transpose). - 12.6j Unary
+(no-op),**alias for^(Octave),,non-silent separator. - Bug fixes:
4iimaginary literal now works via tokenizerpush_imag_suffix();split_stmts'disambiguation extended to recognise.as a transpose indicator (fixingB.';mis-parse);run_pipegainedcont_buffor...continuation.
Phase 13 adds Value::Struct(IndexMap<String, Value>) — scalar structs
with insertion-order-preserving fields (using the indexmap crate).
Token::Dot is emitted only when . is followed by an ASCII letter/underscore,
leaving DotStar/DotSlash/DotCaret/DotApostrophe unaffected.
Expr::FieldGet handles chained reads (s.a.b); Stmt::FieldSet(String, Vec<String>, Expr) handles writes with arbitrary depth paths via the
set_nested() recursive helper in exec.rs.
Built-ins: struct(), fieldnames, isfield, rmfield, isstruct.
19 regression tests added; 488 total.
Compatibility notes
%is a comment character (Octave/MATLAB convention). It terminates tokenization at that point. This is already implemented.ansis the sole implicit variable (Octave/MATLAB convention). The old accumulator (acc) and memory cells (m1–m9) were removed in Phase 1.- 1-based indexing for matrices (Octave/MATLAB convention) — implemented in Phase 6.
- Where MATLAB and Octave differ, ccalc follows the modern MATLAB standard (R2016b+).
- Full toolbox compatibility (Signal Processing, Optimization, etc.) is out of scope.
Phase 1 — Variables and Assignment
Status: ✅ Done (v0.7.0)
Goal: x = 5, then x + 1 → 6
What was implemented
Variable environment (Env)
#![allow(unused)]
fn main() {
pub type Env = HashMap<String, f64>;
}
eval takes &Env. The REPL holds one Env per session, initialized with
ans = 0.0 on startup.
Assignment statement
x = expr is a top-level statement, separate from an expression:
#![allow(unused)]
fn main() {
pub enum Stmt {
Assign(String, Expr), // x = expr
Expr(Expr), // standalone expression → stored in ans
}
}
Variable lookup in expressions
#![allow(unused)]
fn main() {
Expr::Var(String)
}
When the evaluator encounters Var(name), it looks up name in Env.
Unknown names produce an error.
ans — implicit last result
ans is the reserved name for the result of the last standalone expression.
It is initialized to 0.0 on startup and updated after every expression
that is not assigned to a named variable — exactly as in Octave/MATLAB.
Partial expressions (starting with an operator) automatically prepend ans:
[ 100 ]: / 4
[ 25 ]: + 5
[ 30 ]:
Commands
| Command | Action |
|---|---|
who | List all variables and their values |
clear | Delete all variables (reinitializes ans = 0) |
clear x | Delete variable x |
ws | Save workspace to ~/.config/ccalc/workspace.toml |
wl | Load workspace from file |
Workspace persistence
Variables are saved and loaded via env::save_workspace_default() /
load_workspace_default(). The file format is plain name = value lines.
What was removed in this phase
acc(old accumulator) — replaced byans.m1–m9(memory cells) — replaced by named variables.ccommand — replaced byclear.
Octave/MATLAB alignment
ansfollows Octave/MATLAB convention exactly.piandeare resolved in the parser (not stored inEnv).%terminates tokenization — everything after%on a line is a comment.
Phase 2 — Multi-argument Functions
Status: ✅ Done (v0.7.0+011)
Goal: atan2(y, x), mod(a, b), max(a, b), hypot(a, b), …
What was implemented
Expr::Call — variadic arguments
Expr::Call(String, Box<Expr>) was replaced by Expr::Call(String, Vec<Expr>).
The parser now handles comma-separated argument lists:
fn() → passes ans (backward-compatible)
fn(x) → single argument
fn(a, b) → two arguments
fn(a, b, c) → three arguments (future use)
call_builtin dispatcher
The inline match name in the evaluator was replaced by call_builtin(name, args: &[f64]),
which uses slice pattern matching to dispatch on both the function name and the
argument count:
#![allow(unused)]
fn main() {
("log", [x]) => x.log10()
("log", [x, base]) => x.log(*base)
}
This makes it trivial to add overloaded functions and keeps the evaluator clean.
New one-argument functions
| Function | Description |
|---|---|
asin(x) | Inverse sine (radians) |
acos(x) | Inverse cosine (radians) |
atan(x) | Inverse tangent (radians) |
sign(x) | Sign: −1.0, 0.0, or 1.0 |
New two-argument functions
| Function | Description |
|---|---|
atan2(y, x) | Four-quadrant inverse tangent (radians) |
mod(a, b) | Remainder, sign follows divisor (Octave/MATLAB mod) |
rem(a, b) | Remainder, sign follows dividend (IEEE 754 truncation) |
max(a, b) | Larger of two values |
min(a, b) | Smaller of two values |
hypot(a, b) | √(a²+b²), numerically stable |
log(x, base) | Logarithm of x to an arbitrary base |
mod vs rem — sign convention
mod( 10, 3) → 1 rem( 10, 3) → 1
mod(-1, 3) → 2 rem(-1, 3) → -1
mod( 1, -3) → -2 rem( 1, -3) → 1
mod is implemented as a - b * floor(a / b) — sign follows the divisor.
rem is implemented as a - b * trunc(a / b) — sign follows the dividend.
Octave/MATLAB alignment
atan2,mod,rem,max,min,hypotmatch Octave/MATLAB exactly.log(x, base)— Octave useslog(x)for the natural log; ccalc keepslog(x)as base-10 (legacy). Useln(x)for natural log, orlog(x, e)for the two-argument form.
Example
See examples/ac_impedance.ccalc for a practical use of hypot, atan2,
mod, max, min, log, and log(x, base) in an AC circuit calculation.
Phase 3 — Matrix Literals
Version: v0.8.0
What was added
Matrix literals using Octave/MATLAB bracket syntax:
[1 2 3] % row vector
[1; 2; 3] % column vector
[1 2; 3 4] % 2×2 matrix
Elements can be arbitrary expressions. Spaces and commas are both valid element separators within a row.
Arithmetic
scalar op matrixandmatrix op scalar— element-wise for+,-,*,/,^matrix + matrixandmatrix - matrix— element-wise (shapes must match)matrix * matrix— not yet (Phase 4)
Type system changes
Value enum introduced in env.rs:
#![allow(unused)]
fn main() {
pub enum Value {
Scalar(f64),
Matrix(ndarray::Array2<f64>),
}
pub type Env = HashMap<String, Value>;
}
eval() now returns Result<Value, String> (was Result<f64, String>).
Display
Matrices print with right-aligned columns:
A =
1 2
3 4
The REPL prompt shows matrix size when ans is a matrix: [ [2×2] ]: .
Workspace save (ws) skips matrix variables — only scalars are persisted.
Parser changes
New tokens: LBracket ([), RBracket (]), Semicolon (;).
New AST node: Expr::Matrix(Vec<Vec<Expr>>).
split_stmts() in repl.rs is now bracket-depth-aware: a ; inside
[...] is treated as a row separator by the parser, not a statement
separator by the REPL.
Dependency added
ndarray = "0.16" in crates/ccalc-engine/Cargo.toml.
Phase 4 — Matrix Operations
Version: v0.9.0
What was added
Matrix multiplication
* between two matrices now performs standard matrix multiplication via
ndarray’s .dot(). Inner dimensions must agree; otherwise an error is
returned.
A = [1 2; 3 4];
b = [1; 1];
A * b % → [3; 7]
Transpose
Postfix ' transposes a matrix. It binds tighter than any binary operator,
so A' * B parses as (A') * B.
v = [1; 2; 3];
v' % → [1 2 3] (1×3 row vector)
v' * v % → 14 (dot product, result is 1×1 matrix)
Element-wise operators
.*, ./, .^ apply the operation to each pair of corresponding elements.
Both operands must have the same shape (or one must be a scalar).
A .* B % Hadamard product
A ./ B % element-wise division
A .^ 2 % square each element
Note: * is matrix multiplication; .* is element-wise product.
New built-in functions
| Function | Description |
|---|---|
zeros(m, n) | m×n matrix of zeros |
ones(m, n) | m×n matrix of ones |
eye(n) | n×n identity matrix |
size(A) | [rows cols] as a 1×2 row vector |
size(A, dim) | Rows (dim=1) or columns (dim=2) as scalar |
length(A) | max(rows, cols) |
numel(A) | Total element count |
trace(A) | Sum of diagonal elements |
det(A) | Determinant |
inv(A) | Inverse matrix |
det and inv are implemented via Gaussian / Gauss-Jordan elimination
with no external BLAS/LAPACK dependency.
Parser changes
New tokens:
| Token | Input | Usage |
|---|---|---|
Apostrophe | ' | Postfix transpose |
DotStar | .* | Element-wise * |
DotSlash | ./ | Element-wise / |
DotCaret | .^ | Element-wise ^ |
New AST node: Expr::Transpose(Box<Expr>).
New Op variants: ElemMul, ElemDiv, ElemPow.
is_partial extended: .*, ./, .^ prefixes are now recognised as
partial expressions (e.g. .* 2 expands to ans .* 2).
The number tokenizer no longer absorbs . into a number when it is
immediately followed by *, /, or ^. This means 3.*2 correctly
tokenizes as Number(3), DotStar, Number(2) rather than
Number(3.0), Star, Number(2).
split_stmts fix
split_stmts in repl.rs previously toggled in_sq on every ',
which caused R' * q; to hide the ; inside a “string”. The function
now checks the left context: ' preceded by an identifier character,
digit, ), ], or another ' is treated as a transpose operator and
does not affect the string-tracking state.
Evaluator changes
eval_binop updated:
Matrix * Matrix→ ndarray.dot()(was an error in Phase 3)Matrix .* Matrix→ element-wise&lm * &rmMatrix ./ Matrix→ element-wise&lm / &rmMatrix .^ Matrix→ element-wiseZip::map_collect(a.powf(b))
call_builtin refactored: the function now returns Result<Value, String>
directly from each match arm, replacing the old pattern that extracted an
f64 result and always wrapped it in Value::Scalar. This allows matrix-
returning built-ins (zeros, ones, eye, size, inv) to coexist with
scalar-returning ones in the same match.
Phase 5 — Range Operator
Version: v0.10.0
What was added
Range expressions
The : operator generates row vectors. Two forms are supported:
| Form | Meaning |
|---|---|
a:b | start a, stop b, step 1 |
a:step:b | start a, stop b, explicit step |
1:5 % [1 2 3 4 5]
1:2:9 % [1 3 5 7 9]
0:0.5:2 % [0 0.5 1 1.5 2]
5:-1:1 % [5 4 3 2 1]
5:1 % [] (empty — step in wrong direction)
The range operator has lower precedence than arithmetic:
1+1:2+2 % 2:4 → [2 3 4]
Ranges inside matrix literals
Range elements inside [...] are evaluated to row vectors and concatenated
horizontally into the containing row:
[1:4] % [1 2 3 4] (1×4)
[0, 1:3, 10] % [0 1 2 3 10] (1×5)
[1:2:7] % [1 3 5 7] (1×4)
[1:3; 4:6] % [1 2 3; 4 5 6] (2×3)
linspace
linspace(a, b, n) generates n evenly spaced values from a to b
(both endpoints included). This is numerically superior to range expressions
when the number of points matters more than the step size.
linspace(0, 1, 5) % [0 0.25 0.5 0.75 1]
linspace(1, 5, 5) % [1 2 3 4 5]
linspace(0, 1, 1) % [1] (single element returns b, MATLAB convention)
linspace(0, 1, 0) % [] (empty)
Parser changes
New token: Token::Colon — produced by the : character.
New AST node: Expr::Range(Box<Expr>, Option<Box<Expr>>, Box<Expr>) where
the middle field is the optional step expression.
New parser function parse_range() sits above parse_expr() in the
precedence hierarchy:
parse_range (lowest)
parse_expr (+, -)
parse_term (*, /, .*, ./)
parse_power (^, .^)
parse_unary (unary -)
parse_primary (atoms)
parse() and parse_matrix() (element parsing) now call parse_range()
instead of parse_expr().
Evaluator changes
Expr::Range evaluation:
- Evaluate
start,stop, andstep(must all be scalars). - Compute
n = floor((stop - start) / step + ε) + 1. - If
n ≤ 0, return a 1×0 empty matrix. - Generate values as
start + i * stepfori = 0..n.
Expr::Matrix evaluation updated: when a row element evaluates to a
Value::Matrix, its values are appended to the current row (horizontal
concatenation). This enables ranges inside [...]. Column vectors and
higher-dimensional sub-matrices are rejected with an error.
Phase 6 — Indexing
Version: v0.11.0
What was added
Vector indexing
All indices are 1-based (Octave/MATLAB convention).
v = [10 20 30 40 50];
v(3) % → 30 scalar element
v(2:4) % → [20 30 40] sub-vector via range
v(:) % → column vector [10;20;30;40;50]
v(:) follows Octave convention: all elements are returned as a column vector
in column-major order.
Matrix indexing
A = [1 2 3; 4 5 6; 7 8 9];
A(2, 3) % → 6 scalar at row 2, col 3
A(1, :) % → [1 2 3] entire row 1 (1×3)
A(:, 2) % → [2;5;8] entire column 2 (3×1)
A(1:2, 2:3) % → [2 3; 5 6] submatrix
A(:, :) % → copy of A (all rows, all cols)
Scalar result: A(i,j) returns Value::Scalar when both i and j are
scalars. Sub-matrix results return Value::Matrix.
Linear (1D) indexing
v(i) on a matrix uses column-major linear indexing — columns are counted
before rows. For a 1×N row vector this is simply sequential:
v = [10 20 30 40 50];
v(3) % 3rd element → 30
For a 2D matrix, linear indexing counts down each column before moving to the next:
A = [1 2; 3 4]; % linear order: 1, 3, 2, 4
A(3) % → 2 (3rd in column-major order)
Call vs. index disambiguation
When name(args) is parsed, the evaluator checks Env at eval time:
- Name is in
Env→ indexing (eval_index) - Name is not in
Env→ built-in function call (call_builtin)
This means variables shadow built-in function names, matching Octave
semantics. Assigning zeros = [1 2; 3 4] makes zeros(1,2) an indexing
operation on that variable, not a call to zeros(m,n).
Parser changes
New AST node: Expr::Colon — represents a bare : used as an
all-elements index selector. Evaluating Expr::Colon outside an indexing
context returns an error.
New parser function parse_call_arg():
parse_call_arg:
if current token is ':' → consume, return Expr::Colon
else → parse_range(...)
All function call / index argument positions now use parse_call_arg
instead of parse_expr. This enables:
A(:, j)— bare colon as first argA(1:3, :)— range as first arg, colon as secondf(1:5)— range as function argument
Bug fix: range in grouping parentheses
parse_primary for grouped expressions (...) previously called
parse_expr, which does not handle :. This was changed to parse_range,
enabling expressions like 2 .^ (0:7).
Evaluator changes
eval_index(val, args, env) — dispatches based on argument count:
| Args | Form | Result |
|---|---|---|
1, Colon | v(:) | All elements as column vector (column-major) |
| 1, scalar | v(i) | Value::Scalar |
| 1, vector | v(1:3) | Value::Matrix 1×N |
| 2, both scalar | A(i,j) | Value::Scalar |
| 2, otherwise | A(:,j), A(i,:), A(1:2,2:3) | Value::Matrix |
resolve_dim(expr, dim_size, env) — converts one index argument to a list
of 0-based indices:
Expr::Colon→DimIdx::AllValue::Scalar(n)→ validates1 ≤ n ≤ dim_size, returns[n-1]Value::Matrix(vector) → validates each element, returns[i-1, ...]
Phase 7 — Comparison & Logical Operators
Version: v0.11.0+001
What was added
Comparison operators
All six relational operators from Octave/MATLAB are now supported.
They return 1.0 (true) or 0.0 (false):
3 > 2 % → 1
3 == 3 % → 1
3 == 4 % → 0
5 ~= 3 % → 1 (not equal)
4 <= 4 % → 1
4 >= 5 % → 0
Comparison has lower precedence than arithmetic, so the operands are evaluated first:
1 + 1 == 2 % → 1 (evaluates as (1+1) == 2)
2 * 3 > 5 % → 1 (evaluates as (2*3) > 5)
Comparison is non-associative — chaining like a < b < c is a parse
error. Write a < b && b < c instead.
Logical NOT — ~
Unary ~ negates a truth value: zero becomes 1, any non-zero becomes 0.
It binds at the same precedence level as unary minus:
~0 % → 1
~1 % → 0
~(3 == 3) % → 0
~(3 == 4) % → 1
Short-circuit logical AND/OR — &&, ||
&& and || evaluate both operands and return 0.0 or 1.0.
&& binds tighter than ||:
1 && 1 % → 1
1 && 0 % → 0
0 || 1 % → 1
0 || 0 % → 0
% '&&' before '||':
1 || 0 && 0 % → 1 (1 || (0 && 0))
Combining comparisons:
x = 2.7;
x >= 0 && x <= 3.3 % → 1 (in range check)
x < 0 || x > 3.3 % → 0 (out-of-range flag)
Element-wise on matrices
All operators work element-wise on matrices of the same shape, producing
a 0/1 mask of the same dimensions:
v = [1 2 3 4 5];
v > 3 % → [0 0 0 1 1]
v == 3 % → [0 0 1 0 0]
v ~= 3 % → [1 1 0 1 1]
~(v > 3) % → [1 1 1 0 0]
Scalar–matrix mixed comparisons broadcast the scalar:
v >= 2 % → [0 1 1 1 1]
3 < v % → [0 0 0 1 1]
Soft masking via .*
Since masks are 0/1 matrices, multiplying by them zeroes out unwanted
elements — a common pattern for conditional selection:
v .* (v > 3) % → [0 0 0 4 5] keep elements > 3
% Combine two masks (element-wise AND):
lo = v >= 2;
hi = v <= 4;
v .* (lo .* hi) % → [0 2 3 4 0] keep 2–4 only
Precedence summary
Full precedence table from lowest to highest:
| Level | Operators | Notes |
|---|---|---|
| 1 (lowest) | || | logical OR |
| 2 | && | logical AND |
| 3 | == ~= < > <= >= | comparison, non-associative |
| 4 | : | range (a:b, a:step:b) |
| 5 | + - | additive |
| 6 | * / .* ./ | multiplicative |
| 7 | ^ .^ | power (right-associative) |
| 8 | unary - ~ | negation, logical NOT |
| 9 (highest) | postfix ' | transpose |
Parser changes
Three new parser levels were inserted above parse_range:
parse_logical_or % '||'
parse_logical_and % '&&'
parse_comparison % == ~= < > <= >=
parse_range % a:b, a:step:b (existing)
parse_expr % + -
...
parse_logical_or is now the top-level entry point for parse(),
parse_call_arg(), and grouped expressions (...) in parse_primary.
New tokens: EqEq, NotEq, Lt, Gt, LtEq, GtEq, AmpAmp,
PipePipe, Tilde.
A bare = in expression context (not a valid assignment left-hand side)
is now a tokenizer error, making 3 = 3 produce a clear message instead
of a silent parse failure.
Evaluator changes
New Expr variant:
Expr::UnaryNot(Box<Expr>)— evaluates to1.0if inner is0.0, else0.0. Works element-wise on matrices.
New Op variants:
| Variant | Operation |
|---|---|
Op::Eq | == |
Op::NotEq | ~= |
Op::Lt | < |
Op::Gt | > |
Op::LtEq | <= |
Op::GtEq | >= |
Op::And | && |
Op::Or | || |
eval_binop handles all four combinations (Scalar×Scalar,
Matrix×Matrix, Scalar×Matrix, Matrix×Scalar) for every new operator.
Scalar×Scalar returns a Value::Scalar(0.0|1.0); any matrix combination
returns a Value::Matrix of the same shape.
Helper functions added to eval.rs:
bool_to_f64(b: bool) -> f64— converts a boolean to0.0/1.0cmp_op(op: &Op, a: f64, b: f64) -> bool— applies comparison or logical op to two scalar values
Phase 7.5 — Vector Utilities
Version: v0.11.0+003
What was added
7.5a — Special floating-point constants
nan and inf are parser-level constants (like pi and e) — they require
no variable definition and cannot be overwritten by assignment.
nan % Not-a-Number
inf % positive infinity
-inf % negative infinity
nan + 5 % → NaN (NaN propagates through all arithmetic)
nan == nan % → 0 (always false in IEEE 754)
Matrix constructor:
nan(3) % 3×3 matrix filled with NaN
nan(2, 4) % 2×4 matrix filled with NaN
Element-wise predicates — work on both scalars and matrices:
| Function | Description |
|---|---|
isnan(x) | 1.0 if NaN, else 0.0 |
isinf(x) | 1.0 if ±Inf, else 0.0 |
isfinite(x) | 1.0 if finite (not NaN, not Inf) |
isnan(nan) % → 1
isinf(inf) % → 1
isfinite(42) % → 1
isfinite(nan) % → 0
v = [1 nan 3 inf];
isnan(v) % → [0 1 0 0]
isfinite(v) % → [1 0 1 0]
7.5b — Vector reductions
All reduction functions follow the same rule:
- Vector (1×N or N×1): collapse all elements to a scalar.
- M×N matrix (M>1, N>1): operate column-wise, return a 1×N row vector.
This matches Octave/MATLAB behaviour.
| Function | Description |
|---|---|
sum(v) | Sum of elements |
prod(v) | Product of elements |
mean(v) | Arithmetic mean |
min(v) | Minimum element (1-arg form; min(a,b) still works) |
max(v) | Maximum element (1-arg form) |
any(v) | 1.0 if any element is non-zero |
all(v) | 1.0 if all elements are non-zero |
norm(v) | Euclidean (L2) norm |
norm(v, p) | General Lp norm; p = inf → max of absolute values |
sum([1 2 3 4]) % → 10
mean([1 2 3 4]) % → 2.5
min([3 1 4 1 5]) % → 1
max([3 1 4 1 5]) % → 5
any([0 0 1 0]) % → 1
all([1 2 3] > 0) % → 1
norm([3 4]) % → 5
norm([1 2 3], 1) % → 6 (L1 = sum of absolute values)
Column-wise on a matrix:
M = [1 2 3; 4 5 6]
sum(M) % → [5 7 9]
mean(M) % → [2.5 3.5 4.5]
Cumulative operations — return the same shape as the input:
| Function | Description |
|---|---|
cumsum(v) | Cumulative sum along the vector / each column |
cumprod(v) | Cumulative product |
cumsum([1 2 3 4]) % → [1 3 6 10]
cumprod([1 2 3 4]) % → [1 2 6 24]
7.5c — end keyword in indexing
Inside any index expression (...), the keyword end resolves to the size
of the dimension being indexed. Arithmetic on end is fully supported.
v = [10 20 30 40 50];
v(end) % → 50
v(end-1) % → 40
v(end-2:end) % → [30 40 50]
v(1:2:end) % → [10 30 50]
A = [1 2 3; 4 5 6; 7 8 9];
A(end, :) % → [7 8 9] last row
A(:, end) % → [3; 6; 9] last column
A(1:end-1, 2:end) % → [2 3; 5 6] submatrix
Implementation note: end is a context-sensitive value injected into the
evaluation environment by eval_index. Outside an indexing context it is
an undefined variable.
7.5d — Sort, reshape, and find
| Function | Description |
|---|---|
sort(v) | Sort ascending (vectors only) |
reshape(A, m, n) | Reshape to m×n using column-major (MATLAB) element order |
fliplr(v) | Reverse column order (left↔right mirror) |
flipud(v) | Reverse row order (up↔down mirror) |
find(v) | 1-based column-major indices of non-zero elements |
find(v, k) | First k non-zero indices |
unique(v) | Sorted unique elements as a 1×N row vector |
sort([3 1 4 1 5 9]) % → [1 1 3 4 5 9]
reshape(1:6, 2, 3) % → [1 3 5; 2 4 6]
fliplr([1 2 3]) % → [3 2 1]
flipud([1;2;3]) % → [3; 2; 1]
find([0 3 0 5 0]) % → [2 4]
find([1 0 2 0 3], 2) % → [1 3]
unique([3 1 4 1 5 3]) % → [1 3 4 5]
reshape uses column-major order — elements fill the output matrix
column-by-column, matching MATLAB/Octave behaviour:
reshape([1 2 3 4 5 6], 2, 3)
% col 0: [1 2], col 1: [3 4], col 2: [5 6]
% → [1 3 5]
% [2 4 6]
Example file
examples/vector_utils.calc covers all Phase 7.5 features with annotated
output. Run it with:
ccalc examples/vector_utils.calc
Implementation details
nan/infare handled inparse_primary(parser.rs) as named constants →Expr::Number(f64::NAN)/Expr::Number(f64::INFINITY), exactly likepiande. This allowsnan(m,n)to work as a builtin call without the variable shadowing the function.apply_elem,apply_reduction,apply_cumulative,find_nonzeroare private helpers ineval.rsthat keep the builtin match arms concise.endsupport:eval_indexcreates a cloned environment with"end" = dim_sizeviaenv_with_end()before callingresolve_dim. No new AST node is required —endis just a variable that exists only within the scope of the index evaluation.
Phase 8 — Complex Numbers
Version: v0.12.0
What was added
Value::Complex
A third variant was added to the Value enum in env.rs:
#![allow(unused)]
fn main() {
pub enum Value {
Scalar(f64),
Matrix(Array2<f64>),
Complex(f64, f64), // re, im
}
}
make_complex(re, im) in eval.rs collapses the result to Scalar(re)
when im == 0.0 exactly, so operations like (1+i)*(1-i) produce Scalar(2)
rather than Complex(2, 0).
Imaginary units i and j
i and j are pre-seeded in Env at startup as Complex(0.0, 1.0),
matching Octave/MATLAB behaviour. The user can overwrite them with
i = 5 (the new value shadows the imaginary unit for the session).
clear i removes the variable; the imaginary unit is not automatically
restored (document limitation).
Syntax — no new tokens
4i tokenizes as Number(4) Ident("i"), which triggers implicit
multiplication. With i = Complex(0.0, 1.0) in Env:
4*i → Complex(0.0, 4.0)
3 + 4*i → Complex(3.0, 4.0)
No tokenizer or parser changes were needed.
Arithmetic
complex_binop(re1, im1, op, re2, im2) handles all four combination groups:
| Left | Right | Routing |
|---|---|---|
| Complex | Complex | complex_binop directly |
| Complex | Scalar | complex_binop(re, im, op, s, 0.0) |
| Scalar | Complex | complex_binop(s, 0.0, op, re, im) |
| Complex | Matrix | error |
Supported operations:
| Op | Formula |
|---|---|
+ | (a+c) + (b+d)i |
- | (a-c) + (b-d)i |
* / .* | (ac-bd) + (ad+bc)i |
/ / ./ | ((ac+bd) + (bc-ad)i) / (c²+d²) |
^ / .^ integer | binary exponentiation (exact) |
^ / .^ general | polar form exp((c+di)·ln(a+bi)) |
== | 1 if both parts equal |
~= | 1 if either part differs |
</>/<=/>= | error: “Ordering is not defined for complex numbers” |
&& / || | nonzero test on `re != 0 |
Powers — integer exactness
Integer powers use binary exponentiation (repeated squaring) to avoid polar-form floating-point error:
i^2 = -1 (exact, not -1 + 1.22e-16i)
i^3 = -i (exact)
i^4 = 1 (exact)
General non-integer powers fall back to the polar form
exp((c+di) * (ln|z| + i*arg(z))).
Unary operators
| Op | Result |
|---|---|
-z | Complex(-re, -im) |
~z | 0 if re≠0 or im≠0, else 1 |
z' | Complex(re, -im) — conjugate transpose |
Display
format_complex(re, im, precision) in eval.rs:
| Condition | Display |
|---|---|
im == 0 | a (same as scalar) |
re == 0, im > 0 | bi (or i when im == 1) |
re == 0, im < 0 | -bi (or -i) |
im > 0 | a + bi |
im < 0 | a - |b|i |
The REPL prompt shows the complex value directly (e.g. [ 3 + 4i ]:).
Built-in functions
| Function | Description |
|---|---|
real(z) | Real part; returns z unchanged for scalars |
imag(z) | Imaginary part; returns 0 for scalars |
abs(z) | Modulus sqrt(re²+im²); overloads scalar and matrix abs |
angle(z) | Argument atan2(im, re) in radians |
conj(z) | Complex conjugate re - im*i |
complex(re, im) | Construct from two real scalars |
isreal(z) | 1 if im == 0, else 0 |
scalar_arg compatibility
scalar_arg(v, name, pos) now accepts Complex with im == 0 as a real
scalar, so sqrt(complex(4, 0)) returns 2 without error.
Scope boundary
Complex matrices (e.g. [1+2i, 3+4i]) are out of scope for this phase.
A matrix literal containing a Complex element returns:
Error: Complex elements in matrix literals are not supported yet
Complex variables are not persisted by ws/wl (same policy as matrices).
Files changed
| File | Change |
|---|---|
crates/ccalc-engine/src/env.rs | Added Value::Complex(f64, f64) |
crates/ccalc-engine/src/eval.rs | complex_binop, make_complex, format_complex, built-ins, exhaustive match arms |
crates/ccalc/src/repl.rs | new_env seeds i/j; all output paths handle Value::Complex |
crates/ccalc-engine/src/eval_tests.rs | 38 new tests covering all operations |
examples/complex_numbers.calc | New annotated example file |
Phase 9 — String Data Types
Version: v0.13.0
What was added
Two new Value variants
Two variants were added to the Value enum in env.rs:
#![allow(unused)]
fn main() {
pub enum Value {
Scalar(f64),
Matrix(Array2<f64>),
Complex(f64, f64),
/// Char array (single-quoted). Represents a 1×N row of characters.
Str(String),
/// String object (double-quoted). Scalar container of arbitrary text.
StringObj(String),
}
}
save_workspace uses as_scalar() to filter variables before writing —
the new variants return None from as_scalar() and are automatically
skipped, matching the policy for matrices and complex numbers.
Tokenizer changes — ' disambiguation
The ' character has two meanings:
- Transpose operator — when preceded by an rvalue context
- Char array literal — at the start of an expression or after an operator
The tokenizer tracks the last emitted token and applies this rule:
| Last token | ' is |
|---|---|
Number, Ident, RParen, RBracket, Apostrophe, Str | Transpose (Token::Apostrophe) |
| Anything else (including “nothing” = start of input) | Char array literal start |
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)
'A'' → Str("A") Apostrophe (char array, then transpose)
"..." string object tokens
A new arm handles double-quoted string objects. Escape sequences are processed at tokenization time:
| Sequence | Result |
|---|---|
"" | Literal " |
\n | Newline |
\t | Tab |
\\ | Literal \ |
\" | Literal " |
New AST nodes
#![allow(unused)]
fn main() {
pub enum Expr {
// ...existing variants...
/// Single-quoted char array literal.
StrLiteral(String),
/// Double-quoted string object literal.
StringObjLiteral(String),
}
}
parse_primary handles Token::Str → Expr::StrLiteral and
Token::StringObj → Expr::StringObjLiteral. The existing postfix
transpose loop then applies: 'hello'' produces
Expr::Transpose(Box::new(Expr::StrLiteral("hello"))).
Arithmetic on char arrays
str_to_numeric(s: &str) -> Value converts a char array to its numeric
representation before binary operations:
| Input length | Result |
|---|---|
| 0 | Value::Matrix 1×0 |
| 1 | Value::Scalar(code) |
| N | Value::Matrix 1×N |
In eval_binop, the arms for Str appear before all others:
#![allow(unused)]
fn main() {
(Value::Str(s), r) => eval_binop(str_to_numeric(&s), op, r),
(l, Value::Str(s)) => eval_binop(l, op, str_to_numeric(&s)),
}
This means 'abc' + 1 → str_to_numeric("abc") = Matrix([97,98,99])
→ [98, 99, 100], and 'a' + 0 → Scalar(97) → Scalar(97).
String object operations
String objects support only + (concatenation) and ==/~= (comparison).
All other operators return an error:
#![allow(unused)]
fn main() {
(Value::StringObj(a), Value::StringObj(b)) => match op {
Op::Add => Ok(Value::StringObj(a + &b)),
Op::Eq => Ok(Value::Scalar(bool_to_f64(a == b))),
Op::NotEq => Ok(Value::Scalar(bool_to_f64(a != b))),
_ => Err("Operator not supported on string objects"),
},
}
New built-in functions
| Function | Arity | Description |
|---|---|---|
num2str | 1 | Number → char array |
num2str | 2 | Number → char array, N decimal digits |
str2num | 1 | Char array → number, error on failure |
str2double | 1 | Char array → number, NaN on failure |
strcat | ≥2 | Concatenate strings |
strcmp | 2 | Case-sensitive equality → 0/1 |
strcmpi | 2 | Case-insensitive equality → 0/1 |
lower | 1 | Lowercase |
upper | 1 | Uppercase |
strtrim | 1 | Strip whitespace |
strrep | 3 | Find-and-replace |
sprintf | 1 | Process escape sequences, return Str |
ischar | 1 | 1 if Str, else 0 |
isstring | 1 | 1 if StringObj, else 0 |
length, numel, and size were extended to handle both new variants:
length(Str(s))→ number of characters inslength(StringObj(_))→ 1 (scalar element)numelandsizefollow the same pattern
Helper functions
string_arg(v, fname, pos) extracts a string slice from Str or StringObj,
returning a descriptive error for any other type.
process_escape_sequences(s) handles \n, \t, \\, \', \" —
factored out of repl.rs into eval.rs for use by sprintf.
Display
format_valuereturns the string content as-is for both variants.format_value_fullreturnsNone(strings are displayed inline like scalars).- The REPL prompt shows the first 15 characters with surrounding quotes when
ansis a string. whoannotates type:name [1×N char]forStr,name [string]forStringObj.
Exhaustiveness
All existing functions that matched Value variants (eval_index,
resolve_dim, apply_elem, apply_reduction, apply_cumulative,
find_nonzero, scalar_arg) were updated to handle Str and StringObj —
either with string-specific logic or a clear error message.
What was not changed
- Matrix literals containing string elements are not yet supported
(
['a', 'b']as a char matrix). This requires a separate char-matrix representation and is deferred to a later phase. - Workspace save/load for strings is intentionally skipped (same policy as matrices and complex).
strsplitrequires cell arrays (not yet implemented) and is deferred.- The
split_stmts()function inrepl.rsalready tracked single-quoted and double-quoted string boundaries (from earlier disambiguation work in Phase 4). No changes were needed there.
Phase 10 — C-style I/O and format command
Status: Complete ✅
Version: v0.14.0 / v0.14.0+001
Prerequisite: Phase 9 (string types)
What was implemented
10a — fprintf with format specifiers
fprintf(fmt, arg1, arg2, ...) built-in, interpreting a format string with
C-style conversion specifiers:
| Specifier | Meaning |
|---|---|
%d, %i | decimal integer (value truncated to i64) |
%f | fixed-point decimal, default 6 places |
%e | scientific notation (1.23e+04) |
%g | shorter of %f and %e |
%s | string (char array or string object) |
%% | literal % |
Width, precision, and flags (-, +, 0, space) follow C printf.
When there are more arguments than specifiers, the format string repeats for remaining args (Octave behaviour).
fprintf is implemented as a case in call_builtin in eval.rs — not as a
REPL special case. Returns Value::Void; the REPL suppresses display for Void.
10b — sprintf
Same format engine (format_printf in eval.rs), but returns the formatted
result as Value::Str (char array) instead of printing.
Single-arg sprintf(fmt) processes escape sequences — this replaces the former
escape-only form.
10c — Precision system overhaul
The p / p<N> precision directive was removed from the language.
Replaced by the format command (v0.14.0+001).
10d — format command (v0.14.0+001)
MATLAB-compatible number display modes:
| Mode | Description |
|---|---|
short | 5 significant digits, auto fixed/scientific (default) |
long | 15 significant digits |
shortE | always scientific, 4 decimal places |
longE | always scientific, 14 decimal places |
shortG | alias for short |
longG | alias for long |
bank | fixed 2 decimal places |
rat | rational approximation via continued fractions |
hex | IEEE 754 double bit pattern (16 uppercase hex digits) |
+ | sign only: +, -, or space |
compact | suppress blank lines between outputs |
loose | add blank line after every output |
N | N decimal places (legacy custom mode) |
format alone resets to short.
Key implementation details
Value::Void
New variant added to the Value enum in env.rs. Returned by side-effectful
functions (fprintf). The REPL checks for Value::Void and skips display.
format_printf function
Core format engine in eval.rs (public). Processes a format string and a slice
of Value args, returns a String. Handles repeat-format semantics, all
specifiers, all flags.
FormatMode enum
#![allow(unused)]
fn main() {
pub enum FormatMode {
Short, Long, ShortE, LongE, ShortG, LongG,
Bank, Rat, Hex, Plus, Custom(usize),
}
}
Added after Base enum in eval.rs. Default is Custom(10) (preserves
existing test baseline). format command in REPL resets to Short.
Changed public API signatures:
#![allow(unused)]
fn main() {
pub fn format_scalar(n: f64, base: Base, mode: &FormatMode) -> String
pub fn format_complex(re: f64, im: f64, mode: &FormatMode) -> String
pub fn format_value(v: &Value, base: Base, mode: &FormatMode) -> String
pub fn format_value_full(v: &Value, mode: &FormatMode) -> Option<String>
}
fmt_rat — rational approximation
Continued fractions algorithm. Tolerance: 1e-6. Max denominator: 10,000.
Gives 355/113 for pi.
trim_sci — exponent normalization
Normalizes Rust’s e0/e-3 format to MATLAB-style e+00/e-03 with
two-digit exponent and explicit sign.
Scope boundary
- File I/O (
fopen/fclose/fgetl,dlmread/dlmwrite) — implemented in Phase 10.5. fprintf(fd, ...)with file descriptor — implemented in Phase 10.5.- Complex matrices in
fprintf— not supported (same boundary as Phase 8).
Tests
eval_tests.rs: 9 newtest_format_*tests covering all modes.repl_tests.rs: updated harness (FormatMode::default()replacesprecision: usize).- Total: 427 tests passing (97 repl + 330 engine).
Phase 10.5 — File I/O and Filesystem Queries
Status: Complete ✅
Version: v0.14.0+006
Prerequisite: Phase 10 (string types for filename arguments)
What was implemented
10.5a — File handles
Low-level file I/O using integer file descriptors, matching the MATLAB/Octave fopen/fclose model.
| Function | Description |
|---|---|
fopen(path, mode) | Open file; returns fd (≥3) or -1 on failure |
fclose(fd) | Close by fd; returns 0 on success, -1 on error |
fclose('all') | Close all open file handles |
fgetl(fd) | Read one line, strip trailing newline; returns -1 at EOF |
fgets(fd) | Read one line, keep trailing newline; returns -1 at EOF |
fprintf(fd, fmt, ...) | Write formatted output to file descriptor |
Supported modes: 'r' read, 'w' write (create/truncate), 'a' append, 'r+' read+write.
Pre-opened virtual descriptors: fd 1 = stdout, fd 2 = stderr (Octave convention).
10.5b — Delimiter-separated data
Read and write numeric data in CSV or TSV format.
| Function | Description |
|---|---|
dlmread(path) | Read; auto-detect , / \t / whitespace |
dlmread(path, delim) | Explicit delimiter |
dlmwrite(path, A) | Write matrix with , separator |
dlmwrite(path, A, delim) | Explicit delimiter |
Returns Value::Matrix; all values must be numeric. Non-numeric data returns an error with the line number.
10.5c — Filesystem queries
| Function | Description |
|---|---|
isfile(path) | 1 if the path exists and is a regular file |
isfolder(path) | 1 if the path exists and is a directory |
pwd() | Current working directory as a char array |
exist(name) | 1 if variable in workspace; 2 if a file exists on disk |
exist(name, 'var') | Check workspace only |
exist(name, 'file') | Check filesystem only; returns 2 (MATLAB numeric code) |
10.5d — Workspace with explicit path
Extension of the existing ws/wl commands to accept an explicit file path.
| Syntax | Description |
|---|---|
save | Save all variables to default path (alias for ws) |
save('path.mat') | Save all variables to named file |
save('path.mat', 'x', 'y') | Save specific variables only |
load | Load from default path (alias for wl) |
load('path.mat') | Load from named file |
Path argument can be a variable reference: save(path_var).
Persisted types: Scalar, Str (char array), StringObj. Matrices, complex values, and Void are always skipped.
Key implementation details
IoContext
New struct in ccalc-engine/src/io.rs. Holds a HashMap<i32, FileHandle> and next_fd: i32 (starts at 3). FileHandle is an enum: Read(BufReader<File>) | Write(File). fd 1 and 2 are handled virtually in write_to_fd.
eval_with_io / eval
#![allow(unused)]
fn main() {
pub fn eval(expr: &Expr, env: &Env) -> Result<Value, String>
pub fn eval_with_io(expr: &Expr, env: &Env, io: &mut IoContext) -> Result<Value, String>
}
Both are wrappers around private eval_inner(expr, env, mut io: Option<&mut IoContext>). The Option<&mut T> pattern with as_deref_mut() reborrowing allows sequential mutable borrows in recursive calls without cloning.
call_builtin signature change
#![allow(unused)]
fn main() {
fn call_builtin(name: &str, args: &[Value], env: &Env, io: Option<&mut IoContext>)
}
env: &Env was added for exist('x', 'var') which needs to inspect the workspace.
try_parse_save_load
Intercepts save/load statements in repl.rs before they reach evaluate(). Accepts env: &Env for resolving variable-reference path arguments (save(mat_path) where mat_path is a Str/StringObj in the workspace).
#![allow(unused)]
fn main() {
enum SaveLoadCmd {
Save { path: Option<String>, vars: Vec<String> },
Load { path: Option<String> },
}
fn try_parse_save_load(stmt: &str, env: &Env) -> Option<SaveLoadCmd>
}
Workspace serialization
name = 3.14 # Scalar
name = 'text' # Str (char array)
name = "text" # StringObj
Strings containing ' or " characters (matching the quote style) or \n are skipped to preserve the line-based format.
Tests
eval_tests.rs:test_fopen_write_and_fclose,test_fgetl_reads_lines,test_fgets_keeps_newline,test_fprintf_to_file,test_fclose_all,test_fopen_nonexistent_returns_minus_one,test_dlmwrite_and_dlmread_comma,test_dlmwrite_tab_delimiter,test_dlmread_empty_file,test_dlmread_non_numeric_error,test_dlmread_whitespace_auto,test_dlmwrite_scalar,test_isfile_existing_file,test_isfile_nonexistent,test_isfile_directory_is_false,test_isfolder_existing_dir,test_isfolder_file_is_false,test_exist_var_found,test_exist_var_not_found,test_exist_file_found,test_exist_file_not_found,test_exist_one_arg_checks_var_then_file,test_pwd_returns_string,test_fprintf_returns_voidrepl_tests.rs: 9 new Phase 10.5d tests includingtest_pipe_save_load_roundtripandtest_pipe_save_selective_vars- Total: 461 tests passing
Phase 11 — Core Control Flow
Version: 0.15.x
Status: ✅ Complete
Motivation
Phase 10 (formatted output) made it practical to write loop scripts. Phase 11
adds the block statements that make those scripts possible: if, for,
while, break, continue, and compound assignment operators.
Phase 11a — Multi-line input and if / for / while (v0.15.0)
Architecture
A new exec.rs module houses exec_stmts(), which avoids circular dependency:
parser.rs → eval.rs, and exec.rs → both. The Stmt enum gains five
new variants:
#![allow(unused)]
fn main() {
pub enum Stmt {
Assign(String, Expr),
Expr(Expr),
If {
cond: Expr,
body: Vec<(Stmt, bool)>,
elseif_branches: Vec<(Expr, Vec<(Stmt, bool)>)>,
else_body: Option<Vec<(Stmt, bool)>>,
},
For { var: String, range_expr: Expr, body: Vec<(Stmt, bool)> },
While { cond: Expr, body: Vec<(Stmt, bool)> },
Break,
Continue,
}
}
exec_stmts returns Result<Option<Signal>, String> where Signal is
Break | Continue. Loops catch these signals; a signal that escapes to the
top level is reported as an error.
REPL buffering
block_depth_delta(line) returns +1 for if/for/while and -1 for
end. The REPL accumulates lines into block_buf while block_depth > 0,
then calls parse_stmts() + exec_stmts() when the block closes.
[ 0 ]: for k = 1:3
>> fprintf('%d\n', k)
>> end
1
2
3
For loop semantics
The range expression is evaluated once before any iteration. Iteration is column-by-column (Octave convention):
- Row vector (1×N): each element becomes a
Scalar - M×N matrix: each column becomes an M×1
Matrix
is_truthy
#![allow(unused)]
fn main() {
fn is_truthy(val: &Value) -> bool {
match val {
Value::Scalar(n) => *n != 0.0 && !n.is_nan(),
Value::Matrix(m) => m.iter().all(|&x| x != 0.0 && !x.is_nan()),
Value::Complex(re, im) => *re != 0.0 || *im != 0.0,
Value::Str(s) | Value::StringObj(s) => !s.is_empty(),
Value::Void => false,
}
}
}
Known limitation
Single-line blocks (if cond; body; end on one line) are not supported.
Use multi-line form.
Phase 11b — Compound assignment operators (v0.15.1)
Six new token variants are added to the lexer:
| Token | Source |
|---|---|
PlusEq | += |
MinusEq | -= |
StarEq | *= |
SlashEq | /= |
PlusPlus | ++ |
MinusMinus | -- |
try_parse_compound(tokens) is called first in parse(). All forms desugar
to Stmt::Assign at parse time — no new AST nodes are needed.
| Input | Desugared to |
|---|---|
x += e | x = x + e |
x -= e | x = x - e |
x *= e | x = x * e |
x /= e | x = x / e |
x++ | x = x + 1 |
x-- | x = x - 1 |
++x | x = x + 1 |
--x | x = x - 1 |
The RHS of op= is a full expression (parse_logical_or), so
x *= 2 + 3 desugars to x = x * (2 + 3).
is_partial() was updated so ++x/--x are not mistakenly treated as
partial binary expressions.
Limitation: ++/-- are statement-level only.
Syntax aliases (v0.15.2)
| Alias | Equivalent |
|---|---|
# | % (comment) |
! | ~ (logical NOT) |
!= | ~= (not equal) |
# is recognised in the tokenizer, strip_line_comment(), and
split_stmts(). ! and != are emitted as Token::Tilde and
Token::NotEq respectively — no changes downstream.
Phase 11.5 — Extended Control Flow
Version: 0.16.0
Status: ✅ Complete
Motivation
Phase 11 added the core control flow constructs (if, for, while). Phase 11.5
fills in the remaining Octave constructs most commonly found in real .m scripts:
switch, do...until, and the ability to source another script file with run().
Phase 11.5a — switch / case / otherwise (v0.16.0)
Semantics
switch in Octave/MATLAB has no fall-through: the first matching case
executes and control jumps directly to end. otherwise is the optional
default branch.
switch expr
case val1
% ...
case val2
% ...
otherwise
% ...
end
Matching rules
| Switch value | Case value | Match |
|---|---|---|
| Scalar | Scalar | exact == |
Str or StringObj | Str or StringObj | string equality (Str and StringObj interchangeable) |
| Any other combination | — | no match |
break and continue
break/continue inside a switch body propagate outward to the nearest
enclosing loop. switch itself is not a loop.
AST
#![allow(unused)]
fn main() {
Stmt::Switch {
expr: Expr,
cases: Vec<(Vec<Expr>, Vec<(Stmt, bool)>)>,
otherwise_body: Option<Vec<(Stmt, bool)>>,
}
}
Each case carries a Vec<Expr> for future multi-value case support (case {1,2,3}
from Phase 11.5b, deferred until cell arrays are introduced in a later phase).
Example
switch code
case 200
msg = 'OK';
case 301
msg = 'Moved Permanently';
case 404
msg = 'Not Found';
otherwise
msg = 'Unknown';
end
fprintf('HTTP %d: %s\n', code, msg)
Phase 11.5b — Multi-value cases (deferred)
case {val1, val2} syntax requires Value::Cell, which is not yet implemented.
Deferred until cell arrays are introduced.
Phase 11.5c — do...until (v0.16.0)
Semantics
Octave-specific post-test loop. The body always executes at least once, then the condition is tested. If truthy, the loop exits.
do
body
until (cond)
Parentheses around cond are optional. break exits the loop immediately;
continue re-tests the condition.
until closes the block without a separate end. In the REPL,
block_depth_delta("until …") returns −1.
AST
#![allow(unused)]
fn main() {
Stmt::DoUntil {
body: Vec<(Stmt, bool)>,
cond: Expr,
}
}
Execution (exec.rs)
#![allow(unused)]
fn main() {
Stmt::DoUntil { body, cond } => loop {
match exec_stmts(body, env, io, fmt, base, compact)? {
Some(Signal::Break) => break,
Some(Signal::Continue) | None => {}
}
if is_truthy(&eval_with_io(cond, env, io)?) {
break;
}
},
}
Example
% Smallest power of 2 >= n
n = 100;
p = 1;
do
p *= 2;
until (p >= n)
fprintf('%d\n', p) % 128
Phase 11.5d — try/catch (deferred)
Deferred to Phase 14 (after structs). try/catch requires Value::Struct
for the error object, and the error-handling model is under design review.
Phase 11.5e — Script sourcing run() / source() (v0.16.0)
Semantics
Execute a script file in the caller’s workspace (MATLAB run semantics).
Variables defined in the script persist after run returns. This is the opposite
of a function call, which would have an isolated scope.
run('script') % search for script.calc, then script.m in CWD
run('script.calc') % explicit .calc extension
run('script.m') % explicit .m extension
source('script') % Octave alias — identical behaviour
Extension resolution for bare names
When no file extension is given, ccalc tries:
<name>.calc— native ccalc script format (preferred)<name>.m— Octave/MATLAB compatibility
Explicit extensions (.calc, .m, or any other) are used verbatim.
Implementation
run()/source() are intercepted in exec_stmts by pattern-matching
Stmt::Expr(Expr::Call("run"|"source", args)) before calling eval_with_io.
This avoids needing a new AST node:
#![allow(unused)]
fn main() {
if let Expr::Call(fn_name, args) = expr
&& matches!(fn_name.as_str(), "run" | "source")
&& args.len() == 1
{
// resolve path → read file → parse_stmts → exec_stmts (recursive)
}
}
In pipe/script mode, single-line statements bypass exec_stmts and go through
evaluate(). A try_run_source() helper in repl.rs bridges that gap by
routing run/source calls through exec_stmts before evaluate() is reached.
Recursion limit
A thread_local! RUN_DEPTH counter prevents infinite recursion. Maximum depth
is 64; exceeding it returns an error.
Example
% euclid_helper.calc — reads a, b from workspace; writes g = gcd(a, b)
g = a;
r = b;
while r ~= 0
temp = mod(g, r);
g = r;
r = temp;
end
% caller
a = 252; b = 105;
run('euclid_helper')
fprintf('gcd(252, 105) = %d\n', g) % 21
REPL block depth
block_depth_delta was updated for the new keywords:
| Line starts with | Delta |
|---|---|
switch | +1 |
do | +1 |
until … | −1 |
end | −1 (unchanged) |
Demo
cd examples
ccalc extended_control_flow.calc
The example covers all constructs from this phase: switch integer and string
dispatch, do...until with break/continue, Euclidean GCD via run(), and the
source() alias.
Phase 12 — User-defined Functions
Version: v0.17.0
Prerequisite: Phase 11.5 (extended control flow, return, run/source)
Overview
Phase 12 introduces user-defined named functions, multiple return values,
the return statement for early exit, and anonymous functions (lambdas) created
with @(params) expr.
Named functions
function result = name(p1, p2)
...
result = expr;
end
Functions are defined at the top level — in the REPL or in a script file.
They are stored in the workspace like any variable and persist until clear is called.
Single return value
function y = square(x)
y = x ^ 2;
end
square(5) % 25
square(12) % 144
Multiple return values
function [mn, mx, avg] = stats(v)
mn = min(v);
mx = max(v);
avg = mean(v);
end
data = [4 7 2 9 1 5 8 3 6];
[lo, hi, mu] = stats(data);
% lo = 1 hi = 9 mu = 5
Discarding outputs with ~
[~, top, ~] = stats([10 30 20]); % top = 30
nargin — optional arguments
nargin is injected into every function body and holds the number of arguments
actually passed by the caller. Use it to implement default parameter values:
function y = power_fn(base, exp)
if nargin < 2
exp = 2;
end
y = base ^ exp;
end
power_fn(5) % 25 (exp defaults to 2)
power_fn(2, 8) % 256
power_fn(3, 3) % 27
return — early exit
return exits the function immediately. All output variables must be assigned
before return is reached:
function g = gcd_fn(a, b)
while b ~= 0
r = mod(a, b);
a = b;
b = r;
end
g = a;
end
Recursive early return:
function result = factorial_r(n)
if n <= 1
result = 1;
return
end
result = n * factorial_r(n - 1);
end
factorial_r(7) % 5040
Scope
Each function call creates a fresh isolated scope:
- The caller’s data variables (scalars, matrices, strings, etc.) are not visible.
- Declared parameters are bound in the local scope.
i,j, andansare pre-seeded.narginandnargoutare injected.- All
FunctionandLambdavalues from the caller’s workspace are forwarded, enabling self-recursion and mutual recursion without leaking data.
Anonymous functions (lambdas)
f = @(params) expr
@(x) expr creates a closure. The current environment is captured at
definition time (lexical scoping):
sq = @(x) x ^ 2;
cube = @(x) x ^ 3;
hyp = @(a, b) sqrt(a^2 + b^2);
sq(7) % 49
cube(4) % 64
hyp(3, 4) % 5
Lexical capture
Changing a variable after a lambda is defined does not affect the lambda:
rate = 0.05;
interest = @(principal, years) principal * (1 + rate) ^ years;
interest(1000, 10) % 1628.89 (captured rate = 0.05)
rate = 0.99; % lambda is unaffected
interest(1000, 10) % still 1628.89
Lambdas as arguments
Pass a lambda to a named function using @:
function s = midpoint(f, a, b, n)
h = (b - a) / n;
s = 0;
for k = 1:n
xm = a + (k - 0.5) * h;
s += f(xm);
end
s *= h;
end
% integral of x^2 from 0 to 1 = 1/3
midpoint(@(x) x^2, 0, 1, 1000) % 0.333333
% integral of sin(x) from 0 to pi = 2
midpoint(@(x) sin(x), 0, pi, 1000) % 2.000001
Functions returning functions
Named functions can return lambdas (higher-order programming):
function f = make_adder(c)
f = @(x) x + c;
end
add5 = make_adder(5);
add10 = make_adder(10);
add5(3) % 8
add10(7) % 17
add5(add10(1)) % 16
Implementation details
| Concern | Solution |
|---|---|
Circular dependency (eval.rs ↔ parser.rs) | Named functions store body_source: String; re-parsed on each call in exec.rs |
| Cross-module dispatch | Thread-local FnCallHook in eval.rs, registered by exec::init() |
| Lexical closure | Lambda captures Env clone at @ parse time; stored as Value::Lambda(Rc<dyn Fn>) |
| Recursion | call_user_function copies all Function/Lambda entries from caller’s env into local scope |
| Multi-return | Value::Tuple(Vec<Value>) returned and destructured by Stmt::MultiAssign |
Empty call f() | Parser injects Expr::Var("ans"); both call sites trim 1 extra arg silently |
New AST nodes and tokens
| Name | Kind | Description |
|---|---|---|
Stmt::FunctionDef | Statement | function [outs] = name(params) body end |
Stmt::Return | Statement | return inside a function |
Stmt::MultiAssign | Statement | [a, b] = expr destructuring |
Expr::Lambda | Expression | @(params) expr |
Token::At | Token | @ prefix for lambdas |
Signal::Return | Signal | propagates early return through exec_stmts |
New Value variants
| Variant | Description |
|---|---|
Value::Function { outputs, params, body_source } | Named user-defined function |
Value::Lambda(LambdaFn) | Anonymous function / closure |
Value::Tuple(Vec<Value>) | Internal multi-return value |
Example
Run the full demo:
ccalc examples/user_functions.calc
Phase 12.5 — Cell Arrays
Version: v0.17.0+005
Prerequisite: Phase 12 (lambdas needed for cellfun/arrayfun)
Trigger: Three features blocked on Value::Cell:
case {2, 3} (Phase 11.5b), varargin/varargout (deferred from Phase 12),
and cellfun/arrayfun (higher-order built-ins).
12.5a — Core cell array infrastructure
Value::Cell
Added to env.rs after Value::Tuple:
#![allow(unused)]
fn main() {
/// Heterogeneous 1-D container: each element may be any `Value`.
Cell(Vec<Value>),
}
Only 1-D cell vectors for now (Vec<Value>). 2-D cell arrays deferred.
New tokens and AST nodes
Tokens (parser.rs):
Token::LBrace({) andToken::RBrace(})
Expr variants (eval.rs):
#![allow(unused)]
fn main() {
CellLiteral(Vec<Expr>) // {e1, e2, e3}
CellIndex(Box<Expr>, Box<Expr>) // c{i}
FuncHandle(String) // @funcname
}
Stmt variant (parser.rs):
#![allow(unused)]
fn main() {
CellSet(String, Expr, Expr) // c{i} = v
}
Parser changes
parse_primary:{→ comma-separatedparse_logical_orelements →Expr::CellLiteral.- After parsing an identifier, if next token is
LBrace→Expr::CellIndex. c{i} = vdetected inparse()lookahead viatry_split_cell_assign(), producesStmt::CellSet.split_stmts()now tracksbrace_depthalongsidebracket_depthso;inside{...}is not treated as a statement separator.
Evaluator changes (eval.rs)
Expr::CellLiteral→Value::Cell(vals)Expr::CellIndex→ bounds-check (1-based), returns element valueExpr::FuncHandle(name)→Value::Lambdathat looks upnamein caller env then falls back to builtins
Executor changes (exec.rs)
Stmt::CellSet→ look up cell in env, update element at index, auto-grow if neededis_truthyextended:Value::Cell(v) => !v.is_empty()
Built-ins
| Function | Description |
|---|---|
iscell(v) | 1.0 if v is Value::Cell, else 0.0 |
cell(n) | Value::Cell of n slots, each Scalar(0.0) |
cell(m, n) | Value::Cell of m*n slots |
numel(c) | element count of a cell |
length(c) | same as numel for 1-D |
size(c) | [1, numel(c)] as 1×2 matrix |
Display
c =
{
[1,1]: 42
[1,2]: hello
[1,3]: [1×3 double]
}
12.5b — varargin / varargout
varargin
When a user function’s last parameter is named varargin, all extra call
arguments are collected into a Value::Cell and bound to varargin in the
local scope. Fixed parameters are bound normally.
function s = sum_all(varargin)
s = 0;
for k = 1:numel(varargin)
s += varargin{k};
end
end
sum_all(1, 2, 3) % varargin = {1, 2, 3} → 6
sum_all() % varargin = {} → 0
varargout
When the sole output variable is varargout, after the function body executes,
its cell elements are unpacked as return values:
function varargout = swap(a, b)
varargout{1} = b;
varargout{2} = a;
end
[x, y] = swap(10, 20) % x = 20, y = 10
Implementation note (injection fix, v0.17.0+005)
The parser previously injected Expr::Var("ans") into every empty f() call
at the AST level. This made sum_all() and sum_all(1) indistinguishable
inside call_user_function when fixed_params.is_empty(), causing varargin
to always be empty.
Fix: injection moved from parser to eval-time:
- Builtins and lambdas: inject
answhen called with empty args (f()=f(ans)) Value::Function(user functions): no injection — empty call = no arguments
12.5c — case {val1, val2} in switch
Completes Phase 11.5b. When a switch case value evaluates to Value::Cell,
the evaluator iterates its elements and tests each with == against the switch
expression. First match wins; no fall-through (same semantics as scalar case).
switch x
case {2, 3}
disp('two or three')
case {10, 20, 30}
disp('ten, twenty, or thirty')
end
No parser change needed: case {2, 3} was already parsed as Expr::CellLiteral
once LBrace/RBrace tokens existed (12.5a).
12.5d — cellfun and arrayfun
Higher-order built-ins that apply a function to each element of a collection.
cellfun(f, c)
Applies f to each element of cell c. Returns Value::Matrix when all
results are scalar; returns Value::Cell otherwise.
cellfun(@sqrt, {1, 4, 9}) % [1 2 3]
cellfun(@(x) x * 2, {1, 4, 9}) % [2 8 18]
arrayfun(f, v)
Applies f to each element of numeric vector v. Returns same-shape Value::Matrix.
arrayfun(@(x) x^2, [1 2 3 4]) % [1 4 9 16]
arrayfun(@abs, [-1 2 -3 4]) % [1 2 3 4]
Both implemented as cases in call_builtin; no new AST nodes.
@funcname function handles
@funcname (without parentheses) creates a Value::Lambda that forwards its
arguments to funcname — either a builtin or a named user function stored in
the environment.
Expr::FuncHandle(name) → Value::Lambda wrapping name lookup + call
The lambda captures the caller’s env at creation time. On each call it first
checks the captured env for a user function, then falls back to call_builtin.
f = @sqrt;
g = @myFunc; % wraps a user-defined function
cellfun(@sqrt, {1, 4, 9}) % works: @sqrt passes sqrt as a Lambda
Tests
17 new tests in parser_tests.rs covering:
- Cell literal and indexing
- Cell assignment and auto-grow
iscell,cell(),numel,lengthcellfun,arrayfunswitchwithcase {…}varargin- Out-of-bounds index error
Known limitations
- 2-D cell arrays are not supported (all cells are 1-D
Vec<Value>) c{i}is only supported whereiis a simple expression — postfix chainingc{k}(args)is not yet supported (usef = c{k}; f(args)instead)- Workspace save/load skips cell variables (same policy as matrices)
Phase 12.6 — Language Polish and Small Completions
Version: v0.18.0+001
Prerequisite: Phase 12.5 (cell arrays — required for strsplit return type)
This phase closes accumulated gaps that are each small in isolation but collectively leave visible holes in Octave/MATLAB compatibility.
12.6a — Single-line blocks
if, for, while, and switch can now appear on a single line with their
body and closing end separated by semicolons:
if x > 5; label = 'big'; end
for k = 1:5; total += k; end
while mod(n,2) == 0; n = n/2; end
switch day; case 1; name='Mon'; case 2; name='Tue'; otherwise; name='?'; end
Implementation: is_single_line_block(line) detects a complete block by
splitting on ; and checking whether the last segment’s leading keyword is
end or until. The REPL and pipe mode bypass block buffering for these lines.
12.6b — Line continuation ...
A line ending with ... (after stripping comments) continues on the next line:
result = 1 + ...
2 + ...
3; % result = 6
A = [1 2 3; ...
4 5 6]; % 2×3 matrix
if value > 0 && ...
value < 100
disp('in range')
end
Three implementation points:
- REPL:
cont_bufaccumulates partial lines; not dispatched until continuation ends. - Pipe/file mode: same
cont_buflogic inrun_pipe. - Block parser:
join_line_continuations()pre-pass inparse_stmtsjoins...-continued lines before statement splitting.
12.6c — Element-wise logical operators & and |
&& and || are short-circuit scalar operators. & and | are element-wise
operators that work on matrices and always evaluate both sides:
a = [1 0 1 0];
b = [1 1 0 0];
a & b % [1 0 0 0] element-wise AND
a | b % [1 1 1 0] element-wise OR
% Logical mask — common pattern
v = [3, -1, 8, 0, 5, -2, 7];
mask = v > 0 & v < 6 % [1 0 0 0 1 0 0]
New tokens Token::Amp / Token::Pipe and Op::ElemAnd / Op::ElemOr.
New parse levels parse_elem_or and parse_elem_and sit between
parse_logical_and and parse_comparison in the precedence hierarchy.
12.6d — xor and not built-ins
xor(1, 0) % 1
xor(0, 0) % 0
xor([1 0 1 0], [1 1 0 0]) % [0 1 1 0]
not(0) % 1 (alias for ~)
not(5) % 0
not([1 0 1]) % [0 1 0]
12.6e — Lambda source display
Lambdas now display their source expression instead of @<lambda>:
>> f = @(x) x^2 + 1
f = @(x) x ^ 2 + 1
>> g = @(a, b) sqrt(a^2 + b^2)
g = @(a, b) sqrt(a ^ 2 + b ^ 2)
>> h = @sin
h = @sin
LambdaFn carries a second field with the source string, populated at parse
time by expr_to_string() which reconstructs a readable expression from the AST.
12.6f — String utilities
% strsplit — returns a cell array
parts = strsplit('alpha,beta,gamma', ',')
parts{1} % 'alpha'
parts{2} % 'beta'
words = strsplit('hello world') % split on whitespace
numel(words) % 2
% int2str — round to integer, return string
int2str(3.2) % '3'
int2str(3.7) % '4'
int2str(-1.5) % '-2'
% mat2str — matrix to MATLAB literal string
mat2str([1 2; 3 4]) % '[1 2;3 4]'
mat2str([10 20 30]) % '[10 20 30]'
strsplit returns Value::Cell of Value::Str — requires Phase 12.5.
12.6g — .' non-conjugate transpose
A' is the conjugate transpose (Hermitian) — it flips the sign of complex imaginary
parts. A.' is the plain transpose with no conjugation:
% Real matrices — identical result
B = [1 2 3; 4 5 6];
B.' % [1 4; 2 5; 3 6] (same as B')
% Complex — different result
z = 3 + 4i
z' % 3 - 4i (conjugate)
z.' % 3 + 4i (plain)
Token::DotApostrophe is emitted when . is immediately followed by '.
Expr::PlainTranspose(Box<Expr>) evaluates identically to Expr::Transpose for
real values, but skips complex conjugation for Value::Complex.
12.6j — Minor syntax completions
Unary +
+x is a no-op (returns x unchanged). Previously caused a parse error.
+5 % 5
+(-3) % -3
+[1 2 3] % [1 2 3]
** exponentiation alias
Octave accepts ** as a synonym for ^:
2 ** 10 % 1024
3 ** 3 % 27
2 ** 0.5 % 1.41421...
, as non-silent statement separator
A comma between statements is like a newline — the result is shown (unlike ;
which suppresses output):
a = 1, b = 2 % shows: a = 1 then b = 2
a = 1; b = 2 % a silent, b shown
Bug fixes (v0.18.0+001)
-
4iimaginary literal —3 + 4inow works in pipe and file mode. The tokenizer’spush_imag_suffix()helper emits* itokens after any decimal literal followed immediately byiorj. -
B.';split incorrectly —split_stmtsnow recognises.as a transpose indicator, preventingB.'from being mis-parsed as a string start. -
...in pipe mode —run_pipenow has the samecont_buflogic asrun_repl, so multi-line scripts using...work correctly.
Example
ccalc examples/language_polish.calc
The example covers all ten sections with expected output annotations.
Phase 13 — Structs
Version: 0.19.0 Status: Complete (13a — scalar structs)
Motivation
Scalar structs are required by:
- Real
.mscripts that pass labelled data between functions - Phase 14 (
try/catch) — the caught exception objecteis a struct with fieldsmessageandidentifier dir()— returns a struct array of file entries (Phase 13.5, deferred)
13a — Scalar structs (complete)
Value type
Value::Struct(IndexMap<String, Value>) in env.rs. Uses the indexmap
crate to preserve insertion order (MATLAB-compatible). Added dependency:
# crates/ccalc-engine/Cargo.toml
indexmap = "2"
Token
Token::Dot emitted in the tokenizer only when . is followed by an ASCII
letter or underscore. The existing DotStar / DotSlash / DotCaret /
DotApostrophe tokens are unaffected — no ambiguity.
AST changes
| Node | Description |
|---|---|
Expr::FieldGet(Box<Expr>, String) | s.x — postfix field read; chained via a loop in parse_primary: s.a.b → FieldGet(FieldGet(Var("s"),"a"),"b") |
Stmt::FieldSet(String, Vec<String>, Expr) | s.x = rhs → ("s", ["x"], rhs); s.a.b = rhs → ("s", ["a","b"], rhs) |
Parser
try_split_field_assign() — byte-level string scan that detects the pattern
ident (.ident)+ = before tokenization. Called first in parse(), before
try_split_cell_assign.
parse_primary() has a postfix loop that handles Token::Dot after any
expression to build a FieldGet chain.
Execution — Stmt::FieldSet
Implemented in exec.rs:
- Remove the root variable from
Env(or start with an emptyIndexMapif the variable doesn’t exist yet). - Call
set_nested(map, path, value)— a recursive, ownership-by-value helper that walks theVec<String>path, creating intermediate structs where needed. - Re-insert the updated
Value::StructintoEnv. - Display the struct if not silent.
Built-ins
| Function | Behaviour |
|---|---|
struct('k1',v1,...) | Constructor; requires an even number of arguments; struct() returns empty struct |
fieldnames(s) | Returns Value::Cell of Value::Str names in insertion order |
isfield(s, 'x') | Scalar(1.0) or Scalar(0.0) |
rmfield(s, 'x') | Copy of struct without the named field; error if absent |
isstruct(v) | Scalar(1.0) if Value::Struct, else Scalar(0.0) |
struct() and the other struct built-ins skip the ans-injection logic
(zero-argument built-in calls normally inject ans as the first argument —
this is suppressed for struct/cell utilities via the no_ans_inject list in
eval.rs).
Display
s =
struct with fields:
x: 1
y: [1×3 double]
inner: [1×1 struct]
- Inline format (
format_value):[1×1 struct] - Full format (
format_value_full): thestruct with fields:block above - Nested struct fields: always shown inline as
[1×1 struct]
Exhaustive match coverage
Value::Struct(_) was added to every exhaustive match arm across
eval.rs, exec.rs, repl.rs, and repl_tests.rs:
- Arithmetic, comparison, unary ops → error
size/length/numel→ returns 1 /[1 1](treats struct as 1×1)eval_indexwith()→ helpful error messageis_truthy→true- Display arms in test harness → delegates to
format_value_full
Tests
19 regression tests in crates/ccalc-engine/src/parser_tests.rs:
| Test | What it checks |
|---|---|
test_struct_field_assign_basic | s.x = 42 stores scalar |
test_struct_field_read | s.x = 7; ans = s.x returns 7 |
test_struct_multiple_fields | Three fields stored correctly |
test_struct_field_overwrite | Re-assigning a field updates it |
test_struct_nested_assign | s.a.b = 5 creates nested struct |
test_struct_nested_read | s.a.b = 10; ans = s.a.b returns 10 |
test_struct_constructor_basic | struct('x',1,'y',2) |
test_struct_constructor_empty | struct() returns empty struct |
test_struct_fieldnames | Returns correct Cell of Str |
test_struct_isfield_true/false | Both cases |
test_struct_rmfield | Field removed, others intact |
test_struct_isstruct_true/false | Both cases |
test_struct_field_missing_error | Access of absent field → error |
test_struct_field_on_non_struct_error | .field on non-struct → error |
test_struct_constructor_odd_args_error | struct('x') → error |
test_struct_rmfield_missing_error | rmfield(s,'z') → error |
test_struct_field_insertion_order | IndexMap preserves order |
13b — Struct arrays (deferred → Phase 13.5)
s(i).field — indexing into a vector of structs. Required for e.stack in
catch e (Phase 14). Design decision deferred.
13c — Dynamic field access (deferred → §3)
fname = 'x';
v = s.(fname); % read via string variable
s.(fname) = 1; % write via string variable