@citisen/litearea 0.1.0 → 0.2.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/README.md +39 -18
- package/README.zh.md +25 -11
- package/dist/types/core/types.d.ts +5 -5
- package/dist/types/index.d.ts.map +1 -1
- package/docs/architecture.md +10 -6
- package/docs/completion.md +3 -3
- package/docs/grammar.md +16 -15
- package/package.json +1 -6
- package/scripts/browser-check.mjs +133 -54
- package/scripts/verify-package.mjs +51 -27
- package/src/core/types.ts +5 -5
- package/src/index.ts +6 -5
- package/dist/grammars.cjs +0 -1228
- package/dist/grammars.cjs.map +0 -1
- package/dist/grammars.js +0 -1213
- package/dist/grammars.js.map +0 -1
- package/dist/types/grammars/dshFont.d.ts +0 -127
- package/dist/types/grammars/dshFont.d.ts.map +0 -1
- package/dist/types/grammars/dshSentry.d.ts +0 -84
- package/dist/types/grammars/dshSentry.d.ts.map +0 -1
- package/dist/types/grammars/index.d.ts +0 -3
- package/dist/types/grammars/index.d.ts.map +0 -1
- package/src/grammars/dshFont.ts +0 -1004
- package/src/grammars/dshSentry.ts +0 -742
- package/src/grammars/index.ts +0 -57
|
@@ -1,742 +0,0 @@
|
|
|
1
|
-
// ─── dsh-sentry's style document, as a litearea grammar ─────────────────────
|
|
2
|
-
//
|
|
3
|
-
// The language is line-oriented: a line names one of the plugin's states and then
|
|
4
|
-
// says what that state looks like, either as bare words or as `key=value` pairs.
|
|
5
|
-
//
|
|
6
|
-
// # comments and blank lines are ignored
|
|
7
|
-
// running circle blue turn 3
|
|
8
|
-
// waiting rounded amber blink 1.1
|
|
9
|
-
// approval rounded amber blink 1.9
|
|
10
|
-
// done circle green flush 1.6
|
|
11
|
-
//
|
|
12
|
-
// running shape=none color=gray motion=still speed=1
|
|
13
|
-
//
|
|
14
|
-
// A bare word goes into the first positional slot the line has not filled, in the
|
|
15
|
-
// order shape, colour, pattern, motion, speed — except that the value sets are
|
|
16
|
-
// disjoint, so in practice the *word itself* says where it goes and the slot order
|
|
17
|
-
// only decides what a word that fits nothing is measured against. That is the
|
|
18
|
-
// plugin's own reading of the language and this grammar reproduces it.
|
|
19
|
-
//
|
|
20
|
-
// This is a REFERENCE grammar, not a built-in one. litearea ships no syntax of its
|
|
21
|
-
// own and nothing in `src/core/` knows this language exists; the file is here to
|
|
22
|
-
// prove that a fully custom rule set can express a real DSL, to be the worked
|
|
23
|
-
// example the documentation points at, and to be copied or imported by a host.
|
|
24
|
-
//
|
|
25
|
-
// Why the lexical layer cannot validate an option's value
|
|
26
|
-
// ------------------------------------------------------
|
|
27
|
-
// It is worth stating, because it is the first thing a grammar author tries. The
|
|
28
|
-
// obvious rule is "a word after `=` must be one of the shape words", and it is
|
|
29
|
-
// wrong: the rule can see the `=` but not the KEY, so it cannot tell `shape=blue`
|
|
30
|
-
// from `color=blue`. Written that way, `color=blue` earns a complaint that blue is
|
|
31
|
-
// not a shape. The key is structural information, so option values are validated by
|
|
32
|
-
// the structural pass below and only the line-leading state word is validated
|
|
33
|
-
// lexically, where nothing shares its position and there is nothing to confuse it
|
|
34
|
-
// with.
|
|
35
|
-
//
|
|
36
|
-
// Deliberately stricter than the host parser
|
|
37
|
-
// ------------------------------------------
|
|
38
|
-
// `parseStyle` in the plugin does no value checking for a known option: it writes
|
|
39
|
-
// `shape=bogus` into the rule and lets `resolveLook` quietly substitute the shipped
|
|
40
|
-
// default later. Nothing tells the user, and a typo shows up as an icon that simply
|
|
41
|
-
// never changed. This grammar reports it, as a warning rather than an error, since
|
|
42
|
-
// the document still works — it just does not mean what it says.
|
|
43
|
-
//
|
|
44
|
-
// It also does NOT accept `fallback`. The plugin's module comment shows a
|
|
45
|
-
// `fallback none` line, but `STYLE_STATES` holds only the four states and any other
|
|
46
|
-
// leading word is reported as an unknown state; `fallback` is derived internally
|
|
47
|
-
// from `STYLE_FALLBACK_LOOK` and has never been parseable. The comment is stale,
|
|
48
|
-
// and copying it here would have made the editor disagree with the parser it edits
|
|
49
|
-
// for.
|
|
50
|
-
|
|
51
|
-
import type {
|
|
52
|
-
Grammar,
|
|
53
|
-
ResolvedVocabulary,
|
|
54
|
-
Scope,
|
|
55
|
-
Severity,
|
|
56
|
-
SuggestionItem,
|
|
57
|
-
} from '../core/types.js'
|
|
58
|
-
import { defineGrammar } from '../core/grammar.js'
|
|
59
|
-
import { defineVocabulary, type VocabularySpec } from '../core/vocabulary.js'
|
|
60
|
-
import { lineStarts } from '../core/text.js'
|
|
61
|
-
|
|
62
|
-
/** One positional slot of a state line, in the order a bare word fills them. */
|
|
63
|
-
export type DshSentrySlot = 'shape' | 'color' | 'pattern' | 'motion' | 'speed'
|
|
64
|
-
|
|
65
|
-
/** The four facts one state's appearance is made of. */
|
|
66
|
-
export interface DshSentryLook {
|
|
67
|
-
shape: string
|
|
68
|
-
color: string
|
|
69
|
-
pattern: string
|
|
70
|
-
motion: string
|
|
71
|
-
speed: number
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** One word of the document, with the range it occupies. */
|
|
75
|
-
export interface DshSentryWord {
|
|
76
|
-
text: string
|
|
77
|
-
from: number
|
|
78
|
-
to: number
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** A problem the structural walk found, ready to be reported. */
|
|
82
|
-
export interface DshSentryProblem {
|
|
83
|
-
from: number
|
|
84
|
-
to: number
|
|
85
|
-
message: string
|
|
86
|
-
code: string
|
|
87
|
-
severity: Severity
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** One line, read. */
|
|
91
|
-
export interface DshSentryLine {
|
|
92
|
-
/** Zero-based line number, so a completion can find the line the caret is on. */
|
|
93
|
-
number: number
|
|
94
|
-
/** The state the line opens, as written. */
|
|
95
|
-
state: string | undefined
|
|
96
|
-
/** Whether that state is one this document understands. */
|
|
97
|
-
known: boolean
|
|
98
|
-
/** The words on the line, comments removed. */
|
|
99
|
-
words: DshSentryWord[]
|
|
100
|
-
/** The value each positional slot ended up with, whatever syntax put it there. */
|
|
101
|
-
slots: Partial<Record<DshSentrySlot, string>>
|
|
102
|
-
/** The option keys the line named, in order, whether or not they are known. */
|
|
103
|
-
keys: string[]
|
|
104
|
-
/** The option key whose value the line has not written yet, as `speed=`. */
|
|
105
|
-
pendingKey: string | undefined
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** What one pass over the document produced. */
|
|
109
|
-
export interface DshSentryState {
|
|
110
|
-
lines: DshSentryLine[]
|
|
111
|
-
/** What the structural walk found. `validate` reports these verbatim. */
|
|
112
|
-
problems: DshSentryProblem[]
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** The vocabularies and shipped defaults a host may override. */
|
|
116
|
-
export interface DshSentryStyleOptions {
|
|
117
|
-
/** The states a document may address, in the order the list shows them. */
|
|
118
|
-
states?: readonly string[]
|
|
119
|
-
/** The background shapes a rule may name. */
|
|
120
|
-
shapes?: readonly string[]
|
|
121
|
-
/** The motions a rule may apply. */
|
|
122
|
-
motions?: readonly string[]
|
|
123
|
-
/** The patterns still accepted as `pattern=<name>`. */
|
|
124
|
-
patterns?: readonly string[]
|
|
125
|
-
/** The named palette. Presets on purpose: a free colour can be illegible. */
|
|
126
|
-
colors?: Readonly<Record<string, string>>
|
|
127
|
-
/** The option keys a rule may write. */
|
|
128
|
-
options?: readonly string[]
|
|
129
|
-
/** The shipped look per state, used to rank and to document. */
|
|
130
|
-
defaults?: Readonly<Record<string, DshSentryLook>>
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ─── the shipped vocabulary ─────────────────────────────────────────────────
|
|
134
|
-
//
|
|
135
|
-
// Every word below is verbatim from the plugin, including the palette, whose eight
|
|
136
|
-
// members exist because the two contrast failures that plugin has already shipped
|
|
137
|
-
// were both free colour choices. A preset cannot be illegible, so the set is closed
|
|
138
|
-
// and the editor must not offer anything else.
|
|
139
|
-
|
|
140
|
-
const DEFAULT_STATES = ['running', 'waiting', 'approval', 'done']
|
|
141
|
-
const DEFAULT_SHAPES = ['circle', 'rounded', 'square', 'none']
|
|
142
|
-
const DEFAULT_MOTIONS = ['still', 'turn', 'blink', 'flush']
|
|
143
|
-
const DEFAULT_PATTERNS: readonly string[] = []
|
|
144
|
-
const DEFAULT_COLORS: Readonly<Record<string, string>> = {
|
|
145
|
-
blue: '#4d6bfe',
|
|
146
|
-
amber: '#f59e0b',
|
|
147
|
-
green: '#22c55e',
|
|
148
|
-
red: '#ef4444',
|
|
149
|
-
purple: '#8b5cf6',
|
|
150
|
-
gray: '#8b8f97',
|
|
151
|
-
dark: '#23262c',
|
|
152
|
-
light: '#eef0f3',
|
|
153
|
-
}
|
|
154
|
-
const DEFAULT_OPTIONS = ['shape', 'color', 'pattern', 'motion', 'speed', 'bg']
|
|
155
|
-
const DEFAULT_SLOTS: readonly DshSentrySlot[] = ['shape', 'color', 'pattern', 'motion', 'speed']
|
|
156
|
-
const DEFAULT_LOOK: Readonly<Record<string, DshSentryLook>> = {
|
|
157
|
-
running: { shape: 'circle', color: 'blue', pattern: 'none', motion: 'turn', speed: 3 },
|
|
158
|
-
waiting: { shape: 'rounded', color: 'amber', pattern: 'none', motion: 'blink', speed: 1.1 },
|
|
159
|
-
approval: { shape: 'rounded', color: 'amber', pattern: 'none', motion: 'blink', speed: 1.9 },
|
|
160
|
-
done: { shape: 'circle', color: 'green', pattern: 'none', motion: 'flush', speed: 1.6 },
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** The scope each slot's values are painted under. */
|
|
164
|
-
const SLOT_SCOPE: Record<DshSentrySlot, Scope> = {
|
|
165
|
-
shape: 'value.shape',
|
|
166
|
-
color: 'value.color',
|
|
167
|
-
pattern: 'value.pattern',
|
|
168
|
-
motion: 'value.motion',
|
|
169
|
-
speed: 'value.number',
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Which positional slot an option key writes. `bg` is an alias for `color`. */
|
|
173
|
-
const KEY_SLOT: Record<string, DshSentrySlot | undefined> = {
|
|
174
|
-
shape: 'shape',
|
|
175
|
-
color: 'color',
|
|
176
|
-
bg: 'color',
|
|
177
|
-
pattern: 'pattern',
|
|
178
|
-
motion: 'motion',
|
|
179
|
-
speed: 'speed',
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/** The nine colours, so a message can list them without resolving a vocabulary. */
|
|
183
|
-
const COLOR_NAMES_OF = (colors: Readonly<Record<string, string>>): string[] => Object.keys(colors)
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* A vocabulary, with the one setting this grammar always wants.
|
|
187
|
-
*
|
|
188
|
-
* `caseSensitive` is on for every word here because the host parser compares with
|
|
189
|
-
* `includes` on the literal: a suggestion list that accepted `Circle` would be
|
|
190
|
-
* teaching a spelling the plugin then rejects.
|
|
191
|
-
* @param spec - the declaration, minus the repeated setting.
|
|
192
|
-
* @returns the resolved vocabulary.
|
|
193
|
-
*/
|
|
194
|
-
function closed<State>(spec: VocabularySpec<State>): ResolvedVocabulary<State> {
|
|
195
|
-
return defineVocabulary<State>({ ...spec, caseSensitive: true })
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Build the grammar for dsh-sentry's style document.
|
|
200
|
-
* @param options - the vocabularies and shipped defaults, defaulting to the plugin's.
|
|
201
|
-
* @returns a grammar that paints, completes, diagnoses, and explains the language.
|
|
202
|
-
*/
|
|
203
|
-
export function dshSentryStyleGrammar(
|
|
204
|
-
options: DshSentryStyleOptions = {},
|
|
205
|
-
): Grammar<DshSentryState> {
|
|
206
|
-
const STATES = options.states ?? DEFAULT_STATES
|
|
207
|
-
const SHAPES = options.shapes ?? DEFAULT_SHAPES
|
|
208
|
-
const MOTIONS = options.motions ?? DEFAULT_MOTIONS
|
|
209
|
-
const PATTERNS = options.patterns ?? DEFAULT_PATTERNS
|
|
210
|
-
const COLORS = options.colors ?? DEFAULT_COLORS
|
|
211
|
-
const COLOR_NAMES = COLOR_NAMES_OF(COLORS)
|
|
212
|
-
const OPTIONS = options.options ?? DEFAULT_OPTIONS
|
|
213
|
-
const LOOK = options.defaults ?? DEFAULT_LOOK
|
|
214
|
-
const SLOTS = DEFAULT_SLOTS
|
|
215
|
-
/** The patterns a bare word may name. Empty in this build, and that is the shipped truth. */
|
|
216
|
-
const PATTERN_WORDS = PATTERNS.length > 0 ? PATTERNS : ['none']
|
|
217
|
-
|
|
218
|
-
// ── vocabularies ────────────────────────────────────────────────────────
|
|
219
|
-
// Declared once each so the word set, the paint, the hover text, and the
|
|
220
|
-
// completion list cannot disagree. Nothing here carries `unknownMessage` except
|
|
221
|
-
// the state vocabulary, because nothing else can be validated from a token: see
|
|
222
|
-
// the header note about the key a lexical rule cannot see.
|
|
223
|
-
|
|
224
|
-
const STATE_VOCAB = closed({
|
|
225
|
-
id: 'state',
|
|
226
|
-
words: STATES,
|
|
227
|
-
scope: 'state',
|
|
228
|
-
unknownMessage: 'Unknown state "{word}" — this document understands {allowed}.',
|
|
229
|
-
docs: {
|
|
230
|
-
running: { detail: 'a turn is in progress', body: 'The agent is working and does not need anyone.' },
|
|
231
|
-
waiting: { detail: 'a question is waiting', body: 'The agent asked something, and the turn is blocked until it is answered.' },
|
|
232
|
-
approval: { detail: 'a permission is waiting', body: 'The agent requested an escalation or a plan review, and the turn is blocked on it.' },
|
|
233
|
-
done: { detail: 'the turn finished', body: 'The agent stopped and left the tab alone.' },
|
|
234
|
-
},
|
|
235
|
-
})
|
|
236
|
-
|
|
237
|
-
const SHAPE_VOCAB = closed({
|
|
238
|
-
id: 'shape',
|
|
239
|
-
words: SHAPES,
|
|
240
|
-
scope: SLOT_SCOPE.shape,
|
|
241
|
-
docs: {
|
|
242
|
-
circle: { detail: 'a full disc' },
|
|
243
|
-
rounded: { detail: 'a rounded square' },
|
|
244
|
-
square: { detail: 'a square with sharp corners' },
|
|
245
|
-
none: {
|
|
246
|
-
detail: 'no background',
|
|
247
|
-
body: 'The fish alone, on whatever the tab gives it. A bare `none` is a SHAPE and never a pattern: one word cannot mean two things.',
|
|
248
|
-
},
|
|
249
|
-
},
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
const COLOR_VOCAB = closed({
|
|
253
|
-
id: 'color',
|
|
254
|
-
words: COLOR_NAMES,
|
|
255
|
-
scope: SLOT_SCOPE.color,
|
|
256
|
-
docs: Object.fromEntries(Object.entries(COLORS).map(([name, hex]) => [name, { detail: hex }])),
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
const MOTION_VOCAB = closed({
|
|
260
|
-
id: 'motion',
|
|
261
|
-
words: MOTIONS,
|
|
262
|
-
scope: SLOT_SCOPE.motion,
|
|
263
|
-
docs: {
|
|
264
|
-
still: { detail: 'nothing moves' },
|
|
265
|
-
turn: { detail: 'rotates', body: 'speed is seconds per revolution.' },
|
|
266
|
-
blink: { detail: 'alternates', body: 'speed is seconds per cycle.' },
|
|
267
|
-
flush: { detail: 'pulses', body: 'speed is seconds per cycle.' },
|
|
268
|
-
},
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
const PATTERN_VOCAB = closed({
|
|
272
|
-
id: 'pattern',
|
|
273
|
-
words: PATTERN_WORDS,
|
|
274
|
-
scope: SLOT_SCOPE.pattern,
|
|
275
|
-
docs: {
|
|
276
|
-
none: {
|
|
277
|
-
detail: 'carve nothing',
|
|
278
|
-
body: 'Every dial-like pattern was tried on a real 16px favicon and read as noise, so `none` is the only pattern left. It is written as `pattern=none` and never as a bare word, because `none` is also a shape.',
|
|
279
|
-
},
|
|
280
|
-
},
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
/** What each option key explains about itself. */
|
|
284
|
-
const KEY_DOCS: Record<string, { detail: string; body: string }> = {
|
|
285
|
-
shape: { detail: 'background shape', body: `One of ${SHAPES.join(', ')}.` },
|
|
286
|
-
color: { detail: 'disc colour', body: `${COLOR_NAMES.join(', ')} — all presets, chosen so the fish stays legible.` },
|
|
287
|
-
pattern: { detail: 'carved pattern', body: 'Only `none` remains; write it as `pattern=none`.' },
|
|
288
|
-
motion: { detail: 'what moves', body: `One of ${MOTIONS.join(', ')}.` },
|
|
289
|
-
speed: { detail: 'seconds per cycle', body: 'A number: seconds per revolution for `turn`, seconds per cycle otherwise.' },
|
|
290
|
-
bg: { detail: 'an alias for color', body: 'Kept so a document written against an earlier release still says what it means.' },
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/** Every vocabulary, by the scope its words are painted under, for hover. */
|
|
294
|
-
const BY_SCOPE = new Map<Scope, ResolvedVocabulary<DshSentryState>>([
|
|
295
|
-
['state', STATE_VOCAB],
|
|
296
|
-
[SLOT_SCOPE.shape, SHAPE_VOCAB],
|
|
297
|
-
[SLOT_SCOPE.color, COLOR_VOCAB],
|
|
298
|
-
[SLOT_SCOPE.pattern, PATTERN_VOCAB],
|
|
299
|
-
[SLOT_SCOPE.motion, MOTION_VOCAB],
|
|
300
|
-
])
|
|
301
|
-
|
|
302
|
-
/** The vocabulary that decides a value written for an option key. */
|
|
303
|
-
const VOCAB_FOR_KEY: Record<string, ResolvedVocabulary<DshSentryState>> = {
|
|
304
|
-
shape: SHAPE_VOCAB,
|
|
305
|
-
color: COLOR_VOCAB,
|
|
306
|
-
bg: COLOR_VOCAB,
|
|
307
|
-
pattern: PATTERN_VOCAB,
|
|
308
|
-
motion: MOTION_VOCAB,
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** The words a slot accepts, for both a message and a completion list. */
|
|
312
|
-
const wordsForSlot = (slot: string): readonly string[] => {
|
|
313
|
-
if (slot === 'shape') return SHAPES
|
|
314
|
-
if (slot === 'color' || slot === 'bg') return COLOR_NAMES
|
|
315
|
-
if (slot === 'motion') return MOTIONS
|
|
316
|
-
if (slot === 'pattern') return PATTERN_WORDS
|
|
317
|
-
return []
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* What a slot expects, in words, for a diagnostic message.
|
|
322
|
-
* @param slot - a positional slot name, or an option key such as `bg`.
|
|
323
|
-
* @returns a readable list.
|
|
324
|
-
*/
|
|
325
|
-
const expectedList = (slot: string): string => {
|
|
326
|
-
if (slot === 'speed') return 'a number of seconds, such as 3 or 1.1'
|
|
327
|
-
const words = wordsForSlot(slot)
|
|
328
|
-
if (words.length === 0) return `pattern=<name>`
|
|
329
|
-
return words.join(', ')
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Whether a value written for an option key is acceptable.
|
|
334
|
-
* @param key - the option key.
|
|
335
|
-
* @param value - the value as written.
|
|
336
|
-
* @returns a complaint, or undefined when the value is fine.
|
|
337
|
-
*/
|
|
338
|
-
const valueProblem = (key: string, value: string): string | undefined => {
|
|
339
|
-
if (key === 'speed') {
|
|
340
|
-
return Number.isFinite(Number.parseFloat(value))
|
|
341
|
-
? undefined
|
|
342
|
-
: `"${value}" is not a speed — write a number of seconds, such as 3 or 1.1. The shipped rate is used instead.`
|
|
343
|
-
}
|
|
344
|
-
if (VOCAB_FOR_KEY[key] === undefined) return undefined
|
|
345
|
-
const words = wordsForSlot(key)
|
|
346
|
-
if (words.includes(value)) return undefined
|
|
347
|
-
const noun = key === 'bg' ? 'preset colour' : key
|
|
348
|
-
return `"${value}" is not a ${noun} — expected ${words.join(', ')}. The shipped default is used instead.`
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return defineGrammar<DshSentryState>({
|
|
352
|
-
id: 'dsh-sentry-style',
|
|
353
|
-
name: 'dsh-sentry style document',
|
|
354
|
-
|
|
355
|
-
// A decimal speed is one token because the number RULE says so, not because `.`
|
|
356
|
-
// is a word character. Leaving `.` out of the predicate stops a stray
|
|
357
|
-
// `circle.` from being read as one word, matching no shape, and earning a
|
|
358
|
-
// diagnostic about a word the user never typed.
|
|
359
|
-
wordChars: /[\p{L}\p{N}_]/u,
|
|
360
|
-
|
|
361
|
-
rules: [
|
|
362
|
-
{ kind: 'match', scope: 'comment', pattern: /#[^\n]*/ },
|
|
363
|
-
|
|
364
|
-
// The first word on a line is a state or it is a mistake. `unknown: {}` asks
|
|
365
|
-
// for the vocabulary's own rejection, and this is the one place in the
|
|
366
|
-
// language where the lexical layer can be that sure: nothing else may stand
|
|
367
|
-
// at the head of a line, so there is no later rule to wait for.
|
|
368
|
-
{
|
|
369
|
-
kind: 'words',
|
|
370
|
-
words: STATE_VOCAB,
|
|
371
|
-
when: { firstOnLine: true },
|
|
372
|
-
unknown: {},
|
|
373
|
-
},
|
|
374
|
-
|
|
375
|
-
// `shape=` — the key, seen from the `=` so it cannot also swallow a value.
|
|
376
|
-
{ kind: 'match', scope: 'property', pattern: /[A-Za-z][\w-]*(?==)/ },
|
|
377
|
-
{ kind: 'match', scope: 'operator', pattern: /=/ },
|
|
378
|
-
{ kind: 'match', scope: 'separator', pattern: /,/ },
|
|
379
|
-
|
|
380
|
-
// Values. The sets are disjoint, so membership alone places a bare word, and
|
|
381
|
-
// none of these rules rejects: a rule that did would claim a word belonging
|
|
382
|
-
// to the next vocabulary down the list.
|
|
383
|
-
{ kind: 'words', words: SHAPE_VOCAB },
|
|
384
|
-
{ kind: 'words', words: COLOR_VOCAB },
|
|
385
|
-
{ kind: 'words', words: MOTION_VOCAB },
|
|
386
|
-
{ kind: 'match', scope: SLOT_SCOPE.speed, pattern: /\d+(?:\.\d+)?/ },
|
|
387
|
-
|
|
388
|
-
// Anything left is a word this language does not know. Painting it as invalid
|
|
389
|
-
// rather than as plain text makes a typo visible before the structural pass
|
|
390
|
-
// has even run, and that pass supplies the precise message.
|
|
391
|
-
{ kind: 'match', scope: 'invalid', pattern: /\S+/ },
|
|
392
|
-
],
|
|
393
|
-
|
|
394
|
-
fallbackScope: 'text',
|
|
395
|
-
|
|
396
|
-
// ── what the document means ───────────────────────────────────────────
|
|
397
|
-
//
|
|
398
|
-
// This walk decides the slot filling AND records what went wrong, rather than
|
|
399
|
-
// leaving the second job to a second walk. They are the same decision: the only
|
|
400
|
-
// reason to know which slot is free is to say what a word that fits nothing
|
|
401
|
-
// should have been, and splitting them would mean two implementations of one
|
|
402
|
-
// rule — which is exactly how the editors this library replaces came to
|
|
403
|
-
// disagree with themselves.
|
|
404
|
-
analyze: (text) => {
|
|
405
|
-
const starts = lineStarts(text)
|
|
406
|
-
const lines: DshSentryLine[] = []
|
|
407
|
-
const problems: DshSentryProblem[] = []
|
|
408
|
-
|
|
409
|
-
for (let number = 0; number < starts.length; number += 1) {
|
|
410
|
-
const from = starts[number] ?? 0
|
|
411
|
-
const rawTo = starts[number + 1] ?? text.length
|
|
412
|
-
let to = rawTo
|
|
413
|
-
while (to > from && (text.charAt(to - 1) === '\n' || text.charAt(to - 1) === '\r')) to -= 1
|
|
414
|
-
const raw = text.slice(from, to)
|
|
415
|
-
// The host parser splits the comment off at the first `#` anywhere on the
|
|
416
|
-
// line, so a `#` inside a value begins a comment there too.
|
|
417
|
-
const comment = raw.indexOf('#')
|
|
418
|
-
const body = comment === -1 ? raw : raw.slice(0, comment)
|
|
419
|
-
|
|
420
|
-
const words: DshSentryWord[] = []
|
|
421
|
-
const wordPattern = /[^\s,]+/g
|
|
422
|
-
let match: RegExpExecArray | null
|
|
423
|
-
while ((match = wordPattern.exec(body)) !== null) {
|
|
424
|
-
words.push({
|
|
425
|
-
text: match[0],
|
|
426
|
-
from: from + match.index,
|
|
427
|
-
to: from + match.index + match[0].length,
|
|
428
|
-
})
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const line: DshSentryLine = {
|
|
432
|
-
number,
|
|
433
|
-
state: undefined,
|
|
434
|
-
known: true,
|
|
435
|
-
words,
|
|
436
|
-
slots: {},
|
|
437
|
-
keys: [],
|
|
438
|
-
pendingKey: undefined,
|
|
439
|
-
}
|
|
440
|
-
lines.push(line)
|
|
441
|
-
|
|
442
|
-
const first = words[0]
|
|
443
|
-
if (first === undefined) continue
|
|
444
|
-
line.state = first.text
|
|
445
|
-
line.known = STATES.includes(first.text)
|
|
446
|
-
// An unknown state is a dead end for the parser: it discards the rest of
|
|
447
|
-
// the line, so complaining about the values on it would be inventing
|
|
448
|
-
// problems the document does not have.
|
|
449
|
-
if (!line.known) continue
|
|
450
|
-
|
|
451
|
-
const claim = (slot: DshSentrySlot, value: string): void => {
|
|
452
|
-
line.slots[slot] = value
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
for (let index = 1; index < words.length; index += 1) {
|
|
456
|
-
const word = words[index]
|
|
457
|
-
if (word === undefined) continue
|
|
458
|
-
const equals = word.text.indexOf('=')
|
|
459
|
-
const key = equals === -1 ? undefined : word.text.slice(0, equals)
|
|
460
|
-
const value = equals === -1 ? undefined : word.text.slice(equals + 1)
|
|
461
|
-
|
|
462
|
-
if (key !== undefined) {
|
|
463
|
-
line.keys.push(key)
|
|
464
|
-
// `key` exists only because the word held an `=`, so there is always a
|
|
465
|
-
// right-hand side; an empty one means the value is still being typed.
|
|
466
|
-
const written = value ?? ''
|
|
467
|
-
if (written === '') {
|
|
468
|
-
line.pendingKey = key
|
|
469
|
-
continue
|
|
470
|
-
}
|
|
471
|
-
const complaint = valueProblem(key, written)
|
|
472
|
-
if (complaint !== undefined) {
|
|
473
|
-
problems.push({
|
|
474
|
-
from: word.from,
|
|
475
|
-
to: word.to,
|
|
476
|
-
message: complaint,
|
|
477
|
-
code: 'bad-option-value',
|
|
478
|
-
severity: 'warning',
|
|
479
|
-
})
|
|
480
|
-
continue
|
|
481
|
-
}
|
|
482
|
-
const slot = KEY_SLOT[key]
|
|
483
|
-
if (slot !== undefined) claim(slot, written)
|
|
484
|
-
continue
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// A bare word is placed by what it IS. The sets are disjoint, which is
|
|
488
|
-
// what lets the position be inferred without the slot order mattering.
|
|
489
|
-
if (MOTIONS.includes(word.text)) {
|
|
490
|
-
claim('motion', word.text)
|
|
491
|
-
continue
|
|
492
|
-
}
|
|
493
|
-
if (SHAPES.includes(word.text)) {
|
|
494
|
-
claim('shape', word.text)
|
|
495
|
-
continue
|
|
496
|
-
}
|
|
497
|
-
if (COLOR_NAMES.includes(word.text)) {
|
|
498
|
-
claim('color', word.text)
|
|
499
|
-
continue
|
|
500
|
-
}
|
|
501
|
-
if (PATTERNS.includes(word.text)) {
|
|
502
|
-
claim('pattern', word.text)
|
|
503
|
-
continue
|
|
504
|
-
}
|
|
505
|
-
if (Number.isFinite(Number.parseFloat(word.text))) {
|
|
506
|
-
claim('speed', word.text)
|
|
507
|
-
continue
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// It fits nothing, so the host parser measures it against the first slot
|
|
511
|
-
// the line has not filled — and since every value set has been ruled out
|
|
512
|
-
// above, the answer can only be "not valid for that slot" or "there is no
|
|
513
|
-
// slot left".
|
|
514
|
-
const free = SLOTS.find((slot) => line.slots[slot] === undefined)
|
|
515
|
-
if (free === undefined) {
|
|
516
|
-
problems.push({
|
|
517
|
-
from: word.from,
|
|
518
|
-
to: word.to,
|
|
519
|
-
message: `"${word.text}" has nowhere to go — this line already names a shape, colour, pattern, motion, and speed.`,
|
|
520
|
-
code: 'unexpected-value',
|
|
521
|
-
severity: 'error',
|
|
522
|
-
})
|
|
523
|
-
} else {
|
|
524
|
-
problems.push({
|
|
525
|
-
from: word.from,
|
|
526
|
-
to: word.to,
|
|
527
|
-
message: `"${word.text}" is not a valid ${free} — expected ${expectedList(free)}.`,
|
|
528
|
-
code: 'bad-value',
|
|
529
|
-
severity: 'error',
|
|
530
|
-
})
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
return { lines, problems }
|
|
536
|
-
},
|
|
537
|
-
|
|
538
|
-
// ── what the tokens must be ───────────────────────────────────────────
|
|
539
|
-
// One declarative check, because "a key must be one it knows" is exactly the
|
|
540
|
-
// shape a check is for: a scope, an allowed set, and a message.
|
|
541
|
-
checks: [
|
|
542
|
-
{
|
|
543
|
-
code: 'unknown-option',
|
|
544
|
-
scopes: ['property'],
|
|
545
|
-
allow: closed({ id: 'option', words: [...OPTIONS, ...PATTERNS] }),
|
|
546
|
-
severity: 'error',
|
|
547
|
-
message: 'Unknown option "{word}" — this document understands {allowed}.',
|
|
548
|
-
},
|
|
549
|
-
],
|
|
550
|
-
|
|
551
|
-
validate: (context) => {
|
|
552
|
-
for (const problem of context.state.problems) {
|
|
553
|
-
context.report({
|
|
554
|
-
from: problem.from,
|
|
555
|
-
to: problem.to,
|
|
556
|
-
message: problem.message,
|
|
557
|
-
code: problem.code,
|
|
558
|
-
severity: problem.severity,
|
|
559
|
-
})
|
|
560
|
-
}
|
|
561
|
-
},
|
|
562
|
-
|
|
563
|
-
// ── what can come next ────────────────────────────────────────────────
|
|
564
|
-
compose: [
|
|
565
|
-
{
|
|
566
|
-
id: 'state',
|
|
567
|
-
// A state opens a line, so its list belongs at the head of one — and it has to
|
|
568
|
-
// stay offered while the state is being spelled, which is why this asks
|
|
569
|
-
// `firstWord` rather than `firstOnLine`. Asking the stricter question makes the
|
|
570
|
-
// list vanish after the first letter, which is precisely the "the completion
|
|
571
|
-
// feels unnatural" complaint this grammar exists to answer.
|
|
572
|
-
when: (context) => context.firstWord,
|
|
573
|
-
range: (context) => context.word,
|
|
574
|
-
items: () =>
|
|
575
|
-
STATES.map((state) => ({
|
|
576
|
-
label: state,
|
|
577
|
-
insert: state,
|
|
578
|
-
// A space is what the next word on the line needs. The engine will not
|
|
579
|
-
// add a second one if the document already has whitespace there.
|
|
580
|
-
append: ' ',
|
|
581
|
-
kind: 'state',
|
|
582
|
-
detail: STATE_VOCAB.entryFor(state)?.detail,
|
|
583
|
-
documentation: STATE_VOCAB.entryFor(state)?.body,
|
|
584
|
-
sortText: '0',
|
|
585
|
-
})),
|
|
586
|
-
},
|
|
587
|
-
{
|
|
588
|
-
id: 'value',
|
|
589
|
-
when: (context) => {
|
|
590
|
-
const line = context.state.lines[context.line.number]
|
|
591
|
-
// Values belong to a line that has opened a state, and to the part of it
|
|
592
|
-
// after the state word.
|
|
593
|
-
if (line === undefined || line.state === undefined || !line.known) return false
|
|
594
|
-
const stateWord = line.words[0]
|
|
595
|
-
return stateWord !== undefined && context.caret > stateWord.to
|
|
596
|
-
},
|
|
597
|
-
range: (context) => context.word,
|
|
598
|
-
items: (context) => {
|
|
599
|
-
const line = context.state.lines[context.line.number]
|
|
600
|
-
// Inside `key=`, the key names the slot, so the list is exactly that slot's
|
|
601
|
-
// vocabulary.
|
|
602
|
-
const key = optionAtCaret(context.line.before)
|
|
603
|
-
if (key !== undefined && key !== '') {
|
|
604
|
-
return valueItems(key, line)
|
|
605
|
-
}
|
|
606
|
-
const free = SLOTS.find((slot) => line?.slots[slot] === undefined)
|
|
607
|
-
const items: SuggestionItem[] = free === undefined ? [] : valueItems(free, line)
|
|
608
|
-
// The keys follow the values: a line names values far more often than it
|
|
609
|
-
// switches to the `key=value` spelling, so the values lead.
|
|
610
|
-
for (const key of OPTIONS) {
|
|
611
|
-
if (line?.keys.includes(key) === true) continue
|
|
612
|
-
const doc = KEY_DOCS[key]
|
|
613
|
-
items.push({
|
|
614
|
-
label: `${key}=`,
|
|
615
|
-
insert: `${key}=`,
|
|
616
|
-
kind: 'property',
|
|
617
|
-
detail: doc?.detail,
|
|
618
|
-
documentation: doc?.body,
|
|
619
|
-
sortText: '1',
|
|
620
|
-
})
|
|
621
|
-
}
|
|
622
|
-
return items
|
|
623
|
-
},
|
|
624
|
-
},
|
|
625
|
-
],
|
|
626
|
-
|
|
627
|
-
// ── what a thing is ───────────────────────────────────────────────────
|
|
628
|
-
describe: (context) => {
|
|
629
|
-
const token = context.token
|
|
630
|
-
if (token === undefined) return undefined
|
|
631
|
-
if (token.scope === 'comment') {
|
|
632
|
-
return { title: 'comment', body: 'Ignored by the parser. A `#` anywhere on a line starts one.' }
|
|
633
|
-
}
|
|
634
|
-
if (token.scope === 'property') {
|
|
635
|
-
const doc = KEY_DOCS[token.text]
|
|
636
|
-
return doc === undefined ? undefined : { title: token.text, detail: doc.detail, body: doc.body }
|
|
637
|
-
}
|
|
638
|
-
if (token.scope === 'operator' || token.scope === 'separator') return undefined
|
|
639
|
-
if (token.scope === 'invalid') {
|
|
640
|
-
return {
|
|
641
|
-
title: token.text,
|
|
642
|
-
detail: 'not part of this language',
|
|
643
|
-
body: 'Nothing here accepts this word: it is not a state, not one of the option keys, and not a value any slot recognises.',
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
if (token.scope === SLOT_SCOPE.speed) {
|
|
647
|
-
return {
|
|
648
|
-
title: token.text,
|
|
649
|
-
detail: 'seconds per cycle',
|
|
650
|
-
body: 'Seconds per revolution for `turn`, seconds per cycle otherwise.',
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
const entry = BY_SCOPE.get(token.scope)?.entryFor(token.text)
|
|
654
|
-
// Nothing documented means nothing to say. Falling back to the scope name would put an
|
|
655
|
-
// internal identifier in front of the user — resting the pointer on a gap in the
|
|
656
|
-
// document once produced a tooltip whose only content was the word `text`.
|
|
657
|
-
return entry === undefined
|
|
658
|
-
? undefined
|
|
659
|
-
: { title: token.text, detail: entry.detail, body: entry.body }
|
|
660
|
-
},
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* The completion rows for one slot or option key.
|
|
665
|
-
*
|
|
666
|
-
* A slot already filled by the line still gets a list, because replacing a value
|
|
667
|
-
* is as common as writing the first one — but its own value leads, so accepting
|
|
668
|
-
* the top row changes nothing by accident.
|
|
669
|
-
* @param slot - a positional slot name, or an option key such as `bg`.
|
|
670
|
-
* @param line - the line the caret is on, when there is one.
|
|
671
|
-
* @returns the rows, ready to rank.
|
|
672
|
-
*/
|
|
673
|
-
function valueItems(slot: string, line: DshSentryLine | undefined): SuggestionItem[] {
|
|
674
|
-
if (slot === 'speed') {
|
|
675
|
-
// A speed is a free number, so the list offers the shipped rates rather than
|
|
676
|
-
// pretending to be exhaustive. The state's own rate leads.
|
|
677
|
-
const current = line?.state === undefined ? undefined : LOOK[line.state]?.speed
|
|
678
|
-
const rates = [...new Set([...Object.values(LOOK).map((look) => look.speed), 1, 2, 3])].sort(
|
|
679
|
-
(left, right) => left - right,
|
|
680
|
-
)
|
|
681
|
-
return rates.map((rate) => ({
|
|
682
|
-
label: String(rate),
|
|
683
|
-
insert: String(rate),
|
|
684
|
-
kind: 'number',
|
|
685
|
-
detail: current === rate ? 'the shipped rate for this state' : 'seconds per cycle',
|
|
686
|
-
sortText: current === rate ? '0' : '1',
|
|
687
|
-
}))
|
|
688
|
-
}
|
|
689
|
-
const words = wordsForSlot(slot)
|
|
690
|
-
if (words.length === 0) return []
|
|
691
|
-
const scope = slot === 'bg' ? SLOT_SCOPE.color : SLOT_SCOPE[slot as DshSentrySlot]
|
|
692
|
-
const vocabulary = BY_SCOPE.get(scope)
|
|
693
|
-
const filled = line?.slots[KEY_SLOT[slot] ?? 'shape']
|
|
694
|
-
const noun = slot === 'bg' ? 'color' : slot
|
|
695
|
-
return words.map((word) => {
|
|
696
|
-
const entry = vocabulary?.entryFor(word)
|
|
697
|
-
return {
|
|
698
|
-
label: word,
|
|
699
|
-
insert: word,
|
|
700
|
-
kind: 'value',
|
|
701
|
-
detail: word === filled ? `the current ${noun}` : entry?.detail,
|
|
702
|
-
documentation: entry?.body,
|
|
703
|
-
sortText: word === filled ? '0' : '1',
|
|
704
|
-
}
|
|
705
|
-
})
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/**
|
|
710
|
-
* The option a value is being written for, when the caret is inside one.
|
|
711
|
-
*
|
|
712
|
-
* Read from the text before the caret on the caret's own line. The parse reads whole
|
|
713
|
-
* lines, and part-way through `shape=ci` the line is not a finished rule yet — the local
|
|
714
|
-
* reading is correct in the middle of the edit, which is the only moment a completion is
|
|
715
|
-
* ever asked.
|
|
716
|
-
*
|
|
717
|
-
* The value has to be the word the caret is IN, which is why the pattern allows no
|
|
718
|
-
* whitespace between the `=` and the caret. Without that, any earlier `key=` on the line
|
|
719
|
-
* would claim the list: a line that already said `pattern=none` would go on offering
|
|
720
|
-
* patterns instead of moving on to the slot that is still empty.
|
|
721
|
-
* @param before - the caret's line, up to the caret.
|
|
722
|
-
* @returns the key, or undefined when the caret is not in a value.
|
|
723
|
-
*/
|
|
724
|
-
function optionAtCaret(before: string): string | undefined {
|
|
725
|
-
const match = /([A-Za-z][\w-]*)\s*=([^\s=]*)$/.exec(before)
|
|
726
|
-
return match?.[1]
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
/** The four states this grammar understands, for a host that wants to list them. */
|
|
730
|
-
export const DSH_SENTRY_STATES: readonly string[] = DEFAULT_STATES
|
|
731
|
-
|
|
732
|
-
/** The palette it documents, for a host that wants to draw a swatch. */
|
|
733
|
-
export const DSH_SENTRY_COLORS: Readonly<Record<string, string>> = DEFAULT_COLORS
|
|
734
|
-
|
|
735
|
-
/** The value sets it accepts, for a host that wants to validate a stored document. */
|
|
736
|
-
export const DSH_SENTRY_VOCABULARY = {
|
|
737
|
-
states: DEFAULT_STATES,
|
|
738
|
-
shapes: DEFAULT_SHAPES,
|
|
739
|
-
motions: DEFAULT_MOTIONS,
|
|
740
|
-
colors: DEFAULT_COLORS,
|
|
741
|
-
options: DEFAULT_OPTIONS,
|
|
742
|
-
} as const
|