Repository: oclif/core
Pull request #722 (2023-06-29 21:15:44)

Properly truncate table cells that contain fullwidth characters or ANSI escape sequences
The [current implementation of `outputTable`](https://github.com/oclif/core/blob/fbdf25b9eff4c7b8e3dd36ec29a6e035ce2ce5a6/src/cli-ux/styled/table.ts#L283-L288) is correctly computing cell widths using `sw()`, which takes into account fullwidth characters and ANSI escape sequences:

```
const visualWidth = sw(d)
```

However, when truncating the cell, the raw string is getting sliced without considering any special characters:

```
cell = cell.slice(0, width - 2) + '… '
```

Because of that, a red-colored string like `"\u001b[31m hello \u001b[39m"` may get incorrectly truncated to `"\u001b[31m hell… "`, which will make the red color spill over adjacent cells. Similarly, a string containing fullwidth characters like `"你好我是意大利人"` may get truncated to twice the expected width, resulting in misaligned output.

This commit fixes the problem by using [slice-ansi](https://www.npmjs.com/package/slice-ansi) instead of `slice()`.

salesforce-cla[bot]: Thanks for the contribution!  Before we can merge this, we need @andreacorbellini to [sign the Salesforce Inc. Contributor License Agreement](https://cla.salesforce.com/sign-cla?prUrl=https%3A%2F%2Fgithub.com%2Foclif%2Fcore%2Fpull%2F722).
WillieRuemmele: Hi @andreacorbellini we'll be happy to get this fixed. Can this be done with adding extra dependencies? 
andreacorbellini: > Hi @andreacorbellini we'll be happy to get this fixed. Can this be done with adding extra dependencies?

You mean with*out* adding extra dependencies? I think so, yes: I did some digging into `cli-truncate` and saw that it's a simple wrapper around `slice-ansi` with a few extra features that are not needed here. And `slice-ansi` is already in `yarn.lock`. Let's see if I can get it to work

Patch:
diff --git a/package.json b/package.json
index 259c269..7263ebe 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
     "object-treeify": "^1.1.33",
     "password-prompt": "^1.1.2",
     "semver": "^7.5.3",
+    "slice-ansi": "^4.0.0",
     "string-width": "^4.2.3",
     "strip-ansi": "^6.0.1",
     "supports-color": "^8.1.1",
@@ -55,6 +56,7 @@
     "@types/proxyquire": "^1.3.28",
     "@types/semver": "^7.5.0",
     "@types/shelljs": "^0.8.11",
+    "@types/slice-ansi": "^4.0.0",
     "@types/strip-ansi": "^5.2.1",
     "@types/supports-color": "^8.1.1",
     "@types/wordwrap": "^1.0.1",
@@ -114,4 +116,4 @@
     "pretest": "yarn build --noEmit && tsc -p test --noEmit --skipLibCheck"
   },
   "types": "lib/index.d.ts"
-}
\ No newline at end of file
+}
diff --git a/src/cli-ux/styled/table.ts b/src/cli-ux/styled/table.ts
index 962c89c..04f8b0c 100644
--- a/src/cli-ux/styled/table.ts
+++ b/src/cli-ux/styled/table.ts
@@ -9,6 +9,7 @@ import {stdout} from '../stream'
 
 const sw = require('string-width')
 const {orderBy} = require('natural-orderby')
+const sliceAnsi = require('slice-ansi')
 
 class Table<T extends Record<string, unknown>> {
   options: table.Options & { printLine(s: any): any }
@@ -284,7 +285,12 @@ class Table<T extends Record<string, unknown>> {
           const colorWidth = (d.length - visualWidth)
           let cell = d.padEnd(width + colorWidth)
           if ((cell.length - colorWidth) > width || visualWidth === width) {
-            cell = cell.slice(0, width - 2) + '… '
+            // truncate the cell, preserving ANSI escape sequences, and keeping
+            // into account the width of fullwidth unicode characters
+            cell = sliceAnsi(cell, 0, width - 2) + '… '
+            // pad with spaces; this is necessary in case the original string
+            // contained fullwidth characters which cannot be split
+            cell += ' '.repeat(width - sw(cell))
           }
 
           l += cell
diff --git a/yarn.lock b/yarn.lock
index 3cdead2..a4e45c2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -722,6 +722,11 @@
   dependencies:
     "@sinonjs/fake-timers" "^7.1.0"
 
+"@types/slice-ansi@^4.0.0":
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/@types/slice-ansi/-/slice-ansi-4.0.0.tgz#eb40dfbe3ac5c1de61f6bcb9ed471f54baa989d6"
+  integrity sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==
+
 "@types/strip-ansi@^5.2.1":
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/@types/strip-ansi/-/strip-ansi-5.2.1.tgz#acd97f1f091e332bb7ce697c4609eb2370fa2a92"


Test patch:
diff --git a/test/cli-ux/styled/table.test.ts b/test/cli-ux/styled/table.test.ts
index 3383fb5..977954d 100644
--- a/test/cli-ux/styled/table.test.ts
+++ b/test/cli-ux/styled/table.test.ts
@@ -1,6 +1,7 @@
 import {expect, fancy} from 'fancy-test'
 
 import {ux} from '../../../src'
+import * as screen from '../../../src/screen'
 
 /* eslint-disable camelcase */
 const apps = [
@@ -317,6 +318,38 @@ describe('styled/table', () => {
  321 supertable-test-2${ws}
  123 supertable-test-1${ws}\n`)
     })
+
+    const orig = {
+      stdtermwidth: screen.stdtermwidth,
+      CLI_UX_SKIP_TTY_CHECK: process.env.CLI_UX_SKIP_TTY_CHECK,
+    }
+
+    fancy
+    .do(() => {
+      Object.assign(screen, {stdtermwidth: 9})
+      process.env.CLI_UX_SKIP_TTY_CHECK = 'true'
+    })
+    .finally(() => {
+      Object.assign(screen, {stdtermwidth: orig.stdtermwidth})
+      process.env.CLI_UX_SKIP_TTY_CHECK = orig.CLI_UX_SKIP_TTY_CHECK
+    })
+    .stdout({stripColor: false})
+    .end('correctly truncates columns with fullwidth characters or ansi escape sequences', output => {
+      /* eslint-disable camelcase */
+      const app4 = {
+        build_stack: {
+          name: 'heroku-16',
+        },
+        id: '456',
+        name: '\u001B[31m超级表格—测试\u001B[0m',
+        web_url: 'https://supertable-test-1.herokuapp.com/',
+      }
+      /* eslint-enable camelcase */
+
+      ux.table([...apps, app4 as any], {name: {}}, {'no-header': true})
+      expect(output.stdout).to.equal(` super…${ws}
+ super…${ws}
+ \u001B[31m超级\u001B[39m…${ws}${ws}\n`)
+    })
   })
 })
-