Created
January 2, 2025 15:50
-
-
Save ktraunmueller/aa8c8095ff31bb276635bd010b2fe71a to your computer and use it in GitHub Desktop.
Code completion state machine
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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