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
182 changes: 182 additions & 0 deletions src/indexing/DocumentationIndexer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright 2026 The MathWorks, Inc.

import { spawn } from 'child_process'
import { EventEmitter } from 'events'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { WorkDoneProgressServerReporter } from 'vscode-languageserver'
import ClientConnection from '../ClientConnection'
import ClientCapabilitiesManager from '../lifecycle/ClientCapabilitiesManager'
import ConfigurationManager, { DocumentationIndexTiming } from '../lifecycle/ConfigurationManager'
import Logger from '../logging/Logger'

export class DocumentationIndexer {
private isIndexing = false
public readonly eventEmitter = new EventEmitter()

/**
* Returns whether documentation indexing is currently in progress.
*/
public isIndexingInProgress (): boolean {
return this.isIndexing
}

/**
* Gets the path to the local SQLite documentation database.
*/
public getDatabasePath (): string {
return path.join(os.homedir(), '.cache', 'matlabls', 'matlab_docs.db')
}

/**
* Checks if the documentation database exists and is populated.
*/
public isDatabaseReady (targetPath?: string): boolean {
const dbPath = targetPath ?? this.getDatabasePath()
try {
if (fs.existsSync(dbPath)) {
const stat = fs.statSync(dbPath)
return stat.size > 0
}
} catch {
return false
}
return false
}

/**
* Resolves the location of the indexer script.
*/
public getIndexerScriptPath (): string | null {
const candidates = [
path.resolve(__dirname, '..', '..', 'tools', 'indexer', 'index_docs.js'),
path.resolve(__dirname, '..', 'tools', 'indexer', 'index_docs.js'),
path.resolve(__dirname, 'tools', 'indexer', 'index_docs.js'),
path.resolve(process.cwd(), 'tools', 'indexer', 'index_docs.js')
]

for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate
}
}
return null
}

/**
* Starts background indexing of MATLAB documentation if required by settings or forced.
*
* @param force - If true, bypasses configuration and existing database checks.
* @returns Promise resolving to true if indexing was spawned, false otherwise.
*/
public async startIndexing (force = false): Promise<boolean> {
if (this.isIndexingInProgress()) {
Logger.log('MATLAB documentation indexing is already running.')
return false
}

const configuration = await ConfigurationManager.getConfiguration()

if (!force) {
if (configuration.indexDocumentation === DocumentationIndexTiming.Never) {
Logger.log('Documentation indexing skipped (setting: never).')
return false
}

if (configuration.indexDocumentation === DocumentationIndexTiming.OnMissing && this.isDatabaseReady()) {
Logger.log('Documentation database already exists. Skipping indexing.')
return false
}
}

const scriptPath = this.getIndexerScriptPath()
if (scriptPath == null) {
Logger.warn('MATLAB documentation indexer script (index_docs.js) was not found.')
return false
}

const env = { ...process.env }
if (configuration.installPath !== '' && configuration.installPath.trim() !== '') {
env.MATLAB_INSTALL_PATH = configuration.installPath.trim()
}

Logger.log(`Spawning background documentation indexer: ${scriptPath}`)
this.isIndexing = true

let progressReporter: WorkDoneProgressServerReporter | null = null
if (ClientCapabilitiesManager.hasWorkDoneProgress()) {
try {
const connection = ClientConnection.getConnection()
const progressPromise = connection.window.createWorkDoneProgress()
const timeoutPromise = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000))
progressReporter = await Promise.race([progressPromise, timeoutPromise])
progressReporter?.begin('Indexing MATLAB Documentation', 0, 'Initializing indexer...')
} catch (err) {
Logger.log(`Failed to create workDoneProgress reporter: ${String(err)}`)
progressReporter = null
}
}

const child = spawn(process.execPath, [scriptPath, '--quiet'], {
env,
stdio: ['ignore', 'pipe', 'pipe']
})

let stdoutBuffer = ''
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBuffer += chunk.toString()
const lines = stdoutBuffer.split('\n')
stdoutBuffer = lines.pop() ?? ''

for (const rawLine of lines) {
const line = rawLine.trim()
if (line.length === 0) continue

if (line.startsWith('LSP_PROGRESS:')) {
const parts = line.split(':')
const current = parseInt(parts[1], 10)
const total = parseInt(parts[2], 10)
const pct = parseInt(parts[3], 10)
if (!isNaN(pct)) {
progressReporter?.report(pct, `${current}/${total} functions (${pct}%)`)
}
} else if (line.startsWith('LSP_STAGE:')) {
const stage = line.substring('LSP_STAGE:'.length)
progressReporter?.report(95, stage)
} else {
Logger.log(`[Indexer] ${line}`)
}
}
})

child.stderr?.on('data', (chunk: Buffer) => {
const msg = chunk.toString().trim()
if (msg.length > 0) {
Logger.warn(`[Indexer] ${msg}`)
}
})

