@forming/tools 0.0.0-stage → 0.1.0-dev.2.1

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/LICENSE ADDED
@@ -0,0 +1,86 @@
1
+ Forming Source Available License 1.0
2
+
3
+ Copyright (c) 2026 Forming contributors.
4
+
5
+ 1. Scope and definitions
6
+
7
+ This license covers the Forming implementation, tools, examples and other
8
+ original material supplied with this notice (the Software). Each copyright
9
+ holder grants rights only to material they own or are authorized to license.
10
+ Third-party components and material expressly offered under another license
11
+ remain governed by those terms. See NOTICE.md and the attribution notices.
12
+
13
+ An Internal Use is use by your employees or contractors solely for your own
14
+ organization's operations. Customers and other external users are not internal
15
+ users. A Commercial Product is a product or service sold, licensed for a fee,
16
+ monetized through advertising, or offered to support a paid business offering,
17
+ including its free tiers, trials and bundled features.
18
+
19
+ 2. Permission
20
+
21
+ Subject to the conditions below, you may use, inspect, copy and modify the
22
+ Software for personal, educational, noncommercial and Internal Use. Internal
23
+ Use by a commercial business is permitted without payment.
24
+
25
+ You may also use the Software on the backend of a Commercial Product to compile
26
+ definitions, validate data or process results, provided that you do not offer
27
+ the Software's capabilities as a service or use the Software in that product's
28
+ external user interface as described in section 3.
29
+
30
+ You may redistribute unmodified or modified copies without charge for uses
31
+ permitted by this license. You must include this license and all applicable
32
+ copyright, attribution and third-party notices. Identify changes you make.
33
+ Recipients receive their rights from the applicable copyright holders under
34
+ these same terms; you may not grant broader rights to the Software.
35
+
36
+ 3. Uses requiring separate written permission
37
+
38
+ Without a separate written agreement from the applicable copyright holders,
39
+ you may not, directly or through another party:
40
+
41
+ (a) sell, rent, sublicense for a fee, or otherwise charge for copies of or
42
+ access to the Software, including modified, rebranded or bundled versions;
43
+
44
+ (b) offer the Software or its form authoring, rendering, compilation, validation
45
+ or runtime capabilities to external users as a hosted, managed, API or other
46
+ commercial service, including through a wrapper or a free tier of that service;
47
+
48
+ (c) embed, distribute or otherwise use the Software to provide any part of a
49
+ Commercial Product's external user interface, including ordinary rendered
50
+ forms, widgets, editors, visual builders and white-label interfaces.
51
+
52
+ These restrictions apply even if the Software is only one part of an offering
53
+ or is not separately priced. They do not prohibit Internal Use or the backend
54
+ integration expressly allowed in section 2. Charging for consulting, installation
55
+ or support is allowed when the resulting use itself complies with this license.
56
+
57
+ 4. Your material and other rights
58
+
59
+ You retain ownership of your independently authored form definitions, submitted
60
+ data and other original output. This license does not require you to disclose
61
+ your application's source code. Output that contains Software code remains
62
+ subject to this license to the extent it contains that code. No trademark rights
63
+ are granted. Rights not expressly granted are reserved.
64
+
65
+ 5. Termination
66
+
67
+ Your permissions end if you violate this license. If you correct your first
68
+ violation within 30 days of receiving written notice from a copyright holder,
69
+ your permissions are restored. Further violations require express written
70
+ reinstatement. Termination does not remove obligations that apply to copies
71
+ already distributed.
72
+
73
+ 6. Warranty and liability
74
+
75
+ TO THE EXTENT PERMITTED BY LAW, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT
76
+ WARRANTIES, INCLUDING MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
77
+ NONINFRINGEMENT. COPYRIGHT HOLDERS AND CONTRIBUTORS ARE NOT LIABLE FOR CLAIMS,
78
+ DAMAGES OR OTHER LIABILITY ARISING FROM THE SOFTWARE OR ITS USE, WHETHER IN
79
+ CONTRACT, TORT OR OTHERWISE.
80
+
81
+ 7. Additional permission
82
+
83
+ Contact the project maintainers through the repository from which you obtained
84
+ the Software to request a commercial agreement. An unanswered request does not
85
+ grant permission. A separate written agreement controls only to the extent it
86
+ expressly grants different rights.
package/NOTICE.md ADDED
@@ -0,0 +1,18 @@
1
+ # Licensing notices
2
+
3
+ The Forming implementation is covered by the [Forming Source Available License](LICENSE).
4
+ This license permits internal business use and commercial backend integration,
5
+ but requires separate written permission for resale, commercial hosted Forming
6
+ services and external UI use in commercial products. It is not an open-source
7
+ license. The license text controls over this summary.
8
+
9
+ The specifications and adapted specification text retain their existing
10
+ [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) terms and attribution.
11
+ See `core/ATTRIBUTION.md` and `website/ATTRIBUTION.md` in the source repository.
12
+ The implementation license does not replace those rights or revoke rights
13
+ granted under earlier licenses.
14
+
15
+ Dependencies retain their own licenses. Rust dependency notices are collected
16
+ under `tools/skills/notices/` in the source repository and included with packaged
17
+ WASM distributions. JavaScript and Go dependency notices are supplied by their
18
+ respective packages. Preserve applicable notices when redistributing bundles.
package/README.md CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
1
+ # Forming tools
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ JavaScript tooling for writing Forming: autocomplete, editor support and the language server. The lint rules, `forming check` and `forming fmt` live in the [Rust core](../core/spec/lint.md), so every tool reports the same errors and warnings.
4
+
5
+ | Folder | Package | What it is |
6
+ | --- | --- | --- |
7
+ | `.` (this folder) | `@forming/tools` | Completion engine and tokenizer shared by the playground and editors |
8
+ | [`language-server/`](language-server) | `@forming/language-server` | Errors, lint warnings, autocomplete and formatting over the Language Server Protocol: VS Code, Neovim, Zed, Helix and other LSP clients |
9
+ | [`vscode/`](vscode) | VS Code extension | `.forming` files, syntax highlighting, comments and indentation, and the language server |
10
+ | [`skills/`](skills) | `@forming/skills` (local package prototype) | Installable Codex/Claude skill with an offline WASM validator and extension references; not yet published |
11
+
12
+ ## @forming/tools
13
+
14
+ ```js
15
+ import {complete, importKinds} from '@forming/tools/completions';
16
+
17
+ const kinds = importKinds(source, packageManifests); // which imports are uploads, options, annotations...
18
+ const result = complete(source, offset, {kinds}); // {from, to, options: [{label, type, apply}]}
19
+ ```
20
+
21
+ - Suggests keywords, types, settings, rules, field paths, pages and functions for the line being edited.
22
+ - With package manifests, imports are offered only where their kind fits: uploads after `upload with`, option lists after `options from`, validators after `validate with`, actions after `invoke`, and annotations beneath fields and fragment uses. Without manifests every import is offered.
23
+ - `formingCompletions(context, {kinds})` is a CodeMirror completion source; `complete(text, offset)` works on plain text for any editor.
24
+ - `@forming/tools/tokens` exports the tokenizer and its word lists (`WORDS`), from which the VS Code grammar is generated.
25
+
26
+ The website vendors a copy (`npm run sync:runtime` in `website/`) so it builds on its own.
27
+
28
+ ## Editors
29
+
30
+ Both the server and the extension read package manifests from the nearest `forming.packages.json` beside your forms, so imports resolve and completions know what each import is:
31
+
32
+ ```json
33
+ [{"profile": "forming.packages/1", "name": "hidden", "version": "1.0.0", "exports": {"visibleTo": {"kind": "annotation", "valueSchema": {"type": "array", "items": {"type": "string"}}}}}]
34
+ ```
35
+
36
+ Copy each plugin's `manifest.json` (for example `extensions/hidden/manifest.json`) into that array.
37
+
38
+ ### VS Code
39
+
40
+ ```sh
41
+ cd tools/language-server && npm ci # needs js-sdk built (npm run build:wasm && npm run build)
42
+ cd ../vscode && npm ci && npm run package # bundles, then: code --install-extension forming-*.vsix
43
+ ```
44
+
45
+ `npm run package` bundles the extension and server into `dist/` with the core's WebAssembly in `wasm/`, so the `.vsix` carries no `node_modules`. The extension is not yet on the Marketplace.
46
+
47
+ ### Other editors
48
+
49
+ Run the server with `--stdio`:
50
+
51
+ ```lua
52
+ -- Neovim
53
+ vim.lsp.start({name = 'forming', cmd = {'forming-language-server', '--stdio'}, root_dir = vim.fn.getcwd()})
54
+ ```
55
+
56
+ ## Development
57
+
58
+ ```sh
59
+ npm test # @forming/tools
60
+ npm --prefix language-server test # diagnostics, completion, formatting and a real stdio LSP session
61
+ npm --prefix vscode test # grammar, activation, and the bundled server answering LSP
62
+ npm --prefix vscode run grammar # regenerate syntaxes/forming.tmLanguage.json
63
+ ```
64
+
65
+ ## Automatic npm publishing
66
+
67
+ Publishes `@forming/tools` from `.`.
68
+
69
+ `.github/workflows/publish.yml` publishes the JavaScript package on pushes to
70
+ `dev` (base version plus `-dev.<run>.<attempt>`, npm tag `dev`) and `main`
71
+ (stable package.json version, npm tag `latest`). Bump package.json and its lockfile
72
+ before another stable release. Manual runs support only these two branches.
73
+ The workflow creates no commits, Git tags or GitHub releases.
74
+
75
+ Set Actions variable `NPM_ACCESS` to `public` or `restricted`. Configure secret
76
+ `NPM_TOKEN`, or npm trusted publishing for this repository and workflow
77
+ `publish.yml` after the first publish. Private repositories publish with provenance
78
+ disabled. Packages exclude tests, source directories, build scripts and source maps.
@@ -0,0 +1,419 @@
1
+ import {scriptTokens} from './tokens.mjs';
2
+
3
+ const NAME = '[A-Za-z_][A-Za-z0-9_]*';
4
+ const FIELD = new RegExp(`^(${NAME})\\s*:\\s*(.+)?$`);
5
+ const TYPES = [
6
+ 'string',
7
+ 'integer',
8
+ 'number',
9
+ 'boolean',
10
+ 'email',
11
+ 'url',
12
+ 'phone',
13
+ 'date',
14
+ 'time',
15
+ 'datetime',
16
+ 'file',
17
+ 'object',
18
+ 'enum(',
19
+ ];
20
+ const VIEWS = ['section', 'group', 'stack', 'columns', 'grid', 'repeat', 'display', 'use'];
21
+ const RULES = ['show when ', 'enable when ', 'readonly when '];
22
+ const FUNCTIONS = {
23
+ has: 'has(path): is an answer present?',
24
+ default: 'default(value, fallback)',
25
+ size: 'size(text | list)',
26
+ startsWith: 'startsWith(text, prefix)',
27
+ endsWith: 'endsWith(text, suffix)',
28
+ contains: 'contains(text, part)',
29
+ sum: 'sum(list, key?)',
30
+ count: 'count(list, key)',
31
+ every: 'every(list, key)',
32
+ some: 'some(list, key)',
33
+ };
34
+ const OPERATORS = ['and', 'or', 'in', '??', '==', '!=', '<', '<=', '>', '>=', '+', '-', '*', '/', '%', '?'];
35
+ const numeric = ['min ', 'max ', 'multipleOf '];
36
+ const strings = ['minLength ', 'maxLength ', 'pattern '];
37
+ const arrays = ['minItems ', 'maxItems ', 'uniqueItems'];
38
+ const constraints = type => [
39
+ 'required',
40
+ 'default ',
41
+ 'id ',
42
+ ...(type.includes('[]')
43
+ ? arrays
44
+ : /^(number|integer)\b/.test(type)
45
+ ? numeric
46
+ : /^(string|email|url|phone|date|time|datetime|enum)\b/.test(type)
47
+ ? strings
48
+ : []),
49
+ ...(!type.includes('[]') && !/^(object|file)\b/.test(type) ? ['mustBe '] : []),
50
+ ...(type.startsWith('phone') ? ['region '] : []),
51
+ ...(type.startsWith('file') ? ['accept ', 'maxSize ', ...(type.includes('[]') ? ['maxTotalSize '] : [])] : []),
52
+ ];
53
+ const controls = type =>
54
+ type.includes('[]')
55
+ ? ['multichoice', 'hidden']
56
+ : /^(string|email|url|phone)\b/.test(type)
57
+ ? ['textarea', 'password', 'choice', 'hidden']
58
+ : /^(date|time|datetime)\b/.test(type)
59
+ ? [type.match(/^\w+/)[0], 'hidden']
60
+ : ['choice', 'hidden'];
61
+ const choices = (labels, type = 'keyword') =>
62
+ labels.map(label => (typeof label === 'string' ? {label, type} : {...label, type}));
63
+ const clean = text =>
64
+ scriptTokens(text)
65
+ .filter(token => token.type !== 'lineComment')
66
+ .map(token => token.text)
67
+ .join('');
68
+ const masked = text =>
69
+ scriptTokens(text)
70
+ .map(token =>
71
+ token.type === 'string' || token.type === 'lineComment' ? ' '.repeat(token.text.length) : token.text
72
+ )
73
+ .join('');
74
+
75
+ // Recover indentation scopes from incomplete source. Blank/comment lines don't
76
+ // close blocks. Keep following declarations too, for forward references and
77
+ // duplicate settings while editing in the middle of a document.
78
+ function documentLines(text) {
79
+ const stack = [],
80
+ records = [];
81
+ let offset = 0;
82
+ for (const raw of text.split('\n')) {
83
+ const code = clean(raw).trim();
84
+ const indent = raw.match(/^ */)[0].length;
85
+ if (code) {
86
+ while (stack.length && stack.at(-1).indent >= indent) stack.pop();
87
+ const field = code.match(FIELD);
88
+ const record = {
89
+ code,
90
+ indent,
91
+ from: offset,
92
+ parents: [...stack],
93
+ field,
94
+ kind: field ? 'field' : code.split(/\s/)[0],
95
+ };
96
+ records.push(record);
97
+ stack.push(record);
98
+ }
99
+ offset += raw.length + 1;
100
+ }
101
+ return records;
102
+ }
103
+
104
+ function symbols(records, parents) {
105
+ const fragment = parents.find(record => record.kind === 'fragment');
106
+ const fields = [],
107
+ inputs = [],
108
+ computed = [];
109
+ for (const record of records) {
110
+ const owner = record.parents.find(parent => parent.kind === 'fragment');
111
+ if (owner !== fragment) continue;
112
+ if (record.field) {
113
+ const prefix = record.parents.flatMap(parent =>
114
+ parent.field ? [parent.field[1]] : parent.kind === 'repeat' ? [parent.code.split(/\s+/)[1]] : []
115
+ );
116
+ fields.push([...prefix, record.field[1]].join('.'));
117
+ }
118
+ const input = record.code.match(/^input\s+(\w+)\s*:/);
119
+ if (input) inputs.push(input[1]);
120
+ const calc = record.code.match(/^(\w+)\s*=/);
121
+ if (calc && record.parents.at(-1)?.kind === 'computed') computed.push(calc[1]);
122
+ }
123
+ return {fields, inputs, computed};
124
+ }
125
+
126
+ /**
127
+ * CodeMirror-compatible completion source. `kinds` maps imported names to
128
+ * their export kind ("upload", "options", "annotation", ...) when manifests
129
+ * are known; see importKinds. Without it every import is offered everywhere.
130
+ */
131
+ export function formingCompletions(context, {kinds} = {}) {
132
+ const doc = context.state.doc;
133
+ const line = doc.lineAt(context.pos);
134
+ const column = context.pos - line.from;
135
+ const before = line.text.slice(0, column);
136
+ const tokens = scriptTokens(line.text);
137
+ // No suggestions in comments or quoted labels, even on explicit Ctrl+Space.
138
+ if (
139
+ tokens.some(
140
+ token =>
141
+ token.from < column &&
142
+ (token.type === 'lineComment' ||
143
+ (token.type === 'string' &&
144
+ (column < token.to || (column === token.to && !/^"(?:\\.|[^"\\])*"$/.test(token.text)))))
145
+ )
146
+ )
147
+ return null;
148
+ const word = before.match(/[A-Za-z_][A-Za-z0-9_-]*$/)?.[0] ?? '';
149
+ const from = context.pos - word.length;
150
+ const suffix = line.text.slice(column).match(/^[A-Za-z0-9_-]*/)[0];
151
+ const to = context.pos + suffix.length;
152
+ const head = before.slice(0, before.length - word.length);
153
+ const after = line.text.slice(to - line.from);
154
+ if (!word && !context.explicit && !/[\s:.]$/.test(before)) return null;
155
+ const done = list => {
156
+ const unique = [...new Map(list.map(option => [option.label, option])).values()];
157
+ return unique.length
158
+ ? {
159
+ from,
160
+ to,
161
+ options: unique.map(option => {
162
+ let apply = option.apply ?? option.label;
163
+ if (typeof apply === 'string') {
164
+ if (/\s$/.test(apply) && /^\s/.test(after)) apply = apply.trimEnd();
165
+ if (apply.endsWith('(') && /^\s*\(/.test(after)) apply = apply.slice(0, -1);
166
+ }
167
+ return {...option, apply};
168
+ }),
169
+ validFor: /^[A-Za-z0-9_-]*$/,
170
+ }
171
+ : null;
172
+ };
173
+ const text = doc.toString();
174
+ const records = documentLines(text);
175
+ const indent = line.text.match(/^ */)[0].length;
176
+ const current = records.find(record => record.from === line.from);
177
+ const previous = records.filter(record => record.from < line.from).at(-1);
178
+ const parents = previous ? [...previous.parents, previous].filter(record => record.indent < indent) : [];
179
+ const parent = parents.at(-1);
180
+ const names = symbols(records, parents);
181
+ const imported = records
182
+ .filter(record => record.kind === 'import')
183
+ .flatMap(record =>
184
+ (record.code.match(/\{([^}]*)\}/)?.[1] ?? '')
185
+ .split(',')
186
+ .map(name =>
187
+ name
188
+ .trim()
189
+ .split(/\s+as\s+/)
190
+ .at(-1)
191
+ )
192
+ .filter(Boolean)
193
+ );
194
+ const kindOf = name => kinds?.get?.(name) ?? kinds?.[name];
195
+ // Imports of the wanted kinds; a name whose kind is unknown stays on offer.
196
+ const importedOf = (...wanted) =>
197
+ imported.filter(name => kindOf(name) === undefined || wanted.includes(kindOf(name)));
198
+ const annotations = imported.filter(name => kindOf(name) === 'annotation');
199
+ const declared = kind =>
200
+ records
201
+ .filter(record => record.kind === kind && record.indent === 0)
202
+ .map(record => record.code.split(/\s+/)[1])
203
+ .filter(Boolean);
204
+ const field = current?.field ? current : [...parents].reverse().find(record => record.field);
205
+ const type = field?.field[2]?.split(/\s+"/)[0] ?? 'string';
206
+ const inData = parents.some(record => record.kind === 'data');
207
+ const inActions = parent?.kind === 'actions';
208
+ const inFragment = parents.some(record => record.kind === 'fragment');
209
+ const repeats = parents.filter(record => record.kind === 'repeat');
210
+ const namespaces = [
211
+ 'data',
212
+ 'computed',
213
+ ...(inFragment ? ['inputs', 'local'] : []),
214
+ ...(repeats.length ? ['item'] : []),
215
+ ...(repeats.length > 1 ? ['parent'] : []),
216
+ ];
217
+ const target = labels => done(choices(labels, 'variable'));
218
+ const words = labels => done(choices(labels));
219
+ const unused = (labels, owner = field) => {
220
+ // Include both sides of the cursor and settings elsewhere in this block.
221
+ const related = owner ? records.filter(record => record === owner || record.parents.at(-1) === owner) : [];
222
+ const occupied = related
223
+ .map(record => (record.from === line.from ? head + after : record.code))
224
+ .map(masked)
225
+ .join('\n');
226
+ return labels.filter(label => !new RegExp(`\\b${label.trim().split(/\s/)[0]}\\b`).test(occupied));
227
+ };
228
+
229
+ if (/^\s*(?:input\s+)?\w+\s*:\s*(?:\(\s*)?$/.test(head)) return done(choices(TYPES, 'type'));
230
+ if (/^\s*forming\s+$/.test(head)) return words(['1']);
231
+ if (/^\s*import\s+\{[^}]*\}\s*$/.test(head)) return words(['from ']);
232
+ if (/^\s*options\s+from\s+$/.test(head)) return target([...declared('options'), ...importedOf('options')]);
233
+ if (/^\s*options\s+$/.test(head) && field) return words(['from ']);
234
+ if (/^\s*data\s+$/.test(head)) return words(['from ']);
235
+ if (/^\s*data\s+from\s+$/.test(head)) return target(importedOf('schema'));
236
+ if (/^\s*use\s+$/.test(head)) return target([...declared('fragment'), ...importedOf('fragment')]);
237
+ if (/^\s*use\s+\w+\s+$/.test(head)) return words(['at ']);
238
+ if (/^\s*(?:use\s+\w+\s+at|repeat|set|clear)\s+$/.test(head)) return target(names.fields);
239
+ if (/^\s*next\s+$/.test(head) && inActions) return target(declared('page'));
240
+ if (/^\s*validate\s+with\s+$/.test(head)) return target(importedOf('validator'));
241
+ if (/^\s*upload\s+with\s+$/.test(head)) return target(importedOf('upload'));
242
+ if (/^\s*invoke\s+$/.test(head)) return target(importedOf('action'));
243
+ if (/^\s*upload\s+$/.test(head)) return words(['with ']);
244
+ if (/\bas\s+$/.test(masked(head)) && field) return done(choices(controls(type), 'enum'));
245
+ if (/^\s*(?:show|enable|readonly|required)\s+$/.test(head)) return words(['when ']);
246
+ if (/^\s*with\s+$/.test(head) && parent?.kind === 'set') return words(['value = ']);
247
+ if (/^\s*with\s+\w+\s*$/.test(head)) return words(['= ']);
248
+
249
+ const expressionMatch = masked(head).match(
250
+ /(?:\bwhen\s+|^\s*validate\s+(?!with\b)|^\s*computed\s+|(?<![=!<>])=(?!=)\s*)(.*)$/
251
+ );
252
+ if (expressionMatch && !(inActions && /^\s*validate\s/.test(head))) {
253
+ const expression = head.slice(expressionMatch.index + expressionMatch[0].length - expressionMatch[1].length);
254
+ const member = expression.match(/((?:[A-Za-z_][A-Za-z0-9_]*\.)+)$/)?.[1];
255
+ if (member) {
256
+ const [namespace, ...path] = member.slice(0, -1).split('.');
257
+ let paths = names.fields;
258
+ if (namespace === 'computed') paths = names.computed;
259
+ else if (namespace === 'inputs') paths = names.inputs;
260
+ else if (namespace === 'item' || namespace === 'parent') {
261
+ const repeat = repeats.at(namespace === 'item' ? -1 : -2);
262
+ if (!repeat) return null;
263
+ const prefix = repeat.code.split(/\s+/)[1] + '.';
264
+ paths = paths.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length));
265
+ } else if (!['data', 'local'].includes(namespace)) path.unshift(namespace);
266
+ const prefix = path.length ? path.join('.') + '.' : '';
267
+ return target(paths.filter(name => name.startsWith(prefix)).map(name => name.slice(prefix.length).split('.')[0]));
268
+ }
269
+ const significant = scriptTokens(expression).filter(token => token.text.trim());
270
+ const last = significant.at(-1);
271
+ const operand =
272
+ !last ||
273
+ /^(and|or|not|in)$/.test(last.text) ||
274
+ (last.type === 'operator' && last.text !== '.') ||
275
+ /^[([,:]$/.test(last.text);
276
+ if (!operand) return words(OPERATORS);
277
+ return done([
278
+ ...choices(
279
+ names.fields.filter(name => !name.includes('.')),
280
+ 'variable'
281
+ ),
282
+ ...choices(namespaces, 'namespace'),
283
+ ...choices(
284
+ Object.entries(FUNCTIONS).map(([label, detail]) => ({label, detail, apply: label + '('})),
285
+ 'function'
286
+ ),
287
+ ...choices(['true', 'false', 'null', 'not', ...(/^\s*validate\s+$/.test(head) ? ['with '] : [])]),
288
+ ]);
289
+ }
290
+
291
+ // Value slots have their own vocabulary; structural keywords cannot fill them.
292
+ const valueSlot = head.match(
293
+ /\b(default|mustBe|min|max|multipleOf|minLength|maxLength|minItems|maxItems|pattern|region|accept|maxSize|maxTotalSize|description|placeholder|message|id)\s*$/
294
+ )?.[1];
295
+ if (valueSlot)
296
+ return ['default', 'mustBe'].includes(valueSlot)
297
+ ? words(
298
+ ['true', 'false', 'null'].filter(value =>
299
+ value === 'null' ? type.includes('?') : type.startsWith('boolean')
300
+ )
301
+ )
302
+ : null;
303
+ if (/^\s*next\s+\S+\s+"(?:\\.|[^"\\])*"\s+$/.test(head)) return words(['when ']);
304
+ if (current?.field && /"(?:\\.|[^"\\])*"\s/.test(head))
305
+ return words(unused([...constraints(type), ...(!inData ? ['as '] : [])]));
306
+ if (head.trim()) return null;
307
+
308
+ if (inActions)
309
+ return words([
310
+ 'submit ',
311
+ 'save ',
312
+ 'reset ',
313
+ 'validate ',
314
+ 'set ',
315
+ 'clear ',
316
+ 'invoke ',
317
+ ...(parents.some(record => record.kind === 'page') ? ['next ', 'back '] : []),
318
+ ]);
319
+ if (parent?.kind === 'options') {
320
+ const provider = parent.code.match(/^options\s+from\s+(\w+)/)?.[1];
321
+ return provider ? (imported.includes(provider) ? words(['with ']) : null) : words(['option ']);
322
+ }
323
+ if (parent?.field) {
324
+ return words(
325
+ unused([
326
+ ...annotations,
327
+ ...constraints(type),
328
+ 'description ',
329
+ 'schema ',
330
+ ...(!inData
331
+ ? [
332
+ 'as ',
333
+ 'placeholder ',
334
+ 'options',
335
+ 'validate ',
336
+ ...RULES,
337
+ 'required when ',
338
+ ...(type.startsWith('file') ? ['upload with '] : []),
339
+ ...(!/^(object|file)\b/.test(type) && !type.includes('[]') ? ['computed '] : []),
340
+ ]
341
+ : []),
342
+ ...(type.startsWith('object') && !inData ? VIEWS : []),
343
+ ])
344
+ );
345
+ }
346
+ if (parent?.kind === 'data' || parent?.kind === 'computed') return null;
347
+ if (parent?.kind === 'set') return words(['with value = ']);
348
+ if (parent && ['invoke', 'upload', 'as'].includes(parent.kind)) return words(['with ']);
349
+ if (parent?.kind === 'validate')
350
+ return words(unused(['message ', 'when ', ...(parent.code.includes(' with ') ? ['with '] : [])], parent));
351
+ if (parent && ['use', 'display'].includes(parent.kind))
352
+ return words([
353
+ 'with ',
354
+ ...RULES,
355
+ ...(parent.kind === 'use' ? ['required when '] : []),
356
+ ...unused(annotations, parent),
357
+ ]);
358
+ if (
359
+ parent &&
360
+ ['page', ...VIEWS.filter(name => name !== 'use' && name !== 'display'), 'fragment'].includes(parent.kind)
361
+ ) {
362
+ return words([
363
+ ...VIEWS,
364
+ ...(!inFragment ? RULES : []),
365
+ ...(parent.kind === 'page' ? ['actions'] : []),
366
+ ...(parent.kind === 'repeat' ? ['computed', 'required when ', 'id '] : []),
367
+ ...(parent.kind === 'fragment' ? ['input '] : []),
368
+ ]);
369
+ }
370
+ if (indent > 0) return null;
371
+ const earlier = records.filter(record => record.from < line.from);
372
+ if (!earlier.some(record => record.kind === 'forming')) return words(['forming ']);
373
+ if (!earlier.some(record => record.kind === 'form'))
374
+ return words([
375
+ 'import',
376
+ 'options',
377
+ 'fragment',
378
+ ...(!records.some(record => record !== current && record.kind === 'form') ? ['form'] : []),
379
+ ]);
380
+ const existing = records.filter(record => record !== current && record.indent === 0).map(record => record.kind);
381
+ return done([
382
+ ...choices(['page', ...VIEWS, ...['data', 'computed', 'actions'].filter(kind => !existing.includes(kind))]),
383
+ ...choices(names.fields, 'variable'),
384
+ ]);
385
+ }
386
+
387
+ /** Map each imported name to its export kind, using the supplied package manifests. */
388
+ export function importKinds(source, packages = []) {
389
+ const manifests = new Map(
390
+ (typeof packages === 'string' ? JSON.parse(packages) : packages).map(p => [`${p.name}@${p.version}`, p])
391
+ );
392
+ const kinds = new Map();
393
+ for (const match of source.matchAll(/^import\s*\{([^}]*)\}\s*from\s*"([^"]+)"/gm)) {
394
+ const manifest = manifests.get(match[2]);
395
+ for (const part of match[1].split(',')) {
396
+ const [name, alias = name] = part.trim().split(/\s+as\s+/);
397
+ const kind = manifest?.exports?.[name]?.kind;
398
+ if (name && kind) kinds.set(alias, kind);
399
+ }
400
+ }
401
+ return kinds;
402
+ }
403
+
404
+ /** Completions for plain text at a UTF-16 offset, for editors and language servers. */
405
+ export function complete(text, offset, {explicit = true, kinds} = {}) {
406
+ const starts = [0];
407
+ for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
408
+ const doc = {
409
+ toString: () => text,
410
+ lineAt(pos) {
411
+ let index = starts.length - 1;
412
+ while (starts[index] > pos) index--;
413
+ const from = starts[index];
414
+ const end = text.indexOf('\n', from);
415
+ return {from, text: text.slice(from, end < 0 ? text.length : end), number: index + 1};
416
+ },
417
+ };
418
+ return formingCompletions({state: {doc}, pos: offset, explicit}, {kinds});
419
+ }
@@ -0,0 +1,216 @@
1
+ // Structural words and namespaces reserved by the grammar (spec/grammar.md).
2
+ const STRUCTURE = new Set([
3
+ 'forming',
4
+ 'form',
5
+ 'page',
6
+ 'fragment',
7
+ 'input',
8
+ 'use',
9
+ 'actions',
10
+ 'options',
11
+ 'option',
12
+ 'schema',
13
+ 'import',
14
+ 'from',
15
+ 'with',
16
+ 'section',
17
+ 'group',
18
+ 'stack',
19
+ 'columns',
20
+ 'grid',
21
+ 'repeat',
22
+ 'display',
23
+ 'data',
24
+ 'computed',
25
+ 'upload',
26
+ ]);
27
+ const NAMESPACES = new Set(['data', 'context', 'item', 'parent', 'computed', 'inputs', 'local', 'node']);
28
+ const ACTIONS = new Set(['next', 'back', 'save', 'submit', 'reset', 'set', 'clear', 'invoke', 'validate']);
29
+ const MODIFIERS = new Set([
30
+ 'required',
31
+ 'show',
32
+ 'enable',
33
+ 'readonly',
34
+ 'when',
35
+ 'message',
36
+ 'default',
37
+ 'description',
38
+ 'placeholder',
39
+ 'as',
40
+ 'at',
41
+ 'id',
42
+ 'region',
43
+ 'accept',
44
+ 'maxSize',
45
+ 'maxTotalSize',
46
+ 'min',
47
+ 'max',
48
+ 'minLength',
49
+ 'maxLength',
50
+ 'minItems',
51
+ 'maxItems',
52
+ 'multipleOf',
53
+ 'mustBe',
54
+ 'uniqueItems',
55
+ 'pattern',
56
+ 'format',
57
+ 'hide',
58
+ 'disable',
59
+ ]);
60
+ const TYPES = new Set([
61
+ 'string',
62
+ 'integer',
63
+ 'number',
64
+ 'boolean',
65
+ 'object',
66
+ 'email',
67
+ 'url',
68
+ 'phone',
69
+ 'date',
70
+ 'time',
71
+ 'datetime',
72
+ 'file',
73
+ 'enum',
74
+ ]);
75
+ const FUNCTIONS = new Set([
76
+ 'has',
77
+ 'size',
78
+ 'sum',
79
+ 'default',
80
+ 'startsWith',
81
+ 'endsWith',
82
+ 'contains',
83
+ 'count',
84
+ 'every',
85
+ 'some',
86
+ ]);
87
+ const LOGIC = new Set(['and', 'or', 'not', 'in']);
88
+ const LITERALS = new Set(['true', 'false', 'null']);
89
+
90
+ const NAME = /[A-Za-z_][A-Za-z0-9_-]*/;
91
+ const NUMBER = /-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/;
92
+
93
+ export const scriptTokenizer = {
94
+ name: 'forming',
95
+ startState: () => ({expectType: false}),
96
+ token(stream, state) {
97
+ if (stream.sol()) state.expectType = false;
98
+ if (stream.eatSpace()) return null;
99
+ if (stream.peek() === '#') {
100
+ stream.skipToEnd();
101
+ return 'lineComment';
102
+ }
103
+ if (stream.peek() === '"') {
104
+ stream.next();
105
+ while (!stream.eol()) {
106
+ const ch = stream.next();
107
+ if (ch === '\\') stream.next();
108
+ else if (ch === '"') break;
109
+ }
110
+ state.expectType = false;
111
+ return 'string';
112
+ }
113
+ if (stream.match(NUMBER)) {
114
+ state.expectType = false;
115
+ return 'number';
116
+ }
117
+ if (stream.match(NAME)) {
118
+ const word = stream.current();
119
+ const expectType = state.expectType;
120
+ state.expectType = false;
121
+ if (stream.match(/^\s*:/, false)) {
122
+ state.expectType = true;
123
+ return 'variableName.definition';
124
+ }
125
+ if (expectType && TYPES.has(word)) return 'typeName';
126
+ if (stream.match(/^\(/, false) && FUNCTIONS.has(word)) return 'variableName.function';
127
+ if (LITERALS.has(word)) return word === 'null' ? 'null' : 'bool';
128
+ if (LOGIC.has(word)) return 'logicOperator';
129
+ if (STRUCTURE.has(word)) return 'keyword';
130
+ if (ACTIONS.has(word)) return 'keyword';
131
+ if (NAMESPACES.has(word)) return 'namespace';
132
+ if (MODIFIERS.has(word)) return 'modifier';
133
+ if (TYPES.has(word)) return 'typeName';
134
+ return 'variableName';
135
+ }
136
+ const ch = stream.next();
137
+ if (ch === ':') {
138
+ state.expectType = true;
139
+ return 'punctuation';
140
+ }
141
+ if (ch === '?' && stream.peek() === '?') {
142
+ stream.next();
143
+ return 'operator';
144
+ }
145
+ if ('=!<>'.includes(ch) && stream.peek() === '=') {
146
+ stream.next();
147
+ return 'operator';
148
+ }
149
+ if ('=!<>+-*/%?.'.includes(ch)) return 'operator';
150
+ if ('()[],'.includes(ch)) return 'punctuation';
151
+ return null;
152
+ },
153
+ };
154
+
155
+ // Minimal stream interface for static snippets. No editor or browser is needed.
156
+ export function scriptTokens(source) {
157
+ const result = [];
158
+ const state = scriptTokenizer.startState();
159
+ let offset = 0;
160
+ for (const line of source.split('\n')) {
161
+ const stream = {
162
+ pos: 0,
163
+ start: 0,
164
+ sol() {
165
+ return this.pos === 0;
166
+ },
167
+ eol() {
168
+ return this.pos >= line.length;
169
+ },
170
+ peek() {
171
+ return line[this.pos];
172
+ },
173
+ next() {
174
+ return line[this.pos++];
175
+ },
176
+ current() {
177
+ return line.slice(this.start, this.pos);
178
+ },
179
+ skipToEnd() {
180
+ this.pos = line.length;
181
+ },
182
+ eatSpace() {
183
+ return this.match(/^\s+/);
184
+ },
185
+ match(pattern, consume = true) {
186
+ const match = line.slice(this.pos).match(pattern);
187
+ if (!match || match.index !== 0) return false;
188
+ if (consume) this.pos += match[0].length;
189
+ return match;
190
+ },
191
+ };
192
+ while (!stream.eol()) {
193
+ stream.start = stream.pos;
194
+ const type = scriptTokenizer.token(stream, state);
195
+ // An unfinished escape can advance past the final character.
196
+ stream.pos = Math.min(stream.pos, line.length);
197
+ result.push({from: offset + stream.start, to: offset + stream.pos, type, text: stream.current()});
198
+ }
199
+ if (offset + line.length < source.length)
200
+ result.push({from: offset + line.length, to: offset + line.length + 1, type: null, text: '\n'});
201
+ offset += line.length + 1;
202
+ }
203
+ return result;
204
+ }
205
+
206
+ /** The tokenizer's word lists, for editors that build their own highlighting. */
207
+ export const WORDS = Object.freeze({
208
+ structure: [...STRUCTURE],
209
+ namespaces: [...NAMESPACES],
210
+ actions: [...ACTIONS],
211
+ modifiers: [...MODIFIERS],
212
+ types: [...TYPES],
213
+ functions: [...FUNCTIONS],
214
+ logic: [...LOGIC],
215
+ literals: [...LITERALS],
216
+ });
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@forming/tools",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-dev.2.1",
4
+ "type": "module",
5
+ "exports": {
6
+ "./completions": "./dist/completions.mjs",
7
+ "./tokens": "./dist/tokens.mjs"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "NOTICE.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test test/*.test.mjs",
17
+ "build": "node build-package.mjs",
18
+ "prepack": "npm run build"
19
+ },
20
+ "license": "SEE LICENSE IN LICENSE",
21
+ "publishConfig": {
22
+ "registry": "https://registry.npmjs.org/"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/forming-info/tools.git"
27
+ }
28
+ }