Skip to content

Commit 8cc24fe

Browse files
sanohiroclaude
andcommitted
fix: LineIndex境界条件の致命的バグを修正(v1.5.3リグレッション)
updateForInsert/updateForDeleteで `> pos` が `>= pos` に誤変更されていた。 行頭への挿入時にline_startsが不正シフトし、文字が表示されなくなっていた。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c90bcd0 commit 8cc24fe

6 files changed

Lines changed: 108 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.5.4] - 2026-03-08
9+
10+
### Fixed
11+
- **致命的バグ: 文字入力が表示されない**: LineIndex.updateForInsertの境界条件が誤っていた(`>= pos``> pos`に修正)
12+
- v1.5.3で導入されたリグレッション(a28fae6)
13+
- 行頭への挿入時にline_startsが不正にシフトされ、レンダリングが空になっていた
14+
- **LineIndex.updateForDelete**: 削除範囲の境界条件も同様に修正(`< end_pos``<= end_pos`
15+
16+
### Changed
17+
- LineIndexの線形探索をバイナリサーチ(upperBound)に最適化
18+
- 検索ハイライトの境界チェックをclamping方式から早期リターンに変更
19+
- ループ外でのtab_width取得によるマイクロ最適化
20+
- Query Replace時のerrdefer追加でエラー時のロールバックを改善
21+
822
## [1.5.3] - 2026-01-10
923

1024
### Fixed

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .ze,
3-
.version = "1.5.3",
3+
.version = "1.5.4",
44
.fingerprint = 0xa9d116540b6957d2,
55
.paths = .{""},
66
}

src/buffer.zig

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,21 @@ pub const LineIndex = struct {
589589
}
590590
}
591591

