Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions javascript/packages/analysis/src/dependency-index.ts
Original file line number Diff line number Diff line change
@@ -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<string, AffectedNode[]> {
const dependencies = collectTemplateDependencies(backend, file, source, options)
const index: Record<string, AffectedNode[]> = {}

for (const state of [...dependencies.instanceVariables, ...dependencies.constants]) {
const nodes = affectedNodes(backend, source, state)

if (nodes.length > 0) index[state] = nodes
}

return index
}
1 change: 1 addition & 0 deletions javascript/packages/analysis/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
102 changes: 102 additions & 0 deletions javascript/packages/analysis/test/dependency-index.test.ts
Original file line number Diff line number Diff line change
@@ -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(`<div><h1><%= @post.title %></h1><p><%= @post.body %></p></div>`)

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(`<div class="<%= @active ? "on" : "off" %>">Content</div>`)

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(`<div><% if @admin %><%= @post.name %><% end %></div>`)

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(`<h1>Static</h1>`)).toEqual({})
})

test("records where each node is", () => {
const [node] = indexOf(`<div><h1><%= @post.title %></h1></div>`)["@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(`<div><h1><%= @post.title %></h1></div>`)["@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)
})
})
})
Loading