dms+hcl — DMS dialect for HCL / Terraform

Brand: dms+hcl File extension: .dms.hcl Spec version: 0.1 (draft) DMS tier required: 1 (_dms_tier: 1 in front matter) Parent spec: TIER1.md Tier-0 base: SPEC.md

Pre-1.0: breaking changes are still possible. No version-bump rules apply yet.

What this is

dms+hcl is a tier-1 dialect that lets you write HCL-shaped documents (HashiCorp Configuration Language v2 — Terraform, Packer, Vault, Nomad, Consul, Boundary, Waypoint) in DMS. Each HCL block becomes a |<block_kind>(...) decoration; nested blocks become decorated list-items; HCL expressions become @expr "..." decorations carrying the expression source as an opaque string.

What dms+hcl is not. It is not an HCL evaluator. DMS doesn't have expressions, references, or interpolation, and dms+hcl preserves them as opaque strings rather than evaluating them. The runtime layer (a dms+hcl → HCL emitter, or a Terraform- binding tool that consumes a decoded Document_t1) is responsible for any actual interpretation.

Quick comparison

terraform {
  required_version = ">= 1.0"
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_instance" "web" {
  count         = var.instance_count
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
  tags = {
    Name        = "web-${count.index}"
    Environment = "production"
  }
}

resource "aws_security_group" "web" {
  name = "web"
  ingress {
    from_port = 80
    to_port   = 80
    protocol  = "tcp"
  }
  ingress {
    from_port = 443
    to_port   = 443
    protocol  = "tcp"
  }
}

In dms+hcl:

+++
_dms_tier: 1
_dms_imports:
  + dialect: "hcl"
    version: "1.0.0"
+++

+ |terraform(required_version: ">= 1.0")

+ |provider("aws")(region: "us-east-1")

+ |resource("aws_instance", "web")(
    count:         @expr "var.instance_count",
    ami:           "ami-0c55b159cbfafe1f0",
    instance_type: "t2.micro",
    tags: {
      Name:        @expr '"web-${count.index}"',
      Environment: "production",
    },
  )

+ |resource("aws_security_group", "web")(name: "web")
  + |ingress(from_port: 80,  to_port: 80,  protocol: "tcp")
  + |ingress(from_port: 443, to_port: 443, protocol: "tcp")

Realistic example

A small but realistic Terraform module: VPC, subnet, security group with multiple ingress rules, and an EC2 instance. Native HCL first, then the dms+hcl equivalent.

Native HCL / Terraform

terraform {
  required_version = ">= 1.5"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

variable "instance_count" {
  type        = number
  default     = 2
  description = "Number of web instances to launch"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = {
    Name        = "main-vpc"
    Environment = "production"
  }
}

resource "aws_subnet" "public" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "us-east-1a"
  tags = {
    Name = "public-subnet"
  }
}

resource "aws_security_group" "web" {
  name        = "web-sg"
  description = "Allow HTTP and HTTPS inbound"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "HTTPS"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "web-sg"
  }
}

resource "aws_instance" "web" {
  count         = var.instance_count
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.small"
  subnet_id     = aws_subnet.public.id

  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = <<-EOF
    #!/bin/bash
    apt-get update -y
    apt-get install -y nginx
    systemctl enable nginx
  EOF

  tags = {
    Name        = "web-${count.index}"
    Environment = "production"
  }
}

output "instance_ips" {
  value       = aws_instance.web[*].public_ip
  description = "Public IPs of all web instances"
}

dms+hcl equivalent

+++
_dms_tier: 1
_dms_imports:
  + dialect: "hcl"
    version: "1.0.0"
+++

+ |terraform(
    required_version: ">= 1.5",
    required_providers: {
      aws: {
        source:  "hashicorp/aws",
        version: "~> 5.0",
      },
    },
  )

+ |provider("aws")(region: "us-east-1")

+ |variable("instance_count")(
    type:        @expr "number",
    default:     2,
    description: "Number of web instances to launch",
  )

# VPC: cidr_block and boolean attrs are typed — no silent "true"/"false" strings
+ |resource("aws_vpc", "main")(
    cidr_block:           "10.0.0.0/16",
    enable_dns_support:   true,
    enable_dns_hostnames: true,
    tags: {
      Name:        "main-vpc",
      Environment: "production",
    },
  )

# aws_subnet.public references vpc_id via @expr — the opaque source is preserved
+ |resource("aws_subnet", "public")(
    vpc_id:            @expr "aws_vpc.main.id",
    cidr_block:        "10.0.1.0/24",
    availability_zone: "us-east-1a",
    tags: {
      Name: "public-subnet",
    },
  )

