@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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// ─── segments: one pass over the document, for the painter ──────────────────
|
|
2
|
+
//
|
|
3
|
+
// The painted layer has to cover every character exactly once, in order, and it
|
|
4
|
+
// has to do it while three independent things want to say something about those
|
|
5
|
+
// characters: the scope that colours them, the decoration that marks them, and the
|
|
6
|
+
// diagnostic that underlines them.
|
|
7
|
+
//
|
|
8
|
+
// The naive way is three layers, and it does not work. Each would have to position
|
|
9
|
+
// its spans itself, so each would need its own idea of where a range starts, and a
|
|
10
|
+
// span positioned by measuring is a span that drifts as soon as the font, the
|
|
11
|
+
// wrapping, or the padding is even slightly different from what was measured.
|
|
12
|
+
//
|
|
13
|
+
// So they are merged into ONE stream of adjacent segments, and the renderer emits
|
|
14
|
+
// them back to back with no positioning at all: the characters lay out where the
|
|
15
|
+
// characters lay out, and the colours follow. Alignment stops being something the
|
|
16
|
+
// code maintains and becomes something the layout cannot get wrong.
|
|
17
|
+
//
|
|
18
|
+
// This is pure — text and ranges in, segments out — which is why the trickiest
|
|
19
|
+
// part of the painter is testable without a browser.
|
|
20
|
+
|
|
21
|
+
import type { Decoration, Diagnostic, Severity, Token } from './types.js'
|
|
22
|
+
|
|
23
|
+
/** One run of characters that all share the same presentation. */
|
|
24
|
+
export interface PaintSegment {
|
|
25
|
+
from: number
|
|
26
|
+
to: number
|
|
27
|
+
/** The characters themselves, so the painter never re-slices. */
|
|
28
|
+
text: string
|
|
29
|
+
/** The scope that colours them. */
|
|
30
|
+
scope: string
|
|
31
|
+
/** The decoration kinds that mark them, in declaration order. */
|
|
32
|
+
decorations: string[]
|
|
33
|
+
/** The loudest diagnostic covering them, when one does. */
|
|
34
|
+
severity: Severity | undefined
|
|
35
|
+
/** A hover title donated by a decoration, when it has one. */
|
|
36
|
+
title: string | undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a segment builder needs: the three range lists and nothing else. */
|
|
40
|
+
export interface SegmentInput {
|
|
41
|
+
tokens: readonly Token[]
|
|
42
|
+
decorations: readonly Decoration[]
|
|
43
|
+
diagnostics: readonly Diagnostic[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The order severities outrank each other.
|
|
48
|
+
*
|
|
49
|
+
* One segment shows one squiggle, because two underlines on the same characters
|
|
50
|
+
* only make a messier line rather than a more informative one. The loudest wins,
|
|
51
|
+
* which is also the one a reader needs to see first.
|
|
52
|
+
*/
|
|
53
|
+
const SEVERITY_RANK: Record<Severity, number> = { error: 4, warning: 3, info: 2, hint: 1 }
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge a scope, a decoration, and a diagnostic into adjacent segments.
|
|
57
|
+
*
|
|
58
|
+
* @param text - the document the ranges refer to.
|
|
59
|
+
* @param input - the three range lists.
|
|
60
|
+
* @param fallbackScope - the scope for characters no token covers.
|
|
61
|
+
* @returns the segments, in order, covering the whole document.
|
|
62
|
+
*/
|
|
63
|
+
export function buildSegments(
|
|
64
|
+
text: string,
|
|
65
|
+
input: SegmentInput,
|
|
66
|
+
fallbackScope = 'text',
|
|
67
|
+
): PaintSegment[] {
|
|
68
|
+
const length = text.length
|
|
69
|
+
if (length === 0) return []
|
|
70
|
+
|
|
71
|
+
// ── the cut points ────────────────────────────────────────────────────────
|
|
72
|
+
// Every range boundary is a potential change of presentation, so they are the
|
|
73
|
+
// only places a segment can begin or end.
|
|
74
|
+
const cuts = new Set<number>([0, length])
|
|
75
|
+
for (const token of input.tokens) {
|
|
76
|
+
cuts.add(Math.max(0, Math.min(token.from, length)))
|
|
77
|
+
cuts.add(Math.max(0, Math.min(token.to, length)))
|
|
78
|
+
}
|
|
79
|
+
for (const decoration of input.decorations) {
|
|
80
|
+
cuts.add(Math.max(0, Math.min(decoration.from, length)))
|
|
81
|
+
cuts.add(Math.max(0, Math.min(decoration.to, length)))
|
|
82
|
+
}
|
|
83
|
+
for (const diagnostic of input.diagnostics) {
|
|
84
|
+
cuts.add(Math.max(0, Math.min(diagnostic.from, length)))
|
|
85
|
+
cuts.add(Math.max(0, Math.min(diagnostic.to, length)))
|
|
86
|
+
}
|
|
87
|
+
const bounds = [...cuts].sort((left, right) => left - right)
|
|
88
|
+
|
|
89
|
+
const segments: PaintSegment[] = []
|
|
90
|
+
for (let index = 0; index < bounds.length - 1; index += 1) {
|
|
91
|
+
const from = bounds[index] ?? 0
|
|
92
|
+
const to = bounds[index + 1] ?? 0
|
|
93
|
+
if (to <= from) continue
|
|
94
|
+
|
|
95
|
+
const scope = coverScope(input.tokens, from, fallbackScope)
|
|
96
|
+
const decorations = coverDecorations(input.decorations, from, to)
|
|
97
|
+
const severity = coverSeverity(input.diagnostics, from, to)
|
|
98
|
+
const title = input.decorations.find(
|
|
99
|
+
(decoration) => decoration.title !== undefined && from >= decoration.from && to <= decoration.to,
|
|
100
|
+
)?.title
|
|
101
|
+
|
|
102
|
+
const last = segments[segments.length - 1]
|
|
103
|
+
if (
|
|
104
|
+
last !== undefined &&
|
|
105
|
+
last.scope === scope &&
|
|
106
|
+
last.severity === severity &&
|
|
107
|
+
last.title === title &&
|
|
108
|
+
sameList(last.decorations, decorations)
|
|
109
|
+
) {
|
|
110
|
+
// Extending the previous segment keeps the DOM small without changing what
|
|
111
|
+
// is drawn, which is the only reason the merge exists.
|
|
112
|
+
last.to = to
|
|
113
|
+
last.text = text.slice(last.from, to)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
segments.push({ from, to, text: text.slice(from, to), scope, decorations, severity, title })
|
|
117
|
+
}
|
|
118
|
+
return segments
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The scope of the token covering a position, or the fallback. */
|
|
122
|
+
function coverScope(tokens: readonly Token[], offset: number, fallback: string): string {
|
|
123
|
+
for (const token of tokens) {
|
|
124
|
+
if (token.from > offset) break
|
|
125
|
+
if (offset >= token.from && offset < token.to) return token.scope
|
|
126
|
+
}
|
|
127
|
+
return fallback
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The decoration kinds covering a range, in the order they were declared. */
|
|
131
|
+
function coverDecorations(decorations: readonly Decoration[], from: number, to: number): string[] {
|
|
132
|
+
const kinds: string[] = []
|
|
133
|
+
for (const decoration of decorations) {
|
|
134
|
+
if (decoration.from <= from && to <= decoration.to) kinds.push(decoration.kind)
|
|
135
|
+
}
|
|
136
|
+
return kinds
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The loudest diagnostic severity covering a range. */
|
|
140
|
+
function coverSeverity(
|
|
141
|
+
diagnostics: readonly Diagnostic[],
|
|
142
|
+
from: number,
|
|
143
|
+
to: number,
|
|
144
|
+
): Severity | undefined {
|
|
145
|
+
let loudest: Severity | undefined
|
|
146
|
+
for (const diagnostic of diagnostics) {
|
|
147
|
+
if (diagnostic.from > from || diagnostic.to < to) continue
|
|
148
|
+
if (loudest === undefined || SEVERITY_RANK[diagnostic.severity] > SEVERITY_RANK[loudest]) {
|
|
149
|
+
loudest = diagnostic.severity
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return loudest
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether two short lists say the same thing, in the same order. */
|
|
156
|
+
function sameList(left: readonly string[], right: readonly string[]): boolean {
|
|
157
|
+
if (left.length !== right.length) return false
|
|
158
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
159
|
+
if (left[index] !== right[index]) return false
|
|
160
|
+
}
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The class attribute for a segment.
|
|
166
|
+
*
|
|
167
|
+
* Order matters for a stylesheet author's sanity rather than for the cascade: the
|
|
168
|
+
* scope comes first so a rule can target `scope + mark` if it ever needs to.
|
|
169
|
+
* @param segment - the segment.
|
|
170
|
+
* @param scopeClass - how a scope becomes a class.
|
|
171
|
+
* @param decorationClass - how a decoration kind becomes a class.
|
|
172
|
+
* @param severityClass - how a severity becomes a class.
|
|
173
|
+
* @returns the class list.
|
|
174
|
+
*/
|
|
175
|
+
export function segmentClasses(
|
|
176
|
+
segment: PaintSegment,
|
|
177
|
+
scopeClass: (scope: string) => string,
|
|
178
|
+
decorationClass: (kind: string) => string,
|
|
179
|
+
severityClass: (severity: string) => string,
|
|
180
|
+
): string[] {
|
|
181
|
+
const classes = [scopeClass(segment.scope)]
|
|
182
|
+
for (const kind of segment.decorations) classes.push(decorationClass(kind))
|
|
183
|
+
if (segment.severity !== undefined) classes.push(severityClass(segment.severity))
|
|
184
|
+
return classes
|
|
185
|
+
}
|
package/src/core/text.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// ─── text: offsets, lines, and words ────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// The engine counts in character offsets and nothing else. Not rows, not
|
|
4
|
+
// columns, not pixels: every position that crosses a module boundary in litearea
|
|
5
|
+
// is an offset into the document string, so there is exactly one coordinate
|
|
6
|
+
// system to get wrong instead of three that must be kept in step.
|
|
7
|
+
//
|
|
8
|
+
// Pixels enter only in `src/dom/`, only to place the popup, and never travel
|
|
9
|
+
// back inward. That boundary is the fix for a whole family of bugs in the
|
|
10
|
+
// editors this library replaces, where a caret was tracked as a character index
|
|
11
|
+
// in one place and read back as a screen position in another.
|
|
12
|
+
|
|
13
|
+
import type { LineInfo, Range, Token, WordInfo } from './types.js'
|
|
14
|
+
|
|
15
|
+
/** Keeps a number inside a range, which every caller here needs and none should repeat. */
|
|
16
|
+
export function clamp(value: number, low: number, high: number): number {
|
|
17
|
+
return value < low ? low : value > high ? high : value
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Whether a value is a usable offset: a finite, non-negative integer. */
|
|
21
|
+
export function isOffset(value: unknown): value is number {
|
|
22
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The offset each line starts at, in order. The first entry is always `0`.
|
|
27
|
+
*
|
|
28
|
+
* `\r\n`, `\n`, and a lone `\r` all end a line, because this text is typed by
|
|
29
|
+
* hand into a textarea and a paste from a Windows editor must not read as one
|
|
30
|
+
* long line. The terminator itself belongs to no line: a line's text excludes it
|
|
31
|
+
* so that column arithmetic never has to reason about a stray carriage return.
|
|
32
|
+
* @param source - the document.
|
|
33
|
+
* @returns one offset per line, ascending.
|
|
34
|
+
*/
|
|
35
|
+
export function lineStarts(source: string): number[] {
|
|
36
|
+
const starts = [0]
|
|
37
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
38
|
+
const code = source.charCodeAt(index)
|
|
39
|
+
if (code === 10) {
|
|
40
|
+
starts.push(index + 1)
|
|
41
|
+
} else if (code === 13) {
|
|
42
|
+
if (source.charCodeAt(index + 1) === 10) index += 1
|
|
43
|
+
starts.push(index + 1)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return starts
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The index of the line an offset falls on.
|
|
51
|
+
* @param starts - the result of {@link lineStarts}.
|
|
52
|
+
* @param offset - a character offset.
|
|
53
|
+
* @returns a zero-based line index.
|
|
54
|
+
*/
|
|
55
|
+
export function lineIndexAt(starts: readonly number[], offset: number): number {
|
|
56
|
+
let low = 0
|
|
57
|
+
let high = starts.length - 1
|
|
58
|
+
while (low < high) {
|
|
59
|
+
const middle = Math.ceil((low + high) / 2)
|
|
60
|
+
if ((starts[middle] ?? 0) <= offset) low = middle
|
|
61
|
+
else high = middle - 1
|
|
62
|
+
}
|
|
63
|
+
return low
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The line an offset falls on, with the offset expressed inside it.
|
|
68
|
+
*
|
|
69
|
+
* `before` and `after` are the line's own text split at the caret, which is what
|
|
70
|
+
* completion and the indentation rules actually want; handing them out here
|
|
71
|
+
* means neither has to re-derive the split and get it subtly different.
|
|
72
|
+
* @param source - the document.
|
|
73
|
+
* @param offset - a character offset; clamped into the document.
|
|
74
|
+
* @param starts - the result of {@link lineStarts}, when the caller already has it.
|
|
75
|
+
* @returns the line, its bounds, and the caret's place in it.
|
|
76
|
+
*/
|
|
77
|
+
export function lineAt(source: string, offset: number, starts?: readonly number[]): LineInfo {
|
|
78
|
+
const position = clamp(offset, 0, source.length)
|
|
79
|
+
const boundaries = starts ?? lineStarts(source)
|
|
80
|
+
const index = lineIndexAt(boundaries, position)
|
|
81
|
+
const from = boundaries[index] ?? 0
|
|
82
|
+
const rawTo = boundaries[index + 1] ?? source.length
|
|
83
|
+
// The next line's start sits after the terminator, so the last character of
|
|
84
|
+
// the raw slice is the terminator itself — one character, or two for `\r\n`.
|
|
85
|
+
let to = rawTo
|
|
86
|
+
while (to > from) {
|
|
87
|
+
const code = source.charCodeAt(to - 1)
|
|
88
|
+
if (code === 10 || code === 13) to -= 1
|
|
89
|
+
else break
|
|
90
|
+
}
|
|
91
|
+
const text = source.slice(from, to)
|
|
92
|
+
const column = clamp(position - from, 0, text.length)
|
|
93
|
+
return {
|
|
94
|
+
from,
|
|
95
|
+
to,
|
|
96
|
+
text,
|
|
97
|
+
number: index,
|
|
98
|
+
column,
|
|
99
|
+
before: text.slice(0, column),
|
|
100
|
+
after: text.slice(column),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether a character is part of a word.
|
|
106
|
+
*
|
|
107
|
+
* The predicate is supplied by the grammar because it decides three things at
|
|
108
|
+
* once — what a completion replaces, what a diagnostic underlines, and where a
|
|
109
|
+
* double click puts the selection — and a language whose names contain a hyphen
|
|
110
|
+
* must say so or every completion will replace one segment of a name instead of
|
|
111
|
+
* the name.
|
|
112
|
+
* @param char - one character, or the empty string at the end of the document.
|
|
113
|
+
* @param wordChars - a single-character test.
|
|
114
|
+
* @returns whether the character continues a word.
|
|
115
|
+
*/
|
|
116
|
+
export function isWordChar(char: string, wordChars: RegExp): boolean {
|
|
117
|
+
return char !== '' && wordChars.test(char)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The word at an offset.
|
|
122
|
+
*
|
|
123
|
+
* The word is found by expanding in BOTH directions from the offset rather than
|
|
124
|
+
* by requiring the offset to be strictly inside a word. Completion needs the
|
|
125
|
+
* second reading: a caret sitting at `Geist|` is at the end of the word, and the
|
|
126
|
+
* word it is completing is still `Geist` — a lookup that insisted the offset be
|
|
127
|
+
* interior would find nothing at exactly the moment the user wants a list.
|
|
128
|
+
* @param source - the document.
|
|
129
|
+
* @param offset - a character offset; clamped into the document.
|
|
130
|
+
* @param wordChars - a single-character test.
|
|
131
|
+
* @returns the word, its range, and the text either side of the caret.
|
|
132
|
+
*/
|
|
133
|
+
export function wordInfoAt(source: string, offset: number, wordChars: RegExp): WordInfo {
|
|
134
|
+
const position = clamp(offset, 0, source.length)
|
|
135
|
+
let from = position
|
|
136
|
+
let to = position
|
|
137
|
+
while (from > 0 && isWordChar(source.charAt(from - 1), wordChars)) from -= 1
|
|
138
|
+
while (to < source.length && isWordChar(source.charAt(to), wordChars)) to += 1
|
|
139
|
+
const text = source.slice(from, to)
|
|
140
|
+
const column = position - from
|
|
141
|
+
return {
|
|
142
|
+
from,
|
|
143
|
+
to,
|
|
144
|
+
text,
|
|
145
|
+
prefix: text.slice(0, column),
|
|
146
|
+
suffix: text.slice(column),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Whether a range is empty. */
|
|
151
|
+
export function isEmptyRange(range: Range): boolean {
|
|
152
|
+
return range.to <= range.from
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether an offset lies inside a range, with `to` exclusive. */
|
|
156
|
+
export function containsOffset(range: Range, offset: number): boolean {
|
|
157
|
+
return offset >= range.from && offset < range.to
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── reading the token stream ───────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The token covering an offset.
|
|
164
|
+
*
|
|
165
|
+
* Zero-length tokens are skipped, so a caret at a boundary resolves to the token
|
|
166
|
+
* it is *inside* rather than to an empty marker sitting on the seam.
|
|
167
|
+
* @param tokens - the scanned tokens, ascending.
|
|
168
|
+
* @param offset - a character offset.
|
|
169
|
+
* @returns the covering token, or undefined in whitespace.
|
|
170
|
+
*/
|
|
171
|
+
export function tokenAt(tokens: readonly Token[], offset: number): Token | undefined {
|
|
172
|
+
for (const token of tokens) {
|
|
173
|
+
if (token.from <= offset && offset < token.to) return token
|
|
174
|
+
if (token.from > offset) break
|
|
175
|
+
}
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The scope painted at an offset, when there is one.
|
|
181
|
+
* @param tokens - the scanned tokens, ascending.
|
|
182
|
+
* @param offset - a character offset.
|
|
183
|
+
* @returns the scope, or undefined in whitespace.
|
|
184
|
+
*/
|
|
185
|
+
export function scopeAt(tokens: readonly Token[], offset: number): string | undefined {
|
|
186
|
+
return tokenAt(tokens, offset)?.scope
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The nearest token that ends at or before an offset, skipping whitespace.
|
|
191
|
+
*
|
|
192
|
+
* Whitespace is skipped because every caller is asking a question about
|
|
193
|
+
* structure — "which slot is this word in?" — and a space is never an answer.
|
|
194
|
+
* @param tokens - the scanned tokens, ascending.
|
|
195
|
+
* @param offset - a character offset.
|
|
196
|
+
* @returns the token, or undefined at the start of the document.
|
|
197
|
+
*/
|
|
198
|
+
export function tokenBefore(tokens: readonly Token[], offset: number): Token | undefined {
|
|
199
|
+
let found
|
|
200
|
+
for (const token of tokens) {
|
|
201
|
+
if (token.to > offset) break
|
|
202
|
+
if (token.text.trim() !== '') found = token
|
|
203
|
+
}
|
|
204
|
+
return found
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The nearest token that starts at or after an offset, skipping whitespace.
|
|
209
|
+
* @param tokens - the scanned tokens, ascending.
|
|
210
|
+
* @param offset - a character offset.
|
|
211
|
+
* @returns the token, or undefined at the end of the document.
|
|
212
|
+
*/
|
|
213
|
+
export function tokenAfter(tokens: readonly Token[], offset: number): Token | undefined {
|
|
214
|
+
for (const token of tokens) {
|
|
215
|
+
if (token.to <= offset) continue
|
|
216
|
+
if (token.text.trim() !== '') return token
|
|
217
|
+
}
|
|
218
|
+
return undefined
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The non-whitespace tokens on one line, in order.
|
|
223
|
+
*
|
|
224
|
+
* Completion and diagnostics both ask "what is on this line so far", and both
|
|
225
|
+
* must ignore the indentation and the gaps between words. Returning the tokens
|
|
226
|
+
* rather than the words means a grammar can ask what they *were* (their scopes)
|
|
227
|
+
* and not only what they said.
|
|
228
|
+
* @param tokens - the scanned tokens, ascending.
|
|
229
|
+
* @param line - a line number, or a line record.
|
|
230
|
+
* @returns the tokens on that line.
|
|
231
|
+
*/
|
|
232
|
+
export function tokensOnLine(
|
|
233
|
+
tokens: readonly Token[],
|
|
234
|
+
line: number | LineInfo,
|
|
235
|
+
): Token[] {
|
|
236
|
+
const number = typeof line === 'number' ? line : line.number
|
|
237
|
+
return tokens.filter((token) => token.line === number && token.text.trim() !== '')
|
|
238
|
+
}
|