Search by

rmb32 / barntree

rogerbarnfather

Keep a tree of things valid against the kinds of thing it may hold

Package info

bitbucket.org/rmb32/barntree

pkg:composer/rmb32/barntree

Statistics

Installs: 38

Dependents: 14

Suggesters: 0

v0.1.1 2026-11-10 22:05 UTC

This package is auto-updated.

Last update: 2026-11-11 00:05:27 UTC


README

Early, version 0.x. Not ready for real projects yet; the minor version is the breaking one.

Keep a tree of things valid against the kinds of thing it may hold.

A Tree names the types of thing in one subject, and holds a tree of items that follow them. A type is a leaf, a list of one type or a map of several. A dinner party is a map of guests, courses and a shopping list. The guests are a list, and each guest is a leaf. Barntree builds the tree from the types, and refuses any document or edit that would break them.

A Tree can be about anything: the layers of a software system, the rooms of a house, the modules of a course or the courses of a dinner. It is kept as one plain JSON document, so any language can read it.

Templates, Constructions and saved revisions

Use a Template to define a reusable structure, and a Construction for a named tree of real members made from it. Every explicit save creates an immutable revision; unchanged content creates none. Templates and Constructions each have their own history. A Construction retains its source revision and its own copy of the definitions, so later Template edits cannot alter it.

use Rmb32\Barntree\Api\Factory\RevisionWorkspaceFactory;
use Rmb32\Barntree\Api\Factory\TemplateHistoryStoreFactory;
use Rmb32\Barntree\Api\ValueObject\DocumentKind;
use Rmb32\BarnPaths\Api\ValueObject\BarnPaths;

$paths = BarnPaths::at('/my/project');
$templates = new TemplateHistoryStoreFactory()->forBarn($paths);
$opened = $templates->checkout('My template');
$saved = $templates->saveTemplate('My template', $opened->content, $opened->token);
$template = $saved->revision;
$workspace = new RevisionWorkspaceFactory()->forBarn($paths);
$constructionDraft = $workspace->newConstruction($template->documentId, 'My project');
$construction = $workspace->save($constructionDraft);
$history = $workspace->history(DocumentKind::Construction, $construction->documentId);

Starting and editing drafts writes nothing. Edit draft->content with the existing immutable Tree operations and pass the result to draft->withContent($edited). Call save() when ready. Dropping a draft cancels it; revision->draft() starts again from a saved state. A stale base is refused instead of overwriting a newer save. A save may carry a reason, saveTemplate($name, $content, $token, 'Why'), which the revision returns as reason. To keep a catalogue apart from the barn's, open it with inDirectory('/my/folder') on either factory instead of forBarn(). TemplateHistoryStoreFactory::lockFiles() lists the lock files a catalogue writes, as patterns relative to its folder (template-history/save.lock, ...), to keep out of version control. Existing Template files and saved item identities are preserved. New Constructions start from their source’s Skeleton with empty lists. Construction edits keep their captured root, type definitions and tags while allowing valid members.

The GUI gives Templates and Constructions their own Tree, Overview and History views. Selecting a building block opens its details; explicit edits use Save and Cancel. Templates support structural editing, while Constructions add named list members within their captured rules. The CLI currently provides init, check, export and the optional GUI launcher; it does not yet expose Construction or revision-management commands.

Existing current-file readers continue to work. Checking out an existing Template through the history store records its saved baseline once, including when opened in the GUI. A later changed save records a new revision; unchanged saves add none. See the worked example and storage design.

Requirements

  • PHP 8.5+
  • The ds extension, ^2.0
  • The mbstring extension

Installation

composer require rmb32/barntree

Sixty seconds

