CLIs mais simples com Util.parseArgs

Lucas Santos - Oct 21 '22 - - Dev Community

Aplicações de linha de comando, os famosos CLI's (Command Line Interfaces), são extremamente comuns, principalmente quando estamos lidando com devs.

Essas aplicações costumam ser ordens de grandeza mais leves do que uma aplicação com uma interface gráfica, são mais simples de serem utilizadas e permitem automação e scripting de forma nativa, tudo isso sacrificando a interface e um pouco da experiência de usuário, já que é necessário que você tenha um pouco – ou as vezes, muito – conhecimento de ambientes de linha de comando para poder começar a usar as funcionalidades básicas.

Enquanto a psrte de UI está ficando cada vez mais avançada com bibliotecas como o blessed, e a UX está cada vez mais avançada com outras libs como o inquirer, a DX, ou Developer Experience, continua a mesma. Desenvolver um CLI acaba sendo complicado e cheio de pequenos hacks que temos que fazer, principalmente para poder entender o que o usuário enviou no comando inicial, os chamados argumentos.

Hoje, algumas bibliotecas famosas como o Yargs, o commander, meow e caporal facilitam a tarefa de ter que pegar os argumentos da linha de comando e enviá-las para a aplicação executar alguma coisa, mas isso é algo tão simples que as pessoas se perguntam: "Por que isso não é nativo do Node.js?", bom essa espera acabou.

util.parseArgs

Na versão 18, o Node.js implementou uma API experimental chamada util.parseArgs. O objetivo é justamente facilitar e automatizar a forma como buscamos argumentos de linha de comando para melhorar (e até mesmo eliminar) a necessidade de bibliotecas externas para fazer a mesma coisa.

Ela tem uma API bastante simples, que leva apenas um objeto de configuração:

import { parseArgs } from 'node:util'
const { values, positionals } = parseArgs({ args, options })

Enter fullscreen mode Exit fullscreen mode

O objeto de configuração tem quatro opções:

  • args: O array de argumentos que queremos parsear, por padrão, ele vai ser o process.argv removenvo os dois primeiros argumentos que são o execPath e o filename (o caminho do comando do Node que foi executado e o nome do arquivo que está rodando) e deixando apenas o que veio depois.
  • options: É um outro objeto que é usado para definir quais são os argumentos que vão ser identificados como válidos pelo programa, essa chave é obrigatória e é um objeto com itens que devem seguir essa interface:
    • type: Uma string definindo qual é o tipo do argumento, no momento o parseArgs só suporta string e boolean
    • multiple: Um boolean que define se o argumento pode ser passado múltiplas vezes. Se for true, todos os valores desse argumento vão ser coletados em um array, caso contrário, a última opção setada vai ser a escolhida. Por padrão, o valor é false
    • short: O alias da opção em um único caractere, por exemplo, a abreviação de --all ser -A então o short será A
  • strict: Se o programa deve lançar um erro caso algum argumento não reconhecido por options seja passado, é true por padrão
  • allowPositionals: Se o comando vai aceitar argumentos posicionais, ou seja, se podemos passar argumentos que não tem flags como -a, esses argumentos serão recebidos em um array próprio que fizemos o destructuring como positionals
  • tokens: Retorna os tokens que foram passados. Essa função é mais útil quando você quer estender o comportamento da função original, não tanto quando você quer apenas usar a base da função.

O retorno do parseArgs é um objeto com três chaves:

  • values: Um mapa de todos os nomes das opções com seus valores respectivos
  • positionals: Um array de strings com os argumentos posicionais passados, em ordem.
  • tokens: Um array de objetos retornado se a opção tokens da configuração for true

O objeto de tokens pode ter tokens de dois tipos, ou opções ou argumentos posicionais. Todos eles vão ser retornados em um único objeto que vai ter todos os tokens. Todos os valores desse objeto vão ter pelo menos duas chaves:

  • kind: Ou option ou positional ou option-terminator
  • index: O indice do elemento no array de argumentos, de forma que o argumento original de um token possa ser obtido com args[token.index]

