Modernize our benchmarks (#7891)

This PR modernizes our benchmarks. This PR focuses on the benchmark
_framework_ and not the benchmarks themselves. That will come in later
PRs.

We now produce two binaries with `-Demit-bench`: `ghostty-bench` and
`ghostty-gen`. The former is our benchmark tool. The latter is our
synthetic data generation tool. The benchmarking CLI usually takes in
data from the synthetic generator but we want to do that offline because
synthetic data generation can be slow and CPU intensive and mess up our
benchmarks.

Our previous benchmark-specific binaries (like
`ghostty-bench-codepoint-width`) are all gone. This is all executed as
subcommands in the format similar to Ghostty users: `ghostty-bench
+codepoint-width --other --args`.

Previously, synthetic data generation was a mess and all unified with
`ghostty-bench-stream` which is just nasty. A dedicated CLI now gets us
args like `ghostty-gen +osc --p-valid=0.5`. Neat!

## Signposts and Xcode/Instruments on macOS

The benchmark framework now automatically emits
[signposts](https://developer.apple.com/documentation/os/recording-performance-data)
around the code that is under test. This is surfaced in Instruments as a
region that you can visualize and zoom in on so you can omit any of the
other overhead.

Additionally, I've integrated benchmarks with libghostty and our Xcode
project so you can just right click a benchmark to open it in
Instruments.

These are macOS-specific niceties but the core benchmarking tool is
platform-agnostic.

## Generalized CLI Actions

The `src/cli/action.zig` file was generalized so that it can be shared
amongst our three action-ized binaries. The Ghostty-specific actions are
now in `src/cli/ghostty.zig`. As an added bonus, our action parsing is
now fully unit tested.

I don't like mixing refactors in with other tasks in PRs but in this
case this one was done to enable not one but two other consumers in the
same PR, so I think it fits.

## TODO

Some things I want to do before merge.

- [ ] Add flags to `ghostty-bench` to configure once mode vs duration
mode
This commit is contained in:
Mitchell Hashimoto
2025-07-09 21:42:53 -07:00
committed by GitHub
64 changed files with 2266 additions and 1230 deletions

View File

@ -932,6 +932,9 @@ bool ghostty_inspector_metal_shutdown(ghostty_inspector_t);
// Don't use these unless you know what you're doing.
void ghostty_set_window_background_blur(ghostty_app_t, void*);
// Benchmark API, if available.
bool ghostty_benchmark_cli(const char*, const char*);
#ifdef __cplusplus
}
#endif

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objectVersion = 70;
objects = {
/* Begin PBXBuildFile section */
@ -152,6 +152,16 @@
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A5B30529299BEAAA0047F10C /* Project object */;
proxyType = 1;
remoteGlobalIDString = A5B30530299BEAAA0047F10C;
remoteInfo = Ghostty;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
29C15B1C2CDC3B2000520DD4 /* bat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = bat; path = "../zig-out/share/bat"; sourceTree = "<group>"; };
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = "<group>"; };
@ -199,6 +209,7 @@
A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = "<group>"; };
A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = "<group>"; };
A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = "<group>"; };
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
A553F4122E06EB1600257779 /* Ghostty.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; name = Ghostty.icon; path = ../images/Ghostty.icon; sourceTree = SOURCE_ROOT; };
A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = "<group>"; };
A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = "<group>"; };
@ -291,7 +302,18 @@
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A54F45F42E1F047A0046BD5C /* GhosttyTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyTests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
A54F45F02E1F047A0046BD5C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A5B3052E299BEAAA0047F10C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@ -590,6 +612,7 @@
A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */,
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */,
A54CD6ED299BEB14008C95BB /* Sources */,
A54F45F42E1F047A0046BD5C /* GhosttyTests */,
A5D495A3299BECBA00DD1313 /* Frameworks */,
A5A1F8862A489D7400D1E8BC /* Resources */,
A5B30532299BEAAA0047F10C /* Products */,
@ -601,6 +624,7 @@
children = (
A5B30531299BEAAA0047F10C /* Ghostty.app */,
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */,
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -674,6 +698,29 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
A54F45F22E1F047A0046BD5C /* GhosttyTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */;
buildPhases = (
A54F45EF2E1F047A0046BD5C /* Sources */,
A54F45F02E1F047A0046BD5C /* Frameworks */,
A54F45F12E1F047A0046BD5C /* Resources */,
);
buildRules = (
);
dependencies = (
A54F45F82E1F047A0046BD5C /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
A54F45F42E1F047A0046BD5C /* GhosttyTests */,
);
name = GhosttyTests;
packageProductDependencies = (
);
productName = GhosttyTests;
productReference = A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
A5B30530299BEAAA0047F10C /* Ghostty */ = {
isa = PBXNativeTarget;
buildConfigurationList = A5B30540299BEAAB0047F10C /* Build configuration list for PBXNativeTarget "Ghostty" */;
@ -718,9 +765,13 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1520;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 1610;
TargetAttributes = {
A54F45F22E1F047A0046BD5C = {
CreatedOnToolsVersion = 26.0;
TestTargetID = A5B30530299BEAAA0047F10C;
};
A5B30530299BEAAA0047F10C = {
CreatedOnToolsVersion = 14.2;
LastSwiftMigration = 1510;
@ -748,11 +799,19 @@
targets = (
A5B30530299BEAAA0047F10C /* Ghostty */,
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */,
A54F45F22E1F047A0046BD5C /* GhosttyTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A54F45F12E1F047A0046BD5C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A5B3052F299BEAAA0047F10C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@ -794,6 +853,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A54F45EF2E1F047A0046BD5C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
A5B3052D299BEAAA0047F10C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@ -925,6 +991,14 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
A54F45F82E1F047A0046BD5C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = A5B30530299BEAAA0047F10C /* Ghostty */;
targetProxy = A54F45F72E1F047A0046BD5C /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
3B39CAA22B33946300DABEB8 /* ReleaseLocal */ = {
isa = XCBuildConfiguration;
@ -1034,6 +1108,76 @@
};
name = ReleaseLocal;
};
A54F45F92E1F047A0046BD5C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
};
name = Debug;
};
A54F45FA2E1F047A0046BD5C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
};
name = Release;
};
A54F45FB2E1F047A0046BD5C /* ReleaseLocal */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.5;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.GhosttyTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Ghostty.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ghostty";
};
name = ReleaseLocal;
};
A5B3053E299BEAAB0047F10C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@ -1378,6 +1522,16 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
A54F45FC2E1F047A0046BD5C /* Build configuration list for PBXNativeTarget "GhosttyTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A54F45F92E1F047A0046BD5C /* Debug */,
A54F45FA2E1F047A0046BD5C /* Release */,
A54F45FB2E1F047A0046BD5C /* ReleaseLocal */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = ReleaseLocal;
};
A5B3052C299BEAAA0047F10C /* Build configuration list for PBXProject "Ghostty" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@ -28,6 +28,19 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A54F45F22E1F047A0046BD5C"
BuildableName = "GhosttyTests.xctest"
BlueprintName = "GhosttyTests"
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"

View File

@ -0,0 +1,32 @@
//
// GhosttyTests.swift
// GhosttyTests
//
// Created by Mitchell Hashimoto on 7/9/25.
//
import Testing
import GhosttyKit
extension Tag {
@Tag static var benchmark: Self
}
/// The whole idea behind these benchmarks is that they're run by right-clicking
/// in Xcode and using "Profile" to open them in instruments. They aren't meant to
/// be run in general.
///
/// When running them, set the `if:` to `true`. There's probably a better
/// programmatic way to do this but I don't know it yet!
@Suite(
"Benchmarks",
.enabled(if: false),
.tags(.benchmark)
)
struct BenchmarkTests {
@Test func example() async throws {
ghostty_benchmark_cli(
"terminal-stream",
"--data=/Users/mitchellh/Documents/ghostty/bug.osc.txt")
}
}

View File

@ -18,15 +18,12 @@ pub fn build(b: *std.Build) !void {
.optimize = optimize,
});
var flags = std.ArrayList([]const u8).init(b.allocator);
defer flags.deinit();
lib.addCSourceFile(.{
.file = b.path("os/zig_log.c"),
.flags = flags.items,
.file = b.path("os/zig_macos.c"),
.flags = &.{"-std=c99"},
});
lib.addCSourceFile(.{
.file = b.path("text/ext.c"),
.flags = flags.items,
});
lib.linkFramework("CoreFoundation");
lib.linkFramework("CoreGraphics");

View File

@ -23,6 +23,7 @@ pub const c = @cImport({
@cInclude("IOSurface/IOSurfaceRef.h");
@cInclude("dispatch/dispatch.h");
@cInclude("os/log.h");
@cInclude("os/signpost.h");
if (builtin.os.tag == .macos) {
@cInclude("Carbon/Carbon.h");

View File

@ -1,6 +1,7 @@
const log = @import("os/log.zig");
pub const c = @import("os/c.zig");
pub const signpost = @import("os/signpost.zig");
pub const Log = log.Log;
pub const LogType = log.LogType;

View File

@ -8,10 +8,10 @@ pub const Log = opaque {
subsystem: [:0]const u8,
category: [:0]const u8,
) *Log {
return @as(?*Log, @ptrFromInt(@intFromPtr(c.os_log_create(
return @ptrCast(c.os_log_create(
subsystem.ptr,
category.ptr,
)))).?;
).?);
}
pub fn release(self: *Log) void {
@ -32,7 +32,11 @@ pub const Log = opaque {
comptime format: []const u8,
args: anytype,
) void {
const str = nosuspend std.fmt.allocPrintZ(alloc, format, args) catch return;
const str = nosuspend std.fmt.allocPrintZ(
alloc,
format,
args,
) catch return;
defer alloc.free(str);
zig_os_log_with_type(self, typ, str.ptr);
}

214
pkg/macos/os/signpost.zig Normal file
View File

@ -0,0 +1,214 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const c = @import("c.zig").c;
const logpkg = @import("log.zig");
const Log = logpkg.Log;
/// This should be called once at the start of the program to intialize
/// some required state for signpost logging.
///
/// This is all to workaround a Zig bug:
/// https://github.com/ziglang/zig/issues/24370
pub fn init() void {
if (__dso_handle != null) return;
const sym = comptime sym: {
const root = @import("root");
// If we have a main function, use that as the symbol.
if (@hasDecl(root, "main")) break :sym root.main;
// Otherwise, we're in a library, so we just use the first
// function in our root module. I actually don't know if this is
// all required or if we can just use the real `__dso_handle` symbol,
// but this seems to work for now.
for (@typeInfo(root).@"struct".decls) |decl_info| {
const decl = @field(root, decl_info.name);
if (@typeInfo(@TypeOf(decl)) == .@"fn") break :sym decl;
}
@compileError("no functions found in root module");
};
// Since __dso_handle is not automatically populated by the linker,
// we populate it by looking up the main function's module address
// which should be a mach-o header.
var info: DlInfo = undefined;
const result = dladdr(sym, &info);
assert(result != 0);
__dso_handle = @ptrCast(@alignCast(info.dli_fbase));
}
/// This should REALLY be an extern var that is populated by the linker,
/// but there is a Zig bug: https://github.com/ziglang/zig/issues/24370
var __dso_handle: ?*c.mach_header = null;
// Import the necessary C functions and types
extern "c" fn dladdr(addr: ?*const anyopaque, info: *DlInfo) c_int;
// Define the Dl_info structure
const DlInfo = extern struct {
dli_fname: [*:0]const u8, // Pathname of shared object
dli_fbase: ?*anyopaque, // Base address of shared object
dli_sname: [*:0]const u8, // Name of nearest symbol
dli_saddr: ?*anyopaque, // Address of nearest symbol
};
/// Checks whether signpost logging is enabled for the given log handle.
/// Returns true if signposts will be recorded for this log, false otherwise.
/// This can be used to avoid expensive operations when signpost logging is disabled.
///
/// https://developer.apple.com/documentation/os/os_signpost_enabled?language=objc
pub fn enabled(log: *Log) bool {
return c.os_signpost_enabled(@ptrCast(log));
}
/// Emits a signpost event - a single point in time marker.
/// Events are useful for marking when specific actions occur, such as
/// user interactions, state changes, or other discrete occurrences.
/// The event will appear as a vertical line in Instruments.
///
/// https://developer.apple.com/documentation/os/os_signpost_event_emit?language=objc
pub fn emitEvent(
log: *Log,
id: Id,
comptime name: [:0]const u8,
) void {
emitWithName(log, id, .event, name);
}
/// Marks the beginning of a time interval.
/// Use this with intervalEnd to measure the duration of operations.
/// The same ID must be used for both the begin and end calls.
/// Intervals appear as horizontal bars in Instruments timeline.
///
/// https://developer.apple.com/documentation/os/os_signpost_interval_begin?language=objc
pub fn intervalBegin(log: *Log, id: Id, comptime name: [:0]const u8) void {
emitWithName(log, id, .interval_begin, name);
}
/// Marks the end of a time interval.
/// Must be paired with a prior intervalBegin call using the same ID.
/// The name should match the name used in intervalBegin.
/// Instruments will calculate and display the duration between begin and end.
///
/// https://developer.apple.com/documentation/os/os_signpost_interval_end?language=objc
pub fn intervalEnd(log: *Log, id: Id, comptime name: [:0]const u8) void {
emitWithName(log, id, .interval_end, name);
}
/// The internal function to emit a signpost with a specific name.
fn emitWithName(
log: *Log,
id: Id,
typ: Type,
comptime name: [:0]const u8,
) void {
// Init must be called by this point.
assert(__dso_handle != null);
var buf: [2]u8 = @splat(0);
c._os_signpost_emit_with_name_impl(
__dso_handle,
@ptrCast(log),
@intFromEnum(typ),
@intFromEnum(id),
name.ptr,
"".ptr,
&buf,
buf.len,
);
}
/// https://developer.apple.com/documentation/os/os_signpost_id_t?language=objc
pub const Id = enum(u64) {
null = 0, // OS_SIGNPOST_ID_NULL
invalid = 0xFFFFFFFFFFFFFFFF, // OS_SIGNPOST_ID_INVALID
exclusive = 0xEEEEB0B5B2B2EEEE, // OS_SIGNPOST_ID_EXCLUSIVE
_,
/// Generates a new signpost ID for use with signpost operations.
/// The ID is unique for the given log handle and can be used to track
/// asynchronous operations or mark specific points of interest in the code.
/// Returns a unique signpost ID that can be used with os_signpost functions.
///
/// https://developer.apple.com/documentation/os/os_signpost_id_generate?language=objc
pub fn generate(log: *Log) Id {
return @enumFromInt(c.os_signpost_id_generate(@ptrCast(log)));
}
/// Creates a signpost ID based on a pointer value.
/// This is useful for tracking operations associated with a specific object
/// or memory location. The same pointer will always generate the same ID
/// for a given log handle, allowing correlation of signpost events.
/// Pass null to get the null signpost ID.
///
/// https://developer.apple.com/documentation/os/os_signpost_id_for_pointer?language=objc
pub fn forPointer(log: *Log, ptr: ?*anyopaque) Id {
return @enumFromInt(c.os_signpost_id_make_with_pointer(
@ptrCast(log),
@ptrCast(ptr),
));
}
test "generate ID" {
// We can't really test the return value because it may return null
// if signposts are disabled.
const id: Id = .generate(Log.create("com.mitchellh.ghostty", "test"));
try std.testing.expect(id != .invalid);
}
test "generate ID for pointer" {
var foo: usize = 0x1234;
const id: Id = .forPointer(Log.create("com.mitchellh.ghostty", "test"), &foo);
try std.testing.expect(id != .null);
}
};
/// https://developer.apple.com/documentation/os/ossignposttype?language=objc
pub const Type = enum(u8) {
event = 0, // OS_SIGNPOST_EVENT
interval_begin = 1, // OS_SIGNPOST_INTERVAL_BEGIN
interval_end = 2, // OS_SIGNPOST_INTERVAL_END
pub const mask: u8 = 0x03; // OS_SIGNPOST_TYPE_MASK
};
/// Special os_log category values that surface in Instruments and other
/// tooling.
pub const Category = struct {
/// Points of Interest appear as a dedicated track in Instruments.
/// Use this for high-level application events that help understand
/// the flow of your application.
pub const points_of_interest: [:0]const u8 = "PointsOfInterest";
/// Dynamic Tracing category enables runtime-configurable logging.
/// Signposts in this category can be enabled/disabled dynamically
/// without recompiling.
pub const dynamic_tracing: [:0]const u8 = "DynamicTracing";
/// Dynamic Stack Tracing category captures call stacks at signpost
/// events. This provides deeper debugging information but has higher
/// performance overhead.
pub const dynamic_stack_tracing: [:0]const u8 = "DynamicStackTracing";
};
test {
_ = Id;
}
test enabled {
_ = enabled(Log.create("com.mitchellh.ghostty", "test"));
}
test "intervals" {
init();
const log = Log.create("com.mitchellh.ghostty", "test");
defer log.release();
// Test that we can begin and end an interval
const id = Id.generate(log);
intervalBegin(log, id, "Test Interval");
}

View File

@ -1,4 +1,5 @@
#include <os/log.h>
#include <os/signpost.h>
// A wrapper so we can use the os_log_with_type macro.
void zig_os_log_with_type(

View File

@ -1,34 +0,0 @@
#!/usr/bin/env bash
#
# This is a trivial helper script to help run the codepoint-width benchmark.
# You probably want to tweak this script depending on what you're
# trying to measure.
# Options:
# - "ascii", uniform random ASCII bytes
# - "utf8", uniform random unicode characters, encoded as utf8
# - "rand", pure random data, will contain many invalid code sequences.
DATA="utf8"
SIZE="25000000"
# Add additional arguments
ARGS=""
# Generate the benchmark input ahead of time so it's not included in the time.
./zig-out/bin/bench-stream --mode=gen-$DATA | head -c $SIZE > /tmp/ghostty_bench_data
#cat ~/Downloads/JAPANESEBIBLE.txt > /tmp/ghostty_bench_data
# Uncomment to instead use the contents of `stream.txt` as input.
# yes $(cat ./stream.txt) | head -c $SIZE > /tmp/ghostty_bench_data
hyperfine \
--warmup 10 \
-n noop \
"./zig-out/bin/bench-codepoint-width --mode=noop${ARGS} </tmp/ghostty_bench_data" \
-n wcwidth \
"./zig-out/bin/bench-codepoint-width --mode=wcwidth${ARGS} </tmp/ghostty_bench_data" \
-n table \
"./zig-out/bin/bench-codepoint-width --mode=table${ARGS} </tmp/ghostty_bench_data" \
-n simd \
"./zig-out/bin/bench-codepoint-width --mode=simd${ARGS} </tmp/ghostty_bench_data"

View File

@ -1,204 +0,0 @@
//! This benchmark tests the throughput of codepoint width calculation.
//! This is a common operation in terminal character printing and the
//! motivating factor to write this benchmark was discovering that our
//! codepoint width function was 30% of the runtime of every character
//! print.
//!
//! This will consume all of the available stdin, so you should run it
//! with `head` in a pipe to restrict. For example, to test ASCII input:
//!
//! bench-stream --mode=gen-ascii | head -c 50M | bench-codepoint-width --mode=ziglyph
//!
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ziglyph = @import("ziglyph");
const cli = @import("../cli.zig");
const simd = @import("../simd/main.zig");
const table = @import("../unicode/main.zig").table;
const UTF8Decoder = @import("../terminal/UTF8Decoder.zig");
const Args = struct {
mode: Mode = .noop,
/// The size for read buffers. Doesn't usually need to be changed. The
/// main point is to make this runtime known so we can avoid compiler
/// optimizations.
@"buffer-size": usize = 4096,
/// This is set by the CLI parser for deinit.
_arena: ?ArenaAllocator = null,
pub fn deinit(self: *Args) void {
if (self._arena) |arena| arena.deinit();
self.* = undefined;
}
};
const Mode = enum {
/// The baseline mode copies the data from the fd into a buffer. This
/// is used to show the minimal overhead of reading the fd into memory
/// and establishes a baseline for the other modes.
noop,
/// libc wcwidth
wcwidth,
/// Use ziglyph library to calculate the display width of each codepoint.
ziglyph,
/// Our SIMD implementation.
simd,
/// Test our lookup table implementation.
table,
};
pub const std_options: std.Options = .{
.log_level = .debug,
};
pub fn main() !void {
// We want to use the c allocator because it is much faster than GPA.
const alloc = std.heap.c_allocator;
// Parse our args
var args: Args = .{};
defer args.deinit();
{
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
}
const reader = std.io.getStdIn().reader();
const buf = try alloc.alloc(u8, args.@"buffer-size");
// Handle the modes that do not depend on terminal state first.
switch (args.mode) {
.noop => try benchNoop(reader, buf),
.wcwidth => try benchWcwidth(reader, buf),
.ziglyph => try benchZiglyph(reader, buf),
.simd => try benchSimd(reader, buf),
.table => try benchTable(reader, buf),
}
}
noinline fn benchNoop(
reader: anytype,
buf: []u8,
) !void {
var d: UTF8Decoder = .{};
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| {
_ = d.next(c);
}
}
}
extern "c" fn wcwidth(c: u32) c_int;
noinline fn benchWcwidth(
reader: anytype,
buf: []u8,
) !void {
var d: UTF8Decoder = .{};
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp| {
const width = wcwidth(cp);
// Write the width to the buffer to avoid it being compiled away
buf[0] = @intCast(width);
}
}
}
}
noinline fn benchTable(
reader: anytype,
buf: []u8,
) !void {
var d: UTF8Decoder = .{};
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp| {
// This is the same trick we do in terminal.zig so we
// keep it here.
const width = if (cp <= 0xFF) 1 else table.get(@intCast(cp)).width;
// Write the width to the buffer to avoid it being compiled away
buf[0] = @intCast(width);
}
}
}
}
noinline fn benchZiglyph(
reader: anytype,
buf: []u8,
) !void {
var d: UTF8Decoder = .{};
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp| {
const width = ziglyph.display_width.codePointWidth(cp, .half);
// Write the width to the buffer to avoid it being compiled away
buf[0] = @intCast(width);
}
}
}
}
noinline fn benchSimd(
reader: anytype,
buf: []u8,
) !void {
var d: UTF8Decoder = .{};
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp| {
const width = simd.codepointWidth(cp);
// Write the width to the buffer to avoid it being compiled away
buf[0] = @intCast(width);
}
}
}
}

View File

@ -1,33 +0,0 @@
#!/usr/bin/env bash
#
# This is a trivial helper script to help run the grapheme-break benchmark.
# You probably want to tweak this script depending on what you're
# trying to measure.
# Options:
# - "ascii", uniform random ASCII bytes
# - "utf8", uniform random unicode characters, encoded as utf8
# - "rand", pure random data, will contain many invalid code sequences.
DATA="utf8"
SIZE="25000000"
# Add additional arguments
ARGS=""
# Generate the benchmark input ahead of time so it's not included in the time.
./zig-out/bin/bench-stream --mode=gen-$DATA | head -c $SIZE > /tmp/ghostty_bench_data
#cat ~/Downloads/JAPANESEBIBLE.txt > /tmp/ghostty_bench_data
# Uncomment to instead use the contents of `stream.txt` as input.
# yes $(cat ./stream.txt) | head -c $SIZE > /tmp/ghostty_bench_data
hyperfine \
--warmup 10 \
-n noop \
"./zig-out/bin/bench-grapheme-break --mode=noop${ARGS} </tmp/ghostty_bench_data" \
-n ziglyph \
"./zig-out/bin/bench-grapheme-break --mode=ziglyph${ARGS} </tmp/ghostty_bench_data" \
-n table \
"./zig-out/bin/bench-grapheme-break --mode=table${ARGS} </tmp/ghostty_bench_data"

View File

@ -1,144 +0,0 @@
//! This benchmark tests the throughput of grapheme break calculation.
//! This is a common operation in terminal character printing for terminals
//! that support grapheme clustering.
//!
//! This will consume all of the available stdin, so you should run it
//! with `head` in a pipe to restrict. For example, to test ASCII input:
//!
//! bench-stream --mode=gen-ascii | head -c 50M | bench-grapheme-break --mode=ziglyph
//!
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ziglyph = @import("ziglyph");
const cli = @import("../cli.zig");
const simd = @import("../simd/main.zig");
const unicode = @import("../unicode/main.zig");
const UTF8Decoder = @import("../terminal/UTF8Decoder.zig");
const Args = struct {
mode: Mode = .noop,
/// The size for read buffers. Doesn't usually need to be changed. The
/// main point is to make this runtime known so we can avoid compiler
/// optimizations.
@"buffer-size": usize = 4096,
/// This is set by the CLI parser for deinit.
_arena: ?ArenaAllocator = null,
pub fn deinit(self: *Args) void {
if (self._arena) |arena| arena.deinit();
self.* = undefined;
}
};
const Mode = enum {
/// The baseline mode copies the data from the fd into a buffer. This
/// is used to show the minimal overhead of reading the fd into memory
/// and establishes a baseline for the other modes.
noop,
/// Use ziglyph library to calculate the display width of each codepoint.
ziglyph,
/// Ghostty's table-based approach.
table,
};
pub const std_options: std.Options = .{
.log_level = .debug,
};
pub fn main() !void {
// We want to use the c allocator because it is much faster than GPA.
const alloc = std.heap.c_allocator;
// Parse our args
var args: Args = .{};
defer args.deinit();
{
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
}
const reader = std.io.getStdIn().reader();
const buf = try alloc.alloc(u8, args.@"buffer-size");
// Handle the modes that do not depend on terminal state first.
switch (args.mode) {
.noop => try benchNoop(reader, buf),
.ziglyph => try benchZiglyph(reader, buf),
.table => try benchTable(reader, buf),
}
}
noinline fn benchNoop(
reader: anytype,
buf: []u8,
) !void {
var d: UTF8Decoder = .{};
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| {
_ = d.next(c);
}
}
}
noinline fn benchTable(
reader: anytype,
buf: []u8,
) !void {
var d: UTF8Decoder = .{};
var state: unicode.GraphemeBreakState = .{};
var cp1: u21 = 0;
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp2| {
const v = unicode.graphemeBreak(cp1, @intCast(cp2), &state);
buf[0] = @intCast(@intFromBool(v));
cp1 = cp2;
}
}
}
}
noinline fn benchZiglyph(
reader: anytype,
buf: []u8,
) !void {
var d: UTF8Decoder = .{};
var state: u3 = 0;
var cp1: u21 = 0;
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp2| {
const v = ziglyph.graphemeBreak(cp1, @intCast(cp2), &state);
buf[0] = @intCast(@intFromBool(v));
cp1 = cp2;
}
}
}
}

View File

@ -1,16 +0,0 @@
#!/usr/bin/env bash
#
# This is a trivial helper script to help run the page init benchmark.
# You probably want to tweak this script depending on what you're
# trying to measure.
# Uncomment to test with an active terminal state.
# ARGS=" --terminal"
hyperfine \
--warmup 10 \
-n alloc \
"./zig-out/bin/bench-page-init --mode=alloc${ARGS} </tmp/ghostty_bench_data" \
-n pool \
"./zig-out/bin/bench-page-init --mode=pool${ARGS} </tmp/ghostty_bench_data"

View File

@ -1,78 +0,0 @@
//! This benchmark tests the speed to create a terminal "page". This is
//! the internal data structure backing a terminal screen. The creation speed
//! is important because it is one of the primary bottlenecks for processing
//! large amounts of plaintext data.
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const cli = @import("../cli.zig");
const terminal_new = @import("../terminal/main.zig");
const Args = struct {
mode: Mode = .alloc,
/// The number of pages to create sequentially.
count: usize = 10_000,
/// This is set by the CLI parser for deinit.
_arena: ?ArenaAllocator = null,
pub fn deinit(self: *Args) void {
if (self._arena) |arena| arena.deinit();
self.* = undefined;
}
};
const Mode = enum {
/// The default allocation strategy of the structure.
alloc,
/// Use a memory pool to allocate pages from a backing buffer.
pool,
};
pub const std_options: std.Options = .{
.log_level = .debug,
};
pub fn main() !void {
// We want to use the c allocator because it is much faster than GPA.
const alloc = std.heap.c_allocator;
// Parse our args
var args: Args = .{};
defer args.deinit();
{
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
}
// Handle the modes that do not depend on terminal state first.
switch (args.mode) {
.alloc => try benchAlloc(args.count),
.pool => try benchPool(alloc, args.count),
}
}
noinline fn benchAlloc(count: usize) !void {
for (0..count) |_| {
_ = try terminal_new.Page.init(terminal_new.page.std_capacity);
}
}
noinline fn benchPool(alloc: Allocator, count: usize) !void {
var list = try terminal_new.PageList.init(
alloc,
terminal_new.page.std_capacity.cols,
terminal_new.page.std_capacity.rows,
0,
);
defer list.deinit();
for (0..count) |_| {
_ = try list.grow();
}
}

View File

@ -1,71 +0,0 @@
//! This benchmark tests the throughput of the terminal escape code parser.
//!
//! To benchmark, this takes an input stream (which is expected to come in
//! as fast as possible), runs it through the parser, and does nothing
//! with the parse result. This bottlenecks and tests the throughput of the
//! parser.
//!
//! Usage:
//!
//! "--f=<path>" - A file to read to parse. If path is "-" then stdin
//! is read. Required.
//!
const std = @import("std");
const ArenaAllocator = std.heap.ArenaAllocator;
const cli = @import("../cli.zig");
const terminal = @import("../terminal/main.zig");
pub fn main() !void {
// Just use a GPA
const GPA = std.heap.GeneralPurposeAllocator(.{});
var gpa = GPA{};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
// Parse our args
var args: Args = args: {
var args: Args = .{};
errdefer args.deinit();
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
break :args args;
};
defer args.deinit();
// Read the file for our input
const file = file: {
if (std.mem.eql(u8, args.f, "-"))
break :file std.io.getStdIn();
@panic("file reading not implemented yet");
};
// Read all into memory (TODO: support buffers one day)
const input = try file.reader().readAllAlloc(
alloc,
1024 * 1024 * 1024 * 1024 * 16, // 16 GB
);
defer alloc.free(input);
// Run our parser
var p: terminal.Parser = .{};
for (input) |c| {
const actions = p.next(c);
//std.log.warn("actions={any}", .{actions});
_ = actions;
}
}
const Args = struct {
f: []const u8 = "-",
/// This is set by the CLI parser for deinit.
_arena: ?ArenaAllocator = null,
pub fn deinit(self: *Args) void {
if (self._arena) |arena| arena.deinit();
self.* = undefined;
}
};

View File

@ -1,30 +0,0 @@
#!/usr/bin/env bash
#
# This is a trivial helper script to help run the stream benchmark.
# You probably want to tweak this script depending on what you're
# trying to measure.
# Options:
# - "ascii", uniform random ASCII bytes
# - "utf8", uniform random unicode characters, encoded as utf8
# - "rand", pure random data, will contain many invalid code sequences.
DATA="ascii"
SIZE="25000000"
# Uncomment to test with an active terminal state.
# ARGS=" --terminal"
# Generate the benchmark input ahead of time so it's not included in the time.
./zig-out/bin/bench-stream --mode=gen-$DATA | head -c $SIZE > /tmp/ghostty_bench_data
# Uncomment to instead use the contents of `stream.txt` as input. (Ignores SIZE)
# echo $(cat ./stream.txt) > /tmp/ghostty_bench_data
hyperfine \
--warmup 10 \
-n memcpy \
"./zig-out/bin/bench-stream --mode=noop${ARGS} </tmp/ghostty_bench_data" \
-n scalar \
"./zig-out/bin/bench-stream --mode=scalar${ARGS} </tmp/ghostty_bench_data" \
-n simd \
"./zig-out/bin/bench-stream --mode=simd${ARGS} </tmp/ghostty_bench_data"

View File

@ -1,253 +0,0 @@
//! This benchmark tests the throughput of the VT stream. It has a few
//! modes in order to test different methods of stream processing. It
//! provides a "noop" mode to give us the `memcpy` speed.
//!
//! This will consume all of the available stdin, so you should run it
//! with `head` in a pipe to restrict. For example, to test ASCII input:
//!
//! bench-stream --mode=gen-ascii | head -c 50M | bench-stream --mode=simd
//!
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const cli = @import("../cli.zig");
const terminal = @import("../terminal/main.zig");
const synthetic = @import("../synthetic/main.zig");
const Args = struct {
mode: Mode = .noop,
/// The PRNG seed used by the input generators.
/// -1 uses a random seed (default)
seed: i64 = -1,
/// Process input with a real terminal. This will be MUCH slower than
/// the other modes because it has to maintain terminal state but will
/// help get more realistic numbers.
terminal: Terminal = .none,
@"terminal-rows": usize = 80,
@"terminal-cols": usize = 120,
/// The size for read buffers. Doesn't usually need to be changed. The
/// main point is to make this runtime known so we can avoid compiler
/// optimizations.
@"buffer-size": usize = 4096,
/// This is set by the CLI parser for deinit.
_arena: ?ArenaAllocator = null,
pub fn deinit(self: *Args) void {
if (self._arena) |arena| arena.deinit();
self.* = undefined;
}
const Terminal = enum { none, new };
};
const Mode = enum {
// Do nothing, just read from stdin into a stack-allocated buffer.
// This is used to benchmark our base-case: it gives us our maximum
// throughput on a basic read.
noop,
// These benchmark the throughput of the terminal stream parsing
// with and without SIMD. The "simd" option will use whatever is best
// for the running platform.
//
// Note that these run through the full VT parser but do not apply
// the operations to terminal state, so there is no terminal state
// overhead.
scalar,
simd,
// Generate an infinite stream of random printable ASCII characters.
@"gen-ascii",
// Generate an infinite stream of random printable unicode characters.
@"gen-utf8",
// Generate an infinite stream of arbitrary random bytes.
@"gen-rand",
// Generate an infinite stream of OSC requests. These will be mixed
// with valid and invalid OSC requests by default, but the
// `-valid` and `-invalid`-suffixed variants can be used to get only
// a specific type of OSC request.
@"gen-osc",
@"gen-osc-valid",
@"gen-osc-invalid",
};
pub const std_options: std.Options = .{
.log_level = .debug,
};
pub fn main() !void {
// We want to use the c allocator because it is much faster than GPA.
const alloc = std.heap.c_allocator;
// Parse our args
var args: Args = .{};
defer args.deinit();
{
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Args, alloc, &args, &iter);
}
const reader = std.io.getStdIn().reader();
const writer = std.io.getStdOut().writer();
const buf = try alloc.alloc(u8, args.@"buffer-size");
// Build our RNG
const seed: u64 = if (args.seed >= 0) @bitCast(args.seed) else @truncate(@as(u128, @bitCast(std.time.nanoTimestamp())));
var prng = std.Random.DefaultPrng.init(seed);
const rand = prng.random();
// Handle the modes that do not depend on terminal state first.
switch (args.mode) {
.@"gen-ascii" => {
var gen: synthetic.Bytes = .{
.rand = rand,
.alphabet = synthetic.Bytes.Alphabet.ascii,
};
try generate(writer, gen.generator());
},
.@"gen-utf8" => {
var gen: synthetic.Utf8 = .{
.rand = rand,
};
try generate(writer, gen.generator());
},
.@"gen-rand" => {
var gen: synthetic.Bytes = .{ .rand = rand };
try generate(writer, gen.generator());
},
.@"gen-osc" => {
var gen: synthetic.Osc = .{
.rand = rand,
.p_valid = 0.5,
};
try generate(writer, gen.generator());
},
.@"gen-osc-valid" => {
var gen: synthetic.Osc = .{
.rand = rand,
.p_valid = 1.0,
};
try generate(writer, gen.generator());
},
.@"gen-osc-invalid" => {
var gen: synthetic.Osc = .{
.rand = rand,
.p_valid = 0.0,
};
try generate(writer, gen.generator());
},
.noop => try benchNoop(reader, buf),
// Handle the ones that depend on terminal state next
inline .scalar,
.simd,
=> |tag| switch (args.terminal) {
.new => {
const TerminalStream = terminal.Stream(*TerminalHandler);
var t = try terminal.Terminal.init(alloc, .{
.cols = @intCast(args.@"terminal-cols"),
.rows = @intCast(args.@"terminal-rows"),
});
var handler: TerminalHandler = .{ .t = &t };
var stream: TerminalStream = .{ .handler = &handler };
switch (tag) {
.scalar => try benchScalar(reader, &stream, buf),
.simd => try benchSimd(reader, &stream, buf),
else => @compileError("missing case"),
}
},
.none => {
var stream: terminal.Stream(NoopHandler) = .{ .handler = .{} };
switch (tag) {
.scalar => try benchScalar(reader, &stream, buf),
.simd => try benchSimd(reader, &stream, buf),
else => @compileError("missing case"),
}
},
},
}
}
fn generate(
writer: anytype,
gen: synthetic.Generator,
) !void {
var buf: [1024]u8 = undefined;
while (true) {
const data = try gen.next(&buf);
writer.writeAll(data) catch |err| switch (err) {
error.BrokenPipe => return, // stdout closed
else => return err,
};
}
}
noinline fn benchNoop(reader: anytype, buf: []u8) !void {
var total: usize = 0;
while (true) {
const n = try reader.readAll(buf);
if (n == 0) break;
total += n;
}
std.log.info("total bytes len={}", .{total});
}
noinline fn benchScalar(
reader: anytype,
stream: anytype,
buf: []u8,
) !void {
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
// Using stream.next directly with a for loop applies a naive
// scalar approach.
for (buf[0..n]) |c| try stream.next(c);
}
}
noinline fn benchSimd(
reader: anytype,
stream: anytype,
buf: []u8,
) !void {
while (true) {
const n = try reader.read(buf);
if (n == 0) break;
try stream.nextSlice(buf[0..n]);
}
}
const NoopHandler = struct {
pub fn print(self: NoopHandler, cp: u21) !void {
_ = self;
_ = cp;
}
};
const TerminalHandler = struct {
t: *terminal.Terminal,
pub fn print(self: *TerminalHandler, cp: u21) !void {
try self.t.print(cp);
}
};

166
src/benchmark/Benchmark.zig Normal file
View File

@ -0,0 +1,166 @@
//! A single benchmark case.
const Benchmark = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const macos = @import("macos");
const build_config = @import("../build_config.zig");
ptr: *anyopaque,
vtable: VTable,
/// Create a new benchmark from a pointer and a vtable.
///
/// This usually is only called by benchmark implementations, not
/// benchmark users.
pub fn init(
pointer: anytype,
vtable: VTable,
) Benchmark {
const Ptr = @TypeOf(pointer);
assert(@typeInfo(Ptr) == .pointer); // Must be a pointer
assert(@typeInfo(Ptr).pointer.size == .one); // Must be a single-item pointer
assert(@typeInfo(@typeInfo(Ptr).pointer.child) == .@"struct"); // Must point to a struct
return .{ .ptr = pointer, .vtable = vtable };
}
/// Run the benchmark.
pub fn run(
self: Benchmark,
mode: RunMode,
) Error!RunResult {
// Run our setup function if it exists. We do this first because
// we don't want this part of our benchmark and we want to fail fast.
if (self.vtable.setupFn) |func| try func(self.ptr);
defer if (self.vtable.teardownFn) |func| func(self.ptr);
// Our result accumulator. This will be returned at the end of the run.
var result: RunResult = .{};
// If we're on macOS, we setup signposts so its easier to find
// the results in Instruments. There's a lot of nasty comptime stuff
// here but its just to ensure this does nothing on other platforms.
const signpost_name = "ghostty";
const signpost: if (builtin.target.os.tag.isDarwin()) struct {
log: *macos.os.Log,
id: macos.os.signpost.Id,
} else void = if (builtin.target.os.tag.isDarwin()) darwin: {
macos.os.signpost.init();
const log = macos.os.Log.create(
build_config.bundle_id,
macos.os.signpost.Category.points_of_interest,
);
const id = macos.os.signpost.Id.forPointer(log, self.ptr);
macos.os.signpost.intervalBegin(log, id, signpost_name);
break :darwin .{ .log = log, .id = id };
} else {};
defer if (comptime builtin.target.os.tag.isDarwin()) {
macos.os.signpost.intervalEnd(
signpost.log,
signpost.id,
signpost_name,
);
signpost.log.release();
};
const start = std.time.Instant.now() catch return error.BenchmarkFailed;
while (true) {
// Run our step function. If it fails, we return the error.
try self.vtable.stepFn(self.ptr);
result.iterations += 1;
// Get our current monotonic time and check our exit conditions.
const now = std.time.Instant.now() catch return error.BenchmarkFailed;
const exit = switch (mode) {
.once => true,
.duration => |ns| now.since(start) >= ns,
};
if (exit) {
result.duration = now.since(start);
return result;
}
}
// We exit within the loop body.
unreachable;
}
/// The type of benchmark run. This is used to determine how the benchmark
/// is executed.
pub const RunMode = union(enum) {
/// Run the benchmark exactly once.
once,
/// Run the benchmark for a fixed duration in nanoseconds. This
/// will not interrupt a running step so if the granularity of the
/// duration is too low, benchmark results may be inaccurate.
duration: u64,
};
/// The result of a benchmark run.
pub const RunResult = struct {
/// The total iterations that step was executed. For "once" run
/// modes this will always be 1.
iterations: u32 = 0,
/// The total time taken for the run. For "duration" run modes
/// this will be relatively close to the requested duration.
/// The units are nanoseconds.
duration: u64 = 0,
};
/// The possible errors that can occur during various stages of the
/// benchmark. Right now its just "failure" which ends the benchmark.
pub const Error = error{BenchmarkFailed};
/// The vtable that must be provided to invoke the real implementation.
pub const VTable = struct {
/// A single step to execute the benchmark. This should do the work
/// that is under test. This may be called multiple times if we're
/// testing throughput.
stepFn: *const fn (ptr: *anyopaque) Error!void,
/// Setup and teardown functions. These are called once before
/// the first step and once after the last step. They are not part
/// of the benchmark results (unless you're benchmarking the full
/// binary).
setupFn: ?*const fn (ptr: *anyopaque) Error!void = null,
teardownFn: ?*const fn (ptr: *anyopaque) void = null,
};
test Benchmark {
const testing = std.testing;
const Simple = struct {
const Self = @This();
setup_i: usize = 0,
step_i: usize = 0,
pub fn benchmark(self: *Self) Benchmark {
return .init(self, .{
.stepFn = step,
.setupFn = setup,
});
}
fn setup(ptr: *anyopaque) Error!void {
const self: *Self = @ptrCast(@alignCast(ptr));
self.setup_i += 1;
}
fn step(ptr: *anyopaque) Error!void {
const self: *Self = @ptrCast(@alignCast(ptr));
self.step_i += 1;
}
};
var s: Simple = .{};
const b = s.benchmark();
const result = try b.run(.once);
try testing.expectEqual(1, s.setup_i);
try testing.expectEqual(1, s.step_i);
try testing.expectEqual(1, result.iterations);
try testing.expect(result.duration > 0);
}

34
src/benchmark/CApi.zig Normal file
View File

@ -0,0 +1,34 @@
const std = @import("std");
const cli = @import("cli.zig");
const state = &@import("../global.zig").state;
const log = std.log.scoped(.benchmark);
/// Run the Ghostty benchmark CLI with the given action and arguments.
export fn ghostty_benchmark_cli(
action_name_: [*:0]const u8,
args: [*:0]const u8,
) bool {
const action_name = std.mem.sliceTo(action_name_, 0);
const action: cli.Action = std.meta.stringToEnum(
cli.Action,
action_name,
) orelse {
log.warn("unknown action={s}", .{action_name});
return false;
};
cli.mainAction(
state.alloc,
action,
.{ .string = std.mem.sliceTo(args, 0) },
) catch |err| {
log.warn("failed to run action={s} err={}", .{
@tagName(action),
err,
});
return false;
};
return true;
}

View File

@ -0,0 +1,204 @@
//! This benchmark tests the throughput of codepoint width calculation.
//! This is a common operation in terminal character printing and the
//! motivating factor to write this benchmark was discovering that our
//! codepoint width function was 30% of the runtime of every character
//! print.
const CodepointWidth = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const UTF8Decoder = @import("../terminal/UTF8Decoder.zig");
const simd = @import("../simd/main.zig");
const table = @import("../unicode/main.zig").table;
const log = std.log.scoped(.@"terminal-stream-bench");
opts: Options,
/// The file, opened in the setup function.
data_f: ?std.fs.File = null,
pub const Options = struct {
/// The type of codepoint width calculation to use.
mode: Mode = .noop,
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
data: ?[]const u8 = null,
};
pub const Mode = enum {
/// The baseline mode copies the data from the fd into a buffer. This
/// is used to show the minimal overhead of reading the fd into memory
/// and establishes a baseline for the other modes.
noop,
/// libc wcwidth
wcwidth,
/// Our SIMD implementation.
simd,
/// Test our lookup table implementation.
table,
};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
opts: Options,
) !*CodepointWidth {
const ptr = try alloc.create(CodepointWidth);
errdefer alloc.destroy(ptr);
ptr.* = .{ .opts = opts };
return ptr;
}
pub fn destroy(self: *CodepointWidth, alloc: Allocator) void {
alloc.destroy(self);
}
pub fn benchmark(self: *CodepointWidth) Benchmark {
return .init(self, .{
.stepFn = switch (self.opts.mode) {
.noop => stepNoop,
.wcwidth => stepWcwidth,
.table => stepTable,
.simd => stepSimd,
},
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
// Open our data file to prepare for reading. We can do more
// validation here eventually.
assert(self.data_f == null);
self.data_f = options.dataFile(self.opts.data) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
};
}
fn teardown(ptr: *anyopaque) void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
if (self.data_f) |f| {
f.close();
self.data_f = null;
}
}
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
_ = ptr;
}
extern "c" fn wcwidth(c: u32) c_int;
fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var r = std.io.bufferedReader(f.reader());
var d: UTF8Decoder = .{};
var buf: [4096]u8 = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp| {
const width = wcwidth(cp);
// Write the width to the buffer to avoid it being compiled
// away
buf[0] = @intCast(width);
}
}
}
}
fn stepTable(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var r = std.io.bufferedReader(f.reader());
var d: UTF8Decoder = .{};
var buf: [4096]u8 = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp| {
// This is the same trick we do in terminal.zig so we
// keep it here.
const width = if (cp <= 0xFF)
1
else
table.get(@intCast(cp)).width;
// Write the width to the buffer to avoid it being compiled
// away
buf[0] = @intCast(width);
}
}
}
}
fn stepSimd(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var r = std.io.bufferedReader(f.reader());
var d: UTF8Decoder = .{};
var buf: [4096]u8 = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp| {
const width = simd.codepointWidth(cp);
// Write the width to the buffer to avoid it being compiled
// away
buf[0] = @intCast(width);
}
}
}
}
test CodepointWidth {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *CodepointWidth = try .create(alloc, .{});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@ -0,0 +1,146 @@
//! This benchmark tests the throughput of grapheme break calculation.
//! This is a common operation in terminal character printing for terminals
//! that support grapheme clustering.
const GraphemeBreak = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const UTF8Decoder = @import("../terminal/UTF8Decoder.zig");
const unicode = @import("../unicode/main.zig");
const log = std.log.scoped(.@"terminal-stream-bench");
opts: Options,
/// The file, opened in the setup function.
data_f: ?std.fs.File = null,
pub const Options = struct {
/// The type of codepoint width calculation to use.
mode: Mode = .table,
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
data: ?[]const u8 = null,
};
pub const Mode = enum {
/// The baseline mode copies the data from the fd into a buffer. This
/// is used to show the minimal overhead of reading the fd into memory
/// and establishes a baseline for the other modes.
noop,
/// Ghostty's table-based approach.
table,
};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
opts: Options,
) !*GraphemeBreak {
const ptr = try alloc.create(GraphemeBreak);
errdefer alloc.destroy(ptr);
ptr.* = .{ .opts = opts };
return ptr;
}
pub fn destroy(self: *GraphemeBreak, alloc: Allocator) void {
alloc.destroy(self);
}
pub fn benchmark(self: *GraphemeBreak) Benchmark {
return .init(self, .{
.stepFn = switch (self.opts.mode) {
.noop => stepNoop,
.table => stepTable,
},
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *GraphemeBreak = @ptrCast(@alignCast(ptr));
// Open our data file to prepare for reading. We can do more
// validation here eventually.
assert(self.data_f == null);
self.data_f = options.dataFile(self.opts.data) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
};
}
fn teardown(ptr: *anyopaque) void {
const self: *GraphemeBreak = @ptrCast(@alignCast(ptr));
if (self.data_f) |f| {
f.close();
self.data_f = null;
}
}
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
const self: *GraphemeBreak = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var r = std.io.bufferedReader(f.reader());
var d: UTF8Decoder = .{};
var buf: [4096]u8 = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
for (buf[0..n]) |c| {
_ = d.next(c);
}
}
}
fn stepTable(ptr: *anyopaque) Benchmark.Error!void {
const self: *GraphemeBreak = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var r = std.io.bufferedReader(f.reader());
var d: UTF8Decoder = .{};
var state: unicode.GraphemeBreakState = .{};
var cp1: u21 = 0;
var buf: [4096]u8 = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
for (buf[0..n]) |c| {
const cp_, const consumed = d.next(c);
assert(consumed);
if (cp_) |cp2| {
const v = unicode.graphemeBreak(cp1, @intCast(cp2), &state);
buf[0] = @intCast(@intFromBool(v));
cp1 = cp2;
}
}
}
}
test GraphemeBreak {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *GraphemeBreak = try .create(alloc, .{});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@ -0,0 +1,106 @@
//! This benchmark tests the throughput of the terminal escape code parser.
const TerminalParser = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const terminalpkg = @import("../terminal/main.zig");
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const log = std.log.scoped(.@"terminal-stream-bench");
opts: Options,
/// The file, opened in the setup function.
data_f: ?std.fs.File = null,
pub const Options = struct {
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
data: ?[]const u8 = null,
};
pub fn create(
alloc: Allocator,
opts: Options,
) !*TerminalParser {
const ptr = try alloc.create(TerminalParser);
errdefer alloc.destroy(ptr);
ptr.* = .{ .opts = opts };
return ptr;
}
pub fn destroy(self: *TerminalParser, alloc: Allocator) void {
alloc.destroy(self);
}
pub fn benchmark(self: *TerminalParser) Benchmark {
return .init(self, .{
.stepFn = step,
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalParser = @ptrCast(@alignCast(ptr));
// Open our data file to prepare for reading. We can do more
// validation here eventually.
assert(self.data_f == null);
self.data_f = options.dataFile(self.opts.data) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
};
}
fn teardown(ptr: *anyopaque) void {
const self: *TerminalParser = @ptrCast(@alignCast(ptr));
if (self.data_f) |f| {
f.close();
self.data_f = null;
}
}
fn step(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalParser = @ptrCast(@alignCast(ptr));
// Get our buffered reader so we're not predominantly
// waiting on file IO. It'd be better to move this fully into
// memory. If we're IO bound though that should show up on
// the benchmark results and... I know writing this that we
// aren't currently IO bound.
const f = self.data_f orelse return;
var r = std.io.bufferedReader(f.reader());
var p: terminalpkg.Parser = .{};
var buf: [4096]u8 = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
for (buf[0..n]) |c| {
const actions = p.next(c);
//std.log.warn("actions={any}", .{actions});
_ = actions;
}
}
}
test TerminalParser {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *TerminalParser = try .create(alloc, .{});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@ -0,0 +1,153 @@
//! This benchmark tests the performance of the terminal stream
//! handler from input to terminal state update. This is useful to
//! test general throughput of VT parsing and handling.
//!
//! Note that the handler used for this benchmark isn't the full
//! terminal handler, since that requires a significant amount of
//! state. This is a simplified version that only handles specific
//! terminal operations like printing characters. We should expand
//! this to include more operations to improve the accuracy of the
//! benchmark.
//!
//! It is a fairly broad benchmark that can be used to determine
//! if we need to optimize something more specific (e.g. the parser).
const TerminalStream = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const terminalpkg = @import("../terminal/main.zig");
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Terminal = terminalpkg.Terminal;
const Stream = terminalpkg.Stream(*Handler);
const log = std.log.scoped(.@"terminal-stream-bench");
opts: Options,
terminal: Terminal,
handler: Handler,
stream: Stream,
/// The file, opened in the setup function.
data_f: ?std.fs.File = null,
pub const Options = struct {
/// The size of the terminal. This affects benchmarking when
/// dealing with soft line wrapping and the memory impact
/// of page sizes.
@"terminal-rows": u16 = 80,
@"terminal-cols": u16 = 120,
/// The data to read as a filepath. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
data: ?[]const u8 = null,
};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
opts: Options,
) !*TerminalStream {
const ptr = try alloc.create(TerminalStream);
errdefer alloc.destroy(ptr);
ptr.* = .{
.opts = opts,
.terminal = try .init(alloc, .{
.rows = opts.@"terminal-rows",
.cols = opts.@"terminal-cols",
}),
.handler = .{ .t = &ptr.terminal },
.stream = .{ .handler = &ptr.handler },
};
return ptr;
}
pub fn destroy(self: *TerminalStream, alloc: Allocator) void {
self.terminal.deinit(alloc);
alloc.destroy(self);
}
pub fn benchmark(self: *TerminalStream) Benchmark {
return .init(self, .{
.stepFn = step,
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
// Always reset our terminal state
self.terminal.fullReset();
// Open our data file to prepare for reading. We can do more
// validation here eventually.
assert(self.data_f == null);
self.data_f = options.dataFile(self.opts.data) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
};
}
fn teardown(ptr: *anyopaque) void {
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
if (self.data_f) |f| {
f.close();
self.data_f = null;
}
}
fn step(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
// Get our buffered reader so we're not predominantly
// waiting on file IO. It'd be better to move this fully into
// memory. If we're IO bound though that should show up on
// the benchmark results and... I know writing this that we
// aren't currently IO bound.
const f = self.data_f orelse return;
var r = std.io.bufferedReader(f.reader());
var buf: [4096]u8 = undefined;
while (true) {
const n = r.read(&buf) catch |err| {
log.warn("error reading data file err={}", .{err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
const chunk = buf[0..n];
self.stream.nextSlice(chunk) catch |err| {
log.warn("error processing data file chunk err={}", .{err});
return error.BenchmarkFailed;
};
}
}
/// Implements the handler interface for the terminal.Stream.
/// We should expand this to include more operations to make
/// our benchmark more realistic.
const Handler = struct {
t: *Terminal,
pub fn print(self: *Handler, cp: u21) !void {
try self.t.print(cp);
}
};
test TerminalStream {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *TerminalStream = try .create(alloc, .{});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

94
src/benchmark/cli.zig Normal file
View File

@ -0,0 +1,94 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cli = @import("../cli.zig");
/// The available actions for the CLI. This is the list of available
/// benchmarks. View docs for each individual one in the predictably
/// named files.
pub const Action = enum {
@"codepoint-width",
@"grapheme-break",
@"terminal-parser",
@"terminal-stream",
/// Returns the struct associated with the action. The struct
/// should have a few decls:
///
/// - `const Options`: The CLI options for the action.
/// - `fn create`: Create a new instance of the action from options.
/// - `fn benchmark`: Returns a `Benchmark` instance for the action.
///
/// See TerminalStream for an example.
pub fn Struct(comptime action: Action) type {
return switch (action) {
.@"terminal-stream" => @import("TerminalStream.zig"),
.@"codepoint-width" => @import("CodepointWidth.zig"),
.@"grapheme-break" => @import("GraphemeBreak.zig"),
.@"terminal-parser" => @import("TerminalParser.zig"),
};
}
};
/// An entrypoint for the benchmark CLI.
pub fn main() !void {
const alloc = std.heap.c_allocator;
const action_ = try cli.action.detectArgs(Action, alloc);
const action = action_ orelse return error.NoAction;
try mainAction(alloc, action, .cli);
}
/// Arguments that can be passed to the benchmark.
pub const Args = union(enum) {
/// The arguments passed to the CLI via argc/argv.
cli,
/// Simple string arguments, parsed via std.process.ArgIteratorGeneral.
string: []const u8,
};
pub fn mainAction(
alloc: Allocator,
action: Action,
args: Args,
) !void {
switch (action) {
inline else => |comptime_action| {
const BenchmarkImpl = Action.Struct(comptime_action);
try mainActionImpl(BenchmarkImpl, alloc, args);
},
}
}
fn mainActionImpl(
comptime BenchmarkImpl: type,
alloc: Allocator,
args: Args,
) !void {
// First, parse our CLI options.
const Options = BenchmarkImpl.Options;
var opts: Options = .{};
defer if (@hasDecl(Options, "deinit")) opts.deinit();
switch (args) {
.cli => {
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Options, alloc, &opts, &iter);
},
.string => |str| {
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
str,
);
defer iter.deinit();
try cli.args.parse(Options, alloc, &opts, &iter);
},
}
// Create our implementation
const impl = try BenchmarkImpl.create(alloc, opts);
defer impl.destroy(alloc);
// Initialize our benchmark
const b = impl.benchmark();
_ = try b.run(.once);
}

11
src/benchmark/main.zig Normal file
View File

@ -0,0 +1,11 @@
pub const cli = @import("cli.zig");
pub const Benchmark = @import("Benchmark.zig");
pub const CApi = @import("CApi.zig");
pub const TerminalStream = @import("TerminalStream.zig");
pub const CodepointWidth = @import("CodepointWidth.zig");
pub const GraphemeBreak = @import("GraphemeBreak.zig");
pub const TerminalParser = @import("TerminalParser.zig");
test {
@import("std").testing.refAllDecls(@This());
}

20
src/benchmark/options.zig Normal file
View File

@ -0,0 +1,20 @@
//! This file contains helpers for CLI options.
const std = @import("std");
/// Returns the data file for the given path in a way that is consistent
/// across our CLI. If the path is not set then no file is returned.
/// If the path is "-", then we will return stdin. If the path is
/// a file then we will open and return the handle.
pub fn dataFile(path_: ?[]const u8) !?std.fs.File {
const path = path_ orelse return null;
// Stdin
if (std.mem.eql(u8, path, "-")) return std.io.getStdIn();
// Normal file
const file = try std.fs.cwd().openFile(path, .{});
errdefer file.close();
return file;
}

View File

@ -528,11 +528,6 @@ pub const ExeEntrypoint = enum {
webgen_config,
webgen_actions,
webgen_commands,
bench_parser,
bench_stream,
bench_codepoint_width,
bench_grapheme_break,
bench_page_init,
};
/// The release channel for the build.

View File

@ -14,52 +14,37 @@ pub fn init(
var steps = std.ArrayList(*std.Build.Step.Compile).init(b.allocator);
errdefer steps.deinit();
// Open the directory ./src/bench
const c_dir_path = b.pathFromRoot("src/bench");
var c_dir = try std.fs.cwd().openDir(c_dir_path, .{ .iterate = true });
defer c_dir.close();
// Go through and add each as a step
var c_dir_it = c_dir.iterate();
while (try c_dir_it.next()) |entry| {
// Get the index of the last '.' so we can strip the extension.
const index = std.mem.lastIndexOfScalar(u8, entry.name, '.') orelse continue;
if (index == 0) continue;
// If it doesn't end in 'zig' then ignore
if (!std.mem.eql(u8, entry.name[index + 1 ..], "zig")) continue;
// Name of the conformance app and full path to the entrypoint.
const name = entry.name[0..index];
// Executable builder.
const bin_name = try std.fmt.allocPrint(b.allocator, "bench-{s}", .{name});
const c_exe = b.addExecutable(.{
.name = bin_name,
// Our synthetic data generator
{
const exe = b.addExecutable(.{
.name = "ghostty-gen",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.root_source_file = b.path("src/main_gen.zig"),
.target = deps.config.target,
// We always want our datagen to be fast because it
// takes awhile to run.
.optimize = .ReleaseFast,
}),
});
exe.linkLibC();
_ = try deps.add(exe);
try steps.append(exe);
}
// Our benchmarking application.
{
const exe = b.addExecutable(.{
.name = "ghostty-bench",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main_bench.zig"),
.target = deps.config.target,
// We always want our benchmarks to be in release mode.
.optimize = .ReleaseFast,
}),
});
c_exe.linkLibC();
// Update our entrypoint
var enum_name: [64]u8 = undefined;
@memcpy(enum_name[0..name.len], name);
std.mem.replaceScalar(u8, enum_name[0..name.len], '-', '_');
var buf: [64]u8 = undefined;
const new_deps = try deps.changeEntrypoint(b, std.meta.stringToEnum(
Config.ExeEntrypoint,
try std.fmt.bufPrint(&buf, "bench_{s}", .{enum_name[0..name.len]}),
).?);
_ = try new_deps.add(c_exe);
try steps.append(c_exe);
exe.linkLibC();
_ = try deps.add(exe);
try steps.append(exe);
}
return .{ .steps = steps.items };

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Config = @import("../config/Config.zig");
const Action = @import("../cli/action.zig").Action;
const Action = @import("../cli.zig").ghostty.Action;
/// A bash completions configuration that contains all the available commands
/// and options.

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Config = @import("../config/Config.zig");
const Action = @import("../cli/action.zig").Action;
const Action = @import("../cli.zig").ghostty.Action;
/// A fish completions configuration that contains all the available commands
/// and options.

View File

@ -2,7 +2,7 @@ const std = @import("std");
const help_strings = @import("help_strings");
const build_config = @import("../../build_config.zig");
const Config = @import("../../config/Config.zig");
const Action = @import("../../cli/action.zig").Action;
const Action = @import("../../cli/ghostty.zig").Action;
const KeybindAction = @import("../../input/Binding.zig").Action;
pub fn substitute(alloc: std.mem.Allocator, input: []const u8, writer: anytype) !void {

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Config = @import("../config/Config.zig");
const Action = @import("../cli/action.zig").Action;
const Action = @import("../cli.zig").ghostty.Action;
/// A zsh completions configuration that contains all the available commands
/// and options.

View File

@ -1,7 +1,8 @@
const diags = @import("cli/diagnostics.zig");
pub const args = @import("cli/args.zig");
pub const Action = @import("cli/action.zig").Action;
pub const action = @import("cli/action.zig");
pub const ghostty = @import("cli/ghostty.zig");
pub const CompatibilityHandler = args.CompatibilityHandler;
pub const compatibilityRenamed = args.compatibilityRenamed;
pub const DiagnosticList = diags.DiagnosticList;

View File

@ -1,320 +1,277 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const help_strings = @import("help_strings");
const list_fonts = @import("list_fonts.zig");
const help = @import("help.zig");
const version = @import("version.zig");
const list_keybinds = @import("list_keybinds.zig");
const list_themes = @import("list_themes.zig");
const list_colors = @import("list_colors.zig");
const list_actions = @import("list_actions.zig");
const ssh_cache = @import("ssh_cache.zig");
const edit_config = @import("edit_config.zig");
const show_config = @import("show_config.zig");
const validate_config = @import("validate_config.zig");
const crash_report = @import("crash_report.zig");
const show_face = @import("show_face.zig");
const boo = @import("boo.zig");
pub const DetectError = error{
/// Multiple actions were detected. You can specify at most one
/// action on the CLI otherwise the behavior desired is ambiguous.
MultipleActions,
/// Special commands that can be invoked via CLI flags. These are all
/// invoked by using `+<action>` as a CLI flag. The only exception is
/// "version" which can be invoked additionally with `--version`.
pub const Action = enum {
/// Output the version and exit
version,
/// Output help information for the CLI or configuration
help,
/// List available fonts
@"list-fonts",
/// List available keybinds
@"list-keybinds",
/// List available themes
@"list-themes",
/// List named RGB colors
@"list-colors",
/// List keybind actions
@"list-actions",
/// Manage SSH terminfo cache for automatic remote host setup
@"ssh-cache",
/// Edit the config file in the configured terminal editor.
@"edit-config",
/// Dump the config to stdout
@"show-config",
// Validate passed config file
@"validate-config",
// Show which font face Ghostty loads a codepoint from.
@"show-face",
// List, (eventually) view, and (eventually) send crash reports.
@"crash-report",
// Boo!
boo,
pub const Error = error{
/// Multiple actions were detected. You can specify at most one
/// action on the CLI otherwise the behavior desired is ambiguous.
MultipleActions,
/// An unknown action was specified.
InvalidAction,
};
/// This should be returned by actions that want to print the help text.
pub const help_error = error.ActionHelpRequested;
/// Detect the action from CLI args.
pub fn detectCLI(alloc: Allocator) !?Action {
var iter = try std.process.argsWithAllocator(alloc);
defer iter.deinit();
return try detectIter(&iter);
}
/// Detect the action from any iterator, used primarily for tests.
pub fn detectIter(iter: anytype) Error!?Action {
var pending_help: bool = false;
var pending: ?Action = null;
while (iter.next()) |arg| {
// If we see a "-e" and we haven't seen a command yet, then
// we are done looking for commands. This special case enables
// `ghostty -e ghostty +command`. If we've seen a command we
// still want to keep looking because
// `ghostty +command -e +command` is invalid.
if (std.mem.eql(u8, arg, "-e") and pending == null) return null;
// Special case, --version always outputs the version no
// matter what, no matter what other args exist.
if (std.mem.eql(u8, arg, "--version")) return .version;
// --help matches "help" but if a subcommand is specified
// then we match the subcommand.
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
pending_help = true;
continue;
}
// Commands must start with "+"
if (arg.len == 0 or arg[0] != '+') continue;
if (pending != null) return Error.MultipleActions;
pending = std.meta.stringToEnum(Action, arg[1..]) orelse return Error.InvalidAction;
}
// If we have an action, we always return that action, even if we've
// seen "--help" or "-h" because the action may have its own help text.
if (pending != null) return pending;
// If we've seen "--help" or "-h" then we return the help action.
if (pending_help) return .help;
return pending;
}
/// Run the action. This returns the exit code to exit with.
pub fn run(self: Action, alloc: Allocator) !u8 {
return self.runMain(alloc) catch |err| switch (err) {
// If help is requested, then we use some comptime trickery
// to find this action in the help strings and output that.
help_error => err: {
inline for (@typeInfo(Action).@"enum".fields) |field| {
// Future note: for now we just output the help text directly
// to stdout. In the future we can style this much prettier
// for all commands by just changing this one place.
if (std.mem.eql(u8, field.name, @tagName(self))) {
const stdout = std.io.getStdOut().writer();
const text = @field(help_strings.Action, field.name) ++ "\n";
stdout.writeAll(text) catch |write_err| {
std.log.warn("failed to write help text: {}\n", .{write_err});
break :err 1;
};
break :err 0;
}
}
break :err err;
},
else => err,
};
}
fn runMain(self: Action, alloc: Allocator) !u8 {
return switch (self) {
.version => try version.run(alloc),
.help => try help.run(alloc),
.@"list-fonts" => try list_fonts.run(alloc),
.@"list-keybinds" => try list_keybinds.run(alloc),
.@"list-themes" => try list_themes.run(alloc),
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
.@"ssh-cache" => try ssh_cache.run(alloc),
.@"edit-config" => try edit_config.run(alloc),
.@"show-config" => try show_config.run(alloc),
.@"validate-config" => try validate_config.run(alloc),
.@"crash-report" => try crash_report.run(alloc),
.@"show-face" => try show_face.run(alloc),
.boo => try boo.run(alloc),
};
}
/// Returns the filename associated with an action. This is a relative
/// path from the root src/ directory.
pub fn file(comptime self: Action) []const u8 {
comptime {
const filename = filename: {
const tag = @tagName(self);
var filename: [tag.len]u8 = undefined;
_ = std.mem.replace(u8, tag, "-", "_", &filename);
break :filename &filename;
};
return "cli/" ++ filename ++ ".zig";
}
}
/// Returns the options of action. Supports generating shell completions
/// without duplicating the mapping from Action to relevant Option
/// @import(..) declaration.
pub fn options(comptime self: Action) type {
comptime {
return switch (self) {
.version => version.Options,
.help => help.Options,
.@"list-fonts" => list_fonts.Options,
.@"list-keybinds" => list_keybinds.Options,
.@"list-themes" => list_themes.Options,
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
.@"ssh-cache" => ssh_cache.Options,
.@"edit-config" => edit_config.Options,
.@"show-config" => show_config.Options,
.@"validate-config" => validate_config.Options,
.@"crash-report" => crash_report.Options,
.@"show-face" => show_face.Options,
.boo => boo.Options,
};
}
}
/// An unknown action was specified.
InvalidAction,
};
test "parse action none" {
/// Detect the action from CLI args.
pub fn detectArgs(comptime E: type, alloc: Allocator) !?E {
var iter = try std.process.argsWithAllocator(alloc);
defer iter.deinit();
return try detectIter(E, &iter);
}
/// Detect the action from any iterator. Each iterator value should yield
/// a CLI argument such as "--foo".
///
/// The comptime type E must be an enum with the available actions.
/// If the type E has a decl `detectSpecialCase`, then it will be called
/// for each argument to allow handling of special cases. The function
/// signature for `detectSpecialCase` should be:
///
/// fn detectSpecialCase(arg: []const u8) ?SpecialCase(E)
///
pub fn detectIter(
comptime E: type,
iter: anytype,
) DetectError!?E {
var fallback: ?E = null;
var pending: ?E = null;
while (iter.next()) |arg| {
// Allow handling of special cases.
if (@hasDecl(E, "detectSpecialCase")) special: {
const special = E.detectSpecialCase(arg) orelse break :special;
switch (special) {
.action => |a| return a,
.fallback => |a| fallback = a,
.abort_if_no_action => if (pending == null) return null,
}
}
// Commands must start with "+"
if (arg.len == 0 or arg[0] != '+') continue;
if (pending != null) return DetectError.MultipleActions;
pending = std.meta.stringToEnum(E, arg[1..]) orelse
return DetectError.InvalidAction;
}
// If we have an action, we always return that action, even if we've
// seen "--help" or "-h" because the action may have its own help text.
if (pending != null) return pending;
// If we have no action but we have a fallback, then we return that.
if (fallback) |a| return a;
return null;
}
/// The action enum E can implement the decl `detectSpecialCase` to
/// return this enum in order to perform various special case actions.
pub fn SpecialCase(comptime E: type) type {
return union(enum) {
/// Immediately return this action.
action: E,
/// Return this action if no other action is found.
fallback: E,
/// If there is no pending action (we haven't seen an action yet)
/// then we should return no action. This is kind of weird but is
/// a special case to allow "-e" in Ghostty.
abort_if_no_action,
};
}
test "detect direct match" {
const testing = std.testing;
const alloc = testing.allocator;
const Enum = enum { foo, bar, baz };
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 --b --b-f=false",
"+foo",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action == null);
const result = try detectIter(Enum, &iter);
try testing.expectEqual(Enum.foo, result.?);
}
test "parse action version" {
test "detect invalid match" {
const testing = std.testing;
const alloc = testing.allocator;
const Enum = enum { foo, bar, baz };
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 --b --b-f=false --version",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action.? == .version);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--version --a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action.? == .version);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--c=84 --d --version --a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action.? == .version);
}
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+invalid",
);
defer iter.deinit();
try testing.expectError(
DetectError.InvalidAction,
detectIter(Enum, &iter),
);
}
test "parse action plus" {
test "detect multiple actions" {
const testing = std.testing;
const alloc = testing.allocator;
const Enum = enum { foo, bar, baz };
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 --b --b-f=false +version",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action.? == .version);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+version --a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action.? == .version);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--c=84 --d +version --a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action.? == .version);
}
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+foo +bar",
);
defer iter.deinit();
try testing.expectError(
DetectError.MultipleActions,
detectIter(Enum, &iter),
);
}
test "parse action plus ignores -e" {
test "detect no match" {
const testing = std.testing;
const alloc = testing.allocator;
const Enum = enum { foo, bar, baz };
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--some-flag",
);
defer iter.deinit();
const result = try detectIter(Enum, &iter);
try testing.expect(result == null);
}
test "detect special case action" {
const testing = std.testing;
const alloc = testing.allocator;
const Enum = enum {
foo,
bar,
fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) {
return if (std.mem.eql(u8, arg, "--special"))
.{ .action = .foo }
else
null;
}
};
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 -e +version",
"--special +bar",
);
defer iter.deinit();
const action = try Action.detectIter(&iter);
try testing.expect(action == null);
const result = try detectIter(Enum, &iter);
try testing.expectEqual(Enum.foo, result.?);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+list-fonts --a=42 -e +version",
"+bar --special",
);
defer iter.deinit();
try testing.expectError(
Action.Error.MultipleActions,
Action.detectIter(&iter),
const result = try detectIter(Enum, &iter);
try testing.expectEqual(Enum.foo, result.?);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+bar",
);
defer iter.deinit();
const result = try detectIter(Enum, &iter);
try testing.expectEqual(Enum.bar, result.?);
}
}
test "detect special case fallback" {
const testing = std.testing;
const alloc = testing.allocator;
const Enum = enum {
foo,
bar,
fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) {
return if (std.mem.eql(u8, arg, "--special"))
.{ .fallback = .foo }
else
null;
}
};
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--special",
);
defer iter.deinit();
const result = try detectIter(Enum, &iter);
try testing.expectEqual(Enum.foo, result.?);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+bar --special",
);
defer iter.deinit();
const result = try detectIter(Enum, &iter);
try testing.expectEqual(Enum.bar, result.?);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--special +bar",
);
defer iter.deinit();
const result = try detectIter(Enum, &iter);
try testing.expectEqual(Enum.bar, result.?);
}
}
test "detect special case abort_if_no_action" {
const testing = std.testing;
const alloc = testing.allocator;
const Enum = enum {
foo,
bar,
fn detectSpecialCase(arg: []const u8) ?SpecialCase(@This()) {
return if (std.mem.eql(u8, arg, "-e"))
.abort_if_no_action
else
null;
}
};
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"-e",
);
defer iter.deinit();
const result = try detectIter(Enum, &iter);
try testing.expect(result == null);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+foo -e",
);
defer iter.deinit();
const result = try detectIter(Enum, &iter);
try testing.expectEqual(Enum.foo, result.?);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"-e +bar",
);
defer iter.deinit();
const result = try detectIter(Enum, &iter);
try testing.expect(result == null);
}
}

View File

@ -1,7 +1,7 @@
const std = @import("std");
const builtin = @import("builtin");
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const Allocator = std.mem.Allocator;
const help_strings = @import("help_strings");
const vaxis = @import("vaxis");

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const Config = @import("../config.zig").Config;
const crash = @import("../crash/main.zig");

View File

@ -3,7 +3,7 @@ const builtin = @import("builtin");
const assert = std.debug.assert;
const args = @import("args.zig");
const Allocator = std.mem.Allocator;
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const configpkg = @import("../config.zig");
const internal_os = @import("../os/main.zig");
const Config = configpkg.Config;

290
src/cli/ghostty.zig Normal file
View File

@ -0,0 +1,290 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const help_strings = @import("help_strings");
const actionpkg = @import("action.zig");
const SpecialCase = actionpkg.SpecialCase;
const list_fonts = @import("list_fonts.zig");
const help = @import("help.zig");
const version = @import("version.zig");
const list_keybinds = @import("list_keybinds.zig");
const list_themes = @import("list_themes.zig");
const list_colors = @import("list_colors.zig");
const list_actions = @import("list_actions.zig");
const ssh_cache = @import("ssh_cache.zig");
const edit_config = @import("edit_config.zig");
const show_config = @import("show_config.zig");
const validate_config = @import("validate_config.zig");
const crash_report = @import("crash_report.zig");
const show_face = @import("show_face.zig");
const boo = @import("boo.zig");
/// Special commands that can be invoked via CLI flags. These are all
/// invoked by using `+<action>` as a CLI flag. The only exception is
/// "version" which can be invoked additionally with `--version`.
pub const Action = enum {
/// Output the version and exit
version,
/// Output help information for the CLI or configuration
help,
/// List available fonts
@"list-fonts",
/// List available keybinds
@"list-keybinds",
/// List available themes
@"list-themes",
/// List named RGB colors
@"list-colors",
/// List keybind actions
@"list-actions",
/// Manage SSH terminfo cache for automatic remote host setup
@"ssh-cache",
/// Edit the config file in the configured terminal editor.
@"edit-config",
/// Dump the config to stdout
@"show-config",
// Validate passed config file
@"validate-config",
// Show which font face Ghostty loads a codepoint from.
@"show-face",
// List, (eventually) view, and (eventually) send crash reports.
@"crash-report",
// Boo!
boo,
pub fn detectSpecialCase(arg: []const u8) ?SpecialCase(Action) {
// If we see a "-e" and we haven't seen a command yet, then
// we are done looking for commands. This special case enables
// `ghostty -e ghostty +command`. If we've seen a command we
// still want to keep looking because
// `ghostty +command -e +command` is invalid.
if (std.mem.eql(u8, arg, "-e")) return .abort_if_no_action;
// Special case, --version always outputs the version no
// matter what, no matter what other args exist.
if (std.mem.eql(u8, arg, "--version")) {
return .{ .action = .version };
}
// --help matches "help" but if a subcommand is specified
// then we match the subcommand.
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
return .{ .fallback = .help };
}
return null;
}
/// This should be returned by actions that want to print the help text.
pub const help_error = error.ActionHelpRequested;
/// Run the action. This returns the exit code to exit with.
pub fn run(self: Action, alloc: Allocator) !u8 {
return self.runMain(alloc) catch |err| switch (err) {
// If help is requested, then we use some comptime trickery
// to find this action in the help strings and output that.
help_error => err: {
inline for (@typeInfo(Action).@"enum".fields) |field| {
// Future note: for now we just output the help text directly
// to stdout. In the future we can style this much prettier
// for all commands by just changing this one place.
if (std.mem.eql(u8, field.name, @tagName(self))) {
const stdout = std.io.getStdOut().writer();
const text = @field(help_strings.Action, field.name) ++ "\n";
stdout.writeAll(text) catch |write_err| {
std.log.warn("failed to write help text: {}\n", .{write_err});
break :err 1;
};
break :err 0;
}
}
break :err err;
},
else => err,
};
}
fn runMain(self: Action, alloc: Allocator) !u8 {
return switch (self) {
.version => try version.run(alloc),
.help => try help.run(alloc),
.@"list-fonts" => try list_fonts.run(alloc),
.@"list-keybinds" => try list_keybinds.run(alloc),
.@"list-themes" => try list_themes.run(alloc),
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
.@"ssh-cache" => try ssh_cache.run(alloc),
.@"edit-config" => try edit_config.run(alloc),
.@"show-config" => try show_config.run(alloc),
.@"validate-config" => try validate_config.run(alloc),
.@"crash-report" => try crash_report.run(alloc),
.@"show-face" => try show_face.run(alloc),
.boo => try boo.run(alloc),
};
}
/// Returns the filename associated with an action. This is a relative
/// path from the root src/ directory.
pub fn file(comptime self: Action) []const u8 {
comptime {
const filename = filename: {
const tag = @tagName(self);
var filename: [tag.len]u8 = undefined;
_ = std.mem.replace(u8, tag, "-", "_", &filename);
break :filename &filename;
};
return "cli/" ++ filename ++ ".zig";
}
}
/// Returns the options of action. Supports generating shell completions
/// without duplicating the mapping from Action to relevant Option
/// @import(..) declaration.
pub fn options(comptime self: Action) type {
comptime {
return switch (self) {
.version => version.Options,
.help => help.Options,
.@"list-fonts" => list_fonts.Options,
.@"list-keybinds" => list_keybinds.Options,
.@"list-themes" => list_themes.Options,
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
.@"ssh-cache" => ssh_cache.Options,
.@"edit-config" => edit_config.Options,
.@"show-config" => show_config.Options,
.@"validate-config" => validate_config.Options,
.@"crash-report" => crash_report.Options,
.@"show-face" => show_face.Options,
.boo => boo.Options,
};
}
}
};
test "parse action none" {
const testing = std.testing;
const alloc = testing.allocator;
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try actionpkg.detectIter(Action, &iter);
try testing.expect(action == null);
}
test "parse action version" {
const testing = std.testing;
const alloc = testing.allocator;
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 --b --b-f=false --version",
);
defer iter.deinit();
const action = try actionpkg.detectIter(Action, &iter);
try testing.expect(action.? == .version);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--version --a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try actionpkg.detectIter(Action, &iter);
try testing.expect(action.? == .version);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--c=84 --d --version --a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try actionpkg.detectIter(Action, &iter);
try testing.expect(action.? == .version);
}
}
test "parse action plus" {
const testing = std.testing;
const alloc = testing.allocator;
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 --b --b-f=false +version",
);
defer iter.deinit();
const action = try actionpkg.detectIter(Action, &iter);
try testing.expect(action.? == .version);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+version --a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try actionpkg.detectIter(Action, &iter);
try testing.expect(action.? == .version);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--c=84 --d +version --a=42 --b --b-f=false",
);
defer iter.deinit();
const action = try actionpkg.detectIter(Action, &iter);
try testing.expect(action.? == .version);
}
}
test "parse action plus ignores -e" {
const testing = std.testing;
const alloc = testing.allocator;
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"--a=42 -e +version",
);
defer iter.deinit();
const action = try actionpkg.detectIter(Action, &iter);
try testing.expect(action == null);
}
{
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
"+list-fonts --a=42 -e +version",
);
defer iter.deinit();
try testing.expectError(
actionpkg.DetectError.MultipleActions,
actionpkg.detectIter(Action, &iter),
);
}
}

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
// Note that this options struct doesn't implement the `help` decl like other
// actions. That is because the help command is special and wants to handle its

View File

@ -1,6 +1,6 @@
const std = @import("std");
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const Allocator = std.mem.Allocator;
const helpgen_actions = @import("../input/helpgen_actions.zig");

View File

@ -1,5 +1,5 @@
const std = @import("std");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const args = @import("args.zig");
const x11_color = @import("../terminal/main.zig").x11_color;

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const args = @import("args.zig");
const font = @import("../font/main.zig");

View File

@ -1,7 +1,7 @@
const std = @import("std");
const builtin = @import("builtin");
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const Arena = std.heap.ArenaAllocator;
const Allocator = std.mem.Allocator;
const configpkg = @import("../config.zig");

View File

@ -1,7 +1,7 @@
const std = @import("std");
const inputpkg = @import("../input.zig");
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const Config = @import("../config/Config.zig");
const themepkg = @import("../config/theme.zig");
const tui = @import("tui.zig");

View File

@ -1,7 +1,7 @@
const std = @import("std");
const args = @import("args.zig");
const Allocator = std.mem.Allocator;
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const configpkg = @import("../config.zig");
const Config = configpkg.Config;

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const args = @import("args.zig");
const diagnostics = @import("diagnostics.zig");
const font = @import("../font/main.zig");

View File

@ -3,7 +3,7 @@ const fs = std.fs;
const Allocator = std.mem.Allocator;
const xdg = @import("../os/xdg.zig");
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
pub const Entry = @import("ssh-cache/Entry.zig");
pub const DiskCache = @import("ssh-cache/DiskCache.zig");

View File

@ -1,7 +1,7 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const args = @import("args.zig");
const Action = @import("action.zig").Action;
const Action = @import("ghostty.zig").Action;
const Config = @import("../config.zig").Config;
const cli = @import("../cli.zig");

View File

@ -41,7 +41,7 @@ pub const BackgroundImageFit = Config.BackgroundImageFit;
pub const LinkPreviews = Config.LinkPreviews;
// Alternate APIs
pub const CAPI = @import("config/CAPI.zig");
pub const CApi = @import("config/CApi.zig");
pub const Wasm = if (!builtin.target.cpu.arch.isWasm()) struct {} else @import("config/Wasm.zig");
test {

View File

@ -30,7 +30,7 @@ pub const GlobalState = struct {
gpa: ?GPA,
alloc: std.mem.Allocator,
action: ?cli.Action,
action: ?cli.ghostty.Action,
logging: Logging,
rlimits: ResourceLimits = .{},
@ -92,7 +92,10 @@ pub const GlobalState = struct {
unreachable;
// We first try to parse any action that we may be executing.
self.action = try cli.Action.detectCLI(self.alloc);
self.action = try cli.action.detectArgs(
cli.ghostty.Action,
self.alloc,
);
// If we have an action executing, we disable logging by default
// since we write to stderr we don't want logs messing up our

View File

@ -4,7 +4,7 @@
const std = @import("std");
const Config = @import("config/Config.zig");
const Action = @import("cli/action.zig").Action;
const Action = @import("cli.zig").ghostty.Action;
const KeybindAction = @import("input/Binding.zig").Action;
pub fn main() !void {

View File

@ -10,11 +10,6 @@ const entrypoint = switch (build_config.exe_entrypoint) {
.webgen_config => @import("build/webgen/main_config.zig"),
.webgen_actions => @import("build/webgen/main_actions.zig"),
.webgen_commands => @import("build/webgen/main_commands.zig"),
.bench_parser => @import("bench/parser.zig"),
.bench_stream => @import("bench/stream.zig"),
.bench_codepoint_width => @import("bench/codepoint-width.zig"),
.bench_grapheme_break => @import("bench/grapheme-break.zig"),
.bench_page_init => @import("bench/page-init.zig"),
};
/// The main entrypoint for the program.

5
src/main_bench.zig Normal file
View File

@ -0,0 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const benchmark = @import("benchmark/main.zig");
pub const main = benchmark.cli.main;

View File

@ -33,10 +33,16 @@ pub const std_options = main.std_options;
comptime {
// These structs need to be referenced so the `export` functions
// are truly exported by the C API lib.
_ = @import("config.zig").CAPI;
if (@hasDecl(apprt.runtime, "CAPI")) {
_ = apprt.runtime.CAPI;
}
// Our config API
_ = @import("config.zig").CApi;
// Any apprt-specific C API, mainly libghostty for apprt.embedded.
if (@hasDecl(apprt.runtime, "CAPI")) _ = apprt.runtime.CAPI;
// Our benchmark API. We probably want to gate this on a build
// config in the future but for now we always just export it.
_ = @import("benchmark/main.zig").CApi;
}
/// ghostty_info_s
@ -72,7 +78,7 @@ pub const String = extern struct {
};
/// Initialize ghostty global state.
export fn ghostty_init(argc: usize, argv: [*][*:0]u8) c_int {
pub export fn ghostty_init(argc: usize, argv: [*][*:0]u8) c_int {
assert(builtin.link_libc);
std.os.argv = argv[0..argc];
@ -86,7 +92,7 @@ export fn ghostty_init(argc: usize, argv: [*][*:0]u8) c_int {
/// Runs an action if it is specified. If there is no action this returns
/// false. If there is an action then this doesn't return.
export fn ghostty_cli_try_action() void {
pub export fn ghostty_cli_try_action() void {
const action = state.action orelse return;
std.log.info("executing CLI action={}", .{action});
posix.exit(action.run(state.alloc) catch |err| {
@ -98,7 +104,7 @@ export fn ghostty_cli_try_action() void {
}
/// Return metadata about Ghostty, such as version, build mode, etc.
export fn ghostty_info() Info {
pub export fn ghostty_info() Info {
return .{
.mode = switch (builtin.mode) {
.Debug => .debug,
@ -117,11 +123,11 @@ export fn ghostty_info() Info {
/// the function call.
///
/// This should only be used for singular strings maintained by Ghostty.
export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 {
pub export fn ghostty_translate(msgid: [*:0]const u8) [*:0]const u8 {
return internal_os.i18n._(msgid);
}
/// Free a string allocated by Ghostty.
export fn ghostty_string_free(str: String) void {
pub export fn ghostty_string_free(str: String) void {
state.alloc.free(str.ptr.?[0..str.len]);
}

5
src/main_gen.zig Normal file
View File

@ -0,0 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const synthetic = @import("synthetic/main.zig");
pub const main = synthetic.cli.main;

View File

@ -182,6 +182,7 @@ test {
_ = @import("surface_mouse.zig");
// Libraries
_ = @import("benchmark/main.zig");
_ = @import("crash/main.zig");
_ = @import("datastruct/main.zig");
_ = @import("inspector/main.zig");

108
src/synthetic/cli.zig Normal file
View File

@ -0,0 +1,108 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const cli = @import("../cli.zig");
/// The available actions for the CLI. This is the list of available
/// synthetic generators. View docs for each individual one in the
/// predictably named files under `cli/`.
pub const Action = enum {
ascii,
osc,
utf8,
/// Returns the struct associated with the action. The struct
/// should have a few decls:
///
/// - `const Options`: The CLI options for the action.
/// - `fn create`: Create a new instance of the action from options.
/// - `fn destroy`: Destroy the instance of the action.
///
/// See TerminalStream for an example.
pub fn Struct(comptime action: Action) type {
return switch (action) {
.ascii => @import("cli/Ascii.zig"),
.osc => @import("cli/Osc.zig"),
.utf8 => @import("cli/Utf8.zig"),
};
}
};
/// An entrypoint for the synthetic generator CLI.
pub fn main() !void {
const alloc = std.heap.c_allocator;
const action_ = try cli.action.detectArgs(Action, alloc);
const action = action_ orelse return error.NoAction;
try mainAction(alloc, action, .cli);
}
pub const Args = union(enum) {
/// The arguments passed to the CLI via argc/argv.
cli,
/// Simple string arguments, parsed via std.process.ArgIteratorGeneral.
string: []const u8,
};
pub fn mainAction(
alloc: Allocator,
action: Action,
args: Args,
) !void {
switch (action) {
inline else => |comptime_action| {
const Impl = Action.Struct(comptime_action);
try mainActionImpl(Impl, alloc, args);
},
}
}
fn mainActionImpl(
comptime Impl: type,
alloc: Allocator,
args: Args,
) !void {
// First, parse our CLI options.
const Options = Impl.Options;
var opts: Options = .{};
defer if (@hasDecl(Options, "deinit")) opts.deinit();
switch (args) {
.cli => {
var iter = try cli.args.argsIterator(alloc);
defer iter.deinit();
try cli.args.parse(Options, alloc, &opts, &iter);
},
.string => |str| {
var iter = try std.process.ArgIteratorGeneral(.{}).init(
alloc,
str,
);
defer iter.deinit();
try cli.args.parse(Options, alloc, &opts, &iter);
},
}
// TODO: Make this a command line option.
const seed: u64 = @truncate(@as(
u128,
@bitCast(std.time.nanoTimestamp()),
));
var prng = std.Random.DefaultPrng.init(seed);
const rand = prng.random();
// Our output always goes to stdout.
const writer = std.io.getStdOut().writer();
// Create our implementation
const impl = try Impl.create(alloc, opts);
defer impl.destroy(alloc);
try impl.run(writer, rand);
}
test {
// Make sure we ref all our actions
inline for (@typeInfo(Action).@"enum".fields) |field| {
const action = @field(Action, field.name);
const Impl = Action.Struct(action);
_ = Impl;
}
}

View File

@ -0,0 +1,63 @@
const Ascii = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const synthetic = @import("../main.zig");
const log = std.log.scoped(.@"terminal-stream-bench");
pub const Options = struct {};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
_: Options,
) !*Ascii {
const ptr = try alloc.create(Ascii);
errdefer alloc.destroy(ptr);
return ptr;
}
pub fn destroy(self: *Ascii, alloc: Allocator) void {
alloc.destroy(self);
}
pub fn run(self: *Ascii, writer: anytype, rand: std.Random) !void {
_ = self;
var gen: synthetic.Bytes = .{
.rand = rand,
.alphabet = synthetic.Bytes.Alphabet.ascii,
};
var buf: [1024]u8 = undefined;
while (true) {
const data = try gen.next(&buf);
writer.writeAll(data) catch |err| {
const Error = error{ NoSpaceLeft, BrokenPipe } || @TypeOf(err);
switch (@as(Error, err)) {
error.BrokenPipe => return, // stdout closed
error.NoSpaceLeft => return, // fixed buffer full
else => return err,
}
};
}
}
test Ascii {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *Ascii = try .create(alloc, .{});
defer impl.destroy(alloc);
var prng = std.Random.DefaultPrng.init(1);
const rand = prng.random();
var buf: [1024]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
const writer = fbs.writer();
try impl.run(writer, rand);
}

67
src/synthetic/cli/Osc.zig Normal file
View File

@ -0,0 +1,67 @@
const Osc = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const synthetic = @import("../main.zig");
const log = std.log.scoped(.@"terminal-stream-bench");
pub const Options = struct {
/// Probability of generating a valid value.
@"p-valid": f64 = 0.5,
};
opts: Options,
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
opts: Options,
) !*Osc {
const ptr = try alloc.create(Osc);
errdefer alloc.destroy(ptr);
ptr.* = .{ .opts = opts };
return ptr;
}
pub fn destroy(self: *Osc, alloc: Allocator) void {
alloc.destroy(self);
}
pub fn run(self: *Osc, writer: anytype, rand: std.Random) !void {
var gen: synthetic.Osc = .{
.rand = rand,
.p_valid = self.opts.@"p-valid",
};
var buf: [1024]u8 = undefined;
while (true) {
const data = try gen.next(&buf);
writer.writeAll(data) catch |err| {
const Error = error{ NoSpaceLeft, BrokenPipe } || @TypeOf(err);
switch (@as(Error, err)) {
error.BrokenPipe => return, // stdout closed
error.NoSpaceLeft => return, // fixed buffer full
else => return err,
}
};
}
}
test Osc {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *Osc = try .create(alloc, .{});
defer impl.destroy(alloc);
var prng = std.Random.DefaultPrng.init(1);
const rand = prng.random();
var buf: [1024]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
const writer = fbs.writer();
try impl.run(writer, rand);
}

View File

@ -0,0 +1,62 @@
const Utf8 = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const synthetic = @import("../main.zig");
const log = std.log.scoped(.@"terminal-stream-bench");
pub const Options = struct {};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
_: Options,
) !*Utf8 {
const ptr = try alloc.create(Utf8);
errdefer alloc.destroy(ptr);
return ptr;
}
pub fn destroy(self: *Utf8, alloc: Allocator) void {
alloc.destroy(self);
}
pub fn run(self: *Utf8, writer: anytype, rand: std.Random) !void {
_ = self;
var gen: synthetic.Utf8 = .{
.rand = rand,
};
var buf: [1024]u8 = undefined;
while (true) {
const data = try gen.next(&buf);
writer.writeAll(data) catch |err| {
const Error = error{ NoSpaceLeft, BrokenPipe } || @TypeOf(err);
switch (@as(Error, err)) {
error.BrokenPipe => return, // stdout closed
error.NoSpaceLeft => return, // fixed buffer full
else => return err,
}
};
}
}
test Utf8 {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *Utf8 = try .create(alloc, .{});
defer impl.destroy(alloc);
var prng = std.Random.DefaultPrng.init(1);
const rand = prng.random();
var buf: [1024]u8 = undefined;
var fbs = std.io.fixedBufferStream(&buf);
const writer = fbs.writer();
try impl.run(writer, rand);
}

View File

@ -13,6 +13,8 @@
//! is not limited to that and we may want to extract this to a
//! standalone package one day.
pub const cli = @import("cli.zig");
pub const Generator = @import("Generator.zig");
pub const Bytes = @import("Bytes.zig");
pub const Utf8 = @import("Utf8.zig");