Files
bun.sh/src/sourcemap/CodeCoverage.zig
Jarred Sumner f2f227720b WASM test analyzer (#4043)
* wasm

* WASM test scanner

* Update Makefile

* Update Makefile

* Configurable heap limit

* slightly better error

* Update js_parser.zig

* Update path.test.js

* Update node.mjs

---------

Co-authored-by: Jarred Sumner <709451+Jarred-Sumner@users.noreply.github.com>
2023-08-07 18:51:16 -07:00

652 lines
25 KiB
Zig

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);
if (total_count == 0) {
return 1.0;
}
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 {
if (bun.Environment.isNative) {
@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,
};