Skip to content
Open
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/linter/docs/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ This page contains documentation for all Herb Linter rules.
- [`actionview-no-unused-strict-locals`](./actionview-no-unused-strict-locals.md) - Disallow strict locals that are never used in the partial
- [`actionview-no-void-element-content`](./actionview-no-void-element-content.md) - Disallow content arguments for void Action View elements
- [`actionview-prefer-collection-render`](./actionview-prefer-collection-render.md) - Prefer collection rendering over rendering a partial in a loop
- [`actionview-prefer-pluralize-helper`](./actionview-prefer-pluralize-helper.md) - Prefer the `pluralize` helper over `String#pluralize` for counts
- [`actionview-strict-locals-first-line`](./actionview-strict-locals-first-line.md) - Require strict locals on the first line of partials with a blank line after.
- [`actionview-strict-locals-partial-only`](./actionview-strict-locals-partial-only.md) - Only allow strict local definitions in partial files.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Linter Rule: Prefer the `pluralize` helper over `String#pluralize` for counts

**Rule:** `actionview-prefer-pluralize-helper`

## Description

Prefer Rails' `ActionView::Helpers::TextHelper#pluralize` helper over calling `String#pluralize` with a count in ERB templates.

## Rationale

Both `pluralize("Alias", count)` and `"Alias".pluralize(count)` inflect a word based on a count, but they behave differently. The `pluralize` helper prepends the count to the resulting string (e.g. `pluralize(2, "person")` returns `"2 people"`), which is almost always what you want when rendering a count next to a noun. It also handles the `1`/singular case correctly and reads naturally in a template.

Reaching for `String#pluralize(count)` usually means the count is rendered separately, leading to duplicated output like `<%= aliases.size %> <%= "Alias".pluralize(aliases.size) %>`. Consolidating on the `pluralize` helper keeps the count and the noun together, avoids the extra output tag, and makes the intent clearer.

## Examples

### ✅ Good

```erb
<%= pluralize("Known Alias", aliases.size) %>
```

```erb
Known <%= pluralize("Alias", aliases.size) %>
```

### 🚫 Bad

```erb
<%= aliases.size %><%= "Known Alias".pluralize(aliases.size) %>
```

```erb
<%= aliases.size %> Known <%= "Alias".pluralize(aliases.size) %>
```

## References

