Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "40ants-lisp-dev-mcp",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/egao1980/features/roswell:1": {
"version": "latest",
"installLisp": "sbcl-bin",
"useLisp": "sbcl-bin",
"installTools": true,
"toolsToInstall": "qlot",
"installUltralisp": true
}
},
"customizations": {
"vscode": {
"extensions": [
"rheller.alive"
],
"tasks": {
"version": "2.0.0",
"tasks": [
{
"label": "Run tests",
"type": "shell",
"command": "qlot exec ros run --eval '(push (truename \".\") asdf:*central-registry*)' --eval '(asdf:test-system \"40ants-lisp-dev-mcp\")' --quit",
"group": "test",
"presentation": { "reveal": "always", "panel": "shared" }
}
]
}
}
},
"postCreateCommand": "cd /workspaces/lisp-dev-mcp && qlot install"
}
3 changes: 2 additions & 1 deletion 40ants-lisp-dev-mcp-tests.asd
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
:source-control (:git "https://github.com/40ants/lisp-dev-mcp")
:bug-tracker "https://github.com/40ants/lisp-dev-mcp/issues"
:pathname "t"
:depends-on ("40ants-lisp-dev-mcp-tests/core")
:depends-on ("40ants-lisp-dev-mcp-tests/core"
"40ants-lisp-dev-mcp-tests/paredit")
:perform (test-op (op c)
(unless (symbol-call :rove :run c)
(error "Tests failed"))))
11 changes: 10 additions & 1 deletion 40ants-lisp-dev-mcp.asd
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,18 @@
"openrpc-server"
"jsonrpc/errors"
"bordeaux-threads"
"40ants-lisp-dev-mcp/core")
"eclector"
"cl-ppcre"
"yason"
"40ants-lisp-dev-mcp/core"
"40ants-lisp-dev-mcp/paredit-tools")
:in-order-to ((test-op (test-op "40ants-lisp-dev-mcp-tests"))))


