Repository: nodejs/node
Pull request: module: Add SourceMap.findOrigin

This adds the `SourceMap.findOrigin(lineNumber, columnNumber)` method, for finding the origin source file and 1-indexed line and column numbers corresponding to the 1-indexed line and column numbers from a call site in generated source code.

Fix: #47770

<!--
Before submitting a pull request, please read
https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md.

Commit message formatting guidelines:
https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: doc/api/module.md
patch: @@ -293,23 +293,67 @@ Creates a new `sourceMap` instance.
 
 Getter for the payload used to construct the [`SourceMap`][] instance.
 
-#### `sourceMap.findEntry(lineNumber, columnNumber)`
+#### `sourceMap.findEntry(lineOffset, columnOffset)`
 
-* `lineNumber` {number}
-* `columnNumber` {number}
+* `lineOffset` {number} The zero-indexed line number offset in
+  the generated source
+* `columnOffset` {number} The zero-indexed column number offset
+  in the generated source
 * Returns: {Object}
 
-Given a line number and column number in the generated source file, returns
-an object representing the position in the original file. The object returned
-consists of the following keys:
-
-* generatedLine: {number}
-* generatedColumn: {number}
-* originalSource: {string}
-* originalLine: {number}
-* originalColumn: {number}
+Given a line offset and column offset in the generated source
+file, returns an object representing the SourceMap range in the
+original file if found, or an empty object if not.
+
+The object returned contains the following keys:
+
+* generatedLine: {number} The line offset of the start of the
+  range in the generated source
+* generatedColumn: {number} The column offset of start of the
+  range in the generated source
+* originalSource: {string} The file name of the original source,
+  as reported in the SourceMap
+* originalLine: {number} The line offset of the start of the
+  range in the original source
+* originalColumn: {number} The column offset of start of the
+  range in the original source
 * name: {string}
 
+The returned value represents the raw range as it appears in the
+SourceMap, based on zero-indexed offsets, _not_ 1-indexed line and
+column numbers as they appear in Error messages and CallSite
+objects.
+
+To get the corresponding 1-indexed line and column numbers from a
+lineNumber and columnNumber as they are reported by Error stacks
+and CallSite objects, use `sourceMap.findOrigin(lineNumber,
+columnNumber)`
+
+#### `sourceMap.findOrigin(lineNumber, columnNumber)`
+
+* `lineNumber` {number} The 1-indexed line number of the call
+  site in the generated source
+* `columnOffset` {number} The 1-indexed column number
+  of the call site in the generated source
+* Returns: {Object}
+
+Given a 1-indexed lineNumber and columnNumber from a call site in
+the generated source, find the corresponding call site location
+in the original source.
+
+If the lineNumber and columnNumber provided are not found in any
+source map, then an empty object is returned.  Otherwise, the
+returned object contains the following keys:
+
+* name: {string | undefined} The name of the range in the
+  source map, if one was provided
+* fileName: {string} The file name of the original source, as
+  reported in the SourceMap
+* lineNumber: {number} The 1-indexed lineNumber of the
+  corresponding call site in the original source
+* columnNumber: {number} The 1-indexed columnNumber of the
+  corresponding call site in the original source
+
 [CommonJS]: modules.md
 [ES Modules]: esm.md
 [Source map v3 format]: https://sourcemaps.info/spec.html#h.mofvlxcwqzej

filename: lib/internal/source_map/source_map.js
patch: @@ -169,28 +169,28 @@ class SourceMap {
   };
 
   /**
-   * @param {number} lineNumber in compiled resource
-   * @param {number} columnNumber in compiled resource
-   * @return {?Array}
+   * @param {number} lineOffset 0-indexed line offset in compiled resource
+   * @param {number} columnOffset 0-indexed column offset in compiled resource
+   * @return {object} representing start of range if found, or empty object
    */
