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
42 changes: 41 additions & 1 deletion packages/core/src/edit-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, beforeEach, afterEach } from "bun:test"
import { describe, expect, it, beforeEach, afterEach, spyOn } from "bun:test"
import { EditBuffer } from "./edit-buffer.js"
import { resolveRenderLib } from "./zig.js"
import { ManualClock } from "./testing/manual-clock.js"
Expand Down Expand Up @@ -48,6 +48,31 @@ describe("EditBuffer", () => {
expect(buffer.getText()).toBe(text)
})

it("should retrieve large text without truncating or overallocating ranges", () => {
const text = "x".repeat(1024 * 1024 + 10)
buffer.setText(text)

const sliceSpy = spyOn(Uint8Array.prototype, "slice")

expect(buffer.getText()).toBe(text)
expect(buffer.getTextRange(0, text.length)).toBe(text)
expect(buffer.getTextRangeByCoords(0, 0, 0, text.length)).toBe(text)

const lib = (buffer as any).lib
const rangeSpy = spyOn(lib, "editBufferGetTextRange")
const coordsSpy = spyOn(lib, "editBufferGetTextRangeByCoords")

expect(buffer.getTextRange(0, 1)).toBe("x")
expect(buffer.getTextRangeByCoords(0, 0, 0, 1)).toBe("x")
expect(rangeSpy).toHaveBeenCalledWith(buffer.ptr, 0, 1, 1)
expect(coordsSpy).toHaveBeenCalledWith(buffer.ptr, 0, 0, 0, 1, 1)
expect(sliceSpy).not.toHaveBeenCalled()

rangeSpy.mockRestore()
coordsSpy.mockRestore()
sliceSpy.mockRestore()
})

