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

Welcome

Welcome to the documentation for Andy C++!

This language is the result of a hobby project that I’ve poured a lot of time and effort into, purely for the fun of exploring new ideas in programming. I was inspired to create this language by Brian Chen, who developed Noulith to participate in (and ultimately win) Advent of Code. I’d like to thank Brian for the inspiration his work provided.

Disclaimers

  • To enhance clarity and minimize errors in this documentation, I used AI assistance, hoping it would make the language’s concepts easier to understand. Thank you for checking out Andy C++, I hope you enjoy exploring it as much as I enjoyed creating it!
  • This is the first time I attempted to make a programming language, and it’s one of my first Rust projects. You’re welcome to look at the source or even send in contributions but don’t expect it to be easy. I’m just an average guy bashing my head against problems until I stumble into a solution that seems to work. There will be a lot of really bad stuff under the hood.
  • I made this language for fun, and it’s meant to be used for fun. Don’t rely on the output of the interpreter to make important decissions. It took me 4 months before I noticed that subtracting floats was actually performing addition.
  • The performance of the interpreter is pretty bad, but as long as you come up with smart solutions and offload some of the calculations to functions written in rust you should be fine.

Installation

Prerequisites

Make sure you have Rust and Cargo installed on your system. If not, you can install them by following the instructions at https://rustup.rs/.

Installing

To install Andy C++ using Cargo, run the following command:

cargo install --git https://github.com/timfennis/andy-cpp

This will compile Andy C++ and install it into your Cargo bin directory (usually located at $HOME/.cargo/bin).

Hello, World!

Now that the Andy C++ interpreter is installed we can write our first program.

First create a file called hello.ndc add the code in Listing 1-1.

print("Hello, world!");

In Andy C++ we don’t need a main function. Semicolons are mandatory, just like they are in Rust. If you already know Rust, Andy C++ will feel very familiar.

$ ndc hello.ndc
Hello, world!

Variables and Scopes

In Andy C++, you declare new variables with let, like you do in Rust. Use = to reassign an existing variable.

let x = 1;
print(x); // 1

Shadowing

Andy C++ follows Rust-style shadowing. If you declare a variable with an existing name, the new binding hides the old one in that scope.

let x = 1;
let x = 2;
print(x); // 2

You can create a new scope with curly braces. A binding inside that scope can shadow an outer binding. When the scope ends, the outer binding stays available.

let x = 1;
{
  let x = 2;
  print(x); // 2
}
print(x); // 1

Scopes

In Andy C++, every block is an expression. End the block with an expression, not a semicolon, and that expression becomes the block’s value.

let x = {
  let a = 1;
  let b = 2;
  a + b
};

print(x); // 3

Reassignment

The = operator can be used to reassign a value to an existing variable. When you reassign a variable to a value of a different type, the variable’s type is widened to the least upper bound (LUB) of the old and new types.

let x = 1;     // the initializer and declaration have type Int
x = 2;         // subsequent reads still have type Int
x = 3.14;      // subsequent reads have type Any (Int and Float are siblings)
x;             // type is Any

Augmented assignment widens an inferred binding from the operation’s result in the same way:

let total = 3;
total += 0.5;  // Int + Float returns Float, so subsequent reads have type Any
total;         // type is Any
let pos = ();        // type is ()
pos = (1, 2);        // type widens to Sequence<Any>
pos = ("a", "b");    // type is still Sequence<Any>

Tip: For the best type inference, initialize variables with a value that matches the intended type. For example, use let pos = (0, 0); instead of let pos = (); if you intend to store a 2-tuple of numbers.

Type annotations

You can pin a variable’s type by adding : Type after the name. The initialiser still has to fit, the analyser just checks it for you up front.

let count: Int = 0;
let name: String = "world";
let xs: List<Int> = [1, 2, 3];

A subtype is fine. All concrete types fit where Any is requested:

let n: Number = 3n;
let x: Any = "anything";  // every value fits Any

A mismatch is rejected with a mismatched types error:

let x: Int = "hello";   // ERROR: mismatched types: found String but expected Int

An empty container literal adopts the annotated type — there are no elements to infer from, so the annotation decides:

let xs: List<Int> = [];        // xs is List<Int>, not List<Any>
let m: Map<Int, Int> = %{};    // m is Map<Int, Int>
let nested: List<List<Int>> = [[]];

Once a binding has an annotation, it stays locked to that type. Reassignment and augmented assignment can’t widen it the way they widen an inferred binding:

let x: Int = 5;
x = "test";   // ERROR: mismatched types
x += 0.5;     // ERROR: the Float result doesn't fit in Int

If you want a binding that widens freely, just leave the annotation off. Annotations are opt-in.

The same syntax shows up on function parameters and return types — see the Function page.

See Types for the full list of names you can use, including generics like List<T>, Map<K, V>, and tuple shorthand (Int, String).

Destructuring

Destructuring works more like Python than Rust. Commas matter more than the delimiters, so [] and () both work.

The statements below are all equivalent:

let a, b = 3, 4;
let [a, b] = 3, 4;
let (a, b) = [3, 4];
let [a, b] = (3, 4);

You can also destructure nested patterns:

let [a, (b, c)] = (1, [2, 3]);

Types

Andy C++ runs as a dynamically typed language — values carry their types at runtime and most checking happens then. You can also attach type annotations to variables, function parameters, and return values, and the analyser will use them to flag obvious mismatches before the program runs.

The type system is hierarchical with Any at the root:

  • Any
    • Option<T>
    • Bool
    • Int — checked signed 64-bit integers
    • Float — IEEE 754 f64 values
    • Number — arbitrary-size, rational, floating-point, and complex values
    • Sequence<T>
    • Function
    • Structs: user-defined record types, one per struct declaration

These are also the names you write in annotations. Generic types take their parameters in angle brackets:

let xs: List<Int> = [1, 2, 3];
let table: Map<String, Int> = %{"a": 1, "b": 2};
let maybe: Option<Any> = Some("hi");
let pair: Tuple<Int, String> = (1, "hi");
let pair2: (Int, String) = (1, "hi");  // tuple shorthand

Nested generics work too — the parser handles the >> ambiguity for you:

let grid: List<List<Int>> = [[1, 2], [3, 4]];

Struct names are type names like any other, including inside generics:

struct Point { x: Int, y: Int }

fn manhattan(p: Point) => p.x + p.y
let points: List<Point> = [Point(1, 2), Point(3, 4)];

A struct name refers to the struct declared earlier in an enclosing scope; it cannot be used before (or inside) its own declaration, and it goes out of scope together with the block or function that declared it. See Struct for the scoping rules.

Note: Any is the base type for every other type, so an Any-annotated binding will accept anything. When a parameter or value has no annotation and the analyser can’t infer a type, it falls back to Any. There is also a Never type used internally for things like break that don’t produce a value — you’ll rarely need to write it by hand.

To go the other way — recover a precise type from an Any-typed value — use a cast: value as List<Int>.

Option

let value = Some(3);

if value.is_some() {
   // Some!!
   let value = value.unwrap();
}

if value.is_none() {
    value.unwrap(); // ERROR!!
}

Some functions have variations with a ? appended to their name that return options instead of throwing errors:

let empty = [];
let fst = empty.first(); // ERROR: list is empty
let fst = empty.first?(); // None

let my_list = [1,2,3];
let fst = my_list.first?; // Some(1)

A list has two pairs of element accessors. get / get? take a non-negative index (a usize); index / index? take a signed integer and wrap negative indices from the end, like the [] operator. The ? variant of each returns None when the index is past the end instead of erroring:

let my_list = [10, 20, 30];

my_list.get(0);     // 10
my_list.get(5);     // ERROR: index 5 is out of bounds
my_list.get?(0);    // Some(10)
my_list.get?(5);    // None
my_list.get?(-1);   // ERROR: a negative index is not a valid usize

my_list.index(-1);  // 30   (wraps from the end)
my_list.index(5);   // ERROR: index out of bounds
my_list.index?(-1); // Some(30)
my_list.index?(5);  // None

unwrap_or extracts the contained value, falling back to a default when the option is None:

[10, 20, 30].get?(1).unwrap_or(0); // 20
[10, 20, 30].get?(9).unwrap_or(0); // 0

Note: unfortunately the language doesn’t support pattern matching on options

Boolean

Booleans in Andy C++ represent a binary state, capable of holding either the value true or false. Unlike some other languages, where booleans are often used as numbers, Andy C++ treats them as a distinct type. When attempting to perform arithmetic operations with booleans, such as true + true, Andy C++ will throw an error because these operations are not defined for this type.

Operators defined for booleans:

OperatorFunction
&non-lazy and
|non-lazy or
~non-lazy xor
!not
orlazy logical or
andlazy logical and
notlogical not like ! but lower precedence

See Logical operators for short-circuiting and precedence rules.

Numbers

Andy C++ exposes three sibling numeric types:

  • Int stores a signed 64-bit integer. Checked arithmetic reports overflow. The remainder operators % and %% are the exception: once the divisor is non-zero the result always fits, even where the quotient it implies would not.
  • Float stores an IEEE 754 f64.
  • Number supports arbitrary-size integers, exact rational values, floats, and complex values.

Int, Float, and Number share Any as their nearest common supertype. An Int does not satisfy a Number annotation. Use an n literal or the Number constructor when you need the advanced mode:

let count: Int = 42;
let measurement: Float = 42.0;
let exact: Number = 42n;

assert_eq(Number(42), 42n);
assert_eq(Number(42.0), 42.0n);

Literals

The n suffix creates a Number from a decimal integer or float. Binary, octal, and hexadecimal integers also accept it:

let large = 123456789123456789123456789n;
let decimal = 1.25n;
let binary = 0b101010n;
let octal = 0o52n;
let hexadecimal = 0x2an;

An integer literal without n must fit in i64. The lexer reports an error and suggests the suffixed form when it does not fit.

Arbitrary-radix literals such as 16r2a remain Int literals and do not accept n. The i and j suffixes create complex Number values:

let z: Number = 2 + 3i;
assert_eq(z, 2 + 3j);

Arithmetic modes

The arithmetic operators +, -, *, /, \, %, %%, and ^ define all nine pairs of Int, Float, and Number. The operands select the result type:

OperandsResult
Int, IntInt
Int, Float or Float, IntFloat
Float, FloatFloat
Any pair containing NumberNumber

Int uses checked i64 arithmetic. / truncates toward zero, while \ rounds toward negative infinity. % pairs with truncating division and %% returns a Euclidean remainder:

assert_eq(-7 / 2, -3);
assert_eq(-7 \ 2, -4);
assert_eq(-7 % 2, -1);
assert_eq(-7 %% 2, 1);

Float follows IEEE 754 behavior. Number keeps integer and rational operations exact when it can:

assert_eq(7 / 2, 3);
assert_eq(7n / 2n, 7n / 2n);
assert_eq(2n ^ 100n, 1267650600228229401496703205376n);

Integer powers and shifts must fit their checked Int result. Use Number for negative exponents, arbitrary-size powers, and complex continuation. Roots, logarithms, inverse trigonometric functions, and fractional powers return a complex Number when the real result does not exist:

assert_eq(5n ^ -1n, 1n / 5n);
assert_eq(sqrt(-1n), 1i);

Division by zero

Int division and remainder by zero report an error. Float returns IEEE infinity or NaN. Number also falls back to a wrapped Float result when an exact zero divisor has no rational representation:

assert_eq(1n / 0n, Inf);
assert_eq(1n \ 0n, Inf);

let nan = 0n / 0n;
assert(nan == nan);

Both remainder operators follow the same rule, so 5n % 0n and 5n %% 0n are NaN where 5 % 0 and 5 %% 0 are errors. Moving an accumulator from Int to Number therefore trades the zero-divisor diagnostic for a value that propagates.

Equality, hashing, and ordering

Numeric equality compares exact values across all three modes. Equal values produce the same map or set hash. Andy C++ converts each finite Float to its exact binary rational value for this comparison, so decimal approximation does not make values equal:

assert(1 == 1.0);
assert(1.0 == 1n);
assert(1n == 1 + 0i);
assert(0.1 != 1n / 10n);
assert_eq(%{1, 1.0, 1n, 1 + 0i}.len(), 1);

Positive and negative zero compare equal. All NaN values compare equal and hash alike. Real scalars sort in this order:

-Inf < finite values < Inf < NaN

Complex values keep lexicographic ordering. The comparison checks the real part, then the imaginary part:

assert((2 + 0i) > (1 + 100i));
assert((1 + 2i) < (1 + 3i));

Integer-only operators

Only Int supports bitwise operations and shifts:

OperatorFunction
|Bitwise OR
&Bitwise AND
~Binary XOR or unary NOT
>>Checked right shift
<<Checked left shift

Use Int values for list indices, range bounds, and APIs that take counts. Convert with int(value) when the value fits in i64.

String

In Andy C++ a String is a mutable list of characters. Characters don’t have their own type in the language so if you iterate over a string you get strings of length 1. Just like in Rust strings are guaranteed (and required) to be valid UTF-8. This means that you can’t store arbitrary binary data in a String.

Indexing into a String is done by UTF-8 codepoint (equivalent to Rust’s char) rather than by byte offset. This means that indexing into a string is O(n) instead of O(1).

let string = "I ❤ Andy C++";
assert_eq(string[2], "❤");
assert_eq(string[4], "A");

The advantage is that it’s a bit easier to guess that A is the 4th character in the example above. The downside is that depending on which heart you’re using there might be an invisible Variation Selector after the heart which messes everything up. This specific behavior will probably change in the future.

Raw strings

You may also define raw strings using this syntax borrowed from rust.

let string = r#"raw string with "" lots of "" quotes"#;

and even

let string = r###"raw string with r#"stop doing this"#"###;

Removing prefixes and suffixes

