vcdeps 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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ module Vcdeps
7
+ # Reads, validates, and canonically hashes a vcpkg manifest directory. The
8
+ # install key (§2.5) is SHA256 over the canonicalized vcpkg.json +
9
+ # vcpkg-configuration.json + triplet + tool version, so the same inputs always
10
+ # map to the same out-of-tree install root and any change forces a fresh one.
11
+ module Manifest
12
+ module_function
13
+
14
+ MANIFEST_NAME = "vcpkg.json"
15
+ CONFIGURATION_NAME = "vcpkg-configuration.json"
16
+
17
+ # Absolute path to <dir>\vcpkg.json (display-backslashed).
18
+ def manifest_path(dir)
19
+ File.join(dir, MANIFEST_NAME).tr("/", "\\")
20
+ end
21
+
22
+ def configuration_path(dir)
23
+ File.join(dir, CONFIGURATION_NAME)
24
+ end
25
+
26
+ # Read and JSON-parse a file with BOM tolerance (editors love BOMs).
27
+ # Returns the parsed Hash. Raises Vcdeps::ManifestError on a missing file or
28
+ # a JSON syntax error (carrying the parser's position message).
29
+ def read_json(path)
30
+ raw = File.read(path, encoding: "BOM|UTF-8")
31
+ JSON.parse(raw)
32
+ rescue Errno::ENOENT
33
+ raise ManifestError, "vcdeps: no manifest at #{path.tr('/', '\\')}. " \
34
+ "Create a vcpkg.json there (see `vcdeps doctor`)."
35
+ rescue JSON::ParserError => e
36
+ raise ManifestError, "vcdeps: #{File.basename(path)} is not valid JSON: " \
37
+ "#{e.message}"
38
+ end
39
+
40
+ # Pre-flight check (§5.1/§5.2): the manifest exists, parses, and either pins
41
+ # "builtin-baseline" OR has a vcpkg-configuration.json beside it (git-registry
42
+ # instances hard-fail otherwise). Returns the parsed manifest Hash.
43
+ def load!(dir)
44
+ path = File.join(dir, MANIFEST_NAME)
45
+ manifest = read_json(path)
46
+
47
+ unless manifest.is_a?(Hash)
48
+ raise ManifestError, "vcdeps: #{MANIFEST_NAME} must be a JSON object."
49
+ end
50
+
51
+ has_baseline = manifest.key?("builtin-baseline") &&
52
+ !manifest["builtin-baseline"].to_s.empty?
53
+ has_config = File.exist?(configuration_path(dir))
54
+
55
+ unless has_baseline || has_config
56
+ raise ManifestError, "vcdeps: #{manifest_path(dir)} has no " \
57
+ "\"builtin-baseline\" (required by git-registry vcpkg instances). " \
58
+ "Add one with: vcdeps baseline --manifest #{dir.tr('/', '\\')}"
59
+ end
60
+
61
+ manifest
62
+ end
63
+
64
+ # The 16-hex install key for (manifest dir, triplet, tool version) (§2.5).
65
+ def key(dir, triplet, tool_version)
66
+ manifest = read_json(File.join(dir, MANIFEST_NAME))
67
+ config_path = configuration_path(dir)
68
+ config = File.exist?(config_path) ? read_json(config_path) : nil
69
+
70
+ material = [
71
+ canonical(manifest),
72
+ config ? canonical(config) : "",
73
+ triplet.to_s,
74
+ tool_version.to_s
75
+ ].join("\0")
76
+
77
+ Digest::SHA256.hexdigest(material)[0, 16]
78
+ end
79
+
80
+ # Canonical JSON: recursively sort object keys, preserve array order,
81
+ # compact-generate. Two textually different but semantically identical
82
+ # manifests canonicalize identically.
83
+ def canonical(value)
84
+ JSON.generate(sort_value(value))
85
+ end
86
+
87
+ def sort_value(value)
88
+ case value
89
+ when Hash
90
+ value.keys.sort.each_with_object({}) do |k, h|
91
+ h[k] = sort_value(value[k])
92
+ end
93
+ when Array
94
+ value.map { |v| sort_value(v) }
95
+ else
96
+ value
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ # vcdeps/mkmf — the extconf.rb entry point. NEVER require this into a normal
4
+ # process: it requires "mkmf", which mutates global build state. Load it only
5
+ # from inside an extconf.rb, after `require "mkmf"` and the suite's mswin guard.
6
+ #
7
+ # require "vcdeps/mkmf"
8
+ # Vcdeps.mkmf!(vendor: File.expand_path("../../lib/foo/vendor", __dir__))
9
+
10
+ require "mkmf"
11
+ require "vcvars"
12
+ require "vcdeps"
13
+
14
+ module Vcdeps
15
+ module_function
16
+
17
+ # Install the manifest's ports and wire mkmf so the compile/link see the vcpkg
18
+ # tree FIRST (defeating opt-dir shadowing, R§8.4). Behavior in order (§2.4):
19
+ # 1. Vcvars.activate! (idempotent) so mkmf's own try_compile probes work.
20
+ # 2. --with-vcdeps-dir bypass: skip vcpkg, wire a prebuilt <prefix>.
21
+ # 3. install! (manifest defaults to the extension SOURCE dir — $srcdir).
22
+ # 4. PREPEND -I to $INCFLAGS and unshift lib_dir onto $LIBPATH.
23
+ # 5. vendor! when vendor: is given; else warn loudly if DLLs went unvendored.
24
+ #
25
+ # triplet precedence: kwarg > --with-vcdeps-triplet= > VCDEPS_TRIPLET > default.
26
+ # Returns the Installed. Raises everything install!/vendor! raise.
27
+ def mkmf!(manifest: nil, triplet: nil, vendor: nil, force: false)
28
+ # 1. Toolchain: a Vcvars::Error propagates (no cl == doomed; message names
29
+ # `vcvars doctor`). No-op when a dev env is already active (CI).
30
+ Vcvars.activate!
31
+
32
+ # triplet from --with-vcdeps-triplet (with_config) sits between kwarg and ENV.
33
+ cfg_triplet = mkmf_with_config("vcdeps-triplet")
34
+ resolved_triplet = triplet || (cfg_triplet unless cfg_triplet == true) || nil
35
+
36
+ # 2. Bypass: --with-vcdeps-dir=<prefix> wires a prebuilt tree, no vcpkg.
37
+ bypass = mkmf_with_config("vcdeps-dir")
38
+ installed =
39
+ if bypass && bypass != true
40
+ bypass_installed(File.expand_path(bypass), resolved_triplet)
41
+ else
42
+ manifest_dir = manifest || default_manifest_dir
43
+ install!(manifest: manifest_dir, triplet: resolved_triplet, force: force)
44
+ end
45
+
46
+ wire_mkmf!(installed)
47
+ handle_vendor(installed, vendor)
48
+
49
+ installed
50
+ end
51
+
52
+ # Wire mkmf: PREPEND the include dir to $INCFLAGS (it precedes $CPPFLAGS on the
53
+ # compile line) and unshift the lib dir onto $LIBPATH (renders before the
54
+ # opt-dir -libpath:). q() double-quotes a spaced path; $LIBPATH quoting is
55
+ # delegated to mkmf's libpathflag (verified to quote spaced paths).
56
+ def wire_mkmf!(installed)
57
+ inc = installed.include_dir
58
+ $INCFLAGS = "-I#{mkmf_quote(inc)} #{$INCFLAGS}"
59
+ $LIBPATH.unshift(installed.lib_dir)
60
+ installed
61
+ end
62
+
63
+ # Build an Installed for a --with-vcdeps-dir bypass (tool: nil; prefix taken
64
+ # as-is so include/lib/bin resolve under <prefix>; dlls from a bin\*.dll glob;
65
+ # ports: []).
66
+ def bypass_installed(prefix, triplet)
67
+ triplet ||= resolve_triplet_for_bypass
68
+ Installed.new(root: prefix, triplet: triplet, manifest_dir: prefix,
69
+ key: "bypass", tool: nil, prefix: prefix)
70
+ end
71
+
72
+ # The extension SOURCE dir: File.expand_path($srcdir) when mkmf defined it
73
+ # (under rake-compiler cwd is tmp/<arch>/... while vcpkg.json sits beside
74
+ # extconf.rb — $srcdir is the correct anchor), else Dir.pwd.
75
+ def default_manifest_dir
76
+ srcdir = (defined?($srcdir) && $srcdir) ? $srcdir : nil
77
+ srcdir ? File.expand_path(srcdir) : Dir.pwd
78
+ end
79
+
80
+ # vendor! when a vendor dir is given; else loudly warn that the built .so will
81
+ # need these DLLs at runtime and the .so's own dir is not searched (R§9.1).
82
+ def handle_vendor(installed, vendor)
83
+ if vendor
84
+ vendor!(installed, into: vendor)
85
+ elsif !installed.dlls.empty?
86
+ warn <<~MSG
87
+ [vcdeps] WARNING: this build links #{installed.dlls.size} runtime DLL(s)
88
+ that are NOT vendored. The built extension will fail to load at runtime
89
+ ("LoadError: 126: The specified module could not be found") because
90
+ Windows does not search the .so's own directory for dependent DLLs.
91
+ Pass `vendor:` to Vcdeps.mkmf! (so vcdeps copies the DLLs + a preload
92
+ shim into your gem), or build with --with-vcdeps-triplet=x64-windows-static-md
93
+ to link the libraries statically and ship no DLLs.
94
+ MSG
95
+ end
96
+ end
97
+
98
+ # --- mkmf seams (kept tiny so tests can reason about them) ----------------
99
+
100
+ # Read a --with-<name>[=value] flag via mkmf's with_config. Returns the value,
101
+ # true (bare flag), or nil/false (absent). Wrapped so a non-mkmf context is
102
+ # tolerated in unit tests.
103
+ def mkmf_with_config(name)
104
+ return nil unless respond_to?(:with_config, true) || defined?(with_config)
105
+
106
+ with_config(name)
107
+ rescue StandardError
108
+ nil
109
+ end
110
+
111
+ # Double-quote a path containing spaces for a naked -I flag (§5.20). Already
112
+ # backslashed by Installed.
113
+ def mkmf_quote(path)
114
+ path.to_s.include?(" ") ? %("#{path}") : path.to_s
115
+ end
116
+
117
+ def resolve_triplet_for_bypass
118
+ env = ENV["VCDEPS_TRIPLET"]
119
+ return env if env && !env.empty?
120
+
121
+ default_triplet
122
+ end
123
+
124
+ private_class_method :resolve_triplet_for_bypass
125
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "time"
6
+
7
+ module Vcdeps
8
+ # The ONLY place a vcpkg argv is constructed and spawned. Every `--x-*` flag
9
+ # lives in `args` so an upstream rename is a one-line fix (R§3/§5.13), and the
10
+ # runner test pins the exact argv. Combined child output is streamed to `out`
11
+ # line by line (never silent during a multi-minute port build); on any abnormal
12
+ # unwind the child is TerminateProcess'd and reaped so vcpkg.exe never outlives
13
+ # the call (§3.2).
14
+ module Runner
15
+ module_function
16
+
17
+ MARKER_NAME = ".vcdeps-complete"
18
+ LOG_TAIL_MAX = 4000
19
+
20
+ # Build the `vcpkg install` argv (§5.13 pin). All `--x-*` roots redirect out
21
+ # of the gem tree into <home> (R§7 path-length trap).
22
+ def install_args(exe, triplet, manifest_dir, install_root, home)
23
+ [
24
+ exe,
25
+ "install",
26
+ "--triplet", triplet,
27
+ "--x-manifest-root=#{manifest_dir}",
28
+ "--x-install-root=#{install_root}",
29
+ "--x-buildtrees-root=#{File.join(home, 'blds')}",
30
+ "--x-packages-root=#{File.join(home, 'pkgs')}"
31
+ ]
32
+ end
33
+
34
+ # The child environment: telemetry OFF by default (a gem install must not
35
+ # phone home, §5.15), VCPKG_ROOT pinned to the tool's own root. All other
36
+ # vcpkg env (binary cache, downloads) passes through untouched (§5.18).
37
+ def child_env(tool_root)
38
+ env = {}
39
+ env["VCPKG_DISABLE_METRICS"] = "1" unless ENV["VCDEPS_METRICS"] == "1"
40
+ env["VCPKG_ROOT"] = tool_root if tool_root
41
+ env
42
+ end
43
+
44
+ # Run `vcpkg install`. Streams combined output to `out` (nil to silence),
45
+ # returns the captured combined output on success, raises InstallError on a
46
+ # nonzero exit. `command` is the full argv (already built by install_args).
47
+ def run_install(command, env:, chdir:, out:)
48
+ run(command, env: env, chdir: chdir, out: out)
49
+ end
50
+
51
+ # Generic streamed runner shared by install! and baseline!. Raises
52
+ # InstallError on nonzero exit; the network-failure hint (§5.17) is appended
53
+ # by the caller via install_error so this stays generic.
54
+ def run(command, env:, chdir:, out:)
55
+ combined = +""
56
+
57
+ Open3.popen2e(env, *command, chdir: chdir) do |_stdin, oe, wt|
58
+ pid = wt.pid
59
+ oe.binmode
60
+ begin
61
+ # Buffered each_line dispatches to a scheduler's io_read hook on mswin
62
+ # (§6); reads block in VM I/O with the GVL released. Scrub to UTF-8.
63
+ oe.each_line do |raw|
64
+ line = raw.dup.force_encoding("UTF-8").scrub("�")
65
+ combined << line
66
+ out&.print(line)
67
+ out&.flush if out.respond_to?(:flush)
68
+ end
69
+ status = wt.value
70
+ unless status.success?
71
+ raise InstallError.new(
72
+ install_error_message(command, status.exitstatus, combined),
73
+ command: command, status: status.exitstatus,
74
+ log_tail: log_tail(combined)
75
+ )
76
+ end
77
+ rescue InstallError
78
+ raise
79
+ rescue Exception # rubocop:disable Lint/RescueException
80
+ # ANY abnormal unwind (Interrupt, Timeout, a raise from the out block):
81
+ # kill THIS vcpkg.exe so its install-root lock handle is released. We
82
+ # do it HERE, before Open3's block teardown tries to drain the pipe
83
+ # (which would otherwise block on a still-running child). TerminateProcess
84
+ # is not recursive — helper processes vcpkg spawned may briefly survive
85
+ # (§3.2/§5.16). The pipe is then closed so the drain returns at once.
86
+ begin
87
+ Process.kill(:KILL, pid)
88
+ rescue Errno::ESRCH, RangeError
89
+ nil
90
+ end
91
+ begin
92
+ oe.close
93
+ rescue IOError
94
+ nil
95
+ end
96
+ raise
97
+ end
98
+ end
99
+
100
+ combined
101
+ end
102
+
103
+ # Last <= LOG_TAIL_MAX chars, UTF-8 scrubbed.
104
+ def log_tail(combined)
105
+ tail = combined.length > LOG_TAIL_MAX ? combined[-LOG_TAIL_MAX..] : combined
106
+ tail.to_s.dup.force_encoding("UTF-8").scrub("�")
107
+ end
108
+
109
+ # Compose the InstallError message, appending the offline hint when the
110
+ # output looks network-shaped (§5.17).
111
+ def install_error_message(command, status, combined)
112
+ msg = "vcdeps: `#{File.basename(command.first)} #{command[1]}` exited #{status}."
113
+ if combined =~ /(failed to (fetch|download)|could not resolve|network)/i
114
+ msg += " First install of this baseline requires network access; " \
115
+ "see `vcdeps doctor` checks 9-10."
116
+ end
117
+ msg
118
+ end
119
+
120
+ # ---- completion marker (§2.5) -------------------------------------------
121
+
122
+ def marker_path(install_root)
123
+ File.join(install_root, MARKER_NAME)
124
+ end
125
+
126
+ def marker_present?(install_root)
127
+ File.exist?(marker_path(install_root))
128
+ end
129
+
130
+ # Write the marker atomically: temp file then File.rename (atomic on NTFS).
131
+ def write_marker!(install_root, key, tool_version)
132
+ payload = JSON.generate(
133
+ "key" => key,
134
+ "tool_version" => tool_version.to_s,
135
+ "finished_at" => Time.now.utc.iso8601
136
+ )
137
+ final = marker_path(install_root)
138
+ tmp = "#{final}.tmp.#{Process.pid}"
139
+ File.write(tmp, payload)
140
+ File.rename(tmp, final)
141
+ ensure
142
+ File.unlink(tmp) if tmp && File.exist?(tmp) && !File.exist?(final)
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "vcvars"
5
+
6
+ module Vcdeps
7
+ # Where a usable vcpkg lives. `source` is one of :env, :devenv, :vs, :actions,
8
+ # :private (see §2.2). `version` is the date stamp parsed from `vcpkg version`.
9
+ Tool = Struct.new(:exe, :root, :version, :source, keyword_init: true) do
10
+ # => "vcpkg 2026-02-21 (vs) at C:\...\VC\vcpkg"
11
+ def to_s
12
+ "vcpkg #{version || '?'} (#{source}) at #{root}"
13
+ end
14
+ end
15
+
16
+ # Locates a usable vcpkg instance (or bootstraps a private one). The
17
+ # resolution order and validation are the load-bearing integration logic
18
+ # (R§2.5): every candidate must have a real vcpkg.exe AND a ".vcpkg-root"
19
+ # marker before it is trusted, and a user-set VCPKG_INSTALLATION_ROOT that
20
+ # lies (runner-images #9269) is skipped rather than believed.
21
+ module ToolFinder
22
+ module_function
23
+
24
+ # The upstream standalone vcpkg.exe (the same artifact vcpkg-init downloads).
25
+ BOOTSTRAP_URL =
26
+ "https://github.com/microsoft/vcpkg-tool/releases/latest/download/vcpkg.exe"
27
+
28
+ # Memoized version per resolved exe path: avoids re-spawning `vcpkg version`.
29
+ def version_cache
30
+ @version_cache ||= {}
31
+ end
32
+
33
+ # Display-normalize to backslashes, like Vcvars::Locator.win.
34
+ def win(path)
35
+ path.nil? ? path : path.tr("/", "\\")
36
+ end
37
+
38
+ # A directory is a valid vcpkg root iff it holds vcpkg.exe AND a
39
+ # ".vcpkg-root" marker (R§2.2/R§2.3). Returns the absolute exe path or nil.
40
+ def valid_root(root)
41
+ return nil if root.nil? || root.to_s.empty?
42
+
43
+ exe = File.join(root, "vcpkg.exe")
44
+ marker = File.join(root, ".vcpkg-root")
45
+ return nil unless File.file?(exe) && File.exist?(marker)
46
+
47
+ File.expand_path(exe)
48
+ end
49
+
50
+ # Resolve a Tool, or nil. With bootstrap: true a miss bootstraps a private
51
+ # instance instead of returning nil. Resolution order is §2.3 steps 1-5.
52
+ def find(bootstrap: false, out: $stderr)
53
+ tool = resolve
54
+ return tool if tool
55
+ return nil unless bootstrap
56
+
57
+ Vcdeps.bootstrap!(out: out)
58
+ end
59
+
60
+ # The five-step resolution (no bootstrap). Returns a Tool or nil.
61
+ def resolve
62
+ # 1. Explicit VCPKG_ROOT — explicit beats implicit.
63
+ if (exe = valid_root(ENV["VCPKG_ROOT"]))
64
+ return build_tool(exe, :env)
65
+ end
66
+
67
+ # 2. Activate vcvars (idempotent, mswin only, errors swallowed) and read
68
+ # the VCPKG_ROOT the VS developer env sets. After activation the VS
69
+ # bundle's VCPKG_ROOT is usually already present (R§2.1).
70
+ activate_vcvars_quietly
71
+ if (exe = valid_root(ENV["VCPKG_ROOT"]))
72
+ return build_tool(exe, :devenv)
73
+ end
74
+
75
+ # 3. Vcvars.locate.vs_path + "\VC\vcpkg" probed directly (covers a located
76
+ # VS even if the activation env did not export the vcpkg extension).
77
+ if (vs = locate_vs_path) && (exe = valid_root(File.join(vs, "VC", "vcpkg")))
78
+ return build_tool(exe, :vs)
79
+ end
80
+
81
+ # 4. VCPKG_INSTALLATION_ROOT (GitHub Actions) — VALIDATED; a stale/wrong
82
+ # value (issue #9269) is skipped, not trusted.
83
+ if (exe = valid_root(ENV["VCPKG_INSTALLATION_ROOT"]))
84
+ return build_tool(exe, :actions)
85
+ end
86
+
87
+ # 5. A previously bootstrapped private instance under <home>\vcpkg.
88
+ if (exe = valid_root(File.join(Vcdeps.home, "vcpkg")))
89
+ return build_tool(exe, :private)
90
+ end
91
+
92
+ nil
93
+ end
94
+
95
+ # Build a Tool for a resolved exe, reading (and memoizing) its version.
96
+ def build_tool(exe, source)
97
+ Tool.new(exe: win(exe), root: win(File.dirname(exe)),
98
+ version: read_version(exe), source: source)
99
+ end
100
+
101
+ # Parse the date stamp from `vcpkg version`'s first line:
102
+ # "vcpkg package management program version 2026-02-21-<sha>..."
103
+ # -> "2026-02-21". Falls back to the whole token if the shape differs.
104
+ # Memoized per exe path.
105
+ def read_version(exe)
106
+ key = File.expand_path(exe)
107
+ return version_cache[key] if version_cache.key?(key)
108
+
109
+ version_cache[key] = capture_version(exe)
110
+ end
111
+
112
+ def capture_version(exe)
113
+ out, status = Open3.capture2(exe, "version")
114
+ return nil unless status&.success?
115
+
116
+ line = out.to_s.lines.first.to_s
117
+ # Grab a leading date "YYYY-MM-DD" from the version token if present.
118
+ if (m = line.match(/version\s+(\d{4}-\d{2}-\d{2})/))
119
+ m[1]
120
+ elsif (m = line.match(/(\d{4}-\d{2}-\d{2})/))
121
+ m[1]
122
+ else
123
+ line.strip.empty? ? nil : line.strip
124
+ end
125
+ rescue StandardError
126
+ nil
127
+ end
128
+
129
+ # Vcvars.activate! is idempotent and mswin-only; any failure here is not
130
+ # fatal to resolution (steps 3-5 may still find a tool), so swallow it.
131
+ def activate_vcvars_quietly
132
+ return unless Vcvars.mswin?
133
+
134
+ Vcvars.activate!
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def locate_vs_path
140
+ inst = Vcvars.locate
141
+ inst&.vs_path
142
+ rescue StandardError
143
+ nil
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Vcdeps
6
+ # Derives the vcpkg triplet for the running Ruby and validates user-supplied
7
+ # triplets. The default is the dynamic-CRT, dynamic-library triplet that
8
+ # matches an `-MD` mswin Ruby (x64 -> "x64-windows"); static-CRT triplets are
9
+ # rejected (the LNK2005/dual-heap hazard, R§4).
10
+ #
11
+ # No "x64" literal lives in code: the architecture token is derived from
12
+ # RbConfig::CONFIG["arch"], so arm64-mswin derives "arm64-windows" for free.
13
+ module Triplet
14
+ module_function
15
+
16
+ # Maps a Ruby arch string to its vcpkg architecture token, or nil if
17
+ # unrecognized. Mirrors Vcvars::Doctor.arch's regexes.
18
+ # "x64-mswin64_140" -> "x64"
19
+ # "arm64-mswin64_140" -> "arm64"
20
+ # "i386-mswin32" -> "x86"
21
+ def arch_token(arch)
22
+ a = arch.to_s
23
+ return "x64" if a =~ /\A(?:x64|x86_64|amd64)/i
24
+ return "arm64" if a =~ /arm64|aarch64/i
25
+ return "x86" if a =~ /\A(?:i[3-6]86|x86)/i
26
+ nil
27
+ end
28
+
29
+ # Known architecture prefixes a triplet may start with — used to detect an
30
+ # arch mismatch against the running Ruby.
31
+ ARCH_TOKENS = %w[x64 x86 arm64 arm].freeze
32
+
33
+ # The default triplet for the given Ruby arch (dynamic CRT, dynamic libs).
34
+ # Raises Vcdeps::TripletError for an unrecognized arch.
35
+ def default(arch = RbConfig::CONFIG["arch"])
36
+ token = arch_token(arch)
37
+ raise TripletError, "vcdeps: cannot derive a vcpkg triplet for arch " \
38
+ "#{arch.inspect} (recognized: x64, x86, arm64). Set VCDEPS_TRIPLET or " \
39
+ "pass --with-vcdeps-triplet=<triplet> explicitly." if token.nil?
40
+
41
+ "#{token}-windows"
42
+ end
43
+
44
+ # Validate a triplet against §2.6 and return it unchanged, or raise
45
+ # Vcdeps::TripletError. `arch` is the running Ruby's arch (for the mismatch
46
+ # check); pass it explicitly in tests to exercise the forward-compat path.
47
+ def validate!(triplet, arch: RbConfig::CONFIG["arch"])
48
+ t = triplet.to_s
49
+
50
+ unless t =~ /\A[a-z0-9][a-z0-9-]*\z/
51
+ raise TripletError, "vcdeps: invalid triplet #{triplet.inspect} " \
52
+ "(must match /\\A[a-z0-9][a-z0-9-]*\\z/). Run `vcdeps doctor`."
53
+ end
54
+
55
+ # Reject a static-CRT triplet (-static suffix). static-md (static libs +
56
+ # dynamic CRT) is fine; plain -static sets VCPKG_CRT_LINKAGE static.
57
+ if t =~ /-static\z/
58
+ raise TripletError, "vcdeps: triplet #{triplet.inspect} uses a STATIC " \
59
+ "CRT, which is ABI-incompatible with this -MD Ruby (LNK2005 / dual " \
60
+ "heap). Use #{t.sub(/-static\z/, '-static-md')} (static libs, dynamic " \
61
+ "CRT — no DLLs to vendor) or the default #{default(arch)}."
62
+ end
63
+
64
+ # If the triplet leads with a known arch token that differs from this
65
+ # Ruby's arch, it would LNK1112 at link time.
66
+ lead = t.split("-", 2).first
67
+ ruby_token = arch_token(arch)
68
+ if ARCH_TOKENS.include?(lead) && ruby_token && lead != ruby_token
69
+ raise TripletError, "vcdeps: triplet #{triplet.inspect} targets #{lead} " \
70
+ "but this Ruby is #{ruby_token} (#{arch}); linking would fail with " \
71
+ "LNK1112. Use a #{ruby_token}-* triplet (default #{default(arch)})."
72
+ end
73
+
74
+ t
75
+ end
76
+ end
77
+ end