Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

monkey-c-rs

monkey-c-rs logo

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.

CommandDoes
rafiki fmtFormat source files
rafiki lintReport and optionally fix lint findings
rafiki coverageMeasure test coverage
rafiki serverRun the language server on stdio
rafiki completionsPrint 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

CodeMeaning
0Clean: nothing to report
1The check found something — unformatted files, lint findings, a failing test
2The 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:

  1. The built-in defaults.
  2. rafiki.toml.
  3. 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]

KeyTypeDefaultMeaning
line-widthinteger111Target width before a group is broken onto lines.
alignmentbooleantrueColumn-align separators across related entries.
wrap-declarationsbooleanfalseBreak each binding of a multi-binding var/const.

As flags: --line-width/-l, --alignment/--no-alignment, --wrap-declarations/-w/--no-wrap-declarations.

[lint]

KeyTypeDefaultMeaning
enablelist of rule nameallWhen non-empty, only these rules run.
disablelist of rule namenoneThese 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]

KeyTypeDefaultMeaning
excludelist of globnonePaths to skip, in gitignore syntax.
respect-gitignorebooleantrueSkip 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
function SomeFunction(arg1 as String, arg2 as Number,) as Void {
    var arr = [
        1,
        2,
        3
    ];

    var arr2 = [1, 2, 3,];

    var dict = {
        :keyOne => 1,
        :keyNumberTwo => 2
    };

    var dict2 = {:keyOne=>1, :keyNumberTwo=>2,};

    AnotherFunction(arr, arr2, dict, dict2,)
}
function SomeFunction(
    arg1 as String,
    arg2 as Number,
) as Void {
    var arr = [1, 2, 3];

    var arr2 = [
        1,
        2,
        3,
    ];

    var dict = {:keyOne => 1, :keyNumberTwo => 2};

    var dict2 = {
        :keyOne       => 1,
        :keyNumberTwo => 2,
    };

    AnotherFunction(
        arr,
        arr2,
        dict,
        dict2,
    )
}

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
var dict = {
    :keyOne => 1,
    :keyNumberTwo => 2
    :a => 3
};

enum Color {
    COLOR_RED = 1,
    COLOR_GREEN = 2,
    COLOR_BLUE_DARK = 3,
}

enum State {
    STATE_A,
    STATE_B = 5,
    STATE_C = 6,
    STATE_D,
}
var dict = {
    :keyOne       => 1,
    :keyNumberTwo => 2
    :a            => 3
};

enum Color {
    COLOR_RED       = 1,
    COLOR_GREEN     = 2,
    COLOR_BLUE_DARK = 3,
}

enum State {
    STATE_A,
    STATE_B = 5,
    STATE_C = 6,
    STATE_D,
}

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

FeatureLSP requestBacked by
DiagnosticstextDocument/publishDiagnosticsparser (syntax errors), linter (lints)
FormattingtextDocument/formattingformatter
Code actionstextDocument/codeActionlinter 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.

Keyrafiki.tomlTypeDefault
lineWidth[format] line-widthinteger111
alignment[format] alignmentbooleantrue
wrapDeclarations[format] wrap-declarationsbooleanfalse

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.documentFormattingProvider should print true. An empty get_clients means nothing attached, so confirm the filetype is monkeyc.
  • 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 at vim.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 monkeyc exit, or a monkeydo run that produced no coverage hits at all (even after a --start-simulator retry): test stops 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 monkeydo run whose verdict line reports a failed or errored test (FAILED (passed=0, failed=0, errors=1)): test still 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

  1. instrument parses 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 generated AutoGeneratedCov.mc runtime and a coverage-manifest.tsv into the output directory. It also copies the project’s jungle file to coverage.jungle, rewritten to build from there (see below).
  2. Compiling that output directory with monkeyc --unit-test and running it with monkeydo -t prints one COVHIT <id> line the first time each probe executes.
  3. report joins 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.manifest is repointed at the real manifest.xml, since it isn’t mirrored into the output directory the way .mc sources are.
  • Every sourcePath gains a . entry, if it doesn’t already have one, so monkeyc also picks up AutoGeneratedCov.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

