001package gudusoft.gsqlparser.parser.powerquery; 002 003import gudusoft.gsqlparser.EDbVendor; 004import gudusoft.gsqlparser.ETokenType; 005import gudusoft.gsqlparser.TSourceToken; 006import gudusoft.gsqlparser.nodes.powerquery.TPowerQueryConnectorCall; 007import gudusoft.gsqlparser.nodes.powerquery.TPowerQueryIdentifierRef; 008import gudusoft.gsqlparser.nodes.powerquery.TPowerQueryLetExpr; 009import gudusoft.gsqlparser.nodes.powerquery.TPowerQueryLiteral; 010import gudusoft.gsqlparser.nodes.powerquery.TPowerQueryNativeQuery; 011import gudusoft.gsqlparser.nodes.powerquery.TPowerQueryNavChain; 012import gudusoft.gsqlparser.nodes.powerquery.TPowerQueryNavSegment; 013import gudusoft.gsqlparser.nodes.powerquery.TPowerQueryStep; 014import gudusoft.gsqlparser.powerquery.ConnectorCatalog; 015 016import java.util.ArrayList; 017import java.util.List; 018 019/** 020 * Hand-written recursive-style parser that walks an M-language token list 021 * (produced by {@link PowerQueryTokenizer}) and builds the step graph 022 * rooted at {@link TPowerQueryLetExpr}. 023 * 024 * <p>This is intentionally a <em>lightweight</em> parser, not a full 025 * M-language parser. It answers exactly the 6 questions the plan calls 026 * out: 027 * 028 * <ol> 029 * <li>What are the {@code let} bindings?</li> 030 * <li>Which binding is returned by {@code in}?</li> 031 * <li>Does a binding contain {@code Value.NativeQuery}? → extract SQL, 032 * decode escapes</li> 033 * <li>Does a binding represent a navigation chain to a table/view?</li> 034 * <li>What connector function is at the root?</li> 035 * <li>Which bindings reference other bindings?</li> 036 * </ol> 037 * 038 * <p>Anything else (each, closures, type ascription, metadata, etc.) is 039 * swallowed by {@link #skipExpression} which advances past a balanced 040 * token sequence. A warning is emitted when an expression cannot be 041 * classified into one of the lineage-bearing shapes. 042 */ 043public class PowerQueryDocumentParser { 044 045 private final List<TSourceToken> tokens; 046 private int pos; 047 private final List<String> warnings = new ArrayList<>(); 048 049 public PowerQueryDocumentParser(List<TSourceToken> tokens) { 050 this.tokens = tokens != null ? tokens : new ArrayList<>(); 051 } 052 053 public List<String> getWarnings() { 054 return warnings; 055 } 056 057 /** 058 * Parse the token list as a complete M document. Returns the 059 * resulting {@link TPowerQueryLetExpr} or {@code null} if the document 060 * is not a {@code let ... in ...} shape and nothing can be extracted. 061 */ 062 public TPowerQueryLetExpr parse() { 063 TPowerQueryLetExpr doc = new TPowerQueryLetExpr(); 064 pos = 0; 065 skipTrivia(); 066 067 // Optional "section <name>;" header — we just skip everything up to 068 // the first "let" so sectioned docs do not confuse the parser. 069 if (peekIs(PowerQueryTokenCodes.RW_SECTION)) { 070 while (pos < tokens.size() 071 && !peekIs(PowerQueryTokenCodes.RW_LET)) { 072 pos++; 073 } 074 skipTrivia(); 075 } 076 077 if (!peekIs(PowerQueryTokenCodes.RW_LET)) { 078 // Not a let/in document. We still hand back a container so 079 // that callers can route on getSteps().isEmpty(). 080 if (!tokens.isEmpty()) { 081 warnings.add( 082 "Power Query document did not begin with 'let'; " 083 + "lineage extraction limited to NativeQuery scans."); 084 } 085 return doc; 086 } 087 088 pos++; // consume LET 089 parseStepList(doc); 090 091 skipTrivia(); 092 if (peekIs(PowerQueryTokenCodes.RW_IN)) { 093 pos++; 094 skipTrivia(); 095 TSourceToken resultTok = peek(); 096 if (resultTok != null 097 && (resultTok.tokentype == ETokenType.ttidentifier 098 || resultTok.tokencode == PowerQueryTokenCodes.QIDENT)) { 099 doc.setResultStepName(identifierText(resultTok)); 100 pos++; 101 } 102 } 103 return doc; 104 } 105 106 // ---------- step list ---------- 107 108 private void parseStepList(TPowerQueryLetExpr doc) { 109 while (pos < tokens.size()) { 110 skipTrivia(); 111 if (peekIs(PowerQueryTokenCodes.RW_IN) || peek() == null) { 112 return; 113 } 114 115 TPowerQueryStep step = parseStep(); 116 if (step != null) { 117 doc.addStep(step); 118 } 119 120 skipTrivia(); 121 if (peekIsChar(',')) { 122 pos++; 123 continue; 124 } 125 return; 126 } 127 } 128 129 private TPowerQueryStep parseStep() { 130 TSourceToken nameTok = peek(); 131 if (nameTok == null) return null; 132 133 if (nameTok.tokentype != ETokenType.ttidentifier 134 && nameTok.tokencode != PowerQueryTokenCodes.QIDENT) { 135 // Not a step binding — skip one token and bail. 136 warnings.add("Unexpected token '" + nameTok.toString() 137 + "' while parsing step name."); 138 pos++; 139 return null; 140 } 141 String name = identifierText(nameTok); 142 int stepStart = pos; 143 pos++; 144 skipTrivia(); 145 146 if (!peekIsChar('=')) { 147 warnings.add("Step '" + name + "' is missing '=' assignment."); 148 return null; 149 } 150 pos++; 151 skipTrivia(); 152 153 int exprStart = pos; 154 int exprEnd = skipExpression(); 155 TPowerQueryStep step = new TPowerQueryStep(); 156 step.setName(name); 157 step.setRawExpressionText(tokenSlice(exprStart, exprEnd)); 158 159 // Try to classify the expression into one of the lineage-bearing 160 // shapes. Order matters: NativeQuery is the most specific. 161 TSourceToken expressionFirst = tokenAt(exprStart); 162 if (expressionFirst != null) { 163 step.setStartToken(tokenAt(stepStart)); 164 step.setEndToken(tokenAt(Math.max(exprStart, exprEnd - 1))); 165 } 166 167 step.setExpression(classifyExpression(exprStart, exprEnd)); 168 return step; 169 } 170 171 // ---------- expression classification ---------- 172 173 private gudusoft.gsqlparser.nodes.TParseTreeNode classifyExpression( 174 int start, int endExclusive) { 175 if (start >= endExclusive) return null; 176 177 // 1) Value.NativeQuery(source, "sql", ...) 178 TPowerQueryNativeQuery nq = tryMatchNativeQuery(start, endExclusive); 179 if (nq != null) return nq; 180 181 // 2) Navigation chain rooted at identifier or connector call 182 TPowerQueryNavChain nav = tryMatchNavigationChain(start, endExclusive); 183 if (nav != null) return nav; 184 185 // 3) Connector call as step value (rare but legitimate when a 186 // step binds directly to e.g. Snowflake.Databases(...)) 187 TPowerQueryConnectorCall connector = tryMatchConnectorCall(start, endExclusive); 188 if (connector != null) return connector; 189 190 // 4) Plain identifier reference (step-to-step alias) 191 TPowerQueryIdentifierRef ref = tryMatchIdentifierRef(start, endExclusive); 192 if (ref != null) return ref; 193 194 // 5) Literal 195 TPowerQueryLiteral lit = tryMatchLiteral(start, endExclusive); 196 if (lit != null) return lit; 197 198 // Otherwise: unknown shape — the raw text is still on the step. 199 warnings.add("Opaque step expression: " 200 + tokenSlice(start, endExclusive)); 201 return null; 202 } 203 204 // ---------- NativeQuery detection ---------- 205 206 private TPowerQueryNativeQuery tryMatchNativeQuery(int start, int endExclusive) { 207 // Shape: Value.NativeQuery ( <source> , <sqlLiteral> [, ... ] ) 208 int p = start; 209 TSourceToken id1 = tokenAt(p); 210 if (id1 == null || id1.tokentype != ETokenType.ttidentifier) return null; 211 if (!"Value".equalsIgnoreCase(id1.toString())) return null; 212 p++; 213 if (!tokenIsChar(p, '.')) return null; 214 p++; 215 TSourceToken id2 = tokenAt(p); 216 if (id2 == null) return null; 217 if (!"NativeQuery".equalsIgnoreCase(id2.toString())) return null; 218 p++; 219 if (!tokenIsChar(p, '(')) return null; 220 int argsStart = p + 1; 221 int parenEnd = findMatchingParen(p); 222 if (parenEnd < 0 || parenEnd >= endExclusive) return null; 223 224 List<int[]> argRanges = splitArgsByTopLevelCommas(argsStart, parenEnd); 225 if (argRanges.size() < 2) return null; 226 227 int[] sourceRange = argRanges.get(0); 228 int[] sqlRange = argRanges.get(1); 229 230 String sourceName = extractSourceStepName(sourceRange[0], sourceRange[1]); 231 TSourceToken sqlTok = firstStringLiteral(sqlRange[0], sqlRange[1]); 232 if (sqlTok == null) return null; 233 234 String raw = stripSurroundingQuotes(sqlTok.toString()); 235 PowerQueryEscapeDecoder.Result decoded = PowerQueryEscapeDecoder.decode(raw); 236 if (decoded.hasWarnings()) { 237 warnings.add(decoded.getWarning()); 238 } 239 240 TPowerQueryNativeQuery nq = new TPowerQueryNativeQuery(); 241 nq.setSourceStepName(sourceName); 242 nq.setRawSqlLiteral(sqlTok.toString()); 243 nq.setDecodedSql(decoded.getDecoded()); 244 nq.setStartToken(tokenAt(start)); 245 nq.setEndToken(tokenAt(parenEnd)); 246 247 // When the source argument is an inline connector call (e.g. 248 // Snowflake.Databases(...){[Name=...]}[Data]) rather than a named 249 // step reference, infer the vendor directly from the connector 250 // function name found in the source token range. 251 if (sourceName == null || !looksLikeStepName(sourceName)) { 252 EDbVendor inlineVendor = detectInlineConnectorVendor( 253 sourceRange[0], sourceRange[1]); 254 if (inlineVendor != null) { 255 nq.setInferredInnerVendor(inlineVendor); 256 } 257 } 258 259 return nq; 260 } 261 262 // ---------- Navigation chain detection ---------- 263 264 private TPowerQueryNavChain tryMatchNavigationChain(int start, int endExclusive) { 265 // Shape: <source> { [ Name="X", Kind="Y" ] } [ Data ] 266 // [<source> { [ Name="..." ] } [Data]]* 267 int p = start; 268 TSourceToken sourceTok = tokenAt(p); 269 if (sourceTok == null 270 || (sourceTok.tokentype != ETokenType.ttidentifier 271 && sourceTok.tokencode != PowerQueryTokenCodes.QIDENT)) { 272 return null; 273 } 274 p++; 275 if (!tokenIsChar(p, '{')) return null; 276 277 TPowerQueryNavChain chain = new TPowerQueryNavChain(); 278 chain.setSourceStepName(identifierText(sourceTok)); 279 chain.setStartToken(sourceTok); 280 281 while (p < endExclusive && tokenIsChar(p, '{')) { 282 int braceEnd = findMatchingBrace(p); 283 if (braceEnd < 0 || braceEnd >= endExclusive) return null; 284 285 TPowerQueryNavSegment seg = parseNavSegment(p + 1, braceEnd); 286 if (seg != null) { 287 chain.addSegment(seg); 288 } else { 289 warnings.add("Skipping unparseable navigation segment at token " 290 + p); 291 } 292 p = braceEnd + 1; 293 294 // optional [Data] accessor (we just skip over it) 295 if (tokenIsChar(p, '[')) { 296 int bracketEnd = findMatchingBracket(p); 297 if (bracketEnd < 0 || bracketEnd >= endExclusive) return null; 298 p = bracketEnd + 1; 299 } 300 } 301 302 if (chain.getSegments().isEmpty()) return null; 303 chain.setEndToken(tokenAt(Math.max(start, p - 1))); 304 return chain; 305 } 306 307 private TPowerQueryNavSegment parseNavSegment(int start, int endExclusive) { 308 // We expect: [ Name="X", Kind="Y" ] (record literal) 309 int p = start; 310 if (!tokenIsChar(p, '[')) return null; 311 int endBracket = findMatchingBracket(p); 312 if (endBracket < 0 || endBracket >= endExclusive) return null; 313 314 TPowerQueryNavSegment seg = new TPowerQueryNavSegment(); 315 int fieldPos = p + 1; 316 while (fieldPos < endBracket) { 317 TSourceToken nameTok = tokenAt(fieldPos); 318 if (nameTok == null) break; 319 String fieldName = identifierText(nameTok); 320 fieldPos++; 321 if (!tokenIsChar(fieldPos, '=')) { fieldPos++; continue; } 322 fieldPos++; 323 TSourceToken valTok = tokenAt(fieldPos); 324 if (valTok == null) break; 325 if (valTok.tokencode == gudusoft.gsqlparser.TBaseType.sconst) { 326 String rawValue = stripSurroundingQuotes(valTok.toString()); 327 PowerQueryEscapeDecoder.Result dec 328 = PowerQueryEscapeDecoder.decode(rawValue); 329 String decoded = dec.getDecoded(); 330 if ("Name".equalsIgnoreCase(fieldName)) { 331 seg.setName(decoded); 332 } else if ("Kind".equalsIgnoreCase(fieldName)) { 333 seg.setKind(decoded); 334 } 335 } 336 fieldPos++; 337 if (tokenIsChar(fieldPos, ',')) fieldPos++; 338 } 339 return seg; 340 } 341 342 // ---------- Connector call detection ---------- 343 344 private TPowerQueryConnectorCall tryMatchConnectorCall(int start, int endExclusive) { 345 // Shape: Qualified.Function ( … ) where Qualified.Function is in 346 // ConnectorCatalog.FUNCTION_TO_VENDOR. 347 int p = start; 348 StringBuilder qualified = new StringBuilder(); 349 TSourceToken first = tokenAt(p); 350 if (first == null || first.tokentype != ETokenType.ttidentifier) return null; 351 qualified.append(first.toString()); 352 p++; 353 while (p < endExclusive && tokenIsChar(p, '.')) { 354 p++; 355 TSourceToken nxt = tokenAt(p); 356 if (nxt == null || nxt.tokentype != ETokenType.ttidentifier) return null; 357 qualified.append('.').append(nxt.toString()); 358 p++; 359 } 360 if (!tokenIsChar(p, '(')) return null; 361 362 EDbVendor resolved = ConnectorCatalog.vendorFor(qualified.toString()); 363 if (resolved == null) return null; 364 365 TPowerQueryConnectorCall call = new TPowerQueryConnectorCall(); 366 call.setConnectorFunction(qualified.toString()); 367 call.setResolvedVendor(resolved); 368 call.setStartToken(first); 369 370 int parenEnd = findMatchingParen(p); 371 if (parenEnd >= 0 && parenEnd < endExclusive) { 372 for (int[] arg : splitArgsByTopLevelCommas(p + 1, parenEnd)) { 373 call.addRawArgument(tokenSlice(arg[0], arg[1])); 374 } 375 call.setEndToken(tokenAt(parenEnd)); 376 } else { 377 call.setEndToken(tokenAt(Math.max(start, p))); 378 } 379 return call; 380 } 381 382 // ---------- Identifier ref / literal detection ---------- 383 384 private TPowerQueryIdentifierRef tryMatchIdentifierRef(int start, int endExclusive) { 385 if (endExclusive - start != 1) return null; 386 TSourceToken tok = tokenAt(start); 387 if (tok == null) return null; 388 if (tok.tokentype != ETokenType.ttidentifier 389 && tok.tokencode != PowerQueryTokenCodes.QIDENT) return null; 390 TPowerQueryIdentifierRef ref = new TPowerQueryIdentifierRef(); 391 ref.setName(identifierText(tok)); 392 ref.setStartToken(tok); 393 ref.setEndToken(tok); 394 return ref; 395 } 396 397 private TPowerQueryLiteral tryMatchLiteral(int start, int endExclusive) { 398 if (endExclusive - start != 1) return null; 399 TSourceToken tok = tokenAt(start); 400 if (tok == null) return null; 401 TPowerQueryLiteral lit = new TPowerQueryLiteral(); 402 lit.setRawText(tok.toString()); 403 lit.setStartToken(tok); 404 lit.setEndToken(tok); 405 if (tok.tokencode == gudusoft.gsqlparser.TBaseType.sconst) { 406 lit.setKind(TPowerQueryLiteral.Kind.STRING); 407 lit.setDecodedValue(PowerQueryEscapeDecoder.decode( 408 stripSurroundingQuotes(tok.toString())).getDecoded()); 409 return lit; 410 } 411 if (tok.tokencode == gudusoft.gsqlparser.TBaseType.iconst) { 412 lit.setKind(TPowerQueryLiteral.Kind.INTEGER); 413 lit.setDecodedValue(tok.toString()); 414 return lit; 415 } 416 if (tok.tokencode == gudusoft.gsqlparser.TBaseType.fconst) { 417 lit.setKind(TPowerQueryLiteral.Kind.FLOAT); 418 lit.setDecodedValue(tok.toString()); 419 return lit; 420 } 421 if (tok.tokencode == PowerQueryTokenCodes.RW_TRUE 422 || tok.tokencode == PowerQueryTokenCodes.RW_FALSE) { 423 lit.setKind(TPowerQueryLiteral.Kind.BOOLEAN); 424 lit.setDecodedValue(tok.toString()); 425 return lit; 426 } 427 if (tok.tokencode == PowerQueryTokenCodes.RW_NULL) { 428 lit.setKind(TPowerQueryLiteral.Kind.NULL); 429 lit.setDecodedValue("null"); 430 return lit; 431 } 432 return null; 433 } 434 435 // ---------- helpers ---------- 436 437 /** 438 * Advance {@link #pos} past a balanced expression. Returns the 439 * exclusive end index. Terminators are: top-level comma, top-level 440 * {@code in} keyword, and end-of-tokens. 441 */ 442 private int skipExpression() { 443 int start = pos; 444 int depthParen = 0, depthBracket = 0, depthBrace = 0; 445 while (pos < tokens.size()) { 446 TSourceToken t = tokens.get(pos); 447 if (t == null) { pos++; continue; } 448 if (isTrivia(t)) { pos++; continue; } 449 450 if (depthParen == 0 && depthBracket == 0 && depthBrace == 0) { 451 if (t.tokencode == ',') break; 452 if (t.tokencode == PowerQueryTokenCodes.RW_IN) break; 453 if (t.tokencode == ';') break; 454 } 455 if (t.tokencode == '(') depthParen++; 456 else if (t.tokencode == ')') depthParen--; 457 else if (t.tokencode == '[') depthBracket++; 458 else if (t.tokencode == ']') depthBracket--; 459 else if (t.tokencode == '{') depthBrace++; 460 else if (t.tokencode == '}') depthBrace--; 461 pos++; 462 } 463 return pos; 464 } 465 466 private void skipTrivia() { 467 while (pos < tokens.size() && isTrivia(tokens.get(pos))) { 468 pos++; 469 } 470 } 471 472 private boolean isTrivia(TSourceToken t) { 473 if (t == null) return true; 474 return t.tokentype == ETokenType.ttwhitespace 475 || t.tokentype == ETokenType.ttsimplecomment 476 || t.tokentype == ETokenType.ttbracketedcomment 477 || t.tokentype == ETokenType.ttreturn; 478 } 479 480 private TSourceToken peek() { 481 skipTrivia(); 482 return pos < tokens.size() ? tokens.get(pos) : null; 483 } 484 485 private boolean peekIs(int tokencode) { 486 TSourceToken t = peek(); 487 return t != null && t.tokencode == tokencode; 488 } 489 490 private boolean peekIsChar(char c) { 491 TSourceToken t = peek(); 492 return t != null && t.tokencode == c; 493 } 494 495 private boolean tokenIsChar(int p, char c) { 496 TSourceToken t = tokenAt(p); 497 return t != null && t.tokencode == c; 498 } 499 500 private TSourceToken tokenAt(int p) { 501 // skip trivia at p 502 int cursor = p; 503 while (cursor < tokens.size() && isTrivia(tokens.get(cursor))) cursor++; 504 return cursor < tokens.size() ? tokens.get(cursor) : null; 505 } 506 507 private int findMatchingParen(int openIndex) { 508 return findMatching(openIndex, '(', ')'); 509 } 510 511 private int findMatchingBracket(int openIndex) { 512 return findMatching(openIndex, '[', ']'); 513 } 514 515 private int findMatchingBrace(int openIndex) { 516 return findMatching(openIndex, '{', '}'); 517 } 518 519 private int findMatching(int openIndex, char open, char close) { 520 if (openIndex < 0 || openIndex >= tokens.size()) return -1; 521 int depth = 0; 522 for (int i = openIndex; i < tokens.size(); i++) { 523 TSourceToken t = tokens.get(i); 524 if (t == null) continue; 525 if (t.tokencode == open) depth++; 526 else if (t.tokencode == close) { 527 depth--; 528 if (depth == 0) return i; 529 } 530 } 531 return -1; 532 } 533 534 private List<int[]> splitArgsByTopLevelCommas(int start, int endExclusive) { 535 List<int[]> out = new ArrayList<>(); 536 int depthParen = 0, depthBracket = 0, depthBrace = 0; 537 int argStart = start; 538 for (int i = start; i < endExclusive; i++) { 539 TSourceToken t = tokens.get(i); 540 if (t == null) continue; 541 if (isTrivia(t)) continue; 542 if (t.tokencode == '(') depthParen++; 543 else if (t.tokencode == ')') depthParen--; 544 else if (t.tokencode == '[') depthBracket++; 545 else if (t.tokencode == ']') depthBracket--; 546 else if (t.tokencode == '{') depthBrace++; 547 else if (t.tokencode == '}') depthBrace--; 548 else if (t.tokencode == ',' 549 && depthParen == 0 && depthBracket == 0 && depthBrace == 0) { 550 out.add(new int[]{argStart, i}); 551 argStart = i + 1; 552 } 553 } 554 if (argStart < endExclusive) { 555 out.add(new int[]{argStart, endExclusive}); 556 } 557 return out; 558 } 559 560 private String extractSourceStepName(int start, int endExclusive) { 561 for (int i = start; i < endExclusive; i++) { 562 TSourceToken t = tokens.get(i); 563 if (t == null || isTrivia(t)) continue; 564 if (t.tokentype == ETokenType.ttidentifier 565 || t.tokencode == PowerQueryTokenCodes.QIDENT) { 566 return identifierText(t); 567 } 568 return null; 569 } 570 return null; 571 } 572 573 private TSourceToken firstStringLiteral(int start, int endExclusive) { 574 for (int i = start; i < endExclusive; i++) { 575 TSourceToken t = tokens.get(i); 576 if (t == null || isTrivia(t)) continue; 577 if (t.tokencode == gudusoft.gsqlparser.TBaseType.sconst) { 578 return t; 579 } 580 return null; 581 } 582 return null; 583 } 584 585 private String tokenSlice(int start, int endExclusive) { 586 StringBuilder sb = new StringBuilder(); 587 for (int i = start; i < endExclusive && i < tokens.size(); i++) { 588 TSourceToken t = tokens.get(i); 589 if (t == null) continue; 590 if (isTrivia(t)) continue; 591 if (sb.length() > 0) sb.append(' '); 592 sb.append(t.toString()); 593 } 594 return sb.toString(); 595 } 596 597 private String identifierText(TSourceToken t) { 598 String raw = t.toString(); 599 if (t.tokencode == PowerQueryTokenCodes.QIDENT 600 && raw.startsWith("#\"") && raw.endsWith("\"")) { 601 String inner = raw.substring(2, raw.length() - 1); 602 return PowerQueryEscapeDecoder.decode(inner).getDecoded(); 603 } 604 return raw; 605 } 606 607 private String stripSurroundingQuotes(String raw) { 608 if (raw == null || raw.length() < 2) return raw; 609 if (raw.charAt(0) == '"' && raw.charAt(raw.length() - 1) == '"') { 610 return raw.substring(1, raw.length() - 1); 611 } 612 return raw; 613 } 614 615 /** 616 * Scan a token range for a connector function call (e.g. 617 * {@code Snowflake.Databases}) and return the vendor if found. 618 * Used for inline connector calls inside {@code Value.NativeQuery()} 619 * source arguments. 620 */ 621 private EDbVendor detectInlineConnectorVendor(int start, int endExclusive) { 622 for (int p = start; p < endExclusive; p++) { 623 TSourceToken t = tokenAt(p); 624 if (t == null || isTrivia(t)) continue; 625 if (t.tokentype != ETokenType.ttidentifier) continue; 626 627 StringBuilder qualified = new StringBuilder(); 628 qualified.append(t.toString()); 629 int q = p + 1; 630 while (q < endExclusive && tokenIsChar(q, '.')) { 631 q++; 632 TSourceToken nxt = tokenAt(q); 633 if (nxt == null || nxt.tokentype != ETokenType.ttidentifier) break; 634 qualified.append('.').append(nxt.toString()); 635 q++; 636 } 637 if (tokenIsChar(q, '(')) { 638 EDbVendor v = ConnectorCatalog.vendorFor(qualified.toString()); 639 if (v != null) return v; 640 } 641 } 642 return null; 643 } 644 645 /** 646 * Check if a name looks like a real step name (not a connector function 647 * prefix like "Snowflake"). 648 */ 649 private boolean looksLikeStepName(String name) { 650 if (name == null) return false; 651 // Connector function prefixes are in ConnectorCatalog; a step name 652 // that matches the first segment of a connector is likely not a 653 // real step reference. 654 return ConnectorCatalog.vendorFor(name + ".Databases") == null 655 && ConnectorCatalog.vendorFor(name + ".Database") == null; 656 } 657}