use std::borrow::Cow;
use std::ops::Range;
use super::lexer::{Token, TokenKind, tokenize};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RawRef<'a> {
Field {
table: Option<&'a str>,
name: &'a str,
span: Range<usize>,
},
Name {
name: &'a str,
span: Range<usize>,
},
ColumnString {
name: &'a str,
span: Range<usize>,
},
}
#[must_use]
pub fn references(text: &str) -> Vec<RawRef<'_>> {
let tokens = tokenize(text);
extract(text, &tokens)
}
#[must_use]
pub fn unescape_name(name: &str) -> Cow<'_, str> {
if !name.starts_with("#\"") && !name.contains("\"\"") {
return Cow::Borrowed(name);
}
let stripped = name
.strip_prefix("#\"")
.map_or(name, |inner| inner.strip_suffix('"').unwrap_or(inner));
Cow::Owned(stripped.replace("\"\"", "\""))
}
const KEYWORDS: [&str; 21] = [
"and",
"as",
"each",
"else",
"error",
"false",
"if",
"in",
"is",
"let",
"meta",
"not",
"null",
"or",
"otherwise",
"section",
"shared",
"then",
"true",
"try",
"type",
];
const COLUMN_STRING_FUNCTIONS: [&str; 26] = [
"#table",
"record.field",
"record.fieldordefault",
"table.addcolumn",
"table.addindexcolumn",
"table.combinecolumns",
"table.column",
"table.duplicatecolumn",
"table.expandlistcolumn",
"table.expandtablecolumn",
"table.filldown",
"table.group",
"table.join",
"table.nestedjoin",
"table.pivot",
"table.removecolumns",
"table.reordercolumns",
"table.replaceerrorvalues",
"table.replacevalue",
"table.selectcolumns",
"table.sort",
"table.splitcolumn",
"table.transformcolumns",
"table.transformcolumntypes",
"table.unpivot",
"table.unpivotothercolumns",
];
fn is_keyword(name: &str) -> bool {
KEYWORDS.contains(&name)
}
fn is_column_string_function(name: &str) -> bool {
let folded = name.to_lowercase();
COLUMN_STRING_FUNCTIONS
.binary_search(&folded.as_str())
.is_ok()
}
fn extract<'a>(text: &'a str, tokens: &[Token<'a>]) -> Vec<RawRef<'a>> {
let mut out = Vec::new();
let mut index = 0usize;
while index < tokens.len() {
let token = &tokens[index];
match token.kind {
TokenKind::Identifier => match tokens.get(index + 1).map(|t| t.kind) {
Some(TokenKind::OpenParen) => {
if is_column_string_function(token.text) {
harvest_column_strings(tokens, index + 1, &mut out);
}
if !is_keyword(token.text) {
emit_names(token, &mut out);
}
index += 1;
}
Some(TokenKind::OpenBracket) if !is_keyword(token.text) => {
if token.text.eq_ignore_ascii_case("#shared") {
index = shared_members(tokens, index + 1, &mut out);
} else {
index = bracket_field(text, tokens, index + 1, Some(token.text), &mut out);
}
}
_ => {
if !is_keyword(token.text) {
emit_names(token, &mut out);
}
index += 1;
}
},
TokenKind::QuotedIdentifier => {
let inner = quoted_inner(token.text);
if tokens.get(index + 1).map(|t| t.kind) == Some(TokenKind::OpenBracket) {
index = bracket_field(text, tokens, index + 1, Some(inner), &mut out);
} else {
out.push(RawRef::Name {
name: inner,
span: token.start..token.end(),
});
index += 1;
}
}
TokenKind::OpenBracket => {
index = bracket_field(text, tokens, index, None, &mut out);
}
_ => index += 1,
}
}
out
}
fn emit_names<'a>(token: &Token<'a>, out: &mut Vec<RawRef<'a>>) {
out.push(RawRef::Name {
name: token.text,
span: token.start..token.end(),
});
if !token.text.contains('.') {
return;
}
let mut offset = token.start;
for part in token.text.split('.') {
if !part.is_empty() {
out.push(RawRef::Name {
name: part,
span: offset..offset + part.len(),
});
}
offset += part.len() + 1; }
}
fn bracket_field<'a>(
text: &'a str,
tokens: &[Token<'a>],
start: usize,
table: Option<&'a str>,
out: &mut Vec<RawRef<'a>>,
) -> usize {
if tokens.get(start).is_none() {
return start;
}
let mut depth = 0usize;
let mut generalized = true;
let mut inner: Vec<&Token<'a>> = Vec::new();
let mut close = None;
for (offset, token) in tokens[start..].iter().enumerate() {
match token.kind {
TokenKind::OpenBracket => {
depth += 1;
if depth > 1 {
generalized = false;
}
}
TokenKind::CloseBracket => {
if depth == 0 {
generalized = false;
} else {
depth -= 1;
if depth == 0 {
close = Some(start + offset);
break;
}
generalized = false;
}
}
TokenKind::Identifier
| TokenKind::Dot
| TokenKind::Number
| TokenKind::QuotedIdentifier
if depth == 1 =>
{
inner.push(token);
}
TokenKind::Comment => {}
_ if depth >= 1 => generalized = false,
_ => {}
}
}
match close {
None => start + 1,
Some(close) if inner.is_empty() => close + 1,
Some(close) => {
if generalized {
let first = inner[0];
let last = inner[inner.len() - 1];
out.push(RawRef::Field {
table,
name: &text[first.start..last.end()],
span: tokens[start].start..tokens[close].end(),
});
}
close + 1
}
}
}
fn shared_members<'a>(tokens: &[Token<'a>], start: usize, out: &mut Vec<RawRef<'a>>) -> usize {
let mut depth = 0usize;
for (offset, token) in tokens[start..].iter().enumerate() {
match token.kind {
TokenKind::OpenBracket => depth += 1,
TokenKind::CloseBracket => {
depth -= 1;
if depth == 0 {
return start + offset + 1;
}
}
TokenKind::Identifier if depth == 1 => out.push(RawRef::Name {
name: token.text,
span: token.start..token.end(),
}),
TokenKind::QuotedIdentifier if depth == 1 => out.push(RawRef::Name {
name: quoted_inner(token.text),
span: token.start..token.end(),
}),
_ => {}
}
}
start
}
fn harvest_column_strings<'a>(tokens: &[Token<'a>], open: usize, out: &mut Vec<RawRef<'a>>) {
let mut depth = 0usize;
for token in &tokens[open..] {
match token.kind {
TokenKind::OpenParen | TokenKind::OpenBracket | TokenKind::OpenBrace => depth += 1,
TokenKind::CloseParen | TokenKind::CloseBracket | TokenKind::CloseBrace => {
depth -= 1;
if depth == 0 {
return;
}
}
TokenKind::String => out.push(RawRef::ColumnString {
name: string_inner(token.text),
span: token.start..token.end(),
}),
_ => {}
}
}
}
fn quoted_inner(text: &str) -> &str {
text[2..].strip_suffix('"').unwrap_or(&text[2..])
}
fn string_inner(text: &str) -> &str {
text[1..].strip_suffix('"').unwrap_or(&text[1..])
}
#[cfg(test)]
mod tests {
use super::*;
fn refs(text: &str) -> Vec<RawRef<'_>> {
references(text)
}
fn fields(text: &str) -> Vec<(Option<&str>, &str)> {
refs(text)
.into_iter()
.filter_map(|r| match r {
RawRef::Field { table, name, .. } => Some((table, name)),
_ => None,
})
.collect()
}
fn names(text: &str) -> Vec<&str> {
refs(text)
.into_iter()
.filter_map(|r| match r {
RawRef::Name { name, .. } => Some(name),
_ => None,
})
.collect()
}
fn column_strings(text: &str) -> Vec<&str> {
refs(text)
.into_iter()
.filter_map(|r| match r {
RawRef::ColumnString { name, .. } => Some(name),
_ => None,
})
.collect()
}
#[test]
fn finds_the_issue_three_reference_forms() {
let expand = r#"Table.ExpandTableColumn(Source, "Old", {"A", "B"})"#;
assert_eq!(column_strings(expand), ["Old", "A", "B"]);
assert_eq!(fields(r#"#"Sales"[Amount]"#), [(Some("Sales"), "Amount")]);
assert_eq!(fields("[Amount]"), [(None, "Amount")]);
let join = r#"Table.NestedJoin(A, "Key1", B, "Key2", "New")"#;
assert_eq!(column_strings(join), ["Key1", "Key2", "New"]);
}
#[test]
fn spans_cover_the_whole_reference() {
let text = "#\"Sales Header\"[Net Price]";
let found = refs(text);
let RawRef::Field { span, .. } = &found[0] else {
panic!("expected a field ref");
};
assert_eq!(&text[span.clone()], "[Net Price]");
}
#[test]
fn every_span_is_a_valid_source_subslice() {
let text = concat!(
"let\n",
" Source = #\"My Table\"[X],\n",
" Typed = Table.TransformColumnTypes(Source, {{\"Amount\", type text}}),\n",
" Filtered = Table.SelectRows(Typed, each [Amount] > 0)\n",
"in\n",
" Filtered",
);
for found in refs(text) {
let span = match found {
RawRef::Field { span, .. }
| RawRef::Name { span, .. }
| RawRef::ColumnString { span, .. } => span,
};
assert!(!span.is_empty());
assert!(
text.get(span.clone()).is_some(),
"span {span:?} must be inside the source"
);
}
}
#[test]
fn unqualified_field_access_in_each_row_context_is_a_field() {
assert_eq!(
fields("Table.SelectRows(Source, each [Amount] > 0)"),
[(None, "Amount")]
);
assert_eq!(
column_strings("Table.SelectRows(Source, each [Amount] > 0)"),
Vec::<&str>::new()
);
}
#[test]
fn a_record_literal_is_not_a_field_access() {
assert!(fields(r#"Sql.Database("srv", "db", [CommandTimeout = 30])"#).is_empty());
let fields_found = fields("[Amount] + [K = 1][Nope]");
assert_eq!(fields_found[0], (None, "Amount"));
assert!(
!fields_found.contains(&(None, "K")),
"the record literal [K = 1] must not yield a field for its key"
);
}
#[test]
fn generalized_identifiers_keep_blanks_and_dots() {
assert_eq!(fields("[Base Line]"), [(None, "Base Line")]);
assert_eq!(fields("[A. B]"), [(None, "A. B")]);
assert_eq!(fields("[1998 Sales]"), [(None, "1998 Sales")]);
assert_eq!(fields(r#"[#"It""s"]"#), [(None, "#\"It\"\"s\"")]);
}
#[test]
fn keywords_are_never_qualifiers_or_names() {
assert_eq!(fields("each [Amount]"), [(None, "Amount")]);
assert!(names("each [Amount]").is_empty(), "`each` is a keyword");
assert_eq!(fields("if [X] then 1 else 2"), [(None, "X")]);
}
#[test]
fn bare_identifiers_are_conservative_name_candidates() {
assert_eq!(
names("let Source = Sql.Database(ServerName) in Source"),
[
"Source",
"Sql.Database",
"Sql",
"Database",
"ServerName",
"Source",
],
"every bare word and call name is a candidate — built-ins resolve to nothing"
);
}
#[test]
fn dotted_identifiers_emit_their_parts() {
assert!(names("Sql.Database(Server.Name)").contains(&"Server"));
assert!(names("Sql.Database(Server.Name)").contains(&"Server.Name"));
}
#[test]
fn quoted_identifiers_are_name_candidates() {
assert_eq!(names("#\"My Query\""), ["My Query"]);
assert_eq!(names("#shared[#\"My Query\"]"), ["My Query"]);
assert!(fields("#shared[#\"My Query\"]").is_empty());
}
#[test]
fn harvesting_reaches_inside_pair_lists_and_nested_calls() {
let text = concat!(
"Table.Group(Source, {\"Key\"}, {{\"All\", ",
"each Table.TransformColumnTypes(_, {{\"Amount\", type text}})}})",
);
assert_eq!(column_strings(text), ["Key", "All", "Amount", "Amount"]);
}
#[test]
fn value_strings_outside_column_functions_are_not_columns() {
let text = concat!(
"Table.SelectRows(Source, each [Status] = \"Active\")\n",
"& Text.From(123) & \"Amount\"",
);
assert!(column_strings(text).is_empty());
assert_eq!(fields(text), [(None, "Status")]);
}
#[test]
fn references_inside_strings_and_comments_do_not_count() {
let text = concat!(
"\"[In String] [X]\"\n",
"// [In Comment] [Y]\n",
"/* [Block] [Comment] */\n",
"[Real]",
);
assert_eq!(fields(text), [(None, "Real")]);
assert!(names(text).is_empty());
assert!(column_strings(text).is_empty());
}
#[test]
fn escaped_names_carry_raw_slices() {
let found = refs("#\"It\"\"s\"[X]");
match &found[0] {
RawRef::Field {
table: Some(table),
name,
..
} => {
assert_eq!(*table, "It\"\"s");
assert_eq!(*name, "X");
}
other => panic!("expected a qualified field ref, got {other:?}"),
}
assert_eq!(unescape_name("It\"\"s").as_ref(), "It\"s");
}
#[test]
fn the_full_let_query_yields_every_kind_of_reference() {
let text = concat!(
"let\n",
" Source = Sql.Database(ServerName, \"db\"),\n",
" Staging = #\"My Staging\"[Amount],\n",
" Typed = Table.TransformColumnTypes(Staging, {{\"Amount\", type text}, {\"Beløb\", Currency.Type}}),\n",
" Filtered = Table.SelectRows(Typed, each [Region] = \"West\"),\n",
" Joined = Table.NestedJoin(Filtered, {\"Key\"}, DimTable, {\"Key\"}, \"Dim\")\n",
"in\n",
" Joined",
);
assert_eq!(
column_strings(text),
["Amount", "Beløb", "Key", "Key", "Dim"]
);
assert!(fields(text).contains(&(Some("My Staging"), "Amount")));
assert!(fields(text).contains(&(None, "Region")));
assert!(names(text).contains(&"DimTable"));
assert!(names(text).contains(&"ServerName"));
}
#[test]
fn unterminated_input_still_yields_conservative_refs() {
assert!(fields("#\"Sales [X").is_empty());
assert!(fields("[Col").is_empty());
assert_eq!(column_strings("Table.SelectColumns(Source, {\"A\""), ["A"]);
}
#[test]
fn empty_input_has_no_references() {
assert!(refs("").is_empty());
assert!(refs(" \n\t ").is_empty());
}
#[test]
fn column_string_function_matching_is_case_insensitive() {
assert_eq!(
column_strings("table.selectcolumns(Source, \"A\")"),
["A"],
"matching a built-in must not depend on its casing"
);
}
#[test]
fn unescape_name_handles_wrapper_and_escapes() {
assert!(matches!(unescape_name("Plain"), Cow::Borrowed("Plain")));
assert_eq!(unescape_name("#\"A B\"").as_ref(), "A B");
assert_eq!(unescape_name("A\"\"B").as_ref(), "A\"B");
assert_eq!(unescape_name("#\"A\"\"B\"").as_ref(), "A\"B");
assert_eq!(unescape_name("#\"A B").as_ref(), "A B");
}
}