Repository: stoplightio/spectral
Pull request #1580 (2021-04-18 01:28:00)

feat(formatters): added pretty formatter for colored and wrapped cli output
**Checklist**

- [X] Tests added / updated
- [X] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [X] No

**Screenshots**

Complex Wrapping:
<img width="1679" alt="Screen Shot 2021-04-17 at 4 44 19 AM" src="https://user-images.githubusercontent.com/9692863/115131105-606b0e80-9fc3-11eb-9048-0c87d0896731.png">

With Stylish:
<img width="1680" alt="Screen Shot 2021-04-17 at 9 24 08 PM" src="https://user-images.githubusercontent.com/9692863/115131094-4af5e480-9fc3-11eb-8233-6419b3dd1257.png">

With Pretty:
<img width="1669" alt="Screen Shot 2021-04-17 at 9 24 51 PM" src="https://user-images.githubusercontent.com/9692863/115131164-e424fb00-9fc3-11eb-8f6f-4ce6400add62.png">



**Additional context**

I've added this due to the stylish formatter being somewhat rough on certain terminals based on their terminal width and based on the spec file. For some very long spec files, the paths and line numbers are longer than the expected terminal width. Additionally, the colors are very subtle and easy to miss. It's often difficult to read and doesn't wrap appropriately (See screenshot above of the comparison of stylish to pretty)


amthorn: @P0lip commit your requested changes:) thanks! ran yarn lint + yarn test again. All looks good. Thanks for letting me contribute:)

