Minga.Buffer.Process (Minga v0.1.0)

Copy Markdown View Source

GenServer wrapping a Document with file I/O and dirty tracking.

Each open file gets its own Buffer.Process process, managed by the Buffer.Supervisor (DynamicSupervisor). If a buffer process crashes, only that buffer is lost, and all other buffers and the editor continue running.

Examples

{:ok, pid} = Minga.Buffer.Process.start_link(file_path: "README.md")
:ok = Minga.Buffer.Process.insert_char(pid, "x")
true = Minga.Buffer.Process.dirty?(pid)
:ok = Minga.Buffer.Process.save(pid)
false = Minga.Buffer.Process.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

Accepts content as the saved base revision and clears dirty state.

Acknowledges the current disk metadata after the user chooses to keep local edits.

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 multiple text edits in a single GenServer call.

Applies a cursor motion inside the buffer process.

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

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.

Returns and clears pending edit deltas accumulated since the last legacy consumer read.

Returns edit deltas accumulated since the given consumer's last read.

Returns the full text content of the buffer.

Returns the content and cursor position in a single GenServer call.

Returns the joined text of lines [start_line, end_line] inclusive (no trailing newline).

Returns the grapheme count in the range [start_pos, end_pos] inclusive. Positions are sorted automatically.

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.

Force-saves the buffer, skipping mtime conflict detection.

Returns a buffer-local option value using the resolution chain: buffer-local → filetype override → global default.

Inserts a character at the current cursor position.

Inserts a string at the current cursor position.

Returns the edit source of the most recent redo entry, or nil if the redo stack is empty.

Returns the edit source of the most recent undo entry, or nil if the undo stack is empty.

Returns the total line count.

Returns a range of lines from the buffer.

Returns only options explicitly overridden on this buffer.

Returns all buffer option values currently cached on this buffer.

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

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.

Atomically replaces buffer content and rebuilds decorations in a single GenServer call.

Replaces buffer content bypassing read-only. For programmatic panel updates.

Retargets the buffer to a new file path without writing content.

Saves the buffer content to the associated file.

Saves the buffer content to a specific file path.

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.

Returns the buffer storage backend.

Returns the text in the range [start_pos, end_pos] inclusive. Positions are sorted automatically.

Undoes the last mutation, restoring the previous buffer state.

Undoes all consecutive agent-sourced entries from the top of the undo stack.

Returns whether the buffer is unlisted (hidden from buffer picker).

Returns the buffer's mutation version counter (increments on every content change).

Types

boundary()

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

edit_delta_update()

@type edit_delta_update() :: {:ok, [Minga.Buffer.EditDelta.t()]} | :reset_required

motion_fun()

replace_edit()

@type replace_edit() :: {old_text :: String.t(), new_text :: String.t()}

A find-and-replace edit pair for batch operations.

replace_result()

@type replace_result() :: {:ok, String.t()} | {:error, String.t()}

Result of a single edit within a batch.

start_opt()

@type start_opt() ::
  {:file_path, String.t()}
  | {:content, String.t()}
  | {:name, GenServer.name()}
  | {:buffer_name, String.t()}
  | {:buffer_type, Minga.Buffer.State.buffer_type()}
  | {:storage, Minga.Buffer.State.storage()}
  | {:filetype, atom()}
  | {:options_server, Minga.Config.Options.server() | nil}
  | {:read_only, boolean()}
  | {:unlisted, boolean()}
  | {:persistent, boolean()}
  | {:events_registry, Minga.Events.registry()}

Options for starting a buffer server.

state()

@type state() :: Minga.Buffer.State.t()

Internal state of the buffer server.

text_edit()

@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

accept_saved_content(server, new_content)

@spec accept_saved_content(GenServer.server(), String.t()) :: :ok

Accepts content as the saved base revision and clears dirty state.

acknowledge_disk_change(server)

@spec acknowledge_disk_change(GenServer.server()) :: :ok

Acknowledges the current disk metadata after the user chooses to keep local edits.

add_block_decoration(server, anchor_line, opts)

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

add_highlight(server, start_pos, end_pos, opts)

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.

add_virtual_text(server, anchor, opts)

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.

append(server, text)

@spec append(GenServer.server(), String.t()) :: :ok

Appends text to the end of the buffer, bypassing read-only. For programmatic writes.

apply_edit(server, start_line, start_col, end_line, end_col, new_text, source \\ EditSource.user())

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.

apply_edits(server, edits, source \\ EditSource.lsp(:unknown))

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

