diff --git a/javascript/packages/analysis/src/index.ts b/javascript/packages/analysis/src/index.ts index d3f509e06..f79042853 100644 --- a/javascript/packages/analysis/src/index.ts +++ b/javascript/packages/analysis/src/index.ts @@ -3,3 +3,4 @@ export * from "./partial-callers" export * from "./partial-resolution" export * from "./render-expression" export * from "./ancestor-attributes" +export * from "./template-dependencies" diff --git a/javascript/packages/analysis/src/render-call-collector.ts b/javascript/packages/analysis/src/render-call-collector.ts new file mode 100644 index 000000000..78012a6d3 --- /dev/null +++ b/javascript/packages/analysis/src/render-call-collector.ts @@ -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 + collection?: string +} + +export class RenderCallCollector extends Visitor { + readonly renderCalls: RenderCallDependency[] = [] + readonly localsReceived: Record = {} + readonly localsDeclared = new Set() + + 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 = {} + + 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) + } + } +} diff --git a/javascript/packages/analysis/src/ruby-dependency-collector.ts b/javascript/packages/analysis/src/ruby-dependency-collector.ts new file mode 100644 index 000000000..245a5ff7a --- /dev/null +++ b/javascript/packages/analysis/src/ruby-dependency-collector.ts @@ -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() + readonly constants = new Set() + readonly knownLocals = new Set() + readonly bareCalls = new Set() + + 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) + } +} diff --git a/javascript/packages/analysis/src/template-dependencies.ts b/javascript/packages/analysis/src/template-dependencies.ts new file mode 100644 index 000000000..dbda9f99b --- /dev/null +++ b/javascript/packages/analysis/src/template-dependencies.ts @@ -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 + renderCalls: RenderCallDependency[] + helperCalls: string[] + unknownCalls: string[] +} + +export interface DependencyOptions { + customHelpers?: Iterable +} + +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() + const unknownCalls = new Set() + + 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(), + } +} diff --git a/javascript/packages/analysis/test/template-dependencies.test.ts b/javascript/packages/analysis/test/template-dependencies.test.ts new file mode 100644 index 000000000..4653bdf17 --- /dev/null +++ b/javascript/packages/analysis/test/template-dependencies.test.ts @@ -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(`

<%= @post.title %>

<%= @user.name %>

`) + + expect(result.instanceVariables).toContain("@post") + expect(result.instanceVariables).toContain("@user") + }) + + test("detects instance variables in conditionals", () => { + expect(analyze(`<% if @admin %>

Admin

<% 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 %>

Logged in

<% 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

<%= title %>

`) + + 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") + }) + }) +})