1023 lines
37 KiB
Python
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("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">"))
|
|
|
|
|
|
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 — %(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 — %(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())
|