use std::borrow::Cow;
use std::ops::Range;
use super::lexer::{Token, TokenKind, tokenize};
use crate::identity::{FieldRef, NameKey};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum RawRef<'a> {
Field {
table: Option<&'a str>,
name: &'a str,
span: Range<usize>,
},
Table {
name: &'a str,
span: Range<usize>,
},
Function {
name: &'a str,
span: Range<usize>,
},
}
#[must_use]
pub fn references(text: &str) -> Vec<RawRef<'_>> {
extract(&tokenize(text))
}
#[must_use]
pub fn unescape_name(name: &str) -> Cow<'_, str> {
if name.contains("''") {
Cow::Owned(name.replace("''", "'"))
} else {
Cow::Borrowed(name)
}
}
impl RawRef<'_> {
#[must_use]
pub fn to_field_ref(&self) -> Option<FieldRef> {
let RawRef::Field { table, name, .. } = self else {
return None;
};
Some(FieldRef {
table: table.map(|table| NameKey::new(unescape_name(table).as_ref())),
name: NameKey::new(unescape_name(name).as_ref()),
})
}
}
fn extract<'a>(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::QuotedTable => {
let name = quoted_inner(token.text);
if let Some(bracket) = bracket_at(tokens, index + 1) {
out.push(RawRef::Field {
table: Some(name),
name: quoted_inner(bracket.text),
span: token.start..bracket.end(),
});
index += 2;
} else {
out.push(RawRef::Table {
name,
span: token.start..token.end(),
});
index += 1;
}
}
TokenKind::Identifier => {
match tokens.get(index + 1).map(|t| t.kind) {
Some(TokenKind::BracketName) => {
let bracket = &tokens[index + 1];
out.push(RawRef::Field {
table: Some(token.text),
name: quoted_inner(bracket.text),
span: token.start..bracket.end(),
});
index += 2;
}
Some(TokenKind::OpenParen) => {
out.push(RawRef::Function {
name: token.text,
span: token.start..token.end(),
});
index += 1;
}
_ => {
out.push(RawRef::Table {
name: token.text,
span: token.start..token.end(),
});
index += 1;
}
}
}
TokenKind::BracketName => {
out.push(RawRef::Field {
table: None,
name: quoted_inner(token.text),
span: token.start..token.end(),
});
index += 1;
}
_ => index += 1,
}
}
out
}
fn bracket_at<'a, 'b>(tokens: &'a [Token<'b>], index: usize) -> Option<&'a Token<'b>> {
let token = tokens.get(index)?;
(token.kind == TokenKind::BracketName).then_some(token)
}
fn quoted_inner(text: &str) -> &str {
let bytes = text.as_bytes();
let closed = text.len() >= 2
&& match bytes[0] {
b'\'' => bytes[text.len() - 1] == b'\'',
b'[' => bytes[text.len() - 1] == b']',
_ => false,
};
if closed {
&text[1..text.len() - 1]
} else {
&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 tables(text: &str) -> Vec<&str> {
refs(text)
.into_iter()
.filter_map(|r| match r {
RawRef::Table { name, .. } => Some(name),
_ => None,
})
.collect()
}
fn functions(text: &str) -> Vec<&str> {
refs(text)
.into_iter()
.filter_map(|r| match r {
RawRef::Function { name, .. } => Some(name),
_ => None,
})
.collect()
}
#[test]
fn finds_the_three_reference_forms() {
assert_eq!(
fields("'Sales Header'[Net Price] + Sales[Amount] - [Total]"),
[
(Some("Sales Header"), "Net Price"),
(Some("Sales"), "Amount"),
(None, "Total"),
]
);
}
#[test]
fn spans_cover_the_whole_reference() {
let text = "SUM('Sales Header'[Net Price])";
let found = refs(text);
assert_eq!(found.len(), 2, "the SUM call plus its one argument");
let RawRef::Field { span, .. } = &found[1] else {
panic!("the second ref is the qualified field");
};
assert_eq!(&text[span.clone()], "'Sales Header'[Net Price]");
}
#[test]
fn every_span_is_a_valid_source_subslice() {
let text = "'It''s'[X] + COUNTROWS(Sales) + [Total] * MyFunc([Amount])";
for found in refs(text) {
let span = match found {
RawRef::Field { span, .. }
| RawRef::Table { span, .. }
| RawRef::Function { span, .. } => span,
};
assert!(!span.is_empty());
assert!(
text.get(span.clone()).is_some(),
"span {span:?} must be inside the source"
);
}
}
#[test]
fn whitespace_between_qualifier_and_name_still_qualifies() {
assert_eq!(
fields("'Sales' [Amount] + Dato [Måned]"),
[(Some("Sales"), "Amount"), (Some("Dato"), "Måned"),]
);
}
#[test]
fn quoted_tables_with_escaped_quotes_carry_raw_names() {
let found = refs("'It''s'[X]");
assert_eq!(found.len(), 1);
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:?}"),
}
let field = found[0].to_field_ref().expect("field ref");
assert_eq!(field.table.as_ref().map(NameKey::as_str), Some("It's"));
assert_eq!(field.name.as_str(), "X");
}
#[test]
fn bare_table_uses_are_detected() {
assert_eq!(
tables("COUNTROWS(Sales) + COUNTROWS('Sales Order')"),
["Sales", "Sales Order"]
);
assert_eq!(tables("EVALUATE Sales"), ["EVALUATE", "Sales"]);
}
#[test]
fn calls_become_function_candidates() {
assert_eq!(functions("SUMX(VALUES(T), [M])"), ["SUMX", "VALUES"]);
assert_eq!(fields("SUMX(VALUES(T), [M])"), [(None, "M"),]);
assert_eq!(tables("SUMX(VALUES(T), [M])"), ["T"]);
}
#[test]
fn a_reference_followed_by_a_call_is_not_a_call() {
assert!(functions("[Total]([Amount])").is_empty());
assert_eq!(
fields("[Total]([Amount])"),
[(None, "Total"), (None, "Amount")]
);
}
#[test]
fn refs_inside_strings_and_comments_do_not_count() {
let text = concat!(
"\"[In String] 'Table'[X]\"\n",
"// [In Line Comment] 'Q'[Y]\n",
"-- [Dash Comment]\n",
"/* [Block] [Comment] */\n",
"dt\"[Date Literal]\"\n",
"[Real]",
);
assert_eq!(fields(text), [(None, "Real")]);
assert!(tables(text).is_empty());
assert!(functions(text).is_empty());
}
#[test]
fn comments_are_skipped_but_the_code_around_them_is_not() {
assert_eq!(
fields("[A] /* [B] */ [C] -- [D]\n[E] // [F]"),
[(None, "A"), (None, "C"), (None, "E"),]
);
}
#[test]
fn the_tricky_sample_from_the_sqlbi_smoke_tests_yields_one_field_ref() {
let source = concat!(
"Tricky :=\n",
"-- a real comment\n",
"VAR Year = 2024\n",
"VAR Note = \"-- not a comment\"\n",
"RETURN Year & Note & Sales[Amount]\n",
);
assert_eq!(refs(source).len(), 9);
assert_eq!(fields(source), [(Some("Sales"), "Amount")]);
assert_eq!(tables(source).len(), 8);
assert!(functions(source).is_empty());
let tokens = crate::dax::lexer::tokenize(source);
assert_eq!(
tokens
.iter()
.filter(|t| t.kind == TokenKind::Comment)
.count(),
1,
"the real comment, and only it, is a comment"
);
}
#[test]
fn the_sumx_definition_sample_yields_three_field_refs() {
assert_eq!(
fields("SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )"),
[(Some("Sales"), "Quantity"), (Some("Sales"), "Net Price")]
);
assert_eq!(
tables("SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )"),
["Sales"]
);
let header = "[Sales Amount] = SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )";
assert_eq!(
fields(header),
[
(None, "Sales Amount"),
(Some("Sales"), "Quantity"),
(Some("Sales"), "Net Price"),
]
);
}
#[test]
fn function_bodies_keep_their_own_references() {
let source = "SELECTCOLUMNS('Sales', \"Key\", [SalesOrderLineKey])";
assert_eq!(fields(source), [(None, "SalesOrderLineKey")]);
assert_eq!(tables(source), ["Sales"]);
}
#[test]
fn hierarchy_level_syntax_lexes_as_independent_refs() {
assert_eq!(
fields("Product[Category].[Subcategory]"),
[(Some("Product"), "Category"), (None, "Subcategory"),]
);
}
#[test]
fn unterminated_tokens_still_produce_conservative_refs() {
assert_eq!(tables("'Sales [Amount]"), ["Sales [Amount]"]);
assert_eq!(fields("[Col"), [(None, "Col")]);
}
#[test]
fn keywords_and_variables_are_conservative_table_candidates() {
assert_eq!(tables("TRUE && FALSE"), ["TRUE", "FALSE"]);
}
#[test]
fn numbers_and_parameters_are_never_refs() {
assert!(refs("1.5E+10 + @Risk + .5").is_empty());
}
#[test]
fn empty_input_has_no_references() {
assert!(refs("").is_empty());
assert!(refs(" \n\t ").is_empty());
}
#[test]
fn unescape_name_borrows_without_escapes_and_resolves_with_them() {
assert!(matches!(unescape_name("Plain"), Cow::Borrowed("Plain")));
assert!(matches!(unescape_name("It''s"), Cow::Owned(_)));
assert_eq!(unescape_name("It''s''''s").as_ref(), "It's''s");
assert!(matches!(
unescape_name("Net Price"),
Cow::Borrowed("Net Price")
));
}
#[test]
fn to_field_ref_is_none_for_table_and_function_refs() {
let text = "COUNTROWS(Sales)";
for found in &refs(text) {
match found {
RawRef::Table { .. } | RawRef::Function { .. } => {
assert!(found.to_field_ref().is_none());
}
RawRef::Field { .. } => assert!(found.to_field_ref().is_some()),
}
}
}
}