Skip to content

Commit ab81d28

Browse files
committed
feat: support option choice inference from union of literals
1 parent 491ada8 commit ab81d28

File tree

5 files changed

+152
-40
lines changed

5 files changed

+152
-40
lines changed

README.md

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,6 @@ export default (name: string, style: string) => {
132132
}
133133
```
134134

135-
> **Coming Soon:** Support choices inferred from Typescript literal union types
136-
137135
## Web Interface
138136

139137
Access the command management interface at `/discord/slash-commands` in your application. The interface provides:
@@ -272,12 +270,7 @@ Here's what's planned for future releases:
272270

273271
## Contribution
274272

275-
<details>
276-
<summary>Local development</summary>
277-
278-
Refer to the official [Module Author Guide](https://nuxt.com/docs/guide/going-further/modules)
279-
280-
</details>
273+
Refer to the official [Module Author Guide](https://nuxt.com/docs/guide/going-further/modules)
281274

282275
## Acknowledgement
283276

playground/discord/commands/greet.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,17 @@
44
* @param name The person to greet
55
* @param style The greeting style
66
*/
7-
export default (name: string, style: string) => {
7+
export default (name: string, style: 'formal' | 'casual' | 'enthusiastic') => {
88
describeOption(name, {
99
minLength: 1,
1010
maxLength: 32,
1111
})
1212

13-
describeOption(style, {
14-
choices: [
15-
{ name: 'Formal', value: 'formal' },
16-
{ name: 'Casual', value: 'casual' },
17-
{ name: 'Enthusiastic', value: 'enthusiastic' },
18-
],
19-
})
20-
2113
const greetings = {
2214
formal: `Good day, ${name}.`,
2315
casual: `Hey ${name}!`,
2416
enthusiastic: `HELLO THERE ${name.toUpperCase()}!!! 🎉`,
2517
} as const
2618

27-
return greetings[style as keyof typeof greetings]
19+
return greetings[style]
2820
}

src/runtime/client/pages/slash-commands.vue

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ const commandsWithStatus = computed(() => {
120120
name: cmd.name,
121121
description: cmd.description,
122122
path: 'unknown',
123-
options: (cmd.options as SlashCommandOption[]),
123+
options: (cmd.options as SlashCommandOption[]) ?? [],
124124
status: 'removed' as const,
125125
remote: cmd,
126126
})),
@@ -277,7 +277,6 @@ const statusClasses = {
277277
/>
278278
</div>
279279

280-
<!-- <UButton icon="i-lucide-circle" @click="test" /> -->
281280
<UButton
282281
v-if="!allSynced"
283282
:icon="pendingSync ? 'i-lucide-refresh-ccw' : 'i-lucide-cloud-upload'"
@@ -398,8 +397,12 @@ const statusClasses = {
398397
<!-- Choices dropdown -->
399398
<UDropdownMenu
400399
v-if="'choices' in option && option.choices && option.choices.length > 0"
401-
:items="option.choices.map(choice => ({ label: choice.name }))"
400+
:items="option.choices.map(choice => ({
401+
label: choice.name,
402+
value: typeof choice.value === 'string' ? `'${choice.value}'` : choice.value,
403+
}))"
402404
:popper="{ placement: 'bottom-start' }"
405+
:ui="{ itemTrailing: 'text-sm text-gray-500 dark:text-gray-400' }"
403406
>
404407
<UBadge
405408
v-if="'choices' in option && option.choices && option.choices.length > 0"
@@ -409,6 +412,9 @@ const statusClasses = {
409412
size="sm"
410413
class="hover:cursor-pointer"
411414
/>
415+
<template #item-trailing="{ item: { value } }">
416+
{{ value }}
417+
</template>
412418
</UDropdownMenu>
413419
</div>
414420

src/runtime/server/utils/describeOption.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,37 @@ export interface DescribeOptionOptionsBase {
99
description?: string
1010
}
1111

12-
export interface IntegerOption extends DescribeOptionOptionsBase {
12+
export interface IntegerOption<T extends number = number> extends DescribeOptionOptionsBase {
1313
/** The minimum value for the integer option. */
1414
min?: number
1515

1616
/** The maximum value for the integer option. */
1717
max?: number
1818

1919
/** An array of choices for the integer option. */
20-
choices?: APIApplicationCommandOptionChoice<number>[]
20+
choices?: APIApplicationCommandOptionChoice<T>[]
2121
}
2222

23-
export interface NumberOption extends DescribeOptionOptionsBase {
23+
export interface NumberOption<T extends number = number> extends DescribeOptionOptionsBase {
2424
/** The minimum value for the number option. */
2525
min?: number
2626

2727
/** The maximum value for the number option. */
2828
max?: number
2929

3030
/** An array of choices for the number option. */
31-
choices?: APIApplicationCommandOptionChoice<number>[]
31+
choices?: APIApplicationCommandOptionChoice<T>[]
3232
}
3333

34-
export interface StringOption extends DescribeOptionOptionsBase {
34+
export interface StringOption<T extends string = string> extends DescribeOptionOptionsBase {
3535
/** The minimum length of the string option. */
3636
minLength?: number
3737

3838
/** The maximum length of the string option. */
3939
maxLength?: number
4040

4141
/** An array of choices for the string option. */
42-
choices?: APIApplicationCommandOptionChoice<string>[]
42+
choices?: APIApplicationCommandOptionChoice<T>[]
4343
}
4444

4545
export type DescribeOptionOptions
@@ -48,12 +48,33 @@ export type DescribeOptionOptions
4848
| StringOption
4949

5050
/**
51+
* A compiler macro that describes a slash command option. Used for defining
52+
* metadata for registering slash command options. Options defined here will
53+
* take higher precedence over the inferred ones from the function signature
54+
* and JSDoc tags.
55+
*
56+
* @param _option - The type of the option, used for type inference.
57+
* @param _options - The options for the command option.
58+
* @example
59+
* // this will describe the name option as a string with a minimum length of 1 and a maximum length of 32
60+
* describeOption(name, {
61+
* minLength: 1,
62+
* maxLength: 32,
63+
* })
64+
* // this will describe the style option as a string with three choices
65+
* describeOption(style, {
66+
* choices: [
67+
* { name: 'Formal', value: 'formal' },
68+
* { name: 'Casual', value: 'casual' },
69+
* { name: 'Enthusiastic', value: 'enthusiastic' },
70+
* ],
71+
* })
5172
*/
5273
export function describeOption<const T extends SlashCommandOptionType>(
5374
_option: T | undefined,
54-
_options: T extends integer ? IntegerOption
55-
: T extends number ? NumberOption
56-
: T extends string ? StringOption : DescribeOptionOptionsBase,
75+
_options: T extends integer ? IntegerOption<T>
76+
: T extends number ? NumberOption<T>
77+
: T extends string ? StringOption<T> : never,
5778
): void {
58-
// This function is a placeholder for the build time macro
79+
// This function is a placeholder for the build time macro
5980
}

src/utils/collect.ts

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,28 +71,128 @@ export function processCommandFile(ctx: NuxtDiscordContext, file: string): Slash
7171
return
7272
}
7373

74+
const whichLiteral = (node: ts.TypeNode): SlashCommandOptionTypeIdentifier | undefined => {
75+
if (ts.isLiteralTypeNode(node)) {
76+
if (ts.isStringLiteral(node.literal)) {
77+
return 'string'
78+
}
79+
if (ts.isNumericLiteral(node.literal)) {
80+
return 'number'
81+
}
82+
}
83+
}
84+
85+
const getType = (type: ts.TypeNode | undefined): {
86+
type: SlashCommandOptionTypeIdentifier
87+
choices?: (string | number)[]
88+
} => {
89+
if (!type) {
90+
ctx.logger.warn(`No type found for slash command option in ${file}, defaulting to string`)
91+
return { type: 'string' as const }
92+
}
93+
94+
if (ts.isUnionTypeNode(type)) {
95+
const types = new Set(type.types.map(t => whichLiteral(t)))
96+
if (types.size !== 1) {
97+
ctx.logger.warn(`Union type with multiple conflicting types found in ${file}, defaulting to string`)
98+
return { type: 'string' as const }
99+
}
100+
const typeName = types.values().next().value
101+
if (!(typeName! in typeIdentifierToEnum)) {
102+
ctx.logger.warn(`Unrecognizable type ${type.getText(sourceFile)}, defaulting to string`)
103+
return { type: 'string' as const }
104+
}
105+
return {
106+
type: typeName as SlashCommandOptionTypeIdentifier,
107+
choices: typeName === 'string'
108+
? type.types.map(t => t.getText(sourceFile).slice(1, -1))
109+
: type.types.map(t => Number(t.getText(sourceFile))),
110+
}
111+
}
112+
113+
if (ts.isLiteralTypeNode(type)) {
114+
const typeName = whichLiteral(type)
115+
if (!typeName) {
116+
ctx.logger.warn(`Unrecognizable literal type ${type.getText(sourceFile)}, defaulting to string`)
117+
return { type: 'string' as const }
118+
}
119+
if (!(typeName in typeIdentifierToEnum)) {
120+
ctx.logger.warn(`Unrecognizable literal type ${type.getText(sourceFile)}, defaulting to string`)
121+
return { type: 'string' as const }
122+
}
123+
return {
124+
type: typeName,
125+
choices: typeName === 'string'
126+
? [type.literal.getText(sourceFile).slice(1, -1)]
127+
: [Number(type.literal.getText(sourceFile))],
128+
}
129+
}
130+
131+
const typeName = type.getText(sourceFile)
132+
if (typeName && typeName in typeIdentifierToEnum) {
133+
return { type: typeName as SlashCommandOptionTypeIdentifier }
134+
}
135+
136+
ctx.logger.warn(`Unknown slash command option type: ${type.getText(sourceFile)} in ${file}, defaulting to string`)
137+
return { type: 'string' as const }
138+
}
139+
74140
for (const param of commandDefinition.parameters) {
75141
const name = param.name.getText(sourceFile)
76-
let type = param.type?.getText(sourceFile)
142+
// let type = param.type?.getText(sourceFile)
143+
let { type, choices } = getType(param.type)
144+
145+
const jsDocTagIdx = jsDocTags
146+
.findIndex(tag => ts.isJSDocParameterTag(tag) && tag.name.getText() === name)
147+
148+
const findModifiers = <K extends string>(modifiers: K[]): Record<K, any> => {
149+
let idx = jsDocTagIdx
150+
const ret = {} as Record<K, any>
151+
while (idx >= 0) {
152+
const tag = jsDocTags[idx]
153+
if (modifiers.includes(tag.tagName.escapedText as string as K) && tag.comment) {
154+
ret[tag.tagName.escapedText as string as K] = JSON.parse(tag.comment.toString())
155+
}
156+
else {
157+
return ret
158+
}
159+
idx -= 1
160+
}
161+
return ret
162+
}
77163

78-
// TODO: support literal union types
164+
const jsdocDescription = jsDocTagIdx !== -1
165+
? jsDocTags[jsDocTagIdx].comment?.toString() ?? ''
166+
: ''
79167

80-
if (!type || !(type in typeIdentifierToEnum)) {
81-
ctx.logger.warn(`Unknown slash command option type: ${type} for parameter ${name} in ${file}, defaulting to string`)
82-
type = 'string' as const
168+
const modifiersMap = {
169+
string: ['minLength', 'maxLength', 'choices'],
170+
number: ['min', 'max', 'choices'],
171+
integer: ['min', 'max', 'choices'],
172+
boolean: [],
83173
}
84174

85-
const jsdocDescription = jsDocTags
86-
.find(tag => ts.isJSDocParameterTag(tag) && tag.name.getText() === name)
87-
?.comment
88-
?.toString() ?? ''
175+
const modifiers = findModifiers(modifiersMap[type as SlashCommandOptionTypeIdentifier])
176+
177+
choices = choices ?? modifiers.choices
89178

90179
command.options!.push({
91180
name,
92181
// TODO: remove this type assertion
93182
type: typeIdentifierToEnum[type as SlashCommandOptionTypeIdentifier],
94183
description: jsdocDescription,
95184
required: !param.questionToken,
185+
...modifiers,
186+
...choices
187+
? {
188+
choices: choices.map((choice) => {
189+
if (typeof choice === 'string') {
190+
return { name: choice, value: choice }
191+
}
192+
return { name: String(choice), value: choice }
193+
}) as any, // hmm... is it possible to not use `any` here?
194+
}
195+
: {},
96196
})
97197
}
98198

0 commit comments

Comments
 (0)