@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.
- package/README.md +105 -0
- package/dist/src/ast.d.ts +122 -0
- package/dist/src/ast.d.ts.map +1 -0
- package/dist/src/ast.js +13 -0
- package/dist/src/ast.js.map +1 -0
- package/dist/src/document.d.ts +36 -0
- package/dist/src/document.d.ts.map +1 -0
- package/dist/src/document.js +171 -0
- package/dist/src/document.js.map +1 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +19 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/line.d.ts +39 -0
- package/dist/src/line.d.ts.map +1 -0
- package/dist/src/line.js +293 -0
- package/dist/src/line.js.map +1 -0
- package/dist/src/token.d.ts +37 -0
- package/dist/src/token.d.ts.map +1 -0
- package/dist/src/token.js +51 -0
- package/dist/src/token.js.map +1 -0
- package/package.json +43 -0
- package/src/ast.ts +92 -0
- package/src/document.ts +190 -0
- package/src/index.ts +19 -0
- package/src/line.ts +335 -0
- package/src/token.ts +80 -0
package/src/document.ts
ADDED
|
@@ -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
|
+
}
|