Line data Source code
1 : import * as core from '@ankoh/dashql-core';
2 :
3 1 : const TokenType = core.buffers.parser.ScannerTokenType;
4 1 : const DiagnosticSource = core.buffers.editor.EditorDiagnosticSource;
5 1 : const DiagnosticSeverity = core.buffers.editor.EditorDiagnosticSeverity;
6 :
7 : /// AI -> SQL: a viable prefix with this many settled tokens is SQL
8 1 : const ENTER_MIN_TOKENS = 3;
9 : /// AI -> SQL: a shorter viable prefix is SQL if it has a token that is not an identifier after the leading keyword
10 1 : const ENTER_MIN_TOKENS_WITH_SQL_SYNTAX = 2;
11 : /// SQL -> AI: the invalid tail needs at least this many tokens
12 1 : const EXIT_MIN_TAIL = 2;
13 : /// SQL -> AI: the invalid tail needs at least this share of the valid prefix, long SQL holds on longer
14 1 : const EXIT_TAIL_RATIO = 0.5;
15 : /// SQL -> AI: the invalid tail reads as natural language below this share of SQL-shaped tokens
16 1 : const NL_TAIL_MAX_SQL_SHARE = 0.35;
17 :
18 : /// The input mode of the composer
19 1 : export enum ComposerInputMode {
20 : /// The draft is a SQL script
21 1 : Sql,
22 : /// The draft is a question in natural language
23 1 : Ai,
24 1 : }
25 :
26 : /// How much a draft looks like SQL
27 : export interface SqlEvidence {
28 : /// Is the first settled token a keyword?
29 : leadingKeyword: boolean;
30 : /// The number of settled tokens before the first scanner or parser error
31 : validTokens: number;
32 : /// The number of valid tokens after the leading one that are not identifiers
33 : validSqlTokens: number;
34 : /// The number of settled tokens from the first scanner or parser error on
35 : tailTokens: number;
36 : /// The summed SQL weight of the tail tokens, see tokenSqlWeight
37 : tailSqlWeight: number;
38 : /// Is the text free of any scanner or parser error, including an error at the end of the input?
39 : complete: boolean;
40 : }
41 :
42 : /// How strongly a token suggests SQL rather than natural language.
43 : /// English is mostly identifiers with a few keywords (by, for, all), SQL brings operators, literals and punctuation.
44 1 : function tokenSqlWeight(tokenType: core.buffers.parser.ScannerTokenType): number {
45 1 : switch (tokenType) {
46 1 : case TokenType.IDENTIFIER:
47 1 : return 0;
48 1 : case TokenType.KEYWORD:
49 1 : return 0.5;
50 1 : default:
51 1 : return 1;
52 1 : }
53 1 : }
54 :
55 : /// Can the token still grow while the cursor sits at its end?
56 1 : function isGrowingToken(tokenType: core.buffers.parser.ScannerTokenType): boolean {
57 1 : switch (tokenType) {
58 1 : case TokenType.IDENTIFIER:
59 1 : case TokenType.KEYWORD:
60 1 : case TokenType.LITERAL_INTEGER:
61 1 : case TokenType.LITERAL_FLOAT:
62 1 : return true;
63 1 : default:
64 1 : return false;
65 1 : }
66 1 : }
67 :
68 : /// Collect the evidence of an editor update.
69 : ///
70 : /// The token the user is still typing (cursor at its end) is not settled and is ignored, partial words must not switch the mode.
71 : /// Pass a null cursor to settle every token, e.g. when the draft is sent.
72 1 : export function assessSql(update: core.buffers.editor.EditorUpdateT, cursor: number | null): SqlEvidence {
73 : // The first scanner or parser error, diagnostics are sorted by offset
74 1 : let errorOffset: number | null = null;
75 1 : const scannerErrors: [number, number][] = [];
76 1 : for (const diagnostic of update.diagnostics) {
77 1 : if (diagnostic.severity !== DiagnosticSeverity.ERROR || diagnostic.source === DiagnosticSource.ANALYZER) {
78 0 : continue;
79 : }
80 1 : const offset = Number(diagnostic.textSpan?.offset ?? 0);
81 1 : const length = Number(diagnostic.textSpan?.length ?? 0);
82 1 : if (errorOffset == null) {
83 1 : errorOffset = offset;
84 : }
85 1 : if (diagnostic.source === DiagnosticSource.SCANNER) {
86 0 : scannerErrors.push([offset, offset + length]);
87 : }
88 1 : }
89 :
90 : // The settled tokens
91 1 : const tokens: { type: core.buffers.parser.ScannerTokenType, begin: number, end: number }[] = [];
92 1 : for (const span of update.syntaxSpans) {
93 1 : if (span.tokenType === TokenType.COMMENT) {
94 1 : continue;
95 : }
96 1 : const begin = Number(span.textSpan?.offset ?? 0);
97 1 : tokens.push({ type: span.tokenType, begin, end: begin + Number(span.textSpan?.length ?? 0) });
98 1 : }
99 1 : const last = tokens[tokens.length - 1];
100 1 : if (cursor != null && last != null && last.end === cursor && isGrowingToken(last.type)) {
101 1 : tokens.pop();
102 : }
103 :
104 1 : const evidence: SqlEvidence = {
105 1 : leadingKeyword: tokens.length > 0 && tokens[0].type === TokenType.KEYWORD,
106 1 : validTokens: 0,
107 1 : validSqlTokens: 0,
108 1 : tailTokens: 0,
109 1 : tailSqlWeight: 0,
110 1 : complete: errorOffset == null,
111 1 : };
112 1 : for (let i = 0; i < tokens.length; ++i) {
113 1 : const token = tokens[i];
114 1 : if (errorOffset == null || token.begin < errorOffset) {
115 1 : evidence.validTokens += 1;
116 1 : if (i > 0 && token.type !== TokenType.IDENTIFIER) {
117 1 : evidence.validSqlTokens += 1;
118 : }
119 1 : } else {
120 1 : evidence.tailTokens += 1;
121 : // Text swallowed by a scanner error (e.g. an unterminated string after an apostrophe) is no evidence of SQL
122 1 : const garbage = scannerErrors.some(([begin, end]) => token.begin < end && begin < token.end);
123 1 : evidence.tailSqlWeight += garbage ? 0 : tokenSqlWeight(token.type);
124 1 : }
125 1 : }
126 1 : return evidence;
127 1 : }
128 :
129 : /// Decide the next input mode.
130 : ///
131 : /// The switch is asymmetric to avoid flicker: entering SQL needs a viable prefix with some substance, leaving SQL
132 : /// needs an invalid tail that reads as natural language and that grows with the length of the valid SQL.
133 : /// A final decision (when the draft is sent) also accepts any complete statement that starts with a keyword.
134 1 : export function decideMode(current: ComposerInputMode, evidence: SqlEvidence, final: boolean = false): ComposerInputMode {
135 1 : if (current === ComposerInputMode.Ai) {
136 1 : if (!evidence.leadingKeyword || evidence.tailTokens > 0) {
137 1 : return current;
138 : }
139 1 : if (final && evidence.complete) {
140 1 : return ComposerInputMode.Sql;
141 : }
142 1 : const substantial = evidence.validTokens >= ENTER_MIN_TOKENS
143 1 : || (evidence.validTokens >= ENTER_MIN_TOKENS_WITH_SQL_SYNTAX && evidence.validSqlTokens > 0);
144 1 : return substantial ? ComposerInputMode.Sql : current;
145 1 : } else {
146 1 : const minTail = Math.max(EXIT_MIN_TAIL, Math.ceil(evidence.validTokens * EXIT_TAIL_RATIO));
147 1 : if (evidence.tailTokens < minTail) {
148 1 : return current;
149 : }
150 1 : const naturalTail = evidence.tailSqlWeight / evidence.tailTokens < NL_TAIL_MAX_SQL_SHARE;
151 1 : return naturalTail ? ComposerInputMode.Ai : current;
152 1 : }
153 1 : }
|