如何在Common Lisp中解析未加载依赖包的Lisp文件?
完全可以做到在不加载依赖包、不验证符号存在性的前提下,把CL源码完整读取为S表达式,而且这是比sed替换靠谱得多的方案——毕竟CL的读取器是处理自身语法的权威工具,能覆盖所有语法细节,不需要你自己啃复杂的语法规则。下面分步骤给你讲具体实现:
1. 解决未定义包的错误
当读取带包前缀的符号(比如woo.ev.tcp:start-listening-socket)时,如果包不存在,CL会抛出package-does-not-exist条件。我们可以通过捕获这个条件,动态创建空包来解决:
(handler-bind ((package-does-not-exist (lambda (condition) ;; 获取不存在的包名,创建一个空包(不继承任何其他包的符号) (let ((pkg-name (package-error-package condition))) (make-package pkg-name :use '()) ;; 让读取器继续处理,此时包已经存在 (continue condition))))) ;; 这里放读取文件的代码 )
这个逻辑很简单:遇到不存在的包,就自动创建一个空的,读取器就能正常处理带该包前缀的符号了——而且符号会被正确intern到对应的空包中,保留完整的包前缀信息。
2. 保留条件编译标记(#+/#-)
默认情况下,CL读取器会根据当前Lisp的*features*列表,决定是否跳过#+/#-后面的内容。但你需要保留这些标记的原始结构,所以要重新定义对应的读取宏:
;; 先保存原始的#+读取宏,之后要恢复 (let ((original-plus-macro (get-dispatch-macro-character #\# #\+))) (set-dispatch-macro-character #\# #\+ (lambda (stream char arg) (declare (ignore char arg)) ;; 读取条件标签和后续的表达式,包装成列表 (list '#+ (read stream t nil t) (read stream t nil t))))) ;; 同样处理#- (let ((original-minus-macro (get-dispatch-macro-character #\# #\-))) (set-dispatch-macro-character #\# #\- (lambda (stream char arg) (declare (ignore char arg)) (list '#- (read stream t nil t) (read stream t nil t)))))
这样,读取#+sbcl (print "hello")时,就会得到(#+ sbcl (print "hello")),而不是根据当前环境跳过或直接读取内部表达式。
3. 整合所有逻辑的完整读取函数
把上面的处理整合起来,写一个可以直接调用的函数,同时保证不会破坏当前Lisp会话的默认行为(比如用完后恢复原始读取宏):
(defun read-lisp-source-preserved (file-path) ;; 保存原始的读取宏,避免影响后续操作 (let ((original-plus (get-dispatch-macro-character #\# #\+)) (original-minus (get-dispatch-macro-character #\# #\-)) (original-dot (get-dispatch-macro-character #\# #\.)) ; 可选:处理#.求值表达式 (*read-eval* nil)) ; 禁止自动求值#.表达式,避免安全问题 ;; 重新定义#+/#-读取宏以保留结构 (set-dispatch-macro-character #\# #\+ (lambda (stream char arg) (declare (ignore char arg)) (list '#+ (read stream t nil t) (read stream t nil t)))) (set-dispatch-macro-character #\# #\- (lambda (stream char arg) (declare (ignore char arg)) (list '#- (read stream t nil t) (read stream t nil t)))) ;; 可选:重新定义#.读取宏以保留求值表达式结构 (set-dispatch-macro-character #\# #\. (lambda (stream char arg) (declare (ignore char arg)) (list '#. (read stream t nil t)))) (unwind-protect ;; 处理未定义包 + 读取整个文件的S表达式 (handler-bind ((package-does-not-exist (lambda (c) (make-package (package-error-package c) :use '()) (continue c))) (read-eval-not-allowed (lambda (c) ;; 如果没重新定义#.,捕获这个错误并保留结构 (declare (ignore c)) (list '#. (read stream t nil t))))) (with-open-file (stream file-path :direction :input) (loop for form = (read stream nil nil) while form collect form))) ;; 恢复所有原始读取宏 (set-dispatch-macro-character #\# #\+ original-plus) (set-dispatch-macro-character #\# #\- original-minus) (set-dispatch-macro-character #\# #\. original-dot))))
4. 后续导出到JSON的注意事项
用cl-json导出时,符号默认会被转换成小写字符串,但你可能需要保留包前缀信息。可以自定义符号的JSON序列化规则:
(defmethod cl-json:encode-json ((sym symbol) &optional stream) (cl-json:encode-json (if (symbol-package sym) (format nil "~A:~A" (package-name (symbol-package sym)) (symbol-name sym)) (symbol-name sym)) stream))
如果需要保留原始符号的大小写,在读取文件前加上(setf *readtable-case* :preserve)即可,这样CL读取器会保留源码中的大小写格式。
为什么不推荐sed方案?
sed这类文本替换工具完全不理解CL语法:比如字符串里的"woo.ev.tcp:foo"会被错误替换,带特殊字符的符号(比如|woo.ev.tcp:start|)也会处理失败,更别说嵌套的语法结构了。用CL自身的读取器是唯一能正确处理所有语法细节的方案。
内容的提问来源于stack exchange,提问作者user3113723

