@artkit-ai/format-openui 0.0.0-stage → 0.2.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/src/syntax.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Line-based OpenUI Lang syntax (spec §12.1): one declaration per line, `name = Component(args)`. This module only
3
+ * tokenizes and edits text; schema checks belong to the parser of `@openuidev/lang-core`.
4
+ */
5
+
6
+ export interface Statement {
7
+ name: string;
8
+ expr: string;
9
+ }
10
+
11
+ const DECLARATION = /^([a-z][A-Za-z0-9_]{0,63})\s*=\s*(.+)$/;
12
+
13
+ export interface ParsedLines {
14
+ statements: Statement[];
15
+ /** 1-based numbers of non-empty lines that are not declarations. */
16
+ invalidLines: number[];
17
+ }
18
+
19
+ export function parseStatements(content: string): ParsedLines {
20
+ const statements: Statement[] = [];
21
+ const invalidLines: number[] = [];
22
+ content.split('\n').forEach((line, index) => {
23
+ if (line.trim() === '') return;
24
+ const match = DECLARATION.exec(line.trim());
25
+ if (match) statements.push({ name: match[1]!, expr: match[2]!.trim() });
26
+ else invalidLines.push(index + 1);
27
+ });
28
+ return { statements, invalidLines };
29
+ }
30
+
31
+ export function serializeStatements(statements: readonly Statement[]): string {
32
+ return `${statements.map((statement) => `${statement.name} = ${statement.expr}`).join('\n')}\n`;
33
+ }
34
+
35
+ export function formatStatement(statement: Statement): string {
36
+ return `${statement.name} = ${statement.expr}`;
37
+ }
38
+
39
+ export type Token =
40
+ | { kind: 'string'; text: string; start: number; end: number }
41
+ | { kind: 'ident'; text: string; start: number; end: number }
42
+ | { kind: 'punct'; text: string; start: number; end: number }
43
+ | { kind: 'other'; text: string; start: number; end: number };
44
+
45
+ /** Tokenizes an expression. Strings keep their quotes and escapes; identifiers may contain dots (e.g. `Q1.total`). */
46
+ export function tokenize(expr: string): Token[] {
47
+ const tokens: Token[] = [];
48
+ let i = 0;
49
+ while (i < expr.length) {
50
+ const char = expr[i]!;
51
+ if (/\s/.test(char)) {
52
+ i += 1;
53
+ continue;
54
+ }
55
+ if (char === '"') {
56
+ let j = i + 1;
57
+ while (j < expr.length && expr[j] !== '"') j += expr[j] === '\\' ? 2 : 1;
58
+ const end = Math.min(j + 1, expr.length);
59
+ tokens.push({ kind: 'string', text: expr.slice(i, end), start: i, end });
60
+ i = end;
61
+ continue;
62
+ }
63
+ if (/[A-Za-z_]/.test(char)) {
64
+ let j = i + 1;
65
+ while (j < expr.length && /[A-Za-z0-9_.]/.test(expr[j]!)) j += 1;
66
+ tokens.push({ kind: 'ident', text: expr.slice(i, j), start: i, end: j });
67
+ i = j;
68
+ continue;
69
+ }
70
+ if ('()[],'.includes(char)) {
71
+ tokens.push({ kind: 'punct', text: char, start: i, end: i + 1 });
72
+ i += 1;
73
+ continue;
74
+ }
75
+ let j = i + 1;
76
+ while (j < expr.length && !/[\sA-Za-z_"()[\],]/.test(expr[j]!)) j += 1;
77
+ tokens.push({ kind: 'other', text: expr.slice(i, j), start: i, end: j });
78
+ i = j;
79
+ }
80
+ return tokens;
81
+ }
82
+
83
+ const LITERALS = new Set(['true', 'false', 'null']);
84
+
85
+ /** Names a declaration refers to: identifiers that are not component calls, literals or function calls. */
86
+ export function references(expr: string): string[] {
87
+ const tokens = tokenize(expr);
88
+ const refs: string[] = [];
89
+ tokens.forEach((token, index) => {
90
+ if (token.kind !== 'ident' || LITERALS.has(token.text)) return;
91
+ const next = tokens[index + 1];
92
+ if (next?.kind === 'punct' && next.text === '(') return;
93
+ refs.push(token.text);
94
+ });
95
+ return refs;
96
+ }
97
+
98
+ /** Component name of a declaration (`Slide` for `Slide("title", [...])`), or undefined when there is none. */
99
+ export function componentOf(expr: string): string | undefined {
100
+ const first = tokenize(expr)[0];
101
+ return first?.kind === 'ident' ? first.text : undefined;
102
+ }
103
+
104
+ /** Replaces every reference to `from` with `to`, keeping the rest of the text untouched. */
105
+ export function renameReferences(expr: string, from: string, to: string): string {
106
+ return rewriteTokens(expr, (token, next) => {
107
+ if (token.kind === 'ident' && token.text === from && !(next?.kind === 'punct' && next.text === '('))
108
+ return to;
109
+ return undefined;
110
+ });
111
+ }
112
+
113
+ /** Removes every reference to `name` inside array items (cascade). Items that are whole references are dropped. */
114
+ export function removeReferences(expr: string, name: string): string {
115
+ const span = firstArraySpan(expr);
116
+ if (!span) return expr;
117
+ const items = splitItems(expr.slice(span.start + 1, span.end));
118
+ const kept = items.filter((item) => item !== name);
119
+ return `${expr.slice(0, span.start)}[${kept.join(', ')}]${expr.slice(span.end + 1)}`;
120
+ }
121
+
122
+ /** Position of the first `[ ... ]` argument of the outer call, or undefined. */
123
+ export function firstArraySpan(expr: string): { start: number; end: number } | undefined {
124
+ let depth = 0;
125
+ let start = -1;
126
+ const tokens = tokenize(expr);
127
+ for (const token of tokens) {
128
+ if (token.kind === 'punct' && (token.text === '(' || token.text === '[')) {
129
+ if (token.text === '[' && depth === 1 && start < 0) start = token.start;
130
+ depth += 1;
131
+ } else if (token.kind === 'punct' && (token.text === ')' || token.text === ']')) {
132
+ depth -= 1;
133
+ if (token.text === ']' && start >= 0 && depth === 1) return { start, end: token.start };
134
+ }
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ /** Splits the inside of an array into top-level items (commas inside calls and strings do not split). Items keep their text. */
140
+ export function splitItems(inner: string): string[] {
141
+ const items: string[] = [];
142
+ let depth = 0;
143
+ let first: Token | undefined;
144
+ let last: Token | undefined;
145
+ const flush = () => {
146
+ if (first && last) items.push(inner.slice(first.start, last.end));
147
+ first = undefined;
148
+ last = undefined;
149
+ };
150
+ for (const token of tokenize(inner)) {
151
+ if (token.kind === 'punct' && token.text === ',' && depth === 0) {
152
+ flush();
153
+ continue;
154
+ }
155
+ if (token.kind === 'punct' && (token.text === '(' || token.text === '[')) depth += 1;
156
+ if (token.kind === 'punct' && (token.text === ')' || token.text === ']')) depth -= 1;
157
+ first ??= token;
158
+ last = token;
159
+ }
160
+ flush();
161
+ return items;
162
+ }
163
+
164
+ /** Children of the first array argument (names and inline components), in order. */
165
+ export function childrenOf(expr: string): string[] {
166
+ const span = firstArraySpan(expr);
167
+ return span ? splitItems(expr.slice(span.start + 1, span.end)) : [];
168
+ }
169
+
170
+ /** Replaces the children of the first array argument with `items`. */
171
+ export function setChildren(expr: string, items: string[]): string | undefined {
172
+ const span = firstArraySpan(expr);
173
+ if (!span) return undefined;
174
+ return `${expr.slice(0, span.start)}[${items.join(', ')}]${expr.slice(span.end + 1)}`;
175
+ }
176
+
177
+ function rewriteTokens(
178
+ expr: string,
179
+ replace: (token: Token, next: Token | undefined) => string | undefined,
180
+ ): string {
181
+ const tokens = tokenize(expr);
182
+ let out = '';
183
+ let cursor = 0;
184
+ tokens.forEach((token, index) => {
185
+ const replacement = replace(token, tokens[index + 1]);
186
+ if (replacement === undefined) return;
187
+ out += expr.slice(cursor, token.start) + replacement;
188
+ cursor = token.end;
189
+ });
190
+ return out + expr.slice(cursor);
191
+ }
192
+
193
+ export interface ComponentCall {
194
+ component: string;
195
+ /** Text between the parentheses of this call. */
196
+ inner: string;
197
+ }
198
+
199
+ /** Every component call in an expression, outer and inline ones (`Slide(…, [Bullets([…])])` yields both). */
200
+ export function callsOf(expr: string): ComponentCall[] {
201
+ const tokens = tokenize(expr);
202
+ const calls: ComponentCall[] = [];
203
+ tokens.forEach((token, index) => {
204
+ const next = tokens[index + 1];
205
+ if (token.kind !== 'ident' || next?.kind !== 'punct' || next.text !== '(') return;
206
+ let depth = 0;
207
+ for (let j = index + 1; j < tokens.length; j++) {
208
+ const current = tokens[j]!;
209
+ if (current.kind !== 'punct') continue;
210
+ if (current.text === '(') depth += 1;
211
+ if (current.text === ')') {
212
+ depth -= 1;
213
+ if (depth === 0) {
214
+ calls.push({ component: token.text, inner: expr.slice(next.end, current.start) });
215
+ return;
216
+ }
217
+ }
218
+ }
219
+ });
220
+ return calls;
221
+ }
222
+
223
+ /** Top-level arguments of a call's inside text (commas inside nested calls and arrays do not split). */
224
+ export function argsOf(inner: string): string[] {
225
+ return splitItems(inner);
226
+ }