Skip to main content

yaml_edit/nodes/
scalar_node.rs

1use super::{fresh_token, Lang, SyntaxNode};
2use crate::as_yaml::{AsYaml, YamlKind};
3use crate::lex::SyntaxKind;
4use crate::scalar::ScalarValue;
5use crate::yaml::ValueNode;
6use rowan::ast::AstNode;
7use std::borrow::Cow;
8use std::fmt;
9
10ast_node!(Scalar, SCALAR, "A YAML scalar value");
11
12/// Chomping indicator for block scalars
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub(crate) enum Chomping {
15    /// Strip final line breaks (indicator: -)
16    Strip,
17    /// Keep final line breaks (indicator: +)
18    Keep,
19    /// Clip to single final line break (default, no indicator)
20    Clip,
21}
22
23/// Error type for scalar type conversions
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum ScalarConversionError {
26    /// The scalar value is quoted, indicating it's a string type in YAML
27    QuotedValue,
28    /// The scalar value cannot be parsed as the target type
29    ParseError(String),
30}
31
32impl fmt::Display for ScalarConversionError {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            ScalarConversionError::QuotedValue => {
36                write!(f, "Cannot convert quoted scalar to numeric/boolean type")
37            }
38            ScalarConversionError::ParseError(msg) => {
39                write!(f, "Failed to parse scalar: {msg}")
40            }
41        }
42    }
43}
44
45impl std::error::Error for ScalarConversionError {}
46
47/// The chomping and explicit indentation indicators in `header`, the
48/// first line of a block scalar. Only the run straight after `|` or `>`
49/// holds them; a comment may follow (`> # a-b`) and its text says nothing
50/// about either.
51fn header_indicators(header: &str) -> (Chomping, Option<usize>) {
52    let indicators: String = header
53        .chars()
54        .skip(1)
55        .take_while(|c| c.is_ascii_digit() || *c == '+' || *c == '-')
56        .collect();
57    let chomping = if indicators.contains('-') {
58        Chomping::Strip
59    } else if indicators.contains('+') {
60        Chomping::Keep
61    } else {
62        Chomping::Clip
63    };
64    let indent = indicators
65        .chars()
66        .find(|c| c.is_ascii_digit() && *c != '0')
67        .and_then(|c| c.to_digit(10))
68        .map(|d| d as usize);
69    (chomping, indent)
70}
71
72/// The chomping and explicit indentation indicators of `scalar`, a block
73/// scalar.
74pub(crate) fn block_scalar_indicators(scalar: &SyntaxNode) -> (Chomping, Option<usize>) {
75    let header: String = scalar
76        .children_with_tokens()
77        .filter_map(|c| c.into_token())
78        .take_while(|t| t.kind() != SyntaxKind::NEWLINE)
79        .map(|t| t.text().to_string())
80        .collect();
81    header_indicators(&header)
82}
83
84/// The column a block scalar's indentation indicator counts from: that of
85/// the innermost collection entry holding `scalar`, or 0 at the document
86/// root.
87pub(crate) fn block_parent_column(scalar: &SyntaxNode) -> usize {
88    scalar
89        .ancestors()
90        .find(|n| {
91            matches!(
92                n.kind(),
93                SyntaxKind::MAPPING_ENTRY | SyntaxKind::SEQUENCE_ENTRY
94            )
95        })
96        .map_or(0, |entry| crate::column::node_column(&entry))
97}
98
99/// Replace `\r\n` and lone `\r` line breaks with `\n`, which
100/// `str::lines` does not split on.
101fn normalize_line_breaks(text: &str) -> Cow<'_, str> {
102    if text.contains('\r') {
103        Cow::Owned(text.replace("\r\n", "\n").replace('\r', "\n"))
104    } else {
105        Cow::Borrowed(text)
106    }
107}
108
109impl Scalar {
110    /// Get the string value of this scalar
111    pub fn value(&self) -> String {
112        self.0.text().to_string()
113    }
114
115    /// Get the string representation of this scalar, properly unquoted and unescaped
116    pub fn as_string(&self) -> String {
117        let text = self.value();
118
119        // Handle quoted strings
120        if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
121            // Double-quoted string - handle escape sequences
122            ScalarValue::parse_escape_sequences(&text[1..text.len() - 1])
123        } else if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
124            // Single-quoted string - handle '' -> ' escape and fold multi-line strings
125            let content = &text[1..text.len() - 1];
126            let unescaped = content.replace("''", "'");
127            // Only fold lines if actually multi-line
128            if unescaped.contains(['\n', '\r']) {
129                // A line break folds to a space, or to one newline per empty
130                // line after it, and takes the white space around it along.
131                let unescaped = normalize_line_breaks(&unescaped);
132                let lines: Vec<&str> = unescaped.split('\n').collect();
133                let last = lines.len() - 1;
134                let mut result = lines[0].trim_end_matches([' ', '\t']).to_string();
135                let mut empty_lines = 0;
136                for (i, line) in lines.iter().enumerate().skip(1) {
137                    let line = line.trim_start_matches([' ', '\t']);
138                    let line = if i == last {
139                        line
140                    } else {
141                        line.trim_end_matches([' ', '\t'])
142                    };
143                    if line.is_empty() && i != last {
144                        empty_lines += 1;
145                        continue;
146                    }
147                    match empty_lines {
148                        0 => result.push(' '),
149                        n => result.push_str(&"\n".repeat(n)),
150                    }
151                    empty_lines = 0;
152                    result.push_str(line);
153                }
154                result
155            } else {
156                unescaped
157            }
158        } else if self.is_block() {
159            Self::parse_block_scalar(&text, block_parent_column(&self.0))
160        } else {
161            // Plain scalar - fold lines if multi-line
162            if text.contains(['\n', '\r']) {
163                // Multi-line plain scalar: a single line break folds to a
164                // space, and each additional one is kept, so a blank line
165                // between two lines becomes a newline (YAML 1.2 line folding).
166                let mut result = String::new();
167                let mut pending_breaks = 0usize;
168                for line in normalize_line_breaks(&text).lines() {
169                    let trimmed = line.trim();
170                    if trimmed.is_empty() {
171                        // Only counts once content has started; leading blank
172                        // lines are stripped along with the indentation.
173                        if !result.is_empty() {
174                            pending_breaks += 1;
175                        }
176                        continue;
177                    }
178                    if !result.is_empty() {
179                        if pending_breaks == 0 {
180                            result.push(' ');
181                        } else {
182                            for _ in 0..pending_breaks {
183                                result.push('\n');
184                            }
185                        }
186                    }
187                    pending_breaks = 0;
188                    result.push_str(trimmed);
189                }
190                result
191            } else {
192                text
193            }
194        }
195    }
196
197    /// Parse a block scalar (literal `|` or folded `>`) into its string content
198    ///
199    /// `parent_column` is the column of the collection the scalar is a node
200    /// of, which an explicit indentation indicator counts from.
201    fn parse_block_scalar(text: &str, parent_column: usize) -> String {
202        let text = normalize_line_breaks(text);
203        let mut lines = text.lines();
204        let first_line = match lines.next() {
205            Some(line) => line,
206            None => return String::new(),
207        };
208
209        let is_literal = first_line.starts_with('|');
210        let (chomping, explicit_indent) = header_indicators(first_line);
211
212        // Collect content lines
213        let content_lines: Vec<&str> = lines.collect();
214        if content_lines.is_empty() {
215            return String::new();
216        }
217
218        // An explicit indentation indicator (`|2`, `>2-`) says where the
219        // content starts, counted from the parent collection's column.
220        // Anything further right is part of the value: `|2` over a 4-space
221        // body at the root is `"  x"`, not `"x"`. Without it, take the first
222        // non-empty line's indentation as the base.
223        let base_indent = match explicit_indent {
224            Some(explicit) => parent_column + explicit,
225            // With no content line at all, every line is empty however
226            // many spaces it holds. A tab is not indentation, so a line
227            // with one is not empty: `|\n \t\n` is "\t\n".
228            None => content_lines
229                .iter()
230                .find(|line| !line.trim_start_matches(' ').is_empty())
231                .map_or(usize::MAX, |line| crate::column::space_indent(line)),
232        };
233
234        // A line with no more than `base_indent` spaces and nothing else is
235        // empty; other lines lose up to `base_indent` spaces of indentation.
236        let mut result = String::new();
237        let mut empty_lines = 0;
238        // Whether the previous content line, if any, started with white space
239        let mut prev_spaced: Option<bool> = None;
240
241        for line in &content_lines {
242            // base_indent is a character count, so we step by chars to
243            // stay on UTF-8 boundaries even if the line starts with
244            // multi-byte content at less than base_indent spaces of
245            // indentation.
246            let leading_spaces = crate::column::space_indent(line);
247            let strip = leading_spaces.min(base_indent);
248            let strip_bytes = line
249                .char_indices()
250                .nth(strip)
251                .map_or(line.len(), |(i, _)| i);
252            let stripped = &line[strip_bytes..];
253            // A line indented less than the body holds no content, so tabs
254            // after its spaces are white space too.
255            let less_indented = leading_spaces < base_indent;
256            if stripped.is_empty()
257                || (less_indented && stripped.trim_start_matches([' ', '\t']).is_empty())
258            {
259                empty_lines += 1;
260                continue;
261            }
262
263            // Folding joins two lines with a space, unless empty lines
264            // separate them or either starts with white space, in which
265            // case every line break is kept.
266            let spaced = stripped.starts_with([' ', '\t']);
267            let breaks = match prev_spaced {
268                Some(prev) if is_literal || prev || spaced => empty_lines + 1,
269                Some(_) if empty_lines == 0 => {
270                    result.push(' ');
271                    0
272                }
273                _ => empty_lines,
274            };
275            result.push_str(&"\n".repeat(breaks));
276            result.push_str(stripped);
277            prev_spaced = Some(spaced);
278            empty_lines = 0;
279        }
280
281        // `empty_lines` is now the number of trailing empty lines
282        let has_content = prev_spaced.is_some();
283        match chomping {
284            Chomping::Strip => {}
285            Chomping::Clip => {
286                if has_content {
287                    result.push('\n');
288                }
289            }
290            Chomping::Keep => {
291                // One newline ends the last content line, then one per
292                // trailing blank. A body that is nothing but blank lines has
293                // no content line to end, so it gets only the blanks:
294                // `k: |+\n\n\n` is `"\n\n"`, as saphyr and PyYAML both
295                // read it, not `"\n\n\n"`.
296                let newlines = empty_lines + usize::from(has_content);
297                result.push_str(&"\n".repeat(newlines));
298            }
299        }
300
301        result
302    }
303
304    /// Check if this scalar is quoted
305    pub fn is_quoted(&self) -> bool {
306        let text = self.value();
307        text.len() >= 2
308            && ((text.starts_with('"') && text.ends_with('"'))
309                || (text.starts_with('\'') && text.ends_with('\'')))
310    }
311
312    /// Get the raw content of this scalar with outer quotes stripped, but
313    /// without processing any escape sequences.
314    ///
315    /// For most purposes [`as_string`](Self::as_string) is more appropriate as
316    /// it fully unescapes double-quoted strings (`\"`, `\\`, `\n`, etc.) and
317    /// handles the `''` → `'` escape in single-quoted strings. Use this method
318    /// only when you need the verbatim content without escape processing.
319    pub fn unquoted_value(&self) -> String {
320        let text = self.value();
321        if self.is_quoted() {
322            text[1..text.len() - 1].to_string()
323        } else {
324            text
325        }
326    }
327}
328
329impl Scalar {
330    /// Replace the text content of this scalar with `value`.
331    ///
332    /// The token is stored with `SyntaxKind::STRING` regardless of the semantic
333    /// type of `value` (e.g., setting `"42"` does not produce an `INT` token).
334    /// If token-kind accuracy matters, build a replacement scalar node via the
335    /// higher-level API instead.
336    pub fn set_value(&self, value: &str) {
337        let children: Vec<_> = self.0.children_with_tokens().collect();
338        // A block scalar holds the line break ending its last line and the
339        // blank lines after that, which stay but must move out of it.
340        let mut tail = Vec::new();
341        if crate::nodes::is_block_scalar(&self.0) {
342            let is_layout = |k| {
343                matches!(
344                    k,
345                    SyntaxKind::NEWLINE | SyntaxKind::INDENT | SyntaxKind::WHITESPACE
346                )
347            };
348            let content_end = children
349                .iter()
350                .rposition(|c| !is_layout(c.kind()))
351                .map_or(0, |i| i + 1);
352            tail = children[content_end..]
353                .iter()
354                .skip_while(|c| c.kind() != SyntaxKind::NEWLINE)
355                .filter_map(|c| c.as_token().cloned())
356                .collect();
357        }
358        crate::nodes::replace_children(
359            &self.0,
360            0..children.len(),
361            vec![fresh_token(SyntaxKind::STRING, value).into()],
362        );
363        if let Some((line_break, blank_lines)) = tail.split_first() {
364            crate::nodes::trivia::end_former_block_scalar(&self.0, line_break, blank_lines);
365        }
366    }
367
368    crate::nodes::ast_node_spans!("scalar");
369
370    /// Try to interpret this scalar as an i64.
371    ///
372    /// Returns `None` if the scalar is not a plain YAML 1.2 integer (decimal,
373    /// `0o` octal or `0x` hexadecimal) or does not fit in an `i64`.
374    pub fn as_i64(&self) -> Option<i64> {
375        TryInto::<i64>::try_into(self).ok()
376    }
377
378    /// Try to interpret this scalar as an f64.
379    ///
380    /// Returns `None` if the scalar is not a plain YAML 1.2 float or
381    /// integer.
382    pub fn as_f64(&self) -> Option<f64> {
383        TryInto::<f64>::try_into(self).ok()
384    }
385
386    /// Try to interpret this scalar as a bool.
387    ///
388    /// Returns `None` if the scalar is not a plain YAML 1.2 boolean: true,
389    /// True, TRUE, false, False or FALSE.
390    pub fn as_bool(&self) -> Option<bool> {
391        TryInto::<bool>::try_into(self).ok()
392    }
393
394    /// Check if this scalar is a literal (`|`) or folded (`>`) block scalar
395    ///
396    /// A plain scalar starting with `|` or `>`, which the parser accepts
397    /// in a flow collection, is not one.
398    pub fn is_block(&self) -> bool {
399        crate::nodes::is_block_scalar(&self.0)
400    }
401
402    /// Check if this scalar represents a null value.
403    ///
404    /// Returns `true` if the plain value is null, Null, NULL, ~, or empty.
405    /// Quoted and block scalars are always strings, never null.
406    pub fn is_null(&self) -> bool {
407        if self.is_quoted() || self.is_block() {
408            return false;
409        }
410        let val = self.as_string();
411        matches!(val.as_str(), "null" | "Null" | "NULL" | "~" | "")
412    }
413
414    /// Whether this is a plain scalar holding a flow indicator, at which
415    /// it would end inside a flow collection.
416    pub(crate) fn needs_flow_quotes(&self) -> bool {
417        !self.is_quoted() && !self.is_block() && self.value().contains([',', '[', ']', '{', '}'])
418    }
419}
420
421impl AsYaml for Scalar {
422    fn as_node(&self) -> Option<&SyntaxNode> {
423        Some(&self.0)
424    }
425
426    fn kind(&self) -> YamlKind {
427        YamlKind::Scalar
428    }
429
430    /// The continuation lines of a multi-line plain or quoted scalar are
431    /// shifted as for a flow collection, keeping their offset from the
432    /// indentation of the line the scalar starts on, which becomes
433    /// `indent`. Leading whitespace on those lines is not content, so the
434    /// value is unchanged. A block scalar is copied as is: its body is laid
435    /// out by the caller, and its indentation can be content.
436    fn build_content(
437        &self,
438        builder: &mut rowan::GreenNodeBuilder,
439        indent: usize,
440        flow_context: bool,
441    ) -> bool {
442        if flow_context && self.needs_flow_quotes() {
443            return ScalarValue::double_quoted(self.as_string())
444                .build_content(builder, indent, true);
445        }
446        if !ValueNode::is_inline(self) {
447            crate::yaml::copy_node_to_builder(builder, &self.0);
448            return false;
449        }
450        let delta = indent as isize - crate::column::line_indent(&self.0) as isize;
451        let delta = crate::as_yaml::clear_parent_column(&self.0, indent, delta);
452        builder.start_node(SyntaxKind::SCALAR.into());
453        copy_shifting_continuation_lines(builder, &self.0, delta, false);
454        builder.finish_node();
455        // Scalars don't end with newlines
456        false
457    }
458
459    fn is_inline(&self) -> bool {
460        ValueNode::is_inline(self)
461    }
462}
463
464/// Copy the children of `node`, moving the indentation of every line after
465/// the first by `delta` columns, both between tokens (a plain scalar) and
466/// within them (a quoted scalar). Empty lines stay empty. Returns whether
467/// the copy ends with a line break.
468fn copy_shifting_continuation_lines(
469    builder: &mut rowan::GreenNodeBuilder,
470    node: &SyntaxNode,
471    delta: isize,
472    mut after_newline: bool,
473) -> bool {
474    for child in node.children_with_tokens() {
475        let token = match child {
476            rowan::NodeOrToken::Node(n) => {
477                builder.start_node(n.kind().into());
478                after_newline = copy_shifting_continuation_lines(builder, &n, delta, after_newline);
479                builder.finish_node();
480                continue;
481            }
482            rowan::NodeOrToken::Token(t) => t,
483        };
484        match token.kind() {
485            SyntaxKind::NEWLINE => {
486                builder.token(token.kind().into(), token.text());
487                after_newline = true;
488                continue;
489            }
490            SyntaxKind::INDENT | SyntaxKind::WHITESPACE if after_newline => {
491                let shifted = crate::as_yaml::shift_line_start(token.text(), delta);
492                if !shifted.is_empty() {
493                    builder.token(SyntaxKind::INDENT.into(), &shifted);
494                }
495            }
496            kind => {
497                if after_newline && delta > 0 {
498                    builder.token(SyntaxKind::INDENT.into(), &" ".repeat(delta as usize));
499                }
500                builder.token(
501                    kind.into(),
502                    &crate::as_yaml::shift_inner_lines(token.text(), delta),
503                );
504            }
505        }
506        after_newline = false;
507    }
508    after_newline
509}
510
511// TryFrom implementations for typed access. These read the scalar the way
512// the YAML 1.2 core schema resolves it, so only a plain scalar of the
513// matching type converts.
514fn typed_value(scalar: &Scalar) -> Result<ScalarValue, ScalarConversionError> {
515    if scalar.is_quoted() {
516        return Err(ScalarConversionError::QuotedValue);
517    }
518    Ok(ScalarValue::from_scalar(scalar))
519}
520
521impl TryFrom<&Scalar> for i64 {
522    type Error = ScalarConversionError;
523
524    fn try_from(scalar: &Scalar) -> Result<Self, Self::Error> {
525        let value = typed_value(scalar)?;
526        value.to_i64().ok_or_else(|| {
527            ScalarConversionError::ParseError(format!(
528                "'{}' is not an integer that fits in an i64",
529                value.value()
530            ))
531        })
532    }
533}
534
535impl TryFrom<&Scalar> for f64 {
536    type Error = ScalarConversionError;
537
538    fn try_from(scalar: &Scalar) -> Result<Self, Self::Error> {
539        let value = typed_value(scalar)?;
540        value
541            .to_f64()
542            .or_else(|| value.to_i64().map(|i| i as f64))
543            .ok_or_else(|| {
544                ScalarConversionError::ParseError(format!("'{}' is not a float", value.value()))
545            })
546    }
547}
548
549impl TryFrom<&Scalar> for bool {
550    type Error = ScalarConversionError;
551
552    fn try_from(scalar: &Scalar) -> Result<Self, Self::Error> {
553        let value = typed_value(scalar)?;
554        value.to_bool().ok_or_else(|| {
555            ScalarConversionError::ParseError(format!(
556                "'{}' is not a recognized boolean value",
557                value.value()
558            ))
559        })
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    #[test]
566    fn test_parse_block_scalar_multibyte_after_dedent() {
567        // Regression: `base_indent` is a char count but we sliced bytes,
568        // which panicked when a continuation line started with a multi-byte
569        // character at less than `base_indent` spaces of indentation.
570        let yaml = ">\n  a\n\u{4f1}b\n";
571        // We only care that it does not panic.
572        let _ = super::Scalar::parse_block_scalar(yaml, 0);
573    }
574}