///|
/// The HTML report: one self-contained document with four charts.
///
/// The charts are drawn by Apache ECharts, which is vendored under `assets/` and
/// inlined here by the caller. Inlining rather than linking keeps the promise the
/// rest of the tool makes — everything happens on the machine you run it on, with
/// no network and no second file to keep next to the report.
///|
/// One module with the functions that belong to it, heaviest first.
///
/// The treemap needs the nesting, and the pie needs the totals, so the grouping
/// is done once here rather than twice in the page.
priv struct ModuleGroup {
name : String
size : Int
functions : Int
children : Array[FunctionStat]
}
///|
/// Group functions by the package their names carry, heaviest total first.
fn group_modules(stats : Array[FunctionStat]) -> Array[ModuleGroup] {
let order : Array[String] = []
let index : Map[String, Int] = Map([])
let groups : Array[ModuleGroup] = []
for stat in stats {
let at = match index.get(stat.module_name) {
Some(at) => at
None => {
index.set(stat.module_name, groups.length())
order.push(stat.module_name)
groups.push({
name: stat.module_name,
size: 0,
functions: 0,
children: [],
})
groups.length() - 1
}
}
let group = groups[at]
groups[at] = {
name: group.name,
size: group.size + stat.total_size,
functions: group.functions + 1,
children: group.children,
}
groups[at].children.push(stat)
}
for group in groups {
group.children.sort_by(fn(a, b) {
if a.total_size != b.total_size {
b.total_size - a.total_size
} else {
a.index - b.index
}
})
}
groups.sort_by(fn(a, b) {
if a.size != b.size {
b.size - a.size
} else {
String::compare(a.name, b.name)
}
})
groups
}
///|
/// The bytes spent on a named section, or zero when the module has none.
fn section_size(parsed : WasmModule, kind : SectionId) -> Int {
let mut total = 0
for section in parsed.sections {
if same_kind(section.kind, kind) {
total = total + section.total_size
}
}
total
}
///|
/// The bytes in functions no root can reach.
fn dead_size(analysis : Analysis) -> Int {
let mut total = 0
for stat in analysis.stats {
if !analysis.reachable.contains(stat.index) {
total = total + stat.total_size
}
}
total
}
///|
/// An integer as a JSON number.
///
/// The representation is pinned to the decimal text: a file size has no
/// fractional part, and letting the printer choose would turn 10645 into
/// `10645.0` or `1.0645E4` depending on its mood.
fn int_json(value : Int) -> Json {
Json::number(value.to_double(), repr=value.to_string())
}
///|
/// One measurement per range, reused by the card, data rows and treemap leaves.
/// Kept internal so tests can inject failures at the rendering boundary.
priv struct CompressionMeasurements {
whole_file : Result[Int, String]
sections : Array[Result[Int, String]]
functions : Map[Int, Result[Int, String]]
}
///|
fn measure_compression(analysis : Analysis) -> CompressionMeasurements {
let functions : Map[Int, Result[Int, String]] = Map([])
for stat in analysis.stats {
functions.set(
stat.index,
compressed_span(analysis.data, stat.offset, stat.total_size),
)
}
{
whole_file: compressed_size(analysis.data),
sections: compress_sections(analysis.data, analysis.parsed).map(fn(row) {
row.gzip
}),
functions,
}
}
///|
/// The number and error always describe the same measurement result.
fn set_gzip_fields(row : Map[String, Json], gzip : Result[Int, String]) -> Unit {
row.set("gzip", gzip_json(gzip))
row.set("gzipError", gzip_error_json(gzip))
}
///|
/// The payload the page draws from, as one JSON object.
fn report_data(
analysis : Analysis,
file_path : String,
compression? : CompressionMeasurements,
) -> Json {
let measured = match compression {
Some(measured) => measured
None => measure_compression(analysis)
}
let parsed = analysis.parsed
let members : Map[String, Json] = Map([])
members.set("file", Json::string(file_path))
members.set("fileSize", int_json(parsed.file_size))
members.set("schema_version", int_json(1))
members.set(
"graph_complete",
Json::boolean(is_graph_complete(analysis.graph)),
)
members.set("diagnostics", diagnostics_json(analysis.graph))
members.set("modeling_scope", Json::string(graph_modeling_scope()))
members.set("gzipSize", gzip_json(measured.whole_file))
members.set("gzipError", gzip_error_json(measured.whole_file))
members.set(
"compression_scope",
Json::string(
"Whole-file gzip is one complete-file stream. Sections include their headers and payloads; functions include their size prefixes (size, not bodySize). Each local range is an independent gzip stream. These sizes are not additive and do not represent whole-file transfer shares or removal savings.",
),
)
members.set("codeSize", int_json(section_size(parsed, Code)))
members.set("deadSize", int_json(dead_size(analysis)))
let sections : Array[Json] = []
for i, section in ordered_sections(parsed) {
let row : Map[String, Json] = Map([])
row.set("name", Json::string(section_label(section)))
row.set("total", int_json(section.total_size))
row.set("payload", int_json(section.payload_size))
set_gzip_fields(row, measured.sections[i])
sections.push(Json::object(row))
}
members.set("sections", Json::array(sections))
let ranked = analysis.stats.copy()
ranked.sort_by(fn(a, b) {
if a.body_size != b.body_size {
b.body_size - a.body_size
} else {
a.index - b.index
}
})
let functions : Array[Json] = []
for stat in ranked {
let row : Map[String, Json] = Map([])
row.set("index", int_json(stat.index))
row.set("name", Json::string(stat.name))
row.set("module", Json::string(stat.module_name))
row.set("bodySize", int_json(stat.body_size))
row.set("size", int_json(stat.total_size))
set_gzip_fields(row, measured.functions[stat.index])
row.set("dead", Json::boolean(!analysis.reachable.contains(stat.index)))
functions.push(Json::object(row))
}
members.set("functions", Json::array(functions))
let groups = group_modules(analysis.stats)
let modules : Array[Json] = []
for group in groups {
let children : Array[Json] = []
for child in group.children {
let leaf : Map[String, Json] = Map([])
leaf.set("name", Json::string(child.name))
leaf.set("value", int_json(child.total_size))
set_gzip_fields(leaf, measured.functions[child.index])
leaf.set("dead", Json::boolean(!analysis.reachable.contains(child.index)))
leaf.set("index", int_json(child.index))
children.push(Json::object(leaf))
}
let row : Map[String, Json] = Map([])
row.set("name", Json::string(group.name))
row.set("size", int_json(group.size))
row.set("functions", int_json(group.functions))
row.set("children", Json::array(children))
modules.push(Json::object(row))
}
members.set("modules", Json::array(modules))
// The pie gets its own roster: thin slices are rolled up there, while the
// treemap keeps every module because its cells are drawn to scale.
let slices : Array[Json] = []
for slice in pie_slices(groups) {
let row : Map[String, Json] = Map([])
row.set("name", Json::string(slice.name))
row.set("size", int_json(slice.size))
let names : Array[Json] = []
for name in slice.members {
names.push(Json::string(name))
}
row.set("members", Json::array(names))
slices.push(Json::object(row))
}
members.set("pie", Json::array(slices))
Json::object(members)
}
///|
/// One slice of the module pie.
priv struct PieSlice {
name : String
size : Int
/// The modules this slice stands for; empty when it is a module itself.
members : Array[String]
}
///|
/// A slice below half a percent of attributed function bytes cannot carry a legible label
/// or be aimed at with a pointer, so the ones below it are rolled into a single
/// slice that names them on hover.
const MIN_SLICE_TENTHS : Int = 5
///|
/// The pie's slices: every module at or above the threshold, plus one `other`
/// slice holding the rest.
///
/// The comparison is integer arithmetic — `size / total < 0.5%` is
/// `size * 1000 < total * 5` — so it cannot drift the way a float would.
fn pie_slices(groups : Array[ModuleGroup]) -> Array[PieSlice] {
let slices : Array[PieSlice] = []
let mut total = 0
for group in groups {
total = total + group.size
}
let big : Array[ModuleGroup] = []
let small : Array[ModuleGroup] = []
for group in groups {
if total > 0 && group.size * 1000 < total * MIN_SLICE_TENTHS {
small.push(group)
} else {
big.push(group)
}
}
// Bucketing nothing, or bucketing everything, would both make the chart worse.
if small.length() == 0 || big.length() == 0 {
for group in groups {
slices.push({ name: group.name, size: group.size, members: [], })
}
return slices
}
for group in big {
slices.push({ name: group.name, size: group.size, members: [], })
}
let mut rest = 0
let members : Array[String] = []
for group in small {
rest = rest + group.size
members.push(group.name)
}
slices.push({
name: "other (" + small.length().to_string() + ")",
size: rest,
members,
})
slices
}
///|
/// The length of the UTF-8 sequence a lead byte introduces.
fn utf8_width(lead : Byte) -> Int {
if lead < b'\x80' {
1
} else if lead >= b'\xf0' {
4
} else if lead >= b'\xe0' {
3
} else if lead >= b'\xc0' {
2
} else {
1
}
}
///|
/// Replace every occurrence of `needle` in `text`, which must not be empty.
fn replace_all(text : String, needle : String, replacement : String) -> String {
if needle.length() == 0 {
return text
}
let bytes = @utf8.encode(text)
let find = @utf8.encode(needle)
let out = StringBuilder(size_hint=bytes.length())
let mut i = 0
while i < bytes.length() {
if i + find.length() <= bytes.length() && matches_at(bytes, find, i) {
out.write_string(replacement)
i = i + find.length()
} else {
// A whole character at a time: one byte of a multi-byte character is not
// decodable on its own, so decoding it lossily would turn every non-ASCII
// character in the document into a replacement character.
let width = utf8_width(bytes[i])
let end = if i + width <= bytes.length() { i + width } else { i + 1 }
out.write_string(@utf8.decode_lossy(bytes[i:end]))
i = end
}
}
out.to_string()
}
///|
/// True when `needle` sits in `bytes` at `at`.
fn matches_at(bytes : Bytes, needle : Bytes, at : Int) -> Bool {
let mut i = 0
while i < needle.length() {
if bytes[at + i] != needle[i] {
return false
}
i = i + 1
}
true
}
///|
/// Make text safe inside a ` String {
replace_all(text, "<", "\\u003c")
}
///|
/// Break up any ` String {
replace_all(
replace_all(text, " String {
let out = replace_all(text, "&", "&")
let out = replace_all(out, "<", "<")
let out = replace_all(out, ">", ">")
let out = replace_all(out, "\"", """)
replace_all(out, "'", "'")
}
///|
/// A compressed size as JSON: the number, or `null` when the compressor gave
/// none. `null` is not `0` and not the raw size — the page has to be able to say
/// "not measured" and have it survive the trip.
fn gzip_json(gzip : Result[Int, String]) -> Json {
match gzip {
Ok(size) => int_json(size)
Err(_) => Json::null()
}
}
///|
fn gzip_error_json(gzip : Result[Int, String]) -> Json {
match gzip {
Ok(_) => Json::null()
Err(message) => Json::string(message)
}
}
///|
/// The gzip card's value, sub-line and hover text.
///
/// A failure is `N/A` carrying the compressor's own complaint, rather than the
/// raw size dressed up as a ratio of 1.00x — the two mean opposite things and the
/// page must not make them look alike.
fn gzip_card_fields(
raw : Int,
gzip : Result[Int, String],
) -> (String, String, String) {
match gzip {
Ok(size) =>
(
grouped(size) + " B",
share_text(size, raw) + " of raw · " + ratio_text(raw, size),
"",
)
Err(message) => ("N/A", "compression failed", message)
}
}
///|
/// A byte count with thousands separators, for the headline numbers.
fn grouped(value : Int) -> String {
let digits = value.to_string()
let out = StringBuilder(size_hint=digits.length() + 4)
let mut i = 0
while i < digits.length() {
if i > 0 && (digits.length() - i) % 3 == 0 {
out.write_string(",")
}
out.write_string(@utf8.decode_lossy(@utf8.encode(digits)[i:i + 1]))
i = i + 1
}
out.to_string()
}
///|
/// A byte count in the units a person reads sizes in.
///
/// Integer arithmetic rather than floating point, so the same number always
/// prints the same way.
fn human_bytes(bytes : Int) -> String {
if bytes >= 1048576 {
let tenths = bytes * 10 / 1048576
(tenths / 10).to_string() + "." + (tenths % 10).to_string() + " MB"
} else if bytes >= 1024 {
let tenths = bytes * 10 / 1024
(tenths / 10).to_string() + "." + (tenths % 10).to_string() + " KB"
} else {
bytes.to_string() + " B"
}
}
///|
/// One headline number.
fn card(
label : String,
value : String,
sub : String,
alert : Bool,
title? : String = "",
) -> String {
let klass = if alert { "card alert" } else { "card" }
let tooltip = if title.length() == 0 {
""
} else {
" title=\"" + html_escape(title) + "\""
}
"
\n"
}
///|
/// Render the whole analysis as one HTML document.
///
/// `echarts` is the chart library's source. Passing it inlines the library, so
/// the report opens from any directory with no network and no sibling file;
/// leaving it empty selects the compatible library CDN mode. This library does
/// no I/O. The CLI always supplies its compiled-in vendored source;
/// a library caller can explicitly supply a source or retain the CDN default.
pub fn render_html(
analysis : Analysis,
file_path : String,
echarts? : String = "",
) -> String {
render_html_measured(
analysis,
file_path,
measure_compression(analysis),
echarts,
)
}
///|
/// Opt-in current-input details from inspect_data_segments(analysis.data).
/// No parsing or extra compression measurements are performed here.
pub fn render_html_with_data_segments(
analysis : Analysis,
file_path : String,
report : DataSegmentsReport,
echarts? : String = "",
) -> String {
render_html_measured(
analysis,
file_path,
measure_compression(analysis),
echarts,
data_segments=report,
)
}
///|
fn html_data_field(row : Json, key : String) -> Json {
match row {
Object(fields) => fields.get(key).unwrap_or(Json::null())
_ => Json::null()
}
}
///|
fn html_data_text(row : Json, key : String) -> String {
match html_data_field(row, key) {
String(text) => text
_ => ""
}
}
///|
fn html_data_int(row : Json, key : String) -> Int {
match html_data_field(row, key) {
Number(n, ..) => n.to_int()
_ => 0
}
}
///|
fn html_data_array(row : Json, key : String) -> Array[Json] {
match html_data_field(row, key) {
Array(items) => items
_ => []
}
}
///|
/// The exact ranked rows and measurements already prepared for the charts.
/// This table is usable without JavaScript or ECharts.
fn html_function_table(data : Json) -> String {
let out = StringBuilder()
out.write_string(
"Function details · full names and measured bytes\n",
)
out.write_string(
"
\n",
)
out.write_string(
"
Same Top 20 local functions as the body-size chart. Encoded B includes the size prefix. Indices use the full function index space, including imports. Reachability is within the current model.
\n",
)
out.write_string(
"
\n",
)
out.write_string(
"
Rank
Index
Function / module
Body B
Encoded B
Independent gzip B
Model reachability
\n",
)
let functions = html_data_array(data, "functions")
for rank, row in functions {
if rank >= 20 {
break
}
let index = html_data_int(row, "index").to_string()
let dead = html_data_field(row, "dead") == Json::boolean(true)
out.write_string(
"
" +
(if dead { "Unreachable in model" } else { "Reachable in model" }) +
"
\n",
)
}
if functions.is_empty() {
out.write_string(
"
No local functions in this module.
\n",
)
}
out.write_string("
\n")
out.to_string()
}
///|
fn render_html_measured(
analysis : Analysis,
file_path : String,
compression : CompressionMeasurements,
echarts : String,
data_segments? : DataSegmentsReport,
) -> String {
let parsed = analysis.parsed
let file_size = parsed.file_size
let code_size = section_size(parsed, Code)
let dead = dead_size(analysis)
let data = report_data(analysis, file_path, compression~)
if data_segments is Some(report) && data is Object(fields) {
fields.set("dataSegments", data_segments_json(report))
}
let json = escape_for_script(data.stringify())
let script = replace_all(HTML_SCRIPT, "__DATA__", json)
let title = html_escape(file_path)
let out = StringBuilder(size_hint=json.length() + echarts.length() + 24576)
out.write_string(
"\n\n\n\n\nmoonsize — " +
title +
"\n\n\n\n",
)
out.write_string(
"Skip to report\n
" +
(if echarts.is_empty() {
"CDN chart mode: charts require a network connection. Measured cards, function details and methods are included in this file."
} else {
"Chart library and report data are embedded in this file; no network or sibling assets are required."
}) +
"
\n",
)
out.write_string(
"
Where the bytes go
Complete sections, then attributed function encodings
\n",
)
out.write_string(
"
Sections
Raw sizes include section headers and payloads. Both views use the same byte axis.
Each section is compressed separately. These sizes are not additive and do not represent whole-file transfer shares.
\n",
)
for row in html_data_array(data, "sections") {
if html_data_field(row, "gzipError") is String(error) {
out.write_string(
"
Area is raw encoded bytes. Tooltip gzip values are independent function streams, not additive transfer shares. Modules keep their ring colors; outlined cells and text identify functions unreachable in the model.
\n",
)
if data_segments is Some(report) {
out.write_string(html_data_segments_panel(report))
}
out.write_string(
"Methods, measurement ranges & model limits
",
)
out.write_string(
if echarts.is_empty() {
"CDN fallback mode: charts need a network connection to load ECharts. The measured cards, function details and methods are included in this file and remain readable without it."
} else {
"ECharts, styles, scripts and measured data are embedded in this file. No remote fonts, chart assets or sibling files are required; the report can be moved to another directory."
},
)
out.write_string(
"