git.delta.rocks / jrsonnet / refs/commits / 46ef62a1f872

difftreelog

feat(fmt) single-line objects

unzqvprpYaroslav Bolyukin2026-02-08parent: #9b1cb43.patch.diff
in: master

6 files changed

modifiedcrates/jrsonnet-formatter/src/children.rsdiffbeforeafterboth
before · crates/jrsonnet-formatter/src/children.rs
1// TODO: Return errors as trivia23use std::{fmt::Debug, mem};45use jrsonnet_rowan_parser::{6	nodes::{CustomError, Trivia, TriviaKind},7	AstNode, AstToken, SyntaxElement, SyntaxNode, TS,8};910pub type ChildTrivia = Vec<Result<Trivia, String>>;1112/// Node should have no non-trivia tokens before element13pub fn trivia_before(node: SyntaxNode, end: Option<&SyntaxElement>) -> ChildTrivia {14	let mut out = Vec::new();15	for item in node.children_with_tokens() {16		if Some(&item) == end {17			break;18		}1920		if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) {21			out.push(Ok(trivia));22		} else if CustomError::can_cast(item.kind()) {23			out.push(Err(item.to_string()));24		} else if end.is_none() {25			break;26		} else {27			assert!(28				TS![, ;].contains(item.kind()),29				"silently eaten token: {:?}",30				item.kind()31			);32		}33	}34	out35}36/// Node should have no non-trivia tokens after element37pub fn trivia_after(node: SyntaxNode, start: Option<&SyntaxElement>) -> ChildTrivia {38	if start.is_none() {39		return Vec::new();40	}41	let mut iter = node.children_with_tokens().peekable();42	while iter.peek() != start {43		iter.next();44	}45	iter.next();46	let mut out = Vec::new();47	for item in iter {48		if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) {49			out.push(Ok(trivia));50		} else if CustomError::can_cast(item.kind()) {51			out.push(Err(item.to_string()));52		} else {53			assert!(54				TS![, ;].contains(item.kind()),55				"silently eaten token: {:?}",56				item.kind()57			);58		}59	}60	out61}6263pub fn children_between<T: AstNode + Debug>(64	node: SyntaxNode,65	start: Option<&SyntaxElement>,66	end: Option<&SyntaxElement>,67	trailing: Option<ChildTrivia>,68) -> (Vec<Child<T>>, EndingComments) {69	let mut iter = node.children_with_tokens().peekable();70	if start.is_some() {71		while iter.peek() != start {72			iter.next();73		}74		iter.next();75	}76	children(77		iter.take_while(|i| Some(i) != end),78		start.is_none() && end.is_none(),79		trailing,80	)81}8283pub fn should_start_with_newline(prev_inline: Option<&ChildTrivia>, tt: &ChildTrivia) -> bool {84	count_newlines_before(tt)85		+ prev_inline86			.map(count_newlines_after)87			.unwrap_or_default()8889		// First for previous item end, second for current item90		>= 291}9293fn count_newlines_before(tt: &ChildTrivia) -> usize {94	let mut nl_count = 0;95	for t in tt {96		match t {97			Ok(t) => match t.kind() {98				TriviaKind::Whitespace => {99					nl_count += t.text().bytes().filter(|b| *b == b'\n').count();100				}101				_ => break,102			},103			Err(_) => {104				nl_count += 1;105			}106		}107	}108	nl_count109}110fn count_newlines_after(tt: &ChildTrivia) -> usize {111	let mut nl_count = 0;112	for t in tt.iter().rev() {113		match t {114			Ok(t) => match t.kind() {115				TriviaKind::Whitespace => {116					nl_count += t.text().bytes().filter(|b| *b == b'\n').count();117				}118				TriviaKind::SingleLineHashComment | TriviaKind::SingleLineSlashComment => {119					nl_count += 1;120					break;121				}122				_ => {}123			},124			Err(_) => nl_count += 1,125		}126	}127	nl_count128}129130pub fn children<T: AstNode + Debug>(131	items: impl Iterator<Item = SyntaxElement>,132	loose: bool,133	mut trailing: Option<ChildTrivia>,134) -> (Vec<Child<T>>, EndingComments) {135	let mut out = Vec::new();136	let mut current_child = None::<Child<T>>;137	let mut next = ChildTrivia::new();138	// Previous element ended, do not add more inline comments139	let mut started_next = false;140	let mut had_some = false;141142	for item in items {143		if let Some(value) = item.as_node().cloned().and_then(T::cast) {144			let before_trivia = if let Some(trailing) = trailing.take() {145				assert!(next.is_empty());146				trailing147			} else {148				mem::take(&mut next)149			};150			let last_child = current_child.replace(Child {151				// First item should not start with newline152				should_start_with_newline: had_some153					&& should_start_with_newline(154						current_child.as_ref().map(|c| &c.inline_trivia),155						&before_trivia,156					),157				before_trivia,158				value,159				inline_trivia: Vec::new(),160			});161			if let Some(last_child) = last_child {162				out.push(last_child);163			}164			had_some = true;165			started_next = false;166		} else if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) {167			let is_single_line_comment = trivia.kind() == TriviaKind::SingleLineHashComment168				|| trivia.kind() == TriviaKind::SingleLineSlashComment;169			if trailing.is_some() {170				// Someone have already parsed trivia for us171				continue;172			} else if started_next173				|| current_child.is_none()174				|| trivia.text().contains('\n') && !is_single_line_comment175			{176				next.push(Ok(trivia.clone()));177				started_next = true;178			} else {179				let cur = current_child.as_mut().expect("checked not none");180				cur.inline_trivia.push(Ok(trivia));181				if is_single_line_comment {182					started_next = true;183				}184			}185			had_some = true;186		} else if CustomError::can_cast(item.kind()) {187			next.push(Err(item.to_string()));188		} else if loose {189			if had_some {190				break;191			}192			started_next = true;193		} else {194			assert!(195				TS![, ;].contains(item.kind()),196				"silently eaten token: {:?}",197				item.kind()198			);199		}200	}201202	let ending_comments = EndingComments {203		should_start_with_newline: should_start_with_newline(204			current_child.as_ref().map(|c| &c.inline_trivia),205			&next,206		),207		trivia: next,208	};209210	if let Some(current_child) = current_child {211		out.push(current_child);212	}213214	(out, ending_comments)215}216217#[derive(Debug)]218pub struct Child<T> {219	/// If this child has two newlines above in source code, so it needs to have it in the output220	pub should_start_with_newline: bool,221	/// Comment before item, i.e222	///223	/// ```ignore224	/// // Comment225	/// item226	/// ```227	pub before_trivia: ChildTrivia,228	pub value: T,229	/// Comment after line, but located at same line230	///231	/// ```ignore232	/// item1, // Inline comment233	/// // Not inline comment234	/// item2,235	/// ```236	pub inline_trivia: ChildTrivia,237}238239pub struct EndingComments {240	/// If this child has two newlines above in source code, so it needs to have it in the output241	pub should_start_with_newline: bool,242	pub trivia: ChildTrivia,243}244impl EndingComments {245	pub fn is_empty(&self) -> bool {246		!self.should_start_with_newline && self.trivia.is_empty()247	}248	pub fn extract_trailing(&mut self) -> ChildTrivia {249		mem::take(&mut self.trivia)250	}251}
modifiedcrates/jrsonnet-formatter/src/comments.rsdiffbeforeafterboth
--- a/crates/jrsonnet-formatter/src/comments.rs
+++ b/crates/jrsonnet-formatter/src/comments.rs
@@ -72,7 +72,10 @@
 					if matches!(loc, CommentLocation::ItemInline) {
 						p!(out, str(" "));
 					}
-					p!(out, str("/* ") string(lines[0].trim().to_string()) str(" */") nl);
+					p!(out, str("/* ") string(lines[0].trim().to_string()) str(" */"));
+					if matches!(loc, CommentLocation::AboveItem | CommentLocation::EndOfItems) {
+						p!(out, nl);
+					}
 				} else if !lines.is_empty() {
 					fn common_ws_prefix<'a>(a: &'a str, b: &str) -> &'a str {
 						let offset = a
modifiedcrates/jrsonnet-formatter/src/lib.rsdiffbeforeafterboth
--- a/crates/jrsonnet-formatter/src/lib.rs
+++ b/crates/jrsonnet-formatter/src/lib.rs
@@ -3,8 +3,10 @@
 use children::{children_between, trivia_before};
 use dprint_core::formatting::{
 	condition_helpers::is_multiple_lines,
+	condition_resolvers::true_resolver,
 	ir_helpers::{new_line_group, with_indent},
-	ConditionResolver, ConditionResolverContext, LineNumber, PrintItems, PrintOptions,
+	ConditionResolver, ConditionResolverContext, LineNumber, PrintItemPath, PrintItems,
+	PrintOptions, Signal,
 };
 use hi_doc::{Formatting, SnippetBuilder};
 use jrsonnet_rowan_parser::{
@@ -12,13 +14,13 @@
 		Arg, ArgsDesc, Assertion, BinaryOperator, Bind, CompSpec, Destruct, DestructArrayPart,
 		DestructRest, Expr, ExprBase, FieldName, ForSpec, IfSpec, ImportKind, Literal, Member,
 		Name, Number, ObjBody, ObjLocal, ParamsDesc, SliceDesc, SourceFile, Stmt, Suffix, Text,
-		UnaryOperator, Visibility,
+		Trivia, TriviaKind, UnaryOperator, Visibility,
 	},
 	AstNode, AstToken as _, SyntaxToken,
 };
 
 use crate::{
-	children::{trivia_after, Child},
+	children::{trivia_after, Child, EndingComments},
 	comments::{format_comments, CommentLocation},
 };
 
