Splint: An idiom checker and code-shape linter

Introducing Splint, an idiom checker/code-shape linter. It reads code forms and checks them against a set of rules, aka code patterns which have “better” or more idiomatic forms. For example, (when (pos? x) (do (y) (z))) can drop the do to be (when (pos? x) (y) (z)). As such, it occupies the same space as Kibit or Rubocop.

It currently covers all of Kibit’s existing rules and has a number more.

It is designed to be significantly faster by using macros to compile somewhat efficient predicate functions for the offending patterns instead of relying on the powerful but slow core.logic library:

$ tokei
 Clojure               169       113620       102756         4266         6598
 ClojureC                4         1012          850           36          126
 ClojureScript          48        12666        11649          142          875

$ time lein kibit
real    34m30.395s

$ time splint .
real    0m9.220s

The rules are easy to read and write, and you can write your own directly in your projects if you need project-specific rules.

Rules look like this:

(defrule lint/when-do
  "`when` already defines an implicit `do`. Rely on it.


  ; bad
  (when x (do (println :a) (println :b) :c))

  ; good
  (when x (println :a) (println :b) :c)
  {:pattern '(when ?x (do &&. ?y))
   :message "Unnecessary `do` in `when` body."
   :replace '(when ?x &&. ?y)})

It currently doesn’t do any replacements (like Kibit) but that’s on my to-do list for coming releases.


@NoahTheDuke: I’m curious if you’ve tried chatgpt to do linting/style checking and how it might compare to a programmed linter.

I’ve not as I’m not a fan of chatgpt and the examples I’ve seen don’t make me confident it would produce helpful code for me.

You should try it and let me know how it goes!

to be honest, i don’t find linters that useful so i have no way of doing the comparisons.

i think gpt is an extremely good learning tool and i’m pretty sure it understands the code structure - you can feed it a piece of python code and ask it to write the same function in js or some really esoteric language.

though i’m only pretty sure - not 100%

It currently covers all of Kibit’s existing rules and has a number more.

It is designed to be significantly faster …

scrolls down

$ time lein kibit
real    34m30.395s

$ time splint .
real    0m9.220s


Great work @NoahTheDuke; I’ll be downloading this!

1 Like

I’ve forgotten to post any patch notes in here, so here’s a loose summary of the last 3 releases:

v1.3.0 is all new rules, some of which might be helpful! These are all pretty self-explanatory:

  • naming/single-segment-namespace: Prefer (ns foo.bar) to (ns foo).
  • lint/prefer-require-over-use: Prefer (:require [clojure.string ...]) to (:use clojure.string). Accepts different styles in the replacement form: :as, :refer [...] and :refer :all.
  • naming/conventional-aliases: Prefer idiomatic aliases for core libraries ([clojure.string :as str] to [clojure.string :as string]).
  • naming/lisp-case: Prefer kebab-case over other cases for top-level definitions. Relies on camel-snake-kebab.
  • style/multiple-arity-order: Function definitions should have multiple arities sorted fewest arguments to most: (defn foo ([a] 1) ([a b] 2) ([a b & more] 3))

v1.4.0 added -v and --version to the cli, which is obvious in hindsight. Also adds config TYPE to print either the full config (any local merged on top of default), the local (just the local file), and the diff (to only see where the local is different from default when explicitly setting local config to match defaults).

I also fixed a couple small bugs.

v1.5.0 adds a new genre! metrics will handle rules that are like style rules but meant to help give you rough measures of code quality (complexity, etc).

  • metrics/fn-length: Function bodies shouldn’t be longer than 10 lines. Has :body and :defn styles, and :length configurable value to set maximum length.

Changed how I write tests to make it better: Now I’m using nubank/matcher-combinators to do sub-matching, meaning I can check multiple things at once in a single assertion. Love it. Here’s some other changes:

  • Track end position of diagnostics.
  • Attach location metadata to function “arities” when a defn arg+body isn’t wrapped in a list.
  • Parse defn forms in postprocessing and attach as metadata instead of parsing in individual rules.

Fixed some bugs:

  • Fix style/multiple-arity-order with :arglists metadata.
  • Fix binding pattern when binding is falsey.
  • Skip #(.someMethod %) in lint/fn-wrapper.
  • Skip and and or in style/prefer-condp.
1 Like

Awesome, I stopped using Kibit because it was so slow, this is a great replacement.

1 Like

Last two updates have had some big changes/improvements!

v1.6.0 correctly merges cli and local options (fixes #5). I also wrote short descriptions in the default config.edn to make reading/editing easier. This will help when I get around to implementing a “generate todo .splint.edn” command.

v1.7.0 adds a new rule, a new cli flag, and two new output options.

  • metrics/parameter-count: Functions shouldn’t have more than 4 positional parameters. Has :positional and :include-rest styles (only positional or include & args rest params too?), and :count configurable value to set maximum number of parameters allowed.

After using --quiet for a while, I realized that it still prints a lot, so I added -s / --silent to print literally nothing (only returning an exit code). Been helpful when running smoke tests locally, or testing speed of execution.

I added json and json-pretty to --output, which outputs json strings, raw or prettified. The prettified json doesn’t look great (it’s a clojurist’s pprint, not jq or eslint lol) but it’s better than nothing. Might revisit this at a later date.

Splint now tracks all files that have been parsed and checked in the return map under :checked-files. No use case yet, but it’s helpful to know what’s actually been looked at.

Splint now uses farolero to handle splint.runner errors. I was worried it would slow down the app but so far, I’ve not run into anything yet.

And here’s a short list of bug fixes:

  • Only attach parsed defn metadata when fn name exactly matches defn or defn- and second form is a symbol.
  • --no-parallel was producing a lazy seq, now consumes to actually check all files. Oops lol.
  • Map over top-level forms with nil parent form instead of treating the whole file as a top-level vector of forms. Fixes naming/lisp-case.
  • Add pre- and post- attr-maps to defn metadata when parsing defn forms.

I published v1.8.0 yesterday.

New rules

  • lint/warn-on-reflection: Require that (set! *warn-on-reflection* true) is called after the ns declaration at the start of every file. Defaults to false.


  • Deprecate --config. Add --print-config. No timeline for removal of --config (maybe never?).


  • edn / edn-pretty output: Print diagnostics as edn using clojure.core/prn and clojure.pprint/pprint.
  • Continue to process files after running into errors during rules checking.


  • Dependencies are updated to latest.
  • json and json-pretty keys are now sorted.
  • Small performance improvements to patterns.
1 Like

Just released v1.10.0 which includes many cool changes. Forgot to post after 1.9 but that was a pretty small release overall.

In short: v1.9 added a new rule and a new cli flag (--[no-]summary). v1.10 added reading available deps.edn and project.clj files to determine the directories to check, changing the pattern DSL to use a variation of pangloss/pattern’s DSL, and to add 5 new rules, 4 of which are performance rules.

The change in v1.10 means that you can run bb splint or clojure -M:splint and it’ll “just work”: it’ll check the primary directories and the directories under and :test alias without any extra fiddling. This makes it much easier to use quickly. I’ve always been annoyed at clj-kondo’s insistence on forcing the --lint flag for choosing files, and loved Rubocop’s “just run it” mentality.

The performance rules are disabled by default cuz they might make code more wordy or annoying to work with, and they’re highly opinionated. I like them but I get it.

New rules

  • performance/assoc-many: Prefer (-> m (assoc :k1 1) (assoc :k2 2)) over (assoc m :k1 1 :k2 2).
  • performance/avoid-satisfies: Do not use clojure.core/satisfies?, full stop.
  • performance/get-in-literals: Prefer (-> m :k1 :k2 :k3) over (get-in m [:k1 :k2 :k3]).
  • performance/get-keyword: Prefer (:k m) over (get m :k).
  • style/prefer-clj-string: Prefer clojure.string functions over raw interop. Defaults to true.
  • style/redundant-regex-constructor: Prefer #"abc" over (re-pattern #"abc").

Full release notes for v1.10

This topic was automatically closed 182 days after the last reply. New replies are no longer allowed.