Merge pull request 'I have extended vscode with an extension' (#23) from vscode into main

Reviewed-on: #23
This commit is contained in:
probablycorey 2025-11-06 00:20:28 +00:00
commit ea01a93563
17 changed files with 1151 additions and 0 deletions

4
vscode-extension/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
client/dist
server/dist
*.vsix

19
vscode-extension/.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

18
vscode-extension/.vscode/tasks.json vendored Normal file
View File

@ -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
}
}
}

View File

@ -0,0 +1,5 @@
.vscode/**
src/**
tsconfig.json
node_modules/**
*.map

View File

@ -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)

47
vscode-extension/bun.lock Normal file
View File

@ -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=="],
}
}

View File

@ -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<string>('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<string>('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<string>('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() {}

View File

@ -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

BIN
vscode-extension/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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)
}

View File

@ -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 }
}

View File

@ -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<number, Set<string>>()
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<string> {
const position = node.from
// Check cache first
if (this.scopeCache.has(position)) {
return this.scopeCache.get(position)!
}
const scope = new Set<string>()
// 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<string>) {
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<string>) {
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
}
})
}
}

View File

@ -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
}

View File

@ -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, '')}`
}
}

View File

@ -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"]
}