592+
/// line_starts内でtargetより大きい最初のインデックスを返す(upper bound)
593+
fn upperBound(self: *const LineIndex, target: usize) usize {
594+
var left: usize = 0;
595+
var right: usize = self.line_starts.items.len;
596+
while (left < right) {
597+
const mid = left + (right - left) / 2;
598+
if (self.line_starts.items[mid] <= target) {
599+
left = mid + 1;
600+
} else {
601+
right = mid;
602+
}
603+
}
604+
return left;
605+
}
606+
592607
pub fn rebuild(self: *LineIndex, buffer: *const Buffer) !void {
593608
errdefer self.valid = false;
594609

@@ -679,13 +694,13 @@ pub const LineIndex = struct {
679694
/// 挿入時のインクリメンタル更新(O(改行数 + 影響行数))
680695
/// 再スキャンなしで行インデックスを更新する
681696
pub fn updateForInsert(self: *LineIndex, pos: usize, text: []const u8) !void {
682-
if (!self.valid) return; // 無効なら何もしない
697+
if (!self.valid or text.len == 0) return; // 無効または空挿入なら何もしない
683698

684-
// pos以降の全エントリにテキスト長を加算(行頭への挿入を含む
685-
for (self.line_starts.items) |*line_start| {
686-
if (line_start.* >= pos) {
687-
line_start.* += text.len;
688-
}
699+
// posより後のエントリのみシフト(行頭のline_start == posは動かさない
700+
const shift_start = self.upperBound(pos);
701+
var idx = shift_start;
702+
while (idx < self.line_starts.items.len) : (idx += 1) {
703+
self.line_starts.items[idx] += text.len;
689704
}
690705

691706
// 挿入テキスト内の改行位置を検出して追加
@@ -700,17 +715,8 @@ pub const LineIndex = struct {
700715
}
701716

702717
if (new_lines.items.len > 0) {
703-
// 挿入位置に対応するインデックスを見つける
704-
var insert_idx: usize = self.line_starts.items.len;
705-
for (self.line_starts.items, 0..) |line_start, idx| {
706-
if (line_start > pos) {
707-
insert_idx = idx;
708-
break;
709-
}
710-
}
711-
712718
// 新しい行をその位置に挿入
713-
try self.line_starts.insertSlice(self.allocator, insert_idx, new_lines.items);
719+
try self.line_starts.insertSlice(self.allocator, shift_start, new_lines.items);
714720
}
715721

716722
self.valid_until_pos += text.len;
@@ -719,32 +725,30 @@ pub const LineIndex = struct {
719725
/// 削除時のインクリメンタル更新(O(削除行数 + 影響行数))
720726
/// 再スキャンなしで行インデックスを更新する
721727
pub fn updateForDelete(self: *LineIndex, pos: usize, count: usize, deleted_newlines: usize) void {
722-
if (!self.valid) return; // 無効なら何もしない
728+
if (!self.valid or count == 0) return; // 無効なら何もしない
723729

724730
const end_pos = pos + count;
725731

726732
// 削除範囲内の行エントリを削除
727733
if (deleted_newlines > 0) {
728-
var write_idx: usize = 0;
729-
for (self.line_starts.items) |line_start| {
730-
if (line_start < pos or line_start >= end_pos) {
731-
// 範囲外: 保持(end_pos以降は位置調整が必要)
732-
if (line_start >= end_pos) {
733-
self.line_starts.items[write_idx] = line_start - count;
734-
} else {
735-
self.line_starts.items[write_idx] = line_start;
736-
}
737-
write_idx += 1;
734+
const remove_start = self.upperBound(pos);
735+
var write_idx = remove_start;
736+
var read_idx = remove_start;
737+
while (read_idx < self.line_starts.items.len) : (read_idx += 1) {
738+
const line_start = self.line_starts.items[read_idx];
739+
if (line_start <= end_pos) {
740+
continue; // 削除範囲内 (pos < line_start <= end_pos): エントリを落とす
738741
}
739-
// 範囲内(pos <= line_start < end_pos)の行は削除(スキップ)
742+
self.line_starts.items[write_idx] = line_start - count;
743+
write_idx += 1;
740744
}
741745
self.line_starts.shrinkRetainingCapacity(write_idx);
742746
} else {
743747
// 改行削除なし: 位置の調整のみ
744-
for (self.line_starts.items) |*line_start| {
745-
if (line_start.* >= end_pos) {
746-
line_start.* -= count;
747-
}
748+
const shift_start = self.upperBound(end_pos);
749+
var idx = shift_start;
750+
while (idx < self.line_starts.items.len) : (idx += 1) {
751+
self.line_starts.items[idx] -= count;
748752
}
749753
}
750754

@@ -2355,18 +2359,18 @@ pub const Buffer = struct {
23552359

23562360
var search_pos: usize = 0;
23572361
const limit = if (max_count == 0) std.math.maxInt(usize) else max_count;
2362+
var found_current = false; // フラグで条件分岐を最小化
23582363

23592364
while (result.total < limit) {
23602365
const match = self.searchForward(pattern, search_pos) orelse break;
23612366

23622367
result.total += 1;
23632368

23642369
// カーソル位置がマッチ範囲内または直後にある場合、このマッチが「現在」
2365-
// Emacs風: 前方検索はマッチ終端にカーソルがあるので、match.start + match.len == cursor_pos
2366-
if (result.current_index == null) {
2367-
if (cursor_pos >= match.start and cursor_pos <= match.start + match.len) {
2368-
result.current_index = result.total; // 1-based
2369-
}
2370+
// found_currentフラグで、見つかった後の不要なチェックをスキップ
2371+
if (!found_current and cursor_pos >= match.start and cursor_pos <= match.start + match.len) {
2372+
result.current_index = result.total; // 1-based
2373+
found_current = true;
23702374
}
23712375

23722376
// 次の検索位置(空マッチ防止のため最低1バイト進める)

src/editor.zig

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3968,10 +3968,17 @@ pub const Editor = struct {
39683968
defer self.allocator.free(old_text);
39693969

39703970
// 置換を実行(削除してから挿入)- 実際のマッチ長を使用
3971+
// insert失敗時はdelete前の状態に戻す
39713972
try buffer.delete(match_pos, match_len);
3973+
errdefer buffer.insertSlice(match_pos, old_text) catch {};
39723974
if (replacement.len > 0) {
39733975
try buffer.insertSlice(match_pos, replacement);
39743976
}
3977+
// recordReplace失敗時は置換自体を取り消す
3978+
errdefer {
3979+
buffer.delete(match_pos, replacement.len) catch {};
3980+
buffer.insertSlice(match_pos, old_text) catch {};
3981+
}
39753982

39763983
// 原子的な置換操作として記録(1回のundoで元に戻る)
39773984
try self.recordReplace(match_pos, old_text, replacement, cursor_pos_before);

src/view.zig

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1059,16 +1059,9 @@ pub const View = struct {
10591059
// 最初のマッチを処理
10601060
var match_start_vis = first_match_vis;
10611061
var match_end_vis = first_match_vis + search_str.len;
1062-
// 境界チェック: match_start_visおよびmatch_end_visがitems範囲内か確認
1062+
// 境界チェック: マッピング配列の範囲外ならスキップ(clamping せず早期リターン)
10631063
if (self.regex_visible_to_raw.items.len == 0) return line;
1064-
if (match_start_vis >= self.regex_visible_to_raw.items.len) {
1065-
match_start_vis = self.regex_visible_to_raw.items.len - 1;
1066-
}
1067-
if (match_end_vis >= self.regex_visible_to_raw.items.len) {
1068-
match_end_vis = self.regex_visible_to_raw.items.len - 1;
1069-
}
1070-
// match_start_vis > match_end_vis の場合はハイライトをスキップ
1071-
if (match_start_vis > match_end_vis) return line;
1064+
if (match_end_vis >= self.regex_visible_to_raw.items.len) return line;
10721065
var match_start_raw = self.regex_visible_to_raw.items[match_start_vis];
10731066
var match_end_raw = self.regex_visible_to_raw.items[match_end_vis];
10741067
// match_start_raw > match_end_raw の場合もスキップ
@@ -2505,13 +2498,14 @@ pub const View = struct {
25052498
var iter = PieceIterator.init(self.buffer);
25062499
iter.seek(line_start);
25072500

2501+
const tab_width = self.getTabWidth(); // ループ外で一度だけ取得
25082502
var line_width: usize = 0;
25092503
while (true) {
25102504
const cluster = iter.nextGraphemeCluster() catch break;
25112505
if (cluster) |gc| {
25122506
if (gc.base == '\n') break;
25132507
const char_width = if (gc.base == '\t')
2514-
nextTabStop(line_width, self.getTabWidth()) - line_width
2508+
nextTabStop(line_width, tab_width) - line_width
25152509
else if (unicode.isAsciiControl(gc.base))
25162510
2 // 制御文字は ^X 形式で表示幅2
25172511
else
@@ -2546,6 +2540,7 @@ pub const View = struct {
25462540
var iter = PieceIterator.init(self.buffer);
25472541
iter.seek(line_start);
25482542

2543+
const tab_width = self.getTabWidth(); // ループ外で一度だけ取得
25492544
var line_width: usize = 0;
25502545

25512546
while (true) {
@@ -2559,7 +2554,7 @@ pub const View = struct {
25592554
}
25602555

25612556
const char_width = if (gc.base == '\t')
2562-
nextTabStop(line_width, self.getTabWidth()) - line_width
2557+
nextTabStop(line_width, tab_width) - line_width
25632558
else if (unicode.isAsciiControl(gc.base))
25642559
2 // 制御文字は ^X 形式で表示幅2
25652560
else

tests/buffer_test.zig

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,45 @@ test "Enter key: Line counting" {
122122
try testing.expect(line_count >= 2);
123123
}
124124

125+
test "LineIndex incremental insert updates starts" {
126+
var buffer = try Buffer.init(testing.allocator);
127+
defer buffer.deinit();
128+
129+
try buffer.insertSlice(0, "line1\nline2\nline3\n");
130+
try buffer.line_index.rebuild(&buffer);
131+
try testing.expect(buffer.line_index.valid);
132+
try testing.expectEqualSlices(usize, &[_]usize{ 0, 6, 12, 18 }, buffer.line_index.line_starts.items);
133+
134+
try buffer.insertSlice(6, "foo\nbar\n");
135+
try testing.expectEqualSlices(usize, &[_]usize{ 0, 6, 10, 14, 20, 26 }, buffer.line_index.line_starts.items);
136+
}
137+
138+
test "LineIndex incremental delete removes newline entries" {
139+
var buffer = try Buffer.init(testing.allocator);
140+
defer buffer.deinit();
141+
142+
try buffer.insertSlice(0, "line1\nline2\nline3\n");
143+
try buffer.line_index.rebuild(&buffer);
144+
try testing.expectEqualSlices(usize, &[_]usize{ 0, 6, 12, 18 }, buffer.line_index.line_starts.items);
145+
146+
// 削除対象: "line2\n" (位置6から6バイト)
147+
try buffer.delete(6, 6);
148+
try testing.expectEqualSlices(usize, &[_]usize{ 0, 6, 12 }, buffer.line_index.line_starts.items);
149+
}
150+
151+
test "LineIndex delete without newline shifts positions" {
152+
var buffer = try Buffer.init(testing.allocator);
153+
defer buffer.deinit();
154+
155+
try buffer.insertSlice(0, "abcde\nxyz\n");
156+
try buffer.line_index.rebuild(&buffer);
157+
try testing.expectEqualSlices(usize, &[_]usize{ 0, 6, 10 }, buffer.line_index.line_starts.items);
158+
159+
// 改行を含まない削除: "bc" (位置1から2バイト)
160+
try buffer.delete(1, 2);
161+
try testing.expectEqualSlices(usize, &[_]usize{ 0, 4, 8 }, buffer.line_index.line_starts.items);
162+
}
163+
125164
// buffer.zigから移動したテスト
126165
test "empty buffer initialization" {
127166
var buffer = try Buffer.init(testing.allocator);

0 commit comments

Comments
 (0)