diff --git a/vscode-extension/.gitignore b/vscode-extension/.gitignore new file mode 100644 index 0000000..e3eaf16 --- /dev/null +++ b/vscode-extension/.gitignore @@ -0,0 +1,4 @@ +node_modules +client/dist +server/dist +*.vsix \ No newline at end of file diff --git a/vscode-extension/.vscode/launch.json b/vscode-extension/.vscode/launch.json new file mode 100644 index 0000000..b3decc9 --- /dev/null +++ b/vscode-extension/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--profile=Shrimp Dev" + ], + "outFiles": [ + "${workspaceFolder}/client/dist/**/*.js", + "${workspaceFolder}/server/dist/**/*.js" + ], + "preLaunchTask": "bun: compile" + } + ] +} diff --git a/vscode-extension/.vscode/tasks.json b/vscode-extension/.vscode/tasks.json new file mode 100644 index 0000000..f1998c2 --- /dev/null +++ b/vscode-extension/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "label": "bun: compile", + "command": "bun", + "args": ["run", "compile"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": "$tsc", + "group": { + "kind": "build", + "isDefault": true + } + } +} diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore new file mode 100644 index 0000000..7fa6f56 --- /dev/null +++ b/vscode-extension/.vscodeignore @@ -0,0 +1,5 @@ +.vscode/** +src/** +tsconfig.json +node_modules/** +*.map diff --git a/vscode-extension/README.md b/vscode-extension/README.md new file mode 100644 index 0000000..7218f5c --- /dev/null +++ b/vscode-extension/README.md @@ -0,0 +1,49 @@ +# Shrimp VSCode Extension + +Language support for Shrimp in VSCode. This README is for probablycorey and defunkt. + +**What it provides:** + +- Syntax highlighting and semantic tokens +- Language server with error diagnostics +- Commands: "Show Parse Tree" (Alt+K Alt+I), "Show Bytecode" (Alt+K Alt+,), and "Run File" (Cmd+R) +- `.sh` file association + +## Development Workflow + +**Developing the extension:** + +1. Open `vscode-extension/` in VSCode +2. Run `bun run watch` in a terminal (keeps it compiling as you make changes) +3. Use **Run > Start Debugging** to launch Extension Development Host +4. Make changes to the code +5. Press **Cmd+R** (or Ctrl+R) in the Extension Development Host window to reload +6. Repeat steps 4-5 + +The `.vscode/launch.json` is configured to compile before launching and use a separate "Shrimp Dev" profile. This means you can have the extension installed in your main VSCode while developing without conflicts. + +**Installing for daily use:** + +Run `bun run build-and-install` to build a VSIX and install it in your current VSCode profile. This lets you use the extension when working on Shrimp scripts outside of development mode. + +## Project Structure + +The extension has two parts: a **client** (`client/src/extension.ts`) that registers commands and starts the language server, and a **server** (`server/src/`) that implements the Language Server Protocol for diagnostics and semantic highlighting. + +Both compile to their respective `dist/` folders. + +## Next Steps + +**Autocomplete:** + +- [ ] Identifiers in scope +- [ ] Globals from the prelude (including native functions) +- [ ] Imports +- [ ] Dot-get properties +- [ ] Function argument completion + +**Other features:** + +- [ ] Better syntax coloring +- [ ] REPL integration +- [ ] Bundle shrimp binary with extension (currently uses `shrimp.binaryPath` setting) diff --git a/vscode-extension/bun.lock b/vscode-extension/bun.lock new file mode 100644 index 0000000..72072f7 --- /dev/null +++ b/vscode-extension/bun.lock @@ -0,0 +1,47 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "shrimp", + "dependencies": { + "vscode-languageclient": "^9.0.1", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-textdocument": "^1.0.12", + }, + "devDependencies": { + "@types/node": "22.x", + "@types/vscode": "^1.105.0", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@22.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA=="], + + "@types/vscode": ["@types/vscode@1.105.0", "", {}, "sha512-Lotk3CTFlGZN8ray4VxJE7axIyLZZETQJVWi/lYoUVQuqfRxlQhVOfoejsD2V3dVXPSbS15ov5ZyowMAzgUqcw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageclient": ["vscode-languageclient@9.0.1", "", { "dependencies": { "minimatch": "^5.1.0", "semver": "^7.3.7", "vscode-languageserver-protocol": "3.17.5" } }, "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + } +} diff --git a/vscode-extension/client/src/extension.ts b/vscode-extension/client/src/extension.ts new file mode 100644 index 0000000..70d7c6b --- /dev/null +++ b/vscode-extension/client/src/extension.ts @@ -0,0 +1,100 @@ +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, + TransportKind, +} from 'vscode-languageclient/node' +import * as vscode from 'vscode' + +export function activate(context: vscode.ExtensionContext) { + const serverModule = context.asAbsolutePath('server/dist/server.js') + + const serverOptions: ServerOptions = { + run: { module: serverModule, transport: TransportKind.ipc }, + debug: { module: serverModule, transport: TransportKind.ipc }, + } + + const clientOptions: LanguageClientOptions = { + documentSelector: [{ scheme: 'file', language: 'shrimp' }], + } + + const client = new LanguageClient( + 'shrimpLanguageServer', + 'Shrimp Language Server', + serverOptions, + clientOptions + ) + + client.start() + context.subscriptions.push(client) + + // Command: Show Parse Tree + context.subscriptions.push( + vscode.commands.registerCommand('shrimp.showParseTree', async () => { + const editor = vscode.window.activeTextEditor + if (!editor || editor.document.languageId !== 'shrimp') { + vscode.window.showErrorMessage('No active Shrimp file') + return + } + + const result = await client.sendRequest('shrimp/parseTree', { + uri: editor.document.uri.toString(), + }) + + const doc = await vscode.workspace.openTextDocument({ + content: result, + language: 'text', + }) + await vscode.window.showTextDocument(doc, { preview: false }) + }) + ) + + // Command: Show Bytecode + context.subscriptions.push( + vscode.commands.registerCommand('shrimp.showBytecode', async () => { + const editor = vscode.window.activeTextEditor + if (!editor || editor.document.languageId !== 'shrimp') { + vscode.window.showErrorMessage('No active Shrimp file') + return + } + + const result = await client.sendRequest('shrimp/bytecode', { + uri: editor.document.uri.toString(), + }) + + const doc = await vscode.workspace.openTextDocument({ + content: result, + language: 'text', + }) + await vscode.window.showTextDocument(doc, { preview: false }) + }) + ) + + // Command: Run File + context.subscriptions.push( + vscode.commands.registerCommand('shrimp.run', async () => { + const editor = vscode.window.activeTextEditor + if (!editor || editor.document.languageId !== 'shrimp') { + vscode.window.showErrorMessage('No active Shrimp file') + return + } + + // Auto-save before running + await editor.document.save() + + // Get binary path from settings + const config = vscode.workspace.getConfiguration('shrimp') + const binaryPath = config.get('binaryPath', 'shrimp') + + // Get the file path + const filePath = editor.document.uri.fsPath + + // Create or show terminal + const terminal = vscode.window.createTerminal('Shrimp') + terminal.show() + terminal.sendText(`${binaryPath} "${filePath}"`) + }) + ) +} + +export function deactivate() {} diff --git a/vscode-extension/example.sh b/vscode-extension/example.sh new file mode 100644 index 0000000..b5b4a64 --- /dev/null +++ b/vscode-extension/example.sh @@ -0,0 +1,25 @@ +# This just has some stuff I use to make sure the extension is working! + +like-a-function = do x y z: + echo 'This is a function with parameters: $x, $y, $z' +end + +value = if true: + 'This is true!' +else: + 'This is false!' +end + +echo 'value is $(value)' + +html lang=en do: + head do: + meta charset='UTF-8' + meta name='viewport' content='width=device-width, initial-scale=1.0' + end + + body do: + h1 'Hello, World!' + p 'This is a sample HTML generated by the extension.' + end +end \ No newline at end of file diff --git a/vscode-extension/icon.png b/vscode-extension/icon.png new file mode 100644 index 0000000..040c85b Binary files /dev/null and b/vscode-extension/icon.png differ diff --git a/vscode-extension/language-configuration.json b/vscode-extension/language-configuration.json new file mode 100644 index 0000000..eef8f23 --- /dev/null +++ b/vscode-extension/language-configuration.json @@ -0,0 +1,28 @@ +{ + "comments": { + "lineComment": { + "comment": "#" + } + }, + "brackets": [ + ["(", ")"], + ["[", "]"] + ], + "autoClosingPairs": [ + { "open": "(", "close": ")" }, + { "open": "[", "close": "]" }, + { "open": "'", "close": "'", "notIn": ["string"] }, + { "open": "\"", "close": "\"", "notIn": ["string"] } + ], + "surroundingPairs": [ + ["(", ")"], + ["[", "]"], + ["'", "'"], + ["\"", "\""] + ], + "wordPattern": "([a-z][a-z0-9-]*)|(-?\\d+\\.?\\d*)", + "indentationRules": { + "increaseIndentPattern": ":\\s*$", + "decreaseIndentPattern": "^\\s*(end|else)\\b" + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000..0b5d6bb --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,96 @@ +{ + "name": "shrimp", + "version": "0.0.1", + "main": "./client/dist/extension.js", + "devDependencies": { + "@types/vscode": "^1.105.0", + "@types/node": "22.x", + "typescript": "^5.9.3" + }, + "categories": [ + "Programming Languages" + ], + "contributes": { + "languages": [ + { + "id": "shrimp", + "aliases": [ + "Shrimp", + "shrimp" + ], + "extensions": [ + ".sh" + ], + "configuration": "./language-configuration.json" + } + ], + "configurationDefaults": { + "[shrimp]": { + "editor.semanticHighlighting.enabled": true + } + }, + "configuration": { + "title": "Shrimp", + "properties": { + "shrimp.binaryPath": { + "type": "string", + "default": "shrimp", + "description": "Path to the shrimp binary" + } + } + }, + "commands": [ + { + "command": "shrimp.showParseTree", + "title": "Shrimp: Show Parse Tree" + }, + { + "command": "shrimp.showBytecode", + "title": "Shrimp: Show Bytecode" + }, + { + "command": "shrimp.run", + "title": "Shrimp: Run File" + } + ], + "keybindings": [ + { + "command": "shrimp.showParseTree", + "key": "alt+k alt+i", + "when": "editorLangId == shrimp" + }, + { + "command": "shrimp.showBytecode", + "key": "alt+k alt+,", + "when": "editorLangId == shrimp" + }, + { + "command": "shrimp.run", + "key": "cmd+r", + "when": "editorLangId == shrimp" + } + ] + }, + "description": "Language support for Shrimp shell scripting language", + "displayName": "Shrimp", + "engines": { + "vscode": "^1.105.0" + }, + "icon": "icon.png", + "publisher": "shrimp-lang", + "scripts": { + "vscode:prepublish": "bun run package", + "compile": "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", + "check-types": "tsc --noEmit", + "build-and-install": "bun run package && bunx @vscode/vsce package --allow-missing-repository && code --install-extension shrimp-*.vsix" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-textdocument": "^1.0.12" + } +} \ No newline at end of file diff --git a/vscode-extension/server/src/diagnostics.ts b/vscode-extension/server/src/diagnostics.ts new file mode 100644 index 0000000..2f1a449 --- /dev/null +++ b/vscode-extension/server/src/diagnostics.ts @@ -0,0 +1,93 @@ +import { TextDocument, Position } from 'vscode-languageserver-textdocument' +import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver/node' +import { parser } from '../../../src/parser/shrimp' +import { Compiler } from '../../../src/compiler/compiler' +import { CompilerError } from '../../../src/compiler/compilerError' + +export const buildDiagnostics = (textDocument: TextDocument): Diagnostic[] => { + const text = textDocument.getText() + const diagnostics = getParseErrors(textDocument) + + if (diagnostics.length > 0) { + return diagnostics + } + const diagnostic = getCompilerError(text) + if (diagnostic) return [diagnostic] + + return [] +} + +const getCompilerError = (text: string): Diagnostic | undefined => { + try { + new Compiler(text) + } catch (e) { + if (!(e instanceof CompilerError)) { + return unknownDiagnostic(getErrorMessage(e)) + } + + const lineInfo = e.lineAtPosition(text)! + const cause = e.cause ? ` Cause: ${e.cause}` : '' + const message = e.message + + if (!lineInfo) { + return unknownDiagnostic(message + cause) + } + + const diagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: { + start: { line: lineInfo.lineNumber, character: lineInfo.columnStart }, + end: { line: lineInfo.lineNumber, character: lineInfo.columnEnd }, + }, + message: `Compiler error: ${message}${cause}`, + source: 'shrimp', + } + return diagnostic + } +} + +const unknownDiagnostic = (message: string): Diagnostic => { + const diagnostic: Diagnostic = { + severity: DiagnosticSeverity.Error, + range: { + start: { line: 0, character: 0 }, + end: { line: -1, character: -1 }, + }, + message, + source: 'shrimp', + } + return diagnostic +} + +const getParseErrors = (textDocument: TextDocument): Diagnostic[] => { + const tree = parser.parse(textDocument.getText()) + + const ranges: { start: Position; end: Position }[] = [] + tree.iterate({ + enter(n) { + if (n.type.isError) { + ranges.push({ + start: textDocument.positionAt(n.from), + end: textDocument.positionAt(n.to), + }) + return false + } + }, + }) + + return ranges.map((range) => { + return { + range, + severity: DiagnosticSeverity.Error, + message: 'Parse error: Invalid syntax', + source: 'shrimp', + } + }) +} + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message + } + return String(error) +} diff --git a/vscode-extension/server/src/scopeTracker.test.ts b/vscode-extension/server/src/scopeTracker.test.ts new file mode 100644 index 0000000..9604e36 --- /dev/null +++ b/vscode-extension/server/src/scopeTracker.test.ts @@ -0,0 +1,145 @@ +import { test, expect, describe } from 'bun:test' +import { ScopeTracker } from './scopeTracker' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { parser } from '../../../src/parser/shrimp' +import * as Terms from '../../../src/parser/shrimp.terms' + +describe('ScopeTracker', () => { + test('top-level assignment is in scope', () => { + const code = 'x = 5\necho x' + const { tree, tracker } = parseAndGetScope(code) + + // Find the 'x' identifier in 'echo x' + const identifiers: any[] = [] + tree.topNode.cursor().iterate((node: any) => { + if (node.type.id === Terms.Identifier) { + identifiers.push(node.node) + } + }) + + // Second identifier should be the 'x' in 'echo x' + const xInEcho = identifiers[1] + expect(xInEcho).toBeDefined() + expect(tracker.isInScope('x', xInEcho)).toBe(true) + }) + + test('undeclared variable is not in scope', () => { + const code = 'echo x' + const { tree, tracker } = parseAndGetScope(code) + + // Find the 'x' identifier + let xNode: any = null + tree.topNode.cursor().iterate((node: any) => { + if (node.type.id === Terms.Identifier) { + xNode = node.node + } + }) + + expect(xNode).toBeDefined() + expect(tracker.isInScope('x', xNode)).toBe(false) + }) + + test('function parameter is in scope inside function', () => { + const code = `greet = do name: + echo name +end` + const { tree, tracker } = parseAndGetScope(code) + + // Find all identifiers + const identifiers: any[] = [] + tree.topNode.cursor().iterate((node: any) => { + if (node.type.id === Terms.Identifier) { + identifiers.push(node.node) + } + }) + + // Find the 'name' in 'echo name' (should be last identifier) + const nameInEcho = identifiers[identifiers.length - 1] + expect(tracker.isInScope('name', nameInEcho)).toBe(true) + }) + + test('assignment before usage is in scope', () => { + const code = `x = 5 +y = 10 +echo x y` + const { tree, tracker } = parseAndGetScope(code) + + // Find identifiers + const identifiers: any[] = [] + tree.topNode.cursor().iterate((node: any) => { + if (node.type.id === Terms.Identifier) { + identifiers.push(node.node) + } + }) + + // Last two identifiers should be 'x' and 'y' in 'echo x y' + const xInEcho = identifiers[identifiers.length - 2] + const yInEcho = identifiers[identifiers.length - 1] + + expect(tracker.isInScope('x', xInEcho)).toBe(true) + expect(tracker.isInScope('y', yInEcho)).toBe(true) + }) + + test('assignment after usage is not in scope', () => { + const code = `echo x +x = 5` + const { tree, tracker } = parseAndGetScope(code) + + // Find the first 'x' identifier (in echo) + let xNode: any = null + tree.topNode.cursor().iterate((node: any) => { + if (node.type.id === Terms.Identifier && !xNode) { + xNode = node.node + } + }) + + expect(tracker.isInScope('x', xNode)).toBe(false) + }) + + test('nested function has access to outer scope', () => { + const code = `x = 5 +greet = do: + echo x +end` + const { tree, tracker } = parseAndGetScope(code) + + // Find all identifiers + const identifiers: any[] = [] + tree.topNode.cursor().iterate((node: any) => { + if (node.type.id === Terms.Identifier) { + identifiers.push(node.node) + } + }) + + // Find the 'x' in 'echo x' (should be last identifier) + const xInEcho = identifiers[identifiers.length - 1] + expect(tracker.isInScope('x', xInEcho)).toBe(true) + }) + + test('inner function parameter shadows outer variable', () => { + const code = `x = 5 +greet = do x: + echo x +end` + const { tree, tracker } = parseAndGetScope(code) + + // Find all identifiers + const identifiers: any[] = [] + tree.topNode.cursor().iterate((node: any) => { + if (node.type.id === Terms.Identifier) { + identifiers.push(node.node) + } + }) + + // The 'x' in 'echo x' should have 'x' in scope (from parameter) + const xInEcho = identifiers[identifiers.length - 1] + expect(tracker.isInScope('x', xInEcho)).toBe(true) + }) +}) + +const parseAndGetScope = (code: string) => { + const document = TextDocument.create('test://test.sh', 'shrimp', 1, code) + const tree = parser.parse(code) + const tracker = new ScopeTracker(document) + return { document, tree, tracker } +} diff --git a/vscode-extension/server/src/scopeTracker.ts b/vscode-extension/server/src/scopeTracker.ts new file mode 100644 index 0000000..70ebf73 --- /dev/null +++ b/vscode-extension/server/src/scopeTracker.ts @@ -0,0 +1,135 @@ +import { SyntaxNode } from '@lezer/common' +import { TextDocument } from 'vscode-languageserver-textdocument' +import * as Terms from '../../../src/parser/shrimp.terms' + +/** + * Tracks variables in scope at a given position in the parse tree. + * Used to distinguish identifiers (in scope) from words (not in scope). + */ +export class ScopeTracker { + private document: TextDocument + private scopeCache = new Map>() + + constructor(document: TextDocument) { + this.document = document + } + + /** + * Check if a name is in scope at the given node's position. + */ + isInScope(name: string, node: SyntaxNode): boolean { + const scope = this.getScopeAt(node) + return scope.has(name) + } + + /** + * Get all variables in scope at the given node's position. + */ + private getScopeAt(node: SyntaxNode): Set { + const position = node.from + + // Check cache first + if (this.scopeCache.has(position)) { + return this.scopeCache.get(position)! + } + + const scope = new Set() + + // Find all containing function definitions + const containingFunctions = this.findContainingFunctions(node) + + // Collect scope from each containing function (inner to outer) + for (const fnNode of containingFunctions) { + this.collectParams(fnNode, scope) + this.collectAssignments(fnNode, position, scope) + } + + // Collect top-level assignments + const root = this.getRoot(node) + this.collectAssignments(root, position, scope) + + this.scopeCache.set(position, scope) + return scope + } + + /** + * Find all function definitions that contain the given node. + */ + private findContainingFunctions(node: SyntaxNode): SyntaxNode[] { + const functions: SyntaxNode[] = [] + let current = node.parent + + while (current) { + if (current.type.id === Terms.FunctionDef) { + functions.unshift(current) // Add to beginning for outer-to-inner order + } + current = current.parent + } + + return functions + } + + /** + * Get the root node of the tree. + */ + private getRoot(node: SyntaxNode): SyntaxNode { + let current = node + while (current.parent) { + current = current.parent + } + return current + } + + /** + * Collect parameter names from a function definition. + */ + private collectParams(fnNode: SyntaxNode, scope: Set) { + let child = fnNode.firstChild + while (child) { + if (child.type.id === Terms.Params) { + let param = child.firstChild + while (param) { + if (param.type.id === Terms.Identifier) { + const text = this.document.getText({ + start: this.document.positionAt(param.from), + end: this.document.positionAt(param.to), + }) + scope.add(text) + } + param = param.nextSibling + } + break + } + child = child.nextSibling + } + } + + /** + * Collect assignment names from a scope node that occur before the given position. + */ + private collectAssignments(scopeNode: SyntaxNode, beforePosition: number, scope: Set) { + const cursor = scopeNode.cursor() + + cursor.iterate((node) => { + // Stop if we've passed the position we're checking + if (node.from >= beforePosition) return false + + if (node.type.id === Terms.Assign) { + const assignNode = node.node + const child = assignNode.firstChild + if (child?.type.id === Terms.AssignableIdentifier) { + const text = this.document.getText({ + start: this.document.positionAt(child.from), + end: this.document.positionAt(child.to), + }) + scope.add(text) + } + } + + // Don't descend into nested functions unless it's the current scope + if (node.type.id === Terms.FunctionDef && node.node !== scopeNode) { + return false + } + }) + } +} diff --git a/vscode-extension/server/src/semanticTokens.ts b/vscode-extension/server/src/semanticTokens.ts new file mode 100644 index 0000000..a01b06a --- /dev/null +++ b/vscode-extension/server/src/semanticTokens.ts @@ -0,0 +1,221 @@ +import { parser } from '../../../src/parser/shrimp' +import * as Terms from '../../../src/parser/shrimp.terms' +import { SyntaxNode } from '@lezer/common' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { + SemanticTokensBuilder, + SemanticTokenTypes, + SemanticTokenModifiers, +} from 'vscode-languageserver/node' +import { ScopeTracker } from './scopeTracker' + +export const TOKEN_TYPES = [ + SemanticTokenTypes.function, + SemanticTokenTypes.variable, + SemanticTokenTypes.string, + SemanticTokenTypes.number, + SemanticTokenTypes.operator, + SemanticTokenTypes.keyword, + SemanticTokenTypes.parameter, + SemanticTokenTypes.property, + SemanticTokenTypes.regexp, + SemanticTokenTypes.comment, +] + +export const TOKEN_MODIFIERS = [ + SemanticTokenModifiers.declaration, + SemanticTokenModifiers.modification, + SemanticTokenModifiers.readonly, +] + +export function buildSemanticTokens(document: TextDocument): number[] { + const text = document.getText() + const tree = parser.parse(text) + const builder = new SemanticTokensBuilder() + const scopeTracker = new ScopeTracker(document) + + walkTree(tree.topNode, document, builder, scopeTracker) + + return builder.build().data +} + +// Walk the tree and collect tokens +function walkTree( + node: SyntaxNode, + document: TextDocument, + builder: SemanticTokensBuilder, + scopeTracker: ScopeTracker +) { + const tokenInfo = getTokenType(node, document, scopeTracker) + + if (tokenInfo !== undefined) { + const start = document.positionAt(node.from) + const length = node.to - node.from + builder.push(start.line, start.character, length, tokenInfo.type, tokenInfo.modifiers) + } + + let child = node.firstChild + while (child) { + walkTree(child, document, builder, scopeTracker) + child = child.nextSibling + } +} + +// Map Lezer node IDs to semantic token type indices and modifiers +type TokenInfo = { type: number; modifiers: number } | undefined +function getTokenType( + node: SyntaxNode, + document: TextDocument, + scopeTracker: ScopeTracker +): TokenInfo { + const nodeTypeId = node.type.id + const parentTypeId = node.parent?.type.id + + // Special case for now, eventually keywords will go away + if (node.type.name === 'keyword') { + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.keyword), + modifiers: 0, + } + } + + switch (nodeTypeId) { + case Terms.Identifier: + // Check parent to determine context + if (parentTypeId === Terms.FunctionCall) { + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.function), + modifiers: 0, + } + } + if (parentTypeId === Terms.FunctionDef) { + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.function), + modifiers: getModifierBits(SemanticTokenModifiers.declaration), + } + } + if (parentTypeId === Terms.FunctionCallOrIdentifier) { + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.function), + modifiers: 0, + } + } + if (parentTypeId === Terms.Params) { + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.parameter), + modifiers: 0, + } + } + if (parentTypeId === Terms.DotGet) { + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.property), + modifiers: 0, + } + } + + // Special case: Identifier in PositionalArg - check scope + if (parentTypeId === Terms.PositionalArg) { + const identifierText = document.getText({ + start: document.positionAt(node.from), + end: document.positionAt(node.to), + }) + + // If not in scope, treat as string (like a Word) + if (!scopeTracker.isInScope(identifierText, node)) { + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.string), + modifiers: 0, + } + } + // If in scope, fall through to treat as variable + } + + // Otherwise it's a regular variable + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.variable), + modifiers: 0, + } + + case Terms.IdentifierBeforeDot: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.variable), + modifiers: 0, + } + + case Terms.NamedArgPrefix: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.property), + modifiers: 0, + } + + case Terms.AssignableIdentifier: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.variable), + modifiers: getModifierBits(SemanticTokenModifiers.modification), + } + + case Terms.String: + case Terms.StringFragment: + case Terms.Word: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.string), + modifiers: 0, + } + + case Terms.Number: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.number), + modifiers: 0, + } + + case Terms.Plus: + case Terms.Minus: + case Terms.Star: + case Terms.Slash: + case Terms.Eq: + case Terms.EqEq: + case Terms.Neq: + case Terms.Lt: + case Terms.Lte: + case Terms.Gt: + case Terms.Gte: + case Terms.Modulo: + case Terms.And: + case Terms.Or: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.operator), + modifiers: 0, + } + + case Terms.Do: + case Terms.colon: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.keyword), + modifiers: 0, + } + + case Terms.Regex: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.regexp), + modifiers: 0, + } + + case Terms.Comment: + return { + type: TOKEN_TYPES.indexOf(SemanticTokenTypes.comment), + modifiers: 0, + } + + default: + return undefined + } +} + +const getModifierBits = (...modifiers: SemanticTokenModifiers[]): number => { + let bits = 0 + for (const modifier of modifiers) { + const index = TOKEN_MODIFIERS.indexOf(modifier) + if (index !== -1) bits |= 1 << index + } + return bits +} diff --git a/vscode-extension/server/src/server.ts b/vscode-extension/server/src/server.ts new file mode 100644 index 0000000..194b35e --- /dev/null +++ b/vscode-extension/server/src/server.ts @@ -0,0 +1,150 @@ +import { TextDocument } from 'vscode-languageserver-textdocument' +import { buildDiagnostics } from './diagnostics' +import { buildSemanticTokens, TOKEN_MODIFIERS, TOKEN_TYPES } from './semanticTokens' +import { parser } from '../../../src/parser/shrimp' +import { Compiler } from '../../../src/compiler/compiler' +import { + InitializeResult, + TextDocuments, + TextDocumentSyncKind, + createConnection, + ProposedFeatures, + CompletionItemKind, +} from 'vscode-languageserver/node' + +const connection = createConnection(ProposedFeatures.all) +const documents = new TextDocuments(TextDocument) +documents.listen(connection) + +// Server capabilities +connection.onInitialize(handleInitialize) + +// Language features +connection.languages.semanticTokens.on(handleSemanticTokens) +documents.onDidChangeContent(handleDocumentChange) +connection.onCompletion(handleCompletion) + +// Debug commands +connection.onRequest('shrimp/parseTree', handleParseTree) +connection.onRequest('shrimp/bytecode', handleBytecode) + +// Start listening +connection.listen() + +// ============================================================================ +// Handler implementations +// ============================================================================ + +function handleInitialize(): InitializeResult { + connection.console.log('🦐 Server initialized with capabilities') + const result: InitializeResult = { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Full, + completionProvider: { + triggerCharacters: ['.'], + }, + semanticTokensProvider: { + legend: { + tokenTypes: TOKEN_TYPES, + tokenModifiers: TOKEN_MODIFIERS, + }, + full: true, + }, + }, + } + + return result +} + +function handleSemanticTokens(params: any) { + const document = documents.get(params.textDocument.uri) + if (!document) return { data: [] } + + const data = buildSemanticTokens(document) + return { data } +} + +function handleDocumentChange(change: any) { + const textDocument = change.document + const diagnostics = buildDiagnostics(textDocument) + connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }) +} + +function handleCompletion(params: any) { + const keywords = ['if', 'else', 'do', 'end', 'and', 'or', 'true', 'false', 'null'] + + return keywords.map((keyword) => ({ + label: keyword, + kind: CompletionItemKind.Keyword, + })) +} + +function handleParseTree(params: { uri: string }) { + connection.console.log(`🦐 Parse tree requested for: ${params.uri}`) + const document = documents.get(params.uri) + if (!document) return 'Document not found' + + const text = document.getText() + const tree = parser.parse(text) + const cursor = tree.cursor() + + let formatted = '' + let depth = 0 + + const printNode = () => { + const nodeName = cursor.name + const nodeText = text.slice(cursor.from, cursor.to) + const indent = ' '.repeat(depth) + + formatted += `${indent}${nodeName}` + if (nodeText) { + const escapedText = nodeText.replace(/\n/g, '\\n').replace(/\r/g, '\\r') + formatted += ` "${escapedText}"` + } + formatted += '\n' + } + + const traverse = (): void => { + printNode() + + if (cursor.firstChild()) { + depth++ + do { + traverse() + } while (cursor.nextSibling()) + cursor.parent() + depth-- + } + } + + traverse() + return formatted +} + +function handleBytecode(params: { uri: string }) { + connection.console.log(`🦐 Bytecode requested for: ${params.uri}`) + const document = documents.get(params.uri) + if (!document) return 'Document not found' + + try { + const text = document.getText() + const compiler = new Compiler(text) + + // Format bytecode as readable string + let output = 'Bytecode:\n\n' + const bytecode = compiler.bytecode + + output += bytecode.instructions + .map((op, i) => `${i.toString().padStart(4)}: ${JSON.stringify(op)}`) + .join('\n') + + // Strip ANSI color codes + output = output.replace(/\x1b\[[0-9;]*m/g, '') + + return output + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + // Strip ANSI color codes from error message too + return `Compilation failed: ${errorMsg.replace(/\x1b\[[0-9;]*m/g, '')}` + } +} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 0000000..a915a81 --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "commonjs", + "moduleResolution": "bundler", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["client/src/**/*", "server/src/**/*", "../src/**/*"], + "exclude": ["node_modules", "client/dist", "server/dist"] +}