Introduction
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 fmtformats your code.rafiki lintfinds common mistakes and can fix many of them for you.rafiki serverbrings both into your editor as a language server.rafiki coverageshows 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 Foogets an empty argument list and becomesnew Foo().- Extra semicolons are dropped, so
var x = 1;;becomesvar 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;
}
| Setting | Default | Rewrites |
|---|---|---|
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-imports | true | Imports sorted into groups |
[format.numbers] | See settings | 0Xff 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.
| Rule | Flags | Fix |
|---|---|---|
bool-comparison | Comparing with true or false | Yes |
collapsible-else-if | An else block that only contains an if | Yes |
collapsible-if | An if that only contains another if | Yes |
compound-assignment | x = x + n instead of x += n | Yes |
ifs-same-cond | Two branches of an if chain with the same condition | No |
invalid-directive | A rafiki: comment that isn’t a valid directive | No |
naming-convention | Names that don’t follow Garmin’s coding conventions | No |
one-class-per-file | More than one class in a file | No |
super-initializer-call | An initialize that doesn’t call the parent’s | No |
unused-ignore | A rule in a rafiki: ignore comment with nothing to silence | Yes |
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:
| Comparison | Written as |
|---|---|
x == true | x |
x != false | x |
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
elseblock with a comment next to theif, since collapsing would lose the comment. - An
elseblock with anything besides theif.
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
ifwith a comment next to the innerif, since merging would lose the comment. - An
ifwith anything besides the innerif.
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 as | Becomes |
|---|---|
x = x + 1 | x++ |
x = x - 1 | x-- |
x = x + n | x += n |
x = x - n | x -= n |
x = x * n | x *= n |
x = x / n | x /= n |
x = x % n | x %= n |
x = x & n | x &= n |
x = x | n | x |= n |
x = x ^ n | x ^= n |
x = x << n | x <<= n |
x = x >> n | x >>= 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
ifstatements 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: fmtwithoutofforonafter it- A
rafiki: fmt offorrafiki: fmt onthat shares its line with code, or arafiki: fmt offwith no declaration or statement after it, which the formatter leaves as a plain comment - A
rafiki: fmt onwith norafiki: fmt offbefore it in the same block, or arafiki: fmt offwhen formatting is already off - A rule name that doesn’t exist
- A missing
[or], or an empty rule list - A
file-ignoreafter the first line of code unused-ignoreorinvalid-directivein 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
| Kind | Convention | Example |
|---|---|---|
| Modules and classes | PascalCase | MyClass |
| Functions and parameters | camelCase | myFunction(myArg) |
| Public class members | camelCase | var myValue; |
| Private, protected and hidden members | _camelCase | private var _value; |
| Variables | camelCase | var myTotal = 0; |
| Enum values | SCREAMING_SNAKE_CASE, one prefix | COLOR_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: ignorecomment.
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?printsmonkeyc. - If formatting on save does nothing, the file probably has a syntax error.
:lua =vim.diagnostic.get(0)lists the diagnostics. - Run
:LspRestartafter installing a new version ofrafikior changingrafiki.toml. - For more detail, run
:lua vim.lsp.set_log_level("debug")and read the log atvim.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-simulatorstarts the simulator if it isn’t running.--out-format lcov --out coverage.infowrites an LCOV report for tools likegenhtml, Codecov or the VS Code Coverage Gutters extension.--dry-runprints themonkeycandmonkeydocommands instead of running them.- Anything after
--is passed on tomonkeyc, 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 likeCOVHIT 3the first time each function runs.coverage-manifest.tsv, which maps each number back to its file and function.coverage.jungle, a copy of yourmonkey.jungleadjusted to build the copies. It points at your realmanifest.xmland 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
resourcePathentries 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:
- The default.
rafiki.toml.- 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:
--alignmentor--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-gitignoreor--no-respect-gitignore
CLI
Everything runs through the rafiki command:
| Command | Does |
|---|---|
rafiki fmt | Formats source files |
rafiki lint | Reports lint findings, and fixes them with --fix |
rafiki server | Runs the language server on stdio |
rafiki coverage | Measures test coverage |
rafiki completions | Prints 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
| Code | Meaning |
|---|---|
0 | Nothing to report. |
1 | Something was found, like an unformatted file or a lint finding. |
2 | The 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
| Crate | Contains |
|---|---|
rafiki | The command line tool |
monkey-c-parser | The lexer, parser and syntax tree |
monkey-c-formatter | The formatter |
monkey-c-linter | The lint rules and their fixes |
monkey-c-lsp | The language server |
monkey-c-config | The rafiki.toml format |
monkey-c-coverage | The source rewriting behind rafiki coverage |
monkey-c-jungle | A parser and printer for jungle files |
Adding a lint rule
- Add a module under
monkey-c-linter/src/ruleswith acheck_exprorcheck_stmtfunction that returns aDiagnostic, and a fix if one is safe. - Call it from the matching
dispatch_*function invisit.rs. - Add it to
rules::ALLwith a one-line summary and whether it has a fix. The command line and the Rules table are built from it. - Add tests next to the rule for what it flags and what it leaves alone.
- Add a page at
docs/src/linter/rules/<name>/README.md, list it indocs/src/SUMMARY.mdand runjust update-docsto 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:
| Source | Parsed as |
|---|---|
fenix5.lang.eng | qualifier fenix5, property lang.eng |
a;b | two values |
$(base.sourcePath)/shared | one 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.