From dc56b90c262ea90fd1ee909f54cc4974cfaa2a80 Mon Sep 17 00:00:00 2001 From: Ricardo Alves <112292689+RicardoASJunior@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:32:38 -0300 Subject: [PATCH] signature-help --- .github/workflows/ci.yml | 8 +- client/src/ccs/formattingControl.ts | 79 ++++ client/src/extension.ts | 131 ++++-- client/src/requestForwarding.ts | 37 +- server/src/ccs/hover/classSupport.ts | 188 ++++++++ server/src/ccs/hover/routineSupport.ts | 82 ++++ .../src/ccs/signatureHelp/routineSupport.ts | 404 ++++++++++++++++++ server/src/providers/hover.ts | 396 +++++++++-------- server/src/providers/signatureHelp.ts | 326 ++++++++------ server/src/utils/types.ts | 32 +- 10 files changed, 1308 insertions(+), 375 deletions(-) create mode 100644 client/src/ccs/formattingControl.ts create mode 100644 server/src/ccs/hover/classSupport.ts create mode 100644 server/src/ccs/hover/routineSupport.ts create mode 100644 server/src/ccs/signatureHelp/routineSupport.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c255068..640da19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,10 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v4 - - name: Fetch tags - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + with: + fetch-depth: 0 + fetch-tags: true + - name: Set package name and version id: set-version run: | @@ -158,4 +160,4 @@ jobs: git add ./client/package.json git add ./server/package.json git commit -m 'auto bump version with release [skip ci]' - git push + git push \ No newline at end of file diff --git a/client/src/ccs/formattingControl.ts b/client/src/ccs/formattingControl.ts new file mode 100644 index 0000000..6558509 --- /dev/null +++ b/client/src/ccs/formattingControl.ts @@ -0,0 +1,79 @@ +const skipUris = new Set(); +const autoSkipUris = new Set(); +const manualAllowances = new Map(); +const compileBlocks = new Map(); + +const manualAllowanceMs = 1000; +const compileBlockMs = 8000; + +function purgeExpiredEntries(uri: string, now: number): void { + const manualExpiry = manualAllowances.get(uri); + if (manualExpiry !== undefined && manualExpiry < now) { + manualAllowances.delete(uri); + } + + const compileExpiry = compileBlocks.get(uri); + if (compileExpiry !== undefined && compileExpiry < now) { + compileBlocks.delete(uri); + } +} + +export function scheduleFormatSkip(uri: string): void { + skipUris.add(uri); + autoSkipUris.add(uri); + manualAllowances.delete(uri); +} + +export function consumeFormatSkip(uri: string): boolean { + const now = Date.now(); + purgeExpiredEntries(uri, now); + + const manualExpiry = manualAllowances.get(uri); + if (manualExpiry !== undefined && manualExpiry >= now) { + skipUris.delete(uri); + autoSkipUris.delete(uri); + return false; + } + + if (compileBlocks.has(uri)) { + return true; + } + + if (skipUris.has(uri)) { + skipUris.delete(uri); + autoSkipUris.delete(uri); + return true; + } + + return false; +} + +export function clearFormatSkip(uri: string): void { + skipUris.delete(uri); + autoSkipUris.delete(uri); + manualAllowances.delete(uri); +} + +export function removeFormatSkip(uri: string): void { + skipUris.delete(uri); + autoSkipUris.delete(uri); + manualAllowances.delete(uri); + compileBlocks.delete(uri); +} + +export function blockFormatAfterCompile(uri: string): void { + skipUris.add(uri); + autoSkipUris.delete(uri); + compileBlocks.set(uri, Date.now() + compileBlockMs); + manualAllowances.delete(uri); +} + +export function allowManualFormat(uri: string): void { + const now = Date.now(); + if (autoSkipUris.has(uri)) { + return; + } + manualAllowances.set(uri, now + manualAllowanceMs); + compileBlocks.delete(uri); + skipUris.delete(uri); +} diff --git a/client/src/extension.ts b/client/src/extension.ts index ef14d7a..663bb88 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -11,6 +11,11 @@ import { authentication } from 'vscode'; +type CommandExecutionEvent = { + command: string; + arguments?: readonly unknown[]; +}; + import * as Cache from 'vscode-cache'; import { DocumentSelector, @@ -33,6 +38,7 @@ import { setSelection } from './commands'; import { makeRESTRequest, ServerSpec } from './makeRESTRequest'; +import { allowManualFormat, blockFormatAfterCompile, scheduleFormatSkip, clearFormatSkip, removeFormatSkip } from './ccs/formattingControl'; import { ISCEmbeddedContentProvider, requestForwardingMiddleware } from './requestForwarding'; export let client: LanguageClient; @@ -46,13 +52,13 @@ export async function updateCookies(newCookies: string[], server: ServerSpec): P const key = `${server.username}@${server.host}:${server.port}${server.pathPrefix}`; const cookies = cookiesCache.get(key, []); newCookies.forEach((cookie) => { - const [cookieName] = cookie.split("="); - const index = cookies.findIndex((el) => el.startsWith(cookieName)); - if (index >= 0) { - cookies[index] = cookie; - } else { - cookies.push(cookie); - } + const [cookieName] = cookie.split("="); + const index = cookies.findIndex((el) => el.startsWith(cookieName)); + if (index >= 0) { + cookies[index] = cookie; + } else { + cookies.push(cookie); + } }); await cookiesCache.put(key, cookies); return cookies; @@ -69,7 +75,7 @@ let serverManagerApi: serverManager.ServerManagerAPI; const wsFolderServerSpecs: Map = new Map(); type MakeRESTRequestParams = { - method: "GET"|"POST"; + method: "GET" | "POST"; api: number; path: string; server: ServerSpec; @@ -161,10 +167,10 @@ export async function activate(context: ExtensionContext) { // The server manager extension is installed serverManagerApi = serverManagerExt.isActive ? serverManagerExt.exports : await serverManagerExt.activate(); serverManagerApi.onDidChangePassword()((serverName: string) => { - for (const [k,v] of wsFolderServerSpecs.entries()) { + for (const [k, v] of wsFolderServerSpecs.entries()) { if (v.serverName == serverName) wsFolderServerSpecs.delete(k); } - client.sendNotification("intersystems/server/passwordChange",serverName); + client.sendNotification("intersystems/server/passwordChange", serverName); }); } @@ -186,7 +192,7 @@ export async function activate(context: ExtensionContext) { typeof serverSpec.password === "undefined" && // A supported version of the Server Manager is installed serverManagerExt != undefined && - gt(serverManagerExt.packageJSON.version,"3.0.0") + gt(serverManagerExt.packageJSON.version, "3.0.0") ) { // The main extension didn't provide a password, so we must // get it from the server manager's authentication provider. @@ -222,7 +228,7 @@ export async function activate(context: ExtensionContext) { return newuri.toString(); }), client.onRequest("intersystems/uri/forDocument", (document: string): string | null => { - if (lte(objectScriptExt.packageJSON.version,"1.0.10")) { + if (lte(objectScriptExt.packageJSON.version, "1.0.10")) { // If the active version of vscode-objectscript doesn't expose // DocumentContentProvider.getUri(), just return the empty string. return ""; @@ -261,13 +267,13 @@ export async function activate(context: ExtensionContext) { uriParams.has("csp") && ["", "1"].includes(uriParams.get("csp")) ? uri.path.slice(1) : uri.path.split("/").slice(1).join("."); - const docParams = + const docParams = params.server.apiVersion >= 4 && workspace.getConfiguration("objectscript", workspace.workspaceFolders?.find((f) => f.name.toLowerCase() == uri.authority.toLowerCase()) ).get("multilineMethodArgs") ? { format: "udl-multiline" } : undefined; - const resp = await makeRESTRequest("GET",1,`/doc/${fileName}`,params.server,undefined,undefined,docParams); + const resp = await makeRESTRequest("GET", 1, `/doc/${fileName}`, params.server, undefined, undefined, docParams); return resp?.data?.result?.content || []; } else { // Read the contents of the file at uri @@ -280,18 +286,79 @@ export async function activate(context: ExtensionContext) { }), // Register commands - commands.registerCommand("intersystems.language-server.overrideClassMembers",overrideClassMembers), - commands.registerCommand("intersystems.language-server.selectParameterType",selectParameterType), - commands.registerCommand("intersystems.language-server.selectImportPackage",selectImportPackage), - commands.registerCommand("intersystems.language-server.extractMethod",extractMethod), - commands.registerCommand("intersystems.language-server.showSymbolInClass",showSymbolInClass), - commands.registerTextEditorCommand("intersystems.language-server.setSelection",setSelection), + commands.registerCommand("intersystems.language-server.overrideClassMembers", overrideClassMembers), + commands.registerCommand("intersystems.language-server.selectParameterType", selectParameterType), + commands.registerCommand("intersystems.language-server.selectImportPackage", selectImportPackage), + commands.registerCommand("intersystems.language-server.extractMethod", extractMethod), + commands.registerCommand("intersystems.language-server.showSymbolInClass", showSymbolInClass), + commands.registerTextEditorCommand("intersystems.language-server.setSelection", setSelection), // Register EvaluatableExpressionProvider - languages.registerEvaluatableExpressionProvider(documentSelector,new ObjectScriptEvaluatableExpressionProvider()), + languages.registerEvaluatableExpressionProvider(documentSelector, new ObjectScriptEvaluatableExpressionProvider()), + + workspace.onWillSaveTextDocument((event) => { + if ( + targetLanguages.includes(event.document.languageId) && + targetSchemes.includes(event.document.uri.scheme) + ) { + scheduleFormatSkip(event.document.uri.toString(true)); + } + }), + workspace.onDidSaveTextDocument((document) => { + if ( + targetLanguages.includes(document.languageId) && + targetSchemes.includes(document.uri.scheme) + ) { + clearFormatSkip(document.uri.toString(true)); + } + }), + workspace.onDidCloseTextDocument((document) => { + if ( + targetLanguages.includes(document.languageId) && + targetSchemes.includes(document.uri.scheme) + ) { + removeFormatSkip(document.uri.toString(true)); + } + }), + (() => { + const commandsApi = commands as typeof commands & { + onDidExecuteCommand?: (listener: (e: CommandExecutionEvent) => any) => { dispose(): any }; + }; + if (typeof commandsApi.onDidExecuteCommand !== 'function') { + return { dispose: () => undefined }; + } + return commandsApi.onDidExecuteCommand((event) => { + const activeDoc = window.activeTextEditor?.document; + if (activeDoc === undefined) { + return; + } + if (!targetLanguages.includes(activeDoc.languageId) || !targetSchemes.includes(activeDoc.uri.scheme)) { + return; + } + + const activeUri = activeDoc.uri.toString(true); + if (event.command === 'editor.action.formatDocument') { + allowManualFormat(activeUri); + return; + } + if (event.command === 'vscode.executeFormatDocumentProvider') { + allowManualFormat(activeUri); + return; + } + + const lower = event.command.toLowerCase(); + if ( + lower.includes('compile') || + lower === 'objectscript.compileandrun' || + lower === 'objectscript.compileandsave' + ) { + blockFormatAfterCompile(activeUri); + } + }); + })(), // Register embedded language request forwarding content provider - workspace.registerTextDocumentContentProvider("isc-embedded-content",new ISCEmbeddedContentProvider()) + workspace.registerTextDocumentContentProvider("isc-embedded-content", new ISCEmbeddedContentProvider()) ); // Start the client. This will also launch the server @@ -314,10 +381,10 @@ export async function activate(context: ExtensionContext) { "Don't Ask Again" ).then((answer) => { if (answer === "Yes") { - workbenchConfig.update("colorTheme","InterSystems Default Light Modern",true); + workbenchConfig.update("colorTheme", "InterSystems Default Light Modern", true); } else if (answer === "Don't Ask Again") { - workspace.getConfiguration("intersystems.language-server").update("suggestTheme",false,true); + workspace.getConfiguration("intersystems.language-server").update("suggestTheme", false, true); } }); } @@ -330,13 +397,13 @@ export async function activate(context: ExtensionContext) { "Don't Ask Again" ).then((answer) => { if (answer === "Globally") { - workbenchConfig.update("colorTheme","InterSystems Default Light Modern",true); + workbenchConfig.update("colorTheme", "InterSystems Default Light Modern", true); } else if (answer === "Only This Workspace") { - workbenchConfig.update("colorTheme","InterSystems Default Light Modern",false); + workbenchConfig.update("colorTheme", "InterSystems Default Light Modern", false); } else if (answer === "Don't Ask Again") { - workspace.getConfiguration("intersystems.language-server").update("suggestTheme",false,true); + workspace.getConfiguration("intersystems.language-server").update("suggestTheme", false, true); } }); } @@ -349,10 +416,10 @@ export async function activate(context: ExtensionContext) { "Don't Ask Again" ).then((answer) => { if (answer === "Yes") { - workbenchConfig.update("colorTheme","InterSystems Default Dark Modern",true); + workbenchConfig.update("colorTheme", "InterSystems Default Dark Modern", true); } else if (answer === "Don't Ask Again") { - workspace.getConfiguration("intersystems.language-server").update("suggestTheme",false,true); + workspace.getConfiguration("intersystems.language-server").update("suggestTheme", false, true); } }); } @@ -365,13 +432,13 @@ export async function activate(context: ExtensionContext) { "Don't Ask Again" ).then((answer) => { if (answer === "Globally") { - workbenchConfig.update("colorTheme","InterSystems Default Dark Modern",true); + workbenchConfig.update("colorTheme", "InterSystems Default Dark Modern", true); } else if (answer === "Only This Workspace") { - workbenchConfig.update("colorTheme","InterSystems Default Dark Modern",false); + workbenchConfig.update("colorTheme", "InterSystems Default Dark Modern", false); } else if (answer === "Don't Ask Again") { - workspace.getConfiguration("intersystems.language-server").update("suggestTheme",false,true); + workspace.getConfiguration("intersystems.language-server").update("suggestTheme", false, true); } }); } diff --git a/client/src/requestForwarding.ts b/client/src/requestForwarding.ts index 6510081..9cc436d 100644 --- a/client/src/requestForwarding.ts +++ b/client/src/requestForwarding.ts @@ -1,16 +1,17 @@ import { commands, CompletionList, Hover, Position, SignatureHelp, TextDocumentContentProvider, Uri } from 'vscode'; import { Middleware } from 'vscode-languageclient'; import { client } from './extension'; +import { consumeFormatSkip } from './ccs/formattingControl'; export const requestForwardingMiddleware: Middleware = { provideCompletionItem: async (document, position, context, token, next) => { // If not in a class or CSP file, do not attempt request forwarding - if (!["objectscript-class","objectscript-csp"].includes(document.languageId)) { + if (!["objectscript-class", "objectscript-csp"].includes(document.languageId)) { return await next(document, position, context, token); } const originalUri = document.uri.toString(true); - const language: number = await client.sendRequest("intersystems/embedded/languageAtPosition",{ + const language: number = await client.sendRequest("intersystems/embedded/languageAtPosition", { textDocument: { uri: originalUri }, @@ -55,12 +56,12 @@ export const requestForwardingMiddleware: Middleware = { }, provideHover: async (document, position, token, next) => { // If not in a class or CSP file, do not attempt request forwarding - if (!["objectscript-class","objectscript-csp"].includes(document.languageId)) { + if (!["objectscript-class", "objectscript-csp"].includes(document.languageId)) { return await next(document, position, token); } const originalUri = document.uri.toString(true); - const language: number = await client.sendRequest("intersystems/embedded/languageAtPosition",{ + const language: number = await client.sendRequest("intersystems/embedded/languageAtPosition", { textDocument: { uri: originalUri }, @@ -96,12 +97,12 @@ export const requestForwardingMiddleware: Middleware = { }, provideSignatureHelp: async (document, position, context, token, next) => { // If not in a class or CSP file, do not attempt request forwarding - if (!["objectscript-class","objectscript-csp"].includes(document.languageId)) { + if (!["objectscript-class", "objectscript-csp"].includes(document.languageId)) { return await next(document, position, context, token); } const originalUri = document.uri.toString(true); - const language: number = await client.sendRequest("intersystems/embedded/languageAtPosition",{ + const language: number = await client.sendRequest("intersystems/embedded/languageAtPosition", { textDocument: { uri: originalUri }, @@ -129,33 +130,45 @@ export const requestForwardingMiddleware: Middleware = { // Do not forward the request return await next(document, position, context, token); } + }, + provideDocumentFormattingEdits: async (document, options, token, next) => { + if (consumeFormatSkip(document.uri.toString(true))) { + return []; + } + return await next(document, options, token); + }, + provideDocumentRangeFormattingEdits: async (document, range, options, token, next) => { + if (consumeFormatSkip(document.uri.toString(true))) { + return []; + } + return await next(document, range, options, token); } }; export class ISCEmbeddedContentProvider implements TextDocumentContentProvider { - constructor() {} + constructor() { } provideTextDocumentContent(uri: Uri): Promise { // Get the isclexer language number and position from the URI authority const language: number = Number(uri.authority.split(":")[0]); const positionText = uri.authority.split(":")[1]; - const position = new Position(Number(positionText.split("-")[0]),Number(positionText.split("-")[1])); + const position = new Position(Number(positionText.split("-")[0]), Number(positionText.split("-")[1])); // Use the language number to isolate the original URI let originalUri: string; if (language == 11) { // Language is JavaScript so the extension is .js - originalUri = uri.path.slice(1).slice(0,-3); + originalUri = uri.path.slice(1).slice(0, -3); } else if (language == 5) { // Language is HTML so the extension is .html - originalUri = uri.path.slice(1).slice(0,-5); + originalUri = uri.path.slice(1).slice(0, -5); } else if (language == 15) { // Language is CSS so the extension is .css - originalUri = uri.path.slice(1).slice(0,-4); + originalUri = uri.path.slice(1).slice(0, -4); } if (originalUri) { // Ask the server to isolate the embedded language - return client.sendRequest("intersystems/embedded/isolateEmbeddedLanguage",{ + return client.sendRequest("intersystems/embedded/isolateEmbeddedLanguage", { uri: decodeURIComponent(originalUri), language: language, position: position diff --git a/server/src/ccs/hover/classSupport.ts b/server/src/ccs/hover/classSupport.ts new file mode 100644 index 0000000..5330f74 --- /dev/null +++ b/server/src/ccs/hover/classSupport.ts @@ -0,0 +1,188 @@ +import { Hover, Position, Range, SignatureInformation } from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +import { + beautifyFormalSpec, + determineActiveParam, + findOpenParen, + getClassMemberContext, + makeRESTRequest, + quoteUDLIdentifier +} from '../../utils/functions'; +import { ServerSpec, compressedline } from '../../utils/types'; +import * as ld from '../../utils/languageDefinitions'; +import { buildRoutineDocumentation, formalSpecToParamsArr } from '../signatureHelp/routineSupport'; + +export type MethodSignatureDetails = { + signature: SignatureInformation; + start: Position; +}; + +async function getMethodSignatureDetails( + doc: TextDocument, + parsed: compressedline[], + parenLine: number, + parenToken: number, + server: ServerSpec +): Promise { + const tokens = parsed[parenLine]; + if (tokens === undefined || tokens[parenToken] === undefined) { + return null; + } + const calleeToken = tokens[parenToken - 1]; + if (calleeToken === undefined) { + return null; + } + if ( + calleeToken.l !== ld.cos_langindex || + ![ld.cos_method_attrindex, ld.cos_mem_attrindex].includes(calleeToken.s) + ) { + return null; + } + + const memberRange = Range.create( + parenLine, + calleeToken.p, + parenLine, + calleeToken.p + calleeToken.c + ); + const member = doc.getText(memberRange); + const unquotedName = quoteUDLIdentifier(member, 0); + + const memberContext = await getClassMemberContext(doc, parsed, parenToken - 2, parenLine, server); + if (memberContext.baseclass === '') { + return null; + } + + const queryData = member === '%New' + ? { + query: 'SELECT FormalSpec, ReturnType, Description, Stub, Origin FROM %Dictionary.CompiledMethod WHERE Parent = ? AND (Name = ? OR Name = ?)', + parameters: [memberContext.baseclass, unquotedName, '%OnNew'] + } + : { + query: 'SELECT FormalSpec, ReturnType, Description, Stub FROM %Dictionary.CompiledMethod WHERE Parent = ? AND Name = ?', + parameters: [memberContext.baseclass, unquotedName] + }; + const respData = await makeRESTRequest('POST', 1, '/action/query', server, queryData); + const rows: any[] | undefined = respData?.data?.result?.content; + if (!Array.isArray(rows) || rows.length === 0) { + return null; + } + + const start = Position.create(parenLine, tokens[parenToken].p + 1); + + if (member === '%New') { + if (rows.length === 2 && rows[1].Origin !== '%Library.RegisteredObject') { + const formalSpec = typeof rows[1].FormalSpec === 'string' ? rows[1].FormalSpec : ''; + if (formalSpec === '') { + return null; + } + const raw = beautifyFormalSpec(formalSpec); + const signature: SignatureInformation = { + label: raw, + parameters: formalSpecToParamsArr(raw) + }; + signature.label += ` As ${memberContext.baseclass}`; + return { signature, start }; + } + return null; + } + + let methodRow = rows[0] ?? {}; + const stubValue = typeof methodRow.Stub === 'string' ? methodRow.Stub : ''; + if (stubValue !== '') { + const stubParts = stubValue.split('.'); + if (stubParts.length >= 3) { + let stubQuery = ''; + if (stubParts[2] === 'i') { + stubQuery = 'SELECT Description, FormalSpec, ReturnType FROM %Dictionary.CompiledIndexMethod WHERE Name = ? AND parent->Parent = ? AND parent->Name = ?'; + } else if (stubParts[2] === 'q') { + stubQuery = 'SELECT Description, FormalSpec, ReturnType FROM %Dictionary.CompiledQueryMethod WHERE Name = ? AND parent->Parent = ? AND parent->Name = ?'; + } else if (stubParts[2] === 'a') { + stubQuery = 'SELECT Description, FormalSpec, ReturnType FROM %Dictionary.CompiledPropertyMethod WHERE Name = ? AND parent->Parent = ? AND parent->Name = ?'; + } else if (stubParts[2] === 'n') { + stubQuery = 'SELECT Description, FormalSpec, ReturnType FROM %Dictionary.CompiledConstraintMethod WHERE Name = ? AND parent->Parent = ? AND parent->Name = ?'; + } + if (stubQuery !== '') { + const stubResp = await makeRESTRequest('POST', 1, '/action/query', server, { + query: stubQuery, + parameters: [stubParts[1], memberContext.baseclass, stubParts[0]] + }); + const stubRows: any[] | undefined = stubResp?.data?.result?.content; + if (Array.isArray(stubRows) && stubRows.length > 0) { + methodRow = stubRows[0]; + } + } + } + } + + const formalSpec = typeof methodRow.FormalSpec === 'string' ? methodRow.FormalSpec : ''; + if (formalSpec === '') { + return null; + } + + const raw = beautifyFormalSpec(formalSpec); + const signature: SignatureInformation = { + label: raw, + parameters: formalSpecToParamsArr(raw) + }; + const returnType = typeof methodRow.ReturnType === 'string' ? methodRow.ReturnType : ''; + if (['%Open', '%OpenId'].includes(member)) { + signature.label += ` As ${memberContext.baseclass}`; + } else if (returnType !== '') { + signature.label += ` As ${returnType}`; + } + + return { signature, start }; +} + +export async function getClassMethodHover( + doc: TextDocument, + parsed: compressedline[], + position: Position, + tokenIndex: number, + server: ServerSpec +): Promise { + const [parenLine, parenToken] = findOpenParen(doc, parsed, position.line, tokenIndex); + if (parenLine === -1 || parenToken === -1) { + return null; + } + + const details = await getMethodSignatureDetails(doc, parsed, parenLine, parenToken, server); + if (details === null) { + return null; + } + + const startPos = details.start; + const signature = details.signature; + const paramInfos = signature.parameters ?? []; + + let activeIndex: number | null = null; + if ( + position.line < startPos.line || + (position.line === startPos.line && position.character < startPos.character) + ) { + activeIndex = paramInfos.length > 0 ? 0 : null; + } else { + const spanText = doc.getText(Range.create(startPos, position)); + const computed = determineActiveParam(spanText); + if (paramInfos.length > 0) { + activeIndex = Math.min(Math.max((computed ?? 0), 0), paramInfos.length - 1); + } else { + activeIndex = null; + } + } + + const docContent = buildRoutineDocumentation( + signature, + activeIndex, + { context: 'hover', showHeaderInHover: false }) + if (docContent === undefined) { + return null; + } + + return { + contents: docContent, + range: Range.create(position, position) + }; +} diff --git a/server/src/ccs/hover/routineSupport.ts b/server/src/ccs/hover/routineSupport.ts new file mode 100644 index 0000000..83fd686 --- /dev/null +++ b/server/src/ccs/hover/routineSupport.ts @@ -0,0 +1,82 @@ +import { Hover, Position, Range } from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +import { determineActiveParam, findOpenParen } from '../../utils/functions'; +import { ServerSpec, compressedline } from '../../utils/types'; +import * as ld from '../../utils/languageDefinitions'; +import { + buildRoutineDocumentation, + getRoutineSignatureDetails +} from '../signatureHelp/routineSupport'; + +/** Try to build a hover result for an extrinsic routine call parameter at the given location. */ +export async function getRoutineHover( + doc: TextDocument, + parsed: compressedline[], + position: Position, + tokenIndex: number, + uri: string, + server: ServerSpec +): Promise { + const token = parsed[position.line]?.[tokenIndex]; + if (token === undefined) { + return null; + } + + if (token.l !== ld.cos_langindex) { + return null; + } + if ([ld.cos_comment_attrindex, ld.cos_dcom_attrindex, ld.cos_str_attrindex].includes(token.s)) { + return null; + } + + const [parenLine, parenToken] = findOpenParen(doc, parsed, position.line, tokenIndex); + if (parenLine === -1 || parenToken === -1) { + return null; + } + + const routineDetails = await getRoutineSignatureDetails( + doc, + parsed, + parenLine, + parenToken, + uri, + server + ); + if (routineDetails === null) { + return null; + } + + const startPos = routineDetails.start; + let activeIndex: number | null = null; + if ( + position.line < startPos.line || + (position.line === startPos.line && position.character < startPos.character) + ) { + activeIndex = 0; + } else { + const spanText = doc.getText(Range.create(startPos, position)); + const computed = determineActiveParam(spanText); + if (routineDetails.signature.parameters.length > 0) { + activeIndex = Math.min( + Math.max((computed ?? 0), 0), + routineDetails.signature.parameters.length - 1 + ); + } else { + activeIndex = null; + } + } + + const docContent = buildRoutineDocumentation( + routineDetails.signature, + activeIndex, + { context: 'hover', showHeaderInHover: false }) + if (docContent === undefined) { + return null; + } + + return { + contents: docContent, + range: Range.create(position, position) + }; +} diff --git a/server/src/ccs/signatureHelp/routineSupport.ts b/server/src/ccs/signatureHelp/routineSupport.ts new file mode 100644 index 0000000..4f42dec --- /dev/null +++ b/server/src/ccs/signatureHelp/routineSupport.ts @@ -0,0 +1,404 @@ +import { MarkupContent, MarkupKind, ParameterInformation, Position, Range, SignatureInformation } from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +import { createDefinitionUri, getTextForUri, makeRESTRequest } from '../../utils/functions'; +import { ServerSpec, compressedline } from '../../utils/types'; + +/** Remove anything following the first semicolon in a routine line. */ +function stripRoutineComment(line: string): string { + const commentIdx = line.indexOf(';'); + return commentIdx === -1 ? line : line.slice(0, commentIdx); +} + +/** Split a routine parameter list into individual parameters. */ +function splitRoutineParams(paramStr: string): string[] { + const normalized = paramStr.replace(/\r?\n/g, ' '); + if (normalized.trim() === '') { + return []; + } + const result: string[] = []; + let current = ''; + let inQuote = false; + let parenDepth = 0; + for (let i = 0; i < normalized.length; i++) { + const char = normalized.charAt(i); + if (char === '"' && normalized.charAt(i - 1) !== '\\') { + inQuote = !inQuote; + current += char; + continue; + } + if (!inQuote) { + if (char === '(') { + parenDepth++; + current += char; + continue; + } + if (char === ')' && parenDepth > 0) { + parenDepth--; + current += char; + continue; + } + if (char === ',' && parenDepth === 0) { + result.push(current.trim()); + current = ''; + continue; + } + } + current += char; + } + const finalParam = current.trim(); + if (finalParam.length) { + result.push(finalParam); + } + return result.filter((param) => param.length > 0); +} + +type RoutineCallContext = { + label: string; + routine: string; +}; + +function extractRoutineCall(callPrefix: string): RoutineCallContext | null { + const prefix = callPrefix.trimEnd(); + + const extrinsicMatch = prefix.match(/\$\$([%A-Za-z][\w%]*)(?:\s*\^\s*([%A-Za-z][\w%]*))?$/); + if (extrinsicMatch) { + return { + label: extrinsicMatch[1], + routine: extrinsicMatch[2] ?? '' + }; + } + + const doLabelMatch = prefix.match(/\b[Dd][Oo]\b\s+([%A-Za-z][\w%]*)(?:\s*\^\s*([%A-Za-z][\w%]*))?$/); + if (doLabelMatch) { + return { + label: doLabelMatch[1], + routine: doLabelMatch[2] ?? '' + }; + } + + const doRoutineMatch = prefix.match(/\b[Dd][Oo]\b\s*\^\s*([%A-Za-z][\w%]*)$/); + if (doRoutineMatch) { + return { + label: doRoutineMatch[1], + routine: doRoutineMatch[1] + }; + } + + return null; +} + +/** Build the ParameterInformation array for a routine signature label. */ +export function routineParameterInfos(signatureLabel: string, params: string[]): ParameterInformation[] { + if (params.length === 0) { + return []; + } + const result: ParameterInformation[] = []; + let currentPos = signatureLabel.indexOf('(') + 1; + params.forEach((param, idx) => { + const trimmed = param.trim(); + const start = currentPos; + const end = start + trimmed.length; + result.push(ParameterInformation.create([start, end])); + currentPos = end; + if (idx < params.length - 1) { + currentPos += 2; // Account for the comma and following space + } + }); + return result; +} + +/** Returns the [start,end] tuples for all parameters in a formal spec string. */ +export function formalSpecToParamsArr(formalSpec: string): ParameterInformation[] { + const result: ParameterInformation[] = []; + if (formalSpec.replace(/\s+/g, "") === "()") { + return result; + } + let currentParamStart = 1; + let openParenCount = 0; + let openBraceCount = 0; + let inQuote = false; + Array.from(formalSpec).forEach((char: string, idx: number) => { + switch (char) { + case "{": + if (!inQuote) { + openBraceCount++; + } + break; + case "}": + if (!inQuote) { + openBraceCount--; + } + break; + case "(": + if (!inQuote) { + openParenCount++; + } + break; + case ")": + if (!inQuote) { + openParenCount--; + } + break; + case "\"": + inQuote = !inQuote; + break; + case ",": + if (!inQuote && !openBraceCount && openParenCount === 1) { + result.push(ParameterInformation.create([currentParamStart, idx])); + currentParamStart = idx + 1; + } + break; + default: + break; + } + }); + result.push(ParameterInformation.create([currentParamStart, formalSpec.length - 1])); + return result; +} + +export type RoutineSignatureDetails = { + signature: SignatureInformation; + start: Position; + parameters: string[]; +}; + +/** Try to build signature help details for an extrinsic routine call preceding the paren at (line, token). */ +export async function getRoutineSignatureDetails( + doc: TextDocument, + parsed: compressedline[], + parenLine: number, + parenToken: number, + paramsUri: string, + server: ServerSpec +): Promise { + if (parsed[parenLine]?.[parenToken] === undefined) { + return null; + } + const parenPos = parsed[parenLine][parenToken].p; + const callPrefix = doc.getText(Range.create(Position.create(parenLine, 0), Position.create(parenLine, parenPos))); + const callContext = extractRoutineCall(callPrefix); + if (callContext === null) { + return null; + } + const { label } = callContext; + let routineName = callContext.routine ?? ''; + + let currentRoutine = ''; + if (["objectscript", "objectscript-int"].includes(doc.languageId) && parsed[0]?.length > 1) { + currentRoutine = doc.getText( + Range.create( + Position.create(0, parsed[0][1].p), + Position.create(0, parsed[0][1].p + parsed[0][1].c) + ) + ); + } + if (routineName === '') { + routineName = currentRoutine; + } + if (routineName === '') { + return null; + } + + let routineLines: string[] = []; + if (routineName === currentRoutine && ["objectscript", "objectscript-int"].includes(doc.languageId)) { + routineLines = doc.getText().split(/\r?\n/); + } else { + const indexResp = await makeRESTRequest('POST', 1, '/action/index', server, [`${routineName}.int`]); + if (!Array.isArray(indexResp?.data?.result?.content) || indexResp.data.result.content.length === 0) { + return null; + } + const routineEntry = indexResp.data.result.content[0]; + if (routineEntry.status !== '') { + return null; + } + let ext = '.int'; + if ( + Array.isArray(routineEntry.others) && + routineEntry.others.some((other: string) => other.slice(-3).toLowerCase() === 'mac') + ) { + ext = '.mac'; + } + const routineUri = await createDefinitionUri(paramsUri, routineName, ext); + if (routineUri === '') { + return null; + } + routineLines = await getTextForUri(routineUri, server); + if (!Array.isArray(routineLines) || routineLines.length === 0) { + return null; + } + } + + let labelLineIndex = -1; + for (let i = 0; i < routineLines.length; i++) { + const line = routineLines[i]; + if (!line.startsWith(label)) { + continue; + } + const trimmed = line.trimEnd(); + const afterLabel = line.slice(label.length); + if ( + trimmed.length === label.length || + afterLabel.startsWith(' ') || + afterLabel.startsWith('\t') || + afterLabel.startsWith('(') || + afterLabel.startsWith(';') || + afterLabel.startsWith('##;') || + afterLabel.startsWith('//') || + afterLabel.startsWith('/*') + ) { + labelLineIndex = i; + break; + } + } + if (labelLineIndex === -1) { + return null; + } + + const labelLine = routineLines[labelLineIndex]; + const noComment = stripRoutineComment(labelLine); + const openParenIdx = noComment.indexOf('('); + let paramString = ''; + if (openParenIdx >= 0) { + let remainder = noComment.slice(openParenIdx + 1); + let closeIdx = remainder.indexOf(')'); + let searchIdx = labelLineIndex; + while (closeIdx === -1) { + searchIdx++; + if (searchIdx >= routineLines.length) { + break; + } + const nextLineRaw = stripRoutineComment(routineLines[searchIdx]); + if (nextLineRaw.length && /^[A-Za-z%]/.test(nextLineRaw.charAt(0))) { + break; + } + const nextTrimmed = nextLineRaw.trim(); + if (nextTrimmed.length) { + if (remainder.length && !remainder.endsWith(' ') && !nextTrimmed.startsWith(',')) { + remainder += ' '; + } + remainder += nextTrimmed; + } + closeIdx = remainder.indexOf(')'); + } + if (closeIdx !== -1) { + paramString = remainder.slice(0, closeIdx); + } else { + paramString = remainder.trim(); + } + } + + const params = splitRoutineParams(paramString); + const paramsText = params.join(', '); + const signatureLabel = `${label}(${paramsText})`; + const signature: SignatureInformation = { + label: signatureLabel, + parameters: routineParameterInfos(signatureLabel, params) + }; + + return { + signature, + start: Position.create(parenLine, parsed[parenLine][parenToken].p + 1), + parameters: params + }; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function buildRoutineDocumentation( + signature: SignatureInformation, + activeIndex: number | null, + options?: { + context?: 'hover' | 'signature'; + boldParameter?: boolean; + // opcional: mostrar o cabeçalho (bloco de código) no hover + showHeaderInHover?: boolean; + } +): MarkupContent | undefined { + const paramInfos = signature.parameters ?? []; + const isHover = options?.context === 'hover'; + const isSignature = options?.context === 'signature'; + const boldParam = options?.boldParameter === true; + const showHeaderInHover = options?.showHeaderInHover ?? true; + + const clamp = (i: number) => + paramInfos.length ? Math.min(Math.max(i, 0), paramInfos.length - 1) : 0; + + const getParamText = (i: number) => { + const info = paramInfos[i]; + if (!info) return ''; + if (Array.isArray(info.label)) { + const [s, e] = info.label; + return s < e ? signature.label.slice(s, e) : ''; + } + return typeof info.label === 'string' ? info.label : ''; + }; + + const esc = (s: string) => + s.replace(/[<&>]/g, m => ({ '<': '<', '>': '>', '&': '&' }[m]!)); + + // assinatura com o parâmetro ativo em `inline code` + const renderSignatureWithInlineParam = (label: string, i: number) => { + const info = paramInfos[i]; + if (!info) return esc(label); + + if (Array.isArray(info.label)) { + const [s, e] = info.label; + if (s < e) { + const pre = esc(label.slice(0, s)); + const mid = esc(label.slice(s, e)); + const pos = esc(label.slice(e)); + const midCode = boldParam ? `**\`${mid}\`**` : `\`${mid}\``; + return `${pre}${midCode}${pos}`; + } + return esc(label); + } + + if (typeof info.label === 'string' && info.label) { + const safeLabel = esc(label); + const needle = esc(info.label); + const k = safeLabel.indexOf(needle); + if (k >= 0) { + const pre = safeLabel.slice(0, k); + const pos = safeLabel.slice(k + needle.length); + const midCode = boldParam ? `**\`${needle}\`**` : `\`${needle}\``; + return `${pre}${midCode}${pos}`; + } + } + return esc(label); + }; + + const idx = clamp(activeIndex ?? 0); + const pText = getParamText(idx); + const pEsc = esc(pText); + const pInline = boldParam ? `**\`${pEsc}\`**` : `\`${pEsc}\``; + + const lines: string[] = []; + + if (isHover && showHeaderInHover) { + // cabeçalho tipo “bloco de código” no hover + lines.push('```'); + lines.push(signature.label); // sem escape — preserve exatamente a assinatura + lines.push('```'); + lines.push(''); + } + + // ⛔ NO SIGNATURE: NÃO renderiza a linha “do meio” + if (!isSignature) { + // hover: mantém a linha “signature-like” + lines.push(renderSignatureWithInlineParam(signature.label, idx)); + lines.push(''); + } + + // “Parâmetro na origem” em ambos os contextos + lines.push(pText ? `Parâmetro na origem: ${pInline}` : 'Parâmetro na origem:'); + + return { kind: MarkupKind.Markdown, value: lines.join('\n') }; +} diff --git a/server/src/providers/hover.ts b/server/src/providers/hover.ts index f3a36ae..9d238ab 100644 --- a/server/src/providers/hover.ts +++ b/server/src/providers/hover.ts @@ -3,6 +3,8 @@ import { getServerSpec, getLanguageServerSettings, findFullRange, normalizeClass import { ServerSpec, QueryData, CommandDoc, KeywordDoc } from '../utils/types'; import { documents, corePropertyParams, mppContinue } from '../utils/variables'; import * as ld from '../utils/languageDefinitions'; +import { getClassMethodHover } from '../ccs/hover/classSupport'; +import { getRoutineHover } from '../ccs/hover/routineSupport'; import commands = require("../documentation/commands.json"); import structuredSystemVariables = require("../documentation/structuredSystemVariables.json"); @@ -25,9 +27,8 @@ import triggerKeywords = require("../documentation/keywords/Trigger.json"); import xdataKeywords = require("../documentation/keywords/XData.json"); function documaticLink(server: ServerSpec, cls: string): string { - return `[${cls}](${server.scheme}://${server.host}:${server.port}${server.pathPrefix}/csp/documatic/%25CSP.Documatic.cls?LIBRARY=${ - encodeURIComponent(server.namespace.toUpperCase()) - }&CLASSNAME=${encodeURIComponent(cls)})`; + return `[${cls}](${server.scheme}://${server.host}:${server.port}${server.pathPrefix}/csp/documatic/%25CSP.Documatic.cls?LIBRARY=${encodeURIComponent(server.namespace.toUpperCase()) + }&CLASSNAME=${encodeURIComponent(cls)})`; } function markupValue(header: string, body?: string): string { @@ -36,9 +37,9 @@ function markupValue(header: string, body?: string): string { export async function onHover(params: TextDocumentPositionParams): Promise { const doc = documents.get(params.textDocument.uri); - if (doc === undefined) {return null;} + if (doc === undefined) { return null; } const parsed = await getParsedDocument(params.textDocument.uri); - if (parsed === undefined) {return null;} + if (parsed === undefined) { return null; } const server: ServerSpec = await getServerSpec(params.textDocument.uri); const settings = await getLanguageServerSettings(params.textDocument.uri); @@ -48,25 +49,48 @@ export async function onHover(params: TextDocumentPositionParams): Promise= symbolstart && params.position.character <= symbolend) { // We found the right symbol in the line + const routineHover = await getRoutineHover( + doc, + parsed, + params.position, + i, + params.textDocument.uri, + server + ); + if (routineHover !== null) { + return routineHover; + } + + const methodHover = await getClassMethodHover( + doc, + parsed, + params.position, + i, + server + ); + if (methodHover !== null) { + return methodHover; + } + if (( (parsed[params.position.line][i].l == ld.cls_langindex && parsed[params.position.line][i].s == ld.cls_clsname_attrindex) || (parsed[params.position.line][i].l == ld.cos_langindex && parsed[params.position.line][i].s == ld.cos_clsname_attrindex) - ) && doc.getText(Range.create(params.position.line,0,params.position.line,6)).toLowerCase() !== "import" + ) && doc.getText(Range.create(params.position.line, 0, params.position.line, 6)).toLowerCase() !== "import" ) { // This is a class name - + // Get the full text of the selection - let wordrange = findFullRange(params.position.line,parsed,i,symbolstart,symbolend); + let wordrange = findFullRange(params.position.line, parsed, i, symbolstart, symbolend); let word = doc.getText(wordrange); if (word.charAt(0) === ".") { // This might be $SYSTEM.ClassName const prevseven = doc.getText(Range.create( - params.position.line,wordrange.start.character-7, - params.position.line,wordrange.start.character + params.position.line, wordrange.start.character - 7, + params.position.line, wordrange.start.character )); if (prevseven.toUpperCase() === "$SYSTEM") { // This is $SYSTEM.ClassName @@ -79,25 +103,25 @@ export async function onHover(params: TextDocumentPositionParams): Promise 3 && parsed[ln][3].s == ld.cos_delim_attrindex && - doc.getText(Range.create(ln,parsed[ln][3].p,ln,parsed[ln][3].p+parsed[ln][3].c)) == "(" + doc.getText(Range.create(ln, parsed[ln][3].p, ln, parsed[ln][3].p + parsed[ln][3].c)) == "(" ) { // Capture the formal spec for (let tkn = 3; tkn < parsed[ln].length; tkn++) { - formalspec += doc.getText(Range.create(ln,parsed[ln][tkn].p,ln,parsed[ln][tkn].p+parsed[ln][tkn].c)); + formalspec += doc.getText(Range.create(ln, parsed[ln][tkn].p, ln, parsed[ln][tkn].p + parsed[ln][tkn].c)); if (parsed[ln][tkn].s == ld.cos_delim_attrindex && formalspec.endsWith(")")) { definitionendtkn = tkn; break; @@ -205,37 +229,37 @@ export async function onHover(params: TextDocumentPositionParams): Promise argslist[argidx]); + definition = definition.replace(new RegExp(paramslist[argidx].trim(), "g"), () => argslist[argidx]); } } } } } - + return { contents: { kind: MarkupKind.Markdown, @@ -297,7 +321,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // We got data back const exptext = exprespdata.data.result.content.expansion.join(" \n"); - if (exptext.slice(0,5) === "ERROR") { + if (exptext.slice(0, 5) === "ERROR") { // An error occurred while generating the expansion, so return the definition instead const defquerydata = { docname: maccon.docname, @@ -329,7 +353,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // The macro definition was found return { @@ -362,7 +386,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // The macro definition was found return { @@ -375,18 +399,17 @@ export async function onHover(params: TextDocumentPositionParams): Promise el.label === sysftext || el.alias.includes(sysftext)); if (sysfdoc && sysftext != "$PREPROCESS") { return { contents: { kind: MarkupKind.Markdown, - value: ["$ZUTIL","$ZU"].includes(sysftext) ? + value: ["$ZUTIL", "$ZU"].includes(sysftext) ? markupValue(`[Online documentation](${sysfdoc.link})`) : - markupValue(sysfdoc.documentation.join(""), sysfdoc.link ? `[Online documentation](${ - sysfdoc.link[0] == "h" ? sysfdoc.link : `https://docs.intersystems.com/irislatest/csp/docbook/Doc.View.cls?KEY=RCOS_${sysfdoc.link}` - })` : "") + markupValue(sysfdoc.documentation.join(""), sysfdoc.link ? `[Online documentation](${sysfdoc.link[0] == "h" ? sysfdoc.link : `https://docs.intersystems.com/irislatest/csp/docbook/Doc.View.cls?KEY=RCOS_${sysfdoc.link}` + })` : "") }, range: sysfrange }; @@ -394,7 +417,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise= 0; j--) { + for (let j = i - 1; j >= 0; j--) { if (parsed[params.position.line][j].l == ld.cos_langindex && parsed[params.position.line][j].s == ld.cos_ssysv_attrindex) { const firsthalf = doc.getText(Range.create( - params.position.line,parsed[params.position.line][j].p, - params.position.line,parsed[params.position.line][j].p+parsed[params.position.line][j].c + params.position.line, parsed[params.position.line][j].p, + params.position.line, parsed[params.position.line][j].p + parsed[params.position.line][j].c )); if (firsthalf === "^$") { firsthalfstart = parsed[params.position.line][j].p; @@ -441,7 +464,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise el.label === ssysvtext || el.alias.includes(ssysvtext)); if (ssysvdoc !== undefined) { @@ -459,7 +482,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise el.label === sysvtext || el.alias.includes(sysvtext)); if (sysvdoc !== undefined) { @@ -477,15 +500,15 @@ export async function onHover(params: TextDocumentPositionParams): Promise el.label === commandtext|| el.alias.includes(commandtext)); + commanddoc = commands.find((el) => el.label === commandtext || el.alias.includes(commandtext)); } if (commanddoc !== undefined) { return { @@ -502,23 +525,23 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // We got data back let header = `(**${membercontext.baseclass}**) **${member}**`; - const nextchar = doc.getText(Range.create(params.position.line,memberrange.end.character,params.position.line,memberrange.end.character+1)); + const nextchar = doc.getText(Range.create(params.position.line, memberrange.end.character, params.position.line, memberrange.end.character + 1)); if (member == "%New" && respdata.data.result.content.length == 2 && respdata.data.result.content[1].Origin != "%Library.RegisteredObject") { // %OnNew has been overridden for this class - header += beautifyFormalSpec(respdata.data.result.content[1].FormalSpec,true); + header += beautifyFormalSpec(respdata.data.result.content[1].FormalSpec, true); header += ` As **${membercontext.baseclass}**`; return { contents: { kind: MarkupKind.Markdown, - value: markupValue(header,documaticHtmlToMarkdown(respdata.data.result.content[ + value: markupValue(header, documaticHtmlToMarkdown(respdata.data.result.content[ respdata.data.result.content[1].Description.trim().length ? 1 : 0 ].Description)) }, @@ -632,14 +655,14 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // We got data back if (nextchar === "(") { - header = header + beautifyFormalSpec(stubrespdata.data.result.content[0].FormalSpec,true); + header = header + beautifyFormalSpec(stubrespdata.data.result.content[0].FormalSpec, true); if (stubrespdata.data.result.content[0].ReturnType !== "") { header = `${header} As **${stubrespdata.data.result.content[0].ReturnType}**`; } @@ -647,7 +670,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise= 0; j--) { + for (let j = i - 1; j >= 0; j--) { if (parsed[params.position.line][j].l == ld.cls_langindex && parsed[params.position.line][j].s == ld.cls_delim_attrindex) { // This is a UDL delimiter const delim = doc.getText( Range.create( - params.position.line,parsed[params.position.line][j].p, - params.position.line,parsed[params.position.line][j].p+1 + params.position.line, parsed[params.position.line][j].p, + params.position.line, parsed[params.position.line][j].p + 1 ) ); if (delim === "[") { @@ -700,30 +723,30 @@ export async function onHover(params: TextDocumentPositionParams): Promise= 0; k--) { + for (let k = params.position.line - 1; k >= 0; k--) { if (parsed[k].length === 0) { continue; } if (parsed[k][0].l == ld.cls_langindex && parsed[k][0].s == ld.cls_keyword_attrindex) { firstkey = doc.getText(Range.create( - k,parsed[k][0].p, - k,parsed[k][0].p+parsed[k][0].c + k, parsed[k][0].p, + k, parsed[k][0].p + parsed[k][0].c )).toLowerCase(); break; } } } - + var keydoc: KeywordDoc | undefined; if (firstkey === "class") { // This is a class keyword @@ -777,9 +800,9 @@ export async function onHover(params: TextDocumentPositionParams): Promise typedoc.name === tokentext); @@ -817,15 +840,15 @@ export async function onHover(params: TextDocumentPositionParams): Promise= 0; ln--) { - for (let tk = parsed[ln].length-1; tk >= 0; tk--) { + for (let tk = parsed[ln].length - 1; tk >= 0; tk--) { if (ln === params.position.line && parsed[ln][tk].p >= idenrange.start.character) { // Start looking when we pass the full range of the selected identifier continue; @@ -836,8 +859,8 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // We got data back @@ -884,7 +907,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // We got data back let header = `(**${normalizedname}**) **${procname}**`; - const nextchar = doc.getText(Range.create(params.position.line,idenrange.end.character,params.position.line,idenrange.end.character+1)); + const nextchar = doc.getText(Range.create(params.position.line, idenrange.end.character, params.position.line, idenrange.end.character + 1)); if (nextchar === "(") { - header = header + beautifyFormalSpec(respdata.data.result.content[0].FormalSpec,true); + header = header + beautifyFormalSpec(respdata.data.result.content[0].FormalSpec, true); if (respdata.data.result.content[0].ReturnType !== "") { header = `${header} As **${respdata.data.result.content[0].ReturnType}**`; } @@ -949,7 +972,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // We won't resolve properties that don't contain the table name - const tblname = iden.slice(0,iden.lastIndexOf(".")); - const propname = iden.slice(iden.lastIndexOf(".")+1); + const tblname = iden.slice(0, iden.lastIndexOf(".")); + const propname = iden.slice(iden.lastIndexOf(".") + 1); if (tblname.lastIndexOf("_") > tblname.lastIndexOf(".")) { // This table is projected from a multi-dimensional property, so we can't provide any info } else { // Normalize the class name if there are imports - const normalizedname = await normalizeClassname(doc,parsed,tblname.replace(/_/g,"."),server,params.position.line); + const normalizedname = await normalizeClassname(doc, parsed, tblname.replace(/_/g, "."), server, params.position.line); if (normalizedname !== "") { // Query the server to get the description of this property const data: QueryData = { query: "SELECT Description, CASE WHEN Collection IS NOT NULL THEN Collection||' Of '||Type ELSE Type END AS DisplayType FROM %Dictionary.CompiledProperty WHERE Parent = ? AND name = ?", - parameters: [normalizedname,propname] + parameters: [normalizedname, propname] }; - const respdata = await makeRESTRequest("POST",1,"/action/query",server,data); + const respdata = await makeRESTRequest("POST", 1, "/action/query", server, data); if (Array.isArray(respdata?.data?.result?.content) && respdata.data.result.content.length > 0) { // We got data back @@ -985,7 +1008,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise el.label.toLowerCase().replace(/\s+/g,'') === pp.toLowerCase()); + const ppobj = preprocessorDirectives.find((el) => el.label.toLowerCase().replace(/\s+/g, '') === pp.toLowerCase()); if (ppobj !== undefined) { return { contents: { @@ -1021,11 +1044,11 @@ export async function onHover(params: TextDocumentPositionParams): Promise 0) { // We got data back - const header = `(**${normalizedcls}**) **${param}**${ - respdata.data.result.content[0].Type != "" ? ` As **${respdata.data.result.content[0].Type}**` : "" - }`; + const header = `(**${normalizedcls}**) **${param}**${respdata.data.result.content[0].Type != "" ? ` As **${respdata.data.result.content[0].Type}**` : "" + }`; return { contents: { kind: MarkupKind.Markdown, - value: markupValue(header,documaticHtmlToMarkdown(respdata.data.result.content[0].Description)) + value: markupValue(header, documaticHtmlToMarkdown(respdata.data.result.content[0].Description)) }, range: paramrange }; @@ -1068,13 +1090,13 @@ export async function onHover(params: TextDocumentPositionParams): Promise**${method}**${beautifyFormalSpec(respdata.data.result.content[0].FormalSpec,true)}`; + let header = `(**${cls}**) **${method}**${beautifyFormalSpec(respdata.data.result.content[0].FormalSpec, true)}`; if (respdata.data.result.content[0].ReturnType != "") { let type: string = respdata.data.result.content[0].ReturnType; type = type.includes(" ") ? type.charAt(0).toUpperCase() + type.slice(1) : type; @@ -1195,7 +1217,7 @@ export async function onHover(params: TextDocumentPositionParams): Promise numargs) { // The given argument doesn't exist in the list - return arglist.replace(/\s+/g,""); + return normalized.replace(/\s+/g, ""); } var start: number = -1; // inclusive @@ -44,101 +46,67 @@ function emphasizeArgument(arglist: string, arg: number): string { var lastspace: number = 0; if (arg === numargs) { // The last argument always ends at the second-to-last position - end = arglist.length - 1; - if (numargs > 1) start = arglist.lastIndexOf(" ") + 1; + end = normalized.length - 1; + if (numargs > 1) start = normalized.lastIndexOf(" ") + 1; } if (arg === 1) { // The first argument always starts at position 1 start = 1; if (end === -1) { // Find the first space - end = arglist.indexOf(" ") - 1; + end = normalized.indexOf(" ") - 1; } } if (start !== -1 && end !== -1) { // Do the replacement - return (arglist.slice(0,start) + emphasizePrefix + arglist.slice(start,end) + emphasizeSuffix + arglist.slice(end)).replace(/\s+/g,""); - } - else { + return (arglist.slice(0, start) + emphasizePrefix + arglist.slice(start, end) + emphasizeSuffix + arglist.slice(end)).replace(/\s+/g, ""); + } else { // Find the unknown positions var result = arglist; - while (arglist.indexOf(" ",lastspace+1) !== -1) { - const thisspace = arglist.indexOf(" ",lastspace); + while (normalized.indexOf(" ", lastspace + 1) !== -1) { + const thisspace = normalized.indexOf(" ", lastspace); spacesfound++; if (arg === spacesfound + 1) { // This is the space before the argument start = thisspace + 1; if (end === -1) { // Look for the next space - end = arglist.indexOf(" ",start) - 1; + end = normalized.indexOf(" ", start) - 1; } - result = arglist.slice(0,start) + emphasizePrefix + arglist.slice(start,end) + emphasizeSuffix + arglist.slice(end); + result = arglist.slice(0, start) + emphasizePrefix + arglist.slice(start, end) + emphasizeSuffix + arglist.slice(end); break; } lastspace = thisspace; } - return result.replace(/\s+/g,""); + return result.replace(/\s+/g, ""); } }; /** Use HTML to display `exp` as a code block with the empasized argument rendered bold, italic and underlined. */ function markdownifyExpansion(exp: string[]): string { return "
\n" + exp.map(e => e.trimEnd()).join("\n")
-		.replace(new RegExp(emphasizePrefix,"g"),"")
-		.replace(new RegExp(emphasizeSuffix,"g"),"") + "\n
"; -} - -/** Returns the [start,end] tuples for all parameters in `formalSpec` */ -function formalSpecToParamsArr(formalSpec: string): ParameterInformation[] { - const result: ParameterInformation[] = []; - if (formalSpec.replace(/\s+/g,"") == "()") return result; // No parameters - let currentParamStart = 1, openParenCount = 0, openBraceCount = 0, inQuote = false; - Array.from(formalSpec).forEach((char: string, idx: number) => { - switch (char) { - case "{": - if (!inQuote) openBraceCount++; - break; - case "}": - if (!inQuote) openBraceCount--; - break; - case "(": - if (!inQuote) openParenCount++; - break; - case ")": - if (!inQuote) openParenCount--; - break; - case "\"": - inQuote = !inQuote; - break; - case ",": - if (!inQuote && !openBraceCount && openParenCount == 1) { - result.push(ParameterInformation.create([currentParamStart,idx])); - currentParamStart = idx + 1; - } - } - }); - result.push(ParameterInformation.create([currentParamStart,formalSpec.length - 1])); - return result; + .replace(new RegExp(emphasizePrefix, "g"), "") + .replace(new RegExp(emphasizeSuffix, "g"), "") + "\n"; } export async function onSignatureHelp(params: SignatureHelpParams): Promise { - if (params.context === undefined) {return null;} + if (params.context === undefined) { return null; } const doc = documents.get(params.textDocument.uri); - if (doc === undefined) {return null;} + if (doc === undefined) { return null; } const parsed = await getParsedDocument(params.textDocument.uri); - if (parsed === undefined) {return null;} + if (parsed === undefined) { return null; } const server: ServerSpec = await getServerSpec(params.textDocument.uri); const settings = await getLanguageServerSettings(params.textDocument.uri); if (params.context.triggerKind == SignatureHelpTriggerKind.Invoked) { // We always base our return value on the triggerCharacter - params.context.triggerCharacter = doc.getText(Range.create(Position.create(params.position.line,params.position.character-1),params.position)); + params.context.triggerCharacter = doc.getText(Range.create(Position.create(params.position.line, params.position.character - 1), params.position)); } let thistoken: number = -1; for (let i = 0; i < parsed[params.position.line].length; i++) { const symbolstart: number = parsed[params.position.line][i].p; - const symbolend: number = parsed[params.position.line][i].p + parsed[params.position.line][i].c; + const symbolend: number = parsed[params.position.line][i].p + parsed[params.position.line][i].c; thistoken = i; if (params.position.character >= symbolstart && params.position.character <= symbolend) { // We found the right symbol in the line @@ -152,10 +120,10 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0) { signatureHelpDocumentationCache.doc = { kind: MarkupKind.Markdown, @@ -191,6 +160,23 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0 ) { // This is potentially the start of a signature var newsignature: SignatureHelp | null = null; - if (parsed[params.position.line][thistoken-1].l == ld.cos_langindex && parsed[params.position.line][thistoken-1].s == ld.cos_macro_attrindex) { + if (parsed[params.position.line][thistoken - 1].l == ld.cos_langindex && parsed[params.position.line][thistoken - 1].s == ld.cos_macro_attrindex) { // This is a macro // Get the details of this class - const maccon = getMacroContext(doc,parsed,params.position.line); + const maccon = getMacroContext(doc, parsed, params.position.line); // Get the full range of the macro - const macrorange = findFullRange(params.position.line,parsed,thistoken-1,parsed[params.position.line][thistoken-1].p,parsed[params.position.line][thistoken-1].p+parsed[params.position.line][thistoken-1].c); + const macrorange = findFullRange(params.position.line, parsed, thistoken - 1, parsed[params.position.line][thistoken - 1].p, parsed[params.position.line][thistoken - 1].p + parsed[params.position.line][thistoken - 1].c); const macroname = doc.getText(macrorange).slice(3); // Get the macro signature from the server @@ -232,10 +218,10 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0) { signatureHelpDocumentationCache = { type: "macro", @@ -274,20 +260,20 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0) { // We got data back if (member == "%New") { if (respdata.data.result.content.length == 2 && respdata.data.result.content[1].Origin != "%Library.RegisteredObject") { // %OnNew has been overridden for this class + const raw = beautifyFormalSpec(respdata.data.result.content[1].FormalSpec); const sig: SignatureInformation = { - label: beautifyFormalSpec(respdata.data.result.content[1].FormalSpec), + label: raw, parameters: [] }; if (settings.signaturehelp.documentation) { @@ -325,8 +312,8 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0) { // We got data back @@ -373,8 +360,9 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0 && activeparam !== null) { + activeparam = Math.min(Math.max(activeparam, 0), macroParamCount - 1); + } // Get the macro expansion with the correct parameter emphasized signatureHelpMacroCache = { @@ -471,9 +493,9 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0) { signatureHelpDocumentationCache = { type: "macro", @@ -484,7 +506,7 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0) { // We got data back if (member == "%New") { if (respdata.data.result.content.length == 2 && respdata.data.result.content[1].Origin != "%Library.RegisteredObject") { // %OnNew has been overridden for this class + const raw = beautifyFormalSpec(respdata.data.result.content[1].FormalSpec); const sig: SignatureInformation = { - label: beautifyFormalSpec(respdata.data.result.content[1].FormalSpec), + label: raw, parameters: [] }; if (settings.signaturehelp.documentation) { @@ -544,14 +567,19 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0 && newActive !== null) + ? Math.min(Math.max(newActive, 0), newParamCount - 1) + : null; + signatureHelpStartPosition = Position.create(sigstartln, parsed[sigstartln][sigstarttkn].p + 1); newsignature = { signatures: [sig], activeSignature: 0, - activeParameter: determineActiveParam(doc.getText(Range.create(Position.create(sigstartln,parsed[sigstartln][sigstarttkn].p+1),params.position))) + activeParameter: boundedNewActive }; } else { // If there's no %OnNew, then %New shouldn't have arguments @@ -580,9 +608,9 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0) { // We got data back @@ -592,8 +620,9 @@ export async function onSignatureHelp(params: SignatureHelpParams): Promise 0 && methodActive !== null) + ? Math.min(Math.max(methodActive, 0), methodParamCount - 1) + : null; + if (["%Open", "%OpenId"].includes(member)) { sig.label += ` As ${membercontext.baseclass}`; } else if (memobj.ReturnType != "") { sig.label += ` As ${memobj.ReturnType}`; } - signatureHelpStartPosition = Position.create(sigstartln,parsed[sigstartln][sigstarttkn].p+1); + signatureHelpStartPosition = Position.create(sigstartln, parsed[sigstartln][sigstarttkn].p + 1); return { signatures: [sig], activeSignature: 0, - activeParameter: determineActiveParam(doc.getText(Range.create(Position.create(sigstartln,parsed[sigstartln][sigstarttkn].p+1),params.position))) + activeParameter: boundedMethodActive }; } } } } + else { + const routineDetails = await getRoutineSignatureDetails( + doc, + parsed, + sigstartln, + sigstarttkn, + params.textDocument.uri, + server + ); + if (routineDetails !== null) { + const startPos = routineDetails.start; + const beforeStart = ( + params.position.line < startPos.line || + (params.position.line == startPos.line && params.position.character < startPos.character) + ); + const activeParamValue = beforeStart + ? 0 + : determineActiveParam(doc.getText(Range.create(startPos, params.position))); + const boundedIndex = routineDetails.signature.parameters.length + ? Math.min( + Math.max((activeParamValue ?? 0), 0), + routineDetails.signature.parameters.length - 1 + ) + : null; + const docContent = buildRoutineDocumentation( + routineDetails.signature, + boundedIndex, + { context: 'signature' } + ); + signatureHelpDocumentationCache = { + type: "routine", + doc: docContent + }; + routineDetails.signature.documentation = docContent; + signatureHelpStartPosition = startPos; + return { + signatures: [routineDetails.signature], + activeSignature: 0, + activeParameter: boundedIndex + }; + } + } } } return null; diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index 43a5933..65e4b7b 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -55,11 +55,11 @@ export type StudioOpenDialogFile = { * Schema of an element in the command documentation file. */ export type CommandDoc = { - label: string; - alias: string[]; - documentation: string[]; - link: string; - insertText?: string; + label: string; + alias: string[]; + documentation: string[]; + link: string; + insertText?: string; }; /** @@ -144,8 +144,8 @@ export type SignatureHelpMacroContext = { * The content of the last SignatureHelp documentation sent and the type of signature that it applies to. */ export type SignatureHelpDocCache = { - doc: MarkupContent, - type: "macro" | "method" + doc: MarkupContent | undefined, + type: "macro" | "method" | "routine" }; /** @@ -159,15 +159,15 @@ export type PossibleClasses = { export type compresseditem = { /** The numerical index of the language. */ - l: number; - /** The index of the attribute within the array returned by `GetLanguageAttributes()`. */ - s: number; - /** The starting position of this token in the source line. */ - p: number; - /** The length of this token's source. */ - c: number; - /** A short description of the syntax error. It will only be defined if `l` is `1` (ObjectScript) and `s` is `0`. */ - e?: string; + l: number; + /** The index of the attribute within the array returned by `GetLanguageAttributes()`. */ + s: number; + /** The starting position of this token in the source line. */ + p: number; + /** The length of this token's source. */ + c: number; + /** A short description of the syntax error. It will only be defined if `l` is `1` (ObjectScript) and `s` is `0`. */ + e?: string; }; export type compressedline = compresseditem[];