A generic parser for slash command text input
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial commit

Ben Pevsner da9e68c9

+460
+10
.editorconfig
··· 1 + root = true 2 + 3 + [*] 4 + charset = utf-8 5 + end_of_line = lf 6 + indent_size = 2 7 + indent_style = space 8 + insert_final_newline = true 9 + tab_width = 2 10 + trim_trailing_whitespace = true
+1
.gitignore
··· 1 + .DS_Store
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2023 Ben Pevsner 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+71
README.md
··· 1 + # Slash Command Parser 2 + 3 + A generic parser for slash command text input. Pulls inspiration from Slack and Discord slash commands. 4 + 5 + # Usage 6 + 7 + Basic Usage: 8 + 9 + ```ts 10 + import parse from 'https://deno.land/x/slash_command_parser/mod.ts' 11 + 12 + parse('/todos add name: My Todo Name') 13 + ``` 14 + 15 + ```ts 16 + { 17 + text: 'add name: My Todo Name', 18 + command: 'todos', 19 + options: { name: 'My Todo Name' }, 20 + subCommands: ['add'], 21 + } 22 + ``` 23 + 24 + Pass in a template to parse options: 25 + 26 + ```ts 27 + import parse, { 28 + OptionDefinition, 29 + OptionType, 30 + } from 'https://deno.land/x/slash_command_parser/mod.ts' 31 + 32 + const template: OptionDefinition[] = [ 33 + { name: 'item', type: OptionType.string }, 34 + { name: 'howmany', type: OptionType.integer }, 35 + { name: 'complete', type: OptionType.boolean }, 36 + ] 37 + 38 + parse('/todos add item: lettuce howmany: 2 complete: false', template) 39 + ``` 40 + 41 + ```ts 42 + { 43 + text: 'add item: lettuce howmany: 2 complete: false', 44 + command: 'todos', 45 + options: { item: 'lettuce', howmany: 2, complete: false }, 46 + subCommands: ['add'], 47 + } 48 + ``` 49 + 50 + If you want greater control over parsing or error handling, you can call each step individually: 51 + 52 + ```ts 53 + import { 54 + parseCommand, 55 + parseOptions, 56 + parseSubCommands, 57 + } from 'https://deno.land/x/slash_command_parser/mod.ts' 58 + 59 + const content = 'add item: lettuce howmany: 2 complete: false' 60 + 61 + // Just parsing command + text can give you a "Slack"-style slashcommand 62 + const { command, text } = parseCommand(content) 63 + 64 + // Expand to support 4 sub-commands instead of 2 65 + const { subCommands, remaining } = parseSubCommands(text, 4) 66 + const options = parseOptions(remaining, template) 67 + 68 + console.log({ command, text, options, subCommands }) 69 + ``` 70 + 71 + See more usage examples in the [test file](./mod.test.ts).
+13
deno.json
··· 1 + { 2 + "fmt": { 3 + "proseWrap": "preserve", 4 + "semiColons": false, 5 + "singleQuote": true 6 + }, 7 + "imports": { 8 + "std/": "https://deno.land/std@0.201.0/" 9 + }, 10 + "tasks": { 11 + "test": "deno fmt && deno check ./mod.ts && deno lint && deno test -A ./" 12 + } 13 + }
+34
deno.lock
··· 1 + { 2 + "version": "2", 3 + "remote": { 4 + "https://deno.land/std@0.200.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", 5 + "https://deno.land/std@0.200.0/assert/_diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", 6 + "https://deno.land/std@0.200.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", 7 + "https://deno.land/std@0.200.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", 8 + "https://deno.land/std@0.200.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", 9 + "https://deno.land/std@0.200.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", 10 + "https://deno.land/std@0.200.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", 11 + "https://deno.land/std@0.200.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", 12 + "https://deno.land/std@0.200.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc", 13 + "https://deno.land/std@0.200.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8", 14 + "https://deno.land/std@0.200.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9", 15 + "https://deno.land/std@0.200.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", 16 + "https://deno.land/std@0.200.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", 17 + "https://deno.land/std@0.200.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", 18 + "https://deno.land/std@0.200.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", 19 + "https://deno.land/std@0.200.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", 20 + "https://deno.land/std@0.200.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", 21 + "https://deno.land/std@0.200.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", 22 + "https://deno.land/std@0.200.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", 23 + "https://deno.land/std@0.200.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", 24 + "https://deno.land/std@0.200.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", 25 + "https://deno.land/std@0.200.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", 26 + "https://deno.land/std@0.200.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", 27 + "https://deno.land/std@0.200.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", 28 + "https://deno.land/std@0.200.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee", 29 + "https://deno.land/std@0.200.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", 30 + "https://deno.land/std@0.200.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", 31 + "https://deno.land/std@0.200.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", 32 + "https://deno.land/std@0.200.0/testing/asserts.ts": "b4e4b1359393aeff09e853e27901a982c685cb630df30426ed75496961931946" 33 + } 34 + }
+135
mod.test.ts
··· 1 + import { assertEquals, assertThrows } from 'std/testing/asserts.ts' 2 + import parse, { isCommand, OptionType, parseCommand } from './mod.ts' 3 + 4 + Deno.test('isCommand == true', () => { 5 + assertEquals(isCommand('/hello this is a command'), true) 6 + }) 7 + 8 + Deno.test('isCommand == false', () => { 9 + assertEquals(isCommand('hello this is not a command'), false) 10 + }) 11 + 12 + Deno.test('parseCommand', () => { 13 + assertEquals(parseCommand('/hello this is a slack-style command'), { 14 + command: 'hello', 15 + text: 'this is a slack-style command', 16 + }) 17 + }) 18 + 19 + Deno.test('command without options', () => { 20 + assertEquals(parse('/hello'), { 21 + text: '', 22 + command: 'hello', 23 + options: {}, 24 + subCommands: [], 25 + }) 26 + }) 27 + 28 + Deno.test('command with named options', () => { 29 + assertEquals( 30 + parse('/hello message: hello this is my message'), 31 + { 32 + command: 'hello', 33 + text: 'message: hello this is my message', 34 + options: { message: 'hello this is my message' }, 35 + subCommands: [], 36 + }, 37 + ) 38 + }) 39 + 40 + Deno.test('command with multiple named options', () => { 41 + assertEquals( 42 + parse('/hello message: hello this is my message custom: -O.O-'), 43 + { 44 + command: 'hello', 45 + text: 'message: hello this is my message custom: -O.O-', 46 + options: { message: 'hello this is my message', custom: '-O.O-' }, 47 + subCommands: [], 48 + }, 49 + ) 50 + }) 51 + 52 + Deno.test('command with subcommand', () => { 53 + assertEquals(parse('/todos add name: My Todo Name'), { 54 + text: 'add name: My Todo Name', 55 + command: 'todos', 56 + options: { name: 'My Todo Name' }, 57 + subCommands: ['add'], 58 + }) 59 + }) 60 + 61 + Deno.test('command with multiple subcommands', () => { 62 + assertEquals(parse('/todos add shopping veggie: lettuce'), { 63 + text: 'add shopping veggie: lettuce', 64 + command: 'todos', 65 + options: { veggie: 'lettuce' }, 66 + subCommands: ['add', 'shopping'], 67 + }) 68 + }) 69 + 70 + Deno.test('command with option definition', () => { 71 + const interaction = parse( 72 + '/todos add item: lettuce howmany: 2 complete: false', 73 + [ 74 + { name: 'item', type: OptionType.string }, 75 + { name: 'howmany', type: OptionType.integer }, 76 + { name: 'complete', type: OptionType.boolean }, 77 + ], 78 + ) 79 + assertEquals(interaction, { 80 + text: 'add item: lettuce howmany: 2 complete: false', 81 + command: 'todos', 82 + options: { item: 'lettuce', howmany: 2, complete: false }, 83 + subCommands: ['add'], 84 + }) 85 + }) 86 + 87 + Deno.test('command with nested option definition', () => { 88 + const interaction = parse( 89 + '/todos add item: lettuce howmany: 2 complete: false', 90 + [ 91 + { 92 + name: 'item', 93 + type: OptionType.string, 94 + options: [ 95 + { name: 'howmany', type: OptionType.integer }, 96 + { name: 'complete', type: OptionType.boolean }, 97 + ], 98 + }, 99 + ], 100 + ) 101 + assertEquals(interaction, { 102 + text: 'add item: lettuce howmany: 2 complete: false', 103 + command: 'todos', 104 + options: { item: 'lettuce', howmany: 2, complete: false }, 105 + subCommands: ['add'], 106 + }) 107 + }) 108 + 109 + Deno.test('command with single option definition', () => { 110 + const interaction = parse( 111 + '/todos add item: lettuce', 112 + { name: 'item', type: OptionType.string }, 113 + ) 114 + assertEquals(interaction, { 115 + text: 'add item: lettuce', 116 + command: 'todos', 117 + options: { item: 'lettuce' }, 118 + subCommands: ['add'], 119 + }) 120 + }) 121 + 122 + Deno.test('Error: no content', () => { 123 + const fn = () => parse('') 124 + assertThrows(fn, Error, 'no content') 125 + }) 126 + 127 + Deno.test('Error: no prefix', () => { 128 + const fn = () => parse('message without a command') 129 + assertThrows(fn, Error, 'no prefix (not a command)') 130 + }) 131 + 132 + Deno.test('Error: no body after prefix', () => { 133 + const fn = () => parse('/') 134 + assertThrows(fn, Error, 'no body after prefix') 135 + })
+175
mod.ts
··· 1 + export type InteractionOption = string | number | boolean 2 + 3 + export interface Interaction { 4 + command: string 5 + text: string 6 + subCommands?: string[] 7 + options: { [name: string]: InteractionOption } 8 + } 9 + 10 + export interface OptionChoice { 11 + name: string 12 + value: string | number 13 + } 14 + 15 + export interface OptionDefinition { 16 + name: string 17 + type: OptionType 18 + description?: string 19 + options?: OptionDefinition[] 20 + required?: boolean 21 + min_value?: number 22 + max_value?: number 23 + min_length?: number 24 + max_length?: number 25 + choices?: OptionChoice[] 26 + } 27 + 28 + export enum OptionType { 29 + sub_command = 1, 30 + string = 2, 31 + integer = 3, 32 + boolean = 4, 33 + number = 5, 34 + attachment = 6, 35 + } 36 + 37 + const prefix = '/' 38 + 39 + export function isCommand(content: string): boolean { 40 + try { 41 + parseCommand(content) 42 + return true 43 + } catch { 44 + return false 45 + } 46 + } 47 + 48 + export function parseCommand( 49 + content: string, 50 + ): { command: string; text: string } { 51 + if (!content) throw new Error('no content') 52 + const matchedPrefix = content.toLowerCase().startsWith(prefix.toLowerCase()) 53 + if (!matchedPrefix) throw new Error('no prefix (not a command)') 54 + 55 + let text = content.slice(prefix.length).trim() 56 + if (!text) throw new Error('no body after prefix') 57 + 58 + const command = text.match(/^[^\s]+/i)?.[0] 59 + if (!command) throw new Error('invalid command') 60 + 61 + text = text.slice(command.length).trim() 62 + 63 + return { command, text } 64 + } 65 + 66 + export function parseSubCommands( 67 + content: string, 68 + max = 2, 69 + ): { subCommands: string[]; remaining: string } { 70 + const args = content.split(/ +/).filter((arg) => arg.length > 0) 71 + 72 + const subCommands: string[] = [] 73 + 74 + for (let i = 0; i < max; i++) { 75 + if (!args.length) break 76 + if (args[0]?.endsWith(':')) break 77 + if (subCommands.length > i) break 78 + subCommands.push(args[0]) 79 + args.shift() 80 + } 81 + 82 + return { subCommands, remaining: args.join(' ') } 83 + } 84 + 85 + export function parseOptions( 86 + content: string, 87 + template?: OptionDefinition | OptionDefinition[], 88 + ): { [name: string]: InteractionOption } { 89 + const args = content.split(/ +/).filter((arg) => arg.length > 0) 90 + const options: { [name: string]: InteractionOption } = {} 91 + 92 + let name = '' 93 + let value = '' 94 + 95 + args.forEach((arg: string) => { 96 + if (arg.endsWith(':')) { 97 + if (name) { 98 + options[name] = value 99 + value = '' 100 + } 101 + name = arg.slice(0, -1) 102 + } else if (name) { 103 + if (value.length) value += ' ' 104 + value += arg 105 + } else { 106 + throw new Error('Invalid arguments') 107 + } 108 + }) 109 + 110 + if (name) options[name] = value 111 + 112 + if (template) { 113 + flattenOptionDefinitions(Array.isArray(template) ? template : [template]) 114 + .forEach((definition: OptionDefinition) => { 115 + const { name, type, required, choices } = definition 116 + const value = options[name] 117 + if (value == null) { 118 + if (required) throw new Error(`missing required option: ${name}`) 119 + else return 120 + } 121 + const parsedValue = parseOptionValue(value, type) 122 + if (parsedValue === undefined) { 123 + throw new Error(`Invalid option ${name}: ${options[name]}`) 124 + } 125 + if (choices && !choices.find(({ value }) => value === parsedValue)) { 126 + throw new Error( 127 + `Option value ${name}: ${parsedValue} is not one of the choices ${choices}`, 128 + ) 129 + } 130 + options[name] = parsedValue 131 + }) 132 + } 133 + 134 + return options 135 + } 136 + 137 + function flattenOptionDefinitions( 138 + definitions: OptionDefinition[], 139 + ): OptionDefinition[] { 140 + let all_options: OptionDefinition[] = [] 141 + definitions.forEach(({ options, ...def }) => { 142 + all_options.push(def) 143 + if (options) { 144 + all_options = all_options.concat(flattenOptionDefinitions(options)) 145 + } 146 + }) 147 + return all_options 148 + } 149 + 150 + export function parseOptionValue( 151 + value: InteractionOption, 152 + type: OptionType, 153 + ): InteractionOption | void { 154 + switch (type) { 155 + case OptionType.attachment: 156 + case OptionType.string: 157 + return value 158 + case OptionType.integer: 159 + case OptionType.number: 160 + return Number(value) 161 + case OptionType.boolean: 162 + return value === 'true' 163 + } 164 + } 165 + 166 + export default function parse( 167 + content: string, 168 + template?: OptionDefinition | OptionDefinition[], 169 + ): Interaction { 170 + const { command, text } = parseCommand(content) 171 + const { subCommands, remaining } = parseSubCommands(text) 172 + const options = parseOptions(remaining, template) 173 + 174 + return { command, text, options, subCommands } 175 + }