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:
- Attributes (
key = value) — single named values. - Nested blocks (
KIND [LABELS] { BODY }) — typed sub-structures.
dms+hcl maps these as:
- Attributes → named params on the parent decoration's
named group:
|resource(...)(name: "...", count: 3). - Nested blocks → decorated list-item children in the
parent's child block:
+ |ingress(from_port: 80, ...).
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:
- Breaking (major). Renaming a typed meta-argument; changing
a typed field's type (e.g.,
count: any → integer); switching theblockfamily frompositionaltowildcard_with_typed; changing theexprfamily's content_slot name. - Additive (minor). Adding a new typed meta-argument; adding a new representative block kind (no behavioral change since validation is family-level).
- Bug fix (patch). Documentation clarifications, fixing typos in typed key names (with deprecation), correcting inconsistencies in the dialect spec text.
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)
- Expression evaluation.
@expr "..."carries the source string; evaluation is a runtime / render layer concern. - HCL → dms+hcl source-to-source migration tooling. A conversion utility is a separate project; the dialect spec defines the target shape.
- Terraform-specific semantics. Plan, apply, state — none
of these. dms+hcl is a representation format; downstream
Terraform-binding tools consume a decoded
Document_t1. - Provider schema validation. "AWS provider's
aws_instance.amimust be a valid AMI ID format" — provider-side, not dialect-side. - Plan/apply diffs. Same — runtime concern.
Open questions for v0.2+
- Per-block-kind empty defaults.
terraformandlocalsblocks have no labels and only attributes;dataandresourcehave two labels. The current single-familyblockmodel treats these uniformly. A future revision could ship per-kind sub-families (hcl.resource,hcl.variable, etc.) for tighter validation. Trade-off: fewer wildcard fall-through cases vs. larger spec surface. - Expression sub-types. Currently all expressions are
opaque
@expr "...". A future@ref,@interp,@funcsplit could give the runtime more dispatch context. Defer until a real use case demonstrates need. - Map-vs-block ambiguity. HCL has both
tags = { Name = "x" }(map attribute) andlifecycle { create_before_destroy = true }(nested block namedlifecycle). Some users find this confusing in HCL itself; dms+hcl preserves the distinction via the syntactic difference (tags: { ... }vs+ |lifecycle(...)), but a future revision could add an explicit marker if it helps. - JSON-mode HCL (
.tf.json). HCL has a JSON form for machine-generated configs. dms+hcl could either ignore it (JSON has tier-0 mappings already) or define a normalization rule. Defer.