-
-
Notifications
You must be signed in to change notification settings - Fork 27
feat: add support for getLocFromIndex
and getIndexFromLoc
#212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
08a179a
c0b4cc4
5f6fb42
adc573f
8def91f
b9f5a17
9c2c153
7655663
bdb8f11
b970ccc
1e319ee
953a16c
4977cb2
e1df65c
bc963b3
c31843f
a831324
eaa212d
478b8d5
498402f
fbd565e
e4b789b
bc5c6de
4844c96
c0f6bab
ed7dffc
bd21283
b7a4abf
3ef01a9
2098f91
c4e80dd
82591a6
1a5022e
3c5fad7
2d9d12b
c70890a
59b3f2e
bb13197
1750960
2cef189
1d1022a
0e4cac0
1c0531c
1e3ba06
bf02009
aebf8d2
710f945
23510e0
7a6e57f
0d98c6b
2fe8cd5
75e156f
b1e5249
9d1f541
123118c
91f3b8e
98691ad
ae465b6
8afa6ac
ffb9b54
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -63,6 +63,32 @@ function hasPosStyleRange(node) { | |||||
return "position" in node; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Performs binary search to find the line number containing a given target index. | ||||||
* Returns the lower bound - the index of the first element greater than the target. | ||||||
* **Please note that the `lineStartIndices` should be sorted in ascending order**. | ||||||
* - Time Complexity: O(log n) - Significantly faster than linear search for large files. | ||||||
* @param {number[]} lineStartIndices Sorted array of line start indices. | ||||||
* @param {number} targetIndex The target index to find the line number for. | ||||||
* @returns {number} The line number for the target index. | ||||||
*/ | ||||||
function findLineNumberBinarySearch(lineStartIndices, targetIndex) { | ||||||
let low = 0; | ||||||
let high = lineStartIndices.length - 1; | ||||||
|
||||||
while (low < high) { | ||||||
const mid = ((low + high) / 2) | 0; // Use bitwise OR to floor the division. | ||||||
|
||||||
if (targetIndex < lineStartIndices[mid]) { | ||||||
high = mid; | ||||||
} else { | ||||||
low = mid + 1; | ||||||
} | ||||||
} | ||||||
|
||||||
return low; | ||||||
} | ||||||
|
||||||
//----------------------------------------------------------------------------- | ||||||
// Exports | ||||||
//----------------------------------------------------------------------------- | ||||||
|
@@ -216,15 +242,27 @@ export class Directive { | |||||
|
||||||
/** | ||||||
* Source Code Base Object | ||||||
* @template {SourceCodeBaseTypeOptions & {SyntaxElementWithLoc: object}} [Options=SourceCodeBaseTypeOptions & {SyntaxElementWithLoc: object}] | ||||||
* @template {SourceCodeBaseTypeOptions & {RootNode: object, SyntaxElementWithLoc: object}} [Options=SourceCodeBaseTypeOptions & {RootNode: object, SyntaxElementWithLoc: object}] | ||||||
* @implements {TextSourceCode<Options>} | ||||||
*/ | ||||||
export class TextSourceCodeBase { | ||||||
/** | ||||||
* The lines of text in the source code. | ||||||
* @type {Array<string>} | ||||||
*/ | ||||||
#lines; | ||||||
#lines = []; | ||||||
|
||||||
/** | ||||||
* The indices of the start of each line in the source code. | ||||||
* @type {Array<number>} | ||||||
*/ | ||||||
#lineStartIndices = [0]; | ||||||
|
||||||
/** | ||||||
* The pattern to match lineEndings in the source code. | ||||||
* @type {RegExp} | ||||||
*/ | ||||||
#lineEndingPattern; | ||||||
|
||||||
/** | ||||||
* The AST of the source code. | ||||||
|
@@ -243,12 +281,102 @@ export class TextSourceCodeBase { | |||||
* @param {Object} options The options for the instance. | ||||||
* @param {string} options.text The source code text. | ||||||
* @param {Options['RootNode']} options.ast The root AST node. | ||||||
* @param {RegExp} [options.lineEndingPattern] The pattern to match lineEndings in the source code. | ||||||
* @param {RegExp} [options.lineEndingPattern] The pattern to match lineEndings in the source code. Defaults to `/\r?\n/u`. | ||||||
*/ | ||||||
constructor({ text, ast, lineEndingPattern = /\r?\n/u }) { | ||||||
this.ast = ast; | ||||||
this.text = text; | ||||||
this.#lines = text.split(lineEndingPattern); | ||||||
this.#lineEndingPattern = lineEndingPattern; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Parses the source text into lines and updates the `#lines` and `#lineStartIndices` properties. | ||||||
* @param {string} text The source text to parse into lines. | ||||||
* @returns {boolean} `true` if the text was successfully parsed into lines, `false` otherwise. | ||||||
*/ | ||||||
#parseText(text) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This isn't actually parsing text, so the name is misleading. Maybe (Also, the JSDoc needs to be updated to reflect this.) |
||||||
// Create a new RegExp instance to avoid lastIndex issues. | ||||||
const match = structuredClone(this.#lineEndingPattern).exec(text); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It doesn't seem like this is necessary, or at least, we should be able to reuse just one copy of the regex that is passed into the constructor. I don't think we need a new copy every time this function is called. Also, the best way to duplicate a regex would be |
||||||
|
||||||
if (!match) { | ||||||
return false; | ||||||
} | ||||||
|
||||||
this.#lines.push(text.slice(0, match.index)); | ||||||
this.#lineStartIndices.push( | ||||||
(this.#lineStartIndices.at(-1) ?? 0) + | ||||||
match.index + | ||||||
match[0].length, | ||||||
); | ||||||
|
||||||
return true; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Ensures `#lines` is lazily calculated from the source text. | ||||||
* @returns {void} | ||||||
*/ | ||||||
#ensureLines() { | ||||||
lumirlumir marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
// If `#lines` has already been calculated, do nothing. | ||||||
if (this.#lines.length === this.#lineStartIndices.length) { | ||||||
return; | ||||||
} | ||||||
|
||||||
while ( | ||||||
this.#parseText(this.text.slice(this.#lineStartIndices.at(-1))) | ||||||
) { | ||||||
// Continue parsing until no more matches are found. | ||||||
} | ||||||
|
||||||
this.#lines.push(this.text.slice(this.#lineStartIndices.at(-1))); | ||||||
|
||||||
Object.freeze(this.#lines); | ||||||
} | ||||||
|
||||||
/** | ||||||
* Ensures `#lineStartIndices` is lazily calculated up to the specified index. | ||||||
* @param {number} index The index of a character in a file. | ||||||
* @returns {void} | ||||||
*/ | ||||||
#ensureLineStartIndicesFromIndex(index) { | ||||||
// If we've already parsed up to or beyond this index, do nothing. | ||||||
if (index <= (this.#lineStartIndices.at(-1) ?? 0)) { | ||||||
return; | ||||||
} | ||||||
|
||||||
while ( | ||||||
this.#parseText( | ||||||
this.text.slice(this.#lineStartIndices.at(-1), index + 1), | ||||||
) | ||||||
) { | ||||||
// Continue parsing until no more matches are found. | ||||||
} | ||||||
} | ||||||
|
||||||
/** | ||||||
* Ensures `#lineStartIndices` is lazily calculated up to the specified loc. | ||||||
* @param {Object} loc A line/column location. | ||||||
* @param {number} loc.line The line number of the location. (0 or 1-indexed based on language.) | ||||||
* @param {number} lineStart The line number at which the parser starts counting. | ||||||
* @returns {void} | ||||||
*/ | ||||||
#ensureLineStartIndicesFromLoc(loc, lineStart) { | ||||||
// Calculate line indices up to the potentially next line, as it is needed for the follow‑up calculation. | ||||||
const nextLocLineIndex = loc.line - lineStart + 1; | ||||||
const lastCalculatedLineIndex = this.#lineStartIndices.length - 1; | ||||||
let additionalLinesNeeded = nextLocLineIndex - lastCalculatedLineIndex; | ||||||
|
||||||
// If we've already parsed up to or beyond this line, do nothing. | ||||||
if (additionalLinesNeeded <= 0) { | ||||||
return; | ||||||
} | ||||||
|
||||||
while ( | ||||||
this.#parseText(this.text.slice(this.#lineStartIndices.at(-1))) && | ||||||
Boolean(additionalLinesNeeded--) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The intent here isn't very clear. Can we do this something like this?
Suggested change
Although, I think it would be even clearer if we could decrement inside the start of the loop instead of in the condition. |
||||||
) { | ||||||
// Continue parsing until no more matches are found. | ||||||
} | ||||||
} | ||||||
|
||||||
/** | ||||||
|
@@ -271,6 +399,135 @@ export class TextSourceCodeBase { | |||||
); | ||||||
} | ||||||
|
||||||
/** | ||||||
* Converts a source text index into a `{ line: number, column: number }` pair. | ||||||
* @param {number} index The index of a character in a file. | ||||||
* @throws {TypeError|RangeError} If non-numeric index or index out of range. | ||||||
* @returns {{line: number, column: number}} A `{ line: number, column: number }` location object with 0 or 1-indexed line and 0 or 1-indexed column based on language. | ||||||
* @public | ||||||
*/ | ||||||
getLocFromIndex(index) { | ||||||
if (typeof index !== "number") { | ||||||
throw new TypeError("Expected `index` to be a number."); | ||||||
} | ||||||
|
||||||
if (index < 0 || index > this.text.length) { | ||||||
throw new RangeError( | ||||||
`Index out of range (requested index ${index}, but source text has length ${this.text.length}).`, | ||||||
); | ||||||
} | ||||||
|
||||||
const { | ||||||
start: { line: lineStart, column: columnStart }, | ||||||
end: { line: lineEnd, column: columnEnd }, | ||||||
} = this.getLoc(this.ast); | ||||||
|
||||||
// If the index is at the start, return the start location of the root node. | ||||||
if (index === 0) { | ||||||
return { | ||||||
line: lineStart, | ||||||
column: columnStart, | ||||||
}; | ||||||
} | ||||||
|
||||||
// If the index is `this.text.length`, return the location one "spot" past the last character of the file. | ||||||
if (index === this.text.length) { | ||||||
return { | ||||||
line: lineEnd, | ||||||
column: columnEnd, | ||||||
}; | ||||||
} | ||||||
|
||||||
// Ensure `#lineStartIndices` are lazily calculated. | ||||||
this.#ensureLineStartIndicesFromIndex(index); | ||||||
|
||||||
/* | ||||||
* To figure out which line `index` is on, determine the last place at which index could | ||||||
* be inserted into `#lineStartIndices` to keep the list sorted. | ||||||
*/ | ||||||
const lineNumber = | ||||||
(index >= (this.#lineStartIndices.at(-1) ?? 0) | ||||||
? this.#lineStartIndices.length | ||||||
: findLineNumberBinarySearch(this.#lineStartIndices, index)) - | ||||||
1 + | ||||||
lineStart; | ||||||
|
||||||
return { | ||||||
line: lineNumber, | ||||||
column: | ||||||
index - | ||||||
this.#lineStartIndices[lineNumber - lineStart] + | ||||||
columnStart, | ||||||
}; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Converts a `{ line: number, column: number }` pair into a source text index. | ||||||
* @param {Object} loc A line/column location. | ||||||
* @param {number} loc.line The line number of the location. (0 or 1-indexed based on language.) | ||||||
* @param {number} loc.column The column number of the location. (0 or 1-indexed based on language.) | ||||||
* @throws {TypeError|RangeError} If `loc` is not an object with a numeric | ||||||
* `line` and `column`, if the `line` is less than or equal to zero or | ||||||
* the `line` or `column` is out of the expected range. | ||||||
* @returns {number} The index of the line/column location in a file. | ||||||
* @public | ||||||
*/ | ||||||
getIndexFromLoc(loc) { | ||||||
if ( | ||||||
loc === null || | ||||||
typeof loc !== "object" || | ||||||
typeof loc.line !== "number" || | ||||||
typeof loc.column !== "number" | ||||||
) { | ||||||
throw new TypeError( | ||||||
"Expected `loc` to be an object with numeric `line` and `column` properties.", | ||||||
); | ||||||
} | ||||||
|
||||||
const { | ||||||
start: { line: lineStart, column: columnStart }, | ||||||
end: { line: lineEnd, column: columnEnd }, | ||||||
} = this.getLoc(this.ast); | ||||||
|
||||||
if (loc.line < lineStart || lineEnd < loc.line) { | ||||||
throw new RangeError( | ||||||
`Line number out of range (line ${loc.line} requested). Valid range: ${lineStart}-${lineEnd}`, | ||||||
); | ||||||
} | ||||||
|
||||||
// If the loc is at the start, return the start index of the root node. | ||||||
if (loc.line === lineStart && loc.column === columnStart) { | ||||||
return 0; | ||||||
} | ||||||
|
||||||
// If the loc is at the end, return the index one "spot" past the last character of the file. | ||||||
if (loc.line === lineEnd && loc.column === columnEnd) { | ||||||
return this.text.length; | ||||||
} | ||||||
|
||||||
// Ensure `#lineStartIndices` are lazily calculated. | ||||||
this.#ensureLineStartIndicesFromLoc(loc, lineStart); | ||||||
|
||||||
const isLastLine = loc.line === lineEnd; | ||||||
const lineStartIndex = this.#lineStartIndices[loc.line - lineStart]; | ||||||
const lineEndIndex = isLastLine | ||||||
? this.text.length | ||||||
: this.#lineStartIndices[loc.line - lineStart + 1]; | ||||||
const positionIndex = lineStartIndex + loc.column - columnStart; | ||||||
|
||||||
if ( | ||||||
loc.column < columnStart || | ||||||
(isLastLine && positionIndex > lineEndIndex) || | ||||||
(!isLastLine && positionIndex >= lineEndIndex) | ||||||
) { | ||||||
throw new RangeError( | ||||||
`Column number out of range (column ${loc.column} requested). Valid range for line ${loc.line}: ${columnStart}-${lineEndIndex - lineStartIndex + columnStart + (isLastLine ? 0 : -1)}`, | ||||||
); | ||||||
} | ||||||
|
||||||
return positionIndex; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Returns the range information for the given node or token. | ||||||
* @param {Options['SyntaxElementWithLoc']} nodeOrToken The node or token to get the range information for. | ||||||
|
@@ -356,6 +613,8 @@ export class TextSourceCodeBase { | |||||
* @public | ||||||
*/ | ||||||
get lines() { | ||||||
this.#ensureLines(); // Ensure `#lines` is lazily calculated. | ||||||
|
||||||
return this.#lines; | ||||||
} | ||||||
|
||||||
|
Uh oh!
There was an error while loading. Please reload this page.