├── .ameba.yml ├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── nightly.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── cat │ └── cat.cr ├── greet │ ├── greet.cr │ └── welcome_command.cr └── stat │ └── stat.cr ├── shard.yml ├── spec ├── argument_spec.cr ├── command_spec.cr ├── formatter_spec.cr ├── helper_spec.cr ├── main_spec.cr ├── option_spec.cr ├── parser_spec.cr ├── spec_helper.cr └── value_spec.cr └── src ├── cling.cr └── cling ├── argument.cr ├── command.cr ├── errors.cr ├── executor.cr ├── ext.cr ├── formatter.cr ├── helper.cr ├── option.cr ├── parser.cr └── value.cr /.ameba.yml: -------------------------------------------------------------------------------- 1 | Globs: 2 | - src/**/*.cr 3 | - spec/**/*.cr 4 | 5 | Metrics/CyclomaticComplexity: 6 | Enabled: false 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths: src/ 6 | branches: 7 | - main 8 | 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | checks: write 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: Install Crystal 24 | uses: crystal-lang/install-crystal@v1 25 | with: 26 | crystal: latest 27 | 28 | - name: Install Ameba 29 | uses: crystal-ameba/github-action@v0.8.0 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Check Format 34 | run: crystal tool format --check 35 | 36 | - name: Run Specs 37 | run: crystal spec 38 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Nightly 2 | 3 | on: 4 | schedule: 5 | - cron: "0 6 * * 6" 6 | 7 | permissions: 8 | checks: write 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Install Crystal 18 | uses: crystal-lang/install-crystal@v1 19 | with: 20 | crystal: nightly 21 | 22 | - name: Install Ameba 23 | uses: crystal-ameba/github-action@v0.8.0 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: Check Format 28 | run: crystal tool format --check 29 | 30 | - name: Run Specs 31 | run: crystal spec 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /test/ 5 | /.shards/ 6 | /.vscode/ 7 | *.cmd 8 | *.dwarf 9 | *.sh 10 | 11 | # Libraries don't need dependency lock 12 | # Dependencies will be locked in applications that use them 13 | /shard.lock 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cling 2 | 3 | Based on [spf13/cobra](https://github.com/spf13/cobra), Cling is built to be almost entirely modular, giving you absolute control over almost everything without the need for embedded macros - there isn't even a default help command or flag! 4 | 5 | ## Contents 6 | 7 | - [Installation](#installation) 8 | - [Basic Usage](#basic-usage) 9 | - [Commands](#commands) 10 | - [Arguments and Options](#arguments-and-options) 11 | - [Customising](#customising) 12 | - [Extensions](#extensions) 13 | - [Motivation](#motivation) 14 | - [Projects using Cling](#projects-using-cling) 15 | - [Contributing](#contributing) 16 | - [Contributors](#contributors) 17 | 18 | ## Installation 19 | 20 | 1. Add the dependency to your `shard.yml`: 21 | 22 | ```yaml 23 | dependencies: 24 | cling: 25 | github: devnote-dev/cling 26 | version: ">= 3.0.0" 27 | ``` 28 | 29 | 2. Run `shards install` 30 | 31 | ## Basic Usage 32 | 33 | ```crystal 34 | require "cling" 35 | 36 | class MainCommand < Cling::Command 37 | def setup : Nil 38 | @name = "greet" 39 | @description = "Greets a person" 40 | add_argument "name", description: "the name of the person to greet", required: true 41 | add_option 'c', "caps", description: "greet with capitals" 42 | add_option 'h', "help", description: "sends help information" 43 | end 44 | 45 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Bool 46 | if options.has? "help" 47 | puts help_template # generated using Cling::Formatter 48 | 49 | false 50 | else 51 | true 52 | end 53 | end 54 | 55 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 56 | message = "Hello, #{arguments.get("name")}!" 57 | 58 | if options.has? "caps" 59 | puts message.upcase 60 | else 61 | puts message 62 | end 63 | end 64 | end 65 | 66 | main = MainCommand.new 67 | main.execute ARGV 68 | ``` 69 | 70 | ``` 71 | $ crystal greet.cr -h 72 | Usage: 73 | greet [options] 74 | 75 | Arguments: 76 | name the name of the person to greet (required) 77 | 78 | Options: 79 | -c, --caps greet with capitals 80 | -h, --help sends help information 81 | 82 | $ crystal greet.cr Dev 83 | Hello, Dev! 84 | 85 | $ crystal greet.cr -c Dev 86 | HELLO, DEV! 87 | ``` 88 | 89 | ## Commands 90 | 91 | By default, the `Command` class is initialized with almost no values. All information about the command must be defined in the `setup` method. 92 | 93 | ```crystal 94 | class MainCommand < Cling::Command 95 | def setup : Nil 96 | @name = "greet" 97 | @description = "Greets a person" 98 | # defines an argument 99 | add_argument "name", description: "the name of the person to greet", required: true 100 | # defines a flag option 101 | add_option 'c', "caps", description: "greet with capitals" 102 | add_option 'h', "help", description: "sends help information" 103 | end 104 | end 105 | ``` 106 | 107 | > [!NOTE] 108 | > See [command.cr](/src/cling/command.cr) for the full list of options. 109 | 110 | Commands can also contain children, or subcommands: 111 | 112 | ```crystal 113 | require "cling" 114 | # import our subcommand here 115 | require "./welcome_command" 116 | 117 | # using the `MainCommand` created earlier 118 | main = MainCommand.new 119 | main.add_command WelcomeCommand.new 120 | # there is also the `add_commands` method for adding multiple 121 | # subcommands at one time 122 | 123 | # run the command 124 | main.execute ARGV 125 | ``` 126 | 127 | ``` 128 | $ crystal greet.cr -h 129 | Usage: 130 | greet [options] 131 | 132 | Commands: 133 | welcome sends a friendly welcome message 134 | 135 | Arguments: 136 | name the name of person to greet (required) 137 | 138 | Options: 139 | -c, --caps greet with capitals 140 | -h, --help sends help information 141 | 142 | $ crystal greet.cr welcome Dev 143 | Welcome to the CLI world, Dev! 144 | ``` 145 | 146 | As well as being able to have subcommands, they can also inherit certain properties from the parent command: 147 | 148 | ```crystal 149 | # in welcome_command.cr ... 150 | class WelcomeCommand < Cling::Command 151 | def setup : Nil 152 | # ... 153 | 154 | # this will inherit the header and footer properties 155 | @inherit_borders = true 156 | # this will NOT inherit the parent flag options 157 | @inherit_options = false 158 | # this will inherit the input, output and error IO streams 159 | @inherit_streams = true 160 | end 161 | end 162 | ``` 163 | 164 | ## Arguments and Options 165 | 166 | Arguments and flag options can be defined in the `setup` method of a command using the `add_argument` and `add_option` methods respectively. 167 | 168 | ```crystal 169 | class MainCommand < Cling::Command 170 | def setup : Nil 171 | add_argument "name", 172 | # sets a description for it 173 | description: "the name of the person to greet", 174 | # set it as a required or optional argument 175 | required: true, 176 | # allow multiple values for the argument 177 | multiple: false, 178 | # make it hidden from the help template 179 | hidden: false 180 | 181 | # define an option with a short flag using chars 182 | add_option 'c', "caps", 183 | # sets a description for it 184 | description: "greet with capitals", 185 | # set it as a required or optional flag 186 | required: false, 187 | # the type of option it is, can be: 188 | # :none to take no arguments 189 | # :single to take one argument 190 | # or :multiple to take multiple arguments 191 | type: :none, 192 | # optionally set a default value 193 | default: nil, 194 | # make it hidden from the help template 195 | hidden: false 196 | end 197 | end 198 | ``` 199 | 200 | > [!WARNING] 201 | > You can only have **one** argument with the `multiple` option which will include all the remaining input values (or unknown arguments). See the [example command](/examples/cat/cat.cr) for usage. 202 | 203 | These arguments and options can then be accessed at execution time via the `arguments` and `options` parameters in the `pre_run`, `run` and `post_run` methods of a command: 204 | 205 | ```crystal 206 | class MainCommand < Cling::Command 207 | # ... 208 | 209 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Bool # can also be `Nil` 210 | if arguments.get("name").as_s.blank? 211 | stderr.puts "Your name can't be blank!" 212 | 213 | false 214 | else 215 | true 216 | end 217 | end 218 | end 219 | ``` 220 | 221 | The `pre_run` method is slightly different to the other run methods: it allows returning a boolean to the command executor, which will determine whether the command should continue running – `false` will stop the command, `true` will continue. Explicitly returning `nil` or not specifying a return type is the same as returning `true`; the command will continue to run. 222 | 223 | If you try to access the value of an argument or option that isn't set, it will raise a `ValueNotFound` exception. To avoid this, use the `get?` method and check accordingly: 224 | 225 | ```crystal 226 | # ... 227 | 228 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 229 | caps = options.get?("caps").try(&.as_bool) || false 230 | stdout.puts caps # => false 231 | end 232 | ``` 233 | 234 | > [!NOTE] 235 | > See [argument.cr](/src/cling/argument.cr#L34) and [option.cr](/src/cling/option.cr#L51) for more information on parameter methods, and [value.cr](/src/cling/value.cr) for value methods. 236 | 237 | ## Customising 238 | 239 | The help template is divided into the following sections: 240 | 241 | ``` 242 | [HEADER] 243 | 244 | [DESCRIPTION] 245 | 246 | [USAGE] 247 | ]" "[]"> 248 | 249 | [COMMANDS] 250 | [ALIASES] 251 | 252 | [ARGUMENTS] 253 | ["(required)"] 254 | 255 | [OPTIONS] 256 | [SHORT] ["(required)"] ["(default: ...)"] 257 | 258 | [FOOTER] 259 | ``` 260 | 261 | Sections in `<>` will always be present, and ones in `[]` are optional depending on whether they are defined. Because of Cling's modularity, this means that you could essentially have a blank help template (wouldn't recommend it though). 262 | 263 | You can customise the following options for the help template formatter: 264 | 265 | ```crystal 266 | class Cling::Formatter::Options 267 | # The character to use for flag option delimiters (default is `-`). 268 | property option_delim : Char 269 | 270 | # Whether to show the `default` tag for options with default values (default is `true`). 271 | property show_defaults : Bool 272 | 273 | # Whether to show the `required` tag for required arguments/options (default is `true`). 274 | property show_required : Bool 275 | end 276 | ``` 277 | 278 | And pass it to the command like so: 279 | 280 | ```crystal 281 | require "cling" 282 | 283 | options = Cling::Formatter::Options.new option_delim: '+', show_defaults: false 284 | # we can re-use this in multiple commands 285 | formatter = Cling::Formatter.new options 286 | 287 | class MainCommand < Cling::Command 288 | # ... 289 | 290 | def help_template : String 291 | formatter.generate self 292 | end 293 | end 294 | ``` 295 | 296 | Alternatively, if you want a completely custom design, you can pass a string directly: 297 | 298 | ```crystal 299 | def help_template : String 300 | <<-TEXT 301 | My custom command help text! 302 | 303 | Use: 304 | greet [-c | --caps] [-h | --help] 305 | TEXT 306 | end 307 | ``` 308 | 309 | ## Extensions 310 | 311 | Cling comes with a few useful extension methods for handling argument and option values: 312 | 313 | ```crystal 314 | require "cling" 315 | require "cling/ext" 316 | 317 | class StatCommand < Cling::MainCommand 318 | def setup : Nil 319 | super 320 | 321 | @name = "stat" 322 | @description = "Gets the stat information of a file" 323 | 324 | add_argument "path", description: "the path of the file to stat", required: true 325 | end 326 | 327 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 328 | path = arguments.get("path").as_path 329 | 330 | if File.exists? path 331 | info = File.info path 332 | stdout.puts <<-INFO 333 | name: #{path.basename} 334 | size: #{info.size} 335 | directory: #{info.directory?} 336 | symlink: #{info.symlink?} 337 | permissions: #{info.permissions} 338 | INFO 339 | else 340 | stderr.puts "No file found at that path" 341 | end 342 | end 343 | end 344 | 345 | StatCommand.new.execute ARGV 346 | ``` 347 | 348 | ``` 349 | $ crystal stat.cr ./shard.yml 350 | name: shard.yml 351 | size: 272 352 | directory: false 353 | symlink: false 354 | permissions: rwxrwxrwx (0o777) 355 | ``` 356 | 357 | > [!NOTE] 358 | > See [ext.cr](/src/cling/ext.cr) for the full list of extension methods. 359 | 360 | Additionally, you can define your own extension methods on the `Value` struct like so: 361 | 362 | ```crystal 363 | require "cling" 364 | 365 | module Cling 366 | struct Value 367 | def as_chars : Array(Char) 368 | @raw.to_s.chars 369 | end 370 | end 371 | end 372 | 373 | class StatCommand 374 | # ... 375 | 376 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Nil 377 | puts arguments.get("path").as_chars 378 | # => ['.', '/', 's', 'h', 'a', 'r', 'd', '.', 'y', 'm', 'l'] 379 | end 380 | end 381 | ``` 382 | 383 | ## Motivation 384 | 385 | Most Crystal CLI builders/DSLs are opinionated with limited customisation available. Cling aims to be entirely modular so that you have the freedom to change whatever you want without having to write tons of boilerplate or monkey-patch code. Macro-based CLI shards can also be quite restrictive as they are not scalable, meaning that you may eventually have to refactor your application to another CLI shard. This is not meant to discourage you from using macro-based CLI shards, they are still useful for short and simple applications with a general template, but if you are looking for something to handle larger applications with guaranteed stability and scalability, Cling is the library for you. 386 | 387 | ## Projects using Cling 388 | 389 | Information made available thanks to [shards.info](https://shards.info/github/devnote-dev/cling/). 390 | 391 | - [Docr](https://github.com/devnote-dev/docr) - A CLI tool for searching Crystal documentation 392 | - [Fossil](https://github.com/PteroPackages/Fossil) - 📦 Pterodactyl Archive Manager 393 | - [Geode](https://github.com/devnote-dev/geode) - An alternative Crystal package manager 394 | - [Crimson](https://github.com/crimson-crystal/crimson) - A Crystal Version Manager 395 | - [tanda_cli](https://github.com/DanielGilchrist/tanda_cli) - A CLI application for people using Tanda/Workforce.com 396 | 397 | ## Contributing 398 | 399 | 1. Fork it () 400 | 2. Create your feature branch (`git checkout -b my-new-feature`) 401 | 3. Commit your changes (`git commit -am 'Add some feature'`) 402 | 4. Push to the branch (`git push origin my-new-feature`) 403 | 5. Create a new Pull Request 404 | 405 | ## Contributors 406 | 407 | - [Devonte W](https://github.com/devnote-dev) - creator and maintainer 408 | 409 | This repository is managed under the Mozilla Public License v2. 410 | 411 | © 2022-present devnote-dev 412 | -------------------------------------------------------------------------------- /examples/cat/cat.cr: -------------------------------------------------------------------------------- 1 | require "../../src/cling" 2 | require "../../src/cling/ext" 3 | 4 | class CatCommand < Cling::MainCommand 5 | def setup : Nil 6 | super 7 | 8 | @name = "cat" 9 | @description = "Concatenates one or more files" 10 | 11 | add_argument "files", description: "the files to concatenate", required: true, multiple: true 12 | end 13 | 14 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Bool 15 | files = arguments.get? "files" 16 | unless files 17 | stdout.puts help_template 18 | return false 19 | end 20 | 21 | files.as_set.each do |path| 22 | stderr.puts "file '#{path}' not found" unless File.exists? path 23 | end 24 | 25 | true 26 | end 27 | 28 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 29 | paths = arguments.get("files").as_set.select { |p| File.exists? p } 30 | return if paths.empty? 31 | 32 | str = paths.map do |path| 33 | File.read path rescue "" 34 | end.join '\n' 35 | 36 | stdout.puts str 37 | end 38 | end 39 | 40 | CatCommand.new.execute ARGV 41 | -------------------------------------------------------------------------------- /examples/greet/greet.cr: -------------------------------------------------------------------------------- 1 | require "../../src/cling" 2 | require "./welcome_command" 3 | 4 | class MainCommand < Cling::Command 5 | def setup : Nil 6 | @name = "greet" 7 | @description = "Greets a person" 8 | add_argument "name", description: "the name of the person to greet", required: true 9 | add_option 'c', "caps", description: "greet with capitals" 10 | add_option 'h', "help", description: "sends help information" 11 | end 12 | 13 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Bool 14 | if options.has? "help" 15 | puts help_template # generated using Cling::Formatter 16 | 17 | false 18 | else 19 | true 20 | end 21 | end 22 | 23 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 24 | message = "Hello, #{arguments.get("name")}!" 25 | 26 | if options.has? "caps" 27 | puts message.upcase 28 | else 29 | puts message 30 | end 31 | end 32 | end 33 | 34 | main = MainCommand.new 35 | main.add_command WelcomeCommand.new 36 | 37 | main.execute ARGV 38 | -------------------------------------------------------------------------------- /examples/greet/welcome_command.cr: -------------------------------------------------------------------------------- 1 | class WelcomeCommand < Cling::Command 2 | def setup : Nil 3 | @name = "welcome" 4 | @summary = @description = "sends a friendly welcome message" 5 | 6 | add_argument "name", description: "the name of the person to greet", required: true 7 | # this will inherit the header and footer properties 8 | @inherit_borders = true 9 | # this will NOT inherit the parent flag options 10 | @inherit_options = false 11 | # this will inherit the input, output and error IO streams 12 | @inherit_streams = true 13 | end 14 | 15 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Bool 16 | if options.has? "help" 17 | puts help_template # generated using Cling::Formatter 18 | 19 | false 20 | else 21 | true 22 | end 23 | end 24 | 25 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 26 | stdout.puts "Welcome to the CLI world, #{arguments.get("name")}!" 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /examples/stat/stat.cr: -------------------------------------------------------------------------------- 1 | require "../../src/cling" 2 | require "../../src/cling/ext" 3 | 4 | class StatCommand < Cling::MainCommand 5 | def setup : Nil 6 | super 7 | 8 | @name = "stat" 9 | @description = "Gets the stat information of a file" 10 | 11 | add_argument "path", description: "the path of the file to stat", required: true 12 | end 13 | 14 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 15 | path = arguments.get("path").as_path 16 | 17 | if File.exists? path 18 | info = File.info path 19 | stdout.puts <<-INFO 20 | name: #{path.basename} 21 | size: #{info.size} 22 | directory: #{info.directory?} 23 | symlink: #{info.symlink?} 24 | permissions: #{info.permissions} 25 | INFO 26 | else 27 | stderr.puts "No file found at that path" 28 | end 29 | end 30 | end 31 | 32 | StatCommand.new.execute ARGV 33 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: cling 2 | description: A modular, non-macro-based command line interface library 3 | authors: 4 | - Devonte W 5 | 6 | version: 3.0.0 7 | crystal: '>= 1.8.0' 8 | license: MPL 9 | repository: https://github.com/devnote-dev/cling 10 | 11 | development_dependencies: 12 | ameba: 13 | github: crystal-ameba/ameba 14 | version: ~> 1.5.0 15 | -------------------------------------------------------------------------------- /spec/argument_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cling::Argument do 4 | it "parses an argument" do 5 | argument = Cling::Argument.new "spec", "a test argument" 6 | 7 | argument.name.should eq "spec" 8 | argument.description.should eq "a test argument" 9 | argument.required?.should be_false 10 | argument.value.should be_nil 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/command_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | private class TestArgsCommand < Cling::Command 4 | def setup : Nil 5 | @name = "main" 6 | 7 | add_argument "first", required: true 8 | add_argument "second", multiple: true 9 | add_option 's', "skip" 10 | end 11 | 12 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Nil 13 | exit_program 0 if options.has? "skip" 14 | end 15 | 16 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 17 | arguments.get "first" 18 | arguments.get "second" 19 | end 20 | end 21 | 22 | private class TestOptionsCommand < Cling::Command 23 | def setup : Nil 24 | @name = "main" 25 | 26 | add_option "foo" 27 | add_option "double-foo", required: true 28 | add_option 'b', "bar", type: :single, required: true 29 | add_option 'n', "num", type: :multiple, default: %w[] 30 | end 31 | 32 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 33 | options.get? "foo" 34 | options.get "double-foo" 35 | options.get('b').as_s 36 | options.get('n').as_a 37 | end 38 | end 39 | 40 | private class TestHooksCommand < Cling::Command 41 | def setup : Nil 42 | @name = "main" 43 | 44 | add_argument "foo", required: true 45 | add_option "double-foo", required: true 46 | add_option 'b', "bar", type: :single 47 | add_option 'n', "num", type: :multiple, default: %w[] 48 | end 49 | 50 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 51 | end 52 | 53 | def on_missing_arguments(arguments : Array(String)) 54 | stderr.puts arguments.join ", " 55 | end 56 | 57 | def on_unknown_arguments(arguments : Array(String)) 58 | stderr.puts arguments.join ", " 59 | end 60 | 61 | def on_invalid_option(message : String) 62 | stderr.puts message 63 | end 64 | 65 | def on_missing_options(options : Array(String)) 66 | stderr.puts options.join ", " 67 | end 68 | 69 | def on_unknown_options(options : Array(String)) 70 | stderr.puts options.join ", " 71 | end 72 | end 73 | 74 | private class TestErrorsCommand < Cling::Command 75 | def setup : Nil 76 | @name = "main" 77 | 78 | add_option "fail-fast" 79 | end 80 | 81 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 82 | if options.has? "fail-fast" 83 | exit_program 84 | else 85 | raise "failed slowly" 86 | end 87 | end 88 | end 89 | 90 | arguments_command = TestArgsCommand.new 91 | options_command = TestOptionsCommand.new 92 | hooks_command = TestHooksCommand.new 93 | errors_command = TestErrorsCommand.new 94 | 95 | describe Cling::Command do 96 | it "executes the pre_run only" do 97 | arguments_command.execute("--skip").should eq 0 98 | end 99 | 100 | it "fails on missing arguments" do 101 | expect_raises Cling::ExecutionError, "Error while executing command error handler:\n\ 102 | Missing required argument: first" do 103 | arguments_command.execute "" 104 | end 105 | end 106 | 107 | it "executes without errors" do 108 | arguments_command.execute("foo bar").should eq 0 109 | arguments_command.execute("foo bar baz qux").should eq 0 110 | end 111 | 112 | it "fails on unknown values" do 113 | expect_raises Cling::ExecutionError, "Error while executing command error handler:\n\ 114 | Value not found for key: second" do 115 | arguments_command.execute "foo" 116 | end 117 | end 118 | 119 | it "fails on missing options" do 120 | expect_raises Cling::ExecutionError, "Error while executing command error handler:\n\ 121 | Missing required options: double-foo, bar" do 122 | options_command.execute "" 123 | end 124 | end 125 | 126 | it "executes without errors" do 127 | options_command.execute("--double-foo --bar=true").should eq 0 128 | end 129 | 130 | it "fails on unknown options" do 131 | expect_raises Cling::ExecutionError, "Error while executing command error handler:\n\ 132 | Unknown option: double-bar" do 133 | options_command.execute "--double-foo --double-bar" 134 | end 135 | end 136 | 137 | it "fails on invalid options" do 138 | expect_raises Cling::ExecutionError, "Error while executing command error handler:\n\ 139 | Option 'foo' takes no arguments" do 140 | options_command.execute "--foo=true --double-foo" 141 | end 142 | 143 | expect_raises Cling::ExecutionError, "Error while executing command error handler:\n\ 144 | Option 'double-foo' takes no arguments" do 145 | options_command.execute "--double-foo=true --bar baz" 146 | end 147 | end 148 | 149 | it "catches missing required arguments" do 150 | io = IO::Memory.new 151 | hooks_command.stderr = io 152 | 153 | hooks_command.execute("--double-foo").should eq 0 154 | io.to_s.should eq "foo\n" 155 | end 156 | 157 | it "catches unknown arguments" do 158 | io = IO::Memory.new 159 | hooks_command.stderr = io 160 | 161 | hooks_command.execute("foo --double-foo bar baz").should eq 0 162 | io.to_s.should eq "bar, baz\n" 163 | end 164 | 165 | it "catches an invalid option" do 166 | io = IO::Memory.new 167 | hooks_command.stderr = io 168 | 169 | hooks_command.execute("foo --double-foo=true\n").should eq 1 170 | io.to_s.should eq "Option 'double-foo' takes no arguments\n" 171 | end 172 | 173 | it "catches missing required values for options" do 174 | io = IO::Memory.new 175 | hooks_command.stderr = io 176 | 177 | hooks_command.execute("foo --double-foo --bar").should eq 1 178 | io.to_s.should eq "Missing required argument for option 'bar'\n" 179 | 180 | io.rewind 181 | hooks_command.execute("foo --double-foo -n").should eq 1 182 | io.to_s.should eq "Missing required arguments for option 'num'\n" 183 | end 184 | 185 | it "catches exceptions for program exit and other errors" do 186 | errors_command.execute("--fail-fast").should eq 1 187 | 188 | expect_raises(Exception, "failed slowly") do 189 | errors_command.execute "" 190 | end 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /spec/formatter_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | private class GreetCommand < Cling::MainCommand 4 | def setup : Nil 5 | super 6 | 7 | @name = "greet" 8 | @description = "Greets a person" 9 | 10 | add_argument "name", description: "the name of the person", required: true 11 | add_option 'c', "caps", description: "greet with caps" 12 | end 13 | 14 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 15 | end 16 | end 17 | 18 | class WelcomeCommand < Cling::Command 19 | def setup : Nil 20 | @name = "welcome" 21 | @summary = @description = "sends a friendly welcome message" 22 | end 23 | 24 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 25 | end 26 | end 27 | 28 | command = GreetCommand.new 29 | command.add_command WelcomeCommand.new 30 | formatter = Cling::Formatter.new 31 | 32 | describe Cling::Formatter do 33 | it "generates a help template" do 34 | formatter.generate(command).chomp.should eq <<-HELP 35 | Greets a person 36 | 37 | Usage: 38 | \tgreet [options] 39 | 40 | Commands: 41 | \twelcome sends a friendly welcome message 42 | 43 | Arguments: 44 | \tname the name of the person (required) 45 | 46 | Options: 47 | \t-h, --help sends help information 48 | \t-v, --version sends the app version 49 | \t-c, --caps greet with caps 50 | HELP 51 | end 52 | 53 | it "generates with a custom delimiter" do 54 | formatter.options.option_delim = '+' 55 | 56 | formatter.generate(command).chomp.should eq <<-HELP 57 | Greets a person 58 | 59 | Usage: 60 | \tgreet [options] 61 | 62 | Commands: 63 | \twelcome sends a friendly welcome message 64 | 65 | Arguments: 66 | \tname the name of the person (required) 67 | 68 | Options: 69 | \t+h, ++help sends help information 70 | \t+v, ++version sends the app version 71 | \t+c, ++caps greet with caps 72 | HELP 73 | end 74 | 75 | it "generates a description section" do 76 | String.build do |io| 77 | formatter.format_description(command, io) 78 | end.should eq "Greets a person\n\n" 79 | end 80 | 81 | it "generates a usage section" do 82 | String.build do |io| 83 | formatter.format_usage(command, io) 84 | end.should eq "Usage:\n\tgreet [options]\n\n" 85 | end 86 | 87 | it "generates an arguments section" do 88 | String.build do |io| 89 | formatter.format_arguments(command, io) 90 | end.should eq "Arguments:\n\tname the name of the person (required)\n\n" 91 | end 92 | 93 | it "generates an options section" do 94 | formatter.options.option_delim = '-' 95 | 96 | String.build do |io| 97 | formatter.format_options(command, io) 98 | end.chomp.should eq <<-HELP 99 | Options: 100 | \t-h, --help sends help information 101 | \t-v, --version sends the app version 102 | \t-c, --caps greet with caps 103 | 104 | HELP 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /spec/helper_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | # Inspired by Clim 4 | 5 | private class ContextCommand < Cling::Command 6 | def setup : Nil 7 | @name = "context" 8 | @description = "Runs the Crystal context tool" 9 | end 10 | 11 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 12 | stdout.puts "Fake crystal context command!" 13 | end 14 | end 15 | 16 | private class FormatCommand < Cling::Command 17 | def setup : Nil 18 | @name = "format" 19 | @description = "Runs the Crystal format tool" 20 | end 21 | 22 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 23 | stdout.puts "Fake crystal format command!" 24 | end 25 | end 26 | 27 | private class CrystalCommand < Cling::MainCommand 28 | def setup : Nil 29 | super 30 | 31 | @description = "Runs some Crystal commands" 32 | end 33 | 34 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 35 | end 36 | end 37 | 38 | command = CrystalCommand.new 39 | command.add_command ContextCommand.new 40 | command.add_command FormatCommand.new 41 | 42 | describe Cling::MainCommand do 43 | it "prints the help message" do 44 | io = IO::Memory.new 45 | command.stdout = io 46 | 47 | command.execute("").should eq 0 48 | io.to_s.chomp.should eq <<-HELP 49 | Runs some Crystal commands 50 | 51 | Usage: 52 | \tmain [options] 53 | 54 | Commands: 55 | \tcontext 56 | \tformat 57 | 58 | Options: 59 | \t-h, --help sends help information 60 | \t-v, --version sends the app version 61 | HELP 62 | end 63 | 64 | it "runs the context command" do 65 | io = IO::Memory.new 66 | command.children["context"].stdout = io 67 | 68 | command.execute("context").should eq 0 69 | io.to_s.should eq "Fake crystal context command!\n" 70 | end 71 | 72 | it "runs the format command" do 73 | io = IO::Memory.new 74 | command.children["format"].stdout = io 75 | 76 | command.execute("format").should eq 0 77 | io.to_s.should eq "Fake crystal format command!\n" 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/main_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | private class GreetCommand < Cling::Command 4 | def setup : Nil 5 | @name = "greet" 6 | @description = "Greets a person" 7 | 8 | add_argument "name", description: "the name of the person", required: true 9 | add_option 'c', "caps", description: "greet with caps" 10 | end 11 | 12 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Nil 13 | return if arguments.has? "name" 14 | stdout.puts Cling::Formatter.new.generate self 15 | exit_program 0 16 | end 17 | 18 | def run(arguments : Cling::Arguments, options : Cling::Options) : Nil 19 | message = %(Hello, #{arguments.get "name"}!) 20 | 21 | if options.has? "caps" 22 | stdout.puts message.upcase 23 | else 24 | stdout.puts message 25 | end 26 | end 27 | end 28 | 29 | command = GreetCommand.new 30 | 31 | describe Cling do 32 | it "tests the help command" do 33 | io = IO::Memory.new 34 | command.stdout = io 35 | 36 | command.execute("").should eq 0 37 | io.to_s.should eq <<-HELP 38 | Greets a person 39 | 40 | Usage: 41 | \tgreet [options] 42 | 43 | Arguments: 44 | \tname the name of the person (required) 45 | 46 | Options: 47 | \t-c, --caps greet with caps 48 | 49 | HELP 50 | end 51 | 52 | it "tests the main command" do 53 | io = IO::Memory.new 54 | command.stdout = io 55 | 56 | command.execute("Dev").should eq 0 57 | io.to_s.should eq "Hello, Dev!\n" 58 | end 59 | 60 | it "tests the main command with flag" do 61 | io = IO::Memory.new 62 | command.stdout = io 63 | 64 | command.execute("-c Dev").should eq 0 65 | io.to_s.should eq "HELLO, DEV!\n" 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/option_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cling::Option do 4 | it "parses a long option" do 5 | option = Cling::Option.new "spec" 6 | option.long.should eq "spec" 7 | option.short.should be_nil 8 | 9 | option.is?("spec").should be_true 10 | end 11 | 12 | it "parses a short option" do 13 | option = Cling::Option.new "spec", 's' 14 | option.long.should eq "spec" 15 | option.short.should eq 's' 16 | 17 | option.is?("s").should be_true 18 | end 19 | 20 | it "compares options" do 21 | option1 = Cling::Option.new "spec", 's' 22 | option2 = Cling::Option.new "flag", 'f' 23 | 24 | option1.should_not eq option2 25 | option1.should eq option1.dup 26 | end 27 | 28 | it "should not allow required defaults" do 29 | expect_raises ArgumentError do 30 | Cling::Option.new "foo", required: true, default: 1 31 | end 32 | end 33 | 34 | it "should not allow no-value defaults" do 35 | expect_raises ArgumentError do 36 | Cling::Option.new "bar", default: true 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/parser_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cling::Parser do 4 | it "parses standard input arguments" do 5 | parser = Cling::Parser.new %(these --are "some" -c arguments) 6 | results = parser.parse 7 | 8 | results[0].kind.should eq Cling::Parser::Result::Kind::Argument 9 | results[1].kind.should eq Cling::Parser::Result::Kind::LongFlag 10 | results[2].kind.should eq Cling::Parser::Result::Kind::Argument 11 | results[3].kind.should eq Cling::Parser::Result::Kind::ShortFlag 12 | results[4].kind.should eq Cling::Parser::Result::Kind::Argument 13 | end 14 | 15 | it "parses custom flag input arguments" do 16 | options = Cling::Parser::Options.new option_delim: '+' 17 | parser = Cling::Parser.new %(these ++are "some" +c arguments), options 18 | results = parser.parse 19 | 20 | results[0].kind.should eq Cling::Parser::Result::Kind::Argument 21 | results[1].kind.should eq Cling::Parser::Result::Kind::LongFlag 22 | results[2].kind.should eq Cling::Parser::Result::Kind::Argument 23 | results[3].kind.should eq Cling::Parser::Result::Kind::ShortFlag 24 | results[4].kind.should eq Cling::Parser::Result::Kind::Argument 25 | end 26 | 27 | it "parses a string argument" do 28 | parser = Cling::Parser.new %(--test "string argument" -t) 29 | results = parser.parse 30 | 31 | results[0].kind.should eq Cling::Parser::Result::Kind::LongFlag 32 | results[1].kind.should eq Cling::Parser::Result::Kind::Argument 33 | results[1].value.should eq "string argument" 34 | results[2].kind.should eq Cling::Parser::Result::Kind::ShortFlag 35 | end 36 | 37 | it "parses an option argument" do 38 | parser = Cling::Parser.new %(--name=foo -k=bar) 39 | results = parser.parse 40 | 41 | results[0].kind.should eq Cling::Parser::Result::Kind::LongFlag 42 | results[0].key.should eq "name" 43 | results[0].value.should eq "foo" 44 | 45 | results[1].kind.should eq Cling::Parser::Result::Kind::ShortFlag 46 | results[1].key.should eq "k" 47 | results[1].value.should eq "bar" 48 | end 49 | 50 | it "parses an array argument" do 51 | parser = Cling::Parser.new %(-n 1 -n=2,3 -n 4) 52 | results = parser.parse 53 | 54 | results[0].kind.should eq Cling::Parser::Result::Kind::ShortFlag 55 | results[0].key.should eq "n" 56 | expect_raises(NilAssertionError) { results[0].value } 57 | results[1].value.should eq "1" 58 | # This isn't managed by the parser so this is the raw value, 59 | # the executor will parse this into ["2", "3"] 60 | results[2].value.should eq "2,3" 61 | expect_raises(NilAssertionError) { results[3].value } 62 | results[4].value.should eq "4" 63 | end 64 | 65 | it "parses all-positional arguments" do 66 | parser = Cling::Parser.new %(one two -- three -four --five -s-i-x-) 67 | results = parser.parse 68 | 69 | results.size.should eq 6 70 | results[0].kind.should eq Cling::Parser::Result::Kind::Argument 71 | results[0].value.should eq "one" 72 | results[1].kind.should eq Cling::Parser::Result::Kind::Argument 73 | results[1].value.should eq "two" 74 | results[2].kind.should eq Cling::Parser::Result::Kind::Argument 75 | results[2].value.should eq "three" 76 | results[3].kind.should eq Cling::Parser::Result::Kind::Argument 77 | results[3].value.should eq "-four" 78 | results[4].kind.should eq Cling::Parser::Result::Kind::Argument 79 | results[4].value.should eq "--five" 80 | results[5].kind.should eq Cling::Parser::Result::Kind::Argument 81 | results[5].value.should eq "-s-i-x-" 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/cling" 3 | -------------------------------------------------------------------------------- /spec/value_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe Cling::Value do 4 | it "parses a value" do 5 | Cling::Value.new("foo").should be_a Cling::Value 6 | Cling::Value.new(123_i64).should be_a Cling::Value 7 | Cling::Value.new(4.56).should be_a Cling::Value 8 | Cling::Value.new(true).should be_a Cling::Value 9 | Cling::Value.new(nil).should be_a Cling::Value 10 | Cling::Value.new(%w[foo bar baz]).should be_a Cling::Value 11 | end 12 | 13 | it "compares values" do 14 | Cling::Value.new("foo").should eq "foo" 15 | Cling::Value.new(123).should eq 123 16 | Cling::Value.new(4.56).should eq 4.56 17 | Cling::Value.new(true).should eq true 18 | Cling::Value.new(nil).should eq nil 19 | Cling::Value.new(%w[foo bar baz]).should eq ["foo", "bar", "baz"] 20 | end 21 | 22 | it "asserts types" do 23 | Cling::Value.new("foo").as_s.should be_a String 24 | Cling::Value.new(nil).as_s?.should be_a String? 25 | Cling::Value.new(123).as_i32.should be_a Int32 26 | Cling::Value.new(4.56).as_f64.should be_a Float64 27 | Cling::Value.new(true).as_bool.should be_true 28 | Cling::Value.new(nil).as_bool?.should be_nil 29 | Cling::Value.new(nil).raw.should be_nil 30 | Cling::Value.new(%w[]).as_a.should be_a Array(String) 31 | end 32 | 33 | it "converts types" do 34 | Cling::Value.new("123").to_i.should be_a Int32 35 | Cling::Value.new(false).to_i32?.should be_nil 36 | Cling::Value.new(456).to_f.should be_a Float64 37 | Cling::Value.new("true").to_bool.should be_true 38 | Cling::Value.new(false).to_bool.should be_false 39 | Cling::Value.new(nil).to_bool?.should be_nil 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /src/cling.cr: -------------------------------------------------------------------------------- 1 | require "./cling/argument" 2 | require "./cling/command" 3 | require "./cling/errors" 4 | require "./cling/executor" 5 | require "./cling/formatter" 6 | require "./cling/helper" 7 | require "./cling/option" 8 | require "./cling/parser" 9 | require "./cling/value" 10 | 11 | module Cling 12 | VERSION = "3.0.0" 13 | end 14 | -------------------------------------------------------------------------------- /src/cling/argument.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | # Represents a command line argument, this can be a single value or a string value. Arguments are 3 | # parsed after the main command and any subcommands are resolved. Note that `Option`s that have 4 | # values take priority in the resolution list, so the following example would only yield 2 5 | # arguments: 6 | # 7 | # ``` 8 | # ./greet --time=day Dev 9 | # # ^^^ 10 | # # belongs to the flag option 11 | # ``` 12 | # 13 | # Arguments should typically be defined in the `Command#setup` method of a command using 14 | # `Command#add_argument` to prevent conflicts. 15 | class Argument 16 | property name : String 17 | property description : String? 18 | property? required : Bool 19 | property? multiple : Bool 20 | property? hidden : Bool 21 | property value : Value? 22 | 23 | def initialize(@name : String, @description : String? = nil, @required : Bool = false, 24 | @multiple : Bool = false, @hidden : Bool = false) 25 | end 26 | 27 | # :inherit: 28 | def to_s(io : IO) : Nil 29 | io << @name 30 | end 31 | end 32 | 33 | # An input structure to access validated arguments at execution time. 34 | struct Arguments 35 | getter hash : Hash(String, Argument) 36 | 37 | # :nodoc: 38 | def initialize(@hash) 39 | end 40 | 41 | # Indexes an argument by its name and returns the `Argument` object, not the argument's 42 | # value. 43 | def [](key : String) : Argument 44 | @hash[key]? || raise ValueNotFound.new(key) 45 | end 46 | 47 | # Indexes an argument by its name and returns the `Argument` object or `nil` if not found, 48 | # not the argument's value. 49 | def []?(key : String) : Argument? 50 | @hash[key]? 51 | end 52 | 53 | # Returns `true` if an argument by the given name exists. 54 | def has?(key : String) : Bool 55 | @hash.has_key? key 56 | end 57 | 58 | # Gets an argument by its name and returns its `Value`, or `nil` if not found. 59 | def get?(key : String) : Value? 60 | @hash[key]?.try &.value 61 | end 62 | 63 | # Gets an argument by its name and returns its `Value`. 64 | def get(key : String) : Value 65 | get?(key) || raise ValueNotFound.new(key) 66 | end 67 | 68 | # Returns `true` if there are no parsed arguments. 69 | def empty? : Bool 70 | @hash.empty? 71 | end 72 | 73 | # Returns the number of parsed arguments. 74 | def size : Int32 75 | @hash.size 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /src/cling/command.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | abstract class Command 3 | # The name of the command. This is the only required field of a command and cannot be empty or 4 | # blank. 5 | property name : String 6 | 7 | # A set of aliases for the command. 8 | getter aliases : Set(String) 9 | 10 | # An array of usage strings to display in generated help templates. 11 | getter usage : Array(String) 12 | 13 | # A header message to display at the top of generated help templates. 14 | property header : String? 15 | 16 | # The summary of the command to show in generated help templates. 17 | property summary : String? 18 | 19 | # The description of the command to show for specific help with the command. 20 | property description : String? 21 | 22 | # A footer message to display at the bottom of generated help templates. 23 | property footer : String? 24 | 25 | # The parent of the command of which the current command inherits from. 26 | property parent : Command? 27 | 28 | # A hash of commands that belong and inherit from the parent command. 29 | getter children : Hash(String, Command) 30 | 31 | # A hash of arguments belonging to the command. These arguments are parsed at execution time 32 | # and can be accessed in the `pre_run`, `run`, and `post_run` methods via `ArgumentsInput`. 33 | getter arguments : Hash(String, Argument) 34 | 35 | # A hash of flag options belonging to the command. These options are parsed at execution time 36 | # and can be accessed in the `pre_run`, `run`, and `post_run` methods via `OptionsInput`. 37 | getter options : Hash(String, Option) 38 | 39 | # Whether the command should be hidden from generated help templates. 40 | property? hidden : Bool 41 | 42 | # Whether the command should inherit the `header` and `footer` strings from the parent command. 43 | property? inherit_borders : Bool 44 | 45 | # Whether the command should inherit the options from the parent command. 46 | property? inherit_options : Bool 47 | 48 | # Whether the command should inherit the IO streams from the parent command. 49 | property? inherit_streams : Bool 50 | 51 | # The standard input stream for commands (defaults to `STDIN`). This is a helper method for 52 | # custom commands and is only used by the `MainCommand` helper class. 53 | property stdin : IO 54 | 55 | # The standard output stream for commands (defaults to `STDOUT`). This is a helper method for 56 | # custom commands and is only used by the `MainCommand` helper class. 57 | property stdout : IO 58 | 59 | # The standard error stream for commands (defaults to `STDERR`). This is a helper method for 60 | # custom commands and is only used by the `MainCommand` helper class. 61 | property stderr : IO 62 | 63 | def initialize(*, aliases : Set(String)? = nil, usage : Array(String)? = nil, 64 | @header : String? = nil, @summary : String? = nil, @description : String? = nil, 65 | @footer : String? = nil, @parent : Command? = nil, children : Array(Command)? = nil, 66 | arguments : Hash(String, Argument)? = nil, options : Hash(String, Option)? = nil, 67 | @hidden : Bool = false, @inherit_borders : Bool = false, @inherit_options : Bool = false, 68 | @inherit_streams : Bool = false, @stdin : IO = STDIN, @stdout : IO = STDOUT, @stderr : IO = STDERR) 69 | @name = "" 70 | @aliases = aliases || Set(String).new 71 | @usage = usage || [] of String 72 | @children = children || {} of String => Command 73 | @arguments = arguments || {} of String => Argument 74 | @options = options || {} of String => Option 75 | 76 | setup 77 | raise CommandError.new "Command name cannot be empty" if @name.empty? 78 | raise CommandError.new "Command name cannot be blank" if @name.blank? 79 | end 80 | 81 | # An abstract method that should define information about the command such as the name, 82 | # aliases, arguments, options, etc. The command name is required for all commands, all other 83 | # values are optional including the help message. 84 | abstract def setup : Nil 85 | 86 | # Returns `true` if the argument matches the command name or any aliases. 87 | def is?(name : String) : Bool 88 | @name == name || @aliases.includes? name 89 | end 90 | 91 | # Returns the help template for this command. By default, one is generated interally unless 92 | # this method is overridden. 93 | def help_template : String 94 | Formatter.new.generate self 95 | end 96 | 97 | # Adds an alias to the command. 98 | def add_alias(name : String) : Nil 99 | @aliases << name 100 | end 101 | 102 | # Adds several aliases to the command. 103 | def add_aliases(*names : String) : Nil 104 | @aliases.concat names 105 | end 106 | 107 | # Adds a usage string to the command. 108 | def add_usage(usage : String) : Nil 109 | @usage << usage 110 | end 111 | 112 | # Adds a command as a subcommand to the parent. The command can then be referenced by 113 | # specifying it as the first argument in the command line. 114 | def add_command(command : Command) : Nil 115 | raise CommandError.new "Duplicate command '#{command.name}'" if @children.has_key? command.name 116 | command.aliases.each do |a| 117 | raise CommandError.new "Duplicate command alias '#{a}'" if @children.values.any? &.is? a 118 | end 119 | 120 | command.parent = self 121 | if command.inherit_borders? 122 | command.header = @header 123 | command.footer = @footer 124 | end 125 | 126 | command.options.merge! @options if command.inherit_options? 127 | 128 | if command.inherit_streams? 129 | command.stdin = @stdin 130 | command.stdout = @stdout 131 | command.stderr = @stderr 132 | end 133 | 134 | @children[command.name] = command 135 | end 136 | 137 | # Adds several commands as subcommands to the parent (see `add_command`). 138 | def add_commands(*commands : Command) : Nil 139 | commands.each { |c| add_command(c) } 140 | end 141 | 142 | # Adds an argument to the command. 143 | def add_argument(name : String, *, description : String? = nil, required : Bool = false, 144 | multiple : Bool = false, hidden : Bool = false) : Nil 145 | raise CommandError.new "Duplicate argument '#{name}'" if @arguments.has_key? name 146 | if multiple && @arguments.values.find &.multiple? 147 | raise CommandError.new "Cannot have more than one argument with multiple values" 148 | end 149 | 150 | @arguments[name] = Argument.new(name, description, required, multiple, hidden) 151 | end 152 | 153 | # Adds a long flag option to the command. 154 | def add_option(long : String, *, description : String? = nil, required : Bool = false, 155 | hidden : Bool = false, type : Option::Type = :none, default : Value::Type = nil) : Nil 156 | raise CommandError.new "Duplicate flag option '#{long}'" if @options.has_key? long 157 | 158 | @options[long] = Option.new(long, nil, description, required, hidden, type, default) 159 | end 160 | 161 | # Adds a short flag option to the command. 162 | def add_option(short : Char, long : String, *, description : String? = nil, required : Bool = false, 163 | hidden : Bool = false, type : Option::Type = :none, default : Value::Type = nil) : Nil 164 | raise CommandError.new "Duplicate flag option '#{long}'" if @options.has_key? long 165 | if op = @options.values.find { |o| o.short == short } 166 | raise CommandError.new "Flag '#{op.long}' already has the short option '#{short}'" 167 | end 168 | 169 | @options[long] = Option.new(long, short, description, required, hidden, type, default) 170 | end 171 | 172 | {% begin %} 173 | # Executes the command with the given input and parser (see `Parser`). Returns the program exit 174 | # status code, by default it is `0`. 175 | {% if @top_level.has_constant?("Spec") %} 176 | def execute(input : String | Array(String), *, parser : Parser? = nil, terminate : Bool = false) : Int32 177 | {% else %} 178 | def execute(input : String | Array(String), *, parser : Parser? = nil, terminate : Bool = true) : Int32 179 | {% end %} 180 | parser ||= Parser.new input 181 | results = parser.parse 182 | code = Executor.handle self, results 183 | exit code if terminate 184 | 185 | code 186 | end 187 | {% end %} 188 | 189 | # A hook method to run once the command/subcommands, arguments and options have been parsed. 190 | # This has access to the parsed arguments and options from the command line. This is useful if 191 | # you want to implement checks for specific flags outside of the main `run` method, such as 192 | # `-v`/`--version` flags or `-h`/`--help` flags. 193 | def pre_run(arguments : Arguments, options : Options) : Bool? 194 | end 195 | 196 | # The main point of execution for the command, where arguments and options can be accessed. 197 | abstract def run(arguments : Arguments, options : Options) : Nil 198 | 199 | # A hook method to run once the `pre_run` and main `run` methods have been executed. 200 | def post_run(arguments : Arguments, options : Options) : Nil 201 | end 202 | 203 | # Raises an `ExitProgram` exception to exit the program. By default it is `1`. 204 | def exit_program(code : Int32 = 1) : NoReturn 205 | raise ExitProgram.new code 206 | end 207 | 208 | # A hook method for when the command raises an exception during execution. By default, this 209 | # raises the exception. 210 | def on_error(ex : Exception) 211 | raise ex 212 | end 213 | 214 | # A hook method for when the command receives missing arguments during execution. By default, 215 | # this raises a `CommandError`. 216 | def on_missing_arguments(arguments : Array(String)) 217 | raise CommandError.new %(Missing required argument#{"s" if arguments.size > 1}: #{arguments.join(", ")}) 218 | end 219 | 220 | # A hook method for when the command receives unknown arguments during execution. By default, 221 | # this raises a `CommandError`. 222 | def on_unknown_arguments(arguments : Array(String)) 223 | raise CommandError.new %(Unknown argument#{"s" if arguments.size > 1}: #{arguments.join(", ")}) 224 | end 225 | 226 | # A hook method for when the command receives an invalid option, for example, a value given to 227 | # an option that takes no arguments. By default, this raises a `CommandError`. 228 | def on_invalid_option(message : String) 229 | raise CommandError.new message 230 | end 231 | 232 | # A hook method for when the command receives missing options that are required during 233 | # execution. By default, this raises an `CommandError`. 234 | def on_missing_options(options : Array(String)) 235 | raise CommandError.new %(Missing required option#{"s" if options.size > 1}: #{options.join(", ")}) 236 | end 237 | 238 | # A hook method for when the command receives unknown options during execution. By default, 239 | # this raises an `CommandError`. 240 | def on_unknown_options(options : Array(String)) 241 | raise CommandError.new %(Unknown option#{"s" if options.size > 1}: #{options.join(", ")}) 242 | end 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /src/cling/errors.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | # The base error for this module. 3 | class Error < Exception 4 | end 5 | 6 | # An error raised from a command, or argument/option in a command. 7 | class CommandError < Error 8 | end 9 | 10 | # An error raised during a command execution. 11 | class ExecutionError < Error 12 | end 13 | 14 | # An error raised during the command line parsing process. 15 | class ParserError < Error 16 | end 17 | 18 | # An error raised if the `Value` of an argument or an option is not found/set. 19 | class ValueNotFound < Error 20 | def initialize(key : String) 21 | super "Value not found for key: #{key}" 22 | end 23 | end 24 | 25 | # An error used for signalling the end of the current program. 26 | class ExitProgram < Error 27 | getter code : Int32 28 | 29 | def initialize(@code : Int32) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /src/cling/executor.cr: -------------------------------------------------------------------------------- 1 | # Handles the execution of commands. In most cases you should never need to interact with this 2 | # module as the `Command#execute` method is the main entrypoint for executing commands. For this 3 | # reason, most of the modules methods are hidden. 4 | module Cling::Executor 5 | private struct Result 6 | getter parsed_options : Options 7 | getter unknown_options : Array(String) 8 | getter missing_options : Array(String) 9 | getter parsed_arguments : Arguments 10 | getter unknown_arguments : Array(String) 11 | getter missing_arguments : Array(String) 12 | 13 | def initialize(parsed_options, @unknown_options, @missing_options, parsed_arguments, 14 | @unknown_arguments, @missing_arguments) 15 | @parsed_options = Options.new parsed_options 16 | @parsed_arguments = Arguments.new parsed_arguments 17 | end 18 | end 19 | 20 | # Handles the execution of a command using the given results from the parser. Returns the program 21 | # exit code from the command, by default it is `0`. 22 | # 23 | # ### Process 24 | # 25 | # 1. The command is resolved first using a pointer to the results to prevent having to deal with 26 | # multiple copies of the same object, and is mutated in the `resolve_command` method. 27 | # 28 | # 2. The results are evaluated with the command arguments and options to set their values and 29 | # move the missing, unknown and invalid arguments/options into place. 30 | # 31 | # 3. The `Command#pre_run` hook is executed with the resolved arguments and options. 32 | # 33 | # 4. The evaluated arguments and options are finalized: missing, unknown and invalid arguments/ 34 | # options trigger the necessary missing/unknown/invalid command hooks. 35 | # 36 | # 5. The `Command#run` and `Command#post_run` methods are executed with the evaluated arguments 37 | # and options. 38 | # 39 | # 6. The program exit code is returned from the command, or `0` if no `ExitProgram` exception was 40 | # raised during the command's execution. 41 | def self.handle(command : Command, results : Array(Parser::Result)) : Int32 42 | resolved_command = resolve_command command, results 43 | unless resolved_command 44 | begin 45 | # TODO: should this be an ExecutionError? 46 | command.on_error CommandError.new("Command '#{results.first.value}' not found") 47 | return 1 48 | rescue ex : ExitProgram 49 | return ex.code 50 | rescue ex 51 | raise ExecutionError.new "Error while executing command error handler:\n#{ex.message}", cause: ex 52 | end 53 | end 54 | 55 | begin 56 | executed = get_in_position resolved_command, results 57 | rescue ex : ExecutionError 58 | begin 59 | resolved_command.on_invalid_option ex.to_s 60 | return 1 61 | rescue ex : ExitProgram 62 | return ex.code 63 | rescue ex 64 | raise ExecutionError.new "Error while executing command error handler:\n#{ex.message}", cause: ex 65 | end 66 | end 67 | 68 | begin 69 | deprecation_helper( 70 | resolved_command, 71 | resolved_command.pre_run(executed.parsed_arguments, executed.parsed_options) 72 | ) 73 | 74 | finalize resolved_command, executed 75 | 76 | resolved_command.run executed.parsed_arguments, executed.parsed_options 77 | resolved_command.post_run executed.parsed_arguments, executed.parsed_options 78 | rescue ex : ExitProgram 79 | return ex.code 80 | rescue ex 81 | begin 82 | resolved_command.on_error ex 83 | rescue ex : ExitProgram 84 | return ex.code 85 | rescue ex 86 | raise ExecutionError.new "Error while executing command error handler:\n#{ex.message}", cause: ex 87 | end 88 | end 89 | 90 | 0 91 | end 92 | 93 | private def self.deprecation_helper(type : T, value : Bool?) : Nil forall T 94 | {% T.warning "#{T}#pre_run : Bool? is deprecated. Use `pre_run(arguments : Arguments, options : Options) : Nil` instead" %} 95 | raise ExitProgram.new 1 if value == false 96 | end 97 | 98 | private def self.deprecation_helper(type : T, value : Nil) : Nil forall T 99 | end 100 | 101 | private def self.resolve_command(command : Command, results : Array(Parser::Result)) : Command? 102 | arguments = results.select { |r| r.kind.argument? && !r.string? } 103 | return command if arguments.empty? || command.children.empty? 104 | 105 | result = arguments.first 106 | if found_command = command.children.values.find &.is?(result.value) 107 | results.shift 108 | resolve_command found_command, results 109 | elsif !command.arguments.empty? 110 | command 111 | else 112 | nil 113 | end 114 | end 115 | 116 | private def self.get_in_position(command : Command, results : Array(Parser::Result)) : Result 117 | options = {} of String => Value 118 | parsed_options = {} of String => Option 119 | unknown_options = [] of String 120 | 121 | results.each_with_index do |result, index| 122 | next if result.kind.argument? 123 | 124 | if option = command.options.values.find &.is?(result.key) 125 | if option.type.none? 126 | raise ExecutionError.new "Option '#{option}' takes no arguments" if result.value? 127 | options[option.long] = Value.new nil 128 | else 129 | if result.value? 130 | if current = options[option.long]? 131 | options[option.long] = Value.new(current.as_a << result.value) 132 | elsif option.type.multiple? 133 | options[option.long] = Value.new [result.value] 134 | else 135 | options[option.long] = Value.new result.value 136 | end 137 | elsif res = results[index + 1]? 138 | unless res.kind.argument? 139 | raise ExecutionError.new "Missing required argument#{"s" if option.type.multiple?} for option '#{option}'" 140 | end 141 | 142 | if option.type.single? 143 | options[option.long] = Value.new res.value 144 | else 145 | if current = options[option.long]? 146 | options[option.long] = Value.new(current.as_a << res.value) 147 | else 148 | options[option.long] = Value.new [res.value] 149 | end 150 | end 151 | 152 | results.delete_at(index + 1) 153 | elsif default = option.default 154 | unless option.required? 155 | raise ExecutionError.new "Missing required argument#{"s" if option.type.multiple?} for option '#{option}'" 156 | end 157 | 158 | if option.type.single? 159 | options[option.long] = Value.new default 160 | else 161 | if current = options[option.long]? 162 | options[option.long] = Value.new(current.as_a << default.to_s) 163 | else 164 | options[option.long] = Value.new [default.to_s] 165 | end 166 | end 167 | 168 | results.delete_at index if 0 <= index > results.size 169 | else 170 | raise ExecutionError.new "Missing required argument#{"s" if option.type.multiple?} for option '#{option}'" 171 | end 172 | end 173 | else 174 | unknown_options << result.key 175 | end 176 | end 177 | 178 | options.each do |key, value| 179 | option = command.options[key] 180 | if option.type.none? 181 | raise ExecutionError.new("Option '#{option}' takes no arguments") unless value.raw.nil? 182 | else 183 | if value.raw.nil? 184 | raise ExecutionError.new "Missing required argument#{"s" if option.type.multiple?} for option '#{option}'" 185 | end 186 | 187 | if option.type.multiple? && !value.raw.is_a?(Array) 188 | str = value.raw.to_s 189 | value = if str.includes?(',') 190 | Value.new str.split(',', remove_empty: true) 191 | else 192 | Value.new [str] 193 | end 194 | end 195 | end 196 | 197 | option.value = value 198 | parsed_options[option.long] = option 199 | end 200 | 201 | default_options = command.options 202 | .select { |_, v| v.has_default? } 203 | .reject { |k, _| parsed_options.has_key?(k) } 204 | 205 | parsed_options.merge! default_options 206 | missing_options = command.options 207 | .select { |_, v| v.required? } 208 | .keys 209 | .reject { |k| parsed_options.has_key?(k) } 210 | 211 | arguments = results.select &.kind.argument? 212 | parsed_arguments = {} of String => Argument 213 | missing_arguments = [] of String 214 | 215 | command.arguments.values.each_with_index do |argument, index| 216 | if res = arguments[index]? 217 | argument.value = Value.new res.value 218 | parsed_arguments[argument.name] = argument 219 | else 220 | missing_arguments << argument.name if argument.required? 221 | end 222 | end 223 | 224 | unknown_arguments = if arguments.empty? 225 | [] of String 226 | else 227 | arguments[parsed_arguments.size...].map &.value 228 | end 229 | 230 | if argument = parsed_arguments.values.find &.multiple? 231 | argument.value = Value.new([argument.value.as(Value).as_s] + unknown_arguments) 232 | unknown_arguments.clear 233 | parsed_arguments[argument.name] = argument 234 | end 235 | 236 | Result.new( 237 | parsed_options, 238 | unknown_options, 239 | missing_options, 240 | parsed_arguments, 241 | unknown_arguments, 242 | missing_arguments 243 | ) 244 | end 245 | 246 | private def self.finalize(command : Command, res : Result) : Nil 247 | command.on_unknown_options(res.unknown_options) unless res.unknown_options.empty? 248 | command.on_missing_options(res.missing_options) unless res.missing_options.empty? 249 | command.on_missing_arguments(res.missing_arguments) unless res.missing_arguments.empty? 250 | command.on_unknown_arguments(res.unknown_arguments) unless res.unknown_arguments.empty? 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /src/cling/ext.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | struct Value 3 | {% if @top_level.has_constant?("BigInt") %} 4 | # Returns the value as a `BigInt`. 5 | def as_big : BigInt 6 | BigInt.new @raw.to_s 7 | end 8 | {% end %} 9 | 10 | # Returns the value as a `Dir` object. Note that this will raise an exception if the directory 11 | # is not found. 12 | def as_dir : Dir 13 | Dir.new as_path 14 | end 15 | 16 | # Returns the value as a `File` object. Note that this will raise an exception if the file is 17 | # is not found. 18 | def as_file : File 19 | File.open as_path 20 | end 21 | 22 | # Returns the value as a `Path` object. This will attempt to resolve the value into a valid 23 | # path (see `Path.new`). 24 | def as_path : Path 25 | Path.new @raw.to_s 26 | end 27 | 28 | # Returns the value as a `Set`. Note that this does not change the type of the set. 29 | def as_set : Set(String) 30 | as_a.to_set 31 | end 32 | 33 | # Returns the value as a `Time` object. This will attempt to parse the value according to the 34 | # matching time format, otherwise it will raise an exception (see `Time.new`). 35 | def as_time : Time 36 | Time.new @raw.to_s 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /src/cling/formatter.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | # Generates a formatted help template for command components. 3 | class Formatter 4 | # Represents options for the formatter. 5 | class Options 6 | # The character to use for flag option delimiters (default is `-`). 7 | property option_delim : Char 8 | 9 | # Whether to show the `default` tag for options with default values (default is `true`). 10 | property? show_defaults : Bool 11 | 12 | # Whether to show the `required` tag for required arguments/options (default is `true`). 13 | property? show_required : Bool 14 | 15 | def initialize(*, @option_delim : Char = '-', @show_defaults : Bool = true, 16 | @show_required : Bool = true) 17 | end 18 | end 19 | 20 | # :nodoc: 21 | property options : Options 22 | 23 | def initialize(@options : Options = Options.new) 24 | end 25 | 26 | # Generates a help template for the specified command. This will attempt to fill fields that 27 | # have not been set in the command, for example, command usage strings. Values that are not 28 | # set, such as arguments and options, will not be written to the IO. 29 | def generate(command : Command) : String 30 | String.build do |io| 31 | format_header(command, io) 32 | format_description(command, io) 33 | format_usage(command, io) 34 | format_commands(command, io) 35 | format_arguments(command, io) 36 | format_options(command, io) 37 | format_footer(command, io) 38 | end.chomp 39 | end 40 | 41 | # :ditto: 42 | # 43 | # Writes to the IO and returns nothing. 44 | def generate(command : Command, io : IO) : Nil 45 | io << generate command 46 | end 47 | 48 | # Formats the header of a command into the given IO. 49 | def format_header(command : Command, io : IO) : Nil 50 | return unless header = command.header 51 | io << header << "\n\n" 52 | end 53 | 54 | # Formats the description of a command into the given IO. 55 | def format_description(command : Command, io : IO) : Nil 56 | return unless description = command.description 57 | io << description << "\n\n" 58 | end 59 | 60 | # Formats the usage strings of a command into the given IO. 61 | def format_usage(command : Command, io : IO) : Nil 62 | io << "Usage:" 63 | 64 | if command.usage.empty? 65 | io << "\n\t" << command.name 66 | unless command.arguments.empty? 67 | if command.arguments.values.any? &.required? 68 | io << " " 69 | else 70 | io << " [arguments]" 71 | end 72 | end 73 | 74 | unless command.options.empty? 75 | if command.options.values.any? &.required? 76 | io << " " 77 | else 78 | io << " [options]" 79 | end 80 | end 81 | else 82 | command.usage.each do |use| 83 | io << "\n\t" << use 84 | end 85 | end 86 | 87 | io << "\n\n" 88 | end 89 | 90 | # Formats the command information including subcommands into the given IO. By default, this 91 | # does not include hidden commands, but you can override this if you wish. 92 | def format_commands(command : Command, io : IO) : Nil 93 | commands = command.children.values.reject &.hidden? 94 | return if commands.empty? 95 | max_space = 4 + commands.max_of &.name.size 96 | 97 | io << "Commands:" 98 | commands.each do |cmd| 99 | io << "\n\t" 100 | if summary = cmd.summary 101 | cmd.name.ljust(io, max_space, ' ') 102 | io << summary 103 | else 104 | io << cmd.name 105 | end 106 | end 107 | 108 | io << "\n\n" 109 | end 110 | 111 | # Formats the arguments of the command into the given IO. 112 | def format_arguments(command : Command, io : IO) : Nil 113 | arguments = command.arguments.values.reject &.hidden? 114 | return if arguments.empty? 115 | max_space = 4 + arguments.max_of &.name.size 116 | 117 | io << "Arguments:" 118 | arguments.each do |argument| 119 | io << "\n\t" 120 | argument.name.ljust(io, max_space, ' ') 121 | io << argument.description 122 | io << " (required)" if @options.show_required? && argument.required? 123 | end 124 | 125 | io << "\n\n" 126 | end 127 | 128 | # Formats the options of the command into the given IO. 129 | def format_options(command : Command, io : IO) : Nil 130 | options = command.options.reject { |_, v| v.hidden? } 131 | return if options.empty? 132 | 133 | delim = @options.option_delim 134 | max_space = 4 + options.values.max_of { |o| o.long.size + (o.short ? 6 : 4) } 135 | 136 | io << "Options:" 137 | options.each do |name, option| 138 | io << "\n\t" 139 | if option.short 140 | "#{delim}#{option.short}, #{delim}#{delim}#{name}".ljust(io, max_space, ' ') 141 | else 142 | "#{delim}#{delim}#{name}".ljust(io, max_space, ' ') 143 | end 144 | 145 | io << option.description 146 | io << " (required)" if @options.show_required? && option.required? 147 | 148 | if @options.show_defaults? && option.has_default? 149 | default = option.default.to_s 150 | next if default.blank? 151 | io << " (default: " << default << ')' 152 | end 153 | end 154 | 155 | io << "\n\n" 156 | end 157 | 158 | # Formats the footer of the command into the given IO. 159 | def format_footer(command : Command, io : IO) : Nil 160 | return unless footer = command.footer 161 | io << footer << "\n\n" 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /src/cling/helper.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | abstract class MainCommand < Command 3 | def setup : Nil 4 | @name = "main" 5 | @version = "version 0.0.1" 6 | @inherit_borders = true 7 | @inherit_options = true 8 | 9 | add_option 'h', "help", description: "sends help information" 10 | add_option 'v', "version", description: "sends the app version" 11 | end 12 | 13 | def pre_run(arguments : Cling::Arguments, options : Cling::Options) : Nil 14 | if arguments.empty? && options.empty? 15 | Formatter.new.generate(self).to_s(stdout) 16 | exit_program 0 17 | end 18 | 19 | case options 20 | when .has? "help" 21 | Formatter.new.generate(self).to_s(stdout) 22 | 23 | exit_program 0 24 | when .has? "version" 25 | puts @version 26 | 27 | exit_program 0 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /src/cling/option.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | # Represents a command line flag option, supporting boolean and string values. Options are parsed 3 | # after the main command taking priority over the argument resolution (see `Executor#handle`). 4 | class Option 5 | # Identifies the value type of the option. `None` (the default) will not accept any arguments, 6 | # `Single` will accept exactly 1 argument, and `Multiple` will accept multiple arguments. 7 | # Multiple type options also support specifying the option name more than once in the command 8 | # line: 9 | # 10 | # ``` 11 | # command argument --option=1,2,3 # allowed 12 | # command argument -o 1 -o=2 -o 3 # also allowed 13 | # ``` 14 | enum Type 15 | None 16 | Single 17 | Multiple 18 | end 19 | 20 | property long : String 21 | property short : Char? 22 | property description : String? 23 | property? required : Bool 24 | property? hidden : Bool 25 | property type : Type 26 | property default : Value::Type 27 | property value : Value? 28 | 29 | def_equals @long, @short 30 | 31 | def initialize(@long : String, @short : Char? = nil, @description : String? = nil, 32 | @required : Bool = false, @hidden : Bool = false, @type : Type = :none, 33 | @default : Value::Type = nil) 34 | if type.none? && default 35 | raise ArgumentError.new "A default value for a flag option that takes no arguments is useless" 36 | end 37 | raise ArgumentError.new "Required options cannot have a default value" if required && default 38 | 39 | @value = Value.new @default 40 | end 41 | 42 | # :inherit: 43 | def to_s(io : IO) : Nil 44 | io << @long 45 | end 46 | 47 | # Returns `true` if a default value is set. 48 | def has_default? : Bool 49 | !@default.nil? 50 | end 51 | 52 | # Returns true if the name matches the option's long or short flag name. 53 | def is?(name : String) : Bool 54 | @short.to_s == name || @long == name 55 | end 56 | end 57 | 58 | # An input structure to access validated options at execution time. 59 | struct Options 60 | getter hash : Hash(String, Option) 61 | 62 | # :nodoc: 63 | def initialize(@hash) 64 | end 65 | 66 | # Indexes an option by its long name and returns the `Option` object, not the option's 67 | # value. 68 | def [](key : String) : Option 69 | @hash[key] rescue raise ValueNotFound.new(key) 70 | end 71 | 72 | # Indexes an option by its short name and returns the `Option` object, not the option's 73 | # value. 74 | def [](key : Char) : Option 75 | @hash.values.find! &.short.== key 76 | rescue 77 | raise ValueNotFound.new(key.to_s) 78 | end 79 | 80 | # Indexes an option by its long name and returns the `Option` object or `nil` if not found, 81 | # not the option's value. 82 | def []?(key : String) : Option? 83 | @hash[key]? 84 | end 85 | 86 | # Indexes an option by its short name and returns the `Option` object or `nil` if not found, 87 | # not the option's value. 88 | def []?(key : Char) : Option? 89 | @hash.values.find &.is? key.to_s 90 | end 91 | 92 | # Returns `true` if an option by the given long name exists. 93 | def has?(key : String) : Bool 94 | @hash.has_key?(key) || !@hash.values.find(&.is? key).nil? 95 | end 96 | 97 | # Returns `true` if an option by the given short name exists. 98 | def has?(key : Char) : Bool 99 | has? key.to_s 100 | end 101 | 102 | # Gets an option by its short or long name and returns its `Value`, or `nil` if not found. 103 | def get?(key : String | Char) : Value? 104 | self[key]?.try &.value 105 | end 106 | 107 | # Gets an option by its short or long name and returns its `Value`. 108 | def get(key : String | Char) : Value 109 | self[key].value || raise ValueNotFound.new(key.to_s) 110 | end 111 | 112 | # Returns `true` if there are no parsed options. 113 | def empty? : Bool 114 | @hash.empty? 115 | end 116 | 117 | # Returns the number of parsed options. 118 | def size : Int32 119 | @hash.size 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /src/cling/parser.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | # Handles parsing command line arguments into raw argument objects (see `Result`) which are used 3 | # by the `Executor` at execution time. 4 | class Parser 5 | # Represents options for the parser. 6 | class Options 7 | # Parse string arguments as one value instead of separate values (defaults is `true`). 8 | property? parse_string : Bool 9 | # TODO 10 | # property parse_escape : Bool 11 | 12 | # The character to use for flag option delimiters (default is `-`). 13 | property option_delim : Char 14 | 15 | # The characters to accept as string delimiters (default is `"` and `'`). 16 | property string_delims : Set(Char) 17 | 18 | def initialize(*, @parse_string : Bool = true, @option_delim : Char = '-', 19 | @string_delims : Set(Char) = Set{'"', '\''}) 20 | end 21 | end 22 | 23 | # The result of a parsed value from the command line. This can be a normal argument, string 24 | # argument, short flag, or long flag. 25 | class Result 26 | # Represents the kind of the result. 27 | enum Kind 28 | Argument 29 | ShortFlag 30 | LongFlag 31 | end 32 | 33 | property kind : Kind 34 | property! key : String 35 | property! value : String 36 | getter? string : Bool 37 | 38 | def initialize(@kind : Kind, @key : String? = nil, @value : String? = nil, *, @string : Bool = false) 39 | end 40 | 41 | # Returns the non-nil form of the result key which is the name if it is a flag, or the value 42 | # if it is an argument. 43 | @[Deprecated("Use `Cling::Parser::Result#key` instead.")] 44 | def key! : String 45 | key 46 | end 47 | 48 | # Returns the non-nil form of the result value which is the explicit value if it is a flag, 49 | # or the value if it is an argument. 50 | @[Deprecated("Use `Cling::Parser::Result#value` instead.")] 51 | def value! : String 52 | value 53 | end 54 | end 55 | 56 | @reader : Char::Reader 57 | @options : Options 58 | 59 | def initialize(input : String, @options : Options = Options.new) 60 | @reader = Char::Reader.new input 61 | end 62 | 63 | def self.new(input : Array(String), options : Options = Options.new) 64 | arguments = input.map do |a| 65 | if a.includes?(' ') && options.string_delims.none? { |d| a.starts_with?(d) && a.ends_with?(d) } 66 | d = options.string_delims.first 67 | d.to_s + a + d.to_s 68 | else 69 | a 70 | end 71 | end 72 | 73 | new arguments.join(' '), options 74 | end 75 | 76 | # Parses the command line arguments from the reader and returns a hash of the results. 77 | def parse : Array(Result) 78 | results = [] of Result 79 | all_positional = false 80 | 81 | loop do 82 | case char = @reader.current_char 83 | when '\0' 84 | break 85 | when ' ' 86 | @reader.next_char 87 | when '-' 88 | case @reader.peek_next_char 89 | when '-' 90 | @reader.next_char 91 | if @reader.peek_next_char == ' ' 92 | @reader.next_char 93 | all_positional = true 94 | break 95 | else 96 | @reader.previous_char 97 | results << read_option 98 | end 99 | else 100 | results << read_option 101 | end 102 | when @options.option_delim 103 | results << read_option 104 | else 105 | if char.in?(@options.string_delims) && @options.parse_string? 106 | results << read_string 107 | else 108 | results << read_argument 109 | end 110 | end 111 | end 112 | 113 | if all_positional 114 | loop do 115 | case char = @reader.current_char 116 | when '\0' 117 | break 118 | when ' ' 119 | @reader.next_char 120 | else 121 | if char.in?(@options.string_delims) && @options.parse_string? 122 | results << read_string 123 | else 124 | results << read_argument 125 | end 126 | end 127 | end 128 | end 129 | 130 | validated = [] of Result 131 | results.each do |result| 132 | unless result.kind.short_flag? 133 | validated << result 134 | next 135 | end 136 | 137 | if (key = result.key) && key.size > 1 138 | flags = key.chars.map { |c| Result.new(:short_flag, c.to_s) } 139 | if result.value? 140 | option = flags[-1] 141 | option.value = result.value 142 | flags[-1] = option 143 | end 144 | validated += flags 145 | else 146 | validated << result 147 | end 148 | end 149 | 150 | validated 151 | end 152 | 153 | private def read_option : Result 154 | kind = Result::Kind::ShortFlag 155 | if @reader.peek_next_char == @options.option_delim 156 | kind = Result::Kind::LongFlag 157 | @reader.pos += 2 158 | else 159 | @reader.next_char 160 | end 161 | 162 | result = Result.new kind 163 | result.key = String.build do |str| 164 | loop do 165 | case char = @reader.current_char 166 | when '\0', ' ', '=' 167 | break 168 | else 169 | if @options.string_delims.includes?(char) && @options.parse_string? 170 | str << read_string_raw 171 | break 172 | else 173 | str << char 174 | @reader.next_char 175 | end 176 | end 177 | end 178 | end 179 | 180 | if @reader.current_char == '=' 181 | @reader.next_char 182 | 183 | result.value = String.build do |str| 184 | loop do 185 | case char = @reader.current_char 186 | when '\0', ' ' 187 | break 188 | else 189 | if @options.string_delims.includes?(char) && @options.parse_string? 190 | str << read_string_raw 191 | break 192 | else 193 | str << char 194 | @reader.next_char 195 | end 196 | end 197 | end 198 | end 199 | end 200 | 201 | result 202 | end 203 | 204 | private def read_argument : Result 205 | value = String.build do |str| 206 | loop do 207 | case char = @reader.current_char 208 | when '\0' 209 | break 210 | when ' ' 211 | @reader.next_char 212 | break 213 | else 214 | str << char 215 | @reader.next_char 216 | end 217 | end 218 | end 219 | 220 | Result.new :argument, nil, value 221 | end 222 | 223 | private def read_string : Result 224 | Result.new :argument, nil, read_string_raw, string: true 225 | end 226 | 227 | private def read_string_raw : String 228 | delim = @reader.current_char 229 | escaped = false 230 | @reader.next_char 231 | 232 | String.build do |str| 233 | loop do 234 | case char = @reader.current_char 235 | when '\0' 236 | raise ParserError.new "Unterminated quote string" 237 | when '\\' 238 | escaped = !escaped 239 | @reader.next_char 240 | when delim 241 | if escaped 242 | escaped = false 243 | str << char 244 | @reader.next_char 245 | else 246 | @reader.next_char 247 | break 248 | end 249 | else 250 | str << char 251 | @reader.next_char 252 | end 253 | end 254 | end 255 | end 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /src/cling/value.cr: -------------------------------------------------------------------------------- 1 | module Cling 2 | # Represents a value for an argument or option. 3 | struct Value 4 | alias Type = String | Number::Primitive | Bool | Nil | Array(String) 5 | 6 | getter raw : Type 7 | 8 | delegate :==, :===, :to_s, to: @raw 9 | 10 | def initialize(@raw : Type) 11 | end 12 | 13 | # Returns the size of the value if it is an array or hash, otherwise raises an exception. 14 | def size : Int32 15 | case value = @raw 16 | when Array 17 | value.size 18 | when Hash 19 | value.size 20 | else 21 | raise ArgumentError.new "Cannot get size of type #{value.class}" 22 | end 23 | end 24 | 25 | # Returns the value as a `String`. 26 | def as_s : String 27 | @raw.as(String) 28 | end 29 | 30 | # Returns the value as a `String` or `nil` if the underlying value is not a string. 31 | def as_s? : String? 32 | @raw.as?(String) 33 | end 34 | 35 | # Returns the value as an `Int`. 36 | def as_i : Int 37 | @raw.as(Int) 38 | end 39 | 40 | # Returns the value as a `Int` or `nil` if the underlying value is not an integer. 41 | def as_i? : Int? 42 | @raw.as?(Int) 43 | end 44 | 45 | # Conversts the value to an `Int`. 46 | def to_i : Int 47 | @raw.to_s.to_i 48 | end 49 | 50 | # Converts the value to an `Int` or `Nil` if the underlying value cannot be parsed. 51 | def to_i? : Int? 52 | @raw.to_s.to_i? 53 | end 54 | 55 | # Returns the value as a `Float`. 56 | def as_f : Float 57 | @raw.as(Float) 58 | end 59 | 60 | # Returns the value as a `Float` or `nil` if the underlying value is not a float. 61 | def as_f? : Float? 62 | @raw.as?(Float) 63 | end 64 | 65 | # Converts the value to a `Float`. 66 | def to_f : Float 67 | @raw.to_s.to_f 68 | end 69 | 70 | # Converts the value to a `Float` or `Nil` if the underlying value cannot be parsed. 71 | def to_f? : Float? 72 | @raw.to_s.to_f? 73 | end 74 | 75 | # Returns the value as a `Bool`. 76 | def as_bool : Bool 77 | @raw.as(Bool) 78 | end 79 | 80 | # Returns the value as a `Bool` or `nil` if the underlying value is not a boolean. 81 | def as_bool? : Bool? 82 | @raw.as?(Bool) 83 | end 84 | 85 | # Converts the value to a `Bool`. 86 | def to_bool : Bool 87 | value = to_bool? 88 | return value unless value.nil? 89 | 90 | raise ArgumentError.new "cannot parse Bool from #{@raw.class}" 91 | end 92 | 93 | # Converts the value to a `Bool` or `Nil` if the underlying value cannot be parsed. 94 | def to_bool? : Bool? 95 | case @raw.to_s 96 | when "true" then true 97 | when "false" then false 98 | else nil 99 | end 100 | end 101 | 102 | # Returns the value as an `Array`. Note that this does not change the type of the array. 103 | def as_a : Array(String) 104 | @raw.as(Array(String)) 105 | end 106 | 107 | # Returns the value as an `Array`. Note that this does not change the type of the array. 108 | # Returns `nil` if the underlying value is not an array. 109 | def as_a? : Array(String)? 110 | @raw.as?(Array(String)) 111 | end 112 | 113 | {% for base in %w(8 16 32 64 128) %} 114 | # Returns the value as an `Int{{ base }}`. 115 | def as_i{{ base.id }} : Int{{ base.id }} 116 | @raw.as(Int{{ base.id }}) 117 | end 118 | 119 | # Returns the value as an `Int{{ base }}` or `nil`. 120 | def as_i{{ base.id }}? : Int{{ base.id }}? 121 | @raw.as?(Int{{ base.id }}) 122 | end 123 | 124 | # Converts the value to an `Int{{ base.id }}`. 125 | def to_i{{ base.id }} : Int{{ base.id }} 126 | @raw.to_s.to_i{{ base.id }} 127 | end 128 | 129 | # Converts the value to an `Int{{ base.id }}` or `Nil` if the underlying value cannot be parsed. 130 | def to_i{{ base.id }}? : Int{{ base.id }}? 131 | @raw.to_s.to_i{{ base.id }}? 132 | end 133 | 134 | # Returns the value as a `UInt{{ base }}`. 135 | def as_u{{ base.id }} : UInt{{ base.id }} 136 | @raw.as(UInt{{ base.id }}) 137 | end 138 | 139 | # Returns the value as a `UInt{{ base }}` or `nil`. 140 | def as_u{{ base.id }}? : UInt{{ base.id }}? 141 | @raw.as?(UInt{{ base.id }}) 142 | end 143 | 144 | # Converts the value to a `UInt{{ base.id }}`. 145 | def to_u{{ base.id }} : UInt{{ base.id }} 146 | @raw.to_s.to_u{{ base.id }} 147 | end 148 | 149 | # Converts the value to a `UInt{{ base.id }}` or `Nil` if the underlying value cannot be parsed. 150 | def to_u{{ base.id }}? : UInt{{ base.id }}? 151 | @raw.to_s.to_u{{ base.id }}? 152 | end 153 | {% end %} 154 | 155 | {% for base in %w(32 64) %} 156 | # Returns the value as a `Float{{ base }}`. 157 | def as_f{{ base.id }} : Float{{ base.id }} 158 | @raw.as(Float{{ base.id }}) 159 | end 160 | 161 | # Returns the value as a `Float{{ base }}` or `nil`. 162 | def as_f{{ base.id }}? : Float{{ base.id }}? 163 | @raw.as?(Float{{ base.id }}) 164 | end 165 | 166 | # Converts the value to a `Float{{ base.id }}`. 167 | def to_f{{ base.id }} : Float{{ base.id }} 168 | @raw.to_s.to_f{{ base.id }} 169 | end 170 | 171 | # Converts the value to a `Float{{ base.id }}` or `Nil` if the underlying value cannot be parsed. 172 | def to_f{{ base.id }}? : Float{{ base.id }}? 173 | @raw.to_s.to_f{{ base.id }}? 174 | end 175 | {% end %} 176 | 177 | # Indexes the value if the value is an array, otherwise raises an exception. 178 | def [](index : Int32) : Type 179 | case value = @raw 180 | when Array 181 | value[index] 182 | else 183 | raise "Cannot get index of type #{value.class}" 184 | end 185 | end 186 | 187 | # Attempts to index the value if the value is an array or returns `nil`, otherwise raises an 188 | # exception. 189 | def []?(index : Int32) : Type 190 | case value = @raw 191 | when Array 192 | value[index]? 193 | else 194 | raise ArgumentError.new "Cannot get index of type #{value.class}" 195 | end 196 | end 197 | 198 | # :ditto: 199 | def [](index : Range) : Type 200 | case value = @raw 201 | when Array 202 | value[index] 203 | else 204 | raise ArgumentError.new "Cannot get index of type #{value.class}" 205 | end 206 | end 207 | 208 | # :ditto: 209 | def []?(index : Range) : Type 210 | case value = @raw 211 | when Array 212 | value[index]? 213 | else 214 | raise ArgumentError.new "Cannot get index of type #{value.class}" 215 | end 216 | end 217 | 218 | # Indexes the value if the value is a hash, otherwise raises an exception. 219 | def [](key : String) : Type 220 | case value = @raw 221 | when Hash 222 | value[index] 223 | else 224 | raise ArgumentError.new "Cannot get index of type #{value.class}" 225 | end 226 | end 227 | 228 | # Attempts to index the value if the value is a hash or returns `nil`, otherwise raises an 229 | # exception. 230 | def []?(key : String) : Type? 231 | case value = @raw 232 | when Hash 233 | value[index]? 234 | else 235 | raise ArgumentError.new "Cannot get index of type #{value.class}" 236 | end 237 | end 238 | end 239 | end 240 | --------------------------------------------------------------------------------