1use std::borrow::Cow;
9use std::ops::Range;
10
11use super::lexer::{Token, TokenKind, tokenize};
12use crate::identity::{FieldRef, NameKey};
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub enum RawRef<'a> {
26 Field {
30 table: Option<&'a str>,
32 name: &'a str,
34 span: Range<usize>,
36 },
37 Table {
40 name: &'a str,
42 span: Range<usize>,
44 },
45 Function {
49 name: &'a str,
51 span: Range<usize>,
53 },
54}
55
56#[must_use]
72pub fn references(text: &str) -> Vec<RawRef<'_>> {
73 extract(&tokenize(text))
74}
75
76#[must_use]
89pub fn quoted_names(text: &str) -> Vec<Cow<'_, str>> {
90 tokenize(text)
91 .into_iter()
92 .filter(|token| token.kind == TokenKind::String)
93 .map(|token| string_inner(token.text))
94 .collect()
95}
96
97#[must_use]
103pub fn unescape_name(name: &str) -> Cow<'_, str> {
104 if name.contains("''") {
105 Cow::Owned(name.replace("''", "'"))
106 } else {
107 Cow::Borrowed(name)
108 }
109}
110
111impl RawRef<'_> {
112 #[must_use]
117 pub fn to_field_ref(&self) -> Option<FieldRef> {
118 let RawRef::Field { table, name, .. } = self else {
119 return None;
120 };
121 Some(FieldRef {
122 table: table.map(|table| NameKey::new(unescape_name(table).as_ref())),
123 name: NameKey::new(unescape_name(name).as_ref()),
124 })
125 }
126}
127
128fn extract<'a>(tokens: &[Token<'a>]) -> Vec<RawRef<'a>> {
129 let mut out = Vec::new();
130 let mut index = 0usize;
131
132 while index < tokens.len() {
133 let token = &tokens[index];
134 match token.kind {
135 TokenKind::QuotedTable => {
136 let name = quoted_inner(token.text);
137 if let Some(bracket) = bracket_at(tokens, index + 1) {
139 out.push(RawRef::Field {
140 table: Some(name),
141 name: quoted_inner(bracket.text),
142 span: token.start..bracket.end(),
143 });
144 index += 2;
145 } else {
146 out.push(RawRef::Table {
147 name,
148 span: token.start..token.end(),
149 });
150 index += 1;
151 }
152 }
153 TokenKind::Identifier => {
154 match tokens.get(index + 1).map(|t| t.kind) {
155 Some(TokenKind::BracketName) => {
157 let bracket = &tokens[index + 1];
158 out.push(RawRef::Field {
159 table: Some(token.text),
160 name: quoted_inner(bracket.text),
161 span: token.start..bracket.end(),
162 });
163 index += 2;
164 }
165 Some(TokenKind::OpenParen) => {
168 out.push(RawRef::Function {
169 name: token.text,
170 span: token.start..token.end(),
171 });
172 index += 1;
173 }
174 _ => {
178 out.push(RawRef::Table {
179 name: token.text,
180 span: token.start..token.end(),
181 });
182 index += 1;
183 }
184 }
185 }
186 TokenKind::BracketName => {
188 out.push(RawRef::Field {
189 table: None,
190 name: quoted_inner(token.text),
191 span: token.start..token.end(),
192 });
193 index += 1;
194 }
195 _ => index += 1,
196 }
197 }
198
199 out
200}
201
202fn bracket_at<'a, 'b>(tokens: &'a [Token<'b>], index: usize) -> Option<&'a Token<'b>> {
204 let token = tokens.get(index)?;
205 (token.kind == TokenKind::BracketName).then_some(token)
206}
207
208fn quoted_inner(text: &str) -> &str {
211 let bytes = text.as_bytes();
212 let closed = text.len() >= 2
213 && match bytes[0] {
214 b'\'' => bytes[text.len() - 1] == b'\'',
215 b'[' => bytes[text.len() - 1] == b']',
216 _ => false,
217 };
218 if closed {
219 &text[1..text.len() - 1]
220 } else {
221 &text[1..]
222 }
223}
224
225fn string_inner(text: &str) -> Cow<'_, str> {
229 let bytes = text.as_bytes();
230 let closed = text.len() >= 2 && bytes[0] == b'"' && bytes[text.len() - 1] == b'"';
231 let inner = if closed {
232 &text[1..text.len() - 1]
233 } else {
234 &text[1..]
235 };
236 if inner.contains("\"\"") {
237 Cow::Owned(inner.replace("\"\"", "\""))
238 } else {
239 Cow::Borrowed(inner)
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn refs(text: &str) -> Vec<RawRef<'_>> {
248 references(text)
249 }
250
251 fn names(text: &str) -> Vec<String> {
252 quoted_names(text)
253 .into_iter()
254 .map(|n| n.into_owned())
255 .collect()
256 }
257
258 fn fields(text: &str) -> Vec<(Option<&str>, &str)> {
260 refs(text)
261 .into_iter()
262 .filter_map(|r| match r {
263 RawRef::Field { table, name, .. } => Some((table, name)),
264 _ => None,
265 })
266 .collect()
267 }
268
269 fn tables(text: &str) -> Vec<&str> {
271 refs(text)
272 .into_iter()
273 .filter_map(|r| match r {
274 RawRef::Table { name, .. } => Some(name),
275 _ => None,
276 })
277 .collect()
278 }
279
280 fn functions(text: &str) -> Vec<&str> {
282 refs(text)
283 .into_iter()
284 .filter_map(|r| match r {
285 RawRef::Function { name, .. } => Some(name),
286 _ => None,
287 })
288 .collect()
289 }
290
291 #[test]
292 fn finds_the_three_reference_forms() {
293 assert_eq!(
294 fields("'Sales Header'[Net Price] + Sales[Amount] - [Total]"),
295 [
296 (Some("Sales Header"), "Net Price"),
297 (Some("Sales"), "Amount"),
298 (None, "Total"),
299 ]
300 );
301 }
302
303 #[test]
304 fn spans_cover_the_whole_reference() {
305 let text = "SUM('Sales Header'[Net Price])";
306 let found = refs(text);
307 assert_eq!(found.len(), 2, "the SUM call plus its one argument");
308 let RawRef::Field { span, .. } = &found[1] else {
309 panic!("the second ref is the qualified field");
310 };
311 assert_eq!(&text[span.clone()], "'Sales Header'[Net Price]");
312 }
313
314 #[test]
315 fn every_span_is_a_valid_source_subslice() {
316 let text = "'It''s'[X] + COUNTROWS(Sales) + [Total] * MyFunc([Amount])";
317 for found in refs(text) {
318 let span = match found {
319 RawRef::Field { span, .. }
320 | RawRef::Table { span, .. }
321 | RawRef::Function { span, .. } => span,
322 };
323 assert!(!span.is_empty());
324 assert!(
325 text.get(span.clone()).is_some(),
326 "span {span:?} must be inside the source"
327 );
328 }
329 }
330
331 #[test]
332 fn whitespace_between_qualifier_and_name_still_qualifies() {
333 assert_eq!(
334 fields("'Sales' [Amount] + Dato [Måned]"),
335 [(Some("Sales"), "Amount"), (Some("Dato"), "Måned"),]
336 );
337 }
338
339 #[test]
340 fn quoted_tables_with_escaped_quotes_carry_raw_names() {
341 let found = refs("'It''s'[X]");
342 assert_eq!(found.len(), 1);
343 match &found[0] {
344 RawRef::Field {
345 table: Some(table),
346 name,
347 ..
348 } => {
349 assert_eq!(*table, "It''s");
350 assert_eq!(*name, "X");
351 }
352 other => panic!("expected a qualified field ref, got {other:?}"),
353 }
354 let field = found[0].to_field_ref().expect("field ref");
356 assert_eq!(field.table.as_ref().map(NameKey::as_str), Some("It's"));
357 assert_eq!(field.name.as_str(), "X");
358 }
359
360 #[test]
361 fn bare_table_uses_are_detected() {
362 assert_eq!(
363 tables("COUNTROWS(Sales) + COUNTROWS('Sales Order')"),
364 ["Sales", "Sales Order"]
365 );
366 assert_eq!(tables("EVALUATE Sales"), ["EVALUATE", "Sales"]);
369 }
370
371 #[test]
372 fn calls_become_function_candidates() {
373 assert_eq!(functions("SUMX(VALUES(T), [M])"), ["SUMX", "VALUES"]);
374 assert_eq!(fields("SUMX(VALUES(T), [M])"), [(None, "M"),]);
375 assert_eq!(tables("SUMX(VALUES(T), [M])"), ["T"]);
376 }
377
378 #[test]
379 fn a_reference_followed_by_a_call_is_not_a_call() {
380 assert!(functions("[Total]([Amount])").is_empty());
382 assert_eq!(
383 fields("[Total]([Amount])"),
384 [(None, "Total"), (None, "Amount")]
385 );
386 }
387
388 #[test]
389 fn refs_inside_strings_and_comments_do_not_count() {
390 let text = concat!(
391 "\"[In String] 'Table'[X]\"\n",
392 "// [In Line Comment] 'Q'[Y]\n",
393 "-- [Dash Comment]\n",
394 "/* [Block] [Comment] */\n",
395 "dt\"[Date Literal]\"\n",
396 "[Real]",
397 );
398 assert_eq!(fields(text), [(None, "Real")]);
399 assert!(tables(text).is_empty());
400 assert!(functions(text).is_empty());
401 }
402
403 #[test]
404 fn comments_are_skipped_but_the_code_around_them_is_not() {
405 assert_eq!(
406 fields("[A] /* [B] */ [C] -- [D]\n[E] // [F]"),
407 [(None, "A"), (None, "C"), (None, "E"),]
408 );
409 }
410
411 #[test]
412 fn the_tricky_sample_from_the_sqlbi_smoke_tests_yields_one_field_ref() {
413 let source = concat!(
418 "Tricky :=\n",
419 "-- a real comment\n",
420 "VAR Year = 2024\n",
421 "VAR Note = \"-- not a comment\"\n",
422 "RETURN Year & Note & Sales[Amount]\n",
423 );
424 assert_eq!(refs(source).len(), 9);
425 assert_eq!(fields(source), [(Some("Sales"), "Amount")]);
426 assert_eq!(tables(source).len(), 8);
427 assert!(functions(source).is_empty());
428
429 let tokens = crate::dax::lexer::tokenize(source);
430 assert_eq!(
431 tokens
432 .iter()
433 .filter(|t| t.kind == TokenKind::Comment)
434 .count(),
435 1,
436 "the real comment, and only it, is a comment"
437 );
438 }
439
440 #[test]
441 fn the_sumx_definition_sample_yields_three_field_refs() {
442 assert_eq!(
446 fields("SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )"),
447 [(Some("Sales"), "Quantity"), (Some("Sales"), "Net Price")]
448 );
449 assert_eq!(
450 tables("SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )"),
451 ["Sales"]
452 );
453
454 let header = "[Sales Amount] = SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )";
459 assert_eq!(
460 fields(header),
461 [
462 (None, "Sales Amount"),
463 (Some("Sales"), "Quantity"),
464 (Some("Sales"), "Net Price"),
465 ]
466 );
467 }
468
469 #[test]
470 fn function_bodies_keep_their_own_references() {
471 let source = "SELECTCOLUMNS('Sales', \"Key\", [SalesOrderLineKey])";
474 assert_eq!(fields(source), [(None, "SalesOrderLineKey")]);
475 assert_eq!(tables(source), ["Sales"]);
476 }
477
478 #[test]
479 fn hierarchy_level_syntax_lexes_as_independent_refs() {
480 assert_eq!(
482 fields("Product[Category].[Subcategory]"),
483 [(Some("Product"), "Category"), (None, "Subcategory"),]
484 );
485 }
486
487 #[test]
488 fn unterminated_tokens_still_produce_conservative_refs() {
489 assert_eq!(tables("'Sales [Amount]"), ["Sales [Amount]"]);
492 assert_eq!(fields("[Col"), [(None, "Col")]);
494 }
495
496 #[test]
497 fn keywords_and_variables_are_conservative_table_candidates() {
498 assert_eq!(tables("TRUE && FALSE"), ["TRUE", "FALSE"]);
501 }
502
503 #[test]
504 fn numbers_and_parameters_are_never_refs() {
505 assert!(refs("1.5E+10 + @Risk + .5").is_empty());
506 }
507
508 #[test]
512 fn quoted_names_returns_string_contents() {
513 assert_eq!(
514 names("ADDCOLUMNS(t, \"@Krav\", \"It\"\"s\") -- \"gone\" dt\"2024-01-01\""),
515 ["@Krav".to_string(), "It\"s".to_string()]
516 );
517 assert!(names("[No string here] + 1").is_empty());
518 }
519
520 #[test]
521 fn empty_input_has_no_references() {
522 assert!(refs("").is_empty());
523 assert!(refs(" \n\t ").is_empty());
524 }
525
526 #[test]
527 fn unescape_name_borrows_without_escapes_and_resolves_with_them() {
528 assert!(matches!(unescape_name("Plain"), Cow::Borrowed("Plain")));
529 assert!(matches!(unescape_name("It''s"), Cow::Owned(_)));
530 assert_eq!(unescape_name("It''s''''s").as_ref(), "It's''s");
531 assert!(matches!(
533 unescape_name("Net Price"),
534 Cow::Borrowed("Net Price")
535 ));
536 }
537
538 #[test]
539 fn to_field_ref_is_none_for_table_and_function_refs() {
540 let text = "COUNTROWS(Sales)";
541 for found in &refs(text) {
542 match found {
543 RawRef::Table { .. } | RawRef::Function { .. } => {
544 assert!(found.to_field_ref().is_none());
545 }
546 RawRef::Field { .. } => assert!(found.to_field_ref().is_some()),
547 }
548 }
549 }
550}