Skip to content

Commit

Permalink
feat: implement text and teamcity formatters (#825)
Browse files Browse the repository at this point in the history
* Add two formatters: text, teamcity. Closes issues #823 and #822.

The text formatter is close to the stylish but with no special formatting and a source:line:column suitable for navigation within an IDE console (IntelliJ specifically).

The teamcity formatter emits teamcity inspection service messages that teamcity will automatically detect and add to the build inspection tab.

* Updates as per P0lip
  • Loading branch information
memelet authored and P0lip committed Dec 3, 2019
1 parent 84a8238 commit 7580e7f
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 1 deletion.
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
10.15.3
4 changes: 3 additions & 1 deletion src/cli/services/output.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Dictionary } from '@stoplight/types';
import { writeFile } from 'fs';
import { promisify } from 'util';
import { html, json, junit, stylish } from '../../formatters';
import { html, json, junit, stylish, teamcity, text } from '../../formatters';
import { Formatter } from '../../formatters/types';
import { IRuleResult } from '../../types';
import { OutputFormat } from '../../types/config';
Expand All @@ -13,6 +13,8 @@ const formatters: Dictionary<Formatter, OutputFormat> = {
stylish,
junit,
html,
text,
teamcity,
};

export function formatOutput(results: IRuleResult[], format: OutputFormat): string {
Expand Down
22 changes: 22 additions & 0 deletions src/formatters/__tests__/teamcity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { teamcity } from '../teamcity';

const mixedErrors = require('./__fixtures__/mixed-errors.json');

describe('Teamcity formatter', () => {
test('should format messages', () => {
const result = teamcity(mixedErrors);
expect(result)
.toContain(`##teamcity[inspectionType category='openapi' id='info-contact' name='info-contact' description='hint -- Info object should contain \`contact\` object.']
##teamcity[inspection typeId='info-contact' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='3' message='hint -- Info object should contain \`contact\` object.']
##teamcity[inspectionType category='openapi' id='info-description' name='info-description' description='warning -- OpenAPI object info \`description\` must be present and non-empty string.']
##teamcity[inspection typeId='info-description' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='3' message='warning -- OpenAPI object info \`description\` must be present and non-empty string.']
##teamcity[inspectionType category='openapi' id='info-matches-stoplight' name='info-matches-stoplight' description='error -- Info must contain Stoplight']
##teamcity[inspection typeId='info-matches-stoplight' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='5' message='error -- Info must contain Stoplight']
##teamcity[inspectionType category='openapi' id='operation-description' name='operation-description' description='information -- Operation \`description\` must be present and non-empty string.']
##teamcity[inspection typeId='operation-description' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='17' message='information -- Operation \`description\` must be present and non-empty string.']
##teamcity[inspectionType category='openapi' id='operation-description' name='operation-description' description='information -- Operation \`description\` must be present and non-empty string.']
##teamcity[inspection typeId='operation-description' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='64' message='information -- Operation \`description\` must be present and non-empty string.']
##teamcity[inspectionType category='openapi' id='operation-description' name='operation-description' description='information -- Operation \`description\` must be present and non-empty string.']
##teamcity[inspection typeId='operation-description' file='/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json' line='86' message='information -- Operation \`description\` must be present and non-empty string.']`);
});
});
16 changes: 16 additions & 0 deletions src/formatters/__tests__/text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { text } from '../text';

const mixedErrors = require('./__fixtures__/mixed-errors.json');

describe('Text formatter', () => {
test('should format messages', () => {
const result = text(mixedErrors);
expect(result)
.toContain(`/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:3:10 hint info-contact "Info object should contain \`contact\` object."
/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:3:10 warning info-description "OpenAPI object info \`description\` must be present and non-empty string."
/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:5:14 error info-matches-stoplight "Info must contain Stoplight"
/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:17:13 information operation-description "Operation \`description\` must be present and non-empty string."
/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:64:14 information operation-description "Operation \`description\` must be present and non-empty string."
/home/Stoplight/spectral/src/__tests__/__fixtures__/petstore.oas3.json:86:13 information operation-description "Operation \`description\` must be present and non-empty string."`);
});
});
2 changes: 2 additions & 0 deletions src/formatters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ export * from './json';
export * from './stylish';
export * from './junit';
export * from './html';
export * from './text';
export * from './teamcity';
52 changes: 52 additions & 0 deletions src/formatters/teamcity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Dictionary, Optional } from '@stoplight/types';
import { IRuleResult } from '../types';
import { Formatter } from './types';
import { getSeverityName, groupBySource, sortResults } from './utils';

function escapeString(str: Optional<string | number>) {
if (str === void 0) {
return '';
}
return String(str)
.replace(/\|/g, '||')
.replace(/'/g, "|'")
.replace(/\n/g, '|n')
.replace(/\r/g, '|r')
.replace(/\u0085/g, '|x') // TeamCity 6
.replace(/\u2028/g, '|l') // TeamCity 6
.replace(/\u2029/g, '|p') // TeamCity 6
.replace(/\[/g, '|[')
.replace(/\]/g, '|]');
}

function inspectionType(result: IRuleResult) {
const code = escapeString(result.code);
const severity = getSeverityName(result.severity);
const message = escapeString(result.message);
return `##teamcity[inspectionType category='openapi' id='${code}' name='${code}' description='${severity} -- ${message}']`;
}

function inspection(result: IRuleResult) {
const code = escapeString(result.code);
const severity = getSeverityName(result.severity);
const message = escapeString(result.message);
const line = result.range.start.line + 1;
return `##teamcity[inspection typeId='${code}' file='${result.source}' line='${line}' message='${severity} -- ${message}']`;
}

function renderResults(results: IRuleResult[], parentIndex: number) {
return sortResults(results)
.map(result => `${inspectionType(result)}\n${inspection(result)}`)
.join('\n');
}

function renderGroupedResults(groupedResults: Dictionary<IRuleResult[]>) {
return Object.keys(groupedResults)
.map((source, index) => renderResults(groupedResults[source], index))
.join('\n');
}

export const teamcity: Formatter = results => {
const groupedResults = groupBySource(results);
return renderGroupedResults(groupedResults);
};
26 changes: 26 additions & 0 deletions src/formatters/text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Dictionary } from '@stoplight/types';
import { IRuleResult } from '../types';
import { Formatter } from './types';
import { getSeverityName, groupBySource, sortResults } from './utils';

function renderResults(results: IRuleResult[], parentIndex: number) {
return sortResults(results)
.map(result => {
const line = result.range.start.line + 1;
const character = result.range.start.character + 1;
const severity = getSeverityName(result.severity);
return `${result.source}:${line}:${character} ${severity} ${result.code} "${result.message}"`;
})
.join('\n');
}

function renderGroupedResults(groupedResults: Dictionary<IRuleResult[]>) {
return Object.keys(groupedResults)
.map((source, index) => renderResults(groupedResults[source], index))
.join('\n');
}

export const text: Formatter = results => {
const groupedResults = groupBySource(results);
return renderGroupedResults(groupedResults);
};
2 changes: 2 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export enum OutputFormat {
STYLISH = 'stylish',
JUNIT = 'junit',
HTML = 'html',
TEXT = 'text',
TEAMCITY = 'teamcity',
}

export interface ILintConfig {
Expand Down

0 comments on commit 7580e7f

Please sign in to comment.