Skip to content

Instantly share code, notes, and snippets.

@ktraunmueller
Created January 2, 2025 15:50
Show Gist options
  • Save ktraunmueller/aa8c8095ff31bb276635bd010b2fe71a to your computer and use it in GitHub Desktop.
Save ktraunmueller/aa8c8095ff31bb276635bd010b2fe71a to your computer and use it in GitHub Desktop.
Code completion state machine
import Combine
/// A state machine for the code completions window.
///
/// Does not keep track of the source edits, or the cursor position. This is left to the call site.
///
/// - Note: Use the PlantUML source at the bottom to generate a UML state diagram.
final class CodeCompletionStateMachine {
/// The states the state machine can be in.
enum State: Equatable {
case idle
case editingCommand
case editingArguments
case browsingCommands
}
/// The set of events that can be sent to the state machine.
enum Event: Equatable {
/// The "show completions" shortcut was pressed.
/// Note: the caller needs to make sure the cursor is somewhere inside a command.
/// - `insideCommandName` is `true` if the cursor is within the command name,
/// or `false` if the cursor has moved outside of the command name (possibly into the arguments).
/// - `insideCommand` is true if the cursor is within the command name or arguments.
case shortcutPressed(insideCommandName: Bool,
insideCommand: Bool)
/// Cancel was pressed.
case cancel
/// Tab was pressed.
case tab
/// Enter/Return was pressed.
case enter
/// Forward or backspace delete was pressed.
/// - `insideCommandName` is true if the cursor is still within the command name, or immediately
/// following a standalone backslash, afterwards.
/// - `insideCommand` is true if the cursor is within the command name or arguments.
case delete(insideCommandName: Bool,
insideCommand: Bool,
didDeleteLineBreak: Bool)
/// A character was typed (can be a printable character, a tab character, a backslash, etc).
/// - `matchesCharAtCursor` is true if char is equal to the character at the current cursor position.
/// - `haveFullMatch` is true if char completes a valid command (there is a full match). This flag
/// is used only in the `.editingCommand` state (not `.editingArgument`).
/// - `insideCommandName` is `true` if the cursor is within the command name,
/// or `false` if the cursor has moved outside of the command name (possibly into the arguments).
/// - `insideCommand` is true if the cursor is within the command name or arguments.
case characterTyped(char: ASCIICharacter,
matchesCharAtCursor: Bool,
haveFullMatch: Bool,
insideCommandName: Bool,
insideCommand: Bool)
/// Cursor left or right was pressed.
/// - `insideCommandName` is true if the cursor is still within the command name afterwards,
/// or `false` if the cursor has moved outside of the command name (possibly into the arguments).
/// - `insideCommand` is true if the cursor is within the command name or arguments.
case cursorLeftRight(insideCommandName: Bool,
insideCommand: Bool,
didCrossLineBreak: Bool)
/// Cursor up or down was pressed.
case cursorUp, cursorDown
}
/// The set of actions emitted by the state machine.
enum Action: Equatable {
/// Begin (command) editing, save the current source and cursor location.
case beginEditing
/// End editing (accepting the edits) and trigger a typesetting run.
case endEditing
/// Show the completions window.
case showCompletionsWindow
/// Dismiss the completions window.
case dismissCompletionsWindow
/// Update the list of results.
case updateListOfResults
/// Advance the cursor one character to the right.
case advanceCursor
/// Select the previous/next/no result in the list.
case selectPreviousResult, selectNextResult, deselectResults
/// Show a "Press TAB to complete" message in the UI.
case showTabToComplete
/// Completes the selected result.
/// - If the result is a `.command`:
/// - Removes the remainder of any existing command at the cursor location, including arguments.
/// The value of the first non-optional argument is saved if present.
/// - Completes the currently selected command, and inserts argument placeholders.
/// If a non-empty first non-optional argument was captured previously, it will be used for the first
/// non-optional argument of the new command.
/// - Positions the cursor within the first argument of the newly inserted command.
/// - TODO document other possibilities
case completeSelectedResult
/// Select the previous/next known argument value (if available)
case selectPreviousKnownArgumentValue, selectNextKnownArgumentValue
/// Insert or replace the current argument with the selected (known) value.
case insertOrReplaceArgumentValue
/// Temporarily disable typesetting (until we have a well-formed command).
case suspendTypesetting
/// Re-enable typesetting (not actually triggering any typesetting here).
case resumeTypesetting
}
/// The current state.
private(set) var state: State = .idle {
didSet {
if state != oldValue {
ddLogEvent(category: "CodeCompletionStateMachine", label: "state => .\(state)")
stateDidChange.send(state)
}
}
}
/// A publisher for state change notificiations. Note that `stateDidChange` will only fire
/// for actual state changes (i.e., the old and new states are different).
let stateDidChange = PassthroughSubject<State, Never>()
/// A publisher for actions emitted by the state machine.
let actionRequired = PassthroughSubject<Action, Never>()
/// Check whether a key event should be forwarded to the text input system, or swallowed.
/// - Returns: `true` if a key event should be forwarded for further processing.
/// - Note: The return value is meaningful only if `event` represents a key event.
/// - Note: For the `.characterTyped` event, only the `matchesCharAtCursor` associated
/// value needs to be supplied, the other values are ignored.
/// - Examples of swallowing key events:
/// - The shortcut key to activate the completions window should be consumed.
/// - A tab key to complete the currently selected command should not be forwarded
/// to the text input system.
/// - If the cursor is currently inside a command, a character matching the character
/// right of the cursor should not be forwarded to the text input system.
func forward(event: Event) -> Bool {
switch (state, event) {
case (.idle, .shortcutPressed):
return false
case (.browsingCommands, .tab):
return false // tab translates to "complete current suggestion", swallow
case (.editingCommand, .characterTyped(_, let matchesCharAtCursor, _, _, _)):
if matchesCharAtCursor {
return false // skip over (advance cursor)
}
return true
case (.editingCommand, .tab),
(.editingArguments, .tab):
return false // swallow tab
case (.editingCommand, .enter),
(.editingArguments, .enter),
(.browsingCommands, .enter):
return false // swallow return
default:
break
}
return true
}
/// Handle an incoming event, potentially causing a state change, or one or more actions being
/// emitted, or both.
/// - Parameter event: The incoming event.
func handle(event: Event) {
ddLogEvent(category: "CodeCompletionStateMachine", label: ".\(state) <- .\(event)")
switch (state, event) {
case (.idle, .shortcutPressed(let insideCommandName, let insideCommand)):
state = insideCommand && !insideCommandName ? .editingArguments : .editingCommand
send(action: .beginEditing)
send(action: .suspendTypesetting)
send(action: .showCompletionsWindow)
case (.idle, .characterTyped(let char, let matchesCharAtCursor, _, _, _)):
if char == ASCII.backslash, !matchesCharAtCursor {
state = .editingCommand
send(action: .beginEditing)
send(action: .suspendTypesetting)
send(action: .showCompletionsWindow)
}
case (.editingCommand, .cancel):
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
case (.editingCommand, .characterTyped(let char, let matchesCharAtCursor,
let haveFullMatch,
_, _)):
if char.isPrintable { // TODO use !char.isWhitespace instead?
if matchesCharAtCursor {
send(action: .advanceCursor)
send(action: .updateListOfResults)
} else {
send(action: .updateListOfResults)
}
if haveFullMatch {
send(action: .showTabToComplete)
}
}
case (.editingCommand, .cursorLeftRight(let insideCommandName, let insideCommand, let didCrossLineBreak)):
if didCrossLineBreak {
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
} else if insideCommandName {
send(action: .updateListOfResults)
} else if insideCommand {
state = .editingArguments // TODO resume typesetting at this point? only if actual arguments satisfy formal arguments?
send(action: .updateListOfResults)
} else {
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
}
case (.editingCommand, .delete(let insideCommandName, let insideCommand, let didCrossLineBreak)):
if didCrossLineBreak {
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
} else if insideCommandName {
send(action: .updateListOfResults)
} else if insideCommand {
state = .editingArguments // ? TODO
} else {
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
}
case (.editingCommand, .cursorUp):
state = .browsingCommands
send(action: .selectPreviousResult)
case (.editingCommand, .cursorDown):
state = .browsingCommands
send(action: .selectNextResult)
case (.editingArguments, .cancel):
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
case (.editingArguments, .characterTyped):
break // TODO any actions required here
case (.editingArguments, .cursorLeftRight(let insideCommandName, let insideCommand, let didCrossLineBreak)):
if didCrossLineBreak {
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
} else if insideCommandName {
state = .editingCommand
send(action: .updateListOfResults)
} else if insideCommand {
send(action: .updateListOfResults)
} else {
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
}
case (.editingArguments, .cursorUp):
send(action: .selectPreviousKnownArgumentValue)
case (.editingArguments, .cursorDown):
send(action: .selectNextKnownArgumentValue)
case (.editingArguments, .enter):
send(action: .insertOrReplaceArgumentValue)
case (.browsingCommands, .cursorUp):
send(action: .selectPreviousResult)
case (.browsingCommands, .cursorDown):
send(action: .selectNextResult)
case (.browsingCommands, .cursorLeftRight(let insideCommandName, let insideCommand, let didCrossLineBreak)):
if didCrossLineBreak {
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
} else {
assert(insideCommand) // probably _has to be_ true here?
state = insideCommandName ? .editingCommand : .editingArguments
send(action: .deselectResults)
send(action: .updateListOfResults)
}
case (.browsingCommands, .characterTyped):
state = .editingCommand // exit browsing
send(action: .deselectResults)
handle(event: event) // continue with same behavior as when editing command
case (.browsingCommands, .delete):
state = .editingCommand // exit browsing
send(action: .deselectResults)
handle(event: event) // continue with same behavior as when editing command
case (.browsingCommands, .tab):
state = .idle
send(action: .completeSelectedResult)
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
case (.browsingCommands, .cancel):
state = .idle
send(action: .dismissCompletionsWindow)
send(action: .resumeTypesetting)
send(action: .endEditing)
default:
break // not a valid transition
}
}
private func send(action: Action) {
ddLogEvent(category: "CodeCompletionStateMachine", label: "action -> .\(action)")
actionRequired.send(action)
}
// MARK: Test Support
func setState(_ state: State) {
self.state = state
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment