Module: Vcvars::Doctor

Defined in:
lib/vcvars/doctor.rb

Overview

Diagnoses the common reasons a native C-extension build fails under an MSVC (mswin) Ruby on Windows, and explains how to fix each one. The checks are derived from the actual RbConfig of the running Ruby plus live probes of the toolchain, so the advice is specific rather than generic.

Defined Under Namespace

Classes: Check

Class Method Summary collapse

Class Method Details

.archObject

Target machine the linker must produce: :x64 | :x86 | :arm64 | :unknown



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

def arch
  a = RbConfig::CONFIG["arch"].to_s
  return :x64   if a =~ /\A(?:x64|x86_64|amd64)/i
  return :arm64 if a =~ /arm64|aarch64/i
  return :x86   if a =~ /\A(?:i[3-6]86|x86)/i
  :unknown
end

.crt_conflict(ext_cflags, ruby_crt = crt_flag) ⇒ Object

If the given extension flags use a static CRT while Ruby uses a dynamic one (or vice-versa), return the offending flag (:MT/:MTd/:MD/:MDd); else nil.



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

def crt_conflict(ext_cflags, ruby_crt = crt_flag)
  return nil if ext_cflags.to_s.empty?
  ruby_dynamic = %i[MD MDd].include?(ruby_crt)
  if ruby_dynamic && ext_cflags =~ %r{(?:^|\s)[-/]MT(d?)\b}
    Regexp.last_match(1) == "d" ? :MTd : :MT
  elsif !ruby_dynamic && ext_cflags =~ %r{(?:^|\s)[-/]MD(d?)\b}
    Regexp.last_match(1) == "d" ? :MDd : :MD
  end
end

.crt_flag(cflags = RbConfig::CONFIG["CFLAGS"].to_s) ⇒ Object

CRT linkage Ruby itself was built with: :MD | :MDd | :MT | :MTd | :unknown



40
41
42
43
44
45
46
47
48
# File 'lib/vcvars/doctor.rb', line 40

def crt_flag(cflags = RbConfig::CONFIG["CFLAGS"].to_s)
  case cflags
  when %r{(?:^|\s)[-/]MDd\b} then :MDd
  when %r{(?:^|\s)[-/]MTd\b} then :MTd
  when %r{(?:^|\s)[-/]MD\b}  then :MD
  when %r{(?:^|\s)[-/]MT\b}  then :MT
  else :unknown
  end
end

.env_checksObject



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/vcvars/doctor.rb', line 155

def env_checks
  out = []
  if Environment.active?
    cl = which("cl") || "(on PATH)"
    out << Check.new(status: :ok, label: "Developer environment is ACTIVE in this process",
                     detail: "cl: #{cl}")
    out << if (ENV["LIB"] || "").strip.empty?
      Check.new(status: :warn, label: "LIB is empty despite an active dev env",
                detail: "The linker may fail with LNK1104. Re-activate with a full vcvars.")
    else
      Check.new(status: :ok, label: "LIB / library search path is set", detail: nil)
    end
    if ENV["VSCMD_ARG_TGT_ARCH"] && !ENV["VSCMD_ARG_TGT_ARCH"].casecmp?(arch.to_s)
      out << Check.new(status: :fail,
                       label: "Toolchain arch (#{ENV['VSCMD_ARG_TGT_ARCH']}) != Ruby arch (#{arch})",
                       detail: "Causes LNK1112. For #{arch} Ruby use vcvars64.bat, not vcvars32.bat.")
    end
  else
    out << Check.new(status: :warn, label: "Developer environment is NOT active",
                     detail: "cl.exe / nmake.exe are not on PATH, so `rake compile` / " \
                             "`gem install <native>` will fail with \"'nmake' is not " \
                             "recognized\". Use `vcvars exec -- <cmd>`, `require \"vcvars/rake\"` " \
                             "in your Rakefile, or `vcvars env` to load it.")
  end
  out
end

.header_checksObject



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# File 'lib/vcvars/doctor.rb', line 135

