Add support for `query` subcommand (#525)
diff --git a/tfexec/internal/e2etest/query_test.go b/tfexec/internal/e2etest/query_test.go
new file mode 100644
index 0000000..4d7f16b
--- /dev/null
+++ b/tfexec/internal/e2etest/query_test.go
@@ -0,0 +1,57 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package e2etest
+
+import (
+	"bytes"
+	"context"
+	"io"
+	"regexp"
+	"strings"
+	"testing"
+
+	"github.com/hashicorp/go-version"
+	"github.com/hashicorp/terraform-exec/tfexec"
+	"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
+)
+
+func TestQueryJSON_TF112(t *testing.T) {
+	versions := []string{testutil.Latest_v1_12}
+
+	runTestWithVersions(t, versions, "query", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		err := tf.Init(context.Background())
+		if err != nil {
+			t.Fatalf("error running Init in test directory: %s", err)
+		}
+
+		re := regexp.MustCompile("terraform query -json was added in 1.14.0")
+
+		err = tf.QueryJSON(context.Background(), io.Discard)
+		if err != nil && !re.MatchString(err.Error()) {
+			t.Fatalf("error running Query: %s", err)
+		}
+	})
+}
+
+func TestQueryJSON_TF114(t *testing.T) {
+	versions := []string{testutil.Latest_Alpha_v1_14}
+
+	runTestWithVersions(t, versions, "query", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) {
+		err := tf.Init(context.Background())
+		if err != nil {
+			t.Fatalf("error running Init in test directory: %s", err)
+		}
+
+		var output bytes.Buffer
+		err = tf.QueryJSON(context.Background(), &output)
+		if err != nil {
+			t.Fatalf("error running Query: %s", err)
+		}
+
+		results := strings.Count(output.String(), "list.concept_pet.pets: Result found")
+		if results != 5 {
+			t.Fatalf("expected 5 query results, but got %d", results)
+		}
+	})
+}
diff --git a/tfexec/internal/e2etest/testdata/query/main.tf b/tfexec/internal/e2etest/testdata/query/main.tf
new file mode 100644
index 0000000..a6cb2c2
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/query/main.tf
@@ -0,0 +1,8 @@
+terraform {
+  required_providers {
+    concept = {
+      source  = "dbanck/concept"
+      version = "0.1.0"
+    }
+  }
+}
diff --git a/tfexec/internal/e2etest/testdata/query/main.tfquery.hcl b/tfexec/internal/e2etest/testdata/query/main.tfquery.hcl
new file mode 100644
index 0000000..99a5b2a
--- /dev/null
+++ b/tfexec/internal/e2etest/testdata/query/main.tfquery.hcl
@@ -0,0 +1,3 @@
+list "concept_pet" "pets" {
+    provider = concept
+}
diff --git a/tfexec/internal/testutil/tfcache.go b/tfexec/internal/testutil/tfcache.go
index 6c4d09d..fa7d260 100644
--- a/tfexec/internal/testutil/tfcache.go
+++ b/tfexec/internal/testutil/tfcache.go
@@ -36,6 +36,7 @@
 	Latest_Alpha_v1_10 = "1.10.0-alpha20240926"
 	Latest_v1_11       = "1.11.4"
 	Latest_v1_12       = "1.12.2"
+	Latest_Alpha_v1_14 = "1.14.0-alpha20250903"
 )
 
 const appendUserAgent = "tfexec-testutil"
diff --git a/tfexec/options.go b/tfexec/options.go
index 339bf39..0129bd5 100644
--- a/tfexec/options.go
+++ b/tfexec/options.go
@@ -184,6 +184,14 @@
 	return &FromModuleOption{source}
 }
 
+type GenerateConfigOutOption struct {
+	path string
+}
+
+func GenerateConfigOut(path string) *GenerateConfigOutOption {
+	return &GenerateConfigOutOption{path}
+}
+
 type GetOption struct {
 	get bool
 }
