Repository: stoplightio/spectral
Pull request #867 (2019-12-19 13:08:42)

feat: consistent paths in messages
Addresses https://github.com/stoplightio/spectral/issues/815

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No




P0lip: @marbemac @philsturgeon 
the last remaining question is whether we are making paths configurable - right now we always try to display the property name, but some want to have the whole path printed.
What do you think?
philsturgeon: If I had to guess at the use case for people who want the whole path, they are probably using it programmatically do if we make it available in the object (and it can be inserted into the message string via a {{path}} variable) then both JS and CLI users should be happy. Then we can keep it property whenever possible by default.

On Mon, Dec 23, 2019 at 13:56, Jakub Rożek <notifications@github.com> wrote:

> [@marbemac](https://github.com/marbemac) [@philsturgeon](https://github.com/philsturgeon)
> the last remaining question is whether we are making paths configurable - right now we always try to display the property name, but some want to have the whole path printed.
> What do you think?
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, [view it on GitHub](https://github.com/stoplightio/spectral/pull/867?email_source=notifications&email_token=AAAQONOHU7MENJVEUIF7ZJDQ2CYP7A5CNFSM4J5FZ4QKYY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEHRCD7Y#issuecomment-568467967), or [unsubscribe](https://github.com/notifications/unsubscribe-auth/AAAQONO5TB4AHKKHJ7APZLLQ2CYP7ANCNFSM4J5FZ4QA).
marbemac: > whether we are making paths configurable

Suggest we punt this to later - not convinced we need it so might as well save the time now :).
P0lip: > If I had to guess at the use case for people who want the whole path, they are probably using it programmatically do if we make it available in the object (and it can be inserted into the message string via a {{path}} variable) then both JS and CLI users should be happy. Then we can keep it property whenever possible by default.

We do expose `{{path}}` now, so your use case would be covered :)



Patch:
diff --git a/src/formatters/junit.ts b/src/formatters/junit.ts
index 3191f732..91aae1ee 100644
--- a/src/formatters/junit.ts
+++ b/src/formatters/junit.ts
@@ -23,17 +23,12 @@
  * @author Jamund Ferguson
  */
 
-import { encodePointerFragment } from '@stoplight/json';
 import { extname } from '@stoplight/path';
-import { DiagnosticSeverity, JsonPath } from '@stoplight/types';
+import { DiagnosticSeverity } from '@stoplight/types';
 import { escapeRegExp } from 'lodash';
+import { printPath, PrintStyle } from '../utils';
 import { Formatter } from './types';