apply_motion(server, motion_fn)

@spec apply_motion(GenServer.server(), motion_fun()) :: :ok

Applies a cursor motion inside the buffer process.

batch_decorations(server, fun)

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

break_undo_coalescing(server)

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

buffer_name(server)

@spec buffer_name(GenServer.server()) :: String.t() | nil

Returns the buffer name (e.g. *Messages*), or nil for file buffers.

buffer_type(server)

Returns the buffer's type (:file, :nofile, :nowrite, :prompt, :terminal).

byte_offset_for_line(server, line)

@spec byte_offset_for_line(GenServer.server(), non_neg_integer()) :: non_neg_integer()

Returns the byte offset for the start of a given line.

child_spec(init_arg)

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

clear_face_override(server, face_name)

@spec clear_face_override(GenServer.server(), String.t()) :: :ok

Clears a buffer-local face override, restoring the theme default.

clear_line(server, line)

@spec clear_line(GenServer.server(), non_neg_integer()) :: {:ok, String.t()}

Clears all content on the given line. Returns {:ok, yanked_text}.

commit_navigable_snapshot(server, buffer_snapshot)

Applies a BufferSnapshot back to the server, updating the document and returning the updated scroll state.

Only writes the document back if content or cursor changed. Returns the scroll state from the snapshot for the caller to store.

commit_snapshot(server, new_buf)

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

consume_edit_deltas(server)

This function is deprecated. Use consume_edit_deltas/2 with a consumer_id instead.
@spec consume_edit_deltas(GenServer.server()) :: [Minga.Buffer.EditDelta.t()]

Returns and clears pending edit deltas accumulated since the last legacy consumer read.

Deprecated: use consume_edit_deltas/2 with a consumer_id for per-consumer cursors. This legacy version destructively drains the shared pending changes list.

consume_edit_deltas(server, consumer_id)

@spec consume_edit_deltas(GenServer.server(), atom()) :: edit_delta_update()

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. Returns :reset_required if older retained deltas were compacted before the consumer caught up.

This avoids the data race where two consumers calling consume_edit_deltas/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.

content(server)

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

Returns the full text content of the buffer.

content_and_cursor(server)

@spec content_and_cursor(GenServer.server()) ::
  {String.t(), Minga.Buffer.Document.position()}

Returns the content and cursor position in a single GenServer call.

content_on_lines(server, start_line, end_line)

@spec content_on_lines(GenServer.server(), non_neg_integer(), non_neg_integer()) ::
  String.t()

Returns the joined text of lines [start_line, end_line] inclusive (no trailing newline).

content_range_length(server, start_pos, end_pos)

Returns the grapheme count in the range [start_pos, end_pos] inclusive. Positions are sorted automatically.

cursor(server)

Returns the current cursor position.

decorations(server)

@spec decorations(GenServer.server()) :: Minga.Core.Decorations.t()

Returns the decorations struct for read-only access (e.g., by the render pipeline).

decorations_version(server)

@spec decorations_version(GenServer.server()) :: non_neg_integer()

Returns the decorations version for cheap change detection.

delete_at(server)

@spec delete_at(GenServer.server()) :: :ok

Deletes the character at the cursor (delete forward).

delete_before(server)

@spec delete_before(GenServer.server()) :: :ok

Deletes the character before the cursor (backspace).

delete_lines(server, start_line, end_line)

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

delete_range(server, from_pos, to_pos)

Deletes the text between two positions (from_pos inclusive, to_pos exclusive), placing the cursor at the start of the range.

dirty?(server)

@spec dirty?(GenServer.server()) :: boolean()

Returns whether the buffer has unsaved changes.

display_name(server)

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

face_overrides(server)

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

file_path(server)

@spec file_path(GenServer.server()) :: String.t() | nil

Returns the file path associated with this buffer, if any.

filetype(server)

@spec filetype(GenServer.server()) :: atom()

Returns the detected filetype atom for this buffer.

find_and_replace(server, old_text, new_text, boundary \\ nil)

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

find_and_replace_batch(server, edits, boundary \\ nil)

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

force_save(server)

@spec force_save(GenServer.server()) :: :ok | {:error, term()}

Force-saves the buffer, skipping mtime conflict detection.

get_option(server, name)

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

insert_char(server, char, source \\ EditSource.user())

@spec insert_char(GenServer.server(), String.t(), Minga.Buffer.EditSource.t()) :: :ok

Inserts a character at the current cursor position.

