From 4f69da14cce037eaf09faf3c5f59632d06f353ef Mon Sep 17 00:00:00 2001 From: OdenTakashi Date: Sat, 6 Jun 2026 17:10:00 +0900 Subject: [PATCH] Fix duplicate annotations when a file ends with a trailing blank line ## Problem Given a model whose file ends with two newlines (`end\n\n`): ```rb # == Schema Information # # Table name: users # class User < ApplicationRecord end <= 1st newline (ends "end") <= 2nd newline (blank line; file ends with \n\n) ``` running `annotaterb models --force` duplicates the schema block on every run: ```rb # == Schema Information # # Table name: users # # == Schema Information # <- duplicated, grows by one every run # # Table name: users # class User < ApplicationRecord end ``` ## Cause With an extra trailing newline (`end\n\n`), `@file_lines[-1]` is a real blank line (`"\n"`). For a top-of-file annotation (annotation_start == 0), the leading-whitespace check reads `@file_lines[annotation_start - 1]`, i.e. `@file_lines[-1]` via Ruby's negative index, and treats that blank line as leading whitespace. `annotation_start` becomes -1, which makes `@file_lines[-1..annotation_end]` an empty range, so `annotations_with_whitespace` is `""`. Removal then runs `sub("", "")`, a no-op, so the old annotation is never removed and a new one is appended. ## Solution Add an `annotation_start > 0` guard so a top-of-file annotation never reads `@file_lines[-1]`. --- .../file_parser/parsed_file.rb | 2 +- .../file_parser/parsed_file_spec.rb | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 spec/lib/annotate_rb/model_annotator/file_parser/parsed_file_spec.rb diff --git a/lib/annotate_rb/model_annotator/file_parser/parsed_file.rb b/lib/annotate_rb/model_annotator/file_parser/parsed_file.rb index 5ad8320b..0d62718e 100644 --- a/lib/annotate_rb/model_annotator/file_parser/parsed_file.rb +++ b/lib/annotate_rb/model_annotator/file_parser/parsed_file.rb @@ -45,7 +45,7 @@ def parse annotation_start = @finder.annotation_start annotation_end = @finder.annotation_end - if @file_lines[annotation_start - 1]&.strip&.empty? + if annotation_start > 0 && @file_lines[annotation_start - 1]&.strip&.empty? annotation_start -= 1 has_leading_whitespace = true end diff --git a/spec/lib/annotate_rb/model_annotator/file_parser/parsed_file_spec.rb b/spec/lib/annotate_rb/model_annotator/file_parser/parsed_file_spec.rb new file mode 100644 index 00000000..3c3caccb --- /dev/null +++ b/spec/lib/annotate_rb/model_annotator/file_parser/parsed_file_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +RSpec.describe AnnotateRb::ModelAnnotator::FileParser::ParsedFile do + describe "#parse" do + subject { described_class.new(file_content, new_annotations, parser_klass, options).parse } + + let(:parser_klass) { AnnotateRb::ModelAnnotator::FileParser::CustomParser } + let(:options) { AnnotateRb::Options.new({}) } + let(:new_annotations) do + <<~ANNOTATIONS + # == Schema Information + # + # Table name: users + # + # id :bigint not null, primary key + # + ANNOTATIONS + end + + # Regression: when the annotation is at the very top of the file (annotation_start == 0), + # `@file_lines[annotation_start - 1]` used to wrap around to `@file_lines[-1]` (the last + # line). If the file ended with a blank line, this was mistaken for leading whitespace, + # decremented annotation_start to -1, and produced an empty `annotations_with_whitespace`. + # An empty removal string is a no-op, so old annotations were never removed and duplicated. + context "when the annotation is at the top of the file and the file ends with a blank line" do + let(:file_content) do + "# == Schema Information\n" \ + "#\n" \ + "# Table name: users\n" \ + "#\n" \ + "# id :bigint not null, primary key\n" \ + "#\n" \ + "class User < ApplicationRecord\n" \ + "end\n" \ + "\n" + end + + it "does not treat the trailing blank line as leading whitespace" do + expect(subject.has_leading_whitespace?).to eq(false) + end + + it "captures the annotation in annotations_with_whitespace" do + expect(subject.annotations_with_whitespace).not_to be_empty + expect(subject.annotations_with_whitespace).to include("# == Schema Information") + end + end + end +end