Something went wrong. Try again.
An http client for the Helix editor
plugin curl http-client helix-editor steel-scheme http helix steel works-on-my-machine
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584#!/usr/bin/env steel
;; SPDX-License-Identifier: AGPL-3.0-or-later;; Copyright (C) 2025 Tom Waddington;;;; This file is part of http.hx.;;;; http.hx is free software: you can redistribute it and/or modify;; it under the terms of the GNU Affero General Public License as published by;; the Free Software Foundation, either version 3 of the License, or;; (at your option) any later version.;;;; http.hx is distributed in the hope that it will be useful,;; but WITHOUT ANY WARRANTY; without even the implied warranty of;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the;; GNU Affero General Public License for more details.;;;; You should have received a copy of the GNU Affero General Public License;; along with http.hx. If not, see <https://www.gnu.org/licenses/>.
;;; http.hx - HTTP client plugin for Helix editor;;;;;; This plugin executes HTTP requests from .http buffers using vscode-restclient;;; syntax. It converts requests to curl commands via http2curl.scm and displays;;; responses in a persistent *http* scratch buffer.
;; ============================================================================;; Dependencies;; ============================================================================
(require (prefix-in http2curl: "http2curl/http2curl.scm"))(require "run-command/run-command.scm")(require-builtin helix/core/text as text.)(require (prefix-in helix. "helix/commands.scm"))(require (prefix-in helix.static. "helix/static.scm"))(require "helix/editor.scm")(require "helix/misc.scm")
;; ============================================================================;; State Management;; ============================================================================
;; HTTP client state structure(struct http-state (buffer-id ; DocumentId - persistent *http* scratch buffer timeout-ms ; Number - curl execution timeout in milliseconds orientation ; Symbol - 'vsplit or 'hsplit include-headers? ; Boolean - include response headers in output request-count) ; Number - counter for request numbering #:transparent)
;; Global state (boxed for mutability)(define *http-state* (box #f))
;; Initialize default state(define (init-http-state!) "Initialize default http-state if not exists" (when (not (unbox *http-state*)) (set-box! *http-state* (http-state #f ; buffer-id 30000 ; timeout-ms (30 seconds) 'vsplit ; orientation #t ; include-headers? 0)))) ; request-count
;; Get current state (initializes if needed)(define (get-state) "Get current http-state, initializing if necessary" (init-http-state!) (unbox *http-state*))
;; Update state(define (set-state! new-state) "Update global http-state" (set-box! *http-state* new-state))
;; ============================================================================;; Buffer Management;; ============================================================================
;; Ensure *http* scratch buffer exists and is visible(define (http:ensure-buffer) "Create or get existing *http* scratch buffer Returns: updated state" (let* ([state (get-state)] [doc-id (http-state-buffer-id state)] [orientation (http-state-orientation state)])
;; Check if buffer exists and is visible (if (and doc-id (editor-doc-exists? doc-id) (editor-doc-in-view? doc-id)) ;; Buffer exists and visible - return current state state
;; Create new buffer (let* ([original-view (editor-focus)] ;; Split based on orientation [_ (if (equal? orientation 'vsplit) (helix.vsplit) (helix.hsplit))] ;; Create new buffer [_ (helix.new)] [new-view (editor-focus)] [new-doc-id (editor->doc-id new-view)] ;; Configure scratch buffer [_ (set-scratch-buffer-name! "*http*")] [_ (helix.set-language "markdown")] ;; Update state with new buffer-id [new-state (http-state new-doc-id (http-state-timeout-ms state) (http-state-orientation state) (http-state-include-headers? state) (http-state-request-count state))]) (set-state! new-state) ;; Return to original view (editor-set-focus! original-view) new-state))))
;; Append text to *http* buffer(define (http:append-to-buffer text) "Append text to *http* scratch buffer" (let* ([state (http:ensure-buffer)] [doc-id (http-state-buffer-id state)] [current-view (editor-focus)] [buffer-view (editor-doc-in-view? doc-id)])
;; Switch to *http* buffer if it's in view (when buffer-view (editor-set-focus! buffer-view)
;; Move to end and append text (helix.static.select_all) (helix.static.collapse_selection) (helix.static.insert_string text) (helix.static.align_view_bottom)
;; Return to original view (editor-set-focus! current-view))
state))
;; Clear *http* buffer contents(define (http:clear-buffer) "Clear *http* scratch buffer contents" (let* ([state (http:ensure-buffer)] [doc-id (http-state-buffer-id state)] [current-view (editor-focus)] [buffer-view (editor-doc-in-view? doc-id)])
;; Switch to *http* buffer if it's in view (when buffer-view (editor-set-focus! buffer-view) (helix.static.select_all) (helix.static.insert_string "") (editor-set-focus! current-view))
state))
;; ============================================================================;; Utility Functions;; ============================================================================
;; String utility: split string on first occurrence of delimiter(define (split-once str delimiter) "Split string on first occurrence of delimiter, return (cons left right) or #f" (let ([parts (split-many str delimiter)]) (if (>= (length parts) 2) (cons (car parts) (string-join (cdr parts) delimiter)) #f)))
;; String utility: check if string starts with prefix(define (starts-with? str prefix) "Check if string starts with prefix" (and (>= (string-length str) (string-length prefix)) (equal? (substring str 0 (string-length prefix)) prefix)))
;; Note: string-contains? is a built-in Steel function, no need to define it
;; ============================================================================;; Variable Extraction;; ============================================================================
;; Extract @variable = value declarations from buffer(define (http:extract-file-variables buffer-text) "Extract @variable = value declarations from buffer text Returns: association list '((\"var1\" . \"value1\") (\"var2\" . \"value2\"))" (let* ([lines (split-many buffer-text "\n")] [var-pattern (lambda (line) (let ([trimmed (trim line)]) (if (starts-with? trimmed "@") (let* ([without-at (substring trimmed 1 (string-length trimmed))] [parts (split-once without-at "=")]) (if parts (cons (trim (car parts)) (trim (cdr parts))) #f)) #f)))]) (filter (lambda (x) x) (map var-pattern lines))))
;; ============================================================================;; Selection Handling;; ============================================================================
;; Get text of primary selection(define (http:get-primary-selection) "Get text of primary selection" (helix.static.current-highlighted-text!))
;; Get list of text from all selections(define (http:get-all-selections) "Get list of text from all selections, return (listof string)" (let* ([doc-id (editor->doc-id (editor-focus))] [rope (editor->text doc-id)] [full-text (text.rope->string rope)] [selection-obj (helix.static.current-selection-object)] [ranges (helix.static.selection->ranges selection-obj)]) (map (lambda (range) (let* ([from (helix.static.range->from range)] [to (helix.static.range->to range)] [slice (text.rope->slice rope from to)]) (text.rope->string slice))) ranges)))
;; Get entire buffer text(define (http:get-buffer-text) "Get entire buffer text" (let* ([doc-id (editor->doc-id (editor-focus))] [rope (editor->text doc-id)]) (text.rope->string rope)))
;; ============================================================================;; Curl Execution;; ============================================================================
;; Execute curl command with timeout using the run-command library(define (http:run-curl-with-timeout curl-cmd-str timeout-ms) "Execute curl command string with timeout via the run-command library. curl-cmd-str: string - complete curl command (e.g., 'curl -i https://...') timeout-ms: number - timeout in milliseconds Returns: Result<String> - Ok with stdout or Err with error message" (with-handler (lambda (err) (Err (string-append "Curl execution failed: " (to-string err)))) ;; run-command returns a result hash {stdout, stderr, exit, ok, timed-out}; ;; the response parser only consumes curl's stdout. Map to the prior ;; Result<String> contract: timeouts, spawn failures, and non-zero curl ;; exits (connection refused, DNS failure) all surface as Err so a failed ;; request is never mistaken for an empty body. (let ([result (run-command curl-cmd-str (hash 'timeout-ms timeout-ms))]) (cond [(hash-ref result 'timed-out) (Err (string-append "Curl timed out after " (to-string timeout-ms) "ms"))] [(hash-ref result 'ok) (Ok (hash-ref result 'stdout))] [else (Err (string-append "Curl failed (exit " (to-string (hash-ref result 'exit)) "): " (trim (hash-ref result 'stderr))))]))))
;; Execute curl command(define (http:execute-curl-command curl-cmd-str state) "Execute curl command string with configured timeout curl-cmd-str: string - curl command state: http-state - current state (for timeout) Returns: Result<String> - Ok with output or Err with error message" (let ([timeout-ms (http-state-timeout-ms state)]) (http:run-curl-with-timeout curl-cmd-str timeout-ms)))
;; ============================================================================;; Response Parsing and Formatting;; ============================================================================
;; Extract Content-Type from response headers(define (http:extract-content-type headers) "Extract Content-Type from response headers" (let* ([lines (split-many headers "\n")] [ct-line (filter (lambda (line) (starts-with? (string-downcase (trim line)) "content-type:")) lines)]) (if (not (null? ct-line)) (let* ([line (car ct-line)] [parts (split-once line ":")] [value (if parts (trim (cdr parts)) "")]) ;; Extract just the media type, ignore charset (let ([semicolon-parts (split-many value ";")]) (trim (car semicolon-parts)))) "text/plain")))
;; Map Content-Type to code block language(define (http:content-type->lang content-type) "Map Content-Type to code block language for syntax highlighting" (let ([ct (string-downcase content-type)]) (cond [(string-contains? ct "json") "json"] [(string-contains? ct "xml") "xml"] [(string-contains? ct "html") "html"] [(string-contains? ct "javascript") "javascript"] [(string-contains? ct "css") "css"] [(string-contains? ct "yaml") "yaml"] [else "text"])))
;; Parse curl output into headers and body(define (http:parse-curl-output output include-headers?) "Parse curl output into headers and body Returns: hash with 'headers, 'body, and 'lang keys" (if include-headers? ;; Parse headers and body (separated by \r\n\r\n) (let* ([separator "\r\n\r\n"] [parts (split-once output separator)]) (if parts (let* ([headers (car parts)] [body (cdr parts)] [content-type (http:extract-content-type headers)] [lang (http:content-type->lang content-type)]) (hash 'headers headers 'body body 'lang lang)) ;; No separator found - treat all as body (hash 'headers "" 'body output 'lang "text")))
;; No headers requested - all is body (hash 'headers #f 'body output 'lang "text")))
;; Format response for display in Markdown(define (http:format-response request-num curl-cmd parsed-response) "Format response for display in Markdown buffer request-num: Number - request counter curl-cmd: String - the curl command that was executed parsed-response: Hash - result from http:parse-curl-output Returns: String - formatted Markdown" (let* ([headers (hash-ref parsed-response 'headers)] [body (hash-ref parsed-response 'body)] [lang (hash-ref parsed-response 'lang)] [separator "---\n\n"])
(string-append "## Request " (number->string request-num) "\n\n```sh\n" curl-cmd "\n```\n\n" "### Response\n\n" (if (and headers (not (equal? headers ""))) (string-append "**Headers:**\n```http\n" headers "\n```\n\n**Body:**\n") "") "```" lang "\n" body "\n```\n\n" separator)))
;; ============================================================================;; Configuration Commands;; ============================================================================
;;@doc;; Set timeout for curl execution(define (http-set-timeout . args) "Set or show timeout for HTTP request execution Usage: :http-set-timeout [seconds] Without args: shows current timeout With args: sets timeout to specified seconds" (let ([state (get-state)]) (if (null? args) ;; Show current timeout (helix.echo (string-append "Current timeout: " (number->string (/ (http-state-timeout-ms state) 1000)) " seconds")) ;; Set new timeout (let* ([input (car args)] [seconds (string->number input)]) (if seconds (let ([timeout-ms (* seconds 1000)]) (set-state! (http-state (http-state-buffer-id state) timeout-ms (http-state-orientation state) (http-state-include-headers? state) (http-state-request-count state))) (helix.echo (string-append "Timeout set to " (number->string seconds) " seconds"))) (helix.echo "Error: Invalid number"))))))
;;@doc;; Set scratch buffer orientation(define (http-set-orientation . args) "Set or show scratch buffer split orientation Usage: :http-set-orientation [vsplit|hsplit|v|h|vertical|horizontal] Without args: shows current orientation With args: sets orientation" (let ([state (get-state)]) (if (null? args) ;; Show current orientation (helix.echo (string-append "Current orientation: " (symbol->string (http-state-orientation state)))) ;; Set new orientation (let* ([arg (car args)] [orientation (cond [(or (equal? arg "vsplit") (equal? arg "v") (equal? arg "vertical")) 'vsplit] [(or (equal? arg "hsplit") (equal? arg "h") (equal? arg "horizontal")) 'hsplit] [else #f])]) (if orientation (begin (set-state! (http-state (http-state-buffer-id state) (http-state-timeout-ms state) orientation (http-state-include-headers? state) (http-state-request-count state))) (helix.echo (string-append "Orientation set to " (symbol->string orientation)))) (helix.echo "Error: Use vsplit/v/vertical or hsplit/h/horizontal"))))))
;;@doc;; Toggle response header inclusion(define (http-toggle-headers) "Toggle inclusion of response headers in output Usage: :http-toggle-headers" (let* ([state (get-state)] [current (http-state-include-headers? state)] [new-val (not current)]) (set-state! (http-state (http-state-buffer-id state) (http-state-timeout-ms state) (http-state-orientation state) new-val (http-state-request-count state))) (helix.echo (string-append "Response headers: " (if new-val "enabled" "disabled")))))
;; ============================================================================;; Main Execution Flow;; ============================================================================
;; Execute HTTP requests(define (http:execute-requests selections variables) "Main execution function selections: (listof string) - HTTP request texts variables: (listof (cons string string)) - variable bindings Returns: Result<String> - Ok with formatted output or Err with error message" (with-handler (lambda (err) (Err (string-append "Execution error: " (to-string err)))) (let* ([state (get-state)] [include-headers? (http-state-include-headers? state)]
;; Convert to curl commands via http2curl [curl-cmds (http2curl:http->curl selections variables #:include-headers? include-headers?)]
;; Execute each curl command [results (map (lambda (curl-cmd) (http:execute-curl-command curl-cmd state)) curl-cmds)]
;; Check for errors [errors (filter Err? results)])
(if (not (null? errors)) ;; Return first error (car errors)
;; Format all responses (let* ([outputs (map Ok->value results)] [parsed (map (lambda (output) (http:parse-curl-output output include-headers?)) outputs)] [formatted (map (lambda (idx) (let ([request-num (+ (http-state-request-count state) idx 1)] [curl-cmd (list-ref curl-cmds idx)] [response (list-ref parsed idx)]) (http:format-response request-num curl-cmd response))) (range 0 (length parsed)))] [combined (apply string-append formatted)]
;; Update request counter [new-state (http-state (http-state-buffer-id state) (http-state-timeout-ms state) (http-state-orientation state) (http-state-include-headers? state) (+ (http-state-request-count state) (length curl-cmds)))])
(set-state! new-state) (Ok combined))))))
;; ============================================================================;; Validation;; ============================================================================
;; Validate selection is not empty(define (http:validate-selection text) "Validate selection is not empty Returns: Result<String> - Ok with text or Err with error message" (if (or (not text) (equal? (trim text) "")) (Err "Selection is empty") (Ok text)))
;; Validate all selections(define (http:validate-selections selections) "Validate all selections are not empty Returns: Result<(listof string)> - Ok with selections or Err with error message" (let* ([results (map http:validate-selection selections)] [errors (filter Err? results)]) (if (not (null? errors)) (Err "One or more selections are empty") (Ok selections))))
;; ============================================================================;; User Commands;; ============================================================================
;; Safe command wrapper(define (safe-command command-fn) "Wrap command function with error handler" (lambda args (with-handler (lambda (err) (helix.echo (string-append "Error: " (to-string err)))) (apply command-fn args))))
;; Execute HTTP request in primary selection(define (http-exec-selection-impl) "Execute HTTP request in primary selection (internal)" (let* ([selection-text (http:get-primary-selection)] [validation (http:validate-selection selection-text)])
(if (Err? validation) (helix.echo (Err->value validation)) (let* ([buffer-text (http:get-buffer-text)] [variables (http:extract-file-variables buffer-text)] [result (http:execute-requests (list selection-text) variables)])
(if (Ok? result) (let ([output (Ok->value result)]) (http:append-to-buffer output) (helix.echo "Request executed successfully")) (helix.echo (Err->value result)))))))
;; Execute HTTP requests in all selections(define (http-exec-multiple-selections-impl) "Execute HTTP requests in all selections (internal)" (let* ([selections (http:get-all-selections)] [validation (http:validate-selections selections)])
(if (Err? validation) (helix.echo (Err->value validation)) (let* ([buffer-text (http:get-buffer-text)] [variables (http:extract-file-variables buffer-text)] [result (http:execute-requests selections variables)])
(if (Ok? result) (let ([output (Ok->value result)]) (http:append-to-buffer output) (helix.echo (string-append (number->string (length selections)) " requests executed successfully"))) (helix.echo (Err->value result)))))))
;; Execute all HTTP requests in buffer(define (http-exec-buffer-impl) "Execute all HTTP requests in buffer (internal)" (let* ([buffer-text (http:get-buffer-text)] [validation (http:validate-selection buffer-text)])
(if (Err? validation) (helix.echo "Buffer is empty") (let* ([variables (http:extract-file-variables buffer-text)] [result (http:execute-requests (list buffer-text) variables)])
(if (Ok? result) (let ([output (Ok->value result)]) (http:append-to-buffer output) (helix.echo "Buffer executed successfully")) (helix.echo (Err->value result)))))))
;; Wrap commands with error handling
;;@doc;; Execute HTTP request in primary selection(define http-exec-selection (safe-command http-exec-selection-impl))
;;@doc;; Execute HTTP requests in all selections(define http-exec-multiple-selections (safe-command http-exec-multiple-selections-impl))
;;@doc;; Execute all HTTP requests in buffer(define http-exec-buffer (safe-command http-exec-buffer-impl))
;; ============================================================================;; Command Registration;; ============================================================================
;; Register commands with Helix(provide http-exec-selection http-exec-multiple-selections http-exec-buffer http-set-timeout http-set-orientation http-toggle-headers)