Speck: concise inline function specs

Speck is a tiny library that allows you to write concise inline function specs. It plays well with others: instead of introducing a defn wrapper (btw, I think it’s an antipattern!), it utilises compile-time var metadata. Please feel free to leave any comments or suggestions in the issues!

3 Likes

I’m happy to see a healthy dose of libraries that all try to address the verbosity of fn specs and improve its ergonomic convenience all in their own way.

I think it’s too soon to know what way will win in the end. I think right now I saw:

  • Using fn meta
  • Using defn wrapping macro where specs are inlined with their args java style.
  • Using defn wrapping macro where specs are defined in an extra spec-vector with hiccup like syntax.
  • Using an inline macro you call isnide the fn

I can’t find all the libs for these back though. Would be nice to tally them all up and compare them.

You see, the problem I have with defn wrappers is that they are, by definition, not composable. You have to commit to one of them, so if you e.g. have a DSL with some defqux macro that expands to defn and you want to add a spec to it - well, you’re out of luck. At the same time, clojure has all the bits and pieces to make stuff like this composable and extensible - its metadata feature is actually quite unique among other languages, especially combined with compile-time metaprogramming. Also, I think the dichotomy here is very similar to frameworks vs libraries notion.

As for the last item in your list, I’m not sure how to implement that. Macros have access to the lexical environment they’re executed in, but not to the dynamic one, so I don’t think it’s possible to know inside which defn the macro was called. Well, or at least I don’t know how to do that, so if you’re positive you’ve seen this being done, I’m definitely curious!

Also, note that the scope of this library is much smaller compared to stuff like e.g. ghostwheel. It only does one thing, which is to provide a nicer syntax for short function specs, whereas ghostwheel does a whole bunch of stuff really, from generating tests to doing static analysis, and I think it’s intended more as a all-in-one wrapper for all spec-related things (including expound, orchestra, etc).

2 Likes

I like the meta idea, but it doesn’t land to as nice a syntax I feel. So its value proposition over a normal fdef seems lesser. At least right now. Your idea of having the meta key be an arrow seems like it would improve that.

I believe the inner macro one I saw wasn’t generating an fdef. It was instead generating an assert. So you would use it inside pre/post. You could also use it inside let. Cause I think you’re right, no way for the macro to know what fn it’s inside of.

Thanks for the feedback! I see your point, though tbh my main irritation with normal fdef was that you have to manually write the s/cats for the args, so you often end up with something like (s/fdef my-fn :args (s/cat :foo ::foo :bar ::bar)), and then (defn my-fn [foo bar] ...) just below that - essentially you repeat yourself 3 times for each argument. Well, fdef really was designed for the case where you have your specs and impls in two different places; also, it’s easy to built a concise syntax on top of a verbose one, but not the other way around.

Utilizing pre-post map is a nice idea indeed (also, it gives you different ret specs per arity for free), but it’s even heavier in syntax and doesn’t give you as nice error messages (though maybe it’s possible to throw custom exceptions from pre-post - I’m not sure about this one).

2 Likes

Ya, I totally agree with you. I’ll end up using something more ergonomic then fdef eventually. For now though, I’m waiting for the space to mature, so all the unknowns are revealed, and we have a better idea of which one turned out to be a net positive in practice or didn’t. A lot of the DSL space is still in flux too.

I’d rather something that doesn’t wrap defn, for the reason you mentioned, for sure.

So thank you for your library and the work you’re doing in that space.

I think if you can refine the DSL of the meta, so it becomes lightweight, conscise, yet stays readable, and is able to spec all things equally to fdef, you’ll have something pretty great on your hand.

1 Like

So I thought about it a bit more and here’s something I’ve came up with:

It’s a bit of an abuse of tagged literals for sure, but… looks kinda nice - I don’t think it can get any more succinct without a wrapper macro. Any feedback would be greatly appreciated!

I’m no expert in metadata and tagged literals. How do this compose with other metadata? Like :doc, :private, :argslist

