Skip to main content

i_slint_compiler/
literals.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4// cSpell: ignore qsdf
5use crate::diagnostics::{BuildDiagnostics, SourceLocation, Span, Spanned};
6use crate::expression_tree::WrittenUnit;
7use itertools::Itertools;
8use smol_str::SmolStr;
9use strum::IntoEnumIterator;
10
11/// Describes one chunk produced by [`walk_escapes`].
12enum EscapeChunk<'a> {
13    /// Consecutive plain characters (same bytes in source and output).
14    Plain(&'a str),
15    /// An escape sequence: `source_len` bytes in the source produce `decoded`.
16    Escape { source_len: usize, decoded: char },
17}
18
19/// Error returned by [`walk_escapes`]: byte offset within the raw token and a
20/// human-readable message.
21struct EscapeError {
22    offset: usize,
23    length: usize,
24    message: &'static str,
25}
26
27/// Walk a string literal token (including its delimiters), strip the delimiters,
28/// and call `callback` for each chunk of the content. Returns `Ok(())` on success,
29/// or an [`EscapeError`] pointing at the problematic byte in the raw token.
30fn walk_escapes<'a>(
31    raw_token: &'a str,
32    mut callback: impl FnMut(EscapeChunk<'a>),
33) -> Result<(), EscapeError> {
34    if raw_token.contains('\n') {
35        return Err(EscapeError { offset: 0, length: 0, message: "Newline in string literal" });
36    }
37    let prefix_len = if raw_token.starts_with('"') || raw_token.starts_with('}') {
38        1
39    } else {
40        return Err(EscapeError { offset: 0, length: 0, message: "Cannot parse string literal" });
41    };
42    let content = &raw_token[prefix_len..];
43    let content = content
44        .strip_suffix('"')
45        .or_else(|| content.strip_suffix("\\{"))
46        .ok_or(EscapeError { offset: 0, length: 0, message: "Cannot parse string literal" })?;
47
48    let mut pos = 0;
49    while pos < content.len() {
50        if content.as_bytes()[pos] == b'\\' {
51            if pos + 1 >= content.len() {
52                return Err(EscapeError {
53                    offset: prefix_len + pos,
54                    length: 1,
55                    message: r"Unknown escape sequence. Use '\\' to escape a literal backslash",
56                });
57            }
58            let (source_len, decoded) = match content.as_bytes()[pos + 1] {
59                b'"' => (2, '"'),
60                b'\\' => (2, '\\'),
61                b'n' => (2, '\n'),
62                b'u' => {
63                    let brace_start = pos + 2;
64                    let has_brace = content.as_bytes().get(brace_start) == Some(&b'{');
65                    if !has_brace {
66                        return Err(EscapeError {
67                            offset: prefix_len + brace_start,
68                            length: 0,
69                            message: "Invalid unicode escape: expected '{'",
70                        });
71                    }
72                    let brace_end = match content[brace_start..].find('}') {
73                        Some(i) => i + brace_start,
74                        None => {
75                            return Err(EscapeError {
76                                offset: prefix_len + brace_start,
77                                length: 0,
78                                message: "Unterminated unicode escape",
79                            });
80                        }
81                    };
82                    let hex = &content[brace_start + 1..brace_end];
83                    let x = u32::from_str_radix(hex, 16).map_err(|_| EscapeError {
84                        offset: prefix_len + brace_start + 1,
85                        length: hex.len(),
86                        message: "Invalid hexadecimal in unicode escape",
87                    })?;
88                    let ch = std::char::from_u32(x).ok_or(EscapeError {
89                        offset: prefix_len + brace_start + 1,
90                        length: hex.len(),
91                        message: "Invalid unicode code point",
92                    })?;
93                    (brace_end + 1 - pos, ch)
94                }
95                _ => {
96                    let next_char_len =
97                        content[pos + 1..].chars().next().map_or(1, |c| c.len_utf8());
98                    return Err(EscapeError {
99                        offset: prefix_len + pos,
100                        length: 1 + next_char_len,
101                        message: r"Unknown escape sequence. Use '\\' to escape a literal backslash",
102                    });
103                }
104            };
105            callback(EscapeChunk::Escape { source_len, decoded });
106            pos += source_len;
107        } else {
108            let start = pos;
109            pos = content[pos..].find('\\').map_or(content.len(), |i| pos + i);
110            callback(EscapeChunk::Plain(&content[start..pos]));
111        }
112    }
113    Ok(())
114}
115
116/// Unescape a string literal token, returning `None` on error.
117pub fn unescape_string(string: &str) -> Option<SmolStr> {
118    let mut result = String::with_capacity(string.len());
119    walk_escapes(string, |chunk| match chunk {
120        EscapeChunk::Plain(s) => result += s,
121        EscapeChunk::Escape { decoded, .. } => result.push(decoded),
122    })
123    .ok()?;
124    Some(result.into())
125}
126
127/// Unescape a string literal token, reporting any error on the token's source location
128/// with the span pointing at the invalid escape sequence.
129/// If `token` is `None` (no string literal found), reports a generic error on `fallback`.
130pub fn unescape_string_reporting(
131    token: Option<&crate::parser::SyntaxToken>,
132    diag: &mut BuildDiagnostics,
133    fallback: &dyn Spanned,
134) -> Option<SmolStr> {
135    let Some(token) = token else {
136        diag.push_error("Cannot parse string literal".into(), fallback);
137        return None;
138    };
139    let mut result = String::with_capacity(token.text().len());
140    match walk_escapes(token.text(), |chunk| match chunk {
141        EscapeChunk::Plain(s) => result += s,
142        EscapeChunk::Escape { decoded, .. } => result.push(decoded),
143    }) {
144        Ok(()) => Some(result.into()),
145        Err(e) => {
146            let loc = token.to_source_location();
147            diag.push_error_with_span(
148                e.message.into(),
149                SourceLocation {
150                    source_file: loc.source_file,
151                    span: Span::new(loc.span.offset + e.offset, e.length),
152                },
153            );
154            None
155        }
156    }
157}
158
159#[test]
160fn test_unescape_string() {
161    assert_eq!(unescape_string(r#""foo_bar""#).as_deref(), Some("foo_bar"));
162    assert_eq!(unescape_string(r#""foo\"bar""#).as_deref(), Some("foo\"bar"));
163    assert_eq!(unescape_string(r#""foo\\\"bar""#).as_deref(), Some("foo\\\"bar"));
164    assert_eq!(unescape_string(r#""fo\na\\r""#).as_deref(), Some("fo\na\\r"));
165    assert_eq!(unescape_string(r#""fo\xa""#), None);
166    assert_eq!(unescape_string(r#""fooo\""#), None);
167    assert_eq!(unescape_string(r#""f\n\n\nf""#).as_deref(), Some("f\n\n\nf"));
168    assert_eq!(unescape_string(r#""music\♪xx""#), None);
169    assert_eq!(unescape_string(r#""music\"♪\"🎝""#).as_deref(), Some("music\"♪\"🎝"));
170    assert_eq!(unescape_string(r#""foo_bar"#), None);
171    assert_eq!(unescape_string(r#""foo_bar\"#), None);
172    assert_eq!(unescape_string(r#"foo_bar""#), None);
173    assert_eq!(
174        unescape_string(r#""d\u{8}a\u{d4}f\u{Ed3}""#).as_deref(),
175        Some("d\u{8}a\u{d4}f\u{ED3}")
176    );
177    assert_eq!(unescape_string(r#""xxx\""#), None);
178    assert_eq!(unescape_string(r#""xxx\u""#), None);
179    assert_eq!(unescape_string(r#""xxx\uxx""#), None);
180    assert_eq!(unescape_string(r#""xxx\u{""#), None);
181    assert_eq!(unescape_string(r#""xxx\u{22""#), None);
182    assert_eq!(unescape_string(r#""xxx\u{qsdf}""#), None);
183    assert_eq!(unescape_string(r#""xxx\u{1234567890}""#), None);
184}
185
186/// Maps byte offsets in a string assembled from one or more string literal tokens
187/// back to precise source locations, accounting for escape sequences.
188#[derive(Default)]
189pub struct StringLiteralSourceMap {
190    assembled: String,
191    entries: Vec<SourceMapEntry>,
192}
193
194/// One segment where assembled-string offsets map 1:1 to source-file offsets.
195/// A new entry is created at every escape boundary.
196struct SourceMapEntry {
197    /// Start byte offset in the assembled (unescaped) string.
198    assembled_start: usize,
199    /// Absolute byte offset in the source file corresponding to `assembled_start`.
200    source_offset: usize,
201    source_file: Option<crate::diagnostics::SourceFile>,
202}
203
204impl StringLiteralSourceMap {
205    pub fn new() -> Self {
206        Self::default()
207    }
208
209    /// Return the assembled (unescaped) string.
210    pub fn as_str(&self) -> &str {
211        &self.assembled
212    }
213
214    /// Consume the source map and return the assembled string.
215    pub fn into_string(self) -> String {
216        self.assembled
217    }
218
219    /// Unescape a string literal token, appending to the internal assembled string
220    /// and recording the source mapping. Reports errors to `diag` and returns
221    /// `false` on failure.
222    pub fn push(
223        &mut self,
224        token: &crate::parser::SyntaxToken,
225        diag: &mut BuildDiagnostics,
226    ) -> bool {
227        let loc = token.to_source_location();
228        let token_offset = loc.span.offset;
229        let raw = token.text();
230        let base = self.assembled.len();
231
232        let mut source_pos = 1usize;
233        let mut segment_start_assembled = base;
234        let mut segment_start_source = 1usize;
235
236        let result = walk_escapes(raw, |chunk| match chunk {
237            EscapeChunk::Plain(s) => {
238                self.assembled += s;
239                source_pos += s.len();
240            }
241            EscapeChunk::Escape { source_len, decoded } => {
242                if self.assembled.len() > segment_start_assembled {
243                    self.entries.push(SourceMapEntry {
244                        assembled_start: segment_start_assembled,
245                        source_offset: token_offset + segment_start_source,
246                        source_file: loc.source_file.clone(),
247                    });
248                }
249                self.entries.push(SourceMapEntry {
250                    assembled_start: self.assembled.len(),
251                    source_offset: token_offset + source_pos,
252                    source_file: loc.source_file.clone(),
253                });
254                self.assembled.push(decoded);
255                source_pos += source_len;
256                segment_start_assembled = self.assembled.len();
257                segment_start_source = source_pos;
258            }
259        });
260
261        match result {
262            Ok(()) => {
263                if self.assembled.len() > segment_start_assembled {
264                    self.entries.push(SourceMapEntry {
265                        assembled_start: segment_start_assembled,
266                        source_offset: token_offset + segment_start_source,
267                        source_file: loc.source_file,
268                    });
269                }
270                true
271            }
272            Err(e) => {
273                self.assembled.truncate(base);
274                diag.push_error_with_span(
275                    e.message.into(),
276                    SourceLocation {
277                        source_file: loc.source_file,
278                        span: Span::new(loc.span.offset + e.offset, e.length),
279                    },
280                );
281                false
282            }
283        }
284    }
285
286    /// Append a non-literal character (e.g., an interpolation placeholder)
287    /// where source and assembled offsets correspond 1:1.
288    pub fn push_raw_char(&mut self, ch: char, loc: SourceLocation) {
289        let start = self.assembled.len();
290        self.assembled.push(ch);
291        self.entries.push(SourceMapEntry {
292            assembled_start: start,
293            source_offset: loc.span.offset,
294            source_file: loc.source_file,
295        });
296    }
297
298    /// Resolve a byte range in the assembled string to a precise source location.
299    /// The returned span points at the specific position within the string literal.
300    pub fn resolve(&self, range: std::ops::Range<usize>) -> Option<SourceLocation> {
301        let idx = self.entries.partition_point(|e| e.assembled_start <= range.start);
302        if idx == 0 {
303            return None;
304        }
305        let entry = &self.entries[idx - 1];
306        let delta = range.start - entry.assembled_start;
307        Some(SourceLocation {
308            source_file: entry.source_file.clone(),
309            span: Span::new(entry.source_offset + delta, range.len()),
310        })
311    }
312
313    /// Report an error at a precise position within the string, falling back to
314    /// the full node if the position cannot be resolved.
315    pub fn report(
316        &self,
317        diag: &mut BuildDiagnostics,
318        message: String,
319        range: std::ops::Range<usize>,
320        fallback: &dyn Spanned,
321    ) {
322        if let Some(loc) = self.resolve(range) {
323            diag.push_error_with_span(message, loc);
324        } else {
325            diag.push_error(message, fallback);
326        }
327    }
328}
329
330pub fn parse_number_literal(s: SmolStr) -> Result<(f64, WrittenUnit), SmolStr> {
331    let bytes = s.as_bytes();
332    let mut end = 0;
333    while end < bytes.len() && matches!(bytes[end], b'0'..=b'9' | b'.') {
334        end += 1;
335    }
336    let val = s[..end].parse().map_err(|_| "Cannot parse number literal".to_owned())?;
337    let unit = s[end..].parse().map_err(|_| {
338        format!(
339            "Invalid unit '{}'. Valid units are: {}",
340            s.get(end..).unwrap_or(&s),
341            WrittenUnit::iter().filter(|x| !x.to_string().is_empty()).join(", ")
342        )
343    })?;
344    Ok((val, unit))
345}
346
347#[test]
348fn test_parse_number_literal() {
349    use crate::expression_tree::WrittenUnit;
350    use smol_str::{ToSmolStr, format_smolstr};
351
352    assert_eq!(parse_number_literal("10".into()), Ok((10., WrittenUnit::None)));
353    assert_eq!(parse_number_literal("10phx".into()), Ok((10., WrittenUnit::Phx)));
354    assert_eq!(parse_number_literal("10.0phx".into()), Ok((10., WrittenUnit::Phx)));
355    assert_eq!(parse_number_literal("10.0".into()), Ok((10., WrittenUnit::None)));
356    assert_eq!(parse_number_literal("1.1phx".into()), Ok((1.1, WrittenUnit::Phx)));
357    assert_eq!(parse_number_literal("10.10".into()), Ok((10.10, WrittenUnit::None)));
358    assert_eq!(parse_number_literal("10000000".into()), Ok((10000000., WrittenUnit::None)));
359    assert_eq!(parse_number_literal("10000001phx".into()), Ok((10000001., WrittenUnit::Phx)));
360    assert_eq!(parse_number_literal("5cm".into()), Ok((5., WrittenUnit::Cm)));
361    assert_eq!(parse_number_literal("90grad".into()), Ok((90., WrittenUnit::Grad)));
362
363    let cannot_parse = Err("Cannot parse number literal".to_smolstr());
364    assert_eq!(parse_number_literal("12.10.12phx".into()), cannot_parse);
365
366    let valid_units = WrittenUnit::iter().filter(|x| !x.to_string().is_empty()).join(", ");
367    let wrong_unit_spaced =
368        Err(format_smolstr!("Invalid unit ' phx'. Valid units are: {}", valid_units));
369    assert_eq!(parse_number_literal("10000001 phx".into()), wrong_unit_spaced);
370    let wrong_unit_oo = Err(format_smolstr!("Invalid unit 'oo'. Valid units are: {}", valid_units));
371    assert_eq!(parse_number_literal("12.12oo".into()), wrong_unit_oo);
372    let wrong_unit_euro =
373        Err(format_smolstr!("Invalid unit '€'. Valid units are: {}", valid_units));
374    assert_eq!(parse_number_literal("12.12€".into()), wrong_unit_euro);
375}