diff --git a/Makefile b/Makefile index c7d715a..d54d6eb 100644 --- a/Makefile +++ b/Makefile @@ -35,12 +35,22 @@ client: tools: mkdir -p $(CONFIG_DIR)/tools - go build -o $(CONFIG_DIR)/tools/toolsrv ./cmd/toolsrv/ - for d in tools/codeintel/cmd/* tools/filetools/cmd/* tools/lsp/cmd/* tools/web/cmd/*; do \ - name=$$(basename $$d); [ -f $$d/main.go ] || continue; \ - go build -o $(CONFIG_DIR)/tools/$$name ./$$d; \ + go build -ldflags="-s -w" -o $(CONFIG_DIR)/tools/toolsrv ./cmd/toolsrv/ + go build -ldflags="-s -w" -o $(CONFIG_DIR)/tools/codeintel ./tools/codeintel/ + go build -ldflags="-s -w" -o $(CONFIG_DIR)/tools/filetools ./tools/filetools/ + go build -ldflags="-s -w" -o $(CONFIG_DIR)/tools/lsptools ./tools/lsp/cmd/lsptools/ + go build -ldflags="-s -w" -o $(CONFIG_DIR)/tools/web_fetch ./tools/web/cmd/web_fetch/ + cd $(CONFIG_DIR)/tools && for name in code_outline code_query code_rewrite code_symbols code_dependencies codebase_overview; do \ + ln -sf codeintel $$name; \ + done + cd $(CONFIG_DIR)/tools && for name in file_read file_write file_edit file_grep file_glob; do \ + ln -sf filetools $$name; \ + done + cd $(CONFIG_DIR)/tools && for name in lsp_hover lsp_definition lsp_references lsp_completion lsp_diagnostics lsp_symbols lsp_rename; do \ + ln -sf lsptools $$name; \ done install -m644 data/tools/*.meta $(CONFIG_DIR)/tools/ + find tools -name '*.meta' -exec install -m644 {} $(CONFIG_DIR)/tools/ \; for f in data/tools/*; do [ -f "$$f" ] && [ -x "$$f" ] && install -m755 "$$f" $(CONFIG_DIR)/tools/; done; true cp -a data/tools/_lib $(CONFIG_DIR)/tools/ diff --git a/cmd/toolsrv/internal/sandbox/native_linux.go b/cmd/toolsrv/internal/sandbox/native_linux.go index 8ba95a1..d9bcd3c 100644 --- a/cmd/toolsrv/internal/sandbox/native_linux.go +++ b/cmd/toolsrv/internal/sandbox/native_linux.go @@ -79,12 +79,28 @@ func restrict(cfg *Config, cwd string, env map[string]string) error { } f, e := unix.Open(p, unix.O_PATH|unix.O_CLOEXEC, 0) if e != nil { - return e + continue + } + // Filter access mask by file type: directory-only rights are + // invalid on regular files and vice versa. Landlock returns + // EINVAL if inapplicable bits are set. + allowed := access + var st unix.Stat_t + if err := unix.Fstat(f, &st); err == nil && st.Mode&unix.S_IFDIR == 0 { + // Regular file (or other non-directory): keep only file-applicable rights. + const fileBits = unix.LANDLOCK_ACCESS_FS_READ_FILE | + unix.LANDLOCK_ACCESS_FS_WRITE_FILE | + unix.LANDLOCK_ACCESS_FS_EXECUTE + allowed &= fileBits + } + if allowed == 0 { + unix.Close(f) + continue } rule := struct { Allowed uint64 ParentFD int - }{access, int(f)} + }{allowed, int(f)} _, _, errno := unix.Syscall6(unix.SYS_LANDLOCK_ADD_RULE, fd, uintptr(1), uintptr(unsafe.Pointer(&rule)), 0, 0, 0) unix.Close(f) if errno != 0 { diff --git a/cmd/toolsrv/main.go b/cmd/toolsrv/main.go index 295fe52..c9b84e0 100644 --- a/cmd/toolsrv/main.go +++ b/cmd/toolsrv/main.go @@ -94,6 +94,7 @@ func main() { // Create server state srv := server.NewServer() + srv.Fs.SetCWD(*cwd) srv.SetYolo(*yolo) if toolReg != nil { srv.SetRegistry(toolReg) diff --git a/tools/codeintel/main.go b/tools/codeintel/main.go new file mode 100644 index 0000000..b4098c1 --- /dev/null +++ b/tools/codeintel/main.go @@ -0,0 +1,214 @@ +// Multicall binary for codeintel tools. +// Invoked via symlinks: code_outline, code_query, code_rewrite, code_symbols, +// code_dependencies, codebase_overview. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "ollie/tools/codeintel/internal/codeintel" +) + +func main() { + name := filepath.Base(os.Args[0]) + switch name { + case "code_outline": + cmdOutline() + case "code_query": + cmdQuery() + case "code_rewrite": + cmdRewrite() + case "code_symbols": + cmdSymbols() + case "code_dependencies": + cmdDependencies() + case "codebase_overview": + cmdOverview() + default: + fmt.Fprintf(os.Stderr, "codeintel: unknown tool %q\n", name) + os.Exit(1) + } +} + +func fail(e error) { fmt.Fprintln(os.Stderr, "error:", e); os.Exit(1) } + +// --- code_outline --- + +func cmdOutline() { + var a struct { + Path string `json:"path"` + Language string `json:"language"` + } + if err := json.NewDecoder(os.Stdin).Decode(&a); err != nil { + fail(err) + } + p, err := filepath.Abs(a.Path) + if err != nil { + fail(err) + } + d, err := codeintel.Parse(p, a.Language) + if err != nil { + fail(err) + } + defer d.Close() + for _, s := range codeintel.Symbols(d) { + fmt.Printf("%s:%d: %s [%s]\n", s.Path, s.Line, s.Name, s.Kind) + } +} + +// --- code_query --- + +func cmdQuery() { + var a struct { + Path string `json:"path"` + Language string `json:"language"` + Query string `json:"query"` + } + if err := json.NewDecoder(os.Stdin).Decode(&a); err != nil { + fail(err) + } + p, err := filepath.Abs(a.Path) + if err != nil { + fail(err) + } + cs, err := codeintel.Query(p, a.Language, a.Query) + if err != nil { + fail(err) + } + for _, c := range cs { + fmt.Printf("%s:%d: %s = %s\n", c.Name, c.Line, c.Name, c.Text) + } +} + +// --- code_rewrite --- + +func cmdRewrite() { + var a struct { + Path string `json:"path"` + Language string `json:"language"` + Query string `json:"query"` + Replacement string `json:"replacement"` + DryRun bool `json:"dry_run"` + } + if err := json.NewDecoder(os.Stdin).Decode(&a); err != nil { + fail(err) + } + p, err := filepath.Abs(a.Path) + if err != nil { + fail(err) + } + out, err := codeintel.ReplaceMatches(p, a.Language, a.Query, a.Replacement) + if err != nil { + fail(err) + } + if a.DryRun { + fmt.Print(string(out)) + return + } + if err := os.WriteFile(p, out, 0644); err != nil { + fail(err) + } +} + +// --- code_symbols --- + +func cmdSymbols() { + var a struct { + Path string `json:"path"` + Query string `json:"query"` + } + if err := json.NewDecoder(os.Stdin).Decode(&a); err != nil { + fail(err) + } + root, err := filepath.Abs(a.Path) + if err != nil { + fail(err) + } + ss, err := codeintel.MatchSymbols(root, a.Query) + if err != nil { + fail(err) + } + for _, s := range ss { + fmt.Printf("%s:%d: %s [%s]\n", s.Path, s.Line, s.Name, s.Kind) + } +} + +// --- code_dependencies --- + +func cmdDependencies() { + var a struct { + Path string `json:"path"` + Language string `json:"language"` + } + if err := json.NewDecoder(os.Stdin).Decode(&a); err != nil { + fail(err) + } + p, err := filepath.Abs(a.Path) + if err != nil { + fail(err) + } + ds, err := codeintel.Dependencies(p, a.Language) + if err != nil { + fail(err) + } + for _, d := range ds { + fmt.Println(d) + } +} + +// --- codebase_overview --- + +func cmdOverview() { + var a struct { + Path string `json:"path"` + Offset int `json:"offset"` + Limit int `json:"limit"` + } + if err := json.NewDecoder(os.Stdin).Decode(&a); err != nil { + fail(err) + } + root, err := filepath.Abs(a.Path) + if err != nil { + fail(err) + } + files, err := codeintel.Files(root) + if err != nil { + fail(err) + } + start := a.Offset + if start < 0 { + fail(fmt.Errorf("offset must be non-negative")) + } + if a.Limit < 0 { + fail(fmt.Errorf("limit must be non-negative")) + } + if start >= len(files) { + start = len(files) + } + end := len(files) + if a.Limit > 0 && start+a.Limit < end { + end = start + a.Limit + } + fmt.Printf("# Codebase Overview\n\nPath: `%s`\n\n", root) + if len(files) > 0 && (start > 0 || end < len(files)) { + fmt.Printf("Files %d-%d of %d. Next offset: %d\n\n", start+1, end, len(files), end) + } + for _, f := range files[start:end] { + d, err := codeintel.Parse(f.Path, f.Language) + if err != nil { + continue + } + fmt.Printf("## `%s` (%s)\n\n", relPath(root, f.Path), f.Language) + for _, s := range codeintel.Symbols(d) { + fmt.Printf("- %s`%s` — line %d\n", strings.Repeat(" ", min(s.Depth, 4)), s.Name, s.Line) + } + fmt.Println() + d.Close() + } +} + +func relPath(root, path string) string { p, _ := filepath.Rel(root, path); return p } diff --git a/tools/filetools/main.go b/tools/filetools/main.go new file mode 100644 index 0000000..ddd2c1b --- /dev/null +++ b/tools/filetools/main.go @@ -0,0 +1,47 @@ +// Multicall binary for file tools. +// Invoked via symlinks: file_read, file_write, file_edit, file_grep, file_glob. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "ollie/tools/filetools/internal/filetools" +) + +func main() { + name := filepath.Base(os.Args[0]) + var fn func(map[string]any) (string, error) + switch name { + case "file_read": + fn = filetools.Read + case "file_write": + fn = filetools.Write + case "file_edit": + fn = filetools.Edit + case "file_grep": + fn = filetools.Grep + case "file_glob": + fn = filetools.Glob + default: + fmt.Fprintf(os.Stderr, "filetools: unknown tool %q\n", name) + os.Exit(1) + } + + var m map[string]any + if err := json.NewDecoder(os.Stdin).Decode(&m); err != nil { + fail("invalid JSON input: %v", err) + } + out, err := fn(m) + if err != nil { + if out != "" { + fmt.Fprintln(os.Stdout, out) + } + fail("%v", err) + } + fmt.Println(out) +} + +func fail(f string, a ...any) { fmt.Fprintf(os.Stderr, "error: "+f+"\n", a...); os.Exit(1) } diff --git a/tools/lsp/cmd/lsptools/main.go b/tools/lsp/cmd/lsptools/main.go new file mode 100644 index 0000000..be25809 --- /dev/null +++ b/tools/lsp/cmd/lsptools/main.go @@ -0,0 +1,491 @@ +// Multicall binary for LSP tools. +// Invoked via symlinks: lsp_hover, lsp_definition, lsp_references, +// lsp_completion, lsp_diagnostics, lsp_symbols, lsp_rename. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "ollie/tools/lsp" + "ollie/tools/lsp/cmdutil" +) + +func main() { + name := filepath.Base(os.Args[0]) + switch name { + case "lsp_hover": + cmdHover() + case "lsp_definition": + cmdDefinition() + case "lsp_references": + cmdReferences() + case "lsp_completion": + cmdCompletion() + case "lsp_diagnostics": + cmdDiagnostics() + case "lsp_symbols": + cmdSymbols() + case "lsp_rename": + cmdRename() + default: + fmt.Fprintf(os.Stderr, "lsp: unknown tool %q\n", name) + os.Exit(1) + } +} + +// --- lsp_hover --- + +func cmdHover() { + cmdutil.Run("hover", func(result json.RawMessage) { + var hover struct { + Contents struct { + Kind string `json:"kind"` + Value string `json:"value"` + } `json:"contents"` + } + if err := json.Unmarshal(result, &hover); err != nil { + var plain struct { + Contents string `json:"contents"` + } + if json.Unmarshal(result, &plain) == nil { + fmt.Println(plain.Contents) + return + } + fmt.Println("(no hover information)") + return + } + fmt.Println(hover.Contents.Value) + }) +} + +// --- lsp_definition --- + +func cmdDefinition() { + cmdutil.Run("definition", func(result json.RawMessage) { + type position struct { + Line int `json:"line"` + Character int `json:"character"` + } + type rang struct { + Start position `json:"start"` + } + var locations []struct { + URI string `json:"uri"` + TargetURI string `json:"targetUri"` + Range *rang `json:"range"` + TargetRange *rang `json:"targetRange"` + } + if err := json.Unmarshal(result, &locations); err != nil { + var single struct { + URI string `json:"uri"` + TargetURI string `json:"targetUri"` + Range *rang `json:"range"` + TargetRange *rang `json:"targetRange"` + } + if err := json.Unmarshal(result, &single); err != nil { + fmt.Println("(no definition found)") + return + } + locations = append(locations, single) + } + + for _, loc := range locations { + uri := loc.URI + if uri == "" { + uri = loc.TargetURI + } + path := lsp.URIToPath(uri) + r := loc.Range + if r == nil { + r = loc.TargetRange + } + if r == nil { + fmt.Println(path) + continue + } + fmt.Printf("%s:%d:%d\n", path, r.Start.Line+1, r.Start.Character+1) + } + }) +} + +// --- lsp_references --- + +func cmdReferences() { + cmdutil.Run("references", func(result json.RawMessage) { + var locations []struct { + URI string `json:"uri"` + Range struct { + Start struct { + Line int `json:"line"` + Character int `json:"character"` + } `json:"start"` + } `json:"range"` + } + if err := json.Unmarshal(result, &locations); err != nil { + fmt.Println("(no references found)") + return + } + + fmt.Printf("STATUS=ok MATCH_COUNT=%d\n", len(locations)) + for _, loc := range locations { + path := lsp.URIToPath(loc.URI) + fmt.Printf("%s:%d:%d\n", path, loc.Range.Start.Line+1, loc.Range.Start.Character+1) + } + }) +} + +// --- lsp_completion --- + +func cmdCompletion() { + cmdutil.Run("completion", func(result json.RawMessage) { + type completionItem struct { + Label string `json:"label"` + Kind int `json:"kind"` + Detail string `json:"detail"` + } + printItem := func(item completionItem) { + kind := completionKind(item.Kind) + if item.Detail != "" { + fmt.Printf("%s %s [%s]\n", item.Label, item.Detail, kind) + } else { + fmt.Printf("%s [%s]\n", item.Label, kind) + } + } + + var list struct { + Items []completionItem `json:"items"` + } + if json.Unmarshal(result, &list) == nil && len(list.Items) > 0 { + for _, item := range list.Items { + printItem(item) + } + return + } + + var items []completionItem + if json.Unmarshal(result, &items) == nil { + for _, item := range items { + printItem(item) + } + return + } + + fmt.Println("(no completions)") + }) +} + +// --- lsp_diagnostics --- + +func cmdDiagnostics() { + cmdutil.RunCustom(func() { + var args struct { + Path string `json:"path"` + } + if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + file, _ := filepath.Abs(args.Path) + + result, err := lsp.ClientRequest(&lsp.BridgeRequest{ + Method: "diagnostics", + File: file, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if result == nil || string(result) == "null" || string(result) == "[]" { + fmt.Println("(no diagnostics)") + return + } + + type diagnostic struct { + Range struct { + Start struct { + Line int `json:"line"` + Character int `json:"character"` + } `json:"start"` + } `json:"range"` + Severity int `json:"severity"` + Message string `json:"message"` + } + var diags []diagnostic + if err := json.Unmarshal(result, &diags); err != nil { + fmt.Println("(no diagnostics)") + return + } + + for _, d := range diags { + sev := severity(d.Severity) + line := d.Range.Start.Line + 1 + col := d.Range.Start.Character + 1 + fmt.Printf("%s:%d:%d: %s: %s\n", file, line, col, sev, d.Message) + } + }) +} + +// --- lsp_symbols --- + +func cmdSymbols() { + cmdutil.RunCustom(func() { + var args struct { + Path string `json:"path"` + Query string `json:"query"` + } + if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + file, _ := filepath.Abs(args.Path) + workspace := args.Query != "" + + result, err := lsp.ClientRequest(&lsp.BridgeRequest{ + Method: "symbols", + File: file, + Query: args.Query, + Workspace: workspace, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if result == nil || string(result) == "null" { + fmt.Println("(no symbols found)") + return + } + + type rang struct { + Start struct { + Line int `json:"line"` + } `json:"start"` + } + type documentSymbol struct { + Name string `json:"name"` + Kind int `json:"kind"` + Range rang `json:"range"` + Children []documentSymbol `json:"children"` + } + type workspaceSymbol struct { + Name string `json:"name"` + Kind int `json:"kind"` + Location struct { + URI string `json:"uri"` + Range rang `json:"range"` + } `json:"location"` + } + + var docSymbols []documentSymbol + if json.Unmarshal(result, &docSymbols) == nil && len(docSymbols) > 0 { + var printDoc func([]documentSymbol, int) + printDoc = func(symbols []documentSymbol, indent int) { + prefix := strings.Repeat(" ", indent) + for _, s := range symbols { + line := s.Range.Start.Line + 1 + fmt.Printf("%s%s L%d [%s]\n", prefix, s.Name, line, symbolKind(s.Kind)) + if len(s.Children) > 0 { + printDoc(s.Children, indent+1) + } + } + } + printDoc(docSymbols, 0) + return + } + + var wsSymbols []workspaceSymbol + if json.Unmarshal(result, &wsSymbols) == nil && len(wsSymbols) > 0 { + for _, s := range wsSymbols { + path := lsp.URIToPath(s.Location.URI) + line := s.Location.Range.Start.Line + 1 + fmt.Printf("%s:%d %s [%s]\n", path, line, s.Name, symbolKind(s.Kind)) + } + return + } + + fmt.Println("(no symbols found)") + }) +} + +// --- lsp_rename --- + +func cmdRename() { + cmdutil.RunCustom(func() { + var args struct { + Path string `json:"path"` + Line string `json:"line"` + Col string `json:"col"` + NewName string `json:"new_name"` + } + if err := json.NewDecoder(os.Stdin).Decode(&args); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + file, _ := filepath.Abs(args.Path) + line, _ := strconv.Atoi(args.Line) + col, _ := strconv.Atoi(args.Col) + + result, err := lsp.ClientRequest(&lsp.BridgeRequest{ + Method: "rename", + File: file, + Line: line - 1, + Col: col - 1, + NewName: args.NewName, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + if result == nil || string(result) == "null" { + fmt.Println("(rename produced no changes)") + return + } + + summary := applyWorkspaceEdit(result) + if summary == "" { + fmt.Println("(rename produced no changes)") + } else { + fmt.Print(summary) + } + }) +} + +// --- helpers --- + +func applyWorkspaceEdit(raw json.RawMessage) string { + type textEdit struct { + Range struct { + Start struct { + Line int `json:"line"` + Character int `json:"character"` + } `json:"start"` + End struct { + Line int `json:"line"` + Character int `json:"character"` + } `json:"end"` + } `json:"range"` + NewText string `json:"newText"` + } + var edit struct { + Changes map[string][]textEdit `json:"changes"` + DocumentChanges []struct { + TextDocument struct { + URI string `json:"uri"` + } `json:"textDocument"` + Edits []textEdit `json:"edits"` + } `json:"documentChanges"` + } + if err := json.Unmarshal(raw, &edit); err != nil { + return "" + } + + changes := make(map[string][]textEdit) + if len(edit.DocumentChanges) > 0 { + for _, dc := range edit.DocumentChanges { + path := lsp.URIToPath(dc.TextDocument.URI) + changes[path] = append(changes[path], dc.Edits...) + } + } else { + for uri, edits := range edit.Changes { + path := lsp.URIToPath(uri) + changes[path] = edits + } + } + + var sb strings.Builder + totalEdits := 0 + for path, edits := range changes { + data, err := os.ReadFile(path) + if err != nil { + continue + } + lines := strings.SplitAfter(string(data), "\n") + + sort.Slice(edits, func(i, j int) bool { + if edits[i].Range.Start.Line != edits[j].Range.Start.Line { + return edits[i].Range.Start.Line > edits[j].Range.Start.Line + } + return edits[i].Range.Start.Character > edits[j].Range.Start.Character + }) + + for _, e := range edits { + sl, sc := e.Range.Start.Line, e.Range.Start.Character + el, ec := e.Range.End.Line, e.Range.End.Character + if sl >= len(lines) { + continue + } + + before := strings.Join(lines[:sl], "") + lines[sl][:min(sc, len(lines[sl]))] + var after string + if el < len(lines) { + after = lines[el][min(ec, len(lines[el])):] + strings.Join(lines[el+1:], "") + } + content := before + e.NewText + after + lines = strings.SplitAfter(content, "\n") + } + + os.WriteFile(path, []byte(strings.Join(lines, "")), 0644) + totalEdits += len(edits) + fmt.Fprintf(&sb, "%s: %d edit(s)\n", path, len(edits)) + } + fmt.Fprintf(&sb, "total: %d file(s), %d edit(s)\n", len(changes), totalEdits) + return sb.String() +} + +func completionKind(k int) string { + kinds := map[int]string{ + 1: "Text", 2: "Method", 3: "Function", 4: "Constructor", + 5: "Field", 6: "Variable", 7: "Class", 8: "Interface", + 9: "Module", 10: "Property", 11: "Unit", 12: "Value", + 13: "Enum", 14: "Keyword", 15: "Snippet", 16: "Color", + 17: "File", 18: "Reference", 19: "Folder", 20: "EnumMember", + 21: "Constant", 22: "Struct", 23: "Event", 24: "Operator", + 25: "TypeParameter", + } + if name, ok := kinds[k]; ok { + return name + } + return "Unknown" +} + +func symbolKind(k int) string { + kinds := map[int]string{ + 1: "File", 2: "Module", 3: "Namespace", 4: "Package", + 5: "Class", 6: "Method", 7: "Property", 8: "Field", + 9: "Constructor", 10: "Enum", 11: "Interface", 12: "Function", + 13: "Variable", 14: "Constant", 15: "String", 16: "Number", + 17: "Boolean", 18: "Array", 19: "Object", 20: "Key", + 21: "Null", 22: "EnumMember", 23: "Struct", 24: "Event", + 25: "Operator", 26: "TypeParameter", + } + if name, ok := kinds[k]; ok { + return name + } + return fmt.Sprintf("Kind(%d)", k) +} + +func severity(s int) string { + switch s { + case 1: + return "error" + case 2: + return "warning" + case 3: + return "info" + case 4: + return "hint" + default: + return "diagnostic" + } +}