Skip to content

Instantly share code, notes, and snippets.

@d12frosted
Created January 15, 2026 14:19
Show Gist options
  • Select an option

  • Save d12frosted/51edb747167c0fa8fcce40fed3338ab2 to your computer and use it in GitHub Desktop.

Select an option

Save d12frosted/51edb747167c0fa8fcce40fed3338ab2 to your computer and use it in GitHub Desktop.
json-schema-form.el reimplemented with vui.el (60% less code)

json-schema-form: vui.el vs raw widget.el

This is a reimplementation of whacked's json-schema-form.el gist using vui.el.

Line Count Comparison

Metric Gist (raw widget.el) VUI-based
Total lines 1,127 456
Reduction 60%
Tests 0 41 specs

What was ELIMINATED (vui.el handles it automatically)

Gist Section Lines Purpose
Widget highlighting ~35 Overlay management for invalid fields
Inline extension ~330 The entire multi-form infrastructure

The inline extension included:

  • jsf--inline-forms — Buffer-local hash-table registry
  • Marker tracking — jsf--safe-marker-pos, start/end markers
  • Resync/GC — jsf-inline-resync, jsf--after-change-inline-resync
  • widget-before-change advice — Scoped form protection
  • post-command-hook — Property restoration after widget redraws
  • jsf-inline-destroy etc — Manual cleanup functions

What REMAINS (domain logic)

Section Gist VUI Notes
Schema utilities ~75 ~25 Simpler, no path tracking needed
Validation ~95 ~80 Nearly identical
Schema→widget ~270 ~120 Components vs widget-specs
Data extraction ~125 ~25 vui state handles most of it
Renderer/Demo ~105 ~50 vui-mount vs manual buffer setup

Key Insight

The gist's inline extension (lines 800-1127) is ~330 lines of infrastructure to handle:

  • Multiple forms per buffer
  • State isolation between forms
  • Cleanup when forms are destroyed
  • Orphaned widget detection

With vui.el: zero lines — it's all automatic via component instances.

Usage

cd json-schema-form
eldev test              # Run 41 specs
eldev emacs -l demo.el  # Launch interactive demo

Files

  • Eldev — Build configuration with vui.el dependency
  • json-schema-form.el — The implementation (456 lines)
  • test/json-schema-form-test.el — Buttercup tests (41 specs)
image

| Reduction | — | 60% |

