Skip to content

Commit c70a866

Browse files
authored
Linter for MicroZig (ZigEmbeddedGroup#579)
1 parent 19b6f45 commit c70a866

5 files changed

Lines changed: 324 additions & 0 deletions

File tree

.github/workflows/lint.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: Code Linting
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize]
6+
7+
jobs:
8+
lint:
9+
runs-on: ubuntu-latest
10+
permissions:
11+
contents: read
12+
pull-requests: write
13+
14+
steps:
15+
- uses: actions/checkout@v3
16+
with:
17+
fetch-depth: 0
18+
19+
- name: Setup Zig
20+
uses: mlugg/setup-zig@v2
21+
with:
22+
version: 0.14.1
23+
24+
- name: Build linter
25+
working-directory: tools/linter
26+
run: zig build --release=safe
27+
28+
- name: Run linter
29+
run: |
30+
FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} | grep '\.zig$')
31+
echo changed files: $FILES
32+
./tools/linter/zig-out/bin/linter $FILES > lint_results.json
33+
34+
- name: Post comments
35+
uses: actions/github-script@v6
36+
with:
37+
github-token: ${{ secrets.GITHUB_TOKEN }}
38+
script: |
39+
const fs = require('fs');
40+
const issues = JSON.parse(fs.readFileSync('lint_results.json', 'utf8'));
41+
42+
for (const issue of issues) {
43+
await github.rest.pulls.createReviewComment({
44+
owner: context.repo.owner,
45+
repo: context.repo.repo,
46+
pull_number: context.issue.number,
47+
commit_id: context.payload.pull_request.head.sha,
48+
path: issue.file,
49+
line: issue.line,
50+
body: issue.message
51+
});
52+
}
53+

tools/linter/build.zig

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const std = @import("std");
2+
3+
pub fn build(b: *std.Build) void {
4+
const target = b.standardTargetOptions(.{});
5+
const optimize = b.standardOptimizeOption(.{});
6+
7+
const exe_mod = b.createModule(.{
8+
.root_source_file = b.path("src/main.zig"),
9+
.target = target,
10+
.optimize = optimize,
11+
});
12+
13+
const exe = b.addExecutable(.{
14+
.name = "linter",
15+
.root_module = exe_mod,
16+
});
17+
18+
b.installArtifact(exe);
19+
20+
const run_cmd = b.addRunArtifact(exe);
21+
run_cmd.step.dependOn(b.getInstallStep());
22+
if (b.args) |args| {
23+
run_cmd.addArgs(args);
24+
}
25+
26+
const run_step = b.step("run", "Run the app");
27+
run_step.dependOn(&run_cmd.step);
28+
29+
const exe_unit_tests = b.addTest(.{
30+
.root_module = exe_mod,
31+
});
32+
33+
const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
34+
35+
const test_step = b.step("test", "Run unit tests");
36+
test_step.dependOn(&run_exe_unit_tests.step);
37+
}

