Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cd0dc38
Rust: Index templates Rails renders but the linter globs miss
marcoroth Aug 14, 2026
3223257
Rust: Resolve partials against Rails' ordered view paths
marcoroth Aug 14, 2026
cf9bd6c
Analysis: Resolve partials against Rails' ordered view paths
marcoroth Aug 14, 2026
4eb5e6a
Analysis: Resolve partials against Rails' ordered view paths in TypeS…
marcoroth Aug 14, 2026
9d645d5
Analysis: Take view roots as a list in every binding
marcoroth Aug 14, 2026
8c4affd
Analysis: Drop the singular view root
marcoroth Aug 14, 2026
4fdd1a4
RBS
marcoroth Aug 14, 2026
afb98e9
Analysis: Resolve partial names that spell out the extension
marcoroth Aug 14, 2026
48da114
Analysis: Resolve conditional renders whose branches are literals
marcoroth Aug 14, 2026
7ef3b11
Analysis: Resolve partials against the caller's format
marcoroth Aug 14, 2026
eab40ee
Analysis: Don't report a guessed object partial as unresolved
marcoroth Aug 14, 2026
e0254f3
Analysis: Resolve template variants
marcoroth Aug 14, 2026
3c1d505
Analysis: Resolve partials against the caller's format in TypeScript
marcoroth Aug 14, 2026
3cc85b3
Analysis: Update viewRoot consumers outside the analysis package
marcoroth Aug 14, 2026
d6fbf3d
Analysis: List the partials a dynamic render could reach
marcoroth Aug 14, 2026
cbb6e3b
Analysis: Ignore component templates at any depth
marcoroth Aug 14, 2026
49285bd
Analysis: Only treat path-like names as dynamic renders
marcoroth Aug 14, 2026
49e525f
Analysis: Resolve conditional render branches in Ruby
marcoroth Aug 14, 2026
2845ba4
Analysis: Bring the Ruby check output back to parity
marcoroth Aug 14, 2026
c053732
Analysis: List the partials a dynamic render could reach in Ruby
marcoroth Aug 14, 2026
692f7b3
Analysis: Follow renders out of prefix-matched partials when finding …
marcoroth Aug 14, 2026
ee1bd56
Analysis: Scan every statement in a Ruby file for render references
marcoroth Aug 14, 2026
1789113
Analysis: Fix CI and bring the Ruby check output closer to parity
marcoroth Aug 14, 2026
cf34d01
Analysis: Prefer the plainest template when several formats match
marcoroth Aug 15, 2026
542cd03
Analysis: Require the actionview framework and show project-relative …
marcoroth Aug 15, 2026
5d91a63
Analysis: Count every skipped component template
marcoroth Aug 15, 2026
07e02b9
Merge remote-tracking branch 'origin/main' into partial-index-discovery
marcoroth Aug 15, 2026
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
5 changes: 4 additions & 1 deletion javascript/packages/analysis/src/partial-index-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,14 @@ export async function buildPartialIndex(herb: HerbBackend, projectPath: string):
const viewRoot = await findViewRoot(projectPath)
const files = await partialsIn(projectPath, viewRoot)
const declarations = new Map<string, PartialDeclaration>()
const filesByName = new Map<string, string[]>()

for (const file of files.sort()) {
const name = partialNameForFile(file, viewRoot)
if (name === null) continue

filesByName.set(name, [...(filesByName.get(name) ?? []), file])

const existing = declarations.get(name)
if (existing && !outranksTemplate(file, existing.file)) continue

Expand All @@ -58,7 +61,7 @@ export async function buildPartialIndex(herb: HerbBackend, projectPath: string):
if (declaration) declarations.set(name, declaration)
}

return new PartialIndex(viewRoot, declarations)
return new PartialIndex([viewRoot], declarations, filesByName)
}

export function partialIndexFrom(data: SerializedPartialIndex | undefined): PartialIndex | undefined {
Expand Down
22 changes: 11 additions & 11 deletions javascript/packages/analysis/src/partial-index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isERBStrictLocalsNode, isRubyParameterNode } from "@herb-tools/core"
import { PARTIAL_EXTENSIONS, partialNameForFile, resolvePartial } from "./partial-resolution"
import { PARTIAL_EXTENSIONS, partialNameForRoots, resolvePartial } from "./partial-resolution"

import type { DocumentNode } from "@herb-tools/core"
import type { PartialPaths } from "./partial-resolution"
Expand All @@ -23,7 +23,7 @@ export interface PartialDeclaration {
}