(asdf:register-system-packages "log4cl" '("LOG"))
(asdf:register-system-packages "bordeaux-threads" '("BORDEAUX-THREADS-2"))
(asdf:register-system-packages "eclector" '("ECLECTOR.PARSE-RESULT"
"ECLECTOR.BASE"
"ECLECTOR.READER"
"ECLECTOR.READTABLE"))
(asdf:register-system-packages "yason" '("YASON"))
13 changes: 12 additions & 1 deletion docs/changelog.lisp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@
(defchangelog (:ignore-words ("SLY"
"ASDF"
"REPL"
"HTTP"))
"HTTP"
"CST"
"AST"
"JSON"
"MCP"
"LLM"))
(0.2.0 2026-03-06
"* Added paredit-style structural editing tools (wrap, unwrap, raise, slurp, barf, kill, transpose, split, join).
* Added sexp-replace, sexp-insert-before, sexp-insert-after for code replacement and insertion.
* Added sexp-show-structure and sexp-get-enclosing for code inspection.
* Added sexp-to-json-ast for CST-based JSON AST output.
* Added Eclector-based CST parser with source position tracking.")
(0.1.0 2026-01-25
"* Initial version."))
5 changes: 4 additions & 1 deletion docs/index.lisp
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@
"URL"
"URI"
"RPC"
"GIT"))
"GIT"
"CST"
"AST"
"S-expression"))
(40ants-lisp-dev-mcp system)
"
[![](https://github-actions.40ants.com/40ants/lisp-dev-mcp/matrix.svg?only=ci.run-tests)](https://github.com/40ants/lisp-dev-mcp/actions)
Expand Down
3 changes: 2 additions & 1 deletion src/core.lisp
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
#:define-tool)
(:import-from #:bordeaux-threads-2
#:make-thread)
(:export #:start-server))
(:export #:start-server
#:dev-tools))
(in-package #:40ants-lisp-dev-mcp/core)


Expand Down
124 changes: 124 additions & 0 deletions src/cst.lisp
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
(uiop:define-package #:40ants-lisp-dev-mcp/cst
(:use #:cl)
(:import-from #:eclector.parse-result
#:parse-result-client
#:make-expression-result
#:make-skipped-input-result)
(:import-from #:eclector.base)
(:import-from #:eclector.reader)
(:export #:cst-node
#:make-cst-node
#:cst-node-kind
#:cst-node-value
#:cst-node-children
#:cst-node-start
#:cst-node-end
#:cst-node-start-line
#:cst-node-end-line
#:parse-top-level-forms))
(in-package #:40ants-lisp-dev-mcp/cst)


(defstruct cst-node
"A node in a concrete syntax tree with source positions."
(kind :expr :type keyword)
(value nil)
(children nil :type list)
(start 0 :type fixnum)
(end 0 :type fixnum)
(start-line 1 :type fixnum)
(end-line 1 :type fixnum))


;;; Eclector parse-result client that tracks character offsets

(defclass cst-client (eclector.parse-result:parse-result-client)
((source-text :initarg :source-text :reader client-source-text)))

(defmethod eclector.base:source-position ((client cst-client) stream)
(file-position stream))

(defmethod eclector.base:make-source-range ((client cst-client) start end)
(cons start end))

(defmethod eclector.reader:interpret-symbol
((client cst-client) input-stream
package-indicator symbol-name internp)
"Intern symbols leniently: create missing packages on the fly so that
parsing arbitrary Lisp source never fails due to unknown packages."
(let ((package
(cond
((null package-indicator)
(return-from eclector.reader:interpret-symbol
(make-symbol symbol-name)))
((eq package-indicator :keyword) (find-package :keyword))
((eq package-indicator :current) *package*)
(t (or (find-package package-indicator)
(make-package package-indicator :use '()))))))
(if internp
(intern symbol-name package)
(let ((sym (find-symbol symbol-name package)))
(or sym (intern symbol-name package))))))

(defmethod eclector.reader:evaluate-expression ((client cst-client) expression)
"Suppress read-time evaluation for safety."
expression)

(defun count-newlines-before (text position)
"Count line number (1-based) at POSITION in TEXT."
(1+ (count #\Newline text :end (min position (length text)))))

(defmethod eclector.parse-result:make-expression-result
((client cst-client) result children source)
(let ((start (car source))
(end (cdr source))
(text (client-source-text client)))
(make-cst-node :kind :expr
:value result
:children (remove nil children)
:start start
:end end
:start-line (count-newlines-before text start)
:end-line (count-newlines-before text end))))

(defmethod eclector.parse-result:make-skipped-input-result
((client cst-client) stream reason children source)
(declare (ignore stream children))
(when (member reason '(:line-comment :block-comment
(:sharpsign-plus :if) (:sharpsign-plus :else)
(:sharpsign-minus :if) (:sharpsign-minus :else))
:test #'equal)
(let ((start (car source))
(end (cdr source))
(text (client-source-text client)))
(make-cst-node :kind :skipped
:value reason
:start start
:end end
:start-line (count-newlines-before text start)
:end-line (count-newlines-before text end)))))


(defun parse-top-level-forms (text)
"Parse TEXT into a list of CST-NODE objects representing top-level forms.
Returns all forms including comments."
(let* ((client (make-instance 'cst-client :source-text text))
(results '())
(*package* (find-package :cl-user))
(*read-eval* nil))
(with-input-from-string (stream text)
(loop
(multiple-value-bind (result orphans)
(eclector.parse-result:read client stream nil stream)
(when (eq result stream)
;; Collect any trailing orphans (comments after last form)
(when orphans
(dolist (o orphans)
(when o (push o results))))
(return))
;; Collect orphans that precede this form (e.g. comments)
(when orphans
(dolist (o orphans)
(when o (push o results))))
(push result results))))
(nreverse results)))
Loading