Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor help internals into separate interface/class #1365

Merged
merged 66 commits into from
Oct 23, 2020

Conversation

shadowspawn
Copy link
Collaborator

@shadowspawn shadowspawn commented Sep 28, 2020

Pull Request

Major help refactor (see also earlier experimentation in #1338, #1346).

Problem

The help generation is an organic collection of methods with custom logic embedded at various levels, and inconsistent handling of arguments, options, and commands.

Related issues:

Solution

Move the help related routines into a separate class. Inspired by discussion and examples from @cravler: #1346 (comment)

Implement the Help methods as if they are static to allow override not only with subclass (see .createHelp()), but also with plain functions (see .configureHelp()).

The impact on existing programs only using public methods should be small. All of the methods which are moved or deleted in the refactor were private, except for Option.fullDescription which is new in v7 so not released yet.

Callers can:

  • subclass Help for complex cases, such as styling output with colour
  • configure Help for simpler cases

For example:

program.configureHelp({
   sortSubcommands: true,
   subcommandTerm: (cmd) => cmd.name()
});

ChangeLog

  • (added) refactor the code generating the help into a separate public Help class
    • support sorting subcommands and options in help
    • support specifying wrap width (columns)
    • allow subclassing Help class
    • allow configuring Help class without subclassing
  • (changed) initialise the command description to empty string (previously undefined)
  • (fixed) in the help the first line of command description was wrapping two characters early
  • (fixed) in the help the pad width calculation was not including help option and help command
  • (fixed) in the help the pad width calculation was including hidden options and commands

@shadowspawn shadowspawn self-assigned this Sep 30, 2020
@shadowspawn
Copy link
Collaborator Author

shadowspawn commented Sep 30, 2020

Here is a example of a big override, to output the help in markdown.

const { program } = require('commander');

function formatHelpAsMarkdown(cmd, helper) {
  function formatItem(term, description) {
    if (description) {
      return `\`${term}\`: ${description}`;
    }
    return `\`${term}\``;
  };
  function formatList(textArray) {
    return textArray.join('\n').replace(/^/gm, '* ');
  }

  let output = [`# ${cmd.name()}`, ''];

  // Usage
  output = output.concat([helper.commandUsage(cmd), '']);

  // Description
  if (cmd.description()) {
    output = output.concat([cmd.description(), '']);
  }

  // Arguments
  const visibleArguments = helper.visibleArguments(cmd);
  if (visibleArguments.length) {
    const argumentsList = visibleArguments.map((argument) => {
      return formatItem(argument.term, argument.description);
    });
    output = output.concat(['## Arguments:', formatList(argumentsList), '']);
  }

  // Options
  const visibleOptions = helper.visibleOptions(cmd);
  if (visibleOptions.length) {
    const optionList = visibleOptions.map((option) => {
      return formatItem(helper.optionTerm(option), helper.optionDescription(option));
    });
    output = output.concat(['## Options:', formatList(optionList), '']);
  }

  // Commands
  const visibleCommands = helper.visibleCommands(cmd);
  if (visibleCommands.length) {
    const commandList = visibleCommands.map((cmd) => {
      return formatItem(helper.commandTerm(cmd), helper.commandDescription(cmd));
    });
    output = output.concat(['## Commands:', formatList(commandList), '']);
  }

  return output.join('\n');
}

function commandUsage(cmd) {
  return `Usage: \`${cmd.name()} ${cmd.usage()}\``;
}

program
  .name('mighty-markdown')
  .helpOverrides({ formatHelp: formatHelpAsMarkdown, commandUsage: commandUsage })
  .option('-f, --foo', 'first option')
  .option('-b, --bar', 'first option')
  .command('sub1', 'external')
  .command('sub2', 'external')


program.parse();

node markdown.sh --help
# paste in below

mighty-markdown

Usage: mighty-markdown [options] [command]

Options:

  • -f, --foo: first option
  • -b, --bar: first option
  • -h, --help: display help for command

Commands:

  • sub1: external
  • sub2: external
  • help [command]: display help for command

@shadowspawn
Copy link
Collaborator Author

And a much smaller example, setting the wrapping width for say running a unit test:

program.helpOverrides({ columns: 80 })

@cravler
Copy link
Contributor

cravler commented Sep 30, 2020

Hi @shadowspawn,

Played around with new HelpTools and it looks very flexible:

const { Command, Help: HelpTools } = require('./index');

class Help {
  constructor(cmd, helper) {
    this.cmd = cmd;
    this.helper = helper;
  };

  usage() {
    return this.helper.commandUsage(this.cmd);
  };

  description() {
    return this.helper.commandDescription(this.cmd);
  };

  arguments() {
    return this.helper.visibleArguments(this.cmd);
  };

  options() {
    return this.helper.visibleOptions(this.cmd).map(option => ({
      term: this.helper.optionTerm(option),
      description: this.helper.optionDescription(option),
    }));
  };

  commands() {
    return this.helper.visibleCommands(this.cmd).map(cmd => ({
      term: this.helper.commandTerm(cmd),
      description: this.helper.commandDescription(cmd),
    }));
  };

  render() {
    const params = this.helper.formatParams();

    const baseIndentWidth = params.baseIndentWidth || 0;
    const itemIndentWidth = params.itemIndentWidth || 2;
    const itemSeparatorWidth = params.itemSeparatorWidth || 2;

    const columns = this.helper.columns;
    const termWidth = this.helper.padWidth(this.cmd, this.helper) + baseIndentWidth;
    const descriptionWidth = columns - termWidth - baseIndentWidth - itemIndentWidth - itemSeparatorWidth;

    const styles = this.helper.styles();

    const formatList = (arr) => {
      return arr.map(({ term, description }) => {
        if (description) {
          return [
            styles.term(this.helper.pad(term, termWidth + itemSeparatorWidth)),
            this.helper.optionalWrap(description, descriptionWidth, termWidth + itemSeparatorWidth, this.helper),
          ].join('');
        }
        return term;
      }).join('\n').replace(/^/gm, Array(itemIndentWidth + 1).join(' '));
    };

    const labels = this.helper.labels();
    Object.keys(labels).map(key => {
      labels[key] = styles.label(labels[key]);
    });

    // Usage
    let output = [
      labels.usage,
      Array(itemIndentWidth + 1).join(' ') + this.usage(),
      '',
    ];

    // Description
    if (this.description()) {
      output = output.concat([
        Array(itemIndentWidth + 1).join(' ') + styles.description(this.helper.optionalWrap(
          this.description(),
          columns - baseIndentWidth - itemIndentWidth,
          itemIndentWidth,
          this.helper
        )),
        '',
      ]);
    }

    // Arguments
    if (this.arguments().length) {
      output = output.concat([
        labels.arguments,
        formatList(this.arguments()),
        '',
      ]);
    }

    // Options
    if (this.options().length) {
      output = output.concat([
        labels.options,
        formatList(this.options()),
        '',
      ]);
    }

    // Commands
    if (this.commands().length) {
      output = output.concat([
        labels.commands,
        formatList(this.commands()),
        '',
      ]);
    }

    return output.join('\n').replace(/^/gm, Array(baseIndentWidth + 1).join(' ')).trimEnd() + '\n';
  };
}

class MyHelpTools extends HelpTools {
  labels() {
    return {
      usage: 'Usage:',
      arguments: 'Arguments:',
      options: 'Options:',
      commands: 'Commands:',
    };
  };

  styles() {
    return {
      label: str => str,
      term: str => str,
      description: str => str,
    };
  };

  commandUsage(cmd) {
    return super.commandUsage(cmd).split('Usage: ')[1];
  };

  formatParams() {
    return {
      baseIndentWidth: 0,
      itemIndentWidth: 2,
      itemSeparatorWidth: 2
    };
  };

  formatHelp(cmd, helper) {
    return ['', (new Help(cmd, helper)).render(), ''].join('\n');
  };
}

class MyCommand extends Command {
  createCommand(name) {
    return new MyCommand(name);
  };

  createHelp() {
    return Object.assign(new MyHelpTools(), this._helpOverrides);
  };
}

const program = new MyCommand('example');

program.version('0.0.1');

program.helpOverrides({
  labels: () => {
    return {
      usage: '>>> Usage:\n',
      arguments: '>>> Arguments:\n',
      options: '>>> Options:\n',
      commands: '>>> Commands:\n',
    }
  },
  styles: () => {
    return {
      label: str => '\x1b[33m' + str + '\x1b[0m', // yellow
      term: str => '\x1b[32m' + str + '\x1b[0m', // green
      description: str => '\x1b[36m' + str + '\x1b[0m', // cyan
    };
  },
  formatParams: () => {
    return {
      baseIndentWidth: 2,
    };
  },
});

program
  .command('test <cmd> [env] [val]')
  .description(Array(100).join('long description '), {
    cmd: 'cmd description',
    env: 'env description',
    val: Array(50).join('val description ')
  })
  .action((cmd, env, options) => {});

program.parse(process.argv);

@shadowspawn
Copy link
Collaborator Author

Thanks @cravler . I was wondering about applying styles, and that is just one of the layers you have added, and even used both subclass and arrow function customisations. Thanks for the big try-out!

(I changed HelpTools to Help in the PR today, apologies for the churn.)

@shadowspawn shadowspawn changed the title WIP: Refactor help internals into separate interface/class Refactor help internals into separate interface/class Oct 18, 2020
@shadowspawn
Copy link
Collaborator Author

Ready for review. This is a big refactor of existing code, and makes the methods used for building the help public so they may be configured in custom help implementations.

index.js Outdated Show resolved Hide resolved
index.js Outdated Show resolved Hide resolved
@shadowspawn
Copy link
Collaborator Author

(I'll hopefully have time over next week to prepare a prerelease of 7.x, and a 6.x as well.)

Copy link
Collaborator

@abetomo abetomo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.
That is great! Thank you!

@shadowspawn shadowspawn added this to the v7.0.0 milestone Oct 23, 2020
@shadowspawn shadowspawn merged commit 19ae912 into tj:release/7.x Oct 23, 2020
@shadowspawn shadowspawn added the pending release Merged into a branch for a future release, but not released yet label Oct 23, 2020
@shadowspawn
Copy link
Collaborator Author

Included in v7.0.0 pre-release #1386

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
semver: major Releasing requires a major version bump, not backwards compatible
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants