A gap buffer implementation for text editing.
The gap buffer stores text as two binaries: before contains all text
before the cursor (in natural order), and after contains all text
after the cursor (in natural order). The "gap" is the conceptual space
between them where insertions happen in O(1).
Moving the cursor shifts characters between the two binaries. This gives O(1) insertions and deletions at the cursor, and O(n) cursor movement (where n is the distance moved). For an interactive editor where the cursor moves incrementally, this is ideal.
Byte-indexed positions
All positions are zero-indexed {line, byte_col} tuples, where byte_col
is the byte offset within the line. For ASCII content (the common case),
byte offset equals grapheme index. For multi-byte UTF-8 characters,
byte offset is larger (e.g., é = 2 bytes, emoji = 4+ bytes).
This representation enables O(1) binary_part slicing throughout the
editor and aligns with tree-sitter's byte-offset model.
Use grapheme_col/2 to convert a byte-indexed position to a display
column for rendering.
Examples
iex> buf = Minga.Buffer.Document.new("hello\nworld")
iex> Minga.Buffer.Document.cursor(buf)
{0, 0}
iex> buf = Minga.Buffer.Document.insert_text(buf, "H")
iex> Minga.Buffer.Document.content(buf)
"Hhello\nworld"
Summary
Functions
Returns one byte from the document without materializing full content.
Returns the full text content of the buffer.
Returns the content and cursor position in a single call, avoiding separate content/1 + cursor/1 round-trips.
Returns the content between two positions inclusive on both ends. If the positions are reversed, they are normalised automatically.
Returns the total byte size of the document without materializing full content.
Returns the grapheme count between two positions inclusive on both ends. If the positions are reversed, they are normalised automatically.
Returns the current cursor position as a {line, byte_col} tuple.
Returns the byte offset of the cursor in the full text.
Deletes the character at the cursor (delete forward). Returns the buffer unchanged if the cursor is at the end.
Deletes the character before the cursor (backspace). Returns the buffer unchanged if the cursor is at the beginning.
Deletes lines [start_line, end_line] inclusive from the buffer.
Deletes the text between two positions inclusive on both ends. If the positions are reversed, they are normalised automatically. The cursor is placed at the earlier position.
Returns true if the buffer contains no text.
Inserts a multi-character string at the cursor position in a single binary operation.
Returns the position of the last selectable character on a line.
Returns the total number of lines in the buffer.
Creates a new gap buffer from a string. Cursor starts at {0, 0}.
Returns a byte range from the document without materializing full content.
Types
@type direction() :: :left | :right | :up | :down
A direction for cursor movement.
@type line_offsets() :: tuple() | nil
Cached line-start byte offsets.
Clean: a tuple of line-start positions {0, 6, 12, ...}.
Pending: {:pending, starts_tuple, adjust_after_line, delta} for
deferred shift. nil when the cache needs a full rebuild.
@type position() :: Minga.Buffer.Position.t()
@type t() :: %Minga.Buffer.Document{ after: String.t(), before: String.t(), cursor_col: non_neg_integer(), cursor_line: non_neg_integer(), line_count: pos_integer(), line_offsets: line_offsets() }
A gap buffer instance.
Functions
@spec byte_at(t(), non_neg_integer()) :: byte()
Returns one byte from the document without materializing full content.
Returns the full text content of the buffer.
Returns the content and cursor position in a single call, avoiding separate content/1 + cursor/1 round-trips.
Returns the content between two positions inclusive on both ends. If the positions are reversed, they are normalised automatically.
@spec content_byte_size(t()) :: non_neg_integer()
Returns the total byte size of the document without materializing full content.
@spec content_on_lines(t(), non_neg_integer(), non_neg_integer()) :: String.t()
@spec content_range_length(t(), position(), position()) :: non_neg_integer()
Returns the grapheme count between two positions inclusive on both ends. If the positions are reversed, they are normalised automatically.
Returns the current cursor position as a {line, byte_col} tuple.
@spec cursor_offset(t()) :: non_neg_integer()
Returns the byte offset of the cursor in the full text.
Deletes the character at the cursor (delete forward). Returns the buffer unchanged if the cursor is at the end.
Deletes the character before the cursor (backspace). Returns the buffer unchanged if the cursor is at the beginning.
Examples
iex> buf = Minga.Buffer.Document.new("hello")
iex> buf = Minga.Buffer.Document.move_to(buf, {0, 5})
iex> buf = Minga.Buffer.Document.delete_before(buf)
iex> Minga.Buffer.Document.content(buf)
"hell"
@spec delete_lines(t(), non_neg_integer(), non_neg_integer()) :: t()
Deletes lines [start_line, end_line] inclusive from the buffer.
The cursor is placed at the beginning of the line that now occupies the start position (or the last remaining line if fewer lines remain).
Deletes the text between two positions inclusive on both ends. If the positions are reversed, they are normalised automatically. The cursor is placed at the earlier position.
Returns true if the buffer contains no text.
Examples
iex> Minga.Buffer.Document.empty?(Minga.Buffer.Document.new(""))
true
iex> Minga.Buffer.Document.empty?(Minga.Buffer.Document.new("hi"))
false
Inserts a multi-character string at the cursor position in a single binary operation.
Examples
iex> buf = Minga.Buffer.Document.new("world")
iex> buf = Minga.Buffer.Document.insert_text(buf, "hello ")
iex> Minga.Buffer.Document.content(buf)
"hello world"
iex> buf = Minga.Buffer.Document.new("end")
iex> buf = Minga.Buffer.Document.insert_text(buf, "line1\nline2\n")
iex> Minga.Buffer.Document.content(buf)
"line1\nline2\nend"
iex> Minga.Buffer.Document.cursor(buf)
{2, 0}
@spec last_grapheme_byte_offset(String.t()) :: non_neg_integer()
Returns the position of the last selectable character on a line.
@spec line_at(t(), non_neg_integer()) :: String.t() | nil
@spec line_count(t()) :: pos_integer()
Returns the total number of lines in the buffer.
An empty buffer counts as one line.
Examples
iex> buf = Minga.Buffer.Document.new("one\ntwo\nthree")
iex> Minga.Buffer.Document.line_count(buf)
3
iex> Minga.Buffer.Document.line_count(Minga.Buffer.Document.new(""))
1
@spec lines(t(), non_neg_integer(), non_neg_integer()) :: [String.t()]
Creates a new gap buffer from a string. Cursor starts at {0, 0}.
Examples
iex> buf = Minga.Buffer.Document.new("hello")
iex> Minga.Buffer.Document.content(buf)
"hello"
iex> Minga.Buffer.Document.cursor(buf)
{0, 0}
@spec slice_byte_range(t(), non_neg_integer(), non_neg_integer()) :: binary()
Returns a byte range from the document without materializing full content.