Skip to content

Commit

Permalink
feat: inlay hint provider (#683)
Browse files Browse the repository at this point in the history
Closes #679

### Summary of Changes

Show inlay hints for
* the type of block lambda results, placeholders, yields,
* the corresponding parameter for positional arguments.
  • Loading branch information
lars-reimann authored Oct 23, 2023
1 parent 275ad5e commit f23fa29
Show file tree
Hide file tree
Showing 4 changed files with 240 additions and 0 deletions.
4 changes: 4 additions & 0 deletions src/language/helpers/nodeProperties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export const isNamedArgument = (node: SdsArgument): boolean => {
return Boolean(node.parameter);
};

export const isPositionalArgument = (node: SdsArgument): boolean => {
return !node.parameter;
};

export const isNamedTypeArgument = (node: SdsTypeArgument): boolean => {
return Boolean(node.typeParameter);
};
Expand Down
46 changes: 46 additions & 0 deletions src/language/lsp/safe-ds-inlay-hint-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { AbstractInlayHintProvider, AstNode, InlayHintAcceptor } from 'langium';
import { SafeDsTypeComputer } from '../typing/safe-ds-type-computer.js';
import { SafeDsNodeMapper } from '../helpers/safe-ds-node-mapper.js';
import { SafeDsServices } from '../safe-ds-module.js';
import { isSdsArgument, isSdsBlockLambdaResult, isSdsPlaceholder, isSdsYield } from '../generated/ast.js';
import { isPositionalArgument } from '../helpers/nodeProperties.js';
import { InlayHintKind } from 'vscode-languageserver';

export class SafeDsInlayHintProvider extends AbstractInlayHintProvider {
private readonly nodeMapper: SafeDsNodeMapper;
private readonly typeComputer: SafeDsTypeComputer;

constructor(services: SafeDsServices) {
super();

this.nodeMapper = services.helpers.NodeMapper;
this.typeComputer = services.types.TypeComputer;
}

override computeInlayHint(node: AstNode, acceptor: InlayHintAcceptor) {
const cstNode = node.$cstNode;
/* c8 ignore start */
if (!cstNode) {
return;
}
/* c8 ignore stop */

if (isSdsArgument(node) && isPositionalArgument(node)) {
const parameter = this.nodeMapper.argumentToParameter(node);
if (parameter) {
acceptor({
position: cstNode.range.start,
label: `${parameter.name} = `,
kind: InlayHintKind.Parameter,
});
}
} else if (isSdsBlockLambdaResult(node) || isSdsPlaceholder(node) || isSdsYield(node)) {
const type = this.typeComputer.computeType(node);
acceptor({
position: cstNode.range.end,
label: `: ${type}`,
kind: InlayHintKind.Type,
});
}
}
}
2 changes: 2 additions & 0 deletions src/language/safe-ds-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { SafeDsNodeKindProvider } from './lsp/safe-ds-node-kind-provider.js';
import { SafeDsDocumentSymbolProvider } from './lsp/safe-ds-document-symbol-provider.js';
import { SafeDsDocumentBuilder } from './workspace/safe-ds-document-builder.js';
import { SafeDsEnums } from './builtins/safe-ds-enums.js';
import { SafeDsInlayHintProvider } from './lsp/safe-ds-inlay-hint-provider.js';

/**
* Declaration of custom services - add your own service classes here.
Expand Down Expand Up @@ -83,6 +84,7 @@ export const SafeDsModule: Module<SafeDsServices, PartialLangiumServices & SafeD
lsp: {
DocumentSymbolProvider: (services) => new SafeDsDocumentSymbolProvider(services),
Formatter: () => new SafeDsFormatter(),
InlayHintProvider: (services) => new SafeDsInlayHintProvider(services),
SemanticTokenProvider: (services) => new SafeDsSemanticTokenProvider(services),
},
parser: {
Expand Down
188 changes: 188 additions & 0 deletions tests/language/lsp/safe-ds-inlay-hint-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { clearDocuments, parseHelper } from 'langium/test';
import { createSafeDsServices } from '../../../src/language/safe-ds-module.js';
import { Position } from 'vscode-languageserver';
import { NodeFileSystem } from 'langium/node';
import { findTestChecks } from '../../helpers/testChecks.js';
import { URI } from 'langium';

const services = createSafeDsServices(NodeFileSystem).SafeDs;
const inlayHintProvider = services.lsp.InlayHintProvider!;
const workspaceManager = services.shared.workspace.WorkspaceManager;
const parse = parseHelper(services);

describe('SafeDsInlayHintProvider', async () => {
beforeEach(async () => {
// Load the builtin library
await workspaceManager.initializeWorkspace([]);
});

afterEach(async () => {
await clearDocuments(services);
});

const testCases: InlayHintProviderTest[] = [
{
testName: 'resolved positional argument',
code: `
fun f(p: Int)
pipeline myPipeline {
// $TEST$ before "p = "
f(»«1);
}
`,
},
{
testName: 'unresolved positional argument',
code: `
fun f()
pipeline myPipeline {
f(1);
}
`,
},
{
testName: 'named argument',
code: `
fun f(p: Int)
pipeline myPipeline {
f(p = 1);
}
`,
},
{
testName: 'block lambda result',
code: `
pipeline myPipeline {
() {
// $TEST$ after ": literal<1>"
yield r»« = 1;
};
}
`,
},
{
testName: 'placeholder',
code: `
pipeline myPipeline {
// $TEST$ after ": literal<1>"
val x»« = 1;
}
`,
},
{
testName: 'wildcard',
code: `
pipeline myPipeline {
_ = 1;
}
`,
},
{
testName: 'yield',
code: `
segment s() -> r: Int {
// $TEST$ after ": literal<1>"
yield r»« = 1;
}
`,
},
];

it.each(testCases)('should assign the correct inlay hints ($testName)', async ({ code }) => {
const actualInlayHints = await getActualInlayHints(code);
const expectedInlayHints = getExpectedInlayHints(code);

expect(actualInlayHints).toStrictEqual(expectedInlayHints);
});
});

const getActualInlayHints = async (code: string): Promise<SimpleInlayHint[] | undefined> => {
const document = await parse(code);
const inlayHints = await inlayHintProvider.getInlayHints(document, {
range: document.parseResult.value.$cstNode!.range,
textDocument: { uri: document.textDocument.uri },
});

return inlayHints?.map((hint) => {
if (typeof hint.label === 'string') {
return {
label: hint.label,
position: hint.position,
};
} else {
return {
label: hint.label.join(''),
position: hint.position,
};
}
});
};

const getExpectedInlayHints = (code: string): SimpleInlayHint[] => {
const testChecks = findTestChecks(code, URI.file('file:///test.sdstest'), { failIfFewerRangesThanComments: true });
if (testChecks.isErr) {
throw new Error(testChecks.error.message);
}

return testChecks.value.map((check) => {
const range = check.location!.range;

const afterMatch = /after "(?<label>[^"]*)"/gu.exec(check.comment);
if (afterMatch) {
return {
label: afterMatch.groups!.label,
position: {
line: range.start.line,
character: range.start.character - 1,
},
};
}

const beforeMatch = /before "(?<label>[^"]*)"/gu.exec(check.comment);
if (beforeMatch) {
return {
label: beforeMatch.groups!.label,
position: {
line: range.end.line,
character: range.end.character + 1,
},
};
}

throw new Error('Incorrect test comment format');
});
};

/**
* A description of a test case for the inlay hint provider.
*/
interface InlayHintProviderTest {
/**
* A short description of the test case.
*/
testName: string;

/**
* The code to parse.
*/
code: string;
}

/**
* A simple inlay hint with some information removed.
*/
interface SimpleInlayHint {
/**
* The text of the inlay hint.
*/
label: string;

/**
* The position of the inlay hint.
*/
position: Position;
}

0 comments on commit f23fa29

Please sign in to comment.