diff --git a/tfexec/query.go b/tfexec/query.go
new file mode 100644
index 0000000..04021d1
--- /dev/null
+++ b/tfexec/query.go
@@ -0,0 +1,134 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os/exec"
+)
+
+type queryConfig struct {
+	dir            string
+	generateConfig string
+	reattachInfo   ReattachInfo
+	vars           []string
+	varFiles       []string
+}
+
+var defaultQueryOptions = queryConfig{}
+
+// QueryOption represents options used in the Query method.
+type QueryOption interface {
+	configureQuery(*queryConfig)
+}
+
+func (opt *DirOption) configureQuery(conf *queryConfig) {
+	conf.dir = opt.path
+}
+
+func (opt *GenerateConfigOutOption) configureQuery(conf *queryConfig) {
+	conf.generateConfig = opt.path
+}
+
+func (opt *ReattachOption) configureQuery(conf *queryConfig) {
+	conf.reattachInfo = opt.info
+}
+
+func (opt *VarFileOption) configureQuery(conf *queryConfig) {
+	conf.varFiles = append(conf.varFiles, opt.path)
+}
+
+func (opt *VarOption) configureQuery(conf *queryConfig) {
+	conf.vars = append(conf.vars, opt.assignment)
+}
+
+// QueryJSON executes `terraform query` with the specified options as well as the
+// `-json` flag and waits for it to complete.
+//
+// Using the `-json` flag will result in
+// [machine-readable](https://developer.hashicorp.com/terraform/internals/machine-readable-ui)
+// JSON being written to the supplied `io.Writer`.
+//
+// The returned error is nil if `terraform query` has been executed and exits
+// with 0.
+//
+// QueryJSON is likely to be removed in a future major version in favour of
+// query returning JSON by default.
+func (tf *Terraform) QueryJSON(ctx context.Context, w io.Writer, opts ...QueryOption) error {
+	err := tf.compatible(ctx, tf1_14_0, nil)
+	if err != nil {
+		return fmt.Errorf("terraform query -json was added in 1.14.0: %w", err)
+	}
+
+	tf.SetStdout(w)
+
+	cmd, err := tf.queryJSONCmd(ctx, opts...)
+	if err != nil {
+		return err
+	}
+
+	err = tf.runTerraformCmd(ctx, cmd)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (tf *Terraform) queryJSONCmd(ctx context.Context, opts ...QueryOption) (*exec.Cmd, error) {
+	c := defaultQueryOptions
+
+	for _, o := range opts {
+		o.configureQuery(&c)
+	}
+
+	args, err := tf.buildQueryArgs(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+
+	args = append(args, "-json")
+
+	return tf.buildQueryCmd(ctx, c, args)
+}
+
+func (tf *Terraform) buildQueryArgs(ctx context.Context, c queryConfig) ([]string, error) {
+	args := []string{"query", "-no-color"}
+
+	if c.generateConfig != "" {
+		args = append(args, "-generate-config-out="+c.generateConfig)
+	}
+
+	for _, vf := range c.varFiles {
+		args = append(args, "-var-file="+vf)
+	}
+
+	if c.vars != nil {
+		for _, v := range c.vars {
+			args = append(args, "-var", v)
+		}
+	}
+
+	return args, nil
+}
+
+func (tf *Terraform) buildQueryCmd(ctx context.Context, c queryConfig, args []string) (*exec.Cmd, error) {
+	// optional positional argument
+	if c.dir != "" {
+		args = append(args, c.dir)
+	}
+
+	mergeEnv := map[string]string{}
+	if c.reattachInfo != nil {
+		reattachStr, err := c.reattachInfo.marshalString()
+		if err != nil {
+			return nil, err
+		}
+		mergeEnv[reattachEnvVar] = reattachStr
+	}
+
+	return tf.buildTerraformCmd(ctx, mergeEnv, args...), nil
+}
diff --git a/tfexec/query_test.go b/tfexec/query_test.go
new file mode 100644
index 0000000..9291667
--- /dev/null
+++ b/tfexec/query_test.go
@@ -0,0 +1,59 @@
+// Copyright (c) HashiCorp, Inc.
+// SPDX-License-Identifier: MPL-2.0
+
+package tfexec
+
+import (
+	"context"
+	"testing"
+
+	"github.com/hashicorp/terraform-exec/tfexec/internal/testutil"
+)
+
+func TestQueryJSONCmd(t *testing.T) {
+	td := t.TempDir()
+
+	tf, err := NewTerraform(td, tfVersion(t, testutil.Latest_Alpha_v1_14))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// empty env, to avoid environ mismatch in testing
+	tf.SetEnv(map[string]string{})
+
+	t.Run("defaults", func(t *testing.T) {
+		queryCmd, err := tf.queryJSONCmd(context.Background())
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"query",
+			"-no-color",
+			"-json",
+		}, nil, queryCmd)
+	})
+
+	t.Run("override all", func(t *testing.T) {
+		queryCmd, err := tf.queryJSONCmd(context.Background(),
+			GenerateConfigOut("generated.tf"),
+			Var("android=paranoid"),
+			Var("brain_size=planet"),
+			VarFile("trillian"),
+			Dir("earth"))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		assertCmd(t, []string{
+			"query",
+			"-no-color",
+			"-generate-config-out=generated.tf",
+			"-var-file=trillian",
+			"-var", "android=paranoid",
+			"-var", "brain_size=planet",
+			"-json",
+			"earth",
+		}, nil, queryCmd)
+	})
+}
diff --git a/tfexec/version.go b/tfexec/version.go
index 87addd1..40b9301 100644
--- a/tfexec/version.go
+++ b/tfexec/version.go
@@ -34,6 +34,8 @@
 	tf1_4_0  = version.Must(version.NewVersion("1.4.0"))
 	tf1_6_0  = version.Must(version.NewVersion("1.6.0"))
 	tf1_9_0  = version.Must(version.NewVersion("1.9.0"))
+	tf1_13_0 = version.Must(version.NewVersion("1.13.0"))
+	tf1_14_0 = version.Must(version.NewVersion("1.14.0"))
 )
 
 // Version returns structured output from the terraform version command including both the Terraform CLI version