docs/internal/PLAN-lsp-plugin-buffer.md
When a user writes plugin code in a buffer (via "Load Plugin from Buffer"), they get zero editor intelligence — no autocomplete for getEditor(), registerHandler(), EditorAPI methods, no type checking, no hover docs. This makes the plugin development experience significantly worse than editing a .ts file in the plugins directory.
The core challenge: the Fresh plugin API is defined in fresh.d.ts (1,368 lines, auto-generated from Rust), and an LSP server needs to see both this type definition and the buffer contents to provide completions. But the buffer may be unsaved, unnamed, or ephemeral.
LspManager manages one LSP server per language (e.g., typescript-language-server --stdio for TypeScript)with_lsp_for_buffer() is the central helper — it requires metadata.file_uri() to return Some(Uri)None for file_uri(), so LSP is completely disabled for themBufferMetadata::new_unnamed() explicitly sets lsp_enabled: falsefile:// scheme — no support for untitled: or virtual document schemescrates/fresh-editor/plugins/lib/fresh.d.ts/// <reference path="./lib/fresh.d.ts" />embed-plugins feature is on, entire plugins/ dir (including lib/fresh.d.ts) is compiled into the binary via include_dir!() and extracted to ~/.cache/fresh/embedded-plugins/{hash}/fresh.d.ts IS embedded in the binary and extractable at runtimeApproach: When a buffer is being used for plugin development, write its content to a temporary .ts file alongside a tsconfig.json that includes fresh.d.ts. Point the LSP at this temp file.
Mechanics:
~/.cache/fresh/plugin-dev/{session}/fresh.d.ts there (copy from embedded plugins cache or from source)tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"strict": true,
"noEmit": true,
"lib": ["ES2020"]
},
"files": ["plugin.ts", "fresh.d.ts"]
}
plugin.ts in that directoryfile:// URI pointing to this temp filedidChange), also update the temp file (or rely on LSP's in-memory document sync)Pros:
typescript-language-server — no custom LSP neededtsconfig.json can be precisely configured for the plugin environment (no DOM, correct target, etc.)fresh.d.ts is naturally discoverable by the TS compilerCons:
didChange already handles in-memory; the temp file is only needed for initial didOpen)Important details:
tsconfig.json must NOT include "dom" lib since plugins run in QuickJS, not a browser"skipLibCheck": true to avoid checking fresh.d.ts itselffresh.d.ts uses declare function getEditor(): EditorAPI at global scope — this is exactly what plugins see at runtime, so type checking will be accuratetypescript-language-server respects tsconfig.json found in the file's directory hierarchyApproach: Use untitled: URI scheme for the buffer. Implement a middleware or wrapper around typescript-language-server that intercepts textDocument/didOpen for untitled: URIs and injects fresh.d.ts types.
Mechanics:
untitled:buffer-plugin.tstypescript-language-serverdidOpen for untitled: docs, also opens a virtual fresh.d.tstsconfigPros:
Cons:
typescript-language-server has limited support for untitled: URIs — it needs a workspace root to find tsconfig.json and node_modulesuntitled: documents can't resolve relative imports or type referencesVerdict: Too complex for the benefit. LSP servers are designed around the filesystem.
Approach: Use deno lsp which has built-in TypeScript support and can work with virtual documents.
Mechanics:
deno lsp with --unstable flagdeno: or data URIs for type injectionPros:
npm install -g typescript-language-server typescriptnode_modules needed)Cons:
fresh.d.ts types visible requires Deno-specific configuration (deno.json with compilerOptions.types)typescript-language-serverVerdict: Introducing a Deno dependency for plugin development is heavy-handed. Could be offered as an alternative configuration, but shouldn't be the default.
Approach: Bundle a TypeScript type-checker (or a subset) directly into Fresh's Rust binary, avoiding external LSP servers entirely.
Pros:
Cons:
Verdict: Not practical.
Create PluginDevWorkspace struct in fresh-editor/src/services/plugins/:
pub struct PluginDevWorkspace {
/// Path to the temp directory
dir: PathBuf,
/// Path to the temp plugin.ts file
plugin_file: PathBuf,
/// Whether fresh.d.ts has been written
types_ready: bool,
}
On "Load Plugin from Buffer" or explicit "Enable Plugin LSP" action:
~/.cache/fresh/plugin-dev/ directoryfresh.d.ts from embedded plugins dir (already extracted at ~/.cache/fresh/embedded-plugins/{hash}/lib/fresh.d.ts)tsconfig.json with plugin-appropriate settingsplugin.tsUpdate buffer metadata:
kind to BufferKind::File { path: temp_plugin_path, uri: file_uri }BufferKind::PluginDev { backing_file, original_name } to track the associationlsp_enabled: truefile_uri() return Some(...), enabling the entire LSP pipelineEnsure TypeScript LSP auto-starts for plugin buffers:
auto_start: false for TypeScript LSPlsp.allow_language("typescript") to enable itwith_lsp_for_buffer() → try_spawn() flow will then work normallyBuffer change sync:
didChange notifications already handle in-memory updates to the LSPdidOpenCleanup on buffer close:
PluginDevWorkspace trackingCleanup on editor exit:
~/.cache/fresh/plugin-dev/ contents/// <reference path or calls getEditor(), offer to enable plugin LSP mode~/.cache/fresh/plugin-dev/{buffer_id}/fresh.d.ts and tsconfig.jsontsconfig.json roots).ts files — but this could cause cross-contamination of types between plugins~/.config/fresh/plugins/my_plugin.ts, the metadata should update to point to the real filefresh.d.ts is findable — it is, via /// <reference path>)lib/fresh.d.ts exists theregetEditor() etc.typescript-lsp.ts plugin already handles this case with a helpful popupThe current fresh.d.ts uses declare function at global scope, which is exactly how the QuickJS runtime exposes these APIs. This means:
tsconfig.json just needs to include it in "files" and it will provide global type augmentationgetEditor(), registerHandler(), ProcessHandle<T>, EditorAPI — all correctly typed as globals{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"lib": ["ES2020"],
"types": []
},
"files": ["fresh.d.ts", "plugin.ts"]
}
Key decisions:
"dom" lib: Plugins run in QuickJS, not a browser — no window, document, fetch"types": []: Prevents picking up @types/node or other ambient types that don't exist in QuickJS"strict": true: Helps catch bugs in plugin codeES2020 target: Matches QuickJS's capability level"skipLibCheck": true: Don't waste time checking fresh.d.ts itself"files" not "include": Explicit file list prevents picking up stray .ts files| Risk | Mitigation |
|---|---|
| Temp file accumulation | Session-scoped dirs + cleanup on exit + periodic cache cleanup |
| LSP startup latency | TypeScript LSP is slow to initialize (~2-5s); show loading indicator |
| Stale type definitions | fresh.d.ts comes from the same binary that's running — always in sync |
| Disk space | Each workspace is ~50KB (fresh.d.ts + tsconfig.json); negligible |
| Cross-platform temp paths | Use dirs::cache_dir() (already used by embedded plugins) |