[tint][tools] Add a 'tintd install' command

Installs the tintd vscode extension for local development via a symlink to the build directory

Bug: tint:2127
Change-Id: If813eebd58d9eb2fbc2a3f41a69174d88d263fb9
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/179100
Kokoro: Kokoro <noreply+kokoro@google.com>
Reviewed-by: dan sinclair <dsinclair@chromium.org>
diff --git a/tools/src/cmd/tintd/common/cmds.go b/tools/src/cmd/tintd/common/cmds.go
new file mode 100644
index 0000000..44edcd5
--- /dev/null
+++ b/tools/src/cmd/tintd/common/cmds.go
@@ -0,0 +1,44 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+package common
+
+import (
+	"dawn.googlesource.com/dawn/tools/src/subcmd"
+)
+
+// The registered commands
+var commands []Command
+
+// Command is the type of a single cts command
+type Command = subcmd.Command[any]
+
+// Register registers the command for use by the 'cts' tool
+func Register(c Command) { commands = append(commands, c) }
+
+// Commands returns all the commands registered
+func Commands() []Command { return commands }
diff --git a/tools/src/cmd/tintd/install/install.go b/tools/src/cmd/tintd/install/install.go
new file mode 100644
index 0000000..fadf860
--- /dev/null
+++ b/tools/src/cmd/tintd/install/install.go
@@ -0,0 +1,144 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+// Package install installs the tintd vscode extension for local development
+// via a symlink to the build directory
+package install
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+
+	"dawn.googlesource.com/dawn/tools/src/cmd/tintd/common"
+	"dawn.googlesource.com/dawn/tools/src/fileutils"
+)
+
+func init() {
+	common.Register(&Cmd{})
+}
+
+type Cmd struct {
+	flags struct {
+		buildDir string
+		npmPath  string
+	}
+}
+
+func (Cmd) Name() string {
+	return "install"
+}
+
+func (Cmd) Desc() string {
+	return `install installs the tintd vscode extension for local development via a symlink to the build directory`
+}
+
+func (c *Cmd) RegisterFlags(ctx context.Context, _ any) ([]string, error) {
+	dawnRoot := fileutils.DawnRoot()
+	npmPath, _ := exec.LookPath("npm")
+	flag.StringVar(&c.flags.buildDir, "build", filepath.Join(dawnRoot, "out", "active"), "the output build directory")
+	flag.StringVar(&c.flags.npmPath, "npm", npmPath, "path to npm")
+
+	return nil, nil
+}
+
+func (c Cmd) Run(ctx context.Context, _ any) error {
+	pkgDir := c.findPackage()
+	if pkgDir == "" {
+		return fmt.Errorf("could not find extension package directory at '%v'", c.flags.buildDir)
+	}
+
+	if !fileutils.IsExe(c.flags.npmPath) {
+		return fmt.Errorf("could not find npm")
+	}
+
+	// Build the package
+	npmCmd := exec.Command(c.flags.npmPath, "install")
+	npmCmd.Dir = pkgDir
+	if out, err := npmCmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("npm install failed:\n%v\n%v", err, string(out))
+	}
+
+	// Load the package to get the name and version
+	pkg := struct {
+		Name    string
+		Version string
+	}{}
+	packageJSONPath := filepath.Join(pkgDir, "package.json")
+	packageJSON, err := os.ReadFile(packageJSONPath)
+	if err != nil {
+		return fmt.Errorf("could not open '%v'", packageJSONPath)
+	}
+	if err := json.NewDecoder(bytes.NewReader(packageJSON)).Decode(&pkg); err != nil {
+		return fmt.Errorf("could not parse '%v': %v", packageJSONPath, err)
+	}
+
+	// Symlink to vscode extensions directory
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return fmt.Errorf("failed to obtain home directory: %w", err)
+	}
+	vscodeBaseExtsDir := filepath.Join(home, ".vscode", "extensions")
+	if !fileutils.IsDir(vscodeBaseExtsDir) {
+		return fmt.Errorf("vscode extensions directory not found at '%v'", vscodeBaseExtsDir)
+	}
+
+	vscodeTintdDir := filepath.Join(vscodeBaseExtsDir, fmt.Sprintf("google.%v-%v", pkg.Name, pkg.Version))
+	os.RemoveAll(vscodeTintdDir)
+
+	if err := os.Symlink(pkgDir, vscodeTintdDir); err != nil {
+		return fmt.Errorf("failed to create symlink '%v' <- '%v': %w", pkgDir, vscodeTintdDir, err)
+	}
+
+	return nil
+}
+
+// findPackage looks for and returns the tintd package directory. Returns an empty string if not found.
+func (c Cmd) findPackage() string {
+	searchPaths := []string{
+		filepath.Join(c.flags.buildDir, "gen/vscode"),
+		c.flags.buildDir,
+	}
+	files := []string{"tintd", "package.json"}
+
+nextDir:
+	for _, dir := range searchPaths {
+		for _, file := range files {
+			if !fileutils.IsFile(filepath.Join(dir, file)) {
+				continue nextDir
+			}
+		}
+		return dir
+	}
+
+	return "" // Not found
+}
diff --git a/tools/src/cmd/tintd/main.go b/tools/src/cmd/tintd/main.go
new file mode 100644
index 0000000..55fc9e9
--- /dev/null
+++ b/tools/src/cmd/tintd/main.go
@@ -0,0 +1,55 @@
+// Copyright 2024 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+// tintd is a collection of sub-commands for the tintd language server
+//
+// To view available commands run: '<dawn>/tools/run tintd --help'
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"dawn.googlesource.com/dawn/tools/src/cmd/tintd/common"
+	"dawn.googlesource.com/dawn/tools/src/subcmd"
+
+	// Register sub-commands
+	_ "dawn.googlesource.com/dawn/tools/src/cmd/tintd/install"
+)
+
+func main() {
+	ctx := context.Background()
+
+	var noArg any
+	if err := subcmd.Run(ctx, noArg, common.Commands()...); err != nil {
+		if err != subcmd.ErrInvalidCLA {
+			fmt.Fprintln(os.Stderr, err)
+		}
+		os.Exit(1)
+	}
+}