@effected/source 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/LineIndex.js ADDED
@@ -0,0 +1,258 @@
1
+ // src/LineIndex.ts
2
+ const LF = 10;
3
+ const CR = 13;
4
+ const LS = 8232;
5
+ const PS = 8233;
6
+ const requireInteger = (name, value) => {
7
+ if (!Number.isInteger(value)) throw new RangeError(`LineIndex: ${name} must be an integer, received ${value}`);
8
+ };
9
+ const requireLength = (length) => {
10
+ requireInteger("length", length);
11
+ if (length < 0) throw new RangeError(`LineIndex: length must not be negative, received ${length}`);
12
+ };
13
+ const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
14
+ /**
15
+ * A line table over one source text, built once and queried many times:
16
+ * offset to `{ line, character }` and back, the number of lines, and where
17
+ * each line starts and ends.
18
+ *
19
+ * @remarks
20
+ * Positions are zero-based and `character` counts UTF-16 code units, LSP's
21
+ * default encoding (see {@link SourcePosition}). Building the index is one
22
+ * forward scan, `O(n)`; every lookup is a binary search, `O(log n)`, so build
23
+ * one per text and reuse it rather than rescanning for every diagnostic.
24
+ *
25
+ * Out-of-range input clamps instead of throwing, following
26
+ * `vscode-languageserver-textdocument`'s `TextDocument`, which most LSP
27
+ * servers already use:
28
+ *
29
+ * - an offset below `0` reads as `0`, and one past the end as `text.length`;
30
+ * - an offset inside a two-character terminator (the `\n` of a `\r\n`) reads
31
+ * as the end of its line, because no position can name the middle of a
32
+ * terminator;
33
+ * - a `character` past the end of its line reads as the end of the line's
34
+ * text, before its terminator, and a negative one as the line's start;
35
+ * - a `line` below `0` reads as offset `0`, and one past the last line as
36
+ * `text.length`.
37
+ *
38
+ * A value that is not an integer (`NaN`, `1.5`, `Infinity`) is a programmer
39
+ * error, not out-of-range input, and throws a `RangeError`: clamping `NaN`
40
+ * would silently answer "line 0".
41
+ *
42
+ * Every text has at least one line. The empty text has one empty line, and a
43
+ * text ending in a terminator has an empty last line after it, so `"a\n"` has
44
+ * two lines, as an editor shows it.
45
+ *
46
+ * `character` can name the low half of a surrogate pair; LSP leaves that to
47
+ * the client, and so does this index. It offers no code-point encoding:
48
+ * every LSP client must accept UTF-16, and each kit format package reports
49
+ * UTF-16 offsets.
50
+ *
51
+ * There is no shared-instance contract. Nothing checks `instanceof LineIndex`,
52
+ * and every value it returns is a plain record, so two installed copies of
53
+ * this package interoperate at runtime. At the type level the class does
54
+ * not: its `private` members make TypeScript compare it nominally, so a
55
+ * `LineIndex` from one copy is not assignable to another copy's `LineIndex`.
56
+ * Type a signature that takes or returns an index with the structural
57
+ * {@link SourceIndex} interface this class implements, never with the class.
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * import { LineIndex } from "@effected/source";
62
+ *
63
+ * const index = LineIndex.make("name: kit\r\nversion: 1\n");
64
+ * console.log(index.lineCount);
65
+ * // => 3
66
+ * console.log(index.positionAt(11));
67
+ * // => { line: 1, character: 0 }
68
+ * console.log(index.offsetAt({ line: 1, character: 9 }));
69
+ * // => 20
70
+ * console.log(index.lineText(1));
71
+ * // => "version: 1"
72
+ * ```
73
+ *
74
+ * @public
75
+ */
76
+ var LineIndex = class LineIndex {
77
+ text;
78
+ lineBreaks;
79
+ starts;
80
+ constructor(text, lineBreaks, starts) {
81
+ this.text = text;
82
+ this.lineBreaks = lineBreaks;
83
+ this.starts = starts;
84
+ }
85
+ /**
86
+ * Build the line table for `text` with one forward scan.
87
+ *
88
+ * @param text - The source text.
89
+ * @param options - Which characters end a line; see {@link LineIndexOptions}.
90
+ * @returns The index.
91
+ */
92
+ static make(text, options) {
93
+ const lineBreaks = options?.lineBreaks ?? "lsp";
94
+ const starts = [0];
95
+ const length = text.length;
96
+ for (let i = 0; i < length; i++) {
97
+ const code = text.charCodeAt(i);
98
+ if (code === LF) starts.push(i + 1);
99
+ else if (code === CR) {
100
+ if (i + 1 < length && text.charCodeAt(i + 1) === LF) {
101
+ starts.push(i + 2);
102
+ i++;
103
+ } else if (lineBreaks !== "lf") starts.push(i + 1);
104
+ } else if ((code === LS || code === PS) && lineBreaks === "unicode") starts.push(i + 1);
105
+ }
106
+ return new LineIndex(text, lineBreaks, starts);
107
+ }
108
+ /** The number of lines, at least `1`. */
109
+ get lineCount() {
110
+ return this.starts.length;
111
+ }
112
+ /**
113
+ * The zero-based line that contains `offset`. An offset at a terminator
114
+ * belongs to the line the terminator ends.
115
+ *
116
+ * @param offset - A UTF-16 offset, clamped to `[0, text.length]`.
117
+ * @returns The line number.
118
+ */
119
+ lineAt(offset) {
120
+ requireInteger("offset", offset);
121
+ const target = clamp(offset, 0, this.text.length);
122
+ let low = 0;
123
+ let high = this.starts.length - 1;
124
+ while (low < high) {
125
+ const mid = low + high + 1 >> 1;
126
+ if (this.starts[mid] <= target) low = mid;
127
+ else high = mid - 1;
128
+ }
129
+ return low;
130
+ }
131
+ /**
132
+ * The offset of the first character of `line`.
133
+ *
134
+ * @param line - A zero-based line, clamped to `[0, lineCount - 1]`.
135
+ * @returns The line's start offset.
136
+ */
137
+ lineStart(line) {
138
+ return this.starts[this.clampLine(line)];
139
+ }
140
+ /**
141
+ * The offset just past the last character of `line`'s text, which is
142
+ * where its terminator starts, or `text.length` on the last line.
143
+ *
144
+ * @param line - A zero-based line, clamped to `[0, lineCount - 1]`.
145
+ * @returns The line's end offset, excluding its terminator.
146
+ */
147
+ lineEnd(line) {
148
+ return this.endOf(this.clampLine(line));
149
+ }
150
+ /**
151
+ * The text of `line`, without its terminator.
152
+ *
153
+ * @param line - A zero-based line, clamped to `[0, lineCount - 1]`.
154
+ * @returns The line's text.
155
+ */
156
+ lineText(line) {
157
+ const index = this.clampLine(line);
158
+ return this.text.slice(this.starts[index], this.endOf(index));
159
+ }
160
+ /**
161
+ * The zero-based `{ line, character }` of `offset`.
162
+ *
163
+ * @param offset - A UTF-16 offset, clamped to `[0, text.length]`; one
164
+ * inside a `\r\n` reads as the end of its line.
165
+ * @returns The position.
166
+ */
167
+ positionAt(offset) {
168
+ const line = this.lineAt(offset);
169
+ const target = clamp(offset, 0, this.text.length);
170
+ const start = this.starts[line];
171
+ return {
172
+ line,
173
+ character: Math.min(target, this.endOf(line)) - start
174
+ };
175
+ }
176
+ /**
177
+ * The offset of a zero-based `{ line, character }`.
178
+ *
179
+ * @param position - The position. A `line` past the last reads as
180
+ * `text.length` and one below `0` as `0`; a `character` past the line's
181
+ * text reads as the line's end, before its terminator.
182
+ * @returns The UTF-16 offset.
183
+ */
184
+ offsetAt(position) {
185
+ requireInteger("line", position.line);
186
+ requireInteger("character", position.character);
187
+ if (position.line < 0) return 0;
188
+ if (position.line >= this.starts.length) return this.text.length;
189
+ const start = this.starts[position.line];
190
+ const end = this.endOf(position.line);
191
+ return clamp(start + position.character, start, end);
192
+ }
193
+ /**
194
+ * The {@link SourceSpan} (LSP `Range`) of an offset range.
195
+ *
196
+ * @param range - The range; its `length` must not be negative.
197
+ * @returns The start and exclusive end positions.
198
+ */
199
+ spanOf(range) {
200
+ requireLength(range.length);
201
+ return {
202
+ start: this.positionAt(range.offset),
203
+ end: this.positionAt(range.offset + range.length)
204
+ };
205
+ }
206
+ /**
207
+ * The {@link SourceRange} of a {@link SourceSpan}, after each position is
208
+ * clamped as {@link LineIndex.offsetAt} clamps it.
209
+ *
210
+ * @param span - The span; `end` must not precede `start`.
211
+ * @returns The offset and length.
212
+ */
213
+ rangeOf(span) {
214
+ const start = this.offsetAt(span.start);
215
+ const end = this.offsetAt(span.end);
216
+ if (end < start) throw new RangeError(`LineIndex: span end ${span.end.line}:${span.end.character} precedes its start ${span.start.line}:${span.start.character}`);
217
+ return {
218
+ offset: start,
219
+ length: end - start
220
+ };
221
+ }
222
+ /**
223
+ * The {@link SourceLocation} of a range: the range itself, unchanged,
224
+ * with the position of its start. The shape a diagnostic carries.
225
+ *
226
+ * @param range - The range; its `length` must be a non-negative integer,
227
+ * as {@link LineIndex.spanOf} requires.
228
+ * @returns `offset`, `length`, `line` and `character`.
229
+ */
230
+ locationOf(range) {
231
+ requireLength(range.length);
232
+ const { line, character } = this.positionAt(range.offset);
233
+ return {
234
+ offset: range.offset,
235
+ length: range.length,
236
+ line,
237
+ character
238
+ };
239
+ }
240
+ /**
241
+ * Where `line`'s text ends, derived from the next line's start rather
242
+ * than stored: the next start minus the terminator's length, two for a
243
+ * `\r\n` and one for every other terminator, or `text.length` on the
244
+ * last line. Keeping one table halves the index's memory; the lookup
245
+ * stays `O(1)` after the `O(log n)` search that found the line.
246
+ */
247
+ endOf(line) {
248
+ const next = this.starts[line + 1];
249
+ if (next === void 0) return this.text.length;
250
+ return this.text.charCodeAt(next - 1) === LF && this.text.charCodeAt(next - 2) === CR ? next - 2 : next - 1;
251
+ }
252
+ clampLine(line) {
253
+ requireInteger("line", line);
254
+ return clamp(line, 0, this.starts.length - 1);
255
+ }
256
+ };
257
+
258
+ export { LineIndex };
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # @effected/source
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@effected%2Fsource?label=npm&color=cb3837)](https://www.npmjs.com/package/@effected/source)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
5
+ [![Node.js %3E%3D24.11.0](https://img.shields.io/badge/Node.js-%3E%3D24.11.0-5fa04e.svg)](https://nodejs.org/)
6
+ [![TypeScript 7.0](https://img.shields.io/badge/TypeScript-7.0-3178c6.svg)](https://www.typescriptlang.org/)
7
+
8
+ Source-text positions as plain values: a line index that turns an offset into an LSP-style zero-based line and character and back, and the range, position and diagnostic shapes the `@effected` format packages share. No service, no platform, no `effect`, and no dependency at all.
9
+
10
+ > **Pre-`1.0.0`.** This package is part of the `@effected/*` kit, still in `0.x`
11
+ > development. It has no dependencies, not even `effect`.
12
+ >
13
+ > **Stability: unstable.** This package's API surface is not yet considered
14
+ > complete and may change across `0.x` releases. Pin an exact version. Full
15
+ > policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
16
+
17
+ ## Why @effected/source
18
+
19
+ A parser reports where something went wrong as an offset into the text. An editor, a language server and a person all want a line and a column. The conversion looks like a five-line loop, and that loop is easy to get subtly wrong: which characters end a line, what an offset between the `\r` and `\n` of a Windows line ending means, and whether a column counts code units or characters. This package is the conversion written once, by the rules the Language Server Protocol uses.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm install @effected/source
25
+ ```
26
+
27
+ ```bash
28
+ pnpm add @effected/source
29
+ ```
30
+
31
+ Requires Node.js >=24.11.0. No dependencies and no peers. ESM-only: import it from an ES module.
32
+
33
+ ## Usage
34
+
35
+ ```ts
36
+ import { LineIndex } from "@effected/source";
37
+
38
+ const index = LineIndex.make("name: kit\r\nversion: 1\n");
39
+
40
+ index.positionAt(11);
41
+ // { line: 1, character: 0 }
42
+
43
+ index.offsetAt({ line: 1, character: 9 });
44
+ // 20
45
+
46
+ index.spanOf({ offset: 11, length: 7 });
47
+ // { start: { line: 1, character: 0 }, end: { line: 1, character: 7 } }
48
+ ```
49
+
50
+ - **`LineIndex.make(text, options?)`** builds the line table once; every query after that is a binary search. `positionAt` and `offsetAt` convert both ways, `lineCount`, `lineAt`, `lineStart`, `lineEnd` and `lineText` describe the lines, and `spanOf`, `rangeOf` and `locationOf` convert ranges.
51
+ - **Positions are zero-based** and `character` counts UTF-16 code units, LSP's default. Add 1 to both for a person.
52
+ - **Lines end at `\n`, `\r\n` or a bare `\r`** by default. `lineBreaks: "lf"` ends them at `\n` only, and `lineBreaks: "unicode"` also at U+2028 and U+2029.
53
+ - **Out-of-range input clamps** to the nearest valid position, as `vscode-languageserver-textdocument` does. A value that is not an integer throws a `RangeError`.
54
+ - **`SourcePosition`, `SourceRange`, `SourceSpan`, `SourceLocation` and `SourceDiagnostic`** are structural interfaces, so the diagnostics and ranges of `@effected/jsonc`, `@effected/yaml`, `@effected/toml` and `@effected/markdown` can be read through them as they are.
55
+
56
+ ## Documentation
57
+
58
+ The guide and the API reference are at [effected.spencerbeg.gs/source](https://effected.spencerbeg.gs/source).
59
+
60
+ ## License
61
+
62
+ [MIT](LICENSE)
package/index.d.ts ADDED
@@ -0,0 +1,359 @@
1
+ /**
2
+ * A zero-based line and character in a source text, the shape of an LSP
3
+ * `Position`.
4
+ *
5
+ * @remarks
6
+ * Both fields count from `0`. `character` counts UTF-16 code units from the
7
+ * start of the line, which is LSP's default position encoding and what a
8
+ * JavaScript string index already is, so an astral character such as an emoji
9
+ * occupies two. A line is ended by `\n`, `\r\n` or a bare `\r` unless the
10
+ * {@link LineIndex} that produced the position was built with another
11
+ * {@link (LineBreaks:type)} rule.
12
+ *
13
+ * This is a structural interface, not a class: any `{ line, character }`
14
+ * record satisfies it, so the kit's format packages can return their own
15
+ * classes and still be read through it, and two installed copies of this
16
+ * package never disagree about what a position is.
17
+ *
18
+ * A human reader counts from 1. Add 1 to both fields when printing a
19
+ * position for a person, and keep the structured value zero-based.
20
+ *
21
+ * @public
22
+ */
23
+ interface SourcePosition {
24
+ /** The zero-based line number. */
25
+ readonly line: number;
26
+ /** The zero-based offset within the line, in UTF-16 code units. */
27
+ readonly character: number;
28
+ }
29
+ /**
30
+ * A span of a source text as a start offset and a length, both in UTF-16 code
31
+ * units: the characters `[offset, offset + length)`.
32
+ *
33
+ * @remarks
34
+ * The shape every kit format package uses for a range (`JsoncRange`,
35
+ * `YamlRange`, `TomlRange`, `MarkdownRange`) and for the positional part of an
36
+ * edit, so any of them satisfies this interface as it stands. A `length` of
37
+ * `0` is an empty span, an insertion point.
38
+ *
39
+ * Offsets are string indices, not bytes and not code points. Convert to a
40
+ * line and character with {@link LineIndex.spanOf}.
41
+ *
42
+ * @public
43
+ */
44
+ interface SourceRange {
45
+ /** The zero-based UTF-16 offset of the first character. */
46
+ readonly offset: number;
47
+ /** The number of UTF-16 code units in the span. */
48
+ readonly length: number;
49
+ }
50
+ /**
51
+ * Where something sits in a source text, said both ways: the
52
+ * {@link SourceRange} (`offset`, `length`) and the {@link SourcePosition} of
53
+ * its start (`line`, `character`, zero-based).
54
+ *
55
+ * @remarks
56
+ * The positional core of every kit diagnostic. `JsoncParseErrorDetail`
57
+ * satisfies it, and so do `YamlDiagnostic`, `TomlDiagnostic`,
58
+ * `MarkdownDiagnostic` and `YamlToken`. {@link LineIndex.locationOf} builds
59
+ * one from a range.
60
+ *
61
+ * @public
62
+ */
63
+ interface SourceLocation extends SourceRange, SourcePosition {}
64
+ /**
65
+ * A diagnostic about a source text: a machine-readable `code`, a
66
+ * human-readable `message`, and its {@link SourceLocation}.
67
+ *
68
+ * @remarks
69
+ * A structural contract, not a base class. Each format package keeps its own
70
+ * diagnostic class, its own `code` union and its own error types, which stay
71
+ * part of that package's API; this interface only names the shape they
72
+ * already share, so a consumer (an LSP server, a CLI printing annotations, a
73
+ * lint host) can be written once over any of them. `YamlDiagnostic`,
74
+ * `TomlDiagnostic` and `MarkdownDiagnostic` satisfy it as they stand.
75
+ * `JsoncParseErrorDetail` carries no `message`, so it satisfies
76
+ * {@link SourceLocation} only.
77
+ *
78
+ * `Code` narrows the code union for a consumer that knows which package the
79
+ * diagnostic came from; the default accepts any.
80
+ *
81
+ * @typeParam Code - The diagnostic code union.
82
+ *
83
+ * @public
84
+ */
85
+ interface SourceDiagnostic<Code extends string = string> extends SourceLocation {
86
+ /** The machine-readable diagnostic code. */
87
+ readonly code: Code;
88
+ /** The human-readable description. */
89
+ readonly message: string;
90
+ }
91
+ /**
92
+ * A span of a source text as two {@link SourcePosition}s, the shape of an LSP
93
+ * `Range`. `end` is exclusive.
94
+ *
95
+ * @remarks
96
+ * The line-and-character twin of {@link SourceRange}. An LSP server reports
97
+ * and receives this shape; a parser reports offsets. {@link LineIndex.spanOf}
98
+ * and {@link LineIndex.rangeOf} convert between them.
99
+ *
100
+ * @public
101
+ */
102
+ interface SourceSpan {
103
+ /** The position of the first character. */
104
+ readonly start: SourcePosition;
105
+ /** The position just past the last character. */
106
+ readonly end: SourcePosition;
107
+ }
108
+ /**
109
+ * The public surface of a line index, as a structural interface: what
110
+ * {@link LineIndex} answers, with nothing that ties a value to one copy of
111
+ * this package.
112
+ *
113
+ * @remarks
114
+ * Type a signature that takes or returns a line index with this interface,
115
+ * never with the `LineIndex` class. The class has `private` members, and
116
+ * TypeScript compares a class with `private` members nominally: when two
117
+ * copies of `@effected/source` are installed (a format package on one
118
+ * version, its consumer on another), a `LineIndex` built by one copy is not
119
+ * assignable to the other copy's `LineIndex` type (TS2345, "Types have
120
+ * separate declarations of a private property"). Every member here is a
121
+ * public method or a readonly property, so an index from any copy satisfies
122
+ * this interface from any other.
123
+ *
124
+ * Construct one with {@link LineIndex.make}; this interface has no
125
+ * constructor of its own, and nothing checks `instanceof`.
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * import type { SourceIndex, SourcePosition } from "@effected/source";
130
+ * import { LineIndex } from "@effected/source";
131
+ *
132
+ * const startOf = (index: SourceIndex, offset: number): SourcePosition => index.positionAt(offset);
133
+ * console.log(startOf(LineIndex.make("a\nb"), 2));
134
+ * // => { line: 1, character: 0 }
135
+ * ```
136
+ *
137
+ * @public
138
+ */
139
+ interface SourceIndex {
140
+ /** The text this index was built over. */
141
+ readonly text: string;
142
+ /** The rule that decided where lines end. */
143
+ readonly lineBreaks: LineBreaks;
144
+ /** The number of lines, at least `1`. */
145
+ readonly lineCount: number;
146
+ /** The zero-based line that contains `offset`; see {@link LineIndex.lineAt}. */
147
+ lineAt(offset: number): number;
148
+ /** The offset of the first character of `line`; see {@link LineIndex.lineStart}. */
149
+ lineStart(line: number): number;
150
+ /** The offset where `line`'s terminator starts; see {@link LineIndex.lineEnd}. */
151
+ lineEnd(line: number): number;
152
+ /** The text of `line`, without its terminator; see {@link LineIndex.lineText}. */
153
+ lineText(line: number): string;
154
+ /** The `{ line, character }` of `offset`; see {@link LineIndex.positionAt}. */
155
+ positionAt(offset: number): SourcePosition;
156
+ /** The offset of a position; see {@link LineIndex.offsetAt}. */
157
+ offsetAt(position: SourcePosition): number;
158
+ /** The span of an offset range; see {@link LineIndex.spanOf}. */
159
+ spanOf(range: SourceRange): SourceSpan;
160
+ /** The offset range of a span; see {@link LineIndex.rangeOf}. */
161
+ rangeOf(span: SourceSpan): SourceRange;
162
+ /** The location of a range; see {@link LineIndex.locationOf}. */
163
+ locationOf(range: SourceRange): SourceLocation;
164
+ }
165
+ /**
166
+ * Which characters end a line, for {@link LineIndex.make}.
167
+ *
168
+ * @remarks
169
+ * - `"lsp"` (the default): `\n`, `\r\n` and a bare `\r`, the Language Server
170
+ * Protocol's rule. `\r\n` is one terminator. Markdown, TOML, YAML (its
171
+ * tokens, lint lines and diagnostics alike) and an LSP client all count
172
+ * lines this way.
173
+ * - `"lf"`: a line ends at `\n` only. `\r\n` is still one terminator (the
174
+ * `\r` is not part of the line's text), but a bare `\r` is an ordinary
175
+ * character. No kit package uses it now; it stays for a consumer that must
176
+ * reproduce a `\n`-only line count, such as a tool that splits on `\n`.
177
+ * - `"unicode"`: the `"lsp"` rule plus U+2028 LINE SEPARATOR and U+2029
178
+ * PARAGRAPH SEPARATOR, each a terminator on its own. `@effected/jsonc`
179
+ * counts its diagnostics' lines this way, as its scanner does. An LSP
180
+ * client does not, so a position computed under this rule disagrees with
181
+ * the client's after the first separator.
182
+ *
183
+ * @public
184
+ */
185
+ type LineBreaks = "lsp" | "lf" | "unicode";
186
+ /**
187
+ * Options for {@link LineIndex.make}.
188
+ *
189
+ * @public
190
+ */
191
+ interface LineIndexOptions {
192
+ /**
193
+ * Which characters end a line. Defaults to `"lsp"`: `\n`, `\r\n` and a
194
+ * bare `\r`.
195
+ */
196
+ readonly lineBreaks?: LineBreaks;
197
+ }
198
+ /**
199
+ * A line table over one source text, built once and queried many times:
200
+ * offset to `{ line, character }` and back, the number of lines, and where
201
+ * each line starts and ends.
202
+ *
203
+ * @remarks
204
+ * Positions are zero-based and `character` counts UTF-16 code units, LSP's
205
+ * default encoding (see {@link SourcePosition}). Building the index is one
206
+ * forward scan, `O(n)`; every lookup is a binary search, `O(log n)`, so build
207
+ * one per text and reuse it rather than rescanning for every diagnostic.
208
+ *
209
+ * Out-of-range input clamps instead of throwing, following
210
+ * `vscode-languageserver-textdocument`'s `TextDocument`, which most LSP
211
+ * servers already use:
212
+ *
213
+ * - an offset below `0` reads as `0`, and one past the end as `text.length`;
214
+ * - an offset inside a two-character terminator (the `\n` of a `\r\n`) reads
215
+ * as the end of its line, because no position can name the middle of a
216
+ * terminator;
217
+ * - a `character` past the end of its line reads as the end of the line's
218
+ * text, before its terminator, and a negative one as the line's start;
219
+ * - a `line` below `0` reads as offset `0`, and one past the last line as
220
+ * `text.length`.
221
+ *
222
+ * A value that is not an integer (`NaN`, `1.5`, `Infinity`) is a programmer
223
+ * error, not out-of-range input, and throws a `RangeError`: clamping `NaN`
224
+ * would silently answer "line 0".
225
+ *
226
+ * Every text has at least one line. The empty text has one empty line, and a
227
+ * text ending in a terminator has an empty last line after it, so `"a\n"` has
228
+ * two lines, as an editor shows it.
229
+ *
230
+ * `character` can name the low half of a surrogate pair; LSP leaves that to
231
+ * the client, and so does this index. It offers no code-point encoding:
232
+ * every LSP client must accept UTF-16, and each kit format package reports
233
+ * UTF-16 offsets.
234
+ *
235
+ * There is no shared-instance contract. Nothing checks `instanceof LineIndex`,
236
+ * and every value it returns is a plain record, so two installed copies of
237
+ * this package interoperate at runtime. At the type level the class does
238
+ * not: its `private` members make TypeScript compare it nominally, so a
239
+ * `LineIndex` from one copy is not assignable to another copy's `LineIndex`.
240
+ * Type a signature that takes or returns an index with the structural
241
+ * {@link SourceIndex} interface this class implements, never with the class.
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * import { LineIndex } from "@effected/source";
246
+ *
247
+ * const index = LineIndex.make("name: kit\r\nversion: 1\n");
248
+ * console.log(index.lineCount);
249
+ * // => 3
250
+ * console.log(index.positionAt(11));
251
+ * // => { line: 1, character: 0 }
252
+ * console.log(index.offsetAt({ line: 1, character: 9 }));
253
+ * // => 20
254
+ * console.log(index.lineText(1));
255
+ * // => "version: 1"
256
+ * ```
257
+ *
258
+ * @public
259
+ */
260
+ export declare class LineIndex implements SourceIndex {
261
+ /** The text this index was built over. */
262
+ readonly text: string;
263
+ /** The rule that decided where lines end. */
264
+ readonly lineBreaks: LineBreaks;
265
+ private readonly starts;
266
+ private constructor();
267
+ /**
268
+ * Build the line table for `text` with one forward scan.
269
+ *
270
+ * @param text - The source text.
271
+ * @param options - Which characters end a line; see {@link LineIndexOptions}.
272
+ * @returns The index.
273
+ */
274
+ static make(text: string, options?: LineIndexOptions): LineIndex;
275
+ /** The number of lines, at least `1`. */
276
+ get lineCount(): number;
277
+ /**
278
+ * The zero-based line that contains `offset`. An offset at a terminator
279
+ * belongs to the line the terminator ends.
280
+ *
281
+ * @param offset - A UTF-16 offset, clamped to `[0, text.length]`.
282
+ * @returns The line number.
283
+ */
284
+ lineAt(offset: number): number;
285
+ /**
286
+ * The offset of the first character of `line`.
287
+ *
288
+ * @param line - A zero-based line, clamped to `[0, lineCount - 1]`.
289
+ * @returns The line's start offset.
290
+ */
291
+ lineStart(line: number): number;
292
+ /**
293
+ * The offset just past the last character of `line`'s text, which is
294
+ * where its terminator starts, or `text.length` on the last line.
295
+ *
296
+ * @param line - A zero-based line, clamped to `[0, lineCount - 1]`.
297
+ * @returns The line's end offset, excluding its terminator.
298
+ */
299
+ lineEnd(line: number): number;
300
+ /**
301
+ * The text of `line`, without its terminator.
302
+ *
303
+ * @param line - A zero-based line, clamped to `[0, lineCount - 1]`.
304
+ * @returns The line's text.
305
+ */
306
+ lineText(line: number): string;
307
+ /**
308
+ * The zero-based `{ line, character }` of `offset`.
309
+ *
310
+ * @param offset - A UTF-16 offset, clamped to `[0, text.length]`; one
311
+ * inside a `\r\n` reads as the end of its line.
312
+ * @returns The position.
313
+ */
314
+ positionAt(offset: number): SourcePosition;
315
+ /**
316
+ * The offset of a zero-based `{ line, character }`.
317
+ *
318
+ * @param position - The position. A `line` past the last reads as
319
+ * `text.length` and one below `0` as `0`; a `character` past the line's
320
+ * text reads as the line's end, before its terminator.
321
+ * @returns The UTF-16 offset.
322
+ */
323
+ offsetAt(position: SourcePosition): number;
324
+ /**
325
+ * The {@link SourceSpan} (LSP `Range`) of an offset range.
326
+ *
327
+ * @param range - The range; its `length` must not be negative.
328
+ * @returns The start and exclusive end positions.
329
+ */
330
+ spanOf(range: SourceRange): SourceSpan;
331
+ /**
332
+ * The {@link SourceRange} of a {@link SourceSpan}, after each position is
333
+ * clamped as {@link LineIndex.offsetAt} clamps it.
334
+ *
335
+ * @param span - The span; `end` must not precede `start`.
336
+ * @returns The offset and length.
337
+ */
338
+ rangeOf(span: SourceSpan): SourceRange;
339
+ /**
340
+ * The {@link SourceLocation} of a range: the range itself, unchanged,
341
+ * with the position of its start. The shape a diagnostic carries.
342
+ *
343
+ * @param range - The range; its `length` must be a non-negative integer,
344
+ * as {@link LineIndex.spanOf} requires.
345
+ * @returns `offset`, `length`, `line` and `character`.
346
+ */
347
+ locationOf(range: SourceRange): SourceLocation;
348
+ /**
349
+ * Where `line`'s text ends, derived from the next line's start rather
350
+ * than stored: the next start minus the terminator's length, two for a
351
+ * `\r\n` and one for every other terminator, or `text.length` on the
352
+ * last line. Keeping one table halves the index's memory; the lookup
353
+ * stays `O(1)` after the `O(log n)` search that found the line.
354
+ */
355
+ private endOf;
356
+ private clampLine;
357
+ }
358
+ export type { LineBreaks, LineIndexOptions, SourceDiagnostic, SourceIndex, SourceLocation, SourcePosition, SourceRange, SourceSpan };
359
+ //# sourceMappingURL=index.d.ts.map
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { LineIndex } from "./LineIndex.js";
2
+
3
+ export { LineIndex };
package/package.json CHANGED
@@ -1,6 +1,45 @@
1
1
  {
2
- "name": "@effected/source",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@effected/source",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Source-text positions as plain values: a line index over a text, LSP-style line and character, and the structural range and diagnostic shapes the kit's format packages share.",
6
+ "keywords": [
7
+ "source",
8
+ "position",
9
+ "line",
10
+ "character",
11
+ "offset",
12
+ "lsp",
13
+ "diagnostic",
14
+ "range",
15
+ "effected"
16
+ ],
17
+ "homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/source#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/spencerbeggs/effected/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/spencerbeggs/effected.git",
24
+ "directory": "packages/source"
25
+ },
26
+ "license": "MIT",
27
+ "author": {
28
+ "name": "C. Spencer Beggs",
29
+ "email": "spencer@beggs.codes",
30
+ "url": "https://spencerbeg.gs"
31
+ },
32
+ "sideEffects": false,
33
+ "type": "module",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./index.d.ts",
37
+ "import": "./index.js",
38
+ "default": "./index.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "engines": {
43
+ "node": ">=24.11.0"
44
+ }
45
+ }
@@ -0,0 +1,11 @@
1
+ // This file is read by tools that parse documentation comments conforming to the TSDoc standard.
2
+ // It should be published with your NPM package. It should not be tracked by Git.
3
+ {
4
+ "tsdocVersion": "0.12",
5
+ "toolPackages": [
6
+ {
7
+ "packageName": "@microsoft/api-extractor",
8
+ "packageVersion": "7.59.4"
9
+ }
10
+ ]
11
+ }