cmake: add ghostty_vt_add_target() for cross-compilation (#12212)

Add a ghostty_vt_add_target() CMake function that lets downstream
projects build libghostty-vt for a specific Zig target triple. The
function encapsulates zig discovery, build-type-to-optimize mapping, the
zig build invocation, and output path conventions so consumers do not
need to duplicate any of that logic. It creates named IMPORTED targets
(e.g. ghostty-vt-static-linux-amd64) that work alongside the existing
native ghostty-vt and ghostty-vt-static targets.

The build-type mapping is factored into a shared _GHOSTTY_ZIG_OPT_FLAG
variable used by both the native build and the new function.

A new example/c-vt-cmake-cross/ demonstrates end-to-end cross-
compilation using zig cc as the C compiler, auto-detecting a cross
target based on the host OS.
This commit is contained in:
Mitchell Hashimoto
2026-04-10 08:00:03 -07:00
committed by GitHub
9 changed files with 472 additions and 31 deletions

View File

@@ -278,6 +278,11 @@ jobs:
fail-fast: false
matrix:
dir: ${{ fromJSON(needs.list-examples.outputs.cmake) }}
exclude:
# Cross-compilation with zig cc requires a single-config
# generator (Makefiles/Ninja). The Windows CI uses Visual
# Studio which always uses MSVC and ignores CMAKE_C_COMPILER.
- dir: c-vt-cmake-cross
name: Example ${{ matrix.dir }} (Windows)
runs-on: namespace-profile-ghostty-windows
timeout-minutes: 45

View File

@@ -44,8 +44,27 @@
# target_link_libraries(myapp PRIVATE ghostty-vt::ghostty-vt) # shared
# target_link_libraries(myapp PRIVATE ghostty-vt::ghostty-vt-static) # static
#
# See dist/cmake/README.md for more details and example/c-vt-cmake/ for a
# complete working example.
# Cross-compilation
# -------------------
#
# For building libghostty-vt for a non-native Zig target (e.g. cross-
# compiling), use the ghostty_vt_add_target() function after FetchContent:
#
# FetchContent_MakeAvailable(ghostty)
# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu)
#
# target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64) # static
# target_link_libraries(myapp PRIVATE ghostty-vt-linux-amd64) # shared
#
# This handles zig discovery, build-type-to-optimize mapping, and output
# path conventions internally. Extra flags can be forwarded with ZIG_FLAGS:
#
# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu
# ZIG_FLAGS -Dsimd=false)
#
# See dist/cmake/README.md for more details, example/c-vt-cmake/ for a
# complete working example, and example/c-vt-cmake-cross/ for a cross-
# compilation example.
cmake_minimum_required(VERSION 3.19)
project(ghostty-vt VERSION 0.1.0 LANGUAGES C)
@@ -54,15 +73,22 @@ project(ghostty-vt VERSION 0.1.0 LANGUAGES C)
set(GHOSTTY_ZIG_BUILD_FLAGS "" CACHE STRING "Additional flags to pass to zig build")
# Map CMake build types to Zig optimization levels.
# Map CMake build types to Zig optimization levels. The result is stored in
# _GHOSTTY_ZIG_OPT_FLAG so both the native build and ghostty_vt_add_target()
# can reuse it without duplicating the mapping logic.
set(_GHOSTTY_ZIG_OPT_FLAG "")
if(CMAKE_BUILD_TYPE)
string(TOUPPER "${CMAKE_BUILD_TYPE}" _bt)
if(_bt STREQUAL "RELEASE" OR _bt STREQUAL "MINSIZEREL" OR _bt STREQUAL "RELWITHDEBINFO")
list(APPEND GHOSTTY_ZIG_BUILD_FLAGS "-Doptimize=ReleaseFast")
set(_GHOSTTY_ZIG_OPT_FLAG "-Doptimize=ReleaseFast")
endif()
unset(_bt)
endif()
if(_GHOSTTY_ZIG_OPT_FLAG)
list(APPEND GHOSTTY_ZIG_BUILD_FLAGS "${_GHOSTTY_ZIG_OPT_FLAG}")
endif()
# --- Find Zig ----------------------------------------------------------------
find_program(ZIG_EXECUTABLE zig REQUIRED)
@@ -229,3 +255,128 @@ install(
"${CMAKE_CURRENT_BINARY_DIR}/ghostty-vt-config-version.cmake"
DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/ghostty-vt"
)
# --- Cross-compilation helper ------------------------------------------------
#
# For downstream projects that need to build libghostty-vt for a specific
# Zig target triple. For native builds, use the IMPORTED targets above
# (ghostty-vt, ghostty-vt-static) directly.
#
# Usage (in a downstream CMakeLists.txt after FetchContent_MakeAvailable):
#
# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu)
#
# Creates:
# ghostty-vt-static-linux-amd64 (IMPORTED STATIC library)
# ghostty-vt-linux-amd64 (IMPORTED SHARED library)
#
# Optional ZIG_FLAGS to pass additional flags to zig build:
#
# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu
# ZIG_FLAGS -Dsimd=false)
function(ghostty_vt_add_target)
cmake_parse_arguments(PARSE_ARGV 0 _GVT "" "NAME;ZIG_TARGET" "ZIG_FLAGS")
if(NOT _GVT_NAME)
message(FATAL_ERROR "ghostty_vt_add_target: NAME is required")
endif()
if(NOT _GVT_ZIG_TARGET)
message(FATAL_ERROR "ghostty_vt_add_target: ZIG_TARGET is required")
endif()
set(_src_dir "${CMAKE_CURRENT_FUNCTION_LIST_DIR}")
set(_prefix "${CMAKE_CURRENT_BINARY_DIR}/ghostty-${_GVT_NAME}")
# Build flags
set(_flags
-Demit-lib-vt
-Dtarget=${_GVT_ZIG_TARGET}
--prefix "${_prefix}"
)
# Default to ReleaseFast when no build type is set. Debug builds enable
# UBSan in zig, and the sanitizer runtime is not available for all
# cross-compilation targets.
if(_GHOSTTY_ZIG_OPT_FLAG)
list(APPEND _flags "${_GHOSTTY_ZIG_OPT_FLAG}")
else()
list(APPEND _flags "-Doptimize=ReleaseFast")
endif()
if(_GVT_ZIG_FLAGS)
list(APPEND _flags ${_GVT_ZIG_FLAGS})
endif()
# Output paths
set(_include_dir "${_prefix}/include")
if(_GVT_ZIG_TARGET MATCHES "windows")
set(_static_lib "${_prefix}/lib/ghostty-vt-static.lib")
set(_shared_lib "${_prefix}/bin/ghostty-vt.dll")
set(_implib "${_prefix}/lib/ghostty-vt.lib")
elseif(_GVT_ZIG_TARGET MATCHES "darwin|macos")
set(_static_lib "${_prefix}/lib/libghostty-vt.a")
set(_shared_lib "${_prefix}/lib/libghostty-vt.0.1.0.dylib")
else()
set(_static_lib "${_prefix}/lib/libghostty-vt.a")
set(_shared_lib "${_prefix}/lib/libghostty-vt.so.0.1.0")
endif()
file(MAKE_DIRECTORY "${_include_dir}")
# Custom command: invoke zig build
add_custom_command(
OUTPUT "${_static_lib}" "${_shared_lib}"
COMMAND "${ZIG_EXECUTABLE}" build ${_flags}
WORKING_DIRECTORY "${_src_dir}"
COMMENT "Building libghostty-vt for ${_GVT_ZIG_TARGET}..."
USES_TERMINAL
)
set(_build_target "zig_build_lib_vt_${_GVT_NAME}")
add_custom_target(${_build_target} ALL
DEPENDS "${_static_lib}" "${_shared_lib}"
)
# Static target
set(_static_target "ghostty-vt-static-${_GVT_NAME}")
add_library(${_static_target} STATIC IMPORTED GLOBAL)
set_target_properties(${_static_target} PROPERTIES
IMPORTED_LOCATION "${_static_lib}"
INTERFACE_INCLUDE_DIRECTORIES "${_include_dir}"
INTERFACE_COMPILE_DEFINITIONS "GHOSTTY_STATIC"
)
if(_GVT_ZIG_TARGET MATCHES "windows")
set_target_properties(${_static_target} PROPERTIES
INTERFACE_LINK_LIBRARIES "c++;ntdll;kernel32"
)
else()
set_target_properties(${_static_target} PROPERTIES
INTERFACE_LINK_LIBRARIES "c++"
)
endif()
add_dependencies(${_static_target} ${_build_target})
# Shared target
set(_shared_target "ghostty-vt-${_GVT_NAME}")
add_library(${_shared_target} SHARED IMPORTED GLOBAL)
set_target_properties(${_shared_target} PROPERTIES
IMPORTED_LOCATION "${_shared_lib}"
INTERFACE_INCLUDE_DIRECTORIES "${_include_dir}"
)
if(_GVT_ZIG_TARGET MATCHES "windows")
set_target_properties(${_shared_target} PROPERTIES
IMPORTED_IMPLIB "${_implib}"
)
elseif(_GVT_ZIG_TARGET MATCHES "darwin|macos")
set_target_properties(${_shared_target} PROPERTIES
IMPORTED_SONAME "@rpath/libghostty-vt.0.dylib"
)
else()
set_target_properties(${_shared_target} PROPERTIES
IMPORTED_SONAME "libghostty-vt.so.0"
)
endif()
add_dependencies(${_shared_target} ${_build_target})
endfunction()

74
dist/cmake/GhosttyZigCompiler.cmake vendored Normal file
View File

@@ -0,0 +1,74 @@
# GhosttyZigCompiler.cmake — set up zig cc as a cross compiler
#
# Provides ghostty_zig_compiler() which configures zig cc / zig c++ as
# the C/CXX compiler for a given Zig target triple. It creates small
# wrapper scripts (shell on Unix, .cmd on Windows) and sets the
# following CMake variables in the caller's scope:
#
# CMAKE_C_COMPILER, CMAKE_CXX_COMPILER,
# CMAKE_C_COMPILER_FORCED, CMAKE_CXX_COMPILER_FORCED,
# CMAKE_SYSTEM_NAME, CMAKE_EXECUTABLE_SUFFIX (Windows only)
#
# This file is self-contained with no dependencies on the ghostty
# source tree. Copy it into your project and include it directly.
# It cannot be consumed via FetchContent because it must run before
# project(), but FetchContent_MakeAvailable triggers project()
# internally.
#
# Must be called BEFORE project() — CMake reads the compiler variables
# at project() time and won't re-detect after that.
#
# Usage:
#
# cmake_minimum_required(VERSION 3.19)
#
# include(cmake/GhosttyZigCompiler.cmake)
# ghostty_zig_compiler(ZIG_TARGET x86_64-linux-gnu)
#
# project(myapp LANGUAGES C CXX)
#
# FetchContent_MakeAvailable(ghostty)
# ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu)
# target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64)
#
# See example/c-vt-cmake-cross/ for a complete working example.
include_guard(GLOBAL)
function(ghostty_zig_compiler)
cmake_parse_arguments(PARSE_ARGV 0 _GZC "" "ZIG_TARGET" "")
if(NOT _GZC_ZIG_TARGET)
message(FATAL_ERROR "ghostty_zig_compiler: ZIG_TARGET is required")
endif()
find_program(_GZC_ZIG zig REQUIRED)
if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows")
set(_cc "${CMAKE_CURRENT_BINARY_DIR}/zig-cc.cmd")
set(_cxx "${CMAKE_CURRENT_BINARY_DIR}/zig-cxx.cmd")
file(WRITE "${_cc}" "@\"${_GZC_ZIG}\" cc -target ${_GZC_ZIG_TARGET} %*\n")
file(WRITE "${_cxx}" "@\"${_GZC_ZIG}\" c++ -target ${_GZC_ZIG_TARGET} %*\n")
else()
set(_cc "${CMAKE_CURRENT_BINARY_DIR}/zig-cc")
set(_cxx "${CMAKE_CURRENT_BINARY_DIR}/zig-c++")
file(WRITE "${_cc}" "#!/bin/sh\nexec \"${_GZC_ZIG}\" cc -target ${_GZC_ZIG_TARGET} \"$@\"\n")
file(WRITE "${_cxx}" "#!/bin/sh\nexec \"${_GZC_ZIG}\" c++ -target ${_GZC_ZIG_TARGET} \"$@\"\n")
file(CHMOD "${_cc}" "${_cxx}"
PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE)
endif()
set(CMAKE_C_COMPILER "${_cc}" PARENT_SCOPE)
set(CMAKE_CXX_COMPILER "${_cxx}" PARENT_SCOPE)
set(CMAKE_C_COMPILER_FORCED TRUE PARENT_SCOPE)
set(CMAKE_CXX_COMPILER_FORCED TRUE PARENT_SCOPE)
if(_GZC_ZIG_TARGET MATCHES "windows")
set(CMAKE_SYSTEM_NAME Windows PARENT_SCOPE)
set(CMAKE_EXECUTABLE_SUFFIX ".exe" PARENT_SCOPE)
elseif(_GZC_ZIG_TARGET MATCHES "linux")
set(CMAKE_SYSTEM_NAME Linux PARENT_SCOPE)
elseif(_GZC_ZIG_TARGET MATCHES "darwin|macos")
set(CMAKE_SYSTEM_NAME Darwin PARENT_SCOPE)
endif()
endfunction()

45
dist/cmake/README.md vendored
View File

@@ -57,11 +57,46 @@ add_executable(myapp main.c)
target_link_libraries(myapp PRIVATE ghostty-vt::ghostty-vt)
```
## Files
## Cross-compilation
- `ghostty-vt-config.cmake.in` — template for the CMake package config
file installed alongside the library, enabling `find_package()` support.
For cross-compiling to a different Zig target triple, use
`ghostty_vt_add_target()` after `FetchContent_MakeAvailable`:
## Example
```cmake
FetchContent_MakeAvailable(ghostty)
ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu)
See `example/c-vt-cmake/` for a complete working example.
add_executable(myapp main.c)
target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64)
```
### Using zig cc as the C/CXX compiler
When cross-compiling, the host C compiler can't link binaries for the
target platform. `GhosttyZigCompiler.cmake` provides
`ghostty_zig_compiler()` to set up `zig cc` as the C/CXX compiler for
the cross target. It creates wrapper scripts (shell on Unix, `.cmd` on
Windows) and configures `CMAKE_C_COMPILER`, `CMAKE_CXX_COMPILER`, and
`CMAKE_SYSTEM_NAME`.
The module is self-contained — copy it into your project (e.g. to
`cmake/`) and include it directly. It cannot be consumed via
FetchContent because it must run before `project()`, but
`FetchContent_MakeAvailable` triggers `project()` internally:
```cmake
cmake_minimum_required(VERSION 3.19)
include(cmake/GhosttyZigCompiler.cmake)
ghostty_zig_compiler(ZIG_TARGET x86_64-linux-gnu)
project(myapp LANGUAGES C CXX)
FetchContent_MakeAvailable(ghostty)
ghostty_vt_add_target(NAME linux-amd64 ZIG_TARGET x86_64-linux-gnu)
add_executable(myapp main.c)
target_link_libraries(myapp PRIVATE ghostty-vt-static-linux-amd64)
```
See `example/c-vt-cmake-cross/` for a complete working example.

View File

@@ -0,0 +1,59 @@
cmake_minimum_required(VERSION 3.19)
# --- Determine cross-compilation target before project() --------------------
#
# We need to know the target before project() so we can set up zig cc as the
# C/C++ compiler for the cross target.
# Pick a cross-compilation target: build for a different OS than the host.
# Can be overridden with -DZIG_TARGET=... on the command line.
if(NOT ZIG_TARGET)
# CMAKE_HOST_SYSTEM_PROCESSOR may not be set before project(), so
# fall back to `uname -m`.
if(CMAKE_HOST_SYSTEM_PROCESSOR)
set(_arch "${CMAKE_HOST_SYSTEM_PROCESSOR}")
else()
execute_process(COMMAND uname -m OUTPUT_VARIABLE _arch OUTPUT_STRIP_TRAILING_WHITESPACE)
endif()
if(_arch MATCHES "^(x86_64|AMD64)$")
set(_arch "x86_64")
elseif(_arch MATCHES "^(aarch64|arm64|ARM64)$")
set(_arch "aarch64")
endif()
if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux")
set(ZIG_TARGET "${_arch}-windows-gnu")
elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows")
set(ZIG_TARGET "${_arch}-linux-gnu")
elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin")
set(ZIG_TARGET "${_arch}-linux-gnu")
else()
message(FATAL_ERROR
"Cannot derive ZIG_TARGET for ${CMAKE_HOST_SYSTEM_NAME}. "
"Pass -DZIG_TARGET=... manually.")
endif()
message(STATUS "Cross-compiling for ZIG_TARGET: ${ZIG_TARGET}")
endif()
# --- Set up zig cc as the cross compiler ------------------------------------
# GhosttyZigCompiler.cmake must be called before project().
# Downstream projects would copy this file into their tree; here we
# include it directly from the repo.
include(../../dist/cmake/GhosttyZigCompiler.cmake)
ghostty_zig_compiler(ZIG_TARGET "${ZIG_TARGET}")
project(c-vt-cmake-cross LANGUAGES C CXX)
include(FetchContent)
FetchContent_Declare(ghostty
GIT_REPOSITORY https://github.com/ghostty-org/ghostty.git
GIT_TAG main
)
FetchContent_MakeAvailable(ghostty)
ghostty_vt_add_target(NAME cross ZIG_TARGET "${ZIG_TARGET}")
add_executable(c_vt_cmake_cross src/main.c)
target_link_libraries(c_vt_cmake_cross PRIVATE ghostty-vt-static-cross)

View File

@@ -0,0 +1,21 @@
# c-vt-cmake-cross
Demonstrates using `ghostty_vt_add_target()` to cross-compile
libghostty-vt with static linking. The target OS is chosen automatically:
| Host | Target |
| ------- | --------------- |
| Linux | Windows (MinGW) |
| Windows | Linux (glibc) |
| macOS | Linux (glibc) |
Override with `-DZIG_TARGET=...` if needed.
## Building
```shell-session
cd example/c-vt-cmake-cross
cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY=../..
cmake --build build
file build/c_vt_cmake_cross
```

View File

@@ -0,0 +1,52 @@
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ghostty/vt.h>
int main() {
// Create a terminal with a small grid
GhosttyTerminal terminal;
GhosttyTerminalOptions opts = {
.cols = 80,
.rows = 24,
.max_scrollback = 0,
};
GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts);
assert(result == GHOSTTY_SUCCESS);
// Write some VT-encoded content into the terminal
const char *commands[] = {
"Hello from a \033[1mCMake\033[0m-built program!\r\n",
"Line 2: \033[4munderlined\033[0m text\r\n",
"Line 3: \033[31mred\033[0m \033[32mgreen\033[0m \033[34mblue\033[0m\r\n",
};
for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) {
ghostty_terminal_vt_write(terminal, (const uint8_t *)commands[i],
strlen(commands[i]));
}
// Format the terminal contents as plain text
GhosttyFormatterTerminalOptions fmt_opts =
GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
fmt_opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
fmt_opts.trim = true;
GhosttyFormatter formatter;
result = ghostty_formatter_terminal_new(NULL, &formatter, terminal, fmt_opts);
assert(result == GHOSTTY_SUCCESS);
uint8_t *buf = NULL;
size_t len = 0;
result = ghostty_formatter_format_alloc(formatter, NULL, &buf, &len);
assert(result == GHOSTTY_SUCCESS);
printf("Plain text (%zu bytes):\n", len);
fwrite(buf, 1, len, stdout);
printf("\n");
ghostty_free(NULL, buf, len);
ghostty_formatter_free(formatter);
ghostty_terminal_free(terminal);
return 0;
}

View File

@@ -279,10 +279,9 @@ fn initLib(
// For static libraries with vendored SIMD dependencies, combine
// all archives into a single fat archive so consumers only need
// to link one file. Skip on Windows where ar/libtool aren't available.
// to link one file.
if (kind == .static and
zig.simd_libs.items.len > 0 and
target.result.os.tag != .windows)
zig.simd_libs.items.len > 0)
{
var sources: SharedDeps.LazyPathList = .empty;
try sources.append(b.allocator, lib.getEmittedBin());
@@ -329,26 +328,17 @@ fn combineArchives(
return .{ .step = libtool.step, .output = libtool.output };
}
// On non-Darwin, use an MRI script with ar -M to combine archives
// directly without extracting. This avoids issues with ar x
// producing full-path member names and read-only permissions.
const run = RunStep.create(b, "combine-archives ghostty-vt");
run.addArgs(&.{
"/bin/sh", "-c",
\\set -e
\\out="$1"; shift
\\script="CREATE $out"
\\for a in "$@"; do
\\ script="$script
\\ADDLIB $a"
\\done
\\script="$script
\\SAVE
\\END"
\\echo "$script" | ar -M
,
"_",
// On non-Darwin, use a build tool that generates an MRI script and
// pipes it to `zig ar -M`. This works on all platforms including
// Windows (the previous /bin/sh approach did not).
const tool = b.addExecutable(.{
.name = "combine_archives",
.root_module = b.createModule(.{
.root_source_file = b.path("src/build/combine_archives.zig"),
.target = b.graph.host,
}),
});
const run = b.addRunArtifact(tool);
const output = run.addOutputFileArg("libghostty-vt.a");
for (sources) |source| run.addFileArg(source);

View File

@@ -0,0 +1,54 @@
//! Build tool that combines multiple static archives into a single fat
//! archive using an MRI script piped to `zig ar -M`.
//!
//! MRI scripts require stdin piping (`ar -M < script`), which can't be
//! expressed as a single command in the zig build system's RunStep. The
//! previous approach used `/bin/sh -c` to do the piping, but that isn't
//! available on Windows. This tool handles both the script generation
//! and the piping in a single cross-platform executable.
//!
//! Usage: combine_archives <output.a> <input1.a> [input2.a ...]
const std = @import("std");
pub fn main() !void {
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
const alloc = gpa.allocator();
const args = try std.process.argsAlloc(alloc);
if (args.len < 3) {
std.log.err("usage: combine_archives <output> <input...>", .{});
std.process.exit(1);
}
const output_path = args[1];
const inputs = args[2..];
// Build the MRI script.
var script: std.ArrayListUnmanaged(u8) = .empty;
try script.appendSlice(alloc, "CREATE ");
try script.appendSlice(alloc, output_path);
try script.append(alloc, '\n');
for (inputs) |input| {
try script.appendSlice(alloc, "ADDLIB ");
try script.appendSlice(alloc, input);
try script.append(alloc, '\n');
}
try script.appendSlice(alloc, "SAVE\nEND\n");
var child: std.process.Child = .init(&.{ "zig", "ar", "-M" }, alloc);
child.stdin_behavior = .Pipe;
child.stdout_behavior = .Inherit;
child.stderr_behavior = .Inherit;
try child.spawn();
try child.stdin.?.writeAll(script.items);
child.stdin.?.close();
child.stdin = null;
const term = try child.wait();
if (term.Exited != 0) {
std.log.err("zig ar -M exited with code {d}", .{term.Exited});
std.process.exit(1);
}
}