diff --git a/javascript/packages/analysis/src/affected-templates.ts b/javascript/packages/analysis/src/affected-templates.ts new file mode 100644 index 000000000..0deee26f1 --- /dev/null +++ b/javascript/packages/analysis/src/affected-templates.ts @@ -0,0 +1,74 @@ +import type { TemplateDependencies } from "./template-dependencies" + +export interface TemplateGraph { + dependencies: Map + filesForPartial(partialName: string): string[] +} + +export function expressionReferences(expression: string | undefined, name: string): boolean { + if (!expression || !name) return false + if (name.startsWith("@")) return expression.includes(name) + + return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(expression) +} + +export function affectedTemplates(graph: TemplateGraph, entryPoint: string, state: string): string[] { + const entry = graph.dependencies.get(entryPoint) + if (!entry) return [] + + if (!entry.instanceVariables.includes(state) && !entry.constants.includes(state)) { + return [] + } + + const affected = new Set([entryPoint]) + const carrying = new Map>([[entryPoint, new Set([state])]]) + const visited = new Set() + const queue = [entryPoint] + + while (queue.length > 0) { + const file = queue.shift()! + + if (visited.has(file)) continue + + visited.add(file) + + const dependencies = graph.dependencies.get(file) + if (!dependencies) continue + + const carried = [...(carrying.get(file) ?? [])] + + for (const call of dependencies.renderCalls) { + const flowing = Object.entries(call.locals).filter(([, expression]) => carried.some(name => expressionReferences(expression, name))).map(([local]) => local) + const collectionFlows = carried.some(name => expressionReferences(call.collection, name)) + + if (flowing.length === 0 && !collectionFlows) { + continue + } + + for (const partialFile of graph.filesForPartial(call.partial)) { + const names = carrying.get(partialFile) ?? new Set() + + for (const local of flowing) { + names.add(local) + } + + if (collectionFlows) { + names.add(itemNameFor(call.partial)) + } + + carrying.set(partialFile, names) + + if (!affected.has(partialFile)) { + affected.add(partialFile) + queue.push(partialFile) + } + } + } + } + + return [...affected].sort() +} + +function itemNameFor(partialName: string): string { + return partialName.split("/").pop() ?? partialName +} diff --git a/javascript/packages/analysis/src/index.ts b/javascript/packages/analysis/src/index.ts index f79042853..118b7941f 100644 --- a/javascript/packages/analysis/src/index.ts +++ b/javascript/packages/analysis/src/index.ts @@ -1,6 +1,7 @@ -export * from "./partial-index" +export * from "./affected-templates" +export * from "./ancestor-attributes" export * from "./partial-callers" +export * from "./partial-index" export * from "./partial-resolution" export * from "./render-expression" -export * from "./ancestor-attributes" export * from "./template-dependencies" diff --git a/javascript/packages/analysis/test/affected-templates.test.ts b/javascript/packages/analysis/test/affected-templates.test.ts new file mode 100644 index 000000000..670ed4dab --- /dev/null +++ b/javascript/packages/analysis/test/affected-templates.test.ts @@ -0,0 +1,125 @@ +import { describe, test, expect, beforeAll } from "vitest" + +import { Herb } from "@herb-tools/node-wasm" + +import { affectedTemplates, expressionReferences } from "../src/affected-templates" +import { collectTemplateDependencies } from "../src/template-dependencies" + +import type { TemplateGraph } from "../src/affected-templates" + +describe("affectedTemplates", () => { + beforeAll(async () => { + await Herb.load() + }) + + function graphOf(files: Record): TemplateGraph { + const dependencies = new Map( + Object.entries(files).map(([file, source]) => [file, collectTemplateDependencies(Herb, file, source)]) + ) + + const byPartial = new Map() + + for (const file of Object.keys(files)) { + const name = file.replace(/^app\/views\//, "").replace(/\/_/, "/").replace(/\.html\.erb$/, "") + + if (!file.split("/").pop()!.startsWith("_")) continue + + byPartial.set(name, [...(byPartial.get(name) ?? []), file]) + } + + return { dependencies, filesForPartial: name => byPartial.get(name) ?? [] } + } + + test("traces state through the render graph", () => { + const graph = graphOf({ + "app/views/posts/show.html.erb": `<%= @post.title %><%= render "posts/header", post: @post %>`, + "app/views/posts/_header.html.erb": `

<%= post.name %>

`, + }) + + const affected = affectedTemplates(graph, "app/views/posts/show.html.erb", "@post") + + expect(affected).toContain("app/views/posts/show.html.erb") + expect(affected).toContain("app/views/posts/_header.html.erb") + }) + + test("does not include unrelated templates", () => { + const graph = graphOf({ + "app/views/posts/show.html.erb": `<%= @post.title %><%= render "posts/header", post: @post %>`, + "app/views/posts/_header.html.erb": `

<%= post.name %>

`, + "app/views/pages/about.html.erb": `

About

`, + }) + + expect(affectedTemplates(graph, "app/views/posts/show.html.erb", "@post")).not.toContain("app/views/pages/about.html.erb") + }) + + test("traces through nested renders", () => { + const graph = graphOf({ + "app/views/posts/show.html.erb": `<%= render "posts/header", post: @post %>`, + "app/views/posts/_header.html.erb": `<%= render "posts/title", title: post.title %>`, + "app/views/posts/_title.html.erb": `

<%= title %>

`, + }) + + const affected = affectedTemplates(graph, "app/views/posts/show.html.erb", "@post") + + expect(affected).toContain("app/views/posts/show.html.erb") + expect(affected).toContain("app/views/posts/_header.html.erb") + expect(affected).toContain("app/views/posts/_title.html.erb") + }) + + test("handles constants", () => { + const graph = graphOf({ "app/views/posts/index.html.erb": `<%= Post.count %>` }) + + expect(affectedTemplates(graph, "app/views/posts/index.html.erb", "Post.count")).toContain("app/views/posts/index.html.erb") + }) + + test("returns nothing when the entry point does not read the state", () => { + const graph = graphOf({ + "app/views/posts/show.html.erb": `<%= @other.title %><%= render "posts/header", post: @other %>`, + "app/views/posts/_header.html.erb": `

<%= post.name %>

`, + }) + + expect(affectedTemplates(graph, "app/views/posts/show.html.erb", "@post")).toEqual([]) + }) + + test("returns nothing for a template it does not know", () => { + expect(affectedTemplates(graphOf({}), "app/views/posts/show.html.erb", "@post")).toEqual([]) + }) + + test("stops where the state stops flowing", () => { + const graph = graphOf({ + "app/views/posts/show.html.erb": `<%= @post.title %><%= render "posts/header", title: "static" %>`, + "app/views/posts/_header.html.erb": `

<%= title %>

`, + }) + + const affected = affectedTemplates(graph, "app/views/posts/show.html.erb", "@post") + + expect(affected).toEqual(["app/views/posts/show.html.erb"]) + }) + + test("follows a collection through to the partial", () => { + const graph = graphOf({ + "app/views/posts/index.html.erb": `<%= @posts.count %><%= render partial: "posts/post", collection: @posts %>`, + "app/views/posts/_post.html.erb": `<%= post.title %>`, + }) + + expect(affectedTemplates(graph, "app/views/posts/index.html.erb", "@posts")).toContain("app/views/posts/_post.html.erb") + }) + + describe("expressionReferences", () => { + test("matches an instance variable literally", () => { + expect(expressionReferences("@post.title", "@post")).toBe(true) + expect(expressionReferences("@poster.title", "@post")).toBe(true) + }) + + test("matches a local on word boundaries", () => { + expect(expressionReferences("post.title", "post")).toBe(true) + expect(expressionReferences("posts.count", "post")).toBe(false) + expect(expressionReferences("post_id", "post")).toBe(false) + }) + + test("is false for nothing", () => { + expect(expressionReferences(undefined, "post")).toBe(false) + expect(expressionReferences("post.title", "")).toBe(false) + }) + }) +})