Para tokens do tipo opções (os que tem as flags), vamos ter algumas propriedades extras:

  • name: O nome longo do token, por exemplos all
  • rawName: O nome original da opção, sem remover os traços, do jeito que foi passado para o comando, por exemplo, --all
  • value: O valor do argumento, se for um boolean, esse valor vai ser undefined
  • inlineValue: Se o valor foi especificado de forma inline como --foo=bar

Pra argumentos posicionais, sem opções, só vamos ter o valor em uma chave value que é o equivalente a args[index]

Esses tokens vão ser sempre retornados na ordem que foram passados, então é possível estener a funcionalidade caso você precise suportar algum tipo de argumento antes de outro argumento.

Outra coisa importante é quando temos os chamados short option groups, que são casos como -abc nesses casos, cada um deles vai ser expandido para um token diferente, então se tivermos casos comuns como -vvv vamos ter três tokens do tipo option.

Vamos a alguns exemplos.

Opções simples

Imagine que temos o seguinte código:

const options = {
  verbose: {
    type: 'boolean',
    short: 'v',
  },
  color: {
    type: 'string',
    short: 'c',
  },
  times: {
    type: 'string',
    short: 't',
  },
}

const { values, positionals } = parseArgs({options, args: ['-v', '-c', 'green']})

Enter fullscreen mode Exit fullscreen mode

Teremos em values o seguinte objeto:

{
  __proto__ : null,
  verbose: true,
  color: 'green'
}

Enter fullscreen mode Exit fullscreen mode

Já o nosso array de positionals será vazio porque não estamos específicando que queremos posicionais. Veja que em values as chaves serão sempre os nomes completos das opções.

Parâmetros posicionais

Se modificarmos o código para permitir positionals dessa forma:

const { values, positionals } = parseArgs({
    options,
    allowPositionals: true,
    args: [
      'home.html', '--verbose', 'main.js', '--color', 'red', 'post.md'
    ]
  })

Enter fullscreen mode Exit fullscreen mode

Vamos ter a seguinte saída em values:

{
    __proto__ :null,
    verbose: true,
    color: 'red'
}

Enter fullscreen mode Exit fullscreen mode

Porém agora vamos ter um array de opções posicionais em positionals:

['home.html', 'main.js', 'post.md']

Enter fullscreen mode Exit fullscreen mode

Veja que eles aparecem na ordem que estamos enviando no código.

Opções múltiplas

Se utilizarmos a mesma opção várias vezes normalmente, como eu mencionei antes, somente uma chave vai ser criada, por exemplo:

const options = {
  'bool': {
    type: 'boolean',
  },
  'str': {
    type: 'string',
  },
}
parseArgs({
  options, args: [
    '--bool', '--bool', '--str', 'yes', '--str', 'no'
  ]
})

Enter fullscreen mode Exit fullscreen mode

Vamos ter um values como:

{
  __proto__ :null,
  bool: true,
  str: 'no'
}

Enter fullscreen mode Exit fullscreen mode

Veja que só temos o último valor da opção. Agora, se passarmos o parâmetro multiple para qualquer tipo de opção no nosso objeto options:

const options = {
  'bool': {
    type: 'boolean',
    multiple: true,
  },
  'str': {
    type: 'string',
    multiple: true,
  },
}
parseArgs({
  options, args: [
    '--bool', '--bool', '--str', 'yes', '--str', 'no'
  ]
})

Enter fullscreen mode Exit fullscreen mode

Vamos ter o seguinte valor em values:

{
  __proto__ :null,
  bool: [true, true],
  str: ['yes', 'no']
}

Enter fullscreen mode Exit fullscreen mode

Shorthands conexos

Existe um tipo de valor que podemos passar para uma linha de comando que é conhecida como shorthand , a ideia é que, quando setamos múltiplas opções booleanas, podemos agrupar todos em um único -, por exemplo, ao invés de main.js -v -s podemos fazer main.js -vs.

Isso também funciona no parseArgs sem precisar fazer nada de mais, só precisamos setar a opção short para essas propriedades:

