xo-umbrella2/xo-cmake/bin/xo-loc.in

1023 lines
37 KiB
Python

#!/usr/bin/env python3
"""xo-loc - treemap of the xo subsystem world, area proportional to lines of code.
Sibling to xo-deps. Counts the tree live with scc on every invocation; there is
deliberately no checked-in snapshot data file.
This file is configure_file()'d by xo-cmake, but is kept valid Python as-is:
the only configure_file substitution sits inside a string literal, so the tests can
import it straight from the source tree.
"""
import argparse
import collections
import csv
import io
import json
import os
import subprocess
import sys
XO_LOC_VERSION = "@PROJECT_VERSION@"
# ----------------------------------------------------------------
# normalize: backend output -> a table nothing downstream can tell the
# backend from. A cloc adapter would emit the same Row list with
# complexity=0 and generated=False.
Row = collections.namedtuple(
"Row", "subsystem path language kind generated loc complexity")
KIND_BY_LANGUAGE = {
# Code
"C++": "Code",
"C++ Header": "Code",
"C Header": "Code",
"C": "Code",
"Objective C++": "Code",
"Python": "Code",
"JavaScript": "Code",
"GLSL": "Code",
"LLVM IR": "Code",
# Build
"CMake": "Build",
"Nix": "Build",
"Makefile": "Build",
"Autoconf": "Build",
"BASH": "Build",
"Shell": "Build",
"YAML": "Build",
"INI": "Build",
"Patch": "Build",
# Docs
"ReStructuredText": "Docs",
"Markdown": "Docs",
"Plain Text": "Docs",
"License": "Docs",
"HTML": "Docs",
# Data
"JSON5": "Data",
"JSON": "Data",
"Jinja": "Data",
"SVG": "Data",
}
# the kind vocabulary, in the order the legend presents it
KIND_ORDER = ("Code", "Build", "Docs", "Data", "Generated", "Other")
def kind_of(language, generated):
"""Artifact kind for a file.
'generated' is a per-file property orthogonal to language -- a generated
.hpp is still C++ -- so it overrides the language mapping rather than
being looked up in it.
"""
if generated:
return "Generated"
return KIND_BY_LANGUAGE.get(language, "Other")
def _repo_relative(path, prefix):
"""Strip the walk root from an scc Location.
scc echoes its walk root into every Location verbatim, so an absolute
--root yields absolute Locations. Everything downstream keys on the first
path component, so the prefix has to come off here.
"""
if prefix and path.startswith(prefix):
path = path[len(prefix):]
if path.startswith("./"):
path = path[2:]
return path
def normalize(raw, root=None):
"""scc --by-file -f json output -> [Row].
`root` is the directory scc walked; Locations are made relative to it.
"""
prefix = ""
if root:
prefix = os.path.normpath(root)
if prefix == ".":
prefix = ""
elif not prefix.endswith(os.sep):
prefix += os.sep
rows = []
for lang in raw:
language = lang["Name"]
for f in lang["Files"]:
path = _repo_relative(f["Location"], prefix)
generated = bool(f.get("Generated", False))
rows.append(Row(subsystem=path.split("/")[0],
path=path,
language=language,
kind=kind_of(language, generated),
generated=generated,
loc=f["Code"],
complexity=f["Complexity"]))
return rows
# ----------------------------------------------------------------
# select: apply exclusions, aggregate to (subsystem, kind).
#
# Every exclusion is counted and reported. A treemap that silently drops a
# third of the tree is worse than no treemap.
DEFAULT_VENDORED = ("xo-imgui/include/imgui/",)
Cell = collections.namedtuple("Cell", "loc complexity nfiles languages")
# `languages` is optional so callers (and tests) can build a cell positionally
Cell.__new__.__defaults__ = (None,)
Selection = collections.namedtuple("Selection", "groups notes")
def select(rows, known_subsystems,
subsystems=None, include_generated=False, include_vendored=False,
vendored=DEFAULT_VENDORED, kinds=None, min_loc=20):
notes = []
# 1. xo-* directories only. The root walk also sees pkgs/, docs/,
# README.md and xo-deps.svg (a *file* matching xo-*).
rows = [r for r in rows if r.subsystem in known_subsystems]
# 2. emptiness is judged on TOTAL loc, before any exclusion. Judging it on
# code loc would drop xo-cmake, which is a real subsystem that happens to
# be 100% Build.
total_by_sub = collections.Counter()
for r in rows:
total_by_sub[r.subsystem] += r.loc
empty = sorted(s for s in known_subsystems if total_by_sub[s] < min_loc)
# report only what the caller actually asked about: `xo-loc xo-gc` has no
# business naming placeholders elsewhere in the tree
named = [s for s in empty if not subsystems or s in set(subsystems)]
if named:
notes.append("%d subsystem(s) empty, not drawn: %s"
% (len(named), ", ".join(named)))
rows = [r for r in rows if r.subsystem not in empty]
# 3. vendored third-party
if not include_vendored:
def is_vendored(row):
return any(row.path.startswith(v) for v in vendored)
hidden = [r for r in rows if is_vendored(r)]
if hidden:
notes.append("excluded vendored: %s (%s LOC, %d files)"
% (", ".join(vendored),
sum(r.loc for r in hidden), len(hidden)))
rows = [r for r in rows if not is_vendored(r)]
# 4. generated
if not include_generated:
hidden = [r for r in rows if r.generated]
if hidden:
notes.append("excluded %d generated files (%s LOC)"
% (len(hidden), sum(r.loc for r in hidden)))
rows = [r for r in rows if not r.generated]
# 5. explicit kind filter -- an exclusion like any other, so it reports too
if kinds:
hidden = [r for r in rows if r.kind not in kinds]
if hidden:
notes.append("excluded %d file(s) (%s LOC) outside --kinds=%s"
% (len(hidden), sum(r.loc for r in hidden),
",".join(kinds)))
rows = [r for r in rows if r.kind in kinds]
# 6. positional subsystem arguments restrict the OUTPUT (never the walk)
if subsystems:
rows = [r for r in rows if r.subsystem in set(subsystems)]
unmapped = sorted({r.language for r in rows if r.kind == "Other"})
if unmapped:
notes.append("unmapped language(s) bucketed as Other: %s"
% ", ".join(unmapped))
groups = {}
for r in rows:
cell = groups.setdefault(r.subsystem, {}).get(
r.kind, Cell(0, 0, 0, {}))
languages = dict(cell.languages or {})
languages[r.language] = languages.get(r.language, 0) + r.loc
groups[r.subsystem][r.kind] = Cell(cell.loc + r.loc,
cell.complexity + r.complexity,
cell.nfiles + 1,
languages)
# a subsystem can survive emptiness yet have nothing left after filtering
drained = sorted(s for s in total_by_sub
if s not in empty and s not in groups
and (not subsystems or s in set(subsystems)))
if drained:
notes.append("%d subsystem(s) have no content in this view: %s"
% (len(drained), ", ".join(drained)))
return Selection(groups=groups, notes=notes)
def subsystem_loc(groups):
"""subsystem -> total LOC across its kinds."""
return {s: sum(c.loc for c in kinds.values()) for s, kinds in groups.items()}
# ----------------------------------------------------------------
# layout: squarified treemap (Bruls, Huizing & van Wijk).
#
# Pure: weights and a rectangle in, rectangles out. Nesting is just calling
# this again on a tile's own rect.
Rect = collections.namedtuple("Rect", "x y w h")
Placement = collections.namedtuple("Placement", "key rect")
def _worst(areas, length):
"""Worst aspect ratio in a row of the given areas laid along `length`."""
total = sum(areas)
if total <= 0 or length <= 0:
return float("inf")
hi, lo = max(areas), min(areas)
if lo <= 0:
return float("inf")
return max((length * length * hi) / (total * total),
(total * total) / (length * length * lo))
def _place_row(row, rect, out):
"""Lay `row` (list of (key, area)) along the short side of `rect`.
Returns the leftover rect.
"""
total = sum(a for _, a in row)
if rect.w >= rect.h:
width = total / rect.h if rect.h else 0.0
y = rect.y
for key, area in row:
h = area / width if width else 0.0
out.append(Placement(key, Rect(rect.x, y, width, h)))
y += h
return Rect(rect.x + width, rect.y, rect.w - width, rect.h)
height = total / rect.w if rect.w else 0.0
x = rect.x
for key, area in row:
w = area / height if height else 0.0
out.append(Placement(key, Rect(x, rect.y, w, height)))
x += w
return Rect(rect.x, rect.y + height, rect.w, rect.h - height)
def _aspect(rect):
"""Long/short ratio of a rect; inf for a degenerate one."""
if rect.w <= 0 or rect.h <= 0:
return float("inf")
return max(rect.w / rect.h, rect.h / rect.w)
def squarify(items, rect):
"""items: [(key, weight)] -> [Placement] tiling `rect` exactly."""
items = [(k, float(w)) for k, w in items if w > 0]
if not items or rect.w <= 0 or rect.h <= 0:
return []
total = sum(w for _, w in items)
scale = (rect.w * rect.h) / total
scaled = sorted(((k, w * scale) for k, w in items), key=lambda kv: -kv[1])
out = []
remaining = rect
while scaled:
length = min(remaining.w, remaining.h)
row = []
for key, area in scaled:
candidate = [a for _, a in row] + [area]
if row and _worst(candidate, length) > _worst([a for _, a in row], length):
break
row.append((key, area))
remaining = _place_row(row, remaining, out)
scaled = scaled[len(row):]
return out
# a tile must be able to hold its own label
LABEL_W = 64.0
LABEL_H = 18.0
A_MIN = LABEL_W * LABEL_H
# A kind must be at least this fraction of its own subsystem to earn a sub-tile.
#
# This is a significance test, not a size test: 2% build material says the build
# is not a meaningful contributor to that subsystem, whatever the subsystem's
# absolute size, while 30% build material matters even in a small one. An
# absolute area floor cannot express that -- it scales with the subsystem.
#
# It also does most of the work of keeping sub-tiles from becoming slivers.
# Exact tiling of a rectangle admits only a guillotine cut, so a kind holding
# fraction f of a parent of long/short ratio a becomes a strip of aspect
# 1/(a*f); f >= 1/8 holds the MINOR tile to 8:1 for a squarish parent. It is
# not a guarantee on its own, though -- the major tile runs at a*(1-f), which a
# slivery parent blows past -- so MAX_KIND_ASPECT below enforces the shape
# directly rather than trusting the algebra.
MIN_KIND_FRACTION = 0.125
# No sub-tile may be worse than this; past it a tile reads as a seam rather
# than a region. A parent too lopsided to split within this bound renders flat.
MAX_KIND_ASPECT = 8.0
FloorResult = collections.namedtuple("FloorResult", "weights floored inflation")
Tile = collections.namedtuple("Tile", "subsystem rect kind_rects dominant")
def apply_min_area(weights, canvas_area, min_tile_area=A_MIN):
"""Raise small weights so no tile falls below `min_tile_area`.
Solves F = T*a/(1 - N*a), which is exactly the F for which
F / sum(max(w, F)) >= a -- i.e. the smallest tile lands at min_tile_area.
"""
total = float(sum(weights.values()))
n = len(weights)
if total <= 0 or n == 0:
return FloorResult(dict(weights), [], 0.0)
a = min_tile_area / canvas_area
if n * a >= 1.0:
# the canvas is too small to give everyone a legible tile; do not
# pretend otherwise by inflating into nonsense
return FloorResult(dict(weights), [], 0.0)
floor = total * a / (1.0 - n * a)
floored = sorted(k for k, w in weights.items() if w < floor)
if not floored:
return FloorResult(dict(weights), [], 0.0)
adjusted = {k: max(float(w), floor) for k, w in weights.items()}
inflation = (sum(adjusted.values()) - total) / total
return FloorResult(adjusted, floored, inflation)
def _subdivide(kinds, rect, min_tile_area, min_kind_fraction):
"""Kind sub-tiles for one subsystem, or [] to draw the tile flat.
Two independent gates, because they answer different questions:
significance -- is this kind a real part of THIS subsystem? Judged as a
fraction of the subsystem's own LOC, so it does not drift with the
subsystem's size (see MIN_KIND_FRACTION).
legibility -- can you actually see the result? Judged on the laid-out
rectangle: big enough to label, and not a splinter. Both are absolute
properties of the drawn shape, so they are checked after layout rather
than predicted from the weights.
Dropping a kind grows the survivors, which can lift a borderline sub-tile
over the bar, so the legibility gate iterates to a fixpoint. Whatever is
dropped has its area absorbed by the survivors, and the caller reports it.
"""
total = sum(c.loc for c in kinds.values())
if total <= 0:
return []
keep = {k: c for k, c in kinds.items()
if c.loc >= min_kind_fraction * total}
while len(keep) > 1:
placed = squarify(sorted((k, c.loc) for k, c in keep.items()), rect)
unreadable = [p.key for p in placed
if p.rect.w * p.rect.h < min_tile_area
or _aspect(p.rect) > MAX_KIND_ASPECT]
if not unreadable:
return placed
for k in unreadable:
keep.pop(k, None)
return []
def layout(groups, canvas, min_tile_area=A_MIN,
min_kind_fraction=MIN_KIND_FRACTION):
"""groups -> ([Tile], [note]). Two levels: subsystem, then kind."""
notes = []
totals = subsystem_loc(groups)
if not totals:
return [], notes
floor = apply_min_area(totals, canvas.w * canvas.h, min_tile_area)
if floor.floored:
notes.append("min-area floor applied to %d subsystem(s) (+%.2f%% area): %s"
% (len(floor.floored), 100.0 * floor.inflation,
", ".join(floor.floored)))
tiles = []
folded_cells = 0
folded_loc = 0
drawn_loc = 0
for placement in squarify(sorted(floor.weights.items()), canvas):
kinds = groups[placement.key]
dominant = max(sorted(kinds.items()), key=lambda kv: kv[1].loc)[0]
kind_rects = _subdivide(kinds, placement.rect,
min_tile_area, min_kind_fraction)
drawn_loc += sum(c.loc for c in kinds.values())
if kind_rects:
shown = {p.key for p in kind_rects}
folded_cells += len(kinds) - len(shown)
folded_loc += sum(c.loc for k, c in kinds.items() if k not in shown)
tiles.append(Tile(subsystem=placement.key, rect=placement.rect,
kind_rects=kind_rects, dominant=dominant))
# the map must never hide something silently
if folded_cells:
notes.append(
"%d kind cell(s) folded into their subsystem's remaining kinds "
"(%.2f%% of drawn LOC): under %g%% of the subsystem, or too "
"small to label"
% (folded_cells, 100.0 * folded_loc / drawn_loc if drawn_loc else 0.0,
100.0 * min_kind_fraction))
return tiles, notes
# ----------------------------------------------------------------
# collect: the only stage that touches the outside world.
SCC_GENERATED_MARKER = "Generated automagically"
class XoLocError(Exception):
"""A condition worth reporting to the user rather than a traceback."""
def find_root(explicit=None, env=None, cwd=None):
"""Locate the umbrella source root.
The scc walk MUST start here: scc only honours .gitignore files at or below
its walk root, so walking a subsystem directory re-admits the .build tree.
Ladder: --root, then $XO_SOURCE_ROOT, then git, then an upward search.
"""
if explicit:
return explicit
if env:
return env
start = cwd or os.getcwd()
try:
out = subprocess.run(["git", "-C", start, "rev-parse", "--show-toplevel"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
check=True, text=True)
top = out.stdout.strip()
if top and os.path.isdir(os.path.join(top, "xo-cmake")):
return top
except (OSError, subprocess.CalledProcessError):
pass
path = os.path.abspath(start)
while True:
if os.path.isdir(os.path.join(path, "xo-cmake")):
return path
parent = os.path.dirname(path)
if parent == path:
raise XoLocError(
"cannot locate the xo source root from %s; pass --root=DIR "
"or set $XO_SOURCE_ROOT" % start)
path = parent
def known_subsystems(root):
"""The xo-* DIRECTORIES under `root`.
Directories only: the root also holds xo-deps.svg, a file matching xo-*.
"""
return {name for name in os.listdir(root)
if name.startswith("xo-")
and os.path.isdir(os.path.join(root, name))}
def run_scc(root, scc="scc"):
"""Run scc once over the whole tree; return parsed JSON."""
cmd = [scc, "--by-file", "--gen",
"--generated-markers", SCC_GENERATED_MARKER,
"-f", "json", root]
try:
out = subprocess.run(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, check=True, text=True)
except OSError as exc:
# not just FileNotFoundError: a --scc=PATH that exists but is not
# executable raises PermissionError, and a traceback is not a report
raise XoLocError(
"cannot run %s (%s). xo-loc needs scc >= 3.5.0 to count the "
"tree; install it into the dev environment (see shells.nix)."
% (scc, exc.strerror or exc))
except subprocess.CalledProcessError as exc:
raise XoLocError("%s failed: %s" % (scc, exc.stderr.strip()))
try:
return json.loads(out.stdout)
except ValueError as exc:
raise XoLocError("could not parse %s json output: %s" % (scc, exc))
# ----------------------------------------------------------------
# render: table formats. svg and html register themselves further down, after
# the palette they depend on.
def _subsystem_summary(groups):
out = {}
for subsystem, kinds in sorted(groups.items()):
loc = sum(c.loc for c in kinds.values())
complexity = sum(c.complexity for c in kinds.values())
out[subsystem] = {
"loc": loc,
"complexity": complexity,
"cx_per_kloc": (1000.0 * complexity / loc) if loc else 0.0,
"nfiles": sum(c.nfiles for c in kinds.values()),
"kinds": {k: {"loc": c.loc,
"complexity": c.complexity,
"nfiles": c.nfiles}
for k, c in sorted(kinds.items())},
}
return out
def emit_csv(tiles, groups, notes, args):
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(["subsystem", "kind", "loc", "complexity", "nfiles"])
for subsystem, kinds in sorted(groups.items()):
for kind, cell in sorted(kinds.items()):
writer.writerow([subsystem, kind, cell.loc, cell.complexity,
cell.nfiles])
return buf.getvalue()
def emit_json(tiles, groups, notes, args):
summary = _subsystem_summary(groups)
return json.dumps({
"version": XO_LOC_VERSION,
"total_loc": sum(s["loc"] for s in summary.values()),
"subsystems": summary,
"notes": list(notes),
}, indent=2, sort_keys=True) + "\n"
RENDERERS = {
"csv": emit_csv,
"json": emit_json,
}
# ----------------------------------------------------------------
# render: svg
#
# Palette: validated with the dataviz skill's validator, not chosen by eye.
#
# validate_palette.js "<the six below>" --mode light \
# --surface "#fcfcfb" --pairs all -> ALL CHECKS PASS
#
# --pairs all, not the default adjacent pairlist: a treemap puts arbitrary
# tiles against each other, so every pair has to hold up, not just neighbours.
# That is a much harder gate -- the reference palette's own eight hues fail it
# (green vs orange collapses to dE 3.2 under protanopia), which is why these
# are derived rather than taken from the documented categorical order. Only
# Code keeps the reference blue, being the hue that dominates the map.
#
# worst all-pairs CVD dE 9.9 (deutan), target >= 8
# worst all-pairs normal-vision dE 19.8, floor 15
# the default view (Code/Build/Docs/Data -- Generated is opt-in and Other
# appears only for an unmapped language) scores 10.2 / 24.3 and clears 3:1
# contrast outright
#
# Generated is the one slot under 3:1 (2.29), which the validator flags as
# needing relief. That is deliberate rather than tolerated: pale and receding
# is what "machine-written, not hand-written" should look like, and the relief
# is already there -- every tile carries a <title> tooltip, large tiles are
# directly labelled, --format=html adds a legend, and csv/json are the table
# view. Identity never rests on colour alone.
KIND_COLORS = {
"Code": "#2a78d6", # blue - primary; Code dominates the map
"Build": "#843826", # rust - machinery, visibly not code
"Docs": "#1ea81d", # green - prose
"Data": "#e74b8c", # pink - data files
"Generated": "#b49ee3", # lavender - pale: recedes, not hand-written
"Other": "#8927a1", # purple - unclassified languages
}
NEUTRAL_COLOR = "#c3c2b7" # non-code under --color=complexity
BACKGROUND_COLOR = "#fcfcfb" # light chart surface, set explicitly: xo-deps.in:196
# Labels sit on top of a tile, so they are judged against that tile's own fill,
# never against the canvas. One fixed colour cannot work across this palette:
# white on Generated is 2.35:1 and white on the pale end of the complexity ramp
# is 1.54:1, while ink on Build is 2.41:1. Pick per tile instead.
LABEL_INK = "#0b0b0b"
LABEL_ON_DARK = "#ffffff"
# observed cx/kLOC range on this tree
CX_LO = 10.0
CX_HI = 165.0
# sequential blue ramp for --color=complexity, light -> dark.
# Sequential encoding, so the full 100->700 range is in play.
CX_RAMP = ["#b7d3f6", "#86b6ef", "#5598e7", "#2a78d6", "#1c5cab", "#104281"]
def svg_escape(text):
return (str(text).replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
def _relative_luminance(color):
"""WCAG relative luminance of a #rrggbb colour."""
channels = []
for i in (1, 3, 5):
c = int(color[i:i + 2], 16) / 255.0
channels.append(c / 12.92 if c <= 0.03928
else ((c + 0.055) / 1.055) ** 2.4)
r, g, b = channels
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(a, b):
"""WCAG contrast ratio between two #rrggbb colours."""
la, lb = _relative_luminance(a), _relative_luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
def label_color(*fills):
"""Whichever of ink / white reads best across every fill it touches.
Returns (colour, worst_contrast). A label sitting across two sub-tiles is
judged on the worse of them, so a colour that works on one but vanishes on
its neighbour is not chosen.
"""
best = None
for ink in (LABEL_INK, LABEL_ON_DARK):
worst = min(contrast_ratio(ink, f) for f in fills)
if best is None or worst > best[1]:
best = (ink, worst)
return best
def tile_color(kind, cx_per_kloc, mode):
if mode == "complexity":
# scc complexity is ~0 for Docs/Data/Build languages, so a complexity
# scale is only meaningful over Code
if kind != "Code":
return NEUTRAL_COLOR
span = CX_HI - CX_LO
t = 0.0 if span <= 0 else (cx_per_kloc - CX_LO) / span
t = min(1.0, max(0.0, t))
return CX_RAMP[min(len(CX_RAMP) - 1, int(t * len(CX_RAMP)))]
return KIND_COLORS.get(kind, KIND_COLORS["Other"])
def _cx_per_kloc(cell):
return (1000.0 * cell.complexity / cell.loc) if cell.loc else 0.0
def _languages_of(cells):
"""Merged 'C++ 400, CMake 25' summary, biggest first."""
merged = {}
for cell in cells:
for language, loc in (cell.languages or {}).items():
merged[language] = merged.get(language, 0) + loc
ranked = sorted(merged.items(), key=lambda kv: (-kv[1], kv[0]))
return ", ".join("%s %d" % (language, loc) for language, loc in ranked)
def _tooltip(subsystem, kind, cell):
"""The spec requires LOC, complexity, cx/kLOC, file count and languages."""
return ("%s / %s: %d LOC, cx %d (%.0f/kLOC), %d files [%s]"
% (subsystem, kind, cell.loc, cell.complexity,
_cx_per_kloc(cell), cell.nfiles, _languages_of([cell])))
def _svg_rect(rect, fill, title):
return (' <rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" '
'fill="%s" stroke="%s" stroke-width="1"><title>%s</title></rect>'
% (rect.x, rect.y, rect.w, rect.h, fill, BACKGROUND_COLOR,
svg_escape(title)))
MIN_LABEL_CONTRAST = 3.0 # WCAG large-text minimum
def _overlaps(a, b):
return (a.x < b.x + b.w and b.x < a.x + a.w
and a.y < b.y + b.h and b.y < a.y + a.h)
def _svg_label(rect, text, leaves):
"""Label for a tile, or None when it cannot be drawn readably.
`leaves` is [(rect, fill)] for the tile's drawn regions. The label box
sits in the tile's top-left corner and on a subdivided tile can straddle
more than one sub-tile, so the colour is judged against every fill it
actually covers -- not just the first one. If no colour clears the
large-text minimum on all of them, the label is dropped rather than
rendered illegibly; the tooltip still carries the name.
"""
if rect.w < LABEL_W or rect.h < LABEL_H:
return None
box = Rect(rect.x + 4, rect.y + 2, LABEL_W, LABEL_H)
fills = [fill for leaf, fill in leaves if _overlaps(box, leaf)]
if not fills:
return None
ink, worst = label_color(*fills)
if worst < MIN_LABEL_CONTRAST:
return None
return (' <text x="%.2f" y="%.2f" font-family="system-ui, -apple-system, '
'\'Segoe UI\', sans-serif" font-size="11" fill="%s">%s</text>'
% (rect.x + 4, rect.y + 13, ink, svg_escape(text)))
def emit_svg(tiles, groups, notes, args):
out = [
'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d">' % (args.width, args.height, args.width, args.height),
' <rect x="0" y="0" width="%d" height="%d" fill="%s"/>'
% (args.width, args.height, BACKGROUND_COLOR),
]
for tile in tiles:
kinds = groups[tile.subsystem]
total = sum(c.loc for c in kinds.values())
leaves = []
if tile.kind_rects:
for placement in tile.kind_rects:
cell = kinds[placement.key]
fill = tile_color(placement.key, _cx_per_kloc(cell), args.color)
leaves.append((placement.rect, fill))
out.append(_svg_rect(
placement.rect, fill,
_tooltip(tile.subsystem, placement.key, cell)))
else:
# flat tile: the tooltip must still account for the whole subsystem,
# not just its dominant kind
cell = kinds[tile.dominant]
complexity = sum(c.complexity for c in kinds.values())
nfiles = sum(c.nfiles for c in kinds.values())
fill = tile_color(tile.dominant, _cx_per_kloc(cell), args.color)
leaves.append((tile.rect, fill))
out.append(_svg_rect(
tile.rect, fill,
"%s: %d LOC, cx %d (%.0f/kLOC), %d files, mostly %s [%s]"
% (tile.subsystem, total, complexity,
1000.0 * complexity / total if total else 0.0,
nfiles, tile.dominant, _languages_of(kinds.values()))))
label = _svg_label(tile.rect, tile.subsystem, leaves)
if label:
out.append(label)
for note in notes:
out.append(" <!-- %s -->" % svg_escape(note))
out.append("</svg>")
return "\n".join(out) + "\n"
RENDERERS["svg"] = emit_svg
# ----------------------------------------------------------------
# render: html. Self-contained -- no CDN, no external assets, like
# xo-deps --format=html.
_HTML_TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>xo-loc &mdash; %(title)s</title>
<style>
:root { --bg: #f9f9f7; --surface: #fcfcfb; --fg: #0b0b0b;
--muted: #52514e; --panel: #f0efec; --hairline: #e1e0d9; }
@media (prefers-color-scheme: dark) {
:root { --bg: #0d0d0d; --surface: #1a1a19; --fg: #ffffff;
--muted: #c3c2b7; --panel: #1f2429; --hairline: #2c2c2a; }
}
body { margin: 0; padding: 1rem; background: var(--bg); color: var(--fg);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
h1 { font-size: 1rem; font-weight: 600; margin: 0 0 .5rem; }
.wrap { overflow-x: auto; }
svg { max-width: 100%%; height: auto; }
.legend { display: flex; flex-wrap: wrap; gap: .75rem; margin: .5rem 0; }
.legend span { display: inline-flex; align-items: center; gap: .35rem;
font-size: .8rem; color: var(--muted); }
.swatch { width: .8rem; height: .8rem; border-radius: 2px;
border: 1px solid var(--hairline); }
.notes { background: var(--panel); border-radius: 4px; padding: .5rem .75rem;
font-size: .78rem; color: var(--muted); margin-top: .75rem; }
.notes ul { margin: 0; padding-left: 1.1rem; }
.notes li { margin: .15rem 0; }
#tip { position: fixed; pointer-events: none; background: var(--panel);
color: var(--fg); border: 1px solid var(--hairline); border-radius: 4px;
padding: .35rem .5rem; font-size: .78rem; opacity: 0;
transition: opacity .1s; max-width: 32rem; }
rect.dim { opacity: .25; }
</style>
</head>
<body>
<h1>%(title)s &mdash; %(total)s LOC across %(count)s subsystems</h1>
<div class="legend">%(legend)s</div>
<div class="wrap">%(svg)s</div>
<div class="notes"><ul>%(notes)s</ul></div>
<div id="tip"></div>
<script>
(function () {
var tip = document.getElementById('tip');
var svg = document.querySelector('svg');
if (!svg) { return; }
var rects = Array.prototype.slice.call(svg.querySelectorAll('rect'));
var tiles = rects.slice(1); // rects[0] is the background
tiles.forEach(function (r) {
var title = r.querySelector('title');
var label = title ? title.textContent : '';
r.addEventListener('mousemove', function (e) {
tip.textContent = label;
tip.style.opacity = '1';
tip.style.left = (e.clientX + 12) + 'px';
tip.style.top = (e.clientY + 12) + 'px';
});
r.addEventListener('mouseleave', function () {
tip.style.opacity = '0';
});
r.addEventListener('click', function (e) {
e.stopPropagation();
var on = r.classList.contains('focus');
tiles.forEach(function (o) {
o.classList.remove('focus');
o.classList.toggle('dim', !on && o !== r);
});
if (!on) { r.classList.add('focus'); }
});
});
document.body.addEventListener('click', function () {
tiles.forEach(function (o) {
o.classList.remove('focus');
o.classList.remove('dim');
});
});
}());
</script>
</body>
</html>
"""
def _legend_swatch(color, label):
return ('<span><i class="swatch" style="background:%s"></i>%s</span>'
% (color, svg_escape(label)))
def emit_html(tiles, groups, notes, args):
# the svg carries the notes as comments; here they get their own visible
# panel instead, so the exclusion report cannot be missed
svg = emit_svg(tiles, groups, [], args)
if args.color == "complexity":
legend = (_legend_swatch(CX_RAMP[0], "low cx/kLOC")
+ _legend_swatch(CX_RAMP[-1], "high cx/kLOC")
+ _legend_swatch(NEUTRAL_COLOR, "non-code"))
else:
legend = "".join(_legend_swatch(KIND_COLORS[k], k) for k in KIND_ORDER)
total = sum(c.loc for kinds in groups.values() for c in kinds.values())
title = ", ".join(args.subsystems) if args.subsystems else "xo subsystems"
return _HTML_TEMPLATE % {
"title": svg_escape(title),
"total": "{:,}".format(total),
"count": len(groups),
"legend": legend,
"svg": svg,
"notes": "".join("<li>%s</li>" % svg_escape(n) for n in notes)
or "<li>nothing excluded</li>",
}
RENDERERS["html"] = emit_html
# ----------------------------------------------------------------
# CLI
def build_parser():
parser = argparse.ArgumentParser(
prog="xo-loc",
description="Treemap of the xo subsystem world; area is lines of code.",
epilog="Counted live with scc on every run -- there is no snapshot file.")
parser.add_argument("subsystems", nargs="*", default=[], metavar="SUBSYSTEM",
help="restrict output to these subsystems")
parser.add_argument("--format", default="svg",
choices=["svg", "html", "csv", "json"],
help="output format (default: svg)")
parser.add_argument("--output", default=None,
help="output path (default: xo-loc.<format>)")
parser.add_argument("--color", default="kind", choices=["kind", "complexity"],
help="what tile color encodes (default: kind)")
parser.add_argument("--include-generated", action="store_true",
help="draw generated code as its own kind")
parser.add_argument("--include-vendored", action="store_true",
help="draw vendored third-party code")
parser.add_argument("--vendored", default=",".join(DEFAULT_VENDORED),
help="comma-separated vendored path prefixes")
parser.add_argument("--kinds", default=None,
help="comma-separated kinds to include")
parser.add_argument("--min-loc", type=int, default=20,
help="below this total LOC a subsystem is reported, "
"not drawn (default: 20)")
parser.add_argument("--width", type=int, default=1200, help="canvas width")
parser.add_argument("--height", type=int, default=800, help="canvas height")
parser.add_argument("--root", default=None,
help="source root (default: auto-discover)")
parser.add_argument("--scc", default="scc", help="path to the scc binary")
parser.add_argument("--list", action="store_true",
help="list subsystems and exit")
parser.add_argument("--version", action="version",
version="xo-loc " + XO_LOC_VERSION)
return parser
def resolve_args(args):
"""Normalise interdependent options. Raises XoLocError on a bad value."""
args.kinds = [k.strip() for k in args.kinds.split(",")] if args.kinds else None
if args.kinds:
unknown = [k for k in args.kinds if k not in KIND_ORDER]
if unknown:
# naming the bad value beats "nothing to draw after filtering"
raise XoLocError("unknown kind(s) %s in --kinds; expected any of %s"
% (", ".join(unknown), ", ".join(KIND_ORDER)))
args.vendored = tuple(v.strip() for v in args.vendored.split(",") if v.strip())
if args.kinds and "Generated" in args.kinds:
args.include_generated = True
if args.output is None:
args.output = "xo-loc." + args.format
return args
def main(argv=None, stdout=None, stderr=None):
stdout = stdout or sys.stdout
stderr = stderr or sys.stderr
parsed = build_parser().parse_args(argv)
try:
args = resolve_args(parsed)
root = find_root(args.root, os.environ.get("XO_SOURCE_ROOT"))
subsystems = known_subsystems(root)
if args.list:
for name in sorted(subsystems):
print(name, file=stdout)
return 0
rows = normalize(run_scc(root, args.scc), root=root)
selection = select(rows, subsystems,
subsystems=args.subsystems,
include_generated=args.include_generated,
include_vendored=args.include_vendored,
vendored=args.vendored,
kinds=args.kinds,
min_loc=args.min_loc)
if not selection.groups:
raise XoLocError("nothing to draw after filtering")
canvas = Rect(0.0, 0.0, float(args.width), float(args.height))
tiles, layout_notes = layout(selection.groups, canvas)
notes = list(selection.notes) + list(layout_notes)
text = RENDERERS[args.format](tiles, selection.groups, notes, args)
with open(args.output, "w") as f:
f.write(text)
# the map must never hide something silently
for note in notes:
print("xo-loc: " + note, file=stderr)
print("xo-loc: wrote %s" % args.output, file=stderr)
return 0
except XoLocError as exc:
print("xo-loc: %s" % exc, file=stderr)
return 1
if __name__ == "__main__":
sys.exit(main())