├── 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 ) %