Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
bin/
build/
dist/
.gocache/
.gomodcache/
cmd/toastapp/payload/ghostling
cmd/toastapp/payload/toast
cmd/toastapp/payload/libghostty-vt.dylib
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ make build
# binary written to bin/toast
```

On Windows, run the native build script from `cmd.exe`:

```bat
git clone https://github.com/paradise-runner/toast
cd toast
build.cmd
rem binary written to bin\toast.exe
```

The script uses `go` from `PATH`, or falls back to
`C:\bin\Go\bin\go.exe`. A C compiler is optional: builds with
`CGO_ENABLED=0` retain JSON/JSONC highlighting and use plain text for the
tree-sitter-backed languages.

## Usage

```bash
Expand Down
19 changes: 19 additions & 0 deletions build.cmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@echo off
setlocal

set "GO_EXE=go"
where go >nul 2>nul
if errorlevel 1 (
if exist "C:\bin\Go\bin\go.exe" (
set "GO_EXE=C:\bin\Go\bin\go.exe"
) else (
echo error: Go was not found on PATH or at C:\bin\Go\bin\go.exe 1>&2
exit /b 1
)
)

if not exist "bin" mkdir "bin"
"%GO_EXE%" build -o "bin\toast.exe" ".\cmd\toast"
if errorlevel 1 exit /b %errorlevel%

echo Built bin\toast.exe
6 changes: 6 additions & 0 deletions internal/components/editor/editor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,9 @@ func TestDeleteBackward_StaleCursor_NoPanic(t *testing.T) {
// ── Syntax re-parse after edits ──────────────────────────────────────────────

func TestReparseSyntax_AfterTyping(t *testing.T) {
if !syntax.TreeSitterAvailable() {
t.Skip("tree-sitter requires cgo")
}
// Start with a Go file. "func" on line 0 should be highlighted as a keyword.
src := "func main() {}\n"
m := newTestModelWithSyntax(src, "main.go")
Expand Down Expand Up @@ -1313,6 +1316,9 @@ func TestReparseSyntax_AfterDelete(t *testing.T) {
}

func TestReparseSyntax_AfterPasteMsg(t *testing.T) {
if !syntax.TreeSitterAvailable() {
t.Skip("tree-sitter requires cgo")
}
// Paste a string literal into a Go buffer and verify highlighting works.
src := "package main\n"
m := newTestModelWithSyntax(src, "main.go")
Expand Down
21 changes: 21 additions & 0 deletions internal/lsp/client_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//go:build windows

package lsp_test

import (
"path/filepath"
"testing"

"github.com/yourusername/toast/internal/lsp"
)

func TestWindowsPathToURIRoundtrip(t *testing.T) {
path := `C:\Users\bob\My Project\main.go`
uri := lsp.URIFromPath(path)
if uri != "file:///C:/Users/bob/My%20Project/main.go" {
t.Fatalf("URIFromPath() = %q", uri)
}
if back := lsp.PathFromURI(uri); back != filepath.Clean(path) {
t.Fatalf("PathFromURI() = %q, want %q", back, filepath.Clean(path))
}
}
3 changes: 2 additions & 1 deletion internal/lsp/install_download_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -102,7 +103,7 @@ func TestInstallFromDownloadInstall(t *testing.T) {
if err != nil {
t.Fatalf("binary not installed at %s: %v", bin, err)
}
if info.Mode()&0o111 == 0 {
if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 {
t.Fatalf("binary not executable: %v", info.Mode())
}
output, err := os.ReadFile(bin)
Expand Down
4 changes: 4 additions & 0 deletions internal/lsp/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,10 @@ func rustTargetTriple() string {
return "aarch64-unknown-linux-gnu"
case runtime.GOOS == "linux" && runtime.GOARCH == "amd64":
return "x86_64-unknown-linux-gnu"
case runtime.GOOS == "windows" && runtime.GOARCH == "arm64":
return "aarch64-pc-windows-msvc"
case runtime.GOOS == "windows" && runtime.GOARCH == "amd64":
return "x86_64-pc-windows-msvc"
default:
return ""
}
Expand Down
31 changes: 23 additions & 8 deletions internal/lsp/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ package lsp

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -166,17 +167,16 @@ func TestDidChangeTracksEveryFullDocumentVersion(t *testing.T) {
}

func TestInstallCapsVerboseFailureOutput(t *testing.T) {
// An installer that floods stdout with a megabyte of noise before failing.
installer := filepath.Join(t.TempDir(), "noisy-installer")
if err := os.WriteFile(installer, []byte("#!/bin/sh\nhead -c 1048576 /dev/zero | tr '\\0' 'x'\necho FAILED >&2\nexit 1\n"), 0o755); err != nil {
t.Fatal(err)
}

cfg := config.Defaults()
cfg.LSP = map[string]config.LSPCmd{
"test": {
Command: "missing-server",
Install: &config.LSPInstall{Name: "Test LS", Command: installer},
Install: &config.LSPInstall{
Name: "Test LS",
Command: os.Args[0],
Args: []string{"-test.run=TestInstallFailureHelperProcess"},
Env: map[string]string{"TOAST_TEST_INSTALL_FAILURE": "1"},
},
},
}
var sent []tea.Msg
Expand All @@ -200,9 +200,24 @@ func TestInstallCapsVerboseFailureOutput(t *testing.T) {
}
}

func TestInstallFailureHelperProcess(t *testing.T) {
if os.Getenv("TOAST_TEST_INSTALL_FAILURE") != "1" {
return
}
fmt.Print(strings.Repeat("x", 1<<20))
fmt.Fprintln(os.Stderr, "FAILED")
os.Exit(1)
}

func TestExpandTargetTriple(t *testing.T) {
m := NewManager(config.Defaults(), t.TempDir(), func(tea.Msg) {})
triple := rustTargetTriple()
if runtime.GOOS == "windows" && runtime.GOARCH == "amd64" && triple != "x86_64-pc-windows-msvc" {
t.Fatalf("rustTargetTriple() = %q, want x86_64-pc-windows-msvc", triple)
}
if runtime.GOOS == "windows" && runtime.GOARCH == "arm64" && triple != "aarch64-pc-windows-msvc" {
t.Fatalf("rustTargetTriple() = %q, want aarch64-pc-windows-msvc", triple)
}
if triple == "" {
t.Skip("platform has no prebuilt harper binary")
}
Expand Down
33 changes: 31 additions & 2 deletions internal/lsp/protocol.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package lsp

import "net/url"
import (
"net/url"
"path/filepath"
"runtime"
"strings"
)

// RequestMessage is a JSON-RPC 2.0 request.
type RequestMessage struct {
Expand Down Expand Up @@ -153,6 +158,20 @@ type LocationLink struct {

// URIFromPath converts a filesystem path to a file:// URI.
func URIFromPath(path string) string {
if runtime.GOOS == "windows" {
path = filepath.ToSlash(filepath.Clean(path))
if strings.HasPrefix(path, "//") {
parts := strings.SplitN(strings.TrimPrefix(path, "//"), "/", 2)
uri := &url.URL{Scheme: "file", Host: parts[0]}
if len(parts) == 2 {
uri.Path = "/" + parts[1]
}
return uri.String()
}
if len(path) >= 2 && path[1] == ':' {
path = "/" + path
}
}
return (&url.URL{Scheme: "file", Path: path}).String()
}

Expand All @@ -162,5 +181,15 @@ func PathFromURI(uri string) string {
if err != nil || parsed.Scheme != "file" {
return ""
}
return parsed.Path
path := parsed.Path
if runtime.GOOS == "windows" {
if parsed.Host != "" && !strings.EqualFold(parsed.Host, "localhost") {
path = "//" + parsed.Host + path
return filepath.FromSlash(path)
}
if len(path) >= 3 && path[0] == '/' && path[2] == ':' {
return filepath.FromSlash(path[1:])
}
}
return path
}
6 changes: 6 additions & 0 deletions internal/syntax/highlight.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build cgo

package syntax

import (
Expand Down Expand Up @@ -34,6 +36,10 @@ type Highlighter struct {
theme *theme.Manager
}

// TreeSitterAvailable reports whether this build includes the cgo-backed
// tree-sitter highlighter.
func TreeSitterAvailable() bool { return true }

// NewHighlighter creates a Highlighter for the given file path. If the
// extension is not recognised, a no-op highlighter (lang == nil) is returned
// so callers never have to handle a nil value.
Expand Down
92 changes: 92 additions & 0 deletions internal/syntax/highlight_nocgo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//go:build !cgo

package syntax

import (
"strings"

"github.com/yourusername/toast/internal/theme"
)

// Span represents a highlighted byte range within a line.
type Span struct {
Start int
End int
Style string
}

// Highlighter provides JSON/JSONC highlighting when tree-sitter is not
// available. Other recognized languages safely fall back to plain text.
type Highlighter struct {
lang *LangDef
jsonTokens []jsonToken
jsonAllowComments bool
content []byte
theme *theme.Manager
}

// TreeSitterAvailable reports whether this build includes the cgo-backed
// tree-sitter highlighter.
func TreeSitterAvailable() bool { return false }

func NewHighlighter(path string, tm *theme.Manager) (*Highlighter, error) {
h := &Highlighter{lang: ForPath(path), theme: tm}
if h.lang != nil && h.lang.Name == "json" {
h.jsonAllowComments = strings.HasSuffix(strings.ToLower(path), ".jsonc")
}
return h, nil
}

func (h *Highlighter) HasQuery() bool {
return h.lang != nil && h.lang.Name == "json"
}

func (h *Highlighter) Parse(src []byte) {
if h.lang == nil || h.lang.Name != "json" {
return
}
h.jsonTokens = scanJSON(src, h.jsonAllowComments)
h.content = src
}

func (h *Highlighter) Edit(
src []byte,
startByte, oldEndByte, newEndByte uint32,
startRow, startCol, oldEndRow, oldEndCol, newEndRow, newEndCol uint32,
) {
h.Parse(src)
}

func (h *Highlighter) HighlightLine(lineStart int, lineContent string) []Span {
if h.lang == nil || h.lang.Name != "json" || h.jsonTokens == nil {
return nil
}

lineStartByte := 0
if lineStart > 0 {
nlCount := 0
for i, b := range h.content {
if b == '\n' {
nlCount++
if nlCount == lineStart {
lineStartByte = i + 1
break
}
}
}
}
lineEndByte := lineStartByte + len(lineContent)

var spans []Span
for _, tok := range h.jsonTokens {
if tok.endByte <= lineStartByte || tok.startByte >= lineEndByte {
continue
}
start := max(tok.startByte-lineStartByte, 0)
end := min(tok.endByte-lineStartByte, len(lineContent))
if start < end {
spans = append(spans, Span{Start: start, End: end, Style: tok.style})
}
}
return spans
}
30 changes: 30 additions & 0 deletions internal/syntax/highlight_nocgo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//go:build !cgo

package syntax_test

import (
"testing"

"github.com/yourusername/toast/internal/syntax"
"github.com/yourusername/toast/internal/theme"
)

func TestHighlighterWithoutCgoFallsBackGracefully(t *testing.T) {
if syntax.TreeSitterAvailable() {
t.Fatal("tree-sitter must be unavailable in a !cgo build")
}

tm, _ := theme.NewManager("toast-dark", "")
h, err := syntax.NewHighlighter("main.go", tm)
if err != nil {
t.Fatalf("NewHighlighter() error = %v", err)
}
h.Parse([]byte("package main\n"))
h.Edit([]byte("package toast\n"), 8, 12, 13, 0, 8, 0, 12, 0, 13)
if h.HasQuery() {
t.Fatal("non-JSON highlighter unexpectedly has a query without cgo")
}
if spans := h.HighlightLine(0, "package toast\n"); spans != nil {
t.Fatalf("HighlightLine() = %#v, want nil fallback", spans)
}
}
Loading