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}