@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,317 @@
1
+ // ─── rank: how a completion list is ordered and filtered ────────────────────
2
+ //
3
+ // Two jobs that are easy to conflate and must not be. "Which rows does this
4
+ // needle match?" is a filter, and a row either matches or does not. "In what
5
+ // order?" is a ranking, and it is where the feel of a completer actually lives:
6
+ // typing `inter` should put `Inter` above `Inter Tight` above `Plex Inter`, and
7
+ // nothing about the matching itself decides that.
8
+ //
9
+ // The ranking is expressed as a TIER plus a local score rather than as one
10
+ // arithmetic blend. A single blend is the classic mistake — add enough bonuses
11
+ // and a longer subsequence eventually outscores an exact match, so the obvious
12
+ // answer sinks below a coincidence — and it is also untestable, because no one
13
+ // can say what the number 47.3 means. Tiers make the precedence a list you can
14
+ // read and a set of cases you can assert:
15
+ //
16
+ // exact, case-sensitive `Inter` → `Inter`
17
+ // exact, any case `inter` → `Inter`
18
+ // prefix `inte` → `Inter`
19
+ // word-boundary `fm` → `Fira Mono`, `IPM` → `IBM Plex Mono`
20
+ // substring `lex` → `Plex Inter`
21
+ // subsequence `jbmo` → `JetBrains Mono`
22
+ //
23
+ // Nothing here knows anything about any language. A needle and a label go in, a
24
+ // score and the matched offsets come out, which is why this module has no import
25
+ // of the grammar contract at all.
26
+
27
+ /** The most of a score a local bonus may contribute, so a tier always dominates. */
28
+ const TIER_STRIDE = 1_000_000
29
+
30
+ /**
31
+ * The precedence tiers, highest first. The numbers are grades, not weights: a
32
+ * tier only has to be larger than the one below it.
33
+ */
34
+ const TIER = {
35
+ /** The needle is the label, in the same case. */
36
+ exact: 5,
37
+ /** The needle is the label, ignoring case. */
38
+ exactFold: 4,
39
+ /** The label starts with the needle. */
40
+ prefix: 3,
41
+ /**
42
+ * Every needle character either starts a word or continues one the match has
43
+ * already started. This is what makes initials work: `fm` finds `Fira Mono` and
44
+ * `IPM` finds `IBM Plex Mono`, without `IPM` being a substring of anything.
45
+ */
46
+ boundary: 2,
47
+ /** The needle appears as one unbroken run inside the label. */
48
+ substring: 1,
49
+ /** The needle's characters appear in order, with gaps. */
50
+ subsequence: 0,
51
+ } as const
52
+
53
+ /** Characters that make the character after them the start of a word. */
54
+ const SEPARATORS = /[\s\-_./:@()+[\]]/
55
+
56
+ /** A needle and the positions in the label it matched. */
57
+ export interface FuzzyMatch {
58
+ /** The ranking score. Higher is a better match. */
59
+ score: number
60
+ /** The matched offsets into the label, ascending, for showing what matched. */
61
+ indices: number[]
62
+ /** The tier the match reached, for tests and for explaining a ranking. */
63
+ tier: number
64
+ }
65
+
66
+ /**
67
+ * Whether a position in a label starts a word.
68
+ *
69
+ * Three things start one: the beginning, a separator, and a lower-to-upper
70
+ * transition. The third is what makes camelCase names searchable by their humps,
71
+ * and it is why `NotoSans` and `Noto Sans` behave the same way under a needle
72
+ * like `ns`.
73
+ * @param label - the text being searched.
74
+ * @param index - a position in it.
75
+ * @returns whether a word starts there.
76
+ */
77
+ export function isWordStart(label: string, index: number): boolean {
78
+ if (index <= 0) return index === 0
79
+ const previous = label.charAt(index - 1)
80
+ if (SEPARATORS.test(previous)) return true
81
+ const current = label.charAt(index)
82
+ return previous === previous.toLowerCase() && current !== current.toLowerCase()
83
+ }
84
+
85
+ /** Whether a needle wants case-sensitive matching: it does when it has any uppercase. */
86
+ function wantsExactCase(needle: string): boolean {
87
+ return needle !== needle.toLowerCase()
88
+ }
89
+
90
+ /** A local bonus for a matched character. Never large enough to cross a tier. */
91
+ function localScore(
92
+ label: string,
93
+ needle: string,
94
+ indices: readonly number[],
95
+ exactCase: boolean,
96
+ ): number {
97
+ let score = 0
98
+ for (let position = 0; position < indices.length; position += 1) {
99
+ const index = indices[position]
100
+ if (index === undefined) continue
101
+ if (index === 0) score += 24
102
+ else if (isWordStart(label, index)) score += 14
103
+ if (position > 0 && index === (indices[position - 1] ?? -2) + 1) score += 10
104
+ if (exactCase && label.charAt(index) === needle.charAt(position)) score += 4
105
+ }
106
+ const first = indices[0] ?? 0
107
+ const last = indices[indices.length - 1] ?? 0
108
+ // Gaps inside the matched span hurt, starting late hurts, and a short label
109
+ // beats a long one that matched equally well — the same tie-break a reader
110
+ // makes when two rows look alike.
111
+ score -= (last - first + 1 - needle.length) * 1.5
112
+ score -= first * 2
113
+ score -= label.length * 0.05
114
+ return score
115
+ }
116
+
117
+ /**
118
+ * Match a needle against a label.
119
+ *
120
+ * Case follows the needle: a needle with an uppercase character is matched
121
+ * case-sensitively, so `Inter` does not quietly match `inter`, while an
122
+ * all-lowercase needle matches anything. That is the same "smart case" a
123
+ * terminal has used for decades and the one VSCode applies.
124
+ * @param needle - what the user has typed.
125
+ * @param label - what is being searched.
126
+ * @returns the score and matched offsets, or undefined when the needle does not match.
127
+ */
128
+ export function fuzzyMatch(needle: string, label: string): FuzzyMatch | undefined {
129
+ if (needle === '') return { score: 0, indices: [], tier: 0 }
130
+ const exactCase = wantsExactCase(needle)
131
+ const haystack = exactCase ? label : label.toLowerCase()
132
+ const query = exactCase ? needle : needle.toLowerCase()
133
+
134
+ // ── the cheap wins, in the order they should be believed ────────────────
135
+ if (label === needle) {
136
+ return { score: TIER.exact * TIER_STRIDE + localScore(label, needle, range(needle.length), true), indices: range(needle.length), tier: TIER.exact }
137
+ }
138
+ if (label.toLowerCase() === query) {
139
+ const indices = range(needle.length)
140
+ return {
141
+ score: TIER.exactFold * TIER_STRIDE + localScore(label, needle, indices, exactCase),
142
+ indices,
143
+ tier: TIER.exactFold,
144
+ }
145
+ }
146
+ if (haystack.startsWith(query)) {
147
+ const indices = range(needle.length)
148
+ return {
149
+ score: TIER.prefix * TIER_STRIDE + localScore(label, needle, indices, exactCase),
150
+ indices,
151
+ tier: TIER.prefix,
152
+ }
153
+ }
154
+
155
+ // ── the candidates ──────────────────────────────────────────────────────
156
+ // A contiguous run is not automatically the best answer, which is why every
157
+ // candidate is scored and the best one wins rather than the first one found.
158
+ // `ns` against `notoSans` matches contiguously at the end and as two humps at the
159
+ // start; only the second is what the typist meant, and a scorer that returned on
160
+ // the first contiguous hit would never see it.
161
+ let best: FuzzyMatch | undefined
162
+ const consider = (candidate: FuzzyMatch): void => {
163
+ if (best === undefined || candidate.score > best.score) best = candidate
164
+ }
165
+
166
+ for (let at = haystack.indexOf(query); at >= 0; at = haystack.indexOf(query, at + 1)) {
167
+ const indices = range(needle.length, at)
168
+ const tier = isWordStart(label, at) ? TIER.boundary : TIER.substring
169
+ consider({ score: tier * TIER_STRIDE + localScore(label, needle, indices, exactCase), indices, tier })
170
+ }
171
+
172
+ // Every position the first character could have started at is tried, rather than
173
+ // only the first one. Greedy-from-the-first-hit looks right and is wrong: `IPM`
174
+ // against `IBM Plex Mono` would take the `P` of `Plex` and then fail to find an
175
+ // `M`, when the `M` of `Mono` was available all along.
176
+ for (let start = 0; start < haystack.length; start += 1) {
177
+ if (haystack.charAt(start) !== query.charAt(0)) continue
178
+ const indices: number[] = [start]
179
+ let cursor = start + 1
180
+ let complete = true
181
+ for (let position = 1; position < query.length; position += 1) {
182
+ const found = haystack.indexOf(query.charAt(position), cursor)
183
+ if (found < 0) {
184
+ complete = false
185
+ break
186
+ }
187
+ indices.push(found)
188
+ cursor = found + 1
189
+ }
190
+ if (!complete) continue
191
+ // A match whose characters all start or continue a word reads as initials and
192
+ // outranks one that merely happens to appear in order.
193
+ const tier = indicesAreBoundaryish(label, indices) ? TIER.boundary : TIER.subsequence
194
+ consider({ score: tier * TIER_STRIDE + localScore(label, needle, indices, exactCase), indices, tier })
195
+ }
196
+ return best
197
+ }
198
+
199
+ /**
200
+ * Whether every matched position either starts a word or continues the run before it.
201
+ * @param label - the searched text.
202
+ * @param indices - the matched offsets.
203
+ * @returns whether the match reads as initials rather than as scattered letters.
204
+ */
205
+ function indicesAreBoundaryish(label: string, indices: readonly number[]): boolean {
206
+ for (let position = 0; position < indices.length; position += 1) {
207
+ const index = indices[position]
208
+ if (index === undefined) continue
209
+ if (isWordStart(label, index)) continue
210
+ if (position > 0 && index === (indices[position - 1] ?? -2) + 1) continue
211
+ return false
212
+ }
213
+ return true
214
+ }
215
+
216
+ /** `[from, from + count)`, as an array. */
217
+ function range(count: number, from = 0): number[] {
218
+ const out: number[] = []
219
+ for (let index = 0; index < count; index += 1) out.push(from + index)
220
+ return out
221
+ }
222
+
223
+ /** One row of a ranked list, with the evidence for its position. */
224
+ export interface Ranked<T> {
225
+ item: T
226
+ score: number
227
+ /** The offsets in the label that matched, for emphasising them in the list. */
228
+ indices: number[]
229
+ }
230
+
231
+ /** How to read a row while ranking it. */
232
+ export interface RankOptions<T> {
233
+ /** The text shown to the user. */
234
+ label(item: T): string
235
+ /** The text matched against the needle, when it differs from the label. */
236
+ filterText?(item: T): string
237
+ /**
238
+ * The primary sort key, as VSCode's `sortText` is: rows are ordered by it
239
+ * first and by match score only within an equal key. It is how a grammar puts
240
+ * a whole group on top — the value already in effect, say — without pretending
241
+ * its label starts with a `0`.
242
+ */
243
+ sortText?(item: T): string | undefined
244
+ }
245
+
246
+ /**
247
+ * Filter and order rows for a needle.
248
+ *
249
+ * An empty needle keeps every row in the order the grammar declared, which is
250
+ * what Ctrl+Space should show: the whole vocabulary, grouped the way the grammar
251
+ * thinks about it, not alphabetised by a scorer that has nothing to go on.
252
+ * @param items - the candidate rows.
253
+ * @param needle - what the user has typed.
254
+ * @param options - how to read a row.
255
+ * @returns the matching rows, best first.
256
+ */
257
+ export function rank<T>(items: readonly T[], needle: string, options: RankOptions<T>): Ranked<T>[] {
258
+ const out: Ranked<T>[] = []
259
+ for (const item of items) {
260
+ if (needle === '') {
261
+ out.push({ item, score: 0, indices: [] })
262
+ continue
263
+ }
264
+ const label = options.label(item)
265
+ const subject = options.filterText?.(item) ?? label
266
+ const match = fuzzyMatch(needle, subject)
267
+ if (match === undefined) continue
268
+ // The offsets are only meaningful against the label, so a row matched on
269
+ // different text is ranked without an emphasis rather than with the wrong one.
270
+ out.push({ item, score: match.score, indices: subject === label ? match.indices : [] })
271
+ }
272
+
273
+ if (options.sortText === undefined) {
274
+ // Sorting by score alone, on a stable sort, keeps the order the grammar
275
+ // declared for everything the needle does not separate. That is load-bearing:
276
+ // an empty needle must show a catalogue in catalogue order, and an alphabetical
277
+ // tie-break would quietly rearrange a list the grammar had already ranked.
278
+ out.sort((left, right) => right.score - left.score)
279
+ return out
280
+ }
281
+ return out.sort((left, right) => {
282
+ const leftKey = options.sortText?.(left.item) ?? options.label(left.item)
283
+ const rightKey = options.sortText?.(right.item) ?? options.label(right.item)
284
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : right.score - left.score
285
+ })
286
+ }
287
+
288
+ /**
289
+ * Split a label around the characters a needle matched, for the list to emphasise.
290
+ *
291
+ * Returned as segments rather than as a marked-up string because the caller is
292
+ * building DOM nodes, not HTML: a font family called `<b>` must be shown as
293
+ * `<b>`, and a completer that returns markup has to escape what it was given or
294
+ * it is wrong about a real font.
295
+ * @param label - the row's text.
296
+ * @param indices - the matched offsets, ascending.
297
+ * @returns alternating plain and matched segments, in order.
298
+ */
299
+ export function highlightSegments(
300
+ label: string,
301
+ indices: readonly number[],
302
+ ): Array<{ text: string; matched: boolean }> {
303
+ if (indices.length === 0) return label === '' ? [] : [{ text: label, matched: false }]
304
+ const marked = new Set(indices)
305
+ const segments: Array<{ text: string; matched: boolean }> = []
306
+ let current: { text: string; matched: boolean } | undefined
307
+ for (let index = 0; index < label.length; index += 1) {
308
+ const matched = marked.has(index)
309
+ if (current === undefined || current.matched !== matched) {
310
+ if (current !== undefined) segments.push(current)
311
+ current = { text: '', matched }
312
+ }
313
+ current.text += label.charAt(index)
314
+ }
315
+ if (current !== undefined) segments.push(current)
316
+ return segments
317
+ }