const options = {
  'verbose': {
    type: 'boolean',
    short: 'v',
  },
  'silent': {
    type: 'boolean',
    short: 's',
  },
  'color': {
    type: 'string',
    short: 'c',
  },
}
parseArgs({options, args: ['-vs']})

Enter fullscreen mode Exit fullscreen mode

Vai nos dar esse objeto de values:

{
  __proto__ :null,
  verbose: true,
  silent: true,
}

Enter fullscreen mode Exit fullscreen mode

Option terminators

Existe um tipo de opção específica chamada de terminator , isso é, após esse argumento, todo o resto é tratado como posicional. No caso da maioria dos shells, essa opção é o --, por exemplo, quando queremos executar um comando do NPM antigamente e enviar um argumento ao comando que seria executado, fazíamos: npm run <comando> -- param param param e o comando iria receber os três parâmetros individualmente. O mesmo vale para o parseArgs:

const options = {
  'verbose': {
    type: 'boolean',
  },
  'count': {
    type: 'string',
  },
}

parseArgs({options, allowPositionals: true,
 args: [
   'how', 
   '--verbose', 
   'are', 
   '--', 
   '--count', 
   '5', 
   'you'
 ]
})

Enter fullscreen mode Exit fullscreen mode

O objeto values será:

{
  __proto__ :null,
  verbose: true
}

Enter fullscreen mode Exit fullscreen mode

E vamos ter os positionals:

['how', 'are', '--count', '5', 'you']

Enter fullscreen mode Exit fullscreen mode

Tokens

Quando falamos de tokens, a funcionalidade é um pouco mais complexa, pra isso precisamos explicar como essa API funciona, de fato.

O parseArgs funciona em duas fases:

  • A primeira fase é parsear o array de argumentos em um array de tokens. O objetivo disso é que a gente tenha uma espécie de array de argumentos parseados parecidos com o que a gente já tem, mas com anotações de tipos, se o argumento é uma opção, se é um argumento posicional e etc
  • Na segunda fase, a saída dessa primeira fase é lida pelo parser e acabamos com o array que temos anteriormente.

A gente pode ter acesso à primeira parte como uma saída se setarmos o array de configurações com a opção tokens como true. Ai vamos ter uma chave tokens na saída final.

O tipo desse objeto vai ser a seguinte interface (como explicada aqui):

type Token = OptionToken | PositionalToken | OptionTerminatorToken;

interface CommonTokenProperties {
    /** Onde o token começa na string? */
  index: number;
}

interface OptionToken extends CommonTokenProperties {
  kind: 'option';

  /** Nome longo */
  name: string;

  /** O nome da opção no array `args` */
  rawName: string;

  /** O valor da opção. Sempre `undefined` para boolean. */
  value: string | undefined;

  /** O valor está inline (ex --level=5)? */
  inlineValue: boolean | undefined;
}

interface PositionalToken extends CommonTokenProperties {
  kind: 'positional';

  /** O valor do argumento posicional, args[token.index] */
  value: string;
}

interface OptionTerminatorToken extends CommonTokenProperties {
  kind: 'option-terminator';
}

Enter fullscreen mode Exit fullscreen mode

Vamos a um exemplo, digamos que temos o seguinte array de opções:

const options = {
  'bool': {
    type: 'boolean',
    short: 'b',
  },
  'flag': {
    type: 'boolean',
    short: 'f',
  },
  'str': {
    type: 'string',
    short: 's',
  },
}

Enter fullscreen mode Exit fullscreen mode

Quando rodarmos parseArgs({ options, tokens: true, args: ['--bool', '-b', '-bf'] }), vamos obter o seguinte objeto:

{
    values: {
      __proto__ :null,
      bool: true,
      flag: true,
    },
    positionals: [],
    tokens: [
      {
        kind: 'option',
        name: 'bool',
        rawName: '--bool',
        index: 0,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'bool',
        rawName: '-b',
        index: 1,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'bool',
        rawName: '-b',
        index: 2,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'flag',
        rawName: '-f',
        index: 2,
        value: undefined,
        inlineValue: undefined
      },
    ]
  }

