Initial implementation of msvc-go-wine

A single-binary Go tool for cross compiling with the real MSVC toolchain
on Linux via Wine. Behaves as cl/link/lib/rc/midl/mt/dumpbin/msbuild/
nmake/ml/ml64/armasm/armasm64/cmd/findstr depending on the name it's
invoked as, plus download/install/env/version management subcommands.

- download: fetches the MSVC/WinSDK installer manifest, resolves package
  selection and dependencies, downloads and verifies payloads, unpacks
  VSIX/MSI packages, and applies a handful of compatibility patches so
  VsDevCmd.bat and MSBuild's SDK detection work without a Windows
  Registry (which doesn't exist under Wine).
- install: locates the installed toolchain/SDK versions, normalizes
  header/library name casing, lays out per-architecture tool symlinks
  with an env.json config each, and compiles a small native launcher
  (toolrelay.exe) that lets mt.exe's CMake-compatibility exit code
  survive Wine's own exit-code truncation.
- The wrapper runtime rewrites absolute unix paths in tool arguments into
  Wine's z:\... form, runs the real .exe under wine, and rewrites the
  tool's output back to plain unix paths.

Verified end-to-end against a real MSVC/WinSDK download: cl, link, mt and
the resulting hello.exe all work under Wine, including through the
toolrelay.exe relay path and with paths containing non-ASCII characters.

Offline unit tests cover the wrapper's path-rewrite/output-filter logic,
install-time header lowercasing, and download package-selection/
dependency-resolution.
This commit is contained in:
Cheviiot
2026-07-25 00:57:41 +10:00
commit 6464da7847
41 changed files with 4207 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
package install
import (
"os"
"path/filepath"
"regexp"
"strings"
)
// FixIncludeOptions controls how FixInclude rewrites include directives.
type FixIncludeOptions struct {
// MapWinSDK restores the canonical "GL/" casing after lowercasing,
// since that's the cross-platform spelling for that header directory.
MapWinSDK bool
}
// reIncludeLine matches `#include <foo/bar.h>` or `#include "foo.h"`, but
// not `#include IDENTIFIER` (macro-expanded includes).
var reIncludeLine = regexp.MustCompile(`^\s*#\s*include\s+["<][\w.\\/]+[">]`)
// FixInclude rewrites #include directives under root to reference the
// lowercase header names produced by Lowercase, since MSVC/WinSDK headers
// reference each other with casing that's internally inconsistent (but
// self-consistent once lowercased). Every text file under root is
// rewritten with normalized (LF) line endings as a side effect.
func FixInclude(root string, opts FixIncludeOptions) error {
var doDir func(dir string) error
doDir = func(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
path := filepath.Join(dir, e.Name())
if e.Type()&os.ModeSymlink != 0 {
continue
}
if e.IsDir() {
if err := doDir(path); err != nil {
return err
}
continue
}
if err := fixFile(path, opts); err != nil {
return err
}
}
return nil
}
return doDir(root)
}
func fixFile(path string, opts FixIncludeOptions) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if reIncludeLine.MatchString(line) {
code, comment := line, ""
if idx := strings.Index(line, "//"); idx >= 0 {
code, comment = line[:idx], line[idx:]
}
code = lowercaseAndSlash(code)
if opts.MapWinSDK {
code = strings.Replace(code, "gl/", "GL/", 1)
}
line = code + comment
}
lines[i] = strings.TrimRight(line, "\r\n")
}
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
}
// lowercaseAndSlash lowercases ASCII letters and turns backslashes into
// forward slashes, leaving everything else (including non-ASCII bytes)
// untouched.
func lowercaseAndSlash(s string) string {
b := []byte(s)
for i, c := range b {
switch {
case c >= 'A' && c <= 'Z':
b[i] = c - 'A' + 'a'
case c == '\\':
b[i] = '/'
}
}
return string(b)
}
+442
View File
@@ -0,0 +1,442 @@
// Package install wires up a downloaded MSVC/WinSDK tree so the msvc-go-wine
// wrapper commands can find it: locating the installed toolchain/SDK
// versions, fixing up header/library name casing, laying out the
// per-architecture tool symlinks, and building the toolrelay helper.
package install
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/Cheviiot/msvc-go-wine/assets"
"github.com/Cheviiot/msvc-go-wine/internal/wineenv"
)
var archs = []string{"x86", "x64", "arm", "arm64"}
// Install wires up dest (a directory previously populated by
// `msvc-go-wine download --dest dest`) with the tool wrapper symlinks and
// env.json config the wrapper runtime expects. selfBinary is the path to
// the currently running msvc-go-wine executable, copied into dest/bin so
// the arch-specific tool symlinks have something to point at.
func Install(dest, selfBinary string) error {
dest, err := filepath.Abs(dest)
if err != nil {
return err
}
if fi, err := os.Stat(dest); err != nil || !fi.IsDir() {
return fmt.Errorf("destination %q is not a directory", dest)
}
// Targets are relative so the whole installed tree stays relocatable -
// moving or renaming dest doesn't break these symlinks the way an
// absolute target baked in at install time would.
if err := lnS("Windows Kits", filepath.Join(dest, "kits")); err != nil {
return err
}
if err := lnS("VC", filepath.Join(dest, "vc")); err != nil {
return err
}
if err := lnS("Tools", filepath.Join(dest, "vc", "tools")); err != nil {
return err
}
if err := lnS("MSVC", filepath.Join(dest, "vc", "tools", "msvc")); err != nil {
return err
}
msvcRoot := filepath.Join(dest, "vc", "tools", "msvc")
msvcVer, err := findMSVCVersion(msvcRoot)
if err != nil {
return err
}
fmt.Println("Using MSVC version", msvcVer)
msvcDir := filepath.Join(msvcRoot, msvcVer)
if err := fixLibCasing(filepath.Join(msvcDir, "lib")); err != nil {
return err
}
includeDir := filepath.Join(msvcDir, "include")
if err := Lowercase(includeDir, LowercaseOptions{Symlink: true}); err != nil {
return fmt.Errorf("lowercasing %s: %w", includeDir, err)
}
if err := FixInclude(includeDir, FixIncludeOptions{}); err != nil {
return fmt.Errorf("fixing includes in %s: %w", includeDir, err)
}
atlIncludeDir := filepath.Join(msvcDir, "atlmfc", "include")
if isDir(atlIncludeDir) {
if err := FixInclude(atlIncludeDir, FixIncludeOptions{}); err != nil {
return fmt.Errorf("fixing includes in %s: %w", atlIncludeDir, err)
}
}
binDir := filepath.Join(msvcDir, "bin")
if err := removeVctip(binDir); err != nil {
return err
}
if err := renameHostDirs(binDir); err != nil {
return err
}
kits10 := filepath.Join(dest, "kits", "10")
if !isDir(kits10) {
return fmt.Errorf("%s not found - expected a Windows SDK already unpacked by `msvc-go-wine download`", kits10)
}
if err := lnS("Lib", filepath.Join(kits10, "lib")); err != nil {
return err
}
if err := lnS("Include", filepath.Join(kits10, "include")); err != nil {
return err
}
sdkVer, err := findSDKVersion(filepath.Join(kits10, "include"))
if err != nil {
return err
}
fmt.Println("Using SDK version", sdkVer)
for _, sub := range []string{"um", "shared", "winrt", "km"} {
dir := filepath.Join(kits10, "include", sdkVer, sub)
if !isDir(dir) {
continue
}
if err := Lowercase(dir, LowercaseOptions{Symlink: true, MapWinSDK: true}); err != nil {
return fmt.Errorf("lowercasing %s: %w", dir, err)
}
if err := FixInclude(dir, FixIncludeOptions{MapWinSDK: true}); err != nil {
return fmt.Errorf("fixing includes in %s: %w", dir, err)
}
}
wdfDir := filepath.Join(kits10, "include", "wdf")
if isDir(wdfDir) {
if err := Lowercase(wdfDir, LowercaseOptions{Symlink: true, MapWinSDK: true}); err != nil {
return err
}
if err := FixInclude(wdfDir, FixIncludeOptions{MapWinSDK: true}); err != nil {
return err
}
}
for _, arch := range archs {
for _, sub := range []string{"um", "km"} {
dir := filepath.Join(kits10, "lib", sdkVer, sub, arch)
if !isDir(dir) {
continue
}
if err := Lowercase(dir, LowercaseOptions{Symlink: true}); err != nil {
return fmt.Errorf("lowercasing %s: %w", dir, err)
}
}
}
host, dotnetHost := hostArch()
modulesRel := filepath.Join("VC", "Tools", "MSVC", msvcVer, "modules")
if isDir(filepath.Join(dest, modulesRel)) {
if err := lnS(modulesRel, filepath.Join(dest, "modules")); err != nil {
return err
}
}
destBin := filepath.Join(dest, "bin")
if err := os.MkdirAll(destBin, 0o755); err != nil {
return err
}
sharedBinary := filepath.Join(destBin, "msvc-go-wine")
if err := copyFile(selfBinary, sharedBinary, 0o755); err != nil {
return fmt.Errorf("installing shared binary: %w", err)
}
installed := 0
for _, arch := range archs {
clExe := filepath.Join(msvcDir, "bin", "Host"+host, arch, "cl.exe")
if !isFile(clExe) {
continue
}
if err := setupWrapperDir(destBin, selfBinary, arch, host, dotnetHost, msvcVer, sdkVer); err != nil {
return err
}
installed++
fmt.Println("Installed tool wrappers for", arch)
}
if installed == 0 {
return fmt.Errorf("no target architecture found under %s/bin/Host%s/*", msvcDir, host)
}
if bootstrapWine() {
// Best-effort: if this fails, the wrapper runtime just falls back
// to invoking tools directly through wine, without toolrelay.exe's
// mt.exe/CMake exit-code fixup.
if err := buildToolRelay(dest, destBin, host); err != nil {
fmt.Println("Building toolrelay failed (continuing without it):", err)
} else {
fmt.Println("Build toolrelay done.")
}
}
return nil
}
func findMSVCVersion(msvcRoot string) (string, error) {
entries, err := os.ReadDir(msvcRoot)
if err != nil {
return "", fmt.Errorf("reading %s: %w", msvcRoot, err)
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
// `ls -r`: reverse of the default (lexical) sort order.
sort.Sort(sort.Reverse(sort.StringSlice(names)))
for _, name := range names {
dir := filepath.Join(msvcRoot, name)
if isDir(filepath.Join(dir, "bin")) && isDir(filepath.Join(dir, "include")) && isDir(filepath.Join(dir, "lib")) {
return name, nil
}
}
return "", fmt.Errorf("no suitable MSVC version found under %s", msvcRoot)
}
func findSDKVersion(includeDir string) (string, error) {
entries, err := os.ReadDir(includeDir)
if err != nil {
return "", fmt.Errorf("reading %s: %w", includeDir, err)
}
var versions []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "10.") {
versions = append(versions, e.Name())
}
}
if len(versions) == 0 {
return "", fmt.Errorf("no Windows SDK version (10.*) found under %s", includeDir)
}
sort.Strings(versions)
return versions[len(versions)-1], nil
}
// fixLibCasing adds uppercase symlinks (LIBCMT.lib -> libcmt.lib, etc) so
// lld-link can resolve the /DEFAULTLIB directives cl.exe emits, which name
// these libs in upper case, on a case-sensitive filesystem.
func fixLibCasing(libDir string) error {
names := []string{"libcmt", "libcmtd", "msvcrt", "msvcrtd", "oldnames"}
for _, arch := range archs {
dir := filepath.Join(libDir, arch)
if !isDir(dir) {
continue
}
for _, n := range names {
lower := filepath.Join(dir, n+".lib")
if !isFile(lower) {
continue
}
upper := filepath.Join(dir, strings.ToUpper(n)+".lib")
if err := lnS(n+".lib", upper); err != nil {
return err
}
}
}
return nil
}
// removeVctip deletes any vctip.exe found under dir: it phones home to
// Microsoft and is known to cause problems under Wine.
func removeVctip(dir string) error {
return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.EqualFold(d.Name(), "vctip.exe") {
return os.Remove(path)
}
return nil
})
}
// renameHostDirs normalizes the several casings different MSVC releases
// have used for their host-arch bin directories.
func renameHostDirs(binDir string) error {
renames := []struct{ from, to string }{
{"HostX64", "Hostx64"}, // 15.x - 16.4
{"HostARM64", "Hostarm64"}, // 17.2 - 17.3
{"HostArm64", "Hostarm64"}, // 17.4
}
for _, r := range renames {
from := filepath.Join(binDir, r.from)
to := filepath.Join(binDir, r.to)
if isDir(from) && !exists(to) {
if err := os.Rename(from, to); err != nil {
return err
}
}
}
oldArm64 := filepath.Join(binDir, "Hostarm64", "ARM64")
newArm64 := filepath.Join(binDir, "Hostarm64", "arm64")
if isDir(oldArm64) && !exists(newArm64) {
if err := os.Rename(oldArm64, newArm64); err != nil {
return err
}
}
return nil
}
// setupWrapperDir creates <destBin>/<arch> with its own local copy of the
// msvc-go-wine binary (not a symlink to the shared one in destBin), and
// symlinks every tool name to that LOCAL copy.
//
// This matters: the wrapper runtime locates its own install root via
// os.Executable(), which fully resolves symlinks (like /proc/self/exe does)
// - it can't rely on os.Args[0], since not every shell passes a
// PATH-resolved absolute path as argv[0] (some just pass the bare command
// name, e.g. "cl", which would make a naive argv[0]-based lookup resolve
// against the caller's cwd instead of the install dir). A same-directory
// symlink (cl -> msvc-go-wine) resolves to a binary that's still in the
// right arch dir; a symlink to a binary one level up (cl -> ../msvc-go-wine)
// would not be.
func setupWrapperDir(destBin, selfBinary, arch, host, dotnetHost, msvcVer, sdkVer string) error {
archDir := filepath.Join(destBin, arch)
if err := os.MkdirAll(archDir, 0o755); err != nil {
return err
}
localBinary := filepath.Join(archDir, "msvc-go-wine")
if err := copyFile(selfBinary, localBinary, 0o755); err != nil {
return fmt.Errorf("installing per-arch binary: %w", err)
}
for name := range toolNames {
if err := lnS("msvc-go-wine", filepath.Join(archDir, name)); err != nil {
return err
}
if err := lnS("msvc-go-wine", filepath.Join(archDir, name+".exe")); err != nil {
return err
}
}
cfg := &wineenv.Config{
Arch: arch,
Host: host,
DotnetHost: dotnetHost,
MSVCVer: msvcVer,
SDKVer: sdkVer,
}
return cfg.Save(archDir)
}
// toolNames is the set of tool wrapper symlinks created in every arch dir.
var toolNames = map[string]bool{
"cl": true, "link": true, "lib": true, "ml": true, "ml64": true,
"mc": true, "midl": true, "mt": true, "rc": true, "dumpbin": true,
"msbuild": true, "nmake": true, "armasm": true, "armasm64": true,
"cmd": true, "findstr": true,
}
func hostArch() (host, dotnetHost string) {
switch runtime.GOARCH {
case "arm64":
return "arm64", "arm64"
default:
return "x64", "amd64"
}
}
// bootstrapWine runs `wineboot --init` to set up the wine prefix ahead of
// time, and reports whether wine is available at all (so callers can decide
// whether to attempt building toolrelay.exe).
func bootstrapWine() bool {
wine, err := wineenv.FindWine()
if err != nil {
fmt.Println("wine not found, skipping wineboot bootstrap and toolrelay build (install wine before running the tools)")
return false
}
fmt.Println("Bootstrapping wine prefix...")
if err := runQuiet(wine, "wineboot", "--init"); err != nil {
fmt.Println("wineboot --init failed (continuing):", err)
}
return true
}
// buildToolRelay compiles the vendored toolrelay.cpp helper using the
// freshly-installed host-arch cl wrapper. toolrelay.exe is what lets the
// wrapper runtime give mt.exe's CMake-compatibility exit code
// (0x41020001 -> 0xbb) a chance to survive Wine's own exit-code truncation -
// see internal/wrapper's runViaToolRelay. Best-effort: any failure here just
// means the wrapper runtime falls back to a plain wine invocation without
// that fixup.
func buildToolRelay(dest, destBin, host string) error {
clWrapper := filepath.Join(destBin, host, "cl")
if !isFile(clWrapper) {
return fmt.Errorf("no cl wrapper for host arch %s at %s", host, clWrapper)
}
srcPath := filepath.Join(dest, "toolrelay.cpp")
if err := os.WriteFile(srcPath, assets.ToolRelaySource, 0o644); err != nil {
return err
}
defer os.Remove(srcPath)
fmt.Println("Build toolrelay ...")
cmd := exec.Command(clWrapper, "/EHsc", "/O2", srcPath)
cmd.Dir = dest
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("compiling toolrelay.cpp: %w", err)
}
exePath := filepath.Join(dest, "toolrelay.exe")
objPath := filepath.Join(dest, "toolrelay.obj")
defer os.Remove(objPath)
if !isFile(exePath) {
return fmt.Errorf("cl did not produce %s", exePath)
}
return os.Rename(exePath, filepath.Join(destBin, "toolrelay.exe"))
}
func runQuiet(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Env = append(os.Environ(), "WINEDEBUG=-all")
return cmd.Run()
}
func lnS(target, link string) error {
if _, err := os.Lstat(link); err == nil {
return nil
}
return os.Symlink(target, link)
}
func isDir(path string) bool {
fi, err := os.Stat(path)
return err == nil && fi.IsDir()
}
func isFile(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}
func exists(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
_ = os.Remove(dst)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
+131
View File
@@ -0,0 +1,131 @@
package install
import (
"os"
"path/filepath"
"strings"
)
// LowercaseOptions controls how Lowercase renames entries.
type LowercaseOptions struct {
// Symlink adds lowercase-named symlinks alongside the original entries
// instead of renaming them in place (used for WinSDK/MSVC headers so
// both casings stay available; renaming would break other packages
// that reference the original casing).
Symlink bool
// MapWinSDK is shorthand for path-keyed overrides that keep the "GL"
// header directory's canonical uppercase spelling.
MapWinSDK bool
}
// Lowercase recursively lowercases every file/dir name under root, merging
// into an existing same-named lowercase directory on collision.
func Lowercase(root string, opts LowercaseOptions) error {
mapPaths := map[string]string{}
if opts.MapWinSDK {
mapPaths["gl"] = "GL"
}
remap := func(relPath string) string {
rp := strings.TrimSuffix(relPath, "/")
base := rp
if idx := strings.LastIndexByte(rp, '/'); idx >= 0 {
base = rp[idx+1:]
}
if opts.MapWinSDK {
if v, ok := mapPaths[strings.ToLower(rp)]; ok {
return v
}
}
return strings.ToLower(base)
}
var doDir func(dir, relPath string) error
doDir = func(dir, relPath string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
childPath := filepath.Join(dir, name)
if e.IsDir() {
if err := doDir(childPath, relPath+name+"/"); err != nil {
return err
}
continue
}
newName := remap(relPath + name)
if newName != name {
if err := renameOrSymlink(childPath, dir, newName, opts.Symlink); err != nil {
return err
}
}
}
var newName string
if relPath == "" {
newName = strings.ToLower(filepath.Base(dir))
} else {
newName = remap(relPath)
}
oldName := filepath.Base(dir)
if oldName == newName {
return nil
}
parent := filepath.Dir(dir)
newPath := filepath.Join(parent, newName)
if fi, err := os.Stat(newPath); err == nil && fi.IsDir() {
return combineIntoDir(dir, newPath, opts.Symlink)
}
return renameOrSymlink(dir, parent, newName, opts.Symlink)
}
return doDir(root, "")
}
func renameOrSymlink(src, destDir, destName string, symlink bool) error {
dest := filepath.Join(destDir, destName)
if symlink {
if _, err := os.Lstat(dest); err == nil {
// A conflicting entry already exists at dest - this happens on
// case-insensitive filesystems where dest and src are the same
// path, so treat it as already done rather than failing.
return nil
}
rel, err := filepath.Rel(destDir, src)
if err != nil {
rel = src
}
return os.Symlink(rel, dest)
}
return os.Rename(src, dest)
}
// combineIntoDir moves (or symlinks) every entry of src into the already-existing
// dest, recursing into same-named subdirectories, then removes src (unless
// symlink mode, where src's real content must be left in place).
func combineIntoDir(src, dest string, symlink bool) error {
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
name := e.Name()
srcChild := filepath.Join(src, name)
destChild := filepath.Join(dest, name)
if fi, err := os.Stat(destChild); err == nil && fi.IsDir() && e.IsDir() {
if err := combineIntoDir(srcChild, destChild, symlink); err != nil {
return err
}
continue
}
if err := renameOrSymlink(srcChild, dest, name, symlink); err != nil {
return err
}
}
if !symlink {
return os.Remove(src)
}
return nil
}
+168
View File
@@ -0,0 +1,168 @@
package install
import (
"os"
"path/filepath"
"testing"
)
func mustMkdirAll(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
}
func mustWriteFile(t *testing.T, path, content string) {
t.Helper()
mustMkdirAll(t, filepath.Dir(path))
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLowercaseRenameMode(t *testing.T) {
root := t.TempDir()
// The top-level dir passed to Lowercase is itself lowercased too when
// not already lowercase (matching the original perl script's dodir,
// which lowercases relpath=="" using the dir's own basename).
tree := filepath.Join(root, "Include")
mustWriteFile(t, filepath.Join(tree, "Foo", "Bar.H"), "content")
if err := Lowercase(tree, LowercaseOptions{}); err != nil {
t.Fatal(err)
}
lowerTree := filepath.Join(root, "include")
if !isFile(filepath.Join(lowerTree, "foo", "bar.h")) {
t.Fatalf("expected lowercased path to exist under %s", lowerTree)
}
if isDir(tree) {
t.Fatalf("original-cased top dir should have been renamed away")
}
}
func TestLowercaseSymlinkMode(t *testing.T) {
root := t.TempDir()
tree := filepath.Join(root, "include")
mustWriteFile(t, filepath.Join(tree, "Foo", "Bar.h"), "content")
if err := Lowercase(tree, LowercaseOptions{Symlink: true}); err != nil {
t.Fatal(err)
}
// Original casing must still be reachable (symlink mode never deletes).
if !isFile(filepath.Join(tree, "Foo", "Bar.h")) {
t.Fatalf("original-cased file should still exist in symlink mode")
}
// Lowercase alias must resolve to the same content.
data, err := os.ReadFile(filepath.Join(tree, "foo", "bar.h"))
if err != nil {
t.Fatalf("expected lowercase alias to resolve: %v", err)
}
if string(data) != "content" {
t.Errorf("got %q", data)
}
}
func TestLowercaseMergeOnCollision(t *testing.T) {
root := t.TempDir()
tree := filepath.Join(root, "include")
// Both "GL" and "gl" exist as siblings; lowercasing "GL" must merge its
// contents into the already-lowercase "gl" rather than clobbering it.
mustWriteFile(t, filepath.Join(tree, "gl", "existing.h"), "existing")
mustWriteFile(t, filepath.Join(tree, "GL", "New.h"), "new")
if err := Lowercase(tree, LowercaseOptions{}); err != nil {
t.Fatal(err)
}
if !isFile(filepath.Join(tree, "gl", "existing.h")) {
t.Errorf("pre-existing lowercase file lost during merge")
}
if !isFile(filepath.Join(tree, "gl", "new.h")) {
t.Errorf("merged file not found at lowercase destination")
}
if isDir(filepath.Join(tree, "GL")) {
t.Errorf("source dir should have been removed after merge (non-symlink mode)")
}
}
func TestLowercaseMapWinSDKPreservesGL(t *testing.T) {
root := t.TempDir()
tree := filepath.Join(root, "um")
mustWriteFile(t, filepath.Join(tree, "GL", "gl.h"), "content")
if err := Lowercase(tree, LowercaseOptions{Symlink: true, MapWinSDK: true}); err != nil {
t.Fatal(err)
}
if !isDir(filepath.Join(tree, "GL")) {
t.Errorf("GL directory casing should be preserved under -map_winsdk")
}
if isDir(filepath.Join(tree, "gl")) {
t.Errorf("no lowercase alias should be created for GL under -map_winsdk (name maps back to itself)")
}
}
func TestFixIncludeLowercasesAndConvertsSlashes(t *testing.T) {
root := t.TempDir()
header := filepath.Join(root, "foo.h")
mustWriteFile(t, header, "#include <Some\\Path.H>\r\n#include \"Other.h\" // comment\r\nplain line\r\n")
if err := FixInclude(root, FixIncludeOptions{}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(header)
if err != nil {
t.Fatal(err)
}
want := "#include <some/path.h>\n#include \"other.h\" // comment\nplain line\n"
if string(data) != want {
t.Errorf("got %q want %q", data, want)
}
}
func TestFixIncludeMapWinSDKPreservesGL(t *testing.T) {
root := t.TempDir()
header := filepath.Join(root, "foo.h")
mustWriteFile(t, header, "#include <GL/gl.h>\n")
if err := FixInclude(root, FixIncludeOptions{MapWinSDK: true}); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(header)
if err != nil {
t.Fatal(err)
}
want := "#include <GL/gl.h>\n"
if string(data) != want {
t.Errorf("got %q want %q", data, want)
}
}
func TestFixIncludeSkipsSymlinks(t *testing.T) {
root := t.TempDir()
real := filepath.Join(root, "Real.h")
mustWriteFile(t, real, "#include <Foo.h>\n")
link := filepath.Join(root, "alias.h")
if err := os.Symlink(real, link); err != nil {
t.Fatal(err)
}
if err := FixInclude(root, FixIncludeOptions{}); err != nil {
t.Fatal(err)
}
// The symlink itself must be untouched (still a symlink to the same
// target); only Real.h's contents get rewritten.
target, err := os.Readlink(link)
if err != nil {
t.Fatalf("alias.h should still be a symlink: %v", err)
}
if target != real {
t.Errorf("symlink target changed: %q", target)
}
}
+54
View File
@@ -0,0 +1,54 @@
package install
import (
"os"
"path/filepath"
"testing"
)
// TestBuildToolRelay exercises the file shuffling around the actual
// compiler invocation (write embedded source, run "cl", move the resulting
// .exe into bin/, clean up the .obj and temp source) using a fake `cl`
// shell script standing in for the real Wine-hosted compiler - this can
// run fully offline, without wine or a real MSVC install.
func TestBuildToolRelay(t *testing.T) {
dest := t.TempDir()
destBin := filepath.Join(dest, "bin")
hostDir := filepath.Join(destBin, "x64")
if err := os.MkdirAll(hostDir, 0o755); err != nil {
t.Fatal(err)
}
// Fake `cl`: just drop toolrelay.exe/.obj next to the source it was
// given, like the real cl.exe would when invoked without /Fe or /Fo.
fakeCl := filepath.Join(hostDir, "cl")
script := "#!/bin/sh\ntouch toolrelay.exe toolrelay.obj\n"
if err := os.WriteFile(fakeCl, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
if err := buildToolRelay(dest, destBin, "x64"); err != nil {
t.Fatalf("buildToolRelay: %v", err)
}
if !isFile(filepath.Join(destBin, "toolrelay.exe")) {
t.Errorf("expected %s/toolrelay.exe to exist", destBin)
}
if exists(filepath.Join(dest, "toolrelay.obj")) {
t.Errorf("toolrelay.obj should have been removed from %s", dest)
}
if exists(filepath.Join(dest, "toolrelay.cpp")) {
t.Errorf("temp toolrelay.cpp source should have been removed from %s", dest)
}
}
func TestBuildToolRelayNoClWrapper(t *testing.T) {
dest := t.TempDir()
destBin := filepath.Join(dest, "bin")
if err := os.MkdirAll(destBin, 0o755); err != nil {
t.Fatal(err)
}
if err := buildToolRelay(dest, destBin, "x64"); err == nil {
t.Fatal("expected an error when no host-arch cl wrapper exists")
}
}