@timiagama/ecr 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +411 -7
- package/dist/bin.d.ts +20 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +26 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli/corpus-loader.d.ts +198 -0
- package/dist/cli/corpus-loader.d.ts.map +1 -0
- package/dist/cli/corpus-loader.js +351 -0
- package/dist/cli/corpus-loader.js.map +1 -0
- package/dist/cli/corpus-statistics.d.ts +113 -0
- package/dist/cli/corpus-statistics.d.ts.map +1 -0
- package/dist/cli/corpus-statistics.js +119 -0
- package/dist/cli/corpus-statistics.js.map +1 -0
- package/dist/cli/diagnostic-reporter.d.ts +103 -0
- package/dist/cli/diagnostic-reporter.d.ts.map +1 -0
- package/dist/cli/diagnostic-reporter.js +254 -0
- package/dist/cli/diagnostic-reporter.js.map +1 -0
- package/dist/cli/project-ignore.d.ts +113 -0
- package/dist/cli/project-ignore.d.ts.map +1 -0
- package/dist/cli/project-ignore.js +199 -0
- package/dist/cli/project-ignore.js.map +1 -0
- package/dist/cli/safe-text.d.ts +25 -0
- package/dist/cli/safe-text.d.ts.map +1 -0
- package/dist/cli/safe-text.js +34 -0
- package/dist/cli/safe-text.js.map +1 -0
- package/dist/cli/supervisor.d.ts +108 -0
- package/dist/cli/supervisor.d.ts.map +1 -0
- package/dist/cli/supervisor.js +139 -0
- package/dist/cli/supervisor.js.map +1 -0
- package/dist/cli.d.ts +247 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +591 -0
- package/dist/cli.js.map +1 -0
- package/dist/corpus-validator.d.ts +183 -0
- package/dist/corpus-validator.d.ts.map +1 -0
- package/dist/corpus-validator.js +479 -0
- package/dist/corpus-validator.js.map +1 -0
- package/dist/document-identity-rule.d.ts +198 -0
- package/dist/document-identity-rule.d.ts.map +1 -0
- package/dist/document-identity-rule.js +244 -0
- package/dist/document-identity-rule.js.map +1 -0
- package/dist/ecr.d.ts +103 -0
- package/dist/ecr.d.ts.map +1 -0
- package/dist/ecr.js +216 -0
- package/dist/ecr.js.map +1 -0
- package/dist/glob-pattern.d.ts +94 -0
- package/dist/glob-pattern.d.ts.map +1 -0
- package/dist/glob-pattern.js +188 -0
- package/dist/glob-pattern.js.map +1 -0
- package/dist/heading-source-form.d.ts +77 -0
- package/dist/heading-source-form.d.ts.map +1 -0
- package/dist/heading-source-form.js +129 -0
- package/dist/heading-source-form.js.map +1 -0
- package/dist/identifier-grammar.d.ts +212 -0
- package/dist/identifier-grammar.d.ts.map +1 -0
- package/dist/identifier-grammar.js +273 -0
- package/dist/identifier-grammar.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/inline-reference-rule.d.ts +557 -0
- package/dist/inline-reference-rule.d.ts.map +1 -0
- package/dist/inline-reference-rule.js +802 -0
- package/dist/inline-reference-rule.js.map +1 -0
- package/dist/meta-documents.d.ts +91 -0
- package/dist/meta-documents.d.ts.map +1 -0
- package/dist/meta-documents.js +133 -0
- package/dist/meta-documents.js.map +1 -0
- package/dist/per-document-visitor.d.ts +510 -0
- package/dist/per-document-visitor.d.ts.map +1 -0
- package/dist/per-document-visitor.js +1032 -0
- package/dist/per-document-visitor.js.map +1 -0
- package/dist/references-section-rule.d.ts +482 -0
- package/dist/references-section-rule.d.ts.map +1 -0
- package/dist/references-section-rule.js +777 -0
- package/dist/references-section-rule.js.map +1 -0
- package/dist/section-hierarchy-rule.d.ts +299 -0
- package/dist/section-hierarchy-rule.d.ts.map +1 -0
- package/dist/section-hierarchy-rule.js +482 -0
- package/dist/section-hierarchy-rule.js.map +1 -0
- package/dist/source-alignment.d.ts +41 -0
- package/dist/source-alignment.d.ts.map +1 -0
- package/dist/source-alignment.js +166 -0
- package/dist/source-alignment.js.map +1 -0
- package/dist/source-lines.d.ts +69 -0
- package/dist/source-lines.d.ts.map +1 -0
- package/dist/source-lines.js +104 -0
- package/dist/source-lines.js.map +1 -0
- package/dist/spec-version.d.ts +19 -0
- package/dist/spec-version.d.ts.map +1 -0
- package/dist/spec-version.js +19 -0
- package/dist/spec-version.js.map +1 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/examples/docs/0. Orientation/0.0 - System Overview.md +34 -0
- package/examples/docs/0. Orientation/0.0.1 - Glossary.md +21 -0
- package/examples/docs/0. Orientation/0.0.2 - Quality Gates.md +22 -0
- package/examples/docs/3. Ingestion/3.1 - Ingestion - Validation Rules.md +33 -0
- package/examples/docs/4. Payments/4.10 - Settlement.md +22 -0
- package/examples/docs/4. Payments/4.2 - Payment Processing Contract.md +33 -0
- package/examples/docs/8. Orchestration/8.1 - Workflow Orchestration Contract.md +38 -0
- package/examples/docs/8. Orchestration/8.1.3 - Retry Policy.md +17 -0
- package/examples/docs/8. Orchestration/8.10 - Dead Letter Queue.md +20 -0
- package/examples/docs/README.md +7 -0
- package/package.json +65 -7
- package/protocol/navigation-protocol.md +223 -0
- package/spec/v2/1 - ECR - Structural Specification.md +1467 -0
- package/spec/v2/2 - ECR - User Guide.md +845 -0
- package/spec/v2/3 - Design Rationale - The Section Separator.md +243 -0
- package/src/bin.ts +30 -0
- package/src/cli/corpus-loader.ts +482 -0
- package/src/cli/corpus-statistics.ts +196 -0
- package/src/cli/diagnostic-reporter.ts +344 -0
- package/src/cli/project-ignore.ts +238 -0
- package/src/cli/safe-text.ts +39 -0
- package/src/cli/supervisor.ts +204 -0
- package/src/cli.ts +836 -0
- package/src/corpus-validator.ts +642 -0
- package/src/document-identity-rule.ts +422 -0
- package/src/ecr.ts +298 -0
- package/src/glob-pattern.ts +242 -0
- package/src/heading-source-form.ts +173 -0
- package/src/identifier-grammar.ts +380 -0
- package/src/index.ts +78 -0
- package/src/inline-reference-rule.ts +1235 -0
- package/src/meta-documents.ts +159 -0
- package/src/per-document-visitor.ts +1528 -0
- package/src/references-section-rule.ts +1182 -0
- package/src/section-hierarchy-rule.ts +705 -0
- package/src/source-alignment.ts +209 -0
- package/src/source-lines.ts +123 -0
- package/src/spec-version.ts +19 -0
- package/src/types.ts +267 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline Reference Rule (ECR104)
|
|
3
|
+
*
|
|
4
|
+
* Detects and validates inline references in document prose as defined
|
|
5
|
+
* in the ECR specification:
|
|
6
|
+
* - 1#9.5 -- Inline Reference Rules
|
|
7
|
+
* - 1#10.7 -- InlineReferenceEdge
|
|
8
|
+
* - 1#6.3 -- Inline References (overview)
|
|
9
|
+
*
|
|
10
|
+
* This rule operates on text node data supplied by the document visitor.
|
|
11
|
+
* The visitor handles AST filtering (code blocks, inline code,
|
|
12
|
+
* HTML, link URLs) and heading context tracking. This rule receives only
|
|
13
|
+
* text nodes that are valid for inline reference detection, along with
|
|
14
|
+
* the current section context.
|
|
15
|
+
*
|
|
16
|
+
* The rule detects `see TargetID` and `per TargetID` forms where:
|
|
17
|
+
* - The keyword (`see` or `per`) is preceded by a word boundary
|
|
18
|
+
* (start of string, whitespace, or punctuation such as `(`)
|
|
19
|
+
* - The keyword's first letter may be capitalised (`see`, `See`, `per`, `Per`)
|
|
20
|
+
* - TargetID is a DocID, optionally followed by `#` and a section path
|
|
21
|
+
* - TargetID is maximally matched and terminated by a non-digit/non-dot
|
|
22
|
+
* character or end of string (a period followed by a non-digit is
|
|
23
|
+
* treated as punctuation, not part of the TargetID)
|
|
24
|
+
*
|
|
25
|
+
* For each valid inline reference, the rule checks that the TargetID's
|
|
26
|
+
* parent DocID (or the TargetID itself, if it is a DocID) is declared
|
|
27
|
+
* in the References section or is a self-reference to the document's
|
|
28
|
+
* own DocID. An undeclared SectionID target (`per 3.1#2`) is an error: the
|
|
29
|
+
* `#` form never occurs in prose. An undeclared DocID target is only a
|
|
30
|
+
* warning, because on its own a document cannot tell `per 3.1` (a reference
|
|
31
|
+
* someone forgot to declare) from `per 60 seconds` (ordinary prose). The
|
|
32
|
+
* corpus validator, which knows which documents exist, raises the former to
|
|
33
|
+
* an error.
|
|
34
|
+
*
|
|
35
|
+
* Extracted artefacts:
|
|
36
|
+
* - {@link InlineReferenceEdge} for each valid inline reference whose
|
|
37
|
+
* parent DocID is declared or is a self-reference
|
|
38
|
+
*/
|
|
39
|
+
import { indexLineStarts } from './source-lines.js';
|
|
40
|
+
import { alignParsedToSource } from './source-alignment.js';
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Rule identifier constant
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/**
|
|
45
|
+
* Canonical rule identifier for the Inline Reference Rule.
|
|
46
|
+
*
|
|
47
|
+
* Referenced as [ECR104] in the ECR specification (1#9.5).
|
|
48
|
+
*/
|
|
49
|
+
export const INLINE_REFERENCE_RULE_ID = 'ECR104';
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Diagnostic severity constant (module-level)
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* The severity of Inline Reference Rule diagnostics about targets that may be
|
|
55
|
+
* ordinary prose.
|
|
56
|
+
*
|
|
57
|
+
* Per 1#9.5, an undeclared DocID target (`per 60`, `see 8.1`) is a warning at
|
|
58
|
+
* document level: `see`/`per` followed by a number also occurs in ordinary
|
|
59
|
+
* prose. The corpus validator reports an error when the target turns out to be
|
|
60
|
+
* a real document. A wrapped reference is likewise a warning.
|
|
61
|
+
*/
|
|
62
|
+
export const INLINE_REFERENCE_DIAGNOSTIC_SEVERITY = 'warning';
|
|
63
|
+
/**
|
|
64
|
+
* The severity of an undeclared SectionID target (`see 8.1#3`).
|
|
65
|
+
*
|
|
66
|
+
* The `#` form never occurs in prose, so such a target is certainly a
|
|
67
|
+
* reference, and an undeclared one is an error without consulting the corpus.
|
|
68
|
+
*/
|
|
69
|
+
export const UNDECLARED_SECTION_TARGET_SEVERITY = 'error';
|
|
70
|
+
/**
|
|
71
|
+
* The `data.reason` carried by an undeclared-target diagnostic, which the
|
|
72
|
+
* corpus validator uses to find the warnings it must check against the index.
|
|
73
|
+
*/
|
|
74
|
+
export const UNDECLARED_TARGET_REASON = 'undeclared-target';
|
|
75
|
+
/**
|
|
76
|
+
* The `data.cause` carried by a citation whose candidate is not a complete,
|
|
77
|
+
* conforming token and contains a `#` (1#9.5 rule 1).
|
|
78
|
+
*/
|
|
79
|
+
export const MALFORMED_TARGET_CAUSE = 'malformed-target';
|
|
80
|
+
/**
|
|
81
|
+
* The `data.cause` carried by a citation whose keyword and identifier are not
|
|
82
|
+
* adjacent literal text on one source line (1#9.11 rule 2).
|
|
83
|
+
*/
|
|
84
|
+
export const CITATION_SOURCE_FORM_CAUSE = 'citation-source-form';
|
|
85
|
+
/**
|
|
86
|
+
* Characters that may terminate a candidate identifier (1#9.5 rule 1).
|
|
87
|
+
*
|
|
88
|
+
* Whitespace, the end of the text, and sentence punctuation. A letter is
|
|
89
|
+
* deliberately absent: `see 1#1oops` never wrote an identifier at all.
|
|
90
|
+
*/
|
|
91
|
+
const PERMITTED_TERMINATOR_PUNCTUATION = new Set([
|
|
92
|
+
',', ';', ':', ')', ']', '}', '"', "'", '!', '?',
|
|
93
|
+
]);
|
|
94
|
+
/**
|
|
95
|
+
* Any whitespace, which may also terminate a candidate.
|
|
96
|
+
*
|
|
97
|
+
* Tested as a class rather than listed, so a non-breaking or em space ends an
|
|
98
|
+
* identifier as an ordinary space does. Listing only the ASCII four made
|
|
99
|
+
* `see 1#1` followed by a non-breaking space a malformed-target error, and
|
|
100
|
+
* silently dropped the edge for `see 1`.
|
|
101
|
+
*/
|
|
102
|
+
const WHITESPACE = /\s/u;
|
|
103
|
+
/**
|
|
104
|
+
* A word character, for deciding whether a keyword stands at a boundary.
|
|
105
|
+
*
|
|
106
|
+
* This is ripgrep's definition (Rust's `\w`), chosen after measuring both
|
|
107
|
+
* published engines against the same inputs. They do not agree:
|
|
108
|
+
*
|
|
109
|
+
* preceding character ripgrep grep -E (C.UTF-8)
|
|
110
|
+
* space, hyphen match match
|
|
111
|
+
* e, e-acute, digit, _ no no
|
|
112
|
+
* combining acute (U+0301) no MATCH
|
|
113
|
+
* connector (U+203F) no MATCH
|
|
114
|
+
* supplementary (U+10400) no MATCH
|
|
115
|
+
*
|
|
116
|
+
* Where they differ, ripgrep is the stricter: it treats marks, connector
|
|
117
|
+
* punctuation and supplementary letters as word characters. The looser cases
|
|
118
|
+
* are simply not recognised as citations -- no edge, and no diagnostic,
|
|
119
|
+
* because text like `e\u0301see 1#1` is not a citation anyone wrote
|
|
120
|
+
* deliberately.
|
|
121
|
+
*
|
|
122
|
+
* The published recipes no longer use `\b` at all: they bound a citation by
|
|
123
|
+
* ERE recipes open with `(\b|_)`: ripgrep's `\b` is this same definition, so
|
|
124
|
+
* every keyword the linter recognises is found, and `_` is admitted so that
|
|
125
|
+
* `_see 8.1_` -- ordinary emphasis -- is found too. Measured against 46
|
|
126
|
+
* preceding characters in both engines, that form has no case where the
|
|
127
|
+
* linter recognises a keyword and either engine misses it. The PCRE recipe
|
|
128
|
+
* keeps an ASCII lookbehind instead, because PCRE's `\b` counts `\u00b2` and `\u00bd` as
|
|
129
|
+
* word characters and would miss them. Where an engine matches a keyword
|
|
130
|
+
* this set refuses, that is a permitted search hit, not an edge (1#9.11).
|
|
131
|
+
*/
|
|
132
|
+
const WORD_CHARACTER = /[\p{Alphabetic}\p{M}\p{Nd}\p{Pc}\p{Join_Control}]/u;
|
|
133
|
+
/** Characters a candidate identifier may be built from. */
|
|
134
|
+
const CANDIDATE_CHARACTERS = /[0-9.#]/;
|
|
135
|
+
/**
|
|
136
|
+
* Characters the published recipes refuse after a citation: an ASCII letter
|
|
137
|
+
* or digit. Anything else -- whitespace, punctuation, `_`, `*`, the `<` of a
|
|
138
|
+
* tag, the `&` of a character reference -- ends it.
|
|
139
|
+
*/
|
|
140
|
+
const RECIPE_EXCLUDED_BOUNDARY = /[0-9A-Za-z]/;
|
|
141
|
+
/** A character that ends a source line, which a recipe's `$` accepts. */
|
|
142
|
+
const LINE_BREAK_CHARACTER = /[\n\r]/;
|
|
143
|
+
/** A `see`/`per` keyword at a word boundary, wherever it occurs. */
|
|
144
|
+
const KEYWORD_PATTERN = /([Ss]ee|[Pp]er)/g;
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Split citations (module-level)
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
/**
|
|
149
|
+
* Inline Markdown formatting that can split a citation, with the words used
|
|
150
|
+
* to describe each in a diagnostic.
|
|
151
|
+
*/
|
|
152
|
+
const WRAPPER_DESCRIPTIONS = new Map([
|
|
153
|
+
['link', 'a link'],
|
|
154
|
+
['linkReference', 'a link'],
|
|
155
|
+
['strong', 'bold text'],
|
|
156
|
+
['emphasis', 'italic text'],
|
|
157
|
+
['delete', 'strikethrough text'],
|
|
158
|
+
]);
|
|
159
|
+
/**
|
|
160
|
+
* How a citation split by something other than formatting is described, by
|
|
161
|
+
* the kind of segment in the way. Where two text segments meet with nothing
|
|
162
|
+
* visible between them, only inline HTML can have separated them.
|
|
163
|
+
*/
|
|
164
|
+
const SPLIT_DESCRIPTIONS = new Map([
|
|
165
|
+
['code', 'inline code'],
|
|
166
|
+
['break', 'a line break'],
|
|
167
|
+
]);
|
|
168
|
+
/** What separates inline text segments when no segment is in the way. */
|
|
169
|
+
const TRANSPARENT_SPLIT_DESCRIPTION = 'inline HTML';
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Rule class
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
/**
|
|
174
|
+
* Detects and validates inline `see` and `per` references in document
|
|
175
|
+
* prose as defined in the ECR specification (1#9.5, 1#10.7).
|
|
176
|
+
*
|
|
177
|
+
* The rule enforces that:
|
|
178
|
+
* - Inline references use the `see TargetID` or `per TargetID` keyword forms
|
|
179
|
+
* - The keyword is preceded by a word boundary (start of string, whitespace,
|
|
180
|
+
* or punctuation) to prevent false positives from words like "oversee"
|
|
181
|
+
* or "hyperparameter"
|
|
182
|
+
* - The keyword's first letter may be capitalised (`See`, `Per`); the
|
|
183
|
+
* `kind` field normalises to lowercase
|
|
184
|
+
* - TargetID conforms to the identifier grammar and is maximally matched
|
|
185
|
+
* - The parent DocID of the TargetID is declared in the References section,
|
|
186
|
+
* or the TargetID is a self-reference to the document's own DocID
|
|
187
|
+
*
|
|
188
|
+
* For each valid inline reference whose parent DocID is declared (or is a
|
|
189
|
+
* self-reference), the rule extracts an {@link InlineReferenceEdge} with
|
|
190
|
+
* `fromId` set to the section context passed per call and `kind` normalised
|
|
191
|
+
* to lowercase.
|
|
192
|
+
*
|
|
193
|
+
* For undeclared references, the rule emits a warning diagnostic and does
|
|
194
|
+
* not extract an edge.
|
|
195
|
+
*
|
|
196
|
+
* Usage:
|
|
197
|
+
* 1. Construct a rule instance with the document URI, established DocID,
|
|
198
|
+
* grammar instance, and the set of declared DocIDs from the References section.
|
|
199
|
+
* 2. Call {@link evaluateTextNode} for every text node encountered during
|
|
200
|
+
* AST traversal (after the visitor has filtered out code/inlineCode/HTML/link
|
|
201
|
+
* URL contexts), passing the current section context.
|
|
202
|
+
* 3. Call {@link finalise} after all text nodes have been evaluated to obtain
|
|
203
|
+
* the complete result.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```ts
|
|
207
|
+
* const grammar = new IdentifierGrammar();
|
|
208
|
+
* const rule = new InlineReferenceRule({
|
|
209
|
+
* uri: 'file:///doc.md',
|
|
210
|
+
* docId: '5.1',
|
|
211
|
+
* grammar,
|
|
212
|
+
* declaredDocIds: new Set(['3.1', '8.1']),
|
|
213
|
+
* });
|
|
214
|
+
*
|
|
215
|
+
* rule.evaluateTextNode(
|
|
216
|
+
* { text: 'Guardrail logic per 3.1#2 and retry semantics see 8.1.' },
|
|
217
|
+
* '5.1#1',
|
|
218
|
+
* );
|
|
219
|
+
*
|
|
220
|
+
* const result: InlineReferenceRuleResult = rule.finalise();
|
|
221
|
+
* // result.inlineReferences has two edges:
|
|
222
|
+
* // { fromId: '5.1#1', toId: '3.1#2', kind: 'per' }
|
|
223
|
+
* // { fromId: '5.1#1', toId: '8.1', kind: 'see' }
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
export class InlineReferenceRule {
|
|
227
|
+
/** The opaque, host-provided URI identifying the document being validated. */
|
|
228
|
+
uri;
|
|
229
|
+
/** The established DocID for this document, used for self-reference detection. */
|
|
230
|
+
docId;
|
|
231
|
+
/** The grammar instance used for TargetID validation. */
|
|
232
|
+
grammar;
|
|
233
|
+
/** The set of DocIDs declared in the References section. */
|
|
234
|
+
declaredDocIds;
|
|
235
|
+
/** Diagnostics accumulated during evaluation. */
|
|
236
|
+
collectedDiagnostics;
|
|
237
|
+
/** Inline reference edges extracted from valid forms, in traversal order. */
|
|
238
|
+
collectedInlineReferences;
|
|
239
|
+
/** The document's raw source, for the source-form checks of 1#9.11. */
|
|
240
|
+
sourceText;
|
|
241
|
+
/** Absolute offset at which each source line begins. */
|
|
242
|
+
lineStarts;
|
|
243
|
+
/**
|
|
244
|
+
* Constructs a new Inline Reference Rule evaluator.
|
|
245
|
+
*
|
|
246
|
+
* @param options - Configuration including the document URI, established DocID,
|
|
247
|
+
* grammar instance, and declared DocIDs from the References section
|
|
248
|
+
*/
|
|
249
|
+
constructor(options) {
|
|
250
|
+
this.uri = options.uri;
|
|
251
|
+
this.docId = options.docId;
|
|
252
|
+
this.grammar = options.grammar;
|
|
253
|
+
this.declaredDocIds = options.declaredDocIds;
|
|
254
|
+
this.sourceText = options.sourceText;
|
|
255
|
+
this.lineStarts = indexLineStarts(options.sourceText);
|
|
256
|
+
this.collectedDiagnostics = [];
|
|
257
|
+
this.collectedInlineReferences = [];
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Evaluates a single text node for inline `see`/`per` references.
|
|
261
|
+
*
|
|
262
|
+
* Scans the text for all occurrences of `see TargetID` or `per TargetID`
|
|
263
|
+
* where the keyword is preceded by a word boundary. For each detected
|
|
264
|
+
* inline reference:
|
|
265
|
+
*
|
|
266
|
+
* 1. Validates that the TargetID conforms to the identifier grammar
|
|
267
|
+
* 2. Determines the parent DocID of the TargetID
|
|
268
|
+
* 3. Checks whether the parent DocID is declared in the References section
|
|
269
|
+
* or is a self-reference to the document's own DocID
|
|
270
|
+
* 4. If declared or self-referencing, extracts an {@link InlineReferenceEdge}
|
|
271
|
+
* with `fromId` set to the provided `sectionContext`
|
|
272
|
+
* 5. If undeclared, emits a warning diagnostic and does not extract an edge
|
|
273
|
+
*
|
|
274
|
+
* Multiple inline references within a single text node are all independently
|
|
275
|
+
* detected and validated. Duplicate references are not deduplicated (that is
|
|
276
|
+
* a downstream concern).
|
|
277
|
+
*
|
|
278
|
+
* @param textNodeData - Data extracted from a text AST node
|
|
279
|
+
* @param sectionContext - The current heading identifier (DocID or SectionID)
|
|
280
|
+
* at the point where this text node appears in the document.
|
|
281
|
+
* Used as the `fromId` for any extracted edges.
|
|
282
|
+
*/
|
|
283
|
+
evaluateTextNode(textNodeData,
|
|
284
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
285
|
+
sectionContext) {
|
|
286
|
+
this.evaluateInlineRun([
|
|
287
|
+
{
|
|
288
|
+
kind: 'text',
|
|
289
|
+
text: textNodeData.text,
|
|
290
|
+
wrappers: [],
|
|
291
|
+
...(textNodeData.range !== undefined ? { range: textNodeData.range } : {}),
|
|
292
|
+
},
|
|
293
|
+
], sectionContext);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Evaluates one inline run -- a paragraph, say -- for inline references.
|
|
297
|
+
*
|
|
298
|
+
* Recognition reads the run as a reader sees it, across every node boundary
|
|
299
|
+
* (1#9.5 rule 1). Only then is the source consulted: a citation extracts an
|
|
300
|
+
* edge only when its keyword, space and identifier all lie in one text
|
|
301
|
+
* segment and are literal in the source there (1#9.11 rule 2). One that
|
|
302
|
+
* spans segments -- split by formatting, inline HTML, code or a break -- is
|
|
303
|
+
* reported, never passed over.
|
|
304
|
+
*
|
|
305
|
+
* @param segments - The run's visible text, in order, per {@link InlineSegment}
|
|
306
|
+
* @param sectionContext - Section the run belongs to, used as `fromId`
|
|
307
|
+
*/
|
|
308
|
+
evaluateInlineRun(segments,
|
|
309
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
310
|
+
sectionContext) {
|
|
311
|
+
const visible = segments.map((segment) => segment.text).join('');
|
|
312
|
+
const owners = InlineReferenceRule.indexSegmentOwners(segments);
|
|
313
|
+
const starts = InlineReferenceRule.indexSegmentStarts(segments);
|
|
314
|
+
const aligned = new Map();
|
|
315
|
+
for (const detected of this.detectInlineReferences(visible, segments, owners)) {
|
|
316
|
+
const keywordOwner = owners[detected.index] ?? 0;
|
|
317
|
+
const keywordSegment = segments[keywordOwner];
|
|
318
|
+
const range = keywordSegment?.range;
|
|
319
|
+
if (!detected.conforms) {
|
|
320
|
+
this.reportNonConformingCandidate(detected, sectionContext, range);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const end = detected.index + detected.keyword.length + detected.gap.length + detected.targetId.length;
|
|
324
|
+
const lastOwner = owners[end - 1] ?? keywordOwner;
|
|
325
|
+
if (lastOwner !== keywordOwner || keywordSegment === undefined) {
|
|
326
|
+
const split = InlineReferenceRule.describeSplit(segments, keywordOwner, lastOwner);
|
|
327
|
+
this.reportUnnavigableCitation(detected, sectionContext, range, split.because, split.extra);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
let alignment = aligned.get(keywordOwner);
|
|
331
|
+
if (alignment === undefined) {
|
|
332
|
+
const source = this.sliceSource(keywordSegment.range);
|
|
333
|
+
alignment = {
|
|
334
|
+
source,
|
|
335
|
+
offsets: source === undefined
|
|
336
|
+
? undefined
|
|
337
|
+
: alignParsedToSource(source, keywordSegment.text),
|
|
338
|
+
};
|
|
339
|
+
aligned.set(keywordOwner, alignment);
|
|
340
|
+
}
|
|
341
|
+
const local = {
|
|
342
|
+
...detected,
|
|
343
|
+
index: detected.index - (starts[keywordOwner] ?? 0),
|
|
344
|
+
};
|
|
345
|
+
if (!this.tellCitationIsLiteral(local, range, alignment.source, alignment.offsets)) {
|
|
346
|
+
this.reportUnnavigableCitation(detected, sectionContext, range);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (!this.tellTargetIdDeclared(detected.targetId)) {
|
|
350
|
+
this.reportUndeclaredTarget(detected, sectionContext, range);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
this.collectedInlineReferences.push({
|
|
354
|
+
fromId: sectionContext,
|
|
355
|
+
toId: detected.targetId,
|
|
356
|
+
kind: detected.kind,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Records which segment each character of a run's visible text came from.
|
|
362
|
+
*
|
|
363
|
+
* @param segments - The run's segments
|
|
364
|
+
* @returns For each visible offset, the index of its segment
|
|
365
|
+
*/
|
|
366
|
+
static indexSegmentOwners(segments) {
|
|
367
|
+
const owners = [];
|
|
368
|
+
// One entry per UTF-16 unit, to line up with string offsets; iterating the
|
|
369
|
+
// text with for...of would step by code point instead.
|
|
370
|
+
segments.forEach((segment, index) => {
|
|
371
|
+
const start = owners.length;
|
|
372
|
+
owners.length = start + segment.text.length;
|
|
373
|
+
owners.fill(index, start);
|
|
374
|
+
});
|
|
375
|
+
return owners;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Records where each segment begins in a run's visible text.
|
|
379
|
+
*
|
|
380
|
+
* @param segments - The run's segments
|
|
381
|
+
* @returns The visible offset of each segment's first character
|
|
382
|
+
*/
|
|
383
|
+
static indexSegmentStarts(segments) {
|
|
384
|
+
const starts = [];
|
|
385
|
+
let offset = 0;
|
|
386
|
+
for (const segment of segments) {
|
|
387
|
+
starts.push(offset);
|
|
388
|
+
offset += segment.text.length;
|
|
389
|
+
}
|
|
390
|
+
return starts;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Says what split a citation across segments, for its diagnostic.
|
|
394
|
+
*
|
|
395
|
+
* Formatting is named when it is what differs: `see **8.1#3**` is split by
|
|
396
|
+
* bold text. Otherwise the kind of segment in the way is named, and where
|
|
397
|
+
* two text segments meet with nothing visible between them, only inline
|
|
398
|
+
* HTML can have separated them.
|
|
399
|
+
*
|
|
400
|
+
* @param segments - The run's segments
|
|
401
|
+
* @param first - Index of the segment holding the keyword
|
|
402
|
+
* @param last - Index of the segment holding the identifier's end
|
|
403
|
+
* @returns The phrase for the message, and any data it adds
|
|
404
|
+
*/
|
|
405
|
+
static describeSplit(segments, first, last) {
|
|
406
|
+
const spanned = segments.slice(first, last + 1);
|
|
407
|
+
const shared = spanned.reduce((common, segment) => common.filter((wrapper, depth) => segment.wrappers[depth] === wrapper), spanned[0]?.wrappers ?? []);
|
|
408
|
+
for (const segment of spanned) {
|
|
409
|
+
const wrapper = segment.wrappers[shared.length];
|
|
410
|
+
const description = wrapper === undefined ? undefined : WRAPPER_DESCRIPTIONS.get(wrapper);
|
|
411
|
+
if (wrapper !== undefined && description !== undefined) {
|
|
412
|
+
return { because: `is split by ${description}`, extra: { wrapper } };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const obstacle = spanned.find((segment) => segment.kind !== 'text');
|
|
416
|
+
const description = SPLIT_DESCRIPTIONS.get(obstacle?.kind ?? '') ?? TRANSPARENT_SPLIT_DESCRIPTION;
|
|
417
|
+
return { because: `is split by ${description}` };
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Reports whether this citation is written literally at its own position.
|
|
421
|
+
*
|
|
422
|
+
* Only the keyword, the single space and the identifier are required to be
|
|
423
|
+
* literal. What follows them is not: a citation may be terminated by a
|
|
424
|
+
* character reference (`see 1#1 here`) and remain perfectly findable,
|
|
425
|
+
* because a search stops at the identifier. Applying the parsed token's
|
|
426
|
+
* terminator rules to raw characters rejected exactly those documents.
|
|
427
|
+
*
|
|
428
|
+
* @param detected - The citation under test
|
|
429
|
+
* @param range - The node's range, absent in unit tests that supply bare text
|
|
430
|
+
* @param source - The node's raw source, when it could be located
|
|
431
|
+
* @param offsets - Parsed-to-source offset map, when alignment succeeded
|
|
432
|
+
* @returns `true` when the source carries this citation literally
|
|
433
|
+
*/
|
|
434
|
+
tellCitationIsLiteral(detected, range, source, offsets) {
|
|
435
|
+
if (detected.gap !== ' ') {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
// No range means no source to consult: the rule's unit tests supply bare
|
|
439
|
+
// text. That differs from having a range and failing to map it, which is
|
|
440
|
+
// treated as unverifiable below.
|
|
441
|
+
if (range === undefined) {
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
if (source === undefined || offsets === undefined) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
const at = offsets[detected.index];
|
|
448
|
+
const nodeStart = this.offsetOf(range.start);
|
|
449
|
+
if (at === undefined || nodeStart === undefined) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
return (source.startsWith(`${detected.keyword} ${detected.targetId}`, at) &&
|
|
453
|
+
this.tellRecipeBoundsCitation(nodeStart + at, detected));
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Reports whether the published recipes accept the characters either side
|
|
457
|
+
* of a citation in the source (1#9.11).
|
|
458
|
+
*
|
|
459
|
+
* Before the keyword, the recipes accept a word boundary or `_`: `_` is a
|
|
460
|
+
* word character to both engines, and `_see 8.1_` is ordinary emphasis.
|
|
461
|
+
* After the identifier they accept anything but an ASCII letter or digit.
|
|
462
|
+
* After a bare DocID a `#` is refused too, and a `.` is allowed only as the
|
|
463
|
+
* end of a sentence -- not before a letter, digit, `.` or `#` -- so that
|
|
464
|
+
* `8.1` is not found inside `8.1#3`, `8.1.2` or `8.1..2`; after a SectionID
|
|
465
|
+
* a `.` is allowed, since the recipe finds a section and those below it.
|
|
466
|
+
*
|
|
467
|
+
* The whole source is read, not the node's slice: a citation at the start
|
|
468
|
+
* of a node is preceded by whatever came before the node.
|
|
469
|
+
*
|
|
470
|
+
* @param start - Absolute source offset of the keyword
|
|
471
|
+
* @param detected - The citation, known to be literal at `start`
|
|
472
|
+
* @returns `true` when the recipe for this target would match here
|
|
473
|
+
*/
|
|
474
|
+
tellRecipeBoundsCitation(start, detected) {
|
|
475
|
+
// `(\b|_)` before the keyword: `_`, or anything the word-boundary test
|
|
476
|
+
// accepts, which is ripgrep's own definition of `\b`.
|
|
477
|
+
const before = start > 0 ? this.sourceText.charAt(start - 1) : '';
|
|
478
|
+
if (before !== '_' && !this.tellAtWordBoundary(this.sourceText, start)) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
const end = start + detected.keyword.length + 1 + detected.targetId.length;
|
|
482
|
+
const next = this.sourceText.charAt(end);
|
|
483
|
+
if (next === '' || LINE_BREAK_CHARACTER.test(next)) {
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
if (RECIPE_EXCLUDED_BOUNDARY.test(next) || next === '#') {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
if (next !== '.' || detected.targetId.includes('#')) {
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
const afterFullStop = this.sourceText.charAt(end + 1);
|
|
493
|
+
return (!RECIPE_EXCLUDED_BOUNDARY.test(afterFullStop) &&
|
|
494
|
+
afterFullStop !== '.' &&
|
|
495
|
+
afterFullStop !== '#');
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Reports a candidate that is not a complete, conforming identifier.
|
|
499
|
+
*
|
|
500
|
+
* The `#` decides whether anything is reported at all. Ordinary writing
|
|
501
|
+
* produces numbers after `see` and `per` constantly -- `per 60s`,
|
|
502
|
+
* `see 1..2` -- and never produces `1#1`. So a failed candidate carrying a
|
|
503
|
+
* separator was certainly meant as an identifier and is an error, while one
|
|
504
|
+
* without is prose and is passed over in silence.
|
|
505
|
+
*
|
|
506
|
+
* @param detected - The candidate that failed
|
|
507
|
+
* @param sectionContext - Section the text belongs to
|
|
508
|
+
* @param range - Positional range of the text node
|
|
509
|
+
*/
|
|
510
|
+
reportNonConformingCandidate(detected,
|
|
511
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
512
|
+
sectionContext, range) {
|
|
513
|
+
if (!detected.candidate.includes('#')) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this.collectedDiagnostics.push(this.createDiagnostic(`"${detected.keyword} ${detected.candidate}" is malformed: ` +
|
|
517
|
+
`"${detected.candidate}" is not a complete identifier. It is not read as a ` +
|
|
518
|
+
`reference to a shorter one.`, range, {
|
|
519
|
+
cause: MALFORMED_TARGET_CAUSE,
|
|
520
|
+
candidate: detected.candidate,
|
|
521
|
+
fromId: sectionContext,
|
|
522
|
+
kind: detected.kind,
|
|
523
|
+
}, 'error'));
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Reports a citation whose source form no search can find (1#9.11 rule 2).
|
|
527
|
+
*
|
|
528
|
+
* Severity follows the same test as an undeclared target: an identifiable
|
|
529
|
+
* target -- a SectionID, a declared DocID, or the document's own -- was
|
|
530
|
+
* certainly meant as a citation, so an unfindable one is an error. A bare
|
|
531
|
+
* number naming nothing known cannot be told from emphasised prose, so it
|
|
532
|
+
* warns, and corpus validation raises it if the document turns out to exist.
|
|
533
|
+
*
|
|
534
|
+
* @param detected - The citation that is not literal in the source
|
|
535
|
+
* @param sectionContext - Section the text belongs to
|
|
536
|
+
* @param range - Positional range of the text node
|
|
537
|
+
* @param split - What split it, when it spans segments, e.g. `is split by bold text`
|
|
538
|
+
* @param extra - Data the diagnostic carries in addition
|
|
539
|
+
*/
|
|
540
|
+
reportUnnavigableCitation(detected,
|
|
541
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
542
|
+
sectionContext, range, split, extra) {
|
|
543
|
+
const identifiable = detected.targetId.includes('#') || this.tellTargetIdDeclared(detected.targetId);
|
|
544
|
+
const because = split === undefined
|
|
545
|
+
? 'is not written as adjacent literal text on one line'
|
|
546
|
+
: `${split} rather than written as adjacent literal text on one line`;
|
|
547
|
+
this.collectedDiagnostics.push(this.createDiagnostic(`"${detected.keyword} ${detected.targetId}" ${because}, so no search finds it. ` +
|
|
548
|
+
`Put the keyword and the identifier on one line with a single space between ` +
|
|
549
|
+
`them, and without formatting or escapes.`, range, {
|
|
550
|
+
cause: CITATION_SOURCE_FORM_CAUSE,
|
|
551
|
+
reason: UNDECLARED_TARGET_REASON,
|
|
552
|
+
targetId: detected.targetId,
|
|
553
|
+
targetDocId: this.showTargetDocId(detected.targetId),
|
|
554
|
+
fromId: sectionContext,
|
|
555
|
+
kind: detected.kind,
|
|
556
|
+
...(extra ?? {}),
|
|
557
|
+
}, identifiable ? 'error' : INLINE_REFERENCE_DIAGNOSTIC_SEVERITY));
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Reports a conforming, navigable citation whose DocID is not declared.
|
|
561
|
+
*
|
|
562
|
+
* @param detected - The citation
|
|
563
|
+
* @param sectionContext - Section the text belongs to
|
|
564
|
+
* @param range - Positional range of the text node
|
|
565
|
+
*/
|
|
566
|
+
reportUndeclaredTarget(detected,
|
|
567
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
568
|
+
sectionContext, range) {
|
|
569
|
+
const parentDocId = this.showTargetDocId(detected.targetId);
|
|
570
|
+
const quoted = `"${detected.kind} ${detected.targetId}"`;
|
|
571
|
+
const data = {
|
|
572
|
+
reason: UNDECLARED_TARGET_REASON,
|
|
573
|
+
targetId: detected.targetId,
|
|
574
|
+
targetDocId: parentDocId,
|
|
575
|
+
fromId: sectionContext,
|
|
576
|
+
kind: detected.kind,
|
|
577
|
+
};
|
|
578
|
+
const diagnostic = detected.targetId.includes('#')
|
|
579
|
+
? this.createDiagnostic(`${quoted} targets DocID "${parentDocId}", which the References section ` +
|
|
580
|
+
`does not declare. Declare ${parentDocId} in References.`, range, data, UNDECLARED_SECTION_TARGET_SEVERITY)
|
|
581
|
+
: this.createDiagnostic(`${quoted} reads as a reference to DocID "${parentDocId}", which the ` +
|
|
582
|
+
`References section does not declare. If it is a reference, declare ` +
|
|
583
|
+
`${parentDocId} in References; if it is ordinary prose, it can be left as it is.`, range, data);
|
|
584
|
+
this.collectedDiagnostics.push(diagnostic);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Finalises the rule evaluation and produces the complete result.
|
|
588
|
+
*
|
|
589
|
+
* This method must be called after all text nodes have been supplied
|
|
590
|
+
* via {@link evaluateTextNode}. It returns the accumulated diagnostics
|
|
591
|
+
* and extracted inline reference edges.
|
|
592
|
+
*
|
|
593
|
+
* @returns The complete rule result including all diagnostics and
|
|
594
|
+
* extracted inline reference edges
|
|
595
|
+
*/
|
|
596
|
+
finalise() {
|
|
597
|
+
return {
|
|
598
|
+
diagnostics: this.collectedDiagnostics,
|
|
599
|
+
inlineReferences: this.collectedInlineReferences,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
// -------------------------------------------------------------------------
|
|
603
|
+
// Private helpers
|
|
604
|
+
// -------------------------------------------------------------------------
|
|
605
|
+
/**
|
|
606
|
+
* Finds every `see`/`per` citation candidate in a run's visible text.
|
|
607
|
+
*
|
|
608
|
+
* A keyword is recognised where a word boundary precedes it in the text a
|
|
609
|
+
* reader sees, and a candidate where digits follow it after whitespace.
|
|
610
|
+
* Neither is recognised when it starts inside inline code or a break: code
|
|
611
|
+
* may continue a candidate, but an author never cites through it (1#9.5).
|
|
612
|
+
*
|
|
613
|
+
* @param visible - The run's visible text
|
|
614
|
+
* @param segments - The run's segments
|
|
615
|
+
* @param owners - For each visible offset, the index of its segment
|
|
616
|
+
* @returns The candidates, in order of occurrence, indexed into `visible`
|
|
617
|
+
*/
|
|
618
|
+
detectInlineReferences(visible, segments, owners) {
|
|
619
|
+
const detectedMatches = [];
|
|
620
|
+
const tellIsText = (offset) => segments[owners[offset] ?? -1]?.kind === 'text';
|
|
621
|
+
KEYWORD_PATTERN.lastIndex = 0;
|
|
622
|
+
let keywordMatch = KEYWORD_PATTERN.exec(visible);
|
|
623
|
+
while (keywordMatch !== null) {
|
|
624
|
+
const keyword = keywordMatch[1] ?? '';
|
|
625
|
+
const index = keywordMatch.index;
|
|
626
|
+
const keywordIsText = Array.from(keyword, (_, offset) => tellIsText(index + offset)).every(Boolean);
|
|
627
|
+
if (keywordIsText && this.tellAtWordBoundary(visible, index)) {
|
|
628
|
+
const detected = this.readCandidate(visible, index, keyword);
|
|
629
|
+
if (detected !== undefined &&
|
|
630
|
+
tellIsText(index + keyword.length + detected.gap.length)) {
|
|
631
|
+
detectedMatches.push(detected);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
keywordMatch = KEYWORD_PATTERN.exec(visible);
|
|
635
|
+
}
|
|
636
|
+
return detectedMatches;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Reads the candidate identifier that follows a keyword, if there is one.
|
|
640
|
+
*
|
|
641
|
+
* The candidate is the maximal run of identifier characters, taken whole
|
|
642
|
+
* before it is tested. Taking it whole is what stops a malformed identifier
|
|
643
|
+
* decaying into a shorter valid one: `1#1#9` fails, rather than passing as
|
|
644
|
+
* `1#1`.
|
|
645
|
+
*
|
|
646
|
+
* @param text - The run's visible text
|
|
647
|
+
* @param keywordIndex - Index of the keyword's first character
|
|
648
|
+
* @param keyword - The keyword exactly as written
|
|
649
|
+
* @returns The candidate, or `undefined` when no digits follow the keyword
|
|
650
|
+
*/
|
|
651
|
+
readCandidate(text, keywordIndex, keyword) {
|
|
652
|
+
const afterKeyword = keywordIndex + keyword.length;
|
|
653
|
+
let cursor = afterKeyword;
|
|
654
|
+
while (cursor < text.length && WHITESPACE.test(text.charAt(cursor))) {
|
|
655
|
+
cursor += 1;
|
|
656
|
+
}
|
|
657
|
+
const gap = text.slice(afterKeyword, cursor);
|
|
658
|
+
if (gap.length === 0 || !/[0-9]/.test(text.charAt(cursor))) {
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
const candidateStart = cursor;
|
|
662
|
+
while (cursor < text.length && CANDIDATE_CHARACTERS.test(text.charAt(cursor))) {
|
|
663
|
+
cursor += 1;
|
|
664
|
+
}
|
|
665
|
+
const candidate = text.slice(candidateStart, cursor);
|
|
666
|
+
const terminator = cursor < text.length ? text.charAt(cursor) : undefined;
|
|
667
|
+
const terminated = terminator === undefined ||
|
|
668
|
+
WHITESPACE.test(terminator) ||
|
|
669
|
+
PERMITTED_TERMINATOR_PUNCTUATION.has(terminator);
|
|
670
|
+
const targetId = candidate.endsWith('.') ? candidate.slice(0, -1) : candidate;
|
|
671
|
+
const conforms = terminated &&
|
|
672
|
+
(this.grammar.parseDocId(targetId).valid || this.grammar.parseSectionId(targetId).valid);
|
|
673
|
+
return {
|
|
674
|
+
kind: keyword.toLowerCase(),
|
|
675
|
+
keyword,
|
|
676
|
+
candidate,
|
|
677
|
+
targetId,
|
|
678
|
+
conforms,
|
|
679
|
+
gap,
|
|
680
|
+
index: keywordIndex,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Reports whether a keyword at `index` stands at a word boundary.
|
|
685
|
+
*
|
|
686
|
+
* Reads whole code points: `charAt` would return half of a supplementary
|
|
687
|
+
* character and misjudge it.
|
|
688
|
+
*
|
|
689
|
+
* @param text - The text being scanned
|
|
690
|
+
* @param index - Index of the keyword's first character
|
|
691
|
+
* @returns `true` when nothing word-like immediately precedes the keyword
|
|
692
|
+
*/
|
|
693
|
+
tellAtWordBoundary(text, index) {
|
|
694
|
+
if (index === 0) {
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
const before = text.codePointAt(index - 1);
|
|
698
|
+
if (before === undefined) {
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
// A low surrogate here means the preceding character is supplementary;
|
|
702
|
+
// step back one more unit to read the whole code point.
|
|
703
|
+
const isLowSurrogate = before >= 0xdc00 && before <= 0xdfff;
|
|
704
|
+
const codePoint = isLowSurrogate
|
|
705
|
+
? text.codePointAt(index - 2)
|
|
706
|
+
: before;
|
|
707
|
+
if (codePoint === undefined) {
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
return !WORD_CHARACTER.test(String.fromCodePoint(codePoint));
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Returns the raw source belonging to a node, when its range is known.
|
|
714
|
+
*
|
|
715
|
+
* @param range - The node's positional range
|
|
716
|
+
* @returns The source slice, or `undefined` when no range was supplied
|
|
717
|
+
*/
|
|
718
|
+
sliceSource(range) {
|
|
719
|
+
if (range === undefined) {
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
const startOffset = this.offsetOf(range.start);
|
|
723
|
+
const endOffset = this.offsetOf(range.end);
|
|
724
|
+
if (startOffset === undefined || endOffset === undefined) {
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
return this.sourceText.slice(startOffset, endOffset);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Converts a line/character position into an absolute source offset.
|
|
731
|
+
*
|
|
732
|
+
* @param position - A position within the document
|
|
733
|
+
* @returns The offset, or `undefined` when the line is out of range
|
|
734
|
+
*/
|
|
735
|
+
offsetOf(position) {
|
|
736
|
+
const lineStart = this.lineStarts[position.line];
|
|
737
|
+
return lineStart === undefined ? undefined : lineStart + position.character;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Determines whether a TargetID's parent DocID is declared in the
|
|
741
|
+
* References section or is a self-reference to the document's own DocID.
|
|
742
|
+
*
|
|
743
|
+
* The target's DocID is read directly — the TargetID itself, or the text
|
|
744
|
+
* before its `#` — and compared exactly with the document's own DocID and
|
|
745
|
+
* the declared set. No prefix matching is involved, so `8.1.3` is not
|
|
746
|
+
* covered by a declaration of `8.1`.
|
|
747
|
+
*
|
|
748
|
+
* @param targetId - The TargetID to check
|
|
749
|
+
* @returns `true` if the TargetID is declared or is a self-reference,
|
|
750
|
+
* `false` if it is an undeclared reference
|
|
751
|
+
*/
|
|
752
|
+
tellTargetIdDeclared(targetId) {
|
|
753
|
+
const targetDocId = this.showTargetDocId(targetId);
|
|
754
|
+
// A reference into this document's own sections needs no declaration.
|
|
755
|
+
if (targetDocId === this.docId) {
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
return this.declaredDocIds.has(targetDocId);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Extracts the DocID a TargetID refers to.
|
|
762
|
+
*
|
|
763
|
+
* This needs no inference: everything before the `#` separator is the
|
|
764
|
+
* DocID, and an identifier with no separator is itself a DocID. The
|
|
765
|
+
* separator removes the ambiguity a dotted-only grammar would have to
|
|
766
|
+
* resolve by guessing.
|
|
767
|
+
*
|
|
768
|
+
* @param targetId - A DocID or SectionID as written in an inline reference
|
|
769
|
+
* @returns The DocID portion of the identifier
|
|
770
|
+
*/
|
|
771
|
+
showTargetDocId(targetId) {
|
|
772
|
+
const separatorIndex = targetId.indexOf('#');
|
|
773
|
+
if (separatorIndex < 0) {
|
|
774
|
+
return targetId;
|
|
775
|
+
}
|
|
776
|
+
return targetId.substring(0, separatorIndex);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Creates a diagnostic object for the Inline Reference Rule.
|
|
780
|
+
*
|
|
781
|
+
* All diagnostics share the same rule ID ({@link INLINE_REFERENCE_RULE_ID})
|
|
782
|
+
* and document URI.
|
|
783
|
+
*
|
|
784
|
+
* @param message - Human-readable description of the issue
|
|
785
|
+
* @param range - Optional positional range within the source document
|
|
786
|
+
* @param data - Optional structured detail
|
|
787
|
+
* @param severity - Defaults to {@link INLINE_REFERENCE_DIAGNOSTIC_SEVERITY}
|
|
788
|
+
* @returns A fully populated diagnostic object
|
|
789
|
+
*/
|
|
790
|
+
createDiagnostic(message, range, data, severity = INLINE_REFERENCE_DIAGNOSTIC_SEVERITY) {
|
|
791
|
+
const diagnostic = {
|
|
792
|
+
ruleId: INLINE_REFERENCE_RULE_ID,
|
|
793
|
+
severity,
|
|
794
|
+
message,
|
|
795
|
+
uri: this.uri,
|
|
796
|
+
...(range !== undefined ? { range } : {}),
|
|
797
|
+
...(data !== undefined ? { data } : {}),
|
|
798
|
+
};
|
|
799
|
+
return diagnostic;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
//# sourceMappingURL=inline-reference-rule.js.map
|