-  findEntry(lineNumber, columnNumber) {
+  findEntry(lineOffset, columnOffset) {
     let first = 0;
     let count = this.#mappings.length;
     while (count > 1) {
       const step = count >> 1;
       const middle = first + step;
       const mapping = this.#mappings[middle];
-      if (lineNumber < mapping[0] ||
-          (lineNumber === mapping[0] && columnNumber < mapping[1])) {
+      if (lineOffset < mapping[0] ||
+          (lineOffset === mapping[0] && columnOffset < mapping[1])) {
         count = step;
       } else {
         first = middle;
         count -= step;
       }
     }
     const entry = this.#mappings[first];
-    if (!first && entry && (lineNumber < entry[0] ||
-        (lineNumber === entry[0] && columnNumber < entry[1]))) {
+    if (!first && entry && (lineOffset < entry[0] ||
+        (lineOffset === entry[0] && columnOffset < entry[1]))) {
       return {};
     } else if (!entry) {
       return {};
@@ -205,6 +205,32 @@ class SourceMap {
     };
   }
 
+  /**
+   * @param {number} lineNumber 1-indexed line number in compiled resource call site
+   * @param {number} columnNumber 1-indexed column number in compiled resource call site
+   * @return {object} representing origin call site if found, or empty object
+   */
+  findOrigin(lineNumber, columnNumber) {
+    const range = this.findEntry(lineNumber - 1, columnNumber - 1);
+    if (
+      range.originalSource === undefined ||
+      range.originalLine === undefined ||
+      range.originalColumn === undefined ||
+      range.generatedLine === undefined ||
+      range.generatedColumn === undefined
+    ) {
+      return {};
+    }
+    const lineOffset = lineNumber - range.generatedLine;
+    const columnOffset = columnNumber - range.generatedColumn;
+    return {
+      name: range.name,
+      fileName: range.originalSource,
+      lineNumber: range.originalLine + lineOffset,
+      columnNumber: range.originalColumn + columnOffset,
+    };
+  }
+
   /**
    * @override
    */

filename: test/parallel/test-source-map-api.js
patch: @@ -49,6 +49,14 @@ const { readFileSync } = require('fs');
   assert.strictEqual(originalLine, 2);
   assert.strictEqual(originalColumn, 4);
   assert(originalSource.endsWith('disk.js'));
+  const {
+    fileName,
+    lineNumber,
+    columnNumber,
+  } = sourceMap.findOrigin(1, 30);
+  assert.strictEqual(fileName, originalSource);
+  assert.strictEqual(lineNumber, 3);
+  assert.strictEqual(columnNumber, 6);
 }
 
 // findSourceMap() can be used in Error.prepareStackTrace() to lookup
@@ -89,6 +97,18 @@ const { readFileSync } = require('fs');
   assert.strictEqual(originalLine, 17);
   assert.strictEqual(originalColumn, 10);
   assert(originalSource.endsWith('typescript-throw.ts'));
+
+  const {
+    fileName,
+    lineNumber,
+    columnNumber,
+  } = sourceMap.findOrigin(
+    callSite.getLineNumber(),
+    callSite.getColumnNumber()
+  );
+  assert.strictEqual(fileName, originalSource);
+  assert.strictEqual(lineNumber, 18);
+  assert.strictEqual(columnNumber, 11);
 }
 
 // SourceMap can be instantiated with Source Map V3 object as payload.
@@ -112,8 +132,8 @@ const { readFileSync } = require('fs');
   assert.notStrictEqual(payload.sources, sourceMap.payload.sources);
 }
 
-// findEntry() must return empty object instead error when
-// receive a malformed mappings.
+// findEntry() and findOrigin() must return empty object instead of
+// error when receiving a malformed mappings.
 {
   const payload = JSON.parse(readFileSync(
     require.resolve('../fixtures/source-map/disk.map'), 'utf8'
@@ -124,6 +144,9 @@ const { readFileSync } = require('fs');
   const result = sourceMap.findEntry(0, 5);
   assert.strictEqual(typeof result, 'object');
   assert.strictEqual(Object.keys(result).length, 0);
+  const origin = sourceMap.findOrigin(0, 5);
+  assert.strictEqual(typeof origin, 'object');
+  assert.strictEqual(Object.keys(origin).length, 0);
 }
 
 // SourceMap can be instantiated with Index Source Map V3 object as payload.