FlagDefaultMeaning
[FILES]...whole projectFiles or directories to instrument.
--out{root}/bin/coverageOutput directory; cleared on every run.
--jungle{root}/monkey.jungleJungle file to copy into coverage.jungle.
--exclude-annotationnoneExtra annotations to skip, comma-separated or repeated. test/release are always skipped.

report

ArgumentDefaultMeaning
<LOG>requiredCaptured simulator log; - reads it from stdin.
--dir{root}/bin/coverageDirectory holding coverage-manifest.tsv.
--out-formattexttext for a human-readable table, or lcov for an LCOV .info file (genhtml, VS Code Coverage Gutters, Codecov, Coveralls).
--outstdoutWrite 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.

FlagDefaultMeaning
-d, --devicerequiredDevice to build and run for, e.g. fr965. Passed to monkeyc -d and monkeydo.
-y, --keyrequiredDeveloper key. Passed to monkeyc -y.
--instrument-out{root}/bin/coverageInstrumentation output directory (same as instrument’s --out).
--out-formattexttext for a human-readable table, or lcov for an LCOV .info file.
--outstdoutWrite the coverage report here instead of stdout.
--start-simulatoroffLaunch connectiq and retry once if the first monkeydo attempt has no hits.
--simulator-boot-time5Seconds to wait after launching the simulator before retrying.
--dry-runoffPrint the monkeyc/monkeydo commands instead of running them.
-- <MONKEYC_ARGS>...noneExtra 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

RuleAuto-fixNotes
bool-comparisonRewrites x == true as x, x == false as !x
collapsible-else-ifRewrites else { if … } as else if …
collapsible-ifMerges a nested if with &&; skipped when comments intervene
compound-assignmentRewrites x = x + n as x += n
ifs-same-condChecks for consecutive ifs with the same condition
import-order⚠️Suppressed when comments interleave
naming-conventionNaming convention according to Coding Conventions
one-class-per-fileOnly allow one class per file according to Coding Conventions
redundant-resource-refDrops legacy @ on Rez.* refs
super-initializer-callFlags missing Base.initialize(…) Coding Conventions
unneeded-parensRemoves 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.

ComparisonRewrite
x == truex
x != falsex
x == false!x
x != true!x

What does not trigger

  • Both sides literal — true == false is a constant with no clearer form.
  • Any operator other than == / !=; count > 0 is 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 else block around the nested if. Collapsing would discard it, so the rule backs off.
  • An else block with more than the single nested if.
  • An else if already 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:

  1. The outer if has no else.
  2. Its body is exactly one statement, and that statement is an if.
  3. The inner if has no else.

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 opCompoundSpecial 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()] + 1 would call next() once after the rewrite instead of twice, and arr[i++] = arr[i++] + 1 similarly changes how many times i gets bumped.
  • Commutative variants where the target appears on the right — x = 1 + x is semantically x += 1, but the binary’s left operand isn’t x, so the rule leaves it alone.
  • Mismatched targets — obj.x = obj2.x + 1 or obj.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 if statements 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 a new. 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:

  1. using Toybox.*
  2. import Toybox.*
  3. using <other>
  4. 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

CategoryPatternExample
Modules & ClassesPascalCaseMyClass
Functions & parameterscamelCasemyFunction(myArg)
Public class memberscamelCasevar myValue;
Private/protected/hidden class members_camelCaseprivate var _value;
Module-scope variablescamelCasevar myCounter = 0;
Local variablescamelCasevar myTotal = 0;
Enum variantsSCREAMING_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:

  1. The class declares extends Base.
  2. The class body defines its own initialize function.
  3. No call to Base.initialize(...) appears anywhere in that function body, including inside if / else branches 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 extends clause.
  • 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 .member or [index](x + 1).foo needs the parens.
  • (Method(…) as Return)? — without the parens, the trailing ? binds to Return rather 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:

SourceAST
baseone qualifier segment, no property
fenix5.lang.engqualifier fenix5, property lang.eng
a;btwo values
$(base.sourcePath)/sharedone 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 itmonkeyc 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.