Skip to main content

ripbi_core/m/
refs.rs

1//! Reference extraction over the M token stream.
2//!
3//! M names objects in three shapes, and extraction covers each:
4//!
5//! - **Field access** — `[Amount]`, `#"Sales"[Amount]`, `each [Amount]`. A
6//!   bracket group is a field access exactly when its contents are one
7//!   *generalized identifier* (identifiers, dots, digits, quoted identifiers —
8//!   no operators); a group like `[K = 1]` is a record literal and names
9//!   nothing.
10//! - **Names** — bare and quoted identifiers. In M most bare words are local
11//!   variables, but the conservatism rule from the DAX side applies unchanged:
12//!   a word that collides with a table or shared-expression name can only mark
13//!   an object used that truly is reachable, never the reverse. Dotted
14//!   identifiers additionally emit their dot-separated parts, preserving the
15//!   old substring matcher's `Server`-inside-`Server.Name` over-marking.
16//! - **Column strings** — the string arguments of Power Query's column-centric
17//!   built-ins: `"Amount"` in `Table.ExpandTableColumn(Source, "Amount")`.
18//!   Harvesting is keyed on a curated whitelist of function names, so the
19//!   `"Active"` in `Table.SelectRows(…, [Status] = "Active")` is never
20//!   mistaken for a column.
21//!
22//! Strings and comments never yield references by themselves: a name inside a
23//! comment or an unrelated string literal is not a use.
24
25use std::borrow::Cow;
26use std::ops::Range;
27
28use super::lexer::{Token, TokenKind, tokenize};
29
30/// One reference found in an M expression, exactly as written.
31///
32/// Names are raw source slices: delimiters are stripped, but quote escapes are
33/// left intact (`#"It""s"` yields `"It""s"`). [`unescape_name`] produces the
34/// logical names resolution expects.
35///
36/// Extraction is purely syntactic and knows nothing about any model: whether a
37/// name is a table, a shared expression, or a local `let` variable is decided
38/// by resolution, not here.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub enum RawRef<'a> {
41    /// A column reference: `#"Sales"[Amount]`, `Source[Amount]`, or an
42    /// unqualified `[Amount]`. `table` is `None` exactly when the reference was
43    /// written unqualified — including `each [Amount]` row context.
44    Field {
45        /// Qualifying name as written, delimiters stripped.
46        table: Option<&'a str>,
47        /// The generalized identifier between the brackets, as written.
48        name: &'a str,
49        /// Byte range of the bracketed name, brackets included.
50        span: Range<usize>,
51    },
52    /// A bare or quoted identifier: conservatively a table or shared-expression
53    /// use. Most are local variables that resolve to nothing, which is data.
54    Name {
55        /// The name as written, delimiters stripped.
56        name: &'a str,
57        /// Byte range of the name.
58        span: Range<usize>,
59    },
60    /// A string literal in the argument list of a column-centric built-in:
61    /// `"Amount"` in `Table.SelectColumns(Source, "Amount")`.
62    ColumnString {
63        /// The string's content, delimiters stripped and escapes intact.
64        name: &'a str,
65        /// Byte range of the whole string literal.
66        span: Range<usize>,
67    },
68}
69
70/// Every reference in an M expression, in source order.
71///
72/// Comments, numbers, and unrelated strings never produce references;
73/// malformed input yields the references it can, never an error.
74///
75/// ```
76/// use ripbi_core::m::{RawRef, references};
77///
78/// let refs = references(r#"Table.ExpandTableColumn(Source, "Amount", {"Beløb"})"#);
79/// // The harvested column strings come first, then the call name's
80/// // candidates, then the walked names.
81/// assert_eq!(
82///     refs[0],
83///     RawRef::ColumnString { name: "Amount", span: 32..40 }
84/// );
85/// assert_eq!(
86///     refs[5],
87///     RawRef::Name { name: "Source", span: 24..30 }
88/// );
89/// ```
90#[must_use]
91pub fn references(text: &str) -> Vec<RawRef<'_>> {
92    let tokens = tokenize(text);
93    extract(text, &tokens)
94}
95
96/// Produces the logical name of a raw slice: a `#"…"` wrapper is stripped and
97/// doubled-quote escapes are resolved. Borrows when there is nothing to strip.
98///
99/// ```
100/// use ripbi_core::m::unescape_name;
101///
102/// assert_eq!(unescape_name("Amount").as_ref(), "Amount");
103/// assert_eq!(unescape_name("#\"1998 Sales\"").as_ref(), "1998 Sales");
104/// assert_eq!(unescape_name("It\"\"s").as_ref(), "It\"s");
105/// ```
106#[must_use]
107pub fn unescape_name(name: &str) -> Cow<'_, str> {
108    if !name.starts_with("#\"") && !name.contains("\"\"") {
109        return Cow::Borrowed(name);
110    }
111    let stripped = name
112        .strip_prefix("#\"")
113        .map_or(name, |inner| inner.strip_suffix('"').unwrap_or(inner));
114    Cow::Owned(stripped.replace("\"\"", "\""))
115}
116
117/// The M keywords. A bare keyword is never a qualifier or a name candidate —
118/// `each [Amount]` is row context, not a table named `each`. The comparison is
119/// case-sensitive: M keywords are lowercase, and `Each` is a legal identifier
120/// that stays a (harmless) candidate.
121const KEYWORDS: [&str; 21] = [
122    "and",
123    "as",
124    "each",
125    "else",
126    "error",
127    "false",
128    "if",
129    "in",
130    "is",
131    "let",
132    "meta",
133    "not",
134    "null",
135    "or",
136    "otherwise",
137    "section",
138    "shared",
139    "then",
140    "true",
141    "try",
142    "type",
143];
144
145/// The column-centric built-ins whose string arguments name columns, sorted for
146/// [`binary_search`]. Curated by hand against real model corpora: partition M
147/// is dominated by these, while value-centric built-ins (`Table.SelectRows`,
148/// `Text.From`, …) take data as strings too and are deliberately absent — only
149/// a real column reference may keep a column alive. `Table.ReplaceValue` is
150/// the deliberate judgment call: most of its arguments are data values, but
151/// its trailing column list is not, and a missed column reference is the one
152/// direction this module must never err in.
153const COLUMN_STRING_FUNCTIONS: [&str; 26] = [
154    "#table",
155    "record.field",
156    "record.fieldordefault",
157    "table.addcolumn",
158    "table.addindexcolumn",
159    "table.combinecolumns",
160    "table.column",
161    "table.duplicatecolumn",
162    "table.expandlistcolumn",
163    "table.expandtablecolumn",
164    "table.filldown",
165    "table.group",
166    "table.join",
167    "table.nestedjoin",
168    "table.pivot",
169    "table.removecolumns",
170    "table.reordercolumns",
171    "table.replaceerrorvalues",
172    "table.replacevalue",
173    "table.selectcolumns",
174    "table.sort",
175    "table.splitcolumn",
176    "table.transformcolumns",
177    "table.transformcolumntypes",
178    "table.unpivot",
179    "table.unpivotothercolumns",
180];
181
182fn is_keyword(name: &str) -> bool {
183    KEYWORDS.contains(&name)
184}
185
186fn is_column_string_function(name: &str) -> bool {
187    let folded = name.to_lowercase();
188    COLUMN_STRING_FUNCTIONS
189        .binary_search(&folded.as_str())
190        .is_ok()
191}
192
193fn extract<'a>(text: &'a str, tokens: &[Token<'a>]) -> Vec<RawRef<'a>> {
194    let mut out = Vec::new();
195    let mut index = 0usize;
196
197    while index < tokens.len() {
198        let token = &tokens[index];
199        match token.kind {
200            TokenKind::Identifier => match tokens.get(index + 1).map(|t| t.kind) {
201                // A call. Column-string functions harvest their string
202                // arguments; every call is then walked into normally, so field
203                // accesses and nested calls inside the arguments still count.
204                Some(TokenKind::OpenParen) => {
205                    if is_column_string_function(token.text) {
206                        harvest_column_strings(tokens, index + 1, &mut out);
207                    }
208                    // The called name is itself a candidate: shared
209                    // expressions frequently hold user-defined functions
210                    // (`fnEasterSunday(year)`), and skipping call names would
211                    // be the one direction this module must never err in.
212                    // Built-ins resolve to nothing, so the cost is only
213                    // over-keeping.
214                    if !is_keyword(token.text) {
215                        emit_names(token, &mut out);
216                    }
217                    index += 1;
218                }
219                // Name[Field] — a qualified field access. `#shared[Name]` is
220                // the one qualifier that is not a table: its pieces are the
221                // section's query names.
222                Some(TokenKind::OpenBracket) if !is_keyword(token.text) => {
223                    if token.text.eq_ignore_ascii_case("#shared") {
224                        index = shared_members(tokens, index + 1, &mut out);
225                    } else {
226                        index = bracket_field(text, tokens, index + 1, Some(token.text), &mut out);
227                    }
228                }
229                _ => {
230                    if !is_keyword(token.text) {
231                        emit_names(token, &mut out);
232                    }
233                    index += 1;
234                }
235            },
236            TokenKind::QuotedIdentifier => {
237                let inner = quoted_inner(token.text);
238                if tokens.get(index + 1).map(|t| t.kind) == Some(TokenKind::OpenBracket) {
239                    index = bracket_field(text, tokens, index + 1, Some(inner), &mut out);
240                } else {
241                    out.push(RawRef::Name {
242                        name: inner,
243                        span: token.start..token.end(),
244                    });
245                    index += 1;
246                }
247            }
248            // [Field] — unqualified field access, or a record literal, which
249            // names nothing.
250            TokenKind::OpenBracket => {
251                index = bracket_field(text, tokens, index, None, &mut out);
252            }
253            _ => index += 1,
254        }
255    }
256
257    out
258}
259
260/// Emits a bare identifier as a name candidate, plus its dot-separated parts:
261/// `Server.Name` keeps both `Server.Name` and `Server` alive, preserving the
262/// substring matcher's deliberate over-marking.
263fn emit_names<'a>(token: &Token<'a>, out: &mut Vec<RawRef<'a>>) {
264    out.push(RawRef::Name {
265        name: token.text,
266        span: token.start..token.end(),
267    });
268    if !token.text.contains('.') {
269        return;
270    }
271    let mut offset = token.start;
272    for part in token.text.split('.') {
273        if !part.is_empty() {
274            out.push(RawRef::Name {
275                name: part,
276                span: offset..offset + part.len(),
277            });
278        }
279        offset += part.len() + 1; // the dot
280    }
281}
282
283/// Classifies the bracket group whose `[` sits at `start` and emits a field
284/// reference when its contents are a generalized identifier. Returns the index
285/// the walker continues from: past the `]` of a field access, or onto the
286/// first token inside when the group is a record literal — its contents are
287/// then walked as plain tokens, so strings inside a whitelisted call's record
288/// arguments still get harvested.
289fn bracket_field<'a>(
290    text: &'a str,
291    tokens: &[Token<'a>],
292    start: usize,
293    table: Option<&'a str>,
294    out: &mut Vec<RawRef<'a>>,
295) -> usize {
296    if tokens.get(start).is_none() {
297        return start;
298    }
299
300    let mut depth = 0usize;
301    let mut generalized = true;
302    let mut inner: Vec<&Token<'a>> = Vec::new();
303    let mut close = None;
304
305    for (offset, token) in tokens[start..].iter().enumerate() {
306        match token.kind {
307            TokenKind::OpenBracket => {
308                depth += 1;
309                if depth > 1 {
310                    generalized = false;
311                }
312            }
313            TokenKind::CloseBracket => {
314                if depth == 0 {
315                    // A stray `]` before the group ever opened: malformed in a
316                    // way no field access explains.
317                    generalized = false;
318                } else {
319                    depth -= 1;
320                    if depth == 0 {
321                        close = Some(start + offset);
322                        break;
323                    }
324                    generalized = false;
325                }
326            }
327            TokenKind::Identifier
328            | TokenKind::Dot
329            | TokenKind::Number
330            | TokenKind::QuotedIdentifier
331                if depth == 1 =>
332            {
333                inner.push(token);
334            }
335            TokenKind::Comment => {}
336            _ if depth >= 1 => generalized = false,
337            _ => {}
338        }
339    }
340
341    match close {
342        // Unterminated: walk the contents conservatively instead of guessing.
343        None => start + 1,
344        // `[]`, the empty record, names nothing.
345        Some(close) if inner.is_empty() => close + 1,
346        Some(close) => {
347            if generalized {
348                let first = inner[0];
349                let last = inner[inner.len() - 1];
350                out.push(RawRef::Field {
351                    table,
352                    name: &text[first.start..last.end()],
353                    span: tokens[start].start..tokens[close].end(),
354                });
355            }
356            close + 1
357        }
358    }
359}
360
361/// Emits the bracket group whose `[` sits at `start` as name candidates — the
362/// pieces of a `#shared[…]` section lookup, which are query names rather than
363/// fields of a table. Returns the index the walker continues from.
364fn shared_members<'a>(tokens: &[Token<'a>], start: usize, out: &mut Vec<RawRef<'a>>) -> usize {
365    let mut depth = 0usize;
366    for (offset, token) in tokens[start..].iter().enumerate() {
367        match token.kind {
368            TokenKind::OpenBracket => depth += 1,
369            TokenKind::CloseBracket => {
370                depth -= 1;
371                if depth == 0 {
372                    return start + offset + 1;
373                }
374            }
375            TokenKind::Identifier if depth == 1 => out.push(RawRef::Name {
376                name: token.text,
377                span: token.start..token.end(),
378            }),
379            TokenKind::QuotedIdentifier if depth == 1 => out.push(RawRef::Name {
380                name: quoted_inner(token.text),
381                span: token.start..token.end(),
382            }),
383            _ => {}
384        }
385    }
386    start
387}
388
389/// Emits every string literal between the call's `(` at `open` and its matching
390/// `)` as column-string candidates. Nesting is tracked over all three bracket
391/// kinds, so names inside pair lists like `{{"Amount", type text}}` count; an
392/// unterminated call harvests to the end of the input.
393fn harvest_column_strings<'a>(tokens: &[Token<'a>], open: usize, out: &mut Vec<RawRef<'a>>) {
394    let mut depth = 0usize;
395    for token in &tokens[open..] {
396        match token.kind {
397            TokenKind::OpenParen | TokenKind::OpenBracket | TokenKind::OpenBrace => depth += 1,
398            TokenKind::CloseParen | TokenKind::CloseBracket | TokenKind::CloseBrace => {
399                depth -= 1;
400                if depth == 0 {
401                    return;
402                }
403            }
404            TokenKind::String => out.push(RawRef::ColumnString {
405                name: string_inner(token.text),
406                span: token.start..token.end(),
407            }),
408            _ => {}
409        }
410    }
411}
412
413/// The name inside a quoted-identifier token, delimiters stripped. Unterminated
414/// input has no closing delimiter; the opening one is still dropped.
415fn quoted_inner(text: &str) -> &str {
416    // Drop the leading #"; strip the closing quote only when it is there —
417    // `""` at the end is an escape and stays.
418    text[2..].strip_suffix('"').unwrap_or(&text[2..])
419}
420
421/// The content inside a string-literal token, delimiters stripped. Unterminated
422/// input has no closing delimiter; the opening one is still dropped.
423fn string_inner(text: &str) -> &str {
424    text[1..].strip_suffix('"').unwrap_or(&text[1..])
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    fn refs(text: &str) -> Vec<RawRef<'_>> {
432        references(text)
433    }
434
435    /// The qualified/unqualified shape of every `Field` ref, as `(table, name)`.
436    fn fields(text: &str) -> Vec<(Option<&str>, &str)> {
437        refs(text)
438            .into_iter()
439            .filter_map(|r| match r {
440                RawRef::Field { table, name, .. } => Some((table, name)),
441                _ => None,
442            })
443            .collect()
444    }
445
446    /// The names of every `Name` ref.
447    fn names(text: &str) -> Vec<&str> {
448        refs(text)
449            .into_iter()
450            .filter_map(|r| match r {
451                RawRef::Name { name, .. } => Some(name),
452                _ => None,
453            })
454            .collect()
455    }
456
457    /// The names of every `ColumnString` ref.
458    fn column_strings(text: &str) -> Vec<&str> {
459        refs(text)
460            .into_iter()
461            .filter_map(|r| match r {
462                RawRef::ColumnString { name, .. } => Some(name),
463                _ => None,
464            })
465            .collect()
466    }
467
468    #[test]
469    fn finds_the_issue_three_reference_forms() {
470        // The shapes issue #39 names as the gap.
471        let expand = r#"Table.ExpandTableColumn(Source, "Old", {"A", "B"})"#;
472        assert_eq!(column_strings(expand), ["Old", "A", "B"]);
473
474        assert_eq!(fields(r#"#"Sales"[Amount]"#), [(Some("Sales"), "Amount")]);
475        assert_eq!(fields("[Amount]"), [(None, "Amount")]);
476
477        let join = r#"Table.NestedJoin(A, "Key1", B, "Key2", "New")"#;
478        assert_eq!(column_strings(join), ["Key1", "Key2", "New"]);
479    }
480
481    #[test]
482    fn spans_cover_the_whole_reference() {
483        let text = "#\"Sales Header\"[Net Price]";
484        let found = refs(text);
485        let RawRef::Field { span, .. } = &found[0] else {
486            panic!("expected a field ref");
487        };
488        assert_eq!(&text[span.clone()], "[Net Price]");
489    }
490
491    #[test]
492    fn every_span_is_a_valid_source_subslice() {
493        let text = concat!(
494            "let\n",
495            "    Source = #\"My Table\"[X],\n",
496            "    Typed = Table.TransformColumnTypes(Source, {{\"Amount\", type text}}),\n",
497            "    Filtered = Table.SelectRows(Typed, each [Amount] > 0)\n",
498            "in\n",
499            "    Filtered",
500        );
501        for found in refs(text) {
502            let span = match found {
503                RawRef::Field { span, .. }
504                | RawRef::Name { span, .. }
505                | RawRef::ColumnString { span, .. } => span,
506            };
507            assert!(!span.is_empty());
508            assert!(
509                text.get(span.clone()).is_some(),
510                "span {span:?} must be inside the source"
511            );
512        }
513    }
514
515    #[test]
516    fn unqualified_field_access_in_each_row_context_is_a_field() {
517        assert_eq!(
518            fields("Table.SelectRows(Source, each [Amount] > 0)"),
519            [(None, "Amount")]
520        );
521        // The comparison string is a value, not a column: SelectRows is not a
522        // column-string function.
523        assert_eq!(
524            column_strings("Table.SelectRows(Source, each [Amount] > 0)"),
525            Vec::<&str>::new()
526        );
527    }
528
529    #[test]
530    fn a_record_literal_is_not_a_field_access() {
531        // The options record of Sql.Database names keys, not columns.
532        assert!(fields(r#"Sql.Database("srv", "db", [CommandTimeout = 30])"#).is_empty());
533        // But a real field access right next to it still counts.
534        let fields_found = fields("[Amount] + [K = 1][Nope]");
535        assert_eq!(fields_found[0], (None, "Amount"));
536        assert!(
537            !fields_found.contains(&(None, "K")),
538            "the record literal [K = 1] must not yield a field for its key"
539        );
540        // `[K = 1][Nope]` is a field access *on the record*; the walker cannot
541        // tell that from a column access, so it conservatively keeps `Nope` —
542        // the same over-marking every bare word gets.
543    }
544
545    #[test]
546    fn generalized_identifiers_keep_blanks_and_dots() {
547        assert_eq!(fields("[Base Line]"), [(None, "Base Line")]);
548        assert_eq!(fields("[A. B]"), [(None, "A. B")]);
549        assert_eq!(fields("[1998 Sales]"), [(None, "1998 Sales")]);
550        assert_eq!(fields(r#"[#"It""s"]"#), [(None, "#\"It\"\"s\"")]);
551    }
552
553    #[test]
554    fn keywords_are_never_qualifiers_or_names() {
555        // `each [Amount]` is row context, not a table named each.
556        assert_eq!(fields("each [Amount]"), [(None, "Amount")]);
557        assert!(names("each [Amount]").is_empty(), "`each` is a keyword");
558        // Keywords still walk into the bracket group they introduce.
559        assert_eq!(fields("if [X] then 1 else 2"), [(None, "X")]);
560    }
561
562    #[test]
563    fn bare_identifiers_are_conservative_name_candidates() {
564        assert_eq!(
565            names("let Source = Sql.Database(ServerName) in Source"),
566            [
567                "Source",
568                "Sql.Database",
569                "Sql",
570                "Database",
571                "ServerName",
572                "Source",
573            ],
574            "every bare word and call name is a candidate — built-ins resolve to nothing"
575        );
576    }
577
578    #[test]
579    fn dotted_identifiers_emit_their_parts() {
580        // `Server.Name` must keep `Server` alive, as the substring matcher
581        // deliberately did.
582        assert!(names("Sql.Database(Server.Name)").contains(&"Server"));
583        assert!(names("Sql.Database(Server.Name)").contains(&"Server.Name"));
584    }
585
586    #[test]
587    fn quoted_identifiers_are_name_candidates() {
588        assert_eq!(names("#\"My Query\""), ["My Query"]);
589        // #shared[Name] names a query, not a field of a table.
590        assert_eq!(names("#shared[#\"My Query\"]"), ["My Query"]);
591        assert!(fields("#shared[#\"My Query\"]").is_empty());
592    }
593
594    #[test]
595    fn harvesting_reaches_inside_pair_lists_and_nested_calls() {
596        let text = concat!(
597            "Table.Group(Source, {\"Key\"}, {{\"All\", ",
598            "each Table.TransformColumnTypes(_, {{\"Amount\", type text}})}})",
599        );
600        // The outer call harvests everything inside itself — including the
601        // aggregation's new-column name — and the nested whitelisted call
602        // harvests its own arguments again. Duplicates are fine: they collapse
603        // into one graph edge.
604        assert_eq!(column_strings(text), ["Key", "All", "Amount", "Amount"]);
605    }
606
607    #[test]
608    fn value_strings_outside_column_functions_are_not_columns() {
609        let text = concat!(
610            "Table.SelectRows(Source, each [Status] = \"Active\")\n",
611            "& Text.From(123) & \"Amount\"",
612        );
613        assert!(column_strings(text).is_empty());
614        assert_eq!(fields(text), [(None, "Status")]);
615    }
616
617    #[test]
618    fn references_inside_strings_and_comments_do_not_count() {
619        let text = concat!(
620            "\"[In String] [X]\"\n",
621            "// [In Comment] [Y]\n",
622            "/* [Block] [Comment] */\n",
623            "[Real]",
624        );
625        assert_eq!(fields(text), [(None, "Real")]);
626        assert!(names(text).is_empty());
627        assert!(column_strings(text).is_empty());
628    }
629
630    #[test]
631    fn escaped_names_carry_raw_slices() {
632        let found = refs("#\"It\"\"s\"[X]");
633        match &found[0] {
634            RawRef::Field {
635                table: Some(table),
636                name,
637                ..
638            } => {
639                assert_eq!(*table, "It\"\"s");
640                assert_eq!(*name, "X");
641            }
642            other => panic!("expected a qualified field ref, got {other:?}"),
643        }
644        // ...and unescaping produces the logical name.
645        assert_eq!(unescape_name("It\"\"s").as_ref(), "It\"s");
646    }
647
648    #[test]
649    fn the_full_let_query_yields_every_kind_of_reference() {
650        let text = concat!(
651            "let\n",
652            "    Source = Sql.Database(ServerName, \"db\"),\n",
653            "    Staging = #\"My Staging\"[Amount],\n",
654            "    Typed = Table.TransformColumnTypes(Staging, {{\"Amount\", type text}, {\"Beløb\", Currency.Type}}),\n",
655            "    Filtered = Table.SelectRows(Typed, each [Region] = \"West\"),\n",
656            "    Joined = Table.NestedJoin(Filtered, {\"Key\"}, DimTable, {\"Key\"}, \"Dim\")\n",
657            "in\n",
658            "    Joined",
659        );
660        // "db" is a database name and "West" a filter value: Sql.Database and
661        // Table.SelectRows are not column-string functions, so neither counts.
662        assert_eq!(
663            column_strings(text),
664            ["Amount", "Beløb", "Key", "Key", "Dim"]
665        );
666        assert!(fields(text).contains(&(Some("My Staging"), "Amount")));
667        assert!(fields(text).contains(&(None, "Region")));
668        assert!(names(text).contains(&"DimTable"));
669        assert!(names(text).contains(&"ServerName"));
670    }
671
672    #[test]
673    fn unterminated_input_still_yields_conservative_refs() {
674        // `#"Sales [X` never closes: one quoted identifier that names nothing
675        // as a field.
676        assert!(fields("#\"Sales [X").is_empty());
677        // `[Col` without a closing bracket is never swallowed; its contents
678        // are walked as plain tokens instead of guessed at.
679        assert!(fields("[Col").is_empty());
680        // An unterminated call harvests everything it can see.
681        assert_eq!(column_strings("Table.SelectColumns(Source, {\"A\""), ["A"]);
682    }
683
684    #[test]
685    fn empty_input_has_no_references() {
686        assert!(refs("").is_empty());
687        assert!(refs("   \n\t  ").is_empty());
688    }
689
690    #[test]
691    fn column_string_function_matching_is_case_insensitive() {
692        assert_eq!(
693            column_strings("table.selectcolumns(Source, \"A\")"),
694            ["A"],
695            "matching a built-in must not depend on its casing"
696        );
697    }
698
699    #[test]
700    fn unescape_name_handles_wrapper_and_escapes() {
701        assert!(matches!(unescape_name("Plain"), Cow::Borrowed("Plain")));
702        assert_eq!(unescape_name("#\"A B\"").as_ref(), "A B");
703        assert_eq!(unescape_name("A\"\"B").as_ref(), "A\"B");
704        assert_eq!(unescape_name("#\"A\"\"B\"").as_ref(), "A\"B");
705        // Unterminated wrappers degrade gracefully.
706        assert_eq!(unescape_name("#\"A B").as_ref(), "A B");
707    }
708}