@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,1235 @@
|
|
|
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
|
+
|
|
40
|
+
import type {
|
|
41
|
+
DocID,
|
|
42
|
+
SectionID,
|
|
43
|
+
Diagnostic,
|
|
44
|
+
DiagnosticSeverity,
|
|
45
|
+
InlineReferenceEdge,
|
|
46
|
+
InlineReferenceKind,
|
|
47
|
+
Position,
|
|
48
|
+
PositionRange,
|
|
49
|
+
} from './types.js';
|
|
50
|
+
import type { IdentifierGrammar } from './identifier-grammar.js';
|
|
51
|
+
import { indexLineStarts } from './source-lines.js';
|
|
52
|
+
import { alignParsedToSource } from './source-alignment.js';
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Rule identifier constant
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Canonical rule identifier for the Inline Reference Rule.
|
|
60
|
+
*
|
|
61
|
+
* Referenced as [ECR104] in the ECR specification (1#9.5).
|
|
62
|
+
*/
|
|
63
|
+
export const INLINE_REFERENCE_RULE_ID: string = 'ECR104';
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Input types
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Data extracted from a single text AST node, provided by the document
|
|
71
|
+
* visitor/traversal layer.
|
|
72
|
+
*
|
|
73
|
+
* The visitor filters out text nodes inside code blocks, inline code,
|
|
74
|
+
* HTML elements, and link URL portions before passing data to this rule.
|
|
75
|
+
* This rule only receives text nodes that are valid candidates for
|
|
76
|
+
* inline reference detection.
|
|
77
|
+
*/
|
|
78
|
+
export interface TextNodeData {
|
|
79
|
+
/**
|
|
80
|
+
* Plain text content of the text node.
|
|
81
|
+
*
|
|
82
|
+
* @example "Guardrail requirements are enforced per 3.1#2."
|
|
83
|
+
*/
|
|
84
|
+
readonly text: string;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Positional range of the text node within the source document.
|
|
88
|
+
* Optional; depends on whether the Markdown parser provides positional metadata.
|
|
89
|
+
*/
|
|
90
|
+
readonly range?: PositionRange;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How a piece of an inline run takes part in recognition (1#9.5 rule 1).
|
|
95
|
+
*
|
|
96
|
+
* - `text`: a parsed text node. Keywords and candidates may be recognised in
|
|
97
|
+
* it, and only text can be literal in the source.
|
|
98
|
+
* - `code`: inline code. It may continue a candidate, but neither a keyword
|
|
99
|
+
* nor a candidate is ever recognised as starting inside it.
|
|
100
|
+
* - `break`: a hard break, an image, a `<br>` tag -- anything a reader sees
|
|
101
|
+
* as separating the words either side. Its text is a single space.
|
|
102
|
+
*/
|
|
103
|
+
export type InlineSegmentKind = 'text' | 'code' | 'break';
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One piece of the text a reader sees in an inline run: a paragraph, or
|
|
107
|
+
* anything else whose children are inline content.
|
|
108
|
+
*
|
|
109
|
+
* A Markdown text node ends wherever formatting begins, which is not where a
|
|
110
|
+
* word or an identifier ends. Recognising citations one text node at a time
|
|
111
|
+
* therefore missed or misread every citation that crossed formatting:
|
|
112
|
+
* `per 60**s**` became a reference, and `see <span>8.1#3</span>` vanished
|
|
113
|
+
* without a diagnostic. The run is scanned as a whole instead, and each
|
|
114
|
+
* segment records where its characters came from.
|
|
115
|
+
*
|
|
116
|
+
* Inline HTML other than a line break is transparent, so it contributes no
|
|
117
|
+
* segment at all.
|
|
118
|
+
*/
|
|
119
|
+
export interface InlineSegment {
|
|
120
|
+
/** How the segment takes part in recognition. */
|
|
121
|
+
readonly kind: InlineSegmentKind;
|
|
122
|
+
|
|
123
|
+
/** The characters a reader sees. */
|
|
124
|
+
readonly text: string;
|
|
125
|
+
|
|
126
|
+
/** Position of the parsed text node, for a `text` segment. */
|
|
127
|
+
readonly range?: PositionRange;
|
|
128
|
+
|
|
129
|
+
/** MDAST types of the formatting spans enclosing it, outermost first. */
|
|
130
|
+
readonly wrappers: readonly string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Result types
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The complete result produced by finalising the Inline Reference Rule
|
|
139
|
+
* after all text nodes have been evaluated.
|
|
140
|
+
*
|
|
141
|
+
* Contains zero or more diagnostics and the list of successfully extracted
|
|
142
|
+
* inline reference edges.
|
|
143
|
+
*/
|
|
144
|
+
export interface InlineReferenceRuleResult {
|
|
145
|
+
/** Diagnostics emitted during evaluation (errors for undeclared references). */
|
|
146
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Inline reference edges extracted from valid `see`/`per` forms,
|
|
150
|
+
* in document traversal order.
|
|
151
|
+
*
|
|
152
|
+
* Each edge represents a section-attributed relationship from the
|
|
153
|
+
* current section context to the referenced TargetID.
|
|
154
|
+
*/
|
|
155
|
+
readonly inlineReferences: readonly InlineReferenceEdge[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Constructor options
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Configuration options for constructing an {@link InlineReferenceRule} instance.
|
|
164
|
+
*/
|
|
165
|
+
export interface InlineReferenceRuleOptions {
|
|
166
|
+
/**
|
|
167
|
+
* The opaque, host-provided URI identifying the document being validated.
|
|
168
|
+
* Attached to all emitted diagnostics.
|
|
169
|
+
*/
|
|
170
|
+
readonly uri: string;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The DocID established by the Document Identity Rule for this document.
|
|
174
|
+
* Used for self-reference detection: an inline reference whose target
|
|
175
|
+
* DocID (the TargetID itself, or the text before its `#`) equals this
|
|
176
|
+
* DocID needs no declaration in the References section.
|
|
177
|
+
*/
|
|
178
|
+
readonly docId: DocID;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The {@link IdentifierGrammar} instance used for TargetID validation.
|
|
182
|
+
*/
|
|
183
|
+
readonly grammar: IdentifierGrammar;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The set of DocIDs declared in the References section (the References Section Rule's output).
|
|
187
|
+
*
|
|
188
|
+
* Used to determine whether an inline reference's parent DocID has been
|
|
189
|
+
* declared. If a TargetID's parent DocID is not in this set and is not
|
|
190
|
+
* a self-reference, an undeclared-reference diagnostic is emitted.
|
|
191
|
+
*/
|
|
192
|
+
readonly declaredDocIds: ReadonlySet<DocID>;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The document's raw Markdown source.
|
|
196
|
+
*
|
|
197
|
+
* 1#9.11 is the one rule stated over source rather than over the parsed
|
|
198
|
+
* tree, because a search reads the file and the parser does not. A citation
|
|
199
|
+
* written `see 8\\.1#3` parses to `see 8.1#3` and is invisible to every
|
|
200
|
+
* recipe; only the source shows the difference.
|
|
201
|
+
*/
|
|
202
|
+
readonly sourceText: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Diagnostic severity constant (module-level)
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The severity of Inline Reference Rule diagnostics about targets that may be
|
|
211
|
+
* ordinary prose.
|
|
212
|
+
*
|
|
213
|
+
* Per 1#9.5, an undeclared DocID target (`per 60`, `see 8.1`) is a warning at
|
|
214
|
+
* document level: `see`/`per` followed by a number also occurs in ordinary
|
|
215
|
+
* prose. The corpus validator reports an error when the target turns out to be
|
|
216
|
+
* a real document. A wrapped reference is likewise a warning.
|
|
217
|
+
*/
|
|
218
|
+
export const INLINE_REFERENCE_DIAGNOSTIC_SEVERITY: DiagnosticSeverity = 'warning';
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The severity of an undeclared SectionID target (`see 8.1#3`).
|
|
222
|
+
*
|
|
223
|
+
* The `#` form never occurs in prose, so such a target is certainly a
|
|
224
|
+
* reference, and an undeclared one is an error without consulting the corpus.
|
|
225
|
+
*/
|
|
226
|
+
export const UNDECLARED_SECTION_TARGET_SEVERITY: DiagnosticSeverity = 'error';
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The `data.reason` carried by an undeclared-target diagnostic, which the
|
|
230
|
+
* corpus validator uses to find the warnings it must check against the index.
|
|
231
|
+
*/
|
|
232
|
+
export const UNDECLARED_TARGET_REASON: string = 'undeclared-target';
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The `data.cause` carried by a citation whose candidate is not a complete,
|
|
236
|
+
* conforming token and contains a `#` (1#9.5 rule 1).
|
|
237
|
+
*/
|
|
238
|
+
export const MALFORMED_TARGET_CAUSE: string = 'malformed-target';
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The `data.cause` carried by a citation whose keyword and identifier are not
|
|
242
|
+
* adjacent literal text on one source line (1#9.11 rule 2).
|
|
243
|
+
*/
|
|
244
|
+
export const CITATION_SOURCE_FORM_CAUSE: string = 'citation-source-form';
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Characters that may terminate a candidate identifier (1#9.5 rule 1).
|
|
248
|
+
*
|
|
249
|
+
* Whitespace, the end of the text, and sentence punctuation. A letter is
|
|
250
|
+
* deliberately absent: `see 1#1oops` never wrote an identifier at all.
|
|
251
|
+
*/
|
|
252
|
+
const PERMITTED_TERMINATOR_PUNCTUATION: ReadonlySet<string> = new Set([
|
|
253
|
+
',', ';', ':', ')', ']', '}', '"', "'", '!', '?',
|
|
254
|
+
]);
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Any whitespace, which may also terminate a candidate.
|
|
258
|
+
*
|
|
259
|
+
* Tested as a class rather than listed, so a non-breaking or em space ends an
|
|
260
|
+
* identifier as an ordinary space does. Listing only the ASCII four made
|
|
261
|
+
* `see 1#1` followed by a non-breaking space a malformed-target error, and
|
|
262
|
+
* silently dropped the edge for `see 1`.
|
|
263
|
+
*/
|
|
264
|
+
const WHITESPACE: RegExp = /\s/u;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A word character, for deciding whether a keyword stands at a boundary.
|
|
268
|
+
*
|
|
269
|
+
* This is ripgrep's definition (Rust's `\w`), chosen after measuring both
|
|
270
|
+
* published engines against the same inputs. They do not agree:
|
|
271
|
+
*
|
|
272
|
+
* preceding character ripgrep grep -E (C.UTF-8)
|
|
273
|
+
* space, hyphen match match
|
|
274
|
+
* e, e-acute, digit, _ no no
|
|
275
|
+
* combining acute (U+0301) no MATCH
|
|
276
|
+
* connector (U+203F) no MATCH
|
|
277
|
+
* supplementary (U+10400) no MATCH
|
|
278
|
+
*
|
|
279
|
+
* Where they differ, ripgrep is the stricter: it treats marks, connector
|
|
280
|
+
* punctuation and supplementary letters as word characters. The looser cases
|
|
281
|
+
* are simply not recognised as citations -- no edge, and no diagnostic,
|
|
282
|
+
* because text like `e\u0301see 1#1` is not a citation anyone wrote
|
|
283
|
+
* deliberately.
|
|
284
|
+
*
|
|
285
|
+
* The published recipes no longer use `\b` at all: they bound a citation by
|
|
286
|
+
* ERE recipes open with `(\b|_)`: ripgrep's `\b` is this same definition, so
|
|
287
|
+
* every keyword the linter recognises is found, and `_` is admitted so that
|
|
288
|
+
* `_see 8.1_` -- ordinary emphasis -- is found too. Measured against 46
|
|
289
|
+
* preceding characters in both engines, that form has no case where the
|
|
290
|
+
* linter recognises a keyword and either engine misses it. The PCRE recipe
|
|
291
|
+
* keeps an ASCII lookbehind instead, because PCRE's `\b` counts `\u00b2` and `\u00bd` as
|
|
292
|
+
* word characters and would miss them. Where an engine matches a keyword
|
|
293
|
+
* this set refuses, that is a permitted search hit, not an edge (1#9.11).
|
|
294
|
+
*/
|
|
295
|
+
const WORD_CHARACTER: RegExp = /[\p{Alphabetic}\p{M}\p{Nd}\p{Pc}\p{Join_Control}]/u;
|
|
296
|
+
|
|
297
|
+
/** Characters a candidate identifier may be built from. */
|
|
298
|
+
const CANDIDATE_CHARACTERS: RegExp = /[0-9.#]/;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Characters the published recipes refuse after a citation: an ASCII letter
|
|
302
|
+
* or digit. Anything else -- whitespace, punctuation, `_`, `*`, the `<` of a
|
|
303
|
+
* tag, the `&` of a character reference -- ends it.
|
|
304
|
+
*/
|
|
305
|
+
const RECIPE_EXCLUDED_BOUNDARY: RegExp = /[0-9A-Za-z]/;
|
|
306
|
+
|
|
307
|
+
/** A character that ends a source line, which a recipe's `$` accepts. */
|
|
308
|
+
const LINE_BREAK_CHARACTER: RegExp = /[\n\r]/;
|
|
309
|
+
|
|
310
|
+
/** A `see`/`per` keyword at a word boundary, wherever it occurs. */
|
|
311
|
+
const KEYWORD_PATTERN: RegExp = /([Ss]ee|[Pp]er)/g;
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Internal match type
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Represents a single inline reference match detected within a text node.
|
|
319
|
+
*
|
|
320
|
+
* This is an intermediate type used internally during evaluation. It holds
|
|
321
|
+
* the parsed components of a detected `see`/`per` form before they are
|
|
322
|
+
* validated against the declared References set and assembled into an
|
|
323
|
+
* {@link InlineReferenceEdge}.
|
|
324
|
+
*/
|
|
325
|
+
export interface DetectedInlineReference {
|
|
326
|
+
/**
|
|
327
|
+
* The keyword form that introduced this inline reference,
|
|
328
|
+
* normalised to lowercase.
|
|
329
|
+
*/
|
|
330
|
+
readonly kind: InlineReferenceKind;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The TargetID extracted from the text, maximally matched.
|
|
334
|
+
* A DocID (`8.1`) or a SectionID (`8.1#3.2`).
|
|
335
|
+
*/
|
|
336
|
+
readonly targetId: string;
|
|
337
|
+
|
|
338
|
+
/** The keyword exactly as written, preserving its capitalisation. */
|
|
339
|
+
readonly keyword: string;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The candidate run of identifier characters, before conformance is tested.
|
|
343
|
+
*
|
|
344
|
+
* Held separately from {@link targetId} because a candidate that fails is
|
|
345
|
+
* never shortened into one that passes: `1#1#9` does not become `1#1`.
|
|
346
|
+
*/
|
|
347
|
+
readonly candidate: string;
|
|
348
|
+
|
|
349
|
+
/** Whether the candidate conforms, once a single trailing `.` is allowed for. */
|
|
350
|
+
readonly conforms: boolean;
|
|
351
|
+
|
|
352
|
+
/** The whitespace between the keyword and the candidate, exactly as written. */
|
|
353
|
+
readonly gap: string;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Offset of the keyword's first character within the parsed text node.
|
|
357
|
+
*
|
|
358
|
+
* This is what ties a citation to its own source characters. Every approach
|
|
359
|
+
* that tried to avoid carrying it -- searching for matching text, counting
|
|
360
|
+
* occurrences, comparing lists of targets -- could be defeated by another
|
|
361
|
+
* citation, or by raw text that merely resembled one.
|
|
362
|
+
*/
|
|
363
|
+
readonly index: number;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// Split citations (module-level)
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Inline Markdown formatting that can split a citation, with the words used
|
|
372
|
+
* to describe each in a diagnostic.
|
|
373
|
+
*/
|
|
374
|
+
const WRAPPER_DESCRIPTIONS: ReadonlyMap<string, string> = new Map([
|
|
375
|
+
['link', 'a link'],
|
|
376
|
+
['linkReference', 'a link'],
|
|
377
|
+
['strong', 'bold text'],
|
|
378
|
+
['emphasis', 'italic text'],
|
|
379
|
+
['delete', 'strikethrough text'],
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* How a citation split by something other than formatting is described, by
|
|
384
|
+
* the kind of segment in the way. Where two text segments meet with nothing
|
|
385
|
+
* visible between them, only inline HTML can have separated them.
|
|
386
|
+
*/
|
|
387
|
+
const SPLIT_DESCRIPTIONS: ReadonlyMap<string, string> = new Map([
|
|
388
|
+
['code', 'inline code'],
|
|
389
|
+
['break', 'a line break'],
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
/** What separates inline text segments when no segment is in the way. */
|
|
393
|
+
const TRANSPARENT_SPLIT_DESCRIPTION: string = 'inline HTML';
|
|
394
|
+
|
|
395
|
+
/** A text segment's source, located and aligned on first use. */
|
|
396
|
+
interface AlignedSegment {
|
|
397
|
+
/** The segment's raw source, when it could be located. */
|
|
398
|
+
readonly source: string | undefined;
|
|
399
|
+
/** Parsed-to-source offsets, when alignment succeeded. */
|
|
400
|
+
readonly offsets: readonly number[] | undefined;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** What split a citation, as a diagnostic states it. */
|
|
404
|
+
interface SplitDescription {
|
|
405
|
+
/** Phrase completing `"see 8.1#3" …`, such as `is split by bold text`. */
|
|
406
|
+
readonly because: string;
|
|
407
|
+
/** Data the diagnostic carries in addition, such as the wrapper's type. */
|
|
408
|
+
readonly extra?: Readonly<Record<string, unknown>>;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Rule class
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Detects and validates inline `see` and `per` references in document
|
|
417
|
+
* prose as defined in the ECR specification (1#9.5, 1#10.7).
|
|
418
|
+
*
|
|
419
|
+
* The rule enforces that:
|
|
420
|
+
* - Inline references use the `see TargetID` or `per TargetID` keyword forms
|
|
421
|
+
* - The keyword is preceded by a word boundary (start of string, whitespace,
|
|
422
|
+
* or punctuation) to prevent false positives from words like "oversee"
|
|
423
|
+
* or "hyperparameter"
|
|
424
|
+
* - The keyword's first letter may be capitalised (`See`, `Per`); the
|
|
425
|
+
* `kind` field normalises to lowercase
|
|
426
|
+
* - TargetID conforms to the identifier grammar and is maximally matched
|
|
427
|
+
* - The parent DocID of the TargetID is declared in the References section,
|
|
428
|
+
* or the TargetID is a self-reference to the document's own DocID
|
|
429
|
+
*
|
|
430
|
+
* For each valid inline reference whose parent DocID is declared (or is a
|
|
431
|
+
* self-reference), the rule extracts an {@link InlineReferenceEdge} with
|
|
432
|
+
* `fromId` set to the section context passed per call and `kind` normalised
|
|
433
|
+
* to lowercase.
|
|
434
|
+
*
|
|
435
|
+
* For undeclared references, the rule emits a warning diagnostic and does
|
|
436
|
+
* not extract an edge.
|
|
437
|
+
*
|
|
438
|
+
* Usage:
|
|
439
|
+
* 1. Construct a rule instance with the document URI, established DocID,
|
|
440
|
+
* grammar instance, and the set of declared DocIDs from the References section.
|
|
441
|
+
* 2. Call {@link evaluateTextNode} for every text node encountered during
|
|
442
|
+
* AST traversal (after the visitor has filtered out code/inlineCode/HTML/link
|
|
443
|
+
* URL contexts), passing the current section context.
|
|
444
|
+
* 3. Call {@link finalise} after all text nodes have been evaluated to obtain
|
|
445
|
+
* the complete result.
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```ts
|
|
449
|
+
* const grammar = new IdentifierGrammar();
|
|
450
|
+
* const rule = new InlineReferenceRule({
|
|
451
|
+
* uri: 'file:///doc.md',
|
|
452
|
+
* docId: '5.1',
|
|
453
|
+
* grammar,
|
|
454
|
+
* declaredDocIds: new Set(['3.1', '8.1']),
|
|
455
|
+
* });
|
|
456
|
+
*
|
|
457
|
+
* rule.evaluateTextNode(
|
|
458
|
+
* { text: 'Guardrail logic per 3.1#2 and retry semantics see 8.1.' },
|
|
459
|
+
* '5.1#1',
|
|
460
|
+
* );
|
|
461
|
+
*
|
|
462
|
+
* const result: InlineReferenceRuleResult = rule.finalise();
|
|
463
|
+
* // result.inlineReferences has two edges:
|
|
464
|
+
* // { fromId: '5.1#1', toId: '3.1#2', kind: 'per' }
|
|
465
|
+
* // { fromId: '5.1#1', toId: '8.1', kind: 'see' }
|
|
466
|
+
* ```
|
|
467
|
+
*/
|
|
468
|
+
export class InlineReferenceRule {
|
|
469
|
+
/** The opaque, host-provided URI identifying the document being validated. */
|
|
470
|
+
private readonly uri: string;
|
|
471
|
+
|
|
472
|
+
/** The established DocID for this document, used for self-reference detection. */
|
|
473
|
+
private readonly docId: DocID;
|
|
474
|
+
|
|
475
|
+
/** The grammar instance used for TargetID validation. */
|
|
476
|
+
private readonly grammar: IdentifierGrammar;
|
|
477
|
+
|
|
478
|
+
/** The set of DocIDs declared in the References section. */
|
|
479
|
+
private readonly declaredDocIds: ReadonlySet<DocID>;
|
|
480
|
+
|
|
481
|
+
/** Diagnostics accumulated during evaluation. */
|
|
482
|
+
private readonly collectedDiagnostics: Diagnostic[];
|
|
483
|
+
|
|
484
|
+
/** Inline reference edges extracted from valid forms, in traversal order. */
|
|
485
|
+
private readonly collectedInlineReferences: InlineReferenceEdge[];
|
|
486
|
+
|
|
487
|
+
/** The document's raw source, for the source-form checks of 1#9.11. */
|
|
488
|
+
private readonly sourceText: string;
|
|
489
|
+
|
|
490
|
+
/** Absolute offset at which each source line begins. */
|
|
491
|
+
private readonly lineStarts: readonly number[];
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Constructs a new Inline Reference Rule evaluator.
|
|
495
|
+
*
|
|
496
|
+
* @param options - Configuration including the document URI, established DocID,
|
|
497
|
+
* grammar instance, and declared DocIDs from the References section
|
|
498
|
+
*/
|
|
499
|
+
public constructor(options: InlineReferenceRuleOptions) {
|
|
500
|
+
this.uri = options.uri;
|
|
501
|
+
this.docId = options.docId;
|
|
502
|
+
this.grammar = options.grammar;
|
|
503
|
+
this.declaredDocIds = options.declaredDocIds;
|
|
504
|
+
this.sourceText = options.sourceText;
|
|
505
|
+
this.lineStarts = indexLineStarts(options.sourceText);
|
|
506
|
+
this.collectedDiagnostics = [];
|
|
507
|
+
this.collectedInlineReferences = [];
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Evaluates a single text node for inline `see`/`per` references.
|
|
512
|
+
*
|
|
513
|
+
* Scans the text for all occurrences of `see TargetID` or `per TargetID`
|
|
514
|
+
* where the keyword is preceded by a word boundary. For each detected
|
|
515
|
+
* inline reference:
|
|
516
|
+
*
|
|
517
|
+
* 1. Validates that the TargetID conforms to the identifier grammar
|
|
518
|
+
* 2. Determines the parent DocID of the TargetID
|
|
519
|
+
* 3. Checks whether the parent DocID is declared in the References section
|
|
520
|
+
* or is a self-reference to the document's own DocID
|
|
521
|
+
* 4. If declared or self-referencing, extracts an {@link InlineReferenceEdge}
|
|
522
|
+
* with `fromId` set to the provided `sectionContext`
|
|
523
|
+
* 5. If undeclared, emits a warning diagnostic and does not extract an edge
|
|
524
|
+
*
|
|
525
|
+
* Multiple inline references within a single text node are all independently
|
|
526
|
+
* detected and validated. Duplicate references are not deduplicated (that is
|
|
527
|
+
* a downstream concern).
|
|
528
|
+
*
|
|
529
|
+
* @param textNodeData - Data extracted from a text AST node
|
|
530
|
+
* @param sectionContext - The current heading identifier (DocID or SectionID)
|
|
531
|
+
* at the point where this text node appears in the document.
|
|
532
|
+
* Used as the `fromId` for any extracted edges.
|
|
533
|
+
*/
|
|
534
|
+
public evaluateTextNode(
|
|
535
|
+
textNodeData: TextNodeData,
|
|
536
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
537
|
+
sectionContext: DocID | SectionID,
|
|
538
|
+
): void {
|
|
539
|
+
this.evaluateInlineRun(
|
|
540
|
+
[
|
|
541
|
+
{
|
|
542
|
+
kind: 'text',
|
|
543
|
+
text: textNodeData.text,
|
|
544
|
+
wrappers: [],
|
|
545
|
+
...(textNodeData.range !== undefined ? { range: textNodeData.range } : {}),
|
|
546
|
+
},
|
|
547
|
+
],
|
|
548
|
+
sectionContext,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Evaluates one inline run -- a paragraph, say -- for inline references.
|
|
554
|
+
*
|
|
555
|
+
* Recognition reads the run as a reader sees it, across every node boundary
|
|
556
|
+
* (1#9.5 rule 1). Only then is the source consulted: a citation extracts an
|
|
557
|
+
* edge only when its keyword, space and identifier all lie in one text
|
|
558
|
+
* segment and are literal in the source there (1#9.11 rule 2). One that
|
|
559
|
+
* spans segments -- split by formatting, inline HTML, code or a break -- is
|
|
560
|
+
* reported, never passed over.
|
|
561
|
+
*
|
|
562
|
+
* @param segments - The run's visible text, in order, per {@link InlineSegment}
|
|
563
|
+
* @param sectionContext - Section the run belongs to, used as `fromId`
|
|
564
|
+
*/
|
|
565
|
+
public evaluateInlineRun(
|
|
566
|
+
segments: readonly InlineSegment[],
|
|
567
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
568
|
+
sectionContext: DocID | SectionID,
|
|
569
|
+
): void {
|
|
570
|
+
const visible: string = segments.map((segment: InlineSegment): string => segment.text).join('');
|
|
571
|
+
const owners: readonly number[] = InlineReferenceRule.indexSegmentOwners(segments);
|
|
572
|
+
const starts: readonly number[] = InlineReferenceRule.indexSegmentStarts(segments);
|
|
573
|
+
const aligned: Map<number, AlignedSegment> = new Map<number, AlignedSegment>();
|
|
574
|
+
|
|
575
|
+
for (const detected of this.detectInlineReferences(visible, segments, owners)) {
|
|
576
|
+
const keywordOwner: number = owners[detected.index] ?? 0;
|
|
577
|
+
const keywordSegment: InlineSegment | undefined = segments[keywordOwner];
|
|
578
|
+
const range: PositionRange | undefined = keywordSegment?.range;
|
|
579
|
+
|
|
580
|
+
if (!detected.conforms) {
|
|
581
|
+
this.reportNonConformingCandidate(detected, sectionContext, range);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const end: number =
|
|
586
|
+
detected.index + detected.keyword.length + detected.gap.length + detected.targetId.length;
|
|
587
|
+
const lastOwner: number = owners[end - 1] ?? keywordOwner;
|
|
588
|
+
|
|
589
|
+
if (lastOwner !== keywordOwner || keywordSegment === undefined) {
|
|
590
|
+
const split: SplitDescription = InlineReferenceRule.describeSplit(
|
|
591
|
+
segments,
|
|
592
|
+
keywordOwner,
|
|
593
|
+
lastOwner,
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
this.reportUnnavigableCitation(detected, sectionContext, range, split.because, split.extra);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
let alignment: AlignedSegment | undefined = aligned.get(keywordOwner);
|
|
601
|
+
|
|
602
|
+
if (alignment === undefined) {
|
|
603
|
+
const source: string | undefined = this.sliceSource(keywordSegment.range);
|
|
604
|
+
|
|
605
|
+
alignment = {
|
|
606
|
+
source,
|
|
607
|
+
offsets:
|
|
608
|
+
source === undefined
|
|
609
|
+
? undefined
|
|
610
|
+
: alignParsedToSource(source, keywordSegment.text),
|
|
611
|
+
};
|
|
612
|
+
aligned.set(keywordOwner, alignment);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const local: DetectedInlineReference = {
|
|
616
|
+
...detected,
|
|
617
|
+
index: detected.index - (starts[keywordOwner] ?? 0),
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
if (!this.tellCitationIsLiteral(local, range, alignment.source, alignment.offsets)) {
|
|
621
|
+
this.reportUnnavigableCitation(detected, sectionContext, range);
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (!this.tellTargetIdDeclared(detected.targetId)) {
|
|
626
|
+
this.reportUndeclaredTarget(detected, sectionContext, range);
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
this.collectedInlineReferences.push({
|
|
631
|
+
fromId: sectionContext,
|
|
632
|
+
toId: detected.targetId,
|
|
633
|
+
kind: detected.kind,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Records which segment each character of a run's visible text came from.
|
|
640
|
+
*
|
|
641
|
+
* @param segments - The run's segments
|
|
642
|
+
* @returns For each visible offset, the index of its segment
|
|
643
|
+
*/
|
|
644
|
+
private static indexSegmentOwners(segments: readonly InlineSegment[]): readonly number[] {
|
|
645
|
+
const owners: number[] = [];
|
|
646
|
+
|
|
647
|
+
// One entry per UTF-16 unit, to line up with string offsets; iterating the
|
|
648
|
+
// text with for...of would step by code point instead.
|
|
649
|
+
segments.forEach((segment: InlineSegment, index: number): void => {
|
|
650
|
+
const start: number = owners.length;
|
|
651
|
+
|
|
652
|
+
owners.length = start + segment.text.length;
|
|
653
|
+
owners.fill(index, start);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
return owners;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Records where each segment begins in a run's visible text.
|
|
661
|
+
*
|
|
662
|
+
* @param segments - The run's segments
|
|
663
|
+
* @returns The visible offset of each segment's first character
|
|
664
|
+
*/
|
|
665
|
+
private static indexSegmentStarts(segments: readonly InlineSegment[]): readonly number[] {
|
|
666
|
+
const starts: number[] = [];
|
|
667
|
+
let offset: number = 0;
|
|
668
|
+
|
|
669
|
+
for (const segment of segments) {
|
|
670
|
+
starts.push(offset);
|
|
671
|
+
offset += segment.text.length;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
return starts;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Says what split a citation across segments, for its diagnostic.
|
|
679
|
+
*
|
|
680
|
+
* Formatting is named when it is what differs: `see **8.1#3**` is split by
|
|
681
|
+
* bold text. Otherwise the kind of segment in the way is named, and where
|
|
682
|
+
* two text segments meet with nothing visible between them, only inline
|
|
683
|
+
* HTML can have separated them.
|
|
684
|
+
*
|
|
685
|
+
* @param segments - The run's segments
|
|
686
|
+
* @param first - Index of the segment holding the keyword
|
|
687
|
+
* @param last - Index of the segment holding the identifier's end
|
|
688
|
+
* @returns The phrase for the message, and any data it adds
|
|
689
|
+
*/
|
|
690
|
+
private static describeSplit(
|
|
691
|
+
segments: readonly InlineSegment[],
|
|
692
|
+
first: number,
|
|
693
|
+
last: number,
|
|
694
|
+
): SplitDescription {
|
|
695
|
+
const spanned: readonly InlineSegment[] = segments.slice(first, last + 1);
|
|
696
|
+
const shared: readonly string[] = spanned.reduce(
|
|
697
|
+
(common: readonly string[], segment: InlineSegment): readonly string[] =>
|
|
698
|
+
common.filter((wrapper: string, depth: number): boolean => segment.wrappers[depth] === wrapper),
|
|
699
|
+
spanned[0]?.wrappers ?? [],
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
for (const segment of spanned) {
|
|
703
|
+
const wrapper: string | undefined = segment.wrappers[shared.length];
|
|
704
|
+
const description: string | undefined =
|
|
705
|
+
wrapper === undefined ? undefined : WRAPPER_DESCRIPTIONS.get(wrapper);
|
|
706
|
+
|
|
707
|
+
if (wrapper !== undefined && description !== undefined) {
|
|
708
|
+
return { because: `is split by ${description}`, extra: { wrapper } };
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const obstacle: InlineSegment | undefined = spanned.find(
|
|
713
|
+
(segment: InlineSegment): boolean => segment.kind !== 'text',
|
|
714
|
+
);
|
|
715
|
+
const description: string =
|
|
716
|
+
SPLIT_DESCRIPTIONS.get(obstacle?.kind ?? '') ?? TRANSPARENT_SPLIT_DESCRIPTION;
|
|
717
|
+
|
|
718
|
+
return { because: `is split by ${description}` };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Reports whether this citation is written literally at its own position.
|
|
723
|
+
*
|
|
724
|
+
* Only the keyword, the single space and the identifier are required to be
|
|
725
|
+
* literal. What follows them is not: a citation may be terminated by a
|
|
726
|
+
* character reference (`see 1#1 here`) and remain perfectly findable,
|
|
727
|
+
* because a search stops at the identifier. Applying the parsed token's
|
|
728
|
+
* terminator rules to raw characters rejected exactly those documents.
|
|
729
|
+
*
|
|
730
|
+
* @param detected - The citation under test
|
|
731
|
+
* @param range - The node's range, absent in unit tests that supply bare text
|
|
732
|
+
* @param source - The node's raw source, when it could be located
|
|
733
|
+
* @param offsets - Parsed-to-source offset map, when alignment succeeded
|
|
734
|
+
* @returns `true` when the source carries this citation literally
|
|
735
|
+
*/
|
|
736
|
+
private tellCitationIsLiteral(
|
|
737
|
+
detected: DetectedInlineReference,
|
|
738
|
+
range?: PositionRange,
|
|
739
|
+
source?: string,
|
|
740
|
+
offsets?: readonly number[],
|
|
741
|
+
): boolean {
|
|
742
|
+
if (detected.gap !== ' ') {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// No range means no source to consult: the rule's unit tests supply bare
|
|
747
|
+
// text. That differs from having a range and failing to map it, which is
|
|
748
|
+
// treated as unverifiable below.
|
|
749
|
+
if (range === undefined) {
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (source === undefined || offsets === undefined) {
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const at: number | undefined = offsets[detected.index];
|
|
758
|
+
const nodeStart: number | undefined = this.offsetOf(range.start);
|
|
759
|
+
|
|
760
|
+
if (at === undefined || nodeStart === undefined) {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return (
|
|
765
|
+
source.startsWith(`${detected.keyword} ${detected.targetId}`, at) &&
|
|
766
|
+
this.tellRecipeBoundsCitation(nodeStart + at, detected)
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Reports whether the published recipes accept the characters either side
|
|
772
|
+
* of a citation in the source (1#9.11).
|
|
773
|
+
*
|
|
774
|
+
* Before the keyword, the recipes accept a word boundary or `_`: `_` is a
|
|
775
|
+
* word character to both engines, and `_see 8.1_` is ordinary emphasis.
|
|
776
|
+
* After the identifier they accept anything but an ASCII letter or digit.
|
|
777
|
+
* After a bare DocID a `#` is refused too, and a `.` is allowed only as the
|
|
778
|
+
* end of a sentence -- not before a letter, digit, `.` or `#` -- so that
|
|
779
|
+
* `8.1` is not found inside `8.1#3`, `8.1.2` or `8.1..2`; after a SectionID
|
|
780
|
+
* a `.` is allowed, since the recipe finds a section and those below it.
|
|
781
|
+
*
|
|
782
|
+
* The whole source is read, not the node's slice: a citation at the start
|
|
783
|
+
* of a node is preceded by whatever came before the node.
|
|
784
|
+
*
|
|
785
|
+
* @param start - Absolute source offset of the keyword
|
|
786
|
+
* @param detected - The citation, known to be literal at `start`
|
|
787
|
+
* @returns `true` when the recipe for this target would match here
|
|
788
|
+
*/
|
|
789
|
+
private tellRecipeBoundsCitation(start: number, detected: DetectedInlineReference): boolean {
|
|
790
|
+
// `(\b|_)` before the keyword: `_`, or anything the word-boundary test
|
|
791
|
+
// accepts, which is ripgrep's own definition of `\b`.
|
|
792
|
+
const before: string = start > 0 ? this.sourceText.charAt(start - 1) : '';
|
|
793
|
+
|
|
794
|
+
if (before !== '_' && !this.tellAtWordBoundary(this.sourceText, start)) {
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const end: number = start + detected.keyword.length + 1 + detected.targetId.length;
|
|
799
|
+
const next: string = this.sourceText.charAt(end);
|
|
800
|
+
|
|
801
|
+
if (next === '' || LINE_BREAK_CHARACTER.test(next)) {
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (RECIPE_EXCLUDED_BOUNDARY.test(next) || next === '#') {
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (next !== '.' || detected.targetId.includes('#')) {
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const afterFullStop: string = this.sourceText.charAt(end + 1);
|
|
814
|
+
|
|
815
|
+
return (
|
|
816
|
+
!RECIPE_EXCLUDED_BOUNDARY.test(afterFullStop) &&
|
|
817
|
+
afterFullStop !== '.' &&
|
|
818
|
+
afterFullStop !== '#'
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Reports a candidate that is not a complete, conforming identifier.
|
|
824
|
+
*
|
|
825
|
+
* The `#` decides whether anything is reported at all. Ordinary writing
|
|
826
|
+
* produces numbers after `see` and `per` constantly -- `per 60s`,
|
|
827
|
+
* `see 1..2` -- and never produces `1#1`. So a failed candidate carrying a
|
|
828
|
+
* separator was certainly meant as an identifier and is an error, while one
|
|
829
|
+
* without is prose and is passed over in silence.
|
|
830
|
+
*
|
|
831
|
+
* @param detected - The candidate that failed
|
|
832
|
+
* @param sectionContext - Section the text belongs to
|
|
833
|
+
* @param range - Positional range of the text node
|
|
834
|
+
*/
|
|
835
|
+
private reportNonConformingCandidate(
|
|
836
|
+
detected: DetectedInlineReference,
|
|
837
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
838
|
+
sectionContext: DocID | SectionID,
|
|
839
|
+
range?: PositionRange,
|
|
840
|
+
): void {
|
|
841
|
+
if (!detected.candidate.includes('#')) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
this.collectedDiagnostics.push(
|
|
846
|
+
this.createDiagnostic(
|
|
847
|
+
`"${detected.keyword} ${detected.candidate}" is malformed: ` +
|
|
848
|
+
`"${detected.candidate}" is not a complete identifier. It is not read as a ` +
|
|
849
|
+
`reference to a shorter one.`,
|
|
850
|
+
range,
|
|
851
|
+
{
|
|
852
|
+
cause: MALFORMED_TARGET_CAUSE,
|
|
853
|
+
candidate: detected.candidate,
|
|
854
|
+
fromId: sectionContext,
|
|
855
|
+
kind: detected.kind,
|
|
856
|
+
},
|
|
857
|
+
'error',
|
|
858
|
+
),
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Reports a citation whose source form no search can find (1#9.11 rule 2).
|
|
864
|
+
*
|
|
865
|
+
* Severity follows the same test as an undeclared target: an identifiable
|
|
866
|
+
* target -- a SectionID, a declared DocID, or the document's own -- was
|
|
867
|
+
* certainly meant as a citation, so an unfindable one is an error. A bare
|
|
868
|
+
* number naming nothing known cannot be told from emphasised prose, so it
|
|
869
|
+
* warns, and corpus validation raises it if the document turns out to exist.
|
|
870
|
+
*
|
|
871
|
+
* @param detected - The citation that is not literal in the source
|
|
872
|
+
* @param sectionContext - Section the text belongs to
|
|
873
|
+
* @param range - Positional range of the text node
|
|
874
|
+
* @param split - What split it, when it spans segments, e.g. `is split by bold text`
|
|
875
|
+
* @param extra - Data the diagnostic carries in addition
|
|
876
|
+
*/
|
|
877
|
+
private reportUnnavigableCitation(
|
|
878
|
+
detected: DetectedInlineReference,
|
|
879
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
880
|
+
sectionContext: DocID | SectionID,
|
|
881
|
+
range?: PositionRange,
|
|
882
|
+
split?: string,
|
|
883
|
+
extra?: Readonly<Record<string, unknown>>,
|
|
884
|
+
): void {
|
|
885
|
+
const identifiable: boolean =
|
|
886
|
+
detected.targetId.includes('#') || this.tellTargetIdDeclared(detected.targetId);
|
|
887
|
+
const because: string =
|
|
888
|
+
split === undefined
|
|
889
|
+
? 'is not written as adjacent literal text on one line'
|
|
890
|
+
: `${split} rather than written as adjacent literal text on one line`;
|
|
891
|
+
|
|
892
|
+
this.collectedDiagnostics.push(
|
|
893
|
+
this.createDiagnostic(
|
|
894
|
+
`"${detected.keyword} ${detected.targetId}" ${because}, so no search finds it. ` +
|
|
895
|
+
`Put the keyword and the identifier on one line with a single space between ` +
|
|
896
|
+
`them, and without formatting or escapes.`,
|
|
897
|
+
range,
|
|
898
|
+
{
|
|
899
|
+
cause: CITATION_SOURCE_FORM_CAUSE,
|
|
900
|
+
reason: UNDECLARED_TARGET_REASON,
|
|
901
|
+
targetId: detected.targetId,
|
|
902
|
+
targetDocId: this.showTargetDocId(detected.targetId),
|
|
903
|
+
fromId: sectionContext,
|
|
904
|
+
kind: detected.kind,
|
|
905
|
+
...(extra ?? {}),
|
|
906
|
+
},
|
|
907
|
+
identifiable ? 'error' : INLINE_REFERENCE_DIAGNOSTIC_SEVERITY,
|
|
908
|
+
),
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Reports a conforming, navigable citation whose DocID is not declared.
|
|
914
|
+
*
|
|
915
|
+
* @param detected - The citation
|
|
916
|
+
* @param sectionContext - Section the text belongs to
|
|
917
|
+
* @param range - Positional range of the text node
|
|
918
|
+
*/
|
|
919
|
+
private reportUndeclaredTarget(
|
|
920
|
+
detected: DetectedInlineReference,
|
|
921
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
|
|
922
|
+
sectionContext: DocID | SectionID,
|
|
923
|
+
range?: PositionRange,
|
|
924
|
+
): void {
|
|
925
|
+
const parentDocId: string = this.showTargetDocId(detected.targetId);
|
|
926
|
+
const quoted: string = `"${detected.kind} ${detected.targetId}"`;
|
|
927
|
+
const data: Readonly<Record<string, unknown>> = {
|
|
928
|
+
reason: UNDECLARED_TARGET_REASON,
|
|
929
|
+
targetId: detected.targetId,
|
|
930
|
+
targetDocId: parentDocId,
|
|
931
|
+
fromId: sectionContext,
|
|
932
|
+
kind: detected.kind,
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
const diagnostic: Diagnostic = detected.targetId.includes('#')
|
|
936
|
+
? this.createDiagnostic(
|
|
937
|
+
`${quoted} targets DocID "${parentDocId}", which the References section ` +
|
|
938
|
+
`does not declare. Declare ${parentDocId} in References.`,
|
|
939
|
+
range,
|
|
940
|
+
data,
|
|
941
|
+
UNDECLARED_SECTION_TARGET_SEVERITY,
|
|
942
|
+
)
|
|
943
|
+
: this.createDiagnostic(
|
|
944
|
+
`${quoted} reads as a reference to DocID "${parentDocId}", which the ` +
|
|
945
|
+
`References section does not declare. If it is a reference, declare ` +
|
|
946
|
+
`${parentDocId} in References; if it is ordinary prose, it can be left as it is.`,
|
|
947
|
+
range,
|
|
948
|
+
data,
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
this.collectedDiagnostics.push(diagnostic);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Finalises the rule evaluation and produces the complete result.
|
|
956
|
+
*
|
|
957
|
+
* This method must be called after all text nodes have been supplied
|
|
958
|
+
* via {@link evaluateTextNode}. It returns the accumulated diagnostics
|
|
959
|
+
* and extracted inline reference edges.
|
|
960
|
+
*
|
|
961
|
+
* @returns The complete rule result including all diagnostics and
|
|
962
|
+
* extracted inline reference edges
|
|
963
|
+
*/
|
|
964
|
+
public finalise(): InlineReferenceRuleResult {
|
|
965
|
+
return {
|
|
966
|
+
diagnostics: this.collectedDiagnostics,
|
|
967
|
+
inlineReferences: this.collectedInlineReferences,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// -------------------------------------------------------------------------
|
|
972
|
+
// Private helpers
|
|
973
|
+
// -------------------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* Finds every `see`/`per` citation candidate in a run's visible text.
|
|
977
|
+
*
|
|
978
|
+
* A keyword is recognised where a word boundary precedes it in the text a
|
|
979
|
+
* reader sees, and a candidate where digits follow it after whitespace.
|
|
980
|
+
* Neither is recognised when it starts inside inline code or a break: code
|
|
981
|
+
* may continue a candidate, but an author never cites through it (1#9.5).
|
|
982
|
+
*
|
|
983
|
+
* @param visible - The run's visible text
|
|
984
|
+
* @param segments - The run's segments
|
|
985
|
+
* @param owners - For each visible offset, the index of its segment
|
|
986
|
+
* @returns The candidates, in order of occurrence, indexed into `visible`
|
|
987
|
+
*/
|
|
988
|
+
private detectInlineReferences(
|
|
989
|
+
visible: string,
|
|
990
|
+
segments: readonly InlineSegment[],
|
|
991
|
+
owners: readonly number[],
|
|
992
|
+
): readonly DetectedInlineReference[] {
|
|
993
|
+
const detectedMatches: DetectedInlineReference[] = [];
|
|
994
|
+
const tellIsText = (offset: number): boolean =>
|
|
995
|
+
segments[owners[offset] ?? -1]?.kind === 'text';
|
|
996
|
+
|
|
997
|
+
KEYWORD_PATTERN.lastIndex = 0;
|
|
998
|
+
|
|
999
|
+
let keywordMatch: RegExpExecArray | null = KEYWORD_PATTERN.exec(visible);
|
|
1000
|
+
|
|
1001
|
+
while (keywordMatch !== null) {
|
|
1002
|
+
const keyword: string = keywordMatch[1] ?? '';
|
|
1003
|
+
const index: number = keywordMatch.index;
|
|
1004
|
+
const keywordIsText: boolean = Array.from(keyword, (_: string, offset: number): boolean =>
|
|
1005
|
+
tellIsText(index + offset),
|
|
1006
|
+
).every(Boolean);
|
|
1007
|
+
|
|
1008
|
+
if (keywordIsText && this.tellAtWordBoundary(visible, index)) {
|
|
1009
|
+
const detected: DetectedInlineReference | undefined = this.readCandidate(
|
|
1010
|
+
visible,
|
|
1011
|
+
index,
|
|
1012
|
+
keyword,
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
if (
|
|
1016
|
+
detected !== undefined &&
|
|
1017
|
+
tellIsText(index + keyword.length + detected.gap.length)
|
|
1018
|
+
) {
|
|
1019
|
+
detectedMatches.push(detected);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
keywordMatch = KEYWORD_PATTERN.exec(visible);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
return detectedMatches;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Reads the candidate identifier that follows a keyword, if there is one.
|
|
1031
|
+
*
|
|
1032
|
+
* The candidate is the maximal run of identifier characters, taken whole
|
|
1033
|
+
* before it is tested. Taking it whole is what stops a malformed identifier
|
|
1034
|
+
* decaying into a shorter valid one: `1#1#9` fails, rather than passing as
|
|
1035
|
+
* `1#1`.
|
|
1036
|
+
*
|
|
1037
|
+
* @param text - The run's visible text
|
|
1038
|
+
* @param keywordIndex - Index of the keyword's first character
|
|
1039
|
+
* @param keyword - The keyword exactly as written
|
|
1040
|
+
* @returns The candidate, or `undefined` when no digits follow the keyword
|
|
1041
|
+
*/
|
|
1042
|
+
private readCandidate(
|
|
1043
|
+
text: string,
|
|
1044
|
+
keywordIndex: number,
|
|
1045
|
+
keyword: string,
|
|
1046
|
+
): DetectedInlineReference | undefined {
|
|
1047
|
+
const afterKeyword: number = keywordIndex + keyword.length;
|
|
1048
|
+
let cursor: number = afterKeyword;
|
|
1049
|
+
|
|
1050
|
+
while (cursor < text.length && WHITESPACE.test(text.charAt(cursor))) {
|
|
1051
|
+
cursor += 1;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const gap: string = text.slice(afterKeyword, cursor);
|
|
1055
|
+
|
|
1056
|
+
if (gap.length === 0 || !/[0-9]/.test(text.charAt(cursor))) {
|
|
1057
|
+
return undefined;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const candidateStart: number = cursor;
|
|
1061
|
+
|
|
1062
|
+
while (cursor < text.length && CANDIDATE_CHARACTERS.test(text.charAt(cursor))) {
|
|
1063
|
+
cursor += 1;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const candidate: string = text.slice(candidateStart, cursor);
|
|
1067
|
+
const terminator: string | undefined =
|
|
1068
|
+
cursor < text.length ? text.charAt(cursor) : undefined;
|
|
1069
|
+
|
|
1070
|
+
const terminated: boolean =
|
|
1071
|
+
terminator === undefined ||
|
|
1072
|
+
WHITESPACE.test(terminator) ||
|
|
1073
|
+
PERMITTED_TERMINATOR_PUNCTUATION.has(terminator);
|
|
1074
|
+
|
|
1075
|
+
const targetId: string = candidate.endsWith('.') ? candidate.slice(0, -1) : candidate;
|
|
1076
|
+
const conforms: boolean =
|
|
1077
|
+
terminated &&
|
|
1078
|
+
(this.grammar.parseDocId(targetId).valid || this.grammar.parseSectionId(targetId).valid);
|
|
1079
|
+
|
|
1080
|
+
return {
|
|
1081
|
+
kind: keyword.toLowerCase() as InlineReferenceKind,
|
|
1082
|
+
keyword,
|
|
1083
|
+
candidate,
|
|
1084
|
+
targetId,
|
|
1085
|
+
conforms,
|
|
1086
|
+
gap,
|
|
1087
|
+
index: keywordIndex,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Reports whether a keyword at `index` stands at a word boundary.
|
|
1093
|
+
*
|
|
1094
|
+
* Reads whole code points: `charAt` would return half of a supplementary
|
|
1095
|
+
* character and misjudge it.
|
|
1096
|
+
*
|
|
1097
|
+
* @param text - The text being scanned
|
|
1098
|
+
* @param index - Index of the keyword's first character
|
|
1099
|
+
* @returns `true` when nothing word-like immediately precedes the keyword
|
|
1100
|
+
*/
|
|
1101
|
+
private tellAtWordBoundary(text: string, index: number): boolean {
|
|
1102
|
+
if (index === 0) {
|
|
1103
|
+
return true;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const before: number | undefined = text.codePointAt(index - 1);
|
|
1107
|
+
|
|
1108
|
+
if (before === undefined) {
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// A low surrogate here means the preceding character is supplementary;
|
|
1113
|
+
// step back one more unit to read the whole code point.
|
|
1114
|
+
const isLowSurrogate: boolean = before >= 0xdc00 && before <= 0xdfff;
|
|
1115
|
+
const codePoint: number | undefined = isLowSurrogate
|
|
1116
|
+
? text.codePointAt(index - 2)
|
|
1117
|
+
: before;
|
|
1118
|
+
|
|
1119
|
+
if (codePoint === undefined) {
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
return !WORD_CHARACTER.test(String.fromCodePoint(codePoint));
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Returns the raw source belonging to a node, when its range is known.
|
|
1129
|
+
*
|
|
1130
|
+
* @param range - The node's positional range
|
|
1131
|
+
* @returns The source slice, or `undefined` when no range was supplied
|
|
1132
|
+
*/
|
|
1133
|
+
private sliceSource(range?: PositionRange): string | undefined {
|
|
1134
|
+
if (range === undefined) {
|
|
1135
|
+
return undefined;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const startOffset: number | undefined = this.offsetOf(range.start);
|
|
1139
|
+
const endOffset: number | undefined = this.offsetOf(range.end);
|
|
1140
|
+
|
|
1141
|
+
if (startOffset === undefined || endOffset === undefined) {
|
|
1142
|
+
return undefined;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return this.sourceText.slice(startOffset, endOffset);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Converts a line/character position into an absolute source offset.
|
|
1150
|
+
*
|
|
1151
|
+
* @param position - A position within the document
|
|
1152
|
+
* @returns The offset, or `undefined` when the line is out of range
|
|
1153
|
+
*/
|
|
1154
|
+
private offsetOf(position: Position): number | undefined {
|
|
1155
|
+
const lineStart: number | undefined = this.lineStarts[position.line];
|
|
1156
|
+
|
|
1157
|
+
return lineStart === undefined ? undefined : lineStart + position.character;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* Determines whether a TargetID's parent DocID is declared in the
|
|
1162
|
+
* References section or is a self-reference to the document's own DocID.
|
|
1163
|
+
*
|
|
1164
|
+
* The target's DocID is read directly — the TargetID itself, or the text
|
|
1165
|
+
* before its `#` — and compared exactly with the document's own DocID and
|
|
1166
|
+
* the declared set. No prefix matching is involved, so `8.1.3` is not
|
|
1167
|
+
* covered by a declaration of `8.1`.
|
|
1168
|
+
*
|
|
1169
|
+
* @param targetId - The TargetID to check
|
|
1170
|
+
* @returns `true` if the TargetID is declared or is a self-reference,
|
|
1171
|
+
* `false` if it is an undeclared reference
|
|
1172
|
+
*/
|
|
1173
|
+
private tellTargetIdDeclared(targetId: string): boolean {
|
|
1174
|
+
const targetDocId: string = this.showTargetDocId(targetId);
|
|
1175
|
+
|
|
1176
|
+
// A reference into this document's own sections needs no declaration.
|
|
1177
|
+
if (targetDocId === this.docId) {
|
|
1178
|
+
return true;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return this.declaredDocIds.has(targetDocId);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Extracts the DocID a TargetID refers to.
|
|
1186
|
+
*
|
|
1187
|
+
* This needs no inference: everything before the `#` separator is the
|
|
1188
|
+
* DocID, and an identifier with no separator is itself a DocID. The
|
|
1189
|
+
* separator removes the ambiguity a dotted-only grammar would have to
|
|
1190
|
+
* resolve by guessing.
|
|
1191
|
+
*
|
|
1192
|
+
* @param targetId - A DocID or SectionID as written in an inline reference
|
|
1193
|
+
* @returns The DocID portion of the identifier
|
|
1194
|
+
*/
|
|
1195
|
+
private showTargetDocId(targetId: string): string {
|
|
1196
|
+
const separatorIndex: number = targetId.indexOf('#');
|
|
1197
|
+
|
|
1198
|
+
if (separatorIndex < 0) {
|
|
1199
|
+
return targetId;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
return targetId.substring(0, separatorIndex);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Creates a diagnostic object for the Inline Reference Rule.
|
|
1208
|
+
*
|
|
1209
|
+
* All diagnostics share the same rule ID ({@link INLINE_REFERENCE_RULE_ID})
|
|
1210
|
+
* and document URI.
|
|
1211
|
+
*
|
|
1212
|
+
* @param message - Human-readable description of the issue
|
|
1213
|
+
* @param range - Optional positional range within the source document
|
|
1214
|
+
* @param data - Optional structured detail
|
|
1215
|
+
* @param severity - Defaults to {@link INLINE_REFERENCE_DIAGNOSTIC_SEVERITY}
|
|
1216
|
+
* @returns A fully populated diagnostic object
|
|
1217
|
+
*/
|
|
1218
|
+
private createDiagnostic(
|
|
1219
|
+
message: string,
|
|
1220
|
+
range?: PositionRange,
|
|
1221
|
+
data?: Readonly<Record<string, unknown>>,
|
|
1222
|
+
severity: DiagnosticSeverity = INLINE_REFERENCE_DIAGNOSTIC_SEVERITY,
|
|
1223
|
+
): Diagnostic {
|
|
1224
|
+
const diagnostic: Diagnostic = {
|
|
1225
|
+
ruleId: INLINE_REFERENCE_RULE_ID,
|
|
1226
|
+
severity,
|
|
1227
|
+
message,
|
|
1228
|
+
uri: this.uri,
|
|
1229
|
+
...(range !== undefined ? { range } : {}),
|
|
1230
|
+
...(data !== undefined ? { data } : {}),
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
return diagnostic;
|
|
1234
|
+
}
|
|
1235
|
+
}
|