git.delta.rocks / jrsonnet / refs/commits / a34df9df5436

difftreelog

slop: implement ir parser

kwvkpnlqYaroslav Bolyukin2026-03-23parent: #6314afd.patch.diff
in: master

86 files changed

modifiedCargo.lockdiffbeforeafterboth
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -638,6 +638,7 @@
  "jrsonnet-gcmodule",
  "jrsonnet-interner",
  "jrsonnet-ir",
+ "jrsonnet-ir-parser",
  "jrsonnet-macros",
  "jrsonnet-peg-parser",
  "jrsonnet-types",
modifiedcrates/jrsonnet-evaluator/Cargo.tomldiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/Cargo.toml
+++ b/crates/jrsonnet-evaluator/Cargo.toml
@@ -13,11 +13,13 @@
 workspace = true
 
 [features]
-default = ["explaining-traces"]
+default = ["explaining-traces", "ir-parser"]
 # Rustc-like trace visualization
 explaining-traces = ["annotate-snippets", "hi-doc"]
 # Allows library authors to throw custom errors
 anyhow-error = ["anyhow"]
+# Use hand-written recursive descent parser instead of PEG parser
+ir-parser = ["dep:jrsonnet-ir-parser"]
 
 # Allows to preserve field order in objects
 exp-preserve-order = []
@@ -28,12 +30,13 @@
 # Bigint type
 exp-bigint = ["num-bigint", "jrsonnet-types/exp-bigint"]
 # obj?.field, obj?.['field']
-exp-null-coaelse = ["jrsonnet-peg-parser/exp-null-coaelse"]
+exp-null-coaelse = ["jrsonnet-peg-parser/exp-null-coaelse", "jrsonnet-ir-parser?/exp-null-coaelse"]
 
 [dependencies]
 jrsonnet-interner.workspace = true
 jrsonnet-ir.workspace = true
 jrsonnet-peg-parser.workspace = true
+jrsonnet-ir-parser = { workspace = true, optional = true }
 jrsonnet-types.workspace = true
 jrsonnet-macros.workspace = true
 jrsonnet-gcmodule.workspace = true
modifiedcrates/jrsonnet-evaluator/src/async_import.rsdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/async_import.rs
+++ b/crates/jrsonnet-evaluator/src/async_import.rs
@@ -7,6 +7,9 @@
 	FieldMember, FieldName, ForSpecData, IfElse, IfSpecData, ImportKind, ObjBody, Slice, SliceDesc,
 	Source, SourcePath, Spanned,
 };
+#[cfg(feature = "ir-parser")]
+use jrsonnet_ir_parser::ParserSettings;
+#[cfg(not(feature = "ir-parser"))]
 use jrsonnet_peg_parser::ParserSettings;
 use rustc_hash::FxHashMap;
 
@@ -323,7 +326,7 @@
 						};
 						let source = Source::new(path.clone(), code.clone());
 						// If failed - then skip import
-						file.parsed = jrsonnet_peg_parser::parse(&code, &ParserSettings { source })
+						file.parsed = crate::parse_jsonnet(&code, &ParserSettings { source })
 							.map(Rc::new)
 							.ok();
 						if let Some(parsed) = &file.parsed {
modifiedcrates/jrsonnet-evaluator/src/error.rsdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/error.rs
+++ b/crates/jrsonnet-evaluator/src/error.rs
@@ -154,6 +154,7 @@
 	ImportNotSupported(SourcePath, ResolvePathOwned),
 	#[error("can't import from virtual file")]
 	CantImportFromVirtualFile,
+	#[cfg(not(feature = "ir-parser"))]
 	#[error(
 		"syntax error: {}",
 		// Peg has no fancier way to handle critical parsing errors https://github.com/kevinmehall/rust-peg/issues/225
@@ -172,6 +173,14 @@
 		error: Box<jrsonnet_peg_parser::ParseError>,
 	},
 
+	#[cfg(feature = "ir-parser")]
+	#[error("syntax error: {error}")]
+	ImportSyntaxError {
+		path: Source,
+		#[trace(skip)]
+		error: Box<jrsonnet_ir_parser::ParseError>,
+	},
+
 	#[error("runtime error: {}", format_empty_str(.0))]
 	RuntimeError(IStr),
 	#[error("stack overflow, try to reduce recursion, or set --max-stack to bigger value")]
modifiedcrates/jrsonnet-evaluator/src/lib.rsdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/lib.rs
+++ b/crates/jrsonnet-evaluator/src/lib.rs
@@ -46,6 +46,9 @@
 use jrsonnet_ir::{Expr, Source, SourcePath};
 #[doc(hidden)]
 pub use jrsonnet_macros;
+#[cfg(feature = "ir-parser")]
+use jrsonnet_ir_parser::ParserSettings;
+#[cfg(not(feature = "ir-parser"))]
 use jrsonnet_peg_parser::ParserSettings;
 pub use obj::*;
 pub use rustc_hash;
@@ -56,6 +59,22 @@
 
 use crate::gc::WithCapacityExt as _;
 
