diff --git a/src/puter-shell/coreutils/coreutil_lib/help.js b/src/puter-shell/coreutils/coreutil_lib/help.js
index 2cdb526..fbf16f4 100644
--- a/src/puter-shell/coreutils/coreutil_lib/help.js
+++ b/src/puter-shell/coreutils/coreutil_lib/help.js
@@ -41,6 +41,9 @@ export const printUsage = async (command, out, vars) => {
const colorOptionArgument = text => {
return `\x1B[91m${text}\x1B[0m`;
};
+ const wrap = text => {
+ return wrapText(text, vars.size.cols).join('\n') + '\n';
+ }
await heading('Usage');
if (!usage) {
@@ -62,10 +65,7 @@ export const printUsage = async (command, out, vars) => {
}
if (description) {
- const wrappedLines = wrapText(description, vars.size.cols);
- for (const line of wrappedLines) {
- await out.write(`${line}\n`);
- }
+ await out.write(wrap(description));
await out.write(`\n`);
}
@@ -127,8 +127,7 @@ export const printUsage = async (command, out, vars) => {
if (helpSections) {
for (const [title, contents] of Object.entries(helpSections)) {
await heading(title);
- // FIXME: Wrap the text nicely.
- await out.write(contents);
+ await out.write(wrap(contents));
await out.write('\n\n');
}
}
diff --git a/src/util/wrap-text.js b/src/util/wrap-text.js
index dc46dc9..478f1e7 100644
--- a/src/util/wrap-text.js
+++ b/src/util/wrap-text.js
@@ -16,20 +16,44 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-// TODO: Detect ANSI escape sequences in the text and treat them as 0 width?
+export function lengthIgnoringEscapes(text) {
+ const escape = '\x1b';
+ // There are a lot of different ones, but we only use graphics-mode ones, so only parse those for now.
+ // TODO: Parse other escape sequences as needed.
+ // Format is: ESC, '[', DIGIT, 0 or more characters, and then 'm'
+ const escapeSequenceRegex = /^\x1B\[\d.*?m/;
+
+ let length = 0;
+ for (let i = 0; i < text.length; i++) {
+ const char = text[i];
+ if (char === escape) {
+ // Consume an ANSI escape sequence
+ const match = text.substring(i).match(escapeSequenceRegex);
+ if (match) {
+ i += match[0].length - 1;
+ }
+ continue;
+ }
+ length++;
+ }
+ return length;
+}
+
// TODO: Ensure this works with multi-byte characters (UTF-8)
export const wrapText = (text, width) => {
+ const whitespaceChars = ' \t'.split('');
+ const isWhitespace = c => {
+ return whitespaceChars.includes(c);
+ };
+
// If width was invalid, just return the original text as a failsafe.
if (typeof width !== 'number' || width < 1)
return [text];
const lines = [];
- // This reduces all whitespace to single space characters. Is that a problem?
- const words = text.split(/\s+/);
-
let currentLine = '';
const splitWordIfTooLong = (word) => {
- while (word.length > width) {
+ while (lengthIgnoringEscapes(word) > width) {
lines.push(word.substring(0, width - 1) + '-');
word = word.substring(width - 1);
}
@@ -37,20 +61,51 @@ export const wrapText = (text, width) => {
currentLine = word;
};
- for (let word of words) {
- if (currentLine.length === 0) {
- splitWordIfTooLong(word);
+ for (let i = 0; i < text.length; i++) {
+ const char = text.charAt(i);
+ // Handle special characters
+ if (char === '\n') {
+ lines.push(currentLine.trimEnd());
+ currentLine = '';
+ // Don't skip whitespace after a newline, to allow for indentation.
+ continue;
+ }
+ // TODO: Handle \t?
+ if (/\S/.test(char)) {
+ // Grab next word
+ let word = char;
+ while ((i+1) < text.length && /\S/.test(text[i + 1])) {
+ word += text[i+1];
+ i++;
+ }
+ if (lengthIgnoringEscapes(currentLine) === 0) {
+ splitWordIfTooLong(word);
+ continue;
+ }
+ if ((lengthIgnoringEscapes(currentLine) + lengthIgnoringEscapes(word)) > width) {
+ // Next line
+ lines.push(currentLine.trimEnd());
+ splitWordIfTooLong(word);
+ continue;
+ }
+ currentLine += word;
continue;
}
- if ((currentLine.length + 1 + word.length) > width) {
- // Next line
- lines.push(currentLine);
- splitWordIfTooLong(word);
+
+ currentLine += char;
+ if (lengthIgnoringEscapes(currentLine) >= width) {
+ lines.push(currentLine.trimEnd());
+ currentLine = '';
+ // Skip whitespace at end of line.
+ while (isWhitespace(text[i + 1])) {
+ i++;
+ }
continue;
}
- currentLine += ' ' + word;
}
- lines.push(currentLine);
+ if (currentLine.length >= 0) { // Not lengthIgnoringEscapes!
+ lines.push(currentLine);
+ }
return lines;
};
\ No newline at end of file
diff --git a/test/wrap-text.js b/test/wrap-text.js
index cdae516..3ee7a7b 100644
--- a/test/wrap-text.js
+++ b/test/wrap-text.js
@@ -17,7 +17,7 @@
* along with this program. If not, see .
*/
import assert from 'assert';
-import { wrapText } from '../src/util/wrap-text.js';
+import { lengthIgnoringEscapes, wrapText } from '../src/util/wrap-text.js';
describe('wrapText', () => {
const testCases = [
@@ -51,19 +51,34 @@ describe('wrapText', () => {
width: 0,
output: ['Well, hello friends!'],
},
+ {
+ description: 'should maintain existing newlines',
+ input: 'Well\nhello\n\nfriends!',
+ width: 20,
+ output: ['Well', 'hello', '', 'friends!'],
+ },
+ {
+ description: 'should maintain indentation after newlines',
+ input: 'Well\n hello\n\nfriends!',
+ width: 20,
+ output: ['Well', ' hello', '', 'friends!'],
+ },
+ {
+ description: 'should ignore ansi escape sequences',
+ input: '\x1B[34;1mWell this is some text with ansi escape sequences\x1B[0m',
+ width: 20,
+ output: ['\x1B[34;1mWell this is some', 'text with ansi', 'escape sequences\x1B[0m'],
+ },
];
for (const { description, input, width, output } of testCases) {
it (description, () => {
const result = wrapText(input, width);
for (const line of result) {
if (typeof width === 'number' && width > 0) {
- assert.ok(line.length <= width, `Line is too long: '${line}`);
+ assert.ok(lengthIgnoringEscapes(line) <= width, `Line is too long: '${line}'`);
}
}
- assert.equal(result.length, output.length, 'Wrong number of lines');
- for (const i in result) {
- assert.equal(result[i], output[i], `Line ${i} doesn't match: expected '${output[i]}', got '${result[i]}'`);
- }
+ assert.equal('|' + result.join('|\n|') + '|', '|' + output.join('|\n|') + '|');
});
}
})
\ No newline at end of file