git.delta.rocks / jrsonnet / refs/commits / 40875769025f

difftreelog

refactor better static analysis error display

uklooykrYaroslav Bolyukin2026-05-07parent: #2223f9a.patch.diff
in: master

67 files changed

modifiedcrates/jrsonnet-evaluator/src/analyze.rsdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/analyze.rs
+++ b/crates/jrsonnet-evaluator/src/analyze.rs
@@ -472,7 +472,7 @@
 		match bind {
 			BindSpec::Field { into, .. } => self.alloc_destruct(into),
 			BindSpec::Function { name, .. } => {
-				let (_, id) = self.define_local(name.clone(), None)?;
+				let (_, id) = self.define_local(name.value.clone(), Some(name.span.clone()))?;
 				Some(LDestruct::Full(id))
 			}
 		}
@@ -1337,18 +1337,18 @@
 	stack: &mut AnalysisStack,
 	taint: &mut AnalysisResult,
 ) -> LExpr {
-	if let Expr::Function(params, body) = expr {
-		return analyze_function(Some(name), params, body, stack, taint);
+	if let Expr::Function(span, params, body) = expr {
+		return analyze_function(Some(name), &span, &params, body, stack, taint);
 	}
 	analyze(expr, stack, taint)
 }
 #[allow(clippy::too_many_lines)]
 pub fn analyze(expr: &Expr, stack: &mut AnalysisStack, taint: &mut AnalysisResult) -> LExpr {
 	match expr {
-		Expr::Literal(l) => match l {
+		Expr::Literal(span, l) => match l {
 			LiteralType::This => stack.use_this(taint).map_or_else(
 				|| {
-					stack.report_error("`self` used outside of object", None);
+					stack.report_error("`self` used outside of object", Some(span.clone()));
 					LExpr::BadLocal("self")
 				},
 				LExpr::Slot,
@@ -1357,13 +1357,13 @@
 				if stack.use_super(taint).is_some() {
 					LExpr::Super
 				} else {
-					stack.report_error("`super` used outside of object", None);
+					stack.report_error("`super` used outside of object", Some(span.clone()));
 					LExpr::BadLocal("super")
 				}
 			}
 			LiteralType::Dollar => stack.use_dollar(taint).map_or_else(
 				|| {
-					stack.report_error("`$` used outside of object", None);
+					stack.report_error("`$` used outside of object", Some(span.clone()));
 					LExpr::BadLocal("$")
 				},
 				LExpr::Slot,
@@ -1475,7 +1475,9 @@
 				parts: parts_l,
 			}
 		}
-		Expr::Function(params, body) => analyze_function(None, params, body, stack, taint),
+		Expr::Function(span, params, body) => {
+			analyze_function(None, span, params, body, stack, taint)
+		}
 		Expr::IfElse(ifelse) => {
 			let IfElse {
 				cond,
@@ -1541,15 +1543,22 @@
 ) -> LExpr {
 	match bind {
 		BindSpec::Field {
-			value: Expr::Function(params, value),
+			value: Expr::Function(span, params, value),
 			into: Destruct::Full(name),
-		} => analyze_function(Some(name.value.clone()), params, value, stack, taint),
+		} => analyze_function(Some(name.value.clone()), &span, params, value, stack, taint),
 		BindSpec::Field { value, .. } => analyze(value, stack, taint),
 		BindSpec::Function {
 			params,
 			value,
 			name,
-		} => analyze_function(Some(name.clone()), params, value, stack, taint),
+		} => analyze_function(
+			Some(name.value.clone()),
+			&name.span,
+			params,
+			value,
+			stack,
+			taint,
+		),
 	}
 }
 
@@ -1595,6 +1604,7 @@
 
 fn analyze_function(
 	name: Option<IStr>,
+	span: &Span,
 	params: &ExprParams,
 	body: &Expr,
 	stack: &mut AnalysisStack,
@@ -1648,15 +1658,15 @@
 
 	// function(x) x is an identity function
 	if l_params.len() == 1 && l_params[0].default.is_none() {
-		stack.report_warning(
-			"do not define identity functions manually, use std.id instead",
-			None,
-		);
 		#[allow(irrefutable_let_patterns, reason = "refutable with exp-destruct")]
 		if let LDestruct::Full(param_slot) = &l_params[0].destruct
 			&& let LExpr::Slot(LSlot::Local(s)) = &body_expr
 			&& s == param_slot
 		{
+			stack.report_warning(
+				"do not define identity functions manually, use std.id instead",
+				Some(span.clone()),
+			);
 			return LExpr::IdentityFunction {};
 		}
 	}
@@ -1727,7 +1737,14 @@
 				for (f, name) in fields.iter().zip(field_names) {
 					let value = stack.in_using_closure(|stack| {
 						if let Some(params) = &f.params {
-							analyze_function(name.function_name(), params, &f.value, stack, taint)
+							analyze_function(
+								name.function_name(),
+								&f.name.span,
+								params,
+								&f.value,
+								stack,
+								taint,
+							)
 						} else {
 							analyze(&f.value, stack, taint)
 						}
@@ -1771,7 +1788,14 @@
 			process_local_frame(&comp.locals, stack, taint, |stack, taint| {
 				let value = stack.in_using_closure(|stack| {
 					if let Some(params) = &comp.field.params {
-						analyze_function(None, params, &comp.field.value, stack, taint)
+						analyze_function(
+							None,
+							&comp.field.name.span,
+							params,
+							&comp.field.value,
+							stack,
+							taint,
+						)
 					} else {
 						analyze(&comp.field.value, stack, taint)
 					}
modifiedcrates/jrsonnet-evaluator/src/trace/mod.rsdiffbeforeafterboth
--- a/crates/jrsonnet-evaluator/src/trace/mod.rs
+++ b/crates/jrsonnet-evaluator/src/trace/mod.rs
@@ -7,11 +7,9 @@
 };
 
 use jrsonnet_gcmodule::Trace;
-use jrsonnet_ir::CodeLocation;
-#[cfg(feature = "explaining-traces")]
-use jrsonnet_ir::Span;
+use jrsonnet_ir::{CodeLocation, Span};
 
-use crate::{Error, ResolvePathOwned, error::ErrorKind};
+use crate::{Error, ResolvePathOwned, analyze::DiagLevel, error::ErrorKind};
 
 /// The way paths should be displayed
 #[derive(Clone, Trace)]
@@ -72,31 +70,64 @@
 	fn as_any_mut(&mut self) -> &mut dyn Any;
 }
 
+fn span_label(resolver: &PathResolver, span: &Span) -> String {
+	use std::fmt::Write;
+	let mut path = span
+		.0
+		.source_path()
+		.path()
+		.map_or_else(|| span.0.source_path().to_string(), |p| resolver.resolve(p));
+	#[expect(clippy::cast_possible_truncation, reason = "code is limited by 4gb")]
+	let len = span.0.code().len() as u32;
+	let start = span.1.min(len);
+	let end = span.2.min(len);
+	let (start_loc, end_loc) = if start == end {
+		let [loc] = span.0.map_source_locations(&[start]);
+		(loc, loc)
+	} else {
+		let [s, e] = span.0.map_source_locations(&[start, end]);
+		(s, e)
+	};
+	write!(path, ":").unwrap();
+	print_code_location(&mut path, &start_loc, &end_loc).unwrap();
+	path
+}
+
+#[cfg(feature = "explaining-traces")]
+fn span_render_range(span: &Span) -> Option<std::ops::RangeInclusive<usize>> {
+	let len = span.0.code().len();
+	if len == 0 {
+		return None;
+	}
+	let max = len - 1;
+	let r = span.range();
+	Some((*r.start()).min(max)..=(*r.end()).min(max))
+}
+
+fn diag_level_label(level: DiagLevel) -> &'static str {
+	match level {
+		DiagLevel::Error => "error",
+		DiagLevel::Warning => "warning",
+	}
+}
+
 fn print_code_location(
 	out: &mut impl fmt::Write,
 	start: &CodeLocation,
 	end: &CodeLocation,
 ) -> Result<(), fmt::Error> {
+	let end_col = end.column.saturating_sub(1).max(start.column);
 	if start.line == end.line {
-		if start.column == end.column {
+		if start.column == end_col {
 			write!(out, "{}:{}", start.line, start.column)?;
 		} else {
-			write!(
-				out,
-				"{}:{}-{}",
-				start.line,
-				start.column,
-				end.column.saturating_sub(1)
-			)?;
+			write!(out, "{}:{}-{}", start.line, start.column, end_col)?;
 		}
 	} else {
 		write!(
 			out,
 			"{}:{}-{}:{}",
-			start.line,
-			start.column,
-			end.line,
-			end.column.saturating_sub(1)
+			start.line, start.column, end.line, end_col
 		)?;
 	}
 	Ok(())
@@ -121,61 +152,63 @@
 
 impl TraceFormat for CompactFormat {
 	fn write_trace(&self, out: &mut dyn fmt::Write, error: &Error) -> Result<(), fmt::Error> {
-		if let ErrorKind::ImportFileNotFound(from, import) = error.error() {
-			let from = from
-				.path()
-				.map_or_else(|| from.to_string(), |path| self.resolver.resolve(path));
-			let import = match import {
-				ResolvePathOwned::Str(s) => s.clone(),
-				ResolvePathOwned::Path(path_buf) => self.resolver.resolve(path_buf),
-			};
-			write!(out, "import file not found {import} from {from}")?;
-		} else {
-			write!(out, "{}", error.error())?;
+		match error.error() {
+			ErrorKind::ImportFileNotFound(from, import) => {
+				let from = from
+					.path()
+					.map_or_else(|| from.to_string(), |path| self.resolver.resolve(path));
+				let import = match import {
+					ResolvePathOwned::Str(s) => s.clone(),
+					ResolvePathOwned::Path(path_buf) => self.resolver.resolve(path_buf),
+				};
+				write!(out, "import file not found {import} from {from}")?;
+			}
+			ErrorKind::StaticAnalysisError(_) => {
+				write!(out, "static analysis errors")?;
+			}
+			_ => {
+				write!(out, "{}", error.error())?;
+			}
 		}
 
-		if let ErrorKind::ImportSyntaxError { path, error } = error.error() {
-			use std::fmt::Write;
+		if let ErrorKind::StaticAnalysisError(diagnostics) = error.error() {
+			let labels: Vec<Option<String>> = diagnostics
+				.iter()
+				.map(|d| d.span.as_ref().map(|s| span_label(&self.resolver, s)))
+				.collect();
+			let align = labels.iter().flatten().map(String::len).max().unwrap_or(0);
+			let cont_indent = " ".repeat(self.padding + align + 1);
+			for (diag, label) in diagnostics.iter().zip(labels.iter()) {
+				writeln!(out)?;
+				let level = diag_level_label(diag.level);
+				let message = diag.message.replace('\n', &format!("\n{cont_indent}"));
+				let label = label.as_deref().unwrap_or("");
+				write!(
+					out,
+					"{:<p$}{label:<w$} {level}: {message}",
+					"",
+					p = self.padding,
+					w = align,
+				)?;
+			}
+		}
 
+		if let ErrorKind::ImportSyntaxError { error, .. } = error.error() {
 			writeln!(out)?;
-			let mut n = path.source_path().path().map_or_else(
-				|| path.source_path().to_string(),
-				|r| self.resolver.resolve(r),
-			);
-			let offset = (error.location.1 as usize).min(path.code().len());
-			#[expect(clippy::cast_possible_truncation, reason = "code is limited by 4gb")]
-			let location = path
-				.map_source_locations(&[offset as u32])
-				.into_iter()
-				.next()
-				.unwrap();
-
-			write!(n, ":").unwrap();
-			print_code_location(&mut n, &location, &location).unwrap();
-			write!(out, "{:<p$}{n}", "", p = self.padding)?;
+			let label = span_label(&self.resolver, &error.location);
+			write!(out, "{:<p$}{label}", "", p = self.padding)?;
 		}
 		let file_names = error
 			.trace()
 			.0
 			.iter()
-			.map(|el| &el.location)
-			.map(|location| {
-				use std::fmt::Write;
-				#[allow(clippy::option_if_let_else)]
-				if let Some(location) = location {
-					let mut resolved_path = match location.0.source_path().path() {
-						Some(r) => self.resolver.resolve(r),
-						None => location.0.source_path().to_string(),
-					};
-					// TODO: Process all trace elements first
-					let location = location.0.map_source_locations(&[location.1, location.2]);
-					write!(resolved_path, ":").unwrap();
-					print_code_location(&mut resolved_path, &location[0], &location[1]).unwrap();
-					write!(resolved_path, ":").unwrap();
-					Some(resolved_path)
-				} else {
-					None
-				}
+			.map(|el| {
+				el.location.as_ref().map(|loc| {
+					use std::fmt::Write;
+					let mut s = span_label(&self.resolver, loc);
+					write!(s, ":").unwrap();
+					s
+				})
 			})
 			.collect::<Vec<_>>();
 		let align = file_names
@@ -265,40 +298,45 @@
 		}
 		use hi_doc::{Formatting, SnippetBuilder, Text, source_to_ansi};
 
-		write!(out, "{}", error.error())?;
+		match error.error() {
+			ErrorKind::StaticAnalysisError(_) => write!(out, "static analysis errors")?,
+			_ => write!(out, "{}", error.error())?,
+		}
 		if let ErrorKind::ImportSyntaxError { path, error } = error.error() {
-			writeln!(out)?;
-			let mut builder = SnippetBuilder::new(path.code());
-			builder
-				.error(Text::fragment("syntax error", Formatting::default()))
-				.range(error.location.range())
-				.build();
-			let source = builder.build();
-			let ansi = source_to_ansi(&source);
-			write!(out, "{ansi}")?;
+			writeln!(out, "\n...at {}", path.source_path())?;
+			if let Some(range) = span_render_range(&error.location) {
+				let mut builder = SnippetBuilder::new(path.code());
+				builder
+					.error(Text::fragment("syntax error", Formatting::default()))
+					.range(range)
+					.build();
+				let ansi = source_to_ansi(&builder.build());
+				write!(out, "{}", ansi.trim_end())?;
+			}
 		}
 		if let ErrorKind::StaticAnalysisError(diagnostics) = error.error() {
-			use crate::analyze::DiagLevel;
-			let mut builder: Option<SnippetBuilder> = None;
-			let mut current_src: Option<&str> = None;
-			let flush = |builder: Option<SnippetBuilder>,
+			let mut builder: Option<(SnippetBuilder, Span)> = None;
+			let flush = |slot: Option<(SnippetBuilder, Span)>,
 			             out: &mut dyn fmt::Write|
 			 -> Result<(), fmt::Error> {
-				if let Some(b) = builder {
+				if let Some((b, anchor)) = slot {
+					writeln!(out, "\n...at {}", anchor.0.source_path())?;
 					let ansi = source_to_ansi(&b.build());
-					write!(out, "\n{}", ansi.trim_end())?;
+					write!(out, "{}", ansi.trim_end())?;
 				}
 				Ok(())
 			};
 			for diag in diagnostics {
 				if let Some(span) = &diag.span {
-					let src = span.0.code();
-					if current_src != Some(src) {
+					let Some(range) = span_render_range(span) else {
+						continue;
+					};
+					let same_src = builder.as_ref().is_some_and(|(_, a)| a.0 == span.0);
+					if !same_src {
 						flush(builder.take(), out)?;
-						builder = Some(SnippetBuilder::new(src));
-						current_src = Some(src);
+						builder = Some((SnippetBuilder::new(span.0.code()), span.clone()));
 					}
-					let b = builder.as_mut().unwrap();
+					let b = &mut builder.as_mut().unwrap().0;
 					let ab = match diag.level {
 						DiagLevel::Error => {
 							b.error(Text::fragment(diag.message.clone(), Formatting::default()))
@@ -307,14 +345,10 @@
 							b.warning(Text::fragment(diag.message.clone(), Formatting::default()))
 						}
 					};
-					ab.range(span.range()).build();
+					ab.range(range).build();
 				} else {
 					flush(builder.take(), out)?;
-					current_src = None;
-					let prefix = match diag.level {
-						DiagLevel::Error => "error",
-						DiagLevel::Warning => "warning",
-					};
+					let prefix = diag_level_label(diag.level);
 					write!(out, "\n{prefix}: {}", diag.message)?;
 				}
 			}
modifiedcrates/jrsonnet-ir-parser/src/lib.rsdiffbeforeafterboth
after · crates/jrsonnet-ir-parser/src/lib.rs
1use jrsonnet_gcmodule::Acyclic;2use jrsonnet_ir::{3	ArgsDesc, AssertExpr, AssertStmt, BinaryOp, BinaryOpType, BindSpec, CompSpec, Destruct, Expr,4	ExprParam, ExprParams, FieldMember, FieldName, ForSpecData, IStr, IfElse, IfSpecData,5	ImportKind, IndexPart, LiteralType, Member, NumValue, ObjBody, ObjComp, ObjMembers, Slice,6	SliceDesc, Source, Span, Spanned, UnaryOpType, Visibility, unescape,7};8use jrsonnet_lexer::{Lexeme, Lexer, Span as LexSpan, SyntaxKind, T, collect_lexed_str_block};910pub struct ParserSettings {11	pub source: Source,12}1314#[derive(Debug, Clone)]15pub struct ParseError {16	pub message: String,17	pub location: Span,18}1920impl std::fmt::Display for ParseError {21	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {22		write!(f, "{}", self.message)23	}24}2526type Result<T> = std::result::Result<T, ParseError>;2728struct Parser<'a> {29	lexemes: Vec<Lexeme<'a>>,30	offset: usize,31	source: Source,32}3334impl<'a> Parser<'a> {35	fn new(code: &'a str, source: Source) -> Self {36		Self {37			lexemes: Lexer::new(code)38				.filter(|l| {39					!matches!(40						l.kind,41						SyntaxKind::WHITESPACE42							| SyntaxKind::SINGLE_LINE_SLASH_COMMENT43							| SyntaxKind::SINGLE_LINE_HASH_COMMENT44							| SyntaxKind::MULTI_LINE_COMMENT45					)46				})47				.collect(),48			offset: 0,49			source,50		}51	}5253	fn peek(&self) -> SyntaxKind {54		if self.at_eof() {55			SyntaxKind::EOF56		} else {57			self.lexemes[self.offset].kind58		}59	}6061	fn text(&self) -> &'a str {62		self.lexemes[self.offset].text63	}6465	fn at(&self, kind: SyntaxKind) -> bool {66		!self.at_eof() && self.peek() == kind67	}6869	#[allow(dead_code)]70	fn nth(&self, n: usize) -> SyntaxKind {71		self.lexemes72			.get(self.offset + n)73			.map_or(SyntaxKind::EOF, |l| l.kind)74	}7576	fn eat_any(&mut self) {77		self.offset += 1;78	}7980	fn eat_any_spanned(&mut self) -> Span {81		let start = self.span_start();82		self.eat_any();83		let end = self.span_end();84		Span(self.source.clone(), start, end)85	}8687	fn at_eof(&self) -> bool {88		self.offset >= self.lexemes.len()89	}9091	fn try_eat(&mut self, t: SyntaxKind) -> bool {92		if self.at(t) {93			self.eat_any();94			return true;95		}96		false97	}9899	fn current_desc(&self) -> String {100		if self.at_eof() {101			return "end of file".to_owned();102		}103		let kind = self.peek();104		let text = self.text();105		let name = kind.display_name();106		if matches!(kind, SyntaxKind::IDENT | SyntaxKind::FLOAT) {107			format!("{name} \"{text}\"")108		} else {109			name.to_owned()110		}111	}112113	fn eat(&mut self, t: SyntaxKind) -> Result<()> {114		if !self.at(t) {115			return Err(self.error(format!(116				"expected {}, got {}",117				t.display_name(),118				self.current_desc(),119			)));120		}121		self.eat_any();122		Ok(())123	}124	fn eat_spanned(&mut self, t: SyntaxKind) -> Result<Span> {125		let start = self.span_start();126		self.eat(t)?;127		let end = self.span_end();128		Ok(Span(self.source.clone(), start, end))129	}130131	fn span_start(&self) -> u32 {132		if self.at_eof() {133			if let Some(last) = self.lexemes.last() {134				return last.range.1;135			}136			return 0;137		}138		self.lexemes[self.offset].range.0139	}140141	fn span_end(&self) -> u32 {142		self.lexemes[self.offset - 1].range.1143	}144145	fn error(&self, message: String) -> ParseError {146		if self.offset == self.lexemes.len() {147			let pos = self.lexemes.last().map_or(0, |v| v.range.1);148			return ParseError {149				location: Span(self.source.clone(), pos, pos),150				message,151			};152		}153		let LexSpan(start, end) = self.lexemes[self.offset].range;154		ParseError {155			location: Span(self.source.clone(), start, end),156			message,157		}158	}159}160161fn spanned<T: Acyclic>(162	p: &mut Parser<'_>,163	cb: impl FnOnce(&mut Parser<'_>) -> Result<T>,164) -> Result<Spanned<T>> {165	let start = p.span_start();166	let v = cb(p)?;167	let end = p.span_end();168	Ok(Spanned::new(v, Span(p.source.clone(), start, end)))169}170171fn parse_string_content(p: &mut Parser<'_>) -> Result<IStr> {172	let kind = p.peek();173	let text = p.text();174	let s = match kind {175		SyntaxKind::STRING_DOUBLE => {176			let inner = &text[1..text.len() - 1];177			unescape::unescape(inner).ok_or_else(|| p.error("invalid string escape".into()))?178		}179		SyntaxKind::STRING_SINGLE => {180			let inner = &text[1..text.len() - 1];181			unescape::unescape(inner).ok_or_else(|| p.error("invalid string escape".into()))?182		}183		SyntaxKind::STRING_DOUBLE_VERBATIM => {184			let inner = &text[2..text.len() - 1];185			inner.replace("\"\"", "\"")186		}187		SyntaxKind::STRING_SINGLE_VERBATIM => {188			let inner = &text[2..text.len() - 1];189			inner.replace("''", "'")190		}191		SyntaxKind::STRING_BLOCK => {192			let inner = &text[3..];193			let collected = collect_lexed_str_block(inner)194				.map_err(|_| p.error("invalid string block".into()))?;195			let mut result = String::new();196			for (i, line) in collected.lines.iter().enumerate() {197				if i > 0 {198					result.push('\n');199				}200				result.push_str(line);201			}202			if !collected.truncate {203				result.push('\n');204			}205			result206		}207		_ => return Err(p.error(format!("expected string, got {}", p.current_desc()))),208	};209	p.eat_any();210	Ok(s.into())211}212213fn is_string_token(kind: SyntaxKind) -> bool {214	matches!(215		kind,216		SyntaxKind::STRING_DOUBLE217			| SyntaxKind::STRING_SINGLE218			| SyntaxKind::STRING_DOUBLE_VERBATIM219			| SyntaxKind::STRING_SINGLE_VERBATIM220			| SyntaxKind::STRING_BLOCK221	)222}223224fn parse_number(p: &mut Parser<'_>) -> Result<NumValue> {225	let text = p.text();226	let n: f64 = text227		.replace('_', "")228		.parse()229		.map_err(|_| p.error(format!("invalid number literal: {text}")))?;230231	let v = match NumValue::try_from(n) {232		Ok(v) => v,233		Err(e) => return Err(p.error(format!("invalid number value: {e}"))),234	};235236	p.eat_any();237238	Ok(v)239}240241fn ident(p: &mut Parser<'_>) -> Result<IStr> {242	if !p.at(SyntaxKind::IDENT) {243		return Err(p.error(format!("expected identifier, got {}", p.current_desc())));244	}245	let text = p.text();246	p.eat_any();247	Ok(IStr::from(text))248}249250fn literal(p: &mut Parser<'_>) -> Option<(Span, LiteralType)> {251	let t = match p.peek() {252		T![self] => LiteralType::This,253		T![super] => LiteralType::Super,254		T!['$'] => LiteralType::Dollar,255		T![null] => LiteralType::Null,256		T![true] => LiteralType::True,257		T![false] => LiteralType::False,258		_ => return None,259	};260	Some((p.eat_any_spanned(), t))261}262263fn assert_stmt(p: &mut Parser<'_>) -> Result<AssertStmt> {264	p.eat(T![assert])?;265	let assertion = spanned(p, expr)?;266	let message = if p.try_eat(T![:]) {267		Some(expr(p)?)268	} else {269		None270	};271	Ok(AssertStmt { assertion, message })272}273274fn if_spec_data(p: &mut Parser<'_>) -> Result<IfSpecData> {275	let v = spanned(p, |p| p.eat(T![if]))?;276	let cond = expr(p)?;277	Ok(IfSpecData { span: v.span, cond })278}279280fn if_else(p: &mut Parser<'_>) -> Result<IfElse> {281	let cond = if_spec_data(p)?;282	p.eat(T![then])?;283	let cond_then = expr(p)?;284	let cond_else = if p.try_eat(T![else]) {285		Some(expr(p)?)286	} else {287		None288	};289	Ok(IfElse {290		cond,291		cond_then,292		cond_else,293	})294}295296fn slice_desc(p: &mut Parser<'_>, start: Option<Spanned<Expr>>) -> Result<SliceDesc> {297	p.eat(T![:])?;298	let end = if !p.at(T![:]) && !p.at(T![']']) {299		Some(spanned(p, expr)?)300	} else {301		None302	};303	let step = if p.try_eat(T![:]) {304		if p.at(T![']']) {305			None306		} else {307			Some(spanned(p, expr)?)308		}309	} else {310		None311	};312	Ok(SliceDesc { start, end, step })313}314315fn destruct(p: &mut Parser<'_>) -> Result<Destruct> {316	if p.at(SyntaxKind::IDENT) {317		return Ok(Destruct::Full(spanned(p, ident)?));318	}319	#[cfg(not(feature = "exp-destruct"))]320	return Err(p.error(format!("expected identifier, got {}", p.current_desc())));321	#[cfg(feature = "exp-destruct")]322	{323		if p.try_eat(T![?]) {324			return Ok(Destruct::Skip);325		}326		if p.at(T!['[']) {327			return destruct_array(p);328		}329		if p.at(T!['{']) {330			return destruct_object(p);331		}332		Err(p.error(format!(333			"expected destructure pattern, got {}",334			p.current_desc()335		)))336	}337}338339#[cfg(feature = "exp-destruct")]340fn destruct_rest(p: &mut Parser<'_>) -> Result<jrsonnet_ir::DestructRest> {341	p.eat(T![...])?;342	if p.at(SyntaxKind::IDENT) {343		Ok(jrsonnet_ir::DestructRest::Keep(ident(p)?))344	} else {345		Ok(jrsonnet_ir::DestructRest::Drop)346	}347}348349#[cfg(feature = "exp-destruct")]350fn destruct_array(p: &mut Parser<'_>) -> Result<Destruct> {351	p.eat(T!['['])?;352	let mut start = Vec::new();353	let mut rest = None;354	let mut end = Vec::new();355	if !p.at(T![']']) {356		loop {357			if p.at(T![...]) {358				rest = Some(destruct_rest(p)?);359				if p.try_eat(T![,]) {360					if !p.at(T![']']) {361						loop {362							end.push(destruct(p)?);363							if !p.try_eat(T![,]) {364								break;365							}366							if p.at(T![']']) {367								break;368							}369						}370					}371				}372				break;373			}374			start.push(destruct(p)?);375			if !p.try_eat(T![,]) {376				break;377			}378			if p.at(T![']']) {379				break;380			}381		}382	}383	p.eat(T![']'])?;384	Ok(Destruct::Array { start, rest, end })385}386387#[cfg(feature = "exp-destruct")]388fn destruct_object(p: &mut Parser<'_>) -> Result<Destruct> {389	p.eat(T!['{'])?;390	let mut fields = Vec::new();391	let mut rest = None;392	if !p.at(T!['}']) {393		loop {394			if p.at(T![...]) {395				rest = Some(destruct_rest(p)?);396				p.try_eat(T![,]);397				break;398			}399			let name = ident(p)?;400			let into = if p.try_eat(T![:]) {401				Some(destruct(p)?)402			} else {403				None404			};405			let default = if p.try_eat(T![=]) {406				Some(spanned(p, expr)?)407			} else {408				None409			};410			fields.push((name, into, default));411			if !p.try_eat(T![,]) {412				break;413			}414			if p.at(T!['}']) {415				break;416			}417		}418	}419	p.eat(T!['}'])?;420	Ok(Destruct::Object { fields, rest })421}422423fn params(p: &mut Parser<'_>) -> Result<ExprParams> {424	if p.at(T![')']) {425		return Ok(ExprParams::new(Vec::new()));426	}427	let mut result = Vec::new();428	loop {429		let d = destruct(p)?;430		let default = if p.try_eat(T![=]) {431			Some(expr(p)?)432		} else {433			None434		};435		result.push(ExprParam {436			destruct: d,437			default,438		});439		if !p.try_eat(T![,]) {440			break;441		}442		if p.at(T![')']) {443			break;444		}445	}446	Ok(ExprParams::new(result))447}448449fn args(p: &mut Parser<'_>) -> Result<ArgsDesc> {450	if p.at(T![')']) {451		return Ok(ArgsDesc::new(Vec::new(), Vec::new(), Vec::new()));452	}453	let mut unnamed = Vec::new();454	let mut names = Vec::new();455	let mut values = Vec::new();456	let mut named_started = false;457	loop {458		let is_named = p.at(SyntaxKind::IDENT) && {459			let next_offset = p.offset + 1;460			next_offset < p.lexemes.len() && p.lexemes[next_offset].kind == T![=]461		};462		if is_named {463			let name: IStr = ident(p)?;464			p.eat(T![=])?;465466			names.push(name);467			values.push(expr(p)?);468			named_started = true;469		} else {470			if named_started {471				return Err(p.error("positional argument after named argument".into()));472			}473			unnamed.push(expr(p)?);474		}475		if !p.try_eat(T![,]) {476			break;477		}478		if p.at(T![')']) {479			break;480		}481	}482	Ok(ArgsDesc::new(unnamed, names, values))483}484485fn bind(p: &mut Parser<'_>) -> Result<BindSpec> {486	#[cfg(feature = "exp-destruct")]487	{488		if !p.at(SyntaxKind::IDENT) {489			let d = destruct(p)?;490			p.eat(T![=])?;491			return Ok(BindSpec::Field {492				into: d,493				value: expr(p)?,494			});495		}496	}497	let name = spanned(p, ident)?;498	if p.try_eat(T!['(']) {499		let ps = params(p)?;500		p.eat(T![')'])?;501		p.eat(T![=])?;502		Ok(BindSpec::Function {503			name,504			params: ps,505			value: expr(p)?,506		})507	} else {508		p.eat(T![=])?;509		Ok(BindSpec::Field {510			into: Destruct::Full(name),511			value: expr(p)?,512		})513	}514}515516fn visibility(p: &mut Parser<'_>) -> Result<Visibility> {517	p.eat(T![:])?;518	if p.try_eat(T![:]) {519		if p.try_eat(T![:]) {520			Ok(Visibility::Unhide)521		} else {522			Ok(Visibility::Hidden)523		}524	} else {525		Ok(Visibility::Normal)526	}527}528529fn field_name(p: &mut Parser<'_>) -> Result<FieldName> {530	if p.at(SyntaxKind::IDENT) {531		Ok(FieldName::Fixed(ident(p)?))532	} else if is_string_token(p.peek()) {533		Ok(FieldName::Fixed(parse_string_content(p)?))534	} else if p.at(T!['[']) {535		p.eat(T!['['])?;536		let e = expr(p)?;537		p.eat(T![']'])?;538		Ok(FieldName::Dyn(e))539	} else {540		Err(p.error(format!("expected field name, got {}", p.current_desc())))541	}542}543544fn field(p: &mut Parser<'_>) -> Result<FieldMember> {545	let name = spanned(p, field_name)?;546547	if p.at(T!['(']) {548		p.eat(T!['('])?;549		let ps = params(p)?;550		p.eat(T![')'])?;551		let vis = visibility(p)?;552		Ok(FieldMember {553			name,554			plus: false,555			params: Some(ps),556			visibility: vis,557			value: expr(p)?,558		})559	} else {560		let plus = p.try_eat(T![+]);561		let vis = visibility(p)?;562		Ok(FieldMember {563			name,564			plus,565			params: None,566			visibility: vis,567			value: expr(p)?,568		})569	}570}571572fn member(p: &mut Parser<'_>) -> Result<Member> {573	if p.at(T![local]) {574		p.eat(T![local])?;575		Ok(Member::BindStmt(bind(p)?))576	} else if p.at(T![assert]) {577		Ok(Member::AssertStmt(assert_stmt(p)?))578	} else {579		Ok(Member::Field(field(p)?))580	}581}582583fn for_spec(p: &mut Parser<'_>) -> Result<CompSpec> {584	p.eat(T![for])?;585	#[cfg(feature = "exp-object-iteration")]586	if p.at(T!['[']) && p.nth(1) == SyntaxKind::IDENT && p.nth(2) == T![']'] && p.nth(3) == T![:] {587		p.eat(T!['['])?;588		let key = ident(p)?;589		p.eat(T![']'])?;590		let visibility = visibility(p)?;591		let value = destruct(p)?;592		p.eat(T![in])?;593		let over = expr(p)?;594		return Ok(CompSpec::ForObjSpec(jrsonnet_ir::ForObjSpecData {595			key,596			visibility,597			value,598			over,599		}));600	}601	let d = destruct(p)?;602	p.eat(T![in])?;603	let over = expr(p)?;604	Ok(CompSpec::ForSpec(ForSpecData { destruct: d, over }))605}606607fn compspecs(p: &mut Parser<'_>) -> Result<Vec<CompSpec>> {608	let mut specs = Vec::new();609	specs.push(for_spec(p)?);610	loop {611		if p.at(T![for]) {612			specs.push(for_spec(p)?);613		} else if p.at(T![if]) {614			let isd = if_spec_data(p)?;615			specs.push(CompSpec::IfSpec(isd));616		} else {617			break;618		}619	}620	Ok(specs)621}622623fn objinside(p: &mut Parser<'_>) -> Result<ObjBody> {624	if p.at(T!['}']) {625		return Ok(ObjBody::MemberList(ObjMembers {626			locals: Vec::new(),627			asserts: Vec::new(),628			fields: Vec::new(),629		}));630	}631632	let mut members = Vec::new();633	loop {634		members.push(member(p)?);635		if !p.try_eat(T![,]) {636			break;637		}638		if p.at(T!['}']) || p.at(T![for]) {639			break;640		}641	}642643	if p.at(T![for]) {644		let specs = compspecs(p)?;645		let mut locals = Vec::new();646		let mut field_member = None;647		for m in members {648			match m {649				Member::Field(f) => {650					if field_member.is_some() {651						return Err(652							p.error("object comprehension can only contain one field".into())653						);654					}655					field_member = Some(f);656				}657				Member::BindStmt(b) => locals.push(b),658				Member::AssertStmt(_) => {659					return Err(p.error("asserts are unsupported in object comprehension".into()));660				}661			}662		}663		Ok(ObjBody::ObjComp(ObjComp {664			locals,665			field: Box::new(666				field_member.ok_or_else(|| p.error("missing object comprehension field".into()))?,667			),668			compspecs: specs,669		}))670	} else {671		let mut locals = Vec::new();672		let mut asserts = Vec::new();673		let mut fields = Vec::new();674		for m in members {675			match m {676				Member::Field(f) => fields.push(f),677				Member::BindStmt(b) => locals.push(b),678				Member::AssertStmt(a) => asserts.push(a),679			}680		}681		Ok(ObjBody::MemberList(ObjMembers {682			locals,683			asserts,684			fields,685		}))686	}687}688689#[allow(clippy::too_many_lines)]690fn expr_basic(p: &mut Parser<'_>) -> Result<Expr> {691	if let Some((span, lit)) = literal(p) {692		return Ok(Expr::Literal(span, lit));693	}694695	match p.peek() {696		SyntaxKind::STRING_DOUBLE697		| SyntaxKind::STRING_SINGLE698		| SyntaxKind::STRING_DOUBLE_VERBATIM699		| SyntaxKind::STRING_SINGLE_VERBATIM700		| SyntaxKind::STRING_BLOCK => Ok(Expr::Str(parse_string_content(p)?)),701702		SyntaxKind::FLOAT => Ok(Expr::Num(parse_number(p)?)),703704		T!['('] => {705			p.eat(T!['('])?;706			let e = expr(p)?;707			p.eat(T![')'])?;708			Ok(e)709		}710711		T!['['] => {712			p.eat(T!['['])?;713			if p.at(T![']']) {714				p.eat(T![']'])?;715				return Ok(Expr::Arr(Vec::new()));716			}717			let first = expr(p)?;718			if p.at(T![for]) {719				let specs = compspecs(p)?;720				p.eat(T![']'])?;721				Ok(Expr::ArrComp(Box::new(first), specs))722			} else if p.at(T![,]) && {723				let next = p.offset + 1;724				next < p.lexemes.len() && p.lexemes[next].kind == T![for]725			} {726				p.eat(T![,])?;727				let specs = compspecs(p)?;728				p.eat(T![']'])?;729				Ok(Expr::ArrComp(Box::new(first), specs))730			} else {731				let mut elems = vec![first];732				while p.try_eat(T![,]) {733					if p.at(T![']']) {734						break;735					}736					elems.push(expr(p)?);737				}738				p.eat(T![']'])?;739				Ok(Expr::Arr(elems))740			}741		}742743		T!['{'] => {744			p.eat(T!['{'])?;745			let body = objinside(p)?;746			p.eat(T!['}'])?;747			Ok(Expr::Obj(body))748		}749750		T![local] => {751			p.eat(T![local])?;752			let mut binds = Vec::new();753			loop {754				if p.at(T![;]) {755					break;756				}757				binds.push(bind(p)?);758				if !p.try_eat(T![,]) {759					break;760				}761			}762			p.eat(T![;])?;763			let body = expr(p)?;764			Ok(Expr::LocalExpr(binds, Box::new(body)))765		}766767		T![if] => Ok(Expr::IfElse(Box::new(if_else(p)?))),768769		T![function] => {770			let span = p.eat_spanned(T![function])?;771			p.eat(T!['('])?;772			let ps = params(p)?;773			p.eat(T![')'])?;774			let body = expr(p)?;775			Ok(Expr::Function(span, ps, Box::new(body)))776		}777778		T![assert] => {779			let a = assert_stmt(p)?;780			p.eat(T![;])?;781			let rest = expr(p)?;782			Ok(Expr::AssertExpr(Box::new(AssertExpr { assert: a, rest })))783		}784785		T![error] => {786			let span = spanned(p, |p| p.eat(T![error]))?;787			let e = expr(p)?;788			Ok(Expr::ErrorStmt(span.span, Box::new(e)))789		}790791		T![importstr] => {792			let kind = spanned(p, |p| {793				p.eat(T![importstr])?;794				Ok(ImportKind::Str)795			})?;796			let path = expr(p)?;797			Ok(Expr::Import(kind, Box::new(path)))798		}799800		T![importbin] => {801			let kind = spanned(p, |p| {802				p.eat(T![importbin])?;803				Ok(ImportKind::Bin)804			})?;805			let path = expr(p)?;806			Ok(Expr::Import(kind, Box::new(path)))807		}808809		T![import] => {810			let kind = spanned(p, |p| {811				p.eat(T![import])?;812				Ok(ImportKind::Normal)813			})?;814			let path = expr(p)?;815			Ok(Expr::Import(kind, Box::new(path)))816		}817818		SyntaxKind::IDENT => {819			let n = spanned(p, |p| {820				let s: IStr = p.text().into();821				p.eat_any();822				Ok(s)823			})?;824			Ok(Expr::Var(n))825		}826827		_ => Err(p.error(format!("unexpected {}", p.current_desc()))),828	}829}830831fn flush_index_parts(e: &mut Expr, parts: &mut Vec<IndexPart>) {832	if parts.is_empty() {833		return;834	}835	let old = std::mem::replace(e, Expr::Str(IStr::empty()));836	*e = Expr::Index {837		indexable: Box::new(old),838		parts: std::mem::take(parts),839	};840}841842fn expr_suffix(p: &mut Parser<'_>) -> Result<Expr> {843	let mut e = expr_basic(p)?;844	// Accumulate consecutive index parts (.field, [expr], ?.field, ?.[expr])845	// into a single Expr::Index. This is critical for null-coalesce semantics:846	// a?.b.c needs all parts in one Index so the evaluator can skip .c when .b is null.847	let mut parts: Vec<IndexPart> = Vec::new();848849	loop {850		#[cfg(feature = "exp-null-coaelse")]851		if p.at(T![?]) {852			p.eat_any();853			if p.try_eat(T![.]) {854				if p.at(T!['[']) {855					// ?.[expr]856					p.eat(T!['['])?;857					let idx = spanned(p, expr)?;858					p.eat(T![']'])?;859					parts.push(IndexPart {860						span: idx.span,861						value: idx.value,862						null_coaelse: true,863					});864				} else {865					// ?.field866					let id_spanned = spanned(p, |p| Ok(Expr::Str(ident(p)?)))?;867					parts.push(IndexPart {868						span: id_spanned.span,869						value: id_spanned.value,870						null_coaelse: true,871					});872				}873			} else {874				return Err(p.error("expected '.' after '?'".into()));875			}876			continue;877		}878879		if p.at(T![.]) {880			p.eat(T![.])?;881			let id_spanned = spanned(p, |p| Ok(Expr::Str(ident(p)?)))?;882			parts.push(IndexPart {883				span: id_spanned.span,884				value: id_spanned.value,885				#[cfg(feature = "exp-null-coaelse")]886				null_coaelse: false,887			});888		} else if p.at(T!['[']) {889			p.eat(T!['['])?;890891			if p.at(T![:]) {892				// Slice: flush index parts first, then handle slice893				flush_index_parts(&mut e, &mut parts);894				let slice = slice_desc(p, None)?;895				p.eat(T![']'])?;896				e = Expr::Slice(Box::new(Slice { value: e, slice }));897			} else {898				let idx = spanned(p, expr)?;899				if p.at(T![:]) {900					// Slice with start: flush index parts first901					flush_index_parts(&mut e, &mut parts);902					let slice = slice_desc(p, Some(idx))?;903					p.eat(T![']'])?;904					e = Expr::Slice(Box::new(Slice { value: e, slice }));905				} else {906					// Bracket index: add to parts907					p.eat(T![']'])?;908					parts.push(IndexPart {909						span: idx.span,910						value: idx.value,911						#[cfg(feature = "exp-null-coaelse")]912						null_coaelse: false,913					});914				}915			}916		} else if p.at(T!['(']) {917			flush_index_parts(&mut e, &mut parts);918			let args_spanned = spanned(p, |p| {919				p.eat(T!['('])?;920				let a = args(p)?;921				p.eat(T![')'])?;922				Ok(a)923			})?;924			let tailstrict = p.try_eat(T![tailstrict]);925			e = Expr::Apply(Box::new(e), args_spanned, tailstrict);926		} else if p.at(T!['{']) {927			flush_index_parts(&mut e, &mut parts);928			p.eat(T!['{'])?;929			let body = objinside(p)?;930			p.eat(T!['}'])?;931			e = Expr::ObjExtend(Box::new(e), body);932		} else {933			break;934		}935	}936937	flush_index_parts(&mut e, &mut parts);938	Ok(e)939}940941fn prefix_binding_power(op: UnaryOpType) -> u8 {942	match op {943		UnaryOpType::Plus | UnaryOpType::Minus | UnaryOpType::Not | UnaryOpType::BitNot => 20,944	}945}946947fn infix_binding_power(op: BinaryOpType) -> (u8, u8) {948	match op {949		BinaryOpType::Or => (2, 3),950		#[cfg(feature = "exp-null-coaelse")]951		BinaryOpType::NullCoaelse => (2, 3),952		BinaryOpType::And => (4, 5),953		BinaryOpType::BitOr => (6, 7),954		BinaryOpType::BitXor => (8, 9),955		BinaryOpType::BitAnd => (10, 11),956		BinaryOpType::Eq | BinaryOpType::Neq => (12, 13),957		BinaryOpType::Lt958		| BinaryOpType::Gt959		| BinaryOpType::Lte960		| BinaryOpType::Gte961		| BinaryOpType::In => (14, 15),962		BinaryOpType::Lhs | BinaryOpType::Rhs => (16, 17),963		BinaryOpType::Add | BinaryOpType::Sub => (18, 19),964		BinaryOpType::Mul | BinaryOpType::Div | BinaryOpType::Mod => (20, 21),965	}966}967968fn unary_op(kind: SyntaxKind) -> Option<UnaryOpType> {969	match kind {970		T![+] => Some(UnaryOpType::Plus),971		T![-] => Some(UnaryOpType::Minus),972		T![!] => Some(UnaryOpType::Not),973		T![~] => Some(UnaryOpType::BitNot),974		_ => None,975	}976}977978fn binary_op(p: &Parser<'_>) -> Option<BinaryOpType> {979	match p.peek() {980		T![||] => Some(BinaryOpType::Or),981		T![&&] => Some(BinaryOpType::And),982		T![|] => Some(BinaryOpType::BitOr),983		T![^] => Some(BinaryOpType::BitXor),984		T![&] => Some(BinaryOpType::BitAnd),985		T![==] => Some(BinaryOpType::Eq),986		T![!=] => Some(BinaryOpType::Neq),987		T![<] => Some(BinaryOpType::Lt),988		T![>] => Some(BinaryOpType::Gt),989		T![<=] => Some(BinaryOpType::Lte),990		T![>=] => Some(BinaryOpType::Gte),991		T![<<] => Some(BinaryOpType::Lhs),992		T![>>] => Some(BinaryOpType::Rhs),993		T![+] => Some(BinaryOpType::Add),994		T![-] => Some(BinaryOpType::Sub),995		T![*] => Some(BinaryOpType::Mul),996		T![/] => Some(BinaryOpType::Div),997		T![%] => Some(BinaryOpType::Mod),998		T![in] => Some(BinaryOpType::In),999		#[cfg(feature = "exp-null-coaelse")]1000		T![??] => Some(BinaryOpType::NullCoaelse),1001		_ => None,1002	}1003}10041005fn expr_bp(p: &mut Parser<'_>, min_bp: u8) -> Result<Expr> {1006	let mut lhs = if let Some(op) = unary_op(p.peek()) {1007		p.eat_any();1008		let rbp = prefix_binding_power(op);1009		let rhs = expr_bp(p, rbp)?;1010		Expr::UnaryOp(op, Box::new(rhs))1011	} else {1012		expr_suffix(p)?1013	};10141015	loop {1016		if p.at_eof() {1017			break;1018		}10191020		let Some(op) = binary_op(p) else {1021			break;1022		};10231024		let (lbp, rbp) = infix_binding_power(op);1025		if lbp < min_bp {1026			break;1027		}10281029		p.eat_any();1030		let rhs = expr_bp(p, rbp)?;1031		lhs = Expr::BinaryOp(Box::new(BinaryOp { lhs, op, rhs }));1032	}10331034	Ok(lhs)1035}10361037fn expr(p: &mut Parser<'_>) -> Result<Expr> {1038	expr_bp(p, 0)1039}10401041pub fn parse(str: &str, settings: &ParserSettings) -> Result<Expr> {1042	let mut p = Parser::new(str, settings.source.clone());1043	for lexeme in &p.lexemes {1044		if let Some(desc) = lexeme.kind.error_description() {1045			return Err(ParseError {1046				message: desc.to_owned(),1047				location: Span(p.source.clone(), lexeme.range.0, lexeme.range.1),1048			});1049		}1050	}1051	let e = expr(&mut p)?;1052	if !p.at_eof() {1053		return Err(p.error(format!("expected end of file, got {}", p.current_desc())));1054	}1055	Ok(e)1056}10571058pub fn string_to_expr(s: IStr, settings: &ParserSettings) -> Spanned<Expr> {1059	let len = u32::try_from(s.len()).expect("code size is limited by 4gb");10601061	Spanned::new(Expr::Str(s), Span(settings.source.clone(), 0, len))1062}10631064#[cfg(test)]1065mod tests {1066	use insta::assert_snapshot;10671068	use super::*;10691070	fn parse_str(input: &str) -> Expr {1071		let source = Source::new_virtual("<test>".into(), input.into());1072		let settings = ParserSettings { source };1073		parse(input, &settings).unwrap()1074	}10751076	#[test]1077	#[cfg(not(feature = "exp-null-coaelse"))]1078	fn basic_test() {1079		let v = parse_str("assert true[false] : false ; true");1080		assert_snapshot!(format!("{v:#?}"));1081	}10821083	#[test]1084	fn literals() {1085		let v = parse_str("[null, true, false, self, super, $]");1086		assert_snapshot!(format!("{v:#?}"));1087	}10881089	#[test]1090	fn basic_math() {1091		let v = parse_str("2+2*2");1092		assert_snapshot!(format!("{v:#?}"));1093	}10941095	#[test]1096	fn underscore_numbers() {1097		let v = parse_str("[1_000, 1_000.000_1, 1_0e1_0]");1098		assert_snapshot!(format!("{v:#?}"));1099	}11001101	#[test]1102	fn strings() {1103		let v = parse_str(r#"["hello", 'world', @"raw""str", @'raw''str']"#);1104		assert_snapshot!(format!("{v:#?}"));1105	}11061107	#[test]1108	fn object() {1109		let v = parse_str("{a: 1, b:: 2, c::: 3}");1110		assert_snapshot!(format!("{v:#?}"));1111	}11121113	#[test]1114	fn function_and_call() {1115		let v = parse_str("local f(x, y=1) = x + y; f(2, y=3)");1116		assert_snapshot!(format!("{v:#?}"));1117	}11181119	#[test]1120	fn if_then_else() {1121		let v = parse_str("if true then 1 else 2");1122		assert_snapshot!(format!("{v:#?}"));1123	}11241125	#[test]1126	fn imports() {1127		let v = parse_str(r#"[import "a", importstr "b", importbin "c"]"#);1128		assert_snapshot!(format!("{v:#?}"));1129	}11301131	#[test]1132	fn array_comp() {1133		let v = parse_str("[x for x in arr]");1134		assert_snapshot!(format!("{v:#?}"));1135	}11361137	#[test]1138	#[cfg(not(feature = "exp-null-coaelse"))]1139	fn index_and_suffix() {1140		let v = parse_str("std.test(2).field[0]");1141		assert_snapshot!(format!("{v:#?}"));1142	}11431144	#[test]1145	fn obj_extend() {1146		let v = parse_str("{} { x: 1 }");1147		assert_snapshot!(format!("{v:#?}"));1148	}11491150	#[test]1151	fn unary_ops() {1152		let v = parse_str("!a && !b");1153		assert_snapshot!(format!("{v:#?}"));1154	}11551156	#[test]1157	fn error_expr() {1158		let v = parse_str("error \"bad\"");1159		assert_snapshot!(format!("{v:#?}"));1160	}11611162	#[test]1163	fn slice() {1164		let v = parse_str("[a[1:], a[1::], a[:1:], a[::1]]");1165		assert_snapshot!(format!("{v:#?}"));1166	}11671168	#[test]1169	#[cfg(not(feature = "exp-null-coaelse"))]1170	fn peg_snapshots() {1171		use std::fs;11721173		use insta::glob;1174		use jrsonnet_ir::{IStr, Source};11751176		glob!("../../jrsonnet-peg-parser/src", "tests/*.jsonnet", |path| {1177			let input = fs::read_to_string(path).expect("read test file");1178			let source = Source::new_virtual("<test>".into(), IStr::empty());1179			let settings = ParserSettings { source };1180			let v = parse(&input, &settings).unwrap();1181			let v = format!("{v:#?}");1182			assert_snapshot!(v);1183		});1184	}1185}
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__function_and_call.snap.newdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__function_and_call.snap.new
@@ -0,0 +1,81 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+assertion_line: 1110
+expression: "format!(\"{v:#?}\")"
+---
+LocalExpr(
+    [
+        Function {
+            name: "f" from virtual:<test>:6-7,
+            params: ExprParams {
+                exprs: [
+                    ExprParam {
+                        destruct: Full(
+                            "x" from virtual:<test>:8-9,
+                        ),
+                        default: None,
+                    },
+                    ExprParam {
+                        destruct: Full(
+                            "y" from virtual:<test>:11-12,
+                        ),
+                        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,
+                ),
+            ],
+            names: [
+                "y",
+            ],
+            values: [
+                Num(
+                    3.0,
+                ),
+            ],
+        } from virtual:<test>:26-34,
+        false,
+    ),
+)
addedcrates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@default_nondefault.jsonnet.snap.newdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-ir-parser/src/snapshots/jrsonnet_ir_parser__tests__peg_snapshots@default_nondefault.jsonnet.snap.new
@@ -0,0 +1,56 @@
+---
+source: crates/jrsonnet-ir-parser/src/lib.rs
+assertion_line: 1176
+expression: v
+input_file: crates/jrsonnet-peg-parser/src/tests/default_nondefault.jsonnet
+---
+LocalExpr(
+    [
+        Function {
+            name: "x" from virtual:<test>:6-7,
+            params: ExprParams {
+                exprs: [
+                    ExprParam {
+                        destruct: Full(
+                            "foo" from virtual:<test>:8-11,
+                        ),
+                        default: Some(
+                            Str(
+                                "foo",
+                            ),
+                        ),
+                    },
+                    ExprParam {
+                        destruct: Full(
+                            "bar" from virtual:<test>:21-24,
+                        ),
+                        default: None,
+                    },
+                ],
+                signature: FunctionSignature(
+                    [
+                        ParamParse {
+                            name: Named(
+                                "foo",
+                            ),
+                            default: Exists,
+                        },
+                        ParamParse {
+                            name: Named(
+                                "bar",
+                            ),
+                            default: None,
+                        },
+                    ],
+                ),
+                binds_len: 2,
+            },
+            value: Literal(
+                Null,
+            ),
+        },
+    ],
+    Literal(
+        Null,
+    ),
+)
modifiedcrates/jrsonnet-ir/src/expr.rsdiffbeforeafterboth
--- a/crates/jrsonnet-ir/src/expr.rs
+++ b/crates/jrsonnet-ir/src/expr.rs
@@ -288,7 +288,7 @@
 		value: Expr,
 	},
 	Function {
-		name: IStr,
+		name: Spanned<IStr>,
 		params: ExprParams,
 		value: Expr,
 	},
@@ -404,7 +404,7 @@
 /// Syntax base
 #[derive(Debug, PartialEq, Acyclic)]
 pub enum Expr {
-	Literal(LiteralType),
+	Literal(Span, LiteralType),
 
 	/// String value: "hello"
 	Str(IStr),
@@ -454,7 +454,7 @@
 		parts: Vec<IndexPart>,
 	},
 	/// function(x) x
-	Function(ExprParams, Box<Expr>),
+	Function(Span, ExprParams, Box<Expr>),
 	/// if true == false then 1 else 2
 	IfElse(Box<IfElse>),
 	Slice(Box<Slice>),
modifiedcrates/jrsonnet-ir/src/visit.rsdiffbeforeafterboth
--- a/crates/jrsonnet-ir/src/visit.rs
+++ b/crates/jrsonnet-ir/src/visit.rs
@@ -178,7 +178,7 @@
 }
 pub fn visit_expr<V: Visitor>(v: &mut V, e: &Expr) {
 	match e {
-		Expr::Literal(_literal_type) => {}
+		Expr::Literal(_span, _literal_type) => {}
 		Expr::Str(_istr) => {}
 		Expr::Num(_num) => {}
 		Expr::Var(_spanned) => {}
@@ -254,7 +254,7 @@
 				v.visit_expr(value);
 			}
 		}
-		Expr::Function(expr_params, expr) => {
+		Expr::Function(_span, expr_params, expr) => {
 			visit_params(v, expr_params);
 			v.visit_expr(expr);
 		}
modifiedcrates/jrsonnet-peg-parser/src/lib.rsdiffbeforeafterboth
--- a/crates/jrsonnet-peg-parser/src/lib.rs
+++ b/crates/jrsonnet-peg-parser/src/lib.rs
@@ -131,7 +131,7 @@
 
 		pub rule bind(s: &ParserSettings) -> BindSpec
 			= into:destruct(s) _ "=" _ value:expr(s) {BindSpec::Field{into, value}}
-			/ name:id() _ "(" _ params:params(s) _ ")" _ "=" _ value:expr(s) {BindSpec::Function{name, params, value}}
+			/ name:spanned(<id()>, s) _ "(" _ params:params(s) _ ")" _ "=" _ value:expr(s) {BindSpec::Function{name, params, value}}
 
 		pub rule assertion(s: &ParserSettings) -> AssertStmt
 			= keyword("assert") _ assertion:spanned(<expr(s)>, s) message:(_ ":" _ e:expr(s) {e})? { AssertStmt{assertion, message} }
@@ -290,14 +290,14 @@
 			}))}
 
 		pub rule literal(s: &ParserSettings) -> Expr
-			= v:(
+			= a:position!() v:(
 				keyword("null") {LiteralType::Null}
 				/ keyword("true") {LiteralType::True}
 				/ keyword("false") {LiteralType::False}
 				/ keyword("self") {LiteralType::This}
 				/ keyword("$") {LiteralType::Dollar}
 				/ keyword("super") {LiteralType::Super}
-			) {Expr::Literal(v)}
+			) b:position!() {Expr::Literal(Span(s.source.clone(), codeidx(a), codeidx(b)), v)}
 
 		rule import_kind() -> ImportKind
 			= keyword("importstr") { ImportKind::Str }
@@ -319,7 +319,7 @@
 			/ local_expr(s)
 			/ if_then_else_expr(s)
 
-			/ keyword("function") _ "(" _ params:params(s) _ ")" _ expr:expr(s) {Expr::Function(params, Box::new(expr))}
+			/ kw:spanned(<keyword("function")>, s) _ "(" _ params:params(s) _ ")" _ expr:expr(s) {Expr::Function(kw.span, params, Box::new(expr))}
 			/ assert:assertion(s) _ ";" _ rest:expr(s) { Expr::AssertExpr(Box::new(AssertExpr{
 				assert, rest
 			})) }
modifiedtests/cpp_test_suite_golden_override/error.03.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.03.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.03.jsonnet.golden
@@ -1,3 +1,3 @@
 runtime error: foo
     error.03.jsonnet:17:21-25: error statement
-    error.03.jsonnet:18:8-8:   field <x> access
\ No newline at end of file
+    error.03.jsonnet:18:8:     field <x> access
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.07.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.07.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.07.jsonnet.golden
@@ -1,4 +1,4 @@
 runtime error: sarcasm
     error.07.jsonnet:18:31-35: error statement
-    error.07.jsonnet:17:33-33: element <3> access
+    error.07.jsonnet:17:33:    element <3> access
     error.07.jsonnet:18:20-53: function <third> call
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.args_commafodder.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.args_commafodder.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.args_commafodder.jsonnet.golden
@@ -1 +1,4 @@
-static analysis errors: undefined local: foo; undefined local: bar; undefined local: qux
\ No newline at end of file
+static analysis errors
+    error.args_commafodder.jsonnet:1:1-3 error: undefined local: foo
+    error.args_commafodder.jsonnet:2:3-5 error: undefined local: bar
+    error.args_commafodder.jsonnet:4:7-9 error: undefined local: qux
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.computed_field_scope.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.computed_field_scope.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.computed_field_scope.jsonnet.golden
@@ -1 +1,3 @@
-static analysis errors: undefined local: x; unused local: x
\ No newline at end of file
+static analysis errors
+    error.computed_field_scope.jsonnet:17:21 error: undefined local: x
+    error.computed_field_scope.jsonnet:17:9  warning: unused local: x
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.field_not_exist.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.field_not_exist.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.field_not_exist.jsonnet.golden
@@ -1,2 +1,2 @@
 no such field: y
-    error.field_not_exist.jsonnet:17:10-10: field <y> access
\ No newline at end of file
+    error.field_not_exist.jsonnet:17:10: field <y> access
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.function_duplicate_param.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.function_duplicate_param.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.function_duplicate_param.jsonnet.golden
@@ -1 +1,3 @@
-static analysis errors: local is already defined in the current frame: x; do not define identity functions manually, use std.id instead
\ No newline at end of file
+static analysis errors
+    error.function_duplicate_param.jsonnet:17:13  error: local is already defined in the current frame: x
+    error.function_duplicate_param.jsonnet:17:1-8 warning: do not define identity functions manually, use std.id instead
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.import_static-check-failure.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.import_static-check-failure.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.import_static-check-failure.jsonnet.golden
@@ -1,2 +1,3 @@
-static analysis errors: undefined local: x
+static analysis errors
+    lib/static_check_failure.jsonnet:2:1 error: undefined local: x
     error.import_static-check-failure.jsonnet:1:1-6: import
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.import_syntax-error.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.import_syntax-error.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.import_syntax-error.jsonnet.golden
@@ -1,4 +1,4 @@
 syntax error: unterminated double-quoted string
-    lib/syntax_error.jsonnet:1:1
-    lib/syntax_error.jsonnet:1:1-2:0:        parse imported
+    lib/syntax_error.jsonnet:1:1-2:1
+    lib/syntax_error.jsonnet:1:1-2:1:        parse imported
     error.import_syntax-error.jsonnet:1:1-6: import
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.overflow.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.overflow.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.overflow.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: invalid number value: non-finite
-    error.overflow.jsonnet:17:1
+    error.overflow.jsonnet:17:1-5
     error.overflow.jsonnet:17:1-5: parse imported
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.overflow3.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.overflow3.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.overflow3.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: invalid number value: non-finite
-    error.overflow3.jsonnet:17:1
+    error.overflow3.jsonnet:17:1-5
     error.overflow3.jsonnet:17:1-5: parse imported
\ No newline at end of file
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,3 +1,3 @@
 syntax error: expected ']', got number "3"
     error.parse.array_comma.jsonnet:17:7
-    error.parse.array_comma.jsonnet:17:7-7: parse imported
\ No newline at end of file
+    error.parse.array_comma.jsonnet:17:7: parse imported
\ 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,3 +1,3 @@
 syntax error: positional argument after named argument
     error.parse.function_arg_positional_after_named.jsonnet:19:10
-    error.parse.function_arg_positional_after_named.jsonnet:19:10-10: parse imported
\ No newline at end of file
+    error.parse.function_arg_positional_after_named.jsonnet:19:10: parse imported
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.import_not_literal.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.import_not_literal.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.import_not_literal.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: import path must be a string literal
\ No newline at end of file
+static analysis errors
+    error.parse.import_not_literal.jsonnet:17:1-6 error: import path must be a string literal
\ 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,3 +1,3 @@
 syntax error: unexpected end of file
     error.parse.index_unterminated.jsonnet:17:3
-    error.parse.index_unterminated.jsonnet:17:3-0:0: parse imported
\ No newline at end of file
+    error.parse.index_unterminated.jsonnet:17:3: parse imported
\ 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,3 +1,3 @@
 syntax error: expected ':', got '+'
     error.parse.method_plus.jsonnet:17:18
-    error.parse.method_plus.jsonnet:17:18-18: parse imported
\ No newline at end of file
+    error.parse.method_plus.jsonnet:17:18: parse imported
\ 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,3 +1,3 @@
 syntax error: expected '}', got identifier "z"
     error.parse.object_comma.jsonnet:17:11
-    error.parse.object_comma.jsonnet:17:11-11: parse imported
\ No newline at end of file
+    error.parse.object_comma.jsonnet:17:11: parse imported
\ 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,3 +1,3 @@
 syntax error: expected '}', got ':'
     error.parse.object_comprehension_local_clash.jsonnet:17:29
-    error.parse.object_comprehension_local_clash.jsonnet:17:29-29: parse imported
\ No newline at end of file
+    error.parse.object_comprehension_local_clash.jsonnet:17:29: parse imported
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.object_local_clash.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.object_local_clash.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.object_local_clash.jsonnet.golden
@@ -1 +1,3 @@
-static analysis errors: local is already defined in the current frame: x; unused local: x
\ No newline at end of file
+static analysis errors
+    error.parse.object_local_clash.jsonnet:17:21 error: local is already defined in the current frame: x
+    error.parse.object_local_clash.jsonnet:17:9  warning: unused local: x
\ 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,3 +1,3 @@
 syntax error: expected field name, got 'self'
-    error.parse.self_in_computed_field.jsonnet:17:15
+    error.parse.self_in_computed_field.jsonnet:17:15-18
     error.parse.self_in_computed_field.jsonnet:17:15-18: parse imported
\ 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,3 +1,3 @@
 syntax error: unexpected '.'
     error.parse.static_error_bad_number.jsonnet:17:1
-    error.parse.static_error_bad_number.jsonnet:17:1-1: parse imported
\ No newline at end of file
+    error.parse.static_error_bad_number.jsonnet:17:1: parse imported
\ 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,3 +1,3 @@
 syntax error: invalid string escape
-    error.parse.string.invalid_escape.jsonnet:17:1
+    error.parse.string.invalid_escape.jsonnet:17:1-4
     error.parse.string.invalid_escape.jsonnet:17:1-4: parse imported
\ 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,3 +1,3 @@
 syntax error: invalid string escape
-    error.parse.string.invalid_escape_unicode_non_hex.jsonnet:17:1
+    error.parse.string.invalid_escape_unicode_non_hex.jsonnet:17:1-8
     error.parse.string.invalid_escape_unicode_non_hex.jsonnet:17:1-8: parse imported
\ 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,3 +1,3 @@
 syntax error: unterminated double-quoted string
-    error.parse.string.invalid_escape_unicode_short.jsonnet:17:1
-    error.parse.string.invalid_escape_unicode_short.jsonnet:17:1-18:0: parse imported
\ No newline at end of file
+    error.parse.string.invalid_escape_unicode_short.jsonnet:17:1-18:1
+    error.parse.string.invalid_escape_unicode_short.jsonnet:17:1-18:1: parse imported
\ 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,3 +1,3 @@
 syntax error: invalid string escape
-    error.parse.string.invalid_escape_unicode_short2.jsonnet:17:1
+    error.parse.string.invalid_escape_unicode_short2.jsonnet:17:1-7
     error.parse.string.invalid_escape_unicode_short2.jsonnet:17:1-7: parse imported
\ 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,3 +1,3 @@
 syntax error: unterminated double-quoted string
-    error.parse.string.invalid_escape_unicode_short3.jsonnet:17:1
-    error.parse.string.invalid_escape_unicode_short3.jsonnet:17:1-18:0: parse imported
\ No newline at end of file
+    error.parse.string.invalid_escape_unicode_short3.jsonnet:17:1-18:1
+    error.parse.string.invalid_escape_unicode_short3.jsonnet:17:1-18:1: parse imported
\ 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,3 +1,3 @@
 syntax error: unterminated double-quoted string
-    error.parse.string.unfinished.jsonnet:17:1
-    error.parse.string.unfinished.jsonnet:17:1-18:0: parse imported
\ No newline at end of file
+    error.parse.string.unfinished.jsonnet:17:1-18:1
+    error.parse.string.unfinished.jsonnet:17:1-18:1: parse imported
\ 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,3 +1,3 @@
 syntax error: unterminated single-quoted string
-    error.parse.string.unfinished2.jsonnet:17:1
-    error.parse.string.unfinished2.jsonnet:17:1-18:0: parse imported
\ No newline at end of file
+    error.parse.string.unfinished2.jsonnet:17:1-18:1
+    error.parse.string.unfinished2.jsonnet:17:1-18:1: parse imported
\ 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,3 +1,3 @@
 syntax error: text block requires new line after |||
-    error.parse.string_multi_no_newline.jsonnet:17:1
-    error.parse.string_multi_no_newline.jsonnet:17:1-18:0: parse imported
\ No newline at end of file
+    error.parse.string_multi_no_newline.jsonnet:17:1-18:1
+    error.parse.string_multi_no_newline.jsonnet:17:1-18:1: parse imported
\ 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,3 +1,3 @@
 syntax error: unterminated text block
-    error.parse.text_block_bad_whitespace.jsonnet:17:1
+    error.parse.text_block_bad_whitespace.jsonnet:17:1-20:3
     error.parse.text_block_bad_whitespace.jsonnet:17:1-20:3: parse imported
\ 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,3 +1,3 @@
 syntax error: unexpected end of text block
-    error.parse.text_block_eof.jsonnet:17:1
+    error.parse.text_block_eof.jsonnet:17:1-18:6
     error.parse.text_block_eof.jsonnet:17:1-18:6: parse imported
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.parse.text_block_indent_spaces.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.parse.text_block_indent_spaces.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.parse.text_block_indent_spaces.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: unterminated text block
-    error.parse.text_block_indent_spaces.jsonnet:17:1
+    error.parse.text_block_indent_spaces.jsonnet:17:1-20:3
     error.parse.text_block_indent_spaces.jsonnet:17:1-20:3: parse imported
\ 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,3 +1,3 @@
 syntax error: unexpected end of text block
-    error.parse.text_block_not_terminated.jsonnet:17:1
-    error.parse.text_block_not_terminated.jsonnet:17:1-19:0: parse imported
\ No newline at end of file
+    error.parse.text_block_not_terminated.jsonnet:17:1-19:1
+    error.parse.text_block_not_terminated.jsonnet:17:1-19:1: parse imported
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.static_error_self.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.static_error_self.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.static_error_self.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: `self` used outside of object
\ No newline at end of file
+static analysis errors
+    error.static_error_self.jsonnet:17:2-5 error: `self` used outside of object
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.static_error_super.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.static_error_super.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.static_error_super.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: `super` used outside of object
\ No newline at end of file
+static analysis errors
+    error.static_error_super.jsonnet:17:2-6 error: `super` used outside of object
\ No newline at end of file
modifiedtests/cpp_test_suite_golden_override/error.static_error_var_not_exist.jsonnet.goldendiffbeforeafterboth
--- a/tests/cpp_test_suite_golden_override/error.static_error_var_not_exist.jsonnet.golden
+++ b/tests/cpp_test_suite_golden_override/error.static_error_var_not_exist.jsonnet.golden
@@ -1,2 +1,4 @@
-static analysis errors: undefined local: tmp2
-There is a local with similar name present: tmp; unused local: tmp
\ No newline at end of file
+static analysis errors
+    error.static_error_var_not_exist.jsonnet:17:16-19 error: undefined local: tmp2
+                                                      There is a local with similar name present: tmp
+    error.static_error_var_not_exist.jsonnet:17:7-9   warning: unused local: tmp
\ No newline at end of file
modifiedtests/go_testdata_golden_override/arrcomp5.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/arrcomp5.jsonnet.golden
+++ b/tests/go_testdata_golden_override/arrcomp5.jsonnet.golden
@@ -1 +1,3 @@
-static analysis errors: undefined local: x; unused local: x
\ No newline at end of file
+static analysis errors
+    arrcomp5.jsonnet:1:14 error: undefined local: x
+    arrcomp5.jsonnet:1:25 warning: unused local: x
\ No newline at end of file
modifiedtests/go_testdata_golden_override/arrcomp_if4.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/arrcomp_if4.jsonnet.golden
+++ b/tests/go_testdata_golden_override/arrcomp_if4.jsonnet.golden
@@ -1 +1,3 @@
-static analysis errors: undefined local: y; local could be hoisted to an outer scope: y
\ No newline at end of file
+static analysis errors
+    arrcomp_if4.jsonnet:1:33 error: undefined local: y
+    arrcomp_if4.jsonnet:1:44 warning: local could be hoisted to an outer scope: y
\ No newline at end of file
modifiedtests/go_testdata_golden_override/builtinObjectRemoveKey_super_assert.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/builtinObjectRemoveKey_super_assert.jsonnet.golden
+++ b/tests/go_testdata_golden_override/builtinObjectRemoveKey_super_assert.jsonnet.golden
@@ -1,3 +1,3 @@
 no such field: x
-    builtinObjectRemoveKey_super_assert.jsonnet:2:15-15: field <x> access
+    builtinObjectRemoveKey_super_assert.jsonnet:2:15:    field <x> access
     builtinObjectRemoveKey_super_assert.jsonnet:2:10-15: assertion condition
\ No newline at end of file
modifiedtests/go_testdata_golden_override/dollar_bad.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/dollar_bad.jsonnet.golden
+++ b/tests/go_testdata_golden_override/dollar_bad.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: `$` used outside of object
\ No newline at end of file
+static analysis errors
+    dollar_bad.jsonnet:1:1 error: `$` used outside of object
\ No newline at end of file
modifiedtests/go_testdata_golden_override/error_from_array.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/error_from_array.jsonnet.golden
+++ b/tests/go_testdata_golden_override/error_from_array.jsonnet.golden
@@ -1,3 +1,3 @@
 runtime error: xxx
-    error_from_array.jsonnet:1:2-6:   error statement
-    error_from_array.jsonnet:1:15-15: element <0> access
\ No newline at end of file
+    error_from_array.jsonnet:1:2-6: error statement
+    error_from_array.jsonnet:1:15:  element <0> access
\ No newline at end of file
modifiedtests/go_testdata_golden_override/error_hexnumber.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/error_hexnumber.jsonnet.golden
+++ b/tests/go_testdata_golden_override/error_hexnumber.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: expected end of file, got identifier "x42"
-    error_hexnumber.jsonnet:1:2
+    error_hexnumber.jsonnet:1:2-4
     error_hexnumber.jsonnet:1:2-4: parse imported
\ No newline at end of file
modifiedtests/go_testdata_golden_override/import_computed.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/import_computed.jsonnet.golden
+++ b/tests/go_testdata_golden_override/import_computed.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: import path must be a string literal
\ No newline at end of file
+static analysis errors
+    import_computed.jsonnet:1:1-6 error: import path must be a string literal
\ No newline at end of file
modifiedtests/go_testdata_golden_override/import_syntax_error.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/import_syntax_error.jsonnet.golden
+++ b/tests/go_testdata_golden_override/import_syntax_error.jsonnet.golden
@@ -1,4 +1,4 @@
 syntax error: unexpected end of file
     syntax_error.jsonnet:1:4
-    syntax_error.jsonnet:1:4-0:0:      parse imported
+    syntax_error.jsonnet:1:4:          parse imported
     import_syntax_error.jsonnet:1:1-6: import
\ No newline at end of file
modifiedtests/go_testdata_golden_override/importbin_computed.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/importbin_computed.jsonnet.golden
+++ b/tests/go_testdata_golden_override/importbin_computed.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: import path must be a string literal
\ No newline at end of file
+static analysis errors
+    importbin_computed.jsonnet:1:1-9 error: import path must be a string literal
\ No newline at end of file
modifiedtests/go_testdata_golden_override/importstr_computed.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/importstr_computed.jsonnet.golden
+++ b/tests/go_testdata_golden_override/importstr_computed.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: import path must be a string literal
\ No newline at end of file
+static analysis errors
+    importstr_computed.jsonnet:1:1-9 error: import path must be a string literal
\ No newline at end of file
modifiedtests/go_testdata_golden_override/insuper4.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/insuper4.jsonnet.golden
+++ b/tests/go_testdata_golden_override/insuper4.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: `super` used outside of object
\ No newline at end of file
+static analysis errors
+    insuper4.jsonnet:1:8-12 error: `super` used outside of object
\ No newline at end of file
modifiedtests/go_testdata_golden_override/insuper6.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/insuper6.jsonnet.golden
+++ b/tests/go_testdata_golden_override/insuper6.jsonnet.golden
@@ -1 +1,2 @@
-static analysis errors: undefined local: undeclared
\ No newline at end of file
+static analysis errors
+    insuper6.jsonnet:1:10-19 error: undefined local: undeclared
\ No newline at end of file
modifiedtests/go_testdata_golden_override/number_leading_zero.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/number_leading_zero.jsonnet.golden
+++ b/tests/go_testdata_golden_override/number_leading_zero.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: expected end of file, got number "42"
-    number_leading_zero.jsonnet:1:2
+    number_leading_zero.jsonnet:1:2-3
     number_leading_zero.jsonnet:1:2-3: parse imported
\ No newline at end of file
modifiedtests/go_testdata_golden_override/object_comp_assert.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/object_comp_assert.jsonnet.golden
+++ b/tests/go_testdata_golden_override/object_comp_assert.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: asserts are unsupported in object comprehension
     object_comp_assert.jsonnet:1:46
-    object_comp_assert.jsonnet:1:46-46: parse imported
\ No newline at end of file
+    object_comp_assert.jsonnet:1:46: parse imported
\ No newline at end of file
modifiedtests/go_testdata_golden_override/object_comp_illegal.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/object_comp_illegal.jsonnet.golden
+++ b/tests/go_testdata_golden_override/object_comp_illegal.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: missing object comprehension field
     object_comp_illegal.jsonnet:1:34
-    object_comp_illegal.jsonnet:1:34-34: parse imported
\ No newline at end of file
+    object_comp_illegal.jsonnet:1:34: parse imported
\ No newline at end of file
modifiedtests/go_testdata_golden_override/object_invariant11.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/object_invariant11.jsonnet.golden
+++ b/tests/go_testdata_golden_override/object_invariant11.jsonnet.golden
@@ -1,3 +1,3 @@
 assert failed: null
     object_invariant11.jsonnet:1:10-14: assertion failure
-    object_invariant11.jsonnet:1:18-18: field <x> access
\ No newline at end of file
+    object_invariant11.jsonnet:1:18:    field <x> access
\ No newline at end of file
modifiedtests/go_testdata_golden_override/static_error_eof.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/static_error_eof.jsonnet.golden
+++ b/tests/go_testdata_golden_override/static_error_eof.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: expected ';', got end of file
     static_error_eof.jsonnet:1:12
-    static_error_eof.jsonnet:1:12-0:0: parse imported
\ No newline at end of file
+    static_error_eof.jsonnet:1:12: parse imported
\ No newline at end of file
modifiedtests/go_testdata_golden_override/std.codepoint7.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/std.codepoint7.jsonnet.golden
+++ b/tests/go_testdata_golden_override/std.codepoint7.jsonnet.golden
@@ -1,3 +1,3 @@
 type error: expected char, got string
     argument <str> evaluation
-    std.codepoint7.jsonnet:2:14-0:0: function <builtin_codepoint> call
\ No newline at end of file
+    std.codepoint7.jsonnet:2:14-0:14: function <builtin_codepoint> call
\ No newline at end of file
modifiedtests/go_testdata_golden_override/syntax_error.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/syntax_error.jsonnet.golden
+++ b/tests/go_testdata_golden_override/syntax_error.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: unexpected end of file
     syntax_error.jsonnet:1:4
-    syntax_error.jsonnet:1:4-0:0: parse imported
\ No newline at end of file
+    syntax_error.jsonnet:1:4: parse imported
\ No newline at end of file
modifiedtests/go_testdata_golden_override/unfinished_args.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/unfinished_args.jsonnet.golden
+++ b/tests/go_testdata_golden_override/unfinished_args.jsonnet.golden
@@ -1,3 +1,3 @@
 syntax error: expected ')', got end of file
     unfinished_args.jsonnet:1:17
-    unfinished_args.jsonnet:1:17-0:0: parse imported
\ No newline at end of file
+    unfinished_args.jsonnet:1:17: parse imported
\ No newline at end of file
modifiedtests/go_testdata_golden_override/variable_not_visible.jsonnet.goldendiffbeforeafterboth
--- a/tests/go_testdata_golden_override/variable_not_visible.jsonnet.golden
+++ b/tests/go_testdata_golden_override/variable_not_visible.jsonnet.golden
@@ -1 +1,3 @@
-static analysis errors: undefined local: nested; unused local: x1
\ No newline at end of file
+static analysis errors
+    variable_not_visible.jsonnet:1:44-49 error: undefined local: nested
+    variable_not_visible.jsonnet:1:7-8   warning: unused local: x1
\ No newline at end of file
modifiedtests/tests/snapshots/golden__golden@issue172.jsonnet.snapdiffbeforeafterboth
--- a/tests/tests/snapshots/golden__golden@issue172.jsonnet.snap
+++ b/tests/tests/snapshots/golden__golden@issue172.jsonnet.snap
@@ -3,4 +3,5 @@
 expression: result
 input_file: tests/golden/issue172.jsonnet
 ---
-static analysis errors: undefined local: b
+static analysis errors
+    issue172.jsonnet:1:45 error: undefined local: b
modifiedtests/tests/snapshots/golden__golden@missing_binding.jsonnet.snapdiffbeforeafterboth
--- a/tests/tests/snapshots/golden__golden@missing_binding.jsonnet.snap
+++ b/tests/tests/snapshots/golden__golden@missing_binding.jsonnet.snap
@@ -3,5 +3,6 @@
 expression: result
 input_file: tests/golden/missing_binding.jsonnet
 ---
-static analysis errors: undefined local: sta
-There is a local with similar name present: std
+static analysis errors
+    missing_binding.jsonnet:1:1-3 error: undefined local: sta
+                                  There is a local with similar name present: std
modifiedxtask/src/sourcegen/kinds.rsdiffbeforeafterboth
--- a/xtask/src/sourcegen/kinds.rs
+++ b/xtask/src/sourcegen/kinds.rs
@@ -294,7 +294,7 @@
 		lit("WHITESPACE") => r"[ \t\n\r]+";
 		lit("SINGLE_LINE_SLASH_COMMENT") => r"//[^\r\n]*?(\r\n|\n)?";
 		lit("SINGLE_LINE_HASH_COMMENT") => r"#[^\r\n]*?(\r\n|\n)?";
-		lit("MULTI_LINE_COMMENT") => r"/\*([^*]|\*[^/])*\*/";
+		lit("MULTI_LINE_COMMENT") => r"/\*([^*]|\*+[^*/])*\*+/";
 		error("COMMENT_TOO_SHORT", "comment too short") => r"/\*/";
 		error("COMMENT_UNTERMINATED", "unterminated multi-line comment") =>  r"/\*([^*/]|\*[^/])+";
 		error("NO_OPERATOR", "expected operator");