clj-kondo

borkdude 2025-12-16T14:55:26.873239Z

I sometimes make mistakes with unquote while writing a macro. E.g. (contrived non working example):

(defmacro foo [x]
 (map (fn [] ~x) [1 2 3]))
Here unquote occurs outside of a syntax-quote. Sometimes this can be valid for weird macros that actually expect the user to use unquote outside of syntax-quote. But that's usually only in macro calls, not in macro definitions. That's why I consider warning (optionally?) about using unquote outside of syntax-quote only in defmacro. Good idea?

2025-12-16T14:58:46.764429Z

I think it's in line with the rest of clj-kondo's design, in that complex macros don't lint very well until you create a custom hook.

2025-12-16T14:59:37.089559Z

and it will take care of itself once clj-kondo can expand macros

borkdude 2025-12-16T15:01:10.056529Z

I don't think thats related to how macros expand in clj-kondo. It's just related to users making silly errors while writing a macro. Even when clj-kondo would run the macro itself, it would expand in (clojure.core/unquote x) without warning

borkdude 2025-12-16T15:02:03.627239Z

(clojure.core/unquote 1)
isn't a warning in clj-kondo

2025-12-16T15:02:47.073679Z

sure I'm thinking about the Sometimes this can be valid for weird macros step

2025-12-16T15:03:25.431059Z

assuming we don't care about the weird macros, then I completely agree that it makes sense.

2025-12-16T15:04:21.115979Z

but if there are weird macros, it can be handled by writing a hook or probably just expanding them, because if a macro has special handling for unquote it's probably not going to expand to code that uses unquote.

2025-12-16T15:05:02.168799Z

I think https://github.com/brandonbloom/backtick is probably a good lib to explore this latter point

borkdude 2025-12-16T15:05:18.875699Z

an example of a weird macro that supports unquote in calls:

