/**
* 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();
}
}