You can do (defn ^:private foo "doc" #|[in? => out?] ...) - these two are the most common ones. If you need other meta, you’d have to use the long form with the map (the one that’s currently in the readme).

How will you get the & to work? It seems all the symbols you’re using in them wouldn’t exist when the tag litteral is processed, and would fail to compile.

Tagged literals work at read time - basically, they have no knowledge about which symbols resolve to which vars and stuff like that; they just take a datastructure, apply a function to it and return the new datastructure back. So in #|[foo bar], the argument is simply a list of two symbols, similar to '[foo bar] - it doesn’t matter what they resolve to (and if they resolve at all).

I’ve just pushed [speck "0.0.2-SNAPSHOT"] to clojars, so if you want you can try it out (I’ve updated the gist too):

(ns user
  (:require [speck.v0.core :as speck :refer [| =>]]
            [orchestra.spec.test :as orchestra]))

;; add the tag:
(set! *data-readers* (assoc *data-readers* '| #'speck-reader))

;; if you're not in cider, you might need to do this instead:
(alter-var-root #'*data-readers* assoc '| #'speck-reader)

;; voilà!
(defn single-clause
  #|[number? => odd?]
  [x] (inc x))

(speck/gen-spec single-clause)
(orchestra/instrument `[single-clause])

(ugh, is there any way to prevent it from inserting a huge preview thing when I paste a link to gist?)

Oh, I always thought data readers received an evaluated form. So they receive unevaluated code uh. But they can only return evaluated code correct?

So like:
fn: evaluated code -> evaluated code
macro: unevaluated code -> unevaluated code
data reader: unevaluated code -> evaluated code

Is that correct?

If I understood you correctly, then the answer is no. The code is executed roughly like that: read -> macroexpand -> eval. If you use a tagged literal, you don’t magically “jump” between the phases, it’s still just a reader. So when you write e.g. [1 2 3] the reader returns an instance of clojure.lang.PersistentVector. When you write #inst "..." it returns an instance of java.util.Date. When you write #|[foo? => bar?] it simply returns {:speck (| foo? => bar?)}, which is then macroexpanded and evaluated.

So, like, there’s no “evaluated code” and “unevaluated code” - you can, for example, do IO in macros, create files, send a web request, etc. It’s just that they must return a datastructure that then will be evaluated (well, actually macroexpanded again before that). Same thing with reader tags: we had syntax for Lists, Symbols, Vectors, etc - reader tags simply allow to expand the set of what can be read by the reader, and they do it by invoking an ordinary clojure function a user provides.

Well, at least that’s my understanding, hope someone would correct me if I’m wrong somewhere. : )

Thanks for building this! I really like how you include the speck as metadata as I agree that each library writing it’s own defn macro doesn’t compose.

I messed around and like the api, but am having issues with the REPL driven workflow. After I change my function and it’s spec, I expect the new spec to be live. Instead I have to evaluate speck/gen-speck and the instrumentation to see the spec live. I guess that’s one reason why the defn wrappers are nice? Do you have any methods to improve that workflow?

Also as a relative clojure noob, I kind of expect cider-doc to always show the documentation, but it doesn’t work for tagged literals.

Nice work!

Thanks for the kind words! I’m glad you’ve found it interesting.

As for the REPL workflow - yes, I’m aware of this issue. I don’t have a definitive solution, but here are some thoughts:

  • You can reload the whole file instead of a single definition, and put the calls to gen-specs-in-ns and instrument at the end of the file. That’s the way proposed in the readme, and in general I find it good enough (I tend to reload the whole file instead of single defns anyway, but hey - maybe that’s just me). Also notice that you have to call instrument again after recompiling your fn regardless of whether you use speck or vanilla fdef. One little thing I’m thinking about is adding an option to gen-specs, so you can call it like this: (speck/gen-specs-in-ns {:instrument-with orchestra/instrument}) - though you’d have to :require orchestra or spec.test anyway (I don’t want to hard-code one of those in the lib), but at least you’ll be sure that only the functions in the current ns (vs all instrumentable fns as per (instrument)) will be re-instrumented.

  • I can add a tag you would mark your function with, similar to cider’s debugging features: #speck/reload (defn foo ...). So if you’re working on one particular function, you can use that to have the spec reloaded when you recompile the function. The downside, obviously, is that you have to actually write that tag, so it’s not a good default (but can be helpful in certain situations).

  • If you use the Reloaded workflow and clojure.tools.namespace, you can re-gen\instrument in the after-refresh hook (see the same page for how to do that in cider). That means you don’t have to add anything (beyond the actual speck defs) to your source files, which is good. For that to work, I need to write gen-specs-in-all-namespaces (better name suggestions, lol?) that will find all speck defs in your app (thanks for the idea, added that to my todo-list).

  • Now, the optimal solution (as I see it) would be adding compile hooks to the tooling, so you could call any clojure code you want when a defn is recompiled. Essentially, that’s a more granular version of the previous approach, and it’s useful outside of the context of speck: you can e.g. rerun your tests when a single var (not the whole buffer!) is recompiled, or even run a test.check just for the recompiled var based on its :ret:fn specs, etc. Actually, I think I’ll go ahead create a cider issue to see what bbatsov thinks about it.

Would any of that help your workflow?

As for the cider-doc vs tagged literals - yeah, technically they are not resolvable symbols, so docstrings on them aren’t supported. Though, it’s totally possible to attach meta to them, it just needs some special handling from the tooling. I’ll create an issue about that too!

Thanks again, you’ve brought up a good amount of excellent points. : )

See: #2382 #2383

I wrote one of the libraries “Using defn wrapping macro where specs are inlined with their args java style.”, called sayang. I really like the readability of specs right next to the args. Also, I included a data DSL, which improves the readability even further, imho. Another central design decision is to provide the same functionality in Cursive as function definitions with clojure.core. That means: symbol resolution, Find Occurrences, refactoring like renaming, etc. This why sayang is going the “defn wrapping macro”-route, probably also because I did not encounter the need to compose multiple defn-DSLs.

From a Clojure Book I can’t remember (maybe Clojure Programming, or Programming Clojure) : REPL really is RECEPL : Read (macro)Expand Compile Eval Print Loop.

1 Like

Hey j-cr,

Sorry for the tardy response, and I really appreciate your thought and work trying to improve the speccing workflow. I don’t use the Reloaded workflow, and instead opt to evaluate the single form or the ns. I would put the instrumentation at the end of each ns, but we don’t run specs on production (though perhaps I could use my when-not-prod macro there…)

Your last suggestion sounds pretty cool, and will be following the issue.

I’ll keep trying different mechanisms to improve my setup, thanks for the ideas!

1 Like

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