1use std::borrow::Cow;
26use std::ops::Range;
27
28use super::lexer::{Token, TokenKind, tokenize};
29
30#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub enum RawRef<'a> {
41 Field {
45 table: Option<&'a str>,
47 name: &'a str,
49 span: Range<usize>,
51 },
52 Name {
55 name: &'a str,
57 span: Range<usize>,
59 },
60 ColumnString {
63 name: &'a str,
65 span: Range<usize>,
67 },
68}
69
70#[must_use]
91pub fn references(text: &str) -> Vec<RawRef<'_>> {
92 let tokens = tokenize(text);
93 extract(text, &tokens)
94}
95
96#[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
117const 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
145const 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 Some(TokenKind::OpenParen) => {
205 if is_column_string_function(token.text) {
206 harvest_column_strings(tokens, index + 1, &mut out);
207 }
208 if !is_keyword(token.text) {
215 emit_names(token, &mut out);
216 }
217 index += 1;
218 }
219 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 TokenKind::OpenBracket => {
251 index = bracket_field(text, tokens, index, None, &mut out);
252 }
253 _ => index += 1,
254 }
255 }
256
257 out
258}
259
260fn 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; }
281}
282
283fn 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 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 None => start + 1,
344 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
361fn 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
389fn 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
413fn quoted_inner(text: &str) -> &str {
416 text[2..].strip_suffix('"').unwrap_or(&text[2..])
419}
420
421fn 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 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 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 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 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 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 assert!(fields(r#"Sql.Database("srv", "db", [CommandTimeout = 30])"#).is_empty());
533 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 }
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 assert_eq!(fields("each [Amount]"), [(None, "Amount")]);
557 assert!(names("each [Amount]").is_empty(), "`each` is a keyword");
558 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 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 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 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 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 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 assert!(fields("#\"Sales [X").is_empty());
677 assert!(fields("[Col").is_empty());
680 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 assert_eq!(unescape_name("#\"A B").as_ref(), "A B");
707 }
708}