From bbe7448493fd5c53be3faf24785c15cd85bb80a3 Mon Sep 17 00:00:00 2001 From: "Ramazan G." Date: Tue, 6 Oct 2026 20:37:36 +0200 Subject: [PATCH] feat: add hover documentation support for MATLAB functions - Add HoverSupportProvider handling textDocument/hover requests - Dynamically query MATLAB documentation via MVM and getHover handler when connected - Format raw MATLAB help text into structured Markdown with syntax highlighting and argument lists - Register hoverProvider capability in Language Server - Add unit tests for HoverSupportProvider and HoverMarkdownUtils --- matlab/+matlabls/+handlers/+hover/getHover.m | 11 ++ src/providers/hover/HoverMarkdownUtils.ts | 145 ++++++++++++++++++ src/providers/hover/HoverSupportProvider.ts | 87 +++++++++++ src/server.ts | 14 +- .../hover/HoverMarkdownUtils.test.ts | 70 +++++++++ .../hover/HoverSupportProvider.test.ts | 143 +++++++++++++++++ 6 files changed, 467 insertions(+), 3 deletions(-) create mode 100644 matlab/+matlabls/+handlers/+hover/getHover.m create mode 100644 src/providers/hover/HoverMarkdownUtils.ts create mode 100644 src/providers/hover/HoverSupportProvider.ts create mode 100644 tests/providers/hover/HoverMarkdownUtils.test.ts create mode 100644 tests/providers/hover/HoverSupportProvider.test.ts diff --git a/matlab/+matlabls/+handlers/+hover/getHover.m b/matlab/+matlabls/+handlers/+hover/getHover.m new file mode 100644 index 0000000..c007e25 --- /dev/null +++ b/matlab/+matlabls/+handlers/+hover/getHover.m @@ -0,0 +1,11 @@ +function hoverText = getHover(topic) + % GETHOVER Retrieves hover documentation for a given topic or symbol. + % + % Copyright 2026 The MathWorks, Inc. + + try + hoverText = help(topic); + catch + hoverText = ''; + end +end diff --git a/src/providers/hover/HoverMarkdownUtils.ts b/src/providers/hover/HoverMarkdownUtils.ts new file mode 100644 index 0000000..7d9fd0b --- /dev/null +++ b/src/providers/hover/HoverMarkdownUtils.ts @@ -0,0 +1,145 @@ +// Copyright 2026 The MathWorks, Inc. + +/** + * Converts raw MATLAB help text into structured Markdown with code highlighting + * for syntax and examples, and clean text formatting for arguments and descriptions. + * + * @param rawHelp The raw text returned by MATLAB help() + * @param word The symbol/function name being queried + * @returns Structured Markdown string + */ +export function formatMatlabHelpToMarkdown (rawHelp: string, word: string): string { + if (rawHelp == null || rawHelp.trim() === '') { + return '' + } + + const lines = rawHelp.split(/\r?\n/) + let md = '' + + // 1. Extract function title and short purpose from top line (e.g. "disp - Display value of variable") + let startIdx = 0 + for (let i = 0; i < Math.min(lines.length, 6); i++) { + const trimmed = lines[i].trim() + const headerMatch = trimmed.match(/^([a-zA-Z0-9_.]+)\s+-\s+(.+)$/) + if (headerMatch != null) { + const funcName = headerMatch[1] + const purpose = headerMatch[2] + md += `### \`${funcName}\`\n\n${purpose}\n\n` + startIdx = i + 1 + break + } + } + + if (md === '' && word !== '') { + md += `### \`${word}\`\n\n` + } + + type SectionType = 'none' | 'syntax' | 'args' | 'examples' | 'seealso' | 'misc' + let currentSection: SectionType = 'none' + let buffer: string[] = [] + + const flushSection = (): void => { + if (buffer.length === 0) return + const text = buffer.join('\n').trim() + buffer = [] + if (text === '') return + + switch (currentSection) { + case 'syntax': + md += `#### Syntax\n\`\`\`matlab\n${text}\n\`\`\`\n\n` + break + case 'examples': + md += `#### Examples\n\`\`\`matlab\n${text}\n\`\`\`\n\n` + break + case 'args': + case 'seealso': + case 'misc': + default: + md += `${text}\n\n` + break + } + } + + for (let i = startIdx; i < lines.length; i++) { + const rawLine = lines[i] + const trimmed = rawLine.trim() + + if (trimmed === '') { + if (currentSection === 'syntax' || currentSection === 'examples') { + buffer.push('') + } + continue + } + + // Detect section headers + if (/^Syntax\s*$/i.test(trimmed)) { + flushSection() + currentSection = 'syntax' + continue + } + + if (/^(Input Arguments|Output Arguments|Name-Value Arguments|Parameters)\s*$/i.test(trimmed)) { + flushSection() + currentSection = 'args' + buffer.push(`#### ${trimmed}`) + continue + } + + if (/^Examples\s*$/i.test(trimmed)) { + flushSection() + currentSection = 'examples' + continue + } + + if (/^See also\b/i.test(trimmed)) { + flushSection() + currentSection = 'seealso' + const rest = trimmed.replace(/^See also\s*:?/i, '').trim() + buffer.push(`**See also:** ${rest}`) + continue + } + + if (/^(Introduced in|Documentation for|Other uses of)\b/i.test(trimmed)) { + flushSection() + currentSection = 'misc' + buffer.push(`*${trimmed}*`) + continue + } + + // Handle section content + if (currentSection === 'syntax') { + // Sub-headings in syntax (e.g. "Vector and Matrix Data") converted to comments for highlight + if (!/[()=]/.test(trimmed) && !trimmed.startsWith('%')) { + buffer.push(`% --- ${trimmed} ---`) + } else { + buffer.push(trimmed) + } + } else if (currentSection === 'examples') { + buffer.push(trimmed) + } else if (currentSection === 'seealso') { + buffer[buffer.length - 1] += ' ' + trimmed + } else if (currentSection === 'args') { + const argMatch = trimmed.match(/^([a-zA-Z0-9_]+)\s+-\s+(.+)$/) + if (argMatch != null) { + buffer.push(`\n- **\`${argMatch[1]}\`** — ${argMatch[2]}`) + } else { + buffer.push(` ${trimmed}`) + } + } else { + buffer.push(trimmed) + } + } + + flushSection() + + // Format "See also" items with inline code tags + md = md.replace(/\*\*See also:\*\*\s*(.+)/g, (_match, items: string) => { + const tokens = items.split(/,\s*/).map(t => { + const clean = t.trim().replace(/\.$/, '') + return clean !== '' ? `\`${clean}\`` : '' + }).filter(t => t !== '') + return `**See also:** ${tokens.join(', ')}` + }) + + return md.trim() +} diff --git a/src/providers/hover/HoverSupportProvider.ts b/src/providers/hover/HoverSupportProvider.ts new file mode 100644 index 0000000..93b77fe --- /dev/null +++ b/src/providers/hover/HoverSupportProvider.ts @@ -0,0 +1,87 @@ +// Copyright 2026 The MathWorks, Inc. + +import { Hover, HoverParams, MarkupKind, Position, Range, TextDocuments } from 'vscode-languageserver' +import { TextDocument } from 'vscode-languageserver-textdocument' +import MatlabLifecycleManager from '../../lifecycle/MatlabLifecycleManager' +import MVM from '../../mvm/impl/MVM' +import Logger from '../../logging/Logger' +import { formatMatlabHelpToMarkdown } from './HoverMarkdownUtils' + +class HoverSupportProvider { + constructor ( + private readonly matlabLifecycleManager: MatlabLifecycleManager, + private readonly mvm: MVM + ) {} + + /** + * Handles an incoming textDocument/hover request. + */ + async handleHoverRequest (params: HoverParams, documentManager: TextDocuments): Promise { + if (!this.matlabLifecycleManager.isMatlabConnected() || !this.mvm.isReady()) { + return null + } + + const document = documentManager.get(params.textDocument.uri) + if (document == null) { + return null + } + + const { word, range } = this.getWordAndRangeAtPosition(document, params.position) + if (word == null || word === '') { + return null + } + + try { + const response = await this.mvm.feval( + 'matlabls.handlers.hover.getHover', + 1, + [word] + ) + const res = response as { result?: unknown[] } | null + if (res != null && !('error' in res) && Array.isArray(res.result) && res.result.length > 0) { + const helpText = String(res.result[0]).trim() + if (helpText.length > 0) { + return { + contents: { + kind: MarkupKind.Markdown, + value: formatMatlabHelpToMarkdown(helpText, word) + }, + range + } + } + } + } catch (err) { + Logger.error(`Error querying MATLAB MVM for hover: ${String(err)}`) + } + + return null + } + + /** + * Extracts word and range at the given position. + */ + private getWordAndRangeAtPosition (document: TextDocument, position: Position): { word: string | null, range?: Range } { + const text = document.getText() + const offset = document.offsetAt(position) + + let start = offset + while (start > 0 && /[a-zA-Z0-9_]/.test(text[start - 1])) { + start-- + } + + let end = offset + while (end < text.length && /[a-zA-Z0-9_]/.test(text[end])) { + end++ + } + + if (start === end) { + return { word: null } + } + + const word = text.substring(start, end) + const range = Range.create(document.positionAt(start), document.positionAt(end)) + return { word, range } + } +} + +export default HoverSupportProvider diff --git a/src/server.ts b/src/server.ts index ed4d87f..108327e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,6 +23,7 @@ import PathResolver from './providers/navigation/PathResolver' import Indexer from './indexing/Indexer' import RenameSymbolProvider from './providers/rename/RenameSymbolProvider' import HighlightSymbolProvider from './providers/highlighting/HighlightSymbolProvider' +import HoverSupportProvider from './providers/hover/HoverSupportProvider' import SemanticTokensProvider, { SEMANTIC_TOKEN_TYPES, SEMANTIC_TOKEN_MODIFIERS, setupSemanticTokensRefresh } from './providers/semanticTokens/SemanticTokensProvider' import { RequestType } from './indexing/SymbolSearchService' import { cacheAndClearProxyEnvironmentVariables } from './utils/ProxyUtils' @@ -75,6 +76,7 @@ export async function startServer (): Promise { const formatSupportProvider = new FormatSupportProvider(matlabLifecycleManager, mvm) const foldingSupportProvider = new FoldingSupportProvider(matlabLifecycleManager, mvm) const lintingSupportProvider = new LintingSupportProvider(matlabLifecycleManager, mvm) + const hoverSupportProvider = new HoverSupportProvider(matlabLifecycleManager, mvm) const executeCommandProvider = new ExecuteCommandProvider(lintingSupportProvider) const completionSupportProvider = new CompletionSupportProvider(matlabLifecycleManager, mvm) const navigationSupportProvider = new NavigationSupportProvider(matlabLifecycleManager, fileInfoIndex, indexer, documentIndexer, pathResolver) @@ -100,7 +102,7 @@ export async function startServer (): Promise { mvm.on(IMVM.Events.stateChange, (state: MatlabMVMConnectionState) => { if (state === MatlabMVMConnectionState.CONNECTED) { // Handle when the MVM has connected - mvm.feval('matlabls.utils.startupHelper', 0, []) + void mvm.feval('matlabls.utils.startupHelper', 0, []) // Initiate workspace indexing void workspaceIndexer.indexWorkspace() @@ -151,6 +153,7 @@ export async function startServer (): Promise { prepareProvider: true }, documentHighlightProvider: true, + hoverProvider: true, semanticTokensProvider: { legend: { tokenTypes: SEMANTIC_TOKEN_TYPES, @@ -202,7 +205,7 @@ export async function startServer (): Promise { void startMatlabIfOnStartLaunch() // Connect to Workspace Browser - matlabLifecycleManager.eventEmitter.on('connected', async ()=> { + matlabLifecycleManager.eventEmitter.on('connected', async () => { const connection = await matlabLifecycleManager.getMatlabConnection(); connection?.subscribe('/MobileWSB/ServerMsg', (data) => { NotificationService.sendNotification(Notification.WSBServerMessage, data); @@ -295,7 +298,7 @@ export async function startServer (): Promise { reportFileOpened(params.document) void lintingSupportProvider.lintDocument(params.document) void documentIndexer.indexDocument(params.document) - + void navigationSupportProvider.handleDocumentSymbol(params.document.uri, documentManager, RequestType.DocumentSymbol) }) @@ -397,6 +400,11 @@ export async function startServer (): Promise { connection.onRequest(SemanticTokensRequest.method, async (params: SemanticTokensParams) => { return await semanticTokensProvider.handleSemanticTokensRequest(params, documentManager) }) + + /** -------------------- HOVER SUPPORT -------------------- **/ + connection.onHover(async params => { + return await hoverSupportProvider.handleHoverRequest(params, documentManager) + }) } /** -------------------- Helper Functions -------------------- **/ diff --git a/tests/providers/hover/HoverMarkdownUtils.test.ts b/tests/providers/hover/HoverMarkdownUtils.test.ts new file mode 100644 index 0000000..c387837 --- /dev/null +++ b/tests/providers/hover/HoverMarkdownUtils.test.ts @@ -0,0 +1,70 @@ +// Copyright 2026 The MathWorks, Inc. +import assert from 'assert' +import { formatMatlabHelpToMarkdown } from '../../../src/providers/hover/HoverMarkdownUtils' + +describe('HoverMarkdownUtils', () => { + describe('#formatMatlabHelpToMarkdown', () => { + it('should return empty string when input is empty or null', () => { + assert.equal(formatMatlabHelpToMarkdown('', 'disp'), '') + assert.equal(formatMatlabHelpToMarkdown(' ', 'disp'), '') + }) + + it('should format standard function help with syntax, arguments, and examples', () => { + const raw = ` disp - Display value of variable + + Syntax + disp(X) + + Input Arguments + X - Input array + array + + Examples + openExample('matlab/DisplayVariableValuesExample') + + See also format, int2str + + Introduced in MATLAB before R2006a` + + const md = formatMatlabHelpToMarkdown(raw, 'disp') + + assert.ok(md.includes('### `disp`'), 'Should contain function title') + assert.ok(md.includes('Display value of variable'), 'Should contain purpose description') + assert.ok(md.includes('#### Syntax\n```matlab\ndisp(X)\n```'), 'Should highlight syntax in code block') + assert.ok(md.includes('#### Input Arguments'), 'Should have Input Arguments header') + assert.ok(md.includes('**`X`** — Input array'), 'Should format argument item') + assert.ok(md.includes('#### Examples\n```matlab'), 'Should format Examples in code block') + assert.ok(md.includes('**See also:** `format`, `int2str`'), 'Should format See also links') + assert.ok(md.includes('*Introduced in MATLAB before R2006a*'), 'Should format version info in italics') + }) + + it('should format multi-part syntax with subheaders as comments', () => { + const raw = ` plot - 2-D line plot + + Syntax + Vector and Matrix Data + plot(X,Y) + plot(Y) + + Table Data + plot(tbl,xvar,yvar)` + + const md = formatMatlabHelpToMarkdown(raw, 'plot') + + assert.ok(md.includes('% --- Vector and Matrix Data ---'), 'Should convert syntax subheadings to comments') + assert.ok(md.includes('plot(X,Y)'), 'Should contain plot(X,Y)') + assert.ok(md.includes('% --- Table Data ---'), 'Should contain Table Data subheader') + assert.ok(md.includes('plot(tbl,xvar,yvar)'), 'Should contain table plot syntax') + }) + + it('should handle non-standard help without errors', () => { + const raw = `My custom function description. +Line 2 of description.` + + const md = formatMatlabHelpToMarkdown(raw, 'myfun') + + assert.ok(md.includes('### `myfun`'), 'Should prepend word title') + assert.ok(md.includes('My custom function description.'), 'Should preserve raw text') + }) + }) +}) diff --git a/tests/providers/hover/HoverSupportProvider.test.ts b/tests/providers/hover/HoverSupportProvider.test.ts new file mode 100644 index 0000000..f3b925c --- /dev/null +++ b/tests/providers/hover/HoverSupportProvider.test.ts @@ -0,0 +1,143 @@ +// Copyright 2026 The MathWorks, Inc. +import assert from 'assert' +import sinon from 'sinon' + +import getMockConnection from '../../mocks/Connection.mock' +import getMockMvm from '../../mocks/Mvm.mock' + +import HoverSupportProvider from '../../../src/providers/hover/HoverSupportProvider' +import MatlabLifecycleManager from '../../../src/lifecycle/MatlabLifecycleManager' +import ClientConnection from '../../../src/ClientConnection' + +import { TextDocument } from 'vscode-languageserver-textdocument' +import { HoverParams, Position, TextDocuments } from 'vscode-languageserver' + +describe('HoverSupportProvider', () => { + let hoverSupportProvider: HoverSupportProvider + let matlabLifecycleManager: MatlabLifecycleManager + let documentManager: TextDocuments + let mockMvm: any + let mockTextDocument: TextDocument + + const setup = (documentContents: string) => { + matlabLifecycleManager = new MatlabLifecycleManager() + mockMvm = getMockMvm() + hoverSupportProvider = new HoverSupportProvider(matlabLifecycleManager, mockMvm) + documentManager = new TextDocuments(TextDocument) + mockTextDocument = TextDocument.create('file:///test.m', 'matlab', 1, documentContents) + + sinon.stub(documentManager, 'get').returns(mockTextDocument) + } + + const teardown = () => { + sinon.restore() + } + + before(() => { + ClientConnection._setConnection(getMockConnection()) + }) + + after(() => { + ClientConnection._clearConnection() + }) + + describe('#handleHoverRequest', () => { + beforeEach(() => setup('x = 10;\ny = plot(x);\n')) + afterEach(() => teardown()) + + it('should return null if no document found', async () => { + (documentManager.get as sinon.SinonStub).returns(undefined) + + const params: HoverParams = { + textDocument: { uri: 'file:///test.m' }, + position: Position.create(1, 4) + } + const res = await hoverSupportProvider.handleHoverRequest(params, documentManager) + assert.equal(res, null, 'Result should be null when there is no document') + }) + + it('should return null if position is not on a word', async () => { + const params: HoverParams = { + textDocument: { uri: 'file:///test.m' }, + position: Position.create(0, 3) // whitespace after '=' + } + const res = await hoverSupportProvider.handleHoverRequest(params, documentManager) + assert.equal(res, null, 'Result should be null when hovering on whitespace') + }) + + it('should return null when MATLAB is not connected', async () => { + sinon.stub(matlabLifecycleManager, 'isMatlabConnected').returns(false) + mockMvm.isReady.returns(true) + + const params: HoverParams = { + textDocument: { uri: 'file:///test.m' }, + position: Position.create(1, 5) // over 'plot' + } + const res = await hoverSupportProvider.handleHoverRequest(params, documentManager) + + assert.equal(res, null, 'Result should be null when MATLAB engine is not connected') + }) + + it('should return null when MVM is not ready', async () => { + sinon.stub(matlabLifecycleManager, 'isMatlabConnected').returns(true) + mockMvm.isReady.returns(false) + + const params: HoverParams = { + textDocument: { uri: 'file:///test.m' }, + position: Position.create(1, 5) // over 'plot' + } + const res = await hoverSupportProvider.handleHoverRequest(params, documentManager) + + assert.equal(res, null, 'Result should be null when MVM is not ready') + }) + + it('should query MATLAB MVM when connected and return formatted markdown', async () => { + sinon.stub(matlabLifecycleManager, 'isMatlabConnected').returns(true) + mockMvm.isReady.returns(true) + mockMvm.feval.resolves({ + result: ['plot - 2-D line plot\n\nSyntax\nplot(X,Y)'] + }) + + const params: HoverParams = { + textDocument: { uri: 'file:///test.m' }, + position: Position.create(1, 5) + } + const res = await hoverSupportProvider.handleHoverRequest(params, documentManager) + + assert.notEqual(res, null) + const contents = res?.contents as { kind: string, value: string } + assert.ok(contents.value.includes('`plot`'), 'Should contain function name') + assert.ok(contents.value.includes('2-D line plot'), 'Should contain description') + }) + + it('should return null when MATLAB MVM returns empty help', async () => { + sinon.stub(matlabLifecycleManager, 'isMatlabConnected').returns(true) + mockMvm.isReady.returns(true) + mockMvm.feval.resolves({ + result: [''] + }) + + const params: HoverParams = { + textDocument: { uri: 'file:///test.m' }, + position: Position.create(1, 5) + } + const res = await hoverSupportProvider.handleHoverRequest(params, documentManager) + + assert.equal(res, null, 'Result should be null for empty help') + }) + + it('should return null gracefully when feval throws an error', async () => { + sinon.stub(matlabLifecycleManager, 'isMatlabConnected').returns(true) + mockMvm.isReady.returns(true) + mockMvm.feval.rejects(new Error('MVM error')) + + const params: HoverParams = { + textDocument: { uri: 'file:///test.m' }, + position: Position.create(1, 5) + } + const res = await hoverSupportProvider.handleHoverRequest(params, documentManager) + + assert.equal(res, null, 'Result should be null when MVM throws') + }) + }) +})