From d250638cfebe8955a2b316dad4004e0a969ec3bd Mon Sep 17 00:00:00 2001 From: lvcas-dotcom <134722316+lvcas-dotcom@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:09:28 -0300 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20a=20se=C3=A7=C3=A3o=20de=20detec?= =?UTF-8?q?=C3=A7=C3=A3o=20n=C3=A3o=20sai=20mais=20com=20o=20cabe=C3=A7alh?= =?UTF-8?q?o=20vazio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A auditoria de eficiência acrescentou uma dimensão à detecção — o nome que o schema dá às próprias chaves primárias — e a incluiu em Empty(). O renderizador do terminal não foi junto: ele imprime sufixo, prefixo e prefixo de tabela, e não sabe da quarta. Num schema cuja única convenção detectada seja essa, o relatório abre "DETECTED — conventions read from the schema itself", desenha a régua, e não lista nada. Uma seção afirmando ter lido convenções e nomeando nenhuma, que é o oposto do que este relatório promete. O teste percorre as quatro dimensões e falha se alguma contar como evidência sem aparecer, que é a costura que se soltou. Só apareceu porque fui gravar o demo: nenhuma leitura de código pega isto, precisa de um schema com essa forma exata. --- CHANGELOG.md | 6 +++++ internal/report/discover.go | 3 +++ internal/report/terminal_test.go | 39 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b957844..adb305a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ change between minor versions; both are versioned and documented when they do. GitLab and 18% on Discourse — each one a validation query against your database, which is why the number is published here. +- **The detection section no longer prints a heading with nothing under it.** + A new dimension — the name a schema gives its primary keys — was added to the + model and counted as evidence, but the terminal report did not know how to + print it. A schema whose only detected convention was that one got a section + announcing conventions and naming none. + ### Changed - The lexical fallback no longer extracts a table's trigrams once per column in diff --git a/internal/report/discover.go b/internal/report/discover.go index 50ca103..e5593a6 100644 --- a/internal/report/discover.go +++ b/internal/report/discover.go @@ -327,6 +327,9 @@ func writeDetection(b *strings.Builder, v DiscoverView) { {"reference suffix", v.Detection.ColumnSuffixes}, {"reference prefix", v.Detection.ColumnPrefixes}, {"table prefix", v.Detection.TablePrefixes}, + // Counted by Empty(), so leaving it out here printed the heading with + // nothing under it — a section announcing conventions and listing none. + {"key name", v.Detection.PrimaryKeyNames}, } { for _, e := range group.items { detail := fmt.Sprintf("%d occurrences (%.0f%%)", e.Occurrences, 100*e.Share) diff --git a/internal/report/terminal_test.go b/internal/report/terminal_test.go index 3f2e183..d5a34e2 100644 --- a/internal/report/terminal_test.go +++ b/internal/report/terminal_test.go @@ -374,3 +374,42 @@ func TestDetectionCountsReadAsEnglish(t *testing.T) { t.Errorf("counts of one must read as singular, got:\n%s", got) } } + +// TestDetectionSectionIsNeverEmpty guards the seam between what Empty() counts +// and what the renderer knows how to print. They drifted once: a detection +// dimension was added to the model and to Empty(), the heading started +// appearing for schemas that had only that dimension, and it appeared with +// nothing beneath it — a section claiming to have read conventions and naming +// none. +func TestDetectionSectionIsNeverEmpty(t *testing.T) { + dimensions := map[string]model.NamingDetection{ + "reference suffix": {Enabled: true, ColumnSuffixes: []model.NamingEvidence{{Affix: "_id", Occurrences: 9}}}, + "reference prefix": {Enabled: true, ColumnPrefixes: []model.NamingEvidence{{Affix: "id_", Occurrences: 9}}}, + "table prefix": {Enabled: true, TablePrefixes: []model.NamingEvidence{{Affix: "tb_", Occurrences: 9}}}, + "key name": {Enabled: true, PrimaryKeyNames: []model.NamingEvidence{{Affix: "id", Occurrences: 9}}}, + } + + for name, detection := range dimensions { + if detection.Empty() { + t.Fatalf("%s: fixture must not be empty, or it tests nothing", name) + } + + var b bytes.Buffer + view := report.DiscoverView{ + Result: model.NewResult("test", "en", time.Unix(0, 0).UTC(), + model.Coverage{TablesTotal: 1, TablesAnalyzed: 1}), + Detection: detection, + } + if err := report.Discover(&b, view); err != nil { + t.Fatalf("%s: Discover: %v", name, err) + } + + if !strings.Contains(b.String(), "DETECTED") { + t.Errorf("%s: a non-empty detection must show the section", name) + continue + } + if !strings.Contains(b.String(), "9 occurrences") { + t.Errorf("%s: the section must name what it detected:\n%s", name, b.String()) + } + } +} From a80e3b2b2e88b0864e2f63ea521edb049b71941a Mon Sep 17 00:00:00 2001 From: lvcas-dotcom <134722316+lvcas-dotcom@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:09:54 -0300 Subject: [PATCH 2/2] feat: um comando para ver a ferramenta funcionando MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quem chega pelo README dá trinta segundos. Nesse tempo não dava para ver nada: a saída colorida existia, era bonita, e exigia ter um banco. docker compose -f demo/compose.yaml up sobe um Postgres descartável, carrega um schema que quase não declara a própria integridade, e imprime o relatório. Nada do usuário envolvido, e o banco morre com o contêiner. A imagem no topo do README é a saída real renderizada em SVG, não um screenshot: screenshot ninguém consegue versionar, envelhece calado, e o mantenedor não regrava sem a mesma máquina e a mesma fonte. Isto lê os bytes que a ferramenta emite, escapes inclusive, e sai de make demo-svg. O gerador é Go com //go:build ignore — roda por go run, não entra no binário de ninguém e não traz dependência. O SQL do demo passa a ter uma cópia só. docs/DEMO.md carregava a segunda, e duas cópias que precisam concordar sempre acabam discordando. E o alvo constrói na última tag, não na árvore de trabalho: o relatório carimba a própria versão, e uma gravação dizendo v0.1.2-16-g837ec2a-dirty conta ao leitor sobre o checkout do mantenedor em vez de sobre o banco dele. --- Makefile | 33 +++++++- README.md | 21 ++++- assets/demo.svg | 1 + demo/compose.yaml | 36 ++++++++ demo/schema.sql | 71 ++++++++++++++++ docs/DEMO.md | 74 ++--------------- docs/RELEASING.md | 3 +- scripts/termsvg.go | 200 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 366 insertions(+), 73 deletions(-) create mode 100644 assets/demo.svg create mode 100644 demo/compose.yaml create mode 100644 demo/schema.sql create mode 100644 scripts/termsvg.go diff --git a/Makefile b/Makefile index 3da19aa..36e4b5c 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,10 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) -LDFLAGS := -s -w \ +# Recursive on purpose: demo-svg overrides VERSION for its own build, and an +# immediate assignment here would have expanded VERSION before that override +# exists. +LDFLAGS = -s -w \ -X '$(PKG)/internal/buildinfo.Version=$(VERSION)' \ -X '$(PKG)/internal/buildinfo.Commit=$(COMMIT)' \ -X '$(PKG)/internal/buildinfo.Date=$(DATE)' @@ -13,7 +16,7 @@ LDFLAGS := -s -w \ # has to stay trivial. export CGO_ENABLED := 0 -.PHONY: build test test-integration corpus benchmark lint fmt cover crosscheck release-check image-check clean help +.PHONY: demo-svg build test test-integration corpus benchmark lint fmt cover crosscheck release-check image-check clean help ## build: compile the binary into ./bin build: @@ -112,6 +115,32 @@ image-check: docker buildx build --platform linux/amd64,linux/arm64 --output=type=cacheonly "$$ctx" @echo "image ok" +## demo-svg: re-record the README image from a real run +# +# The image is generated rather than screenshotted so it can be regenerated when +# the report format changes — see docs/RELEASING.md. It needs Docker. +# +# It builds at the latest tag rather than at the working tree's version, because +# the tool stamps its own version into the report and a recording that says +# v0.1.2-16-g837ec2a-dirty tells the reader about the maintainer's checkout +# instead of about their database. +demo-svg: VERSION = $(shell git describe --tags --abbrev=0 2>/dev/null || echo dev) +demo-svg: build + @ctr=pgfathom-demo-svg; \ + docker rm -f $$ctr >/dev/null 2>&1 || true; \ + docker run -d --name $$ctr -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=loja -p 55440:5432 \ + -v "$$PWD/demo/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro" postgres:16-alpine >/dev/null; \ + for i in $$(seq 40); do \ + docker exec $$ctr psql -U postgres -d loja -c 'SELECT 1 FROM nota_fiscal LIMIT 1' >/dev/null 2>&1 && break; \ + sleep 1; \ + done; \ + COLORTERM=truecolor PGFATHOM_DSN='postgres://postgres:demo@localhost:55440/loja?sslmode=disable' \ + ./bin/$(BINARY) discover --schema public --full --color always 2>/dev/null > /tmp/pgfathom-demo.ansi; \ + docker rm -f $$ctr >/dev/null; \ + go run scripts/termsvg.go < /tmp/pgfathom-demo.ansi > assets/demo.svg; \ + echo "assets/demo.svg regenerated; the plain text below goes in the README"; \ + sed 's/\x1b\[[0-9;]*m//g' /tmp/pgfathom-demo.ansi + ## clean: remove build artifacts clean: rm -rf bin dist coverage.out diff --git a/README.md b/README.md index c3a6723..87653b7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,19 @@ `pgfathom` finds the relationships your database has but never declared — and proves them against the data instead of guessing from column names. +

