Skip to content

Commands

Joyce Er edited this page Jul 1, 2019 · 8 revisions

The VCS Commands infrastructure is used within the Python Extension to implement the publish-subscribe messaging pattern. (The primary benefit is decoupled architecture).

Registration

Guidelines

  • Register commands with their handlers in the above CommandRegistry class.
  • Use pub-sub pattern for loose coupling.
  • When adding a new command or invoking a new VS Code command, ensure the corresponding entry exists in the following type:
// Add items in here only for commands that do not have any arguments
interface ICommandNameWithoutArgumentTypeMapping {
    [Commands.Set_Interpreter]: [];
    [Commands.Run_Linter]: [];
}

// Add items in here for commands that have arguments, and add the type definitions for the arguments as well.
export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgumentTypeMapping {
    ['setContext']: [string, boolean];
    ['revealLine']: [{ lineNumber: number; at: 'top' | 'center' | 'bottom' }];
    [Commands.Sort_Imports]: [undefined, Uri];
    [Commands.Exec_In_Terminal]: [undefined, Uri];
    [Commands.Tests_ViewOutput]: [undefined, CommandSource];
    [Commands.Tests_Stop]: [undefined, Uri];
}
  • When adding handlers for custom commands, try to leave an empty first argument of undefined.
    • Why:
    • Assume a command is hooked to a UI element such as Tree Node (File Explorer, Test Explorer, etc)
    • When this command is invoked, the first argument passed into the command is the data associated with the Tree Node.
    • In the case of the File Explorer the Uri of the selected file is passed.
    • In the case of the Test Explorer the underlying Data associated with the node is passed.
    • Thus adding a blank argument for a node makes the arguments future proof, or you can always bear the above in mind.

Strong Typing

The VSC API sits behind an interface named ICommandManager.

Unfortunately the methods used to execute a command (executeCommand) and adding of handlers (callbacks) for commands are not strongly typed (see below). This has lead to a few bugs in the past.

export interface ICommandManager {
    registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any): Disposable;
    executeCommand<T>(command: string, ...rest: any[]): Thenable<T | undefined>;
}

As can be seen above the arguments passed are loosely typed (any), same with the arguments passed into the handler.

Example:

commandManager.registerCommand('HelloWorld', (name: string, age: number) => {
	console.log(`Hello ${name}, your age is ${age}`);
});
commandManager.executeCommand('HelloWorld', false, 'Bye');
  • Considering the above sample, TypeScript will not provide any warnings about passing incorrect arguments to the command Hello Word.
  • All is good during compile time
  • However during runtime, the code could fall over, due to unexpected types.

Solution:

  • Add strong typing to the methods executeCommand and registerCommand.
  • Ensure the correct arguments are passed for a specific command
  • Ensure the signature of the command handler is correct

This is achieved using type inference in TypeScript, more information can be found here Advanced Types.

Union Types of String Literals

  • We will keep track of all commands that will be invoked in code without passing any arguments.
  • This will be defined as a union of string literal types. E.g.
type Command = 'HelloWorld' | 'command_name_1' | 'command_name_2' | 'command_name_3';

const myCommand: Command = 'command_name_1';
const myCommand: Command = 'something'; // Typescript compiler will throw errors.

Arguments list of a Command

  • Following the previous sample, we can define a type that defines the arguments for the command HelloWorld
  • As follows:
type HelloWorldArguments = [string, number];

Mapping of command and arguments:

  • Next step, we keep track of the command and the argument types.
  • The mapped list is maintained in a simple type dictionary (an interface).
  • Note: It cannot be done in a literal dictionary, as typing (type hints) are compile time, not run time.
  • This is achieved today as follows:
export type CommandsWithoutArgs = keyof ICommandNameWithoutArgumentTypeMapping;

interface ICommandNameWithoutArgumentTypeMapping {
    [Commands.Set_Interpreter]: [];
    [Commands.Run_Linter]: [];
}

export interface ICommandNameArgumentTypeMapping extends ICommandNameWithoutArgumentTypeMapping {
    ['setContext']: [string, boolean];
    ['revealLine']: [{ lineNumber: number; at: 'top' | 'center' | 'bottom' }];
    [Commands.Sort_Imports]: [undefined, Uri];
    [Commands.Exec_In_Terminal]: [undefined, Uri];
    [Commands.Tests_ViewOutput]: [undefined, CommandSource];
    [Commands.Tests_Stop]: [undefined, Uri];
}
  • The interface ICommandNameWithoutArgumentTypeMapping keeps track of all commands that do not have any arguments.
  • The interface ICommandNameArgumentTypeMapping keeps track of all commands along with their argument definitions.
    • As we have commands that have arguments, this is a super set of ICommandNameWithoutArgumentTypeMapping (hence the inheritance).
    • Not having arguments is the same as having an arguments length of 0, hence the empty array []
  • These interfaces have a name value pair of the command and the type definition for the arguments.
  • The type CommandsWithoutArgs is a list of all commands that do not have any arguments (used in other parts of the code, this is basically the same as hardcoding a union literal string, we're just inferring using keyof).
  • Finally all of the above is put together as follows:
export interface ICommandManager {
	registerCommand<E extends keyof ICommandNameArgumentTypeMapping, U extends ICommandNameArgumentTypeMapping[E]>(command: E, callback: (...args: U) => any, thisArg?: any): Disposable;
	executeCommand<T, E extends keyof ICommandNameArgumentTypeMapping, U extends ICommandNameArgumentTypeMapping[E]>(command: E, ...rest: U): Thenable<T | undefined>;
}

// Now using the previous sample:
commandManager.registerCommand('HelloWorld', (name: string, age: number) => {
	console.log(`Hello ${name}, your age is ${age}`);
});
commandManager.executeCommand('HelloWorld', false, 'Bye');	// Compiler will throw errors.

Benefits:

  • With strong typing (type checking) we get the benefits of compile time type checks
  • Intellisense for the command handlers
  • Intellisense for the command arguments

See below, for samples on intellisense for command handlers and when passing arguments: Screen Shot 2019-03-12 at 4 52 56 PM Screen Shot 2019-03-12 at 4 53 05 PM

Clone this wiki locally