From d46952f93015b6ff138f320f927bace9dc5ac5ef Mon Sep 17 00:00:00 2001 From: skanga Date: Thu, 6 Aug 2026 20:50:51 -0700 Subject: [PATCH] Add native Windows build support --- .gitignore | 2 + README.md | 14 ++++ build.cmd | 19 +++++ internal/components/editor/editor_test.go | 6 ++ internal/lsp/client_windows_test.go | 21 ++++++ internal/lsp/install_download_test.go | 3 +- internal/lsp/manager.go | 4 + internal/lsp/manager_test.go | 31 ++++++-- internal/lsp/protocol.go | 33 +++++++- internal/syntax/highlight.go | 6 ++ internal/syntax/highlight_nocgo.go | 92 +++++++++++++++++++++++ internal/syntax/highlight_nocgo_test.go | 30 ++++++++ internal/syntax/highlight_test.go | 9 +++ internal/syntax/languages.go | 2 + internal/syntax/languages_nocgo.go | 44 +++++++++++ 15 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 build.cmd create mode 100644 internal/lsp/client_windows_test.go create mode 100644 internal/syntax/highlight_nocgo.go create mode 100644 internal/syntax/highlight_nocgo_test.go create mode 100644 internal/syntax/languages_nocgo.go diff --git a/.gitignore b/.gitignore index ae4a280..7371956 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ bin/ build/ dist/ +.gocache/ +.gomodcache/ cmd/toastapp/payload/ghostling cmd/toastapp/payload/toast cmd/toastapp/payload/libghostty-vt.dylib diff --git a/README.md b/README.md index afa0da9..0876f9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..48e5139 --- /dev/null +++ b/build.cmd @@ -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 diff --git a/internal/components/editor/editor_test.go b/internal/components/editor/editor_test.go index a6c2abc..8ca3ee4 100644 --- a/internal/components/editor/editor_test.go +++ b/internal/components/editor/editor_test.go @@ -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") @@ -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") diff --git a/internal/lsp/client_windows_test.go b/internal/lsp/client_windows_test.go new file mode 100644 index 0000000..063ea30 --- /dev/null +++ b/internal/lsp/client_windows_test.go @@ -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)) + } +} diff --git a/internal/lsp/install_download_test.go b/internal/lsp/install_download_test.go index b645e71..70113b2 100644 --- a/internal/lsp/install_download_test.go +++ b/internal/lsp/install_download_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -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) diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 995734e..72a60d6 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -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 "" } diff --git a/internal/lsp/manager_test.go b/internal/lsp/manager_test.go index 39eace6..076b46d 100644 --- a/internal/lsp/manager_test.go +++ b/internal/lsp/manager_test.go @@ -2,8 +2,9 @@ package lsp import ( "encoding/json" + "fmt" "os" - "path/filepath" + "runtime" "strings" "testing" @@ -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 @@ -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") } diff --git a/internal/lsp/protocol.go b/internal/lsp/protocol.go index 9ad02a6..98417c6 100644 --- a/internal/lsp/protocol.go +++ b/internal/lsp/protocol.go @@ -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 { @@ -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() } @@ -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 } diff --git a/internal/syntax/highlight.go b/internal/syntax/highlight.go index 6ccd89f..c017bda 100644 --- a/internal/syntax/highlight.go +++ b/internal/syntax/highlight.go @@ -1,3 +1,5 @@ +//go:build cgo + package syntax import ( @@ -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. diff --git a/internal/syntax/highlight_nocgo.go b/internal/syntax/highlight_nocgo.go new file mode 100644 index 0000000..23b74b5 --- /dev/null +++ b/internal/syntax/highlight_nocgo.go @@ -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 +} diff --git a/internal/syntax/highlight_nocgo_test.go b/internal/syntax/highlight_nocgo_test.go new file mode 100644 index 0000000..5b4ba89 --- /dev/null +++ b/internal/syntax/highlight_nocgo_test.go @@ -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) + } +} diff --git a/internal/syntax/highlight_test.go b/internal/syntax/highlight_test.go index 6db09c8..e01c935 100644 --- a/internal/syntax/highlight_test.go +++ b/internal/syntax/highlight_test.go @@ -8,6 +8,9 @@ import ( ) func TestHighlightGoCode(t *testing.T) { + if !syntax.TreeSitterAvailable() { + t.Skip("tree-sitter requires cgo") + } tm, _ := theme.NewManager("toast-dark", "") h, err := syntax.NewHighlighter("test.go", tm) if err != nil { @@ -33,6 +36,9 @@ func TestHighlightUnknownLang(t *testing.T) { } func TestHighlightTerraformHCL(t *testing.T) { + if !syntax.TreeSitterAvailable() { + t.Skip("tree-sitter requires cgo") + } tm, _ := theme.NewManager("toast-dark", "") h, err := syntax.NewHighlighter("main.tf", tm) if err != nil { @@ -222,6 +228,9 @@ func TestHighlightJSONArray(t *testing.T) { } func TestHighlightTerraformExtensions(t *testing.T) { + if !syntax.TreeSitterAvailable() { + t.Skip("tree-sitter requires cgo") + } tests := []struct { path string }{ diff --git a/internal/syntax/languages.go b/internal/syntax/languages.go index 79cea3d..f84d1d3 100644 --- a/internal/syntax/languages.go +++ b/internal/syntax/languages.go @@ -1,3 +1,5 @@ +//go:build cgo + package syntax import ( diff --git a/internal/syntax/languages_nocgo.go b/internal/syntax/languages_nocgo.go new file mode 100644 index 0000000..f8e21b3 --- /dev/null +++ b/internal/syntax/languages_nocgo.go @@ -0,0 +1,44 @@ +//go:build !cgo + +package syntax + +import ( + "path/filepath" + "strings" +) + +// LangDef describes a recognized source language. Query is retained in the +// no-cgo API for compatibility, but is empty because tree-sitter is disabled. +type LangDef struct { + Name string + Query []byte +} + +var langByExt map[string]*LangDef +var langByName map[string]*LangDef + +func init() { + names := []string{ + "go", "python", "javascript", "typescript", "rust", "css", "html", + "yaml", "bash", "hcl", "markdown", "json", + } + defs := make([]*LangDef, len(names)) + langByName = make(map[string]*LangDef, len(names)) + for i, name := range names { + defs[i] = &LangDef{Name: name} + langByName[name] = defs[i] + } + + langByExt = map[string]*LangDef{ + ".go": defs[0], ".py": defs[1], ".js": defs[2], ".mjs": defs[2], + ".ts": defs[3], ".tsx": defs[3], ".rs": defs[4], ".css": defs[5], + ".html": defs[6], ".htm": defs[6], ".yaml": defs[7], ".yml": defs[7], + ".sh": defs[8], ".bash": defs[8], + ".hcl": defs[9], ".tf": defs[9], ".tfvars": defs[9], + ".md": defs[10], ".markdown": defs[10], + ".json": defs[11], ".jsonc": defs[11], + } +} + +func ForPath(path string) *LangDef { return langByExt[strings.ToLower(filepath.Ext(path))] } +func ForName(name string) *LangDef { return langByName[strings.ToLower(name)] }