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::fmt;
8
9ast_node!(Scalar, SCALAR, "A YAML scalar value");
10
11/// Chomping indicator for block scalars
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13enum Chomping {
14    /// Strip final line breaks (indicator: -)
15    Strip,
16    /// Keep final line breaks (indicator: +)
17    Keep,
18    /// Clip to single final line break (default, no indicator)
19    Clip,
20}
21
22/// Error type for scalar type conversions
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ScalarConversionError {
25    /// The scalar value is quoted, indicating it's a string type in YAML
26    QuotedValue,
27    /// The scalar value cannot be parsed as the target type
28    ParseError(String),
29}
30
31impl fmt::Display for ScalarConversionError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        match self {
34            ScalarConversionError::QuotedValue => {
35                write!(f, "Cannot convert quoted scalar to numeric/boolean type")
36            }
37            ScalarConversionError::ParseError(msg) => {
38                write!(f, "Failed to parse scalar: {msg}")
39            }
40        }
41    }
42}
43
44impl std::error::Error for ScalarConversionError {}
45
46impl Scalar {
47    /// Get the string value of this scalar
48    pub fn value(&self) -> String {
49        self.0.text().to_string()
50    }
51
52    /// Get the string representation of this scalar, properly unquoted and unescaped
53    pub fn as_string(&self) -> String {
54        let text = self.value();
55
56        // Handle quoted strings
57        if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
58            // Double-quoted string - handle escape sequences
59            ScalarValue::parse_escape_sequences(&text[1..text.len() - 1])
60        } else if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
61            // Single-quoted string - handle '' -> ' escape and fold multi-line strings
62            let content = &text[1..text.len() - 1];
63            let unescaped = content.replace("''", "'");
64            // Only fold lines if actually multi-line
65            if unescaped.contains('\n') {
66                // Fold line breaks (newlines + indentation) to spaces per YAML spec
67                // Using fold() avoids intermediate Vec allocation
68                let mut result = String::new();
69                for (i, line) in unescaped.lines().enumerate() {
70                    if i > 0 {
71                        result.push(' ');
72                    }
73                    result.push_str(line.trim());
74                }
75                result
76            } else {
77                unescaped
78            }
79        } else if text.starts_with('|') || text.starts_with('>') {
80            // Block scalar (literal or folded)
81            Self::parse_block_scalar(&text)
82        } else {
83            // Plain scalar - fold lines if multi-line
84            if text.contains('\n') {
85                // Multi-line plain scalar: a single line break folds to a
86                // space, and each additional one is kept, so a blank line
87                // between two lines becomes a newline (YAML 1.2 line folding).
88                let mut result = String::new();
89                let mut pending_breaks = 0usize;
90                for line in text.lines() {
91                    let trimmed = line.trim();
92                    if trimmed.is_empty() {
93                        // Only counts once content has started; leading blank
94                        // lines are stripped along with the indentation.
95                        if !result.is_empty() {
96                            pending_breaks += 1;
97                        }
98                        continue;
99                    }
100                    if !result.is_empty() {
101                        if pending_breaks == 0 {
102                            result.push(' ');
103                        } else {
104                            for _ in 0..pending_breaks {
105                                result.push('\n');
106                            }
107                        }
108                    }
109                    pending_breaks = 0;
110                    result.push_str(trimmed);
111                }
112                result
113            } else {
114                text
115            }
116        }
117    }
118
119    /// Parse a block scalar (literal `|` or folded `>`) into its string content
120    fn parse_block_scalar(text: &str) -> String {
121        let mut lines = text.lines();
122        let first_line = match lines.next() {
123            Some(line) => line,
124            None => return String::new(),
125        };
126
127        let is_literal = first_line.starts_with('|');
128
129        // Parse chomping indicator and indentation from header
130        let header = first_line.trim();
131        let chomping = if header.contains('-') {
132            Chomping::Strip
133        } else if header.contains('+') {
134            Chomping::Keep
135        } else {
136            Chomping::Clip
137        };
138
139        // Collect content lines
140        let content_lines: Vec<&str> = lines.collect();
141        if content_lines.is_empty() {
142            return String::new();
143        }
144
145        // An explicit indentation indicator (`|2`, `>2-`) says where the
146        // content starts, counted from the block's own column. Anything
147        // further right is part of the value: `|2` over a 4-space body is
148        // `"  x"`, not `"x"`. Without it, take the first non-empty line's
149        // indentation as the base.
150        let explicit_indent = header
151            .chars()
152            .find(|c| c.is_ascii_digit() && *c != '0')
153            .and_then(|c| c.to_digit(10))
154            .map(|d| d as usize);
155        let base_indent = match explicit_indent {
156            Some(explicit) => explicit,
157            None => content_lines
158                .iter()
159                .find(|line| !line.trim().is_empty())
160                .map_or(0, |line| line.chars().take_while(|c| *c == ' ').count()),
161        };
162
163        // Count trailing empty lines for Keep chomping
164        let trailing_empty_count = content_lines
165            .iter()
166            .rev()
167            .take_while(|line| line.trim().is_empty())
168            .count();
169
170        // Process content
171        let mut result = String::new();
172        let mut prev_was_empty = false;
173        let mut prev_was_more_indented = false;
174
175        for (i, line) in content_lines.iter().enumerate() {
176            if line.trim().is_empty() {
177                // Empty line
178                if is_literal {
179                    // Literal: each line (including empty) gets a newline after it
180                    result.push('\n');
181                } else {
182                    // Folded: empty lines create paragraph breaks (single newline)
183                    if !prev_was_empty && i > 0 {
184                        // Add newline to create paragraph break
185                        result.push('\n');
186                    }
187                }
188                prev_was_empty = true;
189                prev_was_more_indented = false;
190            } else {
191                // Non-empty line - strip up to `base_indent` leading spaces.
192                // base_indent is a character count, so we step by chars to
193                // stay on UTF-8 boundaries even if the line starts with
194                // multi-byte content at less than base_indent spaces of
195                // indentation.
196                let leading_spaces = line.chars().take_while(|c| *c == ' ').count();
197                let strip = leading_spaces.min(base_indent);
198                let strip_bytes = line
199                    .char_indices()
200                    .nth(strip)
201                    .map_or(line.len(), |(i, _)| i);
202                let stripped = &line[strip_bytes..];
203
204                if is_literal {
205                    // Literal: each line gets content + newline
206                    result.push_str(stripped);
207                    result.push('\n');
208                    prev_was_more_indented = false;
209                } else {
210                    // Folded: check if line is more indented than base
211                    let line_indent = line.chars().take_while(|c| *c == ' ').count();
212                    let is_more_indented = line_indent > base_indent;
213
214                    if is_more_indented {
215                        // More-indented lines: preserve on their own line with extra indent
216                        if i > 0 && !prev_was_empty && !prev_was_more_indented {
217                            // Only add newline if transitioning from normal to more-indented
218                            result.push('\n');
219                        }
220                        result.push_str(stripped);
221                        result.push('\n');
222                        prev_was_more_indented = true;
223                    } else {
224                        // Normal line: fold with previous unless after empty line or more-indented
225                        if i > 0 {
226                            if prev_was_empty || prev_was_more_indented {
227                                // After paragraph break or more-indented section, don't add space
228                                result.push_str(stripped);
229                            } else {
230                                // Join with space
231                                result.push(' ');
232                                result.push_str(stripped);
233                            }
234                        } else {
235                            // First line
236                            result.push_str(stripped);
237                        }
238                        prev_was_more_indented = false;
239                    }
240                }
241                prev_was_empty = false;
242            }
243        }
244
245        // Apply chomping
246        match chomping {
247            Chomping::Strip => {
248                // Remove all trailing newlines
249                result = result.trim_end_matches('\n').to_string();
250            }
251            Chomping::Clip => {
252                // Keep single trailing newline
253                result = result.trim_end_matches('\n').to_string();
254                result.push('\n');
255            }
256            Chomping::Keep => {
257                // Keep all trailing newlines - preserve the count we detected
258                // Remove all trailing newlines first, then add back the original count
259                result = result.trim_end_matches('\n').to_string();
260                // One newline ends the last content line, then one per
261                // trailing blank. A body that is nothing but blank lines has
262                // no content line to end, so it gets only the blanks:
263                // `k: |+\n\n\n` is `"\n\n"`, as saphyr and PyYAML both
264                // read it, not `"\n\n\n"`.
265                let has_content = content_lines.len() > trailing_empty_count;
266                let newlines = trailing_empty_count + usize::from(has_content);
267                for _ in 0..newlines {
268                    result.push('\n');
269                }
270            }
271        }
272
273        result
274    }
275
276    /// Check if this scalar is quoted
277    pub fn is_quoted(&self) -> bool {
278        let text = self.value();
279        text.len() >= 2
280            && ((text.starts_with('"') && text.ends_with('"'))
281                || (text.starts_with('\'') && text.ends_with('\'')))
282    }
283
284    /// Get the raw content of this scalar with outer quotes stripped, but
285    /// without processing any escape sequences.
286    ///
287    /// For most purposes [`as_string`](Self::as_string) is more appropriate as
288    /// it fully unescapes double-quoted strings (`\"`, `\\`, `\n`, etc.) and
289    /// handles the `''` → `'` escape in single-quoted strings. Use this method
290    /// only when you need the verbatim content without escape processing.
291    pub fn unquoted_value(&self) -> String {
292        let text = self.value();
293        if self.is_quoted() {
294            text[1..text.len() - 1].to_string()
295        } else {
296            text
297        }
298    }
299}
300
301impl Scalar {
302    /// Replace the text content of this scalar with `value`.
303    ///
304    /// The token is stored with `SyntaxKind::STRING` regardless of the semantic
305    /// type of `value` (e.g., setting `"42"` does not produce an `INT` token).
306    /// If token-kind accuracy matters, build a replacement scalar node via the
307    /// higher-level API instead.
308    pub fn set_value(&self, value: &str) {
309        let children_count = self.0.children_with_tokens().count();
310        self.0.splice_children(
311            0..children_count,
312            vec![fresh_token(SyntaxKind::STRING, value).into()],
313        );
314    }
315
316    crate::nodes::ast_node_spans!("scalar");
317
318    /// Try to interpret this scalar as an i64.
319    ///
320    /// Returns `None` if the scalar is quoted (string type) or cannot be parsed as an integer.
321    /// Supports decimal, octal (0o), hexadecimal (0x), and binary (0b) notation.
322    pub fn as_i64(&self) -> Option<i64> {
323        TryInto::<i64>::try_into(self).ok()
324    }
325
326    /// Try to interpret this scalar as an f64.
327    ///
328    /// Returns `None` if the scalar is quoted (string type) or cannot be parsed as a float.
329    pub fn as_f64(&self) -> Option<f64> {
330        TryInto::<f64>::try_into(self).ok()
331    }
332
333    /// Try to interpret this scalar as a bool.
334    ///
335    /// Returns `None` if the scalar is quoted (string type) or is not a recognized boolean value.
336    /// Recognizes: true, false, True, False, TRUE, FALSE, yes, no, Yes, No, YES, NO, on, off, On, Off, ON, OFF
337    pub fn as_bool(&self) -> Option<bool> {
338        TryInto::<bool>::try_into(self).ok()
339    }
340
341    /// Check if this scalar represents a null value.
342    ///
343    /// Returns `true` if the unquoted value is null, Null, NULL, ~, or empty.
344    pub fn is_null(&self) -> bool {
345        if self.is_quoted() {
346            return false;
347        }
348        let val = self.as_string();
349        matches!(val.as_str(), "null" | "Null" | "NULL" | "~" | "")
350    }
351}
352
353impl AsYaml for Scalar {
354    fn as_node(&self) -> Option<&SyntaxNode> {
355        Some(&self.0)
356    }
357
358    fn kind(&self) -> YamlKind {
359        YamlKind::Scalar
360    }
361
362    fn build_content(
363        &self,
364        builder: &mut rowan::GreenNodeBuilder,
365        _indent: usize,
366        _flow_context: bool,
367    ) -> bool {
368        crate::as_yaml::copy_node_content(builder, &self.0);
369        // Scalars don't end with newlines
370        false
371    }
372
373    fn is_inline(&self) -> bool {
374        ValueNode::is_inline(self)
375    }
376}
377
378// TryFrom implementations for typed access
379impl TryFrom<&Scalar> for i64 {
380    type Error = ScalarConversionError;
381
382    fn try_from(scalar: &Scalar) -> Result<Self, Self::Error> {
383        if scalar.is_quoted() {
384            return Err(ScalarConversionError::QuotedValue);
385        }
386
387        let value = scalar.as_string();
388
389        // Handle different number formats
390        if let Some(hex) = value
391            .strip_prefix("0x")
392            .or_else(|| value.strip_prefix("0X"))
393        {
394            i64::from_str_radix(hex, 16)
395                .map_err(|e| ScalarConversionError::ParseError(e.to_string()))
396        } else if let Some(octal) = value
397            .strip_prefix("0o")
398            .or_else(|| value.strip_prefix("0O"))
399        {
400            i64::from_str_radix(octal, 8)
401                .map_err(|e| ScalarConversionError::ParseError(e.to_string()))
402        } else if let Some(binary) = value
403            .strip_prefix("0b")
404            .or_else(|| value.strip_prefix("0B"))
405        {
406            i64::from_str_radix(binary, 2)
407                .map_err(|e| ScalarConversionError::ParseError(e.to_string()))
408        } else {
409            value
410                .parse::<i64>()
411                .map_err(|e| ScalarConversionError::ParseError(e.to_string()))
412        }
413    }
414}
415
416impl TryFrom<&Scalar> for f64 {
417    type Error = ScalarConversionError;
418
419    fn try_from(scalar: &Scalar) -> Result<Self, Self::Error> {
420        if scalar.is_quoted() {
421            return Err(ScalarConversionError::QuotedValue);
422        }
423
424        let value = scalar.as_string();
425
426        // Handle special float values
427        match value.as_str() {
428            ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => Ok(f64::INFINITY),
429            "-.inf" | "-.Inf" | "-.INF" => Ok(f64::NEG_INFINITY),
430            ".nan" | ".NaN" | ".NAN" => Ok(f64::NAN),
431            _ => value
432                .parse::<f64>()
433                .map_err(|e| ScalarConversionError::ParseError(e.to_string())),
434        }
435    }
436}
437
438impl TryFrom<&Scalar> for bool {
439    type Error = ScalarConversionError;
440
441    fn try_from(scalar: &Scalar) -> Result<Self, Self::Error> {
442        if scalar.is_quoted() {
443            return Err(ScalarConversionError::QuotedValue);
444        }
445
446        let value = scalar.as_string();
447
448        // YAML 1.2 Core Schema boolean values
449        match value.as_str() {
450            "true" | "True" | "TRUE" => Ok(true),
451            "false" | "False" | "FALSE" => Ok(false),
452            // YAML 1.1 compatibility (commonly used)
453            "yes" | "Yes" | "YES" | "on" | "On" | "ON" => Ok(true),
454            "no" | "No" | "NO" | "off" | "Off" | "OFF" => Ok(false),
455            _ => Err(ScalarConversionError::ParseError(format!(
456                "'{value}' is not a recognized boolean value"
457            ))),
458        }
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use crate::Document;
465    use std::str::FromStr;
466
467    #[test]
468    fn test_unterminated_quote_as_string_does_not_panic() {
469        let (doc, errors) = Document::from_str_relaxed("\"");
470        assert!(!errors.is_empty());
471        let scalar = doc.as_scalar().expect("relaxed parse keeps a scalar");
472        assert_eq!(scalar.as_string(), "\"");
473        assert_eq!(scalar.unquoted_value(), "\"");
474
475        let (doc, _) = Document::from_str_relaxed("'");
476        let scalar = doc.as_scalar().expect("relaxed parse keeps a scalar");
477        assert_eq!(scalar.as_string(), "'");
478        assert_eq!(scalar.unquoted_value(), "'");
479    }
480
481    #[test]
482    fn test_json_array_quoted_strings_cst_structure() {
483        // This test verifies that quoted strings in flow sequences (JSON arrays)
484        // don't incorrectly consume trailing whitespace into the SCALAR node.
485        //
486        // The bug was that the parser would include NEWLINE and INDENT tokens
487        // as children of the SCALAR node instead of as siblings.
488
489        let json = r#"{
490  "items": [
491    "first",
492    "second"
493  ]
494}"#;
495
496        let doc = Document::from_str(json).unwrap();
497        let mapping = doc.as_mapping().unwrap();
498        let items = mapping.get("items").unwrap();
499        let sequence = items.as_sequence().unwrap();
500
501        // Get the scalars
502        let values: Vec<_> = sequence
503            .values()
504            .filter_map(|node| {
505                if let crate::YamlNode::Scalar(scalar) = node {
506                    Some(scalar)
507                } else {
508                    None
509                }
510            })
511            .collect();
512
513        assert_eq!(values.len(), 2);
514
515        // Both values should be clean quoted strings without trailing whitespace
516        assert_eq!(
517            values[0].value(),
518            r#""first""#,
519            "first item should not have trailing whitespace"
520        );
521        assert_eq!(
522            values[1].value(),
523            r#""second""#,
524            "second item should not have trailing whitespace"
525        );
526
527        // as_string() should correctly unquote
528        assert_eq!(values[0].as_string(), "first");
529        assert_eq!(values[1].as_string(), "second");
530    }
531
532    #[test]
533    fn test_compact_json_array() {
534        // Compact JSON should also work correctly
535        let json = r#"{"items": ["first", "second"]}"#;
536
537        let doc = Document::from_str(json).unwrap();
538        let mapping = doc.as_mapping().unwrap();
539        let items = mapping.get("items").unwrap();
540        let sequence = items.as_sequence().unwrap();
541
542        let values: Vec<_> = sequence
543            .values()
544            .filter_map(|node| {
545                if let crate::YamlNode::Scalar(scalar) = node {
546                    Some(scalar)
547                } else {
548                    None
549                }
550            })
551            .collect();
552
553        assert_eq!(values.len(), 2);
554        assert_eq!(values[0].value(), r#""first""#);
555        assert_eq!(values[1].value(), r#""second""#);
556        assert_eq!(values[0].as_string(), "first");
557        assert_eq!(values[1].as_string(), "second");
558    }
559
560    #[test]
561    fn test_yaml_flow_arrays_quoted_strings() {
562        // YAML flow-style arrays should behave the same
563        let yaml = r#"
564items: ["first", "second"]
565"#;
566
567        let doc = Document::from_str(yaml).unwrap();
568        let mapping = doc.as_mapping().unwrap();
569        let items = mapping.get("items").unwrap();
570        let sequence = items.as_sequence().unwrap();
571
572        let values: Vec<_> = sequence
573            .values()
574            .filter_map(|node| {
575                if let crate::YamlNode::Scalar(scalar) = node {
576                    Some(scalar)
577                } else {
578                    None
579                }
580            })
581            .collect();
582
583        assert_eq!(values.len(), 2);
584        assert_eq!(values[0].value(), r#""first""#);
585        assert_eq!(values[1].value(), r#""second""#);
586        assert_eq!(values[0].as_string(), "first");
587        assert_eq!(values[1].as_string(), "second");
588    }
589
590    #[test]
591    fn test_parse_block_scalar_multibyte_after_dedent() {
592        // Regression: `base_indent` is a char count but we sliced bytes,
593        // which panicked when a continuation line started with a multi-byte
594        // character at less than `base_indent` spaces of indentation.
595        let yaml = ">\n  a\n\u{4f1}b\n";
596        // We only care that it does not panic.
597        let _ = super::Scalar::parse_block_scalar(yaml);
598    }
599}