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
before · crates/jrsonnet-evaluator/src/lib.rs
1//! jsonnet interpreter implementation2#![cfg_attr(nightly, feature(thread_local, type_alias_impl_trait))]34// For jrsonnet-macros5extern crate self as jrsonnet_evaluator;67mod arr;8// pub mod async_import;9mod ctx;10mod dynamic;11pub mod error;12mod evaluate;13pub mod function;14pub mod gc;15mod import;16mod integrations;17pub mod manifest;18mod map;19mod obj;20pub mod stack;21pub mod stdlib;22pub mod tla;23pub mod trace;24pub mod typed;25pub mod val;2627use std::{28	any::Any,29	cell::{RefCell, RefMut},30	clone::Clone,31	collections::hash_map::Entry,32	fmt::{self, Debug},33	marker::PhantomData,34	rc::Rc,35};3637pub use ctx::*;38pub use dynamic::*;39pub use error::{Error, ErrorKind::*, Result, ResultExt};40pub use evaluate::*;41use function::CallLocation;42pub use import::*;43use jrsonnet_gcmodule::{cc_dyn, Cc, Trace};44pub use jrsonnet_interner::{IBytes, IStr};45pub use jrsonnet_ir as parser;46use jrsonnet_ir::{Expr, Source, SourcePath};47#[doc(hidden)]48pub use jrsonnet_macros;49use jrsonnet_peg_parser::ParserSettings;50pub use obj::*;51pub use rustc_hash;52use rustc_hash::FxHashMap;53use stack::check_depth;54pub use tla::apply_tla;55pub use val::{Thunk, Val};5657use crate::gc::WithCapacityExt as _;5859cc_dyn!(60	#[derive(Clone)]61	CcUnbound<V>,62	Unbound<Bound = V>63);6465/// Thunk without bound `super`/`this`66/// object inheritance may be overriden multiple times, and will be fixed only on field read67pub trait Unbound: Trace {68	/// Type of value after object context is bound69	type Bound;70	/// Create value bound to specified object context71	fn bind(&self, sup_this: SupThis) -> Result<Self::Bound>;72}7374/// Object fields may, or may not depend on `this`/`super`, this enum allows cheaper reuse of object-independent fields for native code75/// Standard jsonnet fields are always unbound76#[derive(Clone, Trace)]77pub enum MaybeUnbound {78	/// Value needs to be bound to `this`/`super`79	Unbound(CcUnbound<Val>),80	/// Value is object-independent81	Bound(Thunk<Val>),82}8384impl Debug for MaybeUnbound {85	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {86		write!(f, "MaybeUnbound")87	}88}89impl MaybeUnbound {90	/// Attach object context to value, if required91	pub fn evaluate(&self, sup_this: SupThis) -> Result<Val> {92		match self {93			Self::Unbound(v) => v.0.bind(sup_this),94			Self::Bound(v) => Ok(v.evaluate()?),95		}96	}97}9899cc_dyn!(CcContextInitializer, ContextInitializer);100101/// During import, this trait will be called to create initial context for file.102/// It may initialize global variables, stdlib for example.103pub trait ContextInitializer: Trace {104	/// For which size the builder should be preallocated105	fn reserve_vars(&self) -> usize {106		0107	}108	/// Initialize default file context.109	/// Has default implementation, which calls `populate`.110	/// Prefer to always implement `populate` instead.111	fn initialize(&self, for_file: Source) -> Context {112		let mut builder = ContextBuilder::with_capacity(self.reserve_vars());113		self.populate(for_file, &mut builder);114		builder.build()115	}116	/// For composability: extend builder. May panic if this initialization is not supported,117	/// and the context may only be created via `initialize`.118	fn populate(&self, for_file: Source, builder: &mut ContextBuilder);119	/// Allows upcasting from abstract to concrete context initializer.120	/// jrsonnet by itself doesn't use this method, it is allowed for it to panic.121	fn as_any(&self) -> &dyn Any;122}123124/// Context initializer which adds nothing.125impl ContextInitializer for () {126	fn populate(&self, _for_file: Source, _builder: &mut ContextBuilder) {}127	fn as_any(&self) -> &dyn Any {128		self129	}130}131132impl<T> ContextInitializer for Option<T>133where134	T: ContextInitializer,135{136	fn initialize(&self, for_file: Source) -> Context {137		if let Some(ctx) = self {138			ctx.initialize(for_file)139		} else {140			().initialize(for_file)141		}142	}143144	fn populate(&self, for_file: Source, builder: &mut ContextBuilder) {145		if let Some(ctx) = self {146			ctx.populate(for_file, builder);147		}148	}149150	fn as_any(&self) -> &dyn Any {151		self152	}153}154155macro_rules! impl_context_initializer {156	($($gen:ident)*) => {157		#[allow(non_snake_case)]158		impl<$($gen: ContextInitializer + Trace,)*> ContextInitializer for ($($gen,)*) {159			fn reserve_vars(&self) -> usize {160				let mut out = 0;161				let ($($gen,)*) = self;162				$(out += $gen.reserve_vars();)*163				out164			}165			fn populate(&self, for_file: Source, builder: &mut ContextBuilder) {166				let ($($gen,)*) = self;167				$($gen.populate(for_file.clone(), builder);)*168			}169			fn as_any(&self) -> &dyn Any {170				self171			}172		}173	};174	($($cur:ident)* @ $c:ident $($rest:ident)*) => {175		impl_context_initializer!($($cur)*);176		impl_context_initializer!($($cur)* $c @ $($rest)*);177	};178	($($cur:ident)* @) => {179		impl_context_initializer!($($cur)*);180	}181}182impl_context_initializer! {183	A @ B C D E F G184}185186#[derive(Trace)]187struct FileData {188	string: Option<IStr>,189	bytes: Option<IBytes>,190	parsed: Option<Rc<Expr>>,191	evaluated: Option<Val>,192193	evaluating: bool,194}195impl FileData {196	fn new_string(data: IStr) -> Self {197		Self {198			string: Some(data),199			bytes: None,200			parsed: None,201			evaluated: None,202			evaluating: false,203		}204	}205	fn new_bytes(data: IBytes) -> Self {206		Self {207			string: None,208			bytes: Some(data),209			parsed: None,210			evaluated: None,211			evaluating: false,212		}213	}214	pub(crate) fn get_string(&mut self) -> Option<IStr> {215		if self.string.is_none() {216			self.string = Some(217				self.bytes218					.as_ref()219					.expect("either string or bytes should be set")220					.clone()221					.cast_str()?,222			);223		}224		Some(self.string.clone().expect("just set"))225	}226}227228#[derive(Trace)]229pub struct EvaluationStateInternals {230	/// Internal state231	file_cache: RefCell<FxHashMap<SourcePath, FileData>>,232	/// Context initializer, which will be used for imports and everything233	/// [`NoopContextInitializer`] is used by default, most likely you want to have `jrsonnet-stdlib`234	context_initializer: CcContextInitializer,235	/// Used to resolve file locations/contents236	import_resolver: Rc<dyn ImportResolver>,237}238239/// Maintains stack trace and import resolution240#[derive(Clone, Trace)]241pub struct State(Cc<EvaluationStateInternals>);242243thread_local! {244	pub static DEFAULT_STATE: State = State::builder().build();245	pub static STATE: RefCell<Option<State>> = const {RefCell::new(None)};246}247pub struct StateEnterGuard(PhantomData<()>);248impl Drop for StateEnterGuard {249	fn drop(&mut self) {250		STATE.with_borrow_mut(|v| *v = None);251	}252}253254pub fn with_state<V>(v: impl FnOnce(State) -> V) -> V {255	if let Some(state) = STATE.with_borrow(Clone::clone) {256		v(state)257	} else {258		let s = DEFAULT_STATE.with(Clone::clone);259		v(s)260	}261}262263impl State {264	pub fn enter(&self) -> StateEnterGuard {265		self.try_enter().expect("entered state already exists")266	}267	pub fn try_enter(&self) -> Option<StateEnterGuard> {268		STATE.with_borrow_mut(|v| {269			if v.is_none() {270				*v = Some(self.clone());271				Some(StateEnterGuard(PhantomData))272			} else {273				None274			}275		})276	}277	/// Should only be called with path retrieved from [`resolve_path`], may panic otherwise278	pub fn import_resolved_str(&self, path: SourcePath) -> Result<IStr> {279		let mut file_cache = self.file_cache();280		let mut file = file_cache.entry(path.clone());281282		let file = match file {283			Entry::Occupied(ref mut d) => d.get_mut(),284			Entry::Vacant(v) => {285				let data = self.import_resolver().load_file_contents(&path)?;286				v.insert(FileData::new_string(287					std::str::from_utf8(&data)288						.map_err(|_| ImportBadFileUtf8(path.clone()))?289						.into(),290				))291			}292		};293		Ok(file294			.get_string()295			.ok_or_else(|| ImportBadFileUtf8(path.clone()))?)296	}297	/// Should only be called with path retrieved from [`resolve_path`], may panic otherwise298	pub fn import_resolved_bin(&self, path: SourcePath) -> Result<IBytes> {299		let mut file_cache = self.file_cache();300		let mut file = file_cache.entry(path.clone());301302		let file = match file {303			Entry::Occupied(ref mut d) => d.get_mut(),304			Entry::Vacant(v) => {305				let data = self.import_resolver().load_file_contents(&path)?;306				v.insert(FileData::new_bytes(data.as_slice().into()))307			}308		};309		if let Some(str) = &file.bytes {310			return Ok(str.clone());311		}312		if file.bytes.is_none() {313			file.bytes = Some(314				file.string315					.as_ref()316					.expect("either string or bytes should be set")317					.clone()318					.cast_bytes(),319			);320		}321		Ok(file.bytes.as_ref().expect("just set").clone())322	}323	/// Should only be called with path retrieved from [`resolve_path`], may panic otherwise324	pub fn import_resolved(&self, path: SourcePath) -> Result<Val> {325		let mut file_cache = self.file_cache();326		let mut file = file_cache.entry(path.clone());327328		let file = match file {329			Entry::Occupied(ref mut d) => d.get_mut(),330			Entry::Vacant(v) => {331				let data = self.import_resolver().load_file_contents(&path)?;332				v.insert(FileData::new_string(333					std::str::from_utf8(&data)334						.map_err(|_| ImportBadFileUtf8(path.clone()))?335						.into(),336				))337			}338		};339		if let Some(val) = &file.evaluated {340			return Ok(val.clone());341		}342		let code = file343			.get_string()344			.ok_or_else(|| ImportBadFileUtf8(path.clone()))?;345		let file_name = Source::new(path.clone(), code.clone());346		if file.parsed.is_none() {347			file.parsed = Some(348				jrsonnet_peg_parser::parse(349					&code,350					&ParserSettings {351						source: file_name.clone(),352					},353				)354				.map(Rc::new)355				.map_err(|e| ImportSyntaxError {356					path: file_name.clone(),357					error: Box::new(e),358				})?,359			);360		}361		let parsed = file.parsed.as_ref().expect("just set").clone();362		if file.evaluating {363			bail!(InfiniteRecursionDetected)364		}365		file.evaluating = true;366		// Dropping file cache guard here, as evaluation may use this map too367		drop(file_cache);368		let res = evaluate(self.create_default_context(file_name), &parsed);369370		let mut file_cache = self.file_cache();371		let mut file = file_cache.entry(path);372373		let Entry::Occupied(file) = &mut file else {374			unreachable!("this file was just here")375		};376		let file = file.get_mut();377		file.evaluating = false;378		match res {379			Ok(v) => {380				file.evaluated = Some(v.clone());381				Ok(v)382			}383			Err(e) => Err(e),384		}385	}386387	/// Has same semantics as `import 'path'` called from `from` file388	pub fn import_from(&self, from: &SourcePath, path: impl AsPathLike) -> Result<Val> {389		let resolved = self.resolve_from(from, &path)?;390		self.import_resolved(resolved)391	}392	pub fn import(&self, path: impl AsPathLike) -> Result<Val> {393		let resolved = self.resolve_from_default(&path)?;394		self.import_resolved(resolved)395	}396397	/// Creates context with all passed global variables398	pub fn create_default_context(&self, source: Source) -> Context {399		self.context_initializer().initialize(source)400	}401402	/// Creates context with all passed global variables, calling custom modifier403	pub fn create_default_context_with(404		&self,405		source: Source,406		context_initializer: impl ContextInitializer,407	) -> Context {408		let default_initializer = self.context_initializer();409		let mut builder = ContextBuilder::with_capacity(410			default_initializer.reserve_vars() + context_initializer.reserve_vars(),411		);412		default_initializer.populate(source.clone(), &mut builder);413		context_initializer.populate(source, &mut builder);414415		builder.build()416	}417}418419/// Internals420impl State {421	fn file_cache(&self) -> RefMut<'_, FxHashMap<SourcePath, FileData>> {422		self.0.file_cache.borrow_mut()423	}424}425/// Executes code creating a new stack frame, to be replaced with try{}426pub fn in_frame<T>(427	e: CallLocation<'_>,428	frame_desc: impl FnOnce() -> String,429	f: impl FnOnce() -> Result<T>,430) -> Result<T> {431	let _guard = check_depth()?;432433	f().with_description_src(e, frame_desc)434}435436/// Executes code creating a new stack frame, to be replaced with try{}437pub fn in_description_frame<T>(438	frame_desc: impl FnOnce() -> String,439	f: impl FnOnce() -> Result<T>,440) -> Result<T> {441	let _guard = check_depth()?;442443	f().with_description(frame_desc)444}445446#[derive(Trace)]447pub struct InitialUnderscore(pub Thunk<Val>);448impl ContextInitializer for InitialUnderscore {449	fn populate(&self, _for_file: Source, builder: &mut ContextBuilder) {450		builder.bind("_", self.0.clone());451	}452453	fn as_any(&self) -> &dyn Any {454		self455	}456}457458/// Raw methods evaluate passed values but don't perform TLA execution459impl State {460	/// Parses and evaluates the given snippet461	pub fn evaluate_snippet(&self, name: impl Into<IStr>, code: impl Into<IStr>) -> Result<Val> {462		let code = code.into();463		let source = Source::new_virtual(name.into(), code.clone());464		let parsed = jrsonnet_peg_parser::parse(465			&code,466			&ParserSettings {467				source: source.clone(),468			},469		)470		.map_err(|e| ImportSyntaxError {471			path: source.clone(),472			error: Box::new(e),473		})?;474		evaluate(self.create_default_context(source), &parsed)475	}476	/// Parses and evaluates the given snippet with custom context modifier477	pub fn evaluate_snippet_with(478		&self,479		name: impl Into<IStr>,480		code: impl Into<IStr>,481		context_initializer: impl ContextInitializer,482	) -> Result<Val> {483		let code = code.into();484		let source = Source::new_virtual(name.into(), code.clone());485		let parsed = jrsonnet_peg_parser::parse(486			&code,487			&ParserSettings {488				source: source.clone(),489			},490		)491		.map_err(|e| ImportSyntaxError {492			path: source.clone(),493			error: Box::new(e),494		})?;495		evaluate(496			self.create_default_context_with(source, context_initializer),497			&parsed,498		)499	}500}501502/// Settings utilities503impl State {504	// Only panics in case of [`ImportResolver`] contract violation505	#[allow(clippy::missing_panics_doc)]506	pub fn resolve_from(&self, from: &SourcePath, path: &dyn AsPathLike) -> Result<SourcePath> {507		self.import_resolver().resolve_from(from, path)508	}509	#[allow(clippy::missing_panics_doc)]510	pub fn resolve_from_default(&self, path: &dyn AsPathLike) -> Result<SourcePath> {511		self.import_resolver().resolve_from_default(path)512	}513	pub fn import_resolver(&self) -> &dyn ImportResolver {514		&*self.0.import_resolver515	}516	pub fn context_initializer(&self) -> &dyn ContextInitializer {517		&*self.0.context_initializer.0518	}519}520521impl State {522	pub fn builder() -> StateBuilder {523		StateBuilder::default()524	}525}526527impl Default for State {528	fn default() -> Self {529		Self::builder().build()530	}531}532533#[derive(Default)]534pub struct StateBuilder {535	import_resolver: Option<Rc<dyn ImportResolver>>,536	context_initializer: Option<CcContextInitializer>,537}538impl StateBuilder {539	pub fn import_resolver(&mut self, import_resolver: impl ImportResolver) -> &mut Self {540		let _ = self.import_resolver.insert(Rc::new(import_resolver));541		self542	}543	pub fn context_initializer(544		&mut self,545		context_initializer: impl ContextInitializer,546	) -> &mut Self {547		let _ = self548			.context_initializer549			.insert(CcContextInitializer::new(context_initializer));550		self551	}552	pub fn build(mut self) -> State {553		State(Cc::new(EvaluationStateInternals {554			file_cache: RefCell::new(FxHashMap::new()),555			context_initializer: self556				.context_initializer557				.take()558				.unwrap_or_else(|| CcContextInitializer::new(())),559			import_resolver: self560				.import_resolver561				.take()562				.unwrap_or_else(|| Rc::new(DummyImportResolver)),563		}))564	}565}
after · crates/jrsonnet-evaluator/src/lib.rs
1//! jsonnet interpreter implementation2#![cfg_attr(nightly, feature(thread_local, type_alias_impl_trait))]34// For jrsonnet-macros5extern crate self as jrsonnet_evaluator;67mod arr;8// pub mod async_import;9mod ctx;10mod dynamic;11pub mod error;12mod evaluate;13pub mod function;14pub mod gc;15mod import;16mod integrations;17pub mod manifest;18mod map;19mod obj;20pub mod stack;21pub mod stdlib;22pub mod tla;23pub mod trace;24pub mod typed;25pub mod val;2627use std::{28	any::Any,29	cell::{RefCell, RefMut},30	clone::Clone,31	collections::hash_map::Entry,32	fmt::{self, Debug},33	marker::PhantomData,34	rc::Rc,35};3637pub use ctx::*;38pub use dynamic::*;39pub use error::{Error, ErrorKind::*, Result, ResultExt};40pub use evaluate::*;41use function::CallLocation;42pub use import::*;43use jrsonnet_gcmodule::{cc_dyn, Cc, Trace};44pub use jrsonnet_interner::{IBytes, IStr};45pub use jrsonnet_ir as parser;46use jrsonnet_ir::{Expr, Source, SourcePath};47#[doc(hidden)]48pub use jrsonnet_macros;49#[cfg(feature = "ir-parser")]50use jrsonnet_ir_parser::ParserSettings;51#[cfg(not(feature = "ir-parser"))]52use jrsonnet_peg_parser::ParserSettings;53pub use obj::*;54pub use rustc_hash;55use rustc_hash::FxHashMap;56use stack::check_depth;57pub use tla::apply_tla;58pub use val::{Thunk, Val};5960use crate::gc::WithCapacityExt as _;6162#[cfg(feature = "ir-parser")]63pub(crate) fn parse_jsonnet(64	code: &str,65	settings: &ParserSettings,66) -> Result<Expr, jrsonnet_ir_parser::ParseError> {67	jrsonnet_ir_parser::parse(code, settings)68}6970#[cfg(not(feature = "ir-parser"))]71pub(crate) fn parse_jsonnet(72	code: &str,73	settings: &ParserSettings,74) -> Result<Expr, jrsonnet_peg_parser::ParseError> {75	jrsonnet_peg_parser::parse(code, settings)76}7778cc_dyn!(79	#[derive(Clone)]80	CcUnbound<V>,81	Unbound<Bound = V>82);8384/// Thunk without bound `super`/`this`85/// object inheritance may be overriden multiple times, and will be fixed only on field read86pub trait Unbound: Trace {87	/// Type of value after object context is bound88	type Bound;89	/// Create value bound to specified object context90	fn bind(&self, sup_this: SupThis) -> Result<Self::Bound>;91}9293/// Object fields may, or may not depend on `this`/`super`, this enum allows cheaper reuse of object-independent fields for native code94/// Standard jsonnet fields are always unbound95#[derive(Clone, Trace)]96pub enum MaybeUnbound {97	/// Value needs to be bound to `this`/`super`98	Unbound(CcUnbound<Val>),99	/// Value is object-independent100	Bound(Thunk<Val>),101}102103impl Debug for MaybeUnbound {104	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {105		write!(f, "MaybeUnbound")106	}107}108impl MaybeUnbound {109	/// Attach object context to value, if required110	pub fn evaluate(&self, sup_this: SupThis) -> Result<Val> {111		match self {112			Self::Unbound(v) => v.0.bind(sup_this),113			Self::Bound(v) => Ok(v.evaluate()?),114		}115	}116}117118cc_dyn!(CcContextInitializer, ContextInitializer);119120/// During import, this trait will be called to create initial context for file.121/// It may initialize global variables, stdlib for example.122pub trait ContextInitializer: Trace {123	/// For which size the builder should be preallocated124	fn reserve_vars(&self) -> usize {125		0126	}127	/// Initialize default file context.128	/// Has default implementation, which calls `populate`.129	/// Prefer to always implement `populate` instead.130	fn initialize(&self, for_file: Source) -> Context {131		let mut builder = ContextBuilder::with_capacity(self.reserve_vars());132		self.populate(for_file, &mut builder);133		builder.build()134	}135	/// For composability: extend builder. May panic if this initialization is not supported,136	/// and the context may only be created via `initialize`.137	fn populate(&self, for_file: Source, builder: &mut ContextBuilder);138	/// Allows upcasting from abstract to concrete context initializer.139	/// jrsonnet by itself doesn't use this method, it is allowed for it to panic.140	fn as_any(&self) -> &dyn Any;141}142143/// Context initializer which adds nothing.144impl ContextInitializer for () {145	fn populate(&self, _for_file: Source, _builder: &mut ContextBuilder) {}146	fn as_any(&self) -> &dyn Any {147		self148	}149}150151impl<T> ContextInitializer for Option<T>152where153	T: ContextInitializer,154{155	fn initialize(&self, for_file: Source) -> Context {156		if let Some(ctx) = self {157			ctx.initialize(for_file)158		} else {159			().initialize(for_file)160		}161	}162163	fn populate(&self, for_file: Source, builder: &mut ContextBuilder) {164		if let Some(ctx) = self {165			ctx.populate(for_file, builder);166		}167	}168169	fn as_any(&self) -> &dyn Any {170		self171	}172}173174macro_rules! impl_context_initializer {175	($($gen:ident)*) => {176		#[allow(non_snake_case)]177		impl<$($gen: ContextInitializer + Trace,)*> ContextInitializer for ($($gen,)*) {178			fn reserve_vars(&self) -> usize {179				let mut out = 0;180				let ($($gen,)*) = self;181				$(out += $gen.reserve_vars();)*182				out183			}184			fn populate(&self, for_file: Source, builder: &mut ContextBuilder) {185				let ($($gen,)*) = self;186				$($gen.populate(for_file.clone(), builder);)*187			}188			fn as_any(&self) -> &dyn Any {189				self190			}191		}192	};193	($($cur:ident)* @ $c:ident $($rest:ident)*) => {194		impl_context_initializer!($($cur)*);195		impl_context_initializer!($($cur)* $c @ $($rest)*);196	};197	($($cur:ident)* @) => {198		impl_context_initializer!($($cur)*);199	}200}201impl_context_initializer! {202	A @ B C D E F G203}204205#[derive(Trace)]206struct FileData {207	string: Option<IStr>,208	bytes: Option<IBytes>,209	parsed: Option<Rc<Expr>>,210	evaluated: Option<Val>,211212	evaluating: bool,213}214impl FileData {215	fn new_string(data: IStr) -> Self {216		Self {217			string: Some(data),218			bytes: None,219			parsed: None,220			evaluated: None,221			evaluating: false,222		}223	}224	fn new_bytes(data: IBytes) -> Self {225		Self {226			string: None,227			bytes: Some(data),228			parsed: None,229			evaluated: None,230			evaluating: false,231		}232	}233	pub(crate) fn get_string(&mut self) -> Option<IStr> {234		if self.string.is_none() {235			self.string = Some(236				self.bytes237					.as_ref()238					.expect("either string or bytes should be set")239					.clone()240					.cast_str()?,241			);242		}243		Some(self.string.clone().expect("just set"))244	}245}246247#[derive(Trace)]248pub struct EvaluationStateInternals {249	/// Internal state250	file_cache: RefCell<FxHashMap<SourcePath, FileData>>,251	/// Context initializer, which will be used for imports and everything252	/// [`NoopContextInitializer`] is used by default, most likely you want to have `jrsonnet-stdlib`253	context_initializer: CcContextInitializer,254	/// Used to resolve file locations/contents255	import_resolver: Rc<dyn ImportResolver>,256}257258/// Maintains stack trace and import resolution259#[derive(Clone, Trace)]260pub struct State(Cc<EvaluationStateInternals>);261262thread_local! {263	pub static DEFAULT_STATE: State = State::builder().build();264	pub static STATE: RefCell<Option<State>> = const {RefCell::new(None)};265}266pub struct StateEnterGuard(PhantomData<()>);267impl Drop for StateEnterGuard {268	fn drop(&mut self) {269		STATE.with_borrow_mut(|v| *v = None);270	}271}272273pub fn with_state<V>(v: impl FnOnce(State) -> V) -> V {274	if let Some(state) = STATE.with_borrow(Clone::clone) {275		v(state)276	} else {277		let s = DEFAULT_STATE.with(Clone::clone);278		v(s)279	}280}281282impl State {283	pub fn enter(&self) -> StateEnterGuard {284		self.try_enter().expect("entered state already exists")285	}286	pub fn try_enter(&self) -> Option<StateEnterGuard> {287		STATE.with_borrow_mut(|v| {288			if v.is_none() {289				*v = Some(self.clone());290				Some(StateEnterGuard(PhantomData))291			} else {292				None293			}294		})295	}296	/// Should only be called with path retrieved from [`resolve_path`], may panic otherwise297	pub fn import_resolved_str(&self, path: SourcePath) -> Result<IStr> {298		let mut file_cache = self.file_cache();299		let mut file = file_cache.entry(path.clone());300301		let file = match file {302			Entry::Occupied(ref mut d) => d.get_mut(),303			Entry::Vacant(v) => {304				let data = self.import_resolver().load_file_contents(&path)?;305				v.insert(FileData::new_string(306					std::str::from_utf8(&data)307						.map_err(|_| ImportBadFileUtf8(path.clone()))?308						.into(),309				))310			}311		};312		Ok(file313			.get_string()314			.ok_or_else(|| ImportBadFileUtf8(path.clone()))?)315	}316	/// Should only be called with path retrieved from [`resolve_path`], may panic otherwise317	pub fn import_resolved_bin(&self, path: SourcePath) -> Result<IBytes> {318		let mut file_cache = self.file_cache();319		let mut file = file_cache.entry(path.clone());320321		let file = match file {322			Entry::Occupied(ref mut d) => d.get_mut(),323			Entry::Vacant(v) => {324				let data = self.import_resolver().load_file_contents(&path)?;325				v.insert(FileData::new_bytes(data.as_slice().into()))326			}327		};328		if let Some(str) = &file.bytes {329			return Ok(str.clone());330		}331		if file.bytes.is_none() {332			file.bytes = Some(333				file.string334					.as_ref()335					.expect("either string or bytes should be set")336					.clone()337					.cast_bytes(),338			);339		}340		Ok(file.bytes.as_ref().expect("just set").clone())341	}342	/// Should only be called with path retrieved from [`resolve_path`], may panic otherwise343	pub fn import_resolved(&self, path: SourcePath) -> Result<Val> {344		let mut file_cache = self.file_cache();345		let mut file = file_cache.entry(path.clone());346347		let file = match file {348			Entry::Occupied(ref mut d) => d.get_mut(),349			Entry::Vacant(v) => {350				let data = self.import_resolver().load_file_contents(&path)?;351				v.insert(FileData::new_string(352					std::str::from_utf8(&data)353						.map_err(|_| ImportBadFileUtf8(path.clone()))?354						.into(),355				))356			}357		};358		if let Some(val) = &file.evaluated {359			return Ok(val.clone());360		}361		let code = file362			.get_string()363			.ok_or_else(|| ImportBadFileUtf8(path.clone()))?;364		let file_name = Source::new(path.clone(), code.clone());365		if file.parsed.is_none() {366			file.parsed = Some(367				parse_jsonnet(368					&code,369					&ParserSettings {370						source: file_name.clone(),371					},372				)373				.map(Rc::new)374				.map_err(|e| ImportSyntaxError {375					path: file_name.clone(),376					error: Box::new(e),377				})?,378			);379		}380		let parsed = file.parsed.as_ref().expect("just set").clone();381		if file.evaluating {382			bail!(InfiniteRecursionDetected)383		}384		file.evaluating = true;385		// Dropping file cache guard here, as evaluation may use this map too386		drop(file_cache);387		let res = evaluate(self.create_default_context(file_name), &parsed);388389		let mut file_cache = self.file_cache();390		let mut file = file_cache.entry(path);391392		let Entry::Occupied(file) = &mut file else {393			unreachable!("this file was just here")394		};395		let file = file.get_mut();396		file.evaluating = false;397		match res {398			Ok(v) => {399				file.evaluated = Some(v.clone());400				Ok(v)401			}402			Err(e) => Err(e),403		}404	}405406	/// Has same semantics as `import 'path'` called from `from` file407	pub fn import_from(&self, from: &SourcePath, path: impl AsPathLike) -> Result<Val> {408		let resolved = self.resolve_from(from, &path)?;409		self.import_resolved(resolved)410	}411	pub fn import(&self, path: impl AsPathLike) -> Result<Val> {412		let resolved = self.resolve_from_default(&path)?;413		self.import_resolved(resolved)414	}415416	/// Creates context with all passed global variables417	pub fn create_default_context(&self, source: Source) -> Context {418		self.context_initializer().initialize(source)419	}420421	/// Creates context with all passed global variables, calling custom modifier422	pub fn create_default_context_with(423		&self,424		source: Source,425		context_initializer: impl ContextInitializer,426	) -> Context {427		let default_initializer = self.context_initializer();428		let mut builder = ContextBuilder::with_capacity(429			default_initializer.reserve_vars() + context_initializer.reserve_vars(),430		);431		default_initializer.populate(source.clone(), &mut builder);432		context_initializer.populate(source, &mut builder);433434		builder.build()435	}436}437438/// Internals439impl State {440	fn file_cache(&self) -> RefMut<'_, FxHashMap<SourcePath, FileData>> {441		self.0.file_cache.borrow_mut()442	}443}444/// Executes code creating a new stack frame, to be replaced with try{}445pub fn in_frame<T>(446	e: CallLocation<'_>,447	frame_desc: impl FnOnce() -> String,448	f: impl FnOnce() -> Result<T>,449) -> Result<T> {450	let _guard = check_depth()?;451452	f().with_description_src(e, frame_desc)453}454455/// Executes code creating a new stack frame, to be replaced with try{}456pub fn in_description_frame<T>(457	frame_desc: impl FnOnce() -> String,458	f: impl FnOnce() -> Result<T>,459) -> Result<T> {460	let _guard = check_depth()?;461462	f().with_description(frame_desc)463}464465#[derive(Trace)]466pub struct InitialUnderscore(pub Thunk<Val>);467impl ContextInitializer for InitialUnderscore {468	fn populate(&self, _for_file: Source, builder: &mut ContextBuilder) {469		builder.bind("_", self.0.clone());470	}471472	fn as_any(&self) -> &dyn Any {473		self474	}475}476477/// Raw methods evaluate passed values but don't perform TLA execution478impl State {479	/// Parses and evaluates the given snippet480	pub fn evaluate_snippet(&self, name: impl Into<IStr>, code: impl Into<IStr>) -> Result<Val> {481		let code = code.into();482		let source = Source::new_virtual(name.into(), code.clone());483		let parsed = parse_jsonnet(484			&code,485			&ParserSettings {486				source: source.clone(),487			},488		)489		.map_err(|e| ImportSyntaxError {490			path: source.clone(),491			error: Box::new(e),492		})?;493		evaluate(self.create_default_context(source), &parsed)494	}495	/// Parses and evaluates the given snippet with custom context modifier496	pub fn evaluate_snippet_with(497		&self,498		name: impl Into<IStr>,499		code: impl Into<IStr>,500		context_initializer: impl ContextInitializer,501	) -> Result<Val> {502		let code = code.into();503		let source = Source::new_virtual(name.into(), code.clone());504		let parsed = parse_jsonnet(505			&code,506			&ParserSettings {507				source: source.clone(),508			},509		)510		.map_err(|e| ImportSyntaxError {511			path: source.clone(),512			error: Box::new(e),513		})?;514		evaluate(515			self.create_default_context_with(source, context_initializer),516			&parsed,517		)518	}519}520521/// Settings utilities522impl State {523	// Only panics in case of [`ImportResolver`] contract violation524	#[allow(clippy::missing_panics_doc)]525	pub fn resolve_from(&self, from: &SourcePath, path: &dyn AsPathLike) -> Result<SourcePath> {526		self.import_resolver().resolve_from(from, path)527	}528	#[allow(clippy::missing_panics_doc)]529	pub fn resolve_from_default(&self, path: &dyn AsPathLike) -> Result<SourcePath> {530		self.import_resolver().resolve_from_default(path)531	}532	pub fn import_resolver(&self) -> &dyn ImportResolver {533		&*self.0.import_resolver534	}535	pub fn context_initializer(&self) -> &dyn ContextInitializer {536		&*self.0.context_initializer.0537	}538}539540impl State {541	pub fn builder() -> StateBuilder {542		StateBuilder::default()543	}544}545546impl Default for State {547	fn default() -> Self {548		Self::builder().build()549	}550}551552#[derive(Default)]553pub struct StateBuilder {554	import_resolver: Option<Rc<dyn ImportResolver>>,555	context_initializer: Option<CcContextInitializer>,556}557impl StateBuilder {558	pub fn import_resolver(&mut self, import_resolver: impl ImportResolver) -> &mut Self {559		let _ = self.import_resolver.insert(Rc::new(import_resolver));560		self561	}562	pub fn context_initializer(563		&mut self,564		context_initializer: impl ContextInitializer,565	) -> &mut Self {566		let _ = self567			.context_initializer568			.insert(CcContextInitializer::new(context_initializer));569		self570	}571	pub fn build(mut self) -> State {572		State(Cc::new(EvaluationStateInternals {573			file_cache: RefCell::new(FxHashMap::new()),574			context_initializer: self575				.context_initializer576				.take()577				.unwrap_or_else(|| CcContextInitializer::new(())),578			import_resolver: self579				.import_resolver580				.take()581				.unwrap_or_else(|| Rc::new(DummyImportResolver)),582		}))583	}584}
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
--- a/crates/jrsonnet-ir-parser/src/lib.rs
+++ b/crates/jrsonnet-ir-parser/src/lib.rs
@@ -1,13 +1,37 @@
 use std::rc::Rc;
 
