Minga.Buffer.Document (Minga v0.1.0)

Copy Markdown View Source

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.

t()

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

direction()

@type direction() :: :left | :right | :up | :down

A direction for cursor movement.

line_offsets()

@type line_offsets() :: tuple() | nil

Cached line offset tuple, or nil when stale.

position()

@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.

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

byte_col_for_grapheme(line_text, grapheme_index)

@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.

clear_line(buf, line_num)

@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.

content(document)

@spec content(t()) :: String.t()

Returns the full text content of the buffer.

content_and_cursor(document)

@spec content_and_cursor(t()) :: {String.t(), position()}

Returns the content and cursor position in a single call, avoiding separate content/1 + cursor/1 round-trips.

content_range(buf, from_pos, to_pos)

@spec content_range(t(), position(), position()) :: String.t()

Returns the text between two positions inclusive on both ends. If the positions are reversed, they are normalised automatically.

cursor(document)

@spec cursor(t()) :: position()

Returns the current cursor position as a {line, byte_col} tuple.

cursor_offset(document)

@spec cursor_offset(t()) :: non_neg_integer()

Returns the byte offset of the cursor in the full text.

delete_at(buf)

@spec delete_at(t()) :: t()

Deletes the character at the cursor (delete forward). Returns the buffer unchanged if the cursor is at the end.

delete_before(buf)

@spec delete_before(t()) :: t()

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"

delete_lines(buf, start_line, end_line)

@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).

delete_range(buf, from_pos, to_pos)

@spec delete_range(t(), position(), position()) :: t()

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.

empty?(document)

@spec empty?(t()) :: boolean()

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

get_lines_content(buf, start_line, end_line)

@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).

get_range(buf, start_pos, end_pos)

@spec get_range(t(), position(), position()) :: String.t()

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.

grapheme_col(buf, arg)

@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.

insert_char(buf, char)

@spec insert_char(t(), String.t()) :: t()

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"

insert_text(buf, text)

@spec insert_text(t(), String.t()) :: t()

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}

last_grapheme_byte_offset(text)

@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.

line_at(buf, line_num)

@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.

line_count(document)

@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

lines(buf, start, count)

@spec lines(t(), non_neg_integer(), non_neg_integer()) :: [String.t()]

Returns a range of lines (zero-indexed, inclusive start, exclusive end).

move(buf, atom)

@spec move(t(), direction()) :: t()

Moves the cursor one step in the given direction.

move_to(buf, arg)

@spec move_to(t(), position()) :: t()

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}

new(text \\ "")

@spec new(String.t()) :: 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}

offset_to_position(buf, offset)

@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.

position_to_offset(buf, arg)

@spec position_to_offset(t(), position()) :: non_neg_integer()

Returns the byte offset of a {line, byte_col} position in the buffer content.