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
1 change: 1 addition & 0 deletions javascript/packages/analysis/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from "./partial-callers"
export * from "./partial-resolution"
export * from "./render-expression"
export * from "./ancestor-attributes"
export * from "./template-dependencies"
62 changes: 62 additions & 0 deletions javascript/packages/analysis/src/render-call-collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Visitor, isERBRenderNode, isERBStrictLocalsNode, isRubyParameterNode, isRubyRenderLocalNode } from "@herb-tools/core"

import type { ERBRenderNode, ERBStrictLocalsNode, Node } from "@herb-tools/core"

export interface RenderCallDependency {
partial: string
locals: Record<string, string>
collection?: string
}

export class RenderCallCollector extends Visitor {
readonly renderCalls: RenderCallDependency[] = []
readonly localsReceived: Record<string, string> = {}
readonly localsDeclared = new Set<string>()

override visitChildNodes(node: Node): void {
if (isERBRenderNode(node)) this.collectRender(node)
if (isERBStrictLocalsNode(node)) this.collectDeclared(node)

super.visitChildNodes(node)
}

private collectRender(node: ERBRenderNode): void {
const keywords = node.keywords
if (!keywords) return

const locals: Record<string, string> = {}

for (const local of keywords.locals) {
if (!isRubyRenderLocalNode(local)) continue

const name = local.name?.value
const raw = local.value?.content

if (!name || raw === undefined || raw === null) continue

const value = raw === `${name}:` ? name : String(raw)

this.localsReceived[name] = value
locals[name] = value
}

const partial = keywords.partial?.value
if (!partial) return

this.renderCalls.push({
partial: String(partial).replace(/^["']|["']$/g, ""),
locals,
...(keywords.collection?.value ? { collection: String(keywords.collection.value) } : {}),
})
}

private collectDeclared(node: ERBStrictLocalsNode): void {
for (const local of node.locals) {
if (!isRubyParameterNode(local)) continue

const name = local.name?.value

if (name) this.localsDeclared.add(name)
}
}
}
68 changes: 68 additions & 0 deletions javascript/packages/analysis/src/ruby-dependency-collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { PrismVisitor } from "@herb-tools/core"

import type * as PrismNodes from "@ruby/prism/src/nodes.js"

export class RubyDependencyCollector extends PrismVisitor {
readonly instanceVariables = new Set<string>()
readonly constants = new Set<string>()
readonly knownLocals = new Set<string>()
readonly bareCalls = new Set<string>()

override visitInstanceVariableReadNode(node: PrismNodes.InstanceVariableReadNode): void {
this.instanceVariables.add(String(node.name))

this.visitChildNodes(node)
}

override visitLocalVariableReadNode(node: PrismNodes.LocalVariableReadNode): void {
this.knownLocals.add(String(node.name))

this.visitChildNodes(node)
}

override visitLocalVariableWriteNode(node: PrismNodes.LocalVariableWriteNode): void {
this.bind(node.name, node)
}

override visitLocalVariableOrWriteNode(node: PrismNodes.LocalVariableOrWriteNode): void {
this.bind(node.name, node)
}

override visitLocalVariableAndWriteNode(node: PrismNodes.LocalVariableAndWriteNode): void {
this.bind(node.name, node)
}

override visitLocalVariableOperatorWriteNode(node: PrismNodes.LocalVariableOperatorWriteNode): void {
this.bind(node.name, node)
}

override visitBlockParameterNode(node: PrismNodes.BlockParameterNode): void {
if (node.name) this.knownLocals.add(String(node.name))

this.visitChildNodes(node)
}

override visitRequiredParameterNode(node: PrismNodes.RequiredParameterNode): void {
this.knownLocals.add(String(node.name))

this.visitChildNodes(node)
}

override visitCallNode(node: PrismNodes.CallNode): void {
const name = String(node.name)

if (node.receiver === null) {
this.bareCalls.add(name)
} else if (node.receiver.constructor.name === "ConstantReadNode") {
this.constants.add(`${String((node.receiver as PrismNodes.ConstantReadNode).name)}.${name}`)
}

this.visitChildNodes(node)
}

private bind(name: unknown, node: PrismNodes.Node): void {
this.knownLocals.add(String(name))

this.visitChildNodes(node)
}
}
69 changes: 69 additions & 0 deletions javascript/packages/analysis/src/template-dependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { helperExists } from "@herb-tools/core"

import { RubyDependencyCollector } from "./ruby-dependency-collector"
import { RenderCallCollector } from "./render-call-collector"

import type { DocumentNode, HerbBackend } from "@herb-tools/core"
import type { RenderCallDependency } from "./render-call-collector"

const PARSER_OPTIONS = { render_nodes: true, strict_locals: true, prism_nodes: true, prism_program: true, track_whitespace: true }

export interface TemplateDependencies {
file: string
instanceVariables: string[]
constants: string[]
localsDeclared: string[]
localsReceived: Record<string, string>
renderCalls: RenderCallDependency[]
helperCalls: string[]
unknownCalls: string[]
}

export interface DependencyOptions {
customHelpers?: Iterable<string>
}

export function collectTemplateDependencies(backend: HerbBackend, file: string, source: string, options: DependencyOptions = {}): TemplateDependencies {
const custom = new Set(options.customHelpers ?? [])
const parsed = backend.parse(source, PARSER_OPTIONS)
const document = parsed.value as DocumentNode

const renders = new RenderCallCollector()
renders.visit(document)

const ruby = new RubyDependencyCollector()
const program = document.prismNode

if (program) {
ruby.visit(program)
}

const helperCalls = new Set<string>()
const unknownCalls = new Set<string>()

for (const name of ruby.bareCalls) {
if (helperExists(name) || custom.has(name)) {
helperCalls.add(name)

continue
}

if (name === "render") continue
if (ruby.knownLocals.has(name)) continue
if (name in renders.localsReceived) continue
if (renders.localsDeclared.has(name)) continue

unknownCalls.add(name)
}

return {
file,
instanceVariables: [...ruby.instanceVariables].sort(),
constants: [...ruby.constants].sort(),
localsDeclared: [...renders.localsDeclared].sort(),
localsReceived: renders.localsReceived,
renderCalls: renders.renderCalls,
helperCalls: [...helperCalls].sort(),
unknownCalls: [...unknownCalls].sort(),
}
}
141 changes: 141 additions & 0 deletions javascript/packages/analysis/test/template-dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, test, expect, beforeAll } from "vitest"

import { Herb } from "@herb-tools/node-wasm"

import { collectTemplateDependencies } from "../src/template-dependencies"

import type { DependencyOptions, TemplateDependencies } from "../src/template-dependencies"

describe("collectTemplateDependencies", () => {
beforeAll(async () => {
await Herb.load()
})

function analyze(source: string, options: DependencyOptions = {}): TemplateDependencies {
return collectTemplateDependencies(Herb, "app/views/posts/show.html.erb", source, options)
}

describe("instance variables", () => {
test("detects instance variables", () => {
const result = analyze(`<h1><%= @post.title %></h1><p><%= @user.name %></p>`)

expect(result.instanceVariables).toContain("@post")
expect(result.instanceVariables).toContain("@user")
})

test("detects instance variables in conditionals", () => {
expect(analyze(`<% if @admin %><p>Admin</p><% end %>`).instanceVariables).toContain("@admin")
})

test("detects instance variables inside string interpolation", () => {
expect(analyze(`<%= "Hello #{@name}" %>`).instanceVariables).toContain("@name")
})

test("detects multiple instance variables in a ternary", () => {
const result = analyze(`<%= @admin ? @admin_name : @guest_name %>`)

expect(result.instanceVariables).toEqual(expect.arrayContaining(["@admin", "@admin_name", "@guest_name"]))
})

test("deduplicates instance variables", () => {
expect(analyze(`<%= @post.title %><%= @post.body %>`).instanceVariables).toEqual(["@post"])
})
})

describe("constants", () => {
test("detects constants with method calls", () => {
const result = analyze(`<%= Current.user %><%= Post.count %>`)

expect(result.constants).toContain("Current.user")
expect(result.constants).toContain("Post.count")
})

test("detects constants in conditionals", () => {
expect(analyze(`<% if Current.user %><p>Logged in</p><% end %>`).constants).toContain("Current.user")
})
})

describe("locals", () => {
test("detects strict locals", () => {
const result = collectTemplateDependencies(Herb, "app/views/posts/_card.html.erb",
`<%# locals: (title:, body:) %>\n<h1><%= title %></h1>`)

expect(result.localsDeclared).toContain("title")
expect(result.localsDeclared).toContain("body")
})

test("does not flag declared locals as unknown", () => {
const result = collectTemplateDependencies(Herb, "app/views/posts/_card.html.erb",
`<%# locals: (title:) %>\n<%= title %>`)

expect(result.unknownCalls).toEqual([])
expect(result.localsDeclared).toContain("title")
})

test("detects locals passed to render calls", () => {
expect(analyze(`<%= render "shared/header", title: @post.title %>`).localsReceived.title).toBe("@post.title")
})

test("tracks instance variables from render local values", () => {
const result = analyze(`<%= render "shared/header", user: @current_user %>`)

expect(result.instanceVariables).toContain("@current_user")
expect(result.localsReceived.user).toBe("@current_user")
})
})

describe("helper and unknown calls", () => {
test("detects known Action View helpers", () => {
expect(analyze(`<%= link_to "Home", "/" %>`).helperCalls).toContain("link_to")
})

test("detects custom helpers once they are known", () => {
const result = analyze(`<%= markdown(@post.body) %>`, { customHelpers: ["markdown"] })

expect(result.helperCalls).toContain("markdown")
expect(result.unknownCalls).not.toContain("markdown")
})

test("flags unknown method calls", () => {
expect(analyze(`<%= current_user.name %>`).unknownCalls).toContain("current_user")
})

test("does not flag template defined locals as unknown", () => {
expect(analyze(`<% total = 1 %><%= total %>`).unknownCalls).not.toContain("total")
})

test("does not flag block parameters as unknown", () => {
expect(analyze(`<% @posts.each do |post| %><%= post.title %><% end %>`).unknownCalls).not.toContain("post")
})

test("does not flag nested block parameters as unknown", () => {
const source = `<% @posts.each do |post| %><% post.tags.each do |tag| %><%= tag.name %><% end %><% end %>`

expect(analyze(source).unknownCalls).not.toContain("tag")
})

test("conditional assignment registers as a local", () => {
expect(analyze(`<% total ||= 0 %><%= total %>`).unknownCalls).not.toContain("total")
})

test("operator assignment registers as a local", () => {
expect(analyze(`<% count = 0 %><% count += 1 %><%= count %>`).unknownCalls).not.toContain("count")
})
})

describe("render calls", () => {
test("tracks render calls with partials and locals", () => {
const [call] = analyze(`<%= render "shared/header", title: @post.title %>`).renderCalls

expect(call.partial).toBe("shared/header")
expect(call.locals.title).toBe("@post.title")
})

test("detects collection expression dependencies", () => {
const [call] = analyze(`<%= render partial: "posts/post", collection: @posts %>`).renderCalls

expect(call.partial).toBe("posts/post")
expect(call.collection).toBe("@posts")
})
})
})
Loading