# Security group with multiple ingress blocks as nested decorated list-items
+ |resource("aws_security_group", "web")(
    name:        "web-sg",
    description: "Allow HTTP and HTTPS inbound",
    vpc_id:      @expr "aws_vpc.main.id",
    tags: { Name: "web-sg" },
  )
  # Each ingress is a child list-item — grep '|ingress' finds all ingress rules
  + |ingress(
      description: "HTTP",
      from_port:   80,
      to_port:     80,
      protocol:    "tcp",
      cidr_blocks: ["0.0.0.0/0"],
    )
  + |ingress(
      description: "HTTPS",
      from_port:   443,
      to_port:     443,
      protocol:    "tcp",
      cidr_blocks: ["0.0.0.0/0"],
    )
  + |egress(from_port: 0, to_port: 0, protocol: "-1", cidr_blocks: ["0.0.0.0/0"])

+ |resource("aws_instance", "web")(
    count:         @expr "var.instance_count",
    ami:           @expr "data.aws_ami.ubuntu.id",
    instance_type: "t3.small",
    subnet_id:     @expr "aws_subnet.public.id",
    vpc_security_group_ids: [@expr "aws_security_group.web.id"],
    # DMS heredoc with _trim modifier — explicit about trailing-newline handling
    user_data: """EOF _trim("\n", ">")
      #!/bin/bash
      apt-get update -y
      apt-get install -y nginx
      systemctl enable nginx
      EOF,
    tags: {
      Name:        @expr '"web-${count.index}"',
      Environment: "production",
    },
  )

+ |output("instance_ips")(
    value:       @expr "aws_instance.web[*].public_ip",
    description: "Public IPs of all web instances",
  )

What's visible in DMS that's lost or obscured in HCL

Opaque expressions are first-class, not stringly-typed. Every HCL interpolation (aws_vpc.main.id, var.instance_count, "web-${count.index}") is an untyped string in the source. Tools that consume .tf files must parse this string to understand the reference graph. In dms+hcl, these are @expr "..." decorations — a distinct syntactic category. A linter can grep '@expr' to enumerate all expressions; a refactoring tool can distinguish count: 2 (literal integer) from count: @expr "var.instance_count" (expression) without a sub-parser.

Positional labels are typed. |resource("aws_vpc", "main") has two positional string params. The dialect spec validates that the first is type: "string" and required. In native HCL, resource "aws_vpc" "main" is a syntactic form whose label arity is checked by the Terraform CLI, not by the HCL parser — an HCL library reading arbitrary HCL gets untyped label strings.

Mixed positional + named is explicit. |provider("aws")(region: "us-east-1") separates the provider label (positional) from the provider configuration (named). In HCL, both live in the same block body without visual distinction — "aws" is a label, region = "us-east-1" is an attribute, and only the HCL spec tells you which is which.

Dialect canonical spec

+++
_dms_tier: 0
+++

name:             "hcl"
version:          "1.0.0"
version_strategy: "caret"        # default; npm-style ^

families:
  # ── HCL block: resource, variable, provider, etc. ─────────
  + name:           "block"
    default_sigils: ["|"]
    empty_default:  []           # no nested blocks → empty children list
    # No content_slot. HCL has no flow-form children syntax;
    # nested blocks are always indent-block list-items.
    params:
      mode: "positional"
      positional:
        - { name: "label_1", type: "string", required: false }
        - { name: "label_2", type: "string", required: false }
      typed:
        # ── Terraform meta-arguments (work on most block kinds) ─
        count:        { type: "any" }    # number or expr
        for_each:     { type: "any" }    # set/map or expr
        provider:     { type: "string" }
        depends_on:   { type: "list_of any" }
        lifecycle:    { type: "any" }
        provisioner:  { type: "any" }

        # ── Common block-specific attributes ─
        # variable / output blocks
        type:         { type: "any" }    # type expression
        default:      { type: "any" }
        description:  { type: "string" }
        sensitive:    { type: "boolean" }
        nullable:     { type: "boolean" }
        validation:   { type: "any" }
        value:        { type: "any" }    # output blocks

        # module blocks
        source:       { type: "string" }
        version:      { type: "string" }

        # provider blocks
        alias:        { type: "string" }

        # terraform block
        required_version:   { type: "string" }
        required_providers: { type: "any" }
        backend:            { type: "any" }

        # data / resource: most attrs are provider-defined and
        # pass through wildcard. The above list is meta-args;
        # provider attrs land in the wildcard tail.

  # ── HCL expression: var.foo, aws_instance.web.id, ${...} ─
  + name:           "expr"
    default_sigils: ["@"]
    empty_default:  ""
    content_slot:   "value"
    params:
      mode: "wildcard_with_typed"
      typed:
        value: { type: "string", required: true }

The block family is positional mode because HCL block headers carry 0 / 1 / 2 positional string labels. Positional slot 0 is the block's first label (resource type for resource, variable name for variable, etc.); slot 1 is the second label (resource name for resource). Both are optional to support terraform { ... } (zero labels) and provider "aws" { ... } (one label) and resource "aws_instance" "web" { ... } (two labels).

Named attributes — count, ami, name, plus all provider-defined attributes — go in the named-params group after the positional group: |resource("type", "name")(named: attrs). Common HCL meta-arguments are typed; provider-specific attrs pass through wildcard_with_typed's wildcard tail.

The expr family is wildcard_with_typed mode with a single typed key value (the opaque expression string). content_slot: "value" lets you write @expr "var.region" (inline base_value hoisted into value) instead of @expr(value: "var.region").

Content semantics

HCL blocks → |<kind>(labels)(attrs) + child list

HCL block bodies contain two kinds of items:

  1. Attributes (key = value) — single named values.
  2. Nested blocks (KIND [LABELS] { BODY }) — typed sub-structures.

dms+hcl maps these as:

The named-params group typically uses the multi-line flow form (SPEC.md §"Flow forms — canonical multi-line layout") for readability when the block has many attributes. dms+hcl's encoder breaks to multi-line above the configured line-width threshold; users may also force multi-line by emitting trailing commas at decode time.

HCL expressions → @expr "..."

HCL expressions are preserved as opaque strings. The dialect does not parse, validate, or evaluate them — it round-trips the source.

count:        @expr "var.instance_count"
ami:          @expr "data.aws_ami.ubuntu.id"
tags:         @expr "merge(var.common_tags, { Name = local.name })"

The @expr "..." form uses the expr family's content_slot hoisting — equivalent to @expr(value: "...").

Type expressions (HCL type = list(string), type = object({ name = string })) are also opaque expressions:

+ |variable("instance_count")(
    type:    @expr "number",
    default: 3,
  )

+ |variable("config")(
    type:    @expr 'object({ name = string, port = number })',
    default: {
      name: "web",
      port: 80,
    },
  )

Heredocs → DMS heredocs

HCL heredocs (<<-EOT ... EOT) translate directly to DMS heredocs:

user_data = <<-EOF
  #!/bin/bash
  echo "hello"
EOF
+ |resource("aws_instance", "web")(
    user_data: """EOF _trim("\n", ">")
      #!/bin/bash
      echo "hello"
      EOF
  )

