diff --git a/include/ghostty.h b/include/ghostty.h index 312e6595a..bcd88251b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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 diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f6eedd864..f7ae5f525 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; 3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; }; @@ -199,6 +209,7 @@ A54B0CEC2D0CFB7300CBEFF8 /* ColorizedGhosttyIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIcon.swift; sourceTree = ""; }; A54B0CEE2D0D2E2400CBEFF8 /* ColorizedGhosttyIconImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorizedGhosttyIconImage.swift; sourceTree = ""; }; A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; + 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 = ""; }; A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenTitlebarTerminalWindow.swift; sourceTree = ""; }; @@ -291,7 +302,18 @@ FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + A54F45F42E1F047A0046BD5C /* GhosttyTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyTests; sourceTree = ""; }; +/* 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 = ""; @@ -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 = ( diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme index 5900042f2..0d8761c9e 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -28,6 +28,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + +#include // A wrapper so we can use the os_log_with_type macro. void zig_os_log_with_type( diff --git a/src/bench/codepoint-width.sh b/src/bench/codepoint-width.sh deleted file mode 100755 index 43304ec2e..000000000 --- a/src/bench/codepoint-width.sh +++ /dev/null @@ -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} 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); - } - } - } -} diff --git a/src/bench/grapheme-break.sh b/src/bench/grapheme-break.sh deleted file mode 100755 index 24f475caa..000000000 --- a/src/bench/grapheme-break.sh +++ /dev/null @@ -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} 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; - } - } - } -} diff --git a/src/bench/page-init.sh b/src/bench/page-init.sh deleted file mode 100755 index 54712250b..000000000 --- a/src/bench/page-init.sh +++ /dev/null @@ -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} 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(); - } -} diff --git a/src/bench/parser.zig b/src/bench/parser.zig deleted file mode 100644 index 9245c06cb..000000000 --- a/src/bench/parser.zig +++ /dev/null @@ -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=" - 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; - } -}; diff --git a/src/bench/stream.sh b/src/bench/stream.sh deleted file mode 100755 index 38d4c37cd..000000000 --- a/src/bench/stream.sh +++ /dev/null @@ -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} = 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); - } -}; diff --git a/src/benchmark/Benchmark.zig b/src/benchmark/Benchmark.zig new file mode 100644 index 000000000..4128a7adc --- /dev/null +++ b/src/benchmark/Benchmark.zig @@ -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); +} diff --git a/src/benchmark/CApi.zig b/src/benchmark/CApi.zig new file mode 100644 index 000000000..3bef8b269 --- /dev/null +++ b/src/benchmark/CApi.zig @@ -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; +} diff --git a/src/benchmark/CodepointWidth.zig b/src/benchmark/CodepointWidth.zig new file mode 100644 index 000000000..e9207aed5 --- /dev/null +++ b/src/benchmark/CodepointWidth.zig @@ -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); +} diff --git a/src/benchmark/GraphemeBreak.zig b/src/benchmark/GraphemeBreak.zig new file mode 100644 index 000000000..57effebe4 --- /dev/null +++ b/src/benchmark/GraphemeBreak.zig @@ -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); +} diff --git a/src/benchmark/TerminalParser.zig b/src/benchmark/TerminalParser.zig new file mode 100644 index 000000000..9107d4555 --- /dev/null +++ b/src/benchmark/TerminalParser.zig @@ -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); +} diff --git a/src/benchmark/TerminalStream.zig b/src/benchmark/TerminalStream.zig new file mode 100644 index 000000000..5d235c4ee --- /dev/null +++ b/src/benchmark/TerminalStream.zig @@ -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); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig new file mode 100644 index 000000000..97bb9c683 --- /dev/null +++ b/src/benchmark/cli.zig @@ -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); +} diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig new file mode 100644 index 000000000..49bb17289 --- /dev/null +++ b/src/benchmark/main.zig @@ -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()); +} diff --git a/src/benchmark/options.zig b/src/benchmark/options.zig new file mode 100644 index 000000000..867be6afc --- /dev/null +++ b/src/benchmark/options.zig @@ -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; +} diff --git a/src/build/Config.zig b/src/build/Config.zig index a9a79fb53..69a9dd8a0 100644 --- a/src/build/Config.zig +++ b/src/build/Config.zig @@ -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. diff --git a/src/build/GhosttyBench.zig b/src/build/GhosttyBench.zig index 9e93a3b85..5859a8bcf 100644 --- a/src/build/GhosttyBench.zig +++ b/src/build/GhosttyBench.zig @@ -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 }; diff --git a/src/build/bash_completions.zig b/src/build/bash_completions.zig index ad62ff97d..536cadbc4 100644 --- a/src/build/bash_completions.zig +++ b/src/build/bash_completions.zig @@ -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. diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index 2b2563ee7..0b6c45e1f 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -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. diff --git a/src/build/mdgen/mdgen.zig b/src/build/mdgen/mdgen.zig index e7d966323..53ed02067 100644 --- a/src/build/mdgen/mdgen.zig +++ b/src/build/mdgen/mdgen.zig @@ -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 { diff --git a/src/build/zsh_completions.zig b/src/build/zsh_completions.zig index 2ded6d73c..6bddcd285 100644 --- a/src/build/zsh_completions.zig +++ b/src/build/zsh_completions.zig @@ -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. diff --git a/src/cli.zig b/src/cli.zig index 151e6e648..008ff1ebf 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -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; diff --git a/src/cli/action.zig b/src/cli/action.zig index 728f36efe..41173a9f1 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -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 `+` 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); } } diff --git a/src/cli/boo.zig b/src/cli/boo.zig index 47c8ab741..72b282ef6 100644 --- a/src/cli/boo.zig +++ b/src/cli/boo.zig @@ -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"); diff --git a/src/cli/crash_report.zig b/src/cli/crash_report.zig index ff8509797..c6a383563 100644 --- a/src/cli/crash_report.zig +++ b/src/cli/crash_report.zig @@ -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"); diff --git a/src/cli/edit_config.zig b/src/cli/edit_config.zig index 3be88e090..dd09d7e2f 100644 --- a/src/cli/edit_config.zig +++ b/src/cli/edit_config.zig @@ -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; diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig new file mode 100644 index 000000000..c1b661f70 --- /dev/null +++ b/src/cli/ghostty.zig @@ -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 `+` 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), + ); + } +} diff --git a/src/cli/help.zig b/src/cli/help.zig index 6c989fd0c..0528dc1c2 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -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 diff --git a/src/cli/list_actions.zig b/src/cli/list_actions.zig index 1d17873cc..6f5ce06a2 100644 --- a/src/cli/list_actions.zig +++ b/src/cli/list_actions.zig @@ -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"); diff --git a/src/cli/list_colors.zig b/src/cli/list_colors.zig index bfe17df7c..e43a43c86 100644 --- a/src/cli/list_colors.zig +++ b/src/cli/list_colors.zig @@ -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; diff --git a/src/cli/list_fonts.zig b/src/cli/list_fonts.zig index e8a010ecd..58246d3ad 100644 --- a/src/cli/list_fonts.zig +++ b/src/cli/list_fonts.zig @@ -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"); diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index f84d540c3..94f445eea 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -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"); diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index e80a92286..b85f98445 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -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"); diff --git a/src/cli/show_config.zig b/src/cli/show_config.zig index cbcd2486d..3f22c75c2 100644 --- a/src/cli/show_config.zig +++ b/src/cli/show_config.zig @@ -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; diff --git a/src/cli/show_face.zig b/src/cli/show_face.zig index b7f039dc8..e3b596bcd 100644 --- a/src/cli/show_face.zig +++ b/src/cli/show_face.zig @@ -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"); diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig index c8e2e1123..1099f0112 100644 --- a/src/cli/ssh_cache.zig +++ b/src/cli/ssh_cache.zig @@ -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"); diff --git a/src/cli/validate_config.zig b/src/cli/validate_config.zig index 5bc6ff406..114843e9a 100644 --- a/src/cli/validate_config.zig +++ b/src/cli/validate_config.zig @@ -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"); diff --git a/src/config.zig b/src/config.zig index efc9fd973..c5bab5877 100644 --- a/src/config.zig +++ b/src/config.zig @@ -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 { diff --git a/src/config/CAPI.zig b/src/config/CApi.zig similarity index 100% rename from src/config/CAPI.zig rename to src/config/CApi.zig diff --git a/src/global.zig b/src/global.zig index 668d2faec..e68ec7f74 100644 --- a/src/global.zig +++ b/src/global.zig @@ -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 diff --git a/src/helpgen.zig b/src/helpgen.zig index 560e5ce29..e1628c218 100644 --- a/src/helpgen.zig +++ b/src/helpgen.zig @@ -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 { diff --git a/src/main.zig b/src/main.zig index 121a3b7d2..b08e63dd2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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. diff --git a/src/main_bench.zig b/src/main_bench.zig new file mode 100644 index 000000000..2314dc2ed --- /dev/null +++ b/src/main_bench.zig @@ -0,0 +1,5 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const benchmark = @import("benchmark/main.zig"); + +pub const main = benchmark.cli.main; diff --git a/src/main_c.zig b/src/main_c.zig index 2c266cfb5..9a9bcc6d2 100644 --- a/src/main_c.zig +++ b/src/main_c.zig @@ -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]); } diff --git a/src/main_gen.zig b/src/main_gen.zig new file mode 100644 index 000000000..b988819f8 --- /dev/null +++ b/src/main_gen.zig @@ -0,0 +1,5 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const synthetic = @import("synthetic/main.zig"); + +pub const main = synthetic.cli.main; diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig index b747fe6f0..fb29303f1 100644 --- a/src/main_ghostty.zig +++ b/src/main_ghostty.zig @@ -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"); diff --git a/src/synthetic/cli.zig b/src/synthetic/cli.zig new file mode 100644 index 000000000..36832587c --- /dev/null +++ b/src/synthetic/cli.zig @@ -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; + } +} diff --git a/src/synthetic/cli/Ascii.zig b/src/synthetic/cli/Ascii.zig new file mode 100644 index 000000000..25e5bb00b --- /dev/null +++ b/src/synthetic/cli/Ascii.zig @@ -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); +} diff --git a/src/synthetic/cli/Osc.zig b/src/synthetic/cli/Osc.zig new file mode 100644 index 000000000..4792cda6b --- /dev/null +++ b/src/synthetic/cli/Osc.zig @@ -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); +} diff --git a/src/synthetic/cli/Utf8.zig b/src/synthetic/cli/Utf8.zig new file mode 100644 index 000000000..28a11f891 --- /dev/null +++ b/src/synthetic/cli/Utf8.zig @@ -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); +} diff --git a/src/synthetic/main.zig b/src/synthetic/main.zig index 67cd47054..85f9f7d35 100644 --- a/src/synthetic/main.zig +++ b/src/synthetic/main.zig @@ -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");