Support Terraform Variable Definitions as separate language (#7524)

Daniel Banck created

With https://github.com/zed-industries/zed/pull/6882 basic syntax
highlighting support for Terraform has arrived in Zed. To fully support
all features of the language server (when it lands), it's necessary to
handle `*.tfvars` slightly differently.

TL;DR: [terraform-ls](https://github.com/hashicorp/terraform-ls) expects
`terraform` as language id for `*.tf` files and `terraform-vars` as
language id for `*.tfvars` files because the allowed configuration
inside the files is different. Duplicating the Terraform language
configuration was the only way I could see to achieve this.

---

In the
[LSP](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem),
text documents have a language identifier to identify a document on the
server side to avoid reinterpreting the file extension.

The Terraform language server currently uses two different language
identifiers:
* `terraform` - for `*.tf` files
* `terraform-vars` - for `*.tfvars` files

Both file types contain HCL and can be highlighted using the same
grammar and tree-sitter configuration files. The difference in the file
content is that `*.tfvars` files only allow top-level attributes and no
blocks. [_So you could argue that `*.tfvars` can use a stripped down
version of the grammar_]. To set the right context (which affects
completion, hover, validation...) for each file, we need to send a
different language id.

The only way I could see to achieve this with the current architecture
was to copy the Terraform language configuration with a different `name`
and different `path_suffixes`. Everything else is the same.

A Terraform LSP adapter implementation would then map the language
configurations to their specific language ids:

```rust
fn language_ids(&self) -> HashMap<String, String> {
    HashMap::from_iter([
        ("Terraform".into(), "terraform".into()),
        ("Terraform Vars".into(), "terraform-vars".into()),
    ])
}
```

I think it might be helpful in the future to have another way to map
file extensions to specific language ids without having to create a new
language configuration.

### UX Before

![CleanShot 2024-02-07 at 23 00
56@2x](https://github.com/zed-industries/zed/assets/45985/2c40f477-99a2-4dc1-86de-221acccfcedb)

### UX After

![CleanShot 2024-02-07 at 22 58
40@2x](https://github.com/zed-industries/zed/assets/45985/704c9cca-ae14-413a-be1f-d2439ae1ae22)

Release Notes:

- N/A

---

* Part of https://github.com/zed-industries/zed/issues/5098

Change summary

crates/zed/src/languages.rs                            |   1 
crates/zed/src/languages/terraform-vars/config.toml    |  14 +
crates/zed/src/languages/terraform-vars/highlights.scm | 159 ++++++++++++
crates/zed/src/languages/terraform-vars/indents.scm    |  14 +
crates/zed/src/languages/terraform-vars/injections.scm |   9 
crates/zed/src/languages/terraform/config.toml         |   2 
6 files changed, 198 insertions(+), 1 deletion(-)

Detailed changes

crates/zed/src/languages.rs 🔗

@@ -287,6 +287,7 @@ pub fn init(
     language("uiua", vec![Arc::new(uiua::UiuaLanguageServer {})]);
     language("proto", vec![]);
     language("terraform", vec![]);
+    language("terraform-vars", vec![]);
     language("hcl", vec![]);
 }
 

crates/zed/src/languages/terraform-vars/config.toml 🔗

@@ -0,0 +1,14 @@
+name = "Terraform Vars"
+grammar = "hcl"
+path_suffixes = ["tfvars"]
+line_comments = ["# ", "// "]
+block_comment = ["/*", "*/"]
+autoclose_before = ",}])"
+brackets = [
+    { start = "{", end = "}", close = true, newline = true },
+    { start = "[", end = "]", close = true, newline = true },
+    { start = "(", end = ")", close = true, newline = true },
+    { start = "\"", end = "\"", close = true, newline = false, not_in = ["comment", "string"] },
+    { start = "'", end = "'", close = true, newline = false, not_in = ["comment", "string"] },
+    { start = "/*", end = " */", close = true, newline = false, not_in = ["comment", "string"] },
+]

crates/zed/src/languages/terraform-vars/highlights.scm 🔗

@@ -0,0 +1,159 @@
+; https://github.com/nvim-treesitter/nvim-treesitter/blob/cb79d2446196d25607eb1d982c96939abdf67b8e/queries/hcl/highlights.scm
+; highlights.scm
+[
+  "!"
+  "\*"
+  "/"
+  "%"
+  "\+"
+  "-"
+  ">"
+  ">="
+  "<"
+  "<="
+  "=="
+  "!="
+  "&&"
+  "||"
+] @operator
+
+[
+  "{"
+  "}"
+  "["
+  "]"
+  "("
+  ")"
+] @punctuation.bracket
+
+[
+  "."
+  ".*"
+  ","
+  "[*]"
+] @punctuation.delimiter
+
+[
+  (ellipsis)
+  "\?"
+  "=>"
+] @punctuation.special
+
+[
+  ":"
+  "="
+] @punctuation
+
+[
+  "for"
+  "endfor"
+  "in"
+  "if"
+  "else"
+  "endif"
+] @keyword
+
+[
+  (quoted_template_start) ; "
+  (quoted_template_end) ; "
+  (template_literal) ; non-interpolation/directive content
+] @string
+
+[
+  (heredoc_identifier) ; END
+  (heredoc_start) ; << or <<-
+] @punctuation.delimiter
+
+[
+  (template_interpolation_start) ; ${
+  (template_interpolation_end) ; }
+  (template_directive_start) ; %{
+  (template_directive_end) ; }
+  (strip_marker) ; ~
+] @punctuation.special
+
+(numeric_lit) @number
+
+(bool_lit) @boolean
+
+(null_lit) @constant
+
+(comment) @comment
+
+(identifier) @variable
+
+(body
+  (block
+    (identifier) @keyword))
+
+(body
+  (block
+    (body
+      (block
+        (identifier) @type))))
+
+(function_call
+  (identifier) @function)
+
+(attribute
+  (identifier) @variable)
+
+; { key: val }
+;
+; highlight identifier keys as though they were block attributes
+(object_elem
+  key:
+    (expression
+      (variable_expr
+        (identifier) @variable)))
+
+; var.foo, data.bar
+;
+; first element in get_attr is a variable.builtin or a reference to a variable.builtin
+(expression
+  (variable_expr
+    (identifier) @variable)
+  (get_attr
+    (identifier) @variable))
+
+; https://github.com/nvim-treesitter/nvim-treesitter/blob/cb79d2446196d25607eb1d982c96939abdf67b8e/queries/terraform/highlights.scm
+; Terraform specific references
+;
+;
+; local/module/data/var/output
+(expression
+  (variable_expr
+    (identifier) @variable
+    (#any-of? @variable "data" "var" "local" "module" "output"))
+  (get_attr
+    (identifier) @variable))
+
+; path.root/cwd/module
+(expression
+  (variable_expr
+    (identifier) @type
+    (#eq? @type "path"))
+  (get_attr
+    (identifier) @variable
+    (#any-of? @variable "root" "cwd" "module")))
+
+; terraform.workspace
+(expression
+  (variable_expr
+    (identifier) @type
+    (#eq? @type "terraform"))
+  (get_attr
+    (identifier) @variable
+    (#any-of? @variable "workspace")))
+
+; Terraform specific keywords
+; FIXME: ideally only for identifiers under a `variable` block to minimize false positives
+((identifier) @type
+  (#any-of? @type "bool" "string" "number" "object" "tuple" "list" "map" "set" "any"))
+
+(object_elem
+  val:
+    (expression
+      (variable_expr
+        (identifier) @type
+        (#any-of? @type "bool" "string" "number" "object" "tuple" "list" "map" "set" "any"))))

crates/zed/src/languages/terraform-vars/indents.scm 🔗

@@ -0,0 +1,14 @@
+; https://github.com/nvim-treesitter/nvim-treesitter/blob/ce4adf11cfe36fc5b0e5bcdce0c7c6e8fbc9798a/queries/hcl/indents.scm
+[
+  (block)
+  (object)
+  (tuple)
+  (function_call)
+] @indent
+
+(_ "[" "]" @end) @indent
+(_ "(" ")" @end) @indent
+(_ "{" "}" @end) @indent
+
+; https://github.com/nvim-treesitter/nvim-treesitter/blob/ce4adf11cfe36fc5b0e5bcdce0c7c6e8fbc9798a/queries/terraform/indents.scm
+; inherits: hcl

crates/zed/src/languages/terraform-vars/injections.scm 🔗

@@ -0,0 +1,9 @@
+; https://github.com/nvim-treesitter/nvim-treesitter/blob/ce4adf11cfe36fc5b0e5bcdce0c7c6e8fbc9798a/queries/hcl/injections.scm
+
+(heredoc_template
+  (template_literal) @content
+  (heredoc_identifier) @language
+  (#downcase! @language))
+
+; https://github.com/nvim-treesitter/nvim-treesitter/blob/ce4adf11cfe36fc5b0e5bcdce0c7c6e8fbc9798a/queries/terraform/injections.scm
+; inherits: hcl