UNPKG

pss-langserver

Version:

A Language server for the Portable Stimulus Standard

526 lines (451 loc) 18.4 kB
/* * Copyright (C) 2025 Darshan(@thisisthedarshan) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { createConnection, TextDocuments, ProposedFeatures, InitializeParams, DidChangeConfigurationNotification, CompletionItem, TextDocumentPositionParams, TextDocumentSyncKind, InitializeResult, TextEdit, Connection, DidChangeConfigurationParams, ParameterInformation, SignatureInformation, SignatureHelpParams, SignatureHelp, SemanticTokensParams, DefinitionParams, Definition, SemanticTokens, HoverParams, Hover, DeclarationParams, Declaration, ReferenceParams, Location, } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; import { formatDocument } from './providers/formattingProvider'; import { builtInSignatures } from './definitions/builtinFunctions'; import { DoxygenGenerationRequest, DoxygenGenerationResponse, objType, PSS_Config, RequestDoxygenGeneration, semanticTokensLegend } from './definitions/dataTypes'; import { version } from './version'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { buildASTForFiles, notify, spawnProcessor } from './helpers'; import { buildMarkdownComment, fullRange, getNodeFromNameArray, scanDirectory, updateAST, updateASTMeta, updateASTNew, updateASTNewMeta } from './parser/helpers'; import { buildAutocompletions, buildAutocompletionBuiltinsBlock } from './providers/autoCompletionProvider'; import { createSemanticTokensFor, generateSemanticTokensAdvanced } from './providers/semanticTokenProvider'; import { getGoToDefinitionAdvanced, getReferencesAdvanced } from './providers/gotoProvider'; import { buildHoverItems, createBuiltinHoverCache, getHoverFor } from './providers/hoverProvider'; import { FunctionNode, PSSLangObjects } from './definitions/dataStructures'; import debounce from 'lodash.debounce'; import { keywords } from './definitions/keywords'; import { createCommentsFromNode } from './providers/objectCommentsProvider'; import { getSmartAutocompletions } from './providers/autoCompletionEngine'; import { url } from 'inspector'; const fs = require('fs-extra') /* To make the process act like an actual executable */ const args = process.argv.slice(2); if (args.includes('--version') || args.includes('-v')) { /* Show build information */ version(); process.exit(0); } /* Support all connection types - ipc, stdio, tcp */ const connection = createConnection(ProposedFeatures.all); /* Documents manager */ const documents = new TextDocuments(TextDocument); /* Boolean to track the first scan of files. */ var isFirst = true; /* To check if an ast has already been built or not */ /* Caches */ var fileWiseAST: { [fileURI: string]: PSSLangObjects[] } = {}; /* Maybe use it later? */ var pssAST: PSSLangObjects[] = []; var autoCompletions: CompletionItem[] = []; var hoverCache: Record<string, Hover>[] = []; /* Caches for built-in objects - like keywords and functions */ var hoverCacheBuiltin: Record<string, Hover>[] = []; var builtInCompletions: CompletionItem[]; /* Holds all autocompletion items */ var semanticTokenCache: SemanticTokens | undefined = undefined; let hasConfigurationCapability = false; let hasWorkspaceFolderCapability = true; let hasDiagnosticRelatedInformationCapability = false; /* Setup initialization */ connection.onInitialize((params: InitializeParams) => { const capabilities = params.capabilities; const workspaceFolders = params.workspaceFolders || []; const pssFiles: string[] = []; /* Does the client support the `workspace/configuration` request? */ /* If not, we fall back using global settings. */ hasConfigurationCapability = !!( capabilities.workspace && !!capabilities.workspace.configuration ); hasWorkspaceFolderCapability = !!( capabilities.workspace && !!capabilities.workspace.workspaceFolders ); hasDiagnosticRelatedInformationCapability = !!( capabilities.textDocument && capabilities.textDocument.publishDiagnostics && capabilities.textDocument.publishDiagnostics.relatedInformation ); const result: InitializeResult = { capabilities: { /* Antlr just generates AST (not CST) so incremental sync isn't a good idea */ textDocumentSync: TextDocumentSyncKind.Full, /* Tell the client that this server supports code completion. */ completionProvider: { resolveProvider: true, triggerCharacters: ['.', ':', '(', ','], }, /* Our language has inter-file dependencies */ /* For now, we will not support diagnostics */ diagnosticProvider: undefined, /*{ interFileDependencies: true, workspaceDiagnostics: false },*/ /* Our LSP Supports workspace by default - necessary */ workspace: { workspaceFolders: { supported: true } }, documentFormattingProvider: true, signatureHelpProvider: { triggerCharacters: ['(', ','] }, semanticTokensProvider: { legend: semanticTokensLegend, range: false, full: { delta: false }, }, definitionProvider: true, declarationProvider: true, referencesProvider: true, hoverProvider: true, /* End Capabilities */ } }; return result; }); /* Completed initialization */ connection.onInitialized(() => { if (hasConfigurationCapability) { // Register for all configuration changes. connection.client.register(DidChangeConfigurationNotification.type, undefined); } if (hasWorkspaceFolderCapability) { connection.workspace.onDidChangeWorkspaceFolders(_event => { connection.console.log('Workspace folder change event received.'); }); } /* Build Caches for hover and auto-completions to speed-up the delivery */ hoverCacheBuiltin = createBuiltinHoverCache(); builtInCompletions = buildAutocompletionBuiltinsBlock(); notify(connection, "PSS Language Server Started :D"); /* Ask the client to get new sematic tokens */ connection.languages.semanticTokens.refresh(); }); /* Default settings - in case config not supported */ const defaultSettings: PSS_Config = { tabspaces: 4, fileAuthor: "", formatPatterns: ["=", "//"], autoFormatHeader: false, wrapAt: 0 }; let globalSettings: PSS_Config = defaultSettings; /* Cache the settings of all open documents */ const documentSettings = new Map<string, Thenable<PSS_Config>>(); /* When connection/config change */ connection.onDidChangeConfiguration((change: DidChangeConfigurationParams) => { documentSettings.clear(); if (!hasConfigurationCapability) { globalSettings = change.settings.PSS || defaultSettings; } /*connection.languages.diagnostics.refresh();*/ }); /* For future use - with diagnostics */ /*connection.sendDiagnostics({ uri: "", diagnostics: [] });*/ function getSettings(connection: Connection, resource: string): Thenable<PSS_Config> { if (!hasConfigurationCapability) { return Promise.resolve(globalSettings); } let result = documentSettings.get(resource); if (!result) { result = Promise.all([ connection.workspace.getConfiguration({ scopeUri: resource, section: 'PSS.tabspaces' }), connection.workspace.getConfiguration({ scopeUri: resource, section: 'PSS.fileAuthor' }), connection.workspace.getConfiguration({ scopeUri: resource, section: 'PSS.formatPatterns' }), connection.workspace.getConfiguration({ scopeUri: resource, section: 'PSS.autoFormatHeader' }), connection.workspace.getConfiguration({ scopeUri: resource, section: 'PSS.wrapAt' }) ]).then(([tabspaces, fileAuthor, formatPatterns, autoFormatHeader, wrapAt]) => { // Validate tabspaces const validatedTabspaces = Math.min(Math.max(tabspaces ?? defaultSettings.tabspaces, 1), 9); return { tabspaces: validatedTabspaces, fileAuthor: fileAuthor ?? defaultSettings.fileAuthor, formatPatterns: formatPatterns ?? [], autoFormatHeader: autoFormatHeader ?? false, wrapAt: (wrapAt !== null && typeof wrapAt === 'number') ? (wrapAt < 69 && wrapAt > 0 ? 69 : wrapAt) : 0 }; }); documentSettings.set(resource, result); } return result; } /* File has been closed - so drop that config */ documents.onDidClose(e => { documentSettings.delete(e.document.uri); }); documents.onDidOpen((params) => { if (isFirst) { const file = params.document.uri; const pssFiles: string[] = []; try { const filePath = fileURLToPath(file); const folderPath = path.dirname(filePath); notify(connection, `Scanning local folder for pss files`); scanDirectory(folderPath, pssFiles); pssFiles.forEach(file => { const content: string = fs.readFileSync(file, 'utf8'); const fileURI: string = encodeURI("file://" + file); spawnProcessor(content, fileURI, callbackForWorker); }); isFirst = false; } catch (e) { console.error(`Error parsing URI ${file}: ${e}`); } } }); const callbackForWorker = (results: { result: PSSLangObjects[]; uri: string; }): void => { var result = results.result; var uri = results.uri; if (result.length > 0) { fileWiseAST[uri] = result; pssAST = []; Object.entries(fileWiseAST).forEach(([uri, ast]) => { pssAST = [...pssAST, ...ast]; }); autoCompletions = buildAutocompletions(pssAST); hoverCache = buildHoverItems(pssAST); semanticTokenCache = generateSemanticTokensAdvanced(pssAST); } } /* Handle updating AST using debounce logic - for on Change */ const debouncedASTBuilder = debounce((uri: string, content: string) => { if (content.length === 0) { return; } }, 3600); const saveDebouncer = debounce((uri: string, content: string): void => { if (content.length === 0) { return; } spawnProcessor(content, uri, callbackForWorker); }, 9000); /* Once every 9 seconds */ /* Event when a document is changed or first opened */ documents.onDidChangeContent(change => { /* New function */ debouncedASTBuilder(change.document.uri.toString(), change.document.getText().toString()); }); /* Refresh semantic tokens on document saves */ documents.onDidSave(save => { connection.languages.semanticTokens.refresh(); saveDebouncer(save.document.uri, save.document.getText() || ""); }); /* See if monitored files have changed */ connection.onDidChangeWatchedFiles(_change => { connection.console.log('We received a file change event'); }); /* Completions provider */ connection.onCompletion( (_textDocumentPosition: TextDocumentPositionParams): CompletionItem[] => { const contents = documents.get(_textDocumentPosition.textDocument.uri)?.getText() || ""; let completions = getSmartAutocompletions(_textDocumentPosition, contents, pssAST, autoCompletions, builtInCompletions); return [...new Set(completions)]; } ); /* Resolve info about selected item in completion list */ connection.onCompletionResolve( (item: CompletionItem): CompletionItem => { /* No plans for now - later we will add comment parsing as well */ return item; } ); /* Provide function Signatures */ connection.onSignatureHelp( (params: SignatureHelpParams): SignatureHelp | null => { const document = documents.get(params.textDocument.uri); if (!document) { return null; } const position = params.position; const line = document.getText({ start: { line: position.line, character: 0 }, end: position }); // Updated regex to match function name with parameters const match = line.match(/(\w+)\((.*)/); if (!match) { return null; } const funcName = match[1]; // Count commas to determine active parameter var activeParameter = (match[2].match(/,/g) || []).length; const refNode = getNodeFromNameArray(pssAST, funcName, objType.FUNCTION_CALL); if (refNode) { const functionInfo: FunctionNode = refNode as FunctionNode; const parameters = functionInfo.parameters.map(p => ParameterInformation.create(p.paramName, `Function parameter of type ${p.paramType}`) ); if (parameters.length > 0 && (parameters[parameters.length - 1].label === "...args" || parameters[parameters.length - 1].label?.toString().includes("...")) && activeParameter > parameters.length - 1) { activeParameter = parameters.length - 1; } const params: string = functionInfo.parameters .map(param => `${param.paramType} ${param.paramName}`) .join(", "); const document: string = (typeof functionInfo.comments === 'string') ? functionInfo.comments : buildMarkdownComment(functionInfo.comments); const signature = SignatureInformation.create( `${functionInfo.platformQualifier} function ${functionInfo.returnType} ${functionInfo.name} (${params})`, document, ...parameters ); return { signatures: [signature], activeSignature: 0, activeParameter }; } const funcInfo = builtInSignatures[funcName]; if (!funcInfo) { return null; } const parameters = funcInfo.parameters.map(p => ParameterInformation.create(p.label, p.documentation) ); /* This hack simply ensures that the function signature helper respects varargs... */ if (parameters[parameters.length - 1].label === "...args" && activeParameter > parameters.length - 1) { activeParameter = parameters.length - 1; } const signature = SignatureInformation.create( funcInfo.signature, funcInfo.documentation + `\nPart of \`${funcInfo.package}\``, ...parameters ); return { signatures: [signature], activeSignature: 0, activeParameter }; } ); /* Provide semantic tokens to the client */ connection.languages.semanticTokens.on((params: SemanticTokensParams): SemanticTokens => { const uri = params.textDocument.uri; const document = documents.get(uri); if (!document) { return { data: [] }; } let semanticTokens: SemanticTokens = createSemanticTokensFor(document.getText()); if (semanticTokenCache) { semanticTokens.data = [...semanticTokens.data, ...semanticTokenCache.data]; } return semanticTokens; }); /* Provide formatting */ connection.onDocumentFormatting((params, tokens) => { const { textDocument, options } = params; const sourceDocument = documents.get(textDocument.uri); if (!sourceDocument) { return []; } /* File doesn't exist */ /* Get contents of the document */ const documentContents = sourceDocument.getText(); /* Get the filename */ const filePath = fileURLToPath(textDocument.uri); /* Get settings for author name and tabspaces and return formatted text */ return getSettings(connection, sourceDocument.uri).then((settings) => { const formattedText = formatDocument(filePath, documentContents, settings.tabspaces, settings.fileAuthor, settings.formatPatterns, settings.autoFormatHeader, (typeof settings.wrapAt === 'number') ? settings.wrapAt : 0); return [TextEdit.replace(fullRange(sourceDocument), formattedText)]; }); }); /* Provide hover features */ connection.onHover(async (params: HoverParams): Promise<Hover | null> => { const document = documents.get(params.textDocument.uri); if (!document) { return null; } return getHoverFor([...hoverCacheBuiltin, ...hoverCache], document.getText(), document.offsetAt(params.position)); } ); /* Provide go-to definitions functionality */ connection.onDefinition((params: DefinitionParams): Definition | null => { const { textDocument, position } = params; const doc = documents.get(textDocument.uri); if (!doc) { return null } const content = doc.getText() const offset = doc.offsetAt(position); // const loc = getGoToDefinition(content, offset, globalAST); const loc = getGoToDefinitionAdvanced(content, offset, pssAST); return loc; } ); /* Provide go-to declarations functionality */ connection.onDeclaration((params: DeclarationParams): Declaration | null | undefined => { const { textDocument, position } = params; const doc = documents.get(textDocument.uri); if (!doc) { return null } const content = doc.getText() const offset = doc.offsetAt(position); const loc = getReferencesAdvanced(content, offset, pssAST); return loc; }); connection.onReferences((params: ReferenceParams): Location[] | null | undefined => { const { textDocument, position } = params; const doc = documents.get(textDocument.uri); if (!doc) { return null } const content = doc.getText() const offset = doc.offsetAt(position); const loc = getReferencesAdvanced(content, offset, pssAST); return loc; }); /* Handle custom requests */ /* This request is for server to create doxygen comments when requested by server */ connection.onRequest(RequestDoxygenGeneration, async (params: DoxygenGenerationRequest) => { const { line, lineNumber, fileURI } = params; let response: DoxygenGenerationResponse = { content: '', keyword: '' }; const trimmedLine = line.trim(); const words = trimmedLine.split(/\s+/); for (const word of words) { const trimmedWord = word.trim(); // Split identifiers with dots and process each part const identifiers = trimmedWord.split('.').filter(id => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(id)); for (const identifier of identifiers) { if (!(identifier in keywords.list) && !(identifier in builtInSignatures)) { const objNode = getNodeFromNameArray(pssAST, identifier); if (objNode) { response.keyword = objNode.name; response.content = createCommentsFromNode(objNode); return response; } } } } return response; }); /* Prepare to listen */ documents.listen(connection); /* Start listening */ connection.listen();