;;; demo.el --- Launch json-schema-form demo -*- lexical-binding: t; -*-
(require 'json-schema-form)
(jsf-demo)
; -*- mode: emacs-lisp; lexical-binding: t -*-
;; Package archives
(eldev-use-package-archive 'gnu)
(eldev-use-package-archive 'melpa)
;; Use vui.el from GitHub
(eldev-use-vc-repository 'vui
:github "d12frosted/vui.el")
;; Dependencies
(eldev-add-extra-dependencies 'runtime 'vui)
(eldev-add-extra-dependencies 'test 'buttercup)
;; Use buttercup for testing
(setf eldev-test-framework 'buttercup)
;;; json-schema-form-test.el --- Tests for json-schema-form -*- lexical-binding: t; -*-
;;; Commentary:
;; Buttercup tests for json-schema-form.el
;;; Code:
(require 'buttercup)
(require 'json-schema-form)
;;; Schema Utilities
(describe "jsf--get"
(it "returns value for existing key"
(expect (jsf--get '((:type . "string") (:title . "Name")) :type)
:to-equal "string"))
(it "returns nil for missing key"
(expect (jsf--get '((:type . "string")) :title)
:to-be nil))
(it "returns default for missing key"
(expect (jsf--get '((:type . "string")) :title "Default")
:to-equal "Default")))
(describe "jsf--schema-type"
(it "returns explicit type"
(expect (jsf--schema-type '((:type . "integer")))
:to-equal "integer"))
(it "infers string from enum"
(expect (jsf--schema-type '((:enum . ("a" "b" "c"))))
:to-equal "string"))
(it "infers object from properties"
(expect (jsf--schema-type '((:properties . (("name" . nil)))))
:to-equal "object"))
(it "infers array from items"
(expect (jsf--schema-type '((:items . ((:type . "string")))))
:to-equal "array"))
(it "defaults to string"
(expect (jsf--schema-type '())
:to-equal "string")))
(describe "jsf--schema-title"
(it "returns title when present"
(expect (jsf--schema-title '((:title . "Full Name")) nil)
:to-equal "Full Name"))
(it "falls back to description"
(expect (jsf--schema-title '((:description . "Enter your name")) nil)
:to-equal "Enter your name"))
(it "falls back to last path element"
(expect (jsf--schema-title '() '("user" "name"))
:to-equal "name"))
(it "defaults to field"
(expect (jsf--schema-title '() nil)
:to-equal "field")))
;;; Number Parsing
(describe "jsf--parse-number"
(it "parses integers"
(expect (jsf--parse-number "42")
:to-equal '(t . 42)))
(it "parses floats"
(expect (jsf--parse-number "3.14")
:to-equal '(t . 3.14)))
(it "handles whitespace"
(expect (jsf--parse-number " 42 ")
:to-equal '(t . 42)))
(it "returns nil for empty string"
(expect (jsf--parse-number "")
:to-equal '(nil)))
(it "returns nil for non-numbers"
(expect (jsf--parse-number "abc")
:to-equal '(nil))))
(describe "jsf--parse-integer"
(it "parses integers"
(expect (jsf--parse-integer "42")
:to-equal '(t . 42)))
(it "rejects floats"
(expect (jsf--parse-integer "3.14")
:to-equal '(nil)))
(it "handles negative integers"
(expect (jsf--parse-integer "-5")
:to-equal '(t . -5))))
;;; Validation
(describe "jsf--validate-string"
(it "passes valid string"
(expect (jsf--validate-string "hello" '())
:to-be nil))
(it "validates minLength"
(expect (jsf--validate-string "hi" '((:minLength . 5)))
:to-match "Minimum length"))
(it "validates maxLength"
(expect (jsf--validate-string "hello world" '((:maxLength . 5)))
:to-match "Maximum length"))
(it "validates pattern"
(expect (jsf--validate-string "invalid" '((:pattern . "^[0-9]+$")))
:to-match "pattern")))
(describe "jsf--validate-number"
(it "passes valid number"
(expect (jsf--validate-number 50 '((:minimum . 0) (:maximum . 100)))
:to-be nil))
(it "validates minimum"
(expect (jsf--validate-number -5 '((:minimum . 0)))
:to-match ">= 0"))
(it "validates maximum"
(expect (jsf--validate-number 150 '((:maximum . 100)))
:to-match "<= 100"))
(it "validates exclusiveMinimum"
(expect (jsf--validate-number 0 '((:exclusiveMinimum . 0)))
:to-match "> 0"))
(it "validates exclusiveMaximum"
(expect (jsf--validate-number 100 '((:exclusiveMaximum . 100)))
:to-match "< 100")))
(describe "jsf--validate-field"
(it "validates required empty string"
(expect (jsf--validate-field "" '((:type . "string")) t)
:to-equal "Required"))
(it "validates required nil"
(expect (jsf--validate-field nil '((:type . "string")) t)
:to-equal "Required"))
(it "passes non-required empty"
(expect (jsf--validate-field "" '((:type . "string")) nil)
:to-be nil))
(it "validates integer type"
(expect (jsf--validate-field "abc" '((:type . "integer")) nil)
:to-match "integer"))
(it "validates number type"
(expect (jsf--validate-field "abc" '((:type . "number")) nil)
:to-match "number")))
(describe "jsf--validate-schema"
(let ((schema '((:type . "object")
(:required . ("name"))
(:properties
. (("name" . ((:type . "string") (:minLength . 1)))
("age" . ((:type . "integer") (:minimum . 0))))))))
(it "returns nil for valid data"
(expect (jsf--validate-schema schema '(:name "John" :age "25"))
:to-be nil))
(it "returns errors for invalid data"
(let ((errors (jsf--validate-schema schema '(:name "" :age "-5"))))
(expect (assoc "name" errors) :to-be-truthy)
(expect (assoc "age" errors) :to-be-truthy)))))
;;; Value Conversion
(describe "jsf--convert-value"
(it "converts integers"
(expect (jsf--convert-value "42" '((:type . "integer")))
:to-equal 42))
(it "converts numbers"
(expect (jsf--convert-value "3.14" '((:type . "number")))
:to-equal 3.14))
(it "converts booleans true"
(expect (jsf--convert-value t '((:type . "boolean")))
:to-equal t))
(it "converts booleans false"
(expect (jsf--convert-value nil '((:type . "boolean")))
:to-equal :false))
(it "trims strings"
(expect (jsf--convert-value " hello " '((:type . "string")))
:to-equal "hello")))
;;; json-schema-form-test.el ends here
;;; json-schema-form.el --- JSON Schema forms using vui.el -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Boris Buliga
;; SPDX-License-Identifier: GPL-3.0-or-later
;; Author: Boris Buliga <boris@d12frosted.io>
;; URL: https://github.com/d12frosted/json-schema-form
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1") (vui "1.0.0"))
;; Keywords: ui, forms, json
;;; Commentary:
;; Generate Emacs forms from JSON Schema using vui.el.
;;
;; This package provides a declarative way to render JSON Schema as
;; interactive forms in Emacs buffers. Built on vui.el, it handles
;; state management, validation, and lifecycle automatically.
;;
;; Usage:
;; (jsf-render schema :on-submit (lambda (data) (message "%S" data)))
;;
;; Or mount inline in any buffer:
;; (jsf-insert schema :on-submit callback)
;;; Code:
(require 'vui)
(require 'json)
(require 'cl-lib)
;;; Custom Variables
(defgroup json-schema-form nil
"JSON Schema forms using vui.el."
:group 'vui
:prefix "jsf-")
(defcustom jsf-field-width 40
"Default width for text input fields."
:type 'integer
:group 'json-schema-form)
(defface jsf-error-face
'((t :inherit error))
"Face for validation error messages."
:group 'json-schema-form)
(defface jsf-label-face
'((t :inherit bold))
"Face for field labels."
:group 'json-schema-form)
(defface jsf-required-face
'((t :inherit font-lock-warning-face))
"Face for required field indicator."
:group 'json-schema-form)
;;; Schema Utilities
(defun jsf--get (schema key &optional default)
"Get KEY from SCHEMA alist, or DEFAULT."
(if-let* ((cell (assq key schema)))
(cdr cell)
default))
(defun jsf--schema-type (schema)
"Return the type of SCHEMA, inferring if necessary."
(let ((type (jsf--get schema :type)))
(cond
((stringp type) type)
((jsf--get schema :enum) "string")
((jsf--get schema :properties) "object")
((jsf--get schema :items) "array")
(t "string"))))
(defun jsf--schema-title (schema &optional path)
"Get display title for SCHEMA, using PATH as fallback."
(or (jsf--get schema :title)
(jsf--get schema :description)
(and path (car (last path)))
"field"))
(defun jsf--required-p (prop-name required-list)
"Return non-nil if PROP-NAME is in REQUIRED-LIST."
(member prop-name required-list))
;;; Validation
(defun jsf--validate-string (value schema)
"Validate string VALUE against SCHEMA. Return error message or nil."
(let ((minlen (jsf--get schema :minLength))
(maxlen (jsf--get schema :maxLength))
(pattern (jsf--get schema :pattern))
(len (length (or value ""))))
(cond
((and minlen (< len minlen))
(format "Minimum length is %d" minlen))
((and maxlen (> len maxlen))
(format "Maximum length is %d" maxlen))
((and pattern (not (string-match-p pattern (or value ""))))
"Does not match required pattern"))))
(defun jsf--validate-number (value schema)
"Validate number VALUE against SCHEMA. Return error message or nil."
(when (numberp value)
(let ((min (jsf--get schema :minimum))
(max (jsf--get schema :maximum))
(emin (jsf--get schema :exclusiveMinimum))
(emax (jsf--get schema :exclusiveMaximum)))
(cond
((and min (< value min))
(format "Must be >= %s" min))
((and max (> value max))
(format "Must be <= %s" max))
((and emin (<= value emin))
(format "Must be > %s" emin))
((and emax (>= value emax))
(format "Must be < %s" emax))))))
(defun jsf--parse-number (s)
"Parse S as a number. Return (ok . value) cons."
(let ((str (string-trim (or s ""))))
(if (string-empty-p str)
(cons nil nil)
(condition-case nil
(let ((val (car (read-from-string str))))
(if (numberp val)
(cons t val)
(cons nil nil)))
(error (cons nil nil))))))
(defun jsf--parse-integer (s)
"Parse S as an integer. Return (ok . value) cons."
(let ((result (jsf--parse-number s)))
(if (and (car result) (integerp (cdr result)))
result
(cons nil nil))))
(defun jsf--validate-field (value schema required)
"Validate VALUE against SCHEMA. REQUIRED if non-nil.
Return error message or nil."
(let ((type (jsf--schema-type schema)))
(cond
;; Required check
((and required
(or (null value)
(and (stringp value) (string-empty-p (string-trim value)))))
"Required")
;; Type-specific validation
((string= type "string")
(jsf--validate-string value schema))
((string= type "integer")
(let ((parsed (jsf--parse-integer value)))
(cond
((and (not (string-empty-p (or value ""))) (not (car parsed)))
"Must be an integer")
((car parsed)
(jsf--validate-number (cdr parsed) schema)))))
((string= type "number")
(let ((parsed (jsf--parse-number value)))
(cond
((and (not (string-empty-p (or value ""))) (not (car parsed)))
"Must be a number")
((car parsed)
(jsf--validate-number (cdr parsed) schema))))))))
(defun jsf--validate-schema (schema values)
"Validate VALUES against SCHEMA. Return alist of (path . error) or nil."
(let ((errors nil))
(when (string= (jsf--schema-type schema) "object")
(let ((props (jsf--get schema :properties))
(required (jsf--get schema :required)))
(dolist (prop props)
(let* ((name (car prop))
(subschema (cdr prop))
(value (plist-get values (intern (concat ":" name))))
(req (jsf--required-p name required))
(err (jsf--validate-field value subschema req)))
(when err
(push (cons name err) errors))))))
(nreverse errors)))
;;; Value Conversion
(defun jsf--convert-value (value schema)
"Convert VALUE according to SCHEMA type for output."
(let ((type (jsf--schema-type schema)))
(pcase type
("integer" (cdr (jsf--parse-integer value)))
("number" (cdr (jsf--parse-number value)))
("boolean" (if value t :false))
("string" (string-trim (or value "")))
(_ value))))
(defun jsf--gather-values (schema values)
"Convert VALUES to proper types according to SCHEMA for JSON output."
(let ((result nil))
(when (string= (jsf--schema-type schema) "object")
(let ((props (jsf--get schema :properties)))
(dolist (prop props)
(let* ((name (car prop))
(subschema (cdr prop))
(key (intern (concat ":" name)))
(value (plist-get values key))
(converted (jsf--convert-value value subschema)))
(push converted result)
(push key result)))))
result))
;;; Components
(vui-defcomponent jsf-field-error (error)
"Display validation error message."
:render
(when error
(vui-text error :face 'jsf-error-face)))
(vui-defcomponent jsf-string-field (name schema value required on-change)
"Render a string field with optional enum support."
:render
(let ((enum (jsf--get schema :enum)))
(if enum
;; Dropdown for enum
(vui-select
:value (or value (car enum))
:options (mapcar (lambda (opt) (cons opt opt)) enum)
:on-change on-change)
;; Regular text field
(vui-field
:value (or value "")
:size jsf-field-width
:on-change on-change))))
(vui-defcomponent jsf-number-field (name schema value required on-change)
"Render a number/integer field."
:render
(vui-field
:value (if value (format "%s" value) "")
:size 15
:on-change on-change))
(vui-defcomponent jsf-boolean-field (name schema value on-change)
"Render a boolean checkbox."
:render
(vui-checkbox
:checked value
:on-change on-change))
(vui-defcomponent jsf-array-field (name schema value on-change)
"Render an array field with add/remove support."
:state ((items (or value [])))
:render
(let* ((add-item (lambda ()
(let ((new-items (vconcat items [""])))
(vui-set-state :items new-items)
(funcall on-change new-items))))
(remove-item (lambda (idx)
(let ((new-items (vconcat
(seq-into (seq-subseq items 0 idx) 'list)
(seq-into (seq-subseq items (1+ idx)) 'list))))
(vui-set-state :items new-items)
(funcall on-change new-items))))
(update-item (lambda (idx val)
(let ((new-items (copy-sequence items)))
(aset new-items idx val)
(vui-set-state :items new-items)
(funcall on-change new-items)))))
(vui-vstack
;; Existing items
(when (> (length items) 0)
(apply #'vui-fragment
(seq-into
(seq-map-indexed
(lambda (item idx)
(vui-hstack
:key idx
(vui-field
:value (or item "")
:size 30
:on-change (lambda (v) (funcall update-item idx v)))
(vui-space 1)
(vui-button "×"
:on-click (lambda () (funcall remove-item idx)))))
items)
'list)))
;; Add button
(vui-button "+ Add"
:on-click add-item))))
(defcustom jsf-label-width 12
"Width for field labels to ensure alignment."
:type 'integer
:group 'json-schema-form)
(vui-defcomponent jsf-property (name schema value required error on-change)
"Render a single property with label and field."
:render
(let* ((title (jsf--schema-title schema (list name)))
(type (jsf--schema-type schema))
(label (vui-box
(vui-hstack
(vui-text (format "%s:" title) :face 'jsf-label-face)
(if required
(vui-text " *" :face 'jsf-required-face)
(vui-text "")))
:width jsf-label-width))
(field (pcase type
("boolean"
(vui-component 'jsf-boolean-field
:name name :schema schema :value value :on-change on-change))
((or "integer" "number")
(vui-component 'jsf-number-field
:name name :schema schema :value value :required required
:on-change on-change))
("array"
(vui-component 'jsf-array-field
:name name :schema schema :value value :on-change on-change))
(_
(vui-component 'jsf-string-field
:name name :schema schema :value value :required required
:on-change on-change)))))
;; Arrays get label on separate line, others inline
(if (string= type "array")
(if error
(vui-vstack
label
(vui-box field :padding-left 2)
(vui-text error :face 'jsf-error-face))
(vui-vstack
label
(vui-box field :padding-left 2)))
(if error
(vui-vstack
(vui-hstack label field)
(vui-text error :face 'jsf-error-face))
(vui-hstack label field)))))
(vui-defcomponent jsf-form (schema on-submit on-cancel)
"Main form component that renders a JSON schema."
:state ((values nil)
(errors nil)
(submitted nil))
:render
(let* ((title (jsf--get schema :title "Form"))
(props (jsf--get schema :properties))
(required (jsf--get schema :required))
(prop-names (mapcar #'car props))
(get-value (lambda (name)
(plist-get values (intern (concat ":" name)))))
(set-value (lambda (name val)
(let ((key (intern (concat ":" name))))
(vui-set-state :values (plist-put (copy-sequence values) key val)))))
(get-error (lambda (name)
(cdr (assoc name errors))))
(do-submit (lambda ()
(let ((errs (jsf--validate-schema schema values)))
(if errs
(vui-set-state :errors errs)
(vui-batch
(vui-set-state :errors nil)
(vui-set-state :submitted t))
(when on-submit
(funcall on-submit (jsf--gather-values schema values)))))))
(property-fields
(mapcar
(lambda (name)
(let ((subschema (cdr (assoc name props))))
(vui-component 'jsf-property
:key name
:name name
:schema subschema
:value (funcall get-value name)
:required (jsf--required-p name required)
:error (funcall get-error name)
:on-change (lambda (v) (funcall set-value name v)))))
prop-names)))
(if submitted
;; Success state
(vui-vstack
(vui-text "Form submitted successfully!" :face 'success)
(vui-newline)
(vui-button "Submit Another"
:on-click (lambda ()
(vui-batch
(vui-set-state :values nil)
(vui-set-state :errors nil)
(vui-set-state :submitted nil)))))
;; Form
(vui-vstack
:spacing 1
(vui-text title :face 'bold)
(vui-text (make-string (min 40 (length title)) ?─))
(apply #'vui-vstack property-fields)
(vui-hstack
:spacing 2
(when on-cancel
(vui-button "Cancel" :on-click on-cancel))
(vui-button "Submit" :on-click do-submit))))))
;;; Public API
;;;###autoload
(defun jsf-render (schema &rest args)
"Render SCHEMA as a form in a new buffer.
ARGS is a plist with:
:on-submit - callback receiving the form data
:on-cancel - callback when cancelled
:buffer-name - name for the buffer (default \"*JSON Schema Form*\")"
(let ((on-submit (plist-get args :on-submit))
(on-cancel (plist-get args :on-cancel))
(buffer-name (or (plist-get args :buffer-name) "*JSON Schema Form*")))
(vui-mount
(vui-component 'jsf-form
:schema schema
:on-submit on-submit
:on-cancel (or on-cancel
(lambda () (kill-buffer (current-buffer)))))
buffer-name)))
;;;###autoload
(defun jsf-demo ()
"Show a demo form."
(interactive)
(jsf-render
'((:type . "object")
(:title . "Demo Form")
(:required . ("name" "age"))
(:properties
. (("name" . ((:type . "string")
(:title . "Name")
(:minLength . 1)
(:maxLength . 30)))
("age" . ((:type . "integer")
(:title . "Age")
(:minimum . 0)
(:maximum . 130)))
("email" . ((:type . "string")
(:title . "Email")
(:pattern . "^[^@]+@[^@]+\\.[^@]+$")))
("role" . ((:type . "string")
(:title . "Role")
(:enum . ("user" "admin" "operator"))))
("enabled" . ((:type . "boolean")
(:title . "Enabled")))
("tags" . ((:type . "array")
(:title . "Tags")
(:items . ((:type . "string"))))))))
:on-submit (lambda (data)
(message "Submitted: %S" data))))
(provide 'json-schema-form)
;;; json-schema-form.el ends here
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment