Monorepo for PackTools - tooling for Minecraft Modpacks!
packtools apps packlsp docs packfmt.md
15 kB · 236 lines
Markdown

PackFMT #

PackFMT formats Reverie's authored scripts: KubeJS (kubejs/*_scripts/**/*.js) and ZenScript (scripts/**/*.zs). It is part of PackLSP and uses the same parsers, so it runs as packlsp fmt, and Packwand runs it for packwand fmt. Mod configs and JSON are out of scope, since mods rewrite them in their own style.

PackFMT keeps your line breaks. It fixes whitespace, indentation and a few token-level details, and never wraps or joins lines.

House style #

The style follows Reverie's newer scripts (silver-lining), then Prettier, then the Google JavaScript style guide.

Rule KubeJS .js ZenScript .zs Status
Line endings LF LF done
Trailing whitespace removed removed done
Blank lines at most 1 in a row; none at the start or end of the file same done
Final newline exactly one exactly one done
Indentation 4 spaces, by nesting; continuation lines +1 (below) 4 spaces, the same way done
Quotes single, unless double needs fewer escapes unchanged (CraftTweaker style) done
Semicolons removed where that is safe (see below) kept (CraftTweaker requires them) done
Comments //x → // x //x → // x done
Brackets { a }, [a] on one line — done
Arrow functions event =>, not (event) => — done
Spacing between tokens on one line (below) — done
Trailing commas kept as written, never added (KubeJS 6 rejects some) — done
Comment blocks at most 5 lines (reported, not fixed) same done
Line length 120 (reported, not fixed) same done

Indentation #

