diff --git a/.github/workflows/agent-bindings.yml b/.github/workflows/agent-bindings.yml new file mode 100644 index 000000000000..7a15a792d99d --- /dev/null +++ b/.github/workflows/agent-bindings.yml @@ -0,0 +1,38 @@ +# Please post in #team-cody-clients if you need help getting this CI check to pass. +# Worst-case: feel free to disable this workflow here https://github.com/sourcegraph/cody/actions/workflows/agent-bindings.yml +name: agent-bindings +on: + + pull_request: + paths: + - '**.ts' + - '**.tsx' + - '**.js' + +jobs: + kotlin: + if: github.repository == 'sourcegraph/cody' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # SECURITY: pin third-party action hashes + id: pnpm-install + with: + version: 8.6.7 + run_install: false + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + - run: pnpm install --frozen-lockfile + - run: pnpm generate-agent-kotlin-bindings + - run: ./agent/scripts/error-if-diff.sh + - run: ./agent/scripts/compile-bindings-if-diff.sh diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyAgentServer.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyAgentServer.kt index b8140ed8af07..ac8dfb8710a5 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyAgentServer.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyAgentServer.kt @@ -120,10 +120,12 @@ interface CodyAgentServer { fun testing_autocomplete_awaitPendingVisibilityTimeout(params: Null?): CompletableFuture @JsonRequest("testing/autocomplete/setCompletionVisibilityDelay") fun testing_autocomplete_setCompletionVisibilityDelay(params: Testing_Autocomplete_SetCompletionVisibilityDelayParams): CompletableFuture + @JsonRequest("testing/autocomplete/providerConfig") + fun testing_autocomplete_providerConfig(params: Null?): CompletableFuture @JsonRequest("extensionConfiguration/change") - fun extensionConfiguration_change(params: ExtensionConfiguration): CompletableFuture + fun extensionConfiguration_change(params: ExtensionConfiguration): CompletableFuture @JsonRequest("extensionConfiguration/status") - fun extensionConfiguration_status(params: Null?): CompletableFuture + fun extensionConfiguration_status(params: Null?): CompletableFuture @JsonRequest("extensionConfiguration/getSettingsSchema") fun extensionConfiguration_getSettingsSchema(params: Null?): CompletableFuture @JsonRequest("textDocument/change") diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyLLMSiteConfiguration.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyLLMSiteConfiguration.kt deleted file mode 100644 index 022766952071..000000000000 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyLLMSiteConfiguration.kt +++ /dev/null @@ -1,14 +0,0 @@ -@file:Suppress("FunctionName", "ClassName", "unused", "EnumEntryName", "UnusedImport") -package com.sourcegraph.cody.agent.protocol_generated; - -data class CodyLLMSiteConfiguration( - val chatModel: String? = null, - val chatModelMaxTokens: Long? = null, - val fastChatModel: String? = null, - val fastChatModelMaxTokens: Long? = null, - val completionModel: String? = null, - val completionModelMaxTokens: Long? = null, - val provider: String? = null, - val smartContextWindow: Boolean? = null, -) - diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Constants.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Constants.kt index 105da2e2c31e..94da7f958133 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Constants.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Constants.kt @@ -16,6 +16,7 @@ object Constants { const val agentic = "agentic" const val ask = "ask" const val assistant = "assistant" + const val authenticated = "authenticated" const val autocomplete = "autocomplete" const val balanced = "balanced" const val byok = "byok" @@ -41,7 +42,6 @@ object Constants { const val function = "function" const val gateway = "gateway" const val history = "history" - const val `https-example-com` = "https://example.com" const val human = "human" const val ignore = "ignore" const val indentation = "indentation" @@ -84,6 +84,7 @@ object Constants { const val terminal = "terminal" const val tree = "tree" const val `tree-sitter` = "tree-sitter" + const val unauthenticated = "unauthenticated" const val unified = "unified" const val use = "use" const val user = "user" diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Chat_RestoreParams.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/OrganizationsParams.kt similarity index 53% rename from agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Chat_RestoreParams.kt rename to agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/OrganizationsParams.kt index 671e79f878e9..eb6e2500cc5e 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Chat_RestoreParams.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/OrganizationsParams.kt @@ -1,9 +1,8 @@ @file:Suppress("FunctionName", "ClassName", "unused", "EnumEntryName", "UnusedImport") package com.sourcegraph.cody.agent.protocol_generated; -data class Chat_RestoreParams( - val modelID: String? = null, - val messages: List, - val chatID: String, +data class OrganizationsParams( + val name: String, + val id: String, ) diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/AuthStatus.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ProtocolAuthStatus.kt similarity index 51% rename from agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/AuthStatus.kt rename to agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ProtocolAuthStatus.kt index 62476234c0be..2baeee0e0917 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/AuthStatus.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ProtocolAuthStatus.kt @@ -8,31 +8,23 @@ import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import java.lang.reflect.Type; -sealed class AuthStatus { +sealed class ProtocolAuthStatus { companion object { - val deserializer: JsonDeserializer = + val deserializer: JsonDeserializer = JsonDeserializer { element: JsonElement, _: Type, context: JsonDeserializationContext -> - if (element.getAsJsonObject().get("username") == null) { - context.deserialize(element, UnauthenticatedAuthStatus::class.java) - } else { - context.deserialize(element, AuthenticatedAuthStatus::class.java) - } + when (element.getAsJsonObject().get("status").getAsString()) { + "authenticated" -> context.deserialize(element, ProtocolAuthenticatedAuthStatus::class.java) + "unauthenticated" -> context.deserialize(element, ProtocolUnauthenticatedAuthStatus::class.java) + else -> throw Exception("Unknown discriminator ${element}") + } } } } -data class UnauthenticatedAuthStatus( - val endpoint: String, +data class ProtocolAuthenticatedAuthStatus( + val status: StatusEnum, // Oneof: authenticated val authenticated: Boolean, - val showNetworkError: Boolean? = null, - val showInvalidAccessTokenError: Boolean? = null, - val pendingValidation: Boolean, -) : AuthStatus() { -} - -data class AuthenticatedAuthStatus( val endpoint: String, - val authenticated: Boolean, val username: String, val isFireworksTracingEnabled: Boolean? = null, val hasVerifiedEmail: Boolean? = null, @@ -41,6 +33,25 @@ data class AuthenticatedAuthStatus( val displayName: String? = null, val avatarURL: String? = null, val pendingValidation: Boolean, -) : AuthStatus() { + val organizations: List? = null, +) : ProtocolAuthStatus() { + + enum class StatusEnum { + @SerializedName("authenticated") Authenticated, + } +} + +data class ProtocolUnauthenticatedAuthStatus( + val status: StatusEnum, // Oneof: unauthenticated + val authenticated: Boolean, + val endpoint: String, + val showNetworkError: Boolean? = null, + val showInvalidAccessTokenError: Boolean? = null, + val pendingValidation: Boolean, +) : ProtocolAuthStatus() { + + enum class StatusEnum { + @SerializedName("unauthenticated") Unauthenticated, + } } diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ProtocolTypeAdapters.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ProtocolTypeAdapters.kt index 8dd4606f0666..f3b58496f040 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ProtocolTypeAdapters.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ProtocolTypeAdapters.kt @@ -3,9 +3,9 @@ package com.sourcegraph.cody.agent.protocol_generated; object ProtocolTypeAdapters { fun register(gson: com.google.gson.GsonBuilder) { - gson.registerTypeAdapter(AuthStatus::class.java, AuthStatus.deserializer) gson.registerTypeAdapter(ContextItem::class.java, ContextItem.deserializer) gson.registerTypeAdapter(CustomCommandResult::class.java, CustomCommandResult.deserializer) + gson.registerTypeAdapter(ProtocolAuthStatus::class.java, ProtocolAuthStatus.deserializer) gson.registerTypeAdapter(TextEdit::class.java, TextEdit.deserializer) gson.registerTypeAdapter(WorkspaceEditOperation::class.java, WorkspaceEditOperation.deserializer) } diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/SerializedChatMessage.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/SerializedChatMessage.kt index 87f8b72cb1db..9e193acca994 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/SerializedChatMessage.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/SerializedChatMessage.kt @@ -10,6 +10,7 @@ data class SerializedChatMessage( val speaker: SpeakerEnum, // Oneof: human, assistant, system val text: String? = null, val model: String? = null, + val intent: IntentEnum? = null, // Oneof: search, chat ) { enum class SpeakerEnum { @@ -17,5 +18,10 @@ data class SerializedChatMessage( @SerializedName("assistant") Assistant, @SerializedName("system") System, } + + enum class IntentEnum { + @SerializedName("search") Search, + @SerializedName("chat") Chat, + } } diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ServerInfo.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ServerInfo.kt index 177c0a4c25dd..2551dc9c3220 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ServerInfo.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/ServerInfo.kt @@ -4,6 +4,6 @@ package com.sourcegraph.cody.agent.protocol_generated; data class ServerInfo( val name: String, val authenticated: Boolean? = null, - val authStatus: AuthStatus? = null, + val authStatus: ProtocolAuthStatus? = null, ) diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Testing_Autocomplete_ProviderConfigResult.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Testing_Autocomplete_ProviderConfigResult.kt new file mode 100644 index 000000000000..9d1499d2adb9 --- /dev/null +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/Testing_Autocomplete_ProviderConfigResult.kt @@ -0,0 +1,9 @@ +@file:Suppress("FunctionName", "ClassName", "unused", "EnumEntryName", "UnusedImport") +package com.sourcegraph.cody.agent.protocol_generated; + +data class Testing_Autocomplete_ProviderConfigResult( + val id: String, + val legacyModel: String, + val configSource: String, +) + diff --git a/agent/scripts/generate-agent-kotlin-bindings.sh b/agent/scripts/generate-agent-kotlin-bindings.sh index 8e0d49e30c0c..562359f4b0ae 100755 --- a/agent/scripts/generate-agent-kotlin-bindings.sh +++ b/agent/scripts/generate-agent-kotlin-bindings.sh @@ -2,11 +2,11 @@ set -eux INDEXER_DIR=${SCIP_TYPESCRIPT_DIR:-../scip-typescript-cody-bindings} -if [ ! -d $INDEXER_DIR ]; then - git clone https://github.com/sourcegraph/scip-typescript.git $INDEXER_DIR +if [ ! -d "$INDEXER_DIR" ]; then + git clone https://github.com/sourcegraph/scip-typescript.git "$INDEXER_DIR" fi -pushd $INDEXER_DIR +pushd "$INDEXER_DIR" git fetch origin git checkout olafurpg/signatures-rebase1 git pull origin olafurpg/signatures-rebase1 @@ -16,5 +16,5 @@ popd pnpm install --prefer-offline pnpm build # TODO: invoke @sourcegraph/scip-typescript npm package instead -pnpm exec ts-node $INDEXER_DIR/src/main.ts index --emit-signatures --emit-external-symbols -pnpm exec ts-node agent/src/cli/scip-codegen/command.ts --output agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated \ No newline at end of file +pnpm exec ts-node "$INDEXER_DIR"/src/main.ts index --emit-signatures --emit-external-symbols +pnpm exec ts-node agent/src/cli/scip-codegen/command.ts --output agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated diff --git a/agent/scripts/test-agent-binary.ts b/agent/scripts/test-agent-binary.ts index 264a31298fd7..cb4bdf4f3261 100644 --- a/agent/scripts/test-agent-binary.ts +++ b/agent/scripts/test-agent-binary.ts @@ -49,7 +49,7 @@ async function main() { customHeaders: {}, }) - if (!valid?.authenticated) { + if (valid?.status !== 'authenticated') { throw new Error('Failed to authenticate') } diff --git a/agent/src/agent.ts b/agent/src/agent.ts index 788c72f3b080..829c50f92b61 100644 --- a/agent/src/agent.ts +++ b/agent/src/agent.ts @@ -10,7 +10,6 @@ import { ModelUsage, currentAuthStatus, currentAuthStatusAuthed, - currentAuthStatusOrNotReadyYet, firstResultFromOperation, telemetryRecorder, waitUntilComplete, @@ -78,6 +77,10 @@ import { AgentWorkspaceDocuments } from './AgentWorkspaceDocuments' import { registerNativeWebviewHandlers, resolveWebviewView } from './NativeWebview' import type { PollyRequestError } from './cli/command-jsonrpc-stdio' import { codyPaths } from './codyPaths' +import { + currentProtocolAuthStatus, + currentProtocolAuthStatusOrNotReadyYet, +} from './currentProtocolAuthStatus' import { AgentGlobalState } from './global-state/AgentGlobalState' import { MessageHandler, @@ -473,7 +476,7 @@ export class Agent extends MessageHandler implements ExtensionClient { this.registerWebviewHandlers() } - const authStatus = currentAuthStatusOrNotReadyYet() + const authStatus = currentProtocolAuthStatusOrNotReadyYet() return { name: 'cody-agent', authenticated: authStatus?.authenticated ?? false, @@ -571,12 +574,12 @@ export class Agent extends MessageHandler implements ExtensionClient { this.registerRequest('extensionConfiguration/change', async config => { this.authenticationPromise = this.handleConfigChanges(config) await this.authenticationPromise - return currentAuthStatus() + return currentProtocolAuthStatus() }) this.registerRequest('extensionConfiguration/status', async () => { await this.authenticationPromise - return currentAuthStatus() + return currentProtocolAuthStatus() }) this.registerRequest('extensionConfiguration/getSettingsSchema', async () => { @@ -994,7 +997,7 @@ export class Agent extends MessageHandler implements ExtensionClient { this.registerAuthenticatedRequest('testing/autocomplete/providerConfig', async () => { const provider = await vscode_shim.completionProvider() - return provider.config + return provider.config.provider }) this.registerAuthenticatedRequest('graphql/getRepoIds', async ({ names, first }) => { diff --git a/agent/src/cli/scip-codegen/BaseCodegen.ts b/agent/src/cli/scip-codegen/BaseCodegen.ts index 245756c01bf4..7049010d431d 100644 --- a/agent/src/cli/scip-codegen/BaseCodegen.ts +++ b/agent/src/cli/scip-codegen/BaseCodegen.ts @@ -21,8 +21,18 @@ export interface ProtocolSymbol { kind: ProtocolMethodKind } +export type ConstantType = string | boolean | number +export type ConstantTypeType = 'string' | 'boolean' | 'number' + +export function typeOfUnion(union: DiscriminatedUnion): ConstantTypeType { + if (union.members.length === 0) { + throw new TypeError(`Union ${JSON.stringify(union, null, 2)} has no members`) + } + return typeof union.members[0].value as ConstantTypeType +} + export interface DiscriminatedUnionMember { - value: string + value: ConstantType type: scip.Type } export interface DiscriminatedUnion { @@ -212,6 +222,9 @@ export abstract class BaseCodegen { for (const sibling of this.siblingDiscriminatedUnionProperties.get(info.symbol) ?? []) { visitInfo(this.symtab.info(sibling)) } + if (!info.has_signature) { + return + } if (info.signature.has_value_signature) { visitType(info.signature.value_signature.tpe) return @@ -290,8 +303,6 @@ export abstract class BaseCodegen { // literals. If you're hitting on this error with types like string // literals it means you are not guarding against it higher up in the // call stack. - // throw new TypeError(`type has no properties: ${this.debug(type)}`) - this.reporter.error('', `type has no properties: ${this.debug(type)}`) - return [] + throw new TypeError(`type has no properties: ${this.debug(type)}`) } } diff --git a/agent/src/cli/scip-codegen/Codegen.ts b/agent/src/cli/scip-codegen/Codegen.ts index eacd17c3e665..e6ffafb882e3 100644 --- a/agent/src/cli/scip-codegen/Codegen.ts +++ b/agent/src/cli/scip-codegen/Codegen.ts @@ -1,14 +1,23 @@ import fspromises from 'node:fs/promises' import path from 'node:path' import dedent from 'dedent' -import { BaseCodegen, type DiscriminatedUnion, type DiscriminatedUnionMember } from './BaseCodegen' +import { + BaseCodegen, + type ConstantType, + type DiscriminatedUnion, + type DiscriminatedUnionMember, +} from './BaseCodegen' import { CodePrinter } from '../../../../vscode/src/completions/context/retrievers/tsc/CodePrinter' import type { ConsoleReporter } from './ConsoleReporter' import { type Diagnostic, Severity } from './Diagnostic' -import { Formatter } from './Formatter' +import type { Formatter } from './Formatter' import type { SymbolTable } from './SymbolTable' import type { CodegenOptions } from './command' +import { CSharpEmitter } from './emitters/CSharpEmitter' +import type { DataClassOptions, Emitter, Enum, Member } from './emitters/Emitter' +import { JavaEmitter } from './emitters/JavaEmitter' +import { KotlinEmitter } from './emitters/KotlinEmitter' import { resetOutputPath } from './resetOutputPath' import { scip } from './scip' import { stringLiteralType } from './stringLiteralType' @@ -25,21 +34,31 @@ export enum TargetLanguage { Kotlin = 'kotlin', CSharp = 'csharp', } - export class Codegen extends BaseCodegen { - private f: Formatter public queue: scip.SymbolInformation[] = [] public generatedSymbols = new Set() public stringLiteralConstants = new Set() + private emitter: Emitter constructor( - private language: TargetLanguage, + language: TargetLanguage, options: CodegenOptions, symtab: SymbolTable, reporter: ConsoleReporter ) { super(options, symtab, reporter) - this.f = new Formatter(this.language, this.symtab, this) + this.emitter = this.getEmitter(language) + } + + private getEmitter(language: TargetLanguage): Emitter { + switch (language) { + case TargetLanguage.Kotlin: + return new KotlinEmitter(this.options, this.symtab, this) + case TargetLanguage.Java: + return new JavaEmitter(this.options, this.symtab, this) + case TargetLanguage.CSharp: + return new CSharpEmitter(this.options, this.symtab, this) + } } public async run(): Promise { @@ -56,9 +75,9 @@ export class Codegen extends BaseCodegen { BaseCodegen.protocolSymbols.server.notifications ) let info = this.queue.pop() - while (info !== undefined) { + while (info) { if (!this.generatedSymbols.has(info.symbol)) { - this.writeType(info) + await this.writeType(info) this.generatedSymbols.add(info.symbol) } info = this.queue.pop() @@ -73,7 +92,11 @@ export class Codegen extends BaseCodegen { private startDocument(): DocumentContext & { c: DocumentContext } { - const context: DocumentContext = { f: this.f, p: new CodePrinter(), symtab: this.symtab } + const context: DocumentContext = { + f: this.emitter.formatter, + p: new CodePrinter(), + symtab: this.symtab, + } return { ...context, c: context } } @@ -82,62 +105,13 @@ export class Codegen extends BaseCodegen { return } const { p } = this.startDocument() - switch (this.language) { - case TargetLanguage.Kotlin: - p.line('@file:Suppress("unused", "ConstPropertyName")') - p.line(`package ${this.options.kotlinPackage};`) - p.line() - p.line('object ProtocolTypeAdapters {') - break - case TargetLanguage.Java: - p.line(`package ${this.options.kotlinPackage};`) - p.line() - p.line('public final class ProtocolTypeAdapters {') - break - case TargetLanguage.CSharp: - p.line(`namespace ${this.options.kotlinPackage};`) - p.line('{') - p.block(() => { - p.line('public static class ProtocolTypeAdapters') - p.line('{') - }) - break - } - p.block(() => { - switch (this.language) { - case TargetLanguage.Kotlin: - p.line('fun register(gson: com.google.gson.GsonBuilder) {') - break - case TargetLanguage.Java: - p.line('public static void register(com.google.gson.GsonBuilder gson) {') - break - case TargetLanguage.CSharp: - p.line('public static void Register(JsonSerializerOptions options)') - p.line('{') - break - } - p.block(() => { - const discriminatedUnions = [...this.discriminatedUnions.keys()].sort() - for (const symbol of discriminatedUnions) { - const name = this.symtab.info(symbol).display_name - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`gson.registerTypeAdapter(${name}::class.java, ${name}.deserializer)`) - break - case TargetLanguage.Java: - p.line(`gson.registerTypeAdapter(${name}.class, ${name}.deserializer());`) - break - case TargetLanguage.CSharp: - p.line(`options.Converters.Add(new ${name}Converter());`) - break - } - } - }) - p.line('}') - }) - p.line('}') + + const discriminatedUnions = [...this.discriminatedUnions.keys()] + .sort() + .map(symbol => this.symtab.info(symbol).display_name) + this.emitter.emitSerializationAdapter(p, discriminatedUnions) await fspromises.writeFile( - path.join(this.options.output, `ProtocolTypeAdapters.${this.fileExtension()}`), + path.join(this.options.output, `ProtocolTypeAdapters.${this.emitter.getFileType()}`), p.build() ) } @@ -147,92 +121,19 @@ export class Codegen extends BaseCodegen { return } const { p } = this.startDocument() - switch (this.language) { - case TargetLanguage.CSharp: - p.line(`namespace ${this.options.kotlinPackage};`) - p.line('{') - p.line('public static class Constants') - p.line('{') - break - case TargetLanguage.Kotlin: - p.line('@file:Suppress("unused", "ConstPropertyName")') - p.line(`package ${this.options.kotlinPackage};`) - p.line() - p.line('object Constants {') - break - case TargetLanguage.Java: - p.line(`package ${this.options.kotlinPackage};`) - p.line() - p.line('public final class Constants {') - break - } - p.block(() => { - const constants = [...this.stringLiteralConstants.values()].sort() - for (const constant of constants) { - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`const val ${this.f.formatFieldName(constant)} = "${constant}"`) - break - case TargetLanguage.Java: - p.line( - `public static final String ${this.f.formatFieldName( - constant - )} = "${constant}";` - ) - break - case TargetLanguage.CSharp: - p.line( - `public const string ${this.f.formatFieldName(constant)} = "${constant}";` - ) - break - } - } - }) - p.line('}') - if (this.language === TargetLanguage.CSharp) { - p.line('}') - } + const constants = [...this.stringLiteralConstants.values()].sort() + this.emitter.emitStringLiteralConstants(p, constants) await fspromises.writeFile( - path.join(this.options.output, `Constants.${this.fileExtension()}`), + path.join(this.options.output, `Constants.${this.emitter.getFileType()}`), p.build() ) } - private fileExtension() { - switch (this.language) { - case TargetLanguage.CSharp: - return 'cs' - case TargetLanguage.Kotlin: - return 'kt' - default: - return 'java' - } - } - private async writeNullAlias(): Promise { const { p } = this.startDocument() - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`package ${this.options.kotlinPackage};`) - p.line() - p.line('typealias Null = Void?') - break - case TargetLanguage.Java: - p.line(`package ${this.options.kotlinPackage};`) - p.line() - p.line('public final class Null {}') - break - case TargetLanguage.CSharp: - p.line(`namespace ${this.options.kotlinPackage};`) - p.line('{') - p.block(() => { - p.line('public sealed class Null {}') - }) - p.line('}') - break - } + this.emitter.emitNullAlias(p) await fspromises.writeFile( - path.join(this.options.output, `Null.${this.fileExtension()}`), + path.join(this.options.output, `Null.${this.emitter.getFileType()}`), p.build() ) } @@ -241,170 +142,24 @@ export class Codegen extends BaseCodegen { { p, f, symtab }: DocumentContext, name: string, info: scip.SymbolInformation, - union: DiscriminatedUnion + { members, ...unionArgs }: DiscriminatedUnion ): Promise { - if (this.language === TargetLanguage.CSharp) { - p.addImport('using Newtonsoft.Json;') - } else { - p.line('import com.google.gson.Gson;') - p.line('import com.google.gson.JsonDeserializationContext;') - p.line('import com.google.gson.JsonDeserializer;') - p.line('import com.google.gson.JsonElement;') - p.line('import java.lang.reflect.Type;') - } - p.line() - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`sealed class ${name} {`) - break - case TargetLanguage.Java: - p.line(`public abstract class ${name} {`) - break - case TargetLanguage.CSharp: - p.line(`[JsonConverter(typeof(${name}Converter))]`) - name = name - .split(/[ -]/) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') - p.line(`public abstract class ${name}`) - p.line('{') - break - } - p.block(() => { - if (this.language === TargetLanguage.Kotlin) { - p.line('companion object {') + const isHandledCase = new Set() + const union: DiscriminatedUnion = { ...unionArgs, members: [] } + for (const member of members) { + if (isHandledCase.has(member.value)) { + // There's a bug in ContextProvider where + // two cases have the same discriminator + // 'search' + this.reporter.warn(info.symbol, `duplicate discriminator value ${member.value}`) + continue } - p.block(() => { - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`val deserializer: JsonDeserializer<${name}> =`) - break - case TargetLanguage.Java: - p.line(`public static JsonDeserializer<${name}> deserializer() {`) - break - case TargetLanguage.CSharp: - p.line(`private class ${name}Converter : JsonConverter<${name}>`) - p.line('{') - p.block(() => { - p.line( - `public override ${name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)` - ) - p.line('{') - p.block(() => { - p.line('var jsonDoc = JsonDocument.ParseValue(ref reader);') - p.line( - `var discriminator = jsonDoc.RootElement.GetProperty("${union.discriminatorDisplayName}").GetString();` - ) - p.line('switch (discriminator)') - p.line('{') - }) - }) - break - } - p.block(() => { - switch (this.language) { - case TargetLanguage.Kotlin: - p.line( - 'JsonDeserializer { element: JsonElement, _: Type, context: JsonDeserializationContext ->' - ) - break - case TargetLanguage.Java: - p.line('return (element, _type, context) -> {') - break - } - p.block(() => { - const keyword = this.language === TargetLanguage.Kotlin ? 'when' : 'switch' - if (this.language !== TargetLanguage.CSharp) { - p.line( - `${keyword} (element.getAsJsonObject().get("${union.discriminatorDisplayName}").getAsString()) {` - ) - } - p.block(() => { - const isHandledCase = new Set() - for (const member of union.members) { - if (isHandledCase.has(member.value)) { - // There's a bug in ContextProvider where - // two cases have the same discriminator - // 'search' - this.reporter.warn( - info.symbol, - `duplicate discriminator value ${member.value}` - ) - continue - } - isHandledCase.add(member.value) - const typeName = this.f.discriminatedUnionTypeName(union, member) - switch (this.language) { - case TargetLanguage.Kotlin: - p.line( - `"${member.value}" -> context.deserialize<${typeName}>(element, ${typeName}::class.java)` - ) - break - case TargetLanguage.Java: - p.line( - `case "${member.value}": return context.deserialize(element, ${typeName}.class);` - ) - break - case TargetLanguage.CSharp: - p.line(`case "${member.value}":`) - p.block(() => { - p.line( - `return JsonSerializer.Deserialize<${typeName}>(jsonDoc.RootElement.GetRawText(), options);` - ) - }) - break - } - } - switch (this.language) { - case TargetLanguage.Kotlin: - p.line('else -> throw Exception("Unknown discriminator ${element}")') - break - case TargetLanguage.Java: - p.line( - 'default: throw new RuntimeException("Unknown discriminator " + element);' - ) - break - case TargetLanguage.CSharp: - p.line('default:') - p.block(() => { - p.line( - 'throw new JsonException($"Unknown discriminator {discriminator}");' - ) - }) - p.line('}') - break - } - }) - p.line('}') - }) - switch (this.language) { - case TargetLanguage.CSharp: - p.line( - 'public override void Write(Utf8JsonWriter writer, ${name} value, JsonSerializerOptions options)' - ) - p.line('{') - p.block(() => { - p.line( - 'JsonSerializer.Serialize(writer, value, value.GetType(), options);' - ) - }) - break - case TargetLanguage.Java: - p.line('};') - break - default: - p.line('}') - } - }) - }) - p.line('}') - }) - if (this.language === TargetLanguage.Kotlin || this.language === TargetLanguage.CSharp) { - p.line('}') + isHandledCase.add(member.value) + union.members.push(member) } + this.emitter.startSealedClass(p, { name, info, union }) for (const member of union.members) { - p.line() - const typeName = this.f.discriminatedUnionTypeName(union, member) + const typeName = this.emitter.formatter.discriminatedUnionTypeName(union, member) const info = member.type.has_type_ref ? this.symtab.info(member.type.type_ref.symbol) : new scip.SymbolInformation({ @@ -415,24 +170,31 @@ export class Codegen extends BaseCodegen { }) this.writeDataClass({ p, f, symtab }, typeName, info, { innerClass: true, - heritageClause: - this.language === TargetLanguage.Kotlin - ? ` : ${name}()` - : this.language === TargetLanguage.Java - ? ` extends ${name}` - : ` : ${name}`, + parentClass: name, }) } - if (this.language === TargetLanguage.Java || this.language === TargetLanguage.CSharp) { - p.line('}') - } + this.emitter.closeSealedClass?.(p, { name, info, union }) } private async writeDataClass( { p, f, symtab }: DocumentContext, name: string, info: scip.SymbolInformation, - params?: { heritageClause?: string; innerClass?: boolean } + params?: Partial + ): Promise { + try { + await this.writeDataClassUnsafe({ p, f, symtab }, name, info, params) + } catch (e) { + const errorMessage = `Failed to handle class ${info.symbol}. To fix this problem, consider skipping this type by adding the symbol to "ignoredInfoSymbol" in Formatter.ts\n${e}` + this.reporter.error(info.symbol, errorMessage) + } + } + + private async writeDataClassUnsafe( + { p, f, symtab }: DocumentContext, + name: string, + info: scip.SymbolInformation, + params?: Partial ): Promise { if (info.kind === scip.SymbolInformation.Kind.Class) { this.reporter.warn( @@ -441,156 +203,98 @@ export class Codegen extends BaseCodegen { ) } const generatedName = new Set() - const enums: { name: string; members: string[] }[] = [] - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`data class ${name}(`) - break - case TargetLanguage.Java: { - const staticModifier = params?.innerClass ? 'static ' : '' - p.line(`public ${staticModifier}final class ${name}${params?.heritageClause ?? ''} {`) - break + const enums: Enum[] = [] + const members: Member[] = [] + for (const memberSymbol of this.infoProperties(info)) { + if ( + this.emitter.formatter.ignoredProperties.find(ignoredProperty => + memberSymbol.includes(ignoredProperty) + ) + ) { + continue } - case TargetLanguage.CSharp: - p.line(`public class ${name}${params?.heritageClause ?? ''}`) - p.line('{') - break - } - p.block(() => { - let hasMembers = false - for (const memberSymbol of this.infoProperties(info)) { - if ( - this.f.ignoredProperties.find(ignoredProperty => - memberSymbol.includes(ignoredProperty) - ) - ) { - continue - } - if (memberSymbol.endsWith('().')) { - // Ignore method members because they should not leak into - // the protocol in the first place because functions don't - // have meaningful JSON serialization. The most common cause - // is that a class leaks into the protocol. - continue - } - const member = symtab.info(memberSymbol) + if (memberSymbol.endsWith('().')) { + // Ignore method members because they should not leak into + // the protocol in the first place because functions don't + // have meaningful JSON serialization. The most common cause + // is that a class leaks into the protocol. + continue + } + const member = symtab.info(memberSymbol) - if (generatedName.has(member.display_name)) { - continue - } - generatedName.add(member.display_name) + if (generatedName.has(member.display_name)) { + continue + } + generatedName.add(member.display_name) - if (!member.signature.has_value_signature) { - throw new TypeError( - `not a value signature: ${JSON.stringify(member.toObject(), null, 2)}` - ) - } - if (member.signature.value_signature.tpe.has_lambda_type) { - this.reporter.warn( - memberSymbol, - `ignoring property '${member.display_name}' because it does not serialize correctly to JSON. ` + - `To fix this warning, don't expose this lambda type to the protocol` - ) - // Ignore properties with signatures like - // `ChatButton.onClick: (action: string) => void` - continue - } - const memberType = member.signature.value_signature.tpe - if (memberType === undefined) { - throw new TypeError(`no type: ${JSON.stringify(member.toObject(), null, 2)}`) - } + if (!member.signature.has_value_signature) { + throw new TypeError( + `not a value signature: ${JSON.stringify(member.toObject(), null, 2)}` + ) + } + if (member.signature.value_signature.tpe.has_lambda_type) { + this.reporter.warn( + memberSymbol, + `ignoring property '${member.display_name}' because it does not serialize correctly to JSON. ` + + `To fix this warning, don't expose this lambda type to the protocol` + ) + // Ignore properties with signatures like + // `ChatButton.onClick: (action: string) => void` + continue + } + const memberType = member.signature.value_signature.tpe + if (memberType === undefined) { + throw new TypeError(`no type: ${JSON.stringify(member.toObject(), null, 2)}`) + } - if (this.f.isIgnoredType(memberType)) { - continue - } + if (f.isIgnoredType(memberType)) { + continue + } - let memberTypeSyntax = f.jsonrpcTypeName(member, memberType, 'parameter') - const constants = this.stringConstantsFromInfo(member) - for (const constant of constants) { - // HACK: merge this duplicate code with the same logic in this file - this.stringLiteralConstants.add(constant) - } + let memberTypeSyntax = f.jsonrpcTypeName(member, memberType, 'parameter') + const constants = this.stringConstantsFromInfo(member) + for (const constant of constants) { + // HACK: merge this duplicate code with the same logic in this file + this.stringLiteralConstants.add(constant) + } - if (constants.length > 0 && memberTypeSyntax.startsWith('String')) { - const enumTypeName = this.f.formatEnumType(member.display_name) - memberTypeSyntax = enumTypeName + this.f.nullableSyntax(memberType) - enums.push({ name: enumTypeName, members: constants }) - } else { + if (constants.length > 0 && memberTypeSyntax.startsWith('String')) { + const enumTypeName = this.emitter.formatter.formatEnumType(member.display_name) + memberTypeSyntax = enumTypeName + this.emitter.formatter.nullableSyntax(memberType) + enums.push({ + name: enumTypeName, + members: constants.map(constant => ({ + serializedName: constant, + formattedName: this.emitter.formatter.formatFieldName(capitalize(constant)), + })), + }) + } else { + try { this.queueClassLikeType(memberType, member, 'parameter') - } - const oneofSyntax = constants.length > 0 ? ' // Oneof: ' + constants.join(', ') : '' - const defaultValueSyntax = this.f.isNullable(memberType) ? ' = null' : '' - const fieldName = this.f.formatFieldName(member.display_name) - const serializedAnnotation = - fieldName === member.display_name - ? '' - : `@com.google.gson.annotations.SerializedName("${member.display_name}") ` - switch (this.language) { - case TargetLanguage.Kotlin: - p.line( - `val ${member.display_name}: ${memberTypeSyntax}${defaultValueSyntax},${oneofSyntax}` - ) - break - case TargetLanguage.Java: - p.line( - `${serializedAnnotation}public ${memberTypeSyntax} ${this.f.formatFieldName( - member.display_name - )};${oneofSyntax}` - ) - break - case TargetLanguage.CSharp: - p.line(`[JsonProperty(PropertyName = "${member.display_name}")]`) - if (oneofSyntax.includes('-')) { - p.line( - `public string ${this.f.formatFieldName( - member.display_name - )} { get; set; }${oneofSyntax}` - ) - enums.length = 0 - } else { - p.line( - `public ${memberTypeSyntax} ${this.f.formatFieldName( - member.display_name - )} { get; set; }${oneofSyntax}` - ) - } - break - } - hasMembers = true - } - if (!hasMembers) { - if (this.language === TargetLanguage.Kotlin) { - p.line('val placeholderField: String? = null // Empty data class') - } else if (this.language === TargetLanguage.CSharp) { - p.line('public string PlaceholderField { get; set; } // Empty class') + } catch (error) { + const stack = error instanceof Error ? '\n' + error.stack : '' + const errorMessage = `error handling member: ${member.symbol}. To fix this problem, you may want to ignore it from code generation by adding the symbol name to the "ignoredProperties" in the Formatter.ts file.\n${error}${stack}` + this.reporter.error(memberSymbol, errorMessage) + continue } } - }) - if (enums.length === 0) { - if (this.language === TargetLanguage.Kotlin) { - p.line(`)${params?.heritageClause ?? ''}`) - } else { - p.line('}') - } - return - } - if (this.language === TargetLanguage.Kotlin) { - p.line(`)${params?.heritageClause ?? ''} {`) + const oneofSyntax = constants.length > 0 ? ' // Oneof: ' + constants.join(', ') : '' + const fieldName = this.emitter.formatter.formatFieldName(member.display_name) + members.push({ + info: member, + typeSyntax: memberTypeSyntax, + formattedName: fieldName, + oneOfComment: oneofSyntax, + isNullable: this.emitter.formatter.isNullable(memberType), + }) } - // Nest enum classe inside data class to avoid naming conflicts with - // enums for other data classes in the same package. - p.block(() => { - if (this.language === TargetLanguage.Kotlin) { - p.addImport('import com.google.gson.annotations.SerializedName;') - } else if (this.language === TargetLanguage.CSharp) { - p.addImport('using Newtonsoft.Json;') - } - - for (const { name, members } of enums) { - this.writeEnum(p, name, members) - } + this.emitter.emitDataClass(p, { + ...params, + name, + info, + members, + enums, }) - p.line('}') } private aliasType(info: scip.SymbolInformation): string | undefined { @@ -612,136 +316,36 @@ export class Codegen extends BaseCodegen { return undefined } - private writeEnum(p: CodePrinter, name: string, members: string[]): void { - p.line() - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`enum class ${name} {`) - break - case TargetLanguage.Java: - p.line(`public enum ${name} {`) - break - case TargetLanguage.CSharp: - p.line(`public enum ${name}`) - p.line('{') - break - } - p.block(() => { - for (const member of members) { - const serializedName = (() => { - switch (this.language) { - case TargetLanguage.CSharp: - return '' - case TargetLanguage.Kotlin: - return `@SerializedName("${member}")` - case TargetLanguage.Java: - return `@com.google.gson.annotations.SerializedName("${member}")` - default: - return '' - } - })() - const enumName = this.f.formatFieldName(capitalize(member)) - switch (this.language) { - case TargetLanguage.CSharp: - p.line(`${enumName}, // ${member}`) - break - default: - p.line(`${serializedName} ${enumName},`) - break - } - } - }) - p.line('}') - } - private async writeType(info: scip.SymbolInformation): Promise { const { f, p, c } = this.startDocument() const name = f.typeName(info) const alias = this.aliasType(info) - - if (this.language === TargetLanguage.CSharp) { - p.addImport('using Newtonsoft.Json;') - p.line() - p.line(`namespace ${this.options.kotlinPackage}`) - p.line('{') - p.block(() => { - if (alias) { - if (this.isStringTypeInfo(info)) { - const constants = this.stringConstantsFromInfo(info) - if (constants.length > 0) { - this.writeEnum(p, name, constants) - } else { - p.line(`public class ${name}`) - p.line('{') - p.block(() => { - p.line('public string Value { get; set; }') - p.line() - p.line( - `public static implicit operator string(${name} value) => value.Value;` - ) - p.line( - `public static implicit operator ${name}(string value) => new ${name} { Value = value };` - ) - }) - p.line('}') - } - } else { - p.line(`public class ${name} : ${alias} { }`) - } - } else if (info.signature.has_class_signature) { - this.writeDataClass(c, name, info) - } else if (info.signature.has_type_signature) { - const discriminatedUnion = this.discriminatedUnions.get(info.symbol) - if (discriminatedUnion) { - this.writeSealedClass(c, name, info, discriminatedUnion) - } else if (this.isStringTypeInfo(info)) { - const constants = this.stringConstantsFromInfo(info) - if (constants.length > 0) { - this.writeEnum(p, name, constants) - } else { - p.line(`public class ${name} : String { }`) - } - } else { - this.writeDataClass(c, name, info) - } - } else { - throw new Error(`Unsupported signature: ${JSON.stringify(info.toObject(), null, 2)}`) - } - }) - p.line('}') - const filename = `${info.display_name}.${this.fileExtension()}` - .split('_') - .map(capitalize) - .join('') - fspromises.writeFile(path.join(this.options.output, filename), p.build()) + if (f.isIgnoredInfo(info)) { return } - - if (this.language === TargetLanguage.Kotlin) { - p.line( - '@file:Suppress("FunctionName", "ClassName", "unused", "EnumEntryName", "UnusedImport")' - ) - } - p.line(`package ${this.options.kotlinPackage};`) - p.line() + this.emitter.startType(p, { name, info }) if (alias) { - if (this.language === TargetLanguage.Kotlin) { - p.line(`typealias ${name} = ${alias}`) - } else { - if (info.display_name === 'Date') { - p.line('public final class Date {}') - } else if (info.display_name === 'Null') { - p.line('public final class Null {}') - } else { - const constants = this.stringConstantsFromInfo(info) - if (constants.length === 0) { - this.reporter.warn(info.symbol, `no constants for ${info.display_name}`) - p.line(`public final class ${name} {} // TODO: fixme`) - } else { - this.writeEnum(p, name, constants) + const isStringType = this.isStringTypeInfo(info) + let enum_: Enum | undefined + if (isStringType) { + const constants = this.stringConstantsFromInfo(info) + if (constants.length > 0) { + enum_ = { + name, + members: constants.map(name => ({ + serializedName: name, + formattedName: f.formatFieldName(capitalize(name)), + })), } } } + this.emitter.emitTypeAlias(p, { + name, + alias, + isStringType, + info, + enum: enum_, + }) } else { const discriminatedUnion = this.discriminatedUnions.get(info.symbol) if (discriminatedUnion) { @@ -750,147 +354,64 @@ export class Codegen extends BaseCodegen { this.writeDataClass(c, name, info) } } - p.line() + this.emitter.closeType(p, { name, info }) await fspromises.writeFile( - path.join(this.options.output, `${name}.${this.fileExtension()}`), + path.join(this.options.output, this.emitter.getFileNameForType(info.display_name)), p.build() ) } private async writeProtocolInterface( name: string, - requests: string, - notifications: string + requestSymbol: string, + notificationSymbol: string ): Promise { const { f, p, symtab } = this.startDocument() - switch (this.language) { - case TargetLanguage.Kotlin: - p.line('@file:Suppress("FunctionName", "ClassName", "RedundantNullable")') - p.line(`package ${this.options.kotlinPackage};`) - p.line() - p.line('import org.eclipse.lsp4j.jsonrpc.services.JsonNotification;') - p.line('import org.eclipse.lsp4j.jsonrpc.services.JsonRequest;') - p.line('import java.util.concurrent.CompletableFuture;') - break - case TargetLanguage.Java: - p.line(`package ${this.options.kotlinPackage};`) - p.line() - p.line('import org.eclipse.lsp4j.jsonrpc.services.JsonNotification;') - p.line('import org.eclipse.lsp4j.jsonrpc.services.JsonRequest;') - p.line('import java.util.concurrent.CompletableFuture;') - break - case TargetLanguage.CSharp: - p.addImport('using System.Threading.Tasks;') - p.line() - p.line(`namespace ${this.options.kotlinPackage};`) - p.line('{') - break - } - p.line() - switch (this.language) { - case TargetLanguage.Kotlin: - p.line('@Suppress("unused")') - p.line(`interface ${name} {`) - break - case TargetLanguage.Java: - p.line('@SuppressWarnings("unused")') - p.line(`public interface ${name} {`) - break - case TargetLanguage.CSharp: - p.line('public interface ' + name) - p.line('{') - break - } - p.block(() => { - p.sectionComment('Requests') - for (const request of symtab.structuralType(symtab.canonicalSymbol(requests))) { - // We skip the webview protocol because our IDE clients are now - // using the string-encoded protocol instead. - if ( - request.display_name === 'webview/receiveMessage' || - request.display_name === 'chat/submitMessage' || - request.display_name === 'chat/editMessage' - ) { - continue - } - // Process a JSON-RPC request signature. For example: - // type Requests = { 'textDocument/inlineCompletions': [RequestParams, RequestResult] } - const resultType = request.signature.value_signature.tpe.type_ref.type_arguments?.[1] - if (resultType === undefined) { - this.reporter.error( - request.symbol, - `missing result type for request. To fix this problem, add a second element to the array type like this: 'example/method: [RequestParams, RequestResult]'` - ) - continue - } - const { parameterType, parameterSyntax } = f.jsonrpcMethodParameter(request) - this.queueClassLikeType(parameterType, request, 'parameter') - this.queueClassLikeType(resultType, request, 'result') - const resultTypeSyntax = f.jsonrpcTypeName(request, resultType, 'result') - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`@JsonRequest("${request.display_name}")`) - p.line( - `fun ${f.functionName(request)}(${parameterSyntax}): ` + - `CompletableFuture<${resultTypeSyntax}>` - ) - break - case TargetLanguage.Java: - p.line(`@JsonRequest("${request.display_name}")`) - p.line( - `CompletableFuture<${resultTypeSyntax}> ${f.functionName( - request - )}(${parameterSyntax});` - ) - break - case TargetLanguage.CSharp: { - p.line(`[JsonRpcMethod("${request.display_name}")]`) - const _task = resultTypeSyntax === 'Void' ? 'Task' : `Task<${resultTypeSyntax}>` - const _params = parameterSyntax.startsWith('Void') ? '' : parameterSyntax - const _func = capitalize(f.functionName(request)) - p.line(`${_task} ${_func}(${_params});`) - break - } - } + const requests: scip.SymbolInformation[] = [] + const notifications: scip.SymbolInformation[] = [] + + for (const request of symtab.structuralType(symtab.canonicalSymbol(requestSymbol))) { + // We skip the webview protocol because our IDE clients are now + // using the string-encoded protocol instead. + if ( + request.display_name === 'webview/receiveMessage' || + request.display_name === 'chat/submitMessage' || + request.display_name === 'chat/editMessage' + ) { + continue } - p.line() - p.sectionComment('Notifications') - for (const notification of symtab.structuralType(symtab.canonicalSymbol(notifications))) { - // We skip the webview protocol because our IDE clients are now - // using the string-encoded protocol instead. - if (notification.display_name === 'webview/postMessage') { - continue - } - // Process a JSON-RPC request signature. For example: - // type Notifications = { 'textDocument/inlineCompletions': [NotificationParams] } - const { parameterType, parameterSyntax } = f.jsonrpcMethodParameter(notification) - this.queueClassLikeType(parameterType, notification, 'parameter') - const notificationName = f.functionName(notification) - switch (this.language) { - case TargetLanguage.Kotlin: - p.line(`@JsonNotification("${notification.display_name}")`) - p.line(`fun ${notificationName}(${parameterSyntax})`) - break - case TargetLanguage.Java: - p.line(`@JsonNotification("${notification.display_name}")`) - p.line(`void ${notificationName}(${parameterSyntax});`) - break - case TargetLanguage.CSharp: - p.line(`[JsonRpcMethod("${notification.display_name}")]`) - p.line(`void ${capitalize(notificationName)}(${parameterSyntax});`) - break - } + // Process a JSON-RPC request signature. For example: + // type Requests = { 'textDocument/inlineCompletions': [RequestParams, RequestResult] } + const resultType = request.signature.value_signature.tpe.type_ref.type_arguments?.[1] + if (resultType === undefined) { + this.reporter.error( + request.symbol, + `missing result type for request. To fix this problem, add a second element to the array type like this: 'example/method: [RequestParams, RequestResult]'` + ) + continue } - }) - - p.line('}') + const { parameterType } = f.jsonrpcMethodParameter(request) + this.queueClassLikeType(parameterType, request, 'parameter') + this.queueClassLikeType(resultType, request, 'result') + requests.push(request) + } - if (this.language === TargetLanguage.CSharp) { - p.line('}') + for (const notification of symtab.structuralType(symtab.canonicalSymbol(notificationSymbol))) { + // We skip the webview protocol because our IDE clients are now + // using the string-encoded protocol instead. + if (notification.display_name === 'webview/postMessage') { + continue + } + // Process a JSON-RPC request signature. For example: + // type Notifications = { 'textDocument/inlineCompletions': [NotificationParams] } + const { parameterType } = f.jsonrpcMethodParameter(notification) + this.queueClassLikeType(parameterType, notification, 'parameter') + notifications.push(notification) } + this.emitter.emitProtocolInterface(p, { name, requests, notifications }) await fspromises.writeFile( - path.join(this.options.output, `${name}.${this.fileExtension()}`), + path.join(this.options.output, `${name}.${this.emitter.getFileType()}`), p.build() ) } @@ -906,13 +427,13 @@ export class Codegen extends BaseCodegen { if (type.has_type_ref) { if (type.type_ref.symbol === typescriptKeyword('array')) { this.queueClassLikeType(type.type_ref.type_arguments[0], jsonrpcMethod, kind) - } else if (this.f.isRecord(type.type_ref.symbol)) { + } else if (this.emitter.formatter.isRecord(type.type_ref.symbol)) { if (type.type_ref.type_arguments.length !== 2) { throw new TypeError(`record must have 2 type arguments: ${this.debug(type)}`) } this.queueClassLikeType(type.type_ref.type_arguments[0], jsonrpcMethod, kind) this.queueClassLikeType(type.type_ref.type_arguments[1], jsonrpcMethod, kind) - } else if (typescriptKeywordSyntax(this.language, type.type_ref.symbol)) { + } else if (typescriptKeywordSyntax(type.type_ref.symbol)) { // Typescript keywords map to primitive types (Int, Double) or built-in types like String } else { this.queueClassLikeInfo(this.symtab.info(type.type_ref.symbol)) @@ -928,7 +449,7 @@ export class Codegen extends BaseCodegen { // aggregate properties of `A & B` or `{a: b, c: d}`. this.queueClassLikeInfo( new scip.SymbolInformation({ - display_name: this.f.jsonrpcTypeName(jsonrpcMethod, type, kind), + display_name: this.emitter.formatter.jsonrpcTypeName(jsonrpcMethod, type, kind), // Need unique symbol for parameter+result types symbol: `${jsonrpcMethod.symbol}(${kind}).`, signature: new scip.Signature({ @@ -949,10 +470,12 @@ export class Codegen extends BaseCodegen { } if (type.has_union_type) { - const nonNullableTypes = type.union_type.types.filter(type => !this.f.isNullable(type)) + const nonNullableTypes = type.union_type.types.filter( + type => !this.emitter.formatter.isNullable(type) + ) if ( nonNullableTypes.every( - tpe => tpe.has_type_ref && isTypescriptKeyword(this.language, tpe.type_ref.symbol) + tpe => tpe.has_type_ref && isTypescriptKeyword(tpe.type_ref.symbol) ) ) { // Nothing to queue @@ -968,8 +491,8 @@ export class Codegen extends BaseCodegen { // types. In some cases, we are exposing VS Code APIs that have // unions like `string | MarkdownString` where we just assume // the type will always be `string`. - const exceptionIndex = this.f.unionTypeExceptionIndex.find(({ prefix }) => - jsonrpcMethod.symbol.startsWith(prefix) + const exceptionIndex = this.emitter.formatter.unionTypeExceptionIndex.find( + ({ prefix }) => jsonrpcMethod.symbol.startsWith(prefix) )?.index if (exceptionIndex !== undefined) { this.reporter.warn( @@ -1017,6 +540,7 @@ export class Codegen extends BaseCodegen { loop(type) return result } + private discriminatedUnion(info: scip.SymbolInformation): DiscriminatedUnion | undefined { if (!info.signature.has_type_signature) { return undefined @@ -1059,6 +583,9 @@ export class Codegen extends BaseCodegen { // Same as `queueClassLikeType` but for `scip.SymbolInformation` instead of `scip.Type`. private queueClassLikeInfo(jsonrpcMethod: scip.SymbolInformation): void { + if (!jsonrpcMethod.has_signature) { + return + } if (jsonrpcMethod.signature.has_class_signature) { // Easy, this looks like a class/interface. this.queue.push(jsonrpcMethod) diff --git a/agent/src/cli/scip-codegen/Formatter.ts b/agent/src/cli/scip-codegen/Formatter.ts index 6365e76755c4..79e25c723b4c 100644 --- a/agent/src/cli/scip-codegen/Formatter.ts +++ b/agent/src/cli/scip-codegen/Formatter.ts @@ -1,23 +1,31 @@ import type { DiscriminatedUnion, DiscriminatedUnionMember } from './BaseCodegen' -import { type Codegen, TargetLanguage } from './Codegen' +import type { Codegen } from './Codegen' import type { SymbolTable } from './SymbolTable' import { isNullOrUndefinedOrUnknownType } from './isNullOrUndefinedOrUnknownType' import type { scip } from './scip' -import { capitalize, typescriptKeyword, typescriptKeywordSyntax } from './utils' +import { TypescriptKeyword, capitalize, typescriptKeyword, typescriptKeywordSyntax } from './utils' -export class Formatter { +export interface LanguageOptions { + typeNameSeparator: string + typeAnnotations: 'before' | 'after' + voidType: string + nullableSyntax?: string + reserved: Set + keywordOverrides: Map +} +export abstract class Formatter { constructor( - private readonly language: TargetLanguage, private readonly symtab: SymbolTable, private codegen: Codegen ) {} + + public abstract options: LanguageOptions + public abstract mapSyntax(key: string, value: string): string + public abstract listSyntax(elementType: string): string + public abstract formatFieldName(name: string): string + public functionName(info: scip.SymbolInformation): string { - switch (this.language) { - case TargetLanguage.CSharp: - return info.display_name.replaceAll('$/', '').split('/').map(capitalize).join('') - default: - return info.display_name.replaceAll('$/', '').replaceAll('/', '_') - } + return info.display_name.replaceAll('$/', '').replaceAll('/', '_') } public typeName(info: scip.SymbolInformation): string { @@ -29,7 +37,7 @@ export class Formatter { .replaceAll('$/', '') .split('/') .map(part => capitalize(part)) - .join(this.language === TargetLanguage.CSharp ? '' : '_') + .join(this.options.typeNameSeparator) } public jsonrpcMethodParameter(jsonrpcMethod: scip.SymbolInformation): { @@ -38,7 +46,7 @@ export class Formatter { } { const parameterType = jsonrpcMethod.signature.value_signature.tpe.type_ref.type_arguments[0] const parameterSyntax = this.jsonrpcTypeName(jsonrpcMethod, parameterType, 'parameter') - if (this.language === TargetLanguage.Kotlin) { + if (this.options.typeAnnotations === 'after') { return { parameterType, parameterSyntax: `params: ${parameterSyntax}` } } return { parameterType, parameterSyntax: `${parameterSyntax} params` } @@ -52,14 +60,10 @@ export class Formatter { return this.isNullable(info.signature.value_signature.tpe) } public nullableSyntax(tpe: scip.Type): string { - if (this.language === TargetLanguage.Java) { - // TODO: emit @Nullable - return '' - } - if (this.language === TargetLanguage.CSharp) { - return '' + if (this.options.nullableSyntax && this.isNullable(tpe)) { + return this.options.nullableSyntax } - return this.isNullable(tpe) ? '?' : '' + return '' } public isNullable(tpe: scip.Type): boolean { @@ -94,31 +98,19 @@ export class Formatter { const [k, v] = parameterOrResultType.type_ref.type_arguments const key = this.jsonrpcTypeName(jsonrpcMethod, k, kind) const value = this.jsonrpcTypeName(jsonrpcMethod, v, kind) - if (this.language === TargetLanguage.Kotlin) { - return `Map<${key}, ${value}>` - } - if (this.language === TargetLanguage.CSharp) { - return `Dictionary<${key}, ${value}>` - } - return `java.util.Map<${key}, ${value}>` + return this.mapSyntax(key, value) } - const keyword = typescriptKeywordSyntax(this.language, parameterOrResultType.type_ref.symbol) - if (keyword === 'List') { + const keyword = typescriptKeywordSyntax(parameterOrResultType.type_ref.symbol) + if (keyword === TypescriptKeyword.List) { const elementType = this.jsonrpcTypeName( jsonrpcMethod, parameterOrResultType.type_ref.type_arguments[0], kind ) - if (this.language === TargetLanguage.Kotlin) { - return `List<${elementType}>` - } - if (this.language === TargetLanguage.CSharp) { - return `${elementType}[]` - } - return `java.util.List<${elementType}>` + return this.listSyntax(elementType) } if (keyword) { - return this.languageSpecificKeyword(keyword) + return this.options.keywordOverrides.get(keyword) ?? keyword } return this.typeName(this.symtab.info(parameterOrResultType.type_ref.symbol)) } @@ -147,10 +139,7 @@ export class Formatter { tpe => !this.isNullable(tpe) ) if (nonNullableTypes.length === 0) { - if (this.language === TargetLanguage.Kotlin) { - return 'Null' - } - return 'Void' + return this.options.voidType } if (nonNullableTypes.length === 1) { return this.nonNullableJsonrpcTypeName(jsonrpcMethod, nonNullableTypes[0], kind) @@ -186,29 +175,17 @@ export class Formatter { ) } - private languageSpecificKeyword(keyword: string): string { - switch (this.language) { - case TargetLanguage.Kotlin: - case TargetLanguage.Java: - return keyword - case TargetLanguage.CSharp: - switch (keyword) { - case 'Boolean': - return 'bool' - case 'String': - return 'string' - case 'Long': - return 'int' - default: - return keyword - } - } - } + public readonly ignoredInfoSymbol: string[] = [] public readonly ignoredProperties = [ 'npm @sourcegraph/telemetry ', // Too many complicated types from this package + '`inline-completion-item-provider-config-singleton.ts`/tracer0:', + '`observable.d.ts`/Subscription#', + '`provider.ts`/Provider#configSource', + '`StatusBar.ts`/CodyStatusBar', ] private readonly ignoredTypeRefs = [ + '`provider.ts`/Provider#', 'npm @sourcegraph/telemetry', // Too many complicated types from this package '/TelemetryEventParameters#', ' lib/`lib.es5.d.ts`/Omit#', @@ -228,6 +205,15 @@ export class Formatter { return false } + public isIgnoredInfo(info: scip.SymbolInformation): boolean { + for (const ignored of this.ignoredInfoSymbol) { + if (info.symbol.includes(ignored)) { + return true + } + } + return false + } + // Hacky workaround: we are exposing a few tricky union types in the // protocol that don't have a clean encoding in other languages. We use this // list to manually pick one of the types in the union type. @@ -242,55 +228,6 @@ export class Formatter { ) } - // Incomplete, but useful list of keywords. Thank you Cody! - private kotlinKeywords = new Set([ - 'class', - 'interface', - 'object', - 'package', - 'typealias', - 'val', - 'var', - 'fun', - 'when', - ]) - private javaKeywords = new Set([ - 'class', - 'interface', - 'object', - 'package', - 'var', - 'default', - 'case', - 'switch', - 'native', - ]) - - public formatFieldName(name: string): string { - const escaped = name.replace(':', '_').replace('/', '_') - if (this.language === TargetLanguage.Kotlin) { - const isKeyword = this.kotlinKeywords.has(escaped) - const needsBacktick = isKeyword || !/^[a-zA-Z0-9_]+$/.test(escaped) - // Replace all non-alphanumeric characters with underscores - const fieldName = getEscapedValue(escaped, '-') - return needsBacktick ? `\`${fieldName}\`` : fieldName - } - // CSharp - if (this.language === TargetLanguage.CSharp) { - return getEscapedValue(escaped) - .split('_') - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join('') - .replaceAll('_', '') - } - // Java - const isKeyword = this.javaKeywords.has(escaped) - if (isKeyword) { - return escaped + '_' - } - return escaped.replace(/[^a-zA-Z0-9]/g, '_') - } - public discriminatedUnionTypeName( union: DiscriminatedUnion, member: DiscriminatedUnionMember @@ -302,18 +239,19 @@ export class Formatter { this.formatFieldName(member.value + this.symtab.info(union.symbol).display_name) ) } + public formatEnumType(name: string): string { return `${capitalize(name)}Enum` } -} -function getEscapedValue(name: string, replacer: '_' | '-' = '_'): string { - const nonAlphanumericRegex = replacer === '-' ? /[^a-zA-Z0-9]+/g : /[^a-zA-Z0-9]/g - const repeatedReplacerRegex = new RegExp(`${replacer}+`, 'g') - const trimReplacerRegex = new RegExp(`^${replacer}|${replacer}$`, 'g') + protected escape(name: string, replacer: '_' | '-' = '_'): string { + const nonAlphanumericRegex = replacer === '-' ? /[^a-zA-Z0-9]+/g : /[^a-zA-Z0-9]/g + const repeatedReplacerRegex = new RegExp(`${replacer}+`, 'g') + const trimReplacerRegex = new RegExp(`^${replacer}|${replacer}$`, 'g') - return name - .replace(nonAlphanumericRegex, replacer) - .replace(repeatedReplacerRegex, replacer) - .replace(trimReplacerRegex, '') + return name + .replace(nonAlphanumericRegex, replacer) + .replace(repeatedReplacerRegex, replacer) + .replace(trimReplacerRegex, '') + } } diff --git a/agent/src/cli/scip-codegen/emitters/CSharpEmitter.ts b/agent/src/cli/scip-codegen/emitters/CSharpEmitter.ts new file mode 100644 index 000000000000..b89e0d9a93ce --- /dev/null +++ b/agent/src/cli/scip-codegen/emitters/CSharpEmitter.ts @@ -0,0 +1,316 @@ +import type { CodePrinter } from '../../../../../vscode/src/completions/context/retrievers/tsc/CodePrinter' +import type { Codegen } from '../Codegen' +import { Formatter, type LanguageOptions } from '../Formatter' +import type { SymbolTable } from '../SymbolTable' +import type { CodegenOptions } from '../command' +import type { scip } from '../scip' +import { TypescriptKeyword, capitalize } from '../utils' +import type { + DataClassOptions, + Emitter, + Enum, + ProtocolInterface, + SealedClassOptions, + TypeAliasOptions, + TypeOptions, +} from './Emitter' + +export class CSharpEmitter implements Emitter { + formatter: CSharpFormatter + + constructor( + private options: CodegenOptions, + symtab: SymbolTable, + codegen: Codegen + ) { + this.formatter = new CSharpFormatter(symtab, codegen) + } + + emitSerializationAdapter(p: CodePrinter, discriminatedUnions: string[]): void { + p.line(`namespace ${this.options.kotlinPackage};`) + p.line('{') + p.block(() => { + p.line('public static class ProtocolTypeAdapters') + p.line('{') + p.block(() => { + p.line('public static void Register(JsonSerializerOptions options)') + p.line('{') + for (const name of discriminatedUnions) { + p.line(`options.Converters.Add(new ${name}Converter());`) + } + }) + p.line('}') + }) + p.line('}') + } + + emitNullAlias(p: CodePrinter): void { + p.line(`namespace ${this.options.kotlinPackage};`) + p.line('{') + p.block(() => { + p.line('public sealed class Null {}') + }) + p.line('}') + } + + emitStringLiteralConstants(p: CodePrinter, stringLiterals: string[]): void { + p.line(`namespace ${this.options.kotlinPackage};`) + p.line('{') + p.line('public static class Constants') + p.line('{') + p.block(() => { + for (const literal of stringLiterals) { + p.line(`public const string ${this.formatter.formatFieldName(literal)} = "${literal}";`) + } + }) + p.line('}') + p.line('}') + } + + emitProtocolInterface(p: CodePrinter, { name, requests, notifications }: ProtocolInterface): void { + p.addImport('using System.Threading.Tasks;') + p.line() + p.line(`namespace ${this.options.kotlinPackage};`) + p.line('{') + p.line() + p.line('public interface ' + name) + p.line('{') + + p.block(() => { + p.sectionComment('Requests') + for (const request of requests) { + const resultType = request.signature.value_signature.tpe.type_ref.type_arguments?.[1] + const { parameterSyntax } = this.formatter.jsonrpcMethodParameter(request) + const resultTypeSyntax = this.formatter.jsonrpcTypeName(request, resultType, 'result') + p.line(`[JsonRpcMethod("${request.display_name}")]`) + const _task = resultTypeSyntax === 'Void' ? 'Task' : `Task<${resultTypeSyntax}>` + const _params = parameterSyntax.startsWith('Void') ? '' : parameterSyntax + const _func = capitalize(this.formatter.functionName(request)) + p.line(`${_task} ${_func}(${_params});`) + } + + p.line() + p.sectionComment('Notifications') + for (const notification of notifications) { + const { parameterSyntax } = this.formatter.jsonrpcMethodParameter(notification) + const notificationName = this.formatter.functionName(notification) + p.line(`[JsonRpcMethod("${notification.display_name}")]`) + p.line(`void ${capitalize(notificationName)}(${parameterSyntax});`) + } + }) + p.line('}') + p.line('}') + } + + startType(p: CodePrinter, _: TypeOptions): void { + this.addJsonImport(p) + p.line() + p.line(`namespace ${this.options.kotlinPackage}`) + p.line('{') + } + + closeType(p: CodePrinter, _: TypeOptions): void { + p.line('}') + } + + emitTypeAlias(p: CodePrinter, { name, alias, isStringType, enum: enum_ }: TypeAliasOptions): void { + p.block(() => { + if (isStringType) { + if (enum_) { + this.emitEnum(p, enum_) + } else { + // Create an implicit string wrapper class + p.line(`public class ${name}`) + p.line('{') + p.block(() => { + p.line('public string Value { get; set; }') + p.line() + p.line(`public static implicit operator string(${name} value) => value.Value;`) + p.line( + `public static implicit operator ${name}(string value) => new ${name} { Value = value };` + ) + }) + p.line('}') + } + } else { + // Create a class that inherits from the alias + p.line(`public class ${name} : ${alias} {}`) + } + }) + } + + startSealedClass(p: CodePrinter, { name, union }: SealedClassOptions): void { + this.addJsonImport(p) + p.line() + p.block(() => { + p.line(`[JsonConverter(typeof(${name}Converter))]`) + name = name.split(/[ -]/).map(capitalize).join('') + p.line(`public abstract class ${name}`) + p.line('{') + p.block(() => { + p.block(() => { + p.line(`private class ${name}Converter : JsonConverter<${name}>`) + p.line('{') + p.block(() => { + p.line( + `public override ${name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)` + ) + p.line('{') + p.block(() => { + p.line('var jsonDoc = JsonDocument.ParseValue(ref reader);') + p.line( + `var discriminator = jsonDoc.RootElement.GetProperty("${union.discriminatorDisplayName}").GetString();` + ) + p.line('switch (discriminator)') + p.line('{') + p.block(() => { + for (const member of union.members) { + const typeName = this.formatter.discriminatedUnionTypeName( + union, + member + ) + p.line(`case "${member.value}":`) + p.block(() => { + p.line( + `return JsonSerializer.Deserialize<${typeName}>(jsonDoc.RootElement.GetRawText(), options);` + ) + }) + } + p.line('default:') + p.block(() => { + p.line( + 'throw new JsonException($"Unknown discriminator {discriminator}");' + ) + }) + p.line('}') + }) + }) + p.line('}') + p.line() + + p.line( + `public override void Write(Utf8JsonWriter writer, ${name} value, JsonSerializerOptions options)` + ) + p.line('{') + p.block(() => + p.line('JsonSerializer.Serialize(writer, value, value.GetType(), options);') + ) + + p.line('}') + }) + p.line('}') + }) + }) + }) + } + + closeSealedClass(p: CodePrinter, _: SealedClassOptions): void { + p.line('}') + } + + emitDataClass( + p: CodePrinter, + { name, members, enums, parentClass, isStringType, innerClass }: DataClassOptions + ): void { + // Special case for string types + if (isStringType) { + for (const enum_ of enums) { + this.emitEnum(p, enum_) + } + if (enums.length === 0) { + p.line(`public class ${name} : string {}`) + } + return + } + if (innerClass) { + p.line() + } + const heritage = parentClass ? ` : ${parentClass}` : '' + p.block(() => { + p.line(`public class ${name}${heritage}`) + p.line('{') + p.block(() => { + for (const { info, typeSyntax, formattedName, oneOfComment } of members) { + p.line(`[JsonProperty(PropertyName = "${info.display_name}")]`) + if (oneOfComment.includes('-')) { + p.line(`public string ${formattedName} { get; set; }${oneOfComment}`) + } else { + p.line(`public ${typeSyntax} ${formattedName} { get; set; }${oneOfComment}`) + } + } + if (members.length === 0) { + p.line('public string PlaceholderField { get; set; } // Empty class') + } + if (enums.length > 0) { + this.addJsonImport(p) + for (const enum_ of enums) { + this.emitEnum(p, enum_) + } + } + }) + + p.line('}') + }) + } + + emitEnum(p: CodePrinter, { name, members }: Enum): void { + p.line() + p.line(`public enum ${name}`) + p.line('{') + p.block(() => { + for (const { serializedName, formattedName } of members) { + p.line(`[EnumMember(Value = "${serializedName}")]`) + p.line(`${formattedName},`) + } + }) + p.line('}') + } + + getFileType(): string { + return 'cs' + } + + getFileNameForType(tpe: string): string { + return `${tpe}.${this.getFileType()}`.split('_').map(capitalize).join('') + } + + private addJsonImport(p: CodePrinter): void { + p.addImport('using System.Text.Json.Serialization;') + } +} + +export class CSharpFormatter extends Formatter { + override options: LanguageOptions = { + typeNameSeparator: '', + typeAnnotations: 'before', + voidType: 'Void', + reserved: new Set(), + keywordOverrides: new Map([ + [TypescriptKeyword.Null, 'Void'], + [TypescriptKeyword.Boolean, 'bool'], + [TypescriptKeyword.String, 'string'], + [TypescriptKeyword.Long, 'int'], + ]), + } + + override functionName(info: scip.SymbolInformation): string { + return info.display_name.replaceAll('$/', '').split('/').map(capitalize).join('') + } + + override mapSyntax(key: string, value: string): string { + return `Dictionary<${key}, ${value}>` + } + + override listSyntax(value: string): string { + return `${value}[]` + } + + override formatFieldName(name: string): string { + const escaped = name.replace(':', '_').replace('/', '_') + return this.escape(escaped) + .split('_') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') + .replaceAll('_', '') + } +} diff --git a/agent/src/cli/scip-codegen/emitters/Emitter.ts b/agent/src/cli/scip-codegen/emitters/Emitter.ts new file mode 100644 index 000000000000..80927f213e37 --- /dev/null +++ b/agent/src/cli/scip-codegen/emitters/Emitter.ts @@ -0,0 +1,84 @@ +import type { CodePrinter } from '../../../../../vscode/src/completions/context/retrievers/tsc/CodePrinter' +import type { DiscriminatedUnion } from '../BaseCodegen' +import type { Formatter } from '../Formatter' +import type { scip } from '../scip' + +export interface Emitter { + emitSerializationAdapter(p: CodePrinter, discriminatedUnions: string[]): void + + emitStringLiteralConstants(p: CodePrinter, stringLiterals: string[]): void + + emitNullAlias(p: CodePrinter): void + + emitProtocolInterface(p: CodePrinter, options: ProtocolInterface): void + + startType(p: CodePrinter, options: TypeOptions): void + closeType(p: CodePrinter, options: TypeOptions): void + emitTypeAlias(p: CodePrinter, options: TypeAliasOptions): void + + startSealedClass(p: CodePrinter, options: SealedClassOptions): void + closeSealedClass?(p: CodePrinter, options: SealedClassOptions): void + + emitDataClass(p: CodePrinter, options: DataClassOptions): void + + emitEnum(p: CodePrinter, _enum: Enum): void + + getFileType(): string + getFileNameForType(tpe: string): string + + formatter: Formatter +} + +export interface ProtocolInterface { + name: string + requests: scip.SymbolInformation[] + notifications: scip.SymbolInformation[] +} + +export interface TypeAliasOptions { + name: string + alias: string + isStringType: boolean + info: scip.SymbolInformation + enum?: Enum +} + +export interface TypeOptions { + name: string + info: scip.SymbolInformation + enum?: Enum +} + +export interface SealedClassOptions { + name: string + info: scip.SymbolInformation + union: DiscriminatedUnion +} + +export interface DataClassOptions { + name: string + info: scip.SymbolInformation + members: Member[] + enums: Enum[] + parentClass?: string + innerClass?: boolean + isStringType?: boolean +} + +export interface Member { + info: scip.SymbolInformation + formattedName: string + typeSyntax: string + isNullable: boolean + oneOfComment: string +} + +export interface Enum { + name: string + members: EnumMember[] +} + +export interface EnumMember { + serializedName: string + formattedName: string +} diff --git a/agent/src/cli/scip-codegen/emitters/JavaEmitter.ts b/agent/src/cli/scip-codegen/emitters/JavaEmitter.ts new file mode 100644 index 000000000000..5ca542374af7 --- /dev/null +++ b/agent/src/cli/scip-codegen/emitters/JavaEmitter.ts @@ -0,0 +1,306 @@ +import type { CodePrinter } from '../../../../../vscode/src/completions/context/retrievers/tsc/CodePrinter' +import { + type ConstantType, + type DiscriminatedUnion, + type DiscriminatedUnionMember, + typeOfUnion, +} from '../BaseCodegen' +import type { Codegen } from '../Codegen' +import { Formatter, type LanguageOptions } from '../Formatter' +import type { SymbolTable } from '../SymbolTable' +import type { CodegenOptions } from '../command' +import { TypescriptKeyword } from '../utils' +import type { + DataClassOptions, + Emitter, + Enum, + ProtocolInterface, + SealedClassOptions, + TypeAliasOptions, + TypeOptions, +} from './Emitter' + +export class JavaEmitter implements Emitter { + formatter: JavaFormatter + + constructor( + private options: CodegenOptions, + symtab: SymbolTable, + codegen: Codegen + ) { + this.formatter = new JavaFormatter(symtab, codegen) + } + + emitSerializationAdapter(p: CodePrinter, discriminatedUnions: string[]): void { + p.line(`package ${this.options.kotlinPackage};`) + p.line() + p.line('public final class ProtocolTypeAdapters {') + + p.block(() => { + p.line('public static void register(com.google.gson.GsonBuilder gson) {') + p.block(() => { + for (const name of discriminatedUnions) { + p.line(`gson.registerTypeAdapter(${name}.class, ${name}.deserializer());`) + } + }) + p.line('}') + }) + p.line('}') + } + + emitStringLiteralConstants(p: CodePrinter, stringLiterals: string[]): void { + p.line(`package ${this.options.kotlinPackage};`) + p.line() + p.line('public final class Constants {') + + p.block(() => { + for (const literal of stringLiterals) { + p.line( + `public static final String ${this.formatter.formatFieldName( + literal + )} = "${literal}";` + ) + } + }) + p.line('}') + } + + emitNullAlias(p: CodePrinter): void { + p.line(`package ${this.options.kotlinPackage};`) + p.line() + p.line('public final class Null {}') + } + + emitProtocolInterface(p: CodePrinter, { name, requests, notifications }: ProtocolInterface): void { + p.line(`package ${this.options.kotlinPackage};`) + p.line() + p.line('import org.eclipse.lsp4j.jsonrpc.services.JsonNotification;') + p.line('import org.eclipse.lsp4j.jsonrpc.services.JsonRequest;') + p.line('import java.util.concurrent.CompletableFuture;') + p.line() + p.line('@SuppressWarnings("unused")') + p.line(`public interface ${name} {`) + + p.block(() => { + p.sectionComment('Requests') + for (const request of requests) { + const resultType = request.signature.value_signature.tpe.type_ref.type_arguments?.[1] + const { parameterSyntax } = this.formatter.jsonrpcMethodParameter(request) + const resultTypeSyntax = this.formatter.jsonrpcTypeName(request, resultType, 'result') + p.line(`@JsonRequest("${request.display_name}")`) + p.line( + `CompletableFuture<${resultTypeSyntax}> ${this.formatter.functionName( + request + )}(${parameterSyntax});` + ) + p.line() + } + + p.line() + p.sectionComment('Notifications') + for (const notification of notifications) { + const { parameterSyntax } = this.formatter.jsonrpcMethodParameter(notification) + p.line(`@JsonNotification("${notification.display_name}")`) + p.line(`void ${this.formatter.functionName(notification)}(${parameterSyntax});`) + } + }) + + p.line('}') + } + + startType(p: CodePrinter, _: TypeOptions): void { + p.line(`package ${this.options.kotlinPackage};`) + p.line() + } + + closeType(p: CodePrinter, _: TypeOptions): void { + p.line() + } + + emitTypeAlias(p: CodePrinter, { name, info, enum: enum_ }: TypeAliasOptions): void { + if (name === 'Date') { + p.line('public final class Date {}') + } else if (info.display_name === 'Null') { + p.line('public final class Null {}') + } + if (enum_) { + this.emitEnum(p, enum_) + } else { + p.line(`public final class ${name} {} // TODO: fixme`) + } + } + + startSealedClass(p: CodePrinter, { name, union }: SealedClassOptions): void { + function getDeserializer(union: DiscriminatedUnion): string { + switch (typeOfUnion(union)) { + case 'boolean': + return 'getAsBoolean' + case 'number': + return 'getAsInt' + case 'string': + return 'getAsString' + } + } + function formatValueLiteral(value: ConstantType): string { + switch (typeof value) { + case 'string': + return `"${value}"` + case 'number': + return value.toString() + case 'boolean': + return value.toString() + default: + throw new TypeError('Invalid value type') + } + } + + const getDeserializationClause = (member?: DiscriminatedUnionMember): string | undefined => { + if (!member) return undefined + + const typeName = member ? this.formatter.discriminatedUnionTypeName(union, member) : 'Void' + return `return context.deserialize(element, ${typeName}.class);` + } + + p.line('import com.google.gson.Gson;') + p.line('import com.google.gson.JsonDeserializationContext;') + p.line('import com.google.gson.JsonDeserializer;') + p.line('import com.google.gson.JsonElement;') + p.line('import java.lang.reflect.Type;') + p.line() + p.line(`public abstract class ${name} {`) + p.block(() => { + p.line(`public static JsonDeserializer<${name}> deserializer() {`) + p.block(() => { + p.line('return (element, _type, context) -> {') + p.block(() => { + // In Java, we can't switch on booleans, so we have to use if-else + if (typeOfUnion(union) === 'boolean') { + const trueCase = union.members.find(m => m.value === true) + const falseCase = union.members.find(m => m.value === false) + p.line( + `if (element.getAsJsonObject().get("${union.discriminatorDisplayName}").getAsBoolean()) {` + ) + p.block(() => { + p.line(getDeserializationClause(trueCase)) + }) + p.line('} else {') + p.block(() => { + p.line(getDeserializationClause(falseCase)) + }) + p.line('}') + } else { + p.line( + `switch (element.getAsJsonObject().get("${ + union.discriminatorDisplayName + }").${getDeserializer(union)}()) {` + ) + p.block(() => { + for (const member of union.members) { + const typeName = this.formatter.discriminatedUnionTypeName(union, member) + p.line( + `case ${formatValueLiteral( + member.value + )}: return context.deserialize(element, ${typeName}.class);` + ) + } + p.line( + 'default: throw new RuntimeException("Unknown discriminator " + element);' + ) + }) + p.line('}') + } + }) + p.line('};') + }) + p.line('}') + }) + } + + closeSealedClass(p: CodePrinter, _: SealedClassOptions): void { + p.line('}') + } + + emitDataClass( + p: CodePrinter, + { name, members, enums, innerClass, parentClass }: DataClassOptions + ): void { + const staticModifier = innerClass ? 'static ' : '' + const heritage = parentClass ? ` extends ${parentClass}` : '' + p.line(`public ${staticModifier}final class ${name}${heritage} {`) + + p.block(() => { + for (const { info, typeSyntax, formattedName, oneOfComment } of members) { + const serializedAnnotation = + formattedName === info.display_name + ? '' + : `@com.google.gson.annotations.SerializedName("${info.display_name}") ` + p.line(`${serializedAnnotation}public ${typeSyntax} ${formattedName};${oneOfComment}`) + } + }) + + p.block(() => { + for (const _enum of enums) { + this.emitEnum(p, _enum) + } + }) + p.line('}') + } + + emitEnum(p: CodePrinter, { name, members }: Enum): void { + p.line() + p.line(`public enum ${name} {`) + p.block(() => { + for (const member of members) { + p.line( + `@com.google.gson.annotations.SerializedName("${member.serializedName}") ${member.formattedName},` + ) + } + }) + p.line('}') + } + + getFileNameForType(tpe: string): string { + return `${tpe}.${this.getFileType()}` + } + + getFileType(): string { + return 'java' + } +} + +export class JavaFormatter extends Formatter { + public options: LanguageOptions = { + typeNameSeparator: '_', + typeAnnotations: 'before', + voidType: 'Void', + reserved: new Set([ + 'class', + 'interface', + 'object', + 'package', + 'var', + 'default', + 'case', + 'switch', + 'native', + ]), + keywordOverrides: new Map([[TypescriptKeyword.Null, 'Void']]), + } + + mapSyntax(key: string, value: string): string { + return `java.util.Map<${key}, ${value}>` + } + + listSyntax(value: string): string { + return `java.util.List<${value}>` + } + + override formatFieldName(name: string): string { + const escaped = name.replace(':', '_').replace('/', '_') + const isKeyword = this.options.reserved.has(escaped) + if (isKeyword) { + return escaped + '_' + } + return escaped.replace(/[^a-zA-Z0-9]/g, '_') + } +} diff --git a/agent/src/cli/scip-codegen/emitters/KotlinEmitter.ts b/agent/src/cli/scip-codegen/emitters/KotlinEmitter.ts new file mode 100644 index 000000000000..53bb019fefd5 --- /dev/null +++ b/agent/src/cli/scip-codegen/emitters/KotlinEmitter.ts @@ -0,0 +1,276 @@ +import type { CodePrinter } from '../../../../../vscode/src/completions/context/retrievers/tsc/CodePrinter' +import { type ConstantType, type DiscriminatedUnion, typeOfUnion } from '../BaseCodegen' +import type { Codegen } from '../Codegen' +import { Formatter, type LanguageOptions } from '../Formatter' +import type { SymbolTable } from '../SymbolTable' +import type { CodegenOptions } from '../command' +import { TypescriptKeyword, capitalize } from '../utils' +import type { + DataClassOptions, + Emitter, + Enum, + ProtocolInterface, + SealedClassOptions, + TypeAliasOptions, + TypeOptions, +} from './Emitter' + +export class KotlinEmitter implements Emitter { + formatter: KotlinFormatter + constructor( + private options: CodegenOptions, + symtab: SymbolTable, + codegen: Codegen + ) { + this.formatter = new KotlinFormatter(symtab, codegen) + } + + emitStringLiteralConstants(p: CodePrinter, stringLiterals: string[]): void { + p.line('@file:Suppress("unused", "ConstPropertyName")') + p.line(`package ${this.options.kotlinPackage};`) + p.line() + p.line('object Constants {') + p.block(() => { + for (const literal of stringLiterals) { + p.line(`const val ${this.formatter.formatFieldName(literal)} = "${literal}"`) + } + }) + p.line('}') + } + + emitNullAlias(p: CodePrinter): void { + p.line(`package ${this.options.kotlinPackage};`) + p.line() + p.line('typealias Null = Void?') + } + + emitSerializationAdapter(p: CodePrinter, discriminatedUnions: string[]): void { + p.line('@file:Suppress("unused", "ConstPropertyName")') + p.line(`package ${this.options.kotlinPackage};`) + p.line() + p.line('object ProtocolTypeAdapters {') + + p.block(() => { + p.line('fun register(gson: com.google.gson.GsonBuilder) {') + p.block(() => { + for (const name of discriminatedUnions) { + p.line(`gson.registerTypeAdapter(${name}::class.java, ${name}.deserializer)`) + } + }) + p.line('}') + }) + p.line('}') + } + + emitProtocolInterface(p: CodePrinter, { name, requests, notifications }: ProtocolInterface): void { + p.line('@file:Suppress("FunctionName", "ClassName", "RedundantNullable")') + p.line(`package ${this.options.kotlinPackage};`) + p.line() + p.line('import org.eclipse.lsp4j.jsonrpc.services.JsonNotification;') + p.line('import org.eclipse.lsp4j.jsonrpc.services.JsonRequest;') + p.line('import java.util.concurrent.CompletableFuture;') + p.line() + p.line('@Suppress("unused")') + p.line(`interface ${name} {`) + p.block(() => { + p.sectionComment('Requests') + for (const request of requests) { + const resultType = request.signature.value_signature.tpe.type_ref.type_arguments?.[1] + const { parameterSyntax } = this.formatter.jsonrpcMethodParameter(request) + const resultTypeSyntax = this.formatter.jsonrpcTypeName(request, resultType, 'result') + p.line(`@JsonRequest("${request.display_name}")`) + p.line( + `fun ${this.formatter.functionName( + request + )}(${parameterSyntax}): CompletableFuture<${resultTypeSyntax}>` + ) + } + p.line() + p.sectionComment('Notifications') + for (const notification of notifications) { + // Process a JSON-RPC request signature. For example: + // type Notifications = { 'textDocument/inlineCompletions': [NotificationParams] } + const { parameterSyntax } = this.formatter.jsonrpcMethodParameter(notification) + const notificationName = this.formatter.functionName(notification) + p.line(`@JsonNotification("${notification.display_name}")`) + p.line(`fun ${notificationName}(${parameterSyntax})`) + } + }) + + p.line('}') + } + + startType(p: CodePrinter, _: TypeOptions): void { + p.line('@file:Suppress("FunctionName", "ClassName", "unused", "EnumEntryName", "UnusedImport")') + p.line(`package ${this.options.kotlinPackage};`) + p.line() + } + + closeType(p: CodePrinter, _: TypeOptions): void { + p.line() + } + + emitTypeAlias(p: CodePrinter, { name, alias }: TypeAliasOptions): void { + p.line(`typealias ${name} = ${alias}`) + } + + startSealedClass(p: CodePrinter, { name, union }: SealedClassOptions): void { + function getDeserializer(union: DiscriminatedUnion): string { + switch (typeOfUnion(union)) { + case 'boolean': + return 'getAsBoolean' + case 'number': + return 'getAsInt' + case 'string': + return 'getAsString' + } + } + function formatValueLiteral(value: ConstantType): string { + switch (typeof value) { + case 'string': + return `"${value}"` + case 'number': + return value.toString() + case 'boolean': + return value.toString() + } + } + p.line('import com.google.gson.Gson;') + p.line('import com.google.gson.JsonDeserializationContext;') + p.line('import com.google.gson.JsonDeserializer;') + p.line('import com.google.gson.JsonElement;') + p.line('import java.lang.reflect.Type;') + p.line() + p.line(`sealed class ${name} {`) + p.block(() => { + p.line('companion object {') + p.block(() => { + p.line(`val deserializer: JsonDeserializer<${name}> =`) + p.block(() => { + p.line( + 'JsonDeserializer { element: JsonElement, _: Type, context: JsonDeserializationContext ->' + ) + p.block(() => { + p.line( + `when (element.getAsJsonObject().get("${ + union.discriminatorDisplayName + }").${getDeserializer(union)}()) {` + ) + p.block(() => { + for (const member of union.members) { + const typeName = this.formatter.discriminatedUnionTypeName(union, member) + p.line( + `${formatValueLiteral( + member.value + )} -> context.deserialize<${typeName}>(element, ${typeName}::class.java)` + ) + } + if (typeOfUnion(union) !== 'boolean') { + p.line('else -> throw Exception("Unknown discriminator ${element}")') + } + }) + p.line('}') + }) + p.line('}') + }) + }) + p.line('}') + }) + p.line('}') + } + + emitDataClass( + p: CodePrinter, + { name, members, enums, parentClass, innerClass }: DataClassOptions + ): void { + if (innerClass) { + p.line() + } + p.line(`data class ${name}(`) + p.block(() => { + for (const member of members) { + p.line( + `val ${member.info.display_name}: ${member.typeSyntax}${ + member.isNullable ? ' = null' : '' + },${member.oneOfComment}` + ) + } + if (members.length === 0) { + p.line('val placeholderField: String? = null // Empty data class') + } + }) + const heritage = parentClass ? ` : ${parentClass}()` : '' + if (enums.length === 0) { + p.line(`)${heritage}`) + } else { + p.line(`)${heritage} {`) + p.block(() => { + p.addImport('import com.google.gson.annotations.SerializedName;') + for (const enum_ of enums) { + this.emitEnum(p, enum_) + } + }) + p.line('}') + } + } + + emitEnum(p: CodePrinter, { name, members }: Enum): void { + p.line() + p.line(`enum class ${name} {`) + p.block(() => { + for (const member of members) { + p.line(`@SerializedName("${member.serializedName}") ${member.formattedName},`) + } + }) + p.line('}') + } + + getFileNameForType(tpe: string): string { + return `${capitalize(tpe)}.${this.getFileType()}` + } + + getFileType(): string { + return 'kt' + } +} + +export class KotlinFormatter extends Formatter { + public options: LanguageOptions = { + typeNameSeparator: '_', + typeAnnotations: 'after', + nullableSyntax: '?', + voidType: 'Null', + reserved: new Set([ + 'class', + 'interface', + 'object', + 'package', + 'typealias', + 'val', + 'var', + 'fun', + 'when', + ]), + keywordOverrides: new Map([ + [TypescriptKeyword.Null, 'Null'], + [TypescriptKeyword.Object, 'Any'], + ]), + } + + override formatFieldName(name: string): string { + const escaped = name.replace(':', '_').replace('/', '_') + const isKeyword = this.options.reserved.has(escaped) + const needsBacktick = isKeyword || !/^[a-zA-Z0-9_]+$/.test(escaped) + // Replace all non-alphanumeric characters with underscores + const fieldName = this.escape(escaped, '-') + return needsBacktick ? `\`${fieldName}\`` : fieldName + } + + public mapSyntax(key: string, value: string): string { + return `Map<${key}, ${value}>` + } + + public listSyntax(value: string): string { + return `List<${value}>` + } +} diff --git a/agent/src/cli/scip-codegen/utils.ts b/agent/src/cli/scip-codegen/utils.ts index a32177379c56..5552a8a759d7 100644 --- a/agent/src/cli/scip-codegen/utils.ts +++ b/agent/src/cli/scip-codegen/utils.ts @@ -1,27 +1,34 @@ -import { TargetLanguage } from './Codegen' - -export function typescriptKeywordSyntax(language: TargetLanguage, symbol: string): string | undefined { +export function typescriptKeywordSyntax(symbol: string): TypescriptKeyword | undefined { switch (symbol) { case 'scip-typescript npm typescript . array#': - return 'List' + return TypescriptKeyword.List case 'scip-typescript npm typescript . null#': - return language === TargetLanguage.Kotlin ? 'Null' : 'Void' + return TypescriptKeyword.Null case 'scip-typescript npm typescript . string#': - return 'String' + return TypescriptKeyword.String case 'scip-typescript npm typescript . false#': case 'scip-typescript npm typescript . true#': case 'scip-typescript npm typescript . boolean#': - return 'Boolean' + return TypescriptKeyword.Boolean case 'scip-typescript npm typescript . number#': - return 'Long' + return TypescriptKeyword.Long case 'scip-typescript npm typescript . any#': case 'scip-typescript npm typescript . unknown#': - return language === TargetLanguage.Kotlin ? 'Any' : 'Object' + return TypescriptKeyword.Object default: return undefined } } +export enum TypescriptKeyword { + List = 'List', + Null = 'Null', + String = 'String', + Boolean = 'Boolean', + Long = 'Long', + Object = 'Object', +} + export function isBooleanTypeRef(symbol: string): boolean { switch (symbol) { case 'scip-typescript npm typescript . false#': @@ -40,9 +47,9 @@ export function capitalize(text: string): string { return text[0].toUpperCase() + text.slice(1) } -export function isTypescriptKeyword(language: TargetLanguage, symbol: string): boolean { +export function isTypescriptKeyword(symbol: string): boolean { return ( - typescriptKeywordSyntax(language, symbol) !== undefined && + typescriptKeywordSyntax(symbol) !== undefined && symbol !== 'scip-typescript npm typescript . array#' ) } diff --git a/agent/src/currentProtocolAuthStatus.ts b/agent/src/currentProtocolAuthStatus.ts new file mode 100644 index 000000000000..6743c1c1e1c7 --- /dev/null +++ b/agent/src/currentProtocolAuthStatus.ts @@ -0,0 +1,31 @@ +import { + type AuthStatus, + currentAuthStatus, + currentAuthStatusOrNotReadyYet, +} from '@sourcegraph/cody-shared' +import type { ProtocolAuthStatus } from './protocol-alias' + +export function toProtocolAuthStatus(status: AuthStatus): ProtocolAuthStatus { + if (status.authenticated) { + return { + status: 'authenticated', + ...status, + } + } + return { + status: 'unauthenticated', + ...status, + } +} + +export function currentProtocolAuthStatus(): ProtocolAuthStatus { + return toProtocolAuthStatus(currentAuthStatus()) +} + +export function currentProtocolAuthStatusOrNotReadyYet(): ProtocolAuthStatus | undefined { + const status = currentAuthStatusOrNotReadyYet() + if (status) { + return toProtocolAuthStatus(status) + } + return undefined +} diff --git a/agent/src/enterprise-demo.test.ts b/agent/src/enterprise-demo.test.ts index bc44c8326163..531b3d839835 100644 --- a/agent/src/enterprise-demo.test.ts +++ b/agent/src/enterprise-demo.test.ts @@ -22,7 +22,10 @@ describe('Enterprise', () => { if (!serverInfo.authStatus?.authenticated) { throw new Error('unreachable') } - expect(serverInfo.authStatus?.username).toStrictEqual('codytesting') + expect(serverInfo.authStatus?.status).toStrictEqual('authenticated') + if (serverInfo.authStatus?.status === 'authenticated') { + expect(serverInfo.authStatus?.username).toStrictEqual('codytesting') + } }, 10_000) // Skip because it consistently fails with: diff --git a/agent/src/enterprise-s2.test.ts b/agent/src/enterprise-s2.test.ts index 77868375480c..46a64bfa6a9b 100644 --- a/agent/src/enterprise-s2.test.ts +++ b/agent/src/enterprise-s2.test.ts @@ -39,13 +39,17 @@ describe('Enterprise - S2 (close main branch)', { timeout: 5000 }, () => { if (!serverInfo.authStatus?.authenticated) { throw new Error('unreachable') } - expect(serverInfo.authStatus?.username).toStrictEqual('codytesting') + expect(serverInfo.authStatus?.status).toStrictEqual('authenticated') + if (serverInfo.authStatus?.status === 'authenticated') { + expect(serverInfo.authStatus?.username).toStrictEqual('codytesting') + } }, 10_000) it('creates an autocomplete provider using server-side model config from S2', async () => { - const { id, legacyModel, configSource } = ( - await s2EnterpriseClient.request('testing/autocomplete/providerConfig', null) - ).provider + const { id, legacyModel, configSource } = await s2EnterpriseClient.request( + 'testing/autocomplete/providerConfig', + null + ) expect({ id, legacyModel, configSource }).toMatchInlineSnapshot(` { diff --git a/agent/src/index.test.ts b/agent/src/index.test.ts index b3191cb35aea..4a97b48206ff 100644 --- a/agent/src/index.test.ts +++ b/agent/src/index.test.ts @@ -154,8 +154,8 @@ describe('Agent', () => { serverEndpoint: client.info.extensionConfiguration?.serverEndpoint ?? DOTCOM_URL.toString(), customHeaders: {}, }) - expect(valid?.authenticated).toBeTruthy() - if (!valid?.authenticated) { + expect(valid?.status).toEqual('authenticated') + if (valid?.status !== 'authenticated') { throw new Error('unreachable') } @@ -174,7 +174,7 @@ describe('Agent', () => { // source agent/scripts/export-cody-http-recording-tokens.sh // // If you don't have access to this private file then you need to ask - expect(valid?.username).toStrictEqual('sourcegraphbot9k-fnwmu') + expect(valid.username).toStrictEqual('sourcegraphbot9k-fnwmu') // telemetry assertion, to validate the expected events fired during the test run // Do not remove this assertion, and instead update the expectedEvents list above @@ -254,8 +254,8 @@ describe('Agent', () => { transcript: transcript, }) const auth = await client.request('extensionConfiguration/status', null) - expect(auth?.authenticated).toBeTruthy() - if (!auth?.authenticated) { + expect(auth?.status).toEqual('authenticated') + if (auth?.status !== 'authenticated') { throw new Error('unreachable') } @@ -324,7 +324,7 @@ describe('Agent', () => { // The history we are importing contains two transcripts from the same user and one from a different user. // when we do an export, we should only get the transcript from the currently logged in user const history: Record> = { - [`${auth?.endpoint}-${auth?.username}`]: { + [`${auth.endpoint}-${auth.username}`]: { [transcript1.id]: transcript1, [transcript2.id]: transcript2, }, @@ -681,11 +681,11 @@ describe('Agent', () => { beforeAll(async () => { const serverInfo = await rateLimitedClient.initialize() - expect(serverInfo.authStatus?.authenticated).toBeTruthy() - if (!serverInfo.authStatus?.authenticated) { + expect(serverInfo.authStatus?.status).toEqual('authenticated') + if (serverInfo.authStatus?.status !== 'authenticated') { throw new Error('unreachable') } - expect(serverInfo.authStatus?.username).toStrictEqual('sourcegraphcodyclients-1-efapb') + expect(serverInfo.authStatus.username).toStrictEqual('sourcegraphcodyclients-1-efapb') }, 10_000) // Skipped because Polly is failing to record the HTTP rate-limit error diff --git a/lib/shared/src/auth/types.ts b/lib/shared/src/auth/types.ts index fd8fa75b4ab6..b770f282021a 100644 --- a/lib/shared/src/auth/types.ts +++ b/lib/shared/src/auth/types.ts @@ -51,21 +51,23 @@ export interface UnauthenticatedAuthStatus { } export const AUTH_STATUS_FIXTURE_AUTHED: AuthenticatedAuthStatus = { - endpoint: 'https://example.com', + // this typecast is necessary to prevent codegen from becoming too specific + endpoint: 'https://example.com' as string, authenticated: true, username: 'alice', pendingValidation: false, } export const AUTH_STATUS_FIXTURE_UNAUTHED: AuthStatus & { authenticated: false } = { - endpoint: 'https://example.com', + // this typecast is necessary to prevent codegen from becoming too specific + endpoint: 'https://example.com' as string, authenticated: false, pendingValidation: false, } export const AUTH_STATUS_FIXTURE_AUTHED_DOTCOM: AuthenticatedAuthStatus = { ...AUTH_STATUS_FIXTURE_AUTHED, - endpoint: 'https://sourcegraph.com', + endpoint: 'https://sourcegraph.com' as string, } export function isCodyProUser(authStatus: AuthStatus, sub: UserProductSubscription | null): boolean { diff --git a/vscode/src/jsonrpc/agent-protocol.ts b/vscode/src/jsonrpc/agent-protocol.ts index 570ef2d9fa73..ab3f72946016 100644 --- a/vscode/src/jsonrpc/agent-protocol.ts +++ b/vscode/src/jsonrpc/agent-protocol.ts @@ -1,7 +1,6 @@ import type * as vscode from 'vscode' import type { - AuthStatus, BillingCategory, BillingProduct, CodyCommand, @@ -23,7 +22,6 @@ import type { import type { ExtensionMessage, WebviewMessage } from '../chat/protocol' import type { CompletionBookkeepingEvent, CompletionItemID } from '../completions/analytics-logger' -import type { InlineCompletionItemProviderConfig } from '../completions/inline-completion-item-provider-config-singleton' import type { FixupTaskID } from '../non-stop/FixupTask' import type { CodyTaskState } from '../non-stop/state' @@ -238,16 +236,19 @@ export type ClientRequests = { 'testing/autocomplete/setCompletionVisibilityDelay': [{ delay: number }, null] // For testing purposes, returns the current autocomplete provider configuration. - 'testing/autocomplete/providerConfig': [null, InlineCompletionItemProviderConfig] + 'testing/autocomplete/providerConfig': [ + null, + { id: string; legacyModel: string; configSource: string }, + ] // Updates the extension configuration and returns the new // authentication status, which indicates whether the provided credentials are // valid or not. The agent can't support autocomplete or chat if the credentials // are invalid. - 'extensionConfiguration/change': [ExtensionConfiguration, AuthStatus | null] + 'extensionConfiguration/change': [ExtensionConfiguration, ProtocolAuthStatus | null] // Returns the current authentication status without making changes to it. - 'extensionConfiguration/status': [null, AuthStatus | null] + 'extensionConfiguration/status': [null, ProtocolAuthStatus | null] // Returns the json schema of the extension confi 'extensionConfiguration/getSettingsSchema': [null, string] @@ -626,7 +627,7 @@ export interface WebviewNativeConfig { export interface ServerInfo { name: string authenticated?: boolean | undefined | null - authStatus?: AuthStatus | undefined | null + authStatus?: ProtocolAuthStatus | undefined | null } export interface ExtensionConfiguration { @@ -736,6 +737,53 @@ export interface Range { end: Position } +// Equivalent to our internal `AuthStatus` type but using a string discriminator +// instead of a boolean discriminator. Boolean discriminators complicate +// deserializing in other languages. We have custom codegen for string +// discriminators but not boolean ones. +// It's good practice to be more intentional about the Agent protocol types +// anyways. As a rule of thumb, we should try to avoid leaking internal types +// that are constantly making tiny changes that are irrelevant for the other +// clients anyways. +export type ProtocolAuthStatus = ProtocolAuthenticatedAuthStatus | ProtocolUnauthenticatedAuthStatus + +export interface ProtocolAuthenticatedAuthStatus { + status: 'authenticated' + authenticated: boolean + endpoint: string + + username: string + + /** + * Used to enable Fireworks tracing for Sourcegraph teammates on DotCom. + * https://readme.fireworks.ai/docs/enabling-tracing + */ + isFireworksTracingEnabled?: boolean | null | undefined + hasVerifiedEmail?: boolean | null | undefined + requiresVerifiedEmail?: boolean | null | undefined + + primaryEmail?: string | null | undefined + displayName?: string | null | undefined + avatarURL?: string | null | undefined + + pendingValidation: boolean + + /** + * Organizations on the instance that the user is a member of. + */ + organizations?: { name: string; id: string }[] | null | undefined +} + +export interface ProtocolUnauthenticatedAuthStatus { + status: 'unauthenticated' + authenticated: boolean + endpoint: string + showNetworkError?: boolean | null | undefined + + showInvalidAccessTokenError?: boolean | null | undefined + pendingValidation: boolean +} + export interface ProtocolTextDocument { // Use TextDocumentWithUri.fromDocument(TextDocument) if you want to parse this `uri` property. uri: string