Skip to content

Template DSL

Overview

Majutsu includes a domain-specific language (DSL) for building Jujutsu templates in Emacs Lisp. The main entry point is majutsu-tpl, which compiles a vector-based DSL form into a jj template string.

Why Use the DSL?

The DSL provides several advantages over writing raw template strings:

  • Early validation: Constant vector forms with an explicit constant self type are validated during macro expansion/byte-compilation. Dynamic forms and forms that rely on the configurable default self type are validated when Majutsu compiles them, before they are sent to jj.

  • Automatic escaping: String literals are properly escaped (quotes, backslashes, control characters) without manual intervention.

  • Elisp integration: Embed Elisp expressions that are evaluated when Majutsu compiles the template—during macro expansion for constant forms and at runtime for dynamic forms—so configuration and context can shape the result.

  • Composability: Define reusable template functions with majutsu-template-defun that lower into the final template instead of requiring jj-side aliases.

  • Type awareness: The DSL understands jj's type system, enabling self-type context for cleaner keyword syntax ([:description] instead of [:method [:self] :description]).

  • Readability: Vector-based syntax with keywords is more readable than deeply nested string concatenation.

Example comparison:

;; Raw string (error-prone, hard to read)
"if(self.root(), \"(root)\", self.commit_id().short())"

;; DSL (validated, composable, readable)
(majutsu-tpl [:if [:root] "(root)" [:commit_id :short]] 'Commit)

Basic Syntax

Vectors and Concatenation

Vectors without a leading keyword are implicitly concatenated:

(majutsu-tpl ["A" "B"])           ; => "concat(\"A\", \"B\")"
(majutsu-tpl [:concat "A" "B"])   ; => "concat(\"A\", \"B\")"

Bare strings inside vectors are automatically treated as string literals (:str).

String Literals

Use :str for explicit string literals with proper escaping:

(majutsu-tpl [:str "Hello \"World\""])  ; => "Hello \"World\""

Raw Injection

Use :raw to inject template code directly without escaping:

(majutsu-tpl [:raw "self.commit_id().short()"])  ; => "self.commit_id().short()"

Untyped raw snippets now default to semantic type Unknown rather than pretending to be Template. If you want method-chain typing to keep flowing, add an explicit annotation:

[:raw "self" :Commit]

The callable position of :call can be written as a quoted symbol, keyword, or string name:

(majutsu-tpl [:call 'coalesce [:str ""] [:str "X"]])  ; => "coalesce(\"\", \"X\")"

Booleans and Numbers

Elisp t and nil map to true and false. Numbers pass through directly:

(majutsu-tpl [:if t "yes" "no"])      ; => "if(true, \"yes\", \"no\")"
(majutsu-tpl [:call 'pad_end 8 "x"])  ; => "pad_end(8, \"x\")"

Method Calls and Self

Explicit Method Chaining

Use :method to call methods on objects. Methods can be chained:

(majutsu-tpl [:method [:raw "commit" :Commit] :commit_id])
; => "commit.commit_id()"

(majutsu-tpl [:method [:raw "commit" :Commit] :parents :len])
; => "commit.parents().len()"

(majutsu-tpl [:method [:raw "commit" :Commit] :diff "src"])
; => "commit.diff(\"src\")"

Implicit Self Context

At public compile entry points, when a self type is provided (or the default self type is configured), Majutsu installs a root self binding and bare keywords become method calls on that receiver:

(majutsu-tpl [:description] 'Commit)      ; => "self.description()"
(majutsu-tpl [:parents :len] 'Commit)     ; => "self.parents().len()"

Explicit Receiver References

Use [:self] when you need the current implicit receiver as a value. When nested lambdas or helper-local :bind-self scopes introduce new receivers, [:self N] selects the outer binding N levels up ([:self 0] is the same as [:self]):

(majutsu-tpl [:method [:self] :description] 'Commit)
; => "self.description()"

(majutsu-tpl [:method [:raw "self" :Commit]
                      :parents
                      :map
                      [:|p| [:method [:self 1] :description]]])
; => "self.parents().map(|p| self.description())"

Binding Self in Helper Bodies

Ordinary helpers can temporarily rebind the implicit self inside their body with :bind-self:

(majutsu-template-defun show-canonical-log-id ((object Commit :optional t))
  (:returns Template :bind-self object)
  [:canonical-log-id])

When the bound parameter is nil, the helper inherits the outer self binding.

Operators

Arithmetic and logical operators are supported:

(majutsu-tpl [:+ 1 2])              ; => "(1 + 2)"
(majutsu-tpl [:and [:> 3 1] [:<= 2 2]])  ; => "((3 > 1) && (2 <= 2))"
(majutsu-tpl [:not t])              ; => "(!true)"
(majutsu-tpl [:++ "L" "R"])         ; => "(\"L\" ++ \"R\")"

Conditionals and Composition

(majutsu-tpl [:if [:root] "(root)" [:commit_id]])
; => "if(self.root(), \"(root)\", self.commit_id())"

(majutsu-tpl [:separate " " [:label "a" "A"] [:label "b" "B"]])
; => "separate(\" \", label(\"a\", \"A\"), label(\"b\", \"B\"))"

Elisp Embedding

Elisp expressions are evaluated when the template is compiled.

(let* ((tmp 1)
       (s1 `[:concat ,(if (> 2 tmp) [:str "T"] [:str "F"]) [:str "!"]])
       (s2 [:concat (if (> 2 tmp) [:str "T"] [:str "F"]) [:str "!"]])
       (tmp 3))
  (concat (majutsu-tpl s1) (majutsu-tpl s2)))
; => "concat(\"T\", \"!\")concat(\"F\", \"!\")"

Anonymous Functions and Higher-Order Operations

Anonymous functions are first-class template values:

(majutsu-tpl [:lambda [c] [:description]])
; => "|c| c.description()"

(majutsu-tpl [:|c| [:description]])
; => "|c| c.description()"

(majutsu-tpl [:parents :map [:|c| [:description]]])
; => "self.parents().map(|c| c.description())"

(majutsu-tpl [:call [:|c| [:description]] [:raw "item" :Commit]])
; => "item.description()"

(majutsu-tpl [[:lambda [c] [:description]] [:raw "item" :Commit]])
; => "item.description()"

The shorthand [:|c| BODY] is equivalent to [:lambda [c] BODY].

Lambda parameters are lexical variables and can act as a deferred implicit self for bare keyword dispatch. A surface lambda such as [:lambda [c] [:description]] is therefore kept generic first and can later be specialized from a typed call argument or a higher-order container element. In those cases [:description] becomes equivalent to [:method 'c :description]. If you prefer, you can also write that explicit [:method 'c ...] form directly, but most examples use bare keywords because it better matches the implicit-self model. Explicit lexical references also keep working across nested lambdas, so inner bodies may still refer to outer parameters by name when that is clearer than rebinding receiver context. This deferred behavior is limited to lambda parameters; Majutsu no longer uses unknown non-lambda receivers as a general bare-keyword fallback. If you need an outer receiver explicitly inside a nested lambda, use [:self N].

For example, the inner body below uses all three forms at once: [:description] for the inner receiver, [:self 1] for the outer receiver, and explicit [:method 'o ...] for the outer lexical parameter:

(majutsu-tpl
 [[:|o|
   [[:|i|
     [:if [:method 'o :root]
         [:description]
       [:method [:self 1] :description]]]
    [:raw "inner" :Commit]]]
  [:raw "outer" :Commit]])
; => "if(outer.root(), inner.description(), outer.description())"

List-oriented methods can be written in several equivalent styles:

;; Historical explicit-binder sugar (lowers to a native lambda internally)
(majutsu-tpl [:map [:raw "self.bookmarks()"] b [:raw "b.name()"]])
; => "self.bookmarks().map(|b| b.name())"

;; Direct jj-style method call with an anonymous lambda
(majutsu-tpl [:method [:raw "refs"] :map [:lambda [c] [:description]]])
; => "refs.map(|c| c.description())"

;; Dash-style explicit lambda
(majutsu-tpl [:-map [:lambda [c] [:description]] [:raw "refs"]])
; => "refs.map(|c| c.description())"

;; Dash-style anaphoric shorthand
(majutsu-tpl [:--map [:method 'it :description] [:raw "refs"]])
; => "refs.map(|it| it.description())"

(majutsu-tpl [:method
              [:map [:raw "self.parents()"] p [:raw "p.commit_id()"]]
              :join [:str ", "]])
; => "self.parents().map(|p| p.commit_id()).join(\", \")"

(majutsu-tpl [:method [:raw "self.parents()"]
                      :map [:lambda [p] [:raw "p.commit_id()"]]
                      :join [:str ", "]])
; => "self.parents().map(|p| p.commit_id()).join(\", \")"

Prefer direct lambdas and :map + :join composition in new code. The historical binder form [:map collection var body] remains as compatibility sugar, but it now lowers through the same core :method + :lambda path as direct method calls. Similarly, :-map remains a value-level explicit-lambda helper while the :--map family is syntax sugar layered on top of the same lambda support.

This is a deliberate Majutsu DSL adaptation. Upstream jj centers the higher-order story around expressions such as collection.map(|x| body); Majutsu keeps that core model and layers additional sugar on top of it. Reusable lambda bodies can therefore also be defined as ordinary helpers that return native lambda values:

(majutsu-template-defun description-fn ()
  (:returns Lambda)
  [:lambda [c] [:description]])

(majutsu-tpl [:description-fn])
; => "|c| c.description()"

(majutsu-tpl [:method [:raw "refs"] :map [:description-fn]])
; => "refs.map(|c| c.description())"

(majutsu-template-defun description-with-suffix ((suffix Template))
  (:returns Lambda)
  `[:lambda [c] [:concat [:description] ,suffix]])

(majutsu-tpl [:method [:raw "refs"] :map [:description-with-suffix [:str "!"]]])
; => "refs.map(|c| concat(c.description(), \"!\"))"

Extending the DSL

Use majutsu-template-defun to define reusable template functions:

(majutsu-template-defun my-helper ((label Template) (value Template :optional t))
  (:returns Template)
  `[:concat ,label [:str ": "] ,(or value [:str ""])])

(majutsu-tpl [:my-helper [:str "ID"] [:str "VAL"]])
; => "concat(\"ID\", \": \", \"VAL\")"

When a helper omits its body, majutsu-template-defun defaults to a simple wrapper around the same jj callable name:

(majutsu-template-defun my-fill ((width Integer) (content Template))
  (:returns Template))

(majutsu-tpl [:my-fill 8 "x"])
; => "my-fill(8, \"x\")"

Syntax-level sugar should be defined separately with majutsu-template-defspecial, which receives raw forms and lowers them to more primitive template syntax:

(majutsu-template-defspecial :wrap-angle (body)
  `[:concat [:str "<"] ,body [:str ">"]])

(majutsu-tpl [:wrap-angle [:str "x"]])
; => "concat(\"<\", \"x\", \">\")"

Owner-bound methods can also be defined locally in the DSL. A body-less majutsu-template-defmethod / majutsu-template-defkeyword declaration just registers metadata for a native/rendered method, but providing a body turns it into a local owner-bound lowering:

(majutsu-template-defkeyword canonical-log-id Commit
  (:returns Template)
  [:if [:or [:hidden]
             [:divergent]]
       [:commit_id :shortest 8]
     [:change_id :shortest 8]])

(majutsu-tpl [:canonical-log-id] 'Commit)
; => "if((self.hidden() || self.divergent()), self.commit_id().shortest(8), self.change_id().shortest(8))"

(majutsu-tpl [:method [:raw "p" :Commit] :canonical-log-id])
; => "if((p.hidden() || p.divergent()), p.commit_id().shortest(8), p.change_id().shortest(8))"

Type System and Upstream Alignment

The DSL supports Jujutsu's type system: Any, String, Boolean, Integer, Template, Commit, Signature, Timestamp, List, Option, and more. Type annotations can be added to :raw nodes:

[:raw "self" :Commit]  ; Declares the raw value has type Commit

Without an annotation, :raw remains Unknown. This keeps the DSL honest: raw snippets still compile, but richer type propagation only kicks in once the expression is explicitly typed or inferred through later semantic steps.

Majutsu distinguishes between broad Any expression placeholders and the narrower printable Template capability. In practice, Any means “some expression”, while Template means content that can actually be rendered or concatenated.

Majutsu also propagates core result types through the normalized AST. For example, parents() is tracked as a list of commits, lines() as a list of strings, trailers() as (:list Trailer), mapped lists use ordinary container refs such as (:list String), and list methods such as first() preserve the element type.

A few upstream categories are still intentionally simplified in Majutsu's checker. For parameters that upstream models as StringLiteral, Majutsu currently recognizes obvious literal strings, but it does not try to prove that more complex expressions become literals after helper expansion or constant folding. Upstream also has distinct AnyList-style result categories; Majutsu currently models those as ordinary container refs instead.

Supported Commands (for development reference)

Templates can be used with these jj commands:

CommandSelf Type
logCommit
showCommit
evologCommitEvolutionEntry
diffTreeDiffEntry
bookmark listCommitRef
tag listCommitRef
file annotateAnnotationLine
file listTreeEntry
file showTreeEntry
op logOperation
op showOperation
workspace listWorkspaceRef
config listNamed keyword fields; value is ConfigValue

Unlike the object-oriented rows above, jj config list exposes named keywords (name, value, overridden, source, and path) rather than a single ConfigValue receiver.