From 1458da58cca6472dd57942e6cf4d648a5d287276 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 24 Nov 2025 12:19:58 -0800 Subject: [PATCH 1/3] Shrimp was broken --- examples/find.shrimp | 1 + .../plans/2025-01-24-autocomplete-design.md | 345 +++++++++ .../{example.sh => example.shrimp} | 0 vscode-extension/package.json | 7 +- .../scripts/generate-prelude-metadata.ts | 117 +++ .../src/completion/completionProvider.ts | 52 ++ .../server/src/completion/contextAnalyzer.ts | 66 ++ .../server/src/editorScopeAnalyzer.ts | 5 +- .../src/metadata/prelude-completions.ts | 732 ++++++++++++++++++ .../server/src/metadata/prelude-names.ts | 40 + vscode-extension/server/src/server.ts | 38 +- vscode-extension/tmp/test-dotget-parse.ts | 41 + 12 files changed, 1436 insertions(+), 8 deletions(-) create mode 100644 examples/find.shrimp create mode 100644 vscode-extension/docs/plans/2025-01-24-autocomplete-design.md rename vscode-extension/{example.sh => example.shrimp} (100%) create mode 100644 vscode-extension/scripts/generate-prelude-metadata.ts create mode 100644 vscode-extension/server/src/completion/completionProvider.ts create mode 100644 vscode-extension/server/src/completion/contextAnalyzer.ts create mode 100644 vscode-extension/server/src/metadata/prelude-completions.ts create mode 100644 vscode-extension/server/src/metadata/prelude-names.ts create mode 100644 vscode-extension/tmp/test-dotget-parse.ts diff --git a/examples/find.shrimp b/examples/find.shrimp new file mode 100644 index 0000000..1a84c32 --- /dev/null +++ b/examples/find.shrimp @@ -0,0 +1 @@ +echo \ No newline at end of file diff --git a/vscode-extension/docs/plans/2025-01-24-autocomplete-design.md b/vscode-extension/docs/plans/2025-01-24-autocomplete-design.md new file mode 100644 index 0000000..ea3b05e --- /dev/null +++ b/vscode-extension/docs/plans/2025-01-24-autocomplete-design.md @@ -0,0 +1,345 @@ +# Autocomplete Design for Shrimp VSCode Extension + +**Date:** 2025-01-24 +**Status:** Approved, ready for implementation + +## Goal + +Add intelligent autocomplete for Shrimp prelude functions and dollar properties in the VSCode extension. + +## Scope + +### Phase 1 (This Implementation) +- **Module completions:** `dict.*`, `list.*`, `str.*` member functions +- **Dollar completions:** `$.*` runtime properties +- **Trigger behavior:** + - Auto-trigger on `.` for module/dollar members + - Manual trigger (Ctrl+Space) for keywords (existing behavior) + +### Future Phases (Deferred) +- Top-level prelude globals (echo, type, inspect, etc.) +- Completion descriptions and documentation +- Chained property access (e.g., `dict.keys.`) + +## Requirements + +1. Show function signatures with parameter names (e.g., `get(dict, key)`) +2. Use proper VSCode completion kinds (Method for functions, Property for dollar) +3. Parser-based context detection (no regex hacks) +4. TypeScript metadata for easy maintenance and editor support +5. Minimal, readable code + +## Architecture + +### File Structure + +``` +vscode-extension/ +├── server/src/ +│ ├── metadata/ +│ │ └── prelude-completions.ts # TypeScript metadata +│ ├── completion/ +│ │ ├── completionProvider.ts # Main completion logic +│ │ └── contextAnalyzer.ts # Parser-based context detection +│ └── server.ts # Wire up completion handler +``` + +### Data Flow + +1. User types `dict.` → VSCode sends `onCompletion` request with cursor position +2. `contextAnalyzer` uses Shrimp parser to determine context: + - Is cursor in a DotGet node? + - What's on the left side of the dot? +3. `completionProvider` returns appropriate completions based on context: + - Module functions if `dict.`, `list.`, or `str.` + - Dollar properties if `$.` + - Empty list otherwise (fall back to keywords) + +## Parser Behavior Analysis + +### Test Results + +We tested how incomplete and partial DotGet expressions parse: + +| Input | Parse Result | Node at cursor | +|------------|-----------------------------------------|----------------| +| `dict.` | `DotGet(IdentifierBeforeDot, ⚠)` | `DotGet` | +| `dict.g` | `DotGet(IdentifierBeforeDot, Identifier)` | `Identifier` | +| `dict.get` | `DotGet(IdentifierBeforeDot, Identifier)` | `Identifier` | +| `$.` | `DotGet(Dollar, ⚠)` | `DotGet` | +| `$.e` | `DotGet(Dollar, Identifier)` | `Identifier` | +| `$.env` | `DotGet(Dollar, Identifier)` | `Identifier` | + +**Key Insights:** +- Incomplete DotGet (`dict.`, `$.`) → `resolveInner` returns `DotGet` node directly +- Partial identifier (`dict.g`, `$.e`) → `resolveInner` returns `Identifier` node (parent is `DotGet`) +- Error node (⚠) marks incomplete input, perfect for triggering autocomplete + +### Context Detection Strategy + +Handle both cases: +1. **Node type is `DotGet`** → cursor right after dot +2. **Node type is `Identifier` with `DotGet` parent** → typing property name + +Extract left side of dot to determine if it's a known module or `$`. + +## Implementation Details + +### 1. Metadata Structure + +**File:** `server/src/metadata/prelude-completions.ts` + +```typescript +export type CompletionMetadata = { + params: string[] + description?: string // Optional for now, add later +} + +export const completions = { + modules: { + dict: { + 'get': { params: ['dict', 'key'] }, + 'set': { params: ['dict', 'key', 'value'] }, + 'keys': { params: ['dict'] }, + 'values': { params: ['dict'] }, + 'has?': { params: ['dict', 'key'] }, + }, + list: { + 'map': { params: ['list', 'fn'] }, + 'filter': { params: ['list', 'fn'] }, + 'reduce': { params: ['list', 'fn', 'initial'] }, + 'reverse': { params: ['list'] }, + 'length': { params: ['list'] }, + }, + str: { + 'split': { params: ['str', 'separator'] }, + 'join': { params: ['list', 'separator'] }, + 'trim': { params: ['str'] }, + 'uppercase': { params: ['str'] }, + 'lowercase': { params: ['str'] }, + }, + }, + dollar: { + 'env': { params: [] }, + 'pid': { params: [] }, + 'args': { params: [] }, + 'argv': { params: [] }, + 'cwd': { params: [] }, + 'script': { params: [] }, + }, +} as const +``` + +**Design decisions:** +- TypeScript (not JSON) for editor autocomplete while editing metadata +- `as const` for type safety +- Single export with nested structure (`completions.modules`, `completions.dollar`) +- Start with ~20 most common functions per module + +### 2. Context Analyzer + +**File:** `server/src/completion/contextAnalyzer.ts` + +```typescript +import { TextDocument } from 'vscode-languageserver-textdocument' +import { SyntaxNode } from '@lezer/common' +import { parser } from '../../../src/parser/shrimp' +import * as Terms from '../../../src/parser/shrimp.terms' + +export type CompletionContext = + | { type: 'module', moduleName: string } + | { type: 'dollar' } + | { type: 'none' } + +export const analyzeCompletionContext = ( + document: TextDocument, + position: { line: number, character: number } +): CompletionContext => { + const offset = document.offsetAt(position) + const text = document.getText() + const tree = parser.parse(text) + + // Find node at cursor - could be DotGet or Identifier inside DotGet + let node = tree.resolveInner(offset, -1) + + // Case 1: Incomplete DotGet (dict. or $.) + // resolveInner returns DotGet node directly + if (node.type.id === Terms.DotGet) { + const leftSide = extractLeftSide(node, text) + if (leftSide === '$') return { type: 'dollar' } + if (['dict', 'list', 'str'].includes(leftSide)) { + return { type: 'module', moduleName: leftSide } + } + } + + // Case 2: Partial identifier (dict.g or $.e) + // resolveInner returns Identifier, parent is DotGet + if (node.type.id === Terms.Identifier && node.parent?.type.id === Terms.DotGet) { + const dotGetNode = node.parent + const leftSide = extractLeftSide(dotGetNode, text) + if (leftSide === '$') return { type: 'dollar' } + if (['dict', 'list', 'str'].includes(leftSide)) { + return { type: 'module', moduleName: leftSide } + } + } + + return { type: 'none' } +} + +const extractLeftSide = (dotGetNode: SyntaxNode, text: string): string => { + const firstChild = dotGetNode.firstChild + if (!firstChild) return '' + return text.slice(firstChild.from, firstChild.to) +} +``` + +**Design decisions:** +- Use Shrimp parser (not regex) for accurate token detection +- Return typed context for type-safe consumption +- Handle both incomplete and partial completion cases +- Simple left-side extraction from first child of DotGet + +### 3. Completion Provider + +**File:** `server/src/completion/completionProvider.ts` + +```typescript +import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { completions } from '../metadata/prelude-completions' +import { analyzeCompletionContext } from './contextAnalyzer' + +export const provideCompletions = ( + document: TextDocument, + position: { line: number, character: number } +): CompletionItem[] => { + const context = analyzeCompletionContext(document, position) + + if (context.type === 'module') { + return buildModuleCompletions(context.moduleName) + } + + if (context.type === 'dollar') { + return buildDollarCompletions() + } + + return [] // No completions for other contexts yet +} + +const buildModuleCompletions = (moduleName: string): CompletionItem[] => { + const functions = completions.modules[moduleName as keyof typeof completions.modules] + if (!functions) return [] + + return Object.entries(functions).map(([name, meta]) => ({ + label: name, + kind: CompletionItemKind.Method, + detail: `(${meta.params.join(', ')})`, + insertText: name, + })) +} + +const buildDollarCompletions = (): CompletionItem[] => { + return Object.entries(completions.dollar).map(([name, meta]) => ({ + label: name, + kind: CompletionItemKind.Property, + insertText: name, + })) +} +``` + +**Design decisions:** +- `CompletionItemKind.Method` for module functions (shows function icon) +- `CompletionItemKind.Property` for dollar properties (shows property icon) +- `detail` field shows parameter signature +- Simple and focused - easy to extend later with descriptions + +### 4. Server Integration + +**File:** `server/src/server.ts` (updates) + +**Update initialization to declare trigger character:** + +```typescript +function handleInitialize(): InitializeResult { + connection.console.log('🦐 Server initialized with capabilities') + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Full, + completionProvider: { + triggerCharacters: ['.'], // Auto-trigger on dot + }, + semanticTokensProvider: { /* ... existing ... */ }, + }, + } +} +``` + +**Update completion handler:** + +```typescript +import { provideCompletions } from './completion/completionProvider' + +function handleCompletion(params: any) { + const document = documents.get(params.textDocument.uri) + if (!document) return [] + + const position = params.position + + // First try context-aware completions (module/dollar) + const contextCompletions = provideCompletions(document, position) + if (contextCompletions.length > 0) { + return contextCompletions + } + + // Fall back to keywords (for Ctrl+Space in general context) + const keywords = ['if', 'else', 'do', 'end', 'and', 'or', 'true', 'false', 'null'] + return keywords.map((keyword) => ({ + label: keyword, + kind: CompletionItemKind.Keyword, + })) +} +``` + +**Design decisions:** +- Try context completions first (module/dollar) +- Fall back to keywords if no context match +- Preserves existing keyword completion behavior +- Simple, linear flow + +## Testing Strategy + +### Manual Testing +1. Type `dict.` → should show dict completions +2. Type `list.m` → should filter to `map` +3. Type `$.` → should show dollar properties +4. Type random identifier + `.` → should show nothing (fall back to keywords) +5. Verify signatures appear in completion detail + +### Future Automated Testing +- Unit tests for context analyzer with various parse trees +- Unit tests for completion provider with mock contexts +- Integration tests for end-to-end completion flow + +## Future Enhancements + +### Phase 2: Documentation +- Add `description` field to metadata +- Show in completion item documentation +- Consider pulling from actual prelude JSDoc comments + +### Phase 3: Top-Level Completions +- Show prelude globals on Ctrl+Space +- Requires different trigger logic (not just dot) +- May need position-aware filtering (don't show in strings/comments) + +### Phase 4: Advanced Features +- Chained completions (`dict.keys.`) +- Type-aware completions (know what `dict.keys` returns) +- Snippet completions with parameter placeholders +- Fuzzy matching for better search + +## References + +- [VSCode Language Server Guide](https://code.visualstudio.com/api/language-extensions/language-server-extension-guide) +- [CompletionItemKind Documentation](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#completionItemKind) +- Shrimp Grammar: `src/parser/shrimp.grammar` (lines 245-248 for DotGet definition) diff --git a/vscode-extension/example.sh b/vscode-extension/example.shrimp similarity index 100% rename from vscode-extension/example.sh rename to vscode-extension/example.shrimp diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 0b5d6bb..1422480 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -19,7 +19,7 @@ "shrimp" ], "extensions": [ - ".sh" + ".shrimp" ], "configuration": "./language-configuration.json" } @@ -80,11 +80,12 @@ "publisher": "shrimp-lang", "scripts": { "vscode:prepublish": "bun run package", - "compile": "bun run compile:client && bun run compile:server", + "generate-prelude-metadata": "bun scripts/generate-prelude-metadata.ts", + "compile": "bun run generate-prelude-metadata && bun run compile:client && bun run compile:server", "compile:client": "bun build client/src/extension.ts --outdir client/dist --target node --format cjs --external vscode", "compile:server": "bun build server/src/server.ts --outdir server/dist --target node --format cjs", "watch": "bun run compile:client --watch & bun run compile:server --watch", - "package": "bun run compile:client --minify && bun run compile:server --minify", + "package": "bun run generate-prelude-metadata && bun run compile:client --minify && bun run compile:server --minify", "check-types": "tsc --noEmit", "build-and-install": "bun run package && bunx @vscode/vsce package --allow-missing-repository && code --install-extension shrimp-*.vsix" }, diff --git a/vscode-extension/scripts/generate-prelude-metadata.ts b/vscode-extension/scripts/generate-prelude-metadata.ts new file mode 100644 index 0000000..594a725 --- /dev/null +++ b/vscode-extension/scripts/generate-prelude-metadata.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env bun +/** + * Generates prelude metadata for the VSCode extension. + * - Prelude names (for parser scope tracking) + * - Function signatures (for autocomplete) + */ + +import { writeFileSync } from 'fs' +import { join } from 'path' +import { globals } from '../../src/prelude' + +// Extract parameter names from a function +const extractParams = (fn: Function): string[] => { + const fnStr = fn.toString() + const match = fnStr.match(/\(([^)]*)\)/) + if (!match) return [] + + const paramsStr = match[1]!.trim() + if (!paramsStr) return [] + + // Split by comma, but be careful of default values with commas + const params: string[] = [] + let current = '' + let inString = false + let stringChar = '' + + for (let i = 0; i < paramsStr.length; i++) { + const char = paramsStr[i] + if ((char === '"' || char === "'") && (i === 0 || paramsStr[i - 1] !== '\\')) { + if (!inString) { + inString = true + stringChar = char + } else if (char === stringChar) { + inString = false + } + } + + if (char === ',' && !inString) { + params.push(current.trim()) + current = '' + } else { + current += char + } + } + if (current.trim()) params.push(current.trim()) + + return params + .map((p) => p.split(/[=:]/)[0]!.trim()) // Handle defaults and types + .filter((p) => p && p !== 'this') +} + +// Generate metadata for a module +const generateModuleMetadata = (module: Record) => { + const metadata: Record = {} + + for (const [name, value] of Object.entries(module)) { + if (typeof value === 'function') { + metadata[name] = { params: extractParams(value) } + } + } + + return metadata +} + +// Generate names list +const names = Object.keys(globals).sort() + +// Generate module metadata +const moduleMetadata: Record = {} +for (const [name, value] of Object.entries(globals)) { + if (typeof value === 'object' && value !== null && name !== '$') { + moduleMetadata[name] = generateModuleMetadata(value) + } +} + +// Generate dollar metadata +const dollarMetadata: Record = {} +if (globals.$ && typeof globals.$ === 'object') { + for (const key of Object.keys(globals.$)) { + dollarMetadata[key] = { params: [] } + } +} + +// Write prelude-names.ts +const namesOutput = `// Auto-generated by scripts/generate-prelude-metadata.ts +// Do not edit manually - run 'bun run generate-prelude-metadata' to regenerate + +export const PRELUDE_NAMES = ${JSON.stringify(names, null, 2)} as const +` + +const namesPath = join(import.meta.dir, '../server/src/metadata/prelude-names.ts') +writeFileSync(namesPath, namesOutput) + +// Write prelude-completions.ts +const completionsOutput = `// Auto-generated by scripts/generate-prelude-metadata.ts +// Do not edit manually - run 'bun run generate-prelude-metadata' to regenerate + +export type CompletionMetadata = { + params: string[] + description?: string +} + +export const completions = { + modules: ${JSON.stringify(moduleMetadata, null, 2)}, + dollar: ${JSON.stringify(dollarMetadata, null, 2)}, +} as const +` + +const completionsPath = join(import.meta.dir, '../server/src/metadata/prelude-completions.ts') +writeFileSync(completionsPath, completionsOutput) + +console.log(`✓ Generated ${names.length} prelude names to server/src/metadata/prelude-names.ts`) +console.log( + `✓ Generated completions for ${ + Object.keys(moduleMetadata).length + } modules to server/src/metadata/prelude-completions.ts` +) diff --git a/vscode-extension/server/src/completion/completionProvider.ts b/vscode-extension/server/src/completion/completionProvider.ts new file mode 100644 index 0000000..11d11cd --- /dev/null +++ b/vscode-extension/server/src/completion/completionProvider.ts @@ -0,0 +1,52 @@ +import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { completions } from '../metadata/prelude-completions' +import { analyzeCompletionContext } from './contextAnalyzer' + +/** + * Provides context-aware completions for Shrimp code. + * Returns module function completions (dict.*, list.*, str.*) or dollar property + * completions ($.*) based on the cursor position. + */ +export const provideCompletions = ( + document: TextDocument, + position: { line: number; character: number } +): CompletionItem[] => { + const context = analyzeCompletionContext(document, position) + + if (context.type === 'module') { + return buildModuleCompletions(context.moduleName) + } + + if (context.type === 'dollar') { + return buildDollarCompletions() + } + + return [] // No completions for other contexts yet +} + +/** + * Builds completion items for module functions (dict.*, list.*, str.*). + */ +const buildModuleCompletions = (moduleName: string): CompletionItem[] => { + const functions = completions.modules[moduleName as keyof typeof completions.modules] + if (!functions) return [] + + return Object.entries(functions).map(([name, meta]) => ({ + label: name, + kind: CompletionItemKind.Method, + detail: `(${meta.params.join(', ')})`, + insertText: name, + })) +} + +/** + * Builds completion items for dollar properties ($.*). + */ +const buildDollarCompletions = (): CompletionItem[] => { + return Object.entries(completions.dollar).map(([name, meta]) => ({ + label: name, + kind: CompletionItemKind.Property, + insertText: name, + })) +} diff --git a/vscode-extension/server/src/completion/contextAnalyzer.ts b/vscode-extension/server/src/completion/contextAnalyzer.ts new file mode 100644 index 0000000..07d2aff --- /dev/null +++ b/vscode-extension/server/src/completion/contextAnalyzer.ts @@ -0,0 +1,66 @@ +import { TextDocument } from 'vscode-languageserver-textdocument' +import { SyntaxNode } from '@lezer/common' +import { parser } from '../../../../src/parser/shrimp' +import * as Terms from '../../../../src/parser/shrimp.terms' + +export type CompletionContext = + | { type: 'module'; moduleName: string } + | { type: 'dollar' } + | { type: 'none' } + +/** + * Analyzes the document at the given position to determine what kind of + * completion context we're in (module member access, dollar property, or none). + */ +export const analyzeCompletionContext = ( + document: TextDocument, + position: { line: number; character: number } +): CompletionContext => { + const offset = document.offsetAt(position) + const text = document.getText() + const tree = parser.parse(text) + + // Find node at cursor - could be DotGet or Identifier inside DotGet + const node = tree.resolveInner(offset, -1) + + console.log(`🔍 Node at cursor: ${node.name} (type: ${node.type.id})`) + console.log(`🔍 Parent: ${node.parent?.name} (type: ${node.parent?.type.id})`) + console.log(`🔍 Node text: "${text.slice(node.from, node.to)}"`) + + const SUPPORTED_MODULES = ['dict', 'list', 'str', 'math', 'fs', 'json', 'load'] + + // Case 1: Incomplete DotGet (dict. or $.) + // resolveInner returns DotGet node directly + if (node.type.id === Terms.DotGet) { + const leftSide = extractLeftSide(node, text) + console.log(`✅ Case 1: DotGet found, left side: "${leftSide}"`) + if (leftSide === '$') return { type: 'dollar' } + if (SUPPORTED_MODULES.includes(leftSide)) { + return { type: 'module', moduleName: leftSide } + } + } + + // Case 2: Partial identifier (dict.g or $.e) + // resolveInner returns Identifier, parent is DotGet + if (node.type.id === Terms.Identifier && node.parent?.type.id === Terms.DotGet) { + const dotGetNode = node.parent + const leftSide = extractLeftSide(dotGetNode, text) + console.log(`✅ Case 2: Identifier in DotGet found, left side: "${leftSide}"`) + if (leftSide === '$') return { type: 'dollar' } + if (SUPPORTED_MODULES.includes(leftSide)) { + return { type: 'module', moduleName: leftSide } + } + } + + console.log(`❌ No matching context found`) + return { type: 'none' } +} + +/** + * Extracts the text of the left side of a DotGet node (the part before the dot). + */ +const extractLeftSide = (dotGetNode: SyntaxNode, text: string): string => { + const firstChild = dotGetNode.firstChild + if (!firstChild) return '' + return text.slice(firstChild.from, firstChild.to) +} diff --git a/vscode-extension/server/src/editorScopeAnalyzer.ts b/vscode-extension/server/src/editorScopeAnalyzer.ts index 449c3df..a911970 100644 --- a/vscode-extension/server/src/editorScopeAnalyzer.ts +++ b/vscode-extension/server/src/editorScopeAnalyzer.ts @@ -1,7 +1,7 @@ import { SyntaxNode } from '@lezer/common' import { TextDocument } from 'vscode-languageserver-textdocument' import * as Terms from '../../../src/parser/shrimp.terms' -import { globals } from '../../../src/prelude' +import { PRELUDE_NAMES } from './metadata/prelude-names' /** * Tracks variables in scope at a given position in the parse tree. @@ -13,8 +13,7 @@ export class EditorScopeAnalyzer { constructor(document: TextDocument) { this.document = document - const preludeKeys = Object.keys(globals) - this.scopeCache.set(0, new Set(preludeKeys)) + this.scopeCache.set(0, new Set(PRELUDE_NAMES)) } /** diff --git a/vscode-extension/server/src/metadata/prelude-completions.ts b/vscode-extension/server/src/metadata/prelude-completions.ts new file mode 100644 index 0000000..1b9992a --- /dev/null +++ b/vscode-extension/server/src/metadata/prelude-completions.ts @@ -0,0 +1,732 @@ +// Auto-generated by scripts/generate-prelude-metadata.ts +// Do not edit manually - run 'bun run generate-prelude-metadata' to regenerate + +export type CompletionMetadata = { + params: string[] + description?: string +} + +export const completions = { + modules: { + "dict": { + "keys": { + "params": [ + "dict" + ] + }, + "values": { + "params": [ + "dict" + ] + }, + "entries": { + "params": [ + "dict" + ] + }, + "has?": { + "params": [ + "dict", + "key" + ] + }, + "get": { + "params": [ + "dict", + "key", + "defaultValue" + ] + }, + "set": { + "params": [ + "dict", + "key", + "value" + ] + }, + "merge": { + "params": [ + "...dicts" + ] + }, + "empty?": { + "params": [ + "dict" + ] + }, + "map": { + "params": [ + "dict", + "cb" + ] + }, + "filter": { + "params": [ + "dict", + "cb" + ] + }, + "from-entries": { + "params": [ + "entries" + ] + } + }, + "fs": { + "ls": { + "params": [ + "path" + ] + }, + "mkdir": { + "params": [ + "path" + ] + }, + "rmdir": { + "params": [ + "path" + ] + }, + "pwd": { + "params": [] + }, + "cd": { + "params": [ + "path" + ] + }, + "read": { + "params": [ + "path" + ] + }, + "cat": { + "params": [ + "path" + ] + }, + "read-bytes": { + "params": [ + "path" + ] + }, + "write": { + "params": [ + "path", + "content" + ] + }, + "append": { + "params": [ + "path", + "content" + ] + }, + "delete": { + "params": [ + "path" + ] + }, + "rm": { + "params": [ + "path" + ] + }, + "copy": { + "params": [ + "from", + "to" + ] + }, + "move": { + "params": [ + "from", + "to" + ] + }, + "mv": { + "params": [ + "from", + "to" + ] + }, + "basename": { + "params": [ + "path" + ] + }, + "dirname": { + "params": [ + "path" + ] + }, + "extname": { + "params": [ + "path" + ] + }, + "join": { + "params": [ + "...paths" + ] + }, + "resolve": { + "params": [ + "...paths" + ] + }, + "stat": { + "params": [ + "path" + ] + }, + "exists?": { + "params": [ + "path" + ] + }, + "file?": { + "params": [ + "path" + ] + }, + "dir?": { + "params": [ + "path" + ] + }, + "symlink?": { + "params": [ + "path" + ] + }, + "exec?": { + "params": [ + "path" + ] + }, + "size": { + "params": [ + "path" + ] + }, + "chmod": { + "params": [ + "path", + "mode" + ] + }, + "symlink": { + "params": [ + "target", + "path" + ] + }, + "readlink": { + "params": [ + "path" + ] + }, + "glob": { + "params": [ + "pattern" + ] + }, + "watch": { + "params": [ + "path", + "callback" + ] + }, + "cp": { + "params": [ + "from", + "to" + ] + } + }, + "json": { + "encode": { + "params": [ + "s" + ] + }, + "decode": { + "params": [ + "s" + ] + }, + "parse": { + "params": [ + "s" + ] + }, + "stringify": { + "params": [ + "s" + ] + } + }, + "list": { + "slice": { + "params": [ + "list", + "start", + "end" + ] + }, + "map": { + "params": [ + "list", + "cb" + ] + }, + "filter": { + "params": [ + "list", + "cb" + ] + }, + "reject": { + "params": [ + "list", + "cb" + ] + }, + "reduce": { + "params": [ + "list", + "cb", + "initial" + ] + }, + "find": { + "params": [ + "list", + "cb" + ] + }, + "empty?": { + "params": [ + "list" + ] + }, + "contains?": { + "params": [ + "list", + "item" + ] + }, + "includes?": { + "params": [ + "list", + "item" + ] + }, + "has?": { + "params": [ + "list", + "item" + ] + }, + "any?": { + "params": [ + "list", + "cb" + ] + }, + "all?": { + "params": [ + "list", + "cb" + ] + }, + "push": { + "params": [ + "list", + "item" + ] + }, + "pop": { + "params": [ + "list" + ] + }, + "shift": { + "params": [ + "list" + ] + }, + "unshift": { + "params": [ + "list", + "item" + ] + }, + "splice": { + "params": [ + "list", + "start", + "deleteCount", + "...items" + ] + }, + "insert": { + "params": [ + "list", + "index", + "item" + ] + }, + "reverse": { + "params": [ + "list" + ] + }, + "sort": { + "params": [ + "list", + "cb" + ] + }, + "concat": { + "params": [ + "...lists" + ] + }, + "flatten": { + "params": [ + "list", + "depth" + ] + }, + "unique": { + "params": [ + "list" + ] + }, + "zip": { + "params": [ + "list1", + "list2" + ] + }, + "first": { + "params": [ + "list" + ] + }, + "last": { + "params": [ + "list" + ] + }, + "rest": { + "params": [ + "list" + ] + }, + "take": { + "params": [ + "list", + "n" + ] + }, + "drop": { + "params": [ + "list", + "n" + ] + }, + "append": { + "params": [ + "list", + "item" + ] + }, + "prepend": { + "params": [ + "list", + "item" + ] + }, + "index-of": { + "params": [ + "list", + "item" + ] + }, + "sum": { + "params": [ + "list" + ] + }, + "count": { + "params": [ + "list", + "cb" + ] + }, + "partition": { + "params": [ + "list", + "cb" + ] + }, + "compact": { + "params": [ + "list" + ] + }, + "group-by": { + "params": [ + "list", + "cb" + ] + } + }, + "math": { + "abs": { + "params": [ + "n" + ] + }, + "floor": { + "params": [ + "n" + ] + }, + "ceil": { + "params": [ + "n" + ] + }, + "round": { + "params": [ + "n" + ] + }, + "min": { + "params": [ + "...nums" + ] + }, + "max": { + "params": [ + "...nums" + ] + }, + "pow": { + "params": [ + "base", + "exp" + ] + }, + "sqrt": { + "params": [ + "n" + ] + }, + "random": { + "params": [] + }, + "clamp": { + "params": [ + "n", + "min", + "max" + ] + }, + "sign": { + "params": [ + "n" + ] + }, + "trunc": { + "params": [ + "n" + ] + }, + "even?": { + "params": [ + "n" + ] + }, + "odd?": { + "params": [ + "n" + ] + }, + "positive?": { + "params": [ + "n" + ] + }, + "negative?": { + "params": [ + "n" + ] + }, + "zero?": { + "params": [ + "n" + ] + } + }, + "str": { + "join": { + "params": [ + "arr", + "sep" + ] + }, + "split": { + "params": [ + "str", + "sep" + ] + }, + "to-upper": { + "params": [ + "str" + ] + }, + "to-lower": { + "params": [ + "str" + ] + }, + "trim": { + "params": [ + "str" + ] + }, + "starts-with?": { + "params": [ + "str", + "prefix" + ] + }, + "ends-with?": { + "params": [ + "str", + "suffix" + ] + }, + "contains?": { + "params": [ + "str", + "substr" + ] + }, + "empty?": { + "params": [ + "str" + ] + }, + "index-of": { + "params": [ + "str", + "search" + ] + }, + "last-index-of": { + "params": [ + "str", + "search" + ] + }, + "replace": { + "params": [ + "str", + "search", + "replacement" + ] + }, + "replace-all": { + "params": [ + "str", + "search", + "replacement" + ] + }, + "slice": { + "params": [ + "str", + "start", + "end" + ] + }, + "substring": { + "params": [ + "str", + "start", + "end" + ] + }, + "repeat": { + "params": [ + "str", + "count" + ] + }, + "pad-start": { + "params": [ + "str", + "length", + "pad" + ] + }, + "pad-end": { + "params": [ + "str", + "length", + "pad" + ] + }, + "lines": { + "params": [ + "str" + ] + }, + "chars": { + "params": [ + "str" + ] + }, + "match": { + "params": [ + "str", + "regex" + ] + }, + "test?": { + "params": [ + "str", + "regex" + ] + } + } +}, + dollar: { + "args": { + "params": [] + }, + "argv": { + "params": [] + }, + "env": { + "params": [] + }, + "pid": { + "params": [] + }, + "cwd": { + "params": [] + }, + "script": { + "params": [] + } +}, +} as const diff --git a/vscode-extension/server/src/metadata/prelude-names.ts b/vscode-extension/server/src/metadata/prelude-names.ts new file mode 100644 index 0000000..9e268b8 --- /dev/null +++ b/vscode-extension/server/src/metadata/prelude-names.ts @@ -0,0 +1,40 @@ +// Auto-generated by scripts/generate-prelude-metadata.ts +// Do not edit manually - run 'bun run generate-prelude-metadata' to regenerate + +export const PRELUDE_NAMES = [ + "$", + "array?", + "at", + "bnot", + "boolean?", + "dec", + "describe", + "dict", + "dict?", + "each", + "echo", + "empty?", + "exit", + "fs", + "function?", + "identity", + "import", + "inc", + "inspect", + "json", + "length", + "list", + "load", + "math", + "not", + "null?", + "number?", + "range", + "ref", + "some?", + "str", + "string?", + "type", + "var", + "var?" +] as const diff --git a/vscode-extension/server/src/server.ts b/vscode-extension/server/src/server.ts index 194b35e..9177abb 100644 --- a/vscode-extension/server/src/server.ts +++ b/vscode-extension/server/src/server.ts @@ -1,7 +1,10 @@ import { TextDocument } from 'vscode-languageserver-textdocument' import { buildDiagnostics } from './diagnostics' import { buildSemanticTokens, TOKEN_MODIFIERS, TOKEN_TYPES } from './semanticTokens' +import { provideCompletions } from './completion/completionProvider' +import { PRELUDE_NAMES } from './metadata/prelude-names' import { parser } from '../../../src/parser/shrimp' +import { setGlobals } from '../../../src/parser/tokenizer' import { Compiler } from '../../../src/compiler/compiler' import { InitializeResult, @@ -12,6 +15,9 @@ import { CompletionItemKind, } from 'vscode-languageserver/node' +// Initialize parser with prelude globals so it knows dict/list/str are in scope +setGlobals(PRELUDE_NAMES) + const connection = createConnection(ProposedFeatures.all) const documents = new TextDocuments(TextDocument) documents.listen(connection) @@ -71,12 +77,40 @@ function handleDocumentChange(change: any) { } function handleCompletion(params: any) { - const keywords = ['if', 'else', 'do', 'end', 'and', 'or', 'true', 'false', 'null'] + console.log(`🌭 YOU ARE COMPLETING at position`, params.position) + const document = documents.get(params.textDocument.uri) + if (!document) { + console.log('❌ No document found') + return [] + } - return keywords.map((keyword) => ({ + const position = params.position + const text = document.getText() + const offset = document.offsetAt(position) + console.log(`📍 Text around cursor: "${text.slice(Math.max(0, offset - 10), offset + 10)}"`) + + // First try context-aware completions (module/dollar) + const contextCompletions = provideCompletions(document, position) + console.log(`🎯 Context completions count: ${contextCompletions.length}`) + if (contextCompletions.length > 0) { + console.log(`✅ Returning ${contextCompletions.length} completions:`, contextCompletions.map(c => c.label).join(', ')) + return contextCompletions + } + + // Fall back to keywords + prelude globals (for Ctrl+Space in general context) + console.log(`⌨️ Falling back to keywords + prelude globals`) + const keywords = ['if', 'else', 'do', 'end', 'and', 'or', 'true', 'false', 'null'] + const keywordCompletions = keywords.map((keyword) => ({ label: keyword, kind: CompletionItemKind.Keyword, })) + + const preludeCompletions = PRELUDE_NAMES.map((name) => ({ + label: name, + kind: CompletionItemKind.Function, + })) + + return [...keywordCompletions, ...preludeCompletions] } function handleParseTree(params: { uri: string }) { diff --git a/vscode-extension/tmp/test-dotget-parse.ts b/vscode-extension/tmp/test-dotget-parse.ts new file mode 100644 index 0000000..7a654fb --- /dev/null +++ b/vscode-extension/tmp/test-dotget-parse.ts @@ -0,0 +1,41 @@ +import { parser } from '../../src/parser/shrimp' +import { setGlobals } from '../../src/parser/tokenizer' +import { PRELUDE_NAMES } from '../server/src/prelude-names' + +// Set globals for DotGet detection +setGlobals(PRELUDE_NAMES as unknown as string[]) + +// Test cases - does incomplete DotGet parse correctly? +const testCases = [ + 'dict.', + 'dict.g', + 'dict.get', + '$.', + '$.e', + '$.env', +] + +for (const code of testCases) { + console.log(`\nTesting: "${code}"`) + const tree = parser.parse(code) + const cursor = tree.cursor() + + // Print the parse tree + const printTree = (depth = 0) => { + const indent = ' '.repeat(depth) + console.log(`${indent}${cursor.name} [${cursor.from}-${cursor.to}]`) + + if (cursor.firstChild()) { + do { + printTree(depth + 1) + } while (cursor.nextSibling()) + cursor.parent() + } + } + + printTree() + + // Check at cursor position (end of string) + const node = tree.resolveInner(code.length, -1) + console.log(`Node at end: ${node.name} (type: ${node.type.id})`) +} From 028ccf2bf9861373d23a5a0b2dacf4ab9b97000b Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 24 Nov 2025 12:20:54 -0800 Subject: [PATCH 2/3] Delete 2025-01-24-autocomplete-design.md --- .../plans/2025-01-24-autocomplete-design.md | 345 ------------------ 1 file changed, 345 deletions(-) delete mode 100644 vscode-extension/docs/plans/2025-01-24-autocomplete-design.md diff --git a/vscode-extension/docs/plans/2025-01-24-autocomplete-design.md b/vscode-extension/docs/plans/2025-01-24-autocomplete-design.md deleted file mode 100644 index ea3b05e..0000000 --- a/vscode-extension/docs/plans/2025-01-24-autocomplete-design.md +++ /dev/null @@ -1,345 +0,0 @@ -# Autocomplete Design for Shrimp VSCode Extension - -**Date:** 2025-01-24 -**Status:** Approved, ready for implementation - -## Goal - -Add intelligent autocomplete for Shrimp prelude functions and dollar properties in the VSCode extension. - -## Scope - -### Phase 1 (This Implementation) -- **Module completions:** `dict.*`, `list.*`, `str.*` member functions -- **Dollar completions:** `$.*` runtime properties -- **Trigger behavior:** - - Auto-trigger on `.` for module/dollar members - - Manual trigger (Ctrl+Space) for keywords (existing behavior) - -### Future Phases (Deferred) -- Top-level prelude globals (echo, type, inspect, etc.) -- Completion descriptions and documentation -- Chained property access (e.g., `dict.keys.`) - -## Requirements - -1. Show function signatures with parameter names (e.g., `get(dict, key)`) -2. Use proper VSCode completion kinds (Method for functions, Property for dollar) -3. Parser-based context detection (no regex hacks) -4. TypeScript metadata for easy maintenance and editor support -5. Minimal, readable code - -## Architecture - -### File Structure - -``` -vscode-extension/ -├── server/src/ -│ ├── metadata/ -│ │ └── prelude-completions.ts # TypeScript metadata -│ ├── completion/ -│ │ ├── completionProvider.ts # Main completion logic -│ │ └── contextAnalyzer.ts # Parser-based context detection -│ └── server.ts # Wire up completion handler -``` - -### Data Flow - -1. User types `dict.` → VSCode sends `onCompletion` request with cursor position -2. `contextAnalyzer` uses Shrimp parser to determine context: - - Is cursor in a DotGet node? - - What's on the left side of the dot? -3. `completionProvider` returns appropriate completions based on context: - - Module functions if `dict.`, `list.`, or `str.` - - Dollar properties if `$.` - - Empty list otherwise (fall back to keywords) - -## Parser Behavior Analysis - -### Test Results - -We tested how incomplete and partial DotGet expressions parse: - -| Input | Parse Result | Node at cursor | -|------------|-----------------------------------------|----------------| -| `dict.` | `DotGet(IdentifierBeforeDot, ⚠)` | `DotGet` | -| `dict.g` | `DotGet(IdentifierBeforeDot, Identifier)` | `Identifier` | -| `dict.get` | `DotGet(IdentifierBeforeDot, Identifier)` | `Identifier` | -| `$.` | `DotGet(Dollar, ⚠)` | `DotGet` | -| `$.e` | `DotGet(Dollar, Identifier)` | `Identifier` | -| `$.env` | `DotGet(Dollar, Identifier)` | `Identifier` | - -**Key Insights:** -- Incomplete DotGet (`dict.`, `$.`) → `resolveInner` returns `DotGet` node directly -- Partial identifier (`dict.g`, `$.e`) → `resolveInner` returns `Identifier` node (parent is `DotGet`) -- Error node (⚠) marks incomplete input, perfect for triggering autocomplete - -### Context Detection Strategy - -Handle both cases: -1. **Node type is `DotGet`** → cursor right after dot -2. **Node type is `Identifier` with `DotGet` parent** → typing property name - -Extract left side of dot to determine if it's a known module or `$`. - -## Implementation Details - -### 1. Metadata Structure - -**File:** `server/src/metadata/prelude-completions.ts` - -```typescript -export type CompletionMetadata = { - params: string[] - description?: string // Optional for now, add later -} - -export const completions = { - modules: { - dict: { - 'get': { params: ['dict', 'key'] }, - 'set': { params: ['dict', 'key', 'value'] }, - 'keys': { params: ['dict'] }, - 'values': { params: ['dict'] }, - 'has?': { params: ['dict', 'key'] }, - }, - list: { - 'map': { params: ['list', 'fn'] }, - 'filter': { params: ['list', 'fn'] }, - 'reduce': { params: ['list', 'fn', 'initial'] }, - 'reverse': { params: ['list'] }, - 'length': { params: ['list'] }, - }, - str: { - 'split': { params: ['str', 'separator'] }, - 'join': { params: ['list', 'separator'] }, - 'trim': { params: ['str'] }, - 'uppercase': { params: ['str'] }, - 'lowercase': { params: ['str'] }, - }, - }, - dollar: { - 'env': { params: [] }, - 'pid': { params: [] }, - 'args': { params: [] }, - 'argv': { params: [] }, - 'cwd': { params: [] }, - 'script': { params: [] }, - }, -} as const -``` - -**Design decisions:** -- TypeScript (not JSON) for editor autocomplete while editing metadata -- `as const` for type safety -- Single export with nested structure (`completions.modules`, `completions.dollar`) -- Start with ~20 most common functions per module - -### 2. Context Analyzer - -**File:** `server/src/completion/contextAnalyzer.ts` - -```typescript -import { TextDocument } from 'vscode-languageserver-textdocument' -import { SyntaxNode } from '@lezer/common' -import { parser } from '../../../src/parser/shrimp' -import * as Terms from '../../../src/parser/shrimp.terms' - -export type CompletionContext = - | { type: 'module', moduleName: string } - | { type: 'dollar' } - | { type: 'none' } - -export const analyzeCompletionContext = ( - document: TextDocument, - position: { line: number, character: number } -): CompletionContext => { - const offset = document.offsetAt(position) - const text = document.getText() - const tree = parser.parse(text) - - // Find node at cursor - could be DotGet or Identifier inside DotGet - let node = tree.resolveInner(offset, -1) - - // Case 1: Incomplete DotGet (dict. or $.) - // resolveInner returns DotGet node directly - if (node.type.id === Terms.DotGet) { - const leftSide = extractLeftSide(node, text) - if (leftSide === '$') return { type: 'dollar' } - if (['dict', 'list', 'str'].includes(leftSide)) { - return { type: 'module', moduleName: leftSide } - } - } - - // Case 2: Partial identifier (dict.g or $.e) - // resolveInner returns Identifier, parent is DotGet - if (node.type.id === Terms.Identifier && node.parent?.type.id === Terms.DotGet) { - const dotGetNode = node.parent - const leftSide = extractLeftSide(dotGetNode, text) - if (leftSide === '$') return { type: 'dollar' } - if (['dict', 'list', 'str'].includes(leftSide)) { - return { type: 'module', moduleName: leftSide } - } - } - - return { type: 'none' } -} - -const extractLeftSide = (dotGetNode: SyntaxNode, text: string): string => { - const firstChild = dotGetNode.firstChild - if (!firstChild) return '' - return text.slice(firstChild.from, firstChild.to) -} -``` - -**Design decisions:** -- Use Shrimp parser (not regex) for accurate token detection -- Return typed context for type-safe consumption -- Handle both incomplete and partial completion cases -- Simple left-side extraction from first child of DotGet - -### 3. Completion Provider - -**File:** `server/src/completion/completionProvider.ts` - -```typescript -import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node' -import { TextDocument } from 'vscode-languageserver-textdocument' -import { completions } from '../metadata/prelude-completions' -import { analyzeCompletionContext } from './contextAnalyzer' - -export const provideCompletions = ( - document: TextDocument, - position: { line: number, character: number } -): CompletionItem[] => { - const context = analyzeCompletionContext(document, position) - - if (context.type === 'module') { - return buildModuleCompletions(context.moduleName) - } - - if (context.type === 'dollar') { - return buildDollarCompletions() - } - - return [] // No completions for other contexts yet -} - -const buildModuleCompletions = (moduleName: string): CompletionItem[] => { - const functions = completions.modules[moduleName as keyof typeof completions.modules] - if (!functions) return [] - - return Object.entries(functions).map(([name, meta]) => ({ - label: name, - kind: CompletionItemKind.Method, - detail: `(${meta.params.join(', ')})`, - insertText: name, - })) -} - -const buildDollarCompletions = (): CompletionItem[] => { - return Object.entries(completions.dollar).map(([name, meta]) => ({ - label: name, - kind: CompletionItemKind.Property, - insertText: name, - })) -} -``` - -**Design decisions:** -- `CompletionItemKind.Method` for module functions (shows function icon) -- `CompletionItemKind.Property` for dollar properties (shows property icon) -- `detail` field shows parameter signature -- Simple and focused - easy to extend later with descriptions - -### 4. Server Integration - -**File:** `server/src/server.ts` (updates) - -**Update initialization to declare trigger character:** - -```typescript -function handleInitialize(): InitializeResult { - connection.console.log('🦐 Server initialized with capabilities') - return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - completionProvider: { - triggerCharacters: ['.'], // Auto-trigger on dot - }, - semanticTokensProvider: { /* ... existing ... */ }, - }, - } -} -``` - -**Update completion handler:** - -```typescript -import { provideCompletions } from './completion/completionProvider' - -function handleCompletion(params: any) { - const document = documents.get(params.textDocument.uri) - if (!document) return [] - - const position = params.position - - // First try context-aware completions (module/dollar) - const contextCompletions = provideCompletions(document, position) - if (contextCompletions.length > 0) { - return contextCompletions - } - - // Fall back to keywords (for Ctrl+Space in general context) - const keywords = ['if', 'else', 'do', 'end', 'and', 'or', 'true', 'false', 'null'] - return keywords.map((keyword) => ({ - label: keyword, - kind: CompletionItemKind.Keyword, - })) -} -``` - -**Design decisions:** -- Try context completions first (module/dollar) -- Fall back to keywords if no context match -- Preserves existing keyword completion behavior -- Simple, linear flow - -## Testing Strategy - -### Manual Testing -1. Type `dict.` → should show dict completions -2. Type `list.m` → should filter to `map` -3. Type `$.` → should show dollar properties -4. Type random identifier + `.` → should show nothing (fall back to keywords) -5. Verify signatures appear in completion detail - -### Future Automated Testing -- Unit tests for context analyzer with various parse trees -- Unit tests for completion provider with mock contexts -- Integration tests for end-to-end completion flow - -## Future Enhancements - -### Phase 2: Documentation -- Add `description` field to metadata -- Show in completion item documentation -- Consider pulling from actual prelude JSDoc comments - -### Phase 3: Top-Level Completions -- Show prelude globals on Ctrl+Space -- Requires different trigger logic (not just dot) -- May need position-aware filtering (don't show in strings/comments) - -### Phase 4: Advanced Features -- Chained completions (`dict.keys.`) -- Type-aware completions (know what `dict.keys` returns) -- Snippet completions with parameter placeholders -- Fuzzy matching for better search - -## References - -- [VSCode Language Server Guide](https://code.visualstudio.com/api/language-extensions/language-server-extension-guide) -- [CompletionItemKind Documentation](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#completionItemKind) -- Shrimp Grammar: `src/parser/shrimp.grammar` (lines 245-248 for DotGet definition) From 09d24205084a1ee63aaef63c3823dd68a7d513a5 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Mon, 24 Nov 2025 16:04:03 -0800 Subject: [PATCH 3/3] add some arg help --- vscode-extension/server/src/server.ts | 11 ++ vscode-extension/server/src/signatureHelp.ts | 105 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 vscode-extension/server/src/signatureHelp.ts diff --git a/vscode-extension/server/src/server.ts b/vscode-extension/server/src/server.ts index 9177abb..5feacd9 100644 --- a/vscode-extension/server/src/server.ts +++ b/vscode-extension/server/src/server.ts @@ -2,6 +2,7 @@ import { TextDocument } from 'vscode-languageserver-textdocument' import { buildDiagnostics } from './diagnostics' import { buildSemanticTokens, TOKEN_MODIFIERS, TOKEN_TYPES } from './semanticTokens' import { provideCompletions } from './completion/completionProvider' +import { provideSignatureHelp } from './signatureHelp' import { PRELUDE_NAMES } from './metadata/prelude-names' import { parser } from '../../../src/parser/shrimp' import { setGlobals } from '../../../src/parser/tokenizer' @@ -29,6 +30,7 @@ connection.onInitialize(handleInitialize) connection.languages.semanticTokens.on(handleSemanticTokens) documents.onDidChangeContent(handleDocumentChange) connection.onCompletion(handleCompletion) +connection.onSignatureHelp(handleSignatureHelp) // Debug commands connection.onRequest('shrimp/parseTree', handleParseTree) @@ -49,6 +51,9 @@ function handleInitialize(): InitializeResult { completionProvider: { triggerCharacters: ['.'], }, + signatureHelpProvider: { + triggerCharacters: [' '], + }, semanticTokensProvider: { legend: { tokenTypes: TOKEN_TYPES, @@ -113,6 +118,12 @@ function handleCompletion(params: any) { return [...keywordCompletions, ...preludeCompletions] } +function handleSignatureHelp(params: any) { + const document = documents.get(params.textDocument.uri) + if (!document) return + return provideSignatureHelp(document, params.position) +} + function handleParseTree(params: { uri: string }) { connection.console.log(`🦐 Parse tree requested for: ${params.uri}`) const document = documents.get(params.uri) diff --git a/vscode-extension/server/src/signatureHelp.ts b/vscode-extension/server/src/signatureHelp.ts new file mode 100644 index 0000000..b356397 --- /dev/null +++ b/vscode-extension/server/src/signatureHelp.ts @@ -0,0 +1,105 @@ +import { SignatureHelp, SignatureInformation, ParameterInformation } from 'vscode-languageserver/node' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { Tree, SyntaxNode } from '@lezer/common' +import { parser } from '../../../src/parser/shrimp' +import { completions } from './metadata/prelude-completions' + +export const provideSignatureHelp = ( + document: TextDocument, + position: { line: number; character: number } +): SignatureHelp | undefined => { + const text = document.getText() + const tree = parser.parse(text) + const cursorPos = document.offsetAt(position) + + const context = findCallContext(tree, cursorPos, text) + if (!context) return + + const params = lookupFunctionParams(context.funcName) + if (!params) return + + return { + signatures: [buildSignature(context.funcName, params)], + activeParameter: Math.min(context.argCount, params.length - 1), + } +} + +const findCallContext = (tree: Tree, cursorPos: number, text: string) => { + const findBestCall = (node: SyntaxNode): SyntaxNode | undefined => { + let result: SyntaxNode | undefined + + const isCall = node.name === 'FunctionCall' || node.name === 'FunctionCallOrIdentifier' + + // Call ends just before cursor (within 5 chars) + if (isCall && node.to <= cursorPos && cursorPos <= node.to + 5) { + result = node + } + + // Cursor is inside the call's span + if (isCall && node.from < cursorPos && cursorPos < node.to) { + result = node + } + + // Recurse - prefer smaller spans (more specific) + let child = node.firstChild + while (child) { + const found = findBestCall(child) + if (found) { + const foundSpan = found.to - found.from + const resultSpan = result ? result.to - result.from : Infinity + if (foundSpan < resultSpan) { + result = found + } + } + child = child.nextSibling + } + + return result + } + + const call = findBestCall(tree.topNode) + if (!call) return + + // Count args before cursor + let argCount = 0 + let child = call.firstChild + while (child) { + if ((child.name === 'PositionalArg' || child.name === 'NamedArg') && child.to <= cursorPos) { + argCount++ + } + child = child.nextSibling + } + + // Extract function name + const firstChild = call.firstChild + if (!firstChild) return + + let funcName: string | undefined + if (firstChild.name === 'DotGet') { + funcName = text.slice(firstChild.from, firstChild.to) + } else if (firstChild.name === 'Identifier') { + funcName = text.slice(firstChild.from, firstChild.to) + } + + if (!funcName) return + + return { funcName, argCount } +} + +const lookupFunctionParams = (funcName: string): string[] | undefined => { + // Handle module functions: "list.map" → modules.list.map + if (funcName.includes('.')) { + const [moduleName, methodName] = funcName.split('.') + const module = completions.modules[moduleName as keyof typeof completions.modules] + const method = module?.[methodName as keyof typeof module] + return method?.params as string[] | undefined + } + + // TODO: Handle top-level prelude functions (print, range, etc.) +} + +const buildSignature = (funcName: string, params: string[]): SignatureInformation => { + const label = `${funcName}(${params.join(', ')})` + const parameters: ParameterInformation[] = params.map(p => ({ label: p })) + return { label, parameters } +}