def header_checks
  out = []
  out << if File.exist?(ruby_header_path)
    Check.new(status: :ok, label: "Ruby headers present", detail: ruby_header_path)
  else
    Check.new(status: :fail, label: "ruby.h NOT found",
              detail: "Expected at #{ruby_header_path}. C1083 'Cannot open include " \
                      "file' results. Reinstall Ruby's dev headers, and always run " \
                      "`ruby extconf.rb` (which injects the -I paths) before nmake.")
  end
  out << if File.exist?(import_lib_path)
    Check.new(status: :ok, label: "Ruby import library present", detail: import_lib_path)
  else
    Check.new(status: :fail, label: "Ruby import library NOT found",
              detail: "Expected #{import_lib_path}. Missing it causes LNK2019/LNK1104. " \
                      "Link through mkmf, which adds it via LIBRUBYARG.")
  end
  out
end

.healthy?(checks) ⇒ Boolean

Convenience: did the run surface any hard failure?

Returns:

  • (Boolean)


238
239
240
# File 'lib/vcvars/doctor.rb', line 238

def healthy?(checks)
  checks.none? { |c| c.status == :fail }
end

.hygiene_checksObject



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/vcvars/doctor.rb', line 214

def hygiene_checks
  out = []
  # A GNU `link` (from Git/MSYS) on PATH shadows the MSVC linker and causes
  # baffling link failures.
  link = which("link")
  if link && link =~ %r{[\\/](Git|usr|msys|mingw)[\\/]}i
    out << Check.new(status: :warn, label: "A non-MSVC `link` is on PATH",
                     detail: "#{link} is the GNU coreutils `link`, not the MSVC linker. " \
                             "Inside an active dev env the MSVC link.exe should win; if you " \
                             "see strange linker errors, check PATH order.")
  end

  # User-injected CRT flags that would conflict with Ruby's CRT.
  ext_flags = "#{ENV['CL']} #{ENV['CFLAGS']}".strip
  if (bad = crt_conflict(ext_flags))
    out << Check.new(status: :fail, label: "CRT conflict in CL/CFLAGS: #{bad}",
                     detail: "Ruby uses #{crt_flag}; your environment forces #{bad}. " \
                             "This causes LNK2005 'already defined' and runtime heap " \
                             "corruption. Remove /#{bad} from the CL and CFLAGS env vars.")
  end
  out
end

.import_lib_pathObject



76
77
78
# File 'lib/vcvars/doctor.rb', line 76

def import_lib_path
  File.join(RbConfig::CONFIG["libdir"], RbConfig::CONFIG["LIBRUBYARG_SHARED"].to_s)
end

.mswin?Boolean

—- RbConfig-based primitives (cheap, no shell-out) ——————–

Returns:

  • (Boolean)


25
26
27
# File 'lib/vcvars/doctor.rb', line 25

def mswin?
  RbConfig::CONFIG["target_os"].to_s =~ /mswin/ ? true : false
end

.ruby_checksObject



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/vcvars/doctor.rb', line 109

def ruby_checks
  ver = "#{RUBY_VERSION} #{RbConfig::CONFIG['arch']}"
  tc = toolchain
  out = []
  out << if mswin?
    Check.new(status: :ok, label: "Ruby is a native MSVC (mswin) build",
              detail: "#{ver}  RUBY_SO_NAME=#{RbConfig::CONFIG['RUBY_SO_NAME']}")
  else
    Check.new(status: :warn, label: "Ruby is NOT an mswin build (#{tc})",
              detail: "vcvars targets the MSVC (mswin) Ruby. On a MinGW/UCRT " \
                      "Ruby use RubyInstaller's `ridk enable` instead — the MSVC " \
                      "toolchain is ABI-incompatible with this build.")
  end
  out << Check.new(status: :info, label: "Ruby CRT linkage: #{crt_flag}",
                   detail: "Extensions must use the same CRT. mkmf inherits this " \
                           "automatically — never force /MT or /MTd.")
  we = werror_flags
  unless we.empty?
    out << Check.new(status: :info, label: "Warnings promoted to errors: #{we.join(' ')}",
                     detail: "Missing prototypes (C4013) and pointer/type mismatches " \
                             "(C4047/C4028) will hard-fail. Include the right headers; " \
                             "do not strip these flags.")
  end
  out
end

.ruby_header_pathObject



80
81
82
# File 'lib/vcvars/doctor.rb', line 80

def ruby_header_path
  File.join(RbConfig::CONFIG["rubyhdrdir"].to_s, "ruby.h")
end

.run(deep: true) ⇒ Object

Returns an Array<Check>. When deep: true, also locates VS and tries a real environment capture (so it can confirm a build would actually find cl).



99
100
101
102
103
104
105
106
107
# File 'lib/vcvars/doctor.rb', line 99

def run(deep: true)
  checks = []
  checks.concat(ruby_checks)
  checks.concat(header_checks)
  checks.concat(env_checks)
  checks.concat(vs_checks(deep: deep))
  checks.concat(hygiene_checks)
  checks
end

.toolchainObject

:mswin | :mingw_ucrt | :mingw_msvcrt | :other



30
31
32
33
34
35
36
37
# File 'lib/vcvars/doctor.rb', line 30

def toolchain
  os = RbConfig::CONFIG["target_os"].to_s
  so = RbConfig::CONFIG["RUBY_SO_NAME"].to_s
  return :mswin        if os =~ /mswin/
  return :mingw_ucrt   if so =~ /ucrt/i || os =~ /ucrt/i
  return :mingw_msvcrt if os =~ /mingw/
  :other
end

.vs_checks(deep:) ⇒ Object



182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
# File 'lib/vcvars/doctor.rb', line 182

def vs_checks(deep:)
  out = []
  inst = Locator.find
  if inst.nil?
    out << Check.new(status: :fail, label: "No Visual Studio with C++ tools found",
                     detail: "Looked via vswhere (#{Locator::VSWHERE}) and known install " \
                             "roots. Install the \"Desktop development with C++\" workload.")
    return out
  end

  out << Check.new(status: :ok, label: "Visual Studio located", detail: inst.to_s)
  out << Check.new(status: :info, label: "vcvars script", detail: inst.vcvars)

  return out unless deep

  begin
    captured = Environment.capture(vcvars: inst.vcvars)
    cl_dir = (captured.find { |k, _| k.casecmp("PATH").zero? }&.last || "")
             .split(File::PATH_SEPARATOR).find { |d| !d.empty? && File.exist?(File.join(d, "cl.exe")) }
    out << if cl_dir
      Check.new(status: :ok, label: "vcvars activation works (cl.exe resolvable)",
                detail: File.join(cl_dir, "cl.exe"))
    else
      Check.new(status: :warn, label: "vcvars ran but cl.exe was not found on the new PATH",
                detail: "The C++ toolset may not be installed for this arch.")
    end
  rescue Error => e
    out << Check.new(status: :fail, label: "vcvars activation failed", detail: e.message)
  end
  out
end

.werror_flags(cflags = RbConfig::CONFIG["CFLAGS"].to_s) ⇒ Object

Warnings Ruby promotes to hard errors, e.g. [“-we4028”, “-we4047”].



72
73
74
# File 'lib/vcvars/doctor.rb', line 72

def werror_flags(cflags = RbConfig::CONFIG["CFLAGS"].to_s)
  cflags.scan(/-we\d{4}/).uniq
end

.which(tool) ⇒ Object

‘where <tool>` prints “INFO: Could not find files…” and exits non-zero when a tool is absent. Returns the resolved path, or nil.



86
87
88
89
90
91
92
93
# File 'lib/vcvars/doctor.rb', line 86

def which(tool)
  out, status = Open3.capture2("where", tool)
  return nil unless status.success?
  line = out.lines.map(&:strip).find { |l| !l.empty? && l !~ /Could not find/ }
  line
rescue StandardError
  nil
end