Skip to content
Draft
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
8 changes: 7 additions & 1 deletion src/Program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1027,11 +1027,17 @@ export class Program {
let file = this.getFile(srcPath);
let result: Hover[];
if (file) {
//find the scopes for this file
let scopes = this.getScopesForFile(file);

//if there are no scopes, include the global scope so we at least get the built-in functions
scopes = scopes.length > 0 ? scopes : [this.globalScope];

const event = {
program: this,
file: file,
position: position,
scopes: this.getScopesForFile(file),
scopes: scopes,
hovers: []
} as ProvideHoverEvent;
this.plugins.emit('beforeProvideHover', event);
Expand Down
57 changes: 57 additions & 0 deletions src/bscPlugin/hover/HoverProcessor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,5 +219,62 @@ describe('HoverProcessor', () => {
expect(hover?.range).to.eql(util.createRange(2, 40, 2, 50));
expect(hover?.contents).to.eql(fence('const name.sp.a.c.e.SOME_VALUE = true'));
});

it('finds hover for global function', () => {
const file = program.setFile('source/main.brs', `
sub main()
result = Abs(-5)
end sub
`);
program.validate();

// hover over the `Abs` function call
let hover = program.getHover(file.pathAbsolute, util.createPosition(2, 29))[0];
expect(hover).to.exist;
expect(hover.range).to.eql(util.createRange(2, 29, 2, 32));
expect(hover.contents).to.contain('function Abs(x as float) as float');
expect(hover.contents).to.contain('Returns the absolute value of the argument.');
});

it('finds hover for global function when file has no scopes', () => {
// Create a program with no manifest and no scopes to ensure global scope fallback
const testProgram = new Program({ rootDir: rootDir });

const file = testProgram.setFile('standalone/main.brs', `
sub main()
result = Abs(-5)
end sub
`);

// Don't validate - this simulates a file that might not be in any scopes

// hover over the `Abs` function call
let hover = testProgram.getHover(file.pathAbsolute, util.createPosition(2, 29))[0];

testProgram.dispose();

// After the fix, this should now work even when file has no scopes
expect(hover).to.exist;
expect(hover.range).to.eql(util.createRange(2, 29, 2, 32));
expect(hover.contents).to.contain('function Abs(x as float) as float');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to also see the description of the function. Looking at globalCallables.ts it shows the description for this function should be something like "Returns the absolute value of the argument.".

Here's what I'm expecting.

'a test function
sub test()
end sub

sub init()
    test() 'hover over this, it includes "a test function"
end sub

But for the global functions, it doesn't. It's not even related to files with zero scopes. I can reproduce this in my source/main.brs file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by modifying HoverProcessor to show shortDescription and documentation from global callables. The hover now includes descriptions like "Returns the absolute value of the argument." for global functions. Updated tests to verify the descriptions are shown.

expect(hover.contents).to.contain('Returns the absolute value of the argument.');
});

it('finds hover for global function with both shortDescription and documentation', () => {
const file = program.setFile('source/main.brs', `
sub main()
result = Atn(-5)
end sub
`);
program.validate();

// hover over the `Atn` function call
let hover = program.getHover(file.pathAbsolute, util.createPosition(2, 29))[0];
expect(hover).to.exist;
expect(hover.range).to.eql(util.createRange(2, 29, 2, 32));
expect(hover.contents).to.contain('function Atn(x as float) as float');
expect(hover.contents).to.contain('Returns the arctangent (in radians) of the argument.');
expect(hover.contents).to.contain('ATN(X)` returns "the angle whose tangent is X"');
});
});
});
48 changes: 21 additions & 27 deletions src/bscPlugin/hover/HoverProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isBrsFile, isFunctionType, isXmlFile } from '../../astUtils/reflection';
import type { BrsFile } from '../../files/BrsFile';
import type { XmlFile } from '../../files/XmlFile';
import type { Hover, ProvideHoverEvent } from '../../interfaces';
import type { Hover, ProvideHoverEvent, Callable } from '../../interfaces';
import type { Token } from '../../lexer/Token';
import { TokenKind } from '../../lexer/TokenKind';
import { BrsTranspileState } from '../../parser/BrsTranspileState';
Expand Down Expand Up @@ -39,6 +39,24 @@ export class HoverProcessor {
return parts.join('\n');
}

private buildCallableContents(callable: Callable, fence: (code: string) => string) {
const parts = [fence(callable.type.toString())];

// Use shortDescription and documentation for all callables (both global and user-defined)
const docs = [];
if (callable.shortDescription) {
docs.push(callable.shortDescription);
}
if (callable.documentation) {
docs.push(callable.documentation);
}
if (docs.length > 0) {
parts.push('***', docs.join('\n\n'));
}

return parts.join('\n');
}

