diff --git a/javascript/packages/analysis/src/dependency-index.ts b/javascript/packages/analysis/src/dependency-index.ts new file mode 100644 index 000000000..fbce25a61 --- /dev/null +++ b/javascript/packages/analysis/src/dependency-index.ts @@ -0,0 +1,141 @@ +import { collectTemplateDependencies } from "./template-dependencies" +import { isERBCaseNode, isERBContentNode, isERBIfNode, isERBRenderNode, isERBUnlessNode, isHTMLAttributeNode, isLiteralNode } from "@herb-tools/core" + +const PARSER_OPTIONS = { render_nodes: true, strict_locals: true, prism_nodes: true, prism_program: true, track_whitespace: true } + +import type { DependencyOptions } from "./template-dependencies" +import type { DocumentNode, HTMLAttributeNode, HerbBackend, Node, Token } from "@herb-tools/core" + +export type AffectedNodeKind = "text_content" | "conditional" | "render" | "attribute_value" + +export interface AffectedNode { + nodePath: number[] + kind: AffectedNodeKind + expression?: string + attribute?: string + location?: string +} + +export function referencesState(code: string | undefined, state: string): boolean { + if (!code || !state) return false + + if (state.startsWith("@")) { + return code.includes(state) + } + + if (state.includes(".")) { + return code.includes(state.split(".")[0]) + } + + return new RegExp(`\\b${state.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(code) +} + +function childrenOf(node: Node): Node[] { + return node.childNodes().filter((child): child is Node => child !== null) +} + +function locationOf(node: Node): string | undefined { + const start = node.location?.start + + return start ? `${start.line}:${start.column}` : undefined +} + +function expressionOf(node: Node): string | undefined { + const value = "content" in node ? (node as { content?: Token | null }).content?.value : undefined + + return typeof value === "string" ? value.trim() : undefined +} + +function expressionsWithin(node: Node): string[] { + const own = expressionOf(node) + const found = own ? [own] : [] + + for (const child of childrenOf(node)) found.push(...expressionsWithin(child)) + + return found +} + +function attributeNameOf(node: HTMLAttributeNode): string | undefined { + const [first] = node.name?.children ?? [] + const literal = first && isLiteralNode(first) ? first.content : undefined + + return typeof literal === "string" ? literal : undefined +} + +export function affectedNodes(backend: HerbBackend, source: string, state: string): AffectedNode[] { + const document = backend.parse(source, PARSER_OPTIONS).value as DocumentNode + const affected: AffectedNode[] = [] + const path: number[] = [] + + const record = (node: Node, kind: AffectedNodeKind, expression?: string) => { + affected.push({ + nodePath: [...path], + kind, + ...(expression ? { expression } : {}), + ...(locationOf(node) ? { location: locationOf(node) } : {}), + }) + } + + const inspectAttribute = (node: HTMLAttributeNode) => { + const attribute = attributeNameOf(node) + + for (const value of node.value?.children ?? []) { + const expression = expressionOf(value) + + if (!referencesState(expression, state)) continue + + affected.push({ + nodePath: [...path], + kind: "attribute_value", + ...(expression ? { expression } : {}), + ...(attribute ? { attribute } : {}), + ...(locationOf(value) ? { location: locationOf(value) } : {}), + }) + } + } + + const walk = (node: Node) => { + if (isHTMLAttributeNode(node)) { + inspectAttribute(node) + + return + } + + if (isERBIfNode(node) || isERBUnlessNode(node) || isERBCaseNode(node)) { + if (expressionsWithin(node).some(code => referencesState(code, state))) { + record(node, "conditional", expressionOf(node)) + } + } else if (isERBContentNode(node) && referencesState(expressionOf(node), state)) { + record(node, "text_content", expressionOf(node)) + } else if (isERBRenderNode(node) && referencesState(expressionOf(node), state)) { + record(node, "render", expressionOf(node)) + } + + for (const [index, child] of childrenOf(node).entries()) { + path.push(index) + walk(child) + path.pop() + } + } + + for (const [index, child] of childrenOf(document).entries()) { + path.push(index) + walk(child) + path.pop() + } + + return affected +} + +export function dependencyIndex(backend: HerbBackend, file: string, source: string, options: DependencyOptions = {}): Record { + const dependencies = collectTemplateDependencies(backend, file, source, options) + const index: Record = {} + + for (const state of [...dependencies.instanceVariables, ...dependencies.constants]) { + const nodes = affectedNodes(backend, source, state) + + if (nodes.length > 0) index[state] = nodes + } + + return index +} diff --git a/javascript/packages/analysis/src/index.ts b/javascript/packages/analysis/src/index.ts index 118b7941f..f60551ca2 100644 --- a/javascript/packages/analysis/src/index.ts +++ b/javascript/packages/analysis/src/index.ts @@ -1,5 +1,6 @@ export * from "./affected-templates" export * from "./ancestor-attributes" +export * from "./dependency-index" export * from "./partial-callers" export * from "./partial-index" export * from "./partial-resolution" diff --git a/javascript/packages/analysis/test/dependency-index.test.ts b/javascript/packages/analysis/test/dependency-index.test.ts new file mode 100644 index 000000000..3edb35bc0 --- /dev/null +++ b/javascript/packages/analysis/test/dependency-index.test.ts @@ -0,0 +1,102 @@ +import { describe, test, expect, beforeAll } from "vitest" + +import { Herb } from "@herb-tools/node-wasm" + +import { affectedNodes, dependencyIndex, referencesState } from "../src/dependency-index" + +const FILE = "app/views/posts/show.html.erb" + +describe("dependencyIndex", () => { + beforeAll(async () => { + await Herb.load() + }) + + function indexOf(source: string) { + return dependencyIndex(Herb, FILE, source) + } + + test("maps state to the nodes that read it", () => { + const index = indexOf(`

<%= @post.title %>

<%= @post.body %>

`) + + expect(index["@post"]).toBeDefined() + expect(index["@post"]).toHaveLength(2) + expect(index["@post"].map(node => node.kind)).toEqual(["text_content", "text_content"]) + }) + + test("includes attribute values", () => { + const index = indexOf(`
">Content
`) + + expect(index["@active"]).toBeDefined() + + const attribute = index["@active"].find(node => node.kind === "attribute_value") + + expect(attribute).toBeDefined() + expect(attribute?.attribute).toBe("class") + }) + + test("marks an if block containing state as conditional", () => { + const index = indexOf(`
<% if @admin %><%= @post.name %><% end %>
`) + + expect(index["@post"].map(node => node.kind)).toEqual(expect.arrayContaining(["conditional", "text_content"])) + expect(index["@admin"][0].kind).toBe("conditional") + }) + + test("leaves out state that nothing reads", () => { + expect(indexOf(`

Static

`)).toEqual({}) + }) + + test("records where each node is", () => { + const [node] = indexOf(`

<%= @post.title %>

`)["@post"] + + expect(node.location).toMatch(/^\d+:\d+$/) + expect(node.expression).toBe("@post.title") + }) + + test("records a path that leads back to the node", () => { + const [node] = indexOf(`

<%= @post.title %>

`)["@post"] + + expect(node.nodePath.length).toBeGreaterThan(0) + expect(node.nodePath.every(step => Number.isInteger(step))).toBe(true) + }) + + test("covers constants as well as instance variables", () => { + const index = indexOf(`<%= Post.count %>`) + + expect(index["Post.count"]).toBeDefined() + expect(index["Post.count"][0].kind).toBe("text_content") + }) + + describe("affectedNodes", () => { + test("finds nothing for state the template does not read", () => { + expect(affectedNodes(Herb, `<%= @post.title %>`, "@other")).toEqual([]) + }) + + test("finds a render call that passes the state on", () => { + const nodes = affectedNodes(Herb, `<%= render "posts/card", post: @post %>`, "@post") + + expect(nodes.some(node => node.kind === "render")).toBe(true) + }) + }) + + describe("referencesState", () => { + test("matches an instance variable literally", () => { + expect(referencesState("@post.title", "@post")).toBe(true) + expect(referencesState("@other.title", "@post")).toBe(false) + }) + + test("matches a constant on the constant alone", () => { + expect(referencesState("Post.where(id: 1)", "Post.count")).toBe(true) + expect(referencesState("Comment.count", "Post.count")).toBe(false) + }) + + test("matches a plain name on word boundaries", () => { + expect(referencesState("post.title", "post")).toBe(true) + expect(referencesState("posts.count", "post")).toBe(false) + }) + + test("is false for nothing", () => { + expect(referencesState(undefined, "@post")).toBe(false) + expect(referencesState("@post", "")).toBe(false) + }) + }) +})