MCPcopy Index your code
hub / github.com/betaveros/noulith

github.com/betaveros/noulith @main

Chat with this repo
repository ↗ · DeepWiki ↗ · + Follow
664 symbols 1,896 edges 19 files 45 documented · 7%
What it actually does AI analysis from the code graph — generated when you open this
loading…
README

An attempt to give myself a new Pareto-optimal choice for quick-and-dirty scripts, particularly when I'm not on a dev computer, and to practice writing a more realistic programming language instead of the overengineered stack-based nonsense I spend too much time on. (Crafting Interpreters is such a good book, I have no excuses.)

You can try Noulith online (via wasm)!

Elevator pitches (and anti-pitches)

  • Immutable data structures (but not variables) means you can write matrix = [[0] ** 10] ** 10; matrix[1][2] = 3 and not worry about it, instead of the [[0] * 10 for _ in range(10)] you always have to do in Python. You can also freely use things as keys in dictionaries. But, thanks to mutate-or-copy-on-write shenanigans behind the scenes (powered by Rust's overpowered reference-counting pointers), you don't have to sacrifice the performance you'd get from mutating lists. (There are almost certainly space leaks from cavalier use of Rc but shhhhh.)
  • Everything is an infix operator; nearly everything can be partially applied. If you thought Scala had a lot of syntax sugar, wait till you see what we've got.

noulith> 1 to 10 filter even map (3*) [6, 12, 18, 24, 30]

  • Ever wanted to write x max= y while searching for some maximum value in some complicated loop? You can do that here. You can do it with literally any function.
  • You know how Python has this edge case where you can write things like {1} and {1, 2} to get sets, but {} is a dictionary because dictionaries came first? We don't have that problem because we don't distinguish sets and dictionaries.
  • Operator precedence is customizable and resolved at runtime.

noulith> f := \-> 2 + 5 * 3 noulith> f() 17 noulith> swap +, * noulith> f() # (2 times 5) plus 3 13 noulith> swap +::precedence, *::precedence noulith> f() # 2 times (5 plus 3) 16 noulith> swap +, * noulith> f() # (2 plus 5) times 3 21

Imagine all the operator parsing code you won't need to write. When you need like arbitrarily many levels of operator precedence, and are happy to eval inputs.

How do you run this thing?

It's a standard Rust project, so, in brief:

  • Install Rust and set it up
  • Clone this repository, cd to it
  • cargo run --release --features cli,request,crypto

This will drop you into a REPL, or you can pass a filename to run it. If you just want to build an executable so you can alias it or add it to $PATH, just run cargo build --release --features cli,request,crypto and look inside target/release.

None of the command-line options to cargo run or cargo build are required; they just give you better run-time performance and features for a slower compile time and larger binary size. (Without --release, stack frames are so large that one of the tests overflows the stack...)

Features (and anti-features) (and claims that will become false as I keep hacking on this)

  • Dynamically typed.
  • Not whitespace- or indentation-sensitive (except for delimiting tokens, of course, but that does matter more than is common: operator symbols can be strung together freely like Haskell or Scala). In particular, newlines don't mean anything; semicolons everywhere. (I can foresee myself regretting this choice so we might revisit it later.)
  • Declare variables with :=. (I never would have considered this on my own, but then I read the Crafting Interpreters design note and was just totally convinced.)
  • List concatenation is ++. String concatenation is $. Maybe? Not sure yet.
  • Things that introduce scopes: functions, loops, switch, try, apparently.
  • Everything is an expression.
  • No classes or members or whatever, it's just global functions all the way down. Or up.
  • I already said this, but operator precedence is resolved at runtime.
  • At the highest level, statements are C/Java/Scala-style if (condition) body else body, for (thing) body (not the modern if cond { body }). The if ... else is the ternary expression.
  • Lists and dictionaries should look familiar from Python. Lists are brackets: [a, b, c]. Dictionaries are curly braces: {a, b, c}. We don't bother with a separate set type, but dictionaries often behave quite like their sets of keys.
  • For loops use left arrows: for (x <- xs) .... Use a double-headed arrow for index-value or key-value pairs: for (i, x <<- xs) ....
  • Prefix operators are wonky. When in doubt, parenthesize the operand: a + -(b); x and not(y).
  • Lambdas look like \x, y -> x + y.

Example

Somewhat imperative:

for (x <- 1 to 100) (
  o := '';
  for (f, s <- [[3, 'Fizz'], [5, 'Buzz']])
    if (x % f == 0)
      o $= s;
  print(if (o == '') x else o)
)

Somewhat functional:

for (x <- 1 to 100) print([[3, 'Fizz'], [5, 'Buzz']] map (\(f, s) -> if (x % f == 0) s else "") join "" or x)

More in-depth tour

NOTE: I will probably keep changing the language and may not keep all this totally up to date.

Numbers, arithmetic operators, and comparisons mostly work as you'd expect, including C-style bitwise operators, except that:

  • ^ is exponentiation. Instead, ~ as a binary operator is xor (but can still be unary as bitwise complement). Or you can just use xor.
  • / does perfect rational division like in Common Lisp or something. % does C-style signed modulo. // does integer division rounding down, and %% does the paired modulo (roughly).
  • The precedence is something somewhat reasonable and simpler, inspired by Go's precedence, rather than following C's legacy:
Tighter ^ << >>
        * / % &
        + - ~
        |
Looser  == != < > <= >=

We support arbitrary radixes up to 36 with syntax 36r1000 == 36^3, plus specifically the slightly weird base-64 64rBAAA == 64^3 (because in base-64 A is 0, B is 1, etc.)

Like in Python and mathematics, comparison operators can be chained like 1 < 2 < 3; we explain how this works later. We also have min, max, and the three-valued comparison operator <=> and its reverse >=<.

End-of-line comments: # (not immediately followed by (). Range comments: #( ... ). Those count parentheses so can be nested.

Strings: " or '. (What will we use the backtick for one day, I wonder.) Also like in Python, we don't have a separate character type; iterating over a string just gives single-character strings.

Data types:

  • Null
  • Numbers: big integers, rationals, floats, and complex numbers, which coerce from left to right in that list as needed. Note that there are no booleans, we just use 0 and 1.
  • Lists (heterogeneous): [a, b]. Pythonic indexing and slicing, both in syntax and semantics of negative integers. Assigning to slices is indefinitely unimplemented.
  • Dictionaries (heterogeneous): {a: b, c: d}. (Valid JSON is valid Noulith, maybe modulo the same kind of weird whitespace issues that make valid JSON not valid JavaScript.) Values can be omitted, in which case they're just null, and are used like sets. Index my_dict[key], test key in my_dict. If you add a {:a}, that's the default value.
  • Strings: just what Rust has, always valid UTF-8 sequences of bytes
  • Bytes
  • Vectors: lists of numbers, notable in that most operations on these automatically vectorize/broadcast, e.g. V(2, 3) + V(4, 5) == V(6, 8); V(2, 3) + 4 == V(6, 7). (Note that comparison operators don't vectorize!)
  • Streams: lazy lists, only generated in a few specific situations for now. Most higher-order functions are eager.
  • Functions, which carry with them a precedence. Fun!
  • Structs!

Expressions

Everything is a global function and can be used as an operator! For example a + b is really just +(a, b); a max b is max(a, b). As a special case, a b (when fenced by other syntax that prevents treating either as binary operator) is a(b) (this is mainly to allow unary minus), but four or more evenly-many identifiers and similar things in a row like (a b c d) is illegal. (Also, beware that a[b] parses as indexing b into a, not a([b]) like you might sometimes hope if you start relying on this too much.) Also:

  • Many functions/operators that normally accept two arguments also accept just one and partially-apply it as their second, e.g. +(3) (which, as above, can be written +3 in the right context) is a function that adds 3. (This is not special syntax, just opt-in from many functions; + is defined to take one or two arguments and if it takes one it partially applies itself.) Since - and ~ have unary overloads, we provide alternatives subtract and xor that do partially apply when called with one argument, just like in Haskell.
  • If you call a(b) where a isn't a function but b is, b partially applies a as its first argument! It's just like Haskell sections. For a slightly more verbose / less mystical way to do this, you can use Scala-style _, see below.

(Sort of considering removing some of the partial application stuff now that _s work... hmm...)

Operator precedence is determined at runtime! This is mainly to support chained comparisons: 1 < 2 < 3 works like in Python. Functions can decide at runtime when they chain (though there's no way for user-defined functions to do this yet), and we use this to make a few other functions nicer. For example, zip and ** (cartesian product) chain with themselves; a ** b ** c and a zip b zip c will give you a list of triplets, instead of a bunch of [[x, y], z]-shaped things.

Identifiers can consist of a letter or _ followed by any number of alphanumerics, ', or ?; or any consecutive number of valid symbols for use in operators, including ?. (So e.g. a*-1 won't work because *- will be parsed as a single token. a* -1 won't work either, but for a different reason — it parses like it begins with calling * with a and - as arguments. a*(-1) or a* -(1) would work.) Compared to similar languages, note that : is not a legal character to use in operators, while $ is. In addition, a bunch of keywords are forbidden, as are all single-letter uppercase letters and tokens beginning with single-letter uppercase letters immediately followed by a single quote (though these are just reserved and the language doesn't recognize all of them yet); =, !, ..., <-, ->, and <<-. Also, with the exception of == != <= and >=, operators ending in = will be parsed as the operator followed by an =, so in general operators cannot end with =.

Almost all builtin functions' precedences are determined by this Scala-inspired rule: Look up each character in the function's name in this table, then take the loosest precedence of any individual character. But note that this isn't a rule in the syntax, it's just a strategy I decided to follow when selecting builtin functions' precedences. For example, +, ++, .+, and +. all have the same precedence. As of time of writing, the only exceptions to this rule are << and >>, which have precedence like ^.

Tighter . (every other symbol, mainly @ which I haven't allocated yet)
        !?
        ^
        */%&
        +-~
        |
        $
        =<>
Looser  (alphanumerics)

. is not special syntax, it's actually just an operator that does tightly-binding reverse function application! a.b = b(a). then is loosely-binding reverse function application.

! is syntax that's spiritually sort of like what Haskell's $ lets you write. It's as tight as an opening parenthesis on its left, but performs a function call that lets you can omit the closing one up to the next semicolon or so. f! a, b is f(a, b).

So, these three expressions are equivalent (assuming the built-in . hasn't been reassigned or shadowed):

print foo
print(foo)
foo.print

As are these:

max(foo, bar)
foo max bar
max! foo, bar

_ is special; assigning to it discards (but type checks still happen; see below). Some expressions produce Scala-style anonymous functions, e.g. 1 < _ < 3, [_, 2], _[3]. I might implement more later.

Types double as conversion functions: str(3) int(3) dict([[1, 2], [3, 4]]) etc. Bending internal consistency for pure syntax sweetness, to is overloaded to takes a type as its second argument to call the same conversion. Test types explicitly with is: 3 is int, int is type. The type of null is nulltype. Strings are str and functions are func. The "top" type is anything.

We got eval, a dumb dynamic guy; vars for examining local variables; assert, which is currently a silly function and will probably become a keyword so it can inspect the expression being asserted.

freeze is a wonky keyword that goes through an expression and eagerly resolves each free variable to what it points to outside. It can slightly optimize some functions, surface some name errors earlier, and more elegantly(??) handle some binding acrobatics that you might have to write IIFEs for in other languages.

The import statement takes a filename and approximately just parses it and splices it in where written, sort of like how C/C++'s #include works. This is an awful hack and might be fixed one day.

Variables and assignments

Declare with :=, assign with =. (Statements must be separated by semicolons.)

x := 0; x = 1

Actually := is just a declaration with an empty type. You can declare typed variables like:

x : int = 3

Pythonically, sequences can be unpacked with commas, including a single trailing comma for a single-element unpack. Type annotations are looser than commas, so below, x and y are both ints. Prefix ... to pack/unpack multiple things, and likewise in function calls.

x, y : int

Yo

Extension points exported contracts — how you extend this code

SoftDeref (Interface)
???????????????????? [6 implementers]
src/nnum.rs
Stream (Interface)
to support Rc , can't have Clone, because https://doc.rust-lang.org/reference/items/traits.html#object-safety [12 implementers]
src/core.rs
MaybeSync (Interface)
(no doc) [1 implementers]
src/rc.rs
PowIF (Interface)
(no doc) [2 implementers]
src/nnum.rs
Builtin (Interface)
(no doc) [45 implementers]
src/core.rs
MaybeSend (Interface)
(no doc) [1 implementers]
src/rc.rs
Catamorphism (Interface)
(no doc) [8 implementers]
src/core.rs
MyDisplay (Interface)
(no doc) [3 implementers]
src/core.rs

Core symbols most depended-on inside this repo

to_string
called by 514
src/nnum.rs
clone
called by 334
src/streams.rs
insert_builtin
called by 264
src/core.rs
next
called by 108
src/lex.rs
len
called by 93
src/few.rs
advance
called by 75
src/core.rs
is_empty
called by 53
src/core.rs
mut_obj_into_iter
called by 50
src/core.rs

Shape

Method 266
Function 260
Class 94
Enum 35
Interface 9

Languages

Rust100%
TypeScript1%

Modules by API surface

src/core.rs235 symbols
src/lib.rs129 symbols
src/nnum.rs79 symbols
tests/test.rs50 symbols
src/eval.rs47 symbols
src/nint.rs31 symbols
src/streams.rs22 symbols
src/lex.rs15 symbols
src/ein.rs10 symbols
src/cli.rs9 symbols
src/few.rs8 symbols
src/rc.rs6 symbols

For agents

$ claude mcp add noulith \
  -- python -m otcore.mcp_server <graph>

⬇ download graph artifact