@pretextbook/markdown-style-pretext 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,514 @@
1
+ // A lightweight, single-pass scanner over Markdown-style PreTeXt source.
2
+ //
3
+ // Like the LaTeX flavor's scanner, it answers local questions the completion
4
+ // engine and linter need cheaply and without a full remark parse: at a given
5
+ // offset, am I in math / a fenced code block / an HTML comment / frontmatter,
6
+ // and which directives are open? It also harvests every id (`{#id}`, `id=`,
7
+ // `label=`) for cross-reference completion.
8
+ //
9
+ // It recognizes both directive syntaxes the converter accepts, mirroring the
10
+ // pipeline `indentation → colons → directive-normalize` in
11
+ // packages/remark-pretext/src/lib/remark-pretext.ts:
12
+ // - COLON FENCES (`:::name` … `:::`) whose open/close colon counts must match
13
+ // to pair (directive-normalizer.ts:122); a non-matching close is an orphan.
14
+ // - PYTHON-STYLE markers (`Name:` with an indented body), which open a
15
+ // directive whose body is the deeper-indented block and which close
16
+ // implicitly when a later line outdents (indentation-normalizer.ts). We
17
+ // synthesize `close` occurrences at those dedent points.
18
+ //
19
+ // This dialect disables indented code blocks (remark-pretext.ts:108), so
20
+ // indentation is only ever python-directive structure, never code.
21
+ //
22
+ // The scanner is line-oriented for block constructs but scans characters within
23
+ // a line for math delimiters and HTML comments, carrying math/comment state
24
+ // across line boundaries. It never throws.
25
+
26
+ import { isKnownContainerDirective } from "../data/directives";
27
+
28
+ export interface Region {
29
+ /** Inclusive start offset. */
30
+ start: number;
31
+ /** Exclusive end offset. */
32
+ end: number;
33
+ }
34
+
35
+ export interface LabelInfo {
36
+ name: string;
37
+ /** Offset of the first character of the id. */
38
+ offset: number;
39
+ }
40
+
41
+ export interface DirectiveOccurrence {
42
+ /** Directive name; empty string for a bare closing fence (`:::`). */
43
+ name: string;
44
+ type: "open" | "close" | "leaf";
45
+ /** How the directive was written. */
46
+ style: "fence" | "python";
47
+ /**
48
+ * Number of leading colons on the marker: 3+ for container fences, exactly 2
49
+ * for leaf directives. Synthesized python markers carry 0 (they pair by the
50
+ * indentation stack, not by colon count).
51
+ */
52
+ colons: number;
53
+ /** Offset of the marker start (first colon, or the name for python markers). */
54
+ start: number;
55
+ /** Offset just past the marker (or the dedent line start, for python closes). */
56
+ end: number;
57
+ }
58
+
59
+ export interface DocumentScan {
60
+ labels: LabelInfo[];
61
+ /** Math regions delimited by `$`, `$$`, `\(…\)`, `\[…\]`. */
62
+ mathRegions: Region[];
63
+ /** `<!-- … -->` comment regions. */
64
+ commentRegions: Region[];
65
+ /** Fenced code blocks (` ``` ` / `~~~`), including the fence lines. */
66
+ codeRegions: Region[];
67
+ /** Leading `---` … `---` YAML frontmatter, if present. */
68
+ frontmatterRegion: Region | null;
69
+ /** Directive markers in source order (synthesized python closes included). */
70
+ directives: DirectiveOccurrence[];
71
+ }
72
+
73
+ export interface ScanContext {
74
+ mode: "text" | "math";
75
+ inComment: boolean;
76
+ inCode: boolean;
77
+ inFrontmatter: boolean;
78
+ /** Open container directives, innermost last. */
79
+ directiveStack: string[];
80
+ /** Innermost open container directive, if any. */
81
+ currentDirective?: string;
82
+ }
83
+
84
+ const CONTAINER_FENCE_RE = /^(:{3,})\s*([A-Za-z][\w-]*)?/;
85
+ const LEAF_RE = /^::([A-Za-z][\w-]*)/;
86
+ const CODE_FENCE_RE = /^(`{3,}|~{3,})/;
87
+ // A python-style marker body (the line without its trailing colon): a directive
88
+ // name followed only by `[title]` / `{attrs}` groups (indentation-normalizer.ts).
89
+ const PYTHON_MARKER_RE =
90
+ /^([A-Za-z][A-Za-z0-9_-]*)((?:\[[^\]]*\]|\{[^}]*\})*)$/;
91
+ const ID_SHORTHAND_RE = /\{#([\w:.-]+)\}/g;
92
+ const ID_ATTR_RE = /(?:xml:id|id|label)\s*=\s*["']?([\w:.-]+)/g;
93
+ const FRONTMATTER_ID_RE = /^(?:xmlid|xml:id|id|label)\s*:\s*["']?([\w:.-]+)/;
94
+
95
+ interface LineInfo {
96
+ text: string;
97
+ start: number;
98
+ }
99
+
100
+ /** Indentation width in columns, counting a tab as 4 (indentation-normalizer.ts). */
101
+ function indentWidth(line: string): number {
102
+ let width = 0;
103
+ for (const ch of line) {
104
+ if (ch === " ") width += 1;
105
+ else if (ch === "\t") width += 4;
106
+ else break;
107
+ }
108
+ return width;
109
+ }
110
+
111
+ /** Number of leading-whitespace *characters* (for offset math). */
112
+ function indentChars(line: string): number {
113
+ return line.length - line.trimStart().length;
114
+ }
115
+
116
+ /**
117
+ * If `trimmed` is a python-style directive marker (`Name[...]{...}:` with a
118
+ * name the converter supports), return the canonical lowercase name; else null.
119
+ * Mirrors `parseIndentationDirective` in indentation-normalizer.ts.
120
+ */
121
+ function pythonDirectiveName(trimmed: string): string | null {
122
+ if (!trimmed.endsWith(":")) return null;
123
+ const body = trimmed.slice(0, -1);
124
+ const m = PYTHON_MARKER_RE.exec(body);
125
+ if (!m) return null;
126
+ const name = m[1].toLowerCase();
127
+ return isKnownContainerDirective(name) ? name : null;
128
+ }
129
+
130
+ /** True if the next non-blank line after `i` is indented deeper than `lines[i]`. */
131
+ function hasIndentedBody(lines: LineInfo[], i: number): boolean {
132
+ const current = indentWidth(lines[i].text);
133
+ for (let j = i + 1; j < lines.length; j++) {
134
+ if (lines[j].text.trim() === "") continue;
135
+ return indentWidth(lines[j].text) > current;
136
+ }
137
+ return false;
138
+ }
139
+
140
+ export function scanDocument(text: string): DocumentScan {
141
+ const labels: LabelInfo[] = [];
142
+ const mathRegions: Region[] = [];
143
+ const commentRegions: Region[] = [];
144
+ const codeRegions: Region[] = [];
145
+ const directives: DirectiveOccurrence[] = [];
146
+
147
+ const n = text.length;
148
+
149
+ // Cross-line inline state.
150
+ let mathDelim: string | null = null;
151
+ let mathStart = 0;
152
+ let inComment = false;
153
+ let commentStart = 0;
154
+
155
+ // Fenced-code state.
156
+ let fenceMarker: string | null = null; // e.g. "```" or "~~~~"
157
+ let fenceStart = 0;
158
+
159
+ // Open python-style directives, innermost last (they close by dedent).
160
+ const pyStack: { name: string; indent: number }[] = [];
161
+
162
+ // --- frontmatter (leading `---` … `---`) --------------------------------
163
+ let frontmatterRegion: Region | null = null;
164
+ let bodyStart = 0;
165
+ if (text.startsWith("---\n") || text === "---") {
166
+ const closeIdx = findFrontmatterClose(text);
167
+ if (closeIdx !== -1) {
168
+ frontmatterRegion = { start: 0, end: closeIdx };
169
+ bodyStart = closeIdx;
170
+ harvestFrontmatterIds(text.slice(0, closeIdx), labels);
171
+ }
172
+ }
173
+
174
+ const lines = splitLines(text, bodyStart);
175
+
176
+ /** Emit python closes for every open marker at indent >= `indent`. */
177
+ const closePythonTo = (indent: number, at: number) => {
178
+ while (pyStack.length > 0 && pyStack[pyStack.length - 1].indent >= indent) {
179
+ pyStack.pop();
180
+ directives.push({
181
+ name: "",
182
+ type: "close",
183
+ style: "python",
184
+ colons: 0,
185
+ start: at,
186
+ end: at,
187
+ });
188
+ }
189
+ };
190
+
191
+ /** Scan the characters of one line for math delimiters and HTML comments. */
192
+ const scanInline = (line: string, lineStart: number, from: number) => {
193
+ let i = from;
194
+ while (i < line.length) {
195
+ const c = line[i];
196
+
197
+ if (c === "\\") {
198
+ const next = line[i + 1];
199
+ if (next === "(" || next === "[") {
200
+ if (mathDelim === null) {
201
+ mathDelim = next === "(" ? "\\)" : "\\]";
202
+ mathStart = lineStart + i;
203
+ }
204
+ i += 2;
205
+ continue;
206
+ }
207
+ if (next === ")" || next === "]") {
208
+ const token = "\\" + next;
209
+ if (mathDelim === token) {
210
+ mathRegions.push({ start: mathStart, end: lineStart + i + 2 });
211
+ mathDelim = null;
212
+ }
213
+ i += 2;
214
+ continue;
215
+ }
216
+ // Any other escape (`\$`, `\_`, `\*`, `\\`): consume both chars.
217
+ i += 2;
218
+ continue;
219
+ }
220
+
221
+ if (c === "$") {
222
+ const isDouble = line[i + 1] === "$";
223
+ const token = isDouble ? "$$" : "$";
224
+ const width = isDouble ? 2 : 1;
225
+ if (mathDelim === null) {
226
+ mathDelim = token;
227
+ mathStart = lineStart + i;
228
+ } else if (mathDelim === token) {
229
+ mathRegions.push({ start: mathStart, end: lineStart + i + width });
230
+ mathDelim = null;
231
+ }
232
+ i += width;
233
+ continue;
234
+ }
235
+
236
+ // HTML comment start (only meaningful outside math).
237
+ if (mathDelim === null && line.startsWith("<!--", i)) {
238
+ const close = line.indexOf("-->", i + 4);
239
+ if (close !== -1) {
240
+ commentRegions.push({
241
+ start: lineStart + i,
242
+ end: lineStart + close + 3,
243
+ });
244
+ i = close + 3;
245
+ continue;
246
+ }
247
+ // Unterminated on this line: comment runs into following lines.
248
+ inComment = true;
249
+ commentStart = lineStart + i;
250
+ return;
251
+ }
252
+
253
+ i++;
254
+ }
255
+ };
256
+
257
+ // --- main line loop ------------------------------------------------------
258
+ for (let li = 0; li < lines.length; li++) {
259
+ const { text: line, start: lineStart } = lines[li];
260
+ const lineEnd = lineStart + line.length;
261
+
262
+ // 1. Inside a fenced code block: everything is literal until the close.
263
+ if (fenceMarker !== null) {
264
+ if (isClosingCodeFence(line, fenceMarker)) {
265
+ codeRegions.push({ start: fenceStart, end: lineEnd });
266
+ fenceMarker = null;
267
+ }
268
+ continue;
269
+ }
270
+
271
+ // 2. Inside a multi-line HTML comment: look for its close.
272
+ if (inComment) {
273
+ const close = line.indexOf("-->");
274
+ if (close !== -1) {
275
+ commentRegions.push({
276
+ start: commentStart,
277
+ end: lineStart + close + 3,
278
+ });
279
+ inComment = false;
280
+ scanInline(line, lineStart, close + 3);
281
+ }
282
+ continue;
283
+ }
284
+
285
+ // 3. Block-level constructs are only recognized at "text" line starts:
286
+ // a `:::` inside an open `$$…$$` block must not read as a directive.
287
+ if (mathDelim === null) {
288
+ const wsChars = indentChars(line);
289
+ const trimmed = line.slice(wsChars);
290
+
291
+ // Code fences toggle without running python dedent (indentation-
292
+ // normalizer.ts short-circuits on fence lines).
293
+ const codeFence = CODE_FENCE_RE.exec(trimmed);
294
+ if (codeFence) {
295
+ fenceMarker = codeFence[1];
296
+ fenceStart = lineStart;
297
+ continue;
298
+ }
299
+
300
+ // Blank lines never dedent a python directive.
301
+ if (trimmed === "") continue;
302
+
303
+ // Any non-blank content at this indent closes deeper python directives
304
+ // before it is interpreted (indentation-normalizer.ts:256).
305
+ const indent = indentWidth(line);
306
+ closePythonTo(indent, lineStart);
307
+
308
+ const markerStart = lineStart + wsChars;
309
+
310
+ const container = CONTAINER_FENCE_RE.exec(trimmed);
311
+ if (container) {
312
+ const colons = container[1].length;
313
+ const name = container[2];
314
+ if (name) {
315
+ directives.push({
316
+ name,
317
+ type: "open",
318
+ style: "fence",
319
+ colons,
320
+ start: markerStart,
321
+ // container[0] spans the colons, any spacing, and the name.
322
+ end: markerStart + container[0].length,
323
+ });
324
+ } else {
325
+ directives.push({
326
+ name: "",
327
+ type: "close",
328
+ style: "fence",
329
+ colons,
330
+ start: markerStart,
331
+ end: markerStart + colons,
332
+ });
333
+ }
334
+ harvestLineIds(line, lineStart, labels);
335
+ continue;
336
+ }
337
+
338
+ const leaf = LEAF_RE.exec(trimmed);
339
+ if (leaf && trimmed[2] !== ":") {
340
+ directives.push({
341
+ name: leaf[1],
342
+ type: "leaf",
343
+ style: "fence",
344
+ colons: 2,
345
+ start: markerStart,
346
+ end: markerStart + 2 + leaf[1].length,
347
+ });
348
+ harvestLineIds(line, lineStart, labels);
349
+ continue;
350
+ }
351
+
352
+ // Python-style marker (`Name:` with an indented body).
353
+ const pyName = pythonDirectiveName(trimmed);
354
+ if (pyName && hasIndentedBody(lines, li)) {
355
+ directives.push({
356
+ name: pyName,
357
+ type: "open",
358
+ style: "python",
359
+ colons: 0,
360
+ start: markerStart,
361
+ end: markerStart + trimmed.length,
362
+ });
363
+ pyStack.push({ name: pyName, indent });
364
+ harvestLineIds(line, lineStart, labels);
365
+ continue;
366
+ }
367
+
368
+ harvestLineIds(line, lineStart, labels);
369
+ }
370
+
371
+ // 4. Inline scan for math and comments (also continues open math).
372
+ scanInline(line, lineStart, 0);
373
+ }
374
+
375
+ // Unterminated regions run to end of document; open python directives close.
376
+ closePythonTo(0, n);
377
+ if (mathDelim !== null) mathRegions.push({ start: mathStart, end: n });
378
+ if (inComment) commentRegions.push({ start: commentStart, end: n });
379
+ if (fenceMarker !== null) codeRegions.push({ start: fenceStart, end: n });
380
+
381
+ return {
382
+ labels,
383
+ mathRegions,
384
+ commentRegions,
385
+ codeRegions,
386
+ frontmatterRegion,
387
+ directives,
388
+ };
389
+ }
390
+
391
+ /** Split `text` from `from` into lines with their start offsets. */
392
+ function splitLines(text: string, from: number): LineInfo[] {
393
+ const lines: LineInfo[] = [];
394
+ let idx = from;
395
+ const n = text.length;
396
+ while (idx <= n) {
397
+ const nl = text.indexOf("\n", idx);
398
+ const end = nl === -1 ? n : nl;
399
+ lines.push({ text: text.slice(idx, end), start: idx });
400
+ if (nl === -1) break;
401
+ idx = nl + 1;
402
+ }
403
+ return lines;
404
+ }
405
+
406
+ function findFrontmatterClose(text: string): number {
407
+ // Find the next line that is exactly `---` after the opening line.
408
+ let idx = text.indexOf("\n") + 1;
409
+ if (idx === 0) return -1;
410
+ while (idx <= text.length) {
411
+ const nl = text.indexOf("\n", idx);
412
+ const end = nl === -1 ? text.length : nl;
413
+ if (text.slice(idx, end).trim() === "---") return end;
414
+ if (nl === -1) break;
415
+ idx = nl + 1;
416
+ }
417
+ return -1;
418
+ }
419
+
420
+ function harvestFrontmatterIds(block: string, labels: LabelInfo[]): void {
421
+ let idx = 0;
422
+ while (idx <= block.length) {
423
+ const nl = block.indexOf("\n", idx);
424
+ const end = nl === -1 ? block.length : nl;
425
+ const line = block.slice(idx, end);
426
+ const m = FRONTMATTER_ID_RE.exec(line.trim());
427
+ if (m) {
428
+ const at = line.indexOf(m[1], line.indexOf(":"));
429
+ labels.push({ name: m[1], offset: idx + Math.max(at, 0) });
430
+ }
431
+ if (nl === -1) break;
432
+ idx = nl + 1;
433
+ }
434
+ }
435
+
436
+ function harvestLineIds(
437
+ line: string,
438
+ lineStart: number,
439
+ labels: LabelInfo[],
440
+ ): void {
441
+ for (const re of [ID_SHORTHAND_RE, ID_ATTR_RE]) {
442
+ re.lastIndex = 0;
443
+ let m: RegExpExecArray | null;
444
+ while ((m = re.exec(line)) !== null) {
445
+ const at = line.indexOf(m[1], m.index);
446
+ labels.push({ name: m[1], offset: lineStart + at });
447
+ }
448
+ }
449
+ }
450
+
451
+ /** True if `line` is a closing fence for an open ` ``` `/`~~~` block. */
452
+ function isClosingCodeFence(line: string, marker: string): boolean {
453
+ const trimmed = line.trim();
454
+ const ch = marker[0];
455
+ if (trimmed.length < marker.length) return false;
456
+ for (const c of trimmed) if (c !== ch) return false;
457
+ return true;
458
+ }
459
+
460
+ function offsetInRegions(regions: Region[], offset: number): boolean {
461
+ for (const r of regions) {
462
+ if (offset >= r.start && offset < r.end) return true;
463
+ if (r.start > offset) break;
464
+ }
465
+ return false;
466
+ }
467
+
468
+ /** Reconstruct the open-directive stack and mode at a given offset. */
469
+ export function contextAt(scan: DocumentScan, offset: number): ScanContext {
470
+ const stack: { name: string; colons: number }[] = [];
471
+ for (const occ of scan.directives) {
472
+ // Only apply markers that fully precede the cursor. A marker whose span
473
+ // reaches the cursor is the line currently being typed (e.g. `:::pro|`) and
474
+ // is not yet "open" — excluding it keeps child-directive boosting anchored
475
+ // to the *enclosing* directive.
476
+ if (occ.end >= offset) break;
477
+ if (occ.type === "open") {
478
+ stack.push({ name: occ.name, colons: occ.colons });
479
+ } else if (occ.type === "close") {
480
+ const top = stack[stack.length - 1];
481
+ // Python closes are implicit and always pop; a colon fence only pairs
482
+ // when its colon count matches the innermost open (directive-normalizer).
483
+ if (occ.style === "python") {
484
+ stack.pop();
485
+ } else if (top && top.colons === occ.colons) {
486
+ stack.pop();
487
+ }
488
+ // A non-matching colon fence is an orphan: leave the stack unchanged.
489
+ }
490
+ // Leaf directives are self-closing: no stack effect.
491
+ }
492
+
493
+ const inCode = offsetInRegions(scan.codeRegions, offset);
494
+ // A comment includes its trailing edge (cursor at end-of-comment counts).
495
+ const inComment = scan.commentRegions.some(
496
+ (r) => offset > r.start && offset <= r.end,
497
+ );
498
+ const inFrontmatter = scan.frontmatterRegion
499
+ ? offset >= scan.frontmatterRegion.start &&
500
+ offset < scan.frontmatterRegion.end
501
+ : false;
502
+ const inMath = offsetInRegions(scan.mathRegions, offset);
503
+ const mode: "text" | "math" =
504
+ inMath && !inCode && !inComment && !inFrontmatter ? "math" : "text";
505
+
506
+ return {
507
+ mode,
508
+ inComment,
509
+ inCode,
510
+ inFrontmatter,
511
+ directiveStack: stack.map((s) => s.name),
512
+ currentDirective: stack[stack.length - 1]?.name,
513
+ };
514
+ }
package/src/types.ts ADDED
@@ -0,0 +1,75 @@
1
+ // The host-facing contract is shared with the LaTeX flavor: both packages
2
+ // implement the *same* `PretextFlavorLanguage`, so the VS Code LSP server and
3
+ // the Monaco adapter register a second entry with no other changes. We import
4
+ // (not redefine) that interface to guarantee the two flavors can never drift.
5
+ import type {
6
+ PretextFlavorLanguage,
7
+ GetCompletionsParams,
8
+ } from "@pretextbook/latex-style-pretext";
9
+
10
+ export type { PretextFlavorLanguage, GetCompletionsParams };
11
+
12
+ /**
13
+ * The three remark-directive shapes this flavor understands:
14
+ * - `container` — `:::name` … `:::` (block), the workhorse (theorem, exercise…)
15
+ * - `leaf` — `::name{…}` (a self-closing block; the PreTeXt Plus include
16
+ * syntax, so *any* name is accepted and none is ever "unknown")
17
+ * - `text` — `:name[…]{…}` (inline). The converter has no `textDirective`
18
+ * handler yet, so these are recognized by the scanner but neither completed
19
+ * nor linted (see the note in ./data/directives.ts).
20
+ */
21
+ export type DirectiveKind = "container" | "leaf" | "text";
22
+
23
+ /**
24
+ * Routing category, mirrored from `remark-pretext`'s `DirectiveCategory`. Kept
25
+ * as a plain string union rather than imported so this package has no runtime
26
+ * dependency on the converter (only a dev-time drift-guard test does).
27
+ */
28
+ export type DirectiveCategory =
29
+ | "theorem-like"
30
+ | "proof-like"
31
+ | "definition-like"
32
+ | "axiom-like"
33
+ | "remark-like"
34
+ | "example-like"
35
+ | "project-like"
36
+ | "exercise-like"
37
+ | "solution-like";
38
+
39
+ /**
40
+ * Curated specification for a container directive. The direct analogue of the
41
+ * LaTeX `EnvironmentSpec`: `requiresStatement` carries the same meaning (the
42
+ * converter wraps the body in `<statement>` and hoists proof/solution siblings).
43
+ *
44
+ * Mirror of `DirectiveSpec` in
45
+ * packages/remark-pretext/src/lib/directive-map.ts
46
+ * (the drift-guard test asserts every entry here still converts cleanly).
47
+ */
48
+ export interface DirectiveSpec {
49
+ /** Directive name as typed after the colons (usually the PreTeXt element). */
50
+ name: string;
51
+ /** The PreTeXt element the directive converts to. */
52
+ type: string;
53
+ category: DirectiveCategory;
54
+ kind: DirectiveKind;
55
+ /**
56
+ * Converter wraps the body in `<statement>` and lifts proof/solution to
57
+ * siblings of it. Drives the generated snippet skeleton.
58
+ */
59
+ requiresStatement: boolean;
60
+ /** Whether the directive may contain nested `:::task` children. */
61
+ hasNestedTasks?: boolean;
62
+ /**
63
+ * Directives meaningful directly inside this one (e.g. `proof` inside a
64
+ * theorem; `hint`/`answer`/`solution` inside an exercise). Boosted in
65
+ * completion sort order when the cursor is inside the parent.
66
+ */
67
+ childDirectives?: string[];
68
+ /**
69
+ * Override the auto-generated snippet body (LSP snippet syntax). The
70
+ * generator always supplies the `name` line and the closing `:::` fence, so
71
+ * this is the inner body only.
72
+ */
73
+ snippet?: string;
74
+ documentation?: string;
75
+ }
@@ -0,0 +1,30 @@
1
+ import type { Position, Range } from "vscode-languageserver-types";
2
+
3
+ /**
4
+ * Convert a zero-based character offset into an LSP `Position` (line/character).
5
+ * Both hosts (the LSP server and Monaco) speak line/character, not offsets, so
6
+ * completion `textEdit`s and diagnostics ranges are built from these.
7
+ */
8
+ export function offsetToPosition(text: string, offset: number): Position {
9
+ const clamped = Math.max(0, Math.min(offset, text.length));
10
+ let line = 0;
11
+ let lineStart = 0;
12
+ for (let i = 0; i < clamped; i++) {
13
+ if (text[i] === "\n") {
14
+ line++;
15
+ lineStart = i + 1;
16
+ }
17
+ }
18
+ return { line, character: clamped - lineStart };
19
+ }
20
+
21
+ export function rangeFromOffsets(
22
+ text: string,
23
+ start: number,
24
+ end: number,
25
+ ): Range {
26
+ return {
27
+ start: offsetToPosition(text, start),
28
+ end: offsetToPosition(text, end),
29
+ };
30
+ }