@@ -27,6 +29,23 @@
 #[cfg(test)]
 mod tests;
 
+fn with_indent_eoi(cond: ConditionResolver, o: PrintItems, e: EndingComments) -> PrintItems {
+	let end_comments_items = {
+		let mut items = PrintItems::new();
+		if e.should_start_with_newline {
+			p!(&mut items, nl);
+		}
+		format_comments(&e.trivia, CommentLocation::EndOfItems, &mut items);
+		items.into_rc_path()
+	};
+	let items =
+		new_line_group(pi!(@i; items(o.into()) items(end_comments_items.into()))).into_rc_path();
+
+	let indented = with_indent(pi!(@i; nl items(items.into())));
+
+	pi!(@i; if_else("indented body", cond, items(indented))(str(" ") items(items.into())))
+}
+
 pub trait Printable {
 	fn print(&self, out: &mut PrintItems);
 }
@@ -147,6 +166,10 @@
 	($o:ident, $($t:tt)*) => {
 		pi!(@s; $o: $($t)*)
 	};
+	(&mut $o:ident, $($t:tt)*) => {
+		let om = &mut $o;
+		pi!(@s; om: $($t)*)
+	};
 }
 pub(crate) use p;
 pub(crate) use pi;
@@ -461,22 +484,53 @@
 					p!(out, str("{ }"));
 					return;
 				}
-				p!(out, str("{") >i nl);
-				for (i, mem) in children.into_iter().enumerate() {
-					if mem.should_start_with_newline && i != 0 {
-						p!(out, nl);
+
+				let source_is_multiline = children.iter().any(|c| c.triggers_multiline)
+					|| end_comments.should_start_with_newline;
+
+				let start = LineNumber::new("obj start line");
+				let end = LineNumber::new("obj end line");
+				let multi_line: ConditionResolver = if source_is_multiline {
+					true_resolver()
+				} else {
+					Rc::new(move |ctx: &mut ConditionResolverContext| {
+						is_multiple_lines(ctx, start, end)
+					})
+				};
+
+				fn gen_members(
+					children: Vec<Child<Member>>,
+					multi_line: ConditionResolver,
+				) -> PrintItems {
+					let mut _out = PrintItems::new();
+					let out = &mut _out;
+					let mut members = children.into_iter().peekable();
+					while let Some(mem) = members.next() {
+						if mem.should_start_with_newline {
+							p!(out, nl);
+						}
+						format_comments(&mem.before_trivia, CommentLocation::AboveItem, out);
+						p!(out, { mem.value });
+						let has_more = members.peek().is_some();
+						if has_more {
+							p!(out, str(","));
+						} else {
+							p!(out, if("trailing comma", multi_line, str(",")));
+						}
+						format_comments(&mem.inline_trivia, CommentLocation::ItemInline, out);
+						p!(out, if_else("member separator", multi_line, nl)(sonl));
 					}
-					format_comments(&mem.before_trivia, CommentLocation::AboveItem, out);
-					p!(out, {mem.value} str(","));
-					format_comments(&mem.inline_trivia, CommentLocation::ItemInline, out);
-					p!(out, nl);
+					_out
 				}
 
-				if end_comments.should_start_with_newline {
-					p!(out, nl);
-				}
-				format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out);
-				p!(out, <i str("}"));
+				let members_items =
+					new_line_group(gen_members(children, multi_line.clone())).into_rc_path();
+
+				let members = with_indent_eoi(multi_line, members_items.into(), end_comments);
+
+				p!(out, str("{") info(start));
+				p!(out, items(members));
+				p!(out, str("}") info(end));
 			}
 		}
 	}
