Lask — a task runner with its own typed language, written in Haskell

This is my first post here, and I’m afraid it’s about my own project — I hope that’s an acceptable way to introduce myself.

What I made

Lask is a task runner. It replaces Makefiles and CI YAML with a small typed language in which every command is bound to a container image:

command "go" on #golang:1.22
command "npm" on #node:20

test_api(): String = $ go test ./...
test_web(): String = $ npm test

lask run test_api on my laptop and the same line in CI run in the same pinned image. lask check resolves every name, argument and type across the whole file before anything executes, and the same checker runs as a language server.

I’ve just tagged 0.5.0. It’s MIT, pre-1.0, roughly 12k lines of Haskell, and ships as static binaries for macOS, Linux and Windows, so users never need a Haskell toolchain.

brew install lask

Why I chose Haskell for it

Three reasons, and they’ve all held up over the two years I’ve been working on this.

Development guarded by types is simply pleasant. That sounds soft, but it’s the reason I kept going on a side project through the evenings when I had no particular obligation to. Making a change and having the compiler walk me through the consequences is a good way to spend a limited amount of attention.

The types let me get away with far fewer tests, especially early on. A large class of mistakes couldn’t survive compilation, so I didn’t need a test to catch them, and I got usable quality without building a test suite before I’d even decided what the language was. For a project that changed shape every few weeks at the start, not having to maintain tests against a design I was about to throw away mattered a lot. (I do have tests now, and they catch the things types can’t — logic errors in the elaborator, mostly.)

The parser combinators made the whole thing tractable. I had written parsers with lex and yacc before, and the difference is not marginal — it is the difference between a project I would start on a weeknight and one I wouldn’t. There is no separate grammar file, no generation step in the build, and no generated code to read when something goes wrong. The parser is ordinary Haskell, so I factor it with ordinary functions, and the grammar ends up reading close to the specification I wrote it from. I never once had to work out what a shift/reduce conflict was trying to tell me. That mattered most in the first months, when the language was still changing shape every few weeks: with combinators, changing the grammar is just editing a function.

Where it is now

These days a large share of the implementation is written by generative AI, and I spend my time on the specification and on review. What surprises me is the quality: compared with the other mainstream languages I’ve tried this with, what comes back in Haskell holds up much better once I actually run it. I assume the types are doing most of that work — a wrong edit here usually fails to compile, and in a dynamically typed language it usually doesn’t.

Which leads to the thing I find myself hoping for. The usual objection to Haskell has always been a human cost: the learning curve, the unfamiliarity. That cost was attached to humans writing the code. If we’re writing less of it by hand, the objection weakens while the benefit — the compiler refusing to let a bad edit through — doesn’t weaken at all. I’d like to think Haskell turns out to be the right choice for the age of generative AI, and becomes a more ordinary one to make.

Feedback of any kind is welcome, including on the parts that are rough — there are plenty.

11 Likes

This doesn’t seem right in the README:

A task’s signature is its command line — release(--dry_run = false) is lask run release --dry-run true

I think the idea of the project is admirable. You’ll find it difficult to attract users though, vibe-coded tools are dime a dozen nowadays. If you’re serious, you could start by adding more examples: how to replace a Makefile, how to improve an existing read-world GitHub CI pipeline, etc.

Also: I understand that using a parser combinator library is a joy, but could the same language be expressed as an EDSL? I’m not suggesting that you should drop the parser, just that the parsed language (PDSL) could be a thin wrapper over the EDSL. The EDSL could then be released as a Haskell library others could build on, and it should also be easier to test in some ways.

2 Likes

Thanks for taking the time. this is the most useful feedback the project has had!

README. Fixed, thanks: docs: compare a task call with its command line in the README · lask-task-runner/lask@ef9387c · GitHub — let me know if anything else looks off.

Examples. I appreciate your suggestion. I’ll add migration examples starting from common setups: a typical Makefile, and a real-world GitHub Actions workflow.

EDSL. I’d love to try that. Considering EDSL, I ran into two questions.

First, evaluation strategy. If tasks are plain Haskell functions over E a, calls get inlined at construction time. Lask allows recursion, so a recursive task whose base case is decided at run time builds an infinite term. And since commands are effects, a Haskell let around one duplicates it, so the command runs twice. Named declarations with explicit call and bind fix both, but that drifts back toward the surface syntax. Would observable sharing in the style of data-reify be worth it here, or is explicit naming the right answer for a language where calls are effects?

Second, Any type expression. Lask’s types are structural, with Any and untagged unions and no unifier, so I’m not sure how much maps onto Haskell’s types. My plan would be to start by running the existing checker over EDSL-built terms.

I’d value your view on the first question in particular.

As the author of grammatical-parsers I’m delighted to see them suggested as a solution in the PR, but they come with some heavy requirements: you’d have to declare all your functions as fields of a HKD record. Have you first considered monads, applicatives, arrows, or PHOAS instead? There’s a rich literature on embedding a language in Haskell. Since most functions in your language are effectful, applicatives are the first thing I’d reach for: design an applicative functor (which may or may not also be a monad) that statically tracks everything you need.

1 Like

Thanks, this changed the design. I dropped the HKD record and went with PHOAS. Lask needs the whole program before running anything, but the language itself is monadic: commands depend on earlier output. An applicative would keep things static only by forbidding that. PHOAS keeps the term inspectable under every binder.

fact :: Task '["n" ::: 'TNumber] 'TNumber
fact = task "fact" $ \n -> if_ (n ==. 0) 1 (n * call fact (n - 1))

lintChanged :: Task '[] 'TVoid
lintChanged = task "lint_changed" $
  forEach (call changed) $ \f -> run npm ("exec eslint -- " <> f)

Applicatives are still used where the structure is static: keyword parameters, and parallel composition. The parser isn’t built on the EDSL. Both front ends lower to the same Core, which is where they meet, and a test checks that they agree. It’s experimental and covers a subset of the language: feat: embed the language in Haskell as an experimental EDSL by Ikepeeee · Pull Request #111 · lask-task-runner/lask · GitHub

Next, I plan to extend it to the rest of the language and add a checker over Core that both front ends share.