Skip to content
This repository has been archived by the owner on Jul 16, 2024. It is now read-only.

Commit

Permalink
implement zsh completions
Browse files Browse the repository at this point in the history
  • Loading branch information
IMax153 committed Nov 25, 2023
1 parent bc2110e commit a7b2f69
Show file tree
Hide file tree
Showing 9 changed files with 533 additions and 116 deletions.
9 changes: 9 additions & 0 deletions src/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@ export const getFishCompletions: <A>(
programName: string
) => Effect<never, never, ReadonlyArray<string>> = InternalCommand.getFishCompletions

/**
* @since 1.0.0
* @category combinators
*/
export const getZshCompletions: <A>(
self: Command<A>,
programName: string
) => Effect<never, never, ReadonlyArray<string>> = InternalCommand.getZshCompletions

/**
* @since 1.0.0
* @category combinators
Expand Down
7 changes: 4 additions & 3 deletions src/internal/cliApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,21 +155,22 @@ const handleBuiltInOption = <A>(
case "ShowCompletions": {
const commandNames = ReadonlyArray.fromIterable(InternalCommand.getNames(self.command))
if (ReadonlyArray.isNonEmptyReadonlyArray(commandNames)) {
const programName = ReadonlyArray.headNonEmpty(commandNames)
switch (builtIn.shellType) {
case "bash": {
const programName = ReadonlyArray.headNonEmpty(commandNames)
return InternalCommand.getBashCompletions(self.command, programName).pipe(
Effect.flatMap((completions) => Console.log(ReadonlyArray.join(completions, "\n")))
)
}
case "fish": {
const programName = ReadonlyArray.headNonEmpty(commandNames)
return InternalCommand.getFishCompletions(self.command, programName).pipe(
Effect.flatMap((completions) => Console.log(ReadonlyArray.join(completions, "\n")))
)
}
case "zsh":
throw new Error("Zsh completions not implemented ... yet")
return InternalCommand.getZshCompletions(self.command, programName).pipe(
Effect.flatMap((completions) => Console.log(ReadonlyArray.join(completions, "\n")))
)
}
}
throw new Error(
Expand Down
163 changes: 163 additions & 0 deletions src/internal/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ export const getFishCompletions = <A>(
): Effect.Effect<never, never, ReadonlyArray<string>> =>
getFishCompletionsInternal(self as Instruction, programName)

/** @internal */
export const getZshCompletions = <A>(
self: Command.Command<A>,
programName: string
): Effect.Effect<never, never, ReadonlyArray<string>> =>
getZshCompletionsInternal(self as Instruction, programName)

/** @internal */
export const getSubcommands = <A>(
self: Command.Command<A>
Expand Down Expand Up @@ -1205,3 +1212,159 @@ const getFishCompletionsInternal = (
ReadonlyArray.appendAll(subcommandCompletions(conditionals))
))
})

const getZshCompletionsInternal = (
self: Instruction,
rootCommand: string
): Effect.Effect<never, never, ReadonlyArray<string>> =>
traverseCommand(self, ReadonlyArray.empty<string>(), (state, info) => {
const preformatted = ReadonlyArray.isEmptyReadonlyArray(info.parentCommands)
? ReadonlyArray.of(info.command.name)
: pipe(
info.parentCommands,
ReadonlyArray.append(info.command.name),
ReadonlyArray.map((command) => command.replace("-", "__"))
)
const underscoreName = ReadonlyArray.join(preformatted, "__")
const spaceName = ReadonlyArray.join(preformatted, " ")
const subcommands = pipe(
info.subcommands,
ReadonlyArray.map(([name, subcommand]) => {
const desc = getShortDescription(subcommand)
return `'${name}:${desc}' \\`
})
)
const commands = ReadonlyArray.isEmptyReadonlyArray(subcommands)
? `commands=()`
: `commands=(\n${ReadonlyArray.join(indentAll(subcommands, 8), "\n")}\n )`
const handlerLines = [
`(( $+functions[_${underscoreName}_commands] )) ||`,
`_${underscoreName}_commands() {`,
` local commands; ${commands}`,
` _describe -t commands '${spaceName} commands' commands "$@"`,
"}"
]
return Effect.succeed(ReadonlyArray.appendAll(state, handlerLines))
}).pipe(Effect.map((handlers) => {
const cases = getZshSubcommandCases(self, ReadonlyArray.empty(), ReadonlyArray.empty())
const scriptName = `_${rootCommand}_zsh_completions`
return [
`#compdef ${rootCommand}`,
"",
"autoload -U is-at-least",
"",
`function ${scriptName}() {`,
" typeset -A opt_args",
" typeset -a _arguments_options",
" local ret=1",
"",
" if is-at-least 5.2; then",
" _arguments_options=(-s -S -C)",
" else",
" _arguments_options=(-s -C)",
" fi",
"",
" local context curcontext=\"$curcontext\" state line",
...indentAll(cases, 4),
"}",
"",
...handlers,
"",
`if [ "$funcstack[1]" = "${scriptName}" ]; then`,
` ${scriptName} "$@"`,
"else",
` compdef ${scriptName} ${rootCommand}`,
"fi"
]
}))

