Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 39 additions & 14 deletions src/lib/chunkify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ test('creates chunks with outer chunks covered', () => {
let coverage = {
text: 'a { color: red; } b { color: green; } c { color: blue; }',
ranges: [
{ start: 0, end: 17 },
{ start: 38, end: 56 },
{ start: 0, end: 17, count: 1 },
{ start: 38, end: 56, count: 1 },
],
url: 'https://example.com',
}
Expand All @@ -19,16 +19,19 @@ test('creates chunks with outer chunks covered', () => {
start_offset: 0,
end_offset: 17,
is_covered: true,
coverage_count: 1,
},
{
start_offset: 17,
end_offset: 38,
is_covered: false,
coverage_count: 0,
},
{
start_offset: 38,
end_offset: 56,
is_covered: true,
coverage_count: 1,
},
],
} satisfies ChunkedCoverage)
Expand All @@ -37,7 +40,7 @@ test('creates chunks with outer chunks covered', () => {
test('creates chunks with only middle chunk covered', () => {
let coverage = {
text: 'a { color: red; } b { color: green; } c { color: blue; }',
ranges: [{ start: 17, end: 38 }],
ranges: [{ start: 17, end: 38, count: 1 }],
url: 'https://example.com',
}
let result = chunkify(coverage)
Expand All @@ -49,16 +52,19 @@ test('creates chunks with only middle chunk covered', () => {
start_offset: 0,
end_offset: 17,
is_covered: false,
coverage_count: 0,
},
{
start_offset: 17,
end_offset: 38,
is_covered: true,
coverage_count: 1,
},
{
start_offset: 38,
end_offset: 56,
is_covered: false,
coverage_count: 0,
},
],
} satisfies ChunkedCoverage)
Expand All @@ -67,7 +73,7 @@ test('creates chunks with only middle chunk covered', () => {
test('creates a single chunk when all is covered', () => {
let coverage = {
text: 'a { color: red; } b { color: green; } c { color: blue; }',
ranges: [{ start: 0, end: 56 }],
ranges: [{ start: 0, end: 56, count: 1 }],
url: 'https://example.com',
}
let result = chunkify(coverage)
Expand All @@ -79,6 +85,7 @@ test('creates a single chunk when all is covered', () => {
start_offset: 0,
end_offset: 56,
is_covered: true,
coverage_count: 1,
},
],
} satisfies ChunkedCoverage)
Expand All @@ -99,6 +106,7 @@ test('creates a single chunk when none is covered', () => {
start_offset: 0,
end_offset: 56,
is_covered: false,
coverage_count: 0,
},
],
} satisfies ChunkedCoverage)
Expand All @@ -108,16 +116,16 @@ test('includes a trailing uncovered chunk when the last byte is not covered', ()
// text length = 4; range covers first 3 bytes, leaving the last byte uncovered
let coverage = {
text: 'abcd',
ranges: [{ start: 0, end: 3 }],
ranges: [{ start: 0, end: 3, count: 1 }],
url: 'https://example.com',
}
let result = chunkify(coverage)
delete coverage.ranges
expect(result).toEqual({
...coverage,
chunks: [
{ start_offset: 0, end_offset: 3, is_covered: true },
{ start_offset: 3, end_offset: 4, is_covered: false },
{ start_offset: 0, end_offset: 3, is_covered: true, coverage_count: 1 },
{ start_offset: 3, end_offset: 4, is_covered: false, coverage_count: 0 },
],
} satisfies ChunkedCoverage)
})
Expand All @@ -126,14 +134,14 @@ test('does not emit a spurious empty chunk when the last byte is covered', () =>
// range covers the full text — no trailing chunk should appear
let coverage = {
text: 'abcd',
ranges: [{ start: 0, end: 4 }],
ranges: [{ start: 0, end: 4, count: 1 }],
url: 'https://example.com',
}
let result = chunkify(coverage)
delete coverage.ranges
expect(result).toEqual({
...coverage,
chunks: [{ start_offset: 0, end_offset: 4, is_covered: true }],
chunks: [{ start_offset: 0, end_offset: 4, is_covered: true, coverage_count: 1 }],
} satisfies ChunkedCoverage)
})

