@citisen/litearea 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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/README.zh.md +362 -0
  4. package/dist/grammars.cjs +1228 -0
  5. package/dist/grammars.cjs.map +1 -0
  6. package/dist/grammars.js +1213 -0
  7. package/dist/grammars.js.map +1 -0
  8. package/dist/index.cjs +3103 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3040 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/react.cjs +3032 -0
  13. package/dist/react.cjs.map +1 -0
  14. package/dist/react.js +3010 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/styles.cjs +453 -0
  17. package/dist/styles.cjs.map +1 -0
  18. package/dist/styles.css +432 -0
  19. package/dist/styles.js +447 -0
  20. package/dist/styles.js.map +1 -0
  21. package/dist/types/core/complete.d.ts +70 -0
  22. package/dist/types/core/complete.d.ts.map +1 -0
  23. package/dist/types/core/format.d.ts +34 -0
  24. package/dist/types/core/format.d.ts.map +1 -0
  25. package/dist/types/core/grammar.d.ts +14 -0
  26. package/dist/types/core/grammar.d.ts.map +1 -0
  27. package/dist/types/core/hover.d.ts +23 -0
  28. package/dist/types/core/hover.d.ts.map +1 -0
  29. package/dist/types/core/index.d.ts +12 -0
  30. package/dist/types/core/index.d.ts.map +1 -0
  31. package/dist/types/core/inspect.d.ts +34 -0
  32. package/dist/types/core/inspect.d.ts.map +1 -0
  33. package/dist/types/core/rank.d.ts +82 -0
  34. package/dist/types/core/rank.d.ts.map +1 -0
  35. package/dist/types/core/scan.d.ts +51 -0
  36. package/dist/types/core/scan.d.ts.map +1 -0
  37. package/dist/types/core/segments.d.ts +44 -0
  38. package/dist/types/core/segments.d.ts.map +1 -0
  39. package/dist/types/core/text.d.ts +113 -0
  40. package/dist/types/core/text.d.ts.map +1 -0
  41. package/dist/types/core/types.d.ts +604 -0
  42. package/dist/types/core/types.d.ts.map +1 -0
  43. package/dist/types/core/vocabulary.d.ts +82 -0
  44. package/dist/types/core/vocabulary.d.ts.map +1 -0
  45. package/dist/types/dom/create.d.ts +17 -0
  46. package/dist/types/dom/create.d.ts.map +1 -0
  47. package/dist/types/dom/editing.d.ts +89 -0
  48. package/dist/types/dom/editing.d.ts.map +1 -0
  49. package/dist/types/dom/editor.d.ts +366 -0
  50. package/dist/types/dom/editor.d.ts.map +1 -0
  51. package/dist/types/dom/index.d.ts +9 -0
  52. package/dist/types/dom/index.d.ts.map +1 -0
  53. package/dist/types/dom/mirror.d.ts +107 -0
  54. package/dist/types/dom/mirror.d.ts.map +1 -0
  55. package/dist/types/dom/overlay.d.ts +52 -0
  56. package/dist/types/dom/overlay.d.ts.map +1 -0
  57. package/dist/types/dom/popup.d.ts +95 -0
  58. package/dist/types/dom/popup.d.ts.map +1 -0
  59. package/dist/types/dom/support.d.ts +41 -0
  60. package/dist/types/dom/support.d.ts.map +1 -0
  61. package/dist/types/dom/tooltip.d.ts +39 -0
  62. package/dist/types/dom/tooltip.d.ts.map +1 -0
  63. package/dist/types/grammars/dshFont.d.ts +127 -0
  64. package/dist/types/grammars/dshFont.d.ts.map +1 -0
  65. package/dist/types/grammars/dshSentry.d.ts +84 -0
  66. package/dist/types/grammars/dshSentry.d.ts.map +1 -0
  67. package/dist/types/grammars/index.d.ts +3 -0
  68. package/dist/types/grammars/index.d.ts.map +1 -0
  69. package/dist/types/index.d.ts +15 -0
  70. package/dist/types/index.d.ts.map +1 -0
  71. package/dist/types/react/index.d.ts +91 -0
  72. package/dist/types/react/index.d.ts.map +1 -0
  73. package/dist/types/styles.d.ts +29 -0
  74. package/dist/types/styles.d.ts.map +1 -0
  75. package/docs/architecture.md +316 -0
  76. package/docs/completion.md +320 -0
  77. package/docs/grammar.md +823 -0
  78. package/package.json +105 -0
  79. package/scripts/browser-check.mjs +838 -0
  80. package/scripts/build-css.mjs +35 -0
  81. package/scripts/release.mjs +91 -0
  82. package/scripts/verify-package.mjs +253 -0
  83. package/src/core/complete.ts +286 -0
  84. package/src/core/format.ts +71 -0
  85. package/src/core/grammar.ts +40 -0
  86. package/src/core/hover.ts +129 -0
  87. package/src/core/index.ts +98 -0
  88. package/src/core/inspect.ts +198 -0
  89. package/src/core/rank.ts +317 -0
  90. package/src/core/scan.ts +720 -0
  91. package/src/core/segments.ts +185 -0
  92. package/src/core/text.ts +238 -0
  93. package/src/core/types.ts +681 -0
  94. package/src/core/vocabulary.ts +196 -0
  95. package/src/dom/create.ts +31 -0
  96. package/src/dom/editing.ts +213 -0
  97. package/src/dom/editor.ts +1143 -0
  98. package/src/dom/index.ts +46 -0
  99. package/src/dom/mirror.ts +305 -0
  100. package/src/dom/overlay.ts +106 -0
  101. package/src/dom/popup.ts +323 -0
  102. package/src/dom/support.ts +88 -0
  103. package/src/dom/tooltip.ts +112 -0
  104. package/src/grammars/dshFont.ts +1004 -0
  105. package/src/grammars/dshSentry.ts +742 -0
  106. package/src/grammars/index.ts +57 -0
  107. package/src/index.ts +122 -0
  108. package/src/react/index.tsx +248 -0
  109. package/src/styles.ts +529 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Write the stylesheet out as a plain CSS file.
