diff --git a/.editorconfig b/.editorconfig index d305bd294..4e9bec6ce 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,6 @@ root = true -[*.{sh,bash}] +[*.{sh,bash,elv}] indent_size = 2 indent_style = space diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index bf8fd7208..beeaa76a4 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -36,13 +36,13 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index e260996bb..7f48d109f 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -211,7 +211,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 4cc364127..cc81b1a79 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,13 +83,13 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable @@ -130,7 +130,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index b7c4949a5..330f12259 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -107,12 +107,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -164,7 +164,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -175,6 +175,9 @@ jobs: - name: XCode Select run: sudo xcode-select -s /Applications/Xcode_26.0.app + - name: Xcode Version + run: xcodebuild -version + # Setup Sparkle - name: Setup Sparkle env: @@ -381,7 +384,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -392,6 +395,9 @@ jobs: - name: XCode Select run: sudo xcode-select -s /Applications/Xcode_26.0.app + - name: Xcode Version + run: xcodebuild -version + # Setup Sparkle - name: Setup Sparkle env: @@ -558,7 +564,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -569,6 +575,9 @@ jobs: - name: XCode Select run: sudo xcode-select -s /Applications/Xcode_26.0.app + - name: Xcode Version + run: xcodebuild -version + # Setup Sparkle - name: Setup Sparkle env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8af7140c6..b632ae8f4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,7 @@ jobs: - prettier - alejandra - typos + - shellcheck - translations - blueprint-compiler - test-pkg-linux @@ -68,14 +69,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -99,14 +100,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -135,14 +136,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -164,14 +165,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -197,14 +198,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -241,14 +242,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -277,7 +278,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -288,6 +289,9 @@ jobs: - name: Xcode Select run: sudo xcode-select -s /Applications/Xcode_26.0.app + - name: Xcode Version + run: xcodebuild -version + - name: get the Zig deps id: deps run: nix build -L .#deps && echo "deps=$(readlink ./result)" >> $GITHUB_OUTPUT @@ -357,7 +361,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -409,7 +413,7 @@ jobs: mkdir dist tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix @@ -504,14 +508,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -546,14 +550,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -561,7 +565,16 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" - - name: Test GTK Build + - name: Test + run: | + nix develop -c \ + zig build \ + -Dapp-runtime=gtk \ + -Dgtk-x11=${{ matrix.x11 }} \ + -Dgtk-wayland=${{ matrix.wayland }} \ + test + + - name: Build run: | nix develop -c \ zig build \ @@ -585,14 +598,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -612,7 +625,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -640,12 +653,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -668,12 +681,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -695,12 +708,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -722,12 +735,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -749,12 +762,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -766,6 +779,40 @@ jobs: - name: typos check run: nix develop -c typos + shellcheck: + if: github.repository == 'ghostty-org/ghostty' + runs-on: namespace-profile-ghostty-xsm + timeout-minutes: 60 + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 + with: + path: | + /nix + /zig + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + skipPush: true + useDaemon: false # sometimes fails on short jobs + - name: shellcheck + run: | + nix develop -c shellcheck \ + --check-sourced \ + --color=always \ + --severity=warning \ + --shell=bash \ + --external-sources \ + $(find . \( -name "*.sh" -o -name "*.bash" \) -type f ! -path "./zig-out/*" ! -path "./macos/build/*" ! -path "./.git/*" | sort) + translations: if: github.repository == 'ghostty-org/ghostty' runs-on: namespace-profile-ghostty-xsm @@ -776,12 +823,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -803,12 +850,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -838,14 +885,14 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + - uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 @@ -896,13 +943,13 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index b9ded559e..20cda12c9 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,14 +22,14 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@449c929cd5138e6607e7e78458e88cc476e76f89 # v1.2.8 + uses: namespacelabs/nscloud-cache-action@0ac1550c04676e19d39872be6216ccbf9c6bab43 # v1.2.9 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@f0fe604f8a612776892427721526b4c7cfb23aba # v31.4.1 + uses: cachix/install-nix-action@cebd211ec2008b83bda8fb0b21c3c072f004fe04 # v31.5.0 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/build.zig b/build.zig index 024e2db61..1c98b2fa5 100644 --- a/build.zig +++ b/build.zig @@ -8,7 +8,22 @@ comptime { } pub fn build(b: *std.Build) !void { + // This defines all the available build options (e.g. `-D`). const config = try buildpkg.Config.init(b); + const test_filter = b.option( + []const u8, + "test-filter", + "Filter for test. Only applies to Zig tests.", + ); + + // All our steps which we'll hook up later. The steps are shown + // up here just so that they are more self-documenting. + const run_step = b.step("run", "Run the app"); + const test_step = b.step("test", "Run all tests"); + const translations_step = b.step( + "update-translations", + "Update translation files", + ); // Ghostty resources like terminfo, shell integration, themes, etc. const resources = try buildpkg.GhosttyResources.init(b, &config); @@ -131,7 +146,6 @@ pub fn build(b: *std.Build) !void { b.getInstallPath(.prefix, "share/ghostty"), ); - const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); break :run; } @@ -157,16 +171,18 @@ pub fn build(b: *std.Build) !void { }, ); - const run_step = b.step("run", "Run the app"); + // Run uses the native macOS app run_step.dependOn(&macos_app_native_only.open.step); + + // If we have no test filters, install the tests too + if (test_filter == null) { + macos_app_native_only.addTestStepDependencies(test_step); + } } } // Tests { - const test_step = b.step("test", "Run all tests"); - const test_filter = b.option([]const u8, "test-filter", "Filter for test"); - const test_exe = b.addTest(.{ .name = "ghostty-test", .filters = if (test_filter) |v| &.{v} else &.{}, @@ -180,18 +196,13 @@ pub fn build(b: *std.Build) !void { }), }); - { - if (config.emit_test_exe) b.installArtifact(test_exe); - _ = try deps.add(test_exe); - const test_run = b.addRunArtifact(test_exe); - test_step.dependOn(&test_run.step); - } + if (config.emit_test_exe) b.installArtifact(test_exe); + _ = try deps.add(test_exe); + const test_run = b.addRunArtifact(test_exe); + test_step.dependOn(&test_run.step); } // update-translations does what it sounds like and updates the "pot" // files. These should be committed to the repo. - { - const step = b.step("update-translations", "Update translation files"); - step.dependOn(i18n.update_step); - } + translations_step.dependOn(i18n.update_step); } diff --git a/dist/linux/systemd.service.in b/dist/linux/systemd.service.in index 3ff848ddd..76ccdd3f4 100644 --- a/dist/linux/systemd.service.in +++ b/dist/linux/systemd.service.in @@ -1,9 +1,12 @@ [Unit] Description=@NAME@ After=graphical-session.target +After=dbus.socket +Requires=dbus.socket [Service] -Type=dbus +Type=notify-reload +ReloadSignal=SIGUSR2 BusName=@APPID@ ExecStart=@GHOSTTY@ --launched-from=systemd 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..0c54ba693 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 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; 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 /* Tests */, 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 /* Tests */, + ); + 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/po/ko_KR.UTF-8.po b/po/ko_KR.UTF-8.po index 9aa4aad5e..875e7a1a5 100644 --- a/po/ko_KR.UTF-8.po +++ b/po/ko_KR.UTF-8.po @@ -8,8 +8,8 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2025-07-08 15:13-0500\n" -"PO-Revision-Date: 2025-03-31 03:08+0200\n" -"Last-Translator: Ruben Engelbrecht \n" +"PO-Revision-Date: 2025-07-09 16:11-0400\n" +"Last-Translator: Hojin You \n" "Language-Team: Korean \n" "Language: ko\n" "MIME-Version: 1.0\n" @@ -87,7 +87,7 @@ msgstr "오른쪽으로 창 나누기" #: src/apprt/gtk/ui/1.5/command-palette.blp:16 msgid "Execute a command…" -msgstr "" +msgstr "명령을 실행하세요…" #: src/apprt/gtk/ui/1.0/menu-surface-context_menu.blp:6 #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:6 @@ -160,7 +160,7 @@ msgstr "설정 열기" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:85 msgid "Command Palette" -msgstr "" +msgstr "명령 팔레트" #: src/apprt/gtk/ui/1.0/menu-window-titlebar_menu.blp:90 msgid "Terminal Inspector" @@ -208,12 +208,12 @@ msgstr "허용" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:81 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:77 msgid "Remember choice for this split" -msgstr "" +msgstr "이 분할에 대한 선택 기억하기" #: src/apprt/gtk/ui/1.5/ccw-osc-52-read.blp:82 #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:78 msgid "Reload configuration to show this prompt again" -msgstr "" +msgstr "이 창을 다시 보려면 설정을 다시 불러오세요" #: src/apprt/gtk/ui/1.5/ccw-osc-52-write.blp:7 #: src/apprt/gtk/ui/1.2/ccw-osc-52-write.blp:7 @@ -285,9 +285,8 @@ msgid "View Open Tabs" msgstr "열린 탭 보기" #: src/apprt/gtk/Window.zig:266 -#, fuzzy msgid "New Split" -msgstr "나누기" +msgstr "새 분할" #: src/apprt/gtk/Window.zig:329 msgid "" diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 907f3a36d..bdb2f0f24 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -29,6 +29,7 @@ const apprt = @import("../../apprt.zig"); const configpkg = @import("../../config.zig"); const input = @import("../../input.zig"); const internal_os = @import("../../os/main.zig"); +const systemd = @import("../../os/systemd.zig"); const terminal = @import("../../terminal/main.zig"); const Config = configpkg.Config; const CoreApp = @import("../../App.zig"); @@ -1035,6 +1036,12 @@ pub fn reloadConfig( target: apprt.action.Target, opts: apprt.action.ReloadConfig, ) !void { + // Tell systemd that reloading has started. + systemd.notify.reloading(); + + // When we exit this function tell systemd that reloading has finished. + defer systemd.notify.ready(); + if (opts.soft) { switch (target) { .app => try self.core_app.updateConfig(self, &self.config), @@ -1367,6 +1374,9 @@ pub fn run(self: *App) !void { log.warn("error handling configuration changes err={}", .{err}); }; + // Tell systemd that we are ready. + systemd.notify.ready(); + while (self.running) { _ = glib.MainContext.iteration(self.ctx, 1); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 5c886e663..d16083d5a 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -2333,6 +2333,7 @@ pub fn defaultTermioEnv(self: *Surface) !std.process.EnvMap { env.remove("DBUS_STARTER_BUS_TYPE"); env.remove("INVOCATION_ID"); env.remove("JOURNAL_STREAM"); + env.remove("NOTIFY_SOCKET"); // Unset environment varies set by snaps if we're running in a snap. // This allows Ghostty to further launch additional snaps. diff --git a/src/apprt/gtk/gtk_version.zig b/src/apprt/gtk/gtk_version.zig index 5d75fb4fe..6f3d733a5 100644 --- a/src/apprt/gtk/gtk_version.zig +++ b/src/apprt/gtk/gtk_version.zig @@ -103,7 +103,7 @@ pub inline fn runtimeUntil( test "atLeast" { const testing = std.testing; - const funs = &.{ atLeast, runtimeAtLeast, runtimeUntil }; + const funs = &.{ atLeast, runtimeAtLeast }; inline for (funs) |fun| { try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); @@ -118,3 +118,23 @@ test "atLeast" { try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); } } + +test "runtimeUntil" { + const testing = std.testing; + + // This is an array in case we add a comptime variant. + const funs = &.{runtimeUntil}; + inline for (funs) |fun| { + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + try testing.expect(fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(fun(c.GTK_MAJOR_VERSION + 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION + 1, c.GTK_MICRO_VERSION)); + try testing.expect(!fun(c.GTK_MAJOR_VERSION - 1, c.GTK_MINOR_VERSION, c.GTK_MICRO_VERSION + 1)); + + try testing.expect(!fun(c.GTK_MAJOR_VERSION, c.GTK_MINOR_VERSION - 1, c.GTK_MICRO_VERSION + 1)); + } +} 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/GhosttyDist.zig b/src/build/GhosttyDist.zig index 3d7ba3b8d..25ec7182b 100644 --- a/src/build/GhosttyDist.zig +++ b/src/build/GhosttyDist.zig @@ -115,7 +115,8 @@ pub fn init(b: *std.Build, cfg: *const Config) !GhosttyDist { // Capture stderr so it doesn't spew into the parent build. // On the flip side, if the test fails we won't know why so // that sucks but we should have already ran tests at this point. - _ = step.captureStdErr(); + // NOTE(mitchellh): temporarily disabled to diagnose heisenbug + //_ = step.captureStdErr(); break :step step; }; diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig index 4e36c57c8..857fd1798 100644 --- a/src/build/GhosttyLib.zig +++ b/src/build/GhosttyLib.zig @@ -1,6 +1,7 @@ const GhosttyLib = @This(); const std = @import("std"); +const RunStep = std.Build.Step.Run; const Config = @import("Config.zig"); const SharedDeps = @import("SharedDeps.zig"); const LibtoolStep = @import("LibtoolStep.zig"); @@ -11,6 +12,7 @@ step: *std.Build.Step, /// The final static library file output: std.Build.LazyPath, +dsym: ?std.Build.LazyPath, pub fn initStatic( b: *std.Build, @@ -18,9 +20,14 @@ pub fn initStatic( ) !GhosttyLib { const lib = b.addStaticLibrary(.{ .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .target = deps.config.target, - .optimize = deps.config.optimize, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_c.zig"), + .target = deps.config.target, + .optimize = deps.config.optimize, + .strip = deps.config.strip, + .omit_frame_pointer = deps.config.strip, + .unwind_tables = if (deps.config.strip) .none else .sync, + }), }); lib.linkLibC(); @@ -37,6 +44,7 @@ pub fn initStatic( if (!deps.config.target.result.os.tag.isDarwin()) return .{ .step = &lib.step, .output = lib.getEmittedBin(), + .dsym = null, }; // Create a static lib that contains all our dependencies. @@ -50,6 +58,9 @@ pub fn initStatic( return .{ .step = libtool.step, .output = libtool.output, + + // Static libraries cannot have dSYMs because they aren't linked. + .dsym = null, }; } @@ -59,16 +70,35 @@ pub fn initShared( ) !GhosttyLib { const lib = b.addSharedLibrary(.{ .name = "ghostty", - .root_source_file = b.path("src/main_c.zig"), - .target = deps.config.target, - .optimize = deps.config.optimize, - .strip = deps.config.strip, + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main_c.zig"), + .target = deps.config.target, + .optimize = deps.config.optimize, + .strip = deps.config.strip, + .omit_frame_pointer = deps.config.strip, + .unwind_tables = if (deps.config.strip) .none else .sync, + }), }); _ = try deps.add(lib); + // Get our debug symbols + const dsymutil: ?std.Build.LazyPath = dsymutil: { + if (!deps.config.target.result.os.tag.isDarwin()) { + break :dsymutil null; + } + + const dsymutil = RunStep.create(b, "dsymutil"); + dsymutil.addArgs(&.{"dsymutil"}); + dsymutil.addFileArg(lib.getEmittedBin()); + dsymutil.addArgs(&.{"-o"}); + const output = dsymutil.addOutputFileArg("libghostty.dSYM"); + break :dsymutil output; + }; + return .{ .step = &lib.step, .output = lib.getEmittedBin(), + .dsym = dsymutil, }; } @@ -95,6 +125,10 @@ pub fn initMacOSUniversal( return .{ .step = universal.step, .output = universal.output, + + // You can't run dsymutil on a universal binary, you have to + // do it on the individual binaries. + .dsym = null, }; } diff --git a/src/build/GhosttyXCFramework.zig b/src/build/GhosttyXCFramework.zig index 7debd6906..d036e7020 100644 --- a/src/build/GhosttyXCFramework.zig +++ b/src/build/GhosttyXCFramework.zig @@ -64,20 +64,24 @@ pub fn init( .{ .library = macos_universal.output, .headers = b.path("include"), + .dsym = macos_universal.dsym, }, .{ .library = ios.output, .headers = b.path("include"), + .dsym = ios.dsym, }, .{ .library = ios_sim.output, .headers = b.path("include"), + .dsym = ios_sim.dsym, }, }, .native => &.{.{ .library = macos_native.output, .headers = b.path("include"), + .dsym = macos_native.dsym, }}, }, }); diff --git a/src/build/GhosttyXcodebuild.zig b/src/build/GhosttyXcodebuild.zig index 7fa2d2f95..d3bda032d 100644 --- a/src/build/GhosttyXcodebuild.zig +++ b/src/build/GhosttyXcodebuild.zig @@ -12,6 +12,7 @@ const XCFramework = @import("GhosttyXCFramework.zig"); build: *std.Build.Step.Run, open: *std.Build.Step.Run, copy: *std.Build.Step.Run, +xctest: *std.Build.Step.Run, pub const Deps = struct { xcframework: *const XCFramework, @@ -33,6 +34,21 @@ pub fn init( => "Release", }; + const xc_arch: ?[]const u8 = switch (deps.xcframework.target) { + // Universal is our default target, so we don't have to + // add anything. + .universal => null, + + // Native we need to override the architecture in the Xcode + // project with the -arch flag. + .native => switch (builtin.cpu.arch) { + .aarch64 => "arm64", + .x86_64 => "x86_64", + else => @panic("unsupported macOS arch"), + }, + }; + + const env = try std.process.getEnvMap(b.allocator); const app_path = b.fmt("macos/build/{s}/Ghostty.app", .{xc_config}); // Our step to build the Ghostty macOS app. @@ -41,12 +57,13 @@ pub fn init( // we create a new empty environment. const env_map = try b.allocator.create(std.process.EnvMap); env_map.* = .init(b.allocator); + if (env.get("PATH")) |v| try env_map.put("PATH", v); - const build = RunStep.create(b, "xcodebuild"); - build.has_side_effects = true; - build.cwd = b.path("macos"); - build.env_map = env_map; - build.addArgs(&.{ + const step = RunStep.create(b, "xcodebuild"); + step.has_side_effects = true; + step.cwd = b.path("macos"); + step.env_map = env_map; + step.addArgs(&.{ "xcodebuild", "-target", "Ghostty", @@ -54,36 +71,55 @@ pub fn init( xc_config, }); - switch (deps.xcframework.target) { - // Universal is our default target, so we don't have to - // add anything. - .universal => {}, - - // Native we need to override the architecture in the Xcode - // project with the -arch flag. - .native => build.addArgs(&.{ - "-arch", - switch (builtin.cpu.arch) { - .aarch64 => "arm64", - .x86_64 => "x86_64", - else => @panic("unsupported macOS arch"), - }, - }), - } + // If we have a specific architecture, we need to pass it + // to xcodebuild. + if (xc_arch) |arch| step.addArgs(&.{ "-arch", arch }); // We need the xcframework - deps.xcframework.addStepDependencies(&build.step); + deps.xcframework.addStepDependencies(&step.step); // We also need all these resources because the xcode project // references them via symlinks. - deps.resources.addStepDependencies(&build.step); - deps.i18n.addStepDependencies(&build.step); - deps.docs.installDummy(&build.step); + deps.resources.addStepDependencies(&step.step); + deps.i18n.addStepDependencies(&step.step); + deps.docs.installDummy(&step.step); // Expect success - build.expectExitCode(0); + step.expectExitCode(0); - break :build build; + break :build step; + }; + + const xctest = xctest: { + const env_map = try b.allocator.create(std.process.EnvMap); + env_map.* = .init(b.allocator); + if (env.get("PATH")) |v| try env_map.put("PATH", v); + + const step = RunStep.create(b, "xcodebuild test"); + step.has_side_effects = true; + step.cwd = b.path("macos"); + step.env_map = env_map; + step.addArgs(&.{ + "xcodebuild", + "test", + "-scheme", + "Ghostty", + }); + if (xc_arch) |arch| step.addArgs(&.{ "-arch", arch }); + + // We need the xcframework + deps.xcframework.addStepDependencies(&step.step); + + // We also need all these resources because the xcode project + // references them via symlinks. + deps.resources.addStepDependencies(&step.step); + deps.i18n.addStepDependencies(&step.step); + deps.docs.installDummy(&step.step); + + // Expect success + step.expectExitCode(0); + + break :xctest step; }; // Our step to open the resulting Ghostty app. @@ -143,6 +179,7 @@ pub fn init( .build = build, .open = open, .copy = copy, + .xctest = xctest, }; } @@ -155,3 +192,10 @@ pub fn installXcframework(self: *const Ghostty) void { const b = self.build.step.owner; b.getInstallStep().dependOn(&self.build.step); } + +pub fn addTestStepDependencies( + self: *const Ghostty, + other_step: *std.Build.Step, +) void { + other_step.dependOn(&self.xctest.step); +} diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig index b6e9900e2..ea7e696ef 100644 --- a/src/build/SharedDeps.zig +++ b/src/build/SharedDeps.zig @@ -139,7 +139,7 @@ pub fn add( if (b.lazyDependency("harfbuzz", .{ .target = target, .optimize = optimize, - .@"enable-freetype" = true, + .@"enable-freetype" = self.config.font_backend.hasFreetype(), .@"enable-coretext" = self.config.font_backend.hasCoretext(), })) |harfbuzz_dep| { step.root_module.addImport( diff --git a/src/build/XCFrameworkStep.zig b/src/build/XCFrameworkStep.zig index 8a0d5dc67..39f0f9bac 100644 --- a/src/build/XCFrameworkStep.zig +++ b/src/build/XCFrameworkStep.zig @@ -26,6 +26,9 @@ pub const Library = struct { /// Path to a directory with the headers. headers: LazyPath, + + /// Path to a debug symbols file (.dSYM) if available. + dsym: ?LazyPath, }; step: *Step, @@ -52,6 +55,10 @@ pub fn create(b: *std.Build, opts: Options) *XCFrameworkStep { run.addFileArg(lib.library); run.addArg("-headers"); run.addFileArg(lib.headers); + if (lib.dsym) |dsym| { + run.addArg("-debug-symbols"); + run.addFileArg(dsym); + } } run.addArg("-output"); run.addArg(opts.out_path); 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 009afb4c9..41173a9f1 100644 --- a/src/cli/action.zig +++ b/src/cli/action.zig @@ -1,314 +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 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", - - /// 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), - .@"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, - .@"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/DiskCache.zig b/src/cli/ssh-cache/DiskCache.zig new file mode 100644 index 000000000..10f38a27c --- /dev/null +++ b/src/cli/ssh-cache/DiskCache.zig @@ -0,0 +1,549 @@ +/// An SSH terminfo entry cache that stores its cache data on +/// disk. The cache only stores metadata (hostname, terminfo value, +/// etc.) and does not store any sensitive data. +const DiskCache = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const xdg = @import("../../os/main.zig").xdg; +const TempDir = @import("../../os/main.zig").TempDir; +const Entry = @import("Entry.zig"); + +// 512KB - sufficient for approximately 10k entries +const MAX_CACHE_SIZE = 512 * 1024; + +/// Path to a file where the cache is stored. +path: []const u8, + +pub const DefaultPathError = Allocator.Error || error{ + /// The general error that is returned for any filesystem error + /// that may have resulted in the XDG lookup failing. + XdgLookupFailed, +}; + +pub const Error = error{ CacheIsLocked, HostnameIsInvalid }; + +/// Returns the default path for the cache for a given program. +/// +/// On all platforms, this is `${XDG_STATE_HOME}/ghostty/ssh_cache`. +/// +/// The returned value is allocated and must be freed by the caller. +pub fn defaultPath( + alloc: Allocator, + program: []const u8, +) DefaultPathError![]const u8 { + const state_dir: []const u8 = xdg.state( + alloc, + .{ .subdir = program }, + ) catch |err| return switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.XdgLookupFailed, + }; + defer alloc.free(state_dir); + return try std.fs.path.join(alloc, &.{ state_dir, "ssh_cache" }); +} + +/// Clear all cache data stored in the disk cache. +/// This removes the cache file from disk, effectively clearing all cached +/// SSH terminfo entries. +pub fn clear(self: DiskCache) !void { + std.fs.cwd().deleteFile(self.path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return err, + }; +} + +pub const AddResult = enum { added, updated }; + +/// Add or update a hostname entry in the cache. +/// Returns AddResult.added for new entries or AddResult.updated for existing ones. +/// The cache file is created if it doesn't exist with secure permissions (0600). +pub fn add( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !AddResult { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Create cache directory if needed + if (std.fs.path.dirname(self.path)) |dir| { + std.fs.makeDirAbsolute(dir) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + + // Open or create cache file with secure permissions + const file = std.fs.createFileAbsolute(self.path, .{ + .read = true, + .truncate = false, + .mode = 0o600, + }) catch |err| switch (err) { + error.PathAlreadyExists => blk: { + const existing_file = try std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ); + errdefer existing_file.close(); + try fixupPermissions(existing_file); + break :blk existing_file; + }, + else => return err, + }; + defer file.close(); + + // Lock + _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + defer file.unlock(); + + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Add or update entry + const gop = try entries.getOrPut(hostname); + const result: AddResult = if (!gop.found_existing) add: { + const hostname_copy = try alloc.dupe(u8, hostname); + errdefer alloc.free(hostname_copy); + const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty"); + errdefer alloc.free(terminfo_copy); + + gop.key_ptr.* = hostname_copy; + gop.value_ptr.* = .{ + .hostname = gop.key_ptr.*, + .timestamp = std.time.timestamp(), + .terminfo_version = terminfo_copy, + }; + break :add .added; + } else update: { + // Update timestamp for existing entry + gop.value_ptr.timestamp = std.time.timestamp(); + break :update .updated; + }; + + try self.writeCacheFile(alloc, entries, null); + return result; +} + +/// Remove a hostname entry from the cache. +/// No error is returned if the hostname doesn't exist or the cache file is missing. +pub fn remove( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !void { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Acquire exclusive lock + _ = file.tryLock(.exclusive) catch return error.CacheIsLocked; + defer file.unlock(); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + // Remove the entry if it exists and ensure we free the memory + if (entries.fetchRemove(hostname)) |kv| { + assert(kv.key.ptr == kv.value.hostname.ptr); + alloc.free(kv.value.hostname); + alloc.free(kv.value.terminfo_version); + } + + try self.writeCacheFile(alloc, entries, null); +} + +/// Check if a hostname exists in the cache. +/// Returns false if the cache file doesn't exist. +pub fn contains( + self: DiskCache, + alloc: Allocator, + hostname: []const u8, +) !bool { + if (!isValidCacheKey(hostname)) return error.HostnameIsInvalid; + + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{ .mode = .read_write }, + ) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(); + try fixupPermissions(file); + + // Read existing entries + var entries = try readEntries(alloc, file); + defer deinitEntries(alloc, &entries); + + return entries.contains(hostname); +} + +fn fixupPermissions(file: std.fs.File) !void { + // Ensure file has correct permissions (readable/writable by + // owner only) + const stat = try file.stat(); + if (stat.mode & 0o777 != 0o600) { + try file.chmod(0o600); + } +} + +fn writeCacheFile( + self: DiskCache, + alloc: Allocator, + entries: std.StringHashMap(Entry), + expire_days: ?u32, +) !void { + var td: TempDir = try .init(); + defer td.deinit(); + + const tmp_file = try td.dir.createFile("ssh-cache", .{ .mode = 0o600 }); + defer tmp_file.close(); + const tmp_path = try td.dir.realpathAlloc(alloc, "ssh-cache"); + defer alloc.free(tmp_path); + + const writer = tmp_file.writer(); + var iter = entries.iterator(); + while (iter.next()) |kv| { + // Only write non-expired entries + if (kv.value_ptr.isExpired(expire_days)) continue; + try kv.value_ptr.format(writer); + } + + // Atomic replace + try std.fs.renameAbsolute(tmp_path, self.path); +} + +/// List all entries in the cache. +/// The returned HashMap must be freed using `deinitEntries`. +/// Returns an empty map if the cache file doesn't exist. +pub fn list( + self: DiskCache, + alloc: Allocator, +) !std.StringHashMap(Entry) { + // Open our file + const file = std.fs.openFileAbsolute( + self.path, + .{}, + ) catch |err| switch (err) { + error.FileNotFound => return .init(alloc), + else => return err, + }; + defer file.close(); + return readEntries(alloc, file); +} + +/// Free memory allocated by the `list` function. +/// This must be called to properly deallocate all entry data. +pub fn deinitEntries( + alloc: Allocator, + entries: *std.StringHashMap(Entry), +) void { + // All our entries we dupe the memory owned by the hostname and the + // terminfo, and we always match the hostname key and value. + var it = entries.iterator(); + while (it.next()) |entry| { + assert(entry.key_ptr.*.ptr == entry.value_ptr.hostname.ptr); + alloc.free(entry.value_ptr.hostname); + alloc.free(entry.value_ptr.terminfo_version); + } + entries.deinit(); +} + +fn readEntries( + alloc: Allocator, + file: std.fs.File, +) !std.StringHashMap(Entry) { + const content = try file.readToEndAlloc(alloc, MAX_CACHE_SIZE); + defer alloc.free(content); + + var entries = std.StringHashMap(Entry).init(alloc); + var lines = std.mem.tokenizeScalar(u8, content, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + const entry = Entry.parse(trimmed) orelse continue; + + // Always allocate hostname first to avoid key pointer confusion + const hostname = try alloc.dupe(u8, entry.hostname); + errdefer alloc.free(hostname); + + const gop = try entries.getOrPut(hostname); + if (!gop.found_existing) { + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.* = .{ + .hostname = hostname, + .timestamp = entry.timestamp, + .terminfo_version = terminfo_copy, + }; + } else { + // Don't need the copy since entry already exists + alloc.free(hostname); + + // Handle duplicate entries - keep newer timestamp + if (entry.timestamp > gop.value_ptr.timestamp) { + gop.value_ptr.timestamp = entry.timestamp; + if (!std.mem.eql( + u8, + gop.value_ptr.terminfo_version, + entry.terminfo_version, + )) { + alloc.free(gop.value_ptr.terminfo_version); + const terminfo_copy = try alloc.dupe(u8, entry.terminfo_version); + gop.value_ptr.terminfo_version = terminfo_copy; + } + } + } + } + + return entries; +} + +// Supports both standalone hostnames and user@hostname format +fn isValidCacheKey(key: []const u8) bool { + // 253 + 1 + 64 for user@hostname + if (key.len == 0 or key.len > 320) return false; + + // Check for user@hostname format + if (std.mem.indexOf(u8, key, "@")) |at_pos| { + const user = key[0..at_pos]; + const hostname = key[at_pos + 1 ..]; + return isValidUser(user) and isValidHostname(hostname); + } + + return isValidHostname(key); +} + +// Basic hostname validation - accepts domains and IPs +// (including IPv6 in brackets) +fn isValidHostname(host: []const u8) bool { + if (host.len == 0 or host.len > 253) return false; + + // Handle IPv6 addresses in brackets + if (host.len >= 4 and host[0] == '[' and host[host.len - 1] == ']') { + const ipv6_part = host[1 .. host.len - 1]; + if (ipv6_part.len == 0) return false; + var has_colon = false; + for (ipv6_part) |c| { + switch (c) { + 'a'...'f', 'A'...'F', '0'...'9' => {}, + ':' => has_colon = true, + else => return false, + } + } + return has_colon; + } + + // Standard hostname/domain validation + for (host) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '.', '-' => {}, + else => return false, + } + } + + // No leading/trailing dots or hyphens, no consecutive dots + if (host[0] == '.' or host[0] == '-' or + host[host.len - 1] == '.' or host[host.len - 1] == '-') + { + return false; + } + + return std.mem.indexOf(u8, host, "..") == null; +} + +fn isValidUser(user: []const u8) bool { + if (user.len == 0 or user.len > 64) return false; + for (user) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '_', '-', '.' => {}, + else => return false, + } + } + return true; +} + +test "disk cache default path" { + const testing = std.testing; + const alloc = std.testing.allocator; + + const path = try DiskCache.defaultPath(alloc, "ghostty"); + defer alloc.free(path); + try testing.expect(path.len > 0); +} + +test "disk cache clear" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create our path + var td: TempDir = try .init(); + defer td.deinit(); + { + var file = try td.dir.createFile("cache", .{}); + defer file.close(); + try file.writer().writeAll("HELLO!"); + } + const path = try td.dir.realpathAlloc(alloc, "cache"); + defer alloc.free(path); + + // Setup our cache + const cache: DiskCache = .{ .path = path }; + try cache.clear(); + + // Verify the file is gone + try testing.expectError( + error.FileNotFound, + td.dir.openFile("cache", .{}), + ); +} + +test "disk cache operations" { + const testing = std.testing; + const alloc = testing.allocator; + + // Create our path + var td: TempDir = try .init(); + defer td.deinit(); + { + var file = try td.dir.createFile("cache", .{}); + defer file.close(); + try file.writer().writeAll("HELLO!"); + } + const path = try td.dir.realpathAlloc(alloc, "cache"); + defer alloc.free(path); + + // Setup our cache + const cache: DiskCache = .{ .path = path }; + try testing.expectEqual( + AddResult.added, + try cache.add(alloc, "example.com"), + ); + try testing.expectEqual( + AddResult.updated, + try cache.add(alloc, "example.com"), + ); + try testing.expect( + try cache.contains(alloc, "example.com"), + ); + + // List + var entries = try cache.list(alloc); + deinitEntries(alloc, &entries); + + // Remove + try cache.remove(alloc, "example.com"); + try testing.expect( + !(try cache.contains(alloc, "example.com")), + ); + try testing.expectEqual( + AddResult.added, + try cache.add(alloc, "example.com"), + ); +} + +// Tests +test "hostname validation - valid cases" { + const testing = std.testing; + try testing.expect(isValidHostname("example.com")); + try testing.expect(isValidHostname("sub.example.com")); + try testing.expect(isValidHostname("host-name.domain.org")); + try testing.expect(isValidHostname("192.168.1.1")); + try testing.expect(isValidHostname("a")); + try testing.expect(isValidHostname("1")); +} + +test "hostname validation - IPv6 addresses" { + const testing = std.testing; + try testing.expect(isValidHostname("[::1]")); + try testing.expect(isValidHostname("[2001:db8::1]")); + try testing.expect(!isValidHostname("[fe80::1%eth0]")); // Interface notation not supported + try testing.expect(!isValidHostname("[]")); // Empty IPv6 + try testing.expect(!isValidHostname("[invalid]")); // No colons +} + +test "hostname validation - invalid cases" { + const testing = std.testing; + try testing.expect(!isValidHostname("")); + try testing.expect(!isValidHostname("host\nname")); + try testing.expect(!isValidHostname(".example.com")); + try testing.expect(!isValidHostname("example.com.")); + try testing.expect(!isValidHostname("host..domain")); + try testing.expect(!isValidHostname("-hostname")); + try testing.expect(!isValidHostname("hostname-")); + try testing.expect(!isValidHostname("host name")); + try testing.expect(!isValidHostname("host_name")); + try testing.expect(!isValidHostname("host@domain")); + try testing.expect(!isValidHostname("host:port")); + + // Too long + const long_host = "a" ** 254; + try testing.expect(!isValidHostname(long_host)); +} + +test "user validation - valid cases" { + const testing = std.testing; + try testing.expect(isValidUser("user")); + try testing.expect(isValidUser("deploy")); + try testing.expect(isValidUser("test-user")); + try testing.expect(isValidUser("user_name")); + try testing.expect(isValidUser("user.name")); + try testing.expect(isValidUser("user123")); + try testing.expect(isValidUser("a")); +} + +test "user validation - complex realistic cases" { + const testing = std.testing; + try testing.expect(isValidUser("git")); + try testing.expect(isValidUser("ubuntu")); + try testing.expect(isValidUser("root")); + try testing.expect(isValidUser("service.account")); + try testing.expect(isValidUser("user-with-dashes")); +} + +test "user validation - invalid cases" { + const testing = std.testing; + try testing.expect(!isValidUser("")); + try testing.expect(!isValidUser("user name")); + try testing.expect(!isValidUser("user@domain")); + try testing.expect(!isValidUser("user:group")); + try testing.expect(!isValidUser("user\nname")); + + // Too long + const long_user = "a" ** 65; + try testing.expect(!isValidUser(long_user)); +} + +test "cache key validation - hostname format" { + const testing = std.testing; + try testing.expect(isValidCacheKey("example.com")); + try testing.expect(isValidCacheKey("sub.example.com")); + try testing.expect(isValidCacheKey("192.168.1.1")); + try testing.expect(isValidCacheKey("[::1]")); + try testing.expect(!isValidCacheKey("")); + try testing.expect(!isValidCacheKey(".invalid.com")); +} + +test "cache key validation - user@hostname format" { + const testing = std.testing; + try testing.expect(isValidCacheKey("user@example.com")); + try testing.expect(isValidCacheKey("deploy@prod.server.com")); + try testing.expect(isValidCacheKey("test-user@192.168.1.1")); + try testing.expect(isValidCacheKey("user_name@host.domain.org")); + try testing.expect(isValidCacheKey("git@github.com")); + try testing.expect(isValidCacheKey("ubuntu@[::1]")); + try testing.expect(!isValidCacheKey("@example.com")); + try testing.expect(!isValidCacheKey("user@")); + try testing.expect(!isValidCacheKey("user@@host")); + try testing.expect(!isValidCacheKey("user@.invalid.com")); +} diff --git a/src/cli/ssh-cache/Entry.zig b/src/cli/ssh-cache/Entry.zig new file mode 100644 index 000000000..3a691be80 --- /dev/null +++ b/src/cli/ssh-cache/Entry.zig @@ -0,0 +1,154 @@ +/// A single entry within our SSH entry cache. Our SSH entry cache +/// stores which hosts we've sent our terminfo to so that we don't have +/// to send it again. It doesn't store any sensitive information. +const Entry = @This(); + +const std = @import("std"); + +hostname: []const u8, +timestamp: i64, +terminfo_version: []const u8, + +pub fn parse(line: []const u8) ?Entry { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (trimmed.len == 0) return null; + + // Parse format: hostname|timestamp|terminfo_version + var iter = std.mem.tokenizeScalar(u8, trimmed, '|'); + const hostname = iter.next() orelse return null; + const timestamp_str = iter.next() orelse return null; + const terminfo_version = iter.next() orelse "xterm-ghostty"; + const timestamp = std.fmt.parseInt(i64, timestamp_str, 10) catch |err| { + std.log.warn( + "Invalid timestamp in cache entry: {s} err={}", + .{ timestamp_str, err }, + ); + return null; + }; + + return .{ + .hostname = hostname, + .timestamp = timestamp, + .terminfo_version = terminfo_version, + }; +} + +pub fn format(self: Entry, writer: anytype) !void { + try writer.print( + "{s}|{d}|{s}\n", + .{ self.hostname, self.timestamp, self.terminfo_version }, + ); +} + +pub fn isExpired(self: Entry, expire_days_: ?u32) bool { + const expire_days = expire_days_ orelse return false; + const now = std.time.timestamp(); + const age_days = @divTrunc(now -| self.timestamp, std.time.s_per_day); + return age_days > expire_days; +} + +test "cache entry expiration" { + const testing = std.testing; + const now = std.time.timestamp(); + + const fresh_entry: Entry = .{ + .hostname = "test.com", + .timestamp = now - std.time.s_per_day, // 1 day old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!fresh_entry.isExpired(90)); + + const old_entry: Entry = .{ + .hostname = "old.com", + .timestamp = now - (std.time.s_per_day * 100), // 100 days old + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(old_entry.isExpired(90)); + + // Test never-expire case + try testing.expect(!old_entry.isExpired(null)); +} + +test "cache entry expiration exact boundary" { + const testing = std.testing; + const now = std.time.timestamp(); + + // Exactly at expiration boundary + const boundary_entry: Entry = .{ + .hostname = "example.com", + .timestamp = now - (std.time.s_per_day * 30), + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!boundary_entry.isExpired(30)); + try testing.expect(boundary_entry.isExpired(29)); +} + +test "cache entry expiration large timestamp" { + const testing = std.testing; + const now = std.time.timestamp(); + + const boundary_entry: Entry = .{ + .hostname = "example.com", + .timestamp = now + (std.time.s_per_day * 30), + .terminfo_version = "xterm-ghostty", + }; + try testing.expect(!boundary_entry.isExpired(30)); +} + +test "cache entry parsing valid formats" { + const testing = std.testing; + + const entry = Entry.parse("example.com|1640995200|xterm-ghostty").?; + try testing.expectEqualStrings("example.com", entry.hostname); + try testing.expectEqual(@as(i64, 1640995200), entry.timestamp); + try testing.expectEqualStrings("xterm-ghostty", entry.terminfo_version); + + // Test default terminfo version + const entry_no_version = Entry.parse("test.com|1640995200").?; + try testing.expectEqualStrings( + "xterm-ghostty", + entry_no_version.terminfo_version, + ); + + // Test complex hostnames + const complex_entry = Entry.parse("user@server.example.com|1640995200|xterm-ghostty").?; + try testing.expectEqualStrings( + "user@server.example.com", + complex_entry.hostname, + ); +} + +test "cache entry parsing invalid formats" { + const testing = std.testing; + + try testing.expect(Entry.parse("") == null); + + // Invalid format (no pipe) + try testing.expect(Entry.parse("v1") == null); + + // Missing timestamp + try testing.expect(Entry.parse("example.com") == null); + + // Invalid timestamp + try testing.expect(Entry.parse("example.com|invalid") == null); + + // Empty terminfo should default + try testing.expect(Entry.parse("example.com|1640995200|") != null); +} + +test "cache entry parsing malformed data resilience" { + const testing = std.testing; + + // Extra pipes should not break parsing + try testing.expect(Entry.parse("host|123|term|extra") != null); + + // Whitespace handling + try testing.expect(Entry.parse(" host|123|term ") != null); + try testing.expect(Entry.parse("\n") == null); + try testing.expect(Entry.parse(" \t \n") == null); + + // Extremely large timestamp + try testing.expect( + Entry.parse("host|999999999999999999999999999999999999999999999999|xterm-ghostty") == null, + ); +} diff --git a/src/cli/ssh_cache.zig b/src/cli/ssh_cache.zig new file mode 100644 index 000000000..1099f0112 --- /dev/null +++ b/src/cli/ssh_cache.zig @@ -0,0 +1,208 @@ +const std = @import("std"); +const fs = std.fs; +const Allocator = std.mem.Allocator; +const xdg = @import("../os/xdg.zig"); +const args = @import("args.zig"); +const Action = @import("ghostty.zig").Action; +pub const Entry = @import("ssh-cache/Entry.zig"); +pub const DiskCache = @import("ssh-cache/DiskCache.zig"); + +pub const Options = struct { + clear: bool = false, + add: ?[]const u8 = null, + remove: ?[]const u8 = null, + host: ?[]const u8 = null, + @"expire-days": ?u32 = null, + + pub fn deinit(self: *Options) void { + _ = self; + } + + pub fn help(self: Options) !void { + _ = self; + return Action.help_error; + } +}; + +/// Manage the SSH terminfo cache for automatic remote host setup. +/// +/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`, +/// Ghostty automatically installs its terminfo on remote hosts. This command +/// manages the cache of successful installations to avoid redundant uploads. +/// +/// The cache stores hostnames (or user@hostname combinations) along with timestamps. +/// Entries older than the expiration period are automatically removed during cache +/// operations. By default, entries never expire. +/// +/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified. +/// If multiple are specified, one of the actions will be executed but +/// it isn't guaranteed which one. This is entirely unsafe so you should split +/// multiple actions into separate commands. +/// +/// Examples: +/// ghostty +ssh-cache # List all cached hosts +/// ghostty +ssh-cache --host=example.com # Check if host is cached +/// ghostty +ssh-cache --add=example.com # Manually add host to cache +/// ghostty +ssh-cache --add=user@example.com # Add user@host combination +/// ghostty +ssh-cache --remove=example.com # Remove host from cache +/// ghostty +ssh-cache --clear # Clear entire cache +/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period +pub fn run(alloc_gpa: Allocator) !u8 { + var arena = std.heap.ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + var opts: Options = .{}; + defer opts.deinit(); + + { + var iter = try args.argsIterator(alloc_gpa); + defer iter.deinit(); + try args.parse(Options, alloc_gpa, &opts, &iter); + } + + const stdout = std.io.getStdOut().writer(); + const stderr = std.io.getStdErr().writer(); + + // Setup our disk cache to the standard location + const cache_path = try DiskCache.defaultPath(alloc, "ghostty"); + const cache: DiskCache = .{ .path = cache_path }; + + if (opts.clear) { + try cache.clear(); + try stdout.print("Cache cleared.\n", .{}); + return 0; + } + + if (opts.add) |host| { + const result = cache.add(alloc, host) catch |err| switch (err) { + DiskCache.Error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + DiskCache.Error.CacheIsLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to add '{s}' to cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, + }; + + switch (result) { + .added => try stdout.print("Added '{s}' to cache.\n", .{host}), + .updated => try stdout.print("Updated '{s}' cache entry.\n", .{host}), + } + return 0; + } + + if (opts.remove) |host| { + cache.remove(alloc, host) catch |err| switch (err) { + DiskCache.Error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + DiskCache.Error.CacheIsLocked => { + try stderr.print("Error: Cache is busy, try again\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to remove '{s}' from cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, + }; + try stdout.print("Removed '{s}' from cache.\n", .{host}); + return 0; + } + + if (opts.host) |host| { + const cached = cache.contains(alloc, host) catch |err| switch (err) { + error.HostnameIsInvalid => { + try stderr.print("Error: Invalid hostname format '{s}'\n", .{host}); + try stderr.print("Expected format: hostname or user@hostname\n", .{}); + return 1; + }, + else => { + try stderr.print( + "Error: Unable to check host '{s}' in cache. Error: {}\n", + .{ host, err }, + ); + return 1; + }, + }; + + if (cached) { + try stdout.print( + "'{s}' has Ghostty terminfo installed.\n", + .{host}, + ); + return 0; + } else { + try stdout.print( + "'{s}' does not have Ghostty terminfo installed.\n", + .{host}, + ); + return 1; + } + } + + // Default action: list all hosts + var entries = try cache.list(alloc); + defer DiskCache.deinitEntries(alloc, &entries); + try listEntries(alloc, &entries, stdout); + return 0; +} + +fn listEntries( + alloc: Allocator, + entries: *const std.StringHashMap(Entry), + writer: anytype, +) !void { + if (entries.count() == 0) { + try writer.print("No hosts in cache.\n", .{}); + return; + } + + // Sort entries by hostname for consistent output + var items = std.ArrayList(Entry).init(alloc); + defer items.deinit(); + + var iter = entries.iterator(); + while (iter.next()) |kv| { + try items.append(kv.value_ptr.*); + } + + std.mem.sort(Entry, items.items, {}, struct { + fn lessThan(_: void, a: Entry, b: Entry) bool { + return std.mem.lessThan(u8, a.hostname, b.hostname); + } + }.lessThan); + + try writer.print("Cached hosts ({d}):\n", .{items.items.len}); + const now = std.time.timestamp(); + + for (items.items) |entry| { + const age_days = @divTrunc(now - entry.timestamp, std.time.s_per_day); + if (age_days == 0) { + try writer.print(" {s} (today)\n", .{entry.hostname}); + } else if (age_days == 1) { + try writer.print(" {s} (yesterday)\n", .{entry.hostname}); + } else { + try writer.print(" {s} ({d} days ago)\n", .{ entry.hostname, age_days }); + } + } +} + +test { + _ = DiskCache; + _ = Entry; +} 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/config/Config.zig b/src/config/Config.zig index 66da2b5e9..1e2086876 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -689,10 +689,10 @@ palette: Palette = .{}, /// other colors at runtime: /// /// * `cell-foreground` - Match the cell foreground color. -/// (Available since version 1.2.0) +/// (Available since: 1.2.0) /// /// * `cell-background` - Match the cell background color. -/// (Available since version 1.2.0) +/// (Available since: 1.2.0) @"cursor-color": ?TerminalColor = null, /// The opacity level (opposite of transparency) of the cursor. A value of 1 @@ -814,6 +814,22 @@ palette: Palette = .{}, /// On macOS, changing this configuration requires restarting Ghostty completely. @"background-opacity": f64 = 1.0, +/// Applies background opacity to cells with an explicit background color +/// set. +/// +/// Normally, `background-opacity` is only applied to the window background. +/// If a cell has an explicit background color set, such as red, then that +/// background color will be fully opaque. An effect of this is that some +/// terminal applications that repaint the background color of the terminal +/// such as a Neovim and Tmux may not respect the `background-opacity` +/// (by design). +/// +/// Setting this to `true` will apply the `background-opacity` to all cells +/// regardless of whether they have an explicit background color set or not. +/// +/// Available since: 1.2.0 +@"background-opacity-cells": bool = false, + /// Whether to blur the background when `background-opacity` is less than 1. /// /// Valid values are: @@ -2202,6 +2218,8 @@ keybind: Keybinds = .{}, /// its default value is used, so you must explicitly disable features you don't /// want. You can also use `true` or `false` to turn all features on or off. /// +/// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` +/// /// Available features: /// /// * `cursor` - Set the cursor to a blinking bar at the prompt. @@ -2210,7 +2228,26 @@ keybind: Keybinds = .{}, /// /// * `title` - Set the window title via shell integration. /// -/// Example: `cursor`, `no-cursor`, `sudo`, `no-sudo`, `title`, `no-title` +/// * `ssh-env` - Enable SSH environment variable compatibility. Automatically +/// converts TERM from `xterm-ghostty` to `xterm-256color` when connecting to +/// remote hosts and propagates COLORTERM, TERM_PROGRAM, and TERM_PROGRAM_VERSION. +/// Whether or not these variables will be accepted by the remote host(s) will +/// depend on whether or not the variables are allowed in their sshd_config. +/// (Available since: 1.2.0) +/// +/// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. +/// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when +/// connecting to hosts that lack it. Requires `infocmp` to be available locally +/// and `tic` to be available on remote hosts. Once terminfo is installed on a +/// remote host, it will be automatically "cached" to avoid repeat installations. +/// If desired, the `+ssh-cache` CLI action can be used to manage the installation +/// cache manually using various arguments. +/// (Available since: 1.2.0) +/// +/// SSH features work independently and can be combined for optimal experience: +/// when both `ssh-env` and `ssh-terminfo` are enabled, Ghostty will install its +/// terminfo on remote hosts and use `xterm-ghostty` as TERM, falling back to +/// `xterm-256color` with environment variables if terminfo installation fails. @"shell-integration-features": ShellIntegrationFeatures = .{}, /// Custom entries into the command palette. @@ -6636,6 +6673,8 @@ pub const ShellIntegrationFeatures = packed struct { cursor: bool = true, sudo: bool = false, title: bool = true, + @"ssh-env": bool = false, + @"ssh-terminfo": bool = false, }; pub const RepeatableCommand = struct { diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index e2d60905d..bb9a472d2 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -343,7 +343,14 @@ pub const Face = struct { const cell_width: f64 = @floatFromInt(metrics.cell_width); // const cell_height: f64 = @floatFromInt(metrics.cell_height); - const glyph_size = opts.constraint.constrain( + // We eliminate any negative vertical padding since these overlap + // values aren't needed under CoreText with how precisely we apply + // constraints, and they can lead to extra height that looks bad + // for things like powerline glyphs. + var constraint = opts.constraint; + constraint.pad_top = @max(0.0, constraint.pad_top); + constraint.pad_bottom = @max(0.0, constraint.pad_bottom); + const glyph_size = constraint.constrain( .{ .width = rect.size.width, .height = rect.size.height, @@ -354,48 +361,75 @@ pub const Face = struct { opts.constraint_width, ); - // We manually quantize the position and size of the glyph to whole - // pixel boundaries. Since macOS doesn't do font hinting this helps - // a lot for legibility at small sizes on low dpi displays. + // These calculations are an attempt to mostly imitate the effect of + // `shouldSubpixelQuantizeFonts`[^1], which helps maximize legibility + // at small pixel sizes (low DPI). We do this math ourselves instead + // of letting CoreText do it because it's not entirely clear how the + // math in CoreText works and we've run in to edge cases where glyphs + // have their bottom or left row cut off due to bad rounding. // - // Well, okay, so, it seems like macOS does have a rudimentary auto- - // hinter of sorts, except they call it "subpixel quantization"[^1]. + // This math seems to have a mostly comparable result to whatever it + // is that CoreText does, and is even (in my opinion) better in some + // cases. // - // Why not just use that? Because it's unpredictable and would force - // us to have an extra pixel of padding in the atlas for most glyphs - // that don't need it, since it's hard to know whether a given glyph - // will have its bottom or left edge snapped out an extra pixel. + // I'm not entirely certain but I suspect that when you enable the + // CoreText option it also does some sort of rudimentary hinting, + // but it doesn't seem to make that big of a difference in terms + // of legibility in the end. // - // Also, this empirically just looks a whole lot better than theirs. - // Admittedly this is a very specific use case, we're rendering for - // a monospace grid and don't really have to worry about sub-pixel - // positioning; I'm sure Apple's technique is better for cases with - // proportional text. - // - // An effort was made to more or less match Apple's quantization in - // terms of resulting whole-pixel glyph sizes. Oddly it looks like - // Apple is still horizontally quantizing to thirds of a pixel, as - // if they're doing subpixel rendering for a horizontally striped - // LCD, even though they haven't done subpixel rendering for years. - // We don't match them on that, it tends to just make it blurrier. - // - // [^1]: Well I'm 80% sure it's hinting since it seems to account for - // features inside of the glyph like crossbars, not just the bounding - // box like we do. The documentation is... sparse. Ref: - // https://developer.apple.com/documentation/coregraphics/cgcontext/setshouldsubpixelquantizefonts(_:)?language=objc + // [^1]: https://developer.apple.com/documentation/coregraphics/cgcontext/setshouldsubpixelquantizefonts(_:)?language=objc + + // We only want to apply quantization if we don't have any + // constraints and this isn't a bitmap glyph, since CoreText + // doesn't seem to apply its quantization to bitmap glyphs. // // TODO: Maybe gate this so it only applies at small font sizes, // or else offer a user config option that can disable it. - const x = @round(glyph_size.x); - const y = @round(glyph_size.y); - // We subtract a third here so that we behave (somewhat) like the weird - // one third pixel quantization that Apple does. This is basically just - // a fudge factor though. - const width = @max(1.0, @ceil(glyph_size.width + glyph_size.x - x - 1.0 / 3.0)); - const height = @max(1.0, @ceil(glyph_size.height + glyph_size.y - y)); + const should_quantize = !sbix and std.meta.eql(opts.constraint, .none); - const px_width: u32 = @intFromFloat(@ceil(width)); - const px_height: u32 = @intFromFloat(@ceil(height)); + // We offset our glyph by its bearings when we draw it, using `@floor` + // here rounds it *up* since we negate it right outside. Moving it by + // whole pixels ensures that we don't disturb the pixel alignment of + // the glyph, fractional pixels will still be drawn on all sides as + // necessary. + const draw_x = -@floor(rect.origin.x); + const draw_y = -@floor(rect.origin.y); + + // We use `x` and `y` for our full pixel bearings post-raster. + // We need to subtract the fractional pixel of difference from + // the edge of the draw area to the edge of the actual glyph. + const frac_x = rect.origin.x + draw_x; + const frac_y = rect.origin.y + draw_y; + const x = glyph_size.x - frac_x; + const y = glyph_size.y - frac_y; + + // We never modify the width. + // + // When using the CoreText option the widths do seem to be + // modified extremely subtly, but even at very small font + // sizes it's hardly a noticeable difference. + const width = glyph_size.width; + + // If the top of the glyph (taking in to account the y position) + // is within half a pixel of an exact pixel edge, we round up the + // height, otherwise leave it alone. + // + // This seems to match what CoreText does. + const frac_top = (glyph_size.height + frac_y) - @floor(glyph_size.height + frac_y); + const height = + if (should_quantize) + if (frac_top >= 0.5) + glyph_size.height + 1 - frac_top + else + glyph_size.height + else + glyph_size.height; + + // Add the fractional pixel to the width and height and take + // the ceiling to get a canvas size that will definitely fit + // our drawn glyph. + const px_width: u32 = @intFromFloat(@ceil(width + frac_x)); + const px_height: u32 = @intFromFloat(@ceil(height + frac_y)); // Settings that are specific to if we are rendering text or emoji. const color: struct { @@ -505,13 +539,8 @@ pub const Face = struct { height / rect.size.height, ); - // We want to render the glyphs at (0,0), but the glyphs themselves - // are offset by bearings, so we have to undo those bearings in order - // to get them to 0,0. - self.font.drawGlyphs(&glyphs, &.{.{ - .x = -rect.origin.x, - .y = -rect.origin.y, - }}, ctx); + // Draw our glyph. + self.font.drawGlyphs(&glyphs, &.{.{ .x = draw_x, .y = draw_y }}, ctx); // Write our rasterized glyph to the atlas. const region = try atlas.reserve(alloc, px_width, px_height); @@ -519,7 +548,7 @@ pub const Face = struct { // This should be the distance from the bottom of // the cell to the top of the glyph's bounding box. - const offset_y: i32 = @as(i32, @intFromFloat(@ceil(y + height))); + const offset_y: i32 = @as(i32, @intFromFloat(@round(y))) + @as(i32, @intCast(px_height)); // This should be the distance from the left of // the cell to the left of the glyph's bounding box. @@ -538,9 +567,7 @@ pub const Face = struct { // since in that case the position was already calculated with the // new cell width in mind. if (opts.constraint.align_horizontal == .none) { - var advances: [glyphs.len]macos.graphics.Size = undefined; - _ = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, &advances); - const advance = advances[0].width; + const advance = self.font.getAdvancesForGlyphs(.horizontal, &glyphs, null); const new_advance = cell_width * @as(f64, @floatFromInt(opts.cell_width orelse 1)); // If the original advance is greater than the cell width then @@ -552,13 +579,13 @@ pub const Face = struct { // We also don't want to do anything if the advance is zero or // less, since this is used for stuff like combining characters. if (advance > new_advance or advance <= 0.0) { - break :offset_x @intFromFloat(@ceil(x)); + break :offset_x @intFromFloat(@round(x)); } break :offset_x @intFromFloat( @round(x + (new_advance - advance) / 2), ); } else { - break :offset_x @intFromFloat(@ceil(x)); + break :offset_x @intFromFloat(@round(x)); } }; 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/os/kernel_info.zig b/src/os/kernel_info.zig index 9e3933dde..e57cc3047 100644 --- a/src/os/kernel_info.zig +++ b/src/os/kernel_info.zig @@ -17,7 +17,7 @@ test "read /proc/sys/kernel/osrelease" { if (comptime builtin.os.tag != .linux) return null; const allocator = std.testing.allocator; - const kernel_info = try getKernelInfo(allocator); + const kernel_info = getKernelInfo(allocator).?; defer allocator.free(kernel_info); // Since we can't hardcode the info in tests, just check diff --git a/src/os/main.zig b/src/os/main.zig index 7398fc779..41dc6aa29 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -2,6 +2,8 @@ //! system. These aren't restricted to syscalls or low-level operations, but //! also OS-specific features and conventions. +const builtin = @import("builtin"); + const dbus = @import("dbus.zig"); const desktop = @import("desktop.zig"); const env = @import("env.zig"); @@ -14,7 +16,7 @@ const openpkg = @import("open.zig"); const pipepkg = @import("pipe.zig"); const resourcesdir = @import("resourcesdir.zig"); const systemd = @import("systemd.zig"); -const kernelInfo = @import("kernel_info.zig"); +const kernel_info = @import("kernel_info.zig"); // Namespaces pub const args = @import("args.zig"); @@ -59,8 +61,12 @@ pub const pipe = pipepkg.pipe; pub const resourcesDir = resourcesdir.resourcesDir; pub const ResourcesDir = resourcesdir.ResourcesDir; pub const ShellEscapeWriter = shell.ShellEscapeWriter; -pub const getKernelInfo = kernelInfo.getKernelInfo; +pub const getKernelInfo = kernel_info.getKernelInfo; test { _ = i18n; + + if (comptime builtin.os.tag == .linux) { + _ = kernel_info; + } } diff --git a/src/os/systemd.zig b/src/os/systemd.zig index 9b67296d6..1a9a10ee7 100644 --- a/src/os/systemd.zig +++ b/src/os/systemd.zig @@ -63,3 +63,136 @@ pub fn launchedBySystemd() bool { else => false, }; } + +/// systemd notifications. Used by Ghostty to inform systemd of the state of the +/// process. Currently only used to notify systemd that we are ready and that +/// configuration reloading has started. +/// +/// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html +/// +/// These functions were re-implemented in Zig instead of using the `libsystemd` +/// library to avoid the complexity of another external dependency, as well as +/// to take advantage of Zig features like `comptime` to ensure minimal impact +/// on non-Linux systems (like FreeBSD) that will never support `systemd`. +/// +/// Linux systems that do not use `systemd` should not be impacted as they +/// should never start Ghostty with the `NOTIFY_SOCKET` environment variable set +/// and these functions essentially become a no-op. +/// +/// See `systemd`'s [Interface Portability and Stability Promise](https://systemd.io/PORTABILITY_AND_STABILITY/) +/// for assurances that the interfaces used here will be supported and stable for +/// the long term. +pub const notify = struct { + /// Send the given message to the UNIX socket specified in the NOTIFY_SOCKET + /// environment variable. If there NOTIFY_SOCKET environment variable does + /// not exist then no message is sent. + fn send(message: []const u8) void { + // systemd is Linux-only so this is a no-op anywhere else + if (comptime builtin.os.tag != .linux) return; + + // Get the socket address that should receive notifications. + const socket_path = std.posix.getenv("NOTIFY_SOCKET") orelse return; + + // If the socket address is an empty string return. + if (socket_path.len == 0) return; + + // The socket address must be a path or an abstract socket. + if (socket_path[0] != '/' and socket_path[0] != '@') { + log.warn("only AF_UNIX sockets with path or abstract namespace addresses are supported!", .{}); + return; + } + + var socket_address: std.os.linux.sockaddr.un = undefined; + + // Error out if the supplied socket path is too long. + if (socket_address.path.len < socket_path.len) { + log.warn("NOTIFY_SOCKET path is too long!", .{}); + return; + } + + socket_address.family = std.os.linux.AF.UNIX; + + @memcpy(socket_address.path[0..socket_path.len], socket_path); + socket_address.path[socket_path.len] = 0; + + const socket: std.os.linux.socket_t = socket: { + const rc = std.os.linux.socket( + std.os.linux.AF.UNIX, + std.os.linux.SOCK.DGRAM | std.os.linux.SOCK.CLOEXEC, + 0, + ); + switch (std.os.linux.E.init(rc)) { + .SUCCESS => break :socket @intCast(rc), + else => |e| { + log.warn("creating socket failed: {s}", .{@tagName(e)}); + return; + }, + } + }; + + defer _ = std.os.linux.close(socket); + + connect: { + const rc = std.os.linux.connect( + socket, + &socket_address, + @offsetOf(std.os.linux.sockaddr.un, "path") + socket_address.path.len, + ); + switch (std.os.linux.E.init(rc)) { + .SUCCESS => break :connect, + else => |e| { + log.warn("unable to connect to notify socket: {s}", .{@tagName(e)}); + return; + }, + } + } + + write: { + const rc = std.os.linux.write(socket, message.ptr, message.len); + switch (std.os.linux.E.init(rc)) { + .SUCCESS => { + const written = rc; + if (written < message.len) { + log.warn("short write to notify socket: {d} < {d}", .{ rc, message.len }); + return; + } + break :write; + }, + else => |e| { + log.warn("unable to write to notify socket: {s}", .{@tagName(e)}); + return; + }, + } + } + } + + /// Tell systemd that we are ready or that we are finished reloading. + /// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#READY=1 + pub fn ready() void { + if (comptime builtin.os.tag != .linux) return; + + send("READY=1"); + } + + /// Tell systemd that we have started reloading our configuration. + /// See: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#RELOADING=1 + /// and: https://www.freedesktop.org/software/systemd/man/latest/sd_notify.html#MONOTONIC_USEC=%E2%80%A6 + pub fn reloading() void { + if (comptime builtin.os.tag != .linux) return; + + const ts = std.posix.clock_gettime(.MONOTONIC) catch |err| { + log.err("unable to get MONOTONIC clock: {}", .{err}); + return; + }; + + const now = ts.sec * std.time.us_per_s + @divFloor(ts.nsec, std.time.ns_per_us); + + var buffer: [64]u8 = undefined; + const message = std.fmt.bufPrint(&buffer, "RELOADING=1\nMONOTONIC_USEC={d}", .{now}) catch |err| { + log.err("unable to format reloading message: {}", .{err}); + return; + }; + + send(message); + } +}; diff --git a/src/renderer/cell.zig b/src/renderer/cell.zig index 43d744176..b1ce4523c 100644 --- a/src/renderer/cell.zig +++ b/src/renderer/cell.zig @@ -156,6 +156,17 @@ pub const Contents = struct { } } + /// Returns the current cursor glyph if present, checking both cursor lists. + pub fn getCursorGlyph(self: *Contents) ?shaderpkg.CellText { + if (self.fg_rows.lists[0].items.len > 0) { + return self.fg_rows.lists[0].items[0]; + } + if (self.fg_rows.lists[self.size.rows + 1].items.len > 0) { + return self.fg_rows.lists[self.size.rows + 1].items[0]; + } + return null; + } + /// Access a background cell. Prefer this function over direct indexing /// of `bg_cells` in order to avoid integer size bugs causing overflows. pub inline fn bgCell( @@ -350,14 +361,17 @@ test Contents { }; c.setCursor(cursor_cell, .block); try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); + try testing.expectEqual(cursor_cell, c.getCursorGlyph().?); // And remove it. c.setCursor(null, null); try testing.expectEqual(0, c.fg_rows.lists[0].items.len); + try testing.expect(c.getCursorGlyph() == null); // Add a hollow cursor. c.setCursor(cursor_cell, .block_hollow); try testing.expectEqual(cursor_cell, c.fg_rows.lists[rows + 1].items[0]); + try testing.expectEqual(cursor_cell, c.getCursorGlyph().?); } test "Contents clear retains other content" { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 3965d302a..1517ec662 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -516,6 +516,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { cursor_text: ?configpkg.Config.TerminalColor, background: terminal.color.RGB, background_opacity: f64, + background_opacity_cells: bool, foreground: terminal.color.RGB, selection_background: ?configpkg.Config.TerminalColor, selection_foreground: ?configpkg.Config.TerminalColor, @@ -568,6 +569,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return .{ .background_opacity = @max(0, @min(1, config.@"background-opacity")), + .background_opacity_cells = config.@"background-opacity-cells", .font_thicken = config.@"font-thicken", .font_thicken_strength = config.@"font-thicken-strength", .font_features = font_features.list, @@ -2218,10 +2220,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; // Update custom cursor uniforms, if we have a cursor. - if (self.cells.fg_rows.lists[0].items.len > 0) { - const cursor: shaderpkg.CellText = - self.cells.fg_rows.lists[0].items[0]; - + if (self.cells.getCursorGlyph()) |cursor| { const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]); @@ -2631,6 +2630,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Cells that are reversed should be fully opaque. if (style.flags.inverse) break :bg_alpha default; + // If the user requested to have opacity on all cells, apply it. + if (self.config.background_opacity_cells and bg_style != null) { + var opacity: f64 = @floatFromInt(default); + opacity *= self.config.background_opacity; + break :bg_alpha @intFromFloat(opacity); + } + // Cells that have an explicit bg color should be fully opaque. if (bg_style != null) break :bg_alpha default; diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index df4c7f9a7..ca5a012c6 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -26,6 +26,12 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then builtin declare __ghostty_bash_flags="$GHOSTTY_BASH_INJECT" builtin unset ENV GHOSTTY_BASH_INJECT + # Restore an existing ENV that was replaced by the shell integration code. + if [[ -n "$GHOSTTY_BASH_ENV" ]]; then + builtin export ENV=$GHOSTTY_BASH_ENV + builtin unset GHOSTTY_BASH_ENV + fi + # Restore bash's default 'posix' behavior. Also reset 'inherit_errexit', # which doesn't happen as part of the 'posix' reset. builtin set +o posix @@ -95,6 +101,76 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then } fi +# SSH Integration +if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then + ssh() { + builtin local ssh_term ssh_opts + ssh_term="xterm-256color" + ssh_opts=() + + # Configure environment variables for remote session + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then + ssh_opts+=(-o "SetEnv COLORTERM=truecolor") + ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + fi + + # Install terminfo on remote host if needed + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then + builtin local ssh_user ssh_hostname + + while IFS=' ' read -r ssh_key ssh_value; do + case "$ssh_key" in + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; + esac + [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break + done < <(builtin command ssh -G "$@" 2>/dev/null) + + if [[ -n "$ssh_hostname" ]]; then + builtin local ssh_target="${ssh_user}@${ssh_hostname}" + + # Check if terminfo is already cached + if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then + ssh_term="xterm-ghostty" + elif builtin command -v infocmp >/dev/null 2>&1; then + builtin local ssh_terminfo ssh_cpath_dir ssh_cpath + + ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) + + if [[ -n "$ssh_terminfo" ]]; then + builtin echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 + + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" + + if builtin echo "$ssh_terminfo" | builtin command ssh -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + ssh_term="xterm-ghostty" + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + else + builtin echo "Warning: Failed to install terminfo." >&2 + fi + else + builtin echo "Warning: Could not generate terminfo data." >&2 + fi + else + builtin echo "Warning: ghostty command not available for cache management." >&2 + fi + fi + fi + + # Execute SSH with TERM environment variable + TERM="$ssh_term" builtin command ssh "${ssh_opts[@]}" "$@" + } +fi + # Import bash-preexec, safe to do multiple times builtin source "$(dirname -- "${BASH_SOURCE[0]}")/bash-preexec.sh" diff --git a/src/shell-integration/elvish/lib/ghostty-integration.elv b/src/shell-integration/elvish/lib/ghostty-integration.elv index a6d052a72..6d0d19f4f 100644 --- a/src/shell-integration/elvish/lib/ghostty-integration.elv +++ b/src/shell-integration/elvish/lib/ghostty-integration.elv @@ -38,6 +38,9 @@ { use str + # List of enabled shell integration features + var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)] + # helper used by `mark-*` functions fn set-prompt-state {|new| set-env __ghostty_prompt_state $new } @@ -98,6 +101,83 @@ (external sudo) $@args } + fn ssh-integration {|@args| + var ssh-term = "xterm-256color" + var ssh-opts = [] + + # Configure environment variables for remote session + if (has-value $features ssh-env) { + set ssh-opts = (conj $ssh-opts ^ + -o "SetEnv COLORTERM=truecolor" ^ + -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + } + + if (has-value $features ssh-terminfo) { + var ssh-user = "" + var ssh-hostname = "" + + # Parse ssh config + for line [((external ssh) -G $@args)] { + var parts = [(str:fields $line)] + if (> (count $parts) 1) { + var ssh-key = $parts[0] + var ssh-value = $parts[1] + if (eq $ssh-key user) { + set ssh-user = $ssh-value + } elif (eq $ssh-key hostname) { + set ssh-hostname = $ssh-value + } + if (and (not-eq $ssh-user "") (not-eq $ssh-hostname "")) { + break + } + } + } + + if (not-eq $ssh-hostname "") { + var ghostty = $E:GHOSTTY_BIN_DIR/"ghostty" + var ssh-target = $ssh-user"@"$ssh-hostname + + # Check if terminfo is already cached + if (bool ?($ghostty +ssh-cache --host=$ssh-target)) { + set ssh-term = "xterm-ghostty" + } elif (has-external infocmp) { + var ssh-terminfo = ((external infocmp) -0 -x xterm-ghostty 2>/dev/null | slurp) + + if (not-eq $ssh-terminfo "") { + echo "Setting up xterm-ghostty terminfo on "$ssh-hostname"..." >&2 + + use os + var ssh-cpath-dir = (os:temp-dir "ghostty-ssh-"$ssh-user".*") + var ssh-cpath = $ssh-cpath-dir"/socket" + + if (bool ?(echo $ssh-terminfo | (external ssh) $@ssh-opts -o ControlMaster=yes -o ControlPath=$ssh-cpath -o ControlPersist=60s $@args ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null)) { + set ssh-term = "xterm-ghostty" + set ssh-opts = (conj $ssh-opts -o ControlPath=$ssh-cpath) + + # Cache successful installation + $ghostty +ssh-cache --add=$ssh-target >/dev/null + } else { + echo "Warning: Failed to install terminfo." >&2 + } + } else { + echo "Warning: Could not generate terminfo data." >&2 + } + } else { + echo "Warning: ghostty command not available for cache management." >&2 + } + } + } + + with [E:TERM = $ssh-term] { + (external ssh) $@ssh-opts $@args + } + } + defer { mark-prompt-start report-pwd @@ -107,8 +187,6 @@ set edit:after-readline = (conj $edit:after-readline $mark-output-start~) set edit:after-command = (conj $edit:after-command $mark-output-end~) - var features = [(str:split ',' $E:GHOSTTY_SHELL_FEATURES)] - if (has-value $features title) { set after-chdir = (conj $after-chdir {|_| report-pwd }) } @@ -121,4 +199,7 @@ if (and (has-value $features sudo) (not-eq "" $E:TERMINFO) (has-external sudo)) { edit:add-var sudo~ $sudo-with-terminfo~ } + if (and (str:contains $E:GHOSTTY_SHELL_FEATURES ssh-) (has-external ssh)) { + edit:add-var ssh~ $ssh-integration~ + } } diff --git a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish index e7c264e1f..834f0ef10 100644 --- a/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +++ b/src/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish @@ -86,6 +86,89 @@ function __ghostty_setup --on-event fish_prompt -d "Setup ghostty integration" end end + # SSH Integration + set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") + if contains ssh-env $features; or contains ssh-terminfo $features + function ssh --wraps=ssh --description "SSH wrapper with Ghostty integration" + set -l features (string split ',' -- "$GHOSTTY_SHELL_FEATURES") + set -l ssh_term "xterm-256color" + set -l ssh_opts + + # Configure environment variables for remote session + if contains ssh-env $features + set -a ssh_opts -o "SetEnv COLORTERM=truecolor" + set -a ssh_opts -o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION" + end + + # Install terminfo on remote host if needed + if contains ssh-terminfo $features + set -l ssh_user + set -l ssh_hostname + + for line in (command ssh -G $argv 2>/dev/null) + set -l parts (string split ' ' -- $line) + if test (count $parts) -ge 2 + switch $parts[1] + case user + set ssh_user $parts[2] + case hostname + set ssh_hostname $parts[2] + end + if test -n "$ssh_user"; and test -n "$ssh_hostname" + break + end + end + end + + if test -n "$ssh_hostname" + set -l ssh_target "$ssh_user@$ssh_hostname" + + # Check if terminfo is already cached + if test -x "$GHOSTTY_BIN_DIR/ghostty"; and "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1 + set ssh_term "xterm-ghostty" + else if command -q infocmp + set -l ssh_terminfo + set -l ssh_cpath_dir + set -l ssh_cpath + + set ssh_terminfo (infocmp -0 -x xterm-ghostty 2>/dev/null) + + if test -n "$ssh_terminfo" + echo "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 + + set ssh_cpath_dir (mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null; or echo "/tmp/ghostty-ssh-$ssh_user."(random)) + set ssh_cpath "$ssh_cpath_dir/socket" + + if echo "$ssh_terminfo" | command ssh $ssh_opts -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s $argv ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null + set ssh_term "xterm-ghostty" + set -a ssh_opts -o "ControlPath=$ssh_cpath" + + # Cache successful installation + if test -x "$GHOSTTY_BIN_DIR/ghostty" + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1; or true + end + else + echo "Warning: Failed to install terminfo." >&2 + end + else + echo "Warning: Could not generate terminfo data." >&2 + end + else + echo "Warning: ghostty command not available for cache management." >&2 + end + end + end + + # Execute SSH with TERM environment variable + env TERM="$ssh_term" command ssh $ssh_opts $argv + end + end + # Setup prompt marking function __ghostty_mark_prompt_start --on-event fish_prompt --on-event fish_cancel --on-event fish_posterror # If we never got the output end event, then we need to send it now. diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index c1329683e..8607664a2 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -244,6 +244,79 @@ _ghostty_deferred_init() { } fi + # SSH Integration + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then + ssh() { + emulate -L zsh + setopt local_options no_glob_subst + + local ssh_term ssh_opts + ssh_term="xterm-256color" + ssh_opts=() + + # Configure environment variables for remote session + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-env* ]]; then + ssh_opts+=(-o "SetEnv COLORTERM=truecolor") + ssh_opts+=(-o "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION") + fi + + # Install terminfo on remote host if needed + if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-terminfo* ]]; then + local ssh_user ssh_hostname + + while IFS=' ' read -r ssh_key ssh_value; do + case "$ssh_key" in + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; + esac + [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break + done < <(command ssh -G "$@" 2>/dev/null) + + if [[ -n "$ssh_hostname" ]]; then + local ssh_target="${ssh_user}@${ssh_hostname}" + + # Check if terminfo is already cached + if "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --host="$ssh_target" >/dev/null 2>&1; then + ssh_term="xterm-ghostty" + elif (( $+commands[infocmp] )); then + local ssh_terminfo ssh_cpath_dir ssh_cpath + + ssh_terminfo=$(infocmp -0 -x xterm-ghostty 2>/dev/null) + + if [[ -n "$ssh_terminfo" ]]; then + print "Setting up xterm-ghostty terminfo on $ssh_hostname..." >&2 + + ssh_cpath_dir=$(mktemp -d "/tmp/ghostty-ssh-$ssh_user.XXXXXX" 2>/dev/null) || ssh_cpath_dir="/tmp/ghostty-ssh-$ssh_user.$$" + ssh_cpath="$ssh_cpath_dir/socket" + + if print "$ssh_terminfo" | command ssh "${ssh_opts[@]}" -o ControlMaster=yes -o ControlPath="$ssh_cpath" -o ControlPersist=60s "$@" ' + infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 + command -v tic >/dev/null 2>&1 || exit 1 + mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 + exit 1 + ' 2>/dev/null; then + ssh_term="xterm-ghostty" + ssh_opts+=(-o "ControlPath=$ssh_cpath") + + # Cache successful installation + "$GHOSTTY_BIN_DIR/ghostty" +ssh-cache --add="$ssh_target" >/dev/null 2>&1 || true + else + print "Warning: Failed to install terminfo." >&2 + fi + else + print "Warning: Could not generate terminfo data." >&2 + fi + else + print "Warning: ghostty command not available for cache management." >&2 + fi + fi + fi + + # Execute SSH with TERM environment variable + TERM="$ssh_term" command ssh "${ssh_opts[@]}" "$@" + } + fi + # Some zsh users manually run `source ~/.zshrc` in order to apply rc file # changes to the current shell. This is a terrible practice that breaks many # things, including our shell integration. For example, Oh My Zsh and Prezto 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"); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9838bfb53..660949c9c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1401,6 +1401,15 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) !void { assert(count < rows); for (count..rows) |_| _ = try self.grow(); } + + // Make sure that the viewport pin isn't below the active + // area, since that will lead to all sorts of problems. + switch (self.viewport) { + .pin => if (self.pinIsActive(self.viewport_pin.*)) { + self.viewport = .{ .active = {} }; + }, + .active, .top => {}, + } }, } @@ -5975,6 +5984,36 @@ test "PageList resize (no reflow) more rows extends blank lines" { } } +test "PageList resize (no reflow) more rows contains viewport" { + const testing = std.testing; + const alloc = testing.allocator; + + // When the rows are increased we need to make sure that the viewport + // doesn't end up below the active area if it's currently in pin mode. + + var s = try init(alloc, 5, 5, 1); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + + // Make it so we have scrollback + _ = try s.grow(); + + try testing.expectEqual(@as(usize, 5), s.rows); + try testing.expectEqual(@as(usize, 6), s.totalRows()); + + // Set viewport above active by scrolling up one. + s.scroll(.{ .delta_row = -1 }); + // The viewport should be a pin now. + try testing.expectEqual(Viewport.pin, s.viewport); + + // Resize + try s.resize(.{ .rows = 7, .reflow = false }); + try testing.expectEqual(@as(usize, 7), s.rows); + try testing.expectEqual(@as(usize, 7), s.totalRows()); + // The viewport should now be active, not a pin. + try testing.expectEqual(Viewport.active, s.viewport); +} + test "PageList resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/termio/shell_integration.zig b/src/termio/shell_integration.zig index fb62327d3..438c2a0ea 100644 --- a/src/termio/shell_integration.zig +++ b/src/termio/shell_integration.zig @@ -177,10 +177,28 @@ pub fn setupFeatures( }; var buffer = try std.BoundedArray(u8, capacity).init(0); - inline for (fields) |field| { - if (@field(features, field.name)) { + // Sort the fields so that the output is deterministic. This is + // done at comptime so it has no runtime cost + const fields_sorted: [fields.len][]const u8 = comptime fields: { + var fields_sorted: [fields.len][]const u8 = undefined; + for (fields, 0..) |field, i| fields_sorted[i] = field.name; + std.mem.sortUnstable( + []const u8, + &fields_sorted, + {}, + (struct { + fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.ascii.orderIgnoreCase(lhs, rhs) == .lt; + } + }).lessThan, + ); + break :fields fields_sorted; + }; + + inline for (fields_sorted) |name| { + if (@field(features, name)) { if (buffer.len > 0) try buffer.append(','); - try buffer.appendSlice(field.name); + try buffer.appendSlice(name); } } @@ -201,8 +219,8 @@ test "setup features" { var env = EnvMap.init(alloc); defer env.deinit(); - try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true }); - try testing.expectEqualStrings("cursor,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?); + try setupFeatures(&env, .{ .cursor = true, .sudo = true, .title = true, .@"ssh-env" = true, .@"ssh-terminfo" = true }); + try testing.expectEqualStrings("cursor,ssh-env,ssh-terminfo,sudo,title", env.get("GHOSTTY_SHELL_FEATURES").?); } // Test: all features disabled @@ -210,7 +228,7 @@ test "setup features" { var env = EnvMap.init(alloc); defer env.deinit(); - try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false }); + try setupFeatures(&env, .{ .cursor = false, .sudo = false, .title = false, .@"ssh-env" = false, .@"ssh-terminfo" = false }); try testing.expect(env.get("GHOSTTY_SHELL_FEATURES") == null); } @@ -219,8 +237,8 @@ test "setup features" { var env = EnvMap.init(alloc); defer env.deinit(); - try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false }); - try testing.expectEqualStrings("sudo", env.get("GHOSTTY_SHELL_FEATURES").?); + try setupFeatures(&env, .{ .cursor = false, .sudo = true, .title = false, .@"ssh-env" = true, .@"ssh-terminfo" = false }); + try testing.expectEqualStrings("ssh-env,sudo", env.get("GHOSTTY_SHELL_FEATURES").?); } } @@ -322,6 +340,11 @@ fn setupBash( } } + // Preserve an existing ENV value. We're about to overwrite it. + if (env.get("ENV")) |v| { + try env.put("GHOSTTY_BASH_ENV", v); + } + // Set our new ENV to point to our integration script. var path_buf: [std.fs.max_path_bytes]u8 = undefined; const integ_dir = try std.fmt.bufPrint( @@ -484,6 +507,22 @@ test "bash: HISTFILE" { } } +test "bash: ENV" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var env = EnvMap.init(alloc); + defer env.deinit(); + + try env.put("ENV", "env.sh"); + + _ = try setupBash(alloc, .{ .shell = "bash" }, ".", &env); + try testing.expectEqualStrings("./shell-integration/bash/ghostty.bash", env.get("ENV").?); + try testing.expectEqualStrings("env.sh", env.get("GHOSTTY_BASH_ENV").?); +} + test "bash: additional arguments" { const testing = std.testing; var arena = ArenaAllocator.init(testing.allocator); diff --git a/test/run-all.sh b/test/run-all.sh index 77beb344a..d4a785a44 100755 --- a/test/run-all.sh +++ b/test/run-all.sh @@ -9,9 +9,6 @@ DIR=$(cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd) # We always copy the bin in case it was rebuilt cp ${DIR}/../zig-out/bin/ghostty ${DIR}/ -# Build our image once -IMAGE=$(docker build --file ${DIR}/Dockerfile -q ${DIR}) - # Unix shortcut to just execute ./run-host for each one. We can do # this less esoterically if we ever wanted. find ${DIR}/cases \ @@ -23,4 +20,4 @@ find ${DIR}/cases \ ${DIR}/run-host.sh \ --case '{}' \ --rewrite-abs-path \ - $@ + "$@" diff --git a/test/run-host.sh b/test/run-host.sh index 887f2cfc1..da9dbe2e5 100755 --- a/test/run-host.sh +++ b/test/run-host.sh @@ -13,4 +13,4 @@ docker run \ --entrypoint "xvfb-run" \ $IMAGE \ --server-args="-screen 0, 1600x900x24" \ - /entrypoint.sh $@ + /entrypoint.sh "$@" diff --git a/test/run.sh b/test/run.sh index 641dc6943..db05ede76 100755 --- a/test/run.sh +++ b/test/run.sh @@ -63,6 +63,7 @@ if [ $bad -ne 0 ]; then fi # Load our test case +# shellcheck disable=SC1090 source ${ARG_CASE} if ! has_func "test_do"; then echo "Test case is invalid." @@ -79,7 +80,7 @@ if [ "$ARG_EXEC" = "ghostty" ]; then # We build in Nix (maybe). To be sure, we replace the interpreter so # it doesn't point to a Nix path. If we don't build in Nix, this should # still be safe. - patchelf --set-interpreter /lib/ld-linux-$(uname -m).so.1 ${ARG_EXEC} + patchelf --set-interpreter /lib/ld-linux-"$(uname -m)".so.1 ${ARG_EXEC} fi #--------------------------------------------------------------------