yedit

package module
v0.20.0 Latest Latest
Warning

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

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

README

yedit

A reusable TUI library for editing structured YAML files in Go.

yedit turns any Go struct annotated with yaml tags into a two-panel bubbletea editor. The left panel lists the top-level keys discovered from the struct; the right panel shows a live YAML preview. Pressing Enter on a key opens a full-screen block editor where sub-fields can be toggled on/off, edited with presets, or written directly in YAML.

The library is schema-agnostic: clients supply a Go struct, optional presets, and any cross-field validation rules.

Example

yedit demo

Requirements

Go 1.24+

Install

go get github.com/lucasassuncao/yedit

Quick start

package main

import (
    "log"
    "github.com/lucasassuncao/yedit/editor"
)

type Config struct {
    Name  string `yaml:"name"`
    Image string `yaml:"image"`
    Build *struct {
        Dockerfile string `yaml:"dockerfile"`
        Context    string `yaml:"context"`
    } `yaml:"build"`
}

func main() {
    res, err := editor.Run(editor.Config{
        Path:   "config.yaml",
        Schema: &Config{},
        Title:  "my editor",
    })
    if err != nil {
        log.Fatal(err)
    }
    if res.Saved {
        // at least one save succeeded — reload config.yaml, apply it, etc.
    }
}

A non-existent Path is not an error — the editor starts with an empty document and saves to that path on Ctrl+S.

editor.Run returns a Result whose Saved field reports whether the user saved at least once during the session. Use editor.RunContext(ctx, cfg) when the editor should shut down on context cancellation.

UI layout

The editor adapts its left panel to the field type:

Main list — shows all top-level keys split into ADDED (present in the file) and AVAILABLE (schema-known but not yet set). Enter opens a block; h toggles a Hint/Example panel describing the selected key.

Struct tree (KindObject) — left panel lists sub-fields in ADDED/AVAILABLE sections; Enter adds a field, Ctrl+D removes it, and / expand or collapse a node. The right panel shows a live YAML preview.

Collection navigator (KindList / KindDictionary with child defs) — left panel shows [0] item, [1] item … (map entries are keyed by their map key) and a [+ add new] row. Enter adds an item, Ctrl+D deletes the selected one.

Single field (KindPrimitive, KindEnum, free-form map/list) — the YAML editor takes focus immediately; the left panel shows the field itself and the Hint/Example panel shows its type, constraints, and an example.

A Hint/Example panel (bottom-right) shows the focused field's concrete type (string, int, bool, …), whether it is required, its default, allowed values, and an example. It is only shown when Config.Hints is set; toggled with h in the main list and always visible inside the block editor.

Keyboard reference

Main list
Key Action
/ k, / j Navigate
g / G Jump to top / bottom
/ Filter list
h Toggle the Hint/Example panel
Enter Open or add block
Ctrl+D Delete block (with confirmation)
Ctrl+U Undo last change
Ctrl+Y Redo last undone change
Ctrl+R Reload file from disk (prompts if unsaved)
Tab Focus the read-only preview pane
Ctrl+S Save changes
Ctrl+L Validate document
Esc / q Quit (prompts if unsaved)
Block-edit tree
Key Action
, Navigate
, Expand / collapse node (on an openable field, drills into a nested editor)
Enter Add field / item, or drill into an openable field
Ctrl+D Remove field / sequence item
Ctrl+U Undo last change in this editor
Ctrl+Y Redo last undone change in this editor
p Open preset picker
Tab Switch to YAML editor
Ctrl+S Commit changes
Esc Back (prompts if uncommitted)
YAML editor (right panel)

Only Tab and Esc are captured — all other keys go to the textarea.

Tags

Only the yaml tag is required. The rest are optional enrichments:

Tag Effect
yaml:"name" Required. The YAML key name.
yaml:"-" Hide field from the editor.
yaml:"name,omitempty" Sets FieldDef.OmitEmpty = true (zero value not written to disk).
yaml:"name,flow" Sets FieldDef.Flow = true (serialised inline, e.g. [a, b]).
validate:"required" Marks field as required (stored in FieldDef.Required).
validate:"oneof=a b c" Restricts accepted values (stored in FieldDef.OneOf).
jsonschema:"required" Alternative way to mark required.
jsonschema:"default=X" Default value (stored in FieldDef.Default).
jsonschema_description:"..." Description text (stored in FieldDef.Description).

Required, Default, OneOf, Description, OmitEmpty, and Flow are surfaced in schema.FieldDef and available to external tooling (doc generators, custom renderers). Required, Default, OneOf, and Description are also shown in the editor's Hint/Example panel.

Full Config

editor.Run(editor.Config{
    // Required
    Path:   "config.yaml",
    Schema: &MyConfig{},

    // Optional
    Title:  "my editor",

    // Preset snippets loaded from an fs.FS (see presets.FromFS)
    Presets: presets.FromFS(embedFS, "."),

    // Cross-field validation rules
    Validators: []editor.Validator{
        editor.MutuallyExclusive("image", "build"),
        editor.RequiredWith("service", "dockerComposeFile"),
    },

    // Sub-fields to pre-check when a new block is opened for the first time
    PreCheckedFields: editor.CheckedFieldMap{
        "build": {"dockerfile", "context"},
    },

    // Default YAML snippets inserted when a sub-field is toggled on
    FieldSnippets: editor.FieldSnippetMap{
        "build": {
            "dockerfile": "  dockerfile: Dockerfile\n",
            "context":    "  context: .\n",
        },
    },

    // Top-level keys to hide (e.g. legacy aliases)
    Hidden: []string{"dockerFile"},

    // Extra recursive levels for self-referential types (default 1)
    SchemaRecursionDepth: 2,
})

Validators

Validators run before every save and when the user presses Ctrl+L. Each returns a list of editor.Violation values — a Path locating the offending node (empty for document-wide rules) plus a human-readable Message — shown in a blocking alert. Pass them via editor.Config.Validators; use editor.ValidatorFunc for inline custom rules.

Dotted paths expand sequences and dict-style mappings automatically in every path-based validator: a rule on categories.installers.source.type is checked inside every installer of every category, whether categories is a list or a map. Cross-field validators (RequiredIf, CrossFieldOrdered) apply this per-entry when both paths share the same parent prefix.

MutuallyExclusive

Reports a violation when more than one of the listed keys is present at the same time.

Top-level keys — compared against the document's root-level blocks:

editor.Run(editor.Config{
    // ...
    Validators: []editor.Validator{
        editor.MutuallyExclusive("image", "build", "dockerComposeFile"),
    },
})

Dotted paths — all paths must share the same parent prefix (paths that don't are a configuration error, reported as a violation on every validate). The validator navigates to that parent in the YAML tree and checks the leaf keys there.

Sequences (- item) and dict-style mappings (key: {…}) are expanded automatically, so every entry in a list or every value in a map is checked:

editor.Run(editor.Config{
    // ...
    Validators: []editor.Validator{
        // "any" and "all" must not appear together under every
        // categories › <name> › installers › [n] › source › filter.
        editor.MutuallyExclusive(
            "categories.installers.source.filter.any",
            "categories.installers.source.filter.all",
        ),
    },
})
MutuallyExclusiveNested

Like MutuallyExclusive for dotted paths, but designed for recursive schemas where the same constraint must hold at every nesting level (e.g. a filter struct whose any/all lists can themselves contain filter structs).

Single key — searches the entire document for every mapping whose direct parent key equals the given name, regardless of depth:

editor.Run(editor.Config{
    // ...
    Validators: []editor.Validator{
        editor.MutuallyExclusiveNested("filter", "any", "all"),
    },
})

Scoped dotted path — navigates to the prefix first, then recurses only within that subtree. Use this when multiple unrelated objects in the document share the same key name and the rule should only apply to one of them. The last segment of the path is the recursive key name:

editor.Run(editor.Config{
    // ...
    Validators: []editor.Validator{
        // Only recurse through filters that live under
        // categories › <name> › installers › [n] › source.
        // A "filter" key elsewhere in the document is ignored.
        editor.MutuallyExclusiveNested(
            "categories.installers.source.filter",
            "any", "all",
        ),
    },
})
RequiredWith

Reports a violation when key is present but parent is not:

editor.Run(editor.Config{
    // ...
    Validators: []editor.Validator{
        editor.RequiredWith("service", "dockerComposeFile"),
    },
})
AtLeastOneOf

Reports a violation when none of the listed keys is present:

editor.AtLeastOneOf("image", "build", "dockerComposeFile")
ExactlyOneOf

Reports a violation when none or more than one of the listed keys is present:

editor.ExactlyOneOf("image", "build", "dockerComposeFile")
RequiredIf

Reports a violation when key is absent but condPath equals condValue:

// "tls-cert" is required when "protocol" is "https"
editor.RequiredIf("tls-cert", "server.protocol", "https")
ValueOneOf

Reports a violation when the field at path exists but its value is not in the allowed set:

editor.ValueOneOf("server.protocol", "http", "https", "grpc")
CrossFieldOrdered

Reports a violation when both paths are present but the value at smallerPath is not strictly less than largerPath. Supports plain numbers ("1", "0.5"), time.Duration strings (e.g. "24h"), and size strings — KB/MB/GB/TB are decimal (powers of 1000), KiB/MiB/GiB/TiB are binary (powers of 1024). Both sides must be of the same kind:

editor.CrossFieldOrdered("source.filter.min-age", "source.filter.max-age")
editor.CrossFieldOrdered("source.filter.min-size", "source.filter.max-size")
NoDuplicates

Reports a violation when two or more items in the sequence at seqPath share the same value for field:

editor.NoDuplicates("categories", "name")
Required

Reports a violation when a path is absent or holds an empty/null scalar. A path with no dots is required unconditionally; a dotted path is conditional — the leaf is only required where its parent exists, and sequences / dict-style mappings along the path are expanded automatically:

editor.Required("version")          // top-level, unconditional
editor.Required("categories.name")  // every category entry needs "name"
RequiredFromSchema

Enforces the schema's validate:"required" / jsonschema:"required" tags at validate/save time (without it the marker is display-only). Required fields inside an optional block are only enforced while the block is present; sequence and dictionary entries are checked individually. The editor wires the discovered schema in automatically:

editor.RequiredFromSchema()
AllOrNone

Reports a violation when only some of the listed keys are present — they must appear together or not at all (e.g. a TLS cert/key pair). Supports top-level keys and dotted paths with a shared parent, like MutuallyExclusive:

editor.AllOrNone("tls-cert", "tls-key")
editor.AllOrNone("server.tls-cert", "server.tls-key")
ValueInRange

Reports a violation when the scalar at path is present but outside the inclusive [min, max] range. Bounds and value may be plain numbers, time.Duration strings, or size strings (same kinds as CrossFieldOrdered):

editor.ValueInRange("server.port", "1", "65535")
editor.ValueInRange("filter.max-age", "1h", "8760h")
ValueMatches

Reports a violation when the scalar at path is present but does not match the regular expression. Combine with Required when the field is mandatory:

editor.ValueMatches("version", `^\d+\.\d+\.\d+$`)
CountRange

Reports a violation when the collection at path has fewer than min or more than max entries (max < 0 means no upper bound). Sequences count items, mappings count keys:

editor.CountRange("workers", 1, 10)
editor.CountRange("categories", 1, -1) // at least one
UniqueValues

Reports a violation when two or more scalar items in the sequence at seqPath share the same value (the struct-entry variant is NoDuplicates):

editor.UniqueValues("tags")
Deprecated

Reports a violation whenever path is present, carrying a migration hint. Combine with Config.NoValidateOnSave to make it a non-blocking warning:

editor.Deprecated("dockerFile", "use build.dockerfile instead")
Combining validators
editor.Run(editor.Config{
    // ...
    Validators: []editor.Validator{
        editor.MutuallyExclusive("image", "build", "dockerComposeFile"),
        editor.RequiredWith("service", "dockerComposeFile"),
        editor.MutuallyExclusiveNested(
            "categories.installers.source.filter",
            "any", "all",
        ),
    },
})

Union types

Reflection cannot infer the shape of types that wrap a union (scalar / sequence / mapping). Such types opt into a small interface:

type Provider interface {
    YeditSchema() []schema.FieldDef
}

If a field's type implements Provider, the editor uses the returned []FieldDef instead of descending by reflection. The field kind is set to KindVariant and the YAML editor takes focus directly.

type TimeoutValue struct{}

func (TimeoutValue) YeditSchema() []schema.FieldDef {
    return []schema.FieldDef{
        {YAMLName: "connect", Kind: schema.KindPrimitive},
        {YAMLName: "read",    Kind: schema.KindPrimitive},
        {YAMLName: "write",   Kind: schema.KindPrimitive},
    }
}

Presets

Each field can have named presets. Implement editor.PresetSource or use presets.FromFS with a directory tree:

my-presets/
├── build/
│   ├── base.yaml
│   └── multi-stage.yaml
└── customizations/
    └── vscode-go.yaml

The preset picker is opened with p in the block-edit tree panel.

Environment

Variable Effect
NO_COLOR Disables all color output (monochrome mode).

Minimum terminal size: 80 × 20. Below that the editor shows a resize prompt.

Sub-packages

Package Purpose
editor Two-panel TUI; main entry point (editor.Run)
schema Reflection over Go structs into a FieldDef tree; opt-in Provider for union types
document YAML document state: block-level parse / insert / remove / replace, undo, save
presets Source interface + FromFS for per-field YAML snippets
viewer Read-only preset browser TUI
theme Shared palette and layout primitives
internal Non-public helpers (alert widget, yamlnode utilities)

Examples

See examples/test for a self-contained program that exercises all five schema patterns, KindVariant, nested slices, deep nesting, oneof, MutuallyExclusive, unknown-key validation, and schema edge cases (anonymous embeds, yaml.Marshaler, interface{}, non-string map keys, omitempty/flow flags).

Status

Pre-1.0. The API may change between minor versions until v1.0.0.

License

MIT — see LICENSE.

Documentation

Overview

Package yedit provides reusable building blocks for TUI editors over structured YAML files.

The library is composed of independent sub-packages:

  • schema: reflection over the client's Go structs (yaml tag required; validate and jsonschema_description optional)
  • document: YAML state with block-level mutations, history, and parsing
  • editor: two-panel bubbletea TUI that ties the pieces together
  • presets: Source interface + fs.FS-backed implementation for per-field YAML snippets
  • viewer: read-only TUI to browse a preset Source
  • theme: palette and layout primitives (header, panels, two-column layout)
  • internal: non-public helpers (alert widget, yaml node utilities)

yedit is intentionally headless of any specific YAML schema. Clients pass a pointer to their own annotated struct and (optionally) a preset Source; the editor introspects the struct via the schema package and renders an add/edit/remove UI keyed by the canonical top-level order.

See editor.Run and editor.Config for the main entry point.

Directories

Path Synopsis
Package document provides primitives for editing YAML files structured as a flat mapping of top-level keys ("blocks").
Package document provides primitives for editing YAML files structured as a flat mapping of top-level keys ("blocks").
Package editor provides the bubbletea TUI for editing a YAML file driven by a struct-based schema and a preset source.
Package editor provides the bubbletea TUI for editing a YAML file driven by a struct-based schema and a preset source.
examples
test command
Command test is a self-contained yedit example that exercises every schema pattern, Config option, and known edge case.
Command test is a self-contained yedit example that exercises every schema pattern, Config option, and known edge case.
internal
alert
Package alert provides a modal alert/confirm component for bubbletea TUIs.
Package alert provides a modal alert/confirm component for bubbletea TUIs.
yamlnode
Package yamlnode provides read-mostly query and navigation helpers over gopkg.in/yaml.v3 node trees, shared by the editor's editing flow and its validators.
Package yamlnode provides read-mostly query and navigation helpers over gopkg.in/yaml.v3 node trees, shared by the editor's editing flow and its validators.
Package presets defines the Source interface for per-field YAML presets and provides a filesystem-backed implementation.
Package presets defines the Source interface for per-field YAML presets and provides a filesystem-backed implementation.
Package schema discovers the editable shape of a Go struct via reflection over yaml/validate/jsonschema tags.
Package schema discovers the editable shape of a Go struct via reflection over yaml/validate/jsonschema tags.
Package theme provides the palette, base lipgloss styles, and shared layout primitives used across yedit-built TUIs.
Package theme provides the palette, base lipgloss styles, and shared layout primitives used across yedit-built TUIs.
Package viewer is a read-only TUI that browses the presets exposed by a presets.Source.
Package viewer is a read-only TUI that browses the presets exposed by a presets.Source.

Jump to

Keyboard shortcuts

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