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_char(buf, "H")
iex> Minga.Buffer.Document.content(buf)
"Hhello\nworld"
Summary
Types
A direction for cursor movement.
Cached line offset tuple, or nil when stale.
A zero-indexed {line, byte_col} position in the buffer.
A gap buffer instance.
Functions
Converts a grapheme column to a byte column for the given line.
Clears all content on the given line, leaving an empty line.
Returns {yanked_text, new_buffer} where yanked_text is the text
that was on the line. The cursor is placed at column 0 of the line.
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 text 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.
Returns the joined text of lines [start_line, end_line] inclusive, with newlines between them (no trailing newline).
Returns the text in the range [start_pos, end_pos] inclusive (characterwise).
Converts a {line, byte_col} position to a grapheme (display) column.
Inserts a character (or string) at the cursor position.
Inserts a multi-character string at the cursor position in a single
binary operation. Use this instead of decomposing into graphemes and
calling insert_char/2 in a loop; that pattern is O(n²) on the gap
buffer's binary.
Returns the byte offset of the first byte of the last grapheme in text.
Returns 0 for empty strings.
Returns the text of a specific line (zero-indexed), without the trailing newline.
Returns nil if the line number is out of range.
Returns the total number of lines in the buffer.
Returns a range of lines (zero-indexed, inclusive start, exclusive end).
Moves the cursor one step in the given direction.
Moves the cursor to an exact {line, byte_col} position.
Creates a new gap buffer from a string. Cursor starts at {0, 0}.
Converts a byte offset in the buffer content to a {line, byte_col} position.
Clamps to valid bounds.
Returns the byte offset of a {line, byte_col} position in the buffer content.
Types
@type direction() :: :left | :right | :up | :down
A direction for cursor movement.
@type line_offsets() :: tuple() | nil
Cached line offset tuple, or nil when stale.
@type position() :: {line :: non_neg_integer(), byte_col :: non_neg_integer()}
A zero-indexed {line, byte_col} position in the buffer.
byte_col is the byte offset within the line's UTF-8 binary.
For ASCII text, this equals the character/grapheme index.
@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_col_for_grapheme(String.t(), non_neg_integer()) :: non_neg_integer()
Converts a grapheme column to a byte column for the given line.
Walks graphemes until grapheme_index graphemes have been counted,
returning the byte offset at that point. Used by motions that need
to reason about character positions.
@spec clear_line(t(), non_neg_integer()) :: {String.t(), t()}
Clears all content on the given line, leaving an empty line.
Returns {yanked_text, new_buffer} where yanked_text is the text
that was on the line. The cursor is placed at column 0 of the line.
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 text 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
@spec get_lines_content(t(), non_neg_integer(), non_neg_integer()) :: String.t()
Returns the joined text of lines [start_line, end_line] inclusive, with newlines between them (no trailing newline).
Returns the text in the range [start_pos, end_pos] inclusive (characterwise).
Positions are clamped to valid buffer bounds. If start_pos is after end_pos, the positions are swapped automatically.
@spec grapheme_col(t(), position()) :: non_neg_integer()
Converts a {line, byte_col} position to a grapheme (display) column.
Counts graphemes in the line text from byte 0 to byte_col.
Used by the renderer to convert byte positions to screen columns.
Inserts a character (or string) at the cursor position.
Examples
iex> buf = Minga.Buffer.Document.new("world")
iex> buf = Minga.Buffer.Document.insert_char(buf, "hello ")
iex> Minga.Buffer.Document.content(buf)
"hello world"
Inserts a multi-character string at the cursor position in a single
binary operation. Use this instead of decomposing into graphemes and
calling insert_char/2 in a loop; that pattern is O(n²) on the gap
buffer's binary.
Functionally equivalent to insert_char/2 (which already accepts
arbitrary strings), but exists as a separate entry point so the intent
is clear and Buffer.Server can route bulk inserts here directly.
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 byte offset of the first byte of the last grapheme in text.
Returns 0 for empty strings.
@spec line_at(t(), non_neg_integer()) :: String.t() | nil
Returns the text of a specific line (zero-indexed), without the trailing newline.
Returns nil if the line number is out of range.
@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()]
Returns a range of lines (zero-indexed, inclusive start, exclusive end).
Moves the cursor one step in the given direction.
Moves the cursor to an exact {line, byte_col} position.
Line and column are clamped to valid buffer bounds.
Examples
iex> buf = Minga.Buffer.Document.new("hello\nworld")
iex> buf = Minga.Buffer.Document.move_to(buf, {1, 3})
iex> Minga.Buffer.Document.cursor(buf)
{1, 3}
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 offset_to_position(t(), non_neg_integer()) :: position()
Converts a byte offset in the buffer content to a {line, byte_col} position.
Clamps to valid bounds.
@spec position_to_offset(t(), position()) :: non_neg_integer()
Returns the byte offset of a {line, byte_col} position in the buffer content.