Enter fullscreen mode Exit fullscreen mode

É importante notar que, mesmo que a gente tenha uma única opção chamada bool, vamos ter três índices no array porque estamos passando a chave três vezes. Um exemplo mais completo pode ser usando terminadores e também inline values como aqui:

parseArgs({
    options, allowPositionals: true, tokens: true,
    args: [
      'command', '--', '--str', 'yes', '--str=yes'
    ]
})

Enter fullscreen mode Exit fullscreen mode

Que vai nos dar a seguinte saída:

{
    values: {
      __proto__ :null,
    },
    positionals: ['command', '--str', 'yes', '--str=yes'],
    tokens: [
      { kind: 'positional', index: 0, value: 'command' },
      { kind: 'option-terminator', index: 1 },
      { kind: 'positional', index: 2, value: '--str' },
      { kind: 'positional', index: 3, value: 'yes' },
      { kind: 'positional', index: 4, value: '--str=yes' }
    ]
  }

Enter fullscreen mode Exit fullscreen mode

Veja que quando usamos um terminador, todos os demais valores são considerador posicionais.

Um exemplo seria usar essa funcionalidade para poder implementar um CLI que usa subcomandos, como por exemplo o git com git commit ou a Azure com az aks create. Vou passar por essa implementação explicando como ela funcionaria.

Primeiro, vamos definir uma função para buscar o primeiro comando, que é um posicional, e depois vamos buscar o primeiro elemento posicional que acharmos:

function parseSubcommand(config) {
  // Permitindo posicionais já que o subcomando é posicional
  const {tokens} = parseArgs({
    ...config, tokens: true, allowPositionals: true
  });
  // Encontra a primeira ocorrência do posicional
  let firstPosToken = tokens.find(({kind}) => kind==='positional');
  if (!firstPosToken) {
    throw new Error('Command name is missing: ' + config.args);
  }

Enter fullscreen mode Exit fullscreen mode

Depois vamos pegar as opções do comando e chamar o parseArgs novamente:

  const cmdArgs = config.args.slice(0, firstPosToken.index);
  // Substituímos a ocorrencia em `config.args`
  const commandResult = parseArgs({
    ...config, args: cmdArgs, tokens: false, allowPositionals: false
  })

Enter fullscreen mode Exit fullscreen mode

Agora vamos pegar o subcomando desse comando:

  const subcommandName = firstPosToken.value

  const subcmdArgs = config.args.slice(firstPosToken.index+1)
  // substituindo o `config.args`
  const subcommandResult = parseArgs({
    ...config, args: subcmdArgs, tokens: false
  })

  return {
    commandResult,
    subcommandName,
    subcommandResult,
  }
}

Enter fullscreen mode Exit fullscreen mode

A função toda ficaria assim:

function parseSubcommand(config) {
  const {tokens} = parseArgs({
    ...config, tokens: true, allowPositionals: true
  })
  let firstPosToken = tokens.find(({kind}) => kind==='positional')
  if (!firstPosToken) {
    throw new Error('Command name is missing: ' + config.args)
  }

  //----- Command options
  const cmdArgs = config.args.slice(0, firstPosToken.index)
  const commandResult = parseArgs({
    ...config, args: cmdArgs, tokens: false, allowPositionals: false
  })

  //----- Subcommand
  const subcommandName = firstPosToken.value;

  const subcmdArgs = config.args.slice(firstPosToken.index+1)
  const subcommandResult = parseArgs({
    ...config, args: subcmdArgs, tokens: false
  })

  return {
    commandResult,
    subcommandName,
    subcommandResult,
  }
}

Enter fullscreen mode Exit fullscreen mode

Conclusão

O parseArgs é uma excelente opção para facilitar a criação de CLIs e mostrar como podemos melhorar ainda mais o desenvolvimento de aplicações usando Node.js. Não se esqueça de ler a documentação oficial e o artigo que mostrei nesse post!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .