From e192dfa2e88ca328053c3d6decc52e5dce266c38 Mon Sep 17 00:00:00 2001 From: angelozerr Date: Fri, 2 Apr 2021 17:35:35 +0200 Subject: [PATCH] Completion support for kafka file Fixes #146 Signed-off-by: azerr --- snippets/producers.json | 4 +- src/kafka-file/kafkaFileClient.ts | 31 +- .../kafkaFileLanguageService.ts | 27 +- src/kafka-file/languageservice/model.ts | 175 ++++ .../languageservice/parser/kafkaFileParser.ts | 178 +++- .../services/codeLensProvider.ts | 2 +- .../languageservice/services/completion.ts | 172 ++++ src/kafka-file/utils/arrays.ts | 43 + src/kafka-file/{ => utils}/runner.ts | 0 .../languageservice/completion.test.ts | 889 ++++++++++++++++++ .../kafka-file/languageservice/kafkaAssert.ts | 46 +- syntaxes/kafka.tmLanguage.json | 2 +- 12 files changed, 1508 insertions(+), 61 deletions(-) create mode 100644 src/kafka-file/languageservice/model.ts create mode 100644 src/kafka-file/languageservice/services/completion.ts create mode 100644 src/kafka-file/utils/arrays.ts rename src/kafka-file/{ => utils}/runner.ts (100%) create mode 100644 src/test/suite/kafka-file/languageservice/completion.test.ts diff --git a/snippets/producers.json b/snippets/producers.json index 55ac000..fb42cfe 100644 --- a/snippets/producers.json +++ b/snippets/producers.json @@ -40,7 +40,7 @@ "PRODUCER ${1:key-formatted-message}", "topic: ${2:topic_name}", "key: ${3:mykeyq}", - "key-format: ${3|none,double,float,integer,long,short|}", + "key-format: ${3|string,double,float,integer,long,short|}", "${4:{{random.words}}}", "", "###", @@ -56,7 +56,7 @@ "PRODUCER ${1:formatted-message}", "topic: ${2:topic_name}", "key: ${3:mykeyq}", - "value-format: ${3|none,double,float,integer,long,short|}", + "value-format: ${3|string,double,float,integer,long,short|}", "${4:{{random.words}}}", "", "###", diff --git a/src/kafka-file/kafkaFileClient.ts b/src/kafka-file/kafkaFileClient.ts index 2138f32..91bf43c 100644 --- a/src/kafka-file/kafkaFileClient.ts +++ b/src/kafka-file/kafkaFileClient.ts @@ -6,7 +6,7 @@ import { ClusterSettings } from "../settings/clusters"; import { getLanguageModelCache, LanguageModelCache } from './languageModelCache'; import { KafkaFileDocument } from "./languageservice/parser/kafkaFileParser"; import { ConsumerLaunchStateProvider, getLanguageService, LanguageService, ProducerLaunchStateProvider, SelectedClusterProvider } from "./languageservice/kafkaFileLanguageService"; -import { runSafeAsync } from "./runner"; +import { runSafeAsync } from "./utils/runner"; export function startLanguageClient( clusterSettings: ClusterSettings, @@ -41,7 +41,7 @@ export function startLanguageClient( { language: "kafka", scheme: "untitled" }, { language: "kafka", scheme: "kafka" }, ]; - + // Code Lenses const codeLensProvider = new KafkaFileCodeLensProvider(kafkaFileDocuments, languageService); context.subscriptions.push( @@ -61,6 +61,10 @@ export function startLanguageClient( codeLensProvider.refresh(); }); + // Completion + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider(documentSelector, new KafkaFileCompletionItemProvider(kafkaFileDocuments, languageService))); + return { dispose() { kafkaFileDocuments.dispose(); @@ -103,7 +107,7 @@ class AbstractKafkaFileFeature { constructor( private kafkaFileDocuments: LanguageModelCache, protected readonly languageService: LanguageService - ) {} + ) { } getKafkaFileDocument(document: vscode.TextDocument): KafkaFileDocument { return this.kafkaFileDocuments.get(document); @@ -133,4 +137,23 @@ class KafkaFileCodeLensProvider extends AbstractKafkaFileFeature implements vsco refresh() { this._onDidChangeCodeLenses.fire(); } -} \ No newline at end of file +} + +class KafkaFileCompletionItemProvider extends AbstractKafkaFileFeature implements vscode.CompletionItemProvider { + + constructor( + kafkaFileDocuments: LanguageModelCache, + languageService: LanguageService + ) { + super(kafkaFileDocuments, languageService); + } + + provideCompletionItems(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, context: vscode.CompletionContext): vscode.ProviderResult { + return runSafeAsync(async () => { + const kafkaFileDocument = this.getKafkaFileDocument(document); + return this.languageService.doComplete(document, kafkaFileDocument, position); + }, new vscode.CompletionList(), `Error while computing code lenses for ${document.uri}`, token); + } + +} + diff --git a/src/kafka-file/languageservice/kafkaFileLanguageService.ts b/src/kafka-file/languageservice/kafkaFileLanguageService.ts index d7e53cd..d3fd264 100644 --- a/src/kafka-file/languageservice/kafkaFileLanguageService.ts +++ b/src/kafka-file/languageservice/kafkaFileLanguageService.ts @@ -1,8 +1,9 @@ -import { CodeLens, TextDocument, Uri } from "vscode"; +import { CodeLens, CompletionList, Position, TextDocument, Uri } from "vscode"; import { ConsumerLaunchState } from "../../client"; import { ProducerLaunchState } from "../../client/producer"; import { KafkaFileDocument, parseKafkaFile } from "./parser/kafkaFileParser"; -import { KafkaFileDocumentCodeLenses } from "./services/codeLensProvider"; +import { KafkaFileCodeLenses } from "./services/codeLensProvider"; +import { KafkaFileCompletion } from "./services/completion"; /** * Provider API which gets the state for a given producer. @@ -27,41 +28,45 @@ export interface SelectedClusterProvider { /** * Kafka language service API. - * + * */ export interface LanguageService { /** * Parse the given text document and returns an AST. - * + * * @param document the text document of a kafka file. - * + * * @returns the parsed AST. */ parseKafkaFileDocument(document: TextDocument): KafkaFileDocument; /** * Returns the code lenses for the given text document and parsed AST. - * + * * @param document the text document. * @param kafkaFileDocument the parsed AST. - * + * * @returns the code lenses. */ getCodeLenses(document: TextDocument, kafkaFileDocument: KafkaFileDocument): CodeLens[]; + + doComplete(document: TextDocument, kafkaFileDocument: KafkaFileDocument, position: Position): CompletionList | undefined } /** * Returns the Kafka file language service which manages codelens, completion, validation features for kafka file. - * + * * @param producerLaunchStateProvider the provider which gets the state for a given producer. * @param consumerLaunchStateProvider the provider which gets the state for a given consumer. - * @param selectedClusterProvider the provider which gets the selected cluster id and name. + * @param selectedClusterProvider the provider which gets the selected cluster id and name. */ export function getLanguageService(producerLaunchStateProvider: ProducerLaunchStateProvider, consumerLaunchStateProvider: ConsumerLaunchStateProvider, selectedClusterProvider: SelectedClusterProvider): LanguageService { - const kafkaFileDocumentCodeLenses = new KafkaFileDocumentCodeLenses(producerLaunchStateProvider, consumerLaunchStateProvider, selectedClusterProvider); + const kafkaFileCodeLenses = new KafkaFileCodeLenses(producerLaunchStateProvider, consumerLaunchStateProvider, selectedClusterProvider); + const kafkaFileCompletion = new KafkaFileCompletion(); return { parseKafkaFileDocument: (document: TextDocument) => parseKafkaFile(document), - getCodeLenses: kafkaFileDocumentCodeLenses.getCodeLenses.bind(kafkaFileDocumentCodeLenses) + getCodeLenses: kafkaFileCodeLenses.getCodeLenses.bind(kafkaFileCodeLenses), + doComplete: kafkaFileCompletion.doComplete.bind(kafkaFileCompletion) }; } diff --git a/src/kafka-file/languageservice/model.ts b/src/kafka-file/languageservice/model.ts new file mode 100644 index 0000000..10552aa --- /dev/null +++ b/src/kafka-file/languageservice/model.ts @@ -0,0 +1,175 @@ +export interface ModelDefinition { + name: string; + description: string; + enum?: ModelDefinition[]; +} + +export const consumerProperties = [ + { + name: "topic", + description: "The topic id *[required]*" + }, + { + name: "from", + description: "The offset from which the consumer group will start consuming messages from. Possible values are: `earliest`, `latest`, or an integer value. *[optional]*.", + enum: [ + { + name: "earliest" + }, + { + name: "last" + }, + { + name: "0" + } + ] + }, + { + name: "key-format", + description: "[Deserializer](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Consuming.md#Deserializer) to use for the key *[optional]*.", + enum: [ + { + name: "none", + description: "No deserializer (ignores content)" + }, + { + name: "string", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.StringDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/StringDeserializer.java) which currently only supports `UTF-8` encoding." + }, + { + name: "double", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.DoubleDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/DoubleDeserializer.java)." + }, + { + name: "float", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.FloatDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/FloatDeserializer.java)." + }, + { + name: "integer", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.IntegerDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/IntegerDeserializer.java)." + }, + { + name: "long", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.LongDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/LongDeserializer.java)." + }, + { + name: "short", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.ShortDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/ShortDeserializer.java)." + } + ] + }, + { + name: "value-format", + description: "[Deserializer](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Consuming.md#Deserializer) to use for the value *[optional]*.", + enum: [ + { + name: "none", + description: "No deserializer (ignores content)" + }, + { + name: "string", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.StringDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/StringDeserializer.java) which currently only supports `UTF-8` encoding." + }, + { + name: "double", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.DoubleDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/DoubleDeserializer.java)." + }, + { + name: "float", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.FloatDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/FloatDeserializer.java)." + }, + { + name: "integer", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.IntegerDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/IntegerDeserializer.java)." + }, + { + name: "long", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.LongDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/LongDeserializer.java)." + }, + { + name: "short", + description: "Similar deserializer to the Kafka Java client [org.apache.kafka.common.serialization.ShortDeserializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/ShortDeserializer.java)." + } + ] + }, + { + name: "partitions", + description: "the partition number(s), or a partitions range, or a combinaison of partitions ranges *[optional]*. eg:\n* 0\n* 0,1,2\n* 0-2\n* 0,2-3", + enum: [ + { + name: "0" + } + ] + } +] as ModelDefinition[]; + +export const producerProperties = [ + { + name: "topic", + description: "The topic id *[required]*" + }, + { + name: "key", + description: "The key *[optional]*." + }, + { + name: "key-format", + description: "[Serializer](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Producing.md#Serializer) to use for the key *[optional]*.", + enum: [ + { + name: "string", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.StringSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/StringSerializer.java) which currently only supports `UTF-8` encoding." + }, + { + name: "double", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.DoubleSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/DoubleSerializer.java)." + }, + { + name: "float", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.FloatSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/FloatSerializer.java)." + }, + { + name: "integer", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.IntegerSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/IntegerSerializer.java)." + }, + { + name: "long", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.LongSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/LongSerializer.java)." + }, + { + name: "short", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.ShortSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/ShortSerializer.java)." + } + ] + }, + { + name: "value-format", + description: "[Serializer](https://github.com/jlandersen/vscode-kafka/blob/master/docs/Producing.md#Serializer) to use for the value *[optional]*.", + enum: [ + { + name: "string", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.StringSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/StringSerializer.java) which currently only supports `UTF-8` encoding." + }, + { + name: "double", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.DoubleSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/DoubleSerializer.java)." + }, + { + name: "float", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.FloatSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/FloatSerializer.java)." + }, + { + name: "integer", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.IntegerSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/IntegerSerializer.java)." + }, + { + name: "long", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.LongSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/LongSerializer.java)." + }, + { + name: "short", + description: "Similar serializer to the Kafka Java client [org.apache.kafka.common.serialization.ShortSerializer](https://github.com/apache/kafka/blob/master/clients/src/main/java/org/apache/kafka/common/serialization/ShortSerializer.java)." + } + ] + } +] as ModelDefinition[]; diff --git a/src/kafka-file/languageservice/parser/kafkaFileParser.ts b/src/kafka-file/languageservice/parser/kafkaFileParser.ts index 86cc67c..6355789 100644 --- a/src/kafka-file/languageservice/parser/kafkaFileParser.ts +++ b/src/kafka-file/languageservice/parser/kafkaFileParser.ts @@ -1,48 +1,142 @@ import { Position, Range, TextDocument } from "vscode"; - +import { findFirst } from "../../utils/arrays"; + +export enum NodeKind { + document, + producerBlock, + producerValue, + consumerBlock, + consumerGroupId, + property, + propertyKey, + separator, + propertyValue, +} export interface Node { start: Position; end: Position; + findNodeBefore(offset: Position): Node; + lastChild: Node | undefined; + parent: Node | undefined; + kind: NodeKind; + } class BaseNode implements Node { - constructor(public readonly start: Position, public readonly end: Position) { + constructor(public readonly start: Position, public readonly end: Position, public readonly parent: Node | undefined, public readonly kind: NodeKind) { + + } + + public findNodeBefore(offset: Position): Node { + return this; + } + + public get lastChild(): Node | undefined { return undefined; } +} + +class ChildrenNode extends BaseNode { + protected readonly children: Array = []; + + public addChild(node: T) { + node.parent = this; + this.children.push(node); + } + public findNodeBefore(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)) { + if (offset.isBefore(child.end)) { + return child.findNodeBefore(offset); + } + const lastChild = child.lastChild; + if (lastChild && lastChild.end.isEqual(child.end)) { + return child.findNodeBefore(offset); + } + return child; + } + } + return this; } + + public get lastChild(): Node | undefined { return this.children.length ? this.children[this.children.length - 1] : void 0; }; } -export class KafkaFileDocument extends BaseNode { - public readonly blocks: Array = []; +export class KafkaFileDocument extends ChildrenNode { + constructor(start: Position, end: Position) { - super(start, end); + super(start, end, undefined, NodeKind.document); + } + + public get blocks(): Array { + return this.children; } } export class Chunk extends BaseNode { - constructor(public readonly content: string, start: Position, end: Position) { - super(start, end); + constructor(public readonly content: string, start: Position, end: Position, public parent: Node | undefined, kind: NodeKind) { + super(start, end, parent, kind); } } -export class Property { - - constructor(public readonly key?: Chunk, public readonly value?: Chunk) { +export class Property extends BaseNode { + constructor(parent: Block, public readonly key?: Chunk, public readonly separatorCharacter?: number, public readonly value?: Chunk) { + super(key?.start || value?.start || new Position(0, 0,), value?.end || key?.end || new Position(0, 0,), parent, NodeKind.property); + if (key) { + key.parent = this; + } + if (value) { + value.parent = this; + } } public get propertyName(): string | undefined { return this.key?.content; } + + 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.separatorCharacter ? new Position(this.start.line, this.separatorCharacter) : this.end; + return new Range(start, end); + } + + public get propertyValueRange() : Range | undefined { + if (!this.separatorCharacter) { + return; + } + const start = new Position(this.start.line, this.separatorCharacter + 1); + const end = this.end; + return new Range(start, end); + } + + isBeforeSeparator(position: Position): boolean { + if (this.separatorCharacter) { + return position.character <= this.separatorCharacter; + } + return true; + } } -export class Block extends BaseNode { +export abstract class Block extends ChildrenNode { - public readonly properties: Array = []; + constructor(public readonly type: BlockType, start: Position, end: Position, parent: KafkaFileDocument) { + super(start, end, parent, type === BlockType.consumer ? NodeKind.consumerBlock : NodeKind.producerBlock); + } - constructor(public readonly type: BlockType, start: Position, end: Position) { - super(start, end); + public get properties(): Array { + return >( + this.children + .filter(node => node.kind === NodeKind.property)); } getPropertyValue(name: string): string | undefined { @@ -58,17 +152,19 @@ export class Block extends BaseNode { export class ProducerBlock extends Block { public value: Chunk | undefined; - constructor(start: Position, end: Position) { - super(BlockType.producer, start, end); + constructor(start: Position, end: Position, parent: KafkaFileDocument) { + super(BlockType.producer, start, end, parent); } + + } export class ConsumerBlock extends Block { public consumerGroupId: Chunk | undefined; - constructor(start: Position, end: Position) { - super(BlockType.consumer, start, end); + constructor(start: Position, end: Position, parent: KafkaFileDocument) { + super(BlockType.consumer, start, end, parent); } } @@ -82,7 +178,6 @@ export function parseKafkaFile(document: TextDocument): KafkaFileDocument { const start = new Position(0, 0); const end = document.lineAt(lineCount - 1).range.end; const kafkaFileDocument = new KafkaFileDocument(start, end); - const blocks = kafkaFileDocument.blocks; // Create block PRODUCER / CONSUMER block codeLens let blockStartLine = 0; @@ -102,7 +197,7 @@ export function parseKafkaFile(document: TextDocument): KafkaFileDocument { // A PRODUCER / CONSUMER block is parsing, check if it's the end of the block if (isEndBlock(lineText, currentBlockType)) { blockEndLine = currentLine - 1; - blocks.push(createBlock(blockStartLine, blockEndLine, document, currentBlockType)); + kafkaFileDocument.addChild(createBlock(blockStartLine, blockEndLine, document, currentBlockType, kafkaFileDocument)); if (currentBlockType === BlockType.consumer) { currentBlockType = getBlockType(lineText); if (currentBlockType !== undefined) { @@ -117,7 +212,7 @@ export function parseKafkaFile(document: TextDocument): KafkaFileDocument { } if (currentBlockType !== undefined) { - blocks.push(createBlock(blockStartLine, document.lineCount - 1, document, currentBlockType)); + kafkaFileDocument.addChild(createBlock(blockStartLine, document.lineCount - 1, document, currentBlockType, kafkaFileDocument)); } return kafkaFileDocument; @@ -142,15 +237,15 @@ function isSeparator(lineText: string): boolean { return lineText.startsWith("###"); } -function createBlock(blockStartLine: number, blockEndLine: number, document: TextDocument, blockType: BlockType): Block { +function createBlock(blockStartLine: number, blockEndLine: number, document: TextDocument, blockType: BlockType, parent: KafkaFileDocument): Block { const start = new Position(blockStartLine, 0); const end = document.lineAt(blockEndLine).range.end; if (blockType === BlockType.consumer) { - const block = new ConsumerBlock(start, end); + const block = new ConsumerBlock(start, end, parent); parseConsumerBlock(block, document); return block; } - const block = new ProducerBlock(start, end); + const block = new ProducerBlock(start, end, parent); parseProducerBlock(block, document); return block; } @@ -166,29 +261,30 @@ function parseProducerBlock(block: ProducerBlock, document: TextDocument) { if (startsWith(lineText, ["topic:", "key:", "key-format:", "value-format:"])) { // Known properties - block.properties.push(createProperty(lineText, currentLine)); + block.addChild(createProperty(lineText, currentLine, block)); continue; } // The rest of the content is the value - const startValue = new Position(currentLine,0); + const startValue = new Position(currentLine, 0); const endValue = new Position(block.end.line + 1, 0); const contentValue = document.getText(new Range(startValue, endValue)).trim(); - block.value = new Chunk(contentValue, startValue, endValue); + block.value = new Chunk(contentValue, startValue, endValue, block, NodeKind.producerValue); + block.addChild(new Chunk(contentValue, startValue, endValue, block, NodeKind.producerValue)); break; } } -function startsWith(lineText: string, searchStrings:string[]) : boolean { +function startsWith(lineText: string, searchStrings: string[]): boolean { for (let i = 0; i < searchStrings.length; i++) { if (lineText.startsWith(searchStrings[i])) { return true; } } - return false; + return false; } -function isIgnoreLine(lineText : string) : boolean{ +function isIgnoreLine(lineText: string): boolean { return lineText.startsWith("--") || lineText.trim().length === 0; } @@ -200,7 +296,7 @@ function parseConsumerBlock(block: ConsumerBlock, document: TextDocument) { const start = "CONSUMER".length; const end = lineText.length; const content = lineText.substr(start).trim(); - const consumerGroupId = new Chunk(content, new Position(currentLine, start), new Position(currentLine, end)); + const consumerGroupId = new Chunk(content, new Position(currentLine, start), new Position(currentLine, end), block, NodeKind.consumerGroupId); block.consumerGroupId = consumerGroupId; continue; } @@ -210,13 +306,14 @@ function parseConsumerBlock(block: ConsumerBlock, document: TextDocument) { continue; } // Add the line content as property (ex : topic: MY_TOPIC) - block.properties.push(createProperty(lineText, currentLine)); + block.addChild(createProperty(lineText, currentLine, block)); } } -function createProperty(lineText: string, lineNumber: number): Property { +function createProperty(lineText: string, lineNumber: number, parent: Block): Property { let propertyKey: Chunk | undefined = undefined; let propertyValue: Chunk | undefined = undefined; + let separatorCharacter = undefined; let withinValue = false; let start = -1; for (let i = 0; i < lineText.length; i++) { @@ -229,11 +326,12 @@ function createProperty(lineText: string, lineNumber: number): Property { if (!withinValue) { if (start !== -1) { const end = i; - const content = lineText.substr(start, end); - propertyKey = new Chunk(content, new Position(lineNumber, start), new Position(lineNumber, end)); + const content = lineText.substr(start, end ); + propertyKey = new Chunk(content, new Position(lineNumber, start), new Position(lineNumber, end), undefined, NodeKind.propertyKey); } + separatorCharacter = i; withinValue = true; - start = -1; + start = i + 1; } } else { if (start === -1) { @@ -244,7 +342,11 @@ function createProperty(lineText: string, lineNumber: number): Property { if (start !== -1) { const end = lineText.length; const content = lineText.substr(start, end).trim(); - propertyValue = new Chunk(content, new Position(lineNumber, start), new Position(lineNumber, end)); + if (withinValue) { + propertyValue = new Chunk(content, new Position(lineNumber, start), new Position(lineNumber, end), undefined, NodeKind.propertyValue); + } else { + propertyKey = new Chunk(content, new Position(lineNumber, start), new Position(lineNumber, end), undefined, NodeKind.propertyKey); + } } - return new Property(propertyKey, propertyValue); + return new Property(parent, propertyKey, separatorCharacter, propertyValue); } diff --git a/src/kafka-file/languageservice/services/codeLensProvider.ts b/src/kafka-file/languageservice/services/codeLensProvider.ts index 7adb99d..4685349 100644 --- a/src/kafka-file/languageservice/services/codeLensProvider.ts +++ b/src/kafka-file/languageservice/services/codeLensProvider.ts @@ -5,7 +5,7 @@ import { LaunchConsumerCommand, ProduceRecordCommand, ProduceRecordCommandHandle import { ProducerLaunchStateProvider, ConsumerLaunchStateProvider, SelectedClusterProvider } from "../kafkaFileLanguageService"; import { Block, BlockType, ConsumerBlock, KafkaFileDocument, ProducerBlock } from "../parser/kafkaFileParser"; -export class KafkaFileDocumentCodeLenses { +export class KafkaFileCodeLenses { constructor(private producerLaunchStateProvider: ProducerLaunchStateProvider, private consumerLaunchStateProvider: ConsumerLaunchStateProvider, private selectedClusterProvider: SelectedClusterProvider) { diff --git a/src/kafka-file/languageservice/services/completion.ts b/src/kafka-file/languageservice/services/completion.ts new file mode 100644 index 0000000..5ecb863 --- /dev/null +++ b/src/kafka-file/languageservice/services/completion.ts @@ -0,0 +1,172 @@ +import { TextDocument, Position, CompletionList, CompletionItem, SnippetString, MarkdownString, CompletionItemKind, Range } from "vscode"; +import { consumerProperties, ModelDefinition, producerProperties } from "../model"; +import { Block, BlockType, Chunk, ConsumerBlock, KafkaFileDocument, NodeKind, ProducerBlock, Property } from "../parser/kafkaFileParser"; + +export class KafkaFileCompletion { + + doComplete(document: TextDocument, kafkaFileDocument: KafkaFileDocument, position: Position): CompletionList | undefined { + const node = kafkaFileDocument.findNodeBefore(position); + if (!node) { + return; + } + + const items: Array = []; + switch (node.kind) { + case NodeKind.consumerBlock: { + if (node.start.line !== position.line) { + // CONSUMER + // | + const lineRange = document.lineAt(position.line).range; + this.collectConsumerPropertyNames(undefined, lineRange, node, items); + } + } + break; + case NodeKind.producerBlock: { + if (node.start.line !== position.line) { + // PRODUCER + // | + const lineRange = document.lineAt(position.line).range; + this.collectProducerPropertyNames(undefined, lineRange, node, items); + } + } + break; + case NodeKind.producerValue: { + // Check if previous line is a property + const previous = new Position(position.line - 1, 1); + const node = kafkaFileDocument.findNodeBefore(previous); + if (node && node.kind !== NodeKind.producerValue) { + const lineRange = document.lineAt(position.line).range; + const block = (node.kind === NodeKind.producerBlock) ? node : node.parent; + this.collectProducerPropertyNames(undefined, lineRange, block, items); + } + } + break; + case NodeKind.propertyKey: { + const propertyKey = node; + const block = propertyKey.parent; + const lineRange = document.lineAt(position.line).range; + const propertyName = propertyKey.content; + if (block.type === BlockType.consumer) { + this.collectConsumerPropertyNames(propertyName, lineRange, block, items); + } else { + this.collectProducerPropertyNames(propertyName, lineRange, block, items); + } + } + case NodeKind.propertyValue: { + const propertyValue = node; + const property = propertyValue.parent; + const block = propertyValue.parent; + if (block.type === BlockType.consumer) { + this.collectConsumerPropertyValues(propertyValue, property, block, items); + } else { + this.collectProducerPropertyValues(propertyValue, property, block, items); + } + } + case NodeKind.property: { + const property = node; + const block = property.parent; + if (property.isBeforeSeparator(position)) { + const propertyName = position.line === property.start.line ? property.propertyName : undefined; + const lineRange = document.lineAt(position.line).range; + if (block.type === BlockType.consumer) { + this.collectConsumerPropertyNames(propertyName, lineRange, block, items); + } else { + this.collectProducerPropertyNames(propertyName, lineRange, block, items); + } + } else { + const propertyValue = property.value; + const block = property.parent; + if (block.type === BlockType.consumer) { + this.collectConsumerPropertyValues(propertyValue, property, block, items); + } else { + this.collectProducerPropertyValues(propertyValue, property, block, items); + } + } + } + break; + } + return new CompletionList(items, true); + } + + collectConsumerPropertyNames(propertyName: string | undefined, lineRange: Range, block: ConsumerBlock, items: Array) { + this.collectPropertyNames(propertyName, lineRange, block, consumerProperties, items); + } + + collectProducerPropertyNames(propertyName: string | undefined,lineRange: Range,block: ProducerBlock, items: Array) { + this.collectPropertyNames(propertyName, lineRange, block, producerProperties, items); + } + + collectPropertyNames(propertyName: string | undefined, lineRange : Range, block: Block, metadata: ModelDefinition[], items: Array) { + const existingProperties = block.properties + .filter(property => property.key) + .map(property => property.key?.content); + metadata.forEach((definition) => { + const currentName = definition.name; + if (existingProperties.indexOf(currentName) === -1 || propertyName === currentName) { + const item = new CompletionItem(currentName); + item.kind = CompletionItemKind.Property; + if (definition.description) { + item.documentation = new MarkdownString(definition.description); + } + const insertText = new SnippetString(`${currentName}: `); + if (definition.enum) { + insertText.appendChoice(definition.enum.map(item => item.name)); + } else { + insertText.appendPlaceholder(currentName); + } + item.insertText = insertText; + item.range = lineRange; + items.push(item); + } + }); + } + + collectConsumerPropertyValues(propertyValue: Chunk | undefined, property: Property, block: ConsumerBlock, items: Array) { + const propertyName = property.propertyName; + switch (propertyName) { + case 'topic': + + break; + + default: + this.collectPropertyValues(propertyValue, property, block, consumerProperties, items); + break; + } + } + + collectProducerPropertyValues(propertyValue: Chunk | undefined, property: Property, block: ProducerBlock, items: Array) { + const propertyName = property.propertyName; + switch (propertyName) { + case 'topic': + + break; + + default: + this.collectPropertyValues(propertyValue, property, block, producerProperties, items); + break; + } + } + + collectPropertyValues(propertyValue: Chunk | undefined, property: Property, block: Block, metadata: ModelDefinition[], items: Array) { + const propertyName = property.propertyName; + const definition = metadata.find(definition => definition.name === propertyName); + if (!definition || !definition.enum) { + return; + } + + definition.enum.forEach((definition) => { + const value = definition.name; + const item = new CompletionItem(value); + item.kind = CompletionItemKind.Value; + if (definition.description) { + item.documentation = new MarkdownString(definition.description); + } + + const insertText = new SnippetString(' '); + insertText.appendText(value); + item.insertText = insertText; + item.range = property.propertyValueRange; + items.push(item); + }); + } +} diff --git a/src/kafka-file/utils/arrays.ts b/src/kafka-file/utils/arrays.ts new file mode 100644 index 0000000..1c23e0c --- /dev/null +++ b/src/kafka-file/utils/arrays.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Takes a sorted array and a function p. The array is sorted in such a way that all elements where p(x) is false + * are located before all elements where p(x) is true. + * @returns the least x for which p(x) is true or array.length if no element fullfills the given function. + */ + export function findFirst(array: T[], p: (x: T) => boolean): number { + let low = 0, high = array.length; + if (high === 0) { + return 0; // no children + } + while (low < high) { + let mid = Math.floor((low + high) / 2); + if (p(array[mid])) { + high = mid; + } else { + low = mid + 1; + } + } + return low; +} + +export function binarySearch(array: T[], key: T, comparator: (op1: T, op2: T) => number): number { + let low = 0, + high = array.length - 1; + + while (low <= high) { + const mid = ((low + high) / 2) | 0; + const comp = comparator(array[mid], key); + if (comp < 0) { + low = mid + 1; + } else if (comp > 0) { + high = mid - 1; + } else { + return mid; + } + } + return -(low + 1); +} diff --git a/src/kafka-file/runner.ts b/src/kafka-file/utils/runner.ts similarity index 100% rename from src/kafka-file/runner.ts rename to src/kafka-file/utils/runner.ts diff --git a/src/test/suite/kafka-file/languageservice/completion.test.ts b/src/test/suite/kafka-file/languageservice/completion.test.ts new file mode 100644 index 0000000..43a319b --- /dev/null +++ b/src/test/suite/kafka-file/languageservice/completion.test.ts @@ -0,0 +1,889 @@ +import { CompletionItemKind } from "vscode"; +import { position, range, testCompletion } from "./kafkaAssert"; + +suite("Kafka File Completion Test Suite", () => { + + test("Empty completion", async () => { + await testCompletion('', { + items: [] + }); + + await testCompletion('ab|cd', { + items: [] + }); + + await testCompletion('CONSU|UMER', { + items: [] + }); + + await testCompletion('PROD|UCER', { + items: [] + }); + }); + +}); + +suite("Kafka File CONSUMER Completion Test Suite", () => { + + test("CONSUMER property names (empty line)", async () => { + await testCompletion( + 'CONSUMER a\n' + + '|' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 0)) + }, + { + label: 'from', kind: CompletionItemKind.Property, + insertText: 'from: ${1|earliest,last,0|}', + range: range(position(1, 0), position(1, 0)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 0)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 0)) + }, + { + label: 'partitions', kind: CompletionItemKind.Property, + insertText: 'partitions: ${1|0|}', + range: range(position(1, 0), position(1, 0)) + } + ] + }); + }); + + test("CONSUMER property names (property key) 1", async () => { + + await testCompletion( + 'CONSUMER a\n' + + 't|' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 1)) + }, + { + label: 'from', kind: CompletionItemKind.Property, + insertText: 'from: ${1|earliest,last,0|}', + range: range(position(1, 0), position(1, 1)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 1)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 1)) + }, + { + label: 'partitions', kind: CompletionItemKind.Property, + insertText: 'partitions: ${1|0|}', + range: range(position(1, 0), position(1, 1)) + } + ] + }); + }); + + test("CONSUMER property names (property key) 2", async () => { + await testCompletion( + 'CONSUMER a\n' + + 't|opic' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 5)) + }, + { + label: 'from', kind: CompletionItemKind.Property, + insertText: 'from: ${1|earliest,last,0|}', + range: range(position(1, 0), position(1, 5)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 5)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 5)) + }, + { + label: 'partitions', kind: CompletionItemKind.Property, + insertText: 'partitions: ${1|0|}', + range: range(position(1, 0), position(1, 5)) + } + ] + }); + }); + + test("CONSUMER property names (property key) 3", async () => { + await testCompletion( + 'CONSUMER a\n' + + 't|opic:' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'from', kind: CompletionItemKind.Property, + insertText: 'from: ${1|earliest,last,0|}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'partitions', kind: CompletionItemKind.Property, + insertText: 'partitions: ${1|0|}', + range: range(position(1, 0), position(1, 6)) + } + ] + }); + }); + + test("CONSUMER property names (property key) 4", async () => { + await testCompletion( + 'CONSUMER a\n' + + 'topic|:' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'from', kind: CompletionItemKind.Property, + insertText: 'from: ${1|earliest,last,0|}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'partitions', kind: CompletionItemKind.Property, + insertText: 'partitions: ${1|0|}', + range: range(position(1, 0), position(1, 6)) + } + ] + }); + }); + + test("CONSUMER property names (property key) 5", async () => { + await testCompletion( + 'CONSUMER a\n' + + 't|opic: abcd' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 11)) + }, + { + label: 'from', kind: CompletionItemKind.Property, + insertText: 'from: ${1|earliest,last,0|}', + range: range(position(1, 0), position(1, 11)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 11)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 11)) + }, + { + label: 'partitions', kind: CompletionItemKind.Property, + insertText: 'partitions: ${1|0|}', + range: range(position(1, 0), position(1, 11)) + } + ] + }); + }); + + test("CONSUMER property names (property key) 6", async () => { + await testCompletion( + 'CONSUMER a\n' + + 'from: 0\n' + + 't|opic' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(2, 0), position(2, 5)) + }, + /* 'from' is removed from completion because it is declared in the CONSUMER + { + label: 'from', kind: CompletionItemKind.Property, + insertText: 'from: ${1|earliest,last,0|}', + range: range(position(2, 0), position(2, 5)) + },*/ + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(2, 0), position(2, 5)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|none,string,double,float,integer,long,short|}', + range: range(position(2, 0), position(2, 5)) + }, + { + label: 'partitions', kind: CompletionItemKind.Property, + insertText: 'partitions: ${1|0|}', + range: range(position(2, 0), position(2, 5)) + } + ] + }); + }); + + test("CONSUMER property value for from 1", async () => { + await testCompletion( + 'CONSUMER a\n' + + 'from:|' + , { + items: [ + { + label: 'earliest', kind: CompletionItemKind.Value, + insertText: ' earliest', + range: range(position(1, 5), position(1, 5)) + }, + { + label: 'last', kind: CompletionItemKind.Value, + insertText: ' last', + range: range(position(1, 5), position(1, 5)) + }, + { + label: '0', kind: CompletionItemKind.Value, + insertText: ' 0', + range: range(position(1, 5), position(1, 5)) + } + ] + }); + }); + + test("CONSUMER property value for from 2", async () => { + await testCompletion( + 'CONSUMER a\n' + + 'from:e|a' + , { + items: [ + { + label: 'earliest', kind: CompletionItemKind.Value, + insertText: ' earliest', + range: range(position(1, 5), position(1, 7)) + }, + { + label: 'last', kind: CompletionItemKind.Value, + insertText: ' last', + range: range(position(1, 5), position(1, 7)) + }, + { + label: '0', kind: CompletionItemKind.Value, + insertText: ' 0', + range: range(position(1, 5), position(1, 7)) + } + ] + }); + }); + + test("CONSUMER property value for from 3", async () => { + await testCompletion( + 'CONSUMER a\n' + + 'topic: abcd\n' + + 'from:e|a' + , { + items: [ + { + label: 'earliest', kind: CompletionItemKind.Value, + insertText: ' earliest', + range: range(position(2, 5), position(2, 7)) + }, + { + label: 'last', kind: CompletionItemKind.Value, + insertText: ' last', + range: range(position(2, 5), position(2, 7)) + }, + { + label: '0', kind: CompletionItemKind.Value, + insertText: ' 0', + range: range(position(2, 5), position(2, 7)) + } + ] + }); + }); + + test("CONSUMER property value for from 4", async () => { + await testCompletion( + 'CONSUMER a\n' + + 'topic: abcd\n' + + 'from:e|a\n' + + 'key-format: long' + , { + items: [ + { + label: 'earliest', kind: CompletionItemKind.Value, + insertText: ' earliest', + range: range(position(2, 5), position(2, 7)) + }, + { + label: 'last', kind: CompletionItemKind.Value, + insertText: ' last', + range: range(position(2, 5), position(2, 7)) + }, + { + label: '0', kind: CompletionItemKind.Value, + insertText: ' 0', + range: range(position(2, 5), position(2, 7)) + } + ] + }); + }); + + test("CONSUMER property value for key-format", async () => { + await testCompletion( + 'CONSUMER a\n' + + 'key-format:|' + , { + items: [ + { + label: 'none', kind: CompletionItemKind.Value, + insertText: ' none', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'string', kind: CompletionItemKind.Value, + insertText: ' string', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'double', kind: CompletionItemKind.Value, + insertText: ' double', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'float', kind: CompletionItemKind.Value, + insertText: ' float', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'integer', kind: CompletionItemKind.Value, + insertText: ' integer', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'long', kind: CompletionItemKind.Value, + insertText: ' long', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'short', kind: CompletionItemKind.Value, + insertText: ' short', + range: range(position(1, 11), position(1, 11)) + } + ] + }); + }); + + test("CONSUMER property value for value-format", async () => { + await testCompletion( + 'CONSUMER a\n' + + 'value-format:|' + , { + items: [ + { + label: 'none', kind: CompletionItemKind.Value, + insertText: ' none', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'string', kind: CompletionItemKind.Value, + insertText: ' string', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'double', kind: CompletionItemKind.Value, + insertText: ' double', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'float', kind: CompletionItemKind.Value, + insertText: ' float', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'integer', kind: CompletionItemKind.Value, + insertText: ' integer', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'long', kind: CompletionItemKind.Value, + insertText: ' long', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'short', kind: CompletionItemKind.Value, + insertText: ' short', + range: range(position(1, 13), position(1, 13)) + } + ] + }); + }); + +}); + +suite("Kafka File PRODUCER Completion Test Suite", () => { + + test("PRODUCER property names (empty line)", async () => { + await testCompletion( + 'PRODUCER a\n' + + '|' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 0)) + }, + { + label: 'key', kind: CompletionItemKind.Property, + insertText: 'key: ${1:key}', + range: range(position(1, 0), position(1, 0)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 0)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 0)) + } + ] + }); + }); + + test("PRODUCER property names (property key) 1", async () => { + + await testCompletion( + 'PRODUCER a\n' + + 't|' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 1)) + }, + { + label: 'key', kind: CompletionItemKind.Property, + insertText: 'key: ${1:key}', + range: range(position(1, 0), position(1, 1)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 1)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 1)) + } + ] + }); + }); + + test("PRODUCER property names (property key) 2", async () => { + await testCompletion( + 'PRODUCER a\n' + + 't|opic' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 5)) + }, + { + label: 'key', kind: CompletionItemKind.Property, + insertText: 'key: ${1:key}', + range: range(position(1, 0), position(1, 5)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 5)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 5)) + } + ] + }); + }); + + test("PRODUCER property names (property key) 3", async () => { + await testCompletion( + 'PRODUCER a\n' + + 't|opic:' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'key', kind: CompletionItemKind.Property, + insertText: 'key: ${1:key}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 6)) + } + ] + }); + }); + + test("PRODUCER property names (property key) 4", async () => { + await testCompletion( + 'PRODUCER a\n' + + 'topic|:' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'key', kind: CompletionItemKind.Property, + insertText: 'key: ${1:key}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 6)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 6)) + } + ] + }); + }); + + test("PRODUCER property names (property key) 5", async () => { + await testCompletion( + 'PRODUCER a\n' + + 't|opic: abcd' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(1, 0), position(1, 11)) + }, + { + label: 'key', kind: CompletionItemKind.Property, + insertText: 'key: ${1:key}', + range: range(position(1, 0), position(1, 11)) + }, + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 11)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|string,double,float,integer,long,short|}', + range: range(position(1, 0), position(1, 11)) + } + ] + }); + }); + + test("PRODUCER property names (property key) 6", async () => { + await testCompletion( + 'PRODUCER a\n' + + 'key: abcd\n' + + 't|opic' + , { + items: [ + { + label: 'topic', kind: CompletionItemKind.Property, + insertText: 'topic: ${1:topic}', + range: range(position(2, 0), position(2, 5)) + }, + /* 'key' is removed from completion because it is declared in the PRODUCER + { + label: 'key', kind: CompletionItemKind.Property, + insertText: 'key: ${1:key}', + range: range(position(2, 0), position(2, 5)) + },*/ + { + label: 'key-format', kind: CompletionItemKind.Property, + insertText: 'key-format: ${1|string,double,float,integer,long,short|}', + range: range(position(2, 0), position(2, 5)) + }, + { + label: 'value-format', kind: CompletionItemKind.Property, + insertText: 'value-format: ${1|string,double,float,integer,long,short|}', + range: range(position(2, 0), position(2, 5)) + } + ] + }); + }); + + test("PRODUCER property value for key-format 1", async () => { + await testCompletion( + 'PRODUCER a\n' + + 'key-format:|' + , { + items: [ + { + label: 'string', kind: CompletionItemKind.Value, + insertText: ' string', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'double', kind: CompletionItemKind.Value, + insertText: ' double', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'float', kind: CompletionItemKind.Value, + insertText: ' float', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'integer', kind: CompletionItemKind.Value, + insertText: ' integer', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'long', kind: CompletionItemKind.Value, + insertText: ' long', + range: range(position(1, 11), position(1, 11)) + }, + { + label: 'short', kind: CompletionItemKind.Value, + insertText: ' short', + range: range(position(1, 11), position(1, 11)) + } + ] + }); + }); + + test("PRODUCER property value for key-format 2", async () => { + await testCompletion( + 'PRODUCER a\n' + + 'key-format:s|t' + , { + items: [ + { + label: 'string', kind: CompletionItemKind.Value, + insertText: ' string', + range: range(position(1, 11), position(1, 13)) + }, + { + label: 'double', kind: CompletionItemKind.Value, + insertText: ' double', + range: range(position(1, 11), position(1, 13)) + }, + { + label: 'float', kind: CompletionItemKind.Value, + insertText: ' float', + range: range(position(1, 11), position(1, 13)) + }, + { + label: 'integer', kind: CompletionItemKind.Value, + insertText: ' integer', + range: range(position(1, 11), position(1, 13)) + }, + { + label: 'long', kind: CompletionItemKind.Value, + insertText: ' long', + range: range(position(1, 11), position(1, 13)) + }, + { + label: 'short', kind: CompletionItemKind.Value, + insertText: ' short', + range: range(position(1, 11), position(1, 13)) + } + ] + }); + }); + + test("PRODUCER property value for key-format 3", async () => { + await testCompletion( + 'PRODUCER a\n' + + 'topic: abcd\n' + + 'key-format:s|t' + , { + items: [ + { + label: 'string', kind: CompletionItemKind.Value, + insertText: ' string', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'double', kind: CompletionItemKind.Value, + insertText: ' double', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'float', kind: CompletionItemKind.Value, + insertText: ' float', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'integer', kind: CompletionItemKind.Value, + insertText: ' integer', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'long', kind: CompletionItemKind.Value, + insertText: ' long', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'short', kind: CompletionItemKind.Value, + insertText: ' short', + range: range(position(2, 11), position(2, 13)) + } + ] + }); + }); + + test("PRODUCER property value for key-format 4", async () => { + await testCompletion( + 'PRODUCER a\n' + + 'topic: abcd\n' + + 'key-format:s|t\n' + + 'value-format: long' + , { + items: [ + { + label: 'string', kind: CompletionItemKind.Value, + insertText: ' string', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'double', kind: CompletionItemKind.Value, + insertText: ' double', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'float', kind: CompletionItemKind.Value, + insertText: ' float', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'integer', kind: CompletionItemKind.Value, + insertText: ' integer', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'long', kind: CompletionItemKind.Value, + insertText: ' long', + range: range(position(2, 11), position(2, 13)) + }, + { + label: 'short', kind: CompletionItemKind.Value, + insertText: ' short', + range: range(position(2, 11), position(2, 13)) + } + ] + }); + }); + + test("PRODUCER property value for value-format", async () => { + await testCompletion( + 'PRODUCER a\n' + + 'value-format:|' + , { + items: [ + { + label: 'string', kind: CompletionItemKind.Value, + insertText: ' string', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'double', kind: CompletionItemKind.Value, + insertText: ' double', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'float', kind: CompletionItemKind.Value, + insertText: ' float', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'integer', kind: CompletionItemKind.Value, + insertText: ' integer', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'long', kind: CompletionItemKind.Value, + insertText: ' long', + range: range(position(1, 13), position(1, 13)) + }, + { + label: 'short', kind: CompletionItemKind.Value, + insertText: ' short', + range: range(position(1, 13), position(1, 13)) + } + ] + }); + }); + +}); diff --git a/src/test/suite/kafka-file/languageservice/kafkaAssert.ts b/src/test/suite/kafka-file/languageservice/kafkaAssert.ts index 3426650..16c8520 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 } from "vscode"; +import { CodeLens, Position, Range, Command, Uri, workspace, CompletionList, SnippetString } from "vscode"; import { ConsumerLaunchState } from "../../../../client"; import { ProducerLaunchState } from "../../../../client/producer"; import { ConsumerLaunchStateProvider, getLanguageService, LanguageService, ProducerLaunchStateProvider, SelectedClusterProvider } from "../../../../kafka-file/languageservice/kafkaFileLanguageService"; @@ -67,11 +67,15 @@ export function position(startLine: number, startCharacter: number): Position { return new Position(startLine, startCharacter); } +export function range(start: Position, end: Position): Range { + return new Range(start, end); +} + // Code Lens assert export function codeLens(start: Position, end: Position, command: Command): CodeLens { - const range = new Range(start, end); - return new CodeLens(range, command); + const r = range(start, end); + return new CodeLens(r, command); } export async function assertCodeLens(content: string, expected: Array, languageService: LanguageService) { let document = await getDocument(content); @@ -80,6 +84,40 @@ export async function assertCodeLens(content: string, expected: Array, assert.deepStrictEqual(actual, expected); } +// Completion assert + +export async function testCompletion(value: string, expected: CompletionList) { + const offset = value.indexOf('|'); + value = value.substr(0, offset) + value.substr(offset + 1); + + let document = await getDocument(value); + const position = document.positionAt(offset); + let ast = languageService.parseKafkaFileDocument(document); + const list = languageService.doComplete(document, ast, position); + const items = list?.items; + + // no duplicate labels + const labels = items?.map(i => i.label).sort(); + let previous = null; + if (labels) { + for (const label of labels) { + assert.ok(previous !== label, `Duplicate label ${label} in ${labels.join(',')}`); + previous = label; + } + } + + if (items) { + assert.deepStrictEqual(items.length, expected.items.length); + expected.items.forEach((expectedItem, i) => { + const actualItem = items[i]; + assert.deepStrictEqual(actualItem.label, expectedItem.label); + assert.deepStrictEqual(actualItem.kind, expectedItem.kind); + assert.deepStrictEqual((actualItem.insertText)?.value, expectedItem.insertText); + assert.deepStrictEqual(actualItem.range, expectedItem.range); + }); + } +} + // Kafka parser assert export interface ExpectedChunckResult { @@ -142,4 +180,4 @@ export async function assertParseBlock(content: string, expected: Array