it("should return null bytes for zero-length getText output buffer", () => {
buffer.setText("Hello World")

Expand Down Expand Up @@ -388,6 +413,21 @@ describe("EditBuffer", () => {
})

describe("range getters", () => {
it("should use wcwidth for ranges beyond 65535 columns", () => {
const prefix = "x".repeat(65_536)
buffer.setText(`${prefix}👋🏻👋🏼`)

expect(buffer.getTextRange(65_536, 65_540)).toBe("👋🏻")
expect(buffer.getTextRangeByCoords(0, 65_536, 0, 65_540)).toBe("👋🏻")
})

it("should snap a range start inside the final grapheme", () => {
buffer.setText("❤️好")

expect(buffer.getTextRange(2, 3)).toBe("好")
expect(buffer.getTextRangeByCoords(0, 2, 0, 3)).toBe("好")
})

it("should return null bytes for zero-length range output buffers", () => {
buffer.setText("Hello\nWorld")

Expand Down
26 changes: 15 additions & 11 deletions packages/core/src/edit-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,7 @@ export class EditBuffer extends EventEmitter {

public getText(): string {
this.guard()
// TODO: Use byte size of text buffer to get the actual size of the text
// actually native can stack alloc all the text and decode will alloc as js string then
const maxSize = 1024 * 1024 // 1MB max
const maxSize = this.lib.textBufferGetByteSize(this.textBufferPtr)
const textBytes = this.lib.editBufferGetText(this.bufferPtr, maxSize)

if (!textBytes) return ""
Expand Down Expand Up @@ -274,12 +272,10 @@ export class EditBuffer extends EventEmitter {
this.guard()
if (startOffset >= endOffset) return ""

// TODO: Use actual expected size of the text
// like other methods native can just return a pointer and size
// and we immediately decode the text into a js string then the native stack
// can go out of scope
const maxSize = 1024 * 1024 // 1MB max
const textBytes = this.lib.editBufferGetTextRange(this.bufferPtr, startOffset, endOffset, maxSize)
const rangeSize = this.lib.textBufferGetTextRangeByteSize(this.textBufferPtr, startOffset, endOffset)
if (rangeSize === 0) return ""

const textBytes = this.lib.editBufferGetTextRange(this.bufferPtr, startOffset, endOffset, rangeSize)

if (!textBytes) return ""

Expand All @@ -289,14 +285,22 @@ export class EditBuffer extends EventEmitter {
public getTextRangeByCoords(startRow: number, startCol: number, endRow: number, endCol: number): string {
this.guard()

const maxSize = 1024 * 1024 // 1MB max
const rangeSize = this.lib.textBufferGetTextRangeByteSizeByCoords(
this.textBufferPtr,
startRow,
startCol,
endRow,
endCol,
)
if (rangeSize === 0) return ""

const textBytes = this.lib.editBufferGetTextRangeByCoords(
this.bufferPtr,
startRow,
startCol,
endRow,
endCol,
maxSize,
rangeSize,
)

if (!textBytes) return ""
Expand Down
35 changes: 33 additions & 2 deletions packages/core/src/zig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,14 @@ function getOpenTUILib(libPath?: string) {
args: ["u32"],
returns: "u32",
},
textBufferGetTextRangeByteSize: {
args: ["u32", "u32", "u32"],
returns: "u32",
},
textBufferGetTextRangeByteSizeByCoords: {
args: ["u32", "u32", "u32", "u32", "u32"],
returns: "u32",
},
textBufferGetTextRange: {
args: ["u32", "u32", "u32", "ptr", "u32"],
returns: "u32",
Expand Down Expand Up @@ -2306,6 +2314,14 @@ export interface RenderLib extends AudioEngineLib {
textBufferSetTabWidth: (buffer: TextBufferHandle, width: number) => void
textBufferGetLineCount: (buffer: TextBufferHandle) => number
getPlainTextBytes: (buffer: TextBufferHandle, maxLength: number) => Uint8Array | null
textBufferGetTextRangeByteSize: (buffer: TextBufferHandle, startOffset: number, endOffset: number) => number
textBufferGetTextRangeByteSizeByCoords: (
buffer: TextBufferHandle,
startRow: number,
startCol: number,
endRow: number,
endCol: number,
) => number
textBufferGetTextRange: (
buffer: TextBufferHandle,
startOffset: number,
Expand Down Expand Up @@ -3974,6 +3990,20 @@ class FFIRenderLib implements RenderLib {
return outBuffer.slice(0, actualLen)
}

public textBufferGetTextRangeByteSize(buffer: Pointer, startOffset: number, endOffset: number): number {
return this.opentui.symbols.textBufferGetTextRangeByteSize(buffer, startOffset, endOffset)
}

public textBufferGetTextRangeByteSizeByCoords(
buffer: Pointer,
startRow: number,
startCol: number,
endRow: number,
endCol: number,
): number {
return this.opentui.symbols.textBufferGetTextRangeByteSizeByCoords(buffer, startRow, startCol, endRow, endCol)
}

public textBufferGetTextRange(
buffer: Pointer,
startOffset: number,
Expand Down Expand Up @@ -4468,7 +4498,7 @@ class FFIRenderLib implements RenderLib {
const actualLen = this.opentui.symbols.editBufferGetText(buffer, ptrOrNull(outBuffer), maxLength)
const len = actualLen
if (len === 0) return null
return outBuffer.slice(0, len)
return len === outBuffer.length ? outBuffer : outBuffer.slice(0, len)
}

public editBufferInsertChar(buffer: Pointer, char: string): void {
Expand Down Expand Up @@ -4647,7 +4677,7 @@ class FFIRenderLib implements RenderLib {
)
const len = actualLen
if (len === 0) return null
return outBuffer.slice(0, len)
return len === outBuffer.length ? outBuffer : outBuffer.slice(0, len)
}

public editBufferGetTextRangeByCoords(
Expand All @@ -4670,6 +4700,7 @@ class FFIRenderLib implements RenderLib {
)
const len = actualLen
if (len === 0) return null
if (len === outBuffer.length) return outBuffer
return usesBunFFI ? outBuffer.slice(0, len) : trimNodeFFIOutputBytes(outBuffer, len)
}

Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/zig/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2643,6 +2643,16 @@ export fn textBufferGetHighlightCount(tb_handle: NativeHandle) u32 {
return object_ptr.getHighlightCount();
}

export fn textBufferGetTextRangeByteSize(tb_handle: NativeHandle, start_offset: u32, end_offset: u32) u32 {
const object_ptr = acquireTextBuffer(tb_handle) orelse return 0;
return @intCast(object_ptr.getTextRangeByteSize(start_offset, end_offset));
}

export fn textBufferGetTextRangeByteSizeByCoords(tb_handle: NativeHandle, start_row: u32, start_col: u32, end_row: u32, end_col: u32) u32 {
const object_ptr = acquireTextBuffer(tb_handle) orelse return 0;
return @intCast(object_ptr.getTextRangeByteSizeByCoords(start_row, start_col, end_row, end_col));
}

export fn textBufferGetTextRange(tb_handle: NativeHandle, start_offset: u32, end_offset: u32, outPtr: ?[*]u8, maxLen: u32) u32 {
const object_ptr = acquireTextBuffer(tb_handle) orelse return 0;
if (maxLen == 0) return 0;
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/zig/rope.zig
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@ pub fn Rope(comptime T: type) type {
total_metrics: Metrics,

fn is_balanced(self: *const Branch) bool {
const left_weight = self.left.metrics().weight();
const right_weight = self.right.metrics().weight();
const left_weight: u64 = self.left.metrics().weight();
const right_weight: u64 = self.right.metrics().weight();
const total_weight = left_weight + right_weight;

if (total_weight == 0) return true;
Expand Down Expand Up @@ -361,8 +361,8 @@ pub fn Rope(comptime T: type) type {
if (left_count == 0) return right;
if (right_count == 0) return left;

const left_weight = left.metrics().weight();
const right_weight = right.metrics().weight();
const left_weight: u64 = left.metrics().weight();
const right_weight: u64 = right.metrics().weight();
const total_weight = left_weight + right_weight;

if (total_weight > 0) {
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/zig/tests/edit-buffer_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,30 @@ test "EditBuffer - getTextRange full text" {
try std.testing.expectEqualStrings("Hello World", buffer[0..len]);
}

test "EditBuffer - getTextRange beyond 65535 columns" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();
const link_pool = link.initGlobalLinkPool(std.testing.allocator);
defer link.deinitGlobalLinkPool();

var eb = try EditBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth, null);
defer eb.deinit();

const prefix_len = 65_536;
const suffix = "👋🏻👋🏼";
const text = try std.testing.allocator.alloc(u8, prefix_len + suffix.len);
defer std.testing.allocator.free(text);
@memset(text[0..prefix_len], 'x');
@memcpy(text[prefix_len..], suffix);
try eb.setText(text);

try std.testing.expectEqual(@as(u32, 65_544), eb.getTextBuffer().getLength());

var buffer: [8]u8 = undefined;
const len = try eb.getTextRange(65_536, 65_540, &buffer);
try std.testing.expectEqualStrings("👋🏻", buffer[0..len]);
}

test "EditBuffer - getTextRange with emojis" {
const pool = gp.initGlobalPool(std.testing.allocator);
defer gp.deinitGlobalPool();
Expand Down Expand Up @@ -839,6 +863,8 @@ test "EditBuffer - getTextRange emoji with skin tone" {
// "Hi " = 3 cols, emoji = 2 cols
const len = try eb.getTextRange(3, 5, &buffer);
try std.testing.expectEqualStrings("👋🏽", buffer[0..len]);
try std.testing.expectEqual(len, eb.getTextBuffer().getTextRangeByteSize(3, 5));
try std.testing.expectEqual(len, eb.getTextBuffer().getTextRangeByteSizeByCoords(0, 3, 0, 5));
}

test "EditBuffer - getTextRange flag emoji" {
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/zig/tests/rope_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,17 @@ const WeightedItem = struct {
// Leaf split function for testing (callback format)
const WeightedRope = rope_mod.Rope(WeightedItem);

test "Rope - balancing handles large representable weights" {
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena.deinit();

const large_weight = std.math.maxInt(u32) / 3 + 1;
var rope = try WeightedRope.from_item(arena.allocator(), .{ .value = 1, .weight = large_weight });
try rope.append(.{ .value = 2, .weight = 1 });

try std.testing.expectEqual(large_weight + 1, rope.totalWeight());
}

fn splitWeightedItemCallback(
allocator: std.mem.Allocator,
ctx: ?*anyopaque,
Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/zig/tests/text-buffer-iterators_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,41 @@ const LineInfo = iter_mod.LineInfo;
const TextChunk = seg_mod.TextChunk;
const TextBuffer = text_buffer.UnifiedTextBuffer;

test "walkLinesAndSegments stops at the end offset" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();

var rope = try UnifiedRope.init(arena.allocator());
try rope.append(.{ .linestart = {} });
try rope.append(.{ .text = .{ .mem_id = 0, .byte_start = 0, .byte_end = 1, .width = 1 } });
try rope.append(.{ .brk = {} });

const Context = struct {
segment_count: u32 = 0,
line_count: u32 = 0,

fn segmentCallback(ctx_ptr: *anyopaque, _: u32, _: *const TextChunk, _: u32) void {
const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr)));
ctx.segment_count += 1;
}

fn lineEndCallback(ctx_ptr: *anyopaque, _: LineInfo) void {
const ctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr)));
ctx.line_count += 1;
}
};

var empty_ctx: Context = .{};
iter_mod.walkLinesAndSegments(&rope, 0, &empty_ctx, Context.segmentCallback, Context.lineEndCallback);
try testing.expectEqual(@as(u32, 0), empty_ctx.segment_count);
try testing.expectEqual(@as(u32, 0), empty_ctx.line_count);

var ctx: Context = .{};
iter_mod.walkLinesAndSegments(&rope, 1, &ctx, Context.segmentCallback, Context.lineEndCallback);
try testing.expectEqual(@as(u32, 1), ctx.segment_count);
try testing.expectEqual(@as(u32, 0), ctx.line_count);
}

test "walkLines - empty rope" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
Expand Down
Loading
Loading