Skip to content

Commit 41fb546

Browse files
authored
Add YAML+Jinja lexer (#1268)
## Summary Adds a `YAML+Jinja` lexer that combines the existing YAML and Django/Jinja lexers via `DelegatingLexer`. This is the equivalent of Pygments' [`YamlJinjaLexer`](https://pygments.org/docs/lexers/#pygments.lexers.templates.YamlJinjaLexer) and covers two common formats that the current lexers cannot highlight correctly on their own: - Ansible playbooks (Jinja2 expressions embedded in YAML) - Salt SLS files Implementation follows the same pattern as `ERB`, `PHTML`, `Svelte`, and `Go HTML Template`: a small Go file wraps two existing lexers with `DelegatingLexer`, reusing `embedded/yaml.xml` and `embedded/django_jinja.xml`. No new XML file is introduced. ## Lexer config | Field | Value | | ----- | ----- | | Name | `YAML+Jinja` | | Aliases | `yaml+jinja`, `salt`, `sls`, `ansible` | | Filenames | `*.sls` | | MIME types | `text/x-yaml+jinja`, `text/x-sls` | ### Why no `*.yml` / `*.yaml`? Ansible playbooks and Salt SLS files often use `*.yml`. Claiming those globs would route every plain YAML file through this lexer and create an ambiguous match against the built-in `YAML` lexer. To stay non-invasive, the lexer only claims `*.sls`; for Ansible playbooks the user is expected to select the lexer explicitly (`chroma -l ansible …`), matching Pygments' behavior. Existing lexers remain unaffected: | Input | Resolved lexer | | ----- | -------------- | | `*.yml` / `*.yaml` | `YAML` (unchanged) | | `*.sls` | `YAML+Jinja` (new) | | `text/x-yaml` | `YAML` (unchanged) | | `application/x-jinja` | `Django/Jinja` (unchanged) | | `text/x-yaml+jinja`, `text/x-sls` | `YAML+Jinja` (new) | ## Test plan - [x] `go test ./...` passes (new `TestLexers/YAML+Jinja/testdata/ansible.actual` case included; `.expected` generated via `RECORD=true go test ./lexers`) - [x] `golangci-lint run ./...` passes - [x] `gofmt -l` clean - [x] Manual smoke test: `lexers.Get("ansible")`, `lexers.Get("yaml+jinja")`, `lexers.Get("salt")`, `lexers.Get("sls")` all resolve to the new `YAML+Jinja` lexer - [x] Verified that `*.yml`, `*.yaml`, `text/x-yaml`, and `application/x-jinja` still resolve to the previous lexers (no regression)
1 parent e99b881 commit 41fb546

3 files changed

Lines changed: 278 additions & 0 deletions

File tree

lexers/testdata/ansible.actual

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
- name: Install and configure web servers
3+
hosts: webservers
4+
become: true
5+
vars:
6+
package_name: nginx
7+
listen_port: 8080
8+
admin_user: "{{ ansible_user | default('root') }}"
9+
tasks:
10+
- name: Ensure {{ package_name }} is installed
11+
ansible.builtin.package:
12+
name: "{{ package_name }}"
13+
state: present
14+
15+
{# render only when SSL is enabled #}
16+
{% if enable_ssl %}
17+
- name: Deploy TLS certificate
18+
ansible.builtin.copy:
19+
src: "files/{{ inventory_hostname }}.pem"
20+
dest: /etc/ssl/certs/{{ inventory_hostname }}.pem
21+
mode: "0644"
22+
{% endif %}
23+
24+
- name: Render configuration
25+
ansible.builtin.template:
26+
src: nginx.conf.j2
27+
dest: /etc/nginx/nginx.conf
28+
notify:
29+
- Restart nginx
30+
31+
- name: Print greeting
32+
ansible.builtin.debug:
33+
msg: "Hello {{ admin_user }}, listening on port {{ listen_port }}"
34+
35+
handlers:
36+
- name: Restart nginx
37+
ansible.builtin.service:
38+
name: "{{ package_name }}"
39+
state: restarted
40+
...

lexers/testdata/ansible.expected

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
[
2+
{"type":"NameNamespace","value":"---"},
3+
{"type":"TextWhitespace","value":"\n"},
4+
{"type":"Text","value":"- "},
5+
{"type":"NameTag","value":"name"},
6+
{"type":"Punctuation","value":":"},
7+
{"type":"TextWhitespace","value":" "},
8+
{"type":"Literal","value":"Install and configure web servers"},
9+
{"type":"TextWhitespace","value":"\n "},
10+
{"type":"NameTag","value":"hosts"},
11+
{"type":"Punctuation","value":":"},
12+
{"type":"TextWhitespace","value":" "},
13+
{"type":"Literal","value":"webservers"},
14+
{"type":"TextWhitespace","value":"\n "},
15+
{"type":"NameTag","value":"become"},
16+
{"type":"Punctuation","value":":"},
17+
{"type":"TextWhitespace","value":" "},
18+
{"type":"KeywordConstant","value":"true"},
19+
{"type":"TextWhitespace","value":"\n "},
20+
{"type":"NameTag","value":"vars"},
21+
{"type":"Punctuation","value":":"},
22+
{"type":"TextWhitespace","value":"\n "},
23+
{"type":"NameTag","value":"package_name"},
24+
{"type":"Punctuation","value":":"},
25+
{"type":"TextWhitespace","value":" "},
26+
{"type":"Literal","value":"nginx"},
27+
{"type":"TextWhitespace","value":"\n "},
28+
{"type":"NameTag","value":"listen_port"},
29+
{"type":"Punctuation","value":":"},
30+
{"type":"TextWhitespace","value":" "},
31+
{"type":"LiteralNumber","value":"8080"},
32+
{"type":"TextWhitespace","value":"\n "},
33+
{"type":"NameTag","value":"admin_user"},
34+
{"type":"Punctuation","value":":"},
35+
{"type":"TextWhitespace","value":" "},
36+
{"type":"LiteralStringDouble","value":"\""},
37+
{"type":"CommentPreproc","value":"{{"},
38+
{"type":"Text","value":" "},
39+
{"type":"NameVariable","value":"ansible_user"},
40+
{"type":"Text","value":" "},
41+
{"type":"Operator","value":"|"},
42+
{"type":"Text","value":" "},
43+
{"type":"NameFunction","value":"default"},
44+
{"type":"Operator","value":"("},
45+
{"type":"LiteralStringSingle","value":"'root'"},
46+
{"type":"Operator","value":")"},
47+
{"type":"Text","value":" "},
48+
{"type":"CommentPreproc","value":"}}"},
49+
{"type":"LiteralStringDouble","value":"\""},
50+
{"type":"TextWhitespace","value":"\n "},
51+
{"type":"NameTag","value":"tasks"},
52+
{"type":"Punctuation","value":":"},
53+
{"type":"TextWhitespace","value":"\n "},
54+
{"type":"Text","value":"- "},
55+
{"type":"NameTag","value":"name"},
56+
{"type":"Punctuation","value":":"},
57+
{"type":"TextWhitespace","value":" "},
58+
{"type":"Literal","value":"Ensure "},
59+
{"type":"CommentPreproc","value":"{{"},
60+
{"type":"Text","value":" "},
61+
{"type":"NameVariable","value":"package_name"},
62+
{"type":"Text","value":" "},
63+
{"type":"CommentPreproc","value":"}}"},
64+
{"type":"Literal","value":" is installed"},
65+
{"type":"TextWhitespace","value":"\n "},
66+
{"type":"NameTag","value":"ansible.builtin.package"},
67+
{"type":"Punctuation","value":":"},
68+
{"type":"TextWhitespace","value":"\n "},
69+
{"type":"NameTag","value":"name"},
70+
{"type":"Punctuation","value":":"},
71+
{"type":"TextWhitespace","value":" "},
72+
{"type":"LiteralStringDouble","value":"\""},
73+
{"type":"CommentPreproc","value":"{{"},
74+
{"type":"Text","value":" "},
75+
{"type":"NameVariable","value":"package_name"},
76+
{"type":"Text","value":" "},
77+
{"type":"CommentPreproc","value":"}}"},
78+
{"type":"LiteralStringDouble","value":"\""},
79+
{"type":"TextWhitespace","value":"\n "},
80+
{"type":"NameTag","value":"state"},
81+
{"type":"Punctuation","value":":"},
82+
{"type":"TextWhitespace","value":" "},
83+
{"type":"Literal","value":"present"},
84+
{"type":"TextWhitespace","value":"\n\n "},
85+
{"type":"Comment","value":"{# render only when SSL is enabled #}"},
86+
{"type":"TextWhitespace","value":"\n "},
87+
{"type":"CommentPreproc","value":"{%"},
88+
{"type":"Text","value":" "},
89+
{"type":"Keyword","value":"if"},
90+
{"type":"Text","value":" "},
91+
{"type":"NameVariable","value":"enable_ssl"},
92+
{"type":"Text","value":" "},
93+
{"type":"CommentPreproc","value":"%}"},
94+
{"type":"TextWhitespace","value":"\n "},
95+
{"type":"Text","value":"- "},
96+
{"type":"NameTag","value":"name"},
97+
{"type":"Punctuation","value":":"},
98+
{"type":"TextWhitespace","value":" "},
99+
{"type":"Literal","value":"Deploy TLS certificate"},
100+
{"type":"TextWhitespace","value":"\n "},
101+
{"type":"NameTag","value":"ansible.builtin.copy"},
102+
{"type":"Punctuation","value":":"},
103+
{"type":"TextWhitespace","value":"\n "},
104+
{"type":"NameTag","value":"src"},
105+
{"type":"Punctuation","value":":"},
106+
{"type":"TextWhitespace","value":" "},
107+
{"type":"LiteralStringDouble","value":"\"files/"},
108+
{"type":"CommentPreproc","value":"{{"},
109+
{"type":"Text","value":" "},
110+
{"type":"NameVariable","value":"inventory_hostname"},
111+
{"type":"Text","value":" "},
112+
{"type":"CommentPreproc","value":"}}"},
113+
{"type":"LiteralStringDouble","value":".pem\""},
114+
{"type":"TextWhitespace","value":"\n "},
115+
{"type":"NameTag","value":"dest"},
116+
{"type":"Punctuation","value":":"},
117+
{"type":"TextWhitespace","value":" "},
118+
{"type":"Literal","value":"/etc/ssl/certs/"},
119+
{"type":"CommentPreproc","value":"{{"},
120+
{"type":"Text","value":" "},
121+
{"type":"NameVariable","value":"inventory_hostname"},
122+
{"type":"Text","value":" "},
123+
{"type":"CommentPreproc","value":"}}"},
124+
{"type":"Literal","value":".pem"},
125+
{"type":"TextWhitespace","value":"\n "},
126+
{"type":"NameTag","value":"mode"},
127+
{"type":"Punctuation","value":":"},
128+
{"type":"TextWhitespace","value":" "},
129+
{"type":"LiteralStringDouble","value":"\"0644\""},
130+
{"type":"TextWhitespace","value":"\n "},
131+
{"type":"CommentPreproc","value":"{%"},
132+
{"type":"Text","value":" "},
133+
{"type":"Keyword","value":"endif"},
134+
{"type":"Text","value":" "},
135+
{"type":"CommentPreproc","value":"%}"},
136+
{"type":"TextWhitespace","value":"\n\n "},
137+
{"type":"Text","value":"- "},
138+
{"type":"NameTag","value":"name"},
139+
{"type":"Punctuation","value":":"},
140+
{"type":"TextWhitespace","value":" "},
141+
{"type":"Literal","value":"Render configuration"},
142+
{"type":"TextWhitespace","value":"\n "},
143+
{"type":"NameTag","value":"ansible.builtin.template"},
144+
{"type":"Punctuation","value":":"},
145+
{"type":"TextWhitespace","value":"\n "},
146+
{"type":"NameTag","value":"src"},
147+
{"type":"Punctuation","value":":"},
148+
{"type":"TextWhitespace","value":" "},
149+
{"type":"Literal","value":"nginx.conf.j2"},
150+
{"type":"TextWhitespace","value":"\n "},
151+
{"type":"NameTag","value":"dest"},
152+
{"type":"Punctuation","value":":"},
153+
{"type":"TextWhitespace","value":" "},
154+
{"type":"Literal","value":"/etc/nginx/nginx.conf"},
155+
{"type":"TextWhitespace","value":"\n "},
156+
{"type":"NameTag","value":"notify"},
157+
{"type":"Punctuation","value":":"},
158+
{"type":"TextWhitespace","value":"\n "},
159+
{"type":"Text","value":"- "},
160+
{"type":"Literal","value":"Restart nginx"},
161+
{"type":"TextWhitespace","value":"\n\n "},
162+
{"type":"Text","value":"- "},
163+
{"type":"NameTag","value":"name"},
164+
{"type":"Punctuation","value":":"},
165+
{"type":"TextWhitespace","value":" "},
166+
{"type":"Literal","value":"Print greeting"},
167+
{"type":"TextWhitespace","value":"\n "},
168+
{"type":"NameTag","value":"ansible.builtin.debug"},
169+
{"type":"Punctuation","value":":"},
170+
{"type":"TextWhitespace","value":"\n "},
171+
{"type":"NameTag","value":"msg"},
172+
{"type":"Punctuation","value":":"},
173+
{"type":"TextWhitespace","value":" "},
174+
{"type":"LiteralStringDouble","value":"\"Hello "},
175+
{"type":"CommentPreproc","value":"{{"},
176+
{"type":"Text","value":" "},
177+
{"type":"NameVariable","value":"admin_user"},
178+
{"type":"Text","value":" "},
179+
{"type":"CommentPreproc","value":"}}"},
180+
{"type":"LiteralStringDouble","value":", listening on port "},
181+
{"type":"CommentPreproc","value":"{{"},
182+
{"type":"Text","value":" "},
183+
{"type":"NameVariable","value":"listen_port"},
184+
{"type":"Text","value":" "},
185+
{"type":"CommentPreproc","value":"}}"},
186+
{"type":"LiteralStringDouble","value":"\""},
187+
{"type":"TextWhitespace","value":"\n\n "},
188+
{"type":"NameTag","value":"handlers"},
189+
{"type":"Punctuation","value":":"},
190+
{"type":"TextWhitespace","value":"\n "},
191+
{"type":"Text","value":"- "},
192+
{"type":"NameTag","value":"name"},
193+
{"type":"Punctuation","value":":"},
194+
{"type":"TextWhitespace","value":" "},
195+
{"type":"Literal","value":"Restart nginx"},
196+
{"type":"TextWhitespace","value":"\n "},
197+
{"type":"NameTag","value":"ansible.builtin.service"},
198+
{"type":"Punctuation","value":":"},
199+
{"type":"TextWhitespace","value":"\n "},
200+
{"type":"NameTag","value":"name"},
201+
{"type":"Punctuation","value":":"},
202+
{"type":"TextWhitespace","value":" "},
203+
{"type":"LiteralStringDouble","value":"\""},
204+
{"type":"CommentPreproc","value":"{{"},
205+
{"type":"Text","value":" "},
206+
{"type":"NameVariable","value":"package_name"},
207+
{"type":"Text","value":" "},
208+
{"type":"CommentPreproc","value":"}}"},
209+
{"type":"LiteralStringDouble","value":"\""},
210+
{"type":"TextWhitespace","value":"\n "},
211+
{"type":"NameTag","value":"state"},
212+
{"type":"Punctuation","value":":"},
213+
{"type":"TextWhitespace","value":" "},
214+
{"type":"Literal","value":"restarted"},
215+
{"type":"TextWhitespace","value":"\n"},
216+
{"type":"NameNamespace","value":"..."},
217+
{"type":"TextWhitespace","value":"\n"}
218+
]

lexers/yaml_jinja.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package lexers
2+
3+
import (
4+
. "github.com/alecthomas/chroma/v2" // nolint
5+
)
6+
7+
// YAML+Jinja is YAML with Jinja templating embedded. Used by Ansible playbooks
8+
// and Salt SLS files.
9+
var YAMLJinja = Register(DelegatingLexer(
10+
MustNewXMLLexer(embedded, "embedded/yaml.xml"),
11+
MustNewXMLLexer(embedded, "embedded/django_jinja.xml").SetConfig(
12+
&Config{
13+
Name: "YAML+Jinja",
14+
Aliases: []string{"yaml+jinja", "salt", "sls", "ansible"},
15+
Filenames: []string{"*.sls"},
16+
MimeTypes: []string{"text/x-yaml+jinja", "text/x-sls"},
17+
DotAll: true,
18+
},
19+
),
20+
))

0 commit comments

Comments
 (0)