(defmacro $
  "Convenience macro around `process`. Takes command as varargs. Options can
  be passed via metadata on the form or as a first map arg. Supports
  interpolation via `~`"
  {:arglists '([opts? & args])}
  [& args]
  (let [opts (meta &form)
        farg (first args)
        args (if (and (= 1 (count args))
                      (string? farg)
                      (not (.exists (io/file farg))))
               (tokenize farg)
               args)
        farg (first args)
        marg? (map? farg)
        cmd (if marg?
              (vec (cons farg (mapv format-arg (rest args))))
              (mapv format-arg args))]
    `(let [cmd# ~cmd
           opts# ~opts
           fcmd# (first cmd#)
           [prev# cmd#]
           (if (:proc fcmd#)
             [fcmd# (rest cmd#)]
             [nil cmd#])
           fcmd# (first cmd#)
           [opts# cmd#]
           (if (map? fcmd#)
             [(merge opts# fcmd#) (rest cmd#)]
             [opts# cmd#])]
       (process* {:prev prev# :cmd cmd# :opts opts#}))))

borkdude 2025-12-16T15:05:32.544779Z

but note that unquote itself doesn't even occurs literally in the macro

borkdude 2025-12-16T15:05:35.208029Z

so no problem there

2025-12-16T15:06:03.929939Z

right, the problem is linting uses of this macro that contain unquotes

borkdude 2025-12-16T15:06:27.572409Z

that's why I want to restrict the check to defmacro

2025-12-16T15:06:46.225009Z

ah missed that

borkdude 2025-12-16T15:07:00.350479Z

it's in the original post :)

borkdude 2025-12-16T15:07:43.871959Z

I boldened it now

borkdude 2025-12-16T15:08:24.379649Z

an example of the above $ macro as a call:

($ {:out sw} ~(symbol bb) ~(symbol u/wd) :out ~config)

borkdude 2025-12-16T15:09:04.152579Z

but maybe it's more of a rare exception that begs for a clj-kondo configuration to only turn the linter off inside calls to $

borkdude 2025-12-16T15:10:15.900289Z

lol, it seems someone is going wild on this already. https://github.com/clj-kondo/clj-kondo/pull/2673

πŸ˜„ 2
2025-12-16T15:14:27.900349Z

my revised opinion is that activating only in defmacro would prevent some false positives and seems like a good targeted step, but I'd be interested in enforcing elsewhere too.

borkdude 2025-12-16T15:16:24.320699Z

Sounds like a configuration option

πŸ‘ 1
2025-12-16T15:18:02.569019Z

yeah good call. I'm seeing it as related to macros that introduce names/bindings, also highly configurable.

2025-12-16T15:18:19.605049Z

in terms of "advanced macrology that clj-kondo handles"

borkdude 2025-12-16T15:36:40.030199Z

I think I've only ever encountered mistakes with unquote in defmacro so defaulting to this behavior seems good to me...

borkdude 2025-12-16T15:37:04.787409Z

or just supporting that use case, without config options for v0

2025-12-16T15:38:58.572169Z

yeah, maybe in macro helper defn's as a runner-up. πŸ‘

borkdude 2025-12-16T15:39:45.911349Z

hmyeah

2025-12-16T15:40:43.446869Z

maybe if you are surrounded by a syntax quote, always activate?

2025-12-16T15:40:53.813259Z

(as an additional rule)

borkdude 2025-12-16T15:40:57.207459Z

that's a good idea yeah

borkdude 2025-12-16T15:51:43.930579Z

maybe this is also one, not sure: https://github.com/metabase/metabase/blob/aa0cdb546d7c9e4ef5c52ad23c656272b7599e23/src/metabase/util/log.clj#L192

πŸ‘Ž 1
dpsutton 2025-12-16T15:55:19.296019Z

my macro rules are a bit hazy. but can syntax quote contexts be nested?

borkdude 2025-12-16T16:01:21.483279Z

@ambrosebs does thumbs up/down mean: yes, it's a bug, no it's not a bug?

2025-12-16T16:01:48.805519Z

yes

borkdude 2025-12-16T16:02:04.676259Z

why is the second one not a bug?

2025-12-16T16:03:00.808379Z

the pprint is evaluated at runtime afaict, it prints the current expression

jramosg 2025-12-16T16:03:16.863299Z

hello! I'm working in the issue. what about

(defmacro spyf
  "Evaluates an expression, and may write both the form and its formatted result to the log.
  Defaults to the `:debug` level."
  ([fmt expr]
   `(spyf :debug ~fmt ~expr))
  ([level fmt expr]
   (macros/case
     :cljs (glogi-spy (str *ns*) level expr #(format ~fmt %))
     :clj  `(spyf ~level ~fmt ~expr))))
The cljs case is missing the syntax-quote?

2025-12-16T16:03:22.134439Z

(spy (+ 1 2)) => (pprint '(+ 1 2))

borkdude 2025-12-16T16:04:03.245559Z

@ambrosebs but what does unquote mean here, without a syntax quote? it will expand into (cljs.core/unquote expr) , not to the quoted expression

borkdude 2025-12-16T16:04:16.112379Z

unless glogy-something does something weird

borkdude 2025-12-16T16:04:36.785689Z

exactly @jonurnieta

2025-12-16T16:04:43.168769Z

it does something weird

2025-12-16T16:06:15.003459Z

it looks broken to me, not compatible with AOT compilation. it splices a fn into the expansion.

jramosg 2025-12-16T16:07:34.321329Z

I skipped the quoted ones like that in pprint, do you think this rule is correct?

borkdude 2025-12-16T16:08:01.056819Z

@jonurnieta seems a bit arbitrary to me

exitsandman 2025-12-16T16:09:08.926449Z

IMO this could well be a warning by default even outside of macro contexts given that the false positive is exceedingly rare compared to the true positive.

πŸ‘ 2
jramosg 2025-12-16T16:09:42.644579Z

Yes, it was to see if regression tests pass like that but I found more like the one in spyf macro

2025-12-16T16:11:45.193979Z

@borkdude yeah you're right, the final glogi-spy arg should be syntax quoted. it's a true positive.

borkdude 2025-12-16T16:15:15.361619Z

I tried to replicate the bug here:

(defn- glogi-spy
  "Macro helper for [[spy]] and [[spyf]] in CLJS."
  [logger-name level expr formatter]
  `(let [a# ~expr
         s# (~formatter a#)]
     (prn s#)
     a#))

(defmacro spy
  "Evaluates an expression, and may write both the form and its result to the log.
  Returns the result of `expr`.
  Defaults to the `:debug` level."
  ([expr] `(spy :debug ~expr))
  ([level expr]
   (glogi-spy (str *ns*) level expr
              #(do (print '~expr)
                   (print "=> ")
                   (println %)))))

(spy 1)
It prints:
(clojure.core/unquote expr)=> 1
nil
1
Seems broken to me. Maybe not used in CLJS in metabase @dpsutton?

borkdude 2025-12-16T16:16:08.238239Z

The broken bit is behind the :cljs case

borkdude 2025-12-16T16:18:27.872499Z

I could try to start the metabase front-end myself but that's another rabbit hole possibly :)

2025-12-16T16:19:36.869409Z

in cljs it would splice a clojure.lang.Fn into javascript. last arg should be syntax quoted, you were right.

borkdude 2025-12-16T16:21:33.106379Z

@jonurnieta you can update the expected findings with this:

CLJ_KONDO_REGRESSION_UPDATE=true bb test:regression

πŸ‘ŒπŸΏ 1
borkdude 2025-12-16T16:22:02.254429Z

it would still be lovely if @dpsutton could confirm later if the spy macros are broken in CLJS

borkdude 2025-12-16T16:22:24.425609Z

(or anyone else at metabase)

dpsutton 2025-12-16T16:24:22.736749Z

about to jump into a meeting. I can look in ~ an hour (quite interested just lack the immediate time)

borkdude 2025-12-16T16:25:09.935579Z

sure, thanks!

dpsutton 2025-12-16T16:25:14.131269Z

thank you!

2025-12-16T16:26:46.969739Z

we might want a similar rule for ~@, this final line also seems to be missing a syntax quote https://github.com/metabase/metabase/blob/aa0cdb546d7c9e4ef5c52ad23c656272b7599e23/src/metabase/util/log.clj#L243

borkdude 2025-12-16T16:27:34.461119Z

yes, linked that above. already detected with @jonurnieta’s PR

2025-12-16T16:34:09.989269Z

good. did the :oss (validate-oss-args '~ee-ns options)) one show up? that's a bit tricker since it's valid code, just semantically incorrect.

borkdude 2025-12-16T16:35:22.777119Z

It did show up and you also have it a thumbs up above?

2025-12-16T16:39:44.700989Z

yes it's a bug in this specific case, I'm speculating whether it's a good idea to lint under a quote. I don't know the right answer. Probably it's rare to actually want to quote an unquote.

2025-12-16T16:40:34.644119Z

it's easier to decide for code that blows up at runtime with "unquote not bound", clearly a linter error.

2025-12-16T16:41:00.924399Z

which I think the other three cases fall under (EDIT: 2/3? the ~@body and (format ~) ones)

2025-12-16T16:41:58.380829Z

ah there's two or three different kinds of mistakes here. one needs more quoting, another less.

2025-12-16T16:43:16.808809Z

and there's a final kind of usage where someone actually wants to quote a form verbatim that happens to contain an unquote, that's not represented here.

2025-12-16T16:45:06.271529Z

that's probably the rarest of all. after thinking it through the proposed approach seems good.

βœ… 1
borkdude 2025-12-16T19:53:09.749989Z

@ambrosebs double checking: do you agree with @doppiaelle1999 that it should also lint outside of defmacro?

2025-12-16T20:25:43.216439Z

nuanced: I think it's a good idea to lint outside of defmacro for forms that throw exceptions at runtime (like your original example). I'm back on the fence for forms that don't throw (like '~foo). But could be quite useful for bug finding, as demonstrated above. I think my vote would be: β€’ default: lint error for all (inside+outside defmacro) forms that look like they throw at runtime like (fn [] ~x) β—¦ precisely, the "unquote is not bound" error β€’ default: flag unquotes under quotes like '~a where a is in scope, like (fn [a] '~a) β€’ opt-in: flag all unquotes under quotes (that aren't already syntax quoted)

borkdude 2025-12-16T20:30:05.038899Z

can you name an example of 3 that isn't a case of 1 or 2?

πŸ€” 1
2025-12-16T20:38:12.878519Z

Maybe something with multiple mistakes like:

(defn foo [x] '~y)
where the intention is more like
(defn foo [x] `~x)
It's a strange case.

2025-12-16T20:38:59.805229Z

but the reason I suggested opt-in is because it could be valid code under certain circumstances.

2025-12-16T20:39:17.953289Z

since unlike the other two it actually returns a value

2025-12-16T20:39:46.454469Z

tbh I could go either way on 3.

2025-12-16T20:41:23.434569Z

the scenario where I'd use this are pretty rare. maybe macro generating macros to avoid nested syntax quotes.

2025-12-16T20:42:33.269849Z

even that is a stretch. you can't generate a macro that has a syntax quote, so an unquote form isn't going to be useful.

2025-12-16T20:45:22.141799Z

I guess I've talked myself back to what everyone else agreed on πŸ™‚ so yes, enable outside defmacro too

borkdude 2025-12-16T20:48:37.189739Z

I just have trouble imagining an example that isn't a case of 1 or 2 which you proposed as default, not opt-in

borkdude 2025-12-16T20:50:06.727649Z

I think I'm now leaning to: apply it globally with the option to disable it in specific calls (which is already possible now)

πŸ‘ 1
2025-12-16T20:54:23.614519Z

yeah, I specifically narrowed 2 down for the most likely almost-correct code involving quote and unquote. I didn't consider whether any remaining idioms might be left, I'm not sure there is.

2025-12-16T20:55:02.967919Z

So the preconditions of 2 might just be redundant. it might be just as effective to simply warn always. i.e., β€’ always warn (error?) if unquote will throw β€’ always warn if unquote is under a (not-syntax-quoted) quote

borkdude 2025-12-18T15:43:19.235359Z

I went with :unquote-not-syntax-quoted and decided not to split the linter for now. Merged on master

πŸŽ‰ 2
borkdude 2025-12-18T15:43:44.496139Z

@dpsutton did you manage to double-check metabase btw?

dpsutton 2025-12-18T15:55:52.022509Z

haven’t gotten to it unfortunately

borkdude 2025-12-18T15:56:15.187799Z

I guess you will notice soon when upgrading clj-kondo πŸ‘»

dpsutton 2025-12-18T15:56:38.023889Z

the best way to fix it πŸ™‚

borkdude 2025-12-25T13:06:10.226159Z

A darn, lein defproject uses unquote to allow templating some values. Ah, I knew there would be a macro that did this... https://github.com/technomancy/leiningen/blob/40227328d4a9c8945362d6d626d19c2449175df6/leiningen-core/src/leiningen/core/project.clj#L203

borkdude 2025-12-17T14:12:44.162169Z

What would be a good name for this linter? 1.

:unquote-not-in-syntax-quote
2.
:unquote-outside-syntax-quote
React with 1️⃣ , 2️⃣ or another suggestion

1️⃣ 4
2️⃣ 2
borkdude 2025-12-17T14:13:54.032079Z

I think I like 1 better since 2 suggests the presence of a syntax-quote, where the unquote it outside of, but 1 doesn't assume that?

2025-12-17T21:25:38.094849Z

I think of "syntax-quoted" and "not syntax-quoted" contexts. I'll offer :unquote-not-syntax-quoted as another option.

borkdude 2025-12-17T21:27:05.265249Z

that name may be a little confusing in the sense that people might understand it as an unquote that is not directly syntax-quoted, like

`~x

2025-12-17T21:32:36.322249Z

I don't follow, how is that example "an unquote that is not directly syntax-quoted"?

exitsandman 2025-12-17T21:34:18.414059Z

that is in fact the example :)

borkdude 2025-12-17T21:34:22.436569Z

That is a directly syntax-quoted example, but this one isn't:

`[~x]
in the sense that the syntax quote isn't directly on the unquote. I'm probably overthinking here, but not-in-syntax-quote may be 1% clearer perhaps

2025-12-17T21:40:27.952109Z

ok, I don't understand the significance of directly/not directly, but my original (minor) concern with the proposed names are the words "in" and "outside", because you can still get this bug by being "in" a syntax quote like this:

`~~a

2025-12-17T21:41:18.810319Z

I wasn't sure if "in" disambiguated between "nested under" and "syntax-quoted context".

borkdude 2025-12-17T21:43:51.954739Z

you're right. I think the conclusion is that no name is perfect and we just have to pick one...

πŸ‘ 1
borkdude 2025-12-17T21:44:30.570659Z

now I thought more about it, yours is probably better

exitsandman 2025-12-17T22:00:59.212319Z

what about :unquote-without-matching-syntax-quote? Although it's quite long and doesn't indicate the way the unquote should be "matched" by the syntax-quote, it does convey the tendency of the two to come in pairs better.

borkdude 2025-12-17T22:04:18.621619Z

match and pair doesn't say something about that they should be nested. I think :unquote-not-syntax-quoted captures that better perhaps

exitsandman 2025-12-17T22:04:46.854129Z

yeah indeed

2025-12-18T00:19:24.816989Z

Maybe I'm retreading the same conversation we had above, but now we're at the naming stage might we want to split this into two linters? 1. an error linter :unquote-not-syntax-quoted for forms that throw like (do ~x) and 2. a warning linter (say) :quoted-unquote for (do '~x) which don't throw but are likely a typo?

2025-12-18T00:36:53.872689Z

I might not be following the naming philosophy of the linters tho. one direction we could name the potential remedy (`:missing-syntax-quote`) and the other we could name the symptom (`:unquote-in-function-position`), we're landing somewhere in the middle.