- [`ActionView::Helpers::TextHelper#pluralize`](https://api.rubyonrails.org/classes/ActionView/Helpers/TextHelper.html#method-i-pluralize)
- [`String#pluralize`](https://api.rubyonrails.org/classes/String.html#method-i-pluralize)
2 changes: 2 additions & 0 deletions javascript/packages/linter/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ActionViewNoUnnecessaryTagAttributesRule } from "./rules/actionview-no-
import { ActionViewNoUnusedStrictLocalsRule } from "./rules/actionview-no-unused-strict-locals.js"
import { ActionViewNoVoidElementContentRule } from "./rules/actionview-no-void-element-content.js"
import { ActionViewPreferCollectionRenderRule } from "./rules/actionview-prefer-collection-render.js"
import { ActionViewPreferPluralizeHelperRule } from "./rules/actionview-prefer-pluralize-helper.js"
import { ActionViewStrictLocalsFirstLineRule } from "./rules/actionview-strict-locals-first-line.js"
import { ActionViewStrictLocalsPartialOnlyRule } from "./rules/actionview-strict-locals-partial-only.js"

Expand Down Expand Up @@ -144,6 +145,7 @@ export const rules: RuleClass[] = [
ActionViewNoUnusedStrictLocalsRule,
ActionViewNoVoidElementContentRule,
ActionViewPreferCollectionRenderRule,
ActionViewPreferPluralizeHelperRule,
ActionViewStrictLocalsFirstLineRule,
ActionViewStrictLocalsPartialOnlyRule,

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { ParserRule } from "../types.js"
import { BaseRuleVisitor } from "./rule-utils.js"
import {
isERBContentNode,
isERBOutputNode,
isHTMLTextNode,
isLiteralNode,
isPrismNodeType,
locationFromByteOffset,
substringFromByteOffset,
} from "@herb-tools/core"

import type {
DocumentNode,
ERBContentNode,
HTMLElementNode,
Node,
ParseResult,
ParserOptions,
PrismNode,
} from "@herb-tools/core"
import type {
UnboundLintOffense,
LintContext,
FullRuleConfig,
} from "../types.js"

const COUNT_METHODS = new Set(["length", "size", "count"])

function singleExpression(node: PrismNode | null): PrismNode | null {
if (!node) return null

if (isPrismNodeType(node, "ProgramNode")) {
const body = node.statements?.body
return Array.isArray(body) && body.length === 1 ? body[0] : null
}

if (isPrismNodeType(node, "StatementsNode")) {
return node.body.length === 1 ? node.body[0] : null
}

return node
}

function countExpression(node: ERBContentNode): PrismNode | null {
const expression = singleExpression(node.prismNode)

if (!isPrismNodeType(expression, "CallNode")) return null
if (!expression.receiver || !COUNT_METHODS.has(expression.name)) return null

return expression
}

function stringPluralizeCall(node: ERBContentNode): PrismNode | null {
const expression = singleExpression(node.prismNode)

if (!isPrismNodeType(expression, "CallNode")) return null
if (expression.name !== "pluralize") return null
if (
!isPrismNodeType(expression.receiver, "StringNode") &&
!isPrismNodeType(expression.receiver, "InterpolatedStringNode")
) {
return null
}

const args = expression.arguments_?.arguments_
if (!Array.isArray(args) || args.length !== 1) return null

return expression
}

function prismFingerprint(node: PrismNode): string {
return JSON.stringify(node, (key, value) => {
if (key === "location" || key.endsWith("Loc")) return undefined
// Prism marks a call used as the value of a program differently from the
// same call nested in an argument. That contextual flag is not semantic.
if (key === "flags" && typeof value === "number") return value & ~1
return value
})
}

function staticText(node: Node): string | null {
if (isLiteralNode(node) || isHTMLTextNode(node)) return node.content ?? ""
return null
}

class ActionViewPreferPluralizeHelperVisitor extends BaseRuleVisitor {
constructor(
ruleName: string,
context: Partial<LintContext> | undefined,
private readonly source: string,
) {
super(ruleName, context)
}

visitDocumentNode(node: DocumentNode): void {
this.checkSiblings(node.children)
this.visitChildNodes(node)
}

visitHTMLElementNode(node: HTMLElementNode): void {
this.checkSiblings(node.body)
this.visitChildNodes(node)
}

private checkSiblings(nodes: Node[]): void {
for (let index = 0; index < nodes.length; index++) {
const firstERB = nodes[index]
if (!isERBContentNode(firstERB) || !isERBOutputNode(firstERB)) continue

const count = countExpression(firstERB)
if (!count) continue

let nextIndex = index + 1
let interveningText = ""

while (nextIndex < nodes.length) {
const text = staticText(nodes[nextIndex])
if (text === null) break

interveningText += text
nextIndex++
}

const secondERB = nodes[nextIndex]
if (!isERBContentNode(secondERB) || !isERBOutputNode(secondERB)) continue

const pluralize = stringPluralizeCall(secondERB)
if (!pluralize) continue

const pluralizeCount = pluralize.arguments_.arguments_[0]
if (prismFingerprint(count) !== prismFingerprint(pluralizeCount)) continue

this.addPluralizeOffense(count, pluralize, interveningText)
}
}

private addPluralizeOffense(
count: PrismNode,
pluralize: PrismNode,
interveningText: string,
): void {
const slice = (node: PrismNode) =>
substringFromByteOffset(
this.source,
node.location.startOffset,
node.location.length,
)

let singular = slice(pluralize.receiver)

if (interveningText.trim()) {
const canRepresentLiteralText =
interveningText.startsWith(" ") &&
isPrismNodeType(pluralize.receiver, "StringNode")

if (!canRepresentLiteralText) {
singular = "singular"
} else {
const content = substringFromByteOffset(
this.source,
pluralize.receiver.contentLoc.startOffset,
pluralize.receiver.contentLoc.length,
)
singular = JSON.stringify(`${interveningText.slice(1)}${content}`)
}
}

const suggestion = `pluralize(${slice(count)}, ${singular})`
const location = locationFromByteOffset(
this.source,
pluralize.location.startOffset,
pluralize.location.length,
)

this.addOffense(
`Prefer the \`pluralize\` helper over separate count and \`String#pluralize\` output. Use \`<%= ${suggestion} %>\` instead.`,
location,
)
}
}

export class ActionViewPreferPluralizeHelperRule extends ParserRule {
static ruleName = "actionview-prefer-pluralize-helper"
static introducedIn = this.version("unreleased")

get defaultConfig(): FullRuleConfig {
return {
enabled: true,
severity: "warning",
}
}

get parserOptions(): Partial<ParserOptions> {
return {
prism_nodes: true,
prism_program: true,
}
}

check(
result: ParseResult,
context?: Partial<LintContext>,
): UnboundLintOffense[] {
const source = result.value.source
if (!source) return []

const visitor = new ActionViewPreferPluralizeHelperVisitor(
this.ruleName,
context,
source,
)
visitor.visit(result.value)

return visitor.offenses
}
}
1 change: 1 addition & 0 deletions javascript/packages/linter/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export * from "./actionview-no-unnecessary-tag-attributes.js"
export * from "./actionview-no-unused-strict-locals.js"
export * from "./actionview-no-void-element-content.js"
export * from "./actionview-prefer-collection-render.js"
export * from "./actionview-prefer-pluralize-helper.js"
export * from "./actionview-strict-locals-first-line.js"
export * from "./actionview-strict-locals-partial-only.js"

Expand Down
Loading
Loading