Only leading whitespace changes. Line breaks stay where you put them.

  • Inside brackets, a line sits one level past the line holding its innermost open bracket. So foo({ on one line opens a single level.
  • A line starting with a closer (}, ), ]) lines up with the line of its opener.
  • A continuation line sits one level past the line its statement, argument, property or element starts on. Examples are a chained .call(), a condition broken before &&, the ? and : of a ternary, or a value on the line after =. It never drifts further right, however long the chain.
  • A { that follows ) or ) => lines up with the line of that (. So the body of an if whose condition spans lines sits one level past the if.
  • else, catch and finally line up with their statement. A body without braces on its own line is a continuation. Statements under case sit one level past it.
  • An own-line comment takes the indent of the next line of code, or the inner level when that line is a closer. The lines inside a block comment move with its first line, as long as every one of them is indented at least as far. Otherwise the comment is left as written.
  • Lines starting inside a string, template literal or regex keep their whitespace, and so do lines in an ignored region. Tabs in indentation become spaces; tabs later in a line stay.
  • ZenScript follows the same rules. Its items are judged from tokens: a token after ;, ,, {, } or an open bracket starts an item. Preprocessor lines (#priority) go to column 0.
  • A file that does not parse is not re-indented at all.

Text inside strings, template literals and regex literals is never changed, apart from line endings in templates (JavaScript itself reads CRLF there as LF). Block comments keep their blank lines.

PackFMT never changes what a // comment says. KubeJS reads a property from any // line of the form key: value, so rewording or moving one could change how a script loads.

Spacing #

These rules apply between two tokens on the same line with no comment between them:

  • One space:
    • around binary and assignment operators, =>, and a ternary's ? and :;
    • after ,, ; and an object's or case's :;
    • inside { … } on one line (objects follow bracketSpacing);
    • in if (, for (, while (, switch (, catch (, function (, ) { and } else.
  • No space:
    • inside ( … ) and [ … ];
    • before ,, ; and :;
    • around . and ?.;
    • after ... and a unary operator (-a, !b, ++i);
    • before a call's ( and a named function's or method's (.

A space is never removed where the two tokens would merge into one (- -a stays as written), and 1 .toString() keeps its space. Template literals, including ${ … }, are left alone.

Semicolons #

A statement's ; is removed only when all of these hold:

  • The next token is on a later line, or is }, or the file ends there.
  • The next token is not one of these 10, which would join the two statements:
    1. ( open paren
    2. [ open bracket
    3. ` backtick: a template literal or tagged template
    4. + plus
    5. - minus
    6. / slash
    7. /= divide-assign
    8. ++ increment
    9. -- decrement
    10. < less-than
  • It does not end an empty statement or sit in a for (;;) header.

Safety #

PackFMT checks every result before writing it. If any check fails, the file is left as it was and PF0003 is reported.

  1. For a script that parses, the syntax tree is the same as before. It is flow's ESTree output, compared without locations, comments or raw literal text, so a quote change, a dropped ; or dropped parentheses compare equal, and anything that changes what the code does fails. For ZenScript, and for scripts that do not parse, the tokens must be the same apart from whitespace.
  2. Every comment has the same text, apart from whitespace (//x and // x count as the same).
  3. The KubeJS header properties are the same.
  4. Formatting the result again changes nothing.

To check PackFMT itself on real scripts:

  • scripts/fmt-corpus.sh <dir>... runs fmt --verify and fails on any refusal.
  • node scripts/fmt-stress.mjs <dir>... does the same over thousands of mutated copies. Each copy either has random lines deleted, or has lines starting with each semicolon hazard inserted after a ;.
  • --mutant-check reverses the stress run: it expects refusals, which proves the safety net catches a broken build.

A file with a syntax error only gets the whitespace rules, and PF0002 says so. That includes Rhino-only syntax that the JavaScript parser cannot read, such as for each.

Style checks #

Some rules can't be fixed without rewording or restructuring code, so PackFMT reports them instead. They show up in the editor and with packlsp check --style, never in packlsp fmt, so packwand fmt --check doesn't fail on them. diagnostics.md lists each code and its default severity:

  • PF0101 comment block over 5 lines (commented-out code counts, and a quick fix deletes a block that is only code)
  • PF0102 line over 120 characters
  • PF1001 var
  • PF1002 == and !=, off unless you enable it
  • PF1003 a braceless body on its own line
  • PF1004 no header comment
  • PF1005 a top-level constant not in UPPER_SNAKE_CASE, with a quick fix that renames it across the script folder
  • PF1006 KubeJS template residue, with a quick fix that deletes it
  • PF1007 a header setting after the code

Change any code's severity, or turn it off, through .packlsp.json "severity" or the packlsp.diagnostics.severity setting. Naming PF1002 in either one switches it on.

Skipping code #

Comment Effect
// packfmt-ignore-file Before the first line of code: the whole file is left alone.
// packfmt-ignore The next line is left as written.
// packfmt-ignore-start … // packfmt-ignore-end Everything between is left as written (line endings still become LF).
// packfmt-disable-next-line PF0101 Do not report these codes on the next line.

In ZenScript, # comments work too.

Settings #

Override the house style in the pack's .packlsp.json, under "format". Any key that is left out keeps its default. A bad value is reported as PF0004 and the default is used.

{
  "format": {
    "enabled": true,           // false: packlsp fmt skips this pack
    "indent": 4,
    "quotes": "single",        // or "double"
    "semicolons": "remove",    // or "preserve"
    "arrowParens": "avoid",    // or "preserve"
    "bracketSpacing": true,
    "endOfLine": "lf",         // or "preserve"
    "lineLength": 120,
    "maxCommentLines": 5,
    "maxBlankLines": 1,
    // Globs relative to the pack root; * stays in one folder, ** crosses folders.
    "ignore": ["kubejs/server_scripts/advjs_server.js"],
    "zenscript": { "indent": 4 }
  }
}

In the editor #

The same applies in IntelliJ IDEA through the LSP4IJ template (guide.md), where it's Reformat Code.

Format Document and Format Selection use PackFMT for KubeJS scripts and ZenScript files. Other formatters keep JSON and the rest. A CRLF file is switched to LF when the pack's endOfLine is lf. A result PackFMT refuses changes nothing and is logged in the PackLSP output channel.

To format on save, set PackLSP as the formatter in the modpack repository's .vscode/settings.json. editor.defaultFormatter can't be limited to a folder, so this belongs in the repository, not in your user settings:

{
  "[javascript]": { "editor.defaultFormatter": "reverie-projects.packlsp", "editor.formatOnSave": true },
  "[zenscript]": { "editor.defaultFormatter": "reverie-projects.packlsp", "editor.formatOnSave": true },
  "files.eol": "\n"
}

Style checks show up as diagnostics. Turn them off with packlsp.format.styleDiagnostics, or change single codes with packlsp.diagnostics.severity. PF1006's quick fix deletes the template line. Agents get the same edits from the MCP format tool.

Command line #

packlsp fmt <path>...                         # format in place
packlsp fmt --check <path>...                 # report files that would change; exit 1 if any
packlsp fmt --verify <path>...                # run the safety checks only; exit 1 if any result is refused
packlsp fmt --json ...                        # one JSON report (below)
packlsp fmt --strict ...                      # warnings also fail
packlsp fmt --stdin-filepath <path> < in.js   # stdin to stdout; <path> picks the language and the pack

A folder is searched for the scripts KubeJS loads (kubejs/{startup,server,client}_scripts) and for every .zs file. A file named directly is formatted whenever it is .js or .zs. Files are replaced atomically, and unchanged files are not touched.

Exit status: 0 on success; 1 when a file would change (--check), a result was refused, or an issue is an error (a warning too with --strict); 2 for usage errors.

JSON report #

{
  "schema": 1, "tool": "packfmt", "version": "26.10.2", "mode": "check",
  "checked": 3, "changed": 1, "failed": 0,
  "files": [{"path": "pack/kubejs/server_scripts/a.js", "status": "would_format", "edits": 2}],
  "issues": [{"severity": "warning", "code": "PF0001", "path": "pack/kubejs/server_scripts/a.js",
              "message": "File is not formatted (2 changes); run packlsp fmt.",
              "remedy": "run packlsp fmt", "remedy_subject": "pack/kubejs/server_scripts/a.js",
              "position": {"line": 1, "column": 1}}]
}
  • mode: write, check or verify.
  • files[].status:
    • unchanged
    • formatted (written)
    • partial (written, whitespace only)
    • would_format
    • refused
    • skipped (with a reason)
  • issues: Packwand's content-lint issue shape, plus code. Lines and columns are 1-based; columns are UTF-8 bytes.
  • error: appears on a fatal error, such as a missing path or an unknown option. It is {"kind": "not_found" | "invalid_request" | "operation_failed", "message": …}, and files and issues are then empty.

Packwand integration (handoff spec) #

packwand fmt belongs to Packwand (Zig, packwandzig), but all formatting logic lives in PackLSP. Packwand finds the packlsp binary and reads its JSON report. This section is the contract for that work.

  • Command. packwand fmt [PATH...] [--check] [--strict] [--json] [--packlsp <bin>]

    • Lives in src/packwand_cli/fmt_command.zig, modelled on minify_command.zig.
    • Effects row .mutate.
    • Ledger entry D-ZIG-FMT-1. AGENTS.md requires approval before adding a Zig-only command.
    • PATH defaults to [fmt] paths in mpm-config.toml, falling back to modpacks. PackLSP does the walking.
  • Finding the binary. mpm_infra/files/PacklspSidecar.zig, after PackeaterSidecar.zig, looks in this order:

    1. --packlsp
    2. PACKWAND_PACKLSP_BIN
    3. packlsp[.exe] next to the packwand executable
    4. PATH
    5. the binary inside the newest installed PackLSP editor extension (~/.vscode-oss/extensions or ~/.vscode/extensions, reverie-projects.packlsp-<version>), so editor users need no setup

    If none exists, it fails with operation_failed, naming every place it tried. A binary from before PackFMT (it answers fmt with its usage text) fails naming that binary.

  • Running it. Packwand builds the argv directly, without a shell, and always adds --json: packlsp fmt --json [--check] [--strict] <paths>, run in the -C directory. Tests stand in shell scripts for packlsp.

  • Reading the report.

    • stdout must be one object with schema == 1, tool == "packfmt" and a version at or above Packwand's minimum.
    • error.kind maps as for Packeater: not_found and invalid_request become invalid_request; anything else becomes operation_failed.
    • issues map straight onto mpm_api/diagnostics/issue.zig.
    • Each would_format file becomes an error issue, "File is not formatted", with remedy "run packwand fmt".
    • Output that does not parse becomes operation_failed, with the tail of stderr.
    • --check ends with "N file(s) require formatting" and exit 1, like minify --check.
  • mpm-config.toml [fmt] (not done): keys such as packlsp_bin and paths would replace Packwand's contract tests that count config keys, so they wait. The style itself stays in each pack's .packlsp.json.

  • doctor. An optional packlsp row shows where fmt would find the binary (D-ZIG-FMT-2). The packeater row now finds packeater-sidecar where builds do (D-ZIG-DOCTOR-1, its own change).

  • monorepo3. PackFMT is an optional editor tool, so no CI step fetches or runs it. What remains is optional: a one-time formatting commit, .gitattributes *.js/*.zs text eol=lf, and one manual boot of a formatted 1.20.1 pack, to confirm Rhino reads scripts without semicolons the same way.