modifiedcrates/jrsonnet-formatter/src/snapshots/jrsonnet_formatter__tests__complex_comments.snapdiffbeforeafterboth
--- a/crates/jrsonnet-formatter/src/snapshots/jrsonnet_formatter__tests__complex_comments.snap
+++ b/crates/jrsonnet-formatter/src/snapshots/jrsonnet_formatter__tests__complex_comments.snap
@@ -1,6 +1,6 @@
 ---
 source: crates/jrsonnet-formatter/src/tests.rs
-expression: "reformat(indoc!(\"{\n\t\t  comments: {\n\t\t\t_: '',\n\t\t\t//     Plain comment\n\t\t\ta: '',\n\n\t\t\t#    Plain comment with empty line before\n\t\t\tb: '',\n\t\t\t/*Single-line multiline comment\n\n\t\t\t*/\n\t\t\tc: '',\n\n\t\t\t/**Single-line multiline doc comment\n\n\t\t\t*/\n\t\t\tc: '',\n\n\t\t\t/**Multiline doc\n\t\t\tComment\n\t\t\t*/\n\t\t\tc: '',\n\n\t\t\t/*\n\n\tMulti-line\n\n\tcomment\n\t\t\t*/\n\t\t\td: '',\n\n\t\t\te: '', // Inline comment\n\n\t\t\tk: '',\n\n\t\t\t// Text after everything\n\t\t  },\n\t\t  comments2: {\n\t\t\tk: '',\n\t\t\t// Text after everything, but no newline above\n\t\t  },\n          spacing: {\n            a: '',\n\n            b: '',\n          },\n          noSpacing: {\n            a: '',\n            b: '',\n          },\n        }\"))"
+expression: "reformat(indoc!(\"{\n\t\t  comments: {\n\t\t\t_: '',\n\t\t\t//     Plain comment\n\t\t\ta: '',\n\n\t\t\t#    Plain comment with empty line before\n\t\t\tb: '',\n\t\t\t/*Single-line multiline comment\n\n\t\t\t*/\n\t\t\tc: '',\n\n\t\t\t/**Single-line multiline doc comment\n\n\t\t\t*/\n\t\t\tc: '',\n\n\t\t\t/**Multiline doc\n\t\t\tComment\n\t\t\t*/\n\t\t\tc: '',\n\n\t\t\t/*\n\n\tMulti-line\n\n\tcomment\n\t\t\t*/\n\t\t\td: '',\n\n\t\t\te: '', // Inline comment\n\n\t\t\tk: '',\n\n\t\t\t// Text after everything\n\t\t  },\n\t\t  comments2: {\n\t\t\tk: '',\n\t\t\t// Text after everything, but no newline above\n\t\t  },\n          spacing: {\n            a: '',\n\n            b: '',\n          },\n          noSpacing: {\n            a: '',\n            b: '',\n          },\n\n\t\t\t smallObjectWithEnding: {/*Ending comment*/},\n\t\t\t smallObjectWithFieldAndEnding: {a: 11/*Ending comment*/},\n\t\t\t smallObjectWithFieldAndEnding2: {/*Start*/a: 11/*Ending comment*/},\n        }\"))"
 ---
 {
 	comments: {
@@ -50,4 +50,13 @@
 		a: '',
 		b: '',
 	},
+
+	smallObjectWithEnding: {
+		/* Ending comment */
+	},
+	smallObjectWithFieldAndEnding: { a: 11 /* Ending comment */ },
+	smallObjectWithFieldAndEnding2: {
+		/* Start */
+		a: 11, /* Ending comment */
+	},
 }