strip_prefix(string, prefix) and strip_suffix(string, suffix) remove one matching prefix or suffix. strip_circumfix(string, prefix, suffix) removes both only when both match without overlapping. Each function returns a new string on success or () on mismatch, leaving the original string unchanged. Matches are exact and case-sensitive; empty patterns match without removing any text.

assert_eq("foobar".strip_prefix("foo"), "bar");
assert_eq("foobar".strip_suffix("bar"), "foo");
assert_eq("[hello]".strip_circumfix("[", "]"), "hello");
assert_eq("foofoo".strip_prefix("foo"), "foo");
assert_eq("hello".strip_prefix("x"), ());
assert_eq("aaa".strip_circumfix("aa", "aa"), ());
assert_eq("[]".strip_circumfix("[", "]"), "");

Operators

OperatorFunction
++concatenate two strings
<>coerces both operands into strings before concatenating them
incheck if left operand is a substring of right operand
==Equality
!=Inequality
>Greater (lexicographically)
<Less (lexicographically)
>=Greater equals (lexicographically)
<=Less equals (lexicographically)
<=>-1, 0, or 1 (lexicographically)
>=<-1, 0, or 1 (reverse lexicographically)

Note: The in operator checking if the left operand is a substring of the right operand is different from the behavior of in on lists.

Examples

let a = "foo" ++ 3; // Error: cannot concatenate string and int
let b = "foo" <> 3; // foo3
let c = 10 <> 5.0; // 105.0
let d = "oo" in "foobar"; // true

List

Lists are mutable in Andy C++.

let my_list = [1,2,3];

// Values inside a list can be changed
my_list[2] = 4;

// You can add elements to the end of a list
my_list.push(99);

// Remove and return the last element of the list
let element = my_list.pop();

Indexing

Lists, strings, and tuples also support negative indexes. Negative indexes count from the end.

let my_list = [1,2,3,4,5,6,7,8,9];
assert_eq(9, my_list[-1]);

Element accessors

Besides the [] operator, lists provide four element accessor functions. They differ on two axes: whether negative indexes are allowed, and what happens when the index is out of bounds.

functionindexout of bounds
getnon-negative only (negative is an error)error
get?non-negative only (negative is an error)None
indexsigned; negatives wrap from the enderror
index?signed; negatives wrap from the endNone
let my_list = [10, 20, 30];

assert_eq(my_list.get(0), 10);
assert_eq(my_list.get?(9), None);   // in range for a usize, past the end
assert_eq(my_list.index(-1), 30);   // wraps like `[]`
assert_eq(my_list.index?(-9), None);

index behaves like the [] operator (wrap + error on out of bounds); index? is its non-throwing counterpart.

Slicing

Use ranges to slice lists. Ranges can be inclusive or exclusive. Negative indices count from the end of the list.

let my_list = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]

// Exclusive range: 3 to 6 (does not include index 6)
assert_eq([30, 40, 50], my_list[3..6]);

// Inclusive range: 3 to 6 (includes index 6)
assert_eq([30, 40, 50, 60], my_list[3..=6]);

// Negative indices: Counting from the end of the list
assert_eq([80, 90], my_list[-3..-1]);

A range whose start lands after its end (for example my_list[6..3]) is out of bounds and raises an error rather than returning a reversed or empty slice. This holds for lists, strings, tuples and deques alike.

Operators

OperatorFunction
++Concatenation
<>Coerce operands into strings and concatenate
inChecks if an element is present in the list
not inChecks if an element is not present in the list
==Equality
!=Inequality
>Greater (lexicographically)
<Less (lexicographically)
>=Greater equals (lexicographically)
<=Less equals (lexicographically)

Tuple

Tuples are similar to lists, but you cannot change their elements after you create them.

let my_tuple = (1,2,3);

// You can access elements using indexing
assert_eq(my_tuple[0], 1);

// Tuples are immutable, so assignment fails
my_tuple[0] = 99; // ERROR

// You may also iterate over tuples
for item in my_tuple {
  // ..
}

Create a 1-element tuple by adding a trailing comma:

assert_eq((1,).len(), 1);

Copy-on-write

Tuples use copy-on-write. If an operation looks like it modifies a tuple, such as appending elements, Andy C++ keeps the original tuple and creates a new one for the updated value.

let a = (1,2,3);
let b = a;
b ++= (4,5);

// A remains the same
assert_eq(a, (1,2,3));

// B was copied and (4,5) was appended
assert_eq(b, (1,2,3,4,5));

Operators

OperatorFunction
++Concatenation
<>Coerce operands into strings and concatenate
inChecks if an element is present in the list
not inChecks if an element is not present in the list
==Equality
!=Inequality
>Greater (lexicographically)
<Less (lexicographically)
>=Greater equals (lexicographically)
<=Less equals (lexicographically)

Vectorization

Operators broadcast element-wise over tuples. Both arguments must be tuples of the same length, or one side may be a scalar that broadcasts:

assert_eq((1, 2) + (3, 4), (4, 6));
assert_eq(-(1, 2, 3), (-1, -2, -3));
assert_eq((1, 2) + 5, (6, 7));
assert_eq(("a", "b") ++ ("c", "d"), ("ac", "bd"));

Vectorization only kicks in for operator syntax (a + b, -x, a ++ b). Regular function calls never broadcast, so f((1, 2, 3)) passes the whole tuple to f and does not call f once per element.

Mixed-element tuples or length mismatches error at compile time rather than silently producing wrong results:

(1, 2, 3) + (4, 5)   // ERROR: no overload accepts those argument types
(1, "a") + (2, "b")  // ERROR: no `+(String, String)` overload

Unit

In Andy C++ the unit type is written as () and indicates that there is no value. Statements evaluate to the unit type for instance when a semicolon is used after the last value.

let result = if x == y {
  5 + 5;
} else {
  10 + 10;
};

// Result is unit because the last expression in the blocks has a semicolon
assert_eq(result, ());

Unit is also a 0-length tuple:

assert_eq((1,2,3)[0..0], ());

The language does not have null or nil.

Map and Set

Andy C++ does not have separate map and set types. A set is a map whose values all use the unit type ().

Set

