@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.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- package/src/styles.ts +529 -0
package/src/core/rank.ts
ADDED
|
@@ -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
|
+
}
|