Skip to main content

prqlc_parser/lexer/
lr.rs

1use serde::{Deserialize, Serialize};
2
3use enum_as_inner::EnumAsInner;
4use schemars::JsonSchema;
5
6#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
7pub struct Tokens(pub Vec<Token>);
8
9#[derive(Clone, PartialEq, Serialize, Deserialize, Eq, JsonSchema)]
10pub struct Token {
11    pub kind: TokenKind,
12    pub span: std::ops::Range<usize>,
13}
14
15#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, JsonSchema)]
16pub enum TokenKind {
17    NewLine,
18
19    Ident(String),
20    Keyword(String),
21    #[cfg_attr(
22        feature = "serde_yaml",
23        serde(with = "serde_yaml::with::singleton_map"),
24        schemars(with = "Literal")
25    )]
26    Literal(Literal),
27    /// A parameter such as `$1`
28    Param(String),
29
30    Range {
31        /// Whether the left side of the range is bound by the previous token
32        /// (but it's not contained in this token)
33        bind_left: bool,
34        bind_right: bool,
35    },
36    Interpolation(char, String),
37
38    /// single-char control tokens
39    Control(char),
40
41    ArrowThin,   // ->
42    ArrowFat,    // =>
43    Eq,          // ==
44    Ne,          // !=
45    Gte,         // >=
46    Lte,         // <=
47    RegexSearch, // ~=
48    And,         // &&
49    Or,          // ||
50    Coalesce,    // ??
51    DivInt,      // //
52    Pow,         // **
53    Annotate,    // @
54
55    // Aesthetics only
56    Comment(String),
57    DocComment(String),
58    /// Vec containing comments between the newline and the line wrap
59    // Currently we include the comments with the LineWrap token. This isn't
60    // ideal, but I'm not sure of an easy way of having them be separate.
61    // - The line wrap span technically includes the comments — on a newline,
62    //   we need to look ahead to _after_ the comments to see if there's a
63    //   line wrap, and exclude the newline if there is.
64    // - We can only pass one token back
65    //
66    // Alternatives:
67    // - Post-process the stream, removing the newline prior to a line wrap.
68    //   But requires a whole extra pass.
69    // - Change the functionality. But it's very nice to be able to comment
70    //   something out and have line-wraps still work.
71    LineWrap(Vec<TokenKind>),
72
73    /// A token we manually insert at the start of the input, which later stages
74    /// can treat as a newline.
75    Start,
76}
77
78#[derive(
79    Debug, EnumAsInner, PartialEq, Clone, Serialize, Deserialize, strum::AsRefStr, JsonSchema,
80)]
81pub enum Literal {
82    Null,
83    Integer(i64),
84    Float(f64),
85    Boolean(bool),
86    String(String),
87    RawString(String),
88    Date(String),
89    Time(String),
90    Timestamp(String),
91    ValueAndUnit(ValueAndUnit),
92}
93
94impl TokenKind {
95    pub fn range(bind_left: bool, bind_right: bool) -> Self {
96        TokenKind::Range {
97            bind_left,
98            bind_right,
99        }
100    }
101}
102// Compound units, such as "2 days 3 hours" can be represented as `2days + 3hours`
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
104pub struct ValueAndUnit {
105    pub n: i64,       // Do any DBs use floats or decimals for this?
106    pub unit: String, // Could be an enum IntervalType,
107}
108
109impl std::fmt::Display for Literal {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Literal::Null => write!(f, "null")?,
113            Literal::Integer(i) => write!(f, "{i}")?,
114            Literal::Float(i) => write!(f, "{i}")?,
115
116            Literal::String(s) => {
117                write!(
118                    f,
119                    "{}",
120                    quote_string(escape_all_except_quotes(s).as_str(), true)
121                )?;
122            }
123
124            Literal::RawString(s) => {
125                write!(f, "r{}", quote_string(s, false))?;
126            }
127
128            Literal::Boolean(b) => {
129                f.write_str(if *b { "true" } else { "false" })?;
130            }
131
132            Literal::Date(inner) | Literal::Time(inner) | Literal::Timestamp(inner) => {
133                write!(f, "@{inner}")?;
134            }
135
136            Literal::ValueAndUnit(i) => {
137                write!(f, "{}{}", i.n, i.unit)?;
138            }
139        }
140        Ok(())
141    }
142}
143
144/// Wrap `s` in quotes, choosing a delimiter that avoids escaping where possible.
145///
146/// When `allow_escape` is set (normal strings, but not raw strings, which have
147/// no escape mechanism), the function falls back to escaping double-quotes for
148/// content that can't be represented with any bare delimiter.
149fn quote_string(s: &str, allow_escape: bool) -> String {
150    if !s.contains('"') {
151        return format!(r#""{s}""#);
152    }
153
154    if !s.contains('\'') {
155        return format!("'{s}'");
156    }
157
158    // The string contains both quote characters. A delimiter quote that appears
159    // at the start or end of the string merges with the delimiter (the lexer
160    // counts opening/closing quotes greedily), so pick a delimiter that doesn't
161    // occur at either boundary. Default to double quotes.
162    let double_safe = !s.starts_with('"') && !s.ends_with('"');
163    let single_safe = !s.starts_with('\'') && !s.ends_with('\'');
164
165    let quote = if double_safe {
166        '"'
167    } else if single_safe {
168        '\''
169    } else if allow_escape {
170        // Both quote characters appear at a boundary, so no bare delimiter
171        // round-trips. Escape the double-quotes instead.
172        return format!("\"{}\"", s.replace('"', "\\\""));
173    } else {
174        // Raw strings can't escape; fall back to double quotes. This case can't
175        // arise from valid raw-string input, since such content has no
176        // raw-string representation in the first place.
177        '"'
178    };
179
180    // When string contains both single and double quotes find the longest
181    // sequence of consecutive quotes, and then use the next highest odd number
182    // of quotes (quotes must be odd; even number of quotes are empty strings).
183    // i.e.:
184    // 0 -> 1
185    // 1 -> 3
186    // 2 -> 3
187    // 3 -> 5
188    let max_consecutive = s
189        .split(|c| c != quote)
190        .map(|quote_sequence| quote_sequence.len())
191        .max()
192        .unwrap_or(0);
193    let next_odd = max_consecutive.div_ceil(2) * 2 + 1;
194    let delim = quote.to_string().repeat(next_odd);
195
196    format!("{delim}{s}{delim}")
197}
198
199fn escape_all_except_quotes(s: &str) -> String {
200    let mut result = String::new();
201    for ch in s.chars() {
202        if ch == '"' || ch == '\'' {
203            result.push(ch);
204        } else {
205            result.extend(ch.escape_default());
206        }
207    }
208    result
209}
210
211// This is here because Literal::Float(f64) does not implement Hash, so we cannot simply derive it.
212// There are reasons for that, but chumsky::Error needs Hash for the TokenKind, so it can deduplicate
213// tokens in error.
214// So this hack could lead to duplicated tokens in error messages. Oh no.
215#[allow(clippy::derived_hash_with_manual_eq)]
216impl std::hash::Hash for TokenKind {
217    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
218        core::mem::discriminant(self).hash(state);
219    }
220}
221
222impl std::cmp::Eq for TokenKind {}
223
224impl std::fmt::Display for TokenKind {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        match self {
227            TokenKind::NewLine => write!(f, "new line"),
228            TokenKind::Ident(s) => {
229                if s.is_empty() {
230                    // FYI this shows up in errors
231                    write!(f, "an identifier")
232                } else {
233                    write!(f, "{s}")
234                }
235            }
236            TokenKind::Keyword(s) => write!(f, "keyword {s}"),
237            TokenKind::Literal(lit) => write!(f, "{lit}"),
238            TokenKind::Control(c) => write!(f, "{c}"),
239
240            TokenKind::ArrowThin => f.write_str("->"),
241            TokenKind::ArrowFat => f.write_str("=>"),
242            TokenKind::Eq => f.write_str("=="),
243            TokenKind::Ne => f.write_str("!="),
244            TokenKind::Gte => f.write_str(">="),
245            TokenKind::Lte => f.write_str("<="),
246            TokenKind::RegexSearch => f.write_str("~="),
247            TokenKind::And => f.write_str("&&"),
248            TokenKind::Or => f.write_str("||"),
249            TokenKind::Coalesce => f.write_str("??"),
250            TokenKind::DivInt => f.write_str("//"),
251            TokenKind::Pow => f.write_str("**"),
252            TokenKind::Annotate => f.write_str("@"),
253
254            TokenKind::Param(id) => write!(f, "${id}"),
255
256            TokenKind::Range {
257                bind_left,
258                bind_right,
259            } => write!(
260                f,
261                "'{}..{}'",
262                if *bind_left { "" } else { " " },
263                if *bind_right { "" } else { " " }
264            ),
265            TokenKind::Interpolation(c, s) => {
266                write!(f, "{c}\"{s}\"")
267            }
268            TokenKind::Comment(s) => {
269                writeln!(f, "#{s}")
270            }
271            TokenKind::DocComment(s) => {
272                writeln!(f, "#!{s}")
273            }
274            TokenKind::LineWrap(comments) => {
275                write!(f, "\n\\ ")?;
276                for comment in comments {
277                    write!(f, "{comment}")?;
278                }
279                Ok(())
280            }
281            TokenKind::Start => write!(f, "start of input"),
282        }
283    }
284}
285
286impl std::fmt::Debug for Token {
287    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
288        write!(f, "{}..{}: {:?}", self.span.start, self.span.end, self.kind)
289    }
290}
291
292#[cfg(test)]
293mod test {
294    use insta::assert_snapshot;
295
296    use super::*;
297
298    #[test]
299    fn test_string_quoting() {
300        fn make_str(s: &str) -> Literal {
301            Literal::String(s.to_string())
302        }
303
304        assert_snapshot!(
305            make_str("hello").to_string(),
306            @r#""hello""#
307        );
308
309        assert_snapshot!(
310            make_str(r#"he's nice"#).to_string(),
311            @r#""he's nice""#
312        );
313
314        assert_snapshot!(
315            make_str(r#"he said "what up""#).to_string(),
316            @r#"'he said "what up"'"#
317        );
318
319        assert_snapshot!(
320            make_str(r#"he said "what's up""#).to_string(),
321            @r#"'''he said "what's up"'''"#
322        );
323
324        assert_snapshot!(
325            make_str(r#" single' three double""" four double"""" "#).to_string(),
326            @r#"""""" single' three double""" four double"""" """"""#
327
328        );
329
330        assert_snapshot!(
331            make_str(r#""Starts with a double quote and ' contains a single quote"#).to_string(),
332            @r#"'''"Starts with a double quote and ' contains a single quote'''"#
333        );
334    }
335
336    /// Strings that contain both quote characters at their boundaries can't be
337    /// represented with a bare delimiter (the boundary quote would merge with
338    /// the delimiter), so they fall back to escaping double-quotes.
339    #[test]
340    fn test_string_quoting_both_boundary_quotes() {
341        assert_snapshot!(
342            Literal::String(r#""x'"#.to_string()).to_string(),
343            @r#""\"x'""#
344        );
345        assert_snapshot!(
346            Literal::String(r#"'x""#.to_string()).to_string(),
347            @r#""'x\"""#
348        );
349    }
350
351    /// Round-trips quoted strings through the lexer to ensure `Display` produces
352    /// output the lexer parses back to the original value.
353    #[test]
354    fn test_string_roundtrip_boundary() {
355        use crate::lexer::lex_source;
356        for original in [
357            r#""x'"#,  // starts with double, ends with single
358            r#"'x""#,  // starts with single, ends with double
359            r#"a"b'"#, // ends with single, contains double
360            r#"a'b""#, // ends with double, contains single
361        ] {
362            let formatted = Literal::String(original.to_string()).to_string();
363            let toks = lex_source(&formatted).unwrap();
364            let lexed: Vec<_> = toks
365                .0
366                .iter()
367                .filter_map(|t| match &t.kind {
368                    TokenKind::Literal(Literal::String(s)) => Some(s.clone()),
369                    _ => None,
370                })
371                .collect();
372            assert_eq!(
373                lexed,
374                vec![original.to_string()],
375                "roundtrip failed for {original:?}: formatted={formatted:?}, lexed={lexed:?}"
376            );
377        }
378    }
379
380    #[test]
381    fn test_string_escapes() {
382        assert_snapshot!(
383            Literal::String(r#"hello\nworld"#.to_string()).to_string(),
384            @r#""hello\\nworld""#
385        );
386
387        assert_snapshot!(
388            Literal::String(r#"hello\tworld"#.to_string()).to_string(),
389            @r#""hello\\tworld""#
390        );
391
392        // TODO: one problem here is that we don't remember whether the original
393        // string contained an actual line break or contained an `\n` string,
394        // because we immediately normalize both to `\n`. This means that when
395        // we format the PRQL, we can't retain the original. I think three ways of
396        // resolving this:
397        // - Have different tokens in the lexer and parser; normalize at the
398        //   parsing stage, and then use the token in the lexer for writing out
399        //   the formatted PRQL. Literals are one of the only data structures we
400        //   retain between the lexer and parser. (note that this requires the
401        //   current effort to use tokens from the lexer as part of `prqlc fmt`;
402        //   ongoing as of 2024-08)
403        // - Don't normalize at all, and then normalize when we use the string.
404        //   I think this might be viable and maybe easy, but is a bit less
405        //   elegant; the parser is designed to normalize this sort of thing.
406
407        assert_snapshot!(
408            Literal::String(r#"hello
409            world"#.to_string()).to_string(),
410            @r#""hello\n            world""#
411        );
412    }
413
414    #[test]
415    fn test_raw_string_quoting() {
416        // TODO: add some test for escapes
417        fn make_str(s: &str) -> Literal {
418            Literal::RawString(s.to_string())
419        }
420
421        assert_snapshot!(
422            make_str("hello").to_string(),
423            @r#"r"hello""#
424        );
425    }
426}