Skip to main content

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}