Patch:
diff --git a/README.md b/README.md
index 68ba63ce..11c2c61d 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,7 @@ If you are interested in contributing to Spectral, check out [CONTRIBUTING.md](C
 - [Mike Ralphson](https://github.com/MikeRalphson) for kicking off the Spectral CLI and his work on Speccy
 - [Jamund Ferguson](https://github.com/xjamundx) for JUnit formatter
 - [Sindre Sorhus](https://github.com/sindresorhus) for Stylish formatter
+- [Ava Thorn](https://github.com/amthorn) for the Pretty formatter
 - Julian Laval for HTML formatter
 - [@nulltoken](https://github.com/nulltoken) for a whole bunch of amazing features
 
diff --git a/docs/guides/2-cli.md b/docs/guides/2-cli.md
index 48f12bcd..a27c645a 100644
--- a/docs/guides/2-cli.md
+++ b/docs/guides/2-cli.md
@@ -21,22 +21,22 @@ spectral lint ./reference/**/*.oas*.{json,yml,yaml}
 Other options include:
 
 ```text
-  --version                    Show version number                                          [boolean]
-  --help                       Show help                                                    [boolean]
-  --encoding, -e               text encoding to use                        [string] [default: "utf8"]
+  --version                    Show version number                                                    [boolean]
+  --help                       Show help                                                              [boolean]
+  --encoding, -e               text encoding to use                                  [string] [default: "utf8"]
   --format, -f                 formatter to use for outputting results
-      [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity"] [default: "stylish"]
-  --output, -o                 output to a file instead of stdout                            [string]
-  --resolver                   path to custom json-ref-resolver instance                     [string]
-  --ruleset, -r                path/URL to a ruleset file                                    [string]
+      [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty"] [default: "stylish"]
+  --output, -o                 output to a file instead of stdout                                      [string]
+  --resolver                   path to custom json-ref-resolver instance                               [string]
+  --ruleset, -r                path/URL to a ruleset file                                              [string]
   --fail-severity, -F          results of this level or above will trigger a failure exit code
-                               [string] [choices: "error", "warn", "info", "hint"] [default: "error"]
+                                         [string] [choices: "error", "warn", "info", "hint"] [default: "error"]
   --display-only-failures, -D  only output results equal to or greater than --fail-severity
-                                                                           [boolean] [default: false]
-  --ignore-unknown-format      do not warn about unmatched formats         [boolean] [default: false]
-  --fail-on-unmatched-globs    fail on unmatched glob patterns             [boolean] [default: false]
-  --verbose, -v                increase verbosity                                           [boolean]
-  --quiet, -q                  no logging - output only                                     [boolean]
+                                                                                     [boolean] [default: false]
+  --ignore-unknown-format      do not warn about unmatched formats                   [boolean] [default: false]
+  --fail-on-unmatched-globs    fail on unmatched glob patterns                       [boolean] [default: false]
+  --verbose, -v                increase verbosity                                                     [boolean]
+  --quiet, -q                  no logging - output only                                               [boolean]
 ```
 
 The Spectral CLI supports loading documents as YAML or JSON, and validation of OpenAPI v2/v3 documents via our built-in ruleset.
diff --git a/src/cli/formatters/index.ts b/src/cli/formatters/index.ts
index 7908adaa..1a50c90d 100644
--- a/src/cli/formatters/index.ts
+++ b/src/cli/formatters/index.ts
@@ -4,3 +4,4 @@ export * from './junit';
 export * from './html';
 export * from './text';
 export * from './teamcity';
+export * from './pretty';
diff --git a/src/cli/formatters/pretty.ts b/src/cli/formatters/pretty.ts
new file mode 100644
index 00000000..5380bf9f
--- /dev/null
+++ b/src/cli/formatters/pretty.ts
@@ -0,0 +1,87 @@
+/**
+ * Copyright JS Foundation and other contributors, https://js.foundation
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @fileoverview Pretty Reporter
+ * @author Ava Thorn
+ */
+
+import { IDiagnostic, IRange } from '@stoplight/types';
+import * as chalk from 'chalk';
+
+import { Formatter } from './types';
+import { getColorForSeverity, getHighestSeverity, getSummary, getSeverityName, groupBySource } from './utils';
+import { printPath, PrintStyle } from '../../utils';
+
+function formatRange(range?: IRange): string {
+  if (range === void 0) return '';
+
+  return ` ${range.start.line + 1}:${range.start.character + 1}`;
+}
+
+export const pretty: Formatter = results => {
+  const cliui = require('cliui');
+  let output = '\n';
+  const DEFAULT_TOTAL_WIDTH = process.stdout.columns;
+  const COLUMNS = [10, 13, 25, 20, 20];
+  const variableColumns = DEFAULT_TOTAL_WIDTH - COLUMNS.reduce((a, b) => a + b);
+  COLUMNS[2] = Math.floor(variableColumns / 3);
+  COLUMNS[3] = Math.ceil((variableColumns / 3) * 2);
+
+  const PAD_TOP0_LEFT2 = [0, 0, 0, 2];
+  const PAD_TOP1_LEFT0 = [1, 0, 0, 0];
+  const ui = cliui({ width: DEFAULT_TOTAL_WIDTH, wrap: true });
+
+  const groupedResults = groupBySource(results);
+  const summaryColor = getColorForSeverity(getHighestSeverity(results));
+  const summaryText = getSummary(groupedResults);
+
+  const uniqueIssues: IDiagnostic['code'][] = [];
+  Object.keys(groupedResults).forEach(i => {
+    const pathResults = groupedResults[i];
+    ui.div({ text: 'File:   ' + i, padding: PAD_TOP1_LEFT0 });
+
+    pathResults.forEach(result => {
+      if (!uniqueIssues.includes(result.code)) {
+        uniqueIssues.push(result.code);
+      }
+      const color = getColorForSeverity(result.severity);
+      ui.div(
+        { text: chalk[color](formatRange(result.range)), width: COLUMNS[0] },
+        {
+          text: chalk[color].inverse(getSeverityName(result.severity).toUpperCase()),
+          padding: PAD_TOP0_LEFT2,
+          width: COLUMNS[1],
+        },
+        { text: chalk[color].bold(result.code), padding: PAD_TOP0_LEFT2, width: COLUMNS[2] },
+        { text: chalk.gray(result.message), padding: PAD_TOP0_LEFT2, width: COLUMNS[3] },
+        { text: chalk.cyan(printPath(result.path, PrintStyle.Dot)), padding: PAD_TOP0_LEFT2 },
+      );
+      ui.div();
+    });
+  });
+  ui.div();
+
+  output += ui.toString();
+  output += chalk[summaryColor].bold(`${uniqueIssues.length} Unique Issue(s)\n`);
+  output += chalk[summaryColor].bold(`\u2716 ${summaryText}\n`);
+
+  return output;
+};
diff --git a/src/cli/services/output.ts b/src/cli/services/output.ts
index e1fdf7ee..4ea136fb 100644
--- a/src/cli/services/output.ts
+++ b/src/cli/services/output.ts
@@ -1,7 +1,7 @@
 import { Dictionary } from '@stoplight/types';
 import { writeFile } from 'fs';
 import { promisify } from 'util';
-import { html, json, junit, stylish, teamcity, text } from '../formatters';
+import { html, json, junit, stylish, teamcity, text, pretty } from '../formatters';
 import { Formatter, FormatterOptions } from '../formatters/types';
 import { IRuleResult } from '../../types';
 import { OutputFormat } from '../../types/config';
@@ -11,6 +11,7 @@ const writeFileAsync = promisify(writeFile);
 const formatters: Dictionary<Formatter, OutputFormat> = {
   json,
   stylish,
+  pretty,
   junit,
   html,
   text,
diff --git a/src/types/config.ts b/src/types/config.ts
index 49296104..cd5f2b41 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -9,6 +9,7 @@ export enum OutputFormat {
   HTML = 'html',
   TEXT = 'text',
   TEAMCITY = 'teamcity',
+  PRETTY = 'pretty',
 }
 
 export interface ILintConfig {


Test patch:
diff --git a/src/cli/formatters/__tests__/pretty.test.ts b/src/cli/formatters/__tests__/pretty.test.ts
new file mode 100644
index 00000000..8b051343
--- /dev/null
+++ b/src/cli/formatters/__tests__/pretty.test.ts
@@ -0,0 +1,115 @@
+import { DiagnosticSeverity } from '@stoplight/types';
+import * as chalk from 'chalk';
+import { sortResults } from '../../../utils';
+import { pretty } from '../pretty';
+
+const oas3SchemaErrors = sortResults(require('./__fixtures__/oas3-schema-errors.json'));
+const mixedErrors = sortResults(require('./__fixtures__/mixed-errors.json'));
+
+function setColumnWidth(width: number, func: CallableFunction): void {
+  const og = process.stdout.columns;
+  process.stdout.columns = width;
+  try {
+    func(width);
+  } finally {
+    process.stdout.columns = og;
+  }
+}
+function forceWrapped(s: string, wrapType: number): string {
+  // Occurs when line is wrapped at non-whitespace character
+  if (wrapType === 0) {
+    // first line
+    return s.slice(0, s.length - 5);
+  } else if (wrapType === 1) {
+    // middle line
+    return s.slice(5, s.length - 5);
+  } else {
+    // end line
+    return s.slice(5, s.length);
+  }
+}
+
+describe('Pretty formatter', () => {
+  test('should not wrap when terminal width is wide enough', () => {
+    setColumnWidth(185, function (): void {
+      const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error });
+      expect(result).toContain(
+        `${chalk.red('36:22')}       ${chalk.red.inverse('ERROR')}        ${chalk.red.bold(
+          'oas3-schema',
+        )}                     ${chalk.gray(
+          'should NOT have additional properties: type',
+        )}                      ${chalk.cyan('paths./pets.get.responses[200].headers.header-1')}`,
+      );
+      expect(result).toContain(
+        `${chalk.red('36:22')}       ${chalk.red.inverse('ERROR')}        ${chalk.red.bold(
+          'oas3-schema',
+        )}                     ${chalk.gray(
+          'should match exactly one schema in oneOf',
+        )}                         ${chalk.cyan('paths./pets.get.responses[200].headers.header-1')}`,
+      );
+      expect(result).toContain(
+        `${chalk.red('36:22')}       ${chalk.red.inverse('ERROR')}        ${chalk.red.bold(
+          'oas3-schema',
+        )}                     ${chalk.gray(
+          "should have required property '$ref'",
+        )}                             ${chalk.cyan('paths./pets.get.responses[200].headers.header-1')}`,
+      );
+      expect(result).toContain(chalk.red.bold('1 Unique Issue(s)'));
+    });
+  });
+  test('should wrap when terminal width is very small', () => {
+    setColumnWidth(120, function (): void {
+      const result = pretty(oas3SchemaErrors, { failSeverity: DiagnosticSeverity.Error });
+      expect(result).toContain(`
+File:   /home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.invalid-schema.oas3.yaml
+${chalk.red(
+  '36:22',
+)}       ${chalk.red.inverse('ERROR')}        ${forceWrapped(chalk.red.bold('oas3-sch'), 0)}  ${chalk.gray('should NOT have')}       ${chalk.cyan('paths./pets.get.responses[200].headers.header-1')}
+                         ${forceWrapped(chalk.red.bold('ema'), 2)}       ${chalk.gray('additional')}
+                                   ${chalk.gray('properties: type')}
+
+${chalk.red(
+  '36:22',
+)}       ${chalk.red.inverse('ERROR')}        ${forceWrapped(chalk.red.bold('oas3-sch'), 0)}  ${chalk.gray('should match exactly')}  ${chalk.cyan('paths./pets.get.responses[200].headers.header-1')}
+                         ${forceWrapped(chalk.red.bold('ema'), 2)}       ${chalk.gray('one schema in oneOf')}
+
+${chalk.red(
+  '36:22',
+)}       ${chalk.red.inverse('ERROR')}        ${forceWrapped(chalk.red.bold('oas3-sch'), 0)}  ${chalk.gray('should have required')}  ${chalk.cyan('paths./pets.get.responses[200].headers.header-1')}
+                         ${forceWrapped(chalk.red.bold('ema'), 2)}       ${chalk.gray("property '$ref'")}
+
+${chalk.red.bold('1 Unique Issue(s)')}\n`);
+    });
+  });
+  test('should display proper severity level', () => {
+    setColumnWidth(185, function (): void {
+      const result = pretty(mixedErrors, { failSeverity: DiagnosticSeverity.Error });
+      expect(
+        result,
+      ).toContain(`${chalk.white('3:10')}        ${chalk.white.inverse('HINT')}         ${chalk.white.bold('info-contact')}                    ${chalk.gray('Info object should contain `contact` object.')}                     ${chalk.cyan('info')}
+
+${chalk.yellow(
+  '3:10',
+)}        ${chalk.yellow.inverse('WARNING')}      ${chalk.yellow.bold('info-description')}                ${chalk.gray('OpenAPI object info `description` must be present and non-empty')}  ${chalk.cyan('info')}
+                                                         ${chalk.gray('string.')}
+
+${chalk.red(
+  '5:14',
+)}        ${chalk.red.inverse('ERROR')}        ${chalk.red.bold('info-matches-stoplight')}          ${chalk.gray('Info must contain Stoplight')}                                      ${chalk.cyan('info.title')}
+
+${chalk.blue(
+  '17:13',
+)}       ${chalk.blue.inverse('INFORMATION')}  ${chalk.blue.bold('operation-description')}           ${chalk.gray('Operation `description` must be present and non-empty string.')}    ${chalk.cyan('paths./pets.get')}
+
+${chalk.blue(
+  '64:14',
+)}       ${chalk.blue.inverse('INFORMATION')}  ${chalk.blue.bold('operation-description')}           ${chalk.gray('Operation `description` must be present and non-empty string.')}    ${chalk.cyan('paths./pets.post')}
+
+${chalk.blue(
+  '86:13',
+)}       ${chalk.blue.inverse('INFORMATION')}  ${chalk.blue.bold('operation-description')}           ${chalk.gray('Operation `description` must be present and non-empty string.')}    ${chalk.cyan('paths./pets/{petId}.get')}
+
+${chalk.red.bold('4 Unique Issue(s)')}\n`);
+    });
+  });
+});
diff --git a/test-harness/scenarios/help-no-document.scenario b/test-harness/scenarios/help-no-document.scenario
index e9dacf0d..18b6de14 100644
--- a/test-harness/scenarios/help-no-document.scenario
+++ b/test-harness/scenarios/help-no-document.scenario
@@ -14,7 +14,7 @@ Options:
       --version                  Show version number  [boolean]
       --help                     Show help  [boolean]
   -e, --encoding                 text encoding to use  [string] [default: "utf8"]
-  -f, --format                   formatter to use for outputting results  [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity"] [default: "stylish"]
+  -f, --format                   formatter to use for outputting results  [string] [choices: "json", "stylish", "junit", "html", "text", "teamcity", "pretty"] [default: "stylish"]
   -o, --output                   output to a file instead of stdout  [string]
       --resolver                 path to custom json-ref-resolver instance  [string]
   -r, --ruleset                  path/URL to a ruleset file  [string]