3
+ *
4
+ * The CSS is authored once, as a string in `src/styles.ts`, so that an editor can
5
+ * inject it with no CSS loader and no build step. A host that would rather link a
6
+ * file needs it as a file, and the alternative — a second copy maintained by hand —
7
+ * is a stylesheet that drifts from the one that is actually used. So the built ESM
8
+ * module is imported and its string is written here.
9
+ *
10
+ * Usage:
11
+ * node scripts/build-css.mjs
12
+ */
13
+
14
+ import { existsSync, writeFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath, pathToFileURL } from 'node:url'
17
+
18
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
19
+ const built = join(root, 'dist', 'styles.js')
20
+
21
+ if (!existsSync(built)) {
22
+ console.error('build-css: dist/styles.js is missing; run `npm run build` first')
23
+ process.exit(1)
24
+ }
25
+
26
+ const { LITEAREA_STYLES } = await import(pathToFileURL(built).href)
27
+
28
+ if (typeof LITEAREA_STYLES !== 'string' || LITEAREA_STYLES.trim() === '') {
29
+ console.error('build-css: dist/styles.js did not export a stylesheet')
30
+ process.exit(1)
31
+ }
32
+
33
+ const target = join(root, 'dist', 'styles.css')
34
+ writeFileSync(target, `${LITEAREA_STYLES.trimEnd()}\n`, 'utf8')
35
+ console.log(`build-css: wrote ${String(LITEAREA_STYLES.length)} characters to dist/styles.css`)
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Maintainer-side release helper.
3
+ *
4
+ * Publishing is split in two on purpose: CI stages a tarball (see
5
+ * `.github/workflows/stage.yml`), and a human approves it here, where npm
6
+ * demands that human's 2FA. A compromised runner or a malicious commit can
7
+ * therefore stage but never publish.
8
+ *
9
+ * `npm stage` needs npm >= 11.6 (12 for the current subcommands) and Node 22.22
10
+ * / 24.15 / 26+, which the local Node may not satisfy. So rather than requiring
11
+ * a global npm upgrade, this delegates to `npx npm@12`, and borrows the system
12
+ * `git` for the OTP flow npm uses for a 2FA-gated web approval.
13
+ *
14
+ * Usage:
15
+ * node scripts/release.mjs list [package]
16
+ * node scripts/release.mjs view <stage-id>
17
+ * node scripts/release.mjs approve <stage-id>
18
+ * node scripts/release.mjs reject <stage-id>
19
+ * node scripts/release.mjs download <stage-id> [--dry-run]
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process'
23
+ import { readFileSync } from 'node:fs'
24
+ import { dirname, join } from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+
27
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
28
+ const [, , subcommand, ...rest] = process.argv
29
+
30
+ const PACKAGE_NAME = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name
31
+ const VERSION = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version
32
+
33
+ /** npm CLI major to run; `npm stage` needs 12 for `list`/`approve`. */
34
+ const NPM_SPEC = 'npm@12'
35
+
36
+ const USAGE = `usage: node scripts/release.mjs <command> [args]
37
+
38
+ list [package] staged versions (default: ${PACKAGE_NAME})
39
+ view <stage-id> details of one staged version
40
+ approve <stage-id> publish it — REQUIRES your 2FA
41
+ reject <stage-id> discard it
42
+ download <stage-id> fetch the staged tarball for inspection
43
+
44
+ Nothing here publishes without an approval, which is the point: CI can only
45
+ stage.`
46
+
47
+ const KNOWN = new Set(['list', 'view', 'approve', 'reject', 'download'])
48
+
49
+ if (subcommand === undefined || subcommand === '--help' || subcommand === '-h') {
50
+ console.log(USAGE)
51
+ process.exit(0)
52
+ }
53
+ if (!KNOWN.has(subcommand)) {
54
+ console.error(`release: unknown command "${subcommand}"\n\n${USAGE}`)
55
+ process.exit(2)
56
+ }
57
+ if (subcommand !== 'list' && rest.length === 0) {
58
+ console.error(`release: "${subcommand}" needs a stage-id\n\n${USAGE}`)
59
+ process.exit(2)
60
+ }
61
+
62
+ const args = [NPM_SPEC, 'stage', subcommand]
63
+ if (subcommand === 'list') args.push(rest[0] ?? PACKAGE_NAME)
64
+ else args.push(...rest)
65
+
66
+ console.log(`release: npx ${args.join(' ')}`)
67
+ if (subcommand === 'approve') {
68
+ console.log('release: approving publishes to the registry and requires your 2FA')
69
+ }
70
+
71
+ const result = spawnSync('npx', ['--yes', ...args], {
72
+ cwd: root,
73
+ stdio: 'inherit',
74
+ shell: process.platform === 'win32',
75
+ })
76
+
77
+ if (result.error !== undefined) {
78
+ if (result.error.code === 'ENOENT') {
79
+ console.error('release: npx not found on PATH — install Node.js')
80
+ process.exit(127)
81
+ }
82
+ throw result.error
83
+ }
84
+ const exitCode = result.status ?? 1
85
+
86
+ if (exitCode === 0 && subcommand === 'list') {
87
+ console.log(`\nrelease: nothing is installable until a stage is approved.`)
88
+ console.log(`release: a local release runs \`npm run release -- approve <stage-id>\` from v${VERSION}.`)
89
+ }
90
+
91
+ process.exit(exitCode)
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Exercise the BUILT package rather than the working tree.
3
+ *
4
+ * `npm run typecheck` and `npm run test` both read `src/`, so neither of them can
5
+ * notice a build that silently dropped an entry point, produced a declaration file
6
+ * with nothing in it, or failed to write the stylesheet. This is the gate that
7
+ * imports what would actually be installed and asserts it is all there.
8
+ *
9
+ * Usage:
10
+ * node scripts/verify-package.mjs [path/to/dist]
11
+ *
12
+ * The optional path exists so an INSTALLED copy can be checked, which is the only
13
+ * way to be sure the tarball is complete rather than the checkout.
14
+ */
15
+
16
+ import assert from 'node:assert/strict'
17
+ import { existsSync, readFileSync, statSync } from 'node:fs'
18
+ import { dirname, join } from 'node:path'
19
+ import { fileURLToPath, pathToFileURL } from 'node:url'
20
+
21
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
22
+ const dist = process.argv[2] ?? join(root, 'dist')
23
+ const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
24
+
25
+ /**
26
+ * Every entry point `package.json` promises, and where its declarations landed.
27
+ *
28
+ * The declarations come from `tsc` rather than from the JavaScript bundler, so they
29
+ * mirror the source tree: the root entry is one file, while `react` and `grammars`
30
+ * are directories with an `index.d.ts` in them. That extra level is the price of not
31
+ * depending on a declaration bundler that predates TypeScript 7, and it is encoded
32
+ * here so a change to the layout cannot pass unnoticed.
33
+ */
34
+ const ENTRIES = [
35
+ { name: 'index', declarations: 'types/index.d.ts' },
36
+ { name: 'react', declarations: 'types/react/index.d.ts' },
37
+ { name: 'grammars', declarations: 'types/grammars/index.d.ts' },
38
+ { name: 'styles', declarations: 'types/styles.d.ts' },
39
+ ]
40
+
41
+ /** The names the root entry must export. */
42
+ const CORE_EXPORTS = [
43
+ 'defineGrammar',
44
+ 'defineVocabulary',
45
+ 'defineCompletion',
46
+ 'resolveGrammar',
47
+ 'isResolvedGrammar',
48
+ 'scan',
49
+ 'inspect',
50
+ 'complete',
51
+ 'applyCompletion',
52
+ 'resolveHover',
53
+ 'diagnosticHover',
54
+ 'buildSegments',
55
+ 'fuzzyMatch',
56
+ 'rank',
57
+ 'highlightSegments',
58
+ 'lineAt',
59
+ 'lineStarts',
60
+ 'wordInfoAt',
61
+ 'clamp',
62
+ 'LiteArea',
63
+ 'createEditor',
64
+ 'TextMirror',
65
+ 'injectStyles',
66
+ 'scopeClass',
67
+ 'LITEAREA_STYLES',
68
+ 'readSelection',
69
+ 'writeSelection',
70
+ 'replaceThroughPipeline',
71
+ 'undoField',
72
+ 'redoField',
73
+ 'writeDocument',
74
+ 'canEditThroughPipeline',
75
+ ]
76
+
77
+ /** The names the grammars entry must export. */
78
+ const GRAMMAR_EXPORTS = [
79
+ 'dshFontQueryGrammar',
80
+ 'dshSentryStyleGrammar',
81
+ 'fontWeightWord',
82
+ 'fontFaceWeights',
83
+ 'quoteFontFamily',
84
+ 'FONT_WEIGHT_WORDS',
85
+ 'FONT_GENERIC_FAMILIES',
86
+ ]
87
+
88
+ /** The names the React entry must export. */
89
+ const REACT_EXPORTS = ['LiteAreaEditor']
90
+
91
+ /** Report a failure the way the rest of the house scripts do. */
92
+ function fail(message) {
93
+ console.error(`verify-package: ${message}`)
94
+ process.exit(1)
95
+ }
96
+
97
+ // ── every file the package promises is on disk ──────────────────────────────
98
+
99
+ for (const entry of ENTRIES) {
100
+ for (const extension of ['js', 'cjs']) {
101
+ const file = join(dist, `${entry.name}.${extension}`)
102
+ if (!existsSync(file)) fail(`${entry.name}.${extension} is missing — run \`npm run build\``)
103
+ if (statSync(file).size === 0) fail(`${entry.name}.${extension} is empty`)
104
+ }
105
+ const declarations = join(dist, entry.declarations)
106
+ if (!existsSync(declarations)) {
107
+ fail(`${entry.declarations} is missing — run \`npm run build\``)
108
+ }
109
+ if (statSync(declarations).size === 0) fail(`${entry.declarations} is empty`)
110
+ }
111
+
112
+ const css = join(dist, 'styles.css')
113
+ if (!existsSync(css)) fail('styles.css is missing — run `npm run build`')
114
+ const cssText = readFileSync(css, 'utf8')
115
+ /** The stylesheet without its comments, since the prose quotes selectors. */
116
+ const cssRules = cssText.replace(/\/\*[\s\S]*?\*\//g, '')
117
+ // The two classes that make the whole arrangement work. A stylesheet without them
118
+ // would ship, install cleanly, and paint nothing.
119
+ for (const selector of ['.litearea-input', '.litearea-layer', '.litearea-popup']) {
120
+ if (!cssText.includes(selector)) fail(`styles.css does not define ${selector}`)
121
+ }
122
+
123
+ // ── every scope the stylesheet names is actually painted ───────────────────
124
+ //
125
+ // The variables and the rules that spend them must agree, and they once did not: twenty-nine
126
+ // scope variables were declared with no rule that used one, so the tokenizer painted the right
127
+ // classes and every scope rendered in the inherited text colour. The editor had no syntax
128
+ // colouring at all, the stylesheet looked complete, and the DOM was correct — which is also why
129
+ // a browser check cannot catch it, because a colourless token is an ordinary thing to find.
130
+ const declaredScopes = new Set(
131
+ [...cssRules.matchAll(/--litearea-scope-([a-z-]+)\s*:/g)].map((match) => match[1]),
132
+ )
133
+ const paintedScopes = new Set(
134
+ [...cssRules.matchAll(/\.litearea-scope-([a-z-]+)\s*\{/g)].map((match) => match[1]),
135
+ )
136
+ if (paintedScopes.size < 15) {
137
+ fail(`styles.css has only ${String(paintedScopes.size)} scope colour rules; the palette is not wired up`)
138
+ }
139
+ const unpainted = [...declaredScopes].filter((scope) => !paintedScopes.has(scope))
140
+ if (unpainted.length > 0) {
141
+ fail(
142
+ `styles.css declares --litearea-scope-* for ${unpainted.join(', ')} with no rule that uses ` +
143
+ 'them, so those scopes would paint in the inherited colour',
144
+ )
145
+ }
146
+ for (const scope of paintedScopes) {
147
+ if (!cssRules.includes(`.litearea-scope-${scope} { color: var(--litearea-scope-${scope}); }`)) {
148
+ fail(`.litearea-scope-${scope} does not spend its own variable`)
149
+ }
150
+ }
151
+ // A squiggle is only a squiggle if it is drawn by the browser, and only a stylesheet says how.
152
+ for (const severity of ['error', 'warning']) {
153
+ if (!new RegExp(`\\.litearea-diag-${severity} \\{[^}]*text-decoration-style: wavy`).test(cssRules)) {
154
+ fail(`.litearea-diag-${severity} does not draw a wavy underline`)
155
+ }
156
+ }
157
+ if (cssRules.includes('.litearea-paint > span')) {
158
+ fail('.litearea-paint > span would outrank the severity classes and switch every squiggle off')
159
+ }
160
+
161
+ // ── the declarations describe the exports ──────────────────────────────────
162
+
163
+ const declarations = readFileSync(join(dist, 'types', 'index.d.ts'), 'utf8')
164
+ if (declarations.length < 500) {
165
+ fail(`types/index.d.ts looks truncated (${String(declarations.length)} bytes)`)
166
+ }
167
+
168
+ // ── the built modules really export what the declarations claim ────────────
169
+
170
+ const core = await import(pathToFileURL(join(dist, 'index.js')).href)
171
+ const missing = CORE_EXPORTS.filter((name) => core[name] === undefined)
172
+ if (missing.length > 0) fail(`index.js does not export: ${missing.join(', ')}`)
173
+
174
+ const grammars = await import(pathToFileURL(join(dist, 'grammars.js')).href)
175
+ const missingGrammars = GRAMMAR_EXPORTS.filter((name) => grammars[name] === undefined)
176
+ if (missingGrammars.length > 0) fail(`grammars.js does not export: ${missingGrammars.join(', ')}`)
177
+
178
+ const react = await import(pathToFileURL(join(dist, 'react.js')).href)
179
+ const missingReact = REACT_EXPORTS.filter((name) => react[name] === undefined)
180
+ if (missingReact.length > 0) fail(`react.js does not export: ${missingReact.join(', ')}`)
181
+
182
+ // ── the engine works from the built artifact, not just from source ─────────
183
+
184
+ const grammar = grammars.dshSentryStyleGrammar()
185
+ const inspection = core.inspect('running circle blue turn 3\nbogus circle', grammar)
186
+ assert.ok(inspection.tokens.length > 0, 'the built engine produced no tokens')
187
+ assert.ok(
188
+ inspection.diagnostics.some((diagnostic) => diagnostic.code === 'vocabulary:state'),
189
+ 'the built engine did not report the unknown state',
190
+ )
191
+ const completion = core.complete(inspection, grammar, {
192
+ text: 'running ',
193
+ caret: 'running '.length,
194
+ trigger: 'explicit',
195
+ })
196
+ assert.ok(completion !== undefined, 'the built engine offered no completion')
197
+ assert.ok(completion.rows.length > 0, 'the built engine offered an empty completion')
198
+
199
+ const segments = core.buildSegments(inspection.text, inspection)
200
+ assert.equal(
201
+ segments.map((segment) => segment.text).join(''),
202
+ inspection.text,
203
+ 'the built segmenter did not cover the document exactly',
204
+ )
205
+
206
+ // ── the package metadata points at real files ──────────────────────────────
207
+
208
+ for (const [key, value] of Object.entries(packageJson.exports ?? {})) {
209
+ if (typeof value === 'string') {
210
+ if (!existsSync(join(root, value))) fail(`exports["${key}"] points at ${value}, which does not exist`)
211
+ continue
212
+ }
213
+ for (const condition of ['types', 'import', 'require']) {
214
+ const target = value[condition]
215
+ if (target === undefined) fail(`exports["${key}"] has no ${condition} condition`)
216
+ if (!existsSync(join(root, target))) {
217
+ fail(`exports["${key}"].${condition} points at ${target}, which does not exist`)
218
+ }
219
+ }
220
+ }
221
+
222
+ // ── the links a reader will click resolve, and will be published ───────────
223
+ //
224
+ // A README is rendered on the package page by npm, where a relative link only works if the
225
+ // file it points at is in the tarball. `docs/grammar.md` was linked from the README and left
226
+ // out of `files`, which is a link that resolves in a checkout and 404s for everyone who
227
+ // installed the package — the kind of mistake that is invisible until a stranger hits it.
228
+
229
+ const publishedRoots = new Set((packageJson.files ?? []).map((entry) => entry.split('/')[0]))
230
+
231
+ for (const readme of ['README.md', 'README.zh.md']) {
232
+ const file = join(root, readme)
233
+ if (!existsSync(file)) continue
234
+ const text = readFileSync(file, 'utf8')
235
+ for (const match of text.matchAll(/\]\(([^)\s]+)\)/g)) {
236
+ const target = match[1]
237
+ if (target === undefined) continue
238
+ if (/^(https?:|mailto:|#)/.test(target)) continue
239
+ const [path] = target.split('#')
240
+ if (path === undefined || path === '') continue
241
+ if (!existsSync(join(root, path))) {
242
+ fail(`${readme} links to ${path}, which does not exist`)
243
+ }
244
+ const top = path.split('/')[0] ?? ''
245
+ if (top !== readme && !publishedRoots.has(top)) {
246
+ fail(`${readme} links to ${path}, which \`files\` does not publish — the link would 404 on npm`)
247
+ }
248
+ }
249
+ }
250
+
251
+ console.log(`verify-package: OK — ${String(ENTRIES.length)} entries, ${String(CORE_EXPORTS.length)} core exports`)
252
+ console.log('verify-package: the built engine tokenizes, diagnoses, completes, and segments')
253
+ console.log('verify-package: every relative README link resolves and is published')
@@ -0,0 +1,286 @@
1
+ // ─── complete: turning a caret into a list, and a list into an edit ─────────
2
+ //
3
+ // Two pure functions, deliberately separated, because they fail in different
4
+ // ways and only one of them is interesting to a user.
5
+ //
6
+ // `complete` answers "what could go here?" and returns rows. `applyCompletion`
7
+ // answers "what does the document become?" and returns text plus a caret. Keeping
8
+ // them apart is what makes the hard half — the interaction — testable in Node
9
+ // without a browser, and it is why the two bugs that make a completer feel broken
10
+ // have somewhere to live:
11
+ //
12
+ // A SOURCE that swaps mid-typing loses the thread. The list opens over a family
13
+ // name, the user types `=` and it becomes a value list, and the row they were
14
+ // looking at is gone. So once a source has answered, it keeps answering for as long
15
+ // as it stays eligible, and the priority order only decides which source OPENS the
16
+ // list.
17
+ //
18
+ // A range that does not grow leaves text behind. The range is recomputed from the
19
+ // caret on every filter, never reused: type `ru`, accept `running`, and the
20
+ // replacement must cover both letters. Holding the range resolved when the list
21
+ // opened replaces only the `r` and produces `running u` — a bug worth naming,
22
+ // because it is invisible in a test that only ever types one character before
23
+ // accepting.
24
+
25
+ import type {
26
+ Completion,
27
+ CompletionContext,
28
+ CompletionSource,
29
+ CompletionTrigger,
30
+ Grammar,
31
+ Range,
32
+ SuggestionItem,
33
+ } from './types.js'
34
+ import type { Inspection } from './inspect.js'
35
+ import { rank, type Ranked } from './rank.js'
36
+ import { clamp, lineAt, lineStarts, scopeAt, tokenBefore, tokensOnLine, wordInfoAt } from './text.js'
37
+ import { isResolvedGrammar, resolveGrammar, type ResolvedGrammar } from './scan.js'
38
+
39
+ /** The most rows a completion will carry, so a huge catalogue cannot stall a list. */
40
+ const DEFAULT_LIMIT = 100
41
+
42
+ /** What the editor knows when it asks for a list. */
43
+ export interface CompletionRequest {
44
+ text: string
45
+ caret: number
46
+ trigger: CompletionTrigger
47
+ /**
48
+ * The source that answered last time, when a list is already open.
49
+ *
50
+ * Passing it is what keeps a list stable while the user types: the same source answers
51
+ * again for as long as it stays eligible, so typing a character that makes a different
52
+ * source eligible does not swap the rows out from under the reader. Only the SOURCE is
53
+ * held — the range is recomputed from the caret every time, so it grows with the word.
54
+ */
55
+ previousSourceId?: string | undefined
56
+ /** The most rows to return. */
57
+ limit?: number
58
+ }
59
+
60
+ /**
61
+ * Resolve a completion for a caret.
62
+ *
63
+ * @param inspection - the current inspection of the document.
64
+ * @param grammar - the language.
65
+ * @param request - the caret, the trigger, and any list already open.
66
+ * @returns the range and the rows, or undefined when nothing applies.
67
+ */
68
+ export function complete<State>(
69
+ inspection: Inspection<State>,
70
+ grammar: Grammar<State> | ResolvedGrammar<State>,
71
+ request: CompletionRequest,
72
+ ): Completion | undefined {
73
+ const resolved = isResolvedGrammar(grammar) ? grammar : resolveGrammar(grammar)
74
+ const sources = resolved.grammar.compose
75
+ if (sources === undefined || sources.length === 0) return undefined
76
+
77
+ const text = request.text
78
+ const caret = clamp(request.caret, 0, text.length)
79
+ const context = completionContext(inspection, resolved, caret, request.trigger)
80
+
81
+ const eligible = sources.filter((source) => source.when === undefined || source.when(context))
82
+ if (eligible.length === 0) return undefined
83
+
84
+ // ── which source, and over what range ────────────────────────────────────
85
+ // Ordered by priority, and the range comes from the winner because two sources
86
+ // disagreeing about the range have no coherent shared answer.
87
+ // ── which source ────────────────────────────────────────────────────────
88
+ // Priority decides which source OPENS the list; after that the source that answered
89
+ // keeps answering while it remains eligible, so the rows do not change identity under
90
+ // the reader's hands. The range is not part of this: it is computed fresh below, from
91
+ // the caret as it is now.
92
+ const ordered = [...eligible].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
93
+ const reopening =
94
+ request.previousSourceId === undefined
95
+ ? undefined
96
+ : ordered.find((source) => source.id === request.previousSourceId)
97
+ const winner = reopening ?? ordered[0]
98
+ if (winner === undefined) return undefined
99
+ const range = resolveRange(winner, context)
100
+
101
+ // ── the rows ─────────────────────────────────────────────────────────────
102
+ const merged: SuggestionItem[] = [...winner.items(context)]
103
+ for (const source of ordered) {
104
+ if (source === winner || source.merge !== true) continue
105
+ merged.push(...source.items(context))
106
+ }
107
+ if (merged.length === 0) return undefined
108
+
109
+ // The needle is what the user has typed INSIDE the range, which is the only reading
110
+ // that stays meaningful when the range is an entry rather than a word. It is clamped
111
+ // to the range at both ends: a caret can sit past the range — after the trailing space
112
+ // of an entry, say — and slicing to the caret there would pull a separator into the
113
+ // needle and filter the list by a character the user did not mean to search for.
114
+ const needleFrom = clamp(range.from, 0, caret)
115
+ const needleTo = clamp(caret, needleFrom, Math.max(range.to, needleFrom))
116
+ const needle = text.slice(needleFrom, needleTo)
117
+ const ranked: Ranked<SuggestionItem>[] = rank(merged, needle, {
118
+ label: (item) => item.label,
119
+ ...(merged.some((item) => item.filterText !== undefined)
120
+ ? { filterText: (item: SuggestionItem) => item.filterText ?? item.label }
121
+ : {}),
122
+ ...(merged.some((item) => item.sortText !== undefined)
123
+ ? { sortText: (item: SuggestionItem) => item.sortText }
124
+ : {}),
125
+ })
126
+ const limit = request.limit ?? DEFAULT_LIMIT
127
+
128
+ return {
129
+ range,
130
+ rows: ranked.slice(0, limit).map((entry) => ({
131
+ item: entry.item,
132
+ score: entry.score,
133
+ indices: entry.indices,
134
+ })),
135
+ needle,
136
+ sourceId: winner.id,
137
+ }
138
+ }
139
+
140
+ /** Resolve a source's range, whether it declared one or computes it. */
141
+ function resolveRange<State>(
142
+ source: CompletionSource<State>,
143
+ context: CompletionContext<State>,
144
+ ): Range {
145
+ if (typeof source.range !== 'function') return source.range
146
+ const range = source.range(context)
147
+ return {
148
+ from: Math.min(range.from, range.to),
149
+ to: Math.max(range.from, range.to),
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Build everything a completion source may look at.
155
+ * @param inspection - the current inspection.
156
+ * @param resolved - the resolved grammar.
157
+ * @param caret - the caret offset.
158
+ * @param trigger - how the list came to be open.
159
+ * @returns the context.
160
+ */
161
+ function completionContext<State>(
162
+ inspection: Inspection<State>,
163
+ resolved: ResolvedGrammar<State>,
164
+ caret: number,
165
+ trigger: CompletionTrigger,
166
+ ): CompletionContext<State> {
167
+ const { text, tokens, diagnostics, state } = inspection
168
+ const line = lineAt(text, caret, lineStarts(text))
169
+ const lineTokens = tokensOnLine(tokens, line.number)
170
+ const word = wordInfoAt(text, caret, resolved.wordChars)
171
+ return {
172
+ text,
173
+ caret,
174
+ word,
175
+ line,
176
+ tokens,
177
+ diagnostics,
178
+ state,
179
+ scope: scopeAt(tokens, caret),
180
+ scopeBefore: tokenBefore(tokens, caret)?.scope,
181
+ // "First on line" means only whitespace precedes the caret, which is a question
182
+ // about the caret and not about the token under it: a caret in the indentation of
183
+ // a line that already has content is not first on that line. `lineTokens` is what
184
+ // tells the difference, so the tokens are read even though the answer looks like a
185
+ // string test.
186
+ firstOnLine: line.before.trim() === '' && !lineTokens.some((token) => token.to <= caret),
187
+ // "First word" additionally allows the rest of the word the caret is in, which is
188
+ // what keeps a line-head completion alive while the head is being typed.
189
+ firstWord: line.text.slice(0, Math.max(0, word.from - line.from)).trim() === '',
190
+ firstToken: lineTokens[0],
191
+ trigger,
192
+ }
193
+ }
194
+
195
+ /** An edit produced by accepting a row. */
196
+ export interface AppliedCompletion {
197
+ /** The document after the edit. */
198
+ text: string
199
+ /** Where the caret belongs in it. */
200
+ caret: number
201
+ /**
202
+ * What changed, in the NEW document. The editor uses it to scroll the result
203
+ * into view and to describe the edit to a host.
204
+ */
205
+ range: Range
206
+ /**
207
+ * The offset the edit starts at in the ORIGINAL document.
208
+ *
209
+ * Exposed alongside `insert` because an editor must write only the range that
210
+ * changed. Assigning the whole recomputed `text` would be simpler and would throw
211
+ * away the browser's undo history, which is the one thing this library exists to
212
+ * protect.
213
+ */
214
+ from: number
215
+ /** The offset the edit ends at in the original document. */
216
+ to: number
217
+ /** The text written between those two offsets. */
218
+ insert: string
219
+ }
220
+
221
+ /**
222
+ * Apply a chosen row to a document.
223
+ *
224
+ * The row decides three things: what is written, whether it is written over the
225
+ * range or in front of it, and how much text follows it. `append` is the
226
+ * grammar's business and not the engine's — inviting another entry with a `, `
227
+ * is a fact about font stacks, and an engine that appended one by default would
228
+ * be guessing about every other language.
229
+ *
230
+ * @param text - the document.
231
+ * @param range - the range the list was opened over.
232
+ * @param item - the chosen row.
233
+ * @returns the resulting text, caret, and the edit that produced them.
234
+ */
235
+ export function applyCompletion(text: string, range: Range, item: SuggestionItem): AppliedCompletion {
236
+ const from = clamp(Math.min(range.from, range.to), 0, text.length)
237
+ const to = clamp(Math.max(range.from, range.to), from, text.length)
238
+ const insert = item.insert ?? item.label
239
+ const append = item.append ?? ''
240
+ const offset = item.caretOffset ?? 0
241
+
242
+ if (item.mode === 'before') {
243
+ const head = text.slice(0, from)
244
+ // The whitespace in front of the existing entry belonged to the comma before
245
+ // it, so it is restored rather than doubled.
246
+ const gap = head !== '' && !/\s$/.test(head) ? ' ' : ''
247
+ const rest = text.slice(from).replace(/^\s+/, '')
248
+ const written = `${gap}${insert}${redundant(append, rest) ? '' : append}`
249
+ return {
250
+ text: `${head}${written}${rest}`,
251
+ caret: head.length + written.length + offset,
252
+ range: { from: head.length, to: head.length + written.length },
253
+ from,
254
+ to: from,
255
+ insert: written,
256
+ }
257
+ }
258
+
259
+ const head = text.slice(0, from)
260
+ const rest = text.slice(to)
261
+ const written = `${insert}${redundant(append, rest) ? '' : append}`
262
+ return {
263
+ text: `${head}${written}${rest}`,
264
+ caret: head.length + written.length + offset,
265
+ range: { from: head.length, to: head.length + written.length },
266
+ from,
267
+ to,
268
+ insert: written,
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Whether an append would only repeat what the document already says.
274
+ *
275
+ * Accepting `running` immediately before an existing space must not produce two,
276
+ * and accepting a font family immediately before an existing `, ` must not add
277
+ * another comma. Whitespace is compared as whitespace rather than as the exact
278
+ * characters, so a tab counts as the space an append was reaching for.
279
+ * @param append - the row's `append` text.
280
+ * @param rest - the document immediately after the range.
281
+ * @returns whether the append should be left out.
282
+ */
283
+ function redundant(append: string, rest: string): boolean {
284
+ if (append === '') return true
285
+ return append.trim() === '' ? /^\s/.test(rest) : rest.startsWith(append)
286
+ }