-import { groupBySource } from './utils/groupBySource';
-import { xmlEscape } from './utils/xmlEscape';
-
-function stringifyPath(path: JsonPath) {
-  return ['#', ...path.map(encodePointerFragment)].join('/');
-}
+import { groupBySource, xmlEscape } from './utils';
 
 export const junit: Formatter = results => {
   let output = '';
@@ -59,7 +54,7 @@ export const junit: Formatter = results => {
         output += '<![CDATA[';
         output += `line ${result.range.start.line + 1}, col ${result.range.start.character + 1}, `;
         output += `${xmlEscape(result.message)} (${result.code}) `;
-        output += `at path ${xmlEscape(stringifyPath(result.path))}`;
+        output += `at path ${xmlEscape(printPath(result.path, PrintStyle.EscapedPointer))}`;
         output += ']]>';
         output += `</failure>`;
         output += '</testcase>\n';
diff --git a/src/functions/falsy.ts b/src/functions/falsy.ts
index 2d7febc2..30c50d5f 100644
--- a/src/functions/falsy.ts
+++ b/src/functions/falsy.ts
@@ -1,10 +1,10 @@
 import { IFunction, IFunctionResult } from '../types';
 
-export const falsy: IFunction = (targetVal, _opts, paths): void | IFunctionResult[] => {
+export const falsy: IFunction = (targetVal): void | IFunctionResult[] => {
   if (!!targetVal) {
     return [
       {
-        message: `${paths.target ? paths.target.join('.') : 'property'} is not falsy`,
+        message: '{{property|gravis|append-property}}is not falsy',
       },
     ];
   }
diff --git a/src/functions/schema-path.ts b/src/functions/schema-path.ts
index ae11900a..e8843166 100644
--- a/src/functions/schema-path.ts
+++ b/src/functions/schema-path.ts
@@ -21,9 +21,6 @@ export const schemaPath: IFunction<ISchemaPathOptions> = (targetVal, opts, paths
   const relevantObject = opts.field ? object[opts.field] : object;
   if (!relevantObject) return [];
   const { target, given } = paths;
-  const lastItem = target
-    ? target.length > 0 && target[target.length - 1]
-    : given.length > 0 && given[given.length - 1];
 
   // The subsection of the targetValue which contains the schema for us to validate the good bit against
   let schemaObject;
@@ -40,15 +37,5 @@ export const schemaPath: IFunction<ISchemaPathOptions> = (targetVal, opts, paths
     }
   }
 
-  const errors = schema(relevantObject, { schema: schemaObject }, paths, otherValues);
-  return (
-    errors &&
-    errors.map(error => {
-      const propertyPath = [lastItem, opts.field].filter(Boolean).join('.');
-      return {
-        ...error,
-        message: `${propertyPath ? `"${propertyPath}"` : ''} property ${error.message}`,
-      };
-    })
-  );
+  return schema(relevantObject, { schema: schemaObject }, paths, otherValues);
 };
diff --git a/src/functions/schema.ts b/src/functions/schema.ts
index 84ef6fc3..058702b2 100644
--- a/src/functions/schema.ts
+++ b/src/functions/schema.ts
@@ -77,10 +77,31 @@ const validators = new (class extends WeakMap<JSONSchema, ValidateFunction> {
   }
 })();
 
-const cleanAJVErrorMessage = (message: string, path: Optional<string>, suggestion: Optional<string>) => {
-  const cleanMessage =
-    typeof path === 'string' ? message.trim().replace(new RegExp(`^${escapeRegExp(path)}:\\s*`), '') : message.trim();
-  return `${cleanMessage}${typeof suggestion === 'string' && suggestion.length > 0 ? `. ${suggestion}` : ''}`;
+const replaceProperty = (substring: string, group1: string) => {
+  if (group1) {
+    return 'property ';
+  }
+
+  return '{{property|gravis|append-property|optional-typeof}}';
+};
+
+const cleanAJVErrorMessage = (message: string, path: Optional<string>, suggestion: Optional<string>, type: string) => {
+  let cleanMessage = message.trim();
+
+  if (path) {
+    cleanMessage = message.replace(
+      new RegExp(`^${escapeRegExp(decodePointerFragment(path))}:?\\s*(Property\\s+)?`),
+      replaceProperty,
+    );
+  } else if (cleanMessage.startsWith(':')) {
+    cleanMessage = cleanMessage.replace(/:\s*/, replaceProperty);
+  } else {
+    cleanMessage = `${type} ${cleanMessage}`;
+  }
+
+  return `${cleanMessage.replace(/['"]/g, '`')}${
+    typeof suggestion === 'string' && suggestion.length > 0 ? `. ${suggestion}` : ''
+  }`;
 };
 
 export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
@@ -92,7 +113,7 @@ export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
     return [
       {
         path,
-        message: `${paths ? path.join('.') : 'property'} does not exist`,
+        message: `{{property|double-quotes|append-property}}does not exist`,
       },
     ];
 
@@ -107,7 +128,7 @@ export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
         results.push(
           ...(betterAjvErrors(schemaObj, targetVal, validator.errors, { format: 'js' }) as IAJVOutputError[]).map(
             ({ suggestion, error, path: errorPath }) => ({
-              message: cleanAJVErrorMessage(error, errorPath, suggestion),
+              message: cleanAJVErrorMessage(error, errorPath, suggestion, typeof targetVal),
               path: [...path, ...(errorPath ? errorPath.replace(/^\//, '').split('/') : [])],
             }),
           ),
@@ -115,7 +136,7 @@ export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
       } catch {
         results.push(
           ...validator.errors.map(({ message, dataPath }) => ({
-            message: message ? cleanAJVErrorMessage(message, dataPath, void 0) : '',
+            message: message ? cleanAJVErrorMessage(message, dataPath, void 0, typeof targetVal) : '',
             path: [
               ...path,
               ...dataPath
diff --git a/src/functions/truthy.ts b/src/functions/truthy.ts
index e7bae639..e3a2a024 100644
--- a/src/functions/truthy.ts
+++ b/src/functions/truthy.ts
@@ -1,10 +1,10 @@
 import { IFunction, IFunctionResult } from '../types';
 
-export const truthy: IFunction = (targetVal, _opts, paths): void | IFunctionResult[] => {
+export const truthy: IFunction = (targetVal): void | IFunctionResult[] => {
   if (!targetVal) {
     return [
       {
-        message: `${paths.target ? paths.target.join('.') : 'property'} is not truthy`,
+        message: '{{property|gravis|append-property}}is not truthy',
       },
     ];
   }
diff --git a/src/functions/undefined.ts b/src/functions/undefined.ts
index d2352268..e6f5e869 100644
--- a/src/functions/undefined.ts
+++ b/src/functions/undefined.ts
@@ -1,10 +1,10 @@
 import { IFunction, IFunctionResult } from '../types';
 
-export const undefined: IFunction = (targetVal, _opts, paths): void | IFunctionResult[] => {
+export const undefined: IFunction = (targetVal): void | IFunctionResult[] => {
   if (typeof targetVal !== 'undefined') {
     return [
       {
-        message: `${paths.target ? paths.target.join('.') : 'property'} should be undefined`,
+        message: '{{property|gravis|append-property}}should be undefined',
       },
     ];
   }
diff --git a/src/linter.ts b/src/linter.ts
index 8a5e8708..899b1f7b 100644
--- a/src/linter.ts
+++ b/src/linter.ts
@@ -1,13 +1,12 @@
-import { get, isObject } from 'lodash';
-
-const { JSONPath } = require('jsonpath-plus');
-
-import { decodePointerFragment, pathToPointer } from '@stoplight/json';
+import { decodePointerFragment } from '@stoplight/json';
+import { get } from 'lodash';
 import { getDefaultRange, Resolved } from './resolved';
 import { IMessageVars, message } from './rulesets/message';
 import { getDiagnosticSeverity } from './rulesets/severity';
 import { IFunction, IGivenNode, IRuleResult, IRunRule, IThen } from './types';
-import { getClosestJsonPath } from './utils';
+import { getClosestJsonPath, printPath, PrintStyle } from './utils';
+
+const { JSONPath } = require('jsonpath-plus');
 
 // TODO(SO-23): unit test but mock whatShouldBeLinted
 export const lintNode = (
@@ -94,21 +93,19 @@ export const lintNode = (
         const path = parsed?.path || getClosestJsonPath(resolved.resolved, escapedJsonPath);
         const doc = parsed?.doc || resolved.parsed;
         const range = doc.getLocationForJsonPath(doc.parsed, path, true)?.range || getDefaultRange();
+        const value = path.length === 0 ? parsed?.doc.parsed.data : get(parsed?.doc.parsed.data, path);
 
         const vars: IMessageVars = {
-          property: path.length > 0 ? path[path.length - 1] : '',
-          path: pathToPointer(path),
+          property:
+            parsed?.missingPropertyPath && parsed.missingPropertyPath.length > path.length
+              ? printPath(parsed.missingPropertyPath.slice(path.length - 1), PrintStyle.Dot)
+              : path.length > 0
+              ? path[path.length - 1]
+              : '',
           error: result.message,
+          path: printPath(path, PrintStyle.EscapedPointer),
           description: rule.description,
-          get value() {
-            // let's make `value` lazy
-            const value = get(parsed?.doc.parsed.data, path);
-            if (isObject(value)) {
-              return Array.isArray(value) ? 'Array[]' : 'Object{}';
-            }
-
-            return JSON.stringify(value);
-          },
+          value,
         };
 
         const resultMessage = message(result.message, vars);
@@ -116,7 +113,7 @@ export const lintNode = (
 
         return {
           code: rule.name,
-          message: rule.message === void 0 ? rule.description || resultMessage : message(rule.message, vars),
+          message: (rule.message === void 0 ? rule.description ?? resultMessage : message(rule.message, vars)).trim(),
           path,
           severity: getDiagnosticSeverity(rule.severity),
           source: parsed?.doc.source,
diff --git a/src/rulesets/message.ts b/src/rulesets/message.ts
index 6475bf77..c0937532 100644
--- a/src/rulesets/message.ts
+++ b/src/rulesets/message.ts
@@ -1,4 +1,6 @@
 import { Segment } from '@stoplight/types';
+import { isObject } from 'lodash';
+import { Replacer } from '../utils/replacer';
 
 export interface IMessageVars {
   property: Segment;
@@ -6,23 +8,27 @@ export interface IMessageVars {
   description?: string;
   value: unknown;
   path: string;
-  fullPath?: string;
 }
 
 export type MessageInterpolator = (str: string, values: IMessageVars) => string;
 
-const BRACES = /{{([^}]+)}}/g;
+const MessageReplacer = new Replacer<IMessageVars>(2);
 
-export const message: MessageInterpolator = (str, values) => {
-  BRACES.lastIndex = 0;
-  let result: RegExpExecArray | null = null;
+MessageReplacer.addTransformer('double-quotes', (id, value) => (value ? `"${value}"` : ''));
+MessageReplacer.addTransformer('single-quotes', (id, value) => (value ? `'${value}'` : ''));
+MessageReplacer.addTransformer('gravis', (id, value) => (value ? `\`${value}\`` : ''));
 
-  // tslint:disable-next-line:no-conditional-assignment
-  while ((result = BRACES.exec(str))) {
-    const newValue = result[1] in values ? String(values[result[1]]) : '';
-    str = `${str.slice(0, result.index)}${newValue}${str.slice(BRACES.lastIndex)}`;
-    BRACES.lastIndex = result.index + newValue.length;
+MessageReplacer.addTransformer('append-property', (id, value) => (value ? `${value} property ` : ''));
+MessageReplacer.addTransformer('optional-typeof', (id, value, values) =>
+  value ? String(value) : `${typeof values.value} `,
+);
+
+MessageReplacer.addTransformer('to-string', (id, value) => {
+  if (isObject(value)) {
+    return Array.isArray(value) ? 'Array[]' : 'Object{}';
   }
 
-  return str;
-};
+  return JSON.stringify(value);
+});
+
+export const message: MessageInterpolator = MessageReplacer.print.bind(MessageReplacer);
diff --git a/src/utils/index.ts b/src/utils/index.ts
index db6cc12f..5bc65387 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -3,4 +3,5 @@ export * from './hasIntersectingElement';
 export * from './isObject';
 export * from './refs';
 export * from './prepareResults';
+export * from './printPath';
 export * from './sortResults';
diff --git a/src/utils/printPath.ts b/src/utils/printPath.ts
new file mode 100644
index 00000000..884ab968
--- /dev/null
+++ b/src/utils/printPath.ts
@@ -0,0 +1,56 @@
+import { decodePointerFragment, pathToPointer } from '@stoplight/json';
+import { JsonPath, Segment } from '@stoplight/types';
+
+export enum PrintStyle {
+  Dot = 'dot',
+  Pointer = 'pointer',
+  EscapedPointer = 'escapedPointer',
+}
+
+const isNumeric = (input: Segment) => typeof input === 'number' || !Number.isNaN(Number(input));
+const hasWhitespace = (input: string) => /\s/.test(input);
+const safeDecodePointerFragment = (segment: Segment) =>
+  typeof segment === 'number' ? segment : decodePointerFragment(segment);
+
+const printDotBracketsSegment = (segment: Segment) => {
+  if (typeof segment === 'number') {
+    return `[${segment}]`;
+  }
+
+  if (segment.length === 0) {
+    return `['']`;
+  }
+
+  if (hasWhitespace(segment)) {
+    return `['${segment}']`;
+  }
+
+  if (isNumeric(segment)) {
+    return `[${segment}]`;
+  }
+
+  return null;
+};
+
+const pathToDotString = (path: JsonPath) =>
+  path.reduce<string>(
+    (output, segment, index) => `${output}${printDotBracketsSegment(segment) ?? `${index === 0 ? '' : '.'}${segment}`}`,
+    '',
+  );
+
+export const printPath = (path: JsonPath, style: PrintStyle) => {
+  switch (style) {
+    case PrintStyle.Dot:
+      return decodePointerFragment(pathToDotString(path));
+    case PrintStyle.Pointer:
+      if (path.length === 0) {
+        return '#';
+      }
+
+      return `#/${decodePointerFragment(path.join('/'))}`;
+    case PrintStyle.EscapedPointer:
+      return pathToPointer(path.map(safeDecodePointerFragment));
+    default:
+      return String(path);
+  }
+};
diff --git a/src/utils/replacer.ts b/src/utils/replacer.ts
new file mode 100644
index 00000000..077dbced
--- /dev/null
+++ b/src/utils/replacer.ts
@@ -0,0 +1,36 @@
+import { Dictionary } from '@stoplight/types';
+
+export type Transformer<V = unknown, VV = object> = (identifier: string, value: V, values: VV) => string;
+
+export class Replacer<V extends object> {
+  protected readonly regex: RegExp;
+  protected readonly transformers: Dictionary<Transformer<V[keyof V], V>>;
+
+  constructor(count: number) {
+    this.regex = new RegExp(`${'{'.repeat(count)}([^}\n]+)${'}'.repeat(count)}`, 'g');
+
+    this.transformers = {};
+  }
+
+  public addTransformer(name: string, filter: Transformer<V[keyof V], V>) {
+    this.transformers[name] = filter;
+  }
+
+  public print(input: string, values: V) {
+    return input.replace(this.regex, (substr, expr) => {
+      const [identifier, ...transformers] = expr.split('|');
+
+      if (!(identifier in values)) {
+        return '';
+      }
+
+      for (const transformer of transformers) {
+        if (transformer in this.transformers) {
+          values[identifier] = this.transformers[transformer](identifier, values[identifier], values);
+        }
+      }
+
+      return String(values[identifier]);
+    });
+  }
+}


Test patch:
diff --git a/src/__tests__/linter.jest.test.ts b/src/__tests__/linter.jest.test.ts
index e0cd212c..b7d9d088 100644
--- a/src/__tests__/linter.jest.test.ts
+++ b/src/__tests__/linter.jest.test.ts
@@ -84,7 +84,7 @@ describe('Linter', () => {
           severity: DiagnosticSeverity.Error,
           recommended: true,
           description: 'Should be falsy',
-          message: 'Value {{value}} should be falsy',
+          message: 'Value {{value|to-string}} should be falsy',
           given: '$..empty',
           then: {
             function: 'falsy',
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index 5ac5ed73..d3111c51 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -163,7 +163,7 @@ describe('linter', () => {
     expect(result).toEqual([
       expect.objectContaining({
         code: 'oas3-schema',
-        message: "/paths//pets/get/responses/200 should have required property '$ref'",
+        message: `\`200\` property should have required property \`$ref\``,
         path: ['paths', '/pets', 'get', 'responses', '200'],
       }),
       expect.objectContaining({
@@ -194,7 +194,7 @@ describe('linter', () => {
       expect.arrayContaining([
         expect.objectContaining({
           code: 'oas3-schema',
-          message: "/paths//pets/get/responses/200 should have required property '$ref'",
+          message: `\`200\` property should have required property \`$ref\``,
           path: ['paths', '/pets', 'get', 'responses', '200'],
         }),
       ]),
@@ -621,7 +621,7 @@ responses:: !!foo
       }),
       expect.objectContaining({
         code: 'oas3-schema',
-        message: "/paths//pets/get/responses/200 should have required property '$ref'",
+        message: `\`200\` property should have required property \`$ref\``,
         path: ['paths', '/pets', 'get', 'responses', '200'],
       }),
       expect.objectContaining({
@@ -642,7 +642,7 @@ responses:: !!foo
       }),
       expect.objectContaining({
         code: 'oas3-valid-schema-example',
-        message: '"foo.example" property type should be number',
+        message: '`example` property type should be number',
         path: ['components', 'schemas', 'foo', 'example'],
       }),
     ]);
@@ -659,7 +659,7 @@ responses:: !!foo
       expect.arrayContaining([
         expect.objectContaining({
           code: 'oas2-valid-parameter-example',
-          message: '"schema.example" property can\'t resolve reference #/parameters/missing from id #',
+          message: "can't resolve reference #/parameters/missing from id #",
           path: ['paths', '/todos/{todoId}', 'put', 'parameters', '1', 'schema', 'example'],
         }),
       ]),
@@ -931,7 +931,7 @@ responses:: !!foo
           severity: DiagnosticSeverity.Error,
           recommended: true,
           description: 'A parameter in the header should be written in kebab-case',
-          message: '{{value}} is not kebab-cased: {{error}}',
+          message: '{{value|to-string}} is not kebab-cased: {{error}}',
           given: "$..parameters[?(@.in === 'header')]",
           then: {
             field: 'name',
@@ -982,7 +982,7 @@ responses:: !!foo
           severity: DiagnosticSeverity.Error,
           recommended: true,
           description: 'Should be falsy',
-          message: 'Value {{value}} should be falsy',
+          message: 'Value {{value|to-string}} should be falsy',
           given: '$..empty',
           then: {
             function: 'falsy',
diff --git a/src/cli/services/__tests__/linter.test.ts b/src/cli/services/__tests__/linter.test.ts
index 332f55c6..bc978e5b 100644
--- a/src/cli/services/__tests__/linter.test.ts
+++ b/src/cli/services/__tests__/linter.test.ts
@@ -574,7 +574,7 @@ describe('Linter service', () => {
         }),
         expect.objectContaining({
           code: 'oas2-schema',
-          message: '/info Property foo is not expected to be here',
+          message: 'property foo is not expected to be here',
           path: ['definitions', 'info'],
           range: {
             end: {
@@ -625,7 +625,7 @@ describe('Linter service', () => {
         }),
         expect.objectContaining({
           code: 'oas2-schema',
-          message: "/info should have required property 'title'",
+          message: `object should have required property \`title\``,
           path: [],
           range: {
             end: {
diff --git a/src/functions/__tests__/falsy.test.ts b/src/functions/__tests__/falsy.test.ts
index 8959d65f..9dbbd00d 100644
--- a/src/functions/__tests__/falsy.test.ts
+++ b/src/functions/__tests__/falsy.test.ts
@@ -27,7 +27,7 @@ describe('falsy', () => {
   test('returns error message if target value is not falsy', () => {
     expect(runFalsy(true)).toEqual([
       {
-        message: 'property is not falsy',
+        message: '{{property|gravis|append-property}}is not falsy',
       },
     ]);
   });
diff --git a/src/functions/__tests__/schema-path.test.ts b/src/functions/__tests__/schema-path.test.ts
index d165b972..2e3cf5a3 100644
--- a/src/functions/__tests__/schema-path.test.ts
+++ b/src/functions/__tests__/schema-path.test.ts
@@ -60,7 +60,7 @@ describe('schema', () => {
     expect(runSchemaPath(target, fieldToCheck, path)).toEqual([
       {
         path: ['example'],
-        message: `"example" property should have required property 'url'`,
+        message: 'object should have required property `url`',
       },
     ]);
   });
@@ -76,7 +76,7 @@ describe('schema', () => {
 
     expect(runSchemaPath(target, fieldToCheck, path)).toEqual([
       {
-        message: '"example" property format should match format "url"',
+        message: '{{property|gravis|append-property|optional-typeof}}format should match format `url`',
         path: ['example'],
       },
     ]);
diff --git a/src/functions/__tests__/schema.test.ts b/src/functions/__tests__/schema.test.ts
index 948a96b9..54b128c1 100644
--- a/src/functions/__tests__/schema.test.ts
+++ b/src/functions/__tests__/schema.test.ts
@@ -14,7 +14,7 @@ describe('schema', () => {
 
       expect(runSchema('', testSchema)).toEqual([
         {
-          message: 'type should be number',
+          message: '{{property|gravis|append-property|optional-typeof}}type should be number',
           path: [],
         },
       ]);
@@ -27,7 +27,7 @@ describe('schema', () => {
 
       expect(runSchema(0, testSchema)).toEqual([
         {
-          message: 'type should be string',
+          message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
           path: [],
         },
       ]);
@@ -40,7 +40,7 @@ describe('schema', () => {
 
       expect(runSchema(false, testSchema)).toEqual([
         {
-          message: 'type should be string',
+          message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
           path: [],
         },
       ]);
@@ -53,7 +53,7 @@ describe('schema', () => {
 
       expect(runSchema(null, testSchema)).toEqual([
         {
-          message: 'type should be string',
+          message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
           path: [],
         },
       ]);
@@ -94,7 +94,7 @@ describe('schema', () => {
       const input = { foo: 'bar' };
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
-          message: 'type should be array',
+          message: '{{property|gravis|append-property|optional-typeof}}type should be array',
           path: [],
         }),
       ]);
@@ -104,7 +104,7 @@ describe('schema', () => {
       const input = ['1', '2'];
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
-          message: 'maxItems should NOT have more than 1 items',
+          message: '{{property|gravis|append-property|optional-typeof}}maxItems should NOT have more than 1 items',
           path: [],
         }),
       ]);
@@ -140,7 +140,7 @@ describe('schema', () => {
         ),
       ).toEqual([
         {
-          message: 'type should be string',
+          message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
           path: ['foo', 'bar'],
         },
       ]);
@@ -157,7 +157,7 @@ describe('schema', () => {
         ),
       ).toEqual([
         {
-          message: '/foo Property baz is not expected to be here',
+          message: 'property baz is not expected to be here',
           path: ['foo'],
         },
       ]);
@@ -174,7 +174,7 @@ describe('schema', () => {
       const input = 'not an email';
       expect(runSchema(input, testSchema)).toEqual([
         expect.objectContaining({
-          message: 'format should match format "email"',
+          message: '{{property|gravis|append-property|optional-typeof}}format should match format `email`',
           path: [],
         }),
       ]);
@@ -212,7 +212,7 @@ describe('schema', () => {
     expect(runSchema(2, testSchema)).toEqual([
       {
         path: [],
-        message: 'type should be string',
+        message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
       },
     ]);
     expect(runSchema('a', testSchema2)).toEqual([]);
@@ -232,7 +232,7 @@ describe('schema', () => {
     expect(runSchema(2, testSchema)).toEqual([
       {
         path: [],
-        message: 'type should be string',
+        message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
       },
     ]);
     expect(runSchema('a', testSchema2)).toEqual([]);
@@ -258,7 +258,7 @@ describe('schema', () => {
       it('reports pretty enum errors for a string', () => {
         expect(runSchema('baz', testSchema)).toEqual([
           {
-            message: 'should be equal to one of the allowed values: foo, bar. Did you mean bar?',
+            message: `string should be equal to one of the allowed values: foo, bar. Did you mean bar?`,
             path: [],
           },
         ]);
@@ -267,7 +267,7 @@ describe('schema', () => {
       it('reports pretty enum errors for a number', () => {
         expect(runSchema(2, testSchema)).toEqual([
           {
-            message: 'type should be string',
+            message: `{{property|gravis|append-property|optional-typeof}}type should be string`,
             path: [],
           },
         ]);
@@ -284,7 +284,7 @@ describe('schema', () => {
       it('reports pretty enum errors for a string', () => {
         expect(runSchema('baz', testSchema)).toEqual([
           {
-            message: 'type should be integer',
+            message: '{{property|gravis|append-property|optional-typeof}}type should be integer',
             path: [],
           },
         ]);
@@ -293,7 +293,7 @@ describe('schema', () => {
       it('reports pretty enum errors for a number', () => {
         expect(runSchema(2, testSchema)).toEqual([
           {
-            message: 'should be equal to one of the allowed values: 1, 3, 5, 10, 12',
+            message: `number should be equal to one of the allowed values: 1, 3, 5, 10, 12`,
             path: [],
           },
         ]);
@@ -310,7 +310,7 @@ describe('schema', () => {
 
     expect(runSchema('three', testSchema)).toEqual([
       {
-        message: 'should be equal to one of the allowed values: foo, bar',
+        message: `string should be equal to one of the allowed values: foo, bar`,
         path: [],
       },
     ]);
diff --git a/src/functions/__tests__/truthy.test.ts b/src/functions/__tests__/truthy.test.ts
index 373a0024..f31800e2 100644
--- a/src/functions/__tests__/truthy.test.ts
+++ b/src/functions/__tests__/truthy.test.ts
@@ -23,7 +23,7 @@ describe('truthy', () => {
   test('should return an error message if target value is falsy', () => {
     expect(runTruthy(false)).toEqual([
       {
-        message: 'property is not truthy',
+        message: '{{property|gravis|append-property}}is not truthy',
       },
     ]);
   });
@@ -31,15 +31,7 @@ describe('truthy', () => {
   test('should return an error message if target value is null', () => {
     expect(runTruthy(null)).toEqual([
       {
-        message: 'property is not truthy',
-      },
-    ]);
-  });
-
-  test('should return a detailed error message if target path is set', () => {
-    expect(runTruthy(null, ['a', 'b'])).toEqual([
-      {
-        message: 'a.b is not truthy',
+        message: '{{property|gravis|append-property}}is not truthy',
       },
     ]);
   });
diff --git a/src/rulesets/__tests__/message.spec.ts b/src/rulesets/__tests__/message.spec.ts
index feee949a..0b5f4c07 100644
--- a/src/rulesets/__tests__/message.spec.ts
+++ b/src/rulesets/__tests__/message.spec.ts
@@ -8,7 +8,6 @@ describe('message util', () => {
         property: 'description',
         error: 'expected property to be truthy',
         path: '',
-        fullPath: '',
         value: void 0,
       }),
     ).toEqual('oops... "description" is missing;error: expected property to be truthy');
@@ -21,7 +20,6 @@ describe('message util', () => {
         property: 'description',
         error: 'expected property to be truthy',
         path: '',
-        fullPath: '',
         value,
       }),
     ).toEqual(`Value must not equal ${value}`);
@@ -34,7 +32,6 @@ describe('message util', () => {
         property: 'baz',
         error: 'foo',
         path: '',
-        fullPath: '',
         value: void 0,
       }),
     ).toEqual('foofoobazfoofoo');
@@ -47,7 +44,6 @@ describe('message util', () => {
         property: 'description',
         error: 'expected property to be truthy',
         path: '',
-        fullPath: '',
         value: void 0,
       }),
     ).toEqual('missing :(');
diff --git a/src/rulesets/oas/__tests__/oas2-schema.ts b/src/rulesets/oas/__tests__/oas2-schema.ts
index 5585cc77..12936ccb 100644
--- a/src/rulesets/oas/__tests__/oas2-schema.ts
+++ b/src/rulesets/oas/__tests__/oas2-schema.ts
@@ -35,7 +35,7 @@ describe('oas2-schema', () => {
     expect(results).toEqual([
       {
         code: 'oas2-schema',
-        message: "/paths//test/get should have required property 'responses'",
+        message: `\`get\` property should have required property \`responses\``,
         path: ['paths', '/test', 'get'],
         range: {
           end: {
diff --git a/src/rulesets/oas/__tests__/oas2-valid-definition-example.ts b/src/rulesets/oas/__tests__/oas2-valid-definition-example.ts
index fbea933c..bc18d965 100644
--- a/src/rulesets/oas/__tests__/oas2-valid-definition-example.ts
+++ b/src/rulesets/oas/__tests__/oas2-valid-definition-example.ts
@@ -40,7 +40,7 @@ describe('oas2-valid-definition-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas2-valid-definition-example',
-        message: '"xoxo.example" property type should be string',
+        message: `\`example\` property type should be string`,
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -104,7 +104,7 @@ describe('oas2-valid-definition-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas2-valid-definition-example',
-        message: '"xoxo.example" property should have required property \'url\'',
+        message: 'object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -181,7 +181,7 @@ describe('oas2-valid-definition-example', () => {
       expect.objectContaining({
         severity: DiagnosticSeverity.Error,
         code: 'oas2-valid-definition-example',
-        message: '"halRoot.example" property type should be array',
+        message: '`self` property type should be array',
         path: ['definitions', 'halRoot', 'example', '_links', 'self'],
       }),
     ]);
@@ -229,7 +229,7 @@ describe('oas2-valid-definition-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas2-valid-definition-example',
-        message: '"c.example" property type should be string',
+        message: '`example` property type should be string',
         path: ['definitions', '0', 'xoxo', 'properties', 'a', 'properties', 'b', 'properties', 'c', 'example'],
         range: expect.any(Object),
         severity: DiagnosticSeverity.Error,
diff --git a/src/rulesets/oas/__tests__/oas2-valid-parameter-example.ts b/src/rulesets/oas/__tests__/oas2-valid-parameter-example.ts
index 8302f5d2..e3cd24c8 100644
--- a/src/rulesets/oas/__tests__/oas2-valid-parameter-example.ts
+++ b/src/rulesets/oas/__tests__/oas2-valid-parameter-example.ts
@@ -57,7 +57,7 @@ describe('oas2-valid-parameter-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas2-valid-parameter-example',
-        message: '"schema.example" property type should be string',
+        message: '`example` property type should be string',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -127,7 +127,7 @@ describe('oas2-valid-parameter-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas2-valid-parameter-example',
-        message: '"schema.example" property should have required property \'url\'',
+        message: 'object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -194,7 +194,7 @@ describe('oas2-valid-parameter-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas2-valid-parameter-example',
-        message: '"c.example" property type should be string',
+        message: '`example` property type should be string',
         path: [
           'paths',
           '/pet',
diff --git a/src/rulesets/oas/__tests__/oas3-valid-oas-parameter-example.ts b/src/rulesets/oas/__tests__/oas3-valid-oas-parameter-example.ts
index 84064840..839896a3 100644
--- a/src/rulesets/oas/__tests__/oas3-valid-oas-parameter-example.ts
+++ b/src/rulesets/oas/__tests__/oas3-valid-oas-parameter-example.ts
@@ -41,7 +41,7 @@ describe('oas3-valid-oas-parameter-example', () => {
       expect.objectContaining({
         severity: DiagnosticSeverity.Error,
         code: 'oas3-valid-oas-parameter-example',
-        message: '"0.example" property type should be string',
+        message: '`example` property type should be string',
       }),
     ]);
   });
@@ -119,7 +119,7 @@ describe('oas3-valid-oas-parameter-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-oas-parameter-example',
-        message: '"Abc.example" property should have required property \'abc\'',
+        message: 'object should have required property `abc`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -156,7 +156,7 @@ describe('oas3-valid-oas-parameter-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-oas-parameter-example',
-        message: '"0.example" property should have required property \'url\'',
+        message: 'object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/oas3-valid-parameter-schema-example.ts b/src/rulesets/oas/__tests__/oas3-valid-parameter-schema-example.ts
index 566e0619..8067d9b9 100644
--- a/src/rulesets/oas/__tests__/oas3-valid-parameter-schema-example.ts
+++ b/src/rulesets/oas/__tests__/oas3-valid-parameter-schema-example.ts
@@ -87,7 +87,7 @@ describe('oas3-valid-parameter-schema-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-parameter-schema-example',
-        message: '"c.example" property type should be string',
+        message: '`example` property type should be string',
         path: [
           'paths',
           '/pet',
@@ -169,7 +169,7 @@ describe('oas3-valid-parameter-schema-example', () => {
       expect.objectContaining({
         severity: DiagnosticSeverity.Error,
         code: 'oas3-valid-parameter-schema-example',
-        message: '"schema.example" property type should be string',
+        message: '`example` property type should be string',
       }),
     ]);
   });
@@ -247,7 +247,7 @@ describe('oas3-valid-parameter-schema-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-parameter-schema-example',
-        message: '"abc.example" property type should be number',
+        message: '`example` property type should be number',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -284,7 +284,7 @@ describe('oas3-valid-parameter-schema-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-parameter-schema-example',
-        message: '"schema.example" property should have required property \'url\'',
+        message: 'object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts b/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
index 8f9e9d6a..eff60701 100644
--- a/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
+++ b/src/rulesets/oas/__tests__/oas3-valid-schema-example.ts
@@ -43,7 +43,7 @@ describe('oas3-valid-schema-example', () => {
       expect.objectContaining({
         severity: DiagnosticSeverity.Error,
         code: 'oas3-valid-schema-example',
-        message: '"xoxo.example" property type should be string',
+        message: '`example` property type should be string',
       }),
     ]);
   });
@@ -97,7 +97,7 @@ describe('oas3-valid-schema-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-schema-example',
-        message: '"c.example" property type should be string',
+        message: '`example` property type should be string',
         path: [
           'components',
           'schemas',
@@ -188,7 +188,7 @@ describe('oas3-valid-schema-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-schema-example',
-        message: '"abc.example" property type should be number',
+        message: '`example` property type should be number',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -226,7 +226,7 @@ describe('oas3-valid-schema-example', () => {
     expect(results).toEqual([
       expect.objectContaining({
         code: 'oas3-valid-schema-example',
-        message: '"xoxo.example" property should have required property \'url\'',
+        message: 'object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
diff --git a/src/rulesets/oas/__tests__/templates/_oas-example.ts b/src/rulesets/oas/__tests__/templates/_oas-example.ts
index 81c14082..fd4b593e 100644
--- a/src/rulesets/oas/__tests__/templates/_oas-example.ts
+++ b/src/rulesets/oas/__tests__/templates/_oas-example.ts
@@ -43,7 +43,7 @@ export default (ruleName: string, path: string) => {
       expect.objectContaining({
         severity: DiagnosticSeverity.Error,
         code: ruleName,
-        message: '"xoxo.example" property type should be string',
+        message: '`example` property type should be string',
       }),
     ]);
   });
@@ -158,7 +158,7 @@ export default (ruleName: string, path: string) => {
     expect(results).toEqual([
       expect.objectContaining({
         code: ruleName,
-        message: '"xoxo.example" property should have required property \'url\'',
+        message: 'object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -209,7 +209,7 @@ export default (ruleName: string, path: string) => {
       expect.objectContaining({
         severity: DiagnosticSeverity.Error,
         code: ruleName,
-        message: `"xoxo.example" property format should match format "${format}"`, // hm, ip_address is likely to be more meaningful no?
+        message: `\`example\` property format should match format \`${format}\``,
       }),
     ]);
   });
diff --git a/src/rulesets/oas/__tests__/templates/_schema-example.ts b/src/rulesets/oas/__tests__/templates/_schema-example.ts
index 7672bcc5..2f7d698b 100644
--- a/src/rulesets/oas/__tests__/templates/_schema-example.ts
+++ b/src/rulesets/oas/__tests__/templates/_schema-example.ts
@@ -43,7 +43,7 @@ export default (ruleName: string, path: string) => {
       expect.objectContaining({
         severity: DiagnosticSeverity.Error,
         code: ruleName,
-        message: '"schema.example" property type should be string',
+        message: '`example` property type should be string',
       }),
     ]);
   });
@@ -93,7 +93,7 @@ export default (ruleName: string, path: string) => {
     expect(results).toEqual([
       expect.objectContaining({
         code: ruleName,
-        message: '"c.example" property type should be string',
+        message: '`example` property type should be string',
         path: [path, 'xoxo', '0', 'schema', 'properties', 'a', 'properties', 'b', 'properties', 'c', 'example'],
         range: expect.any(Object),
         severity: DiagnosticSeverity.Error,
@@ -174,7 +174,7 @@ export default (ruleName: string, path: string) => {
     expect(results).toEqual([
       expect.objectContaining({
         code: ruleName,
-        message: '"abc.example" property type should be number',
+        message: '`example` property type should be number',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -212,7 +212,7 @@ export default (ruleName: string, path: string) => {
     expect(results).toEqual([
       expect.objectContaining({
         code: ruleName,
-        message: '"schema.example" property should have required property \'url\'',
+        message: 'object should have required property `url`',
         severity: DiagnosticSeverity.Error,
       }),
     ]);
@@ -268,7 +268,7 @@ export default (ruleName: string, path: string) => {
       expect.objectContaining({
         severity: DiagnosticSeverity.Error,
         code: ruleName,
-        message: `"ip_address.example" property format should match format "${format}"`, // hm, ip_address is likely to be more meaningful no?
+        message: `\`example\` property format should match format \`${format}\``,
       }),
     ]);
   });
diff --git a/src/utils/__tests__/printPath.spec.ts b/src/utils/__tests__/printPath.spec.ts
new file mode 100644
index 00000000..d7e018d0
--- /dev/null
+++ b/src/utils/__tests__/printPath.spec.ts
@@ -0,0 +1,106 @@
+import { printPath, PrintStyle } from '../printPath';
+
+describe('printPath util', () => {
+  describe('dot style', () => {
+    it('handles basic scenarios', () => {
+      expect(printPath([], PrintStyle.Dot)).toEqual('');
+      expect(printPath(['user'], PrintStyle.Dot)).toEqual('user');
+      expect(printPath(['user', 'age'], PrintStyle.Dot)).toEqual('user.age');
+    });
+
+    it('handles numeric segments', () => {
+      expect(printPath(['schema', 'addresses', 0, 'street'], PrintStyle.Dot)).toEqual('schema.addresses[0].street');
+      expect(printPath(['foo', '0', 1.2, 4], PrintStyle.Dot)).toEqual('foo[0][1.2][4]');
+    });
+
+    it('handles empty strings', () => {
+      expect(printPath([''], PrintStyle.Dot)).toEqual("['']");
+      expect(printPath(['a', '', 'test'], PrintStyle.Dot)).toEqual("a[''].test");
+    });
+
+    it('handles whitespaces', () => {
+      expect(printPath(['a', ' bar ', 'test'], PrintStyle.Dot)).toEqual("a[' bar '].test");
+      expect(printPath(['a', ' ', 'test'], PrintStyle.Dot)).toEqual("a[' '].test");
+    });
+
+    it('decodes ~0 and ~1', () => {
+      expect(printPath(['paths', '~1pets', 'wildcard*~0'], PrintStyle.Dot)).toEqual('paths./pets.wildcard*~');
+    });
+  });
+
+  describe('pointer style', () => {
+    it('handles basic scenarios', () => {
+      expect(printPath([], PrintStyle.Pointer)).toEqual('#');
+      expect(printPath(['user'], PrintStyle.Pointer)).toEqual('#/user');
+      expect(printPath(['user', 'age'], PrintStyle.Pointer)).toEqual('#/user/age');
+    });
+
+    it('handles numeric segments', () => {
+      expect(printPath(['schema', 'addresses', 0, 'street'], PrintStyle.Pointer)).toEqual(
+        '#/schema/addresses/0/street',
+      );
+      expect(printPath(['foo', '0', 1.2, 4], PrintStyle.Pointer)).toEqual('#/foo/0/1.2/4');
+    });
+
+    it('handles empty strings', () => {
+      expect(printPath([''], PrintStyle.Pointer)).toEqual('#/');
+      expect(printPath(['a', '', 'test'], PrintStyle.Pointer)).toEqual('#/a//test');
+    });
+
+    it('handles whitespaces', () => {
+      expect(printPath(['a', ' bar ', 'test'], PrintStyle.Pointer)).toEqual('#/a/ bar /test');
+      expect(printPath(['a', ' ', 'test'], PrintStyle.Pointer)).toEqual('#/a/ /test');
+    });
+
+    it('preserves slashes', () => {
+      expect(printPath(['paths', '/pets'], PrintStyle.Pointer)).toEqual('#/paths//pets');
+    });
+
+    it('preserves tildes', () => {
+      expect(printPath(['paths', 'wildcard~'], PrintStyle.Pointer)).toEqual('#/paths/wildcard~');
+    });
+
+    it('decodes ~0 and ~1', () => {
+      expect(printPath(['paths', '~1pets', 'wildcard*~0'], PrintStyle.Pointer)).toEqual('#/paths//pets/wildcard*~');
+    });
+  });
+
+  describe('escaped pointer style', () => {
+    it('handles basic scenarios', () => {
+      expect(printPath([], PrintStyle.EscapedPointer)).toEqual('#');
+      expect(printPath(['user'], PrintStyle.EscapedPointer)).toEqual('#/user');
+      expect(printPath(['user', 'age'], PrintStyle.EscapedPointer)).toEqual('#/user/age');
+    });
+
+    it('handles numeric segments', () => {
+      expect(printPath(['schema', 'addresses', 0, 'street'], PrintStyle.EscapedPointer)).toEqual(
+        '#/schema/addresses/0/street',
+      );
+      expect(printPath(['foo', '0', 1.2, 4], PrintStyle.EscapedPointer)).toEqual('#/foo/0/1.2/4');
+    });
+
+    it('handles empty strings', () => {
+      expect(printPath([''], PrintStyle.EscapedPointer)).toEqual('#/');
+      expect(printPath(['a', '', 'test'], PrintStyle.EscapedPointer)).toEqual('#/a//test');
+    });
+
+    it('handles whitespaces', () => {
+      expect(printPath(['a', ' bar ', 'test'], PrintStyle.EscapedPointer)).toEqual('#/a/ bar /test');
+      expect(printPath(['a', ' ', 'test'], PrintStyle.EscapedPointer)).toEqual('#/a/ /test');
+    });
+
+    it('escapes slashes', () => {
+      expect(printPath(['paths', '/pets'], PrintStyle.EscapedPointer)).toEqual('#/paths/~1pets');
+    });
+
+    it('escapes tildes', () => {
+      expect(printPath(['paths', 'wildcard~'], PrintStyle.EscapedPointer)).toEqual('#/paths/wildcard~0');
+    });
+
+    it('preserves ~0 and ~1', () => {
+      expect(printPath(['paths', '~1pets', 'wildcard*~0'], PrintStyle.EscapedPointer)).toEqual(
+        '#/paths/~1pets/wildcard*~0',
+      );
+    });
+  });
+});
diff --git a/src/utils/__tests__/replacer.spec.ts b/src/utils/__tests__/replacer.spec.ts
new file mode 100644
index 00000000..a2754629
--- /dev/null
+++ b/src/utils/__tests__/replacer.spec.ts
@@ -0,0 +1,68 @@
+import { Dictionary } from '@stoplight/types';
+import { Replacer } from '../replacer';
+
+describe('Replacer', () => {
+  it('interpolates correctly', () => {
+    const replacer = new Replacer<Dictionary<unknown>>(2);
+    const template = 'oops... "{{property}}" is missing;error: {{error}}';
+    expect(
+      replacer.print(template, {
+        property: 'description',
+        error: 'expected property to be truthy',
+      }),
+    ).toEqual('oops... "description" is missing;error: expected property to be truthy');
+  });
+
+  it.each([0, false, null, undefined])('interpolates %s value correctly', value => {
+    const replacer = new Replacer<Dictionary<unknown>>(2);
+    const template = 'Value must not equal {{value}}';
+    expect(
+      replacer.print(template, {
+        value,
+      }),
+    ).toEqual(`Value must not equal ${value}`);
+  });
+
+  it('handles siblings', () => {
+    const replacer = new Replacer<Dictionary<unknown>>(2);
+    const template = '{{error}}{{error}}{{property}}{{bar}}{{error}}{{error}}';
+    expect(
+      replacer.print(template, {
+        property: 'baz',
+        error: 'foo',
+        path: '',
+        value: void 0,
+      }),
+    ).toEqual('foofoobazfoofoo');
+  });
+
+  it('ignores new lines', () => {
+    const replacer = new Replacer<Dictionary<unknown>>(2);
+    const template = '{{\ntest}}';
+    expect(replacer.print(template, {})).toEqual(template);
+  });
+
+  it('strips missing keys', () => {
+    const replacer = new Replacer<Dictionary<unknown>>(2);
+    const template = '{{foo}}missing {{bar}}:(';
+    expect(
+      replacer.print(template, {
+        property: 'description',
+        error: 'expected property to be truthy',
+      }),
+    ).toEqual('missing :(');
+  });
+
+  it('supports transformers', () => {
+    const replacer = new Replacer<Dictionary<unknown>>(2);
+    replacer.addTransformer('dot', (id, value) => (Array.isArray(value) ? value.join('.') : String(value)));
+
+    const template = '{{path|dot}}';
+
+    expect(
+      replacer.print(template, {
+        path: ['foo', 'bar', '/a'],
+      }),
+    ).toEqual('foo.bar./a');
+  });
+});
diff --git a/test-harness/scenarios/external-schemas-ruleset.scenario b/test-harness/scenarios/external-schemas-ruleset.scenario
index badf2752..3aa363c0 100644
--- a/test-harness/scenarios/external-schemas-ruleset.scenario
+++ b/test-harness/scenarios/external-schemas-ruleset.scenario
@@ -11,7 +11,7 @@ info:
 OpenAPI 3.x detected
 
 {document}
- 3:10  error  info-title        should be equal to one of the allowed values: Stoplight, Stoplight.io, StoplightIO. Did you mean Stoplight?
- 4:16  error  info-description  should be equal to one of the allowed values: foo, foo-bar, bar-foo
+ 3:10  error  info-title        string should be equal to one of the allowed values: Stoplight, Stoplight.io, StoplightIO. Did you mean Stoplight?
+ 4:16  error  info-description  string should be equal to one of the allowed values: foo, foo-bar, bar-foo
 
 ✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/results-format-junit.oas3.scenario b/test-harness/scenarios/results-format-junit.oas3.scenario
index 08423d7f..94765faa 100644
--- a/test-harness/scenarios/results-format-junit.oas3.scenario
+++ b/test-harness/scenarios/results-format-junit.oas3.scenario
@@ -14,6 +14,6 @@ OpenAPI 3.x detected
 <?xml version="1.0" encoding="utf-8"?>
 <testsuites>
 <testsuite package="org.spectral" time="0" tests="1" errors="0" failures="1" name="{document}">
-<testcase time="0" name="org.spectral.oas3-schema" classname="{document|no-ext}"><failure message="/info Property foo is not expected to be here"><![CDATA[line 2, col 6, /info Property foo is not expected to be here (oas3-schema) at path #/info]]></failure></testcase>
+<testcase time="0" name="org.spectral.oas3-schema" classname="{document|no-ext}"><failure message="property foo is not expected to be here"><![CDATA[line 2, col 6, property foo is not expected to be here (oas3-schema) at path #/info]]></failure></testcase>
 </testsuite>
 </testsuites>
diff --git a/test-harness/scenarios/rules-matching-multiple-places.scenario b/test-harness/scenarios/rules-matching-multiple-places.scenario
index 9c7edd7d..6d26fe3c 100644
--- a/test-harness/scenarios/rules-matching-multiple-places.scenario
+++ b/test-harness/scenarios/rules-matching-multiple-places.scenario
@@ -36,6 +36,6 @@ rules:
   1:1   warning  unrecognized-format       The provided document does not match any of the registered formats [oas2, oas3, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09]
   6:15    error  valid-user-properties     must match the pattern '/^string$/'
  10:15    error  valid-user-properties     must match the pattern '/^string$/'
- 11:11    error  require-user-and-address  schemas.address is not truthy
+ 11:11    error  require-user-and-address  `address` property is not truthy
 
 ✖ 4 problems (3 errors, 1 warning, 0 infos, 0 hints)