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-defunthat 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:
| Command | Self Type |
|---|---|
| log | Commit |
| show | Commit |
| evolog | CommitEvolutionEntry |
| diff | TreeDiffEntry |
| bookmark list | CommitRef |
| tag list | CommitRef |
| file annotate | AnnotationLine |
| file list | TreeEntry |
| file show | TreeEntry |
| op log | Operation |
| op show | Operation |
| workspace list | WorkspaceRef |
| config list | Named 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.