diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 2f9bbdd..126bc55 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node -import { validate_arguments, parse_arguments, InvalidArgumentsError } from './arguments.js' -import { program, MissingDataError } from './program.js' +import { validate_arguments, parse_arguments } from './arguments.js' +import { program } from './program.js' import { read } from './file-reader.js' import { print as pretty } from './reporters/pretty.js' import { print as tap } from './reporters/tap.js' diff --git a/src/cli/reporters/pretty.ts b/src/cli/reporters/pretty.ts index b998e4b..ecaeced 100644 --- a/src/cli/reporters/pretty.ts +++ b/src/cli/reporters/pretty.ts @@ -64,30 +64,20 @@ export function print({ report, context }: Report, params: CliArguments) { console.log(styleText('dim', '─'.repeat(terminal_width))) let lines = sheet.text.split('\n') - let line_coverage = sheet.line_coverage - for (let i = 0; i < lines.length; i++) { - if (line_coverage[i] === 1) continue - - // Rewind cursor N lines to render N previous lines - for (let j = i - NUM_LEADING_LINES; j < i; j++) { - // Make sure that we don't try to start before line 0 - if (j >= 0) { - console.log(styleText('dim', line_number(j)), styleText('dim', indent(lines[j]))) - } + for (let chunk of sheet.chunks.filter((chunk) => !chunk.is_covered)) { + // Render N leading lines + for (let x = Math.max(chunk.start_line - NUM_LEADING_LINES, 0); x < chunk.start_line; x++) { + console.log(styleText('dim', line_number(x)), styleText('dim', indent(lines[x - 1]))) } - - // Render uncovered lines while increasing cursor until reaching next covered block - while (line_coverage[i] === 0) { - console.log(styleText('red', line_number(i, false)), indent(lines[i])) - i++ + // Render the uncovered chunk + for (let i = chunk.start_line; i <= chunk.end_line; i++) { + console.log(styleText('red', line_number(i, false)), indent(lines[i - 1])) } - - // Forward cursor N lines to render N trailing lines - for (let end = i + NUM_TRAILING_LINES; i < end && i < lines.length; i++) { - console.log(styleText('dim', line_number(i)), styleText('dim', indent(lines[i]))) + // Render N trailing lines + for (let y = chunk.end_line; y < Math.min(chunk.end_line + NUM_TRAILING_LINES, lines.length); y++) { + console.log(styleText('dim', line_number(y)), styleText('dim', indent(lines[y - 1]))) } - // Show empty line between blocks console.log() } diff --git a/src/lib/chunkify.test.ts b/src/lib/chunkify.test.ts new file mode 100644 index 0000000..3de5bb4 --- /dev/null +++ b/src/lib/chunkify.test.ts @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test' +import { chunkify, type ChunkedCoverage } from './chunkify' + +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 }, + ], + url: 'https://example.com', + } + let result = chunkify(coverage) + delete coverage.ranges + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 17, + is_covered: true, + }, + { + start_offset: 17, + end_offset: 38, + is_covered: false, + }, + { + start_offset: 38, + end_offset: 56, + is_covered: true, + }, + ], + } satisfies ChunkedCoverage) +}) + +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 }], + url: 'https://example.com', + } + let result = chunkify(coverage) + delete coverage.ranges + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 17, + is_covered: false, + }, + { + start_offset: 17, + end_offset: 38, + is_covered: true, + }, + { + start_offset: 38, + end_offset: 56, + is_covered: false, + }, + ], + } satisfies ChunkedCoverage) +}) + +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 }], + url: 'https://example.com', + } + let result = chunkify(coverage) + delete coverage.ranges + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 56, + is_covered: true, + }, + ], + } satisfies ChunkedCoverage) +}) + +test('creates a single chunk when none is covered', () => { + let coverage = { + text: 'a { color: red; } b { color: green; } c { color: blue; }', + ranges: [], + url: 'https://example.com', + } + let result = chunkify(coverage) + delete coverage.ranges + expect(result).toEqual({ + ...coverage, + chunks: [ + { + start_offset: 0, + end_offset: 56, + is_covered: false, + }, + ], + } satisfies ChunkedCoverage) +}) diff --git a/src/lib/chunkify.ts b/src/lib/chunkify.ts new file mode 100644 index 0000000..31278d0 --- /dev/null +++ b/src/lib/chunkify.ts @@ -0,0 +1,91 @@ +import type { Coverage } from './parse-coverage' + +type Chunk = { + start_offset: number + end_offset: number + is_covered: boolean +} + +export type ChunkedCoverage = Omit & { + chunks: Chunk[] +} + +function merge(stylesheet: ChunkedCoverage): ChunkedCoverage { + let new_chunks: Chunk[] = [] + let previous_chunk: Chunk | undefined + + for (let i = 0; i < stylesheet.chunks.length; i++) { + let chunk = stylesheet.chunks.at(i)! + + // If the current chunk is only whitespace or empty, ignore it + if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset))) { + continue + } + + let latest_chunk = new_chunks.at(-1) + + // merge current and previous if they are both covered or uncovered + if (i > 0 && previous_chunk && latest_chunk) { + if (previous_chunk.is_covered === chunk.is_covered) { + latest_chunk.end_offset = chunk.end_offset + previous_chunk = chunk + continue + } + // If the current chunk is only whitespace or empty, add it to the previous + else if (/^\s+$/.test(stylesheet.text.slice(chunk.start_offset, chunk.end_offset)) || chunk.end_offset === chunk.start_offset) { + latest_chunk.end_offset = chunk.end_offset + // do not update previous_chunk + continue + } + } + + previous_chunk = chunk + new_chunks.push(chunk) + } + + return { + ...stylesheet, + chunks: new_chunks, + } +} + +export function chunkify(stylesheet: Coverage): ChunkedCoverage { + let chunks = [] + let offset = 0 + + for (let range of stylesheet.ranges) { + // Create non-covered chunk + if (offset !== range.start) { + chunks.push({ + start_offset: offset, + end_offset: range.start, + is_covered: false, + }) + offset = range.start + } + + chunks.push({ + start_offset: range.start, + end_offset: range.end, + is_covered: true, + }) + offset = range.end + } + + // fill up last chunk if necessary: + if (offset !== stylesheet.text.length - 1) { + chunks.push({ + start_offset: offset, + end_offset: stylesheet.text.length, + is_covered: false, + }) + } + + let merged = merge({ + url: stylesheet.url, + text: stylesheet.text, + chunks, + }) + + return merged +} diff --git a/src/lib/css-tree.d.ts b/src/lib/css-tree.d.ts deleted file mode 100644 index bb1ad45..0000000 --- a/src/lib/css-tree.d.ts +++ /dev/null @@ -1,61 +0,0 @@ -declare module 'css-tree/tokenizer' { - export function tokenize(css: string, callback: (type: number, start: number, end: number) => void): void - - // css-tree tokens: https://github.com/csstree/csstree/blob/be5ea1257009960c04cccdb58bb327263e27e3b3/lib/tokenizer/types.js - // https://www.w3.org/TR/css-syntax-3/ - export const EOF = 0 // - export const Ident = 1 // - export const Function = 2 // - export const AtKeyword = 3 // - export const Hash = 4 // - export const String = 5 // - export const BadString = 6 // - export const Url = 7 // - export const BadUrl = 8 // - export const Delim = 9 // - export const Number = 10 // - export const Percentage = 11 // - export const Dimension = 12 // - export const WhiteSpace = 13 // - export const CDO = 14 // - export const CDC = 15 // - export const Colon = 16 // : - export const Semicolon = 17 // ; - export const Comma = 18 // , - export const LeftSquareBracket = 19 // <[-token> - export const RightSquareBracket = 20 // <]-token> - export const LeftParenthesis = 21 // <(-token> - export const RightParenthesis = 22 // <)-token> - export const LeftCurlyBracket = 23 // <{-token> - export const RightCurlyBracket = 24 // <}-token> - export const Comment = 25 - - export const tokenTypes = { - EOF, - Ident, - Function, - AtKeyword, - Hash, - String, - BadString, - Url, - BadUrl, - Delim, - Number, - Percentage, - Dimension, - WhiteSpace, - CDO, - CDC, - Colon, - Semicolon, - Comma, - LeftSquareBracket, - RightSquareBracket, - LeftParenthesis, - RightParenthesis, - LeftCurlyBracket, - RightCurlyBracket, - Comment, - } -} diff --git a/src/lib/decuplicate.ts b/src/lib/decuplicate.ts index 42260c1..5afcdd7 100644 --- a/src/lib/decuplicate.ts +++ b/src/lib/decuplicate.ts @@ -1,4 +1,61 @@ import type { Coverage, Range } from './parse-coverage.js' + +// Combine multiple adjecent ranges into a single one +export function concatenate(ranges: Set | Range[]): Range[] { + let result: Range[] = [] + + for (let range of ranges) { + // Update the last range if this range starts at last-range-end + 1 + if (result.length > 0 && (result.at(-1)!.end === range.start - 1 || result.at(-1)!.end === range.start)) { + result.at(-1)!.end = range.end + } else { + result.push(range) + } + } + + return result +} + +function dedupe_list(ranges: Range[]): Set { + let new_ranges: Set = new Set() + + outer: for (let range of ranges) { + for (let processed_range of new_ranges) { + // Case: an existing range fits within this range -> replace it + // { start: 0, end: 100 }, + // { start: 0, end: 200 } + if (range.start <= processed_range.start && range.end >= processed_range.end) { + new_ranges.delete(processed_range) + new_ranges.add(range) + continue outer + } + + // Case: this range fits within an existing range -> skip it + // { start: 324, end: 485 }, --> exists + // { start: 364, end: 485 }, --> skip + // { start: 404, end: 485 }, --> skip + if (range.start >= processed_range.start && range.end <= processed_range.end) { + // console.log('skip', range) + continue outer + } + // Case: ranges partially overlap + // { start: 324, end: 444 }, + // { start: 364, end: 485 }, + if (range.start < processed_range.end && range.start > processed_range.start && range.end > processed_range.end) { + new_ranges.delete(processed_range) + new_ranges.add({ + start: processed_range.start, + end: range.end, + }) + continue outer + } + } + new_ranges.add(range) + } + + return new_ranges +} + /** * @description * prerequisites @@ -18,12 +75,15 @@ export function deduplicate_entries(entries: Coverage[]): Coverage[] { // If not, add them for (let range of entry.ranges) { let found = false + for (let checked_range of ranges) { + // find exact range if (checked_range.start === range.start && checked_range.end === range.end) { found = true break } } + if (!found) { ranges.push(range) } @@ -36,5 +96,9 @@ export function deduplicate_entries(entries: Coverage[]): Coverage[] { } } - return Array.from(checked_stylesheets, ([text, { url, ranges }]) => ({ text, url, ranges })) + return Array.from(checked_stylesheets, ([text, { url, ranges }]) => ({ + text, + url, + ranges: concatenate(dedupe_list(ranges.sort((a, b) => a.start - b.start))).sort((a, b) => a.start - b.start), + })) } diff --git a/src/lib/deduplicate.test.ts b/src/lib/deduplicate.test.ts index f59c4d0..8433522 100644 --- a/src/lib/deduplicate.test.ts +++ b/src/lib/deduplicate.test.ts @@ -50,7 +50,7 @@ test('merges different ranges on identical CSS, different URLs', () => { }, ] let first = entries.at(0)! - expect(deduplicate_entries(entries)).toEqual([{ text: first.text, url: first.url, ranges: [first.ranges[0], entries[1]!.ranges[0]] }]) + expect(deduplicate_entries(entries)).toEqual([{ text: first.text, url: first.url, ranges: [{ start: 0, end: 9 }] }]) }) test('merges different ranges on identical CSS, identical URLs', () => { @@ -66,9 +66,7 @@ test('merges different ranges on identical CSS, identical URLs', () => { url: 'example.com', }, ] - expect(deduplicate_entries(entries)).toEqual([ - { text: entries[0]!.text, url: entries[0]!.url, ranges: [entries[0]!.ranges[0], entries[1]!.ranges[0]] }, - ]) + expect(deduplicate_entries(entries)).toEqual([{ text: entries[0]!.text, url: entries[0]!.url, ranges: [{ start: 0, end: 9 }] }]) }) test('does not merge different CSS with different URLs and identical ranges', () => { diff --git a/src/lib/extend-ranges.test.ts b/src/lib/extend-ranges.test.ts new file mode 100644 index 0000000..d25dd6d --- /dev/null +++ b/src/lib/extend-ranges.test.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test' +import { extend_ranges } from './extend-ranges' +import { generate_coverage } from './test/generate-coverage' +import type { Coverage } from './parse-coverage' + +let html = ` + + + + test document + + + +

Hello world

+

Text

+ +` + +test.describe('leaves ranges intact when nothing to change', () => { + test('lonely rule', async () => { + let css = `body{color:green}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage.at(0)!.ranges).toEqual([{ start: 0, end: 17 }]) + + let result = extend_ranges(coverage) + expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 17 }]) + }) +}) + +test.describe('@rules', () => { + test('lonely @media', async () => { + let css = `@media (min-width:100px){body{color:green}}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage.at(0)!.ranges).toEqual([{ start: 7, end: 42 }]) // (min-width:100px){body{color:green} + + let result = extend_ranges(coverage) + expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 43 }]) // @media (min-width:100px){body{color:green}} + }) + + test.describe('adjecent to uncovered code', () => { + test('@media at end', async () => { + let css = `a{}@media (min-width:100px){body{color:green}}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage.at(0)!.ranges).toEqual([{ start: 10, end: 45 }]) // (min-width:100px){body{color:green} + + let result = extend_ranges(coverage) + expect(result.at(0)!.ranges).toEqual([{ start: 3, end: 46 }]) // @media (min-width:100px){body{color:green}} + }) + + test('@media at start', async () => { + let css = `@media (min-width:100px){body{color:green}}a{}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage.at(0)!.ranges).toEqual([{ start: 7, end: 42 }]) // (min-width:100px){body{color:green} + + let result = extend_ranges(coverage) + expect(result.at(0)!.ranges).toEqual([{ start: 0, end: 43 }]) // @media (min-width:100px){body{color:green}} + }) + }) + + test.describe('adjecent to covered code', () => { + test('@media at end', async () => { + let css = `p{color:red}@media (min-width:100px){body{color:green}}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage).toEqual([ + { + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 12 }, // p{color:red} + { start: 19, end: 54 }, // (min-width:100px){body{color:green} + ], + }, + ]) + + let result = extend_ranges(coverage) + expect(result).toEqual([ + { + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 12 }, // p{color:red} + { start: 12, end: 55 }, // @media (min-width:100px){body{color:green}} + ], + }, + ]) + }) + + test('@media at start', async () => { + let css = `@media (min-width:100px){body{color:green}}p{color:red}` + let coverage = (await generate_coverage(html, { link_css: css })) as Coverage[] + + // Expect the incomplete coverage reported by the browser + expect(coverage).toEqual([ + { + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 7, end: 42 }, // (min-width:100px){body{color:green} + { start: 43, end: 55 }, // p{color:red} + ], + }, + ]) + + let result = extend_ranges(coverage) + expect(result).toEqual([ + { + url: 'http://localhost/style.css', + text: css, + ranges: [ + { start: 0, end: 43 }, // @media (min-width:100px){body{color:green}} + { start: 43, end: 55 }, // p{color:red} + ], + }, + ]) + }) + }) +}) diff --git a/src/lib/extend-ranges.ts b/src/lib/extend-ranges.ts new file mode 100644 index 0000000..afd6e48 --- /dev/null +++ b/src/lib/extend-ranges.ts @@ -0,0 +1,57 @@ +import type { Coverage } from './parse-coverage' + +const AT_SIGN = 64 +const LONGEST_ATRULE_NAME = '@-webkit-font-feature-values'.length + +export function extend_ranges(coverage: Coverage[]): Coverage[] { + return coverage.map(({ text, ranges, url }) => { + // Adjust ranges to include @-rule name (only preludes included) + // Cannot reliably include closing } because it may not be the end of the range + let new_ranges = ranges.map((range, index) => { + let prev_range = ranges[index - 1] + // Add @atrule-name to the front of the range + // Heuristic: atrule names are no longer than LONGEST_ATRULE_NAME + for (let i = range.start; i >= range.start - LONGEST_ATRULE_NAME; i--) { + // Make sure to not overlap with the previous range + if (prev_range && prev_range.end > i) { + break + } + + let char_position = i + if (text.charCodeAt(char_position) === AT_SIGN) { + // Move the start cursor back to the start of the @-sign + range.start = char_position + + // Look if the next character might be the opening { of the atrule's block + let next_offset = range.end + let next_char = text.charAt(next_offset) + // First eat all the whitespace that might be in-between + while (/\s/.test(next_char)) { + next_offset++ + next_char = text.charAt(next_offset) + } + + if (next_char === '{') { + range.end = range.end + 1 + } + break + } + } + + // If the next non-whitespace character is }, add it to the current range + let offset = range.end + let next_char = text.charAt(offset) + while (/\s/.test(next_char)) { + offset++ + next_char = text.charAt(offset) + } + if (next_char === '}') { + range.end = offset + 1 + } + + return range + }) + + return { text, ranges: new_ranges, url } + }) +} diff --git a/src/lib/filter-entries.ts b/src/lib/filter-entries.ts index eb80726..f38c2e9 100644 --- a/src/lib/filter-entries.ts +++ b/src/lib/filter-entries.ts @@ -30,6 +30,7 @@ export async function filter_coverage(coverage: Coverage[]): Promise } // At this point it can only be CSS + // TODO: that's not true, check if it's css-like of js-like result.push({ url: entry.url, text: entry.text, diff --git a/src/lib/index.test.ts b/src/lib/index.test.ts index 83e634d..09ac8b7 100644 --- a/src/lib/index.test.ts +++ b/src/lib/index.test.ts @@ -30,13 +30,13 @@ test.describe('from