Skip to main content

2 posts tagged with "emacs"

View All Tags

Terraform Mode v2.0 - Part 2

· 4 min read

This is continuing from the last Terraform Mode v2.0 post.

Fixing a Previous Mistake

So using terraform-mode--variable is a bad choice since Terraform has variables. We're going to change this to terraform-mode--assignment to accurately capture what it's highlighting and let us be able to highlight variables with an appropriately named function.

(defconst terraform-mode--assignment
(rx line-start (zero-or-more space) (group (one-or-more word)) (zero-or-more space) "="))

Depth Based Highlighting

Next we want to add some more highlighting for certain built in words. Words like cloud and required_providers only need highlighting when they're at depth 1. We can use syntax-ppss to understand information about where the curor is. It tells us information like brace depth, if it's in a string or comment, etc. The zeroeth element of its return tells us the brace depth.

(defun terraform-mode--match-builtin-at-depth (regexp depth limit)
(and (re-search-forward regexp limit t)
(= (nth 0 (syntax-ppss (match-beginning 0))) depth)))

We then use this to apply syntax highlighting by passing a wrapped version of this function to our font-lock-keywords since it can take regexps or function to use for matching.

(defconst terraform-mode--block-builtins-depth-0
(rx line-start (zero-or-more space) (group "terraform")))

(defun terraform-mode--match-depth-0-builtin (limit)
(terraform-mode--match-builtin-at-depth terraform-mode--block-builtins-depth-0 0 limit))

(defconst terraform-mode--block-builtins-depth-1
(rx line-start (zero-or-more space) (group (or "required_providers" "cloud"))))

(defun terraform-mode--match-depth-1-builtin (limit)
(terraform-mode--match-builtin-at-depth terraform-mode--block-builtins-depth-1 1 limit))

(defconst terraform-mode--block-builtins-depth-2
(rx line-start (zero-or-more space) (group "workspaces")))

(defun terraform-mode--match-depth-2-builtin (limit)
(terraform-mode--match-builtin-at-depth terraform-mode--block-builtins-depth-2 2 limit))

(defconst terraform-mode--font-lock-keywords
`((terraform-mode--match-depth-0-builtin 1 font-lock-builtin-face)
(terraform-mode--match-depth-1-builtin 1 font-lock-builtin-face)
(terraform-mode--match-depth-2-builtin 1 font-lock-builtin-face)
; ...
(,terraform-mode--assignment 1 font-lock-variable-name-face)))

Now we have depth aware syntax highlighting so we don't inadveratntly highlight text.

Depth aware highlighting

Using Text Properties to Highlight

Now we want to highlight the providers inside required_providers. We could use depth to check for this, but that is error prone cuz of nesting in other situations. Instead we'll need to use custom text properties to be able to mark the region and highlight based off of that.

(defconst terraform-mode--required-providers-block
(rx line-start (zero-or-more space) "required_providers" (zero-or-more space) "{"))

(defun terraform-mode--propertize-required-providers (start end)
"Mark contents of required_providers blocks with a text property.
Only marks the portion of each block that overlaps with [START, END)."
(remove-text-properties start end '(terraform-mode-required-providers nil))
(save-excursion
(goto-char (point-min))
(while (re-search-forward terraform-mode--required-providers-block nil t)
(let ((content-start (point)))
(save-excursion
(backward-char)
(condition-case nil
(progn
(forward-sexp)
(let ((content-end (1- (point))))
(when (and (> content-end content-start)
(> content-end start)
(< content-start end))
(put-text-property
(max content-start start)
(min content-end end)
'terraform-mode-required-providers t))))
(error nil)))))))

(defun terraform-mode--syntax-propertize (start end)
"Propertize region from START to END."
(terraform-mode--propertize-required-providers start end))

(define-derived-mode terraform-mode prog-mode "Terraform"
"Major mode for editing Terraform files."
:syntax-table terraform-mode-syntax-table
;; ...
(setq-local syntax-propertize-function #'terraform-mode--syntax-propertize))

We use terraform-mode--propertize-required-providers to clear the previous region to prevent stale font properties. We go back to the start of the file and search for required_providers. If we find it we identify its entire block and we add the property to identify it as a providers section.

Now that we can identify when we're in a required_providers we can highlight providers properly using a function to check if what we want to highlight is propertized.

(defconst terraform-mode--provider
(rx line-start (zero-or-more space) (group (one-or-more word)) (one-or-more space) "{"))

(defun terraform-mode--match-provider (limit)
"Match provider names inside required_providers blocks up to LIMIT."
(catch 'found
(while (re-search-forward terraform-mode--provider limit t)
(when (get-text-property (match-beginning 0) 'terraform-mode-required-providers)
(throw 'found t)))))

(defconst terraform-mode--font-lock-keywords
; ...
(terraform-mode--match-provider 1 font-lock-type-face))

Providers highlighted

Overriding Syntactic Highlighting

Emacs breaks highlighting into two phases syntactic highlighting and fontification. Syntactic highlighting identifies the syntactic structure of the code for navigation and also highlights strings and comments. Then comes fontification which enables us to apply custom highlighting. However, in Terraform the code uses string markers for identifying block types and resource types/names. Fontification provides a way for us to force syntax highlighting by passing t in font-lock-keywords. This creates an issue though because it will always override syntactic highlighting and we end up with highlighting in incorrect places like comments. We can use syntax-propertize again to override what a string is in certain scenarios.

(eval-and-compile
(defconst terraform-mode--block-builtins-with-type
(rx line-start (zero-or-more space)
(group (or "backend" "provider_meta"))
(one-or-more space)
(group (group "\"") (one-or-more (not (any "\""))) (group "\""))
(zero-or-more space) "{")))

(defun terraform-mode--propertize-builtins-with-type (start end)
"Mark type argument quotes in builtin-with-type blocks as punctuation syntax.
This prevents them from receiving `font-lock-string-face' during syntactic
fontification, allowing `font-lock-type-face' to be applied without override."
(goto-char start)
(funcall
(syntax-propertize-rules
(terraform-mode--block-builtins-with-type
(3 ".")
(4 ".")))
start end))

(defun terraform-mode--syntax-propertize (start end)
"Propertize region from START to END."
(terraform-mode--propertize-builtins-with-type start end)
(terraform-mode--propertize-required-providers start end))

(defconst terraform-mode--font-lock-keywords
`(; ...
(,terraform-mode--block-builtins-with-type
(1 font-lock-builtin-face)
(2 font-lock-type-face))))