const getZshSubcommandCases = (
self: Instruction,
parentCommands: ReadonlyArray<string>,
subcommands: ReadonlyArray<[string, Standard | GetUserInput]>
): ReadonlyArray<string> => {
switch (self._tag) {
case "Standard":
case "GetUserInput": {
const options = isStandard(self)
? InternalOptions.all([InternalBuiltInOptions.builtIns, self.options])
: InternalBuiltInOptions.builtIns
const optionCompletions = pipe(
InternalOptions.getZshCompletions(options as InternalOptions.Instruction),
ReadonlyArray.map((completion) => `'${completion}' \\`)
)
if (ReadonlyArray.isEmptyReadonlyArray(parentCommands)) {
return [
"_arguments \"${_arguments_options[@]}\" \\",
...indentAll(optionCompletions, 4),
` ":: :_${self.name}_commands" \\`,
` "*::: :->${self.name}" \\`,
" && ret=0"
]
}
if (ReadonlyArray.isEmptyReadonlyArray(subcommands)) {
return [
`(${self.name})`,
"_arguments \"${_arguments_options[@]}\" \\",
...indentAll(optionCompletions, 4),
" && ret=0",
";;"
]
}
return [
`(${self.name})`,
"_arguments \"${_arguments_options[@]}\" \\",
...indentAll(optionCompletions, 4),
` ":: :_${ReadonlyArray.append(parentCommands, self.name).join("__")}_commands" \\`,
` "*::: :->${self.name}" \\`,
" && ret=0"
]
}
case "Map": {
return getZshSubcommandCases(self.command as Instruction, parentCommands, subcommands)
}
case "OrElse": {
const left = getZshSubcommandCases(self.left as Instruction, parentCommands, subcommands)
const right = getZshSubcommandCases(self.right as Instruction, parentCommands, subcommands)
return ReadonlyArray.appendAll(left, right)
}
case "Subcommands": {
const nextSubcommands = getImmediateSubcommands(self.child as Instruction)
const parentName = Array.from(getNamesInternal(self.parent as Instruction))[0]
const parentLines = getZshSubcommandCases(
self.parent as Instruction,
parentCommands,
ReadonlyArray.appendAll(subcommands, nextSubcommands)
)
const childCases = getZshSubcommandCases(
self.child as Instruction,
ReadonlyArray.append(parentCommands, parentName),
subcommands
)
const hyphenName = pipe(
ReadonlyArray.append(parentCommands, parentName),
ReadonlyArray.join("-")
)
const childLines = pipe(
[
"case $state in",
` (${parentName})`,
` words=($line[1] "\${words[@]}")`,
" (( CURRENT += 1 ))",
` curcontext="\${curcontext%:*:*}:${hyphenName}-command-$line[1]:"`,
` case $line[1] in`,
...indentAll(childCases, 8),
" esac",
" ;;",
"esac"
],
ReadonlyArray.appendAll(
ReadonlyArray.isEmptyReadonlyArray(parentCommands)
? ReadonlyArray.empty()
: ReadonlyArray.of(";;")
)
)
return ReadonlyArray.appendAll(parentLines, childLines)
}
}
}
75 changes: 75 additions & 0 deletions src/internal/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,19 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect.
const CLUSTERED_REGEX = /^-{1}([^-]{2,}$)/
const FLAG_REGEX = /^(--[^=]+)(?:=(.+))?$/

