1'use strict'; 2const stringWidth = require('string-width'); 3const stripAnsi = require('strip-ansi'); 4const ansiStyles = require('ansi-styles'); 5 6const ESCAPES = new Set([ 7 '\u001B', 8 '\u009B' 9]); 10 11const END_CODE = 39; 12 13const ANSI_ESCAPE_BELL = '\u0007'; 14const ANSI_CSI = '['; 15const ANSI_OSC = ']'; 16const ANSI_SGR_TERMINATOR = 'm'; 17const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`; 18 19const wrapAnsi = code => `${ESCAPES.values().next().value}${ANSI_CSI}${code}${ANSI_SGR_TERMINATOR}`; 20const wrapAnsiHyperlink = uri => `${ESCAPES.values().next().value}${ANSI_ESCAPE_LINK}${uri}${ANSI_ESCAPE_BELL}`; 21 22// Calculate the length of words split on ' ', ignoring 23// the extra characters added by ansi escape codes 24const wordLengths = string => string.split(' ').map(character => stringWidth(character)); 25 26// Wrap a long word across multiple rows 27// Ansi escape codes do not count towards length 28const wrapWord = (rows, word, columns) => { 29 const characters = [...word]; 30 31 let isInsideEscape = false; 32 let isInsideLinkEscape = false; 33 let visible = stringWidth(stripAnsi(rows[rows.length - 1])); 34 35 for (const [index, character] of characters.entries()) { 36 const characterLength = stringWidth(character); 37 38 if (visible + characterLength <= columns) { 39 rows[rows.length - 1] += character; 40 } else { 41 rows.push(character); 42 visible = 0; 43 } 44 45 if (ESCAPES.has(character)) { 46 isInsideEscape = true; 47 isInsideLinkEscape = characters.slice(index + 1).join('').startsWith(ANSI_ESCAPE_LINK); 48 } 49 50 if (isInsideEscape) { 51 if (isInsideLinkEscape) { 52 if (character === ANSI_ESCAPE_BELL) { 53 isInsideEscape = false; 54 isInsideLinkEscape = false; 55 } 56 } else if (character === ANSI_SGR_TERMINATOR) { 57 isInsideEscape = false; 58 } 59 60 continue; 61 } 62 63 visible += characterLength; 64 65 if (visible === columns && index < characters.length - 1) { 66 rows.push(''); 67 visible = 0; 68 } 69 } 70 71 // It's possible that the last row we copy over is only 72 // ansi escape characters, handle this edge-case 73 if (!visible && rows[rows.length - 1].length > 0 && rows.length > 1) { 74 rows[rows.length - 2] += rows.pop(); 75 } 76}; 77 78// Trims spaces from a string ignoring invisible sequences 79const stringVisibleTrimSpacesRight = string => { 80 const words = string.split(' '); 81 let last = words.length; 82 83 while (last > 0) { 84 if (stringWidth(words[last - 1]) > 0) { 85 break; 86 } 87 88 last--; 89 } 90 91 if (last === words.length) { 92 return string; 93 } 94 95 return words.slice(0, last).join(' ') + words.slice(last).join(''); 96}; 97 98// The wrap-ansi module can be invoked in either 'hard' or 'soft' wrap mode 99// 100// 'hard' will never allow a string to take up more than columns characters 101// 102// 'soft' allows long words to expand past the column length 103const exec = (string, columns, options = {}) => { 104 if (options.trim !== false && string.trim() === '') { 105 return ''; 106 } 107 108 let returnValue = ''; 109 let escapeCode; 110 let escapeUrl; 111 112 const lengths = wordLengths(string); 113 let rows = ['']; 114 115 for (const [index, word] of string.split(' ').entries()) { 116 if (options.trim !== false) { 117 rows[rows.length - 1] = rows[rows.length - 1].trimStart(); 118 } 119 120 let rowLength = stringWidth(rows[rows.length - 1]); 121 122 if (index !== 0) { 123 if (rowLength >= columns && (options.wordWrap === false || options.trim === false)) { 124 // If we start with a new word but the current row length equals the length of the columns, add a new row 125 rows.push(''); 126 rowLength = 0; 127 } 128 129 if (rowLength > 0 || options.trim === false) { 130 rows[rows.length - 1] += ' '; 131 rowLength++; 132 } 133 } 134 135 // In 'hard' wrap mode, the length of a line is never allowed to extend past 'columns' 136 if (options.hard && lengths[index] > columns) { 137 const remainingColumns = (columns - rowLength); 138 const breaksStartingThisLine = 1 + Math.floor((lengths[index] - remainingColumns - 1) / columns); 139 const breaksStartingNextLine = Math.floor((lengths[index] - 1) / columns); 140 if (breaksStartingNextLine < breaksStartingThisLine) { 141 rows.push(''); 142 } 143 144 wrapWord(rows, word, columns); 145 continue; 146 } 147 148 if (rowLength + lengths[index] > columns && rowLength > 0 && lengths[index] > 0) { 149 if (options.wordWrap === false && rowLength < columns) { 150 wrapWord(rows, word, columns); 151 continue; 152 } 153 154 rows.push(''); 155 } 156 157 if (rowLength + lengths[index] > columns && options.wordWrap === false) { 158 wrapWord(rows, word, columns); 159 continue; 160 } 161 162 rows[rows.length - 1] += word; 163 } 164 165 if (options.trim !== false) { 166 rows = rows.map(stringVisibleTrimSpacesRight); 167 } 168 169 const pre = [...rows.join('\n')]; 170 171 for (const [index, character] of pre.entries()) { 172 returnValue += character; 173 174 if (ESCAPES.has(character)) { 175 const {groups} = new RegExp(`(?:\\${ANSI_CSI}(?<code>\\d+)m|\\${ANSI_ESCAPE_LINK}(?<uri>.*)${ANSI_ESCAPE_BELL})`).exec(pre.slice(index).join('')) || {groups: {}}; 176 if (groups.code !== undefined) { 177 const code = Number.parseFloat(groups.code); 178 escapeCode = code === END_CODE ? undefined : code; 179 } else if (groups.uri !== undefined) { 180 escapeUrl = groups.uri.length === 0 ? undefined : groups.uri; 181 } 182 } 183 184 const code = ansiStyles.codes.get(Number(escapeCode)); 185 186 if (pre[index + 1] === '\n') { 187 if (escapeUrl) { 188 returnValue += wrapAnsiHyperlink(''); 189 } 190 191 if (escapeCode && code) { 192 returnValue += wrapAnsi(code); 193 } 194 } else if (character === '\n') { 195 if (escapeCode && code) { 196 returnValue += wrapAnsi(escapeCode); 197 } 198 199 if (escapeUrl) { 200 returnValue += wrapAnsiHyperlink(escapeUrl); 201 } 202 } 203 } 204 205 return returnValue; 206}; 207 208// For each newline, invoke the method separately 209module.exports = (string, columns, options) => { 210 return String(string) 211 .normalize() 212 .replace(/\r\n/g, '\n') 213 .split('\n') 214 .map(line => exec(line, columns, options)) 215 .join('\n'); 216}; 217