├── .gitignore ├── .replit ├── packages ├── clui-gql │ ├── .gitignore │ ├── .prettierignore │ ├── codegen.yml │ ├── src │ │ ├── index.ts │ │ ├── __tests__ │ │ │ ├── util.ts │ │ │ ├── forEach.test.ts │ │ │ ├── schema.graphql │ │ │ ├── toOperation.test.ts │ │ │ ├── generated │ │ │ │ └── schema.ts │ │ │ ├── parseArgs.test.ts │ │ │ └── toCommand.test.ts │ │ ├── forEach.ts │ │ ├── types.ts │ │ ├── toOperation.ts │ │ ├── parseArgs.ts │ │ └── toCommand.ts │ ├── jest.config.js │ ├── typedoc.json │ ├── scripts │ │ └── postBuild.js │ ├── tsconfig.json │ ├── package.json │ ├── .eslintrc.js │ └── README.md ├── clui-input │ ├── src │ │ ├── __tests__ │ │ │ ├── index.test.ts │ │ │ ├── resolver.test.ts │ │ │ ├── tokenizer.test.ts │ │ │ ├── ast.test.ts │ │ │ ├── input.options.test.ts │ │ │ └── parser.test.ts │ │ ├── index.ts │ │ ├── resolver.ts │ │ ├── tokenizer.ts │ │ ├── types.ts │ │ ├── optionsList.ts │ │ ├── ast.ts │ │ ├── parser.ts │ │ ├── options.ts │ │ └── input.ts │ ├── jest.config.js │ ├── typedoc.json │ ├── tsconfig.json │ ├── package.json │ ├── README.md │ └── .eslintrc.js └── clui-session │ ├── src │ ├── index.tsx │ ├── setupEnzyme.ts │ ├── __tests__ │ │ ├── index.tsx │ │ ├── item.previous.tsx │ │ ├── item.remove.test.tsx │ │ ├── item.replace.test.tsx │ │ ├── Session.test.tsx │ │ ├── session.context.test.tsx │ │ ├── item.insertAfter.test.tsx │ │ ├── session.reset.test.tsx │ │ ├── item.insertBefore.test.tsx │ │ ├── session.length.test.tsx │ │ ├── session.currentIndex.test.tsx │ │ └── item.next.test.tsx │ ├── Do.tsx │ ├── Step.tsx │ ├── reducer.ts │ └── Session.tsx │ ├── typedoc.json │ ├── jest.config.js │ ├── tsconfig.json │ ├── package.json │ ├── .eslintrc.js │ └── README.md ├── package.json ├── .prettierrc.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | typedoc/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.replit: -------------------------------------------------------------------------------- 1 | language = "nodejs" 2 | run = "npm run docs:dev" 3 | -------------------------------------------------------------------------------- /packages/clui-gql/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | docs/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": ["packages/*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/clui-gql/.prettierignore: -------------------------------------------------------------------------------- 1 | src/graphqlTypes.ts 2 | src/__tests__/generated/schema.ts 3 | -------------------------------------------------------------------------------- /packages/clui-input/src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import input from '..'; 2 | 3 | it('exports state', () => { 4 | expect(typeof input).toEqual('function'); 5 | }); 6 | -------------------------------------------------------------------------------- /packages/clui-gql/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | generates: 3 | src/__tests__/generated/schema.ts: 4 | schema: "./src/__tests__/schema.graphql" 5 | plugins: 6 | - "typescript" 7 | -------------------------------------------------------------------------------- /packages/clui-input/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | roots: ['/src'], 4 | testMatch: ['**/__tests__/**/*.test.ts'], 5 | transform: { '^.+\\.ts$': 'ts-jest' }, 6 | }; 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'all', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | printWidth: 80, 7 | bracketSpacing: true, 8 | arrowParens: 'always', 9 | }; 10 | -------------------------------------------------------------------------------- /packages/clui-gql/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as toCommand } from './toCommand'; 2 | export { default as parseArgs } from './parseArgs'; 3 | export { default as forEach } from './forEach'; 4 | export * from './types'; 5 | -------------------------------------------------------------------------------- /packages/clui-gql/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | roots: ['/src'], 4 | testMatch: ['**/__tests__/**/*.test.+(ts|tsx|js)'], 5 | transform: { '^.+\\.(ts|tsx)$': 'ts-jest' }, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/clui-gql/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleResolution": "node", 3 | "name":"CLUI GraphQl ", 4 | "includeDeclarations": true, 5 | "excludeExternals": true, 6 | "exclude": ["src/__tests__/**/*"], 7 | "out": "typedeoc" 8 | } 9 | -------------------------------------------------------------------------------- /packages/clui-input/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleResolution": "node", 3 | "name":"CLUI Input", 4 | "includeDeclarations": true, 5 | "excludeExternals": true, 6 | "exclude": ["src/__tests__/**/*"], 7 | "out": "typedeoc" 8 | } 9 | -------------------------------------------------------------------------------- /packages/clui-session/src/index.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | default as Session, 3 | default, 4 | ISession, 5 | ISessionItem, 6 | ISessionItemProps, 7 | } from './Session'; 8 | export { default as Step } from './Step'; 9 | export { default as Do } from './Do'; 10 | -------------------------------------------------------------------------------- /packages/clui-session/typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleResolution": "node", 3 | "name":"CLUI Session", 4 | "includeDeclarations": true, 5 | "excludeExternals": true, 6 | "exclude": ["src/__tests__/**/*", "src/*/__tests__/**/*", "src/setupEnzyme.ts"], 7 | "out": "typedoc" 8 | } 9 | 10 | -------------------------------------------------------------------------------- /packages/clui-session/src/setupEnzyme.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { configure } from 'enzyme'; 3 | import EnzymeAdapter from 'enzyme-adapter-react-16'; 4 | /* eslint-enable import/no-extraneous-dependencies */ 5 | 6 | configure({ adapter: new EnzymeAdapter() }); 7 | -------------------------------------------------------------------------------- /packages/clui-session/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | roots: ['/src'], 4 | testMatch: ['**/__tests__/**/*.test.+(ts|tsx|js)'], 5 | transform: { '^.+\\.(ts|tsx)$': 'ts-jest' }, 6 | snapshotSerializers: ['enzyme-to-json/serializer'], 7 | setupFilesAfterEnv: ['/src/setupEnzyme.ts'], 8 | }; 9 | -------------------------------------------------------------------------------- /packages/clui-gql/src/__tests__/util.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { buildSchema, introspectionFromSchema } from 'graphql'; 4 | 5 | const schema = buildSchema( 6 | fs.readFileSync(path.resolve(__dirname, './schema.graphql'), 'utf8'), 7 | ); 8 | 9 | export const introspection = introspectionFromSchema(schema); 10 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Session } from '..'; 3 | 4 | describe('index', () => { 5 | it('renders ', () => { 6 | expect( 7 | React.isValidElement( 8 | 9 | 10 | , 11 | ), 12 | ).toEqual(true); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/clui-input/src/index.ts: -------------------------------------------------------------------------------- 1 | import { createInput, IConfig, IInputUpdates } from './input'; 2 | 3 | export { 4 | ICommands, 5 | ICommand, 6 | ICommandArgs, 7 | IArg, 8 | ArgType, 9 | ArgTypeDef, 10 | IOption, 11 | IRunOptions, 12 | SubCommands, 13 | } from './types'; 14 | 15 | export { IConfig, IInputUpdates }; 16 | 17 | export default createInput; 18 | -------------------------------------------------------------------------------- /packages/clui-gql/src/__tests__/forEach.test.ts: -------------------------------------------------------------------------------- 1 | import forEach from '../forEach'; 2 | import { IGQLCommand } from '../types'; 3 | 4 | it('call function for each command', () => { 5 | const fn = jest.fn(); 6 | 7 | const command: IGQLCommand = { 8 | outputType: '', 9 | path: [], 10 | commands: { 11 | add: { outputType: '', path: [] }, 12 | remove: { outputType: '', path: [] }, 13 | }, 14 | }; 15 | 16 | forEach(command, fn); 17 | 18 | expect(fn).toHaveBeenCalledTimes(3); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/clui-gql/src/__tests__/schema.graphql: -------------------------------------------------------------------------------- 1 | type Services { 2 | name: String! 3 | } 4 | 5 | type Weather { 6 | config: String 7 | services: Services 8 | } 9 | 10 | enum STATUS { 11 | ACTIVE 12 | INACTIVE 13 | } 14 | 15 | """Cli root""" 16 | type Cli { 17 | """Get weather info""" 18 | status: STATUS! 19 | weather( 20 | """Zipcode""" 21 | zipcode: String!, 22 | view: String, 23 | count: Int, 24 | time: Float, 25 | days: [String], 26 | hours: [Int], 27 | minutes: [Float!]!, 28 | tomorrow: Boolean, 29 | today: Boolean! 30 | status: STATUS): Weather 31 | } 32 | 33 | type Root { 34 | cli: Cli 35 | } 36 | 37 | schema { 38 | query: Root 39 | } 40 | -------------------------------------------------------------------------------- /packages/clui-gql/src/forEach.ts: -------------------------------------------------------------------------------- 1 | import { IGQLCommand } from './types'; 2 | 3 | const forEach = ( 4 | root: IGQLCommand, 5 | fn: (params: { command: IGQLCommand; root: IGQLCommand }) => void, 6 | ) => { 7 | if (typeof root.commands !== 'object') { 8 | throw Error('Expected commands object'); 9 | } 10 | 11 | fn({ command: root, root }); 12 | 13 | const queue: Array = [...Object.values(root.commands)]; 14 | 15 | while (queue.length) { 16 | const command = queue.shift(); 17 | 18 | if (command) { 19 | fn({ command, root }); 20 | 21 | if (command.commands) { 22 | queue.push(...Object.values(command.commands)); 23 | } 24 | } 25 | } 26 | }; 27 | 28 | export default forEach; 29 | -------------------------------------------------------------------------------- /packages/clui-session/src/Do.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ISessionItem, ISessionItemProps } from './Session'; 3 | 4 | interface IProps extends ISessionItemProps { 5 | children: (item: ISessionItem) => React.ReactElement; 6 | } 7 | 8 | /** 9 | * `Do` is a utility component the gives you access to `item` inline 10 | * 11 | * ``` 12 | * 13 | * 14 | * {item => } 15 | * 16 | * 17 | * {item => } 18 | * 19 | * 20 | * ``` 21 | * 22 | */ 23 | const Do = ({ item, children }: IProps) => { 24 | if (!item) { 25 | throw Error('`Do` must be rendered as a direct child of a `Session`'); 26 | } 27 | 28 | return children(item); 29 | }; 30 | 31 | export default Do; 32 | -------------------------------------------------------------------------------- /packages/clui-gql/src/types.ts: -------------------------------------------------------------------------------- 1 | import { ICommand, IArg } from '@replit/clui-input'; 2 | 3 | export interface IGQLCommand { 4 | outputType: string; 5 | description?: string; 6 | path: Array; 7 | mutation?: string; 8 | query?: string; 9 | args?: Record; 10 | commands?: Record; 11 | run?: ICommand['run']; 12 | } 13 | 14 | type Parsed = string | number | boolean; 15 | export type PromptArgs = Record; 16 | 17 | export interface IGQLCommandArg extends IArg { 18 | name: string; 19 | description?: string; 20 | graphql: { 21 | kind: string; 22 | list?: boolean; 23 | }; 24 | } 25 | 26 | export type OutputFn = (options: { 27 | path: Array; 28 | field: any; 29 | operation: 'query' | 'mutation'; 30 | }) => { fields: string; fragments?: string }; 31 | -------------------------------------------------------------------------------- /packages/clui-gql/scripts/postBuild.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line 2 | const path = require('path'); 3 | // eslint-disable-next-line 4 | const fs = require('fs'); 5 | 6 | const root = path.resolve(__dirname, '..'); 7 | 8 | // eslint-disable-next-line 9 | const package = require(path.resolve(root, 'package.json')); 10 | 11 | ['private', 'scripts', 'husky', 'lint-staged', 'devDependencies'].forEach( 12 | (key) => delete package[key], 13 | ); 14 | 15 | package.main = 'index.js'; 16 | package.types = 'index.d.ts'; 17 | 18 | fs.copyFile( 19 | path.resolve(root, 'README.md'), 20 | path.resolve(root, 'dist', 'README.md'), 21 | (err) => { 22 | if (err) throw err; 23 | }, 24 | ); 25 | 26 | fs.writeFile( 27 | path.resolve(root, 'dist', 'package.json'), 28 | JSON.stringify(package, null, 2), 29 | (err) => { 30 | if (err) throw err; 31 | }, 32 | ); 33 | -------------------------------------------------------------------------------- /packages/clui-input/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "rootDir": "./src/", 5 | "esModuleInterop": true, 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "target": "es5", 9 | "strict": true, 10 | "lib": ["es2017", "esnext.asynciterable"], 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "sourceMap": true, 14 | "declaration": true, 15 | "declarationDir": "./dist", 16 | "noImplicitAny": true, 17 | "removeComments": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "typeRoots": [ 20 | "../../node_modules/@types", 21 | "./node_modules/@types" 22 | ], 23 | "baseUrl": ".", 24 | "paths": { 25 | "*": ["src/*"] 26 | }, 27 | "downlevelIteration": true 28 | }, 29 | "include": ["src/**/*"], 30 | "exclude": ["src/**/__tests__"] 31 | } 32 | -------------------------------------------------------------------------------- /packages/clui-gql/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "rootDir": "./src/", 5 | "esModuleInterop": true, 6 | "jsx": "react", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "target": "es5", 10 | "strict": true, 11 | "lib": ["es2017", "esnext.asynciterable"], 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "sourceMap": true, 15 | "declaration": true, 16 | "declarationDir": "./dist", 17 | "noImplicitAny": true, 18 | "removeComments": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "typeRoots": [ 21 | "../../node_modules/@types", 22 | "./node_modules/@types", 23 | "./typings"], 24 | "baseUrl": ".", 25 | "paths": { 26 | "*": ["src/*", "typings/*"] 27 | }, 28 | "downlevelIteration": true 29 | }, 30 | "include": ["src/**/*"], 31 | "exclude": ["src/**/__tests__", "examples/**/*"] 32 | } 33 | -------------------------------------------------------------------------------- /packages/clui-session/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "rootDir": "./src/", 5 | "esModuleInterop": true, 6 | "jsx": "react", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "target": "es5", 10 | "strict": true, 11 | "lib": ["dom", "es2017", "esnext.asynciterable"], 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "sourceMap": true, 15 | "declaration": true, 16 | "declarationDir": "./dist", 17 | "noImplicitAny": true, 18 | "removeComments": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "typeRoots": [ 21 | "../../node_modules/@types", 22 | "./node_modules/@types", 23 | "./typings" 24 | ], 25 | "baseUrl": ".", 26 | "paths": { 27 | "*": ["src/*", "typings/*"] 28 | }, 29 | "downlevelIteration": true 30 | }, 31 | "include": ["src/**/*"], 32 | "exclude": ["src/**/__tests__", "examples/**/*", "src/setupEnzyme.ts"] 33 | } 34 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/item.previous.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('item.previous()', () => { 7 | it('shows previous element', () => { 8 | const wrapper = mount( 9 | 10 | 11 | 12 | 13 | , 14 | ); 15 | 16 | act(() => { 17 | (wrapper.find('.c').prop('item') as ISessionItem).previous(); 18 | }); 19 | wrapper.update(); 20 | expect(wrapper.find('.c')).toHaveLength(0); 21 | expect(wrapper.find('.b')).toHaveLength(1); 22 | expect(wrapper.find('.a')).toHaveLength(1); 23 | 24 | act(() => { 25 | (wrapper.find('.b').prop('item') as ISessionItem).previous(); 26 | }); 27 | wrapper.update(); 28 | expect(wrapper.find('.b')).toHaveLength(0); 29 | expect(wrapper.find('.a')).toHaveLength(1); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/item.remove.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('item.remove()', () => { 7 | it('removes element', () => { 8 | const wrapper = mount( 9 | 10 | 11 | 12 | , 13 | ); 14 | expect(wrapper.find('.a')).toHaveLength(1); 15 | expect(wrapper.find('.b')).toHaveLength(1); 16 | expect( 17 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 18 | ).toEqual(1); 19 | 20 | act(() => { 21 | (wrapper.find('.b').prop('item') as ISessionItem).remove(); 22 | }); 23 | wrapper.update(); 24 | 25 | expect(wrapper.find('.a')).toHaveLength(1); 26 | expect(wrapper.find('.b')).toHaveLength(0); 27 | expect( 28 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 29 | ).toEqual(0); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/clui-input/src/__tests__/resolver.test.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from '../resolver'; 2 | 3 | it('resolves command', async () => { 4 | const root = { 5 | commands: async () => ({ 6 | user: {}, 7 | }), 8 | }; 9 | 10 | const cache = {}; 11 | const resolved = await resolve({ input: 'user', command: root, cache }); 12 | 13 | expect(resolved.command?.ref).toEqual({}); 14 | expect(resolved.command?.token).toEqual({ 15 | kind: 'KEYWORD', 16 | value: 'user', 17 | start: 0, 18 | end: 4, 19 | }); 20 | }); 21 | 22 | it('resolves subcommand', async () => { 23 | const root = { 24 | commands: { 25 | user: { 26 | commands: async () => ({ 27 | add: {}, 28 | }), 29 | }, 30 | }, 31 | }; 32 | 33 | const cache = {}; 34 | const resolved = await resolve({ input: 'user add', command: root, cache }); 35 | 36 | expect(resolved.command?.command?.parent?.ref).toEqual(root.commands.user); 37 | expect(resolved.command?.command?.ref).toEqual({}); 38 | expect(resolved.command?.command?.token).toEqual({ 39 | kind: 'KEYWORD', 40 | value: 'add', 41 | start: 5, 42 | end: 8, 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/item.replace.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('session.replace()', () => { 7 | it('replaces element', () => { 8 | const wrapper = mount( 9 | 10 | 11 | 12 | , 13 | ); 14 | 15 | expect(wrapper.find('.a')).toHaveLength(1); 16 | expect(wrapper.find('.b')).toHaveLength(1); 17 | expect( 18 | (wrapper.find('.b').prop('item') as ISessionItem).session.currentIndex, 19 | ).toEqual(1); 20 | 21 | act(() => { 22 | (wrapper.find('.b').prop('item') as ISessionItem).replace( 23 | , 24 | ); 25 | }); 26 | wrapper.update(); 27 | 28 | expect(wrapper.find('.a')).toHaveLength(1); 29 | expect(wrapper.find('.b')).toHaveLength(0); 30 | expect(wrapper.find('.c')).toHaveLength(1); 31 | expect( 32 | (wrapper.find('.c').prop('item') as ISessionItem).session.currentIndex, 33 | ).toEqual(1); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/clui-input/src/resolver.ts: -------------------------------------------------------------------------------- 1 | import { parse } from './parser'; 2 | import { IAst } from './ast'; 3 | import { ICommand, ICommands } from './types'; 4 | 5 | interface IOptions { 6 | input: string; 7 | command: ICommand; 8 | cache?: Record; 9 | } 10 | 11 | export const resolve = async (options: IOptions) => { 12 | let tries = 0; 13 | const cacheGet = (key: string): ICommands | null => { 14 | if (options.cache) { 15 | return options.cache[key] || null; 16 | } 17 | 18 | return null; 19 | }; 20 | 21 | const cacheSet = (key: string, commands: ICommands) => { 22 | if (options.cache) { 23 | options.cache[key] = commands; 24 | } 25 | 26 | return null; 27 | }; 28 | 29 | const run = async (): Promise => { 30 | tries++; 31 | const ast = parse(options.input, options.command, cacheGet); 32 | 33 | if (ast.pending && options.cache && tries < 50) { 34 | const { value } = ast.pending.token; 35 | const result = await ast.pending.resolve(value || undefined); 36 | if (result) { 37 | cacheSet(ast.pending.key, result); 38 | 39 | return run(); 40 | } 41 | } 42 | 43 | return ast; 44 | }; 45 | 46 | return run(); 47 | }; 48 | -------------------------------------------------------------------------------- /packages/clui-gql/src/__tests__/toOperation.test.ts: -------------------------------------------------------------------------------- 1 | import toOperation from '../toOperation'; 2 | import { introspection } from './util'; 3 | 4 | test('toOperation', () => { 5 | const cli = introspection.__schema.types.find((t) => t.name === 'Cli'); 6 | 7 | if (!cli) { 8 | throw Error('Expected field'); 9 | } 10 | 11 | if (!('fields' in cli)) { 12 | throw Error('Expected fields'); 13 | } 14 | 15 | const weather = cli.fields.find((f) => f.name === 'weather'); 16 | 17 | if (!weather) { 18 | throw Error('Expected field'); 19 | } 20 | 21 | const output = 'services { name }'; 22 | const res = toOperation({ 23 | path: ['cli', weather.name], 24 | field: weather, 25 | output: () => ({ fields: output }), 26 | operation: 'query', 27 | }); 28 | 29 | /* eslint-disable max-len */ 30 | expect(res) 31 | .toEqual(`query Query($zipcode: String!, $view: String, $count: Int, $time: Float, $days: [String], $hours: [Int], $minutes: [Float!]!, $tomorrow: Boolean, $today: Boolean!, $status: STATUS) { 32 | cli { 33 | weather(zipcode: $zipcode, view: $view, count: $count, time: $time, days: $days, hours: $hours, minutes: $minutes, tomorrow: $tomorrow, today: $today, status: $status) { 34 | ${output} 35 | } 36 | } 37 | }`); 38 | /* eslint-enable max-len */ 39 | }); 40 | -------------------------------------------------------------------------------- /packages/clui-session/src/Step.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ISessionItemProps } from './Session'; 3 | 4 | interface IProps extends ISessionItemProps { 5 | /** 6 | * Amount of time in milliseconds to wait before showing next child 7 | */ 8 | wait?: number; 9 | 10 | /** 11 | * Content to render 12 | */ 13 | children?: React.ReactNode; 14 | } 15 | 16 | /** 17 | * `Step` is a utility component that automatically shows the next child 18 | * by calling `item.next` when the component mounts 19 | * 20 | * ``` 21 | * 22 | * 23 | * shown 24 | * 25 | * 26 | * shown for 1 second 27 | * 28 | *
29 | * shown 30 | *
31 | *
32 | * NOT shown 33 | *
34 | *
35 | * ``` 36 | * 37 | */ 38 | const Step: React.FC = ({ item, wait, children }: IProps) => { 39 | React.useEffect(() => { 40 | if (!item) { 41 | return; 42 | } 43 | 44 | if (!wait) { 45 | item.next(); 46 | 47 | return; 48 | } 49 | 50 | const timer = setTimeout(item.next, wait); 51 | 52 | /* eslint-disable-next-line consistent-return */ 53 | return () => clearTimeout(timer); 54 | }, []); 55 | 56 | return <>{children}; 57 | }; 58 | 59 | export default Step; 60 | -------------------------------------------------------------------------------- /packages/clui-gql/src/__tests__/generated/schema.ts: -------------------------------------------------------------------------------- 1 | export type Maybe = T | null; 2 | /** All built-in and custom scalars, mapped to their actual values */ 3 | export type Scalars = { 4 | ID: string, 5 | String: string, 6 | Boolean: boolean, 7 | Int: number, 8 | Float: number, 9 | }; 10 | 11 | /** Cli root */ 12 | export type Cli = { 13 | __typename?: 'Cli', 14 | /** Get weather info */ 15 | status: Status, 16 | weather?: Maybe, 17 | }; 18 | 19 | 20 | /** Cli root */ 21 | export type CliWeatherArgs = { 22 | zipcode: Scalars['String'], 23 | view?: Maybe, 24 | count?: Maybe, 25 | time?: Maybe, 26 | days?: Maybe>>, 27 | hours?: Maybe>>, 28 | minutes: Array, 29 | tomorrow?: Maybe, 30 | today: Scalars['Boolean'], 31 | status?: Maybe 32 | }; 33 | 34 | export type Root = { 35 | __typename?: 'Root', 36 | cli?: Maybe, 37 | }; 38 | 39 | export type Services = { 40 | __typename?: 'Services', 41 | name: Scalars['String'], 42 | }; 43 | 44 | export enum Status { 45 | Active = 'ACTIVE', 46 | Inactive = 'INACTIVE' 47 | } 48 | 49 | export type Weather = { 50 | __typename?: 'Weather', 51 | config?: Maybe, 52 | services?: Maybe, 53 | }; 54 | -------------------------------------------------------------------------------- /packages/clui-input/src/tokenizer.ts: -------------------------------------------------------------------------------- 1 | export type TokenKind = 'KEYWORD' | 'WHITESPACE'; 2 | 3 | export interface IToken { 4 | kind: TokenKind; 5 | start: number; 6 | end: number; 7 | value: string; 8 | } 9 | 10 | export type Tokens = Array; 11 | 12 | const whitespace = /^\s+/; 13 | 14 | export const tokenize = (input: string) => { 15 | const tokens: Tokens = []; 16 | let openQuote: null | '"' | "'" = null; 17 | let prevCtx: null | TokenKind = null; 18 | let i = 0; 19 | let value = ''; 20 | 21 | while (i < input.length) { 22 | const char = input[i]; 23 | const isWhitespace = whitespace.test(char) && !openQuote; 24 | const ctx = isWhitespace ? 'WHITESPACE' : 'KEYWORD'; 25 | 26 | if (char === '"' && openQuote !== "'") { 27 | if (openQuote === '"') { 28 | openQuote = null; 29 | } else { 30 | openQuote = char; 31 | } 32 | } else if (char === "'" && openQuote !== '"') { 33 | if (openQuote === "'") { 34 | openQuote = null; 35 | } else { 36 | openQuote = char; 37 | } 38 | } 39 | 40 | if (!prevCtx || prevCtx === ctx) { 41 | value += char; 42 | } else if (prevCtx && prevCtx !== ctx) { 43 | tokens.push({ 44 | value, 45 | kind: prevCtx, 46 | start: i - value.length, 47 | end: i, 48 | }); 49 | value = char; 50 | } 51 | 52 | prevCtx = ctx; 53 | i++; 54 | } 55 | 56 | // grab last token 57 | if (value && prevCtx) { 58 | tokens.push({ 59 | value, 60 | kind: prevCtx, 61 | start: i - value.length, 62 | end: i, 63 | }); 64 | } 65 | 66 | return tokens; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/Session.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Session, { ISessionItem } from '../Session'; 4 | 5 | describe('Session', () => { 6 | it('renders element', () => { 7 | const a = ; 8 | const wrapper = shallow({a}); 9 | 10 | expect(wrapper.find('.a')).toHaveLength(1); 11 | }); 12 | 13 | it('renders first element', () => { 14 | const a = ; 15 | const b = ; 16 | const wrapper = shallow( 17 | 18 | {a} 19 | {b} 20 | , 21 | ); 22 | 23 | expect(wrapper.find('.a')).toHaveLength(1); 24 | expect(wrapper.find('.b')).toHaveLength(0); 25 | }); 26 | 27 | it('renders elements up to initialIndex', () => { 28 | const a = ; 29 | const b = ; 30 | const c = ; 31 | const wrapper = shallow( 32 | 33 | {a} 34 | {b} 35 | {c} 36 | , 37 | ); 38 | 39 | expect(wrapper.find('.a')).toHaveLength(1); 40 | expect(wrapper.find('.b')).toHaveLength(1); 41 | expect(wrapper.find('.c')).toHaveLength(0); 42 | }); 43 | 44 | it('prevents initial index from being greater than elements length', () => { 45 | const a = ; 46 | const b = ; 47 | const wrapper = shallow( 48 | 49 | {a} 50 | {b} 51 | , 52 | ); 53 | 54 | expect( 55 | (wrapper.find('.b').prop('item') as ISessionItem).session.currentIndex, 56 | ).toEqual(1); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/session.context.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('item.context', () => { 7 | it('passes through value', () => { 8 | const context = 'wat'; 9 | const wrapper = mount( 10 | 11 | 12 | , 13 | ); 14 | 15 | expect( 16 | (wrapper.find('.a').prop('item') as ISessionItem).session.context, 17 | ).toEqual(context); 18 | }); 19 | 20 | it('passes through object', () => { 21 | const context = { wat: 'wat' }; 22 | const wrapper = mount( 23 | 24 | 25 | , 26 | ); 27 | 28 | expect( 29 | (wrapper.find('.a').prop('item') as ISessionItem).session.context, 30 | ).toEqual(context); 31 | }); 32 | 33 | it('passes through updated values', () => { 34 | const Wrap = () => { 35 | const [val, setVal] = React.useState(1); 36 | 37 | return ( 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | const wrapper = mount(); 45 | 46 | expect( 47 | (wrapper.find('.a').prop('item') as ISessionItem).session.context.val, 48 | ).toEqual(1); 49 | 50 | act(() => { 51 | (wrapper.find('.a').prop('item') as ISessionItem).session.context.setVal( 52 | 2, 53 | ); 54 | }); 55 | 56 | wrapper.update(); 57 | 58 | expect( 59 | (wrapper.find('.a').prop('item') as ISessionItem).session.context.val, 60 | ).toEqual(2); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/clui-input/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@replit/clui-input", 3 | "version": "0.0.18", 4 | "description": "A utility library for building CLI style interfaces with autocomplete", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "types": "dist/index.d.ts", 10 | "scripts": { 11 | "build": "rm -rf dist && tsc", 12 | "prepublishOnly": "npm run test && npm run build", 13 | "typedoc:build": "typedoc src", 14 | "typedoc:serve": "npm run docs:build && npx serve docs", 15 | "test": "npm-run-all --parallel test:*", 16 | "test:format": "prettier --check \"src/**/*.ts\"", 17 | "test:lint": "eslint src/ --ext .ts", 18 | "test:tsc": "tsc", 19 | "test:unit": "jest" 20 | }, 21 | "author": "moudy@repl.it", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "@types/jest": "^24.9.0", 25 | "@typescript-eslint/eslint-plugin": "^2.17.0", 26 | "@typescript-eslint/parser": "^2.17.0", 27 | "eslint": "^6.8.0", 28 | "eslint-config-airbnb-base": "^14.0.0", 29 | "eslint-config-prettier": "^6.9.0", 30 | "eslint-import-resolver-typescript": "^2.0.0", 31 | "eslint-plugin-import": "^2.20.0", 32 | "eslint-plugin-jest": "^23.6.0", 33 | "eslint-plugin-jsx-a11y": "^6.2.3", 34 | "eslint-plugin-prettier": "^3.1.2", 35 | "events": "^3.1.0", 36 | "html-colors": "0.0.6", 37 | "husky": "^4.2.0", 38 | "jest": "^24.9.0", 39 | "lint-staged": "^9.5.0", 40 | "npm-run-all": "^4.1.5", 41 | "prettier": "^1.19.1", 42 | "ts-jest": "^24.3.0", 43 | "typedoc": "^0.16.8", 44 | "typescript": "^3.7.5" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "lint-staged" 49 | } 50 | }, 51 | "lint-staged": { 52 | "*.{js,ts,tsx}": [ 53 | "eslint --fix", 54 | "prettier --write", 55 | "git add" 56 | ] 57 | }, 58 | "repository": "git@github.com:replit/clui.git" 59 | } 60 | -------------------------------------------------------------------------------- /packages/clui-input/README.md: -------------------------------------------------------------------------------- 1 | # CLUI Input 2 | 3 | `@replit/clui-input` implementes the logic for mapping text input to suggestions and a potential `run` function. 4 | 5 | ```jsx 6 | import input from '@replit/clui-input'; 7 | 8 | const rootCommand = { 9 | commands: { 10 | open: { 11 | commands: { 12 | sesame: { 13 | run: (args) => { 14 | /* do something */ 15 | }, 16 | }, 17 | }, 18 | }, 19 | }, 20 | }; 21 | 22 | const update = input({ 23 | command: rootCommand, 24 | onUpdate: (updates) => { 25 | /* Update #1: `updates.options` will be 26 | * [ 27 | * { 28 | * "value": "open", 29 | * "inputValue": "open", 30 | * "searchValue": "o", 31 | * "cursorTarget": 4 32 | * } 33 | * ] 34 | */ 35 | 36 | /* Update #2: `updates.options` will be 37 | * [ 38 | * { 39 | * "value": "sesame", 40 | * "inputValue": "open sesame", 41 | * "searchValue": "s", 42 | * "cursorTarget": 12 43 | * } 44 | * ] 45 | */ 46 | }, 47 | }); 48 | 49 | /* Update #1 */ 50 | update({ value: 'o', index: 1 }); 51 | 52 | /* Update #2 */ 53 | update({ value: 'open s', index: 6 }); 54 | ``` 55 | 56 | When the input matches a command with a `run` function, the `onUpdate` callback will include a reference to it. 57 | 58 | ```jsx 59 | const update = input({ 60 | command: rootCommand, 61 | onUpdate: (updates) => { 62 | // call or store reference to `updates.run` based on user interaction 63 | }, 64 | }); 65 | 66 | update({ value: 'open sesame', index: 6 }); 67 | ``` 68 | 69 | `@replit/clui-input` a framework agnostic primitive that can be wrapped by more specific framework or application code (like a react hook). If using react you will most likey want to keep the result of `onUpdate` in a state object. For managing dropdown selection UX I highly recommend [downshift](https://github.com/downshift-js/downshift). 70 | -------------------------------------------------------------------------------- /packages/clui-gql/src/toOperation.ts: -------------------------------------------------------------------------------- 1 | import { IntrospectionInputValue, IntrospectionField } from 'graphql'; 2 | import { OutputFn } from './types'; 3 | 4 | const toArgDec = (inputValue: IntrospectionInputValue) => { 5 | let parts: Array = []; 6 | const queue = [inputValue.type]; 7 | 8 | while (queue.length) { 9 | const n = queue.shift(); 10 | 11 | if (!n) { 12 | break; 13 | } 14 | 15 | const start = parts.splice(0, Math.floor(parts.length / 2)); 16 | 17 | if ('ofType' in n && n.ofType) { 18 | if (n.kind === 'NON_NULL') { 19 | parts = [...start, '', '!', ...parts]; 20 | } 21 | if (n.kind === 'LIST') { 22 | parts = [...start, '[', ']', ...parts]; 23 | } 24 | queue.push(n.ofType); 25 | } else if (n.kind === 'SCALAR' && n.name) { 26 | parts = [...start, n.name, ...parts]; 27 | } else if (n.kind === 'ENUM' && n.name) { 28 | parts = [...start, n.name, ...parts]; 29 | } 30 | } 31 | 32 | return `$${inputValue.name}: ${parts.join('')}`; 33 | }; 34 | 35 | const toOperation = ({ 36 | output, 37 | path, 38 | field, 39 | operation, 40 | }: { 41 | operation: 'query' | 'mutation'; 42 | path: Array; 43 | field: IntrospectionField; 44 | output: OutputFn; 45 | }) => { 46 | const args = field.args.map(toArgDec); 47 | const argVars = field.args.map((a) => `${a.name}: $${a.name}`); 48 | const operationArgs = args.length ? `(${args.join(', ')}) ` : ''; 49 | const operationArgVars = argVars.length ? `(${argVars.join(', ')})` : ''; 50 | 51 | const operationName = 52 | operation.slice(0, 1).toUpperCase() + operation.slice(1); 53 | 54 | const [last, ...first] = [...path].reverse(); 55 | const lines = [ 56 | `${operation} ${operationName}${operationArgs}{`, 57 | ...first.reverse().map((p) => `${p} {`), 58 | `${last}${operationArgVars} {`, 59 | ]; 60 | 61 | const { fields, fragments } = output({ path, field, operation }); 62 | lines.push(fields); 63 | lines.push(...[...Array(lines.length - 1)].map(() => '}')); 64 | 65 | if (fragments) { 66 | lines.push(fragments); 67 | } 68 | 69 | return lines.join('\n'); 70 | }; 71 | 72 | export default toOperation; 73 | -------------------------------------------------------------------------------- /packages/clui-input/src/types.ts: -------------------------------------------------------------------------------- 1 | // Command types 2 | export interface IOption { 3 | value: string; 4 | inputValue: string; 5 | searchValue?: string; 6 | cursorTarget: number; 7 | data?: D; 8 | } 9 | 10 | interface ISearchArgs { 11 | source: string; 12 | search: string; 13 | } 14 | 15 | export type SearchFn = (args: ISearchArgs) => boolean; 16 | 17 | export type ArgTypeDef = 'boolean' | 'string' | 'int' | 'float'; 18 | 19 | export interface IArgsOption { 20 | value: string; 21 | } 22 | 23 | type PValue = Promise | T; 24 | 25 | type ArgsOptionsFn = (str?: string) => PValue>; 26 | 27 | export interface IArg { 28 | options?: ArgsOptionsFn | Array; 29 | type?: ArgTypeDef; 30 | required?: true; 31 | data?: D; 32 | } 33 | 34 | export interface ICommands { 35 | [key: string]: C; 36 | } 37 | 38 | export interface ICommandArgs { 39 | [key: string]: IArg; 40 | } 41 | 42 | export type ArgType = string | boolean | number; 43 | export type ArgsMap = Record; 44 | 45 | export interface IRunOptions { 46 | commands: Array<{ name: string; args?: ArgsMap }>; 47 | args?: ArgsMap; 48 | options?: O; 49 | } 50 | 51 | type ThunkFn = (str?: string) => Promise; 52 | type Thunk = V | ThunkFn; 53 | 54 | type RunFn = (options: IRunOptions) => R; 55 | 56 | export interface ICommand { 57 | args?: ICommandArgs; 58 | commands?: Thunk>; 59 | options?: (search?: string) => Promise>; 60 | run?: RunFn; 61 | } 62 | 63 | export type SubCommands = Thunk>; 64 | 65 | // AST Types 66 | type NodeType = 67 | | 'COMMAND' 68 | | 'ARG_KEY' 69 | | 'ARG_VALUE' 70 | | 'ARG_VALUE_QUOTED' 71 | | 'WHITESPACE'; 72 | 73 | export interface IData { 74 | index: number; 75 | } 76 | 77 | export interface ILocation { 78 | start: number; 79 | end: number; 80 | } 81 | 82 | export interface INode extends ILocation { 83 | type: NodeType; 84 | value: string; 85 | } 86 | 87 | export interface IResult { 88 | isError: boolean; 89 | index: number; 90 | source: string; 91 | result: Array; 92 | } 93 | -------------------------------------------------------------------------------- /packages/clui-gql/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@replit/clui-gql", 3 | "version": "0.0.9", 4 | "private": true, 5 | "description": "A utility to transform GraphQL introspection type into a CLUI command", 6 | "scripts": { 7 | "build": "rm -rf dist && tsc && node ./scripts/postBuild.js", 8 | "codegen": "graphql-codegen --config codegen.yml", 9 | "prepublishOnly": "npm run test && npm run build", 10 | "typedoc:build": "typedoc src", 11 | "typedoc:serve": "npm run typedoc:build && npx serve docs", 12 | "test": "npm-run-all --parallel test:*", 13 | "test:format": "prettier --check \"src/**/*.{js,json,ts,tsx}\"", 14 | "test:lint": "eslint src/ --ext .js,.ts,.tsx", 15 | "test:tsc": "tsc", 16 | "test:unit": "jest" 17 | }, 18 | "author": "moudy@repl.it", 19 | "license": "MIT", 20 | "devDependencies": { 21 | "@graphql-codegen/cli": "^1.11.2", 22 | "@graphql-codegen/introspection": "1.11.2", 23 | "@graphql-codegen/typescript": "1.11.2", 24 | "@graphql-codegen/typescript-operations": "^1.11.2", 25 | "@types/jest": "^24.9.0", 26 | "@types/node": "^13.1.8", 27 | "@typescript-eslint/eslint-plugin": "^2.17.0", 28 | "@typescript-eslint/parser": "^2.17.0", 29 | "eslint": "^6.8.0", 30 | "eslint-config-airbnb-base": "^14.0.0", 31 | "eslint-config-prettier": "^6.9.0", 32 | "eslint-import-resolver-typescript": "^2.0.0", 33 | "eslint-plugin-import": "^2.20.0", 34 | "eslint-plugin-jest": "^23.6.0", 35 | "eslint-plugin-jsx-a11y": "^6.2.3", 36 | "eslint-plugin-prettier": "^3.1.2", 37 | "graphql": "^14.5.8", 38 | "graphql-tag": "^2.10.1", 39 | "husky": "^4.0.10", 40 | "jest": "^24.9.0", 41 | "lint-staged": "^10.0.1", 42 | "npm-run-all": "^4.1.5", 43 | "prettier": "^1.19.1", 44 | "ts-jest": "^24.3.0", 45 | "typedoc": "^0.16.7", 46 | "typescript": "^3.7.5" 47 | }, 48 | "peerDependencies": { 49 | "@replit/clui-input": "^0.0.18", 50 | "graphql": "^14.5.8" 51 | }, 52 | "husky": { 53 | "hooks": { 54 | "pre-commit": "lint-staged" 55 | } 56 | }, 57 | "lint-staged": { 58 | "*.{js,ts,tsx}": [ 59 | "eslint --fix", 60 | "prettier --write", 61 | "git add" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/clui-gql/src/parseArgs.ts: -------------------------------------------------------------------------------- 1 | import { PromptArgs, IGQLCommand, IGQLCommandArg } from './types'; 2 | 3 | const parseValue = (options: { value: string; arg: IGQLCommandArg }) => { 4 | if (options.arg.graphql.kind === 'ENUM') { 5 | return options.value; 6 | } 7 | 8 | switch (options.arg.type) { 9 | case 'string': 10 | return options.value; 11 | case 'int': 12 | return parseInt(options.value, 10); 13 | case 'float': 14 | return parseFloat(options.value); 15 | case 'boolean': 16 | return !!options.value; 17 | default: 18 | return undefined; 19 | } 20 | }; 21 | 22 | // Takes a map to strings to string | boolean and casts it's values 23 | // to match the types defined by the graphql field arguments. Also 24 | // returns any missing and extra values 25 | const parseArgs = (options: { 26 | args: PromptArgs; 27 | command: IGQLCommand; 28 | }): { 29 | variables: PromptArgs; 30 | extra?: PromptArgs; 31 | missing: { 32 | required?: Array; 33 | optional?: Array; 34 | }; 35 | } => { 36 | const required = []; 37 | const optional = []; 38 | const variables: PromptArgs = {}; 39 | 40 | const extra = { ...options.args }; 41 | 42 | if (!options.command.args) { 43 | return { 44 | variables, 45 | missing: {}, 46 | extra: Object.keys(extra).length ? extra : undefined, 47 | }; 48 | } 49 | 50 | for (const arg of Object.values(options.command.args)) { 51 | const value = options.args[arg.name]; 52 | delete extra[arg.name]; 53 | 54 | if (value === undefined) { 55 | if (arg.required) { 56 | required.push(arg); 57 | } else { 58 | optional.push(arg); 59 | } 60 | } else if (arg.type === 'boolean' && typeof value === 'boolean') { 61 | variables[arg.name] = value; 62 | } else { 63 | const val = parseValue({ value: value.toString(), arg }); 64 | 65 | if (typeof val !== 'undefined') { 66 | variables[arg.name] = val; 67 | } 68 | } 69 | } 70 | 71 | const missing = { 72 | ...(required.length ? { required } : {}), 73 | ...(optional.length ? { optional } : {}), 74 | }; 75 | 76 | return { 77 | variables, 78 | missing, 79 | extra: Object.keys(extra).length ? extra : undefined, 80 | }; 81 | }; 82 | 83 | export default parseArgs; 84 | -------------------------------------------------------------------------------- /packages/clui-session/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@replit/clui-session", 3 | "version": "0.0.1", 4 | "description": "A utility library to manage a list of react components", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "author": "moudy@repl.it", 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "rm -rf dist && tsc", 13 | "prepublishOnly": "npm run test && npm run build", 14 | "typedoc:build": "typedoc src", 15 | "typedoc:serve": "npm run docs:build && npx serve docs", 16 | "test": "npm-run-all --parallel test:*", 17 | "test:format": "prettier --check \"src/**/*.{ts,tsx}\"", 18 | "test:lint": "eslint src/ --ext .js,.ts,.tsx", 19 | "test:tsc": "tsc", 20 | "test:unit": "jest" 21 | }, 22 | "devDependencies": { 23 | "@types/enzyme": "^3.10.4", 24 | "@types/enzyme-adapter-react-16": "^1.0.5", 25 | "@types/jest": "^24.9.0", 26 | "@types/react-dom": "^16.9.5", 27 | "@types/react-helmet": "^5.0.15", 28 | "@types/styled-jsx": "^2.2.8", 29 | "@typescript-eslint/eslint-plugin": "^2.17.0", 30 | "@typescript-eslint/parser": "^2.17.0", 31 | "downshift": "^4.0.7", 32 | "enzyme": "^3.11.0", 33 | "enzyme-adapter-react-16": "^1.15.2", 34 | "enzyme-to-json": "^3.4.3", 35 | "eslint": "^6.8.0", 36 | "eslint-config-airbnb": "^18.0.1", 37 | "eslint-config-prettier": "^6.9.0", 38 | "eslint-import-resolver-typescript": "^2.0.0", 39 | "eslint-plugin-import": "^2.20.0", 40 | "eslint-plugin-jest": "^23.6.0", 41 | "eslint-plugin-jsx-a11y": "^6.2.3", 42 | "eslint-plugin-prettier": "^3.1.2", 43 | "eslint-plugin-react": "^7.18.0", 44 | "events": "^3.1.0", 45 | "html-colors": "0.0.6", 46 | "husky": "^4.2.0", 47 | "jest": "^24.9.0", 48 | "lint-staged": "^9.5.0", 49 | "npm-run-all": "^4.1.5", 50 | "prettier": "^1.19.1", 51 | "react": "^16.12.0", 52 | "react-dom": "^16.12.0", 53 | "react-head": "^3.3.0", 54 | "react-helmet": "^5.2.1", 55 | "styled-jsx": "^3.2.4", 56 | "ts-jest": "^24.3.0", 57 | "typedoc": "^0.16.8", 58 | "typescript": "^3.7.5" 59 | }, 60 | "peerDependencies": { 61 | "react": ">=16", 62 | "react-dom": ">=16" 63 | }, 64 | "husky": { 65 | "hooks": { 66 | "pre-commit": "lint-staged" 67 | } 68 | }, 69 | "lint-staged": { 70 | "*.{js,ts,tsx}": [ 71 | "eslint --fix", 72 | "prettier --write", 73 | "git add" 74 | ] 75 | }, 76 | "repository": "git@github.com:replit/clui.git" 77 | } 78 | -------------------------------------------------------------------------------- /packages/clui-input/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'airbnb-base', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'prettier/@typescript-eslint', 7 | ], 8 | plugins: ['@typescript-eslint', 'prettier'], 9 | settings: { 10 | 'import/parsers': { 11 | '@typescript-eslint/parser': ['.ts'], 12 | }, 13 | 'import/resolver': { 14 | typescript: {}, 15 | }, 16 | }, 17 | rules: { 18 | 'react/destructuring-assignment': 'off', 19 | 'react/jsx-props-no-spreading': 'off', 20 | 'import/no-extraneous-dependencies': [ 21 | 'error', 22 | { devDependencies: false, peerDependencies: true }, 23 | ], 24 | 'import/prefer-default-export': 'off', 25 | 'import/extensions': [ 26 | 'error', 27 | { 28 | ts: 'never', 29 | }, 30 | ], 31 | indent: 'off', 32 | 'max-len': ['error', { code: 120 }], 33 | '@typescript-eslint/explicit-function-return-type': 'off', 34 | '@typescript-eslint/no-non-null-assertion': 'error', 35 | '@typescript-eslint/no-explicit-any': 'off', 36 | '@typescript-eslint/no-use-before-define': [ 37 | 'error', 38 | { functions: false, classes: false }, 39 | ], 40 | '@typescript-eslint/interface-name-prefix': [ 41 | 'error', 42 | { prefixWithI: 'always' }, 43 | ], 44 | '@typescript-eslint/no-unused-vars': [ 45 | 'error', 46 | { 47 | varsIgnorePattern: '^_', 48 | argsIgnorePattern: '^_', 49 | caughtErrorsIgnorePattern: '^ignore', 50 | }, 51 | ], 52 | 'operator-linebreak': 'off', 53 | 'no-param-reassign': ['error', { props: false }], 54 | 'object-curly-newline': 'off', 55 | 'no-plusplus': 'off', 56 | 'newline-before-return': 'error', 57 | 'no-restricted-syntax': 'off', 58 | 'implicit-arrow-linebreak': 'off', 59 | 'function-paren-newline': 'off', 60 | }, 61 | globals: { 62 | BigInt: true, 63 | }, 64 | overrides: [ 65 | { 66 | files: ['**/__tests__/**/*.ts'], 67 | env: { 68 | 'jest/globals': true, 69 | }, 70 | plugins: ['jest'], 71 | rules: { 72 | '@typescript-eslint/ban-ts-ignore': 'off', 73 | 'import/no-extraneous-dependencies': [ 74 | 'error', 75 | { devDependencies: true, peerDependencies: true }, 76 | ], 77 | 'jest/no-disabled-tests': 'warn', 78 | 'jest/no-focused-tests': 'error', 79 | 'jest/no-identical-title': 'error', 80 | 'jest/prefer-to-have-length': 'warn', 81 | 'jest/valid-expect': 'error', 82 | }, 83 | }, 84 | ], 85 | }; 86 | -------------------------------------------------------------------------------- /packages/clui-session/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'airbnb', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'prettier/@typescript-eslint', 7 | 'prettier/react', 8 | ], 9 | plugins: ['@typescript-eslint', 'prettier'], 10 | settings: { 11 | 'import/parsers': { 12 | '@typescript-eslint/parser': ['.ts'], 13 | }, 14 | 'import/resolver': { 15 | typescript: {}, 16 | }, 17 | }, 18 | rules: { 19 | 'react/jsx-filename-extension': [1, { extensions: ['.tsx'] }], 20 | 'react/destructuring-assignment': 'off', 21 | 'react/jsx-props-no-spreading': 'off', 22 | 'import/no-extraneous-dependencies': [ 23 | 'error', 24 | { devDependencies: false, peerDependencies: true }, 25 | ], 26 | 'import/prefer-default-export': 'off', 27 | 'import/extensions': [ 28 | 'error', 29 | { 30 | ts: 'never', 31 | }, 32 | ], 33 | indent: 'off', 34 | 'max-len': ['error', { code: 120 }], 35 | '@typescript-eslint/explicit-function-return-type': 'off', 36 | '@typescript-eslint/no-non-null-assertion': 'error', 37 | '@typescript-eslint/no-explicit-any': 'off', 38 | '@typescript-eslint/no-use-before-define': ['error', { functions: false, classes: false }], 39 | '@typescript-eslint/interface-name-prefix': ['error', { prefixWithI: 'always' }], 40 | '@typescript-eslint/no-unused-vars': [ 41 | 'error', 42 | { 43 | varsIgnorePattern: '^_', 44 | argsIgnorePattern: '^_', 45 | caughtErrorsIgnorePattern: '^ignore', 46 | }, 47 | ], 48 | 'operator-linebreak': 'off', 49 | 'no-param-reassign': ['error', { props: false }], 50 | 'object-curly-newline': 'off', 51 | 'no-plusplus': 'off', 52 | 'newline-before-return': 'error', 53 | 'no-restricted-syntax': 'off', 54 | 'implicit-arrow-linebreak': 'off', 55 | 'function-paren-newline': 'off', 56 | }, 57 | globals: { 58 | BigInt: true, 59 | }, 60 | overrides: [ 61 | { 62 | files: ['**/__tests__/**/*.+(ts|tsx|js)'], 63 | env: { 64 | 'jest/globals': true, 65 | }, 66 | plugins: ['jest'], 67 | rules: { 68 | '@typescript-eslint/ban-ts-ignore': 'off', 69 | 'import/no-extraneous-dependencies': [ 70 | 'error', 71 | { devDependencies: true, peerDependencies: true }, 72 | ], 73 | 'jest/no-disabled-tests': 'warn', 74 | 'jest/no-focused-tests': 'error', 75 | 'jest/no-identical-title': 'error', 76 | 'jest/prefer-to-have-length': 'warn', 77 | 'jest/valid-expect': 'error', 78 | }, 79 | }, 80 | ], 81 | }; 82 | -------------------------------------------------------------------------------- /packages/clui-gql/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignorePatterns: ['src/graphqlTypes.ts', 'src/__tests__/generated/schema.ts'], 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'airbnb-base', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'prettier/@typescript-eslint', 8 | ], 9 | plugins: ['@typescript-eslint', 'prettier'], 10 | settings: { 11 | 'import/parsers': { 12 | '@typescript-eslint/parser': ['.ts'], 13 | }, 14 | 'import/resolver': { 15 | typescript: {}, 16 | }, 17 | }, 18 | rules: { 19 | 'react/destructuring-assignment': 'off', 20 | 'import/no-extraneous-dependencies': [ 21 | 'error', 22 | { devDependencies: false, peerDependencies: true }, 23 | ], 24 | 'import/prefer-default-export': 'off', 25 | 'import/extensions': [ 26 | 'error', 27 | { 28 | ts: 'never', 29 | }, 30 | ], 31 | indent: 'off', 32 | 'max-len': ['error', { code: 120 }], 33 | '@typescript-eslint/explicit-function-return-type': 'off', 34 | '@typescript-eslint/no-non-null-assertion': 'error', 35 | '@typescript-eslint/no-explicit-any': 'off', 36 | '@typescript-eslint/no-use-before-define': [ 37 | 'error', 38 | { functions: false, classes: false }, 39 | ], 40 | '@typescript-eslint/interface-name-prefix': [ 41 | 'error', 42 | { prefixWithI: 'always' }, 43 | ], 44 | '@typescript-eslint/no-unused-vars': [ 45 | 'error', 46 | { 47 | varsIgnorePattern: '^_', 48 | argsIgnorePattern: '^_', 49 | caughtErrorsIgnorePattern: '^ignore', 50 | }, 51 | ], 52 | 'operator-linebreak': 'off', 53 | 'no-param-reassign': ['error', { props: false }], 54 | 'object-curly-newline': 'off', 55 | 'no-plusplus': 'off', 56 | 'newline-before-return': 'error', 57 | 'no-restricted-syntax': 'off', 58 | 'implicit-arrow-linebreak': 'off', 59 | 'function-paren-newline': 'off', 60 | 'no-underscore-dangle': 'off', 61 | }, 62 | globals: { 63 | BigInt: true, 64 | }, 65 | overrides: [ 66 | { 67 | files: ['**/__tests__/**/*.+(ts|tsx|js)'], 68 | env: { 69 | 'jest/globals': true, 70 | }, 71 | plugins: ['jest'], 72 | rules: { 73 | '@typescript-eslint/ban-ts-ignore': 'off', 74 | 'import/no-extraneous-dependencies': [ 75 | 'error', 76 | { devDependencies: true, peerDependencies: true }, 77 | ], 78 | 'jest/no-disabled-tests': 'warn', 79 | 'jest/no-focused-tests': 'error', 80 | 'jest/no-identical-title': 'error', 81 | 'jest/prefer-to-have-length': 'warn', 82 | 'jest/valid-expect': 'error', 83 | }, 84 | }, 85 | ], 86 | }; 87 | -------------------------------------------------------------------------------- /packages/clui-gql/src/__tests__/parseArgs.test.ts: -------------------------------------------------------------------------------- 1 | import parseArgs from '../parseArgs'; 2 | import { IGQLCommand } from '../types'; 3 | 4 | const root = { outputType: '', path: [] }; 5 | 6 | test('parseArgs', () => { 7 | const command: IGQLCommand = { 8 | outputType: '', 9 | path: [], 10 | args: { 11 | role: { 12 | name: 'role', 13 | type: 'string', 14 | graphql: { kind: 'SCALAR', list: false }, 15 | }, 16 | 'unread-count': { 17 | name: 'unreadCount', 18 | type: 'int', 19 | graphql: { kind: 'SCALAR', list: false }, 20 | }, 21 | amount: { 22 | name: 'amount', 23 | type: 'float', 24 | graphql: { kind: 'SCALAR', list: false }, 25 | }, 26 | force: { 27 | name: 'force', 28 | type: 'boolean', 29 | graphql: { kind: 'SCALAR', list: false }, 30 | }, 31 | info: { 32 | name: 'info', 33 | type: 'boolean', 34 | graphql: { kind: 'SCALAR', list: false }, 35 | }, 36 | time: { 37 | name: 'time', 38 | type: 'float', 39 | graphql: { kind: 'SCALAR', list: false }, 40 | }, 41 | output: { 42 | name: 'output', 43 | type: 'string', 44 | graphql: { kind: 'SCALAR', list: false }, 45 | }, 46 | format: { 47 | name: 'format', 48 | type: 'string', 49 | required: true, 50 | graphql: { kind: 'SCALAR', list: false }, 51 | }, 52 | }, 53 | }; 54 | 55 | const parsed = parseArgs({ 56 | args: { 57 | role: 'admin', 58 | unreadCount: '2', 59 | amount: 0.2, 60 | force: true, 61 | info: 0, 62 | time: '0.1', 63 | }, 64 | command, 65 | }); 66 | 67 | expect(parsed).toEqual({ 68 | variables: { 69 | role: 'admin', 70 | unreadCount: 2, 71 | amount: 0.2, 72 | force: true, 73 | info: true, 74 | time: 0.1, 75 | }, 76 | missing: { 77 | optional: [ 78 | { 79 | name: 'output', 80 | type: 'string', 81 | graphql: { kind: 'SCALAR', list: false }, 82 | }, 83 | ], 84 | required: [ 85 | { 86 | name: 'format', 87 | type: 'string', 88 | required: true, 89 | graphql: { kind: 'SCALAR', list: false }, 90 | }, 91 | ], 92 | }, 93 | }); 94 | }); 95 | 96 | test('no command args', () => { 97 | const parsed = parseArgs({ args: { name: 'foo' }, command: root }); 98 | 99 | expect(parsed).toEqual({ 100 | variables: {}, 101 | missing: {}, 102 | extra: { name: 'foo' }, 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/item.insertAfter.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('session.insertAfter()', () => { 7 | it('inserts after item', () => { 8 | const wrapper = mount( 9 | 10 | 11 | , 12 | ); 13 | 14 | let el = wrapper.find('.a'); 15 | let item = el.prop('item') as ISessionItem; 16 | 17 | expect(el).toHaveLength(1); 18 | expect(item.session.currentIndex).toEqual(0); 19 | expect(item.session).toHaveLength(1); 20 | expect(wrapper.find('.b')).toHaveLength(0); 21 | 22 | act(() => { 23 | item.insertAfter(); 24 | }); 25 | wrapper.update(); 26 | 27 | el = wrapper.find('.a'); 28 | item = el.prop('item') as ISessionItem; 29 | expect(item.session.currentIndex).toEqual(0); 30 | expect(item.session).toHaveLength(2); 31 | }); 32 | 33 | it('inserts after item and advances if next() is called', () => { 34 | const wrapper = mount( 35 | 36 | 37 | , 38 | ); 39 | 40 | let el = wrapper.find('.a'); 41 | let item = el.prop('item') as ISessionItem; 42 | 43 | expect(el).toHaveLength(1); 44 | expect(item.session.currentIndex).toEqual(0); 45 | expect(item.session).toHaveLength(1); 46 | expect(wrapper.find('.b')).toHaveLength(0); 47 | 48 | act(() => { 49 | item.insertAfter(); 50 | item.next(); 51 | }); 52 | wrapper.update(); 53 | 54 | el = wrapper.find('.a'); 55 | item = el.prop('item') as ISessionItem; 56 | expect(item.session.currentIndex).toEqual(1); 57 | expect(item.session).toHaveLength(2); 58 | expect(wrapper.find('.b')).toHaveLength(1); 59 | }); 60 | 61 | it('inserts after non-active item and advances currentIndex', () => { 62 | const wrapper = mount( 63 | 64 | 65 | 66 | , 67 | ); 68 | 69 | let item = wrapper.find('.a').prop('item') as ISessionItem; 70 | expect(item.session.currentIndex).toEqual(1); 71 | expect(item.session).toHaveLength(2); 72 | 73 | act(() => { 74 | item.insertAfter(); 75 | }); 76 | wrapper.update(); 77 | item = wrapper.find('.a').prop('item') as ISessionItem; 78 | 79 | expect(item.session).toHaveLength(3); 80 | expect(item.session.currentIndex).toEqual(2); 81 | expect(wrapper.find('.a')).toHaveLength(1); 82 | expect(wrapper.find('.b')).toHaveLength(1); 83 | expect(wrapper.find('.c')).toHaveLength(1); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/session.reset.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('session.reset()', () => { 7 | it('resets to first item after next is called', () => { 8 | const wrapper = mount( 9 | 10 | 11 | 12 | , 13 | ); 14 | 15 | act(() => { 16 | (wrapper.find('.a').prop('item') as ISessionItem).next(); 17 | }); 18 | wrapper.update(); 19 | expect(wrapper.find('.b')).toHaveLength(1); 20 | 21 | act(() => { 22 | (wrapper.find('.a').prop('item') as ISessionItem).session.reset(); 23 | }); 24 | wrapper.update(); 25 | 26 | const item = wrapper.find('.a').prop('item') as ISessionItem; 27 | expect(wrapper.find('.b')).toHaveLength(0); 28 | expect(item.session.currentIndex).toEqual(0); 29 | }); 30 | 31 | it('resets to first item after inserting element', () => { 32 | const wrapper = mount( 33 | 34 | 35 | , 36 | ); 37 | 38 | act(() => { 39 | (wrapper.find('.a').prop('item') as ISessionItem) 40 | .insertAfter() 41 | .next(); 42 | }); 43 | wrapper.update(); 44 | expect(wrapper.find('.b')).toHaveLength(1); 45 | 46 | act(() => { 47 | (wrapper.find('.a').prop('item') as ISessionItem).session.reset(); 48 | }); 49 | wrapper.update(); 50 | 51 | const item = wrapper.find('.a').prop('item') as ISessionItem; 52 | expect(wrapper.find('.b')).toHaveLength(0); 53 | expect(item.session.currentIndex).toEqual(0); 54 | }); 55 | 56 | it('resets first item state', () => { 57 | const wrapper = mount( 58 | 59 | 60 | , 61 | ); 62 | 63 | act(() => { 64 | wrapper.find('input').getDOMNode().value = 'updated'; 65 | 66 | (wrapper.find('input').prop('item') as ISessionItem) 67 | .insertAfter() 68 | .next(); 69 | }); 70 | wrapper.update(); 71 | expect(wrapper.find('.b')).toHaveLength(1); 72 | expect(wrapper.find('input').getDOMNode().value).toEqual( 73 | 'updated', 74 | ); 75 | 76 | act(() => { 77 | (wrapper.find('.a').prop('item') as ISessionItem).session.reset(); 78 | }); 79 | wrapper.update(); 80 | 81 | const item = wrapper.find('.a').prop('item') as ISessionItem; 82 | expect(wrapper.find('.b')).toHaveLength(0); 83 | expect(item.session.currentIndex).toEqual(0); 84 | expect(wrapper.find('input').getDOMNode().value).toEqual( 85 | '', 86 | ); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /packages/clui-gql/README.md: -------------------------------------------------------------------------------- 1 | # CLUI GraphQL 2 | 3 | `@replit/clui-gql` is a utility libray for building [CLUI](https://github.com/replit/clui) commands from [GraphQL introspection](https://graphql.org/learn/introspection) data. 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install @replit/clui-gql 9 | ``` 10 | 11 | ## Usage 12 | 13 | To create a tree of CLUI commands call `toCommand` and then call `forEach` to defined a run function for each command. 14 | 15 | ```jsx 16 | import { toCommand, forEach } from '@replit/clui-gql'; 17 | import { introspectionFromSchema } from 'graphql'; 18 | import schema from './your-graphql-schema'; 19 | 20 | // on server 21 | const introspection = introspectionFromSchema(schema); 22 | 23 | // on client 24 | const introspection = makeNetworkRequestForData(); 25 | 26 | // Create a command tree from graphql introspection data. This could be done on 27 | // the server or the client. 28 | const root = toCommand({ 29 | // 'query' or 'mutation' 30 | operation: 'query', 31 | 32 | // The name of the graphql type that has the fields that act as top level commands 33 | rootTypeName: 'CluiCommands' 34 | 35 | // the path at which the above type appears in the graph 36 | mountPath: ['cli', 'admin'], 37 | 38 | // GraphQL introspection data 39 | introspectionSchema: introspection.__schema, 40 | 41 | // Configure fields and fragments for the output of the GraphQL operation string 42 | output: () => ({ 43 | fields: '...Output', 44 | fragments: ` 45 | fragment Output on YourOutputTypes { 46 | ...on SuccessOutput { 47 | message 48 | } 49 | ...on ErrorOutput { 50 | error 51 | } 52 | }`, 53 | }), 54 | }); 55 | 56 | // Define some application specific behavior for when a command is `run` 57 | forEach(root, ({ command }) => { 58 | if (command.outputType !== 'YourOutputTypes') { 59 | // If command does not match an output type you may want do something differeny. 60 | By omitting the run function the command acts as a namespace for sub-commands. 61 | return; 62 | } 63 | 64 | command.run = (options) => { 65 | return 66 | } 67 | } 68 | ``` 69 | 70 | 'parseArgs' is a helper for working with args 71 | 72 | ```jsx 73 | import { parse } from 'graphql'; 74 | import { parseArgs } from '@replit/clui-gql'; 75 | 76 | const OutputView = (props) => { 77 | // CLIU command generated from graphql 78 | const { command } = props; 79 | 80 | // CLUI args 81 | const { args } = props.options; 82 | 83 | const parsed = parseArgs({ command, args }); 84 | 85 | // Handle state for submitting command based on parsed args 86 | 87 | if (parsed.missing.required) { 88 | return ; 89 | } 90 | 91 | if (parsed.missing.optional) { 92 | return ; 93 | } 94 | 95 | if (command.query) { 96 | graphQLClient.query(parse(command.query), { variables: parsed.variables }) 97 | } else if (command.mutation) { 98 | graphQLClient.mutate(parse(command.mutation), { variables: parsed.variables }) 99 | } 100 | 101 | // ...render UI to communicate above state 102 | } 103 | 104 | ``` 105 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/item.insertBefore.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('session.insertBefore()', () => { 7 | it('inserts before item', () => { 8 | const wrapper = mount( 9 | 10 | 11 | , 12 | ); 13 | 14 | let item = wrapper.find('.a').prop('item') as ISessionItem; 15 | 16 | expect(wrapper.childAt(0).prop('className')).toEqual('a'); 17 | expect(item.session.currentIndex).toEqual(0); 18 | expect(item.session).toHaveLength(1); 19 | expect(wrapper.find('.b')).toHaveLength(0); 20 | 21 | act(() => { 22 | item.insertBefore(); 23 | }); 24 | wrapper.update(); 25 | 26 | expect(wrapper.childAt(0).prop('className')).toEqual('b'); 27 | expect(wrapper.childAt(1).prop('className')).toEqual('a'); 28 | 29 | item = wrapper.find('.a').prop('item') as ISessionItem; 30 | expect(item.session.currentIndex).toEqual(1); 31 | expect(item.session).toHaveLength(2); 32 | }); 33 | 34 | it('inserts multiple before item', () => { 35 | const wrapper = mount( 36 | 37 | 38 | , 39 | ); 40 | 41 | let item = wrapper.find('.a').prop('item') as ISessionItem; 42 | 43 | expect(wrapper.childAt(0).prop('className')).toEqual('a'); 44 | expect(item.session.currentIndex).toEqual(0); 45 | expect(item.session).toHaveLength(1); 46 | expect(wrapper.find('.b')).toHaveLength(0); 47 | 48 | act(() => { 49 | item.insertBefore(, ); 50 | }); 51 | wrapper.update(); 52 | 53 | expect(wrapper.childAt(0).prop('className')).toEqual('b'); 54 | expect(wrapper.childAt(1).prop('className')).toEqual('c'); 55 | expect(wrapper.childAt(2).prop('className')).toEqual('a'); 56 | 57 | item = wrapper.find('.a').prop('item') as ISessionItem; 58 | expect(item.index).toEqual(2); 59 | expect(item.session.currentIndex).toEqual(2); 60 | expect(item.session).toHaveLength(3); 61 | }); 62 | 63 | it('inserts multiple before item and moves back 1 when previous is called', () => { 64 | const wrapper = mount( 65 | 66 | 67 | , 68 | ); 69 | 70 | let item = wrapper.find('.a').prop('item') as ISessionItem; 71 | 72 | expect(wrapper.childAt(0).prop('className')).toEqual('a'); 73 | expect(item.session.currentIndex).toEqual(0); 74 | expect(item.session).toHaveLength(1); 75 | expect(wrapper.find('.b')).toHaveLength(0); 76 | 77 | act(() => { 78 | item.insertBefore(, ); 79 | item.previous(); 80 | }); 81 | wrapper.update(); 82 | 83 | expect(wrapper.childAt(0).prop('className')).toEqual('b'); 84 | expect(wrapper.childAt(1).prop('className')).toEqual('c'); 85 | expect(wrapper.find('.a')).toHaveLength(0); 86 | 87 | item = wrapper.find('.b').prop('item') as ISessionItem; 88 | expect(item.index).toEqual(0); 89 | expect(item.session.currentIndex).toEqual(1); 90 | expect(item.session).toHaveLength(3); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /packages/clui-input/src/__tests__/tokenizer.test.ts: -------------------------------------------------------------------------------- 1 | import { tokenize, Tokens } from '../tokenizer'; 2 | 3 | ([ 4 | [ 5 | 'u', 6 | [ 7 | { 8 | kind: 'KEYWORD', 9 | value: 'u', 10 | start: 0, 11 | end: 'u'.length, 12 | }, 13 | ], 14 | ], 15 | [ 16 | 'user', 17 | [ 18 | { 19 | kind: 'KEYWORD', 20 | value: 'user', 21 | start: 0, 22 | end: 'user'.length, 23 | }, 24 | ], 25 | ], 26 | [ 27 | 'user ', 28 | [ 29 | { 30 | kind: 'KEYWORD', 31 | value: 'user', 32 | start: 0, 33 | end: 'user'.length, 34 | }, 35 | { 36 | kind: 'WHITESPACE', 37 | value: ' ', 38 | start: 'user'.length, 39 | end: 'user '.length, 40 | }, 41 | ], 42 | ], 43 | [ 44 | 'user --info', 45 | [ 46 | { 47 | kind: 'KEYWORD', 48 | value: 'user', 49 | start: 0, 50 | end: 'user'.length, 51 | }, 52 | { 53 | kind: 'WHITESPACE', 54 | value: ' ', 55 | start: 'user'.length, 56 | end: 'user '.length, 57 | }, 58 | { 59 | kind: 'KEYWORD', 60 | value: '--info', 61 | start: 'user '.length, 62 | end: 'user --info'.length, 63 | }, 64 | ], 65 | ], 66 | [ 67 | 'user --name "ABC DEF"', 68 | [ 69 | { 70 | kind: 'KEYWORD', 71 | value: 'user', 72 | start: 0, 73 | end: 'user'.length, 74 | }, 75 | { 76 | kind: 'WHITESPACE', 77 | value: ' ', 78 | start: 'user'.length, 79 | end: 'user '.length, 80 | }, 81 | { 82 | kind: 'KEYWORD', 83 | value: '--name', 84 | start: 'user '.length, 85 | end: 'user --name'.length, 86 | }, 87 | { 88 | kind: 'WHITESPACE', 89 | value: ' ', 90 | start: 'user --name'.length, 91 | end: 'user --name '.length, 92 | }, 93 | { 94 | kind: 'KEYWORD', 95 | value: '"ABC DEF"', 96 | start: 'user --name '.length, 97 | end: 'user --name "ABC DEF"'.length, 98 | }, 99 | ], 100 | ], 101 | [ 102 | '"ABC DEF"', 103 | [ 104 | { 105 | kind: 'KEYWORD', 106 | value: '"ABC DEF"', 107 | start: 0, 108 | end: '"ABC DEF"'.length, 109 | }, 110 | ], 111 | ], 112 | [ 113 | "AB'C DEF", 114 | [ 115 | { 116 | kind: 'KEYWORD', 117 | value: "AB'C DEF", 118 | start: 0, 119 | end: "AB'C DEF".length, 120 | }, 121 | ], 122 | ], 123 | [ 124 | 'AB"CDE F', 125 | [ 126 | { 127 | kind: 'KEYWORD', 128 | value: 'AB"CDE F', 129 | start: 0, 130 | end: 'AB"CDE F'.length, 131 | }, 132 | ], 133 | ], 134 | [ 135 | '"A \'BC\' F"', 136 | [ 137 | { 138 | kind: 'KEYWORD', 139 | value: '"A \'BC\' F"', 140 | start: 0, 141 | end: '"A \'BC\' F"'.length, 142 | }, 143 | ], 144 | ], 145 | [ 146 | ' ', 147 | [ 148 | { 149 | kind: 'WHITESPACE', 150 | value: ' ', 151 | start: 0, 152 | end: ' '.length, 153 | }, 154 | ], 155 | ], 156 | ['', []], 157 | ] as Array<[string, Tokens]>).forEach(([source, expected]) => { 158 | it(`tokenizes "${source}"`, () => { 159 | expect(tokenize(source)).toEqual(expected); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /packages/clui-session/README.md: -------------------------------------------------------------------------------- 1 | # CLUI Session 2 | 3 | A utility for manipulating a list of React children. 4 | 5 | When building a CLI-style interfaces this can be useful for adding and removing lines when the prompt is submitted. Each child receives an item prop that contains methods and properties related to navigating and transforming the list. By default only the first child is rendered. It's up to the child elements to call a method on `props.item` to update the list. The child elements can be insered dynamically, defined up-front or a mix of both. 6 | 7 | ## Basic Prompt/Output example 8 | 9 | Here's an exmaple that renders an input and a button. When the button is clicked, 2 components are added (not rendered) to the list by calling `item.insert(/* ... */).next()`. By chaining `next()` the next child is rendered, which is ``. By passing `value` to `Output` it can render something based on it. The only requirement for `Output` is to call `props.item.next()` at some point to show the next `Prompt`. This could be after fetching data, a user interaction, or right when the component mounts (`useEffect(() => props.item.next(), [])`. 10 | 11 | ```jsx 12 | import React, { useState } from 'react' 13 | import { render } from 'react-dom' 14 | import Session from '@replit/clui-session'; 15 | 16 | // Substitute for somehting more interesting! 17 | const useFetchData = (value) => { 18 | return value; 19 | } 20 | 21 | const Output = (props) => { 22 | // Do something interesting with prompt input value 23 | const data = useFetchData(props.value); 24 | 25 | useEffect(() => { 26 | if (data) { 27 | // After data has loaded, call `next` to show next child (which is another Prompt) 28 | props.item.next(); 29 | } 30 | }, [data]); 31 | 32 | if (!data) { 33 | return
Loading...
; 34 | } 35 | 36 | // Render output data 37 | return
output: ${data}
, 38 | } 39 | 40 | const Prompt = (props) => { 41 | const [value, setValue] = useState(''); 42 | 43 | const onClick = () => { 44 | props.item.insert( 45 | , 46 | , 47 | ).next(); 48 | } 49 | 50 | return ( 51 |
52 | setValue(e.target.value)}/> 53 | 54 |
; 55 | ); 56 | } 57 | 58 | render( 59 | 60 | 61 | , 62 | document.getElementById('root'), 63 | ); 64 | 65 | 66 | /* After typing "hello session" and clicking run on the output 67 | * the component tree would look like 68 | * 69 | * 70 | * 71 | * 72 | * 73 | * 74 | */ 75 | ``` 76 | 77 | ## Components 78 | 79 | ### 80 | 81 | `Step` is a utility component that automatically shows the next child by calling `item.next` when the component mounts 82 | 83 | ```jsx 84 | 85 |
step 1
86 |
step 2 (paused for 1 second)
87 |
step 3 (paused for 1 second)
88 |
step 4
89 |
90 | ``` 91 | 92 | ### 93 | 94 | `Do` is a utility component the gives you access to `item` inline as a [render prop](https://reactjs.org/docs/render-props.html). 95 | 96 | ```jsx 97 | 98 | 99 | {item => } 100 | 101 | 102 | {item => } 103 | 104 | 105 | {item => } 106 | 107 | 108 | ``` 109 | -------------------------------------------------------------------------------- /packages/clui-input/src/optionsList.ts: -------------------------------------------------------------------------------- 1 | import { ICommands, SearchFn, IOption, ICommandArgs } from './types'; 2 | 3 | export const commandOptions = (options: { 4 | commands: ICommands; 5 | inputValue: string; 6 | searchFn: SearchFn; 7 | search?: string; 8 | sliceStart?: number; 9 | sliceEnd?: number; 10 | }): Array => 11 | Object.keys(options.commands).reduce((acc: Array, key) => { 12 | if ( 13 | !options.search || 14 | options.searchFn({ source: key, search: options.search }) 15 | ) { 16 | const { sliceStart, sliceEnd, inputValue } = options; 17 | 18 | const newInputValueStart = 19 | inputValue && sliceStart !== undefined 20 | ? inputValue.slice(0, sliceStart) + key 21 | : key; 22 | const newInputValue = 23 | newInputValueStart + 24 | (inputValue && sliceEnd !== undefined 25 | ? inputValue.slice(sliceEnd) 26 | : ''); 27 | 28 | acc.push({ 29 | value: key, 30 | data: options.commands[key], 31 | inputValue: newInputValue, 32 | cursorTarget: newInputValueStart.length, 33 | searchValue: options.search, 34 | }); 35 | } 36 | 37 | return acc; 38 | }, []); 39 | 40 | export const argsOptions = (options: { 41 | args: ICommandArgs; 42 | inputValue: string; 43 | searchFn: SearchFn; 44 | search?: string; 45 | sliceStart?: number; 46 | sliceEnd?: number; 47 | exclude?: Array; 48 | }): Array => { 49 | const search = options.search 50 | ? options.search?.replace(/^-(-?)/, '') 51 | : undefined; 52 | 53 | return Object.keys(options.args).reduce((acc: Array, key) => { 54 | if (options.exclude && options.exclude.includes(key)) { 55 | return acc; 56 | } 57 | 58 | if (!search || options.searchFn({ source: key, search })) { 59 | const value = `--${key}`; 60 | const { sliceStart, sliceEnd, inputValue } = options; 61 | 62 | const newInputValueStart = 63 | inputValue && sliceStart !== undefined 64 | ? inputValue.slice(0, sliceStart) + value 65 | : value; 66 | const newInputValue = 67 | newInputValueStart + 68 | (inputValue && sliceEnd !== undefined 69 | ? inputValue.slice(sliceEnd) 70 | : ''); 71 | 72 | acc.push({ 73 | value, 74 | data: options.args[key], 75 | inputValue: newInputValue, 76 | cursorTarget: newInputValueStart.length, 77 | searchValue: search, 78 | }); 79 | } 80 | 81 | return acc; 82 | }, []); 83 | }; 84 | 85 | export const valueOptions = (options: { 86 | options: Array; 87 | inputValue: string; 88 | searchFn: SearchFn; 89 | search?: string; 90 | sliceStart?: number; 91 | sliceEnd?: number; 92 | }): Array => 93 | options.options.reduce((acc: Array, option) => { 94 | const key = option.value; 95 | if ( 96 | !options.search || 97 | options.searchFn({ source: key, search: options.search }) 98 | ) { 99 | const { sliceStart, sliceEnd, inputValue } = options; 100 | 101 | const newInputValueStart = 102 | inputValue && sliceStart !== undefined 103 | ? inputValue.slice(0, sliceStart) + key 104 | : key; 105 | const newInputValue = 106 | newInputValueStart + 107 | (inputValue && sliceEnd !== undefined 108 | ? inputValue.slice(sliceEnd) 109 | : ''); 110 | 111 | acc.push({ 112 | value: key, 113 | data: option, 114 | inputValue: newInputValue, 115 | cursorTarget: newInputValueStart.length, 116 | searchValue: options.search, 117 | }); 118 | } 119 | 120 | return acc; 121 | }, []); 122 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/session.length.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount, ReactWrapper } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | const testLenghtBeforeAndAfterUpdate = ( 7 | wrapper: ReactWrapper, 8 | item: ISessionItem, 9 | expected: number, 10 | ) => { 11 | expect(item.session).toHaveLength(expected); 12 | // Value should be the same after rendering 13 | wrapper.update(); 14 | expect(item.session).toHaveLength(expected); 15 | }; 16 | 17 | describe('session.length', () => { 18 | it('sets the length', () => { 19 | const wrapper = mount( 20 | 21 | 22 | 23 | 24 | , 25 | ); 26 | 27 | expect( 28 | (wrapper.find('.a').prop('item') as ISessionItem).session, 29 | ).toHaveLength(3); 30 | }); 31 | 32 | describe('item.insertAfter()', () => { 33 | it('updates length after inserting element', () => { 34 | const wrapper = mount( 35 | 36 | 37 | , 38 | ); 39 | 40 | const item = wrapper.find('.a').prop('item') as ISessionItem; 41 | 42 | expect(item.session).toHaveLength(1); 43 | act(() => { 44 | item.insertAfter(); 45 | }); 46 | 47 | testLenghtBeforeAndAfterUpdate(wrapper, item, 2); 48 | }); 49 | 50 | it('updates length after inserting many element', () => { 51 | const wrapper = mount( 52 | 53 | 54 | , 55 | ); 56 | 57 | const item = wrapper.find('.a').prop('item') as ISessionItem; 58 | 59 | expect(item.session).toHaveLength(1); 60 | act(() => { 61 | item.insertAfter( 62 | , 63 | , 64 | , 65 | ); 66 | }); 67 | 68 | testLenghtBeforeAndAfterUpdate(wrapper, item, 4); 69 | }); 70 | }); 71 | 72 | describe('item.insertBefore()', () => { 73 | it('updates after inserting', () => { 74 | const wrapper = mount( 75 | 76 | 77 | , 78 | ); 79 | 80 | const item = wrapper.find('.a').prop('item') as ISessionItem; 81 | 82 | expect(item.session).toHaveLength(1); 83 | act(() => { 84 | item.insertBefore(); 85 | }); 86 | 87 | testLenghtBeforeAndAfterUpdate(wrapper, item, 2); 88 | }); 89 | 90 | it('updates after inserting multiple', () => { 91 | const wrapper = mount( 92 | 93 | 94 | , 95 | ); 96 | 97 | const item = wrapper.find('.a').prop('item') as ISessionItem; 98 | 99 | expect(item.session).toHaveLength(1); 100 | act(() => { 101 | item.insertAfter( 102 | , 103 | , 104 | , 105 | ); 106 | }); 107 | 108 | testLenghtBeforeAndAfterUpdate(wrapper, item, 4); 109 | }); 110 | }); 111 | 112 | describe('item.remove()', () => { 113 | it('updates after removing', () => { 114 | const wrapper = mount( 115 | 116 | 117 | 118 | , 119 | ); 120 | 121 | const item = wrapper.find('.b').prop('item') as ISessionItem; 122 | 123 | expect(item.session).toHaveLength(2); 124 | act(() => { 125 | item.remove(); 126 | }); 127 | 128 | testLenghtBeforeAndAfterUpdate(wrapper, item, 1); 129 | }); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /packages/clui-input/src/__tests__/ast.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '../parser'; 2 | import { find, closestPrevious, IArgNode, toArgs, commandPath } from '../ast'; 3 | import { ICommand } from '../types'; 4 | 5 | const root: ICommand = { 6 | commands: { 7 | user: { 8 | commands: { 9 | add: { 10 | args: { 11 | name: {}, 12 | info: { 13 | type: 'boolean', 14 | }, 15 | }, 16 | }, 17 | }, 18 | }, 19 | }, 20 | }; 21 | 22 | describe('find', () => { 23 | const ast = parse('user add --info --name foo', root); 24 | it('finds command node', async () => { 25 | [0, 1, 2, 3].forEach((num) => { 26 | const node = find(ast, num); 27 | expect(node).toEqual(ast.command); 28 | }); 29 | 30 | expect(find(ast, 4)).toEqual(null); 31 | }); 32 | 33 | it('finds subcommand node', async () => { 34 | [5, 6, 7].forEach((num) => { 35 | const node = find(ast, num); 36 | expect(node).toEqual(ast.command?.command); 37 | }); 38 | 39 | expect(find(ast, 8)).toEqual(null); 40 | }); 41 | 42 | it('finds arg flag node', async () => { 43 | [9, 10, 11, 12, 13, 14].forEach((num) => { 44 | const node = find(ast, num); 45 | 46 | if (!ast.command?.command?.args) { 47 | throw Error('expected args'); 48 | } 49 | 50 | expect(node).toEqual(ast.command?.command?.args[0]); 51 | }); 52 | 53 | expect(find(ast, 15)).toEqual(null); 54 | }); 55 | 56 | it('finds arg key node', async () => { 57 | [16, 17, 18, 19, 20, 21].forEach((num) => { 58 | const node = find(ast, num); 59 | 60 | if (!ast.command?.command?.args) { 61 | throw Error('expected args'); 62 | } 63 | 64 | const arg = ast.command?.command?.args[1] as IArgNode; 65 | expect(node).toEqual(arg.key); 66 | }); 67 | 68 | expect(find(ast, 22)).toEqual(null); 69 | }); 70 | 71 | it('finds arg value node', async () => { 72 | [23, 24, 25].forEach((num) => { 73 | const node = find(ast, num); 74 | 75 | if (!ast.command?.command?.args) { 76 | throw Error('expected args'); 77 | } 78 | 79 | const arg = ast.command?.command?.args[1] as IArgNode; 80 | expect(node).toEqual(arg.value); 81 | }); 82 | 83 | expect(find(ast, 26)).toEqual(null); 84 | }); 85 | 86 | it('finds remainder node', async () => { 87 | const ast2 = parse('us', root); 88 | const node = find(ast2, 1); 89 | 90 | expect(node?.kind).toEqual('REMAINDER'); 91 | }); 92 | }); 93 | 94 | describe('commandPath', () => { 95 | it('finds command path', async () => { 96 | const ast = parse('user add --info', root); 97 | if (!ast.command) { 98 | throw Error('Expected command'); 99 | } 100 | const path = commandPath(ast.command); 101 | expect(path.map((p) => p.token.value)).toEqual(['user', 'add']); 102 | }); 103 | }); 104 | 105 | describe('closestPrevious', () => { 106 | const ast = parse('user add --info --name foo', root); 107 | 108 | it('finds closest previous command node', async () => { 109 | expect(closestPrevious(ast, 4)).toEqual(ast.command); 110 | }); 111 | }); 112 | 113 | describe('toArgs', () => { 114 | it('parses args', async () => { 115 | const command: ICommand = { 116 | commands: { 117 | user: { 118 | args: { 119 | name: {}, 120 | info: { type: 'boolean' }, 121 | id: { type: 'int' }, 122 | username: { type: 'string' }, 123 | }, 124 | }, 125 | }, 126 | }; 127 | 128 | const ast = parse('user --name "Foo Bar" --id 2 --username bar', command); 129 | 130 | if (!ast.command) { 131 | throw Error('expected command'); 132 | } 133 | 134 | expect(toArgs(ast.command).parsed).toEqual({ 135 | id: 2, 136 | name: 'Foo Bar', 137 | username: 'bar', 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /packages/clui-gql/src/__tests__/toCommand.test.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { introspection } from './util'; 3 | import toCommand from '../toCommand'; 4 | import { IGQLCommand } from '../types'; 5 | 6 | describe('toCommand', () => { 7 | let root: IGQLCommand | void; 8 | 9 | beforeAll(async () => { 10 | root = toCommand({ 11 | operation: 'query', 12 | mountPath: ['cli'], 13 | introspectionSchema: introspection.__schema, 14 | rootTypeName: 'Cli', 15 | output: () => ({ fields: 'name' }), 16 | }); 17 | }); 18 | 19 | it('converts child fields to nested commands', async () => { 20 | const { weather } = root.commands; 21 | expect(Object.keys(weather.commands)).toEqual(['config', 'services']); 22 | expect(Object.keys(weather.commands.services.commands)).toEqual(['name']); 23 | }); 24 | 25 | it('converts args', async () => { 26 | const { weather } = root.commands; 27 | [ 28 | [ 29 | 'zipcode', 30 | { 31 | name: 'zipcode', 32 | type: 'string', 33 | description: 'Zipcode', 34 | required: true, 35 | graphql: { kind: 'SCALAR', list: false }, 36 | }, 37 | ], 38 | [ 39 | 'view', 40 | { 41 | type: 'string', 42 | name: 'view', 43 | graphql: { kind: 'SCALAR', list: false }, 44 | }, 45 | ], 46 | [ 47 | 'count', 48 | { 49 | type: 'int', 50 | name: 'count', 51 | graphql: { kind: 'SCALAR', list: false }, 52 | }, 53 | ], 54 | [ 55 | 'time', 56 | { 57 | type: 'float', 58 | name: 'time', 59 | graphql: { kind: 'SCALAR', list: false }, 60 | }, 61 | ], 62 | [ 63 | 'tomorrow', 64 | { 65 | type: 'boolean', 66 | name: 'tomorrow', 67 | graphql: { kind: 'SCALAR', list: false }, 68 | }, 69 | ], 70 | [ 71 | 'today', 72 | { 73 | type: 'boolean', 74 | required: true, 75 | name: 'today', 76 | graphql: { kind: 'SCALAR', list: false }, 77 | }, 78 | ], 79 | [ 80 | 'days', 81 | { 82 | type: 'string', 83 | name: 'days', 84 | graphql: { kind: 'SCALAR', list: true }, 85 | }, 86 | ], 87 | [ 88 | 'hours', 89 | { 90 | type: 'int', 91 | name: 'hours', 92 | graphql: { kind: 'SCALAR', list: true }, 93 | }, 94 | ], 95 | [ 96 | 'minutes', 97 | { 98 | type: 'float', 99 | required: true, 100 | name: 'minutes', 101 | graphql: { kind: 'SCALAR', list: true }, 102 | }, 103 | ], 104 | [ 105 | 'status', 106 | { 107 | type: 'string', 108 | name: 'status', 109 | graphql: { kind: 'ENUM', list: false }, 110 | options: [{ value: 'ACTIVE' }, { value: 'INACTIVE' }], 111 | }, 112 | ], 113 | ].forEach(([key, arg]) => { 114 | expect(weather.args[key]).toEqual(arg); 115 | }); 116 | }); 117 | 118 | it('transforms command name', async () => { 119 | const command = toCommand({ 120 | operation: 'query', 121 | transform: { commandName: (str: string) => str.toUpperCase() }, 122 | mountPath: ['cli'], 123 | introspectionSchema: introspection.__schema, 124 | rootTypeName: 'Cli', 125 | output: () => ({ fields: 'name' }), 126 | }); 127 | expect(command.commands.STATUS).toBeTruthy(); 128 | expect(command.commands.WEATHER).toBeTruthy(); 129 | }); 130 | 131 | it('transforms arg name', async () => { 132 | const command = toCommand({ 133 | operation: 'query', 134 | rootTypeName: 'Cli', 135 | transform: { argName: (str: string) => str.toUpperCase() }, 136 | mountPath: ['cli'], 137 | introspectionSchema: introspection.__schema, 138 | output: () => ({ fields: 'name' }), 139 | }); 140 | expect(command.commands.weather.args.ZIPCODE).toBeTruthy(); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /packages/clui-session/src/reducer.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Manages the state for a `Session` 3 | */ 4 | import React from 'react'; 5 | 6 | type Nodes = Array; 7 | type Elements = Array; 8 | 9 | /** 10 | * The state for a `Session` 11 | */ 12 | export interface IState { 13 | /** 14 | * All child elements up to and including this index are rendered. 15 | */ 16 | currentIndex: number; 17 | 18 | /** 19 | * Conatains all nodes (either dynamically inserted elements or a mapping 20 | * the index of a passed in child. 21 | */ 22 | nodes: Nodes; 23 | 24 | /** 25 | * Key used internally to remount entire Session on reset 26 | */ 27 | sessionKey: number; 28 | } 29 | 30 | export type Action = 31 | | { 32 | type: 'SET_INDEX'; 33 | index: number; 34 | } 35 | | { 36 | type: 'NEXT'; 37 | source: number; 38 | } 39 | | { 40 | type: 'INSERT'; 41 | index: number; 42 | nodes: Elements; 43 | } 44 | | { 45 | type: 'INSERT_AFTER'; 46 | index: number; 47 | nodes: Elements; 48 | } 49 | | { 50 | type: 'INSERT_BEFORE'; 51 | index: number; 52 | nodes: Elements; 53 | } 54 | | { 55 | type: 'REMOVE'; 56 | index: number; 57 | } 58 | | { 59 | type: 'REPLACE'; 60 | index: number; 61 | node: React.ReactElement; 62 | } 63 | | { 64 | type: 'RESET'; 65 | nodes: Elements; 66 | }; 67 | 68 | const replace = (state: IState, index: number, node: React.ReactElement) => { 69 | const nodes = [...state.nodes]; 70 | nodes[index] = node; 71 | 72 | return { ...state, nodes }; 73 | }; 74 | 75 | const remove = (state: IState, index: number) => { 76 | const filterdNodes = state.nodes.filter((_, i) => i !== index); 77 | 78 | return { 79 | ...state, 80 | currentIndex: 81 | state.currentIndex > filterdNodes.length - 1 82 | ? filterdNodes.length - 1 83 | : state.currentIndex, 84 | nodes: filterdNodes, 85 | }; 86 | }; 87 | 88 | const reducer = (state: IState, action: Action) => { 89 | switch (action.type) { 90 | case 'REPLACE': 91 | return replace(state, action.index, action.node); 92 | case 'REMOVE': 93 | return remove(state, action.index); 94 | case 'NEXT': 95 | return { 96 | ...state, 97 | currentIndex: 98 | action.source === state.currentIndex 99 | ? Math.max(Math.min(action.source + 1, state.nodes.length - 1), 0) 100 | : state.currentIndex, 101 | }; 102 | case 'SET_INDEX': 103 | return { 104 | ...state, 105 | currentIndex: Math.max( 106 | Math.min(action.index, state.nodes.length - 1), 107 | 0, 108 | ), 109 | }; 110 | case 'INSERT': 111 | return { 112 | ...state, 113 | nodes: [ 114 | ...state.nodes.slice(0, action.index), 115 | ...action.nodes, 116 | ...state.nodes.slice(action.index), 117 | ], 118 | currentIndex: state.currentIndex + 1, 119 | }; 120 | case 'INSERT_BEFORE': 121 | return { 122 | ...state, 123 | currentIndex: state.currentIndex + action.nodes.length, 124 | nodes: [ 125 | ...state.nodes.slice(0, action.index), 126 | ...action.nodes, 127 | ...state.nodes.slice(action.index), 128 | ], 129 | }; 130 | case 'INSERT_AFTER': 131 | return { 132 | ...state, 133 | currentIndex: 134 | action.index < state.currentIndex 135 | ? state.currentIndex + action.nodes.length 136 | : state.currentIndex, 137 | nodes: [ 138 | ...state.nodes.slice(0, action.index + 1), 139 | ...action.nodes, 140 | ...state.nodes.slice(action.index + 1), 141 | ], 142 | }; 143 | case 'RESET': 144 | return { 145 | ...state, 146 | nodes: action.nodes, 147 | currentIndex: 0, 148 | sessionKey: Math.random(), 149 | }; 150 | default: 151 | return state; 152 | } 153 | }; 154 | 155 | export default reducer; 156 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/session.currentIndex.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('item.session.currentIndex', () => { 7 | describe('item.next()', () => { 8 | it('updates', () => { 9 | const wrapper = mount( 10 | 11 | 12 | 13 | 14 | , 15 | ); 16 | 17 | ['a'].forEach((s, index) => { 18 | const item = wrapper.find(`.${s}`).prop('item') as ISessionItem; 19 | expect(item.index).toEqual(index); 20 | expect(item.session.currentIndex).toEqual(0); 21 | }); 22 | 23 | let currentStep = wrapper.find('.a'); 24 | act(() => { 25 | (currentStep.prop('item') as ISessionItem).next(); 26 | }); 27 | wrapper.update(); 28 | 29 | ['a', 'b'].forEach((s, index) => { 30 | const item = wrapper.find(`.${s}`).prop('item') as ISessionItem; 31 | expect(item.index).toEqual(index); 32 | expect(item.session.currentIndex).toEqual(1); 33 | }); 34 | 35 | currentStep = wrapper.find('.b'); 36 | act(() => { 37 | (currentStep.prop('item') as ISessionItem).next(); 38 | }); 39 | 40 | wrapper.update(); 41 | 42 | ['a', 'b', 'c'].forEach((s, index) => { 43 | const item = wrapper.find(`.${s}`).prop('item') as ISessionItem; 44 | expect(item.index).toEqual(index); 45 | expect(item.session.currentIndex).toEqual(2); 46 | }); 47 | }); 48 | 49 | it('does not increase beyond list size', () => { 50 | const wrapper = mount( 51 | 52 | 53 | , 54 | ); 55 | 56 | [null, null].forEach(() => { 57 | act(() => { 58 | (wrapper.find('.a').prop('item') as ISessionItem).next(); 59 | }); 60 | }); 61 | 62 | expect( 63 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 64 | ).toEqual(0); 65 | 66 | wrapper.update(); 67 | 68 | expect( 69 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 70 | ).toEqual(0); 71 | }); 72 | }); 73 | 74 | describe('item.previous()', () => { 75 | it('updates', () => { 76 | const wrapper = mount( 77 | 78 | 79 | 80 | 81 | , 82 | ); 83 | 84 | ['a', 'b', 'c'].forEach((s, index) => { 85 | const item = wrapper.find(`.${s}`).prop('item') as ISessionItem; 86 | expect(item.index).toEqual(index); 87 | expect(item.session.currentIndex).toEqual(2); 88 | }); 89 | 90 | let currentStep = wrapper.find('.c'); 91 | act(() => { 92 | (currentStep.prop('item') as ISessionItem).previous(); 93 | }); 94 | wrapper.update(); 95 | 96 | ['a', 'b'].forEach((s, index) => { 97 | const item = wrapper.find(`.${s}`).prop('item') as ISessionItem; 98 | expect(item.index).toEqual(index); 99 | expect(item.session.currentIndex).toEqual(1); 100 | }); 101 | 102 | currentStep = wrapper.find('.b'); 103 | act(() => { 104 | (currentStep.prop('item') as ISessionItem).previous(); 105 | }); 106 | 107 | wrapper.update(); 108 | 109 | ['a'].forEach((s, index) => { 110 | const item = wrapper.find(`.${s}`).prop('item') as ISessionItem; 111 | expect(item.index).toEqual(index); 112 | expect(item.session.currentIndex).toEqual(0); 113 | }); 114 | }); 115 | 116 | it('does not go below 0', () => { 117 | const wrapper = mount( 118 | 119 | 120 | , 121 | ); 122 | 123 | [null, null].forEach(() => { 124 | act(() => { 125 | (wrapper.find('.a').prop('item') as ISessionItem).previous(); 126 | }); 127 | }); 128 | 129 | expect( 130 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 131 | ).toEqual(0); 132 | 133 | wrapper.update(); 134 | 135 | expect( 136 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 137 | ).toEqual(0); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /packages/clui-session/src/__tests__/item.next.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { act } from 'react-dom/test-utils'; 4 | import Session, { ISessionItem } from '../Session'; 5 | 6 | describe('item.next()', () => { 7 | it('shows next element', () => { 8 | const wrapper = mount( 9 | 10 | 11 | 12 | 13 | , 14 | ); 15 | 16 | let currentStep = wrapper.find('.a'); 17 | expect(currentStep).toHaveLength(1); 18 | expect(wrapper.find('.b')).toHaveLength(0); 19 | expect(wrapper.find('.c')).toHaveLength(0); 20 | 21 | act(() => { 22 | (currentStep.prop('item') as ISessionItem).next(); 23 | }); 24 | wrapper.update(); 25 | currentStep = wrapper.find('.b'); 26 | 27 | expect(wrapper.find('.a')).toHaveLength(1); 28 | expect(currentStep).toHaveLength(1); 29 | expect(wrapper.find('.c')).toHaveLength(0); 30 | 31 | act(() => { 32 | (currentStep.prop('item') as ISessionItem).next(); 33 | }); 34 | wrapper.update(); 35 | 36 | expect(wrapper.find('.c')).toHaveLength(1); 37 | }); 38 | 39 | it('keeps index less than or equal to total length', () => { 40 | const wrapper = mount( 41 | 42 | 43 | 44 | , 45 | ); 46 | 47 | expect( 48 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 49 | ).toEqual(0); 50 | 51 | act(() => { 52 | (wrapper.find('.a').prop('item') as ISessionItem).next(); 53 | }); 54 | wrapper.update(); 55 | 56 | expect( 57 | (wrapper.find('.b').prop('item') as ISessionItem).session.currentIndex, 58 | ).toEqual(1); 59 | 60 | act(() => { 61 | (wrapper.find('.b').prop('item') as ISessionItem).next(); 62 | }); 63 | wrapper.update(); 64 | 65 | expect( 66 | (wrapper.find('.b').prop('item') as ISessionItem).session.currentIndex, 67 | ).toEqual(1); 68 | }); 69 | 70 | it('calls onDone when at last child', () => { 71 | const onDone = jest.fn(); 72 | mount( 73 | 74 | 75 | , 76 | ); 77 | 78 | expect(onDone).toHaveBeenCalledTimes(1); 79 | }); 80 | 81 | it('calls next if nested within another ', () => { 82 | const wrapper = mount( 83 | 84 | 85 | 86 | 87 | 88 | , 89 | ); 90 | 91 | expect(wrapper.find('.a')).toHaveLength(1); 92 | expect(wrapper.find('.b')).toHaveLength(0); 93 | 94 | act(() => { 95 | (wrapper.find('.a').prop('item') as ISessionItem).next(); 96 | }); 97 | wrapper.update(); 98 | 99 | expect(wrapper.find('.a')).toHaveLength(1); 100 | expect(wrapper.find('.b')).toHaveLength(1); 101 | }); 102 | 103 | it('does not advance when called next multiple times from same element', () => { 104 | const wrapper = mount( 105 | 106 | 107 | 108 | 109 | , 110 | ); 111 | 112 | expect(wrapper.find('.a')).toHaveLength(1); 113 | expect( 114 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 115 | ).toEqual(0); 116 | expect(wrapper.find('.b')).toHaveLength(0); 117 | 118 | act(() => { 119 | (wrapper.find('.a').prop('item') as ISessionItem).next(); 120 | }); 121 | wrapper.update(); 122 | expect( 123 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 124 | ).toEqual(1); 125 | 126 | act(() => { 127 | (wrapper.find('.a').prop('item') as ISessionItem).next(); 128 | }); 129 | wrapper.update(); 130 | expect( 131 | (wrapper.find('.a').prop('item') as ISessionItem).session.currentIndex, 132 | ).toEqual(1); 133 | }); 134 | 135 | it('does not change currentIndex if next is called on non-active item', () => { 136 | const wrapper = mount( 137 | 138 | 139 | 140 | , 141 | ); 142 | 143 | let item = wrapper.find('.a').prop('item') as ISessionItem; 144 | expect(wrapper.find('.c')).toHaveLength(1); 145 | expect(item.session).toHaveLength(2); 146 | expect(item.session.currentIndex).toEqual(1); 147 | 148 | act(() => { 149 | item.insertAfter().next(); 150 | }); 151 | wrapper.update(); 152 | item = wrapper.find('.a').prop('item') as ISessionItem; 153 | 154 | expect(item.session).toHaveLength(3); 155 | expect(item.session.currentIndex).toEqual(2); 156 | expect(wrapper.find('.a')).toHaveLength(1); 157 | expect(wrapper.find('.b')).toHaveLength(1); 158 | expect(wrapper.find('.c')).toHaveLength(1); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /packages/clui-input/src/__tests__/input.options.test.ts: -------------------------------------------------------------------------------- 1 | import { ICommand } from '../types'; 2 | import { createInput } from '../input'; 3 | 4 | describe('root command options', () => { 5 | const root: ICommand = { 6 | options() { 7 | return Promise.resolve([{ value: 'ab' }]); 8 | }, 9 | }; 10 | 11 | it('suggests options', (done) => { 12 | createInput({ 13 | command: root, 14 | value: '', 15 | index: ''.length, 16 | onUpdate: (updates) => { 17 | expect(updates.options).toEqual([ 18 | { 19 | value: 'ab', 20 | data: { 21 | value: 'ab', 22 | }, 23 | inputValue: 'ab', 24 | cursorTarget: 'ab'.length, 25 | }, 26 | ]); 27 | done(); 28 | }, 29 | }); 30 | }); 31 | 32 | it('filters options', (done) => { 33 | createInput({ 34 | command: root, 35 | value: 'a', 36 | index: 'a'.length, 37 | onUpdate: (updates) => { 38 | expect(updates.options).toEqual([ 39 | { 40 | searchValue: 'a', 41 | value: 'ab', 42 | data: { 43 | value: 'ab', 44 | }, 45 | inputValue: 'ab', 46 | cursorTarget: 'ab'.length, 47 | }, 48 | ]); 49 | done(); 50 | }, 51 | }); 52 | }); 53 | }); 54 | 55 | describe('command options', () => { 56 | const options = [{ value: 'foo bar' }]; 57 | 58 | const search = { 59 | options: async (__search?: string) => Promise.resolve(options), 60 | commands: { 61 | foo: {}, 62 | }, 63 | }; 64 | 65 | const root: ICommand = { 66 | commands: { 67 | search, 68 | }, 69 | }; 70 | 71 | it('suggests commands and options without search', (done) => { 72 | createInput({ 73 | command: root, 74 | value: 'search ', 75 | index: 'search '.length, 76 | includeExactMatch: true, 77 | onUpdate: (updates) => { 78 | expect(updates.options).toEqual([ 79 | { 80 | value: 'foo bar', 81 | searchValue: undefined, 82 | data: { value: 'foo bar' }, 83 | inputValue: 'search foo bar', 84 | cursorTarget: 'search foo bar'.length, 85 | }, 86 | { 87 | value: 'foo', 88 | searchValue: undefined, 89 | data: {}, 90 | inputValue: 'search foo', 91 | cursorTarget: 'search foo'.length, 92 | }, 93 | ]); 94 | done(); 95 | }, 96 | }); 97 | }); 98 | 99 | it('suggests options', (done) => { 100 | createInput({ 101 | command: root, 102 | value: 'search foob', 103 | index: 'search foob'.length, 104 | includeExactMatch: true, 105 | onUpdate: (updates) => { 106 | expect(updates.options).toEqual([ 107 | { 108 | value: 'foo bar', 109 | searchValue: 'foob', 110 | data: { value: 'foo bar' }, 111 | inputValue: 'search foo bar', 112 | cursorTarget: 'search foo bar'.length, 113 | }, 114 | ]); 115 | done(); 116 | }, 117 | }); 118 | }); 119 | 120 | it('suggests commands and options', (done) => { 121 | createInput({ 122 | command: root, 123 | value: 'search fo', 124 | index: 'search fo'.length, 125 | includeExactMatch: true, 126 | onUpdate: (updates) => { 127 | expect(updates.options).toEqual([ 128 | { 129 | value: 'foo bar', 130 | searchValue: 'fo', 131 | data: { value: 'foo bar' }, 132 | inputValue: 'search foo bar', 133 | cursorTarget: 'search foo bar'.length, 134 | }, 135 | { 136 | value: 'foo', 137 | searchValue: 'fo', 138 | data: {}, 139 | inputValue: 'search foo', 140 | cursorTarget: 'search foo'.length, 141 | }, 142 | ]); 143 | done(); 144 | }, 145 | }); 146 | }); 147 | 148 | it('does not suggest options when a command is matched exactly', (done) => { 149 | createInput({ 150 | command: root, 151 | value: 'search', 152 | index: 'search'.length, 153 | includeExactMatch: true, 154 | onUpdate: (updates) => { 155 | expect(updates.options).toEqual([ 156 | { 157 | value: 'search ', 158 | data: search, 159 | inputValue: 'search ', 160 | cursorTarget: 'search '.length, 161 | }, 162 | ]); 163 | done(); 164 | }, 165 | }); 166 | }); 167 | 168 | it('does not suggest options after a command is matched', (done) => { 169 | createInput({ 170 | command: root, 171 | value: 'search foo b', 172 | index: 'search foo b'.length, 173 | includeExactMatch: true, 174 | onUpdate: (updates) => { 175 | expect(updates.options).toEqual([]); 176 | done(); 177 | }, 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /packages/clui-input/src/ast.ts: -------------------------------------------------------------------------------- 1 | import { IToken } from './tokenizer'; 2 | import { 3 | ICommand, 4 | ICommands, 5 | IArg, 6 | ArgsMap, 7 | ArgType, 8 | ArgTypeDef, 9 | } from './types'; 10 | 11 | export type ASTNodeKind = 12 | | 'COMMAND' 13 | | 'ARG' 14 | | 'ARG_FLAG' 15 | | 'ARG_KEY' 16 | | 'ARG_VALUE' 17 | | 'REMAINDER' 18 | | 'PENDING'; 19 | 20 | export interface IArgKeyNode { 21 | kind: 'ARG_KEY'; 22 | parent: IArgNode; 23 | token: IToken; 24 | name: string; 25 | } 26 | 27 | export interface IArgValueNode { 28 | kind: 'ARG_VALUE'; 29 | parent: IArgNode; 30 | token: IToken; 31 | } 32 | 33 | export interface IArgFlagNode { 34 | kind: 'ARG_FLAG'; 35 | ref: IArg; 36 | parent: ICmdNode; 37 | token: IToken; 38 | name: string; 39 | } 40 | 41 | export interface IArgNode { 42 | kind: 'ARG'; 43 | ref: IArg; 44 | parent: ICmdNode; 45 | key: IArgKeyNode; 46 | value?: IArgValueNode; 47 | } 48 | 49 | export interface ICmdNode { 50 | kind: 'COMMAND'; 51 | ref: ICommand; 52 | token: IToken; 53 | parent?: ICmdNode; 54 | command?: ICmdNode; 55 | args?: Array; 56 | } 57 | 58 | export interface IRemainder { 59 | kind: 'REMAINDER'; 60 | token: IToken; 61 | cmdNodeCtx?: ICmdNode; 62 | argNodeCtx?: IArgNode; 63 | } 64 | 65 | export interface IPending { 66 | kind: 'PENDING'; 67 | key: string; 68 | token: IToken; 69 | resolve: (str?: string) => Promise; 70 | } 71 | 72 | export interface IAst { 73 | source: string; 74 | command?: ICmdNode; 75 | remainder?: IRemainder; 76 | pending?: IPending; 77 | } 78 | 79 | export type ASTNode = 80 | | ICmdNode 81 | | IArgNode 82 | | IArgValueNode 83 | | IArgKeyNode 84 | | IArgFlagNode 85 | | IRemainder 86 | | IPending; 87 | 88 | export const find = (ast: IAst, index: number): ASTNode | null => { 89 | const queue: Array = []; 90 | if (ast.command) { 91 | queue.push(ast.command); 92 | } 93 | 94 | if (ast.remainder) { 95 | queue.push(ast.remainder); 96 | } 97 | 98 | if (ast.pending) { 99 | queue.push(ast.pending); 100 | } 101 | 102 | while (queue.length) { 103 | const node = queue.shift(); 104 | 105 | if (!node) { 106 | throw Error('Expected node'); 107 | } 108 | 109 | if (!('token' in node)) { 110 | throw Error('Expected token'); 111 | } 112 | 113 | if (index >= node.token.start && index < node.token.end) { 114 | return node; 115 | } 116 | 117 | if ('args' in node && node.args) { 118 | for (const arg of node.args) { 119 | if ('token' in arg) { 120 | queue.push(arg); 121 | } else { 122 | queue.push(arg.key); 123 | 124 | if (arg.value) { 125 | queue.push(arg.value); 126 | } 127 | } 128 | } 129 | } 130 | 131 | if ('command' in node && node.command) { 132 | queue.push(node.command); 133 | } 134 | } 135 | 136 | return null; 137 | }; 138 | 139 | export const closestPrevious = (ast: IAst, index: number): ASTNode | null => { 140 | let i = index; 141 | 142 | while (i > 0) { 143 | i--; 144 | const node = find(ast, i); 145 | 146 | if (node) { 147 | return node; 148 | } 149 | } 150 | 151 | return null; 152 | }; 153 | 154 | export const commandPath = (root: ICmdNode): Array => { 155 | const path = []; 156 | 157 | let node: ICmdNode | void = root; 158 | 159 | while (node) { 160 | path.push(node); 161 | node = node.command; 162 | } 163 | 164 | return path; 165 | }; 166 | 167 | const removeQuotes = (str: string) => { 168 | for (const quote of ["'", '"']) { 169 | if (str.startsWith(quote) && str.endsWith(quote)) { 170 | return str.slice(1, str.length - 1); 171 | } 172 | } 173 | 174 | return str; 175 | }; 176 | 177 | const parseValue = ({ value, type }: { value: string; type: ArgTypeDef }) => { 178 | switch (type) { 179 | case 'boolean': 180 | return !!value; 181 | case 'int': 182 | return parseInt(value, 10); 183 | case 'float': 184 | return parseFloat(value); 185 | default: 186 | return value; 187 | } 188 | }; 189 | 190 | export const toArgs = ( 191 | command: ICmdNode, 192 | ): { parsed?: ArgsMap; remaining?: Array; exhausted: boolean } => { 193 | const parsed: ArgsMap = {}; 194 | 195 | if (!command.ref.args) { 196 | return { exhausted: true }; 197 | } 198 | 199 | if (command.args) { 200 | for (const arg of command.args) { 201 | if (arg.kind === 'ARG_FLAG') { 202 | parsed[arg.name] = true; 203 | } else { 204 | const str = arg.value?.token.value; 205 | 206 | if (str) { 207 | const value: ArgType = arg.ref.type 208 | ? parseValue({ value: str, type: arg.ref.type }) 209 | : str; 210 | parsed[arg.key.name] = 211 | typeof value === 'string' ? removeQuotes(value) : value; 212 | } 213 | } 214 | } 215 | } 216 | 217 | const remaining: Array = []; 218 | 219 | for (const key of Object.keys(command.ref.args)) { 220 | if (!parsed[key]) { 221 | remaining.push(command.ref.args[key]); 222 | } 223 | } 224 | 225 | return { 226 | parsed: Object.keys(parsed).length ? parsed : undefined, 227 | remaining: remaining.length ? remaining : undefined, 228 | exhausted: !remaining.length, 229 | }; 230 | }; 231 | -------------------------------------------------------------------------------- /packages/clui-input/src/parser.ts: -------------------------------------------------------------------------------- 1 | import { tokenize } from './tokenizer'; 2 | import { ICommand, ICommands } from './types'; 3 | 4 | import { IArgFlagNode, IArgNode, ICmdNode, IAst } from './ast'; 5 | 6 | const flagPrefix = /^-(-?)/; 7 | 8 | export const parse = ( 9 | source: string, 10 | program: ICommand, 11 | cacheGet?: (key: string) => null | ICommands, 12 | ): IAst => { 13 | const tokens = tokenize(source).filter((t) => t.kind !== 'WHITESPACE'); 14 | 15 | const ast: IAst = { source }; 16 | let cmdNodeCtx: ICmdNode | null = null; 17 | let argNodeCtx: IArgNode | null = null; 18 | 19 | const queue = [...tokens]; 20 | let done = false; 21 | 22 | while (queue.length && !done) { 23 | const token = queue.shift(); 24 | 25 | if (!token) { 26 | throw new Error('Expected token'); 27 | } 28 | 29 | const isFlag = flagPrefix.test(token.value); 30 | const argKey = isFlag ? token.value.replace(/^-(-?)/, '') : null; 31 | 32 | if (!cmdNodeCtx) { 33 | // Try to resolve first command 34 | if ( 35 | typeof program.commands === 'object' && 36 | program.commands[token.value] 37 | ) { 38 | // Set initial command context 39 | cmdNodeCtx = { 40 | kind: 'COMMAND', 41 | ref: program.commands[token.value], 42 | token, 43 | }; 44 | 45 | ast.command = cmdNodeCtx; 46 | } else if (typeof program.commands === 'function') { 47 | const key = source.slice(0, token.end); 48 | const hit: ICommands | null = cacheGet ? cacheGet(key) : null; 49 | 50 | if (hit && hit[token.value]) { 51 | // Found match in cache 52 | const ref: ICommand = hit[token.value]; 53 | cmdNodeCtx = { ref, token, kind: 'COMMAND' }; 54 | ast.command = cmdNodeCtx; 55 | } else if (!hit) { 56 | // First command's commands function needs to be resolved 57 | ast.pending = { 58 | kind: 'PENDING', 59 | token, 60 | resolve: program.commands, 61 | key, 62 | }; 63 | done = true; 64 | } 65 | } else { 66 | // No match found for top-level command 67 | ast.remainder = { 68 | kind: 'REMAINDER', 69 | token, 70 | cmdNodeCtx: { ref: program, token, kind: 'COMMAND' }, 71 | }; 72 | // ast.remainder = { node }; 73 | } 74 | } else if (argNodeCtx) { 75 | // Set value for matching arg key 76 | argNodeCtx.value = { 77 | kind: 'ARG_VALUE', 78 | parent: argNodeCtx, 79 | token, 80 | }; 81 | // Unset arg context now that the value has been set 82 | argNodeCtx = null; 83 | } else if ( 84 | argKey && 85 | cmdNodeCtx && 86 | cmdNodeCtx.ref.args && 87 | cmdNodeCtx.ref.args[argKey] 88 | ) { 89 | // Found a matching arg key, setting context 90 | const argCtx = cmdNodeCtx.ref.args[argKey]; 91 | 92 | let argNode; 93 | if (argCtx.type === 'boolean') { 94 | argNode = { 95 | parent: cmdNodeCtx, 96 | ref: argCtx, 97 | kind: 'ARG_FLAG', 98 | token, 99 | name: token.value.replace(/^-(-?)/, ''), 100 | } as IArgFlagNode; 101 | } else { 102 | argNode = { 103 | parent: cmdNodeCtx, 104 | ref: argCtx, 105 | kind: 'ARG', 106 | } as IArgNode; 107 | argNode.key = { 108 | parent: argNode, 109 | token, 110 | kind: 'ARG_KEY', 111 | name: token.value.replace(/^-(-?)/, ''), 112 | }; 113 | 114 | // Set arg context since arg's value is a key/value pair (rather than flag) 115 | argNodeCtx = argNode; 116 | } 117 | 118 | if (cmdNodeCtx.args) { 119 | cmdNodeCtx.args.push(argNode); 120 | } else { 121 | cmdNodeCtx.args = [argNode]; 122 | } 123 | } else if ( 124 | cmdNodeCtx && 125 | typeof cmdNodeCtx.ref.commands === 'object' && 126 | cmdNodeCtx.ref.commands[token.value] 127 | ) { 128 | // Found matching subcommand, update context 129 | const ref = cmdNodeCtx.ref.commands[token.value]; 130 | cmdNodeCtx.command = { 131 | ref, 132 | token, 133 | parent: cmdNodeCtx, 134 | kind: 'COMMAND', 135 | }; 136 | cmdNodeCtx = cmdNodeCtx.command; 137 | } else if (cmdNodeCtx && typeof cmdNodeCtx.ref.commands === 'function') { 138 | const key = source.slice(0, token.end); 139 | const hit: ICommands | null = cacheGet ? cacheGet(key) : null; 140 | 141 | if (hit && hit[token.value]) { 142 | // Found match in cache 143 | const ref: ICommand = hit[token.value]; 144 | cmdNodeCtx.command = { 145 | ref, 146 | token, 147 | parent: cmdNodeCtx, 148 | kind: 'COMMAND', 149 | }; 150 | cmdNodeCtx = cmdNodeCtx.command; 151 | } else if (!hit) { 152 | // Command's commands function needs to be resolved 153 | ast.pending = { 154 | kind: 'PENDING', 155 | token, 156 | resolve: cmdNodeCtx.ref.commands, 157 | key, 158 | }; 159 | done = true; 160 | } else { 161 | // Return leftover node 162 | ast.remainder = { 163 | kind: 'REMAINDER', 164 | token, 165 | cmdNodeCtx: cmdNodeCtx || undefined, 166 | argNodeCtx: argNodeCtx || undefined, 167 | }; 168 | } 169 | } else { 170 | if (token) { 171 | // Return leftover node 172 | ast.remainder = { 173 | kind: 'REMAINDER', 174 | token, 175 | cmdNodeCtx: cmdNodeCtx || undefined, 176 | argNodeCtx: argNodeCtx || undefined, 177 | }; 178 | } 179 | 180 | done = true; 181 | } 182 | } 183 | 184 | return ast; 185 | }; 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CLUI 2 | 3 | **This repository is no longer maintained** 4 | 5 | CLUI is a collection of JavaScript libraries for building command-line interfaces with context-aware autocomplete. 6 | 7 | [See Demo](https://repl.it/@clui/demo) 8 | 9 | ## Packages 10 | 11 | ### `@replit/clui-input` 12 | 13 | `@replit/clui-input` implements the logic for mapping text input to suggestions and a potential `run` function. 14 | 15 | ```jsx 16 | import input from '@replit/clui-input'; 17 | 18 | const rootCommand = { 19 | commands: { 20 | open: { 21 | commands: { 22 | sesame: { 23 | run: (args) => { 24 | /* do something */ 25 | }, 26 | }, 27 | }, 28 | }, 29 | }, 30 | }; 31 | 32 | const update = input({ 33 | command: rootCommand, 34 | onUpdate: (updates) => { 35 | /* Update #1: `updates.options` will be 36 | * [ 37 | * { 38 | * "value": "open", 39 | * "inputValue": "open", 40 | * "searchValue": "o", 41 | * "cursorTarget": 4 42 | * } 43 | * ] 44 | */ 45 | 46 | /* Update #2: `updates.options` will be 47 | * [ 48 | * { 49 | * "value": "sesame", 50 | * "inputValue": "open sesame", 51 | * "searchValue": "s", 52 | * "cursorTarget": 12 53 | * } 54 | * ] 55 | */ 56 | }, 57 | }); 58 | 59 | /* Update #1 */ 60 | update({ value: 'o', index: 1 }); 61 | 62 | /* Update #2 */ 63 | update({ value: 'open s', index: 6 }); 64 | ``` 65 | 66 | When the input matches a command with a `run` function, the `onUpdate` callback will include a reference to it. 67 | 68 | ```jsx 69 | const update = input({ 70 | command: rootCommand, 71 | onUpdate: (updates) => { 72 | // call or store reference to `updates.run` based on user interaction 73 | }, 74 | }); 75 | 76 | update({ value: 'open sesame', index: 6 }); 77 | ``` 78 | 79 | `@replit/clui-input` a framework agnostic primitive that can be wrapped by more specific framework or application code (like a react hook). If using react you will most likey want to keep the result of `onUpdate` in a state object. For managing dropdown selection UX I highly recommend [downshift](https://github.com/downshift-js/downshift). 80 | 81 | ### `@replit/clui-session` 82 | 83 | `@replit/clui-session` implements the logic for rendering a list of react children. For building a CLI-style interfaces this can be useful for adding and removing lines when the prompt is submitted. 84 | 85 | ```jsx 86 | import React from 'react' 87 | import { render } from 'react-dom' 88 | import Session, { Do } from '@replit/clui-session'; 89 | 90 | /* `Do` is a helper that exposes the `item` prop 91 | * You will most likey render your own component 92 | * which will get `item` injected as a prop so 93 | * that component can call `item.next` based 94 | * on specific application logic 95 | */ 96 | render( 97 | 98 | 99 | {item => } 100 | 101 | 102 | {item => } 103 | 104 | 105 | {item => } 106 | 107 | , 108 | document.getElementById('root'), 109 | ); 110 | ``` 111 | 112 | ### `@replit/clui-gql` 113 | 114 | `@replit/clui-gql` is a utility library for building [CLUI](https://github.com/replit/clui) commands from [GraphQL introspection](https://graphql.org/learn/introspection) data. 115 | 116 | ## Install 117 | 118 | ```sh 119 | npm install @replit/clui-gql 120 | ``` 121 | 122 | ## Usage 123 | 124 | To create a tree of CLUI commands call `toCommand` and then `visit` each command to define a run function. 125 | 126 | ```jsx 127 | import { toCommand, visit } from '@replit/clui-gql'; 128 | import { introspectionFromSchema } from 'graphql'; 129 | import schema from './your-graphql-schema'; 130 | 131 | // on server 132 | const introspection = introspectionFromSchema(schema); 133 | 134 | // on client 135 | const introspection = makeNetworkRequestForData(); 136 | 137 | // Create a command tree from graphql introspection data. This could be done on 138 | // the server or the client. 139 | const root = toCommand({ 140 | // 'query' or 'mutation' 141 | operation: 'query', 142 | 143 | // The name of the graphql type that has the fields that act as top level commands 144 | rootTypeName: 'CluiCommands' 145 | 146 | // the path at which the above type appears in the graph 147 | mountPath: ['cli', 'admin'], 148 | 149 | // GraphQL introspection data 150 | introspectionSchema: introspection.__schema, 151 | 152 | // Configure fields and fragments for the output of the GraphQL operation string 153 | output: () => ({ 154 | fields: '...Output', 155 | fragments: ` 156 | fragment Output on YourOutputTypes { 157 | ...on SuccessOutput { 158 | message 159 | } 160 | ...on ErrorOutput { 161 | error 162 | } 163 | }`, 164 | }), 165 | }); 166 | 167 | // Define some application specific behavior for when a command is `run` 168 | visit(root, (command) => { 169 | if (command.outputType !== 'YourOutputTypes') { 170 | // If command does not match an output type you may want do something different. 171 | By omitting the run function the command acts as a namespace for sub-commands. 172 | return; 173 | } 174 | 175 | command.run = (options) => { 176 | return 177 | } 178 | } 179 | ``` 180 | 181 | 'parseArgs' is a helper for working with args 182 | 183 | ```jsx 184 | import { parse } from 'graphql'; 185 | import { parseArgs } from '@replit/clui-gql'; 186 | 187 | const OutputView = (props) => { 188 | // CLIU command generated from graphql 189 | const { command } = props; 190 | 191 | // CLUI args 192 | const { args } = props.options; 193 | 194 | const parsed = parseArgs({ command, args }); 195 | 196 | // Handle state for submitting command based on parsed args 197 | 198 | if (parsed.missing.required) { 199 | return ; 200 | } 201 | 202 | if (parsed.missing.optional) { 203 | return ; 204 | } 205 | 206 | if (command.query) { 207 | graphQLClient.query(parse(command.query), { variables: parsed.variables }) 208 | } else if (command.mutation) { 209 | graphQLClient.mutate(parse(command.mutation), { variables: parsed.variables }) 210 | } 211 | 212 | // ...some component to communicate above state 213 | } 214 | 215 | ``` 216 | -------------------------------------------------------------------------------- /packages/clui-gql/src/toCommand.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IntrospectionSchema, 3 | IntrospectionType, 4 | IntrospectionInputValue, 5 | IntrospectionInputTypeRef, 6 | IntrospectionObjectType, 7 | IntrospectionInterfaceType, 8 | IntrospectionOutputTypeRef, 9 | IntrospectionNamedTypeRef, 10 | } from 'graphql'; 11 | import { IGQLCommand, IGQLCommandArg, OutputFn } from './types'; 12 | import toOperation from './toOperation'; 13 | 14 | export const findType = ({ 15 | introspectionSchema, 16 | name, 17 | }: { 18 | introspectionSchema: IntrospectionSchema; 19 | name: string; 20 | }): IntrospectionType | void => 21 | introspectionSchema.types.find((type) => type.name === name); 22 | 23 | const getBaseType = ( 24 | type: IntrospectionInputTypeRef | IntrospectionOutputTypeRef, 25 | ) => { 26 | const queue = [type]; 27 | 28 | while (queue.length) { 29 | const t = queue.shift(); 30 | 31 | if (!t) { 32 | throw Error('Expected type'); 33 | } 34 | 35 | if ('ofType' in t && t.ofType) { 36 | queue.push(t.ofType); 37 | } else { 38 | return t; 39 | } 40 | } 41 | 42 | return null; 43 | }; 44 | 45 | const isNonNull = (type: IntrospectionInputTypeRef) => type.kind === 'NON_NULL'; 46 | const isList = (type: IntrospectionInputTypeRef) => type.kind === 'LIST'; 47 | 48 | const getTypeFromOutputType = ({ 49 | introspectionSchema, 50 | outputType, 51 | }: { 52 | introspectionSchema: IntrospectionSchema; 53 | outputType: IntrospectionOutputTypeRef; 54 | }) => { 55 | const base = getBaseType(outputType); 56 | 57 | if (base && 'name' in base && base.name) { 58 | return findType({ introspectionSchema, name: base.name }); 59 | } 60 | 61 | return null; 62 | }; 63 | 64 | const argType = (type: IntrospectionNamedTypeRef) => { 65 | if (type.kind === 'ENUM') { 66 | return 'string'; 67 | } 68 | 69 | switch (type.name) { 70 | case 'Int': 71 | return 'int'; 72 | case 'Float': 73 | return 'float'; 74 | case 'Boolean': 75 | return 'boolean'; 76 | default: 77 | return 'string'; 78 | } 79 | }; 80 | 81 | const isListType = (type: IntrospectionInputTypeRef) => { 82 | const queue = [type]; 83 | 84 | while (queue.length) { 85 | const t = queue.shift(); 86 | 87 | if (!t) { 88 | throw Error('Expected type'); 89 | } 90 | 91 | if (isList(t)) { 92 | return true; 93 | } 94 | 95 | if ('ofType' in t && t.ofType) { 96 | queue.push(t.ofType); 97 | } 98 | } 99 | 100 | return false; 101 | }; 102 | 103 | const toArg = ({ 104 | introspectionSchema, 105 | inputType, 106 | }: { 107 | introspectionSchema: IntrospectionSchema; 108 | inputType: IntrospectionInputValue; 109 | }) => { 110 | const baseType = getBaseType(inputType.type); 111 | 112 | if (!baseType) { 113 | throw new Error(`Expected to find type for "${inputType.type}"`); 114 | } 115 | 116 | const arg: IGQLCommandArg = { 117 | name: inputType.name, 118 | type: 'name' in baseType && baseType.name ? argType(baseType) : undefined, 119 | required: isNonNull(inputType.type) || undefined, 120 | description: inputType.description || undefined, 121 | graphql: { 122 | kind: baseType.kind, 123 | list: isListType(inputType.type), 124 | }, 125 | }; 126 | 127 | if (baseType.kind === 'ENUM') { 128 | const enumType = findType({ introspectionSchema, name: baseType.name }); 129 | if (enumType && 'enumValues' in enumType && enumType.enumValues.length) { 130 | arg.options = enumType.enumValues.map((v) => ({ 131 | value: v.name, 132 | description: v.description || undefined, 133 | })); 134 | } 135 | } 136 | 137 | return arg; 138 | }; 139 | 140 | export interface IOptions { 141 | rootTypeName: string; 142 | transform?: { 143 | commandName?: (str: string) => string; 144 | argName?: (str: string) => string; 145 | }; 146 | introspectionSchema: IntrospectionSchema; 147 | mountPath: Array; 148 | operation: 'query' | 'mutation'; 149 | output: OutputFn; 150 | } 151 | 152 | const toCommand = ({ 153 | rootTypeName, 154 | transform, 155 | introspectionSchema, 156 | mountPath, 157 | operation, 158 | output, 159 | }: IOptions) => { 160 | const rootType = findType({ introspectionSchema, name: rootTypeName }); 161 | 162 | if (!rootType) { 163 | throw Error(`Expected type with name: "${rootTypeName}"`); 164 | } 165 | 166 | if (!('fields' in rootType)) { 167 | throw Error('Expected root type with fields'); 168 | } 169 | 170 | const rootCommand: IGQLCommand = { 171 | path: mountPath, 172 | outputType: rootType.name, 173 | }; 174 | 175 | const queue: Array<{ 176 | type: IntrospectionObjectType | IntrospectionInterfaceType; 177 | command: IGQLCommand; 178 | }> = [{ type: rootType, command: rootCommand }]; 179 | 180 | while (queue.length) { 181 | const i = queue.shift(); 182 | 183 | if (!i) { 184 | throw new Error('Expected item'); 185 | } 186 | 187 | const commands: Record = {}; 188 | 189 | for (const field of i.type.fields) { 190 | const base = getBaseType(field.type); 191 | 192 | if (!base || !('name' in base && base.name)) { 193 | throw Error(`Expected type with name: "${field.name}"`); 194 | } 195 | 196 | const subCommand: IGQLCommand = { 197 | outputType: base.name, 198 | path: [...i.command.path, field.name], 199 | description: field.description || undefined, 200 | }; 201 | 202 | if (field.args && field.args.length) { 203 | const args: Record = {}; 204 | 205 | for (const fieldArg of field.args) { 206 | const arg = toArg({ introspectionSchema, inputType: fieldArg }); 207 | const name: string = 208 | transform && transform.argName 209 | ? transform.argName(fieldArg.name) 210 | : fieldArg.name; 211 | 212 | args[name] = arg; 213 | } 214 | 215 | if (Object.keys(args).length) { 216 | subCommand.args = args; 217 | } 218 | } 219 | 220 | subCommand[operation] = toOperation({ 221 | field, 222 | operation, 223 | path: subCommand.path, 224 | output, 225 | }); 226 | 227 | const name = 228 | transform && transform.commandName 229 | ? transform.commandName(field.name) 230 | : field.name; 231 | commands[name] = subCommand; 232 | 233 | const fieldType = getTypeFromOutputType({ 234 | introspectionSchema, 235 | outputType: field.type, 236 | }); 237 | 238 | if (fieldType && 'fields' in fieldType && fieldType.fields) { 239 | queue.push({ type: fieldType, command: subCommand }); 240 | } 241 | 242 | i.command.commands = commands; 243 | } 244 | } 245 | 246 | return rootCommand; 247 | }; 248 | 249 | export default toCommand; 250 | -------------------------------------------------------------------------------- /packages/clui-input/src/__tests__/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { IArgNode, IArgFlagNode } from '../ast'; 2 | import { parse } from '../parser'; 3 | import { ICommand } from '../types'; 4 | 5 | describe('command parsing', () => { 6 | const root: ICommand = { 7 | commands: { 8 | user: { 9 | commands: { 10 | add: {}, 11 | }, 12 | }, 13 | }, 14 | }; 15 | 16 | it('parses subcommand', () => { 17 | const ast = parse('user add', root); 18 | 19 | if (typeof root.commands !== 'object') { 20 | throw new Error('Expected object'); 21 | } 22 | 23 | expect(ast.command?.ref).toEqual(root.commands.user); 24 | expect(ast.command?.kind).toEqual('COMMAND'); 25 | expect(ast.command?.token).toEqual({ 26 | value: 'user', 27 | kind: 'KEYWORD', 28 | start: 0, 29 | end: 4, 30 | }); 31 | 32 | if (typeof root.commands.user.commands !== 'object') { 33 | throw new Error('Expected object'); 34 | } 35 | 36 | expect(ast.command?.command?.kind).toEqual('COMMAND'); 37 | expect(ast.command?.command?.ref).toEqual(root.commands.user.commands.add); 38 | expect(ast.command?.command?.parent).toEqual(ast.command); 39 | expect(ast.command?.command?.token).toEqual({ 40 | value: 'add', 41 | kind: 'KEYWORD', 42 | start: 5, 43 | end: 8, 44 | }); 45 | }); 46 | 47 | it('parses command', () => { 48 | const ast = parse('user', root); 49 | 50 | if (typeof root.commands !== 'object') { 51 | throw new Error('Expected object'); 52 | } 53 | 54 | expect(ast.command?.command).toBe(undefined); 55 | expect(ast.command?.ref).toEqual(root.commands.user); 56 | expect(ast.command?.token).toEqual({ 57 | value: 'user', 58 | kind: 'KEYWORD', 59 | start: 0, 60 | end: 4, 61 | }); 62 | }); 63 | }); 64 | 65 | describe('arg parsing', () => { 66 | const root: ICommand = { 67 | commands: { 68 | user: { 69 | args: { 70 | info: { 71 | type: 'boolean', 72 | }, 73 | name: { 74 | type: 'string', 75 | }, 76 | }, 77 | }, 78 | }, 79 | }; 80 | 81 | it('parses string flag', () => { 82 | const ast = parse('user --name foo', root); 83 | 84 | if (typeof root.commands !== 'object') { 85 | throw new Error('Expected object'); 86 | } 87 | 88 | expect(ast.command?.ref).toEqual(root.commands.user); 89 | expect(ast.command?.token).toEqual({ 90 | value: 'user', 91 | kind: 'KEYWORD', 92 | start: 0, 93 | end: 4, 94 | }); 95 | 96 | if (!ast.command?.args) { 97 | throw Error('expected args'); 98 | } 99 | 100 | const arg = ast.command?.args[0] as IArgNode; 101 | 102 | expect(arg.kind).toEqual('ARG'); 103 | expect(arg.key.kind).toEqual('ARG_KEY'); 104 | expect(arg.value?.kind).toEqual('ARG_VALUE'); 105 | expect(arg.parent).toEqual(ast.command); 106 | expect(arg.key.parent).toEqual(arg); 107 | expect(arg?.value?.parent).toEqual(arg); 108 | 109 | expect(arg?.key.token).toEqual({ 110 | kind: 'KEYWORD', 111 | value: '--name', 112 | start: 5, 113 | end: 11, 114 | }); 115 | 116 | expect(arg?.value?.token).toEqual({ 117 | kind: 'KEYWORD', 118 | value: 'foo', 119 | start: 12, 120 | end: 15, 121 | }); 122 | }); 123 | 124 | it('parses boolean flag', () => { 125 | const ast = parse('user --info', root); 126 | 127 | if (typeof root.commands !== 'object') { 128 | throw new Error('Expected object'); 129 | } 130 | 131 | expect(ast.command?.ref).toEqual(root.commands.user); 132 | expect(ast.command?.token).toEqual({ 133 | value: 'user', 134 | kind: 'KEYWORD', 135 | start: 0, 136 | end: 4, 137 | }); 138 | 139 | if (!ast.command?.args) { 140 | throw Error('expected args'); 141 | } 142 | 143 | const arg = ast.command?.args[0] as IArgFlagNode; 144 | 145 | expect(arg.parent).toEqual(ast.command); 146 | expect(arg.kind).toEqual('ARG_FLAG'); 147 | expect(arg?.token).toEqual({ 148 | kind: 'KEYWORD', 149 | value: '--info', 150 | start: 5, 151 | end: 11, 152 | }); 153 | }); 154 | 155 | it('parses string and boolean flag', () => { 156 | const ast = parse('user --info --name foo', root); 157 | 158 | if (typeof root.commands !== 'object') { 159 | throw new Error('Expected object'); 160 | } 161 | 162 | expect(ast.command?.ref).toEqual(root.commands.user); 163 | expect(ast.command?.token).toEqual({ 164 | value: 'user', 165 | kind: 'KEYWORD', 166 | start: 0, 167 | end: 4, 168 | }); 169 | 170 | if (!ast.command?.args) { 171 | throw Error('expected args'); 172 | } 173 | 174 | const boolArg = ast.command?.args[0] as IArgFlagNode; 175 | const stringArg = ast.command?.args[1] as IArgNode; 176 | 177 | expect(stringArg.parent).toEqual(ast.command); 178 | expect(stringArg.kind).toEqual('ARG'); 179 | expect(stringArg?.key.parent).toEqual(stringArg); 180 | expect(stringArg?.key.kind).toEqual('ARG_KEY'); 181 | 182 | expect(stringArg?.key.token).toEqual({ 183 | kind: 'KEYWORD', 184 | value: '--name', 185 | start: 12, 186 | end: 18, 187 | }); 188 | 189 | expect(stringArg?.value?.kind).toEqual('ARG_VALUE'); 190 | expect(stringArg?.value?.token).toEqual({ 191 | kind: 'KEYWORD', 192 | value: 'foo', 193 | start: 19, 194 | end: 22, 195 | }); 196 | 197 | expect(boolArg.parent).toEqual(ast.command); 198 | expect(boolArg.kind).toEqual('ARG_FLAG'); 199 | 200 | expect(boolArg?.token).toEqual({ 201 | kind: 'KEYWORD', 202 | value: '--info', 203 | start: 5, 204 | end: 11, 205 | }); 206 | }); 207 | }); 208 | 209 | describe('pending commands', () => { 210 | const root: ICommand = { 211 | commands: { 212 | user: { 213 | args: { 214 | info: {}, 215 | name: { 216 | type: 'string', 217 | }, 218 | }, 219 | commands: async () => ({ 220 | add: {}, 221 | }), 222 | }, 223 | }, 224 | }; 225 | 226 | it('returns pending for command', () => { 227 | const resolved = { user: {} }; 228 | const program = { commands: async () => resolved }; 229 | const ast = parse('us', program); 230 | 231 | expect(ast.pending?.resolve).toEqual(program.commands); 232 | expect(ast.pending?.key).toEqual('us'); 233 | 234 | expect(ast.pending?.token).toEqual({ 235 | value: 'us', 236 | kind: 'KEYWORD', 237 | start: 0, 238 | end: 2, 239 | }); 240 | }); 241 | 242 | it('returns pending for subcommand', () => { 243 | const ast = parse('user add', root); 244 | 245 | if (typeof root.commands !== 'object') { 246 | throw new Error('Expected object'); 247 | } 248 | 249 | expect(ast.pending?.resolve).toEqual(root.commands.user.commands); 250 | expect(ast.pending?.key).toEqual('user add'); 251 | 252 | expect(ast.pending?.token).toEqual({ 253 | value: 'add', 254 | kind: 'KEYWORD', 255 | start: 5, 256 | end: 8, 257 | }); 258 | }); 259 | 260 | it('returns pending for partrial subcommand', () => { 261 | const ast = parse('user a', root); 262 | 263 | if (typeof root.commands !== 'object') { 264 | throw new Error('Expected object'); 265 | } 266 | 267 | expect(ast.pending?.resolve).toEqual(root.commands.user.commands); 268 | expect(ast.pending?.key).toEqual('user a'); 269 | 270 | expect(ast.pending?.token).toEqual({ 271 | value: 'a', 272 | kind: 'KEYWORD', 273 | start: 5, 274 | end: 6, 275 | }); 276 | }); 277 | }); 278 | 279 | describe('remainder', () => { 280 | const root: ICommand = { 281 | commands: { 282 | user: { 283 | args: { 284 | info: {}, 285 | name: { 286 | type: 'string', 287 | }, 288 | }, 289 | commands: { 290 | add: {}, 291 | }, 292 | }, 293 | }, 294 | }; 295 | 296 | it('returns remainder for command', () => { 297 | const ast = parse('us', root); 298 | expect(ast.remainder?.cmdNodeCtx?.ref).toEqual(root); 299 | expect(ast.remainder?.token).toEqual({ 300 | value: 'us', 301 | kind: 'KEYWORD', 302 | start: 0, 303 | end: 2, 304 | }); 305 | }); 306 | 307 | it('returns remainder for sub command', () => { 308 | const ast = parse('user a', root); 309 | 310 | if (typeof root.commands !== 'object') { 311 | throw new Error('Expected object'); 312 | } 313 | 314 | expect(ast.remainder?.cmdNodeCtx?.ref).toEqual(root.commands.user); 315 | expect(ast.remainder?.cmdNodeCtx?.token).toEqual({ 316 | value: 'user', 317 | kind: 'KEYWORD', 318 | start: 0, 319 | end: 4, 320 | }); 321 | 322 | expect(ast.remainder?.token).toEqual({ 323 | value: 'a', 324 | kind: 'KEYWORD', 325 | start: 5, 326 | end: 6, 327 | }); 328 | }); 329 | }); 330 | -------------------------------------------------------------------------------- /packages/clui-session/src/Session.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Session is a component that manages when a list of child elements 3 | * are displayed. Each child receives an item prop that contains 4 | * methods and properties related to navigating and transforming the list. 5 | */ 6 | 7 | import React, { useReducer, useMemo, useCallback, useEffect } from 'react'; 8 | import reducer, { Action, IState } from './reducer'; 9 | 10 | /** 11 | * An interfce for child element `IProps` to extend. 12 | * 13 | * ``` 14 | * interface IProps extends ISessionItemProps {}; 15 | * 16 | * const Item: React.FC = (props) => ( 17 | * 18 | * ); 19 | * ``` 20 | */ 21 | export interface ISessionItemProps { 22 | item?: ISessionItem; 23 | } 24 | 25 | /** 26 | * An object containing methods and properties related to a `Session` instance. 27 | * In the following case each `Session` component has its own `session` object. 28 | * 29 | * ```jsx 30 | * 31 | * 32 | * 33 | * 34 | * 35 | * 36 | * 37 | * 38 | * ``` 39 | */ 40 | export interface ISession { 41 | /** 42 | * Resets the session to it's original state. This will remount initially 43 | * rendered child/children. 44 | */ 45 | reset: () => void; 46 | 47 | /** 48 | * The index of the last visibile child. 49 | */ 50 | currentIndex: number; 51 | 52 | /** 53 | * The total length of the list of children (rendered or not). 54 | */ 55 | length: number; 56 | 57 | /** 58 | * The context value `Session` was potentially initialized with. Useful 59 | * for sharing state/data within a session . 60 | */ 61 | context: C; 62 | } 63 | 64 | /** 65 | * An object containing methods and properties related to a child item 66 | * of a `Session` instance. These methods and properties are relative to 67 | * the item. For example, calling next multiple times on and item will 68 | * show the next child if it's not shown and otherwise have no effect. 69 | */ 70 | export interface ISessionItem { 71 | /** 72 | * The index of the element in the list. 73 | */ 74 | index: number; 75 | 76 | /** 77 | * Shows the next item if the item is the last displayed item and there 78 | * is at least 1 more item following it. Calling `next` on the last possible 79 | * item will call the `onDone` handler passed to parent `Session` component. 80 | */ 81 | next: () => ISessionItem; 82 | 83 | /** 84 | * Shows the previous item if the item is the last displayed item and there 85 | * is at least 1 more item preceding it. 86 | */ 87 | previous: () => ISessionItem; 88 | 89 | /** 90 | * Removes the element and sets index to previous value 91 | * displayed item and there is at least 1 more item preceding it. 92 | */ 93 | remove: () => ISessionItem; 94 | 95 | /** 96 | * Replaces element with another element. 97 | */ 98 | replace: (node: React.ReactElement) => ISessionItem; 99 | 100 | /** 101 | * Inserts 1 or more element after but does not display them (you can call 102 | * `next` afer inserting to show the first inserted element) 103 | */ 104 | insertAfter: (...nodes: Array) => ISessionItem; 105 | 106 | /** 107 | * Inserts 1 or more element before item. 108 | */ 109 | insertBefore: (...nodes: Array) => ISessionItem; 110 | 111 | /** 112 | * A reference to the session object (shared across items in the list). 113 | */ 114 | session: ISession; 115 | } 116 | 117 | /** 118 | * IProps for `Session` component. Extends `ISessionItemProps` so sessions can 119 | * be nested. 120 | */ 121 | interface IProps extends ISessionItemProps { 122 | /** 123 | * Called when `next` is called on last item 124 | */ 125 | onDone?: () => any; 126 | /** 127 | * One or more React elements 128 | */ 129 | children: React.ReactElement | Array; 130 | /** 131 | * Show all elements up to and including this index (defaults to 0) 132 | */ 133 | initialIndex?: number; 134 | /** 135 | * An optional value that will be available to every item at `item.session.context` 136 | */ 137 | context?: C; 138 | } 139 | 140 | /** 141 | * A component to wrap child elements. By default it will display the first child. 142 | * 143 | * ```jsx 144 | * 145 | *
shown
146 | *
not shown
147 | *
148 | * ``` 149 | * 150 | * The child elements have logic for doing something then calling a method on 151 | * `props.item` to advance to the next element (or modify the list in some other way) 152 | * 153 | * To initially show more then the first child use the`initialIndex` prop. 154 | * 155 | * ```jsx 156 | * 157 | *
shown
158 | *
shown
159 | *
160 | * ``` 161 | */ 162 | function Session(props: IProps) { 163 | const children = useMemo( 164 | () => React.Children.toArray(props.children).filter(React.isValidElement), 165 | [props.children], 166 | ); 167 | 168 | const [state, dispatch] = useReducer>(reducer, { 169 | currentIndex: 170 | props.initialIndex !== undefined 171 | ? Math.min(props.initialIndex, children.length - 1) 172 | : 0, 173 | nodes: children.map((_, index) => index), 174 | sessionKey: Math.random(), 175 | }); 176 | 177 | const nodes = useMemo(() => { 178 | const reduce = ( 179 | acc: Array, 180 | node: React.ReactElement | number, 181 | ) => { 182 | if (typeof node !== 'number') { 183 | acc.push(node); 184 | } else if (typeof node === 'number' && children[node]) { 185 | acc.push(children[node]); 186 | } 187 | 188 | return acc; 189 | }; 190 | 191 | return state.nodes.reduce(reduce, []); 192 | }, [state.nodes, children]); 193 | 194 | const currentNodes = useMemo(() => nodes.slice(0, state.currentIndex + 1), [ 195 | nodes, 196 | state.currentIndex, 197 | ]); 198 | 199 | const reset = useCallback(() => { 200 | dispatch({ type: 'RESET', nodes: children }); 201 | }, [dispatch]); 202 | 203 | const session = useMemo( 204 | () => ({ 205 | reset, 206 | currentIndex: state.currentIndex, 207 | length: nodes.length, 208 | context: props.context, 209 | }), 210 | [nodes.length, reset, state.currentIndex, props.context], 211 | ); 212 | 213 | const item = useCallback<(i: number) => ISessionItem>( 214 | (index: number) => { 215 | let indexOffset = 0; 216 | let lengthOffset = 0; 217 | 218 | const ret = { 219 | next: () => { 220 | dispatch({ 221 | type: 'NEXT', 222 | source: index + indexOffset, 223 | }); 224 | 225 | return ret; 226 | }, 227 | previous: () => { 228 | dispatch({ 229 | type: 'SET_INDEX', 230 | index: index + indexOffset - 1, 231 | }); 232 | 233 | return ret; 234 | }, 235 | insertAfter: (...newNodes: Array) => { 236 | dispatch({ type: 'INSERT_AFTER', nodes: newNodes, index }); 237 | lengthOffset += newNodes.length; 238 | 239 | return ret; 240 | }, 241 | insertBefore: (...newNodes: Array) => { 242 | indexOffset += newNodes.length; 243 | lengthOffset += newNodes.length; 244 | dispatch({ type: 'INSERT_BEFORE', nodes: newNodes, index }); 245 | 246 | return ret; 247 | }, 248 | replace: (node: React.ReactElement) => { 249 | dispatch({ type: 'REPLACE', index, node }); 250 | 251 | return ret; 252 | }, 253 | remove: () => { 254 | lengthOffset -= 1; 255 | dispatch({ type: 'REMOVE', index }); 256 | 257 | return ret; 258 | }, 259 | get index() { 260 | return index + indexOffset; 261 | }, 262 | get session() { 263 | return { 264 | ...session, 265 | length: session.length + lengthOffset, 266 | }; 267 | }, 268 | }; 269 | 270 | return ret; 271 | }, 272 | [ 273 | dispatch, 274 | props.onDone, 275 | state.currentIndex, 276 | nodes, 277 | currentNodes, 278 | props.item, 279 | session, 280 | ], 281 | ); 282 | 283 | useEffect(() => { 284 | if (state.currentIndex < nodes.length - 1) { 285 | return; 286 | } 287 | 288 | if (props.onDone) { 289 | props.onDone(); 290 | } 291 | }, [props.onDone, nodes.length, state.currentIndex]); 292 | 293 | useEffect(() => { 294 | if (!props.item) { 295 | return; 296 | } 297 | 298 | if (props.item.index === session.currentIndex) { 299 | props.item.next(); 300 | } 301 | }, [props.item]); 302 | 303 | return ( 304 | 305 | {React.Children.map(currentNodes, (element, index) => 306 | React.cloneElement(element, { item: item(index) }), 307 | )} 308 | 309 | ); 310 | } 311 | 312 | export default Session; 313 | -------------------------------------------------------------------------------- /packages/clui-input/src/options.ts: -------------------------------------------------------------------------------- 1 | import { commandOptions, valueOptions, argsOptions } from './optionsList'; 2 | import { ASTNodeKind, ASTNode } from './ast'; 3 | import { ICommands, ICommand, IArgsOption, SearchFn, IOption } from './types'; 4 | 5 | interface IConfig { 6 | includeExactMatch?: boolean; 7 | command: ICommand; 8 | commandsCache: Record; 9 | optionsCache: Record>; 10 | searchFn: SearchFn; 11 | } 12 | 13 | interface IParams { 14 | currentNode?: ASTNode; 15 | previousNode?: ASTNode; 16 | index: number; 17 | value: string; 18 | parsedArgKeys?: Array; 19 | } 20 | 21 | interface INodeParams extends IParams { 22 | valueStart: string; 23 | search?: string; 24 | atWhitespace: boolean; 25 | nodeStart: number; 26 | } 27 | 28 | type Options = Array; 29 | type OptionsFn = (params: INodeParams, config: IConfig) => Options; 30 | 31 | const NodeTypes: Record = { 32 | COMMAND: (params, config) => { 33 | const { commandsCache, searchFn } = config; 34 | const { 35 | currentNode, 36 | previousNode, 37 | search, 38 | value, 39 | valueStart, 40 | nodeStart, 41 | parsedArgKeys, 42 | } = params; 43 | 44 | if (currentNode?.kind === 'COMMAND') { 45 | let parentCommands = 46 | currentNode.parent?.ref.commands || config.command.commands; 47 | 48 | if (typeof parentCommands === 'function' && commandsCache[valueStart]) { 49 | parentCommands = commandsCache[valueStart]; 50 | } 51 | 52 | if (typeof parentCommands === 'object') { 53 | return commandOptions({ 54 | commands: parentCommands, 55 | searchFn, 56 | sliceStart: nodeStart, 57 | sliceEnd: currentNode.token.end, 58 | search, 59 | inputValue: value, 60 | }); 61 | } 62 | } 63 | 64 | if (previousNode?.kind === 'COMMAND') { 65 | const options: Options = []; 66 | if (previousNode.ref.args) { 67 | options.push( 68 | ...argsOptions({ 69 | searchFn, 70 | sliceStart: nodeStart, 71 | search, 72 | inputValue: value, 73 | args: previousNode.ref.args, 74 | exclude: parsedArgKeys, 75 | }), 76 | ); 77 | } 78 | 79 | let commands: ICommands | null = null; 80 | const { ref } = previousNode; 81 | 82 | if (typeof ref.commands === 'object') { 83 | commands = ref.commands; 84 | } else if (typeof ref.commands === 'function') { 85 | commands = commandsCache[valueStart]; 86 | } 87 | 88 | if (commands) { 89 | options.push( 90 | ...commandOptions({ 91 | searchFn, 92 | sliceStart: nodeStart, 93 | search, 94 | inputValue: value, 95 | commands, 96 | }), 97 | ); 98 | } 99 | 100 | return options; 101 | } 102 | 103 | return []; 104 | }, 105 | 106 | ARG_KEY: (params, config) => { 107 | const { searchFn, optionsCache } = config; 108 | const { 109 | currentNode, 110 | previousNode, 111 | search, 112 | value, 113 | valueStart, 114 | nodeStart, 115 | } = params; 116 | 117 | const options: Options = []; 118 | 119 | if (currentNode?.kind === 'ARG_KEY') { 120 | const argsMap = currentNode.parent.parent.parent?.ref.args || { 121 | [currentNode.token.value.replace(/^-(-?)/, '')]: currentNode.parent.ref, 122 | }; 123 | 124 | options.push( 125 | ...argsOptions({ 126 | searchFn, 127 | sliceStart: nodeStart, 128 | sliceEnd: currentNode.token.end, 129 | search, 130 | inputValue: value, 131 | args: argsMap, 132 | }), 133 | ); 134 | } 135 | 136 | if (previousNode?.kind === 'ARG_KEY') { 137 | const { ref } = previousNode.parent; 138 | let argOptions: Array | null = null; 139 | 140 | if (Array.isArray(ref.options)) { 141 | argOptions = ref.options; 142 | } else if (optionsCache[valueStart]) { 143 | argOptions = optionsCache[valueStart]; 144 | } 145 | 146 | if (argOptions) { 147 | options.push( 148 | ...valueOptions({ 149 | options: argOptions, 150 | search, 151 | searchFn, 152 | sliceStart: nodeStart, 153 | inputValue: value, 154 | }), 155 | ); 156 | } 157 | } 158 | 159 | return options; 160 | }, 161 | 162 | ARG_VALUE: (params, config) => { 163 | const { optionsCache, searchFn } = config; 164 | const { 165 | currentNode, 166 | previousNode, 167 | search, 168 | value, 169 | valueStart, 170 | atWhitespace, 171 | nodeStart, 172 | parsedArgKeys, 173 | } = params; 174 | 175 | const options: Options = []; 176 | 177 | if (currentNode?.kind === 'ARG_VALUE') { 178 | const { ref } = currentNode.parent; 179 | let argOptions: Array | null = null; 180 | 181 | if (Array.isArray(ref.options)) { 182 | argOptions = ref.options; 183 | } else if (optionsCache[valueStart]) { 184 | argOptions = optionsCache[valueStart]; 185 | } 186 | 187 | if (argOptions) { 188 | options.push( 189 | ...valueOptions({ 190 | options: argOptions, 191 | search, 192 | searchFn, 193 | sliceStart: nodeStart, 194 | inputValue: value, 195 | }), 196 | ); 197 | } 198 | } 199 | 200 | if (previousNode?.kind === 'ARG_VALUE' && atWhitespace) { 201 | const argsMap = previousNode.parent.parent.ref.args; 202 | 203 | if (argsMap) { 204 | options.push( 205 | ...argsOptions({ 206 | search, 207 | searchFn, 208 | sliceStart: nodeStart, 209 | inputValue: value, 210 | args: argsMap, 211 | exclude: parsedArgKeys, 212 | }), 213 | ); 214 | } 215 | } 216 | 217 | if (previousNode?.kind === 'ARG_VALUE' && !atWhitespace) { 218 | const { ref } = previousNode.parent; 219 | let argOptions: Array | null = null; 220 | 221 | if (Array.isArray(ref.options)) { 222 | argOptions = ref.options; 223 | } else if (optionsCache[valueStart]) { 224 | argOptions = optionsCache[valueStart]; 225 | } 226 | 227 | if (argOptions) { 228 | options.push( 229 | ...valueOptions({ 230 | options: argOptions, 231 | search, 232 | searchFn, 233 | sliceStart: nodeStart, 234 | inputValue: value, 235 | }), 236 | ); 237 | } 238 | } 239 | 240 | return options; 241 | }, 242 | 243 | ARG_FLAG: (params, config) => { 244 | const { searchFn, commandsCache } = config; 245 | const { 246 | currentNode, 247 | previousNode, 248 | search, 249 | value, 250 | valueStart, 251 | nodeStart, 252 | parsedArgKeys, 253 | } = params; 254 | 255 | const options: Options = []; 256 | 257 | if (currentNode?.kind === 'ARG_FLAG') { 258 | const argsMap = currentNode.parent?.ref.args || { 259 | [currentNode.token.value.replace(/^-(-?)/, '')]: currentNode.ref, 260 | }; 261 | 262 | options.push( 263 | ...argsOptions({ 264 | searchFn, 265 | sliceStart: nodeStart, 266 | sliceEnd: currentNode.token.end, 267 | search, 268 | inputValue: value, 269 | args: argsMap, 270 | }), 271 | ); 272 | } 273 | 274 | if (previousNode?.kind === 'ARG_FLAG') { 275 | const argsMap = previousNode.parent.ref.args; 276 | 277 | if (argsMap) { 278 | options.push( 279 | ...argsOptions({ 280 | search, 281 | searchFn, 282 | sliceStart: nodeStart, 283 | inputValue: value, 284 | args: argsMap, 285 | exclude: parsedArgKeys, 286 | }), 287 | ); 288 | } 289 | 290 | let commands: ICommands | null = null; 291 | const { ref } = previousNode.parent; 292 | 293 | if (typeof ref.commands === 'object') { 294 | commands = ref.commands; 295 | } else if (typeof ref.commands === 'function') { 296 | commands = commandsCache[valueStart]; 297 | } 298 | 299 | if (commands) { 300 | options.push( 301 | ...commandOptions({ 302 | searchFn, 303 | sliceStart: nodeStart, 304 | search, 305 | inputValue: value, 306 | commands, 307 | }), 308 | ); 309 | } 310 | } 311 | 312 | return options; 313 | }, 314 | 315 | ARG: () => [], 316 | 317 | REMAINDER: (params, config) => { 318 | const { commandsCache, searchFn } = config; 319 | const { 320 | currentNode, 321 | previousNode, 322 | search, 323 | value, 324 | valueStart, 325 | nodeStart, 326 | atWhitespace, 327 | parsedArgKeys, 328 | } = params; 329 | 330 | const options: Options = []; 331 | 332 | if (currentNode?.kind === 'REMAINDER' && currentNode.cmdNodeCtx) { 333 | const argsMap = currentNode.cmdNodeCtx.ref.args; 334 | 335 | if (argsMap) { 336 | options.push( 337 | ...argsOptions({ 338 | args: argsMap, 339 | searchFn, 340 | sliceStart: nodeStart, 341 | sliceEnd: currentNode.token.end, 342 | search, 343 | inputValue: value, 344 | }), 345 | ); 346 | } 347 | 348 | if (currentNode.cmdNodeCtx.ref.commands) { 349 | let commands: ICommands | null = null; 350 | const { ref } = currentNode.cmdNodeCtx; 351 | 352 | if (typeof ref.commands === 'object') { 353 | commands = ref.commands; 354 | } else if (typeof ref.commands === 'function') { 355 | commands = commandsCache[valueStart]; 356 | } 357 | 358 | if (commands) { 359 | options.push( 360 | ...commandOptions({ 361 | searchFn, 362 | sliceStart: nodeStart, 363 | search, 364 | inputValue: value, 365 | commands, 366 | }), 367 | ); 368 | } 369 | } 370 | } 371 | 372 | if ( 373 | previousNode?.kind === 'REMAINDER' && 374 | previousNode.cmdNodeCtx && 375 | !atWhitespace 376 | ) { 377 | const { token } = previousNode; 378 | if (previousNode.cmdNodeCtx?.ref.args) { 379 | options.push( 380 | ...argsOptions({ 381 | searchFn, 382 | sliceStart: nodeStart, 383 | search: token.value.replace(/^-(-?)/, ''), 384 | inputValue: value, 385 | args: previousNode.cmdNodeCtx.ref.args, 386 | exclude: parsedArgKeys, 387 | }), 388 | ); 389 | } else { 390 | let commands: ICommands | null = null; 391 | const { ref } = previousNode.cmdNodeCtx; 392 | 393 | if (typeof ref.commands === 'object') { 394 | commands = ref.commands; 395 | } else if (typeof ref.commands === 'function') { 396 | commands = commandsCache[valueStart]; 397 | } 398 | // Handle top-level options when there is no parent command 399 | if (commands) { 400 | options.push( 401 | ...commandOptions({ 402 | inputValue: value, 403 | commands, 404 | searchFn, 405 | sliceStart: nodeStart, 406 | search: token.value, 407 | }), 408 | ); 409 | } 410 | } 411 | } 412 | 413 | return options; 414 | }, 415 | 416 | PENDING: (__params) => [], 417 | }; 418 | 419 | export const optionsProvider = (config: IConfig) => ( 420 | params: IParams, 421 | ): Options => { 422 | if (!params.value) { 423 | return []; 424 | } 425 | 426 | const valueStart = params.value.slice(0, params.index); 427 | const atWhitespace = params.value[params.index - 1] === ' '; 428 | 429 | if (params.currentNode && NodeTypes[params.currentNode.kind]) { 430 | let nodeStart = 0; 431 | 432 | if ('token' in params.currentNode) { 433 | nodeStart = params.currentNode.token.start; 434 | } 435 | 436 | const search = params.value.slice(nodeStart, params.index) || undefined; 437 | 438 | return NodeTypes[params.currentNode.kind]( 439 | { valueStart, nodeStart, search, atWhitespace, ...params }, 440 | config, 441 | ); 442 | } 443 | 444 | if (params.previousNode && NodeTypes[params.previousNode.kind]) { 445 | let nodeStart = 0; 446 | if ( 447 | !atWhitespace && 448 | params.previousNode && 449 | 'token' in params.previousNode 450 | ) { 451 | nodeStart = params.previousNode.token.start; 452 | } else { 453 | nodeStart = params.index; 454 | } 455 | 456 | const search = !atWhitespace 457 | ? params.value.slice(nodeStart, params.index).trim() 458 | : undefined; 459 | 460 | const options: Options = []; 461 | 462 | if ( 463 | config.includeExactMatch && 464 | 'token' in params.previousNode && 465 | params.previousNode.token.value === search 466 | ) { 467 | const { previousNode } = params; 468 | const value = `${previousNode.token.value} `; 469 | const inputValue = params.value.slice(0, nodeStart) + value; 470 | 471 | let data: any; 472 | 473 | if ('ref' in previousNode) { 474 | data = previousNode.ref; 475 | } else if ('parent' in previousNode) { 476 | data = previousNode.parent.ref; 477 | } 478 | 479 | if (data) { 480 | options.push({ 481 | cursorTarget: inputValue.length, 482 | value, 483 | inputValue, 484 | data, 485 | }); 486 | } 487 | } 488 | 489 | options.push( 490 | ...NodeTypes[params.previousNode.kind]( 491 | { valueStart, nodeStart, search, atWhitespace, ...params }, 492 | config, 493 | ), 494 | ); 495 | 496 | return options; 497 | } 498 | 499 | return []; 500 | }; 501 | -------------------------------------------------------------------------------- /packages/clui-input/src/input.ts: -------------------------------------------------------------------------------- 1 | import { find, closestPrevious, IAst, commandPath, toArgs } from './ast'; 2 | import { 3 | ICommands, 4 | ICommand, 5 | IOption, 6 | ArgType, 7 | ArgsMap, 8 | IArgsOption, 9 | SearchFn, 10 | } from './types'; 11 | import { resolve } from './resolver'; 12 | import { commandOptions } from './optionsList'; 13 | import { optionsProvider } from './options'; 14 | 15 | export interface IInputUpdates { 16 | ast: IAst; 17 | nodeStart?: number; 18 | commands: Array<{ name: string; args?: ArgsMap }>; 19 | args?: Record; 20 | exhausted: boolean; 21 | options: Array; 22 | run?: (opt?: D) => R; 23 | } 24 | 25 | export interface IConfig { 26 | searchFn?: SearchFn; 27 | includeExactMatch?: boolean; 28 | onUpdate: (updates: IInputUpdates) => void; 29 | command: C; 30 | value?: string; 31 | index?: number; 32 | } 33 | 34 | const getRootCommands = async ( 35 | ast: IAst, 36 | command: ICommand, 37 | search?: string, 38 | ): Promise => { 39 | if (ast.command?.ref.commands) { 40 | const { commands } = ast.command.ref; 41 | 42 | return typeof commands === 'object' ? commands : commands(search); 43 | } 44 | 45 | if (command?.commands) { 46 | const { commands } = command; 47 | 48 | return typeof commands === 'object' ? commands : commands(search); 49 | } 50 | 51 | return null; 52 | }; 53 | 54 | export const createInput = (config: IConfig) => { 55 | /* 56 | * This map is used for caching the result of aynsc `commands` 57 | * functions between calls to `update` 58 | */ 59 | const commandsCache: Record = {}; 60 | 61 | /* 62 | * This map is used for caching the result of aysnc `options` 63 | * functions between calls to `update` 64 | */ 65 | const optionsCache: Record> = {}; 66 | 67 | /* 68 | * Allow user to provide a search function, otherwise use a simple 69 | * match function 70 | */ 71 | const searchFn = 72 | config.searchFn || 73 | ((opt: { source: string; search: string }) => 74 | opt.source.toLowerCase().includes(opt.search.toLowerCase())); 75 | 76 | /* 77 | * Used to invalidate outdated update logic. This is necessary when 78 | * a call to `update` is made before the previous update has completed 79 | */ 80 | let updatedAt = Date.now(); 81 | 82 | // Set initial state 83 | let value = config.value || ''; 84 | let index = config.index || 0; 85 | 86 | /* 87 | * This is called on initializaton and every time `update` is called. It 88 | * calculates possible next states and returns them as options. The index 89 | * (ie cursor position) and the value (ie user input) are used to determine 90 | * the options. It can be thought of as an "autocomplete engine". 91 | */ 92 | const processUpdates = async () => { 93 | // Everything leading up to the user's cursor 94 | const valueStart = value.slice(0, index); 95 | 96 | // Store the time started calling this function 97 | const current = updatedAt; 98 | 99 | /* 100 | * Create an AST using the current input value and command configuration. 101 | * This has a side-effect of populating `commandsCache` with resolved 102 | * values from async `commands` functions. 103 | */ 104 | const ast = await resolve({ 105 | input: value, 106 | command: config.command, 107 | cache: commandsCache, 108 | }); 109 | 110 | // We want to do slightly different things based on this 111 | const atWhitespace = value[index - 1] === ' '; 112 | 113 | if (value.length > index || atWhitespace) { 114 | /* 115 | * This is some special-case logic for when the cursor follows a space or 116 | * is not positioned at the end of the input value. 117 | * 118 | * TODO: look into removing this by passing `index` to `resolve` above 119 | */ 120 | const previousNode = closestPrevious(ast, index); 121 | if ( 122 | previousNode?.kind === 'COMMAND' && 123 | typeof previousNode.ref.commands === 'function' && 124 | !commandsCache[valueStart] 125 | ) { 126 | const search = valueStart.slice(previousNode.token.end).trim(); 127 | const result = await previousNode.ref.commands(search || undefined); 128 | 129 | if (result) { 130 | commandsCache[valueStart] = result; 131 | } 132 | } else if ( 133 | !atWhitespace && 134 | previousNode?.kind === 'REMAINDER' && 135 | typeof previousNode.cmdNodeCtx?.ref.commands === 'function' 136 | ) { 137 | const search = valueStart.slice(previousNode.token.start).trim(); 138 | const result = await previousNode.cmdNodeCtx.ref.commands( 139 | search || undefined, 140 | ); 141 | 142 | if (result) { 143 | commandsCache[valueStart] = result; 144 | } 145 | } 146 | } 147 | 148 | if (current !== updatedAt) { 149 | // Bail if an update happened before this function completes 150 | return; 151 | } 152 | 153 | /* 154 | * If the cursor is not positioned at the end of the input we're 155 | * positioned on a `currentNode` 156 | */ 157 | const currentNode = find(ast, index); 158 | 159 | // Get an array of the currently resolved command path 160 | const astCommands = ast.command ? commandPath(ast.command) : []; 161 | 162 | // The last command in the command path is the current matched command 163 | const last = astCommands[astCommands.length - 1]; 164 | const args = last ? toArgs(last) : undefined; 165 | const parsedArgKeys = 166 | args && args.parsed ? Object.keys(args.parsed) : undefined; 167 | 168 | // These are the next possible states we can suggest 169 | const options: Array = []; 170 | 171 | // The index from where to suggest the next state from 172 | let nodeStart = 0; 173 | 174 | if (currentNode && 'token' in currentNode) { 175 | /* 176 | * We're positioned on a `currentNode`, use it's starting location 177 | * as point from which to suggest options from. 178 | * 179 | * Example: 180 | * 181 | * Say there are 2 sub=commands under user 182 | * - "user add" 183 | * - "user update" 184 | * 185 | * If the input string is "user ad" and cursor position is "user a|". 186 | * We suggest: 187 | * - user add 188 | * - user update 189 | */ 190 | nodeStart = currentNode.token.start; 191 | } 192 | 193 | const commonParams = { 194 | parsedArgKeys, 195 | value, 196 | index, 197 | }; 198 | 199 | /* 200 | * Initialize a function with some configuration options. 201 | * 202 | * TODO: does this need to be in `processUpdates`? 203 | */ 204 | const getOptions = optionsProvider({ 205 | includeExactMatch: config.includeExactMatch, 206 | command: config.command, 207 | commandsCache, 208 | optionsCache, 209 | searchFn, 210 | }); 211 | 212 | if (!value) { 213 | /* 214 | * The input is empty. We handle top-level options here 215 | * 216 | * TODO: could an empty AST object handle this special case? 217 | */ 218 | 219 | /* 220 | * If provided with an `options` function on the root command, resolve the 221 | * function and add the result to options. No need to filter here since 222 | * the input is empty (so there's no string to filter by). 223 | * 224 | * Example: 225 | * 226 | * ```ts 227 | * const rootCommand = { 228 | * options: async () => { 229 | * return [...topLevelOptions] 230 | * } 231 | * } 232 | * ``` 233 | */ 234 | if (typeof config.command.options === 'function') { 235 | const optionsFn = config.command.options; 236 | const results = await optionsFn(); 237 | 238 | if (current !== updatedAt) { 239 | // Bail if an update happened before this function completes 240 | return; 241 | } 242 | 243 | options.push( 244 | ...results.map((result) => { 245 | const inputValue = result.value; 246 | 247 | return { 248 | value: result.value, 249 | inputValue, 250 | cursorTarget: inputValue.length, 251 | data: result, 252 | }; 253 | }), 254 | ); 255 | } 256 | 257 | // Handle top-level options when there is no input value 258 | let rootCommands: ICommands | null = commandsCache[''] || null; 259 | 260 | if (!rootCommands) { 261 | rootCommands = await getRootCommands(ast, config.command); 262 | if (rootCommands) { 263 | commandsCache[''] = rootCommands; 264 | } 265 | } 266 | 267 | if (current !== updatedAt) { 268 | // Bail if an update happened before this function completes 269 | return; 270 | } 271 | 272 | if (rootCommands) { 273 | options.push( 274 | ...commandOptions({ 275 | inputValue: value, 276 | commands: rootCommands, 277 | searchFn, 278 | }), 279 | ); 280 | } 281 | } else if (currentNode) { 282 | /* 283 | * The cursor is positied on a node 284 | */ 285 | if (currentNode.kind === 'ARG_VALUE') { 286 | const search = value.slice(nodeStart, index); 287 | const { ref } = currentNode.parent; 288 | 289 | if (typeof ref.options === 'function' && !optionsCache[valueStart]) { 290 | /* 291 | * Handle options function for arguments 292 | * 293 | * Examples: 294 | * "view-user --username abc" 295 | * "view-user --email xyz" 296 | * 297 | * ``` 298 | * const rootCommand = { 299 | * 'view-user': { 300 | * args: { 301 | * username: { 302 | * options: (search) => searchByUsername(search) 303 | * }, 304 | * email: { 305 | * options: (search) => searchByUsername(email) 306 | * } 307 | * } 308 | * } 309 | * } 310 | * ``` 311 | */ 312 | const argOptions = await ref.options(search || undefined); 313 | if (argOptions) { 314 | /* 315 | * Add to cache. `getOptions` below will read this value from the cache 316 | * cache and add results top `options` 317 | */ 318 | optionsCache[valueStart] = argOptions; 319 | } 320 | 321 | if (current !== updatedAt) { 322 | return; 323 | } 324 | } 325 | } 326 | 327 | options.push(...getOptions({ currentNode, ...commonParams })); 328 | } else { 329 | const previousNode = closestPrevious(ast, index); 330 | 331 | if (!atWhitespace && previousNode && 'token' in previousNode) { 332 | // If the previous character is not a space `nodeStart 333 | // is the start of the previous node 334 | nodeStart = previousNode.token.start; 335 | } else { 336 | // The previous character is a space. `nodeStart` is the smae 337 | // as the index (ie cursor position) 338 | nodeStart = index; 339 | } 340 | 341 | const search = !atWhitespace 342 | ? value.slice(nodeStart, index).trim() 343 | : undefined; 344 | 345 | if (previousNode) { 346 | // Cursor is at the end of the input. Get options based on the previous node 347 | if ( 348 | previousNode.kind === 'COMMAND' && 349 | index > previousNode.token.end && 350 | typeof previousNode?.ref.options === 'function' 351 | ) { 352 | /* 353 | * Handle `command.options` function 354 | * 355 | * Examples: 356 | * input: "search " 357 | * 358 | * ``` 359 | * const rootCommand = { 360 | * search: { 361 | * options: async (query) => { 362 | * // query == undefined 363 | * return searchApi.search(query) 364 | * } 365 | * } 366 | * } 367 | * ``` 368 | */ 369 | const optionsFn = previousNode.ref.options; 370 | const prefix = ast.source.slice(0, nodeStart - 1); 371 | const suffix = ast.source.slice(nodeStart); 372 | const searchValue = suffix.trimLeft() || undefined; 373 | // TODO cache like commands/arg options? 374 | const results = await optionsFn(searchValue); 375 | 376 | if (current !== updatedAt) { 377 | // Bail if an update happened before this function completes 378 | return; 379 | } 380 | 381 | options.push( 382 | ...results.map((result) => { 383 | const inputValue = `${prefix} ${result.value}`; 384 | 385 | return { 386 | value: result.value, 387 | inputValue, 388 | cursorTarget: inputValue.length, 389 | searchValue, 390 | data: result, 391 | }; 392 | }), 393 | ); 394 | } 395 | 396 | if ( 397 | 'cmdNodeCtx' in previousNode && 398 | typeof previousNode.cmdNodeCtx?.ref.options === 'function' 399 | ) { 400 | /* 401 | * Handle `command.options` function with search value 402 | * 403 | * Examples: 404 | * input: "search foo bar" 405 | * 406 | * ``` 407 | * const rootCommand = { 408 | * search: { 409 | * options: async (query) => { 410 | * // query == "foo bar" 411 | * return searchApi.search(query) 412 | * } 413 | * } 414 | * } 415 | * ``` 416 | */ 417 | const optionsFn = previousNode.cmdNodeCtx.ref.options; 418 | const prefix = ast.source.slice(0, nodeStart - 1); 419 | const suffix = ast.source.slice(nodeStart); 420 | const searchValue = suffix.trimLeft() || undefined; 421 | const results = await optionsFn(searchValue); 422 | 423 | if (current !== updatedAt) { 424 | // Bail if an update happened before this function completes 425 | return; 426 | } 427 | 428 | options.push( 429 | ...results.map((result) => { 430 | const inputValue = 431 | previousNode.token.start === 0 432 | ? result.value 433 | : `${prefix} ${result.value}`; 434 | 435 | return { 436 | value: result.value, 437 | inputValue, 438 | cursorTarget: inputValue.length, 439 | searchValue, 440 | data: result, 441 | }; 442 | }), 443 | ); 444 | } 445 | 446 | if (previousNode.kind === 'ARG_VALUE') { 447 | // Handle cursor position with key value "user --username ab" 448 | const { ref } = previousNode.parent; 449 | 450 | if (typeof ref.options === 'function' && !optionsCache[valueStart]) { 451 | const argOptions = await ref.options(search || undefined); 452 | if (current !== updatedAt) { 453 | // Bail if an update happened before this function completes 454 | return; 455 | } 456 | 457 | if (argOptions) { 458 | optionsCache[valueStart] = argOptions; 459 | } 460 | } 461 | } 462 | 463 | if (previousNode.kind === 'ARG_KEY') { 464 | // Handle cursor after arg key: "user --username " 465 | const { ref } = previousNode.parent; 466 | if (typeof ref.options === 'function' && !optionsCache[valueStart]) { 467 | const argOptions = await ref.options(search || undefined); 468 | if (current !== updatedAt) { 469 | // Bail if an update happened before this function completes 470 | return; 471 | } 472 | 473 | if (argOptions) { 474 | optionsCache[valueStart] = argOptions; 475 | } 476 | } 477 | } 478 | 479 | options.push(...getOptions({ previousNode, ...commonParams })); 480 | } 481 | } 482 | 483 | let run: void | ((o?: O) => any); 484 | 485 | const commandsList = astCommands.map((c) => { 486 | const cargs = toArgs(c); 487 | 488 | return { 489 | args: cargs.parsed, 490 | name: c.token.value, 491 | }; 492 | }); 493 | 494 | if (last && last.ref.run) { 495 | const { run: refRun } = last.ref; 496 | run = (opt: O): any => 497 | refRun({ 498 | commands: commandsList, 499 | args: commandsList[commandsList.length - 1].args, 500 | options: opt, 501 | }); 502 | } 503 | 504 | if (current !== updatedAt) { 505 | // Bail if an update happened before this function completes 506 | return; 507 | } 508 | 509 | config.onUpdate({ 510 | ast, 511 | args: args?.parsed, 512 | nodeStart, 513 | exhausted: !!args?.exhausted && !last.ref.commands, 514 | commands: commandsList, 515 | options, 516 | run: run || undefined, 517 | }); 518 | }; 519 | 520 | const update = (updates: { index?: number; value?: string }) => { 521 | if (updates.index !== undefined) { 522 | index = updates.index; 523 | updatedAt = Date.now(); 524 | } 525 | 526 | if (updates.value !== undefined) { 527 | value = updates.value; 528 | updatedAt = Date.now(); 529 | } 530 | 531 | processUpdates(); 532 | }; 533 | 534 | processUpdates(); 535 | 536 | return update; 537 | }; 538 | --------------------------------------------------------------------------------