Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions matlab/+matlabls/+handlers/+hover/getHover.m
Original file line number Diff line number Diff line change
@@ -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
145 changes: 145 additions & 0 deletions src/providers/hover/HoverMarkdownUtils.ts
Original file line number Diff line number Diff line change
@@ -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()
}
87 changes: 87 additions & 0 deletions src/providers/hover/HoverSupportProvider.ts
Original file line number Diff line number Diff line change
@@ -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<TextDocument>): Promise<Hover | null> {
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
12 changes: 10 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -75,6 +76,7 @@ export async function startServer (): Promise<void> {
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)
Expand All @@ -100,7 +102,7 @@ export async function startServer (): Promise<void> {
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()
Expand Down Expand Up @@ -151,6 +153,7 @@ export async function startServer (): Promise<void> {
prepareProvider: true
},
documentHighlightProvider: true,
hoverProvider: true,
semanticTokensProvider: {
legend: {
tokenTypes: SEMANTIC_TOKEN_TYPES,
Expand Down Expand Up @@ -303,7 +306,7 @@ export async function startServer (): Promise<void> {
reportFileOpened(params.document)
void lintingSupportProvider.lintDocument(params.document)
void documentIndexer.indexDocument(params.document)

void navigationSupportProvider.handleDocumentSymbol(params.document.uri, documentManager, RequestType.DocumentSymbol)
})

Expand Down Expand Up @@ -405,6 +408,11 @@ export async function startServer (): Promise<void> {
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 -------------------- **/
Expand Down
70 changes: 70 additions & 0 deletions tests/providers/hover/HoverMarkdownUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading