diff --git a/CHANGELOG.md b/CHANGELOG.md index b043ebd..d9e3cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `Tools for Apache Kafka®` are documented in this file. - Hide internal [strimzi](https://strimzi.io/) topics/consumers by default. See [#176](https://github.com/jlandersen/vscode-kafka/pull/176). - Validation for available topics in `.kafka` files. See [#153](https://github.com/jlandersen/vscode-kafka/issues/153). - Simplify snippets. See [#180](https://github.com/jlandersen/vscode-kafka/pull/180). +- Hover support in `.kafka` files. See [#149](https://github.com/jlandersen/vscode-kafka/issues/149). ## [0.12.0] - 2021-04-26 ### Added diff --git a/src/kafka-file/kafkaFileClient.ts b/src/kafka-file/kafkaFileClient.ts index 890e233..39852fc 100644 --- a/src/kafka-file/kafkaFileClient.ts +++ b/src/kafka-file/kafkaFileClient.ts @@ -164,6 +164,12 @@ export function startLanguageClient( const diagnostics = new KafkaFileDiagnostics(kafkaFileDocuments, languageService, clusterSettings, clientAccessor, modelProvider, workspaceSettings); context.subscriptions.push(diagnostics); + // Hover + const hover = new KafkaFileHoverProvider(kafkaFileDocuments, languageService); + context.subscriptions.push( + vscode.languages.registerHoverProvider(documentSelector, hover) + ); + // Open / Close document context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(e => { if (e.languageId === 'kafka') { @@ -277,7 +283,7 @@ class KafkaFileCompletionItemProvider extends AbstractKafkaFileFeature implement return runSafeAsync(async () => { const kafkaFileDocument = this.getKafkaFileDocument(document); return this.languageService.doComplete(document, kafkaFileDocument, this.workspaceSettings.producerFakerJSEnabled, position); - }, new vscode.CompletionList(), `Error while computing code lenses for ${document.uri}`, token); + }, new vscode.CompletionList(), `Error while computing completion for ${document.uri}`, token); } } @@ -359,3 +365,13 @@ class KafkaFileDiagnostics extends AbstractKafkaFileFeature implements vscode.Di this.diagnosticCollection.dispose(); } } + +class KafkaFileHoverProvider extends AbstractKafkaFileFeature implements vscode.HoverProvider { + provideHover(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): vscode.ProviderResult { + return runSafeAsync(async () => { + const kafkaFileDocument = this.getKafkaFileDocument(document); + return this.languageService.doHover(document, kafkaFileDocument, position); + }, null, `Error while computing hover for ${document.uri}`, token); + } + +} \ No newline at end of file diff --git a/src/kafka-file/languageservice/kafkaFileLanguageService.ts b/src/kafka-file/languageservice/kafkaFileLanguageService.ts index e88f553..cf27cfb 100644 --- a/src/kafka-file/languageservice/kafkaFileLanguageService.ts +++ b/src/kafka-file/languageservice/kafkaFileLanguageService.ts @@ -1,4 +1,4 @@ -import { CodeLens, CompletionList, Diagnostic, Position, TextDocument, Uri } from "vscode"; +import { CodeLens, CompletionList, Diagnostic, Hover, Position, TextDocument, Uri } from "vscode"; import { ClientState, ConsumerLaunchState } from "../../client"; import { BrokerConfigs } from "../../client/config"; import { ProducerLaunchState } from "../../client/producer"; @@ -6,6 +6,7 @@ import { KafkaFileDocument, parseKafkaFile } from "./parser/kafkaFileParser"; import { KafkaFileCodeLenses } from "./services/codeLensProvider"; import { KafkaFileCompletion } from "./services/completion"; import { KafkaFileDiagnostics } from "./services/diagnostics"; +import { KafkaFileHover } from "./services/hover"; /** * Provider API which gets the state for a given producer. @@ -49,6 +50,7 @@ export interface TopicProvider { * */ export interface LanguageService { + /** * Parse the given text document and returns an AST. * @@ -85,6 +87,15 @@ export interface LanguageService { * @param kafkaFileDocument the parsed AST. */ doDiagnostics(document: TextDocument, kafkaFileDocument: KafkaFileDocument, producerFakerJSEnabled: boolean): Promise; + + /** + * Returns the hover result for the given text document and parsed AST at given position. + * + * @param document the text document. + * @param kafkaFileDocument the parsed AST. + * @param position the position where the hover was triggered. + */ + doHover(document: TextDocument, kafkaFileDocument: KafkaFileDocument, position: Position): Promise; } /** @@ -100,10 +111,18 @@ export function getLanguageService(producerLaunchStateProvider: ProducerLaunchSt const codeLenses = new KafkaFileCodeLenses(producerLaunchStateProvider, consumerLaunchStateProvider, selectedClusterProvider); const completion = new KafkaFileCompletion(selectedClusterProvider, topicProvider); const diagnostics = new KafkaFileDiagnostics(selectedClusterProvider, topicProvider); + const hover = new KafkaFileHover(selectedClusterProvider, topicProvider); return { parseKafkaFileDocument: (document: TextDocument) => parseKafkaFile(document), getCodeLenses: codeLenses.getCodeLenses.bind(codeLenses), doComplete: completion.doComplete.bind(completion), - doDiagnostics: diagnostics.doDiagnostics.bind(diagnostics) + doDiagnostics: diagnostics.doDiagnostics.bind(diagnostics), + doHover: hover.doHover.bind(hover) }; } + +export function createTopicDocumentation(topic: TopicDetail): string { + return `Topic \`${topic.id}\`\n` + + ` * partition count: \`${topic.partitionCount}\`\n` + + ` * replication factor: \`${topic.replicationFactor}\`\n`; +} \ No newline at end of file diff --git a/src/kafka-file/languageservice/parser/kafkaFileParser.ts b/src/kafka-file/languageservice/parser/kafkaFileParser.ts index b759dab..db3fc84 100644 --- a/src/kafka-file/languageservice/parser/kafkaFileParser.ts +++ b/src/kafka-file/languageservice/parser/kafkaFileParser.ts @@ -17,7 +17,9 @@ export enum NodeKind { export interface Node { start: Position; end: Position; + range(): Range; findNodeBefore(offset: Position): Node; + findNodeAt(offset: Position): Node; lastChild: Node | undefined; parent: Node | undefined; kind: NodeKind; @@ -32,10 +34,20 @@ class BaseNode implements Node { } + public range(): Range { + const start = this.start; + const end = this.end; + return new Range(start, end); + } + public findNodeBefore(offset: Position): Node { return this; } + public findNodeAt(offset: Position): Node { + return this; + } + public get lastChild(): Node | undefined { return undefined; } } @@ -65,6 +77,17 @@ class ChildrenNode extends BaseNode { return this; } + public findNodeAt(offset: Position): Node { + const idx = findFirst(this.children, c => offset.isBeforeOrEqual(c.start)) - 1; + if (idx >= 0) { + const child = this.children[idx]; + if (offset.isAfter(child.start) && offset.isBeforeOrEqual(child.end)) { + return child.findNodeAt(offset); + } + } + return this; + } + public get lastChild(): Node | undefined { return this.children.length ? this.children[this.children.length - 1] : void 0; }; } @@ -84,6 +107,7 @@ export class Chunk extends BaseNode { constructor(public readonly content: string, start: Position, end: Position, kind: NodeKind) { super(start, end, kind); } + } export class Property extends BaseNode { @@ -106,12 +130,6 @@ export class Property extends BaseNode { return this.value?.content.trim(); } - public get propertyRange(): Range { - const start = this.start; - const end = this.end; - return new Range(start, end); - } - public get propertyKeyRange(): Range { const start = this.start; const end = this.assignerCharacter ? new Position(this.start.line, this.assignerCharacter) : this.end; @@ -159,6 +177,13 @@ export class Property extends BaseNode { } return true; } + + findNodeAt(position : Position) : Node { + if (this.isBeforeAssigner(position)) { + return this.key?.findNodeAt(position) || this; + } + return this.value?.findNodeAt(position) || this; + } } export abstract class Block extends ChildrenNode { @@ -175,7 +200,7 @@ export abstract class Block extends ChildrenNode { getPropertyValue(name: string): string | undefined { const property = this.getProperty(name); - return property?.value?.content; + return property?.propertyValue; } getProperty(name: string): Property | undefined { diff --git a/src/kafka-file/languageservice/services/completion.ts b/src/kafka-file/languageservice/services/completion.ts index 2cd2a90..227aa12 100644 --- a/src/kafka-file/languageservice/services/completion.ts +++ b/src/kafka-file/languageservice/services/completion.ts @@ -1,5 +1,5 @@ import { TextDocument, Position, CompletionList, CompletionItem, SnippetString, MarkdownString, CompletionItemKind, Range } from "vscode"; -import { SelectedClusterProvider, TopicDetail, TopicProvider } from "../kafkaFileLanguageService"; +import { createTopicDocumentation, SelectedClusterProvider, TopicProvider } from "../kafkaFileLanguageService"; import { consumerModel, fakerjsAPIModel, Model, ModelDefinition, producerModel } from "../model"; import { Block, BlockType, Chunk, ConsumerBlock, KafkaFileDocument, MustacheExpression, NodeKind, ProducerBlock, Property } from "../parser/kafkaFileParser"; @@ -235,11 +235,6 @@ export class KafkaFileCompletion { return; } - function createDocumentation(topic: TopicDetail): string { - return `Topic \`${topic.id}\`\n` + - ` * partition count: \`${topic.partitionCount}\`\n` + - ` * replication factor: \`${topic.replicationFactor}\`\n`; - } const valueRange = property.propertyValueRange; try { const topics = await this.topicProvider.getTopics(clusterId); @@ -247,7 +242,7 @@ export class KafkaFileCompletion { const value = topic.id; const item = new CompletionItem(value); item.kind = CompletionItemKind.Value; - item.documentation = new MarkdownString(createDocumentation(topic)); + item.documentation = new MarkdownString(createTopicDocumentation(topic)); const insertText = new SnippetString(' '); insertText.appendText(value); item.insertText = insertText; diff --git a/src/kafka-file/languageservice/services/diagnostics.ts b/src/kafka-file/languageservice/services/diagnostics.ts index d05a413..94e0715 100644 --- a/src/kafka-file/languageservice/services/diagnostics.ts +++ b/src/kafka-file/languageservice/services/diagnostics.ts @@ -208,14 +208,14 @@ export class KafkaFileDiagnostics { const assigner = property.assignerCharacter; if (!assigner) { // Error => topic - const range = property.propertyRange; + const range = property.range(); diagnostics.push(new Diagnostic(range, `Missing ':' sign after '${propertyName}'`, DiagnosticSeverity.Error)); return; } // 1.2. property must declare a key if (!propertyName) { // Error => :string - const range = property.propertyRange; + const range = property.range(); diagnostics.push(new Diagnostic(range, "Property must define a name before ':' sign", DiagnosticSeverity.Error)); return; } @@ -280,7 +280,7 @@ export class KafkaFileDiagnostics { // The topic validation is done, only when the cluster is connected if (!await this.topicProvider.getTopic(clusterId, topicId)) { // The topic doesn't exist, report an error - const range = topicProperty.propertyTrimmedValueRange || topicProperty.propertyRange; + const range = topicProperty.propertyTrimmedValueRange || topicProperty.range(); const autoCreate = await this.topicProvider.getAutoCreateTopicEnabled(clusterId); const errorMessage = getTopicErrorMessage(topicId, autoCreate, blockType); const severity = getTopicErrorSeverity(autoCreate); diff --git a/src/kafka-file/languageservice/services/hover.ts b/src/kafka-file/languageservice/services/hover.ts new file mode 100644 index 0000000..98b2234 --- /dev/null +++ b/src/kafka-file/languageservice/services/hover.ts @@ -0,0 +1,168 @@ +import { Hover, Position, Range, TextDocument } from "vscode"; +import { createTopicDocumentation, SelectedClusterProvider, TopicProvider } from "../kafkaFileLanguageService"; +import { consumerModel, Model, producerModel } from "../model"; +import { Block, BlockType, Chunk, ConsumerBlock, KafkaFileDocument, MustacheExpression, NodeKind, ProducerBlock, Property } from "../parser/kafkaFileParser"; + +export class KafkaFileHover { + + constructor(private selectedClusterProvider: SelectedClusterProvider, private topicProvider: TopicProvider) { + + } + + async doHover(document: TextDocument, kafkaFileDocument: KafkaFileDocument, position: Position): Promise { + // Get the AST node before the position where complation was triggered + const node = kafkaFileDocument.findNodeAt(position); + if (!node) { + return; + } + switch (node.kind) { + + case NodeKind.consumerBlock: { + const block = node; + const topic = block.getPropertyValue('topic'); + return new Hover( + `Consumer declaration${topic ? ` for the topic \`${topic}\`` : ''}.\n\nSee [here](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Consuming.md#kafka-file) for more informations.`, + node.range()); + } + + case NodeKind.producerBlock: { + const block = node; + const topic = block.getPropertyValue('topic'); + return new Hover( + `Producer declaration${topic ? ` for the topic \`${topic}\`` : ''}.\n\nSee [here](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Producing.md#kafka-file) for more informations.`, + node.range()); + } + + case NodeKind.propertyKey: { + const propertyKey = node; + const property = propertyKey.parent; + const propertyName = propertyKey.content; + const propertyKeyRange = propertyKey.range(); + const block = property.parent; + if (block.type === BlockType.consumer) { + // CONSUMER + // key|: + + // or + + // CONSUMER + // key| + return await this.getHoverForConsumerPropertyNames(propertyName, propertyKeyRange, block); + } else { + // PRODUCER + // key|: + return await this.getHoverForProducerPropertyNames(propertyName, propertyKeyRange, block); + } + } + + case NodeKind.propertyValue: { + const propertyValue = node; + const property = propertyValue.parent; + const block = property.parent; + if (block.type === BlockType.consumer) { + // CONSUMER + // key-format: | + return await this.getHoverForConsumerPropertyValues(propertyValue, property, block); + } else { + // PRODUCER + // key-format: | + return await this.getHoverForProducerPropertyValues(propertyValue, property, block); + } + } + + case NodeKind.mustacheExpression: { + const expression = node; + return new Hover('FakerJS expression. See [here](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Producing.md#randomized-content) for more informations.', expression.enclosedExpressionRange); + } + + case NodeKind.producerValue: { + return new Hover('Producer value. See [here](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Producing.md#kafka-file) for more informations.', node.range()); + } + } + } + + async getHoverForConsumerPropertyNames(propertyName: string, propertyKeyRange: Range, block: ConsumerBlock): Promise { + return await this.getHoverForPropertyNames(propertyName, propertyKeyRange, block, consumerModel); + } + + async getHoverForProducerPropertyNames(propertyName: string, propertyKeyRange: Range, block: ProducerBlock): Promise { + return await this.getHoverForPropertyNames(propertyName, propertyKeyRange, block, producerModel); + } + + async getHoverForPropertyNames(propertyName: string, propertyKeyRange: Range, block: Block, metadata: Model): Promise { + const definition = metadata.getDefinition(propertyName); + if (definition && definition.description) { + return new Hover(definition.description, propertyKeyRange); + } + } + + async getHoverForConsumerPropertyValues(propertyValue: any, property: Property, block: ConsumerBlock): Promise { + const propertyName = property.propertyName; + switch (propertyName) { + case 'topic': + // CONSUMER + // topic: | + return await this.getHoverForTopic(property); + default: + // CONSUMER + // key-format: | + return await this.getHoverForPropertyValues(propertyValue, property, block, consumerModel); + } + } + + + async getHoverForProducerPropertyValues(propertyValue: any, property: Property, block: ProducerBlock): Promise { + const propertyName = property.propertyName; + switch (propertyName) { + case 'topic': + // PRODUCER + // topic: | + return await this.getHoverForTopic(property); + default: + // PRODUCER + // key-format: | + return await this.getHoverForPropertyValues(propertyValue, property, block, producerModel); + } + } + + async getHoverForTopic(property: Property): Promise { + const propertyValue = property.value; + if (!propertyValue) { + return; + } + const { clusterId } = this.selectedClusterProvider.getSelectedCluster(); + if (!clusterId) { + return; + } + + try { + const topicId = propertyValue.content.trim(); + const topics = await this.topicProvider.getTopics(clusterId); + if (topics.length > 0) { + const topic = topics + .find(t => t.id === topicId); + if (topic) { + return new Hover(createTopicDocumentation(topic), propertyValue.range()); + } + } + } + catch (e) { + return; + } + + return undefined; + } + + async getHoverForPropertyValues(propertyValue: Chunk | undefined, property: Property, block: Block, metadata: Model): Promise { + const propertyName = property.propertyName; + if (!propertyName) { + return; + } + const definition = metadata.getDefinition(propertyName); + if (definition && !definition.description) { + return new Hover(definition.description, propertyValue?.range()); + } + return undefined; + } + +} \ No newline at end of file diff --git a/src/test/suite/kafka-file/languageservice/hover.test.ts b/src/test/suite/kafka-file/languageservice/hover.test.ts new file mode 100644 index 0000000..a2f4e4e --- /dev/null +++ b/src/test/suite/kafka-file/languageservice/hover.test.ts @@ -0,0 +1,156 @@ +import { ClientState } from "../../../../client"; +import { getLanguageService } from "../../../../kafka-file/languageservice/kafkaFileLanguageService"; +import { assertHover, hover, LanguageServiceConfig, position } from "./kafkaAssert"; + +suite("Kafka File Hover Test Suite", () => { + + test("Empty hover", async () => { + await assertHover(''); + + await assertHover('ab|cd'); + + }); + +}); + +suite("Kafka File CONSUMER Hover Test Suite", () => { + + test("CONSUMER declaration no topic Hover", async () => { + + await assertHover( + 'CONS|UMER\n', + hover( + `Consumer declaration.\n\nSee [here](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Consuming.md#kafka-file) for more informations.`, + position(0, 0), + position(1, 0) + ) + ); + + }); + + test("CONSUMER declaration with topic Hover", async () => { + + await assertHover( + 'CONS|UMER\n' + + 'topic: abcd', + hover( + `Consumer declaration for the topic \`abcd\`.\n\n\See [here](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Consuming.md#kafka-file) for more informations.`, + position(0, 0), + position(1, 11) + ) + ); + + }); + + test("topic property name Hover", async () => { + + await assertHover( + 'CONSUMER\n' + + 'top|ic: abcd', + hover( + `The topic id *[required]*`, + position(1, 0), + position(1, 5) + ) + ); + + }); + + test("topic property value Hover", async () => { + + await assertHover( + 'CONSUMER\n' + + 'topic: ab|cd' + ); + + const languageServiceConfig = new LanguageServiceConfig(); + languageServiceConfig.setTopics('cluster1', [{ id: 'abcd', partitionCount: 1, replicationFactor: 1 }]); + const connectedCuster = { clusterId: 'cluster1', clusterName: 'CLUSTER_1', clusterState: ClientState.connected }; + languageServiceConfig.setSelectedCluster(connectedCuster); + const languageService = getLanguageService(languageServiceConfig, languageServiceConfig, languageServiceConfig, languageServiceConfig); + + await assertHover( + 'CONSUMER\n' + + 'topic: ab|cd', + hover( + 'Topic `abcd`\n * partition count: `1`\n * replication factor: `1`\n', + position(1, 6), + position(1, 11) + ), + languageService + ); + + }); + +}); + +suite("Kafka File PRODUCER Hover Test Suite", () => { + + test("PRODUCER declaration no topic Hover", async () => { + + await assertHover( + 'PRODU|CER\n', + hover( + `Producer declaration.\n\nSee [here](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Producing.md#kafka-file) for more informations.`, + position(0, 0), + position(1, 0) + ) + ); + + }); + + test("PRODUCER declaration with topic Hover", async () => { + + await assertHover( + 'PRODU|CER\n' + + 'topic: abcd', + hover( + `Producer declaration for the topic \`abcd\`.\n\n\See [here](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Producing.md#kafka-file) for more informations.`, + position(0, 0), + position(1, 11) + ) + ); + + test("topic property name Hover", async () => { + + await assertHover( + 'PRODUCER\n' + + 'top|ic: abcd', + hover( + `The topic id *[required]*`, + position(1, 0), + position(1, 5) + ) + ); + + }); + + test("topic property value Hover", async () => { + + await assertHover( + 'PRODUCER\n' + + 'topic: ab|cd' + ); + + const languageServiceConfig = new LanguageServiceConfig(); + languageServiceConfig.setTopics('cluster1', [{ id: 'abcd', partitionCount: 1, replicationFactor: 1 }]); + const connectedCuster = { clusterId: 'cluster1', clusterName: 'CLUSTER_1', clusterState: ClientState.connected }; + languageServiceConfig.setSelectedCluster(connectedCuster); + const languageService = getLanguageService(languageServiceConfig, languageServiceConfig, languageServiceConfig, languageServiceConfig); + + await assertHover( + 'PRODUCER\n' + + 'topic: ab|cd', + hover( + 'Topic `abcd`\n * partition count: `1`\n * replication factor: `1`\n', + position(1, 6), + position(1, 11) + ), + languageService + ); + + }); + + }); + +}); \ No newline at end of file diff --git a/src/test/suite/kafka-file/languageservice/kafkaAssert.ts b/src/test/suite/kafka-file/languageservice/kafkaAssert.ts index 8d7f0c0..2d970f6 100644 --- a/src/test/suite/kafka-file/languageservice/kafkaAssert.ts +++ b/src/test/suite/kafka-file/languageservice/kafkaAssert.ts @@ -1,5 +1,5 @@ import * as assert from "assert"; -import { CodeLens, Position, Range, Command, Uri, workspace, CompletionList, SnippetString, Diagnostic, DiagnosticSeverity } from "vscode"; +import { CodeLens, Position, Range, Command, Uri, workspace, CompletionList, SnippetString, Diagnostic, DiagnosticSeverity, Hover } from "vscode"; import { ClientState, ConsumerLaunchState } from "../../../../client"; import { BrokerConfigs } from "../../../../client/config"; import { ProducerLaunchState } from "../../../../client/producer"; @@ -12,7 +12,7 @@ export class LanguageServiceConfig implements ProducerLaunchStateProvider, Consu private consumerLaunchStates = new Map(); - private selectedCluster: { clusterId?: string, clusterName?: string, clusterState? : ClientState } | undefined; + private selectedCluster: { clusterId?: string, clusterName?: string, clusterState?: ClientState } | undefined; private topicsCache = new Map(); @@ -50,7 +50,7 @@ export class LanguageServiceConfig implements ProducerLaunchStateProvider, Consu return {}; } - public setSelectedCluster(selectedCluster: { clusterId?: string, clusterName?: string, clusterState? : ClientState }) { + public setSelectedCluster(selectedCluster: { clusterId?: string, clusterName?: string, clusterState?: ClientState }) { this.selectedCluster = selectedCluster; } @@ -66,11 +66,11 @@ export class LanguageServiceConfig implements ProducerLaunchStateProvider, Consu return topics.find(topic => topic.id === topicId); } - - public setAutoCreateConfig(autoCreateConfig : BrokerConfigs.AutoCreateTopicResult) { - this.autoCreateConfig= autoCreateConfig; + + public setAutoCreateConfig(autoCreateConfig: BrokerConfigs.AutoCreateTopicResult) { + this.autoCreateConfig = autoCreateConfig; } - + async getAutoCreateTopicEnabled(clusterid: string): Promise { return this.autoCreateConfig; } @@ -159,6 +159,24 @@ export async function assertDiagnostics(content: string, expected: Array