tools/linter/build.zig.zon

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
.{
2+
// This is the default name used by packages depending on this one. For
3+
// example, when a user runs `zig fetch --save <url>`, this field is used
4+
// as the key in the `dependencies` table. Although the user can choose a
5+
// different name, most users will stick with this provided value.
6+
//
7+
// It is redundant to include "zig" in this name because it is already
8+
// within the Zig package namespace.
9+
.name = .linter,
10+
11+
// This is a [Semantic Version](https://semver.org/).
12+
// In a future version of Zig it will be used for package deduplication.
13+
.version = "0.0.0",
14+
15+
// Together with name, this represents a globally unique package
16+
// identifier. This field is generated by the Zig toolchain when the
17+
// package is first created, and then *never changes*. This allows
18+
// unambiguous detection of one package being an updated version of
19+
// another.
20+
//
21+
// When forking a Zig project, this id should be regenerated (delete the
22+
// field and run `zig build`) if the upstream project is still maintained.
23+
// Otherwise, the fork is *hostile*, attempting to take control over the
24+
// original project's identity. Thus it is recommended to leave the comment
25+
// on the following line intact, so that it shows up in code reviews that
26+
// modify the field.
27+
.fingerprint = 0x7456b4f0ac0d96c2, // Changing this has security and trust implications.
28+
29+
// Tracks the earliest Zig version that the package considers to be a
30+
// supported use case.
31+
.minimum_zig_version = "0.14.1",
32+
33+
// This field is optional.
34+
// Each dependency must either provide a `url` and `hash`, or a `path`.
35+
// `zig build --fetch` can be used to fetch all dependencies of a package, recursively.
36+
// Once all dependencies are fetched, `zig build` no longer requires
37+
// internet connectivity.
38+
.dependencies = .{
39+
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
40+
//.example = .{
41+
// // When updating this field to a new URL, be sure to delete the corresponding
42+
// // `hash`, otherwise you are communicating that you expect to find the old hash at
43+
// // the new URL. If the contents of a URL change this will result in a hash mismatch
44+
// // which will prevent zig from using it.
45+
// .url = "https://example.com/foo.tar.gz",
46+
//
47+
// // This is computed from the file contents of the directory of files that is
48+
// // obtained after fetching `url` and applying the inclusion rules given by
49+
// // `paths`.
50+
// //
51+
// // This field is the source of truth; packages do not come from a `url`; they
52+
// // come from a `hash`. `url` is just one of many possible mirrors for how to
53+
// // obtain a package matching this `hash`.
54+
// //
55+
// // Uses the [multihash](https://multiformats.io/multihash/) format.
56+
// .hash = "...",
57+
//
58+
// // When this is provided, the package is found in a directory relative to the
59+
// // build root. In this case the package's hash is irrelevant and therefore not
60+
// // computed. This field and `url` are mutually exclusive.
61+
// .path = "foo",
62+
//
63+
// // When this is set to `true`, a package is declared to be lazily
64+
// // fetched. This makes the dependency only get fetched if it is
65+
// // actually used.
66+
// .lazy = false,
67+
//},
68+
},
69+
70+
// Specifies the set of files and directories that are included in this package.
71+
// Only files and directories listed here are included in the `hash` that
72+
// is computed for this package. Only files listed here will remain on disk
73+
// when using the zig package manager. As a rule of thumb, one should list
74+
// files required for compilation plus any license(s).
75+
// Paths are relative to the build root. Use the empty string (`""`) to refer to
76+
// the build root itself.
77+
// A directory listed here means that all files within, recursively, are included.
78+
.paths = .{
79+
"build.zig",
80+
"build.zig.zon",
81+
"src",
82+
// For example...
83+
//"LICENSE",
84+
//"README.md",
85+
},
86+
}