+#[cfg(feature = "ir-parser")]
+pub(crate) fn parse_jsonnet(
+	code: &str,
+	settings: &ParserSettings,
+) -> Result<Expr, jrsonnet_ir_parser::ParseError> {
+	jrsonnet_ir_parser::parse(code, settings)
+}
+
+#[cfg(not(feature = "ir-parser"))]
+pub(crate) fn parse_jsonnet(
+	code: &str,
+	settings: &ParserSettings,
+) -> Result<Expr, jrsonnet_peg_parser::ParseError> {
+	jrsonnet_peg_parser::parse(code, settings)
+}
+
 cc_dyn!(
 	#[derive(Clone)]
 	CcUnbound<V>,
@@ -345,7 +364,7 @@
 		let file_name = Source::new(path.clone(), code.clone());
 		if file.parsed.is_none() {
 			file.parsed = Some(
-				jrsonnet_peg_parser::parse(
+				parse_jsonnet(
 					&code,
 					&ParserSettings {
 						source: file_name.clone(),
@@ -461,7 +480,7 @@
 	pub fn evaluate_snippet(&self, name: impl Into<IStr>, code: impl Into<IStr>) -> Result<Val> {
 		let code = code.into();
 		let source = Source::new_virtual(name.into(), code.clone());
-		let parsed = jrsonnet_peg_parser::parse(
+		let parsed = parse_jsonnet(
 			&code,
 			&ParserSettings {
 				source: source.clone(),
@@ -482,7 +501,7 @@
 	) -> Result<Val> {
 		let code = code.into();
 		let source = Source::new_virtual(name.into(), code.clone());
-		let parsed = jrsonnet_peg_parser::parse(
+		let parsed = parse_jsonnet(
 			&code,
 			&ParserSettings {
 				source: source.clone(),
modifiedcrates/jrsonnet-ir-parser/Cargo.tomldiffbeforeafterboth
--- a/crates/jrsonnet-ir-parser/Cargo.toml
+++ b/crates/jrsonnet-ir-parser/Cargo.toml
@@ -6,6 +6,9 @@
 repository.workspace = true
 version.workspace = true
 
+[features]
+exp-null-coaelse = ["jrsonnet-ir/exp-null-coaelse"]
+
 [dependencies]
 insta.workspace = true
 jrsonnet-gcmodule.workspace = true
modifiedcrates/jrsonnet-ir-parser/src/lib.rsdiffbeforeafterboth
before · crates/jrsonnet-ir-parser/src/lib.rs
1use std::rc::Rc;23use insta::assert_snapshot;4use jrsonnet_gcmodule::Acyclic;5use jrsonnet_ir::{6	AssertExpr, AssertStmt, Expr, IfElse, IfSpecData, LiteralType, Slice, SliceDesc, Source,7	SourceVirtual, Span, Spanned,8};9use jrsonnet_lexer::{Lexeme, Lexer, SyntaxKind, T};1011struct Parser<'a> {12	lexemes: Vec<Lexeme<'a>>,13	offset: usize,14	source: Source,15}1617impl<'a> Parser<'a> {18	fn new(s: &'a str) -> Self {19		Self {20			lexemes: Lexer::new(s)21				.filter(|l| l.kind != SyntaxKind::WHITESPACE)22				.collect(),23			offset: 0,24			source: Source::new_virtual("<test>".into(), s.into()),25		}26	}27	fn peek(&self) -> SyntaxKind {28		self.lexemes[self.offset].kind29	}30	fn text(&self) -> &str {31		self.lexemes[self.offset].text32	}33	fn at(&self, kind: SyntaxKind) -> bool {34		!self.at_eof() && self.peek() == kind35	}36	fn eat_any(&mut self) {37		self.offset += 138	}3940	fn at_eof(&self) -> bool {41		self.offset == self.lexemes.len()42	}4344	fn try_eat(&mut self, t: SyntaxKind) -> bool {45		if self.at(t) {46			self.eat_any();47			return true;48		}49		false50	}51	fn eat(&mut self, t: SyntaxKind) {52		assert_eq!(self.peek(), t);53		self.eat_any();54	}5556	fn span_start(&self) -> u32 {57		self.lexemes[self.offset].range.058	}59	fn span_end(&self) -> u32 {60		self.lexemes[self.offset - 1].range.161	}62}6364fn literal(p: &mut Parser<'_>) -> Option<LiteralType> {65	let t = match p.peek() {66		T![self] => LiteralType::This,67		T![super] => LiteralType::Super,68		T!['$'] => LiteralType::Dollar,69		T![null] => LiteralType::Null,70		T![true] => LiteralType::True,71		T![false] => LiteralType::False,72		_ => return None,73	};74	p.eat_any();75	Some(t)76}7778fn spanned<T: Acyclic>(p: &mut Parser<'_>, cb: impl FnOnce(&mut Parser<'_>) -> T) -> Spanned<T> {79	let start = p.span_start();80	let v = cb(p);81	let end = p.span_end();8283	Spanned::new(v, Span(p.source.clone(), start, end))84}8586fn assert_stmt(p: &mut Parser<'_>) -> AssertStmt {87	p.eat(T![assert]);88	let cond = spanned(p, expr);89	dbg!(p.peek());90	let msg = if p.try_eat(T![:]) {91		Some(spanned(p, expr))92	} else {93		None94	};95	dbg!(AssertStmt(cond, msg))96}9798fn if_spec_data(p: &mut Parser<'_>) -> IfSpecData {99	let v = spanned(p, |p| p.eat(T![if]));100	let cond = expr(p);101	IfSpecData { span: v.span, cond }102}103104fn if_else(p: &mut Parser<'_>) -> IfElse {105	let cond = if_spec_data(p);106	p.eat(T![then]);107	let cond_then = expr(p);108	let cond_else = if p.at(T![else]) { Some(expr(p)) } else { None };109	IfElse {110		cond,111		cond_then,112		cond_else,113	}114}115116fn slice_desc(p: &mut Parser<'_>, start: Option<Spanned<Expr>>) -> SliceDesc {117	// start118	p.eat(T![:]);119	let end = if !p.at(T![:]) && !p.at(T![']']) {120		Some(spanned(p, expr))121	} else {122		None123	};124	let step = if p.try_eat(T![:]) && !p.at(T![']']) {125		Some(spanned(p, expr))126	} else {127		None128	};129	SliceDesc { start, end, step }130}131132fn expr_simple(p: &mut Parser<'_>) -> Expr {133	let mut e = if let Some(literal) = literal(p) {134		Expr::Literal(literal)135	} else if p.at(T![assert]) {136		let assert = assert_stmt(p);137		p.eat(T![;]);138		let rest = expr(p);139		Expr::AssertExpr(Rc::new(AssertExpr { assert, rest }))140	} else if p.at(T![if]) {141		Expr::IfElse(Box::new(if_else(p)))142	} else {143		panic!("unexpected token: {:?}", p.peek());144	};145146	dbg!(&e);147148	loop {149		if p.try_eat(T!['[']) {150			if p.at(T![:]) {151				let slice = slice_desc(p, None);152				e = Expr::Slice(Box::new(Slice { value: e, slice }));153				p.eat(T![']']);154				continue;155			}156157			let idx = spanned(p, expr);158			if p.at(T![:]) {159				let slice = slice_desc(p, Some(idx));160				e = Expr::Slice(Box::new(Slice { value: e, slice }));161			} else {162			}163			p.eat(T![']']);164		} else {165			break;166		}167	}168169	dbg!(e)170}171172fn expr(p: &mut Parser<'_>) -> Expr {173	expr_simple(p)174}175176#[test]177fn basic_test() {178	let mut parser = Parser::new(" assert true[false] : false ; true ");179	let e = expr(&mut parser);180	let l = &parser.lexemes;181182	assert_snapshot!(format!("{l:#?}\n\n---\n\n{e:#?}"));183}
after · crates/jrsonnet-ir-parser/src/lib.rs
1use std::rc::Rc;23use jrsonnet_gcmodule::Acyclic;4use jrsonnet_ir::{5	unescape, ArgsDesc, AssertExpr, AssertStmt, BinaryOp, BinaryOpType, BindSpec, CompSpec,6	Destruct, Expr, ExprParam, ExprParams, FieldMember, FieldName, ForSpecData, IStr, IfElse,7	IfSpecData, ImportKind, IndexPart, LiteralType, Member, ObjBody, ObjComp, ObjMembers, Slice,8	SliceDesc, Source, Span, Spanned, UnaryOpType, Visibility,9};10use jrsonnet_lexer::{collect_lexed_str_block, Lexeme, Lexer, SyntaxKind, T};1112pub struct ParserSettings {13	pub source: Source,14}1516#[derive(Debug, Clone)]17pub struct ParseErrorLocation {18	pub offset: usize,19}2021#[derive(Debug, Clone)]22pub struct ParseError {23	pub message: String,24	pub location: ParseErrorLocation,25}2627impl std::fmt::Display for ParseError {28	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {29		write!(f, "{}", self.message)30	}31}3233type R<T> = Result<T, ParseError>;3435struct Parser<'a> {36	lexemes: Vec<Lexeme<'a>>,37	offset: usize,38	source: Source,39}4041impl<'a> Parser<'a> {42	fn new(code: &'a str, source: Source) -> Self {43		Self {44			lexemes: Lexer::new(code)45				.filter(|l| {46					!matches!(47						l.kind,48						SyntaxKind::WHITESPACE49							| SyntaxKind::SINGLE_LINE_SLASH_COMMENT50							| SyntaxKind::SINGLE_LINE_HASH_COMMENT51							| SyntaxKind::MULTI_LINE_COMMENT52					)53				})54				.collect(),55			offset: 0,56			source,57		}58	}5960	fn peek(&self) -> SyntaxKind {61		if self.at_eof() {62			SyntaxKind::EOF63		} else {64			self.lexemes[self.offset].kind65		}66	}6768	fn text(&self) -> &'a str {69		self.lexemes[self.offset].text70	}7172	fn at(&self, kind: SyntaxKind) -> bool {73		!self.at_eof() && self.peek() == kind74	}7576	fn eat_any(&mut self) {77		self.offset += 1;78	}7980	fn at_eof(&self) -> bool {81		self.offset >= self.lexemes.len()82	}8384	fn try_eat(&mut self, t: SyntaxKind) -> bool {85		if self.at(t) {86			self.eat_any();87			return true;88		}89		false90	}9192	fn current_desc(&self) -> String {93		if self.at_eof() {94			return "end of file".to_owned();95		}96		let kind = self.peek();97		let text = self.text();98		let name = kind.display_name();99		if matches!(kind, SyntaxKind::IDENT | SyntaxKind::FLOAT) {100			format!("{name} \"{text}\"")101		} else {102			name.to_owned()103		}104	}105106	fn eat(&mut self, t: SyntaxKind) -> R<()> {107		if !self.at(t) {108			return Err(self.error(format!(109				"expected {}, got {}",110				t.display_name(),111				self.current_desc(),112			)));113		}114		self.eat_any();115		Ok(())116	}117118	fn span_start(&self) -> u32 {119		if self.at_eof() {120			if let Some(last) = self.lexemes.last() {121				return last.range.1;122			}123			return 0;124		}125		self.lexemes[self.offset].range.0126	}127128	fn span_end(&self) -> u32 {129		self.lexemes[self.offset - 1].range.1130	}131132	fn error(&self, message: String) -> ParseError {133		ParseError {134			location: ParseErrorLocation {135				offset: self.span_start() as usize,136			},137			message,138		}139	}140141	fn expect_ident(&mut self) -> R<IStr> {142		if !self.at(SyntaxKind::IDENT) {143			return Err(self.error(format!("expected identifier, got {}", self.current_desc())));144		}145		let text = self.text();146		if is_reserved(text) {147			return Err(self.error(format!(148				"expected identifier, got reserved word '{text}'"149			)));150		}151		let s: IStr = text.into();152		self.eat_any();153		Ok(s)154	}155156	fn at_ident(&self) -> bool {157		self.at(SyntaxKind::IDENT) && !is_reserved(self.lexemes[self.offset].text)158	}159}160161fn is_reserved(s: &str) -> bool {162	matches!(163		s,164		"assert"165			| "else" | "error"166			| "false" | "for"167			| "function" | "if"168			| "import" | "importstr"169			| "importbin" | "in"170			| "local" | "null"171			| "tailstrict" | "then"172			| "self" | "super"173			| "true"174	)175}176177fn spanned<T: Acyclic>(p: &mut Parser<'_>, cb: impl FnOnce(&mut Parser<'_>) -> R<T>) -> R<Spanned<T>> {178	let start = p.span_start();179	let v = cb(p)?;180	let end = p.span_end();181	Ok(Spanned::new(v, Span(p.source.clone(), start, end)))182}183184fn parse_string_content(p: &mut Parser<'_>) -> R<IStr> {185	let kind = p.peek();186	let text = p.text();187	let s = match kind {188		SyntaxKind::STRING_DOUBLE => {189			let inner = &text[1..text.len() - 1];190			unescape::unescape(inner)191				.ok_or_else(|| p.error("invalid string escape".into()))?192		}193		SyntaxKind::STRING_SINGLE => {194			let inner = &text[1..text.len() - 1];195			unescape::unescape(inner)196				.ok_or_else(|| p.error("invalid string escape".into()))?197		}198		SyntaxKind::STRING_DOUBLE_VERBATIM => {199			let inner = &text[2..text.len() - 1];200			inner.replace("\"\"", "\"")201		}202		SyntaxKind::STRING_SINGLE_VERBATIM => {203			let inner = &text[2..text.len() - 1];204			inner.replace("''", "'")205		}206		SyntaxKind::STRING_BLOCK => {207			let inner = &text[3..];208			let collected = collect_lexed_str_block(inner)209				.map_err(|_| p.error("invalid string block".into()))?;210			let mut result = String::new();211			for (i, line) in collected.lines.iter().enumerate() {212				if i > 0 {213					result.push('\n');214				}215				result.push_str(line);216			}217			if !collected.truncate {218				result.push('\n');219			}220			result221		}222		_ => return Err(p.error(format!("expected string, got {}", p.current_desc()))),223	};224	p.eat_any();225	Ok(s.into())226}227228fn is_string_token(kind: SyntaxKind) -> bool {229	matches!(230		kind,231		SyntaxKind::STRING_DOUBLE232			| SyntaxKind::STRING_SINGLE233			| SyntaxKind::STRING_DOUBLE_VERBATIM234			| SyntaxKind::STRING_SINGLE_VERBATIM235			| SyntaxKind::STRING_BLOCK236	)237}238239fn parse_number(p: &mut Parser<'_>) -> R<f64> {240	let text = p.text();241	let n: f64 = text242		.replace('_', "")243		.parse()244		.map_err(|_| p.error(format!("invalid number literal: {text}")))?;245	if !n.is_finite() {246		return Err(p.error("numbers are finite".into()));247	}248	p.eat_any();249	Ok(n)250}251252fn literal(p: &mut Parser<'_>) -> Option<LiteralType> {253	let t = match p.peek() {254		T![self] => LiteralType::This,255		T![super] => LiteralType::Super,256		T!['$'] => LiteralType::Dollar,257		T![null] => LiteralType::Null,258		T![true] => LiteralType::True,259		T![false] => LiteralType::False,260		_ => return None,261	};262	p.eat_any();263	Some(t)264}265266fn assert_stmt(p: &mut Parser<'_>) -> R<AssertStmt> {267	p.eat(T![assert])?;268	let cond = spanned(p, expr)?;269	let msg = if p.try_eat(T![:]) {270		Some(spanned(p, expr)?)271	} else {272		None273	};274	Ok(AssertStmt(cond, msg))275}276277fn if_spec_data(p: &mut Parser<'_>) -> R<IfSpecData> {278	let v = spanned(p, |p| p.eat(T![if]))?;279	let cond = expr(p)?;280	Ok(IfSpecData { span: v.span, cond })281}282283fn if_else(p: &mut Parser<'_>) -> R<IfElse> {284	let cond = if_spec_data(p)?;285	p.eat(T![then])?;286	let cond_then = expr(p)?;287	let cond_else = if p.try_eat(T![else]) {288		Some(expr(p)?)289	} else {290		None291	};292	Ok(IfElse {293		cond,294		cond_then,295		cond_else,296	})297}298299fn slice_desc(p: &mut Parser<'_>, start: Option<Spanned<Expr>>) -> R<SliceDesc> {300	p.eat(T![:])?;301	let end = if !p.at(T![:]) && !p.at(T![']']) {302		Some(spanned(p, expr)?)303	} else {304		None305	};306	let step = if p.try_eat(T![:]) {307		if !p.at(T![']']) {308			Some(spanned(p, expr)?)309		} else {310			None311		}312	} else {313		None314	};315	Ok(SliceDesc { start, end, step })316}317318fn destruct(p: &mut Parser<'_>) -> R<Destruct> {319	Ok(Destruct::Full(p.expect_ident()?))320}321322fn params(p: &mut Parser<'_>) -> R<ExprParams> {323	if p.at(T![')']) {324		return Ok(ExprParams::new(Vec::new()));325	}326	let mut result = Vec::new();327	loop {328		let d = destruct(p)?;329		let default = if p.try_eat(T![=]) {330			Some(Rc::new(expr(p)?))331		} else {332			None333		};334		result.push(ExprParam {335			destruct: d,336			default,337		});338		if !p.try_eat(T![,]) {339			break;340		}341		if p.at(T![')']) {342			break;343		}344	}345	Ok(ExprParams::new(result))346}347348fn args(p: &mut Parser<'_>) -> R<ArgsDesc> {349	if p.at(T![')']) {350		return Ok(ArgsDesc::new(Vec::new(), Vec::new()));351	}352	let mut unnamed = Vec::new();353	let mut named = Vec::new();354	let mut named_started = false;355	loop {356		let is_named = p.at_ident() && {357			let next_offset = p.offset + 1;358			next_offset < p.lexemes.len() && p.lexemes[next_offset].kind == T![=] && {359				let after_eq = next_offset + 1;360				after_eq >= p.lexemes.len() || p.lexemes[after_eq].kind != T![=]361			}362		};363		if is_named {364			let name: IStr = p.expect_ident()?;365			p.eat(T![=])?;366			let value = Rc::new(expr(p)?);367			named.push((name, value));368			named_started = true;369		} else {370			if named_started {371				return Err(p.error("positional argument after named argument".into()));372			}373			unnamed.push(Rc::new(expr(p)?));374		}375		if !p.try_eat(T![,]) {376			break;377		}378		if p.at(T![')']) {379			break;380		}381	}382	Ok(ArgsDesc::new(unnamed, named))383}384385fn bind(p: &mut Parser<'_>) -> R<BindSpec> {386	let name = p.expect_ident()?;387	if p.try_eat(T!['(']) {388		let ps = params(p)?;389		p.eat(T![')'])?;390		p.eat(T![=])?;391		let value = Rc::new(expr(p)?);392		Ok(BindSpec::Function {393			name,394			params: ps,395			value,396		})397	} else {398		p.eat(T![=])?;399		let value = Rc::new(expr(p)?);400		Ok(BindSpec::Field {401			into: Destruct::Full(name),402			value,403		})404	}405}406407fn visibility(p: &mut Parser<'_>) -> R<Visibility> {408	p.eat(T![:])?;409	if p.try_eat(T![:]) {410		if p.try_eat(T![:]) {411			Ok(Visibility::Unhide)412		} else {413			Ok(Visibility::Hidden)414		}415	} else {416		Ok(Visibility::Normal)417	}418}419420fn field_name(p: &mut Parser<'_>) -> R<FieldName> {421	if p.at_ident() {422		Ok(FieldName::Fixed(p.expect_ident()?))423	} else if is_string_token(p.peek()) {424		Ok(FieldName::Fixed(parse_string_content(p)?))425	} else if p.at(T!['[']) {426		p.eat(T!['['])?;427		let e = expr(p)?;428		p.eat(T![']'])?;429		Ok(FieldName::Dyn(e))430	} else {431		Err(p.error(format!("expected field name, got {}", p.current_desc())))432	}433}434435fn field(p: &mut Parser<'_>) -> R<FieldMember> {436	let name = spanned(p, field_name)?;437438	if p.at(T!['(']) {439		p.eat(T!['('])?;440		let ps = params(p)?;441		p.eat(T![')'])?;442		let vis = visibility(p)?;443		let value = Rc::new(expr(p)?);444		Ok(FieldMember {445			name,446			plus: false,447			params: Some(ps),448			visibility: vis,449			value,450		})451	} else {452		let plus = p.try_eat(T![+]);453		let vis = visibility(p)?;454		let value = Rc::new(expr(p)?);455		Ok(FieldMember {456			name,457			plus,458			params: None,459			visibility: vis,460			value,461		})462	}463}464465fn member(p: &mut Parser<'_>) -> R<Member> {466	if p.at(T![local]) {467		p.eat(T![local])?;468		Ok(Member::BindStmt(bind(p)?))469	} else if p.at(T![assert]) {470		Ok(Member::AssertStmt(assert_stmt(p)?))471	} else {472		Ok(Member::Field(field(p)?))473	}474}475476fn for_spec(p: &mut Parser<'_>) -> R<ForSpecData> {477	p.eat(T![for])?;478	let d = destruct(p)?;479	p.eat(T![in])?;480	let over = expr(p)?;481	Ok(ForSpecData { destruct: d, over })482}483484fn compspecs(p: &mut Parser<'_>) -> R<Vec<CompSpec>> {485	let mut specs = Vec::new();486	specs.push(CompSpec::ForSpec(for_spec(p)?));487	loop {488		if p.at(T![for]) {489			specs.push(CompSpec::ForSpec(for_spec(p)?));490		} else if p.at(T![if]) {491			let isd = if_spec_data(p)?;492			specs.push(CompSpec::IfSpec(isd));493		} else {494			break;495		}496	}497	Ok(specs)498}499500fn objinside(p: &mut Parser<'_>) -> R<ObjBody> {501	if p.at(T!['}']) {502		return Ok(ObjBody::MemberList(ObjMembers {503			locals: Rc::new(Vec::new()),504			asserts: Rc::new(Vec::new()),505			fields: Vec::new(),506		}));507	}508509	let mut members = Vec::new();510	loop {511		members.push(member(p)?);512		if !p.try_eat(T![,]) {513			break;514		}515		if p.at(T!['}']) || p.at(T![for]) {516			break;517		}518	}519520	if p.at(T![for]) {521		let specs = compspecs(p)?;522		let mut locals = Vec::new();523		let mut field_member = None;524		for m in members {525			match m {526				Member::Field(f) => {527					if field_member.is_some() {528						return Err(p.error(529							"object comprehension can only contain one field".into(),530						));531					}532					field_member = Some(f);533				}534				Member::BindStmt(b) => locals.push(b),535				Member::AssertStmt(_) => {536					return Err(p.error(537						"asserts are unsupported in object comprehension".into(),538					));539				}540			}541		}542		Ok(ObjBody::ObjComp(ObjComp {543			locals: Rc::new(locals),544			field: Rc::new(545				field_member.ok_or_else(|| {546					p.error("missing object comprehension field".into())547				})?,548			),549			compspecs: specs,550		}))551	} else {552		let mut locals = Vec::new();553		let mut asserts = Vec::new();554		let mut fields = Vec::new();555		for m in members {556			match m {557				Member::Field(f) => fields.push(f),558				Member::BindStmt(b) => locals.push(b),559				Member::AssertStmt(a) => asserts.push(a),560			}561		}562		Ok(ObjBody::MemberList(ObjMembers {563			locals: Rc::new(locals),564			asserts: Rc::new(asserts),565			fields,566		}))567	}568}569570fn expr_basic(p: &mut Parser<'_>) -> R<Expr> {571	if let Some(lit) = literal(p) {572		return Ok(Expr::Literal(lit));573	}574575	match p.peek() {576		SyntaxKind::STRING_DOUBLE577		| SyntaxKind::STRING_SINGLE578		| SyntaxKind::STRING_DOUBLE_VERBATIM579		| SyntaxKind::STRING_SINGLE_VERBATIM580		| SyntaxKind::STRING_BLOCK => Ok(Expr::Str(parse_string_content(p)?)),581582		SyntaxKind::FLOAT => Ok(Expr::Num(parse_number(p)?)),583584		T!['('] => {585			p.eat(T!['('])?;586			let e = expr(p)?;587			p.eat(T![')'])?;588			Ok(e)589		}590591		T!['['] => {592			p.eat(T!['['])?;593			if p.at(T![']']) {594				p.eat(T![']'])?;595				return Ok(Expr::Arr(Rc::new(Vec::new())));596			}597			let first = expr(p)?;598			if p.at(T![for]) {599				let specs = compspecs(p)?;600				p.eat(T![']'])?;601				Ok(Expr::ArrComp(Rc::new(first), specs))602			} else if p.at(T![,]) && {603				let next = p.offset + 1;604				next < p.lexemes.len() && p.lexemes[next].kind == T![for]605			} {606				p.eat(T![,])?;607				let specs = compspecs(p)?;608				p.eat(T![']'])?;609				Ok(Expr::ArrComp(Rc::new(first), specs))610			} else {611				let mut elems = vec![first];612				while p.try_eat(T![,]) {613					if p.at(T![']']) {614						break;615					}616					elems.push(expr(p)?);617				}618				p.eat(T![']'])?;619				Ok(Expr::Arr(Rc::new(elems)))620			}621		}622623		T!['{'] => {624			p.eat(T!['{'])?;625			let body = objinside(p)?;626			p.eat(T!['}'])?;627			Ok(Expr::Obj(body))628		}629630		T![local] => {631			p.eat(T![local])?;632			let mut binds = Vec::new();633			loop {634				binds.push(bind(p)?);635				if !p.try_eat(T![,]) {636					break;637				}638			}639			p.eat(T![;])?;640			let body = expr(p)?;641			Ok(Expr::LocalExpr(binds, Box::new(body)))642		}643644		T![if] => Ok(Expr::IfElse(Box::new(if_else(p)?))),645646		T![function] => {647			p.eat(T![function])?;648			p.eat(T!['('])?;649			let ps = params(p)?;650			p.eat(T![')'])?;651			let body = expr(p)?;652			Ok(Expr::Function(ps, Rc::new(body)))653		}654655		T![assert] => {656			let a = assert_stmt(p)?;657			p.eat(T![;])?;658			let rest = expr(p)?;659			Ok(Expr::AssertExpr(Rc::new(AssertExpr { assert: a, rest })))660		}661662		T![error] => {663			let span = spanned(p, |p| p.eat(T![error]))?;664			let e = expr(p)?;665			Ok(Expr::ErrorStmt(span.span, Box::new(e)))666		}667668		T![importstr] => {669			let kind = spanned(p, |p| {670				p.eat(T![importstr])?;671				Ok(ImportKind::Str)672			})?;673			let path = expr(p)?;674			Ok(Expr::Import(kind, Box::new(path)))675		}676677		T![importbin] => {678			let kind = spanned(p, |p| {679				p.eat(T![importbin])?;680				Ok(ImportKind::Bin)681			})?;682			let path = expr(p)?;683			Ok(Expr::Import(kind, Box::new(path)))684		}685686		T![import] => {687			let kind = spanned(p, |p| {688				p.eat(T![import])?;689				Ok(ImportKind::Normal)690			})?;691			let path = expr(p)?;692			Ok(Expr::Import(kind, Box::new(path)))693		}694695		SyntaxKind::IDENT => {696			let text = p.text();697			if is_reserved(text) {698				return Err(p.error(format!("unexpected reserved word '{text}'")));699			}700			let n = spanned(p, |p| {701				let s: IStr = p.text().into();702				p.eat_any();703				Ok(s)704			})?;705			Ok(Expr::Var(n))706		}707708		_ => Err(p.error(format!("unexpected {}", p.current_desc()))),709	}710}711712/// Flush accumulated index parts into an Expr::Index wrapping `e`.713fn flush_index_parts(e: &mut Expr, parts: &mut Vec<IndexPart>) {714	if parts.is_empty() {715		return;716	}717	let old = std::mem::replace(e, Expr::Literal(LiteralType::Null));718	*e = Expr::Index {719		indexable: Box::new(old),720		parts: std::mem::take(parts),721	};722}723724fn expr_suffix(p: &mut Parser<'_>) -> R<Expr> {725	let mut e = expr_basic(p)?;726	// Accumulate consecutive index parts (.field, [expr], ?.field, ?.[expr])727	// into a single Expr::Index. This is critical for null-coalesce semantics:728	// a?.b.c needs all parts in one Index so the evaluator can skip .c when .b is null.729	let mut parts: Vec<IndexPart> = Vec::new();730731	loop {732		#[cfg(feature = "exp-null-coaelse")]733		if p.at(T![?]) {734			p.eat_any();735			if p.try_eat(T![.]) {736				if p.at(T!['[']) {737					// ?.[expr]738					p.eat(T!['['])?;739					let idx = spanned(p, expr)?;740					p.eat(T![']'])?;741					parts.push(IndexPart {742						span: idx.span,743						value: idx.value,744						null_coaelse: true,745					});746				} else {747					// ?.field748					let id_spanned = spanned(p, |p| {749						let name = p.expect_ident()?;750						Ok(Expr::Str(name))751					})?;752					parts.push(IndexPart {753						span: id_spanned.span,754						value: id_spanned.value,755						null_coaelse: true,756					});757				}758			} else {759				return Err(p.error("expected '.' after '?'".into()));760			}761			continue;762		}763764		if p.at(T![.]) {765			p.eat(T![.])?;766			let id_spanned = spanned(p, |p| {767				let name = p.expect_ident()?;768				Ok(Expr::Str(name))769			})?;770			parts.push(IndexPart {771				span: id_spanned.span,772				value: id_spanned.value,773				#[cfg(feature = "exp-null-coaelse")]774				null_coaelse: false,775			});776		} else if p.at(T!['[']) {777			p.eat(T!['['])?;778779			if p.at(T![:]) {780				// Slice: flush index parts first, then handle slice781				flush_index_parts(&mut e, &mut parts);782				let slice = slice_desc(p, None)?;783				p.eat(T![']'])?;784				e = Expr::Slice(Box::new(Slice { value: e, slice }));785			} else {786				let idx = spanned(p, expr)?;787				if p.at(T![:]) {788					// Slice with start: flush index parts first789					flush_index_parts(&mut e, &mut parts);790					let slice = slice_desc(p, Some(idx))?;791					p.eat(T![']'])?;792					e = Expr::Slice(Box::new(Slice { value: e, slice }));793				} else {794					// Bracket index: add to parts795					p.eat(T![']'])?;796					parts.push(IndexPart {797						span: idx.span,798						value: idx.value,799						#[cfg(feature = "exp-null-coaelse")]800						null_coaelse: false,801					});802				}803			}804		} else if p.at(T!['(']) {805			flush_index_parts(&mut e, &mut parts);806			let args_spanned = spanned(p, |p| {807				p.eat(T!['('])?;808				let a = args(p)?;809				p.eat(T![')'])?;810				Ok(a)811			})?;812			let tailstrict = p.try_eat(T![tailstrict]);813			e = Expr::Apply(Box::new(e), args_spanned, tailstrict);814		} else if p.at(T!['{']) {815			flush_index_parts(&mut e, &mut parts);816			p.eat(T!['{'])?;817			let body = objinside(p)?;818			p.eat(T!['}'])?;819			e = Expr::ObjExtend(Rc::new(e), body);820		} else {821			break;822		}823	}824825	flush_index_parts(&mut e, &mut parts);826	Ok(e)827}828829fn prefix_binding_power(op: UnaryOpType) -> u8 {830	match op {831		UnaryOpType::Plus | UnaryOpType::Minus | UnaryOpType::Not | UnaryOpType::BitNot => 20,832	}833}834835fn infix_binding_power(op: BinaryOpType) -> (u8, u8) {836	match op {837		BinaryOpType::Or => (2, 3),838		#[cfg(feature = "exp-null-coaelse")]839		BinaryOpType::NullCoaelse => (2, 3),840		BinaryOpType::And => (4, 5),841		BinaryOpType::BitOr => (6, 7),842		BinaryOpType::BitXor => (8, 9),843		BinaryOpType::BitAnd => (10, 11),844		BinaryOpType::Eq | BinaryOpType::Neq => (12, 13),845		BinaryOpType::Lt846		| BinaryOpType::Gt847		| BinaryOpType::Lte848		| BinaryOpType::Gte849		| BinaryOpType::In => (14, 15),850		BinaryOpType::Lhs | BinaryOpType::Rhs => (16, 17),851		BinaryOpType::Add | BinaryOpType::Sub => (18, 19),852		BinaryOpType::Mul | BinaryOpType::Div | BinaryOpType::Mod => (20, 21),853	}854}855856fn unary_op(kind: SyntaxKind) -> Option<UnaryOpType> {857	match kind {858		T![+] => Some(UnaryOpType::Plus),859		T![-] => Some(UnaryOpType::Minus),860		T![!] => Some(UnaryOpType::Not),861		T![~] => Some(UnaryOpType::BitNot),862		_ => None,863	}864}865866fn binary_op(p: &Parser<'_>) -> Option<BinaryOpType> {867	match p.peek() {868		T![||] => Some(BinaryOpType::Or),869		T![&&] => Some(BinaryOpType::And),870		T![|] => Some(BinaryOpType::BitOr),871		T![^] => Some(BinaryOpType::BitXor),872		T![&] => Some(BinaryOpType::BitAnd),873		T![==] => Some(BinaryOpType::Eq),874		T![!=] => Some(BinaryOpType::Neq),875		T![<] => Some(BinaryOpType::Lt),876		T![>] => Some(BinaryOpType::Gt),877		T![<=] => Some(BinaryOpType::Lte),878		T![>=] => Some(BinaryOpType::Gte),879		T![<<] => Some(BinaryOpType::Lhs),880		T![>>] => Some(BinaryOpType::Rhs),881		T![+] => Some(BinaryOpType::Add),882		T![-] => Some(BinaryOpType::Sub),883		T![*] => Some(BinaryOpType::Mul),884		T![/] => Some(BinaryOpType::Div),885		T![%] => Some(BinaryOpType::Mod),886		T![in] => Some(BinaryOpType::In),887		#[cfg(feature = "exp-null-coaelse")]888		T![??] => Some(BinaryOpType::NullCoaelse),889		_ => None,890	}891}892893fn expr_bp(p: &mut Parser<'_>, min_bp: u8) -> R<Expr> {894	let mut lhs = if let Some(op) = unary_op(p.peek()) {895		p.eat_any();896		let rbp = prefix_binding_power(op);897		let rhs = expr_bp(p, rbp)?;898		Expr::UnaryOp(op, Box::new(rhs))899	} else {900		expr_suffix(p)?901	};902903	loop {904		if p.at_eof() {905			break;906		}907908		let Some(op) = binary_op(p) else {909			break;910		};911912		let (lbp, rbp) = infix_binding_power(op);913		if lbp < min_bp {914			break;915		}916917		p.eat_any();918		let rhs = expr_bp(p, rbp)?;919		lhs = Expr::BinaryOp(Box::new(BinaryOp { lhs, op, rhs }));920	}921922	Ok(lhs)923}924925fn expr(p: &mut Parser<'_>) -> R<Expr> {926	expr_bp(p, 0)927}928929pub fn parse(str: &str, settings: &ParserSettings) -> Result<Expr, ParseError> {930	let mut p = Parser::new(str, settings.source.clone());931	for lexeme in &p.lexemes {932		if let Some(desc) = lexeme.kind.error_description() {933			return Err(ParseError {934				message: desc.to_owned(),935				location: ParseErrorLocation {936					offset: lexeme.range.0 as usize,937				},938			});939		}940	}941	let e = expr(&mut p)?;942	if !p.at_eof() {943		return Err(p.error(format!(944			"expected end of file, got {}",945			p.current_desc(),946		)));947	}948	Ok(e)949}950951pub fn string_to_expr(s: IStr, settings: &ParserSettings) -> Spanned<Expr> {952	let len = s.len();953	Spanned::new(954		Expr::Str(s),955		Span(settings.source.clone(), 0, len as u32),956	)957}958959#[cfg(test)]960mod tests {961	use std::fs;962963	use insta::{assert_snapshot, glob};964	use jrsonnet_ir::{IStr, Source};965966	use super::*;967968	fn parse_str(input: &str) -> Expr {969		let source = Source::new_virtual("<test>".into(), input.into());970		let settings = ParserSettings { source };971		parse(input, &settings).unwrap()972	}973974	#[test]975	#[cfg(not(feature = "exp-null-coaelse"))]976	fn basic_test() {977		let v = parse_str("assert true[false] : false ; true");978		assert_snapshot!(format!("{v:#?}"));979	}980981	#[test]982	fn literals() {983		let v = parse_str("[null, true, false, self, super, $]");984		assert_snapshot!(format!("{v:#?}"));985	}986987	#[test]988	fn basic_math() {989		let v = parse_str("2+2*2");990		assert_snapshot!(format!("{v:#?}"));991	}992993	#[test]994	fn underscore_numbers() {995		let v = parse_str("[1_000, 1_000.000_1, 1_0e1_0]");996		assert_snapshot!(format!("{v:#?}"));997	}998999	#[test]1000	fn strings() {1001		let v = parse_str(r#"["hello", 'world', @"raw""str", @'raw''str']"#);1002		assert_snapshot!(format!("{v:#?}"));1003	}10041005	#[test]1006	fn object() {1007		let v = parse_str("{a: 1, b:: 2, c::: 3}");1008		assert_snapshot!(format!("{v:#?}"));1009	}10101011	#[test]1012	fn function_and_call() {1013		let v = parse_str("local f(x, y=1) = x + y; f(2, y=3)");1014		assert_snapshot!(format!("{v:#?}"));1015	}10161017	#[test]1018	fn if_then_else() {1019		let v = parse_str("if true then 1 else 2");1020		assert_snapshot!(format!("{v:#?}"));1021	}10221023	#[test]1024	fn imports() {1025		let v = parse_str(r#"[import "a", importstr "b", importbin "c"]"#);1026		assert_snapshot!(format!("{v:#?}"));1027	}10281029	#[test]1030	fn array_comp() {1031		let v = parse_str("[x for x in arr]");1032		assert_snapshot!(format!("{v:#?}"));1033	}10341035	#[test]1036	#[cfg(not(feature = "exp-null-coaelse"))]1037	fn index_and_suffix() {1038		let v = parse_str("std.test(2).field[0]");1039		assert_snapshot!(format!("{v:#?}"));1040	}10411042	#[test]1043	fn obj_extend() {1044		let v = parse_str("{} { x: 1 }");1045		assert_snapshot!(format!("{v:#?}"));1046	}10471048	#[test]1049	fn unary_ops() {1050		let v = parse_str("!a && !b");1051		assert_snapshot!(format!("{v:#?}"));1052	}10531054	#[test]1055	fn error_expr() {1056		let v = parse_str("error \"bad\"");1057		assert_snapshot!(format!("{v:#?}"));1058	}10591060	#[test]1061	fn slice() {1062		let v = parse_str("[a[1:], a[1::], a[:1:], a[::1]]");1063		assert_snapshot!(format!("{v:#?}"));1064	}10651066	#[test]1067	#[cfg(not(feature = "exp-null-coaelse"))]1068	fn peg_snapshots() {1069		glob!("../../jrsonnet-peg-parser/src", "tests/*.jsonnet", |path| {1070			let input = fs::read_to_string(path).expect("read test file");1071			let source = Source::new_virtual("<test>".into(), IStr::empty());1072			let settings = ParserSettings { source };1073			let v = parse(&input, &settings).unwrap();1074			let v = format!("{v:#?}");1075			assert_snapshot!(v);1076		});1077	}1078}
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__array_comp.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__array_comp.snap
@@ -0,0 +1,21 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+ArrComp(
+    Var(
+        "x" from virtual:<test>:1-2,
+    ),
+    [
+        ForSpec(
+            ForSpecData {
+                destruct: Full(
+                    "x",
+                ),
+                over: Var(
+                    "arr" from virtual:<test>:12-15,
+                ),
+            },
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__basic_math.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__basic_math.snap
@@ -0,0 +1,23 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+BinaryOp(
+    BinaryOp {
+        lhs: Num(
+            2.0,
+        ),
+        op: Add,
+        rhs: BinaryOp(
+            BinaryOp {
+                lhs: Num(
+                    2.0,
+                ),
+                op: Mul,
+                rhs: Num(
+                    2.0,
+                ),
+            },
+        ),
+    },
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__basic_test.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__basic_test.snap
@@ -0,0 +1,31 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+AssertExpr(
+    AssertExpr {
+        assert: AssertStmt(
+            Index {
+                indexable: Literal(
+                    True,
+                ),
+                parts: [
+                    IndexPart {
+                        span: virtual:<test>:12-17,
+                        value: Literal(
+                            False,
+                        ),
+                    },
+                ],
+            } from virtual:<test>:7-18,
+            Some(
+                Literal(
+                    False,
+                ) from virtual:<test>:21-26,
+            ),
+        ),
+        rest: Literal(
+            True,
+        ),
+    },
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__error_expr.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__error_expr.snap
@@ -0,0 +1,10 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+ErrorStmt(
+    virtual:<test>:0-5,
+    Str(
+        "bad",
+    ),
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__function_and_call.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__function_and_call.snap
@@ -0,0 +1,80 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+LocalExpr(
+    [
+        Function {
+            name: "f",
+            params: ExprParams {
+                exprs: [
+                    ExprParam {
+                        destruct: Full(
+                            "x",
+                        ),
+                        default: None,
+                    },
+                    ExprParam {
+                        destruct: Full(
+                            "y",
+                        ),
+                        default: Some(
+                            Num(
+                                1.0,
+                            ),
+                        ),
+                    },
+                ],
+                signature: FunctionSignature(
+                    [
+                        ParamParse {
+                            name: Named(
+                                "x",
+                            ),
+                            default: None,
+                        },
+                        ParamParse {
+                            name: Named(
+                                "y",
+                            ),
+                            default: Exists,
+                        },
+                    ],
+                ),
+                binds_len: 2,
+            },
+            value: BinaryOp(
+                BinaryOp {
+                    lhs: Var(
+                        "x" from virtual:<test>:18-19,
+                    ),
+                    op: Add,
+                    rhs: Var(
+                        "y" from virtual:<test>:22-23,
+                    ),
+                },
+            ),
+        },
+    ],
+    Apply(
+        Var(
+            "f" from virtual:<test>:25-26,
+        ),
+        ArgsDesc {
+            unnamed: [
+                Num(
+                    2.0,
+                ),
+            ],
+            named: [
+                (
+                    "y",
+                    Num(
+                        3.0,
+                    ),
+                ),
+            ],
+        } from virtual:<test>:26-34,
+        false,
+    ),
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__if_then_else.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__if_then_else.snap
@@ -0,0 +1,22 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+IfElse(
+    IfElse {
+        cond: IfSpecData {
+            span: virtual:<test>:0-2,
+            cond: Literal(
+                True,
+            ),
+        },
+        cond_then: Num(
+            1.0,
+        ),
+        cond_else: Some(
+            Num(
+                2.0,
+            ),
+        ),
+    },
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__imports.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__imports.snap
@@ -0,0 +1,26 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+Arr(
+    [
+        Import(
+            Normal from virtual:<test>:1-7,
+            Str(
+                "a",
+            ),
+        ),
+        Import(
+            Str from virtual:<test>:13-22,
+            Str(
+                "b",
+            ),
+        ),
+        Import(
+            Bin from virtual:<test>:28-37,
+            Str(
+                "c",
+            ),
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__index_and_suffix.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__index_and_suffix.snap
@@ -0,0 +1,44 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+Index {
+    indexable: Apply(
+        Index {
+            indexable: Var(
+                "std" from virtual:<test>:0-3,
+            ),
+            parts: [
+                IndexPart {
+                    span: virtual:<test>:4-8,
+                    value: Str(
+                        "test",
+                    ),
+                },
+            ],
+        },
+        ArgsDesc {
+            unnamed: [
+                Num(
+                    2.0,
+                ),
+            ],
+            named: [],
+        } from virtual:<test>:8-11,
+        false,
+    ),
+    parts: [
+        IndexPart {
+            span: virtual:<test>:12-17,
+            value: Str(
+                "field",
+            ),
+        },
+        IndexPart {
+            span: virtual:<test>:18-19,
+            value: Num(
+                0.0,
+            ),
+        },
+    ],
+}
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__literals.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__literals.snap
@@ -0,0 +1,26 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+Arr(
+    [
+        Literal(
+            Null,
+        ),
+        Literal(
+            True,
+        ),
+        Literal(
+            False,
+        ),
+        Literal(
+            This,
+        ),
+        Literal(
+            Super,
+        ),
+        Literal(
+            Dollar,
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__obj_extend.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__obj_extend.snap
@@ -0,0 +1,34 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+ObjExtend(
+    Obj(
+        MemberList(
+            ObjMembers {
+                locals: [],
+                asserts: [],
+                fields: [],
+            },
+        ),
+    ),
+    MemberList(
+        ObjMembers {
+            locals: [],
+            asserts: [],
+            fields: [
+                FieldMember {
+                    name: Fixed(
+                        "x",
+                    ) from virtual:<test>:5-6,
+                    plus: false,
+                    params: None,
+                    visibility: Normal,
+                    value: Num(
+                        1.0,
+                    ),
+                },
+            ],
+        },
+    ),
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__object.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__object.snap
@@ -0,0 +1,47 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+Obj(
+    MemberList(
+        ObjMembers {
+            locals: [],
+            asserts: [],
+            fields: [
+                FieldMember {
+                    name: Fixed(
+                        "a",
+                    ) from virtual:<test>:1-2,
+                    plus: false,
+                    params: None,
+                    visibility: Normal,
+                    value: Num(
+                        1.0,
+                    ),
+                },
+                FieldMember {
+                    name: Fixed(
+                        "b",
+                    ) from virtual:<test>:7-8,
+                    plus: false,
+                    params: None,
+                    visibility: Hidden,
+                    value: Num(
+                        2.0,
+                    ),
+                },
+                FieldMember {
+                    name: Fixed(
+                        "c",
+                    ) from virtual:<test>:14-15,
+                    plus: false,
+                    params: None,
+                    visibility: Unhide,
+                    value: Num(
+                        3.0,
+                    ),
+                },
+            ],
+        },
+    ),
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@array_comp.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@array_comp.jsonnet.snap
@@ -0,0 +1,82 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/array_comp.jsonnet
+---
+Arr(
+    [
+        ArrComp(
+            Apply(
+                Index {
+                    indexable: Var(
+                        "std" from virtual:<test>:3-6,
+                    ),
+                    parts: [
+                        IndexPart {
+                            span: virtual:<test>:7-15,
+                            value: Str(
+                                "deepJoin",
+                            ),
+                        },
+                    ],
+                },
+                ArgsDesc {
+                    unnamed: [
+                        Var(
+                            "x" from virtual:<test>:16-17,
+                        ),
+                    ],
+                    named: [],
+                } from virtual:<test>:15-18,
+                false,
+            ),
+            [
+                ForSpec(
+                    ForSpecData {
+                        destruct: Full(
+                            "x",
+                        ),
+                        over: Var(
+                            "arr" from virtual:<test>:28-31,
+                        ),
+                    },
+                ),
+            ],
+        ),
+        ArrComp(
+            Var(
+                "a" from virtual:<test>:35-36,
+            ),
+            [
+                ForSpec(
+                    ForSpecData {
+                        destruct: Full(
+                            "a",
+                        ),
+                        over: Var(
+                            "b" from virtual:<test>:46-47,
+                        ),
+                    },
+                ),
+                IfSpec(
+                    IfSpecData {
+                        span: virtual:<test>:48-50,
+                        cond: Var(
+                            "c" from virtual:<test>:51-52,
+                        ),
+                    },
+                ),
+                ForSpec(
+                    ForSpecData {
+                        destruct: Full(
+                            "e",
+                        ),
+                        over: Var(
+                            "f" from virtual:<test>:62-63,
+                        ),
+                    },
+                ),
+            ],
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@basic_math.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@basic_math.jsonnet.snap
@@ -0,0 +1,120 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/basic_math.jsonnet
+---
+Arr(
+    [
+        BinaryOp(
+            BinaryOp {
+                lhs: Num(
+                    2.0,
+                ),
+                op: Add,
+                rhs: BinaryOp(
+                    BinaryOp {
+                        lhs: Num(
+                            2.0,
+                        ),
+                        op: Mul,
+                        rhs: Num(
+                            2.0,
+                        ),
+                    },
+                ),
+            },
+        ),
+        BinaryOp(
+            BinaryOp {
+                lhs: Num(
+                    2.0,
+                ),
+                op: Add,
+                rhs: BinaryOp(
+                    BinaryOp {
+                        lhs: Num(
+                            2.0,
+                        ),
+                        op: Mul,
+                        rhs: Num(
+                            2.0,
+                        ),
+                    },
+                ),
+            },
+        ),
+        BinaryOp(
+            BinaryOp {
+                lhs: BinaryOp(
+                    BinaryOp {
+                        lhs: Num(
+                            2.0,
+                        ),
+                        op: Add,
+                        rhs: Num(
+                            2.0,
+                        ),
+                    },
+                ),
+                op: Add,
+                rhs: BinaryOp(
+                    BinaryOp {
+                        lhs: Num(
+                            2.0,
+                        ),
+                        op: Mul,
+                        rhs: Num(
+                            2.0,
+                        ),
+                    },
+                ),
+            },
+        ),
+        BinaryOp(
+            BinaryOp {
+                lhs: Num(
+                    2.0,
+                ),
+                op: Add,
+                rhs: BinaryOp(
+                    BinaryOp {
+                        lhs: Num(
+                            2.0,
+                        ),
+                        op: Add,
+                        rhs: BinaryOp(
+                            BinaryOp {
+                                lhs: Num(
+                                    2.0,
+                                ),
+                                op: Mul,
+                                rhs: Num(
+                                    2.0,
+                                ),
+                            },
+                        ),
+                    },
+                ),
+            },
+        ),
+        BinaryOp(
+            BinaryOp {
+                lhs: Num(
+                    2.0,
+                ),
+                op: Add,
+                rhs: BinaryOp(
+                    BinaryOp {
+                        lhs: Num(
+                            3.0,
+                        ),
+                        op: Mul,
+                        rhs: Num(
+                            4.0,
+                        ),
+                    },
+                ),
+            },
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@comment_eof.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@comment_eof.jsonnet.snap
@@ -0,0 +1,26 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/comment_eof.jsonnet
+---
+Obj(
+    MemberList(
+        ObjMembers {
+            locals: [],
+            asserts: [],
+            fields: [
+                FieldMember {
+                    name: Fixed(
+                        "a",
+                    ) from virtual:<test>:1-2,
+                    plus: false,
+                    params: None,
+                    visibility: Normal,
+                    value: Num(
+                        1.0,
+                    ),
+                },
+            ],
+        },
+    ),
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@default_nondefault.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@default_nondefault.jsonnet.snap
@@ -0,0 +1,55 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/default_nondefault.jsonnet
+---
+LocalExpr(
+    [
+        Function {
+            name: "x",
+            params: ExprParams {
+                exprs: [
+                    ExprParam {
+                        destruct: Full(
+                            "foo",
+                        ),
+                        default: Some(
+                            Str(
+                                "foo",
+                            ),
+                        ),
+                    },
+                    ExprParam {
+                        destruct: Full(
+                            "bar",
+                        ),
+                        default: None,
+                    },
+                ],
+                signature: FunctionSignature(
+                    [
+                        ParamParse {
+                            name: Named(
+                                "foo",
+                            ),
+                            default: Exists,
+                        },
+                        ParamParse {
+                            name: Named(
+                                "bar",
+                            ),
+                            default: None,
+                        },
+                    ],
+                ),
+                binds_len: 2,
+            },
+            value: Literal(
+                Null,
+            ),
+        },
+    ],
+    Literal(
+        Null,
+    ),
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@empty_object.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@empty_object.jsonnet.snap
@@ -0,0 +1,14 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/empty_object.jsonnet
+---
+Obj(
+    MemberList(
+        ObjMembers {
+            locals: [],
+            asserts: [],
+            fields: [],
+        },
+    ),
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@imports.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@imports.jsonnet.snap
@@ -0,0 +1,27 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/imports.jsonnet
+---
+Arr(
+    [
+        Import(
+            Normal from virtual:<test>:2-8,
+            Str(
+                "hello",
+            ),
+        ),
+        Import(
+            Str from virtual:<test>:18-27,
+            Str(
+                "garnish.txt",
+            ),
+        ),
+        Import(
+            Bin from virtual:<test>:43-52,
+            Str(
+                "garnish.bin",
+            ),
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@infix.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@infix.jsonnet.snap
@@ -0,0 +1,52 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/infix.jsonnet
+---
+Arr(
+    [
+        BinaryOp(
+            BinaryOp {
+                lhs: UnaryOp(
+                    Not,
+                    Var(
+                        "a" from virtual:<test>:3-4,
+                    ),
+                ),
+                op: And,
+                rhs: UnaryOp(
+                    Not,
+                    Var(
+                        "b" from virtual:<test>:9-10,
+                    ),
+                ),
+            },
+        ),
+        UnaryOp(
+            Not,
+            BinaryOp(
+                BinaryOp {
+                    lhs: Var(
+                        "a" from virtual:<test>:13-14,
+                    ),
+                    op: Div,
+                    rhs: UnaryOp(
+                        Not,
+                        Var(
+                            "b" from virtual:<test>:18-19,
+                        ),
+                    ),
+                },
+            ),
+        ),
+        UnaryOp(
+            Not,
+            UnaryOp(
+                Not,
+                Var(
+                    "a" from virtual:<test>:23-24,
+                ),
+            ),
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@multiline.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@multiline.jsonnet.snap
@@ -0,0 +1,21 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/multiline.jsonnet
+---
+Arr(
+    [
+        Str(
+            "Hello world!\na\n",
+        ),
+        Str(
+            "Hello world!\na\n",
+        ),
+        Str(
+            "Hello world!\n\ta\n",
+        ),
+        Str(
+            "Hello world!\n a\n",
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@reserved.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@reserved.jsonnet.snap
@@ -0,0 +1,32 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/reserved.jsonnet
+---
+Arr(
+    [
+        Literal(
+            Null,
+        ),
+        Var(
+            "nulla" from virtual:<test>:8-13,
+        ),
+        Apply(
+            Var(
+                "a" from virtual:<test>:15-16,
+            ),
+            ArgsDesc {
+                unnamed: [
+                    Var(
+                        "b" from virtual:<test>:17-18,
+                    ),
+                    Var(
+                        "null_fields" from virtual:<test>:20-31,
+                    ),
+                ],
+                named: [],
+            } from virtual:<test>:16-32,
+            false,
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@slice.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@slice.jsonnet.snap
@@ -0,0 +1,97 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/slice.jsonnet
+---
+Arr(
+    [
+        Slice(
+            Slice {
+                value: Var(
+                    "a" from virtual:<test>:2-3,
+                ),
+                slice: SliceDesc {
+                    start: Some(
+                        Num(
+                            1.0,
+                        ) from virtual:<test>:4-5,
+                    ),
+                    end: None,
+                    step: None,
+                },
+            },
+        ),
+        Slice(
+            Slice {
+                value: Var(
+                    "a" from virtual:<test>:9-10,
+                ),
+                slice: SliceDesc {
+                    start: Some(
+                        Num(
+                            1.0,
+                        ) from virtual:<test>:11-12,
+                    ),
+                    end: None,
+                    step: None,
+                },
+            },
+        ),
+        Slice(
+            Slice {
+                value: Var(
+                    "a" from virtual:<test>:17-18,
+                ),
+                slice: SliceDesc {
+                    start: None,
+                    end: Some(
+                        Num(
+                            1.0,
+                        ) from virtual:<test>:20-21,
+                    ),
+                    step: None,
+                },
+            },
+        ),
+        Slice(
+            Slice {
+                value: Var(
+                    "a" from virtual:<test>:25-26,
+                ),
+                slice: SliceDesc {
+                    start: None,
+                    end: None,
+                    step: Some(
+                        Num(
+                            1.0,
+                        ) from virtual:<test>:29-30,
+                    ),
+                },
+            },
+        ),
+        Slice(
+            Slice {
+                value: Var(
+                    "str" from virtual:<test>:33-36,
+                ),
+                slice: SliceDesc {
+                    start: None,
+                    end: Some(
+                        BinaryOp(
+                            BinaryOp {
+                                lhs: Var(
+                                    "len" from virtual:<test>:38-41,
+                                ),
+                                op: Sub,
+                                rhs: Num(
+                                    1.0,
+                                ),
+                            },
+                        ) from virtual:<test>:38-45,
+                    ),
+                    step: None,
+                },
+            },
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@string_escaping.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@string_escaping.jsonnet.snap
@@ -0,0 +1,24 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/string_escaping.jsonnet
+---
+Arr(
+    [
+        Str(
+            "Hello, \"world\"!",
+        ),
+        Str(
+            "Hello 'world'!",
+        ),
+        Str(
+            "\\\\",
+        ),
+        Str(
+            "Hello\nWorld",
+        ),
+        Str(
+            "Hello\\n\"World\"",
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@subexp.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@subexp.jsonnet.snap
@@ -0,0 +1,58 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/subexp.jsonnet
+---
+BinaryOp(
+    BinaryOp {
+        lhs: ObjExtend(
+            Obj(
+                MemberList(
+                    ObjMembers {
+                        locals: [],
+                        asserts: [],
+                        fields: [],
+                    },
+                ),
+            ),
+            MemberList(
+                ObjMembers {
+                    locals: [
+                        Field {
+                            into: Full(
+                                "x",
+                            ),
+                            value: Num(
+                                1.0,
+                            ),
+                        },
+                    ],
+                    asserts: [],
+                    fields: [
+                        FieldMember {
+                            name: Fixed(
+                                "x",
+                            ) from virtual:<test>:18-19,
+                            plus: false,
+                            params: None,
+                            visibility: Normal,
+                            value: Var(
+                                "x" from virtual:<test>:21-22,
+                            ),
+                        },
+                    ],
+                },
+            ),
+        ),
+        op: Add,
+        rhs: Obj(
+            MemberList(
+                ObjMembers {
+                    locals: [],
+                    asserts: [],
+                    fields: [],
+                },
+            ),
+        ),
+    },
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@suffix.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@suffix.jsonnet.snap
@@ -0,0 +1,73 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/suffix.jsonnet
+---
+Arr(
+    [
+        Index {
+            indexable: Var(
+                "std" from virtual:<test>:2-5,
+            ),
+            parts: [
+                IndexPart {
+                    span: virtual:<test>:6-10,
+                    value: Str(
+                        "test",
+                    ),
+                },
+            ],
+        },
+        Apply(
+            Var(
+                "std" from virtual:<test>:12-15,
+            ),
+            ArgsDesc {
+                unnamed: [
+                    Num(
+                        2.0,
+                    ),
+                ],
+                named: [],
+            } from virtual:<test>:15-18,
+            false,
+        ),
+        Apply(
+            Index {
+                indexable: Var(
+                    "std" from virtual:<test>:20-23,
+                ),
+                parts: [
+                    IndexPart {
+                        span: virtual:<test>:24-28,
+                        value: Str(
+                            "test",
+                        ),
+                    },
+                ],
+            },
+            ArgsDesc {
+                unnamed: [
+                    Num(
+                        2.0,
+                    ),
+                ],
+                named: [],
+            } from virtual:<test>:28-31,
+            false,
+        ),
+        Index {
+            indexable: Var(
+                "a" from virtual:<test>:33-34,
+            ),
+            parts: [
+                IndexPart {
+                    span: virtual:<test>:35-36,
+                    value: Var(
+                        "b" from virtual:<test>:35-36,
+                    ),
+                },
+            ],
+        },
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__slice.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__slice.snap
@@ -0,0 +1,72 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+Arr(
+    [
+        Slice(
+            Slice {
+                value: Var(
+                    "a" from virtual:<test>:1-2,
+                ),
+                slice: SliceDesc {
+                    start: Some(
+                        Num(
+                            1.0,
+                        ) from virtual:<test>:3-4,
+                    ),
+                    end: None,
+                    step: None,
+                },
+            },
+        ),
+        Slice(
+            Slice {
+                value: Var(
+                    "a" from virtual:<test>:8-9,
+                ),
+                slice: SliceDesc {
+                    start: Some(
+                        Num(
+                            1.0,
+                        ) from virtual:<test>:10-11,
+                    ),
+                    end: None,
+                    step: None,
+                },
+            },
+        ),
+        Slice(
+            Slice {
+                value: Var(
+                    "a" from virtual:<test>:16-17,
+                ),
+                slice: SliceDesc {
+                    start: None,
+                    end: Some(
+                        Num(
+                            1.0,
+                        ) from virtual:<test>:19-20,
+                    ),
+                    step: None,
+                },
+            },
+        ),
+        Slice(
+            Slice {
+                value: Var(
+                    "a" from virtual:<test>:24-25,
+                ),
+                slice: SliceDesc {
+                    start: None,
+                    end: None,
+                    step: Some(
+                        Num(
+                            1.0,
+                        ) from virtual:<test>:28-29,
+                    ),
+                },
+            },
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__strings.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__strings.snap
@@ -0,0 +1,20 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+Arr(
+    [
+        Str(
+            "hello",
+        ),
+        Str(
+            "world",
+        ),
+        Str(
+            "raw\"str",
+        ),
+        Str(
+            "raw'str",
+        ),
+    ],
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__unary_ops.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__unary_ops.snap
@@ -0,0 +1,21 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+BinaryOp(
+    BinaryOp {
+        lhs: UnaryOp(
+            Not,
+            Var(
+                "a" from virtual:<test>:1-2,
+            ),
+        ),
+        op: And,
+        rhs: UnaryOp(
+            Not,
+            Var(
+                "b" from virtual:<test>:7-8,
+            ),
+        ),
+    },
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__underscore_numbers.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__underscore_numbers.snap
@@ -0,0 +1,17 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+expression: "format!(\"{v:#?}\")"
+---
+Arr(
+    [
+        Num(
+            1000.0,
+        ),
+        Num(
+            1000.0001,
+        ),
+        Num(
+            100000000000.0,
+        ),
+    ],
+)
modifiedtests/Cargo.tomldiffbeforeafterboth
--- a/tests/Cargo.toml
+++ b/tests/Cargo.toml
@@ -4,6 +4,11 @@
 edition = "2024"
 publish = false
 
+[features]
+default = ["ir-parser"]
+ir-parser = ["jrsonnet-evaluator/ir-parser"]
+exp-null-coaelse = ["jrsonnet-evaluator/exp-null-coaelse"]
+
 [lints]
 workspace = true
 
modifiedtests/cpp_test_suite_golden_override/error.parse.array_comma.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.array_comma.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.array_comma.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", ".", "?", "[", "]", "{", <binary op>, <comma>, got "3"
+syntax error: expected R_BRACK, got "3"
     error.parse.array_comma.jsonnet:17:7
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.function_arg_positional_after_named.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.function_arg_positional_after_named.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.function_arg_positional_after_named.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", ".", "?", "[", "{", <binary op>, <comma>, <named argument>, got ")"
-    error.parse.function_arg_positional_after_named.jsonnet:19:11
\ No newline at end of file
+syntax error: positional argument after named argument
+    error.parse.function_arg_positional_after_named.jsonnet:19:10
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.index_unterminated.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.index_unterminated.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.index_unterminated.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", ":", "[", "{", <identifier>, <number>, <string>, <unary op>, ['"'], ['\''], got "EOF"
-    error.parse.index_unterminated.jsonnet:17:4
\ No newline at end of file
+syntax error: unexpected token in expression: EOF
+    error.parse.index_unterminated.jsonnet:17:3
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.method_plus.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.method_plus.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.method_plus.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of ":", "::", ":::", got "+"
+syntax error: expected COLON, got "+"
     error.parse.method_plus.jsonnet:17:18
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.object_comma.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.object_comma.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.object_comma.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", ".", "?", "[", "{", "}", <binary op>, got "z"
+syntax error: expected R_BRACE, got "z"
     error.parse.object_comma.jsonnet:17:11
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.object_comprehension_local_clash.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.object_comprehension_local_clash.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.object_comprehension_local_clash.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", ".", "?", "[", "{", "}", <binary op>, <comma>, got ":"
+syntax error: expected R_BRACE, got ":"
     error.parse.object_comprehension_local_clash.jsonnet:17:29
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.self_in_computed_field.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.self_in_computed_field.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.self_in_computed_field.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "[", "}", <identifier>, <string>, ['"'], ['\''], got "s"
+syntax error: expected field name, got SELF_KW
     error.parse.self_in_computed_field.jsonnet:17:15
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.static_error_bad_number.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.static_error_bad_number.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.static_error_bad_number.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", "[", "{", <identifier>, <number>, <string>, <unary op>, ['"'], ['\''], got "."
+syntax error: unexpected token in expression: DOT
     error.parse.static_error_bad_number.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.string.invalid_escape.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected <escape character>, got "o"
-    error.parse.string.invalid_escape.jsonnet:17:3
\ No newline at end of file
+syntax error: invalid string escape
+    error.parse.string.invalid_escape.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_non_hex.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_non_hex.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_non_hex.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected <hex char>, got "t"
-    error.parse.string.invalid_escape_unicode_non_hex.jsonnet:17:7
\ No newline at end of file
+syntax error: invalid string escape
+    error.parse.string.invalid_escape_unicode_non_hex.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected <hex char>, got "\n"
-    error.parse.string.invalid_escape_unicode_short.jsonnet:17:7
\ No newline at end of file
+syntax error: unexpected token: ERROR_STRING_DOUBLE_UNTERMINATED
+    error.parse.string.invalid_escape_unicode_short.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short2.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short2.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short2.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected <hex char>, got "\""
-    error.parse.string.invalid_escape_unicode_short2.jsonnet:17:7
\ No newline at end of file
+syntax error: invalid string escape
+    error.parse.string.invalid_escape_unicode_short2.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short3.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short3.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.string.invalid_escape_unicode_short3.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected <hex char>, got "\n"
-    error.parse.string.invalid_escape_unicode_short3.jsonnet:17:7
\ No newline at end of file
+syntax error: unexpected token: ERROR_STRING_DOUBLE_UNTERMINATED
+    error.parse.string.invalid_escape_unicode_short3.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.string.unfinished.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.string.unfinished.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.string.unfinished.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "\\\\", "\\u", "\\x", ['"'], ['\\'], [_], got "EOF"
-    error.parse.string.unfinished.jsonnet:17:3
\ No newline at end of file
+syntax error: unexpected token: ERROR_STRING_DOUBLE_UNTERMINATED
+    error.parse.string.unfinished.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.string.unfinished2.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.string.unfinished2.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.string.unfinished2.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "\\\\", "\\u", "\\x", ['\''], ['\\'], [_], got "EOF"
-    error.parse.string.unfinished2.jsonnet:17:3
\ No newline at end of file
+syntax error: unexpected token: ERROR_STRING_SINGLE_UNTERMINATED
+    error.parse.string.unfinished2.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.string_multi_no_newline.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.string_multi_no_newline.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.string_multi_no_newline.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", "[", "{", <identifier>, <number>, <string>, <unary op>, ['"'], ['\''], got "|"
+syntax error: unexpected token: ERROR_STRING_BLOCK_MISSING_NEW_LINE
     error.parse.string_multi_no_newline.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.text_block_bad_whitespace.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.text_block_bad_whitespace.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.text_block_bad_whitespace.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", "[", "{", <identifier>, <number>, <string>, <unary op>, ['"'], ['\''], got "|"
+syntax error: unexpected token: ERROR_STRING_BLOCK_MISSING_TERMINATION
     error.parse.text_block_bad_whitespace.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.text_block_eof.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.text_block_eof.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.text_block_eof.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", "[", "{", <identifier>, <number>, <string>, <unary op>, ['"'], ['\''], got "|"
+syntax error: unexpected token: ERROR_STRING_BLOCK_UNEXPECTED_END
     error.parse.text_block_eof.jsonnet:17:1
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.text_block_not_terminated.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.text_block_not_terminated.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.text_block_not_terminated.jsonnet.golden
@@ -1,2 +1,2 @@
-syntax error: expected one of "(", "[", "{", <identifier>, <number>, <string>, <unary op>, ['"'], ['\''], got "|"
+syntax error: unexpected token: ERROR_STRING_BLOCK_UNEXPECTED_END
     error.parse.text_block_not_terminated.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.import_syntax-error.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.import_syntax-error.jsonnet.golden
@@ -0,0 +1,3 @@
+syntax error: unterminated double-quoted string
+    syntax_error.jsonnet:1:1
+    error.import_syntax-error.jsonnet:1:1-8: import "lib/syntax_error.jsonnet"
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.overflow.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.overflow.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: numbers are finite
+    error.overflow.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.overflow3.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.overflow3.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: numbers are finite
+    error.overflow3.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.array_comma.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.array_comma.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: expected ']', got number "3"
+    error.parse.array_comma.jsonnet:17:7
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.index_unterminated.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.index_unterminated.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unexpected end of file
+    error.parse.index_unterminated.jsonnet:17:3
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.method_plus.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.method_plus.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: expected ':', got '+'
+    error.parse.method_plus.jsonnet:17:18
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.object_comma.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.object_comma.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: expected '}', got identifier "z"
+    error.parse.object_comma.jsonnet:17:11
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.object_comprehension_local_clash.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.object_comprehension_local_clash.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: expected '}', got ':'
+    error.parse.object_comprehension_local_clash.jsonnet:17:29
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.self_in_computed_field.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.self_in_computed_field.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: expected field name, got 'self'
+    error.parse.self_in_computed_field.jsonnet:17:15
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.static_error_bad_number.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.static_error_bad_number.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unexpected '.'
+    error.parse.static_error_bad_number.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.string.invalid_escape_unicode_short.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.string.invalid_escape_unicode_short.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unterminated double-quoted string
+    error.parse.string.invalid_escape_unicode_short.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.string.invalid_escape_unicode_short3.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.string.invalid_escape_unicode_short3.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unterminated double-quoted string
+    error.parse.string.invalid_escape_unicode_short3.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.string.unfinished.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.string.unfinished.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unterminated double-quoted string
+    error.parse.string.unfinished.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.string.unfinished2.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.string.unfinished2.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unterminated single-quoted string
+    error.parse.string.unfinished2.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.string_multi_no_newline.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.string_multi_no_newline.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: text block requires new line after |||
+    error.parse.string_multi_no_newline.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.text_block_bad_whitespace.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.text_block_bad_whitespace.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unterminated text block
+    error.parse.text_block_bad_whitespace.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.text_block_eof.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.text_block_eof.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unexpected end of text block
+    error.parse.text_block_eof.jsonnet:17:1
\ No newline at end of file
addedtests/cpp_test_suite_golden_override_ir_parser/error.parse.text_block_not_terminated.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/cpp_test_suite_golden_override_ir_parser/error.parse.text_block_not_terminated.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unexpected end of text block
+    error.parse.text_block_not_terminated.jsonnet:17:1
\ No newline at end of file
addedtests/go_testdata_golden_override_ir_parser/error_hexnumber.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/go_testdata_golden_override_ir_parser/error_hexnumber.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: expected end of file, got identifier "x42"
+    error_hexnumber.jsonnet:1:2
\ No newline at end of file
addedtests/go_testdata_golden_override_ir_parser/import_syntax_error.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/go_testdata_golden_override_ir_parser/import_syntax_error.jsonnet.golden
@@ -0,0 +1,3 @@
+syntax error: unexpected end of file
+    syntax_error.jsonnet:1:4
+    import_syntax_error.jsonnet:1:1-8: import "syntax_error.jsonnet"
\ No newline at end of file
addedtests/go_testdata_golden_override_ir_parser/object_comp_assert.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/go_testdata_golden_override_ir_parser/object_comp_assert.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: asserts are unsupported in object comprehension
+    object_comp_assert.jsonnet:1:46
\ No newline at end of file
addedtests/go_testdata_golden_override_ir_parser/object_comp_illegal.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/go_testdata_golden_override_ir_parser/object_comp_illegal.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: missing object comprehension field
+    object_comp_illegal.jsonnet:1:34
\ No newline at end of file
addedtests/go_testdata_golden_override_ir_parser/static_error_eof.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/go_testdata_golden_override_ir_parser/static_error_eof.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: expected ';', got end of file
+    static_error_eof.jsonnet:1:12
\ No newline at end of file
addedtests/go_testdata_golden_override_ir_parser/syntax_error.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/go_testdata_golden_override_ir_parser/syntax_error.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: unexpected end of file
+    syntax_error.jsonnet:1:4
\ No newline at end of file
addedtests/go_testdata_golden_override_ir_parser/unfinished_args.jsonnet.goldendiffbeforeafterboth
--- /dev/null
+++ b/tests/go_testdata_golden_override_ir_parser/unfinished_args.jsonnet.golden
@@ -0,0 +1,2 @@
+syntax error: expected ')', got end of file
+    unfinished_args.jsonnet:1:17
\ No newline at end of file
addedtests/golden/null_coalesce_chain.jsonnetdiffbeforeafterboth
--- /dev/null
+++ b/tests/golden/null_coalesce_chain.jsonnet
@@ -0,0 +1,10 @@
+// Regression test: chained index a.b.c.d should produce a single
+// Index { a, [b, c, d] } not nested Index nodes.
+// This matters for exp-null-coaelse where a?.b.c should skip .c if .b is null.
+
+local obj = { a: { b: { c: 42 } } };
+
+[
+  obj.a.b.c,
+  {a: {b: 1}}.a.b,
+]
addedtests/golden_null_coalesce/null_coalesce_access.jsonnetdiffbeforeafterboth
--- /dev/null
+++ b/tests/golden_null_coalesce/null_coalesce_access.jsonnet
@@ -0,0 +1,18 @@
+// Test null-coalesce chained access: a?.b.c should return null when b is missing,
+// not fail with "field c not found on null".
+
+local obj = { a: { b: { c: 42 } } };
+
+[
+  // null-coalesce on missing field should return null, not error
+  obj?.missing.b.c,
+
+  // null-coalesce on present field continues
+  obj?.a.b.c,
+
+  // null-coalesce with bracket index
+  obj?.["missing"].b.c,
+
+  // chained null-coalesce
+  obj?.a?.missing.c,
+]
modifiedtests/tests/cpp_test_suite.rsdiffbeforeafterboth
--- a/tests/tests/cpp_test_suite.rs
+++ b/tests/tests/cpp_test_suite.rs
@@ -241,9 +241,26 @@
 			golden = Some(golden_path);
 		}
 
+		// ir-parser has its own override layer
+		#[cfg(feature = "ir-parser")]
+		let ir_parser_override_path = {
+			let p = root_tests
+				.join(format!("{root_dir}_golden_override_ir_parser"))
+				.join(golden_path.file_name().expect("file has basename"));
+			if let Some(golden_path) = read_file(&p)? {
+				golden = Some(golden_path);
+			}
+			p
+		};
+
 		// Otherwise assume test should just not fail and return true.
 		let golden = golden.unwrap_or_else(|| "true".to_owned());
 
+		#[cfg(feature = "ir-parser")]
+		let update_golden_path = &ir_parser_override_path;
+		#[cfg(not(feature = "ir-parser"))]
+		let update_golden_path = &golden_override;
+
 		match (serde_json::from_str(&result), serde_json::from_str(&golden)) {
 			(Err(_), Ok(_)) => panic!(
 				"unexpected error for golden {}:\n<got>\n{result}\n</got>\n<golden>\n{golden}\n</golden>",
@@ -258,7 +275,7 @@
 				let diff = JsonDiff::diff_string(&golden, &result_v, false);
 				if let Some(diff) = diff {
 					if env::var_os("UPDATE_GOLDEN").is_some() {
-						fs::write(golden_override, result)?;
+						fs::write(update_golden_path, result)?;
 					} else {
 						panic!(
 							"Result \n{result_v:#}\n\
@@ -273,7 +290,7 @@
 			(Err(_), Err(_)) => {
 				if result != golden.trim_end() {
 					if env::var_os("UPDATE_GOLDEN").is_some() {
-						fs::write(golden_override, result)?;
+						fs::write(update_golden_path, result)?;
 					} else {
 						panic!(
 						"golden didn't match for {}:\n<got>\n{result}\n</got>\n<golden>\n{golden}\n</golden>",
modifiedtests/tests/golden.rsdiffbeforeafterboth
--- a/tests/tests/golden.rs
+++ b/tests/tests/golden.rs
@@ -45,3 +45,13 @@
 		assert_snapshot!(result);
 	});
 }
+
+#[test]
+#[cfg(feature = "exp-null-coaelse")]
+fn golden_null_coalesce() {
+	glob!("../", "golden_null_coalesce/*.jsonnet", |path| {
+		let result = run(path);
+
+		assert_snapshot!(result);
+	});
+}
addedtests/tests/snapshots/golden__golden@null_coalesce_chain.jsonnet.snapdiffbeforeafterboth
--- /dev/null
+++ b/tests/tests/snapshots/golden__golden@null_coalesce_chain.jsonnet.snap
@@ -0,0 +1,9 @@
+---
+source: tests/tests/golden.rs
+expression: result
+input_file: tests/golden/null_coalesce_chain.jsonnet
+---
+[
+    42,
+    1
+]
addedtests/tests/snapshots/golden__golden_null_coalesce.snapdiffbeforeafterboth
--- /dev/null
+++ b/tests/tests/snapshots/golden__golden_null_coalesce.snap
@@ -0,0 +1,11 @@
+---
+source: tests/tests/golden.rs
+expression: result
+input_file: tests/golden_null_coalesce/null_coalesce_access.jsonnet
+---
+[
+    null,
+    42,
+    null,
+    null
+]