vcvars 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+ require "vcvars/locator"
6
+
7
+ module Vcvars
8
+ # Captures the MSVC build environment produced by a vcvars*.bat script and
9
+ # imports it into the current process's ENV.
10
+ #
11
+ # vcvars cannot be "sourced" into a running process, so we run it inside a
12
+ # short-lived child cmd.exe, dump the resulting environment with `set`, and
13
+ # diff it against the current one. A unique marker line separates vcvars'
14
+ # banner noise from the `set` dump.
15
+ module Environment
16
+ module_function
17
+
18
+ MARKER = "___VCVARS_ENV_BEGIN___"
19
+
20
+ # Transient / shell-private variables that must never leak into the parent
21
+ # process. (Compared case-insensitively against upcased names.)
22
+ SKIP = %w[_ PROMPT ERRORLEVEL CMDCMDLINE].freeze
23
+
24
+ # True if a developer environment is already active in this process.
25
+ def active?
26
+ return true if ENV["VSCMD_VER"] && !ENV["VSCMD_VER"].empty?
27
+ cl_on_path?
28
+ end
29
+
30
+ def cl_on_path?
31
+ (ENV["PATH"] || "").split(File::PATH_SEPARATOR).any? do |dir|
32
+ !dir.empty? && File.exist?(File.join(dir, "cl.exe"))
33
+ end
34
+ end
35
+
36
+ # Locate VS, run vcvars, and import the resulting env into ENV.
37
+ # Idempotent: returns false (a no-op) when a dev env is already active,
38
+ # true when it actually activated. Pass force: true to re-import anyway.
39
+ def activate!(arch: Locator.default_arch, force: false)
40
+ return false if !force && active?
41
+
42
+ inst = Locator.find(arch: arch)
43
+ if inst.nil?
44
+ raise Error, "Could not find Visual Studio with the VC++ tools for " \
45
+ "arch #{arch.inspect}. Install the \"Desktop development with C++\" " \
46
+ "workload, or confirm vswhere.exe exists at #{Locator::VSWHERE}."
47
+ end
48
+
49
+ import!(capture(vcvars: inst.vcvars))
50
+ true
51
+ end
52
+
53
+ # The set of variables vcvars adds or changes relative to the current ENV
54
+ # (case-insensitive comparison). Does NOT mutate ENV. Handy for `vcvars env`.
55
+ def delta(arch: Locator.default_arch)
56
+ current = upcased_snapshot
57
+ captured = capture(arch: arch)
58
+ captured.reject do |k, v|
59
+ SKIP.include?(k.upcase) || current[k.upcase] == v
60
+ end
61
+ end
62
+
63
+ # Run vcvars in a child process and return the full captured environment
64
+ # as a Hash (does NOT mutate ENV). Provide an explicit vcvars path to skip
65
+ # location.
66
+ def capture(vcvars: nil, arch: Locator.default_arch)
67
+ vcvars ||= begin
68
+ inst = Locator.find(arch: arch)
69
+ raise Error, "Could not locate a vcvars batch script for arch #{arch.inspect}" if inst.nil?
70
+ inst.vcvars
71
+ end
72
+ raise Error, "vcvars script not found: #{vcvars}" unless File.exist?(vcvars)
73
+
74
+ out = run_capture_batch(vcvars)
75
+ parse_set_output(out)
76
+ end
77
+
78
+ # Parse the stdout of "<vcvars> ; echo MARKER ; set" into a Hash. Public so
79
+ # it can be unit-tested without invoking a real vcvars script.
80
+ def parse_set_output(out)
81
+ body = out.split(MARKER, 2)[1]
82
+ raise Error, "vcvars produced no environment (marker missing). " \
83
+ "Output head: #{out[0, 200].inspect}" if body.nil?
84
+
85
+ env = {}
86
+ body.each_line do |raw|
87
+ line = raw.chomp
88
+ i = line.index("=")
89
+ # Skip blank lines and Windows drive pseudo-vars ("=C:", "=ExitCode").
90
+ next if i.nil? || i.zero?
91
+ # Split on the FIRST "=" only — values legitimately contain "=".
92
+ env[line[0...i]] = line[(i + 1)..]
93
+ end
94
+
95
+ unless env.keys.any? { |k| k.casecmp("INCLUDE").zero? }
96
+ raise Error, "vcvars ran but did not set INCLUDE; the MSVC environment " \
97
+ "was not initialized (is the C++ workload installed?)."
98
+ end
99
+ env
100
+ end
101
+
102
+ # Import a captured env Hash into ENV: skip transient vars, and only write
103
+ # values that differ (case-insensitively) from the current ENV. Returns the
104
+ # hash. Public for testing.
105
+ def import!(env)
106
+ current = upcased_snapshot
107
+ env.each do |k, v|
108
+ next if SKIP.include?(k.upcase)
109
+ ENV[k] = v if current[k.upcase] != v
110
+ end
111
+ env
112
+ end
113
+
114
+ # --- internals -----------------------------------------------------------
115
+
116
+ def upcased_snapshot
117
+ snap = {}
118
+ ENV.each { |k, v| snap[k.upcase] = v }
119
+ snap
120
+ end
121
+
122
+ # Write a tiny batch that calls vcvars then dumps `set`, run it via a clean
123
+ # `cmd /c <file>` (a single unquoted token arg — avoids the embedded-quote
124
+ # escaping that breaks compound cmd lines on Windows), and return stdout.
125
+ def run_capture_batch(vcvars)
126
+ script = +"@echo off\r\n"
127
+ script << %{call "#{vcvars}"\r\n}
128
+ script << "echo #{MARKER}\r\n"
129
+ script << "set\r\n"
130
+
131
+ path = nil
132
+ begin
133
+ file = Tempfile.create(["vcvars_capture", ".bat"])
134
+ path = file.path
135
+ file.write(script)
136
+ file.close # must be closed before cmd.exe can read it on Windows
137
+
138
+ out, status = Open3.capture2("cmd.exe", "/c", path)
139
+ unless status.success?
140
+ raise Error, "vcvars batch exited #{status.exitstatus} (script: #{vcvars})"
141
+ end
142
+ out
143
+ ensure
144
+ File.unlink(path) if path && File.exist?(path)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+
6
+ module Vcvars
7
+ # Finds a Visual Studio / Build Tools installation and the arch-specific
8
+ # vcvars batch script that activates the MSVC build environment.
9
+ #
10
+ # Strategy (most robust first):
11
+ # 1. `vswhere -find VC\Auxiliary\Build\<vcvars>.bat` — returns the script
12
+ # path directly. This is used as the primary method because on some VS
13
+ # layouts (observed on the VS "18" / 2026 line) `-property
14
+ # installationPath` comes back empty, while `-find` still works.
15
+ # 2. `vswhere -property installationPath` + the known relative path.
16
+ # 3. `VSINSTALLDIR` (set when already inside a developer environment).
17
+ # 4. Scan well-known install roots (Program Files\Microsoft Visual Studio).
18
+ module Locator
19
+ module_function
20
+
21
+ VSWHERE = File.join(
22
+ ENV["ProgramFiles(x86)"] || 'C:\Program Files (x86)',
23
+ "Microsoft Visual Studio", "Installer", "vswhere.exe"
24
+ ).freeze
25
+
26
+ # Selects the newest STABLE install carrying the x64/x86 VC++ toolset.
27
+ # Prerelease/Insiders installs are only considered as a fallback (find
28
+ # retries with "-prerelease" appended), so a stable VS is preferred when
29
+ # both are present.
30
+ VSWHERE_SELECT = [
31
+ "-latest", "-products", "*",
32
+ "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64"
33
+ ].freeze
34
+
35
+ Installation = Struct.new(:vs_path, :vcvars, :arch, :version, :name, keyword_init: true) do
36
+ def to_s
37
+ "#{name || 'Visual Studio'}#{version ? " (#{version})" : ''} at #{vs_path}"
38
+ end
39
+ end
40
+
41
+ # The arch this Ruby was built for, e.g. "x64-mswin64_140".
42
+ def default_arch
43
+ RbConfig::CONFIG["arch"]
44
+ end
45
+
46
+ # Map a Ruby/target arch to the matching vcvars script name. The build host
47
+ # is assumed to be x64 (overwhelmingly true for Ruby on Windows), so arm64
48
+ # and x86 targets use the x64-hosted cross scripts.
49
+ def vcvars_name(arch)
50
+ case arch.to_s.downcase
51
+ when "x64", "amd64", "x86_64", /\Ax64\b/, /x86_64/, /amd64/ then "vcvars64.bat"
52
+ when "arm64", "aarch64", /arm64/, /aarch64/ then "vcvarsamd64_arm64.bat"
53
+ when "x86", "i386", "i686", /\Ai\d86/, /\Ax86\b/ then "vcvars32.bat"
54
+ else
55
+ raise ArgumentError, "unsupported arch for vcvars: #{arch.inspect}"
56
+ end
57
+ end
58
+
59
+ # Returns an Installation, or nil if no suitable VS install is found.
60
+ # Memoized per arch within a process (the install location is stable).
61
+ def find(arch: default_arch)
62
+ @cache ||= {}
63
+ key = arch.to_s
64
+ return @cache[key] if @cache.key?(key)
65
+
66
+ @cache[key] = resolve(arch)
67
+ end
68
+
69
+ def resolve(arch)
70
+ name = vcvars_name(arch)
71
+
72
+ if File.exist?(VSWHERE)
73
+ # Prefer a stable install; only then consider prerelease/Insiders.
74
+ [VSWHERE_SELECT, VSWHERE_SELECT + ["-prerelease"]].each do |select|
75
+ inst = resolve_via_vswhere(select, name, arch)
76
+ return inst if inst
77
+ end
78
+ end
79
+
80
+ bat = resolve_via_env(name) || scan_known_roots(name)
81
+ return nil unless bat
82
+
83
+ Installation.new(vs_path: win(vs_root_from(bat)), vcvars: win(bat),
84
+ arch: arch.to_s, version: nil, name: nil)
85
+ end
86
+
87
+ # Resolve a complete Installation from ONE vswhere selection, so the vcvars
88
+ # path and the reported metadata (version/name) describe the SAME install.
89
+ def resolve_via_vswhere(select, name, arch)
90
+ path = vswhere(select + ["-property", "installationPath"]).strip
91
+ bat = nil
92
+ unless path.empty?
93
+ cand = File.join(path, "VC", "Auxiliary", "Build", name)
94
+ bat = cand if File.exist?(cand)
95
+ end
96
+
97
+ # Some VS layouts return an empty installationPath; -find yields the path.
98
+ if bat.nil?
99
+ found = vswhere(select + ["-find", "VC\\Auxiliary\\Build\\#{name}"])
100
+ bat = found.lines.map(&:strip).find { |l| !l.empty? && File.exist?(l) }
101
+ return nil unless bat
102
+ path = vs_root_from(bat)
103
+ end
104
+
105
+ version = first_nonempty(
106
+ vswhere(select + ["-property", "installationVersion"]).strip,
107
+ vswhere(select + ["-property", "catalog_productDisplayVersion"]).strip
108
+ )
109
+ display = vswhere(select + ["-property", "displayName"]).strip
110
+
111
+ Installation.new(
112
+ vs_path: win(path),
113
+ vcvars: win(bat),
114
+ arch: arch.to_s,
115
+ version: version,
116
+ name: display.empty? ? nil : display
117
+ )
118
+ end
119
+
120
+ # Normalize to native Windows separators for clean display (File.join mixes
121
+ # "/" into the backslash paths vswhere returns). Harmless for File.* / cmd.
122
+ def win(path)
123
+ path.nil? ? path : path.tr("/", "\\")
124
+ end
125
+
126
+ # The vcvars*.bat under VSINSTALLDIR (set inside an active dev env), or nil.
127
+ def resolve_via_env(name)
128
+ vsdir = ENV["VSINSTALLDIR"]
129
+ return nil if vsdir.nil? || vsdir.empty?
130
+
131
+ cand = File.join(vsdir, "VC", "Auxiliary", "Build", name)
132
+ File.exist?(cand) ? cand : nil
133
+ end
134
+
135
+ def first_nonempty(*values)
136
+ values.find { |v| v && !v.empty? }
137
+ end
138
+
139
+ def scan_known_roots(name)
140
+ bases = [ENV["ProgramFiles"], ENV["ProgramFiles(x86)"]].compact.uniq
141
+ # VS 2026 uses a numeric "18" directory; 2022/2019/2017 use the year.
142
+ versions = %w[18 2022 2019 2017]
143
+ editions = %w[Preview Enterprise Professional Community BuildTools]
144
+ bases.each do |pf|
145
+ root = File.join(pf, "Microsoft Visual Studio")
146
+ versions.each do |ver|
147
+ editions.each do |ed|
148
+ cand = File.join(root, ver, ed, "VC", "Auxiliary", "Build", name)
149
+ return cand if File.exist?(cand)
150
+ end
151
+ end
152
+ end
153
+ nil
154
+ end
155
+
156
+ # The install root is four levels up from <root>\VC\Auxiliary\Build\x.bat.
157
+ def vs_root_from(bat)
158
+ File.dirname(File.dirname(File.dirname(File.dirname(bat))))
159
+ end
160
+
161
+ # Run vswhere with the given args; returns stdout ("" on any failure).
162
+ # Args are passed as a real argv (no shell), so the literal "*" in
163
+ # "-products *" reaches vswhere instead of being glob-expanded.
164
+ def vswhere(args)
165
+ out, status = Open3.capture2(VSWHERE, *args)
166
+ status.success? ? out : ""
167
+ rescue StandardError
168
+ ""
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vcvars"
4
+
5
+ # Requiring this file auto-activates the MSVC build environment, so that
6
+ # rake-compiler's `rake compile` (which hardcodes `nmake` and assumes cl.exe is
7
+ # already on PATH) works without first opening a "Developer Command Prompt".
8
+ #
9
+ # Usage — put this BEFORE Rake::ExtensionTask.new in your Rakefile:
10
+ #
11
+ # require "vcvars/rake"
12
+ # require "rake/extensiontask"
13
+ # Rake::ExtensionTask.new("my_ext", spec) { |e| e.lib_dir = "lib/my_ext" }
14
+ #
15
+ # It is a no-op off mswin Ruby, and a no-op inside an already-active developer
16
+ # environment. rake-compiler shells out with FileUtils#sh, which inherits this
17
+ # process's ENV, so activating once at Rakefile load time covers the extconf,
18
+ # nmake build, and nmake install steps.
19
+ if Vcvars.mswin?
20
+ begin
21
+ Vcvars.activate!
22
+ rescue Vcvars::Error => e
23
+ warn "[vcvars] could not load the MSVC environment: #{e.message}"
24
+ warn "[vcvars] run `vcvars doctor` to diagnose."
25
+ end
26
+ end
27
+
28
+ module Vcvars
29
+ # Optional belt-and-suspenders: call AFTER defining your compile task to also
30
+ # guarantee activation as a task dependency (covers sub-rake invocations that
31
+ # bypass the load-time activation above).
32
+ #
33
+ # Rake::ExtensionTask.new("my_ext", spec)
34
+ # Vcvars.enhance_compile_task!
35
+ def self.enhance_compile_task!(task_name = "compile")
36
+ return unless mswin?
37
+ return unless defined?(Rake) && Rake::Task.task_defined?(task_name)
38
+
39
+ Rake::Task.define_task("vcvars:activate") { Vcvars.activate! }
40
+ Rake::Task[task_name].enhance(["vcvars:activate"])
41
+ end
42
+ end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "vcvars/version"
5
+
6
+ module Vcvars
7
+ # Generates a new C-extension gem skeleton that is correct for an MSVC
8
+ # (mswin) Ruby: an MSVC-aware extconf.rb, a Rakefile wired to `vcvars/rake`
9
+ # (so `rake compile` works without a Developer Command Prompt), a
10
+ # warning-clean sample .c, and a passing test.
11
+ class Scaffold
12
+ NAME_RE = /\A[a-z][a-z0-9_]*\z/
13
+
14
+ attr_reader :name, :module_name, :dest
15
+
16
+ def initialize(name, dir: nil)
17
+ @name = name.to_s
18
+ unless @name =~ NAME_RE
19
+ raise Error, "invalid extension name #{name.inspect}: use lowercase " \
20
+ "letters, digits and underscores, starting with a letter (e.g. my_ext)."
21
+ end
22
+ @module_name = camelize(@name)
23
+ @dest = File.expand_path(dir || @name)
24
+ end
25
+
26
+ # Writes the skeleton. Returns the list of created file paths.
27
+ # Raises if the destination already contains files (unless force: true).
28
+ def generate(force: false)
29
+ if File.directory?(@dest) && !Dir.empty?(@dest) && !force
30
+ raise Error, "destination #{@dest} already exists and is not empty " \
31
+ "(pass force: true / --force to overwrite)."
32
+ end
33
+
34
+ created = []
35
+ files.each do |relpath, content|
36
+ full = File.join(@dest, relpath)
37
+ FileUtils.mkdir_p(File.dirname(full))
38
+ File.write(full, content)
39
+ created << full
40
+ end
41
+ created
42
+ end
43
+
44
+ # relpath => contents
45
+ def files
46
+ {
47
+ "#{name}.gemspec" => render(GEMSPEC),
48
+ "Gemfile" => render(GEMFILE),
49
+ "Rakefile" => render(RAKEFILE),
50
+ "README.md" => render(README),
51
+ "LICENSE.txt" => render(LICENSE),
52
+ ".gitignore" => render(GITIGNORE),
53
+ "lib/#{name}.rb" => render(LIB_ENTRY),
54
+ "lib/#{name}/version.rb" => render(LIB_VERSION),
55
+ "ext/#{name}/extconf.rb" => render(EXTCONF),
56
+ "ext/#{name}/#{name}.c" => render(EXT_C),
57
+ "test/test_#{name}.rb" => render(TEST)
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def camelize(str)
64
+ str.split("_").map { |p| p.empty? ? p : (p[0].upcase + p[1..]) }.join
65
+ end
66
+
67
+ def render(template)
68
+ template
69
+ .gsub("{{name}}", name)
70
+ .gsub("{{Name}}", module_name)
71
+ .gsub("{{vcvars_version}}", Vcvars::VERSION)
72
+ end
73
+
74
+ # --- templates -----------------------------------------------------------
75
+
76
+ GEMSPEC = <<~'RUBY'
77
+ # frozen_string_literal: true
78
+
79
+ require_relative "lib/{{name}}/version"
80
+
81
+ Gem::Specification.new do |spec|
82
+ spec.name = "{{name}}"
83
+ spec.version = {{Name}}::VERSION
84
+ spec.authors = ["Your Name"]
85
+ spec.email = ["you@example.com"]
86
+ spec.summary = "A native C extension for Ruby."
87
+ # NOTE: RubyGems rejects a description that begins with TODO/FIXME, so
88
+ # this ships as a valid placeholder — edit it, don't prefix it with TODO.
89
+ spec.description = "The {{name}} native C extension for Ruby, scaffolded by vcvars."
90
+ spec.homepage = "https://example.com/{{name}}"
91
+ spec.license = "MIT"
92
+ spec.required_ruby_version = ">= 3.0"
93
+
94
+ spec.files = Dir.glob(["lib/**/*.rb", "ext/**/*.{c,h,rb}", "README.md", "LICENSE*"])
95
+ spec.require_paths = ["lib"]
96
+
97
+ # Build the extension from source on `gem install`.
98
+ spec.extensions = ["ext/{{name}}/extconf.rb"]
99
+
100
+ spec.add_development_dependency "rake"
101
+ spec.add_development_dependency "rake-compiler"
102
+ spec.add_development_dependency "minitest"
103
+ # vcvars loads the MSVC toolchain so `rake compile` works on an mswin Ruby.
104
+ spec.add_development_dependency "vcvars", ">= {{vcvars_version}}"
105
+ end
106
+ RUBY
107
+
108
+ GEMFILE = <<~'RUBY'
109
+ # frozen_string_literal: true
110
+
111
+ source "https://rubygems.org"
112
+
113
+ gemspec
114
+ RUBY
115
+
116
+ RAKEFILE = <<~'RUBY'
117
+ # frozen_string_literal: true
118
+
119
+ require "vcvars/rake" # loads the MSVC build env on mswin Ruby (no-op elsewhere)
120
+ require "rake/extensiontask"
121
+ require "rake/testtask"
122
+
123
+ spec = Gem::Specification.load("{{name}}.gemspec")
124
+
125
+ Rake::ExtensionTask.new("{{name}}", spec) do |ext|
126
+ ext.lib_dir = "lib/{{name}}" # built {{name}}.so lands at lib/{{name}}/{{name}}.so
127
+ end
128
+
129
+ Rake::TestTask.new(test: :compile) do |t|
130
+ t.libs << "test" << "lib"
131
+ t.test_files = FileList["test/**/test_*.rb"]
132
+ t.warning = false
133
+ end
134
+
135
+ task default: :test
136
+ RUBY
137
+
138
+ LIB_ENTRY = <<~'RUBY'
139
+ # frozen_string_literal: true
140
+
141
+ require "{{name}}/version"
142
+ require "{{name}}/{{name}}" # the compiled C extension (lib/{{name}}/{{name}}.so)
143
+
144
+ module {{Name}}
145
+ end
146
+ RUBY
147
+
148
+ LIB_VERSION = <<~'RUBY'
149
+ # frozen_string_literal: true
150
+
151
+ module {{Name}}
152
+ VERSION = "0.1.0"
153
+ end
154
+ RUBY
155
+
156
+ EXTCONF = <<~'RUBY'
157
+ # frozen_string_literal: true
158
+ #
159
+ # extconf.rb for the {{name}} C extension.
160
+ #
161
+ # mkmf reads RbConfig and emits the right Makefile per platform. On an
162
+ # MSVC (mswin) Ruby it generates an *nmake* Makefile whose compile line is
163
+ # "cl -nologo $(INCFLAGS) -MD ... -Fo$(@) -c -Tc<src>". The -MD (dynamic
164
+ # CRT) flag is baked into RbConfig, so every object links against the same
165
+ # CRT as Ruby. NEVER force -MT/-MTd: a static-CRT extension mixed with
166
+ # Ruby's dynamic CRT causes LNK2005 duplicate symbols and heap corruption.
167
+ #
168
+ # The Ruby import library (e.g. x64-vcruntime140-ruby340.lib) is added to
169
+ # the link line automatically — you do not name or locate it here.
170
+
171
+ require "mkmf"
172
+
173
+ # Optional: wrap an external library (adds --with-foo-dir/-include/-lib,
174
+ # and is mswin-aware: -I to $INCFLAGS, -libpath: to $LIBPATH).
175
+ # dir_config("foo")
176
+ # have_header("foo.h") or abort "missing foo.h"
177
+ # have_library("foo", "foo_init") or abort "missing foo.lib" # bare name; no -l on mswin
178
+
179
+ # append_cflags probes each flag with warnings-as-errors; cl rejects
180
+ # GCC-style flags like -std=gnu11, so this is a safe no-op on MSVC and only
181
+ # applies on gcc/clang (MinGW/UCRT/Unix).
182
+ append_cflags(["-std=c11"])
183
+
184
+ # "{{name}}/{{name}}" installs the .so under {{name}}/ so it can be required
185
+ # as "{{name}}/{{name}}". On mswin the artifact is {{name}}.so (DLEXT is "so").
186
+ create_makefile("{{name}}/{{name}}")
187
+ RUBY
188
+
189
+ EXT_C = <<~'C'
190
+ #include <ruby.h>
191
+
192
+ /*
193
+ * On mswin Ruby the Init_ entry point is exported automatically by mkmf via
194
+ * an auto-generated .def file passed to the linker — do NOT add
195
+ * __declspec(dllexport)/RUBY_FUNC_EXPORTED here.
196
+ *
197
+ * Ruby's CFLAGS promote several warnings to errors (-we4028 -we4047 -we4013
198
+ * -we4142). Including <ruby.h>, using correct VALUE signatures, the
199
+ * RUBY_METHOD_FUNC cast, and the NUM2INT/INT2NUM + rb_str_* APIs keeps the
200
+ * build warning- and error-clean. Unused params (C4100) are suppressed by
201
+ * Ruby's own flags, so an unused `self` is fine.
202
+ */
203
+
204
+ /* Adds two integers and returns the sum. */
205
+ static VALUE
206
+ {{name}}_add(VALUE self, VALUE a, VALUE b)
207
+ {
208
+ int x = NUM2INT(a);
209
+ int y = NUM2INT(b);
210
+ return INT2NUM(x + y);
211
+ }
212
+
213
+ /* Returns a greeting string for the given name. */
214
+ static VALUE
215
+ {{name}}_greet(VALUE self, VALUE name)
216
+ {
217
+ VALUE who = StringValue(name); /* validate/coerce to String */
218
+ VALUE out = rb_str_new_cstr("Hello, ");
219
+ rb_str_append(out, who);
220
+ rb_str_cat_cstr(out, "!");
221
+ return out;
222
+ }
223
+
224
+ void
225
+ Init_{{name}}(void)
226
+ {
227
+ VALUE m{{Name}} = rb_define_module("{{Name}}");
228
+ rb_define_singleton_method(m{{Name}}, "add", RUBY_METHOD_FUNC({{name}}_add), 2);
229
+ rb_define_singleton_method(m{{Name}}, "greet", RUBY_METHOD_FUNC({{name}}_greet), 1);
230
+ }
231
+ C
232
+
233
+ TEST = <<~'RUBY'
234
+ # frozen_string_literal: true
235
+
236
+ require "minitest/autorun"
237
+ require "{{name}}"
238
+
239
+ class {{Name}}Test < Minitest::Test
240
+ def test_add
241
+ assert_equal 5, {{Name}}.add(2, 3)
242
+ assert_equal(-1, {{Name}}.add(2, -3))
243
+ end
244
+
245
+ def test_greet
246
+ assert_equal "Hello, Ruby!", {{Name}}.greet("Ruby")
247
+ end
248
+ end
249
+ RUBY
250
+
251
+ README = <<~'MARKDOWN'
252
+ # {{name}}
253
+
254
+ A native C extension for Ruby, scaffolded by [vcvars](https://rubygems.org/gems/vcvars).
255
+
256
+ ## Build & test
257
+
258
+ On an MSVC (mswin) Ruby you do **not** need to open a Developer Command
259
+ Prompt — the `Rakefile` requires `vcvars/rake`, which locates Visual Studio
260
+ and loads the MSVC toolchain automatically.
261
+
262
+ ```sh
263
+ bundle install
264
+ rake compile # builds ext/{{name}}/{{name}}.c -> lib/{{name}}/{{name}}.so
265
+ rake test
266
+ ```
267
+
268
+ If a build fails, run `vcvars doctor` to diagnose the toolchain.
269
+
270
+ ## Usage
271
+
272
+ ```ruby
273
+ require "{{name}}"
274
+
275
+ {{Name}}.add(2, 3) # => 5
276
+ {{Name}}.greet("Ruby") # => "Hello, Ruby!"
277
+ ```
278
+ MARKDOWN
279
+
280
+ LICENSE = <<~'TEXT'
281
+ MIT License
282
+
283
+ Copyright (c) 2026 Your Name
284
+
285
+ Permission is hereby granted, free of charge, to any person obtaining a copy
286
+ of this software and associated documentation files (the "Software"), to deal
287
+ in the Software without restriction, including without limitation the rights
288
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
289
+ copies of the Software, and to permit persons to whom the Software is
290
+ furnished to do so, subject to the following conditions:
291
+
292
+ The above copyright notice and this permission notice shall be included in all
293
+ copies or substantial portions of the Software.
294
+
295
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
296
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
297
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
298
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
299
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
300
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
301
+ SOFTWARE.
302
+ TEXT
303
+
304
+ GITIGNORE = <<~'TEXT'
305
+ /pkg/
306
+ /tmp/
307
+ /.bundle/
308
+ Gemfile.lock
309
+
310
+ # build artifacts
311
+ *.o
312
+ *.obj
313
+ *.so
314
+ *.dll
315
+ *.lib
316
+ *.exp
317
+ *.pdb
318
+ *.def
319
+ Makefile
320
+ mkmf.log
321
+ /lib/{{name}}/{{name}}.so
322
+ TEXT
323
+ end
324
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vcvars
4
+ VERSION = "0.1.0"
5
+ end