GenServer wrapping a Document with file I/O and dirty tracking.
Each open file gets its own Buffer.Server process, managed by
the Buffer.Supervisor (DynamicSupervisor). If a buffer process
crashes, only that buffer is lost — all other buffers and the
editor continue running.
Examples
{:ok, pid} = Minga.Buffer.Server.start_link(file_path: "README.md")
:ok = Minga.Buffer.Server.insert_char(pid, "x")
true = Minga.Buffer.Server.dirty?(pid)
:ok = Minga.Buffer.Server.save(pid)
false = Minga.Buffer.Server.dirty?(pid)
Summary
Types
An edit boundary as {start_line, end_line} (both inclusive, 0-indexed), or nil for unbounded.
A find-and-replace edit pair for batch operations.
Result of a single edit within a batch.
Options for starting a buffer server.
Internal state of the buffer server.
A single text edit: {start_pos, end_pos, replacement_text}.
Functions
Adds a block decoration to the buffer.
Adds a highlight range decoration to the buffer.
Adds a virtual text decoration to the buffer.
Appends text to the end of the buffer, bypassing read-only. For programmatic writes.
Applies a BufferSnapshot back to the server, updating the document
and returning the updated scroll state.
Replaces the internal gap buffer with a new one, pushing the old buffer onto the undo stack and marking the buffer dirty.
Replaces a range of text with new text.
Applies multiple text edits in a single GenServer call.
Executes a batch of decoration operations. The function receives and
returns a Decorations struct. All operations are applied with a
single tree rebuild.
Resets the undo coalescing timer so the next mutation starts a fresh undo entry. Call this at undo boundaries like mode transitions (e.g., leaving insert mode).
Returns the buffer name (e.g. *Messages*), or nil for file buffers.
Returns the buffer's type (:file, :nofile, :nowrite, :prompt, :terminal).
Returns the byte offset for the start of a given line.
Returns a child spec with restart: :temporary.
Clears a buffer-local face override, restoring the theme default.
Clears all content on the given line. Returns {:ok, yanked_text}.
Returns the full text content of the buffer.
Returns the content and cursor position in a single GenServer call.
Returns the text between two positions (from_pos inclusive, to_pos exclusive).
Returns the current cursor position.
Returns the decorations struct for read-only access (e.g., by the render pipeline).
Returns the decorations version for cheap change detection.
Deletes the character at the cursor (delete forward).
Deletes the character before the cursor (backspace).
Deletes lines [start_line, end_line] inclusive. Cursor lands at the first remaining line.
Deletes the text between two positions (from_pos inclusive, to_pos exclusive), placing the cursor at the start of the range.
Returns whether the buffer has unsaved changes.
Returns the display name for use in the status bar and modeline.
Returns the buffer-local face overrides map.
Returns the file path associated with this buffer, if any.
Returns the detected filetype atom for this buffer.
Atomically finds and replaces text in the buffer.
Atomically applies multiple find-and-replace edits in a single handle_call.
Returns and clears pending edit deltas accumulated since the last flush.
Returns edit deltas accumulated since the given consumer's last read.
Force-saves the buffer, skipping mtime conflict detection.
Returns a range of lines from the buffer.
Returns the joined text of lines [start_line, end_line] inclusive (no trailing newline).
Returns a buffer-local option value using the resolution chain: buffer-local → filetype override → global default.
Returns the text in the range [start_pos, end_pos] inclusive. Positions are sorted automatically.
Inserts a character at the current cursor position.
Inserts a string at the current cursor position.
Returns the total line count.
Returns all buffer-local option overrides (not the resolved values, just the overrides set on this buffer).
Moves the cursor in the given direction.
Moves the cursor in the given direction.
Moves the cursor left or right if the move is valid, performing the boundary
check inside the buffer process. Returns {:ok, new_position} if the cursor
moved, or :at_boundary if the cursor was already at the boundary.
Moves the cursor to an exact position.
Takes a snapshot wrapped in a BufferSnapshot struct for use with the
NavigableContent protocol. Includes the given scroll state so that
scroll operations can be composed with cursor and content changes.
Opens a file, replacing the current buffer content.
Returns whether the buffer is persistent (auto-recreated on kill).
Looks up the buffer pid for a file path via Minga.Buffer.Registry.
Returns whether the buffer is read-only.
Redoes the last undone mutation.
Reloads the buffer from disk, preserving cursor position (clamped). Clears undo/redo.
Sets a buffer-local face override.
Removes a block decoration by ID.
Removes a highlight range by ID.
Removes all highlight ranges in a group.
Removes a virtual text decoration by ID.
Returns all data needed to render a single frame in one GenServer call.
Replaces the entire buffer content, pushing the old content onto the undo stack.
Replaces buffer content bypassing read-only. For programmatic panel updates.
Atomically replaces buffer content and rebuilds decorations in a single GenServer call.
Saves the buffer content to the associated file.
Saves the buffer content to a specific file path.
Sets the cursor to an absolute position. Clamped to buffer bounds.
Changes the buffer's filetype and re-seeds per-filetype options.
Sets a buffer-local option override. Only affects this buffer.
Returns the underlying Document.t() struct for pure computation.
Starts a buffer server. Pass file_path: to open a file, or content: for an unnamed buffer.
Undoes the last mutation, restoring the previous buffer state.
Returns whether the buffer is unlisted (hidden from buffer picker).
Returns the buffer's mutation version counter (increments on every content change).
Types
@type boundary() :: {non_neg_integer(), non_neg_integer()} | nil
An edit boundary as {start_line, end_line} (both inclusive, 0-indexed), or nil for unbounded.
A find-and-replace edit pair for batch operations.
Result of a single edit within a batch.
@type start_opt() :: {:file_path, String.t()} | {:content, String.t()} | {:name, GenServer.name()} | {:buffer_name, String.t()} | {:buffer_type, Minga.Buffer.State.buffer_type()} | {:filetype, atom()} | {:read_only, boolean()} | {:unlisted, boolean()} | {:persistent, boolean()}
Options for starting a buffer server.
@type state() :: Minga.Buffer.State.t()
Internal state of the buffer server.
@type text_edit() :: {{non_neg_integer(), non_neg_integer()}, {non_neg_integer(), non_neg_integer()}, String.t()}
A single text edit: {start_pos, end_pos, replacement_text}.
Functions
@spec add_block_decoration(GenServer.server(), non_neg_integer(), keyword()) :: reference()
Adds a block decoration to the buffer.
Returns the decoration ID for later removal.
See Minga.Core.Decorations.add_block_decoration/3 for options.
@spec add_highlight( GenServer.server(), Minga.Core.Decorations.highlight_range_pos(), Minga.Core.Decorations.highlight_range_pos(), keyword() ) :: reference()
Adds a highlight range decoration to the buffer.
Returns the decoration ID (a reference) for later removal.
See Minga.Core.Decorations.add_highlight/4 for options.
@spec add_virtual_text( GenServer.server(), Minga.Core.Decorations.highlight_range_pos(), keyword() ) :: reference()
Adds a virtual text decoration to the buffer.
Returns the decoration ID (a reference) for later removal.
See Minga.Core.Decorations.add_virtual_text/3 for options.
@spec append(GenServer.server(), String.t()) :: :ok
Appends text to the end of the buffer, bypassing read-only. For programmatic writes.
@spec apply_snapshot(GenServer.server(), Minga.Buffer.Document.t()) :: :ok
Replaces the internal gap buffer with a new one, pushing the old buffer onto the undo stack and marking the buffer dirty.
Use this after performing a batch of pure Document operations on a
snapshot. Only call this when content has actually changed; for cursor-only
changes use move_to/2 instead.
@spec apply_text_edit( GenServer.server(), non_neg_integer(), non_neg_integer(), non_neg_integer(), non_neg_integer(), String.t(), Minga.Buffer.EditSource.t() ) :: :ok
Replaces a range of text with new text.
Moves to the start of the range, deletes the range, then inserts the new text. Used by LSP text edits.
@spec apply_text_edits(GenServer.server(), [text_edit()], Minga.Buffer.EditSource.t()) :: :ok
Applies multiple text edits in a single GenServer call.
All edits are applied to the Document sequentially, but only one undo entry is pushed and the version bumps once. Edits should be sorted in reverse document order (last position first) so earlier offsets remain valid as later text is replaced. If edits are not pre-sorted, this function sorts them automatically.
Used by AI/LSP batch operations to avoid N round-trips and N undo entries.
Source defaults to {:lsp, :unknown} (not :user) because batch edits
are typically LSP code actions or agent tool calls, not interactive typing.
@spec batch_decorations(GenServer.server(), (Minga.Core.Decorations.t() -> Minga.Core.Decorations.t())) :: :ok
Executes a batch of decoration operations. The function receives and
returns a Decorations struct. All operations are applied with a
single tree rebuild.
@spec break_undo_coalescing(GenServer.server()) :: :ok
Resets the undo coalescing timer so the next mutation starts a fresh undo entry. Call this at undo boundaries like mode transitions (e.g., leaving insert mode).
@spec buffer_name(GenServer.server()) :: String.t() | nil
Returns the buffer name (e.g. *Messages*), or nil for file buffers.
@spec buffer_type(GenServer.server()) :: Minga.Buffer.State.buffer_type()
Returns the buffer's type (:file, :nofile, :nowrite, :prompt, :terminal).
@spec byte_offset_for_line(GenServer.server(), non_neg_integer()) :: non_neg_integer()
Returns the byte offset for the start of a given line.
@spec child_spec([start_opt()]) :: Supervisor.child_spec()
Returns a child spec with restart: :temporary.
Buffers run under a DynamicSupervisor. If a buffer crashes, it should
stay dead rather than restarting without its original init args (file
path, content). The Editor detects the dead buffer via :DOWN monitor
and shows a clear indicator to the user.
@spec clear_face_override(GenServer.server(), String.t()) :: :ok
Clears a buffer-local face override, restoring the theme default.
@spec clear_line(GenServer.server(), non_neg_integer()) :: {:ok, String.t()}
Clears all content on the given line. Returns {:ok, yanked_text}.
@spec content(GenServer.server()) :: String.t()
Returns the full text content of the buffer.
@spec content_and_cursor(GenServer.server()) :: {String.t(), Minga.Buffer.Document.position()}
Returns the content and cursor position in a single GenServer call.
@spec content_range( GenServer.server(), Minga.Buffer.Document.position(), Minga.Buffer.Document.position() ) :: String.t()
Returns the text between two positions (from_pos inclusive, to_pos exclusive).
@spec cursor(GenServer.server()) :: Minga.Buffer.Document.position()
Returns the current cursor position.
@spec decorations(GenServer.server()) :: Minga.Core.Decorations.t()
Returns the decorations struct for read-only access (e.g., by the render pipeline).
@spec decorations_version(GenServer.server()) :: non_neg_integer()
Returns the decorations version for cheap change detection.
@spec delete_at(GenServer.server()) :: :ok
Deletes the character at the cursor (delete forward).
@spec delete_before(GenServer.server()) :: :ok
Deletes the character before the cursor (backspace).
@spec delete_lines(GenServer.server(), non_neg_integer(), non_neg_integer()) :: :ok
Deletes lines [start_line, end_line] inclusive. Cursor lands at the first remaining line.
@spec delete_range( GenServer.server(), Minga.Buffer.Document.position(), Minga.Buffer.Document.position() ) :: :ok
Deletes the text between two positions (from_pos inclusive, to_pos exclusive), placing the cursor at the start of the range.
@spec dirty?(GenServer.server()) :: boolean()
Returns whether the buffer has unsaved changes.
@spec display_name(GenServer.server()) :: String.t()
Returns the display name for use in the status bar and modeline.
For named buffers (e.g. *Messages*), returns the name directly with a
[RO] suffix when read-only. For file buffers, returns Path.basename(file_path)
with a [RO] suffix when read-only, or "[no file]" when no path is set.
Single GenServer round-trip; prefer over combining buffer_name/1,
file_path/1, and read_only?/1 separately.
@spec face_overrides(GenServer.server()) :: %{required(String.t()) => keyword()}
Returns the buffer-local face overrides map.
Face overrides are %{face_name => [attr: value, ...]} pairs that
are merged on top of the theme's face registry when rendering this
buffer. Used for filetype-specific styling (e.g., Markdown uses a
different default font) and buffer-local customization.
@spec file_path(GenServer.server()) :: String.t() | nil
Returns the file path associated with this buffer, if any.
@spec filetype(GenServer.server()) :: atom()
Returns the detected filetype atom for this buffer.
@spec find_and_replace(GenServer.server(), String.t(), String.t(), boundary()) :: {:ok, String.t()} | {:error, String.t()}
Atomically finds and replaces text in the buffer.
The search, ambiguity check, and replacement all happen inside a single
handle_call, so there is no TOCTOU race between reading content and
applying the edit. Returns {:ok, message} on success, or {:error, reason}
if the text is not found, is ambiguous (multiple matches), or the buffer
is read-only.
@spec find_and_replace_batch(GenServer.server(), [replace_edit()], boundary()) :: {:ok, [replace_result()]} | {:error, String.t()}
Atomically applies multiple find-and-replace edits in a single handle_call.
Edits are applied sequentially: earlier edits affect the content that later edits search against. Failed edits (not found, ambiguous) are reported but don't block subsequent edits. A single undo entry is pushed for the entire batch.
When boundary is provided, each edit is checked against the boundary and
rejected if the match falls outside the allowed line range.
Returns {:ok, results} where results is a list of per-edit outcomes.
@spec flush_edits(GenServer.server()) :: [Minga.Buffer.EditDelta.t()]
Returns and clears pending edit deltas accumulated since the last flush.
Deprecated: use flush_edits/2 with a consumer_id for per-consumer cursors.
This legacy version destructively drains the shared pending_edits list.
@spec flush_edits(GenServer.server(), atom()) :: [Minga.Buffer.EditDelta.t()]
Returns edit deltas accumulated since the given consumer's last read.
Each consumer is identified by an atom (e.g., :lsp, :highlight).
The buffer tracks a per-consumer cursor (sequence number). On each call,
deltas since that cursor are returned and the cursor advances. The buffer
trims deltas that all registered consumers have read.
This avoids the data race where two consumers calling flush_edits/1
would each miss the other's deltas.
Deprecated: prefer consuming deltas from BufferChangedEvent payloads
on the event bus. LSP SyncServer already accumulates deltas from events.
HighlightSync still uses this during the migration period since it runs
synchronously inside the Editor GenServer before the deferred broadcast fires.
@spec force_save(GenServer.server()) :: :ok | {:error, term()}
Force-saves the buffer, skipping mtime conflict detection.
@spec get_lines(GenServer.server(), non_neg_integer(), non_neg_integer()) :: [ String.t() ]
Returns a range of lines from the buffer.
@spec get_lines_content(GenServer.server(), non_neg_integer(), non_neg_integer()) :: String.t()
Returns the joined text of lines [start_line, end_line] inclusive (no trailing newline).
@spec get_option(GenServer.server(), atom()) :: term()
Returns a buffer-local option value using the resolution chain: buffer-local → filetype override → global default.
Buffer-local options take highest priority. If no local override
exists, the filetype default from Config.Options is checked, then
the global default. This gives each buffer its own isolated option
state while inheriting sensible defaults.
@spec get_range( GenServer.server(), Minga.Buffer.Document.position(), Minga.Buffer.Document.position() ) :: String.t()
Returns the text in the range [start_pos, end_pos] inclusive. Positions are sorted automatically.
@spec insert_char(GenServer.server(), String.t(), Minga.Buffer.EditSource.t()) :: :ok
Inserts a character at the current cursor position.
@spec insert_text(GenServer.server(), String.t(), Minga.Buffer.EditSource.t()) :: :ok
Inserts a string at the current cursor position.
Each character is inserted sequentially, advancing the cursor.
@spec line_count(GenServer.server()) :: pos_integer()
Returns the total line count.
@spec local_options(GenServer.server()) :: %{required(atom()) => term()}
Returns all buffer-local option overrides (not the resolved values, just the overrides set on this buffer).
@spec move(GenServer.server(), Minga.Buffer.Document.direction()) :: :ok
Moves the cursor in the given direction.
@spec move_cursor(GenServer.server(), :up | :down | :left | :right) :: :ok
Moves the cursor in the given direction.
@spec move_if_possible(GenServer.server(), :left | :right) :: {:ok, Minga.Buffer.Document.position()} | :at_boundary
Moves the cursor left or right if the move is valid, performing the boundary
check inside the buffer process. Returns {:ok, new_position} if the cursor
moved, or :at_boundary if the cursor was already at the boundary.
For :left, the boundary is column 0.
For :right, the boundary is the last grapheme position on the current line.
This avoids copying the entire Document.t() across the process boundary
just to check whether the cursor is at a line boundary.
@spec move_to(GenServer.server(), Minga.Buffer.Document.position()) :: :ok
Moves the cursor to an exact position.
@spec open(GenServer.server(), String.t()) :: :ok | {:error, term()}
Opens a file, replacing the current buffer content.
@spec persistent?(GenServer.server()) :: boolean()
Returns whether the buffer is persistent (auto-recreated on kill).
Looks up the buffer pid for a file path via Minga.Buffer.Registry.
Returns {:ok, pid} if a buffer is registered for the given path,
or :not_found if no buffer has that path open. O(1) ETS lookup,
no GenServer calls. The path is expanded to an absolute path before lookup.
@spec read_only?(GenServer.server()) :: boolean()
Returns whether the buffer is read-only.
@spec redo(GenServer.server()) :: :ok
Redoes the last undone mutation.
@spec reload(GenServer.server()) :: :ok | {:error, term()}
Reloads the buffer from disk, preserving cursor position (clamped). Clears undo/redo.
@spec remap_face(GenServer.server(), String.t(), keyword()) :: :ok
Sets a buffer-local face override.
Merges the given attributes on top of the named face for this buffer
only. Other buffers are unaffected. The override persists until
cleared with clear_face_override/2.
Examples
Buffer.Server.remap_face(buf, "default", fg: 0x000000, bg: 0xFFFFFF)
Buffer.Server.remap_face(buf, "comment", italic: false)
@spec remove_block_decoration(GenServer.server(), reference()) :: :ok
Removes a block decoration by ID.
@spec remove_highlight(GenServer.server(), reference()) :: :ok
Removes a highlight range by ID.
@spec remove_highlight_group(GenServer.server(), atom()) :: :ok
Removes all highlight ranges in a group.
@spec remove_virtual_text(GenServer.server(), reference()) :: :ok
Removes a virtual text decoration by ID.
@spec render_snapshot(GenServer.server(), non_neg_integer(), non_neg_integer()) :: Minga.Buffer.RenderSnapshot.t()
Returns all data needed to render a single frame in one GenServer call.
Fetches cursor position, total line count, the visible line range starting at
first_line (up to count lines), file path, and dirty flag atomically.
This replaces 5 individual calls (cursor, line_count, get_lines, file_path,
dirty?) with a single round-trip.
@spec replace_content( GenServer.server(), String.t(), Minga.Buffer.State.edit_source() ) :: :ok | {:error, :read_only}
Replaces the entire buffer content, pushing the old content onto the undo stack.
@spec replace_content_force(GenServer.server(), String.t()) :: :ok
Replaces buffer content bypassing read-only. For programmatic panel updates.
@spec replace_content_with_decorations( GenServer.server(), String.t(), (Minga.Core.Decorations.t() -> Minga.Core.Decorations.t()), keyword() ) :: :ok
Atomically replaces buffer content and rebuilds decorations in a single GenServer call.
The decoration_fn receives a fresh Decorations.new() and returns
the new decorations. Optional cursor clamps the cursor position.
This prevents a render frame from seeing new content with zero decorations.
@spec save(GenServer.server()) :: :ok | {:error, term()}
Saves the buffer content to the associated file.
@spec save_as(GenServer.server(), String.t()) :: :ok | {:error, term()}
Saves the buffer content to a specific file path.
@spec set_cursor(GenServer.server(), Minga.Buffer.Document.position()) :: :ok
Sets the cursor to an absolute position. Clamped to buffer bounds.
@spec set_filetype(GenServer.server(), atom()) :: :ok
Changes the buffer's filetype and re-seeds per-filetype options.
The buffer content is not modified; only metadata (filetype, tab_width, indent_with, etc.) changes. The caller is responsible for triggering a highlight reparse after this call.
@spec set_option(GenServer.server(), atom(), term()) :: {:ok, term()} | {:error, String.t()}
Sets a buffer-local option override. Only affects this buffer.
The value is validated against the same type rules as
Config.Options.set/2. Returns {:ok, value} on success or
{:error, reason} if the value is invalid.
@spec snapshot(GenServer.server()) :: Minga.Buffer.Document.t()
Returns the underlying Document.t() struct for pure computation.
Use this to batch multiple reads into a single GenServer call. Perform
all calculations on the returned struct, then apply the result with
move_to/2 (cursor-only changes) or apply_snapshot/2 (content changes).
@spec start_link([start_opt()]) :: GenServer.on_start()
Starts a buffer server. Pass file_path: to open a file, or content: for an unnamed buffer.
@spec undo(GenServer.server()) :: :ok
Undoes the last mutation, restoring the previous buffer state.
@spec unlisted?(GenServer.server()) :: boolean()
Returns whether the buffer is unlisted (hidden from buffer picker).
@spec version(GenServer.server()) :: non_neg_integer()
Returns the buffer's mutation version counter (increments on every content change).