#!/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 .
;;; 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 - 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 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 - 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 - 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 - 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)