@cavelang/canonical 0.34.0 → 0.36.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 +234 -3
- package/dist/src/canonicalize.d.ts.map +1 -1
- package/dist/src/canonicalize.js +31 -2
- package/dist/src/canonicalize.js.map +1 -1
- package/dist/src/emit.d.ts +5 -0
- package/dist/src/emit.d.ts.map +1 -1
- package/dist/src/emit.js +239 -52
- package/dist/src/emit.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/package.json +4 -4
- package/src/canonicalize.ts +27 -3
- package/src/emit.ts +238 -62
- package/src/index.ts +1 -1
package/src/emit.ts
CHANGED
|
@@ -8,39 +8,125 @@
|
|
|
8
8
|
* (`QUALIFIES` edges) re-indent as full lines.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { Claim, Confidence, Tag, Value } from '@cavelang/core'
|
|
12
|
-
import { parseDocument, Token } from '@cavelang/parser'
|
|
11
|
+
import { Claim, Confidence, Entity, Tag, Uncertainty, Value, Verb } from '@cavelang/core'
|
|
12
|
+
import { parseDocument, Token, Line } from '@cavelang/parser'
|
|
13
13
|
import type * as Canonicalize from './canonicalize.ts'
|
|
14
14
|
|
|
15
|
+
const checkLiteral = (kind: string, text: string): void => {
|
|
16
|
+
if (typeof text !== 'string') throw new TypeError('CAVE term and value text must be a string')
|
|
17
|
+
const delimiter = kind === 'text' ? '"' : kind === 'code' ? '`' : undefined
|
|
18
|
+
if (delimiter !== undefined && text.includes(delimiter)) {
|
|
19
|
+
throw new TypeError(`CAVE ${kind} literal cannot contain its own delimiter`)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const termText = (term: Claim.Term): string => {
|
|
24
|
+
if (term.kind !== 'entity' && term.kind !== 'text' && term.kind !== 'code') {
|
|
25
|
+
throw new TypeError('CAVE term kind must be entity, text or code')
|
|
26
|
+
}
|
|
27
|
+
checkLiteral(term.kind, term.text)
|
|
28
|
+
return Claim.formatTerm(term)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Letter/underscore-led names exclude numeric/date prefixes and payload syntax.
|
|
32
|
+
// The separate NOT check retains the only bare negation token in this subset.
|
|
33
|
+
const simpleObject = /^[A-Za-z_][A-Za-z0-9_./-]*(?![\s\S])/
|
|
34
|
+
|
|
35
|
+
// This subset cannot contain token separators, literal delimiters, comments or metadata.
|
|
36
|
+
const simpleSubject = /^[A-Za-z0-9_][A-Za-z0-9_./:-]*(?![\s\S])/
|
|
37
|
+
|
|
38
|
+
const subjectText = (subject: Claim.Term): string => {
|
|
39
|
+
if (subject.kind === 'entity') {
|
|
40
|
+
if (typeof subject.text !== 'string') throw new TypeError('CAVE entity subject must be one non-metadata atom')
|
|
41
|
+
if (!simpleSubject.test(subject.text)) {
|
|
42
|
+
const head = Token.splitComment(subject.text).head
|
|
43
|
+
const tokens = Token.tokenize(head)
|
|
44
|
+
const token = tokens[0]
|
|
45
|
+
if (head !== subject.text || tokens.length !== 1 || token?.kind !== 'word' ||
|
|
46
|
+
token.text !== subject.text || Line.isMetaStart(token)) {
|
|
47
|
+
throw new TypeError('CAVE entity subject must be one non-metadata atom; use a text or code literal')
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return termText(subject)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const valueText = (value: Value.t): string => {
|
|
55
|
+
checkLiteral(value.kind, value.raw)
|
|
56
|
+
const parsed = value.kind === 'text' ? Value.ofText(value.raw) :
|
|
57
|
+
value.kind === 'code' ? Value.ofCode(value.raw) : Value.parse(value.raw)
|
|
58
|
+
if (value.kind !== parsed.kind || value.approx !== parsed.approx ||
|
|
59
|
+
value.num !== parsed.num || value.from !== parsed.from || value.to !== parsed.to || value.unit !== parsed.unit) {
|
|
60
|
+
throw new TypeError('CAVE value fields must agree with their emitted raw text')
|
|
61
|
+
}
|
|
62
|
+
if (value.kind !== 'text' && value.kind !== 'code') {
|
|
63
|
+
const tokens = Token.tokenize(value.raw)
|
|
64
|
+
if (tokens.length === 0 || tokens.some(token => token.kind !== 'word' || Line.isMetaStart(token)) ||
|
|
65
|
+
tokens.map(token => token.text).join(' ') !== value.raw) {
|
|
66
|
+
throw new TypeError('CAVE unquoted value must retain its text and remain outside metadata; use a text or code literal')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return Value.format(value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const attributeText = (attribute: string): string => {
|
|
73
|
+
const text = `${attribute}:`
|
|
74
|
+
const head = Token.splitComment(text).head
|
|
75
|
+
const tokens = Token.tokenize(head), token = tokens[0]
|
|
76
|
+
if (typeof attribute !== 'string' || attribute.length === 0 || head !== text ||
|
|
77
|
+
tokens.length !== 1 || token?.kind !== 'word' || token.text !== text || Line.isMetaStart(token)) {
|
|
78
|
+
throw new TypeError('CAVE attribute name must retain one non-metadata word before its value')
|
|
79
|
+
}
|
|
80
|
+
return text
|
|
81
|
+
}
|
|
82
|
+
|
|
15
83
|
const payloadText = (payload: Claim.Payload): undefined | string => {
|
|
16
84
|
switch (payload.kind) {
|
|
17
85
|
case 'relation':
|
|
18
|
-
return
|
|
86
|
+
return termText(payload.object)
|
|
19
87
|
case 'attribute':
|
|
20
|
-
return `${payload.attribute}
|
|
88
|
+
return `${attributeText(payload.attribute)} ${valueText(payload.value)}`
|
|
21
89
|
case 'metric':
|
|
22
|
-
return
|
|
90
|
+
return valueText(payload.value)
|
|
23
91
|
case 'none':
|
|
24
92
|
return undefined
|
|
93
|
+
default:
|
|
94
|
+
throw new TypeError('CAVE payload kind must be relation, attribute, metric or none')
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const metadataWord = (text: string): string => {
|
|
99
|
+
const head = Token.splitComment(text).head
|
|
100
|
+
const tokens = Token.tokenize(head), token = tokens[0]
|
|
101
|
+
if (head !== text || tokens.length !== 1 || token?.kind !== 'word' || token.text !== text || text === '@' || text === '#') {
|
|
102
|
+
throw new TypeError('CAVE metadata must retain one nonempty context or tag token')
|
|
25
103
|
}
|
|
104
|
+
return text
|
|
26
105
|
}
|
|
27
106
|
|
|
28
107
|
const metaText = (claim: Claim.t): string[] => {
|
|
108
|
+
for (const name of ['contexts', 'tags'] as const) {
|
|
109
|
+
if (!Array.isArray(claim[name])) throw new TypeError(`CAVE claim ${name} must be an array`)
|
|
110
|
+
}
|
|
29
111
|
const parts: string[] = []
|
|
30
112
|
if (claim.delta !== undefined) {
|
|
31
|
-
|
|
113
|
+
Uncertainty.validateDelta(claim.delta.num)
|
|
114
|
+
parts.push(`+/- ${valueText(claim.delta)}`)
|
|
32
115
|
}
|
|
33
116
|
if (claim.sigmaLevel !== undefined) {
|
|
34
|
-
parts.push(`(${claim.sigmaLevel}σ)`)
|
|
117
|
+
parts.push(`(${Value.formatNumber(Uncertainty.validateSigmaLevel(claim.sigmaLevel))}σ)`)
|
|
35
118
|
}
|
|
36
119
|
for (const context of claim.contexts) {
|
|
37
|
-
|
|
120
|
+
if (typeof context !== 'string') throw new TypeError('CAVE context metadata must be a string')
|
|
121
|
+
parts.push(metadataWord(`@${context}`))
|
|
38
122
|
}
|
|
39
123
|
for (const tag of claim.tags) {
|
|
40
|
-
|
|
124
|
+
const text = metadataWord(Tag.format(tag))
|
|
125
|
+
if (!Tag.equals(Tag.parse(text.slice(1)), tag)) throw new TypeError('CAVE tag must retain its key and value through emission')
|
|
126
|
+
parts.push(text)
|
|
41
127
|
}
|
|
42
128
|
if (claim.conf !== 1) {
|
|
43
|
-
parts.push(`@ ${Confidence.
|
|
129
|
+
parts.push(`@ ${Confidence.formatExact(claim.conf)}`)
|
|
44
130
|
}
|
|
45
131
|
if (claim.importance) {
|
|
46
132
|
parts.push('!')
|
|
@@ -48,9 +134,26 @@ const metaText = (claim: Claim.t): string[] => {
|
|
|
48
134
|
return parts
|
|
49
135
|
}
|
|
50
136
|
|
|
137
|
+
const singleLine = (line: string): string => {
|
|
138
|
+
if (line.includes('\n')) throw new TypeError('CAVE claim fields cannot contain a newline; use comment lines for multiline commentary')
|
|
139
|
+
if (/["`;]/.test(line) && Token.splitComment(`${line};`).head !== line) {
|
|
140
|
+
throw new TypeError('CAVE claim fields must not open a comment or leave an unmatched literal delimiter')
|
|
141
|
+
}
|
|
142
|
+
return line
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const validateFlags = (claim: Claim.t): void => {
|
|
146
|
+
for (const name of ['negated', 'importance'] as const) {
|
|
147
|
+
if (typeof claim[name] !== 'boolean') throw new TypeError(`CAVE claim ${name} must be a boolean`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
51
151
|
/** @returns the canonical claim line without its comment. */
|
|
52
152
|
const claimLine = (claim: Claim.t): string => {
|
|
53
|
-
|
|
153
|
+
validateFlags(claim)
|
|
154
|
+
if (typeof claim.verb !== 'string' || !Verb.isVerbToken(claim.verb)) throw new TypeError('CAVE claim verb must be an uppercase atom')
|
|
155
|
+
if (claim.payload.kind === 'none' && claim.verb !== 'EXISTS') throw new TypeError('CAVE claim requires an object or value payload unless its verb is EXISTS')
|
|
156
|
+
const parts = [subjectText(claim.subject), claim.verb]
|
|
54
157
|
if (claim.negated) {
|
|
55
158
|
parts.push('NOT')
|
|
56
159
|
}
|
|
@@ -58,8 +161,28 @@ const claimLine = (claim: Claim.t): string => {
|
|
|
58
161
|
if (payload !== undefined) {
|
|
59
162
|
parts.push(payload)
|
|
60
163
|
}
|
|
61
|
-
|
|
62
|
-
|
|
164
|
+
for (const metadata of metaText(claim)) parts.push(metadata)
|
|
165
|
+
const line = singleLine(parts.join(' '))
|
|
166
|
+
if (claim.payload.kind === 'metric' && !['number', 'date', 'trajectory'].includes(claim.payload.value.kind)) {
|
|
167
|
+
throw new TypeError('CAVE metric payload requires a number, date or trajectory; use a relation or attribute for other values')
|
|
168
|
+
}
|
|
169
|
+
if (claim.payload.kind === 'relation' && claim.payload.object.kind === 'entity' &&
|
|
170
|
+
(claim.payload.object.text === 'NOT' || !simpleObject.test(claim.payload.object.text))) {
|
|
171
|
+
const parsed = Line.parseBody(Token.tokenize(`${claim.verb} ${payload}`))
|
|
172
|
+
if (!parsed.ok || parsed.problems.length > 0 || parsed.value.negated ||
|
|
173
|
+
parsed.value.payload.kind !== 'relation' || parsed.value.payload.object.kind !== 'entity' ||
|
|
174
|
+
Entity.normalize(parsed.value.payload.object.text) !== Entity.normalize(claim.payload.object.text)) {
|
|
175
|
+
throw new TypeError('CAVE entity relation object must retain its payload identity; use an explicit value or literal when needed')
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return line
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const fullClaimLine = (claim: Claim.t): string => {
|
|
182
|
+
const line = claimLine(claim)
|
|
183
|
+
if (claim.subject.kind === 'entity' && Verb.isVerbToken(claim.subject.text) &&
|
|
184
|
+
parseDocument(line).lines[0]?.kind !== 'claim') return `@claim ${line}`
|
|
185
|
+
return line
|
|
63
186
|
}
|
|
64
187
|
|
|
65
188
|
/**
|
|
@@ -68,18 +191,28 @@ const claimLine = (claim: Claim.t): string => {
|
|
|
68
191
|
* lines above the claim line, the last comment line riding on the claim.
|
|
69
192
|
*/
|
|
70
193
|
export const emitClaim = (claim: Claim.t): string =>
|
|
71
|
-
Token.joinComment(
|
|
194
|
+
Token.joinComment(fullClaimLine(claim), claim.comment)
|
|
72
195
|
|
|
73
196
|
/**
|
|
74
|
-
* @returns the qualifier-payload text of a condition claim. Negation
|
|
75
|
-
* emits as a `NOT` *prefix* — the §8.2 canonical `WHEN NOT x` shape —
|
|
76
|
-
*
|
|
197
|
+
* @returns the qualifier-payload text of a condition claim. Negation
|
|
198
|
+
* normally emits as a `NOT` *prefix* — the §8.2 canonical `WHEN NOT x` shape —
|
|
199
|
+
* rather than the claim-internal `VERB NOT` form: a postfix `NOT` after a symbolic
|
|
77
200
|
* comparison verb (`WHEN cpu >= NOT 900`) would be unreadable to the
|
|
78
|
-
* parser and silently invert the condition on round trip.
|
|
201
|
+
* parser and silently invert the condition on round trip. The reserved entity
|
|
202
|
+
* name NOT uses an explicit claim marker for an affirmative condition, as does
|
|
203
|
+
* a condition whose verb is NOT.
|
|
79
204
|
*/
|
|
80
205
|
const conditionText = (claim: Claim.t): string => {
|
|
206
|
+
validateFlags(claim)
|
|
207
|
+
// A leading entity named NOT would be consumed as qualifier negation.
|
|
208
|
+
// An explicit full claim preserves its affirmative reading and also allows
|
|
209
|
+
// NOT in the verb position, which unmarked qualifier parsing excludes.
|
|
210
|
+
if (claim.verb === 'NOT' || (claim.subject.kind === 'entity' && claim.subject.text === 'NOT' && !claim.negated)) {
|
|
211
|
+
const body = `@claim ${claimLine({ ...claim, negated: false })}`
|
|
212
|
+
return claim.negated ? `NOT ${body}` : body
|
|
213
|
+
}
|
|
81
214
|
const body = claim.verb === 'EXISTS' && claim.payload.kind === 'none' ?
|
|
82
|
-
[
|
|
215
|
+
singleLine([subjectText(claim.subject), ...metaText(claim)].join(' ')) :
|
|
83
216
|
claimLine({ ...claim, negated: false })
|
|
84
217
|
return claim.negated ? `NOT ${body}` : body
|
|
85
218
|
}
|
|
@@ -102,12 +235,18 @@ export const txComment = (tx: string): string =>
|
|
|
102
235
|
*/
|
|
103
236
|
export const txOfLine = Token.txOfLine
|
|
104
237
|
|
|
238
|
+
/** Optional annotation payload, interpreted by the interchange reader. */
|
|
239
|
+
export const txDataOfLine = Token.txDataOfLine
|
|
240
|
+
|
|
105
241
|
export type EmitOptions = {
|
|
106
242
|
/**
|
|
107
243
|
* Per-claim annotation lines (spec §28.4): when defined for a claim
|
|
108
244
|
* index, the returned text is emitted verbatim as its own line directly
|
|
109
245
|
* above that claim, at the claim's indentation. Used by tx-carrying
|
|
110
246
|
* export ({@link txComment}); return `undefined` to annotate nothing.
|
|
247
|
+
* Called in depth-first appearance order, including every re-statement
|
|
248
|
+
* of a shared or cyclic claim. Repeated appearances use the same index;
|
|
249
|
+
* return a stable transaction identity for that index when enabling replay.
|
|
111
250
|
*/
|
|
112
251
|
readonly annotate?: (index: number) => undefined | string
|
|
113
252
|
}
|
|
@@ -137,7 +276,7 @@ const tokenText = (token: Token.t): string => {
|
|
|
137
276
|
}
|
|
138
277
|
|
|
139
278
|
const commonPrefixLength = (items: readonly RenderItem[]): number => {
|
|
140
|
-
const shortest =
|
|
279
|
+
const shortest = items.reduce((minimum, item) => Math.min(minimum, item.tokens.length), Infinity)
|
|
141
280
|
let length = 0
|
|
142
281
|
while (
|
|
143
282
|
length < shortest &&
|
|
@@ -167,17 +306,31 @@ const emitForest = (
|
|
|
167
306
|
inherited: readonly string[],
|
|
168
307
|
lines: string[]
|
|
169
308
|
): void => {
|
|
170
|
-
|
|
171
|
-
while (
|
|
309
|
+
const stack = [{ forest, depth, topLevel, inherited, at: 0, runEnd: 0, canFactor: undefined as boolean | undefined }]
|
|
310
|
+
while (stack.length > 0) {
|
|
311
|
+
const frame = stack[stack.length - 1]!
|
|
312
|
+
const { forest, depth, topLevel, inherited, at } = frame
|
|
313
|
+
if (at >= forest.length) {
|
|
314
|
+
stack.pop()
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
172
317
|
const firstToken = forest[at]!.tokens[0]
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
318
|
+
if (at >= frame.runEnd) {
|
|
319
|
+
frame.canFactor = undefined
|
|
320
|
+
frame.runEnd = at + 1
|
|
321
|
+
while (frame.runEnd < forest.length && forest[frame.runEnd]!.tokens[0] === firstToken) {
|
|
322
|
+
frame.runEnd += 1
|
|
323
|
+
}
|
|
176
324
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
325
|
+
const end = frame.runEnd
|
|
326
|
+
// A one-token line cannot lose a prefix and still retain a leaf token.
|
|
327
|
+
// Every suffix shares this first token. If it already completes a claim,
|
|
328
|
+
// none can form a safe header, so avoid copying and scanning each suffix.
|
|
329
|
+
if (end - at > 1 && forest[at]!.tokens.length > 1 &&
|
|
330
|
+
(frame.canFactor ??= isIncomplete([...inherited, firstToken!], topLevel))) {
|
|
331
|
+
const run = forest.slice(at, end)
|
|
179
332
|
const common = commonPrefixLength(run)
|
|
180
|
-
const shortest =
|
|
333
|
+
const shortest = run.reduce((minimum, item) => Math.min(minimum, item.tokens.length), Infinity)
|
|
181
334
|
const maximum = Math.min(common, shortest - 1)
|
|
182
335
|
let safe = 0
|
|
183
336
|
for (let length = 1; length <= maximum; length += 1) {
|
|
@@ -190,14 +343,16 @@ const emitForest = (
|
|
|
190
343
|
if (safe > 0) {
|
|
191
344
|
const prefix = run[0]!.tokens.slice(0, safe)
|
|
192
345
|
lines.push(`${' '.repeat(depth)}${prefix.join(' ')}`)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
346
|
+
frame.at = end
|
|
347
|
+
stack.push({
|
|
348
|
+
forest: run.map(item => ({ node: item.node, tokens: item.tokens.slice(safe) })),
|
|
349
|
+
depth: depth + 1,
|
|
196
350
|
topLevel,
|
|
197
|
-
[...inherited, ...prefix],
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
351
|
+
inherited: [...inherited, ...prefix],
|
|
352
|
+
at: 0,
|
|
353
|
+
runEnd: 0,
|
|
354
|
+
canFactor: undefined
|
|
355
|
+
})
|
|
201
356
|
continue
|
|
202
357
|
}
|
|
203
358
|
}
|
|
@@ -208,19 +363,23 @@ const emitForest = (
|
|
|
208
363
|
// annotation stays the line directly above the claim.
|
|
209
364
|
const rendered = Token.joinComment(item.tokens.join(' '), item.node.comment).split('\n')
|
|
210
365
|
const last = rendered.pop()!
|
|
211
|
-
lines.push(
|
|
366
|
+
for (const line of rendered) lines.push(`${indent}${line}`)
|
|
212
367
|
if (item.node.annotation !== undefined) {
|
|
213
368
|
lines.push(`${indent}${item.node.annotation}`)
|
|
214
369
|
}
|
|
215
370
|
lines.push(`${indent}${last}`)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
371
|
+
frame.at += 1
|
|
372
|
+
if (item.node.children.length > 0) {
|
|
373
|
+
stack.push({
|
|
374
|
+
forest: item.node.children.map(node => ({ node, tokens: node.tokens })),
|
|
375
|
+
depth: depth + 1,
|
|
376
|
+
topLevel: false,
|
|
377
|
+
inherited: [],
|
|
378
|
+
at: 0,
|
|
379
|
+
runEnd: 0,
|
|
380
|
+
canFactor: undefined
|
|
381
|
+
})
|
|
382
|
+
}
|
|
224
383
|
}
|
|
225
384
|
}
|
|
226
385
|
|
|
@@ -239,9 +398,22 @@ const emitForest = (
|
|
|
239
398
|
* the re-statement.
|
|
240
399
|
*/
|
|
241
400
|
export const emit = (result: Pick<Canonicalize.Result, 'claims' | 'edges'>, options: EmitOptions = {}): string => {
|
|
401
|
+
if (!Array.isArray(result.claims)) throw new TypeError('CAVE claims must be a dense array')
|
|
402
|
+
for (let index = 0; index < result.claims.length; index++) {
|
|
403
|
+
if (!Object.hasOwn(result.claims, index)) throw new TypeError('CAVE claims must be a dense array')
|
|
404
|
+
}
|
|
242
405
|
const childEdges = new Map<number, Canonicalize.Edge[]>()
|
|
243
406
|
const isChild = new Set<number>()
|
|
244
|
-
for (const
|
|
407
|
+
for (const { parent, child, role } of result.edges) {
|
|
408
|
+
for (const [field, index] of [['parent', parent], ['child', child]] as const) {
|
|
409
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= result.claims.length) {
|
|
410
|
+
throw new TypeError(`CAVE edge ${field} must index an existing claim`)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if (role !== 'WHEN' && role !== 'VIA' && role !== 'BECAUSE' && role !== 'QUALIFIES') {
|
|
414
|
+
throw new TypeError('CAVE edge role must be WHEN, VIA, BECAUSE or QUALIFIES')
|
|
415
|
+
}
|
|
416
|
+
const edge = { parent, child, role }
|
|
245
417
|
isChild.add(edge.child)
|
|
246
418
|
const existing = childEdges.get(edge.parent)
|
|
247
419
|
if (existing === undefined) {
|
|
@@ -252,29 +424,33 @@ export const emit = (result: Pick<Canonicalize.Result, 'claims' | 'edges'>, opti
|
|
|
252
424
|
}
|
|
253
425
|
const expanded = new Set<number>()
|
|
254
426
|
const nodeAt = (index: number, role: undefined | Canonicalize.EdgeRole): RenderNode => {
|
|
255
|
-
const
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
427
|
+
const roots: RenderNode[] = []
|
|
428
|
+
const pending: { index: number, role: undefined | Canonicalize.EdgeRole, target: RenderNode[] }[] =
|
|
429
|
+
[{ index, role, target: roots }]
|
|
430
|
+
while (pending.length > 0) {
|
|
431
|
+
const { index, role, target } = pending.pop()!
|
|
432
|
+
const { claim } = result.claims[index]!
|
|
433
|
+
const text = role === undefined || role === 'QUALIFIES' ?
|
|
434
|
+
fullClaimLine(claim) : `${role} ${conditionText(claim)}`
|
|
435
|
+
const tokens = Token.tokenize(text).map(tokenText)
|
|
436
|
+
const annotation = options.annotate?.(index)
|
|
437
|
+
const children: RenderNode[] = []
|
|
438
|
+
target.push({
|
|
263
439
|
index,
|
|
264
440
|
tokens,
|
|
265
441
|
...claim.comment === undefined ? {} : { comment: claim.comment },
|
|
266
442
|
...annotation === undefined ? {} : { annotation },
|
|
267
|
-
children
|
|
443
|
+
children
|
|
444
|
+
})
|
|
445
|
+
if (expanded.has(index)) continue
|
|
446
|
+
expanded.add(index)
|
|
447
|
+
const edges = childEdges.get(index) ?? []
|
|
448
|
+
for (let at = edges.length - 1; at >= 0; at--) {
|
|
449
|
+
const edge = edges[at]!
|
|
450
|
+
pending.push({ index: edge.child, role: edge.role, target: children })
|
|
268
451
|
}
|
|
269
452
|
}
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
index,
|
|
273
|
-
tokens,
|
|
274
|
-
...claim.comment === undefined ? {} : { comment: claim.comment },
|
|
275
|
-
...annotation === undefined ? {} : { annotation },
|
|
276
|
-
children: (childEdges.get(index) ?? []).map(edge => nodeAt(edge.child, edge.role))
|
|
277
|
-
}
|
|
453
|
+
return roots[0]!
|
|
278
454
|
}
|
|
279
455
|
const forest: RenderNode[] = []
|
|
280
456
|
result.claims.forEach((_, index) => {
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,6 @@
|
|
|
18
18
|
export * as Registry from './registry.ts'
|
|
19
19
|
export { canonicalize, canonicalizeText } from './canonicalize.ts'
|
|
20
20
|
export type { Edge, EdgeRole, Entry, Problem, Result } from './canonicalize.ts'
|
|
21
|
-
export { emit, emitClaim, txComment, txOfLine } from './emit.ts'
|
|
21
|
+
export { emit, emitClaim, txComment, txOfLine, txDataOfLine } from './emit.ts'
|
|
22
22
|
export type { EmitOptions } from './emit.ts'
|
|
23
23
|
export { standardPrelude, standardRegistry } from './prelude.ts'
|