Module: Vcvars::Locator

Defined in:
lib/vcvars/locator.rb

Overview

Finds a Visual Studio / Build Tools installation and the arch-specific vcvars batch script that activates the MSVC build environment.

Strategy (most robust first):

1. `vswhere -find VC\Auxiliary\Build\<vcvars>.bat` — returns the script
   path directly. This is used as the primary method because on some VS
   layouts (observed on the VS "18" / 2026 line) `-property
   installationPath` comes back empty, while `-find` still works.
2. `vswhere -property installationPath` + the known relative path.
3. `VSINSTALLDIR` (set when already inside a developer environment).
4. Scan well-known install roots (Program Files\Microsoft Visual Studio).

Defined Under Namespace

Classes: Installation

Constant Summary collapse

VSWHERE =
File.join(
  ENV["ProgramFiles(x86)"] || 'C:\Program Files (x86)',
  "Microsoft Visual Studio", "Installer", "vswhere.exe"
).freeze
VSWHERE_SELECT =

Selects the newest STABLE install carrying the x64/x86 VC++ toolset. Prerelease/Insiders installs are only considered as a fallback (find retries with “-prerelease” appended), so a stable VS is preferred when both are present.

[
  "-latest", "-products", "*",
  "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64"
].freeze

Class Method Summary collapse

Class Method Details

.default_archObject

The arch this Ruby was built for, e.g. “x64-mswin64_140”.



42
43
44
# File 'lib/vcvars/locator.rb', line 42

def default_arch
  RbConfig::CONFIG["arch"]
end

.find(arch: default_arch) ⇒ Object

Returns an Installation, or nil if no suitable VS install is found. Memoized per arch within a process (the install location is stable).



61
62
63
64
65
66
67
# File 'lib/vcvars/locator.rb', line 61

def find(arch: default_arch)
  @cache ||= {}
  key = arch.to_s
  return @cache[key] if @cache.key?(key)

  @cache[key] = resolve(arch)
end

.first_nonempty(*values) ⇒ Object



135
136
137
# File 'lib/vcvars/locator.rb', line 135

def first_nonempty(*values)
  values.find { |v| v && !v.empty? }
end

.resolve(arch) ⇒ Object



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/vcvars/locator.rb', line 69

def resolve(arch)
  name = vcvars_name(arch)

  if File.exist?(VSWHERE)
    # Prefer a stable install; only then consider prerelease/Insiders.
    [VSWHERE_SELECT, VSWHERE_SELECT + ["-prerelease"]].each do |select|
      inst = resolve_via_vswhere(select, name, arch)
      return inst if inst
    end
  end

  bat = resolve_via_env(name) || scan_known_roots(name)
  return nil unless bat

  Installation.new(vs_path: win(vs_root_from(bat)), vcvars: win(bat),
                   arch: arch.to_s, version: nil, name: nil)
end

.resolve_via_env(name) ⇒ Object

The vcvars*.bat under VSINSTALLDIR (set inside an active dev env), or nil.



127
128
129
130
131
132
133
# File 'lib/vcvars/locator.rb', line 127

def resolve_via_env(name)
  vsdir = ENV["VSINSTALLDIR"]
  return nil if vsdir.nil? || vsdir.empty?

  cand = File.join(vsdir, "VC", "Auxiliary", "Build", name)
  File.exist?(cand) ? cand : nil
end

.resolve_via_vswhere(select, name, arch) ⇒ Object

Resolve a complete Installation from ONE vswhere selection, so the vcvars path and the reported metadata (version/name) describe the SAME install.



89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/vcvars/locator.rb', line 89

def resolve_via_vswhere(select, name, arch)
  path = vswhere(select + ["-property", "installationPath"]).strip
  bat = nil
  unless path.empty?
    cand = File.join(path, "VC", "Auxiliary", "Build", name)
    bat = cand if File.exist?(cand)
  end

  # Some VS layouts return an empty installationPath; -find yields the path.
  if bat.nil?
    found = vswhere(select + ["-find", "VC\\Auxiliary\\Build\\#{name}"])
    bat = found.lines.map(&:strip).find { |l| !l.empty? && File.exist?(l) }
    return nil unless bat
    path = vs_root_from(bat)
  end

  version = first_nonempty(
    vswhere(select + ["-property", "installationVersion"]).strip,
    vswhere(select + ["-property", "catalog_productDisplayVersion"]).strip
  )
  display = vswhere(select + ["-property", "displayName"]).strip

  Installation.new(
    vs_path: win(path),
    vcvars:  win(bat),
    arch:    arch.to_s,
    version: version,
    name:    display.empty? ? nil : display
  )
end

.scan_known_roots(name) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/vcvars/locator.rb', line 139

def scan_known_roots(name)
  bases = [ENV["ProgramFiles"], ENV["ProgramFiles(x86)"]].compact.uniq
  # VS 2026 uses a numeric "18" directory; 2022/2019/2017 use the year.
  versions = %w[18 2022 2019 2017]
  editions = %w[Preview Enterprise Professional Community BuildTools]
  bases.each do |pf|
    root = File.join(pf, "Microsoft Visual Studio")
    versions.each do |ver|
      editions.each do |ed|
        cand = File.join(root, ver, ed, "VC", "Auxiliary", "Build", name)
        return cand if File.exist?(cand)
      end
    end
  end
  nil
end

.vcvars_name(arch) ⇒ Object

Map a Ruby/target arch to the matching vcvars script name. The build host is assumed to be x64 (overwhelmingly true for Ruby on Windows), so arm64 and x86 targets use the x64-hosted cross scripts.



49
50
51
52
53
54
55
56
57
# File 'lib/vcvars/locator.rb', line 49

def vcvars_name(arch)
  case arch.to_s.downcase
  when "x64", "amd64", "x86_64", /\Ax64\b/, /x86_64/, /amd64/ then "vcvars64.bat"
  when "arm64", "aarch64", /arm64/, /aarch64/                 then "vcvarsamd64_arm64.bat"
  when "x86", "i386", "i686", /\Ai\d86/, /\Ax86\b/            then "vcvars32.bat"
  else
    raise ArgumentError, "unsupported arch for vcvars: #{arch.inspect}"
  end
end

.vs_root_from(bat) ⇒ Object

The install root is four levels up from <root>VCAuxiliaryBuildx.bat.



157
158
159
# File 'lib/vcvars/locator.rb', line 157

def vs_root_from(bat)
  File.dirname(File.dirname(File.dirname(File.dirname(bat))))
end

.vswhere(args) ⇒ Object

Run vswhere with the given args; returns stdout (“” on any failure). Args are passed as a real argv (no shell), so the literal “*” in “-products *” reaches vswhere instead of being glob-expanded.



164
165
166
167
168
169
# File 'lib/vcvars/locator.rb', line 164

def vswhere(args)
  out, status = Open3.capture2(VSWHERE, *args)
  status.success? ? out : ""
rescue StandardError
  ""
end

.win(path) ⇒ Object

Normalize to native Windows separators for clean display (File.join mixes “/” into the backslash paths vswhere returns). Harmless for File.* / cmd.



122
123
124
# File 'lib/vcvars/locator.rb', line 122

def win(path)
  path.nil? ? path : path.tr("/", "\\")
end