Public: anyone can read this; the crew can change it.

A `Makefile` opens as its targets: each target with its prerequisites and recipe, phony ones marked #11218

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11209-a-makefile-opens-as-its-targets-each-tar → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/11209-a-makefile-opens-as-its-targets-each-tar was merged into main as e1557f4.

Review

Sign in to review this change.
7 files changed +315 −1
docs/features/file-makefile.md added +14
@@ -0,0 +1,14 @@
1 # A `Makefile` opens as its targets
2
3 A file named `Makefile`, `GNUmakefile` or `*.mk` opens as what it declares instead of raw text. Source is one tap away (`?view=source`).
4
5 - **Variables** come first, in one table: name, assignment operator (`=`, `:=`, `?=`, `+=`, `!=`), value.
6 - **Targets** follow, one row each: the target name, its prerequisites, its recipe (mono, one line per command). Order-only prerequisites (after `|`) sit under the prerequisites. A `::` rule and a `target: ; cmd` inline recipe read the same way.
7 - **Phony:** a target named in `.PHONY` carries a `phony` badge, wherever the declaration sits in the file. The `.PHONY` line itself is **not** repeated as a row (the badges already say it); a name listed there with no rule of its own shows nowhere.
8 - **Comments** stay in place as muted notes. A trailing backslash joins the next line.
9 - **Not understood** (`include`, `ifeq`/`endif`, a `define` block, a target-specific variable, a recipe with no target) stays as source text where it was; nothing else is dropped except blank lines.
10 - **Cap:** at most 500 items, then `N more not shown`. Files over 1 MB keep Source.
11 - **At 360 px** each table sits in a `table-scroll` region (the existing wrapper, focusable): the table scrolls sideways inside it and the page never does. Recipe cells wrap (`.table td pre`), so most recipes need no scrolling. I chose scroll over stacking because it keeps the three columns aligned for comparing targets, and adds no CSS.
12 - Every value is escaped and the page is drawn on the server. No script, no new CSS.
13
14 Code: `src/web/render/makefile.ts`, `src/web/render/kinds.ts`. Tests: `test/render-makefile.test.ts`.
src/web/render/kinds.ts +6
@@ -42,6 +42,7 @@ export type FileKind =
4242 | "junit"
4343 | "env"
4444 | "openapi"
45 | "makefile"
4546 | "dockerfile"
4647 | "sql"
4748 | "text"
@@ -84,6 +85,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = {
8485 junit: 5 * MB,
8586 env: 1 * MB,
8687 openapi: 1 * MB,
88 makefile: 1 * MB,
8789 dockerfile: 1 * MB,
8890 sql: 1 * MB,
8991 text: 1 * MB,
@@ -219,6 +221,8 @@ export const isEnvExample = (path: string): boolean =>
219221 /** `openapi.json|yaml|yml` and `*.openapi.*`: the name of an OpenAPI document; its content decides whether it is drawn as one. */
220222 const isOpenApiName = (path: string): boolean => /(^|\/|\.)openapi\.(json|ya?ml)$/i.test(path);
221223
224 /** `Makefile`, `GNUmakefile` and `*.mk`, by name. */
225 const isMakefileName = (path: string): boolean => /(^|\/)(makefile|gnumakefile|[^/]+\.mk)$/i.test(path);
222226 /** `Dockerfile`, `Containerfile` and `*.Dockerfile`, in any case. */
223227 const isDockerfileName = (path: string): boolean => /(^|\/)(Dockerfile|Containerfile|[^/]+\.Dockerfile)$/i.test(path);
224228
@@ -294,6 +298,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
294298 if (isEnvExample(path)) return "env";
295299 if (isJunit(path, head)) return "junit";
296300 if (isOpenApiName(path)) return "openapi";
301 if (isMakefileName(path)) return "makefile";
297302 if (isDockerfileName(path)) return "dockerfile";
298303 return TEXTUAL[extension(path)] ?? "text";
299304 }
@@ -311,6 +316,7 @@ export function kindByName(path: string): FileKind {
311316 if (isLcovName(path)) return "lcov";
312317 if (isEnvExample(path)) return "env";
313318 if (isOpenApiName(path)) return "openapi";
319 if (isMakefileName(path)) return "makefile";
314320 if (isDockerfileName(path)) return "dockerfile";
315321 return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text";
316322 }
src/web/render/makefile.ts added +188
@@ -0,0 +1,188 @@
1 /**
2 * A `Makefile`, `GNUmakefile` or `*.mk` as what it declares: variables in one
3 * block on top, then one row per target with its prerequisites and recipe
4 * (mono), `.PHONY` targets marked. Comments stay in place as notes. A line
5 * the parser does not understand (`include`, `ifeq`, a `define` block,
6 * a recipe with no target) stays as source text where it was; nothing is
7 * dropped except blank lines and `.PHONY` lines (the badges say it). A backslash at the end of a line joins the next.
8 * Every cell is escaped by the template; at most `ROW_CAP` items are drawn.
9 */
10
11 import { html, type Html } from "../html.ts";
12 import { fileSize } from "../ui.ts";
13 import type { Rendered, RenderInput } from "./renderers.ts";
14
15 export const ROW_CAP = 500;
16
17 export interface MakeVariable {
18 readonly name: string;
19 readonly op: string;
20 readonly value: string;
21 }
22 export interface MakeTarget {
23 readonly names: string;
24 /** `::` rules and `:` rules alike; the prerequisites before any `|`. */
25 readonly prereqs: readonly string[];
26 readonly orderOnly: readonly string[];
27 /** One entry per recipe line, continuation lines kept inside it. */
28 readonly recipe: readonly string[];
29 readonly phony: boolean;
30 }
31 export type MakeItem =
32 | { readonly kind: "variable"; readonly variable: MakeVariable }
33 | { readonly kind: "target"; readonly target: MakeTarget }
34 | { readonly kind: "comment"; readonly text: string }
35 | { readonly kind: "source"; readonly text: string };
36
37 export interface MakeDoc {
38 readonly items: readonly MakeItem[];
39 readonly targets: number;
40 readonly variables: number;
41 }
42
43 const VARIABLE = /^(?:(?:export|override|private)\s+)*([A-Za-z0-9_.@%<^+?*$(){}-]+?)\s*(:::=|::=|:=|\?=|\+=|!=|=)\s*(.*)$/;
44 const DIRECTIVE = /^(?:-?include|sinclude|ifn?eq|ifn?def|else|endif|define|endef|undefine|vpath|export|unexport|override)\b/;
45
46 /** Physical lines joined where a line ends in an odd number of backslashes. */
47 function logicalLines(source: string): string[] {
48 const out: string[] = [];
49 let cur: string | undefined;
50 for (const raw of source.replace(/^/, "").split(/\r?\n/)) {
51 cur = cur === undefined ? raw : `${cur}\n${raw}`;
52 const slashes = /\\*$/.exec(raw)![0].length;
53 if (slashes % 2 === 0 || raw.startsWith("#") && cur === raw) {
54 out.push(cur);
55 cur = undefined;
56 }
57 }
58 if (cur !== undefined) out.push(cur);
59 return out;
60 }
61
62 const words = (s: string): string[] => s.split(/\s+/).filter((w) => w !== "");
63 const flat = (s: string): string => s.replace(/\\\n\s*/g, " ");
64
65 /** Read a makefile. Pure; never throws. */
66 export function parseMakefile(source: string): MakeDoc {
67 const items: MakeItem[] = [];
68 const phony = new Set<string>();
69 const recipes: string[][] = [];
70 let current: string[] | undefined;
71 let inDefine = false;
72 let targets = 0;
73 let variables = 0;
74 const pending: { index: number; names: string; prereqs: string[]; orderOnly: string[]; recipe: string[] }[] = [];
75
76 for (const line of logicalLines(source)) {
77 if (line.trim() === "") continue;
78 if (inDefine) {
79 items.push({ kind: "source", text: line });
80 if (/^\s*endef\b/.test(line)) inDefine = false;
81 continue;
82 }
83 if (line.startsWith("\t")) {
84 if (current !== undefined) current.push(line.slice(1));
85 else items.push({ kind: "source", text: line });
86 continue;
87 }
88 current = undefined;
89 const text = flat(line).trim();
90 if (text.startsWith("#")) {
91 items.push({ kind: "comment", text: text.replace(/^#+\s?/, "") });
92 continue;
93 }
94 if (/^define\b/.test(text)) {
95 inDefine = true;
96 items.push({ kind: "source", text: line });
97 continue;
98 }
99 if (DIRECTIVE.test(text) && !/^(?:export|override)\s+[\w.-]+\s*(?:[:?+!]?=)/.test(text)) {
100 items.push({ kind: "source", text: line });
101 continue;
102 }
103 const v = VARIABLE.exec(text);
104 if (v !== null) {
105 variables++;
106 items.push({ kind: "variable", variable: { name: v[1]!, op: v[2]!, value: v[3]! } });
107 continue;
108 }
109 const colon = text.search(/::?(?!=)/);
110 if (colon > 0) {
111 const names = text.slice(0, colon).trim();
112 let rest = text.slice(colon).replace(/^::?/, "");
113 let inline: string | undefined;
114 const semi = rest.indexOf(";");
115 if (semi !== -1) {
116 inline = rest.slice(semi + 1).trim();
117 rest = rest.slice(0, semi);
118 }

The .PHONY declaration is pushed as a source row, so the list reads the same fact twice (the badge and the line). Decide and document it; the issue only asks for the marker.

119 rest = rest.replace(/(^|\s)#.*$/, "");
120 // A target-specific variable (`all: CC=gcc`) is not a rule: keep it as written.
121 if (names !== "" && !rest.includes("=")) {
122 const [pre = "", order = ""] = rest.split("|", 2).concat(rest.includes("|") ? [] : [""]);
123 const prereqs = words(pre);
124 const orderOnly = words(order);
125 if (names === ".PHONY") {
126 for (const w of prereqs) phony.add(w);
127 continue; // read into the badges; the line is not repeated as a row
128 }
129 const recipe: string[] = inline !== undefined && inline !== "" ? [inline] : [];
130 targets++;
131 pending.push({ index: items.length, names, prereqs, orderOnly, recipe });
132 items.push({ kind: "source", text: "" }); // replaced below, once `.PHONY` is known
133 recipes.push(recipe);
134 current = recipe;
135 continue;
136 }
137 }
138 items.push({ kind: "source", text: line });
139 }
140 for (const p of pending)
141 items[p.index] = {
142 kind: "target",
143 target: {
144 names: p.names,
145 prereqs: p.prereqs,
146 orderOnly: p.orderOnly,
147 recipe: p.recipe,
148 phony: words(p.names).some((n) => phony.has(n)),
149 },
150 };
151 return { items, targets, variables };
152 }
153
154 const list = (words: readonly string[]): Html =>
155 words.length === 0 ? html`—` : html`${words.map((w) => html`<code>${w}</code> `)}`;
156
157 function targetRow(t: MakeTarget): Html {
158 const mark = t.phony ? html` <span class="badge">phony</span>` : html``;
159 const order = t.orderOnly.length > 0 ? html`<div class="muted small">order-only: ${list(t.orderOnly)}</div>` : html``;
160 const recipe = t.recipe.length === 0 ? html`—` : html`<pre><code>${t.recipe.join("\n")}</code></pre>`;
161 return html`<tr><th scope="row"><code>${t.names}</code>${mark}</th><td>${list(t.prereqs)}${order}</td><td>${recipe}</td></tr>`;
162 }
163
164 export function makefile(input: RenderInput): Rendered {
165 const doc = parseMakefile(new TextDecoder().decode(input.body));
166 if (doc.items.length === 0) return { content: html``, lines: 0, fallback: "This file holds nothing to draw." };
167 const shown = doc.items.slice(0, ROW_CAP);
168 const vars = shown.flatMap((i) => (i.kind === "variable" ? [i.variable] : []));
169 const rows: Html[] = [];
170 for (const i of shown) {
171 if (i.kind === "target") rows.push(targetRow(i.target));
172 else if (i.kind === "comment") rows.push(html`<tr><td colspan="3" class="muted small">${i.text}</td></tr>`);
173 else if (i.kind === "source") rows.push(html`<tr><td colspan="3"><pre><code>${i.text}</code></pre></td></tr>`);
174 }
175 const varBlock =
176 vars.length === 0
177 ? html``
178 : html`<div class="table-scroll" role="region" tabindex="0" aria-label="Variables"><table class="table" data-dense aria-label="Variables"><thead><tr><th scope="col">Variable</th><th scope="col">Value</th></tr></thead><tbody>${vars.map((v) => html`<tr><th scope="row"><code>${v.name}</code> <span class="muted small">${v.op}</span></th><td><code>${v.value}</code></td></tr>`)}</tbody></table></div>`;
179 const ruleBlock =
180 rows.length === 0
181 ? html``
182 : html`<div class="table-scroll" role="region" tabindex="0" aria-label="Targets"><table class="table" data-dense aria-label="Targets"><thead><tr><th scope="col">Target</th><th scope="col">Prerequisites</th><th scope="col">Recipe</th></tr></thead><tbody>${rows}</tbody></table></div>`;
183 const more = doc.items.length - shown.length;
184 const foot = more > 0 ? html`<p class="muted small">${more.toLocaleString()} more not shown</p>` : html``;
185 const content = html`<div>${varBlock}${ruleBlock}${foot}</div>`;
186 const t = doc.targets;
outdated

Principle 1/3: a <table> with three columns and <pre> cells has no narrow layout. At 360px the recipe column forces horizontal scroll of the whole page. Wrap the table so only it scrolls, or stack the cells; give .makefile-view a rule or drop the class.

187 return { content, lines: 0, summary: `${t.toLocaleString()} ${t === 1 ? "target" : "targets"} · ${doc.variables.toLocaleString()} ${doc.variables === 1 ? "variable" : "variables"} · ${fileSize(input.size)}` };
188 }
src/web/render/renderers.ts +2
@@ -25,6 +25,7 @@ import { htmlPreview } from "./html_preview.ts";
2525 import { image } from "./image.ts";
2626 import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
2727 import { log, hasEscape } from "./log.ts";
28 import { makefile } from "./makefile.ts";
2829 import { markdown } from "./markdown.ts";
2930 import { model3d } from "./model3d.ts";
3031 import { notebook } from "./notebook.ts";
@@ -104,6 +105,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
104105 env: envView,
105106 sql: sqlView,
106107 openapi,
108 makefile,
107109 dockerfile,
108110 mail: eml,
109111 archive,
test/fuzz-renderers.test.ts +6
@@ -19,6 +19,7 @@ import { geojson } from "../src/web/render/geojson.ts";
1919 import { subtitles } from "../src/web/render/subtitles.ts";
2020 import { patch } from "../src/web/render/patch.ts";
2121 import { notebook } from "../src/web/render/notebook.ts";
22 import { makefile } from "../src/web/render/makefile.ts";
2223 import { lcov } from "../src/web/render/lcov.ts";
2324 import { sarif } from "../src/web/render/sarif.ts";
2425 import { gcode } from "../src/web/render/gcode.ts";
@@ -121,6 +122,11 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly
121122 render: lcov,
122123 files: ["TN:\nSF:src/a.ts\nFNF:2\nFNH:1\nDA:1,1\nDA:2,0\nLF:2\nLH:1\nBRF:4\nBRH:2\nend_of_record\n"],
123124 },
125 makefile: {
126 path: "Makefile",
127 render: makefile,
128 files: [".PHONY: all clean\nCC := <b>gcc</b>\nall: a.o | dir\n\t$(CC) -o x \\\n\t a.o\nclean:\n\trm -f *.o\ninclude <script>.mk\n"],
129 },
124130 srt: {
125131 path: "talk.srt",
126132 render: subtitles,
test/render-kinds.test.ts +1 −1
@@ -125,7 +125,7 @@ describe("the registry of file kinds", () => {
125125 });
126126
127127 test("a file with no known extension is text or binary by its bytes", () => {
128 expect(fileKind("Makefile", enc("all:\n"))).toBe("text");
128 expect(fileKind("LICENSE", enc("MIT\n"))).toBe("text");
129129 expect(fileKind("src/a.ts", enc("export {}\n"))).toBe("text");
130130 expect(fileKind("blob.dat", of(1, 0, 2))).toBe("binary");
131131 expect(fileKind(".png", SAMPLES.png![0])).toBe("binary");
test/render-makefile.test.ts added +98
@@ -0,0 +1,98 @@
1 import { describe, expect, test } from "bun:test";
2 import { makefile, parseMakefile, ROW_CAP } from "../src/web/render/makefile.ts";
3 import { fileKind, kindByName } from "../src/web/render/kinds.ts";
4
5 const enc = (s: string) => new TextEncoder().encode(s);
6 const draw = (text: string) => {
7 const body = enc(text);
8 const r = makefile({ path: "Makefile", size: body.length, body, media: "", download: "" });
9 return { html: String(r.content), summary: r.summary, fallback: r.fallback };
10 };
11 const targets = (text: string) => parseMakefile(text).items.flatMap((i) => (i.kind === "target" ? [i.target] : []));
12
13 describe("makefile kind", () => {
14 test("by name", () => {
15 for (const p of ["Makefile", "makefile", "GNUmakefile", "a/b/rules.mk", "x/Makefile"]) {
16 expect(fileKind(p, enc("all:\n"))).toBe("makefile");
17 expect(kindByName(p)).toBe("makefile");
18 }
19 expect(fileKind("Makefile.txt", enc("x"))).toBe("text");
20 expect(fileKind("notmakefile.c", enc("x"))).toBe("text");
21 });
22 });
23
24 describe("parseMakefile", () => {
25 test("targets, prerequisites, order-only and recipe", () => {
26 const [t] = targets("app: a.o b.o | dir\n\tcc -o app a.o b.o\n\t@echo done\n");
27 expect(t).toMatchObject({ names: "app", prereqs: ["a.o", "b.o"], orderOnly: ["dir"], recipe: ["cc -o app a.o b.o", "@echo done"], phony: false });
28 });
29 test("phony, even when declared after the target", () => {
30 const ts = targets("test:\n\tbun test\nbuild:\n\tx\n.PHONY: test\n");
31 expect(ts.map((t) => [t.names, t.phony])).toEqual([["test", true], ["build", false]]);
32 });
33 test("variables", () => {
34 const doc = parseMakefile("CC := gcc\nFLAGS ?= -O2 -Wall\nexport PATH += :/x\n");
35 expect(doc.variables).toBe(3);
36 expect(doc.items.map((i) => (i.kind === "variable" ? [i.variable.name, i.variable.op, i.variable.value] : null))).toEqual([
37 ["CC", ":=", "gcc"],
38 ["FLAGS", "?=", "-O2 -Wall"],
39 ["PATH", "+=", ":/x"],
40 ]);
41 });
42 test("continuation lines join in prerequisites and stay in a recipe", () => {
43 const [t] = targets("all: a \\\n b \\\n c\n\techo one \\\n\t two\n\techo three\n");
44 expect(t!.prereqs).toEqual(["a", "b", "c"]);
45 expect(t!.recipe).toEqual(["echo one \\\n\t two", "echo three"]);
46 });
47 test("an inline recipe after ;", () => {
48 expect(targets("a: b ; echo hi\n")[0]!.recipe).toEqual(["echo hi"]);
49 });
50 test("what it does not understand stays as source, in place", () => {
51 const doc = parseMakefile("include foo.mk\nifeq ($(A),1)\nX=1\nendif\ndefine T\n\tbody: not a rule\nendef\n\torphan\nall: CC=gcc\n");
52 expect(doc.targets).toBe(0);
53 expect(doc.items.filter((i) => i.kind === "source").map((i) => (i as { text: string }).text)).toEqual([
54 "include foo.mk",
55 "ifeq ($(A),1)",
56 "endif",
57 "define T",
58 "\tbody: not a rule",
59 "endef",
60 "\torphan",
61 "all: CC=gcc",
62 ]);
63 });
64 test("comments stay as notes", () => {
65 expect(parseMakefile("# build it\nall:\n").items[0]).toEqual({ kind: "comment", text: "build it" });
66 });
67 });
68
69 describe("makefile view", () => {
70 test("draws variables on top, targets after, phony marked", () => {
71 const { html, summary } = draw(".PHONY: clean\nCC = gcc\nall: a.o\n\t$(CC) a.o\nclean:\n\trm a.o\n");
72 expect(html.indexOf("Variables")).toBeLessThan(html.indexOf("Targets"));
73 expect(html).toContain("<code>clean</code> <span class=\"badge\">phony</span>");
74 expect(html).not.toContain("<code>all</code> <span");
75 expect(summary).toContain("2 targets · 1 variable");
76 });
77 test("the .PHONY line is not repeated as a row", () => {
78 const { html } = draw(".PHONY: clean\nclean:\n\trm a.o\n");
79 expect(html).not.toContain(".PHONY");
80 expect(parseMakefile(".PHONY: a\n").items).toEqual([]);
81 });
82 test("each table scrolls on its own, so the page never does", () => {
83 const { html } = draw("CC = gcc\nall:\n\tx\n");
84 expect(html.match(/<div class="table-scroll" role="region" tabindex="0"/g)?.length).toBe(2);
85 });
86 test("escapes every value", () => {
87 const { html } = draw("X = <img src=x onerror=1>\n<b>t</b>: <i>p</i>\n\t<script>1</script>\n# <u>c</u>\ninclude <s>.mk\n");
88 expect(html).not.toMatch(/<(img|b|i|script|u|s)[ >]/);
89 expect(html).toContain("&lt;script&gt;");
90 });
91 test("empty file falls back", () => {
92 expect(draw("\n\n").fallback).toBeDefined();
93 });
94 test("caps the rows and says so", () => {
95 const { html } = draw(Array.from({ length: ROW_CAP + 3 }, (_, i) => `t${i}:\n`).join(""));
96 expect(html).toContain("3 more not shown");
97 });
98 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs