monkey-c-rs
monkey-c-rs is a collection of tools to work with the Monkey C programming
language which is Garmin’s closed-source programming language used for Garmin
devices. For example projects written in Monkey C, see awesome-garmin.
Ecosystem
Although Monkey C has been around for over a decade (founded in 2014), at the time of writing the tooling we’ve grown to love for modern programming languages such as linters and formatters based on static code analysis is almost completely lacking.
By writing a parser that can understand and represent the Monkey C language I thought I could write tools that could also help format and analyse the code. This project is an attempt to close this gap.
CLI
rafiki is the single command for everything in this project.
| Command | Does |
|---|---|
rafiki fmt | Format source files |
rafiki lint | Report and optionally fix lint findings |
rafiki coverage | Measure test coverage |
rafiki server | Run the language server on stdio |
rafiki completions | Print a shell completion script |
Installing
Prebuilt binaries are not published yet, so install from source:
cargo install --git https://github.com/bombsimon/monkey-c-rs rafiki
# or from a checkout
cargo install --path rafiki
Paths
fmt and lint take any mix of files and directories, defaulting to the current
directory:
rafiki fmt # the whole project
rafiki fmt src/ Tests.mc # named paths
rafiki lint --fix src/
Directories are walked recursively for .mc files. A file named explicitly is
used whatever its extension, so an unusual name is always reachable. Hidden
directories are skipped, as is anything .gitignore excludes — pass
--no-respect-gitignore to include those, or --exclude <glob> to skip more.
- reads from stdin instead, which cannot be combined with paths:
cat File.mc | rafiki fmt - # formatted source on stdout
cat File.mc | rafiki lint - # findings on stderr
Checking without writing
rafiki fmt rewrites files in place. Two flags report instead:
rafiki fmt --check # names files that would change
rafiki fmt --diff # prints a unified diff of the changes
Exit codes
| Code | Meaning |
|---|---|
0 | Clean: nothing to report |
1 | The check found something — unformatted files, lint findings, a failing test |
2 | The command could not run — bad arguments, unreadable file, broken configuration |
Separating 1 from 2 lets CI tell a failing check apart from a broken
invocation:
rafiki fmt --check && rafiki lint
Colour
Diagnostics are coloured when the stream is a terminal. --color always|never
overrides that, and the NO_COLOR, CLICOLOR_FORCE and TERM=dumb conventions
are honoured. stdout and stderr are decided independently, so
rafiki fmt --diff | less stays plain even when diagnostics beside it are not.
Shell completions
rafiki completions zsh > "${fpath[1]}/_rafiki"
rafiki completions bash > ~/.local/share/bash-completion/completions/rafiki
rafiki completions fish > ~/.config/fish/completions/rafiki.fish
elvish and powershell work too. The scripts are generated from the same
definitions that parse the arguments, so they cannot describe flags that do not
exist.
Configuration
Formatter settings, rule selection and file selection can all be pinned in a
rafiki.toml, which the language server reads too.
Configuration
A project can pin its settings in a rafiki.toml. Both the CLI
and the language server read it, so a file formatted from an
editor comes out exactly as rafiki fmt would write it.
Every key is optional. Setting none of them is the same as having no file at all, and the defaults are what the tools use with no configuration anywhere.
[format]
line-width = 111
alignment = true
wrap-declarations = false
[lint]
enable = []
disable = ["one-class-per-file"]
[files]
exclude = ["bin/**", "vendor/**"]
respect-gitignore = true
Discovery
The nearest rafiki.toml at or above the first path on the command line is used,
so rafiki fmt src/Foo.mc picks up the project’s settings from anywhere inside
it. The language server walks up from the workspace root the editor reports.
Two flags override discovery:
rafiki fmt --config path/to/rafiki.toml # use this file
rafiki fmt --no-config # use the built-in defaults
Precedence
Three layers, each overriding the one before it:
- The built-in defaults.
rafiki.toml.- The explicit request — a CLI flag, or the language server client’s
initializationOptions.
Only keys that are actually set overlay, so --line-width 80 changes the width
and leaves everything else as the file had it. For lists, a flag replaces the
file’s list rather than extending it, so --disable naming-convention gives
exactly that one rule and not the file’s disables as well.
[format]
| Key | Type | Default | Meaning |
|---|---|---|---|
line-width | integer | 111 | Target width before a group is broken onto lines. |
alignment | boolean | true | Column-align separators across related entries. |
wrap-declarations | boolean | false | Break each binding of a multi-binding var/const. |
As flags: --line-width/-l, --alignment/--no-alignment,
--wrap-declarations/-w/--no-wrap-declarations.
[lint]
| Key | Type | Default | Meaning |
|---|---|---|---|
enable | list of rule name | all | When non-empty, only these rules run. |
disable | list of rule name | none | These rules are silenced. |
A rule in both lists is silenced. Because each flag replaces only its own key,
that also holds across layers: with disable = ["naming-convention"] in the
file, --enable naming-convention still reports nothing, since the file’s
disable is untouched and wins. Pass --no-config to start from a clean slate.
A name no rule answers to is an error rather than a no-op — a misspelling in
enable would otherwise silence everything and look like a clean run.
rafiki lint --list-rules prints the valid names, and they are documented under
Rules.
As flags: --enable, --disable, both comma-separated and repeatable.
[files]
| Key | Type | Default | Meaning |
|---|---|---|---|
exclude | list of glob | none | Paths to skip, in gitignore syntax. |
respect-gitignore | boolean | true | Skip whatever .gitignore excludes. |
Globs are relative to the configuration file’s directory. Hidden directories are
always skipped, whatever respect-gitignore says: turning it off asks for
ignored source files, not for .git/.
Globs passed directly with --exclude are relative to the current working
directory.
As flags: --exclude (repeatable), --respect-gitignore/--no-respect-gitignore.
Errors
Unknown keys are rejected rather than ignored, so a typo is reported with the keys that were expected:
rafiki: rafiki.toml: TOML parse error at line 2, column 1
|
2 | line_width = 40
| ^^^^^^^^^^
unknown field `line_width`, expected one of `line-width`, `alignment`, `wrap-declarations`
The CLI treats that as a fatal error (exit code 2). The language server instead
reports it to the editor with window/showMessage and carries on with the
defaults, since a bad configuration file should not leave you without
diagnostics.
Parser
The monkey-c-parser is the core that allows us to work with Monkey C at a
higher level of abstraction — an Abstract Syntax Tree.
Heavily inspired (and motivated) by the work on astral-sh/ruff, which has the AST used by RustPython and an extremely fast formatter and linter.
Language Specification
The Garmin Connect IQ SDK does a decent job documenting the Monkey C language and has a language specification that the parser is based on.
Although not used in the parser, they also have API docs documenting the standard library.
Formatter
The Monkey C formatter aims to be a zero-config one-size-fits-all solution to ensure consistent formatting of your Monkey C code. More opinionated suggestions for the code is implemented in the linter.
Note
I’d love any input and testing on the formatter. Both help finding bugs and inconsistencies but also input on the formatting algorithm. Please create an issue for any bug or feature request.
Wrapping long lines
The formatter is using the Wadler-Lindig algorithm to wrap lines at a default width of 111 columns. 111 is chosen because 80 is too little and 222 is too much.
The magic trailing comma
The formatter uses the same magic trailing comma as ruff to determine if multiple items should be wrapped over multiple lines even when they would fit on a single line. The rule applies to arrays, dictionaries, function declaration parameters, and function / method call arguments.
| Original | Formatted |
|---|---|
|
|
Column alignment
When alignment is enabled the formatter pads names so that the separator
operators (=> in dictionaries, = in enum variants) line up in a vertical
column. The intent is purely visual — to make related entries easier to scan.
Alignment only kicks in when an entry is already rendered multi-line. For dictionaries that follows the magic trailing comma rule above. For enums the formatter looks for runs of two or more consecutive variants that all have an explicit value, and pads the names within each run.
| Original | Formatted |
|---|---|
|
|
In the State example the run [STATE_B = 5, STATE_C = 6] is already aligned
with itself and no padding is needed. The bare variants STATE_A and STATE_D
break the run and stay as-is.
LSP
rafiki server is a Language Server for Monkey C. It wraps the parser,
linter, and formatter in one server so any LSP-capable editor gets live feedback
without shelling out to the command line.
Capabilities
| Feature | LSP request | Backed by |
|---|---|---|
| Diagnostics | textDocument/publishDiagnostics | parser (syntax errors), linter (lints) |
| Formatting | textDocument/formatting | formatter |
| Code actions | textDocument/codeAction | linter fixes |
A parse error becomes a single error diagnostic. Every lint finding becomes a
warning tagged with its rule name, for example unneeded-parens. Formatting
returns the whole document re-rendered by the formatter.
There are two kinds of code action. A quickfix fixes one finding: invoke code
actions on a diagnostic, or anywhere on its line, and apply that single fix. A
source.fixAll action bundles every auto-fixable finding into one edit. Bind it
to run on save and it fixes the whole file at once (see below). If two fixes
would overlap, one pass keeps the first and drops the rest; the dropped ones
apply on the next run, the same way the linter’s --fix does.
Positions are translated between the parser’s UTF-8 byte offsets and LSP’s
UTF-16 (line, character) coordinates, so diagnostics stay aligned on lines
that contain accents or emoji.
Transport and sync
The server talks over stdio with standard LSP framing. Sync is full document: the editor sends the complete text on every change. That keeps the server simple and is fast enough for source files of the usual size.
Configuration
The server reads the project’s rafiki.toml,
discovered by walking up from the workspace root the editor reports. That is the
same file rafiki fmt reads, so formatting from an editor and formatting from a
terminal produce identical output.
A client can also pass settings directly through initializationOptions, which
override the file. Keys are camelCase there, to match LSP convention rather than
the file’s kebab-case; unknown keys are ignored and any key left unset falls
through to the file and then to the default.
| Key | rafiki.toml | Type | Default |
|---|---|---|---|
lineWidth | [format] line-width | integer | 111 |
alignment | [format] alignment | boolean | true |
wrapDeclarations | [format] wrap-declarations | boolean | false |
Both sources are read at startup, so change either and restart the server
(:LspRestart in Neovim) to take effect. A rafiki.toml that fails to parse is
reported through window/showMessage and the defaults are used, so a broken file
never costs you diagnostics.
Editor setup
The server is a plain stdio LSP program, so any client can launch it: point the
client at rafiki server. Nothing but LSP traffic is ever written to stdout, so
configuration problems and other messages cannot corrupt the stream.
Install rafiki as described in CLI, or build it from a
checkout with --release — the program your editor spawns runs on every
keystroke:
cargo build --release
# creates target/release/rafiki
Neovim
No plugin is needed; vim.lsp.start is built in. Neovim doesn’t recognise the
.mc extension, so setup has two parts: register the filetype, then start the
server for it. Drop this in your config (for example init.lua) and point cmd
at the built binary:
-- Teach Neovim that .mc is Monkey C.
vim.filetype.add({ extension = { mc = "monkeyc" } })
-- Start the server whenever a Monkey C buffer opens.
vim.api.nvim_create_autocmd("FileType", {
pattern = "monkeyc",
callback = function(args)
vim.lsp.start({
name = "rafiki",
cmd = { "rafiki", "server" },
root_dir = vim.fs.root(args.buf, { "rafiki.toml", "manifest.xml", ".git" }) or vim.fn.getcwd(),
-- Optional. Prefer a `rafiki.toml` in the project, so the command line
-- and the editor agree; anything set here overrides it.
init_options = {
lineWidth = 111,
alignment = true,
wrapDeclarations = false,
},
})
end,
})
Open a .mc file and diagnostics show up on their own. They refresh as you
edit, because the server re-analyses on every change.
Format on save
The server advertises textDocument/formatting, so vim.lsp.buf.format()
routes through it:
vim.api.nvim_create_autocmd("BufWritePre", {
pattern = "*.mc",
callback = function() vim.lsp.buf.format() end,
})
If you already have a global format-on-save, check two things. It must not be
filtered to another client (a filter = function(c) return c.name == "..." end
that leaves out rafiki). And formatter-manager plugins such as
conform.nvim or none-ls need to fall back to the LSP for the monkeyc filetype,
or they skip this server.
Formatting does nothing when the document doesn’t parse. A file with a syntax
error can’t be re-rendered from its AST, so the server returns no edits. If
format-on-save goes quiet, look for an error diagnostic first with
:lua =vim.diagnostic.get(0).
Fix all lints on save
To apply every auto-fixable lint on save, run the source.fixAll code action
before formatting:
vim.api.nvim_create_autocmd("BufWritePre", {
pattern = "*.mc",
callback = function()
vim.lsp.buf.code_action({
apply = true,
async = false,
context = { only = { "source.fixAll" } },
})
vim.lsp.buf.format()
end,
})
The server returns a single source.fixAll action whenever something is
fixable, so apply = true applies it without a prompt. With only set to
source.fixAll, per-finding quick-fixes stay out of the way here. Reach those on
demand with vim.lsp.buf.code_action() and no filter.
Troubleshooting
- Check that the client attached and offers formatting:
:lua =vim.lsp.get_clients({ bufnr = 0 })[1].server_capabilities.documentFormattingProvidershould printtrue. An emptyget_clientsmeans nothing attached, so confirm the filetype ismonkeyc. - After you rebuild the binary, run
:LspRestart(or reopen the buffer) so Neovim spawns the new process. - For protocol-level debugging, raise the log level with
:lua vim.lsp.set_log_level("debug")and read the file atvim.lsp.get_log_path().
Other editors
Any client that can register a custom stdio language server for the Monkey C file type works the same way. Point it at the built binary.
Coverage
rafiki coverage measures function-level test coverage for Monkey C by
rewriting the source before compilation — Connect IQ has no native coverage
support to hook into.
Note
Coverage is function-level only. It answers “which functions never ran under the test suite”, not which lines or branches did — a fully executed 200-line function counts the same as a one-line one.
Usage
test runs the whole pipeline — instrument, compile, run under the
simulator, and report — in one step, using the paths instrument already
decided instead of asking you to retype them:
rafiki coverage test -d <device> -y key
That needs monkeyc and monkeydo on PATH, and the simulator already
running. monkeyc’s and monkeydo’s own output stays out of the way on a
clean run — test prints the instrument summary, monkeydo’s own one-line
verdict (PASSED (passed=1, failed=0, errors=0)), and the coverage table,
nothing more (COVHIT lines are always stripped from anything printed,
whichever branch below it takes). The full raw output only shows up once
something looks wrong, and what happens next depends on what “wrong” means:
- A non-zero
monkeycexit, or amonkeydorun that produced no coverage hits at all (even after a--start-simulatorretry):teststops right there and exits non-zero. No coverage table — the build never ran, so a “0/N covered” table would misrepresent that as a real (if terrible) result rather than a broken pipeline. - A
monkeydorun whose verdict line reports a failed or errored test (FAILED (passed=0, failed=0, errors=1)):teststill exits non-zero, but the coverage table is still printed afterwards, since the code did run and that data is still meaningful.
None of this reads monkeydo’s own exit status — it isn’t a reliable
pass/fail signal on its own (a run with a real failing test can still exit
zero, and a fully passing one can still exit non-zero) — test reads the
same verdict line and coverage hits the human-readable output already
shows.
Add --start-simulator to have it launch the simulator itself (via
connectiq, installed alongside monkeyc/monkeydo) if the first
monkeydo attempt comes back with no coverage hits, and retry once:
rafiki coverage test -d <device> -y key --start-simulator
--start-simulator is opt-in rather than default because connectiq brings
an already-running simulator’s window to the front, which is only wanted
when monkeydo actually needed it. Pass --dry-run to print the
monkeyc/monkeydo commands test would run without running them.
Running the steps by hand
test is a convenience wrapper; each step also works standalone, e.g. for CI
or to debug one stage at a time:
rafiki coverage instrument
monkeyc -f bin/coverage/coverage.jungle -d <device> -o bin/coverage/cov.prg -y key --unit-test
monkeydo bin/coverage/cov.prg <device> -t | rafiki coverage report -
Note
The simulator needs to be running when executing
monkeydo.
To keep the raw simulator output around, capture it to a file instead of
piping it straight into report:
monkeydo bin/coverage/cov.prg <device> -t | tee bin/coverage/run.log
rafiki coverage report bin/coverage/run.log
How it works
instrumentparses every source file, splices a probe (AutoGeneratedCov.hit(N)) after each function’s opening brace, and writes the rewritten sources — byte-identical apart from the probes — plus a generatedAutoGeneratedCov.mcruntime and acoverage-manifest.tsvinto the output directory. It also copies the project’s jungle file tocoverage.jungle, rewritten to build from there (see below).- Compiling that output directory with
monkeyc --unit-testand running it withmonkeydo -tprints oneCOVHIT <id>line the first time each probe executes. reportjoins that log against the manifest and prints per-file coverage, listing every function that never ran.
Declarations annotated with (:test) or (:release) are always skipped —
test code doesn’t get to count itself, and unit tests run in debug mode
regardless of (:release). --exclude-annotation adds more names to skip,
e.g. ones a jungle sets via excludeAnnotations.
Everything anchors to the project root — the nearest ancestor holding
manifest.xml — so instrument can run from any subdirectory of the
project and still cover the whole thing, and so files that share a name in
different directories never collide in the output.
The generated coverage.jungle
The copied jungle needs two edits to build correctly from its new home in the output directory:
project.manifestis repointed at the realmanifest.xml, since it isn’t mirrored into the output directory the way.mcsources are.- Every
sourcePathgains a.entry, if it doesn’t already have one, somonkeycalso picks upAutoGeneratedCov.mc, which sits directly in the output directory rather than mirrored under it.
resourcePath entries are copied as-is and not rebased, so a project with
resources currently needs to fix those up by hand.
Flags
instrument
| Flag | Default | Meaning |
|---|---|---|
[FILES]... | whole project | Files or directories to instrument. |
--out | {root}/bin/coverage | Output directory; cleared on every run. |
--jungle | {root}/monkey.jungle | Jungle file to copy into coverage.jungle. |
--exclude-annotation | none | Extra annotations to skip, comma-separated or repeated. test/release are always skipped. |
report
| Argument | Default | Meaning |
|---|---|---|
<LOG> | required | Captured simulator log; - reads it from stdin. |
--dir | {root}/bin/coverage | Directory holding coverage-manifest.tsv. |
--out-format | text | text for a human-readable table, or lcov for an LCOV .info file (genhtml, VS Code Coverage Gutters, Codecov, Coveralls). |
--out | stdout | Write the report here instead of stdout. |
The simulator may reinitialize module state between unit tests, so probe ids
can repeat in the log; report deduplicates while joining. Only lines that
are exactly COVHIT <id> are counted — anything else the simulator
interleaves is ignored.
test
Takes instrument’s [FILES]..., --exclude-annotation and --jungle
flags, plus report’s --out-format/--out for the report it produces at
the end. --out on instrument and --instrument-out here both mean the
instrumentation output directory — they’re just named differently since
test also needs --out for the report path.
| Flag | Default | Meaning |
|---|---|---|
-d, --device | required | Device to build and run for, e.g. fr965. Passed to monkeyc -d and monkeydo. |
-y, --key | required | Developer key. Passed to monkeyc -y. |
--instrument-out | {root}/bin/coverage | Instrumentation output directory (same as instrument’s --out). |
--out-format | text | text for a human-readable table, or lcov for an LCOV .info file. |
--out | stdout | Write the coverage report here instead of stdout. |
--start-simulator | off | Launch connectiq and retry once if the first monkeydo attempt has no hits. |
--simulator-boot-time | 5 | Seconds to wait after launching the simulator before retrying. |
--dry-run | off | Print the monkeyc/monkeydo commands instead of running them. |
-- <MONKEYC_ARGS>... | none | Extra arguments forwarded to monkeyc verbatim, e.g. -- -O 3 -w. |
Linter
Linter for Monkey C code to find both stylistic and other issues in the code.
When possible the linter supports automatically fixing the issues by using the
--fix flag.
Note
The fixer doesn’t format the code to normalize after changes so the user is expected to run the formatter after applying fixes.
› rafiki lint monkey-c-linter/example/Example.mc
[import-order] Warning: imports should be sorted and grouped
╭─[ monkey-c-linter/example/Example.mc:1:1 ]
│
1 │ ╭─▶ using Toybox.Lang;
┆ ┆
5 │ ├─▶ using Toybox.Graphics as Gfx;
│ │
│ ╰─────────────────────────────────── imports should be sorted and grouped
│
│ Note: fix: replace with `using Toybox.Graphics as Gfx;
│ using Toybox.Lang;
│
│ import Toybox.System;
│
│ using MyModule.First;
│ using MyModule.Second;`
───╯
[unneeded-parens] Warning: unneeded parentheses around expression
╭─[ monkey-c-linter/example/Example.mc:10:17 ]
│
10 │ var value = (M + C + 180 + 101.11d);
│ ───────────┬───────────
│ ╰───────────── unneeded parentheses around expression
│
│ Note: fix: replace with `M + C + 180 + 101.11d`
────╯
[unneeded-parens] Warning: unneeded parentheses around expression
╭─[ monkey-c-linter/example/Example.mc:13:20 ]
│
13 │ var isPm = (hour >= 12);
│ ──────┬─────
│ ╰─────── unneeded parentheses around expression
│
│ Note: fix: replace with `hour >= 12`
────╯
[unneeded-parens] Warning: unneeded parentheses around expression
╭─[ monkey-c-linter/example/Example.mc:19:17 ]
│
19 │ value = (valueEnabled ? "V on" : "V off");
│ ────────────────┬────────────────
│ ╰────────────────── unneeded parentheses around expression
│
│ Note: fix: replace with `valueEnabled ? "V on" : "V off"`
────╯
Rules
Each lint rule walks the parsed AST looking for a specific pattern. When a rule
fires it produces a Diagnostic — the source range, a message,
and (when applicable) a machine-applicable Fix that replaces a byte
range with new text.
Fixes are byte-level text replacements, not AST rewrites. That means a fix only
touches the affected source range and leaves the surrounding formatting alone.
Once --fix has been applied the user is expected to run
rafiki fmt if they want whitespace normalised.
Categories
| Rule | Auto-fix | Notes |
|---|---|---|
bool-comparison | ✅ | Rewrites x == true as x, x == false as !x |
collapsible-else-if | ✅ | Rewrites else { if … } as else if … |
collapsible-if | ✅ | Merges a nested if with &&; skipped when comments intervene |
compound-assignment | ✅ | Rewrites x = x + n as x += n |
ifs-same-cond | ❌ | Checks for consecutive ifs with the same condition |
import-order | ⚠️ | Suppressed when comments interleave |
naming-convention | ❌ | Naming convention according to Coding Conventions |
one-class-per-file | ❌ | Only allow one class per file according to Coding Conventions |
redundant-resource-ref | ✅ | Drops legacy @ on Rez.* refs |
super-initializer-call | ❌ | Flags missing Base.initialize(…) Coding Conventions |
unneeded-parens | ✅ | Removes redundant parentheses |
bool-comparison
Flags == / != comparisons against a boolean literal, which can be written
as the operand itself or its negation.
Rationale
Comparing a boolean expression to true or false restates what the
expression already says. if (ready == true) reads as “if ready is true”,
which is just “if ready”; if (ready == false) is “if not ready”. Dropping
the literal removes the redundant comparison and leaves the condition saying
exactly what it means.
What triggers
An == or != binary expression where exactly one side is the literal true
or false. The literal may be on either side, so true == ready is treated
the same as ready == true.
| Comparison | Rewrite |
|---|---|
x == true | x |
x != false | x |
x == false | !x |
x != true | !x |
What does not trigger
- Both sides literal —
true == falseis a constant with no clearer form. - Any operator other than
==/!=;count > 0is left alone. - Expressions that don’t compare against a boolean literal at all.
Example
Before:
function f() {
if (ready == true) {
start();
}
return done != false;
}
After --fix:
function f() {
if (ready) {
start();
}
return done;
}
Fix
The fix replaces the whole comparison with the surviving operand, copied
verbatim from the source. When the rewrite negates an operand that is itself a
binary or ternary expression, it is wrapped in parentheses so ! still binds
the whole expression — a < b == false becomes !(a < b), not !a < b.
collapsible-else-if
Flags an else whose entire body is a single if, which can be written as
else if.
Rationale
else { if (…) { … } } is the same control flow as else if (…) { … } with an
extra level of braces and indentation. The else if form is shorter and is how
the chain is normally written.
What triggers
The rule fires when an else branch is a block containing exactly one
statement, and that statement is an if. The nested if may keep its own
else chain — it is preserved as-is:
if (a) {
} else {
if (b) {
} else {
}
}
becomes
if (a) {
} else if (b) {
} else {
}
What does not trigger
- A comment in the
elseblock around the nestedif. Collapsing would discard it, so the rule backs off. - An
elseblock with more than the single nestedif. - An
else ifalready written as such — there is no wrapping block to remove.
collapsible-if
Flags an if whose entire body is a single nested if, where the two can be
merged into one by &&-combining their conditions.
Rationale
Each if adds a level of nesting. When the inner if is the only thing the
outer one does, that nesting is noise — the guard is really a single compound
condition, and reads more clearly written as one.
What triggers
The rule fires when all of the following hold:
- The outer
ifhas noelse. - Its body is exactly one statement, and that statement is an
if. - The inner
ifhas noelse.
Either else would change which condition guards the fall-through, so the
merge would not preserve behaviour — those cases are left alone.
What does not trigger
- A comment in the outer block around the nested
if. Collapsing would discard it, so the rule backs off. - An outer block with more than the single nested
if.
Example
if (ready) {
if (count > 0) {
process();
}
}
Fixed:
if (ready && count > 0) {
process();
}
A condition that binds looser than && (an || / or, a ternary) is wrapped
in parentheses when merged, so if (a || b) { if (c) { … } } becomes
if ((a || b) && c) { … }.
compound-assignment
Flags <lvalue> = <lvalue> <op> <expr> patterns that can be written with a
compound assignment operator or ++ / --.
Rationale
A self-referential assignment carries a small amount of duplication: the
target appears twice on the same line, and a reader has to confirm that the
left and right occurrences are the same identifier before they can read the
expression as “update x”. The compound form removes that duplication and
makes intent immediately obvious.
What triggers
The rule reports any assignment whose left- and right-hand sides reference the same writable location:
<target> = <target> <op> <expr>
<target> may be an identifier (x), a member access (obj.x), or an
index access (arr[i]) — and may nest, so obj.a.b and grid[row][col]
both qualify. The two occurrences must be structurally identical, and
<op> must have a compound form:
| Binary op | Compound | Special case for literal 1 |
|---|---|---|
+ | += | x++ |
- | -= | x-- |
* | *= | — |
/ | /= | — |
% | %= | — |
& | &= | — |
| | |= | — |
^ | ^= | — |
<< | <<= | — |
>> | >>= | — |
What does not trigger
The rule skips any case where the rewrite could change observable behavior or where the two sides aren’t actually the same location:
- Targets whose receiver or index isn’t side-effect-free —
arr[next()] = arr[next()] + 1would callnext()once after the rewrite instead of twice, andarr[i++] = arr[i++] + 1similarly changes how many timesigets bumped. - Commutative variants where the target appears on the right —
x = 1 + xis semanticallyx += 1, but the binary’s left operand isn’tx, so the rule leaves it alone. - Mismatched targets —
obj.x = obj2.x + 1orobj.x = obj.y + 1. - Already-compound assignments —
x += 1,x *= n, etc. - Operators without a compound form —
==,<,&&, …
Example
Before:
function f() {
x = x + 1;
x = x - 1;
x = x + 3;
x = x * n;
obj.x = obj.x + 1;
arr[i] = arr[i] * 2;
for (i = 0; i < 10; i = i + 1) {
doStuff();
}
}
After --fix:
function f() {
x++;
x--;
x += 3;
x *= n;
obj.x++;
arr[i] *= 2;
for (i = 0; i < 10; i++) {
doStuff();
}
}
Fix
The fix replaces the entire assignment expression with the compound form. The right-hand side is copied verbatim from the source, so any inline comments and whitespace inside the RHS are preserved.
ifs-same-cond
Flags an if / else if chain where two arms test the same condition.
Rationale
A repeated condition in a chain is almost always a copy-paste error: the earlier arm always matches first, so the later arm with the identical condition is unreachable. What the author meant to write was a different condition in the second arm.
What triggers
The rule fires when two arms of the same if / else if chain have
structurally equal conditions — formatting and whitespace are ignored, so
a == b matches a==b. The duplicate need not be adjacent —
if (a) … else if (b) … else if (a) … is flagged too. Each repeated arm is
reported once.
What does not trigger
- Two separate
ifstatements that happen to share a condition. They are not one chain — the first can fall through to the second, so the repetition may be intentional. - Any condition containing a call (
foo()) or anew. A call may have side effects, so two textually-identical calls need not evaluate to the same value on each run.
Example
if (status == OK) {
handleOk();
} else if (status == OK) { // can never run — likely meant `status == ERROR`
handleError();
}
No auto-fix — the rule can’t know what the second condition was meant to be.
import-order
Flags contiguous runs of using / import declarations that aren’t in
canonical order.
Rationale
A consistent import order makes files easier to scan, reduces merge conflicts,
and groups related declarations together. Sorting alphabetically eliminates
the need for editors to argue about placement; grouping Toybox.* separately
keeps SDK imports visually distinct from project ones.
What triggers
The rule reports a run of using / import declarations whose order doesn’t
match the canonical form. Canonical order is four groups, each sorted
alphabetically by the dotted path, separated by a blank line:
using Toybox.*import Toybox.*using <other>import <other>
A declaration is Toybox.* when its path is exactly Toybox or starts with
Toybox..
What does not trigger
Each contiguous run of using / import is treated as its own block. A
non-import declaration between two import blocks creates a hard boundary —
declarations in the second block aren’t pulled up to join the first. This
matches the behaviour of Ruff’s import sorter.
When a comment interleaves the imports (e.g., a // section line between two
using statements) the rule only enforces order — blank-line placement
around the user’s comments is left alone.
Example
Before:
import ModuleC;
using ModuleA;
import Toybox.D;
using Toybox.A;
import Toybox.C;
using Toybox.B as D;
After --fix:
using Toybox.A;
using Toybox.B as D;
import Toybox.C;
import Toybox.D;
using ModuleA;
import ModuleC;
Fix
The fix replaces the entire run with the canonical text. The auto-fix is suppressed when a comment interleaves the imports — rearranging would lose the user’s comment placement, so the edit is left to the user. The diagnostic is still reported in that case.
naming-convention
Flags identifiers that don’t follow Garmin’s Monkey C coding conventions.
Rules
| Category | Pattern | Example |
|---|---|---|
| Modules & Classes | PascalCase | MyClass |
| Functions & parameters | camelCase | myFunction(myArg) |
| Public class members | camelCase | var myValue; |
| Private/protected/hidden class members | _camelCase | private var _value; |
| Module-scope variables | camelCase | var myCounter = 0; |
| Local variables | camelCase | var myTotal = 0; |
| Enum variants | SCREAMING_SNAKE_CASE sharing a <PREFIX>_ | COLOR_RED, COLOR_BLUE |
Rationale
Naming conventions buy consistency at zero ongoing cost — readers don’t
have to wonder whether myThing is a class or a function. The rules
above mirror Garmin’s official conventions verbatim.
const declarations are left unchecked, not as a gap but because
Garmin’s conventions don’t define a case for them — there’s nothing to
enforce. SDK code commonly uses SCREAMING_SNAKE_CASE for constants
(an idiom like example.SOME_CONST reads better than
example.someConst), but that’s convention-by-example rather than a
documented rule, so the linter leaves both module-scope and
class-scope const alone regardless of visibility.
Example
Before:
class example {
var Value = 1;
private var mCounter as Number = 0;
function MyFn() {}
}
enum {
RED,
BLUE,
}
The rule flags each violation and suggests a conformant name via
convert_case:
class `example` should be PascalCase, e.g. `Example`
public class member `Value` should be camelCase, e.g. `value`
private class member `mCounter` should be `_camelCase`, e.g. `_mCounter`
function `MyFn` should be camelCase, e.g. `myFn`
enum variants should share a common `<PREFIX>_` prefix
There is no auto-fix — renaming an identifier has to ripple through every call site, which the linter can’t do safely with byte-level replacements alone.
one-class-per-file
Flags files that declare more than one class following Garmin’s Monkey C coding conventions.
Rationale
Keeping each class in its own file makes the file name a reliable index of its primary type, lets readers locate code by filename alone, and keeps related members (fields, methods, helper functions) grouped together. Co-locating two classes usually means one of them is incidental and should move to its own file.
What triggers
Anything after the first class declaration anywhere in the file. The rule counts across module boundaries, so a file with two top-level classes, a module containing two classes, or two nested modules each holding a class all trigger.
What does not trigger
A file with exactly one class, regardless of how many functions, modules,
typedefs, or using declarations sit alongside it.
Example
Before:
class Foo {
function foo() {}
}
class Bar {
function bar() {}
}
The rule reports Bar as the offending second class. There is no
auto-fix — moving Bar to its own file requires choosing a file name and
deciding what context (imports, module wrapping) to copy across.
redundant-resource-ref
Flags the legacy @ prefix on resource references.
Rationale
@Rez.Strings.foo and Rez.Strings.foo compile to the same thing — the @
is a vestigial marker from older Monkey C syntax. Dropping it removes noise
without changing behavior.
What triggers
Any expression of the form @<resource reference>, e.g.:
dc.drawText(@Rez.Strings.AppName);
Example
Before:
function onLayout(dc as Dc) as Void {
dc.drawText(@Rez.Strings.AppName);
}
After --fix:
function onLayout(dc as Dc) as Void {
dc.drawText(Rez.Strings.AppName);
}
Fix
The fix replaces the @-prefixed expression with the underlying resource
reference, dropping the @.
super-initializer-call
Flags a derived class whose initialize doesn’t call the parent’s
initialize.
Rationale
Monkey C doesn’t automatically chain to the superclass constructor when a
subclass overrides initialize. A derived initialize that omits the
Parent.initialize(...) call leaves the base object in an uninitialised
state — a common, hard-to-debug bug that surfaces only when base-class
fields are read.
What triggers
The rule fires when all three hold:
- The class declares
extends Base. - The class body defines its own
initializefunction. - No call to
Base.initialize(...)appears anywhere in that function body, including insideif/elsebranches and nested blocks.
For a dotted parent (extends WatchUi.View), the rule accepts a call to
the last segment (View.initialize(...)) or any qualified form
(WatchUi.View.initialize(...)).
What does not trigger
- A class with no
extendsclause. - A derived class that doesn’t override
initialize— Monkey C’s implicit no-op constructor still chains.
Example
Before:
class MyView extends WatchUi.View {
function initialize() {
_state = 0;
}
}
After fixing manually:
class MyView extends WatchUi.View {
function initialize() {
View.initialize();
_state = 0;
}
}
No auto-fix — the rule can’t know which arguments to pass to the parent initializer.
unneeded-parens
Flags parentheses written in positions where they don’t change parsing.
Rationale
Parens that don’t affect parsing read as either a typo or copy-paste residue. Removing them clarifies the code without changing behavior.
What triggers
- The right-hand side of an assignment:
x = (1 + 2); - The initializer of a
var/const:var x = (1 + 2); - The value of a
return:return (x); - Parens around a
Method(…)type annotation when they aren’t load-bearing, e.g.(Method() as Boolean)as a function return type.
What does not trigger
Positions where parens can affect parsing:
- Operand of a binary operator —
1 * (2 + 3)needs the parens. - Object position of
.memberor[index]—(x + 1).fooneeds the parens. (Method(…) as Return)?— without the parens, the trailing?binds toReturnrather than to the whole callable.
Example
Before:
function f(cb as (Method(x as Number) as Void)?) as (Method() as Boolean) {
var x = (1 + 2);
return (method(:g));
}
After --fix:
function f(cb as (Method(x as Number) as Void)?) as Method() as Boolean {
var x = 1 + 2;
return method(:g);
}
Fix
The fix replaces the source between the parens (trimmed of surrounding
whitespace) into the outer span. Comments inside the parens are preserved:
(/* tag */ 2 + 3) becomes /* tag */ 2 + 3.
Jungle
The monkey-c-jungle crate parses jungle files, the build
language that tells monkeyc which sources, resources, barrels and annotations
to use for each device.
Jungle is a small, line-oriented language, so the crate is small too: a lexer, a recursive descent parser and an AST that knows how to write itself back out.
use monkey_c_jungle::ast::{JungleFile, Value};
let mut jungle = JungleFile::parse("base.sourcePath = source\n")?;
jungle.set(
"fenix5.resourcePath",
vec![
Value::reference("fenix5.resourcePath"),
Value::text("fenix-resources"),
],
);
print!("{jungle}");
Building a file from scratch
There is no separate builder — a JungleFile is Default, and the same methods
that edit a parsed file fill an empty one. to_string() gives you the bytes.
use monkey_c_jungle::ast::{JungleFile, Value};
let mut jungle = JungleFile::default();
jungle.set("project.manifest", [Value::text("manifest.xml")]);
jungle.push_blank_line();
jungle.push_comment("Only the shared sources");
jungle.set("base.sourcePath", [Value::text("source")]);
jungle.push_blank_line();
jungle.push_comment("Older devices lack the newer API");
for device in ["fenix3", "fr230"] {
jungle.set(
&format!("{device}.excludeAnnotations"),
[Value::text("experimental")],
);
}
jungle.push_blank_line();
// Extend a qualifier rather than replace it, and note why.
jungle.set(
"round.resourcePath",
[
Value::reference("round.resourcePath"),
Value::text("resources-round").with_comment("shared by every round device"),
],
);
std::fs::write("monkey.jungle", jungle.to_string())?;
project.manifest = manifest.xml
# Only the shared sources
base.sourcePath = source
# Older devices lack the newer API
fenix3.excludeAnnotations = experimental
fr230.excludeAnnotations = experimental
round.resourcePath = $(round.resourcePath);resources-round # shared by every round device
The pieces: Value::text for a literal, Value::reference for a $(…),
Value::group for a […], and with_comment to note one. push_comment and
push_blank_line add the lines between instructions — push_comment spaces the
text off the # itself, so pass content rather than formatting.
set replaces the last instruction assigning a target, or appends one if there
is none, so the same call works whether you are building or editing. Reach for
remove to drop a target and get to read one back.
The AST
A JungleFile is a flat list of Entry in source order: an instruction, a
comment, or a blank line. Keeping the last two is what lets a file be edited and
written back without losing the notes around it.
An instruction is a QualifiedName target and a list of Value:
| Source | AST |
|---|---|
base | one qualifier segment, no property |
fenix5.lang.eng | qualifier fenix5, property lang.eng |
a;b | two values |
$(base.sourcePath)/shared | one value, a reference part and a text part |
[round.jungle;rect.jungle] | one ValueKind::Group of two values |
"my sources/app.mc" | one quoted text value |
Only a ; starts a new value. That is why appending to a path
($(fenix5.resourcePath);fenix-resources) and extending one
($(base.sourcePath)/extra) mean different things. Every position in a list
must hold a value — monkeyc rejects a bare qualifier =, a trailing ;, a
gap between two ; and an empty [] alike.
Line breaks
An instruction ends at a line break. Only three things hold one open, all
verified against monkeyc:
foo = # a break after the `=`, before the first value
bar
foo = bar; # # a comment after a `;` — it eats its own line break
baz
foo = bar;\ # a `\` after a `;`
baz
Everything else terminates, including the near-misses: a bare break after a
;, a break between two values with no ;, and any break inside a […]
group. The \ is picky — it only works directly after a ;, so foo = bar \
and foo =\ both fail, and source\ at the end of a line is just the value
source\.
Two deliberate differences from monkeyc. It allows a break before a ;
(source on one line, ;xx on the next); this crate rejects that, because the
form is a trap — monkeyc accepts it and then silently discards the
instruction that follows, with no error at all. It also allows a break between
a name and its =; rejecting that keeps the error for a forgotten = pointing
at the line that forgot it, rather than at the line after.
In the other direction the parser is a little laxer than monkeyc about where
a \ may appear. Those forms are all nonsense that no one writes, and being
lax there costs nothing — a file that relies on it wouldn’t build anyway.
Comments
A comment runs from its # to the end of the line and takes the line break
with it — monkeyc treats the lot as whitespace. So a comment can’t end an
instruction; the instruction carries on below it:
project.manifest = # Foo
manifest.xml
That is one instruction, project.manifest = manifest.xml. The same rule is
what lets a list annotate its entries, the comment absorbing each break:
base.sourcePath = source; # shared by everything
$(round.sourcePath); # round devices
wearable-source # everything else
A comment therefore belongs to the value it was written against and prints after
it. One with no value to attach to — the usual kind, alone on its line — is an
Entry::Comment.
The catch: a comment on the last value eats the instruction’s terminator, so
the line below runs into it. monkeyc rejects that, and so does this parser;
the printer avoids emitting it by always leaving a blank line after one.
A # can’t appear in a value at all. Quoting doesn’t help — the comment starts
inside the quotes and leaves the string unclosed.
Writing files back
Display emits the line structure the grammar requires and normalises the rest:
one space around =, none around ;, one instruction per line, trailing
newline. Paths, globs and redundant quotes are kept verbatim.
A blank line separates, so one survives, but a run of them collapses to a single
line — the second says nothing the first didn’t. monkey-c-formatter treats
blank runs in Monkey C the same way.
A break inside an instruction survives only when something forces it. A comment
does; a \ doesn’t, so a continued instruction folds back onto one line.
The printer doesn’t remember where the author broke a line. If wrapping long
value lists is ever wanted, the way to add it is a width rule the printer
applies itself, the way monkey-c-formatter wraps Monkey C — not a record of the
input’s breaks.
That gives two guarantees, and fixtures cover both. A file written the way
monkeyc projects write them comes back byte for byte, annotated lists
included. A file leaning on the other break forms can’t — folding a \ is a
rewrite. There, what holds is that the rewrite settles in one pass: printing the
output again is a no-op, so a file doesn’t drift each time a tool touches it.