Skip to content
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

Add support for 1:many source maps #91

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
182 changes: 119 additions & 63 deletions lib/source.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const CovLine = require('./line')
const { GREATEST_LOWER_BOUND, LEAST_UPPER_BOUND } = require('source-map').SourceMapConsumer

module.exports = class CovSource {
constructor (sourceRaw, wrapperLength) {
constructor(sourceRaw, wrapperLength) {
sourceRaw = sourceRaw.trimEnd()
this.lines = []
this.eof = sourceRaw.length
Expand Down Expand Up @@ -47,50 +47,116 @@ module.exports = class CovSource {

// given a start column and end column in absolute offsets within
// a source file (0 - EOF), returns the relative line column positions.
offsetToOriginalRelative (sourceMap, startCol, endCol) {
offsetToOriginalRelative (sourceMapScanner, startCol, endCol) {
const lines = this.lines.filter((line, i) => {
return startCol <= line.endCol && endCol >= line.startCol
})
if (!lines.length) return {}

const start = originalPositionTryBoth(
sourceMap,
lines[0].line,
startCol - lines[0].startCol
)
let end = originalEndPositionFor(
sourceMap,
lines[lines.length - 1].line,
endCol - lines[lines.length - 1].startCol
)

if (!(start && end)) {
return {}
if (!lines.length) {
return []
}

if (!(start.source && end.source)) {
return {}
}
const sourceMapIterator = sourceMapScanner.getIterator()
let start = sourceMapIterator.scanTo(lines[0].line, startCol - lines[0].startCol)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm understanding the goal of this PR, it's to make it so that we can handle a source map for a library like uglify, which might translate one compiled file into many source files?

It feels like the logic for each individual source file should remain almost the same? I wonder if we could abstract this logic out a layer, such that the top level class has an array of CovSource objects produced by some sort of factory.

let last = start
let endPos = { line: lines[lines.length - 1].line, col: endCol - lines[lines.length - 1].startCol }
let returnLocs = []
while (true) {
let next = sourceMapIterator.next()
const isPastEnd = !next || sourceMapIterator.isGeneratedAfter(next, endPos.line, endPos.col)

if (isPastEnd || next.source !== start.source ||
next.originalLine < start.originalLine ||
(next.originalLine === start.originalLine && next.originalColumn < start.originalColumn)) {
if (last !== start) {
returnLocs.push({
sourceFile: start.source,
startLine: start.originalLine,
relStartCol: start.originalColumn,
endLine: last.originalLine,
relEndCol: last.originalColumn
})
}
if (isPastEnd) {
break
}
start = next
} else {

if (start.source !== end.source) {
return {}
}
// now we now we aren't going past the start (or different source) or the end etc.
// if the next token is going back past the last, ignore it.
if (next.originalLine < last.originalLine ||
(next.originalLine === last.originalLine && next.originalColumn < last.originalColumn)) {
continue
}
}

if (start.line === end.line && start.column === end.column) {
end = sourceMap.originalPositionFor({
line: lines[lines.length - 1].line,
column: endCol - lines[lines.length - 1].startCol,
bias: LEAST_UPPER_BOUND
})
end.column -= 1
last = next
}
return this._removeOverlapping(returnLocs)
}

return {
startLine: start.line,
relStartCol: start.column,
endLine: end.line,
relEndCol: end.column
// minimize overlapping ranges to avoid bumping up the branches/functions count unless very necessary
_removeOverlapping (returnLocs) {
if (returnLocs.length <= 1) {
return returnLocs
}
const bySource = Object.create(null)
returnLocs.forEach((loc) => {
if (!bySource[loc.source]) {
bySource[loc.source] = []
}
bySource[loc.source].push(loc)
})

const dedupedLocs = []
Object.keys(bySource).forEach((source) => {
let locs = bySource[source]
while (locs.length) {
const loc1 = locs.pop()
let isOverlapped = false

for (let i = 0; i < locs.length; i++) {
const loc2 = locs[i]
if (loc2 === loc1) {
return
}

let isEndLoc1BeforeLoc2Start = ((loc1.endLine < loc2.startLine) || (loc1.endLine === loc2.startLine && loc1.relEndCol < loc2.relStartCol))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the SourceMap generator be making an effort to avoid overlapping source files? I wonder if we could add this logic later.

let isEndLoc2BeforeLoc1Start = ((loc2.endLine < loc1.startLine) || (loc1.endLine === loc2.startLine && loc2.relEndCol < loc1.relStartCol))

// |A...|B...|A..|B
// |A...|B...|B..|A
// |B...|A...|B..|A
// |B...|A...|A..|B
// |A...|A...|B..|B // isEndLoc1BeforeLoc2Start
// |B...|B...|A..|A // isEndLoc2BeforeLoc1Start

if (!(isEndLoc1BeforeLoc2Start || isEndLoc2BeforeLoc1Start)) {
const isStartAfter = ((loc1.startLine > loc2.startLine) || (loc1.startLine === loc2.startLine && loc1.relStartCol >= loc2.relStartCol))
const isEndAfter = ((loc1.endLine > loc2.endLine) || (loc1.endLine === loc2.endLine && loc1.relEndCol >= loc2.relEndCol))

const start = isStartAfter ? loc2 : loc1
const end = isEndAfter ? loc1 : loc2
locs.splice(i, 1)
locs.push({
source: start.source,
startLine: start.startLine,
relStartCol: start.relStartCol,
endLine: end.endLine,
relEndCol: end.relEndCol,
})
isOverlapped = true
break
}
}

if (!isOverlapped) {
dedupedLocs.push(loc1)
}
}
})

return dedupedLocs
}

relativeToOffset (line, relCol) {
Expand Down Expand Up @@ -127,11 +193,11 @@ function originalEndPositionFor (sourceMap, line, column) {
// generated file end location. Note however that this position on its
// own is not useful because it is the position of the _start_ of the range
// on the original file, and we want the _end_ of the range.
const beforeEndMapping = originalPositionTryBoth(
sourceMap,
const beforeEndMapping = sourceMap.originalPositionFor({
line,
Math.max(column - 1, 1)
)
column,
bias: LEAST_UPPER_BOUND
})

if (beforeEndMapping.source === null) {
return null
Expand All @@ -146,17 +212,19 @@ function originalEndPositionFor (sourceMap, line, column) {
source: beforeEndMapping.source,
line: beforeEndMapping.line,
column: beforeEndMapping.column + 1,
bias: LEAST_UPPER_BOUND
bias: GREATEST_LOWER_BOUND
lukeapage marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic has been thoroughly tested in c8, with several real world generators (take a look at the tests here).

I don't think we want to fiddle with this logic, as it grows out of a lot of testing.

})
const afterOriginalPos = afterEndMapping.line !== null && sourceMap.originalPositionFor(afterEndMapping)

if (
// If this is null, it means that we've hit the end of the file,
// so we can use Infinity as the end column.
// If this is null, it means that we've hit the end of the file,
// so we can use Infinity as the end column.
afterEndMapping.line === null ||
// If these don't match, it means that the call to
// 'generatedPositionFor' didn't find any other original mappings on
// the line we gave, so consider the binding to extend to infinity.
sourceMap.originalPositionFor(afterEndMapping).line !==
beforeEndMapping.line
// If these don't match, it means that the call to
// 'generatedPositionFor' didn't find any other original mappings on
// the line we gave, so consider the binding to extend to infinity.
afterOriginalPos.line !== beforeEndMapping.line ||
afterOriginalPos.source !== beforeEndMapping.source
) {
return {
source: beforeEndMapping.source,
Expand All @@ -166,23 +234,11 @@ function originalEndPositionFor (sourceMap, line, column) {
}

// Convert the end mapping into the real original position.
return sourceMap.originalPositionFor(afterEndMapping)
}

function originalPositionTryBoth (sourceMap, line, column) {
const original = sourceMap.originalPositionFor({
line,
column,
bias: GREATEST_LOWER_BOUND
})
if (original.line === null) {
return sourceMap.originalPositionFor({
line,
column,
bias: LEAST_UPPER_BOUND
})
} else {
return original
return {
source: afterOriginalPos.source,
line: afterOriginalPos.line - (afterOriginalPos.column === 0 ? 1 : 0),
column: afterOriginalPos.column === 0 ? Infinity : afterOriginalPos.column
}
}

Expand Down
74 changes: 74 additions & 0 deletions lib/sourcemapScanner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
class SourceMapScannerIterator {
constructor(mappings) {
this.mappings = mappings
this.index = 0
}

_binarySearchStart (startLine, startCol, startIndex, endIndex) {
if (startIndex === endIndex) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the addition of this sourcemapScanner class.

This is kind of what I had in mind with regards to pulling the logic for creating multiple CovSource instances into some sort of factory, and trying to keep additional logic to a minimal in that file.

return startIndex
}
const midPoint = Math.floor((endIndex + startIndex) / 2)
if (this.isGeneratedAfter(this.mappings[midPoint], startLine, startCol)) {
// generated point is after point we are looking for, so exclude is (midPoint - 1)
return this._binarySearchStart(startLine, startCol, 0, Math.max(0, midPoint - 1))
} else {
// if we are down to 2 points
// find 4 in 1 3 5
// midpoint = 1 -> 3
// if 3 is after 4 - false
// 1,2
//
if (endIndex - midPoint === 1) {
return midPoint
}
// generated point is before the point we are looking for, so might be valid, so include it
return this._binarySearchStart(startLine, startCol, midPoint, endIndex)
}
}

isGeneratedAfter (mapping, line, col) {
return mapping.generatedLine > line ||
(mapping.generatedLine === line && mapping.generatedColumn > col)
}

scanTo (startLine, startCol) {
this.index = this._binarySearchStart(startLine, startCol, 0, this.mappings.length - 1)

// make sure we have the first mapping for this exact position
while (this.index > 0 &&
this.mappings[this.index].generatedLine === this.mappings[this.index - 1].generatedLine &&
this.mappings[this.index].generatedColumn === this.mappings[this.index - 1].generatedColumn) {
this.index--
}

return this.mappings[this.index]
}

next () {
return this.mappings[++this.index]
}

peek () {
return this.mappings[this.index + 1]
}
}

module.exports = class SourceMapScanner {
constructor(sourcemap) {
// [{ source: 'illmatic.js',
// generatedLine: 1,
// generatedColumn: 0,
// originalLine: 1,
// originalColumn: 0,
// name: null }]
this.mappings = []
sourcemap.eachMapping((mapping) => {
this.mappings.push(mapping)
})
}

getIterator () {
return new SourceMapScannerIterator(this.mappings)
}
}