insert_text(server, text, source \\ EditSource.user())

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

last_redo_source(server)

@spec last_redo_source(GenServer.server()) :: Minga.Buffer.State.edit_source() | nil

Returns the edit source of the most recent redo entry, or nil if the redo stack is empty.

last_undo_source(server)

@spec last_undo_source(GenServer.server()) :: Minga.Buffer.State.edit_source() | nil

Returns the edit source of the most recent undo entry, or nil if the undo stack is empty.

line_count(server)

@spec line_count(GenServer.server()) :: pos_integer()

Returns the total line count.

lines(server, start, count)

Returns a range of lines from the buffer.

local_option_overrides(server)

@spec local_option_overrides(GenServer.server()) :: %{required(atom()) => term()}

Returns only options explicitly overridden on this buffer.

local_options(server)

@spec local_options(GenServer.server()) :: %{required(atom()) => term()}

Returns all buffer option values currently cached on this buffer.

move(server, direction)

Moves the cursor in the given direction.

move_if_possible(server, 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.

move_to(server, pos)

Moves the cursor to an exact position.

open(server, file_path)

@spec open(GenServer.server(), String.t()) :: :ok | {:error, term()}

Opens a file, replacing the current buffer content.

persistent?(server)

@spec persistent?(GenServer.server()) :: boolean()

Returns whether the buffer is persistent (auto-recreated on kill).

pid_for_path(path)

@spec pid_for_path(String.t()) :: {:ok, pid()} | :not_found

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.

read_only?(server)

@spec read_only?(GenServer.server()) :: boolean()

Returns whether the buffer is read-only.

redo(server)

@spec redo(GenServer.server()) :: :ok

Redoes the last undone mutation.

reload(server)

@spec reload(GenServer.server()) :: :ok | {:error, term()}

Reloads the buffer from disk, preserving cursor position (clamped). Clears undo/redo history.

remap_face(server, face_name, attrs)

@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.Process.remap_face(buf, "default", fg: 0x000000, bg: 0xFFFFFF)
Buffer.Process.remap_face(buf, "comment", italic: false)

remove_block_decoration(server, id)

@spec remove_block_decoration(GenServer.server(), reference()) :: :ok

Removes a block decoration by ID.

remove_highlight(server, id)

@spec remove_highlight(GenServer.server(), reference()) :: :ok

Removes a highlight range by ID.

remove_highlight_group(server, group)

@spec remove_highlight_group(GenServer.server(), atom()) :: :ok

Removes all highlight ranges in a group.

remove_virtual_text(server, id)

@spec remove_virtual_text(GenServer.server(), reference()) :: :ok

Removes a virtual text decoration by ID.

render_snapshot(server, first_line, count)

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, lines, file_path, dirty?) with a single round-trip.

replace_content(server, new_content, source \\ :user)

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

replace_content_with_decorations(server, content, decoration_fn, opts \\ [])

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

replace_generated_content(server, new_content)

@spec replace_generated_content(GenServer.server(), String.t()) :: :ok

Replaces buffer content bypassing read-only. For programmatic panel updates.

retarget_path(server, file_path)

@spec retarget_path(GenServer.server(), String.t()) :: :ok | {:error, term()}

Retargets the buffer to a new file path without writing content.

save(server)

@spec save(GenServer.server()) :: :ok | {:error, term()}

Saves the buffer content to the associated file.

save_as(server, file_path)

@spec save_as(GenServer.server(), String.t()) :: :ok | {:error, term()}

Saves the buffer content to a specific file path.

set_filetype(server, filetype)

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

set_option(server, name, value)

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

snapshot(server)

@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 commit_snapshot/2 (content changes).

start_link(opts \\ [])

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

storage(server)

Returns the buffer storage backend.

text_between_inclusive(server, start_pos, end_pos)

Returns the text in the range [start_pos, end_pos] inclusive. Positions are sorted automatically.

undo(server)

@spec undo(GenServer.server()) :: :ok

Undoes the last mutation, restoring the previous buffer state.

undo_agent_session(server)

@spec undo_agent_session(GenServer.server()) :: {:ok, non_neg_integer()} | :empty

Undoes all consecutive agent-sourced entries from the top of the undo stack.

unlisted?(server)

@spec unlisted?(GenServer.server()) :: boolean()

Returns whether the buffer is unlisted (hidden from buffer picker).

version(server)

@spec version(GenServer.server()) :: non_neg_integer()

Returns the buffer's mutation version counter (increments on every content change).