From 3ca9c10678e7c3181c5a68602cea23402509ace6 Mon Sep 17 00:00:00 2001 From: Luis Eduardo Bueso de Barrio Date: Sat, 24 Jan 2026 13:44:20 +0100 Subject: [PATCH] docs and tasks --- docs/01-emacs-elisp-best-practices.md | 445 +++++++++++ docs/02-testing-emacs-elisp.md | 626 ++++++++++++++++ docs/03-org-babel-implementation-guide.md | 747 +++++++++++++++++++ docs/04-elixir-integration-strategies.md | 644 ++++++++++++++++ docs/05-existing-implementations-analysis.md | 435 +++++++++++ tasks/00-index.md | 144 ++++ tasks/01-project-setup.md | 216 ++++++ tasks/02-basic-execution.md | 242 ++++++ tasks/03-variable-injection.md | 334 +++++++++ tasks/04-error-handling.md | 296 ++++++++ tasks/05-result-formatting.md | 330 ++++++++ tasks/06-test-suite.md | 623 ++++++++++++++++ tasks/07-session-support.md | 442 +++++++++++ tasks/08-mix-project-support.md | 421 +++++++++++ tasks/09-remote-shell.md | 401 ++++++++++ tasks/10-async-execution.md | 358 +++++++++ 16 files changed, 6704 insertions(+) create mode 100644 docs/01-emacs-elisp-best-practices.md create mode 100644 docs/02-testing-emacs-elisp.md create mode 100644 docs/03-org-babel-implementation-guide.md create mode 100644 docs/04-elixir-integration-strategies.md create mode 100644 docs/05-existing-implementations-analysis.md create mode 100644 tasks/00-index.md create mode 100644 tasks/01-project-setup.md create mode 100644 tasks/02-basic-execution.md create mode 100644 tasks/03-variable-injection.md create mode 100644 tasks/04-error-handling.md create mode 100644 tasks/05-result-formatting.md create mode 100644 tasks/06-test-suite.md create mode 100644 tasks/07-session-support.md create mode 100644 tasks/08-mix-project-support.md create mode 100644 tasks/09-remote-shell.md create mode 100644 tasks/10-async-execution.md diff --git a/docs/01-emacs-elisp-best-practices.md b/docs/01-emacs-elisp-best-practices.md new file mode 100644 index 0000000..28066eb --- /dev/null +++ b/docs/01-emacs-elisp-best-practices.md @@ -0,0 +1,445 @@ +# Emacs Lisp Best Practices for Package Development + +This document covers best practices for writing Emacs Lisp packages, specifically targeting org-babel language implementations. + +## Table of Contents + +- [File Structure and Headers](#file-structure-and-headers) +- [Naming Conventions](#naming-conventions) +- [Variables and Customization](#variables-and-customization) +- [Function Definitions](#function-definitions) +- [Error Handling](#error-handling) +- [Documentation](#documentation) +- [Dependencies and Loading](#dependencies-and-loading) +- [References](#references) + +--- + +## File Structure and Headers + +Every Emacs Lisp package file should follow a standard structure: + +```elisp +;;; ob-elixir.el --- Org Babel functions for Elixir evaluation -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Your Name + +;; Author: Your Name +;; URL: https://github.com/username/ob-elixir +;; Keywords: literate programming, reproducible research, elixir +;; Version: 1.0.0 +;; Package-Requires: ((emacs "27.1") (org "9.4")) + +;; This file is not part of GNU Emacs. + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program 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 General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see . + +;;; Commentary: + +;; This package provides Org Babel support for evaluating Elixir code blocks. +;; +;; Features: +;; - Execute Elixir code in org-mode source blocks +;; - Support for Mix project context +;; - Session support via IEx +;; - Variable passing between code blocks +;; +;; Usage: +;; Add (elixir . t) to `org-babel-load-languages': +;; +;; (org-babel-do-load-languages +;; 'org-babel-load-languages +;; '((elixir . t))) + +;;; Code: + +(require 'ob) +(require 'ob-ref) +(require 'ob-comint) +(require 'ob-eval) + +;; ... your code here ... + +(provide 'ob-elixir) +;;; ob-elixir.el ends here +``` + +### Key Header Elements + +| Element | Purpose | Example | +|----------------------|--------------------------------------------|-----------------------| +| `lexical-binding: t` | Enable lexical scoping (always use this) | First line comment | +| `Package-Requires` | Declare dependencies with minimum versions | `((emacs "27.1"))` | +| `Keywords` | Help with package discovery | Standard keyword list | +| `Version` | Semantic versioning | `1.0.0` | + +--- + +## Naming Conventions + +### Package Prefix + +All symbols (functions, variables, constants) must be prefixed with the package name to avoid namespace collisions: + +```elisp +;; GOOD - properly prefixed +(defvar ob-elixir-command "elixir") +(defun ob-elixir-evaluate (body params) ...) +(defconst ob-elixir-eoe-indicator "---ob-elixir-eoe---") + +;; BAD - no prefix, will collide +(defvar elixir-command "elixir") +(defun evaluate-elixir (body) ...) +``` + +### Naming Patterns for org-babel + +Org-babel uses specific naming conventions that must be followed: + +```elisp +;; Required function - note the colon syntax +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with Babel.") + +;; Optional session function +(defun org-babel-elixir-initiate-session (&optional session params) + "Create or return an Elixir session buffer.") + +;; Variable assignments function +(defun org-babel-variable-assignments:elixir (params) + "Return Elixir code to assign variables.") + +;; Default header arguments variable +(defvar org-babel-default-header-args:elixir '() + "Default header arguments for Elixir code blocks.") + +;; Prep session function +(defun org-babel-prep-session:elixir (session params) + "Prepare SESSION with PARAMS.") +``` + +### Private Functions + +Use double hyphens for internal/private functions: + +```elisp +;; Public API +(defun ob-elixir-evaluate (body params) + "Evaluate BODY with PARAMS.") + +;; Internal helper - note the double hyphen +(defun ob-elixir--format-value (value) + "Internal: Format VALUE for Elixir.") + +(defun ob-elixir--clean-output (output) + "Internal: Remove ANSI codes from OUTPUT.") +``` + +--- + +## Variables and Customization + +### User-Customizable Variables + +Use `defcustom` for variables users should configure: + +```elisp +(defgroup ob-elixir nil + "Org Babel support for Elixir." + :group 'org-babel + :prefix "ob-elixir-") + +(defcustom ob-elixir-command "elixir" + "Command to invoke Elixir. +Can be a full path or just the command name if in PATH." + :type 'string + :group 'ob-elixir) + +(defcustom ob-elixir-iex-command "iex" + "Command to invoke IEx for session mode." + :type 'string + :group 'ob-elixir) + +(defcustom ob-elixir-mix-command "mix" + "Command to invoke Mix." + :type 'string + :group 'ob-elixir) + +(defcustom ob-elixir-default-session-timeout 30 + "Default timeout in seconds for session operations." + :type 'integer + :group 'ob-elixir) + +;; For options with specific choices +(defcustom ob-elixir-output-format 'value + "Default output format for results." + :type '(choice (const :tag "Return value" value) + (const :tag "Standard output" output)) + :group 'ob-elixir) +``` + +### Internal Variables + +Use `defvar` for internal state: + +```elisp +(defvar ob-elixir--session-buffer nil + "Current active session buffer. +This is internal state and should not be modified directly.") + +(defvar ob-elixir--pending-output "" + "Accumulated output waiting to be processed.") +``` + +### Constants + +Use `defconst` for values that never change: + +```elisp +(defconst ob-elixir-eoe-indicator "__ob_elixir_eoe__" + "String used to indicate end of evaluation output.") + +(defconst ob-elixir-error-regexp "\\*\\* (\\w+Error)" + "Regexp to match Elixir error messages.") +``` + +--- + +## Function Definitions + +### Function Documentation + +Always include comprehensive docstrings: + +```elisp +(defun ob-elixir-evaluate (body params) + "Evaluate BODY as Elixir code according to PARAMS. + +BODY is the source code string to execute. +PARAMS is an alist of header arguments. + +Supported header arguments: + :session - Name of IEx session to use, or \"none\" + :results - How to handle results (value, output) + :var - Variables to inject into the code + +Returns the result as a string, or nil if execution failed. + +Example: + (ob-elixir-evaluate \"1 + 1\" \\='((:results . \"value\")))" + ...) +``` + +### Interactive Commands + +Mark user-facing commands as interactive: + +```elisp +(defun ob-elixir-check-installation () + "Check if Elixir is properly installed and accessible. +Display version information in the minibuffer." + (interactive) + (let ((version (shell-command-to-string + (format "%s --version" ob-elixir-command)))) + (message "Elixir: %s" (string-trim version)))) +``` + +### Using `cl-lib` Properly + +Prefer `cl-lib` functions over deprecated `cl` package: + +```elisp +(require 'cl-lib) + +;; Use cl- prefixed versions +(cl-defun ob-elixir-parse-result (output &key (format 'value)) + "Parse OUTPUT according to FORMAT." + ...) + +(cl-loop for pair in params + when (eq (car pair) :var) + collect (cdr pair)) + +(cl-destructuring-bind (name . value) variable + ...) +``` + +--- + +## Error Handling + +### Signaling Errors + +Use appropriate error functions: + +```elisp +;; For user errors (incorrect usage) +(user-error "No Elixir session found for %s" session-name) + +;; For programming errors +(error "Invalid parameter: %S" param) + +;; Custom error types +(define-error 'ob-elixir-error "Ob-elixir error") +(define-error 'ob-elixir-execution-error "Elixir execution error" 'ob-elixir-error) +(define-error 'ob-elixir-session-error "Elixir session error" 'ob-elixir-error) + +;; Signaling custom errors +(signal 'ob-elixir-execution-error (list "Compilation failed" output)) +``` + +### Handling Errors Gracefully + +```elisp +(defun ob-elixir-safe-evaluate (body params) + "Safely evaluate BODY, handling errors gracefully." + (condition-case err + (ob-elixir-evaluate body params) + (ob-elixir-execution-error + (message "Elixir error: %s" (cadr err)) + nil) + (file-error + (user-error "Cannot access Elixir command: %s" (error-message-string err))) + (error + (message "Unexpected error: %s" (error-message-string err)) + nil))) +``` + +### Input Validation + +```elisp +(defun ob-elixir-evaluate (body params) + "Evaluate BODY with PARAMS." + ;; Validate inputs early + (unless (stringp body) + (error "BODY must be a string, got %S" (type-of body))) + (unless (listp params) + (error "PARAMS must be an alist")) + (when (string-empty-p body) + (user-error "Cannot evaluate empty code block")) + ...) +``` + +--- + +## Documentation + +### Commentary Section + +Write helpful commentary: + +```elisp +;;; Commentary: + +;; ob-elixir provides Org Babel support for the Elixir programming language. +;; +;; Installation: +;; +;; 1. Ensure Elixir is installed and in your PATH +;; 2. Add to your init.el: +;; +;; (require 'ob-elixir) +;; (org-babel-do-load-languages +;; 'org-babel-load-languages +;; '((elixir . t))) +;; +;; Usage: +;; +;; In an org file, create a source block: +;; +;; #+BEGIN_SRC elixir +;; IO.puts("Hello from Elixir!") +;; 1 + 1 +;; #+END_SRC +;; +;; Press C-c C-c to evaluate. +;; +;; Header Arguments: +;; +;; - :session NAME Use a persistent IEx session +;; - :results value Return the last expression's value +;; - :results output Capture stdout +;; - :mix PROJECT Execute in Mix project context +;; +;; See the README for complete documentation. +``` + +### Info Manual Integration (Optional) + +For larger packages, consider an info manual: + +```elisp +(defun ob-elixir-info () + "Open the ob-elixir info manual." + (interactive) + (info "(ob-elixir)")) +``` + +--- + +## Dependencies and Loading + +### Requiring Dependencies + +```elisp +;;; Code: + +;; Required dependencies +(require 'ob) +(require 'ob-ref) +(require 'ob-comint) +(require 'ob-eval) + +;; Soft dependencies (optional features) +(require 'elixir-mode nil t) ; t = noerror + +;; Check for optional dependency +(defvar ob-elixir-has-elixir-mode (featurep 'elixir-mode) + "Non-nil if elixir-mode is available.") +``` + +### Autoloads + +For functions that should be available before the package loads: + +```elisp +;;;###autoload +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with Babel." + ...) + +;;;###autoload +(eval-after-load 'org + '(add-to-list 'org-src-lang-modes '("elixir" . elixir))) +``` + +### Provide Statement + +Always end with provide: + +```elisp +(provide 'ob-elixir) +;;; ob-elixir.el ends here +``` + +--- + +## References + +- [GNU Emacs Lisp Reference Manual](https://www.gnu.org/software/emacs/manual/elisp.html) +- [Emacs Lisp Style Guide](https://github.com/bbatsov/emacs-lisp-style-guide) +- [Packaging Guidelines (MELPA)](https://github.com/melpa/melpa/blob/master/CONTRIBUTING.org) +- [Writing GNU Emacs Extensions](https://www.gnu.org/software/emacs/manual/html_node/eintr/) +- [Org Mode Source Code](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/) +- [Elisp Conventions (Emacs Wiki)](https://www.emacswiki.org/emacs/ElispConventions) diff --git a/docs/02-testing-emacs-elisp.md b/docs/02-testing-emacs-elisp.md new file mode 100644 index 0000000..13ed738 --- /dev/null +++ b/docs/02-testing-emacs-elisp.md @@ -0,0 +1,626 @@ +# Testing Emacs Lisp Packages + +This document covers testing strategies and tools for Emacs Lisp packages, with focus on org-babel language implementations. + +## Table of Contents + +- [Testing Frameworks Overview](#testing-frameworks-overview) +- [ERT (Emacs Lisp Regression Testing)](#ert-emacs-lisp-regression-testing) +- [Buttercup](#buttercup) +- [Test Organization](#test-organization) +- [Testing org-babel Specifics](#testing-org-babel-specifics) +- [Mocking and Stubbing](#mocking-and-stubbing) +- [CI/CD Integration](#cicd-integration) +- [Build Tools](#build-tools) +- [References](#references) + +--- + +## Testing Frameworks Overview + +| Framework | Style | Best For | Included | +|---------------|------------------|-------------------------|------------------------| +| **ERT** | xUnit | Unit tests, built-in | Yes, in Emacs | +| **Buttercup** | BDD/RSpec | Readable specs, mocking | No, install from MELPA | +| **Ecukes** | Cucumber/Gherkin | Integration/acceptance | No, install from MELPA | + +For a package like ob-elixir, **ERT** is recommended as it: +- Comes built into Emacs +- Is well-documented +- Works well with org-mode's own test patterns +- Integrates easily with CI + +--- + +## ERT (Emacs Lisp Regression Testing) + +### Basic Test Structure + +```elisp +;;; test-ob-elixir.el --- Tests for ob-elixir -*- lexical-binding: t; -*- + +(require 'ert) +(require 'ob-elixir) + +;; Simple test +(ert-deftest ob-elixir-test-basic-evaluation () + "Test basic Elixir code evaluation." + (should (equal "2" (ob-elixir-evaluate "1 + 1" '())))) + +;; Test with setup +(ert-deftest ob-elixir-test-variable-injection () + "Test that variables are properly injected." + (let ((params '((:var . ("x" . 5))))) + (should (equal "10" + (ob-elixir-evaluate "x * 2" params))))) + +;; Test for expected errors +(ert-deftest ob-elixir-test-syntax-error () + "Test that syntax errors are handled." + (should-error (ob-elixir-evaluate "def foo(" '()) + :type 'ob-elixir-execution-error)) + +;; Test with negation +(ert-deftest ob-elixir-test-not-nil () + "Test result is not nil." + (should-not (null (ob-elixir-evaluate ":ok" '())))) +``` + +### ERT Assertions + +```elisp +;; Basic assertions +(should FORM) ; FORM should be non-nil +(should-not FORM) ; FORM should be nil +(should-error FORM) ; FORM should signal an error +(should-error FORM :type 'error-type) ; Specific error type + +;; Equality checks +(should (equal expected actual)) +(should (string= "expected" actual)) +(should (= 42 actual)) ; Numeric equality +(should (eq 'symbol actual)) ; Symbol identity +(should (eql 1.0 actual)) ; Numeric with type + +;; Pattern matching +(should (string-match-p "pattern" actual)) +(should (cl-every #'integerp list)) + +;; With custom messages (ERT 28+) +(ert-deftest example-with-message () + (let ((result (some-function))) + (should (equal expected result) + (format "Expected %s but got %s" expected result)))) +``` + +### Test Fixtures + +```elisp +;; Using let for setup +(ert-deftest ob-elixir-test-with-fixture () + (let ((org-babel-default-header-args:elixir '((:results . "value"))) + (test-code "Enum.sum([1, 2, 3])")) + (should (equal "6" (ob-elixir-evaluate test-code '()))))) + +;; Shared setup with macros +(defmacro ob-elixir-test-with-temp-buffer (&rest body) + "Execute BODY in a temporary org buffer." + `(with-temp-buffer + (org-mode) + ,@body)) + +(ert-deftest ob-elixir-test-in-org-buffer () + (ob-elixir-test-with-temp-buffer + (insert "#+BEGIN_SRC elixir\n1 + 1\n#+END_SRC") + (goto-char (point-min)) + (forward-line 1) + (should (equal "2" (org-babel-execute-src-block))))) + +;; Setup/teardown pattern +(defvar ob-elixir-test--saved-command nil) + +(defun ob-elixir-test-setup () + "Setup for ob-elixir tests." + (setq ob-elixir-test--saved-command ob-elixir-command)) + +(defun ob-elixir-test-teardown () + "Teardown for ob-elixir tests." + (setq ob-elixir-command ob-elixir-test--saved-command)) + +(ert-deftest ob-elixir-test-with-setup-teardown () + (ob-elixir-test-setup) + (unwind-protect + (progn + (setq ob-elixir-command "/custom/path/elixir") + (should (string= "/custom/path/elixir" ob-elixir-command))) + (ob-elixir-test-teardown))) +``` + +### Running ERT Tests + +```elisp +;; Interactive (in Emacs) +M-x ert RET t RET ; Run all tests +M-x ert RET ob-elixir RET ; Run tests matching "ob-elixir" +M-x ert-run-tests-interactively + +;; From command line +emacs -batch -l ert -l ob-elixir.el -l test-ob-elixir.el \ + -f ert-run-tests-batch-and-exit + +;; With specific selector +emacs -batch -l ert -l test-ob-elixir.el \ + --eval "(ert-run-tests-batch-and-exit 'ob-elixir-test-basic)" +``` + +--- + +## Buttercup + +Buttercup provides RSpec-style BDD testing with better mocking support. + +### Installation + +```elisp +;; In Cask file or package installation +(depends-on "buttercup") + +;; Or via use-package +(use-package buttercup :ensure t) +``` + +### Basic Structure + +```elisp +;;; test-ob-elixir-buttercup.el --- Buttercup tests -*- lexical-binding: t; -*- + +(require 'buttercup) +(require 'ob-elixir) + +(describe "ob-elixir" + (describe "basic evaluation" + (it "evaluates simple arithmetic" + (expect (ob-elixir-evaluate "1 + 1" '()) + :to-equal "2")) + + (it "returns nil for empty input" + (expect (ob-elixir-evaluate "" '()) + :to-be nil))) + + (describe "variable injection" + (before-each + (setq test-params '((:var . ("x" . 10))))) + + (it "injects variables into code" + (expect (ob-elixir-evaluate "x * 2" test-params) + :to-equal "20")) + + (it "handles multiple variables" + (setq test-params '((:var . ("x" . 10)) + (:var . ("y" . 5)))) + (expect (ob-elixir-evaluate "x + y" test-params) + :to-equal "15"))) + + (describe "error handling" + (it "signals error on syntax error" + (expect (ob-elixir-evaluate "def foo(" '()) + :to-throw 'ob-elixir-execution-error)))) +``` + +### Buttercup Matchers + +```elisp +;; Equality +(expect value :to-equal expected) +(expect value :to-be expected) ; eq comparison +(expect value :to-be-truthy) +(expect value :to-be nil) + +;; Comparison +(expect value :to-be-greater-than 5) +(expect value :to-be-less-than 10) + +;; Strings +(expect string :to-match "regexp") +(expect string :to-contain-string "substring") + +;; Errors +(expect (lambda () (error-func)) :to-throw) +(expect (lambda () (error-func)) :to-throw 'error-type) + +;; Spies (see Mocking section) +(expect 'function :to-have-been-called) +(expect 'function :to-have-been-called-with args) +(expect 'function :to-have-been-called-times n) +``` + +### Running Buttercup + +```bash +# With Cask +cask exec buttercup -L . + +# With Eldev +eldev test buttercup + +# From command line directly +emacs -batch -L . -l buttercup \ + -f buttercup-run-discover +``` + +--- + +## Test Organization + +### Directory Structure + +``` +ob-elixir/ +├── ob-elixir.el +├── test/ +│ ├── test-ob-elixir.el # Main test file +│ ├── test-ob-elixir-session.el # Session-specific tests +│ ├── test-ob-elixir-vars.el # Variable handling tests +│ └── resources/ +│ ├── sample.org # Test org files +│ └── test-project/ # Test Mix project +├── Cask # Or Eldev file +└── Makefile +``` + +### Test File Template + +```elisp +;;; test-ob-elixir.el --- Tests for ob-elixir -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Tests for ob-elixir package. +;; Run with: make test +;; Or: emacs -batch -l ert -l test-ob-elixir.el -f ert-run-tests-batch-and-exit + +;;; Code: + +(require 'ert) + +;; Add source directory to load path +(let ((dir (file-name-directory (or load-file-name buffer-file-name)))) + (add-to-list 'load-path (expand-file-name ".." dir))) + +(require 'ob-elixir) + +;;; Test Helpers + +(defun ob-elixir-test-org-block (code &optional header-args) + "Create org src block with CODE and evaluate it." + (with-temp-buffer + (org-mode) + (insert (format "#+BEGIN_SRC elixir %s\n%s\n#+END_SRC" + (or header-args "") + code)) + (goto-char (point-min)) + (forward-line 1) + (org-babel-execute-src-block))) + +;;; Unit Tests + +(ert-deftest ob-elixir-test-command-exists () + "Test that Elixir command is accessible." + (should (executable-find ob-elixir-command))) + +(ert-deftest ob-elixir-test-simple-evaluation () + "Test simple code evaluation." + (should (equal "2" (ob-elixir-test-org-block "1 + 1")))) + +;;; Integration Tests + +(ert-deftest ob-elixir-integration-full-block () + "Test full org-babel workflow." + (skip-unless (executable-find "elixir")) + (should (equal "6" (ob-elixir-test-org-block "Enum.sum([1, 2, 3])")))) + +(provide 'test-ob-elixir) +;;; test-ob-elixir.el ends here +``` + +--- + +## Testing org-babel Specifics + +### Testing with Real Org Buffers + +```elisp +(ert-deftest ob-elixir-test-full-org-workflow () + "Test the complete org-babel evaluation workflow." + (skip-unless (executable-find "elixir")) + (with-temp-buffer + (org-mode) + (insert "#+NAME: test-block +#+BEGIN_SRC elixir :results value +Enum.map([1, 2, 3], &(&1 * 2)) +#+END_SRC") + (goto-char (point-min)) + (org-babel-goto-named-src-block "test-block") + (let ((result (org-babel-execute-src-block))) + (should (equal '(2 4 6) result))))) +``` + +### Testing Variable Passing + +```elisp +(ert-deftest ob-elixir-test-var-passing () + "Test :var header argument handling." + (with-temp-buffer + (org-mode) + (insert "#+BEGIN_SRC elixir :var x=5 :var y=10 +x + y +#+END_SRC") + (goto-char (point-min)) + (forward-line 1) + (should (equal "15" (org-babel-execute-src-block))))) +``` + +### Testing Table Input/Output + +```elisp +(ert-deftest ob-elixir-test-table-input () + "Test that tables are passed correctly." + (with-temp-buffer + (org-mode) + (insert "#+NAME: test-data +| a | 1 | +| b | 2 | + +#+BEGIN_SRC elixir :var data=test-data +Enum.map(data, fn [k, v] -> {k, v * 2} end) +#+END_SRC") + (goto-char (point-min)) + (search-forward "BEGIN_SRC") + (let ((result (org-babel-execute-src-block))) + (should (equal '(("a" 2) ("b" 4)) result))))) +``` + +### Testing Session Mode + +```elisp +(ert-deftest ob-elixir-test-session-persistence () + "Test that session maintains state between evaluations." + (skip-unless (executable-find "iex")) + (unwind-protect + (progn + ;; First block: define variable + (with-temp-buffer + (org-mode) + (insert "#+BEGIN_SRC elixir :session test-session +x = 42 +#+END_SRC") + (goto-char (point-min)) + (forward-line 1) + (org-babel-execute-src-block)) + + ;; Second block: use variable + (with-temp-buffer + (org-mode) + (insert "#+BEGIN_SRC elixir :session test-session +x * 2 +#+END_SRC") + (goto-char (point-min)) + (forward-line 1) + (should (equal "84" (org-babel-execute-src-block))))) + ;; Cleanup + (when-let ((buf (get-buffer "*elixir-test-session*"))) + (kill-buffer buf)))) +``` + +--- + +## Mocking and Stubbing + +### ERT with cl-letf + +```elisp +(require 'cl-lib) + +(ert-deftest ob-elixir-test-mock-shell-command () + "Test with mocked shell command." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (cmd) "42"))) + (should (equal "42" (ob-elixir-evaluate "anything" '()))))) + +(ert-deftest ob-elixir-test-mock-process () + "Test with mocked external process." + (cl-letf (((symbol-function 'call-process) + (lambda (&rest _args) 0)) + ((symbol-function 'org-babel-eval) + (lambda (_cmd body) (format "result: %s" body)))) + (should (string-match "result:" (ob-elixir-evaluate "code" '()))))) +``` + +### Buttercup Spies + +```elisp +(describe "ob-elixir with mocks" + (before-each + (spy-on 'shell-command-to-string :and-return-value "mocked-result") + (spy-on 'message)) + + (it "uses shell command" + (ob-elixir-evaluate "code" '()) + (expect 'shell-command-to-string :to-have-been-called)) + + (it "passes correct arguments" + (ob-elixir-evaluate "1 + 1" '()) + (expect 'shell-command-to-string + :to-have-been-called-with + (string-match-p "elixir" (spy-calls-args-for + 'shell-command-to-string 0)))) + + (it "can call through to original" + (spy-on 'some-func :and-call-through) + ;; This will call the real function but track calls + )) +``` + +### Testing Without External Dependencies + +```elisp +(ert-deftest ob-elixir-test-without-elixir () + "Test behavior when Elixir is not installed." + (cl-letf (((symbol-function 'executable-find) + (lambda (_) nil))) + (should-error (ob-elixir-check-installation) + :type 'user-error))) +``` + +--- + +## CI/CD Integration + +### GitHub Actions Workflow + +```yaml +# .github/workflows/test.yml +name: Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + emacs-version: ['27.2', '28.2', '29.1'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Emacs + uses: purcell/setup-emacs@master + with: + version: ${{ matrix.emacs-version }} + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.15' + otp-version: '26' + + - name: Install Eldev + run: curl -fsSL https://raw.github.com/doublep/eldev/master/webinstall/github-eldev | sh + + - name: Run tests + run: eldev test + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Emacs + uses: purcell/setup-emacs@master + with: + version: '29.1' + + - name: Install Eldev + run: curl -fsSL https://raw.github.com/doublep/eldev/master/webinstall/github-eldev | sh + + - name: Lint + run: eldev lint +``` + +### Makefile + +```makefile +EMACS ?= emacs +BATCH = $(EMACS) -Q -batch -L . + +.PHONY: test test-ert compile lint clean + +all: compile test + +compile: + $(BATCH) -f batch-byte-compile ob-elixir.el + +test: test-ert + +test-ert: + $(BATCH) -l ert -l test/test-ob-elixir.el \ + -f ert-run-tests-batch-and-exit + +lint: + $(BATCH) -l package-lint \ + --eval "(setq package-lint-main-file \"ob-elixir.el\")" \ + -f package-lint-batch-and-exit ob-elixir.el + +clean: + rm -f *.elc test/*.elc +``` + +--- + +## Build Tools + +### Eldev (Recommended) + +```elisp +; Eldev file +(eldev-use-package-archive 'gnu) +(eldev-use-package-archive 'melpa) + +; Dependencies +(eldev-add-extra-dependencies 'test 'buttercup) + +; Test configuration +(setf eldev-test-framework 'ert) ; or 'buttercup + +; Lint configuration +(setf eldev-lint-default '(elisp package)) +``` + +```bash +# Common Eldev commands +eldev test # Run tests +eldev lint # Run linters +eldev compile # Byte-compile +eldev clean # Clean build artifacts +eldev doctor # Check project health +``` + +### Cask + +```elisp +; Cask file +(source gnu) +(source melpa) + +(package-file "ob-elixir.el") + +(development + (depends-on "ert") + (depends-on "buttercup") + (depends-on "package-lint")) +``` + +```bash +# Common Cask commands +cask install # Install dependencies +cask exec ert-runner # Run ERT tests +cask exec buttercup -L . # Run Buttercup tests +cask build # Byte-compile +``` + +--- + +## References + +- [ERT Manual](https://www.gnu.org/software/emacs/manual/html_node/ert/) +- [Buttercup Documentation](https://github.com/jorgenschaefer/emacs-buttercup) +- [Eldev Documentation](https://github.com/doublep/eldev) +- [Cask Documentation](https://github.com/cask/cask) +- [GitHub Actions for Emacs](https://github.com/purcell/setup-emacs) +- [Org Mode Test Suite](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/testing) +- [Package-lint](https://github.com/purcell/package-lint) diff --git a/docs/03-org-babel-implementation-guide.md b/docs/03-org-babel-implementation-guide.md new file mode 100644 index 0000000..1607b7b --- /dev/null +++ b/docs/03-org-babel-implementation-guide.md @@ -0,0 +1,747 @@ +# Org Babel Language Implementation Guide + +This document provides a comprehensive guide to implementing org-babel support for a new programming language, specifically targeting Elixir. + +## Table of Contents + +- [Architecture Overview](#architecture-overview) +- [Required Components](#required-components) +- [Optional Components](#optional-components) +- [Header Arguments](#header-arguments) +- [Result Handling](#result-handling) +- [Session Management](#session-management) +- [Variable Handling](#variable-handling) +- [Utility Functions](#utility-functions) +- [Complete Implementation Template](#complete-implementation-template) +- [References](#references) + +--- + +## Architecture Overview + +### How Org Babel Executes Code + +``` +User presses C-c C-c on source block + │ + ▼ +┌─────────────────────────────┐ +│ org-babel-execute-src-block │ +└─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ Parse header arguments │ +│ Get block body │ +└─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ org-babel-execute:LANG │ ◄── Your implementation +│ (language-specific) │ +└─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ Process and format results │ +└─────────────────────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ Insert results in buffer │ +└─────────────────────────────┘ +``` + +### Source Block Structure + +```org +#+NAME: block-name +#+HEADER: :var x=5 +#+BEGIN_SRC elixir :results value :session my-session + # This is the body + x * 2 +#+END_SRC + +#+RESULTS: block-name +: 10 +``` + +### Key Data Structures + +**params alist** - Association list passed to execute function: + +```elisp +((:results . "value") + (:session . "my-session") + (:var . ("x" . 5)) + (:var . ("y" . 10)) + (:colnames . no) + (:rownames . no) + (:result-params . ("value" "replace")) + (:result-type . value) + ...) +``` + +--- + +## Required Components + +### 1. The Execute Function + +This is the **only strictly required** function. It must be named exactly `org-babel-execute:LANG`: + +```elisp +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with org-babel. + +BODY is the code to execute. +PARAMS is an alist of header arguments. + +This function is called by `org-babel-execute-src-block'." + (let* ((session (cdr (assq :session params))) + (result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + (full-body (org-babel-expand-body:generic + body params + (org-babel-variable-assignments:elixir params)))) + ;; Execute and return results + (org-babel-reassemble-table + (org-babel-result-cond result-params + ;; For :results output - return raw output + (ob-elixir--execute full-body session) + ;; For :results value - parse as elisp data + (ob-elixir--table-or-string + (ob-elixir--execute full-body session))) + (org-babel-pick-name (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))))) +``` + +### 2. Default Header Arguments + +Define language-specific defaults: + +```elisp +(defvar org-babel-default-header-args:elixir + '((:results . "value") + (:session . "none")) + "Default header arguments for Elixir code blocks.") +``` + +### 3. Language Registration + +Enable the language for evaluation: + +```elisp +;; Add to tangle extensions (for org-babel-tangle) +(add-to-list 'org-babel-tangle-lang-exts '("elixir" . "ex")) + +;; Add to language mode mapping (for syntax highlighting) +(add-to-list 'org-src-lang-modes '("elixir" . elixir)) +``` + +--- + +## Optional Components + +### Language-Specific Header Arguments + +Define what header arguments your language supports: + +```elisp +(defconst org-babel-header-args:elixir + '((mix-project . :any) ; Path to mix project + (mix-env . :any) ; MIX_ENV value + (iex-args . :any) ; Extra args for iex + (timeout . :any) ; Execution timeout + (async . ((yes no)))) ; Async execution + "Elixir-specific header arguments.") +``` + +### Initiate Session Function + +For persistent REPL sessions: + +```elisp +(defun org-babel-elixir-initiate-session (&optional session params) + "Create or return an Elixir session buffer. + +SESSION is the session name (string or nil). +PARAMS are the header arguments. + +Returns the session buffer, or nil if SESSION is \"none\"." + (unless (string= session "none") + (let ((session-name (or session "default"))) + (ob-elixir--get-or-create-session session-name params)))) +``` + +### Prep Session Function + +Prepare a session with variables before execution: + +```elisp +(defun org-babel-prep-session:elixir (session params) + "Prepare SESSION according to PARAMS. + +Injects variables into the session before code execution." + (let ((session-buffer (org-babel-elixir-initiate-session session params)) + (var-lines (org-babel-variable-assignments:elixir params))) + (when session-buffer + (org-babel-comint-in-buffer session-buffer + (dolist (var-line var-lines) + (insert var-line) + (comint-send-input nil t) + (org-babel-comint-wait-for-output session-buffer)))) + session-buffer)) +``` + +### Load Session Function + +Load code into session without executing: + +```elisp +(defun org-babel-load-session:elixir (session body params) + "Load BODY into SESSION without executing. + +Useful for setting up definitions to be used later." + (save-window-excursion + (let ((buffer (org-babel-prep-session:elixir session params))) + (with-current-buffer buffer + (goto-char (process-mark (get-buffer-process buffer))) + (insert (org-babel-chomp body))) + buffer))) +``` + +### Variable Assignments Function + +Convert Elisp values to language-specific variable assignments: + +```elisp +(defun org-babel-variable-assignments:elixir (params) + "Return list of Elixir statements to assign variables from PARAMS." + (mapcar + (lambda (pair) + (format "%s = %s" + (car pair) + (ob-elixir--elisp-to-elixir (cdr pair)))) + (org-babel--get-vars params))) +``` + +### Expand Body Function + +Customize how the code body is expanded (optional, generic works for most): + +```elisp +(defun org-babel-expand-body:elixir (body params) + "Expand BODY according to PARAMS. + +Add variable bindings, prologue, epilogue." + (let ((vars (org-babel-variable-assignments:elixir params)) + (prologue (cdr (assq :prologue params))) + (epilogue (cdr (assq :epilogue params)))) + (concat + (when prologue (concat prologue "\n")) + (mapconcat #'identity vars "\n") + (when vars "\n") + body + (when epilogue (concat "\n" epilogue))))) +``` + +--- + +## Header Arguments + +### Standard Header Arguments (Always Available) + +| Argument | Values | Description | +|-------------|---------------------------|----------------------| +| `:results` | value, output | What to capture | +| `:session` | name, "none" | Session to use | +| `:var` | name=value | Variable binding | +| `:exports` | code, results, both, none | Export behavior | +| `:file` | path | Save results to file | +| `:dir` | path | Working directory | +| `:prologue` | code | Code to run before | +| `:epilogue` | code | Code to run after | +| `:noweb` | yes, no, tangle | Noweb expansion | +| `:cache` | yes, no | Cache results | +| `:tangle` | yes, no, filename | Tangle behavior | + +### Result Types + +```elisp +;; Accessing result configuration +(let* ((result-params (cdr (assq :result-params params))) + (result-type (cdr (assq :result-type params)))) + + ;; result-type is 'value or 'output + ;; result-params is a list like ("value" "replace" "scalar") + + (cond + ((eq result-type 'output) + ;; Capture stdout + (ob-elixir--execute-output body)) + ((eq result-type 'value) + ;; Return last expression value + (ob-elixir--execute-value body)))) +``` + +### Parsing Header Arguments + +```elisp +(defun ob-elixir--parse-params (params) + "Parse PARAMS into a structured format." + (let ((session (cdr (assq :session params))) + (result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + (dir (cdr (assq :dir params))) + (vars (org-babel--get-vars params)) + ;; Custom header args + (mix-project (cdr (assq :mix-project params))) + (mix-env (cdr (assq :mix-env params))) + (timeout (cdr (assq :timeout params)))) + (list :session session + :result-type result-type + :result-params result-params + :dir dir + :vars vars + :mix-project mix-project + :mix-env mix-env + :timeout (or timeout ob-elixir-default-timeout)))) +``` + +--- + +## Result Handling + +### The `org-babel-result-cond` Macro + +This handles result formatting based on `:results` header: + +```elisp +(org-babel-result-cond result-params + ;; This form is used for :results output/scalar/verbatim + (ob-elixir--raw-output body) + + ;; This form is used for :results value (default) + ;; Should return elisp data for possible table conversion + (ob-elixir--parsed-value body)) +``` + +### Converting Results to Tables + +```elisp +(defun ob-elixir--table-or-string (result) + "Convert RESULT to an Emacs table or string. + +If RESULT looks like a list/table, parse it. +Otherwise return as string." + (let ((res (org-babel-script-escape result))) + (if (listp res) + (mapcar (lambda (el) + (if (eq el 'nil) + org-babel-elixir-nil-to + el)) + res) + res))) + +(defvar org-babel-elixir-nil-to 'hline + "Value to use for nil in Elixir results. +When nil values appear in lists/tables, convert to this.") +``` + +### Using `org-babel-reassemble-table` + +Restore column/row names to tables: + +```elisp +(org-babel-reassemble-table + result ; The result data + (org-babel-pick-name ; Column names + (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name ; Row names + (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))) +``` + +### File Results + +For `:results file`: + +```elisp +(defun ob-elixir--handle-file-result (result params) + "Handle file output for RESULT based on PARAMS." + (let ((file (cdr (assq :file params)))) + (when file + (with-temp-file file + (insert result)) + file))) ; Return filename for link creation +``` + +--- + +## Session Management + +### Using Comint for Sessions + +Org-babel provides utilities for managing comint-based REPLs: + +```elisp +(require 'ob-comint) + +(defvar ob-elixir-prompt-regexp "^iex([0-9]+)> " + "Regexp matching the IEx prompt.") + +(defun ob-elixir--get-or-create-session (name params) + "Get or create an IEx session named NAME." + (let ((buffer-name (format "*ob-elixir-%s*" name))) + (if (org-babel-comint-buffer-livep (get-buffer buffer-name)) + (get-buffer buffer-name) + (ob-elixir--start-session buffer-name params)))) + +(defun ob-elixir--start-session (buffer-name params) + "Start a new IEx session in BUFFER-NAME." + (let* ((iex-cmd (or (cdr (assq :iex-command params)) "iex")) + (mix-project (cdr (assq :mix-project params))) + (cmd (if mix-project + (format "cd %s && iex -S mix" + (shell-quote-argument mix-project)) + iex-cmd))) + (with-current-buffer (get-buffer-create buffer-name) + (make-comint-in-buffer "ob-elixir" (current-buffer) + shell-file-name nil "-c" cmd) + ;; Wait for prompt + (sit-for 1) + ;; Configure IEx for non-interactive use + (ob-elixir--configure-session (current-buffer)) + (current-buffer)))) + +(defun ob-elixir--configure-session (buffer) + "Configure IEx session in BUFFER for programmatic use." + (org-babel-comint-in-buffer buffer + (insert "IEx.configure(colors: [enabled: false])") + (comint-send-input nil t) + (org-babel-comint-wait-for-output buffer))) +``` + +### Evaluating in Session + +```elisp +(defun ob-elixir--evaluate-in-session (session body) + "Evaluate BODY in SESSION buffer, return output." + (let ((session-buffer (ob-elixir--get-or-create-session session nil)) + (eoe-indicator ob-elixir-eoe-indicator)) + (org-babel-comint-with-output + (session-buffer ob-elixir-prompt-regexp t eoe-indicator) + (insert body) + (comint-send-input nil t) + ;; Send EOE marker + (insert (format "\"%s\"" eoe-indicator)) + (comint-send-input nil t)))) +``` + +### Key Comint Utilities + +```elisp +;; Check if buffer has live process +(org-babel-comint-buffer-livep buffer) + +;; Execute forms in comint buffer context +(org-babel-comint-in-buffer buffer + (insert "code") + (comint-send-input)) + +;; Wait for output with timeout +(org-babel-comint-wait-for-output buffer) + +;; Capture output until EOE marker +(org-babel-comint-with-output (buffer prompt t eoe-marker) + body...) +``` + +--- + +## Variable Handling + +### Getting Variables from Params + +```elisp +;; org-babel--get-vars returns list of (name . value) pairs +(let ((vars (org-babel--get-vars params))) + ;; vars = (("x" . 5) ("y" . "hello") ("data" . ((1 2) (3 4)))) + (dolist (var vars) + (let ((name (car var)) + (value (cdr var))) + ;; Process each variable + ))) +``` + +### Converting Elisp to Elixir + +```elisp +(defun ob-elixir--elisp-to-elixir (value) + "Convert Elisp VALUE to Elixir literal syntax." + (cond + ;; nil -> nil + ((null value) "nil") + + ;; t -> true + ((eq value t) "true") + + ;; Numbers stay as-is + ((numberp value) (number-to-string value)) + + ;; Strings get quoted + ((stringp value) (format "\"%s\"" (ob-elixir--escape-string value))) + + ;; Symbols become atoms + ((symbolp value) (format ":%s" (symbol-name value))) + + ;; Lists become Elixir lists + ((listp value) + (if (ob-elixir--alist-p value) + ;; Association list -> keyword list or map + (ob-elixir--alist-to-elixir value) + ;; Regular list + (format "[%s]" + (mapconcat #'ob-elixir--elisp-to-elixir value ", ")))) + + ;; Fallback: convert to string + (t (format "%S" value)))) + +(defun ob-elixir--escape-string (str) + "Escape special characters in STR for Elixir." + (replace-regexp-in-string + "\\\\" "\\\\\\\\" + (replace-regexp-in-string + "\"" "\\\\\"" + (replace-regexp-in-string + "\n" "\\\\n" str)))) + +(defun ob-elixir--alist-p (list) + "Return t if LIST is an association list." + (and (listp list) + (cl-every (lambda (el) + (and (consp el) (atom (car el)))) + list))) + +(defun ob-elixir--alist-to-elixir (alist) + "Convert ALIST to Elixir keyword list or map." + (format "[%s]" + (mapconcat + (lambda (pair) + (format "%s: %s" + (car pair) + (ob-elixir--elisp-to-elixir (cdr pair)))) + alist + ", "))) +``` + +### Handling Tables (hlines) + +```elisp +(defvar org-babel-elixir-hline-to ":hline" + "Elixir representation for org table hlines.") + +(defun ob-elixir--table-to-elixir (table) + "Convert org TABLE to Elixir list of lists." + (format "[%s]" + (mapconcat + (lambda (row) + (if (eq row 'hline) + org-babel-elixir-hline-to + (format "[%s]" + (mapconcat #'ob-elixir--elisp-to-elixir row ", ")))) + table + ", "))) +``` + +--- + +## Utility Functions + +### From ob.el / ob-core.el + +```elisp +;; Execute external command with body as stdin +(org-babel-eval command body) +;; Returns stdout as string + +;; Create temporary file +(org-babel-temp-file "prefix-") +;; Returns temp file path + +;; Read file contents +(org-babel-eval-read-file filename) + +;; Process file path for use in scripts +(org-babel-process-file-name filename) + +;; Parse script output as Elisp data +(org-babel-script-escape output) +;; Parses "[1, 2, 3]" -> (1 2 3) + +;; Remove trailing newlines +(org-babel-chomp string) + +;; Expand generic body with vars +(org-babel-expand-body:generic body params var-lines) +``` + +### Wrapper Method Pattern + +For `:results value`, wrap code to capture return value: + +```elisp +(defconst ob-elixir-wrapper-method + " +result = (fn -> +%s +end).() + +result +|> inspect(limit: :infinity, printable_limit: :infinity) +|> IO.puts() +" + "Template for wrapping Elixir code to capture value. +%s is replaced with the user's code.") + +(defun ob-elixir--wrap-for-value (body) + "Wrap BODY to capture its return value." + (format ob-elixir-wrapper-method body)) +``` + +--- + +## Complete Implementation Template + +Here's a minimal but functional template: + +```elisp +;;; ob-elixir.el --- Org Babel functions for Elixir -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Your Name +;; Author: Your Name +;; Version: 1.0.0 +;; Package-Requires: ((emacs "27.1") (org "9.4")) +;; Keywords: literate programming, elixir +;; URL: https://github.com/user/ob-elixir + +;;; Commentary: + +;; Org Babel support for evaluating Elixir code blocks. + +;;; Code: + +(require 'ob) +(require 'ob-eval) + +;;; Customization + +(defgroup ob-elixir nil + "Org Babel support for Elixir." + :group 'org-babel + :prefix "ob-elixir-") + +(defcustom ob-elixir-command "elixir" + "Command to execute Elixir code." + :type 'string + :group 'ob-elixir) + +;;; Header Arguments + +(defvar org-babel-default-header-args:elixir + '((:results . "value") + (:session . "none")) + "Default header arguments for Elixir code blocks.") + +(defconst org-babel-header-args:elixir + '((mix-project . :any)) + "Elixir-specific header arguments.") + +;;; File Extension + +(add-to-list 'org-babel-tangle-lang-exts '("elixir" . "ex")) + +;;; Execution + +(defun org-babel-execute:elixir (body params) + "Execute Elixir BODY according to PARAMS." + (let* ((result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + (full-body (org-babel-expand-body:generic + body params + (org-babel-variable-assignments:elixir params))) + (result (ob-elixir--execute full-body result-type))) + (org-babel-reassemble-table + (org-babel-result-cond result-params + result + (org-babel-script-escape result)) + (org-babel-pick-name (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))))) + +(defun ob-elixir--execute (body result-type) + "Execute BODY and return result based on RESULT-TYPE." + (let* ((tmp-file (org-babel-temp-file "elixir-" ".exs")) + (wrapper (if (eq result-type 'value) + (ob-elixir--wrap-for-value body) + body))) + (with-temp-file tmp-file + (insert wrapper)) + (org-babel-eval + (format "%s %s" ob-elixir-command + (org-babel-process-file-name tmp-file)) + ""))) + +(defconst ob-elixir--value-wrapper + "result = (\n%s\n)\nIO.puts(inspect(result, limit: :infinity))" + "Wrapper to capture return value.") + +(defun ob-elixir--wrap-for-value (body) + "Wrap BODY to capture its return value." + (format ob-elixir--value-wrapper body)) + +;;; Variables + +(defun org-babel-variable-assignments:elixir (params) + "Return Elixir code to assign variables from PARAMS." + (mapcar + (lambda (pair) + (format "%s = %s" + (car pair) + (ob-elixir--elisp-to-elixir (cdr pair)))) + (org-babel--get-vars params))) + +(defun ob-elixir--elisp-to-elixir (value) + "Convert Elisp VALUE to Elixir syntax." + (cond + ((null value) "nil") + ((eq value t) "true") + ((numberp value) (number-to-string value)) + ((stringp value) (format "\"%s\"" value)) + ((symbolp value) (format ":%s" (symbol-name value))) + ((listp value) + (format "[%s]" (mapconcat #'ob-elixir--elisp-to-elixir value ", "))) + (t (format "%S" value)))) + +(provide 'ob-elixir) +;;; ob-elixir.el ends here +``` + +--- + +## References + +- [Org Mode Manual - Working with Source Code](https://orgmode.org/manual/Working-with-Source-Code.html) +- [Org Mode Manual - Languages](https://orgmode.org/manual/Languages.html) +- [Org Mode Manual - Results of Evaluation](https://orgmode.org/manual/Results-of-Evaluation.html) +- [Worg - Babel Language Template](https://orgmode.org/worg/org-contrib/babel/languages/ob-doc-template.html) +- [Worg - Python Documentation](https://orgmode.org/worg/org-contrib/babel/languages/ob-doc-python.html) +- [Org Source - ob.el](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/ob.el) +- [Org Source - ob-python.el](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/ob-python.el) +- [Org Source - ob-ruby.el](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/ob-ruby.el) +- [Org Element API](https://orgmode.org/worg/dev/org-element-api.html) diff --git a/docs/04-elixir-integration-strategies.md b/docs/04-elixir-integration-strategies.md new file mode 100644 index 0000000..d3a760a --- /dev/null +++ b/docs/04-elixir-integration-strategies.md @@ -0,0 +1,644 @@ +# Elixir Integration Strategies + +This document covers strategies for integrating Elixir with Emacs and org-babel, including execution modes, data conversion, and Mix project support. + +## Table of Contents + +- [Execution Modes](#execution-modes) +- [One-Shot Execution](#one-shot-execution) +- [IEx Session Management](#iex-session-management) +- [Mix Project Context](#mix-project-context) +- [Remote Shell (remsh)](#remote-shell-remsh) +- [Data Type Conversion](#data-type-conversion) +- [Error Handling](#error-handling) +- [elixir-mode Integration](#elixir-mode-integration) +- [Performance Considerations](#performance-considerations) +- [References](#references) + +--- + +## Execution Modes + +### Overview of Execution Strategies + +| Mode | Command | Use Case | Startup Time | State | +|--------------|-------------------|----------------|--------------|-------------| +| One-shot | `elixir -e` | Simple scripts | ~500ms | None | +| Script file | `elixir file.exs` | Larger code | ~500ms | None | +| IEx session | `iex` | Interactive | Once | Persistent | +| Mix context | `iex -S mix` | Projects | Slower | Project | +| Remote shell | `iex --remsh` | Production | Fast | Remote node | + +### Recommended Approach + +For org-babel, we recommend: + +1. **Default**: Script file execution (reliable, predictable) +2. **With `:session`**: IEx via comint (persistent state) +3. **With `:mix-project`**: Mix context execution + +--- + +## One-Shot Execution + +### Using `elixir -e` + +For simple, single expressions: + +```elisp +(defun ob-elixir--eval-simple (code) + "Evaluate CODE using elixir -e." + (shell-command-to-string + (format "elixir -e %s" + (shell-quote-argument code)))) +``` + +**Pros**: Simple, no temp files +**Cons**: Limited code size, quoting issues + +### Using Script Files (Recommended) + +More robust for complex code: + +```elisp +(defun ob-elixir--eval-script (code) + "Evaluate CODE using a temporary script file." + (let ((script-file (org-babel-temp-file "elixir-" ".exs"))) + (with-temp-file script-file + (insert code)) + (org-babel-eval + (format "elixir %s" (org-babel-process-file-name script-file)) + ""))) +``` + +### Capturing Return Values vs Output + +```elisp +;; For :results output - capture stdout +(defun ob-elixir--eval-output (code) + "Execute CODE and capture stdout." + (ob-elixir--eval-script code)) + +;; For :results value - wrap to capture return value +(defconst ob-elixir--value-wrapper + " +_result_ = ( +%s +) +IO.puts(inspect(_result_, limit: :infinity, printable_limit: :infinity, charlists: :as_lists)) +" + "Wrapper to capture the return value of code.") + +(defun ob-elixir--eval-value (code) + "Execute CODE and return its value." + (let ((wrapped (format ob-elixir--value-wrapper code))) + (string-trim (ob-elixir--eval-script wrapped)))) +``` + +### Handling Multiline Output + +```elisp +(defconst ob-elixir--value-wrapper-with-marker + " +_result_ = ( +%s +) +IO.puts(\"__OB_ELIXIR_RESULT_START__\") +IO.puts(inspect(_result_, limit: :infinity, printable_limit: :infinity)) +IO.puts(\"__OB_ELIXIR_RESULT_END__\") +" + "Wrapper with markers for reliable output parsing.") + +(defun ob-elixir--extract-result (output) + "Extract result from OUTPUT between markers." + (when (string-match "__OB_ELIXIR_RESULT_START__\n\\(.*\\)\n__OB_ELIXIR_RESULT_END__" + output) + (match-string 1 output))) +``` + +--- + +## IEx Session Management + +### Starting an IEx Session + +```elisp +(defvar ob-elixir-iex-buffer-name "*ob-elixir-iex*" + "Buffer name for the IEx process.") + +(defvar ob-elixir-prompt-regexp "^iex\\([0-9]+\\)> " + "Regexp matching the IEx prompt.") + +(defvar ob-elixir-continued-prompt-regexp "^\\.\\.\\.\\([0-9]+\\)> " + "Regexp matching the IEx continuation prompt.") + +(defun ob-elixir--start-iex-session (session-name &optional params) + "Start an IEx session named SESSION-NAME." + (let* ((buffer-name (format "*ob-elixir-%s*" session-name)) + (buffer (get-buffer-create buffer-name)) + (mix-project (when params (cdr (assq :mix-project params)))) + (iex-args (when params (cdr (assq :iex-args params))))) + + (unless (comint-check-proc buffer) + (with-current-buffer buffer + ;; Set up environment + (setenv "TERM" "dumb") + (setenv "IEX_WITH_WERL" nil) + + ;; Start the process + (apply #'make-comint-in-buffer + (format "ob-elixir-%s" session-name) + buffer + "iex" + nil + (append + (when mix-project + (list "-S" "mix")) + (when iex-args + (split-string iex-args)))) + + ;; Wait for initial prompt + (ob-elixir--wait-for-prompt buffer 10) + + ;; Configure IEx for programmatic use + (ob-elixir--configure-iex-session buffer))) + buffer)) +``` + +### Configuring IEx for Non-Interactive Use + +```elisp +(defun ob-elixir--configure-iex-session (buffer) + "Configure IEx in BUFFER for non-interactive use." + (with-current-buffer buffer + (let ((config-commands + '("IEx.configure(colors: [enabled: false])" + "IEx.configure(inspect: [limit: :infinity, printable_limit: :infinity])" + "Application.put_env(:elixir, :ansi_enabled, false)"))) + (dolist (cmd config-commands) + (ob-elixir--send-to-iex buffer cmd) + (ob-elixir--wait-for-prompt buffer 5))))) +``` + +### Sending Code to IEx + +```elisp +(defvar ob-elixir-eoe-marker "__OB_ELIXIR_EOE__" + "End-of-evaluation marker.") + +(defun ob-elixir--send-to-iex (buffer code) + "Send CODE to IEx process in BUFFER." + (with-current-buffer buffer + (goto-char (point-max)) + (insert code) + (comint-send-input nil t))) + +(defun ob-elixir--eval-in-session (session code) + "Evaluate CODE in SESSION, return output." + (let* ((buffer (ob-elixir--get-or-create-session session)) + (start-marker nil)) + (with-current-buffer buffer + (goto-char (point-max)) + (setq start-marker (point-marker)) + + ;; Send the code + (ob-elixir--send-to-iex buffer code) + (ob-elixir--wait-for-prompt buffer 30) + + ;; Send EOE marker to clearly delineate output + (ob-elixir--send-to-iex buffer (format "\"%s\"" ob-elixir-eoe-marker)) + (ob-elixir--wait-for-prompt buffer 5) + + ;; Extract output + (ob-elixir--extract-session-output buffer start-marker)))) + +(defun ob-elixir--wait-for-prompt (buffer timeout) + "Wait for IEx prompt in BUFFER with TIMEOUT seconds." + (with-current-buffer buffer + (let ((end-time (+ (float-time) timeout))) + (while (and (< (float-time) end-time) + (not (ob-elixir--at-prompt-p))) + (accept-process-output (get-buffer-process buffer) 0.1) + (goto-char (point-max)))))) + +(defun ob-elixir--at-prompt-p () + "Return t if point is at an IEx prompt." + (save-excursion + (beginning-of-line) + (looking-at ob-elixir-prompt-regexp))) +``` + +### Cleaning Session Output + +```elisp +(defun ob-elixir--clean-iex-output (output) + "Clean OUTPUT from IEx session." + (let ((cleaned output)) + ;; Remove ANSI escape codes + (setq cleaned (ansi-color-filter-apply cleaned)) + ;; Remove prompts + (setq cleaned (replace-regexp-in-string + "^iex([0-9]+)> " "" cleaned)) + (setq cleaned (replace-regexp-in-string + "^\\.\\.\\.([0-9]+)> " "" cleaned)) + ;; Remove EOE marker + (setq cleaned (replace-regexp-in-string + (regexp-quote (format "\"%s\"" ob-elixir-eoe-marker)) + "" cleaned)) + ;; Remove trailing whitespace + (string-trim cleaned))) +``` + +--- + +## Mix Project Context + +### Detecting Mix Projects + +```elisp +(defun ob-elixir--find-mix-project (dir) + "Find mix.exs file starting from DIR, searching up." + (let ((mix-file (locate-dominating-file dir "mix.exs"))) + (when mix-file + (file-name-directory mix-file)))) + +(defun ob-elixir--in-mix-project-p () + "Return t if current buffer is in a Mix project." + (ob-elixir--find-mix-project default-directory)) +``` + +### Executing in Mix Context + +```elisp +(defun ob-elixir--eval-with-mix (code project-dir &optional mix-env) + "Evaluate CODE in the context of Mix project at PROJECT-DIR." + (let* ((default-directory project-dir) + (script-file (org-babel-temp-file "elixir-" ".exs")) + (env-vars (when mix-env + (format "MIX_ENV=%s " mix-env)))) + (with-temp-file script-file + (insert code)) + (shell-command-to-string + (format "%smix run %s" + (or env-vars "") + (org-babel-process-file-name script-file))))) +``` + +### Compiling Before Execution + +```elisp +(defun ob-elixir--ensure-compiled (project-dir) + "Ensure Mix project at PROJECT-DIR is compiled." + (let ((default-directory project-dir)) + (shell-command-to-string "mix compile --force-check"))) + +(defun ob-elixir--eval-with-compilation (code project-dir) + "Compile and evaluate CODE in PROJECT-DIR." + (ob-elixir--ensure-compiled project-dir) + (ob-elixir--eval-with-mix code project-dir)) +``` + +### Using Mix Aliases + +```elisp +(defun ob-elixir--run-mix-task (task project-dir &optional args) + "Run Mix TASK in PROJECT-DIR with ARGS." + (let ((default-directory project-dir)) + (shell-command-to-string + (format "mix %s %s" task (or args ""))))) +``` + +--- + +## Remote Shell (remsh) + +### Connecting to Running Nodes + +```elisp +(defun ob-elixir--start-remsh-session (node-name &optional cookie sname) + "Connect to remote Elixir node NODE-NAME. + +COOKIE is the Erlang cookie (optional). +SNAME is the short name for the local node." + (let* ((local-name (or sname (format "ob_elixir_%d" (random 10000)))) + (buffer-name (format "*ob-elixir-remsh-%s*" node-name)) + (buffer (get-buffer-create buffer-name)) + (args (append + (list "--sname" local-name) + (when cookie (list "--cookie" cookie)) + (list "--remsh" node-name)))) + + (unless (comint-check-proc buffer) + (with-current-buffer buffer + (apply #'make-comint-in-buffer + (format "ob-elixir-remsh-%s" node-name) + buffer + "iex" + nil + args) + (ob-elixir--wait-for-prompt buffer 30) + (ob-elixir--configure-iex-session buffer))) + buffer)) +``` + +### Remote Evaluation + +```elisp +(defun ob-elixir--eval-remote (code node-name &optional cookie) + "Evaluate CODE on remote NODE-NAME." + (let ((session (ob-elixir--start-remsh-session node-name cookie))) + (ob-elixir--eval-in-session session code))) +``` + +--- + +## Data Type Conversion + +### Elisp to Elixir + +```elisp +(defun ob-elixir--elisp-to-elixir (value) + "Convert Elisp VALUE to Elixir literal syntax." + (pcase value + ('nil "nil") + ('t "true") + ((pred numberp) (number-to-string value)) + ((pred stringp) (ob-elixir--format-string value)) + ((pred symbolp) (ob-elixir--format-atom value)) + ((pred vectorp) (ob-elixir--format-tuple value)) + ((pred listp) + (cond + ((ob-elixir--alist-p value) (ob-elixir--format-keyword-list value)) + ((ob-elixir--plist-p value) (ob-elixir--format-map value)) + (t (ob-elixir--format-list value)))) + (_ (format "%S" value)))) + +(defun ob-elixir--format-string (str) + "Format STR as Elixir string." + (format "\"%s\"" + (replace-regexp-in-string + "\\\\" "\\\\\\\\" + (replace-regexp-in-string + "\"" "\\\\\"" + (replace-regexp-in-string + "\n" "\\\\n" + str))))) + +(defun ob-elixir--format-atom (sym) + "Format symbol SYM as Elixir atom." + (let ((name (symbol-name sym))) + (if (string-match-p "^[a-z_][a-zA-Z0-9_]*[?!]?$" name) + (format ":%s" name) + (format ":\"%s\"" name)))) + +(defun ob-elixir--format-list (lst) + "Format LST as Elixir list." + (format "[%s]" + (mapconcat #'ob-elixir--elisp-to-elixir lst ", "))) + +(defun ob-elixir--format-tuple (vec) + "Format vector VEC as Elixir tuple." + (format "{%s}" + (mapconcat #'ob-elixir--elisp-to-elixir (append vec nil) ", "))) + +(defun ob-elixir--format-keyword-list (alist) + "Format ALIST as Elixir keyword list." + (format "[%s]" + (mapconcat + (lambda (pair) + (format "%s: %s" + (car pair) + (ob-elixir--elisp-to-elixir (cdr pair)))) + alist ", "))) + +(defun ob-elixir--format-map (plist) + "Format PLIST as Elixir map." + (let ((pairs '())) + (while plist + (push (format "%s => %s" + (ob-elixir--elisp-to-elixir (car plist)) + (ob-elixir--elisp-to-elixir (cadr plist))) + pairs) + (setq plist (cddr plist))) + (format "%%{%s}" (mapconcat #'identity (nreverse pairs) ", ")))) +``` + +### Elixir to Elisp + +```elisp +(defun ob-elixir--parse-result (output) + "Parse Elixir OUTPUT into Elisp value." + (let ((trimmed (string-trim output))) + (cond + ;; nil + ((string= trimmed "nil") nil) + + ;; Booleans + ((string= trimmed "true") t) + ((string= trimmed "false") nil) + + ;; Numbers + ((string-match-p "^-?[0-9]+$" trimmed) + (string-to-number trimmed)) + ((string-match-p "^-?[0-9]+\\.[0-9]+\\(e[+-]?[0-9]+\\)?$" trimmed) + (string-to-number trimmed)) + + ;; Atoms (convert to symbols) + ((string-match "^:\\([a-zA-Z_][a-zA-Z0-9_]*\\)$" trimmed) + (intern (match-string 1 trimmed))) + + ;; Strings + ((string-match "^\"\\(.*\\)\"$" trimmed) + (match-string 1 trimmed)) + + ;; Lists/tuples - use org-babel-script-escape + ((string-match-p "^\\[.*\\]$" trimmed) + (org-babel-script-escape trimmed)) + ((string-match-p "^{.*}$" trimmed) + (org-babel-script-escape + (replace-regexp-in-string "^{\\(.*\\)}$" "[\\1]" trimmed))) + + ;; Maps - convert to alist + ((string-match-p "^%{.*}$" trimmed) + (ob-elixir--parse-map trimmed)) + + ;; Default: return as string + (t trimmed)))) + +(defun ob-elixir--parse-map (map-string) + "Parse Elixir MAP-STRING to alist." + ;; Simplified - for complex maps, use JSON encoding + (let ((content (substring map-string 2 -1))) ; Remove %{ and } + (mapcar + (lambda (pair) + (when (string-match "\\(.+?\\) => \\(.+\\)" pair) + (cons (ob-elixir--parse-result (match-string 1 pair)) + (ob-elixir--parse-result (match-string 2 pair))))) + (split-string content ", ")))) +``` + +### Using JSON for Complex Data + +For complex nested structures, JSON is more reliable: + +```elisp +(defconst ob-elixir--json-wrapper + " +_result_ = ( +%s +) +IO.puts(Jason.encode!(_result_)) +" + "Wrapper that outputs result as JSON.") + +(defun ob-elixir--eval-as-json (code) + "Evaluate CODE and parse result as JSON." + (let* ((wrapped (format ob-elixir--json-wrapper code)) + (output (ob-elixir--eval-script wrapped))) + (json-read-from-string (string-trim output)))) +``` + +--- + +## Error Handling + +### Detecting Elixir Errors + +```elisp +(defconst ob-elixir-error-patterns + '("^\\*\\* (\\(\\w+Error\\))" ; ** (RuntimeError) ... + "^\\*\\* (\\(\\w+\\)) \\(.+\\)" ; ** (exit) ... + "^\\(CompileError\\)" ; CompileError ... + "^\\(SyntaxError\\)") ; SyntaxError ... + "Patterns matching Elixir error output.") + +(defun ob-elixir--error-p (output) + "Return error info if OUTPUT contains an error, nil otherwise." + (catch 'found + (dolist (pattern ob-elixir-error-patterns) + (when (string-match pattern output) + (throw 'found (list :type (match-string 1 output) + :message output)))))) +``` + +### Signaling Errors to Org + +```elisp +(define-error 'ob-elixir-error "Elixir evaluation error") +(define-error 'ob-elixir-compile-error "Elixir compilation error" 'ob-elixir-error) +(define-error 'ob-elixir-runtime-error "Elixir runtime error" 'ob-elixir-error) + +(defun ob-elixir--handle-error (output) + "Handle error in OUTPUT, signaling appropriate condition." + (when-let ((error-info (ob-elixir--error-p output))) + (let ((type (plist-get error-info :type)) + (message (plist-get error-info :message))) + (cond + ((member type '("CompileError" "SyntaxError" "TokenMissingError")) + (signal 'ob-elixir-compile-error (list message))) + (t + (signal 'ob-elixir-runtime-error (list message))))))) +``` + +### Timeout Handling + +```elisp +(defcustom ob-elixir-timeout 30 + "Default timeout in seconds for Elixir evaluation." + :type 'integer + :group 'ob-elixir) + +(defun ob-elixir--eval-with-timeout (code timeout) + "Evaluate CODE with TIMEOUT seconds limit." + (with-timeout (timeout + (error "Elixir evaluation timed out after %d seconds" timeout)) + (ob-elixir--eval-script code))) +``` + +--- + +## elixir-mode Integration + +### Syntax Highlighting + +```elisp +;; Register with org-src for editing +(add-to-list 'org-src-lang-modes '("elixir" . elixir)) + +;; If elixir-mode isn't available, use a fallback +(unless (fboundp 'elixir-mode) + (add-to-list 'org-src-lang-modes '("elixir" . prog))) +``` + +### Using elixir-mode Functions + +```elisp +(defun ob-elixir--format-code (code) + "Format CODE using mix format if available." + (when (and (executable-find "mix") + (> (length code) 0)) + (with-temp-buffer + (insert code) + (let ((temp-file (make-temp-file "elixir-format" nil ".ex"))) + (unwind-protect + (progn + (write-region (point-min) (point-max) temp-file) + (when (= 0 (call-process "mix" nil nil nil + "format" temp-file)) + (erase-buffer) + (insert-file-contents temp-file) + (buffer-string))) + (delete-file temp-file)))))) +``` + +--- + +## Performance Considerations + +### Caching Compilation + +```elisp +(defvar ob-elixir--module-cache (make-hash-table :test 'equal) + "Cache of compiled Elixir modules.") + +(defun ob-elixir--get-cached-module (code) + "Get cached module for CODE, or compile and cache." + (let ((hash (md5 code))) + (or (gethash hash ob-elixir--module-cache) + (let ((module-name (format "ObElixir_%s" (substring hash 0 8)))) + (ob-elixir--compile-module module-name code) + (puthash hash module-name ob-elixir--module-cache) + module-name)))) +``` + +### Reusing Sessions + +```elisp +(defcustom ob-elixir-reuse-sessions t + "Whether to reuse sessions between evaluations. +When non-nil, sessions persist until explicitly killed." + :type 'boolean + :group 'ob-elixir) +``` + +### Startup Time Optimization + +```elisp +;; Pre-start a session on package load (optional) +(defun ob-elixir-warm-up () + "Pre-start an IEx session for faster first evaluation." + (interactive) + (ob-elixir--start-iex-session "warmup")) +``` + +--- + +## References + +- [Elixir Documentation](https://elixir-lang.org/docs.html) +- [IEx Documentation](https://hexdocs.pm/iex/IEx.html) +- [Mix Documentation](https://hexdocs.pm/mix/Mix.html) +- [Erlang Distribution Protocol](https://www.erlang.org/doc/reference_manual/distributed.html) +- [elixir-mode GitHub](https://github.com/elixir-editors/emacs-elixir) +- [inf-elixir for REPL](https://github.com/J3RN/inf-elixir) +- [Elixir LS (Language Server)](https://github.com/elixir-lsp/elixir-ls) diff --git a/docs/05-existing-implementations-analysis.md b/docs/05-existing-implementations-analysis.md new file mode 100644 index 0000000..6dc32e5 --- /dev/null +++ b/docs/05-existing-implementations-analysis.md @@ -0,0 +1,435 @@ +# Existing Implementations Analysis + +This document analyzes existing org-babel Elixir implementations and related packages, identifying gaps and improvement opportunities. + +## Table of Contents + +- [zweifisch/ob-elixir Analysis](#zweifischob-elixir-analysis) +- [Comparison with Other ob-* Packages](#comparison-with-other-ob--packages) +- [Feature Gap Analysis](#feature-gap-analysis) +- [Recommendations](#recommendations) +- [References](#references) + +--- + +## zweifisch/ob-elixir Analysis + +### Repository Information + +- **URL**: https://github.com/zweifisch/ob-elixir +- **Stars**: 29 (as of 2024) +- **Last Updated**: Limited recent activity +- **License**: GPL-3.0 + +### Source Code Review + +```elisp +;;; Current implementation (simplified) + +(defvar ob-elixir-process-output "") + +(defconst org-babel-header-args:elixir + '((cookie . :any) + (name . :any) + (remsh . :any) + (sname . :any)) + "elixir header arguments") + +(defvar ob-elixir-eoe "\u2029") ; Unicode paragraph separator + +(defun org-babel-execute:elixir (body params) + (let ((session (cdr (assoc :session params))) + (tmp (org-babel-temp-file "elixir-"))) + (ob-elixir-ensure-session session params) + (with-temp-file tmp (insert body)) + (ob-elixir-eval session (format "import_file(\"%s\")" tmp)))) + +(defun ob-elixir-eval (session body) + (let ((result (ob-elixir-eval-in-repl session body))) + ;; Heavy regex cleanup of IEx output + (replace-regexp-in-string + "^\\(import_file([^)]+)\\)+\n" "" + (replace-regexp-in-string + "\r" "" + (replace-regexp-in-string + "\n\\(\\(iex\\|[.]+\\)\\(([^@]+@[^)]+)[0-9]+\\|([0-9]+)\\)> \\)+" "" + (replace-regexp-in-string + "\e\\[[0-9;]*[A-Za-z]" "" + (replace-regexp-in-string + "\"\\\\u2029\"" "" + result))))))) +``` + +### Strengths + +| Feature | Description | +|---------------------|--------------------------------------------------------| +| **Session support** | Uses IEx sessions for persistent state | +| **Remote shell** | Supports `--remsh` for connecting to running nodes | +| **Node naming** | Supports `--sname` and `--name` for distributed Erlang | +| **Cookie support** | Can specify Erlang cookie for authentication | +| **Simple design** | Minimal code, easy to understand | + +### Weaknesses + +| Issue | Description | Impact | +|----------------------------|-----------------------------------------------------|----------------------------| +| **Always uses sessions** | No one-shot execution mode | Slow for simple scripts | +| **No `:results value`** | Only captures output, not return values | Limited functionality | +| **No `:var` support** | Cannot pass variables to code blocks | Poor org-babel integration | +| **Fragile output parsing** | Multiple regex replacements to clean IEx output | Unreliable | +| **Uses `import_file`** | Relies on IEx-specific command | Tight coupling to IEx | +| **Global state** | Uses `ob-elixir-process-output` global variable | Not thread-safe | +| **No Mix support** | Cannot execute in Mix project context | Limited for real projects | +| **Hardcoded `sit-for`** | Uses fixed delays instead of proper synchronization | Timing issues | +| **No error handling** | Errors not properly detected or reported | Poor UX | +| **No tests** | No test suite | Quality concerns | +| **No `defcustom`** | Settings not customizable via Customize | Poor UX | + +### Specific Issues in Code + +1. **Timing-based synchronization**: +```elisp +(sit-for 0.5) ; Wait 500ms and hope IEx is ready +;; This is fragile - slow systems may fail +``` + +2. **No prompt detection**: +```elisp +(defun ob-elixir-wait () + (while (not (string-match-p ob-elixir-eoe ob-elixir-process-output)) + (sit-for 0.2))) +;; Busy-waiting instead of using process filter properly +``` + +3. **Heavy output cleanup**: +```elisp +;; 5 nested regex replacements - fragile and hard to debug +(replace-regexp-in-string "pattern1" "" + (replace-regexp-in-string "pattern2" "" + (replace-regexp-in-string "pattern3" "" + ...))) +``` + +--- + +## Comparison with Other ob-* Packages + +### ob-python (Standard Library) + +**Features we should match:** + +```elisp +;; Multiple execution modes +(defcustom org-babel-python-command "python3" + "Command to execute Python code.") + +;; Proper result handling +(defun org-babel-execute:python (body params) + (let* ((session (org-babel-python-initiate-session + (cdr (assq :session params)) params)) + (result-params (cdr (assq :result-params params))) + (result-type (cdr (assq :result-type params))) + (full-body (org-babel-expand-body:generic ...)) + (result (org-babel-python-evaluate ...))) + ;; Proper result formatting + (org-babel-reassemble-table + (org-babel-result-cond result-params + result + (org-babel-python-table-or-string result)) + ...))) + +;; Variable injection +(defun org-babel-variable-assignments:python (params) + (mapcar (lambda (pair) + (format "%s=%s" (car pair) + (org-babel-python-var-to-python (cdr pair)))) + (org-babel--get-vars params))) + +;; Both session and non-session evaluation +(defun org-babel-python-evaluate (session body &optional result-type result-params) + (if session + (org-babel-python-evaluate-session ...) + (org-babel-python-evaluate-external-process ...))) +``` + +### ob-ruby (Standard Library) + +**Patterns to adopt:** + +```elisp +;; Clean wrapper for value capture +(defconst org-babel-ruby-wrapper-method + " +def main +%s +end +puts main.inspect +") + +;; Proper session management with comint +(defun org-babel-ruby-initiate-session (&optional session params) + (unless (string= session "none") + (let ((session-buffer (or (get-buffer ...) ...))) + (if (org-babel-comint-buffer-livep session-buffer) + session-buffer + (org-babel-ruby-initiate-session session))))) + +;; Type conversion +(defun org-babel-ruby-var-to-ruby (var) + (if (listp var) + (concat "[" (mapconcat #'org-babel-ruby-var-to-ruby var ", ") "]") + (format "%S" var))) +``` + +### ob-shell (Standard Library) + +**Useful patterns:** + +```elisp +;; Multiple shell types +(defcustom org-babel-shell-names + '("sh" "bash" "zsh" "fish" ...) + "Shells to register with org-babel.") + +;; Header arg for shell selection +(defconst org-babel-header-args:shell + '((shebang . :any))) + +;; Proper prep-session +(defun org-babel-prep-session:shell (session params) + (let* ((session (org-babel-sh-initiate-session session)) + (var-lines (org-babel-variable-assignments:shell params))) + (org-babel-comint-in-buffer session + (mapc (lambda (var) + (insert var) (comint-send-input nil t) + (org-babel-comint-wait-for-output session)) + var-lines)) + session)) +``` + +### Feature Comparison Matrix + +| Feature | ob-python | ob-ruby | ob-shell | ob-elixir (zweifisch) | +|--------------------|-----------|---------|----------|-----------------------| +| One-shot execution | Yes | Yes | Yes | No | +| Session support | Yes | Yes | Yes | Yes | +| `:results value` | Yes | Yes | Yes | No | +| `:results output` | Yes | Yes | Yes | Yes (only) | +| Variable injection | Yes | Yes | Yes | No | +| Table output | Yes | Yes | Yes | No | +| Error handling | Yes | Yes | Yes | No | +| Customization | Yes | Yes | Yes | No | +| Tests | Yes | Yes | Yes | No | +| Graphics support | Yes | No | No | No | +| Async support | Yes | No | No | No | + +--- + +## Feature Gap Analysis + +### Critical Gaps (Must Fix) + +1. **No `:results value` support** + - Cannot capture return value of expressions + - Users must use `IO.inspect` workarounds + - **Fix**: Implement wrapper method pattern + +2. **No variable injection** + - Cannot use `:var` header argument + - Breaks org-babel's data passing paradigm + - **Fix**: Implement `org-babel-variable-assignments:elixir` + +3. **No one-shot execution** + - Every evaluation starts/uses IEx session + - Slow for simple scripts + - **Fix**: Add external process execution mode + +4. **Fragile output parsing** + - Multiple regex replacements prone to breaking + - **Fix**: Use markers or JSON encoding + +### Important Gaps (Should Fix) + +1. **No Mix project support** + - Cannot run code in project context + - No access to project dependencies + - **Fix**: Add `:mix-project` header argument + +2. **No proper error handling** + - Errors appear as raw output + - No structured error information + - **Fix**: Detect and signal Elixir errors + +3. **No customization** + - Hardcoded paths and settings + - **Fix**: Add `defcustom` for all configurable values + +4. **No tests** + - No way to verify correctness + - **Fix**: Add comprehensive test suite + +### Nice-to-Have Gaps (Could Fix) + +1. **No async execution** + - Long-running code blocks UI + - Consider for future versions + +2. **No graphics support** + - Elixir has visualization libraries (VegaLite) + - Could add in future + +3. **No LSP integration** + - Could integrate with ElixirLS for completions + - Future enhancement + +--- + +## Recommendations + +### Approach: New Implementation + +Given the significant gaps and architectural issues in zweifisch/ob-elixir, we recommend **creating a new implementation** rather than forking. Reasons: + +1. Need to change fundamental architecture (session-only → dual mode) +2. Core data flow needs redesign +3. Limited active maintenance upstream +4. Cleaner API design possible with fresh start + +### Proposed Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ org-babel-execute:elixir │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌──────────┴──────────┐ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ Session Mode │ │ External Mode │ + │ (IEx/comint) │ │ (elixir cmd) │ + └─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ With Mix │ │ With Mix │ + │ (iex -S mix) │ │ (mix run) │ + └─────────────────┘ └─────────────────┘ +``` + +### Implementation Phases + +#### Phase 1: Core (MVP) +- [x] One-shot execution with `elixir` command +- [x] `:results value` and `:results output` support +- [x] Variable injection with `:var` +- [x] Basic error detection +- [x] Customizable commands +- [x] Test suite foundation + +#### Phase 2: Sessions +- [ ] IEx session support via comint +- [ ] Session persistence +- [ ] Proper prompt detection +- [ ] Session cleanup + +#### Phase 3: Mix Integration +- [ ] `:mix-project` header argument +- [ ] Automatic Mix project detection +- [ ] `iex -S mix` for sessions +- [ ] `mix run` for one-shot + +#### Phase 4: Advanced Features +- [ ] Remote shell (remsh) support +- [ ] Table input/output +- [ ] Async execution +- [ ] Better error messages with line numbers + +### Header Arguments to Support + +```elisp +(defconst org-babel-header-args:elixir + '(;; Standard org-babel args work automatically + ;; Language-specific: + (mix-project . :any) ; Path to mix.exs directory + (mix-env . :any) ; MIX_ENV (dev, test, prod) + (iex-args . :any) ; Extra IEx arguments + (timeout . :any) ; Execution timeout + (node-name . :any) ; --name for distributed + (node-sname . :any) ; --sname for distributed + (cookie . :any) ; Erlang cookie + (remsh . :any)) ; Remote shell node + "Elixir-specific header arguments.") +``` + +### Example Usage (Target) + +```org +* Basic Evaluation + +#+BEGIN_SRC elixir +Enum.map([1, 2, 3], &(&1 * 2)) +#+END_SRC + +#+RESULTS: +| 2 | 4 | 6 | + +* With Variables + +#+NAME: my-data +| a | 1 | +| b | 2 | + +#+BEGIN_SRC elixir :var data=my-data +Enum.map(data, fn [k, v] -> {k, v * 10} end) +#+END_SRC + +#+RESULTS: +| a | 10 | +| b | 20 | + +* In Mix Project + +#+BEGIN_SRC elixir :mix-project ~/my_app :mix-env test +MyApp.some_function() +#+END_SRC + +* With Session + +#+BEGIN_SRC elixir :session my-session +defmodule Helper do + def double(x), do: x * 2 +end +#+END_SRC + +#+BEGIN_SRC elixir :session my-session +Helper.double(21) +#+END_SRC + +#+RESULTS: +: 42 + +* Capturing Output + +#+BEGIN_SRC elixir :results output +IO.puts("Hello") +IO.puts("World") +#+END_SRC + +#+RESULTS: +: Hello +: World +``` + +--- + +## References + +- [zweifisch/ob-elixir](https://github.com/zweifisch/ob-elixir) +- [Org Mode ob-python.el](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/ob-python.el) +- [Org Mode ob-ruby.el](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/ob-ruby.el) +- [Org Mode ob-shell.el](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/ob-shell.el) +- [Org Mode ob-emacs-lisp.el](https://git.savannah.gnu.org/cgit/emacs/org-mode.git/tree/lisp/ob-emacs-lisp.el) +- [elixir-editors/emacs-elixir](https://github.com/elixir-editors/emacs-elixir) +- [Worg Babel Languages](https://orgmode.org/worg/org-contrib/babel/languages/) diff --git a/tasks/00-index.md b/tasks/00-index.md new file mode 100644 index 0000000..6ce8c3d --- /dev/null +++ b/tasks/00-index.md @@ -0,0 +1,144 @@ +# ob-elixir Implementation Tasks + +This directory contains step-by-step implementation tasks for building the ob-elixir package. + +## Overview + +The implementation is organized into 4 phases: + +| Phase | Description | Tasks | Priority | +|-------|-------------|-------|----------| +| **Phase 1** | Core MVP | 01-06 | Critical | +| **Phase 2** | Sessions | 07 | High | +| **Phase 3** | Mix Integration | 08 | High | +| **Phase 4** | Advanced Features | 09-10 | Medium/Low | + +## Task List + +### Phase 1: Core (MVP) + +These tasks implement the minimum viable product - basic Elixir code execution in org-mode. + +| Task | Title | Time | Status | +|------|-------|------|--------| +| [01](01-project-setup.md) | Project Setup | 30 min | Pending | +| [02](02-basic-execution.md) | Basic Code Execution | 1-2 hrs | Pending | +| [03](03-variable-injection.md) | Variable Injection | 1-2 hrs | Pending | +| [04](04-error-handling.md) | Error Handling | 1 hr | Pending | +| [05](05-result-formatting.md) | Result Formatting | 1-2 hrs | Pending | +| [06](06-test-suite.md) | Comprehensive Test Suite | 2-3 hrs | Pending | + +**Phase 1 Deliverables:** +- Execute Elixir code with `C-c C-c` +- `:results value` and `:results output` work +- `:var` header arguments work +- Errors are properly reported +- Lists become org tables +- Comprehensive test coverage + +### Phase 2: Sessions + +| Task | Title | Time | Status | +|------|-------|------|--------| +| [07](07-session-support.md) | IEx Session Support | 3-4 hrs | Pending | + +**Phase 2 Deliverables:** +- `:session name` creates persistent IEx sessions +- Variables and modules persist across blocks +- Session cleanup commands + +### Phase 3: Mix Integration + +| Task | Title | Time | Status | +|------|-------|------|--------| +| [08](08-mix-project-support.md) | Mix Project Support | 2-3 hrs | Pending | + +**Phase 3 Deliverables:** +- `:mix-project path` executes in project context +- Auto-detection of Mix projects +- `:mix-env` sets MIX_ENV +- Sessions with Mix (`iex -S mix`) + +### Phase 4: Advanced Features + +| Task | Title | Time | Status | +|------|-------|------|--------| +| [09](09-remote-shell.md) | Remote Shell (remsh) | 2-3 hrs | Pending | +| [10](10-async-execution.md) | Async Execution | 3-4 hrs | Pending | + +**Phase 4 Deliverables:** +- `:remsh node@host` connects to running nodes +- `:async yes` for non-blocking execution + +## Implementation Order + +``` +Phase 1 (Must complete in order) +├── 01-project-setup +├── 02-basic-execution +├── 03-variable-injection +├── 04-error-handling +├── 05-result-formatting +└── 06-test-suite + +Phase 2 (After Phase 1) +└── 07-session-support + +Phase 3 (After Phase 1, can parallel with Phase 2) +└── 08-mix-project-support + +Phase 4 (After relevant dependencies) +├── 09-remote-shell (after 07) +└── 10-async-execution (after Phase 1) +``` + +## Time Estimates + +| Phase | Estimated Time | +|-------|----------------| +| Phase 1 | 8-12 hours | +| Phase 2 | 3-4 hours | +| Phase 3 | 2-3 hours | +| Phase 4 | 5-7 hours | +| **Total** | **18-26 hours** | + +## Getting Started + +1. Start with [Task 01: Project Setup](01-project-setup.md) +2. Complete Phase 1 tasks in order +3. Phases 2-4 can be done based on your priorities + +## Task Template + +Each task file includes: + +- **Objective**: What the task accomplishes +- **Prerequisites**: What must be done first +- **Steps**: Detailed implementation steps with code +- **Tests**: Test cases to verify the implementation +- **Acceptance Criteria**: Checklist of requirements +- **Troubleshooting**: Common issues and solutions + +## Testing + +Run tests after each task: + +```bash +make test +``` + +For integration tests with org-mode: + +```bash +make test-integration +``` + +## Documentation References + +| Document | Content | +|----------|---------| +| [01-emacs-elisp-best-practices.md](../docs/01-emacs-elisp-best-practices.md) | Elisp conventions | +| [02-testing-emacs-elisp.md](../docs/02-testing-emacs-elisp.md) | Testing strategies | +| [03-org-babel-implementation-guide.md](../docs/03-org-babel-implementation-guide.md) | Org-babel internals | +| [04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) | Elixir execution | +| [05-existing-implementations-analysis.md](../docs/05-existing-implementations-analysis.md) | Prior art analysis | diff --git a/tasks/01-project-setup.md b/tasks/01-project-setup.md new file mode 100644 index 0000000..094e4fa --- /dev/null +++ b/tasks/01-project-setup.md @@ -0,0 +1,216 @@ +# Task 01: Project Setup + +**Phase**: 1 - Core (MVP) +**Priority**: Critical +**Estimated Time**: 30 minutes +**Dependencies**: None + +## Objective + +Set up the project structure with proper Emacs Lisp package conventions, including file headers, licensing, and build tooling. + +## Prerequisites + +- Emacs 27.1+ installed +- Elixir installed and in PATH +- Git repository initialized + +## Steps + +### Step 1: Create the main package file + +Create `ob-elixir.el` with proper headers: + +```elisp +;;; ob-elixir.el --- Org Babel functions for Elixir -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Your Name + +;; Author: Your Name +;; URL: https://github.com/username/ob-elixir +;; Keywords: literate programming, reproducible research, elixir +;; Version: 0.1.0 +;; Package-Requires: ((emacs "27.1") (org "9.4")) + +;; This file is not part of GNU Emacs. + +;; This program is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;;; Commentary: + +;; Org Babel support for evaluating Elixir code blocks. +;; +;; Features: +;; - Execute Elixir code in org-mode source blocks +;; - Support for :results value and :results output +;; - Variable passing with :var header argument +;; - Mix project context support +;; +;; Usage: +;; Add (elixir . t) to `org-babel-load-languages': +;; +;; (org-babel-do-load-languages +;; 'org-babel-load-languages +;; '((elixir . t))) + +;;; Code: + +(require 'ob) +(require 'ob-eval) + +(provide 'ob-elixir) +;;; ob-elixir.el ends here +``` + +### Step 2: Create the Eldev file for build tooling + +Create `Eldev` file: + +```elisp +; -*- mode: emacs-lisp; lexical-binding: t; -*- + +(eldev-use-package-archive 'gnu) +(eldev-use-package-archive 'melpa) + +;; Test dependencies +(eldev-add-extra-dependencies 'test 'ert) + +;; Use ERT for testing +(setf eldev-test-framework 'ert) + +;; Lint configuration +(setf eldev-lint-default '(elisp package)) +``` + +### Step 3: Create test directory structure + +```bash +mkdir -p test +``` + +Create `test/test-ob-elixir.el`: + +```elisp +;;; test-ob-elixir.el --- Tests for ob-elixir -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Test suite for ob-elixir package. + +;;; Code: + +(require 'ert) + +;; Add parent directory to load path +(let ((dir (file-name-directory (or load-file-name buffer-file-name)))) + (add-to-list 'load-path (expand-file-name ".." dir))) + +(require 'ob-elixir) + +(ert-deftest ob-elixir-test-package-loads () + "Test that the package loads successfully." + (should (featurep 'ob-elixir))) + +(provide 'test-ob-elixir) +;;; test-ob-elixir.el ends here +``` + +### Step 4: Create Makefile + +Create `Makefile`: + +```makefile +EMACS ?= emacs +BATCH = $(EMACS) -Q -batch -L . + +.PHONY: all compile test lint clean + +all: compile test + +compile: + $(BATCH) -f batch-byte-compile ob-elixir.el + +test: + $(BATCH) -l ert -l test/test-ob-elixir.el \ + -f ert-run-tests-batch-and-exit + +lint: + $(BATCH) --eval "(require 'package)" \ + --eval "(package-initialize)" \ + --eval "(package-refresh-contents)" \ + --eval "(package-install 'package-lint)" \ + -l package-lint \ + -f package-lint-batch-and-exit ob-elixir.el + +clean: + rm -f *.elc test/*.elc +``` + +### Step 5: Create .gitignore + +Create `.gitignore`: + +``` +# Byte-compiled files +*.elc + +# Eldev +.eldev/ +Eldev-local + +# Package archives +/packages/ + +# Test artifacts +/test/tmp/ + +# Editor +*~ +\#*\# +.#* + +# OS +.DS_Store +``` + +### Step 6: Verify setup + +Run the following commands to verify: + +```bash +# Check Emacs version +emacs --version + +# Check Elixir version +elixir --version + +# Run tests +make test + +# Compile +make compile +``` + +## Acceptance Criteria + +- [ ] `ob-elixir.el` exists with proper headers +- [ ] Package loads without errors: `(require 'ob-elixir)` +- [ ] `make test` runs successfully +- [ ] `make compile` produces no warnings +- [ ] All files follow Emacs Lisp conventions + +## Files Created + +- `ob-elixir.el` - Main package file +- `Eldev` - Build tool configuration +- `Makefile` - Make targets +- `test/test-ob-elixir.el` - Test file +- `.gitignore` - Git ignore rules + +## References + +- [docs/01-emacs-elisp-best-practices.md](../docs/01-emacs-elisp-best-practices.md) +- [docs/02-testing-emacs-elisp.md](../docs/02-testing-emacs-elisp.md) diff --git a/tasks/02-basic-execution.md b/tasks/02-basic-execution.md new file mode 100644 index 0000000..7a61234 --- /dev/null +++ b/tasks/02-basic-execution.md @@ -0,0 +1,242 @@ +# Task 02: Basic Code Execution + +**Phase**: 1 - Core (MVP) +**Priority**: Critical +**Estimated Time**: 1-2 hours +**Dependencies**: Task 01 (Project Setup) + +## Objective + +Implement the core `org-babel-execute:elixir` function that can execute Elixir code blocks using external process (one-shot execution). + +## Prerequisites + +- Task 01 completed +- Elixir installed and accessible via `elixir` command + +## Steps + +### Step 1: Add customization group and variables + +Add to `ob-elixir.el`: + +```elisp +;;; Customization + +(defgroup ob-elixir nil + "Org Babel support for Elixir." + :group 'org-babel + :prefix "ob-elixir-") + +(defcustom ob-elixir-command "elixir" + "Command to execute Elixir code. +Can be a full path or command name if in PATH." + :type 'string + :group 'ob-elixir + :safe #'stringp) +``` + +### Step 2: Add default header arguments + +```elisp +;;; Header Arguments + +(defvar org-babel-default-header-args:elixir + '((:results . "value") + (:session . "none")) + "Default header arguments for Elixir code blocks.") +``` + +### Step 3: Register the language + +```elisp +;;; Language Registration + +;; File extension for tangling +(add-to-list 'org-babel-tangle-lang-exts '("elixir" . "ex")) + +;; Associate with elixir-mode for syntax highlighting (if available) +(with-eval-after-load 'org-src + (add-to-list 'org-src-lang-modes '("elixir" . elixir))) +``` + +### Step 4: Implement the execute function + +```elisp +;;; Execution + +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with org-babel. + +BODY is the Elixir code to execute. +PARAMS is an alist of header arguments. + +This function is called by `org-babel-execute-src-block'." + (let* ((result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + (result (ob-elixir--execute body result-type))) + (org-babel-reassemble-table + (org-babel-result-cond result-params + result + (org-babel-script-escape result)) + (org-babel-pick-name (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))))) +``` + +### Step 5: Implement the internal execute function + +```elisp +(defun ob-elixir--execute (body result-type) + "Execute BODY as Elixir code. + +RESULT-TYPE is either `value' or `output'. +For `value', wraps code to capture return value. +For `output', captures stdout directly. + +Returns the result as a string." + (let* ((tmp-file (org-babel-temp-file "ob-elixir-" ".exs")) + (code (if (eq result-type 'value) + (ob-elixir--wrap-for-value body) + body))) + (with-temp-file tmp-file + (insert code)) + (let ((result (org-babel-eval + (format "%s %s" + ob-elixir-command + (org-babel-process-file-name tmp-file)) + ""))) + (string-trim result)))) +``` + +### Step 6: Implement the value wrapper + +```elisp +(defconst ob-elixir--value-wrapper + "result = ( +%s +) +IO.puts(inspect(result, limit: :infinity, printable_limit: :infinity, charlists: :as_lists)) +" + "Wrapper template for capturing Elixir expression value. +%s is replaced with the user's code.") + +(defun ob-elixir--wrap-for-value (body) + "Wrap BODY to capture its return value. + +The wrapper evaluates BODY, then prints the result using +`inspect/2` with infinite limits to avoid truncation." + (format ob-elixir--value-wrapper body)) +``` + +### Step 7: Add tests + +Add to `test/test-ob-elixir.el`: + +```elisp +(ert-deftest ob-elixir-test-elixir-available () + "Test that Elixir is available." + (should (executable-find ob-elixir-command))) + +(ert-deftest ob-elixir-test-simple-value () + "Test simple value evaluation." + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--execute "1 + 1" 'value))) + (should (equal "2" result)))) + +(ert-deftest ob-elixir-test-simple-output () + "Test simple output evaluation." + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--execute "IO.puts(\"hello\")" 'output))) + (should (equal "hello" result)))) + +(ert-deftest ob-elixir-test-multiline-value () + "Test multiline code value evaluation." + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--execute "x = 10\ny = 20\nx + y" 'value))) + (should (equal "30" result)))) + +(ert-deftest ob-elixir-test-list-result () + "Test list result." + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--execute "[1, 2, 3]" 'value))) + (should (equal "[1, 2, 3]" result)))) + +(ert-deftest ob-elixir-test-map-result () + "Test map result." + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--execute "%{a: 1, b: 2}" 'value))) + (should (string-match-p "%{a: 1, b: 2}" result)))) +``` + +### Step 8: Test in an org buffer + +Create a test org file `test.org`: + +```org +* Test ob-elixir + +** Basic arithmetic (value) + +#+BEGIN_SRC elixir +1 + 1 +#+END_SRC + +** Output test + +#+BEGIN_SRC elixir :results output +IO.puts("Hello, World!") +#+END_SRC + +** List manipulation + +#+BEGIN_SRC elixir +Enum.map([1, 2, 3], fn x -> x * 2 end) +#+END_SRC +``` + +Press `C-c C-c` on each block to test. + +## Acceptance Criteria + +- [ ] `org-babel-execute:elixir` function exists +- [ ] Simple expressions evaluate correctly: `1 + 1` returns `2` +- [ ] `:results value` captures return value (default) +- [ ] `:results output` captures stdout +- [ ] Multiline code executes correctly +- [ ] Lists and maps are returned in Elixir format +- [ ] All tests pass: `make test` + +## Troubleshooting + +### "Cannot find elixir" + +Ensure Elixir is in PATH: +```bash +which elixir +elixir --version +``` + +Or set the full path: +```elisp +(setq ob-elixir-command "/usr/local/bin/elixir") +``` + +### Results are truncated + +The wrapper uses `limit: :infinity` to prevent truncation. If still truncated, check for very large outputs. + +### ANSI codes in output + +We'll handle this in a later task. For now, output should be clean with the current approach. + +## Files Modified + +- `ob-elixir.el` - Add execution functions +- `test/test-ob-elixir.el` - Add execution tests + +## References + +- [docs/03-org-babel-implementation-guide.md](../docs/03-org-babel-implementation-guide.md) +- [docs/04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) diff --git a/tasks/03-variable-injection.md b/tasks/03-variable-injection.md new file mode 100644 index 0000000..ee4858a --- /dev/null +++ b/tasks/03-variable-injection.md @@ -0,0 +1,334 @@ +# Task 03: Variable Injection + +**Phase**: 1 - Core (MVP) +**Priority**: High +**Estimated Time**: 1-2 hours +**Dependencies**: Task 02 (Basic Execution) + +## Objective + +Implement variable injection so that `:var` header arguments work correctly, allowing data to be passed from org-mode into Elixir code blocks. + +## Prerequisites + +- Task 02 completed +- Basic execution working + +## Background + +Org-babel allows passing variables to code blocks: + +```org +#+BEGIN_SRC elixir :var x=5 :var name="Alice" +"Hello, #{name}! x = #{x}" +#+END_SRC +``` + +We need to: +1. Convert Elisp values to Elixir syntax +2. Generate Elixir variable assignment statements +3. Prepend these to the code before execution + +## Steps + +### Step 1: Implement Elisp to Elixir conversion + +Add to `ob-elixir.el`: + +```elisp +;;; Type Conversion + +(defun ob-elixir--elisp-to-elixir (value) + "Convert Elisp VALUE to Elixir literal syntax. + +Handles: +- nil -> nil +- t -> true +- numbers -> numbers +- strings -> quoted strings +- symbols -> atoms +- lists -> Elixir lists +- vectors -> tuples" + (cond + ;; nil + ((null value) "nil") + + ;; Boolean true + ((eq value t) "true") + + ;; Numbers + ((numberp value) + (number-to-string value)) + + ;; Strings + ((stringp value) + (format "\"%s\"" (ob-elixir--escape-string value))) + + ;; Symbols become atoms (except special ones) + ((symbolp value) + (let ((name (symbol-name value))) + (cond + ((string= name "hline") ":hline") + ((string-match-p "^[a-z_][a-zA-Z0-9_]*[?!]?$" name) + (format ":%s" name)) + (t (format ":\"%s\"" name))))) + + ;; Vectors become tuples + ((vectorp value) + (format "{%s}" + (mapconcat #'ob-elixir--elisp-to-elixir + (append value nil) ", "))) + + ;; Lists + ((listp value) + (format "[%s]" + (mapconcat #'ob-elixir--elisp-to-elixir value ", "))) + + ;; Fallback + (t (format "%S" value)))) +``` + +### Step 2: Implement string escaping + +```elisp +(defun ob-elixir--escape-string (str) + "Escape special characters in STR for Elixir string literal." + (let ((result str)) + ;; Escape backslashes first + (setq result (replace-regexp-in-string "\\\\" "\\\\\\\\" result)) + ;; Escape double quotes + (setq result (replace-regexp-in-string "\"" "\\\\\"" result)) + ;; Escape newlines + (setq result (replace-regexp-in-string "\n" "\\\\n" result)) + ;; Escape tabs + (setq result (replace-regexp-in-string "\t" "\\\\t" result)) + result)) +``` + +### Step 3: Implement variable assignments function + +```elisp +;;; Variable Handling + +(defun org-babel-variable-assignments:elixir (params) + "Return list of Elixir statements assigning variables from PARAMS. + +Each statement has the form: var_name = value" + (mapcar + (lambda (pair) + (let ((name (car pair)) + (value (cdr pair))) + (format "%s = %s" + (ob-elixir--var-name name) + (ob-elixir--elisp-to-elixir value)))) + (org-babel--get-vars params))) + +(defun ob-elixir--var-name (name) + "Convert NAME to a valid Elixir variable name. + +Elixir variables must start with lowercase or underscore." + (let ((str (if (symbolp name) (symbol-name name) name))) + ;; Ensure starts with lowercase or underscore + (if (string-match-p "^[a-z_]" str) + str + (concat "_" str)))) +``` + +### Step 4: Update the execute function + +Modify `org-babel-execute:elixir` to use variable assignments: + +```elisp +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with org-babel. + +BODY is the Elixir code to execute. +PARAMS is an alist of header arguments. + +This function is called by `org-babel-execute-src-block'." + (let* ((result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + ;; Expand body with variable assignments + (full-body (org-babel-expand-body:generic + body params + (org-babel-variable-assignments:elixir params))) + (result (ob-elixir--execute full-body result-type))) + (org-babel-reassemble-table + (org-babel-result-cond result-params + result + (org-babel-script-escape result)) + (org-babel-pick-name (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))))) +``` + +### Step 5: Add tests for type conversion + +Add to `test/test-ob-elixir.el`: + +```elisp +;;; Type Conversion Tests + +(ert-deftest ob-elixir-test-convert-nil () + "Test nil conversion." + (should (equal "nil" (ob-elixir--elisp-to-elixir nil)))) + +(ert-deftest ob-elixir-test-convert-true () + "Test t conversion." + (should (equal "true" (ob-elixir--elisp-to-elixir t)))) + +(ert-deftest ob-elixir-test-convert-integer () + "Test integer conversion." + (should (equal "42" (ob-elixir--elisp-to-elixir 42))) + (should (equal "-10" (ob-elixir--elisp-to-elixir -10)))) + +(ert-deftest ob-elixir-test-convert-float () + "Test float conversion." + (should (equal "3.14" (ob-elixir--elisp-to-elixir 3.14)))) + +(ert-deftest ob-elixir-test-convert-string () + "Test string conversion." + (should (equal "\"hello\"" (ob-elixir--elisp-to-elixir "hello")))) + +(ert-deftest ob-elixir-test-convert-string-escaping () + "Test string escaping." + (should (equal "\"say \\\"hi\\\"\"" + (ob-elixir--elisp-to-elixir "say \"hi\""))) + (should (equal "\"line1\\nline2\"" + (ob-elixir--elisp-to-elixir "line1\nline2")))) + +(ert-deftest ob-elixir-test-convert-symbol () + "Test symbol conversion to atom." + (should (equal ":foo" (ob-elixir--elisp-to-elixir 'foo))) + (should (equal ":ok" (ob-elixir--elisp-to-elixir 'ok)))) + +(ert-deftest ob-elixir-test-convert-list () + "Test list conversion." + (should (equal "[1, 2, 3]" (ob-elixir--elisp-to-elixir '(1 2 3)))) + (should (equal "[\"a\", \"b\"]" (ob-elixir--elisp-to-elixir '("a" "b"))))) + +(ert-deftest ob-elixir-test-convert-nested-list () + "Test nested list conversion." + (should (equal "[[1, 2], [3, 4]]" + (ob-elixir--elisp-to-elixir '((1 2) (3 4)))))) + +(ert-deftest ob-elixir-test-convert-vector () + "Test vector to tuple conversion." + (should (equal "{1, 2, 3}" (ob-elixir--elisp-to-elixir [1 2 3])))) +``` + +### Step 6: Add tests for variable injection + +```elisp +;;; Variable Injection Tests + +(ert-deftest ob-elixir-test-variable-assignments () + "Test variable assignment generation." + (let ((params '((:var . ("x" . 5)) + (:var . ("name" . "Alice"))))) + (let ((assignments (org-babel-variable-assignments:elixir params))) + (should (member "x = 5" assignments)) + (should (member "name = \"Alice\"" assignments))))) + +(ert-deftest ob-elixir-test-var-execution () + "Test code execution with variables." + (skip-unless (executable-find ob-elixir-command)) + (let* ((params '((:var . ("x" . 10)))) + (var-lines (org-babel-variable-assignments:elixir params)) + (full-body (concat (mapconcat #'identity var-lines "\n") + "\nx * 2"))) + (should (equal "20" (ob-elixir--execute full-body 'value))))) + +(ert-deftest ob-elixir-test-var-list () + "Test passing list as variable." + (skip-unless (executable-find ob-elixir-command)) + (let* ((params '((:var . ("data" . (1 2 3))))) + (var-lines (org-babel-variable-assignments:elixir params)) + (full-body (concat (mapconcat #'identity var-lines "\n") + "\nEnum.sum(data)"))) + (should (equal "6" (ob-elixir--execute full-body 'value))))) +``` + +### Step 7: Test in an org buffer + +Update `test.org`: + +```org +* Variable Injection Tests + +** Simple variable + +#+BEGIN_SRC elixir :var x=42 +x * 2 +#+END_SRC + +#+RESULTS: +: 84 + +** String variable + +#+BEGIN_SRC elixir :var name="World" +"Hello, #{name}!" +#+END_SRC + +#+RESULTS: +: Hello, World! + +** Multiple variables + +#+BEGIN_SRC elixir :var x=10 :var y=20 +x + y +#+END_SRC + +#+RESULTS: +: 30 + +** List variable + +#+BEGIN_SRC elixir :var numbers='(1 2 3 4 5) +Enum.sum(numbers) +#+END_SRC + +#+RESULTS: +: 15 + +** Table as variable + +#+NAME: my-data +| a | 1 | +| b | 2 | +| c | 3 | + +#+BEGIN_SRC elixir :var data=my-data +Enum.map(data, fn [k, v] -> "#{k}=#{v}" end) +#+END_SRC +``` + +## Acceptance Criteria + +- [ ] `ob-elixir--elisp-to-elixir` correctly converts all Elisp types +- [ ] `org-babel-variable-assignments:elixir` generates valid Elixir code +- [ ] `:var x=5` works in org blocks +- [ ] `:var name="string"` works with string values +- [ ] Multiple `:var` arguments work +- [ ] Lists and tables can be passed as variables +- [ ] All tests pass: `make test` + +## Edge Cases to Consider + +1. **Variable name conflicts**: Elixir variables must start with lowercase +2. **Special characters in strings**: Quotes, newlines, backslashes +3. **Empty lists**: Should produce `[]` +4. **Mixed type lists**: `[1, "two", :three]` +5. **hline in tables**: Special symbol for table separators + +## Files Modified + +- `ob-elixir.el` - Add variable handling functions +- `test/test-ob-elixir.el` - Add variable tests + +## References + +- [docs/03-org-babel-implementation-guide.md](../docs/03-org-babel-implementation-guide.md) - Variable Handling section +- [docs/04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) - Data Type Conversion section diff --git a/tasks/04-error-handling.md b/tasks/04-error-handling.md new file mode 100644 index 0000000..ca79d96 --- /dev/null +++ b/tasks/04-error-handling.md @@ -0,0 +1,296 @@ +# Task 04: Error Handling + +**Phase**: 1 - Core (MVP) +**Priority**: High +**Estimated Time**: 1 hour +**Dependencies**: Task 02 (Basic Execution) + +## Objective + +Implement proper error detection and reporting for Elixir code execution, so users get meaningful feedback when their code fails. + +## Prerequisites + +- Task 02 completed +- Basic execution working + +## Background + +Currently, Elixir errors are returned as raw output. We need to: +1. Detect when Elixir reports an error +2. Extract useful error information +3. Present errors clearly to the user +4. Optionally signal Emacs error conditions + +## Steps + +### Step 1: Define error patterns + +Add to `ob-elixir.el`: + +```elisp +;;; Error Handling + +(defconst ob-elixir--error-regexp + "^\\*\\* (\\([A-Za-z.]+Error\\))\\(.*\\)" + "Regexp matching Elixir runtime errors. +Group 1 is the error type, group 2 is the message.") + +(defconst ob-elixir--compile-error-regexp + "^\\*\\* (\\(CompileError\\|TokenMissingError\\|SyntaxError\\))\\(.*\\)" + "Regexp matching Elixir compile-time errors.") + +(defconst ob-elixir--warning-regexp + "^warning: \\(.*\\)" + "Regexp matching Elixir warnings.") +``` + +### Step 2: Define custom error types + +```elisp +(define-error 'ob-elixir-error + "Elixir evaluation error") + +(define-error 'ob-elixir-compile-error + "Elixir compilation error" + 'ob-elixir-error) + +(define-error 'ob-elixir-runtime-error + "Elixir runtime error" + 'ob-elixir-error) +``` + +### Step 3: Implement error detection + +```elisp +(defun ob-elixir--detect-error (output) + "Check OUTPUT for Elixir errors. + +Returns a plist with :type, :message, and :line if an error is found. +Returns nil if no error detected." + (cond + ;; Compile-time error + ((string-match ob-elixir--compile-error-regexp output) + (list :type 'compile + :error-type (match-string 1 output) + :message (string-trim (match-string 2 output)) + :full-output output)) + + ;; Runtime error + ((string-match ob-elixir--error-regexp output) + (list :type 'runtime + :error-type (match-string 1 output) + :message (string-trim (match-string 2 output)) + :full-output output)) + + ;; No error + (t nil))) +``` + +### Step 4: Implement error formatting + +```elisp +(defun ob-elixir--format-error (error-info) + "Format ERROR-INFO into a user-friendly message." + (let ((type (plist-get error-info :type)) + (error-type (plist-get error-info :error-type)) + (message (plist-get error-info :message))) + (format "Elixir %s: (%s) %s" + (if (eq type 'compile) "Compile Error" "Runtime Error") + error-type + message))) + +(defcustom ob-elixir-signal-errors t + "Whether to signal Emacs errors on Elixir execution failure. + +When non-nil, Elixir errors will be signaled as Emacs errors. +When nil, errors are returned as the result string." + :type 'boolean + :group 'ob-elixir) +``` + +### Step 5: Update the execute function + +Modify `ob-elixir--execute`: + +```elisp +(defun ob-elixir--execute (body result-type) + "Execute BODY as Elixir code. + +RESULT-TYPE is either `value' or `output'. +For `value', wraps code to capture return value. +For `output', captures stdout directly. + +Returns the result as a string. +May signal `ob-elixir-error' if execution fails and +`ob-elixir-signal-errors' is non-nil." + (let* ((tmp-file (org-babel-temp-file "ob-elixir-" ".exs")) + (code (if (eq result-type 'value) + (ob-elixir--wrap-for-value body) + body))) + (with-temp-file tmp-file + (insert code)) + (let ((result (org-babel-eval + (format "%s %s" + ob-elixir-command + (org-babel-process-file-name tmp-file)) + ""))) + (ob-elixir--process-result result)))) + +(defun ob-elixir--process-result (result) + "Process RESULT from Elixir execution. + +Checks for errors and handles them according to `ob-elixir-signal-errors'. +Returns the cleaned result string." + (let ((trimmed (string-trim result)) + (error-info (ob-elixir--detect-error result))) + (if error-info + (if ob-elixir-signal-errors + (signal (if (eq (plist-get error-info :type) 'compile) + 'ob-elixir-compile-error + 'ob-elixir-runtime-error) + (list (ob-elixir--format-error error-info))) + ;; Return error as result + (plist-get error-info :full-output)) + ;; No error, return trimmed result + trimmed))) +``` + +### Step 6: Handle warnings + +```elisp +(defcustom ob-elixir-show-warnings t + "Whether to include warnings in output. + +When non-nil, Elixir warnings are included in the result. +When nil, warnings are stripped from the output." + :type 'boolean + :group 'ob-elixir) + +(defun ob-elixir--strip-warnings (output) + "Remove warning lines from OUTPUT if configured." + (if ob-elixir-show-warnings + output + (let ((lines (split-string output "\n"))) + (mapconcat #'identity + (cl-remove-if (lambda (line) + (string-match-p ob-elixir--warning-regexp line)) + lines) + "\n")))) +``` + +### Step 7: Add tests + +Add to `test/test-ob-elixir.el`: + +```elisp +;;; Error Handling Tests + +(ert-deftest ob-elixir-test-detect-runtime-error () + "Test runtime error detection." + (let ((output "** (RuntimeError) something went wrong")) + (let ((error-info (ob-elixir--detect-error output))) + (should error-info) + (should (eq 'runtime (plist-get error-info :type))) + (should (equal "RuntimeError" (plist-get error-info :error-type)))))) + +(ert-deftest ob-elixir-test-detect-compile-error () + "Test compile error detection." + (let ((output "** (CompileError) test.exs:1: undefined function foo/0")) + (let ((error-info (ob-elixir--detect-error output))) + (should error-info) + (should (eq 'compile (plist-get error-info :type))) + (should (equal "CompileError" (plist-get error-info :error-type)))))) + +(ert-deftest ob-elixir-test-no-error () + "Test that valid output is not detected as error." + (should-not (ob-elixir--detect-error "42")) + (should-not (ob-elixir--detect-error "[1, 2, 3]")) + (should-not (ob-elixir--detect-error "\"hello\""))) + +(ert-deftest ob-elixir-test-error-execution () + "Test that errors are properly handled during execution." + (skip-unless (executable-find ob-elixir-command)) + (let ((ob-elixir-signal-errors nil)) + (let ((result (ob-elixir--execute "raise \"test error\"" 'value))) + (should (string-match-p "RuntimeError" result))))) + +(ert-deftest ob-elixir-test-error-signaling () + "Test that errors are signaled when configured." + (skip-unless (executable-find ob-elixir-command)) + (let ((ob-elixir-signal-errors t)) + (should-error (ob-elixir--execute "raise \"test error\"" 'value) + :type 'ob-elixir-runtime-error))) + +(ert-deftest ob-elixir-test-undefined-function () + "Test handling of undefined function error." + (skip-unless (executable-find ob-elixir-command)) + (let ((ob-elixir-signal-errors nil)) + (let ((result (ob-elixir--execute "undefined_function()" 'value))) + (should (string-match-p "\\(UndefinedFunctionError\\|CompileError\\)" result))))) +``` + +### Step 8: Test in an org buffer + +Add to `test.org`: + +```org +* Error Handling Tests + +** Runtime Error + +#+BEGIN_SRC elixir +raise "This is a test error" +#+END_SRC + +** Compile Error + +#+BEGIN_SRC elixir +def incomplete_function( +#+END_SRC + +** Undefined Function + +#+BEGIN_SRC elixir +this_function_does_not_exist() +#+END_SRC + +** Warning (should still execute) + +#+BEGIN_SRC elixir +x = 1 +y = 2 +x # y is unused, may generate warning +#+END_SRC +``` + +## Acceptance Criteria + +- [ ] Runtime errors are detected (e.g., `raise "error"`) +- [ ] Compile errors are detected (e.g., syntax errors) +- [ ] Errors are formatted with type and message +- [ ] `ob-elixir-signal-errors` controls error behavior +- [ ] Warnings are handled according to `ob-elixir-show-warnings` +- [ ] Valid output is not mistakenly detected as errors +- [ ] All tests pass: `make test` + +## Error Types to Handle + +| Error Type | Example | Detection | +|------------|---------|-----------| +| RuntimeError | `raise "msg"` | `** (RuntimeError)` | +| ArgumentError | Bad function arg | `** (ArgumentError)` | +| ArithmeticError | Division by zero | `** (ArithmeticError)` | +| CompileError | Syntax error | `** (CompileError)` | +| SyntaxError | Invalid syntax | `** (SyntaxError)` | +| TokenMissingError | Missing end | `** (TokenMissingError)` | +| UndefinedFunctionError | Unknown function | `** (UndefinedFunctionError)` | + +## Files Modified + +- `ob-elixir.el` - Add error handling functions +- `test/test-ob-elixir.el` - Add error handling tests + +## References + +- [docs/04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) - Error Handling section diff --git a/tasks/05-result-formatting.md b/tasks/05-result-formatting.md new file mode 100644 index 0000000..e28f7b8 --- /dev/null +++ b/tasks/05-result-formatting.md @@ -0,0 +1,330 @@ +# Task 05: Result Formatting and Table Support + +**Phase**: 1 - Core (MVP) +**Priority**: Medium +**Estimated Time**: 1-2 hours +**Dependencies**: Task 02 (Basic Execution), Task 03 (Variable Injection) + +## Objective + +Implement proper result formatting so Elixir lists become org tables and results are properly parsed back into Elisp data structures. + +## Prerequisites + +- Task 02 and 03 completed +- Basic execution and variables working + +## Background + +Org-babel can display results as: +- Scalar values (`:results scalar`) +- Tables (`:results table`) +- Raw org markup (`:results raw`) +- Verbatim (`:results verbatim`) + +When Elixir returns a list like `[[1, 2], [3, 4]]`, org should display it as a table: + +``` +| 1 | 2 | +| 3 | 4 | +``` + +## Steps + +### Step 1: Implement result parsing + +Add to `ob-elixir.el`: + +```elisp +;;; Result Formatting + +(defun ob-elixir--table-or-string (result) + "Convert RESULT to Emacs table or string. + +If RESULT looks like a list, parse it into an Elisp list. +Otherwise return as string. + +Uses `org-babel-script-escape' for parsing." + (let ((trimmed (string-trim result))) + (cond + ;; Empty result + ((string-empty-p trimmed) nil) + + ;; Looks like a list - try to parse + ((string-match-p "^\\[.*\\]$" trimmed) + (condition-case nil + (let ((parsed (org-babel-script-escape trimmed))) + (ob-elixir--sanitize-table parsed)) + (error trimmed))) + + ;; Looks like a tuple - convert to list first + ((string-match-p "^{.*}$" trimmed) + (condition-case nil + (let* ((as-list (replace-regexp-in-string + "^{\\(.*\\)}$" "[\\1]" trimmed)) + (parsed (org-babel-script-escape as-list))) + (ob-elixir--sanitize-table parsed)) + (error trimmed))) + + ;; Scalar value + (t trimmed)))) +``` + +### Step 2: Implement table sanitization + +```elisp +(defvar ob-elixir-nil-to 'hline + "Elisp value to use for Elixir nil in table cells. + +When nil appears in an Elixir list that becomes a table, +it is replaced with this value. Use `hline' for org table +horizontal lines, or nil for empty cells.") + +(defun ob-elixir--sanitize-table (data) + "Sanitize DATA for use as an org table. + +Replaces nil values according to `ob-elixir-nil-to'. +Ensures consistent structure for table rendering." + (cond + ;; Not a list - return as-is + ((not (listp data)) data) + + ;; Empty list + ((null data) nil) + + ;; List of lists - could be table + ((and (listp (car data)) (not (null (car data)))) + (mapcar #'ob-elixir--sanitize-row data)) + + ;; Simple list - single row + (t (ob-elixir--sanitize-row data)))) + +(defun ob-elixir--sanitize-row (row) + "Sanitize a single ROW for table display." + (if (listp row) + (mapcar (lambda (cell) + (cond + ((null cell) ob-elixir-nil-to) + ((eq cell 'nil) ob-elixir-nil-to) + (t cell))) + row) + row)) +``` + +### Step 3: Handle keyword lists and maps + +```elisp +(defun ob-elixir--parse-keyword-list (str) + "Parse STR as Elixir keyword list into alist. + +Handles format like: [a: 1, b: 2]" + (when (string-match "^\\[\\(.*\\)\\]$" str) + (let ((content (match-string 1 str))) + (when (string-match-p "^[a-z_]+:" content) + (let ((pairs '())) + (dolist (part (split-string content ", ")) + (when (string-match "^\\([a-z_]+\\):\\s-*\\(.+\\)$" part) + (push (cons (intern (match-string 1 part)) + (ob-elixir--parse-value (match-string 2 part))) + pairs))) + (nreverse pairs)))))) + +(defun ob-elixir--parse-value (str) + "Parse STR as a simple Elixir value." + (let ((trimmed (string-trim str))) + (cond + ((string= trimmed "nil") nil) + ((string= trimmed "true") t) + ((string= trimmed "false") nil) + ((string-match-p "^[0-9]+$" trimmed) + (string-to-number trimmed)) + ((string-match-p "^[0-9]+\\.[0-9]+$" trimmed) + (string-to-number trimmed)) + ((string-match-p "^\".*\"$" trimmed) + (substring trimmed 1 -1)) + ((string-match-p "^:.*$" trimmed) + (intern (substring trimmed 1))) + (t trimmed)))) +``` + +### Step 4: Update the execute function + +Ensure `org-babel-execute:elixir` uses the parsing: + +```elisp +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with org-babel. + +BODY is the Elixir code to execute. +PARAMS is an alist of header arguments." + (let* ((result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + (full-body (org-babel-expand-body:generic + body params + (org-babel-variable-assignments:elixir params))) + (result (ob-elixir--execute full-body result-type))) + (org-babel-reassemble-table + (org-babel-result-cond result-params + ;; For output/scalar/verbatim - return as-is + result + ;; For value - parse into Elisp data + (ob-elixir--table-or-string result)) + (org-babel-pick-name (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))))) +``` + +### Step 5: Support column names + +```elisp +(defun ob-elixir--maybe-add-colnames (result params) + "Add column names to RESULT if specified in PARAMS." + (let ((colnames (cdr (assq :colnames params)))) + (if (and colnames (listp result) (listp (car result))) + (cons (if (listp colnames) colnames (car result)) + (if (listp colnames) result (cdr result))) + result))) +``` + +### Step 6: Add tests + +Add to `test/test-ob-elixir.el`: + +```elisp +;;; Result Formatting Tests + +(ert-deftest ob-elixir-test-parse-simple-list () + "Test parsing simple list result." + (should (equal '(1 2 3) (ob-elixir--table-or-string "[1, 2, 3]")))) + +(ert-deftest ob-elixir-test-parse-nested-list () + "Test parsing nested list (table) result." + (should (equal '((1 2) (3 4)) + (ob-elixir--table-or-string "[[1, 2], [3, 4]]")))) + +(ert-deftest ob-elixir-test-parse-tuple () + "Test parsing tuple result." + (should (equal '(1 2 3) (ob-elixir--table-or-string "{1, 2, 3}")))) + +(ert-deftest ob-elixir-test-parse-scalar () + "Test that scalars are returned as strings." + (should (equal "42" (ob-elixir--table-or-string "42"))) + (should (equal ":ok" (ob-elixir--table-or-string ":ok")))) + +(ert-deftest ob-elixir-test-parse-string () + "Test parsing string result." + (should (equal "\"hello\"" (ob-elixir--table-or-string "\"hello\"")))) + +(ert-deftest ob-elixir-test-sanitize-table-nil () + "Test that nil values are sanitized in tables." + (let ((ob-elixir-nil-to 'hline)) + (should (equal '((1 hline) (hline 2)) + (ob-elixir--sanitize-table '((1 nil) (nil 2))))))) + +(ert-deftest ob-elixir-test-execution-returns-table () + "Test that list results become tables." + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--table-or-string + (ob-elixir--execute "[[1, 2], [3, 4]]" 'value)))) + (should (equal '((1 2) (3 4)) result)))) + +(ert-deftest ob-elixir-test-mixed-list () + "Test parsing mixed-type list." + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--table-or-string + (ob-elixir--execute "[1, \"two\", :three]" 'value)))) + (should (listp result)) + (should (= 3 (length result))))) +``` + +### Step 7: Test in an org buffer + +Add to `test.org`: + +```org +* Result Formatting Tests + +** Simple list as table row + +#+BEGIN_SRC elixir +[1, 2, 3, 4, 5] +#+END_SRC + +#+RESULTS: +| 1 | 2 | 3 | 4 | 5 | + +** Nested list as table + +#+BEGIN_SRC elixir +[ + ["Alice", 30], + ["Bob", 25], + ["Charlie", 35] +] +#+END_SRC + +#+RESULTS: +| Alice | 30 | +| Bob | 25 | +| Charlie | 35 | + +** Map result (verbatim) + +#+BEGIN_SRC elixir :results verbatim +%{name: "Alice", age: 30} +#+END_SRC + +#+RESULTS: +: %{age: 30, name: "Alice"} + +** Enum operations returning lists + +#+BEGIN_SRC elixir +Enum.map(1..5, fn x -> [x, x * x] end) +#+END_SRC + +#+RESULTS: +| 1 | 1 | +| 2 | 4 | +| 3 | 9 | +| 4 | 16 | +| 5 | 25 | + +** Tuple result + +#+BEGIN_SRC elixir +{:ok, "success", 123} +#+END_SRC +``` + +## Acceptance Criteria + +- [ ] Simple lists `[1, 2, 3]` become table rows +- [ ] Nested lists `[[1, 2], [3, 4]]` become tables +- [ ] Tuples are handled (converted to lists) +- [ ] Scalar values remain as strings +- [ ] `:results verbatim` bypasses table conversion +- [ ] nil values in tables are handled according to config +- [ ] All tests pass: `make test` + +## Result Format Reference + +| Elixir Value | Org Display | +|--------------|-------------| +| `[1, 2, 3]` | `\| 1 \| 2 \| 3 \|` | +| `[[1], [2]]` | Multi-row table | +| `{:ok, 1}` | `\| ok \| 1 \|` | +| `42` | `: 42` | +| `"hello"` | `: "hello"` | +| `%{a: 1}` | `: %{a: 1}` | + +## Files Modified + +- `ob-elixir.el` - Add result formatting functions +- `test/test-ob-elixir.el` - Add formatting tests + +## References + +- [docs/03-org-babel-implementation-guide.md](../docs/03-org-babel-implementation-guide.md) - Result Handling section +- [Org Manual - Results of Evaluation](https://orgmode.org/manual/Results-of-Evaluation.html) diff --git a/tasks/06-test-suite.md b/tasks/06-test-suite.md new file mode 100644 index 0000000..f41a5bc --- /dev/null +++ b/tasks/06-test-suite.md @@ -0,0 +1,623 @@ +# Task 06: Comprehensive Test Suite + +**Phase**: 1 - Core (MVP) +**Priority**: High +**Estimated Time**: 2-3 hours +**Dependencies**: Tasks 01-05 (All Core Tasks) + +## Objective + +Create a comprehensive test suite that covers all implemented functionality, including unit tests, integration tests, and org-buffer tests. + +## Prerequisites + +- All Phase 1 tasks completed +- Basic functionality working + +## Background + +A good test suite should: +1. Test each function in isolation (unit tests) +2. Test the integration with org-mode (integration tests) +3. Be runnable in CI/CD (batch mode tests) +4. Provide good coverage of edge cases + +## Steps + +### Step 1: Organize test file structure + +Create the test directory structure: + +``` +test/ +├── test-ob-elixir.el # Main test file (loads all) +├── test-ob-elixir-core.el # Core execution tests +├── test-ob-elixir-vars.el # Variable handling tests +├── test-ob-elixir-results.el # Result formatting tests +├── test-ob-elixir-errors.el # Error handling tests +└── test-ob-elixir-org.el # Org integration tests +``` + +### Step 2: Create main test file + +`test/test-ob-elixir.el`: + +```elisp +;;; test-ob-elixir.el --- Tests for ob-elixir -*- lexical-binding: t; -*- + +;;; Commentary: + +;; Main test file that loads all test modules. +;; Run with: make test +;; Or: emacs -batch -l ert -l test/test-ob-elixir.el -f ert-run-tests-batch-and-exit + +;;; Code: + +(require 'ert) + +;; Add source directory to load path +(let ((dir (file-name-directory (or load-file-name buffer-file-name)))) + (add-to-list 'load-path (expand-file-name ".." dir)) + (add-to-list 'load-path dir)) + +;; Load the package +(require 'ob-elixir) + +;; Load test modules +(require 'test-ob-elixir-core) +(require 'test-ob-elixir-vars) +(require 'test-ob-elixir-results) +(require 'test-ob-elixir-errors) +(require 'test-ob-elixir-org) + +;;; Test Helpers + +(defvar ob-elixir-test--elixir-available + (executable-find ob-elixir-command) + "Non-nil if Elixir is available for testing.") + +(defmacro ob-elixir-test-with-elixir (&rest body) + "Execute BODY only if Elixir is available." + `(if ob-elixir-test--elixir-available + (progn ,@body) + (ert-skip "Elixir not available"))) + +(defmacro ob-elixir-test-with-temp-org-buffer (&rest body) + "Execute BODY in a temporary org-mode buffer." + `(with-temp-buffer + (org-mode) + (ob-elixir--ensure-org-babel-loaded) + ,@body)) + +(defun ob-elixir--ensure-org-babel-loaded () + "Ensure org-babel is loaded with Elixir support." + (require 'org) + (require 'ob) + (org-babel-do-load-languages + 'org-babel-load-languages + '((elixir . t)))) + +;;; Smoke Test + +(ert-deftest ob-elixir-test-smoke () + "Basic smoke test - package loads and Elixir is available." + (should (featurep 'ob-elixir)) + (should (fboundp 'org-babel-execute:elixir)) + (should (boundp 'org-babel-default-header-args:elixir))) + +(provide 'test-ob-elixir) +;;; test-ob-elixir.el ends here +``` + +### Step 3: Create core execution tests + +`test/test-ob-elixir-core.el`: + +```elisp +;;; test-ob-elixir-core.el --- Core execution tests -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'ob-elixir) + +;;; Command Tests + +(ert-deftest ob-elixir-test-command-exists () + "Test that the Elixir command is configured." + (should (stringp ob-elixir-command)) + (should (not (string-empty-p ob-elixir-command)))) + +(ert-deftest ob-elixir-test-command-executable () + "Test that the Elixir command is executable." + (skip-unless (executable-find ob-elixir-command)) + (should (executable-find ob-elixir-command))) + +;;; Basic Execution Tests + +(ert-deftest ob-elixir-test-execute-simple-value () + "Test simple value evaluation." + (skip-unless (executable-find ob-elixir-command)) + (should (equal "2" (ob-elixir--execute "1 + 1" 'value)))) + +(ert-deftest ob-elixir-test-execute-simple-output () + "Test simple output evaluation." + (skip-unless (executable-find ob-elixir-command)) + (should (equal "hello" (ob-elixir--execute "IO.puts(\"hello\")" 'output)))) + +(ert-deftest ob-elixir-test-execute-multiline () + "Test multiline code execution." + (skip-unless (executable-find ob-elixir-command)) + (let ((code "x = 10\ny = 20\nx + y")) + (should (equal "30" (ob-elixir--execute code 'value))))) + +(ert-deftest ob-elixir-test-execute-function-def () + "Test function definition and call." + (skip-unless (executable-find ob-elixir-command)) + (let ((code " +defmodule Test do + def double(x), do: x * 2 +end +Test.double(21)")) + (should (equal "42" (ob-elixir--execute code 'value))))) + +(ert-deftest ob-elixir-test-execute-enum () + "Test Enum module usage." + (skip-unless (executable-find ob-elixir-command)) + (should (equal "15" + (ob-elixir--execute "Enum.sum([1, 2, 3, 4, 5])" 'value)))) + +(ert-deftest ob-elixir-test-execute-pipe () + "Test pipe operator." + (skip-unless (executable-find ob-elixir-command)) + (let ((code "[1, 2, 3] |> Enum.map(&(&1 * 2)) |> Enum.sum()")) + (should (equal "12" (ob-elixir--execute code 'value))))) + +;;; Data Type Tests + +(ert-deftest ob-elixir-test-execute-list () + "Test list result." + (skip-unless (executable-find ob-elixir-command)) + (should (equal "[1, 2, 3]" (ob-elixir--execute "[1, 2, 3]" 'value)))) + +(ert-deftest ob-elixir-test-execute-tuple () + "Test tuple result." + (skip-unless (executable-find ob-elixir-command)) + (should (equal "{:ok, 42}" (ob-elixir--execute "{:ok, 42}" 'value)))) + +(ert-deftest ob-elixir-test-execute-map () + "Test map result." + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--execute "%{a: 1, b: 2}" 'value))) + (should (string-match-p "%{" result)) + (should (string-match-p "a:" result)) + (should (string-match-p "b:" result)))) + +(ert-deftest ob-elixir-test-execute-string () + "Test string result." + (skip-unless (executable-find ob-elixir-command)) + (should (equal "\"hello world\"" + (ob-elixir--execute "\"hello world\"" 'value)))) + +(ert-deftest ob-elixir-test-execute-atom () + "Test atom result." + (skip-unless (executable-find ob-elixir-command)) + (should (equal ":ok" (ob-elixir--execute ":ok" 'value)))) + +(ert-deftest ob-elixir-test-execute-boolean () + "Test boolean result." + (skip-unless (executable-find ob-elixir-command)) + (should (equal "true" (ob-elixir--execute "true" 'value))) + (should (equal "false" (ob-elixir--execute "false" 'value)))) + +(ert-deftest ob-elixir-test-execute-nil () + "Test nil result." + (skip-unless (executable-find ob-elixir-command)) + (should (equal "nil" (ob-elixir--execute "nil" 'value)))) + +;;; Wrapper Tests + +(ert-deftest ob-elixir-test-wrap-for-value () + "Test value wrapper generation." + (let ((wrapped (ob-elixir--wrap-for-value "1 + 1"))) + (should (string-match-p "result = " wrapped)) + (should (string-match-p "IO\\.puts" wrapped)) + (should (string-match-p "inspect" wrapped)))) + +(provide 'test-ob-elixir-core) +;;; test-ob-elixir-core.el ends here +``` + +### Step 4: Create variable tests + +`test/test-ob-elixir-vars.el`: + +```elisp +;;; test-ob-elixir-vars.el --- Variable handling tests -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'ob-elixir) + +;;; Type Conversion Tests + +(ert-deftest ob-elixir-test-convert-nil () + (should (equal "nil" (ob-elixir--elisp-to-elixir nil)))) + +(ert-deftest ob-elixir-test-convert-true () + (should (equal "true" (ob-elixir--elisp-to-elixir t)))) + +(ert-deftest ob-elixir-test-convert-integer () + (should (equal "42" (ob-elixir--elisp-to-elixir 42))) + (should (equal "-10" (ob-elixir--elisp-to-elixir -10))) + (should (equal "0" (ob-elixir--elisp-to-elixir 0)))) + +(ert-deftest ob-elixir-test-convert-float () + (should (equal "3.14" (ob-elixir--elisp-to-elixir 3.14))) + (should (equal "-2.5" (ob-elixir--elisp-to-elixir -2.5)))) + +(ert-deftest ob-elixir-test-convert-string () + (should (equal "\"hello\"" (ob-elixir--elisp-to-elixir "hello"))) + (should (equal "\"\"" (ob-elixir--elisp-to-elixir "")))) + +(ert-deftest ob-elixir-test-convert-string-escaping () + (should (equal "\"say \\\"hi\\\"\"" + (ob-elixir--elisp-to-elixir "say \"hi\""))) + (should (equal "\"line1\\nline2\"" + (ob-elixir--elisp-to-elixir "line1\nline2"))) + (should (equal "\"tab\\there\"" + (ob-elixir--elisp-to-elixir "tab\there")))) + +(ert-deftest ob-elixir-test-convert-symbol () + (should (equal ":foo" (ob-elixir--elisp-to-elixir 'foo))) + (should (equal ":ok" (ob-elixir--elisp-to-elixir 'ok))) + (should (equal ":error" (ob-elixir--elisp-to-elixir 'error)))) + +(ert-deftest ob-elixir-test-convert-list () + (should (equal "[1, 2, 3]" (ob-elixir--elisp-to-elixir '(1 2 3)))) + (should (equal "[]" (ob-elixir--elisp-to-elixir '()))) + (should (equal "[\"a\", \"b\"]" (ob-elixir--elisp-to-elixir '("a" "b"))))) + +(ert-deftest ob-elixir-test-convert-nested-list () + (should (equal "[[1, 2], [3, 4]]" + (ob-elixir--elisp-to-elixir '((1 2) (3 4)))))) + +(ert-deftest ob-elixir-test-convert-vector () + (should (equal "{1, 2, 3}" (ob-elixir--elisp-to-elixir [1 2 3])))) + +(ert-deftest ob-elixir-test-convert-mixed () + (should (equal "[1, \"two\", :three]" + (ob-elixir--elisp-to-elixir '(1 "two" three))))) + +;;; Variable Assignment Tests + +(ert-deftest ob-elixir-test-var-assignments-single () + (let ((params '((:var . ("x" . 5))))) + (should (equal '("x = 5") + (org-babel-variable-assignments:elixir params))))) + +(ert-deftest ob-elixir-test-var-assignments-multiple () + (let ((params '((:var . ("x" . 5)) + (:var . ("y" . 10))))) + (let ((assignments (org-babel-variable-assignments:elixir params))) + (should (= 2 (length assignments))) + (should (member "x = 5" assignments)) + (should (member "y = 10" assignments))))) + +(ert-deftest ob-elixir-test-var-assignments-string () + (let ((params '((:var . ("name" . "Alice"))))) + (should (equal '("name = \"Alice\"") + (org-babel-variable-assignments:elixir params))))) + +(ert-deftest ob-elixir-test-var-assignments-list () + (let ((params '((:var . ("data" . (1 2 3)))))) + (should (equal '("data = [1, 2, 3]") + (org-babel-variable-assignments:elixir params))))) + +;;; Execution with Variables + +(ert-deftest ob-elixir-test-execute-with-var () + (skip-unless (executable-find ob-elixir-command)) + (let* ((params '((:var . ("x" . 10)))) + (var-lines (org-babel-variable-assignments:elixir params)) + (full-body (concat (mapconcat #'identity var-lines "\n") + "\nx * 2"))) + (should (equal "20" (ob-elixir--execute full-body 'value))))) + +(ert-deftest ob-elixir-test-execute-with-list-var () + (skip-unless (executable-find ob-elixir-command)) + (let* ((params '((:var . ("nums" . (1 2 3 4 5))))) + (var-lines (org-babel-variable-assignments:elixir params)) + (full-body (concat (mapconcat #'identity var-lines "\n") + "\nEnum.sum(nums)"))) + (should (equal "15" (ob-elixir--execute full-body 'value))))) + +(ert-deftest ob-elixir-test-execute-with-string-var () + (skip-unless (executable-find ob-elixir-command)) + (let* ((params '((:var . ("name" . "World")))) + (var-lines (org-babel-variable-assignments:elixir params)) + (full-body (concat (mapconcat #'identity var-lines "\n") + "\n\"Hello, #{name}!\""))) + (should (equal "\"Hello, World!\"" + (ob-elixir--execute full-body 'value))))) + +(provide 'test-ob-elixir-vars) +;;; test-ob-elixir-vars.el ends here +``` + +### Step 5: Create result formatting tests + +`test/test-ob-elixir-results.el`: + +```elisp +;;; test-ob-elixir-results.el --- Result formatting tests -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'ob-elixir) + +;;; Parsing Tests + +(ert-deftest ob-elixir-test-parse-simple-list () + (should (equal '(1 2 3) (ob-elixir--table-or-string "[1, 2, 3]")))) + +(ert-deftest ob-elixir-test-parse-nested-list () + (should (equal '((1 2) (3 4)) + (ob-elixir--table-or-string "[[1, 2], [3, 4]]")))) + +(ert-deftest ob-elixir-test-parse-empty-list () + (should (equal '() (ob-elixir--table-or-string "[]")))) + +(ert-deftest ob-elixir-test-parse-tuple () + (should (equal '(1 2 3) (ob-elixir--table-or-string "{1, 2, 3}")))) + +(ert-deftest ob-elixir-test-parse-scalar-number () + (should (equal "42" (ob-elixir--table-or-string "42")))) + +(ert-deftest ob-elixir-test-parse-scalar-atom () + (should (equal ":ok" (ob-elixir--table-or-string ":ok")))) + +(ert-deftest ob-elixir-test-parse-scalar-string () + (should (equal "\"hello\"" (ob-elixir--table-or-string "\"hello\"")))) + +(ert-deftest ob-elixir-test-parse-empty () + (should (null (ob-elixir--table-or-string ""))) + (should (null (ob-elixir--table-or-string " ")))) + +;;; Table Sanitization Tests + +(ert-deftest ob-elixir-test-sanitize-nil-values () + (let ((ob-elixir-nil-to 'hline)) + (should (equal '((1 hline) (hline 2)) + (ob-elixir--sanitize-table '((1 nil) (nil 2))))))) + +(ert-deftest ob-elixir-test-sanitize-nested () + (let ((ob-elixir-nil-to 'hline)) + (should (equal '((1 2) (3 4)) + (ob-elixir--sanitize-table '((1 2) (3 4))))))) + +(ert-deftest ob-elixir-test-sanitize-simple () + (should (equal '(1 2 3) + (ob-elixir--sanitize-table '(1 2 3))))) + +;;; Integration Tests + +(ert-deftest ob-elixir-test-full-result-list () + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--table-or-string + (ob-elixir--execute "[1, 2, 3]" 'value)))) + (should (equal '(1 2 3) result)))) + +(ert-deftest ob-elixir-test-full-result-table () + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir--table-or-string + (ob-elixir--execute "[[1, 2], [3, 4]]" 'value)))) + (should (equal '((1 2) (3 4)) result)))) + +(provide 'test-ob-elixir-results) +;;; test-ob-elixir-results.el ends here +``` + +### Step 6: Create error handling tests + +`test/test-ob-elixir-errors.el`: + +```elisp +;;; test-ob-elixir-errors.el --- Error handling tests -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'ob-elixir) + +;;; Error Detection Tests + +(ert-deftest ob-elixir-test-detect-runtime-error () + (let ((output "** (RuntimeError) something went wrong")) + (should (ob-elixir--detect-error output)))) + +(ert-deftest ob-elixir-test-detect-compile-error () + (let ((output "** (CompileError) test.exs:1: undefined function")) + (should (ob-elixir--detect-error output)))) + +(ert-deftest ob-elixir-test-detect-no-error () + (should-not (ob-elixir--detect-error "42")) + (should-not (ob-elixir--detect-error "[1, 2, 3]")) + (should-not (ob-elixir--detect-error ":ok"))) + +(ert-deftest ob-elixir-test-error-type-runtime () + (let* ((output "** (RuntimeError) test error") + (info (ob-elixir--detect-error output))) + (should (eq 'runtime (plist-get info :type))))) + +(ert-deftest ob-elixir-test-error-type-compile () + (let* ((output "** (CompileError) syntax error") + (info (ob-elixir--detect-error output))) + (should (eq 'compile (plist-get info :type))))) + +;;; Error Execution Tests + +(ert-deftest ob-elixir-test-runtime-error-no-signal () + (skip-unless (executable-find ob-elixir-command)) + (let ((ob-elixir-signal-errors nil)) + (let ((result (ob-elixir--execute "raise \"test\"" 'value))) + (should (string-match-p "RuntimeError" result))))) + +(ert-deftest ob-elixir-test-runtime-error-signal () + (skip-unless (executable-find ob-elixir-command)) + (let ((ob-elixir-signal-errors t)) + (should-error (ob-elixir--execute "raise \"test\"" 'value) + :type 'ob-elixir-runtime-error))) + +(ert-deftest ob-elixir-test-compile-error () + (skip-unless (executable-find ob-elixir-command)) + (let ((ob-elixir-signal-errors nil)) + (let ((result (ob-elixir--execute "def incomplete(" 'value))) + (should (string-match-p "\\(SyntaxError\\|TokenMissingError\\)" result))))) + +(provide 'test-ob-elixir-errors) +;;; test-ob-elixir-errors.el ends here +``` + +### Step 7: Create org integration tests + +`test/test-ob-elixir-org.el`: + +```elisp +;;; test-ob-elixir-org.el --- Org integration tests -*- lexical-binding: t; -*- + +;;; Code: + +(require 'ert) +(require 'org) +(require 'ob) +(require 'ob-elixir) + +;;; Helper Functions + +(defun ob-elixir-test--execute-src-block (code &optional header-args) + "Execute CODE as an Elixir src block with HEADER-ARGS." + (with-temp-buffer + (org-mode) + (insert (format "#+BEGIN_SRC elixir%s\n%s\n#+END_SRC" + (if header-args (concat " " header-args) "") + code)) + (goto-char (point-min)) + (forward-line 1) + (org-babel-execute-src-block))) + +;;; Basic Org Tests + +(ert-deftest ob-elixir-test-org-simple () + (skip-unless (executable-find ob-elixir-command)) + (should (equal "2" (ob-elixir-test--execute-src-block "1 + 1")))) + +(ert-deftest ob-elixir-test-org-with-var () + (skip-unless (executable-find ob-elixir-command)) + (should (equal "20" (ob-elixir-test--execute-src-block "x * 2" ":var x=10")))) + +(ert-deftest ob-elixir-test-org-results-output () + (skip-unless (executable-find ob-elixir-command)) + (should (equal "hello" + (ob-elixir-test--execute-src-block + "IO.puts(\"hello\")" + ":results output")))) + +(ert-deftest ob-elixir-test-org-results-value () + (skip-unless (executable-find ob-elixir-command)) + (should (equal '(1 2 3) + (ob-elixir-test--execute-src-block + "[1, 2, 3]" + ":results value")))) + +(ert-deftest ob-elixir-test-org-results-verbatim () + (skip-unless (executable-find ob-elixir-command)) + (should (stringp (ob-elixir-test--execute-src-block + "[1, 2, 3]" + ":results verbatim")))) + +;;; Table Tests + +(ert-deftest ob-elixir-test-org-table-result () + (skip-unless (executable-find ob-elixir-command)) + (let ((result (ob-elixir-test--execute-src-block "[[1, 2], [3, 4]]"))) + (should (equal '((1 2) (3 4)) result)))) + +(provide 'test-ob-elixir-org) +;;; test-ob-elixir-org.el ends here +``` + +### Step 8: Update Makefile + +```makefile +EMACS ?= emacs +BATCH = $(EMACS) -Q -batch -L . -L test + +.PHONY: all compile test test-unit test-integration lint clean + +all: compile test + +compile: + $(BATCH) -f batch-byte-compile ob-elixir.el + +test: test-unit + +test-unit: + $(BATCH) -l ert \ + -l test/test-ob-elixir.el \ + -f ert-run-tests-batch-and-exit + +test-integration: + $(BATCH) -l ert \ + -l org -l ob \ + -l test/test-ob-elixir.el \ + -f ert-run-tests-batch-and-exit + +lint: + $(BATCH) --eval "(require 'package)" \ + --eval "(package-initialize)" \ + --eval "(unless (package-installed-p 'package-lint) \ + (package-refresh-contents) \ + (package-install 'package-lint))" \ + -l package-lint \ + -f package-lint-batch-and-exit ob-elixir.el + +clean: + rm -f *.elc test/*.elc +``` + +## Acceptance Criteria + +- [ ] All test files created and organized +- [ ] `make test` runs all tests +- [ ] Tests cover core execution, variables, results, and errors +- [ ] Org integration tests work +- [ ] Tests can run in CI (batch mode) +- [ ] Test coverage is comprehensive (major code paths) + +## Test Coverage Goals + +| Component | Tests | Coverage | +|-----------|-------|----------| +| Type conversion | 12+ tests | All Elisp types | +| Execution | 10+ tests | Value/output, types | +| Variables | 8+ tests | All var scenarios | +| Results | 8+ tests | Parsing, tables | +| Errors | 6+ tests | Detection, signaling | +| Org integration | 6+ tests | Full workflow | + +## Files Created + +- `test/test-ob-elixir.el` - Main test file +- `test/test-ob-elixir-core.el` - Core tests +- `test/test-ob-elixir-vars.el` - Variable tests +- `test/test-ob-elixir-results.el` - Result tests +- `test/test-ob-elixir-errors.el` - Error tests +- `test/test-ob-elixir-org.el` - Org integration tests + +## References + +- [docs/02-testing-emacs-elisp.md](../docs/02-testing-emacs-elisp.md) +- [ERT Manual](https://www.gnu.org/software/emacs/manual/html_node/ert/) diff --git a/tasks/07-session-support.md b/tasks/07-session-support.md new file mode 100644 index 0000000..3e9f47f --- /dev/null +++ b/tasks/07-session-support.md @@ -0,0 +1,442 @@ +# Task 07: IEx Session Support + +**Phase**: 2 - Sessions +**Priority**: High +**Estimated Time**: 3-4 hours +**Dependencies**: Phase 1 Complete + +## Objective + +Implement IEx session support so code blocks can share state when using `:session` header argument. + +## Prerequisites + +- Phase 1 complete +- Understanding of Emacs comint mode +- IEx (Elixir REPL) available + +## Background + +Sessions allow multiple code blocks to share state: + +```org +#+BEGIN_SRC elixir :session my-session +x = 42 +#+END_SRC + +#+BEGIN_SRC elixir :session my-session +x * 2 # Can use x from previous block +#+END_SRC +``` + +This requires: +1. Starting an IEx process +2. Managing the process via comint +3. Sending code and capturing output +4. Proper prompt detection +5. Session cleanup + +## Steps + +### Step 1: Add session configuration + +Add to `ob-elixir.el`: + +```elisp +;;; Session Configuration + +(defcustom ob-elixir-iex-command "iex" + "Command to start IEx session." + :type 'string + :group 'ob-elixir + :safe #'stringp) + +(defconst ob-elixir--prompt-regexp + "^\\(?:iex\\|\\.\\.\\)([0-9]+)> " + "Regexp matching IEx prompt. +Matches both regular prompt 'iex(N)> ' and continuation '...(N)> '.") + +(defconst ob-elixir--eoe-marker + "__ob_elixir_eoe_marker__" + "End-of-evaluation marker for session output.") + +(defvar ob-elixir--sessions (make-hash-table :test 'equal) + "Hash table mapping session names to buffer names.") +``` + +### Step 2: Implement session initialization + +```elisp +;;; Session Management + +(require 'ob-comint) + +(defun org-babel-elixir-initiate-session (&optional session params) + "Create or return an Elixir session buffer. + +SESSION is the session name (string or nil). +PARAMS are the header arguments. + +Returns the session buffer, or nil if SESSION is \"none\"." + (unless (or (not session) (string= session "none")) + (let* ((session-name (if (stringp session) session "default")) + (buffer (ob-elixir--get-or-create-session session-name params))) + (when buffer + (puthash session-name (buffer-name buffer) ob-elixir--sessions)) + buffer))) + +(defun ob-elixir--get-or-create-session (name params) + "Get or create an IEx session named NAME with PARAMS." + (let* ((buffer-name (format "*ob-elixir:%s*" name)) + (existing (get-buffer buffer-name))) + (if (and existing (org-babel-comint-buffer-livep existing)) + existing + (ob-elixir--start-session buffer-name name params)))) + +(defun ob-elixir--start-session (buffer-name session-name params) + "Start a new IEx session in BUFFER-NAME." + (let* ((buffer (get-buffer-create buffer-name)) + (process-environment (cons "TERM=dumb" process-environment))) + (with-current-buffer buffer + ;; Start the IEx process + (make-comint-in-buffer + (format "ob-elixir-%s" session-name) + buffer + ob-elixir-iex-command + nil) + + ;; Wait for initial prompt + (ob-elixir--wait-for-prompt buffer 10) + + ;; Configure IEx for programmatic use + (ob-elixir--configure-session buffer) + + buffer))) + +(defun ob-elixir--configure-session (buffer) + "Configure IEx session in BUFFER for programmatic use." + (let ((config-commands + '("IEx.configure(colors: [enabled: false])" + "IEx.configure(inspect: [limit: :infinity, printable_limit: :infinity])"))) + (dolist (cmd config-commands) + (ob-elixir--send-command buffer cmd) + (ob-elixir--wait-for-prompt buffer 5)))) +``` + +### Step 3: Implement prompt detection + +```elisp +(defun ob-elixir--wait-for-prompt (buffer timeout) + "Wait for IEx prompt in BUFFER with TIMEOUT seconds." + (with-current-buffer buffer + (let ((end-time (+ (float-time) timeout))) + (while (and (< (float-time) end-time) + (not (ob-elixir--at-prompt-p))) + (accept-process-output (get-buffer-process buffer) 0.1) + (goto-char (point-max))) + (ob-elixir--at-prompt-p)))) + +(defun ob-elixir--at-prompt-p () + "Return t if the last line in buffer looks like an IEx prompt." + (save-excursion + (goto-char (point-max)) + (forward-line 0) + (looking-at ob-elixir--prompt-regexp))) +``` + +### Step 4: Implement command sending + +```elisp +(defun ob-elixir--send-command (buffer command) + "Send COMMAND to IEx process in BUFFER." + (with-current-buffer buffer + (goto-char (point-max)) + (insert command) + (comint-send-input nil t))) + +(defun ob-elixir--evaluate-in-session (session body result-type) + "Evaluate BODY in SESSION, return result. + +RESULT-TYPE is 'value or 'output." + (let* ((buffer (org-babel-elixir-initiate-session session nil)) + (code (if (eq result-type 'value) + (ob-elixir--session-wrap-for-value body) + body)) + (start-marker nil) + output) + (unless buffer + (error "Failed to create Elixir session: %s" session)) + + (with-current-buffer buffer + ;; Mark position before output + (goto-char (point-max)) + (setq start-marker (point-marker)) + + ;; Send the code + (ob-elixir--send-command buffer code) + (ob-elixir--wait-for-prompt buffer 30) + + ;; Send EOE marker + (ob-elixir--send-command buffer + (format "\"%s\"" ob-elixir--eoe-marker)) + (ob-elixir--wait-for-prompt buffer 5) + + ;; Extract output + (setq output (ob-elixir--extract-session-output + buffer start-marker))) + + (ob-elixir--clean-session-output output))) + +(defconst ob-elixir--session-value-wrapper + "_ob_result_ = ( +%s +) +IO.puts(\"__ob_value_start__\") +IO.puts(inspect(_ob_result_, limit: :infinity, printable_limit: :infinity)) +IO.puts(\"__ob_value_end__\") +:ok +" + "Wrapper for capturing value in session mode.") + +(defun ob-elixir--session-wrap-for-value (body) + "Wrap BODY to capture its value in session mode." + (format ob-elixir--session-value-wrapper body)) +``` + +### Step 5: Implement output extraction + +```elisp +(defun ob-elixir--extract-session-output (buffer start-marker) + "Extract output from BUFFER since START-MARKER." + (with-current-buffer buffer + (let ((end-pos (point-max))) + (buffer-substring-no-properties start-marker end-pos)))) + +(defun ob-elixir--clean-session-output (output) + "Clean OUTPUT from IEx session." + (let ((result output)) + ;; Remove ANSI escape codes + (setq result (ansi-color-filter-apply result)) + + ;; Remove prompts + (setq result (replace-regexp-in-string + ob-elixir--prompt-regexp "" result)) + + ;; Remove the input echo + (setq result (replace-regexp-in-string + "^.*\n" "" result nil nil nil 1)) + + ;; Remove EOE marker + (setq result (replace-regexp-in-string + (format "\"%s\"" ob-elixir--eoe-marker) "" result)) + + ;; Extract value if using value wrapper + (when (string-match "__ob_value_start__\n\\(.*\\)\n__ob_value_end__" result) + (setq result (match-string 1 result))) + + ;; Remove :ok from wrapper + (setq result (replace-regexp-in-string ":ok\n*$" "" result)) + + (string-trim result))) +``` + +### Step 6: Update execute function + +Modify `org-babel-execute:elixir`: + +```elisp +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with org-babel." + (let* ((session (cdr (assq :session params))) + (result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + (full-body (org-babel-expand-body:generic + body params + (org-babel-variable-assignments:elixir params))) + (result (if (and session (not (string= session "none"))) + ;; Session mode + (ob-elixir--evaluate-in-session session full-body result-type) + ;; External process mode + (ob-elixir--execute full-body result-type)))) + (org-babel-reassemble-table + (org-babel-result-cond result-params + result + (ob-elixir--table-or-string result)) + (org-babel-pick-name (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))))) +``` + +### Step 7: Implement prep-session + +```elisp +(defun org-babel-prep-session:elixir (session params) + "Prepare SESSION according to PARAMS. + +Sends variable assignments to the session." + (let ((buffer (org-babel-elixir-initiate-session session params)) + (var-lines (org-babel-variable-assignments:elixir params))) + (when (and buffer var-lines) + (dolist (var-line var-lines) + (ob-elixir--send-command buffer var-line) + (ob-elixir--wait-for-prompt buffer 5))) + buffer)) +``` + +### Step 8: Implement session cleanup + +```elisp +(defun ob-elixir-kill-session (session) + "Kill the Elixir session named SESSION." + (interactive + (list (completing-read "Kill session: " + (hash-table-keys ob-elixir--sessions)))) + (let ((buffer-name (gethash session ob-elixir--sessions))) + (when buffer-name + (let ((buffer (get-buffer buffer-name))) + (when buffer + (let ((process (get-buffer-process buffer))) + (when process + (delete-process process))) + (kill-buffer buffer))) + (remhash session ob-elixir--sessions)))) + +(defun ob-elixir-kill-all-sessions () + "Kill all Elixir sessions." + (interactive) + (maphash (lambda (name _buffer) + (ob-elixir-kill-session name)) + ob-elixir--sessions)) +``` + +### Step 9: Add tests + +Add to `test/test-ob-elixir-sessions.el`: + +```elisp +;;; test-ob-elixir-sessions.el --- Session tests -*- lexical-binding: t; -*- + +(require 'ert) +(require 'ob-elixir) + +(ert-deftest ob-elixir-test-session-creation () + "Test session creation." + (skip-unless (executable-find ob-elixir-iex-command)) + (unwind-protect + (let ((buffer (org-babel-elixir-initiate-session "test-create" nil))) + (should buffer) + (should (org-babel-comint-buffer-livep buffer))) + (ob-elixir-kill-session "test-create"))) + +(ert-deftest ob-elixir-test-session-persistence () + "Test that sessions persist state." + (skip-unless (executable-find ob-elixir-iex-command)) + (unwind-protect + (progn + ;; First evaluation - define variable + (ob-elixir--evaluate-in-session "test-persist" "x = 42" 'value) + ;; Second evaluation - use variable + (let ((result (ob-elixir--evaluate-in-session + "test-persist" "x * 2" 'value))) + (should (equal "84" result)))) + (ob-elixir-kill-session "test-persist"))) + +(ert-deftest ob-elixir-test-session-none () + "Test that :session none uses external process." + (skip-unless (executable-find ob-elixir-command)) + (should (null (org-babel-elixir-initiate-session "none" nil)))) + +(ert-deftest ob-elixir-test-session-module-def () + "Test defining module in session." + (skip-unless (executable-find ob-elixir-iex-command)) + (unwind-protect + (progn + (ob-elixir--evaluate-in-session + "test-module" + "defmodule TestMod do\n def double(x), do: x * 2\nend" + 'value) + (let ((result (ob-elixir--evaluate-in-session + "test-module" "TestMod.double(21)" 'value))) + (should (equal "42" result)))) + (ob-elixir-kill-session "test-module"))) + +(provide 'test-ob-elixir-sessions) +``` + +### Step 10: Test in org buffer + +Create session tests in `test.org`: + +```org +* Session Tests + +** Define variable in session + +#+BEGIN_SRC elixir :session my-session +x = 42 +#+END_SRC + +** Use variable from session + +#+BEGIN_SRC elixir :session my-session +x * 2 +#+END_SRC + +#+RESULTS: +: 84 + +** Define module in session + +#+BEGIN_SRC elixir :session my-session +defmodule Helper do + def greet(name), do: "Hello, #{name}!" +end +#+END_SRC + +** Use module from session + +#+BEGIN_SRC elixir :session my-session +Helper.greet("World") +#+END_SRC + +#+RESULTS: +: "Hello, World!" +``` + +## Acceptance Criteria + +- [ ] `:session name` creates persistent IEx session +- [ ] Variables persist across blocks in same session +- [ ] Module definitions persist +- [ ] `:session none` uses external process (default) +- [ ] Multiple named sessions work independently +- [ ] Sessions can be killed with `ob-elixir-kill-session` +- [ ] Proper prompt detection +- [ ] Output is clean (no prompts, ANSI codes) +- [ ] All tests pass + +## Troubleshooting + +### Session hangs + +Check for proper prompt detection. IEx prompts can vary. + +### ANSI codes in output + +The `ansi-color-filter-apply` should remove them. Check TERM environment variable. + +### Process dies unexpectedly + +Check for Elixir errors. May need to handle compilation errors in session context. + +## Files Modified + +- `ob-elixir.el` - Add session support +- `test/test-ob-elixir-sessions.el` - Add session tests + +## References + +- [docs/03-org-babel-implementation-guide.md](../docs/03-org-babel-implementation-guide.md) - Session Management +- [docs/04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) - IEx Session Management +- [Emacs Comint Mode](https://www.gnu.org/software/emacs/manual/html_node/emacs/Shell-Mode.html) diff --git a/tasks/08-mix-project-support.md b/tasks/08-mix-project-support.md new file mode 100644 index 0000000..15390ab --- /dev/null +++ b/tasks/08-mix-project-support.md @@ -0,0 +1,421 @@ +# Task 08: Mix Project Support + +**Phase**: 3 - Mix Integration +**Priority**: High +**Estimated Time**: 2-3 hours +**Dependencies**: Task 07 (Session Support) or Phase 1 complete + +## Objective + +Implement Mix project support so Elixir code can be executed within the context of a Mix project, with access to project dependencies and modules. + +## Prerequisites + +- Phase 1 complete (or Phase 2 for session+mix) +- Understanding of Mix build tool +- A test Mix project + +## Background + +Mix projects have: +- Dependencies in `mix.exs` +- Compiled modules in `_build/` +- Configuration in `config/` + +To execute code in project context, we need: +1. Run code from the project directory +2. Use `mix run` for one-shot execution +3. Use `iex -S mix` for sessions + +## Steps + +### Step 1: Add Mix configuration + +Add to `ob-elixir.el`: + +```elisp +;;; Mix Configuration + +(defcustom ob-elixir-mix-command "mix" + "Command to run Mix." + :type 'string + :group 'ob-elixir + :safe #'stringp) + +(defcustom ob-elixir-auto-detect-mix t + "Whether to automatically detect Mix projects. + +When non-nil and no :mix-project is specified, ob-elixir will +search upward from the org file for a mix.exs file." + :type 'boolean + :group 'ob-elixir) + +(defconst org-babel-header-args:elixir + '((mix-project . :any) ; Path to Mix project root + (mix-env . :any) ; MIX_ENV (dev, test, prod) + (mix-target . :any)) ; MIX_TARGET for Nerves, etc. + "Elixir-specific header arguments.") +``` + +### Step 2: Implement Mix project detection + +```elisp +;;; Mix Project Detection + +(defun ob-elixir--find-mix-project (&optional start-dir) + "Find Mix project root by searching for mix.exs. + +Starts from START-DIR (default: current directory) and searches +upward. Returns the directory containing mix.exs, or nil." + (let* ((dir (or start-dir default-directory)) + (found (locate-dominating-file dir "mix.exs"))) + (when found + (file-name-directory found)))) + +(defun ob-elixir--resolve-mix-project (params) + "Resolve Mix project path from PARAMS or auto-detection. + +Returns project path or nil." + (let ((explicit (cdr (assq :mix-project params)))) + (cond + ;; Explicit project path + ((and explicit (not (eq explicit 'no))) + (expand-file-name explicit)) + ;; Explicitly disabled + ((eq explicit 'no) + nil) + ;; Auto-detect if enabled + (ob-elixir-auto-detect-mix + (ob-elixir--find-mix-project)) + ;; No project + (t nil)))) + +(defun ob-elixir--in-mix-project-p (params) + "Return t if execution should happen in Mix project context." + (not (null (ob-elixir--resolve-mix-project params)))) +``` + +### Step 3: Implement Mix execution + +```elisp +;;; Mix Execution + +(defun ob-elixir--execute-with-mix (body result-type params) + "Execute BODY in Mix project context. + +RESULT-TYPE is 'value or 'output. +PARAMS contains header arguments including :mix-project." + (let* ((project-dir (ob-elixir--resolve-mix-project params)) + (mix-env (cdr (assq :mix-env params))) + (mix-target (cdr (assq :mix-target params))) + (default-directory project-dir) + (tmp-file (org-babel-temp-file "ob-elixir-mix-" ".exs")) + (code (if (eq result-type 'value) + (ob-elixir--wrap-for-value body) + body)) + (env-vars (ob-elixir--build-mix-env mix-env mix-target))) + + ;; Write code to temp file + (with-temp-file tmp-file + (insert code)) + + ;; Execute with mix run + (let ((command (format "%s%s run %s" + env-vars + ob-elixir-mix-command + (org-babel-process-file-name tmp-file)))) + (ob-elixir--process-result + (shell-command-to-string command))))) + +(defun ob-elixir--build-mix-env (mix-env mix-target) + "Build environment variable prefix for Mix execution." + (let ((vars '())) + (when mix-env + (push (format "MIX_ENV=%s" mix-env) vars)) + (when mix-target + (push (format "MIX_TARGET=%s" mix-target) vars)) + (if vars + (concat (mapconcat #'identity vars " ") " ") + ""))) +``` + +### Step 4: Update execute function + +Modify `org-babel-execute:elixir`: + +```elisp +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with org-babel." + (let* ((session (cdr (assq :session params))) + (result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + (mix-project (ob-elixir--resolve-mix-project params)) + (full-body (org-babel-expand-body:generic + body params + (org-babel-variable-assignments:elixir params))) + (result (cond + ;; Session mode + ((and session (not (string= session "none"))) + (ob-elixir--evaluate-in-session + session full-body result-type params)) + ;; Mix project mode + (mix-project + (ob-elixir--execute-with-mix + full-body result-type params)) + ;; Plain execution + (t + (ob-elixir--execute full-body result-type))))) + (org-babel-reassemble-table + (org-babel-result-cond result-params + result + (ob-elixir--table-or-string result)) + (org-babel-pick-name (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))))) +``` + +### Step 5: Update session for Mix projects + +Modify session creation to support Mix: + +```elisp +(defun ob-elixir--start-session (buffer-name session-name params) + "Start a new IEx session in BUFFER-NAME." + (let* ((mix-project (ob-elixir--resolve-mix-project params)) + (mix-env (cdr (assq :mix-env params))) + (buffer (get-buffer-create buffer-name)) + (default-directory (or mix-project default-directory)) + (process-environment + (append + (list "TERM=dumb") + (when mix-env (list (format "MIX_ENV=%s" mix-env))) + process-environment))) + + (with-current-buffer buffer + (if mix-project + ;; Start with mix + (make-comint-in-buffer + (format "ob-elixir-%s" session-name) + buffer + ob-elixir-iex-command + nil + "-S" "mix") + ;; Start plain IEx + (make-comint-in-buffer + (format "ob-elixir-%s" session-name) + buffer + ob-elixir-iex-command + nil)) + + ;; Wait for prompt + (ob-elixir--wait-for-prompt buffer 30) + + ;; Configure IEx + (ob-elixir--configure-session buffer) + + buffer))) +``` + +### Step 6: Add compilation support + +```elisp +(defcustom ob-elixir-compile-before-run nil + "Whether to run mix compile before execution. + +When non-nil, ensures project is compiled before running code. +This adds overhead but catches compilation errors early." + :type 'boolean + :group 'ob-elixir) + +(defun ob-elixir--ensure-compiled (project-dir) + "Ensure Mix project at PROJECT-DIR is compiled." + (let ((default-directory project-dir)) + (shell-command-to-string + (format "%s compile --force-check" ob-elixir-mix-command)))) + +(defun ob-elixir--execute-with-mix (body result-type params) + "Execute BODY in Mix project context." + (let* ((project-dir (ob-elixir--resolve-mix-project params)) + (default-directory project-dir)) + + ;; Optionally compile first + (when ob-elixir-compile-before-run + (ob-elixir--ensure-compiled project-dir)) + + ;; ... rest of execution + )) +``` + +### Step 7: Add tests + +Create `test/test-ob-elixir-mix.el`: + +```elisp +;;; test-ob-elixir-mix.el --- Mix project tests -*- lexical-binding: t; -*- + +(require 'ert) +(require 'ob-elixir) + +(defvar ob-elixir-test--mix-project-dir nil + "Temporary Mix project directory for testing.") + +(defun ob-elixir-test--setup-mix-project () + "Create a temporary Mix project for testing." + (let ((dir (make-temp-file "ob-elixir-test-" t))) + (setq ob-elixir-test--mix-project-dir dir) + (let ((default-directory dir)) + ;; Create mix.exs + (with-temp-file (expand-file-name "mix.exs" dir) + (insert "defmodule TestProject.MixProject do + use Mix.Project + + def project do + [app: :test_project, version: \"0.1.0\", elixir: \"~> 1.14\"] + end +end")) + ;; Create lib directory and module + (make-directory (expand-file-name "lib" dir)) + (with-temp-file (expand-file-name "lib/test_project.ex" dir) + (insert "defmodule TestProject do + def hello, do: \"Hello from TestProject!\" + def add(a, b), do: a + b +end"))) + dir)) + +(defun ob-elixir-test--cleanup-mix-project () + "Clean up temporary Mix project." + (when ob-elixir-test--mix-project-dir + (delete-directory ob-elixir-test--mix-project-dir t) + (setq ob-elixir-test--mix-project-dir nil))) + +;;; Tests + +(ert-deftest ob-elixir-test-find-mix-project () + "Test Mix project detection." + (skip-unless (executable-find ob-elixir-mix-command)) + (unwind-protect + (let* ((project-dir (ob-elixir-test--setup-mix-project)) + (sub-dir (expand-file-name "lib" project-dir)) + (default-directory sub-dir)) + (should (equal project-dir + (ob-elixir--find-mix-project)))) + (ob-elixir-test--cleanup-mix-project))) + +(ert-deftest ob-elixir-test-mix-project-execution () + "Test code execution in Mix project context." + (skip-unless (and (executable-find ob-elixir-mix-command) + (executable-find ob-elixir-command))) + (unwind-protect + (let* ((project-dir (ob-elixir-test--setup-mix-project)) + (params `((:mix-project . ,project-dir)))) + ;; Compile first + (let ((default-directory project-dir)) + (shell-command-to-string "mix compile")) + ;; Test execution + (let ((result (ob-elixir--execute-with-mix + "TestProject.hello()" 'value params))) + (should (string-match-p "Hello from TestProject" result)))) + (ob-elixir-test--cleanup-mix-project))) + +(ert-deftest ob-elixir-test-mix-env () + "Test MIX_ENV handling." + (skip-unless (executable-find ob-elixir-mix-command)) + (let ((env-str (ob-elixir--build-mix-env "test" nil))) + (should (string-match-p "MIX_ENV=test" env-str)))) + +(ert-deftest ob-elixir-test-explicit-no-mix () + "Test disabling Mix with :mix-project no." + (let ((params '((:mix-project . no)))) + (should (null (ob-elixir--resolve-mix-project params))))) + +(provide 'test-ob-elixir-mix) +``` + +### Step 8: Test in org buffer + +Create Mix tests in `test.org`: + +```org +* Mix Project Tests + +** Using project module (explicit path) + +#+BEGIN_SRC elixir :mix-project ~/my_elixir_project +MyApp.hello() +#+END_SRC + +** Using project module (auto-detect) + +When this org file is inside a Mix project: + +#+BEGIN_SRC elixir +MyApp.some_function() +#+END_SRC + +** With specific MIX_ENV + +#+BEGIN_SRC elixir :mix-project ~/my_elixir_project :mix-env test +Application.get_env(:my_app, :some_config) +#+END_SRC + +** Session with Mix + +#+BEGIN_SRC elixir :session mix-session :mix-project ~/my_elixir_project +# Has access to project modules +alias MyApp.SomeModule +#+END_SRC + +** Disable auto-detect + +#+BEGIN_SRC elixir :mix-project no +# Plain Elixir, no project context +1 + 1 +#+END_SRC +``` + +## Acceptance Criteria + +- [ ] `:mix-project path` executes in specified project +- [ ] Auto-detection finds `mix.exs` in parent directories +- [ ] `:mix-project no` disables auto-detection +- [ ] `:mix-env` sets MIX_ENV correctly +- [ ] Project modules are accessible +- [ ] Sessions with `:mix-project` use `iex -S mix` +- [ ] Compilation errors are reported properly +- [ ] All tests pass + +## Header Arguments Reference + +| Argument | Values | Description | +|----------|--------|-------------| +| `:mix-project` | path, `no` | Project path or disable | +| `:mix-env` | dev, test, prod | MIX_ENV value | +| `:mix-target` | host, target | MIX_TARGET for Nerves | + +## Troubleshooting + +### Module not found + +Ensure project is compiled: +```bash +cd /path/to/project && mix compile +``` + +### Dependencies not available + +Check that `mix deps.get` has been run. + +### Wrong MIX_ENV + +Explicitly set `:mix-env` header argument. + +## Files Modified + +- `ob-elixir.el` - Add Mix support +- `test/test-ob-elixir-mix.el` - Add Mix tests + +## References + +- [docs/04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) - Mix Project Context +- [Mix Documentation](https://hexdocs.pm/mix/Mix.html) diff --git a/tasks/09-remote-shell.md b/tasks/09-remote-shell.md new file mode 100644 index 0000000..90ff12a --- /dev/null +++ b/tasks/09-remote-shell.md @@ -0,0 +1,401 @@ +# Task 09: Remote Shell (remsh) Support + +**Phase**: 4 - Advanced Features +**Priority**: Medium +**Estimated Time**: 2-3 hours +**Dependencies**: Task 07 (Session Support) + +## Objective + +Implement remote shell support to connect to running Elixir/Erlang nodes and execute code against them. + +## Prerequisites + +- Session support implemented (Task 07) +- Understanding of Erlang distribution +- Running Elixir node for testing + +## Background + +Elixir/Erlang nodes can connect to each other for distributed computing. The `--remsh` flag allows IEx to connect to a running node: + +```bash +iex --sname console --remsh my_app@hostname --cookie secret +``` + +This is useful for: +- Inspecting production systems +- Running code against a live application +- Debugging distributed systems + +## Steps + +### Step 1: Add remote shell configuration + +Add to `ob-elixir.el`: + +```elisp +;;; Remote Shell Configuration + +(defconst org-babel-header-args:elixir + '((mix-project . :any) + (mix-env . :any) + (remsh . :any) ; Remote node to connect to + (node-name . :any) ; --name for local node + (node-sname . :any) ; --sname for local node + (cookie . :any)) ; Erlang cookie + "Elixir-specific header arguments.") + +(defcustom ob-elixir-default-cookie nil + "Default Erlang cookie for remote connections. + +Set this if all your nodes use the same cookie. +Can be overridden with :cookie header argument." + :type '(choice (const nil) string) + :group 'ob-elixir) +``` + +### Step 2: Implement remote session creation + +```elisp +;;; Remote Shell Sessions + +(defun ob-elixir--start-remote-session (buffer-name session-name params) + "Start a remote shell session in BUFFER-NAME. + +Connects to the node specified in PARAMS." + (let* ((remsh (cdr (assq :remsh params))) + (node-name (cdr (assq :node-name params))) + (node-sname (cdr (assq :node-sname params))) + (cookie (or (cdr (assq :cookie params)) + ob-elixir-default-cookie)) + (buffer (get-buffer-create buffer-name)) + (local-name (or node-sname + node-name + (format "ob_elixir_%d" (random 99999)))) + (process-environment (cons "TERM=dumb" process-environment))) + + (unless remsh + (error "No remote node specified. Use :remsh header argument")) + + (with-current-buffer buffer + ;; Build command arguments + (let ((args (append + ;; Local node name + (if node-name + (list "--name" node-name) + (list "--sname" local-name)) + ;; Cookie + (when cookie + (list "--cookie" cookie)) + ;; Remote shell + (list "--remsh" remsh)))) + + (apply #'make-comint-in-buffer + (format "ob-elixir-remsh-%s" session-name) + buffer + ob-elixir-iex-command + nil + args)) + + ;; Wait for connection + (unless (ob-elixir--wait-for-prompt buffer 30) + (error "Failed to connect to remote node: %s" remsh)) + + ;; Configure session + (ob-elixir--configure-session buffer) + + buffer))) + +(defun ob-elixir--is-remote-session-p (params) + "Return t if PARAMS specify a remote shell connection." + (not (null (cdr (assq :remsh params))))) +``` + +### Step 3: Update session creation dispatcher + +Modify `ob-elixir--start-session`: + +```elisp +(defun ob-elixir--start-session (buffer-name session-name params) + "Start a new IEx session in BUFFER-NAME." + (cond + ;; Remote shell + ((ob-elixir--is-remote-session-p params) + (ob-elixir--start-remote-session buffer-name session-name params)) + + ;; Mix project + ((ob-elixir--resolve-mix-project params) + (ob-elixir--start-mix-session buffer-name session-name params)) + + ;; Plain IEx + (t + (ob-elixir--start-plain-session buffer-name session-name params)))) + +(defun ob-elixir--start-plain-session (buffer-name session-name params) + "Start a plain IEx session in BUFFER-NAME." + (let* ((buffer (get-buffer-create buffer-name)) + (process-environment (cons "TERM=dumb" process-environment))) + (with-current-buffer buffer + (make-comint-in-buffer + (format "ob-elixir-%s" session-name) + buffer + ob-elixir-iex-command + nil) + + (ob-elixir--wait-for-prompt buffer 10) + (ob-elixir--configure-session buffer) + buffer))) + +(defun ob-elixir--start-mix-session (buffer-name session-name params) + "Start an IEx session with Mix in BUFFER-NAME." + (let* ((mix-project (ob-elixir--resolve-mix-project params)) + (mix-env (cdr (assq :mix-env params))) + (buffer (get-buffer-create buffer-name)) + (default-directory mix-project) + (process-environment + (append + (list "TERM=dumb") + (when mix-env (list (format "MIX_ENV=%s" mix-env))) + process-environment))) + (with-current-buffer buffer + (make-comint-in-buffer + (format "ob-elixir-%s" session-name) + buffer + ob-elixir-iex-command + nil + "-S" "mix") + + (ob-elixir--wait-for-prompt buffer 30) + (ob-elixir--configure-session buffer) + buffer))) +``` + +### Step 4: Add connection verification + +```elisp +(defun ob-elixir--verify-remote-connection (buffer remsh) + "Verify that BUFFER is connected to remote node REMSH." + (with-current-buffer buffer + (let ((result (ob-elixir--send-and-receive buffer "Node.self()"))) + (when (string-match-p (regexp-quote remsh) result) + t)))) + +(defun ob-elixir--send-and-receive (buffer command) + "Send COMMAND to BUFFER and return the response." + (let ((start-pos nil) + (result nil)) + (with-current-buffer buffer + (goto-char (point-max)) + (setq start-pos (point)) + (ob-elixir--send-command buffer command) + (ob-elixir--wait-for-prompt buffer 10) + (setq result (buffer-substring-no-properties start-pos (point)))) + (ob-elixir--clean-session-output result))) +``` + +### Step 5: Add safety checks + +```elisp +(defcustom ob-elixir-remsh-confirm t + "Whether to confirm before connecting to remote nodes. + +When non-nil, ask for confirmation before connecting. +This is a safety measure for production systems." + :type 'boolean + :group 'ob-elixir) + +(defun ob-elixir--confirm-remsh (node) + "Confirm remote shell connection to NODE." + (or (not ob-elixir-remsh-confirm) + (yes-or-no-p + (format "Connect to remote Elixir node '%s'? " node)))) + +(defun ob-elixir--start-remote-session (buffer-name session-name params) + "Start a remote shell session in BUFFER-NAME." + (let ((remsh (cdr (assq :remsh params)))) + (unless (ob-elixir--confirm-remsh remsh) + (user-error "Remote shell connection cancelled")) + ;; ... rest of implementation + )) +``` + +### Step 6: Add helper commands + +```elisp +(defun ob-elixir-connect-to-node (node &optional cookie) + "Interactively connect to a remote Elixir NODE. + +Optional COOKIE specifies the Erlang cookie." + (interactive + (list (read-string "Remote node: ") + (when current-prefix-arg + (read-string "Cookie: ")))) + (let ((params `((:remsh . ,node) + ,@(when cookie `((:cookie . ,cookie)))))) + (org-babel-elixir-initiate-session "remote" params))) + +(defun ob-elixir-list-sessions () + "List all active ob-elixir sessions." + (interactive) + (if (= 0 (hash-table-count ob-elixir--sessions)) + (message "No active sessions") + (with-output-to-temp-buffer "*ob-elixir sessions*" + (princ "Active ob-elixir sessions:\n\n") + (maphash (lambda (name buffer-name) + (let ((buffer (get-buffer buffer-name))) + (princ (format " %s -> %s (%s)\n" + name + buffer-name + (if (and buffer + (get-buffer-process buffer)) + "running" + "dead"))))) + ob-elixir--sessions)))) +``` + +### Step 7: Add tests + +Create `test/test-ob-elixir-remsh.el`: + +```elisp +;;; test-ob-elixir-remsh.el --- Remote shell tests -*- lexical-binding: t; -*- + +(require 'ert) +(require 'ob-elixir) + +;; Note: These tests require a running Elixir node +;; Start one with: iex --sname testnode --cookie testcookie + +(defvar ob-elixir-test-remote-node "testnode@localhost" + "Remote node for testing. Set to your test node.") + +(defvar ob-elixir-test-remote-cookie "testcookie" + "Cookie for test node.") + +(ert-deftest ob-elixir-test-is-remote-session () + "Test remote session detection." + (should (ob-elixir--is-remote-session-p + '((:remsh . "node@host")))) + (should-not (ob-elixir--is-remote-session-p + '((:session . "test"))))) + +(ert-deftest ob-elixir-test-remote-session-creation () + "Test remote session creation." + (skip-unless (executable-find ob-elixir-iex-command)) + (skip-unless (getenv "OB_ELIXIR_TEST_REMSH")) ; Only run if explicitly enabled + (let ((ob-elixir-remsh-confirm nil)) + (unwind-protect + (let* ((params `((:remsh . ,ob-elixir-test-remote-node) + (:cookie . ,ob-elixir-test-remote-cookie))) + (buffer (org-babel-elixir-initiate-session "test-remote" params))) + (should buffer) + (should (org-babel-comint-buffer-livep buffer))) + (ob-elixir-kill-session "test-remote")))) + +(ert-deftest ob-elixir-test-remote-execution () + "Test code execution on remote node." + (skip-unless (executable-find ob-elixir-iex-command)) + (skip-unless (getenv "OB_ELIXIR_TEST_REMSH")) + (let ((ob-elixir-remsh-confirm nil)) + (unwind-protect + (let* ((params `((:remsh . ,ob-elixir-test-remote-node) + (:cookie . ,ob-elixir-test-remote-cookie))) + (result (ob-elixir--evaluate-in-session + "test-remote-exec" + "Node.self() |> to_string()" + 'value + params))) + (should (string-match-p ob-elixir-test-remote-node result))) + (ob-elixir-kill-session "test-remote-exec")))) + +(provide 'test-ob-elixir-remsh) +``` + +### Step 8: Document usage + +Add to documentation: + +```org +* Remote Shell Usage + +** Connecting to a running node + +#+BEGIN_SRC elixir :session remote :remsh myapp@localhost :cookie secret +Node.self() +#+END_SRC + +** With explicit node name + +#+BEGIN_SRC elixir :session remote :remsh myapp@localhost :node-sname console :cookie secret +# Local node will be named 'console' +Node.list() +#+END_SRC + +** With distributed name + +#+BEGIN_SRC elixir :session remote :remsh myapp@myhost.example.com :node-name console@myhost.example.com :cookie secret +# Use full node names for cross-machine connections +Application.get_env(:my_app, :some_setting) +#+END_SRC +``` + +## Acceptance Criteria + +- [ ] `:remsh node@host` connects to remote node +- [ ] `:cookie` sets the Erlang cookie +- [ ] `:node-sname` and `:node-name` set local node name +- [ ] Connection failures produce clear error messages +- [ ] Safety confirmation before connecting (configurable) +- [ ] `ob-elixir-connect-to-node` interactive command works +- [ ] Remote session appears in `ob-elixir-list-sessions` +- [ ] All tests pass + +## Header Arguments Reference + +| Argument | Values | Description | +|----------|--------|-------------| +| `:remsh` | node@host | Remote node to connect to | +| `:cookie` | string | Erlang cookie for authentication | +| `:node-sname` | name | Short name for local node | +| `:node-name` | name@host | Full name for local node | + +## Security Considerations + +1. **Cookies**: Never commit cookies to version control +2. **Confirmation**: Enable `ob-elixir-remsh-confirm` for production +3. **Network**: Ensure proper firewall rules for Erlang distribution port (4369 + dynamic) +4. **Scope**: Consider what code could be executed on production systems + +## Troubleshooting + +### Cannot connect to node + +1. Verify node is running: `epmd -names` +2. Check cookie matches +3. Verify network connectivity on port 4369 +4. Check that node allows remote connections + +### Connection times out + +Increase timeout or check for network issues: +```elisp +(setq ob-elixir-session-timeout 60) +``` + +### Node not found + +Ensure using correct node name format: +- Short names: `node@hostname` (same machine or subnet) +- Long names: `node@hostname.domain.com` (cross-network) + +## Files Modified + +- `ob-elixir.el` - Add remote shell support +- `test/test-ob-elixir-remsh.el` - Add remote shell tests + +## References + +- [docs/04-elixir-integration-strategies.md](../docs/04-elixir-integration-strategies.md) - Remote Shell section +- [Erlang Distribution](https://www.erlang.org/doc/reference_manual/distributed.html) +- [IEx Remote Shell](https://hexdocs.pm/iex/IEx.html#module-remote-shells) diff --git a/tasks/10-async-execution.md b/tasks/10-async-execution.md new file mode 100644 index 0000000..795e718 --- /dev/null +++ b/tasks/10-async-execution.md @@ -0,0 +1,358 @@ +# Task 10: Async Execution Support + +**Phase**: 4 - Advanced Features +**Priority**: Low +**Estimated Time**: 3-4 hours +**Dependencies**: Phase 1 Complete + +## Objective + +Implement asynchronous execution so long-running Elixir code blocks don't freeze Emacs. + +## Prerequisites + +- Phase 1 complete +- Understanding of Emacs async processes + +## Background + +Some Elixir operations can take a long time: +- Database migrations +- Large data processing +- Network operations +- Build tasks + +Async execution allows: +- Continue editing while code runs +- Visual indicator of running blocks +- Cancel long-running operations + +## Steps + +### Step 1: Add async configuration + +Add to `ob-elixir.el`: + +```elisp +;;; Async Configuration + +(defcustom ob-elixir-async-timeout 300 + "Timeout in seconds for async execution. + +After this time, async execution will be cancelled." + :type 'integer + :group 'ob-elixir) + +(defvar ob-elixir--async-processes (make-hash-table :test 'equal) + "Hash table mapping buffer positions to async processes.") + +(defconst org-babel-header-args:elixir + '((mix-project . :any) + (mix-env . :any) + (remsh . :any) + (node-name . :any) + (node-sname . :any) + (cookie . :any) + (async . ((yes no)))) ; NEW: async execution + "Elixir-specific header arguments.") +``` + +### Step 2: Implement async execution + +```elisp +;;; Async Execution + +(defun ob-elixir--execute-async (body result-type callback) + "Execute BODY asynchronously. + +RESULT-TYPE is 'value or 'output. +CALLBACK is called with the result when complete." + (let* ((tmp-file (org-babel-temp-file "ob-elixir-async-" ".exs")) + (code (if (eq result-type 'value) + (ob-elixir--wrap-for-value body) + body)) + (output-buffer (generate-new-buffer " *ob-elixir-async*")) + process) + + ;; Write code to temp file + (with-temp-file tmp-file + (insert code)) + + ;; Start async process + (setq process + (start-process + "ob-elixir-async" + output-buffer + ob-elixir-command + tmp-file)) + + ;; Set up process sentinel + (set-process-sentinel + process + (lambda (proc event) + (when (memq (process-status proc) '(exit signal)) + (let ((result (with-current-buffer (process-buffer proc) + (buffer-string)))) + ;; Clean up + (kill-buffer (process-buffer proc)) + (delete-file tmp-file) + ;; Call callback with result + (funcall callback (ob-elixir--process-result + (string-trim result))))))) + + ;; Set up timeout + (run-at-time ob-elixir-async-timeout nil + (lambda () + (when (process-live-p process) + (kill-process process) + (funcall callback "Error: Async execution timed out")))) + + process)) +``` + +### Step 3: Integrate with org-babel + +```elisp +(defun ob-elixir--async-p (params) + "Return t if PARAMS specify async execution." + (string= "yes" (cdr (assq :async params)))) + +(defun org-babel-execute:elixir (body params) + "Execute a block of Elixir code with org-babel." + (let* ((session (cdr (assq :session params))) + (result-type (cdr (assq :result-type params))) + (result-params (cdr (assq :result-params params))) + (async (ob-elixir--async-p params)) + (full-body (org-babel-expand-body:generic + body params + (org-babel-variable-assignments:elixir params)))) + + (if async + ;; Async execution + (ob-elixir--execute-async-block full-body result-type params) + ;; Sync execution (existing code) + (let ((result (ob-elixir--execute-sync full-body result-type params))) + (org-babel-reassemble-table + (org-babel-result-cond result-params + result + (ob-elixir--table-or-string result)) + (org-babel-pick-name (cdr (assq :colname-names params)) + (cdr (assq :colnames params))) + (org-babel-pick-name (cdr (assq :rowname-names params)) + (cdr (assq :rownames params)))))))) + +(defun ob-elixir--execute-sync (body result-type params) + "Execute BODY synchronously." + (cond + ((and (cdr (assq :session params)) + (not (string= (cdr (assq :session params)) "none"))) + (ob-elixir--evaluate-in-session + (cdr (assq :session params)) body result-type params)) + ((ob-elixir--resolve-mix-project params) + (ob-elixir--execute-with-mix body result-type params)) + (t + (ob-elixir--execute body result-type)))) +``` + +### Step 4: Implement async block handling + +```elisp +(defun ob-elixir--execute-async-block (body result-type params) + "Execute BODY asynchronously and insert results when done." + (let ((buffer (current-buffer)) + (point (point)) + (result-params (cdr (assq :result-params params))) + (marker (copy-marker (point)))) + + ;; Show placeholder + (ob-elixir--insert-async-placeholder marker) + + ;; Execute async + (ob-elixir--execute-async + body + result-type + (lambda (result) + (ob-elixir--insert-async-result + buffer marker result result-params params))) + + ;; Return placeholder message + "Executing asynchronously...")) + +(defun ob-elixir--insert-async-placeholder (marker) + "Insert a placeholder at MARKER indicating async execution." + (save-excursion + (goto-char marker) + (end-of-line) + (insert "\n") + (insert "#+RESULTS:\n") + (insert ": [Executing...]\n"))) + +(defun ob-elixir--insert-async-result (buffer marker result result-params params) + "Insert RESULT at MARKER in BUFFER." + (when (buffer-live-p buffer) + (with-current-buffer buffer + (save-excursion + (goto-char marker) + ;; Find and remove placeholder + (when (search-forward ": [Executing...]" nil t) + (beginning-of-line) + (let ((start (point))) + (forward-line 1) + (delete-region start (point)))) + ;; Insert real result + (let ((formatted (org-babel-result-cond result-params + result + (ob-elixir--table-or-string result)))) + (org-babel-insert-result formatted result-params)))))) +``` + +### Step 5: Add cancellation support + +```elisp +(defun ob-elixir-cancel-async () + "Cancel the async execution at point." + (interactive) + (let* ((pos (point)) + (process (gethash pos ob-elixir--async-processes))) + (if (and process (process-live-p process)) + (progn + (kill-process process) + (remhash pos ob-elixir--async-processes) + (message "Async execution cancelled")) + (message "No async execution at point")))) + +(defun ob-elixir-cancel-all-async () + "Cancel all running async executions." + (interactive) + (maphash (lambda (_pos process) + (when (process-live-p process) + (kill-process process))) + ob-elixir--async-processes) + (clrhash ob-elixir--async-processes) + (message "All async executions cancelled")) +``` + +### Step 6: Add visual indicators + +```elisp +(defface ob-elixir-async-running + '((t :background "yellow" :foreground "black")) + "Face for source blocks with running async execution." + :group 'ob-elixir) + +(defun ob-elixir--highlight-async-block (start end) + "Highlight the region from START to END as running." + (let ((overlay (make-overlay start end))) + (overlay-put overlay 'face 'ob-elixir-async-running) + (overlay-put overlay 'ob-elixir-async t) + overlay)) + +(defun ob-elixir--remove-async-highlight () + "Remove async highlighting from current block." + (dolist (ov (overlays-in (point-min) (point-max))) + (when (overlay-get ov 'ob-elixir-async) + (delete-overlay ov)))) +``` + +### Step 7: Add tests + +Create `test/test-ob-elixir-async.el`: + +```elisp +;;; test-ob-elixir-async.el --- Async execution tests -*- lexical-binding: t; -*- + +(require 'ert) +(require 'ob-elixir) + +(ert-deftest ob-elixir-test-async-detection () + "Test async header argument detection." + (should (ob-elixir--async-p '((:async . "yes")))) + (should-not (ob-elixir--async-p '((:async . "no")))) + (should-not (ob-elixir--async-p '()))) + +(ert-deftest ob-elixir-test-async-execution () + "Test async execution completion." + (skip-unless (executable-find ob-elixir-command)) + (let ((result nil) + (done nil)) + (ob-elixir--execute-async + "1 + 1" + 'value + (lambda (r) + (setq result r) + (setq done t))) + ;; Wait for completion + (with-timeout (10 (error "Async test timed out")) + (while (not done) + (accept-process-output nil 0.1))) + (should (equal "2" result)))) + +(ert-deftest ob-elixir-test-async-timeout () + "Test async timeout handling." + (skip-unless (executable-find ob-elixir-command)) + (let ((ob-elixir-async-timeout 1) + (result nil) + (done nil)) + (ob-elixir--execute-async + ":timer.sleep(5000)" ; Sleep for 5 seconds + 'value + (lambda (r) + (setq result r) + (setq done t))) + ;; Wait for timeout + (with-timeout (3 (error "Test timed out")) + (while (not done) + (accept-process-output nil 0.1))) + (should (string-match-p "timed out" result)))) + +(provide 'test-ob-elixir-async) +``` + +### Step 8: Document usage + +Add to documentation: + +```org +* Async Execution + +** Long-running computation + +#+BEGIN_SRC elixir :async yes +# This won't block Emacs +Enum.reduce(1..1000000, 0, &+/2) +#+END_SRC + +** Async with Mix project + +#+BEGIN_SRC elixir :async yes :mix-project ~/my_app +MyApp.expensive_operation() +#+END_SRC + +** Cancel with M-x ob-elixir-cancel-async +``` + +## Acceptance Criteria + +- [ ] `:async yes` executes code asynchronously +- [ ] Placeholder shown while executing +- [ ] Results inserted when complete +- [ ] Timeout handled gracefully +- [ ] `ob-elixir-cancel-async` cancels execution +- [ ] Visual indicator for running blocks +- [ ] All tests pass + +## Limitations + +- Sessions cannot be async (they're inherently stateful) +- Multiple async blocks may have ordering issues +- Async results may not integrate perfectly with noweb + +## Files Modified + +- `ob-elixir.el` - Add async support +- `test/test-ob-elixir-async.el` - Add async tests + +## References + +- [Emacs Async Processes](https://www.gnu.org/software/emacs/manual/html_node/elisp/Asynchronous-Processes.html) +- [Process Sentinels](https://www.gnu.org/software/emacs/manual/html_node/elisp/Sentinels.html)