Repository: alecthomas/chroma
Pull request #332 (2020-02-14 23:35:53)

Add hlb lexer
Hi @alecthomas, just wanted to experiment with various syntax highlighting libraries for [HLB](https://github.com/openllb/hlb), a language I'm writing using participle.

I noticed that chroma doesn't seem to like the non-capturing group:
```go
{`(\b((?!(scratch|image)\b)[a-zA-Z_][a-zA-Z0-9]*\b))`, ByGroups(NameOther), nil},
```
![chroma-hlb](https://user-images.githubusercontent.com/6493975/74576402-71dc0500-4f3f-11ea-84eb-b52b70de52ae.png)

The equivalent from pygments:
```python
(u'(\\b((?!(scratch|image)\\b)[a-zA-Z_][a-zA-Z0-9]*\\b))', bygroups(Name.Variable)),
```
![pygments-hlb](https://user-images.githubusercontent.com/6493975/74576429-8ae4b600-4f3f-11ea-8fc1-6c1609ea9ed3.png)

I also included a `pygments2chroma.hlb` that allowed me to containerize the workflow to convert my pygments lexer, happy to exclude it.

alecthomas: Thanks! Try using `chroma --trace` to debug which rules are matching.
hinshun: I looked at haxe's example of using non-capturing group and updated my lexer to work for both pygments and chroma.

![chroma-hlb](https://user-images.githubusercontent.com/6493975/74679629-3fb2e900-5173-11ea-897c-ae736ac3e8bb.png)
alecthomas: Nice! Can you add a test please? Instructions are in the lexers directory.

Patch:
diff --git a/_tools/pygments2chroma.hlb b/_tools/pygments2chroma.hlb
new file mode 100644
index 0000000..3cb5240
--- /dev/null
+++ b/_tools/pygments2chroma.hlb
@@ -0,0 +1,29 @@
+fs default() {
+	gofmt fs { chromaLexer "pygments.lexers.hlb.HlbLexer"; }
+}
+
+fs script() {
+	local "." with option {
+		includePatterns "pygments2chroma.py"
+	}
+}
+
+fs runChromaPython(string package) {
+	image "python:alpine" with option { resolve; }
+	run "apk add -U git"
+	run "pip install -U pystache"
+	run "pip install -U -e git+https://github.com/hinshun/pygments.git@hlb-lexer#egg=pygments"
+	run string { format "python pygments2chroma.py %s > /out/lexer.go" package; } with option {
+		dir "/chroma"
+		mount script "/chroma"
+		mount fs { scratch; } "/out" as chromaLexer
+	}
+}
+
+fs runGoFormat(fs goFiles) {
+	image "golang:alpine" with option { resolve; }
+	run "gofmt -s -w /gofmt/*.go" with option {
+		dir "/gofmt"
+		mount goFiles "/gofmt" as gofmt
+	}
+}
diff --git a/go.mod b/go.mod
index 38eaa00..307b4ff 100644
--- a/go.mod
+++ b/go.mod
@@ -1,5 +1,7 @@
 module github.com/alecthomas/chroma
 
+go 1.13
+
 require (
 	github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38
 	github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 // indirect
@@ -13,7 +15,3 @@ require (
 	github.com/stretchr/testify v1.3.0 // indirect
 	golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35 // indirect
 )
-
-replace github.com/GeertJohan/go.rice => github.com/alecthomas/go.rice v1.0.1-0.20190719113735-961b99d742e7
-
-go 1.13
diff --git a/lexers/hlb.go b/lexers/hlb.go
new file mode 100644
index 0000000..4dcf8ed
--- /dev/null
+++ b/lexers/hlb.go
@@ -0,0 +1,54 @@
+package lexers
+
+import (
+	. "github.com/alecthomas/chroma" // nolint
+	"github.com/alecthomas/chroma/lexers/internal"
+)
+
+// HLB lexer.
+var HLB = internal.Register(MustNewLexer(
+	&Config{
+		Name:      "HLB",
+		Aliases:   []string{"hlb"},
+		Filenames: []string{"*.hlb"},
+		MimeTypes: []string{},
+	},
+	Rules{
+		"root": {
+			{`(#.*)`, ByGroups(CommentSingle), nil},
+			{`((\b(0(b|B|o|O|x|X)[a-fA-F0-9]+)\b)|(\b(0|[1-9][0-9]*)\b))`, ByGroups(LiteralNumber), nil},
+			{`((\b(true|false)\b))`, ByGroups(NameBuiltin), nil},
+			{`(\bstring\b|\bint\b|\bbool\b|\bfs\b|\boption\b)`, ByGroups(KeywordType), nil},
+			{`(\b[a-zA-Z_][a-zA-Z0-9]*\b)(\()`, ByGroups(NameFunction, Punctuation), Push("params")},
+			{`(\{)`, ByGroups(Punctuation), Push("block")},
+			{`(\n|\r|\r\n)`, Text, nil},
+			{`.`, Text, nil},
+		},
+		"string": {
+			{`"`, LiteralString, Pop(1)},
+			{`\\"`, LiteralString, nil},
+			{`[^\\"]+`, LiteralString, nil},
+		},
+		"block": {
+			{`(\})`, ByGroups(Punctuation), Pop(1)},
+			{`(#.*)`, ByGroups(CommentSingle), nil},
+			{`((\b(0(b|B|o|O|x|X)[a-fA-F0-9]+)\b)|(\b(0|[1-9][0-9]*)\b))`, ByGroups(LiteralNumber), nil},
+			{`((\b(true|false)\b))`, ByGroups(KeywordConstant), nil},
+			{`"`, LiteralString, Push("string")},
+			{`(with)`, ByGroups(KeywordReserved), nil},
+			{`(as)([\t ]+)(\b[a-zA-Z_][a-zA-Z0-9]*\b)`, ByGroups(KeywordReserved, Text, NameFunction), nil},
+			{`(\bstring\b|\bint\b|\bbool\b|\bfs\b|\boption\b)([\t ]+)(\{)`, ByGroups(KeywordType, Text, Punctuation), Push("block")},
+			{`(?!\b(?:scratch|image|resolve|http|checksum|chmod|filename|git|keepGitDir|local|includePatterns|excludePatterns|followPaths|generate|frontendInput|shell|run|readonlyRootfs|env|dir|user|network|security|host|ssh|secret|mount|target|localPath|uid|gid|mode|readonly|tmpfs|sourcePath|cache|mkdir|createParents|chown|createdTime|mkfile|rm|allowNotFound|allowWildcards|copy|followSymlinks|contentsOnly|unpack|createDestPath)\b)(\b[a-zA-Z_][a-zA-Z0-9]*\b)`, ByGroups(NameOther), nil},
+			{`(\n|\r|\r\n)`, Text, nil},
+			{`.`, Text, nil},
+		},
+		"params": {
+			{`(\))`, ByGroups(Punctuation), Pop(1)},
+			{`(variadic)`, ByGroups(Keyword), nil},
+			{`(\bstring\b|\bint\b|\bbool\b|\bfs\b|\boption\b)`, ByGroups(KeywordType), nil},
+			{`(\b[a-zA-Z_][a-zA-Z0-9]*\b)`, ByGroups(NameOther), nil},
+			{`(\n|\r|\r\n)`, Text, nil},
+			{`.`, Text, nil},
+		},
+	},
+))
diff --git a/lexers/s/sas.go b/lexers/s/sas.go
new file mode 100644
index 0000000..47a67ed
--- /dev/null
+++ b/lexers/s/sas.go
@@ -0,0 +1,94 @@
+package s
+
+import (
+	. "github.com/alecthomas/chroma" // nolint
+	"github.com/alecthomas/chroma/lexers/internal"
+)
+
+// Sas lexer.
+var Sas = internal.Register(MustNewLexer(
+	&Config{
+		Name:            "SAS",
+		Aliases:         []string{"sas"},
+		Filenames:       []string{"*.SAS", "*.sas"},
+		MimeTypes:       []string{"text/x-sas", "text/sas", "application/x-sas"},
+		CaseInsensitive: true,
+	},
+	Rules{
+		"root": {
+			Include("comments"),
+			Include("proc-data"),
+			Include("cards-datalines"),
+			Include("logs"),
+			Include("general"),
+			{`.`, Text, nil},
+			{`\\\n`, Text, nil},
+			{`\n`, Text, nil},
+		},
+		"comments": {
+			{`^\s*\*.*?;`, Comment, nil},
+			{`/\*.*?\*/`, Comment, nil},
+			{`^\s*\*(.|\n)*?;`, CommentMultiline, nil},
+			{`/[*](.|\n)*?[*]/`, CommentMultiline, nil},
+		},
+		"proc-data": {
+			{`(^|;)\s*(proc \w+|data|run|quit)[\s;]`, KeywordReserved, nil},
+		},
+		"cards-datalines": {
+			{`^\s*(datalines|cards)\s*;\s*$`, Keyword, Push("data")},
+		},
+		"data": {
+			{`(.|\n)*^\s*;\s*$`, Other, Pop(1)},
+		},
+		"logs": {
+			{`\n?^\s*%?put `, Keyword, Push("log-messages")},
+		},
+		"log-messages": {
+			{`NOTE(:|-).*`, Generic, Pop(1)},
+			{`WARNING(:|-).*`, GenericEmph, Pop(1)},
+			{`ERROR(:|-).*`, GenericError, Pop(1)},
+			Include("general"),
+		},
+		"general": {
+			Include("keywords"),
+			Include("vars-strings"),
+			Include("special"),
+			Include("numbers"),
+		},
+		"keywords": {
+			{Words(`\b`, `\b`, `abort`, `array`, `attrib`, `by`, `call`, `cards`, `cards4`, `catname`, `continue`, `datalines`, `datalines4`, `delete`, `delim`, `delimiter`, `display`, `dm`, `drop`, `endsas`, `error`, `file`, `filename`, `footnote`, `format`, `goto`, `in`, `infile`, `informat`, `input`, `keep`, `label`, `leave`, `length`, `libname`, `link`, `list`, `lostcard`, `merge`, `missing`, `modify`, `options`, `output`, `out`, `page`, `put`, `redirect`, `remove`, `rename`, `replace`, `retain`, `return`, `select`, `set`, `skip`, `startsas`, `stop`, `title`, `update`, `waitsas`, `where`, `window`, `x`, `systask`), Keyword, nil},
+			{Words(`\b`, `\b`, `add`, `and`, `alter`, `as`, `cascade`, `check`, `create`, `delete`, `describe`, `distinct`, `drop`, `foreign`, `from`, `group`, `having`, `index`, `insert`, `into`, `in`, `key`, `like`, `message`, `modify`, `msgtype`, `not`, `null`, `on`, `or`, `order`, `primary`, `references`, `reset`, `restrict`, `select`, `set`, `table`, `unique`, `update`, `validate`, `view`, `where`), Keyword, nil},
+			{Words(`\b`, `\b`, `do`, `if`, `then`, `else`, `end`, `until`, `while`), Keyword, nil},
+			{Words(`%`, `\b`, `bquote`, `nrbquote`, `cmpres`, `qcmpres`, `compstor`, `datatyp`, `display`, `do`, `else`, `end`, `eval`, `global`, `goto`, `if`, `index`, `input`, `keydef`, `label`, `left`, `length`, `let`, `local`, `lowcase`, `macro`, `mend`, `nrquote`, `nrstr`, `put`, `qleft`, `qlowcase`, `qscan`, `qsubstr`, `qsysfunc`, `qtrim`, `quote`, `qupcase`, `scan`, `str`, `substr`, `superq`, `syscall`, `sysevalf`, `sysexec`, `sysfunc`, `sysget`, `syslput`, `sysprod`, `sysrc`, `sysrput`, `then`, `to`, `trim`, `unquote`, `until`, `upcase`, `verify`, `while`, `window`), NameBuiltin, nil},
+			{Words(`\b`, `\(`, `abs`, `addr`, `airy`, `arcos`, `arsin`, `atan`, `attrc`, `attrn`, `band`, `betainv`, `blshift`, `bnot`, `bor`, `brshift`, `bxor`, `byte`, `cdf`, `ceil`, `cexist`, `cinv`, `close`, `cnonct`, `collate`, `compbl`, `compound`, `compress`, `cos`, `cosh`, `css`, `curobs`, `cv`, `daccdb`, `daccdbsl`, `daccsl`, `daccsyd`, `dacctab`, `dairy`, `date`, `datejul`, `datepart`, `datetime`, `day`, `dclose`, `depdb`, `depdbsl`, `depsl`, `depsyd`, `deptab`, `dequote`, `dhms`, `dif`, `digamma`, `dim`, `dinfo`, `dnum`, `dopen`, `doptname`, `doptnum`, `dread`, `dropnote`, `dsname`, `erf`, `erfc`, `exist`, `exp`, `fappend`, `fclose`, `fcol`, `fdelete`, `fetch`, `fetchobs`, `fexist`, `fget`, `fileexist`, `filename`, `fileref`, `finfo`, `finv`, `fipname`, `fipnamel`, `fipstate`, `floor`, `fnonct`, `fnote`, `fopen`, `foptname`, `foptnum`, `fpoint`, `fpos`, `fput`, `fread`, `frewind`, `frlen`, `fsep`, `fuzz`, `fwrite`, `gaminv`, `gamma`, `getoption`, `getvarc`, `getvarn`, `hbound`, `hms`, `hosthelp`, `hour`, `ibessel`, `index`, `indexc`, `indexw`, `input`, `inputc`, `inputn`, `int`, `intck`, `intnx`, `intrr`, `irr`, `jbessel`, `juldate`, `kurtosis`, `lag`, `lbound`, `left`, `length`, `lgamma`, `libname`, `libref`, `log`, `log10`, `log2`, `logpdf`, `logpmf`, `logsdf`, `lowcase`, `max`, `mdy`, `mean`, `min`, `minute`, `mod`, `month`, `mopen`, `mort`, `n`, `netpv`, `nmiss`, `normal`, `note`, `npv`, `open`, `ordinal`, `pathname`, `pdf`, `peek`, `peekc`, `pmf`, `point`, `poisson`, `poke`, `probbeta`, `probbnml`, `probchi`, `probf`, `probgam`, `probhypr`, `probit`, `probnegb`, `probnorm`, `probt`, `put`, `putc`, `putn`, `qtr`, `quote`, `ranbin`, `rancau`, `ranexp`, `rangam`, `range`, `rank`, `rannor`, `ranpoi`, `rantbl`, `rantri`, `ranuni`, `repeat`, `resolve`, `reverse`, `rewind`, `right`, `round`, `saving`, `scan`, `sdf`, `second`, `sign`, `sin`, `sinh`, `skewness`, `soundex`, `spedis`, `sqrt`, `std`, `stderr`, `stfips`, `stname`, `stnamel`, `substr`, `sum`, `symget`, `sysget`, `sysmsg`, `sysprod`, `sysrc`, `system`, `tan`, `tanh`, `time`, `timepart`, `tinv`, `tnonct`, `today`, `translate`, `tranwrd`, `trigamma`, `trim`, `trimn`, `trunc`, `uniform`, `upcase`, `uss`, `var`, `varfmt`, `varinfmt`, `varlabel`, `varlen`, `varname`, `varnum`, `varray`, `varrayx`, `vartype`, `verify`, `vformat`, `vformatd`, `vformatdx`, `vformatn`, `vformatnx`, `vformatw`, `vformatwx`, `vformatx`, `vinarray`, `vinarrayx`, `vinformat`, `vinformatd`, `vinformatdx`, `vinformatn`, `vinformatnx`, `vinformatw`, `vinformatwx`, `vinformatx`, `vlabel`, `vlabelx`, `vlength`, `vlengthx`, `vname`, `vnamex`, `vtype`, `vtypex`, `weekday`, `year`, `yyq`, `zipfips`, `zipname`, `zipnamel`, `zipstate`), NameBuiltin, nil},
+		},
+		"vars-strings": {
+			{`&[a-z_]\w{0,31}\.?`, NameVariable, nil},
+			{`%[a-z_]\w{0,31}`, NameFunction, nil},
+			{`\'`, LiteralString, Push("string_squote")},
+			{`"`, LiteralString, Push("string_dquote")},
+		},
+		"string_squote": {
+			{`'`, LiteralString, Pop(1)},
+			{`\\\\|\\"|\\\n`, LiteralStringEscape, nil},
+			{`[^$\'\\]+`, LiteralString, nil},
+			{`[$\'\\]`, LiteralString, nil},
+		},
+		"string_dquote": {
+			{`"`, LiteralString, Pop(1)},
+			{`\\\\|\\"|\\\n`, LiteralStringEscape, nil},
+			{`&`, NameVariable, Push("validvar")},
+			{`[^$&"\\]+`, LiteralString, nil},
+			{`[$"\\]`, LiteralString, nil},
+		},
+		"validvar": {
+			{`[a-z_]\w{0,31}\.?`, NameVariable, Pop(1)},
+		},
+		"numbers": {
+			{`\b[+-]?([0-9]+(\.[0-9]+)?|\.[0-9]+|\.)(E[+-]?[0-9]+)?i?\b`, LiteralNumber, nil},
+		},
+		"special": {
+			{`(null|missing|_all_|_automatic_|_character_|_n_|_infile_|_name_|_null_|_numeric_|_user_|_webout_)`, KeywordConstant, nil},
+		},
+	},
+))
diff --git a/lexers/t/terraform.go b/lexers/t/terraform.go
index 05125f7..093ee30 100644
--- a/lexers/t/terraform.go
+++ b/lexers/t/terraform.go
@@ -15,55 +15,46 @@ var Terraform = internal.Register(MustNewLexer(
 	},
 	Rules{
 		"root": {
-			Include("string"),
-			Include("punctuation"),
-			Include("curly"),
-			Include("basic"),
-			Include("whitespace"),
-			{`[0-9]+`, LiteralNumber, nil},
-		},
-		"basic": {
-			{Words(`\b`, `\b`, `true`, `false`), KeywordType, nil},
-			{`\s*/\*`, CommentMultiline, Push("comment")},
-			{`\s*#.*\n`, CommentSingle, nil},
-			{`(.*?)(\s*)(=)`, ByGroups(NameAttribute, Text, Operator), nil},
-			{Words(`\b`, `\b`, `variable`, `resource`, `provider`, `provisioner`, `module`), KeywordReserved, Push("function")},
-			{Words(`\b`, `\b`, `ingress`, `egress`, `listener`, `default`, `connection`, `alias`), KeywordDeclaration, nil},
-			{`\$\{`, LiteralStringInterpol, Push("var_builtin")},
-		},
-		"function": {
-			{`(\s+)(".*")(\s+)`, ByGroups(Text, LiteralString, Text), nil},
-			Include("punctuation"),
-			Include("curly"),
-		},
-		"var_builtin": {
-			{`\$\{`, LiteralStringInterpol, Push()},
-			{Words(`\b`, `\b`, `concat`, `file`, `join`, `lookup`, `element`), NameBuiltin, nil},
-			Include("string"),
-			Include("punctuation"),
-			{`\s+`, Text, nil},
-			{`\}`, LiteralStringInterpol, Pop(1)},
+			{`[\[\](),.{}]`, Punctuation, nil},
+			{`-?[0-9]+`, LiteralNumber, nil},
+			{`=>`, Punctuation, nil},
+			{Words(``, `\b`, `true`, `false`), KeywordConstant, nil},
+			{`/(?s)\*(((?!\*/).)*)\*/`, CommentMultiline, nil},
+			{`\s*(#|//).*\n`, CommentSingle, nil},
+			{`([a-zA-Z]\w*)(\s*)(=(?!>))`, ByGroups(NameAttribute, Text, Text), nil},
+			{Words(`^\s*`, `\b`, `variable`, `data`, `resource`, `provider`, `provisioner`, `module`, `output`), KeywordReserved, nil},
+			{Words(``, `\b`, `for`, `in`), Keyword, nil},
+			{Words(``, ``, `count`, `data`, `var`, `module`, `each`), NameBuiltin, nil},
+			{Words(``, `\b`, `abs`, `ceil`, `floor`, `log`, `max`, `min`, `parseint`, `pow`, `signum`), NameBuiltin, nil},
+			{Words(``, `\b`, `chomp`, `format`, `formatlist`, `indent`, `join`, `lower`, `regex`, `regexall`, `replace`, `split`, `strrev`, `substr`, `title`, `trim`, `trimprefix`, `trimsuffix`, `trimspace`, `upper`), NameBuiltin, nil},
+			{Words(`[^.]`, `\b`, `chunklist`, `coalesce`, `coalescelist`, `compact`, `concat`, `contains`, `distinct`, `element`, `flatten`, `index`, `keys`, `length`, `list`, `lookup`, `map`, `matchkeys`, `merge`, `range`, `reverse`, `setintersection`, `setproduct`, `setsubtract`, `setunion`, `slice`, `sort`, `transpose`, `values`, `zipmap`), NameBuiltin, nil},
+			{Words(`[^.]`, `\b`, `base64decode`, `base64encode`, `base64gzip`, `csvdecode`, `jsondecode`, `jsonencode`, `urlencode`, `yamldecode`, `yamlencode`), NameBuiltin, nil},
+			{Words(``, `\b`, `abspath`, `dirname`, `pathexpand`, `basename`, `file`, `fileexists`, `fileset`, `filebase64`, `templatefile`), NameBuiltin, nil},
+			{Words(``, `\b`, `formatdate`, `timeadd`, `timestamp`), NameBuiltin, nil},
+			{Words(``, `\b`, `base64sha256`, `base64sha512`, `bcrypt`, `filebase64sha256`, `filebase64sha512`, `filemd5`, `filesha1`, `filesha256`, `filesha512`, `md5`, `rsadecrypt`, `sha1`, `sha256`, `sha512`, `uuid`, `uuidv5`), NameBuiltin, nil},
+			{Words(``, `\b`, `cidrhost`, `cidrnetmask`, `cidrsubnet`), NameBuiltin, nil},
+			{Words(``, `\b`, `can`, `tobool`, `tolist`, `tomap`, `tonumber`, `toset`, `tostring`, `try`), NameBuiltin, nil},
+			{`=(?!>)|\+|-|\*|\/|:|!|%|>|<(?!<)|>=|<=|==|!=|&&|\||\?`, Operator, nil},
+			{`\n|\s+|\\\n`, Text, nil},
+			{`[a-zA-Z]\w*`, NameOther, nil},
+			{`"`, LiteralStringDouble, Push("string")},
+			{`(?s)(<<-?)(\w+)(\n\s*(?:(?!\2).)*\s*\n\s*)(\2)`, ByGroups(Operator, Operator, String, Operator), nil},
+		},
+		"declaration": {
+			{`(\s*)("(?:\\\\|\\"|[^"])*")(\s*)`, ByGroups(Text, NameVariable, Text), nil},
+			{`\{`, Punctuation, Pop(1)},
 		},
 		"string": {
-			{`(".*")`, ByGroups(LiteralStringDouble), nil},
-		},
-		"punctuation": {
-			{`[\[\](),.]`, Punctuation, nil},
-		},
-		"curly": {
-			{`\{`, TextPunctuation, nil},
-			{`\}`, TextPunctuation, nil},
-		},
-		"comment": {
-			{`[^*/]`, CommentMultiline, nil},
-			{`/\*`, CommentMultiline, Push()},
-			{`\*/`, CommentMultiline, Pop(1)},
-			{`[*/]`, CommentMultiline, nil},
-		},
-		"whitespace": {
-			{`\n`, Text, nil},
-			{`\s+`, Text, nil},
-			{`\\\n`, Text, nil},
+			{`"`, LiteralStringDouble, Pop(1)},
+			{`\\\\`, LiteralStringDouble, nil},
+			{`\\\\"`, LiteralStringDouble, nil},
+			{`\$\{`, LiteralStringInterpol, Push("interp-inside")},
+			{`\$`, LiteralStringDouble, nil},
+			{`[^"\\\\$]+`, LiteralStringDouble, nil},
+		},
+		"interp-inside": {
+			{`\}`, LiteralStringInterpol, Pop(1)},
+			Include("root"),
 		},
 	},
 ))


Test patch:
diff --git a/lexers/testdata/hlb.actual b/lexers/testdata/hlb.actual
new file mode 100644
index 0000000..5def702
--- /dev/null
+++ b/lexers/testdata/hlb.actual
@@ -0,0 +1,29 @@
+# Run `npm test` for the node project `left-pad`.
+fs default() {
+	npmRun leftPad fs { nodeModules leftPad; } "test"
+}
+
+fs leftPad() {
+	git "https://github.com/left-pad/left-pad.git" "master"
+}
+
+fs npmInstall(fs src) {
+	image "node:alpine" with option {
+		resolve
+	}
+	run "npm install" with option {
+		dir "/src"
+		mount src "/src"
+		# Name the mounted filesystem as nodeModules
+		mount fs { scratch; } "/src/node_modules" as nodeModules
+	}
+}
+
+fs npmRun(fs src, fs nodeModules, string script) {
+	image "node:alpine"
+	run string { format "npm run %s" script; } with option {
+		dir "/src"
+		mount src "/src"
+		mount nodeModules "/src/node_modules"
+	}
+}
diff --git a/lexers/testdata/hlb.expected b/lexers/testdata/hlb.expected
new file mode 100644
index 0000000..6bbb6f7
--- /dev/null
+++ b/lexers/testdata/hlb.expected
@@ -0,0 +1,148 @@
+[
+  {"type":"CommentSingle","value":"# Run `npm test` for the node project `left-pad`."},
+  {"type":"Text","value":"\n"},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"NameFunction","value":"default"},
+  {"type":"Punctuation","value":"()"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n\t"},
+  {"type":"NameOther","value":"npmRun"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"leftPad"},
+  {"type":"Text","value":" "},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"nodeModules"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"leftPad"},
+  {"type":"Text","value":"; "},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralString","value":"\"test\""},
+  {"type":"Text","value":"\n"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n\n"},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"NameFunction","value":"leftPad"},
+  {"type":"Punctuation","value":"()"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n\tgit "},
+  {"type":"LiteralString","value":"\"https://github.com/left-pad/left-pad.git\""},
+  {"type":"Text","value":" "},
+  {"type":"LiteralString","value":"\"master\""},
+  {"type":"Text","value":"\n"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n\n"},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"NameFunction","value":"npmInstall"},
+  {"type":"Punctuation","value":"("},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"src"},
+  {"type":"Punctuation","value":")"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n\timage "},
+  {"type":"LiteralString","value":"\"node:alpine\""},
+  {"type":"Text","value":" "},
+  {"type":"KeywordReserved","value":"with"},
+  {"type":"Text","value":" "},
+  {"type":"KeywordType","value":"option"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n\t\tresolve\n\t"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n\trun "},
+  {"type":"LiteralString","value":"\"npm install\""},
+  {"type":"Text","value":" "},
+  {"type":"KeywordReserved","value":"with"},
+  {"type":"Text","value":" "},
+  {"type":"KeywordType","value":"option"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n\t\tdir "},
+  {"type":"LiteralString","value":"\"/src\""},
+  {"type":"Text","value":"\n\t\tmount "},
+  {"type":"NameOther","value":"src"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralString","value":"\"/src\""},
+  {"type":"Text","value":"\n\t\t"},
+  {"type":"CommentSingle","value":"# Name the mounted filesystem as nodeModules"},
+  {"type":"Text","value":"\n\t\tmount "},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":" scratch; "},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralString","value":"\"/src/node_modules\""},
+  {"type":"Text","value":" "},
+  {"type":"KeywordReserved","value":"as"},
+  {"type":"Text","value":" "},
+  {"type":"NameFunction","value":"nodeModules"},
+  {"type":"Text","value":"\n\t"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n\n"},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"NameFunction","value":"npmRun"},
+  {"type":"Punctuation","value":"("},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"src"},
+  {"type":"Text","value":", "},
+  {"type":"KeywordType","value":"fs"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"nodeModules"},
+  {"type":"Text","value":", "},
+  {"type":"KeywordType","value":"string"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"script"},
+  {"type":"Punctuation","value":")"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n\timage "},
+  {"type":"LiteralString","value":"\"node:alpine\""},
+  {"type":"Text","value":"\n\trun "},
+  {"type":"KeywordType","value":"string"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"format"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralString","value":"\"npm run %s\""},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"script"},
+  {"type":"Text","value":"; "},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":" "},
+  {"type":"KeywordReserved","value":"with"},
+  {"type":"Text","value":" "},
+  {"type":"KeywordType","value":"option"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n\t\tdir "},
+  {"type":"LiteralString","value":"\"/src\""},
+  {"type":"Text","value":"\n\t\tmount "},
+  {"type":"NameOther","value":"src"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralString","value":"\"/src\""},
+  {"type":"Text","value":"\n\t\tmount "},
+  {"type":"NameOther","value":"nodeModules"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralString","value":"\"/src/node_modules\""},
+  {"type":"Text","value":"\n\t"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n"}
+]
diff --git a/lexers/testdata/sas.actual b/lexers/testdata/sas.actual
new file mode 100644
index 0000000..9cc5e77
--- /dev/null
+++ b/lexers/testdata/sas.actual
@@ -0,0 +1,13 @@
+ods listing close;
+proc reg data=work.funda edf outest=work.params;
+  /* industry-year regression */
+  by fyear sic2;
+  /* id is necessary for later matching Jackknifed coefficients to firm-year */
+  id key;
+  /* Jones Model */
+  Jones: model tac = inv_at_l drev ppe / noint influence i;
+  /* Kothari Model with ROA */
+  Kothari: model tac = inv_at_l drevadj ppe roa / noint influence i;
+  ods output OutputStatistics=work.outstats InvXPX=work.xpxinv;
+run;
+ods listing;
\ No newline at end of file
diff --git a/lexers/testdata/sas.expected b/lexers/testdata/sas.expected
new file mode 100644
index 0000000..481e572
--- /dev/null
+++ b/lexers/testdata/sas.expected
@@ -0,0 +1,29 @@
+[
+  {"type":"Text","value":"ods listing close"},
+  {"type":"KeywordReserved","value":";\nproc reg "},
+  {"type":"Text","value":"data=work"},
+  {"type":"LiteralNumber","value":"."},
+  {"type":"Text","value":"funda edf outest=work"},
+  {"type":"LiteralNumber","value":"."},
+  {"type":"Text","value":"params;\n  "},
+  {"type":"Comment","value":"/* industry-year regression */"},
+  {"type":"Text","value":"\n  "},
+  {"type":"Keyword","value":"by"},
+  {"type":"Text","value":" fyear sic2;\n  "},
+  {"type":"Comment","value":"/* id is necessary for later matching Jackknifed coefficients to firm-year */"},
+  {"type":"Text","value":"\n  id "},
+  {"type":"Keyword","value":"key"},
+  {"type":"Text","value":";\n  "},
+  {"type":"Comment","value":"/* Jones Model */"},
+  {"type":"Text","value":"\n  Jones: model tac = inv_at_l drev ppe / noint influence i;\n  "},
+  {"type":"Comment","value":"/* Kothari Model with ROA */"},
+  {"type":"Text","value":"\n  Kothari: model tac = inv_at_l drevadj ppe roa / noint influence i;\n  ods "},
+  {"type":"Keyword","value":"output"},
+  {"type":"Text","value":" OutputStatistics=work"},
+  {"type":"LiteralNumber","value":"."},
+  {"type":"Text","value":"outstats InvXPX=work"},
+  {"type":"LiteralNumber","value":"."},
+  {"type":"Text","value":"xpxinv"},
+  {"type":"KeywordReserved","value":";\nrun;"},
+  {"type":"Text","value":"\nods listing;"}
+]
diff --git a/lexers/testdata/terraform.actual b/lexers/testdata/terraform.actual
new file mode 100644
index 0000000..1eb6a27
--- /dev/null
+++ b/lexers/testdata/terraform.actual
@@ -0,0 +1,39 @@
+variable "some_var" {
+    default = 12
+}
+
+resource "something" "nice" {
+  name        = "hello"
+  value       = var.some_var
+  x           = var.some_var > 5 ? var.some_var : 10
+
+  cidr_blocks = [
+    for num in [1,2,3]:
+    cidrhost("10.0.0.0/24", num)
+  ]
+  str = <<-EOT
+    hello
+    world
+  EOT
+
+  /*
+    Multiline comment
+  */
+  # Single comment
+
+  dynamic "setting" {
+    for_each = var.settings
+    content {
+      namespace = setting.value["namespace"]
+      name = setting.value["name"]
+      value = setting.value["value"]
+    }
+  }
+}
+
+
+resource "other" "resource" {
+  count = 3
+  name = "resource${count.index+1}"
+}
+
diff --git a/lexers/testdata/terraform.expected b/lexers/testdata/terraform.expected
new file mode 100644
index 0000000..dcf983b
--- /dev/null
+++ b/lexers/testdata/terraform.expected
@@ -0,0 +1,165 @@
+[
+  {"type":"KeywordReserved","value":"variable"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralStringDouble","value":"\"some_var\""},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n    "},
+  {"type":"NameAttribute","value":"default"},
+  {"type":"Text","value":" = "},
+  {"type":"LiteralNumber","value":"12"},
+  {"type":"Text","value":"\n"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n"},
+  {"type":"KeywordReserved","value":"\nresource"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralStringDouble","value":"\"something\""},
+  {"type":"Text","value":" "},
+  {"type":"LiteralStringDouble","value":"\"nice\""},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n  "},
+  {"type":"NameAttribute","value":"name"},
+  {"type":"Text","value":"        = "},
+  {"type":"LiteralStringDouble","value":"\"hello\""},
+  {"type":"Text","value":"\n  "},
+  {"type":"NameAttribute","value":"value"},
+  {"type":"Text","value":"       = "},
+  {"type":"NameBuiltin","value":"var"},
+  {"type":"Punctuation","value":"."},
+  {"type":"NameOther","value":"some_var"},
+  {"type":"Text","value":"\n  "},
+  {"type":"NameAttribute","value":"x"},
+  {"type":"Text","value":"           = "},
+  {"type":"NameBuiltin","value":"var"},
+  {"type":"Punctuation","value":"."},
+  {"type":"NameOther","value":"some_var"},
+  {"type":"Text","value":" "},
+  {"type":"Operator","value":"\u003e"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralNumber","value":"5"},
+  {"type":"Text","value":" "},
+  {"type":"Operator","value":"?"},
+  {"type":"Text","value":" "},
+  {"type":"NameBuiltin","value":"var"},
+  {"type":"Punctuation","value":"."},
+  {"type":"NameOther","value":"some_var"},
+  {"type":"Text","value":" "},
+  {"type":"Operator","value":":"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralNumber","value":"10"},
+  {"type":"Text","value":"\n\n  "},
+  {"type":"NameAttribute","value":"cidr_blocks"},
+  {"type":"Text","value":" = "},
+  {"type":"Punctuation","value":"["},
+  {"type":"Text","value":"\n    "},
+  {"type":"Keyword","value":"for"},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"num"},
+  {"type":"Text","value":" "},
+  {"type":"Keyword","value":"in"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"["},
+  {"type":"LiteralNumber","value":"1"},
+  {"type":"Punctuation","value":","},
+  {"type":"LiteralNumber","value":"2"},
+  {"type":"Punctuation","value":","},
+  {"type":"LiteralNumber","value":"3"},
+  {"type":"Punctuation","value":"]"},
+  {"type":"Operator","value":":"},
+  {"type":"Text","value":"\n    "},
+  {"type":"NameBuiltin","value":"cidrhost"},
+  {"type":"Punctuation","value":"("},
+  {"type":"LiteralStringDouble","value":"\"10.0.0.0/24\""},
+  {"type":"Punctuation","value":","},
+  {"type":"Text","value":" "},
+  {"type":"NameOther","value":"num"},
+  {"type":"Punctuation","value":")"},
+  {"type":"Text","value":"\n  "},
+  {"type":"Punctuation","value":"]"},
+  {"type":"Text","value":"\n  "},
+  {"type":"NameAttribute","value":"str"},
+  {"type":"Text","value":" = "},
+  {"type":"Operator","value":"\u003c\u003c-EOT"},
+  {"type":"LiteralString","value":"\n    hello\n    world\n  "},
+  {"type":"Operator","value":"EOT"},
+  {"type":"Text","value":"\n\n  "},
+  {"type":"CommentMultiline","value":"/*\n    Multiline comment\n  */"},
+  {"type":"CommentSingle","value":"\n  # Single comment\n"},
+  {"type":"Text","value":"\n  "},
+  {"type":"NameOther","value":"dynamic"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralStringDouble","value":"\"setting\""},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n    "},
+  {"type":"NameAttribute","value":"for_each"},
+  {"type":"Text","value":" = "},
+  {"type":"NameBuiltin","value":"var"},
+  {"type":"Punctuation","value":"."},
+  {"type":"NameOther","value":"settings"},
+  {"type":"Text","value":"\n    "},
+  {"type":"NameOther","value":"content"},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n      "},
+  {"type":"NameAttribute","value":"namespace"},
+  {"type":"Text","value":" = "},
+  {"type":"NameOther","value":"setting"},
+  {"type":"Punctuation","value":"."},
+  {"type":"NameOther","value":"value"},
+  {"type":"Punctuation","value":"["},
+  {"type":"LiteralStringDouble","value":"\"namespace\""},
+  {"type":"Punctuation","value":"]"},
+  {"type":"Text","value":"\n      "},
+  {"type":"NameAttribute","value":"name"},
+  {"type":"Text","value":" = "},
+  {"type":"NameOther","value":"setting"},
+  {"type":"Punctuation","value":"."},
+  {"type":"NameOther","value":"value"},
+  {"type":"Punctuation","value":"["},
+  {"type":"LiteralStringDouble","value":"\"name\""},
+  {"type":"Punctuation","value":"]"},
+  {"type":"Text","value":"\n      "},
+  {"type":"NameAttribute","value":"value"},
+  {"type":"Text","value":" = "},
+  {"type":"NameOther","value":"setting"},
+  {"type":"Punctuation","value":"."},
+  {"type":"NameOther","value":"value"},
+  {"type":"Punctuation","value":"["},
+  {"type":"LiteralStringDouble","value":"\"value\""},
+  {"type":"Punctuation","value":"]"},
+  {"type":"Text","value":"\n    "},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n  "},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n"},
+  {"type":"KeywordReserved","value":"\n\nresource"},
+  {"type":"Text","value":" "},
+  {"type":"LiteralStringDouble","value":"\"other\""},
+  {"type":"Text","value":" "},
+  {"type":"LiteralStringDouble","value":"\"resource\""},
+  {"type":"Text","value":" "},
+  {"type":"Punctuation","value":"{"},
+  {"type":"Text","value":"\n  "},
+  {"type":"NameAttribute","value":"count"},
+  {"type":"Text","value":" = "},
+  {"type":"LiteralNumber","value":"3"},
+  {"type":"Text","value":"\n  "},
+  {"type":"NameAttribute","value":"name"},
+  {"type":"Text","value":" = "},
+  {"type":"LiteralStringDouble","value":"\"resource"},
+  {"type":"LiteralStringInterpol","value":"${"},
+  {"type":"NameBuiltin","value":"count"},
+  {"type":"Punctuation","value":"."},
+  {"type":"NameOther","value":"index"},
+  {"type":"Operator","value":"+"},
+  {"type":"LiteralNumber","value":"1"},
+  {"type":"LiteralStringInterpol","value":"}"},
+  {"type":"LiteralStringDouble","value":"\""},
+  {"type":"Text","value":"\n"},
+  {"type":"Punctuation","value":"}"},
+  {"type":"Text","value":"\n\n"}
+]