diff --git a/src/language/documentation/safe-ds-documentation-provider.ts b/src/language/documentation/safe-ds-documentation-provider.ts new file mode 100644 index 000000000..cf18886f1 --- /dev/null +++ b/src/language/documentation/safe-ds-documentation-provider.ts @@ -0,0 +1,88 @@ +import { + AstNode, + getContainerOfType, + isJSDoc, + JSDocComment, + JSDocDocumentationProvider, + JSDocRenderOptions, + parseJSDoc, +} from 'langium'; +import { + isSdsCallable, + isSdsParameter, + isSdsResult, + isSdsTypeParameter, + SdsParameter, + SdsResult, + SdsTypeParameter, +} from '../generated/ast.js'; + +export class SafeDsDocumentationProvider extends JSDocDocumentationProvider { + override getDocumentation(node: AstNode): string | undefined { + if (isSdsParameter(node) || isSdsResult(node) || isSdsTypeParameter(node)) { + const containingCallable = getContainerOfType(node, isSdsCallable); + /* c8 ignore start */ + if (!containingCallable) { + return undefined; + } + /* c8 ignore stop */ + + const comment = this.getJSDocComment(containingCallable); + if (!comment) { + return undefined; + } + + return this.getMatchingTagContent(comment, node); + } else { + const comment = this.getJSDocComment(node); + return comment?.toMarkdown(this.createJSDocRenderOptions(node)); + } + } + + private getJSDocComment(node: AstNode): JSDocComment | undefined { + const comment = this.commentProvider.getComment(node); + if (comment && isJSDoc(comment)) { + return parseJSDoc(comment); + } + return undefined; + } + + private getMatchingTagContent( + comment: JSDocComment, + node: SdsParameter | SdsResult | SdsTypeParameter, + ): string | undefined { + const name = node.name; + /* c8 ignore start */ + if (!name) { + return undefined; + } + /* c8 ignore stop */ + + const tagName = this.getTagName(node); + const matchRegex = new RegExp(`^${name}\\s+(?.*)`, 'u'); + + return comment + .getTags(tagName) + .map((it) => it.content.toMarkdown(this.createJSDocRenderOptions(node))) + .find((it) => matchRegex.test(it)) + ?.match(matchRegex)?.groups?.content; + } + + private getTagName(node: SdsParameter | SdsResult | SdsTypeParameter): string { + if (isSdsParameter(node)) { + return 'param'; + } else if (isSdsResult(node)) { + return 'result'; + } else { + return 'typeParam'; + } + } + + private createJSDocRenderOptions(node: AstNode): JSDocRenderOptions { + return { + renderLink: (link, display) => { + return this.documentationLinkRenderer(node, link, display); + }, + }; + } +} diff --git a/src/language/safe-ds-module.ts b/src/language/safe-ds-module.ts index d4fa2304f..58a156fc7 100644 --- a/src/language/safe-ds-module.ts +++ b/src/language/safe-ds-module.ts @@ -4,7 +4,6 @@ import { DeepPartial, DefaultSharedModuleContext, inject, - JSDocDocumentationProvider, LangiumServices, LangiumSharedServices, Module, @@ -33,6 +32,7 @@ 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'; import { SafeDsCommentProvider } from './documentation/safe-ds-comment-provider.js'; +import { SafeDsDocumentationProvider } from './documentation/safe-ds-documentation-provider.js'; /** * Declaration of custom services - add your own service classes here. @@ -79,7 +79,7 @@ export const SafeDsModule: Module new SafeDsCommentProvider(services), - DocumentationProvider: (services) => new JSDocDocumentationProvider(services), + DocumentationProvider: (services) => new SafeDsDocumentationProvider(services), }, evaluation: { PartialEvaluator: (services) => new SafeDsPartialEvaluator(services), diff --git a/src/resources/builtins/safeds/lang/codeGeneration.sdsstub b/src/resources/builtins/safeds/lang/codeGeneration.sdsstub index 5e204a00b..efc4e15ad 100644 --- a/src/resources/builtins/safeds/lang/codeGeneration.sdsstub +++ b/src/resources/builtins/safeds/lang/codeGeneration.sdsstub @@ -4,10 +4,9 @@ package safeds.lang * The specification of a corresponding function call in Python. By default, the function is called as specified in the * stub. * - * @param callSpecification - * The specification of corresponding Python call. The specification can contain template expression, which are - * replaced by the corresponding arguments of the function call. `$this` is replaced by the receiver of the call. - * `$param` is replaced by the value of the parameter called `param`. Otherwise, the string is used as-is. + * The specification can contain template expressions, which are replaced by the corresponding arguments of the function + * call. `$this` is replaced by the receiver of the call. `$param` is replaced by the value of the parameter called + * `param`. Otherwise, the string is used as-is. */ @Target([AnnotationTarget.Function]) annotation PythonCall( diff --git a/tests/language/documentation/safe-ds-documentation-provider.test.ts b/tests/language/documentation/safe-ds-documentation-provider.test.ts new file mode 100644 index 000000000..e670cb239 --- /dev/null +++ b/tests/language/documentation/safe-ds-documentation-provider.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createSafeDsServices } from '../../../src/language/safe-ds-module.js'; +import { AstNode, EmptyFileSystem } from 'langium'; +import { clearDocuments } from 'langium/test'; +import { getNodeOfType } from '../../helpers/nodeFinder.js'; +import { + isSdsAnnotation, + isSdsFunction, + isSdsParameter, + isSdsResult, + isSdsTypeParameter, +} from '../../../src/language/generated/ast.js'; + +const services = createSafeDsServices(EmptyFileSystem).SafeDs; +const documentationProvider = services.documentation.DocumentationProvider; +const testDocumentation = 'Lorem ipsum.'; + +describe('SafeDsDocumentationProvider', () => { + afterEach(async () => { + await clearDocuments(services); + }); + + const testCases: DocumentationProviderTest[] = [ + { + testName: 'module member', + code: ` + /** + * ${testDocumentation} + */ + annotation MyAnnotation + `, + predicate: isSdsAnnotation, + expectedDocumentation: testDocumentation, + }, + { + testName: 'documented parameter', + code: ` + /** + * @param param ${testDocumentation} + */ + fun myFunction(param: String) + `, + predicate: isSdsParameter, + expectedDocumentation: testDocumentation, + }, + { + testName: 'documented parameter (duplicate)', + code: ` + /** + * @param param ${testDocumentation} + * @param param bla + */ + fun myFunction(param: String) + `, + predicate: isSdsParameter, + expectedDocumentation: testDocumentation, + }, + { + testName: 'undocumented parameter', + code: ` + /** + * @param param ${testDocumentation} + */ + fun myFunction(param2: String) + `, + predicate: isSdsParameter, + expectedDocumentation: undefined, + }, + { + testName: 'parameter (no documentation on containing callable)', + code: ` + fun myFunction(p: Int) + `, + predicate: isSdsParameter, + expectedDocumentation: undefined, + }, + { + testName: 'documented result', + code: ` + /** + * @result res ${testDocumentation} + */ + fun myFunction() -> (res: String) + `, + predicate: isSdsResult, + expectedDocumentation: testDocumentation, + }, + { + testName: 'documented result (duplicate)', + code: ` + /** + * @result res ${testDocumentation} + * @result res bla + */ + fun myFunction() -> (res: String) + `, + predicate: isSdsResult, + expectedDocumentation: testDocumentation, + }, + { + testName: 'undocumented result', + code: ` + /** + * @result res ${testDocumentation} + */ + fun myFunction() -> (res2: String) + `, + predicate: isSdsResult, + expectedDocumentation: undefined, + }, + { + testName: 'result (no documentation on containing callable)', + code: ` + fun myFunction() -> r: Int + `, + predicate: isSdsResult, + expectedDocumentation: undefined, + }, + { + testName: 'documented type parameter', + code: ` + enum MyEnum { + /** + * @typeParam T + * ${testDocumentation} + */ + MyEnumVariant + } + `, + predicate: isSdsTypeParameter, + expectedDocumentation: testDocumentation, + }, + { + testName: 'documented type parameter (duplicate)', + code: ` + enum MyEnum { + /** + * @typeParam T ${testDocumentation} + * @typeParam T bla + */ + MyEnumVariant + } + `, + predicate: isSdsTypeParameter, + expectedDocumentation: testDocumentation, + }, + { + testName: 'undocumented type parameter', + code: ` + enum MyEnum { + /** + * @typeParam T + * ${testDocumentation} + */ + MyEnumVariant + } + `, + predicate: isSdsTypeParameter, + expectedDocumentation: undefined, + }, + { + testName: 'type parameter (no documentation on containing callable)', + code: ` + fun myFunction() + `, + predicate: isSdsTypeParameter, + expectedDocumentation: undefined, + }, + ]; + + it.each(testCases)('$testName', async ({ code, predicate, expectedDocumentation }) => { + const node = await getNodeOfType(services, code, predicate); + expect(documentationProvider.getDocumentation(node)).toStrictEqual(expectedDocumentation); + }); + + it('should resolve links', async () => { + const code = ` + /** + * {@link myFunction2} + */ + fun myFunction1() + + fun myFunction2() + `; + const node = await getNodeOfType(services, code, isSdsFunction); + expect(documentationProvider.getDocumentation(node)).toMatch(/\[myFunction2\]\(.*\)/u); + }); +}); + +/** + * A description of a test case for the documentation provider. + */ +interface DocumentationProviderTest { + /** + * A short description of the test case. + */ + testName: string; + + /** + * The code to test. + */ + code: string; + + /** + * A predicate to find the node to test. + */ + predicate: (node: unknown) => node is AstNode; + + /** + * The expected documentation. + */ + expectedDocumentation: string | undefined; +}