private getBrsFileHover(file: BrsFile): Hover | undefined {
const scope = this.event.scopes[0];
const fence = (code: string) => util.mdFence(code, 'brightscript');
Expand Down Expand Up @@ -115,7 +133,7 @@ export class HoverProcessor {
if (callable) {
return {
range: token.range,
contents: this.buildContentsWithDocs(fence(callable.type.toString()), callable.functionStatement?.func?.functionType)
contents: this.buildCallableContents(callable, fence)
};
}
}
Expand All @@ -125,31 +143,7 @@ export class HoverProcessor {
* Combine all the documentation found before a token (i.e. comment tokens)
*/
private getTokenDocumentation(tokens: Token[], token?: Token) {
const comments = [] as Token[];
if (!token) {
return undefined;
}
const idx = tokens?.indexOf(token);
if (!idx || idx === -1) {
return undefined;
}
for (let i = idx - 1; i >= 0; i--) {
const token = tokens[i];
//skip whitespace and newline chars
if (token.kind === TokenKind.Comment) {
comments.push(token);
} else if (token.kind === TokenKind.Newline || token.kind === TokenKind.Whitespace) {
//skip these tokens
continue;

//any other token means there are no more comments
} else {
break;
}
}
if (comments.length > 0) {
return comments.reverse().map(x => x.text.replace(/^('|rem)/i, '')).join('\n');
}
return util.getTokenDocumentation(tokens, token);
}

private getXmlFileHover(file: XmlFile): Hover | undefined {
Expand Down
8 changes: 6 additions & 2 deletions src/files/BrsFile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1987,7 +1987,9 @@ describe('BrsFile', () => {
).to.equal([
'```brightscript',
'function UCase(s as string) as string',
'```'
'```',
'***',
'Converts the string to all upper case.'
].join('\n'));
});

Expand All @@ -2004,7 +2006,9 @@ describe('BrsFile', () => {
'```brightscript',
//TODO this really shouldn't be returning the global function, but it does...so make sure it doesn't crash right now.
'function Instr(startOrText as dynamic, textOrSubstring as string, substring? as string) as integer',
'```'
'```',
'***',
'Returns the position of the first instances of substring within text, starting at the specified start position.\nReturns 0 if the substring is not found. Unlike the ifString.Instr() method, the first position is 1.'
].join('\n'));
});

Expand Down
6 changes: 5 additions & 1 deletion src/files/BrsFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,9 @@ export class BrsFile {
functionType.addParameter(callableParam.name, callableParam.type, isOptional);
}

// Extract documentation from comment tokens
const documentation = util.getTokenDocumentation(this.parser.tokens, statement.func.functionType);

this.callables.push({
isSub: statement.func.functionType.text.toLowerCase() === 'sub',
name: statement.name.text,
Expand All @@ -626,7 +629,8 @@ export class BrsFile {
type: functionType,
getName: statement.getName.bind(statement),
hasNamespace: !!statement.findAncestor<NamespaceStatement>(isNamespaceStatement),
functionStatement: statement
functionStatement: statement,
shortDescription: documentation
});
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/globalCallables.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ describe('globalCallables', () => {
).to.eql([
'```brightscript',
'function Mid(s as string, p as integer, n? as integer) as string',
'```'
'```',
'***',
'Returns a substring of s with length n and starting at position p.\nn may be omitted, in which case the string starting at p and ending at the end of the string is returned.\nUnlike the ifString.Mid() method, the first character in the string is position 1.'
].join('\n'));
});

Expand Down
31 changes: 31 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1769,6 +1769,37 @@ export class Util {
//just return empty string so log functions don't crash with undefined project numbers
return '';
}

/**
* Combine all the documentation found before a token (i.e. comment tokens)
*/
public getTokenDocumentation(tokens: Token[], token?: Token) {
const comments = [] as Token[];
if (!token) {
return undefined;
}
const idx = tokens?.indexOf(token);
if (!idx || idx === -1) {
return undefined;
}
for (let i = idx - 1; i >= 0; i--) {
const currentToken = tokens[i];
//skip whitespace and newline chars
if (currentToken.kind === TokenKind.Comment) {
comments.push(currentToken);
} else if (currentToken.kind === TokenKind.Newline || currentToken.kind === TokenKind.Whitespace) {
//skip these tokens
continue;

//any other token means there are no more comments
} else {
break;
}
}
if (comments.length > 0) {
return comments.reverse().map(x => x.text.replace(/^('|rem)/i, '')).join('\n');
}
}
}

/**
Expand Down