-use insta::assert_snapshot;
 use jrsonnet_gcmodule::Acyclic;
 use jrsonnet_ir::{
-	AssertExpr, AssertStmt, Expr, IfElse, IfSpecData, LiteralType, Slice, SliceDesc, Source,
-	SourceVirtual, Span, Spanned,
+	unescape, ArgsDesc, AssertExpr, AssertStmt, BinaryOp, BinaryOpType, BindSpec, CompSpec,
+	Destruct, Expr, ExprParam, ExprParams, FieldMember, FieldName, ForSpecData, IStr, IfElse,
+	IfSpecData, ImportKind, IndexPart, LiteralType, Member, ObjBody, ObjComp, ObjMembers, Slice,
+	SliceDesc, Source, Span, Spanned, UnaryOpType, Visibility,
 };
-use jrsonnet_lexer::{Lexeme, Lexer, SyntaxKind, T};
+use jrsonnet_lexer::{collect_lexed_str_block, Lexeme, Lexer, SyntaxKind, T};
 
+pub struct ParserSettings {
+	pub source: Source,
+}
+
+#[derive(Debug, Clone)]
+pub struct ParseErrorLocation {
+	pub offset: usize,
+}
+
+#[derive(Debug, Clone)]
+pub struct ParseError {
+	pub message: String,
+	pub location: ParseErrorLocation,
+}
+
+impl std::fmt::Display for ParseError {
+	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+		write!(f, "{}", self.message)
+	}
+}
+
+type R<T> = Result<T, ParseError>;
+
 struct Parser<'a> {
 	lexemes: Vec<Lexeme<'a>>,
 	offset: usize,
