Jennifer style guide
This is the recommended source style for Jennifer programs. jennifer fmt re-emits source in exactly this shape, so anything you write that matches the spec will survive a fmt round-trip unchanged.
The guide is short on purpose: there are only a handful of rules, but they are consistent. If you've used gofmt, prettier, rustfmt, or PSR-12, nothing here will surprise you.
Spacing
- One space around every binary operator:
$i = 1 + 2;, not$i=1+2;. Applies to+ - * / // % < > <= >= == != and orand=. - Unary
-hugs its operand:-5,-$x,-fact($n - 1). No space between the-and the value it negates. - Word-form unary operators take a space:
not $ok, nevernot$ok. Same goes for any other keyword operator the language grows. - One space after
,and;insidefor (...; ...; ...), never before.for (def i as int init 0; $i < 10; $i = $i + 1). - No space inside parentheses:
io.printf("hi"), notio.printf( "hi" ). - One space between a keyword and its
(:if (cond),while (cond),for (...). Function calls don't get this space:io.printf(...). - No space inside
[ ]or{ }literals:[1, 2, 3],{"a": 1, "b": 2},Point{x: 1, y: 2}, not[ 1, 2, 3 ]/{ "a" : 1 }/Point{ x: 1 }. This is uniform - list, map, and struct / enum literals are all tight (a struct literal also binds the brace to its type name:Point{, notPoint {, see Braces). Empty literals are[],{},Name{}. Same rule as(). - No space before
[:$xs[0], not$xs [0]. Index expressions hug their target. - No space inside
[]for the append form:$xs[] = item;, never$xs[ ] = item;or$xs [ ] = item;. Same rule as$xs[0]hugs its target and its brackets. - One space after
:in map literals, never before:{"a": 1}, not{"a" :1}or{"a":1}. - No trailing whitespace on any line.
Indentation
- 4 spaces per level, no tabs. Re-indenting on
}always lands you back on a multiple of 4. - One level per block - method body,
if/elseif/elsebody,whilebody,forbody.
Braces
- 1TBS (one true brace style): opening brace on the same line for everything - methods and control flow alike - separated by one space:
func fact(n as int) {,if ($x > 0) {,else {. (Jennifer uses the uniformly-same-line variant that Java, Go, Rust, and the Linux kernel also use.) }on its own line, except for the} else {/} elseif (...) {cascade, whereelse/elseifcontinues on the same line as the preceding}.fmtalways expands blocks across multiple lines - the canonical form has the opening brace at end-of-line, each body statement on its own indented line, and the closing brace on its own line. Applies uniformly to method bodies, control-flow blocks (if / elseif / else,while,for,repeat,match),try { } catch (e) { }, andspawn { }block expressions. Single-line blocks are still legal source (the parser accepts them), butfmtrewrites them to the expanded form for consistency.- Struct and enum declarations expand to one member per line.
def struct Point { x as int, y as int };reflows to
def struct Point {
x as int,
y as int
};and def enum Shape { Circle, Square }; reflows the same way, one variant per line. A payload-carrying variant keeps its payload tight on the variant's line (Circle{r as int}, - the brace binds to the variant name, like a struct literal):
def enum Shape {
Circle{r as int},
Square{side as int},
Empty
};- Struct / enum literals bind the brace to the type name, tight:
Point{x: 1, y: 2}- no space before{(so the brace reads as bound toPoint, not a block) and no space inside it. Bodies are tight just like map and list literals ({k: v},[1, 2]) - one uniform rule, no exceptions. An empty literal isName{}. - Long literals wrap to one element per line. A struct / enum / map / list literal stays inline while it fits; once its single-line form would pass the 100-column limit (or a struct / map literal has more than a handful of members),
fmtbreaks it with the opening bracket at end-of-line, one element per indented line, and the closing bracket on its own line:
def rule as FirewallRule init FirewallRule{
chain: "forward",
action: "drop",
comment: "block inbound"
};Each container decides independently, so a wrapped list of short maps keeps each map inline on its own line. A list of scalars wraps on width only (a long row of short numbers reads fine on one line).
- Tail keywords cuddle the preceding
}.} else { ... },} elseif (cond) { ... },} catch (e) { ... }, and} until (cond);all keep the trailing keyword on the same line as the closing brace.};(a struct-decl terminator) also cuddles. matcharms each start on their own line - they do not cuddle. Amatchis a flat list of peer arms (aswitch/case, not a nested conditional), so - likeswitch/case/whenin C, Go, Rust, Swift, Kotlin, Ruby, and Python - eachwhen(and theelse) begins its own line at the arm indent. An arm holding a single short statement stays inline (when "idle" { begin(); }); an arm with two or more statements, or one that overflows the line, expands one statement per indented line with its}on its own line. This is the one placeelsestarts a line rather than cuddling a}: the} else {cuddle rule is for anif's conditional tail, and amatch'selseis a case-list arm, not that. Reading down thewhencolumn is the point. Awhenvalue list that overflows the line wraps with each continuation value aligned under the first:
match ($state) {
when "idle" { begin(); }
when longEventName1(),
longEventName2() {
prepare();
handle();
}
else { reject(); }
}Line length
- Soft limit: 100 columns.
fmtkeeps lines under 100 columns where it can without changing meaning. It has two levers: it wraps a literal (struct / enum / map / list) to one element per line (see Braces), and it breaks a binary-operator chain (+,and,or) after the operator. The operator break fills the line first, then hangs the operator at end-of-line and indents the continuation one level deeper - so it breaks at the last joiner whose next operand would still fit, not the first one past the limit:
def body as string init "line one\r\n" +
"line two\r\n" +
"line three\r\n";Source-level line breaks at these joiners are also preserved even when the line would fit under 100 - so the shape above survives a fmt round-trip byte-for-byte. What fmt will not guess at: a long argument list or a deeply nested call has no safe break point, so it stays on one line, and a single token longer than the limit (a long string literal, a URL) stays over it - break those by hand. Because fmt never introduces an over-long line, its output does not add lint L203 (line-too-long) findings that the source did not already have.
Statements
- Every statement ends with
;- no exceptions, including the last statement in a block. - One statement per line. Don't chain multiple statements with
;on a single line. - Blank lines separate logical groups - imports from method definitions, methods from top-level code, distinct steps within a long block. Never more than one consecutive blank line.
Loops
- Declare the iterator variable inside the
forinit, not in the surrounding scope. The variable's lifetime should match the loop's:
for (def i as int init 0; $i < 10; $i = $i + 1) { # preferred
io.printf("%d\n", $i);
}not
def i as int;
for ($i = 0; $i < 10; $i = $i + 1) { # avoid
io.printf("%d\n", $i);
}The loop-local form is self-contained (reading the for line tells you everything about i), keeps the iterator out of the surrounding scope, and matches the for-each shape (for (def x in $coll)) which is always loop-local. The outer-scope form is only justified when you genuinely need the iterator's value after the loop ends - for example, to report which iteration triggered a break in a future language version that adds break. Use it deliberately, not by habit.
- One concern per loop. If the body is more than a screen, consider whether the work belongs in a helper method called from inside the loop.
Names
- Variables, methods, parameters: lowercase or
camelCaseif the name has multiple words. - Structs, enums, and enum variants:
PascalCase-Point,Shape,Circle. The interpreter does not enforce this (a name resolves by what it refers to, not by how it is capitalized), so it is a convention, not a rule. Following it keeps types visually distinct fromcamelCasevalues andUPPERCASEconstants, and sidesteps the one real collision: an all-UPPERCASEtype name would be read as a constant inName.memberposition, so never name a type in all caps. - Constants:
UPPERCASE, with_as a single word separator. The full rule is[A-Z]+(_[A-Z]+)*, up to 64 characters: one or more uppercase chunks joined by single_. Every_must be immediately followed by[A-Z]- no leading, trailing, or consecutive_. Examples:MAX,MAX_RETRIES,HTTP_OK,A_B_C. Digits and lowercase letters are not allowed. - Library names: lowercase, single word where possible (
io,math,strings,meta).
Namespaced calls
Domain libraries are addressed by prefix.name(...). The dot binds tight on both sides, like a method call's (:
- No space around
.:os.platform(), neveros . platform(). - The call parens still hug the callee:
os.platform(), notos.platform (). use lib as alias;is one space on each side ofas:use bio as b;, neveruse bio as b;.
When you alias a library, the canonical name is freed for ordinary identifier use (e.g. you could write func os() { ... } after use os as o;). Don't. Reusing a library's canonical name reads as "this is a call into the library" at first glance, then surprises the reader when it isn't - keep the canonical name out of the user-method pool even when aliasing has technically freed it.
Strings
- Default to
"..."(cooked) for ordinary text:"hello","line\nbreak". The two delimiters are not interchangeable -'...'is raw (no escape processing), so pick by intent, not taste. - Reach for
'...'(raw) when escapes would be noise: a regex ('\d+\.\d+'over"\\d+\\.\\d+"), a Windows path, embedded JSON, or a multi-line block. A raw literal cannot contain a', so a string with an apostrophe uses the cooked form ("it's"). fmtpreserves a literal's exact source spelling - its delimiter, its escapes, and any line breaks inside it - so a deliberately raw or multi-line string survives a format untouched (a formatter must not alter a literal's value or shape).- A string literal of either form may span multiple lines (a newline between the quotes is part of the value), which is handy for an embedded template or sample;
fmtkeeps it as written rather than collapsing it.
Interpolation slots
- A cooked
"..."interpolates{expr}; a raw'...'never does. Prefer interpolation oversprintfconcatenation when you are placing a value beside its label:"user {$name} has {$count} items"reads better than a%s/%dtemplate whose arguments trail off the end. - Keep a slot trivial - a variable (
{$x}), a constant ({MAX}), a field or index access ({$p.name},{$xs[0]}), or arithmetic ({$a + $b}). These read at a glance and have no hidden cost. - Do not hide a call in a slot.
"total {computeTotal($cart)}"buries a method call - with its cost and any side effect - inside what looks like a string literal. Compute it first (def total ...; "total {$total}").lintflags a call in a slot as L204. - A literal brace is
\{/\}in a cooked string; if a string is mostly literal braces (JSON, a{{ }}template), reach for a raw'...'string instead of escaping every brace.
Comments
# line commentfor short notes that belong on or just above the thing they describe. The very first line may be a shebang (#!/usr/bin/env -S jennifer run); the lexer treats it as a comment./* block comment */for longer commentary that doesn't fit one line. Block comments nest, so wrapping a chunk of code that already contains a block comment in another/* ... */works.- Inline block comments inside
(,[, or after.get a space on the operand side.printf(/* note */ $x), notprintf(/* note */$x). This is a deliberate exception to the "no space inside()" rule above:*/$xruns together visually and is harder to read than the spaced form. The comment hugs the opening delimiter (no space between(and/*) so only the operand side picks up the space. Same rule for[/* note */ 0]and$obj./* doc */ field. - Comments explain why, not what. The code already says what.
Doc comments
Document every public func, def struct, and def const with a doc comment, and open a file with a module preamble. A doc comment is a block comment that opens with exactly /** (a plain /* stays an ordinary comment), sits immediately above the construct it documents, and holds a summary line, an optional description, and @-tags. This is the format the docblock module parses, so your docs are machine-readable, not just prose.
/**
* Distance between two points.
* A longer description can follow the summary line.
* @param ax {float} first x coordinate
* @param ay {float} first y coordinate
* @return {float} the Euclidean distance
* @since 0.9
*/
export func distance(ax as float, ay as float, bx as float, by as float) { ... }- Types go in
{ }, in Jennifer's own syntax:{int},{list of int},{map of string to int},{json.Value}. There is noany- an opaque value documents asjson.Valueor a named struct. exportis read from the keyword, never a tag - don't write@public.- Tags:
@param name {type} desc(functions) and@field name {type} desc(structs), one per parameter / field;@return {type} desc;@throws {type} desc; and the universals@since,@deprecated [reason],@see,@example,@internal. - A file preamble is a doc comment carrying
@module name, plus optional@author,@version,@license. It goes at the top, after the SPDX header. - Keep doc names in step with the code.
docblockcross-checks@param/@fieldnames against the real declaration and reports drift, so a stale doc is a caught bug, not a silent one.
jennifer fmt preserves doc comments and keeps each on its own line above its construct, so a formatted file is exactly what docblock expects to parse.
SPDX header
Every committed .j file opens with a two-line SPDX header - a machine-readable license tag and a copyright line - so license-scanning and REUSE tooling can attribute each file without parsing prose:
# SPDX-License-Identifier: LGPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 mplx <jennifer@mplx.dev>SPDX-License-Identifieris the SPDX license expression for the file. The Jennifer project itself usesLGPL-3.0-only; your own program carries whatever license you ship under.SPDX-FileCopyrightTextis the copyright notice, kept behind its SPDX tag (SPDX-FileCopyrightText: Copyright (C) <year> <holder> <email>) and placed directly under the license line - one blank line then separates the header from the first code line.- Both are
#line comments. If the file is executable it may open with a#!/usr/bin/env -S jennifer runshebang on line 1, with the two SPDX lines immediately below it. - The identical two-line header (written with
//) tops every.gofile in the interpreter, so the license/copyright form is uniform across the whole repo. Markdown (.md) files carry no header.
jennifer fmt treats the header as an ordinary leading comment block and re-emits it verbatim - it never rewrites, reorders, or drops your SPDX lines.
Source file conventions
.jextension for all Jennifer source. The interpreter rejects anything else.- The two-line SPDX header above tops every committed
.jfile. useandimportstatements come first, before any methods or top-level statements. Groupuselines together, thenimportlines, then a blank line, then the rest of the program.- Blank line after a leading comment block. If the file opens with a header comment (the SPDX license + copyright lines, an optional file description, a shebang), leave one blank line between the comment block and the first code line. Files that start directly with code (no header) start on line 1 - no leading blank.
- Trailing newline at end of file.
Editor configuration
Drop the following into a .editorconfig file at your project root and any editor with EditorConfig support will enforce the spacing and file-encoding rules above automatically:
# .editorconfig
root = true
[*.j]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = trueThat covers the indentation rule (4 spaces, no tabs), trailing-whitespace and final-newline conventions, and pins UTF-8 + LF line endings so collaborators on different OSes don't accidentally introduce CRLF diffs. jennifer fmt re-emits source in the same shape, so the EditorConfig settings and the formatter never disagree.
If you keep .j files alongside other languages in one repository, add a generic fallback as the first block so plain text files don't drift either:
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = trueA complete example
# SPDX-License-Identifier: LGPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 mplx <jennifer@mplx.dev>
use io;
/**
* Factorial of a non-negative integer.
* @param n {int} the operand (assumed >= 0)
* @return {int} n!
*/
func fact(n as int) {
if ($n == 0) {
return 1;
}
return $n * fact($n - 1);
}
for (def i as int init 0; $i <= 8; $i = $i + 1) {
io.printf("%d! = %d\n", $i, fact($i));
}Everything in this example follows the rules above: the two-line SPDX header, 1TBS braces, 4-space indent, spaces around binary operators, double-quoted strings, expanded blocks, and a doc comment on the func. jennifer fmt will produce this output byte-for-byte from any equivalent input.
Comments, blank lines, and a shebang on line 1 all survive a fmt round-trip. The two SPDX header lines (# SPDX-License-Identifier: ... and # SPDX-FileCopyrightText: ...) and any inline # why notes you keep alongside the code are re-emitted at their original positions: leading comments stay on the line above their attached statement, trailing same-line comments stay on the same line, and runs of blank lines collapse to one (matching the "never more than one consecutive blank line" rule).