///|
/// Left-pad to `width` so numeric columns line up.
fn pad_left(text : String, width : Int) -> String {
let mut out = ""
let mut i = text.length()
while i < width {
out = out + " "
i = i + 1
}
out + text
}
///|
/// Right-pad to `width` so the section-name column is a stable gutter.
fn pad_right(text : String, width : Int) -> String {
let mut out = text
while out.length() < width {
out = out + " "
}
out
}
///|
/// The module's sections, heaviest first.
///
/// The chart is about size, so the reader should be able to see which sections
/// matter without scanning the whole list. Ties keep file order, which is the
/// only other ordering the section table has.
fn ordered_sections(parsed : WasmModule) -> Array[Section] {
let ordered = parsed.sections.copy()
ordered.sort_by(fn(a, b) {
if a.total_size != b.total_size {
b.total_size - a.total_size
} else {
a.offset - b.offset
}
})
ordered
}
///|
/// The label a section is known by in the report.
fn section_label(section : Section) -> String {
match section.custom_name {
Some(name) => section.kind.name() + " (" + name + ")"
None => section.kind.name()
}
}
///|
/// Render a ratio as a percentage with one decimal, using double arithmetic so
/// large module sizes cannot overflow the multiplication.
fn share_text(part : Int, whole : Int) -> String {
if whole <= 0 {
return "n/a"
}
let tenths = (part.to_double() / whole.to_double() * 1000.0).to_int()
(tenths / 10).to_string() + "." + (tenths % 10).to_string() + "%"
}
///|
/// Name the binary format version, not just its number.
fn version_label(version : Int) -> String {
if version == 1 {
"1 (WebAssembly MVP)"
} else {
version.to_string()
}
}
///|
/// The user-facing report: one line per section, each with its byte count.
///
/// The result carries no trailing newline, so the caller decides how to end it.
pub fn format_report(parsed : WasmModule, path : String) -> String {
let mut accounted = 0
for section in parsed.sections {
accounted = accounted + section.total_size
}
let mut out = "moonsize — WebAssembly section report\n\n"
out = out + " file " + path + "\n"
out = out + " size " + parsed.file_size.to_string() + " bytes\n"
out = out + " version " + version_label(parsed.version) + "\n"
out = out + " sections " + parsed.sections.length().to_string() + "\n\n"
out = out +
" " +
pad_left("ID", 3) +
" " +
pad_right("SECTION", 12) +
" " +
pad_left("PAYLOAD", 10) +
" " +
pad_left("TOTAL", 10) +
" " +
pad_left("SHARE", 7) +
"\n"
for section in parsed.sections {
let annotation = match section.custom_name {
Some(name) => " " + name
None => ""
}
out = out +
" " +
pad_left(section.id.to_string(), 3) +
" " +
pad_right(section.kind.name(), 12) +
" " +
pad_left(section.payload_size.to_string(), 10) +
" " +
pad_left(section.total_size.to_string(), 10) +
" " +
pad_left(share_text(section.payload_size, parsed.file_size), 7) +
annotation +
"\n"
}
out = out +
"\n accounted " +
accounted.to_string() +
" bytes in sections (" +
share_text(accounted, parsed.file_size) +
" of file)"
out
}
///|
/// `1 module` reads better than `1 modules`, and a report is prose too.
fn plural(count : Int, noun : String) -> String {
if count == 1 {
count.to_string() + " " + noun
} else {
count.to_string() + " " + noun + "s"
}
}
///|
/// The width a right-aligned decimal column needs, never below `minimum`.
fn number_width(value : Int, minimum : Int) -> Int {
let digits = value.to_string().length()
if digits < minimum {
minimum
} else {
digits
}
}
///|
/// One function's byte budget, with the name and package the binary gives it.
pub struct FunctionStat {
/// Index in the function index space, where imported functions come first.
index : Int
/// Display name: demangled when the binary mangles it.
name : String
/// Package attributed by `module_of`.
module_name : String
/// Locals plus instruction bytes, i.e. what a compiler can still shrink.
body_size : Int
/// The body's size prefix plus the body: what the file actually spends.
total_size : Int
/// Offset of that size prefix from the start of the file.
offset : Int
}
///|
/// Every function attributed to one package, rolled up.
priv struct ModuleStat {
name : String
functions : Int
body_size : Int
total_size : Int
}
///|
/// Attribute a body to a name and a package.
///
/// Attribution is best effort by design: a release binary keeps no function
/// names, and the sizes are still worth reporting, so unnamed functions come
/// back labelled `(unnamed)` rather than failing the run.
pub fn function_stats(
bodies : Array[FunctionBody],
names : Map[Int, String]?,
) -> Array[FunctionStat] {
let stats : Array[FunctionStat] = []
for body in bodies {
let raw = match names {
Some(names) =>
match names.get(body.index) {
Some(name) => name
None => ""
}
None => ""
}
stats.push({
index: body.index,
name: if raw.length() == 0 {
UNNAMED_FUNCTION
} else {
display_name(raw)
},
module_name: module_of(raw),
body_size: body.body_size,
total_size: body.total_size,
offset: body.offset,
})
}
stats
}
///|
/// Read a module's function names and sizes.
///
/// A convenience over `analyze` for callers that only want the size table; the
/// two share `function_stats`, so they cannot disagree about attribution.
pub fn collect_function_stats(
parsed : WasmModule,
data : Bytes,
) -> Result[Array[FunctionStat], WasmError] {
let sections = find_sections(parsed)
let imported = match sections.imports {
Some(section) =>
match count_imported_functions(data, section) {
Ok(count) => count
Err(error) => return Err(error)
}
None => 0
}
// The code section lists defined functions only, and imported functions hold
// the first index slots, so the import count is what lines the two up.
let bodies = match sections.code {
Some(section) =>
match parse_code_section(data, section, base_index=imported) {
Ok(bodies) => bodies
Err(error) => return Err(error)
}
None => []
}
let names = match sections.names {
Some(section) => parse_name_section(data, section)
None => None
}
Ok(function_stats(bodies, names))
}
///|
/// Rank functions by the bytes they cost, heaviest first, breaking ties by index
/// so equal-size functions keep a stable order.
fn rank_functions(stats : Array[FunctionStat]) -> Array[FunctionStat] {
let ranked = stats.copy()
ranked.sort_by(fn(a, b) {
if a.total_size != b.total_size {
b.total_size - a.total_size
} else {
a.index - b.index
}
})
ranked
}
///|
/// Group functions by package, heaviest total first.
fn aggregate_by_module(stats : Array[FunctionStat]) -> Array[ModuleStat] {
let rows : Array[ModuleStat] = []
let seen : Map[String, Int] = Map([])
for stat in stats {
match seen.get(stat.module_name) {
Some(at) => {
let row = rows[at]
rows[at] = {
name: row.name,
functions: row.functions + 1,
body_size: row.body_size + stat.body_size,
total_size: row.total_size + stat.total_size,
}
}
None => {
seen.set(stat.module_name, rows.length())
rows.push({
name: stat.module_name,
functions: 1,
body_size: stat.body_size,
total_size: stat.total_size,
})
}
}
}
rows.sort_by(fn(a, b) {
if a.total_size != b.total_size {
b.total_size - a.total_size
} else {
String::compare(a.name, b.name)
}
})
rows
}
///|
/// The Top-N function table, heaviest first.
///
/// Shares are of the whole file, so a row here can be read against the section
/// table above it. `top` is clamped to the number of functions available.
pub fn render_top_functions(
stats : Array[FunctionStat],
file_size : Int,
top : Int,
) -> String {
let ranked = rank_functions(stats)
let shown = if top < 0 {
0
} else if top > ranked.length() {
ranked.length()
} else {
top
}
let mut index_width = 5
let mut total_width = 10
let mut body_width = 10
for stat in stats {
index_width = number_width(stat.index, index_width)
body_width = number_width(stat.body_size, body_width)
total_width = number_width(stat.total_size, total_width)
}
let rank_width = number_width(shown, 3)
let mut out = "Top " +
shown.to_string() +
" of " +
stats.length().to_string() +
" functions by size\n"
if shown == 0 {
let reason = if stats.length() == 0 {
"no functions in this binary"
} else {
"--top 0 requested, nothing to rank"
}
return out + "\n " + reason
}
out = out +
"\n " +
pad_left("#", rank_width) +
" " +
pad_left("INDEX", index_width) +
" " +
pad_left("BODY", body_width) +
" " +
pad_left("TOTAL", total_width) +
" " +
pad_left("SHARE", 7) +
" FUNCTION\n"
let mut covered = 0
let mut rank = 0
while rank < shown {
let stat = ranked[rank]
covered = covered + stat.total_size
out = out +
" " +
pad_left((rank + 1).to_string(), rank_width) +
" " +
pad_left(stat.index.to_string(), index_width) +
" " +
pad_left(stat.body_size.to_string(), body_width) +
" " +
pad_left(stat.total_size.to_string(), total_width) +
" " +
pad_left(share_text(stat.total_size, file_size), 7) +
" " +
stat.name +
"\n"
rank = rank + 1
}
out = out +
"\n these " +
shown.to_string() +
" hold " +
covered.to_string() +
" bytes (" +
share_text(covered, file_size) +
" of file)"
out
}
///|
/// The per-package roll-up of every function in the binary.
pub fn render_module_summary(
stats : Array[FunctionStat],
file_size : Int,
) -> String {
let modules = aggregate_by_module(stats)
// The name column is the only variable-width one, so the gutter it needs is
// simply the longest name; every other column keeps a fixed width.
let mut gutter = "MODULE".length()
let mut functions = 0
let mut covered = 0
for group in modules {
if group.name.length() > gutter {
gutter = group.name.length()
}
functions = functions + group.functions
covered = covered + group.total_size
}
let mut out = "Size by module\n"
if modules.length() == 0 {
return out + "\n no functions in this binary"
}
out = out +
"\n " +
pad_right("MODULE", gutter) +
" " +
pad_left("FUNCS", 5) +
" " +
pad_left("BODY", 10) +
" " +
pad_left("TOTAL", 10) +
" " +
pad_left("SHARE", 7) +
"\n"
for group in modules {
out = out +
" " +
pad_right(group.name, gutter) +
" " +
pad_left(group.functions.to_string(), 5) +
" " +
pad_left(group.body_size.to_string(), 10) +
" " +
pad_left(group.total_size.to_string(), 10) +
" " +
pad_left(share_text(group.total_size, file_size), 7) +
"\n"
}
out = out +
"\n " +
plural(modules.length(), "module") +
", " +
plural(functions, "function") +
", " +
covered.to_string() +
" bytes (" +
share_text(covered, file_size) +
" of file)"
out
}
///|
/// Printed when the tool is invoked without a file, or with arguments it cannot
/// make sense of.
pub fn usage() -> String {
"usage: moonsize [--top ] [--retained] [--dead-code]\n" +
" [--compress] [--call-graph ] [--html ]\n" +
" [--max-size ] [--baseline ]\n\n" +
"Report the section table of a WebAssembly binary, rank its heaviest\n" +
"functions, roll the bytes up by module, and follow the call graph to\n" +
"find what can be deleted.\n\n" +
" --top how many functions to rank (default 10)\n" +
" --retained what deleting each function would free\n" +
" --dead-code functions no root can reach\n" +
" --compress what the file and each section cost gzip-compressed\n" +
" --call-graph write the call graph as .dot or .json\n" +
" --html write the charts as a self-contained HTML report\n" +
" --max-size fail with exit code 3 above this size, where size is\n" +
" bytes or a KB/MB/GB count such as 10KB or 1.5MB\n" +
" --baseline compare against another module and print the change;\n" +
" --max-size then bounds the growth, not the file\n\n" +
"Exit codes: 0 ok, 1 unreadable input, 2 bad command line, 3 over budget."
}
///|
/// A lookup from function index to the name to print for it.
fn names_by_index(stats : Array[FunctionStat]) -> Map[Int, String] {
let names : Map[Int, String] = Map([])
for stat in stats {
names.set(stat.index, stat.name)
}
names
}
///|
/// The name to print for an index, with a fallback for anything the size table
/// does not know about.
fn name_of(names : Map[Int, String], index : Int) -> String {
match names.get(index) {
Some(name) => name
None => UNNAMED_FUNCTION
}
}
///|
/// The retained-size table: what deleting each function would free.
///
/// `SIZE` is the function on its own and `RETAINED` is that plus every function
/// that becomes unreachable with it, with `DIES` counting how many others that
/// is. A row where the two sizes match is load-bearing only for itself; a row
/// where `RETAINED` dwarfs `SIZE` is a good place to look when shrinking a
/// binary, because removing it takes everything underneath with it.
pub fn render_retained(
stats : Array[FunctionStat],
retained : Array[RetainedSize],
indirect : Set[Int],
file_size : Int,
top : Int,
) -> String {
let names = names_by_index(stats)
let ranked = retained.copy()
ranked.sort_by(fn(a, b) {
if a.retained != b.retained {
b.retained - a.retained
} else {
a.index - b.index
}
})
let shown = if top < 0 {
0
} else if top > ranked.length() {
ranked.length()
} else {
top
}
let mut index_width = 5
let mut size_width = 4
let mut retained_width = 8
for entry in retained {
size_width = number_width(entry.own_size, size_width)
retained_width = number_width(entry.retained, retained_width)
index_width = number_width(entry.index, index_width)
}
let mut out = "Retained size\n"
if shown == 0 {
return out + "\n no reachable functions to rank"
}
let rank_width = number_width(shown, 3)
out = out +
"\n " +
pad_left("#", rank_width) +
" " +
pad_left("INDEX", index_width) +
" " +
pad_left("SIZE", size_width) +
" " +
pad_left("RETAINED", retained_width) +
" " +
pad_left("DIES", 4) +
" " +
pad_left("SHARE", 7) +
" IND FUNCTION\n"
let mut rank = 0
while rank < shown {
let entry = ranked[rank]
out = out +
" " +
pad_left((rank + 1).to_string(), rank_width) +
" " +
pad_left(entry.index.to_string(), index_width) +
" " +
pad_left(entry.own_size.to_string(), size_width) +
" " +
pad_left(entry.retained.to_string(), retained_width) +
" " +
pad_left(entry.retained_functions.to_string(), 4) +
" " +
pad_left(share_text(entry.retained, file_size), 7) +
" " +
pad_left(if indirect.contains(entry.index) { "*" } else { "" }, 3) +
" " +
name_of(names, entry.index) +
"\n"
rank = rank + 1
}
out = out +
"\n DIES counts the other functions that become unreachable with this one.\n" +
" IND marks a function a call_indirect could reach."
out
}
///|
/// The dead-code section: functions no root can reach.
///
/// This is the report that pays for the analysis. Anything listed here cannot
/// run: no export, no start function, no table entry and no path of calls leads
/// to it, so its bytes are already paid for and never used.
pub fn render_dead_code(
stats : Array[FunctionStat],
reachable : Set[Int],
file_size : Int,
top : Int,
) -> String {
let dead : Array[FunctionStat] = []
let mut bytes = 0
for stat in stats {
if !reachable.contains(stat.index) {
dead.push(stat)
bytes = bytes + stat.total_size
}
}
dead.sort_by(fn(a, b) {
if a.total_size != b.total_size {
b.total_size - a.total_size
} else {
a.index - b.index
}
})
let mut out = "Dead code\n"
if stats.length() == 0 {
return out + "\n no functions in this binary"
}
out = out +
"\n " +
dead.length().to_string() +
" of " +
stats.length().to_string() +
" functions are unreachable from the roots\n" +
" " +
bytes.to_string() +
" bytes (" +
share_text(bytes, file_size) +
" of file)\n"
if dead.length() == 0 {
return out + "\n every function is reachable"
}
let shown = if top < 0 {
0
} else if top > dead.length() {
dead.length()
} else {
top
}
let mut size_width = 4
for stat in dead {
size_width = number_width(stat.total_size, size_width)
}
out = out +
"\n Top " +
plural(shown, "dead function") +
"\n\n " +
pad_left("SIZE", size_width) +
" " +
pad_left("SHARE", 7) +
" FUNCTION\n"
let mut rank = 0
while rank < shown {
let stat = dead[rank]
if rank > 0 {
out = out + "\n"
}
out = out +
" " +
pad_left(stat.total_size.to_string(), size_width) +
" " +
pad_left(share_text(stat.total_size, file_size), 7) +
" " +
stat.name
rank = rank + 1
}
out
}
///|
/// A compression ratio with two decimals, or `n/a` when there is nothing to
/// divide by.
fn ratio_text(raw : Int, gzip : Int) -> String {
if gzip <= 0 {
return "n/a"
}
let hundredths = (raw.to_double() / gzip.to_double() * 100.0).to_int()
let fraction = hundredths % 100
let fraction_text = if fraction < 10 {
"0" + fraction.to_string()
} else {
fraction.to_string()
}
(hundredths / 100).to_string() + "." + fraction_text + "x"
}
///|
/// The compression report: what the file costs as a transfer, and which sections
/// that cost is made of.
///
/// `SHARE(GZIP)` is a section's compressed size as a share of the whole file's
/// compressed size, not of its raw size, because that is the distribution a
/// network pays for. A section that is large raw and small compressed is not the
/// expensive one, and only this column says so.
///
/// The totals and the per-section numbers come from compressing each piece on its
/// own, so the sections do not add up to the total. Two effects are at work:
/// gzip finds redundancy between sections that compressing one in isolation
/// cannot see, and every stream pays its own header and trailer — about 20 bytes
/// — which a section smaller than that shows up as a ratio below 1. The shares
/// barely move for the same reason: 20 bytes against a table of kilobytes.
pub fn render_compression(
raw_size : Int,
gzip_size : Int,
sections : Array[SectionCompression],
) -> String {
let mut label_width = "SECTION".length()
let mut raw_width = "RAW".length()
let mut gzip_width = "GZIP".length()
let mut ratio_width = "RATIO".length()
let ratios : Array[String] = []
for section in sections {
if section.label.length() > label_width {
label_width = section.label.length()
}
raw_width = number_width(section.raw, raw_width)
gzip_width = number_width(section.gzip, gzip_width)
let ratio = ratio_text(section.raw, section.gzip)
if ratio.length() > ratio_width {
ratio_width = ratio.length()
}
ratios.push(ratio)
}
let share_width = "SHARE(GZIP)".length()
let total_width = number_width(gzip_size, number_width(raw_size, 1))
let mut out = "COMPRESSED SIZE\n " +
pad_right("raw", 4) +
" " +
pad_left(raw_size.to_string(), total_width) +
" B\n " +
pad_right("gzip", 4) +
" " +
pad_left(gzip_size.to_string(), total_width) +
" B (" +
share_text(gzip_size, raw_size) +
" of raw, ratio " +
ratio_text(raw_size, gzip_size) +
")\n\n BY SECTION\n"
if sections.length() == 0 {
return out + "\n no sections"
}
out = out +
"\n " +
pad_right("SECTION", label_width) +
" " +
pad_left("RAW", raw_width) +
" " +
pad_left("GZIP", gzip_width) +
" " +
pad_left("RATIO", ratio_width) +
" " +
pad_left("SHARE(GZIP)", share_width) +
"\n"
let mut i = 0
for section in sections {
out = out +
" " +
pad_right(section.label, label_width) +
" " +
pad_left(section.raw.to_string(), raw_width) +
" " +
pad_left(section.gzip.to_string(), gzip_width) +
" " +
pad_left(ratios[i], ratio_width) +
" " +
pad_left(share_text(section.gzip, gzip_size), share_width) +
"\n"
i = i + 1
}
out
}