From 49f64c9c98c1477289fce86b175eb203ab5d3f15 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 20:48:08 +0300 Subject: [PATCH 01/10] tests: initial test suite for IR compiler Signed-off-by: NotAShelf Change-Id: I70cd1dfa45add9df58a44add69fbd30a6a6a6964 --- tests/attrset.nix | 8 ++++++++ tests/attrset.nixir | Bin 0 -> 109 bytes tests/attrset_var.nix | 4 ++++ tests/comparison.nix | 6 ++++++ tests/comparison.nixir | Bin 0 -> 107 bytes tests/if.nix | 2 ++ tests/if.nixir | Bin 0 -> 58 bytes tests/inherit.nix | 17 +++++++++++++++++ tests/inherit_from.nix | 4 ++++ tests/inherit_simple.nix | 4 ++++ tests/lambda_pattern.nix | 36 ++++++++++++++++++++++++++++++++++++ tests/let.nix | 5 +++++ tests/let.nixir | Bin 0 -> 75 bytes tests/logical.nix | 6 ++++++ tests/logical.nixir | Bin 0 -> 149 bytes tests/operators.nix | 6 ++++++ tests/operators.nixir | Bin 0 -> 109 bytes tests/precedence.nix | 8 ++++++++ tests/precedence.nixir | Bin 0 -> 318 bytes tests/shortcircuit.nix | 11 +++++++++++ tests/shortcircuit2.nix | 6 ++++++ tests/simple.nix | 2 ++ tests/simple.nixir | Bin 0 -> 34 bytes tests/simple_op.nix | 1 + tests/simple_op.nixir | Bin 0 -> 53 bytes tests/string_interp.nix | 19 +++++++++++++++++++ tests/unary.nix | 6 ++++++ tests/unary.nixir | Bin 0 -> 113 bytes 28 files changed, 151 insertions(+) create mode 100644 tests/attrset.nix create mode 100644 tests/attrset.nixir create mode 100644 tests/attrset_var.nix create mode 100644 tests/comparison.nix create mode 100644 tests/comparison.nixir create mode 100644 tests/if.nix create mode 100644 tests/if.nixir create mode 100644 tests/inherit.nix create mode 100644 tests/inherit_from.nix create mode 100644 tests/inherit_simple.nix create mode 100644 tests/lambda_pattern.nix create mode 100644 tests/let.nix create mode 100644 tests/let.nixir create mode 100644 tests/logical.nix create mode 100644 tests/logical.nixir create mode 100644 tests/operators.nix create mode 100644 tests/operators.nixir create mode 100644 tests/precedence.nix create mode 100644 tests/precedence.nixir create mode 100644 tests/shortcircuit.nix create mode 100644 tests/shortcircuit2.nix create mode 100644 tests/simple.nix create mode 100644 tests/simple.nixir create mode 100644 tests/simple_op.nix create mode 100644 tests/simple_op.nixir create mode 100644 tests/string_interp.nix create mode 100644 tests/unary.nix create mode 100644 tests/unary.nixir diff --git a/tests/attrset.nix b/tests/attrset.nix new file mode 100644 index 0000000..18425c5 --- /dev/null +++ b/tests/attrset.nix @@ -0,0 +1,8 @@ +# Attrset test +{ + name = "test"; + value = 123; + nested = { + inner = true; + }; +} diff --git a/tests/attrset.nixir b/tests/attrset.nixir new file mode 100644 index 0000000000000000000000000000000000000000..ad8b3e2d6836c80f26d9b6382c7b2980e8bd9f90 GIT binary patch literal 109 zcmazD^7Lb5Kn08jU_LXDWC7y5#N1RSkO+uVl3HBC3Z%;tb4pXe8mmE4K)?pXc|h6J U6o__^BuHguUS4VuNGk&)09^;fPX1i%cCDi#or I5lk`w0PLp&4gdfE literal 0 HcmV?d00001 diff --git a/tests/if.nix b/tests/if.nix new file mode 100644 index 0000000..0ef94a5 --- /dev/null +++ b/tests/if.nix @@ -0,0 +1,2 @@ +# Conditional test +if true then 1 else 2 diff --git a/tests/if.nixir b/tests/if.nixir new file mode 100644 index 0000000000000000000000000000000000000000..2668f3f78628ac947d2722bc160cffd2ebed17b7 GIT binary patch literal 58 gcmazD^7Lb5Kn08rAU+F-U 2; # Should be (1 < 2) && (3 > 2) = true +in + { a = a; b = b; c = c; d = d; } diff --git a/tests/precedence.nixir b/tests/precedence.nixir new file mode 100644 index 0000000000000000000000000000000000000000..34881861c5e1f9cbf9ccd9061e0b663f1639ead0 GIT binary patch literal 318 zcmY+9OA3H63`Co%DC)v9cml8CM#Pm{KleX_omY`q=#bZ$gmmBL#lrvCDlXHNbBrR& zED3mu^hMm)FC3hNEGyC0xuNplhg@{@Xs0EDp)3?klkRMyHKP$lm5-8i@Ee}|VxK>e V`u;h9Nz`#ds&p?%b453#kq?@H2Fw5e literal 0 HcmV?d00001 diff --git a/tests/shortcircuit.nix b/tests/shortcircuit.nix new file mode 100644 index 0000000..063bf70 --- /dev/null +++ b/tests/shortcircuit.nix @@ -0,0 +1,11 @@ +# Test short-circuit evaluation +let + alwaysFalse = false; + alwaysTrue = true; + x = 10; +in + { + and_false = alwaysFalse && alwaysTrue; + or_true = alwaysTrue || alwaysFalse; + impl_false = alwaysFalse -> alwaysFalse; + } diff --git a/tests/shortcircuit2.nix b/tests/shortcircuit2.nix new file mode 100644 index 0000000..b75cf38 --- /dev/null +++ b/tests/shortcircuit2.nix @@ -0,0 +1,6 @@ +# Test short-circuit evaluation +{ + and_false = false && true; + or_true = true || false; + impl_false = false -> false; +} diff --git a/tests/simple.nix b/tests/simple.nix new file mode 100644 index 0000000..68e636c --- /dev/null +++ b/tests/simple.nix @@ -0,0 +1,2 @@ +# Simple constant test +42 diff --git a/tests/simple.nixir b/tests/simple.nixir new file mode 100644 index 0000000000000000000000000000000000000000..ee4989f321a6af3fbb555b83bf86fb1f8eac086d GIT binary patch literal 34 UcmazD^7Lb5Kn09o0WGK?04je0ZvX%Q literal 0 HcmV?d00001 diff --git a/tests/simple_op.nix b/tests/simple_op.nix new file mode 100644 index 0000000..193df0b --- /dev/null +++ b/tests/simple_op.nix @@ -0,0 +1 @@ +1 + 2 \ No newline at end of file diff --git a/tests/simple_op.nixir b/tests/simple_op.nixir new file mode 100644 index 0000000000000000000000000000000000000000..fa2a99e7ff07eb831392b19d0ce5ea126620cd74 GIT binary patch literal 53 acmazD^7Lb5Kn09SU_MwFCI_aOpy~icx&dnd literal 0 HcmV?d00001 diff --git a/tests/string_interp.nix b/tests/string_interp.nix new file mode 100644 index 0000000..b9ae519 --- /dev/null +++ b/tests/string_interp.nix @@ -0,0 +1,19 @@ +# Test string interpolation +let + name = "world"; + x = 42; + bool_val = true; +in + { + # Simple interpolation + greeting = "Hello ${name}!"; + + # Multiple interpolations + multi = "x is ${x} and name is ${name}"; + + # Nested expression + nested = "Result: ${if bool_val then "yes" else "no"}"; + + # Just a string (no interpolation) + plain = "plain text"; + } diff --git a/tests/unary.nix b/tests/unary.nix new file mode 100644 index 0000000..cc7ddab --- /dev/null +++ b/tests/unary.nix @@ -0,0 +1,6 @@ +# Test unary operators +let + x = 10; + y = true; +in + { neg = -x; not = !y; } diff --git a/tests/unary.nixir b/tests/unary.nixir new file mode 100644 index 0000000000000000000000000000000000000000..00c75c62a194b746260cf64638ba4c4429a1ec94 GIT binary patch literal 113 zcmazD^7Lb5Kn08eAU+cigJdee(p(@RAOLYHS->m<5D8Ys45af?)0M#j0$>uT5G Date: Sat, 21 Feb 2026 20:48:38 +0300 Subject: [PATCH 02/10] chore: update gitignore for build artifacts Signed-off-by: NotAShelf Change-Id: I3fd18bc9e15b0a33557fcd95368b49776a6a6964 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78985c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +nix/ + +nix-irc +*.so +**/*.nixir From da9be4b014df0b5e0dcaf19b0fd97474e226c6f6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 20:49:02 +0300 Subject: [PATCH 03/10] docs: document binary format; add testing instructions Signed-off-by: NotAShelf Change-Id: I2cb3440f97b4add57860b212a60442336a6a6964 --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/README.md b/README.md index 25cf524..e99dc1e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,19 @@ The plugin automatically chooses the fastest path based on file availability. ## Usage +### Building + +```bash +# Configure +$ cmake -B build + +# Build +$ make + +# The nix-irc executable will be in the project root +$ ./nix-irc --help +``` + ### Compiling Nix to IR ```bash @@ -42,6 +55,54 @@ $ nix-irc -I ./lib -I /nix/store/... input.nix output.nixir $ nix-irc --no-imports input.nix output.nixir ``` +### Runtime Evaluation (Plugin) + +```bash +# Load the plugin and evaluate IR +$ nix --plugin-files ./nix-ir-plugin.so eval --expr 'builtins.nixIR_loadIR "output.nixir"' + +# On-the-fly compilation and evaluation +$ nix --plugin-files ./nix-ir-plugin.so eval --expr 'builtins.nixIR_compile "1 + 2 * 3"' + +# Get plugin info +$ nix --plugin-files ./nix-ir-plugin.so eval --expr 'builtins.nixIR_info' +``` + +### Running Tests + +```bash +# Test all sample files +for f in tests/*.nix; do + ./nix-irc "$f" "${f%.nix}.nixir" +done + +# Verify IR format +$ hexdump -C tests/simple.nixir | head -3 +``` + +## IR Format + +The `.nixir` files use a versioned binary format: + +```plaintext +Header: + - Magic: 0x4E495258 ("NIRX") + - Version: 1 (uint32) + - Source count: uint32 + - Import count: uint32 + - String table size: uint32 + +String Table: + - Interned strings for efficient storage + +Nodes: + - Binary encoding of IR nodes + - Each node has type tag + inline data + +Entry: + - Main expression node index +``` + ## Contributing This is a research/experimental project. Contributions welcome! From 3441853eefef2a3380fa4008e916bb70e83a4825 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 20:52:30 +0300 Subject: [PATCH 04/10] various: fix string comparison, interpolation and ADD op. for strings Signed-off-by: NotAShelf Change-Id: Ice1bfb5682ab48a967dc16f1378e23ae6a6a6964 --- src/irc/evaluator.cpp | 27 ++++++++++++++++----------- src/irc/parser.cpp | 39 ++++++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/irc/evaluator.cpp b/src/irc/evaluator.cpp index 8aa64c7..977ea85 100644 --- a/src/irc/evaluator.cpp +++ b/src/irc/evaluator.cpp @@ -21,10 +21,6 @@ struct IREnvironment { explicit IREnvironment(IREnvironment* p = nullptr) : parent(p), with_attrs(nullptr) {} - IREnvironment* push() { - return new IREnvironment(this); - } - void bind(Value* val) { bindings.push_back(val); } @@ -104,7 +100,7 @@ struct Evaluator::Impl { thunk->blackholed = true; eval_node(thunk->expr, *v, thunk->env); - thunks.erase(it); + thunks.erase(v); } void eval_node(const std::shared_ptr& node, Value& v, IREnvironment* env) { @@ -231,6 +227,8 @@ struct Evaluator::Impl { case BinaryOp::ADD: if (left->type() == nInt && right->type() == nInt) { v.mkInt((left->integer() + right->integer()).valueWrapping()); + } else if (left->type() == nString && right->type() == nString) { + v.mkString(std::string(left->c_str()) + std::string(right->c_str())); } else { state.error("type error in addition").debugThrow(); } @@ -268,6 +266,8 @@ struct Evaluator::Impl { case BinaryOp::LT: if (left->type() == nInt && right->type() == nInt) { v.mkBool(left->integer() < right->integer()); + } else if (left->type() == nString && right->type() == nString) { + v.mkBool(std::string(left->c_str()) < std::string(right->c_str())); } else { state.error("type error in comparison").debugThrow(); } @@ -275,6 +275,8 @@ struct Evaluator::Impl { case BinaryOp::GT: if (left->type() == nInt && right->type() == nInt) { v.mkBool(left->integer() > right->integer()); + } else if (left->type() == nString && right->type() == nString) { + v.mkBool(std::string(left->c_str()) > std::string(right->c_str())); } else { state.error("type error in comparison").debugThrow(); } @@ -282,6 +284,8 @@ struct Evaluator::Impl { case BinaryOp::LE: if (left->type() == nInt && right->type() == nInt) { v.mkBool(left->integer() <= right->integer()); + } else if (left->type() == nString && right->type() == nString) { + v.mkBool(std::string(left->c_str()) <= std::string(right->c_str())); } else { state.error("type error in comparison").debugThrow(); } @@ -289,16 +293,15 @@ struct Evaluator::Impl { case BinaryOp::GE: if (left->type() == nInt && right->type() == nInt) { v.mkBool(left->integer() >= right->integer()); + } else if (left->type() == nString && right->type() == nString) { + v.mkBool(std::string(left->c_str()) >= std::string(right->c_str())); } else { state.error("type error in comparison").debugThrow(); } break; case BinaryOp::CONCAT: - if (left->type() == nString && right->type() == nString) { - v.mkString(std::string(left->c_str()) + std::string(right->c_str())); - } else { - state.error("type error in concatenation").debugThrow(); - } + // ++ is list concatenation in Nix; string concat uses ADD (+) + state.error("list concatenation not yet implemented").debugThrow(); break; default: state.error("unknown binary operator").debugThrow(); @@ -410,7 +413,9 @@ struct Evaluator::Impl { auto attr = obj->attrs()->get(sym); if (attr) { - v = *attr->value; + Value* val = attr->value; + force(val); + v = *val; } else if (n->default_expr) { eval_node(*n->default_expr, v, env); } else { diff --git a/src/irc/parser.cpp b/src/irc/parser.cpp index 3ec6f86..8a47e2a 100644 --- a/src/irc/parser.cpp +++ b/src/irc/parser.cpp @@ -27,7 +27,10 @@ static std::string read_file(const std::string& path) { long size = ftell(f); fseek(f, 0, SEEK_SET); std::string content(size, '\0'); - fread(content.data(), 1, size, f); + if (fread(content.data(), 1, size, f) != static_cast(size)) { + fclose(f); + throw std::runtime_error("Failed to read file: " + path); + } fclose(f); return content; } @@ -761,7 +764,11 @@ public: } } - expect(Token::RBRACE); + if (!consume(Token::RBRACE)) { + // Not a lambda pattern, restore + pos = saved_pos; + return nullptr; + } if (!consume(Token::COLON)) { // Not a lambda, restore @@ -817,6 +824,7 @@ public: // Create lambda auto lambda = LambdaNode(1, let_node); lambda.param_name = arg_name; + lambda.strict_pattern = !has_ellipsis; return std::make_shared(std::move(lambda)); } @@ -842,13 +850,33 @@ public: i += 2; // Skip ${ int depth = 1; size_t expr_start = i; + bool in_string = false; + char string_quote = 0; while (i < raw.size() && depth > 0) { - if (raw[i] == '{') depth++; - else if (raw[i] == '}') depth--; + if (!in_string) { + if (raw[i] == '"' || raw[i] == '\'') { + in_string = true; + string_quote = raw[i]; + } else if (raw[i] == '{') { + depth++; + } else if (raw[i] == '}') { + depth--; + } + } else { + if (raw[i] == string_quote && (i == 0 || raw[i-1] != '\\')) { + in_string = false; + } else if (raw[i] == '\\') { + i++; + } + } if (depth > 0) i++; } + if (depth > 0) { + throw std::runtime_error("unterminated ${ in string interpolation"); + } + // Parse the expression std::string expr_str = raw.substr(expr_start, i - expr_start); @@ -893,7 +921,8 @@ public: auto result = parts[0]; for (size_t j = 1; j < parts.size(); j++) { - result = std::make_shared(BinaryOpNode(BinaryOp::CONCAT, result, parts[j])); + // Use ADD (+) for string concatenation; CONCAT (++) is Nix list concatenation + result = std::make_shared(BinaryOpNode(BinaryOp::ADD, result, parts[j])); } return result; From f4135a5dca352a1dadd167128eab556b5e1ae08e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 22:51:16 +0300 Subject: [PATCH 05/10] types/serializer: add `HasAttrNode` binary encoding for `?` operator Signed-off-by: NotAShelf Change-Id: Ibfb89151eb80ab1ae1d8878b6849d2c96a6a6964 --- src/irc/serializer.cpp | 9 ++++++++- src/irc/types.h | 7 ++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/irc/serializer.cpp b/src/irc/serializer.cpp index 7a83e55..8819789 100644 --- a/src/irc/serializer.cpp +++ b/src/irc/serializer.cpp @@ -271,7 +271,14 @@ struct Deserializer::Impl { case NodeType::SELECT: { auto expr = read_node(); auto attr = read_node(); - return std::make_shared(SelectNode(expr, attr, line)); + uint8_t has_default = read_u8(); + std::optional> default_expr; + if (has_default) { + default_expr = read_node(); + } + SelectNode select_node(expr, attr, line); + select_node.default_expr = default_expr; + return std::make_shared(std::move(select_node)); } case NodeType::HAS_ATTR: { auto expr = read_node(); diff --git a/src/irc/types.h b/src/irc/types.h index 50f1cb9..d10acf1 100644 --- a/src/irc/types.h +++ b/src/irc/types.h @@ -14,7 +14,7 @@ namespace nix_irc { constexpr uint32_t IR_MAGIC = 0x4E495258; -constexpr uint32_t IR_VERSION = 1; +constexpr uint32_t IR_VERSION = 2; enum class NodeType : uint8_t { CONST_INT = 0x01, @@ -29,8 +29,8 @@ enum class NodeType : uint8_t { UNARY_OP = 0x23, ATTRSET = 0x30, SELECT = 0x31, - HAS_ATTR = 0x32, - WITH = 0x33, + HAS_ATTR = 0x34, + WITH = 0x32, IF = 0x40, LET = 0x50, LETREC = 0x51, @@ -94,6 +94,7 @@ struct LambdaNode { uint32_t arity = 1; std::shared_ptr body; std::optional param_name; + bool strict_pattern = true; uint32_t line = 0; LambdaNode(uint32_t a, std::shared_ptr b, uint32_t l = 0); }; From ddfbc91b5888d1d176b921af489a94eaedfbd25a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 22:52:13 +0300 Subject: [PATCH 06/10] tests: add regression test suite; update test fixtures Signed-off-by: NotAShelf Change-Id: I5ccf7cb25394bdcae068b49f66787c3a6a6a6964 --- tests/attrset.nixir | Bin 109 -> 109 bytes tests/comparison.nixir | Bin 107 -> 107 bytes tests/if.nixir | Bin 58 -> 58 bytes tests/lambda_pattern.nix | 4 +- tests/let.nixir | Bin 75 -> 75 bytes tests/logical.nixir | Bin 149 -> 149 bytes tests/operators.nixir | Bin 109 -> 109 bytes tests/precedence.nixir | Bin 318 -> 318 bytes tests/regression_test.cpp | 184 ++++++++++++++++++++++++++++++++++++++ tests/simple.nixir | Bin 34 -> 34 bytes tests/simple_op.nixir | Bin 53 -> 53 bytes tests/unary.nixir | Bin 113 -> 113 bytes 12 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 tests/regression_test.cpp diff --git a/tests/attrset.nixir b/tests/attrset.nixir index ad8b3e2d6836c80f26d9b6382c7b2980e8bd9f90..708f5ddd3bfd673373e97f8e7fb2703ef3e2b501 100644 GIT binary patch delta 11 Scmd1JWsL~(^kbUHngIY4kOLtA delta 11 Scmd1JWsL~(^kbaJngIY4i~}G5 diff --git a/tests/comparison.nixir b/tests/comparison.nixir index 3b41e90df303895ccf9d516428c005cae320689a..fb7b4fd62f9b8ac0f3c19b3a9c5eb7b0dcc7058f 100644 GIT binary patch delta 11 Scmd1KW{n8)^kbUHng#$9WCI%j delta 11 Scmd1KW{n8)^kbaJng#$9U;`Qe diff --git a/tests/if.nixir b/tests/if.nixir index 2668f3f78628ac947d2722bc160cffd2ebed17b7..4ee0f5992c9cf0477d61dbc9a4d28e10556fff9d 100644 GIT binary patch literal 58 fcmazD^7Lb3Kn08rAU+F-U}OZ7AOZ-$9GGeVQQrZN literal 58 gcmazD^7Lb5Kn08rAU+F-UIMK1qyoDD delta 11 ScmebFW{n8)^kbaJ>IMK1paQx8 diff --git a/tests/logical.nixir b/tests/logical.nixir index 2544d5cd95e5f503526e013fea2508eaf43c1358..010a5f558addb374be5e2b3786b015f964a46663 100644 GIT binary patch delta 13 UcmbQrIF*q#BFNK^X(H +#include + +using namespace nix_irc; + +int failures = 0; + +#define TEST_CHECK(cond, msg) \ + do { \ + if (!(cond)) { \ + std::cerr << " FAIL: " << msg << std::endl; \ + failures++; \ + } else { \ + std::cout << " PASS: " << msg << std::endl; \ + } \ + } while (0) + +#define TEST_PASS(msg) std::cout << " PASS: " << msg << std::endl +#define TEST_FAIL(msg) \ + do { \ + std::cerr << " FAIL: " << msg << std::endl; \ + failures++; \ + } while (0) + +void test_enum_compatibility() { + std::cout << "> Enum compatibility..." << std::endl; + + if (static_cast(NodeType::WITH) == 0x32) { + std::cout << " PASS: WITH has correct value 0x32" << std::endl; + } else { + std::cerr << " FAIL: WITH should be 0x32, got " + << static_cast(NodeType::WITH) << std::endl; + } + + if (static_cast(NodeType::HAS_ATTR) == 0x34) { + std::cout << " PASS: HAS_ATTR has value 0x34 (new slot after WITH bump)" + << std::endl; + } else if (static_cast(NodeType::HAS_ATTR) == 0x33 && + static_cast(NodeType::WITH) == 0x32) { + std::cout << " PASS: HAS_ATTR has value 0x33 (restored original with WITH " + "at 0x32)" + << std::endl; + } else { + std::cerr << " FAIL: HAS_ATTR value is " + << static_cast(NodeType::HAS_ATTR) + << " (expected 0x34 or 0x33 with WITH=0x32)" << std::endl; + } + + if (IR_VERSION == 2) { + std::cout << " PASS: IR_VERSION bumped to 2 for breaking change" + << std::endl; + } else if (static_cast(NodeType::WITH) == 0x32) { + std::cout << " PASS: IR_VERSION unchanged but WITH restored to 0x32" + << std::endl; + } else { + std::cerr << " FAIL: Either bump IR_VERSION or fix enum values" + << std::endl; + } +} + +void test_serializer_select_with_default() { + std::cout << "> SELECT serialization with default_expr..." << std::endl; + + auto expr = std::make_shared(ConstIntNode(42)); + auto attr = std::make_shared(ConstStringNode("key")); + auto default_val = std::make_shared(ConstIntNode(100)); + + SelectNode select_node(expr, attr); + select_node.default_expr = default_val; + auto select = std::make_shared(select_node); + + IRModule module; + module.entry = select; + + Serializer ser; + auto bytes = ser.serialize_to_bytes(module); + + Deserializer deser; + auto loaded = deser.deserialize(bytes); + + auto *loaded_select = loaded.entry->get_if(); + if (loaded_select && loaded_select->default_expr && + *loaded_select->default_expr) { + auto *def_val = (*loaded_select->default_expr)->get_if(); + if (def_val && def_val->value == 100) { + std::cout << " PASS: SELECT with default_expr round-trips correctly" + << std::endl; + } else { + std::cerr << " FAIL: default_expr value incorrect" << std::endl; + } + } else { + std::cerr << " FAIL: default_expr not deserialized (missing u8 flag read)" + << std::endl; + } +} + +void test_serializer_select_without_default() { + std::cout << "> SELECT serialization without default_expr..." << std::endl; + + auto expr = std::make_shared(ConstIntNode(42)); + auto attr = std::make_shared(ConstStringNode("key")); + + SelectNode select_node(expr, attr); + auto select = std::make_shared(select_node); + + IRModule module; + module.entry = select; + + Serializer ser; + auto bytes = ser.serialize_to_bytes(module); + + Deserializer deser; + auto loaded = deser.deserialize(bytes); + + auto *loaded_select = loaded.entry->get_if(); + if (loaded_select && + (!loaded_select->default_expr || !*loaded_select->default_expr)) { + std::cout << " PASS: SELECT without default_expr round-trips correctly" + << std::endl; + } else { + std::cerr << " FAIL: default_expr should be null/absent" << std::endl; + } +} + +void test_parser_brace_depth_in_strings() { + std::cout << "> Parser brace depth handling in strings..." << std::endl; + + std::string test_input = R"( + let s = "test}"; in ${s} + )"; + + std::cout << " Test input contains '}' inside string - should not end " + "interpolation" + << std::endl; + std::cout << " NOTE: This test requires running through actual parser" + << std::endl; +} + +void test_parser_has_ellipsis_usage() { + std::cout << "> Parser has_ellipsis usage..." << std::endl; + + std::cout << " NOTE: LambdaNode should have strict_pattern field when " + "has_ellipsis is false" + << std::endl; + std::cout << " This requires checking the parser output for strict patterns" + << std::endl; +} + +void test_parser_expect_in_speculative_parsing() { + std::cout << "> Parser expect() in speculative parsing..." << std::endl; + + std::cout << " NOTE: try_parse_lambda should not throw on non-lambda input" + << std::endl; + std::cout << " This requires testing parser with invalid lambda patterns" + << std::endl; +} + +int main() { + std::cout << "=== Regression Tests for Nixir ===" << std::endl << std::endl; + + test_enum_compatibility(); + std::cout << std::endl; + + test_serializer_select_with_default(); + std::cout << std::endl; + + test_serializer_select_without_default(); + std::cout << std::endl; + + test_parser_brace_depth_in_strings(); + std::cout << std::endl; + + test_parser_has_ellipsis_usage(); + std::cout << std::endl; + + test_parser_expect_in_speculative_parsing(); + std::cout << std::endl; + + std::cout << "=== Tests Complete ===" << std::endl; + std::cout << "Failures: " << failures << std::endl; + return failures > 0 ? 1 : 0; +} diff --git a/tests/simple.nixir b/tests/simple.nixir index ee4989f321a6af3fbb555b83bf86fb1f8eac086d..3e26f836aabd5acf5d45ae244f70fbff8eab4fea 100644 GIT binary patch literal 34 VcmazD^7Lb3Kn0A9Aifq<5CAHj0dN2S literal 34 UcmazD^7Lb5Kn09o0WGK?04je0ZvX%Q diff --git a/tests/simple_op.nixir b/tests/simple_op.nixir index fa2a99e7ff07eb831392b19d0ce5ea126620cd74..18ffbd3e6a78b619ea3c0a76bafd0c43e9d94148 100644 GIT binary patch literal 53 acmazD^7Lb3Kn09SU_K*=1QReCrVs!{>j7*4 literal 53 acmazD^7Lb5Kn09SU_MwFCI_aOpy~icx&dnd diff --git a/tests/unary.nixir b/tests/unary.nixir index 00c75c62a194b746260cf64638ba4c4429a1ec94..652fabc4b463b276334d7d94b5fbff4d886983e0 100644 GIT binary patch delta 11 ScmXRcWQ_>&^kbUHngak6=mRYP delta 11 ScmXRcWQ_>&^kbaJngak6 Date: Sat, 21 Feb 2026 22:54:02 +0300 Subject: [PATCH 07/10] chore: add CMake test target; ignore build directories Signed-off-by: NotAShelf Change-Id: I0db5e008e65f5a5109d8eaa6119b3c246a6a6964 --- .gitignore | 1 + CMakeLists.txt | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/.gitignore b/.gitignore index 78985c6..bd97cca 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ nix/ nix-irc *.so **/*.nixir +cmake_install.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index c310f42..edb503c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,3 +73,22 @@ set_target_properties(nix-ir-plugin PROPERTIES # Install to plugin directory install(TARGETS nix-ir-plugin LIBRARY DESTINATION "${CMAKE_INSTALL_PREFIX}/lib/nix/plugins") + +# Regression tests +add_executable(regression_test + tests/regression_test.cpp + src/irc/serializer.cpp +) + +target_include_directories(regression_test PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${NIX_STORE_INCLUDE_DIRS} + ${NIX_EXPR_INCLUDE_DIRS} + ${NIX_UTIL_INCLUDE_DIRS} +) + +target_link_libraries(regression_test PRIVATE + ${NIX_STORE_LINK_LIBRARIES} + ${NIX_EXPR_LINK_LIBRARIES} + ${NIX_UTIL_LINK_LIBRARIES} +) From e36693ac3f8af046a329ec62d439a84e17eaf7bd Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 22:54:48 +0300 Subject: [PATCH 08/10] docs: document project architechture Signed-off-by: NotAShelf Change-Id: Iaa99d706d61857fbd51d3b757b5066ab6a6a6964 --- README.md | 182 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 153 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e99dc1e..1048d9c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ where the plugin parses and compiles Nix code at runtime, or; **ahead-of-time** compilation where the `nix-irc` tool pre-compiles `.nix` files into `.nixir` files. -The plugin automatically chooses the fastest path based on file availability. - ## Supported Nix Constructs - Literals: integers, strings, booleans, null, paths @@ -27,6 +25,145 @@ The plugin automatically chooses the fastest path based on file availability. `||`, `->` - Unary: `-`, `!` +## Overview + +Nixir is a Nix evaluator plugin that compiles Nix expressions to a custom binary +intermediate representation (IR). Think of it like a compiler for Nix: it +translates human-readable Nix code into a compact, fast-to-execute format that +runs in a custom virtual machine. + +The plugin works in two ways: + +1. **Ahead-of-time**: Use the `nix-irc` tool to compile `.nix` files to `.nixir` + once, then load them instantly +2. **On-the-fly**: Let the plugin parse and compile Nix code at runtime when you + need it + +While Nixir _is_ designed as a toy research project, I _envision_[^1] a few +_potential_ use cases built around working with Nix. Sure, you _probably_ would +not go work with Nix willingly, science is not about why, it is about _why not_. + +Some potential use cases for Nixir _might_ include: + +- **CI/CD Acceleration**: Pre-compile stable Nix expressions to `.nixir` for + faster repeated evaluation in CI pipelines +- **Embedded Nix**: Use Nix as a configuration language in C++ applications + without bundling the full evaluator +- **Plugin Ecosystem**: Extend Nix with custom evaluation strategies via the + plugin API +- **Build Caching**: Cache compiled IR alongside source for instant startup of + Nix-based tools + +[^1]: I'm not entirely convinced either, do not ask. + +### Architecture + +```mermaid +flowchart TD + + subgraph Source["User Source"] + A[".nix Source Files"] + end + + subgraph Compiler["External Tool: nix-irc"] + B1["Parse Nix"] + B2["Static Import Resolution"] + B3["Flatten Import Graph"] + B4["Desugar + De Bruijn Conversion"] + B5["Emit Versioned IR Bundle (.nixir)"] + end + + subgraph IR["IR Bundle"] + C1["Binary IR Format"] + C2["Versioned Header"] + C3["No Names, Indexed Vars"] + end + + subgraph Plugin["nix-ir-plugin.so"] + D1["Primop Registration"] + D2["prim_loadIR"] + D3["prim_compileNix"] + D4["prim_info"] + end + + subgraph CompilePath["On-the-fly Path"] + E1["Parse Source String"] + E2["IR Generation"] + end + + subgraph LoadPath["Pre-compiled Path"] + F1["Deserialize .nixir"] + end + + subgraph VM["Custom Lazy VM"] + G1["Heap-Allocated Thunks"] + G2["Memoization"] + G3["Cycle Detection"] + G4["Closure Environments (Array-Based)"] + G5["FORCE / THUNK Execution"] + end + + A --> B1 + B1 --> B2 + B2 --> B3 + B3 --> B4 + B4 --> B5 + B5 --> C1 + + C1 --> D1 + + D2 -->|explicit| F1 + F1 --> G1 + + D3 -->|explicit| E1 + E1 --> E2 + E2 --> G1 + + G1 -.-> G2 -.-> G3 -.-> G4 -.-> G5 +``` + +The same compiler code runs both in the standalone `nix-irc` CLI tool and inside +the plugin for on-the-fly compilation. This ensures consistent behavior between +pre-compiled and runtime-compiled paths. The intermediate representation (IR) +design uses De Brujin indices instead of names for variable binding, which +eliminates string lookup and the binary format uses a versioned header +(`0x4E495258`). In addition, we make use of string interning for repeated +identifiers and type-tagged nodes for efficient dispatching. + +The runtime implements lazy evaluation using heap-allocated thunks. Each thunk +holds a delayed computation and is evaluated at most once through memoization. +Recursive definitions are handled through a blackhole mechanism that detects +cycles at runtime. Variable lookup uses array-based closure environments, +providing O(1) access by index rather than name-based lookup. + +The plugin integrates with Nix through the `RegisterPrimOp` API, exposing three +operations: `nixIR_loadIR` for loading pre-compiled `.nixir` bundles, +`nixIR_compile` for on-the-fly compilation, and `nixIR_info` for metadata. This +integration path is compatible with Nix 2.32+. + +### IR Format + +The `.nixir` files use a versioned binary format: + +```plaintext +Header: + - Magic: 0x4E495258 ("NIRX") + - Version: 1 (uint32) + - Source count: uint32 + - Import count: uint32 + - String table size: uint32 + +String Table: + - Interned strings for efficient storage + +Nodes: + - Binary encoding of IR nodes + - Each node has type tag + inline data + +Entry: + - Main expression node index +``` + ## Usage ### Building @@ -57,6 +194,8 @@ $ nix-irc --no-imports input.nix output.nixir ### Runtime Evaluation (Plugin) + + ```bash # Load the plugin and evaluate IR $ nix --plugin-files ./nix-ir-plugin.so eval --expr 'builtins.nixIR_loadIR "output.nixir"' @@ -68,6 +207,8 @@ $ nix --plugin-files ./nix-ir-plugin.so eval --expr 'builtins.nixIR_compile "1 + $ nix --plugin-files ./nix-ir-plugin.so eval --expr 'builtins.nixIR_info' ``` + + ### Running Tests ```bash @@ -80,36 +221,19 @@ done $ hexdump -C tests/simple.nixir | head -3 ``` -## IR Format - -The `.nixir` files use a versioned binary format: - -```plaintext -Header: - - Magic: 0x4E495258 ("NIRX") - - Version: 1 (uint32) - - Source count: uint32 - - Import count: uint32 - - String table size: uint32 - -String Table: - - Interned strings for efficient storage - -Nodes: - - Binary encoding of IR nodes - - Each node has type tag + inline data - -Entry: - - Main expression node index -``` - ## Contributing -This is a research/experimental project. Contributions welcome! +This is a research project (with no formal association, i.e., no thesis or +anything) that I'm working on entirely for fun and out of curiousity. Extremely +experimental, could change any time. While I do not suggest running this project +in a serious context, I am happy to receive any kind of feedback you might have. +You will notice _very_ quickly that I'm a little out of my depth, and the code +is in a rough shape. Areas where help is needed: -Areas where help is needed: - -- Expanding parser to handle more Nix syntax +- Compiler semantics - Performance optimization - Test coverage - Documentation improvements +- Expanding parser to handle more Nix syntax (module system in particular) + +Contributions _are_ welcome! From 4aa514d83d1d0de7cc81f64a4a90e77c2339f501 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 21 Feb 2026 23:46:00 +0300 Subject: [PATCH 09/10] meta: ignore more generated files and nix artifacts Signed-off-by: NotAShelf Change-Id: Id142ee9a1bdf076542f684130ba925216a6a6964 --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index bd97cca..ddb05bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,14 @@ build/ nix/ +result* +/.direnv +# Build artifacts nix-irc *.so **/*.nixir +regression_test + +# Generated files cmake_install.cmake +Makefile From 5b80e3aa63c68867d85647d8cf4428395ea5b045 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 22 Feb 2026 00:03:39 +0300 Subject: [PATCH 10/10] chore: set up clang-format Signed-off-by: NotAShelf Change-Id: Ia4928173791618598031d584f7b6cf166a6a6964 --- .clang-format | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .clang-format diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..13d0652 --- /dev/null +++ b/.clang-format @@ -0,0 +1,18 @@ +--- +Language: Cpp +BasedOnStyle: LLVM +IndentWidth: 2 +TabWidth: 2 +UseTab: Never +ColumnLimit: 100 +BreakBeforeBraces: Attach +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +SpaceAfterCStyleCast: true +SpaceBeforeParens: ControlStatements +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +NamespaceIndentation: None +PointerAlignment: Left +ReferenceAlignment: Left