Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ccalc

A fast terminal calculator with Octave/MATLAB syntax and script support — one binary, no runtime.

Octave is hundreds of megabytes. Python requires a runtime. ccalc is a single self-contained binary that starts instantly and works anywhere: interactive sessions, shell scripts, CI pipelines, Docker containers.

Quick start

# Interactive REPL
ccalc

# Single expression
ccalc "2 ^ 32"

# Script file
ccalc script.m

# Pipe mode
echo "sqrt(2)" | ccalc

Who is it for?

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

Project structure

CrateRole
crates/ccalcCLI binary: argument parsing, REPL, pipe mode
crates/ccalc-engineLibrary: tokenizer, parser, AST evaluator, variable environment

The engine crate is the computation foundation. It has no I/O dependencies and is the target for all Octave/MATLAB compatibility work (Phases 1–10).

Compatibility standard

Where MATLAB and Octave differ, ccalc follows the modern MATLAB standard (R2016b+). See Architecture → Overview for design principles.

Source

Getting Started

Installation

Build from source (requires Rust):

git clone https://github.com/holgertkey/ccalc
cd ccalc
cargo build --release
# binary: target/release/ccalc

Usage modes

Interactive REPL

ccalc

A prompt shows the current value of ans. Type an expression and press Enter. Type help for a reference, or help <topic> for a specific section.

[ 0 ]: 2 ^ 32
[ 4294967296 ]: / 1024
[ 4194304 ]: sqrt()
[ 2048 ]: help functions
...
[ 2048 ]: exit

Single expression (argument mode)

ccalc "EXPR"

Evaluates the expression, prints the result, and exits. Useful for shell scripts.

$ ccalc "2 ^ 32"
4294967296

$ ccalc "sqrt(2)"
1.4142135624

Script file

Pass a .m or .ccalc file as an argument:

ccalc script.m
ccalc examples/mortgage.ccalc

Pipe / non-interactive mode

When stdin is not a terminal, ccalc reads lines one by one and prints one result per line. ans carries over across lines.

$ echo "sin(pi / 6)" | ccalc
0.5

$ printf "100\n/ 4\n+ 5" | ccalc
100
25
30

$ ccalc < formula.txt

Command-line options

FlagDescription
-h, --helpPrint help and exit
-v, --versionPrint version and exit

REPL Mode

Start with ccalc (no arguments, stdin is a terminal).

Prompt

The prompt always shows the current value of ans:

[ 0 ]:
[ 42 ]:
[ 0xFF ]:

ans

Every expression result is stored in ans. Expressions that start with an operator use ans as the left-hand operand (partial expressions):

[ 0 ]: 100
[ 100 ]: * 2
[ 200 ]: + 50
[ 250 ]: / 5
[ 50 ]:

REPL commands

CommandAction
exit, quitQuit
clsClear the screen (also Ctrl+L)
help, ?Show cheatsheet
help <topic>Detailed help (see topic list below)
whoShow all defined variables
clearClear all variables
clear <name>Clear a single variable
pShow current decimal precision
p<N>Set precision to N decimal places (0–15)
hex / dec / bin / octSwitch display base
baseShow ans in all four bases
wsSave workspace to file
wlLoad workspace from file
disp(expr)Print value without updating ans
fprintf('fmt')Print formatted string (\n, \t, \\ supported)
configShow config file path and active settings
config reloadRe-read config.toml and apply changes

Help topics for help <topic>: syntax functions bases vars script matrices examples

Keyboard shortcuts

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

Silencing a line

Append ; to suppress output. For expressions, ans is still updated. For assignments, ans is never updated regardless of ;.

[ 0 ]: 0.06 / 12;          % expression — ans updated, output suppressed
[ 0.005 ]: rate = 0.07;    % assignment — silent, ans unchanged
[ 0.005 ]:

Multiple ;-separated statements on one line — all but the last are silent:

[ 0 ]: a = 1; b = 2; c = 3;    % all silent
[ 0 ]: a = 1; b = 2             % a = 1 silent, b = 2 shown
b = 2
[ 0 ]:

Configuration

Settings that persist across sessions (precision, default base) 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

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

For modulo use the mod(a, b) function. % is a comment character.

Comparison operators

Return 1.0 (true) or 0.0 (false). Work element-wise on matrices.

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

Logical operators

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

See Comparison & Logical Operators for full details.

Precedence (high → low)

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

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

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:

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

See Matrices for full details.

Functions & Constants

One-argument functions

FunctionDescriptionExample
sqrt(x)Square root of xsqrt(144)12
abs(x)Absolute value of xabs(-7)7
floor(x)Largest integer ≤ x (round toward −∞)floor(2.9)2
ceil(x)Smallest integer ≥ x (round toward +∞)ceil(2.1)3
round(x)Nearest integer; ties round away from zeroround(2.5)3
sign(x)−1 if x < 0, 0 if x = 0, 1 if x > 0sign(-5)-1
log(x)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 xexp(1)2.71828…
sin(x)Sine of x, where x is in radianssin(pi/6)0.5
cos(x)Cosine of x, where x is in radianscos(0)1
tan(x)Tangent of x, where x is in radianstan(pi/4)1
asin(x)Inverse sine of x ∈ [−1, 1]; result in [−π/2, π/2]asin(1) * 180/pi90
acos(x)Inverse cosine of x ∈ [−1, 1]; result in [0, π]acos(0) * 180/pi90
atan(x)Inverse tangent of x; result in (−π/2, π/2)atan(1) * 180/pi45

Notes

sqrt(x)x must be ≥ 0. Passing a negative value returns NaN (no error is raised).

floor, ceil, round — All three return a floating-point result, not an integer type.
round uses round-half-away-from-zero: round(0.5) → 1, round(-0.5) → -1.
Compare: floor(-2.5) → -3, ceil(-2.5) → -2, round(-2.5) → -3.

sign(x) — Returns NaN when x is NaN (sign of NaN is undefined).

log(x), 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

FunctionDescriptionExample
atan2(y, x)Four-quadrant inverse tangent; result in (−π, π]atan2(1,1)*180/pi45
mod(a, b)Remainder of a ÷ b; result has the sign of bmod(370, 360)10
rem(a, b)Remainder of a ÷ b; result has the sign of arem(-1, 3)-1
max(a, b)Larger of two scalar valuesmax(3, 7)7
min(a, b)Smaller of two scalar valuesmin(3, 7)3
hypot(a, b)Euclidean distance √(a²+b²), numerically stablehypot(3, 4)5
log(x, base)Logarithm of x to an arbitrary base (both must be > 0)log(8, 2)3

Notes

atan2(y, x) — First argument is y (numerator), second is x (denominator).
Returns a value in the range (−π, π], correctly determining the quadrant from the signs of both arguments.
atan2(0, -1) * 180/pi → 180, whereas atan(-0/-1) * 180/pi → 0.

mod(a, b) vs rem(a, b) — Both compute the remainder after division, but differ in sign when the operands have opposite signs:

mod(-1, 3)   →   2    (result has the sign of 3, in range [0, 3))
rem(-1, 3)   →  -1    (result has the sign of -1)

mod guarantees the result is in [0, b) for positive b, making it useful for angle wrapping and modular arithmetic. rem follows IEEE 754 remainder convention. b = 0 produces NaN.

max(a, b), min(a, b) — These two-argument forms work with scalars only. To find the maximum or minimum element of a vector or matrix, use the one-argument form max(v) / min(v) (see Vector & Data Utilities).

hypot(a, b) — Computes √(a²+b²) without intermediate overflow or underflow. Prefer hypot over sqrt(a^2 + b^2) when the values may be very large or very small.

log(x, base) — Both x and base must be positive; base must not equal 1. Negative or zero values return NaN or -Inf as with the single-argument form.

