diff --git a/matlab/+matlabls/+internal/computeCodeData.m b/matlab/+matlabls/+internal/computeCodeData.m index e52d6a9..c599d9e 100644 --- a/matlab/+matlabls/+internal/computeCodeData.m +++ b/matlab/+matlabls/+internal/computeCodeData.m @@ -247,7 +247,7 @@ topLevelFunctionNode = topLevelFunctionNode.trueparent; end [declLineEnd, declCharEnd] = topLevelFunctionNode.pos2lc(topLevelFunctionNode.righttreepos); - functionInfo.declaration = createRange(functionInfo.range.lineStart, functionInfo.range.charStart, declLineEnd, declCharEnd); + functionInfo.declaration = createRange(functionInfo.range.lineStart, functionInfo.range.charStart, declLineEnd, declCharEnd + 1); end if ~isClassDef @@ -391,14 +391,15 @@ end function variableInfo = addVariableInfoImpl (vars, ranges, variableInfo, field) - info = cell(1, numel(vars)); - for k = 1:numel(vars) - name = vars{k}; - if isempty(name) - info{k} = {}; - continue - end - info{k} = { name, ranges(k) }; + % Remove any empty entries - these could be caused by function input + % arguments being omitted using ~ + nonemptyIndices = ~cellfun(@isempty, vars); + validVars = vars(nonemptyIndices); + validRanges = ranges(nonemptyIndices); + + info = cell(1, numel(validVars)); + for k = 1:numel(validVars) + info{k} = { validVars{k}, validRanges(k) }; end if ~isfield(variableInfo, field) variableInfo.(field) = info; diff --git a/src/indexing/FileInfoIndex.ts b/src/indexing/FileInfoIndex.ts index 2fb05a4..503413d 100644 --- a/src/indexing/FileInfoIndex.ts +++ b/src/indexing/FileInfoIndex.ts @@ -1,4 +1,5 @@ -import { Range } from 'vscode-languageserver' +import { Position, Range } from 'vscode-languageserver' +import { isPositionGreaterThan, isPositionLessThanOrEqualTo } from '../utils/PositionUtils' /** * Defines the structure of the raw data retrieved from MATLAB. @@ -33,7 +34,7 @@ interface CodeDataFunctionInfo { range: CodeDataRange parentClass: string isPublic: boolean - declaration: CodeDataRange + declaration?: CodeDataRange // Will be undefined if function is prototype variableInfo: CodeDataFunctionVariableInfo globals: string[] isPrototype: boolean @@ -219,7 +220,7 @@ export class MatlabClassInfo { /** * Class to contain info about members of a class (e.g. Properties or Enumerations) */ -class MatlabClassMemberInfo { +export class MatlabClassMemberInfo { readonly name: string readonly range: Range readonly parentClass: string @@ -234,11 +235,11 @@ class MatlabClassMemberInfo { /** * Class to contain info about functions */ -class MatlabFunctionInfo { +export class MatlabFunctionInfo { name: string range: Range - declaration: Range + declaration: Range | null isPrototype: boolean @@ -252,7 +253,7 @@ class MatlabFunctionInfo { this.name = rawFunctionInfo.name this.range = convertRange(rawFunctionInfo.range) - this.declaration = convertRange(rawFunctionInfo.declaration) + this.declaration = rawFunctionInfo.declaration != null ? convertRange(rawFunctionInfo.declaration) : null this.isPrototype = rawFunctionInfo.isPrototype @@ -363,6 +364,35 @@ export class MatlabCodeData { return this.classInfo != null } + /** + * Finds the info for the function containing the given position. + * + * @param position A position in the document + * @returns The info for the function containing the position, or null if no function contains that position. + */ + findContainingFunction (position: Position): MatlabFunctionInfo | null { + let containingFunction: MatlabFunctionInfo | null = null + + for (const functionInfo of this.functions.values()) { + const start = functionInfo.range.start + const end = functionInfo.range.end + + // Check if position is within range + if (isPositionLessThanOrEqualTo(start, position) && isPositionGreaterThan(end, position)) { + if (containingFunction == null) { + containingFunction = functionInfo + } else { + // Prefer a narrower function if we already have a match (e.g. nested functions) + if (isPositionGreaterThan(start, containingFunction.range.start)) { + containingFunction = functionInfo + } + } + } + } + + return containingFunction + } + /** * Parses information about the file's functions. * diff --git a/src/providers/navigation/NavigationSupportProvider.ts b/src/providers/navigation/NavigationSupportProvider.ts new file mode 100644 index 0000000..89d324e --- /dev/null +++ b/src/providers/navigation/NavigationSupportProvider.ts @@ -0,0 +1,517 @@ +import { DefinitionParams, Location, Position, Range, ReferenceParams, TextDocuments } from 'vscode-languageserver' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { URI } from 'vscode-uri' +import * as fs from 'fs/promises' +import FileInfoIndex, { FunctionVisibility, MatlabClassMemberInfo, MatlabCodeData, MatlabFunctionInfo } from '../../indexing/FileInfoIndex' +import Indexer from '../../indexing/Indexer' +import { MatlabConnection } from '../../lifecycle/MatlabCommunicationManager' +import MatlabLifecycleManager from '../../lifecycle/MatlabLifecycleManager' +import { getTextOnLine } from '../../utils/TextDocumentUtils' +import PathResolver from './PathResolver' +import { connection } from '../../server' + +/** + * Represents a code expression, either a single identifier or a dotted expression. + * For example, "plot" or "pkg.Class.func". + */ +class Expression { + constructor (public components: string[], public selectedComponent: number) {} + + /** + * The full, dotted expression + */ + get fullExpression (): string { + return this.components.join('.') + } + + /** + * The dotted expression up to and including the selected component + */ + get targetExpression (): string { + return this.components.slice(0, this.selectedComponent + 1).join('.') + } + + /** + * Only the selected component of the expression + */ + get unqualifiedTarget (): string { + return this.components[this.selectedComponent] + } + + /** + * The first component of the expression + */ + get first (): string { + return this.components[0] + } + + /** + * The last component of the expression + */ + get last (): string { + return this.components[this.components.length - 1] + } +} + +export enum RequestType { + Definition, + References +} + +/** + * Handles requests for navigation-related features. + * Currently, this handles Go-to-Definition and Go-to-References. + */ +class NavigationSupportProvider { + private readonly DOTTED_IDENTIFIER_REGEX = /[\w.]+/ + + /** + * Handles requests for definitions or references. + * + * @param params Parameters for the definition or references request + * @param documentManager The text document manager + * @param requestType The type of request (definition or references) + * @returns An array of locations + */ + async handleDefOrRefRequest (params: DefinitionParams | ReferenceParams, documentManager: TextDocuments, requestType: RequestType): Promise { + const matlabConnection = await MatlabLifecycleManager.getOrCreateMatlabConnection(connection) + if (matlabConnection == null) { + return [] + } + + const uri = params.textDocument.uri + const textDocument = documentManager.get(uri) + + if (textDocument == null) { + return [] + } + + // Find ID for which to find the definition or references + const expression = this.getTarget(textDocument, params.position) + + if (expression == null) { + // No target found + return [] + } + + if (requestType === RequestType.Definition) { + return await this.findDefinition(uri, params.position, expression, matlabConnection) + } else { + return this.findReferences(uri, params.position, expression) + } + } + + /** + * Gets the definition/references request target expression. + * + * @param textDocument The text document + * @param position The position in the document + * @returns The expression at the given position, or null if no expression is found + */ + private getTarget (textDocument: TextDocument, position: Position): Expression | null { + const idAtPosition = this.getIdentifierAtPosition(textDocument, position) + + if (idAtPosition.identifier === '') { + return null + } + + const idComponents = idAtPosition.identifier.split('.') + + // Determine what component was targeted + let length = 0 + let i = 0 + while (i < idComponents.length && length <= position.character - idAtPosition.start) { + length += idComponents[i].length + 1 // +1 for '.' + i++ + } + + return new Expression(idComponents, i - 1) // Compensate for extra increment in loop + } + + /** + * Determines the identifier (or dotted expression) at the given position in the document. + * + * @param textDocument The text document + * @param position The position in the document + * @returns An object containing the string identifier at the position, as well as the column number at which the identifier starts. + */ + private getIdentifierAtPosition (textDocument: TextDocument, position: Position): { identifier: string, start: number } { + let lineText = getTextOnLine(textDocument, position.line) + + const result = { + identifier: '', + start: -1 + } + + let matchResults = lineText.match(this.DOTTED_IDENTIFIER_REGEX) + let offset = 0 + + while (matchResults != null) { + if (matchResults.index == null || matchResults.index > position.character) { + // Already passed the cursor - no match found + break + } + + const startChar = offset + matchResults.index + if (startChar + matchResults[0].length >= position.character) { + // Found overlapping identifier + result.identifier = matchResults[0] + result.start = startChar + break + } + + // Match found too early in line - check for following matches + lineText = lineText.substring(matchResults.index + matchResults[0].length) + offset = startChar + matchResults[0].length + + matchResults = lineText.match(this.DOTTED_IDENTIFIER_REGEX) + } + + return result + } + + /** + * Finds the definition(s) of an expression. + * + * @param uri The URI of the document containing the expression + * @param position The position of the expression + * @param expression The expression for which we are looking for the definition + * @param matlabConnection The connection to MATLAB + * @returns The definition location(s) + */ + private async findDefinition (uri: string, position: Position, expression: Expression, matlabConnection: MatlabConnection): Promise { + // Get code data for current file + const codeData = FileInfoIndex.codeDataCache.get(uri) + + if (codeData == null) { + // File not indexed - unable to look for definition + return [] + } + + // First check within the current file's code data + const definitionInCodeData = this.findDefinitionInCodeData(uri, position, expression, codeData) + + if (definitionInCodeData != null) { + return definitionInCodeData + } + + // Check the MATLAB path + const definitionOnPath = await this.findDefinitionOnPath(uri, position, expression, matlabConnection) + + if (definitionOnPath != null) { + return definitionOnPath + } + + // If not on path, may be in user's workspace + return this.findDefinitionInWorkspace(uri, expression) + } + + /** + * Searches the given code data for the definition(s) of the given expression + * + * @param uri The URI corresponding to the provided code data + * @param position The position of the expression + * @param expression The expression for which we are looking for the definition + * @param codeData The code data which is being searched + * @returns The definition location(s), or null if no definition was found + */ + private findDefinitionInCodeData (uri: string, position: Position, expression: Expression, codeData: MatlabCodeData): Location[] | null { + // If first part of expression targeted - look for a local variable + if (expression.selectedComponent === 0) { + const containingFunction = codeData.findContainingFunction(position) + if (containingFunction != null) { + const varDefs = this.getVariableDefsOrRefs(containingFunction, expression.unqualifiedTarget, uri, RequestType.Definition) + if (varDefs != null) { + return varDefs + } + } + } + + // Check for functions in file + let functionDeclaration = this.getFunctionDeclaration(codeData, expression.fullExpression) + if (functionDeclaration != null) { + return [this.getLocationForFunctionDeclaration(functionDeclaration)] + } + + // Check for definitions within classes + if (codeData.isClassDef && codeData.classInfo != null) { + // Look for methods/properties within class definitions (e.g. obj.foo) + functionDeclaration = this.getFunctionDeclaration(codeData, expression.last) + if (functionDeclaration != null) { + return [this.getLocationForFunctionDeclaration(functionDeclaration)] + } + + // Look for possible properties + if (expression.selectedComponent === 1) { + const propertyDeclaration = this.getPropertyDeclaration(codeData, expression.last) + if (propertyDeclaration != null) { + const propertyRange = Range.create(propertyDeclaration.range.start, propertyDeclaration.range.end) + const uri = codeData.classInfo.uri + if (uri != null) { + return [Location.create(uri, propertyRange)] + } + } + } + } + + return null + } + + /** + * Gets the location of the given function's declaration. If the function does not have + * a definite declaration, provides a location at the beginning of the file. For example, + * this may be the case for built-in functions like 'plot'. + * + * @param functionInfo Info about the function + * @returns The location of the function declaration + */ + private getLocationForFunctionDeclaration (functionInfo: MatlabFunctionInfo): Location { + const range = functionInfo.declaration ?? Range.create(0, 0, 0, 0) + return Location.create(functionInfo.uri, range) + } + + /** + * Searches the MATLAB path for the definition of the given expression + * + * @param uri The URI of the file containing the expression + * @param position The position of the expression + * @param expression The expression for which we are looking for the definition + * @param matlabConnection The connection to MATLAB + * @returns The definition location(s), or null if no definition was found + */ + private async findDefinitionOnPath (uri: string, position: Position, expression: Expression, matlabConnection: MatlabConnection): Promise { + const resolvedPath = await PathResolver.resolvePaths([expression.targetExpression], uri, matlabConnection) + const resolvedUri = resolvedPath[0].uri + + if (resolvedUri === '') { + // Not found + return null + } + + // Ensure URI is not a directory. This can occur with some packages. + const fileStats = await fs.stat(URI.parse(resolvedUri).fsPath) + if (fileStats.isDirectory()) { + return null + } + + if (!FileInfoIndex.codeDataCache.has(resolvedUri)) { + // Index target file, if necessary + await Indexer.indexFile(resolvedUri) + } + + const codeData = FileInfoIndex.codeDataCache.get(resolvedUri) + + // Find definition location within determined file + if (codeData != null) { + const definition = this.findDefinitionInCodeData(resolvedUri, position, expression, codeData) + + if (definition != null) { + return definition + } + } + + // If a definition location cannot be identified, default to the beginning of the file. + // This could be the case for builtin functions which don't actually have a definition in a .m file (e.g. plot). + return [Location.create(resolvedUri, Range.create(0, 0, 0, 0))] + } + + /** + * Searches the (indexed) workspace for the definition of the given expression. These files may not be on the MATLAB path. + * + * @param uri The URI of the file containing the expression + * @param expression The expression for which we are looking for the definition + * @returns The definition location(s). Returns an empty array if no definitions found. + */ + private findDefinitionInWorkspace (uri: string, expression: Expression): Location[] { + const expressionToMatch = expression.fullExpression + + for (const [fileUri, fileCodeData] of FileInfoIndex.codeDataCache) { + if (uri === fileUri) continue // Already looked in the current file + + let match = fileCodeData.packageName === '' ? '' : fileCodeData.packageName + '.' + + if (fileCodeData.classInfo != null) { + const classUri = fileCodeData.classInfo.uri + if (classUri == null) continue + + // Check class name + match += fileCodeData.classInfo.name + if (expressionToMatch === match) { + const range = fileCodeData.classInfo.declaration ?? Range.create(0, 0, 0, 0) + return [Location.create(classUri, range)] + } + + // Check properties + const matchedProperty = this.findMatchingClassMember(expressionToMatch, match, classUri, fileCodeData.classInfo.properties) + if (matchedProperty != null) { + return matchedProperty + } + + // Check enums + const matchedEnum = this.findMatchingClassMember(expressionToMatch, match, classUri, fileCodeData.classInfo.enumerations) + if (matchedEnum != null) { + return matchedEnum + } + } + + // Check functions + for (const [funcName, funcData] of fileCodeData.functions) { + const funcMatch = (match === '') ? funcName : match + '.' + funcName + if (expressionToMatch === funcMatch) { + const range = funcData.declaration ?? Range.create(0, 0, 0, 0) + return [Location.create(funcData.uri, range)] + } + } + } + + return [] + } + + /** + * Finds the class member (property or enumeration) in the given map which matches to given expression. + * + * @param expressionToMatch The expression being compared against + * @param matchPrefix The prefix which should be attached to the class members before comparison + * @param classUri The URI for the current class + * @param classMemberMap The map of class members + * @returns An array containing the location of the matched class member, or null if one was not found + */ + private findMatchingClassMember (expressionToMatch: string, matchPrefix: string, classUri: string, classMemberMap: Map): Location[] | null { + for (const [memberName, memberData] of classMemberMap) { + const match = matchPrefix + '.' + memberName + if (expressionToMatch === match) { + return [Location.create(classUri, memberData.range)] + } + } + + return null + } + + /** + * Finds references of an expression. + * + * @param uri The URI of the document containing the expression + * @param position The position of the expression + * @param expression The expression for which we are looking for references + * @returns The references' locations + */ + private findReferences (uri: string, position: Position, expression: Expression): Location[] { + // Get code data for current file + const codeData = FileInfoIndex.codeDataCache.get(uri) + + if (codeData == null) { + // File not indexed - unable to look for references + return [] + } + + const referencesInCodeData = this.findReferencesInCodeData(uri, position, expression, codeData) + + if (referencesInCodeData != null) { + return referencesInCodeData + } + + return [] + } + + /** + * Searches for references, starting within the given code data. If the expression does not correspond to a local variable, + * the search is broadened to other indexed files in the user's workspace. + * + * @param uri The URI corresponding to the provided code data + * @param position The position of the expression + * @param expression The expression for which we are looking for references + * @param codeData The code data which is being searched + * @returns The references' locations, or null if no reference was found + */ + private findReferencesInCodeData (uri: string, position: Position, expression: Expression, codeData: MatlabCodeData): Location[] | null { + // If first part of expression is targeted - look for a local variable + if (expression.selectedComponent === 0) { + const containingFunction = codeData.findContainingFunction(position) + if (containingFunction != null) { + const varRefs = this.getVariableDefsOrRefs(containingFunction, expression.unqualifiedTarget, uri, RequestType.References) + if (varRefs != null) { + return varRefs + } + } + } + + // Check for functions in file + const functionDeclaration = this.getFunctionDeclaration(codeData, expression.fullExpression) + if (functionDeclaration != null && functionDeclaration.visibility === FunctionVisibility.Private) { + // Found a local function. Look through this file's references + return codeData.references.get(functionDeclaration.name)?.map(range => Location.create(uri, range)) ?? [] + } + + // Check other files + const refs: Location[] = [] + for (const [, fileCodeData] of FileInfoIndex.codeDataCache) { + if (fileCodeData.functions.get(expression.fullExpression)?.visibility === FunctionVisibility.Private) { + // Skip files with other local functions + continue + } + const varRefs = fileCodeData.references.get(expression.fullExpression) + if (varRefs != null) { + varRefs.forEach(range => refs.push(Location.create(fileCodeData.uri, range))) + } + } + return refs + } + + /** + * Gets the definition/references of a variable within a function. + * + * @param containingFunction Info about a function + * @param variableName The variable name for which we are looking for definitions or references + * @param uri The URI of the file + * @param requestType The type of request (definition or references) + * @returns The locations of the definition(s) or references of the given variable name within the given function info, or null if none can be found + */ + private getVariableDefsOrRefs (containingFunction: MatlabFunctionInfo, variableName: string, uri: string, requestType: RequestType): Location[] | null { + const variableInfo = containingFunction.variableInfo.get(variableName) + + if (variableInfo == null) { + return null + } + + const varInfoRanges = requestType === RequestType.Definition ? variableInfo.definitions : variableInfo.references + + // TODO: How do we want to handle global variables? + return varInfoRanges.map(range => { + return Location.create(uri, range) + }) + } + + /** + * Searches for info about a function within the given code data. + * + * @param codeData The code data being searched + * @param functionName The name of the function being searched for + * @returns The info about the desired function, or null if it cannot be found + */ + private getFunctionDeclaration (codeData: MatlabCodeData, functionName: string): MatlabFunctionInfo | null { + let functionDecl = codeData.functions.get(functionName) + if (codeData.isClassDef && (functionDecl == null || functionDecl.isPrototype)) { + // For classes, look in the methods list to better handle @folders + functionDecl = codeData.classInfo?.methods.get(functionName) ?? functionDecl + } + + return functionDecl ?? null + } + + /** + * Searches for info about a property within the given code data. + * + * @param codeData The code data being searched + * @param propertyName The name of the property being searched for + * @returns The info about the desired property, or null if it cannot be found + */ + private getPropertyDeclaration (codeData: MatlabCodeData, propertyName: string): MatlabClassMemberInfo | null { + if (codeData.classInfo == null) { + return null + } + + return codeData.classInfo.properties.get(propertyName) ?? null + } +} + +export default new NavigationSupportProvider() diff --git a/src/server.ts b/src/server.ts index 8d12683..86d7653 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import CompletionProvider from './providers/completion/CompletionSupportProvider import FormatSupportProvider from './providers/formatting/FormatSupportProvider' import LintingSupportProvider from './providers/linting/LintingSupportProvider' import ExecuteCommandProvider, { MatlabLSCommands } from './providers/lspCommands/ExecuteCommandProvider' +import NavigationSupportProvider, { RequestType } from './providers/navigation/NavigationSupportProvider' import { getCliArgs } from './utils/CliUtils' // Create a connection for the server @@ -62,10 +63,12 @@ connection.onInitialize((params: InitializeParams) => { '\\' // File path ] }, + definitionProvider: true, documentFormattingProvider: true, executeCommandProvider: { commands: Object.values(MatlabLSCommands) }, + referencesProvider: true, signatureHelpProvider: { triggerCharacters: ['(', ','] } @@ -148,5 +151,14 @@ connection.onCodeAction(params => { return LintingSupportProvider.handleCodeActionRequest(params) }) +/** -------------------- NAVIGATION SUPPORT -------------------- **/ +connection.onDefinition(async params => { + return await NavigationSupportProvider.handleDefOrRefRequest(params, documentManager, RequestType.Definition) +}) + +connection.onReferences(async params => { + return await NavigationSupportProvider.handleDefOrRefRequest(params, documentManager, RequestType.References) +}) + // Start listening to open/change/close text document events documentManager.listen(connection) diff --git a/src/utils/PositionUtils.ts b/src/utils/PositionUtils.ts new file mode 100644 index 0000000..e69dd78 --- /dev/null +++ b/src/utils/PositionUtils.ts @@ -0,0 +1,89 @@ +import { Position } from 'vscode-languageserver' + +/** + * Determines whether a position is less than another position. + * + * @param a The first position + * @param b The second position + * @returns true if position A is before position B + */ +export function isPositionLessThan (a: Position, b: Position): boolean { + return checkLessThan(a, b) +} + +/** + * Determines whether a position is less than or equal to another position. + * + * @param a The first position + * @param b The second position + * @returns true if position A is before position B, or the same position + */ +export function isPositionLessThanOrEqualTo (a: Position, b: Position): boolean { + return checkLessThan(a, b, true) +} + +/** + * Determines whether a position is greater than another position. + * + * @param a The first position + * @param b The second position + * @returns True if position A is after position B + */ +export function isPositionGreaterThan (a: Position, b: Position): boolean { + return checkGreaterThan(a, b) +} + +/** + * Determines whether a position is greater than or equal to another position. + * + * @param a The first position + * @param b The second position + * @returns True if position A is after position B, or the same position + */ +export function isPositionGreaterThanOrEqualTo (a: Position, b: Position): boolean { + return checkGreaterThan(a, b, true) +} + +/** + * Performs a "less than (or equal to)" check on two positions. + * + * @param a The first position + * @param b The second position + * @param orEqual Whether or not an "or equal to" check should be performed + * @returns true if position A is before position B + */ +function checkLessThan (a: Position, b: Position, orEqual = false): boolean { + if (a.line < b.line) { + return true + } + + if (a.line === b.line) { + return orEqual + ? a.character <= b.character + : a.character < b.character + } + + return false +} + +/** + * Performs a "greater than (or equal to)" check on two positions. + * + * @param a The first position + * @param b The second position + * @param orEqual Whether or not an "or equal to" check should be performed + * @returns true if position A is after position B + */ +function checkGreaterThan (a: Position, b: Position, orEqual = false): boolean { + if (a.line > b.line) { + return true + } + + if (a.line === b.line) { + return orEqual + ? a.character >= b.character + : a.character > b.character + } + + return false +}