yaml_edit/scalar/render.rs
1//! Rendering scalars to YAML text and building their syntax nodes.
2
3use super::{ScalarStyle, ScalarType, ScalarValue};
4use crate::as_yaml::FlowQuoting;
5
6/// Whether `text`, a scalar as [`ScalarValue::render`] writes it, is a
7/// block scalar.
8fn is_block_scalar(text: &str) -> bool {
9 text.starts_with(['|', '>'])
10}
11
12/// Emit `text`, a block scalar as rendered, as the SCALAR the parser builds
13/// for it: the header and body tokens as the lexer splits them, then the
14/// line break ending the last line, which the parser keeps inside the
15/// SCALAR.
16// TODO: build the tokens directly rather than lexing rendered text, once the
17// lexer gives block scalar bodies a simple, consistent token structure.
18fn build_block_scalar(builder: &mut rowan::GreenNodeBuilder, text: &str) {
19 use crate::lex::SyntaxKind;
20 builder.start_node(SyntaxKind::SCALAR.into());
21 for (kind, token) in crate::lex::lex(text) {
22 builder.token(kind.into(), token);
23 }
24 builder.token(SyntaxKind::NEWLINE.into(), "\n");
25 builder.finish_node();
26}
27
28impl ScalarValue {
29 /// Render the scalar as a YAML string with proper escaping.
30 ///
31 /// A block scalar is laid out for a top-level entry, with its body
32 /// indented two spaces. Use
33 /// [`to_yaml_string_with_indent`](Self::to_yaml_string_with_indent) for
34 /// a value nested deeper.
35 pub fn to_yaml_string(&self) -> String {
36 self.to_yaml_string_with_indent(0, 2)
37 }
38
39 /// As [`to_yaml_string`](Self::to_yaml_string), for a value of the
40 /// collection whose entries sit at `parent_column`, with a block
41 /// scalar's body `indent` spaces further right.
42 ///
43 /// `parent_column` is the column of the entry's key, or of the `-` of a
44 /// sequence entry, and 0 for a top-level entry or the document root.
45 /// It matters because a block scalar's indentation indicator (`|2`)
46 /// counts from there: for `a:\n k: <value>` pass 2.
47 ///
48 /// # Panics
49 ///
50 /// For a block scalar, if `indent` is 0, or above 9 when the value
51 /// needs an indentation indicator (its first non-empty line starts
52 /// with a space).
53 pub fn to_yaml_string_with_indent(&self, parent_column: usize, indent: usize) -> String {
54 self.render(parent_column, indent, None)
55 }
56
57 /// Render the scalar for a flow collection, quoted only where it has to
58 /// be.
59 pub(crate) fn to_flow_string(&self) -> String {
60 self.render(0, 2, Some(FlowQuoting::Minimal))
61 }
62
63 /// The tag written in front of the content: the explicit one, or the
64 /// one special data types carry when none is set.
65 pub(super) fn rendered_tag(&self) -> Option<&str> {
66 self.tag.as_deref().or(match self.scalar_type {
67 #[cfg(feature = "base64")]
68 ScalarType::Binary => Some(crate::tag::BINARY),
69 ScalarType::Timestamp => Some(crate::tag::TIMESTAMP),
70 ScalarType::Regex => Some(crate::tag::REGEX),
71 _ => None,
72 })
73 }
74
75 /// Render the scalar with its tag; inside a flow collection quoting
76 /// strings as `flow` says, if it is set.
77 fn render(&self, parent_column: usize, indent: usize, flow: Option<FlowQuoting>) -> String {
78 let tag = self.rendered_tag();
79 let content = self.render_content_in(parent_column, indent, flow);
80 match tag {
81 Some(tag) if content.is_empty() => tag.to_string(),
82 Some(tag) => format!("{tag} {content}"),
83 None => content,
84 }
85 }
86
87 /// Render the scalar without any tag; inside a flow collection quoting
88 /// strings as `flow` says, if it is set. Block scalars and plain
89 /// scalars holding flow indicators can't appear there.
90 fn render_content_in(
91 &self,
92 parent_column: usize,
93 indent: usize,
94 flow: Option<FlowQuoting>,
95 ) -> String {
96 // A plain string is quoted as needed when rendered, and `string`
97 // picks the style itself, so a string with the style it would pick
98 // was not given one explicitly.
99 let auto_string = self.scalar_type == ScalarType::String
100 && (self.style == ScalarStyle::Plain || self.style == Self::detect_style(&self.value));
101 if flow == Some(FlowQuoting::Double) && auto_string {
102 return self.to_double_quoted();
103 }
104 let content = self.render_content(parent_column, indent);
105 let breaks_flow = content.starts_with(['|', '>'])
106 || (!content.starts_with(['"', '\'']) && content.contains([',', '[', ']', '{', '}']));
107 if flow.is_some() && breaks_flow {
108 self.to_double_quoted()
109 } else {
110 content
111 }
112 }
113
114 /// Render the scalar without any tag.
115 fn render_content(&self, parent_column: usize, indent: usize) -> String {
116 match self.style {
117 ScalarStyle::Plain => {
118 // Check if we need to quote based on type vs content
119 match self.scalar_type {
120 // With an explicit tag, the content can stay plain even
121 // where it would otherwise resolve to another type.
122 ScalarType::String => {
123 let needs_quoting = if self.tag.is_some() {
124 Self::needs_quoting_as_tagged(&self.value)
125 } else {
126 Self::needs_quoting(&self.value)
127 };
128 if Self::needs_double_quotes(&self.value) {
129 self.to_double_quoted()
130 } else if needs_quoting {
131 self.to_single_quoted(parent_column + indent)
132 } else {
133 self.value.clone()
134 }
135 }
136 // For non-strings, output as plain (unquoted)
137 ScalarType::Integer
138 | ScalarType::Float
139 | ScalarType::Boolean
140 | ScalarType::Null
141 | ScalarType::Timestamp
142 | ScalarType::Regex => self.value.clone(),
143 #[cfg(feature = "base64")]
144 ScalarType::Binary => self.value.clone(),
145 }
146 }
147 ScalarStyle::SingleQuoted => self.to_single_quoted(parent_column + indent),
148 ScalarStyle::DoubleQuoted => self.to_double_quoted(),
149 ScalarStyle::Literal => self.to_block_with_indent('|', parent_column, indent),
150 ScalarStyle::Folded => self.to_block_with_indent('>', parent_column, indent),
151 }
152 }
153
154 /// Render as a literal block scalar (`|`) whose body sits `indent`
155 /// spaces past `parent_column`; see
156 /// [`to_yaml_string_with_indent`](Self::to_yaml_string_with_indent).
157 /// A value a block scalar can't hold, such as one with a carriage
158 /// return, is double-quoted instead.
159 ///
160 /// # Panics
161 ///
162 /// As [`to_yaml_string_with_indent`](Self::to_yaml_string_with_indent).
163 pub fn to_literal_with_indent(&self, parent_column: usize, indent: usize) -> String {
164 self.to_block_with_indent('|', parent_column, indent)
165 }
166
167 /// Render as a folded block scalar (`>`) whose body sits `indent`
168 /// spaces past `parent_column`; see
169 /// [`to_yaml_string_with_indent`](Self::to_yaml_string_with_indent).
170 /// A value a block scalar can't hold, such as one with a carriage
171 /// return, is double-quoted instead.
172 ///
173 /// # Panics
174 ///
175 /// As [`to_yaml_string_with_indent`](Self::to_yaml_string_with_indent).
176 pub fn to_folded_with_indent(&self, parent_column: usize, indent: usize) -> String {
177 self.to_block_with_indent('>', parent_column, indent)
178 }
179
180 /// Render as a block scalar introduced by `marker` (`|` literal, `>` folded),
181 /// with its body `indent` columns past `parent_column`.
182 fn to_block_with_indent(&self, marker: char, parent_column: usize, indent: usize) -> String {
183 assert!(indent > 0, "a block scalar's body must be indented");
184 // A block scalar reads a carriage return as a line break and can't
185 // escape anything, so such a value can only be double-quoted.
186 if Self::needs_double_quotes(&self.value) {
187 return self.to_double_quoted();
188 }
189 if self.value.is_empty() {
190 return format!("{marker}-");
191 }
192 // Pick the chomping indicator that preserves the value's trailing
193 // line breaks: a bare `|` clips to exactly one, `|-` strips them all
194 // and `|+` keeps them. Otherwise reading the value back gains or
195 // loses a newline. Clipping keeps the break after the last content
196 // line, so a value of only line breaks has to keep them.
197 let trailing = self.value.len() - self.value.trim_end_matches('\n').len();
198 let chomp = match trailing {
199 0 => "-",
200 1 if self.value.len() > 1 => "",
201 _ => "+",
202 };
203 let mut lines: Vec<&str> = self.value.split('\n').collect();
204 if trailing > 0 {
205 lines.pop();
206 }
207 // A reader takes the block's indentation from its first non-empty
208 // line, so leading spaces there would be stripped as indentation
209 // (or make later lines look dedented) unless an indicator says
210 // where the body starts. It counts from the parent collection's
211 // column.
212 let needs_indicator = lines
213 .iter()
214 .find(|line| !line.is_empty())
215 .is_some_and(|line| line.starts_with(' '));
216 let indicator = if needs_indicator {
217 assert!(
218 indent <= 9,
219 "indentation indicator {indent} does not fit in one digit"
220 );
221 indent.to_string()
222 } else {
223 String::new()
224 };
225 let indent_str = " ".repeat(parent_column + indent);
226 let is_folded_text = |line: &str| !line.is_empty() && !line.starts_with([' ', '\t']);
227 let mut body = Vec::with_capacity(lines.len());
228 let mut prev_text: Option<&str> = None;
229 for line in lines {
230 if line.is_empty() {
231 body.push(String::new());
232 continue;
233 }
234 // A folded block turns a break between two unindented lines into
235 // a space; an extra empty line keeps it a newline.
236 if marker == '>' && is_folded_text(line) && prev_text.is_some_and(is_folded_text) {
237 body.push(String::new());
238 }
239 prev_text = Some(line);
240 body.push(format!("{indent_str}{line}"));
241 }
242 format!("{marker}{indicator}{chomp}\n{}", body.join("\n"))
243 }
244
245 /// The token kind for `text`, this scalar as rendered.
246 fn token_kind(&self, text: &str) -> crate::lex::SyntaxKind {
247 use crate::lex::SyntaxKind;
248 if text.starts_with('\'') || text.starts_with('"') {
249 return SyntaxKind::STRING;
250 }
251 match self.scalar_type {
252 ScalarType::Integer => SyntaxKind::INT,
253 ScalarType::Float => SyntaxKind::FLOAT,
254 ScalarType::Boolean => SyntaxKind::BOOL,
255 ScalarType::Null => SyntaxKind::NULL,
256 _ => SyntaxKind::STRING,
257 }
258 }
259
260 /// Emit the scalar, inside a flow collection quoting strings as `flow`
261 /// says if it is set.
262 pub(crate) fn build(
263 &self,
264 builder: &mut rowan::GreenNodeBuilder,
265 indent: usize,
266 flow: Option<FlowQuoting>,
267 ) -> bool {
268 use crate::lex::SyntaxKind;
269 // A block scalar's body has to clear the column its entry sits at,
270 // or the text it renders to is not the value it stood for:
271 // `a:\n b: |-\n x\n` has an empty scalar and a stray `x`.
272 if let Some(tag) = self.rendered_tag() {
273 builder.start_node(SyntaxKind::TAGGED_NODE.into());
274 builder.token(SyntaxKind::TAG.into(), tag);
275 let content = self.render_content_in(indent, 2, flow);
276 // Like the parser, give a tag with no content an empty scalar.
277 let kind = if content.is_empty() {
278 SyntaxKind::NULL
279 } else {
280 builder.token(SyntaxKind::WHITESPACE.into(), " ");
281 if is_block_scalar(&content) {
282 build_block_scalar(builder, &content);
283 builder.finish_node();
284 return true;
285 }
286 self.token_kind(&content)
287 };
288 builder.start_node(SyntaxKind::SCALAR.into());
289 builder.token(kind.into(), &content);
290 builder.finish_node();
291 builder.finish_node();
292 return false;
293 }
294 // Render with proper YAML quoting/escaping. When the value needs
295 // quotes (e.g. the string "null", or text containing ": "), the
296 // rendered text starts with a quote and must be tokenized as a
297 // STRING regardless of the semantic type.
298 let text = self.render(indent, 2, flow);
299 if is_block_scalar(&text) {
300 build_block_scalar(builder, &text);
301 return true;
302 }
303 builder.start_node(SyntaxKind::SCALAR.into());
304 builder.token(self.token_kind(&text).into(), &text);
305 builder.finish_node();
306 false
307 }
308}