@@ -15,30 +39,46 @@
 }
 
 impl<'a> Parser<'a> {
-	fn new(s: &'a str) -> Self {
+	fn new(code: &'a str, source: Source) -> Self {
 		Self {
-			lexemes: Lexer::new(s)
-				.filter(|l| l.kind != SyntaxKind::WHITESPACE)
+			lexemes: Lexer::new(code)
+				.filter(|l| {
+					!matches!(
+						l.kind,
+						SyntaxKind::WHITESPACE
+							| SyntaxKind::SINGLE_LINE_SLASH_COMMENT
+							| SyntaxKind::SINGLE_LINE_HASH_COMMENT
+							| SyntaxKind::MULTI_LINE_COMMENT
+					)
+				})
 				.collect(),
 			offset: 0,
-			source: Source::new_virtual("<test>".into(), s.into()),
+			source,
 		}
 	}
+
 	fn peek(&self) -> SyntaxKind {
-		self.lexemes[self.offset].kind
+		if self.at_eof() {
+			SyntaxKind::EOF
+		} else {
+			self.lexemes[self.offset].kind
+		}
 	}
-	fn text(&self) -> &str {
+
+	fn text(&self) -> &'a str {
 		self.lexemes[self.offset].text
 	}
+
 	fn at(&self, kind: SyntaxKind) -> bool {
 		!self.at_eof() && self.peek() == kind
 	}
+
 	fn eat_any(&mut self) {
-		self.offset += 1
+		self.offset += 1;
 	}
 
 	fn at_eof(&self) -> bool {
-		self.offset == self.lexemes.len()
+		self.offset >= self.lexemes.len()
 	}
 
 	fn try_eat(&mut self, t: SyntaxKind) -> bool {
@@ -48,17 +88,165 @@
 		}
 		false
 	}
-	fn eat(&mut self, t: SyntaxKind) {
-		assert_eq!(self.peek(), t);
+
+	fn current_desc(&self) -> String {
+		if self.at_eof() {
+			return "end of file".to_owned();
+		}
+		let kind = self.peek();
+		let text = self.text();
+		let name = kind.display_name();
+		if matches!(kind, SyntaxKind::IDENT | SyntaxKind::FLOAT) {
+			format!("{name} \"{text}\"")
+		} else {
+			name.to_owned()
+		}
+	}
+
+	fn eat(&mut self, t: SyntaxKind) -> R<()> {
+		if !self.at(t) {
+			return Err(self.error(format!(
+				"expected {}, got {}",
+				t.display_name(),
+				self.current_desc(),
+			)));
+		}
 		self.eat_any();
+		Ok(())
 	}
 
 	fn span_start(&self) -> u32 {
+		if self.at_eof() {
+			if let Some(last) = self.lexemes.last() {
+				return last.range.1;
+			}
+			return 0;
+		}
 		self.lexemes[self.offset].range.0
 	}
+
 	fn span_end(&self) -> u32 {
 		self.lexemes[self.offset - 1].range.1
 	}
+
+	fn error(&self, message: String) -> ParseError {
+		ParseError {
+			location: ParseErrorLocation {
+				offset: self.span_start() as usize,
+			},
+			message,
+		}
+	}
+
+	fn expect_ident(&mut self) -> R<IStr> {
+		if !self.at(SyntaxKind::IDENT) {
+			return Err(self.error(format!("expected identifier, got {}", self.current_desc())));
+		}
+		let text = self.text();
+		if is_reserved(text) {
+			return Err(self.error(format!(
+				"expected identifier, got reserved word '{text}'"
+			)));
+		}
+		let s: IStr = text.into();
+		self.eat_any();
+		Ok(s)
+	}
+
+	fn at_ident(&self) -> bool {
+		self.at(SyntaxKind::IDENT) && !is_reserved(self.lexemes[self.offset].text)
+	}
+}
+
+fn is_reserved(s: &str) -> bool {
+	matches!(
+		s,
+		"assert"
+			| "else" | "error"
+			| "false" | "for"
+			| "function" | "if"
+			| "import" | "importstr"
+			| "importbin" | "in"
+			| "local" | "null"
+			| "tailstrict" | "then"
+			| "self" | "super"
+			| "true"
+	)
+}
+
+fn spanned<T: Acyclic>(p: &mut Parser<'_>, cb: impl FnOnce(&mut Parser<'_>) -> R<T>) -> R<Spanned<T>> {
+	let start = p.span_start();
+	let v = cb(p)?;
+	let end = p.span_end();
+	Ok(Spanned::new(v, Span(p.source.clone(), start, end)))
+}
+
+fn parse_string_content(p: &mut Parser<'_>) -> R<IStr> {
+	let kind = p.peek();
+	let text = p.text();
+	let s = match kind {
+		SyntaxKind::STRING_DOUBLE => {
+			let inner = &text[1..text.len() - 1];
+			unescape::unescape(inner)
+				.ok_or_else(|| p.error("invalid string escape".into()))?
+		}
+		SyntaxKind::STRING_SINGLE => {
+			let inner = &text[1..text.len() - 1];
+			unescape::unescape(inner)
+				.ok_or_else(|| p.error("invalid string escape".into()))?
+		}
+		SyntaxKind::STRING_DOUBLE_VERBATIM => {
+			let inner = &text[2..text.len() - 1];
+			inner.replace("\"\"", "\"")
+		}
+		SyntaxKind::STRING_SINGLE_VERBATIM => {
+			let inner = &text[2..text.len() - 1];
+			inner.replace("''", "'")
+		}
+		SyntaxKind::STRING_BLOCK => {
+			let inner = &text[3..];
+			let collected = collect_lexed_str_block(inner)
+				.map_err(|_| p.error("invalid string block".into()))?;
+			let mut result = String::new();
+			for (i, line) in collected.lines.iter().enumerate() {
+				if i > 0 {
+					result.push('\n');
+				}
+				result.push_str(line);
+			}
+			if !collected.truncate {
+				result.push('\n');
+			}
+			result
+		}
+		_ => return Err(p.error(format!("expected string, got {}", p.current_desc()))),
+	};
+	p.eat_any();
+	Ok(s.into())
+}
+
+fn is_string_token(kind: SyntaxKind) -> bool {
+	matches!(
+		kind,
+		SyntaxKind::STRING_DOUBLE
+			| SyntaxKind::STRING_SINGLE
+			| SyntaxKind::STRING_DOUBLE_VERBATIM
+			| SyntaxKind::STRING_SINGLE_VERBATIM
+			| SyntaxKind::STRING_BLOCK
+	)
+}
+
+fn parse_number(p: &mut Parser<'_>) -> R<f64> {
+	let text = p.text();
+	let n: f64 = text
+		.replace('_', "")
+		.parse()
+		.map_err(|_| p.error(format!("invalid number literal: {text}")))?;
+	if !n.is_finite() {
+		return Err(p.error("numbers are finite".into()));
+	}
+	p.eat_any();
+	Ok(n)
 }
 
 fn literal(p: &mut Parser<'_>) -> Option<LiteralType> {
@@ -75,109 +263,816 @@
 	Some(t)
 }
 
