From 488a8e59a0876cec3e67d9fa2483dceedaec121e Mon Sep 17 00:00:00 2001 From: Peter Melnichenko Date: Sun, 8 Apr 2018 19:03:25 +0300 Subject: [PATCH] Add `help_max_width` property for help description autowrapping. --- CHANGELOG.md | 2 + spec/help_spec.lua | 98 ++++++++++++++++++++++++++++++++++++++++++++++ src/argparse.lua | 77 ++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5adf7c..67f4bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ usage string autogeneration. * Added `help_usage_margin` and `help_description_margin` properties for configuring help string autogeneration. +* Added `help_max_width` property. If set, descriptions in help string + are automatically wrapped to fit into given number of columns. ### Improvements diff --git a/spec/help_spec.lua b/spec/help_spec.lua index 4a268c6..aadbabd 100644 --- a/spec/help_spec.lua +++ b/spec/help_spec.lua @@ -496,4 +496,102 @@ Options: That needs documenting. -h, --help Show this help message and exit.]], parser:get_help()) end) + + describe("autowrap", function() + it("automatically wraps descriptions to match given max width", function() + local parser = Parser "foo" + :help_max_width(80) + + parser:option "-f --foo" + :description("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor " .. + "incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation " .. + "ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit " .. + "in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat " .. + "non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") + parser:option "-b --bar" + :description "See above." + + assert.equal([[ +Usage: foo [-f ] [-b ] [-h] + +Options: + -f , Lorem ipsum dolor sit amet, consectetur adipiscing + --foo elit, sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. Ut enim ad minim veniam, quis + nostrud exercitation ullamco laboris nisi ut aliquip ex + ea commodo consequat. Duis aute irure dolor in + reprehenderit in voluptate velit esse cillum dolore eu + fugiat nulla pariatur. Excepteur sint occaecat + cupidatat non proident, sunt in culpa qui officia + deserunt mollit anim id est laborum. + -b , See above. + --bar + -h, --help Show this help message and exit.]], parser:get_help()) + end) + + it("preserves existing line breaks", function() + local parser = Parser "foo" + :help_max_width(80) + + parser:option "-f --foo" + :description("This is a long line, it should be broken down into several lines. " .. [[ +It just keeps going and going. +This should always be a new line. +Another one. +]]) + parser:option "-b --bar" + + assert.equal([[ +Usage: foo [-f ] [-b ] [-h] + +Options: + -f , This is a long line, it should be broken down into + --foo several lines. It just keeps going and going. + This should always be a new line. + Another one. + -b , + --bar + -h, --help Show this help message and exit.]], parser:get_help()) + end) + + it("preserves indentation", function() + local parser = Parser "foo" + :help_max_width(80) + + parser:option "-f --foo" + :description("This is a long line, it should be broken down into several lines.\n" .. + " This paragraph is indented with three spaces, so when it gets broken down into several lines, " .. + "they will be, too.\n\n" .. + " That was an empty line there, preserve it.") + + assert.equal([[ +Usage: foo [-f ] [-h] + +Options: + -f , This is a long line, it should be broken down into + --foo several lines. + This paragraph is indented with three spaces, so + when it gets broken down into several lines, they + will be, too. + + That was an empty line there, preserve it. + -h, --help Show this help message and exit.]], parser:get_help()) + end) + + it("preserves multiple spaces between words", function() + local parser = Parser "foo" + :help_max_width(80) + + parser:option "-f --foo" + :description("This is a long line with two spaces between words, it should be broken down.") + + assert.equal([[ +Usage: foo [-f ] [-h] + +Options: + -f , This is a long line with two spaces between + --foo words, it should be broken down. + -h, --help Show this help message and exit.]], parser:get_help()) + end) + end) end) diff --git a/src/argparse.lua b/src/argparse.lua index 36d3887..4643fc4 100644 --- a/src/argparse.lua +++ b/src/argparse.lua @@ -251,6 +251,7 @@ local Parser = class({ typechecked("usage_max_width", "number"), typechecked("help_usage_margin", "number"), typechecked("help_description_margin", "number"), + typechecked("help_max_width", "number"), add_help }) @@ -273,6 +274,7 @@ local Command = class({ typechecked("usage_max_width", "number"), typechecked("help_usage_margin", "number"), typechecked("help_description_margin", "number"), + typechecked("help_max_width", "number"), typechecked("hidden", "boolean"), add_help }, Parser) @@ -800,6 +802,75 @@ local function split_lines(s) return lines end +local function autowrap_line(line, max_length) + -- Algorithm for splitting lines is simple and greedy. + local result_lines = {} + + -- Preserve original indentation of the line, put this at the beginning of each result line. + local indentation = line:match("^( *)") + + -- Parts of the last line being assembled. + local line_parts = {} + + -- Length of the current line. + local line_length = 0 + + -- Index of the next character to consider. + local index = 1 + + while true do + local word_start, word_finish, word = line:find("([^ ]+)", index) + + if not word_start then + -- Ignore trailing spaces, if any. + break + end + + local preceding_spaces = line:sub(index, word_start - 1) + index = word_finish + 1 + + if (#line_parts == 0) or (line_length + #preceding_spaces + #word <= max_length) then + -- Either this is the very first word or it fits as an addition to the current line, add it. + table.insert(line_parts, preceding_spaces) -- For the very first word this adds the indentation. + table.insert(line_parts, word) + line_length = line_length + #preceding_spaces + #word + else + -- Does not fit, finish current line and put the word into a new one. + table.insert(result_lines, table.concat(line_parts)) + line_parts = {indentation, word} + line_length = #indentation + #word + end + end + + if #line_parts > 0 then + table.insert(result_lines, table.concat(line_parts)) + end + + if #result_lines == 0 then + -- Preserve empty lines. + result_lines[1] = "" + end + + return result_lines +end + +-- Automatically wraps lines within given array, +-- attempting to limit line length to `max_length`. +-- Existing line splits are preserved. +local function autowrap(lines, max_length) + local result_lines = {} + + for _, line in ipairs(lines) do + local autowrapped_lines = autowrap_line(line, max_length) + + for _, autowrapped_line in ipairs(autowrapped_lines) do + table.insert(result_lines, autowrapped_line) + end + end + + return result_lines +end + function Parser:_get_element_help(element) local label_lines = element:_get_label_lines() local description_lines = split_lines(element:_get_description()) @@ -815,6 +886,12 @@ function Parser:_get_element_help(element) local description_margin_len = self:_inherit_property("help_description_margin", 25) local description_margin = (" "):rep(description_margin_len) + local help_max_width = self:_inherit_property("help_max_width") + + if help_max_width then + local description_max_width = math.max(help_max_width - description_margin_len, 10) + description_lines = autowrap(description_lines, description_max_width) + end if #label_lines[1] >= (description_margin_len - usage_margin_len) then for _, label_line in ipairs(label_lines) do