Skip to main content
Glama
language-server.ts41.7 kB
/** * AL Language Server Client * * Communicates with the Microsoft AL Language Server via LSP protocol. * Provides full LSP integration for AL development. */ import { spawn, ChildProcess } from 'child_process'; import * as path from 'path'; import * as fs from 'fs'; import { createMessageConnection, StreamMessageReader, StreamMessageWriter, MessageConnection, } from 'vscode-jsonrpc/node.js'; import { InitializeParams, InitializeResult, DidOpenTextDocumentParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidSaveTextDocumentParams, DocumentSymbolParams, DefinitionParams, TypeDefinitionParams, ImplementationParams, ReferenceParams, HoverParams, CompletionParams, PublishDiagnosticsParams, SymbolInformation, DocumentSymbol, Location, Hover, CompletionItem, RenameParams, WorkspaceEdit, PrepareRenameParams, CodeActionParams, CodeAction, Command, SignatureHelpParams, SignatureHelp, DocumentFormattingParams, DocumentRangeFormattingParams, DocumentOnTypeFormattingParams, TextEdit, FormattingOptions, DocumentHighlightParams, DocumentHighlight, FoldingRangeParams, FoldingRange, SelectionRangeParams, SelectionRange, CodeLensParams, CodeLens, DocumentLinkParams, DocumentLink, ExecuteCommandParams, SemanticTokensParams, SemanticTokens, } from 'vscode-languageserver-protocol'; import { ALExtensionInfo } from '../config/types.js'; import { getLogger } from '../utils/logger.js'; /** * Symbol types in AL */ export interface ALSymbol { name: string; kind: string; range: { start: { line: number; character: number }; end: { line: number; character: number }; }; uri?: string; children?: ALSymbol[]; detail?: string; } /** * AL Diagnostic */ export interface ALDiagnostic { message: string; severity: 'error' | 'warning' | 'info' | 'hint'; range: { start: { line: number; character: number }; end: { line: number; character: number }; }; code?: string | number; source?: string; } /** * AL Code Action */ export interface ALCodeAction { title: string; kind?: string; diagnostics?: ALDiagnostic[]; isPreferred?: boolean; edit?: { changes?: Record<string, Array<{ range: { start: { line: number; character: number }; end: { line: number; character: number } }; newText: string; }>>; }; command?: { title: string; command: string; arguments?: unknown[]; }; } /** * AL Signature Help */ export interface ALSignatureHelp { signatures: ALSignatureInfo[]; activeSignature?: number; activeParameter?: number; } export interface ALSignatureInfo { label: string; documentation?: string; parameters?: ALParameterInfo[]; } export interface ALParameterInfo { label: string | [number, number]; documentation?: string; } /** * AL Text Edit */ export interface ALTextEdit { range: { start: { line: number; character: number }; end: { line: number; character: number }; }; newText: string; } /** * AL Document Highlight */ export interface ALDocumentHighlight { range: { start: { line: number; character: number }; end: { line: number; character: number }; }; kind?: 'text' | 'read' | 'write'; } /** * AL Folding Range */ export interface ALFoldingRange { startLine: number; startCharacter?: number; endLine: number; endCharacter?: number; kind?: 'comment' | 'imports' | 'region'; } /** * AL Selection Range */ export interface ALSelectionRange { range: { start: { line: number; character: number }; end: { line: number; character: number }; }; parent?: ALSelectionRange; } /** * AL Code Lens */ export interface ALCodeLens { range: { start: { line: number; character: number }; end: { line: number; character: number }; }; command?: { title: string; command: string; arguments?: unknown[]; }; data?: unknown; } /** * AL Document Link */ export interface ALDocumentLink { range: { start: { line: number; character: number }; end: { line: number; character: number }; }; target?: string; tooltip?: string; } /** * AL Semantic Tokens */ export interface ALSemanticTokens { resultId?: string; data: number[]; } /** * AL Language Server Client */ export class ALLanguageServer { private extensionInfo: ALExtensionInfo; private workspaceRoot: string; private process: ChildProcess | null = null; private connection: MessageConnection | null = null; private logger = getLogger(); private initialized = false; private openDocuments = new Set<string>(); private diagnosticsCache = new Map<string, ALDiagnostic[]>(); constructor(extensionInfo: ALExtensionInfo, workspaceRoot: string) { this.extensionInfo = extensionInfo; this.workspaceRoot = path.resolve(workspaceRoot); } /** * Initialize and start the language server */ async initialize(): Promise<void> { if (this.initialized) { return; } this.logger.info('Starting AL Language Server...'); // Start the EditorServices process this.process = spawn(this.extensionInfo.editorServicesPath, [], { cwd: this.workspaceRoot, stdio: ['pipe', 'pipe', 'pipe'], }); if (!this.process.stdout || !this.process.stdin) { throw new Error('Failed to start AL Language Server process'); } // Create LSP connection this.connection = createMessageConnection( new StreamMessageReader(this.process.stdout), new StreamMessageWriter(this.process.stdin) ); // Handle diagnostics this.connection.onNotification('textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => { this.handleDiagnostics(params); }); // Handle errors this.process.stderr?.on('data', (data: Buffer) => { this.logger.debug(`AL LSP stderr: ${data.toString()}`); }); this.process.on('error', (error) => { this.logger.error('AL Language Server process error:', error); }); this.process.on('exit', (code) => { this.logger.info(`AL Language Server exited with code: ${code}`); this.initialized = false; }); // Start listening this.connection.listen(); // Initialize the language server const initParams = this.createInitializeParams(); const initResult = await this.connection.sendRequest<InitializeResult>('initialize', initParams); this.logger.debug('AL LSP initialized:', initResult.capabilities); // Send initialized notification void this.connection.sendNotification('initialized', {}); this.initialized = true; this.logger.info('AL Language Server ready'); } /** * Create initialization parameters for AL LSP */ private createInitializeParams(): InitializeParams { return { processId: process.pid, rootUri: `file:///${this.workspaceRoot.replace(/\\/g, '/')}`, rootPath: this.workspaceRoot, capabilities: { textDocument: { synchronization: { dynamicRegistration: true, willSave: true, willSaveWaitUntil: true, didSave: true, }, completion: { dynamicRegistration: true, completionItem: { snippetSupport: true, commitCharactersSupport: true, documentationFormat: ['markdown', 'plaintext'], }, }, hover: { dynamicRegistration: true, contentFormat: ['markdown', 'plaintext'], }, definition: { dynamicRegistration: true, }, references: { dynamicRegistration: true, }, documentSymbol: { dynamicRegistration: true, hierarchicalDocumentSymbolSupport: true, }, publishDiagnostics: { relatedInformation: true, }, codeAction: { dynamicRegistration: true, codeActionLiteralSupport: { codeActionKind: { valueSet: [ 'quickfix', 'refactor', 'refactor.extract', 'refactor.inline', 'refactor.rewrite', 'source', 'source.organizeImports', ], }, }, resolveSupport: { properties: ['edit'], }, }, signatureHelp: { dynamicRegistration: true, signatureInformation: { documentationFormat: ['markdown', 'plaintext'], parameterInformation: { labelOffsetSupport: true, }, }, contextSupport: true, }, formatting: { dynamicRegistration: true, }, rangeFormatting: { dynamicRegistration: true, }, onTypeFormatting: { dynamicRegistration: true, }, documentHighlight: { dynamicRegistration: true, }, foldingRange: { dynamicRegistration: true, foldingRangeKind: { valueSet: ['comment', 'imports', 'region'], }, }, selectionRange: { dynamicRegistration: true, }, codeLens: { dynamicRegistration: true, }, documentLink: { dynamicRegistration: true, tooltipSupport: true, }, typeDefinition: { dynamicRegistration: true, linkSupport: true, }, implementation: { dynamicRegistration: true, linkSupport: true, }, semanticTokens: { dynamicRegistration: true, tokenTypes: [ 'namespace', 'type', 'class', 'enum', 'interface', 'struct', 'typeParameter', 'parameter', 'variable', 'property', 'enumMember', 'event', 'function', 'method', 'macro', 'keyword', 'modifier', 'comment', 'string', 'number', 'regexp', 'operator', 'decorator', ], tokenModifiers: [ 'declaration', 'definition', 'readonly', 'static', 'deprecated', 'abstract', 'async', 'modification', 'documentation', 'defaultLibrary', ], formats: ['relative'], requests: { full: true, range: true, }, multilineTokenSupport: false, overlappingTokenSupport: false, }, }, workspace: { applyEdit: true, workspaceFolders: true, didChangeConfiguration: { dynamicRegistration: true, }, symbol: { dynamicRegistration: true, }, }, }, workspaceFolders: [ { uri: `file:///${this.workspaceRoot.replace(/\\/g, '/')}`, name: path.basename(this.workspaceRoot), }, ], initializationOptions: { // AL-specific initialization options enableCodeActions: true, enableCodeLens: false, enableDiagnostics: true, backgroundAnalysis: true, }, }; } /** * Handle diagnostics from the language server */ private handleDiagnostics(params: PublishDiagnosticsParams): void { const diagnostics: ALDiagnostic[] = params.diagnostics.map(d => ({ message: d.message, severity: this.mapSeverity(d.severity), range: { start: { line: d.range.start.line, character: d.range.start.character }, end: { line: d.range.end.line, character: d.range.end.character }, }, code: d.code?.toString(), source: d.source, })); this.diagnosticsCache.set(params.uri, diagnostics); this.logger.debug(`Diagnostics updated for ${params.uri}: ${diagnostics.length} issues`); } /** * Map LSP severity to our severity type */ private mapSeverity(severity: number | undefined): ALDiagnostic['severity'] { switch (severity) { case 1: return 'error'; case 2: return 'warning'; case 3: return 'info'; case 4: return 'hint'; default: return 'info'; } } /** * Open a document in the language server */ async openDocument(uri: string): Promise<void> { if (!this.connection || this.openDocuments.has(uri)) { return; } const filePath = this.uriToPath(uri); if (!fs.existsSync(filePath)) { throw new Error(`File not found: ${filePath}`); } const content = fs.readFileSync(filePath, 'utf-8'); const params: DidOpenTextDocumentParams = { textDocument: { uri, languageId: 'al', version: 1, text: content, }, }; void this.connection.sendNotification('textDocument/didOpen', params); this.openDocuments.add(uri); // Wait a bit for diagnostics to be processed await new Promise(resolve => setTimeout(resolve, 100)); } /** * Get document symbols */ async getDocumentSymbols(uri: string): Promise<ALSymbol[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: DocumentSymbolParams = { textDocument: { uri }, }; const result = await this.connection!.sendRequest<SymbolInformation[] | DocumentSymbol[]>( 'textDocument/documentSymbol', params ); return this.convertSymbols(result); } /** * Go to definition */ async goToDefinition(uri: string, line: number, character: number): Promise<Location[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: DefinitionParams = { textDocument: { uri }, position: { line, character }, }; const result = await this.connection!.sendRequest<Location | Location[] | null>( 'textDocument/definition', params ); if (!result) return []; return Array.isArray(result) ? result : [result]; } /** * Find references */ async findReferences(uri: string, line: number, character: number): Promise<Location[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: ReferenceParams = { textDocument: { uri }, position: { line, character }, context: { includeDeclaration: true }, }; const result = await this.connection!.sendRequest<Location[] | null>( 'textDocument/references', params ); return result || []; } /** * Get hover information */ async hover(uri: string, line: number, character: number): Promise<Hover | null> { await this.ensureInitialized(); await this.openDocument(uri); const params: HoverParams = { textDocument: { uri }, position: { line, character }, }; return this.connection!.sendRequest<Hover | null>('textDocument/hover', params); } /** * Get completions */ async getCompletions(uri: string, line: number, character: number): Promise<CompletionItem[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: CompletionParams = { textDocument: { uri }, position: { line, character }, }; const result = await this.connection!.sendRequest<CompletionItem[] | { items: CompletionItem[] } | null>( 'textDocument/completion', params ); if (!result) return []; return Array.isArray(result) ? result : result.items; } /** * Get diagnostics for a document */ async getDiagnostics(uri: string): Promise<ALDiagnostic[]> { await this.ensureInitialized(); await this.openDocument(uri); // Wait for diagnostics to be published await new Promise(resolve => setTimeout(resolve, 500)); return this.diagnosticsCache.get(uri) || []; } /** * Get workspace symbols (search across all files) */ async getWorkspaceSymbols(query: string): Promise<SymbolInformation[]> { await this.ensureInitialized(); const result = await this.connection!.sendRequest<SymbolInformation[] | null>( 'workspace/symbol', { query } ); return result || []; } /** * Update document content (for editing) */ async updateDocument(uri: string, content: string, version: number): Promise<void> { if (!this.connection) { throw new Error('Language server not initialized'); } // If document is not open, open it first if (!this.openDocuments.has(uri)) { await this.openDocument(uri); } const params: DidChangeTextDocumentParams = { textDocument: { uri, version }, contentChanges: [{ text: content }], }; void this.connection.sendNotification('textDocument/didChange', params); // Wait for diagnostics to be processed await new Promise(resolve => setTimeout(resolve, 100)); } /** * Rename symbol across the workspace * @returns WorkspaceEdit with all changes needed */ async renameSymbol(uri: string, line: number, character: number, newName: string): Promise<WorkspaceEdit | null> { await this.ensureInitialized(); await this.openDocument(uri); // First, check if rename is valid const prepareParams: PrepareRenameParams = { textDocument: { uri }, position: { line, character }, }; try { const prepareResult = await this.connection!.sendRequest<{ range: { start: { line: number; character: number }; end: { line: number; character: number } }; placeholder?: string } | null>( 'textDocument/prepareRename', prepareParams ); if (!prepareResult) { this.logger.debug('Rename not available at this position'); return null; } // Perform the rename const renameParams: RenameParams = { textDocument: { uri }, position: { line, character }, newName, }; const result = await this.connection!.sendRequest<WorkspaceEdit | null>( 'textDocument/rename', renameParams ); return result; } catch (error) { this.logger.error('Rename failed:', error); return null; } } /** * Get the symbol at a specific position */ async getSymbolAtPosition(uri: string, line: number, character: number): Promise<ALSymbol | null> { const symbols = await this.getDocumentSymbols(uri); return this.findSymbolAtPosition(symbols, line, character); } /** * Get code actions (quick fixes, refactorings) at a specific position or range */ async getCodeActions( uri: string, range: { start: { line: number; character: number }; end: { line: number; character: number } }, context?: { diagnostics?: ALDiagnostic[]; only?: string[] } ): Promise<ALCodeAction[]> { await this.ensureInitialized(); await this.openDocument(uri); // Build diagnostics with proper typing const diagnostics = context?.diagnostics?.map(d => ({ message: d.message, severity: this.severityToNumber(d.severity) as 1 | 2 | 3 | 4, range: d.range, code: d.code, source: d.source, })) || []; const params: CodeActionParams = { textDocument: { uri }, range, context: { diagnostics, only: context?.only, }, }; const result = await this.connection!.sendRequest<(CodeAction | Command)[] | null>( 'textDocument/codeAction', params ); if (!result) return []; return result.map(item => { // Check if it's a CodeAction (has 'title' as required field for both, but CodeAction has more fields) const isCodeAction = 'kind' in item || 'edit' in item || ('command' in item && typeof (item as CodeAction).command === 'object'); if (isCodeAction) { const action = item as CodeAction; let editResult: ALCodeAction['edit'] | undefined; if (action.edit && action.edit.changes) { editResult = { changes: action.edit.changes as unknown as Record<string, Array<{ range: { start: { line: number; character: number }; end: { line: number; character: number } }; newText: string; }>>, }; } return { title: action.title, kind: action.kind, isPreferred: action.isPreferred, edit: editResult, command: action.command ? { title: action.command.title, command: action.command.command, arguments: action.command.arguments, } : undefined, }; } else { // It's a Command (simpler structure: title, command string, arguments) const cmd = item as Command; return { title: cmd.title, command: { title: cmd.title, command: cmd.command, arguments: cmd.arguments, }, }; } }); } /** * Get signature help (function parameter hints) at a position */ async getSignatureHelp( uri: string, line: number, character: number, context?: { triggerKind?: 1 | 2 | 3; triggerCharacter?: string; isRetrigger?: boolean } ): Promise<ALSignatureHelp | null> { await this.ensureInitialized(); await this.openDocument(uri); const params: SignatureHelpParams = { textDocument: { uri }, position: { line, character }, context: context ? { triggerKind: context.triggerKind || 1, triggerCharacter: context.triggerCharacter, isRetrigger: context.isRetrigger || false, } : undefined, }; const result = await this.connection!.sendRequest<SignatureHelp | null>( 'textDocument/signatureHelp', params ); if (!result) return null; return { signatures: result.signatures.map(sig => ({ label: sig.label, documentation: typeof sig.documentation === 'string' ? sig.documentation : sig.documentation?.value, parameters: sig.parameters?.map(p => ({ label: p.label, documentation: typeof p.documentation === 'string' ? p.documentation : p.documentation?.value, })), })), activeSignature: result.activeSignature, activeParameter: result.activeParameter, }; } /** * Format an entire document */ async formatDocument(uri: string, options?: { tabSize?: number; insertSpaces?: boolean; }): Promise<ALTextEdit[]> { await this.ensureInitialized(); await this.openDocument(uri); const formattingOptions: FormattingOptions = { tabSize: options?.tabSize ?? 4, insertSpaces: options?.insertSpaces ?? true, }; const params: DocumentFormattingParams = { textDocument: { uri }, options: formattingOptions, }; const result = await this.connection!.sendRequest<TextEdit[] | null>( 'textDocument/formatting', params ); if (!result) return []; return result.map(edit => ({ range: { start: { line: edit.range.start.line, character: edit.range.start.character }, end: { line: edit.range.end.line, character: edit.range.end.character }, }, newText: edit.newText, })); } /** * Format a range within a document */ async formatRange( uri: string, range: { start: { line: number; character: number }; end: { line: number; character: number } }, options?: { tabSize?: number; insertSpaces?: boolean } ): Promise<ALTextEdit[]> { await this.ensureInitialized(); await this.openDocument(uri); const formattingOptions: FormattingOptions = { tabSize: options?.tabSize ?? 4, insertSpaces: options?.insertSpaces ?? true, }; const params: DocumentRangeFormattingParams = { textDocument: { uri }, range, options: formattingOptions, }; const result = await this.connection!.sendRequest<TextEdit[] | null>( 'textDocument/rangeFormatting', params ); if (!result) return []; return result.map(edit => ({ range: { start: { line: edit.range.start.line, character: edit.range.start.character }, end: { line: edit.range.end.line, character: edit.range.end.character }, }, newText: edit.newText, })); } /** * Convert severity string to LSP severity number */ private severityToNumber(severity: ALDiagnostic['severity']): number { switch (severity) { case 'error': return 1; case 'warning': return 2; case 'info': return 3; case 'hint': return 4; default: return 3; } } /** * Find symbol containing the given position */ private findSymbolAtPosition(symbols: ALSymbol[], line: number, character: number): ALSymbol | null { for (const symbol of symbols) { const { start, end } = symbol.range; // Check if position is within this symbol's range const isAfterStart = line > start.line || (line === start.line && character >= start.character); const isBeforeEnd = line < end.line || (line === end.line && character <= end.character); if (isAfterStart && isBeforeEnd) { // Check children first (more specific match) if (symbol.children) { const childMatch = this.findSymbolAtPosition(symbol.children, line, character); if (childMatch) { return childMatch; } } return symbol; } } return null; } /** * Find a symbol by name in the document */ async findSymbolByName(uri: string, symbolName: string): Promise<ALSymbol | null> { const symbols = await this.getDocumentSymbols(uri); return this.searchSymbolByName(symbols, symbolName); } /** * Search for symbol by name recursively */ private searchSymbolByName(symbols: ALSymbol[], name: string): ALSymbol | null { for (const symbol of symbols) { if (symbol.name.toLowerCase() === name.toLowerCase()) { return symbol; } if (symbol.children) { const found = this.searchSymbolByName(symbol.children, name); if (found) return found; } } return null; } /** * Convert LSP symbols to our format */ private convertSymbols(symbols: SymbolInformation[] | DocumentSymbol[]): ALSymbol[] { return symbols.map(s => { if ('location' in s) { // SymbolInformation return { name: s.name, kind: this.symbolKindToString(s.kind), range: { start: { line: s.location.range.start.line, character: s.location.range.start.character }, end: { line: s.location.range.end.line, character: s.location.range.end.character }, }, uri: s.location.uri, }; } else { // DocumentSymbol return { name: s.name, kind: this.symbolKindToString(s.kind), range: { start: { line: s.range.start.line, character: s.range.start.character }, end: { line: s.range.end.line, character: s.range.end.character }, }, detail: s.detail, children: s.children ? this.convertSymbols(s.children) : undefined, }; } }); } /** * Convert symbol kind number to string */ private symbolKindToString(kind: number): string { const kinds: Record<number, string> = { 1: 'File', 2: 'Module', 3: 'Namespace', 4: 'Package', 5: 'Class', 6: 'Method', 7: 'Property', 8: 'Field', 9: 'Constructor', 10: 'Enum', 11: 'Interface', 12: 'Function', 13: 'Variable', 14: 'Constant', 15: 'String', 16: 'Number', 17: 'Boolean', 18: 'Array', 19: 'Object', 20: 'Key', 21: 'Null', 22: 'EnumMember', 23: 'Struct', 24: 'Event', 25: 'Operator', 26: 'TypeParameter', }; return kinds[kind] || 'Unknown'; } /** * Convert URI to file path */ private uriToPath(uri: string): string { if (uri.startsWith('file:///')) { return uri.slice(8).replace(/%20/g, ' '); } return uri; } /** * Convert file path to URI */ pathToUri(filePath: string): string { const normalized = path.resolve(filePath).replace(/\\/g, '/'); return `file:///${normalized}`; } /** * Ensure the server is initialized */ private async ensureInitialized(): Promise<void> { if (!this.initialized) { await this.initialize(); } } // ==================== Remaining LSP Features ==================== /** * Close a document (cleanup) */ async closeDocument(uri: string): Promise<void> { await this.ensureInitialized(); if (!this.openDocuments.has(uri)) { return; // Not open } const params: DidCloseTextDocumentParams = { textDocument: { uri }, }; await this.connection!.sendNotification('textDocument/didClose', params); this.openDocuments.delete(uri); this.diagnosticsCache.delete(uri); } /** * Notify save (triggers recompile in some LSPs) */ async saveDocument(uri: string, text?: string): Promise<void> { await this.ensureInitialized(); await this.openDocument(uri); const params: DidSaveTextDocumentParams = { textDocument: { uri }, text, }; await this.connection!.sendNotification('textDocument/didSave', params); } /** * Get document highlights (all occurrences of symbol under cursor) */ async getDocumentHighlights( uri: string, line: number, character: number ): Promise<ALDocumentHighlight[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: DocumentHighlightParams = { textDocument: { uri }, position: { line, character }, }; const result = await this.connection!.sendRequest<DocumentHighlight[] | null>( 'textDocument/documentHighlight', params ); if (!result) return []; return result.map(h => ({ range: { start: { line: h.range.start.line, character: h.range.start.character }, end: { line: h.range.end.line, character: h.range.end.character }, }, kind: h.kind === 1 ? 'text' : h.kind === 2 ? 'read' : h.kind === 3 ? 'write' : undefined, })); } /** * Get folding ranges (code regions, procedures, etc.) */ async getFoldingRanges(uri: string): Promise<ALFoldingRange[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: FoldingRangeParams = { textDocument: { uri }, }; const result = await this.connection!.sendRequest<FoldingRange[] | null>( 'textDocument/foldingRange', params ); if (!result) return []; return result.map(r => ({ startLine: r.startLine, startCharacter: r.startCharacter, endLine: r.endLine, endCharacter: r.endCharacter, kind: r.kind as 'comment' | 'imports' | 'region' | undefined, })); } /** * Get selection ranges (smart expand/shrink selection) */ async getSelectionRanges( uri: string, positions: Array<{ line: number; character: number }> ): Promise<ALSelectionRange[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: SelectionRangeParams = { textDocument: { uri }, positions, }; const result = await this.connection!.sendRequest<SelectionRange[] | null>( 'textDocument/selectionRange', params ); if (!result) return []; const convertSelectionRange = (sr: SelectionRange): ALSelectionRange => ({ range: { start: { line: sr.range.start.line, character: sr.range.start.character }, end: { line: sr.range.end.line, character: sr.range.end.character }, }, parent: sr.parent ? convertSelectionRange(sr.parent) : undefined, }); return result.map(convertSelectionRange); } /** * Go to type definition (variable's type) */ async getTypeDefinition( uri: string, line: number, character: number ): Promise<Location[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: TypeDefinitionParams = { textDocument: { uri }, position: { line, character }, }; const result = await this.connection!.sendRequest<Location | Location[] | null>( 'textDocument/typeDefinition', params ); if (!result) return []; return Array.isArray(result) ? result : [result]; } /** * Go to implementation (interface implementations) */ async getImplementation( uri: string, line: number, character: number ): Promise<Location[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: ImplementationParams = { textDocument: { uri }, position: { line, character }, }; const result = await this.connection!.sendRequest<Location | Location[] | null>( 'textDocument/implementation', params ); if (!result) return []; return Array.isArray(result) ? result : [result]; } /** * Format on type (format after specific character like ';') */ async formatOnType( uri: string, line: number, character: number, ch: string, options?: { tabSize?: number; insertSpaces?: boolean } ): Promise<ALTextEdit[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: DocumentOnTypeFormattingParams = { textDocument: { uri }, position: { line, character }, ch, options: { tabSize: options?.tabSize ?? 4, insertSpaces: options?.insertSpaces ?? true, }, }; const result = await this.connection!.sendRequest<TextEdit[] | null>( 'textDocument/onTypeFormatting', params ); if (!result) return []; return result.map(edit => ({ range: { start: { line: edit.range.start.line, character: edit.range.start.character }, end: { line: edit.range.end.line, character: edit.range.end.character }, }, newText: edit.newText, })); } /** * Get code lenses (inline hints like reference counts) */ async getCodeLenses(uri: string): Promise<ALCodeLens[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: CodeLensParams = { textDocument: { uri }, }; const result = await this.connection!.sendRequest<CodeLens[] | null>( 'textDocument/codeLens', params ); if (!result) return []; return result.map(lens => ({ range: { start: { line: lens.range.start.line, character: lens.range.start.character }, end: { line: lens.range.end.line, character: lens.range.end.character }, }, command: lens.command ? { title: lens.command.title, command: lens.command.command, arguments: lens.command.arguments, } : undefined, data: lens.data as unknown, })); } /** * Resolve a code lens (get the command for a lens) */ async resolveCodeLens(lens: ALCodeLens): Promise<ALCodeLens> { await this.ensureInitialized(); const lspLens: CodeLens = { range: { start: { line: lens.range.start.line, character: lens.range.start.character }, end: { line: lens.range.end.line, character: lens.range.end.character }, }, data: lens.data, }; const result = await this.connection!.sendRequest<CodeLens>( 'codeLens/resolve', lspLens ); return { range: { start: { line: result.range.start.line, character: result.range.start.character }, end: { line: result.range.end.line, character: result.range.end.character }, }, command: result.command ? { title: result.command.title, command: result.command.command, arguments: result.command.arguments, } : undefined, data: result.data, }; } /** * Get document links (clickable URLs in comments) */ async getDocumentLinks(uri: string): Promise<ALDocumentLink[]> { await this.ensureInitialized(); await this.openDocument(uri); const params: DocumentLinkParams = { textDocument: { uri }, }; const result = await this.connection!.sendRequest<DocumentLink[] | null>( 'textDocument/documentLink', params ); if (!result) return []; return result.map(link => ({ range: { start: { line: link.range.start.line, character: link.range.start.character }, end: { line: link.range.end.line, character: link.range.end.character }, }, target: link.target, tooltip: link.tooltip, })); } /** * Execute a command (e.g., from code action) */ async executeCommand(command: string, args?: unknown[]): Promise<unknown> { await this.ensureInitialized(); const params: ExecuteCommandParams = { command, arguments: args, }; return this.connection!.sendRequest('workspace/executeCommand', params); } /** * Get semantic tokens (for syntax highlighting) */ async getSemanticTokens(uri: string): Promise<ALSemanticTokens | null> { await this.ensureInitialized(); await this.openDocument(uri); const params: SemanticTokensParams = { textDocument: { uri }, }; const result = await this.connection!.sendRequest<SemanticTokens | null>( 'textDocument/semanticTokens/full', params ); if (!result) return null; return { resultId: result.resultId, data: result.data, }; } /** * Get semantic tokens for a range */ async getSemanticTokensRange( uri: string, range: { start: { line: number; character: number }; end: { line: number; character: number } } ): Promise<ALSemanticTokens | null> { await this.ensureInitialized(); await this.openDocument(uri); const params = { textDocument: { uri }, range, }; const result = await this.connection!.sendRequest<SemanticTokens | null>( 'textDocument/semanticTokens/range', params ); if (!result) return null; return { resultId: result.resultId, data: result.data, }; } /** * Restart the language server (useful when it hangs or after external changes) */ async restart(): Promise<void> { this.logger.info('Restarting AL Language Server...'); await this.shutdown(); await this.initialize(); this.logger.info('AL Language Server restarted successfully'); } /** * Find all symbols that reference a given symbol (enhanced reference navigation) * Returns referencing symbols with context around the reference */ async findReferencingSymbols( uri: string, line: number, character: number, options?: { includeDeclaration?: boolean; contextLinesBefore?: number; contextLinesAfter?: number; } ): Promise<Array<{ location: Location; containingSymbol?: ALSymbol; contextSnippet?: string; }>> { await this.ensureInitialized(); await this.openDocument(uri); const contextBefore = options?.contextLinesBefore ?? 1; const contextAfter = options?.contextLinesAfter ?? 1; // Get all references const params: ReferenceParams = { textDocument: { uri }, position: { line, character }, context: { includeDeclaration: options?.includeDeclaration ?? false }, }; const locations = await this.connection!.sendRequest<Location[] | null>( 'textDocument/references', params ); if (!locations || locations.length === 0) { return []; } const results: Array<{ location: Location; containingSymbol?: ALSymbol; contextSnippet?: string; }> = []; for (const loc of locations) { const result: { location: Location; containingSymbol?: ALSymbol; contextSnippet?: string } = { location: loc, }; // Try to get the containing symbol try { const symbols = await this.getDocumentSymbols(loc.uri); const containingSymbol = this.findSymbolAtPosition( symbols, loc.range.start.line, loc.range.start.character ); if (containingSymbol) { result.containingSymbol = containingSymbol; } } catch { // Ignore errors getting symbols } // Try to get context snippet try { const filePath = this.uriToPath(loc.uri); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); const startLine = Math.max(0, loc.range.start.line - contextBefore); const endLine = Math.min(lines.length - 1, loc.range.start.line + contextAfter); result.contextSnippet = lines.slice(startLine, endLine + 1).join('\n'); } } catch { // Ignore errors reading file } results.push(result); } return results; } /** * Shutdown the language server */ async shutdown(): Promise<void> { if (!this.connection) { return; } this.logger.info('Shutting down AL Language Server...'); try { await this.connection.sendRequest('shutdown'); void this.connection.sendNotification('exit'); } catch { // Ignore errors during shutdown } this.connection.dispose(); this.process?.kill(); this.connection = null; this.process = null; this.initialized = false; this.openDocuments.clear(); this.diagnosticsCache.clear(); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ciellosinc/partnercore-proxy'

If you have feedback or need assistance with the MCP directory API, please join our Discord server