Because { is already used to create a block expression, Andy C++ uses %{ to create a set or map.

let my_set = %{5, 3, 2, 1};

Map

let my_map = %{
    "apples": 60,
    "oranges": 22,
    "bananas": 0,
};

Default values

let defaultdict = %{:0};

// Missing keys in defaultdict default to 0
assert(defaultdict[10] == 0);

// You can also mutate a missing key this way
defaultdict[33] += 7; // adds 7 to 0 and associates it to key 33

Note: Lists are copied by reference. If you use [] as a dictionary default value, every missing key gets the same list instance. Use a default function instead.

Default functions

You can also specify the default value as a function. Andy C++ evaluates that function each time it needs a new value. Use this form when each missing key needs its own list.

let dd = %{:fn() => []};
dd["foo"].push(3);
dd["bar"].push(4);

assert_eq(dd["foo"].len, 1);
assert_eq(dd["bar"].len, 1);

You can still use a function itself as the default value:

let dd = %{:fn() => fn(x) => x * x};
print(dd["test"](5)); // 25

Iteration

Iterate over a map with a for loop. Each element is a (key, value) tuple:

let m = %{"a": 1, "b": 2};
for (k, v) in m {
    print(k, v);
}

Iteration order is unspecified. Maps are hash-based, so keys may appear in any order.

Keys are snapshotted at the start of the loop. Mutations to the map during iteration, such as adding or removing keys, are not reflected in the current loop. The set of keys visited is fixed when the for loop begins. Values read during iteration do reflect changes to existing keys.

Operators

OperatorFunctionSupport augmented assignment [1]Augmentable with not
|Uniontruefalse
&Intersectiontruefalse
~Symmetric differencetruefalse
inTest if lhs is part of the Set or a key in the Maptruetrue
==Equalitytruetrue
!=Inequalitytruetrue

Note: The union, intersection and symmetric difference operations retain the default value from the left operand

Note: The equality operations ignore the default value

Deque

A double-ended queue backed by Rust’s VecDeque.

This is very useful for implementing algorithms like BFS.


let queue = Deque();
queue.push_back((0, 0));

while not queue.is_empty() {
    let cur = queue.pop_front();
    
    // etc.
}

MinHeap & MaxHeap

A data structure backed by Rust’s BinaryHeap that keeps elements in sorted order. This is very useful when implementing algorithms like Dijkstra and A*.

Note: comparisons between types like Int and String are undefined and the Heap will treat them as equal. If you like well-defined behavior DO NOT MIX THEM.

Function

Functions are first-class values in Andy C++. You can store them in variables, pass them as arguments, and call them like any other value.

Named functions

Declare named functions with fn.

fn my_function(input) {
  if input == something {
    return foo;
  }

  // do some more stuff

  // implicitly return the last expression in the block
  bar
}

Function declarations are not hoisted. Define a function before you call it.

foo(); // ERROR: no function called foo exists

fn foo() {
  print("Hello, World!");
}

Functions close over their environment.

let x = [];

fn my_function(n) {
  // add argument n to x
  x.push(n);

  // return the sum of x
  x.sum()
}

assert_eq(10, my_function(10));
assert_eq(15, my_function(5));
assert_eq([10, 5], x);

Anonymous functions

Anonymous functions let you write inline function values.

// An anonymous function that adds its operands together
let my_function = fn(a, b) { a + b };

assert_eq(my_function(5, 3), 8);

fn foo() {
    // whatever
}

let my_variable = foo; // Store the foo-function in a variable

map applies an anonymous function to each element in the list:

let my_list = [x for x in 1..10];
let out = my_list.map(fn(x) => x * 10);

Arrow syntax

Use the fat arrow => for functions that return a single expression.

// Works for anonymous functions
let my_function = fn(a, b) => a + b;

// Also works for named functions
fn foo() => "whatever";

// Note that commas have a very low precedence in this example x is a tuple (function, 3)
let x = fn(y) => y, 3;

// If you want to return a tuple from a function written in this way you must use parentheses
let x = fn(y) => (y, 3);

Type annotations

Parameters and return values can carry type annotations, just like let bindings:

fn greet(name: String) -> String => "hello " <> name;

fn add(x: Int, y: Int) -> Int {
    x + y
}

Annotations are optional — leave them off and the parameter is treated as Any. Mix and match as you like:

fn first(xs: List<Int>) => xs[0];   // params annotated, return inferred
fn count(xs) -> Int => len(xs);      // return annotated, params inferred

If the body produces a value that doesn’t fit the declared return type, the analyser flags it:

fn bad() -> Int { "hello" }   // ERROR: mismatched types

A return-type annotation also helps the analyser understand recursive calls — without it, a recursive call resolves against an unknown return type and you can lose precision.

Function overloading

You can overload functions by declaring multiple fn definitions with the same name and different parameter counts.

fn add(n) { n + 1 };
fn add(x, y) { x + y };

assert_eq(10, add(9));
assert_eq(12, add(8, 4));

Declaring two fn definitions with the same name and the same number of parameters in the same scope is an error:

fn foo(a) { a + 1 }
fn foo(a) { a + 2 } // ERROR: redefinition of 'foo' with 1 parameter

Note: The engine can also dispatch by argument type, and the standard library uses that to register specialised overloads (for example, an Int-only fast path for +). User code can’t declare two overloads with the same name and arity yet, even when the parameter types differ — the resolver only distinguishes overloads by parameter count.

Function shadowing

A fn declaration in a nested scope shadows only the overload with the same parameter count from outer scopes. Other overloads remain available:

fn foo(a) { "outer-one" }
fn foo(a, b) { "outer-two" }

{
    fn foo(a) { "inner-one" }
    foo("x");        // "inner-one": inner 1-arg shadows outer 1-arg
    foo("x", "y");   // "outer-two": outer 2-arg still reachable
}

foo("x");  // "outer-one": shadow is gone after the block

A let binding with a function value replaces all previous bindings with the same name. It does not participate in function overloading:

fn foo(a) { "one" }
fn foo(a, b) { "two" }

let foo = fn(a) => "let";
foo("x");      // "let": both fn overloads are shadowed

A non-function let binding shadows the name for value access, but function calls still resolve to the underlying function:

let len = 300;
len;           // 300: the value
len("test");   // 4: calls the stdlib function, skipping the non-function binding
"test".len;    // 4: method call also resolves to the function

Method call syntax

In Andy C++, you can call a function as a method on its first argument. You get method-style syntax without defining member functions.

fn add(x, y) {
  return x + y;
}

// Normal function call
print(add(3, 5));

// Method-like syntax
print(3.add(5));

Both forms do the same thing.

Implicit call

You can also omit () when you call a 0-ary function.

let input = "some text\nsome more text";
let lines = input.lines;

Struct

Structs are user-defined record types: a named collection of typed fields.

struct Point {
    x: Int,
    y: Int,
}

let p = Point(1, 2);
assert_eq(p.x, 1);

p.y = 20;
assert_eq(p, Point(1, 20));

Declaring a struct

A declaration names the struct and lists its fields. Every field requires a type annotation, and a trailing comma after the last field is allowed. A struct may have zero fields.

struct Person {
    name: String,
    age: Int,
    locations: List<String>,
}

struct Marker { }

Declare a struct on its own line, like a let declaration — at the top level of a program, inside a block, or in the REPL. A struct cannot be declared in the middle of another expression, so let s = struct P { x: Int } is an error.

Struct names are lexically scoped, like variables. Declaring two structs with the same name in the same scope is an error, but the name can be reused in scopes that never coexist, and a declaration in an inner scope shadows a same-named struct from an outer scope.

struct Point { x: Int }
struct Point { y: Int } // ERROR: Illegal redefinition of struct 'Point'

A struct declared inside a function or block goes out of scope with it: the type name, the constructor, and the field accessors are all unavailable outside.

A struct cannot take the name of a built-in type:

struct Int { x: Float } // ERROR: Struct 'Int' is not allowed to shadow the built-in type 'Int'

A struct can only be used after its declaration, and the name becomes usable as a type annotation in the scopes where the struct is visible.

Constructing instances

Declaring a struct binds a constructor function with the same name. It takes the field values positionally, in declaration order.

struct Point { x: Int, y: Int }

let p = Point(1, 2);

Constructor calls are checked before the program runs: passing the wrong number of arguments or incompatible types is a compile-time error.

Point(1);      // ERROR: no 'Point' matches the arguments 'Int'
Point("x", 2); // ERROR: no 'Point' matches the arguments 'String, Int'

Field access

p.x reads the field x from p. This is not special syntax for structs: declaring a struct binds an ordinary getter function per field, and p.x is exactly the call x(p). Method call syntax works too, so p.x() is the same call again.

struct Point { x: Int, y: Int }
let p = Point(1, 2);

assert_eq(p.x, 1);
assert_eq(x(p), 1);
assert_eq(p.x(), 1);

Because getters are ordinary function values you can pass them to higher-order functions:

let points = [Point(1, 10), Point(2, 20)];

assert_eq(points.map(x), [1, 2]);
assert_eq(points.map(fn (p) => p.x), points.map(x));

// The constructor is a function value too.
struct Wrap { v: Int }
assert_eq([1, 2, 3].map(Wrap), [Wrap(1), Wrap(2), Wrap(3)]);

Accessors are resolved by overloading, so two structs can share a field name without interfering:

struct Foo { size: Int }
struct Bar { size: Int }

assert_eq(Foo(1).size, 1);
assert_eq(Bar(10).size, 10);

Because s.f() is method-call syntax, calling a function stored in a field needs parentheses around the member access: (s.f)() first evaluates s.f (the getter) and then calls its result.

struct Callback { f: Any }
let cb = Callback(fn (a) => a * 2);

cb.f(21);    // ERROR: this is method-call syntax for `f(cb, 21)`
(cb.f)(21)   // 42: reads the field, then calls the stored function

Field assignment

p.x = value writes to a field. The value must fit the field’s declared type:

struct Point { x: Int }
let p = Point(1);

p.x = 10;      // fine
p.x = "ten";   // ERROR: mismatched types: found String but expected Int

Augmented assignment works on fields and evaluates the receiver expression exactly once:

struct Counter { hits: Int }
let c = Counter(1);

c.hits += 4;
assert_eq(c.hits, 5);

A field is a typed location, so an augmented assignment whose result would not fit the field type is rejected:

c.hits += 0.5; // ERROR: mismatched types: found Float but expected Int

Reference semantics

Struct instances are passed by reference, like lists and maps (see Memory Management). Assigning an instance to another variable aliases it rather than copying it:

struct Point { x: Int, y: Int }

let a = Point(1, 2);
let b = a;
b.x = 99;

assert_eq(a.x, 99);

Use clone for an independent instance (nested containers are still shared, like cloning a list of lists) or deepcopy to duplicate nested mutable state as well:

let c = clone(a);
c.x = 1;
assert_eq(a.x, 99);
assert_eq(c.x, 1);

Equality and hashing

Typing is nominal: instances of the same struct compare field by field, and instances of different structs are never equal, even when the fields match.

struct Foo { v: Int }
struct Bar { v: Int }

assert_eq(Foo(1) == Foo(1), true);
assert_eq(Foo(1) == Foo(2), false);
assert_eq(Foo(1) == Bar(1), false);

Instances are hashable, so they work as map keys and set members:

struct Point { x: Int, y: Int }

let visited = %{Point(0, 0): true};
assert_eq(visited[Point(0, 0)], true);

Structs and JSON

json_encode rejects structs, because the struct type would be lost: a JSON object decodes back to a map, not a struct. Use json_encode_lossy to encode an instance as a JSON object with the field names as keys.

struct Point { x: Int, y: Int }

json_encode_lossy(Point(1, 2)); // "{\"x\":1,\"y\":2}"
json_encode(Point(1, 2));       // ERROR: cannot convert a struct to JSON

Current limitations

  • Constructors are positional only; there is no named-field or default-value syntax.
  • Structs do not take generic parameters: Point<Int> is an error.
  • A struct cannot reference itself in its own field types. struct Node { next: Option<Node> } fails with unknown type, because the name is only registered after its field annotations are resolved.

Control flow

Control flow in Andy C++ uses expressions and keywords instead of punctuation-heavy syntax. This section covers branching, loops, and the lazy logical operators that often appear in conditions.

If/else

If-statements are very similar to how they are in Go and Rust. You don’t need parentheses around the conditions but braces around the body are required.

if x == 3 {
  // x is three
} else if y == 4 {
  // y is four
} else {
  // neither x is three or y is four
}

Expressions

Just like in Rust they are expressions and can be used to return a value when you omit the semicolon at the end of the last statement in the block.

let n = 5;
let x = if n > 0 {
  1
} else if n == 0 {
  0
} else {
  -1
};

While loop

Like in every other language a while loop will run as long as a condition is true. A while loop is an expression that always evaluates to the unit value ().

let n = 1;

while n <= 100 {
  if n % 15 == 0 {
    print("fizzbuzz");
  } else if n % 3 == 0 {
    print("fizz");
  } else if n % 5 == 0 {
    print("buzz");
  } else {
    print(n);
  }

  n += 1;
}

For loop

Start with a basic for loop:

for n in 1..=100 {
  if n % 15 == 0 {
    print("fizzbuzz");
  } else if n % 3 == 0 {
    print("fizz");
  } else if n % 5 == 0 {
    print("buzz");
  } else {
    print(n);
  }
}

You can combine multiple iterators in one loop:

let drinks = ["Coffee", "Tea", "Juice"];
let desserts = ["Cake", "Pie", "Ice Cream"];

// Print all combinations of drinks and desserts
for drink in drinks, dessert in desserts {
  print(drink <> " and " <> dessert);
}

You can also add one or more guards. This example finds all pairs from 1..10 with an even sum.

for x in 1..10, y in 1..10, if (x + y) % 2 == 0 {
  print(x, y, "is even");
}

For comprehensions

You can use the same syntax in a list comprehension.

// Produce a series of perfect squares
let perfect_squares = [x * x for x in 1..10];

assert_eq([1,4,9,16,25,36,49,64,81,100], perfect_squares);

The earlier example can also produce pairs:

let pairs_with_even_sum = [x, y for x in 1..10, y in 1..10, if (x + y) % 2 == 0]

Logical operators

Andy C++ uses the keywords and, or, and not for logical operators. If you are coming from Rust, write and and or instead of && and ||.

let ready = has_input and not failed;
let retry = timed_out or disconnected;

Short-circuiting

and and or are lazy. Andy C++ evaluates the right-hand side only when it needs that value to decide the result.

let x = 0;

true and { x = x + 1; false };
false and { x = x + 1; false };
true or { x = x + 1; false };
false or { x = x + 1; false };

assert_eq(x, 2);

false and ... stops at false, and true or ... stops at true.

Precedence

and binds tighter than or.

let a = true or true and false;  // true
let b = false and true or true;  // true

That means Andy C++ reads those expressions as:

let a = true or (true and false);
let b = (false and true) or true;

Bitwise operators

Boolean values also support the non-lazy operators &, |, and ~.

Use and and or when you want short-circuiting. Use & and | when you need both sides to run.

Memory Management

Andy C++ uses reference counting to manage memory. Every value that lives on the heap (strings, lists, maps, closures, etc.) is wrapped in a reference-counted pointer. When the last reference to a value is dropped, it is freed immediately — there is no garbage collector and no pause times.

Reference Cycles

Reference counting cannot detect cycles: two or more values that refer to each other. When a cycle exists, the reference count of each value in the cycle never reaches zero, so the memory is never freed.

There are two common ways to create cycles:

Mutable containers that reference each other

{
    let m = %{};
    let n = %{};
    m["n"] = n;
    n["m"] = m;
}
// m and n are out of scope, but each map still holds
// a reference to the other — neither can be freed.

Self-referential closures

A closure that captures a variable which holds a reference back to itself creates a cycle:

{
    let f = ();
    f = fn(x) { if x > 0 then f(x - 1) else 0 };
}
// The upvalue for f is now closed over a closure
// that itself holds a reference to the same upvalue.

Practical Impact

For short-lived scripts this is a non-issue — all memory is reclaimed when the process exits. Cycles only become a problem in long-running programs that repeatedly create and discard cyclic structures. If you find yourself in that situation, break the cycle manually by assigning () to one side before the values go out of scope:

m["n"] = ();  // break the cycle

Augmented assignment

Andy C++ supports augmented assignment for operations on existing variables.

This example increments a number with augmented assignment.

let my_number = 3;
my_number += 5;

assert_eq(my_number, 8);

Indexed targets

The target of an augmented assignment can also be an indexed location:

let values = [1, 2, 3];
values[0] += 10;
assert_eq(values, [11, 2, 3]);

This works even when reading the location produces a new value rather than a reference, such as a character of a string or a slice of a list. The updated value is always stored back into the container:

let text = "ab";
text[0] ++= "x";
assert_eq(text, "axb");

let items = [1, 2];
items[0..1] ++= [3];
assert_eq(items, [1, 3, 2]);

The target, index, and right-hand side are each evaluated exactly once, in source order. The augmented assignment expression itself evaluates to ().

Type checking

An augmented assignment with an in-place operator such as ++= never changes the type of its target. If the right-hand side would force a type change the program is rejected:

let values = [1];
values ++= ["two"]; // error: mismatched types: found List<String> but expected List<Int>

The right-hand side has to provably fit. A wider element type is not enough, because the operator copies the values across without checking them:

let values: List<Int> = [1];
let rhs: List<Number> = [0.5];
values ++= rhs; // error: mismatched types: found List<Number> but expected List<Int>

The same goes for an operand whose type is unknown. Any says nothing about what the value holds, and no later step re-checks it, so it is rejected too:

fn opaque(x) => x;
let values = [1];
values ++= opaque([2, 3]); // error: mismatched types: found Any but expected List<Int>

Cast the operand to say what it holds. The cast checks the value, so the assignment gets its guarantee from the cast site:

fn opaque(x) => x;
let values = [1];
values ++= opaque([2, 3]) as List<Int>;
assert_eq(values, [1, 2, 3]);

A cast that does not hold fails there rather than corrupting the target:

values ++= opaque([0.5]) as List<Int>; // error: cannot cast List<Float> to List<Int>

Annotate the target with Any to opt into heterogeneous contents:

let mixed: List<Any> = [1];
mixed ++= ["two"];
assert_eq(mixed, [1, "two"]);

Operators without an in-place implementation behave like target = target op value and may widen the inferred type of the target, just like a regular assignment:

let numbers = [1];
numbers[0] += 0.5; // fine: the element type widens from Int to Any
assert_eq(numbers, [1.5]);

Optimization

You might expect list ++= [1,2,3] to desugar to list = list ++ [1,2,3], but that would waste work. Andy C++ handles some augmented assignments directly. In this case, it appends [1,2,3] without creating an intermediate list.

Flexibility

Note: I stole this feature from Noulith.

Augmented assignment also works with built-in functions and user-defined functions. For example:

let x = 3;
let f = fn (a, b) { a + b }; // simple addition
x f= 5; // similar to: x = f(x, 5);
assert_eq(x, 8);

One common use case is tracking the highest or lowest value in a loop:

let lowest, highest = Inf, -Inf;

for x in 1..100 {
  lowest min= g(x);
  highest max= g(x);
}

Casts

The as operator asserts that a value has the given type:

let values = %{};
values.insert(1);
values.insert(2);

// keys returns List<Any>; the cast recovers the element type so the
// result works with functions that expect a List<Int>.
let keys = values.keys as List<Int>;

A cast checks the value without converting it. 5 as Float is an error because 5 is an Int; use conversion functions like float(5) to change a value’s type.

Checking

When the compiler can already prove the cast from the value’s static type, the cast is free: no runtime check is emitted. Otherwise the value is checked at runtime, and a failed check raises an error at the cast site:

let value: Any = ["a"];
value as List<Int>
// error[vm]: cannot cast List<String> to List<Int>

Checking a container inspects every element (and recurses into nested containers), so a cast costs one pass over the value. Empty containers conform to any element type: [] as List<Int> succeeds.

A map’s default value is checked against the value type too, since a missing-key lookup inserts it. A default function’s results can’t be verified without calling it, so such a map only conforms when the value type is Any.

The analyser rejects casts between types that cannot share a value:

"foo" as Int
// error[resolver]: invalid cast: String can never be Int

Precedence

as binds tighter than binary operators and looser than unary operators and calls, so a + b as Int means a + (b as Int). Method calls bind tighter than the cast: recovering an element type before a method call needs parentheses, as in (values.keys as List<Int>).max().

A < after the cast type is read as a type argument list when the tokens that follow form one, and as a less-than comparison otherwise, so n as Int < 10 compares while xs as List<Int> casts.

Casting to state what a value holds

The analyser only accepts what it can prove. Where it cannot, a cast states the value’s type and checks it at the cast site.

Reassigning a variable widens its type, which can lose the detail a function needs:

let line = "a b c";
line = line.split(" ");    // line is now Sequence<String>, not List<String>
line.remove(0);            // error: no `remove` matches Sequence<String>, Int
assert_eq((line as List<String>).remove(0), "a");

A specialized op= mutates its target in place and keeps its type, so it only accepts a right operand that provably fits. A value of unknown type does not, and a cast gets the elements checked instead of letting them slip into the target unverified:

fn opaque(x) => x;
let values = [1];
values ++= opaque([2, 3]) as List<Int>;
assert_eq(values, [1, 2, 3]);

Limitations

Checking an iterator’s elements would consume it, so a runtime check against an element type other than Any fails. Cast a value into a typed collection before turning it into an iterator.

Method call syntax

In Andy C++, you can call a function as a method on its first argument. You get method-style syntax without defining member functions.

fn add(x, y) {
  return x + y;
}

// Normal function call
print(add(3, 5));

// Method-like syntax
print(3.add(5));

Both forms do the same thing.

Implicit call

You can also omit () when you call a 0-ary function.

let input = "some text\nsome more text";
let lines = input.lines;

Examples

let l = [50, 40, 20, 40, 10];

// A plain function-call version
let x = reduce(map(sorted(l), fn(x) => x + 5), fn(a, b) => a * b);

// The same code with method call syntax
let y = l.sorted
         .map(fn(x) => x + 5)
         .reduce(fn(a, b) => a * b);

Slices

Use ranges to slice lists. Ranges can be inclusive or exclusive. Negative indices count from the end of the list.

let my_list = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]

// Exclusive range: 3 to 6 (does not include index 6)
assert_eq([30, 40, 50], my_list[3..6]);

// Inclusive range: 3 to 6 (includes index 6)
assert_eq([30, 40, 50, 60], my_list[3..=6]);

// Negative indices: Counting from the end of the list
assert_eq([80, 90], my_list[-3..-1]);

A range whose start lands after its end (for example my_list[6..3]) is out of bounds and raises an error rather than returning a reversed or empty slice. This holds for lists, strings, tuples and deques alike.

Memoization

Andy C++ supports memoization through the pure keyword. Mark a function as pure when it has no side effects and returns the same output for the same inputs. Andy C++ can then cache and reuse previous results.

Syntax

Mark both named and anonymous functions as pure:

pure fn add(x, y) {
    x + y
}

let multiply = pure fn (x, y) { x * y };

Note: The interpreter does not check whether a function is actually pure. You must avoid side effects yourself.

Performance: keep memoization keys small

The cache key is computed by hashing all arguments. For container types like maps and lists this is an O(n) operation proportional to the number of elements. Passing large containers as arguments to a pure fn therefore adds hashing overhead on every call, even on cache hits.

If a container is large and does not change between recursive calls, such as a lookup table or graph, capture it as an upvalue instead of passing it as an argument. The upvalue is not part of the cache key, so memoization cost depends only on the arguments that change.

// Slow: `graph` is hashed on every call
pure fn count(graph, node, visited) { ... }

// Fast: `graph` captured as upvalue, only (node, visited) are hashed
let graph = build_graph();
pure fn count(node, visited) {
    // use graph here
}

Example: Fibonacci Sequence

pure fn fib (n) {
  return if n == 0 {
    0
  } else if n <= 2 {
    1
  } else {
    fib(n-2) + fib(n-1)
  }
}

for x in 1..1000 {
  print(x, fib(x));
}

Tracing

Andy C++ has built-in tracing support for inspecting how the bytecode VM executes your program. Tracing is available when the binary is compiled with the trace feature flag and is controlled via command-line flags on the run subcommand.

cargo run --features trace -- run --trace-print program.ndc

Multiple trace flags can be combined in a single invocation.

Trace modes

--trace-print

Prints every VM instruction to stderr as it is dispatched, along with the corresponding source excerpt:

[VM] 0000 GetGlobal(2)                    assert_eq
[VM] 0001 GetGlobal(87)                   +
[VM] 0002 Constant(0)                     15
[VM] 0003 Constant(1)                     3
[VM] 0004 Call(2)                         +

This is useful for understanding the exact sequence of bytecode operations your program produces.

--trace-histogram

Prints a summary table of how many times each instruction type was dispatched:

--------------------------------------------------
Instruction histogram (179 total)
--------------------------------------------------
  Call                         43  ( 24.0%)
  Constant                     44  ( 24.6%)
  GetLocal                     32  ( 17.9%)
  ...

--trace-time

Measures cumulative wall-clock time spent per instruction type and prints a summary:

------------------------------------------------------------
Instruction timing (total: 184µs)
------------------------------------------------------------
  Call                        69µs  ( 37.4%)
  Constant                    33µs  ( 18.1%)
  ...

The time for an instruction is measured from when it starts executing until the next instruction begins.

--trace-span

Renders the source code as a heat map, coloring regions from green (cold) to red (hot) based on how much execution time was spent on the bytecode instructions associated with each source span.

cargo run --features trace -- run --trace-span program.ndc

This gives a visual overview of where your program spends its time. The heat is additive: if a character is covered by multiple overlapping spans (e.g. an expression inside a loop body), all their durations contribute, so hot inner code within a hot loop shows as hotter than the surrounding syntax.

Note: The span-based heat map is a rough profiling aid, not a precise profiler. In particular, recursive function calls can produce misleading results because the function body spans overlap with themselves across call depths, and the timing of Call instructions only measures call-setup overhead rather than the total time spent in the callee.

Building with tracing

Tracing is behind a Cargo feature flag so it has zero cost when not compiled in:

# Without tracing (default) — no overhead
cargo build

# With tracing support
cargo build --features trace

The trace flags only appear in --help when compiled with the feature enabled.

Editor support

Andy C++ ships a language server (LSP) so editors can offer rich feedback as you write .ndc files. If you have Andy C++ isntalled you automatically also have the language server.

You can start the language server like this:

ndc lsp --stdio

Most users do not run this command by hand. Editor integrations either launch it automatically or are configured to run it for .ndc files.

What the language server provides

  • Diagnostics — lexer, parser, and semantic/type errors are reported inline as you type.
  • Inlay type hints — inferred types are shown after let bindings and function parameters, and inferred return types after function signatures. Hints are only shown where you didn’t already write an annotation.
  • Hover — hovering an expression shows its inferred type; hovering a built-in function shows its signature and documentation.
  • Completion — typing . offers functions whose first parameter accepts the receiver’s type (method-call style). General completion offers built-in functions, in-scope variables, and language keywords.
  • Document symbols — an outline of the top-level and nested functions and variable declarations in the file.
  • Go-to-definition — jump from a variable or function usage to its declaration.

VS Code and compatible editors

The Andy C++ extension on Open VSX provides syntax highlighting, all of the language-server features listed above, and a Run Script command that executes the current file in the integrated terminal.

Install it from the Extensions view in editors that use the Open VSX registry. For Microsoft VS Code, download the VSIX file from the Open VSX page and install it with Extensions: Install from VSIX… in the command palette.

The extension launches ndc lsp automatically. If ndc is not on the PATH seen by the editor, set andy-cpp.ndcPath to the full path of the binary.

JetBrains IDEs (RustRover, IntelliJ, …)

JetBrains IDEs are supported without a dedicated plugin, in two independent parts.

Syntax highlighting — the bundled TextMate Bundles plugin can import the VS Code extension directory directly:

  1. Go to Settings → Editor → TextMate Bundles, click +, and select the ext/andy-cpp directory from the repository.
  2. Open a .ndc file — it should highlight immediately. If it renders as plain text, check Settings → Editor → File Types and make sure *.ndc is not claimed by another file type.

Language intelligence — the LSP4IJ plugin connects the IDE to the language server:

  1. Build the binary with cargo build --release, then install LSP4IJ from the plugin marketplace.
  2. Open the Language Servers tool window, click +, and configure a new server:
    • Command: /path/to/andy-cpp/target/release/ndc lsp --stdio
    • In the Mappings tab, add a File name patterns mapping with pattern *.ndc and language id ndc.
  3. Open a .ndc file. The server starts automatically and provides everything listed above. ext/lsp4ij-ndc/template.json in the repository contains the same configuration as a reference.

Neovim

Neovim has a built-in Tree-sitter runtime, so nvim-treesitter is not required. The generated parser is committed to the Andy C++ repository and can be compiled with a C compiler; installing it does not require Node.js or npm.

Clone the repository and run the installer:

git clone --depth 1 https://github.com/timfennis/andy-cpp.git
cd andy-cpp/ext/tree-sitter-andy-cpp
./install.sh neovim

The script supports Linux, the BSDs, and macOS. It installs the parser and queries under ${XDG_CONFIG_HOME:-$HOME/.config}/nvim. Set CC to select a different C compiler.

Add the following to init.lua:

-- Treat .ndc files as the `andy_cpp` filetype.
vim.filetype.add({ extension = { ndc = "andy_cpp" } })

-- Start Tree-sitter highlighting for those buffers.
vim.api.nvim_create_autocmd("FileType", {
  pattern = "andy_cpp",
  callback = function(args)
    pcall(vim.treesitter.start, args.buf, "andy_cpp")
  end,
})

-- Language server (Neovim 0.11+).
vim.lsp.config("ndc_lsp", {
  cmd = { "ndc", "lsp", "--stdio" },
  filetypes = { "andy_cpp" },
  root_markers = { ".git" },
})
vim.lsp.enable("ndc_lsp")

-- Optional: show inlay hints once the server attaches.
vim.api.nvim_create_autocmd("LspAttach", {
  callback = function(args)
    local client = vim.lsp.get_client_by_id(args.data.client_id)
    if client and client.name == "ndc_lsp" then
      pcall(vim.lsp.inlay_hint.enable, true, { bufnr = args.buf })
    end
  end,
})

Run ./install.sh neovim again after updating the grammar or its queries, then restart Neovim. After rebuilding ndc, reload the language server with :LspRestart.

If .ndc is already mapped to a different filetype, omit vim.filetype.add and register the parser for that filetype instead:

vim.treesitter.language.register("andy_cpp", "your_filetype")

Use the same filetype in the autocmd pattern and language-server configuration.

Helix

Helix also has built-in Tree-sitter and LSP support. Add the following to ~/.config/helix/languages.toml (or the equivalent path below XDG_CONFIG_HOME):

[[language]]
name = "andy-cpp"
scope = "source.andy-cpp"
file-types = ["ndc"]
comment-tokens = ["//"]
indent = { tab-width = 4, unit = "    " }
language-servers = ["ndc-lsp"]

[language-server.ndc-lsp]
command = "ndc"
args = ["lsp", "--stdio"]

[[grammar]]
name = "andy-cpp"
source = { git = "https://github.com/timfennis/andy-cpp", rev = "master", subpath = "ext/tree-sitter-andy-cpp" }

From a checkout of the Andy C++ repository, install the parser and queries without Node.js or npm:

cd ext/tree-sitter-andy-cpp
./install.sh helix

The files are installed under ${XDG_CONFIG_HOME:-$HOME/.config}/helix/runtime. Check the setup with hx --health andy-cpp (or helix --health andy-cpp on systems where the binary uses that name).

Other editors

Any editor with an LSP client can use the Andy C++ language server. Configure it to run ndc lsp --stdio for .ndc files with the language id andy-cpp.

Notes

  • The server uses full-document synchronisation and re-analyses on each edit.
  • While the buffer is mid-edit and doesn’t parse, the last successful analysis is retained so hints and dot-completion keep working.
  • The Tree-sitter grammar currently has trouble with doubly nested generic type annotations such as List<List<Int>>. Single-level generics work as expected.

Overload dispatch with collections

Background

When Andy C++ can determine at compile time which function overload to call, it does so — the call is free of any type-checking overhead at runtime. When it cannot (because an argument was inferred as Any), the VM performs dynamic dispatch: it tests each candidate overload at runtime to find the best match.

The reverse also holds. Sometimes every argument type is known at compile time, but no overload can accept them: each candidate is fully annotated, and none matches the call’s arity and argument types. Such a call could only ever fail at runtime. Instead of waiting for that, it is rejected at compile time with a No function called '…' found that matches the arguments error.

O(1) dispatch guarantee

For dynamic dispatch the VM checks whether a value conforms to the parameter type without iterating the container contents. Specifically:

Parameter typeCheck performed
ListIs the value a list?
MapIs the value a map?
DequeIs the value a deque?
SequenceIs the value any sequence type?
String, Int, …Exact kind check

This means that dispatch is O(1) regardless of how many elements are in the collection.

Limitation: element types are not checked at runtime

Because the element-type check is skipped, the VM cannot distinguish overloads that differ only in their container element types via dynamic dispatch. For example, two hypothetical overloads:

fn process(List<Int>)
fn process(List<String>)

would both fail to match under dynamic dispatch if the list type cannot be resolved at compile time, because verifying element types would require scanning the entire container.

Standard library overloads do differ by element type. Numeric sequence functions preserve the concrete numeric type, so sum has three typed overloads:

fn sum(Sequence<Int>) -> Int
fn sum(Sequence<Float>) -> Float
fn sum(Sequence<Number>) -> Number

A value the analyser only knows as List<Any> matches none of them. Rather than dispatch on it and scan, the call is rejected at compile time:

let values: List<Any> = [1, 2, 3];
values.sum()
// error[resolver]: No function called 'sum' found that matches the arguments 'List<Any>'
// = An overload would accept a narrower argument type. Cast to say what the value holds,
//   as in `value as List<Int>`.

Overloads that differ only by container kind (e.g. pop(List<Any>) vs pop(MinHeap<Any>)) are still distinguished by the kind check alone. User-defined functions cannot yet declare typed container parameters (the syntax is not implemented), so user overloads always use Any.

Workaround

State the element type with a cast. A cast scans the value, but the cost lands at a site you wrote rather than inside every dispatch:

let values = %{};
values.insert(1);
values.insert(2);

// keys is typed List<Any>; the cast recovers Int so sum can resolve.
assert_eq((values.keys as List<Int>).sum(), 3);

Otherwise, move the call to a location where Andy C++ can infer the argument types statically — for example, directly at the call site rather than through an intermediate untyped function parameter:

// The type of `data` is Any here — dynamic dispatch used
fn handle(data) {
    process(data)
}

// Preferred: call process() directly where the type is known
process(my_list)