`String#truncate` の `:separator` と過長 `:omission` のバグを修正

rails/rails

ActiveSupport の String#truncate:separator:omission を同時に指定した際、omissiontruncate_to 以上の長さになると期待と異なる文字列が返っていた問題を、条件分岐の追加で解消しました。

背景

このバグは String#truncate:separator オプションを利用したときに顕在化します。truncate_to から omission の長さを引いた length_with_room_for_omission が負になると、内部で rindex に負の開始位置が渡され、Ruby はそれを文字列末尾からのオフセットとして解釈します。その結果、stop が文字列末尾付近に設定され、self[0, stop] がほぼ全体を返すため、omission だけが返るべきケースで元文字列が長く出力されました

技術的な変更

修正は length_with_room_for_omission が 0 以下のときは即座に omission を返す という一行のガードを truncate メソッド冒頭に追加するだけです。これにより、separator ブランチは負の長さの場合に入り込まず、非 separator パスと同等の振る舞いになります。

変更前:

def truncate(truncate_to, options = {})
  omission = options[:omission] || "..."
  length_with_room_for_omission = truncate_to - omission.length
  stop = if options[:separator]
    rindex(options[:separator], length_with_room_for_omission) || length_with_room_for_omission
  else
    length_with_room_for_omission
  end
  # ...
end

変更後:

def truncate(truncate_to, options = {})
  omission = options[:omission] || "..."
  length_with_room_for_omission = truncate_to - omission.length
  return omission.dup if length_with_room_for_omission <= 0

  stop = if options[:separator]
    rindex(options[:separator], length_with_room_for_omission) || length_with_room_for_omission
  else
    length_with_room_for_omission
  end
  # ...
end

テストも test_truncate_with_separator_and_omission_longer_than_truncate_to を追加し、truncate_toomission より小さいケースで separator が文字列または正規表現のいずれでも正しく省略文字だけが返ることを確認しています。

def test_truncate_with_separator_and_omission_longer_than_truncate_to
  assert_equal "[...]", "Hello Big World!".truncate(2, omission: "[...]", separator: " ")
  assert_equal "[...]", "Hello Big World!".truncate(2, omission: "[...]", separator: /\s/)
end

設計判断

separator ブランチに入る条件を length_with_room_for_omission >= 0 に限定した点が中心の設計判断です。ガードを追加するだけで既存コードへの侵入を最小限に抑え、String#truncate の従来の API 互換性を保持しています。負の余裕がある場合は文字列本体を切り取る余地が無いため、即座に omission を返すのが自然な振る舞いであり、今回の変更はその直感に沿った実装です。

まとめ

String#truncateseparator と過長 omission の組み合わせ で生じていた不整合を、1 行の条件チェックで解消しました。既存の非 separator 動作はそのまま保たれ、テストカバレッジも追加されたことで回帰リスクが低減しています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
33c7ed4a

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

The article follows the required macro structure with a clear lead, background, technical detail, optional design insight, and a conclusion.

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

All code blocks use the correct `ruby:filepath` syntax and the PR link is properly formatted.

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

The content is appropriate for experienced Rails engineers without unnecessary beginner explanations.

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

Each section begins with a topic sentence, paragraphs stay within a reasonable length, and blank lines separate them.

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

The code snippets exactly match the provided diff, including the added guard line and test case.

技術用語の正確性 ✓ PASS

技術用語の正確な使用

Technical terms are used correctly and match those in the PR description.

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

The explanations accurately describe the bug, the fix, and its impact, consistent with the PR details.

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

All statements are supported by the PR description, diff, or test changes; no hallucinated facts.

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

The PR number and other identifiers are correct.

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

The article title mirrors the PR title accurately.

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

No external version or release information is introduced.

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

No temporal expressions are present, thus no mismatch.