Expand All @@ -145,16 +153,16 @@ test('merges adjacent same-coverage chunks separated by whitespace-only gap', ()
text: 'a{color:red}\n\nb{color:blue}',
// ^12 ^14 — the \n\n gap is whitespace-only
ranges: [
{ start: 0, end: 12 },
{ start: 14, end: 27 },
{ start: 0, end: 12, count: 1 },
{ start: 14, end: 27, count: 1 },
],
url: 'https://example.com',
}
let result = chunkify(coverage)
delete coverage.ranges
expect(result).toEqual({
...coverage,
chunks: [{ start_offset: 0, end_offset: 27, is_covered: true }],
chunks: [{ start_offset: 0, end_offset: 27, is_covered: true, coverage_count: 1 }],
} satisfies ChunkedCoverage)
})

Expand All @@ -163,13 +171,30 @@ test('absorbs a zero-length covered chunk into the surrounding uncovered chunk',
// The empty chunk should not appear in the output.
let coverage = {
text: 'a{color:red}',
ranges: [{ start: 5, end: 5 }],
ranges: [{ start: 5, end: 5, count: 1 }],
url: 'https://example.com',
}
let result = chunkify(coverage)
delete coverage.ranges
expect(result).toEqual({
...coverage,
chunks: [{ start_offset: 0, end_offset: 12, is_covered: false }],
chunks: [{ start_offset: 0, end_offset: 12, is_covered: false, coverage_count: 0 }],
} satisfies ChunkedCoverage)
})

test('merges adjacent covered chunks with different coverage counts, keeping the max', () => {
let coverage = {
text: 'a{color:red}b{color:blue}',
ranges: [
{ start: 0, end: 12, count: 2 },
{ start: 12, end: 25, count: 1 },
],
url: 'https://example.com',
}
let result = chunkify(coverage)
delete coverage.ranges
expect(result).toEqual({
...coverage,
chunks: [{ start_offset: 0, end_offset: 25, is_covered: true, coverage_count: 2 }],
} satisfies ChunkedCoverage)
})
21 changes: 16 additions & 5 deletions src/lib/chunkify.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { tokenize } from '@projectwallace/css-parser/tokenizer'
import type { Coverage } from './parse-coverage'
import type { WeightedCoverage } from './decuplicate.js'

type Chunk = {
start_offset: number
end_offset: number
coverage_count: number
is_covered: boolean
}

export type ChunkedCoverage = Omit<Coverage, 'ranges'> & {
export type ChunkedCoverage = Omit<WeightedCoverage, 'ranges'> & {
chunks: Chunk[]
}

Expand All @@ -27,10 +28,14 @@ function merge(stylesheet: ChunkedCoverage): ChunkedCoverage {

let latest_chunk = new_chunks.at(-1)

// merge current and previous if they are both covered or uncovered
// merge current and previous if they have the same coverage status
if (i > 0 && previous_chunk && latest_chunk) {
if (previous_chunk.is_covered === chunk.is_covered) {
latest_chunk.end_offset = chunk.end_offset
// keep the highest count seen across merged covered chunks
if (chunk.coverage_count > latest_chunk.coverage_count) {
latest_chunk.coverage_count = chunk.coverage_count
}
previous_chunk = chunk
continue
}
Expand Down Expand Up @@ -80,12 +85,14 @@ export function mark_comments_as_covered(stylesheet: ChunkedCoverage): ChunkedCo
start_offset: chunk.start_offset + last_end,
end_offset: chunk.start_offset + comment.start,
is_covered: false,
coverage_count: 0,
})
}
new_chunks.push({
start_offset: chunk.start_offset + comment.start,
end_offset: chunk.start_offset + comment.end,
is_covered: true,
coverage_count: 1,
})
last_end = comment.end
}
Expand All @@ -95,15 +102,16 @@ export function mark_comments_as_covered(stylesheet: ChunkedCoverage): ChunkedCo
start_offset: chunk.start_offset + last_end,
end_offset: chunk.end_offset,
is_covered: false,
coverage_count: 0,
})
}
}