const escape = (string: string): string => {
return string
.replaceAll("\\", "\\\\")
.replaceAll("'", "'\\''")
.replaceAll("[", "\\[")
.replaceAll("]", "\\]")
.replaceAll(":", "\\:")
.replaceAll("$", "\\$")
.replaceAll("`", "\\`")
.replaceAll("(", "\\(")
.replaceAll(")", "\\)")
}

const processArgs = (
args: ReadonlyArray<string>
): Effect.Effect<never, ValidationError.ValidationError, ReadonlyArray<string>> => {
Expand Down Expand Up @@ -1809,3 +1822,65 @@ export const getFishCompletions = (self: Instruction): ReadonlyArray<string> =>
}
}
}

interface ZshCompletionState {
readonly conflicts: ReadonlyArray<string>
readonly multiple: boolean
}

/** @internal */
export const getZshCompletions = (
self: Instruction,
state: ZshCompletionState = { conflicts: ReadonlyArray.empty(), multiple: false }
): ReadonlyArray<string> => {
switch (self._tag) {
case "Empty": {
return ReadonlyArray.empty()
}
case "Single": {
const names = getNames(self)
const description = getShortDescription(self)
const possibleValues = InternalPrimitive.getZshCompletions(
self.primitiveType as InternalPrimitive.Instruction
)
const multiple = state.multiple ? "*" : ""
const conflicts = ReadonlyArray.isNonEmptyReadonlyArray(state.conflicts)
? `(${ReadonlyArray.join(state.conflicts, " ")})`
: ""
return ReadonlyArray.map(
names,
(name) => `${conflicts}${multiple}${name}[${escape(description)}]${possibleValues}`
)
}
case "KeyValueMap": {
return getZshCompletions(self.argumentOption as Instruction, { ...state, multiple: true })
}
case "Map":
case "WithDefault": {
return getZshCompletions(self.options as Instruction, state)
}
case "Both": {
const left = getZshCompletions(self.left as Instruction, state)
const right = getZshCompletions(self.right as Instruction, state)
return ReadonlyArray.appendAll(left, right)
}
case "OrElse": {
const leftNames = getNames(self.left as Instruction)
const rightNames = getNames(self.right as Instruction)
const left = getZshCompletions(
self.left as Instruction,
{ ...state, conflicts: ReadonlyArray.appendAll(state.conflicts, rightNames) }
)
const right = getZshCompletions(
self.right as Instruction,
{ ...state, conflicts: ReadonlyArray.appendAll(state.conflicts, leftNames) }
)
return ReadonlyArray.appendAll(left, right)
}
case "Variadic": {
return Option.isSome(self.max) && self.max.value > 1
? getZshCompletions(self.argumentOption as Instruction, { ...state, multiple: true })
: getZshCompletions(self.argumentOption as Instruction, state)
}
}
}
47 changes: 47 additions & 0 deletions src/internal/primitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,3 +648,50 @@ export const getFishCompletions = (self: Instruction): ReadonlyArray<string> =>
}
}
}

/** @internal */
export const getZshCompletions = (self: Instruction): string => {
switch (self._tag) {
case "Bool": {
return ""
}
case "Choice": {
const choices = pipe(
ReadonlyArray.map(self.alternatives, ([name]) => name),
ReadonlyArray.join(" ")
)
return `:CHOICE:(${choices})`
}
case "DateTime": {
return ""
}
case "Float": {
return ""
}
case "Integer": {
return ""
}
case "Path": {
switch (self.pathType) {
case "file": {
return self.pathExists === "yes" || self.pathExists === "either"
? ":PATH:_files"
: ""
}
case "directory": {
return self.pathExists === "yes" || self.pathExists === "either"
? ":PATH:_files -/"
: ""
}
case "either": {
return self.pathExists === "yes" || self.pathExists === "either"
? ":PATH:_files"
: ""
}
}
}
case "Text": {
return ""
}
}
}
Loading

0 comments on commit a7b2f69

Please sign in to comment.