Skip to content

Commit

Permalink
feat: clj-kondo defc linter
Browse files Browse the repository at this point in the history
Introduce a linter for the `defc` macro. It has the following features:

- Ensure that a `defc` has a `render` definition
- Ensure that only valid hooks can be defined inside defc (ie. `hook`,
  `render`, `bind`, `event`)
- Ensure that events are defined with valid arity
- Provide information to clj-kondo so that symbols from `bind` can be
  linted (eg. unused vars)

Ultimately this could be extended with other things (eg. ensuring that
definitions are provided for events specified in render functions) but
this felt like a good-enough start.
  • Loading branch information
rschmukler committed Dec 15, 2024
1 parent bc58043 commit 9ed594c
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 2 deletions.
4 changes: 2 additions & 2 deletions deps.edn
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{:paths
["src/main"
"src/test"
"src/ui-release"]

"src/ui-release"
"resources"]
:deps
{com.thheller/shadow-css {:mvn/version "0.4.5"}}

Expand Down
165 changes: 165 additions & 0 deletions resources/clj-kondo.exports/shadow/grove/clj_kondo/shadow/grove.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
(ns clj-kondo.shadow.grove
(:require [clj-kondo.hooks-api :as api]
[clojure.string :as str]))

(def valid-hook-names
"Return if the provided `name` is a valid hook name"
#{'render 'bind 'event 'hook 'effect '<<})

(defn- -hook-node->name
"Return the provided name of the `node`, or `nil`
if it is not a list node."
[node]
(when (api/list-node? node)
(api/sexpr (first (:children node)))))

(defmulti rewrite-hooks!
"Multimethod to rewrite a hooks body for linting in clj-kondo. Is called with a sequence of
hooks to be rewritten.
It dispatches of of the hook name of the first hook in the sequence. Most implementations will
recursively call `rewrite-hooks!` on the rest of their list."
(fn [hooks]
(if-not (seq hooks)
:done
(if-some [hook-name (-> hooks first -hook-node->name valid-hook-names)]
hook-name
:invalid))))

(defmethod rewrite-hooks! :done
[_]
nil)

(defmethod rewrite-hooks! :invalid
[[hook & hooks]]
(api/reg-finding!
(assoc
(meta
(if (api/list-node? hook)
(first (:children hook))
hook))
:level :error
:message
(str "Invalid hook: "
(or (-hook-node->name hook) (api/sexpr hook))
", should be one of: " (str/join ", " valid-hook-names))
:type :shadow.grove/invalid-hook))
(rewrite-hooks! hooks))

(defmethod rewrite-hooks! 'bind
[[hook & hooks]]
(list
(let [[_ bindings expr] (:children hook)]
(api/list-node
(list*
(api/token-node 'let)
(api/vector-node [bindings expr])
(rewrite-hooks! hooks))))))

(defmethod rewrite-hooks! 'hook
[[hook & hooks]]
(cons
(api/list-node
(list*
(api/token-node 'do)
(-> hook :children rest)))
(rewrite-hooks! hooks)))

(defmethod rewrite-hooks! 'render
[[hook & hooks]]
(concat
(-> hook :children rest)
(rewrite-hooks! hooks)))

(defmethod rewrite-hooks! '<<
[[hook & hooks]]
(concat
(-> hook :children rest)
(rewrite-hooks! hooks)))

(defmethod rewrite-hooks! 'event
[[hook & hooks]]
(let [[_ event-name params & body] (:children hook)]
(when-not (api/keyword-node? event-name)
(api/reg-finding!
(assoc (meta event-name)
:level :error
:message "Event name must be keyword"
:type :shadow.grove/invalid-event)))
(when-not (<= 1 (count (:children params)) 3)
(api/reg-finding!
(assoc (meta params)
:level :error
:message "Must be arity 1, 2, or 3. Definition called with `env`, `ev`, `e`"
:type :shdow.grove/invalid-event-artity)))
(cons
(api/list-node
(list*
(api/token-node 'fn)
params
body))
(rewrite-hooks! hooks))))


(defmethod rewrite-hooks! 'effect
[[hook & hooks]]
(let [[_ deps-vec & body] (:children hook)]
(when-not (api/vector-node? deps-vec)
(api/reg-finding!
(assoc (meta deps-vec)
:level :error
:message "Dependencies must be a vector"
:type :shadow.grove/invalid-effect-deps)))
(cons
(api/list-node
(list*
(api/token-node 'do)
deps-vec
body))
(rewrite-hooks! hooks))))

(defn validate-component!
"Function to validate all hooks inside a `defc` and make sure they create a valid component."
[component-node hook-nodes]
(let [hook-names (->> hook-nodes
(keep -hook-node->name))
has-render? (some (set hook-names) ['<< 'render])
hook->last-ix (->> hook-names
(map-indexed (comp vec reverse vector))
(into {}))
bind-after-render? (> (hook->last-ix 'bind -1)
(max (hook->last-ix 'render -1)
(hook->last-ix '<< -1)))]
(when-not has-render?
(api/reg-finding!
(assoc
(meta component-node)
:level :error
:message "Components must have a `render` or `<<` hook"
:type :shadow.grove/invalid-component)))
(when bind-after-render?
(api/reg-finding!
(assoc
(meta component-node)
:level :error
:message "All binds must be declared before `render` / `<<`"
:type :shadow.grove/invalid-component)))))

(defn defc
[{:keys [node]}]
(let [[_ name & args] (:children node)
[comp-bindings
hooks] (->> args
(drop-while #(not (api/vector-node? %)))
((juxt first rest)))
rewritten-hooks (->> hooks
(rewrite-hooks!))]

(validate-component! node hooks)
{:node
(api/list-node
(list*
(api/token-node 'defn)
name
comp-bindings
rewritten-hooks))}))
3 changes: 3 additions & 0 deletions resources/clj-kondo.exports/shadow/grove/config.edn
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{:hooks
{:analyze-call
{shadow.grove/defc clj-kondo.shadow.grove/defc}}}

0 comments on commit 9ed594c

Please sign in to comment.