bage

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 2, 2026 License: MIT Imports: 17 Imported by: 0

Documentation

Overview

Package bage is the public, curated facade for Båge — the bidirectional code-graph round-trip file editor. It is the only import surface consumers (Hylla's cross-store coordinator, IDEs, standalone tools) should depend on; the internal/* packages are implementation detail and are deliberately not re-exported beyond the types and behavior gathered here.

Files are the source of truth

Båge treats the files on disk as authoritative. A graph (when present) is a projection of the files, never the reverse. Every edit is addressed by a Region — a (byte range, line/col range, region_hash) locator that is trusted only while the live file still contains the anchored content; on drift Båge rejects or re-grounds rather than silently misapplying an edit.

Region-anchored editing

An Edit targets a Region by its region_hash — the xxHash %016x of the region's RAW bytes (HYLLA_NODE_CONTRACT.md §1, §2; SPEC §8.1). The hash is the content anchor and gives omp-parity with Hylla's per-node locator bundle (the same region_hash is the only seam, so file-mode and graph-mode resolve identically) and is the basis of concurrency safety. Resolution (ADR-0003) is layered:

  • region_hash VERIFIES: the bytes at the region's own offset are the block the edit targets when their hash matches (an Exact, in-place resolve).
  • the CST RELOCATES: when the in-place hash no longer matches, Båge reparses the live file and matches the region_hash against every node. Exactly one match is a benign concurrent shift, re-resolved to the new offset (Shifted).
  • identity DISAMBIGUATES — and Båge declines: zero matches is a conflict and more than one match is ambiguous; both are hard rejects. Båge over-rejects on purpose: corruption is never acceptable, a rejected edit is.

A FileAnchor (per file: RawHash gates byte-offset validity, NormHash classifies whitespace-only drift) accompanies the edits as the file-level drift gate.

The FILE-LEG two-phase contract

Båge owns only the FILE leg of an edit. The two-phase protocol is:

Prepare(ctx, edits, anchors) -> *Plan       // optimistic: resolve + stage + WAL
Commit(plan)                 -> []EditResult // atomic: resolve-under-lock + write
Rollback(plan)                              // discard staged edits

Prepare is OPTIMISTIC and holds no lock: it reads each live file, resolves every edit against those bytes (rejecting a Conflict/Ambiguous with a *ConflictError, matchable via errors.Is(err, ErrConflict)), preview-splices, runs the optional Formatter/Linter, reparses to confirm the result still parses, and durably records a write-ahead-log intent. Prepare never mutates a source file — its sole on-disk effect is the WAL record.

Commit is the ATOMIC, lossless point. Per file, UNDER A PER-FILE LOCK, it RE-READS the live bytes and RE-RESOLVES every edit (resolve-under-lock, so a concurrent commit that benignly shifted a region is picked up and the edit lands at the current offset, never the stale one; a same-region conflict is rejected), atomic-writes, and computes one EditResult per edit — the write-back contract (changed byte range, recomputed region/file hashes, new line range) Hylla reads to re-ingest only the changed region (SPEC §8.2). Same-file commits serialize on one lock; cross-file commits take different locks and run in parallel.

Standalone vs integrated use

In standalone mode Båge is a pure file/LSP edit engine with no graph: callers typically use Apply (Prepare-then-Commit) for a one-shot edit. In integrated mode Hylla's cross-store coordinator drives Prepare/Commit/Rollback on the FILE leg interleaved with its own graph leg, so a single agent-facing edit lands in both the graph and the files as an all-or-nothing operation with no drift between them. The coordinator — not Båge — sequences the two legs; Båge exposes only the FILE-leg verbs.

Recover is the crash path

Recover replays any WAL intent left behind by a crash between Prepare and Commit, restoring the affected files to their pre-Prepare state so the files converge back to a consistent, committed state. A clean Commit leaves nothing to replay, so Recover is then a no-op.

Index

Examples

Constants

View Source
const (
	LangUnknown    = parser.LangUnknown
	LangGo         = parser.LangGo
	LangTypeScript = parser.LangTypeScript
	LangTSX        = parser.LangTSX
	LangJavaScript = parser.LangJavaScript
	LangPython     = parser.LangPython
	LangRust       = parser.LangRust
	LangJava       = parser.LangJava
	LangC          = parser.LangC
	LangCPP        = parser.LangCPP
	LangCSharp     = parser.LangCSharp
	LangRuby       = parser.LangRuby
	LangJSON       = parser.LangJSON
	LangHTML       = parser.LangHTML
	LangCSS        = parser.LangCSS
	LangYAML       = parser.LangYAML
	LangTOML       = parser.LangTOML
	LangXML        = parser.LangXML
	LangMakefile   = parser.LangMakefile
	LangBash       = parser.LangBash
	LangMarkdown   = parser.LangMarkdown
	// LangText is the grammar-free fallback: any file type with no registered
	// grammar opens and round-trips losslessly under it.
	LangText = parser.LangText
)

Re-exported language constants. Each names a tree-sitter grammar the parser adapter can select; see parser for the canonical definitions.

Variables

View Source
var ErrConflict = session.ErrConflict

ErrConflict is the sentinel wrapped by every ConflictError, matchable with errors.Is without inspecting the path. See session.ErrConflict.

Functions

func NormHash

func NormHash(h Hasher, raw []byte) string

NormHash returns the digest of Normalize(raw) — the content anchor used for file_norm_hash, encoded as 16-char zero-padded lowercase hex by h. Pass XXHasher{} to match the contract.

func Normalize

func Normalize(b []byte) []byte

Normalize applies Båge's canonical content-normalization rule and returns a new slice: drop all carriage returns, strip trailing horizontal whitespace per line, then strip ALL leading BOMs LAST (the idempotency fixpoint). Hylla MUST use this exact rule so the normalized hashes agree cross-system — it is the single source of truth for the contract (HYLLA_NODE_CONTRACT §4).

func RawHash

func RawHash(h Hasher, raw []byte) string

RawHash returns the digest of the RAW bytes (gates byte-offset validity), encoded as 16-char zero-padded lowercase hex by h. Pass XXHasher{} to match the contract.

func RegionHash

func RegionHash(src []byte, start, end int) string

RegionHash returns the region_hash for src[start:end]: the normalized-bytes digest (XXHasher %016x) that anchors a region by content, byte-identical to what Hylla stores per node (HYLLA_NODE_CONTRACT §4). This is the cross-system identity/conflict anchor; Hylla computes the same string for the same bytes.

Types

type CmdFormatter

type CmdFormatter = format.CmdFormatter

CmdFormatter is an exec-backed Formatter that shells out to a configured command. See format.CmdFormatter.

type CmdLinter

type CmdLinter = format.CmdLinter

CmdLinter is an exec-backed Linter that shells out to a configured command. See format.CmdLinter.

type Config

type Config struct {
	// Lang is the source language the parser uses; required.
	Lang Lang
	// Hasher computes region/file digests; defaults to XXHasher{} when nil.
	Hasher Hasher
	// Formatter, when non-nil, rewrites staged bytes before linting/parsing.
	Formatter Formatter
	// Linter, when non-nil, blocks the edit on a lint failure.
	Linter Linter
	// WALDir is the directory holding the write-ahead log; required.
	WALDir string
	// LSPCommand is the language-server command (argv) used by Rename; optional.
	LSPCommand []string
}

Config configures an Editor. WALDir and Lang are required; Hasher defaults to XXHasher{} when nil. Formatter and Linter are optional pipeline steps run over the staged bytes. LSPCommand names the language-server command (argv) used by Rename; it may be empty when rename is not needed.

type ConflictError

type ConflictError = session.ConflictError

ConflictError reports that a region-anchored edit could not be resolved against the live file — the target's region_hash matches no live node (a concurrent edit changed the same region) or matches more than one (ambiguous twins). Båge rejects rather than guesses (SPEC §8.3, §8.4, ADR-0003). See session.ConflictError.

type Edit

type Edit = region.Edit

Edit is a region-anchored edit: replace the bytes of a Region with NewText (SPEC §8.1). The model echoes a shown region_hash; it never resends old text. See region.Edit.

type EditResult

type EditResult = region.EditResult

EditResult is the write-back contract returned to Hylla after a commit: the changed byte range plus the recomputed region/file hashes and new line range, so Hylla re-ingests only the changed region (SPEC §8.2). See region.EditResult.

type Editor

type Editor struct {
	// contains filtered or unexported fields
}

Editor is the configured FILE-LEG edit engine: the public handle wrapping a region-anchored session, a shared parser, and (lazily, per Rename) a language-server client. It is the behavior facade consumers drive; data types are re-exported as aliases above.

func Open

func Open(cfg Config) (*Editor, error)

Open validates cfg and wires an Editor: a tree-sitter parser as the ParserPort and a session.Session over the configured WALDir, Hasher, Lang, Formatter, and Linter. WALDir is required; a nil Hasher defaults to XXHasher{}. Lang is OPTIONAL: when LangUnknown (the zero value) each file's language is auto-detected from its path via LangForPath, so an agent IDE can open a mixed-language tree; when set it forces that language for every file.

func (*Editor) Apply

func (e *Editor) Apply(ctx context.Context, edits []Edit, anchors []FileAnchor) ([]EditResult, error)

Apply is the standalone convenience for a one-shot edit: it Prepares the edits and, on success, immediately Commits the resulting Plan, returning the EditResults. Integrated callers that interleave the FILE leg with a graph leg use Prepare/Commit directly so the coordinator controls the commit point.

Example

ExampleEditor_Apply opens an Editor over a temp WALDir with the default XXHasher and no formatter, then applies a single region-anchored edit to a temp Go file. The edit targets the byte range covering "hi" and carries the matching region_hash so Resolve verifies the content before splicing. It is fully hermetic: no language server and no container are involved.

package main

import (
	"context"
	"fmt"
	"os"
	"path/filepath"

	"github.com/hylla-io/bage/internal/hashing"
	"github.com/hylla-io/bage/internal/region"
	"github.com/hylla-io/bage/pkg/bage"
)

func main() {
	dir, err := os.MkdirTemp("", "bage-example-*")
	if err != nil {
		panic(err)
	}
	defer os.RemoveAll(dir)

	src := "package main\n\nvar Greeting = \"hi\"\n"
	file := filepath.Join(dir, "greeting.go")
	if err := os.WriteFile(file, []byte(src), 0o644); err != nil {
		panic(err)
	}

	ed, err := bage.Open(bage.Config{
		Lang:   bage.LangGo,
		WALDir: dir,
	})
	if err != nil {
		panic(err)
	}
	defer ed.Close()

	// Anchor the edit to the byte range covering "hi" with its region_hash, so
	// the resolver verifies the targeted content before applying "hello".
	start := len("package main\n\nvar Greeting = \"")
	end := start + len("hi")
	live := []byte(src)
	hasher := hashing.XXHasher{}

	edit := bage.Edit{
		Region: bage.Region{
			Path:       file,
			StartByte:  start,
			EndByte:    end,
			RegionHash: region.HashRegion(live, start, end),
		},
		NewText: "hello",
	}
	anchor := bage.FileAnchor{
		Path:     file,
		RawHash:  hashing.RawHash(hasher, live),
		NormHash: hashing.NormHash(hasher, live),
	}

	results, err := ed.Apply(context.Background(), []bage.Edit{edit}, []bage.FileAnchor{anchor})
	if err != nil {
		panic(err)
	}

	out, err := os.ReadFile(file)
	if err != nil {
		panic(err)
	}
	fmt.Printf("changed [%d:%d] new lines %d-%d\n", results[0].ChangedStart, results[0].ChangedEnd, results[0].NewStartLine, results[0].NewEndLine)
	fmt.Print(string(out))
}
Output:
changed [30:35] new lines 3-3
package main

var Greeting = "hello"

func (*Editor) Close

func (e *Editor) Close() error

Close releases the Editor's resources. The parser and session hold no long-lived handles between edits (the LSP client is per-Rename), so Close is currently a no-op kept for forward-compatible lifecycle management.

func (*Editor) Commit

func (e *Editor) Commit(plan *Plan) ([]EditResult, error)

Commit is the atomic, lossless point: per file, under that file's lock, it re-reads the live bytes and re-resolves every edit (resolve-under-lock, so a benign concurrent shift lands at the current offset and a same-region conflict is rejected), atomic-writes, and returns one EditResult per edit. A *ConflictError aborts the commit, leaving the source untouched and the WAL intact for Recover; full success clears the WAL.

func (*Editor) Parser

func (e *Editor) Parser() ParserPort

Parser returns the Editor's shared ParserPort so Hylla can reuse the exact parser Båge edits with, keeping graph ingest and file edits structurally consistent.

func (*Editor) Prepare

func (e *Editor) Prepare(ctx context.Context, edits []Edit, anchors []FileAnchor) (*Plan, error)

Prepare optimistically stages every region-anchored edit against the live files, drift-checks via the per-region region_hash (rejecting a Conflict or Ambiguous as a *ConflictError, matchable via errors.Is(err, ErrConflict)), runs the optional Formatter/Linter, reparses to prove the result is valid, and durably records a WAL intent. It returns a Plan whose staged bytes are not yet on disk — Prepare's sole on-disk effect is the WAL record. anchors carries the per-file drift gate (SPEC §8.1) for each file the edits touch.

func (*Editor) Recover

func (e *Editor) Recover(ctx context.Context) error

Recover is the crash path: it replays any WAL intent left in the Editor's WALDir, restoring affected files to their pre-Prepare state, then clears the WAL. A clean Commit leaves nothing to replay, so Recover is then a no-op.

func (*Editor) Rename

func (e *Editor) Rename(ctx context.Context, file string, line, col uint32, newName string) (*Plan, error)

Rename performs an LSP-driven rename of the symbol at the zero-based (line, col) UTF-16 position in file, then stages the resulting cross-file edits as region-anchored edits. It requires Config.LSPCommand: it spawns the language server, requests the rename, converts the server's WorkspaceEdit into byte-range edits, grounds each as a Region with a content region_hash, builds one FileAnchor per file, and Prepares them. It returns the Plan; the caller Commits (or Rollbacks) it. The server is shut down before Rename returns.

func (*Editor) Rollback

func (e *Editor) Rollback(plan *Plan) error

Rollback abandons a prepared Plan, discarding the staged edits and clearing the WAL; the source files are left untouched.

type FileAnchor

type FileAnchor = region.FileAnchor

FileAnchor is the per-file drift gate: a file's RawHash (byte-offset validity) and NormHash (whitespace-only drift classifier). See region.FileAnchor.

type Formatter

type Formatter = format.Formatter

Formatter rewrites staged source content before commit. See format.Formatter.

type Hasher

type Hasher = hashing.Hasher

Hasher computes a stable hex digest of a byte slice. See hashing.Hasher.

type Lang

type Lang = parser.Lang

Lang enumerates the source languages a parser adapter can parse. See parser.Lang.

func LangForPath

func LangForPath(path string) Lang

LangForPath selects the language for a file path by extension/basename, falling back to the grammar-free text mode (never LangUnknown) so any file can be opened and losslessly round-tripped. See parser.LangForPath.

type Linter

type Linter = format.Linter

Linter validates staged source content, blocking the edit on failure. See format.Linter.

type Node

type Node = parser.Node

Node is a single concrete-syntax-tree node addressed by byte range and point. See parser.Node.

type OpenedFile

type OpenedFile struct {
	// Path is the file path that was opened (as supplied by the caller).
	Path string
	// Lang is the language selected for Path via LangForPath; never LangUnknown.
	Lang Lang
	// Tree is the parsed CST together with the source bytes it was parsed from.
	Tree *Tree
}

OpenedFile is a freshly parsed file handle: the path, the selected language, and the concrete syntax tree. It is the read-only convenience an agent IDE uses to inspect a file without opening a full Editor. The caller MUST Close it when done so the adapter can free the native tree.

func OpenFile

func OpenFile(ctx context.Context, path string) (*OpenedFile, error)

OpenFile reads path, selects a language with LangForPath (falling back to the grammar-free text mode for any type without a registered grammar, so ANY file opens), and parses it with the same tree-sitter adapter Båge edits with. The returned OpenedFile must be Closed by the caller.

func (*OpenedFile) Close

func (o *OpenedFile) Close()

Close releases the native resources held by the opened tree. It is nil-safe and idempotent (parser.Tree.Close is idempotent), so calling it twice or on a zero OpenedFile is a no-op.

type ParserPort

type ParserPort = parser.ParserPort

ParserPort is the engine-agnostic parsing contract Hylla consumes for shared ingest parsing. See parser.ParserPort.

func NewParser

func NewParser() ParserPort

NewParser returns a fresh ParserPort backed by the official CGO tree-sitter adapter. It lets Hylla use Båge's shared parser for ingest without opening a full Editor, so the graph and the files can never disagree on structure.

type Plan

type Plan = session.Plan

Plan is the result of a successful Prepare: the durably-logged WAL intent plus the region edits and per-file anchors Commit re-validates under lock. See session.Plan.

type Region

type Region = region.Region

Region is a content-anchored locator into a file: a byte range, the matching line/col range, and the region_hash that anchors the region by content (SPEC §8.1). See region.Region.

type Symbol

type Symbol struct {
	// Kind is the grammar node kind (e.g. "function_declaration"), or "line" for
	// the text fallback.
	Kind string
	// Name is the declared identifier, best-effort; "" when none was found.
	Name string
	// StartByte is the inclusive start byte offset of the node.
	StartByte int
	// EndByte is the exclusive end byte offset of the node.
	EndByte int
	// StartLine is the 1-based start line of the node.
	StartLine int
	// EndLine is the 1-based end line of the node.
	EndLine int
}

Symbol is one entry in a file's Outline: a named declaration node (or, for the grammar-free text fallback, a single line). Bytes are the half-open CST range; StartLine and EndLine are 1-based to match EditResult line numbering. Name is best-effort and may be empty when no identifier child is found.

func Outline

func Outline(tree *Tree) []Symbol

Outline returns a documentSymbol-like listing of a parsed tree: every named declaration node, in source order, with its byte and line ranges. It is grammar-agnostic — it selects declaration nodes by named-node kind, so it works for any tree-sitter grammar. For the grammar-free text fallback it returns one Symbol per source line instead.

The text fallback is identified by a nil native tree (treesitter.textTree sets Tree.Native == nil), NOT by child count: the text document now carries line children, and some real grammars (e.g. HTML) also use a "document" root — so the engine-free handle is the unambiguous discriminator.

type Tree

type Tree = parser.Tree

Tree is a parsed concrete syntax tree together with its source bytes. See parser.Tree.

type XXHasher

type XXHasher = hashing.XXHasher

XXHasher is the canonical xxHash64 Hasher shared with Hylla. See hashing.XXHasher.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL