From aa1e8d84125ee0a1787cb9edb2855d3d44752062 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Fri, 10 Nov 2023 12:33:34 -0500 Subject: [PATCH] add support for auto-generating completions for a cli program --- .changeset/wet-flies-march.md | 5 + .vscode/snippets.code-snippets | 4 +- package.json | 19 +- pnpm-lock.yaml | 276 ++++++---- src/Args.ts | 66 ++- src/CliApp.ts | 22 +- src/Command.ts | 16 +- src/Compgen.ts | 39 ++ src/Completion.ts | 35 ++ src/Exists.ts | 11 - src/Options.ts | 57 +- src/Parameter.ts | 6 +- src/Primitive.ts | 15 +- src/RegularLanguage.ts | 413 ++++++++++++++ src/ShellType.ts | 22 +- src/Terminal.ts | 2 +- src/index.ts | 12 +- src/internal/args.ts | 89 ++- src/internal/cliApp.ts | 159 ++++-- src/internal/command.ts | 58 +- src/internal/compgen.ts | 86 +++ src/internal/completion.ts | 113 ++++ src/internal/options.ts | 116 +++- src/internal/primitive.ts | 254 +++++++-- src/internal/regularLanguage.ts | 500 +++++++++++++++++ src/internal/shellType.ts | 12 +- src/internal/terminal.ts | 2 +- test/Args.test.ts | 57 ++ test/Command.test.ts | 9 +- test/Completion.test.ts | 926 ++++++++++++++++++++++++++++++++ test/Options.test.ts | 106 ++-- test/Primitive.test.ts | 22 +- test/RegularLanguage.test.ts | 472 ++++++++++++++++ test/utils/tail.ts | 6 +- test/utils/wc.ts | 2 +- 35 files changed, 3683 insertions(+), 326 deletions(-) create mode 100644 .changeset/wet-flies-march.md create mode 100644 src/Compgen.ts create mode 100644 src/Completion.ts delete mode 100644 src/Exists.ts create mode 100644 src/RegularLanguage.ts create mode 100644 src/internal/compgen.ts create mode 100644 src/internal/completion.ts create mode 100644 src/internal/regularLanguage.ts create mode 100644 test/Args.test.ts create mode 100644 test/Completion.test.ts create mode 100644 test/RegularLanguage.test.ts diff --git a/.changeset/wet-flies-march.md b/.changeset/wet-flies-march.md new file mode 100644 index 0000000..914bb86 --- /dev/null +++ b/.changeset/wet-flies-march.md @@ -0,0 +1,5 @@ +--- +"@effect/cli": patch +--- + +add support for auto-generating completions for a cli program diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets index 87ad337..5bad1bf 100644 --- a/.vscode/snippets.code-snippets +++ b/.vscode/snippets.code-snippets @@ -1,5 +1,5 @@ { - "Gen Function $": { + "Gen Function _": { "prefix": "gg", "body": [ "Effect.gen(function*(_) {", @@ -8,7 +8,7 @@ ], "description": "Generator Function with a _ parameter" }, - "Gen Yield $": { + "Gen Yield _": { "prefix": "yy", "body": [ "yield* _($0)" diff --git a/package.json b/package.json index e817af7..d34cd43 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "coverage": "vitest run --coverage" }, "peerDependencies": { + "@effect/platform": "^0.27.3", "@effect/printer": "^0.22.1", "@effect/printer-ansi": "^0.22.1", "@effect/schema": "^0.47.2", @@ -52,21 +53,23 @@ }, "devDependencies": { "@babel/cli": "^7.23.0", - "@babel/core": "^7.23.0", + "@babel/core": "^7.23.3", "@babel/plugin-transform-export-namespace-from": "^7.22.11", - "@babel/plugin-transform-modules-commonjs": "^7.23.0", + "@babel/plugin-transform-modules-commonjs": "^7.23.3", "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.2", - "@effect/build-utils": "^0.3.1", - "@effect/docgen": "^0.3.0", + "@effect/build-utils": "^0.4.0", + "@effect/docgen": "^0.3.1", "@effect/eslint-plugin": "^0.1.2", "@effect/language-service": "^0.0.21", + "@effect/platform": "^0.28.0", + "@effect/platform-node": "^0.29.0", "@effect/printer": "^0.22.1", "@effect/printer-ansi": "^0.22.1", - "@effect/schema": "^0.47.2", + "@effect/schema": "^0.47.3", "@preconstruct/cli": "^2.8.1", - "@types/chai": "^4.3.9", - "@types/node": "^20.8.10", + "@types/chai": "^4.3.10", + "@types/node": "^20.9.0", "@typescript-eslint/eslint-plugin": "^6.10.0", "@typescript-eslint/parser": "^6.10.0", "@vitejs/plugin-react": "^4.1.1", @@ -85,7 +88,7 @@ "madge": "^6.1.0", "prettier": "^3.0.3", "stackframe": "^1.3.4", - "tsx": "^3.14.0", + "tsx": "^4.1.0", "typescript": "^5.2.2", "vite": "^4.5.0", "vitest": "^0.34.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44c2d0e..cfa6a8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,16 +7,16 @@ settings: devDependencies: '@babel/cli': specifier: ^7.23.0 - version: 7.23.0(@babel/core@7.23.2) + version: 7.23.0(@babel/core@7.23.3) '@babel/core': - specifier: ^7.23.0 - version: 7.23.2 + specifier: ^7.23.3 + version: 7.23.3 '@babel/plugin-transform-export-namespace-from': specifier: ^7.22.11 - version: 7.23.3(@babel/core@7.23.2) + version: 7.23.3(@babel/core@7.23.3) '@babel/plugin-transform-modules-commonjs': - specifier: ^7.23.0 - version: 7.23.0(@babel/core@7.23.2) + specifier: ^7.23.3 + version: 7.23.3(@babel/core@7.23.3) '@changesets/changelog-github': specifier: ^0.4.8 version: 0.4.8 @@ -24,17 +24,23 @@ devDependencies: specifier: ^2.26.2 version: 2.26.2 '@effect/build-utils': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ^0.4.0 + version: 0.4.0 '@effect/docgen': - specifier: ^0.3.0 - version: 0.3.0(fast-check@3.13.2)(tsx@3.14.0)(typescript@5.2.2) + specifier: ^0.3.1 + version: 0.3.1(fast-check@3.13.2)(tsx@4.1.0)(typescript@5.2.2) '@effect/eslint-plugin': specifier: ^0.1.2 version: 0.1.2 '@effect/language-service': specifier: ^0.0.21 version: 0.0.21 + '@effect/platform': + specifier: ^0.28.0 + version: 0.28.0(@effect/schema@0.47.3)(effect@2.0.0-next.54) + '@effect/platform-node': + specifier: ^0.29.0 + version: 0.29.0(@effect/schema@0.47.3)(effect@2.0.0-next.54) '@effect/printer': specifier: ^0.22.1 version: 0.22.1(@effect/typeclass@0.14.1)(effect@2.0.0-next.54) @@ -42,17 +48,17 @@ devDependencies: specifier: ^0.22.1 version: 0.22.1(@effect/typeclass@0.14.1)(effect@2.0.0-next.54) '@effect/schema': - specifier: ^0.47.2 - version: 0.47.2(effect@2.0.0-next.54)(fast-check@3.13.2) + specifier: ^0.47.3 + version: 0.47.3(effect@2.0.0-next.54)(fast-check@3.13.2) '@preconstruct/cli': specifier: ^2.8.1 version: 2.8.1 '@types/chai': - specifier: ^4.3.9 - version: 4.3.9 + specifier: ^4.3.10 + version: 4.3.10 '@types/node': - specifier: ^20.8.10 - version: 20.8.10 + specifier: ^20.9.0 + version: 20.9.0 '@typescript-eslint/eslint-plugin': specifier: ^6.10.0 version: 6.10.0(@typescript-eslint/parser@6.10.0)(eslint@8.53.0)(typescript@5.2.2) @@ -67,7 +73,7 @@ devDependencies: version: 0.34.6(vitest@0.34.6) babel-plugin-annotate-pure-calls: specifier: ^0.4.0 - version: 0.4.0(@babel/core@7.23.2) + version: 0.4.0(@babel/core@7.23.3) effect: specifier: 2.0.0-next.54 version: 2.0.0-next.54 @@ -108,14 +114,14 @@ devDependencies: specifier: ^1.3.4 version: 1.3.4 tsx: - specifier: ^3.14.0 - version: 3.14.0 + specifier: ^4.1.0 + version: 4.1.0 typescript: specifier: ^5.2.2 version: 5.2.2 vite: specifier: ^4.5.0 - version: 4.5.0(@types/node@20.8.10) + version: 4.5.0(@types/node@20.9.0) vitest: specifier: ^0.34.6 version: 0.34.6 @@ -135,14 +141,14 @@ packages: '@jridgewell/trace-mapping': 0.3.20 dev: true - /@babel/cli@7.23.0(@babel/core@7.23.2): + /@babel/cli@7.23.0(@babel/core@7.23.3): resolution: {integrity: sha512-17E1oSkGk2IwNILM4jtfAvgjt+ohmpfBky8aLerUfYZhiPNg7ca+CRCxZn8QDxwNhV/upsc2VHBCqGFIR+iBfA==} engines: {node: '>=6.9.0'} hasBin: true peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 '@jridgewell/trace-mapping': 0.3.20 commander: 4.1.1 convert-source-map: 2.0.0 @@ -163,25 +169,25 @@ packages: chalk: 2.4.2 dev: true - /@babel/compat-data@7.23.2: - resolution: {integrity: sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==} + /@babel/compat-data@7.23.3: + resolution: {integrity: sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==} engines: {node: '>=6.9.0'} dev: true - /@babel/core@7.23.2: - resolution: {integrity: sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==} + /@babel/core@7.23.3: + resolution: {integrity: sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==} engines: {node: '>=6.9.0'} dependencies: '@ampproject/remapping': 2.2.1 '@babel/code-frame': 7.22.13 - '@babel/generator': 7.23.0 + '@babel/generator': 7.23.3 '@babel/helper-compilation-targets': 7.22.15 - '@babel/helper-module-transforms': 7.23.0(@babel/core@7.23.2) + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.3) '@babel/helpers': 7.23.2 - '@babel/parser': 7.23.0 + '@babel/parser': 7.23.3 '@babel/template': 7.22.15 - '@babel/traverse': 7.23.2 - '@babel/types': 7.23.0 + '@babel/traverse': 7.23.3 + '@babel/types': 7.23.3 convert-source-map: 2.0.0 debug: 4.3.4 gensync: 1.0.0-beta.2 @@ -209,11 +215,21 @@ packages: jsesc: 2.5.2 dev: true + /@babel/generator@7.23.3: + resolution: {integrity: sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.3 + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.20 + jsesc: 2.5.2 + dev: true + /@babel/helper-compilation-targets@7.22.15: resolution: {integrity: sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/compat-data': 7.23.2 + '@babel/compat-data': 7.23.3 '@babel/helper-validator-option': 7.22.15 browserslist: 4.22.1 lru-cache: 5.1.1 @@ -247,13 +263,13 @@ packages: '@babel/types': 7.23.0 dev: true - /@babel/helper-module-transforms@7.23.0(@babel/core@7.23.2): - resolution: {integrity: sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==} + /@babel/helper-module-transforms@7.23.3(@babel/core@7.23.3): + resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 '@babel/helper-environment-visitor': 7.22.20 '@babel/helper-module-imports': 7.22.15 '@babel/helper-simple-access': 7.22.5 @@ -270,7 +286,7 @@ packages: resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.23.0 + '@babel/types': 7.23.3 dev: true /@babel/helper-split-export-declaration@7.22.6: @@ -300,8 +316,8 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/template': 7.22.15 - '@babel/traverse': 7.23.2 - '@babel/types': 7.23.0 + '@babel/traverse': 7.23.3 + '@babel/types': 7.23.3 transitivePeerDependencies: - supports-color dev: true @@ -323,55 +339,63 @@ packages: '@babel/types': 7.23.0 dev: true - /@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.23.2): + /@babel/parser@7.23.3: + resolution: {integrity: sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.23.3 + dev: true + + /@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.23.3): resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-transform-export-namespace-from@7.23.3(@babel/core@7.23.2): + /@babel/plugin-transform-export-namespace-from@7.23.3(@babel/core@7.23.3): resolution: {integrity: sha512-yCLhW34wpJWRdTxxWtFZASJisihrfyMOTOQexhVzA78jlU+dH7Dw+zQgcPepQ5F3C6bAIiblZZ+qBggJdHiBAg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 '@babel/helper-plugin-utils': 7.22.5 - '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.23.2) + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.23.3) dev: true - /@babel/plugin-transform-modules-commonjs@7.23.0(@babel/core@7.23.2): - resolution: {integrity: sha512-32Xzss14/UVc7k9g775yMIvkVK8xwKE0DPdP5JTapr3+Z9w4tzeOuLNY6BXDQR6BdnzIlXnCGAzsk/ICHBLVWQ==} + /@babel/plugin-transform-modules-commonjs@7.23.3(@babel/core@7.23.3): + resolution: {integrity: sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.23.2 - '@babel/helper-module-transforms': 7.23.0(@babel/core@7.23.2) + '@babel/core': 7.23.3 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.23.3) '@babel/helper-plugin-utils': 7.22.5 '@babel/helper-simple-access': 7.22.5 dev: true - /@babel/plugin-transform-react-jsx-self@7.22.5(@babel/core@7.23.2): + /@babel/plugin-transform-react-jsx-self@7.22.5(@babel/core@7.23.3): resolution: {integrity: sha512-nTh2ogNUtxbiSbxaT4Ds6aXnXEipHweN9YRgOX/oNXdf0cCrGn/+2LozFa3lnPV5D90MkjhgckCPBrsoSc1a7g==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 '@babel/helper-plugin-utils': 7.22.5 dev: true - /@babel/plugin-transform-react-jsx-source@7.22.5(@babel/core@7.23.2): + /@babel/plugin-transform-react-jsx-source@7.22.5(@babel/core@7.23.3): resolution: {integrity: sha512-yIiRO6yobeEIaI0RTbIr8iAK9FcBHLtZq0S89ZPjDLQXBA4xvghaKqI0etp/tF3htTM0sazJKKLz9oEiGRtu7w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 '@babel/helper-plugin-utils': 7.22.5 dev: true @@ -394,8 +418,8 @@ packages: engines: {node: '>=6.9.0'} dependencies: '@babel/code-frame': 7.22.13 - '@babel/parser': 7.23.0 - '@babel/types': 7.23.0 + '@babel/parser': 7.23.3 + '@babel/types': 7.23.3 dev: true /@babel/traverse@7.23.2: @@ -416,6 +440,24 @@ packages: - supports-color dev: true + /@babel/traverse@7.23.3: + resolution: {integrity: sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.22.13 + '@babel/generator': 7.23.3 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/parser': 7.23.3 + '@babel/types': 7.23.3 + debug: 4.3.4 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: true + /@babel/types@7.23.0: resolution: {integrity: sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==} engines: {node: '>=6.9.0'} @@ -425,6 +467,15 @@ packages: to-fast-properties: 2.0.0 dev: true + /@babel/types@7.23.3: + resolution: {integrity: sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.22.5 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + dev: true + /@bcoe/v8-coverage@0.2.3: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true @@ -648,22 +699,22 @@ packages: resolution: {integrity: sha512-rPwwm/RrFIolz6xHa8Kzpshuwpe+xu/XcEw9iUmRF2tnyIwxxaW7XoFKaQ+GfPju81cKpH4vJeq7/2IizKvyjg==} dev: true - /@effect/build-utils@0.3.1: - resolution: {integrity: sha512-MoyN7zj3Y068rV08SDaOSAyoKXjOSgG2C4O1fHuDmihnti5XLNt0rpCORbPhQhIwzFp14YLYeZihp3ypDmWlQQ==} + /@effect/build-utils@0.4.0: + resolution: {integrity: sha512-1dL2qgzfGECCvMZ2BXhNjAdSDsKS9zpNJne8caxXRI8FtjFNPuj3HYOhYZVmaYfSvzwlC+p9nPBTRaiczoLe8w==} engines: {node: '>=16.17.1'} hasBin: true dev: true - /@effect/docgen@0.3.0(fast-check@3.13.2)(tsx@3.14.0)(typescript@5.2.2): - resolution: {integrity: sha512-QxhLddKwnxZ/BA/1iZGs53yuKorCF6BJmPbs0XXliwbOzgrSIjrSsS+satyis3ChzrtQ3vY1xHNwB6hgSifauA==} + /@effect/docgen@0.3.1(fast-check@3.13.2)(tsx@4.1.0)(typescript@5.2.2): + resolution: {integrity: sha512-4Lc+fKBkxz9rC+XjI3buB3bSNkiBaYT4cxynxt+G4n0pP+BDsztn75Bl+tKlGCTJUROhD2bhadmn3R2ydRslqA==} engines: {node: '>=16.17.1'} hasBin: true peerDependencies: tsx: ^3.14.0 typescript: ^5.2.2 dependencies: - '@effect/platform-node': 0.28.2(@effect/schema@0.47.2)(effect@2.0.0-next.54) - '@effect/schema': 0.47.2(effect@2.0.0-next.54)(fast-check@3.13.2) + '@effect/platform-node': 0.28.4(@effect/schema@0.47.3)(effect@2.0.0-next.54) + '@effect/schema': 0.47.3(effect@2.0.0-next.54)(fast-check@3.13.2) chalk: 5.3.0 doctrine: 3.0.0 effect: 2.0.0-next.54 @@ -672,7 +723,7 @@ packages: prettier: 3.0.3 ts-morph: 20.0.0 tsconfck: 3.0.0(typescript@5.2.2) - tsx: 3.14.0 + tsx: 4.1.0 typescript: 5.2.2 transitivePeerDependencies: - fast-check @@ -690,13 +741,12 @@ packages: resolution: {integrity: sha512-e8vfKbjnbYiyneBincEFS0tzXluopGK77OkVFbPRtUbNDS5tJfb+jiwOQEiqASDsadcZmd+9J9+Q6v/z7GuN2g==} dev: true - /@effect/platform-node@0.28.2(@effect/schema@0.47.2)(effect@2.0.0-next.54): - resolution: {integrity: sha512-KU+ta/gantpbD5lnZBcgTuu7lgxeHQCy0dvvY2iieOG2UMzo8yogBLTTrjlpcbruFsriXEkF5+/y2uzyb17SDg==} - engines: {node: '>=18.0.0'} + /@effect/platform-node@0.28.4(@effect/schema@0.47.3)(effect@2.0.0-next.54): + resolution: {integrity: sha512-0AK4PPfKf2z1pZHMbdWth58A54rfn8C7QSBToDooQ0jft/Ssj1Ys0RIuwb49ZUKGGY+XNLzkBEeJYzdMRdKNIQ==} peerDependencies: effect: 2.0.0-next.54 dependencies: - '@effect/platform': 0.27.2(@effect/schema@0.47.2)(effect@2.0.0-next.54) + '@effect/platform': 0.27.4(@effect/schema@0.47.3)(effect@2.0.0-next.54) busboy: 1.6.0 effect: 2.0.0-next.54 mime: 3.0.0 @@ -704,13 +754,38 @@ packages: - '@effect/schema' dev: true - /@effect/platform@0.27.2(@effect/schema@0.47.2)(effect@2.0.0-next.54): - resolution: {integrity: sha512-Ya8clQi5FWdlrsyPfhsh4zldvDM+NZe50QPANVXByqOpAWTw3XgPNRTMom/Yx464+VTORN6Pc4q82THWat1iOA==} + /@effect/platform-node@0.29.0(@effect/schema@0.47.3)(effect@2.0.0-next.54): + resolution: {integrity: sha512-P5kA72swAni4D38eI5eovGIve0fhMLpWJf/IfMwMbfhGbNRo8cZ+/Q0KYbmMn9ilp2fxdEw6MEX9hu/cqYw56A==} + peerDependencies: + effect: 2.0.0-next.54 + dependencies: + '@effect/platform': 0.28.0(@effect/schema@0.47.3)(effect@2.0.0-next.54) + busboy: 1.6.0 + effect: 2.0.0-next.54 + mime: 3.0.0 + transitivePeerDependencies: + - '@effect/schema' + dev: true + + /@effect/platform@0.27.4(@effect/schema@0.47.3)(effect@2.0.0-next.54): + resolution: {integrity: sha512-SpxQMN4WqV+alpKmjj9Hq8UP15V4GsksADvmxmbKk4Yu2ocrxYlcIUlgIGM0u9WxF34dEjl0skbQoAMo2gewbQ==} + peerDependencies: + '@effect/schema': ^0.47.1 + effect: 2.0.0-next.54 + dependencies: + '@effect/schema': 0.47.3(effect@2.0.0-next.54)(fast-check@3.13.2) + effect: 2.0.0-next.54 + find-my-way: 7.7.0 + path-browserify: 1.0.1 + dev: true + + /@effect/platform@0.28.0(@effect/schema@0.47.3)(effect@2.0.0-next.54): + resolution: {integrity: sha512-zPtn6PMFGJ+hHtCVC9vKSEo4ZSNtMoQhHaC2k+6TNpCT+xaiKTYmwuEImuHf1qdWSQ0py2m5ivo/8kR5ryoeeg==} peerDependencies: '@effect/schema': ^0.47.1 effect: 2.0.0-next.54 dependencies: - '@effect/schema': 0.47.2(effect@2.0.0-next.54)(fast-check@3.13.2) + '@effect/schema': 0.47.3(effect@2.0.0-next.54)(fast-check@3.13.2) effect: 2.0.0-next.54 find-my-way: 7.7.0 path-browserify: 1.0.1 @@ -737,8 +812,8 @@ packages: effect: 2.0.0-next.54 dev: true - /@effect/schema@0.47.2(effect@2.0.0-next.54)(fast-check@3.13.2): - resolution: {integrity: sha512-AwZg9c/NCD2rKEQuWjQUwFMWBi7xokJyL3jid5jtiLB7kjmrtRnHBxleeOEVoSrdBMJu6yXle3HvdHXefyA2jQ==} + /@effect/schema@0.47.3(effect@2.0.0-next.54)(fast-check@3.13.2): + resolution: {integrity: sha512-n8dYPhKqiP6h11ABCljv7EgzX+QfIBAippvsCHcgxmbvtWpDww3QnZ96RRjCwaHYM22ypqbYPGbvBqgNNWTpXg==} peerDependencies: effect: 2.0.0-next.54 fast-check: ^3.13.2 @@ -1040,7 +1115,7 @@ packages: dependencies: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.8.10 + '@types/node': 20.9.0 '@types/yargs': 15.0.17 chalk: 4.1.2 dev: true @@ -1148,7 +1223,7 @@ packages: hasBin: true dependencies: '@babel/code-frame': 7.22.13 - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 '@babel/helper-module-imports': 7.22.15 '@babel/runtime': 7.23.2 '@preconstruct/hook': 0.4.0 @@ -1192,8 +1267,8 @@ packages: /@preconstruct/hook@0.4.0: resolution: {integrity: sha512-a7mrlPTM3tAFJyz43qb4pPVpUx8j8TzZBFsNFqcKcE/sEakNXRlQAuCT4RGZRf9dQiiUnBahzSIWawU4rENl+Q==} dependencies: - '@babel/core': 7.23.2 - '@babel/plugin-transform-modules-commonjs': 7.23.0(@babel/core@7.23.2) + '@babel/core': 7.23.3 + '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.23.3) pirates: 4.0.6 source-map-support: 0.5.21 transitivePeerDependencies: @@ -1317,11 +1392,11 @@ packages: /@types/chai-subset@1.3.3: resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} dependencies: - '@types/chai': 4.3.9 + '@types/chai': 4.3.10 dev: true - /@types/chai@4.3.9: - resolution: {integrity: sha512-69TtiDzu0bcmKQv3yg1Zx409/Kd7r0b5F1PfpYJfSHzLGtB53547V4u+9iqKYsTu/O2ai6KTb0TInNpvuQ3qmg==} + /@types/chai@4.3.10: + resolution: {integrity: sha512-of+ICnbqjmFCiixUnqRulbylyXQrPqIGf/B3Jax1wIF3DvSheysQxAWvqHhZiW3IQrycvokcLcFQlveGp+vyNg==} dev: true /@types/estree@0.0.39: @@ -1382,8 +1457,8 @@ packages: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} dev: true - /@types/node@20.8.10: - resolution: {integrity: sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w==} + /@types/node@20.9.0: + resolution: {integrity: sha512-nekiGu2NDb1BcVofVcEKMIwzlx4NjHlcjhoxxKBNLtz15Y1z7MYf549DFvkHSId02Ax6kGwWntIBPC3l/JZcmw==} dependencies: undici-types: 5.26.5 dev: true @@ -1395,7 +1470,7 @@ packages: /@types/resolve@1.17.1: resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} dependencies: - '@types/node': 20.8.10 + '@types/node': 20.9.0 dev: true /@types/semver@7.5.3: @@ -1690,12 +1765,12 @@ packages: peerDependencies: vite: ^4.2.0 dependencies: - '@babel/core': 7.23.2 - '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.23.2) - '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2) + '@babel/core': 7.23.3 + '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.23.3) + '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.3) '@types/babel__core': 7.20.3 react-refresh: 0.14.0 - vite: 4.5.0(@types/node@20.8.10) + vite: 4.5.0(@types/node@20.9.0) transitivePeerDependencies: - supports-color dev: true @@ -1960,12 +2035,12 @@ packages: engines: {node: '>= 0.4'} dev: true - /babel-plugin-annotate-pure-calls@0.4.0(@babel/core@7.23.2): + /babel-plugin-annotate-pure-calls@0.4.0(@babel/core@7.23.3): resolution: {integrity: sha512-oi4M/PWUJOU9ZyRGoPTfPMqdyMp06jbJAomd3RcyYuzUtBOddv98BqLm96Lucpi2QFoQHkdGQt0ACvw7VzVEQA==} peerDependencies: '@babel/core': ^6.0.0-0 || 7.x dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 dev: true /balanced-match@1.0.2: @@ -2030,7 +2105,7 @@ packages: hasBin: true dependencies: caniuse-lite: 1.0.30001561 - electron-to-chromium: 1.4.576 + electron-to-chromium: 1.4.580 node-releases: 2.0.13 update-browserslist-db: 1.0.13(browserslist@4.22.1) dev: true @@ -2646,8 +2721,8 @@ packages: resolution: {integrity: sha512-qROhKMxlm6fpa90YRfWSgKeelDfhaDq2igPK+pIKupGehiCnZH4vd2qrY71HVZ10qZgXxh0VXpGyDQxJC+EQqw==} dev: true - /electron-to-chromium@1.4.576: - resolution: {integrity: sha512-yXsZyXJfAqzWk1WKryr0Wl0MN2D47xodPvEEwlVePBnhU5E7raevLQR+E6b9JAD3GfL/7MbAL9ZtWQQPcLx7wA==} + /electron-to-chromium@1.4.580: + resolution: {integrity: sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==} dev: true /emoji-regex@8.0.0: @@ -2944,7 +3019,7 @@ packages: resolution: {integrity: sha512-Sy5nJ7tMahHWygM02w2gAO70MX6Lp0ZK0PD9kMpPPGtoQhyS2n1oN7s9zLpDx5pmFDf3woj6LadqztNpJ5RepQ==} engines: {node: '>=12.0.0'} dependencies: - '@babel/core': 7.23.2 + '@babel/core': 7.23.3 '@babel/generator': 7.12.17 '@babel/parser': 7.23.0 '@babel/traverse': 7.23.2 @@ -4166,7 +4241,7 @@ packages: resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 20.8.10 + '@types/node': 20.9.0 merge-stream: 2.0.0 supports-color: 7.2.0 dev: true @@ -6000,8 +6075,9 @@ packages: typescript: 5.2.2 dev: true - /tsx@3.14.0: - resolution: {integrity: sha512-xHtFaKtHxM9LOklMmJdI3BEnQq/D5F73Of2E1GDrITi9sgoVkvIsrQUTY1G8FlmGtA+awCI4EBlTRRYxkL2sRg==} + /tsx@4.1.0: + resolution: {integrity: sha512-u4l17Yd63Wsk2fzNn1wZCmcS9kwJ/2ysl7wuoVggv2hd3NjLA5JQPpyJMXoWSXOwOvoQUzNcu/sf/35HEsnXsg==} + engines: {node: '>=18.0.0'} hasBin: true dependencies: esbuild: 0.18.20 @@ -6189,7 +6265,7 @@ packages: spdx-expression-parse: 3.0.1 dev: true - /vite-node@0.34.6(@types/node@20.8.10): + /vite-node@0.34.6(@types/node@20.9.0): resolution: {integrity: sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==} engines: {node: '>=v14.18.0'} hasBin: true @@ -6199,7 +6275,7 @@ packages: mlly: 1.4.2 pathe: 1.1.1 picocolors: 1.0.0 - vite: 4.5.0(@types/node@20.8.10) + vite: 4.5.0(@types/node@20.9.0) transitivePeerDependencies: - '@types/node' - less @@ -6211,7 +6287,7 @@ packages: - terser dev: true - /vite@4.5.0(@types/node@20.8.10): + /vite@4.5.0(@types/node@20.9.0): resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -6239,7 +6315,7 @@ packages: terser: optional: true dependencies: - '@types/node': 20.8.10 + '@types/node': 20.9.0 esbuild: 0.18.20 postcss: 8.4.31 rollup: 3.29.4 @@ -6278,9 +6354,9 @@ packages: webdriverio: optional: true dependencies: - '@types/chai': 4.3.9 + '@types/chai': 4.3.10 '@types/chai-subset': 1.3.3 - '@types/node': 20.8.10 + '@types/node': 20.9.0 '@vitest/expect': 0.34.6 '@vitest/runner': 0.34.6 '@vitest/snapshot': 0.34.6 @@ -6299,8 +6375,8 @@ packages: strip-literal: 1.3.0 tinybench: 2.5.1 tinypool: 0.7.0 - vite: 4.5.0(@types/node@20.8.10) - vite-node: 0.34.6(@types/node@20.8.10) + vite: 4.5.0(@types/node@20.9.0) + vite-node: 0.34.6(@types/node@20.9.0) why-is-node-running: 2.2.2 transitivePeerDependencies: - less diff --git a/src/Args.ts b/src/Args.ts index f4982f4..50617bb 100644 --- a/src/Args.ts +++ b/src/Args.ts @@ -1,6 +1,7 @@ /** * @since 1.0.0 */ +import type { FileSystem } from "@effect/platform/FileSystem" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { Option } from "effect/Option" @@ -10,6 +11,8 @@ import type { CliConfig } from "./CliConfig.js" import type { HelpDoc } from "./HelpDoc.js" import * as InternalArgs from "./internal/args.js" import type { Parameter } from "./Parameter.js" +import type { Primitive } from "./Primitive.js" +import type { RegularLanguage } from "./RegularLanguage.js" import type { Usage } from "./Usage.js" import type { ValidationError } from "./ValidationError.js" @@ -39,7 +42,7 @@ export interface Args extends Args.Variance, Parameter, Pipeable { validate( args: ReadonlyArray, config: CliConfig - ): Effect, A]> + ): Effect, A]> addDescription(description: string): Args } @@ -61,9 +64,17 @@ export declare namespace Args { * @since 1.0.0 * @category models */ - export interface ArgsConfig { + export interface BaseArgsConfig { readonly name?: string } + + /** + * @since 1.0.0 + * @category models + */ + export interface PathArgsConfig extends BaseArgsConfig { + readonly exists?: Primitive.PathExists + } } /** @@ -166,7 +177,7 @@ export const between: { * @since 1.0.0 * @category constructors */ -export const boolean: (options?: Args.ArgsConfig) => Args = InternalArgs.boolean +export const boolean: (options?: Args.BaseArgsConfig) => Args = InternalArgs.boolean /** * Creates a choice argument. @@ -178,7 +189,7 @@ export const boolean: (options?: Args.ArgsConfig) => Args = InternalArg */ export const choice: ( choices: NonEmptyReadonlyArray<[string, A]>, - config?: Args.ArgsConfig + config?: Args.BaseArgsConfig ) => Args = InternalArgs.choice /** @@ -189,7 +200,27 @@ export const choice: ( * @since 1.0.0 * @category constructors */ -export const date: (config?: Args.ArgsConfig) => Args = InternalArgs.date +export const date: (config?: Args.BaseArgsConfig) => Args = InternalArgs.date + +/** + * Creates a directory argument. + * + * Can optionally provide a custom argument name (defaults to `"directory"`). + * + * @since 1.0.0 + * @category constructors + */ +export const directory: (config?: Args.PathArgsConfig) => Args = InternalArgs.directory + +/** + * Creates a file argument. + * + * Can optionally provide a custom argument name (defaults to `"file"`). + * + * @since 1.0.0 + * @category constructors + */ +export const file: (config?: Args.PathArgsConfig) => Args = InternalArgs.file /** * Creates a floating point number argument. @@ -199,7 +230,7 @@ export const date: (config?: Args.ArgsConfig) => Args = Interna * @since 1.0.0 * @category constructors */ -export const float: (config?: Args.ArgsConfig) => Args = InternalArgs.float +export const float: (config?: Args.BaseArgsConfig) => Args = InternalArgs.float /** * Creates an integer argument. @@ -209,7 +240,7 @@ export const float: (config?: Args.ArgsConfig) => Args = InternalArgs.fl * @since 1.0.0 * @category constructors */ -export const integer: (config?: Args.ArgsConfig) => Args = InternalArgs.integer +export const integer: (config?: Args.BaseArgsConfig) => Args = InternalArgs.integer /** * @since 1.0.0 @@ -246,6 +277,16 @@ export const mapTryCatch: { */ export const none: Args = InternalArgs.none +/** + * Creates a path argument. + * + * Can optionally provide a custom argument name (defaults to `"path"`). + * + * @since 1.0.0 + * @category constructors + */ +export const path: (config?: Args.PathArgsConfig) => Args = InternalArgs.path + /** * @since 1.0.0 * @category combinators @@ -267,4 +308,13 @@ export const repeatedAtLeastOnce: (self: Args) => Args Args = InternalArgs.text +export const text: (config?: Args.BaseArgsConfig) => Args = InternalArgs.text + +/** + * Returns a `RegularLanguage` whose accepted language is equivalent to the language accepted by the provided `Args`. + * + * @since 1.0.0 + * @category combinators + */ +export const toRegularLanguage: (self: Args) => RegularLanguage = + InternalArgs.toRegularLanguage diff --git a/src/CliApp.ts b/src/CliApp.ts index 557a4c3..7d9ad16 100644 --- a/src/CliApp.ts +++ b/src/CliApp.ts @@ -1,6 +1,9 @@ /** * @since 1.0.0 */ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" import type { Effect } from "effect/Effect" import type { Command } from "./Command.js" import type { HelpDoc } from "./HelpDoc.js" @@ -22,6 +25,17 @@ export interface CliApp { readonly footer: HelpDoc } +/** + * @since 1.0.0 + */ +export declare namespace CliApp { + /** + * @since 1.0.0 + * @category models + */ + export type Environment = CommandExecutor | FileSystem | Path +} + /** * @since 1.0.0 * @category constructors @@ -43,11 +57,11 @@ export const make: ( export const run: { ( args: ReadonlyArray, - f: (a: A) => Effect - ): (self: CliApp) => Effect + execute: (a: A) => Effect + ): (self: CliApp) => Effect ( self: CliApp, args: ReadonlyArray, - f: (a: A) => Effect - ): Effect + execute: (a: A) => Effect + ): Effect } = InternalCliApp.run diff --git a/src/Command.ts b/src/Command.ts index e17e466..9600333 100644 --- a/src/Command.ts +++ b/src/Command.ts @@ -1,6 +1,7 @@ /** * @since 1.0.0 */ +import type { FileSystem } from "@effect/platform/FileSystem" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { HashMap } from "effect/HashMap" @@ -15,6 +16,7 @@ import * as InternalCommand from "./internal/command.js" import type { Options } from "./Options.js" import type { Named } from "./Parameter.js" import type { Prompt } from "./Prompt.js" +import type { RegularLanguage } from "./RegularLanguage.js" import type { Terminal } from "./Terminal.js" import type { Usage } from "./Usage.js" import type { ValidationError } from "./ValidationError.js" @@ -47,7 +49,7 @@ export interface Command extends Command.Variance, Named, Pipeable { parse( args: ReadonlyArray, config: CliConfig - ): Effect> + ): Effect> } /** @@ -190,6 +192,18 @@ export const subcommands: { > } = InternalCommand.subcommands +/** + * Returns a `RegularLanguage` whose accepted language is equivalent to the + * language accepted by the provided `Command`. + * + * @since 1.0.0 + * @category combinators + */ +export const toRegularLanguage: { + (allowAlias: boolean): (self: Command) => RegularLanguage + (self: Command, allowAlias: boolean): RegularLanguage +} = InternalCommand.toRegularLanguage + /** * @since 1.0.0 * @category combinators diff --git a/src/Compgen.ts b/src/Compgen.ts new file mode 100644 index 0000000..b1fca8c --- /dev/null +++ b/src/Compgen.ts @@ -0,0 +1,39 @@ +/** + * @since 1.0.0 + */ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { Tag } from "effect/Context" +import type { Effect } from "effect/Effect" +import type { Layer } from "effect/Layer" +import * as InternalCompgen from "./internal/compgen.js" + +/** + * `Compgen` simplifies the process of calling Bash's built-in `compgen` command. + * + * @since 1.0.0 + * @category models + */ +export interface Compgen { + completeFileNames(word: string): Effect> + completeDirectoryNames(word: string): Effect> +} + +/** + * @since 1.0.0 + * @category context + */ +export const Compgen: Tag = InternalCompgen.Tag + +/** + * @since 1.0.0 + * @category context + */ +export const LiveCompgen: Layer = InternalCompgen.LiveCompgen + +/** + * @since 1.0.0 + * @category context + */ +export const TestCompgen: (workingDirectory: string) => Layer = + InternalCompgen.TestCompgen diff --git a/src/Completion.ts b/src/Completion.ts new file mode 100644 index 0000000..c2fc380 --- /dev/null +++ b/src/Completion.ts @@ -0,0 +1,35 @@ +/** + * @since 1.0.0 + */ +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import type { Effect } from "effect/Effect" +import type { NonEmptyReadonlyArray } from "effect/ReadonlyArray" +import type { CliConfig } from "./CliConfig.js" +import type { Command } from "./Command.js" +import type { Compgen } from "./Compgen.js" +import * as InternalCompletion from "./internal/completion.js" +import type { ShellType } from "./ShellType.js" + +/** + * @since 1.0.0 + * @category completions + */ +export const getCompletions: ( + words: ReadonlyArray, + index: number, + command: Command, + config: CliConfig, + compgen: Compgen +) => Effect> = InternalCompletion.getCompletions + +/** + * @since 1.0.0 + * @category completions + */ +export const getCompletionScript: ( + pathToExecutable: string, + programNames: NonEmptyReadonlyArray, + shellType: ShellType, + path: Path +) => string = InternalCompletion.getCompletionScript diff --git a/src/Exists.ts b/src/Exists.ts deleted file mode 100644 index b6d103e..0000000 --- a/src/Exists.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @since 1.0.0 - */ - -/** - * Describes whether the command-line application wants a file/directory to - * exist or not exist. - * - * @since 1.0.0 - */ -export type Exists = "yes" | "no" | "either" diff --git a/src/Options.ts b/src/Options.ts index 10e353b..0a13415 100644 --- a/src/Options.ts +++ b/src/Options.ts @@ -1,6 +1,7 @@ /** * @since 1.0.0 */ +import type { FileSystem } from "@effect/platform/FileSystem" import type { Effect } from "effect/Effect" import type { Either } from "effect/Either" import type { HashMap } from "effect/HashMap" @@ -11,6 +12,8 @@ import type { CliConfig } from "./CliConfig.js" import type { HelpDoc } from "./HelpDoc.js" import * as InternalOptions from "./internal/options.js" import type { Input, Parameter } from "./Parameter.js" +import type { Primitive } from "./Primitive.js" +import type { RegularLanguage } from "./RegularLanguage.js" import type { Usage } from "./Usage.js" import type { ValidationError } from "./ValidationError.js" @@ -37,7 +40,7 @@ export interface Options extends Options.Variance, Parameter, Pipeable { validate( args: HashMap>, config: CliConfig - ): Effect + ): Effect /** @internal */ modifySingle(f: <_>(single: InternalOptions.Single<_>) => InternalOptions.Single<_>): Options } @@ -60,11 +63,19 @@ export declare namespace Options { * @since 1.0.0 * @category models */ - export interface BooleanOptionConfig { + export interface BooleanOptionsConfig { readonly ifPresent?: boolean readonly negationNames?: NonEmptyReadonlyArray readonly aliases?: NonEmptyReadonlyArray } + + /** + * @since 1.0.0 + * @category models + */ + export interface PathOptionsConfig { + readonly exists?: Primitive.PathExists + } } /** @@ -140,7 +151,7 @@ export const all: < * @since 1.0.0 * @category constructors */ -export const boolean: (name: string, options?: Options.BooleanOptionConfig) => Options = +export const boolean: (name: string, options?: Options.BooleanOptionsConfig) => Options = InternalOptions.boolean /** @@ -204,6 +215,24 @@ export const choiceWithValue: >( */ export const date: (name: string) => Options = InternalOptions.date +/** + * Creates a parameter expecting path to a directory. + * + * @since 1.0.0 + * @category constructors + */ +export const directory: (name: string, config: Options.PathOptionsConfig) => Options = + InternalOptions.directory + +/** + * Creates a parameter expecting path to a file. + * + * @since 1.0.0 + * @category constructors + */ +export const file: (name: string, config: Options.PathOptionsConfig) => Options = + InternalOptions.file + /** * @since 1.0.0 * @category constructors @@ -308,6 +337,16 @@ export const orElseEither: { (self: Options, that: Options): Options> } = InternalOptions.orElseEither +/** + * Returns a `RegularLanguage` whose accepted language is equivalent to the language accepted by the provided + * `Options`. + * + * @since 1.0.0 + * @category combinators + */ +export const toRegularLanguage: (self: Options) => RegularLanguage = + InternalOptions.toRegularLanguage + /** * @since 1.0.0 * @category combinators @@ -318,12 +357,20 @@ export const validate: { config: CliConfig ): ( self: Options - ) => Effect, ReadonlyArray, A]> + ) => Effect< + FileSystem, + ValidationError, + readonly [Option, ReadonlyArray, A] + > ( self: Options, args: ReadonlyArray, config: CliConfig - ): Effect, ReadonlyArray, A]> + ): Effect< + FileSystem, + ValidationError, + readonly [Option, ReadonlyArray, A] + > } = InternalOptions.validate /** diff --git a/src/Parameter.ts b/src/Parameter.ts index 1f20c8f..14d33fa 100644 --- a/src/Parameter.ts +++ b/src/Parameter.ts @@ -1,6 +1,7 @@ /** * @since 1.0.0 */ +import type { FileSystem } from "@effect/platform/FileSystem" import type { Effect } from "effect/Effect" import type { HashSet } from "effect/HashSet" import type { CliConfig } from "./CliConfig.js" @@ -26,7 +27,10 @@ export interface Parameter { * @since 1.0.0 */ export interface Input extends Parameter { - isValid(input: string, config: CliConfig): Effect> + isValid( + input: string, + config: CliConfig + ): Effect> parse( args: ReadonlyArray, config: CliConfig diff --git a/src/Primitive.ts b/src/Primitive.ts index 53b461f..2474440 100644 --- a/src/Primitive.ts +++ b/src/Primitive.ts @@ -1,6 +1,7 @@ /** * @since 1.0.0 */ +import type { FileSystem } from "@effect/platform/FileSystem" import type { Effect } from "effect/Effect" import type { Option } from "effect/Option" import type { Pipeable } from "effect/Pipeable" @@ -33,7 +34,7 @@ export interface Primitive extends Primitive.Variance { get typeName(): string get help(): Span get choices(): Option - validate(value: Option, config: CliConfig): Effect + validate(value: Option, config: CliConfig): Effect } /** @@ -50,6 +51,18 @@ export declare namespace Primitive { } } + /** + * @since 1.0.0 + * @category models + */ + export type PathExists = "yes" | "no" | "either" + + /** + * @since 1.0.0 + * @category models + */ + export type PathType = "file" | "directory" | "either" + /** * @since 1.0.0 * @category models diff --git a/src/RegularLanguage.ts b/src/RegularLanguage.ts new file mode 100644 index 0000000..d56d5bd --- /dev/null +++ b/src/RegularLanguage.ts @@ -0,0 +1,413 @@ +/** + * @since 1.0.0 + */ +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Case } from "effect/Data" +import type { Effect } from "effect/Effect" +import type { Option } from "effect/Option" +import type { Pipeable } from "effect/Pipeable" +import type { CliConfig } from "./CliConfig.js" +import * as InternalRegularLanguage from "./internal/regularLanguage.js" +import type { Primitive } from "./Primitive.js" + +/** + * @since 1.0.0 + * @category symbols + */ +export const RegularLanguageTypeId: unique symbol = InternalRegularLanguage.RegularLanguageTypeId + +/** + * @since 1.0.0 + * @category symbols + */ +export type RegularLanguageTypeId = typeof RegularLanguageTypeId + +/** + * `RegularLanguage` is an implementation of "Parsing With Derivatives" (Might + * et al. 2011) that is used for CLI tab completion. Unlike your usual regular + * languages that are sets of strings of symbols, our regular languages are sets + * of lists of tokens, where tokens can be strings or `Primitive` instances. (If + * you think about it, `Primitive.validate` is an intensional definition of a + * set of strings.) + * + * @since 1.0.0 + * @category models + */ +export type RegularLanguage = + | Empty + | Epsilon + | StringToken + | AnyStringToken + | PrimitiveToken + | Cat + | Alt + | Repeat + | Permutation + +/** + * @since 1.0.0 + */ +export declare namespace RegularLanguage { + /** + * @since 1.0.0 + * @category models + */ + export interface Proto { + readonly [RegularLanguageTypeId]: (_: never) => never + } + + /** + * @since 1.0.0 + * @category models + */ + export interface RepetitionConfiguration { + readonly min?: number + readonly max?: number + } +} + +/** + * The `Empty` language (`∅`) accepts no strings. + * + * @since 1.0.0 + * @category models + */ +export interface Empty extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "Empty" +} + +/** + * The `Epsilon` language (`ε`) accepts only the empty string. + * + * @since 1.0.0 + * @category models + */ +export interface Epsilon extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "Epsilon" +} + +/** + * A `StringToken` language represents the regular language that contains only + * `value`. + * + * @since 1.0.0 + * @category models + */ +export interface StringToken extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "StringToken" + readonly value: string +} + +/** + * `AnyStringToken` represents the set of all strings. For tab completion + * purposes, this is used to represent the name of the executable (It may be + * aliased or renamed to be different). + * + * @since 1.0.0 + * @category models + */ +export interface AnyStringToken extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "AnyStringToken" +} + +/** + * A `PrimitiveToken` language represents the regular language containing any + * strings `s` where `value.validate(s)` succeeds. + * + * @since 1.0.0 + * @category models + */ +export interface PrimitiveToken extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "PrimitiveToken" + readonly primitive: Primitive +} + +/** + * `Cat` represents the concatenation of two regular languages. + * + * @since 1.0.0 + * @category models + */ +export interface Cat extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "Cat" + readonly left: RegularLanguage + readonly right: RegularLanguage +} + +/** + * `Alt` represents the union of two regular languages. We call it "Alt" for + * consistency with the names used in the "Parsing With Derivatives" paper. + * + * @since 1.0.0 + * @category models + */ +export interface Alt extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "Alt" + readonly left: RegularLanguage + readonly right: RegularLanguage +} + +/** + * `Repeat` represents the repetition of `language`. The number of repetitions + * can be bounded via `min` and `max`. Setting `max=None` represents the + * "Kleene star" of `language`. + * + * @since 1.0.0 + * @category models + */ +export interface Repeat extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "Repeat" + readonly language: RegularLanguage + readonly min: Option + readonly max: Option +} + +/** + * Permutation is like `Cat`, but it is a commutative monoid. A + * `Permutation(a_1, a_2, ..., a_{k})` is equivalent to the following language: + * + * ``` + * a2 ~ Permutation(a_1, a_3, ..., a_k) | a1 ~ Permutation(a_2, a_3, ..., a_k) | ... ak ~ Permutation(a_1, a_2, ..., a_{k - 1}) + * ``` + * + * So when we calculate its derivative, we apply the above "desugaring" + * transformation, then compute the derivative as usual. + * + * @since 1.0.0 + * @category models + */ +export interface Permutation extends RegularLanguage.Proto, Case, Pipeable { + readonly _tag: "Permutation" + readonly values: ReadonlyArray +} + +/** + * @since 1.0.0 + * @category refinements + */ +export const isRegularLanguage: (u: unknown) => u is RegularLanguage = + InternalRegularLanguage.isRegularLanguage + +/** + * @since 1.0.0 + * @category refinements + */ +export const isEmpty: (self: RegularLanguage) => self is Empty = InternalRegularLanguage.isEmpty + +/** + * @since 1.0.0 + * @category refinements + */ +export const isEpsilon: (self: RegularLanguage) => self is Epsilon = + InternalRegularLanguage.isEpsilon + +/** + * @since 1.0.0 + * @category refinements + */ +export const isStringToken: (self: RegularLanguage) => self is StringToken = + InternalRegularLanguage.isStringToken + +/** + * @since 1.0.0 + * @category refinements + */ +export const isAnyStringToken: (self: RegularLanguage) => self is AnyStringToken = + InternalRegularLanguage.isAnyStringToken + +/** + * @since 1.0.0 + * @category refinements + */ +export const isPrimitiveToken: (self: RegularLanguage) => self is PrimitiveToken = + InternalRegularLanguage.isPrimitiveToken + +/** + * @since 1.0.0 + * @category refinements + */ +export const isCat: (self: RegularLanguage) => self is Cat = InternalRegularLanguage.isCat + +/** + * @since 1.0.0 + * @category refinements + */ +export const isAlt: (self: RegularLanguage) => self is Alt = InternalRegularLanguage.isAlt + +/** + * @since 1.0.0 + * @category refinements + */ +export const isRepeat: (self: RegularLanguage) => self is Repeat = InternalRegularLanguage.isRepeat + +/** + * @since 1.0.0 + * @category refinements + */ +export const isPermutation: (self: RegularLanguage) => self is Permutation = + InternalRegularLanguage.isPermutation + +/** + * @since 1.0.0 + * @category constructors + */ +export const anyString: RegularLanguage = InternalRegularLanguage.anyString + +/** + * @since 1.0.0 + * @category combinators + */ +export const concat: { + (that: string | RegularLanguage): (self: RegularLanguage) => RegularLanguage + (self: RegularLanguage, that: string | RegularLanguage): RegularLanguage +} = InternalRegularLanguage.concat + +/** + * Checks to see if the input token list is a member of the language. + * + * Returns `true` if and only if `tokens` is in the language. + * + * @since 1.0.0 + * @category combinators + */ +export const contains: { + ( + tokens: ReadonlyArray, + config: CliConfig + ): (self: RegularLanguage) => Effect + ( + self: RegularLanguage, + tokens: ReadonlyArray, + config: CliConfig + ): Effect +} = InternalRegularLanguage.contains + +/** + * Calculate the Brzozowski derivative of this language with respect to the given string. This is an effectful + * function because it can call PrimType.validate (e.g., when validating file paths, etc.). + * + * @param token + * The string to use for calculation of the Brzozowski derivative. + * @return + * Brzozowski derivative wrapped in an UIO instance. + * + * @since 1.0.0 + * @category combinators + */ +export const derive: { + ( + token: string, + config: CliConfig + ): (self: RegularLanguage) => Effect + ( + self: RegularLanguage, + token: string, + config: CliConfig + ): Effect +} = InternalRegularLanguage.derive + +/** + * @since 1.0.0 + * @category constructors + */ +export const empty: RegularLanguage = InternalRegularLanguage.empty + +/** + * @since 1.0.0 + * @category constructors + */ +export const epsilon: RegularLanguage = InternalRegularLanguage.epsilon + +/** + * Returns a set consisting of the first token of all strings in this language + * that are useful for CLI tab completion. For infinite or unwieldly languages, + * it is perfectly fine to return the empty set: This will simply not display + * any completions to the user. + * + * If you'd like the cursor to advance to the next word when tab completion + * unambiguously matches the prefix to a token, append a space (`" "`) character + * to the end of the returned token. Otherwise, the cursor will skip to the end + * of the completed token in the terminal. + * + * Some examples of different use cases: + * 1. Completing file/directory names: + * - Append a space to the ends of file names (e.g., `"bippy.pdf"`). This + * is because we want the cursor to jump to the next argument position if + * tab completion unambiguously succeeds. + * + * - Do not append a space to the end of a directory name (e.g., `"foo/"`). + * This is because we want the user to be able to press tab again to + * gradually complete a lengthy file path. + * + * - Append a space to the ends of string tokens. + * + * You may be asking why we don't try to use the `-o nospace` setting of + * `compgen` and `complete`. The answer is they appear to be all or nothing: For + * a given tab completion execution, you have to choose one behavior or the + * other. This does not work well when completing both file names and directory + * names at the same time. + * + * @since 1.0.0 + * @category combinators + */ +export const firstTokens = InternalRegularLanguage.firstTokens + +/** + * This is the delta (`δ`) predicate from "Parsing With Derivatives", indicating + * whether this language contains the empty string. + * + * Returns `true` if and only if this language contains the empty string. + * + * @since 1.0.0 + * @category combinators + */ +export const isNullable: (self: RegularLanguage) => boolean = InternalRegularLanguage.isNullable + +/** + * @since 1.0.0 + * @category combinators + */ +export const optional: (self: RegularLanguage) => RegularLanguage = InternalRegularLanguage.optional + +/** + * @since 1.0.0 + * @category combinators + */ +export const orElse: { + (that: string | RegularLanguage): (self: RegularLanguage) => RegularLanguage + (self: RegularLanguage, that: string | RegularLanguage): RegularLanguage +} = InternalRegularLanguage.orElse + +/** + * @since 1.0.0 + * @category constructors + */ +export const primitive: (primitive: Primitive) => RegularLanguage = + InternalRegularLanguage.primitive + +/** + * @since 1.0.0 + * @category constructors + */ +export const permutation: (values: ReadonlyArray) => RegularLanguage = + InternalRegularLanguage.permutation + +/** + * @since 1.0.0 + * @category combinators + */ +export const repeated: { + ( + params?: Partial + ): (self: RegularLanguage) => RegularLanguage + ( + self: RegularLanguage, + params?: Partial + ): RegularLanguage +} = InternalRegularLanguage.repeated + +/** + * @since 1.0.0 + * @category constructors + */ +export const string: (value: string) => RegularLanguage = InternalRegularLanguage.string diff --git a/src/ShellType.ts b/src/ShellType.ts index b7dfd2b..2fe35cb 100644 --- a/src/ShellType.ts +++ b/src/ShellType.ts @@ -8,7 +8,7 @@ import type { Options } from "./Options.js" * @since 1.0.0 * @category models */ -export type ShellType = Bash | ZShell +export type ShellType = Bash | Fish | Zsh /** * @since 1.0.0 @@ -22,8 +22,16 @@ export interface Bash { * @since 1.0.0 * @category models */ -export interface ZShell { - readonly _tag: "ZShell" +export interface Fish { + readonly _tag: "Fish" +} + +/** + * @since 1.0.0 + * @category models + */ +export interface Zsh { + readonly _tag: "Zsh" } /** @@ -36,7 +44,13 @@ export const bash: ShellType = InternalShellType.bash * @since 1.0.0 * @category constructors */ -export const zShell: ShellType = InternalShellType.zShell +export const fish: ShellType = InternalShellType.fish + +/** + * @since 1.0.0 + * @category constructors + */ +export const zsh: ShellType = InternalShellType.zsh /** * @since 1.0.0 diff --git a/src/Terminal.ts b/src/Terminal.ts index 4cabaca..d5fc1d7 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -73,4 +73,4 @@ export const Terminal: Context.Tag = internal.Tag * @since 1.0.0 * @category context */ -export const layer: Layer.Layer = internal.layer +export const LiveTerminal: Layer.Layer = internal.LiveTerminal diff --git a/src/index.ts b/src/index.ts index 6249551..b9f3543 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,7 +36,12 @@ export * as CommandDirective from "./CommandDirective.js" /** * @since 1.0.0 */ -export * as Exists from "./Exists.js" +export * as Compgen from "./Compgen.js" + +/** + * @since 1.0.0 + */ +export * as Completion from "./Completion.js" /** * @since 1.0.0 @@ -63,6 +68,11 @@ export * as Primitive from "./Primitive.js" */ export * as Prompt from "./Prompt.js" +/** + * @since 1.0.0 + */ +export * as RegularLanguage from "./RegularLanguage.js" + /** * @since 1.0.0 */ diff --git a/src/internal/args.ts b/src/internal/args.ts index be82623..9a30546 100644 --- a/src/internal/args.ts +++ b/src/internal/args.ts @@ -1,3 +1,4 @@ +import type * as FileSystem from "@effect/platform/FileSystem" import * as Effect from "effect/Effect" import * as Either from "effect/Either" import { dual } from "effect/Function" @@ -9,11 +10,13 @@ import type * as CliConfig from "../CliConfig.js" import type * as HelpDoc from "../HelpDoc.js" import type * as Parameter from "../Parameter.js" import type * as Primitive from "../Primitive.js" +import type * as RegularLanguage from "../RegularLanguage.js" import type * as Usage from "../Usage.js" import type * as ValidationError from "../ValidationError.js" import * as InternalHelpDoc from "./helpDoc.js" import * as InternalSpan from "./helpDoc/span.js" import * as InternalPrimitive from "./primitive.js" +import * as InternalRegularLanguage from "./regularLanguage.js" import * as InternalUsage from "./usage.js" import * as InternalValidationError from "./validationError.js" @@ -117,7 +120,7 @@ export class Single implements Args.Args, Parameter.Input { isValid( input: string, config: CliConfig.CliConfig - ): Effect.Effect> { + ): Effect.Effect> { const args = ReadonlyArray.of(input) return this.validate(args, config).pipe(Effect.as(args)) } @@ -125,7 +128,11 @@ export class Single implements Args.Args, Parameter.Input { validate( args: ReadonlyArray, config: CliConfig.CliConfig - ): Effect.Effect, A]> { + ): Effect.Effect< + FileSystem.FileSystem, + ValidationError.ValidationError, + readonly [ReadonlyArray, A] + > { return Effect.suspend(() => { if (ReadonlyArray.isNonEmptyReadonlyArray(args)) { const head = ReadonlyArray.headNonEmpty(args) @@ -226,7 +233,7 @@ export class Both implements Args.Args { args: ReadonlyArray, config: CliConfig.CliConfig ): Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, readonly [ReadonlyArray, readonly [A, B]] > { @@ -306,7 +313,7 @@ export class Variadic implements Args.Args>, Parameter.Input args: ReadonlyArray, config: CliConfig.CliConfig ): Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, readonly [ReadonlyArray, ReadonlyArray] > { @@ -316,7 +323,7 @@ export class Variadic implements Args.Args>, Parameter.Input args: ReadonlyArray, acc: ReadonlyArray ): Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, readonly [ReadonlyArray, ReadonlyArray] > => { @@ -350,7 +357,7 @@ export class Variadic implements Args.Args>, Parameter.Input isValid( input: string, config: CliConfig.CliConfig - ): Effect.Effect> { + ): Effect.Effect> { const args = input.split(" ") return this.validate(args, config).pipe(Effect.as(args)) } @@ -401,7 +408,11 @@ export class Map implements Args.Args { validate( args: ReadonlyArray, config: CliConfig.CliConfig - ): Effect.Effect, B]> { + ): Effect.Effect< + FileSystem.FileSystem, + ValidationError.ValidationError, + readonly [ReadonlyArray, B] + > { return this.args.validate(args, config).pipe( Effect.flatMap(([leftover, a]) => Either.match(this.f(a), { @@ -481,32 +492,53 @@ export const all: < } /** @internal */ -export const boolean = (config: Args.Args.ArgsConfig = {}): Args.Args => +export const boolean = (config: Args.Args.BaseArgsConfig = {}): Args.Args => new Single(Option.fromNullable(config.name), InternalPrimitive.boolean(Option.none())) /** @internal */ export const choice = ( choices: ReadonlyArray.NonEmptyReadonlyArray<[string, A]>, - config: Args.Args.ArgsConfig = {} + config: Args.Args.BaseArgsConfig = {} ): Args.Args => new Single(Option.fromNullable(config.name), InternalPrimitive.choice(choices)) /** @internal */ -export const date = (config: Args.Args.ArgsConfig = {}): Args.Args => +export const date = (config: Args.Args.BaseArgsConfig = {}): Args.Args => new Single(Option.fromNullable(config.name), InternalPrimitive.date) /** @internal */ -export const float = (config: Args.Args.ArgsConfig = {}): Args.Args => +export const directory = (config: Args.Args.PathArgsConfig = {}): Args.Args => + new Single( + Option.fromNullable(config.name), + InternalPrimitive.path("directory", config.exists || "either") + ) + +/** @internal */ +export const file = (config: Args.Args.PathArgsConfig = {}): Args.Args => + new Single( + Option.fromNullable(config.name), + InternalPrimitive.path("file", config.exists || "either") + ) + +/** @internal */ +export const float = (config: Args.Args.BaseArgsConfig = {}): Args.Args => new Single(Option.fromNullable(config.name), InternalPrimitive.float) /** @internal */ -export const integer = (config: Args.Args.ArgsConfig = {}): Args.Args => +export const integer = (config: Args.Args.BaseArgsConfig = {}): Args.Args => new Single(Option.fromNullable(config.name), InternalPrimitive.integer) /** @internal */ export const none: Args.Args = new Empty() /** @internal */ -export const text = (config: Args.Args.ArgsConfig = {}): Args.Args => +export const path = (config: Args.Args.PathArgsConfig = {}): Args.Args => + new Single( + Option.fromNullable(config.name), + InternalPrimitive.path("either", config.exists || "either") + ) + +/** @internal */ +export const text = (config: Args.Args.BaseArgsConfig = {}): Args.Args => new Single(Option.fromNullable(config.name), InternalPrimitive.text) // ============================================================================= @@ -601,6 +633,37 @@ export const repeatedAtLeastOnce = ( throw new Error(`${message} is not respecting the required minimum of 1`) }) +/** @internal */ +export const toRegularLanguage = ( + self: Args.Args +): RegularLanguage.RegularLanguage => { + if (isEmpty(self)) { + return InternalRegularLanguage.epsilon + } + if (isSingle(self)) { + return InternalRegularLanguage.primitive(self.primitiveType) + } + if (isBoth(self)) { + return InternalRegularLanguage.concat( + toRegularLanguage(self.left), + toRegularLanguage(self.right) + ) + } + if (isVariadic(self)) { + return InternalRegularLanguage.repeated(toRegularLanguage(self.value), { + min: Option.getOrUndefined(self.min), + max: Option.getOrUndefined(self.max) + }) + } + if (isMap(self)) { + return toRegularLanguage(self.args) + } + throw new Error( + "[BUG]: Args.toRegularLanguage - received unrecognized " + + `args type ${JSON.stringify(self)}` + ) +} + // ============================================================================= // Internals // ============================================================================= diff --git a/src/internal/cliApp.ts b/src/internal/cliApp.ts index 9818052..cbcac9d 100644 --- a/src/internal/cliApp.ts +++ b/src/internal/cliApp.ts @@ -1,18 +1,27 @@ +import type * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" import * as Console from "effect/Console" import * as Context from "effect/Context" import * as Effect from "effect/Effect" import { dual, pipe } from "effect/Function" +import * as Layer from "effect/Layer" import * as Option from "effect/Option" +import * as Order from "effect/Order" import * as ReadonlyArray from "effect/ReadonlyArray" +import * as ReadonlyRecord from "effect/ReadonlyRecord" +import { unify } from "effect/Unify" import type * as BuiltInOptions from "../BuiltInOptions.js" import type * as CliApp from "../CliApp.js" import type * as CliConfig from "../CliConfig.js" import type * as Command from "../Command.js" +import type * as Compgen from "../Compgen.js" import type * as HelpDoc from "../HelpDoc.js" import type * as Span from "../HelpDoc/Span.js" import type * as ValidationError from "../ValidationError.js" import * as InternalCliConfig from "./cliConfig.js" import * as InternalCommand from "./command.js" +import * as InternalCompgen from "./compgen.js" +import * as InternalCompletion from "./completion.js" import * as InternalHelpDoc from "./helpDoc.js" import * as InternalSpan from "./helpDoc/span.js" import * as InternalTerminal from "./terminal.js" @@ -41,34 +50,46 @@ export const make = (config: { // Combinators // ============================================================================= +const MainLive = Layer.merge(InternalTerminal.LiveTerminal, InternalCompgen.LiveCompgen) + /** @internal */ export const run = dual< ( args: ReadonlyArray, - f: (a: A) => Effect.Effect - ) => (self: CliApp.CliApp) => Effect.Effect, + execute: (a: A) => Effect.Effect + ) => ( + self: CliApp.CliApp + ) => Effect.Effect, ( self: CliApp.CliApp, args: ReadonlyArray, - f: (a: A) => Effect.Effect - ) => Effect.Effect + execute: (a: A) => Effect.Effect + ) => Effect.Effect >(3, ( self: CliApp.CliApp, args: ReadonlyArray, - f: (a: A) => Effect.Effect -): Effect.Effect => - Effect.contextWithEffect((context: Context.Context) => { + execute: (a: A) => Effect.Effect +): Effect.Effect => + Effect.gen(function*(_) { + const context = yield* _(Effect.context()) + + // Attempt to parse the CliConfig from the environment, falling back to the + // default CliConfig if none was provided const config = Option.getOrElse( Context.getOption(context, InternalCliConfig.Tag), () => InternalCliConfig.defaultConfig ) + + // Prefix the command name to the command line arguments const prefixedArgs = ReadonlyArray.appendAll(prefixCommand(self.command), args) - return self.command.parse(prefixedArgs, config).pipe(Effect.matchEffect({ + + // Handle the command + return yield* _(Effect.matchEffect(self.command.parse(prefixedArgs, config), { onFailure: (e) => Effect.zipRight(printDocs(e.error), Effect.fail(e)), - onSuccess: (directive): Effect.Effect => { + onSuccess: unify((directive) => { switch (directive._tag) { case "UserDefined": { - return f(directive.value) + return execute(directive.value) } case "BuiltIn": { return handleBuiltInOption(self, directive.option, config).pipe( @@ -80,9 +101,9 @@ export const run = dual< ) } } - } + }) })) - }).pipe(Effect.provide(InternalTerminal.layer))) + }).pipe(Effect.provide(MainLive))) // ============================================================================= // Internals @@ -95,47 +116,85 @@ const handleBuiltInOption = ( self: CliApp.CliApp, builtIn: BuiltInOptions.BuiltInOptions, config: CliConfig.CliConfig -): Effect.Effect => { - switch (builtIn._tag) { - case "ShowHelp": { - const banner = InternalHelpDoc.h1(InternalSpan.code(self.name)) - const header = InternalHelpDoc.p(InternalSpan.concat( - InternalSpan.text(`${self.name} ${self.version} -- `), - self.summary - )) - const usage = InternalHelpDoc.sequence( - InternalHelpDoc.h1("USAGE"), - pipe( - InternalUsage.enumerate(builtIn.usage, config), - ReadonlyArray.map((span) => - InternalHelpDoc.p(InternalSpan.concat(InternalSpan.text("$ "), span)) - ), - ReadonlyArray.reduceRight( - InternalHelpDoc.empty, - (left, right) => InternalHelpDoc.sequence(left, right) +): Effect.Effect< + Compgen.Compgen | FileSystem.FileSystem | Path.Path, + ValidationError.ValidationError, + void +> => + Effect.gen(function*(_) { + switch (builtIn._tag) { + case "ShowHelp": { + const banner = InternalHelpDoc.h1(InternalSpan.code(self.name)) + const header = InternalHelpDoc.p(InternalSpan.concat( + InternalSpan.text(`${self.name} ${self.version} -- `), + self.summary + )) + const usage = InternalHelpDoc.sequence( + InternalHelpDoc.h1("USAGE"), + pipe( + InternalUsage.enumerate(builtIn.usage, config), + ReadonlyArray.map((span) => + InternalHelpDoc.p(InternalSpan.concat(InternalSpan.text("$ "), span)) + ), + ReadonlyArray.reduceRight( + InternalHelpDoc.empty, + (left, right) => InternalHelpDoc.sequence(left, right) + ) ) ) - ) - const helpDoc = pipe( - banner, - InternalHelpDoc.sequence(header), - InternalHelpDoc.sequence(usage), - InternalHelpDoc.sequence(builtIn.helpDoc), - InternalHelpDoc.sequence(self.footer) - ) - return Console.log(InternalHelpDoc.toAnsiText(helpDoc)) - } - case "ShowCompletionScript": { - return Console.log("Showing completion script") - } - case "ShowCompletions": { - return Console.log("Showing completions") - } - case "ShowWizard": { - return Console.log("Showing the wizard") + const helpDoc = pipe( + banner, + InternalHelpDoc.sequence(header), + InternalHelpDoc.sequence(usage), + InternalHelpDoc.sequence(builtIn.helpDoc), + InternalHelpDoc.sequence(self.footer) + ) + return yield* _(Console.log(InternalHelpDoc.toAnsiText(helpDoc))) + } + case "ShowCompletionScript": { + const path = yield* _(Path.Path) + const commandNames = ReadonlyArray.fromIterable(self.command.names) + const programNames = ReadonlyArray.isNonEmptyReadonlyArray(commandNames) + ? commandNames + : ReadonlyArray.of(self.name) + const script = InternalCompletion.getCompletionScript( + builtIn.pathToExecutable, + programNames, + builtIn.shellType, + path + ) + return yield* _(Console.log(script)) + } + case "ShowCompletions": { + const compgen = yield* _(InternalCompgen.Tag) + const env = yield* _(Effect.sync(() => process.env)) + const tupleOrder = Order.mapInput(Order.number, (tuple: [number, string]) => tuple[0]) + const compWords = pipe( + ReadonlyRecord.collect( + env, + (key, value) => + key.startsWith("COMP_WORD_") && value !== undefined + ? Option.some<[number, string]>([key.replace("COMP_WORD_", "").length, value]) + : Option.none() + ), + ReadonlyArray.compact, + ReadonlyArray.sortBy(tupleOrder), + ReadonlyArray.map(([, value]) => value) + ) + const completions = yield* _(InternalCompletion.getCompletions( + compWords, + builtIn.index, + self.command, + config, + compgen + )) + return Effect.forEach(completions, (word) => Console.log(word), { discard: true }) + } + case "ShowWizard": { + return yield* _(Console.log("Showing the wizard")) + } } - } -} + }) const prefixCommand = (self: Command.Command): ReadonlyArray => { let command: Command.Command | undefined = self diff --git a/src/internal/command.ts b/src/internal/command.ts index 0ef86f0..402e1bf 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -1,3 +1,4 @@ +import type * as FileSystem from "@effect/platform/FileSystem" import * as Effect from "effect/Effect" import * as Either from "effect/Either" import { dual, pipe } from "effect/Function" @@ -14,6 +15,7 @@ import * as HelpDoc from "../HelpDoc.js" import type * as Span from "../HelpDoc/Span.js" import type * as Options from "../Options.js" import type * as Prompt from "../Prompt.js" +import type * as RegularLanguage from "../RegularLanguage.js" import type * as Terminal from "../Terminal.js" import type * as Usage from "../Usage.js" import type * as ValidationError from "../ValidationError.js" @@ -25,6 +27,7 @@ import * as InternalHelpDoc from "./helpDoc.js" import * as InternalSpan from "./helpDoc/span.js" import * as InternalOptions from "./options.js" import * as InternalPrompt from "./prompt.js" +import * as InternalRegularLanguage from "./regularLanguage.js" import * as InternalUsage from "./usage.js" import * as InternalValidationError from "./validationError.js" @@ -91,7 +94,7 @@ export class Standard args: ReadonlyArray, config: CliConfig.CliConfig ): Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, CommandDirective.CommandDirective< Command.Command.ParsedStandardCommand @@ -120,7 +123,7 @@ export class Standard const parseBuiltInArgs = ( args: ReadonlyArray ): Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, CommandDirective.CommandDirective > => { @@ -143,7 +146,7 @@ export class Standard const parseUserDefinedArgs = ( args: ReadonlyArray ): Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, CommandDirective.CommandDirective< Command.Command.ParsedStandardCommand @@ -175,7 +178,7 @@ export class Standard const exhaustiveSearch = ( args: ReadonlyArray ): Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, CommandDirective.CommandDirective > => { @@ -305,7 +308,7 @@ export class Map implements Command.Command { args: ReadonlyArray, config: CliConfig.CliConfig ): Effect.Effect< - Terminal.Terminal, + FileSystem.FileSystem | Terminal.Terminal, ValidationError.ValidationError, CommandDirective.CommandDirective > { @@ -353,7 +356,7 @@ export class OrElse implements Command.Command { args: ReadonlyArray, config: CliConfig.CliConfig ): Effect.Effect< - Terminal.Terminal, + FileSystem.FileSystem | Terminal.Terminal, ValidationError.ValidationError, CommandDirective.CommandDirective > { @@ -476,7 +479,7 @@ export class Subcommands, B extends Command.Comma args: ReadonlyArray, config: CliConfig.CliConfig ): Effect.Effect< - Terminal.Terminal, + FileSystem.FileSystem | Terminal.Terminal, ValidationError.ValidationError, CommandDirective.CommandDirective> > { @@ -696,6 +699,47 @@ export const orElseEither = dual< (self: Command.Command, that: Command.Command) => Command.Command> >(2, (self, that) => orElse(map(self, Either.left), map(that, Either.right))) +/** @internal */ +export const toRegularLanguage = dual< + (allowAlias: boolean) => (self: Command.Command) => RegularLanguage.RegularLanguage, + (self: Command.Command, allowAlias: boolean) => RegularLanguage.RegularLanguage +>(2, (self: Command.Command, allowAlias: boolean): RegularLanguage.RegularLanguage => { + if (isStandard(self)) { + const commandNameToken = allowAlias + ? InternalRegularLanguage.anyString : + InternalRegularLanguage.string(self.name) + return InternalRegularLanguage.concat( + commandNameToken, + InternalRegularLanguage.concat( + InternalOptions.toRegularLanguage(self.options), + InternalArgs.toRegularLanguage(self.args) + ) + ) + } + if (isGetUserInput(self)) { + throw Error() + } + if (isMap(self)) { + return toRegularLanguage(self.command, allowAlias) + } + if (isOrElse(self)) { + return InternalRegularLanguage.orElse( + toRegularLanguage(self.left, allowAlias), + toRegularLanguage(self.right, allowAlias) + ) + } + if (isSubcommands(self)) { + return InternalRegularLanguage.concat( + toRegularLanguage(self.parent, allowAlias), + toRegularLanguage(self.child, false) + ) + } + throw new Error( + "[BUG]: Command.toRegularLanguage - received unrecognized " + + `command ${JSON.stringify(self)}` + ) +}) + /** @internal */ export const withHelp = dual< (help: string | HelpDoc.HelpDoc) => (self: Command.Command) => Command.Command, diff --git a/src/internal/compgen.ts b/src/internal/compgen.ts new file mode 100644 index 0000000..45fd8fa --- /dev/null +++ b/src/internal/compgen.ts @@ -0,0 +1,86 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import type * as PlatformError from "@effect/platform/Error" +import * as Context from "effect/Context" +import * as Effect from "effect/Effect" +import { pipe } from "effect/Function" +import * as Layer from "effect/Layer" +import * as Option from "effect/Option" +import * as ReadonlyArray from "effect/ReadonlyArray" +import type * as Compgen from "../Compgen.js" + +// ============================================================================= +// Constructors +// ============================================================================= + +/** @internal */ +export const make = (workingDirectory: Option.Option): Effect.Effect< + CommandExecutor.CommandExecutor, + never, + Compgen.Compgen +> => + Effect.gen(function*(_) { + const executor = yield* _(CommandExecutor.CommandExecutor) + + const runShellCommand = ( + command: string + ): Effect.Effect> => { + const cmd = Option.match(workingDirectory, { + onNone: () => + Command.make(command).pipe( + Command.runInShell("/bin/bash") + ), + onSome: (cwd) => + Command.make(command).pipe( + Command.workingDirectory(cwd), + Command.runInShell("/bin/bash") + ) + }) + return executor.lines(cmd) + } + + const completeDirectoryNames = ( + word: string + ): Effect.Effect> => + runShellCommand(`compgen -o nospace -d -S / -- ${word}`) + + const completeFileNames = ( + word: string + ): Effect.Effect> => + Effect.gen(function*(_) { + // For file names, we want the cursor to skip forward to the next + // argument position, so we append a space (" ") to the end of them + // below. For directory names, however, we don't want to skip to the + // next argument position, because we like being able to smash the tab + // key to keep walking down through a directory tree. + const files = yield* _(runShellCommand(`compgen -f -- ${word}`)) + const directories = yield* _(completeDirectoryNames(word)) + const directorySet = new Set(directories) + const filesFiltered = ReadonlyArray.filter(files, (file) => !directorySet.has(`${file}/`)) + return pipe( + ReadonlyArray.map(filesFiltered, (file) => `${file} `), + ReadonlyArray.appendAll(directories) + ) + }) + + return Tag.of({ + completeFileNames, + completeDirectoryNames + }) + }) + +// ============================================================================= +// Context +// ============================================================================= + +/** @internal */ +export const Tag = Context.Tag() + +/** @internal */ +export const LiveCompgen: Layer.Layer = + Layer.effect(Tag, make(Option.none())) + +export const TestCompgen = ( + workingDirectory: string +): Layer.Layer => + Layer.effect(Tag, make(Option.some(workingDirectory))) diff --git a/src/internal/completion.ts b/src/internal/completion.ts new file mode 100644 index 0000000..7c348cf --- /dev/null +++ b/src/internal/completion.ts @@ -0,0 +1,113 @@ +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" +import * as Effect from "effect/Effect" +import { pipe } from "effect/Function" +import * as Order from "effect/Order" +import * as ReadonlyArray from "effect/ReadonlyArray" +import * as String from "effect/String" +import type * as CliConfig from "../CliConfig.js" +import type * as Command from "../Command.js" +import type * as Compgen from "../Compgen.js" +import type * as ShellType from "../ShellType.js" +import * as InternalCommand from "./command.js" +import * as InternalRegularLanguage from "./regularLanguage.js" + +/** @internal */ +export const getCompletions = ( + words: ReadonlyArray, + index: number, + command: Command.Command, + config: CliConfig.CliConfig, + compgen: Compgen.Compgen +): Effect.Effect> => { + // Split the input words into two chunks: + // 1. The chunk that is strictly before the cursor, and + // 2. The chunk that is at or after the cursor. + const [splitted, _] = ReadonlyArray.splitAt(words, index) + // Calculate the `RegularLanguage` corresponding to the input command. + // Here, we allow the top-most `Command.Single.name` field to vary by setting + // `allowAlias = true`. This is because the first argument will be the name + // of the executable that is provided when the shell makes a tab completion + // request. Without doing so, tab completion would fail if the executable + // were renamed or invoked via an alias. + const language = InternalCommand.toRegularLanguage(command, true) + // Repeatedly differentiate the language w.r.t. each of the tokens that + // occur before the cursor. + const derivative = Effect.reduce( + splitted, + language, + (lang, word) => InternalRegularLanguage.derive(lang, word, config) + ) + // Determine the word to complete + const wordToComplete = index >= 0 && index < words.length ? words[index]! : "" + // Finally, obtain the list of completions for the wordToComplete by: + // 1. Getting the list of all of the first tokens in the derivative + // 2. Retaining only those tokens that start with wordToComplete. + return derivative.pipe( + Effect.flatMap((lang) => InternalRegularLanguage.firstTokens(lang, wordToComplete, compgen)), + Effect.map((completions) => + pipe( + ReadonlyArray.fromIterable(completions), + ReadonlyArray.sort(Order.string), + ReadonlyArray.filter((str) => str.length > 0) + ) + ) + ) +} + +/** @internal */ +export const getCompletionScript = ( + pathToExecutable: string, + programNames: ReadonlyArray.NonEmptyReadonlyArray, + shellType: ShellType.ShellType, + path: Path.Path +): string => { + switch (shellType._tag) { + case "Bash": { + return createBashCompletionScript(pathToExecutable, programNames, path) + } + case "Fish": + case "Zsh": { + throw new Error("Not implemented") + } + } +} + +// ============================================================================= +// Internals +// ============================================================================= + +const createBashCompletionScript = ( + pathToExecutable: string, + programNames: ReadonlyArray.NonEmptyReadonlyArray, + path: Path.Path +): string => { + const script = String.stripMargin( + `|#!/usr/bin/env bash + |_${ReadonlyArray.headNonEmpty(programNames)}() + |{ + | local CMDLINE + | local IFS=$'\n' + | CMDLINE=(--shell-type bash --shell-completion-index $COMP_CWORD) + | + | INDEX=0 + | for arg in \${COMP_WORDS[@]}; do + | export COMP_WORD_$INDEX=\${arg} + | (( INDEX++ )) + | done + | + | COMPREPLY=( $(${pathToExecutable} "\${CMDLINE[@]}") ) + | + | # Unset the environment variables. + | unset $(compgen -v | grep "^COMP_WORD_") + |} + |` + ) + return pipe( + ReadonlyArray.prepend(programNames, script), + ReadonlyArray.map((programName) => + `complete -F _${ReadonlyArray.headNonEmpty(programNames)} ${programName}` + ), + ReadonlyArray.join(path.sep) + ) +} diff --git a/src/internal/options.ts b/src/internal/options.ts index 3e898c2..0e79e80 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -1,3 +1,4 @@ +import type * as FileSystem from "@effect/platform/FileSystem" import * as Schema from "@effect/schema/Schema" import * as Effect from "effect/Effect" import * as Either from "effect/Either" @@ -12,6 +13,7 @@ import type * as HelpDoc from "../HelpDoc.js" import type * as Options from "../Options.js" import type * as Parameter from "../Parameter.js" import type * as Primitive from "../Primitive.js" +import type * as RegularLanguage from "../RegularLanguage.js" import type * as Usage from "../Usage.js" import type * as ValidationError from "../ValidationError.js" import * as InternalAutoCorrect from "./autoCorrect.js" @@ -19,6 +21,7 @@ import * as InternalCliConfig from "./cliConfig.js" import * as InternalHelpDoc from "./helpDoc.js" import * as InternalSpan from "./helpDoc/span.js" import * as InternalPrimitive from "./primitive.js" +import * as InternalRegularLanguage from "./regularLanguage.js" import * as InternalUsage from "./usage.js" import * as InternalValidationError from "./validationError.js" @@ -203,7 +206,7 @@ export class Single implements Options.Options, Parameter.Input { validate( args: HashMap.HashMap>, config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { const names = ReadonlyArray.filterMap(this.names, (name) => HashMap.get(args, name)) if (ReadonlyArray.isNonEmptyReadonlyArray(names)) { const head = ReadonlyArray.headNonEmpty(names) @@ -289,7 +292,7 @@ export class Map implements Options.Options { validate( args: HashMap.HashMap>, config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { return this.options.validate(args, config).pipe(Effect.flatMap((a) => this.f(a))) } @@ -339,7 +342,7 @@ export class OrElse implements Options.Options> { validate( args: HashMap.HashMap>, config: CliConfig.CliConfig - ): Effect.Effect> { + ): Effect.Effect> { return this.left.validate(args, config).pipe( Effect.matchEffect({ onFailure: (err1) => @@ -424,7 +427,7 @@ export class Both implements Options.Options { validate( args: HashMap.HashMap>, config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { return this.left.validate(args, config).pipe( Effect.catchAll((err1) => this.right.validate(args, config).pipe(Effect.matchEffect({ @@ -521,7 +524,7 @@ export class WithDefault implements Options.Options, Parameter.Input { validate( args: HashMap.HashMap>, config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { return this.options.validate(args, config).pipe( Effect.catchTag("MissingValue", () => Effect.succeed(this.fallback)) ) @@ -570,7 +573,7 @@ export class KeyValueMap isValid( input: string, config: CliConfig.CliConfig - ): Effect.Effect> { + ): Effect.Effect> { const identifier = Option.getOrElse(this.identifier, () => "") const args = input.split(" ") return this.validate(HashMap.make([identifier, args]), config).pipe( @@ -637,7 +640,11 @@ export class KeyValueMap validate( args: HashMap.HashMap>, config: CliConfig.CliConfig - ): Effect.Effect> { + ): Effect.Effect< + FileSystem.FileSystem, + ValidationError.ValidationError, + HashMap.HashMap + > { const extractKeyValue = ( keyValue: string ): Effect.Effect => { @@ -742,7 +749,7 @@ const defaultBooleanOptions = { /** @internal */ export const boolean = ( name: string, - options: Options.Options.BooleanOptionConfig = {} + options: Options.Options.BooleanOptionsConfig = {} ): Options.Options => { const { aliases, ifPresent, negationNames } = { ...defaultBooleanOptions, ...options } const option = new Single( @@ -785,6 +792,28 @@ export const choiceWithValue = => new Single(name, ReadonlyArray.empty(), InternalPrimitive.date) +/** @internal */ +export const directory = ( + name: string, + config: Options.Options.PathOptionsConfig +): Options.Options => + new Single( + name, + ReadonlyArray.empty(), + InternalPrimitive.path("directory", config.exists || "either") + ) + +/** @internal */ +export const file = ( + name: string, + config: Options.Options.PathOptionsConfig +): Options.Options => + new Single( + name, + ReadonlyArray.empty(), + InternalPrimitive.path("file", config.exists || "either") + ) + /** @internal */ export const filterMap = dual< ( @@ -909,6 +938,73 @@ export const orElseEither = dual< (self: Options.Options, that: Options.Options) => Options.Options> >(2, (self, that) => new OrElse(self, that)) +/** @internal */ +export const toRegularLanguage = ( + self: Options.Options +): RegularLanguage.RegularLanguage => { + if (isEmpty(self)) { + return InternalRegularLanguage.epsilon + } + if (isSingle(self)) { + const names = ReadonlyArray.reduce( + self.names, + InternalRegularLanguage.empty, + (lang, name) => InternalRegularLanguage.orElse(lang, InternalRegularLanguage.string(name)) + ) + if (InternalPrimitive.isBoolType(self.primitiveType)) { + return names + } + return InternalRegularLanguage.concat( + names, + InternalRegularLanguage.primitive(self.primitiveType) + ) + } + if (isMap(self)) { + return toRegularLanguage(self.options) + } + if (isBoth(self)) { + const leftLanguage = toRegularLanguage(self.left) + const rightLanguage = toRegularLanguage(self.right) + // Deforestation + if ( + InternalRegularLanguage.isPermutation(leftLanguage) && + InternalRegularLanguage.isPermutation(rightLanguage) + ) { + return InternalRegularLanguage.permutation( + ReadonlyArray.appendAll(leftLanguage.values, rightLanguage.values) + ) + } + if (InternalRegularLanguage.isPermutation(leftLanguage)) { + return InternalRegularLanguage.permutation( + ReadonlyArray.append(leftLanguage.values, rightLanguage) + ) + } + if (InternalRegularLanguage.isPermutation(rightLanguage)) { + return InternalRegularLanguage.permutation( + ReadonlyArray.append(rightLanguage.values, leftLanguage) + ) + } + return InternalRegularLanguage.permutation([leftLanguage, rightLanguage]) + } + if (isOrElse(self)) { + return InternalRegularLanguage.orElse( + toRegularLanguage(self.left), + toRegularLanguage(self.right) + ) + } + if (isKeyValueMap(self)) { + const optionGrammar = toRegularLanguage(self.argumentOption) + return InternalRegularLanguage.permutation([optionGrammar]) + } + if (isWithDefault(self)) { + return InternalRegularLanguage.optional(toRegularLanguage(self.options)) + } + throw new Error( + "[BUG]: Options.toRegularLanguage - received unrecognized " + + `options type ${JSON.stringify(self)}` + ) +} + /** @internal */ export const validate = dual< ( @@ -917,7 +1013,7 @@ export const validate = dual< ) => ( self: Options.Options ) => Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, readonly [Option.Option, ReadonlyArray, A] >, @@ -926,7 +1022,7 @@ export const validate = dual< args: ReadonlyArray, config: CliConfig.CliConfig ) => Effect.Effect< - never, + FileSystem.FileSystem, ValidationError.ValidationError, readonly [Option.Option, ReadonlyArray, A] > diff --git a/src/internal/primitive.ts b/src/internal/primitive.ts index c459eb3..493f762 100644 --- a/src/internal/primitive.ts +++ b/src/internal/primitive.ts @@ -1,3 +1,4 @@ +import * as FileSystem from "@effect/platform/FileSystem" import * as Schema from "@effect/schema/Schema" import * as Effect from "effect/Effect" import { pipe } from "effect/Function" @@ -56,7 +57,7 @@ export class Bool implements Primitive.Primitive { validate( value: Option.Option, config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { return Option.map(value, (str) => InternalCliConfig.normalizeCase(config, str)).pipe( Option.match({ onNone: () => @@ -76,45 +77,6 @@ export class Bool implements Primitive.Primitive { } } -/** - * Represents a date in ISO-8601 format, such as `2007-12-03T10:15:30`. - * - * @internal - */ -export class Date implements Primitive.Primitive { - readonly [PrimitiveTypeId] = proto - readonly _tag = "Date" - - get typeName(): string { - return "date" - } - - get help(): Span.Span { - return InternalSpan.text( - "A date without a time-zone in the ISO-8601 format, such as 2007-12-03T10:15:30." - ) - } - - get choices(): Option.Option { - return Option.some("date") - } - - validate( - value: Option.Option, - _config: CliConfig.CliConfig - ): Effect.Effect { - return attempt( - value, - this.typeName, - Schema.parse(Schema.dateFromString(Schema.string)) - ) - } - - pipe() { - return pipeArguments(this, arguments) - } -} - export class Choice implements Primitive.Primitive { readonly [PrimitiveTypeId] = proto readonly _tag = "Choice" @@ -146,7 +108,7 @@ export class Choice implements Primitive.Primitive { validate( value: Option.Option, _config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { return Effect.orElseFail( value, () => `Choice options to not have a default value` @@ -175,6 +137,45 @@ export class Choice implements Primitive.Primitive { } } +/** + * Represents a date in ISO-8601 format, such as `2007-12-03T10:15:30`. + * + * @internal + */ +export class Date implements Primitive.Primitive { + readonly [PrimitiveTypeId] = proto + readonly _tag = "Date" + + get typeName(): string { + return "date" + } + + get help(): Span.Span { + return InternalSpan.text( + "A date without a time-zone in the ISO-8601 format, such as 2007-12-03T10:15:30." + ) + } + + get choices(): Option.Option { + return Option.some("date") + } + + validate( + value: Option.Option, + _config: CliConfig.CliConfig + ): Effect.Effect { + return attempt( + value, + this.typeName, + Schema.parse(Schema.dateFromString(Schema.string)) + ) + } + + pipe() { + return pipeArguments(this, arguments) + } +} + /** * Represents a floating point number. * @@ -199,7 +200,7 @@ export class Float implements Primitive.Primitive { validate( value: Option.Option, _config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { const numberFromString = Schema.string.pipe(Schema.numberFromString) return attempt(value, this.typeName, Schema.parse(numberFromString)) } @@ -233,7 +234,7 @@ export class Integer implements Primitive.Primitive { validate( value: Option.Option, _config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { const intFromString = Schema.string.pipe(Schema.numberFromString, Schema.int()) return attempt(value, this.typeName, Schema.parse(intFromString)) } @@ -243,6 +244,90 @@ export class Integer implements Primitive.Primitive { } } +/** @internal */ +export class Path implements Primitive.Primitive { + readonly [PrimitiveTypeId] = proto + readonly _tag = "Path" + + constructor( + readonly pathType: Primitive.Primitive.PathType, + readonly pathExists: Primitive.Primitive.PathExists + ) {} + + get typeName(): string { + if (this.pathType === "either") { + return "path" + } + return this.pathType + } + + get help(): Span.Span { + if (this.pathType === "either" && this.pathExists === "yes") { + return InternalSpan.text("An existing file or directory.") + } + if (this.pathType === "file" && this.pathExists === "yes") { + return InternalSpan.text("An existing file.") + } + if (this.pathType === "directory" && this.pathExists === "yes") { + return InternalSpan.text("An existing directory.") + } + if (this.pathType === "either" && this.pathExists === "no") { + return InternalSpan.text("A file or directory that must not exist.") + } + if (this.pathType === "file" && this.pathExists === "no") { + return InternalSpan.text("A file that must not exist.") + } + if (this.pathType === "directory" && this.pathExists === "no") { + return InternalSpan.text("A directory that must not exist.") + } + if (this.pathType === "either" && this.pathExists === "either") { + return InternalSpan.text("A file or directory.") + } + if (this.pathType === "file" && this.pathExists === "either") { + return InternalSpan.text("A file.") + } + if (this.pathType === "directory" && this.pathExists === "either") { + return InternalSpan.text("A directory.") + } + throw new Error( + "[BUG]: Path.help - encountered invalid combination of path type " + + `('${this.pathType}') and path existence ('${this.pathExists}')` + ) + } + + get choices(): Option.Option { + return Option.none() + } + + validate( + value: Option.Option, + _config: CliConfig.CliConfig + ): Effect.Effect { + return Effect.flatMap(FileSystem.FileSystem, (fileSystem) => { + const errorMsg = "Path options do not have a default value" + return Effect.orElseFail(value, () => errorMsg).pipe( + Effect.tap((path) => + Effect.orDie(fileSystem.exists(path)).pipe( + Effect.tap((pathExists) => + validatePathExistence(path, this.pathExists, pathExists).pipe( + Effect.zipRight( + validatePathType(path, this.pathType, fileSystem).pipe( + Effect.when(() => this.pathExists !== "no" && pathExists) + ) + ) + ) + ) + ) + ) + ) + }) + } + + pipe() { + return pipeArguments(this, arguments) + } +} + /** * Represents a user-defined piece of text. * @@ -267,7 +352,7 @@ export class Text implements Primitive.Primitive { validate( value: Option.Option, _config: CliConfig.CliConfig - ): Effect.Effect { + ): Effect.Effect { return attempt(value, this.typeName, Schema.parse(Schema.string)) } @@ -298,6 +383,12 @@ export const float: Primitive.Primitive = new Float() /** @internal */ export const integer: Primitive.Primitive = new Integer() +/** @internal */ +export const path = ( + pathType: Primitive.Primitive.PathType, + pathExists: Primitive.Primitive.PathExists +): Primitive.Primitive => new Path(pathType, pathExists) + /** @internal */ export const text: Primitive.Primitive = new Text() @@ -313,6 +404,34 @@ export const isPrimitive = (u: unknown): u is Primitive.Primitive => export const isBool = (self: Primitive.Primitive): boolean => isPrimitive(self) && "_tag" in self && self._tag === "Bool" +/** @internal */ +export const isBoolType = (u: unknown): u is Bool => + isPrimitive(u) && "_tag" in u && u._tag === "Bool" + +/** @internal */ +export const isChoiceType = (u: unknown): u is Choice => + isPrimitive(u) && "_tag" in u && u._tag === "Choice" + +/** @internal */ +export const isDateType = (u: unknown): u is Date => + isPrimitive(u) && "_tag" in u && u._tag === "Date" + +/** @internal */ +export const isFloatType = (u: unknown): u is Float => + isPrimitive(u) && "_tag" in u && u._tag === "Float" + +/** @internal */ +export const isIntegerType = (u: unknown): u is Integer => + isPrimitive(u) && "_tag" in u && u._tag === "Integer" + +/** @internal */ +export const isPathType = (u: unknown): u is Path => + isPrimitive(u) && "_tag" in u && u._tag === "Path" + +/** @internal */ +export const isTextType = (u: unknown): u is Text => + isPrimitive(u) && "_tag" in u && u._tag === "Text" + // ============================================================================= // Internals // ============================================================================= @@ -333,3 +452,50 @@ const attempt = ( ) ) ) + +const validatePathExistence = ( + path: string, + shouldPathExist: Primitive.Primitive.PathExists, + pathExists: boolean +): Effect.Effect => { + if (shouldPathExist === "no" && pathExists) { + return Effect.fail(`Path '${path}' must not exist`) + } + if (shouldPathExist === "yes" && !pathExists) { + return Effect.fail(`Path '${path}' must exist`) + } + return Effect.unit +} + +const validatePathType = ( + path: string, + pathType: Primitive.Primitive.PathType, + fileSystem: FileSystem.FileSystem +): Effect.Effect => { + switch (pathType) { + case "file": { + const checkIsFile = fileSystem.stat(path).pipe( + Effect.map((info) => info.type === "File"), + Effect.orDie + ) + return Effect.fail(`Expected path '${path}' to be a regular file`).pipe( + Effect.unlessEffect(checkIsFile), + Effect.asUnit + ) + } + case "directory": { + const checkIsDirectory = fileSystem.stat(path).pipe( + Effect.map((info) => info.type === "Directory"), + Effect.orDie + ) + return Effect.fail(`Expected path '${path}' to be a directory`).pipe( + Effect.unlessEffect(checkIsDirectory), + Effect.asUnit + ) + // ZIO.fail(s"Expected path '$value' to be a directory.").unlessZIO(self.fileSystem.isDirectory(path)).unit + } + case "either": { + return Effect.unit + } + } +} diff --git a/src/internal/regularLanguage.ts b/src/internal/regularLanguage.ts new file mode 100644 index 0000000..a585224 --- /dev/null +++ b/src/internal/regularLanguage.ts @@ -0,0 +1,500 @@ +import type * as FileSystem from "@effect/platform/FileSystem" +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Equal from "effect/Equal" +import { dual, identity, pipe } from "effect/Function" +import * as HashSet from "effect/HashSet" +import * as Option from "effect/Option" +import { pipeArguments } from "effect/Pipeable" +import * as ReadonlyArray from "effect/ReadonlyArray" +import type * as CliConfig from "../CliConfig.js" +import type * as Compgen from "../Compgen.js" +import type * as Primitive from "../Primitive.js" +import type * as RegularLanguage from "../RegularLanguage.js" +import * as InternalPrimitive from "./primitive.js" + +const RegularLanguageSymbolKey = "@effect/cli/RegularLanguage" + +/** @internal */ +export const RegularLanguageTypeId: RegularLanguage.RegularLanguageTypeId = Symbol.for( + RegularLanguageSymbolKey +) as RegularLanguage.RegularLanguageTypeId + +class Empty extends Data.TaggedClass("Empty")<{}> implements RegularLanguage.Empty { + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } + toString() { + return "∅" + } +} + +class Epsilon extends Data.TaggedClass("Epsilon")<{}> implements RegularLanguage.Epsilon { + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } + toString() { + return "ε" + } +} + +class StringToken extends Data.TaggedClass("StringToken")<{ + readonly value: string +}> implements RegularLanguage.StringToken { + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } +} + +class AnyStringToken extends Data.TaggedClass("AnyStringToken")<{}> + implements RegularLanguage.AnyStringToken +{ + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } +} + +class PrimitiveToken extends Data.TaggedClass("PrimitiveToken")<{ + readonly primitive: Primitive.Primitive +}> implements RegularLanguage.PrimitiveToken { + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } +} + +class Cat extends Data.TaggedClass("Cat")<{ + readonly left: RegularLanguage.RegularLanguage + readonly right: RegularLanguage.RegularLanguage +}> implements RegularLanguage.Cat { + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } +} + +class Alt extends Data.TaggedClass("Alt")<{ + readonly left: RegularLanguage.RegularLanguage + readonly right: RegularLanguage.RegularLanguage +}> implements RegularLanguage.Alt { + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } +} + +class Repeat extends Data.TaggedClass("Repeat")<{ + readonly language: RegularLanguage.RegularLanguage + readonly min: Option.Option + readonly max: Option.Option +}> implements RegularLanguage.Repeat { + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } +} + +class Permutation extends Data.TaggedClass("Permutation")<{ + readonly values: ReadonlyArray +}> implements RegularLanguage.Permutation { + readonly [RegularLanguageTypeId] = (_: never) => _ + pipe() { + return pipeArguments(this, arguments) + } +} + +// ============================================================================= +// Refinements +// ============================================================================= + +/** @internal */ +export const isRegularLanguage = (u: unknown): u is RegularLanguage.RegularLanguage => + typeof u === "object" && u !== null && RegularLanguageTypeId in u + +/** @internal */ +export const isEmpty = (self: RegularLanguage.RegularLanguage): self is RegularLanguage.Empty => + self._tag === "Empty" + +/** @internal */ +export const isEpsilon = (self: RegularLanguage.RegularLanguage): self is RegularLanguage.Epsilon => + self._tag === "Epsilon" + +/** @internal */ +export const isStringToken = ( + self: RegularLanguage.RegularLanguage +): self is RegularLanguage.StringToken => self._tag === "StringToken" + +/** @internal */ +export const isAnyStringToken = ( + self: RegularLanguage.RegularLanguage +): self is RegularLanguage.AnyStringToken => self._tag === "AnyStringToken" + +/** @internal */ +export const isPrimitiveToken = ( + self: RegularLanguage.RegularLanguage +): self is RegularLanguage.PrimitiveToken => self._tag === "PrimitiveToken" + +/** @internal */ +export const isCat = (self: RegularLanguage.RegularLanguage): self is RegularLanguage.Cat => + self._tag === "Cat" + +/** @internal */ +export const isAlt = (self: RegularLanguage.RegularLanguage): self is RegularLanguage.Alt => + self._tag === "Alt" + +/** @internal */ +export const isRepeat = (self: RegularLanguage.RegularLanguage): self is RegularLanguage.Repeat => + self._tag === "Repeat" + +/** @internal */ +export const isPermutation = ( + self: RegularLanguage.RegularLanguage +): self is RegularLanguage.Permutation => self._tag === "Permutation" + +// ============================================================================= +// Constructors +// ============================================================================= + +/** @internal */ +export const empty: RegularLanguage.RegularLanguage = new Empty() + +/** @internal */ +export const epsilon: RegularLanguage.RegularLanguage = new Epsilon() + +/** @internal */ +export const string = (value: string): RegularLanguage.RegularLanguage => new StringToken({ value }) + +/** @internal */ +export const anyString: RegularLanguage.RegularLanguage = new AnyStringToken() + +/** @internal */ +export const primitive = ( + primitive: Primitive.Primitive +): RegularLanguage.RegularLanguage => new PrimitiveToken({ primitive }) + +/** @internal */ +export const permutation = ( + values: ReadonlyArray +): RegularLanguage.RegularLanguage => new Permutation({ values }) + +// ============================================================================= +// Combinators +// ============================================================================= + +/** @internal */ +export const concat = dual< + ( + that: string | RegularLanguage.RegularLanguage + ) => (self: RegularLanguage.RegularLanguage) => RegularLanguage.RegularLanguage, + ( + self: RegularLanguage.RegularLanguage, + that: string | RegularLanguage.RegularLanguage + ) => RegularLanguage.RegularLanguage +>( + 2, + (self, that) => + new Cat({ + left: self, + right: typeof that === "string" ? new StringToken({ value: that }) : that + }) +) + +/** @internal */ +export const contains = dual< + ( + tokens: ReadonlyArray, + config: CliConfig.CliConfig + ) => (self: RegularLanguage.RegularLanguage) => Effect.Effect, + ( + self: RegularLanguage.RegularLanguage, + tokens: ReadonlyArray, + config: CliConfig.CliConfig + ) => Effect.Effect +>( + 3, + (self, tokens, config) => + Effect.reduce( + tokens, + self, + (language, word) => derive(language, word, config) + ).pipe(Effect.map(isNullable)) +) + +/** @internal */ +export const derive = dual< + ( + token: string, + config: CliConfig.CliConfig + ) => ( + self: RegularLanguage.RegularLanguage + ) => Effect.Effect, + ( + self: RegularLanguage.RegularLanguage, + token: string, + config: CliConfig.CliConfig + ) => Effect.Effect +>(3, ( + self: RegularLanguage.RegularLanguage, + token: string, + config: CliConfig.CliConfig +): Effect.Effect => { + switch (self._tag) { + case "Empty": { + return Effect.succeed(empty) + } + case "Epsilon": { + return Effect.succeed(empty) + } + case "StringToken": { + const isMatch = config.isCaseSensitive + ? self.value === token + : self.value.toLowerCase() === token.toLowerCase() + return isMatch + ? Effect.succeed(epsilon) + : Effect.succeed(empty) + } + case "AnyStringToken": { + return Effect.succeed(epsilon) + } + case "PrimitiveToken": { + return self.primitive.validate(Option.some(token), config).pipe(Effect.match({ + onFailure: () => empty, + onSuccess: () => epsilon + })) + } + case "Cat": { + if (isNullable(self.left)) { + return Effect.all([ + derive(self.left, token, config), + derive(self.right, token, config) + ]).pipe(Effect.map(([dx, dy]) => orElse(concat(dx, self.right), dy))) + } + return derive(self.left, token, config).pipe(Effect.map((dx) => concat(dx, self.right))) + } + case "Alt": { + return Effect.all([ + derive(self.left, token, config), + derive(self.right, token, config) + ]).pipe(Effect.map(([dx, dy]) => orElse(dx, dy))) + } + case "Repeat": { + const newMin = Option.map(self.min, (n) => n - 1).pipe(Option.filter((n) => n > 0)) + const newMax = Option.map(self.max, (n) => n - 1) + if (Option.match(newMax, { onNone: () => true, onSome: (n) => n >= 0 })) { + return derive(self.language, token, config).pipe( + Effect.map((dx) => + concat( + dx, + repeated(self.language, { + min: Option.getOrUndefined(newMin), + max: Option.getOrUndefined(newMax) + }) + ) + ) + ) + } + return Effect.succeed(empty) + } + case "Permutation": { + return derive(desugared(self), token, config) + } + } +}) + +/** @internal */ +export const firstTokens = dual< + ( + prefix: string, + compgen: Compgen.Compgen + ) => ( + self: RegularLanguage.RegularLanguage + ) => Effect.Effect>, + ( + self: RegularLanguage.RegularLanguage, + prefix: string, + compgen: Compgen.Compgen + ) => Effect.Effect> +>(3, ( + self: RegularLanguage.RegularLanguage, + prefix: string, + compgen: Compgen.Compgen +): Effect.Effect> => { + switch (self._tag) { + case "Empty": { + return Effect.succeed(HashSet.empty()) + } + case "Epsilon": { + return Effect.succeed(HashSet.make("")) + } + case "StringToken": { + return Effect.succeed( + HashSet.filter(HashSet.make(`${self.value} `), (str) => str.startsWith(prefix)) + ) + } + case "AnyStringToken": { + return Effect.succeed(HashSet.empty()) + } + case "PrimitiveToken": { + return primitiveFirstTokens(self.primitive, prefix, compgen) + } + case "Cat": { + if (isNullable(self.left)) { + return Effect.zipWith( + firstTokens(self.left, prefix, compgen), + firstTokens(self.right, prefix, compgen), + (left, right) => HashSet.union(left, right) + ) + } + return firstTokens(self.left, prefix, compgen) + } + case "Alt": { + return Effect.zipWith( + firstTokens(self.left, prefix, compgen), + firstTokens(self.right, prefix, compgen), + (left, right) => HashSet.union(left, right) + ) + } + case "Repeat": { + return firstTokens(self.language, prefix, compgen) + } + case "Permutation": { + return Effect.forEach(self.values, (lang) => firstTokens(lang, prefix, compgen)).pipe( + Effect.map((sets) => HashSet.flatMap(HashSet.fromIterable(sets), identity)) + ) + } + } +}) + +/** @internal */ +export const isNullable = (self: RegularLanguage.RegularLanguage): boolean => { + switch (self._tag) { + case "Empty": + case "StringToken": + case "AnyStringToken": + case "PrimitiveToken": { + return false + } + case "Epsilon": { + return true + } + case "Cat": { + return isNullable(self.left) && isNullable(self.right) + } + case "Alt": { + return isNullable(self.left) || isNullable(self.right) + } + case "Repeat": { + return Option.match(self.min, { + onNone: () => true, + onSome: (n) => n <= 0 + }) + } + case "Permutation": { + return ReadonlyArray.every(self.values, (language) => isNullable(language)) + } + } +} + +/** @internal */ +export const optional = (self: RegularLanguage.RegularLanguage): RegularLanguage.RegularLanguage => + new Alt({ left: self, right: epsilon }) + +/** @internal */ +export const orElse = dual< + ( + that: string | RegularLanguage.RegularLanguage + ) => (self: RegularLanguage.RegularLanguage) => RegularLanguage.RegularLanguage, + ( + self: RegularLanguage.RegularLanguage, + that: string | RegularLanguage.RegularLanguage + ) => RegularLanguage.RegularLanguage +>( + 2, + (self, that) => + new Alt({ + left: self, + right: typeof that === "string" ? new StringToken({ value: that }) : that + }) +) + +/** @internal */ +export const repeated = dual< + ( + params?: Partial + ) => (self: RegularLanguage.RegularLanguage) => RegularLanguage.RegularLanguage, + ( + self: RegularLanguage.RegularLanguage, + params?: Partial + ) => RegularLanguage.RegularLanguage +>((args) => isRegularLanguage(args[0]), (self, params = {}) => { + const min = Option.fromNullable(params.min) + const max = Option.fromNullable(params.max) + return new Repeat({ language: self, min, max }) +}) + +// ============================================================================= +// Internals +// ============================================================================= + +const appendSpace = (str: string): string => `${str} ` + +const desugared = (self: RegularLanguage.Permutation): RegularLanguage.RegularLanguage => + ReadonlyArray.reduce( + self.values, + epsilon, + (acc, lang) => { + const filtered = ReadonlyArray.filter(self.values, (_) => !Equal.equals(_, lang)) + return orElse(acc, concat(lang, permutation(filtered))) + } + ) + +const primitiveFirstTokens = ( + primitive: Primitive.Primitive, + prefix: string, + compgen: Compgen.Compgen +): Effect.Effect> => { + if (InternalPrimitive.isPathType(primitive)) { + if (primitive.pathType === "either" || primitive.pathType === "file") { + return compgen.completeFileNames(prefix).pipe( + Effect.map(HashSet.fromIterable), + Effect.orDie + ) + } + return compgen.completeDirectoryNames(prefix).pipe( + Effect.map(HashSet.fromIterable), + Effect.orDie + ) + } + if (InternalPrimitive.isBoolType(primitive)) { + const set = HashSet.make("true", "false").pipe( + HashSet.filter((str) => str.startsWith(prefix)), + HashSet.map(appendSpace) + ) + return Effect.succeed(set) + } + if (InternalPrimitive.isChoiceType(primitive)) { + const choices = pipe( + ReadonlyArray.filterMap(primitive.alternatives, ([name]) => + name.startsWith(prefix) + ? Option.some(name) : + Option.none()), + ReadonlyArray.map(appendSpace) + ) + return Effect.succeed(HashSet.fromIterable(choices)) + } + if ( + InternalPrimitive.isFloatType(primitive) || + InternalPrimitive.isIntegerType(primitive) || + InternalPrimitive.isTextType(primitive) + ) { + return Effect.succeed(HashSet.empty()) + } + throw new Error( + "[BUG]: RegularLanguage.firstTokens - received unrecognized " + + `primitive ${JSON.stringify(primitive)}` + ) +} diff --git a/src/internal/shellType.ts b/src/internal/shellType.ts index 1991d0c..09e6e82 100644 --- a/src/internal/shellType.ts +++ b/src/internal/shellType.ts @@ -8,8 +8,13 @@ export const bash: ShellType.ShellType = { } /** @internal */ -export const zShell: ShellType.ShellType = { - _tag: "ZShell" +export const fish: ShellType.ShellType = { + _tag: "Fish" +} + +/** @internal */ +export const zsh: ShellType.ShellType = { + _tag: "Zsh" } /** @internal */ @@ -18,6 +23,7 @@ export const shellOption: Options.Options = InternalOptions [ ["sh", bash], ["bash", bash], - ["zsh", zShell] + ["fish", fish], + ["zsh", zsh] ] ) diff --git a/src/internal/terminal.ts b/src/internal/terminal.ts index 61ad8d3..96148d9 100644 --- a/src/internal/terminal.ts +++ b/src/internal/terminal.ts @@ -65,7 +65,7 @@ const parseAction = (key: readline.Key): Effect.Effect = Layer.scoped( +export const LiveTerminal: Layer.Layer = Layer.scoped( Tag, Effect.gen(function*($) { const { input, output } = yield* $( diff --git a/test/Args.test.ts b/test/Args.test.ts new file mode 100644 index 0000000..4ea7dfb --- /dev/null +++ b/test/Args.test.ts @@ -0,0 +1,57 @@ +import * as Args from "@effect/cli/Args" +import * as CliConfig from "@effect/cli/CliConfig" +import * as HelpDoc from "@effect/cli/HelpDoc" +import * as ValidationError from "@effect/cli/ValidationError" +import * as NodeContext from "@effect/platform-node/NodeContext" +import * as Path from "@effect/platform-node/Path" +import * as Effect from "effect/Effect" +import * as ReadonlyArray from "effect/ReadonlyArray" +import { describe, expect, it } from "vitest" + +const runEffect = ( + self: Effect.Effect +): Promise => Effect.provide(self, NodeContext.layer).pipe(Effect.runPromise) + +describe("Args", () => { + it("should validate an existing file that is expected to exist", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const filePath = path.join(__dirname, "Args.test.ts") + const args = Args.file({ name: "files", exists: "yes" }).pipe(Args.repeated) + const result = yield* _(args.validate(ReadonlyArray.of(filePath), CliConfig.defaultConfig)) + expect(result).toEqual([ReadonlyArray.empty(), ReadonlyArray.of(filePath)]) + }).pipe(runEffect)) + + it("should return an error when a file that is expected to exist is not found", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const filePath = path.join(__dirname, "NotExist.test.ts") + const args = Args.file({ name: "files", exists: "yes" }).pipe(Args.repeated) + const result = yield* _( + Effect.flip(args.validate(ReadonlyArray.of(filePath), CliConfig.defaultConfig)) + ) + expect(result).toEqual(ValidationError.invalidArgument(HelpDoc.p( + `Path '${filePath}' must exist` + ))) + }).pipe(runEffect)) + + it("should validate a non-existent file that is expected not to exist", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const filePath = path.join(__dirname, "NotExist.test.ts") + const args = Args.file({ name: "files", exists: "no" }).pipe(Args.repeated) + const result = yield* _(args.validate(ReadonlyArray.of(filePath), CliConfig.defaultConfig)) + expect(result).toEqual([ReadonlyArray.empty(), ReadonlyArray.of(filePath)]) + }).pipe(runEffect)) + + it("should validate a series of files", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const filePath = path.join(__dirname, "NotExist.test.ts") + const args = Args.file({ name: "files", exists: "no" }).pipe(Args.repeated) + const result = yield* _( + args.validate(ReadonlyArray.make(filePath, filePath), CliConfig.defaultConfig) + ) + expect(result).toEqual([ReadonlyArray.empty(), ReadonlyArray.make(filePath, filePath)]) + }).pipe(runEffect)) +}) diff --git a/test/Command.test.ts b/test/Command.test.ts index 455843a..1cb0d6b 100644 --- a/test/Command.test.ts +++ b/test/Command.test.ts @@ -10,13 +10,18 @@ import * as Grep from "@effect/cli/test/utils/grep" import * as Tail from "@effect/cli/test/utils/tail" import * as WordCount from "@effect/cli/test/utils/wc" import * as ValidationError from "@effect/cli/ValidationError" +import * as FileSystem from "@effect/platform-node/FileSystem" import * as Doc from "@effect/printer/Doc" import * as Render from "@effect/printer/Render" import { Effect, Option, ReadonlyArray, String } from "effect" +import * as Layer from "effect/Layer" import { describe, expect, it } from "vitest" -const runEffect = (self: Effect.Effect): Promise => - Effect.provide(self, Terminal.layer).pipe(Effect.runPromise) +const MainLive = Layer.merge(FileSystem.layer, Terminal.LiveTerminal) + +const runEffect = ( + self: Effect.Effect +): Promise => Effect.provide(self, MainLive).pipe(Effect.runPromise) describe("Command", () => { describe("Standard Commands", () => { diff --git a/test/Completion.test.ts b/test/Completion.test.ts new file mode 100644 index 0000000..9f5a97f --- /dev/null +++ b/test/Completion.test.ts @@ -0,0 +1,926 @@ +import * as Args from "@effect/cli/Args" +import * as CliConfig from "@effect/cli/CliConfig" +import * as Command from "@effect/cli/Command" +import * as Compgen from "@effect/cli/Compgen" +import * as Completion from "@effect/cli/Completion" +import * as Options from "@effect/cli/Options" +import * as Tail from "@effect/cli/test/utils/tail" +import * as WordCount from "@effect/cli/test/utils/wc" +import * as FileSystem from "@effect/platform-node/FileSystem" +import * as NodeContext from "@effect/platform-node/NodeContext" +import * as Path from "@effect/platform-node/Path" +import * as Effect from "effect/Effect" +import * as Layer from "effect/Layer" +import * as Order from "effect/Order" +import * as ReadonlyArray from "effect/ReadonlyArray" +import { describe, expect, it } from "vitest" + +const MainLive = Layer.provideMerge(NodeContext.layer, Compgen.LiveCompgen) + +const runEffect = ( + self: Effect.Effect +): Promise => Effect.provide(self, MainLive).pipe(Effect.runPromise) + +describe("Completion", () => { + it("a different command name in the arguments list should not affect completion", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo-alias") + const index = 1 + const command = Command.standard("foo", { + args: Args.choice([["bar", 1], ["baz", 2], ["qux", 3]]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("bar ", "baz ", "qux ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + describe("Commands - No Options or Args", () => { + it("should return no completions", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.of("true") + const index = 1 + const command = Command.standard("true") + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + expect(result).toEqual(ReadonlyArray.empty()) + }).pipe(runEffect)) + }) + + describe("Commands - No Options, Single Args", () => { + describe("Boolean Primitives", () => { + it("no partial word should complete with 'false' and 'true'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo") + const index = 1 + const command = Command.standard("foo", { args: Args.boolean() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("false ", "true ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'f' should complete with 'false'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "f") + const index = 1 + const command = Command.standard("foo", { args: Args.boolean() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("false ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 't' should complete with 'true'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "t") + const index = 1 + const command = Command.standard("foo", { args: Args.boolean() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("true ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'true' should return 'true'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "true") + const index = 1 + const command = Command.standard("foo", { args: Args.boolean() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("true ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'false' should return 'false'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "false") + const index = 1 + const command = Command.standard("foo", { args: Args.boolean() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("false ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'x' should return no completions", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "x") + const index = 1 + const command = Command.standard("foo", { args: Args.boolean() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(runEffect)) + }) + + describe("Choice Primitives", () => { + it("no partial word should return the complete list of choices", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo") + const index = 1 + const command = Command.standard("foo", { + args: Args.choice([["bar", 1], ["baz", 2], ["bippy", 3]]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("bar ", "baz ", "bippy ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'b' should complete with 'bar', 'baz', and 'bippy'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "b") + const index = 1 + const command = Command.standard("foo", { + args: Args.choice([["bar", 1], ["baz", 2], ["bippy", 3]]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("bar ", "baz ", "bippy ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'ba' should complete with 'bar' and 'baz'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "ba") + const index = 1 + const command = Command.standard("foo", { + args: Args.choice([["bar", 1], ["baz", 2], ["bippy", 3]]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("bar ", "baz ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'baz' should return 'baz'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "baz") + const index = 1 + const command = Command.standard("foo", { + args: Args.choice([["bar", 1], ["baz", 2], ["bippy", 3]]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("baz ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + }) + + describe("Float Primitives", () => { + it("no partial word should return no completions", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo") + const index = 1 + const command = Command.standard("foo", { args: Args.float() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word '32.6' should return no completions", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "32.6") + const index = 1 + const command = Command.standard("foo", { args: Args.float() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'x' should return no completions", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "x") + const index = 1 + const command = Command.standard("foo", { args: Args.float() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(runEffect)) + }) + + describe("Path Primitives", () => { + it("Args.file, no prefix provided", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("program") + const index = 1 + const command = Command.standard("foo", { args: Args.file() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = [ + "bar.pdf ", + "barDir/", + "bippy.sh ", + "bippyDir/", + "foo.txt ", + "fooDir/" + ] + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + + it("Args.file, prefix provided", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("program", "f") + const index = 1 + const command = Command.standard("foo", { args: Args.file() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ["foo.txt ", "fooDir/"] + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + + it("Args.file, complete file name provided", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("program", "foo.txt") + const index = 1 + const command = Command.standard("foo", { args: Args.file() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ["foo.txt "] + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + + it("Args.file, prefix of non-existent file should return no completions", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("program", "does-not-exist") + const index = 1 + const command = Command.standard("foo", { args: Args.file() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + + it("Args.directory, no prefix provided", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("program") + const index = 1 + const command = Command.standard("foo", { args: Args.directory() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ReadonlyArray.make("barDir/", "bippyDir/", "fooDir/") + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + + it("Args.directory, prefix provided", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("program", "f") + const index = 1 + const command = Command.standard("foo", { args: Args.directory() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ReadonlyArray.make("fooDir/") + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + + it("Args.directory, complete directory name provided", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("program", "fooDir") + const index = 1 + const command = Command.standard("foo", { args: Args.directory() }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ReadonlyArray.make("fooDir/") + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + }) + }) + + describe("Commands - No Options, Multiple Args", () => { + it("partial word 'baz' should return 'baz' and 'bazinga'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "baz") + const index = 1 + const command = Command.standard("foo", { + args: Args.all([ + Args.choice([["bar", 1], ["baz", 2], ["bazinga", 3]]), + Args.boolean() + ]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("baz ", "bazinga ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'tru' should return 'true'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "baz", "tru") + const index = 2 + const command = Command.standard("foo", { + args: Args.all([ + Args.choice([["bar", 1], ["baz", 2], ["bazinga", 3]]), + Args.boolean() + ]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("true ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("completing ['foo', 'baz', 'tru'] at position 1 should complete with 'baz' and 'bazinga'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "baz", "tru") + const index = 1 + const command = Command.standard("foo", { + args: Args.all([ + Args.choice([["bar", 1], ["baz", 2], ["bazinga", 3]]), + Args.boolean() + ]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.make("baz ", "bazinga ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("completing ['foo', 'x', 'tru'] at position 2 should return no completions ('x' is invalid)", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "x", "tru") + const index = 2 + const command = Command.standard("foo", { + args: Args.all([ + Args.choice([["bar", 1], ["baz", 2], ["bazinga", 3]]), + Args.boolean() + ]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _(Completion.getCompletions(words, index, command, config, compgen)) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(runEffect)) + }) + + describe("Command - Options, No Args", () => { + describe("Boolean Options", () => { + const options = Options.all([ + Options.boolean("a"), + Options.boolean("b"), + Options.boolean("c"), + Options.boolean("d") + ]) + + it("no prefix should show all flags", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo") + const index = 1 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-a ", "-b ", "-c ", "-d ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-' prefix should show all flags", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-") + const index = 1 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-a ", "-b ", "-c ", "-d ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-a' prefix should show flags: '-b'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-a") + const index = 2 + const command = Command.standard("foo", { + options: Options.all([ + Options.boolean("a"), + Options.boolean("b") + ]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-b ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-b' prefix should show flags: '-a'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-b") + const index = 2 + const command = Command.standard("foo", { + options: Options.all([ + Options.boolean("a"), + Options.boolean("b") + ]) + }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-a ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-a' prefix should show flags: '-b' '-c' '-d'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-a") + const index = 2 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-b ", "-c ", "-d ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-d -a' prefix should show flags: '-b' '-c'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-d", "-a") + const index = 3 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-b ", "-c ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-d -c -b -' prefix should show flags: '-a'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-d", "-c", "-b", "-") + const index = 4 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-a ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("invalid flags should return no completions", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-x") + const index = 2 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(runEffect)) + }) + + describe("Integer Options", () => { + const options = Options.all([ + Options.integer("a"), + Options.integer("b"), + Options.integer("c"), + Options.integer("d") + ]) + + it("no prefix should show all flags", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo") + const index = 1 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-a ", "-b ", "-c ", "-d ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-c' without integer value should return no completions", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-c") + const index = 2 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-c' with integer value should complete with '-a' '-b' '-d'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-c", "1") + const index = 3 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-a ", "-b ", "-d ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-c' and '-b' with integer value should complete with '-a' '-d'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-c", "1", "-b", "2") + const index = 5 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-a ", "-d ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("'-c' with integer value and '-b' without integer value should return no completions", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "-c", "1", "-b") + const index = 5 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.empty() + expect(result).toEqual(expected) + }).pipe(runEffect)) + }) + + describe("Enumeration Options", () => { + const options = Options.choiceWithValue("qux", [ + ["bar", 1], + ["baz", 2], + ["bippy", 3] + ]) + + it("partial option names should complete with the same name of the option", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "--q") + const index = 1 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("--qux ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("no partial words should return the complete list of choices", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "--qux") + const index = 2 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("bar ", "baz ", "bippy ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'b' should complete with 'bar' 'baz' 'bippy'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "--qux", "b") + const index = 2 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("bar ", "baz ", "bippy ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'ba' should complete with 'bar' 'baz'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "--qux", "ba") + const index = 2 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("bar ", "baz ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("partial word 'baz' should complete with 'baz'", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("foo", "--qux", "baz") + const index = 2 + const command = Command.standard("foo", { options }) + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("baz ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + }) + }) + + describe("Command - Options and Args", () => { + describe("Tail", () => { + it("should complete the '-n' option name", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("tail", "-") + const index = 1 + const command = Tail.command + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-n ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("should complete file name", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("tail", "f") + const index = 1 + const command = Tail.command + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ReadonlyArray.make("foo.txt ", "fooDir/") + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + }) + + describe("WordCount", () => { + it("should complete the option names", () => + Effect.gen(function*(_) { + const words = ReadonlyArray.make("wc", "-") + const index = 1 + const command = WordCount.command + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen) + ) + const expected = ReadonlyArray.make("-c ", "-l ", "-m ", "-w ") + expect(result).toEqual(expected) + }).pipe(runEffect)) + + it("should complete the first file name", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("wc", "-l", "-c", "f") + const index = 3 + const command = WordCount.command + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ReadonlyArray.make("foo.txt ", "fooDir/") + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + + it("should complete the second file name", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const fileSystem = yield* _(FileSystem.FileSystem) + const tempDir = yield* _(fileSystem.makeTempDirectoryScoped()) + yield* _(Effect.all([ + fileSystem.writeFile(path.join(tempDir, "foo.txt"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bar.pdf"), new Uint8Array()), + fileSystem.writeFile(path.join(tempDir, "bippy.sh"), new Uint8Array()), + fileSystem.makeDirectory(path.join(tempDir, "fooDir")), + fileSystem.makeDirectory(path.join(tempDir, "barDir")), + fileSystem.makeDirectory(path.join(tempDir, "bippyDir")) + ])) + yield* _( + Effect.gen(function*(_) { + const words = ReadonlyArray.make("wc", "-l", "-c", "blah.md", "f") + const index = 4 + const command = WordCount.command + const config = CliConfig.defaultConfig + const compgen = yield* _(Compgen.Compgen) + const result = yield* _( + Completion.getCompletions(words, index, command, config, compgen), + Effect.map(ReadonlyArray.sort(Order.string)) + ) + const expected = ReadonlyArray.make("foo.txt ", "fooDir/") + expect(result).toEqual(expected) + }).pipe(Effect.provide(Compgen.TestCompgen(tempDir))) + ) + }).pipe(Effect.scoped, runEffect)) + }) + }) +}) diff --git a/test/Options.test.ts b/test/Options.test.ts index 3713951..aadd850 100644 --- a/test/Options.test.ts +++ b/test/Options.test.ts @@ -2,7 +2,14 @@ import * as CliConfig from "@effect/cli/CliConfig" import * as HelpDoc from "@effect/cli/HelpDoc" import * as Options from "@effect/cli/Options" import * as ValidationError from "@effect/cli/ValidationError" -import { Data, Effect, Either, HashMap, Option, ReadonlyArray } from "effect" +import * as FileSystem from "@effect/platform-node/FileSystem" +import * as Data from "effect/Data" +import * as Effect from "effect/Effect" +import * as Either from "effect/Either" +import { identity } from "effect/Function" +import * as HashMap from "effect/HashMap" +import * as Option from "effect/Option" +import * as ReadonlyArray from "effect/ReadonlyArray" import { describe, expect, it } from "vitest" const firstName = Options.text("firstName").pipe(Options.withAlias("f")) @@ -12,11 +19,21 @@ const ageOptional = Options.optional(age) const verbose = Options.boolean("verbose", { ifPresent: true }) const defs = Options.keyValueMap("defs").pipe(Options.withAlias("d")) +const MainLive = FileSystem.layer + +const runEffect = ( + self: Effect.Effect +): Promise => Effect.provide(self, MainLive).pipe(Effect.runPromise) + const validation = ( options: Options.Options, args: ReadonlyArray, config: CliConfig.CliConfig -): Effect.Effect, A]> => +): Effect.Effect< + FileSystem.FileSystem, + ValidationError.ValidationError, + readonly [ReadonlyArray, A] +> => Options.validate(options, args, config).pipe( Effect.flatMap(([err, rest, a]) => Option.match(err, { @@ -40,7 +57,7 @@ describe("Options", () => { const expected2 = [ReadonlyArray.empty(), ReadonlyArray.make("--firstName", "--lastName")] expect(result1).toEqual(expected1) expect(result2).toEqual(expected2) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("should not uncluster values", () => Effect.gen(function*(_) { @@ -48,7 +65,7 @@ describe("Options", () => { const result = yield* _(validation(firstName, args, CliConfig.defaultConfig)) const expected = [ReadonlyArray.empty(), "-ab"] expect(result).toEqual(expected) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("should return a HelpDoc if an option is not an exact match and it's a short option", () => Effect.gen(function*(_) { @@ -57,7 +74,22 @@ describe("Options", () => { expect(result).toEqual(ValidationError.missingValue(HelpDoc.p( "Expected to find option: '--age'" ))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) + + it("should return a HelpDoc if there is a collision between arguments", () => + Effect.gen(function*(_) { + const options = Options.orElse( + Options.text("a").pipe(Options.map(identity)), + Options.text("b").pipe(Options.map(identity)) + ) + const args = ReadonlyArray.make("-a", "a", "-b", "b") + const result = yield* _(Effect.flip(validation(options, args, CliConfig.defaultConfig))) + console.log(result) + expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p( + "Collision between two options detected - you can only " + + "specify one of either: ['-a', '-b']" + ))) + }).pipe(runEffect)) it("validates a boolean option without a value", () => Effect.gen(function*(_) { @@ -65,7 +97,7 @@ describe("Options", () => { const result = yield* _(validation(verbose, args, CliConfig.defaultConfig)) const expected = [ReadonlyArray.empty(), true] expect(result).toEqual(expected) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates a boolean option with a followup option", () => Effect.gen(function*(_) { @@ -82,7 +114,7 @@ describe("Options", () => { expect(result1).toEqual(expected1) expect(result2).toEqual(expected2) expect(result3).toEqual(expected3) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates a boolean option with negation", () => Effect.gen(function*(_) { @@ -111,7 +143,7 @@ describe("Options", () => { "Collision between two options detected - " + "you can only specify one of either: ['--verbose', '--silent']" ))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("does not validate collision of boolean options with negation", () => Effect.gen(function*(_) { @@ -122,7 +154,7 @@ describe("Options", () => { "Collision between two options detected - " + "you can only specify one of either: ['-v', '-s']" ))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates a text option", () => Effect.gen(function*(_) { @@ -130,32 +162,32 @@ describe("Options", () => { validation(firstName, ["--firstName", "John"], CliConfig.defaultConfig) ) expect(result).toEqual([[], "John"]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates a text option with an alternative format", () => Effect.gen(function*(_) { const result = yield* _(validation(firstName, ["--firstName=John"], CliConfig.defaultConfig)) expect(result).toEqual([[], "John"]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates a text option with an alias", () => Effect.gen(function*(_) { const result = yield* _(validation(firstName, ["-f", "John"], CliConfig.defaultConfig)) expect(result).toEqual([[], "John"]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates an integer option", () => Effect.gen(function*(_) { const result = yield* _(validation(age, ["--age", "100"], CliConfig.defaultConfig)) expect(result).toEqual([[], 100]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates an option and returns the remainder", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--firstName", "John", "--lastName", "Doe") const result = yield* _(validation(firstName, args, CliConfig.defaultConfig)) expect(result).toEqual([["--lastName", "Doe"], "John"]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("does not validate when no valid values are passed", () => Effect.gen(function*(_) { @@ -164,7 +196,7 @@ describe("Options", () => { expect(result).toEqual(Either.left(ValidationError.missingValue(HelpDoc.p( "Expected to find option: '--firstName'" )))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("does not validate when an option is passed without a corresponding value", () => Effect.gen(function*(_) { @@ -173,7 +205,7 @@ describe("Options", () => { expect(result).toEqual(Either.left(ValidationError.missingValue(HelpDoc.p( "Expected a value following option: '--firstName'" )))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("does not validate an invalid option value", () => Effect.gen(function*(_) { @@ -181,7 +213,7 @@ describe("Options", () => { const args = ReadonlyArray.make("-t", "abc") const result = yield* _(Effect.flip(validation(option, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p("'abc' is not a integer"))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("does not validate an invalid option value even when there is a default", () => Effect.gen(function*(_) { @@ -189,7 +221,7 @@ describe("Options", () => { const args = ReadonlyArray.make("-t", "abc") const result = yield* _(Effect.flip(validation(option, args, CliConfig.defaultConfig))) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p("'abc' is not a integer"))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates with case-sensitive configuration", () => Effect.gen(function*(_) { @@ -211,27 +243,27 @@ describe("Options", () => { expect(result4).toEqual(ValidationError.missingValue(HelpDoc.p( "Expected to find option: '--Firstname'" ))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates an unsupplied optional option", () => Effect.gen(function*(_) { const result = yield* _(validation(ageOptional, [], CliConfig.defaultConfig)) expect(result).toEqual([[], Option.none()]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates an unsupplied optional option with remainder", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--bar", "baz") const result = yield* _(validation(ageOptional, args, CliConfig.defaultConfig)) expect(result).toEqual([args, Option.none()]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates a supplied optional option", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--age", "20") const result = yield* _(validation(ageOptional, args, CliConfig.defaultConfig)) expect(result).toEqual([[], Option.some(20)]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates using all and returns the specified structure", () => Effect.gen(function*(_) { @@ -245,7 +277,7 @@ describe("Options", () => { const result2 = yield* _(validation(option2, args, CliConfig.defaultConfig)) expect(result1).toEqual([[], { firstName: "John", lastName: "Doe" }]) expect(result2).toEqual([[], ["John", "Doe"]]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validate provides a suggestion if a provided option is close to a specified option", () => Effect.gen(function*(_) { @@ -254,7 +286,7 @@ describe("Options", () => { expect(result).toEqual(ValidationError.correctedFlag(HelpDoc.p( "The flag '--firstme' is not recognized. Did you mean '--firstName'?" ))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validate provides a suggestion if a provided option with a default is close to a specified option", () => Effect.gen(function*(_) { @@ -282,7 +314,7 @@ describe("Options", () => { const result2 = yield* _(validation(option, args2, CliConfig.defaultConfig)) expect(result1).toEqual([[], Either.right(2)]) expect(result2).toEqual([[], Either.left("two")]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("orElse - option collision", () => Effect.gen(function*(_) { @@ -293,7 +325,7 @@ describe("Options", () => { "Collision between two options detected - " + "you can only specify one of either: ['--string', '--integer']" ))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("orElse - no options provided", () => Effect.gen(function*(_) { @@ -304,7 +336,7 @@ describe("Options", () => { HelpDoc.p("Expected to find option: '--integer'") )) expect(result).toEqual(error) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("orElse - invalid option provided with a default", () => Effect.gen(function*(_) { @@ -319,7 +351,7 @@ describe("Options", () => { HelpDoc.p("Expected to find option: '--max'") )) expect(result).toEqual(error) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("keyValueMap - validates a missing option", () => Effect.gen(function*(_) { @@ -327,28 +359,28 @@ describe("Options", () => { expect(result).toEqual(ValidationError.missingValue(HelpDoc.p( "Expected to find option: '--defs'" ))) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("keyValueMap - validates repeated values", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("-d", "key1=v1", "-d", "key2=v2", "--verbose") const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([["--verbose"], HashMap.make(["key1", "v1"], ["key2", "v2"])]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("keyValueMap - validates different key/values", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("--defs", "key1=v1", "key2=v2", "--verbose") const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([["--verbose"], HashMap.make(["key1", "v1"], ["key2", "v2"])]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("keyValueMap - validates different key/values with alias", () => Effect.gen(function*(_) { const args = ReadonlyArray.make("-d", "key1=v1", "key2=v2", "--verbose") const result = yield* _(validation(defs, args, CliConfig.defaultConfig)) expect(result).toEqual([["--verbose"], HashMap.make(["key1", "v1"], ["key2", "v2"])]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("keyValueMap - validate should keep non-key-value parameters that follow the key-value pairs (each preceded by alias -d)", () => Effect.gen(function*(_) { @@ -368,7 +400,7 @@ describe("Options", () => { ["arg1", "arg2", "--verbose"], HashMap.make(["key1", "val1"], ["key2", "val2"], ["key3", "val3"]) ]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("keyValueMap - validate should keep non-key-value parameters that follow the key-value pairs (only the first key/value pair is preceded by alias)", () => Effect.gen(function*(_) { @@ -386,7 +418,7 @@ describe("Options", () => { ["arg1", "arg2", "--verbose"], HashMap.make(["key1", "val1"], ["key2", "val2"], ["key3", "val3"]) ]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("keyValueMap - validate should keep non-key-value parameters that follow the key-value pairs (with a 'mixed' style of proceeding -- name or alias)", () => Effect.gen(function*(_) { @@ -406,7 +438,7 @@ describe("Options", () => { ["key4=", "arg1", "arg2", "--verbose"], HashMap.make(["key1", "val1"], ["key2", "val2"], ["key3", "val3"]) ]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("choice", () => Effect.gen(function*($) { @@ -417,7 +449,7 @@ describe("Options", () => { const result2 = yield* $(validation(option, args2, CliConfig.defaultConfig)) expect(result1).toEqual([[], "cat"]) expect(result2).toEqual([[], "dog"]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("choiceWithValue", () => Effect.gen(function*($) { @@ -436,5 +468,5 @@ describe("Options", () => { const result2 = yield* $(validation(option, args2, CliConfig.defaultConfig)) expect(result1).toEqual([[], cat]) expect(result2).toEqual([[], dog]) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) }) diff --git a/test/Primitive.test.ts b/test/Primitive.test.ts index 0445906..02564c6 100644 --- a/test/Primitive.test.ts +++ b/test/Primitive.test.ts @@ -1,9 +1,13 @@ import * as CliConfig from "@effect/cli/CliConfig" import * as Primitive from "@effect/cli/Primitive" +import * as FileSystem from "@effect/platform-node/FileSystem" import { Effect, Equal, Function, Option, ReadonlyArray } from "effect" import fc from "fast-check" import { describe, expect, it } from "vitest" +const runEffect = (self: Effect.Effect): Promise => + Effect.provide(self, FileSystem.layer).pipe(Effect.runPromise) + describe("Primitive", () => { describe("Bool", () => { it("validates that truthy text representations of a boolean return true", () => @@ -12,7 +16,7 @@ describe("Primitive", () => { const bool = Primitive.boolean(Option.none()) const result = yield* _(bool.validate(Option.some(str), CliConfig.defaultConfig)) expect(result).toBe(true) - }).pipe(Effect.runPromise)))) + }).pipe(runEffect)))) it("validates that falsy text representations of a boolean return false", () => fc.assert(fc.asyncProperty(falseValuesArb, (str) => @@ -20,7 +24,7 @@ describe("Primitive", () => { const bool = Primitive.boolean(Option.none()) const result = yield* _(bool.validate(Option.some(str), CliConfig.defaultConfig)) expect(result).toBe(false) - }).pipe(Effect.runPromise)))) + }).pipe(runEffect)))) it("validates that invalid boolean representations are rejected", () => Effect.gen(function*(_) { @@ -29,7 +33,7 @@ describe("Primitive", () => { Effect.flip(bool.validate(Option.some("bad"), CliConfig.defaultConfig)) ) expect(result).toBe("Unable to recognize 'bad' as a valid boolean") - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) it("validates that the default value will be used if a value is not provided", () => fc.assert(fc.asyncProperty(fc.boolean(), (value) => @@ -37,7 +41,7 @@ describe("Primitive", () => { const bool = Primitive.boolean(Option.some(value)) const result = yield* _(bool.validate(Option.none(), CliConfig.defaultConfig)) expect(result).toBe(value) - }).pipe(Effect.runPromise)))) + }).pipe(runEffect)))) }) describe("Choice", () => { @@ -54,7 +58,7 @@ describe("Primitive", () => { choice.validate(Option.some(selectedName), CliConfig.defaultConfig) ) expect(result).toEqual(selectedValue) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) )) it("does not validate a choice that is not one of the alternatives", () => @@ -70,7 +74,7 @@ describe("Primitive", () => { Effect.flip(choice.validate(Option.some(selectedName), CliConfig.defaultConfig)) ) expect(result).toMatch(/^Expected one of the following cases:\s.*/) - }).pipe(Effect.runPromise)))) + }).pipe(runEffect)))) }) simplePrimitiveTestSuite(Primitive.date, fc.date(), "Date") @@ -91,7 +95,7 @@ describe("Primitive", () => { Primitive.text.validate(Option.some(str), CliConfig.defaultConfig) ) expect(result).toEqual(str) - }).pipe(Effect.runPromise)))) + }).pipe(runEffect)))) }) }) @@ -107,7 +111,7 @@ const simplePrimitiveTestSuite = ( const str = value instanceof Date ? value.toISOString() : `${value}` const result = yield* _(primitive.validate(Option.some(str), CliConfig.defaultConfig)) expect(result).toEqual(value) - }).pipe(Effect.runPromise)))) + }).pipe(runEffect)))) it(`validates that invalid values are rejected`, () => Effect.gen(function*(_) { @@ -115,7 +119,7 @@ const simplePrimitiveTestSuite = ( Effect.flip(primitive.validate(Option.some("bad"), CliConfig.defaultConfig)) ) expect(result).toBe(`'bad' is not a ${primitive.typeName}`) - }).pipe(Effect.runPromise)) + }).pipe(runEffect)) }) } diff --git a/test/RegularLanguage.test.ts b/test/RegularLanguage.test.ts new file mode 100644 index 0000000..79462c2 --- /dev/null +++ b/test/RegularLanguage.test.ts @@ -0,0 +1,472 @@ +import * as CliConfig from "@effect/cli/CliConfig" +import * as Primitive from "@effect/cli/Primitive" +import * as RegularLanguage from "@effect/cli/RegularLanguage" +import * as FileSystem from "@effect/platform-node/FileSystem" +import * as Effect from "effect/Effect" +import { pipe } from "effect/Function" +import * as Option from "effect/Option" +import * as ReadonlyArray from "effect/ReadonlyArray" +import * as fc from "fast-check" +import { describe, expect, it } from "vitest" + +const runEffect = (self: Effect.Effect): Promise => + Effect.provide(self, FileSystem.layer).pipe(Effect.runPromise) + +describe("RegularLanguage", () => { + it("Empty language - rejects all strings", () => + fc.assert( + fc.asyncProperty(fc.array(fc.string(), { minLength: 0, maxLength: 5 }), (tokens) => + Effect.gen(function*(_) { + const result = yield* _( + RegularLanguage.contains(RegularLanguage.empty, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + )) + + it("Epsilon language - accepts the empty string", () => + Effect.gen(function*(_) { + const tokens = ReadonlyArray.empty() + const result = yield* _( + RegularLanguage.contains(RegularLanguage.epsilon, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(true) + }).pipe(runEffect)) + + it("Epsilon language - rejects all non-empty strings", () => + fc.assert( + fc.asyncProperty(fc.array(fc.string(), { minLength: 1, maxLength: 5 }), (tokens) => + Effect.gen(function*(_) { + const result = yield* _( + RegularLanguage.contains(RegularLanguage.epsilon, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + )) + + it("StringToken language - accepts the target string", () => + Effect.gen(function*(_) { + const tokens = ReadonlyArray.make("foo") + const result = yield* _( + RegularLanguage.contains(RegularLanguage.string("foo"), tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(true) + }).pipe(runEffect)) + + it("StringToken language - rejects anything other than the target string", () => + fc.assert( + fc.asyncProperty(fc.string().filter((str) => str !== "foo"), (token) => + Effect.gen(function*(_) { + const tokens = ReadonlyArray.make(token) + const result = yield* _( + RegularLanguage.contains(RegularLanguage.string("foo"), tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + )) + + it("Primitive.Bool language - accepts values corresponding to 'true' and 'false'", () => + fc.assert( + fc.asyncProperty(fc.oneof(trueValuesArb, falseValuesArb), (token) => + Effect.gen(function*(_) { + const tokens = ReadonlyArray.make(token) + const language = RegularLanguage.primitive(Primitive.boolean(Option.none())) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(true) + }).pipe(runEffect)) + )) + + it("Primitive.Bool language - rejects values that do not correspond to 'true' or 'false'", () => { + const arbitrary = fc.string() + .map((str) => str.toLowerCase()) + .filter((str) => !trueValues.has(str) && !falseValues.has(str)) + return fc.assert( + fc.asyncProperty(arbitrary, (token) => + Effect.gen(function*(_) { + const tokens = ReadonlyArray.make(token) + const language = RegularLanguage.primitive(Primitive.boolean(Option.none())) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + ) + }) + + it("Cat language - accepts 'foo' 'bar' 'baz'", () => + Effect.gen(function*(_) { + const tokens = ReadonlyArray.make("foo", "bar", "baz") + const language = RegularLanguage.concat( + RegularLanguage.string("foo"), + RegularLanguage.concat( + RegularLanguage.string("bar"), + RegularLanguage.string("baz") + ) + ) + const result = yield* _(RegularLanguage.contains(language, tokens, CliConfig.defaultConfig)) + expect(result).toBe(true) + }).pipe(runEffect)) + + it("Cat language - rejects anything that is not 'foo' 'bar' 'baz'", () => { + const arbitrary = fc.oneof( + fc.constant(ReadonlyArray.empty()), + fc.constant(["foo"]), + fc.constant(["foo", "bar"]), + fc.constant(["foo", "baz"]), + fc.constant(["foo", "bar", "baz", "bippy"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.concat( + RegularLanguage.string("foo"), + RegularLanguage.concat( + RegularLanguage.string("bar"), + RegularLanguage.string("baz") + ) + ) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + ) + }) + + it("Alt language - accepts 'foo' 'bar'", () => + Effect.gen(function*(_) { + const tokens = ReadonlyArray.make("foo", "bar") + const language = RegularLanguage.orElse( + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("bar")), + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("baz")) + ) + const result = yield* _(RegularLanguage.contains(language, tokens, CliConfig.defaultConfig)) + expect(result).toBe(true) + }).pipe(runEffect)) + + it("Alt language - accepts 'foo' 'baz'", () => + Effect.gen(function*(_) { + const tokens = ReadonlyArray.make("foo", "baz") + const language = RegularLanguage.orElse( + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("bar")), + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("baz")) + ) + const result = yield* _(RegularLanguage.contains(language, tokens, CliConfig.defaultConfig)) + expect(result).toBe(true) + }).pipe(runEffect)) + + it("Alt language - rejects anything that is not 'foo' 'bar' | 'foo' 'baz'", () => { + const arbitrary = fc.oneof( + fc.constant(ReadonlyArray.empty()), + fc.constant(["foo"]), + fc.constant(["foo", "bar", "baz"]), + fc.constant(["foo", "bar", "foo", "baz"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.orElse( + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("bar")), + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("baz")) + ) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + ) + }) + + it("Repeat language - accepts zero or more repetitions of 'foo' 'bar' | 'foo' 'baz'", () => { + const arbitrary = fc.oneof( + fc.constant(ReadonlyArray.empty()), + fc.constant(["foo", "bar"]), + fc.constant(["foo", "baz"]), + fc.constant(["foo", "bar", "foo", "baz"]), + fc.constant(["foo", "baz", "foo", "bar"]), + fc.constant(["foo", "bar", "foo", "bar"]), + fc.constant(["foo", "baz", "foo", "baz"]), + fc.constant(["foo", "bar", "foo", "baz", "foo", "bar"]), + fc.constant(["foo", "baz", "foo", "baz", "foo", "bar", "foo", "baz"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.orElse( + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("bar")), + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("baz")) + ).pipe(RegularLanguage.repeated()) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(true) + }).pipe(runEffect)) + ) + }) + + it("Repeat language - rejects everything except zero or more repetitions of 'foo' 'bar' | 'foo' 'baz'", () => { + const arbitrary = fc.oneof( + fc.constant(["foo", "bar", "foo"]), + fc.constant(["foo", "baz", "foo"]), + fc.constant(["foo", "bar", "foo", "baz", "foo"]), + fc.constant(["foo", "baz", "foo", "bar", "baz"]), + fc.constant(["foo", "bar", "foo", "bar", "bar"]), + fc.constant(["foo", "baz", "foo", "baz", "baz"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.orElse( + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("bar")), + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("baz")) + ).pipe(RegularLanguage.repeated()) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + ) + }) + + it("Repeat language - accepts two to four repetitions of 'foo' 'bar' | 'foo' 'baz'", () => { + const arbitrary = fc.oneof( + fc.constant(["foo", "bar", "foo", "bar"]), + fc.constant(["foo", "bar", "foo", "baz"]), + fc.constant(["foo", "bar", "foo", "baz", "foo", "bar"]), + fc.constant(["foo", "baz", "foo", "bar", "foo", "bar"]), + fc.constant(["foo", "bar", "foo", "baz", "foo", "bar", "foo", "baz"]), + fc.constant(["foo", "baz", "foo", "baz", "foo", "bar", "foo", "baz"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.orElse( + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("bar")), + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("baz")) + ).pipe(RegularLanguage.repeated({ min: 2, max: 4 })) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(true) + }).pipe(runEffect)) + ) + }) + + it("Repeat language - rejects everything except two to four repetitions of 'foo' 'bar' | 'foo' 'baz'", () => { + const arbitrary = fc.oneof( + fc.constant(ReadonlyArray.empty()), + fc.constant(["foo", "bar"]), + fc.constant(["foo", "baz"]), + fc.constant(["foo", "baz", "foo"]), + fc.constant(["foo", "baz", "bar"]), + fc.constant(["foo", "bar", "foo", "baz", "foo"]), + fc.constant(["foo", "baz", "foo", "bar", "baz"]), + fc.constant(["foo", "bar", "foo", "bar", "bar"]), + fc.constant(["foo", "baz", "foo", "baz", "baz"]), + fc.constant(["foo", "baz", "foo", "baz", "foo", "baz", "foo", "baz", "foo", "baz"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.orElse( + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("bar")), + RegularLanguage.concat(RegularLanguage.string("foo"), RegularLanguage.string("baz")) + ).pipe(RegularLanguage.repeated({ min: 2, max: 4 })) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + ) + }) + + describe("Permutation Language - { 'a', 'b', 'c', 'd' }", () => { + it("accepts permutations of { 'a', 'b', 'c', 'd' }", () => { + const arbitrary = fc.constantFrom(...permutations(["a", "b", "c", "d"])) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const values = pipe( + ReadonlyArray.make("a", "b", "c", "d"), + ReadonlyArray.map((str) => RegularLanguage.string(str)) + ) + const language = RegularLanguage.permutation(values) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(true) + }).pipe(runEffect)) + ) + }) + + it("rejects everything except permutations of { 'a', 'b', 'c', 'd' }", () => { + const arbitrary = fc.oneof( + fc.constant(ReadonlyArray.empty()), + fc.constant(["a"]), + fc.constant(["b"]), + fc.constant(["c"]), + fc.constant(["d"]), + fc.constant(["a", "b", "c"]), + fc.constant(["d", "c", "b"]), + fc.constant(["a", "b", "c", "d", "d"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const values = pipe( + ReadonlyArray.make("a", "b", "c", "d"), + ReadonlyArray.map((str) => RegularLanguage.string(str)) + ) + const language = RegularLanguage.permutation(values) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + ) + }) + }) + + describe("Permutation Language - { 'a', 'b' | 'c', 'd'.* }", () => { + it("accepts language members", () => { + const arbitrary = fc.oneof( + fc.constant(["a", "b"]), + fc.constant(["a", "c"]), + fc.constant(["a", "b", "d"]), + fc.constant(["a", "b", "d", "d", "d"]), + fc.constant(["a", "c", "d"]), + fc.constant(["a", "c", "d", "d", "d"]), + fc.constant(["d", "b", "a"]), + fc.constant(["d", "d", "d", "b", "a"]), + fc.constant(["d", "c", "a"]), + fc.constant(["d", "d", "d", "c", "a"]), + fc.constant(["d", "a", "b"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.permutation([ + RegularLanguage.string("a"), + RegularLanguage.orElse(RegularLanguage.string("b"), RegularLanguage.string("c")), + RegularLanguage.repeated(RegularLanguage.string("d")) + ]) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(true) + }).pipe(runEffect)) + ) + }) + + it("rejects language non-members", () => { + const arbitrary = fc.oneof( + fc.constant(ReadonlyArray.empty()), + fc.constant(["a"]), + fc.constant(["b"]), + fc.constant(["c"]), + fc.constant(["d"]), + fc.constant(["d", "a", "d"]), + fc.constant(["a", "c", "c"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.permutation([ + RegularLanguage.string("a"), + RegularLanguage.orElse(RegularLanguage.string("b"), RegularLanguage.string("c")), + RegularLanguage.repeated(RegularLanguage.string("d")) + ]) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + ) + }) + }) + + describe("Permutation Language - { 'a'?, 'b'?, 'c'?, 'd'? } 'z'", () => { + it("accepts language members", () => { + const arbitrary = fc.oneof( + fc.constant(["z"]), + fc.constant(["a", "b", "z"]), + fc.constant(["a", "c", "z"]), + fc.constant(["a", "b", "d", "z"]), + fc.constant(["d", "z"]), + fc.constant(["a", "c", "d", "z"]), + fc.constant(["d", "b", "a", "z"]), + fc.constant(["d", "c", "a", "z"]), + fc.constant(["d", "a", "b", "z"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.permutation([ + RegularLanguage.string("a").pipe(RegularLanguage.optional), + RegularLanguage.string("b").pipe(RegularLanguage.optional), + RegularLanguage.string("c").pipe(RegularLanguage.optional), + RegularLanguage.string("d").pipe(RegularLanguage.optional) + ]).pipe(RegularLanguage.concat(RegularLanguage.string("z"))) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(true) + }).pipe(runEffect)) + ) + }) + + it("rejects language non-members", () => { + const arbitrary = fc.oneof( + fc.constant(ReadonlyArray.empty()), + fc.constant(["a", "b"]), + fc.constant(["a", "c", "c", "z"]), + fc.constant(["a", "b", "d"]), + fc.constant(["d", "z", "z"]), + fc.constant(["a", "c", "d"]), + fc.constant(["d", "b", "a"]), + fc.constant(["d", "c", "a"]), + fc.constant(["d", "a", "b"]) + ) + return fc.assert( + fc.asyncProperty(arbitrary, (tokens) => + Effect.gen(function*(_) { + const language = RegularLanguage.permutation([ + RegularLanguage.string("a").pipe(RegularLanguage.optional), + RegularLanguage.string("b").pipe(RegularLanguage.optional), + RegularLanguage.string("c").pipe(RegularLanguage.optional), + RegularLanguage.string("d").pipe(RegularLanguage.optional) + ]).pipe(RegularLanguage.concat(RegularLanguage.string("z"))) + const result = yield* _( + RegularLanguage.contains(language, tokens, CliConfig.defaultConfig) + ) + expect(result).toBe(false) + }).pipe(runEffect)) + ) + }) + }) +}) + +const trueValues = new Set(["true", "1", "y", "yes", "on"]) +const falseValues = new Set(["false", "0", "n", "no", "off"]) + +const trueValuesArb = fc.constantFrom(...trueValues) +const falseValuesArb = fc.constantFrom(...falseValues) + +const permutations = (elements: Array): ReadonlyArray> => { + const result: Array> = [] + const permute = (arr: Array, m: Array) => { + if (arr.length === 0) { + result.push(m) + } else { + for (let i = 0; i < arr.length; i++) { + const curr = arr.slice() + const next = curr.splice(i, 1) + permute(curr.slice(), m.concat(next)) + } + } + } + permute(elements, []) + return result +} diff --git a/test/utils/tail.ts b/test/utils/tail.ts index dfb46d9..5453d7a 100644 --- a/test/utils/tail.ts +++ b/test/utils/tail.ts @@ -2,9 +2,11 @@ import * as Args from "@effect/cli/Args" import * as Command from "@effect/cli/Command" import * as Options from "@effect/cli/Options" -export const options: Options.Options = Options.integer("n") +export const options: Options.Options = Options.integer("n").pipe( + Options.withDefault(10) +) -export const args: Args.Args = Args.text({ name: "file" }) +export const args: Args.Args = Args.file({ name: "file" }) export const command: Command.Command<{ readonly name: "tail" diff --git a/test/utils/wc.ts b/test/utils/wc.ts index 7193dee..99133f2 100644 --- a/test/utils/wc.ts +++ b/test/utils/wc.ts @@ -13,7 +13,7 @@ export const options: Options.Options> = Args.repeated(Args.text({ name: "files" })) +export const args: Args.Args> = Args.repeated(Args.file({ name: "files" })) export const command: Command.Command<{ readonly name: "wc"