arcanis / clipanion

Type-safe CLI library / framework with no runtime dependencies
https://mael.dev/clipanion/
1.1k stars 61 forks source link

Lazy commands #151

Open arcanis opened 11 months ago

arcanis commented 11 months ago

When writing large CLI application, we find ourselves in a pickle. Let's say we have commands similar to:

import something from './lib/something';
import somethingElse from './lib/somethingElse';

export class MyCommand extends Command {
  async execute() {
    something();
    somethingElse();
  }
}

The something and somethingElse functions aren't needed until MyCommand is executed, but since they are in a top-level import the generated code will still import them before even evaluating the command file. At the scale of a large application, those imports start to slow down the startup by a significant factor. We can mitigate it a little by doing something like this:

export class MyCommand extends Command {
  async execute() {
    const [{default: something}, {default: somethingElse}] = await Promise.all([
      import('something'),
      import('somethingElse'),
    ]);

    something();
    somethingElse();
  }
}

But that's really verbose, and that's not even what people doing things like this do (they instead just call import multiple times in a row, like top-level imports, except that it prevents the runtime from fetching / parsing the modules in parallel, making sync something that could be parallelized).

A second problem is that even if the imports are moved into execute, just running files has a cost. They need to be read, parsed, evaluated, and all that when they don't actually contribute to anything at all for the purpose of the command parsing. This problem is exacerbated when using transpilers, as the cost can easily reach hundreds of ms for larger CLIs.

The first point can be solved by the Deferring Module Evaluation proposal, but it's currently still at stage 2 (cc @nicolo-ribaudo in case you're interested by this thread / practical use case), and even with that we'd still have the problem of the files being executed at all (probably not as much a problem if you don't use a transpiler).

Ideally, I'd like to find a way to solve both points.

arcanis commented 11 months ago

One strategy would be to tweak the core so that the state machine starts small, and progressively expand as we find new tokens we don't know how to support.

Let's say we have commands set version <arg>, set version from sources, and install. Let's imagine that, instead of the fully CLI-aware state machine we currently provide to runMachine, we instead provide an empty state machine. The runMachine function would accept a "failsafe callback"; this callback would take the current state machine, a stream of token, and return one of three values: ABORT, FEED, or another state machine.

We'll now parse the following CLI input:

["set", "version", "from", "sources"]

Things would go like this:

This approach allowed us to avoid having to make other calls than 4 filesystem calls. It however has a couple of thorny aspects:

In practice, doing this will require: