@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/docs/grammar.md
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
# Grammar reference
|
|
2
|
+
|
|
3
|
+
The complete contract for a litearea grammar: every field of every rule kind, the
|
|
4
|
+
precedence model, the vocabulary helper, diagnostics, hover, and a worked
|
|
5
|
+
walkthrough. The short version, with a runnable example, is in the
|
|
6
|
+
[README](../README.md#writing-a-grammar).
|
|
7
|
+
|
|
8
|
+
## One object, one pass
|
|
9
|
+
|
|
10
|
+
A language is a single object. The engine knows nothing else: there is no
|
|
11
|
+
built-in syntax, no bundled tokenizer, and no language identifier to switch on.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
rules → what is painted (lexical, declarative)
|
|
15
|
+
analyze → what the document MEANS (structural, one pass, optional)
|
|
16
|
+
checks → what is wrong with it (declarative checks over the tokens)
|
|
17
|
+
validate → what is wrong with it (the grammar's own hook)
|
|
18
|
+
compose → what can come next (completion sources)
|
|
19
|
+
describe → what a thing is (hover tooltips)
|
|
20
|
+
decorate → what is semantically true (ranges that are not tokens)
|
|
21
|
+
onAccept → what just changed (after a row is accepted)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Those eight fields are six *stages*: the engine's own header groups `checks` and
|
|
25
|
+
`validate` under the single word `diagnose`, and `onAccept` is a field that reacts
|
|
26
|
+
to a change rather than producing one. The reason the whole
|
|
27
|
+
language is one object rather than several registrations is the failure this
|
|
28
|
+
library exists to correct: three separate passes over one string — paint, parse,
|
|
29
|
+
suggest — are three chances to disagree, and the editors this replaces disagreed
|
|
30
|
+
routinely. Here `analyze` runs once per text and its result is handed to every
|
|
31
|
+
hook that wants it.
|
|
32
|
+
|
|
33
|
+
| Field | Required | What it does |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `id` | yes | A stable id. It is the default diagnostic `source`, and the one thing a test can rely on |
|
|
36
|
+
| `name` | no | A human name, for a status line or a demo |
|
|
37
|
+
| `rules` | yes | The lexical pass. Tried in order at every position; first match wins |
|
|
38
|
+
| `fallbackScope` | no | The scope for a character no rule claimed. Default `'text'` |
|
|
39
|
+
| `wordChars` | no | The single-character test that decides where words begin and end. See [wordChars](#wordchars) |
|
|
40
|
+
| `initialState` | no | The state used before `analyze` exists, and when it is skipped |
|
|
41
|
+
| `analyze` | no | The single structural pass. Called once per text, before the scan |
|
|
42
|
+
| `checks` | no | Declarative "this token must be one of these words" checks |
|
|
43
|
+
| `validate` | no | The grammar's own validator, for what a check cannot express |
|
|
44
|
+
| `compose` | no | The ways this grammar offers completions |
|
|
45
|
+
| `describe` | no | What a thing is, when the pointer rests on it |
|
|
46
|
+
| `decorate` | no | Semantic ranges that are not tokens |
|
|
47
|
+
| `onAccept` | no | Called by the editor after a row is accepted |
|
|
48
|
+
|
|
49
|
+
Every hook is optional except `rules`. A grammar with nothing but `rules` is a
|
|
50
|
+
plain highlighter; add `compose` and it completes; add `checks` and it complains.
|
|
51
|
+
Use `defineGrammar` to declare one — it is an identity function whose only job is
|
|
52
|
+
to infer `State` from `analyze` and enforce it everywhere else, and
|
|
53
|
+
`defineCompletion` does the same for one source.
|
|
54
|
+
|
|
55
|
+
## Rules
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
type Rule<State> = MatchRule<State> | WordsRule<State> | RegionRule<State>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
All three kinds may carry a `when` predicate; the two that paint a name may carry
|
|
62
|
+
a `scope`. `scope` is a `Scope` (a plain string) or a function of the match:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
type ScopeSpec<State> = Scope | ((match: RuleMatch<State>) => Scope)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A function is handed everything a lexical decision could need:
|
|
69
|
+
|
|
70
|
+
| `RuleMatch` field | What it is |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `text` | The matched text |
|
|
73
|
+
| `source` | The whole document |
|
|
74
|
+
| `from` | Where the match starts |
|
|
75
|
+
| `groups` | The capture groups; index 0 is the whole match |
|
|
76
|
+
| `state` | Whatever `analyze` returned |
|
|
77
|
+
|
|
78
|
+
The `state` field is the one that earns the function form: it is how a scope can
|
|
79
|
+
depend on something the characters do not say. `dshFontQueryGrammar` uses it to
|
|
80
|
+
paint a weight word as `weight` or `weight.missing`, which depends on the
|
|
81
|
+
machine's installed faces, not on the word.
|
|
82
|
+
|
|
83
|
+
### `match`: a regular expression
|
|
84
|
+
|
|
85
|
+
| Field | Type | Notes |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `kind` | `'match'` | |
|
|
88
|
+
| `pattern` | `RegExp` | Tried at the current position. The engine adds the sticky flag (`y`), so a pattern never has to be anchored and can never skip ahead |
|
|
89
|
+
| `scope` | `ScopeSpec` | The scope to paint |
|
|
90
|
+
| `when` | `RuleContext` | Extra positional predicates |
|
|
91
|
+
|
|
92
|
+
Compiled patterns are cached per source pattern, so a scan does not recompile one
|
|
93
|
+
per position.
|
|
94
|
+
|
|
95
|
+
### `words`: a vocabulary
|
|
96
|
+
|
|
97
|
+
A word is its own rule kind rather than sugar over `match`, because it has three
|
|
98
|
+
properties a regex cannot express: it can be resolved at scan time from data
|
|
99
|
+
outside the document, it can span several words when a name contains a space, and
|
|
100
|
+
a word that is *not* in the set is a fact worth reporting.
|
|
101
|
+
|
|
102
|
+
| Field | Type | Notes |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| `kind` | `'words'` | |
|
|
105
|
+
| `words` | `WordsSource` | A `readonly string[]`, a `ResolvedVocabulary`, or `(context) => readonly string[]` |
|
|
106
|
+
| `scope` | `ScopeSpec` | Overrides the vocabulary's own scope for this rule only |
|
|
107
|
+
| `phrase` | object | Lets one entry span several words |
|
|
108
|
+
| `when` | `RuleContext` | Extra positional predicates |
|
|
109
|
+
| `unknown` | object | What to do with a word-shaped token that is not a member |
|
|
110
|
+
|
|
111
|
+
`WordsSource` as a function is called **once per scan** with
|
|
112
|
+
`{ text, state }`, so a dynamic vocabulary — the fonts actually installed, the
|
|
113
|
+
states the host actually has — costs one call, not one per token.
|
|
114
|
+
|
|
115
|
+
`phrase`:
|
|
116
|
+
|
|
117
|
+
| Field | Default | Notes |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| `max` | 4 | The most words one entry may span. A span longer than `max` is never attempted, and four covers every family in the shipped catalogue (`Source Han Serif SC` is four) |
|
|
120
|
+
|
|
121
|
+
The engine tries the longest span first and keeps the longest member it finds, so
|
|
122
|
+
a catalogue holding both `IBM Plex` and `IBM Plex Mono` resolves the longer name.
|
|
123
|
+
Words are separated by any run of whitespace that does not contain a newline; the
|
|
124
|
+
separators are part of the matched span and are painted with the member's own
|
|
125
|
+
scope. A phrase never crosses a line: a name split over two lines is two names.
|
|
126
|
+
|
|
127
|
+
Setting `phrase` also makes `max` a real cap. A member containing whitespace would
|
|
128
|
+
otherwise be reachable through the literal fallback — the path that matches a
|
|
129
|
+
member's exact text when `wordChars` cannot read it in — and a cap that applies on
|
|
130
|
+
only one of the two routes to the same member is not a cap. The literal path still
|
|
131
|
+
serves members whose characters are not word characters for another reason, such as
|
|
132
|
+
`-apple-system`.
|
|
133
|
+
|
|
134
|
+
`unknown`:
|
|
135
|
+
|
|
136
|
+
| Field | Notes |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `scope` | The scope a rejected word is painted with. Falls back to the vocabulary's `unknownScope`, then `'invalid'` |
|
|
139
|
+
| `message` | A string with `{word}` and `{allowed}` substituted, or `(word, match) => string`. Absent, the vocabulary's own rejection message is used |
|
|
140
|
+
| `severity` | Default: the vocabulary's `unknownSeverity`, then `'error'` |
|
|
141
|
+
| `code` | Default: the vocabulary's `unknownCode`, then `'unknown-word'` |
|
|
142
|
+
|
|
143
|
+
With `unknown` absent, a non-member simply does not match and a later rule gets
|
|
144
|
+
its turn — which is what a grammar with two overlapping vocabularies wants. With
|
|
145
|
+
`unknown` present, the rule *claims* the word-shaped run: painting it, reporting
|
|
146
|
+
it, and stopping anything below from seeing it. Write `unknown: {}` to take the
|
|
147
|
+
vocabulary's own rejection and nothing else.
|
|
148
|
+
|
|
149
|
+
Only the first word-shaped token is consumed. The scanner cannot know how far a
|
|
150
|
+
name it has never seen was meant to reach, and guessing would swallow the words
|
|
151
|
+
that follow it; a multi-word name that is merely misspelled is the grammar's
|
|
152
|
+
validator's business, where the structure is known.
|
|
153
|
+
|
|
154
|
+
### `region`: a delimited block
|
|
155
|
+
|
|
156
|
+
| Field | Type | Notes |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| `kind` | `'region'` | |
|
|
159
|
+
| `begin` | `RegExp` | The opening delimiter, matched at the position |
|
|
160
|
+
| `end` | `RegExp` | The closing delimiter. A plain region **searches** for it from the end of `begin`; a nested or transparent one tries it at each position, with the same first match |
|
|
161
|
+
| `scope` | `ScopeSpec` | The scope of the whole region when the three below are not given |
|
|
162
|
+
| `openScope` | `ScopeSpec` | The opening delimiter. Defaults to `scope` |
|
|
163
|
+
| `closeScope` | `ScopeSpec` | The closing delimiter. Defaults to `scope` |
|
|
164
|
+
| `contentScope` | `ScopeSpec` | What lies between. Defaults to `scope` |
|
|
165
|
+
| `nested` | `boolean` | Default `false`. The same rule may open again inside itself; the contents stay opaque |
|
|
166
|
+
| `transparent` | `boolean` | Default `false`. The grammar's other rules also apply inside. Implies nesting |
|
|
167
|
+
| `when` | `RuleContext` | Extra positional predicates |
|
|
168
|
+
| `unclosed` | object | What an unterminated region reports |
|
|
169
|
+
|
|
170
|
+
A region has three shapes, and the two flags are the choice between them.
|
|
171
|
+
|
|
172
|
+
**Neither flag — the fast path.** The end is found with one search over the rest of
|
|
173
|
+
the document, so the content can be painted as a single span: one token, even across
|
|
174
|
+
several lines. It is also opaque, because the rule list never runs inside it. That is
|
|
175
|
+
what makes a string a string — the word `running` inside a quoted string is text and
|
|
176
|
+
not a state. The price of the fast path is that an inner delimiter is not matched:
|
|
177
|
+
`/* a /* b */ c */` without flags closes at the **first** `*/`, so ` c */` is read as
|
|
178
|
+
document text.
|
|
179
|
+
|
|
180
|
+
**`nested: true` — the delimiters are matched, the content is still opaque.** A
|
|
181
|
+
region that nests is still a region all the way down, which is why this flag adds
|
|
182
|
+
only the delimiter: the region's own `end` is tried first at every position, then its
|
|
183
|
+
own `begin`, and everything else is content. `/* a /* b */ c */` is painted as
|
|
184
|
+
comment from end to end — the inner `/* … */` pairs off, and a `[section]` header
|
|
185
|
+
written inside stays comment text rather than becoming a section header. A language
|
|
186
|
+
whose comments behaved any other way would be one nobody could write a comment in.
|
|
187
|
+
|
|
188
|
+
**`transparent: true` — the interior is code.** The rule list runs inside, so the
|
|
189
|
+
grammar's rules apply to the content. It implies nesting, because the rule list this
|
|
190
|
+
exposes includes the rule that opened the region. This is the flag for a region that
|
|
191
|
+
is not prose: a template written in another language, or a string with escapes worth
|
|
192
|
+
colouring. The cost is that nothing inside is opaque any more, which is exactly why
|
|
193
|
+
it is a separate flag from `nested`.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// A nestable block comment: the delimiters pair off, the interior is prose.
|
|
197
|
+
{ kind: 'region', scope: 'comment', begin: /\/\*/, end: /\*\//, nested: true }
|
|
198
|
+
|
|
199
|
+
// A template region whose interior is the language again: `{{ user.name }}` is
|
|
200
|
+
// painted by the grammar's own rules, and braces may nest.
|
|
201
|
+
{ kind: 'region', scope: 'tmpl', begin: /\{\{/, end: /\}\}/, transparent: true }
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
With `[a [b] c]` and one `word` rule, `nested: true` produces no `word` tokens at all
|
|
205
|
+
and no diagnostics, while `transparent: true` produces the words `a`, `b`, and `c`.
|
|
206
|
+
That single difference is the whole flag.
|
|
207
|
+
|
|
208
|
+
In every shape, a character inside a region that no rule claims is painted with the
|
|
209
|
+
region's content scope rather than with the document fallback, and adjacent
|
|
210
|
+
same-scope spans merge, so a body and its closing delimiter that share a scope and
|
|
211
|
+
sit on one line come out as one token. Merging never crosses a newline, so an
|
|
212
|
+
incrementally painted region is one token per line while a plain region's body is a
|
|
213
|
+
single token however many lines it covers.
|
|
214
|
+
|
|
215
|
+
An `end` that is never found runs the region to the end of the document, and an
|
|
216
|
+
unterminated block comment is a mistake rather than an invitation to tint the rest
|
|
217
|
+
of the file, so every shape reports it: a plain region when its search fails, and a
|
|
218
|
+
nested or transparent one when the document ends with a frame still open. An `end`
|
|
219
|
+
that can match the empty string counts as never found.
|
|
220
|
+
|
|
221
|
+
`unclosed`:
|
|
222
|
+
|
|
223
|
+
| Field | Default |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| `message` | `Unterminated <scope>.`, or `(match) => string` |
|
|
226
|
+
| `severity` | `'error'` |
|
|
227
|
+
| `code` | `'unclosed-region'` |
|
|
228
|
+
| `scope` | The scope the unterminated body is painted with instead of `contentScope`. Honoured on the **fast path only** — a plain region |
|
|
229
|
+
|
|
230
|
+
The diagnostic's range is the opening delimiter, not the rest of the document.
|
|
231
|
+
|
|
232
|
+
`unclosed.scope` is how a grammar says "this is not a string, it is a broken
|
|
233
|
+
string", which is the difference between a reader trusting the colours and being
|
|
234
|
+
misled by them. It reaches the plain region alone, because that is the shape whose
|
|
235
|
+
body exists as one span to repaint. A nested or transparent region is painted as it
|
|
236
|
+
is scanned, one run at a time, so only `message`, `severity`, and `code` apply to it.
|
|
237
|
+
|
|
238
|
+
A nested or transparent region is reported **innermost first** when the document ends
|
|
239
|
+
inside it — the one the reader has to fix first — and each open frame produces its
|
|
240
|
+
own diagnostic, over its own opening delimiter. `inspect` then sorts the list by
|
|
241
|
+
range, as it does for every diagnostic, so the order a host sees is positional.
|
|
242
|
+
|
|
243
|
+
### `when` and `RuleContext`
|
|
244
|
+
|
|
245
|
+
Every predicate that is present must hold. They are predicates about *position*,
|
|
246
|
+
not about the parse — a rule that could ask "am I inside a rule named running?"
|
|
247
|
+
would make the highlighter a parser, and the point of `analyze` is that the parse
|
|
248
|
+
happens once, in a function you can read and test.
|
|
249
|
+
|
|
250
|
+
| Field | Type | Holds when |
|
|
251
|
+
| --- | --- | --- |
|
|
252
|
+
| `firstOnLine` | `boolean` | Nothing but whitespace precedes the match on its line — so a match inside a line's indentation counts as first |
|
|
253
|
+
| `after` | `readonly Scope[]` | The nearest preceding non-whitespace token has one of these scopes. Fails when there is no preceding token |
|
|
254
|
+
| `notAfter` | `readonly Scope[]` | The nearest preceding token does not have one of these scopes. Passes when there is no preceding token |
|
|
255
|
+
| `line` | `RegExp` | The whole line's text matches. Anchor it yourself (`/^form/`); the engine runs it as written |
|
|
256
|
+
| `minColumn` | `number` | The match starts at or after this character column |
|
|
257
|
+
| `maxColumn` | `number` | The match starts at or before this character column |
|
|
258
|
+
| `prevNot` | `string` | The character immediately before the match is not one of these. It is used as a character class, so `'\\w'` means "not a word character" — the usual way to stop a keyword matching the tail of a longer word. At offset 0 the predicate holds, because there is nothing in front of the match |
|
|
259
|
+
|
|
260
|
+
Columns are counted in characters, from the start of the line.
|
|
261
|
+
|
|
262
|
+
## Precedence
|
|
263
|
+
|
|
264
|
+
Four rules decide how a document is painted, and all four are visible from
|
|
265
|
+
outside:
|
|
266
|
+
|
|
267
|
+
1. **First match wins, in declaration order.** At each position the rules are
|
|
268
|
+
tried top to bottom and the first one that matches consumes the position. To
|
|
269
|
+
make a rule win, put it higher. A rule that must not shadow another belongs
|
|
270
|
+
below it.
|
|
271
|
+
2. **A rule that can match the empty string is treated as not matching.** A
|
|
272
|
+
pattern such as `/\w*/` is skipped, so the scan cannot stall on one position
|
|
273
|
+
forever. The same guard applies to a region's `begin` and to the sticky test
|
|
274
|
+
for its `end`.
|
|
275
|
+
3. **Adjacent tokens with the same scope merge.** Whitespace is most of a
|
|
276
|
+
document, and one span per space would mean thousands of DOM nodes for a file
|
|
277
|
+
nobody is looking at. Merging stops at a newline, because a token's `line` and
|
|
278
|
+
`column` describe where it starts and a span across two lines would make that
|
|
279
|
+
a lie. Tokens also merge only when their `region` agrees.
|
|
280
|
+
4. **A region's contents are opaque unless it is `transparent`.** A plain or
|
|
281
|
+
`nested` region never offers its interior to the rule list, so the word
|
|
282
|
+
`running` inside a quoted string cannot be read as a state. `transparent: true`
|
|
283
|
+
is the one flag that lifts it.
|
|
284
|
+
|
|
285
|
+
A run of characters no rule claimed is taken in one piece, up to the next rule
|
|
286
|
+
match or the end of the line, and painted with `fallbackScope`.
|
|
287
|
+
|
|
288
|
+
## defineVocabulary
|
|
289
|
+
|
|
290
|
+
A closed set of words appears four times in a language-aware editor: as the syntax
|
|
291
|
+
that accepts it, as the colour it is painted in, as the message a typo earns, and
|
|
292
|
+
as the documentation a hover shows. Written out four times they drift — a word
|
|
293
|
+
added to one and forgotten by the others is the ordinary way a grammar rots, and
|
|
294
|
+
the drift is invisible until someone types the new word and watches the editor get
|
|
295
|
+
it wrong. So they are declared once:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
const SHAPES = defineVocabulary({
|
|
299
|
+
id: 'shape',
|
|
300
|
+
words: ['circle', 'rounded', 'square', 'none'],
|
|
301
|
+
unknownMessage: '"{word}" is not a shape — expected {allowed}.',
|
|
302
|
+
docs: { circle: 'A full disc, the background for a busy state.' },
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// …
|
|
306
|
+
{ kind: 'words', words: SHAPES, unknown: {} }
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
| `VocabularySpec` field | Default | What it does |
|
|
310
|
+
| --- | --- | --- |
|
|
311
|
+
| `id` | — | Used in the default scope and the default diagnostic code |
|
|
312
|
+
| `words` | — | The members, or `(context) => readonly string[]` for a set that lives outside the document. Called once per scan |
|
|
313
|
+
| `scope` | `vocabulary:<id>` | The scope a member is painted with, or `(word) => Scope` |
|
|
314
|
+
| `unknownScope` | `'invalid'` | The scope a rejected word is painted with |
|
|
315
|
+
| `unknownMessage` | — | The message a rejected word earns. `{word}` and `{allowed}` are substituted, the latter as a readable list. **Absent, nothing rejects** — membership is simply not enforced |
|
|
316
|
+
| `unknownSeverity` | `'error'` | How loudly a rejection complains |
|
|
317
|
+
| `unknownCode` | `vocabulary:<id>` | A stable code for the rejection |
|
|
318
|
+
| `docs` | — | What each member explains about itself. A bare string is taken as the body |
|
|
319
|
+
| `detail` | — | A dimmed line beside a member in the completion list |
|
|
320
|
+
| `caseSensitive` | `false` | Whether membership is case-sensitive |
|
|
321
|
+
| `format` | identity | How a member is written into the document when accepted — how a family called `IBM Plex Mono` is inserted as `"IBM Plex Mono"` |
|
|
322
|
+
|
|
323
|
+
The returned `ResolvedVocabulary` is what the engine consumes: `resolve`, `has`,
|
|
324
|
+
`scopeFor`, `unknownScope`, `reject`, `entryFor`, and `format`. Because the test
|
|
325
|
+
for a vocabulary is structural rather than `instanceof`, a host may hand over its
|
|
326
|
+
own object as long as it carries those methods — which is what lets a host wrap a
|
|
327
|
+
vocabulary in logging, caching, or translation without the engine knowing.
|
|
328
|
+
`asResolvedVocabulary`, `resolveWordsSource`, and `vocabularyWords` are exported
|
|
329
|
+
for the same kind of work.
|
|
330
|
+
|
|
331
|
+
The one thing to remember: a vocabulary's rejection only fires where a rule asks
|
|
332
|
+
for it. `unknownMessage` on its own changes nothing — put `unknown: {}` on the
|
|
333
|
+
`words` rule, or `severity`/`message`/`code` overrides on it, and the words rule
|
|
334
|
+
reports.
|
|
335
|
+
|
|
336
|
+
## wordChars
|
|
337
|
+
|
|
338
|
+
`wordChars` is a single-character `RegExp` (`/[\p{L}\p{N}_$]/u` by default) and it
|
|
339
|
+
decides three things at once:
|
|
340
|
+
|
|
341
|
+
1. **What a completion replaces.** The word around the caret is expanded in both
|
|
342
|
+
directions with this predicate, and that range is the default a source returns
|
|
343
|
+
from `range`.
|
|
344
|
+
2. **What a diagnostic underlines**, when a check runs against a word-shaped
|
|
345
|
+
token.
|
|
346
|
+
3. **Where a double click puts the selection**, because that is the platform's
|
|
347
|
+
own word test and the browser uses the same notion of a word the field does.
|
|
348
|
+
|
|
349
|
+
A language whose names contain a dot or a hyphen must say so. `dshFontQueryGrammar`
|
|
350
|
+
uses `/[\p{L}\p{N}_-]/u` so `-apple-system` is one word; `dshSentryStyleGrammar`
|
|
351
|
+
deliberately does *not* include `.`, so a stray `circle.` is not read as one
|
|
352
|
+
unknown word and reported as a name the user never typed.
|
|
353
|
+
|
|
354
|
+
The flags are normalised: `g` and `y` are stripped from the predicate, because
|
|
355
|
+
`RegExp.prototype.test` advances `lastIndex` on a global pattern and a predicate
|
|
356
|
+
carrying one would answer correctly for the first character and then for every
|
|
357
|
+
other one.
|
|
358
|
+
|
|
359
|
+
## analyze
|
|
360
|
+
|
|
361
|
+
`analyze(text)` is called once per text, before the scan, and its return value is
|
|
362
|
+
threaded to every rule's `match.state` and to `validate`, `compose`, `describe`,
|
|
363
|
+
and `decorate` as `context.state`. It receives the text and **not** the tokens,
|
|
364
|
+
deliberately.
|
|
365
|
+
|
|
366
|
+
Handing it the tokens would make the scan depend on the analysis that depends on
|
|
367
|
+
the scan. The only ways out of that cycle are a second pass or a fixpoint, and
|
|
368
|
+
both of them mean the analysis can disagree with the paint — which is precisely
|
|
369
|
+
the failure this library was written to remove. A structural pass over a
|
|
370
|
+
line-oriented language does not need the lexical result anyway: splitting lines
|
|
371
|
+
and words is cheaper than asking the scanner to do it again, and `lineStarts` is
|
|
372
|
+
exported so the two agree on what a line is.
|
|
373
|
+
|
|
374
|
+
Nothing else about `analyze` is constrained. It may record problems (most grammars
|
|
375
|
+
do, so that one walk decides the structure and what is wrong with it together
|
|
376
|
+
rather than implementing one rule twice), and it may be expensive, because it runs
|
|
377
|
+
once per text and its result is reused by everything.
|
|
378
|
+
|
|
379
|
+
## Diagnostics
|
|
380
|
+
|
|
381
|
+
Four sources feed one list, in this order:
|
|
382
|
+
|
|
383
|
+
1. the lexical pass — a vocabulary rejection, an unclosed region;
|
|
384
|
+
2. `checks`, run over the tokens;
|
|
385
|
+
3. `validate`, the grammar's own hook;
|
|
386
|
+
4. nothing else: `inspect()` then deduplicates and sorts.
|
|
387
|
+
|
|
388
|
+
Two rules can legitimately notice the same mistake — a vocabulary's own rejection
|
|
389
|
+
and a validator both know that `nope` is not a colour — and drawing the underline
|
|
390
|
+
twice makes it darker rather than more informative. Duplicates are compared on
|
|
391
|
+
position, code, and message, so two genuinely different complaints about one word
|
|
392
|
+
both survive. The list is sorted by range.
|
|
393
|
+
|
|
394
|
+
### `checks`
|
|
395
|
+
|
|
396
|
+
| `CheckRule` field | Notes |
|
|
397
|
+
| --- | --- |
|
|
398
|
+
| `code` | Required. Copied onto every diagnostic the check raises |
|
|
399
|
+
| `severity` | Default `'error'` |
|
|
400
|
+
| `scopes` | The token scopes this check applies to. `'*'` matches every scope |
|
|
401
|
+
| `allow` | The words that are acceptable. A token in `scopes` whose text is not a member is reported. Any `WordsSource` will do |
|
|
402
|
+
| `message` | Required. `{word}` and `{allowed}` are substituted |
|
|
403
|
+
| `detail` | A second paragraph, shown under the message in a tooltip |
|
|
404
|
+
| `except` | A `RegExp` that skips tokens, so a check can carve out its exceptions |
|
|
405
|
+
| `perLine` | Runs at most once per line, on the first matching token |
|
|
406
|
+
|
|
407
|
+
Without `allow`, a check reports **every** token in `scopes` that `except` does not
|
|
408
|
+
skip — which is how you write a shape rule rather than a vocabulary rule:
|
|
409
|
+
|
|
410
|
+
```ts
|
|
411
|
+
checks: [
|
|
412
|
+
{
|
|
413
|
+
code: 'name-style',
|
|
414
|
+
scopes: ['name', 'form.name'],
|
|
415
|
+
except: /^[a-z][a-z0-9-]*$/,
|
|
416
|
+
severity: 'warning',
|
|
417
|
+
message: '"{word}" is not a lower-case name — write `email-address`, not `{word}`.',
|
|
418
|
+
},
|
|
419
|
+
]
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
A `WordsSource` used as `allow` is resolved once per check with the grammar's
|
|
423
|
+
analysis, so a check may consult a vocabulary the machine supplied (`{ text, state }`,
|
|
424
|
+
exactly as a `words` rule's source is). What it still cannot do is report a range
|
|
425
|
+
other than the token's own, so keep checks to facts about a token's text and leave
|
|
426
|
+
anything structural to `validate`.
|
|
427
|
+
|
|
428
|
+
### `validate` and `report`
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
validate: (context) => {
|
|
432
|
+
context.text // the document
|
|
433
|
+
context.tokens // the painted tokens
|
|
434
|
+
context.state // whatever analyze returned
|
|
435
|
+
context.report({ from, to, message, severity?, code?, detail? })
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
The grammar supplies the range, because only the grammar knows what the range
|
|
440
|
+
means. `severity` defaults to `'error'`, `code` to `'validate'`, and the
|
|
441
|
+
diagnostic's `source` is always the grammar's `id`.
|
|
442
|
+
|
|
443
|
+
`report` is also the place a structural walk that ran in `analyze` publishes what
|
|
444
|
+
it found — record the ranges while the structure is known, and report them here.
|
|
445
|
+
Both reference grammars do exactly that.
|
|
446
|
+
|
|
447
|
+
### Codes and severities
|
|
448
|
+
|
|
449
|
+
| Code | Where it comes from |
|
|
450
|
+
| --- | --- |
|
|
451
|
+
| `vocabulary:<id>` | A vocabulary's rejection; `unknownCode` changes it |
|
|
452
|
+
| `unknown-word` | A `words` rule's `unknown` with a message but no vocabulary to supply a code |
|
|
453
|
+
| `unclosed-region` | A region whose `end` was never found; `unclosed.code` changes it |
|
|
454
|
+
| *`<check.code>`* | A declarative check |
|
|
455
|
+
| `validate` | A `report` call with no code |
|
|
456
|
+
|
|
457
|
+
`Severity` is `'error' | 'warning' | 'info' | 'hint'` — four levels, painted as
|
|
458
|
+
`litearea-diag-error`, `-warning`, `-info`, and `-hint`, and one segment shows one
|
|
459
|
+
squiggle: the loudest diagnostic covering it wins, because two underlines on the
|
|
460
|
+
same characters only make a messier line. The wrapper also gains
|
|
461
|
+
`litearea-invalid` and the field `aria-invalid="true"` while any error is present.
|
|
462
|
+
|
|
463
|
+
## decorate
|
|
464
|
+
|
|
465
|
+
A token is what the *characters* are; a decoration is what they *mean*, and the two
|
|
466
|
+
change on different schedules.
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
decorate: (text, state) => readonly Decoration[]
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
A `Decoration` is `{ from, to, kind, title? }`: it is painted as
|
|
473
|
+
`litearea-dec-<kind>`, and `title` becomes the tooltip's title when the pointer
|
|
474
|
+
rests on it — with the grammar's `describe`, if there is one, still filling in the
|
|
475
|
+
detail and the body. Decorations are clamped into the document, dropped when they
|
|
476
|
+
come out empty, and sorted — a grammar computing them from a stale parse cannot
|
|
477
|
+
paint a span nobody can see.
|
|
478
|
+
|
|
479
|
+
The font grammar's "in effect" pill is the worked case. `Geist Mono` is painted as
|
|
480
|
+
a family from the characters alone — that is a token. Which family is *in effect*
|
|
481
|
+
depends on the machine's installed catalogue, so the same characters mean
|
|
482
|
+
something else on another computer. Painting that as a token would mean re-lexing
|
|
483
|
+
the document whenever the catalogue changed; as a decoration it is one recomputed
|
|
484
|
+
range list, which is what it actually is.
|
|
485
|
+
|
|
486
|
+
Reach for `decorate` when the mark depends on something outside the document. If
|
|
487
|
+
nothing in your language does, do not add one — a decoration that could have been
|
|
488
|
+
a scope is a second place for the same fact to live.
|
|
489
|
+
|
|
490
|
+
## describe and hover precedence
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
describe: (context) => HoverInfo | null | undefined
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`HoverInfo` is `{ title?, detail?, body?, range?, kind? }`, all plain text. There
|
|
497
|
+
is no markdown rendering and no HTML: a font family called `<b>` is shown as
|
|
498
|
+
`<b>`.
|
|
499
|
+
|
|
500
|
+
`resolveHover(inspection, grammar, offset)` resolves one offset, and the
|
|
501
|
+
precedence is fixed:
|
|
502
|
+
|
|
503
|
+
| Order | Source | Why it is here |
|
|
504
|
+
| --- | --- | --- |
|
|
505
|
+
| 1 | A diagnostic covering the offset | The user resting the pointer on a squiggle is asking what is *wrong*, and answering "circle: a full disc" is technically true and useless. The **narrowest** covering diagnostic wins, so a squiggle inside a wider warning still explains itself |
|
|
506
|
+
| 2 | A decoration's `title` | The grammar went out of its way to name this range |
|
|
507
|
+
| 3 | The grammar's `describe` | The general account of a token |
|
|
508
|
+
|
|
509
|
+
The last two are not exclusive. When a decoration with a `title` covers the
|
|
510
|
+
offset, that title becomes the tooltip's title **and** the grammar's `describe`
|
|
511
|
+
still supplies the detail line (falling back to its own title) and the body. A
|
|
512
|
+
decoration title is short by design — `in effect: Geist Mono` — while a
|
|
513
|
+
description is rich but says nothing about the mark, so showing only one of them
|
|
514
|
+
would always be missing the half the reader wanted.
|
|
515
|
+
|
|
516
|
+
`diagnosticHover(diagnostic)` is exported so a host that shows its own marker list
|
|
517
|
+
can use the same wording: the severity as the title, the `source` as the detail,
|
|
518
|
+
and the message plus `detail` as the body.
|
|
519
|
+
|
|
520
|
+
The `HoverContext` a grammar's `describe` receives carries `text`, `offset`,
|
|
521
|
+
`token`, `word`, `line`, `tokens`, `diagnostics`, and `state`. The diagnostic under
|
|
522
|
+
the offset is not offered separately, and that is a fact about the order of business
|
|
523
|
+
rather than an omission: a diagnostic outranks a description, so `describe` is only
|
|
524
|
+
ever called when nothing is wrong at that offset. A grammar that wants to know what
|
|
525
|
+
else is flagged reads `diagnostics`.
|
|
526
|
+
|
|
527
|
+
## A worked walkthrough
|
|
528
|
+
|
|
529
|
+
The language is the small form schema from the README, with the structural half
|
|
530
|
+
added. It is line-oriented and indentation-tolerant:
|
|
531
|
+
|
|
532
|
+
```
|
|
533
|
+
# the signup form
|
|
534
|
+
form signup
|
|
535
|
+
text email required
|
|
536
|
+
text password secret "at least 12 characters"
|
|
537
|
+
number age optional
|
|
538
|
+
text email optional
|
|
539
|
+
number Weight required
|
|
540
|
+
bogus color required
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
### The vocabulary, declared once
|
|
544
|
+
|
|
545
|
+
```ts
|
|
546
|
+
import { defineGrammar, defineVocabulary, lineStarts } from '@citisen/litearea'
|
|
547
|
+
import type { Severity } from '@citisen/litearea'
|
|
548
|
+
|
|
549
|
+
const FIELD_TYPES = ['text', 'number', 'bool'] as const
|
|
550
|
+
const OPTION_WORDS = ['required', 'optional', 'secret'] as const
|
|
551
|
+
|
|
552
|
+
const TYPES = defineVocabulary({
|
|
553
|
+
id: 'field-type',
|
|
554
|
+
words: FIELD_TYPES,
|
|
555
|
+
scope: 'field.type',
|
|
556
|
+
unknownMessage: '"{word}" is not a field type — expected {allowed}.',
|
|
557
|
+
docs: {
|
|
558
|
+
text: { detail: 'one line of text', body: 'The only type that can be `secret`.' },
|
|
559
|
+
number: { detail: 'a number', body: 'Kept as written, so `007` stays `007`.' },
|
|
560
|
+
bool: { detail: 'yes or no' },
|
|
561
|
+
},
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
const OPTIONS = defineVocabulary({
|
|
565
|
+
id: 'option',
|
|
566
|
+
words: OPTION_WORDS,
|
|
567
|
+
scope: 'option',
|
|
568
|
+
docs: {
|
|
569
|
+
required: { detail: 'cannot be left empty' },
|
|
570
|
+
optional: { detail: 'may be left empty' },
|
|
571
|
+
secret: { detail: 'never shown again', body: 'Only a `text` field can be secret.' },
|
|
572
|
+
},
|
|
573
|
+
})
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### The structural pass
|
|
577
|
+
|
|
578
|
+
This walk decides what each row *is* and records what is wrong with it in the same
|
|
579
|
+
pass. They are the same decision: the only reason to know which row a word belongs
|
|
580
|
+
to is to say what a word that belongs to nothing should have been, and splitting
|
|
581
|
+
them would mean two implementations of one rule.
|
|
582
|
+
|
|
583
|
+
```ts
|
|
584
|
+
interface Field {
|
|
585
|
+
name: string
|
|
586
|
+
type: string
|
|
587
|
+
options: string[]
|
|
588
|
+
line: number
|
|
589
|
+
from: number
|
|
590
|
+
to: number
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
interface Problem {
|
|
594
|
+
from: number
|
|
595
|
+
to: number
|
|
596
|
+
message: string
|
|
597
|
+
code: string
|
|
598
|
+
severity: Severity
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
interface FormState {
|
|
602
|
+
fields: Field[]
|
|
603
|
+
problems: Problem[]
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const analyze = (text: string): FormState => {
|
|
607
|
+
const fields: Field[] = []
|
|
608
|
+
const problems: Problem[] = []
|
|
609
|
+
const starts = lineStarts(text)
|
|
610
|
+
|
|
611
|
+
for (let index = 0; index < starts.length; index += 1) {
|
|
612
|
+
const from = starts[index] ?? 0
|
|
613
|
+
const raw = text.slice(from, starts[index + 1] ?? text.length)
|
|
614
|
+
// The reader drops a comment from the first `#` anywhere on the line.
|
|
615
|
+
const hash = raw.indexOf('#')
|
|
616
|
+
const body = hash === -1 ? raw : raw.slice(0, hash)
|
|
617
|
+
|
|
618
|
+
const words: Array<{ text: string; from: number; to: number }> = []
|
|
619
|
+
const pattern = /[^\s]+/g
|
|
620
|
+
let match: RegExpExecArray | null
|
|
621
|
+
while ((match = pattern.exec(body)) !== null) {
|
|
622
|
+
const at = from + match.index
|
|
623
|
+
words.push({ text: match[0], from: at, to: at + match[0].length })
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const type = words[0]
|
|
627
|
+
const name = words[1]
|
|
628
|
+
if (type === undefined || name === undefined) continue
|
|
629
|
+
if (!FIELD_TYPES.includes(type.text as (typeof FIELD_TYPES)[number])) continue
|
|
630
|
+
|
|
631
|
+
// A note is a quoted word, and the quotes belong to the syntax rather than to
|
|
632
|
+
// the option list, so it is left out here.
|
|
633
|
+
const options = words.slice(2).filter((word) => !word.text.startsWith('"'))
|
|
634
|
+
fields.push({
|
|
635
|
+
name: name.text,
|
|
636
|
+
type: type.text,
|
|
637
|
+
options: options.map((option) => option.text),
|
|
638
|
+
line: index,
|
|
639
|
+
from: name.from,
|
|
640
|
+
to: name.to,
|
|
641
|
+
})
|
|
642
|
+
|
|
643
|
+
if (options.some((option) => option.text === 'secret') && type.text !== 'text') {
|
|
644
|
+
problems.push({
|
|
645
|
+
from: name.from,
|
|
646
|
+
to: name.to,
|
|
647
|
+
code: 'secret-not-text',
|
|
648
|
+
severity: 'error',
|
|
649
|
+
message: `\`${name.text}\` is \`${type.text}\`, so it cannot be \`secret\`.`,
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const seen = new Set<string>()
|
|
655
|
+
for (const field of fields) {
|
|
656
|
+
if (seen.has(field.name)) {
|
|
657
|
+
problems.push({
|
|
658
|
+
from: field.from,
|
|
659
|
+
to: field.to,
|
|
660
|
+
code: 'duplicate-field',
|
|
661
|
+
severity: 'error',
|
|
662
|
+
message: `\`${field.name}\` is declared twice.`,
|
|
663
|
+
})
|
|
664
|
+
}
|
|
665
|
+
seen.add(field.name)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return { fields, problems }
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### The grammar
|
|
673
|
+
|
|
674
|
+
```ts
|
|
675
|
+
export const formSchema = defineGrammar({
|
|
676
|
+
id: 'form-schema',
|
|
677
|
+
name: 'form schema',
|
|
678
|
+
wordChars: /[\p{L}\p{N}_-]/u,
|
|
679
|
+
rules: [
|
|
680
|
+
{ kind: 'match', scope: 'comment', pattern: /#[^\n]*/ },
|
|
681
|
+
{ kind: 'match', scope: 'keyword', pattern: /form/, when: { prevNot: '\\w' } },
|
|
682
|
+
{
|
|
683
|
+
kind: 'match',
|
|
684
|
+
scope: 'form.name',
|
|
685
|
+
pattern: /[A-Za-z][\w-]*/,
|
|
686
|
+
when: { after: ['keyword'] },
|
|
687
|
+
},
|
|
688
|
+
{ kind: 'words', words: TYPES, when: { firstOnLine: true }, unknown: {} },
|
|
689
|
+
{ kind: 'words', words: OPTIONS },
|
|
690
|
+
{ kind: 'match', scope: 'name', pattern: /[A-Za-z][\w-]*/, when: { after: ['field.type'] } },
|
|
691
|
+
{ kind: 'region', scope: 'note', begin: /"/, end: /"/, unclosed: { severity: 'warning' } },
|
|
692
|
+
{ kind: 'match', scope: 'invalid', pattern: /\S+/ },
|
|
693
|
+
],
|
|
694
|
+
|
|
695
|
+
analyze,
|
|
696
|
+
|
|
697
|
+
checks: [
|
|
698
|
+
{
|
|
699
|
+
code: 'name-style',
|
|
700
|
+
scopes: ['name', 'form.name'],
|
|
701
|
+
except: /^[a-z][a-z0-9-]*$/,
|
|
702
|
+
severity: 'warning',
|
|
703
|
+
message: '"{word}" is not a lower-case name — write `email-address`, not `{word}`.',
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
|
|
707
|
+
validate: (context) => {
|
|
708
|
+
for (const problem of context.state.problems) context.report(problem)
|
|
709
|
+
},
|
|
710
|
+
|
|
711
|
+
compose: [
|
|
712
|
+
{
|
|
713
|
+
id: 'field-type',
|
|
714
|
+
when: (context) => context.firstWord,
|
|
715
|
+
range: (context) => context.word,
|
|
716
|
+
items: () =>
|
|
717
|
+
FIELD_TYPES.map((type) => ({
|
|
718
|
+
label: type,
|
|
719
|
+
append: ' ',
|
|
720
|
+
kind: 'type',
|
|
721
|
+
detail: TYPES.entryFor(type)?.detail,
|
|
722
|
+
documentation: TYPES.entryFor(type)?.body,
|
|
723
|
+
})),
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
id: 'option',
|
|
727
|
+
when: (context) =>
|
|
728
|
+
context.tokens.some(
|
|
729
|
+
(token) =>
|
|
730
|
+
token.line === context.line.number &&
|
|
731
|
+
token.scope === 'name' &&
|
|
732
|
+
token.to <= context.caret,
|
|
733
|
+
),
|
|
734
|
+
range: (context) => context.word,
|
|
735
|
+
items: () =>
|
|
736
|
+
OPTION_WORDS.map((word) => ({
|
|
737
|
+
label: word,
|
|
738
|
+
kind: 'option',
|
|
739
|
+
detail: OPTIONS.entryFor(word)?.detail,
|
|
740
|
+
})),
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
|
|
744
|
+
describe: (context) => {
|
|
745
|
+
const token = context.token
|
|
746
|
+
if (token === undefined) return undefined
|
|
747
|
+
if (token.scope === 'comment') return { title: 'comment', body: 'Ignored by the reader.' }
|
|
748
|
+
if (token.scope === 'note') return { title: 'note', body: 'Shown under the field.' }
|
|
749
|
+
if (token.scope === 'invalid') {
|
|
750
|
+
return { title: token.text, detail: 'not part of this language' }
|
|
751
|
+
}
|
|
752
|
+
const entry =
|
|
753
|
+
token.scope === 'field.type' ? TYPES.entryFor(token.text) : OPTIONS.entryFor(token.text)
|
|
754
|
+
return entry === undefined
|
|
755
|
+
? { title: token.text, detail: token.scope }
|
|
756
|
+
: { title: token.text, detail: entry.detail, body: entry.body }
|
|
757
|
+
},
|
|
758
|
+
})
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
### Reading each rule
|
|
762
|
+
|
|
763
|
+
| Rule | Why it is where it is |
|
|
764
|
+
| --- | --- |
|
|
765
|
+
| `comment` first | `#` is unambiguous, so it never needs to consult anything |
|
|
766
|
+
| `form` with `prevNot: '\\w'` | The keyword must not match the tail of a longer word, and the character class is what says so |
|
|
767
|
+
| `form.name` with `after: ['keyword']` | The name after `form` is free text, and `after` is the cheapest way to say "the token before me was the keyword" |
|
|
768
|
+
| `TYPES` with `firstOnLine: true` and `unknown: {}` | The first word of a row is a type or it is a mistake — the one place in this language where the lexical layer can be that sure. `unknown: {}` asks for the vocabulary's own message |
|
|
769
|
+
| `OPTIONS` with no `when` and no `unknown` | An option word may appear anywhere after a name and never rejects, so the rules below it still get their turn |
|
|
770
|
+
| `name` with `after: ['field.type']` | A field's name follows its type; putting the options above this rule means a field cannot be called `required`, which is a trade the language makes on purpose |
|
|
771
|
+
| the `note` region | Quotes make the body opaque, so a note containing the word `secret` is text and not an option. `unclosed` is a warning: the document still reads, it just reads further than intended |
|
|
772
|
+
| `invalid` last | Anything left is painted as a word this language does not know, so a typo is visible before the structural pass says anything about it |
|
|
773
|
+
|
|
774
|
+
### What the sample produces
|
|
775
|
+
|
|
776
|
+
| Where | Diagnostic |
|
|
777
|
+
| --- | --- |
|
|
778
|
+
| the second `email` | `duplicate-field`, error — from `validate`, reported with the range the walk recorded |
|
|
779
|
+
| `Weight` | `name-style`, warning — from `checks`, which sees only the token's own text |
|
|
780
|
+
| `bogus` | `vocabulary:field-type`, error — from the words rule's `unknown: {}` |
|
|
781
|
+
|
|
782
|
+
And three hovers: `note` over the quoted note, the field type's documentation over
|
|
783
|
+
`number`, and `Error` plus the rejection message over `bogus`. The third is the
|
|
784
|
+
hover precedence doing its job: the diagnostic outranks the token's description.
|
|
785
|
+
|
|
786
|
+
## What each hook may and may not do
|
|
787
|
+
|
|
788
|
+
| Hook | May | May not |
|
|
789
|
+
| --- | --- | --- |
|
|
790
|
+
| `rules` | Paint a scope, read the match's groups and `state`, look at position | Ask what rule is running, see any token's final scope, or match the empty string |
|
|
791
|
+
| `analyze` | Read the whole text, be expensive, return anything typed | See the tokens (by design), or paint |
|
|
792
|
+
| `checks` | Test a token's text against a word set, skip exceptions, fire once per line | See the analysis through `allow`, or report a range other than the token's |
|
|
793
|
+
| `validate` | See the text, the tokens, and the state; report any range, code, and severity | Suppress a lexical diagnostic, or change the paint |
|
|
794
|
+
| `compose` | See the text, the caret, the tokens, the diagnostics, and the state; return any range and any rows | Choose who wins — that is `priority` and `merge` — or apply an edit |
|
|
795
|
+
| `describe` | Read a token, a word, a line, and the state; return any text | Change the paint, or outrank a diagnostic |
|
|
796
|
+
| `decorate` | Return ranges, a kind, and a title | Change a token's scope, or clear a diagnostic |
|
|
797
|
+
|
|
798
|
+
Exceptions are not caught. A hook that throws propagates out of `inspect` (or
|
|
799
|
+
`complete`) into the caller, because a grammar that fails is a bug the host needs
|
|
800
|
+
to see rather than a document that quietly paints itself plain.
|
|
801
|
+
|
|
802
|
+
## Error behaviour
|
|
803
|
+
|
|
804
|
+
| Situation | What the engine does |
|
|
805
|
+
| --- | --- |
|
|
806
|
+
| A rule's pattern can match the empty string | Treated as not matching, so the scan advances |
|
|
807
|
+
| A pattern was written without `y` or `g` | The sticky flag is added and the compiled pattern cached, so it cannot skip ahead |
|
|
808
|
+
| `wordChars` carries `g` or `y` | The flag is stripped, because `test` would otherwise alternate answers |
|
|
809
|
+
| `when.prevNot` at offset 0 | The predicate holds: a rule guarded this way is not disabled at the start of the document |
|
|
810
|
+
| A plain region's body | One opaque span, found in a single search; the rule list never runs inside it and an inner `begin` is not matched |
|
|
811
|
+
| A `nested` region's body | Opaque as well, but its own `begin` and `end` are matched at every position, so the delimiters pair off |
|
|
812
|
+
| A `transparent` region's body | The rule list runs inside it, and unclaimed characters are painted with the region's content scope |
|
|
813
|
+
| A region's `end` is never found, or can match empty | The region runs to the end of the document and reports `unclosed` over its opening delimiter: a plain region when its search fails, a nested or transparent one when the document ends with a frame open, innermost frame first |
|
|
814
|
+
| An `unclosed.scope` on a nested or transparent region | Ignored: the content was already painted as it was scanned, so only `message`, `severity`, and `code` apply |
|
|
815
|
+
| A `words` rule does not match and has no `unknown` | The rule is skipped and the next rule is tried; no diagnostic |
|
|
816
|
+
| A `words` rule has `unknown` but no message anywhere | The token is painted with the unknown scope and **nothing** is reported |
|
|
817
|
+
| A member contains a character `wordChars` rejects | Matched literally, longest member first, after the word-run attempt — unless the rule sets `phrase`, which drops whitespace-containing members from that fallback so `max` stays a cap |
|
|
818
|
+
| A `words` rule rejects a word | Only the first word-shaped token is consumed |
|
|
819
|
+
| Several sources are eligible | The highest `priority` opens the list; equal priorities go to the one declared first |
|
|
820
|
+
| A list is already open | The source that answered keeps answering while its `when` holds, whatever the priorities say |
|
|
821
|
+
| Several diagnostics cover one range with the same code and message | One survives |
|
|
822
|
+
| A decoration falls outside the document | Clamped into it; when it comes out empty it is dropped |
|
|
823
|
+
| A hook throws | The exception propagates: `inspect` and `complete` do not catch |
|