tools/linter/src/main.zig

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
const Issue = struct {
2+
file: []const u8,
3+
line: u32,
4+
message: []const u8,
5+
};
6+
7+
pub fn main() !void {
8+
var debug_allocator = std.heap.DebugAllocator(.{}){};
9+
defer _ = debug_allocator.deinit();
10+
11+
var arena = std.heap.ArenaAllocator.init(debug_allocator.allocator());
12+
defer arena.deinit();
13+
14+
const allocator = arena.allocator();
15+
16+
const args = try std.process.argsAlloc(allocator);
17+
defer std.process.argsFree(allocator, args);
18+
19+
var issues: std.ArrayList(Issue) = .init(allocator);
20+
defer issues.deinit();
21+
22+
for (args[1..]) |path| {
23+
const source = try std.fs.cwd().readFileAllocOptions(allocator, path, 1024 * 1024, null, 1, 0);
24+
defer allocator.free(source);
25+
26+
var ast = try std.zig.Ast.parse(allocator, source, .zig);
27+
defer ast.deinit(allocator);
28+
for (ast.nodes.items(.tag), ast.nodes.items(.main_token)) |node_tag, main_tok_idx| {
29+
switch (node_tag) {
30+
.fn_proto_simple,
31+
.fn_proto_multi,
32+
.fn_proto_one,
33+
.fn_proto,
34+
=> {
35+
const identifier_tok = find_first_token_tag(ast, .identifier, main_tok_idx);
36+
const identifier_str = ast.tokenSlice(identifier_tok);
37+
if (is_camel_case(identifier_str) and !is_snake_case(identifier_str)) {
38+
const snake_case = try camel_to_snake(allocator, identifier_str);
39+
const location = ast.tokenLocation(0, identifier_tok);
40+
const message = try std.fmt.allocPrint(allocator, "Please change to `{s}`, in MicroZig we use snake case for function names.", .{
41+
snake_case,
42+
});
43+
44+
try issues.append(.{
45+
.line = @intCast(location.line + 1),
46+
.message = message,
47+
.file = path,
48+
});
49+
// TODO: break up camel case
50+
std.log.info("FAILED SNAKE CASE: {s}, location: {}", .{ identifier_str, location });
51+
}
52+
},
53+
54+
.global_var_decl,
55+
.local_var_decl,
56+
.aligned_var_decl,
57+
.simple_var_decl,
58+
=> {
59+
// TODO: check types for common abbreviations and ensure they follow coding style.
60+
61+
},
62+
else => {},
63+
}
64+
}
65+
}
66+
67+
const stdout = std.io.getStdOut().writer();
68+
try std.json.stringify(issues.items, .{}, stdout);
69+
}
70+
71+
const Token = std.zig.Token;
72+
const TokenIndex = std.zig.Ast.TokenIndex;
73+
74+
fn find_first_token_tag(ast: std.zig.Ast, tag: Token.Tag, start_idx: TokenIndex) TokenIndex {
75+
return for (ast.tokens.items(.tag)[start_idx..], start_idx..) |token_tag, token_idx| {
76+
if (token_tag == tag)
77+
break @intCast(token_idx);
78+
} else unreachable;
79+
}
80+
81+
const std = @import("std");
82+
const assert = std.debug.assert;
83+
const Allocator = std.mem.Allocator;
84+
85+
fn is_snake_case(str: []const u8) bool {
86+
for (str) |c| {
87+
switch (c) {
88+
'A'...'Z' => return false,
89+
else => {},
90+
}
91+
}
92+
93+
return true;
94+
}
95+
96+
fn is_camel_case(str: []const u8) bool {
97+
if (str.len == 0)
98+
return false;
99+
100+
if (!std.ascii.isLower(str[0]))
101+
return false;
102+
103+
for (str[1..]) |c| {
104+
if (c == '_')
105+
return false;
106+
}
107+
108+
return true;
109+
}
110+
111+
fn camel_to_snake(arena: Allocator, str: []const u8) ![]const u8 {
112+
if (str.len == 0)
113+
return str;
114+
115+
var ret = std.ArrayList(u8).init(arena);
116+
errdefer ret.deinit();
117+
118+
if (std.ascii.isUpper(str[0])) {
119+
try ret.append(std.ascii.toLower(str[0]));
120+
} else {
121+
try ret.append(str[0]);
122+
}
123+
124+
for (str[1..]) |c| {
125+
if (std.ascii.isUpper(c)) {
126+
// Add underscore before uppercase letters
127+
try ret.append('_');
128+
try ret.append(std.ascii.toLower(c));
129+
} else {
130+
try ret.append(c);
131+
}
132+
}
133+
134+
return ret.toOwnedSlice();
135+
}

tools/linter/src/root.zig

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//! By convention, root.zig is the root source file when making a library. If
2+
//! you are making an executable, the convention is to delete this file and
3+
//! start with main.zig instead.
4+
const std = @import("std");
5+
const testing = std.testing;
6+
7+
pub export fn add(a: i32, b: i32) i32 {
8+
return a + b;
9+
}
10+
11+
test "basic add functionality" {
12+
try testing.expect(add(3, 7) == 10);
13+
}

0 commit comments

Comments
 (0)