When we match backend "type" { we mark those " as punctuation instead of marking it as a string. (Using a different theme in this screenshot to really emphasize the differnet in string vs type.

Changing string to type

Terraform Mode v2.0

· 4 min read

A goal of mine has been rewriting terraform-mode. The current implemenation has some minor bugs that are annoying. It depends on hcl-mode which introduces complexity in being able to rewrite it. Another goal of mine is to have it extensively tested to prevent regressions.

The end result of this exercise will be a terraform-mode version 2.0.

Identifying Comments

We start off by identfying our comments. We use the b style comment for single line comments and we use a style comments for multiline.

(defvar terraform-mode-syntax-table
(let ((table (make-syntax-table)))
;; # and // are line comments (style b); /* */ are block comments (style a)
(modify-syntax-entry ?# "< b" table)
(modify-syntax-entry ?\n "> b" table)
(modify-syntax-entry ?/ ". 124b" table)
(modify-syntax-entry ?* ". 23" table)
...
table)
"Syntax table for `terraform-mode'.")

For the # style comments < b means this is a single characeter comment starter for b type comments.

For \n the > b means this is what terminates a single line comment.

For / the . 124b compacts a lot of information.

  • The . means it's a punctuation character class.
  • The 1 means it's the start of a two character start comment sequence.
  • The 2 means its the second character of a two character start comment sequence.
  • The 4 means its the last character of a two character end comment sequence.
  • The b applies to the second time / shows up making it a b style comment.

For * the . 23 finsihes the multiline comment support.

  • The . serves the same purpose as before.
  • The 2 means the same thing as well.
  • The 3 means it's the first character in a 2 character end sequence.

Comments highlighted only

Brackets

(defvar terraform-mode-syntax-table
(let ((table (make-syntax-table)))
...
(modify-syntax-entry ?{ "(}" table)
(modify-syntax-entry ?} "){" table)
(modify-syntax-entry ?\[ "(]" table)
(modify-syntax-entry ?\] ")[" table)
(modify-syntax-entry ?\( "()" table)
(modify-syntax-entry ?\) ")(" table)
table)
"Syntax table for `terraform-mode'.")

For the open curly brace ?{ "(}" says { is an open brace and its matching closing brace is }.

For the close curly brace ?} "){" says } is a close brace and its matching open brace is {.

I won't explain the rest of the table modifications beacuse it's the same thing over and over for the difference braces.

Creating the mode

In order to create the mode is derived from prog-mode.

;;;###autoload
(define-derived-mode terraform-mode prog-mode "Terraform"
"Major mode for editing Terraform files."
:syntax-table terraform-mode-syntax-table
(setq-local comment-start "#")
(setq-local comment-end "")
(setq-local font-lock-defaults '(nil nil nil)))

;;;###autoload
(add-to-list 'auto-mode-alist '("\\.tf\\'" . terraform-mode))
;;;###autoload
(add-to-list 'auto-mode-alist '("\\.tfvars\\'" . terraform-mode))

(provide 'terraform-mode)

At this point when we open a Terraform file it no highlighting works outside of comments.

First Keyword

Now we want to introduce syntax highlighting for the terraform builtin word. In order to do this we craft a regexp using the rx macro:

(defconst terraform-mode--block-builtins-no-type-or-name
(rx line-start (zero-or-more space) (group "terraform")))

Then we configure the table we'll pass to font-lock-defaults:

(defconst terraform-mode--font-lock-keywords
`((,terraform-mode--block-builtins-no-type-or-name 1 font-lock-builtin-face)))

The , in ,terraform-mode--block-builtins-no-type-or-name exapnds the macro within the quoted list. The 1 says to apply face font-lock-builtin-face to the first capture group.

Then we update our font-lock-defaults from above to use our table:

(setq-local font-lock-defaults '(terraform-mode--font-lock-keywords nil nil))

Terraform as a builtin

Handling Variables

When writing terraform we use _ a lot as part of variable names. We need to treat this as part of words instead of as punction. This is achieved by:

(modify-syntax-entry ?_ "w" table)

Then we can write our regexp to capture assignment statements:

(defconst terraform-mode--variable
(rx line-start (zero-or-more space) (group (one-or-more word)) (zero-or-more space) "="))

Then we add this to our font-lock-keywords table and assignment statements are properly highlighted.

Variable highlights

We'll continue iterating on this in the next post.