DMS's heredoc modifier system (_trim, _fold_paragraphs) gives explicit control over trailing-newline handling that HCL's heredoc indicator characters approximate.

null → key absence

HCL has null. DMS does not. Convention: omit the attribute entirely. If a dialect consumer needs to distinguish "user explicitly wrote null" from "user omitted the attribute", that's a render-layer concern; the dialect treats the two as equivalent.

If a future revision needs an explicit-null marker, it would be a new @null family-level decoration, not a tier-0 null value.

Block inventory (representative)

Block kinds dms+hcl handles natively (i.e., recognized by the typed param signatures):

Block kind Labels Notable named attrs
terraform 0 required_version, required_providers, backend
provider 1 alias, plus provider-specific config
variable 1 type, default, description, sensitive
output 1 value, description, sensitive
locals 0 (any local names)
module 1 source, version, plus module input attrs
data 2 provider-specific data-source attrs
resource 2 count, for_each, lifecycle, plus provider attrs
provisioner 1 connection, plus provisioner-specific
dynamic 1 for_each, iterator, content (nested block)
lifecycle 0 create_before_destroy, prevent_destroy, ignore_changes
connection 0 type, user, host, private_key, etc.

Validation is family-level, not per-block-kind. The decoder accepts any block name; per-kind rules ("input requires type", "resource of kind X must have attribute Y") live in the runtime / render layer.

Versioning

dms+hcl follows semver. The first published version is 1.0.0. The default match strategy is caret (^1.0.0 matches any 1.x.x ≥ 1.0.0).

What counts as a version bump:

File extension

dms+hcl documents use the .dms.hcl extension. Chosen over .dms.tf because dms+hcl covers all HCL2 dialects (Terraform, Packer, Vault, etc.), not just Terraform.

infra/main.dms.hcl     # dms+hcl document (any HCL2 use)
infra/locals.dms.hcl

A dms+hcl document must declare _dms_tier: 1 and import the hcl dialect in _dms_imports regardless of file extension; the extension is a tooling hint, not a binding declaration.

What's not in scope (this dialect)

Open questions for v0.2+