@cavelang/parser 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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Document parser (spec §8, §16).
3
+ *
4
+ * Splits input into physical lines, measures indentation, classifies each
5
+ * line (blank / comment / claim / continuation / qualifier), and resolves
6
+ * each indented line's parent — the nearest less-indented structural line
7
+ * above (spec §8).
8
+ *
9
+ * Classification of an indented line follows spec §8's table, decided by
10
+ * what the line starts with:
11
+ *
12
+ * - qualifier verb (`WHEN`/`UNLESS`/`VIA`/`BECAUSE`) → qualifier
13
+ * - bare relational verb → continuation
14
+ * - full triple → grouped claim
15
+ *
16
+ * One ambiguity needs a tiebreak: `API NEEDS auth` starts with a token that
17
+ * is lexically verb-shaped. If the *second* token is also verb-shaped (and
18
+ * not `NOT`), the line is a full triple with an uppercase subject —
19
+ * `CONTAINS REVERSE PART-OF` and `API NEEDS auth` both land here; otherwise
20
+ * it is a continuation.
21
+ *
22
+ * `parseDocument` never throws: broken lines become `invalid` entries and
23
+ * every problem is a diagnostic. `parse` is the strict variant.
24
+ */
25
+
26
+ import { Verb } from '@cavelang/core'
27
+ import type * as Ast from './ast.ts'
28
+ import * as Line from './line.ts'
29
+ import * as Token from './token.ts'
30
+
31
+ const indentOf = (raw: string): { depth: number, rest: string, tabs: boolean } => {
32
+ let depth = 0
33
+ let tabs = false
34
+ while (depth < raw.length && (raw[depth] === ' ' || raw[depth] === '\t')) {
35
+ tabs ||= raw[depth] === '\t'
36
+ depth += 1
37
+ }
38
+ return { depth, rest: raw.slice(depth), tabs }
39
+ }
40
+
41
+ type Classified = 'claim' | 'continuation' | 'qualifier'
42
+
43
+ const classify = (tokens: readonly Token.t[], depth: number): Classified | { error: string } => {
44
+ const head = tokens[0]!
45
+ if (head.kind === 'word' && Verb.isQualifier(head.text)) {
46
+ return depth > 0 ?
47
+ 'qualifier' :
48
+ { error: `qualifier verb ${head.text} at top level — qualifiers attach to a parent claim (spec §8.2)` }
49
+ }
50
+ if (head.kind === 'word' && Verb.isVerbToken(head.text)) {
51
+ const second = tokens[1]
52
+ const secondWord = second?.kind === 'word' ? second.text : undefined
53
+ // Tiebreak between "continuation" and "full triple with an uppercase
54
+ // subject", using the known standard vocabulary (see the README):
55
+ // CONTAINS REVERSE PART-OF → claim (declaration)
56
+ // NEEDS NOT downtime → continuation (NOT is a modifier)
57
+ // API NEEDS auth → claim (second token is a known verb)
58
+ // USES JWT / PART-OF ORG → continuation (first known, second not)
59
+ // API MIGRATES postgres → claim (neither known — subject wins)
60
+ const kind: Classified =
61
+ secondWord === Verb.REVERSE ? 'claim' :
62
+ secondWord !== undefined && secondWord !== 'NOT' && Verb.isVerbToken(secondWord) ?
63
+ (Verb.isKnown(secondWord) ? 'claim' : Verb.isKnown(head.text) ? 'continuation' : 'claim') :
64
+ 'continuation'
65
+ if (kind === 'continuation' && depth === 0) {
66
+ return { error: `continuation line at top level — nothing to inherit a subject from (spec §8.3)` }
67
+ }
68
+ return kind
69
+ }
70
+ return 'claim'
71
+ }
72
+
73
+ type Frame = { index: number, depth: number }
74
+
75
+ /**
76
+ * Parses a CAVE document. Never throws; problems surface as diagnostics and
77
+ * `invalid` lines.
78
+ */
79
+ export const parseDocument = (input: string): Ast.Document => {
80
+ const lines: Ast.Line[] = []
81
+ const diagnostics: Ast.Diagnostic[] = []
82
+ const stack: Frame[] = []
83
+ const rawLines = input.split(/\r?\n/)
84
+ const problem = (line: number, raw: string, message: string): void => {
85
+ diagnostics.push({ line, message, raw })
86
+ }
87
+ rawLines.forEach((raw, at) => {
88
+ const lineNo = at + 1
89
+ const { depth, rest, tabs } = indentOf(raw)
90
+ if (tabs) {
91
+ problem(lineNo, raw, 'tab in indentation — use spaces')
92
+ }
93
+ if (rest === '') {
94
+ lines.push({ kind: 'blank', line: lineNo, raw })
95
+ return
96
+ }
97
+ if (rest.startsWith(';')) {
98
+ lines.push({ kind: 'comment', line: lineNo, raw, text: rest.slice(1).trim() })
99
+ return
100
+ }
101
+ const { head, comment } = Token.splitComment(rest)
102
+ const tokens = Token.tokenize(head)
103
+ if (tokens.length === 0) {
104
+ lines.push({ kind: 'comment', line: lineNo, raw, text: comment ?? '' })
105
+ return
106
+ }
107
+ const kind = classify(tokens, depth)
108
+ if (typeof kind === 'object') {
109
+ problem(lineNo, raw, kind.error)
110
+ lines.push({ kind: 'invalid', line: lineNo, raw, message: kind.error })
111
+ return
112
+ }
113
+ while (stack.length > 0 && stack[stack.length - 1]!.depth >= depth) {
114
+ stack.pop()
115
+ }
116
+ const parent = stack[stack.length - 1]?.index
117
+ if (parent === undefined && kind !== 'claim') {
118
+ const message = `${kind} line has no parent claim above (spec §8)`
119
+ problem(lineNo, raw, message)
120
+ lines.push({ kind: 'invalid', line: lineNo, raw, message })
121
+ return
122
+ }
123
+ const index = lines.length
124
+ const push = (line: Ast.Line, problems: readonly string[]): void => {
125
+ lines.push(line)
126
+ stack.push({ index, depth })
127
+ for (const message of problems) {
128
+ problem(lineNo, raw, message)
129
+ }
130
+ }
131
+ switch (kind) {
132
+ case 'claim': {
133
+ const result = Line.parseClaim(tokens, comment)
134
+ if (!result.ok) {
135
+ problem(lineNo, raw, result.message)
136
+ lines.push({ kind: 'invalid', line: lineNo, raw, message: result.message })
137
+ return
138
+ }
139
+ push(
140
+ { kind: 'claim', line: lineNo, raw, depth, ...parent !== undefined ? { parent } : {}, claim: result.value },
141
+ result.problems
142
+ )
143
+ return
144
+ }
145
+ case 'continuation': {
146
+ const result = Line.parseBody(tokens, comment)
147
+ if (!result.ok) {
148
+ problem(lineNo, raw, result.message)
149
+ lines.push({ kind: 'invalid', line: lineNo, raw, message: result.message })
150
+ return
151
+ }
152
+ push(
153
+ { kind: 'continuation', line: lineNo, raw, depth, parent: parent!, body: result.value },
154
+ result.problems
155
+ )
156
+ return
157
+ }
158
+ case 'qualifier': {
159
+ const qualifier = (tokens[0] as { text: string }).text as Verb.Qualifier
160
+ const result = Line.parseQualifierPayload(tokens.slice(1), comment)
161
+ if (!result.ok) {
162
+ problem(lineNo, raw, result.message)
163
+ lines.push({ kind: 'invalid', line: lineNo, raw, message: result.message })
164
+ return
165
+ }
166
+ push(
167
+ { kind: 'qualifier', line: lineNo, raw, depth, parent: parent!, qualifier, payload: result.value },
168
+ result.problems
169
+ )
170
+ return
171
+ }
172
+ }
173
+ })
174
+ return { lines, diagnostics }
175
+ }
176
+
177
+ /**
178
+ * Strict parse: like {@link parseDocument} but throws an `Error` listing
179
+ * every diagnostic when the document has any.
180
+ */
181
+ export const parse = (input: string): readonly Ast.Line[] => {
182
+ const { lines, diagnostics } = parseDocument(input)
183
+ if (diagnostics.length > 0) {
184
+ const detail = diagnostics
185
+ .map(diagnostic => ` line ${diagnostic.line}: ${diagnostic.message}`)
186
+ .join('\n')
187
+ throw new Error(`CAVE parse failed with ${diagnostics.length} problem(s):\n${detail}`)
188
+ }
189
+ return lines
190
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@cavelang/parser` — CAVE text → AST (spec §3, §4, §8, §16).
3
+ *
4
+ * Line-oriented parser for the normative CAVE language, built on
5
+ * `@prelude/parser` combinators. Produces pure syntax: inverse verbs,
6
+ * continuations and `UNLESS` are resolved later by `@cavelang/canonical`
7
+ * (spec §13.4).
8
+ *
9
+ * ```ts
10
+ * import { parseDocument } from '@cavelang/parser'
11
+ *
12
+ * const { lines, diagnostics } = parseDocument('auth/middleware USES jwt @ 90%')
13
+ * ```
14
+ */
15
+
16
+ export * as Ast from './ast.ts'
17
+ export * as Line from './line.ts'
18
+ export * as Token from './token.ts'
19
+ export { parseDocument, parse } from './document.ts'
package/src/line.ts ADDED
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Token-stream line parser (spec §3.2, §16).
3
+ *
4
+ * Parses the token list of one line into claim/continuation/qualifier
5
+ * bodies. The payload/metadata boundary is decided lexically: metadata items
6
+ * are unambiguous at their first token (spec §4.3 reserved characters), so
7
+ * everything before the first metadata token is payload.
8
+ */
9
+
10
+ import { Claim, Confidence, Tag, Value, Verb } from '@cavelang/core'
11
+ import type * as Ast from './ast.ts'
12
+ import type { Token } from './token.ts'
13
+
14
+ export type Result<T> =
15
+ | { readonly ok: true, readonly value: T, readonly problems: readonly string[] }
16
+ | { readonly ok: false, readonly message: string }
17
+
18
+ const ok = <T>(value: T, problems: readonly string[] = []): Result<T> =>
19
+ ({ ok: true, value, problems })
20
+
21
+ const fail = (message: string): Result<never> =>
22
+ ({ ok: false, message })
23
+
24
+ const sigmaRe = /^\((\d+(?:\.\d+)?)σ\)$/u
25
+
26
+ /** @returns `true` if `token` starts a metadata item (spec §4.3). */
27
+ export const isMetaStart = (token: Token): boolean =>
28
+ token.kind === 'word' && (
29
+ token.text.startsWith('@') ||
30
+ token.text.startsWith('#') ||
31
+ token.text.startsWith('+/-') ||
32
+ token.text === '!' ||
33
+ sigmaRe.test(token.text)
34
+ )
35
+
36
+ /** @returns term from a single token (spec §16: atom, literal or code literal). */
37
+ const term = (token: Token): Claim.Term => {
38
+ switch (token.kind) {
39
+ case 'text':
40
+ return Claim.text(token.text)
41
+ case 'code':
42
+ return Claim.code(token.text)
43
+ default:
44
+ return Claim.entity(token.text)
45
+ }
46
+ }
47
+
48
+ /** @returns value from payload value tokens (attribute value or metric). */
49
+ const valueOf = (tokens: readonly Token[]): undefined | Value.t => {
50
+ if (tokens.length === 0) {
51
+ return undefined
52
+ }
53
+ const [head] = tokens
54
+ if (tokens.length === 1 && head!.kind === 'text') {
55
+ return Value.ofText(head!.text)
56
+ }
57
+ if (tokens.length === 1 && head!.kind === 'code') {
58
+ return Value.ofCode(head!.text)
59
+ }
60
+ if (tokens.some(token => token.kind !== 'word')) {
61
+ return undefined
62
+ }
63
+ return Value.parse(tokens.map(token => token.text).join(' '))
64
+ }
65
+
66
+ /**
67
+ * Parses metadata tokens (spec §6). Never fails — unrecognized tokens are
68
+ * reported as problems and skipped, honoring the robust-extraction goal
69
+ * (spec §1.6).
70
+ */
71
+ export const parseMeta = (tokens: readonly Token[], comment?: string): { meta: Ast.Meta, problems: string[] } => {
72
+ const contexts: string[] = []
73
+ const tags: Tag.t[] = []
74
+ const problems: string[] = []
75
+ let conf: undefined | number
76
+ let importance = false
77
+ let delta: undefined | Value.t
78
+ let sigmaLevel: undefined | number
79
+ let i = 0
80
+ const collectValueTokens = (): Token[] => {
81
+ const collected: Token[] = []
82
+ while (i < tokens.length && !isMetaStart(tokens[i]!) && tokens[i]!.kind === 'word') {
83
+ collected.push(tokens[i]!)
84
+ i += 1
85
+ }
86
+ return collected
87
+ }
88
+ while (i < tokens.length) {
89
+ const token = tokens[i]!
90
+ i += 1
91
+ if (token.kind !== 'word') {
92
+ problems.push(`unexpected literal ${JSON.stringify(token.text)} in metadata position`)
93
+ continue
94
+ }
95
+ const word = token.text
96
+ if (word === '@') {
97
+ const next = tokens[i]
98
+ const text = next?.kind === 'word' ? next.text : undefined
99
+ const parsed = text === undefined ? undefined : Confidence.parse(text)
100
+ if (parsed === undefined) {
101
+ // Consume a number-like token so `@ 90` / `@ 2026` yield one clear
102
+ // problem instead of a cascade (§6.3: confidence must end in %).
103
+ if (text !== undefined && /^\d/.test(text)) {
104
+ i += 1
105
+ }
106
+ problems.push(`expected percentage ending in % after "@ "${text === undefined ? '' : `, got ${JSON.stringify(text)}`} (spec §6.3)`)
107
+ continue
108
+ }
109
+ i += 1
110
+ if (Number(text!.slice(0, -1)) > 100) {
111
+ problems.push(`confidence ${text} above 100% clamped (spec §6.3)`)
112
+ }
113
+ if (conf !== undefined) {
114
+ problems.push('repeated confidence — only contexts and tags may repeat (spec §3.2); last one wins')
115
+ }
116
+ conf = parsed
117
+ continue
118
+ }
119
+ if (word.startsWith('@')) {
120
+ contexts.push(word.slice(1))
121
+ continue
122
+ }
123
+ if (word === '#') {
124
+ problems.push('empty tag')
125
+ continue
126
+ }
127
+ if (word.startsWith('#')) {
128
+ tags.push(Tag.parse(word.slice(1)))
129
+ continue
130
+ }
131
+ if (word.startsWith('+/-')) {
132
+ const glued = word.slice('+/-'.length)
133
+ const collected = collectValueTokens().map(token_ => token_.text)
134
+ const raw = [...glued === '' ? [] : [glued], ...collected].join(' ')
135
+ if (raw === '') {
136
+ problems.push('expected value after +/- (spec §7.2)')
137
+ continue
138
+ }
139
+ if (delta !== undefined) {
140
+ problems.push('repeated +/- uncertainty — only contexts and tags may repeat (spec §3.2); last one wins')
141
+ }
142
+ delta = Value.parse(raw)
143
+ continue
144
+ }
145
+ const sigma = sigmaRe.exec(word)
146
+ if (sigma) {
147
+ if (sigmaLevel !== undefined) {
148
+ problems.push('repeated (Nσ) level — only contexts and tags may repeat (spec §3.2); last one wins')
149
+ }
150
+ sigmaLevel = Number(sigma[1])
151
+ continue
152
+ }
153
+ if (word === '!') {
154
+ if (importance) {
155
+ problems.push('repeated ! importance marker (spec §3.2)')
156
+ }
157
+ importance = true
158
+ continue
159
+ }
160
+ problems.push(`unexpected token ${JSON.stringify(word)} after payload`)
161
+ }
162
+ return {
163
+ meta: {
164
+ contexts,
165
+ tags,
166
+ importance,
167
+ ...conf !== undefined ? { conf } : {},
168
+ ...delta !== undefined ? { delta } : {},
169
+ ...sigmaLevel !== undefined ? { sigmaLevel } : {},
170
+ ...comment !== undefined ? { comment } : {}
171
+ },
172
+ problems
173
+ }
174
+ }
175
+
176
+ /** Splits tokens at the first metadata token. */
177
+ const splitAtMeta = (tokens: readonly Token[]): { payload: readonly Token[], meta: readonly Token[] } => {
178
+ const at = tokens.findIndex(isMetaStart)
179
+ return at === -1 ?
180
+ { payload: tokens, meta: [] } :
181
+ { payload: tokens.slice(0, at), meta: tokens.slice(at) }
182
+ }
183
+
184
+ /**
185
+ * Parses payload tokens (spec §16 `payload`):
186
+ *
187
+ * - `attr: value` — attribute claim; the colon is canonical (spec §3.4)
188
+ * - legacy colonless `HAS attr value` when the value is numeric/date-like
189
+ * - numeric/date value — metric claim (`latency IS 30ms`; also the shape a
190
+ * comparison condition canonicalizes to, e.g. `load EXCEEDS 1000 req/s`,
191
+ * which keeps `WHERE value > …` filters queryable, spec §12.2)
192
+ * - empty — bare existence, `EXISTS` only
193
+ * - otherwise — relational object (single term or multi-word phrase)
194
+ */
195
+ const parsePayload = (verb: string, tokens: readonly Token[]): Result<Claim.Payload> => {
196
+ if (tokens.length === 0) {
197
+ return verb === 'EXISTS' ?
198
+ ok(Claim.none) :
199
+ fail(`missing object after ${verb} (the minimum line is "entity VERB object", spec §2.2)`)
200
+ }
201
+ const [head, ...rest] = tokens
202
+ if (head!.kind === 'word' && head!.text.endsWith(':') && head!.text.length > 1) {
203
+ const attribute = head!.text.slice(0, -1)
204
+ const value = valueOf(rest)
205
+ if (value === undefined) {
206
+ return fail(`missing or mixed value after attribute ${JSON.stringify(attribute)} (spec §3.4)`)
207
+ }
208
+ return ok(Claim.attribute(attribute, value))
209
+ }
210
+ if (head!.kind === 'word') {
211
+ // Glued colon: `expiry:3600s`. The payload `:` is reserved as the
212
+ // attribute/value separator (spec §4.3) — split it, keep a diagnostic.
213
+ const colonAt = head!.text.indexOf(':')
214
+ if (colonAt > 0 && colonAt < head!.text.length - 1) {
215
+ const attribute = head!.text.slice(0, colonAt)
216
+ const value = valueOf([{ kind: 'word', text: head!.text.slice(colonAt + 1) }, ...rest])
217
+ if (value !== undefined) {
218
+ return ok(
219
+ Claim.attribute(attribute, value),
220
+ [`glued attribute colon; canonical is "${attribute}: ${value.raw}" — quote the object for a literal : (spec §3.4, §4.3)`]
221
+ )
222
+ }
223
+ }
224
+ }
225
+ if (verb === 'HAS' && rest.length > 0 && head!.kind === 'word') {
226
+ const value = valueOf(rest)
227
+ if (value !== undefined && (value.kind === 'number' || value.kind === 'date')) {
228
+ return ok(
229
+ Claim.attribute(head!.text, value),
230
+ [`legacy colonless attribute form; canonical is "${head!.text}: ${value.raw}" (spec §3.4)`]
231
+ )
232
+ }
233
+ }
234
+ {
235
+ const value = valueOf(tokens)
236
+ if (value !== undefined && (value.kind === 'number' || value.kind === 'date')) {
237
+ return ok(Claim.metric(value))
238
+ }
239
+ }
240
+ if (tokens.length === 1) {
241
+ return ok(Claim.relation(term(head!)))
242
+ }
243
+ if (tokens.every(token => token.kind === 'word')) {
244
+ return ok(Claim.relation(Claim.entity(tokens.map(token => token.text).join(' '))))
245
+ }
246
+ return fail('object phrase mixes words and literals — quote the whole object (spec §4.3)')
247
+ }
248
+
249
+ /** Parses `verb [NOT] payload metadata` — a continuation body (spec §8.3). */
250
+ export const parseBody = (tokens: readonly Token[], comment?: string): Result<Ast.Body> => {
251
+ const [head, ...rest] = tokens
252
+ if (head === undefined || head.kind !== 'word' || !Verb.isVerbToken(head.text)) {
253
+ return fail(`expected an UPPERCASE verb, got ${JSON.stringify(head?.text ?? 'end of line')}`)
254
+ }
255
+ const verb = head.text
256
+ const negated = rest[0]?.kind === 'word' && rest[0].text === 'NOT'
257
+ const afterNot = negated ? rest.slice(1) : rest
258
+ const { payload, meta } = splitAtMeta(afterNot)
259
+ const payloadResult = parsePayload(verb, payload)
260
+ if (!payloadResult.ok) {
261
+ return payloadResult
262
+ }
263
+ const parsedMeta = parseMeta(meta, comment)
264
+ return ok(
265
+ { verb, negated, payload: payloadResult.value, meta: parsedMeta.meta },
266
+ [...payloadResult.problems, ...parsedMeta.problems]
267
+ )
268
+ }
269
+
270
+ /** Parses `subject verb [NOT] payload metadata` — a full claim line (spec §16). */
271
+ export const parseClaim = (tokens: readonly Token[], comment?: string): Result<Ast.Full> => {
272
+ const [head, ...rest] = tokens
273
+ if (head === undefined) {
274
+ return fail('empty claim line')
275
+ }
276
+ if (isMetaStart(head)) {
277
+ return fail(`expected a subject, got ${JSON.stringify(head.text)}`)
278
+ }
279
+ const body = parseBody(rest, comment)
280
+ if (!body.ok) {
281
+ return body
282
+ }
283
+ return ok({ subject: term(head), ...body.value }, body.problems)
284
+ }
285
+
286
+ const isComparisonOp = (text: string): text is Ast.ComparisonOp =>
287
+ (['>', '<', '>=', '<=', '=', '!='] as const).includes(text as Ast.ComparisonOp)
288
+
289
+ /**
290
+ * Parses a qualifier payload (spec §8.2): a full claim, a comparison, or a
291
+ * bare entity — tried in that order.
292
+ */
293
+ export const parseQualifierPayload = (tokens: readonly Token[], comment?: string): Result<Ast.QualifierPayload> => {
294
+ const negated = tokens[0]?.kind === 'word' && tokens[0].text === 'NOT'
295
+ const rest = negated ? tokens.slice(1) : tokens
296
+ const [head, second] = rest
297
+ if (head === undefined) {
298
+ return fail('empty qualifier payload')
299
+ }
300
+ if (
301
+ second?.kind === 'word' &&
302
+ Verb.isVerbToken(second.text) &&
303
+ second.text !== 'NOT' &&
304
+ !isMetaStart(head)
305
+ ) {
306
+ const claim = parseClaim(rest, comment)
307
+ if (!claim.ok) {
308
+ return claim
309
+ }
310
+ return ok({ kind: 'claim', negated, claim: claim.value }, claim.problems)
311
+ }
312
+ if (second?.kind === 'word' && isComparisonOp(second.text)) {
313
+ const { payload, meta } = splitAtMeta(rest.slice(2))
314
+ const value = valueOf(payload)
315
+ if (value === undefined) {
316
+ return fail(`missing value after comparison operator ${second.text}`)
317
+ }
318
+ const parsedMeta = parseMeta(meta, comment)
319
+ return ok(
320
+ { kind: 'comparison', negated, left: term(head), op: second.text, value, meta: parsedMeta.meta },
321
+ parsedMeta.problems
322
+ )
323
+ }
324
+ if (isMetaStart(head)) {
325
+ return fail(`expected qualifier payload, got ${JSON.stringify(head.text)}`)
326
+ }
327
+ const parsedMeta = parseMeta(rest.slice(1), comment)
328
+ if (rest.length > 1 && !isMetaStart(rest[1]!)) {
329
+ return fail('cannot parse qualifier payload as claim, comparison or entity')
330
+ }
331
+ return ok(
332
+ { kind: 'entity', negated, term: term(head), meta: parsedMeta.meta },
333
+ parsedMeta.problems
334
+ )
335
+ }
package/src/token.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Line tokenizer (spec §4).
3
+ *
4
+ * Splits one line (indentation and comment already removed) into tokens:
5
+ * backticked code literals, double-quoted text literals, and whitespace
6
+ * separated words. Built on `@prelude/parser` combinators.
7
+ *
8
+ * Literals contain no escape sequences (the spec defines none) — a quoted
9
+ * token runs to the next matching delimiter. An unterminated delimiter
10
+ * degrades gracefully: the raw text is consumed as an ordinary word.
11
+ */
12
+
13
+ import * as P from '@prelude/parser'
14
+
15
+ export type Token =
16
+ | { readonly kind: 'word', readonly text: string }
17
+ | { readonly kind: 'text', readonly text: string }
18
+ | { readonly kind: 'code', readonly text: string }
19
+
20
+ export type t = Token
21
+
22
+ export const word = (text: string): Token =>
23
+ ({ kind: 'word', text })
24
+
25
+ export const text = (value: string): Token =>
26
+ ({ kind: 'text', text: value })
27
+
28
+ export const code = (value: string): Token =>
29
+ ({ kind: 'code', text: value })
30
+
31
+ const codeToken: P.Parser<Token> =
32
+ P.map(P.seq(P.lit('`'), P.whileNotChars('`'), P.lit('`')), ([, body]) => code(body))
33
+
34
+ const textToken: P.Parser<Token> =
35
+ P.map(P.seq(P.lit('"'), P.whileNotChars('"'), P.lit('"')), ([, body]) => text(body))
36
+
37
+ const wordToken: P.Parser<Token> =
38
+ P.map(P.whileNotChars(' \t', 1), word)
39
+
40
+ const anyToken: P.Parser<Token> =
41
+ P.first(codeToken, textToken, wordToken)
42
+
43
+ const lineTokens: P.Parser<Token[]> =
44
+ P.map(
45
+ P.seq(P.star(P.map(P.seq(P.ws0, anyToken), ([, token]) => token)), P.ws0),
46
+ ([tokens]) => tokens
47
+ )
48
+
49
+ /** @returns tokens of a single line. Total — any input tokenizes. */
50
+ export const tokenize = (line: string): Token[] =>
51
+ P.parse(lineTokens, line)
52
+
53
+ /**
54
+ * Splits a line at the first `;` that sits outside quotes and backticks
55
+ * (spec §6.4). @returns the head and the trimmed comment text, `undefined`
56
+ * when there is no comment or it is empty.
57
+ */
58
+ export const splitComment = (line: string): { head: string, comment?: string } => {
59
+ let quote: undefined | string
60
+ for (let i = 0; i < line.length; i++) {
61
+ const char = line[i]!
62
+ if (quote !== undefined) {
63
+ if (char === quote) {
64
+ quote = undefined
65
+ }
66
+ continue
67
+ }
68
+ if (char === '"' || char === '`') {
69
+ quote = char
70
+ continue
71
+ }
72
+ if (char === ';') {
73
+ const comment = line.slice(i + 1).trim()
74
+ return comment === '' ?
75
+ { head: line.slice(0, i) } :
76
+ { head: line.slice(0, i), comment }
77
+ }
78
+ }
79
+ return { head: line }
80
+ }