child.on('close', (code: number | null) => {
this.isIndexing = false
if (code === 0) {
Logger.log('MATLAB documentation indexing completed successfully.')
progressReporter?.done()
this.eventEmitter.emit('indexed')
} else {
Logger.warn(`MATLAB documentation indexer exited with code ${code ?? 'unknown'}`)
progressReporter?.done()
}
})

child.on('error', (err: Error) => {
this.isIndexing = false
progressReporter?.done()
Logger.error(`Failed to execute MATLAB documentation indexer: ${err.message}`)
})

return true
}
}

export default new DocumentationIndexer()
5 changes: 5 additions & 0 deletions src/lifecycle/ClientCapabilitiesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ class ClientCapabilitiesManager {
return this.getCapabilities()?.workspace?.semanticTokens?.refreshSupport === true
}

/** Whether the client supports workDoneProgress notifications in the status line / UI. */
hasWorkDoneProgress (): boolean {
return this.getCapabilities()?.window?.workDoneProgress === true
}

/** Private getter which allows for logging a warning if not yet initialized. */
private getCapabilities (): ClientCapabilities | null {
if (this.clientCapabilities == null) {
Expand Down
31 changes: 30 additions & 1 deletion src/lifecycle/ConfigurationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export enum Argument {
MatlabConnectionTiming = 'matlabConnectionTiming',

ShouldIndexWorkspace = 'indexWorkspace',
IndexDocumentation = 'indexDocumentation',

// Advanced arguments
MatlabUrl = 'matlabUrl',
Expand All @@ -26,6 +27,22 @@ export enum ConnectionTiming {
Never = 'never'
}

export enum DocumentationIndexTiming {
OnMissing = 'onMissing',
Never = 'never',
Always = 'always'
}

export function normalizeDocumentationIndexTiming (value: unknown): DocumentationIndexTiming {
if (value === false || value === 'never' || value === 'Never') {
return DocumentationIndexTiming.Never
}
if (value === 'always' || value === 'Always') {
return DocumentationIndexTiming.Always
}
return DocumentationIndexTiming.OnMissing
}

interface CliArguments {
[Argument.MatlabLaunchCommandArguments]: string
[Argument.MatlabUrl]: string
Expand All @@ -36,6 +53,7 @@ export interface Settings {
installPath: string
matlabConnectionTiming: ConnectionTiming
indexWorkspace: boolean
indexDocumentation: DocumentationIndexTiming
telemetry: boolean
maxFileSizeForAnalysis: number
signIn: boolean
Expand All @@ -48,6 +66,7 @@ const DEFAULT_SETTINGS: Settings = {
installPath: '',
matlabConnectionTiming: ConnectionTiming.OnStart,
indexWorkspace: false,
indexDocumentation: DocumentationIndexTiming.OnMissing,
telemetry: true,
maxFileSizeForAnalysis: 0,
signIn: false,
Expand Down Expand Up @@ -76,6 +95,9 @@ export class ConfigurationManager {
installPath: cliArgs[Argument.MatlabInstallationPath] ?? DEFAULT_SETTINGS.installPath,
matlabConnectionTiming: cliArgs[Argument.MatlabConnectionTiming] as ConnectionTiming ?? DEFAULT_SETTINGS.matlabConnectionTiming,
indexWorkspace: cliArgs[Argument.ShouldIndexWorkspace] ?? DEFAULT_SETTINGS.indexWorkspace,
indexDocumentation: cliArgs[Argument.IndexDocumentation] != null
? normalizeDocumentationIndexTiming(cliArgs[Argument.IndexDocumentation])
: DEFAULT_SETTINGS.indexDocumentation,
telemetry: DEFAULT_SETTINGS.telemetry,
maxFileSizeForAnalysis: DEFAULT_SETTINGS.maxFileSizeForAnalysis,
signIn: DEFAULT_SETTINGS.signIn,
Expand Down Expand Up @@ -147,6 +169,9 @@ export class ConfigurationManager {
private async fetchConfiguration (): Promise<void> {
const connection = ClientConnection.getConnection()
const configuration = await connection.workspace.getConfiguration('MATLAB') as Settings
if (configuration?.indexDocumentation !== undefined) {
configuration.indexDocumentation = normalizeDocumentationIndexTiming(configuration.indexDocumentation)
}
Object.assign(this.settings, configuration)
this.hasFetchedInitialConfiguration = true
}
Expand Down Expand Up @@ -176,7 +201,11 @@ export class ConfigurationManager {
if (this.hasConfigurationCapability) {
await this.fetchConfiguration()
} else {
this.settings = params.settings?.MATLAB ?? this.settings
const rawSettings = params.settings?.MATLAB ?? this.settings
if (rawSettings?.indexDocumentation !== undefined) {
rawSettings.indexDocumentation = normalizeDocumentationIndexTiming(rawSettings.indexDocumentation)
}
this.settings = rawSettings
}

if (shouldCompare) {
Expand Down
Loading