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

Introduction

monkey-c-rs logo

monkey-c-rs is a set of developer tools for Monkey C, the language used to build apps for Garmin devices with the Connect IQ SDK. Everything ships as a single command line tool called rafiki:

  • rafiki fmt formats your code.
  • rafiki lint finds common mistakes and can fix many of them for you.
  • rafiki server brings both into your editor as a language server.
  • rafiki coverage shows which functions your unit tests run.

Why it exists

Monkey C has been around since 2014, but it still lacks the tooling most languages take for granted. The Connect IQ SDK comes with a compiler and an editor extension for code navigation, but there is no formatter or linter.

This project fills that gap. It starts from a full parser for the language and builds the tools on top of it, taking inspiration from ruff and rustfmt. The tools are fast and work out of the box with sensible defaults, so there’s little to configure. See Design principles for the reasoning behind them.

Status

The tools are usable today, but nothing is stable yet. Output and settings can still change between versions, so pin the version you install. See Getting started for how.

Some things aren’t supported yet:

  • A VS Code extension. Until there is one, the language server works with any editor that can start a custom server. See Editor integration.

Feedback

Bug reports and ideas are welcome, whether it’s a formatting choice you disagree with, a lint rule you’d like or something that doesn’t work. Please open an issue on GitHub.

Getting started

Install

There are no prebuilt binaries yet, so rafiki is installed from source with Cargo. Pin a commit, since output and settings can still change between versions:

cargo install --git https://github.com/bombsimon/monkey-c-rs rafiki --rev <commit>

Leave out --rev to get the latest version instead.

Format your code

Run the formatter from anywhere in your project. It formats every .mc file it finds and writes the result back:

rafiki fmt

To see what would change without touching any files, use --check to list the files or --diff to print the changes.

Lint your code

rafiki lint

Each finding names the rule that raised it. Many can be fixed automatically, and the fixes don’t reformat the code around them, so format again afterwards:

rafiki lint --fix
rafiki fmt

Configure

Most projects need no configuration. To change a setting, add a rafiki.toml to the root of the project:

[format]
line-width = 100

[lint]
disable = ["one-class-per-file"]

The file is read by both the command line and the editor, so they always agree. All settings are listed under Configuration.

Set up your editor

rafiki server runs a language server, so your editor can show lint findings as you type and format files when you save them. See Editor integration for how to set it up.

Run it in CI

Both commands exit with 1 when they find something, so they can gate a pull request. A GitHub Actions job could look like this:

jobs:
  rafiki:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo install --git https://github.com/bombsimon/monkey-c-rs rafiki --rev <commit>
      - run: rafiki fmt --check
      - run: rafiki lint

Installing from source compiles rafiki, which takes a few minutes. Caching ~/.cargo/bin between runs avoids doing it every time.

Formatter

rafiki fmt formats Monkey C code, with close to nothing to configure. Run it with --check or --diff to see what would change without writing anything. If you’re coming from Prettier, see Differences from Prettier.

Note

Feedback on the formatter is appreciated, whether it’s a bug, an inconsistency or a thought on how code should be formatted. Please open an issue on GitHub.

Never changes meaning

The formatter changes whitespace and line breaks, and the rewrites below that spell the same code in one consistent way. It never changes what the code does. Changes that could, or that change the shape of the code like merging two ifs, are left to the linter, where they can be reviewed on their own.

Two more changes are always made:

  • new Foo gets an empty argument list and becomes new Foo().
  • Extra semicolons are dropped, so var x = 1;; becomes var x = 1;.

Rewrites

Monkey C often has several ways to write the same thing. The formatter picks one of them, and each choice is a setting that can also keep the code as written with "preserve".

// Before
using Toybox.WatchUi;
import Toybox.Lang;
using Toybox.Graphics;

(:debug, :background)
static private var mLabel as String or Null;

function width(values as Array<Number | Float>) as Number {
    return (values.size() * 2);
}

function title() {
    return @Rez.Strings.Title;
}

// After
using Toybox.Graphics;
using Toybox.WatchUi;

import Toybox.Lang;

(:debug :background)
private static var mLabel as String?;

function width(values as Array<Number or Float>) as Number {
    return values.size() * 2;
}

function title() {
    return Rez.Strings.Title;
}
SettingDefaultRewrites
union-style"or"Number | Float to Number or Float
nullable"shorthand"String or Null to String?
annotation-separator"space"(:debug, :background) to (:debug :background)
resource-ref"bare"@Rez.Strings.Title to Rez.Strings.Title
modifier-order"visibility-first"static private to private static
parens"normalize"return (a + b); to return a + b;
sort-importstrueImports sorted into groups
[format.numbers]See settings0Xff to 0xFF, .5 to 0.5, 1E+05 to 1e5

A rewrite is skipped wherever it would read differently, or where a comment inside the code it changes could end up next to the wrong part. To keep one spot as written, use rafiki: fmt off.

Wrapping long lines

The formatter uses 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 width can be changed with the line-width setting.

Anything that can wrap, such as an argument list, an array or a condition, stays on one line if it fits together with whatever follows it on that line. Otherwise it’s broken with each part on its own line, starting from the outermost construct, and anything still too long is broken the same way.

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.

The formatter never adds a trailing comma itself.

A list without a trailing comma folds onto one line when it fits:

// Before
var values = [
    1,
    2
];

// After
var values = [1, 2];

A trailing comma keeps it broken, one item per line, even when it would fit:

// Before
var values = [1, 2,];

// After
var values = [
    1,
    2,
];

The same goes for parameters and call arguments:

// Before
function f(first, second,) {
    call(first, second,);
}

// After
function f(
    first,
    second,
) {
    call(
        first,
        second,
    );
}

Column alignment

The formatter pads names so that => in dictionaries and = in enums line up in a column, and does the same for trailing comments on consecutive lines. The intent is purely visual, to make related entries easier to scan. It can be turned off with the alignment setting.

Alignment only applies to entries that are already on separate lines, so a dictionary needs a magic trailing comma or has to be too long for one line. In enums only runs of consecutive variants with an explicit value are aligned.

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

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

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

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

Filling short arrays

An array that has to wrap usually gets one item per line. When every item is short, it fills each line instead and lines the items up in columns, so byte arrays and lookup tables don’t run for pages. Numbers line up by their last digit and everything else by its first character. Here at a line width of 80 and 30:

var parameters = [
    0x56, 0x34, 0x12, 0x63, 0x96, 0x47, 0x71, 0x73, 0x4f, 0xbd, 0x76, 0xe3,
    0xb4, 0x05, 0x19, 0xd1, 0xd9, 0x4a, 0x48
]b;

var offsets = [
       0, 12, 250, -3, 1024,
    -512, 16,  88,  7,   42
];

An item counts as short when it’s at most 8 columns wide, which can be changed with the short-array-element-width setting. A magic trailing comma still breaks the array, and the items are filled inside it. An array with a comment or a blank line between its items keeps one item per line, since those usually group the items by hand.

Comments

Comments are kept where they are and are never dropped. When the usual layout would move a comment, the formatter leaves that code as written instead, so a comment stays next to the code it describes. If the formatter can’t find a place for one, it warns rather than losing it.

The text of a comment is left as written apart from trailing whitespace, so a long comment isn’t rewrapped and can go past the line width. A // comment at the end of a line doesn’t count toward the width either, so it never forces the code before it to wrap.

Skipping formatting

To keep some code exactly as written, like a hand-aligned table, put it between rafiki: fmt off and rafiki: fmt on comments:

// rafiki: fmt off
const LOOKUP = [
    1,   2,   3,
    10,  20,  30,
];
// rafiki: fmt on

Both comments go on their own lines between statements or declarations, like ruff’s fmt: off. Without a rafiki: fmt on, the rest of the block is kept as written. The lines keep their layout relative to each other, but the code as a whole is indented to match the code around it. A line indented less than the rafiki: fmt off comment is moved in to line up with it.

Differences from Prettier

Prettier with its Monkey C plugin is the other formatter in use for Monkey C, so it’s a natural point of comparison. rafiki doesn’t aim to reproduce it line for line, and it has no flags to emulate it. This page lists the differences currently known and why they exist. It isn’t complete, and entries may change as either formatter evolves.

The examples compare the output of both at a line width of 80 with alignment disabled.

Line width

rafiki’s default line width is 111 to match modern formatters and monitors, while Prettier defaults to 80. Both can be reconfigured, with --line-width or line-width in rafiki.toml.

Alignment

rafiki aligns dictionaries and trailing comments into columns for readability, which Prettier doesn’t. It can be disabled with --no-alignment, or with alignment = false in rafiki.toml.

Short arrays

Both formatters fill the lines of a wrapped array instead of giving each item its own line, but differ in when and how. Prettier only fills arrays of numbers and puts as many on each line as fit. rafiki fills any array of short items and lines them up in columns, with numbers aligned by their last digit.

// rafiki
var keys = [
    :alpha, :beta,   :gamma, :delta, :epsilon, :zeta, :eta, :theta, :iota,
    :kappa, :lambda, :mu
];

// Prettier
var keys = [
    :alpha,
    :beta,
    // ...
    :lambda,
    :mu,
];

Trailing commas

Prettier adds a trailing comma to every list it breaks over multiple lines. rafiki only keeps a trailing comma that’s already in the source, where it works as a magic trailing comma and keeps the list broken. Leaving it out means a list folds back onto one line by itself if it shrinks, e.g. after renaming a variable.

// rafiki
var settings = {
    :background => Graphics.COLOR_BLACK,
    :foreground => Graphics.COLOR_WHITE
};

// Prettier
var settings = {
    :background => Graphics.COLOR_BLACK,
    :foreground => Graphics.COLOR_WHITE,
};

Parentheses

Prettier rewrites parentheses: it drops the ones precedence makes redundant and adds some for clarity. rafiki drops the ones that can’t change how the code reads, but never adds any.

// rafiki
var width = (percent / 100f * total).toLong();
var scaled = 10 / 360.toFloat();

// Prettier
var width = ((percent / 100f) * total).toLong();
var scaled = 10 / (360).toFloat();

Number literals

Both rewrite number literals with lowercase exponents and a leading zero before the decimal point. Prettier also lowercases hex digits and drops trailing zeros after the decimal point, where rafiki uppercases hex digits and keeps trailing zeros. Setting hex-digit-case = "lower" and trailing-zeros = "trim" matches Prettier.

// rafiki
var color = 0xFFAA00;
var half = 0.5;
var ratio = 0.80;
var tiny = 3.5e-7f;

// Prettier
var color = 0xffaa00;
var half = 0.5;
var ratio = 0.8;
var tiny = 3.5e-7f;

Annotations

Prettier separates annotations with a comma and no space. rafiki separates them with a space, the only form Garmin documents.

// rafiki
(:excludeFromLowMemoryDevices :excludeFromDataField)

// Prettier
(:excludeFromLowMemoryDevices,:excludeFromDataField)

Nested generic types

Prettier puts a space between the closing > of nested generic types. rafiki writes them together.

// rafiki
var rows = [] as Array<Array<Number>>;

// Prettier
var rows = [] as Array<Array<Number> >;

Binary operators

When an expression has to break, rafiki breaks before an operator and starts the continuation line with it. Prettier breaks after the operator and leaves it at the end of the line. This is one of the most common differences between the two, and applies to every binary operator, most often + when building strings and && or || in conditions.

// rafiki
string += posnTime.hour.format("%2d")
    + ":"
    + posnTime.min.format("%02d")
    + ":"
    + posnTime.sec.format("%02d");

// Prettier
string +=
    posnTime.hour.format("%2d") +
    ":" +
    posnTime.min.format("%02d") +
    ":" +
    posnTime.sec.format("%02d");

A parenthesised expression keeps its ( next to the first operand and its ) after the last one. Prettier moves the expression inside the parentheses, as it does for conditions. The example is formatted with parens = "preserve", since rafiki otherwise drops parentheses around a returned value.

// rafiki
return (coordinates[0] > buttonCoordinates[0][0]
    && coordinates[0] < buttonCoordinates[0][1]
    && coordinates[1] > buttonCoordinates[1][0]);

// Prettier
return (
    coordinates[0] > buttonCoordinates[0][0] &&
    coordinates[0] < buttonCoordinates[0][1] &&
    coordinates[1] > buttonCoordinates[1][0]
);

Breaking after =

Prettier first tries breaking after = and keeping the whole right-hand side together on the next line. rafiki keeps the start of the expression next to the = and breaks at its operators instead.

// rafiki
isAvailable = SomeModule.SOME_LONG_CONSTANT_NAME
    == otherObject.someLongPropertyName;

// Prettier
isAvailable =
    SomeModule.SOME_LONG_CONSTANT_NAME == otherObject.someLongPropertyName;

Conditions

A condition that doesn’t fit breaks at its operators, with the closing ) right after the last operand and { on its own line. Prettier moves the whole condition inside the parentheses instead, which leaves if ( and ) { on lines of their own.

// rafiki
if (Toybox has :SomeModule
    && Toybox.SomeModule has :someVeryLongFunctionName)
{

// Prettier
if (
    Toybox has :SomeModule &&
    Toybox.SomeModule has :someVeryLongFunctionName
) {

Call arguments

When a call doesn’t fit, rafiki puts every argument on its own line. Prettier first tries to break only the last argument when it’s a non-empty array or dictionary and the argument before it isn’t of the same kind.

// rafiki
var text = Lang.format(
    "$1$ of $2$",
    [currentValue.format("%d"), maximumValue.format("%d")]
);

// Prettier
var text = Lang.format("$1$ of $2$", [
    currentValue.format("%d"),
    maximumValue.format("%d"),
]);

Prettier tries three layouts in order: everything on one line, only the last argument broken, and every argument on its own line. rafiki only keeps brackets next to the parentheses when the array or dictionary is the only argument.

Note

Whether to expand the last argument like Prettier is still open. It saves a level of indentation for calls like Lang.format, but the other arguments end up easy to miss at the end of a long first line.

Multiple bindings

A var or const declaring several names stays on one line in rafiki when it fits. Prettier puts every name on its own line as soon as one of them has a value, even when the declaration would fit.

// rafiki
var x = 1, y = 2;

// Prettier
var x = 1,
    y = 2;

Comments

rafiki keeps comments where they are. Prettier moves some of them, most visibly a comment after an opening {, which it puts on the next line above the first statement.

// rafiki
if (isReady) { // wait for the first reading
    update();
}

// Prettier
if (isReady) {
    // wait for the first reading
    update();
}

Multi-line block comments

When a /* … */ comment is re-indented, rafiki moves every line of it by the same amount, so the layout inside the comment stays intact. Prettier only does that when every line starts with *, like a /** … */ doc comment. For any other comment it moves just the first line and leaves the rest where they were, which separates the comment from its opener. This matches how rustfmt and gofmt handle block comments.

// Source
function f() {
  if (x) {
/* disabled
foo();
    bar();
*/
                 /*
                 bar
                 */
  }
}

// rafiki
function f() {
    if (x) {
        /* disabled
        foo();
            bar();
        */
        /*
        bar
        */
    }
}

// Prettier
function f() {
    if (x) {
        /* disabled
foo();
    bar();
*/
        /*
                 bar
                 */
    }
}

Comments that are already indented correctly come out unchanged in both.

Dictionary spacing

rafiki writes one-line dictionaries without spaces inside the braces, the same way as arrays and as most of the Connect IQ SDK samples. Prettier pads them.

// rafiki
var params = {"key" => "value"};

// Prettier
var params = { "key" => "value" };

Blank lines between declarations

rafiki always puts a blank line between functions and classes. Prettier keeps whatever blank lines the source has.

// rafiki
class C {
    function a() {}

    function b() {}
}

// Prettier
class C {
    function a() {}
    function b() {}
}

Empty blocks

rafiki writes every empty block as {}. Prettier splits some of them over two lines, such as the body of an if or a class, while keeping others like function f() {} on one.

// rafiki
if (x) {}
class Foo {}

// Prettier
if (x) {
}
class Foo {
}

Long class headers

Prettier breaks a class header that doesn’t fit before extends, with { on its own line. rafiki keeps the header on one line even when it overflows.

// rafiki
class ExampleBackgroundServiceDelegate extends Toybox.Background.ServiceDelegate {

// Prettier
class ExampleBackgroundServiceDelegate
    extends Toybox.Background.ServiceDelegate
{

Index expressions

Prettier breaks inside [...] when an index expression doesn’t fit. rafiki keeps index expressions on one line, even when that overflows.

// rafiki
var entries = {
    :first => lookupTable[indexTable[SomeModule.SOME_VERY_LONG_CONSTANT_NAME_HERE]],
    :second => 2
};

// Prettier
var entries = {
    :first => lookupTable[
        indexTable[SomeModule.SOME_VERY_LONG_CONSTANT_NAME_HERE]
    ],
    :second => 2,
};

Missing semicolons

Prettier adds a semicolon the source is missing. rafiki reports a parse error instead, since the Monkey C compiler rejects the code as well.

// Source
var a = 1
var b = 2;

// Prettier
var a = 1;
var b = 2;

Linter

The linter finds code that is likely a mistake or harder to read than it needs to be. Each finding names the rule behind it, and every rule has a page under Rules explaining what it looks for and why.

How code is spelled when every spelling means the same, like or or | in a union type, is up to the formatter instead.

rafiki lint
[bool-comparison] Warning: comparison to boolean literal — `isPm == true` can be written as `isPm`
   ╭─[ source/Clock.mc:4:21 ]
   │
 4 │     var afternoon = isPm == true;
   │                     ──────┬─────
   │                           ╰─────── comparison to boolean literal — `isPm == true` can be written as `isPm`
   │
   │ Note: fix: replace with `isPm`
───╯

Fixing findings

Many rules come with a fix, which --fix applies:

rafiki lint --fix
rafiki fmt

A fix only changes the code the finding is about and leaves the rest as it is, so run the formatter afterwards to tidy up the layout. Findings without a fix are still reported, and the command exits with 1 while any remain.

Choosing rules

Every rule runs by default. To turn some off, or to run only a few, use enable and disable in rafiki.toml or the matching flags:

rafiki lint --disable one-class-per-file
rafiki lint --list-rules

Suppressing findings

To keep a rule on but silence it in one place, add a rafiki: ignore comment with the rules to skip. Anything after ] is an optional reason.

// rafiki: ignore[naming-convention] name comes from the SDK callback
function onUpdate_legacy(dc as Dc) as Void {
    var ok = a == true; // rafiki: ignore[bool-comparison]
}

A comment on its own line covers the whole declaration or statement below it, like the function above, including its annotations. A comment at the end of a line covers that line and the statement it ends, so it still applies after the formatter splits a long statement over several lines.

To silence a rule for a whole file, use rafiki: file-ignore before any code:

// rafiki: file-ignore[one-class-per-file] the view and its delegate belong together

Every comment has to name its rules; there’s no way to silence all of them at once.

To silence rules for whole paths, like every test file, use per-file-ignores in rafiki.toml instead. To skip linting some paths entirely, use exclude.

Suppressions are checked too. unused-ignore flags a rule with nothing left to silence, and --fix removes it. invalid-directive flags a rafiki: comment that can’t be read, like one naming a rule that doesn’t exist.

Rules

Each rule has a page explaining what it looks for, what it leaves alone and why it exists. The examples show the code after rafiki lint --fix followed by rafiki fmt.

RuleFlagsFix
bool-comparisonComparing with true or falseYes
collapsible-else-ifAn else block that only contains an ifYes
collapsible-ifAn if that only contains another ifYes
compound-assignmentx = x + n instead of x += nYes
ifs-same-condTwo branches of an if chain with the same conditionNo
invalid-directiveA rafiki: comment that isn’t a valid directiveNo
naming-conventionNames that don’t follow Garmin’s coding conventionsNo
one-class-per-fileMore than one class in a fileNo
super-initializer-callAn initialize that doesn’t call the parent’sNo
unused-ignoreA rule in a rafiki: ignore comment with nothing to silenceYes

bool-comparison

Flags a comparison with true or false, which can be written as the value itself or its negation.

Why

if (ready == true) says the same thing as if (ready), and if (ready == false) the same as if (!ready). The comparison only adds something to read past.

What it flags

An == or != where one side is true or false, on either side:

ComparisonWritten as
x == truex
x != falsex
x == false!x
x != true!x

When the fix negates a comparison or a ternary, it adds parentheses so the ! applies to all of it: a < b == false becomes !(a < b).

What it leaves alone

  • A comparison between two literals, like true == false.
  • Other operators, like count > 0.

Example

// Before
if (ready == true) {
    start();
}

return done != false;

// After
if (ready) {
    start();
}

return done;

collapsible-else-if

Flags an else block that only contains an if, which can be written as else if.

Why

else { if (…) { … } } behaves exactly like else if (…) { … }, with an extra level of braces and indentation.

What it flags

An else block whose only statement is an if. The inner if keeps its own else, if it has one.

What it leaves alone

  • An else block with a comment next to the if, since collapsing would lose the comment.
  • An else block with anything besides the if.

Example

// Before
if (a) {
    first();
} else {
    if (b) {
        second();
    } else {
        third();
    }
}

// After
if (a) {
    first();
} else if (b) {
    second();
} else {
    third();
}

collapsible-if

Flags an if that only contains another if, where the two conditions can be joined with &&.

Why

When the inner if is all the outer one does, the nesting doesn’t add anything. The two checks are really one condition and read better written as one.

What it flags

An if without an else whose only statement is another if without an else. With an else on either of them, merging would change what runs, so those are left alone.

A condition that binds looser than &&, like an || or a ternary, gets parentheses when merged: if (a || b) { if (c) { … } } becomes if ((a || b) && c) { … }.

What it leaves alone

  • An if with a comment next to the inner if, since merging would lose the comment.
  • An if with anything besides the inner if.

Example

// Before
if (ready) {
    if (count > 0) {
        process();
    }
}

// After
if (ready && count > 0) {
    process();
}

compound-assignment

Flags an assignment like x = x + n that can use a compound operator like x += n, or x++ and x--.

Why

In x = x + 1 the reader has to check that both sides really are the same x before they can read it as “increase x”. x++ says that directly.

What it flags

An assignment where the right side starts with the same target as the left, followed by an operator that has a compound form. The target can be a variable, a member like obj.x or an index like arr[i], and they can be nested.

Written asBecomes
x = x + 1x++
x = x - 1x--
x = x + nx += n
x = x - nx -= n
x = x * nx *= n
x = x / nx /= n
x = x % nx %= n
x = x & nx &= n
x = x | nx |= n
x = x ^ nx ^= n
x = x << nx <<= n
x = x >> nx >>= n

What it leaves alone

  • Targets where rewriting changes how often something runs, like arr[next()] = arr[next()] + 1.
  • The target on the right of the operator, like x = 1 + x.
  • Different targets on each side, like obj.x = obj.y + 1.

Example

// Before
x = x + 1;
x = x * n;
obj.count = obj.count + 3;
arr[i] = arr[i] * 2;

for (i = 0; i < 10; i = i + 1) {
    process(i);
}

// After
x++;
x *= n;
obj.count += 3;
arr[i] *= 2;

for (i = 0; i < 10; i++) {
    process(i);
}

ifs-same-cond

Flags an if / else if chain where two branches have the same condition.

Why

The first branch with a condition always wins, so a later branch with the same condition can never run. It’s almost always a copy and paste mistake where the second condition should have been something else.

What it flags

Two branches in the same chain with the same condition, ignoring whitespace. They don’t have to be next to each other.

What it leaves alone

  • Separate if statements with the same condition, since both can run.
  • Conditions with a function call or new, since those can give a different result each time.

Example

if (status == OK) {
    handleOk();
} else if (status == OK) {
    handleError();
}

There’s no fix, since the rule can’t know what the second condition should be.

invalid-directive

Flags a rafiki: comment that rafiki can’t read, like a rafiki: ignore with a typo in it.

Why

A directive with a mistake in it does nothing, so a finding it was meant to hide is still reported and the comment looks like it should work.

What it flags

  • A word after rafiki: that isn’t a known directive
  • rafiki: fmt without off or on after it
  • A rafiki: fmt off or rafiki: fmt on that shares its line with code, or a rafiki: fmt off with no declaration or statement after it, which the formatter leaves as a plain comment
  • A rafiki: fmt on with no rafiki: fmt off before it in the same block, or a rafiki: fmt off when formatting is already off
  • A rule name that doesn’t exist
  • A missing [ or ], or an empty rule list
  • A file-ignore after the first line of code
  • unused-ignore or invalid-directive in a rule list, since these can’t be silenced with a comment

Example

// rafiki: ignore[bool-comparisons]
var ready = isReady == true;

bool-comparisons is flagged, since the rule is called bool-comparison. There’s no fix, since rafiki can’t know what was meant.

naming-convention

Flags names that don’t follow Garmin’s Monkey C coding conventions.

Why

Consistent names tell you what something is at a glance, like whether myThing is a class or a function. The rules below follow Garmin’s conventions.

What it flags

KindConventionExample
Modules and classesPascalCaseMyClass
Functions and parameterscamelCasemyFunction(myArg)
Public class memberscamelCasevar myValue;
Private, protected and hidden members_camelCaseprivate var _value;
VariablescamelCasevar myTotal = 0;
Enum valuesSCREAMING_SNAKE_CASE, one prefixCOLOR_RED, COLOR_BLUE

What it leaves alone

Constants. Garmin’s conventions don’t say how to name them, so const names aren’t checked. The SDK itself mostly uses SCREAMING_SNAKE_CASE for them.

Example

class example {
    var Value = 1;
    private var mCounter as Number = 0;

    function MyFn() {}
}

Each finding suggests a name that follows the convention, like Example for example and _mCounter for mCounter. There’s no fix, since renaming something means changing every place it’s used.

one-class-per-file

Flags a file with more than one class, following Garmin’s Monkey C coding conventions.

Why

With one class per file, the file name tells you where to find a class. A second class in the same file is usually a helper that deserves its own file.

What it flags

Every class after the first in a file, including classes inside modules.

What it leaves alone

Functions, modules, typedefs and imports next to the one class.

Example

class Foo {
    function foo() {}
}

class Bar {
    function bar() {}
}

Bar is flagged. There’s no fix, since moving it means picking a file name and deciding what to bring along.

super-initializer-call

Flags a class whose initialize doesn’t call the initialize of the class it extends.

Why

Monkey C doesn’t call the parent’s initialize for you when a class defines its own. Forgetting it leaves the parent half set up, which usually only shows when something reads one of its fields.

What it flags

A class that extends another class, defines initialize and never calls the parent’s initialize anywhere in it. For extends WatchUi.View, both View.initialize() and WatchUi.View.initialize() count.

What it leaves alone

  • Classes that don’t extend another class.
  • Classes that don’t define initialize, since the parent’s is then used.

Example

// Before
class MyView extends WatchUi.View {
    function initialize() {
        _state = 0;
    }
}

// After fixing it by hand
class MyView extends WatchUi.View {
    function initialize() {
        View.initialize();
        _state = 0;
    }
}

There’s no fix, since the rule can’t know what to pass to the parent.

unused-ignore

Flags a rule in a rafiki: ignore comment that has nothing to silence.

Why

Suppressions outlive the code they were written for. Once the finding is gone, the comment only hides the next real finding of that rule in the same place.

What it flags

Each rule in an ignore or file-ignore comment that reports nothing in the code the comment covers.

What it leaves alone

Rules turned off with disable or left out of enable, since they can’t report anything anyway. A suppression for them stays valid if they’re turned back on.

Example

// Before
var total = count + 1; // rafiki: ignore[bool-comparison]
var ready = isReady == true; // rafiki: ignore[bool-comparison, naming-convention]

// After
var total = count + 1;
var ready = isReady == true; // rafiki: ignore[bool-comparison]

When some of a comment’s rules are still in use, the fix keeps those and drops the rest.

This rule can’t be silenced with a rafiki: ignore comment, but it can be turned off with disable.

Editor integration

rafiki server is a language server, so any editor that supports the protocol can use the formatter and linter while you work. It gives you:

  • Lint findings and syntax errors as you type.
  • Formatting of the whole file, for example on save.
  • Lint fixes as code actions, either for a single finding or for the whole file.
  • A code action to ignore a finding with a rafiki: ignore comment.

The server is meant to run next to the language server that comes with the Connect IQ SDK, which handles code navigation like going to a definition. It doesn’t try to replace it. Setup for Neovim is covered in Neovim, and garmin-monkeyc.nvim shows how to run both servers together.

Setting it up

Point your editor at the command rafiki server for .mc files. The server talks over stdio and needs nothing else. There’s no packaged extension for VS Code yet, but an editor that can start a custom language server works the same way.

Settings

The server reads the same rafiki.toml as the command line, found from the root of the workspace your editor opens. Your editor can also send the formatting settings as initializationOptions, which then override the file. Their names are listed under Settings.

Settings are read when the server starts, so restart it after changing them.

Good to know

  • A file that doesn’t parse can’t be formatted, so formatting does nothing until the syntax error is fixed. The error shows up as a diagnostic.
  • Each lint finding is a warning tagged with its rule name, like bool-comparison.
  • When two fixes overlap, fixing the whole file applies the first one. The other is applied the next time you fix the file.

Neovim

Neovim has a built-in language server client, so no plugin is needed. It doesn’t know the .mc extension, though, so the setup registers the file type and then starts the server for it:

vim.filetype.add({ extension = { mc = "monkeyc" } })

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(),
    })
  end,
})

Open a .mc file and diagnostics show up as you edit. Settings are best kept in the project’s rafiki.toml, but you can override the formatting ones by adding init_options = { lineWidth = 100 } to vim.lsp.start.

Format and fix on save

This applies every lint fix and then formats the file each time it’s saved:

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,
})

Leave out the code_action call to only format. Fixes for a single finding are still available on demand through vim.lsp.buf.code_action().

If you use a plugin that manages formatting, like conform.nvim or none-ls, make sure it falls back to the language server for the monkeyc file type.

Troubleshooting

  • Check that the server is attached with :checkhealth vim.lsp. If it isn’t, make sure :set filetype? prints monkeyc.
  • If formatting on save does nothing, the file probably has a syntax error. :lua =vim.diagnostic.get(0) lists the diagnostics.
  • Run :LspRestart after installing a new version of rafiki or changing rafiki.toml.
  • For more detail, run :lua vim.lsp.set_log_level("debug") and read the log at vim.lsp.get_log_path().

Test coverage

The Connect IQ SDK can run unit tests but can’t tell you what they cover. rafiki coverage adds that by recording which functions run during your tests.

Note

Coverage is per function. A function counts as covered as soon as it runs, however much of it runs.

Run it

With the simulator running and monkeyc and monkeydo from the SDK on your PATH, run this from anywhere in the project:

rafiki coverage test -d fr965 -y developer_key.der

It builds the tests, runs them in the simulator and prints how many functions in each file ran, along with the ones that didn’t:

╭─────────────────────┬─────────┬──────────────────╮
│ FILE                ┆ COVERED ┆ MISSED           │
╞═════════════════════╪═════════╪══════════════════╡
│ source/ClockView.mc ┆ 2/3     ┆ ClockView.onHide │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ source/Format.mc    ┆ 1/2     ┆ formatDate       │
╰─────────────────────┴─────────┴──────────────────╯

TOTAL 3/5 functions executed (60%)

The command fails when the build fails or a test fails. A failing test still prints the table, since the code did run.

A few flags are useful here:

  • --start-simulator starts the simulator if it isn’t running.
  • --out-format lcov --out coverage.info writes an LCOV report for tools like genhtml, Codecov or the VS Code Coverage Gutters extension.
  • --dry-run prints the monkeyc and monkeydo commands instead of running them.
  • Anything after -- is passed on to monkeyc, like -- -O 3.

Run rafiki coverage test --help for the full list.

What’s left out

Functions annotated with (:test) or (:release) aren’t counted, since test code shouldn’t cover itself and tests don’t run release builds. Skip more annotations with --exclude-annotation.

Run each step yourself

test is a shortcut for three steps, which you can also run one at a time, for example in CI:

rafiki coverage instrument
monkeyc -f bin/coverage/coverage.jungle -d fr965 -o bin/coverage/cov.prg -y developer_key.der --unit-test
monkeydo bin/coverage/cov.prg fr965 -t | rafiki coverage report -

How it works explains what each step does.

How it works

Monkey C has no hooks for coverage, so rafiki changes a copy of the source instead. The steps are the same whether you run rafiki coverage test or each step yourself.

Instrument

rafiki coverage instrument copies every source file of the project into bin/coverage and adds a call at the start of each function, like AutoGeneratedCov.hit(3). Apart from those calls the copies are identical to the originals. It also writes:

  • AutoGeneratedCov.mc, which prints a line like COVHIT 3 the first time each function runs.
  • coverage-manifest.tsv, which maps each number back to its file and function.
  • coverage.jungle, a copy of your monkey.jungle adjusted to build the copies. It points at your real manifest.xml and adds the output directory to the source paths.

The project root is the nearest directory with a manifest.xml, so the command works from anywhere in the project.

Build and run

Building coverage.jungle with monkeyc --unit-test and running the result with monkeydo -t runs your tests as usual, with a COVHIT line printed for every function that runs.

Report

rafiki coverage report reads that output and the manifest, and prints the coverage per file. Lines that aren’t a COVHIT, and repeated hits for the same function, are ignored.

Limitations

  • Coverage is per function, not per line or branch.
  • Resource paths in the jungle file are copied as they are, so a project with relative resourcePath entries needs to adjust them by hand.

Configuration

Settings live in a rafiki.toml at the root of your project. Every setting is optional, and a project without the file uses the defaults. The available settings are listed under Settings.

[format]
line-width = 111
alignment = true

[lint]
disable = ["one-class-per-file"]

[files]
exclude = ["bin/**", "vendor/**"]

Where it’s read from

rafiki uses the nearest rafiki.toml in or above the first path you pass it, so it finds the project’s settings from anywhere inside the project. The language server looks from the root of the workspace your editor opens.

To use a specific file, or none at all:

rafiki fmt --config path/to/rafiki.toml
rafiki fmt --no-config

What takes precedence

A setting can come from three places, and each one overrides the one before it:

  1. The default.
  2. rafiki.toml.
  3. A flag on the command line, or an option sent by your editor.

Only the settings you actually pass override anything, so --line-width 80 changes the width and keeps the rest of the file. A list given as a flag replaces the list from the file instead of adding to it, so --disable naming-convention disables exactly that one rule.

Mistakes in the file

An unknown key is an error, so a typo doesn’t silently do nothing:

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`, `short-array-element-width`, `union-style`, `nullable`, `annotation-separator`, `resource-ref`, `modifier-order`, `parens`, `sort-imports`, `exclude`, `numbers`

On the command line that stops the run with exit code 2. The language server shows the error in your editor instead and carries on with the defaults, so you keep your diagnostics while you fix the file.

An invalid glob in an exclude or per-file-ignores setting is reported the same way, except that the language server only drops that one glob and keeps the rest of the file, so a typo doesn’t start formatting the files your other excludes protect.

Settings

Each setting lists its default and how to set it outside of rafiki.toml. Only the formatting settings can be sent by an editor. The rest are read from the file.

Every setting at its default:

[format]
line-width = 111
alignment = true
short-array-element-width = 8
union-style = "or"
nullable = "shorthand"
annotation-separator = "space"
resource-ref = "bare"
modifier-order = "visibility-first"
parens = "normalize"
sort-imports = true
exclude = []

[format.numbers]
hex-prefix-case = "lower"
hex-digit-case = "upper"
exponent-case = "lower"
exponent-padding = "trim"
float-suffix-case = "lower"
double-suffix-case = "lower"
long-suffix-case = "upper"
leading-zero = "add"
trailing-zeros = "preserve"

[lint]
enable = []
disable = []
exclude = []

[lint.per-file-ignores]
# "tests/**" = ["naming-convention"]

[files]
exclude = []
respect-gitignore = true

[format]

line-width

The width the formatter tries to keep lines within. See Wrapping long lines.

  • Default: 111
  • Flag: --line-width <COLUMNS>, or -l <COLUMNS>
  • Editor option: lineWidth

alignment

Whether to line up => in dictionaries, = in enums and trailing comments in columns. See Column alignment.

  • Default: true
  • Flag: --alignment or --no-alignment
  • Editor option: alignment

short-array-element-width

The widest an array item can be for the array to fill its lines instead of putting each item on its own line. Every item has to fit, and 0 turns filling off. See Filling short arrays.

  • Default: 8
  • Flag: --short-array-element-width <COLUMNS>
  • Editor option: shortArrayElementWidth

The settings below choose how the formatter spells code, not just how it lays it out. Each one rewrites the code into a form that means exactly the same, and "preserve" keeps it as written. They’re only read from rafiki.toml, since the spelling is a choice for the whole project. See Rewrites.

union-style

The separator between the members of a union type: "or" for Number or Float, "pipe" for Number | Float, or "preserve".

or is what Garmin’s documentation and nearly all Monkey C code use, and it can’t be mistaken for a bitwise or. A cast only reads | as a union when a type name, { or [ follows it, so "pipe" keeps or in front of a parenthesized member there.

  • Default: "or"

nullable

How a type that may be null is written: "shorthand" for String?, "union" for String or Null, or "preserve". With "union", a union with several ? members gets a single Null.

Only a union of one type with Null has a shorthand. String or Number or Null is kept, and so is Null or Method() as Boolean, where the ? would go to the return type.

  • Default: "shorthand"

annotation-separator

The separator between the entries of an annotation: "space" for (:debug :background), "comma" for (:debug, :background), or "preserve". Space is the only form Garmin documents.

  • Default: "space"

resource-ref

Whether to drop the legacy @ in front of a resource reference, which compiles the same without it: "bare" writes Rez.Strings.name, "preserve" keeps @Rez.Strings.name.

  • Default: "bare"

modifier-order

The order of the visibility and static keywords: "visibility-first" for private static, or "preserve". The compiler accepts both, and visibility first matches Java and C#. Keywords with a comment between them are kept as written.

  • Default: "visibility-first"

parens

Whether the formatter decides where parentheses go: "normalize" or "preserve". Normalizing drops the ones that can’t change how the code is read and adds the ones that make it easier to read.

They’re dropped around a whole expression where something else already ends it: the value of an assignment, initializer or return, a call argument, array element, dictionary key or value, index, condition, and a ternary’s condition or branches. They’re also dropped around an operand whose operator binds tighter than the one around it, or as tight on the left, around the same &&, ||, &, | or ^ on the right, so a || (b || c) becomes a || b || c, around a name, literal, call or other single operand, and around a Method(…) type that nothing follows.

A pair with a comment inside is kept, and so are pairs whose removal would change how the code reads: around a cast that something follows, a ternary inside a ternary’s condition or then branch, and -(-x).

They’re added, or kept, where operators mix in a way that’s easy to misread: *, / and % with each other and && inside ||, as Prettier does, and every mix where Monkey C groups differently from C. Monkey C puts <<, >>, &, instanceof and has on the same level as *, and | and ^ with +, so a + b << c means a + (b << c), and that’s how it’s written.

var thirds = width / 3 * 2;      // var thirds = (width / 3) * 2;
var sumShift = a + b << c;       // var sumShift = a + (b << c);
var maskEquals = x & mask == 0;  // var maskEquals = (x & mask) == 0;
var either = a || b && c;        // var either = a || (b && c);
  • Default: "normalize"

sort-imports

Whether to sort each run of using and import declarations into four groups split by blank lines: using Toybox.*, import Toybox.*, the other usings, then the other imports, each sorted by name. A run with a comment between its imports, at the end of the last one or right above the first is kept as written, since the comment could be about any one of them. A comment with a blank line below it is taken to be about the whole block, so the run is still sorted. A run right after an annotation is kept too, since the annotation belongs to the import below it. A rafiki: fmt off region splits a run in two.

  • Default: true

exclude

Paths to never format, on top of [files] exclude, like a file of hand-aligned tables that’s still linted. Uses the same globs, relative to rafiki.toml.

  • Default: []

[format.numbers]

How number literals are spelled, one setting per part of the literal. Like the settings above they’re only read from rafiki.toml, and none of them changes a literal’s type or value: whether it has a ., an exponent or a suffix stays as written, and so do the digits of a decimal integer.

The casing settings take "lower", "upper" or "preserve".

hex-prefix-case

The casing of the 0x in front of a hex literal: 0XFF to 0xFF.

  • Default: "lower"

hex-digit-case

The casing of the digits of a hex literal: 0xff to 0xFF. Uppercase is what Garmin’s documentation uses, and it sets the digits apart from the lowercase x.

  • Default: "upper"

exponent-case

The casing of the e in scientific notation: 1E5 to 1e5.

  • Default: "lower"

exponent-padding

Whether to drop the + and leading zeros of an exponent: "trim" writes 2.5e+07 as 2.5e7, "preserve" keeps it.

  • Default: "trim"

float-suffix-case

The casing of the f that makes a literal a Float: 1.0F to 1.0f.

  • Default: "lower"

double-suffix-case

The casing of the d that makes a literal a Double: 1.0D to 1.0d.

  • Default: "lower"

long-suffix-case

The casing of the l that makes a literal a Long, in decimal and hex alike: 5l to 5L and 0xFFl to 0xFFL. Uppercase is the default since 10l is easy to misread as 101.

  • Default: "upper"

leading-zero

Whether to write the 0 in front of a fraction: "add" writes .5 as 0.5 and .5d as 0.5d, "preserve" keeps it.

  • Default: "add"

trailing-zeros

Whether to drop zeros at the end of a fraction: "trim" writes 1.50 as 1.5, "preserve" keeps it. One digit always stays after the ., so 1.0 is never written as 1, which would be a Number. Kept by default, since trailing zeros are often there on purpose, like lining up a table of values by precision.

  • Default: "preserve"

[lint]

enable

Rules to run. When the list is empty every rule runs, otherwise only the ones listed. rafiki lint --list-rules prints the names, and each rule is described under Rules.

  • Default: []
  • Flag: --enable <RULE>, comma separated or repeated

disable

Rules to turn off. A rule listed in both enable and disable is turned off.

  • Default: []
  • Flag: --disable <RULE>, comma separated or repeated

A rule name that doesn’t exist is an error. Otherwise a typo in enable would turn every rule off and look like a clean run.

exclude

Paths to never lint, on top of [files] exclude, like test fixtures that are still formatted. Uses the same globs, relative to rafiki.toml.

  • Default: []

per-file-ignores

Rules to turn off for some paths only, as a table from a glob to a list of rules. Uses the same globs as exclude, relative to rafiki.toml.

[lint.per-file-ignores]
"tests/**" = ["naming-convention"]
"source/Strings.mc" = ["one-class-per-file", "naming-convention"]
  • Default: {}

[files]

exclude

Paths to neither format nor lint, as globs in .gitignore syntax. In rafiki.toml they’re relative to the file itself, and on the command line they’re relative to the current directory.

As in .gitignore, a ! pattern brings back a path an earlier pattern excluded, but not a file inside an excluded directory. Use gen/* with !gen/Keep.mc rather than gen/.

Excluded files are skipped even when you name them on the command line, so a pre-commit hook or editor integration that passes every changed file still leaves them alone. The language server skips them too. Use --no-config to reach one anyway.

  • Default: []
  • Flag: --exclude <GLOB>, repeated for more than one

respect-gitignore

Whether to skip files that .gitignore excludes. Hidden directories like .git are skipped either way.

  • Default: true
  • Flag: --respect-gitignore or --no-respect-gitignore

CLI

Everything runs through the rafiki command:

CommandDoes
rafiki fmtFormats source files
rafiki lintReports lint findings, and fixes them with --fix
rafiki serverRuns the language server on stdio
rafiki coverageMeasures test coverage
rafiki completionsPrints a shell completion script

Every command has --help. The flags for each setting are listed under Settings.

Choosing files

rafiki fmt and rafiki lint take any mix of files and directories, and use the current directory when given none:

rafiki fmt
rafiki fmt source/ tests/Tests.mc

Directories are searched for .mc files. A file you name directly is used whatever its extension. Hidden directories and anything .gitignore excludes are skipped, see [files] to change that. Paths in an exclude setting are skipped even when named.

Pass - to read from stdin instead:

cat Clock.mc | rafiki fmt -
cat Clock.mc | rafiki lint -

The formatted code goes to stdout and findings go to stderr. --fix has no effect on stdin.

Checking without writing

rafiki fmt writes the formatted code back to each file. To only report what would change:

rafiki fmt --check
rafiki fmt --diff

--check lists the files that aren’t formatted, and --diff prints the changes.

Exit codes

CodeMeaning
0Nothing to report.
1Something was found, like an unformatted file or a lint finding.
2The command couldn’t run, for example because of a bad argument.

Keeping 1 and 2 apart lets CI tell failing checks from a broken setup.

Color

Output is colored when it goes to a terminal. --color always or --color never overrides that, and the NO_COLOR, CLICOLOR_FORCE and TERM=dumb environment variables are respected.

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 are supported too.

Design principles

These are the ideas behind how the tools behave. When a choice isn’t obvious, it usually comes back to one of them.

Close to zero configuration

A formatter is most useful when nobody argues about its output. rafiki has a few settings, like the line width, and a handful for how code is spelled where Monkey C has several spellings for the same thing. Each of those defaults to one canonical form and can be set to keep the code as written. Options that only exist to match another formatter’s output were tried and removed, since each one doubles the ways code can look.

The formatter never changes meaning

Formatting never changes what the code does. Besides the layout, it settles spellings that mean exactly the same, like or and | in a union type, the order of imports or parentheses that can’t change how an expression groups. Those take no judgment about the code around them, so formatting is always safe to run. The tests check this by parsing the formatted code and comparing it with the original.

Changes that alter the shape of the code, or that could be wrong in a specific spot, belong to the linter, where they show up as findings you can inspect before applying.

Fixes are small edits

A lint fix replaces the few characters it’s about and leaves everything else alone, including the formatting around it. The linter and formatter stay independent that way, and running rafiki fmt after rafiki lint --fix tidies up any layout a fix leaves behind.

Same result everywhere

The command line and the language server read the same rafiki.toml and share the same formatter and linter. A file comes out identical whether it’s formatted in the terminal or in your editor.

Fill the gaps in Garmin’s tooling

Garmin’s own editor extension already handles code navigation, like going to a definition or finding references. The language server doesn’t compete with it. It adds the missing pieces, formatting and linting, and is meant to run alongside Garmin’s.

Inspired by ruff

The parser and the tools on top of it are modeled on ruff. The syntax tree keeps enough detail to write code back out, and the formatter uses the same Wadler-Lindig algorithm to decide where lines break.

Contributing

Bug reports, ideas and pull requests are all welcome. For anything bigger than a small fix, open an issue first so the approach can be discussed.

Building and testing

The project is a Rust workspace. The justfile has the commands CI runs:

just test     # run all tests
just clippy   # lint, with warnings as errors
just format   # format the code
just doc      # build the API docs, with warnings as errors
just docs     # build the book and check its links

Parts of the docs are generated from the code, like the table of lint rules. Tests fail when they fall behind, and just update-docs regenerates them.

Most formatter tests are insta snapshot tests. When a change alters the output, review the new snapshots with cargo insta review before accepting them. Every bug fix should come with a snapshot that shows the fixed case.

The docs are an mdBook under docs/. Run mdbook serve docs to preview them, and just docs to build them and check every link with lychee, the same way CI does.

Where things live

CrateContains
rafikiThe command line tool
monkey-c-parserThe lexer, parser and syntax tree
monkey-c-formatterThe formatter
monkey-c-linterThe lint rules and their fixes
monkey-c-lspThe language server
monkey-c-configThe rafiki.toml format
monkey-c-coverageThe source rewriting behind rafiki coverage
monkey-c-jungleA parser and printer for jungle files

Adding a lint rule

  1. Add a module under monkey-c-linter/src/rules with a check_expr or check_stmt function that returns a Diagnostic, and a fix if one is safe.
  2. Call it from the matching dispatch_* function in visit.rs.
  3. Add it to rules::ALL with a one-line summary and whether it has a fix. The command line and the Rules table are built from it.
  4. Add tests next to the rule for what it flags and what it leaves alone.
  5. Add a page at docs/src/linter/rules/<name>/README.md, list it in docs/src/SUMMARY.md and run just update-docs to add it to the rules table.

Parser library

monkey-c-parser turns Monkey C source into an abstract syntax tree. The formatter, linter and language server are all built on it, and it can be used on its own to build other tools.

use monkey_c_parser::parser::Parser;

let output = Parser::new("function f() { return 1; }").parse()?;

// The syntax tree, and the comments kept in a separate table.
println!("{:#?}", output.ast);
println!("{:#?}", output.comments);

Comments aren’t part of the tree itself. They’re kept in their own table with their positions, so tools that don’t care about them can ignore them, and the formatter can put each one back where it belongs.

The parser follows Garmin’s Monkey C reference. Its design is based on the parser in ruff.

Jungle library

monkey-c-jungle reads and writes jungle files, which tell monkeyc which sources, resources and annotations to use for each device. It keeps comments and blank lines, so a file can be edited and written back without losing anything.

use monkey_c_jungle::ast::{JungleFile, Value};

let mut jungle = JungleFile::parse("base.sourcePath = source\n")?;

jungle.set(
    "fenix5.resourcePath",
    [
        Value::reference("fenix5.resourcePath"),
        Value::text("fenix-resources"),
    ],
);

print!("{jungle}");

Building a file

An empty JungleFile is built with the same methods:

let mut jungle = JungleFile::default();

jungle.set("project.manifest", [Value::text("manifest.xml")]);
jungle.push_blank_line();

jungle.push_comment("Shared sources");
jungle.set("base.sourcePath", [Value::text("source")]);
jungle.push_blank_line();

jungle.set(
    "round.resourcePath",
    [
        Value::reference("round.resourcePath"),
        Value::text("resources-round").with_comment("every round device"),
    ],
);

std::fs::write("monkey.jungle", jungle.to_string())?;
project.manifest = manifest.xml

# Shared sources
base.sourcePath = source

round.resourcePath = $(round.resourcePath);resources-round # every round device

Value::text is a plain value, Value::reference a $(…) and Value::group a […]. set replaces the last assignment to a name, or adds one if there is none. get reads a value back and remove deletes it.

The syntax tree

A JungleFile is a list of entries in file order: an assignment, a comment or a blank line. An assignment has a name and a list of values:

SourceParsed as
fenix5.lang.engqualifier fenix5, property lang.eng
a;btwo values
$(base.sourcePath)/sharedone value, a reference followed by text
[round.jungle;rect.jungle]one group of two values
"my sources/app.mc"one quoted value

Only ; separates values, so $(fenix5.resourcePath);extra adds a path while $(base.sourcePath)/extra extends one.

Line breaks and comments

An assignment ends at the end of the line, except in three places, the same as in monkeyc: right after the =, after a ; followed by a comment, and after a ; followed by \.

A comment runs to the end of the line and belongs to the value before it. A comment after the last value continues the assignment onto the next line, so the printer always puts a blank line after one. A # can’t appear inside a value, even in quotes.

Writing files

Printing normalizes the spacing to one space around =, no space around ; and one assignment per line. Several blank lines in a row become one, and a \ line continuation is joined onto one line. Printing a file that was already printed gives the same result, so tools that edit a file don’t make it drift.