@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,71 @@
|
|
|
1
|
+
// ─── format: the small string helpers the messages share ────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Kept apart from the vocabulary so that a grammar can raise its own diagnostics
|
|
4
|
+
// with the same phrasing the built-in ones use, and so that a translated host can
|
|
5
|
+
// reuse the substitution instead of writing a second one.
|
|
6
|
+
|
|
7
|
+
/** The most members a message will name before it stops counting. */
|
|
8
|
+
const DEFAULT_LIST_LIMIT = 12
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Substitute `{name}` placeholders in a template.
|
|
12
|
+
*
|
|
13
|
+
* A placeholder with no matching key is left exactly as it was rather than
|
|
14
|
+
* blanked: a visible `{allowed}` in a message is a bug report, and an empty gap
|
|
15
|
+
* is a mystery.
|
|
16
|
+
* @param template - the template, with `{name}` placeholders.
|
|
17
|
+
* @param values - the substitutions.
|
|
18
|
+
* @returns the filled text.
|
|
19
|
+
*/
|
|
20
|
+
export function fillTemplate(template: string, values: Readonly<Record<string, string>>): string {
|
|
21
|
+
return String(template).replace(/\{(\w+)\}/g, (match, key: string) =>
|
|
22
|
+
Object.hasOwn(values, key) ? String(values[key]) : match,
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A readable list: `a`, `a or b`, `a, b, or c`.
|
|
28
|
+
*
|
|
29
|
+
* The conjunction is a parameter because the same list reads differently as
|
|
30
|
+
* "expected one of X, Y, or Z" and as "X, Y, and Z are installed".
|
|
31
|
+
* @param items - the members.
|
|
32
|
+
* @param options - `conjunction` (`'or'` by default) and `limit`, the most
|
|
33
|
+
* members to name before summarising the rest as `… and N more`.
|
|
34
|
+
* @returns the phrase.
|
|
35
|
+
*/
|
|
36
|
+
export function listPhrase(
|
|
37
|
+
items: readonly string[],
|
|
38
|
+
options?: { conjunction?: string; limit?: number },
|
|
39
|
+
): string {
|
|
40
|
+
const conjunction = options?.conjunction ?? 'or'
|
|
41
|
+
const limit = options?.limit ?? DEFAULT_LIST_LIMIT
|
|
42
|
+
const shown = items.slice(0, limit)
|
|
43
|
+
const rest = items.length - shown.length
|
|
44
|
+
if (shown.length === 0) return ''
|
|
45
|
+
if (shown.length === 1) {
|
|
46
|
+
return rest > 0 ? `${String(shown[0])} and ${String(rest)} more` : String(shown[0])
|
|
47
|
+
}
|
|
48
|
+
// Two members take no comma before the conjunction. "circle, or square" is not how
|
|
49
|
+
// anyone writes it, and every diagnostic that lists a two-member vocabulary would
|
|
50
|
+
// have said it that way.
|
|
51
|
+
if (shown.length === 2) {
|
|
52
|
+
const pair = `${String(shown[0])} ${conjunction} ${String(shown[1])}`
|
|
53
|
+
return rest > 0 ? `${pair}, and ${String(rest)} more` : pair
|
|
54
|
+
}
|
|
55
|
+
const head = shown.slice(0, -1).join(', ')
|
|
56
|
+
const tail = shown[shown.length - 1]
|
|
57
|
+
const phrase = `${head}, ${conjunction} ${String(tail)}`
|
|
58
|
+
return rest > 0 ? `${phrase}, and ${String(rest)} more` : phrase
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Collapse the whitespace in a snippet of user text so it can be quoted inside a
|
|
63
|
+
* one-line message.
|
|
64
|
+
* @param text - the text.
|
|
65
|
+
* @param limit - the longest result, ellipsised beyond it.
|
|
66
|
+
* @returns the excerpt.
|
|
67
|
+
*/
|
|
68
|
+
export function excerpt(text: string, limit = 24): string {
|
|
69
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
70
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`
|
|
71
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// ─── grammar: the two identity functions authors actually want ──────────────
|
|
2
|
+
//
|
|
3
|
+
// Neither does anything at runtime, and that is not a joke at the reader's
|
|
4
|
+
// expense: `defineGrammar` exists so `State` is inferred from `analyze` and then
|
|
5
|
+
// enforced everywhere else. Written as a bare object literal, a grammar whose
|
|
6
|
+
// `analyze` returns `{ entries: Entry[] }` would have every hook's `context.state`
|
|
7
|
+
// typed as `unknown`, and the author would spend the afternoon casting.
|
|
8
|
+
//
|
|
9
|
+
// const grammar = defineGrammar({
|
|
10
|
+
// id: 'my-language',
|
|
11
|
+
// rules: [...],
|
|
12
|
+
// analyze: (text) => ({ lines: text.split('\n') }), // State is inferred
|
|
13
|
+
// compose: [{
|
|
14
|
+
// id: 'values',
|
|
15
|
+
// range: (context) => ({ from: 0, to: context.caret }),
|
|
16
|
+
// items: (context) => context.state.lines.map(...), // typed, not cast
|
|
17
|
+
// }],
|
|
18
|
+
// })
|
|
19
|
+
|
|
20
|
+
import type { CompletionSource, Grammar } from './types.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Declare a grammar, inferring its state type from `analyze`.
|
|
24
|
+
* @param grammar - the grammar.
|
|
25
|
+
* @returns the same grammar, typed.
|
|
26
|
+
*/
|
|
27
|
+
export function defineGrammar<State = unknown>(grammar: Grammar<State>): Grammar<State> {
|
|
28
|
+
return grammar
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Declare a completion source, inferring the state type from its context.
|
|
33
|
+
* @param source - the source.
|
|
34
|
+
* @returns the same source, typed.
|
|
35
|
+
*/
|
|
36
|
+
export function defineCompletion<State = unknown>(
|
|
37
|
+
source: CompletionSource<State>,
|
|
38
|
+
): CompletionSource<State> {
|
|
39
|
+
return source
|
|
40
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// ─── hover: what the pointer is resting on ─────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// A tooltip is only worth showing when it says something the text does not, and
|
|
4
|
+
// there are exactly three things that qualify: a problem, a semantic mark the
|
|
5
|
+
// grammar made, and the grammar's own explanation of a token. They are consulted
|
|
6
|
+
// in that order and the first one that has something to say wins.
|
|
7
|
+
//
|
|
8
|
+
// A diagnostic leads deliberately. The other two describe what the text IS, and
|
|
9
|
+
// the user resting the pointer on a red squiggle is asking what is WRONG with it
|
|
10
|
+
// — answering "circle: a full disc" there would be technically true and useless,
|
|
11
|
+
// and it is exactly the behaviour that makes people stop hovering.
|
|
12
|
+
|
|
13
|
+
import type { Decoration, Diagnostic, Grammar, HoverContext, HoverInfo, Severity } from './types.js'
|
|
14
|
+
import type { Inspection } from './inspect.js'
|
|
15
|
+
import { containsOffset, lineAt, lineStarts, tokenAt, wordInfoAt } from './text.js'
|
|
16
|
+
import { isResolvedGrammar, resolveGrammar, type ResolvedGrammar } from './scan.js'
|
|
17
|
+
|
|
18
|
+
/** The word a severity is shown under, as VSCode titles its hovered diagnostics. */
|
|
19
|
+
const SEVERITY_TITLE: Record<Severity, string> = {
|
|
20
|
+
error: 'Error',
|
|
21
|
+
warning: 'Warning',
|
|
22
|
+
info: 'Info',
|
|
23
|
+
hint: 'Hint',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Turn one diagnostic into a tooltip.
|
|
28
|
+
*
|
|
29
|
+
* Exported because a host that shows its own marker list wants the same wording,
|
|
30
|
+
* and because a test should be able to assert the tooltip without going through
|
|
31
|
+
* the ordering rules below.
|
|
32
|
+
* @param diagnostic - the diagnostic.
|
|
33
|
+
* @returns the tooltip.
|
|
34
|
+
*/
|
|
35
|
+
export function diagnosticHover(diagnostic: Diagnostic): HoverInfo {
|
|
36
|
+
return {
|
|
37
|
+
kind: 'diagnostic',
|
|
38
|
+
title: SEVERITY_TITLE[diagnostic.severity],
|
|
39
|
+
detail: diagnostic.source,
|
|
40
|
+
body: diagnostic.detail === undefined ? diagnostic.message : `${diagnostic.message}\n\n${diagnostic.detail}`,
|
|
41
|
+
range: { from: diagnostic.from, to: diagnostic.to },
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve what a hover should show at an offset.
|
|
47
|
+
*
|
|
48
|
+
* @param inspection - the current inspection.
|
|
49
|
+
* @param grammar - the language.
|
|
50
|
+
* @param offset - the character offset the pointer resolved to.
|
|
51
|
+
* @returns the tooltip, or undefined when there is nothing to say.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveHover<State>(
|
|
54
|
+
inspection: Inspection<State>,
|
|
55
|
+
grammar: Grammar<State> | ResolvedGrammar<State>,
|
|
56
|
+
offset: number,
|
|
57
|
+
): HoverInfo | undefined {
|
|
58
|
+
const resolved = isResolvedGrammar(grammar) ? grammar : resolveGrammar(grammar)
|
|
59
|
+
const { text, tokens, diagnostics, decorations, state } = inspection
|
|
60
|
+
|
|
61
|
+
// ── a problem outranks a description ─────────────────────────────────────
|
|
62
|
+
// The narrowest diagnostic wins when several overlap, so a squiggle inside a
|
|
63
|
+
// wider warning still explains itself rather than its container.
|
|
64
|
+
let covering: Diagnostic | undefined
|
|
65
|
+
for (const diagnostic of diagnostics) {
|
|
66
|
+
if (!containsOffset(diagnostic, offset)) continue
|
|
67
|
+
if (covering === undefined || diagnostic.to - diagnostic.from < covering.to - covering.from) {
|
|
68
|
+
covering = diagnostic
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (covering !== undefined) return diagnosticHover(covering)
|
|
72
|
+
|
|
73
|
+
// ── a mark the grammar made, combined with its own account of the token ──
|
|
74
|
+
// Both, rather than one or the other. A decoration's title is short by design ("in
|
|
75
|
+
// effect: Geist Mono") and the grammar's description is rich but says nothing about
|
|
76
|
+
// the mark, so a tooltip that showed only one of them would always be missing the
|
|
77
|
+
// half the reader wanted.
|
|
78
|
+
const decoration: Decoration | undefined = decorations.find((entry) => containsOffset(entry, offset))
|
|
79
|
+
const described = describeAt(inspection, resolved, offset)
|
|
80
|
+
if (decoration?.title !== undefined) {
|
|
81
|
+
return {
|
|
82
|
+
kind: 'decoration',
|
|
83
|
+
title: decoration.title,
|
|
84
|
+
detail: described?.detail ?? described?.title,
|
|
85
|
+
body: described?.body,
|
|
86
|
+
range: { from: decoration.from, to: decoration.to },
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return described
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Ask the grammar what a token is.
|
|
95
|
+
*
|
|
96
|
+
* Split out because the decoration branch above wants the same answer and must not
|
|
97
|
+
* build a second context for it — one parse, one description, however many tooltips
|
|
98
|
+
* are made from it.
|
|
99
|
+
* @param inspection - the current inspection.
|
|
100
|
+
* @param resolved - the resolved grammar.
|
|
101
|
+
* @param offset - the offset being described.
|
|
102
|
+
* @returns the description, or undefined when the grammar has none.
|
|
103
|
+
*/
|
|
104
|
+
function describeAt<State>(
|
|
105
|
+
inspection: Inspection<State>,
|
|
106
|
+
resolved: ResolvedGrammar<State>,
|
|
107
|
+
offset: number,
|
|
108
|
+
): HoverInfo | undefined {
|
|
109
|
+
if (resolved.grammar.describe === undefined) return undefined
|
|
110
|
+
const { text, tokens, diagnostics, state } = inspection
|
|
111
|
+
const found = tokenAt(tokens, offset)
|
|
112
|
+
// Whitespace is painted like anything else, so a token IS found in blank space — and a
|
|
113
|
+
// grammar handed one will describe it, which is how resting the pointer on a gap produced a
|
|
114
|
+
// tooltip naming the fallback scope. There is no thing under the pointer to explain, so the
|
|
115
|
+
// grammar is told there is no token; a grammar that wants to say something about the
|
|
116
|
+
// POSITION can still do so, because `describe` is called either way.
|
|
117
|
+
const token = found === undefined || found.text.trim() === '' ? undefined : found
|
|
118
|
+
const context: HoverContext<State> = {
|
|
119
|
+
text,
|
|
120
|
+
offset,
|
|
121
|
+
token,
|
|
122
|
+
word: wordInfoAt(text, offset, resolved.wordChars),
|
|
123
|
+
line: lineAt(text, offset, lineStarts(text)),
|
|
124
|
+
tokens,
|
|
125
|
+
diagnostics,
|
|
126
|
+
state,
|
|
127
|
+
}
|
|
128
|
+
return resolved.grammar.describe(context) ?? undefined
|
|
129
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// ─── the engine's public surface ────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Everything here is pure: text and a grammar go in, values come out. Nothing in
|
|
4
|
+
// this module touches the DOM, which is why `scripts/browser-check.mjs` is needed
|
|
5
|
+
// for only one claim and everything else is asserted in plain Node.
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
CheckRule,
|
|
9
|
+
Completion,
|
|
10
|
+
CompletionContext,
|
|
11
|
+
CompletionRow,
|
|
12
|
+
CompletionSource,
|
|
13
|
+
CompletionTrigger,
|
|
14
|
+
Decoration,
|
|
15
|
+
DiagnoseContext,
|
|
16
|
+
Diagnostic,
|
|
17
|
+
Grammar,
|
|
18
|
+
HoverContext,
|
|
19
|
+
HoverInfo,
|
|
20
|
+
LineInfo,
|
|
21
|
+
MatchRule,
|
|
22
|
+
Range,
|
|
23
|
+
RegionRule,
|
|
24
|
+
ResolvedVocabulary,
|
|
25
|
+
Rule,
|
|
26
|
+
RuleContext,
|
|
27
|
+
RuleMatch,
|
|
28
|
+
Scope,
|
|
29
|
+
ScopeSpec,
|
|
30
|
+
Severity,
|
|
31
|
+
SuggestionItem,
|
|
32
|
+
Token,
|
|
33
|
+
VocabularyContext,
|
|
34
|
+
VocabularyEntry,
|
|
35
|
+
WordInfo,
|
|
36
|
+
WordsRule,
|
|
37
|
+
WordsSource,
|
|
38
|
+
} from './types.js'
|
|
39
|
+
|
|
40
|
+
export { excerpt, fillTemplate, listPhrase } from './format.js'
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
clamp,
|
|
44
|
+
containsOffset,
|
|
45
|
+
isEmptyRange,
|
|
46
|
+
isOffset,
|
|
47
|
+
isWordChar,
|
|
48
|
+
lineAt,
|
|
49
|
+
lineIndexAt,
|
|
50
|
+
lineStarts,
|
|
51
|
+
scopeAt,
|
|
52
|
+
tokenAfter,
|
|
53
|
+
tokenAt,
|
|
54
|
+
tokenBefore,
|
|
55
|
+
tokensOnLine,
|
|
56
|
+
wordInfoAt,
|
|
57
|
+
} from './text.js'
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
asResolvedVocabulary,
|
|
61
|
+
defineVocabulary,
|
|
62
|
+
resolveWordsSource,
|
|
63
|
+
vocabularyWords,
|
|
64
|
+
type VocabularySpec,
|
|
65
|
+
} from './vocabulary.js'
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
isResolvedGrammar,
|
|
69
|
+
resolveGrammar,
|
|
70
|
+
scan,
|
|
71
|
+
type ResolvedGrammar,
|
|
72
|
+
type ScanResult,
|
|
73
|
+
} from './scan.js'
|
|
74
|
+
|
|
75
|
+
export { buildSegments, segmentClasses, type PaintSegment, type SegmentInput } from './segments.js'
|
|
76
|
+
|
|
77
|
+
export { inspect, normalizeDiagnostics, type Inspection } from './inspect.js'
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
fuzzyMatch,
|
|
81
|
+
highlightSegments,
|
|
82
|
+
isWordStart,
|
|
83
|
+
rank,
|
|
84
|
+
type FuzzyMatch,
|
|
85
|
+
type Ranked,
|
|
86
|
+
type RankOptions,
|
|
87
|
+
} from './rank.js'
|
|
88
|
+
|
|
89
|
+
export {
|
|
90
|
+
applyCompletion,
|
|
91
|
+
complete,
|
|
92
|
+
type AppliedCompletion,
|
|
93
|
+
type CompletionRequest,
|
|
94
|
+
} from './complete.js'
|
|
95
|
+
|
|
96
|
+
export { diagnosticHover, resolveHover } from './hover.js'
|
|
97
|
+
|
|
98
|
+
export { defineCompletion, defineGrammar } from './grammar.js'
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// ─── inspect: the one call the editor makes per keystroke ───────────────────
|
|
2
|
+
//
|
|
3
|
+
// Everything the editor needs to know about a document is computed here, once,
|
|
4
|
+
// from one grammar. The paint, the squiggles, the semantic decorations, and the
|
|
5
|
+
// structural analysis come out of a single call and are stored together, so the
|
|
6
|
+
// completion list and the coloured text cannot be looking at different documents.
|
|
7
|
+
//
|
|
8
|
+
// That is a deliberate inversion of how the editors this library replaces were
|
|
9
|
+
// built. Each of them re-derived the tokens, the parse, and the suggestions
|
|
10
|
+
// separately, from the same string, in three places — and the three drifted, so
|
|
11
|
+
// a word could be painted as a valid value while the completer thought it was
|
|
12
|
+
// unknown and the diagnostic pointed at a range that had already moved.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
CheckRule,
|
|
16
|
+
Decoration,
|
|
17
|
+
DiagnoseContext,
|
|
18
|
+
Diagnostic,
|
|
19
|
+
Grammar,
|
|
20
|
+
Token,
|
|
21
|
+
} from './types.js'
|
|
22
|
+
import { listPhrase } from './format.js'
|
|
23
|
+
import { isResolvedGrammar, resolveGrammar, scan, type ResolvedGrammar } from './scan.js'
|
|
24
|
+
import { resolveWordsSource } from './vocabulary.js'
|
|
25
|
+
|
|
26
|
+
/** Everything one pass over a document produced. */
|
|
27
|
+
export interface Inspection<State = unknown> {
|
|
28
|
+
/** The text this was computed from, kept so nothing has to guess its version. */
|
|
29
|
+
text: string
|
|
30
|
+
tokens: Token[]
|
|
31
|
+
/** Lexical problems, the grammar's `checks`, and its `validate`, together. */
|
|
32
|
+
diagnostics: Diagnostic[]
|
|
33
|
+
/** Semantic ranges that are not tokens. */
|
|
34
|
+
decorations: Decoration[]
|
|
35
|
+
/** Whatever `Grammar.analyze` returned. */
|
|
36
|
+
state: State
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Run a grammar's declarative checks over the tokens.
|
|
41
|
+
*
|
|
42
|
+
* A check is the shape most vocabulary mistakes actually take — "this token is
|
|
43
|
+
* one of these words or it is wrong" — and expressing it declaratively means the
|
|
44
|
+
* message, the severity, and the code are declared once instead of being
|
|
45
|
+
* reassembled by hand at every site.
|
|
46
|
+
* @param source - the document.
|
|
47
|
+
* @param grammar - the resolved grammar.
|
|
48
|
+
* @param tokens - the scanned tokens.
|
|
49
|
+
* @param state - the analysis, so a check may consult a vocabulary the machine supplied.
|
|
50
|
+
* @returns the diagnostics the checks raised.
|
|
51
|
+
*/
|
|
52
|
+
function runChecks<State>(
|
|
53
|
+
source: string,
|
|
54
|
+
grammar: ResolvedGrammar<State>,
|
|
55
|
+
tokens: readonly Token[],
|
|
56
|
+
state: State,
|
|
57
|
+
): Diagnostic[] {
|
|
58
|
+
const checks = grammar.grammar.checks
|
|
59
|
+
if (checks === undefined || checks.length === 0) return []
|
|
60
|
+
const diagnostics: Diagnostic[] = []
|
|
61
|
+
// The ANALYSIS, not `initialState`. A `WordsSource` function is documented as receiving
|
|
62
|
+
// the grammar's analysis, and the point of a dynamic vocabulary is that it comes from
|
|
63
|
+
// outside the document — so a check whose allowed set is resolved from `analyze` must
|
|
64
|
+
// see it, or the two halves of one declaration would disagree about what is legal.
|
|
65
|
+
const context = { text: source, state }
|
|
66
|
+
|
|
67
|
+
for (const check of checks as readonly CheckRule<State>[]) {
|
|
68
|
+
const members = check.allow === undefined ? undefined : resolveWordsSource(check.allow, context)
|
|
69
|
+
const vocabulary = check.allow === undefined ? undefined : check.allow
|
|
70
|
+
const caseSensitive =
|
|
71
|
+
typeof vocabulary === 'object' && vocabulary !== null && !Array.isArray(vocabulary)
|
|
72
|
+
? (vocabulary as { caseSensitive?: boolean }).caseSensitive === true
|
|
73
|
+
: false
|
|
74
|
+
const fold = (word: string): string => (caseSensitive ? word : word.toLowerCase())
|
|
75
|
+
const set =
|
|
76
|
+
members === undefined ? undefined : new Set(members.map((member) => fold(member)))
|
|
77
|
+
/** Lines already reported, for a check that only wants one complaint each. */
|
|
78
|
+
const reportedLines = new Set<number>()
|
|
79
|
+
|
|
80
|
+
for (const token of tokens) {
|
|
81
|
+
// Whitespace is a token like any other, which means a check written against `'*'` would
|
|
82
|
+
// otherwise underline every space and report it as a misspelling. No one has ever
|
|
83
|
+
// wanted a diagnostic about a space.
|
|
84
|
+
if (token.text.trim() === '') continue
|
|
85
|
+
if (!check.scopes.includes('*') && !check.scopes.includes(token.scope)) continue
|
|
86
|
+
if (check.except !== undefined && check.except.test(token.text)) continue
|
|
87
|
+
if (set !== undefined && set.has(fold(token.text))) continue
|
|
88
|
+
if (check.perLine === true) {
|
|
89
|
+
if (reportedLines.has(token.line)) continue
|
|
90
|
+
reportedLines.add(token.line)
|
|
91
|
+
}
|
|
92
|
+
diagnostics.push({
|
|
93
|
+
from: token.from,
|
|
94
|
+
to: token.to,
|
|
95
|
+
severity: check.severity ?? 'error',
|
|
96
|
+
message: check.message
|
|
97
|
+
.replace(/\{word\}/g, token.text)
|
|
98
|
+
.replace(/\{allowed\}/g, listPhrase(members ?? [])),
|
|
99
|
+
code: check.code,
|
|
100
|
+
detail: check.detail,
|
|
101
|
+
source: grammar.grammar.id,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return diagnostics
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Inspect a document: paint, problems, decorations, and structure in one pass.
|
|
110
|
+
* @param source - the document.
|
|
111
|
+
* @param grammar - the language, as written or already resolved.
|
|
112
|
+
* @returns everything the editor needs for one text.
|
|
113
|
+
*/
|
|
114
|
+
export function inspect<State>(
|
|
115
|
+
source: string,
|
|
116
|
+
grammar: Grammar<State> | ResolvedGrammar<State>,
|
|
117
|
+
): Inspection<State> {
|
|
118
|
+
const resolved = isResolvedGrammar(grammar) ? grammar : resolveGrammar(grammar)
|
|
119
|
+
const scanned = scan(source, resolved)
|
|
120
|
+
const declared = resolved.grammar
|
|
121
|
+
const diagnostics: Diagnostic[] = [...scanned.diagnostics]
|
|
122
|
+
|
|
123
|
+
diagnostics.push(...runChecks(source, resolved, scanned.tokens, scanned.state))
|
|
124
|
+
|
|
125
|
+
if (declared.validate !== undefined) {
|
|
126
|
+
const context: DiagnoseContext<State> = {
|
|
127
|
+
text: source,
|
|
128
|
+
tokens: scanned.tokens,
|
|
129
|
+
state: scanned.state,
|
|
130
|
+
report: (problem) => {
|
|
131
|
+
diagnostics.push({
|
|
132
|
+
from: problem.from,
|
|
133
|
+
to: problem.to,
|
|
134
|
+
severity: problem.severity ?? 'error',
|
|
135
|
+
message: problem.message,
|
|
136
|
+
code: problem.code ?? 'validate',
|
|
137
|
+
detail: problem.detail,
|
|
138
|
+
source: declared.id,
|
|
139
|
+
})
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
declared.validate(context)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const decorations = declared.decorate === undefined ? [] : [...declared.decorate(source, scanned.state)]
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
text: source,
|
|
149
|
+
tokens: scanned.tokens,
|
|
150
|
+
diagnostics: normalizeDiagnostics(diagnostics),
|
|
151
|
+
decorations: normalizeDecorations(decorations, source.length),
|
|
152
|
+
state: scanned.state,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Sort diagnostics by position and drop duplicates.
|
|
158
|
+
*
|
|
159
|
+
* Two rules can legitimately notice the same mistake — a vocabulary's own
|
|
160
|
+
* rejection and a grammar's validator both know that `nope` is not a colour —
|
|
161
|
+
* and drawing the underline twice makes it darker rather than more informative.
|
|
162
|
+
* Duplicates are compared on position, code, and message, so two genuinely
|
|
163
|
+
* different complaints about one word both survive.
|
|
164
|
+
* @param diagnostics - the collected diagnostics.
|
|
165
|
+
* @returns the deduplicated, ordered list.
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] {
|
|
168
|
+
const seen = new Set<string>()
|
|
169
|
+
const out: Diagnostic[] = []
|
|
170
|
+
for (const diagnostic of diagnostics) {
|
|
171
|
+
const key = `${diagnostic.from}:${diagnostic.to}:${diagnostic.code ?? ''}:${diagnostic.message}`
|
|
172
|
+
if (seen.has(key)) continue
|
|
173
|
+
seen.add(key)
|
|
174
|
+
out.push(diagnostic)
|
|
175
|
+
}
|
|
176
|
+
return out.sort((left, right) => left.from - right.from || left.to - right.to)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Clamp decorations into the document and drop the ones that came out empty.
|
|
181
|
+
*
|
|
182
|
+
* A grammar computing decorations from a stale parse can hand over a range that
|
|
183
|
+
* no longer exists, and a decoration outside the text would only produce a span
|
|
184
|
+
* nobody can see.
|
|
185
|
+
* @param decorations - the declared decorations.
|
|
186
|
+
* @param length - the document's length.
|
|
187
|
+
* @returns the usable decorations, ordered.
|
|
188
|
+
*/
|
|
189
|
+
function normalizeDecorations(decorations: readonly Decoration[], length: number): Decoration[] {
|
|
190
|
+
const out: Decoration[] = []
|
|
191
|
+
for (const decoration of decorations) {
|
|
192
|
+
const from = Math.max(0, Math.min(decoration.from, length))
|
|
193
|
+
const to = Math.max(from, Math.min(decoration.to, length))
|
|
194
|
+
if (to <= from) continue
|
|
195
|
+
out.push({ ...decoration, from, to })
|
|
196
|
+
}
|
|
197
|
+
return out.sort((left, right) => left.from - right.from || left.to - right.to)
|
|
198
|
+
}
|