feat: add pigibrack Scheme structural editing extension
This commit is contained in:
43
.pi/extensions/pigibrack/README.md
Normal file
43
.pi/extensions/pigibrack/README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# pigibrack
|
||||||
|
|
||||||
|
**pi guile bracket extension** for structural Scheme/Guile editing.
|
||||||
|
|
||||||
|
## What it provides
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
|
||||||
|
- `pigibrack_read_module(path)`
|
||||||
|
- `pigibrack_read_form(path, name)`
|
||||||
|
- `pigibrack_replace_form(path, name, newSource)`
|
||||||
|
- `pigibrack_insert_form(path, anchorName, position, newSource)`
|
||||||
|
- `pigibrack_delete_form(path, name)`
|
||||||
|
- `pigibrack_check_syntax({ path } | { source })`
|
||||||
|
- `pigibrack_eval_expr(expr, module?)`
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
- `/pigibrack-status`
|
||||||
|
|
||||||
|
## How to load
|
||||||
|
|
||||||
|
This extension is project-local at:
|
||||||
|
|
||||||
|
- `.pi/extensions/pigibrack/index.ts`
|
||||||
|
|
||||||
|
Pi auto-discovers this path. You can also start explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pi -e ./.pi/extensions/pigibrack/index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Structural operations are top-level form by **name**.
|
||||||
|
- Syntax checks and eval run through **guile** scripts in `guile/`.
|
||||||
|
- File mutations use pi's `withFileMutationQueue()` to avoid race conditions in parallel tool mode.
|
||||||
|
- Output is truncated to avoid context blowups.
|
||||||
|
|
||||||
|
## Current limitations
|
||||||
|
|
||||||
|
- `pigibrack_eval_expr` currently runs a fresh Guile process per call (not yet a persistent REPL session).
|
||||||
|
- Name extraction targets common `define*` patterns; unusual macro forms may be unnamed by the tool.
|
||||||
41
.pi/extensions/pigibrack/guile/check_syntax.scm
Normal file
41
.pi/extensions/pigibrack/guile/check_syntax.scm
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
(use-modules (ice-9 textual-ports))
|
||||||
|
|
||||||
|
(define (->string obj)
|
||||||
|
(call-with-output-string
|
||||||
|
(lambda (port)
|
||||||
|
(write obj port))))
|
||||||
|
|
||||||
|
(define (read-all path)
|
||||||
|
(call-with-input-file path get-string-all))
|
||||||
|
|
||||||
|
(define (read-all-datums source)
|
||||||
|
(let ((port (open-input-string source)))
|
||||||
|
(let loop ()
|
||||||
|
(let ((datum (read port)))
|
||||||
|
(unless (eof-object? datum)
|
||||||
|
(loop))))))
|
||||||
|
|
||||||
|
(define (main argv)
|
||||||
|
(if (< (length argv) 2)
|
||||||
|
(begin
|
||||||
|
(display "usage: guile check_syntax.scm <file>\n")
|
||||||
|
(primitive-exit 2))
|
||||||
|
(let* ((path (list-ref argv 1))
|
||||||
|
(ok?
|
||||||
|
(catch #t
|
||||||
|
(lambda ()
|
||||||
|
(read-all-datums (read-all path))
|
||||||
|
#t)
|
||||||
|
(lambda (key . args)
|
||||||
|
(display (symbol->string key))
|
||||||
|
(display " ")
|
||||||
|
(display (->string args))
|
||||||
|
(newline)
|
||||||
|
#f))))
|
||||||
|
(if ok?
|
||||||
|
(begin
|
||||||
|
(display "OK\n")
|
||||||
|
(primitive-exit 0))
|
||||||
|
(primitive-exit 1)))))
|
||||||
|
|
||||||
|
(main (command-line))
|
||||||
71
.pi/extensions/pigibrack/guile/eval_expr.scm
Normal file
71
.pi/extensions/pigibrack/guile/eval_expr.scm
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
(use-modules (ice-9 textual-ports)
|
||||||
|
(ice-9 pretty-print))
|
||||||
|
|
||||||
|
(define stdout-start "__PIGI_STDOUT_START__")
|
||||||
|
(define stdout-end "__PIGI_STDOUT_END__")
|
||||||
|
(define result-start "__PIGI_RESULT_START__")
|
||||||
|
(define result-end "__PIGI_RESULT_END__")
|
||||||
|
|
||||||
|
(define (->string obj)
|
||||||
|
(call-with-output-string
|
||||||
|
(lambda (port)
|
||||||
|
(write obj port))))
|
||||||
|
|
||||||
|
(define (read-all path)
|
||||||
|
(call-with-input-file path get-string-all))
|
||||||
|
|
||||||
|
(define (parse-module-spec spec)
|
||||||
|
(call-with-input-string spec read))
|
||||||
|
|
||||||
|
(define (eval-expression expr-source module-spec)
|
||||||
|
(when module-spec
|
||||||
|
(set-current-module (resolve-interface module-spec)))
|
||||||
|
|
||||||
|
(let* ((expr (call-with-input-string expr-source read))
|
||||||
|
(stdout-port (open-output-string))
|
||||||
|
(value (parameterize ((current-output-port stdout-port))
|
||||||
|
(eval expr (current-module))))
|
||||||
|
(stdout (get-output-string stdout-port))
|
||||||
|
(value-port (open-output-string)))
|
||||||
|
(pretty-print value value-port)
|
||||||
|
(values stdout (get-output-string value-port))))
|
||||||
|
|
||||||
|
(define (main argv)
|
||||||
|
(if (< (length argv) 2)
|
||||||
|
(begin
|
||||||
|
(display "usage: guile eval_expr.scm <expr-file> [module-spec]\n")
|
||||||
|
(primitive-exit 2))
|
||||||
|
(let* ((expr-path (list-ref argv 1))
|
||||||
|
(module-spec (if (> (length argv) 2)
|
||||||
|
(parse-module-spec (list-ref argv 2))
|
||||||
|
#f))
|
||||||
|
(expr-source (read-all expr-path))
|
||||||
|
(ok?
|
||||||
|
(catch #t
|
||||||
|
(lambda ()
|
||||||
|
(call-with-values
|
||||||
|
(lambda ()
|
||||||
|
(eval-expression expr-source module-spec))
|
||||||
|
(lambda (stdout value)
|
||||||
|
(display stdout-start)
|
||||||
|
(newline)
|
||||||
|
(display stdout)
|
||||||
|
(display stdout-end)
|
||||||
|
(newline)
|
||||||
|
(display result-start)
|
||||||
|
(newline)
|
||||||
|
(display value)
|
||||||
|
(display result-end)
|
||||||
|
(newline)))
|
||||||
|
#t)
|
||||||
|
(lambda (key . args)
|
||||||
|
(display (symbol->string key))
|
||||||
|
(display " ")
|
||||||
|
(display (->string args))
|
||||||
|
(newline)
|
||||||
|
#f))))
|
||||||
|
(if ok?
|
||||||
|
(primitive-exit 0)
|
||||||
|
(primitive-exit 1)))))
|
||||||
|
|
||||||
|
(main (command-line))
|
||||||
719
.pi/extensions/pigibrack/index.ts
Normal file
719
.pi/extensions/pigibrack/index.ts
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
import { StringEnum, Type } from '@mariozechner/pi-ai';
|
||||||
|
import {
|
||||||
|
DEFAULT_MAX_BYTES,
|
||||||
|
DEFAULT_MAX_LINES,
|
||||||
|
formatSize,
|
||||||
|
truncateHead,
|
||||||
|
truncateTail,
|
||||||
|
withFileMutationQueue,
|
||||||
|
type ExtensionAPI,
|
||||||
|
} from '@mariozechner/pi-coding-agent';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
const CHECK_SYNTAX_SCRIPT = resolve(__dirname, 'guile/check_syntax.scm');
|
||||||
|
const EVAL_EXPR_SCRIPT = resolve(__dirname, 'guile/eval_expr.scm');
|
||||||
|
|
||||||
|
const TOOL_DESCRIPTION_SUFFIX = `Tool output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}.`;
|
||||||
|
|
||||||
|
interface FormSpan {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
line: number;
|
||||||
|
text: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyntaxCheckResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EvalResult {
|
||||||
|
stdout: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUserPath(path: string): string {
|
||||||
|
return path.startsWith('@') ? path.slice(1) : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineNumberAt(source: string, index: number): number {
|
||||||
|
let line = 1;
|
||||||
|
for (let i = 0; i < index && i < source.length; i += 1) {
|
||||||
|
if (source[i] === '\n') line += 1;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWhitespace(ch: string | undefined): boolean {
|
||||||
|
return ch === ' ' || ch === '\n' || ch === '\r' || ch === '\t' || ch === '\f';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDelimiter(ch: string | undefined): boolean {
|
||||||
|
if (ch === undefined) return true;
|
||||||
|
return (
|
||||||
|
isWhitespace(ch) ||
|
||||||
|
ch === '(' ||
|
||||||
|
ch === ')' ||
|
||||||
|
ch === '[' ||
|
||||||
|
ch === ']' ||
|
||||||
|
ch === '"' ||
|
||||||
|
ch === ';'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTopLevelForms(source: string): FormSpan[] {
|
||||||
|
const len = source.length;
|
||||||
|
|
||||||
|
function skipLineComment(i: number): number {
|
||||||
|
let cursor = i;
|
||||||
|
while (cursor < len && source[cursor] !== '\n') cursor += 1;
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipBlockComment(i: number): number {
|
||||||
|
let cursor = i + 2;
|
||||||
|
let depth = 1;
|
||||||
|
|
||||||
|
while (cursor < len - 1) {
|
||||||
|
if (source[cursor] === '#' && source[cursor + 1] === '|') {
|
||||||
|
depth += 1;
|
||||||
|
cursor += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (source[cursor] === '|' && source[cursor + 1] === '#') {
|
||||||
|
depth -= 1;
|
||||||
|
cursor += 2;
|
||||||
|
if (depth === 0) return cursor;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unterminated block comment (#| ... |#).');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseString(i: number): number {
|
||||||
|
let cursor = i + 1;
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
while (cursor < len) {
|
||||||
|
const ch = source[cursor];
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (ch === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
return cursor + 1;
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unterminated string literal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAtom(i: number): number {
|
||||||
|
let cursor = i;
|
||||||
|
|
||||||
|
while (cursor < len) {
|
||||||
|
const ch = source[cursor];
|
||||||
|
if (isDelimiter(ch)) break;
|
||||||
|
if (ch === '#' && (source[cursor + 1] === '|' || source[cursor + 1] === ';')) break;
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor === i) throw new Error(`Unexpected token at index ${i}.`);
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipTrivia(i: number): number {
|
||||||
|
let cursor = i;
|
||||||
|
|
||||||
|
while (cursor < len) {
|
||||||
|
const ch = source[cursor];
|
||||||
|
|
||||||
|
if (isWhitespace(ch)) {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === ';') {
|
||||||
|
cursor = skipLineComment(cursor);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '#' && source[cursor + 1] === '|') {
|
||||||
|
cursor = skipBlockComment(cursor);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '#' && source[cursor + 1] === ';') {
|
||||||
|
cursor += 2;
|
||||||
|
cursor = skipTrivia(cursor);
|
||||||
|
cursor = parseDatum(cursor);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseList(i: number): number {
|
||||||
|
const open = source[i];
|
||||||
|
const close = open === '(' ? ')' : ']';
|
||||||
|
let cursor = i + 1;
|
||||||
|
|
||||||
|
while (cursor < len) {
|
||||||
|
cursor = skipTrivia(cursor);
|
||||||
|
if (cursor >= len) break;
|
||||||
|
|
||||||
|
if (source[cursor] === close) return cursor + 1;
|
||||||
|
if (source[cursor] === ')' || source[cursor] === ']') {
|
||||||
|
throw new Error(`Mismatched list delimiter near index ${cursor}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = parseDatum(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unterminated list form.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDatum(i: number): number {
|
||||||
|
const cursor = skipTrivia(i);
|
||||||
|
if (cursor >= len) throw new Error('Unexpected end of input.');
|
||||||
|
|
||||||
|
const ch = source[cursor];
|
||||||
|
|
||||||
|
if (ch === '(' || ch === '[') {
|
||||||
|
return parseList(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '"') {
|
||||||
|
return parseString(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === "'" || ch === '`') {
|
||||||
|
return parseDatum(cursor + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === ',') {
|
||||||
|
return parseDatum(source[cursor + 1] === '@' ? cursor + 2 : cursor + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ch === '#') {
|
||||||
|
if (source[cursor + 1] === '(' || source[cursor + 1] === '[') {
|
||||||
|
return parseList(cursor + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let maybeVectorCursor = cursor + 1;
|
||||||
|
while (/[a-zA-Z0-9]/.test(source[maybeVectorCursor] ?? '')) maybeVectorCursor += 1;
|
||||||
|
if (source[maybeVectorCursor] === '(' || source[maybeVectorCursor] === '[') {
|
||||||
|
return parseList(maybeVectorCursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseAtom(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const forms: FormSpan[] = [];
|
||||||
|
let cursor = skipTrivia(0);
|
||||||
|
|
||||||
|
while (cursor < len) {
|
||||||
|
const start = cursor;
|
||||||
|
const end = parseDatum(cursor);
|
||||||
|
const text = source.slice(start, end);
|
||||||
|
|
||||||
|
forms.push({
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
line: lineNumberAt(source, start),
|
||||||
|
text,
|
||||||
|
name: extractFormName(text),
|
||||||
|
});
|
||||||
|
|
||||||
|
cursor = skipTrivia(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
return forms;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFormName(formText: string): string | undefined {
|
||||||
|
const trimmed = formText.trim();
|
||||||
|
if (!trimmed.startsWith('(')) return undefined;
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
/^\(\s*(?:define|define-public|define\*)\s+\(([\w!$%&*+\-./:<=>?@^~]+(?:[\w!$%&*+\-./:<=>?@^~]|#[^\s()\[\]]*)?)\b/,
|
||||||
|
/^\(\s*(?:define|define-public|define\*)\s+([^\s()\[\]]+)\b/,
|
||||||
|
/^\(\s*(?:define-syntax|define-syntax-rule|define-macro)\s+\(([^\s()\[\]]+)\b/,
|
||||||
|
/^\(\s*(?:define-syntax|define-syntax-rule|define-macro)\s+([^\s()\[\]]+)\b/,
|
||||||
|
/^\(\s*define-record-type\s+([^\s()\[\]]+)\b/,
|
||||||
|
/^\(\s*define-class\s+([^\s()\[\]]+)\b/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = trimmed.match(pattern);
|
||||||
|
if (match?.[1]) return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneLine(text: string): string {
|
||||||
|
return text.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseForm(formText: string): string {
|
||||||
|
const trimmed = formText.trim();
|
||||||
|
const lines = trimmed.split('\n');
|
||||||
|
|
||||||
|
let output = lines.length <= 5 ? oneLine(trimmed) : `${oneLine(lines[0] ?? trimmed)} ...`;
|
||||||
|
|
||||||
|
if (output.length > 160) {
|
||||||
|
output = `${output.slice(0, 157)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueByName(forms: FormSpan[], name: string): FormSpan {
|
||||||
|
const matches = forms.filter((form) => form.name === name);
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
const available = Array.from(new Set(forms.map((form) => form.name).filter(Boolean))).slice(
|
||||||
|
0,
|
||||||
|
30,
|
||||||
|
) as string[];
|
||||||
|
const suffix = available.length > 0 ? ` Available names: ${available.join(', ')}` : '';
|
||||||
|
throw new Error(`No top-level form named "${name}" found.${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length > 1) {
|
||||||
|
const locations = matches.map((match) => `line ${match.line}`).join(', ');
|
||||||
|
throw new Error(
|
||||||
|
`Multiple forms named "${name}" found (${locations}). Please disambiguate first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSingleTopLevelForm(source: string): string {
|
||||||
|
const trimmed = source.trim();
|
||||||
|
if (!trimmed) throw new Error('newSource is empty.');
|
||||||
|
|
||||||
|
const forms = parseTopLevelForms(trimmed);
|
||||||
|
if (forms.length !== 1 || forms[0].start !== 0 || forms[0].end !== trimmed.length) {
|
||||||
|
throw new Error('newSource must contain exactly one top-level form.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTruncated(text: string, mode: 'head' | 'tail' = 'head'): string {
|
||||||
|
const truncation =
|
||||||
|
mode === 'head'
|
||||||
|
? truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES })
|
||||||
|
: truncateTail(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
|
||||||
|
|
||||||
|
let output = truncation.content;
|
||||||
|
if (truncation.truncated) {
|
||||||
|
output += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines, ${formatSize(truncation.outputBytes)}/${formatSize(truncation.totalBytes)}.]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvalOutput(raw: string): EvalResult {
|
||||||
|
const stdoutStart = '__PIGI_STDOUT_START__\n';
|
||||||
|
const stdoutEnd = '__PIGI_STDOUT_END__\n';
|
||||||
|
const resultStart = '__PIGI_RESULT_START__\n';
|
||||||
|
const resultEnd = '__PIGI_RESULT_END__\n';
|
||||||
|
|
||||||
|
const stdoutStartIndex = raw.indexOf(stdoutStart);
|
||||||
|
const stdoutEndIndex = raw.indexOf(stdoutEnd);
|
||||||
|
const resultStartIndex = raw.indexOf(resultStart);
|
||||||
|
const resultEndIndex = raw.indexOf(resultEnd);
|
||||||
|
|
||||||
|
if (
|
||||||
|
stdoutStartIndex === -1 ||
|
||||||
|
stdoutEndIndex === -1 ||
|
||||||
|
resultStartIndex === -1 ||
|
||||||
|
resultEndIndex === -1
|
||||||
|
) {
|
||||||
|
return { stdout: '', value: raw.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stdout = raw.slice(stdoutStartIndex + stdoutStart.length, stdoutEndIndex);
|
||||||
|
const value = raw.slice(resultStartIndex + resultStart.length, resultEndIndex);
|
||||||
|
|
||||||
|
return { stdout: stdout.trimEnd(), value: value.trimEnd() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTempScheme(source: string): Promise<string> {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'pigibrack-'));
|
||||||
|
const file = join(dir, 'input.scm');
|
||||||
|
await writeFile(file, source, 'utf8');
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function pigibrackExtension(pi: ExtensionAPI) {
|
||||||
|
async function guileSyntaxCheck(
|
||||||
|
input: { path?: string; source?: string },
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<SyntaxCheckResult> {
|
||||||
|
let targetPath: string | undefined;
|
||||||
|
|
||||||
|
if (input.path) {
|
||||||
|
targetPath = input.path;
|
||||||
|
} else if (typeof input.source === 'string') {
|
||||||
|
targetPath = await writeTempScheme(input.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetPath) {
|
||||||
|
return { valid: false, errors: ['Either path or source must be provided.'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pi.exec('guile', [CHECK_SYNTAX_SCRIPT, targetPath], {
|
||||||
|
signal,
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.code === 0) {
|
||||||
|
return { valid: true, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
[result.stderr, result.stdout].filter(Boolean).join('\n').trim() || 'Unknown syntax error';
|
||||||
|
return { valid: false, errors: [message] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guileEval(
|
||||||
|
expr: string,
|
||||||
|
moduleSpec: string | undefined,
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<EvalResult> {
|
||||||
|
const exprPath = await writeTempScheme(expr);
|
||||||
|
const args = ['-L', cwd, EVAL_EXPR_SCRIPT, exprPath];
|
||||||
|
if (moduleSpec) args.push(moduleSpec);
|
||||||
|
|
||||||
|
const result = await pi.exec('guile', args, {
|
||||||
|
signal,
|
||||||
|
timeout: 30_000,
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.code !== 0) {
|
||||||
|
const message =
|
||||||
|
[result.stderr, result.stdout].filter(Boolean).join('\n').trim() ||
|
||||||
|
'Guile evaluation failed.';
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseEvalOutput(result.stdout ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.registerCommand('pigibrack-status', {
|
||||||
|
description: 'Show pigibrack extension status and guile availability',
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const scriptsExist = existsSync(CHECK_SYNTAX_SCRIPT) && existsSync(EVAL_EXPR_SCRIPT);
|
||||||
|
const guileVersion = await pi.exec('guile', ['--version'], { timeout: 5_000 });
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`scripts: ${scriptsExist ? 'ok' : 'missing'}`,
|
||||||
|
`check_syntax.scm: ${CHECK_SYNTAX_SCRIPT}`,
|
||||||
|
`eval_expr.scm: ${EVAL_EXPR_SCRIPT}`,
|
||||||
|
`guile available: ${guileVersion.code === 0 ? 'yes' : 'no'}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (guileVersion.code === 0) {
|
||||||
|
const firstLine = (guileVersion.stdout ?? '').split('\n')[0]?.trim();
|
||||||
|
if (firstLine) lines.push(`guile: ${firstLine}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(lines.join('\n'), scriptsExist && guileVersion.code === 0 ? 'info' : 'warning');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: 'pigibrack_read_module',
|
||||||
|
label: 'pigibrack read module',
|
||||||
|
description: `Read a Scheme file as collapsed top-level forms (name + one-line summary). ${TOOL_DESCRIPTION_SUFFIX}`,
|
||||||
|
promptSnippet: 'Inspect Scheme modules by top-level forms before editing.',
|
||||||
|
promptGuidelines: [
|
||||||
|
'Use pigibrack_read_module before pigibrack_read_form for large Scheme files.',
|
||||||
|
],
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.String({ description: 'Path to a Scheme source file' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path));
|
||||||
|
const source = await readFile(absolutePath, 'utf8');
|
||||||
|
const forms = parseTopLevelForms(source);
|
||||||
|
|
||||||
|
const lines = forms.map((form, index) => {
|
||||||
|
const name = form.name ? ` name=${form.name}` : '';
|
||||||
|
return `${String(index + 1).padStart(3, ' ')}. line ${form.line}${name} :: ${collapseForm(form.text)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = `Module: ${params.path}\nTop-level forms: ${forms.length}\n\n${lines.join('\n')}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: formatTruncated(output, 'head') }],
|
||||||
|
details: {
|
||||||
|
path: params.path,
|
||||||
|
absolutePath,
|
||||||
|
forms: forms.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: 'pigibrack_read_form',
|
||||||
|
label: 'pigibrack read form',
|
||||||
|
description: `Read one top-level Scheme form by defined name. ${TOOL_DESCRIPTION_SUFFIX}`,
|
||||||
|
promptSnippet: 'Read an individual Scheme top-level form by name.',
|
||||||
|
promptGuidelines: ['Use pigibrack_read_form to fetch a form before replacing it.'],
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.String({ description: 'Path to a Scheme source file' }),
|
||||||
|
name: Type.String({ description: 'Top-level form name (e.g. my-function)' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path));
|
||||||
|
const source = await readFile(absolutePath, 'utf8');
|
||||||
|
const forms = parseTopLevelForms(source);
|
||||||
|
const form = uniqueByName(forms, params.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: formatTruncated(form.text, 'head') }],
|
||||||
|
details: {
|
||||||
|
path: params.path,
|
||||||
|
absolutePath,
|
||||||
|
name: params.name,
|
||||||
|
line: form.line,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: 'pigibrack_replace_form',
|
||||||
|
label: 'pigibrack replace form',
|
||||||
|
description: 'Replace a top-level Scheme form by name, with syntax pre-check via guile reader.',
|
||||||
|
promptSnippet: 'Replace an entire top-level Scheme form by name.',
|
||||||
|
promptGuidelines: [
|
||||||
|
'Always call pigibrack_read_form first, then replace with a complete new form.',
|
||||||
|
'newSource must be exactly one top-level form.',
|
||||||
|
],
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.String({ description: 'Path to a Scheme source file' }),
|
||||||
|
name: Type.String({ description: 'Top-level form name to replace' }),
|
||||||
|
newSource: Type.String({ description: 'The complete new top-level form source' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path));
|
||||||
|
const normalizedSource = ensureSingleTopLevelForm(params.newSource);
|
||||||
|
|
||||||
|
const syntax = await guileSyntaxCheck({ source: normalizedSource }, signal);
|
||||||
|
if (!syntax.valid) {
|
||||||
|
throw new Error(`newSource is not valid Scheme: ${syntax.errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withFileMutationQueue(absolutePath, async () => {
|
||||||
|
const source = await readFile(absolutePath, 'utf8');
|
||||||
|
const forms = parseTopLevelForms(source);
|
||||||
|
const form = uniqueByName(forms, params.name);
|
||||||
|
|
||||||
|
const updated = `${source.slice(0, form.start)}${normalizedSource}${source.slice(form.end)}`;
|
||||||
|
await writeFile(absolutePath, updated, 'utf8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Replaced form "${params.name}" at line ${form.line} in ${params.path}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
path: params.path,
|
||||||
|
absolutePath,
|
||||||
|
name: params.name,
|
||||||
|
line: form.line,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: 'pigibrack_insert_form',
|
||||||
|
label: 'pigibrack insert form',
|
||||||
|
description: 'Insert a new top-level Scheme form before or after an anchor form by name.',
|
||||||
|
promptSnippet: 'Insert a new top-level Scheme form relative to an existing named form.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.String({ description: 'Path to a Scheme source file' }),
|
||||||
|
anchorName: Type.String({ description: 'Anchor form name for insertion' }),
|
||||||
|
position: StringEnum(['before', 'after'] as const),
|
||||||
|
newSource: Type.String({ description: 'New top-level form source' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path));
|
||||||
|
const normalizedSource = ensureSingleTopLevelForm(params.newSource);
|
||||||
|
|
||||||
|
const syntax = await guileSyntaxCheck({ source: normalizedSource }, signal);
|
||||||
|
if (!syntax.valid) {
|
||||||
|
throw new Error(`newSource is not valid Scheme: ${syntax.errors.join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withFileMutationQueue(absolutePath, async () => {
|
||||||
|
const source = await readFile(absolutePath, 'utf8');
|
||||||
|
const forms = parseTopLevelForms(source);
|
||||||
|
const anchor = uniqueByName(forms, params.anchorName);
|
||||||
|
|
||||||
|
const insertionPoint = params.position === 'before' ? anchor.start : anchor.end;
|
||||||
|
|
||||||
|
const left = source.slice(0, insertionPoint);
|
||||||
|
const right = source.slice(insertionPoint);
|
||||||
|
|
||||||
|
const needsLeadingNewline = left.length > 0 && !left.endsWith('\n');
|
||||||
|
const needsTrailingNewline = right.length > 0 && !right.startsWith('\n');
|
||||||
|
|
||||||
|
const inserted = `${needsLeadingNewline ? '\n' : ''}${normalizedSource}${needsTrailingNewline ? '\n' : ''}`;
|
||||||
|
const updated = `${left}${inserted}${right}`;
|
||||||
|
|
||||||
|
await writeFile(absolutePath, updated, 'utf8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `Inserted form ${params.position} "${params.anchorName}" (line ${anchor.line}) in ${params.path}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
details: {
|
||||||
|
path: params.path,
|
||||||
|
absolutePath,
|
||||||
|
anchorName: params.anchorName,
|
||||||
|
position: params.position,
|
||||||
|
line: anchor.line,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: 'pigibrack_delete_form',
|
||||||
|
label: 'pigibrack delete form',
|
||||||
|
description: 'Delete a top-level Scheme form by name.',
|
||||||
|
promptSnippet: 'Delete a top-level Scheme form by its name.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.String({ description: 'Path to a Scheme source file' }),
|
||||||
|
name: Type.String({ description: 'Top-level form name to delete' }),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const absolutePath = resolve(ctx.cwd, normalizeUserPath(params.path));
|
||||||
|
|
||||||
|
return withFileMutationQueue(absolutePath, async () => {
|
||||||
|
const source = await readFile(absolutePath, 'utf8');
|
||||||
|
const forms = parseTopLevelForms(source);
|
||||||
|
const form = uniqueByName(forms, params.name);
|
||||||
|
|
||||||
|
let start = form.start;
|
||||||
|
let end = form.end;
|
||||||
|
|
||||||
|
if (source[end] === '\n') end += 1;
|
||||||
|
if (start > 0 && source[start - 1] === '\n' && source[end] === '\n') start -= 1;
|
||||||
|
|
||||||
|
const updated = `${source.slice(0, start)}${source.slice(end)}`;
|
||||||
|
await writeFile(absolutePath, updated, 'utf8');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Deleted form "${params.name}" from ${params.path}.` }],
|
||||||
|
details: {
|
||||||
|
path: params.path,
|
||||||
|
absolutePath,
|
||||||
|
name: params.name,
|
||||||
|
line: form.line,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: 'pigibrack_check_syntax',
|
||||||
|
label: 'pigibrack check syntax',
|
||||||
|
description:
|
||||||
|
'Check Scheme syntax via guile reader. Accepts either a file path or inline source.',
|
||||||
|
promptSnippet: 'Validate Scheme syntax before editing or evaluating.',
|
||||||
|
parameters: Type.Object({
|
||||||
|
path: Type.Optional(Type.String({ description: 'Path to a Scheme source file' })),
|
||||||
|
source: Type.Optional(Type.String({ description: 'Inline Scheme source to validate' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
if (!params.path && typeof params.source !== 'string') {
|
||||||
|
throw new Error('Provide either path or source.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.path && typeof params.source === 'string') {
|
||||||
|
throw new Error('Provide path or source, not both.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = params.path
|
||||||
|
? resolve(ctx.cwd, normalizeUserPath(params.path))
|
||||||
|
: undefined;
|
||||||
|
const result = await guileSyntaxCheck({ path: resolvedPath, source: params.source }, signal);
|
||||||
|
|
||||||
|
const output = result.valid
|
||||||
|
? 'Syntax check: valid ✅'
|
||||||
|
: `Syntax check: invalid ❌\n\n${result.errors.map((error, index) => `${index + 1}. ${error}`).join('\n')}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: formatTruncated(output, 'head') }],
|
||||||
|
details: {
|
||||||
|
valid: result.valid,
|
||||||
|
errors: result.errors,
|
||||||
|
path: params.path,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: 'pigibrack_eval_expr',
|
||||||
|
label: 'pigibrack eval expr',
|
||||||
|
description: `Evaluate a Scheme expression in guile. Optional module is a module spec, e.g. (my module). ${TOOL_DESCRIPTION_SUFFIX}`,
|
||||||
|
promptSnippet: 'Evaluate Scheme expressions in guile for fast feedback.',
|
||||||
|
promptGuidelines: [
|
||||||
|
'Use pigibrack_eval_expr after structural edits to validate behavior quickly.',
|
||||||
|
],
|
||||||
|
parameters: Type.Object({
|
||||||
|
expr: Type.String({ description: 'A Scheme expression to evaluate' }),
|
||||||
|
module: Type.Optional(Type.String({ description: 'Optional module spec, e.g. (my module)' })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
||||||
|
const result = await guileEval(params.expr, params.module, ctx.cwd, signal);
|
||||||
|
|
||||||
|
const outputParts = [] as string[];
|
||||||
|
if (result.stdout.trim()) {
|
||||||
|
outputParts.push(`stdout:\n${result.stdout}`);
|
||||||
|
}
|
||||||
|
outputParts.push(`result:\n${result.value || '<no value>'}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: formatTruncated(outputParts.join('\n\n'), 'tail') }],
|
||||||
|
details: {
|
||||||
|
module: params.module,
|
||||||
|
hasStdout: result.stdout.trim().length > 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
10
playground/calc.scm
Normal file
10
playground/calc.scm
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
(define-module (playground calc)
|
||||||
|
#:export (triple twice greeting))
|
||||||
|
|
||||||
|
(define (triple x)
|
||||||
|
(* 3 x))
|
||||||
|
|
||||||
|
(define (twice x)
|
||||||
|
(* 2 x))
|
||||||
|
|
||||||
|
(define greeting "hello from module")
|
||||||
8
playground/math.scm
Normal file
8
playground/math.scm
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
(define (square x)
|
||||||
|
(* x x))
|
||||||
|
(define (cube x)
|
||||||
|
(* x x x))
|
||||||
|
|
||||||
|
(define (add a b)
|
||||||
|
(+ a b 1))
|
||||||
|
|
||||||
Reference in New Issue
Block a user