atan2(1, 1) * 180/pi   →   45
atan2(0, -1) * 180/pi  →  180
mod(370, 360)          →   10
mod(-1, 3)             →    2     (result in [0, 3))
rem(-1, 3)             →   -1     (same sign as dividend)
max(3, 7)              →    7
min(3, 7)              →    3
hypot(3, 4)            →    5
hypot(5, 12)           →   13
log(8, 2)              →    3     (log base 2 of 8)
log(100, 10)           →    2     (same as 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.

FunctionDescription
bitand(a, b)Bitwise AND of a and b
bitor(a, b)Bitwise OR of a and b
bitxor(a, b)Bitwise XOR of a and b
bitshift(a, n)Shift a left by n bits (n > 0) or right by `
bitnot(a)Bitwise NOT of a within a 32-bit window
bitnot(a, bits)Bitwise NOT of a within an explicit bits-wide window; bits ∈ [1, 53]

Notes

bitand, bitor, bitxor — Both arguments must be non-negative integers. Floating-point values are truncated toward zero before the operation.

bitshift(a, n) — Positive n shifts left (multiply by 2ⁿ); negative n shifts right (logical, fills with zeros). Returns 0 when |n| ≥ 64. The shift count n may be negative; a must be non-negative.

bitnot(a) — Flips all bits within a 32-bit window (Octave uint32 default). Result is 2³² − 1 − a for values that fit in 32 bits.

bitnot(a, bits) — Flips bits within a window of bits width. bits must be in [1, 53] (limited to the integer precision of IEEE 754 doubles). Result is 2^bits − 1 − a.

bitand(0xFF, 0x0F)      →   15
bitor(0b1010, 0b0101)   →   15
bitxor(0xFF, 0x0F)      →  240     (0xF0)
bitshift(1, 8)          →  256     (1 << 8)
bitshift(256, -4)       →   16     (256 >> 4)
bitnot(5, 8)            →  250     (~5 within 8 bits = 0b11111010)
bitnot(0, 32)           →  4294967295   (0xFFFFFFFF)

Combining shifts and masks:

bitshift(1, 4) - 1      →   15     (0b00001111 — 4-bit all-ones mask)
bitand(0xDEAD, 0xFF00)  →  56832   (0xDE00 — extract high byte)

Empty-argument shorthand

Calling a function with empty parentheses uses ans as the argument:

[ 144 ]: sqrt()      →  12     (same as sqrt(144))
[ -7 ]:  abs()       →   7
[ 0 ]:   sin()       →   0

Constants

NameValue
pi3.14159265358979…
e2.71828182845904…
nanIEEE 754 Not-a-Number — propagates through all arithmetic
infPositive infinity; use -inf for negative infinity
ansResult of the last expression

nan — Not a number. Any arithmetic operation involving nan returns nan. nan == nan evaluates to 0 (IEEE 754: NaN is never equal to itself). Use isnan(x) to test for NaN (see Vector & Data Utilities).

inf — Positive infinity. -inf is negative infinity. 1 / inf → 0, -inf < inf → 1, inf + inf → inf, inf - inf → nan.

ans — Holds the result of the most recent expression (assignments do not update it). ans can appear anywhere in an expression.

nan and inf are parser-level constants and cannot be overwritten by assignment.

nan + 5         % → NaN
nan == nan      % → 0   (IEEE 754: NaN is never equal to itself)
1 / inf         % → 0
-inf < inf      % → 1

ans can appear anywhere in an expression:

[ 9 ]: ans * 2 + 1    →  19
[ 9 ]: sqrt(ans)      →   3

Nesting

Functions can be nested freely:

sqrt(abs(-16))          →    4
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:

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

Mixed-base expressions work naturally:

0xFF + 0b1010        →  265
0x10 + 0o10 + 0b10   →   26

Display base

By default results are shown in decimal. Switch with a command (session-local):

CommandEffect
decDecimal (default)
hexHexadecimal
binBinary
octOctal

The display base persists until changed and affects both the prompt and all results. To make a non-decimal base the default across all sessions, set it in config.toml.

[ 0 ]: 255
[ 255 ]: hex
[ 0xFF ]: + 1
[ 0x100 ]: dec
[ 256 ]:

Inline base suffix

Write a base keyword after an expression to evaluate it and switch the display in one step:

[ 0 ]: 0xFF + 0b1010 hex
[ 0x109 ]:

base — show all representations

[ 10 ]: base
2  - 0b1010
8  - 0o12
10 - 10
16 - 0xA

base can also be used as an inline suffix to show one result in all four bases without changing the active display base:

[ 0 ]: 255 base
2  - 0b11111111
8  - 0o377
10 - 255
16 - 0xFF
[ 255 ]:

Mixed-base display conversion

When the active display base is non-decimal and the input contains literals in other bases, the expression is automatically reprinted with all literals converted to the active base before the result is shown:

[ 0b110 ]: 2 + 0b110 + 0xa
0b10 + 0b110 + 0b1010
[ 0b10010 ]:

Variables

ccalc supports named variables. Any valid identifier can store a value.

Assignment

Use name = expr to assign. Assignments never update ans (MATLAB semantics).

Without ;, the result is displayed:

[ 0 ]: rate = 0.06 / 12
rate = 0.005
[ 0 ]: n = 360
n = 360
[ 0 ]: factor = (1 + rate) ^ n
factor = 10.9357
[ 0 ]: 200000 * rate * factor / (factor - 1)
[ 1199.10 ]:

Append ; to suppress output:

rate = 0.06 / 12;
n = 360;

Using variables

Any defined variable can appear inside an expression:

[ 0 ]: rate = 0.07
rate = 0.07
[ 0 ]: 1000 * (1 + rate) ^ 10
[ 1967.1513573 ]:

ans

ans is the implicit result variable — set automatically after every standalone expression (not after assignments). It is initialized to 0 at startup.

Expressions starting with an operator use ans as the left-hand operand:

[ 0 ]: 100
[ 100 ]: / 4
[ 25 ]: + 5
[ 30 ]:

Empty-argument function calls use ans as the argument:

[ 144 ]: sqrt()      →  12     (same as sqrt(144))

Constants

pi and e are pre-defined read-only constants:

NameValue
pi3.14159265358979…
e2.71828182845904…

View and clear

CommandAction
whoShow all defined variables and their values
clearClear all variables
clear nameClear a single variable by name
[ 0 ]: x = 10
[ 0 ]: y = 3.14
[ 0 ]: x + y
[ 13.14 ]: who
ans = 13.14
x = 10
y = 3.14
[ 13.14 ]: clear x
[ 13.14 ]: who
ans = 13.14
y = 3.14
[ 13.14 ]: clear

Workspace persistence

CommandAction
wsSave all variables to ~/.config/ccalc/workspace.toml
wlLoad variables from file (replaces current workspace)

The workspace file is plain text, one name = value entry per line:

ans = 13.14
n = 360
rate = 0.005

Example — monthly mortgage

% REPL session
[ 0 ]: rate = 0.06 / 12;
[ 0 ]: n = 360;
[ 0 ]: factor = (1 + rate) ^ n;
[ 0 ]: 200000 * rate * factor / (factor - 1)
[ 1199.10 ]:

As a script file (assignments print unless ; suppresses them):

% Monthly mortgage payment
rate = 0.06 / 12;
n = 360;
factor = (1 + rate) ^ n;
200000 * rate * factor / (factor - 1)
fprintf('Monthly payment ($): ')
disp(ans)

Number Display Format

The format command controls how numbers are displayed in the REPL and script/pipe output. It does not affect computation — all arithmetic is done in f64 (IEEE 754 double precision).

Commands

CommandDescription
formatReset to short (5 significant digits)
format short5 significant digits, auto fixed/scientific
format long15 significant digits, auto fixed/scientific
format shortEAlways scientific, 4 decimal places
format longEAlways scientific, 14 decimal places
format shortGSame as short (MATLAB shortG alias)
format longGSame as long (MATLAB longG alias)
format bankFixed 2 decimal places (currency)
format ratRational approximation p/q
format hexIEEE 754 double-precision bit pattern (16 hex digits)
format +Sign only: + positive, - negative, space for 0
format compactSuppress blank lines between outputs
format looseAdd blank line after every output (default)
format NN decimal places (e.g. format 4)

Examples

>> format short
>> pi
3.1416

>> format long
>> pi
3.14159265358979

>> format shortE
>> pi
3.1416e+00

>> format bank
>> 1/3
0.33

>> format rat
>> pi
355/113

>> format hex
>> 1.0
3FF0000000000000

>> format +
>> [-2 0 5]
- +

>> format 4
>> 1/3
0.3333

Scope

format affects:

  • disp() output
  • Variable assignment display (x = 3.1416)
  • The REPL prompt value

format does not affect fprintf / sprintf — those functions use their own per-call format specifiers (e.g. %f, %e, %.3f).

Automatic scientific notation

In short and long modes, numbers switch to scientific notation when:

  • The exponent is less than −3 (e.g. 0.0011e-03)
  • The exponent is ≥ the number of significant digits

Persistent default

The default precision (used by format N and the startup default) is set in config.toml:

[display]
precision = 10

Note: format hex vs hex

These are different commands:

  • format hex — shows the IEEE 754 raw bit pattern of any floating-point number as 16 uppercase hex digits (e.g. 400921FB54442D18 for pi).
  • hex — switches the display base to hexadecimal for integer values (e.g. 0xFF255 shown as 0xFF).

Formatted Output

ccalc supports C-style formatted output via fprintf and sprintf, matching Octave/MATLAB semantics.

fprintf — print to stdout

fprintf(fmt, v1, v2, ...)

Prints formatted text to stdout. No return value — result display is suppressed. No newline is added automatically; include \n explicitly.

fprintf('pi = %.4f\n', pi)         % pi = 3.1416
fprintf('n = %d items\n', 42)      % n = 42 items

sprintf — format to string

s = sprintf(fmt, v1, v2, ...)

Same format engine as fprintf, but returns the result as a char array instead of printing it.

label = sprintf('R = %.1f Ohm', 47.5);
disp(label)      % R = 47.5 Ohm

Format specifiers

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

Width, precision, and flags

The general form is:

%[flags][width][.precision]specifier
FlagMeaning
-Left-align within field width
+Always show sign (+ or −)
0Zero-pad to field width
Space in place of + for non-negative values

Examples:

fprintf('%8.3f\n',   pi)     %      3.142
fprintf('%-10s|\n', 'hi')    % hi        |
fprintf('%+.4e\n', 1000)     % +1.0000e+03
fprintf('%05d\n',    42)     % 00042
fprintf('% d\n',      5)     %  5

Escape sequences

SequenceCharacter
\nNewline
\tTab
\\Backslash

Multiple arguments and repeat behaviour

When there are more arguments than conversion specifiers in the format string, the format string repeats for the remaining arguments (Octave behaviour):

fprintf('%d\n', 1, 2, 3)
% 1
% 2
% 3

fprintf('x=%.1f  y=%.1f\n', 1, 2, 3, 4)
% x=1.0  y=2.0
% x=3.0  y=4.0

Formatted data table example

fprintf('%6s %12s %12s\n', 'time', 'position', 'velocity')
fprintf('%6s %12s %12s\n', '(s)', '(m)', '(m/s)')
fprintf('%s\n', repmat('-', 1, 32))

t = 0:0.5:2;
pos = 0.5 * 9.81 * t.^2;
vel = 9.81 * t;

for k = 1:length(t)
  fprintf('%6.1f %12.3f %12.3f\n', t(k), pos(k), vel(k))
end

Output:

  time     position     velocity
   (s)          (m)        (m/s)
--------------------------------
   0.0        0.000        0.000
   0.5        1.226        4.905
   1.0        4.905        9.810
   1.5       11.036       14.715
   2.0       19.620       19.620

See also

  • format command — controls default display format for disp() and assignment output
  • help io — concise in-REPL reference
  • help script — full format specifier reference
  • examples/formatted_output.calc — runnable example covering all specifiers

Configuration

ccalc stores persistent settings in a plain-text TOML file:

~/.config/ccalc/config.toml          # Linux / macOS
%APPDATA%\ccalc\config.toml          # Windows

The file is created automatically with defaults the first time you start the interactive REPL. You can edit it with any text editor.

Default config.toml

# ccalc configuration
# Edit this file and run 'config reload' in the REPL to apply changes.

[display]
# Default decimal precision (number of digits after the decimal point, 0–15).
precision = 10

# Default number base for output: "dec", "hex", "bin", "oct"
base = "dec"

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

CommandAction
configShow config file path and currently active settings
config reloadRe-read config.toml and apply changes immediately

Changes made with p<N>, hex, dec, bin, oct during a session are session-local and are not written back to config.toml.

Example

Set precision to 4 and default base to hex, then apply without restarting:

  1. Edit config.toml:
    [display]
    precision = 4
    base = "hex"
    
  2. In the REPL:
    [ 0 ]: config reload
    Config reloaded.
    precision:   4
    base:        hex
    [ 0x0 ]:
    

Config file location

The config command shows the full path of the file on the current system:

[ 0 ]: config
config file: /home/user/.config/ccalc/config.toml
precision:   10
base:        dec

Matrices

ccalc supports matrix literals using Octave/MATLAB bracket syntax.

Creating matrices

Separate elements with spaces or commas; separate rows with 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

FunctionDescription
zeros(m, n)m×n matrix of zeros
ones(m, n)m×n matrix of ones
eye(n)n×n identity matrix
size(A)[rows cols] as a 1×2 row vector
size(A, dim)Rows (dim=1) or columns (dim=2) as scalar
length(A)max(rows, cols)
numel(A)Total element count
trace(A)Sum of diagonal elements
det(A)Determinant (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.

ConstantValue
nanIEEE 754 Not-a-Number
infPositive infinity (-inf for negative)
nan + 5         % → NaN    (NaN propagates through all arithmetic)
nan == nan      % → 0      (IEEE 754: NaN is never equal to itself)
inf * 2         % → inf
-inf < inf      % → 1

NaN predicates (element-wise)

FunctionSignatureReturns 1 when…
isnan(x)isnan(x)x is NaN
isinf(x)isinf(x)x is ±Inf
isfinite(x)isfinite(x)x is neither NaN nor ±Inf

All three accept a scalar or a matrix and apply element-wise, returning a result of the same shape with each element replaced by 1.0 (true) or 0.0 (false). Use these instead of == nan or == inf, which do not work as expected: nan == nan always returns 0.

isnan(nan)       % → 1
isinf(inf)       % → 1
isfinite(42)     % → 1
isfinite(nan)    % → 0

v = [1 nan 3 inf];
isnan(v)         % → [0 1 0 0]
isfinite(v)      % → [1 0 1 0]

NaN matrix constructor

nan(n)        % n×n matrix of NaN
nan(m, n)     % m×n matrix of NaN

nan(n) is shorthand for nan(n, n). The result is a matrix where every element is NaN, useful for pre-allocating arrays that must be filled before use.


Reductions

For vectors (1×N or N×1) these return a scalar.
For M×N matrices (M>1, N>1) they operate column-wise and return a 1×N row vector.

FunctionSignatureDescription
sum(v)sum(v)Sum of all elements
prod(v)prod(v)Product of all elements
mean(v)mean(v)Arithmetic mean (sum divided by element count)
min(v)min(v)Minimum element; for 2-scalar form see min(a, b)
max(v)max(v)Maximum element; for 2-scalar form see max(a, b)
any(v)any(v)1.0 if at least one element is non-zero, else 0.0
all(v)all(v)1.0 if every element is non-zero, else 0.0
norm(v)norm(v)Euclidean (L2) norm: √(Σxᵢ²)
norm(v, p)norm(v, p)General Lp norm; p = inf → max of absolute values

Notes

min(v), max(v) — The 1-argument form finds the extreme element of a vector or matrix. The 2-argument forms min(a, b) and max(a, b) compare two scalars. Both forms are available; which is used depends on the number of arguments.

any(v), all(v) — Treat any non-zero value (including negative numbers) as true, and zero as false. NaN is non-zero, so any([nan]) → 1 and all([0 nan]) → 0.

norm(v, p) — Common values of p:

  • p = 1 → L1 norm: sum of absolute values
  • p = 2 (default) → L2 Euclidean norm
  • p = inf → L∞ norm: maximum absolute value

For scalars, norm(x) returns abs(x).

v = [1 2 3 4 5];

sum(v)            % → 15
prod(v)           % → 120
mean(v)           % → 3
min(v)            % → 1
max(v)            % → 5
any(v > 4)        % → 1
all(v > 0)        % → 1
norm(v)           % → sqrt(1+4+9+16+25) ≈ 7.416
norm([3 4])       % → 5
norm([1 2 3], 1)  % → 6   (L1 = sum of absolute values)

Column-wise on a matrix:

M = [1 2 3; 4 5 6];
sum(M)    % → [5  7  9]    one sum per column
mean(M)   % → [2.5  3.5  4.5]
min(M)    % → [1  2  3]
max(M)    % → [4  5  6]

Cumulative operations

These return an array of the same shape as the input.

FunctionSignatureDescription
cumsum(v)cumsum(v)Running sum: element i = sum of first i elements
cumprod(v)cumprod(v)Running product: element i = product of first i

Both functions accept a scalar (returned unchanged) or a vector/matrix. For a matrix the operation runs along all elements in column-major order, returning a matrix of the same shape.

cumsum([1 2 3 4])    % → [1  3  6  10]
cumprod([1 2 3 4])   % → [1  2  6  24]

% Compound interest: balance after each year
rates = [1.05, 1.08, 1.03, 1.10];
cumprod(rates)       % → cumulative growth factors

Sorting and searching

FunctionSignatureDescription
sort(v)sort(v)Sort elements in ascending order; vectors only
find(v)find(v)1-based column-major indices of all non-zero elements
find(v, k)find(v, k)First k non-zero indices; k must be non-negative
unique(v)unique(v)Sorted unique elements as a 1×N row vector

Notes

sort(v) — Sorts in ascending order only. Accepts a scalar (returned unchanged) or a vector. Passing a 2D matrix (more than one row and more than one column) returns an error; use sort on individual rows or columns instead.

find(v) — Returns a row vector of 1-based indices of elements that are non-zero (including ±Inf and NaN). Indices follow column-major order (columns first), matching MATLAB/Octave convention. Returns an empty matrix [] when no elements match.

find(v, k) — Limits the result to the first k indices. k = 0 returns []. k must be a non-negative integer.

unique(v) — Returns a 1×N row vector of distinct values, sorted in ascending order. Accepts scalars, vectors, or matrices (elements are flattened in column-major order before deduplication).

v = [3 1 4 1 5 9 2 6];

sort(v)                    % → [1 1 2 3 4 5 6 9]
unique(v)                  % → [1 2 3 4 5 6 9]

find(v > 4)                % → [5  6  8]   indices where v > 4
find(v > 4, 2)             % → [5  6]      first 2 such indices

% Typical pattern: use find with a comparison mask
idx = find(v > 3);
v(idx)                     % → elements of v greater than 3

Reshape and flip

FunctionSignatureDescription
reshape(A, m, n)reshape(A, m, n)Reshape to m×n using column-major element order
fliplr(v)fliplr(A)Reverse column order (left↔right mirror)
flipud(v)flipud(A)Reverse row order (up↔down mirror)

Notes

reshape(A, m, n) — Rearranges the elements of A into a matrix with m rows and n columns. The total number of elements must be preserved: m * n must equal numel(A), otherwise an error is raised. Elements are read and written in column-major order (column by column), matching MATLAB/Octave.

fliplr(A) — Reverses the order of columns. For a row vector this reverses all elements. A scalar is returned unchanged.

flipud(A) — Reverses the order of rows. For a column vector this reverses all elements. A scalar is returned unchanged.

reshape(1:6, 2, 3)    % fills column-by-column:
                      % [1 3 5]
                      % [2 4 6]

reshape(1:6, 3, 2)    % [1 4]
                      % [2 5]
                      % [3 6]

fliplr([1 2 3])       % → [3 2 1]
fliplr([1 2 3; 4 5 6]) % → [3 2 1; 6 5 4]

flipud([1 2; 3 4])    % → [3 4; 1 2]

Example file

examples/vector_utils.calc demonstrates all of these features:

ccalc examples/vector_utils.calc

Comparison & Logical Operators

Comparison operators

Comparison operators evaluate to 1 (true) or 0 (false). They work on scalars and element-wise on matrices.

OperatorMeaning
==Equal
~=Not equal
<Less than
>Greater than
<=Less or equal
>=Greater or equal
3 > 2        % 1
3 == 4       % 0
5 ~= 3       % 1
4 <= 4       % 1

Comparison has lower precedence than arithmetic — operands are fully evaluated before the comparison:

1 + 1 == 2   % 1   (1+1 = 2, then 2 == 2)
2 * 3 > 5    % 1   (2*3 = 6, then 6 > 5)

Logical NOT — ~

~expr negates a truth value:

  • 01
  • any non-zero → 0
~0           % 1
~1           % 0
~(3 == 3)    % 0
~(3 ~= 3)    % 1

Short-circuit AND and OR — &&, ||

&& returns 1 when both operands are non-zero. || returns 1 when at least one operand is non-zero. && binds more tightly than ||. Both operators short-circuit and are intended for scalar conditions.

1 && 1       % 1
1 && 0       % 0
0 || 1       % 1
0 || 0       % 0

1 || 0 && 0  % 1    (1 || (0 && 0))

Combining conditions

x = 2.7;
x >= 0 && x <= 3.3    % 1  — in-range check
x < 0  || x > 3.3    % 0  — out-of-range flag

% Negate the condition:
~(x >= 0 && x <= 3.3) % 0  — fault flag (0 = OK)

Element-wise logical operators — &, |, xor, not

& and | are element-wise operators — they work on matrices, always evaluate both sides (no short-circuit), and return a 0/1 matrix:

a = [1 0 1 0];
b = [1 1 0 0];

a & b                  % [1 0 0 0]   element-wise AND
a | b                  % [1 1 1 0]   element-wise OR
xor(a, b)              % [0 1 1 0]   element-wise XOR

not(a)                 % [0 1 0 1]   element-wise NOT (alias for ~)

Use &/| for matrix logical masks; use &&/|| for scalar conditions in if.

Logical mask pattern

v = [3, -1, 8, 0, 5, -2, 7];

mask = v > 0 & v < 6   % [1 0 0 0 1 0 0]

Element-wise on matrices (comparison)

When one or both operands are matrices, all comparison operators apply element-wise and return a 0/1 matrix of the same size:

v = [1 2 3 4 5];

v > 3              % [0 0 0 1 1]
v <= 3             % [1 1 1 0 0]
v == 3             % [0 0 1 0 0]
~(v > 3)           % [1 1 1 0 0]

Scalar–matrix comparison broadcasts the scalar to every element:

3 < v              % [0 0 0 1 1]
v >= 3             % [0 0 1 1 1]

Soft masking

Because masks are 0/1 matrices, multiplying a matrix by its mask zeroes out the elements that failed the condition — a pattern often called soft masking or logical selection:

v = [1 2 3 4 5];

v .* (v > 3)             % [0 0 0 4 5]   keep elements > 3

% Keep elements in [2, 4]:
lo = v >= 2;
hi = v <= 4;
v .* (lo .* hi)          % [0 2 3 4 0]

lo .* hi works as element-wise AND because the values are already 0/1.

Precedence

From lowest to highest priority:

||          logical OR  (short-circuit)
&&          logical AND (short-circuit)
|           element-wise OR
&           element-wise AND
== ~= < > <= >=   comparison (non-associative)
:           range
+ -         additive
* / .* ./   multiplicative
^ .^ **     power (right-associative)
unary + - ~ negation / logical NOT
postfix ' .' transpose / plain transpose

REPL session

[ 0 ]: 3 > 2
[ 1 ]:
[ 0 ]: 5 ~= 5
[ 0 ]:
[ 0 ]: 2 > 1 && 10 > 5
[ 1 ]:
[ 0 ]: v = [10 20 30 40 50];
[ 0 ]: v > 25
ans =
   0   0   0   1   1
[ [1×5] ]: v .* (v > 25)
ans =
    0    0    0   40   50

See also

  • help logic — REPL reference with examples
  • ccalc examples/logic.calc — ADC validation and resistor tolerance demo
  • Matrices — element-wise operators

Complex Numbers

ccalc supports complex numbers using the same syntax as Octave/MATLAB. No special mode is needed — i and j are always available as the imaginary unit.

Creating complex numbers

3 + 4i           % 3 + 4i   — Ni suffix (no space before i/j)
3 + 4*i          % same — explicit multiply also works
3 + 4*j          % j is also the imaginary unit
complex(3, 4)    % construct from real and imaginary parts
5i               % pure imaginary: 5i
2 - 3i           % 2 - 3i

Ni suffix syntax: any decimal number immediately followed by i or j (no space, no further alphanumeric characters) is treated as a complex literal. The tokenizer expands 4i to 4 * i — the imaginary unit i must be in scope (it is always pre-seeded at startup).

Arithmetic

All standard operators work on complex numbers:

z1 = 3 + 4*i
z2 = 1 - 2*i

z1 + z2          % 4 + 2i
z1 - z2          % 2 + 6i
z1 * z2          % 11 - 2i     (a+bi)(c+di) = (ac-bd) + (ad+bc)i
z1 / z2          % -1 + 2i

Mixing complex and real scalars works naturally:

z1 + 10          % 13 + 4i
2 * z1           % 6 + 8i
z1 ^ 2           % -7 + 24i

When the imaginary part of a result is exactly zero, the value is shown and stored as a real scalar:

(1+i) * (1-i)    % 2   (not 2 + 0i)

Powers

Integer powers use binary exponentiation for exact results:

i^2              % -1    (exact)
i^3              % -i    (exact)
i^4              %  1    (exact)
(1+i)^4          % -4
(1+i)^-1         % 0.5 - 0.5i

Non-integer powers use the polar form exp((c+di)·ln(a+bi)):

i^0.5            % 0.7071067812 + 0.7071067812i   (sqrt of i)
2^(1+i)          % 1.5384778027 + 1.2779225526i

Polar form

Every complex number z = re + im*i has a polar representation z = r * (cos θ + i * sin θ), where r = abs(z) and θ = angle(z):

z = 3 + 4*i
abs(z)           % 5          (modulus |z| = sqrt(3² + 4²))
angle(z)         % 0.9272...  (argument in radians)
angle(z) * 180/pi  % 53.13°  (in degrees)

Reconstruct from polar:

r = abs(z);
t = angle(z);
complex(r*cos(t), r*sin(t))   % 3 + 4i

Built-in functions

FunctionDescription
real(z)Real part (real(5) → 5, real(3+4i) → 3)
imag(z)Imaginary part (imag(5) → 0, imag(3+4i) → 4)
abs(z)Modulus (also works on real scalars and matrices)
angle(z)Argument in radians
conj(z)Complex conjugate: re - im*i
complex(re, im)Construct from two real scalars
isreal(z)1 if imaginary part is zero, else 0
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:

TypeSyntaxSemantic
Char array'single quotes'1×N array of characters, numeric-compatible
String object"double quotes"Scalar string, concatenation with +

Char arrays

A char array is a 1×N row of character codes. Single quotes delimit it. Inside a char array, '' (two consecutive single quotes) represents a literal single quote character.

[ 0 ]: s = 'Hello'
s = Hello
[ 'Hello' ]: length(s)
[ 5 ]: size(s)
ans =
   1   5

Arithmetic — characters as ASCII codes

Char arrays convert to their ASCII codes before any arithmetic operation. The result is a numeric scalar or row vector, not a string.

[ 0 ]: 'A' + 0        % ASCII code of 'A'
[ 65 ]:
[ 0 ]: 'a' + 1        % shift by one position
[ 98 ]:
[ 0 ]: 'abc' + 0      % codes for 'a', 'b', 'c'
ans =
   97   98   99
[ 0 ]: 'abc' + 1      % shift every character
ans =
   98   99   100

Element-wise comparison returns a 0/1 row vector:

[ 0 ]: 'abc' == 'aXc'
ans =
   1   0   1

Escaped single quote

Use '' inside a char array to include a literal ':

[ 0 ]: disp('it''s fine')
it's fine

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:

ModeDescription
'r'Read (file must exist)
'w'Write — create or truncate
'a'Append — create if missing
'r+'Read and write

fopen returns a file descriptor (integer ≥ 3) on success, or -1 on failure.

File descriptor 1 is stdout and 2 is stderr — they can be used with fprintf directly.

Reading lines

fd = fopen('data.txt', 'r');
line1 = fgetl(fd);    % strip trailing newline; returns -1 at EOF
line2 = fgetl(fd);
raw   = fgets(fd);    % keep trailing newline
fclose(fd);

Closing all handles

fclose('all');    % close every open file handle

Delimiter-separated data

Write and read numeric matrices as CSV or TSV files:

data = [0, 3.30, 0.012; 0.5, 3.28, 0.015; 1.0, 3.25, 0.018];

dlmwrite('measurements.csv', data);          % comma-separated (default)
dlmwrite('measurements.tsv', data, '\t');    % tab-separated

loaded = dlmread('measurements.csv');        % auto-detect delimiter
loaded = dlmread('measurements.tsv', '\t'); % explicit delimiter

dlmread returns a Matrix. All values in the file must be numeric; non-numeric data returns an error with the offending line number.

Auto-detection order: try , first, then \t, then whitespace.


Filesystem queries

Check whether a file or directory exists before opening it:

if isfile('data.csv')
    data = dlmread('data.csv');
end

isfolder('output/')    % 1 if directory exists, 0 otherwise

cwd = pwd()            % current working directory as a char array

exist checks variables or files:

exist('x', 'var')      % 1 if variable x is in the workspace, 0 otherwise
exist('log.txt', 'file')  % 2 if file found, 0 otherwise
exist('x')             % checks workspace first, then filesystem

The numeric codes for exist match MATLAB: 1 = variable, 2 = file.


Workspace with explicit path

Save and load workspace variables to a named file instead of the default path:

R = 4700;
C = 220e-9;
label = 'RC filter';

save('session.mat');                    % save all to named file
save('session.mat', 'R', 'C');         % save specific variables only

clear R
clear C

load('session.mat');                    % load back

fprintf('R = %g\n', R)

The path argument can be a variable holding the path string:

path = 'session.mat';
save(path);
load(path);

save and load without arguments use the default workspace path ~/.config/ccalc/workspace.toml — the same as ws and wl.

What gets saved

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

Example

The examples/file_io.calc file demonstrates all File I/O features end-to-end:

ccalc examples/file_io.calc

It covers: filesystem queries, writing to files with fprintf, line-by-line reading with fgetl/fgets, CSV/TSV with dlmread/dlmwrite, append mode, save/load with explicit paths and variable selection, and fopen error handling.

Control Flow

ccalc supports multi-line control flow blocks in both the interactive REPL and in script/pipe mode. All block constructs use end as the closing keyword.

REPL multi-line input

The REPL detects unclosed blocks and buffers incoming lines, displaying a continuation prompt >> until the block is complete. Press Ctrl+C to cancel an incomplete block.

[ 0 ]:   for k = 1:3
  >>   fprintf('%d\n', k)
  >> end
1
2
3

if / elseif / else

score = 73;
if score >= 90
  grade = 'A';
elseif score >= 80
  grade = 'B';
elseif score >= 70
  grade = 'C';
elseif score >= 60
  grade = 'D';
else
  grade = 'F';
end
fprintf('score %d -> grade %s\n', score, grade)

A condition is truthy when:

Value typeTruthy when
Scalarnon-zero and not NaN
Matrixall elements non-zero and not NaN
Str/StringObjnon-empty
Voidnever

for

for var = range_expr
  % body
end

The range expression is evaluated once. Iteration is column-by-column:

  • Row vector → each element as a scalar
  • M×N matrix → each column as an M×1 column vector
% Simple range
for k = 1:5
  fprintf('%d\n', k)
end

% Step range
for k = 10:-2:0
  fprintf('%d ', k)   % 10 8 6 4 2 0
end

while

while cond
  % body
end
x = 1.0;
while abs(x ^ 2 - 2) > 1e-12
  x = (x + 2 / x) / 2;
end
fprintf('sqrt(2) ≈ %.15f\n', x)

break and continue

break exits the innermost loop immediately. continue skips to the next iteration.

for n = 1:20
  if mod(n, 2) == 0
    continue        % skip even numbers
  end
  if n > 9
    break           % stop after first odd > 9
  end
  fprintf('%d ', n)   % 1 3 5 7 9
end

Compound assignment operators

OperatorEquivalent to
x += ex = x + e
x -= ex = x - e
x *= ex = x * e
x /= ex = x / e
x++x = x + 1
x--x = x - 1
++xx = x + 1
--xx = x - 1

All forms desugar at parse time to a plain Stmt::Assign — no new AST nodes. The RHS is a full expression: x *= 2 + 3 desugars to x = x * (2 + 3).

Limitation: ++/-- are statement-level only. Using them inside a larger expression (b = a - b--) is not supported.

switch / case / otherwise

switch expr
  case val1
    % ...
  case val2
    % ...
  otherwise     % optional
    % ...
end

No fall-through — only the first matching case executes. Works with scalars (exact ==) and strings (Str/StringObj interchangeable). break/continue propagate to the nearest enclosing loop.

switch code
  case 200
    msg = 'OK';
  case 404
    msg = 'Not Found';
  otherwise
    msg = 'Unknown';
end
fprintf('%d: %s\n', code, msg)

do…until

Octave post-test loop — body always runs at least once:

do
  body
until (cond)

Parentheses around cond are optional. Closed by until, not end. break and continue work as in while.

x = 1;
do
  x *= 2;
until (x > 100)
fprintf('%d\n', x)   % 128

run() / source()

Execute a script file in the current workspace. Variables defined in the script persist in the caller’s scope (MATLAB run semantics — not a function call):

a = 252; b = 105;
run('euclid_helper')        % looks for euclid_helper.calc, then .m
fprintf('gcd = %d\n', g)    % g was set by the helper

source('euclid_helper')     % Octave alias — identical behaviour

Extension resolution for bare names: .calc is tried first (native ccalc format), then .m (Octave/MATLAB compatibility).

Examples

See the example scripts for self-contained demos:

ccalc examples/control_flow.calc           # if/for/while/break/continue
ccalc examples/extended_control_flow.calc  # switch/do-until/run/source

User-defined Functions

ccalc supports user-defined named functions, multiple return values, and anonymous functions (lambdas) using Octave/MATLAB syntax.

Named functions

function result = name(p1, p2)
  ...
  result = expr;
end

Define a function at the top level in the REPL or in a .calc / .m script file. 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 indexing c{i} is required to get the element’s value.

Assigning to elements

c{2} = 'world';      % replace existing element
c{5} = pi;           % auto-grows: elements 4–5 are zero-filled
numel(c)             % 5

Predicates and size

FunctionDescription
iscell(c)1 if c is a cell array, else 0
numel(c)Number of elements
length(c)Same as numel(c) for 1-D cells
size(c)[1 numel(c)] as a 1×2 matrix

varargin — variadic input

Declare the last parameter as varargin to collect all extra call arguments into a cell array:

function s = sum_all(varargin)
  s = 0;
  for k = 1:numel(varargin)
    s += varargin{k};
  end
end

sum_all(1, 2, 3)        % 6
sum_all(10, 20)         % 30
sum_all()               % 0  (empty varargin cell)

Fixed and variadic parameters can be mixed:

function show(label, varargin)
  fprintf('[%s]', label)
  for k = 1:numel(varargin)
    fprintf(' %g', varargin{k})
  end
  fprintf('\n')
end

show('A', 1, 2, 3)    % [A] 1 2 3
show('B', 100)         % [B] 100

varargout — variadic output

Declare the sole output variable as varargout (a cell array) and the caller receives one output value per cell element:

function varargout = first_n(v, n)
  for k = 1:n
    varargout{k} = v(k);
  end
end

[a, b, c] = first_n([10 20 30 40], 3)   % a=10  b=20  c=30

case {v1, v2} — multi-value switch cases

Inside a switch block, a cell array case matches if the switch expression equals any element of the cell:

switch x
  case {1, 2, 3}
    disp('small')
  case {4, 5, 6}
    disp('medium')
  otherwise
    disp('large')
end

cellfun — apply a function to a cell

cellfun(f, c) applies f to each element of cell c. Returns a Matrix when all results are scalar; otherwise returns a Cell.

c = {1, 4, 9, 16, 25};
cellfun(@sqrt, c)             % [1  2  3  4  5]
cellfun(@(x) x * 2, c)       % [2  8  18  32  50]

arrayfun — apply a function to a numeric vector

arrayfun(f, v) applies f to each element of matrix v. Returns a same-shape matrix (function must return a scalar per element).

arrayfun(@(x) x^2, [1 2 3 4])        % [1  4  9  16]
arrayfun(@(x) x > 2, [1 2 3 4])      % [0  0  1   1]

@funcname — function handles

@funcname creates a callable that forwards its arguments to funcname. Works with any built-in or user-defined function:

f = @sqrt;
g = @abs;

f(16)     % 4
g(-7.5)   % 7.5

cellfun(@sqrt, {1, 4, 9})   % [1  2  3]
arrayfun(@abs, [-1 -2 3])   % [1  2  3]

Compose handles via a capturing lambda:

compose = @(f, g) @(x) f(g(x));
sqrt_abs = compose(@sqrt, @abs);
sqrt_abs(-9)    % 3   ( sqrt(abs(-9)) )

Function pipelines

Store a sequence of function handles in a cell array and apply them in order:

function y = apply_pipeline(x, pipeline)
  y = x;
  for k = 1:numel(pipeline)
    f = pipeline{k};
    y = f(y);
  end
end

pipeline = {@(x) x + 1, @(x) x * 2, @sqrt};
apply_pipeline(5, pipeline)   % sqrt((5+1)*2) = sqrt(12) ≈ 3.4641

Workspace

Cell arrays are not saved by ws/save — same policy as matrices and complex values. who shows them as:

c = {1×4 cell}

See also

  • help cells — in-REPL reference
  • help userfuncs — varargin/varargout in the context of user functions
  • ccalc examples/cell_arrays.calc — annotated 9-section example

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

FunctionDescription
fieldnames(s)Cell array of field names in insertion order
isfield(s, 'x')1 if field 'x' exists, else 0
rmfield(s, 'x')Copy of s with field 'x' removed; error if absent
isstruct(v)1 if v is a struct, else 0
s.a = 1;  s.b = 2;  s.c = 3;

fn = fieldnames(s)       % {'a'; 'b'; 'c'}
fn{1}                    % a
numel(fn)                % 3

isfield(s, 'b')          % 1
isfield(s, 'z')          % 0

s2 = rmfield(s, 'b');
fieldnames(s2)           % {'a'; 'c'}

isstruct(s)              % 1
isstruct(42)             % 0

Display

s =

  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 reference
  • help cells — cell arrays, varargin/varargout
  • ccalc 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

ModuleResponsibility
main.rsParse CLI args, detect stdin mode (REPL / pipe / file / arg), dispatch
repl.rsREPL event loop, pipe line-reader, shared evaluate(), display logic
help.rsStatic help string
env.rsEnv type (HashMap<String, f64>), workspace save/load to disk
eval.rsExpr AST, Op, Base; eval(), format_value(), format_number()
parser.rsTokenizer, 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-engine has no I/O, no terminal codes, no rustyline. The binary owns all user-facing interaction.
  • Modern MATLAB standard (R2016b+). Where MATLAB and Octave differ, ccalc follows modern MATLAB. Example: 'text' is a char array (numeric), "text" is a string object.
  • Version is defined once in [workspace.package]; both crates inherit it.
  • No runtime allocations on the hot path beyond the input string itself.

Engine Crate (ccalc-engine)

The ccalc-engine crate is a pure computation library with no 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:

LiteralExample
Decimal3.14, 100
Scientific1e5, 2.5e-3, 1E+10
Hexadecimal0xFF, 0X1A
Binary0b1010, 0B11
Octal0o17, 0O377

Scientific notation uses lookahead to avoid treating e (Euler’s number) as an exponent when it appears as a standalone identifier.

Grammar

expr   = term ( ('+' | '-') term )*
term   = 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 acc identifiers
  • Substituted for empty-argument function calls: sqrt()sqrt(acc)
  • Used for postfix %: 20%20 * (acc / 100)

Entry points

#![allow(unused)]
fn main() {
// Full parse — returns Err if any tokens remain after the expression
pub fn parse(input: &str, accumulator: f64) -> Result<Expr, String>

// True if input starts with an operator (caller should prepend acc)
pub fn is_partial(input: &str) -> bool
}

Evaluator (eval.rs)

The evaluator walks an Expr AST and produces a Value, given a variable environment.

Value type

#![allow(unused)]
fn main() {
// defined in env.rs
pub enum Value {
    Scalar(f64),
    Matrix(ndarray::Array2<f64>),
}

pub type Env = HashMap<String, Value>;
}

Scalar is the common case. Matrix was introduced in Phase 3.

AST types

#![allow(unused)]
fn main() {
pub enum Expr {
    Number(f64),
    Var(String),
    UnaryMinus(Box<Expr>),
    BinOp(Box<Expr>, Op, Box<Expr>),
    Call(String, Vec<Expr>),
    Matrix(Vec<Vec<Expr>>),   // outer = rows, inner = elements per row
}

pub enum Op {
    Add, Sub, Mul, Div, Pow,
}
}

eval(expr, env) semantics

VariantSemantics
Number(n)Returns Scalar(n)
Var(name)Clones value from env; Err if not defined
UnaryMinus(e)Negates scalar or every matrix element
BinOp(l, Div, r)Err if scalar divisor is 0.0
BinOp(l, Pow, r)Uses f64::powf for scalars; element-wise for matrix ^ scalar
Call(name, args)Dispatches to built-in; currently all built-ins require scalar args
Matrix(rows)Evaluates each element (must be scalar), builds Array2

Scalar × Matrix arithmetic

LeftOpRightResult
Scalar+ - * /Matrixelement-wise broadcast
Matrix+ - * / ^Scalarelement-wise broadcast
Matrix+ -Matrixelement-wise (shapes must match)
Matrix* / ^Matrixerror — matrix multiplication is Phase 4

Number display

Three formatters serve different purposes:

format_value(v, precision, base) — compact single-line

For scalars: respects the active display base (decimal, hex, bin, oct). For matrices: returns [N×M double].

Used in REPL prompts, who output, and assignment echo for scalars.

format_scalar(n, precision, base) — scalar-only

Same as format_value for Scalar values. Used where a scalar is guaranteed (prompt display, base conversion output).

format_value_full(v, precision) — full multi-line

Returns None for scalars. For matrices returns a right-aligned column string:

   1   2   3
   4   5   6

Used by the REPL and pipe runner when printing matrix results.

format_number(n) — internal / re-parsing

Always decimal, always round-trips through the parser. Used by repl.rs when displaying partial-expression context (e.g. expanding variable names).

Base enum

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum Base { Dec, Hex, Bin, Oct }
}

Carried in repl.rs as the active display mode, passed to format_scalar at output time, switched by hex, dec, bin, oct commands.

Octave Compatibility Roadmap

ccalc aims for maximum practical compatibility with Octave/MATLAB .m files. The work is divided into phases in order of architectural dependency.

Phase summary

PhaseGoalStatus
1Variables and assignment (x = 5, who, clear, ws/wl)✅ Done
2Multi-argument functions (atan2, mod, max, min)✅ Done
3Matrix literals ([1 2 3], [1; 2; 3])✅ Done
4Matrix operations (A * B, A', A .* B)✅ Done
5Range operator (1:5, 1:2:10, linspace)✅ Done
6Indexing (A(1,1), v(2:4))✅ Done
7Comparison and logical operators (==, ~=, &&)✅ Done
7.5Vector utilities, end indexing, NaN/Inf, sort, find✅ Done
8Complex numbers (3 + 4i, abs(z), angle(z))✅ Done
9String data types ('char array', "string object")✅ Done
10C-style I/O (fprintf('%.2f\n', x), sprintf)✅ Done
10.5File I/O (fopen, dlmread, isfile, save/load with path)✅ Done
11Core control flow (if, for, while, break, continue, +=)✅ Done
11.5Extended control flow (switch, do...until, run/source; try/catch deferred to Phase 14)✅ Done
12User-defined functions, multiple return values, @(x) lambdas✅ Done
12.5Cell arrays, varargin/varargout, cellfun/arrayfun, @funcname✅ Done
12.6Language polish: &/|, ..., single-line blocks, .', **, string utils✅ Done
13Scalar structs (s.field, struct(), fieldnames, isfield, rmfield)✅ Done

Key architectural decisions

Phase 1 introduced Env (HashMap<String, f64>) and Stmt (assignment vs expression), which are load-bearing for every subsequent phase.

Phase 2 migrated Expr::Call(String, Box<Expr>) to Expr::Call(String, Vec<Expr>) and introduced call_builtin dispatch. New functions: atan2, mod, rem, max, min, hypot, log(x,base), asin, acos, atan, sign. Empty args fn() still passes ans.

Phase 3 adds ndarray and a Value enum (Scalar(f64) | Matrix(...)), migrating Env from f64 to Value. Matrix literals [1 2; 3 4], element-wise arithmetic with scalars, and matrix +/- are implemented. split_stmts() in repl.rs became bracket-depth-aware so ; inside [...] is parsed as a row separator, not a statement separator.

Phase 4 adds matrix multiplication (A * B via ndarray .dot()), postfix transpose (A' — new token Apostrophe, new Expr::Transpose), and element-wise operators .*, ./, .^ (new tokens DotStar, DotSlash, DotCaret). New built-ins: zeros(m,n), ones(m,n), eye(n), size(A), size(A,dim), length(A), numel(A), trace(A), det(A) (Gaussian elimination), inv(A) (Gauss-Jordan). split_stmts() updated to distinguish transpose ' from string-literal ' by left-context. call_builtin refactored to return Result<Value, String> directly.

Phase 5 adds Token::Colon and Expr::Range(start, step?, stop). A new parse_range() layer sits above parse_expr() with lower precedence, so 1+1:5 = 2:5. The Expr::Matrix evaluator is updated to concatenate row-vector elements horizontally, making [1:5] work. New built-in: linspace(a, b, n).

Phase 6 adds Expr::Colon and parse_call_arg(). The Expr::Call evaluator checks Env first: if the name resolves to a variable, the expression is treated as indexing (variables shadow built-in function names, matching Octave semantics). eval_index() + resolve_dim() handle 1D (column-major linear) and 2D indexing, all 1-based. A bug fix also landed here: range expressions inside grouping parentheses (a:b) now parse correctly.

Phase 7 adds comparison tokens (==, ~=, <, >, <=, >=) and logical operators (~, &&, ||). Comparisons return 0.0/1.0 and work element-wise on matrices. New parse levels parse_logical_orparse_logical_andparse_comparison sit above parse_range in the precedence hierarchy. Expr::UnaryNot and Op::Eq/NotEq/Lt/Gt/LtEq/GtEq/And/Or are added to the AST.

Phase 7.5 adds special floating-point constants (nan, inf as parser-level constants), isnan/isinf/isfinite built-ins, vector reductions (sum, prod, cumsum, any, all, 1-arg min/max, mean, norm), the end keyword in indexing contexts (v(end), A(1:end, 2)env_with_end() injects the dimension size into a cloned env before evaluating index expressions), and data utility functions (sort, reshape, fliplr, flipud, find, unique). No new Value variants are needed.

Phase 8 adds Value::Complex(f64, f64) as a third Value variant. No new tokens are required — 4i already parses as implicit multiplication 4 * i, where i and j are pre-seeded in Env as Complex(0.0, 1.0). complex_binop() handles all arithmetic combinations; integer powers use binary exponentiation for exact results (i^2 = -1 exactly); non-integer powers use the polar form exp((c+di)·ln(a+bi)). make_complex(re, im) collapses to Scalar(re) when im == 0.0 exactly. z' (conjugate transpose) returns the complex conjugate for scalar complex. Built-ins added: real, imag, abs (overloaded), angle, conj, complex, isreal. scalar_arg accepts Complex with im == 0 as a real scalar. Complex matrices are out of scope and deferred.

Phase 9 adds two string value types. Value::Str(String) represents single-quoted char arrays; Value::StringObj(String) represents double-quoted string objects. The ' disambiguation — transpose vs char array literal — is resolved by one token of left context in the tokenizer: after Number/Ident/RParen/RBracket/Apostrophe/Str it is a transpose; otherwise it opens a char array. Char array arithmetic converts characters to their ASCII codes before the operation, matching MATLAB behaviour. String objects use + for concatenation and ==/~= for comparison. New built-ins: num2str, str2num, str2double, strcat, strcmp, strcmpi, lower, upper, strtrim, strrep, sprintf (1-arg), ischar, isstring. length/numel/size updated for strings.

Phase 10 adds fprintf(fmt, ...) and sprintf(fmt, ...) using the string infrastructure from Phase 9. The ad-hoc p/p<N> precision command is deprecated and removed in this phase. Placed before control flow so that loop scripts have formatted output from the start.

Phase 10 adds fprintf(fmt, ...) and sprintf(fmt, ...). The ad-hoc p/p<N> precision command is removed. Phase 10.5 extends this with file I/O (fopen/fclose/fgetl/fgets), data files (dlmread/dlmwrite), filesystem queries (isfile, isfolder, pwd, exist), and workspace persistence with explicit paths (save('f.mat'), load('f.mat')).

Phase 11 adds multi-line REPL buffering and core control flow constructs: if/elseif/else, for (column iteration), while, break, continue. Compound assignment operators (+=, -=, *=, /=, ++, --) are desugared at parse time to plain Stmt::Assign — no new AST nodes. Syntax aliases: # for % (comment), ! for ~ (NOT), != for ~=. Extended control flow (switch, do...until, run/source) was completed in Phase 11.5.

Phase 11.5 adds switch/case/otherwise (no fall-through; scalar exact ==, string equality), do...until (Octave post-test loop; until closes the block without end), and run()/source() script sourcing (MATLAB run semantics — scripts execute in the caller’s workspace). Extension resolution for bare names tries .calc (native ccalc format) before .m (Octave/MATLAB compatibility). A thread_local! RUN_DEPTH counter caps recursion at 64 levels. try/catch is deferred to Phase 14 (after structs).

Phase 12 adds user-defined functions with single and multiple return values ([a, b] = f(x)), and anonymous functions @(x) expr (closures).

Named functions use function [out1, out2] = name(p1, p2) ... end syntax and are stored as Value::Function { outputs, params, body_source } in Env. body_source is stored as a string and re-parsed on each call to avoid a circular dependency between eval.rs and parser.rs. Anonymous functions are stored as Value::Lambda(Rc<dyn Fn>) — a closure compiled at definition time that captures the enclosing environment lexically. A thread-local FnCallHook bridges eval.rs (which dispatches the call) and exec.rs (which executes the body in an isolated scope). Each call gets a fresh environment seeded with i, j, ans, the declared parameters, nargin, and all callable values (Function/Lambda) from the caller’s workspace — the last point enables self-recursion and mutual recursion without exposing caller data. Stmt::FunctionDef, Stmt::Return, Stmt::MultiAssign, and Token::At were added to the parser; Signal::Return to the executor.

Phase 12.5 adds Value::Cell(Vec<Value>) — a heterogeneous 1-D cell array. Cell literals {e1, e2}, brace indexing c{i}, and brace assignment c{i} = v use new Token::LBrace/RBrace and Expr::CellLiteral/CellIndex/Stmt::CellSet. varargin/varargout collect extra call arguments into a Value::Cell. case {v1, v2} multi-value switch cases iterate the cell and test each element. cellfun(f, c) and arrayfun(f, v) apply a function to each element. @funcname desugars to Expr::FuncHandle(name) — a lambda wrapping any named function. split_stmts() updated to track brace depth so ; inside {...} is not a separator.

Phase 12.6 delivers language polish across nine sub-items (12.6h deferred):

  • 12.6a Single-line blocks: if cond; body; endis_single_line_block() detects self-contained blocks; REPL/pipe bypass the block buffer for them.
  • 12.6b ... line continuation: cont_buf in REPL and run_pipe; join_line_continuations() pre-pass in parse_stmts; tokenizer drains rest of line.
  • 12.6c &/| element-wise logical: new Token::Amp/Pipe, Op::ElemAnd/ElemOr, and parse_elem_or/parse_elem_and precedence levels between parse_logical_and and parse_comparison.
  • 12.6d xor(a,b) and not(a) built-ins.
  • 12.6e Lambda display: LambdaFn carries a source string; expr_to_string() helper reconstructs readable source text from the AST at parse time.
  • 12.6f strsplit(s[,delim]), int2str(x), mat2str(A) built-ins.
  • 12.6g .' plain transpose: Token::DotApostrophe, Expr::PlainTranspose — no complex conjugation (contrast with ' which is the Hermitian conjugate transpose).
  • 12.6j Unary + (no-op), ** alias for ^ (Octave), , non-silent separator.
  • Bug fixes: 4i imaginary literal now works via tokenizer push_imag_suffix(); split_stmts ' disambiguation extended to recognise . as a transpose indicator (fixing B.'; mis-parse); run_pipe gained cont_buf for ... continuation.

Phase 13 adds Value::Struct(IndexMap<String, Value>) — scalar structs with insertion-order-preserving fields (using the indexmap crate). Token::Dot is emitted only when . is followed by an ASCII letter/underscore, leaving DotStar/DotSlash/DotCaret/DotApostrophe unaffected. Expr::FieldGet handles chained reads (s.a.b); Stmt::FieldSet(String, Vec<String>, Expr) handles writes with arbitrary depth paths via the set_nested() recursive helper in exec.rs. Built-ins: struct(), fieldnames, isfield, rmfield, isstruct. 19 regression tests added; 488 total.

Compatibility notes

  • % is a comment character (Octave/MATLAB convention). It terminates tokenization at that point. This is already implemented.
  • ans is the sole implicit variable (Octave/MATLAB convention). The old accumulator (acc) and memory cells (m1m9) were removed in Phase 1.
  • 1-based indexing for matrices (Octave/MATLAB convention) — implemented in Phase 6.
  • Where MATLAB and Octave differ, ccalc follows the modern MATLAB standard (R2016b+).
  • Full toolbox compatibility (Signal Processing, Optimization, etc.) is out of scope.

Phase 1 — Variables and Assignment

Status: ✅ Done (v0.7.0)

Goal: x = 5, then x + 16

What was implemented

Variable environment (Env)

#![allow(unused)]
fn main() {
pub type Env = HashMap<String, f64>;
}

eval takes &Env. The REPL holds one Env per session, initialized with ans = 0.0 on startup.

Assignment statement

x = expr is a top-level statement, separate from an expression:

#![allow(unused)]
fn main() {
pub enum Stmt {
    Assign(String, Expr),   // x = expr
    Expr(Expr),             // standalone expression → stored in ans
}
}

Variable lookup in expressions

#![allow(unused)]
fn main() {
Expr::Var(String)
}

When the evaluator encounters Var(name), it looks up name in Env. Unknown names produce an error.

ans — implicit last result

ans is the reserved name for the result of the last standalone expression. It is initialized to 0.0 on startup and updated after every expression that is not assigned to a named variable — exactly as in Octave/MATLAB.

Partial expressions (starting with an operator) automatically prepend ans:

[ 100 ]: / 4
[ 25 ]: + 5
[ 30 ]:

Commands

CommandAction
whoList all variables and their values
clearDelete all variables (reinitializes ans = 0)
clear xDelete variable x
wsSave workspace to ~/.config/ccalc/workspace.toml
wlLoad workspace from file

Workspace persistence

Variables are saved and loaded via env::save_workspace_default() / load_workspace_default(). The file format is plain name = value lines.

What was removed in this phase

  • acc (old accumulator) — replaced by ans.
  • m1m9 (memory cells) — replaced by named variables.
  • c command — replaced by clear.

Octave/MATLAB alignment

  • ans follows Octave/MATLAB convention exactly.
  • pi and e are resolved in the parser (not stored in Env).
  • % terminates tokenization — everything after % on a line is a comment.

Phase 2 — Multi-argument Functions

Status: ✅ Done (v0.7.0+011)

Goal: atan2(y, x), mod(a, b), max(a, b), hypot(a, b), …

What was implemented

Expr::Call — variadic arguments

Expr::Call(String, Box<Expr>) was replaced by Expr::Call(String, Vec<Expr>). The parser now handles comma-separated argument lists:

fn()          →  passes ans (backward-compatible)
fn(x)         →  single argument
fn(a, b)      →  two arguments
fn(a, b, c)   →  three arguments (future use)

call_builtin dispatcher

The inline match name in the evaluator was replaced by call_builtin(name, args: &[f64]), which uses slice pattern matching to dispatch on both the function name and the argument count:

#![allow(unused)]
fn main() {
("log", [x])        => x.log10()
("log", [x, base])  => x.log(*base)
}

This makes it trivial to add overloaded functions and keeps the evaluator clean.

New one-argument functions

FunctionDescription
asin(x)Inverse sine (radians)
acos(x)Inverse cosine (radians)
atan(x)Inverse tangent (radians)
sign(x)Sign: −1.0, 0.0, or 1.0

New two-argument functions

FunctionDescription
atan2(y, x)Four-quadrant inverse tangent (radians)
mod(a, b)Remainder, sign follows divisor (Octave/MATLAB mod)
rem(a, b)Remainder, sign follows dividend (IEEE 754 truncation)
max(a, b)Larger of two values
min(a, b)Smaller of two values
hypot(a, b)√(a²+b²), numerically stable
log(x, base)Logarithm of x to an arbitrary base

mod vs rem — sign convention

mod( 10,  3)  →   1    rem( 10,  3)  →   1
mod(-1,   3)  →   2    rem(-1,   3)  →  -1
mod( 1,  -3)  →  -2    rem( 1,  -3)  →   1

mod is implemented as a - b * floor(a / b) — sign follows the divisor. rem is implemented as a - b * trunc(a / b) — sign follows the dividend.

Octave/MATLAB alignment

  • atan2, mod, rem, max, min, hypot match Octave/MATLAB exactly.
  • log(x, base) — Octave uses log(x) for the natural log; ccalc keeps log(x) as base-10 (legacy). Use ln(x) for natural log, or log(x, e) for the two-argument form.

Example

See examples/ac_impedance.ccalc for a practical use of hypot, atan2, mod, max, min, log, and log(x, base) in an AC circuit calculation.

Phase 3 — Matrix Literals

Version: v0.8.0

What was added

Matrix literals using Octave/MATLAB bracket syntax:

[1 2 3]          % row vector
[1; 2; 3]        % column vector
[1 2; 3 4]       % 2×2 matrix

Elements can be arbitrary expressions. Spaces and commas are both valid element separators within a row.

Arithmetic

  • scalar op matrix and matrix op scalar — element-wise for +, -, *, /, ^
  • matrix + matrix and matrix - matrix — element-wise (shapes must match)
  • matrix * matrix — not yet (Phase 4)

Type system changes

Value enum introduced in env.rs:

#![allow(unused)]
fn main() {
pub enum Value {
    Scalar(f64),
    Matrix(ndarray::Array2<f64>),
}

pub type Env = HashMap<String, Value>;
}

eval() now returns Result<Value, String> (was Result<f64, String>).

Display

Matrices print with right-aligned columns:

A =
   1   2
   3   4

The REPL prompt shows matrix size when ans is a matrix: [ [2×2] ]: .

Workspace save (ws) skips matrix variables — only scalars are persisted.

Parser changes

New tokens: LBracket ([), RBracket (]), Semicolon (;).

New AST node: Expr::Matrix(Vec<Vec<Expr>>).

split_stmts() in repl.rs is now bracket-depth-aware: a ; inside [...] is treated as a row separator by the parser, not a statement separator by the REPL.

Dependency added

ndarray = "0.16" in crates/ccalc-engine/Cargo.toml.

Phase 4 — Matrix Operations

Version: v0.9.0

What was added

Matrix multiplication

* between two matrices now performs standard matrix multiplication via ndarray’s .dot(). Inner dimensions must agree; otherwise an error is returned.

A = [1 2; 3 4];
b = [1; 1];
A * b          % → [3; 7]

Transpose

Postfix ' transposes a matrix. It binds tighter than any binary operator, so A' * B parses as (A') * B.

v = [1; 2; 3];
v'             % → [1  2  3]  (1×3 row vector)
v' * v         % → 14         (dot product, result is 1×1 matrix)

Element-wise operators

.*, ./, .^ apply the operation to each pair of corresponding elements. Both operands must have the same shape (or one must be a scalar).

A .* B         % Hadamard product
A ./ B         % element-wise division
A .^ 2         % square each element

Note: * is matrix multiplication; .* is element-wise product.

New built-in functions

FunctionDescription
zeros(m, n)m×n matrix of zeros
ones(m, n)m×n matrix of ones
eye(n)n×n identity matrix
size(A)[rows cols] as a 1×2 row vector
size(A, dim)Rows (dim=1) or columns (dim=2) as scalar
length(A)max(rows, cols)
numel(A)Total element count
trace(A)Sum of diagonal elements
det(A)Determinant
inv(A)Inverse matrix

det and inv are implemented via Gaussian / Gauss-Jordan elimination with no external BLAS/LAPACK dependency.

Parser changes

New tokens:

TokenInputUsage
Apostrophe'Postfix transpose
DotStar.*Element-wise *
DotSlash./Element-wise /
DotCaret.^Element-wise ^

New AST node: Expr::Transpose(Box<Expr>).

New Op variants: ElemMul, ElemDiv, ElemPow.

is_partial extended: .*, ./, .^ prefixes are now recognised as partial expressions (e.g. .* 2 expands to ans .* 2).

The number tokenizer no longer absorbs . into a number when it is immediately followed by *, /, or ^. This means 3.*2 correctly tokenizes as Number(3), DotStar, Number(2) rather than Number(3.0), Star, Number(2).

split_stmts fix

split_stmts in repl.rs previously toggled in_sq on every ', which caused R' * q; to hide the ; inside a “string”. The function now checks the left context: ' preceded by an identifier character, digit, ), ], or another ' is treated as a transpose operator and does not affect the string-tracking state.

Evaluator changes

eval_binop updated:

  • Matrix * Matrix → ndarray .dot() (was an error in Phase 3)
  • Matrix .* Matrix → element-wise &lm * &rm
  • Matrix ./ Matrix → element-wise &lm / &rm
  • Matrix .^ Matrix → element-wise Zip::map_collect(a.powf(b))

call_builtin refactored: the function now returns Result<Value, String> directly from each match arm, replacing the old pattern that extracted an f64 result and always wrapped it in Value::Scalar. This allows matrix- returning built-ins (zeros, ones, eye, size, inv) to coexist with scalar-returning ones in the same match.

Phase 5 — Range Operator

Version: v0.10.0

What was added

Range expressions

The : operator generates row vectors. Two forms are supported:

FormMeaning
a:bstart a, stop b, step 1
a:step:bstart a, stop b, explicit step
1:5              % [1 2 3 4 5]
1:2:9            % [1 3 5 7 9]
0:0.5:2          % [0 0.5 1 1.5 2]
5:-1:1           % [5 4 3 2 1]
5:1              % []   (empty — step in wrong direction)

The range operator has lower precedence than arithmetic:

1+1:2+2          % 2:4 → [2 3 4]

Ranges inside matrix literals

Range elements inside [...] are evaluated to row vectors and concatenated horizontally into the containing row:

[1:4]            % [1 2 3 4]           (1×4)
[0, 1:3, 10]     % [0 1 2 3 10]        (1×5)
[1:2:7]          % [1 3 5 7]           (1×4)
[1:3; 4:6]       % [1 2 3; 4 5 6]      (2×3)

linspace

linspace(a, b, n) generates n evenly spaced values from a to b (both endpoints included). This is numerically superior to range expressions when the number of points matters more than the step size.

linspace(0, 1, 5)      % [0  0.25  0.5  0.75  1]
linspace(1, 5, 5)      % [1  2  3  4  5]
linspace(0, 1, 1)      % [1]   (single element returns b, MATLAB convention)
linspace(0, 1, 0)      % []   (empty)

Parser changes

New token: Token::Colon — produced by the : character.

New AST node: Expr::Range(Box<Expr>, Option<Box<Expr>>, Box<Expr>) where the middle field is the optional step expression.

New parser function parse_range() sits above parse_expr() in the precedence hierarchy:

parse_range  (lowest)
  parse_expr  (+, -)
    parse_term  (*, /, .*, ./)
      parse_power  (^, .^)
        parse_unary  (unary -)
          parse_primary  (atoms)

parse() and parse_matrix() (element parsing) now call parse_range() instead of parse_expr().

Evaluator changes

Expr::Range evaluation:

  1. Evaluate start, stop, and step (must all be scalars).
  2. Compute n = floor((stop - start) / step + ε) + 1.
  3. If n ≤ 0, return a 1×0 empty matrix.
  4. Generate values as start + i * step for i = 0..n.

Expr::Matrix evaluation updated: when a row element evaluates to a Value::Matrix, its values are appended to the current row (horizontal concatenation). This enables ranges inside [...]. Column vectors and higher-dimensional sub-matrices are rejected with an error.

Phase 6 — Indexing

Version: v0.11.0

What was added

Vector indexing

All indices are 1-based (Octave/MATLAB convention).

v = [10 20 30 40 50];

v(3)         % → 30          scalar element
v(2:4)       % → [20 30 40]  sub-vector via range
v(:)         % → column vector [10;20;30;40;50]

v(:) follows Octave convention: all elements are returned as a column vector in column-major order.

Matrix indexing

A = [1 2 3; 4 5 6; 7 8 9];

A(2, 3)      % → 6           scalar at row 2, col 3
A(1, :)      % → [1 2 3]     entire row 1     (1×3)
A(:, 2)      % → [2;5;8]     entire column 2  (3×1)
A(1:2, 2:3)  % → [2 3; 5 6]  submatrix
A(:, :)      % → copy of A   (all rows, all cols)

Scalar result: A(i,j) returns Value::Scalar when both i and j are scalars. Sub-matrix results return Value::Matrix.

Linear (1D) indexing

v(i) on a matrix uses column-major linear indexing — columns are counted before rows. For a 1×N row vector this is simply sequential:

v = [10 20 30 40 50];
v(3)    % 3rd element → 30

For a 2D matrix, linear indexing counts down each column before moving to the next:

A = [1 2; 3 4];   % linear order: 1, 3, 2, 4
A(3)              % → 2   (3rd in column-major order)

Call vs. index disambiguation

When name(args) is parsed, the evaluator checks Env at eval time:

  • Name is in Env → indexing (eval_index)
  • Name is not in Env → built-in function call (call_builtin)

This means variables shadow built-in function names, matching Octave semantics. Assigning zeros = [1 2; 3 4] makes zeros(1,2) an indexing operation on that variable, not a call to zeros(m,n).

Parser changes

New AST node: Expr::Colon — represents a bare : used as an all-elements index selector. Evaluating Expr::Colon outside an indexing context returns an error.

New parser function parse_call_arg():

parse_call_arg:
  if current token is ':' → consume, return Expr::Colon
  else                    → parse_range(...)

All function call / index argument positions now use parse_call_arg instead of parse_expr. This enables:

  • A(:, j) — bare colon as first arg
  • A(1:3, :) — range as first arg, colon as second
  • f(1:5) — range as function argument

Bug fix: range in grouping parentheses

parse_primary for grouped expressions (...) previously called parse_expr, which does not handle :. This was changed to parse_range, enabling expressions like 2 .^ (0:7).

Evaluator changes

eval_index(val, args, env) — dispatches based on argument count:

ArgsFormResult
1, Colonv(:)All elements as column vector (column-major)
1, scalarv(i)Value::Scalar
1, vectorv(1:3)Value::Matrix 1×N
2, both scalarA(i,j)Value::Scalar
2, otherwiseA(:,j), A(i,:), A(1:2,2:3)Value::Matrix

resolve_dim(expr, dim_size, env) — converts one index argument to a list of 0-based indices:

  • Expr::ColonDimIdx::All
  • Value::Scalar(n) → validates 1 ≤ n ≤ dim_size, returns [n-1]
  • Value::Matrix (vector) → validates each element, returns [i-1, ...]

Phase 7 — Comparison & Logical Operators

Version: v0.11.0+001

What was added

Comparison operators

All six relational operators from Octave/MATLAB are now supported. They return 1.0 (true) or 0.0 (false):

3 > 2        % → 1
3 == 3       % → 1
3 == 4       % → 0
5 ~= 3       % → 1  (not equal)
4 <= 4       % → 1
4 >= 5       % → 0

Comparison has lower precedence than arithmetic, so the operands are evaluated first:

1 + 1 == 2   % → 1   (evaluates as (1+1) == 2)
2 * 3 > 5    % → 1   (evaluates as (2*3) > 5)

Comparison is non-associative — chaining like a < b < c is a parse error. Write a < b && b < c instead.

Logical NOT — ~

Unary ~ negates a truth value: zero becomes 1, any non-zero becomes 0. It binds at the same precedence level as unary minus:

~0           % → 1
~1           % → 0
~(3 == 3)    % → 0
~(3 == 4)    % → 1

Short-circuit logical AND/OR — &&, ||

&& and || evaluate both operands and return 0.0 or 1.0. && binds tighter than ||:

1 && 1       % → 1
1 && 0       % → 0
0 || 1       % → 1
0 || 0       % → 0

% '&&' before '||':
1 || 0 && 0  % → 1   (1 || (0 && 0))

Combining comparisons:

x = 2.7;
x >= 0 && x <= 3.3   % → 1   (in range check)
x < 0  || x > 3.3   % → 0   (out-of-range flag)

Element-wise on matrices

All operators work element-wise on matrices of the same shape, producing a 0/1 mask of the same dimensions:

v = [1 2 3 4 5];

v > 3          % → [0 0 0 1 1]
v == 3         % → [0 0 1 0 0]
v ~= 3         % → [1 1 0 1 1]
~(v > 3)       % → [1 1 1 0 0]

Scalar–matrix mixed comparisons broadcast the scalar:

v >= 2         % → [0 1 1 1 1]
3 < v          % → [0 0 0 1 1]

Soft masking via .*

Since masks are 0/1 matrices, multiplying by them zeroes out unwanted elements — a common pattern for conditional selection:

v .* (v > 3)               % → [0 0 0 4 5]  keep elements > 3

% Combine two masks (element-wise AND):
lo = v >= 2;
hi = v <= 4;
v .* (lo .* hi)            % → [0 2 3 4 0]  keep 2–4 only

Precedence summary

Full precedence table from lowest to highest:

LevelOperatorsNotes
1 (lowest)||logical OR
2&&logical AND
3== ~= < > <= >=comparison, non-associative
4:range (a:b, a:step:b)
5+ -additive
6* / .* ./multiplicative
7^ .^power (right-associative)
8unary - ~negation, logical NOT
9 (highest)postfix 'transpose

Parser changes

Three new parser levels were inserted above parse_range:

parse_logical_or        % '||'
  parse_logical_and     % '&&'
    parse_comparison    % == ~= < > <= >=
      parse_range       % a:b, a:step:b  (existing)
        parse_expr      % + -
          ...

parse_logical_or is now the top-level entry point for parse(), parse_call_arg(), and grouped expressions (...) in parse_primary.

New tokens: EqEq, NotEq, Lt, Gt, LtEq, GtEq, AmpAmp, PipePipe, Tilde.

A bare = in expression context (not a valid assignment left-hand side) is now a tokenizer error, making 3 = 3 produce a clear message instead of a silent parse failure.

Evaluator changes

New Expr variant:

  • Expr::UnaryNot(Box<Expr>) — evaluates to 1.0 if inner is 0.0, else 0.0. Works element-wise on matrices.

New Op variants:

VariantOperation
Op::Eq==
Op::NotEq~=
Op::Lt<
Op::Gt>
Op::LtEq<=
Op::GtEq>=
Op::And&&
Op::Or||

eval_binop handles all four combinations (Scalar×Scalar, Matrix×Matrix, Scalar×Matrix, Matrix×Scalar) for every new operator. Scalar×Scalar returns a Value::Scalar(0.0|1.0); any matrix combination returns a Value::Matrix of the same shape.

Helper functions added to eval.rs:

  • bool_to_f64(b: bool) -> f64 — converts a boolean to 0.0/1.0
  • cmp_op(op: &Op, a: f64, b: f64) -> bool — applies comparison or logical op to two scalar values

Phase 7.5 — Vector Utilities

Version: v0.11.0+003

What was added

7.5a — Special floating-point constants

nan and inf are parser-level constants (like pi and e) — they require no variable definition and cannot be overwritten by assignment.

nan            % Not-a-Number
inf            % positive infinity
-inf           % negative infinity
nan + 5        % → NaN   (NaN propagates through all arithmetic)
nan == nan     % → 0     (always false in IEEE 754)

Matrix constructor:

nan(3)         % 3×3 matrix filled with NaN
nan(2, 4)      % 2×4 matrix filled with NaN

Element-wise predicates — work on both scalars and matrices:

FunctionDescription
isnan(x)1.0 if NaN, else 0.0
isinf(x)1.0 if ±Inf, else 0.0
isfinite(x)1.0 if finite (not NaN, not Inf)
isnan(nan)        % → 1
isinf(inf)        % → 1
isfinite(42)      % → 1
isfinite(nan)     % → 0

v = [1 nan 3 inf];
isnan(v)          % → [0  1  0  0]
isfinite(v)       % → [1  0  1  0]

7.5b — Vector reductions

All reduction functions follow the same rule:

  • Vector (1×N or N×1): collapse all elements to a scalar.
  • M×N matrix (M>1, N>1): operate column-wise, return a 1×N row vector.

This matches Octave/MATLAB behaviour.

FunctionDescription
sum(v)Sum of elements
prod(v)Product of elements
mean(v)Arithmetic mean
min(v)Minimum element (1-arg form; min(a,b) still works)
max(v)Maximum element (1-arg form)
any(v)1.0 if any element is non-zero
all(v)1.0 if all elements are non-zero
norm(v)Euclidean (L2) norm
norm(v, p)General Lp norm; p = inf → max of absolute values
sum([1 2 3 4])        % → 10
mean([1 2 3 4])       % → 2.5
min([3 1 4 1 5])      % → 1
max([3 1 4 1 5])      % → 5
any([0 0 1 0])        % → 1
all([1 2 3] > 0)      % → 1
norm([3 4])           % → 5
norm([1 2 3], 1)      % → 6  (L1 = sum of absolute values)

Column-wise on a matrix:

M = [1 2 3; 4 5 6]
sum(M)    % → [5  7  9]
mean(M)   % → [2.5  3.5  4.5]

Cumulative operations — return the same shape as the input:

FunctionDescription
cumsum(v)Cumulative sum along the vector / each column
cumprod(v)Cumulative product
cumsum([1 2 3 4])    % → [1  3  6  10]
cumprod([1 2 3 4])   % → [1  2  6  24]

7.5c — end keyword in indexing

Inside any index expression (...), the keyword end resolves to the size of the dimension being indexed. Arithmetic on end is fully supported.

v = [10 20 30 40 50];
v(end)           % → 50
v(end-1)         % → 40
v(end-2:end)     % → [30 40 50]
v(1:2:end)       % → [10 30 50]

A = [1 2 3; 4 5 6; 7 8 9];
A(end, :)        % → [7 8 9]      last row
A(:, end)        % → [3; 6; 9]    last column
A(1:end-1, 2:end) % → [2 3; 5 6]  submatrix

Implementation note: end is a context-sensitive value injected into the evaluation environment by eval_index. Outside an indexing context it is an undefined variable.

7.5d — Sort, reshape, and find

FunctionDescription
sort(v)Sort ascending (vectors only)
reshape(A, m, n)Reshape to m×n using column-major (MATLAB) element order
fliplr(v)Reverse column order (left↔right mirror)
flipud(v)Reverse row order (up↔down mirror)
find(v)1-based column-major indices of non-zero elements
find(v, k)First k non-zero indices
unique(v)Sorted unique elements as a 1×N row vector
sort([3 1 4 1 5 9])       % → [1  1  3  4  5  9]
reshape(1:6, 2, 3)        % → [1 3 5; 2 4 6]
fliplr([1 2 3])           % → [3 2 1]
flipud([1;2;3])           % → [3; 2; 1]
find([0 3 0 5 0])         % → [2 4]
find([1 0 2 0 3], 2)      % → [1 3]
unique([3 1 4 1 5 3])     % → [1 3 4 5]

reshape uses column-major order — elements fill the output matrix column-by-column, matching MATLAB/Octave behaviour:

reshape([1 2 3 4 5 6], 2, 3)
% col 0: [1 2], col 1: [3 4], col 2: [5 6]
% → [1 3 5]
%   [2 4 6]

Example file

examples/vector_utils.calc covers all Phase 7.5 features with annotated output. Run it with:

ccalc examples/vector_utils.calc

Implementation details

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

Phase 8 — Complex Numbers

Version: v0.12.0

What was added

Value::Complex

A third variant was added to the Value enum in env.rs:

#![allow(unused)]
fn main() {
pub enum Value {
    Scalar(f64),
    Matrix(Array2<f64>),
    Complex(f64, f64),   // re, im
}
}

make_complex(re, im) in eval.rs collapses the result to Scalar(re) when im == 0.0 exactly, so operations like (1+i)*(1-i) produce Scalar(2) rather than Complex(2, 0).

Imaginary units i and j

i and j are pre-seeded in Env at startup as Complex(0.0, 1.0), matching Octave/MATLAB behaviour. The user can overwrite them with i = 5 (the new value shadows the imaginary unit for the session). clear i removes the variable; the imaginary unit is not automatically restored (document limitation).

Syntax — no new tokens

4i tokenizes as Number(4) Ident("i"), which triggers implicit multiplication. With i = Complex(0.0, 1.0) in Env:

4*i       →  Complex(0.0, 4.0)
3 + 4*i   →  Complex(3.0, 4.0)

No tokenizer or parser changes were needed.

Arithmetic

complex_binop(re1, im1, op, re2, im2) handles all four combination groups:

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

Supported operations:

OpFormula
+(a+c) + (b+d)i
-(a-c) + (b-d)i
* / .*(ac-bd) + (ad+bc)i
/ / ./((ac+bd) + (bc-ad)i) / (c²+d²)
^ / .^ integerbinary exponentiation (exact)
^ / .^ generalpolar form exp((c+di)·ln(a+bi))
==1 if both parts equal
~=1 if either part differs
</>/<=/>=error: “Ordering is not defined for complex numbers”
&& / ||nonzero test on `re != 0

Powers — integer exactness

Integer powers use binary exponentiation (repeated squaring) to avoid polar-form floating-point error:

i^2  =  -1         (exact, not -1 + 1.22e-16i)
i^3  =  -i         (exact)
i^4  =   1         (exact)

General non-integer powers fall back to the polar form exp((c+di) * (ln|z| + i*arg(z))).

Unary operators

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

Display

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

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

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

Built-in functions

FunctionDescription
real(z)Real part; returns z unchanged for scalars
imag(z)Imaginary part; returns 0 for scalars
abs(z)Modulus sqrt(re²+im²); overloads scalar and matrix abs
angle(z)Argument atan2(im, re) in radians
conj(z)Complex conjugate re - im*i
complex(re, im)Construct from two real scalars
isreal(z)1 if im == 0, else 0

scalar_arg compatibility

scalar_arg(v, name, pos) now accepts Complex with im == 0 as a real scalar, so sqrt(complex(4, 0)) returns 2 without error.

Scope boundary

Complex matrices (e.g. [1+2i, 3+4i]) are out of scope for this phase. A matrix literal containing a Complex element returns:

Error: Complex elements in matrix literals are not supported yet

Complex variables are not persisted by ws/wl (same policy as matrices).

Files changed

FileChange
crates/ccalc-engine/src/env.rsAdded Value::Complex(f64, f64)
crates/ccalc-engine/src/eval.rscomplex_binop, make_complex, format_complex, built-ins, exhaustive match arms
crates/ccalc/src/repl.rsnew_env seeds i/j; all output paths handle Value::Complex
crates/ccalc-engine/src/eval_tests.rs38 new tests covering all operations
examples/complex_numbers.calcNew annotated example file

Phase 9 — String Data Types

Version: v0.13.0

What was added

Two new Value variants

Two variants were added to the Value enum in env.rs:

#![allow(unused)]
fn main() {
pub enum Value {
    Scalar(f64),
    Matrix(Array2<f64>),
    Complex(f64, f64),
    /// Char array (single-quoted). Represents a 1×N row of characters.
    Str(String),
    /// String object (double-quoted). Scalar container of arbitrary text.
    StringObj(String),
}
}

save_workspace uses as_scalar() to filter variables before writing — the new variants return None from as_scalar() and are automatically skipped, matching the policy for matrices and complex numbers.

Tokenizer changes — ' disambiguation

The ' character has two meanings:

  • Transpose operator — when preceded by an rvalue context
  • Char array literal — at the start of an expression or after an operator

The tokenizer tracks the last emitted token and applies this rule:

Last token' is
Number, Ident, RParen, RBracket, Apostrophe, StrTranspose (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:

SequenceResult
""Literal "
\nNewline
\tTab
\\Literal \
\"Literal "

New AST nodes

#![allow(unused)]
fn main() {
pub enum Expr {
    // ...existing variants...
    /// Single-quoted char array literal.
    StrLiteral(String),
    /// Double-quoted string object literal.
    StringObjLiteral(String),
}
}

parse_primary handles Token::StrExpr::StrLiteral and Token::StringObjExpr::StringObjLiteral. The existing postfix transpose loop then applies: 'hello'' produces Expr::Transpose(Box::new(Expr::StrLiteral("hello"))).

Arithmetic on char arrays

str_to_numeric(s: &str) -> Value converts a char array to its numeric representation before binary operations:

Input lengthResult
0Value::Matrix 1×0
1Value::Scalar(code)
NValue::Matrix 1×N

In eval_binop, the arms for Str appear before all others:

#![allow(unused)]
fn main() {
(Value::Str(s), r) => eval_binop(str_to_numeric(&s), op, r),
(l, Value::Str(s)) => eval_binop(l, op, str_to_numeric(&s)),
}

This means 'abc' + 1str_to_numeric("abc") = Matrix([97,98,99])[98, 99, 100], and 'a' + 0Scalar(97)Scalar(97).

String object operations

String objects support only + (concatenation) and ==/~= (comparison). All other operators return an error:

#![allow(unused)]
fn main() {
(Value::StringObj(a), Value::StringObj(b)) => match op {
    Op::Add   => Ok(Value::StringObj(a + &b)),
    Op::Eq    => Ok(Value::Scalar(bool_to_f64(a == b))),
    Op::NotEq => Ok(Value::Scalar(bool_to_f64(a != b))),
    _         => Err("Operator not supported on string objects"),
},
}

New built-in functions

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

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

  • length(Str(s)) → number of characters in s
  • length(StringObj(_)) → 1 (scalar element)
  • numel and size follow the same pattern

Helper functions

string_arg(v, fname, pos) extracts a string slice from Str or StringObj, returning a descriptive error for any other type.

process_escape_sequences(s) handles \n, \t, \\, \', \" — factored out of repl.rs into eval.rs for use by sprintf.

Display

  • format_value returns the string content as-is for both variants.
  • format_value_full returns None (strings are displayed inline like scalars).
  • The REPL prompt shows the first 15 characters with surrounding quotes when ans is a string.
  • who annotates type: name [1×N char] for Str, name [string] for StringObj.

Exhaustiveness

All existing functions that matched Value variants (eval_index, resolve_dim, apply_elem, apply_reduction, apply_cumulative, find_nonzero, scalar_arg) were updated to handle Str and StringObj — either with string-specific logic or a clear error message.

What was not changed

  • 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).
  • strsplit requires cell arrays (not yet implemented) and is deferred.
  • The split_stmts() function in repl.rs already tracked single-quoted and double-quoted string boundaries (from earlier disambiguation work in Phase 4). No changes were needed there.

Phase 10 — C-style I/O and format command

Status: Complete ✅
Version: v0.14.0 / v0.14.0+001
Prerequisite: Phase 9 (string types)


What was implemented

10a — fprintf with format specifiers

fprintf(fmt, arg1, arg2, ...) built-in, interpreting a format string with C-style conversion specifiers:

SpecifierMeaning
%d, %idecimal integer (value truncated to i64)
%ffixed-point decimal, default 6 places
%escientific notation (1.23e+04)
%gshorter of %f and %e
%sstring (char array or string object)
%%literal %

Width, precision, and flags (-, +, 0, space) follow C printf.

When there are more arguments than specifiers, the format string repeats for remaining args (Octave behaviour).

fprintf is implemented as a case in call_builtin in eval.rs — not as a REPL special case. Returns Value::Void; the REPL suppresses display for Void.

10b — sprintf

Same format engine (format_printf in eval.rs), but returns the formatted result as Value::Str (char array) instead of printing.

Single-arg sprintf(fmt) processes escape sequences — this replaces the former escape-only form.

10c — Precision system overhaul

The p / p<N> precision directive was removed from the language. Replaced by the format command (v0.14.0+001).

10d — format command (v0.14.0+001)

MATLAB-compatible number display modes:

ModeDescription
short5 significant digits, auto fixed/scientific (default)
long15 significant digits
shortEalways scientific, 4 decimal places
longEalways scientific, 14 decimal places
shortGalias for short
longGalias for long
bankfixed 2 decimal places
ratrational approximation via continued fractions
hexIEEE 754 double bit pattern (16 uppercase hex digits)
+sign only: +, -, or space
compactsuppress blank lines between outputs
looseadd blank line after every output
NN decimal places (legacy custom mode)

format alone resets to short.


Key implementation details

Value::Void

New variant added to the Value enum in env.rs. Returned by side-effectful functions (fprintf). The REPL checks for Value::Void and skips display.

format_printf function

Core format engine in eval.rs (public). Processes a format string and a slice of Value args, returns a String. Handles repeat-format semantics, all specifiers, all flags.

FormatMode enum

#![allow(unused)]
fn main() {
pub enum FormatMode {
    Short, Long, ShortE, LongE, ShortG, LongG,
    Bank, Rat, Hex, Plus, Custom(usize),
}
}

Added after Base enum in eval.rs. Default is Custom(10) (preserves existing test baseline). format command in REPL resets to Short.

Changed public API signatures:

#![allow(unused)]
fn main() {
pub fn format_scalar(n: f64, base: Base, mode: &FormatMode) -> String
pub fn format_complex(re: f64, im: f64, mode: &FormatMode) -> String
pub fn format_value(v: &Value, base: Base, mode: &FormatMode) -> String
pub fn format_value_full(v: &Value, mode: &FormatMode) -> Option<String>
}

fmt_rat — rational approximation

Continued fractions algorithm. Tolerance: 1e-6. Max denominator: 10,000. Gives 355/113 for pi.

trim_sci — exponent normalization

Normalizes Rust’s e0/e-3 format to MATLAB-style e+00/e-03 with two-digit exponent and explicit sign.


Scope boundary

  • File I/O (fopen/fclose/fgetl, dlmread/dlmwrite) — implemented in Phase 10.5.
  • fprintf(fd, ...) with file descriptor — implemented in Phase 10.5.
  • Complex matrices in fprintf — not supported (same boundary as Phase 8).

Tests

  • eval_tests.rs: 9 new test_format_* tests covering all modes.
  • repl_tests.rs: updated harness (FormatMode::default() replaces precision: usize).
  • Total: 427 tests passing (97 repl + 330 engine).

Phase 10.5 — File I/O and Filesystem Queries

Status: Complete ✅
Version: v0.14.0+006
Prerequisite: Phase 10 (string types for filename arguments)


What was implemented

10.5a — File handles

Low-level file I/O using integer file descriptors, matching the MATLAB/Octave fopen/fclose model.

FunctionDescription
fopen(path, mode)Open file; returns fd (≥3) or -1 on failure
fclose(fd)Close by fd; returns 0 on success, -1 on error
fclose('all')Close all open file handles
fgetl(fd)Read one line, strip trailing newline; returns -1 at EOF
fgets(fd)Read one line, keep trailing newline; returns -1 at EOF
fprintf(fd, fmt, ...)Write formatted output to file descriptor

Supported modes: 'r' read, 'w' write (create/truncate), 'a' append, 'r+' read+write.

Pre-opened virtual descriptors: fd 1 = stdout, fd 2 = stderr (Octave convention).

10.5b — Delimiter-separated data

Read and write numeric data in CSV or TSV format.

FunctionDescription
dlmread(path)Read; auto-detect , / \t / whitespace
dlmread(path, delim)Explicit delimiter
dlmwrite(path, A)Write matrix with , separator
dlmwrite(path, A, delim)Explicit delimiter

Returns Value::Matrix; all values must be numeric. Non-numeric data returns an error with the line number.

10.5c — Filesystem queries

FunctionDescription
isfile(path)1 if the path exists and is a regular file
isfolder(path)1 if the path exists and is a directory
pwd()Current working directory as a char array
exist(name)1 if variable in workspace; 2 if a file exists on disk
exist(name, 'var')Check workspace only
exist(name, 'file')Check filesystem only; returns 2 (MATLAB numeric code)

10.5d — Workspace with explicit path

Extension of the existing ws/wl commands to accept an explicit file path.

SyntaxDescription
saveSave all variables to default path (alias for ws)
save('path.mat')Save all variables to named file
save('path.mat', 'x', 'y')Save specific variables only
loadLoad from default path (alias for wl)
load('path.mat')Load from named file

Path argument can be a variable reference: save(path_var).

Persisted types: Scalar, Str (char array), StringObj. Matrices, complex values, and Void are always skipped.


Key implementation details

IoContext

New struct in ccalc-engine/src/io.rs. Holds a HashMap<i32, FileHandle> and next_fd: i32 (starts at 3). FileHandle is an enum: Read(BufReader<File>) | Write(File). fd 1 and 2 are handled virtually in write_to_fd.

eval_with_io / eval

#![allow(unused)]
fn main() {
pub fn eval(expr: &Expr, env: &Env) -> Result<Value, String>
pub fn eval_with_io(expr: &Expr, env: &Env, io: &mut IoContext) -> Result<Value, String>
}

Both are wrappers around private eval_inner(expr, env, mut io: Option<&mut IoContext>). The Option<&mut T> pattern with as_deref_mut() reborrowing allows sequential mutable borrows in recursive calls without cloning.

call_builtin signature change

#![allow(unused)]
fn main() {
fn call_builtin(name: &str, args: &[Value], env: &Env, io: Option<&mut IoContext>)
}

env: &Env was added for exist('x', 'var') which needs to inspect the workspace.

try_parse_save_load

Intercepts save/load statements in repl.rs before they reach evaluate(). Accepts env: &Env for resolving variable-reference path arguments (save(mat_path) where mat_path is a Str/StringObj in the workspace).

#![allow(unused)]
fn main() {
enum SaveLoadCmd {
    Save { path: Option<String>, vars: Vec<String> },
    Load { path: Option<String> },
}
fn try_parse_save_load(stmt: &str, env: &Env) -> Option<SaveLoadCmd>
}

Workspace serialization

name = 3.14          # Scalar
name = 'text'        # Str (char array)
name = "text"        # StringObj

Strings containing ' or " characters (matching the quote style) or \n are skipped to preserve the line-based format.


Tests

  • eval_tests.rs: test_fopen_write_and_fclose, test_fgetl_reads_lines, test_fgets_keeps_newline, test_fprintf_to_file, test_fclose_all, test_fopen_nonexistent_returns_minus_one, test_dlmwrite_and_dlmread_comma, test_dlmwrite_tab_delimiter, test_dlmread_empty_file, test_dlmread_non_numeric_error, test_dlmread_whitespace_auto, test_dlmwrite_scalar, test_isfile_existing_file, test_isfile_nonexistent, test_isfile_directory_is_false, test_isfolder_existing_dir, test_isfolder_file_is_false, test_exist_var_found, test_exist_var_not_found, test_exist_file_found, test_exist_file_not_found, test_exist_one_arg_checks_var_then_file, test_pwd_returns_string, test_fprintf_returns_void
  • repl_tests.rs: 9 new Phase 10.5d tests including test_pipe_save_load_roundtrip and test_pipe_save_selective_vars
  • Total: 461 tests passing

Phase 11 — Core Control Flow

Version: 0.15.x
Status: ✅ Complete

Motivation

Phase 10 (formatted output) made it practical to write loop scripts. Phase 11 adds the block statements that make those scripts possible: if, for, while, break, continue, and compound assignment operators.

Phase 11a — Multi-line input and if / for / while (v0.15.0)

Architecture

A new exec.rs module houses exec_stmts(), which avoids circular dependency: parser.rseval.rs, and exec.rs → both. The Stmt enum gains five new variants:

#![allow(unused)]
fn main() {
pub enum Stmt {
    Assign(String, Expr),
    Expr(Expr),
    If {
        cond: Expr,
        body: Vec<(Stmt, bool)>,
        elseif_branches: Vec<(Expr, Vec<(Stmt, bool)>)>,
        else_body: Option<Vec<(Stmt, bool)>>,
    },
    For  { var: String, range_expr: Expr, body: Vec<(Stmt, bool)> },
    While { cond: Expr, body: Vec<(Stmt, bool)> },
    Break,
    Continue,
}
}

exec_stmts returns Result<Option<Signal>, String> where Signal is Break | Continue. Loops catch these signals; a signal that escapes to the top level is reported as an error.

REPL buffering

block_depth_delta(line) returns +1 for if/for/while and -1 for end. The REPL accumulates lines into block_buf while block_depth > 0, then calls parse_stmts() + exec_stmts() when the block closes.

[ 0 ]:   for k = 1:3
  >>   fprintf('%d\n', k)
  >> end
1
2
3

For loop semantics

The range expression is evaluated once before any iteration. Iteration is column-by-column (Octave convention):

  • Row vector (1×N): each element becomes a Scalar
  • M×N matrix: each column becomes an M×1 Matrix

is_truthy

#![allow(unused)]
fn main() {
fn is_truthy(val: &Value) -> bool {
    match val {
        Value::Scalar(n)             => *n != 0.0 && !n.is_nan(),
        Value::Matrix(m)             => m.iter().all(|&x| x != 0.0 && !x.is_nan()),
        Value::Complex(re, im)       => *re != 0.0 || *im != 0.0,
        Value::Str(s) | Value::StringObj(s) => !s.is_empty(),
        Value::Void                  => false,
    }
}
}

Known limitation

Single-line blocks (if cond; body; end on one line) are not supported. Use multi-line form.

Phase 11b — Compound assignment operators (v0.15.1)

Six new token variants are added to the lexer:

TokenSource
PlusEq+=
MinusEq-=
StarEq*=
SlashEq/=
PlusPlus++
MinusMinus--

try_parse_compound(tokens) is called first in parse(). All forms desugar to Stmt::Assign at parse time — no new AST nodes are needed.

InputDesugared to
x += ex = x + e
x -= ex = x - e
x *= ex = x * e
x /= ex = x / e
x++x = x + 1
x--x = x - 1
++xx = x + 1
--xx = x - 1

The RHS of op= is a full expression (parse_logical_or), so x *= 2 + 3 desugars to x = x * (2 + 3).

is_partial() was updated so ++x/--x are not mistakenly treated as partial binary expressions.

Limitation: ++/-- are statement-level only.

Syntax aliases (v0.15.2)

AliasEquivalent
#% (comment)
!~ (logical NOT)
!=~= (not equal)

# is recognised in the tokenizer, strip_line_comment(), and split_stmts(). ! and != are emitted as Token::Tilde and Token::NotEq respectively — no changes downstream.

Phase 11.5 — Extended Control Flow

Version: 0.16.0
Status: ✅ Complete

Motivation

Phase 11 added the core control flow constructs (if, for, while). Phase 11.5 fills in the remaining Octave constructs most commonly found in real .m scripts: switch, do...until, and the ability to source another script file with run().

Phase 11.5a — switch / case / otherwise (v0.16.0)

Semantics

switch in Octave/MATLAB has no fall-through: the first matching case executes and control jumps directly to end. otherwise is the optional default branch.

switch expr
  case val1
    % ...
  case val2
    % ...
  otherwise
    % ...
end

Matching rules

Switch valueCase valueMatch
ScalarScalarexact ==
Str or StringObjStr or StringObjstring equality (Str and StringObj interchangeable)
Any other combinationno match

break and continue

break/continue inside a switch body propagate outward to the nearest enclosing loop. switch itself is not a loop.

AST

#![allow(unused)]
fn main() {
Stmt::Switch {
    expr: Expr,
    cases: Vec<(Vec<Expr>, Vec<(Stmt, bool)>)>,
    otherwise_body: Option<Vec<(Stmt, bool)>>,
}
}

Each case carries a Vec<Expr> for future multi-value case support (case {1,2,3} from Phase 11.5b, deferred until cell arrays are introduced in a later phase).

Example

switch code
  case 200
    msg = 'OK';
  case 301
    msg = 'Moved Permanently';
  case 404
    msg = 'Not Found';
  otherwise
    msg = 'Unknown';
end
fprintf('HTTP %d: %s\n', code, msg)

Phase 11.5b — Multi-value cases (deferred)

case {val1, val2} syntax requires Value::Cell, which is not yet implemented. Deferred until cell arrays are introduced.

Phase 11.5c — do...until (v0.16.0)

Semantics

Octave-specific post-test loop. The body always executes at least once, then the condition is tested. If truthy, the loop exits.

do
  body
until (cond)

Parentheses around cond are optional. break exits the loop immediately; continue re-tests the condition.

until closes the block without a separate end. In the REPL, block_depth_delta("until …") returns −1.

AST

#![allow(unused)]
fn main() {
Stmt::DoUntil {
    body: Vec<(Stmt, bool)>,
    cond: Expr,
}
}

Execution (exec.rs)

#![allow(unused)]
fn main() {
Stmt::DoUntil { body, cond } => loop {
    match exec_stmts(body, env, io, fmt, base, compact)? {
        Some(Signal::Break) => break,
        Some(Signal::Continue) | None => {}
    }
    if is_truthy(&eval_with_io(cond, env, io)?) {
        break;
    }
},
}

Example

% Smallest power of 2 >= n
n = 100;
p = 1;
do
  p *= 2;
until (p >= n)
fprintf('%d\n', p)   % 128

Phase 11.5d — try/catch (deferred)

Deferred to Phase 14 (after structs). try/catch requires Value::Struct for the error object, and the error-handling model is under design review.

Phase 11.5e — Script sourcing run() / source() (v0.16.0)

Semantics

Execute a script file in the caller’s workspace (MATLAB run semantics). Variables defined in the script persist after run returns. This is the opposite of a function call, which would have an isolated scope.

run('script')         % search for script.calc, then script.m in CWD
run('script.calc')    % explicit .calc extension
run('script.m')       % explicit .m extension
source('script')      % Octave alias — identical behaviour

Extension resolution for bare names

When no file extension is given, ccalc tries:

  1. <name>.calc — native ccalc script format (preferred)
  2. <name>.m — Octave/MATLAB compatibility

Explicit extensions (.calc, .m, or any other) are used verbatim.

Implementation

run()/source() are intercepted in exec_stmts by pattern-matching Stmt::Expr(Expr::Call("run"|"source", args)) before calling eval_with_io. This avoids needing a new AST node:

#![allow(unused)]
fn main() {
if let Expr::Call(fn_name, args) = expr
    && matches!(fn_name.as_str(), "run" | "source")
    && args.len() == 1
{
    // resolve path → read file → parse_stmts → exec_stmts (recursive)
}
}

In pipe/script mode, single-line statements bypass exec_stmts and go through evaluate(). A try_run_source() helper in repl.rs bridges that gap by routing run/source calls through exec_stmts before evaluate() is reached.

Recursion limit

A thread_local! RUN_DEPTH counter prevents infinite recursion. Maximum depth is 64; exceeding it returns an error.

Example

% euclid_helper.calc — reads a, b from workspace; writes g = gcd(a, b)
g = a;
r = b;
while r ~= 0
  temp = mod(g, r);
  g = r;
  r = temp;
end
% caller
a = 252; b = 105;
run('euclid_helper')
fprintf('gcd(252, 105) = %d\n', g)   % 21

REPL block depth

block_depth_delta was updated for the new keywords:

Line starts withDelta
switch+1
do+1
until …−1
end−1 (unchanged)

Demo

cd examples
ccalc extended_control_flow.calc

The example covers all constructs from this phase: switch integer and string dispatch, do...until with break/continue, Euclidean GCD via run(), and the source() alias.

Phase 12 — User-defined Functions

Version: v0.17.0
Prerequisite: Phase 11.5 (extended control flow, return, run/source)


Overview

Phase 12 introduces user-defined named functions, multiple return values, the return statement for early exit, and anonymous functions (lambdas) created with @(params) expr.


Named functions

function result = name(p1, p2)
  ...
  result = expr;
end

Functions are defined at the top level — in the REPL or in a script file. They are stored in the workspace like any variable and persist until clear is called.

Single return value

function y = square(x)
  y = x ^ 2;
end

square(5)     % 25
square(12)    % 144

Multiple return values

function [mn, mx, avg] = stats(v)
  mn  = min(v);
  mx  = max(v);
  avg = mean(v);
end

data = [4 7 2 9 1 5 8 3 6];
[lo, hi, mu] = stats(data);
% lo = 1  hi = 9  mu = 5

Discarding outputs with ~

[~, top, ~] = stats([10 30 20]);   % top = 30

nargin — optional arguments

nargin is injected into every function body and holds the number of arguments actually passed by the caller. Use it to implement default parameter values:

function y = power_fn(base, exp)
  if nargin < 2
    exp = 2;
  end
  y = base ^ exp;
end

power_fn(5)     % 25   (exp defaults to 2)
power_fn(2, 8)  % 256
power_fn(3, 3)  % 27

return — early exit

return exits the function immediately. All output variables must be assigned before return is reached:

function g = gcd_fn(a, b)
  while b ~= 0
    r = mod(a, b);
    a = b;
    b = r;
  end
  g = a;
end

Recursive early return:

function result = factorial_r(n)
  if n <= 1
    result = 1;
    return
  end
  result = n * factorial_r(n - 1);
end

factorial_r(7)   % 5040

Scope

Each function call creates a fresh isolated scope:

  • The caller’s data variables (scalars, matrices, strings, etc.) are not visible.
  • Declared parameters are bound in the local scope.
  • i, j, and ans are pre-seeded.
  • nargin and nargout are injected.
  • All Function and Lambda values from the caller’s workspace are forwarded, enabling self-recursion and mutual recursion without leaking data.

Anonymous functions (lambdas)

f = @(params) expr

@(x) expr creates a closure. The current environment is captured at definition time (lexical scoping):

sq   = @(x) x ^ 2;
cube = @(x) x ^ 3;
hyp  = @(a, b) sqrt(a^2 + b^2);

sq(7)        % 49
cube(4)      % 64
hyp(3, 4)    % 5

Lexical capture

Changing a variable after a lambda is defined does not affect the lambda:

rate = 0.05;
interest = @(principal, years) principal * (1 + rate) ^ years;

interest(1000, 10)   % 1628.89  (captured rate = 0.05)

rate = 0.99;         % lambda is unaffected
interest(1000, 10)   % still 1628.89

Lambdas as arguments

Pass a lambda to a named function using @:

function s = midpoint(f, a, b, n)
  h = (b - a) / n;
  s = 0;
  for k = 1:n
    xm = a + (k - 0.5) * h;
    s += f(xm);
  end
  s *= h;
end

% integral of x^2 from 0 to 1 = 1/3
midpoint(@(x) x^2, 0, 1, 1000)         % 0.333333

% integral of sin(x) from 0 to pi = 2
midpoint(@(x) sin(x), 0, pi, 1000)     % 2.000001

Functions returning functions

Named functions can return lambdas (higher-order programming):

function f = make_adder(c)
  f = @(x) x + c;
end

add5  = make_adder(5);
add10 = make_adder(10);

add5(3)            % 8
add10(7)           % 17
add5(add10(1))     % 16

Implementation details

ConcernSolution
Circular dependency (eval.rsparser.rs)Named functions store body_source: String; re-parsed on each call in exec.rs
Cross-module dispatchThread-local FnCallHook in eval.rs, registered by exec::init()
Lexical closureLambda captures Env clone at @ parse time; stored as Value::Lambda(Rc<dyn Fn>)
Recursioncall_user_function copies all Function/Lambda entries from caller’s env into local scope
Multi-returnValue::Tuple(Vec<Value>) returned and destructured by Stmt::MultiAssign
Empty call f()Parser injects Expr::Var("ans"); both call sites trim 1 extra arg silently

New AST nodes and tokens

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

New Value variants

VariantDescription
Value::Function { outputs, params, body_source }Named user-defined function
Value::Lambda(LambdaFn)Anonymous function / closure
Value::Tuple(Vec<Value>)Internal multi-return value

Example

Run the full demo:

ccalc examples/user_functions.calc

Phase 12.5 — Cell Arrays

Version: v0.17.0+005
Prerequisite: Phase 12 (lambdas needed for cellfun/arrayfun)
Trigger: Three features blocked on Value::Cell: case {2, 3} (Phase 11.5b), varargin/varargout (deferred from Phase 12), and cellfun/arrayfun (higher-order built-ins).


12.5a — Core cell array infrastructure

Value::Cell

Added to env.rs after Value::Tuple:

#![allow(unused)]
fn main() {
/// Heterogeneous 1-D container: each element may be any `Value`.
Cell(Vec<Value>),
}

Only 1-D cell vectors for now (Vec<Value>). 2-D cell arrays deferred.

New tokens and AST nodes

Tokens (parser.rs):

  • Token::LBrace ({) and Token::RBrace (})

Expr variants (eval.rs):

#![allow(unused)]
fn main() {
CellLiteral(Vec<Expr>)        // {e1, e2, e3}
CellIndex(Box<Expr>, Box<Expr>)  // c{i}
FuncHandle(String)             // @funcname
}

Stmt variant (parser.rs):

#![allow(unused)]
fn main() {
CellSet(String, Expr, Expr)   // c{i} = v
}

Parser changes

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

Evaluator changes (eval.rs)

  • Expr::CellLiteralValue::Cell(vals)
  • Expr::CellIndex → bounds-check (1-based), returns element value
  • Expr::FuncHandle(name)Value::Lambda that looks up name in caller env then falls back to builtins

Executor changes (exec.rs)

  • Stmt::CellSet → look up cell in env, update element at index, auto-grow if needed
  • is_truthy extended: Value::Cell(v) => !v.is_empty()

Built-ins

FunctionDescription
iscell(v)1.0 if v is Value::Cell, else 0.0
cell(n)Value::Cell of n slots, each Scalar(0.0)
cell(m, n)Value::Cell of m*n slots
numel(c)element count of a cell
length(c)same as numel for 1-D
size(c)[1, numel(c)] as 1×2 matrix

Display

c =
  {
    [1,1]: 42
    [1,2]: hello
    [1,3]: [1×3 double]
  }

12.5b — varargin / varargout

varargin

When a user function’s last parameter is named varargin, all extra call arguments are collected into a Value::Cell and bound to varargin in the local scope. Fixed parameters are bound normally.

function s = sum_all(varargin)
  s = 0;
  for k = 1:numel(varargin)
    s += varargin{k};
  end
end

sum_all(1, 2, 3)    % varargin = {1, 2, 3}  →  6
sum_all()           % varargin = {}          →  0

varargout

When the sole output variable is varargout, after the function body executes, its cell elements are unpacked as return values:

function varargout = swap(a, b)
  varargout{1} = b;
  varargout{2} = a;
end

[x, y] = swap(10, 20)   % x = 20, y = 10

Implementation note (injection fix, v0.17.0+005)

The parser previously injected Expr::Var("ans") into every empty f() call at the AST level. This made sum_all() and sum_all(1) indistinguishable inside call_user_function when fixed_params.is_empty(), causing varargin to always be empty.

Fix: injection moved from parser to eval-time:

  • Builtins and lambdas: inject ans when called with empty args (f() = f(ans))
  • Value::Function (user functions): no injection — empty call = no arguments

12.5c — case {val1, val2} in switch

Completes Phase 11.5b. When a switch case value evaluates to Value::Cell, the evaluator iterates its elements and tests each with == against the switch expression. First match wins; no fall-through (same semantics as scalar case).

switch x
  case {2, 3}
    disp('two or three')
  case {10, 20, 30}
    disp('ten, twenty, or thirty')
end

No parser change needed: case {2, 3} was already parsed as Expr::CellLiteral once LBrace/RBrace tokens existed (12.5a).


12.5d — cellfun and arrayfun

Higher-order built-ins that apply a function to each element of a collection.

cellfun(f, c)

Applies f to each element of cell c. Returns Value::Matrix when all results are scalar; returns Value::Cell otherwise.

cellfun(@sqrt, {1, 4, 9})         % [1  2  3]
cellfun(@(x) x * 2, {1, 4, 9})   % [2  8  18]

arrayfun(f, v)

Applies f to each element of numeric vector v. Returns same-shape Value::Matrix.

arrayfun(@(x) x^2, [1 2 3 4])     % [1  4  9  16]
arrayfun(@abs, [-1 2 -3 4])        % [1  2   3  4]

Both implemented as cases in call_builtin; no new AST nodes.


@funcname function handles

@funcname (without parentheses) creates a Value::Lambda that forwards its arguments to funcname — either a builtin or a named user function stored in the environment.

Expr::FuncHandle(name)  →  Value::Lambda wrapping name lookup + call

The lambda captures the caller’s env at creation time. On each call it first checks the captured env for a user function, then falls back to call_builtin.

f = @sqrt;
g = @myFunc;     % wraps a user-defined function

cellfun(@sqrt, {1, 4, 9})   % works: @sqrt passes sqrt as a Lambda

Tests

17 new tests in parser_tests.rs covering:

  • Cell literal and indexing
  • Cell assignment and auto-grow
  • iscell, cell(), numel, length
  • cellfun, arrayfun
  • switch with case {…}
  • varargin
  • Out-of-bounds index error

Known limitations

  • 2-D cell arrays are not supported (all cells are 1-D Vec<Value>)
  • c{i} is only supported where i is a simple expression — postfix chaining c{k}(args) is not yet supported (use f = c{k}; f(args) instead)
  • Workspace save/load skips cell variables (same policy as matrices)

Phase 12.6 — Language Polish and Small Completions

Version: v0.18.0+001
Prerequisite: Phase 12.5 (cell arrays — required for strsplit return type)

This phase closes accumulated gaps that are each small in isolation but collectively leave visible holes in Octave/MATLAB compatibility.


12.6a — Single-line blocks

if, for, while, and switch can now appear on a single line with their body and closing end separated by semicolons:

if x > 5; label = 'big'; end
for k = 1:5; total += k; end
while mod(n,2) == 0; n = n/2; end
switch day; case 1; name='Mon'; case 2; name='Tue'; otherwise; name='?'; end

Implementation: is_single_line_block(line) detects a complete block by splitting on ; and checking whether the last segment’s leading keyword is end or until. The REPL and pipe mode bypass block buffering for these lines.


12.6b — Line continuation ...

A line ending with ... (after stripping comments) continues on the next line:

result = 1 + ...
         2 + ...
         3;               % result = 6

A = [1 2 3; ...
     4 5 6];              % 2×3 matrix

if value > 0 && ...
   value < 100
  disp('in range')
end

Three implementation points:

  • REPL: cont_buf accumulates partial lines; not dispatched until continuation ends.
  • Pipe/file mode: same cont_buf logic in run_pipe.
  • Block parser: join_line_continuations() pre-pass in parse_stmts joins ...-continued lines before statement splitting.

12.6c — Element-wise logical operators & and |

&& and || are short-circuit scalar operators. & and | are element-wise operators that work on matrices and always evaluate both sides:

a = [1 0 1 0];
b = [1 1 0 0];

a & b          % [1 0 0 0]   element-wise AND
a | b          % [1 1 1 0]   element-wise OR

% Logical mask — common pattern
v = [3, -1, 8, 0, 5, -2, 7];
mask = v > 0 & v < 6    % [1 0 0 0 1 0 0]

New tokens Token::Amp / Token::Pipe and Op::ElemAnd / Op::ElemOr. New parse levels parse_elem_or and parse_elem_and sit between parse_logical_and and parse_comparison in the precedence hierarchy.


12.6d — xor and not built-ins

xor(1, 0)                       % 1
xor(0, 0)                       % 0
xor([1 0 1 0], [1 1 0 0])       % [0 1 1 0]

not(0)                           % 1   (alias for ~)
not(5)                           % 0
not([1 0 1])                     % [0 1 0]

12.6e — Lambda source display

Lambdas now display their source expression instead of @<lambda>:

>> f = @(x) x^2 + 1
f = @(x) x ^ 2 + 1

>> g = @(a, b) sqrt(a^2 + b^2)
g = @(a, b) sqrt(a ^ 2 + b ^ 2)

>> h = @sin
h = @sin

LambdaFn carries a second field with the source string, populated at parse time by expr_to_string() which reconstructs a readable expression from the AST.


12.6f — String utilities

% strsplit — returns a cell array
parts = strsplit('alpha,beta,gamma', ',')
parts{1}                          % 'alpha'
parts{2}                          % 'beta'

words = strsplit('hello world')   % split on whitespace
numel(words)                      % 2

% int2str — round to integer, return string
int2str(3.2)                      % '3'
int2str(3.7)                      % '4'
int2str(-1.5)                     % '-2'

% mat2str — matrix to MATLAB literal string
mat2str([1 2; 3 4])               % '[1 2;3 4]'
mat2str([10 20 30])               % '[10 20 30]'

strsplit returns Value::Cell of Value::Str — requires Phase 12.5.


12.6g — .' non-conjugate transpose

A' is the conjugate transpose (Hermitian) — it flips the sign of complex imaginary parts. A.' is the plain transpose with no conjugation:

% Real matrices — identical result
B = [1 2 3; 4 5 6];
B.'       % [1 4; 2 5; 3 6]   (same as B')

% Complex — different result
z = 3 + 4i
z'        % 3 - 4i   (conjugate)
z.'       % 3 + 4i   (plain)

Token::DotApostrophe is emitted when . is immediately followed by '. Expr::PlainTranspose(Box<Expr>) evaluates identically to Expr::Transpose for real values, but skips complex conjugation for Value::Complex.


12.6j — Minor syntax completions

Unary +

+x is a no-op (returns x unchanged). Previously caused a parse error.

+5             % 5
+(-3)          % -3
+[1 2 3]       % [1 2 3]

** exponentiation alias

Octave accepts ** as a synonym for ^:

2 ** 10        % 1024
3 ** 3         % 27
2 ** 0.5       % 1.41421...

, as non-silent statement separator

A comma between statements is like a newline — the result is shown (unlike ; which suppresses output):

a = 1, b = 2    % shows: a = 1  then  b = 2
a = 1; b = 2    % a silent, b shown

Bug fixes (v0.18.0+001)

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

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

  • ... in pipe moderun_pipe now has the same cont_buf logic as run_repl, so multi-line scripts using ... work correctly.


Example

ccalc examples/language_polish.calc

The example covers all ten sections with expected output annotations.

Phase 13 — Structs

Version: 0.19.0 Status: Complete (13a — scalar structs)


Motivation

Scalar structs are required by:

  • Real .m scripts that pass labelled data between functions
  • Phase 14 (try/catch) — the caught exception object e is a struct with fields message and identifier
  • dir() — returns a struct array of file entries (Phase 13.5, deferred)

13a — Scalar structs (complete)

Value type

Value::Struct(IndexMap<String, Value>) in env.rs. Uses the indexmap crate to preserve insertion order (MATLAB-compatible). Added dependency:

# crates/ccalc-engine/Cargo.toml
indexmap = "2"

Token

Token::Dot emitted in the tokenizer only when . is followed by an ASCII letter or underscore. The existing DotStar / DotSlash / DotCaret / DotApostrophe tokens are unaffected — no ambiguity.

AST changes

NodeDescription
Expr::FieldGet(Box<Expr>, String)s.x — postfix field read; chained via a loop in parse_primary: s.a.bFieldGet(FieldGet(Var("s"),"a"),"b")
Stmt::FieldSet(String, Vec<String>, Expr)s.x = rhs("s", ["x"], rhs); s.a.b = rhs("s", ["a","b"], rhs)

Parser

try_split_field_assign() — byte-level string scan that detects the pattern ident (.ident)+ = before tokenization. Called first in parse(), before try_split_cell_assign.

parse_primary() has a postfix loop that handles Token::Dot after any expression to build a FieldGet chain.

Execution — Stmt::FieldSet

Implemented in exec.rs:

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

Built-ins

FunctionBehaviour
struct('k1',v1,...)Constructor; requires an even number of arguments; struct() returns empty struct
fieldnames(s)Returns Value::Cell of Value::Str names in insertion order
isfield(s, 'x')Scalar(1.0) or Scalar(0.0)
rmfield(s, 'x')Copy of struct without the named field; error if absent
isstruct(v)Scalar(1.0) if Value::Struct, else Scalar(0.0)

struct() and the other struct built-ins skip the ans-injection logic (zero-argument built-in calls normally inject ans as the first argument — this is suppressed for struct/cell utilities via the no_ans_inject list in eval.rs).

Display

s =

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

Tests

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

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

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