+ pgfathom discover finding one broken and two confirmed relationships +

+ +Try it against a throwaway database, with nothing of yours involved: + +```console +$ docker compose -f demo/compose.yaml up +``` + +That starts PostgreSQL, loads [a schema](demo/schema.sql) that declares almost none of its +own integrity, and prints exactly what you see above. + > [!IMPORTANT] > **Early, and measured.** > `pgfathom audit` and `pgfathom discover` run end to end, verdicts and reviewable @@ -78,7 +91,7 @@ $ pgfathom discover --schema public ``` - pgfathom v0.1.1 · PostgreSQL 16.14 · profile pt-br · threshold 0.50 + pgfathom v0.1.2 · PostgreSQL 16.14 · profile pt-br · threshold 0.50 full validation — every row was examined; verdicts are conclusive BROKEN — the relationship is real; its integrity is not (1) @@ -100,9 +113,11 @@ $ pgfathom discover --schema public ──────────────────────────────────────────────────────────────────────────────────────────────── none - Nothing detected from 6 tables and 1 declared key; the pt-br profile applies alone. + DETECTED — conventions read from the schema itself, added to pt-br + ──────────────────────────────────────────────────────────────────────────────────────────────── + key name id 5 occurrences (100%) — e.g. cliente, funcionario, nota_fiscal - 1 broken · 2 confirmed · 0 weak · 0 unvalidated · 0 discarded · 134ms + 1 broken · 2 confirmed · 0 weak · 0 unvalidated · 0 discarded · 133ms 6 tables · 6 analyzed (100%) stats prefilter: 3 checked · 0 rejected · 0 without statistics ! statistics reset time unknown — usage counters carry no meaning diff --git a/assets/demo.svg b/assets/demo.svg new file mode 100644 index 0000000..6d3d2ab --- /dev/null +++ b/assets/demo.svg @@ -0,0 +1 @@ + pgfathom v0.1.2 · PostgreSQL 16.14 · profile pt-br · threshold 0.50 full validation — every row was examined; verdicts are conclusiveBROKEN — the relationship is real; its integrity is not (1) ──────────────────────────────────────────────────────────────────────────────────────────────── relation rows values orphan rows orphan values examined method public.os_servico.resp_tecnico → public.funcionario.id 97.5% 60.0% 200 200 8.0k fullCONFIRMED — total containment, verified row by row (2) ──────────────────────────────────────────────────────────────────────────────────────────────── relation rows values orphan rows orphan values examined method public.item_pedido.pedido_id → public.pedido.id 100.0% 100.0% 0 0 60.0k full public.pedido.cliente_id → public.cliente.id 100.0% 100.0% 0 0 20.0k fullWEAK — the data supports no conclusion either way (0) ────────────────────────────────────────────────────────────────────────────────────────────────noneUNVALIDATED — no evidence gathered; not the same as clean (0) ────────────────────────────────────────────────────────────────────────────────────────────────noneDETECTED — conventions read from the schema itself, added to pt-br ──────────────────────────────────────────────────────────────────────────────────────────────── key name id 5 occurrences (100%) — e.g. cliente, funcionario, nota_fiscal1 broken · 2 confirmed · 0 weak · 0 unvalidated · 0 discarded · 133ms 6 tables · 6 analyzed (100%) stats prefilter: 3 checked · 0 rejected · 0 without statistics ! statistics reset time unknown — usage counters carry no meaning \ No newline at end of file diff --git a/demo/compose.yaml b/demo/compose.yaml new file mode 100644 index 0000000..4ecfead --- /dev/null +++ b/demo/compose.yaml @@ -0,0 +1,36 @@ +# The shortest path from "what is this" to seeing it work. +# +# docker compose -f demo/compose.yaml up +# +# It starts a throwaway PostgreSQL, loads a schema that declares almost none of +# its own integrity, and runs discover against it. Nothing here touches anything +# you already have: the database lives in the container and dies with it. +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: demo + POSTGRES_DB: loja + volumes: + - ./schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro + healthcheck: + # The report has to run against a loaded schema, and postgres reports + # ready once before running the init scripts. Asking for a table that the + # script creates last is the only signal that the load actually finished. + test: ["CMD-SHELL", "psql -U postgres -d loja -c 'SELECT 1 FROM nota_fiscal LIMIT 1' || exit 1"] + interval: 1s + timeout: 5s + retries: 30 + + pgfathom: + image: ghcr.io/lvcas-dotcom/pgfathom:latest + depends_on: + db: + condition: service_healthy + command: + - discover + - --dsn + - postgres://postgres:demo@db:5432/loja?sslmode=disable + - --schema + - public + - --full diff --git a/demo/schema.sql b/demo/schema.sql new file mode 100644 index 0000000..4365c2c --- /dev/null +++ b/demo/schema.sql @@ -0,0 +1,71 @@ +-- The demo schema. It is the single copy: docs/DEMO.md points here, and +-- demo/compose.yaml loads it, so the two cannot drift apart. +-- +-- Every table here exists to exercise one verdict. See docs/DEMO.md for what +-- each one is for. + +CREATE TABLE cliente ( + id bigserial PRIMARY KEY, + nome text NOT NULL +); + +CREATE TABLE funcionario ( + id bigserial PRIMARY KEY, + nome text NOT NULL +); + +CREATE TABLE pedido ( + id bigserial PRIMARY KEY, + cliente_id bigint NOT NULL, + total numeric(12,2) NOT NULL DEFAULT 0 +); + +CREATE TABLE item_pedido ( + pedido_id bigint NOT NULL, + seq integer NOT NULL, + descricao text NOT NULL, + PRIMARY KEY (pedido_id, seq) +); + +CREATE TABLE os_servico ( + id bigserial PRIMARY KEY, + resp_tecnico bigint, + aberta_em date NOT NULL DEFAULT current_date +); + +INSERT INTO cliente (nome) SELECT 'cliente ' || g FROM generate_series(1, 4000) g; +INSERT INTO funcionario (nome) SELECT 'func ' || g FROM generate_series(1, 300) g; + +-- Every order points at a customer that exists: this relationship is sound, and +-- nobody ever declared it. +INSERT INTO pedido (cliente_id, total) +SELECT 1 + (g % 4000), (g % 500)::numeric +FROM generate_series(1, 20000) g; + +INSERT INTO item_pedido (pedido_id, seq, descricao) +SELECT p.id, s, 'item ' || s +FROM pedido p, generate_series(1, 3) s; + +-- One in forty service orders names a technician who is not in the table. There +-- was never a constraint to stop it. +INSERT INTO os_servico (resp_tecnico) +SELECT CASE WHEN g % 40 = 0 THEN 900000 + g ELSE 1 + (g % 300) END +FROM generate_series(1, 8000) g; + +-- The report that ships with the system joins the two tables, which is how the +-- relationship survived in the database even though the constraint did not. +CREATE VIEW os_por_tecnico AS + SELECT f.nome, count(*) AS abertas + FROM os_servico o + JOIN funcionario f ON f.id = o.resp_tecnico + GROUP BY f.nome; + +-- A table added later, by people who did declare their keys. +CREATE TABLE nota_fiscal ( + id bigserial PRIMARY KEY, + pedido_id bigint NOT NULL REFERENCES pedido(id), + emitida_em date NOT NULL DEFAULT current_date +); +INSERT INTO nota_fiscal (pedido_id) SELECT id FROM pedido WHERE id % 3 = 0; + +ANALYZE; diff --git a/docs/DEMO.md b/docs/DEMO.md index 80f9a70..5ce1830 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -11,76 +11,16 @@ $ docker run -d --name pgfathom-demo -e POSTGRES_PASSWORD=demo \ -e POSTGRES_DB=loja -p 55432:5432 postgres:16-alpine ``` -Load it: +The schema is [`demo/schema.sql`](../demo/schema.sql), kept as one copy so this page and +the compose file cannot drift apart. Load it: -```sql -CREATE TABLE cliente ( - id bigserial PRIMARY KEY, - nome text NOT NULL -); - -CREATE TABLE funcionario ( - id bigserial PRIMARY KEY, - nome text NOT NULL -); - -CREATE TABLE pedido ( - id bigserial PRIMARY KEY, - cliente_id bigint NOT NULL, - total numeric(12,2) NOT NULL DEFAULT 0 -); - -CREATE TABLE item_pedido ( - pedido_id bigint NOT NULL, - seq integer NOT NULL, - descricao text NOT NULL, - PRIMARY KEY (pedido_id, seq) -); - -CREATE TABLE os_servico ( - id bigserial PRIMARY KEY, - resp_tecnico bigint, - aberta_em date NOT NULL DEFAULT current_date -); - -INSERT INTO cliente (nome) SELECT 'cliente ' || g FROM generate_series(1, 4000) g; -INSERT INTO funcionario (nome) SELECT 'func ' || g FROM generate_series(1, 300) g; - --- Every order points at a customer that exists: this relationship is sound, and --- nobody ever declared it. -INSERT INTO pedido (cliente_id, total) -SELECT 1 + (g % 4000), (g % 500)::numeric -FROM generate_series(1, 20000) g; - -INSERT INTO item_pedido (pedido_id, seq, descricao) -SELECT p.id, s, 'item ' || s -FROM pedido p, generate_series(1, 3) s; - --- One in forty service orders names a technician who is not in the table. There --- was never a constraint to stop it. -INSERT INTO os_servico (resp_tecnico) -SELECT CASE WHEN g % 40 = 0 THEN 900000 + g ELSE 1 + (g % 300) END -FROM generate_series(1, 8000) g; - --- The report that ships with the system joins the two tables, which is how the --- relationship survived in the database even though the constraint did not. -CREATE VIEW os_por_tecnico AS - SELECT f.nome, count(*) AS abertas - FROM os_servico o - JOIN funcionario f ON f.id = o.resp_tecnico - GROUP BY f.nome; - --- A table added later, by people who did declare their keys. -CREATE TABLE nota_fiscal ( - id bigserial PRIMARY KEY, - pedido_id bigint NOT NULL REFERENCES pedido(id), - emitida_em date NOT NULL DEFAULT current_date -); -INSERT INTO nota_fiscal (pedido_id) SELECT id FROM pedido WHERE id % 3 = 0; - -ANALYZE; +```console +$ docker exec -i pgfathom-demo psql -U postgres -d loja < demo/schema.sql ``` +Or skip both steps — `docker compose -f demo/compose.yaml up` does all of it and runs the +report. + Run it: ```console diff --git a/docs/RELEASING.md b/docs/RELEASING.md index c1a1a7d..8dc04db 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -44,7 +44,8 @@ pedir autenticação a quem só quer experimentar. $ scripts/release-notes.sh v0.1.2 ``` -3. Se o formato do relatório mudou nesta versão, regrave o exemplo do README. +3. Se o formato do relatório mudou nesta versão, rode `make demo-svg` e cole o + texto que ele imprime no bloco de exemplo do README. Ele é execução real contra o schema de `docs/DEMO.md`, e o que o torna útil é ser verdade — um exemplo que já foi verdade é pior que nenhum, porque ninguém desconfia dele. O número de versão na gravação pode ficar para trás; diff --git a/scripts/termsvg.go b/scripts/termsvg.go new file mode 100644 index 0000000..0812877 --- /dev/null +++ b/scripts/termsvg.go @@ -0,0 +1,200 @@ +//go:build ignore + +// termsvg renders coloured terminal output as an SVG, so the README can show +// what the tool actually prints. +// +// It exists because the alternative is a screenshot: a PNG nobody can diff, +// that goes stale silently, and that a maintainer cannot regenerate without the +// same machine and the same font. This reads the real bytes the tool emits — +// escape sequences included — and the result is regenerated by `make demo-svg` +// whenever the report format changes. +// +// Built with `//go:build ignore` and run through `go run`: it is a maintainer's +// tool, and it has no business in the binary anyone installs. +// +// pgfathom discover --color always | go run scripts/termsvg.go > assets/demo.svg +package main + +import ( + "bufio" + "fmt" + "html" + "os" + "strconv" + "strings" +) + +// Terminal metrics. The advance width is the one number that has to be right: +// too small and glyphs overlap, too large and the box columns drift apart. +// 0.6 em is the ratio every monospace face in the fallback list holds. +const ( + fontSize = 14.0 + lineHeight = 20.0 + charWidth = fontSize * 0.6 + padX = 22.0 + padY = 18.0 + titleBar = 34.0 +) + +// The window, in the same palette the report itself uses. Dark on purpose: the +// brand's red and aqua are chosen against a dark terminal, which is what the +// people who run this tool are looking at. +const ( + background = "#1a1614" + foreground = "#ddd5d0" + chromeBar = "#231e1b" +) + +type style struct { + colour string + bold bool + dim bool +} + +// span is a run of characters sharing one style. +type span struct { + text string + style style +} + +func main() { + lines, width := read(os.Stdin) + + w := padX*2 + float64(width)*charWidth + h := titleBar + padY*2 + float64(len(lines))*lineHeight + + out := bufio.NewWriter(os.Stdout) + defer func() { _ = out.Flush() }() + + fmt.Fprintf(out, ``, + w, h, w, h, fontSize) + + fmt.Fprintf(out, ``, w, h, background) + fmt.Fprintf(out, ``, + w-20, titleBar-10, chromeBar) + for i, dot := range []string{"#e2483d", "#8c7d73", "#7ec9b8"} { + fmt.Fprintf(out, ``, 20+float64(i)*18, dot) + } + + for i, spans := range lines { + y := titleBar + padY + float64(i)*lineHeight + fontSize*0.8 + col := 0.0 + + for _, s := range spans { + if strings.TrimSpace(s.text) != "" { + fmt.Fprintf(out, `%s`, html.EscapeString(s.text)) + } + col += float64(len([]rune(s.text))) + } + } + + fmt.Fprint(out, ``) +} + +func (s style) paint() string { + if s.colour != "" { + return s.colour + } + return foreground +} + +// read consumes ANSI-coloured text and returns styled spans per line, plus the +// widest line in characters. +func read(f *os.File) ([][]span, int) { + var lines [][]span + widest := 0 + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for sc.Scan() { + spans, width := parse(sc.Text()) + lines = append(lines, spans) + if width > widest { + widest = width + } + } + return lines, widest +} + +func parse(line string) ([]span, int) { + var ( + spans []span + cur style + buf strings.Builder + width int + ) + + flush := func() { + if buf.Len() > 0 { + spans = append(spans, span{text: buf.String(), style: cur}) + buf.Reset() + } + } + + for i := 0; i < len(line); { + if line[i] != 0x1b { + r := line[i] + buf.WriteByte(r) + if r < 0x80 || r >= 0xc0 { // count runes, not bytes + width++ + } + i++ + continue + } + + end := strings.IndexByte(line[i:], 'm') + if end < 0 { + break + } + flush() + cur = apply(cur, line[i+2:i+end]) + i += end + 1 + } + + flush() + return spans, width +} + +// apply folds one escape sequence's parameters into the running style. Only the +// attributes this report emits are handled; anything else is ignored rather +// than guessed at. +func apply(s style, params string) style { + fields := strings.Split(params, ";") + + for i := 0; i < len(fields); i++ { + switch fields[i] { + case "", "0": + s = style{} + case "1": + s.bold = true + case "2": + s.dim = true + case "31": + s.colour = "#e2483d" + case "33": + s.colour = "#d9a441" + case "36": + s.colour = "#7ec9b8" + case "38": + // 38;2;R;G;B — the brand colours, when the terminal has them. + if i+4 < len(fields) && fields[i+1] == "2" { + r, _ := strconv.Atoi(fields[i+2]) + g, _ := strconv.Atoi(fields[i+3]) + b, _ := strconv.Atoi(fields[i+4]) + s.colour = fmt.Sprintf("#%02x%02x%02x", r, g, b) + i += 4 + } + } + } + return s +}