return merge({ ...stylesheet, chunks: new_chunks })
}

export function chunkify(stylesheet: Coverage): ChunkedCoverage {
let chunks = []
export function chunkify(stylesheet: WeightedCoverage): ChunkedCoverage {
let chunks: Chunk[] = []
let offset = 0

for (let range of stylesheet.ranges) {
Expand All @@ -113,6 +121,7 @@ export function chunkify(stylesheet: Coverage): ChunkedCoverage {
start_offset: offset,
end_offset: range.start,
is_covered: false,
coverage_count: 0,
})
offset = range.start
}
Expand All @@ -121,6 +130,7 @@ export function chunkify(stylesheet: Coverage): ChunkedCoverage {
start_offset: range.start,
end_offset: range.end,
is_covered: true,
coverage_count: range.count,
})
offset = range.end
}
Expand All @@ -131,6 +141,7 @@ export function chunkify(stylesheet: Coverage): ChunkedCoverage {
start_offset: offset,
end_offset: stylesheet.text.length,
is_covered: false,
coverage_count: 0,
})
}

Expand Down
52 changes: 36 additions & 16 deletions src/lib/decuplicate.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,48 @@
import type { Coverage, Range } from './parse-coverage.js'

// 1. Merge and concatenate ranges
function merge_ranges(ranges: Range[]): Range[] {
export type WeightedRange = Range & { count: number }
export type WeightedCoverage = Omit<Coverage, 'ranges'> & { ranges: WeightedRange[] }

// 1. Sweep-line merge: produces weighted ranges where count = number of input ranges covering each segment
function merge_ranges_weighted(ranges: Range[]): WeightedRange[] {
if (ranges.length === 0) return []

// sort by start
ranges.sort((a, b) => a.start - b.start)
type Event = { pos: number; delta: number }
let events: Event[] = []

for (let r of ranges) {
events.push({ pos: r.start, delta: +1 })
events.push({ pos: r.end, delta: -1 })
}

// sort by position; closes (-1) before opens (+1) at the same position
events.sort((a, b) => a.pos - b.pos || a.delta - b.delta)

let merged: Range[] = [ranges[0]!]
let swept: WeightedRange[] = []
let depth = 0
let prev_pos: number | null = null

for (let r of ranges.slice(1)) {
let last = merged.at(-1)
for (let event of events) {
if (prev_pos !== null && event.pos > prev_pos && depth > 0) {
swept.push({ start: prev_pos, end: event.pos, count: depth })
}
depth += event.delta
prev_pos = event.pos
}

// merge overlapping or adjacent
if (last && r.start <= last.end + 1) {
if (r.end > last.end) {
last.end = r.end
}
// Merge adjacent segments (up to 1-byte gap) with the same count, preserving the
// original behaviour where ranges touching at r.start <= last.end + 1 were merged.
let result: WeightedRange[] = swept.length > 0 ? [{ ...swept[0]! }] : []
for (let r of swept.slice(1)) {
let last = result.at(-1)!
if (r.start <= last.end + 1 && r.count === last.count) {
if (r.end > last.end) last.end = r.end
} else {
merged.push({ start: r.start, end: r.end })
result.push({ ...r })
}
}

return merged
return result
}

// 2. Merge ranges for a single stylesheet entry into an existing grouped sheet
Expand All @@ -42,7 +62,7 @@ function merge_entry_ranges(
}

// 3. Main function orchestrating the grouping and range merging
export function deduplicate_entries(entries: Coverage[]): Coverage[] {
export function deduplicate_entries(entries: Coverage[]): WeightedCoverage[] {
let grouped = entries.reduce<Record<string, { url: string; ranges: Range[] }>>((acc, entry) => {
let key = entry.text
acc[key] = merge_entry_ranges(acc[key], entry)
Expand All @@ -52,6 +72,6 @@ export function deduplicate_entries(entries: Coverage[]): Coverage[] {
return Object.entries(grouped).map(([text, { url, ranges }]) => ({
text,
url,
ranges: merge_ranges(ranges),
ranges: merge_ranges_weighted(ranges),
}))
}
Loading
Loading