-fn spanned<T: Acyclic>(p: &mut Parser<'_>, cb: impl FnOnce(&mut Parser<'_>) -> T) -> Spanned<T> {
-	let start = p.span_start();
-	let v = cb(p);
-	let end = p.span_end();
-
-	Spanned::new(v, Span(p.source.clone(), start, end))
-}
-
-fn assert_stmt(p: &mut Parser<'_>) -> AssertStmt {
-	p.eat(T![assert]);
-	let cond = spanned(p, expr);
-	dbg!(p.peek());
+fn assert_stmt(p: &mut Parser<'_>) -> R<AssertStmt> {
+	p.eat(T![assert])?;
+	let cond = spanned(p, expr)?;
 	let msg = if p.try_eat(T![:]) {
-		Some(spanned(p, expr))
+		Some(spanned(p, expr)?)
 	} else {
 		None
 	};
-	dbg!(AssertStmt(cond, msg))
+	Ok(AssertStmt(cond, msg))
 }
 
-fn if_spec_data(p: &mut Parser<'_>) -> IfSpecData {
-	let v = spanned(p, |p| p.eat(T![if]));
-	let cond = expr(p);
-	IfSpecData { span: v.span, cond }
+fn if_spec_data(p: &mut Parser<'_>) -> R<IfSpecData> {
+	let v = spanned(p, |p| p.eat(T![if]))?;
+	let cond = expr(p)?;
+	Ok(IfSpecData { span: v.span, cond })
 }
 
-fn if_else(p: &mut Parser<'_>) -> IfElse {
-	let cond = if_spec_data(p);
-	p.eat(T![then]);
-	let cond_then = expr(p);
-	let cond_else = if p.at(T![else]) { Some(expr(p)) } else { None };
-	IfElse {
+fn if_else(p: &mut Parser<'_>) -> R<IfElse> {
+	let cond = if_spec_data(p)?;
+	p.eat(T![then])?;
+	let cond_then = expr(p)?;
+	let cond_else = if p.try_eat(T![else]) {
+		Some(expr(p)?)
+	} else {
+		None
+	};
+	Ok(IfElse {
 		cond,
 		cond_then,
 		cond_else,
-	}
+	})
 }
 
-fn slice_desc(p: &mut Parser<'_>, start: Option<Spanned<Expr>>) -> SliceDesc {
-	// start
-	p.eat(T![:]);
+fn slice_desc(p: &mut Parser<'_>, start: Option<Spanned<Expr>>) -> R<SliceDesc> {
+	p.eat(T![:])?;
 	let end = if !p.at(T![:]) && !p.at(T![']']) {
-		Some(spanned(p, expr))
+		Some(spanned(p, expr)?)
 	} else {
 		None
 	};
-	let step = if p.try_eat(T![:]) && !p.at(T![']']) {
-		Some(spanned(p, expr))
+	let step = if p.try_eat(T![:]) {
+		if !p.at(T![']']) {
+			Some(spanned(p, expr)?)
+		} else {
+			None
+		}
 	} else {
 		None
 	};
-	SliceDesc { start, end, step }
+	Ok(SliceDesc { start, end, step })
+}
+
+fn destruct(p: &mut Parser<'_>) -> R<Destruct> {
+	Ok(Destruct::Full(p.expect_ident()?))
+}
+
+fn params(p: &mut Parser<'_>) -> R<ExprParams> {
+	if p.at(T![')']) {
+		return Ok(ExprParams::new(Vec::new()));
+	}
+	let mut result = Vec::new();
+	loop {
+		let d = destruct(p)?;
+		let default = if p.try_eat(T![=]) {
+			Some(Rc::new(expr(p)?))
+		} else {
+			None
+		};
+		result.push(ExprParam {
+			destruct: d,
+			default,
+		});
+		if !p.try_eat(T![,]) {
+			break;
+		}
+		if p.at(T![')']) {
+			break;
+		}
+	}
+	Ok(ExprParams::new(result))
 }
 
-fn expr_simple(p: &mut Parser<'_>) -> Expr {
-	let mut e = if let Some(literal) = literal(p) {
-		Expr::Literal(literal)
+fn args(p: &mut Parser<'_>) -> R<ArgsDesc> {
+	if p.at(T![')']) {
+		return Ok(ArgsDesc::new(Vec::new(), Vec::new()));
+	}
+	let mut unnamed = Vec::new();
+	let mut named = Vec::new();
+	let mut named_started = false;
+	loop {
+		let is_named = p.at_ident() && {
+			let next_offset = p.offset + 1;
+			next_offset < p.lexemes.len() && p.lexemes[next_offset].kind == T![=] && {
+				let after_eq = next_offset + 1;
+				after_eq >= p.lexemes.len() || p.lexemes[after_eq].kind != T![=]
+			}
+		};
+		if is_named {
+			let name: IStr = p.expect_ident()?;
+			p.eat(T![=])?;
+			let value = Rc::new(expr(p)?);
+			named.push((name, value));
+			named_started = true;
+		} else {
+			if named_started {
+				return Err(p.error("positional argument after named argument".into()));
+			}
+			unnamed.push(Rc::new(expr(p)?));
+		}
+		if !p.try_eat(T![,]) {
+			break;
+		}
+		if p.at(T![')']) {
+			break;
+		}
+	}
+	Ok(ArgsDesc::new(unnamed, named))
+}
+
+fn bind(p: &mut Parser<'_>) -> R<BindSpec> {
+	let name = p.expect_ident()?;
+	if p.try_eat(T!['(']) {
+		let ps = params(p)?;
+		p.eat(T![')'])?;
+		p.eat(T![=])?;
+		let value = Rc::new(expr(p)?);
+		Ok(BindSpec::Function {
+			name,
+			params: ps,
+			value,
+		})
+	} else {
+		p.eat(T![=])?;
+		let value = Rc::new(expr(p)?);
+		Ok(BindSpec::Field {
+			into: Destruct::Full(name),
+			value,
+		})
+	}
+}
+
+fn visibility(p: &mut Parser<'_>) -> R<Visibility> {
+	p.eat(T![:])?;
+	if p.try_eat(T![:]) {
+		if p.try_eat(T![:]) {
+			Ok(Visibility::Unhide)
+		} else {
+			Ok(Visibility::Hidden)
+		}
+	} else {
+		Ok(Visibility::Normal)
+	}
+}
+
+fn field_name(p: &mut Parser<'_>) -> R<FieldName> {
+	if p.at_ident() {
+		Ok(FieldName::Fixed(p.expect_ident()?))
+	} else if is_string_token(p.peek()) {
+		Ok(FieldName::Fixed(parse_string_content(p)?))
+	} else if p.at(T!['[']) {
+		p.eat(T!['['])?;
+		let e = expr(p)?;
+		p.eat(T![']'])?;
+		Ok(FieldName::Dyn(e))
+	} else {
+		Err(p.error(format!("expected field name, got {}", p.current_desc())))
+	}
+}
+
+fn field(p: &mut Parser<'_>) -> R<FieldMember> {
+	let name = spanned(p, field_name)?;
+
+	if p.at(T!['(']) {
+		p.eat(T!['('])?;
+		let ps = params(p)?;
+		p.eat(T![')'])?;
+		let vis = visibility(p)?;
+		let value = Rc::new(expr(p)?);
+		Ok(FieldMember {
+			name,
+			plus: false,
+			params: Some(ps),
+			visibility: vis,
+			value,
+		})
+	} else {
+		let plus = p.try_eat(T![+]);
+		let vis = visibility(p)?;
+		let value = Rc::new(expr(p)?);
+		Ok(FieldMember {
+			name,
+			plus,
+			params: None,
+			visibility: vis,
+			value,
+		})
+	}
+}
+
+fn member(p: &mut Parser<'_>) -> R<Member> {
+	if p.at(T![local]) {
+		p.eat(T![local])?;
+		Ok(Member::BindStmt(bind(p)?))
 	} else if p.at(T![assert]) {
-		let assert = assert_stmt(p);
-		p.eat(T![;]);
-		let rest = expr(p);
-		Expr::AssertExpr(Rc::new(AssertExpr { assert, rest }))
-	} else if p.at(T![if]) {
-		Expr::IfElse(Box::new(if_else(p)))
+		Ok(Member::AssertStmt(assert_stmt(p)?))
+	} else {
+		Ok(Member::Field(field(p)?))
+	}
+}
+
+fn for_spec(p: &mut Parser<'_>) -> R<ForSpecData> {
+	p.eat(T![for])?;
+	let d = destruct(p)?;
+	p.eat(T![in])?;
+	let over = expr(p)?;
+	Ok(ForSpecData { destruct: d, over })
+}
+
+fn compspecs(p: &mut Parser<'_>) -> R<Vec<CompSpec>> {
+	let mut specs = Vec::new();
+	specs.push(CompSpec::ForSpec(for_spec(p)?));
+	loop {
+		if p.at(T![for]) {
+			specs.push(CompSpec::ForSpec(for_spec(p)?));
+		} else if p.at(T![if]) {
+			let isd = if_spec_data(p)?;
+			specs.push(CompSpec::IfSpec(isd));
+		} else {
+			break;
+		}
+	}
+	Ok(specs)
+}
+
+fn objinside(p: &mut Parser<'_>) -> R<ObjBody> {
+	if p.at(T!['}']) {
+		return Ok(ObjBody::MemberList(ObjMembers {
+			locals: Rc::new(Vec::new()),
+			asserts: Rc::new(Vec::new()),
+			fields: Vec::new(),
+		}));
+	}
+
+	let mut members = Vec::new();
+	loop {
+		members.push(member(p)?);
+		if !p.try_eat(T![,]) {
+			break;
+		}
+		if p.at(T!['}']) || p.at(T![for]) {
+			break;
+		}
+	}
+
+	if p.at(T![for]) {
+		let specs = compspecs(p)?;
+		let mut locals = Vec::new();
+		let mut field_member = None;
+		for m in members {
+			match m {
+				Member::Field(f) => {
+					if field_member.is_some() {
+						return Err(p.error(
+							"object comprehension can only contain one field".into(),
+						));
+					}
+					field_member = Some(f);
+				}
+				Member::BindStmt(b) => locals.push(b),
+				Member::AssertStmt(_) => {
+					return Err(p.error(
+						"asserts are unsupported in object comprehension".into(),
+					));
+				}
+			}
+		}
+		Ok(ObjBody::ObjComp(ObjComp {
+			locals: Rc::new(locals),
+			field: Rc::new(
+				field_member.ok_or_else(|| {
+					p.error("missing object comprehension field".into())
+				})?,
+			),
+			compspecs: specs,
+		}))
 	} else {
-		panic!("unexpected token: {:?}", p.peek());
+		let mut locals = Vec::new();
+		let mut asserts = Vec::new();
+		let mut fields = Vec::new();
+		for m in members {
+			match m {
+				Member::Field(f) => fields.push(f),
+				Member::BindStmt(b) => locals.push(b),
+				Member::AssertStmt(a) => asserts.push(a),
+			}
+		}
+		Ok(ObjBody::MemberList(ObjMembers {
+			locals: Rc::new(locals),
+			asserts: Rc::new(asserts),
+			fields,
+		}))
+	}
+}
+
+fn expr_basic(p: &mut Parser<'_>) -> R<Expr> {
+	if let Some(lit) = literal(p) {
+		return Ok(Expr::Literal(lit));
+	}
+
+	match p.peek() {
+		SyntaxKind::STRING_DOUBLE
+		| SyntaxKind::STRING_SINGLE
+		| SyntaxKind::STRING_DOUBLE_VERBATIM
+		| SyntaxKind::STRING_SINGLE_VERBATIM
+		| SyntaxKind::STRING_BLOCK => Ok(Expr::Str(parse_string_content(p)?)),
+
+		SyntaxKind::FLOAT => Ok(Expr::Num(parse_number(p)?)),
+
+		T!['('] => {
+			p.eat(T!['('])?;
+			let e = expr(p)?;
+			p.eat(T![')'])?;
+			Ok(e)
+		}
+
+		T!['['] => {
+			p.eat(T!['['])?;
+			if p.at(T![']']) {
+				p.eat(T![']'])?;
+				return Ok(Expr::Arr(Rc::new(Vec::new())));
+			}
+			let first = expr(p)?;
+			if p.at(T![for]) {
+				let specs = compspecs(p)?;
+				p.eat(T![']'])?;
+				Ok(Expr::ArrComp(Rc::new(first), specs))
+			} else if p.at(T![,]) && {
+				let next = p.offset + 1;
+				next < p.lexemes.len() && p.lexemes[next].kind == T![for]
+			} {
+				p.eat(T![,])?;
+				let specs = compspecs(p)?;
+				p.eat(T![']'])?;
+				Ok(Expr::ArrComp(Rc::new(first), specs))
+			} else {
+				let mut elems = vec![first];
+				while p.try_eat(T![,]) {
+					if p.at(T![']']) {
+						break;
+					}
+					elems.push(expr(p)?);
+				}
+				p.eat(T![']'])?;
+				Ok(Expr::Arr(Rc::new(elems)))
+			}
+		}
+
+		T!['{'] => {
+			p.eat(T!['{'])?;
+			let body = objinside(p)?;
+			p.eat(T!['}'])?;
+			Ok(Expr::Obj(body))
+		}
+
+		T![local] => {
+			p.eat(T![local])?;
+			let mut binds = Vec::new();
+			loop {
+				binds.push(bind(p)?);
+				if !p.try_eat(T![,]) {
+					break;
+				}
+			}
+			p.eat(T![;])?;
+			let body = expr(p)?;
+			Ok(Expr::LocalExpr(binds, Box::new(body)))
+		}
+
+		T![if] => Ok(Expr::IfElse(Box::new(if_else(p)?))),
+
+		T![function] => {
+			p.eat(T![function])?;
+			p.eat(T!['('])?;
+			let ps = params(p)?;
+			p.eat(T![')'])?;
+			let body = expr(p)?;
+			Ok(Expr::Function(ps, Rc::new(body)))
+		}
+
+		T![assert] => {
+			let a = assert_stmt(p)?;
+			p.eat(T![;])?;
+			let rest = expr(p)?;
+			Ok(Expr::AssertExpr(Rc::new(AssertExpr { assert: a, rest })))
+		}
+
+		T![error] => {
+			let span = spanned(p, |p| p.eat(T![error]))?;
+			let e = expr(p)?;
+			Ok(Expr::ErrorStmt(span.span, Box::new(e)))
+		}
+
+		T![importstr] => {
+			let kind = spanned(p, |p| {
+				p.eat(T![importstr])?;
+				Ok(ImportKind::Str)
+			})?;
+			let path = expr(p)?;
+			Ok(Expr::Import(kind, Box::new(path)))
+		}
+
+		T![importbin] => {
+			let kind = spanned(p, |p| {
+				p.eat(T![importbin])?;
+				Ok(ImportKind::Bin)
+			})?;
+			let path = expr(p)?;
+			Ok(Expr::Import(kind, Box::new(path)))
+		}
+
+		T![import] => {
+			let kind = spanned(p, |p| {
+				p.eat(T![import])?;
+				Ok(ImportKind::Normal)
+			})?;
+			let path = expr(p)?;
+			Ok(Expr::Import(kind, Box::new(path)))
+		}
+
+		SyntaxKind::IDENT => {
+			let text = p.text();
+			if is_reserved(text) {
+				return Err(p.error(format!("unexpected reserved word '{text}'")));
+			}
+			let n = spanned(p, |p| {
+				let s: IStr = p.text().into();
+				p.eat_any();
+				Ok(s)
+			})?;
+			Ok(Expr::Var(n))
+		}
+
+		_ => Err(p.error(format!("unexpected {}", p.current_desc()))),
+	}
+}
+
+/// Flush accumulated index parts into an Expr::Index wrapping `e`.
+fn flush_index_parts(e: &mut Expr, parts: &mut Vec<IndexPart>) {
+	if parts.is_empty() {
+		return;
+	}
+	let old = std::mem::replace(e, Expr::Literal(LiteralType::Null));
+	*e = Expr::Index {
+		indexable: Box::new(old),
+		parts: std::mem::take(parts),
 	};
+}
 
-	dbg!(&e);
+fn expr_suffix(p: &mut Parser<'_>) -> R<Expr> {
+	let mut e = expr_basic(p)?;
+	// Accumulate consecutive index parts (.field, [expr], ?.field, ?.[expr])
+	// into a single Expr::Index. This is critical for null-coalesce semantics:
+	// a?.b.c needs all parts in one Index so the evaluator can skip .c when .b is null.
+	let mut parts: Vec<IndexPart> = Vec::new();
 
 	loop {
-		if p.try_eat(T!['[']) {
-			if p.at(T![:]) {
-				let slice = slice_desc(p, None);
-				e = Expr::Slice(Box::new(Slice { value: e, slice }));
-				p.eat(T![']']);
-				continue;
+		#[cfg(feature = "exp-null-coaelse")]
+		if p.at(T![?]) {
+			p.eat_any();
+			if p.try_eat(T![.]) {
+				if p.at(T!['[']) {
+					// ?.[expr]
+					p.eat(T!['['])?;
+					let idx = spanned(p, expr)?;
+					p.eat(T![']'])?;
+					parts.push(IndexPart {
+						span: idx.span,
+						value: idx.value,
+						null_coaelse: true,
+					});
+				} else {
+					// ?.field
+					let id_spanned = spanned(p, |p| {
+						let name = p.expect_ident()?;
+						Ok(Expr::Str(name))
+					})?;
+					parts.push(IndexPart {
+						span: id_spanned.span,
+						value: id_spanned.value,
+						null_coaelse: true,
+					});
+				}
+			} else {
+				return Err(p.error("expected '.' after '?'".into()));
 			}
+			continue;
+		}
 
-			let idx = spanned(p, expr);
+		if p.at(T![.]) {
+			p.eat(T![.])?;
+			let id_spanned = spanned(p, |p| {
+				let name = p.expect_ident()?;
+				Ok(Expr::Str(name))
+			})?;
+			parts.push(IndexPart {
+				span: id_spanned.span,
+				value: id_spanned.value,
+				#[cfg(feature = "exp-null-coaelse")]
+				null_coaelse: false,
+			});
+		} else if p.at(T!['[']) {
+			p.eat(T!['['])?;
+
 			if p.at(T![:]) {
-				let slice = slice_desc(p, Some(idx));
+				// Slice: flush index parts first, then handle slice
+				flush_index_parts(&mut e, &mut parts);
+				let slice = slice_desc(p, None)?;
+				p.eat(T![']'])?;
 				e = Expr::Slice(Box::new(Slice { value: e, slice }));
 			} else {
+				let idx = spanned(p, expr)?;
+				if p.at(T![:]) {
+					// Slice with start: flush index parts first
+					flush_index_parts(&mut e, &mut parts);
+					let slice = slice_desc(p, Some(idx))?;
+					p.eat(T![']'])?;
+					e = Expr::Slice(Box::new(Slice { value: e, slice }));
+				} else {
+					// Bracket index: add to parts
+					p.eat(T![']'])?;
+					parts.push(IndexPart {
+						span: idx.span,
+						value: idx.value,
+						#[cfg(feature = "exp-null-coaelse")]
+						null_coaelse: false,
+					});
+				}
 			}
-			p.eat(T![']']);
+		} else if p.at(T!['(']) {
+			flush_index_parts(&mut e, &mut parts);
+			let args_spanned = spanned(p, |p| {
+				p.eat(T!['('])?;
+				let a = args(p)?;
+				p.eat(T![')'])?;
+				Ok(a)
+			})?;
+			let tailstrict = p.try_eat(T![tailstrict]);
+			e = Expr::Apply(Box::new(e), args_spanned, tailstrict);
+		} else if p.at(T!['{']) {
+			flush_index_parts(&mut e, &mut parts);
+			p.eat(T!['{'])?;
+			let body = objinside(p)?;
+			p.eat(T!['}'])?;
+			e = Expr::ObjExtend(Rc::new(e), body);
 		} else {
 			break;
 		}
 	}
 
-	dbg!(e)
+	flush_index_parts(&mut e, &mut parts);
+	Ok(e)
 }
 
-fn expr(p: &mut Parser<'_>) -> Expr {
-	expr_simple(p)
+fn prefix_binding_power(op: UnaryOpType) -> u8 {
+	match op {
+		UnaryOpType::Plus | UnaryOpType::Minus | UnaryOpType::Not | UnaryOpType::BitNot => 20,
+	}
+}
+
+fn infix_binding_power(op: BinaryOpType) -> (u8, u8) {
+	match op {
+		BinaryOpType::Or => (2, 3),
+		#[cfg(feature = "exp-null-coaelse")]
+		BinaryOpType::NullCoaelse => (2, 3),
+		BinaryOpType::And => (4, 5),
+		BinaryOpType::BitOr => (6, 7),
+		BinaryOpType::BitXor => (8, 9),
+		BinaryOpType::BitAnd => (10, 11),
+		BinaryOpType::Eq | BinaryOpType::Neq => (12, 13),
+		BinaryOpType::Lt
+		| BinaryOpType::Gt
+		| BinaryOpType::Lte
+		| BinaryOpType::Gte
+		| BinaryOpType::In => (14, 15),
+		BinaryOpType::Lhs | BinaryOpType::Rhs => (16, 17),
+		BinaryOpType::Add | BinaryOpType::Sub => (18, 19),
+		BinaryOpType::Mul | BinaryOpType::Div | BinaryOpType::Mod => (20, 21),
+	}
 }
 
-#[test]
-fn basic_test() {
-	let mut parser = Parser::new(" assert true[false] : false ; true ");
-	let e = expr(&mut parser);
-	let l = &parser.lexemes;
+fn unary_op(kind: SyntaxKind) -> Option<UnaryOpType> {
+	match kind {
+		T![+] => Some(UnaryOpType::Plus),
+		T![-] => Some(UnaryOpType::Minus),
+		T![!] => Some(UnaryOpType::Not),
+		T![~] => Some(UnaryOpType::BitNot),
+		_ => None,
+	}
+}
 
-	assert_snapshot!(format!("{l:#?}\n\n---\n\n{e:#?}"));
+fn binary_op(p: &Parser<'_>) -> Option<BinaryOpType> {
+	match p.peek() {
+		T![||] => Some(BinaryOpType::Or),
+		T![&&] => Some(BinaryOpType::And),
+		T![|] => Some(BinaryOpType::BitOr),
+		T![^] => Some(BinaryOpType::BitXor),
+		T![&] => Some(BinaryOpType::BitAnd),
+		T![==] => Some(BinaryOpType::Eq),
+		T![!=] => Some(BinaryOpType::Neq),
+		T![<] => Some(BinaryOpType::Lt),
+		T![>] => Some(BinaryOpType::Gt),
+		T![<=] => Some(BinaryOpType::Lte),
+		T![>=] => Some(BinaryOpType::Gte),
+		T![<<] => Some(BinaryOpType::Lhs),
+		T![>>] => Some(BinaryOpType::Rhs),
+		T![+] => Some(BinaryOpType::Add),
+		T![-] => Some(BinaryOpType::Sub),
+		T![*] => Some(BinaryOpType::Mul),
+		T![/] => Some(BinaryOpType::Div),
+		T![%] => Some(BinaryOpType::Mod),
+		T![in] => Some(BinaryOpType::In),
+		#[cfg(feature = "exp-null-coaelse")]
+		T![??] => Some(BinaryOpType::NullCoaelse),
+		_ => None,
+	}
+}
+
+fn expr_bp(p: &mut Parser<'_>, min_bp: u8) -> R<Expr> {
+	let mut lhs = if let Some(op) = unary_op(p.peek()) {
+		p.eat_any();
+		let rbp = prefix_binding_power(op);
+		let rhs = expr_bp(p, rbp)?;
+		Expr::UnaryOp(op, Box::new(rhs))
+	} else {
+		expr_suffix(p)?
+	};
+
+	loop {
+		if p.at_eof() {
+			break;
+		}
+
+		let Some(op) = binary_op(p) else {
+			break;
+		};
+
+		let (lbp, rbp) = infix_binding_power(op);
+		if lbp < min_bp {
+			break;
+		}
+
+		p.eat_any();
+		let rhs = expr_bp(p, rbp)?;
+		lhs = Expr::BinaryOp(Box::new(BinaryOp { lhs, op, rhs }));
+	}
+
+	Ok(lhs)
+}
+
+fn expr(p: &mut Parser<'_>) -> R<Expr> {
+	expr_bp(p, 0)
+}
+
+pub fn parse(str: &str, settings: &ParserSettings) -> Result<Expr, ParseError> {
+	let mut p = Parser::new(str, settings.source.clone());
+	for lexeme in &p.lexemes {
+		if let Some(desc) = lexeme.kind.error_description() {
+			return Err(ParseError {
+				message: desc.to_owned(),
+				location: ParseErrorLocation {
+					offset: lexeme.range.0 as usize,
+				},
+			});
+		}
+	}
+	let e = expr(&mut p)?;
+	if !p.at_eof() {
+		return Err(p.error(format!(
+			"expected end of file, got {}",
+			p.current_desc(),
+		)));
+	}
+	Ok(e)
+}
+
+pub fn string_to_expr(s: IStr, settings: &ParserSettings) -> Spanned<Expr> {
+	let len = s.len();
+	Spanned::new(
+		Expr::Str(s),
+		Span(settings.source.clone(), 0, len as u32),
+	)
+}
+
+#[cfg(test)]
+mod tests {
+	use std::fs;
+
+	use insta::{assert_snapshot, glob};
+	use jrsonnet_ir::{IStr, Source};
+
+	use super::*;
+
+	fn parse_str(input: &str) -> Expr {
+		let source = Source::new_virtual("<test>".into(), input.into());
+		let settings = ParserSettings { source };
+		parse(input, &settings).unwrap()
+	}
+
+	#[test]
+	#[cfg(not(feature = "exp-null-coaelse"))]
+	fn basic_test() {
+		let v = parse_str("assert true[false] : false ; true");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn literals() {
+		let v = parse_str("[null, true, false, self, super, $]");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn basic_math() {
+		let v = parse_str("2+2*2");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn underscore_numbers() {
+		let v = parse_str("[1_000, 1_000.000_1, 1_0e1_0]");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn strings() {
+		let v = parse_str(r#"["hello", 'world', @"raw""str", @'raw''str']"#);
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn object() {
+		let v = parse_str("{a: 1, b:: 2, c::: 3}");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn function_and_call() {
+		let v = parse_str("local f(x, y=1) = x + y; f(2, y=3)");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn if_then_else() {
+		let v = parse_str("if true then 1 else 2");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn imports() {
+		let v = parse_str(r#"[import "a", importstr "b", importbin "c"]"#);
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn array_comp() {
+		let v = parse_str("[x for x in arr]");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	#[cfg(not(feature = "exp-null-coaelse"))]
+	fn index_and_suffix() {
+		let v = parse_str("std.test(2).field[0]");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn obj_extend() {
+		let v = parse_str("{} { x: 1 }");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn unary_ops() {
+		let v = parse_str("!a && !b");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn error_expr() {
+		let v = parse_str("error \"bad\"");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	fn slice() {
+		let v = parse_str("[a[1:], a[1::], a[:1:], a[::1]]");
+		assert_snapshot!(format!("{v:#?}"));
+	}
+
+	#[test]
+	#[cfg(not(feature = "exp-null-coaelse"))]
+	fn peg_snapshots() {
+		glob!("../../jrsonnet-peg-parser/src", "tests/*.jsonnet", |path| {
+			let input = fs::read_to_string(path).expect("read test file");
+			let source = Source::new_virtual("<test>".into(), IStr::empty());
+			let settings = ParserSettings { source };
+			let v = parse(&input, &settings).unwrap();
+			let v = format!("{v:#?}");
+			assert_snapshot!(v);
+		});
+	}
 }
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
+]