diff options
Diffstat (limited to 'src/sourcemap/CodeCoverage.zig')
-rw-r--r-- | src/sourcemap/CodeCoverage.zig | 646 |
1 files changed, 646 insertions, 0 deletions
diff --git a/src/sourcemap/CodeCoverage.zig b/src/sourcemap/CodeCoverage.zig new file mode 100644 index 000000000..2b063bbbe --- /dev/null +++ b/src/sourcemap/CodeCoverage.zig @@ -0,0 +1,646 @@ +const bun = @import("root").bun; +const std = @import("std"); +const LineOffsetTable = bun.sourcemap.LineOffsetTable; +const SourceMap = bun.sourcemap; +const Bitset = bun.bit_set.DynamicBitSetUnmanaged; +const Output = bun.Output; +const prettyFmt = Output.prettyFmt; + +/// Our code coverage currently only deals with lines of code, not statements or branches. +/// JSC doesn't expose function names in their coverage data, so we don't include that either :(. +/// Since we only need to store line numbers, our job gets simpler +/// +/// We can use two bitsets to store code coverage data for a given file +/// 1. executable_lines +/// 2. lines_which_have_executed +/// +/// Not all lines of code are executable. Comments, whitespace, empty lines, etc. are not executable. +/// It's not a problem for anyone if comments, whitespace, empty lines etc are not executed, so those should always be omitted from coverage reports +/// +/// We use two bitsets since the typical size will be decently small, +/// bitsets are simple and bitsets are relatively fast to construct and query +/// +pub const CodeCoverageReport = struct { + source_url: bun.JSC.ZigString.Slice, + executable_lines: Bitset, + lines_which_have_executed: Bitset, + functions: std.ArrayListUnmanaged(Block), + functions_which_have_executed: Bitset, + stmts_which_have_executed: Bitset, + stmts: std.ArrayListUnmanaged(Block), + total_lines: u32 = 0, + + pub const Block = struct { + start_line: u32 = 0, + end_line: u32 = 0, + }; + + pub fn linesCoverageFraction(this: *const CodeCoverageReport) f64 { + var intersected = this.executable_lines.clone(bun.default_allocator) catch @panic("OOM"); + defer intersected.deinit(bun.default_allocator); + intersected.setIntersection(this.lines_which_have_executed); + + const total_count: f64 = @floatFromInt(this.executable_lines.count()); + if (total_count == 0) { + return 1.0; + } + + const intersected_count: f64 = @floatFromInt(intersected.count()); + + return (intersected_count / total_count); + } + + pub fn stmtsCoverageFraction(this: *const CodeCoverageReport) f64 { + const total_count: f64 = @floatFromInt(this.stmts.items.len); + + if (total_count == 0) { + return 1.0; + } + + return ((@as(f64, @floatFromInt(this.stmts_which_have_executed.count()))) / (total_count)); + } + + pub fn functionCoverageFraction(this: *const CodeCoverageReport) f64 { + const total_count: f64 = @floatFromInt(this.functions.items.len); + return (@as(f64, @floatFromInt(this.functions_which_have_executed.count())) / total_count); + } + + pub fn writeFormatWithValues( + filename: []const u8, + max_filename_length: usize, + vals: CoverageFraction, + failing: CoverageFraction, + failed: bool, + writer: anytype, + indent_name: bool, + comptime enable_colors: bool, + ) !void { + if (comptime enable_colors) { + if (failed) { + try writer.writeAll(comptime prettyFmt("<r><b><red>", true)); + } else { + try writer.writeAll(comptime prettyFmt("<r><b><green>", true)); + } + } + + if (indent_name) { + try writer.writeAll(" "); + } + + try writer.writeAll(filename); + try writer.writeByteNTimes(' ', (max_filename_length - filename.len + @as(usize, @intFromBool(!indent_name)))); + try writer.writeAll(comptime prettyFmt("<r><d> | <r>", enable_colors)); + + if (comptime enable_colors) { + if (vals.functions < failing.functions) { + try writer.writeAll(comptime prettyFmt("<b><red>", true)); + } else { + try writer.writeAll(comptime prettyFmt("<b><green>", true)); + } + } + + try writer.print("{d: >7.2}", .{vals.functions * 100.0}); + // try writer.writeAll(comptime prettyFmt("<r><d> | <r>", enable_colors)); + // if (comptime enable_colors) { + // // if (vals.stmts < failing.stmts) { + // try writer.writeAll(comptime prettyFmt("<d>", true)); + // // } else { + // // try writer.writeAll(comptime prettyFmt("<d>", true)); + // // } + // } + // try writer.print("{d: >8.2}", .{vals.stmts * 100.0}); + try writer.writeAll(comptime prettyFmt("<r><d> | <r>", enable_colors)); + + if (comptime enable_colors) { + if (vals.lines < failing.lines) { + try writer.writeAll(comptime prettyFmt("<b><red>", true)); + } else { + try writer.writeAll(comptime prettyFmt("<b><green>", true)); + } + } + + try writer.print("{d: >7.2}", .{vals.lines * 100.0}); + } + + pub fn writeFormat( + report: *const CodeCoverageReport, + max_filename_length: usize, + fraction: *CoverageFraction, + base_path: []const u8, + writer: anytype, + comptime enable_colors: bool, + ) !void { + var failing = fraction.*; + const fns = report.functionCoverageFraction(); + const lines = report.linesCoverageFraction(); + const stmts = report.stmtsCoverageFraction(); + fraction.functions = fns; + fraction.lines = lines; + fraction.stmts = stmts; + + const failed = fns < failing.functions or lines < failing.lines; // or stmts < failing.stmts; + fraction.failing = failed; + + var filename = report.source_url.slice(); + if (base_path.len > 0) { + filename = bun.path.relative(base_path, filename); + } + + try writeFormatWithValues( + filename, + max_filename_length, + fraction.*, + failing, + failed, + writer, + true, + enable_colors, + ); + + try writer.writeAll(comptime prettyFmt("<r><d> | <r>", enable_colors)); + + var executable_lines_that_havent_been_executed = report.lines_which_have_executed.clone(bun.default_allocator) catch @panic("OOM"); + defer executable_lines_that_havent_been_executed.deinit(bun.default_allocator); + executable_lines_that_havent_been_executed.toggleAll(); + + // This sets statements in executed scopes + executable_lines_that_havent_been_executed.setIntersection(report.executable_lines); + + var iter = executable_lines_that_havent_been_executed.iterator(.{}); + var start_of_line_range: usize = 0; + var prev_line: usize = 0; + var is_first = true; + + while (iter.next()) |next_line| { + if (next_line == (prev_line + 1)) { + prev_line = next_line; + continue; + } else if (is_first and start_of_line_range == 0 and prev_line == 0) { + start_of_line_range = next_line; + prev_line = next_line; + continue; + } + + if (is_first) { + is_first = false; + } else { + try writer.print(comptime prettyFmt("<r><d>,<r>", enable_colors), .{}); + } + + if (start_of_line_range == prev_line) { + try writer.print(comptime prettyFmt("<red>{d}", enable_colors), .{start_of_line_range + 1}); + } else { + try writer.print(comptime prettyFmt("<red>{d}-{d}", enable_colors), .{ start_of_line_range + 1, prev_line + 1 }); + } + + prev_line = next_line; + start_of_line_range = next_line; + } + + if (prev_line != start_of_line_range) { + if (is_first) { + is_first = false; + } else { + try writer.print(comptime prettyFmt("<r><d>,<r>", enable_colors), .{}); + } + + if (start_of_line_range == prev_line) { + try writer.print(comptime prettyFmt("<red>{d}", enable_colors), .{start_of_line_range + 1}); + } else { + try writer.print(comptime prettyFmt("<red>{d}-{d}", enable_colors), .{ start_of_line_range + 1, prev_line + 1 }); + } + } + } + + pub fn deinit(this: *CodeCoverageReport, allocator: std.mem.Allocator) void { + this.executable_lines.deinit(allocator); + this.lines_which_have_executed.deinit(allocator); + this.functions.deinit(allocator); + this.stmts.deinit(allocator); + this.functions_which_have_executed.deinit(allocator); + this.stmts_which_have_executed.deinit(allocator); + } + + extern fn CodeCoverage__withBlocksAndFunctions( + *bun.JSC.VM, + i32, + *anyopaque, + bool, + *const fn ( + *Generator, + [*]const BasicBlockRange, + usize, + usize, + bool, + ) callconv(.C) void, + ) bool; + + const Generator = struct { + allocator: std.mem.Allocator, + byte_range_mapping: *ByteRangeMapping, + result: *?CodeCoverageReport, + + pub fn do( + this: *@This(), + blocks_ptr: [*]const BasicBlockRange, + blocks_len: usize, + function_start_offset: usize, + ignore_sourcemap: bool, + ) callconv(.C) void { + const blocks: []const BasicBlockRange = blocks_ptr[0..function_start_offset]; + var function_blocks: []const BasicBlockRange = blocks_ptr[function_start_offset..blocks_len]; + if (function_blocks.len > 1) { + function_blocks = function_blocks[1..]; + } + + if (blocks.len == 0) { + return; + } + + this.result.* = this.byte_range_mapping.generateCodeCoverageReportFromBlocks( + this.allocator, + this.byte_range_mapping.source_url, + blocks, + function_blocks, + ignore_sourcemap, + ) catch null; + } + }; + + pub fn generate( + globalThis: *bun.JSC.JSGlobalObject, + allocator: std.mem.Allocator, + byte_range_mapping: *ByteRangeMapping, + ignore_sourcemap_: bool, + ) ?CodeCoverageReport { + bun.JSC.markBinding(@src()); + var vm = globalThis.vm(); + + var result: ?CodeCoverageReport = null; + + var generator = Generator{ + .result = &result, + .allocator = allocator, + .byte_range_mapping = byte_range_mapping, + }; + + if (!CodeCoverage__withBlocksAndFunctions( + vm, + byte_range_mapping.source_id, + &generator, + ignore_sourcemap_, + &Generator.do, + )) { + return null; + } + + return result; + } +}; + +const BasicBlockRange = extern struct { + startOffset: c_int = 0, + endOffset: c_int = 0, + hasExecuted: bool = false, + executionCount: usize = 0, +}; + +pub const ByteRangeMapping = struct { + line_offset_table: LineOffsetTable.List = .{}, + source_id: i32, + source_url: bun.JSC.ZigString.Slice, + + pub fn isLessThan(_: void, a: ByteRangeMapping, b: ByteRangeMapping) bool { + return bun.strings.order(a.source_url.slice(), b.source_url.slice()) == .lt; + } + + pub const HashMap = std.HashMap(u64, ByteRangeMapping, bun.IdentityContext(u64), std.hash_map.default_max_load_percentage); + + pub fn deinit(this: *ByteRangeMapping) void { + this.line_offset_table.deinit(bun.default_allocator); + } + + pub threadlocal var map: ?*HashMap = null; + pub fn generate(str: bun.String, source_contents_str: bun.String, source_id: i32) callconv(.C) void { + var _map = map orelse brk: { + map = bun.JSC.VirtualMachine.get().allocator.create(HashMap) catch @panic("OOM"); + map.?.* = HashMap.init(bun.JSC.VirtualMachine.get().allocator); + break :brk map.?; + }; + var slice = str.toUTF8(bun.default_allocator); + const hash = bun.hash(slice.slice()); + var entry = _map.getOrPut(hash) catch @panic("Out of memory"); + if (entry.found_existing) { + entry.value_ptr.deinit(); + } + + var source_contents = source_contents_str.toUTF8(bun.default_allocator); + defer source_contents.deinit(); + + entry.value_ptr.* = compute(source_contents.slice(), source_id, slice); + } + + pub fn getSourceID(this: *ByteRangeMapping) callconv(.C) i32 { + return this.source_id; + } + + pub fn find(path: bun.String) callconv(.C) ?*ByteRangeMapping { + var slice = path.toUTF8(bun.default_allocator); + defer slice.deinit(); + + var map_ = map orelse return null; + const hash = bun.hash(slice.slice()); + var entry = map_.getPtr(hash) orelse return null; + return entry; + } + + pub fn generateCodeCoverageReportFromBlocks( + this: *ByteRangeMapping, + allocator: std.mem.Allocator, + source_url: bun.JSC.ZigString.Slice, + blocks: []const BasicBlockRange, + function_blocks: []const BasicBlockRange, + ignore_sourcemap: bool, + ) !CodeCoverageReport { + var line_starts = this.line_offset_table.items(.byte_offset_to_start_of_line); + + var executable_lines: Bitset = Bitset{}; + var lines_which_have_executed: Bitset = Bitset{}; + const parsed_mappings_ = bun.JSC.VirtualMachine.get().source_mappings.get( + source_url.slice(), + ); + + var functions = std.ArrayListUnmanaged(CodeCoverageReport.Block){}; + try functions.ensureTotalCapacityPrecise(allocator, function_blocks.len); + errdefer functions.deinit(allocator); + var functions_which_have_executed: Bitset = try Bitset.initEmpty(allocator, function_blocks.len); + errdefer functions_which_have_executed.deinit(allocator); + var stmts_which_have_executed: Bitset = try Bitset.initEmpty(allocator, blocks.len); + errdefer stmts_which_have_executed.deinit(allocator); + + var stmts = std.ArrayListUnmanaged(CodeCoverageReport.Block){}; + try stmts.ensureTotalCapacityPrecise(allocator, function_blocks.len); + errdefer stmts.deinit(allocator); + + errdefer executable_lines.deinit(allocator); + errdefer lines_which_have_executed.deinit(allocator); + var line_count: u32 = 0; + + if (ignore_sourcemap or parsed_mappings_ == null) { + line_count = @truncate(line_starts.len); + executable_lines = try Bitset.initEmpty(allocator, line_count); + lines_which_have_executed = try Bitset.initEmpty(allocator, line_count); + for (blocks, 0..) |block, i| { + const min: usize = @intCast(@min(block.startOffset, block.endOffset)); + const max: usize = @intCast(@max(block.startOffset, block.endOffset)); + var min_line: u32 = std.math.maxInt(u32); + var max_line: u32 = 0; + + const has_executed = block.hasExecuted or block.executionCount > 0; + + for (min..max) |byte_offset| { + const new_line_index = LineOffsetTable.findIndex(line_starts, .{ .start = @intCast(byte_offset) }) orelse continue; + const line_start_byte_offset = line_starts[new_line_index]; + if (line_start_byte_offset >= byte_offset) { + continue; + } + + const line: u32 = @intCast(new_line_index); + min_line = @min(min_line, line); + max_line = @max(max_line, line); + + executable_lines.set(@intCast(new_line_index)); + if (has_executed) { + lines_which_have_executed.set(@intCast(new_line_index)); + } + } + + if (min_line != std.math.maxInt(u32)) { + if (has_executed) + stmts_which_have_executed.set(i); + + try stmts.append(allocator, .{ + .start_line = min_line, + .end_line = max_line, + }); + } + } + + for (function_blocks, 0..) |function, i| { + const min: usize = @intCast(@min(function.startOffset, function.endOffset)); + const max: usize = @intCast(@max(function.startOffset, function.endOffset)); + var min_line: u32 = std.math.maxInt(u32); + var max_line: u32 = 0; + + for (min..max) |byte_offset| { + const new_line_index = LineOffsetTable.findIndex(line_starts, .{ .start = @intCast(byte_offset) }) orelse continue; + const line_start_byte_offset = line_starts[new_line_index]; + if (line_start_byte_offset >= byte_offset) { + continue; + } + + const line: u32 = @intCast(new_line_index); + min_line = @min(min_line, line); + max_line = @max(max_line, line); + } + + const did_fn_execute = function.executionCount > 0 or function.hasExecuted; + + // only mark the lines as executable if the function has not executed + // functions that have executed have non-executable lines in them and thats fine. + if (!did_fn_execute) { + const end = @min(max_line, line_count); + for (min_line..end) |line| { + executable_lines.set(line); + lines_which_have_executed.unset(line); + } + } + + try functions.append(allocator, .{ + .start_line = min_line, + .end_line = max_line, + }); + + if (did_fn_execute) + functions_which_have_executed.set(i); + } + } else if (parsed_mappings_) |parsed_mapping| { + line_count = @as(u32, @truncate(parsed_mapping.input_line_count)) + 1; + executable_lines = try Bitset.initEmpty(allocator, line_count); + lines_which_have_executed = try Bitset.initEmpty(allocator, line_count); + + for (blocks, 0..) |block, i| { + const min: usize = @intCast(@min(block.startOffset, block.endOffset)); + const max: usize = @intCast(@max(block.startOffset, block.endOffset)); + var min_line: u32 = std.math.maxInt(u32); + var max_line: u32 = 0; + const has_executed = block.hasExecuted or block.executionCount > 0; + + for (min..max) |byte_offset| { + const new_line_index = LineOffsetTable.findIndex(line_starts, .{ .start = @intCast(byte_offset) }) orelse continue; + const line_start_byte_offset = line_starts[new_line_index]; + if (line_start_byte_offset >= byte_offset) { + continue; + } + const column_position = byte_offset -| line_start_byte_offset; + + if (SourceMap.Mapping.find(parsed_mapping.mappings, @intCast(new_line_index), @intCast(column_position))) |point| { + if (point.original.lines < 0) continue; + + const line: u32 = @as(u32, @intCast(point.original.lines)); + + executable_lines.set(line); + if (has_executed) { + lines_which_have_executed.set(line); + } + + min_line = @min(min_line, line); + max_line = @max(max_line, line); + } + } + + if (min_line != std.math.maxInt(u32)) { + try stmts.append(allocator, .{ + .start_line = min_line, + .end_line = max_line, + }); + + if (has_executed) + stmts_which_have_executed.set(i); + } + } + + for (function_blocks, 0..) |function, i| { + const min: usize = @intCast(@min(function.startOffset, function.endOffset)); + const max: usize = @intCast(@max(function.startOffset, function.endOffset)); + var min_line: u32 = std.math.maxInt(u32); + var max_line: u32 = 0; + + for (min..max) |byte_offset| { + const new_line_index = LineOffsetTable.findIndex(line_starts, .{ .start = @intCast(byte_offset) }) orelse continue; + const line_start_byte_offset = line_starts[new_line_index]; + if (line_start_byte_offset >= byte_offset) { + continue; + } + + const column_position = byte_offset -| line_start_byte_offset; + + if (SourceMap.Mapping.find(parsed_mapping.mappings, @intCast(new_line_index), @intCast(column_position))) |point| { + if (point.original.lines < 0) continue; + + const line: u32 = @as(u32, @intCast(point.original.lines)); + min_line = @min(min_line, line); + max_line = @max(max_line, line); + } + } + + // no sourcemaps? ignore it + if (min_line == std.math.maxInt(u32) and max_line == 0) { + continue; + } + + const did_fn_execute = function.executionCount > 0 or function.hasExecuted; + + // only mark the lines as executable if the function has not executed + // functions that have executed have non-executable lines in them and thats fine. + if (!did_fn_execute) { + const end = @min(max_line, line_count); + for (min_line..end) |line| { + executable_lines.set(line); + lines_which_have_executed.unset(line); + } + } + + try functions.append(allocator, .{ + .start_line = min_line, + .end_line = max_line, + }); + if (did_fn_execute) + functions_which_have_executed.set(i); + } + } else { + unreachable; + } + + return CodeCoverageReport{ + .source_url = source_url, + .functions = functions, + .executable_lines = executable_lines, + .lines_which_have_executed = lines_which_have_executed, + .total_lines = line_count, + .stmts = stmts, + .functions_which_have_executed = functions_which_have_executed, + .stmts_which_have_executed = stmts_which_have_executed, + }; + } + + pub fn findExecutedLines( + globalThis: *bun.JSC.JSGlobalObject, + source_url: bun.String, + blocks_ptr: [*]const BasicBlockRange, + blocks_len: usize, + function_start_offset: usize, + ignore_sourcemap: bool, + ) callconv(.C) bun.JSC.JSValue { + var this = ByteRangeMapping.find(source_url) orelse return bun.JSC.JSValue.null; + + const blocks: []const BasicBlockRange = blocks_ptr[0..function_start_offset]; + var function_blocks: []const BasicBlockRange = blocks_ptr[function_start_offset..blocks_len]; + if (function_blocks.len > 1) { + function_blocks = function_blocks[1..]; + } + var url_slice = source_url.toUTF8(bun.default_allocator); + defer url_slice.deinit(); + var report = this.generateCodeCoverageReportFromBlocks(bun.default_allocator, url_slice, blocks, function_blocks, ignore_sourcemap) catch { + globalThis.throwOutOfMemory(); + return .zero; + }; + defer report.deinit(bun.default_allocator); + + var coverage_fraction = CoverageFraction{}; + + var mutable_str = bun.MutableString.initEmpty(bun.default_allocator); + defer mutable_str.deinit(); + var buffered_writer = mutable_str.bufferedWriter(); + var writer = buffered_writer.writer(); + + report.writeFormat(source_url.utf8ByteLength(), &coverage_fraction, "", &writer, false) catch { + globalThis.throwOutOfMemory(); + return .zero; + }; + + buffered_writer.flush() catch { + globalThis.throwOutOfMemory(); + return .zero; + }; + + var str = bun.String.create(mutable_str.toOwnedSliceLeaky()); + defer str.deref(); + return str.toJS(globalThis); + } + + pub fn compute(source_contents: []const u8, source_id: i32, source_url: bun.JSC.ZigString.Slice) ByteRangeMapping { + return ByteRangeMapping{ + .line_offset_table = LineOffsetTable.generate(bun.JSC.VirtualMachine.get().allocator, source_contents, 0), + .source_id = source_id, + .source_url = source_url, + }; + } +}; + +comptime { + @export(ByteRangeMapping.generate, .{ .name = "ByteRangeMapping__generate" }); + @export(ByteRangeMapping.findExecutedLines, .{ .name = "ByteRangeMapping__findExecutedLines" }); + @export(ByteRangeMapping.find, .{ .name = "ByteRangeMapping__find" }); + @export(ByteRangeMapping.getSourceID, .{ .name = "ByteRangeMapping__getSourceID" }); +} + +pub const CoverageFraction = struct { + functions: f64 = 0.9, + lines: f64 = 0.9, + + // This metric is less accurate right now + stmts: f64 = 0.75, + + failing: bool = false, +}; |