├── fish ├── completions │ ├── fisher.fish │ ├── bld.fish │ └── howzit.fish └── functions │ └── bld.fish ├── .gitignore ├── install.sh ├── LICENSE.md ├── CHANGELOG.md ├── README.md └── howzit /fish/completions/fisher.fish: -------------------------------------------------------------------------------- 1 | fisher complete 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | buildnotes.md 2 | update_readmes.rb 3 | -------------------------------------------------------------------------------- /fish/completions/bld.fish: -------------------------------------------------------------------------------- 1 | complete -xc bld -a "(howzit -T)" 2 | -------------------------------------------------------------------------------- /fish/completions/howzit.fish: -------------------------------------------------------------------------------- 1 | complete -xc howzit -a "(howzit -L)" 2 | complete -xc howzit -s r -a "(howzit -T)" 3 | 4 | -------------------------------------------------------------------------------- /fish/functions/bld.fish: -------------------------------------------------------------------------------- 1 | function fallback --description 'allow a fallback value for variable' 2 | if test (count $argv) = 1 3 | echo $argv 4 | else 5 | echo $argv[1..-2] 6 | end 7 | end 8 | 9 | function bld -d "Run howzit build system" 10 | howzit -r (fallback $argv build) 11 | end 12 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo 'Please enter a path to a directory in your $PATH' 3 | read -p 'Where should howzit be installed? ' INSTALL_PATH 4 | 5 | INSTALL_PATH=${INSTALL_PATH/#\~/$HOME} 6 | 7 | if [[ -d "$INSTALL_PATH" ]]; then 8 | HOWZIT=${INSTALL_PATH%/}/howzit 9 | else 10 | echo "Invalid path: ${INSTALL_PATH}" 11 | exit 1 12 | fi 13 | 14 | curl -SsL -o "$HOWZIT" 'https://raw.githubusercontent.com/ttscoff/howzit/main/howzit' 15 | chmod a+x "$HOWZIT" 16 | which howzit &>/dev/null 17 | if [[ $? == 0 ]]; then 18 | echo "Installed to ${HOWZIT}, run $(basename "$HOWZIT") to test." 19 | else 20 | echo "Installed to ${HOWZIT}, but it doesn't seem to be in your path. Ensure that \"$INSTALL_PATH\" is added to your shell's path environment." 21 | fi 22 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2022 Brett Terpstra 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 19 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 1.2.3 2 | 3 | 2022-07-31 14:20 4 | 5 | #### IMPROVED 6 | 7 | - Don't include a topic multiple times in one display 8 | - Don't execute nested topics more than once 9 | - Indicate nested includes in headers 10 | - Code cleanup 11 | 12 | ### 1.2.2 13 | 14 | 2022-07-31 08:56 15 | 16 | - Add -F option to pager setup (quit if less than one screen) 17 | 18 | ### 1.2.1 19 | 20 | 2022-07-31 05:12 21 | 22 | - Add handling for delta pager to not clear screen on exit 23 | 24 | ### 1.2.0 25 | 26 | 2022-07-31 04:59 27 | 28 | - Add grep feature, searches topic/content for pattern and displays matches (selection menu if multiple matches) 29 | 30 | ### 1.1.27 31 | 32 | 2022-01-17 11:45 33 | 34 | #### NEW 35 | 36 | - Use fzf for menus if available 37 | - "@run() TITLE" will show TITLE instead of command when listing runnable topics 38 | - @include(FILENAME) will import an external file if the path exists 39 | 40 | ### 1.1.26 41 | 42 | - Fix for error in interactive build notes creation 43 | 44 | ### 1.1.25 45 | 46 | - Hide run block contents by default 47 | - :show_all_code: config setting to include run block contents 48 | - --show-code flag to display run block contents at runtime 49 | - Modify include display 50 | 51 | ### 1.1.24 52 | 53 | - Use ~/.config/howzit/ignore.yaml to ignore patterns when scanning for build notes 54 | - Use `required` and `optional` keys in templates to request that metadata be defined when importing 55 | - Allow templates to include other templates 56 | 57 | ### 1.1.23 58 | 59 | - Add flags to allow content to stay onscreen after exiting pager (less and bat) 60 | 61 | ### 1.1.21 62 | 63 | - Merge directive and block handling so execution order is sequential 64 | 65 | ### 1.1.20 66 | 67 | - Template functionality for including common tasks/topics 68 | 69 | ### 1.1.19 70 | 71 | - Add `--upstream` option to traverse up parent directories for additional build notes 72 | 73 | ### 1.1.15 74 | 75 | - Code refactoring/cleanup 76 | - Rename "sections" to "topics" 77 | - If no match found for topic search, only show error (`:show_all_on_error: false` option) 78 | 79 | ### 1.1.14 80 | 81 | - Fix removal of non-alphanumeric characters from titles 82 | - -s/--select option to display a menu of all available topics 83 | - Allow arguments to be passed after `--` for variable substitution 84 | - Allow --matching TYPE to match first non-ambigous keyword match 85 | 86 | ### 1.1.13 87 | 88 | - --matching [fuzzy,beginswith,partial,exact] flag 89 | - --edit-config flag 90 | - sort flags in help 91 | 92 | ### 1.1.12 93 | 94 | - After consideration, remove full fuzzy matching. Too many positives for shorter strings. 95 | 96 | ### 1.1.11 97 | 98 | - Add full fuzzy matching for topic titles 99 | - Add `@include(TOPIC)` command to import another topic's tasks 100 | 101 | ### 1.1.10 102 | 103 | - Add config file for default options 104 | 105 | ### 1.1.9 106 | 107 | - Use `system` instead of `exec` to allow multiple @run commands 108 | - Add code block runner 109 | 110 | ### 1.1.8 111 | 112 | - Add `-e/--edit` flag to open build notes in $EDITOR 113 | 114 | ### 1.1.7 115 | 116 | - Use `exec` for @run commands to allow interactive processes (e.g. vim) 117 | 118 | ### 1.1.6 119 | 120 | - Add option for outputting title with notes 121 | - Add option for outputting note title only 122 | 123 | ### 1.1.4 124 | 125 | - Fix for "topic not found" when run with no arguments 126 | 127 | ### 1.1.1 128 | 129 | - Reorganize and rename long output options 130 | - Fix wrapping long lines without spaces 131 | 132 | ### 1.1.0 133 | 134 | - Add -R switch for listing "runnable" topics 135 | - Add -T switch for completion-compatible listing of "runnable" topics 136 | - Add -L switch for completion-compatible listing of all topics 137 | 138 | ### 1.0.1 139 | 140 | - Allow topic matching within title, not just at start 141 | - Remove formatting of topic text for better compatibility with mdless/mdcat 142 | - Add @run() syntax to allow executable commands 143 | - Add @copy() syntax to copy text to clipboard 144 | - Add @url/@open() syntax to open urls/files, OS agnostic (hopefully) 145 | - Add support for mdless/mdcat 146 | - Add support for pager 147 | - Offer to create skeleton buildnotes if none found 148 | - Set iTerm 2 marks for navigation when paging is disabled 149 | - Wrap output with option to specify width (default 80, 0 to disable) 150 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Howzit 2 | 3 | A command-line reference tool for tracking project build systems 4 | 5 | Howzit is a tool that allows you to keep Markdown-formatted notes about a project's tools and procedures. It functions as an easy lookup for notes about a particular task, as well as a task runner to automatically execute appropriate commands. 6 | 7 | 8 | 9 | ## Features 10 | 11 | - Match topic titles with any portion of title 12 | - Automatic pagination of output, with optional Markdown highlighting 13 | - Use `@run()`, `@copy()`, and `@open()` to perform actions within a build notes file 14 | - Use `@include()` to import another topic's tasks 15 | - Use fenced code blocks to include/run embedded scripts 16 | - Sets iTerm 2 marks on topic titles for navigation when paging is disabled 17 | - Inside of git repositories, howzit will work from subdirectories, assuming build notes are in top level of repo 18 | - Templates for easily including repeat tasks 19 | - Grep topics for pattern and choose from matches 20 | 21 | ## Getting Started 22 | 23 | Howzit is a simple, self-contained script (at least until I get stupid and make a gem out of it). 24 | 25 | ### Prerequisites 26 | 27 | - Ruby 2.4+ (It probably works on older Rubys, but is untested prior to 2.4.1.) 28 | - Optional: if [`bat`](https://github.com/sharkdp/bat) is available it will page with that 29 | - Optional: [`mdless`](https://github.com/ttscoff/mdless) or [`mdcat`](https://github.com/lunaryorn/mdcat) for formatting output 30 | 31 | ### Installing 32 | 33 | #### One-Line Install 34 | 35 | You can install `howzit` by running: 36 | 37 | curl -SsL 'https://raw.githubusercontent.com/ttscoff/howzit/main/install.sh'|bash 38 | 39 | #### Manual Install 40 | 41 | [Clone the repo](https://github.com/ttscoff/howzit/) or just [download the self-contained script](https://github.com/ttscoff/howzit/blob/main/howzit). Save the script as `howzit` to a folder in your $PATH and make it executable with: 42 | 43 | chmod a+x howzit 44 | 45 | ## Anatomy of a Build Notes File 46 | 47 | Howzit relies on there being a file in the current directory with a name that starts with "build" or "howzit" and an extension of `.md`, `.txt`, or `.markdown`, e.g. `buildnotes.md` or `howzit.txt`. This note contains topics such as "Build" and "Deploy" with brief notes about each topic in Markdown (or just plain text) format. 48 | 49 | > Tip: Add "buildprivate.md" to your global gitignore (`git config --get core.excludesfile`). In a project where you don't want to share your build notes, just name the file "buildprivate.md" instead of "buildnotes.md" and it will automatically be ignored. 50 | 51 | If there are files that match the "build*" pattern that should not be recognized as build notes by howzit, add them to `~/.config/howzit/ignore.yaml`. This file is a simple list of patterns that should be ignored when scanning for build note files. Use `(?:i)` at the beginning of a pattern to make it case insensitive. 52 | 53 | The topics of the notes are delineated by Markdown headings, level 2 or higher, with the heading being the title of the topic. I split all of mine apart with h2s. For example, a short one from the little website I was working on yesterday: 54 | 55 | ## Build 56 | 57 | gulp js: compiles and minifies all js to dist/js/main.min.js 58 | 59 | gulp css: compass compile to dist/css/ 60 | 61 | gulp watch 62 | 63 | gulp (default): [css,js] 64 | 65 | ## Deploy 66 | 67 | gulp sync: rsync /dist/ to scoffb.local 68 | 69 | ## Package management 70 | 71 | yarn 72 | 73 | ## Components 74 | 75 | - UIKit 76 | 77 | Howzit expects there to only be one header level used to split topics. Anything before the first header is ignored. If your topics use h2 (`##`), you can use a single h1 (`#`) line at the top to title the project. 78 | 79 | ### @Commands 80 | 81 | You can include commands that can be executed by howzit. Commands start at the beginning of a line anywhere in a topic. Only one topic's commands can be run at once, but all commands in the topic will be executed when howzit is run with `-r`. Commands can include any of: 82 | 83 | - `@run(COMMAND)` 84 | 85 | The command in parenthesis will be executed as is from the current directory of the shell 86 | - `@copy(TEXT)` 87 | 88 | On macOS this will copy the text within the parenthesis to the clipboard. An easy way to offer a shortcut to a longer build command while still allowing it to be edited prior to execution. 89 | - `@open(FILE|URL)` 90 | 91 | Will open the file or URL using the default application for the filetype. On macOS this uses the `open` command, on Windows it uses `start`, and on Linux it uses `xdg-open`, which may require separate installation. 92 | - `@include(TOPIC)` 93 | 94 | Includes all tasks from another topic, matching the name (partial match allowed) and returning first match. 95 | 96 | ### Run blocks (embedded scripts) 97 | 98 | For longer scripts you can write shell scripts and then call them with `@run(myscript.sh)`. For those in-between cases where you need a few commands but aren't motivated to create a separate file, you can use fenced code blocks with `run` as the language. 99 | 100 | ```run OPTIONAL TITLE 101 | #!/bin/bash 102 | # Commands... 103 | ``` 104 | 105 | The contents of the block will be written to a temporary file and executed with `/bin/sh -c`. This means that you need a hashbang at the beginning to tell the shell how to interpret the script. If no hashbang is given, the script will be executed as a `sh` script. 106 | 107 | Example: 108 | 109 | ```run Just Testing 110 | #!/bin/bash 111 | echo "Just needed a few lines" 112 | echo "To get through all these commands" 113 | echo "Almost there!" 114 | say "Phew, we did it." 115 | ``` 116 | 117 | Multiple blocks can be included in a topic. @commands take priority over code blocks and will be run first if they exist in the same topic. 118 | 119 | ### Variables 120 | 121 | When running commands in a topic, you can use a double dash (`--`) in the command line (surrounded by spaces) and anything after it will be interpreted as shell arguments. These can be used in commands with `$` placeholders. `$1` represents the first argument, counting up from there. Use `$@` to pass all arguments as a shell-escaped string. 122 | 123 | For example, the topic titled "Test" could contain an @run command with placeholders: 124 | 125 | ## Test 126 | @run(./myscript.sh $@) 127 | 128 | Then you would run it on the command line using: 129 | 130 | howzit -r test -- -x "arg 1" arg2 131 | 132 | This would execute the command as `./myscript.sh -x arg\ 1 arg2`. 133 | 134 | Placeholders can be used in both commands and run blocks. If a placeholder doesn't have an argument supplied, it's not replaced (e.g. leaves `$2` in the command). 135 | 136 | ### Templates and metadata 137 | 138 | You can create templates to reuse topics in multiple build note files. Create files using the same formatting as a build note in `~/.config/howzit/templates` with `.md` extensions. Name them the way you'll reference them: 139 | 140 | ~/.config/howzit/templates 141 | - markdown.md 142 | - ruby.md 143 | - obj-c.md 144 | 145 | > Use `howzit --templates` for a list of templates you've created, along with the topics they'll add when included. Just in case you make a bunch and can't remember what they're called or what they do. I was just planning ahead. 146 | 147 | You can then include the topics from a template in any build note file using a `template:` key at the top of the file. 148 | 149 | Howzit allows MultiMarkdown-style metadata at the top of a build notes file. These are key/value pairs separated by a colon: 150 | 151 | template: markdown 152 | key 1: value 1 153 | key 2: value 2 154 | 155 | The template key can include multiple template names separated by commas. 156 | 157 | Additional metadata keys populate variables you can then use inside of your templates (and build notes), using `[%key]`. You can define a default value for a placeholder with `[%key:default]`. 158 | 159 | For example, in the template `markdown.md` you could have: 160 | 161 | ### Spellcheck 162 | 163 | Check spelling of all Markdown files in git repo. 164 | 165 | ```run 166 | #!/bin/bash 167 | for dir in [%dirs:.]; do 168 | cd "$dir" 169 | /Users/ttscoff/scripts/spellcheck.bash 170 | cd - 171 | done 172 | ``` 173 | 174 | Then, in a `buildnotes.md` file in your project, you could include at the top of the file: 175 | 176 | template: markdown 177 | dirs: . docs 178 | 179 | # My Project... 180 | 181 | If you only want to include certain topics from a template file, use the format `template_name[topic]` or include multiple topics separated by commas: `template_name[topic 1, topic 2]`. You can also use `*` as a wildcard, where `template_name[build*]` would include topics "Build" and "Build and Run". 182 | 183 | If a topic in the current project's build note has an identical name to a template topic, the local topic takes precedence. This allows you to include a template but modify just a part of it by duplicating the topic title. 184 | 185 | Templates can include other templates with a `template:` key at the top of the template. 186 | 187 | You can define what metadata keys are required for the template using a `required:` key at the top of the template. For example, if the template `script.md` uses a placeholder `[%executable]` that can't have a default value as it's specific to each project, you can add: 188 | 189 | required: executable 190 | 191 | at the top of `project.md`. If the template is included in a build notes file and the `executable:` key is not defined, an error will be shown. 192 | 193 | ## Using howzit 194 | 195 | Run `howzit` on its own to view the current folder's buildnotes. 196 | 197 | Include a topic name to see just that topic, or no argument to display all. 198 | 199 | howzit build 200 | 201 | Use `-l` to list all topics. 202 | 203 | howzit -l 204 | 205 | Use `-r` to execute any @copy, @run, or @open commands in the given topic. Options can come after the topic argument, so to run the commands from the last topic you viewed, just hit the up arrow to load the previous command and add `-r`. 206 | 207 | howzit build -r 208 | 209 | Other options: 210 | 211 | Usage: howzit [OPTIONS] [TOPIC] 212 | 213 | Show build notes for the current project (buildnotes.md). Include a topic name to see just that topic, or no argument to display all. 214 | 215 | Options: 216 | -c, --create Create a skeleton build note in the current working directory 217 | -e, --edit Edit buildnotes file in current working directory using editor.sh 218 | --grep PATTERN Display sections matching a search pattern 219 | -L, --list-completions List topics for completion 220 | -l, --list List available topics 221 | -m, --matching TYPE Topics matching type 222 | (partial, exact, fuzzy, beginswith) 223 | -R, --list-runnable List topics containing @ directives (verbose) 224 | -r, --run Execute @run, @open, and/or @copy commands for given topic 225 | -s, --select Select topic from menu 226 | -T, --task-list List topics containing @ directives (completion-compatible) 227 | -t, --title Output title with build notes 228 | -q, --quiet Silence info message 229 | --verbose Show all messages 230 | -u, --upstream Traverse up parent directories for additional build notes 231 | --show-code Display the content of fenced run blocks 232 | -w, --wrap COLUMNS Wrap to specified width (default 80, 0 to disable) 233 | --edit-config Edit configuration file using editor.sh 234 | --title-only Output title only 235 | --templates List available templates 236 | --[no-]color Colorize output (default on) 237 | --[no-]md-highlight Highlight Markdown syntax (default on), requires mdless or mdcat 238 | --[no-]pager Paginate output (default on) 239 | -h, --help Display this screen 240 | -v, --version Display version number 241 | 242 | 243 | ## Configuration 244 | 245 | Some of the command line options can be set as defaults. The first time you run `howzit`, a YAML file is written to `~/.config/howzit/howzit.yaml`. You can open it in your default editor automatically by running `howzit --edit-config`. It contains the available options: 246 | 247 | --- 248 | :color: true 249 | :highlight: true 250 | :paginate: true 251 | :wrap: 80 252 | :output_title: false 253 | :highlighter: auto 254 | :pager: auto 255 | :matching: partial 256 | :include_upstream: false 257 | :log_level: 1 258 | 259 | If `:color:` is false, output will not be colored, and markdown highlighting will be bypassed. 260 | 261 | If `:color:` is true and `:highlight:` is true, the `:highlighter:` option will be used to add Markdown highlighting. 262 | 263 | If `:paginate:` is true, the `:pager:` option will be used to determine the tool used for pagination. If it's false and you're using iTerm, "marks" will be added to topic titles allowing keyboard navigation. 264 | 265 | `:highlighter:` and `:pager:` can be set to `auto` (default) or a command of your choice for markdown highlighting and pagination. 266 | 267 | `:matching:` can be "partial", "beginswith", "fuzzy" or "exact" (see below). 268 | 269 | If `:include_upstream:` is true, build note files in parent directories will be included in addition to the current directory. Priority goes from current directory to root in descending order, so the current directory is top priority, and a build notes file in / is the lowest. Higher priority topics will not be overwritten by a duplicate topic from a lower priority note. 270 | 271 | Set `:log_level:` to 0 for debug messages, or 3 to suppress superfluous info messages. 272 | 273 | ### Matching 274 | 275 | All matching is case insensitive. This setting can be overridden by the `--matching TYPE` flag on the command line. 276 | 277 | - `:matching: partial` 278 | 279 | Partial is the default, search matches any part of the topic title. 280 | 281 | _Example:_ `howzit other` matches 'Another Topic'. 282 | 283 | - `:matching: beginswith` 284 | 285 | Matches from the start of the title. 286 | 287 | _Example:_ `howzit another` matches 'Another Topic', but neither 'other' or 'topic' will. 288 | 289 | - `:matching: fuzzy` 290 | 291 | Matches anything containing the search characters in order, no matter what comes between them. 292 | 293 | _Example:_ `howzit asct` matches 'Another Section' 294 | 295 | - `:matching: exact` 296 | 297 | Case insensitive but must match the entire title. 298 | 299 | _Example:_ Only `howzit another topic` will match 'Another Topic' 300 | 301 | ### Pager 302 | 303 | If set to `auto`, howzit will look for pagers in this order, using the first one it finds available: 304 | 305 | - $GIT_PAGER 306 | - $PAGER 307 | - bat 308 | - less 309 | - more 310 | - cat 311 | - pager 312 | 313 | If you're defining your own, make sure to include any flags necessary to handle the output. If you're using howzit's coloring, for example, you need to specify any options needed to display ANSI escape sequences (e.g. `less -r`). 314 | 315 | ### Highlighter 316 | 317 | If set to `auto` howzit will look for markdown highlighters in this order, using the first it finds available: 318 | 319 | - mdcat 320 | - mdless 321 | 322 | If you're combining a highlighter with howzit's pagination, include any flags needed to disable the highlighter's pagination (e.g. `mdless --no-pager`). 323 | 324 | ## Shell Integration 325 | 326 | I personally like to alias `bld` to `howzit -r`. If you define a function in your shell, you can have it default to "build" but accept an alternate argument. There's an example for Fish included, and in Bash it would be as simple as `howzit -r ${1:build}`. 327 | 328 | For completion you can use `howzit -L` to list all topics, and `howzit -T` to list all "runnable" topics (topics containing an @directive or run block). Completion examples for Fish are included in the `fish` directory. 329 | 330 | ## Similar Projects 331 | 332 | - [mask](https://github.com/jakedeichert/mask/) 333 | - [maid](https://github.com/egoist/maid) 334 | - [saku](https://github.com/kt3k/saku) 335 | 336 | There are a few projects that tackle the same concept (a Markdown makefile). Most of them are superior task runners, so if you're looking for a `make` replacement, I recommend exploring the links above. What I like about `howzit` (and what keeps me from switching) is that it's documentation-first, and that I can display the description for each topic on the command line. The others also don't have options for listing topics or runnable tasks, so I can't use completion (or my cool script that adds available tasks to my Macbook Pro Touch Bar...). But no, I don't think `howzit` is as good an overall task runner as `mask` or `maid`. 337 | 338 | ## Roadmap 339 | 340 | - Recognize header hierarchy, allow showing/running all sub-topics 341 | 342 | ## Author 343 | 344 | **Brett Terpstra** - [brettterpstra.com](https://brettterpstra.com) 345 | 346 | ## License 347 | 348 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 349 | 350 | 351 | 352 | -------------------------------------------------------------------------------- /howzit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby -W1 2 | 3 | #------------------------------------------\ 4 | # _ _ _ ___ | 5 | # | |_ _____ __ ____(_) |_ |__ \ | 6 | # | ' \/ _ \ V V /_ / | _| _ _ _ /_/ | 7 | # |_||_\___/\_/\_//__|_|\__| (_|_|_) (_) | 8 | #-------/ 9 | 10 | VERSION = '1.2.3' 11 | 12 | require 'optparse' 13 | require 'shellwords' 14 | require 'pathname' 15 | require 'readline' 16 | require 'tempfile' 17 | require 'yaml' 18 | 19 | CONFIG_DIR = '~/.config/howzit' 20 | CONFIG_FILE = 'howzit.yaml' 21 | IGNORE_FILE = 'ignore.yaml' 22 | MATCHING_OPTIONS = %w[partial exact fuzzy beginswith].freeze 23 | 24 | module BuildNotes 25 | # String Extensions 26 | module StringUtils 27 | # Just strip out color codes when requested 28 | def uncolor 29 | gsub(/\e\[[\d;]+m/, '') 30 | end 31 | 32 | # Adapted from https://github.com/pazdera/word_wrap/, 33 | # copyright (c) 2014, 2015 Radek Pazdera 34 | # Distributed under the MIT License 35 | def wrap(width) 36 | width ||= 80 37 | output = [] 38 | indent = '' 39 | 40 | text = gsub(/\t/, ' ') 41 | 42 | text.lines do |line| 43 | line.chomp! "\n" 44 | if line.length > width 45 | indent = if line.uncolor =~ /^(\s*(?:[+\-*]|\d+\.) )/ 46 | ' ' * Regexp.last_match[1].length 47 | else 48 | '' 49 | end 50 | new_lines = line.split_line(width) 51 | 52 | while new_lines.length > 1 && new_lines[1].length + indent.length > width 53 | output.push new_lines[0] 54 | 55 | new_lines = new_lines[1].split_line(width, indent) 56 | end 57 | output += [new_lines[0], indent + new_lines[1]] 58 | else 59 | output.push line 60 | end 61 | end 62 | output.map!(&:rstrip) 63 | output.join("\n") 64 | end 65 | 66 | def wrap!(width) 67 | replace(wrap(width)) 68 | end 69 | 70 | # Truncate string to nearest word 71 | # @param len max length of string 72 | def trunc(len) 73 | split(/ /).each_with_object('') do |x, ob| 74 | break ob unless ob.length + ' '.length + x.length <= len 75 | 76 | ob << (" #{x}") 77 | end.strip 78 | end 79 | 80 | def trunc!(len) 81 | replace trunc(len) 82 | end 83 | 84 | def split_line(width, indent = '') 85 | line = dup 86 | at = line.index(/\s/) 87 | last_at = at 88 | 89 | while !at.nil? && at < width 90 | last_at = at 91 | at = line.index(/\s/, last_at + 1) 92 | end 93 | 94 | if last_at.nil? 95 | [indent + line[0, width], line[width, line.length]] 96 | else 97 | [indent + line[0, last_at], line[last_at + 1, line.length]] 98 | end 99 | end 100 | 101 | def available? 102 | if File.exist?(File.expand_path(self)) 103 | File.executable?(File.expand_path(self)) 104 | else 105 | system "which #{self}", out: File::NULL 106 | end 107 | end 108 | 109 | def render_template(vars) 110 | content = dup 111 | vars.each do |k, v| 112 | content.gsub!(/\[%#{k}(:.*?)?\]/, v) 113 | end 114 | 115 | content.gsub(/\[%(.*?):(.*?)\]/, '\2') 116 | end 117 | 118 | def render_template!(vars) 119 | replace render_template(vars) 120 | end 121 | 122 | def extract_metadata 123 | if File.exist?(self) 124 | leader = IO.read(self).split(/^#/)[0].strip 125 | leader.length > 0 ? leader.get_metadata : {} 126 | else 127 | {} 128 | end 129 | end 130 | 131 | def get_metadata 132 | data = {} 133 | scan(/(?mi)^(\S[\s\S]+?): ([\s\S]*?)(?=\n\S[\s\S]*?:|\Z)/).each do |m| 134 | data[m[0].strip.downcase] = m[1] 135 | end 136 | normalize_metadata(data) 137 | end 138 | 139 | def normalize_metadata(meta) 140 | data = {} 141 | meta.each do |k, v| 142 | case k 143 | when /^templ\w+$/ 144 | data['template'] = v 145 | when /^req\w+$/ 146 | data['required'] = v 147 | else 148 | data[k] = v 149 | end 150 | end 151 | data 152 | end 153 | 154 | end 155 | end 156 | 157 | class ::String 158 | include BuildNotes::StringUtils 159 | end 160 | 161 | module BuildNotes 162 | # Main Class 163 | class NoteReader 164 | attr_accessor :arguments, :metadata 165 | 166 | def topics 167 | @topics ||= read_help 168 | end 169 | 170 | # If either mdless or mdcat are installed, use that for highlighting 171 | # markdown 172 | def which_highlighter 173 | if @options[:highlighter] =~ /auto/i 174 | highlighters = %w[mdcat mdless] 175 | highlighters.delete_if(&:nil?).select!(&:available?) 176 | return nil if highlighters.empty? 177 | 178 | hl = highlighters.first 179 | args = case hl 180 | when 'mdless' 181 | '--no-pager' 182 | end 183 | 184 | [hl, args].join(' ') 185 | else 186 | hl = @options[:highlighter].split(/ /)[0] 187 | if hl.available? 188 | @options[:highlighter] 189 | else 190 | warn 'Specified highlighter not found, switching to auto' if @options[:log_level] < 2 191 | @options[:highlighter] = 'auto' 192 | which_highlighter 193 | end 194 | end 195 | end 196 | 197 | # When pagination is enabled, find the best (in my opinion) option, 198 | # favoring environment settings 199 | def which_pager 200 | if @options[:pager] =~ /auto/i 201 | pagers = [ENV['GIT_PAGER'], ENV['PAGER'], 202 | 'bat', 'less', 'more', 'cat', 'pager'] 203 | pagers.delete_if(&:nil?).select!(&:available?) 204 | return nil if pagers.empty? 205 | 206 | pg = pagers.first 207 | args = case pg 208 | when 'delta' 209 | '--pager="less -FXr"' 210 | when /^(less|more)$/ 211 | '-FXr' 212 | when 'bat' 213 | if @options[:highlight] 214 | '--language Markdown --style plain --pager="less -FXr"' 215 | else 216 | '--style plain --pager="less -FXr"' 217 | end 218 | else 219 | '' 220 | end 221 | 222 | [pg, args].join(' ') 223 | else 224 | pg = @options[:pager].split(/ /)[0] 225 | if pg.available? 226 | @options[:pager] 227 | else 228 | warn 'Specified pager not found, switching to auto' if @options[:log_level] < 2 229 | @options[:pager] = 'auto' 230 | which_pager 231 | end 232 | end 233 | end 234 | 235 | # Paginate the output 236 | def page(text) 237 | read_io, write_io = IO.pipe 238 | 239 | input = $stdin 240 | 241 | pid = Kernel.fork do 242 | write_io.close 243 | input.reopen(read_io) 244 | read_io.close 245 | 246 | # Wait until we have input before we start the pager 247 | IO.select [input] 248 | 249 | pager = which_pager 250 | begin 251 | exec(pager) 252 | rescue SystemCallError => e 253 | @log.error(e) 254 | exit 1 255 | end 256 | end 257 | 258 | read_io.close 259 | write_io.write(text) 260 | write_io.close 261 | 262 | _, status = Process.waitpid2(pid) 263 | status.success? 264 | end 265 | 266 | # print output to terminal 267 | def show(string, opts = {}) 268 | options = { 269 | color: true, 270 | highlight: false, 271 | paginate: false, 272 | wrap: 0 273 | } 274 | 275 | options.merge!(opts) 276 | 277 | string = string.uncolor unless options[:color] 278 | 279 | pipes = '' 280 | if options[:highlight] 281 | hl = which_highlighter 282 | pipes = "|#{hl}" if hl 283 | end 284 | 285 | output = `echo #{Shellwords.escape(string.strip)}#{pipes}` 286 | 287 | if options[:paginate] 288 | page(output) 289 | else 290 | output.gsub!(/^╌/, '\e]1337;SetMark\a╌') if ENV['TERM_PROGRAM'] =~ /^iTerm/ && !options[:run] 291 | puts output 292 | end 293 | end 294 | 295 | def color_single_options(choices = %w[y n]) 296 | out = [] 297 | choices.each do |choice| 298 | case choice 299 | when /[A-Z]/ 300 | out.push("\e[1;32m#{choice}\e[0;32m") 301 | else 302 | out.push(choice) 303 | end 304 | end 305 | "\e[0;32m[#{out.join('/')}]\e[0m" 306 | end 307 | 308 | # Create a buildnotes skeleton 309 | def create_note 310 | trap('SIGINT') do 311 | warn "\nCanceled" 312 | exit! 313 | end 314 | # First make sure there isn't already a buildnotes file 315 | if note_file 316 | system 'stty cbreak' 317 | fname = "\e[1;33m#{note_file}\e[1;37m" 318 | yn = color_single_options(%w[y N]) 319 | $stdout.syswrite "#{fname} exists and appears to be a build note, continue anyway #{yn}\e[1;37m? \e[0m" 320 | res = $stdin.sysread 1 321 | res.chomp! 322 | puts 323 | system 'stty cooked' 324 | 325 | unless res =~ /y/i 326 | puts 'Canceled' 327 | Process.exit 0 328 | end 329 | end 330 | 331 | title = File.basename(Dir.pwd) 332 | printf "\e[1;37mProject name \e[0;32m[#{title}]\e[1;37m: \e[0m" 333 | input = STDIN.gets.chomp 334 | title = input unless input.empty? 335 | 336 | summary = '' 337 | printf "\e[1;37mProject summary: \e[0m" 338 | input = STDIN.gets.chomp 339 | summary = input unless input.empty? 340 | 341 | filename = 'buildnotes.md' 342 | printf "\e[1;37mBuild notes filename (must begin with 'howzit' or 'build')\n\e[0;32m[#{filename}]\e[1;37m: \e[0m" 343 | input = STDIN.gets.chomp 344 | filename = input unless input.empty? 345 | 346 | note = <<~EOBUILDNOTES 347 | # #{title} 348 | 349 | #{summary} 350 | 351 | ## File Structure 352 | 353 | Where are the main editable files? Is there a dist/build folder that should be ignored? 354 | 355 | ## Build 356 | 357 | What build system/parameters does this use? 358 | 359 | @run(./build command) 360 | 361 | ## Deploy 362 | 363 | What are the procedures/commands to deploy this project? 364 | 365 | ## Other 366 | 367 | Version control notes, additional gulp/rake/make/etc tasks... 368 | 369 | EOBUILDNOTES 370 | 371 | if File.exist?(filename) 372 | system 'stty cbreak' 373 | yn = color_single_options(%w[y N]) 374 | file = "\e[1;33m#{filename}" 375 | $stdout.syswrite "\e[1;37mAre you absolutely sure you want to overwrite #{file} #{yn}\e[1;37m? \e[0m" 376 | res = $stdin.sysread 1 377 | res.chomp! 378 | puts 379 | system 'stty cooked' 380 | 381 | unless res =~ /y/i 382 | puts 'Canceled' 383 | Process.exit 0 384 | end 385 | end 386 | 387 | File.open(filename, 'w') do |f| 388 | f.puts note 389 | puts "Build notes for #{title} written to #{filename}" 390 | end 391 | end 392 | 393 | # Make a fancy title line for the topic 394 | def format_header(title, opts = {}) 395 | options = { 396 | hr: "\u{254C}", 397 | color: '1;32', 398 | border: '0' 399 | } 400 | 401 | options.merge!(opts) 402 | 403 | cols = `tput cols`.strip.to_i 404 | cols = @options[:wrap] if (@options[:wrap]).positive? && cols > @options[:wrap] 405 | title = "\e[#{options[:border]}m#{options[:hr]}#{options[:hr]}( \e[#{options[:color]}m#{title}\e[#{options[:border]}m )" 406 | tail = options[:hr] * (cols - title.uncolor.length) 407 | "#{title}#{tail}\e[0m" 408 | end 409 | 410 | def os_open(command) 411 | os = RbConfig::CONFIG['target_os'] 412 | out = "\e[1;32mOpening \e[3;37m#{command}" 413 | case os 414 | when /darwin.*/i 415 | warn "#{out} (macOS)\e[0m" if @options[:log_level] < 2 416 | `open #{Shellwords.escape(command)}` 417 | when /mingw|mswin/i 418 | warn "#{out} (Windows)\e[0m" if @options[:log_level] < 2 419 | `start #{Shellwords.escape(command)}` 420 | else 421 | if 'xdg-open'.available? 422 | warn "#{out} (Linux)\e[0m" if @options[:log_level] < 2 423 | `xdg-open #{Shellwords.escape(command)}` 424 | else 425 | warn out if @options[:log_level] < 2 426 | warn 'Unable to determine executable for `open`.' 427 | end 428 | end 429 | end 430 | 431 | def grep_topics(pat) 432 | matching_topics = [] 433 | topics.each do |topic, content| 434 | if content =~ /#{pat}/i || topic =~ /#{pat}/i 435 | matching_topics.push(topic) 436 | end 437 | end 438 | matching_topics 439 | end 440 | 441 | # Handle run command, execute directives 442 | def run_topic(key) 443 | output = [] 444 | tasks = 0 445 | if topics[key] =~ /(@(include|run|copy|open|url)\((.*?)\)|`{3,}run)/i 446 | directives = topics[key].scan(/(?:@(include|run|copy|open|url)\((.*?)\)|(`{3,})run(?: +([^\n]+))?(.*?)\3)/mi) 447 | 448 | tasks += directives.length 449 | directives.each do |c| 450 | if c[0].nil? 451 | title = c[3] ? c[3].strip : '' 452 | warn "\e[1;32mRunning block \e[3;37m#{title}\e[0m" if @options[:log_level] < 2 453 | block = c[4].strip 454 | script = Tempfile.new('howzit_script') 455 | begin 456 | script.write(block) 457 | script.close 458 | File.chmod(0777, script.path) 459 | system(%(/bin/sh -c "#{script.path}")) 460 | ensure 461 | script.close 462 | script.unlink 463 | end 464 | else 465 | cmd = c[0] 466 | obj = c[1] 467 | case cmd 468 | when /include/i 469 | matches = match_topic(obj) 470 | if matches.empty? 471 | warn "No topic match for @include(#{search})" 472 | else 473 | if @included.include?(matches[0]) 474 | warn "\e[1;33mTasks from \e[3;37m#{matches[0]} already included, skipping\e[0m" if @options[:log_level] < 2 475 | else 476 | warn "\e[1;33mIncluding tasks from \e[3;37m#{matches[0]}\e[0m" if @options[:log_level] < 2 477 | process_topic(matches[0], true) 478 | warn "\e[1;33mEnd include \e[3;37m#{matches[0]}\e[0m" if @options[:log_level] < 2 479 | end 480 | end 481 | when /run/i 482 | warn "\e[1;32mRunning \e[3;37m#{obj}\e[0m" if @options[:log_level] < 2 483 | system(obj) 484 | when /copy/i 485 | warn "\e[1;32mCopied \e[3;37m#{obj}\e[1;32m to clipboard\e[0m" if @options[:log_level] < 2 486 | `echo #{Shellwords.escape(obj)}'\\c'|pbcopy` 487 | when /open|url/i 488 | os_open(obj) 489 | end 490 | end 491 | end 492 | else 493 | warn "\e[0;31m--run: No \e[1;31m@directive\e[0;31;40m found in \e[1;37m#{key}\e[0m" 494 | end 495 | output.push("Ran #{tasks} #{tasks == 1 ? 'task' : 'tasks'}") if @options[:log_level] < 2 496 | end 497 | 498 | # Output a topic with fancy title and bright white text. 499 | def output_topic(key, options = {}) 500 | defaults = { single: false, header: true } 501 | opt = defaults.merge(options) 502 | 503 | output = [] 504 | if opt[:header] 505 | output.push(format_header(key)) 506 | output.push('') 507 | end 508 | topic = topics[key].strip 509 | topic.gsub!(/(?mi)^(`{3,})run *([^\n]*)[\s\S]*?\n\1\s*$/, '@@@run \2') unless @options[:show_all_code] 510 | topic.split(/\n/).each do |l| 511 | case l 512 | when /@include\((.*?)\)/ 513 | 514 | m = Regexp.last_match 515 | matches = match_topic(m[1]) 516 | unless matches.empty? 517 | if opt[:single] 518 | title = "From #{matches[0]}:" 519 | color = '33;40' 520 | rule = '30;40' 521 | else 522 | title = "Include #{matches[0]}" 523 | color = '33;40' 524 | rule = '0' 525 | end 526 | output.push(format_header("#{'> ' * @nest_level}#{title}", { color: color, hr: '.', border: rule })) unless @included.include?(matches[0]) 527 | 528 | if opt[:single] 529 | if @included.include?(matches[0]) 530 | output.push(format_header("#{'> ' * @nest_level}#{title} included above", { color: color, hr: '.', border: rule })) 531 | else 532 | @nest_level += 1 533 | output.concat(output_topic(matches[0], {single: true, header: false})) 534 | @nest_level -= 1 535 | end 536 | output.push(format_header("#{'> ' * @nest_level}...", { color: color, hr: '.', border: rule })) unless @included.include?(matches[0]) 537 | end 538 | @included.push(matches[0]) 539 | end 540 | 541 | when /@(run|copy|open|url|include)\((.*?)\)/ 542 | m = Regexp.last_match 543 | cmd = m[1] 544 | obj = m[2] 545 | icon = case cmd 546 | when 'run' 547 | "\u{25B6}" 548 | when 'copy' 549 | "\u{271A}" 550 | when /open|url/ 551 | "\u{279A}" 552 | end 553 | output.push("\e[1;35;40m#{icon} \e[3;37;40m#{obj}\e[0m") 554 | when /(`{3,})run *(.*?)$/i 555 | m = Regexp.last_match 556 | desc = m[2].length.positive? ? "Block: #{m[2]}" : 'Code Block' 557 | output.push("\e[1;35;40m\u{25B6} \e[3;37;40m#{desc}\e[0m\n```") 558 | when /@@@run *(.*?)$/i 559 | m = Regexp.last_match 560 | desc = m[1].length.positive? ? "Block: #{m[1]}" : 'Code Block' 561 | output.push("\e[1;35;40m\u{25B6} \e[3;37;40m#{desc}\e[0m") 562 | else 563 | l.wrap!(@options[:wrap]) if (@options[:wrap]).positive? 564 | output.push(l) 565 | end 566 | end 567 | output.push('') 568 | end 569 | 570 | def process_topic(key, run, single = false) 571 | # Handle variable replacement 572 | content = topics[key] 573 | unless @arguments.empty? 574 | content.gsub!(/\$(\d+)/) do |m| 575 | idx = m[1].to_i - 1 576 | @arguments.length > idx ? @arguments[idx] : m 577 | end 578 | content.gsub!(/\$[@*]/, Shellwords.join(@arguments)) 579 | end 580 | 581 | output = if run 582 | run_topic(key) 583 | else 584 | output_topic(key, {single: single}) 585 | end 586 | output.nil? ? '' : output.join("\n") 587 | end 588 | 589 | # Output a list of topic titles 590 | def list_topics 591 | output = [] 592 | output.push("\e[1;32mTopics:\e[0m\n") 593 | topics.each_key do |title| 594 | output.push("- \e[1;37m#{title}\e[0m") 595 | end 596 | output.join("\n") 597 | end 598 | 599 | # Output a list of topic titles for shell completion 600 | def list_topic_titles 601 | topics.keys.join("\n") 602 | end 603 | 604 | def get_note_title(filename, truncate = 0) 605 | title = nil 606 | help = IO.read(filename).strip 607 | title = help.match(/(?:^(\S.*?)(?=\n==)|^# ?(.*?)$)/) 608 | title = if title 609 | title[1].nil? ? title[2] : title[1] 610 | else 611 | filename.sub(/(\.\w+)?$/, '') 612 | end 613 | 614 | title && truncate.positive? ? title.trunc(truncate) : title 615 | end 616 | 617 | def list_runnable_titles 618 | output = [] 619 | topics.each do |title, sect| 620 | runnable = false 621 | sect.split(/\n/).each do |l| 622 | if l =~ /(@(run|copy|open|url)\((.*?)\)|`{3,}run)/ 623 | runnable = true 624 | break 625 | end 626 | end 627 | output.push(title) if runnable 628 | end 629 | output.join("\n") 630 | end 631 | 632 | def list_runnable 633 | output = [] 634 | output.push(%(\e[1;32m"Runnable" Topics:\e[0m\n)) 635 | topics.each do |title, sect| 636 | s_out = [] 637 | lines = sect.split(/\n/) 638 | lines.each do |l| 639 | case l 640 | when /@run\((.*?)\)(.*)?/ 641 | m = Regexp.last_match 642 | run = m[2].strip.length.positive? ? m[2].strip : m[1] 643 | s_out.push(" * run: #{run.gsub(/\\n/, '\​n')}") 644 | when /@(copy|open|url)\((.*?)\)/ 645 | m = Regexp.last_match 646 | s_out.push(" * #{m[1]}: #{m[2]}") 647 | when /`{3,}run(.*)?/m 648 | run = ' * run code block' 649 | title = Regexp.last_match(1).strip 650 | run += " (#{title})" if title.length.positive? 651 | s_out.push(run) 652 | end 653 | end 654 | unless s_out.empty? 655 | output.push("- \e[1;37m#{title}\e[0m") 656 | output.push(s_out.join("\n")) 657 | end 658 | end 659 | output.join("\n") 660 | end 661 | 662 | def read_upstream 663 | buildnotes = glob_upstream 664 | topics_dict = {} 665 | buildnotes.each do |path| 666 | topics_dict = topics_dict.merge(read_help_file(path)) 667 | end 668 | topics_dict 669 | end 670 | 671 | def ensure_requirements(template) 672 | t_leader = IO.read(template).split(/^#/)[0].strip 673 | if t_leader.length > 0 674 | t_meta = t_leader.get_metadata 675 | if t_meta.key?('required') 676 | required = t_meta['required'].strip.split(/\s*,\s*/) 677 | required.each do |req| 678 | unless @metadata.keys.include?(req.downcase) 679 | warn %(\e[0;31mERROR: Missing required metadata key from template '\e[1;37m#{File.basename(template, '.md')}\e[0;31m'\e[0m) 680 | warn %(\e[0;31mPlease define \e[1;33m#{req.downcase}\e[0;31m in build notes\e[0m) 681 | Process.exit 1 682 | end 683 | end 684 | end 685 | end 686 | end 687 | 688 | def get_template_topics(content) 689 | leader = content.split(/^#/)[0].strip 690 | 691 | template_topics = {} 692 | if leader.length > 0 693 | data = leader.get_metadata 694 | @metadata = @metadata.merge(data) 695 | 696 | if data.key?('template') 697 | templates = data['template'].strip.split(/\s*,\s*/) 698 | templates.each do |t| 699 | tasks = nil 700 | if t =~ /\[(.*?)\]$/ 701 | tasks = Regexp.last_match[1].split(/\s*,\s*/).map {|t| t.gsub(/\*/, '.*?')} 702 | t = t.sub(/\[.*?\]$/, '').strip 703 | end 704 | 705 | t_file = t.sub(/(\.md)?$/, '.md') 706 | template = File.join(template_folder, t_file) 707 | if File.exist?(template) 708 | ensure_requirements(template) 709 | 710 | t_topics = read_help_file(template) 711 | if tasks 712 | tasks.each do |task| 713 | t_topics.keys.each do |topic| 714 | if topic =~ /^(.*?:)?#{task}$/i 715 | template_topics[topic] = t_topics[topic] 716 | end 717 | end 718 | end 719 | else 720 | template_topics = template_topics.merge(t_topics) 721 | end 722 | end 723 | end 724 | end 725 | end 726 | template_topics 727 | end 728 | 729 | # Read in the build notes file and output a hash of "Title" => contents 730 | def read_help_file(path = nil) 731 | filename = path.nil? ? note_file : path 732 | topics_dict = {} 733 | help = IO.read(filename) 734 | 735 | help.gsub!(/@include\((.*?)\)/) do 736 | m = Regexp.last_match 737 | file = File.expand_path(m[1]) 738 | if File.exist?(file) 739 | content = IO.read(file) 740 | home = ENV['HOME'] 741 | short_path = File.dirname(file.sub(/^#{home}/, '~')) 742 | prefix = "#{short_path}:" 743 | parts = content.split(/^##+/) 744 | parts.shift 745 | content = '## ' + parts.join('## ') 746 | content.gsub!(/^(##+ *)(?=\S)/, "\\1#{prefix}") 747 | content 748 | else 749 | m[0] 750 | end 751 | end 752 | 753 | template_topics = get_template_topics(help) 754 | 755 | split = help.split(/^##+/) 756 | split.slice!(0) 757 | split.each do |sect| 758 | next if sect.strip.empty? 759 | 760 | lines = sect.split(/\n/) 761 | title = lines.slice!(0).strip 762 | prefix = '' 763 | if path 764 | if path =~ /#{template_folder}/ 765 | short_path = File.basename(path, '.md') 766 | else 767 | home = ENV['HOME'] 768 | short_path = File.dirname(path.sub(/^#{home}/, '~')) 769 | prefix = "_from #{short_path}_\n\n" 770 | end 771 | title = "#{short_path}:#{title}" 772 | end 773 | topics_dict[title] = prefix + lines.join("\n").strip.render_template(@metadata) 774 | end 775 | 776 | template_topics.each do |title, content| 777 | unless topics_dict.key?(title.sub(/^.+:/, '')) 778 | topics_dict[title] = content 779 | end 780 | end 781 | 782 | topics_dict 783 | end 784 | 785 | def read_help 786 | topics = read_help_file 787 | if @options[:include_upstream] 788 | upstream_topics = read_upstream 789 | upstream_topics.each do |topic, content| 790 | unless topics.key?(topic.sub(/^.*?:/, '')) 791 | topics[topic] = content 792 | end 793 | end 794 | # topics = upstream_topics.merge(topics) 795 | end 796 | topics 797 | end 798 | 799 | 800 | def match_topic(search) 801 | matches = [] 802 | 803 | rx = case @options[:matching] 804 | when 'exact' 805 | /^#{search}$/i 806 | when 'beginswith' 807 | /^#{search}/i 808 | when 'fuzzy' 809 | search = search.split(//).join('.*?') if @options[:matching] == 'fuzzy' 810 | /#{search}/i 811 | else 812 | /#{search}/i 813 | end 814 | 815 | topics.each_key do |k| 816 | matches.push(k) if k.downcase =~ rx 817 | end 818 | matches 819 | end 820 | 821 | def initialize(args) 822 | flags = { 823 | run: false, 824 | list_topics: false, 825 | list_topic_titles: false, 826 | list_runnable: false, 827 | list_runnable_titles: false, 828 | title_only: false, 829 | choose: false, 830 | quiet: false, 831 | verbose: false 832 | } 833 | 834 | defaults = { 835 | color: true, 836 | highlight: true, 837 | paginate: true, 838 | wrap: 0, 839 | output_title: false, 840 | highlighter: 'auto', 841 | pager: 'auto', 842 | matching: 'partial', # exact, partial, fuzzy, beginswith 843 | show_all_on_error: false, 844 | include_upstream: false, 845 | show_all_code: false, 846 | grep: nil, 847 | log_level: 1 # 0: debug, 1: info, 2: warn, 3: error 848 | } 849 | 850 | @metadata = {} 851 | @included = [] 852 | @nest_level = 0 853 | 854 | parts = Shellwords.shelljoin(args).split(/ -- /) 855 | args = parts[0] ? Shellwords.shellsplit(parts[0]) : [] 856 | @arguments = parts[1] ? Shellwords.shellsplit(parts[1]) : [] 857 | 858 | config = load_config(defaults) 859 | @options = flags.merge(config) 860 | 861 | OptionParser.new do |opts| 862 | opts.banner = "Usage: #{__FILE__} [OPTIONS] [TOPIC]" 863 | opts.separator '' 864 | opts.separator 'Show build notes for the current project (buildnotes.md). Include a topic name to see just that topic, or no argument to display all.' 865 | opts.separator '' 866 | opts.separator 'Options:' 867 | 868 | opts.on('-c', '--create', 'Create a skeleton build note in the current working directory') do 869 | create_note 870 | Process.exit 0 871 | end 872 | 873 | opts.on('-e', '--edit', "Edit buildnotes file in current working directory using #{File.basename(ENV['EDITOR'])}") do 874 | edit_note 875 | Process.exit 0 876 | end 877 | 878 | opts.on('--grep PATTERN', 'Display sections matching a search pattern') do |pat| 879 | @options[:grep] = pat 880 | end 881 | 882 | opts.on('-L', '--list-completions', 'List topics for completion') do 883 | @options[:list_topics] = true 884 | @options[:list_topic_titles] = true 885 | end 886 | 887 | opts.on('-l', '--list', 'List available topics') do 888 | @options[:list_topics] = true 889 | end 890 | 891 | opts.on('-m', '--matching TYPE', MATCHING_OPTIONS, 'Topics matching type', "(#{MATCHING_OPTIONS.join(', ')})") do |c| 892 | @options[:matching] = c 893 | end 894 | 895 | opts.on('-R', '--list-runnable', 'List topics containing @ directives (verbose)') do 896 | @options[:list_runnable] = true 897 | end 898 | 899 | opts.on('-r', '--run', 'Execute @run, @open, and/or @copy commands for given topic') do 900 | @options[:run] = true 901 | end 902 | 903 | opts.on('-s', '--select', 'Select topic from menu') do 904 | @options[:choose] = true 905 | end 906 | 907 | opts.on('-T', '--task-list', 'List topics containing @ directives (completion-compatible)') do 908 | @options[:list_runnable] = true 909 | @options[:list_runnable_titles] = true 910 | end 911 | 912 | opts.on('-t', '--title', 'Output title with build notes') do 913 | @options[:output_title] = true 914 | end 915 | 916 | opts.on('-q', '--quiet', 'Silence info message') do 917 | @options[:log_level] = 3 918 | end 919 | 920 | opts.on('-v', '--verbose', 'Show all messages') do 921 | @options[:log_level] = 0 922 | end 923 | 924 | opts.on('-u', '--upstream', 'Traverse up parent directories for additional build notes') do 925 | @options[:include_upstream] = true 926 | end 927 | 928 | opts.on('--show-code', 'Display the content of fenced run blocks') do 929 | @options[:show_all_code] = true 930 | end 931 | 932 | opts.on('-w', '--wrap COLUMNS', 'Wrap to specified width (default 80, 0 to disable)') do |w| 933 | @options[:wrap] = w.to_i 934 | end 935 | 936 | opts.on('--edit-config', "Edit configuration file using #{File.basename(ENV['EDITOR'])}") do 937 | edit_config(defaults) 938 | Process.exit 0 939 | end 940 | 941 | opts.on('--title-only', 'Output title only') do 942 | @options[:output_title] = true 943 | @options[:title_only] = true 944 | end 945 | 946 | opts.on('--templates', 'List available templates') do 947 | Dir.chdir(template_folder) 948 | Dir.glob('*.md').each do |file| 949 | template = File.basename(file, '.md') 950 | puts "\e[7;30;45mtemplate: \e[7;33;40m#{template}\e[0m" 951 | puts "\e[1;30m[\e[1;37mtasks\e[1;30m]──────────────────────────────────────┐\e[0m" 952 | metadata = file.extract_metadata 953 | topics = read_help_file(file) 954 | topics.keys.each do |topic| 955 | puts " \e[1;30m│\e[1;37m-\e[0m \e[1;36;40m#{template}:#{topic.sub(/^.*?:/, '')}\e[0m" 956 | end 957 | if metadata.size > 0 958 | meta = [] 959 | meta << metadata['required'].split(/\s*,\s*/).map {|m| "*\e[1;37m#{m}\e[0;37m" } if metadata.key?('required') 960 | meta << metadata['optional'].split(/\s*,\s*/).map {|m| "#{m}" } if metadata.key?('optional') 961 | puts "\e[1;30m[\e[1;34mmeta\e[1;30m]───────────────────────────────────────┤\e[0m" 962 | puts " \e[1;30m│\e[1;37m \e[0;37m#{meta.join(", ")}\e[0m" 963 | end 964 | puts " \e[1;30m└───────────────────────────────────────────┘\e[0m" 965 | end 966 | Process.exit 0 967 | end 968 | 969 | opts.on('--[no-]color', 'Colorize output (default on)') do |c| 970 | @options[:color] = c 971 | @options[:highlight] = false unless c 972 | end 973 | 974 | opts.on('--[no-]md-highlight', 'Highlight Markdown syntax (default on), requires mdless or mdcat') do |m| 975 | @options[:highlight] = @options[:color] ? m : false 976 | end 977 | 978 | opts.on('--[no-]pager', 'Paginate output (default on)') do |p| 979 | @options[:paginate] = p 980 | end 981 | 982 | opts.on('-h', '--help', 'Display this screen') do 983 | puts opts 984 | Process.exit 0 985 | end 986 | 987 | opts.on('-v', '--version', 'Display version number') do 988 | puts "Howzit v#{VERSION}" 989 | Process.exit 0 990 | end 991 | end.parse!(args) 992 | 993 | process(args) 994 | end 995 | 996 | def edit_note 997 | raise 'No EDITOR variable defined in environment' if ENV['EDITOR'].nil? 998 | 999 | if note_file.nil? 1000 | system 'stty cbreak' 1001 | yn = color_single_options(%w[Y n]) 1002 | $stdout.syswrite "No build notes file found, create one #{yn}? " 1003 | res = $stdin.sysread 1 1004 | puts 1005 | system 'stty cooked' 1006 | 1007 | create_note if res.chomp =~ /^y?$/i 1008 | edit_note 1009 | else 1010 | `#{ENV['EDITOR']} "#{note_file}"` 1011 | end 1012 | end 1013 | 1014 | ## 1015 | ## @brief Traverse up directory tree looking for build notes 1016 | ## 1017 | ## @return topics dictionary 1018 | ## 1019 | def glob_upstream 1020 | home = Dir.pwd 1021 | dir = File.dirname(home) 1022 | buildnotes = [] 1023 | filename = nil 1024 | 1025 | while dir != '/' && (dir =~ %r{[A-Z]:/}).nil? 1026 | Dir.chdir(dir) 1027 | filename = glob_note 1028 | unless filename.nil? 1029 | note = File.join(dir, filename) 1030 | buildnotes.push(note) unless note == note_file 1031 | end 1032 | dir = File.dirname(dir) 1033 | end 1034 | 1035 | Dir.chdir(home) 1036 | 1037 | buildnotes.reverse 1038 | end 1039 | 1040 | def is_build_notes(filename) 1041 | return false if filename.downcase !~ /(^howzit[^.]*|build[^.]+)/ 1042 | return false if should_ignore(filename) 1043 | true 1044 | end 1045 | 1046 | def should_ignore(filename) 1047 | return false unless File.exist?(ignore_file) 1048 | 1049 | unless @ignore_patterns 1050 | @ignore_patterns = YAML.load(IO.read(ignore_file)) 1051 | end 1052 | 1053 | ignore = false 1054 | 1055 | @ignore_patterns.each do |pat| 1056 | if filename =~ /#{pat}/ 1057 | ignore = true 1058 | break 1059 | end 1060 | end 1061 | 1062 | ignore 1063 | end 1064 | 1065 | def glob_note 1066 | filename = nil 1067 | # Check for a build note file in the current folder. Filename must start 1068 | # with "build" and have an extension of txt, md, or markdown. 1069 | 1070 | Dir.glob('*.{txt,md,markdown}').each do |f| 1071 | if is_build_notes(f) 1072 | filename = f 1073 | break 1074 | end 1075 | end 1076 | filename 1077 | end 1078 | 1079 | def note_file 1080 | @note_file ||= find_note_file 1081 | end 1082 | 1083 | def find_note_file 1084 | filename = glob_note 1085 | 1086 | if filename.nil? && 'git'.available? 1087 | proj_dir = `git rev-parse --show-toplevel 2>/dev/null`.strip 1088 | unless proj_dir == '' 1089 | Dir.chdir(proj_dir) 1090 | filename = glob_note 1091 | end 1092 | end 1093 | 1094 | if filename.nil? && @options[:include_upstream] 1095 | upstream_notes = glob_upstream 1096 | filename = upstream_notes[-1] unless upstream_notes.empty? 1097 | end 1098 | 1099 | return nil if filename.nil? 1100 | 1101 | File.expand_path(filename) 1102 | end 1103 | 1104 | def options_list(matches) 1105 | counter = 1 1106 | puts 1107 | matches.each do |match| 1108 | printf("%2d ) %