export interface SerializedPartialIndex {
viewRoot: string
viewRoots: string[]
partials: Record<string, PartialDeclaration>
}

Expand Down Expand Up @@ -90,24 +90,24 @@ export function declarationFromDocument(document: DocumentNode, file: string): P
}

export class PartialIndex {
readonly viewRoot: string
readonly viewRoots: string[]

private readonly declarations: Map<string, PartialDeclaration>
private readonly files: PartialPaths
private readonly byFile: Map<string, PartialDeclaration>

static from(data: SerializedPartialIndex): PartialIndex {
return new PartialIndex(data.viewRoot, new Map(Object.entries(data.partials)))
return new PartialIndex(data.viewRoots, new Map(Object.entries(data.partials)))
}

constructor(viewRoot: string, declarations: Map<string, PartialDeclaration>) {
this.viewRoot = viewRoot
constructor(viewRoots: string[], declarations: Map<string, PartialDeclaration>, filesByName?: Map<string, string[]>) {
this.viewRoots = viewRoots
this.declarations = declarations
this.files = new Map()
this.byFile = new Map()

for (const [name, declaration] of declarations) {
this.files.set(name, declaration.file)
this.files.set(name, filesByName?.get(name) ?? declaration.file)
this.byFile.set(declaration.file, declaration)
}
}
Expand All @@ -117,15 +117,15 @@ export class PartialIndex {
}

lookup(partialName: string, sourceFile: string | undefined): PartialDeclaration | null {
const file = resolvePartial(partialName, sourceFile ?? "", this.files, this.viewRoot)
const file = resolvePartial(partialName, sourceFile ?? "", this.files, this.viewRoots)

if (file === null) return null

return this.byFile.get(file) ?? null
}

update(declaration: PartialDeclaration): string | null {
const name = partialNameForFile(declaration.file, this.viewRoot)
const name = partialNameForRoots(declaration.file, this.viewRoots)
if (name === null) return null

const existing = this.declarations.get(name)
Expand All @@ -144,7 +144,7 @@ export class PartialIndex {
}

remove(file: string): string | null {
const name = partialNameForFile(file, this.viewRoot)
const name = partialNameForRoots(file, this.viewRoots)
if (name === null) return null

const existing = this.declarations.get(name)
Expand All @@ -162,6 +162,6 @@ export class PartialIndex {
}

toJSON(): SerializedPartialIndex {
return { viewRoot: this.viewRoot, partials: Object.fromEntries(this.declarations) }
return { viewRoots: this.viewRoots, partials: Object.fromEntries(this.declarations) }
}
}
157 changes: 151 additions & 6 deletions javascript/packages/analysis/src/partial-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const PARTIAL_GLOB_PATTERN = `_${TEMPLATE_GLOB_PATTERN}`
const PARTIAL_PREFIX = "_"
const APPLICATION_DIRECTORY = "application"

export type PartialPaths = Map<string, string>
export type PartialPaths = Map<string, string | string[]>

function normalize(path: string): string {
const separated = path.replace(/\\/g, "/")
Expand Down Expand Up @@ -50,12 +50,120 @@ function relativeToViewRoot(path: string, viewRoot: string): string | null {
return normalizedPath.slice(normalizedRoot.length + 1)
}

export function relativeToViewRoots(path: string, viewRoots: string[]): [number, string] | null {
for (const [index, root] of viewRoots.entries()) {
const relative = relativeToViewRoot(path, root)

if (relative !== null) return [index, relative]
}

return null
}

export function partialNameForRoots(filePath: string, viewRoots: string[]): string | null {
for (const root of viewRoots) {
const name = partialNameForFile(filePath, root)

if (name !== null) return name
}

return null
}

export function templateNameForRoots(filePath: string, viewRoots: string[]): string | null {
for (const root of viewRoots) {
const name = templateNameForFile(filePath, root)

if (name !== null) return name
}

return null
}

export function rootIndexFor(filePath: string, viewRoots: string[]): number {
return relativeToViewRoots(filePath, viewRoots)?.[0] ?? viewRoots.length
}

export function projectRelativePath(filePath: string, projectPath: string | undefined): string {
if (!projectPath) return normalize(filePath)

return relativeToViewRoot(filePath, projectPath) ?? normalize(filePath)
}

export function formatOf(filePath: string): string | null {
const name = basename(normalize(filePath))
const dot = name.indexOf(".")

if (dot === -1) return null

const extension = name.slice(dot)
const stripped = extension.endsWith(".erb")
? extension.slice(0, -".erb".length)
: extension.endsWith(".herb")
? extension.slice(0, -".herb".length)
: null

if (stripped === null) return null

const segments = stripped.startsWith(".") ? stripped.slice(1) : stripped
const last = segments.split(".").pop() ?? segments
const format = last.split("+")[0] ?? last

return format === "" ? null : format
}

export function hasLocale(filePath: string): boolean {
const name = basename(normalize(filePath))
const dot = name.indexOf(".")

if (dot === -1) return false

const extension = name.slice(dot)
const stripped = extension.endsWith(".erb")
? extension.slice(0, -".erb".length)
: extension.endsWith(".herb")
? extension.slice(0, -".herb".length)
: null

if (stripped === null) return false

const segments = (stripped.startsWith(".") ? stripped.slice(1) : stripped).split(".")

return segments.length > 1
}

export function variantOf(filePath: string): string | null {
const name = basename(normalize(filePath))
const dot = name.indexOf(".")

if (dot === -1) return null

const extension = name.slice(dot)
const stripped = extension.endsWith(".erb")
? extension.slice(0, -".erb".length)
: extension.endsWith(".herb")
? extension.slice(0, -".herb".length)
: null

if (stripped === null) return null

const plus = stripped.indexOf("+")

if (plus === -1) return null

const variant = stripped.slice(plus + 1)

return variant === "" ? null : variant
}

export function withoutTemplateExtension(partialName: string): string {
for (const extension of PARTIAL_EXTENSIONS) {
if (partialName.endsWith(extension)) return partialName.slice(0, -extension.length)
}

return partialName
}

export function isTemplatePath(filePath: string): boolean {
const name = basename(normalize(filePath))

Expand Down Expand Up @@ -111,6 +219,16 @@ export function templateNameForFile(filePath: string, viewRoot: string): string
return directory === "." ? withoutExtension : `${directory}/${withoutExtension}`
}

export function layoutCandidatesForRoots(templateFile: string, viewRoots: string[]): string[] {
for (const root of viewRoots) {
const candidates = layoutCandidatesFor(templateFile, root)

if (candidates.length > 0) return candidates
}

return []
}

export function layoutCandidatesFor(templateFile: string, viewRoot: string): string[] {
const relative = relativeToViewRoot(normalize(templateFile), viewRoot)

Expand All @@ -136,23 +254,50 @@ export function layoutCandidatesFor(templateFile: string, viewRoot: string): str
return candidates
}

export function resolvePartial(partialName: string, sourceFile: string, index: PartialPaths, viewRoot: string): string | null {
function pickForCaller(candidates: string | string[], sourceFile: string): string | null {
if (!Array.isArray(candidates)) return candidates
if (candidates.length === 0) return null

const format = formatOf(sourceFile)

if (format === null) return candidates[0] ?? null

const ranked = [...candidates].sort((a, b) => rankForFormat(a, format) - rankForFormat(b, format))

return ranked[0] ?? null
}

function rankForFormat(file: string, format: string): number {
const candidate = formatOf(file)
const matches = candidate === format ? 0 : candidate === null ? 1 : 2

return matches * 4 + (variantOf(file) === null ? 0 : 2) + (hasLocale(file) ? 1 : 0)
}

export function resolvePartial(
partialName: string,
sourceFile: string,
index: PartialPaths,
viewRoots: string[]
): string | null {
partialName = withoutTemplateExtension(partialName)

const exact = index.get(partialName)

if (exact !== undefined) return exact
if (exact !== undefined) return pickForCaller(exact, sourceFile)

const sourceDirectory = relativeToViewRoot(dirname(normalize(sourceFile)), viewRoot)
const sourceDirectory = relativeToViewRoots(dirname(normalize(sourceFile)), viewRoots)?.[1] ?? null

if (sourceDirectory !== null && sourceDirectory !== ".") {
const relative = index.get(`${sourceDirectory}/${partialName}`)

if (relative !== undefined) return relative
if (relative !== undefined) return pickForCaller(relative, sourceFile)
}

if (!partialName.includes("/")) {
const application = index.get(`${APPLICATION_DIRECTORY}/${partialName}`)

if (application !== undefined) return application
if (application !== undefined) return pickForCaller(application, sourceFile)
}

return null
Expand Down
6 changes: 3 additions & 3 deletions javascript/packages/analysis/src/project-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export class ProjectIndex {
return this.callerIndex
}

get viewRoot(): string | undefined {
return this.partialIndex?.viewRoot
get viewRoots(): string[] | undefined {
return this.partialIndex?.viewRoots
}

async indexAll(): Promise<void> {
Expand All @@ -71,7 +71,7 @@ export class ProjectIndex {
try {
this.partialIndex = await buildPartialIndex(this.backend, this.root)

this.logger?.log(`[Partials] Indexed ${this.partialIndex.size} partials under ${this.partialIndex.viewRoot}`)
this.logger?.log(`[Partials] Indexed ${this.partialIndex.size} partials under ${this.partialIndex.viewRoots.join(", ")}`)
} catch (error) {
this.logger?.warn(`[Partials] Failed to index partials: ${this.messageFor(error)}`)
}
Expand Down
12 changes: 6 additions & 6 deletions javascript/packages/analysis/src/render-graph-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { readFileSync } from "node:fs"

import { getTagLocalName, isERBCaseNode, isERBIfNode, isERBOutputNode, isERBRenderNode, isERBUnlessNode, isHTMLElementNode, isPrismNodeType, isRubyRenderLocalNode } from "@herb-tools/core"
import { outranksTemplate } from "./partial-index"
import { layoutCandidatesFor, templateNameForFile, isPartialPath } from "./partial-resolution"
import { layoutCandidatesForRoots, templateNameForRoots, isPartialPath } from "./partial-resolution"
import { renderPartialExpression } from "./render-expression"
import { staticAncestorAttributes } from "./ancestor-attributes"

Expand Down Expand Up @@ -227,11 +227,11 @@ export function collectCallSites(herb: HerbBackend, partials: PartialIndex, file
return { unresolved, isDocumentRoot, yields, roots: { ...roots, renders: rootRenders, resolved: rootsResolved } }
}

function addLayoutCallSites(files: string[], layoutYields: Map<string, YieldSite[]>, viewRoot: string, callSites: Map<string, PartialCallSite[]>): void {
function addLayoutCallSites(files: string[], layoutYields: Map<string, YieldSite[]>, viewRoots: string[], callSites: Map<string, PartialCallSite[]>): void {
const layouts = new Map<string, string>()

for (const file of files) {
const name = templateNameForFile(file, viewRoot)
const name = templateNameForRoots(file, viewRoots)

if (name === null || !layoutYields.has(file)) {
continue
Expand All @@ -247,7 +247,7 @@ function addLayoutCallSites(files: string[], layoutYields: Map<string, YieldSite
}

for (const file of files) {
for (const candidate of layoutCandidatesFor(file, viewRoot)) {
for (const candidate of layoutCandidatesForRoots(file, viewRoots)) {
const layout = layouts.get(candidate)

if (!layout || layout === file) {
Expand All @@ -268,7 +268,7 @@ function addLayoutCallSites(files: string[], layoutYields: Map<string, YieldSite
}

export async function buildRenderGraph(herb: HerbBackend, projectPath: string, partials: PartialIndex, options: RenderGraphOptions = {}): Promise<RenderGraph> {
const files = await templatesIn(projectPath, partials.viewRoot, options.include ?? [])
const files = await templatesIn(projectPath, partials.viewRoots[0] ?? ".", options.include ?? [])
const excluded = options.exclude?.length ? picomatch(options.exclude) : null
const callSites = new Map<string, PartialCallSite[]>()
const documentRoots = new Set<string>()
Expand Down Expand Up @@ -318,7 +318,7 @@ export async function buildRenderGraph(herb: HerbBackend, projectPath: string, p
}

if (options.resolveLayouts !== false) {
addLayoutCallSites(scanned, layoutYields, partials.viewRoot, callSites)
addLayoutCallSites(scanned, layoutYields, partials.viewRoots, callSites)
}

return new RenderGraph(callSites, roots, documentRoots, unresolvedRenders, skippedFiles)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("buildPartialIndex", () => {

const index = await buildPartialIndex(Herb, root)

expect(index.viewRoot).toBe("app/views")
expect(index.viewRoots).toEqual(["app/views"])
expect(index.size).toBe(2)

expect(index.lookup("users/card", "app/views/posts/index.html.erb")).toEqual({
Expand Down Expand Up @@ -126,7 +126,7 @@ describe("buildPartialIndex", () => {

const index = await buildPartialIndex(Herb, root)

expect(index.viewRoot).toBe(".")
expect(index.viewRoots).toEqual(["."])
expect(index.lookup("views/card", "index.html.erb")?.file).toBe("views/_card.html.erb")
})

Expand Down
Loading
Loading