Formatting GitHub issue

vibecode
{"vibecode": {
    "doc": "formatting",
    "role": "canonical doc on how formatting works across the Puck ecoverse — philosophy, style.json structure, options, and the tools that consume it",
    "scope": "Caspian source, JSON, future per-language formatters, markdown code fences, file-system conventions",
    "status": "living — options accumulate as the formatter is built",
    "consumed_by": ["VS Code Caspian extension", "Gitter format toggle", "Differ normalization layer"]
}}

Formatting in the Puck ecoverse is a personal concern, not a project policy. This doc covers the philosophy, the on-disk style.json format, the catalogue of options known so far, the conventions Miko personally uses, and the tools that act on the file.

Philosophy GitHub issue

vibecode
{"vibecode": {
    "section": "philosophy",
    "model": "personal_formatter_not_team_policy",
    "no_canonical_style": true,
    "no_project_level_config": true,
    "social_contract": "run_formatter_before_complaining_about_formatting"
}}

There is no canonical style for formatting Caspian. Instead, we developed a VS Code extension so that you can easily format code to your own preference.

The rules are simple:

Let's keep bickering about tabs and spaces out of our community.

The style.json file GitHub issue

vibecode
{"vibecode": {
    "section": "style_file",
    "location": "~/.config/caspian/style.json",
    "scope": "personal_not_project",
    "format": "strict JSON",
    "structure": "three top-level groups: indent, lines, languages"
}}

Personal style lives in ~/.config/caspian/style.json. JSON keeps it consistent with the rest of the Puck ecosystem (CJS, vibecode blocks, Mikobase records, Puck wire format are all JSON) and avoids pulling in a separate parser. The file may carry a top-level vibecode block or comment field for human notes (standard fields).

The format is strict JSON — no trailing commas, no # comments. Human notes go in the standard vibecode / comment fields.

Structure GitHub issue

Three top-level groups: indent, lines, languages. Universal settings (the first two groups) apply to every language the formatter handles. Per-language overrides sit under languages, keyed by language name. Per-language wins on conflicts — Python's 88-column max_length overrides the global 100; JSON's spaces override the global tabs.

json
{
  "indent": {
    "character": "tab",
    "width": 4
  },

  "lines": {
    "max_length": 100,
    "trim_trailing_whitespace": true,
    "empty_line_treatment": null,
    "max_consecutive_blank_lines": 1,
    "final_newline": false,
    "blank_line_around_blocks": true
  },

  "languages": {
    "caspian": {
      "class_body_packing": "tight",
      "empty_param_parens": true,
      "bareword_call_parens": "omit",
      "hash_spacing": "tight",
      "return_parens": true,
      "vibecode_placement": "top_of_section"
    },
    "json": {
      "indent": {
        "character": "space",
        "width": 2
      }
    },
    "python": {
      "indent": {
        "character": "space",
        "width": 4
      },
      "lines": {
        "max_length": 88
      }
    }
  }
}

The exact option set is defined as the formatter is implemented. Options known so far are documented below.

Universal options GitHub issue

indent GitHub issue

json
"indent": {
  "character": "tab",
  "width": 4
}

indent.character GitHub issue

The indent character. Accepts "tab" / "tabs" / "space" / "spaces" — singular and plural forms both work, so writers don't have to think about it.

indent.width GitHub issue

Numeric. Polymorphic on character:

Same field, different consumers: spaces-mode talks to the formatter, tabs-mode talks to the renderer.

lines GitHub issue

json
"lines": {
  "max_length": 100,
  "trim_trailing_whitespace": true,
  "empty_line_treatment": null,
  "max_consecutive_blank_lines": 1,
  "final_newline": false
}

lines.max_length GitHub issue

Soft wrap target in characters. Common values: 80, 88 (Black), 100, 120. Effect depends on the language's formatter: auto-wrapping formatters (Prettier, Black, gofmt's -l) enforce it; non-wrapping formatters treat it as a hint or use it as a ruler value only.

lines.trim_trailing_whitespace GitHub issue

Boolean. When true, trailing whitespace on populated lines is stripped — $foo.bar \n becomes $foo.bar\n.

lines.empty_line_treatment GitHub issue

How to treat empty lines (lines with only whitespace).

Note: "neighbors" causes generic trim_trailing_whitespace tools (editors, linters, .editorconfig) to fight the formatter. The Caspian formatter is the source of truth; other tools defer to it. Diff noise on whitespace changes is accepted — GitHub's diff renderer handles it fine.

lines.max_consecutive_blank_lines GitHub issue

Integer cap. Two or more consecutive blanks collapse to this count. Typical value: 1.

lines.final_newline GitHub issue

Boolean. When false, the file ends with the last meaningful character — no trailing newline. When true (the common Unix convention), one newline is enforced at EOF.

The Puck formatter also strips trailing empty lines from the end of the file regardless of final_newline's value; the setting only controls whether one newline gets added back.

lines.blank_line_around_blocks GitHub issue

