@deckbuilderkit/editor 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +53 -4
- package/src/ArchiveView.tsx +111 -0
- package/src/CodeEditor.tsx +265 -0
- package/src/CodeView.tsx +67 -0
- package/src/DiffView.tsx +139 -0
- package/src/EnvView.tsx +42 -0
- package/src/FilePreview.tsx +108 -0
- package/src/FontView.tsx +48 -0
- package/src/HarView.tsx +70 -0
- package/src/HexView.tsx +28 -0
- package/src/HtmlPreview.tsx +23 -0
- package/src/MarkdownEditor.tsx +90 -0
- package/src/MarkdownToc.tsx +135 -0
- package/src/MarkdownView.tsx +84 -0
- package/src/MediaView.tsx +17 -0
- package/src/NotebookView.tsx +74 -0
- package/src/PdfView.tsx +289 -0
- package/src/RequestEditor.tsx +419 -0
- package/src/ResponseView.tsx +89 -0
- package/src/SqlGraph.tsx +322 -0
- package/src/SqlSteps.tsx +487 -0
- package/src/SqliteView.tsx +68 -0
- package/src/SvgView.tsx +231 -0
- package/src/TableView.tsx +85 -0
- package/src/VarSuggest.tsx +125 -0
- package/src/curl-file.ts +70 -0
- package/src/editorOptions.ts +83 -0
- package/src/editorOptionsContext.ts +4 -0
- package/src/fileKinds.ts +69 -0
- package/src/http-client.ts +9 -0
- package/src/http-import.ts +594 -0
- package/src/http.ts +251 -0
- package/src/import-warning.ts +8 -0
- package/src/index.ts +56 -0
- package/src/languages.ts +107 -0
- package/src/markdown.ts +123 -0
- package/src/markdownLive.ts +289 -0
- package/src/options.ts +3 -0
- package/src/pdf-url.d.ts +4 -0
- package/src/sql/format.ts +637 -0
- package/src/sql/formatOptions.ts +71 -0
- package/src/sql/lineage.ts +365 -0
- package/src/sql/lint.ts +951 -0
- package/src/sql/markerHighlight.ts +50 -0
- package/src/sql/markers.ts +138 -0
- package/src/sql/outline.ts +662 -0
- package/src/sql/statements.ts +83 -0
- package/src/sql/tokens.ts +156 -0
- package/src/sql/xmlQuery.ts +249 -0
- package/src/sqlProblems.ts +40 -0
- package/src/table.ts +89 -0
- package/src/text-save.ts +28 -0
- package/src/theme.ts +86 -0
- package/src/toc.ts +11 -0
- package/src/variables.ts +123 -0
- package/README.md +0 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { tokenize, type Token } from './tokens.ts';
|
|
2
|
+
|
|
3
|
+
export interface Statement {
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const TRIVIA = new Set(['whitespace', 'newline', 'lineComment', 'blockComment']);
|
|
10
|
+
const CREATE_SKIP = new Set(['or', 'replace', 'editionable', 'noneditionable']);
|
|
11
|
+
const CREATE_PLSQL = new Set(['procedure', 'function', 'package', 'trigger']);
|
|
12
|
+
|
|
13
|
+
function words(tokens: Token[], start: number, max: number): string[] {
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
for (let i = start; i < tokens.length && out.length < max; i++) {
|
|
16
|
+
const t = tokens[i]!;
|
|
17
|
+
if (TRIVIA.has(t.kind)) continue;
|
|
18
|
+
if (t.kind !== 'keyword' && t.kind !== 'identifier') break;
|
|
19
|
+
out.push(t.text.toLowerCase());
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isPlsqlStart(tokens: Token[], start: number): boolean {
|
|
25
|
+
const w = words(tokens, start, 8);
|
|
26
|
+
if (w[0] === 'begin' || w[0] === 'declare') return true;
|
|
27
|
+
if (w[0] !== 'create') return false;
|
|
28
|
+
let i = 1;
|
|
29
|
+
while (i < w.length && CREATE_SKIP.has(w[i]!)) i++;
|
|
30
|
+
const kind = w[i];
|
|
31
|
+
if (kind === undefined) return false;
|
|
32
|
+
if (CREATE_PLSQL.has(kind)) return true;
|
|
33
|
+
return kind === 'type' && w[i + 1] === 'body';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function splitStatements(text: string): Statement[] {
|
|
37
|
+
const tokens = tokenize(text);
|
|
38
|
+
const out: Statement[] = [];
|
|
39
|
+
let start = 0;
|
|
40
|
+
let started = false;
|
|
41
|
+
let plsql = false;
|
|
42
|
+
|
|
43
|
+
const push = (to: number): void => {
|
|
44
|
+
if (started) {
|
|
45
|
+
const raw = text.slice(start, to);
|
|
46
|
+
const lead = raw.length - raw.trimStart().length;
|
|
47
|
+
const trimmed = raw.trim();
|
|
48
|
+
if (trimmed) out.push({ from: start + lead, to: start + lead + trimmed.length, text: trimmed });
|
|
49
|
+
}
|
|
50
|
+
started = false;
|
|
51
|
+
plsql = false;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
55
|
+
const t = tokens[i]!;
|
|
56
|
+
if (t.kind === 'slash') {
|
|
57
|
+
push(t.from);
|
|
58
|
+
start = t.to;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (TRIVIA.has(t.kind)) continue;
|
|
62
|
+
if (!started) {
|
|
63
|
+
started = true;
|
|
64
|
+
plsql = isPlsqlStart(tokens, i);
|
|
65
|
+
}
|
|
66
|
+
if (t.kind === 'punctuation' && t.text === ';' && !plsql) {
|
|
67
|
+
push(t.from);
|
|
68
|
+
start = t.to;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
push(text.length);
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function statementAt(text: string, offset: number): Statement | undefined {
|
|
76
|
+
const statements = splitStatements(text);
|
|
77
|
+
let before: Statement | undefined;
|
|
78
|
+
for (const s of statements) {
|
|
79
|
+
if (offset >= s.from && offset <= s.to) return s;
|
|
80
|
+
if (s.to < offset) before = s;
|
|
81
|
+
}
|
|
82
|
+
return before ?? statements[0];
|
|
83
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { PLSQL } from '@codemirror/lang-sql';
|
|
2
|
+
|
|
3
|
+
export type TokenKind =
|
|
4
|
+
| 'keyword'
|
|
5
|
+
| 'identifier'
|
|
6
|
+
| 'quotedIdentifier'
|
|
7
|
+
| 'string'
|
|
8
|
+
| 'qquote'
|
|
9
|
+
| 'number'
|
|
10
|
+
| 'operator'
|
|
11
|
+
| 'punctuation'
|
|
12
|
+
| 'bind'
|
|
13
|
+
| 'lineComment'
|
|
14
|
+
| 'blockComment'
|
|
15
|
+
| 'whitespace'
|
|
16
|
+
| 'newline'
|
|
17
|
+
| 'slash';
|
|
18
|
+
|
|
19
|
+
export interface Token {
|
|
20
|
+
kind: TokenKind;
|
|
21
|
+
from: number;
|
|
22
|
+
to: number;
|
|
23
|
+
text: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const SQL_KEYWORDS: ReadonlySet<string> = new Set(
|
|
27
|
+
(PLSQL.spec.keywords ?? '').split(/\s+/).filter(Boolean).map((k) => k.toLowerCase()),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const CLOSERS: Record<string, string> = { '[': ']', '{': '}', '(': ')', '<': '>' };
|
|
31
|
+
const OPERATORS = ['||', '<=', '>=', '<>', '!=', '^=', '~=', ':=', '=>', '..', '**', '<<', '>>'];
|
|
32
|
+
const SINGLE_OPERATORS = '+-*/=<>!^~%|:';
|
|
33
|
+
const PUNCTUATION = '(),;.[]{}@';
|
|
34
|
+
|
|
35
|
+
const isIdentStart = (c: string): boolean => /[\p{L}_]/u.test(c);
|
|
36
|
+
const isIdentPart = (c: string): boolean => /[\p{L}\p{N}_$#]/u.test(c);
|
|
37
|
+
const isDigit = (c: string | undefined): boolean => c !== undefined && c >= '0' && c <= '9';
|
|
38
|
+
const isBlank = (c: string): boolean => c === ' ' || c === '\t' || c === '\f' || c === '\v';
|
|
39
|
+
|
|
40
|
+
function quoted(text: string, start: number, quote: string): number {
|
|
41
|
+
let i = start + 1;
|
|
42
|
+
while (i < text.length) {
|
|
43
|
+
if (text[i] === quote) {
|
|
44
|
+
if (text[i + 1] === quote) {
|
|
45
|
+
i += 2;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return i + 1;
|
|
49
|
+
}
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
return text.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function qQuoteEnd(text: string, quoteAt: number): number {
|
|
56
|
+
const open = text[quoteAt + 1]!;
|
|
57
|
+
const close = CLOSERS[open] ?? open;
|
|
58
|
+
const end = text.indexOf(close + "'", quoteAt + 2);
|
|
59
|
+
return end < 0 ? text.length : end + 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isOwnLine(text: string, i: number): boolean {
|
|
63
|
+
for (let j = i - 1; j >= 0 && text[j] !== '\n'; j--) if (!isBlank(text[j]!) && text[j] !== '\r') return false;
|
|
64
|
+
for (let j = i + 1; j < text.length && text[j] !== '\n'; j++) if (!isBlank(text[j]!) && text[j] !== '\r') return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function numberEnd(text: string, start: number): number {
|
|
69
|
+
let i = start;
|
|
70
|
+
while (isDigit(text[i])) i++;
|
|
71
|
+
if (text[i] === '.' && text[i + 1] !== '.') {
|
|
72
|
+
i++;
|
|
73
|
+
while (isDigit(text[i])) i++;
|
|
74
|
+
}
|
|
75
|
+
if (text[i] === 'e' || text[i] === 'E') {
|
|
76
|
+
let j = i + 1;
|
|
77
|
+
if (text[j] === '+' || text[j] === '-') j++;
|
|
78
|
+
if (isDigit(text[j])) {
|
|
79
|
+
while (isDigit(text[j])) j++;
|
|
80
|
+
i = j;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return i;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function tokenize(text: string): Token[] {
|
|
87
|
+
const tokens: Token[] = [];
|
|
88
|
+
const n = text.length;
|
|
89
|
+
let i = 0;
|
|
90
|
+
while (i < n) {
|
|
91
|
+
const c = text[i]!;
|
|
92
|
+
const next = text[i + 1];
|
|
93
|
+
let kind: TokenKind;
|
|
94
|
+
let end: number;
|
|
95
|
+
if (c === '\n' || (c === '\r' && next === '\n')) {
|
|
96
|
+
kind = 'newline';
|
|
97
|
+
end = c === '\r' ? i + 2 : i + 1;
|
|
98
|
+
} else if (isBlank(c) || c === '\r') {
|
|
99
|
+
kind = 'whitespace';
|
|
100
|
+
end = i + 1;
|
|
101
|
+
while (end < n && (isBlank(text[end]!) || (text[end] === '\r' && text[end + 1] !== '\n'))) end++;
|
|
102
|
+
} else if (c === '-' && next === '-') {
|
|
103
|
+
kind = 'lineComment';
|
|
104
|
+
end = i + 2;
|
|
105
|
+
while (end < n && text[end] !== '\n' && !(text[end] === '\r' && text[end + 1] === '\n')) end++;
|
|
106
|
+
} else if (c === '/' && next === '*') {
|
|
107
|
+
kind = 'blockComment';
|
|
108
|
+
const close = text.indexOf('*/', i + 2);
|
|
109
|
+
end = close < 0 ? n : close + 2;
|
|
110
|
+
} else if (c === '/' && isOwnLine(text, i)) {
|
|
111
|
+
kind = 'slash';
|
|
112
|
+
end = i + 1;
|
|
113
|
+
} else if (c === "'") {
|
|
114
|
+
kind = 'string';
|
|
115
|
+
end = quoted(text, i, "'");
|
|
116
|
+
} else if (c === '"') {
|
|
117
|
+
kind = 'quotedIdentifier';
|
|
118
|
+
end = quoted(text, i, '"');
|
|
119
|
+
} else if (isDigit(c) || (c === '.' && isDigit(next))) {
|
|
120
|
+
kind = 'number';
|
|
121
|
+
end = numberEnd(text, i);
|
|
122
|
+
} else if (isIdentStart(c)) {
|
|
123
|
+
end = i + 1;
|
|
124
|
+
while (end < n && isIdentPart(text[end]!)) end++;
|
|
125
|
+
const lower = text.slice(i, end).toLowerCase();
|
|
126
|
+
if ((lower === 'q' || lower === 'nq') && text[end] === "'" && end + 1 < n && !/\s/.test(text[end + 1]!)) {
|
|
127
|
+
kind = 'qquote';
|
|
128
|
+
end = qQuoteEnd(text, end);
|
|
129
|
+
} else if (lower === 'n' && text[end] === "'") {
|
|
130
|
+
kind = 'string';
|
|
131
|
+
end = quoted(text, end, "'");
|
|
132
|
+
} else {
|
|
133
|
+
kind = SQL_KEYWORDS.has(lower) ? 'keyword' : 'identifier';
|
|
134
|
+
}
|
|
135
|
+
} else if (c === ':' && next !== undefined && isIdentPart(next)) {
|
|
136
|
+
kind = 'bind';
|
|
137
|
+
end = i + 2;
|
|
138
|
+
while (end < n && isIdentPart(text[end]!)) end++;
|
|
139
|
+
} else if (OPERATORS.includes(c + (next ?? ''))) {
|
|
140
|
+
kind = 'operator';
|
|
141
|
+
end = i + 2;
|
|
142
|
+
} else if (SINGLE_OPERATORS.includes(c)) {
|
|
143
|
+
kind = 'operator';
|
|
144
|
+
end = i + 1;
|
|
145
|
+
} else if (PUNCTUATION.includes(c)) {
|
|
146
|
+
kind = 'punctuation';
|
|
147
|
+
end = i + 1;
|
|
148
|
+
} else {
|
|
149
|
+
kind = 'operator';
|
|
150
|
+
end = i + 1;
|
|
151
|
+
}
|
|
152
|
+
tokens.push({ kind, from: i, to: end, text: text.slice(i, end) });
|
|
153
|
+
i = end;
|
|
154
|
+
}
|
|
155
|
+
return tokens;
|
|
156
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/** SQL inside Miles query files: `<queries><query name=".."><statement><![CDATA[ SQL ]]></statement></query></queries>`. */
|
|
2
|
+
|
|
3
|
+
export interface XmlQueryStatement {
|
|
4
|
+
/** Position among the file's statements. */
|
|
5
|
+
index: number;
|
|
6
|
+
/** `name` attribute of the enclosing `<query>` ('' when none). */
|
|
7
|
+
name: string;
|
|
8
|
+
/** `set-name` attribute of the enclosing `<query>` ('' when none). */
|
|
9
|
+
setName: string;
|
|
10
|
+
/** Display label: name, set-name or `#n`. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** The SQL to edit (prefix text + CDATA content, or unescaped text). */
|
|
13
|
+
sql: string;
|
|
14
|
+
/** Range of the file text that `sql` stands for; `spliceXmlStatement` replaces exactly this. */
|
|
15
|
+
from: number;
|
|
16
|
+
to: number;
|
|
17
|
+
/** True when the statement holds a CDATA section. */
|
|
18
|
+
cdata: boolean;
|
|
19
|
+
/** Non-blank text before the first CDATA section (already part of `sql`); '' otherwise. */
|
|
20
|
+
prefix: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface XmlQueries {
|
|
24
|
+
statements: XmlQueryStatement[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ESCAPES: [RegExp, string][] = [[/</g, '<'], [/>/g, '>'], [/"/g, '"'], [/'/g, "'"], [/&/g, '&']];
|
|
28
|
+
|
|
29
|
+
export function unescapeXml(s: string): string {
|
|
30
|
+
let out = s;
|
|
31
|
+
for (const [re, ch] of ESCAPES) out = out.replace(re, ch);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function escapeXml(s: string): string {
|
|
36
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const blank = (m: string) => m.replace(/[^\r\n]/g, ' ');
|
|
40
|
+
|
|
41
|
+
/** Same length as `text`, with comments and CDATA bodies blanked so tag searches cannot hit them. */
|
|
42
|
+
function mask(text: string): string {
|
|
43
|
+
return text.replace(/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>/g, (m) => (m.startsWith('<!--') ? blank(m) : `<![CDATA[${blank(m.slice(9, -3))}]]>`));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function attr(tag: string, key: string): string {
|
|
47
|
+
const m = new RegExp(`\\s${key}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(tag);
|
|
48
|
+
return m ? unescapeXml(m[1] ?? m[2] ?? '') : '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The statements of a `<queries>` file, or null when the root is not `<queries>` or no `<statement>` exists. */
|
|
52
|
+
export function parseXmlQueries(text: string): XmlQueries | null {
|
|
53
|
+
const masked = mask(text);
|
|
54
|
+
const root = /^?\s*(?:<\?[\s\S]*?\?>\s*|<!DOCTYPE[^>]*>\s*)*<([A-Za-z_][\w.:-]*)/.exec(masked);
|
|
55
|
+
if (!root || root[1] !== 'queries') return null;
|
|
56
|
+
const statements: XmlQueryStatement[] = [];
|
|
57
|
+
const re = /<statement\b[^>]*?(\/)?>(?:([\s\S]*?)<\/statement\s*>)?/g;
|
|
58
|
+
for (let m = re.exec(masked); m; m = re.exec(masked)) {
|
|
59
|
+
if (m[1] || m[2] === undefined) continue;
|
|
60
|
+
const innerFrom = m.index + m[0].indexOf('>') + 1;
|
|
61
|
+
const inner = text.slice(innerFrom, innerFrom + m[2].length);
|
|
62
|
+
const before = masked.slice(0, m.index);
|
|
63
|
+
const q = before.lastIndexOf('<query');
|
|
64
|
+
const closed = before.lastIndexOf('</query');
|
|
65
|
+
let name = '';
|
|
66
|
+
let setName = '';
|
|
67
|
+
if (q >= 0 && closed < q && /^<query[\s>]/.test(before.slice(q))) {
|
|
68
|
+
const tag = before.slice(q, before.indexOf('>', q) + 1);
|
|
69
|
+
name = attr(tag, 'name');
|
|
70
|
+
setName = attr(tag, 'set-name');
|
|
71
|
+
}
|
|
72
|
+
const index = statements.length;
|
|
73
|
+
const label = name || setName || `#${index + 1}`;
|
|
74
|
+
const first = inner.indexOf('<![CDATA[');
|
|
75
|
+
if (first < 0) {
|
|
76
|
+
statements.push({ index, name, setName, label, sql: unescapeXml(inner), from: innerFrom, to: innerFrom + inner.length, cdata: false, prefix: '' });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const lastEnd = inner.lastIndexOf(']]>') + 3;
|
|
80
|
+
const lead = inner.slice(0, first);
|
|
81
|
+
const keepLead = lead.trim() === '';
|
|
82
|
+
const start = keepLead ? first : 0;
|
|
83
|
+
let sql = keepLead ? '' : lead;
|
|
84
|
+
const cd = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
|
|
85
|
+
let pos = first;
|
|
86
|
+
for (let c = cd.exec(inner); c; c = cd.exec(inner)) {
|
|
87
|
+
sql += inner.slice(pos, c.index) + c[1];
|
|
88
|
+
pos = c.index + c[0].length;
|
|
89
|
+
}
|
|
90
|
+
sql += inner.slice(pos, lastEnd);
|
|
91
|
+
statements.push({ index, name, setName, label, sql, from: innerFrom + start, to: innerFrom + lastEnd, cdata: true, prefix: keepLead ? '' : lead });
|
|
92
|
+
}
|
|
93
|
+
return statements.length > 0 ? { statements } : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const cdataOf = (sql: string) => `<![CDATA[${sql.replace(/\]\]>/g, ']]]]><![CDATA[>')}]]>`;
|
|
97
|
+
|
|
98
|
+
/** Writes edited SQL back into one statement of `text`; every other byte stays. `sql` unchanged returns `text` itself. */
|
|
99
|
+
export function spliceXmlStatement(text: string, st: XmlQueryStatement, sql: string): string {
|
|
100
|
+
if (sql === st.sql) return text;
|
|
101
|
+
let body: string;
|
|
102
|
+
if (!st.cdata) body = escapeXml(sql);
|
|
103
|
+
else if (st.prefix && sql.startsWith(st.prefix)) body = st.prefix + cdataOf(sql.slice(st.prefix.length));
|
|
104
|
+
else body = cdataOf(sql);
|
|
105
|
+
return text.slice(0, st.from) + body + text.slice(st.to);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const SEP_LINE = /^-- ==== (.*) ====[ \t]*\r?$/;
|
|
109
|
+
const labelOf = (st: XmlQueryStatement) => st.label.replace(/\s+/g, ' ');
|
|
110
|
+
const sepOf = (st: XmlQueryStatement) => `-- ==== ${labelOf(st)} ====`;
|
|
111
|
+
|
|
112
|
+
/** Terminator the statement splitter needs after `sql` ('' when it already ends in `;` or is empty). */
|
|
113
|
+
function suffixFor(sql: string): string {
|
|
114
|
+
const body = sql.trimEnd();
|
|
115
|
+
if (!body || body.endsWith(';')) return '';
|
|
116
|
+
const last = body.slice(body.lastIndexOf('\n') + 1);
|
|
117
|
+
return last.includes('--') ? '\n;' : ';';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One editor value for all statements: a separator comment line per statement, blank line between. One statement: its SQL as is. */
|
|
121
|
+
export function combineXmlStatements(statements: XmlQueryStatement[]): string {
|
|
122
|
+
if (statements.length === 1) return statements[0]!.sql;
|
|
123
|
+
return statements
|
|
124
|
+
.map((st) => {
|
|
125
|
+
const body = st.sql.trimEnd();
|
|
126
|
+
return `${sepOf(st)}\n${body}${suffixFor(st.sql)}${st.sql.slice(body.length)}`;
|
|
127
|
+
})
|
|
128
|
+
.join('\n\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Maps an edited combined value back to one SQL per statement. Missing separators leave that statement empty and its text with the previous one. */
|
|
132
|
+
export function splitXmlStatements(statements: XmlQueryStatement[], combined: string): string[] {
|
|
133
|
+
if (statements.length === 1) return [combined];
|
|
134
|
+
const segs: string[] = statements.map(() => '');
|
|
135
|
+
const lines = combined.split('\n');
|
|
136
|
+
let next = 0;
|
|
137
|
+
let cur = -1;
|
|
138
|
+
let pre = '';
|
|
139
|
+
const add = (line: string, last: boolean) => {
|
|
140
|
+
const piece = last ? line : `${line}\n`;
|
|
141
|
+
if (cur < 0) pre += piece;
|
|
142
|
+
else segs[cur] += piece;
|
|
143
|
+
};
|
|
144
|
+
lines.forEach((line, i) => {
|
|
145
|
+
const m = SEP_LINE.exec(line);
|
|
146
|
+
const hit = m ? statements.findIndex((st, j) => j >= next && labelOf(st) === m[1]) : -1;
|
|
147
|
+
if (hit >= 0) {
|
|
148
|
+
cur = hit;
|
|
149
|
+
next = hit + 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
add(line, i === lines.length - 1);
|
|
153
|
+
});
|
|
154
|
+
segs[0] = pre + segs[0];
|
|
155
|
+
return statements.map((st, i) => {
|
|
156
|
+
let seg = segs[i]!;
|
|
157
|
+
if (i < statements.length - 1) seg = seg.endsWith('\n\n') ? seg.slice(0, -2) : seg.endsWith('\n') ? seg.slice(0, -1) : seg;
|
|
158
|
+
const body = seg.trimEnd();
|
|
159
|
+
const suffix = suffixFor(st.sql);
|
|
160
|
+
return (suffix && body.endsWith(suffix) ? body.slice(0, -suffix.length) : body) + seg.slice(body.length);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Writes an edited combined value back into `text`; unchanged statements keep their bytes, an unchanged value returns `text` itself. */
|
|
165
|
+
export function applyCombinedXml(text: string, statements: XmlQueryStatement[], combined: string): string {
|
|
166
|
+
const sqls = splitXmlStatements(statements, combined);
|
|
167
|
+
let out = text;
|
|
168
|
+
for (let i = statements.length - 1; i >= 0; i--) out = spliceXmlStatement(out, statements[i]!, sqls[i]!);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Blanks comments and string literals (same length) so structure scans ignore them. */
|
|
173
|
+
function blankSql(sql: string): string {
|
|
174
|
+
return sql.replace(/--[^\n]*|\/\*[\s\S]*?\*\/|'(?:[^']|'')*'/g, blank);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Names exposed by the outermost select list (aliases or plain column names, lower-cased). Unsure items are skipped. */
|
|
178
|
+
function selectNames(sql: string): Set<string> {
|
|
179
|
+
const s = blankSql(sql);
|
|
180
|
+
const names = new Set<string>();
|
|
181
|
+
const sel = /\bselect\b/i.exec(s);
|
|
182
|
+
if (!sel) return names;
|
|
183
|
+
let depth = 0;
|
|
184
|
+
let start = sel.index + sel[0].length;
|
|
185
|
+
const items: string[] = [];
|
|
186
|
+
let end = s.length;
|
|
187
|
+
for (let i = start; i < s.length; i++) {
|
|
188
|
+
const c = s[i]!;
|
|
189
|
+
if (c === '(') depth++;
|
|
190
|
+
else if (c === ')') {
|
|
191
|
+
if (depth === 0) {
|
|
192
|
+
end = i;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
depth--;
|
|
196
|
+
} else if (depth === 0 && c === ',') {
|
|
197
|
+
items.push(s.slice(start, i));
|
|
198
|
+
start = i + 1;
|
|
199
|
+
} else if (depth === 0 && /\s/.test(s[i - 1] ?? ' ') && /^from\b/i.test(s.slice(i))) {
|
|
200
|
+
end = i;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
items.push(s.slice(start, end));
|
|
205
|
+
for (const raw of items) {
|
|
206
|
+
const item = raw.replace(/^\s*(?:distinct|unique|all)\s+/i, '').trim();
|
|
207
|
+
if (!item || item.endsWith('*')) continue;
|
|
208
|
+
const q = /(?:\s|\)|^)"([^"]+)"$/.exec(item);
|
|
209
|
+
if (q) {
|
|
210
|
+
names.add(q[1]!.toLowerCase());
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const plain = /^(?:[A-Za-z_][\w$#]*\.)*([A-Za-z_][\w$#]*)$/.exec(item);
|
|
214
|
+
if (plain) {
|
|
215
|
+
names.add(plain[1]!.toLowerCase());
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const alias = /[\s)"'\w]\s+(?:as\s+)?([A-Za-z_][\w$#]*)$/i.exec(item);
|
|
219
|
+
if (alias && !/[+\-*/|<>=]\s*$/.test(item.slice(0, item.length - alias[1]!.length).trimEnd())) names.add(alias[1]!.toLowerCase());
|
|
220
|
+
}
|
|
221
|
+
return names;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const PLACEHOLDER = /\{([A-Za-z_]\w*)\}/g;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* One SQL from chained Miles queries: `{name}` in a later statement becomes a scalar subquery of the nearest earlier statement
|
|
228
|
+
* whose select list exposes `name`. Numeric `{0}` parameters and unresolved names stay. The result is the last statement, earlier ones inlined.
|
|
229
|
+
*/
|
|
230
|
+
export function mergeXmlStatements(statements: XmlQueryStatement[]): string {
|
|
231
|
+
const merged: string[] = [];
|
|
232
|
+
const names = statements.map((st) => selectNames(st.sql));
|
|
233
|
+
statements.forEach((st, i) => {
|
|
234
|
+
merged.push(
|
|
235
|
+
st.sql.replace(PLACEHOLDER, (all, name: string) => {
|
|
236
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
237
|
+
if (!names[j]!.has(name.toLowerCase())) continue;
|
|
238
|
+
const inner = merged[j]!.trimEnd().replace(/\s*;+$/, '');
|
|
239
|
+
const lastLine = inner.slice(inner.lastIndexOf('\n') + 1);
|
|
240
|
+
const close = lastLine.includes('--') ? '\n' : '';
|
|
241
|
+
return `(SELECT ${name} FROM (${inner}${close}) WHERE ROWNUM = 1)`;
|
|
242
|
+
}
|
|
243
|
+
return all;
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
const header = `-- merged from: ${statements.map(labelOf).join(', ')}`;
|
|
248
|
+
return `${header}\n${(merged[merged.length - 1] ?? '').trim()}`;
|
|
249
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { t } from '@deckbuilderkit/ui';
|
|
2
|
+
import type { SqlLintSeverity, SqlProblem } from './sql/lint';
|
|
3
|
+
|
|
4
|
+
/** Most severe first. */
|
|
5
|
+
export const SQL_SEVERITIES: readonly SqlLintSeverity[] = ['error', 'warning', 'info'];
|
|
6
|
+
|
|
7
|
+
/** The localized message of a lint problem (`sqlLint.<code>` with its params). */
|
|
8
|
+
export function sqlProblemMessage(p: SqlProblem): string {
|
|
9
|
+
return t(`sqlLint.${p.code}`, p.params);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The worst severity among the problems, or undefined when there are none. */
|
|
13
|
+
export function worstSeverity(problems: readonly SqlProblem[]): SqlLintSeverity | undefined {
|
|
14
|
+
let worst: SqlLintSeverity | undefined;
|
|
15
|
+
for (const p of problems) {
|
|
16
|
+
if (p.severity === 'error') return 'error';
|
|
17
|
+
if (p.severity === 'warning') worst = 'warning';
|
|
18
|
+
else if (!worst) worst = 'info';
|
|
19
|
+
}
|
|
20
|
+
return worst;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Offsets where each line starts, for offset → line lookups. */
|
|
24
|
+
export function lineStarts(text: string): number[] {
|
|
25
|
+
const starts = [0];
|
|
26
|
+
for (let i = text.indexOf('\n'); i >= 0; i = text.indexOf('\n', i + 1)) starts.push(i + 1);
|
|
27
|
+
return starts;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 1-based line of an offset. */
|
|
31
|
+
export function lineOf(starts: readonly number[], offset: number): number {
|
|
32
|
+
let lo = 0;
|
|
33
|
+
let hi = starts.length - 1;
|
|
34
|
+
while (lo < hi) {
|
|
35
|
+
const mid = (lo + hi + 1) >> 1;
|
|
36
|
+
if (starts[mid]! <= offset) lo = mid;
|
|
37
|
+
else hi = mid - 1;
|
|
38
|
+
}
|
|
39
|
+
return lo + 1;
|
|
40
|
+
}
|
package/src/table.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Splits delimited text (RFC 4180: quoted fields may hold the delimiter, quotes and line breaks). */
|
|
2
|
+
export function parseDelimited(text: string, delimiter: string): string[][] {
|
|
3
|
+
const rows: string[][] = [];
|
|
4
|
+
let row: string[] = [];
|
|
5
|
+
let field = '';
|
|
6
|
+
let quoted = false;
|
|
7
|
+
for (let i = 0; i < text.length; i++) {
|
|
8
|
+
const ch = text[i]!;
|
|
9
|
+
if (quoted) {
|
|
10
|
+
if (ch === '"') {
|
|
11
|
+
if (text[i + 1] === '"') {
|
|
12
|
+
field += '"';
|
|
13
|
+
i++;
|
|
14
|
+
} else quoted = false;
|
|
15
|
+
} else field += ch;
|
|
16
|
+
} else if (ch === '"' && field === '') quoted = true;
|
|
17
|
+
else if (ch === delimiter) {
|
|
18
|
+
row.push(field);
|
|
19
|
+
field = '';
|
|
20
|
+
} else if (ch === '\n' || ch === '\r') {
|
|
21
|
+
if (ch === '\r' && text[i + 1] === '\n') i++;
|
|
22
|
+
row.push(field);
|
|
23
|
+
rows.push(row);
|
|
24
|
+
row = [];
|
|
25
|
+
field = '';
|
|
26
|
+
} else field += ch;
|
|
27
|
+
}
|
|
28
|
+
if (field !== '' || row.length) {
|
|
29
|
+
row.push(field);
|
|
30
|
+
rows.push(row);
|
|
31
|
+
}
|
|
32
|
+
return rows;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The delimiter of a `.tsv` file is a tab; a `.csv` one is `;` when the first line has more of them than commas. */
|
|
36
|
+
export function delimiterOf(name: string, text: string): string {
|
|
37
|
+
if (/\.tsv$/i.test(name)) return '\t';
|
|
38
|
+
const first = text.slice(0, text.search(/\r|\n|$/));
|
|
39
|
+
const count = (ch: string) => first.split(ch).length - 1;
|
|
40
|
+
return count(';') > count(',') ? ';' : ',';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Orders cells as numbers when both are numeric, else as text. */
|
|
44
|
+
export function compareCells(a: string, b: string): number {
|
|
45
|
+
const x = Number(a);
|
|
46
|
+
const y = Number(b);
|
|
47
|
+
if (a.trim() !== '' && b.trim() !== '' && !Number.isNaN(x) && !Number.isNaN(y)) return x - y;
|
|
48
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const MASKED_KEY = /^\s*(?:export\s+)?([A-Za-z_][\w.-]*)\s*=\s*(.*)$/;
|
|
52
|
+
|
|
53
|
+
export interface EnvLine {
|
|
54
|
+
/** The variable name, or null for a comment or blank line. */
|
|
55
|
+
key: string | null;
|
|
56
|
+
value: string;
|
|
57
|
+
/** The text of a comment or blank line. */
|
|
58
|
+
raw: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Reads `KEY=value` lines of a `.env` file; quotes around a value are dropped. */
|
|
62
|
+
export function parseEnv(text: string): EnvLine[] {
|
|
63
|
+
return text.split(/\r?\n/).map((raw) => {
|
|
64
|
+
const m = MASKED_KEY.exec(raw);
|
|
65
|
+
if (!m || raw.trimStart().startsWith('#')) return { key: null, value: '', raw };
|
|
66
|
+
let value = m[2]!.trim();
|
|
67
|
+
if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.endsWith(value[0]!)) value = value.slice(1, -1);
|
|
68
|
+
return { key: m[1]!, value, raw };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One hex-dump row per 16 bytes: offset, hex pairs and printable characters. */
|
|
73
|
+
export function hexRows(bytes: Uint8Array): { offset: string; hex: string; ascii: string }[] {
|
|
74
|
+
const rows: { offset: string; hex: string; ascii: string }[] = [];
|
|
75
|
+
for (let at = 0; at < bytes.length; at += 16) {
|
|
76
|
+
const chunk = bytes.subarray(at, at + 16);
|
|
77
|
+
let hex = '';
|
|
78
|
+
let ascii = '';
|
|
79
|
+
for (let i = 0; i < 16; i++) {
|
|
80
|
+
if (i < chunk.length) {
|
|
81
|
+
const b = chunk[i]!;
|
|
82
|
+
hex += b.toString(16).padStart(2, '0') + (i === 7 ? ' ' : ' ');
|
|
83
|
+
ascii += b >= 32 && b < 127 ? String.fromCharCode(b) : '.';
|
|
84
|
+
} else hex += i === 7 ? ' ' : ' ';
|
|
85
|
+
}
|
|
86
|
+
rows.push({ offset: at.toString(16).padStart(8, '0'), hex, ascii });
|
|
87
|
+
}
|
|
88
|
+
return rows;
|
|
89
|
+
}
|
package/src/text-save.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type Eol = '\n' | '\r\n';
|
|
2
|
+
|
|
3
|
+
/** The line break most lines of `text` end with; `fallback` when it has none. */
|
|
4
|
+
export function detectEol(text: string, fallback: Eol = '\n'): Eol {
|
|
5
|
+
const crlf = text.split('\r\n').length - 1;
|
|
6
|
+
const lf = text.split('\n').length - 1 - crlf;
|
|
7
|
+
if (crlf === 0 && lf === 0) return fallback;
|
|
8
|
+
return crlf > lf ? '\r\n' : '\n';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** `text` with every line break as `\n`, as the editor holds it. */
|
|
12
|
+
export const normalizeEol = (text: string) => text.replace(/\r\n?/g, '\n');
|
|
13
|
+
|
|
14
|
+
/** Editor text (`\n` breaks) with `eol` breaks, for writing. */
|
|
15
|
+
export const applyEol = (text: string, eol: Eol) => (eol === '\n' ? text : text.replace(/\n/g, eol));
|
|
16
|
+
|
|
17
|
+
/** `text` without spaces and tabs at line ends. */
|
|
18
|
+
export const trimTrailingWhitespace = (text: string) => text.replace(/[ \t]+$/gm, '');
|
|
19
|
+
|
|
20
|
+
/** `text` ending with one line break; an empty text stays empty. */
|
|
21
|
+
export const ensureFinalNewline = (text: string) => (text === '' || text.endsWith('\n') ? text : `${text}\n`);
|
|
22
|
+
|
|
23
|
+
/** The save-time edits of VS Code's `files.trimTrailingWhitespace` and `files.insertFinalNewline`. */
|
|
24
|
+
export function prepareForSave(text: string, opts: { trim: boolean; finalNewline: boolean; markdown: boolean }): string {
|
|
25
|
+
let out = opts.trim && !opts.markdown ? trimTrailingWhitespace(text) : text;
|
|
26
|
+
if (opts.finalNewline) out = ensureFinalNewline(out);
|
|
27
|
+
return out;
|
|
28
|
+
}
|