addedcrates/jrsonnet-formatter/src/snapshots/jrsonnet_formatter__tests__complex_nested.snapdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-formatter/src/snapshots/jrsonnet_formatter__tests__complex_nested.snap
@@ -0,0 +1,41 @@
+---
+source: crates/jrsonnet-formatter/src/tests.rs
+expression: "reformat(indoc!(\"\n\t\t\t{\n\t\t\t\tkubernetes: {\n\t\t\t\t deployment: {\n\t\t\t\t\tapiVersion: 'apps/v1',\n\t\t\t\t\tkind: 'Deployment',\n\t\t\t\t\tmetadata: {\n\t\t\t\t\t  name: 'myapp',\n\t\t\t\t\t  labels: { app: 'myapp', version: 'v1' },\n\t\t\t\t\t},\n\t\t\t\t\tspec: {\n\t\t\t\t\t  replicas: 3,\n\t\t\t\t\t  selector: { matchLabels: { app: 'myapp' } },\n\t\t\t\t\t  template: {\n\t\t\t\t\t\t metadata: { labels: { app: 'myapp' } },\n\t\t\t\t\t\t spec: {\n\t\t\t\t\t\t\tcontainers: [\n\t\t\t\t\t\t\t  {\n\t\t\t\t\t\t\t\t name: 'myapp',\n\t\t\t\t\t\t\t\t image: 'myapp:latest',\n\t\t\t\t\t\t\t\t ports: [{ containerPort: 8080 }],\n\t\t\t\t\t\t\t\t env: [\n\t\t\t\t\t\t\t\t\t{ name: 'FOO', value: 'bar' },\n\t\t\t\t\t\t\t\t\t{ name: 'BAZ', valueFrom: { secretKeyRef: { name: 'mysecret', key: 'password' } } },\n\t\t\t\t\t\t\t\t ],\n\t\t\t\t\t\t\t  },\n\t\t\t\t\t\t\t],\n\t\t\t\t\t\t },\n\t\t\t\t\t  },\n\t\t\t\t\t},\n\t\t\t\t },\n\t\t\t  },\n\t\t\t}\n\t\t\"))"
+---
+{
+	kubernetes: {
+		deployment: {
+			apiVersion: 'apps/v1',
+			kind: 'Deployment',
+			metadata: {
+				name: 'myapp',
+				labels: { app: 'myapp', version: 'v1' },
+			},
+			spec: {
+				replicas: 3,
+				selector: { matchLabels: { app: 'myapp' } },
+				template: {
+					metadata: { labels: { app: 'myapp' } },
+					spec: {
+						containers: [
+							{
+								name: 'myapp',
+								image: 'myapp:latest',
+								ports: [
+									{ containerPort: 8080 },
+								],
+								env: [
+									{ name: 'FOO', value: 'bar' },
+									{
+										name: 'BAZ',
+										valueFrom: { secretKeyRef: { name: 'mysecret', key: 'password' } },
+									},
+								],
+							},
+						],
+					},
+				},
+			},
+		},
+	},
+}
modifiedcrates/jrsonnet-formatter/src/tests.rsdiffbeforeafterboth
--- a/crates/jrsonnet-formatter/src/tests.rs
+++ b/crates/jrsonnet-formatter/src/tests.rs
@@ -74,6 +74,10 @@
             a: '',
             b: '',
           },
+
+			 smallObjectWithEnding: {/*Ending comment*/},
+			 smallObjectWithFieldAndEnding: {a: 11/*Ending comment*/},
+			 smallObjectWithFieldAndEnding2: {/*Start*/a: 11/*Ending comment*/},
         }"
 	)));
 }
@@ -104,3 +108,43 @@
 		"
 	)));
 }
+
+#[test]
+fn complex_nested() {
+	insta::assert_snapshot!(reformat(indoc!(
+		"
+			{
+				kubernetes: {
+				 deployment: {
+					apiVersion: 'apps/v1',
+					kind: 'Deployment',
+					metadata: {
+					  name: 'myapp',
+					  labels: { app: 'myapp', version: 'v1' },
+					},
+					spec: {
+					  replicas: 3,
+					  selector: { matchLabels: { app: 'myapp' } },
+					  template: {
+						 metadata: { labels: { app: 'myapp' } },
+						 spec: {
+							containers: [
+							  {
+								 name: 'myapp',
+								 image: 'myapp:latest',
+								 ports: [{ containerPort: 8080 }],
+								 env: [
+									{ name: 'FOO', value: 'bar' },
+									{ name: 'BAZ', valueFrom: { secretKeyRef: { name: 'mysecret', key: 'password' } } },
+								 ],
+							  },
+							],
+						 },
+					  },
+					},
+				 },
+			  },
+			}
+		"
+	)));
+}