From d14f5fd9b38864fae72fcdf378c2e1712cc80cde Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Sun, 08 Feb 2026 19:50:14 +0000 Subject: [PATCH] refactor: split pretty-printer into a library crate --- --- a/Cargo.lock +++ b/Cargo.lock @@ -631,16 +631,23 @@ version = "0.5.0-pre97" dependencies = [ "clap", - "dprint-core", "hi-doc", "indoc", - "insta", - "jrsonnet-rowan-parser", + "jrsonnet-formatter", "tempfile", "thiserror", ] [[package]] +name = "jrsonnet-formatter" +version = "0.5.0-pre97" +dependencies = [ + "dprint-core", + "hi-doc", + "jrsonnet-rowan-parser", +] + +[[package]] name = "jrsonnet-gcmodule" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ jrsonnet-stdlib = { path = "./crates/jrsonnet-stdlib", version = "0.5.0-pre97" } jrsonnet-cli = { path = "./crates/jrsonnet-cli", version = "0.5.0-pre97" } jrsonnet-types = { path = "./crates/jrsonnet-types", version = "0.5.0-pre97" } +jrsonnet-formatter = { path = "./crates/jrsonnet-formatter", version = "0.5.0-pre97" } jrsonnet-gcmodule = { version = "0.4.1" } # Diagnostics. # hi-doc is my library, which handles text formatting very well, but isn't polished enough yet --- a/cmds/jrsonnet-fmt/Cargo.toml +++ b/cmds/jrsonnet-fmt/Cargo.toml @@ -10,11 +10,9 @@ workspace = true [dependencies] -dprint-core.workspace = true -jrsonnet-rowan-parser.workspace = true -insta.workspace = true indoc.workspace = true hi-doc.workspace = true clap = { workspace = true, features = ["derive"] } tempfile.workspace = true thiserror.workspace = true +jrsonnet-formatter.workspace = true --- a/cmds/jrsonnet-fmt/src/children.rs +++ /dev/null @@ -1,251 +0,0 @@ -// TODO: Return errors as trivia - -use std::{fmt::Debug, mem}; - -use jrsonnet_rowan_parser::{ - nodes::{CustomError, Trivia, TriviaKind}, - AstNode, AstToken, SyntaxElement, SyntaxNode, TS, -}; - -pub type ChildTrivia = Vec>; - -/// Node should have no non-trivia tokens before element -pub fn trivia_before(node: SyntaxNode, end: Option<&SyntaxElement>) -> ChildTrivia { - let mut out = Vec::new(); - for item in node.children_with_tokens() { - if Some(&item) == end { - break; - } - - if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) { - out.push(Ok(trivia)); - } else if CustomError::can_cast(item.kind()) { - out.push(Err(item.to_string())); - } else if end.is_none() { - break; - } else { - assert!( - TS![, ;].contains(item.kind()), - "silently eaten token: {:?}", - item.kind() - ); - } - } - out -} -/// Node should have no non-trivia tokens after element -pub fn trivia_after(node: SyntaxNode, start: Option<&SyntaxElement>) -> ChildTrivia { - if start.is_none() { - return Vec::new(); - } - let mut iter = node.children_with_tokens().peekable(); - while iter.peek() != start { - iter.next(); - } - iter.next(); - let mut out = Vec::new(); - for item in iter { - if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) { - out.push(Ok(trivia)); - } else if CustomError::can_cast(item.kind()) { - out.push(Err(item.to_string())); - } else { - assert!( - TS![, ;].contains(item.kind()), - "silently eaten token: {:?}", - item.kind() - ); - } - } - out -} - -pub fn children_between( - node: SyntaxNode, - start: Option<&SyntaxElement>, - end: Option<&SyntaxElement>, - trailing: Option, -) -> (Vec>, EndingComments) { - let mut iter = node.children_with_tokens().peekable(); - if start.is_some() { - while iter.peek() != start { - iter.next(); - } - iter.next(); - } - children( - iter.take_while(|i| Some(i) != end), - start.is_none() && end.is_none(), - trailing, - ) -} - -pub fn should_start_with_newline(prev_inline: Option<&ChildTrivia>, tt: &ChildTrivia) -> bool { - count_newlines_before(tt) - + prev_inline - .map(count_newlines_after) - .unwrap_or_default() - - // First for previous item end, second for current item - >= 2 -} - -fn count_newlines_before(tt: &ChildTrivia) -> usize { - let mut nl_count = 0; - for t in tt { - match t { - Ok(t) => match t.kind() { - TriviaKind::Whitespace => { - nl_count += t.text().bytes().filter(|b| *b == b'\n').count(); - } - _ => break, - }, - Err(_) => { - nl_count += 1; - } - } - } - nl_count -} -fn count_newlines_after(tt: &ChildTrivia) -> usize { - let mut nl_count = 0; - for t in tt.iter().rev() { - match t { - Ok(t) => match t.kind() { - TriviaKind::Whitespace => { - nl_count += t.text().bytes().filter(|b| *b == b'\n').count(); - } - TriviaKind::SingleLineHashComment | TriviaKind::SingleLineSlashComment => { - nl_count += 1; - break; - } - _ => {} - }, - Err(_) => nl_count += 1, - } - } - nl_count -} - -pub fn children( - items: impl Iterator, - loose: bool, - mut trailing: Option, -) -> (Vec>, EndingComments) { - let mut out = Vec::new(); - let mut current_child = None::>; - let mut next = ChildTrivia::new(); - // Previous element ended, do not add more inline comments - let mut started_next = false; - let mut had_some = false; - - for item in items { - if let Some(value) = item.as_node().cloned().and_then(T::cast) { - let before_trivia = if let Some(trailing) = trailing.take() { - assert!(next.is_empty()); - trailing - } else { - mem::take(&mut next) - }; - let last_child = current_child.replace(Child { - // First item should not start with newline - should_start_with_newline: had_some - && should_start_with_newline( - current_child.as_ref().map(|c| &c.inline_trivia), - &before_trivia, - ), - before_trivia, - value, - inline_trivia: Vec::new(), - }); - if let Some(last_child) = last_child { - out.push(last_child); - } - had_some = true; - started_next = false; - } else if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) { - let is_single_line_comment = trivia.kind() == TriviaKind::SingleLineHashComment - || trivia.kind() == TriviaKind::SingleLineSlashComment; - if trailing.is_some() { - // Someone have already parsed trivia for us - continue; - } else if started_next - || current_child.is_none() - || trivia.text().contains('\n') && !is_single_line_comment - { - next.push(Ok(trivia.clone())); - started_next = true; - } else { - let cur = current_child.as_mut().expect("checked not none"); - cur.inline_trivia.push(Ok(trivia)); - if is_single_line_comment { - started_next = true; - } - } - had_some = true; - } else if CustomError::can_cast(item.kind()) { - next.push(Err(item.to_string())); - } else if loose { - if had_some { - break; - } - started_next = true; - } else { - assert!( - TS![, ;].contains(item.kind()), - "silently eaten token: {:?}", - item.kind() - ); - } - } - - let ending_comments = EndingComments { - should_start_with_newline: should_start_with_newline( - current_child.as_ref().map(|c| &c.inline_trivia), - &next, - ), - trivia: next, - }; - - if let Some(current_child) = current_child { - out.push(current_child); - } - - (out, ending_comments) -} - -#[derive(Debug)] -pub struct Child { - /// If this child has two newlines above in source code, so it needs to have it in the output - pub should_start_with_newline: bool, - /// Comment before item, i.e - /// - /// ```ignore - /// // Comment - /// item - /// ``` - pub before_trivia: ChildTrivia, - pub value: T, - /// Comment after line, but located at same line - /// - /// ```ignore - /// item1, // Inline comment - /// // Not inline comment - /// item2, - /// ``` - pub inline_trivia: ChildTrivia, -} - -pub struct EndingComments { - /// If this child has two newlines above in source code, so it needs to have it in the output - pub should_start_with_newline: bool, - pub trivia: ChildTrivia, -} -impl EndingComments { - pub fn is_empty(&self) -> bool { - !self.should_start_with_newline && self.trivia.is_empty() - } - pub fn extract_trailing(&mut self) -> ChildTrivia { - mem::take(&mut self.trivia) - } -} --- a/cmds/jrsonnet-fmt/src/comments.rs +++ /dev/null @@ -1,181 +0,0 @@ -use std::string::String; - -use dprint_core::formatting::PrintItems; -use jrsonnet_rowan_parser::{nodes::TriviaKind, AstToken}; - -use crate::{children::ChildTrivia, p, pi}; - -pub enum CommentLocation { - /// Above local, field, other things - AboveItem, - /// After item - ItemInline, - /// After all items in object - EndOfItems, -} - -#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] -pub fn format_comments(comments: &ChildTrivia, loc: CommentLocation, out: &mut PrintItems) { - for c in comments { - let Ok(c) = c else { - let mut text = c.as_ref().unwrap_err() as &str; - while !text.is_empty() { - let pos = text.find(['\n', '\t']).unwrap_or(text.len()); - let sliced = &text[..pos]; - p!(out, string(sliced.to_string())); - text = &text[pos..]; - if !text.is_empty() { - match text.as_bytes()[0] { - b'\n' => p!(out, nl), - b'\t' => p!(out, tab), - _ => unreachable!(), - } - text = &text[1..]; - } - } - continue; - }; - match c.kind() { - TriviaKind::Whitespace => {} - TriviaKind::MultiLineComment => { - let mut text = c - .text() - .strip_prefix("/*") - .expect("ml comment starts with /*") - .strip_suffix("*/") - .expect("ml comment ends with */"); - // doc-style comment, /** - let doc = if text.starts_with('*') { - text = &text[1..]; - true - } else { - false - }; - // Is comment starts with text immediatly, i.e /*text - let mut immediate_start = true; - let mut lines = text - .split('\n') - .map(|l| l.trim_end().to_string()) - .skip_while(|l| { - if l.is_empty() { - immediate_start = false; - true - } else { - false - } - }) - .collect::>(); - while lines.last().is_some_and(String::is_empty) { - lines.pop(); - } - if lines.len() == 1 && !doc { - if matches!(loc, CommentLocation::ItemInline) { - p!(out, str(" ")); - } - p!(out, str("/* ") string(lines[0].trim().to_string()) str(" */") nl); - } else if !lines.is_empty() { - fn common_ws_prefix<'a>(a: &'a str, b: &str) -> &'a str { - let offset = a - .bytes() - .zip(b.bytes()) - .take_while(|(a, b)| a == b && (a.is_ascii_whitespace() || *a == b'*')) - .count(); - &a[..offset] - } - // First line is not empty, extract ws prefix of it - let mut common_ws_padding = (if immediate_start && lines.len() > 1 { - common_ws_prefix(&lines[1], &lines[1]) - } else { - common_ws_prefix(&lines[0], &lines[0]) - }) - .to_string(); - for line in lines - .iter() - .skip(if immediate_start { 2 } else { 1 }) - .filter(|l| !l.is_empty()) - { - common_ws_padding = common_ws_prefix(&common_ws_padding, line).to_string(); - } - for line in lines - .iter_mut() - .skip(usize::from(immediate_start)) - .filter(|l| !l.is_empty()) - { - *line = line - .strip_prefix(&common_ws_padding) - .expect("all non-empty lines start with this padding") - .to_string(); - } - - p!(out, str("/*")); - if doc { - p!(out, str("*")); - } - p!(out, nl); - for mut line in lines { - if doc { - p!(out, str(" *")); - } - if line.is_empty() { - p!(out, nl); - } else { - if doc { - p!(out, str(" ")); - } - while let Some(new_line) = line.strip_prefix('\t') { - if doc { - p!(out, str(" ")); - } else { - p!(out, tab); - } - line = new_line.to_string(); - } - p!(out, string(line.to_string()) nl); - } - } - if doc { - p!(out, str(" ")); - } - p!(out, str("*/") nl); - } - } - // TODO: Keep common padding for multiple continous lines of single-line comments - // I.e - // ``` - // # Line1 - // # Line2 - // ``` - // Should be reformatted as - // ``` - // # Line1 - // # Line2 - // ``` - // But currently comment formatter is not aware of continous comment lines, and reformats it as - // ``` - // # Line1 - // # Line2 - // ``` - TriviaKind::SingleLineHashComment => { - if matches!(loc, CommentLocation::ItemInline) { - p!(out, str(" ")); - } - p!(out, str("# ") string(c.text().strip_prefix('#').expect("hash comment starts with #").trim().to_string())); - if !matches!(loc, CommentLocation::ItemInline) { - p!(out, nl); - } - } - TriviaKind::SingleLineSlashComment => { - if matches!(loc, CommentLocation::ItemInline) { - p!(out, str(" ")); - } - p!(out, str("// ") string(c.text().strip_prefix("//").expect("comment starts with //").trim().to_string())); - if !matches!(loc, CommentLocation::ItemInline) { - p!(out, nl); - } - } - // Garbage in - garbage out - TriviaKind::ErrorCommentTooShort => p!(out, str("/*/")), - TriviaKind::ErrorCommentUnterminated => p!(out, string(c.text().to_string())), - } - } -} --- a/cmds/jrsonnet-fmt/src/main.rs +++ b/cmds/jrsonnet-fmt/src/main.rs @@ -1,755 +1,7 @@ -use std::{ - any::type_name, - fs, - io::{self, Write}, - path::PathBuf, - process, - rc::Rc, -}; +use std::{fs, io}; -use children::{children_between, trivia_before}; use clap::Parser; -use dprint_core::formatting::{ - condition_helpers::is_multiple_lines, condition_resolvers::true_resolver, - ConditionResolverContext, LineNumber, PrintItems, PrintOptions, -}; -use hi_doc::Formatting; -use jrsonnet_rowan_parser::{ - nodes::{ - 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, - }, - AstNode, AstToken as _, SyntaxToken, -}; - -use crate::{ - children::trivia_after, - comments::{format_comments, CommentLocation}, -}; - -mod children; -mod comments; -#[cfg(test)] -mod tests; - -pub trait Printable { - fn print(&self, out: &mut PrintItems); -} - -macro_rules! pi { - (@i; $($t:tt)*) => {{ - #[allow(unused_mut)] - let mut o = dprint_core::formatting::PrintItems::new(); - pi!(@s; o: $($t)*); - o - }}; - (@s; $o:ident: str($e:expr $(,)?) $($t:tt)*) => {{ - $o.push_string($e.to_owned()); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: string($e:expr $(,)?) $($t:tt)*) => {{ - $o.push_string($e); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: nl $($t:tt)*) => {{ - $o.push_signal(dprint_core::formatting::Signal::NewLine); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: tab $($t:tt)*) => {{ - $o.push_signal(dprint_core::formatting::Signal::Tab); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: >i $($t:tt)*) => {{ - $o.push_signal(dprint_core::formatting::Signal::StartIndent); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: {{ - $o.push_signal(dprint_core::formatting::Signal::FinishIndent); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: info($v:expr) $($t:tt)*) => {{ - $o.push_info($v); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: if($s:literal, $cond:expr, $($i:tt)*) $($t:tt)*) => {{ - $o.push_condition(dprint_core::formatting::conditions::if_true( - $s, - $cond.clone(), - { - let mut o = PrintItems::new(); - p!(o, $($i)*); - o - }, - )); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: if_else($s:literal, $cond:expr, $($i:tt)*)($($e:tt)+) $($t:tt)*) => {{ - $o.push_condition(dprint_core::formatting::conditions::if_true_or( - $s, - $cond.clone(), - { - let mut o = PrintItems::new(); - p!(o, $($i)*); - o - }, - { - let mut o = PrintItems::new(); - p!(o, $($e)*); - o - }, - )); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: if_not($s:literal, $cond:expr, $($e:tt)*) $($t:tt)*) => {{ - $o.push_condition(dprint_core::formatting::conditions::if_true_or( - $s, - $cond.clone(), - { - let o = PrintItems::new(); - o - }, - { - let mut o = PrintItems::new(); - p!(o, $($e)*); - o - }, - )); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: {$expr:expr} $($t:tt)*) => {{ - $expr.print($o); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: items($expr:expr) $($t:tt)*) => {{ - $o.extend($expr); - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: if ($e:expr)($($then:tt)*) $($t:tt)*) => {{ - if $e { - pi!(@s; $o: $($then)*); - } - pi!(@s; $o: $($t)*); - }}; - (@s; $o:ident: ifelse ($e:expr)($($then:tt)*)($($else:tt)*) $($t:tt)*) => {{ - if $e { - pi!(@s; $o: $($then)*); - } else { - pi!(@s; $o: $($else)*); - } - pi!(@s; $o: $($t)*); - }}; - (@s; $i:ident:) => {} -} -macro_rules! p { - ($o:ident, $($t:tt)*) => { - pi!(@s; $o: $($t)*) - }; -} -pub(crate) use p; -pub(crate) use pi; - -impl

Printable for Option

-where - P: Printable, -{ - fn print(&self, out: &mut PrintItems) { - if let Some(v) = self { - v.print(out); - } else { - p!( - out, - string(format!( - "/*missing {}*/", - type_name::

().replace("jrsonnet_rowan_parser::generated::nodes::", "") - ),) - ); - } - } -} - -impl Printable for SyntaxToken { - fn print(&self, out: &mut PrintItems) { - p!(out, string(self.to_string())); - } -} - -impl Printable for Text { - fn print(&self, out: &mut PrintItems) { - p!(out, string(format!("{}", self))); - } -} -impl Printable for Number { - fn print(&self, out: &mut PrintItems) { - p!(out, string(format!("{}", self))); - } -} - -impl Printable for Name { - fn print(&self, out: &mut PrintItems) { - p!(out, { self.ident_lit() }); - } -} - -impl Printable for DestructRest { - fn print(&self, out: &mut PrintItems) { - p!(out, str("...")); - if let Some(name) = self.into() { - p!(out, { name }); - } - } -} - -impl Printable for Destruct { - fn print(&self, out: &mut PrintItems) { - match self { - Self::DestructFull(f) => { - p!(out, { f.name() }); - } - Self::DestructSkip(_) => p!(out, str("?")), - Self::DestructArray(a) => { - p!(out, str("[") >i nl); - for el in a.destruct_array_parts() { - match el { - DestructArrayPart::DestructArrayElement(e) => { - p!(out, {e.destruct()} str(",") nl); - } - DestructArrayPart::DestructRest(d) => { - p!(out, {d} str(",") nl); - } - } - } - p!(out, { - p!(out, str("{") >i nl); - for item in o.destruct_object_fields() { - p!(out, { item.field() }); - if let Some(des) = item.destruct() { - p!(out, str(": ") {des}); - } - if let Some(def) = item.expr() { - p!(out, str(" = ") {def}); - } - p!(out, str(",") nl); - } - if let Some(rest) = o.destruct_rest() { - p!(out, {rest} nl); - } - p!(out, { - if let Some(id) = f.id() { - p!(out, { id }); - } else if let Some(str) = f.text() { - p!(out, { str }); - } else { - p!(out, str("/*missing FieldName*/")); - } - } - Self::FieldNameDynamic(d) => { - p!(out, str("[") {d.expr()} str("]")); - } - } - } -} - -impl Printable for Visibility { - fn print(&self, out: &mut PrintItems) { - p!(out, string(self.to_string())); - } -} - -impl Printable for ObjLocal { - fn print(&self, out: &mut PrintItems) { - p!(out, str("local ") {self.bind()}); - } -} - -impl Printable for Assertion { - fn print(&self, out: &mut PrintItems) { - p!(out, str("assert ") {self.condition()}); - if self.colon_token().is_some() || self.message().is_some() { - p!(out, str(": ") {self.message()}); - } - } -} - -impl Printable for ParamsDesc { - fn print(&self, out: &mut PrintItems) { - p!(out, str("(") >i nl); - for param in self.params() { - p!(out, { param.destruct() }); - if param.assign_token().is_some() || param.expr().is_some() { - p!(out, str(" = ") {param.expr()}); - } - p!(out, str(",") nl); - } - p!(out, i nl)); - let (children, end_comments) = children_between::( - self.syntax().clone(), - self.l_paren_token().map(Into::into).as_ref(), - self.r_paren_token().map(Into::into).as_ref(), - None, - ); - let mut args = children.into_iter().peekable(); - while let Some(ele) = args.next() { - if ele.should_start_with_newline { - p!(out, nl); - } - format_comments(&ele.before_trivia, CommentLocation::AboveItem, out); - let arg = ele.value; - if arg.name().is_some() || arg.assign_token().is_some() { - p!(out, {arg.name()} str(" = ")); - } - let comma_between = if args.peek().is_some() { - true_resolver() - } else { - multi_line.clone() - }; - p!(out, {arg.expr()} if("arg comma", comma_between, str(",") if_not("between args", multi_line, str(" ")))); - format_comments(&ele.inline_trivia, CommentLocation::ItemInline, out); - p!(out, if("between args", multi_line, nl)); - } - if end_comments.should_start_with_newline { - p!(out, nl); - } - format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); - p!(out, if("end args", multi_line, { - p!(out, { b.obj_local() }); - } - Self::MemberAssertStmt(ass) => { - p!(out, { ass.assertion() }); - } - Self::MemberFieldNormal(n) => { - p!(out, {n.field_name()} if(n.plus_token().is_some())({n.plus_token()}) {n.visibility()} str(" ") {n.expr()}); - } - Self::MemberFieldMethod(m) => { - p!(out, {m.field_name()} {m.params_desc()} {m.visibility()} str(" ") {m.expr()}); - } - } - } -} - -impl Printable for ObjBody { - fn print(&self, out: &mut PrintItems) { - match self { - Self::ObjBodyComp(l) => { - let (children, mut end_comments) = children_between::( - l.syntax().clone(), - l.l_brace_token().map(Into::into).as_ref(), - Some( - &(l.comp_specs() - .next() - .expect("at least one spec is defined") - .syntax() - .clone()) - .into(), - ), - None, - ); - let trailing_for_comp = end_comments.extract_trailing(); - p!(out, str("{") >i nl); - for mem in children { - if mem.should_start_with_newline { - p!(out, nl); - } - format_comments(&mem.before_trivia, CommentLocation::AboveItem, out); - p!(out, {mem.value} str(",")); - format_comments(&mem.inline_trivia, CommentLocation::ItemInline, out); - p!(out, nl); - } - - if end_comments.should_start_with_newline { - p!(out, nl); - } - format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); - - let (compspecs, end_comments) = children_between::( - l.syntax().clone(), - l.member_comps() - .last() - .map(|m| m.syntax().clone()) - .map(Into::into) - .or_else(|| l.l_brace_token().map(Into::into)) - .as_ref(), - l.r_brace_token().map(Into::into).as_ref(), - Some(trailing_for_comp), - ); - for mem in compspecs { - if mem.should_start_with_newline { - p!(out, nl); - } - format_comments(&mem.before_trivia, CommentLocation::AboveItem, out); - p!(out, { mem.value }); - format_comments(&mem.inline_trivia, CommentLocation::ItemInline, out); - } - if end_comments.should_start_with_newline { - p!(out, nl); - } - format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); - - p!(out, nl { - let (children, end_comments) = children_between::( - l.syntax().clone(), - l.l_brace_token().map(Into::into).as_ref(), - l.r_brace_token().map(Into::into).as_ref(), - None, - ); - if children.is_empty() && end_comments.is_empty() { - 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); - } - format_comments(&mem.before_trivia, CommentLocation::AboveItem, out); - p!(out, {mem.value} str(",")); - format_comments(&mem.inline_trivia, CommentLocation::ItemInline, out); - p!(out, nl); - } - - if end_comments.should_start_with_newline { - p!(out, nl); - } - format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); - p!(out, { - p!(out, {d.into()} str(" = ") {d.value()}); - } - Self::BindFunction(f) => { - p!(out, {f.name()} {f.params()} str(" = ") {f.value()}); - } - } - } -} -impl Printable for Literal { - fn print(&self, out: &mut PrintItems) { - p!(out, string(self.syntax().to_string())); - } -} -impl Printable for ImportKind { - fn print(&self, out: &mut PrintItems) { - p!(out, string(self.syntax().to_string())); - } -} -impl Printable for ForSpec { - fn print(&self, out: &mut PrintItems) { - p!(out, str("for ") {self.bind()} str(" in ") {self.expr()}); - } -} -impl Printable for IfSpec { - fn print(&self, out: &mut PrintItems) { - p!(out, str("if ") {self.expr()}); - } -} -impl Printable for CompSpec { - fn print(&self, out: &mut PrintItems) { - match self { - Self::ForSpec(f) => f.print(out), - Self::IfSpec(i) => i.print(out), - } - } -} -impl Printable for Expr { - fn print(&self, out: &mut PrintItems) { - let (stmts, _ending) = children_between::( - self.syntax().clone(), - None, - self.expr_base() - .as_ref() - .map(ExprBase::syntax) - .cloned() - .map(Into::into) - .as_ref(), - None, - ); - for stmt in stmts { - p!(out, { stmt.value }); - } - p!(out, { self.expr_base() }); - let (suffixes, _ending) = children_between::( - self.syntax().clone(), - self.expr_base() - .as_ref() - .map(ExprBase::syntax) - .cloned() - .map(Into::into) - .as_ref(), - None, - None, - ); - for suffix in suffixes { - p!(out, { suffix.value }); - } - } -} -impl Printable for Suffix { - fn print(&self, out: &mut PrintItems) { - match self { - Self::SuffixIndex(i) => { - if i.question_mark_token().is_some() { - p!(out, str("?")); - } - p!(out, str(".") {i.index()}); - } - Self::SuffixIndexExpr(e) => { - if e.question_mark_token().is_some() { - p!(out, str(".?")); - } - p!(out, str("[") {e.index()} str("]")); - } - Self::SuffixSlice(d) => { - p!(out, { d.slice_desc() }); - } - Self::SuffixApply(a) => { - p!(out, { a.args_desc() }); - } - } - } -} -impl Printable for Stmt { - fn print(&self, out: &mut PrintItems) { - match self { - Self::StmtLocal(l) => { - let (binds, end_comments) = children_between::( - l.syntax().clone(), - l.local_kw_token().map(Into::into).as_ref(), - l.semi_token().map(Into::into).as_ref(), - None, - ); - if binds.len() == 1 { - let bind = &binds[0]; - format_comments(&bind.before_trivia, CommentLocation::AboveItem, out); - p!(out, str("local ") {bind.value}); - // TODO: keep end_comments, child.inline_trivia somehow, force multiple locals formatting in case of presence? - } else { - p!(out,str("local") >i nl); - for bind in binds { - if bind.should_start_with_newline { - p!(out, nl); - } - format_comments(&bind.before_trivia, CommentLocation::AboveItem, out); - p!(out, {bind.value} str(",")); - format_comments(&bind.inline_trivia, CommentLocation::ItemInline, out); - p!(out, nl); - } - if end_comments.should_start_with_newline { - p!(out, nl); - } - format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); - p!(out, { - p!(out, {a.assertion()} str(";") nl); - } - } - } -} -impl Printable for ExprBase { - fn print(&self, out: &mut PrintItems) { - match self { - Self::ExprBinary(b) => { - p!(out, {b.lhs_work()} str(" ") {b.binary_operator()} str(" ") {b.rhs_work()}); - } - Self::ExprUnary(u) => p!(out, {u.unary_operator()} {u.rhs()}), - // Self::ExprSlice(s) => { - // p!(new: {s.expr()} {s.slice_desc()}) - // } - // Self::ExprIndex(i) => { - // p!(new: {i.expr()} str(".") {i.index()}) - // } - // Self::ExprIndexExpr(i) => p!(new: {i.base()} str("[") {i.index()} str("]")), - // Self::ExprApply(a) => { - // let mut pi = p!(new: {a.expr()} {a.args_desc()}); - // if a.tailstrict_kw_token().is_some() { - // p!(out,str(" tailstrict")); - // } - // pi - // } - Self::ExprObjExtend(ex) => { - p!(out, {ex.lhs_work()} str(" ") {ex.rhs_work()}); - } - Self::ExprParened(p) => { - p!(out, str("(") {p.expr()} str(")")); - } - Self::ExprString(s) => p!(out, { s.text() }), - Self::ExprNumber(n) => p!(out, { n.number() }), - Self::ExprArray(a) => { - p!(out, str("[") >i nl); - for el in a.exprs() { - p!(out, {el} str(",") nl); - } - p!(out, { - p!(out, { obj.obj_body() }); - } - Self::ExprArrayComp(arr) => { - p!(out, str("[") {arr.expr()}); - for spec in arr.comp_specs() { - p!(out, str(" ") {spec}); - } - p!(out, str("]")); - } - Self::ExprImport(v) => { - p!(out, {v.import_kind()} str(" ") {v.text()}); - } - Self::ExprVar(n) => p!(out, { n.name() }), - // Self::ExprLocal(l) => { - // } - Self::ExprIfThenElse(ite) => { - p!(out, str("if ") {ite.cond()} str(" then ") {ite.then().map(|t| t.expr())}); - if ite.else_kw_token().is_some() || ite.else_().is_some() { - p!(out, str(" else ") {ite.else_().map(|t| t.expr())}); - } - } - Self::ExprFunction(f) => p!(out, str("function") {f.params_desc()} nl {f.expr()}), - // Self::ExprAssert(a) => p!(new: {a.assertion()} str("; ") {a.expr()}), - Self::ExprError(e) => p!(out, str("error ") {e.expr()}), - Self::ExprLiteral(l) => { - p!(out, { l.literal() }); - } - } - } -} - -impl Printable for SourceFile { - fn print(&self, out: &mut PrintItems) { - let before = trivia_before( - self.syntax().clone(), - self.expr() - .map(|e| e.syntax().clone()) - .map(Into::into) - .as_ref(), - ); - let after = trivia_after( - self.syntax().clone(), - self.expr() - .map(|e| e.syntax().clone()) - .map(Into::into) - .as_ref(), - ); - format_comments(&before, CommentLocation::AboveItem, out); - p!(out, {self.expr()} nl); - format_comments(&after, CommentLocation::EndOfItems, out); - } -} - -struct FormatOptions { - // 0 for hard tabs - indent: u8, -} -fn format(input: &str, opts: &FormatOptions) -> Option { - let (parsed, errors) = jrsonnet_rowan_parser::parse(input); - if !errors.is_empty() { - let mut builder = hi_doc::SnippetBuilder::new(input); - for error in errors { - builder - .error(hi_doc::Text::fragment( - format!("{:?}", error.error), - Formatting::default(), - )) - .range( - error.range.start().into() - ..=(usize::from(error.range.end()) - 1).max(error.range.start().into()), - ) - .build(); - } - let snippet = builder.build(); - let ansi = hi_doc::source_to_ansi(&snippet); - eprintln!("{ansi}"); - // It is possible to recover from this failure, but the output may be broken, as formatter is free to skip - // ERROR rowan nodes. - // Recovery needs to be enabled for LSP, though. - // - // TODO: Verify how formatter interacts in cases of missing positional values, i.e `if cond then /*missing Expr*/ else residual`. - return None; - } - Some(dprint_core::formatting::format( - || { - let mut out = PrintItems::new(); - parsed.print(&mut out); - out - }, - PrintOptions { - indent_width: if opts.indent == 0 { - // Reasonable max length for both 2 and 4 space sized tabs. - 3 - } else { - opts.indent - }, - max_width: 100, - use_tabs: opts.indent == 0, - new_line_text: "\n", - }, - )) -} +use jrsonnet_formatter::{format, FormatOptions}; #[derive(Parser)] #[allow(clippy::struct_excessive_bools)] @@ -768,9 +20,7 @@ #[arg(long)] test: bool, /// Number of spaces to indent with - /// - /// 0 for guess from input (default), and use hard tabs if unable to guess. - #[arg(long, default_value = "0")] + #[arg(long, default_value = "2")] indent: u8, /// Force hard tab for indentation #[arg(long)] @@ -819,7 +69,7 @@ let mut convergence_tmp; // https://github.com/dprint/dprint/pull/423 loop { - let Some(reformatted) = format( + let reformatted = match format( &formatted, &FormatOptions { indent: if opts.indent == 0 || opts.hard_tabs { @@ -828,8 +78,14 @@ opts.indent }, }, - ) else { - return Err(Error::Parse); + ) { + Ok(v) => v, + Err(e) => { + let snippet = e.build(); + let ansi = hi_doc::source_to_ansi(&snippet); + eprintln!("{ansi}"); + return Err(Error::Parse); + } }; convergence_tmp = reformatted.trim().to_owned(); if formatted == convergence_tmp { --- a/cmds/jrsonnet-fmt/src/snapshots/jrsonnet_fmt__tests__complex_comments_snapshot.snap +++ /dev/null @@ -1,53 +0,0 @@ ---- -source: cmds/jrsonnet-fmt/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 }\"))" ---- -{ - comments: { - _: '', - // Plain comment - a: '', - - # Plain comment with empty line before - b: '', - /* Single-line multiline comment */ - c: '', - - /** - * Single-line multiline doc comment - */ - c: '', - - /** - * Multiline doc - * Comment - */ - c: '', - - /* - Multi-line - - comment - */ - d: '', - - e: '', // Inline comment - - k: '', - - // Text after everything - }, - comments2: { - k: '', - // Text after everything, but no newline above - }, - spacing: { - a: '', - - b: '', - }, - noSpacing: { - a: '', - b: '', - }, -} --- a/cmds/jrsonnet-fmt/src/tests.rs +++ /dev/null @@ -1,79 +0,0 @@ -use dprint_core::formatting::{PrintItems, PrintOptions}; -use indoc::indoc; - -use crate::Printable; - -fn reformat(input: &str) -> String { - let (source, _) = jrsonnet_rowan_parser::parse(input); - - dprint_core::formatting::format( - || { - let mut out = PrintItems::new(); - source.print(&mut out); - out - }, - PrintOptions { - indent_width: 2, - max_width: 100, - use_tabs: true, - new_line_text: "\n", - }, - ) -} - -#[test] -fn complex_comments_snapshot() { - insta::assert_snapshot!(reformat(indoc!( - "{ - comments: { - _: '', - // Plain comment - a: '', - - # Plain comment with empty line before - b: '', - /*Single-line multiline comment - - */ - c: '', - - /**Single-line multiline doc comment - - */ - c: '', - - /**Multiline doc - Comment - */ - c: '', - - /* - - Multi-line - - comment - */ - d: '', - - e: '', // Inline comment - - k: '', - - // Text after everything - }, - comments2: { - k: '', - // Text after everything, but no newline above - }, - spacing: { - a: '', - - b: '', - }, - noSpacing: { - a: '', - b: '', - }, - }" - ))); -} --- /dev/null +++ b/crates/jrsonnet-formatter/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "jrsonnet-formatter" +authors.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +dprint-core.workspace = true +hi-doc.workspace = true +jrsonnet-rowan-parser.workspace = true + +[lints] +workspace = true --- /dev/null +++ b/crates/jrsonnet-formatter/src/children.rs @@ -0,0 +1,251 @@ +// TODO: Return errors as trivia + +use std::{fmt::Debug, mem}; + +use jrsonnet_rowan_parser::{ + nodes::{CustomError, Trivia, TriviaKind}, + AstNode, AstToken, SyntaxElement, SyntaxNode, TS, +}; + +pub type ChildTrivia = Vec>; + +/// Node should have no non-trivia tokens before element +pub fn trivia_before(node: SyntaxNode, end: Option<&SyntaxElement>) -> ChildTrivia { + let mut out = Vec::new(); + for item in node.children_with_tokens() { + if Some(&item) == end { + break; + } + + if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) { + out.push(Ok(trivia)); + } else if CustomError::can_cast(item.kind()) { + out.push(Err(item.to_string())); + } else if end.is_none() { + break; + } else { + assert!( + TS![, ;].contains(item.kind()), + "silently eaten token: {:?}", + item.kind() + ); + } + } + out +} +/// Node should have no non-trivia tokens after element +pub fn trivia_after(node: SyntaxNode, start: Option<&SyntaxElement>) -> ChildTrivia { + if start.is_none() { + return Vec::new(); + } + let mut iter = node.children_with_tokens().peekable(); + while iter.peek() != start { + iter.next(); + } + iter.next(); + let mut out = Vec::new(); + for item in iter { + if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) { + out.push(Ok(trivia)); + } else if CustomError::can_cast(item.kind()) { + out.push(Err(item.to_string())); + } else { + assert!( + TS![, ;].contains(item.kind()), + "silently eaten token: {:?}", + item.kind() + ); + } + } + out +} + +pub fn children_between( + node: SyntaxNode, + start: Option<&SyntaxElement>, + end: Option<&SyntaxElement>, + trailing: Option, +) -> (Vec>, EndingComments) { + let mut iter = node.children_with_tokens().peekable(); + if start.is_some() { + while iter.peek() != start { + iter.next(); + } + iter.next(); + } + children( + iter.take_while(|i| Some(i) != end), + start.is_none() && end.is_none(), + trailing, + ) +} + +pub fn should_start_with_newline(prev_inline: Option<&ChildTrivia>, tt: &ChildTrivia) -> bool { + count_newlines_before(tt) + + prev_inline + .map(count_newlines_after) + .unwrap_or_default() + + // First for previous item end, second for current item + >= 2 +} + +fn count_newlines_before(tt: &ChildTrivia) -> usize { + let mut nl_count = 0; + for t in tt { + match t { + Ok(t) => match t.kind() { + TriviaKind::Whitespace => { + nl_count += t.text().bytes().filter(|b| *b == b'\n').count(); + } + _ => break, + }, + Err(_) => { + nl_count += 1; + } + } + } + nl_count +} +fn count_newlines_after(tt: &ChildTrivia) -> usize { + let mut nl_count = 0; + for t in tt.iter().rev() { + match t { + Ok(t) => match t.kind() { + TriviaKind::Whitespace => { + nl_count += t.text().bytes().filter(|b| *b == b'\n').count(); + } + TriviaKind::SingleLineHashComment | TriviaKind::SingleLineSlashComment => { + nl_count += 1; + break; + } + _ => {} + }, + Err(_) => nl_count += 1, + } + } + nl_count +} + +pub fn children( + items: impl Iterator, + loose: bool, + mut trailing: Option, +) -> (Vec>, EndingComments) { + let mut out = Vec::new(); + let mut current_child = None::>; + let mut next = ChildTrivia::new(); + // Previous element ended, do not add more inline comments + let mut started_next = false; + let mut had_some = false; + + for item in items { + if let Some(value) = item.as_node().cloned().and_then(T::cast) { + let before_trivia = if let Some(trailing) = trailing.take() { + assert!(next.is_empty()); + trailing + } else { + mem::take(&mut next) + }; + let last_child = current_child.replace(Child { + // First item should not start with newline + should_start_with_newline: had_some + && should_start_with_newline( + current_child.as_ref().map(|c| &c.inline_trivia), + &before_trivia, + ), + before_trivia, + value, + inline_trivia: Vec::new(), + }); + if let Some(last_child) = last_child { + out.push(last_child); + } + had_some = true; + started_next = false; + } else if let Some(trivia) = item.as_token().cloned().and_then(Trivia::cast) { + let is_single_line_comment = trivia.kind() == TriviaKind::SingleLineHashComment + || trivia.kind() == TriviaKind::SingleLineSlashComment; + if trailing.is_some() { + // Someone have already parsed trivia for us + continue; + } else if started_next + || current_child.is_none() + || trivia.text().contains('\n') && !is_single_line_comment + { + next.push(Ok(trivia.clone())); + started_next = true; + } else { + let cur = current_child.as_mut().expect("checked not none"); + cur.inline_trivia.push(Ok(trivia)); + if is_single_line_comment { + started_next = true; + } + } + had_some = true; + } else if CustomError::can_cast(item.kind()) { + next.push(Err(item.to_string())); + } else if loose { + if had_some { + break; + } + started_next = true; + } else { + assert!( + TS![, ;].contains(item.kind()), + "silently eaten token: {:?}", + item.kind() + ); + } + } + + let ending_comments = EndingComments { + should_start_with_newline: should_start_with_newline( + current_child.as_ref().map(|c| &c.inline_trivia), + &next, + ), + trivia: next, + }; + + if let Some(current_child) = current_child { + out.push(current_child); + } + + (out, ending_comments) +} + +#[derive(Debug)] +pub struct Child { + /// If this child has two newlines above in source code, so it needs to have it in the output + pub should_start_with_newline: bool, + /// Comment before item, i.e + /// + /// ```ignore + /// // Comment + /// item + /// ``` + pub before_trivia: ChildTrivia, + pub value: T, + /// Comment after line, but located at same line + /// + /// ```ignore + /// item1, // Inline comment + /// // Not inline comment + /// item2, + /// ``` + pub inline_trivia: ChildTrivia, +} + +pub struct EndingComments { + /// If this child has two newlines above in source code, so it needs to have it in the output + pub should_start_with_newline: bool, + pub trivia: ChildTrivia, +} +impl EndingComments { + pub fn is_empty(&self) -> bool { + !self.should_start_with_newline && self.trivia.is_empty() + } + pub fn extract_trailing(&mut self) -> ChildTrivia { + mem::take(&mut self.trivia) + } +} --- /dev/null +++ b/crates/jrsonnet-formatter/src/comments.rs @@ -0,0 +1,181 @@ +use std::string::String; + +use dprint_core::formatting::PrintItems; +use jrsonnet_rowan_parser::{nodes::TriviaKind, AstToken}; + +use crate::{children::ChildTrivia, p, pi}; + +pub enum CommentLocation { + /// Above local, field, other things + AboveItem, + /// After item + ItemInline, + /// After all items in object + EndOfItems, +} + +#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] +pub fn format_comments(comments: &ChildTrivia, loc: CommentLocation, out: &mut PrintItems) { + for c in comments { + let Ok(c) = c else { + let mut text = c.as_ref().unwrap_err() as &str; + while !text.is_empty() { + let pos = text.find(['\n', '\t']).unwrap_or(text.len()); + let sliced = &text[..pos]; + p!(out, string(sliced.to_string())); + text = &text[pos..]; + if !text.is_empty() { + match text.as_bytes()[0] { + b'\n' => p!(out, nl), + b'\t' => p!(out, tab), + _ => unreachable!(), + } + text = &text[1..]; + } + } + continue; + }; + match c.kind() { + TriviaKind::Whitespace => {} + TriviaKind::MultiLineComment => { + let mut text = c + .text() + .strip_prefix("/*") + .expect("ml comment starts with /*") + .strip_suffix("*/") + .expect("ml comment ends with */"); + // doc-style comment, /** + let doc = if text.starts_with('*') { + text = &text[1..]; + true + } else { + false + }; + // Is comment starts with text immediatly, i.e /*text + let mut immediate_start = true; + let mut lines = text + .split('\n') + .map(|l| l.trim_end().to_string()) + .skip_while(|l| { + if l.is_empty() { + immediate_start = false; + true + } else { + false + } + }) + .collect::>(); + while lines.last().is_some_and(String::is_empty) { + lines.pop(); + } + if lines.len() == 1 && !doc { + if matches!(loc, CommentLocation::ItemInline) { + p!(out, str(" ")); + } + p!(out, str("/* ") string(lines[0].trim().to_string()) str(" */") nl); + } else if !lines.is_empty() { + fn common_ws_prefix<'a>(a: &'a str, b: &str) -> &'a str { + let offset = a + .bytes() + .zip(b.bytes()) + .take_while(|(a, b)| a == b && (a.is_ascii_whitespace() || *a == b'*')) + .count(); + &a[..offset] + } + // First line is not empty, extract ws prefix of it + let mut common_ws_padding = (if immediate_start && lines.len() > 1 { + common_ws_prefix(&lines[1], &lines[1]) + } else { + common_ws_prefix(&lines[0], &lines[0]) + }) + .to_string(); + for line in lines + .iter() + .skip(if immediate_start { 2 } else { 1 }) + .filter(|l| !l.is_empty()) + { + common_ws_padding = common_ws_prefix(&common_ws_padding, line).to_string(); + } + for line in lines + .iter_mut() + .skip(usize::from(immediate_start)) + .filter(|l| !l.is_empty()) + { + *line = line + .strip_prefix(&common_ws_padding) + .expect("all non-empty lines start with this padding") + .to_string(); + } + + p!(out, str("/*")); + if doc { + p!(out, str("*")); + } + p!(out, nl); + for mut line in lines { + if doc { + p!(out, str(" *")); + } + if line.is_empty() { + p!(out, nl); + } else { + if doc { + p!(out, str(" ")); + } + while let Some(new_line) = line.strip_prefix('\t') { + if doc { + p!(out, str(" ")); + } else { + p!(out, tab); + } + line = new_line.to_string(); + } + p!(out, string(line.to_string()) nl); + } + } + if doc { + p!(out, str(" ")); + } + p!(out, str("*/") nl); + } + } + // TODO: Keep common padding for multiple continous lines of single-line comments + // I.e + // ``` + // # Line1 + // # Line2 + // ``` + // Should be reformatted as + // ``` + // # Line1 + // # Line2 + // ``` + // But currently comment formatter is not aware of continous comment lines, and reformats it as + // ``` + // # Line1 + // # Line2 + // ``` + TriviaKind::SingleLineHashComment => { + if matches!(loc, CommentLocation::ItemInline) { + p!(out, str(" ")); + } + p!(out, str("# ") string(c.text().strip_prefix('#').expect("hash comment starts with #").trim().to_string())); + if !matches!(loc, CommentLocation::ItemInline) { + p!(out, nl); + } + } + TriviaKind::SingleLineSlashComment => { + if matches!(loc, CommentLocation::ItemInline) { + p!(out, str(" ")); + } + p!(out, str("// ") string(c.text().strip_prefix("//").expect("comment starts with //").trim().to_string())); + if !matches!(loc, CommentLocation::ItemInline) { + p!(out, nl); + } + } + // Garbage in - garbage out + TriviaKind::ErrorCommentTooShort => p!(out, str("/*/")), + TriviaKind::ErrorCommentUnterminated => p!(out, string(c.text().to_string())), + } + } +} --- /dev/null +++ b/crates/jrsonnet-formatter/src/lib.rs @@ -0,0 +1,740 @@ +use std::{any::type_name, rc::Rc}; + +use children::{children_between, trivia_before}; +use dprint_core::formatting::{ + condition_helpers::is_multiple_lines, condition_resolvers::true_resolver, + ConditionResolverContext, LineNumber, PrintItems, PrintOptions, +}; +use hi_doc::{Formatting, SnippetBuilder}; +use jrsonnet_rowan_parser::{ + nodes::{ + 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, + }, + AstNode, AstToken as _, SyntaxToken, +}; + +use crate::{ + children::trivia_after, + comments::{format_comments, CommentLocation}, +}; + +mod children; +mod comments; +#[cfg(test)] +mod tests; + +pub trait Printable { + fn print(&self, out: &mut PrintItems); +} + +macro_rules! pi { + (@i; $($t:tt)*) => {{ + #[allow(unused_mut)] + let mut o = dprint_core::formatting::PrintItems::new(); + pi!(@s; o: $($t)*); + o + }}; + (@s; $o:ident: str($e:expr $(,)?) $($t:tt)*) => {{ + $o.push_string($e.to_owned()); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: string($e:expr $(,)?) $($t:tt)*) => {{ + $o.push_string($e); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: nl $($t:tt)*) => {{ + $o.push_signal(dprint_core::formatting::Signal::NewLine); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: tab $($t:tt)*) => {{ + $o.push_signal(dprint_core::formatting::Signal::Tab); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: >i $($t:tt)*) => {{ + $o.push_signal(dprint_core::formatting::Signal::StartIndent); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: {{ + $o.push_signal(dprint_core::formatting::Signal::FinishIndent); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: info($v:expr) $($t:tt)*) => {{ + $o.push_info($v); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: if($s:literal, $cond:expr, $($i:tt)*) $($t:tt)*) => {{ + $o.push_condition(dprint_core::formatting::conditions::if_true( + $s, + $cond.clone(), + { + let mut o = PrintItems::new(); + p!(o, $($i)*); + o + }, + )); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: if_else($s:literal, $cond:expr, $($i:tt)*)($($e:tt)+) $($t:tt)*) => {{ + $o.push_condition(dprint_core::formatting::conditions::if_true_or( + $s, + $cond.clone(), + { + let mut o = PrintItems::new(); + p!(o, $($i)*); + o + }, + { + let mut o = PrintItems::new(); + p!(o, $($e)*); + o + }, + )); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: if_not($s:literal, $cond:expr, $($e:tt)*) $($t:tt)*) => {{ + $o.push_condition(dprint_core::formatting::conditions::if_true_or( + $s, + $cond.clone(), + { + let o = PrintItems::new(); + o + }, + { + let mut o = PrintItems::new(); + p!(o, $($e)*); + o + }, + )); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: {$expr:expr} $($t:tt)*) => {{ + $expr.print($o); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: items($expr:expr) $($t:tt)*) => {{ + $o.extend($expr); + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: if ($e:expr)($($then:tt)*) $($t:tt)*) => {{ + if $e { + pi!(@s; $o: $($then)*); + } + pi!(@s; $o: $($t)*); + }}; + (@s; $o:ident: ifelse ($e:expr)($($then:tt)*)($($else:tt)*) $($t:tt)*) => {{ + if $e { + pi!(@s; $o: $($then)*); + } else { + pi!(@s; $o: $($else)*); + } + pi!(@s; $o: $($t)*); + }}; + (@s; $i:ident:) => {} +} +macro_rules! p { + ($o:ident, $($t:tt)*) => { + pi!(@s; $o: $($t)*) + }; +} +pub(crate) use p; +pub(crate) use pi; + +impl

Printable for Option

+where + P: Printable, +{ + fn print(&self, out: &mut PrintItems) { + if let Some(v) = self { + v.print(out); + } else { + p!( + out, + string(format!( + "/*missing {}*/", + type_name::

().replace("jrsonnet_rowan_parser::generated::nodes::", "") + ),) + ); + } + } +} + +impl Printable for SyntaxToken { + fn print(&self, out: &mut PrintItems) { + p!(out, string(self.to_string())); + } +} + +impl Printable for Text { + fn print(&self, out: &mut PrintItems) { + p!(out, string(format!("{}", self))); + } +} +impl Printable for Number { + fn print(&self, out: &mut PrintItems) { + p!(out, string(format!("{}", self))); + } +} + +impl Printable for Name { + fn print(&self, out: &mut PrintItems) { + p!(out, { self.ident_lit() }); + } +} + +impl Printable for DestructRest { + fn print(&self, out: &mut PrintItems) { + p!(out, str("...")); + if let Some(name) = self.into() { + p!(out, { name }); + } + } +} + +impl Printable for Destruct { + fn print(&self, out: &mut PrintItems) { + match self { + Self::DestructFull(f) => { + p!(out, { f.name() }); + } + Self::DestructSkip(_) => p!(out, str("?")), + Self::DestructArray(a) => { + p!(out, str("[") >i nl); + for el in a.destruct_array_parts() { + match el { + DestructArrayPart::DestructArrayElement(e) => { + p!(out, {e.destruct()} str(",") nl); + } + DestructArrayPart::DestructRest(d) => { + p!(out, {d} str(",") nl); + } + } + } + p!(out, { + p!(out, str("{") >i nl); + for item in o.destruct_object_fields() { + p!(out, { item.field() }); + if let Some(des) = item.destruct() { + p!(out, str(": ") {des}); + } + if let Some(def) = item.expr() { + p!(out, str(" = ") {def}); + } + p!(out, str(",") nl); + } + if let Some(rest) = o.destruct_rest() { + p!(out, {rest} nl); + } + p!(out, { + if let Some(id) = f.id() { + p!(out, { id }); + } else if let Some(str) = f.text() { + p!(out, { str }); + } else { + p!(out, str("/*missing FieldName*/")); + } + } + Self::FieldNameDynamic(d) => { + p!(out, str("[") {d.expr()} str("]")); + } + } + } +} + +impl Printable for Visibility { + fn print(&self, out: &mut PrintItems) { + p!(out, string(self.to_string())); + } +} + +impl Printable for ObjLocal { + fn print(&self, out: &mut PrintItems) { + p!(out, str("local ") {self.bind()}); + } +} + +impl Printable for Assertion { + fn print(&self, out: &mut PrintItems) { + p!(out, str("assert ") {self.condition()}); + if self.colon_token().is_some() || self.message().is_some() { + p!(out, str(": ") {self.message()}); + } + } +} + +impl Printable for ParamsDesc { + fn print(&self, out: &mut PrintItems) { + p!(out, str("(") >i nl); + for param in self.params() { + p!(out, { param.destruct() }); + if param.assign_token().is_some() || param.expr().is_some() { + p!(out, str(" = ") {param.expr()}); + } + p!(out, str(",") nl); + } + p!(out, i nl)); + let (children, end_comments) = children_between::( + self.syntax().clone(), + self.l_paren_token().map(Into::into).as_ref(), + self.r_paren_token().map(Into::into).as_ref(), + None, + ); + let mut args = children.into_iter().peekable(); + while let Some(ele) = args.next() { + if ele.should_start_with_newline { + p!(out, nl); + } + format_comments(&ele.before_trivia, CommentLocation::AboveItem, out); + let arg = ele.value; + if arg.name().is_some() || arg.assign_token().is_some() { + p!(out, {arg.name()} str(" = ")); + } + let comma_between = if args.peek().is_some() { + true_resolver() + } else { + multi_line.clone() + }; + p!(out, {arg.expr()} if("arg comma", comma_between, str(",") if_not("between args", multi_line, str(" ")))); + format_comments(&ele.inline_trivia, CommentLocation::ItemInline, out); + p!(out, if("between args", multi_line, nl)); + } + if end_comments.should_start_with_newline { + p!(out, nl); + } + format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); + p!(out, if("end args", multi_line, { + p!(out, { b.obj_local() }); + } + Self::MemberAssertStmt(ass) => { + p!(out, { ass.assertion() }); + } + Self::MemberFieldNormal(n) => { + p!(out, {n.field_name()} if(n.plus_token().is_some())({n.plus_token()}) {n.visibility()} str(" ") {n.expr()}); + } + Self::MemberFieldMethod(m) => { + p!(out, {m.field_name()} {m.params_desc()} {m.visibility()} str(" ") {m.expr()}); + } + } + } +} + +impl Printable for ObjBody { + fn print(&self, out: &mut PrintItems) { + match self { + Self::ObjBodyComp(l) => { + let (children, mut end_comments) = children_between::( + l.syntax().clone(), + l.l_brace_token().map(Into::into).as_ref(), + Some( + &(l.comp_specs() + .next() + .expect("at least one spec is defined") + .syntax() + .clone()) + .into(), + ), + None, + ); + let trailing_for_comp = end_comments.extract_trailing(); + p!(out, str("{") >i nl); + for mem in children { + if mem.should_start_with_newline { + p!(out, nl); + } + format_comments(&mem.before_trivia, CommentLocation::AboveItem, out); + p!(out, {mem.value} str(",")); + format_comments(&mem.inline_trivia, CommentLocation::ItemInline, out); + p!(out, nl); + } + + if end_comments.should_start_with_newline { + p!(out, nl); + } + format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); + + let (compspecs, end_comments) = children_between::( + l.syntax().clone(), + l.member_comps() + .last() + .map(|m| m.syntax().clone()) + .map(Into::into) + .or_else(|| l.l_brace_token().map(Into::into)) + .as_ref(), + l.r_brace_token().map(Into::into).as_ref(), + Some(trailing_for_comp), + ); + for mem in compspecs { + if mem.should_start_with_newline { + p!(out, nl); + } + format_comments(&mem.before_trivia, CommentLocation::AboveItem, out); + p!(out, { mem.value }); + format_comments(&mem.inline_trivia, CommentLocation::ItemInline, out); + } + if end_comments.should_start_with_newline { + p!(out, nl); + } + format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); + + p!(out, nl { + let (children, end_comments) = children_between::( + l.syntax().clone(), + l.l_brace_token().map(Into::into).as_ref(), + l.r_brace_token().map(Into::into).as_ref(), + None, + ); + if children.is_empty() && end_comments.is_empty() { + 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); + } + format_comments(&mem.before_trivia, CommentLocation::AboveItem, out); + p!(out, {mem.value} str(",")); + format_comments(&mem.inline_trivia, CommentLocation::ItemInline, out); + p!(out, nl); + } + + if end_comments.should_start_with_newline { + p!(out, nl); + } + format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); + p!(out, { + p!(out, {d.into()} str(" = ") {d.value()}); + } + Self::BindFunction(f) => { + p!(out, {f.name()} {f.params()} str(" = ") {f.value()}); + } + } + } +} +impl Printable for Literal { + fn print(&self, out: &mut PrintItems) { + p!(out, string(self.syntax().to_string())); + } +} +impl Printable for ImportKind { + fn print(&self, out: &mut PrintItems) { + p!(out, string(self.syntax().to_string())); + } +} +impl Printable for ForSpec { + fn print(&self, out: &mut PrintItems) { + p!(out, str("for ") {self.bind()} str(" in ") {self.expr()}); + } +} +impl Printable for IfSpec { + fn print(&self, out: &mut PrintItems) { + p!(out, str("if ") {self.expr()}); + } +} +impl Printable for CompSpec { + fn print(&self, out: &mut PrintItems) { + match self { + Self::ForSpec(f) => f.print(out), + Self::IfSpec(i) => i.print(out), + } + } +} +impl Printable for Expr { + fn print(&self, out: &mut PrintItems) { + let (stmts, _ending) = children_between::( + self.syntax().clone(), + None, + self.expr_base() + .as_ref() + .map(ExprBase::syntax) + .cloned() + .map(Into::into) + .as_ref(), + None, + ); + for stmt in stmts { + p!(out, { stmt.value }); + } + p!(out, { self.expr_base() }); + let (suffixes, _ending) = children_between::( + self.syntax().clone(), + self.expr_base() + .as_ref() + .map(ExprBase::syntax) + .cloned() + .map(Into::into) + .as_ref(), + None, + None, + ); + for suffix in suffixes { + p!(out, { suffix.value }); + } + } +} +impl Printable for Suffix { + fn print(&self, out: &mut PrintItems) { + match self { + Self::SuffixIndex(i) => { + if i.question_mark_token().is_some() { + p!(out, str("?")); + } + p!(out, str(".") {i.index()}); + } + Self::SuffixIndexExpr(e) => { + if e.question_mark_token().is_some() { + p!(out, str(".?")); + } + p!(out, str("[") {e.index()} str("]")); + } + Self::SuffixSlice(d) => { + p!(out, { d.slice_desc() }); + } + Self::SuffixApply(a) => { + p!(out, { a.args_desc() }); + } + } + } +} +impl Printable for Stmt { + fn print(&self, out: &mut PrintItems) { + match self { + Self::StmtLocal(l) => { + let (binds, end_comments) = children_between::( + l.syntax().clone(), + l.local_kw_token().map(Into::into).as_ref(), + l.semi_token().map(Into::into).as_ref(), + None, + ); + if binds.len() == 1 { + let bind = &binds[0]; + format_comments(&bind.before_trivia, CommentLocation::AboveItem, out); + p!(out, str("local ") {bind.value}); + // TODO: keep end_comments, child.inline_trivia somehow, force multiple locals formatting in case of presence? + } else { + p!(out,str("local") >i nl); + for bind in binds { + if bind.should_start_with_newline { + p!(out, nl); + } + format_comments(&bind.before_trivia, CommentLocation::AboveItem, out); + p!(out, {bind.value} str(",")); + format_comments(&bind.inline_trivia, CommentLocation::ItemInline, out); + p!(out, nl); + } + if end_comments.should_start_with_newline { + p!(out, nl); + } + format_comments(&end_comments.trivia, CommentLocation::EndOfItems, out); + p!(out, { + p!(out, {a.assertion()} str(";") nl); + } + } + } +} +impl Printable for ExprBase { + fn print(&self, out: &mut PrintItems) { + match self { + Self::ExprBinary(b) => { + p!(out, {b.lhs_work()} str(" ") {b.binary_operator()} str(" ") {b.rhs_work()}); + } + Self::ExprUnary(u) => p!(out, {u.unary_operator()} {u.rhs()}), + // Self::ExprSlice(s) => { + // p!(new: {s.expr()} {s.slice_desc()}) + // } + // Self::ExprIndex(i) => { + // p!(new: {i.expr()} str(".") {i.index()}) + // } + // Self::ExprIndexExpr(i) => p!(new: {i.base()} str("[") {i.index()} str("]")), + // Self::ExprApply(a) => { + // let mut pi = p!(new: {a.expr()} {a.args_desc()}); + // if a.tailstrict_kw_token().is_some() { + // p!(out,str(" tailstrict")); + // } + // pi + // } + Self::ExprObjExtend(ex) => { + p!(out, {ex.lhs_work()} str(" ") {ex.rhs_work()}); + } + Self::ExprParened(p) => { + p!(out, str("(") {p.expr()} str(")")); + } + Self::ExprString(s) => p!(out, { s.text() }), + Self::ExprNumber(n) => p!(out, { n.number() }), + Self::ExprArray(a) => { + p!(out, str("[") >i nl); + for el in a.exprs() { + p!(out, {el} str(",") nl); + } + p!(out, { + p!(out, { obj.obj_body() }); + } + Self::ExprArrayComp(arr) => { + p!(out, str("[") {arr.expr()}); + for spec in arr.comp_specs() { + p!(out, str(" ") {spec}); + } + p!(out, str("]")); + } + Self::ExprImport(v) => { + p!(out, {v.import_kind()} str(" ") {v.text()}); + } + Self::ExprVar(n) => p!(out, { n.name() }), + // Self::ExprLocal(l) => { + // } + Self::ExprIfThenElse(ite) => { + p!(out, str("if ") {ite.cond()} str(" then ") {ite.then().map(|t| t.expr())}); + if ite.else_kw_token().is_some() || ite.else_().is_some() { + p!(out, str(" else ") {ite.else_().map(|t| t.expr())}); + } + } + Self::ExprFunction(f) => p!(out, str("function") {f.params_desc()} nl {f.expr()}), + // Self::ExprAssert(a) => p!(new: {a.assertion()} str("; ") {a.expr()}), + Self::ExprError(e) => p!(out, str("error ") {e.expr()}), + Self::ExprLiteral(l) => { + p!(out, { l.literal() }); + } + } + } +} + +impl Printable for SourceFile { + fn print(&self, out: &mut PrintItems) { + let before = trivia_before( + self.syntax().clone(), + self.expr() + .map(|e| e.syntax().clone()) + .map(Into::into) + .as_ref(), + ); + let after = trivia_after( + self.syntax().clone(), + self.expr() + .map(|e| e.syntax().clone()) + .map(Into::into) + .as_ref(), + ); + format_comments(&before, CommentLocation::AboveItem, out); + p!(out, {self.expr()} nl); + format_comments(&after, CommentLocation::EndOfItems, out); + } +} + +pub struct FormatOptions { + // 0 for hard tabs + pub indent: u8, +} +pub fn format(input: &str, opts: &FormatOptions) -> Result { + let (parsed, errors) = jrsonnet_rowan_parser::parse(input); + if !errors.is_empty() { + let mut builder = hi_doc::SnippetBuilder::new(input); + for error in errors { + builder + .error(hi_doc::Text::fragment( + format!("{:?}", error.error), + Formatting::default(), + )) + .range( + error.range.start().into() + ..=(usize::from(error.range.end()) - 1).max(error.range.start().into()), + ) + .build(); + } + // let snippet = builder.build(); + return Err(builder); + // It is possible to recover from this failure, but the output may be broken, as formatter is free to skip + // ERROR rowan nodes. + // Recovery needs to be enabled for LSP, though. + } + Ok(dprint_core::formatting::format( + || { + let mut out = PrintItems::new(); + parsed.print(&mut out); + out + }, + PrintOptions { + indent_width: if opts.indent == 0 { + // Reasonable max length for both 2 and 4 space sized tabs. + 3 + } else { + opts.indent + }, + max_width: 100, + use_tabs: opts.indent == 0, + new_line_text: "\n", + }, + )) +} --- /dev/null +++ b/crates/jrsonnet-formatter/src/snapshots/jrsonnet_fmt__tests__complex_comments_snapshot.snap @@ -0,0 +1,53 @@ +--- +source: cmds/jrsonnet-fmt/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 }\"))" +--- +{ + comments: { + _: '', + // Plain comment + a: '', + + # Plain comment with empty line before + b: '', + /* Single-line multiline comment */ + c: '', + + /** + * Single-line multiline doc comment + */ + c: '', + + /** + * Multiline doc + * Comment + */ + c: '', + + /* + Multi-line + + comment + */ + d: '', + + e: '', // Inline comment + + k: '', + + // Text after everything + }, + comments2: { + k: '', + // Text after everything, but no newline above + }, + spacing: { + a: '', + + b: '', + }, + noSpacing: { + a: '', + b: '', + }, +} --- /dev/null +++ b/crates/jrsonnet-formatter/src/tests.rs @@ -0,0 +1,79 @@ +use dprint_core::formatting::{PrintItems, PrintOptions}; +use indoc::indoc; + +use crate::Printable; + +fn reformat(input: &str) -> String { + let (source, _) = jrsonnet_rowan_parser::parse(input); + + dprint_core::formatting::format( + || { + let mut out = PrintItems::new(); + source.print(&mut out); + out + }, + PrintOptions { + indent_width: 2, + max_width: 100, + use_tabs: true, + new_line_text: "\n", + }, + ) +} + +#[test] +fn complex_comments_snapshot() { + insta::assert_snapshot!(reformat(indoc!( + "{ + comments: { + _: '', + // Plain comment + a: '', + + # Plain comment with empty line before + b: '', + /*Single-line multiline comment + + */ + c: '', + + /**Single-line multiline doc comment + + */ + c: '', + + /**Multiline doc + Comment + */ + c: '', + + /* + + Multi-line + + comment + */ + d: '', + + e: '', // Inline comment + + k: '', + + // Text after everything + }, + comments2: { + k: '', + // Text after everything, but no newline above + }, + spacing: { + a: '', + + b: '', + }, + noSpacing: { + a: '', + b: '', + }, + }" + ))); +} -- gitstuff