Name the types. Barntree builds the tree of items that follows them, and edits it only in ways that keep every rule (examples/quickstart.php, run by this package's own tests):

use Rmb32\Barntree\Api\Factory\TreeEditorFactory;
use Rmb32\Barntree\Api\Factory\TreeFactory;
use Rmb32\Barntree\Api\Factory\TextProjectorFactory;
use Rmb32\Barntree\Api\ValueObject\ItemName;
use Rmb32\Barntree\Api\ValueObject\ItemPath;

$tree = new TreeFactory()->create([
    'formatVersion' => 1,
    'name' => 'Shop',
    'description' => 'A shop organised around its business.',
    'root' => 'Project',
    'types' => [
        'Project' => [
            'name' => 'src',
            'meaning' => 'The whole codebase',
            'kind' => 'map',
            'of' => ['Kernel', 'Contexts'],
        ],
        'Kernel' => [
            'name' => 'Kernel',
            'meaning' => 'What every context shares',
            'kind' => 'leaf',
        ],
        'Contexts' => [
            'name' => 'Context',
            'meaning' => 'The bounded contexts, one per area of the business',
            'kind' => 'list',
            'of' => 'Context',
        ],
        'Context' => [
            'name' => 'Context',
            'meaning' => 'One bounded context',
            'kind' => 'map',
            'of' => ['Domain', 'Application'],
        ],
        'Domain' => [
            'name' => 'Domain',
            'meaning' => 'The rules of the business',
            'kind' => 'leaf',
        ],
        'Application' => [
            'name' => 'Application',
            'meaning' => 'What the context can be asked to do',
            'kind' => 'leaf',
        ],
    ],
]);

$editor = new TreeEditorFactory()->create();
$text = new TextProjectorFactory()->create();
$contexts = new ItemPath('Context');
$tree = $editor->addMember($tree, $contexts, new ItemName('Payment'));
$tree = $editor->addMember($tree, $contexts, new ItemName('Marketing'));
echo $text->project($tree);

$marketing = new ItemPath('Context', 'Marketing');
$tree = $editor->removeMember($tree, $marketing);
echo PHP_EOL, $text->project($tree);
src
|
+--Context
   |
   +--Payment
      |
      +--Application
      +--Domain
   +--Marketing
      |
      +--Application
      +--Domain
+--Kernel

src
|
+--Context
   |
   +--Payment
      |
      +--Application
      +--Domain
+--Kernel

The model

Types

Each type has a key, which other types refer to it by, a name for people to read, a meaning that explains what it is, and a kind. A meaning should be clear and concise, with enough detail for humans and AI to understand the tree. Use as many sentences or paragraphs as needed.

The kinds are:

  • a leaf is of nothing. A kitchen is a leaf.
  • a list is of exactly one type. Bedrooms are a list of Bedroom.
  • a map is of a set of types, each at most once. A house is a map of Garden, Ground floor and Upstairs. Map children are alphabetical by display name, ignoring case, with the type key breaking ties. List members keep their sequence.

Existing documents remain readable. Reading, editing and writing a Tree puts map references and entries into alphabetical order without changing item IDs or list-member sequences. The document format remains version 1.

The Tree names its root type, which can be any kind. Types refer to each other only by key, and one type can be used in many places. A type that holds itself through maps alone is refused, because an item of it could never end.

Tags

A Tree can declare tags, each with a name and a meaning, and a type can carry some of them. A tag says that types in different places belong together, which the tree on its own can't say: the House tags its Kitchen and Bathroom Plumbed, one on each floor. A type names only declared tags, each once, and every item of the type carries them. A declared tag no type carries is kept. Tags came in 6.2: an older Barntree refuses a document that has them.

Items

An item follows one type, and has an id that Barntree generates and nobody types. What it holds depends on its type's kind:

  • a map item holds one item for each type of its map;
  • a list item holds its members, any number of them, each with a name you give it;
  • a leaf item holds nothing.

Every item is an item of its own, list items included, so a list can be the root and each item is one level of the tree. The root item and the members have names. Any other item takes its type's name. Members of one list have different names, and so do the entries of one map, so a path of names from the root finds at most one item.

The skeleton

A document can leave its tree out, and most hand-written ones do. Reading it then builds the skeleton: the root, and every map entry below it, down to empty lists and leaves, each with a fresh id. Save the Tree once and its ids stay the same from then on.

trees/house.barntree.json is written as types only. Read it, add two bedrooms, and here is the tree, then each item as the document writes it (examples/house.php; it counts ids so the output stays the same, where the default makes 16 random hex characters):

use Rmb32\Barntree\Api\Contract\ItemIdGeneratorInterface;
use Rmb32\Barntree\Api\Factory\TreeEditorFactory;
use Rmb32\Barntree\Api\Factory\TreeSerializerFactory;
use Rmb32\Barntree\Api\Factory\TreeStoreFactory;
use Rmb32\Barntree\Api\Factory\TextProjectorFactory;
use Rmb32\Barntree\Api\ValueObject\ItemName;
use Rmb32\Barntree\Api\ValueObject\ItemPath;
use Rmb32\Barntree\Api\ValueObject\Key\ItemId;

$ids = new class () implements ItemIdGeneratorInterface {
    private int $count = 0;

    public function next(): ItemId
    {
        return ItemId::new('h' . ++$this->count);
    }
};

$house = new TreeStoreFactory()
    ->create(__DIR__ . '/../trees', writable: false, ids: $ids)
    ->read('House');

$editor = new TreeEditorFactory()->create($ids);
$bedrooms = new ItemPath('Upstairs', 'Bedrooms');
$house = $editor->addMember($house, $bedrooms, new ItemName('Front bedroom'));
$house = $editor->addMember($house, $bedrooms, new ItemName('Back bedroom'));
echo new TextProjectorFactory()->create()->project($house), PHP_EOL;

$tree = new TreeSerializerFactory()->create()->serialize($house)['tree'];
echo 'root: ', $tree['root'], PHP_EOL;
foreach ($tree['items'] as $id => $item) {
    echo $id, ': ', json_encode($item), PHP_EOL;
}

$found = $house->find(new ItemPath('Upstairs', 'Bedrooms', 'Back bedroom'));
echo PHP_EOL, 'Upstairs/Bedrooms/Back bedroom is ', $found->id(), PHP_EOL;
House
|
+--Garden
   |
   +--Beds
   +--Shed
+--Ground floor
   |
   +--Hallway [Shared]
   +--Kitchen [Shared, Plumbed]
   +--Living room [Shared]
+--Upstairs
   |
   +--Bathroom [Shared, Plumbed]
   +--Bedrooms
      |
      +--Front bedroom
      +--Back bedroom

root: h1
h1: {"type":"House","name":"House","containing":{"Garden":"h9","Ground floor":"h2","Upstairs":"h6"}}
h2: {"type":"Ground floor","containing":{"Hallway":"h3","Kitchen":"h4","Living room":"h5"}}
h3: {"type":"Hallway"}
h4: {"type":"Kitchen"}
h5: {"type":"Living room"}
h6: {"type":"Upstairs","containing":{"Bathroom":"h7","Bedrooms":"h8"}}
h7: {"type":"Bathroom"}
h8: {"type":"Bedrooms","containing":["h12","h13"]}
h9: {"type":"Garden","containing":{"Beds":"h11","Shed":"h10"}}
h10: {"type":"Shed"}
h11: {"type":"Beds","containing":[]}
h12: {"type":"Bedroom","name":"Front bedroom"}
h13: {"type":"Bedroom","name":"Back bedroom"}

Upstairs/Bedrooms/Back bedroom is h13

A leaf item writes no containing, an empty list writes [], and only the root and the members write a name.

Editing

TreeEditorInterface edits a Tree by the path of names to an item, and returns a new Tree. The one you passed in is unchanged.

  • addMember($tree, $list, $name) adds a member to the list item at $list, with its own skeleton, so you name only the member.
  • removeMember($tree, $member) removes a member and everything below it.
  • renameMember($tree, $member, $name) renames a real list member while keeping its ID, kind and children. Roots and required map children cannot be renamed.
  • changeTypes($tree, $root, $types) gives the Tree new types and fits its tree to them. An item whose type is still the one expected where it sits is kept, with its id and name. A map entry the new types call for is built, with its own skeleton. A member whose type its list no longer holds, and an entry whose type has left its map, are dropped with everything below them. A new root type rebuilds the root under the same name.
  • $tree->find($path) returns the item at a path, and new ItemPath() is the root.

changeTypes decides nothing for you: compare the trees before and after to see what would be dropped, and keep the result only if that's what you want.

Projecting

A projector turns a Tree into something to read, without touching the disk. Four are built in, each a ProjectorInterface with a kind and a label:

KindLabelWhat you get
textTreethe indented tree, each item's tags after its name (TextProjectorFactory makes this one alone)
markdownDocumenta heading per item, with its tags after its name and its type's meaning beneath
folderFoldersa folder per item, each with a README.md
mermaidDiagrama Mermaid flowchart, which GitHub draws as it is

ProjectorRegistryFactory holds them in that order, and with() adds your own. Every projection reads as text: a document as its content, folders as their tree (examples/projections.php):

use Rmb32\Barntree\Api\Factory\TreeEditorFactory;
use Rmb32\Barntree\Api\Factory\TreeStoreFactory;
use Rmb32\Barntree\Api\Factory\ProjectorRegistryFactory;
use Rmb32\Barntree\Api\ValueObject\ItemName;
use Rmb32\Barntree\Api\ValueObject\ItemPath;

$editor = new TreeEditorFactory()->create();
$party = new TreeStoreFactory()
    ->create(__DIR__ . '/../trees', writable: false)
    ->read('Dinner Party');
$party = $editor->addMember($party, new ItemPath('Guests'), new ItemName('Ann'));
$party = $editor->addMember(
    $party,
    new ItemPath('Courses'),
    new ItemName('Tomato soup'),
);

foreach (new ProjectorRegistryFactory()->create()->projectors() as $projector) {
    if ($projector->kind()->value() !== 'text') {
        echo '--- ', $projector->label(), PHP_EOL;
        echo $projector->project($party);
    }
}
--- Document
# Dinner Party

One evening meal for guests.

## Courses

What is served, in order.

### Tomato soup

One dish, served to everyone at once.

#### Recipe

How the dish is made, and how long it takes.

## Guests

Everyone invited.

### Ann

One person coming to eat.

## Shopping list

Bought the day before.
--- Folders
Dinner Party/
├── README.md
├── Courses/
│   ├── README.md
│   └── Tomato soup/
│       ├── README.md
│       └── Recipe/
│           └── README.md
├── Guests/
│   ├── README.md
│   └── Ann/
│       └── README.md
└── Shopping list/
    └── README.md
--- Diagram
flowchart TD
    n1["Dinner Party"]
    n2["Courses"]
    n1 --> n2
    n3["Tomato soup"]
    n2 --> n3
    n4["Recipe"]
    n3 --> n4
    n5["Guests"]
    n1 --> n5
    n6["Ann"]
    n5 --> n6
    n7["Shopping list"]
    n1 --> n7

A name a filesystem won't take as a folder (Why?, a trailing dot, two siblings that differ only in case) is refused with one FolderNameException listing every one. ProjectionWriterFactory writes a projection to a path that doesn't exist yet, or folders into an empty folder. It never overwrites and never merges.

Storage

TreeStoreFactory keeps Trees in a directory, one .barntree.json file each, found by the name inside it. A store can be made read-only, for a catalogue that ships with your code:

use Rmb32\Barntree\Api\Factory\TreeStoreFactory;

$store = new TreeStoreFactory()->create('trees', writable: false);
$store->names();               // Seq of every name, sorted
$party = $store->read('Dinner Party');
$store->delete('Dinner Party');

Storage has the whole story, with a worked example. If you keep Trees somewhere else, TreeSerializerFactory turns a Tree back into its document: json() writes it as text, and serialize() returns the same document as PHP arrays.

When something is refused

A document that doesn't fit the shape is refused with one TreeNotWellFormedException, whose faults lists every problem at once, each with the path that caused it. A document that fits the shape but breaks a rule, or an edit that would break one, is refused with the exception for that rule. Each one extends TreeException, is checked, and carries what it is about as public readonly properties (examples/refusals.php):

use Rmb32\Barntree\Api\Exception\Tree\TreeNotWellFormedException;
use Rmb32\Barntree\Api\Exception\Tree\DuplicateMemberNameException;
use Rmb32\Barntree\Api\Exception\Tree\NotAListException;
use Rmb32\Barntree\Api\Exception\Tree\UnknownTypeReferenceException;
use Rmb32\Barntree\Api\Factory\TreeEditorFactory;
use Rmb32\Barntree\Api\Factory\TreeFactory;
use Rmb32\Barntree\Api\Factory\TreeStoreFactory;
use Rmb32\Barntree\Api\ValueObject\ItemName;
use Rmb32\Barntree\Api\ValueObject\ItemPath;

$trees = new TreeFactory();

try {
    $trees->create(['name' => 'Garden', 'root' => 7]);
} catch (TreeNotWellFormedException $e) {
    foreach ($e->faults as $fault) {
        echo $fault, PHP_EOL;
    }
}

try {
    $trees->create([
        'formatVersion' => 1,
        'name' => 'Garden',
        'description' => 'Ground outside the house.',
        'root' => 'Garden',
        'types' => [
            'Garden' => [
                'name' => 'Garden',
                'meaning' => 'Ground outside the house.',
                'kind' => 'list',
                'of' => 'Pond',
            ],
        ],
    ]);
} catch (UnknownTypeReferenceException $e) {
    echo PHP_EOL, $e->getMessage(), PHP_EOL;
    echo 'Unknown: ', $e->typeKey, ', in: ', $e->referencedBy, PHP_EOL;
}

$editor = new TreeEditorFactory()->create();
$party = new TreeStoreFactory()
    ->create(__DIR__ . '/../trees', writable: false)
    ->read('Dinner Party');
$guests = new ItemPath('Guests');
$party = $editor->addMember($party, $guests, new ItemName('Ann'));

try {
    $editor->addMember($party, $guests, new ItemName('Ann'));
} catch (DuplicateMemberNameException $e) {
    echo PHP_EOL, 'Refused: a second ', $e->name, PHP_EOL;
}

try {
    $editor->addMember(
        $party,
        new ItemPath('Shopping list'),
        new ItemName('Bread'),
    );
} catch (NotAListException $e) {
    echo $e->getMessage(), PHP_EOL;
}
[formatVersion] Value *missing* is not a valid integer.
[description] Value *missing* is not a valid string.
[root] Value 7 is not a valid string.
[types] Cannot be empty and must be filled with a value matching `array<string, array{name: string, meaning: string, kind: 'leaf'|'list'|'map', of?: string|list<string>|null, tags?: list<string>}>`.

Type 'Garden' is of 'Pond', which is not one of the tree's types.
Unknown: Pond, in: Garden

Refused: a second Ann
The item at 'Shopping list' follows 'Shopping list', a leaf, so it can't hold members: only a list item can.

The document

{
    "formatVersion": 1,         the only version there is; any other is refused
    "name": string,             the Tree's name; not blank
    "description": string,      what it is and what it is for; not blank
    "root": string,             the key of the root type
    "tags": {                   optional, in order: the tags types may carry
        "<tag name>": {         not blank
            "meaning": string
        }
    },
    "types": {                  at least one, by key
        "<type key>": {
            "name": string,     not blank
            "meaning": string,
            "kind": "leaf" | "list" | "map",
            "of": string | [string, …]    a list's one type, or a map's types;
                                          left out for a leaf
            "tags": [string, …]           optional: declared tags, each once
        }
    },
    "tree": {                   optional: the skeleton is built without it
        "root": string,         the root item's id
        "items": {
            "<item id>": {
                "type": string,
                "name": string,           the root item and members only
                "containing": {"<type key>": "<item id>", …}   a map item
                            | ["<item id>", …]                  a list item
                                    left out for a leaf
            }
        }
    }
}

What it does not do

It knows nothing of what your Tree is about. A Tree can say that a dinner party has courses; whether tomato soup is a good starter in August is yours to decide.

It keeps no history. An edit returns a new Tree, and when to save it, and whether to keep the old one, is up to you.

It writes nothing you didn't ask it to. A projector returns a projection, the writer writes one only where nothing is, and the store writes only on write() and deletes only on delete().

It also keeps data documents: plain values checked against a small schema. They are separate from Trees (data documents).

Used in Barn

This package is used in Barn, where Barnark keeps a project's folders in step with an architecture template built on it. Learn about Barn.

More docs

Storage · Data documents · Internals · History · Known issues

License

Proprietary. See LICENSE. Copyright (c) Roger Barnfather.