vibecode
{"vibecode": {
    "option": "lines.blank_line_around_blocks",
    "type": "boolean",
    "effect": "every multi-line block construct gets a blank line before and after, except at parent boundaries"
}}

When true, every multi-line block construct — control-flow blocks (if, for, while, do), nested function or class definitions, and any other multi-line statement that opens with a header and closes with an end-marker (end in Caspian/Lua, } in C-family languages, indentation drop in Python) — gets a blank line before it and a blank line after it.

Exceptions:

Single-line statements pack together — the rule visually distinguishes "blocks of logic" from "linear statements." Applies to any language whose grammar has multi-line block constructs.

Example (Caspian, function &name from puck.uno/color):

caspian
function &name
    $h = .hex

    %bucket['named'].each do($n, $named_hex)
        if $named_hex == $h
            return ($n)
        end
    end

    return (null)
end

The each…end block is multi-line and sits between two single-line statements, so it gets blank lines on both sides. The first statement ($h = .hex) has no blank line before it (first in parent), and the last statement (return (null)) has no blank line after it (last in parent).

Example (Lua):

lua
local function list_md_files()
    local files = {}

    if io.open("README.md", "rb") then
        files[#files + 1] = "README.md"
    end

    local handle = io.popen('find documentation -type f -name "*.md" 2>/dev/null')

    if handle then
        for line in handle:lines() do
            files[#files + 1] = line
        end

        handle:close()
    end

    table.sort(files)
    return files
end

Same rule: each if … end and the nested for … end are blocks, so they get blank lines on both sides. The two trailing single-line statements (table.sort(files) and return files) pack together.

Terminology: blank vs empty GitHub issue

Two distinct concepts:

Blank ⊂ empty. All blank lines are empty; not all empty lines are blank.

Caspian-specific options GitHub issue

These appear under languages.casp in style.json. (For the cross-language blank_line_around_blocks rule, see lines.blank_line_around_blocks under Universal options.)

class_body_packing GitHub issue

"tight" packs class bodies like function bodies: no blank line between the class opening and the first body member; no blank line between the last member's end and the class's closing end. The same rule applies uniformly — class bodies are not visually framed.

empty_param_parens GitHub issue

Boolean. When true, function definitions include empty parens even when the parameter list is empty. Caspian's parser accepts both forms; this preference makes the parens explicit so every function definition has the same visual shape.

function &to_hash()    # empty_param_parens: true
function &to_hash      # empty_param_parens: false (also accepted by the parser)

Applies to definitions only; call sites are unaffected.

bareword_call_parens GitHub issue

"omit": bareword call sites drop the parens around their arguments. "always": parens are required at every call site. Caspian's parser accepts both forms — this preference picks one representation.

puts 'Aye, captain'         # bareword_call_parens: "omit"
puts('Aye, captain')        # bareword_call_parens: "always"

puts $loop.count            # "omit"
puts($loop.count)           # "always"

Applies to bareword calls only (e.g. puts, raise, catch). Method calls ($h.foo(x)) and function-reference calls (&foo(x)) are unaffected; both keep their parens regardless.

hash_spacing GitHub issue

"tight": {lazy: true} — no space after { or before }. "loose" would be { lazy: true }.

return_parens GitHub issue

Boolean. When true, return values are wrapped in parens — return ($x) rather than return $x.

vibecode_placement GitHub issue

Where %vibecode heredocs sit. "top_of_section": immediately after the heading, before any prose intro.

HTML-specific options GitHub issue

These appear under languages.html in style.json. HTML inherits the universal indent setting (tabs by default for Miko).

blank_line_around_block_tags GitHub issue

vibecode
{"vibecode": {
    "option": "blank_line_around_block_tags",
    "type": "boolean",
    "effect": "transitions between block-element runs and inline-element runs get a blank line between them"
}}

When true, sibling HTML elements get a blank line at the boundary between a block-tag run (multi-line elements like <details>, <div>, <section>, <ul>) and an inline-tag run (single-line elements like <a>, <span>, <img>). Same-kind siblings (two inlines in a row, two blocks in a row) get no blank between them; the transition is what carries the blank.

Mirrors the spirit of lines.blank_line_around_blocks: visually distinguishes block-of-markup from a run of inline siblings.

Example:

html
<details class="dropdown">
    <summary>
        <a href="/products">Products</a>
        <span class="chevron"></span>
    </summary>

    <a href="/products/widgets">Widgets</a>
    <a href="/products/gadgets">Gadgets</a>
</details>

Inside <summary>, the two inline siblings (<a>, <span>) pack together. After </summary> (a block) and before the two <a> (inline), there's a blank line. The two <a> siblings then pack together — same kind.

Markdown and file conventions GitHub issue

These are formatting decisions that apply to the project's docs and file layout rather than to executable code.

Code fences GitHub issue

Vibecode blocks in markdown GitHub issue

Nest concepts under headings, not bullet lists GitHub issue

When the items in a list are distinct concepts that a reader might want to comment on, file an issue against, or anchor a link to, give each one its own heading at the appropriate level (H3 / H4 / H5 / H6) rather than a bullet. Orlando (and post-V1 Gitter) injects a "GitHub issue" / "Quick add" panel onto every heading H2 through H6, but bullet items have no anchor and no link surface. Distinct items deserve headings.

Plain prose paragraphs stay plain prose. Tight enumerations where the items aren't distinct enough to merit headings (e.g. "values: 80, 88, 100, 120"), code-flag tables, citation lists, and other reference-style lists are fine as bullets or tables — they aren't "concepts" in the sense that needs per-item discussion.

Headers GitHub issue

Naming GitHub issue

UNS-lookup short form GitHub issue

Tools that consume style.json GitHub issue

The same style.json is the single source of truth across all surfaces:

Tool Status Description
VS Code Caspian extension V1 (separate project) Syntax highlighting + Format Document command. The extension contains no parser — it shells out to caspian fmt per invocation. Lives in its own repo at caspian-vscode.
caspian fmt subcommand V1 (TBD slice) Command-line formatter on the existing caspian CLI. Reads source on stdin, writes formatted source on stdout, applies the user's style.json. Powers the VS Code Format Document command and any other shelling tool.
Gitter format toggle TBD Per-language formatting on code blocks in any rendered file. See gitter.md.
Differ normalization TBD Diff normalization layer for Caspian-aware diff service. See differ.md.
caspian lint TBD Optional linter (separate from formatting).

The caspian fmt subcommand is the canonical formatter. Other tools that need formatted Caspian (the VS Code extension, Gitter, Differ) shell out to it rather than each running their own parser/formatter pipeline.

For Caspian specifically, formatters are CJS-in / Caspian-out generators on top of the canonical Caspian→CJS transpiler — no separate Caspian parsers in any tool. This depends on CJS preserving comments and %vibecode heredocs as first-class nodes (issue #56).

Parse-fail behavior GitHub issue

When a tool can't parse the input — syntax errors, work-in-progress code, an unknown construct, or a language we haven't built a formatter for — it falls back to plain text rendering. No errors, no half-broken highlighting, no partial reformatting. The principle is uniform across languages and tools: if we can't figure out how to display it cleanly, just output the text.

Open questions GitHub issue

Unresolved formatter rules. The list shrinks as decisions land; answers move into the option sections above.

  1. Blank lines between method definitions inside a class. With "no consecutive blanks" already settled, the two-blanks-between-methods option is ruled out. Remaining choice: one blank between method and its attached %vibecode heredoc, then one blank between the heredoc and the next method (heredoc sits between, padded on both sides) — OR the heredoc attaches to the function below it with no blank between (only one blank separating consecutive methods).
  2. Multi-line heredocs (%vibecode <<EOF … EOF) — do they count as "multi-line blocks"? The current blank_line_around_blocks rule mentions end-terminated constructs explicitly. Heredocs use EOF. Treat them as blocks (blank-line padded) or as multi-line statements (no special padding)?
  3. Blank lines around field declarations. Currently they pack tightly. When (if ever) should a blank line break them into groups?
  4. Comment-line treatment. When a # comment immediately precedes a block, does the comment "belong to" the block (no blank between them; the blank goes above the comment) or to the surrounding flow?

Revisit at V1: own format vs. adopting an existing one GitHub issue

We surveyed open-source formatting-config formats before deciding to build our own; none currently checks every box we need (viewer-side, multi-language with real inheritance, hand-editable JSON, room for Caspian-specific rules). Candidates looked at: EditorConfig, dprint, Biome, Prettier, rustfmt, clang-format, Topiary, Treefmt.

The decision: build our own for now. Revisit close to V1 — by then the format will have been exercised across the VS Code Caspian extension, Gitter, and Differ, and we'll know which warts matter. Options at that point:

The current spec stays the working format until that revisit.

Example: Miko's preferences GitHub issue

The values Miko personally uses. Not project policy — see Philosophy above — but a worked example and the source of truth for the formatter's recommended defaults.

Canonical source: miko.json (the actual file the tooling reads). The snippet below is for reading; the file is what runs.

json
{
  "indent": {
    "character": "tab",
    "width": 4
  },

  "lines": {
    "max_length": 100,
    "trim_trailing_whitespace": true,
    "empty_line_treatment": null,
    "max_consecutive_blank_lines": 1,
    "final_newline": false,
    "blank_line_around_blocks": true
  },

  "languages": {
    "caspian": {
      "class_body_packing": "tight",
      "empty_param_parens": true,
      "bareword_call_parens": "omit",
      "hash_spacing": "tight",
      "return_parens": true,
      "vibecode_placement": "top_of_section"
    }
  }
}
Pairing notes: - trim_trailing_whitespace: true plus empty_line_treatment: null means empty lines get stripped to blank as well — one decision, two consistent behaviors. - No per-language overrides for JSON — JSON gets tabs like everything else, inheriting the universal indent. - indent.width: 4 with character: "tab" is a display hint — the file gets one tab per level regardless; the 4 tells renderers and editors how wide to draw each tab.

© 2026 Puck.uno